diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 00df8b4e910..0eed19031c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -5,6 +5,7 @@ // bookkeeping that decides when to run it than buried among the twenty other things a session can // do. +import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import { evictStructuredAgentSession, @@ -198,12 +199,16 @@ export function createStructuredAgentSessionHolds( hasProviderChild: (sessionId) => hasProviderChild(context, sessionId), // A send pending while the child is still starting is held for that start; evicting would // refuse it. Any other pending send may wait on an echo that never comes, so eviction retires it. + // Subagents, commands and monitors outlive the lead's turn inside the child, so the live roster + // the sidebar shows as working is owed too; stopping the child would end them silently. hasOwedWork: (sessionId) => { const session = context.sessions.get(sessionId) return session ? activeStructuredAgentSessionTurnId(session.journal.snapshot().items) !== null || (session.providerChildPhase === 'starting' && - session.journal.pendingSubmissions().length > 0) + session.journal.pendingSubmissions().length > 0) || + agentChildWorkLiveness(context.deps.adapter.backgroundTaskState?.(sessionId)?.tasks) !== + null : false }, onError: (error) => context.deps.onEventSinkError?.(error), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-starting-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts similarity index 77% rename from src/main/native-chat/agent-session-wire/structured-agent-session-starting-release.test.ts rename to src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index df6e6cec4bf..d2d5d746469 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-starting-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -1,7 +1,10 @@ +// Switching away from a chat starts the release clock, and the clock must never stop a provider +// child that still owes the user work. +// // A Claude chat is published before its CLI answers initialize, and a message sent in that window -// is held until it does. Switching away from the chat starts the release clock; the clock must -// treat that held message as work still owed, exactly as it treats a running turn, or it evicts -// the session and refuses a message the user already sent. +// is held until it does; evicting then refuses a message the user already sent. And a lead whose +// turn has settled can leave subagents, commands and monitors running inside the child; evicting +// then ends them silently. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -38,7 +41,7 @@ let landInit: () => void let lifecycle: Promise[] beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-starting-release-')) + root = await mkdtemp(join(tmpdir(), 'orca-owed-work-release-')) resetHostTestOperationIds() claude = fakeClaude() lifecycle = [] @@ -220,3 +223,57 @@ describe('a chat left while its Claude CLI is still starting', () => { expect(claude.connections[0].closeCount).toBe(1) }) }) + +describe('a chat left while its settled lead still has background work running', () => { + function frame(message: Record): void { + claude.connections[0].handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) + } + + async function settleTurnLeavingTask(taskType: string): Promise { + await attachStarting() + landInit() + await adapter.drainStartup(SESSION) + await send('fan out', 'accepted') + frame({ + type: 'system', + subtype: 'task_started', + uuid: 'task-start-1', + task_id: 'task-1', + task_type: taskType, + is_backgrounded: true + }) + frame({ type: 'result', subtype: 'success', uuid: 'result-1', is_error: false, result: 'ok' }) + await host.flushStreamedEvents(SESSION) + expect(adapter.backgroundTaskState(SESSION)?.tasks).toEqual([ + expect.objectContaining({ id: 'task-1' }) + ]) + } + + it.each([ + ['a subagent', 'local_agent'], + ['a background command', 'local_bash'], + ['a monitor', 'monitor'] + ])('keeps the session while %s runs, then releases it once that settles', async (_, type) => { + await settleTurnLeavingTask(type) + + host.release(SESSION, SURFACE) + await waitOutSeveralGraceWindows() + + expect(host.hasSession(SESSION)).toBe(true) + expect(claude.connections[0].closeCount).toBe(0) + + frame({ + type: 'system', + subtype: 'task_notification', + uuid: 'task-done-1', + task_id: 'task-1', + status: 'completed' + }) + // A finished background task can wake the lead; that turn is owed too until it settles. + frame({ type: 'result', subtype: 'success', uuid: 'result-2', is_error: false, result: 'ok' }) + await host.flushStreamedEvents(SESSION) + + await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(claude.connections[0].closeCount).toBe(1) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts index 1cce910c09d..91daa5606a8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts @@ -8,8 +8,9 @@ // already asked for must finish: stopping the child mid-answer strands the open turn marker. // // So the clock arms when the last holder leaves, every journal write while it is armed starts it -// again, and a tick that finds work still owed — a turn running, or a message sent but not yet -// taken by the provider — re-arms instead of evicting. The child goes only after a full window +// again, and a tick that finds work still owed — a turn running, a message sent but not yet +// taken by the provider, or a subagent, command or monitor still running — re-arms instead of +// evicting. The child goes only after a full window // with no holder and no owed work. Quit still stops every child at once. export const STRUCTURED_AGENT_SESSION_RELEASE_GRACE_MS = 30 * 60_000