diff --git a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts index bf528f75684..c502a990d76 100644 --- a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts +++ b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts @@ -29,6 +29,7 @@ beforeEach(() => { }) afterEach(() => { + vi.restoreAllMocks() rmSync(dir, { recursive: true, force: true }) }) @@ -37,6 +38,7 @@ afterEach(() => { * behind when the child's SubagentStop is lost while Orca is down. */ async function restartWithInFlightSubagent(options?: { connectionId?: string + state?: 'working' | 'waiting' subagents?: AgentSubagentSnapshot[] additionalPaneKey?: string }): Promise { @@ -48,7 +50,7 @@ async function restartWithInFlightSubagent(options?: { worktreeId: 'wt-1', connectionId: options?.connectionId ?? null, payload: { - state: 'working', + state: options?.state ?? 'working', prompt: 'review the PR', agentType: 'claude', subagents: options?.subagents ?? [WORKING_CHILD] @@ -61,7 +63,7 @@ async function restartWithInFlightSubagent(options?: { worktreeId: 'wt-1', connectionId: options.connectionId ?? null, payload: { - state: 'working', + state: options.state ?? 'working', prompt: 'review the PR', agentType: 'claude', subagents: options.subagents ?? [WORKING_CHILD] @@ -112,10 +114,19 @@ describe('restored subagent liveness sweep', () => { const server = await restartWithInFlightSubagent() try { expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] }) + const previous = server.getStatusSnapshotForPane(PANE)[0] + const previousReceivedAt = previous?.receivedAt ?? 0 + const reconciledAt = previousReceivedAt + 1 + const now = vi.spyOn(Date, 'now').mockReturnValue(previousReceivedAt) expect(await sweepWith(server, { persistedPtyIdByPaneKey: { [PANE]: PTY } })).toBe(1) expect(paneStatus(server)).toEqual({ state: 'done', subagents: undefined }) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + receivedAt: reconciledAt, + stateStartedAt: reconciledAt + }) + now.mockRestore() } finally { server.stop() } @@ -137,6 +148,24 @@ describe('restored subagent liveness sweep', () => { } }) + it('preserves timing when reaping rows does not change the lead state', async () => { + const server = await restartWithInFlightSubagent({ state: 'waiting' }) + try { + const previous = server.getStatusSnapshotForPane(PANE)[0] + + expect(await sweepWith(server, { persistedPtyIdByPaneKey: { [PANE]: PTY } })).toBe(1) + + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + state: 'waiting', + subagents: undefined, + receivedAt: previous?.receivedAt, + stateStartedAt: previous?.stateStartedAt + }) + } finally { + server.stop() + } + }) + it('uses targeted liveness for a PTY bound in this runtime', async () => { const server = await restartWithInFlightSubagent() try { diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 605b43e4c0d..b4bad15907c 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1986,8 +1986,14 @@ export class AgentHookServer { enriched.payload.state === 'working' && !claudeRosterHasWorkingSubagent(roster) ? 'done' : enriched.payload.state + const stateChanged = state !== enriched.payload.state + const reconciledAt = stateChanged + ? Math.max(Date.now(), enriched.receivedAt + 1) + : enriched.receivedAt this.state.lastStatusByPaneKey.set(paneKey, { ...enriched, + receivedAt: reconciledAt, + stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt, payload: { ...enriched.payload, state, subagents } }) } diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index b2875c925b2..d29eaa8aab4 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -554,7 +554,7 @@ describe('agent sleep planner', () => { ).toEqual([`tab-1:${LEAF}`, `tab-1:${OTHER_LEAF}`]) }) - it('becomes eligible once a phantom subagent stops gating the pane working', () => { + it('restarts the idle window once a phantom subagent stops gating the pane working', () => { // Why: a restored subagent row holds a finished lead at 'working', which is // the one state hibernation never accepts — reaping it is what unlocks it. const gated = entry({ @@ -565,9 +565,14 @@ describe('agent sleep planner', () => { [] ) - const reaped = entry({ state: 'done' }) + const reaped = entry({ state: 'done', updatedAt: NOW, stateStartedAt: NOW }) expect( plannedPaneKeys(snapshot({ agentStatusByPaneKey: { [reaped.paneKey]: reaped } })) + ).toEqual([]) + + const idleReaped = entry({ state: 'done' }) + expect( + plannedPaneKeys(snapshot({ agentStatusByPaneKey: { [idleReaped.paneKey]: idleReaped } })) ).toEqual([`tab-1:${LEAF}`]) // Why: reaping only clears the child gate — a draft typed into the composer @@ -575,8 +580,10 @@ describe('agent sleep planner', () => { expect( plannedPaneKeys( snapshot({ - agentStatusByPaneKey: { [reaped.paneKey]: reaped }, - lastTerminalInputAtByPaneKey: { [reaped.paneKey]: reaped.stateStartedAt + 1 } + agentStatusByPaneKey: { [idleReaped.paneKey]: idleReaped }, + lastTerminalInputAtByPaneKey: { + [idleReaped.paneKey]: idleReaped.stateStartedAt + 1 + } }) ) ).toEqual([])