fix(native-chat): the idle sweep and the restart snapshot read the host's child records

The idle sweep (keep an agent running while its subagents or commands run) and
the restart-resume snapshot (what a chat was doing when Orca stopped it) both
read the provider tracker's roster through the adapter interface, which no
longer carries it. Both now take the host's one child-record read, the same one
the status summary, the chat strip and command admission use.

The snapshot's working test also folded that roster through the shared fold's
old `backgroundTasks` input, which the fold no longer reads, so a settled lead
whose subagent was still running would have been offered nothing. It now hands
the fold the records.
This commit is contained in:
Brennan Benson
2026-09-29 00:59:49 -07:00
parent bb1b369be0
commit 6b2020e583
18 changed files with 187 additions and 120 deletions
@@ -311,7 +311,7 @@ describe('the wind-down retry with a message queued (P2-31)', () => {
now: () => IDLE_MS + 1,
isDisposed: () => false,
deliveryActive: () => true,
backgroundTaskState: () => undefined,
childWork: () => undefined,
hasOpenDispatch: () => false,
stopAgent,
stopStartingAgent: stopAgent,
@@ -9,6 +9,7 @@ import {
AgentSessionRefusalError,
agentSessionRefusalError
} from '../../../shared/agent-session-wire-refusals'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import { createJournalOpenReadRefusals } from '../agent-session-journal/journal-open-failure'
import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations'
import {
@@ -36,6 +37,8 @@ export function createStructuredAgentSessionConversationLifetime(host: {
deliveryActive: (sessionId: string) => boolean
/** The handle closed: `listed` keeps the chat's row in the agent-status store for its tab. */
closeStatus: (sessionId: string, options: { listed: boolean }) => void
/** The session's child records, the host's one read of them. */
readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined
}) {
let disposed = false
const { sessions, serialize } = host
@@ -64,7 +67,7 @@ export function createStructuredAgentSessionConversationLifetime(host: {
now: () => host.context().now(),
isDisposed: () => disposed,
deliveryActive: host.deliveryActive,
backgroundTaskState: (sessionId) => deps().adapter.backgroundTaskState?.(sessionId),
childWork: host.readChildWork,
hasOpenDispatch: (sessionId) => {
const record = deps().store.getRecord(sessionId)
return record !== null && deps().hasOpenDispatch?.(record) === true
@@ -7,6 +7,7 @@ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-ses
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open'
import type {
@@ -167,6 +168,12 @@ export function replaceHostTestState(next: {
host = next.host
}
/** Serves `records` as the session's child records through the status sink, as the host's store
* does. Call before `attach`: only the attach's publish carries the address a row lands under. */
export function serveHostTestChildWork(records: () => AgentChildWorkView[]): void {
host.deps.statusSink = { publish: () => {}, forget: () => {}, readChildWork: records }
}
/** The live per-test state. Read it in a `beforeEach` so a suite's test bodies
* keep using bare `host` / `store` / `dispatch` exactly as they did when this
* setup was inline. */
@@ -151,18 +151,18 @@ export class StructuredAgentSessionHost {
now: () => this.now(),
onBarrierError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error })
})
this.restartResume = createStructuredAgentSessionRestartResume(
deps,
this.sessions,
structuredAgentSessionRestartResumeSurfaces(this, this.now)
)
this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, {
...structuredAgentSessionRestartResumeSurfaces(this, this.now),
readChildWork: this.clientDelivery.readChildWork
})
this.lifetime = createStructuredAgentSessionConversationLifetime({
context: () => this.lifetimeContext(),
sessions: this.sessions,
serialize: (sessionId, task) => this.serialize(sessionId, task),
open: (sessionId) => this.conversationDelivery.open(sessionId),
deliveryActive: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId),
closeStatus: (sessionId, options) => this.clientDelivery.closeSession(sessionId, options)
closeStatus: (sessionId, options) => this.clientDelivery.closeSession(sessionId, options),
readChildWork: this.clientDelivery.readChildWork
})
this.runtimeState.startLeaseRenewal()
this.lifetime.idleSweep.start()
@@ -3,6 +3,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import {
collectSubscriber,
createRestTestRig,
@@ -42,6 +43,25 @@ function lastStatus(): AgentSessionStatusSummary | undefined {
.findLast((summary) => summary.sessionId === SESSION)
}
/** A child record as the host's store serves it; a `done` one has settled. */
function childView(
id: string,
kind: AgentChildWorkView['kind'],
state: 'working' | 'idle' | 'done'
): AgentChildWorkView {
return {
id,
kind,
state,
membership: state === 'done' ? 'settled' : 'live',
...(state === 'done' ? { outcome: 'succeeded' as const, settledAt: rig.clock.now } : {}),
firstObservedAt: rig.clock.now,
observedAt: rig.clock.now,
stoppable: false,
invocation: { invocationId: `spawn-${id}`, generation: 1 }
}
}
function fence(): number {
return rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1
}
@@ -101,25 +121,13 @@ describe('the idle sweep', () => {
it('never stops an agent whose background work still runs (P2-09)', async () => {
await foundRestTestChat(rig)
// A Codex subagent thread is one of these tasks; the tracker projects both kinds alike.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a background roster fixture; the sweep reads only whether live tasks exist.
rig.adapter.backgroundTaskState.mockReturnValue({
state: 'monitoring',
tasks: [
{
taskId: 'subagent-1',
kind: 'subagent',
status: 'running',
title: 'reviewer',
startedAt: rig.clock.now
}
]
} as never)
// A Codex subagent thread is one of these records; the host holds both kinds alike.
rig.sink.readChildWork.mockReturnValue([childView('subagent-1', 'agent', 'working')])
rig.clock.now += IDLE_MS + 1
await sweepTicks()
expect(rig.adapter.closeSession).not.toHaveBeenCalled()
rig.adapter.backgroundTaskState.mockReturnValue(undefined)
rig.sink.readChildWork.mockReturnValue([])
rig.clock.now += IDLE_MS + 1
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
})
@@ -128,16 +136,10 @@ describe('the idle sweep', () => {
// agent in that gap would lose the wake-up. Owed work is activity, as main's release clock had it.
it('gives an agent a full idle window after its background work ends', async () => {
await foundRestTestChat(rig)
rig.adapter.backgroundTaskState.mockReturnValue({
state: 'monitoring',
tasks: [{ id: 'subagent-1', kind: 'agent', state: 'working' }]
})
rig.sink.readChildWork.mockReturnValue([childView('subagent-1', 'agent', 'working')])
rig.clock.now += IDLE_MS + 1
await sweepTicks()
rig.adapter.backgroundTaskState.mockReturnValue({
state: 'monitoring',
tasks: [{ id: 'subagent-1', kind: 'agent', state: 'done' }]
})
rig.sink.readChildWork.mockReturnValue([childView('subagent-1', 'agent', 'done')])
await sweepTicks()
expect(rig.adapter.closeSession).not.toHaveBeenCalled()
@@ -149,16 +151,12 @@ describe('the idle sweep', () => {
// have closed still leaves the agent a full window after it.
it('gives a full window after background work that ends late in a window', async () => {
await foundRestTestChat(rig)
const subagent = (state: 'working' | 'done') => ({
state: 'monitoring' as const,
tasks: [{ id: 'subagent-1', kind: 'agent' as const, state }]
})
rig.adapter.backgroundTaskState.mockReturnValue(subagent('working'))
rig.sink.readChildWork.mockReturnValue([childView('subagent-1', 'agent', 'working')])
rig.clock.now += IDLE_MS + 1
await sweepTicks()
rig.clock.now += IDLE_MS - 60_000
await sweepTicks()
rig.adapter.backgroundTaskState.mockReturnValue(subagent('done'))
rig.sink.readChildWork.mockReturnValue([childView('subagent-1', 'agent', 'done')])
rig.clock.now += 60_000 + 1
await sweepTicks()
@@ -167,15 +165,12 @@ describe('the idle sweep', () => {
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
})
it('stops an agent whose roster holds only children that went idle or finished', async () => {
it('stops an agent whose child records hold only children that went idle or finished', async () => {
await foundRestTestChat(rig)
rig.adapter.backgroundTaskState.mockReturnValue({
state: 'monitoring',
tasks: [
{ id: 'subagent-1', kind: 'agent', state: 'idle' },
{ id: 'command-1', kind: 'command', state: 'done' }
]
})
rig.sink.readChildWork.mockReturnValue([
childView('subagent-1', 'agent', 'idle'),
childView('subagent-2', 'agent', 'done')
])
rig.clock.now += IDLE_MS + 1
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
@@ -298,7 +293,7 @@ describe('the idle sweep with no child running (P2-22 ii)', () => {
now: () => IDLE_MS + 1,
isDisposed: () => false,
deliveryActive: () => false,
backgroundTaskState: () => undefined,
childWork: () => undefined,
hasOpenDispatch: () => false,
stopAgent,
stopStartingAgent: stopAgent,
@@ -12,7 +12,7 @@ import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection'
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
export const STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS = 5 * 60_000
@@ -27,7 +27,8 @@ export type StructuredAgentSessionIdleSweepDeps = {
now: () => number
isDisposed: () => boolean
deliveryActive: (sessionId: string) => boolean
backgroundTaskState: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined
/** The session's child records, the host's one read of them. */
childWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined
/** An orchestration dispatch that still owns this session's worker; derived from its database. */
hasOpenDispatch: (sessionId: string) => boolean
/** Each of these runs inside the session's serialize and never takes it again. */
@@ -143,7 +144,7 @@ export class StructuredAgentSessionIdleSweep {
return (
activeStructuredAgentSessionTurnId(items) !== null ||
this.queuedOrDelivering(sessionId, session) ||
agentChildWorkLiveness(this.deps.backgroundTaskState(sessionId)?.tasks) !== null ||
agentChildWorkLiveness(this.deps.childWork(sessionId)) !== null ||
this.deps.hasOpenDispatch(sessionId) ||
hasPendingStructuredAgentSessionPrompt(items)
)
@@ -13,6 +13,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import { AgentHookServer, _internals } from '../../agent-hooks/server'
import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter'
import {
fakeClaude,
@@ -42,9 +43,11 @@ let claude: ReturnType<typeof fakeClaude>
let landInit: () => void
let lifecycle: Promise<void>[]
let clock: number
const server = new AgentHookServer()
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-owed-work-release-'))
_internals.resetCachesForTests()
resetHostTestOperationIds()
claude = fakeClaude()
lifecycle = []
@@ -71,6 +74,8 @@ beforeEach(async () => {
},
// As the runtime wires it: an admitted prompt's outcome reaches the journal out of band.
onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement),
onChildWorkEvidence: (sessionId, evidence) =>
host.publishChildWorkEvidence(sessionId, evidence),
// Initialize answers only when the test says so.
openConnection: async (launch, handlers) => {
const connection = await claude.openConnection(launch, handlers)
@@ -92,7 +97,15 @@ beforeEach(async () => {
claimKeyId: 'key-1',
mintSpawnToken: () => 'spawn-a',
idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS },
now: () => clock
now: () => clock,
// The host's status row and child records, as the runtime wires them.
statusSink: {
publish: (summary, subject) => server.ingestStructuredStatus(summary, subject),
forget: (subject) => server.dropStructuredStatus(subject),
publishChildWork: (subject, evidence, provider) =>
server.ingestStructuredChildWork(subject, evidence, provider),
readChildWork: (subject) => server.getStructuredChildWorkViews(subject)
}
})
})
@@ -261,8 +274,9 @@ describe('a chat whose settled lead still has background work running', () => {
})
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' })
const page = await host.history({ sessionId: SESSION, direction: 'tail' })
expect(page.ok ? page.page.backgroundTasks?.children : null).toEqual([
expect.objectContaining({ providerId: 'task-1', membership: 'live' })
])
}
@@ -13,6 +13,7 @@ import type {
AgentSessionStatusEvent,
AgentSessionSubscribeEvent
} from '../../../shared/agent-session-wire'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type {
AgentSessionDispatchOutcome,
@@ -42,7 +43,6 @@ export type RestTestAdapter = {
acknowledgeSessionRelease: Mock<
NonNullable<StructuredAgentSessionAdapter['acknowledgeSessionRelease']>
>
backgroundTaskState: Mock<NonNullable<StructuredAgentSessionAdapter['backgroundTaskState']>>
readOptions: Mock<NonNullable<StructuredAgentSessionAdapter['readOptions']>>
}
@@ -53,7 +53,12 @@ export type RestTestRig = {
adapter: RestTestAdapter
clock: { now: number }
statusEvents: AgentSessionStatusEvent[]
sink: { publish: Mock; forget: Mock }
/** `readChildWork` serves the session's child records, as the host's store does. */
sink: {
publish: Mock
forget: Mock
readChildWork: Mock<(subject: unknown) => AgentChildWorkView[]>
}
/** Opens a fresh host over the same store and journals: what a restart leaves behind. */
restart: (deps?: Partial<StructuredAgentSessionHostDeps>) => Promise<StructuredAgentSessionHost>
dispose: () => Promise<void>
@@ -124,7 +129,11 @@ export async function createRestTestRig(
const root = await mkdtemp(join(tmpdir(), 'orca-rest-'))
const clock = { now: HOST_TEST_NOW }
const statusEvents: AgentSessionStatusEvent[] = []
const sink = { publish: vi.fn(), forget: vi.fn() }
const sink: RestTestRig['sink'] = {
publish: vi.fn(),
forget: vi.fn(),
readChildWork: vi.fn((): AgentChildWorkView[] => [])
}
let store = await AgentSessionRecordStore.open({
directory: join(root, 'store'),
hostId: 'local'
@@ -146,7 +155,6 @@ export async function createRestTestRig(
closeSession: vi.fn(async () => true),
dispatch: vi.fn(async () => acceptedDispatch()),
acknowledgeSessionRelease: vi.fn(),
backgroundTaskState: vi.fn(() => undefined),
readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } }))
}
const hostFor = (overrides: Partial<StructuredAgentSessionHostDeps>) =>
@@ -10,16 +10,19 @@ import {
} from '../../runtime/agent-session-recovery-capsule'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner'
import { childRecord } from './structured-agent-session-restart-resume-test-harness'
import {
adapter,
attach,
CALLER,
envelope,
hostTestState,
replaceHostTestState
replaceHostTestState,
serveHostTestChildWork
} from './structured-agent-session-host-test-harness'
import {
HOST_TEST_NOW as NOW,
@@ -51,6 +54,10 @@ export async function interruptedRestart(
})
) {
const previous = hostTestState()
let children: AgentChildWorkView[] = []
if (work === 'children') {
serveHostTestChildWork(() => children)
}
await attach()
const events = previous.acquire.mock.calls[0]?.[0].events
if (!events) {
@@ -93,10 +100,7 @@ export async function interruptedRestart(
]
})
events.appendItem(group, roster('working'))
previous.host.deps.adapter.backgroundTaskState = () => ({
state: 'monitoring',
tasks: [{ id: 'child-1', kind: 'agent', description: 'Review loop 4', state: 'working' }]
})
children = [childRecord({ id: 'child-1', kind: 'agent', description: 'Review loop 4' })]
// As the real adapters do: the child's own close settles the children it can no longer hear.
previous.host.deps.adapter.closeSession = async () => {
events.appendItem(group, roster('unverifiable'))
@@ -87,7 +87,7 @@ export function createStructuredAgentSessionRestartResume(
const witnesses = createStructuredAgentSessionRestartWitnesses({
sessions,
getRecord: deps.store.getRecord,
backgroundTasks: (sessionId) => deps.adapter.backgroundTaskState?.(sessionId)?.tasks,
childWork: surfaces.readChildWork,
...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}),
teardownId: randomUUID(),
now: surfaces.now,
@@ -11,6 +11,7 @@ import type {
} from '../../../shared/agent-session-journal-types'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import { structuredAgentSessionResumableSet } from './structured-agent-session-restart-resume-set'
export const SESSION = 'session-working-1'
@@ -195,6 +196,21 @@ export function submission(
}
}
/** A child record as the host's store serves it; live unless the test settles it. */
export function childRecord(
child: Pick<AgentChildWorkView, 'id' | 'kind'> & Partial<AgentChildWorkView>
): AgentChildWorkView {
return {
state: 'working',
membership: child.state === 'done' || child.state === 'idle' ? 'settled' : 'live',
firstObservedAt: NOW,
observedAt: NOW,
stoppable: false,
invocation: { invocationId: `spawn-${child.id}`, generation: 1 },
...child
}
}
/** The epoch the fake journal reports. */
export const EPOCH = 'epoch-1'
@@ -11,6 +11,7 @@ import type {
AgentSessionMutationResult,
AgentSessionSendResult
} from '../../../shared/agent-session-wire'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import { MAX_TIMER_DELAY_MS } from '../../../shared/timer-delay'
import type { SendSettlementWaitOptions } from './structured-agent-session-send-settlement'
@@ -30,6 +31,8 @@ export type StructuredAgentSessionRestartResumeSurfaces = {
clientMessageId: string
) => Promise<{ value: AgentSessionSendResult } | undefined>
onNoteFailed: (sessionId: string, error: unknown) => void
/** The session's child records, the host's one read of them. */
readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined
now: () => number
}
@@ -60,7 +63,7 @@ type RestartResumeHostBindings = {
export function structuredAgentSessionRestartResumeSurfaces(
host: RestartResumeHostBindings,
now: () => number
): StructuredAgentSessionRestartResumeSurfaces {
): Omit<StructuredAgentSessionRestartResumeSurfaces, 'readChildWork'> {
return {
revealSession: host.revealSession,
send: (params) =>
@@ -15,6 +15,7 @@ import {
} from './structured-agent-session-restart-resume-runner'
import { structuredAgentSessionWorkingAtStop } from './structured-agent-session-working-at-teardown'
import {
childRecord,
CLAUDE_ROOT,
claudeRecord,
EPOCH,
@@ -52,7 +53,7 @@ describe('deriving what was working at teardown', () => {
[SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }]
]),
getRecord: () => record(),
backgroundTasks: () => undefined,
childWork: () => undefined,
trigger: 'quit',
teardownId: TEARDOWN_CURRENT,
now: NOW
@@ -79,7 +80,7 @@ describe('deriving what was working at teardown', () => {
[SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }]
]),
getRecord: () => record(),
backgroundTasks: () => undefined,
childWork: () => undefined,
trigger: 'update',
teardownId: TEARDOWN_CURRENT,
now: NOW
@@ -93,7 +94,7 @@ describe('deriving what was working at teardown', () => {
markersAtTeardown({
sessions: new Map([[SESSION, { journal: journal([]), child: { fence: 1 } }]]),
getRecord: () => record(),
backgroundTasks: () => undefined,
childWork: () => undefined,
trigger: 'quit',
teardownId: TEARDOWN_CURRENT,
now: NOW
@@ -108,7 +109,7 @@ describe('deriving what was working at teardown', () => {
[SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }]
]),
getRecord: () => record(),
backgroundTasks: () => undefined,
childWork: () => undefined,
trigger: 'quit',
teardownId: TEARDOWN_CURRENT,
now: NOW
@@ -125,7 +126,7 @@ describe('deriving what was working at teardown', () => {
[SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: null }]
]),
getRecord: () => record(),
backgroundTasks: () => undefined,
childWork: () => undefined,
trigger: 'quit',
teardownId: TEARDOWN_CURRENT,
now: NOW
@@ -148,7 +149,7 @@ describe('deriving what was working at teardown', () => {
]
]),
getRecord: () => record(),
backgroundTasks: () => undefined,
childWork: () => undefined,
trigger: 'quit',
teardownId: TEARDOWN_CURRENT,
now: NOW
@@ -170,9 +171,7 @@ describe('deriving what was working at teardown', () => {
[SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }]
]),
getRecord: () => record(),
backgroundTasks: () => [
{ id: 'task-a', kind: 'agent', description: 'Review loop 4', state: 'working' }
],
childWork: () => [childRecord({ id: 'task-a', kind: 'agent', description: 'Review loop 4' })],
trigger: 'update',
teardownId: TEARDOWN_CURRENT,
now: NOW
@@ -193,14 +192,15 @@ describe('deriving what was working at teardown', () => {
[SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }]
]),
getRecord: () => record(),
backgroundTasks: () => [
{ id: 'gone', kind: 'agent', description: 'Finished agent', state: 'done' },
...Array.from({ length: 20 }, (_, index) => ({
id: `live-${index}`,
kind: 'command' as const,
description: `Shell ${index}${'x'.repeat(300)}`,
state: 'working' as const
}))
childWork: () => [
childRecord({ id: 'gone', kind: 'agent', description: 'Finished agent', state: 'done' }),
...Array.from({ length: 20 }, (_, index) =>
childRecord({
id: `live-${index}`,
kind: 'command',
description: `Shell ${index}${'x'.repeat(300)}`
})
)
],
trigger: 'quit',
teardownId: TEARDOWN_CURRENT,
@@ -218,7 +218,9 @@ describe('deriving what was working at teardown', () => {
[SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }]
]),
getRecord: () => record(),
backgroundTasks: () => [{ id: 'task-m', kind: 'monitor', name: 'ci-watch' }],
childWork: () => [
childRecord({ id: 'task-m', kind: 'monitor', name: 'ci-watch', state: 'monitoring' })
],
trigger: 'quit',
teardownId: TEARDOWN_CURRENT,
now: NOW
@@ -235,9 +237,9 @@ describe('deriving what was working at teardown', () => {
[SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }]
]),
getRecord: () => record(),
backgroundTasks: () => [
{ id: 'task-a', kind: 'agent', state: 'done' },
{ id: 'task-b', kind: 'command', state: 'idle' }
childWork: () => [
childRecord({ id: 'task-a', kind: 'agent', state: 'done' }),
childRecord({ id: 'task-b', kind: 'command', state: 'idle' })
],
trigger: 'quit',
teardownId: TEARDOWN_CURRENT,
@@ -253,7 +255,7 @@ describe('deriving what was working at teardown', () => {
[SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }]
]),
getRecord: () => claudeRecord(null),
backgroundTasks: () => undefined,
childWork: () => undefined,
trigger: 'quit',
teardownId: TEARDOWN_CURRENT,
now: NOW
@@ -269,7 +271,7 @@ describe('deriving what was working at teardown', () => {
[SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }]
]),
getRecord: () => record({ chain: [] }),
backgroundTasks: () => undefined,
childWork: () => undefined,
trigger: 'quit',
teardownId: TEARDOWN_CURRENT,
now: NOW
@@ -292,7 +294,7 @@ describe('deriving what was working at teardown', () => {
]
]),
getRecord: () => claudeRecord(null),
backgroundTasks: () => undefined,
childWork: () => undefined,
trigger: 'quit',
teardownId: TEARDOWN_CURRENT,
now: NOW
@@ -317,7 +319,7 @@ describe('deriving what was working at teardown', () => {
]
]),
getRecord: () => claudeRecord(null),
backgroundTasks: () => undefined,
childWork: () => undefined,
trigger: 'quit',
teardownId: TEARDOWN_CURRENT,
now: NOW
@@ -342,7 +344,7 @@ describe('deriving what was working at teardown', () => {
]
]),
getRecord: () => claudeRecord(null),
backgroundTasks: () => undefined,
childWork: () => undefined,
trigger: 'quit',
teardownId: TEARDOWN_CURRENT,
now: NOW
@@ -375,7 +377,7 @@ describe('deriving what was working at teardown', () => {
]
]),
getRecord: () => claudeRecord(null),
backgroundTasks: () => undefined,
childWork: () => undefined,
trigger: 'quit',
teardownId: TEARDOWN_CURRENT,
now: NOW
@@ -403,7 +405,7 @@ describe('deriving what was working at teardown', () => {
]
]),
getRecord: () => record(),
backgroundTasks: () => undefined,
childWork: () => undefined,
trigger: 'quit',
teardownId: TEARDOWN_CURRENT,
now: NOW
@@ -1,7 +1,15 @@
import { expect, it, vi } from 'vitest'
import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule'
import { attach, hostTestState } from './structured-agent-session-host-test-harness'
import { pendingApproval } from './structured-agent-session-restart-resume-test-harness'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import {
attach,
hostTestState,
serveHostTestChildWork
} from './structured-agent-session-host-test-harness'
import {
childRecord,
pendingApproval
} from './structured-agent-session-restart-resume-test-harness'
import {
HOST_TEST_NOW as NOW,
HOST_TEST_SESSION as SESSION,
@@ -113,8 +121,10 @@ it.each(['approval', 'question', 'completed'] as const)(
}
)
// The roster is read off the live adapter at teardown: eviction clears it moments later.
// The child records are read at teardown: eviction settles them moments later.
it('marks a settled chat whose subagent was still running', async () => {
let children: AgentChildWorkView[] = []
serveHostTestChildWork(() => children)
await attach()
const { host, root, acquire } = hostTestState()
const events = acquire.mock.calls[0]?.[0].events
@@ -126,15 +136,12 @@ it('marks a settled chat whose subagent was still running', async () => {
{ kind: 'turn', turnId: 'settled', state: 'completed' }
)
await host.flushStreamedEvents(SESSION)
host.deps.adapter.backgroundTaskState = () => ({
state: 'monitoring',
tasks: [{ id: 'task-a', kind: 'agent', description: 'Review loop 4', state: 'working' }]
})
children = [childRecord({ id: 'task-a', kind: 'agent', description: 'Review loop 4' })]
await host.flushAllStreamedEvents()
const [offered] = await new AgentSessionRecoveryCapsule(root).list(NOW)
expect(offered?.work).toEqual({ kind: 'turn', id: 'settled' })
// The description is captured BEFORE the stop, off the roster the sidebar was still showing;
// eviction clears that roster and settles the rows moments later.
// The description is captured BEFORE the stop, off the records the sidebar was still showing;
// eviction settles them moments later.
expect(offered?.activity).toEqual({
state: 'done',
prompts: [],
@@ -31,7 +31,7 @@ export type StructuredAgentSessionRestartWitnesses = {
export function createStructuredAgentSessionRestartWitnesses(deps: {
sessions: ReadonlyMap<string, NonNullable<WorkingAtStopInput['session']>>
getRecord: (sessionId: string) => AgentSessionRecord | null
backgroundTasks: WorkingAtStopInput['backgroundTasks']
childWork: WorkingAtStopInput['childWork']
capsule?: Pick<AgentSessionRecoveryCapsule, 'record'>
teardownId: string
now: () => number
@@ -59,7 +59,7 @@ export function createStructuredAgentSessionRestartWitnesses(deps: {
sessionId,
session: deps.sessions.get(sessionId),
getRecord: deps.getRecord,
backgroundTasks: deps.backgroundTasks,
childWork: deps.childWork,
trigger,
teardownId: deps.teardownId,
now: deps.now()
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { structuredAgentSessionShownStatus } from './structured-agent-session-shown-work'
import { submission } from './structured-agent-session-restart-resume-test-harness'
import { childRecord, submission } from './structured-agent-session-restart-resume-test-harness'
describe('whether a session shows as working', () => {
// The status feed scopes unanswered sends to the lease fence; a stale one would otherwise offer a
@@ -16,7 +16,14 @@ describe('whether a session shows as working', () => {
expect(
structuredAgentSessionShownStatus(
journal,
[{ id: 'watch', kind: 'monitor', description: 'Watch CI', state: 'working' }],
[
childRecord({
id: 'watch',
kind: 'monitor',
description: 'Watch CI',
state: 'monitoring'
})
],
1
).state
).not.toBe('done')
@@ -1,15 +1,15 @@
// Whether a structured session is doing work, asked the one way the product answers it.
//
// The status feed publishes the lead's journal status beside the provider's live background roster,
// and the store ingest folds the two into the row the sidebar shows. The quit snapshot composes the
// The status feed publishes the lead's journal status beside the host's child records, and the
// store ingest folds the two into the row the sidebar shows. The quit snapshot composes the
// same two inputs through the same fold, so it cannot offer a different set of chats than the one
// the user saw working.
import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-background-task-wire'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../shared/agent-session-journal-types'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import {
structuredAgentSessionAgentStatus,
type StructuredAgentSessionAgentStatus
@@ -23,13 +23,13 @@ export function structuredAgentSessionShownStatus(
items: readonly AgentJournalRenderItem[]
submissions: readonly AgentJournalSubmission[]
},
backgroundTasks: readonly AgentSessionBackgroundTask[] | null | undefined,
childWork: readonly AgentChildWorkView[] | undefined,
/** The session's lease fence, as the status feed passes it: a send from an older one is not work. */
fence: number | undefined
): StructuredAgentSessionAgentStatus {
const status = projectStructuredAgentSessionStatus(journal.items, journal.submissions, fence)
return structuredAgentSessionAgentStatus({
status,
...(backgroundTasks ? { backgroundTasks: [...backgroundTasks] } : {})
...(childWork ? { childWork } : {})
})
}
@@ -28,7 +28,6 @@ import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../shared/agent-session-journal-types'
import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-background-task-wire'
import {
AGENT_SESSION_RESTART_ACTIVITY_MAX_LABEL_LENGTH,
AGENT_SESSION_RESTART_ACTIVITY_MAX_PROMPTS,
@@ -38,6 +37,7 @@ import {
type AgentSessionRestartTask
} from '../../../shared/agent-session-restart-activity'
import { isLiveChildWork } from '../../../shared/agent-status-child-work-liveness'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import {
activeStructuredAgentSessionTurnId,
newestStructuredAgentSessionTurn
@@ -123,14 +123,14 @@ function pendingPrompts(items: readonly AgentJournalRenderItem[]): AgentSessionR
return prompts
}
/** The live rows of the provider's roster, by the same liveness rule the sidebar's fold counts. */
/** The live child records, by the same liveness rule the sidebar's fold counts. */
function liveTasks(
roster: readonly AgentSessionBackgroundTask[] | null | undefined
childWork: readonly AgentChildWorkView[] | undefined
): AgentSessionRestartTask[] {
return (roster ?? [])
.filter((task) => isLiveChildWork(task))
return (childWork ?? [])
.filter((child) => isLiveChildWork(child))
.slice(0, AGENT_SESSION_RESTART_ACTIVITY_MAX_TASKS)
.map((task) => ({ kind: task.kind, label: boundedLabel(task.description ?? task.name) }))
.map((child) => ({ kind: child.kind, label: boundedLabel(child.description ?? child.name) }))
}
type WorkingCandidateSession = {
@@ -145,8 +145,8 @@ export function structuredAgentSessionWorkingAtStop(input: {
sessionId: string
session: WorkingCandidateSession | undefined
getRecord: (sessionId: string) => AgentSessionRecord | null
/** The provider's live child roster, the same one the status feed publishes. */
backgroundTasks: (sessionId: string) => readonly AgentSessionBackgroundTask[] | null | undefined
/** The session's child records, the same read the status feed publishes. */
childWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined
trigger: AgentSessionResumeTrigger
/** Stable teardown identity for continuation deduplication, not launch ancestry. */
teardownId: string
@@ -162,10 +162,10 @@ export function structuredAgentSessionWorkingAtStop(input: {
const handedOver = snapshot.submissions.filter(
(submission) => !isQueuedAgentJournalSubmission(submission)
)
const roster = input.backgroundTasks(sessionId)
const childWork = input.childWork(sessionId)
const status = structuredAgentSessionShownStatus(
{ items: snapshot.items, submissions: handedOver },
roster,
childWork,
session.child.fence
)
if (status.state === 'done') {
@@ -183,7 +183,7 @@ export function structuredAgentSessionWorkingAtStop(input: {
// and carries them in `tasks`, which is how the dialog tells the two apart.
state: status.mainAgent.state,
prompts: pendingPrompts(snapshot.items),
tasks: liveTasks(roster)
tasks: liveTasks(childWork)
}
return {
sessionId,