mirror of
https://github.com/stablyai/orca.git
synced 2026-11-05 11:37:25 +00:00
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:
+1
-1
@@ -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,
|
||||
|
||||
+4
-1
@@ -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()
|
||||
|
||||
+33
-38
@@ -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)
|
||||
)
|
||||
|
||||
+17
-3
@@ -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>) =>
|
||||
|
||||
+9
-5
@@ -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'))
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+16
@@ -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'
|
||||
|
||||
|
||||
+4
-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) =>
|
||||
|
||||
+30
-28
@@ -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
|
||||
|
||||
+16
-9
@@ -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: [],
|
||||
|
||||
+2
-2
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
|
||||
+11
-11
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user