fix(native-chat): keep an idle chat alive while its subagents or background commands run (#22794)

This commit is contained in:
Brennan Benson
2026-09-25 11:42:16 -07:00
committed by GitHub
parent 56dfddc297
commit 9643e16fdb
3 changed files with 70 additions and 7 deletions
@@ -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),
@@ -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<void>[]
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<string, unknown>): void {
claude.connections[0].handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message })
}
async function settleTurnLeavingTask(taskType: string): Promise<void> {
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)
})
})
@@ -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