From 6b2020e583d05d307ed6574ff64cb16ad21b9e82 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:15:28 -0700 Subject: [PATCH] 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. --- ...d-agent-session-conversation-close.test.ts | 2 +- ...red-agent-session-conversation-lifetime.ts | 5 +- ...uctured-agent-session-host-test-harness.ts | 7 ++ .../structured-agent-session-host.ts | 12 ++-- ...tructured-agent-session-idle-sweep.test.ts | 71 +++++++++---------- .../structured-agent-session-idle-sweep.ts | 7 +- ...ed-agent-session-owed-work-release.test.ts | 20 +++++- .../structured-agent-session-rest-test-rig.ts | 16 +++-- ...ssion-restart-interruption-test-harness.ts | 14 ++-- ...tured-agent-session-restart-resume-host.ts | 2 +- ...ent-session-restart-resume-test-harness.ts | 16 +++++ ...red-agent-session-restart-resume-wiring.ts | 5 +- ...tured-agent-session-restart-resume.test.ts | 58 +++++++-------- ...red-agent-session-restart-teardown.test.ts | 25 ++++--- ...uctured-agent-session-restart-witnesses.ts | 4 +- ...tructured-agent-session-shown-work.test.ts | 11 ++- .../structured-agent-session-shown-work.ts | 10 +-- ...tured-agent-session-working-at-teardown.ts | 22 +++--- 18 files changed, 187 insertions(+), 120 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index bf3cd1f96a6..5f05a5f78bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -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, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 0fbd1a0a233..cae2a8c3954 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -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 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index 1e0d77b0d52..87091073ec0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -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. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 53bb7828707..86529312b90 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -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() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index ebc2009fa7d..798c9b14809 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -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, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 2394b604171..05e297a5b88 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -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) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 9da47ae1f22..f8aec7d5d3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -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 let landInit: () => void let lifecycle: Promise[] 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' }) ]) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 3ea116fa361..3113323b740 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -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 > - backgroundTaskState: Mock> readOptions: Mock> } @@ -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) => Promise dispose: () => Promise @@ -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) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index d88a6330e78..2e785fc34a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -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')) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index d053caf006b..af2a0970a6f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -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, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts index e359e31157b..93c33146743 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts @@ -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 & Partial +): 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' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index 4004502e408..bb9eb500d1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -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 { return { revealSession: host.revealSession, send: (params) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index efcd70345a8..a2ed07db291 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -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 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts index bf740ac405b..8c3fdbf3aa7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts @@ -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: [], diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts index 6cd19475d69..2beee1b5a59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts @@ -31,7 +31,7 @@ export type StructuredAgentSessionRestartWitnesses = { export function createStructuredAgentSessionRestartWitnesses(deps: { sessions: ReadonlyMap> getRecord: (sessionId: string) => AgentSessionRecord | null - backgroundTasks: WorkingAtStopInput['backgroundTasks'] + childWork: WorkingAtStopInput['childWork'] capsule?: Pick 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() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts index 819c242fdfb..3fc7dbee0a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts @@ -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') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts index d50f0570987..4ed41e8c688 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts @@ -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 } : {}) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 366ce1cf948..ece336266a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -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,