mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +00:00
fix(native-chat): keep an idle chat alive while its subagents or background commands run (#22794)
This commit is contained in:
@@ -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),
|
||||
|
||||
+61
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user