diff --git a/src/api/capacity/services/capacity/workdays/lifecycle/living-workday-lifecycle.ts b/src/api/capacity/services/capacity/workdays/lifecycle/living-workday-lifecycle.ts index c4d6f89c..85edd628 100644 --- a/src/api/capacity/services/capacity/workdays/lifecycle/living-workday-lifecycle.ts +++ b/src/api/capacity/services/capacity/workdays/lifecycle/living-workday-lifecycle.ts @@ -54,10 +54,11 @@ export async function advanceLivingWorkday(store: CapacityGovernanceDatabase & { const reports = nodeRows.filter((row) => row.kind === 'reporting'); const reservations = await store.all('SELECT state FROM capacity_reservations WHERE team_id=? AND work_day_id=?', [run.teamId, run.id]); - if (reports.length > 0 && reports.every((row) => row.status === 'completed') + if (reports.length > 0 && reports.every((row) => terminalNodeStates.has(String(row.status))) && reservations.every((row) => terminalReservationStates.has(String(row.state)))) { next = { ...next, state: 'ended', endedAt: next.endedAt ?? now }; - status = 'completed'; completedAt = completedAt ?? now; + status = reports.every((row) => row.status === 'completed') ? 'completed' : 'failed'; + completedAt = completedAt ?? now; } } if (same(next, plan) && status === run.status) return { changed: false, plan: next, status }; diff --git a/tests/unit/control-plane/capacity/execution/living-workday-lifecycle.test.ts b/tests/unit/control-plane/capacity/execution/living-workday-lifecycle.test.ts index d9d43fdf..5d3107dd 100644 --- a/tests/unit/control-plane/capacity/execution/living-workday-lifecycle.test.ts +++ b/tests/unit/control-plane/capacity/execution/living-workday-lifecycle.test.ts @@ -46,6 +46,22 @@ describe('living workday lifecycle', () => { expect(sql).not.toMatch(/capacity_workday_demands|workday_capacity_envelopes/u); }); + it.each(['failed', 'cancelled', 'stale'])('settles a %s Reporter as a failed workday, not success or an endless drain', async status => { + const closingRun = { ...run, parameters: { appliedPlan: { ...plan, state: 'closing', closingAt: now } } } as never; + const store = { all: vi.fn(async (sql: string) => sql.includes('execution_nodes') + ? [{ id: 'report', kind: 'reporting', status }] : [{ state: 'released' }]), + updateCapacityWorkdayRun: vi.fn(async () => closingRun) }; + expect(await advanceLivingWorkday(store as never, closingRun, now)).toMatchObject({ + status: 'failed', plan: { state: 'ended', endedAt: now } }); + }); + it('does not end a failed Reporter until reservations are settled', async () => { + const closingRun = { ...run, parameters: { appliedPlan: { ...plan, state: 'closing', closingAt: now } } } as never; + const store = { all: vi.fn(async (sql: string) => sql.includes('execution_nodes') + ? [{ id: 'report', kind: 'reporting', status: 'failed' }] : [{ state: 'consuming' }]), + updateCapacityWorkdayRun: vi.fn(async () => closingRun) }; + expect(await advanceLivingWorkday(store as never, closingRun, now)).toMatchObject({ + status: 'running', plan: { state: 'closing' } }); + }); it('ends only after Reporter completion and reservation settlement', async () => { const closing = { ...plan, state: 'closing', closingAt: now } as const; const closingRun = { ...run, parameters: { appliedPlan: closing } } as never;