From 0565294472035495939066d72ed066531fbe2559 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:55:48 -0700 Subject: [PATCH] feat(agent-status): host-owned child producer for structured sessions Admit the full structured background-task roster into the execution host's canonical child-work collection, so one logical child task has one identity and one lifecycle regardless of which surface renders it. The collection, its admission API and both legacy projections already shipped; nothing in production called them. This adds the missing producer and the read-only egress views, plus a parity gate measuring those views against the renderer bridge's own projection code. The renderer bridge remains the live writer. Delivery-on, reader-on and old-writer-off stay one atomic follow-up change. --- .../server/server-ingest-remote.ts | 4 +- .../server-ingest-structured-children.ts | 73 ++++ ...tructured-child-work-bridge-parity.test.ts | 188 ++++++++++ .../structured-child-work-producer.test.ts | 313 ++++++++++++++++ ...ctured-child-work-reader-agreement.test.ts | 128 +++++++ ...-background-task-journal-admission.test.ts | 129 +++++++ ...ctured-agent-session-forget-status.test.ts | 4 +- ...ructured-agent-session-status-feed.test.ts | 4 +- .../structured-agent-session-status-feed.ts | 23 ++ ...red-agent-session-status-ownership.test.ts | 12 +- ...ructured-agent-session-status-ownership.ts | 28 +- ...tured-agent-session-status-reentry.test.ts | 5 +- ...red-agent-session-surface-lifetime.test.ts | 2 +- src/main/orcad/orcad-entry.ts | 4 +- ...time-structured-status-sink-wiring.test.ts | 6 +- ...ree-structured-agent-rows-liveness.test.ts | 4 +- .../startup/main-process-runtime-service.ts | 4 +- .../StructuredAgentSessionStatusBridge.tsx | 65 +--- .../agent-status-child-work-reconciliation.ts | 333 ++++++++++++++++++ ...ent-status-child-work-structured-egress.ts | 136 +++++++ ...t-status-child-work-structured-evidence.ts | 164 +++++++++ ...d-work-structured-producer.test-fixture.ts | 119 +++++++ ...ured-session-legacy-subagent-projection.ts | 63 ++++ 23 files changed, 1733 insertions(+), 78 deletions(-) create mode 100644 src/main/agent-hooks/server/server-ingest-structured-children.ts create mode 100644 src/main/agent-hooks/structured-child-work-bridge-parity.test.ts create mode 100644 src/main/agent-hooks/structured-child-work-producer.test.ts create mode 100644 src/main/agent-hooks/structured-child-work-reader-agreement.test.ts create mode 100644 src/main/claude/claude-background-task-journal-admission.test.ts create mode 100644 src/shared/agent-status-child-work-reconciliation.ts create mode 100644 src/shared/agent-status-child-work-structured-egress.ts create mode 100644 src/shared/agent-status-child-work-structured-evidence.ts create mode 100644 src/shared/agent-status-child-work-structured-producer.test-fixture.ts create mode 100644 src/shared/structured-session-legacy-subagent-projection.ts diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index d14714ae09b4..efbaecda4887 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -23,9 +23,9 @@ import { olderPeerAgentStatusLegacyMode } from '../../../shared/agent-status-legacy-adapter' import { isValidPiProviderSessionOnly } from './server-status-identity' -import { AgentHookServerIngestStructured } from './server-ingest-structured' +import { AgentHookServerIngestStructuredChildren } from './server-ingest-structured-children' -export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestStructured { +export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestStructuredChildren { /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ ingestRemote( envelope: { diff --git a/src/main/agent-hooks/server/server-ingest-structured-children.ts b/src/main/agent-hooks/server/server-ingest-structured-children.ts new file mode 100644 index 000000000000..629da4f77917 --- /dev/null +++ b/src/main/agent-hooks/server/server-ingest-structured-children.ts @@ -0,0 +1,73 @@ +import { randomUUID } from 'node:crypto' + +import { createAgentChildWorkAdmission } from '../../../shared/agent-status-child-work-admission' +import { + reconcileStructuredChildWork, + type StructuredChildWorkReconcileOutcome +} from '../../../shared/agent-status-child-work-reconciliation' +import { + STRUCTURED_SUPPORTS_STOP_ALL_FACT, + STRUCTURED_SUPPORTS_TASK_STOP_FACT +} from '../../../shared/agent-status-child-work-structured-egress' +import type { StructuredChildWorkEvidence } from '../../../shared/agent-status-child-work-structured-evidence' +import { + parseAgentStatusSubject, + type AgentStatusStructuredSessionSubject +} from '../../../shared/agent-status-subject' +import { AgentHookServerIngestStructured } from './server-ingest-structured' + +export abstract class AgentHookServerIngestStructuredChildren extends AgentHookServerIngestStructured { + /** + * Admit one structured session's full child-work roster. The parent publication owns + * the subject and lands first; this refuses to act on a subject the store does not + * already hold, so a child can never conjure a parent row. + */ + ingestStructuredChildWork( + subject: AgentStatusStructuredSessionSubject, + evidence: StructuredChildWorkEvidence, + provider: string + ): StructuredChildWorkReconcileOutcome | null { + const parent = parseAgentStatusSubject(subject) + if (!parent || parent.kind !== 'structured-session') { + throw new Error('Structured child work requires its exact owner subject') + } + const store = this.canonicalStatusStore + if (!store.getParent(parent)) { + return null + } + // Provider stop capability is a fact about the session, not about any one child. + store.applyMutation({ + facts: [ + { + subject: parent, + key: STRUCTURED_SUPPORTS_TASK_STOP_FACT, + value: evidence.supportsTaskStop + }, + { subject: parent, key: STRUCTURED_SUPPORTS_STOP_ALL_FACT, value: evidence.supportsStopAll } + ] + }) + const outcome = reconcileStructuredChildWork({ + store, + admission: createAgentChildWorkAdmission(store, { mintChildWorkId: () => randomUUID() }), + parent, + provider, + evidence, + observedAt: Date.now() + }) + if (outcome.rejected.length > 0) { + console.warn( + '[agent-status-child-work] refused structured child admissions', + outcome.rejected.map( + (entry) => `${entry.providerTaskId || entry.childWorkId}:${entry.reason}` + ) + ) + } + return outcome + } + + /** Every canonical child this host holds for one structured session. */ + getStructuredChildWork(subject: AgentStatusStructuredSessionSubject) { + const parent = parseAgentStatusSubject(subject) + return parent ? this.canonicalStatusStore.getChildren(parent) : [] + } +} diff --git a/src/main/agent-hooks/structured-child-work-bridge-parity.test.ts b/src/main/agent-hooks/structured-child-work-bridge-parity.test.ts new file mode 100644 index 000000000000..083901e22c15 --- /dev/null +++ b/src/main/agent-hooks/structured-child-work-bridge-parity.test.ts @@ -0,0 +1,188 @@ +// Parity gate: the host producer's legacy subagent egress against the renderer bridge's +// own projection code, not a restatement of it. `subagentSnapshotsFromTasks` is imported +// from the bridge's module, so a change there reddens this file. +// +// The bridge is still the only live producer of native-chat child rows. This gate is what +// the atomic switch needs before that writer can be turned off. + +import { describe, expect, it } from 'vitest' +import type { + AgentSessionBackgroundTask, + AgentSessionBackgroundTaskState +} from '../../shared/agent-session-background-task-wire' +import { + AGENT_STATUS_MAX_SUBAGENTS, + type AgentSubagentSnapshot +} from '../../shared/agent-status-types' +import { createStructuredChildWorkProducerFixture } from '../../shared/agent-status-child-work-structured-producer.test-fixture' +import { subagentSnapshotsFromTasks } from '../../shared/structured-session-legacy-subagent-projection' + +function roster(tasks: AgentSessionBackgroundTask[]): AgentSessionBackgroundTaskState { + return { state: 'monitoring', tasks, supportsTaskStop: true } +} + +function agent( + id: string, + overrides: Partial = {} +): AgentSessionBackgroundTask { + return { id, kind: 'agent', startedAt: 5, ...overrides } +} + +/** Compare the two projections field by field, holding `startedAt` aside: the bridge + * publishes the provider's own stamp (or `0`), the host publishes its `firstObservedAt`, + * and that difference is asserted on its own below. */ +function withoutStartedAt( + snapshots: AgentSubagentSnapshot[] | undefined +): Omit[] | undefined { + return snapshots?.map(({ startedAt: _startedAt, ...rest }) => rest) +} + +function compare(tasks: AgentSessionBackgroundTask[]): { + bridge: AgentSubagentSnapshot[] | undefined + host: AgentSubagentSnapshot[] | undefined +} { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(roster(tasks)) + return { bridge: subagentSnapshotsFromTasks(tasks), host: producer.subagents() } +} + +function expectParity(tasks: AgentSessionBackgroundTask[]): AgentSubagentSnapshot[] | undefined { + const { bridge, host } = compare(tasks) + expect(withoutStartedAt(host)).toEqual(withoutStartedAt(bridge)) + return host +} + +describe('legacy subagent egress matches the renderer bridge', () => { + it('admits only agent-kind rows while the other kinds stay canonical', () => { + const tasks: AgentSessionBackgroundTask[] = [ + agent('task-agent', { description: 'review' }), + { id: 'task-shell', kind: 'command', startedAt: 6 }, + { id: 'task-watch', kind: 'monitor', startedAt: 7 }, + { id: 'task-flow', kind: 'workflow', startedAt: 8 }, + { id: 'task-other', kind: 'unknown', startedAt: 9 } + ] + const host = expectParity(tasks) + expect(host?.map((row) => row.id)).toEqual(['task-agent']) + + // Positive control: the rows the closed vocabulary drops are still in the collection, + // so an empty subagent projection is a statement about the projection, not the store. + const producer = createStructuredChildWorkProducerFixture() + producer.publish(roster(tasks)) + expect(producer.children()).toHaveLength(5) + // Ordered by first observation then provider id; these share a publication clock. + expect(producer.backgroundTaskState()?.tasks?.map((task) => [task.id, task.kind])).toEqual([ + ['task-agent', 'agent'], + ['task-flow', 'workflow'], + ['task-other', 'unknown'], + ['task-shell', 'command'], + ['task-watch', 'monitor'] + ]) + }) + + it.each([ + ['working', 'working'], + ['monitoring', 'working'], + [undefined, 'working'], + ['done', 'idle'], + ['idle', 'idle'], + ['waiting', 'waiting'], + ['blocked', 'blocked'], + ['unverifiable', 'unverifiable'] + ] as const)('maps provider state %s to %s exactly as the bridge does', (state, expected) => { + const host = expectParity([agent('task-1', state === undefined ? {} : { state })]) + expect(host?.[0]?.state).toBe(expected) + }) + + it('trims provider ids, and admits only non-blank ids of at most 64 characters', () => { + const host = expectParity([ + agent(' task-padded '), + agent(' '), + agent('x'.repeat(64)), + agent('y'.repeat(65)) + ]) + expect(host?.map((row) => row.id)).toEqual(['task-padded', 'x'.repeat(64)]) + }) + + it('caps accepted rows at AGENT_STATUS_MAX_SUBAGENTS with invalid rows interleaved', () => { + for (const count of [31, 32, 33]) { + const tasks: AgentSessionBackgroundTask[] = [] + for (let index = 0; index < count; index += 1) { + // Interleaved rejects must not consume the cap. + tasks.push({ id: `command-${index}`, kind: 'command', startedAt: index }) + tasks.push(agent(`agent-${String(index).padStart(2, '0')}`, { startedAt: index })) + } + const host = expectParity(tasks) + expect(host).toHaveLength(Math.min(count, AGENT_STATUS_MAX_SUBAGENTS)) + } + }) + + it('never lets the bounded projection evict a canonical record', () => { + const producer = createStructuredChildWorkProducerFixture() + const tasks = Array.from({ length: 40 }, (_unused, index) => + agent(`agent-${String(index).padStart(2, '0')}`, { startedAt: index }) + ) + producer.publish(roster(tasks)) + expect(producer.subagents()).toHaveLength(AGENT_STATUS_MAX_SUBAGENTS) + expect(producer.children()).toHaveLength(40) + }) + + it('settles rows the authoritative roster stopped listing, as the bridge drops them', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(roster([agent('task-1'), agent('task-2')])) + producer.publish(roster([agent('task-1')])) + + expect(producer.subagents()?.map((row) => row.id)).toEqual(['task-1']) + expect(subagentSnapshotsFromTasks([agent('task-1')])?.map((row) => row.id)).toEqual(['task-1']) + // Membership moved; the record and its history did not disappear. + const settled = producer.childFor('task-2') + expect(settled).toMatchObject({ membership: 'settled', state: 'idle', outcome: 'unknown' }) + }) +}) + +describe('the intentional timestamp improvement', () => { + it('publishes host firstObservedAt where the bridge published the provider stamp', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(roster([agent('task-1', { startedAt: 5 })]), 4_242) + expect(producer.subagents()?.[0]?.startedAt).toBe(4_242) + expect(subagentSnapshotsFromTasks([agent('task-1', { startedAt: 5 })])?.[0]?.startedAt).toBe(5) + // Provider timing is retained separately rather than overwritten. + expect(producer.childFor('task-1')?.providerTiming).toEqual({ startedAt: 5 }) + }) + + it('replaces the bridge’s `?? 0` with a real host observation', () => { + const producer = createStructuredChildWorkProducerFixture() + const task: AgentSessionBackgroundTask = { id: 'task-1', kind: 'agent' } + producer.publish(roster([task]), 7_777) + expect(subagentSnapshotsFromTasks([task])?.[0]?.startedAt).toBe(0) + expect(producer.subagents()?.[0]?.startedAt).toBe(7_777) + expect(producer.childFor('task-1')?.providerTiming).toBeUndefined() + }) + + it('keeps first-observation time stable across later updates', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(roster([agent('task-1')]), 1_000) + producer.publish(roster([agent('task-1', { totalTokens: 12 })]), 9_000) + expect(producer.subagents()?.[0]?.startedAt).toBe(1_000) + }) +}) + +describe('declared deviations from the bridge', () => { + it('refuses a provider id carrying control characters that the bridge admits', () => { + const task = agent('taskone') + // The bridge only trims and length-checks, so it publishes the row. + expect(subagentSnapshotsFromTasks([task])?.[0]?.id).toBe('taskone') + // A canonical alias is a serialized key; a control character cannot be one. + const producer = createStructuredChildWorkProducerFixture() + producer.publish(roster([task])) + expect(producer.children()).toHaveLength(0) + expect(producer.subagents()).toBeUndefined() + }) + + it('orders the bounded projection by first observation, not by current roster order', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(roster([agent('b-task'), agent('a-task')]), 1_000) + // The provider reorders; the host keeps the order it first observed them in. + producer.publish(roster([agent('a-task'), agent('b-task')]), 2_000) + expect(producer.subagents()?.map((row) => row.id)).toEqual(['a-task', 'b-task']) + }) +}) diff --git a/src/main/agent-hooks/structured-child-work-producer.test.ts b/src/main/agent-hooks/structured-child-work-producer.test.ts new file mode 100644 index 000000000000..84f077e90f26 --- /dev/null +++ b/src/main/agent-hooks/structured-child-work-producer.test.ts @@ -0,0 +1,313 @@ +// The adoption conditions the architecture review set for this producer, exercised +// through the real decoder, reconciler, admission API and authority store. + +import { describe, expect, it } from 'vitest' +import type { + AgentSessionBackgroundTask, + AgentSessionBackgroundTaskState +} from '../../shared/agent-session-background-task-wire' +import { authorizeAgentChildWorkStop } from '../../shared/agent-status-child-work-stop' +import { createAgentStatusStore } from '../../shared/agent-status-store' +import { + createStructuredChildWorkProducerFixture, + fixtureSubject +} from '../../shared/agent-status-child-work-structured-producer.test-fixture' + +function roster( + tasks: AgentSessionBackgroundTask[], + overrides: Partial = {} +): AgentSessionBackgroundTaskState { + return { state: 'monitoring', tasks, supportsTaskStop: true, ...overrides } +} + +function agent( + id: string, + overrides: Partial = {} +): AgentSessionBackgroundTask { + return { id, kind: 'agent', ...overrides } +} + +describe('one child per logical task', () => { + it('admits one child for duplicate producer evidence and re-announcement', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(roster([agent('task-1', { description: 'first' })])) + producer.publish(roster([agent('task-1', { description: 'first' })])) + producer.publish(roster([agent('task-1', { description: 'renamed' })])) + + expect(producer.children()).toHaveLength(1) + expect(producer.mintedCount).toBe(1) + expect(producer.childFor('task-1')).toMatchObject({ + childWorkId: 'child-1', + description: 'renamed', + membership: 'live' + }) + }) + + it('keeps one child when the same id arrives in both rosters at once', () => { + const producer = createStructuredChildWorkProducerFixture() + // A provider that lists an id as settled and live in one frame is stating it is live. + producer.publish(roster([agent('task-1')], { settledTasks: [agent('task-1')] })) + + expect(producer.children()).toHaveLength(1) + expect(producer.childFor('task-1')?.membership).toBe('live') + }) + + it('keeps the child id when an unknown task is positively reclassified', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(roster([{ id: 'task-1', kind: 'unknown' }])) + const provisional = producer.childFor('task-1') + expect(provisional).toMatchObject({ childWorkId: 'child-1', kind: 'unknown' }) + + producer.publish(roster([agent('task-1', { name: 'reviewer' })])) + + expect(producer.mintedCount).toBe(1) + expect(producer.childFor('task-1')).toMatchObject({ + childWorkId: 'child-1', + kind: 'agent', + name: 'reviewer' + }) + // The provisional kind's alias is retired rather than left answering for the row. + expect(producer.store.getAliasesForChild('child-1').map((alias) => alias.kind)).toEqual([ + 'agent' + ]) + }) +}) + +describe('distinct identities for proven distinct lifetimes', () => { + it('gives a revived provider id a new invocation while keeping its logical id', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(roster([agent('task-1')])) + producer.publish(roster([], { settledTasks: [agent('task-1', { state: 'done' })] })) + expect(producer.childFor('task-1')).toMatchObject({ + membership: 'settled', + invocation: { generation: 0 } + }) + + producer.publish(roster([agent('task-1')])) + + const revived = producer.childFor('task-1') + expect(producer.mintedCount).toBe(1) + expect(revived).toMatchObject({ + childWorkId: 'child-1', + membership: 'live', + invocation: { generation: 1 } + }) + // The settled invocation is retained rather than overwritten. + expect(revived?.previousInvocations).toEqual([ + { fence: expect.objectContaining({ generation: 0 }), outcome: 'unknown', settledAt: 1_002 } + ]) + }) + + it('does not join one provider id across parents or execution scopes', () => { + const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + const local = createStructuredChildWorkProducerFixture({ store }) + const remote = createStructuredChildWorkProducerFixture({ + store, + parent: fixtureSubject({ executionHostId: 'ssh:second-host' }), + idPrefix: 'remote' + }) + local.publish(roster([agent('task-1')])) + remote.publish(roster([agent('task-1')])) + + // Assert both resolved before comparing them: two undefineds would differ too. + expect(local.childFor('task-1')?.childWorkId).toBe('child-1') + expect(remote.childFor('task-1')?.childWorkId).toBe('remote-1') + expect(store.getChildren(local.parent)).toHaveLength(1) + expect(store.getChildren(remote.parent)).toHaveLength(1) + }) + + it('leaves another producer’s children alone', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(roster([agent('task-1')])) + const foreign = producer.store.applyMutation({ + children: [ + { + childWorkId: 'hook-child', + parent: producer.parent, + provider: 'claude', + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 1, + observedAt: 1, + stoppable: false, + invocation: { invocationId: 'hooks', generation: 0 }, + provenance: { source: 'hook', producerId: 'agent-hooks' } + } + ] + }) + expect(foreign).not.toBeNull() + + // An empty roster settles this producer's rows and must not touch the hook's. + producer.publish(roster([])) + + expect(producer.store.getChild('hook-child')).toMatchObject({ membership: 'live' }) + expect(producer.childFor('task-1')?.membership).toBe('settled') + }) +}) + +describe('identity survives restore and republication', () => { + it('reconstructs the collection on restore without reminting children', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(roster([agent('task-1'), agent('task-2')])) + const snapshot = producer.store.getSnapshot() + + const restored = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + expect(restored.applySnapshot(snapshot)).toBe(true) + const afterRestart = createStructuredChildWorkProducerFixture({ + store: restored, + withParent: false + }) + afterRestart.publish(roster([agent('task-1'), agent('task-2')]), 5_000) + + expect(afterRestart.mintedCount).toBe(0) + expect( + afterRestart + .children() + .map((child) => child.childWorkId) + .sort() + ).toEqual(['child-1', 'child-2']) + expect(afterRestart.childFor('task-1')?.firstObservedAt).toBe(1_001) + }) + + it('keeps background work alive across turns and a reset that leaves the roster intact', () => { + const producer = createStructuredChildWorkProducerFixture() + const live = roster([agent('task-1', { state: 'working' })]) + producer.publish(live, 1_000) + // A new turn, and then a /clear: the provider roster still lists the task, so the + // host has no evidence the work stopped. + producer.publish(live, 2_000) + producer.publish(live, 3_000) + + expect(producer.childFor('task-1')).toMatchObject({ + childWorkId: 'child-1', + membership: 'live', + firstObservedAt: 1_000 + }) + }) +}) + +describe('invocation fencing', () => { + it('refuses a stop addressed to a superseded invocation', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(roster([agent('task-1')])) + const first = producer.childFor('task-1') + expect(first).not.toBeNull() + producer.publish(roster([], { settledTasks: [agent('task-1', { state: 'done' })] })) + producer.publish(roster([agent('task-1')])) + + const stale = authorizeAgentChildWorkStop(producer.store, { + parent: producer.parent, + childWorkId: 'child-1', + expectedFence: first!.invocation + }) + expect(stale).toBeNull() + + const current = producer.childFor('task-1')! + expect( + authorizeAgentChildWorkStop(producer.store, { + parent: producer.parent, + childWorkId: 'child-1', + expectedFence: current.invocation + }) + ).toMatchObject({ childWorkId: 'child-1' }) + }) + + it('refuses a stop for a row no provider can target, and for a settled row', () => { + const producer = createStructuredChildWorkProducerFixture() + // Codex publishes no `supportsTaskStop`; the host asserts nothing it cannot do. + producer.publish({ state: 'monitoring', tasks: [agent('task-1')], supportsStopAll: false }) + const child = producer.childFor('task-1')! + expect(child.stoppable).toBe(false) + expect( + authorizeAgentChildWorkStop(producer.store, { + parent: producer.parent, + childWorkId: child.childWorkId, + expectedFence: child.invocation + }) + ).toBeNull() + + const stoppable = createStructuredChildWorkProducerFixture() + stoppable.publish(roster([agent('task-1')])) + expect(stoppable.childFor('task-1')?.stoppable).toBe(true) + stoppable.publish(roster([], { settledTasks: [agent('task-1', { state: 'done' })] })) + const settled = stoppable.childFor('task-1')! + expect(settled.stoppable).toBe(false) + expect( + authorizeAgentChildWorkStop(stoppable.store, { + parent: stoppable.parent, + childWorkId: settled.childWorkId, + expectedFence: settled.invocation + }) + ).toBeNull() + }) +}) + +describe('honest uncertainty and full evidence', () => { + it('treats an authoritative empty roster as lost membership, never as an outcome', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(roster([agent('task-1')])) + // `null` is the provider reporting no live work, not a lost connection. + producer.publish(null) + + expect(producer.childFor('task-1')).toMatchObject({ + membership: 'settled', + state: 'idle', + outcome: 'unknown' + }) + expect(producer.children()).toHaveLength(1) + }) + + it('separates a provider-reported terminal state from live membership', () => { + const producer = createStructuredChildWorkProducerFixture() + // A row the provider still lists is live work even when its own state reads done; + // an idle reusable agent is not therefore settled. + producer.publish( + roster([agent('task-1', { state: 'done' }), agent('task-2', { state: 'idle' })]) + ) + + expect(producer.childFor('task-1')).toMatchObject({ membership: 'live', state: 'done' }) + expect(producer.childFor('task-2')).toMatchObject({ membership: 'live', state: 'idle' }) + expect(producer.childFor('task-1')?.outcome).toBeUndefined() + }) + + it('retains usage, settled history and stop capability before any projection', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish( + roster([agent('task-live', { totalTokens: 1_200, description: 'live work' })], { + settledTasks: [agent('task-done', { state: 'done', totalTokens: 90 })], + supportsStopAll: false + }) + ) + + expect(producer.childFor('task-live')?.totalTokens).toBe(1_200) + expect(producer.childFor('task-done')).toMatchObject({ membership: 'settled', totalTokens: 90 }) + const projected = producer.backgroundTaskState() + expect(projected?.tasks?.map((task) => task.id)).toEqual(['task-live']) + expect(projected?.settledTasks?.map((task) => task.id)).toEqual(['task-done']) + expect(projected).toMatchObject({ supportsTaskStop: true, supportsStopAll: false }) + // The summary drops usage on purpose; the collection does not. + expect(projected?.tasks?.[0]?.totalTokens).toBe(1_200) + }) + + it('advances a child on a usage-only change that leaves the summary equal', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(roster([agent('task-1', { totalTokens: 10 })]), 1_000) + const before = producer.childFor('task-1')! + producer.publish(roster([agent('task-1', { totalTokens: 20 })]), 2_000) + const after = producer.childFor('task-1')! + + expect(after.totalTokens).toBe(20) + expect(after.revision).toBeGreaterThan(before.revision) + expect(after.childWorkId).toBe(before.childWorkId) + }) + + it('refuses children for a parent the store does not hold', () => { + const producer = createStructuredChildWorkProducerFixture({ withParent: false }) + const outcome = producer.publish(roster([agent('task-1')])) + + expect(outcome.announced).toBe(0) + expect(outcome.rejected).toEqual([{ providerTaskId: 'task-1', reason: 'invalid' }]) + expect(producer.children()).toHaveLength(0) + }) +}) diff --git a/src/main/agent-hooks/structured-child-work-reader-agreement.test.ts b/src/main/agent-hooks/structured-child-work-reader-agreement.test.ts new file mode 100644 index 000000000000..68e5b25a4b76 --- /dev/null +++ b/src/main/agent-hooks/structured-child-work-reader-agreement.test.ts @@ -0,0 +1,128 @@ +// Both surface projections read one committed revision of one collection, with no +// renderer in the process. This is the condition the atomic switch turns on. + +import { describe, expect, it } from 'vitest' +import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-background-task-wire' +import { + projectStructuredChildWorkBackgroundTaskState, + projectStructuredChildWorkSubagents, + structuredChildWorkCandidates +} from '../../shared/agent-status-child-work-structured-egress' +import { createStructuredChildWorkProducerFixture } from '../../shared/agent-status-child-work-structured-producer.test-fixture' + +const ROSTER: AgentSessionBackgroundTaskState = { + state: 'monitoring', + tasks: [ + { id: 'task-agent', kind: 'agent', description: 'review', totalTokens: 40 }, + { id: 'task-shell', kind: 'command', description: 'build' } + ], + settledTasks: [{ id: 'task-old', kind: 'agent', state: 'done' }], + supportsTaskStop: true +} + +describe('the strip and the sidebar read one collection', () => { + it('projects both surfaces from the same committed revision', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(ROSTER) + const revision = producer.store.getSnapshot().revision + + const strip = projectStructuredChildWorkBackgroundTaskState(producer.store, producer.parent) + const sidebar = projectStructuredChildWorkSubagents(producer.store, producer.parent) + + // Reading twice must not advance the store: these are selectors, not producers. + expect(producer.store.getSnapshot().revision).toBe(revision) + // The strip keeps all five kinds and the settled row; the sidebar keeps live agents. + expect(strip?.tasks?.map((task) => task.id)).toEqual(['task-agent', 'task-shell']) + expect(strip?.settledTasks?.map((task) => task.id)).toEqual(['task-old']) + expect(sidebar?.map((row) => row.id)).toEqual(['task-agent']) + + // Both answer from the same records: every projected row is a canonical candidate. + const canonical = structuredChildWorkCandidates(producer.store, producer.parent) + expect(canonical.map((candidate) => candidate.providerId).sort()).toEqual([ + 'task-agent', + 'task-old', + 'task-shell' + ]) + for (const row of sidebar ?? []) { + expect(canonical.some((candidate) => candidate.providerId === row.id)).toBe(true) + } + }) + + it('moves both surfaces together when one child changes', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(ROSTER, 1_000) + producer.publish( + { + ...ROSTER, + tasks: [ + { id: 'task-agent', kind: 'agent', description: 'review', state: 'blocked' }, + { id: 'task-shell', kind: 'command', description: 'build' } + ] + }, + 2_000 + ) + + expect(projectStructuredChildWorkSubagents(producer.store, producer.parent)?.[0]?.state).toBe( + 'blocked' + ) + expect( + projectStructuredChildWorkBackgroundTaskState(producer.store, producer.parent)?.tasks?.[0] + ?.state + ).toBe('blocked') + }) + + it('reports no roster at all when the collection holds none for this parent', () => { + const producer = createStructuredChildWorkProducerFixture() + expect( + projectStructuredChildWorkBackgroundTaskState(producer.store, producer.parent) + ).toBeNull() + expect(projectStructuredChildWorkSubagents(producer.store, producer.parent)).toBeUndefined() + }) +}) + +describe('a forgotten parent takes its children with it', () => { + it('retires the whole subtree so no child outlives the session that owned it', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(ROSTER) + expect(producer.children()).toHaveLength(3) + + expect(producer.store.applyMutation({ removeParent: producer.parent })).not.toBeNull() + + expect(producer.children()).toHaveLength(0) + expect(producer.store.getAliasesForChild('child-1')).toEqual([]) + expect(projectStructuredChildWorkSubagents(producer.store, producer.parent)).toBeUndefined() + }) + + it('fences a late roster arriving after the parent was forgotten', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(ROSTER) + producer.store.applyMutation({ removeParent: producer.parent }) + + // Bookkeeping must not throw into the publication path; the refusal is reported. + const outcome = producer.publish(ROSTER) + + expect(outcome.announced).toBe(0) + expect(outcome.rejected.map((entry) => entry.reason)).toEqual(['invalid', 'invalid', 'invalid']) + expect(producer.children()).toHaveLength(0) + }) + + it('frees the retired provider ids when the session is published again', () => { + const producer = createStructuredChildWorkProducerFixture() + producer.publish(ROSTER) + const original = producer.childFor('task-agent')?.childWorkId + producer.store.applyMutation({ removeParent: producer.parent }) + producer.store.applyMutation({ parent: { subject: producer.parent } }) + + producer.publish(ROSTER) + + // A retired binding fences late observations for the lifetime it named; it is not a + // permanent ban on the provider id, or a re-attached session would show no children + // for as long as the tombstone is retained. + const readmitted = producer.childFor('task-agent') + expect(readmitted).not.toBeNull() + expect(readmitted?.childWorkId).not.toBe(original) + expect(readmitted?.invocation.generation).toBe(1) + expect(producer.children()).toHaveLength(3) + expect(projectStructuredChildWorkSubagents(producer.store, producer.parent)).toHaveLength(1) + }) +}) diff --git a/src/main/claude/claude-background-task-journal-admission.test.ts b/src/main/claude/claude-background-task-journal-admission.test.ts new file mode 100644 index 000000000000..bc8cfca88e4d --- /dev/null +++ b/src/main/claude/claude-background-task-journal-admission.test.ts @@ -0,0 +1,129 @@ +// What Claude's background-task channel actually guarantees about journal admission. +// +// Codex proves its guarantee structurally: `emit` refuses to observe or notify until +// `translator.handle` returns an accepted admission. Claude has no admission result to +// check, so copying that callback ordering would have been an assumption. These tests +// establish the two properties the host child producer really depends on, and the one +// that does NOT hold — which is why the canonical child collection is keyed on the +// background-task channel rather than on transcript rows. + +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + adapterFor, + fakeClaude, + identityFor, + PROVIDER_SESSION_ID, + tick +} from './claude-structured-session-test-support' + +const FORWARDED_TOOL = 'tool-forwarded' + +async function harness() { + const order: string[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: vi.fn(() => { + order.push('journal') + }), + appendTombstone: () => {}, + publish: () => {} + } + const claude = fakeClaude() + const adapter = adapterFor( + claude, + {}, + [], + undefined, + undefined, + undefined, + undefined, + (_sessionId, state) => { + order.push(`notify:${state?.tasks?.length ?? 0}`) + } + ) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: sink + }) + const send = async (message: Record): Promise => { + claude.connections[0]?.handlers.onMessage?.(message) + await tick() + } + return { order, adapter, send } +} + +function assistantToolUse(toolUseId: string): Record { + return { + type: 'assistant', + uuid: `assistant-${toolUseId}`, + session_id: PROVIDER_SESSION_ID, + parent_tool_use_id: null, + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: toolUseId, name: 'Bash', input: { command: 'wait' } }] + } + } +} + +function taskStarted(taskId: string, toolUseId: string): Record { + return { + type: 'system', + subtype: 'task_started', + session_id: PROVIDER_SESSION_ID, + uuid: `uuid-${taskId}`, + task_id: taskId, + tool_use_id: toolUseId, + task_type: 'local_bash', + description: 'Wait for the verification verdict', + is_backgrounded: true + } +} + +describe('Claude background-task journal admission', () => { + it('journals a task row before it announces that task to the host', async () => { + const { order, adapter, send } = await harness() + await send(assistantToolUse(FORWARDED_TOOL)) + order.length = 0 + + await send(taskStarted('task-1', FORWARDED_TOOL)) + + const notified = order.findIndex((entry) => entry.startsWith('notify:')) + expect(notified).toBeGreaterThanOrEqual(0) + // The guarantee the producer relies on: the durable write for this frame has already + // run when the host is told, so the collection never leads the journal. + expect(order.indexOf('journal')).toBeGreaterThanOrEqual(0) + expect(order.indexOf('journal')).toBeLessThan(notified) + await adapter.closeAll() + }) + + it('still announces a task whose spawning tool never reached the transcript', async () => { + const { order, adapter, send } = await harness() + order.length = 0 + + // `isForwardedParentTool` declines a transcript row here on purpose: a top-level row + // would claim an invocation the user never saw. + await send(taskStarted('task-nested', 'tool-never-forwarded')) + + expect(order).toContain('notify:1') + // So "the host was told" does NOT imply "a transcript row exists". A producer that + // ingested journal items would silently lose this child; one keyed on the + // background-task channel keeps it. + expect(order).not.toContain('journal') + await adapter.closeAll() + }) + + it('agrees with the snapshot the background-task channel re-reads', async () => { + const { adapter, send } = await harness() + await send(assistantToolUse(FORWARDED_TOOL)) + await send(taskStarted('task-1', FORWARDED_TOOL)) + + // The channel answers snapshots from `adapter.backgroundTaskState`, not from the + // callback payload; a producer fed by either must see the same roster. + const snapshot = adapter.backgroundTaskState?.(identityFor().sessionId) + expect(snapshot?.tasks?.map((task) => task.id)).toEqual(['task-1']) + expect(snapshot?.supportsTaskStop).toBe(true) + await adapter.closeAll() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 2ff6697eae95..cd1d93178b62 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -150,7 +150,9 @@ async function workingSession(): Promise<{ now: () => 1, statusSink: () => ({ publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), - forget: (subject) => server.dropStructuredStatus(subject) + forget: (subject) => server.dropStructuredStatus(subject), + publishChildren: (subject, evidence, provider) => + server.ingestStructuredChildWork(subject, evidence, provider) }) }) feed.publish(SESSION, journal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index e5d3d16e5dd8..7a00c591b309 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -771,7 +771,8 @@ describe('the status sink sees the roster the broadcast cache deliberately lacks const forgotten: Parameters[0][] = [] const sink: StructuredAgentSessionStatusSink = { publish: (summary) => published.push(summary), - forget: (sessionId) => forgotten.push(sessionId) + forget: (sessionId) => forgotten.push(sessionId), + publishChildren: () => undefined } return { sink, published, forgotten } } @@ -830,6 +831,7 @@ describe('the status sink sees the roster the broadcast cache deliberately lacks publish: () => { throw new Error('store down') }, + publishChildren: () => undefined, forget: () => { throw new Error('store down') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index cecd5455ca7d..5c19e5cc0664 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -20,6 +20,7 @@ import { type AgentSessionStatusEvent, type AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { decodeStructuredChildWorkEvidence } from '../../../shared/agent-status-child-work-structured-evidence' import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result' @@ -193,10 +194,14 @@ export class StructuredAgentSessionStatusFeed { if (!this.ownership.matchesLocation(sessionId, session.params.location)) { this.sink(summary, session.params.location) } + // A token-only child change leaves the summary equal by design, so the child + // roster is offered on every projection rather than behind that equality gate. + this.sinkChildren(sessionId, session) return } this.published.set(sessionId, summary) this.sink(summary, session.params.location) + this.sinkChildren(sessionId, session) this.broadcast({ type: 'status', session: summary }) try { this.deps.onStatusChanged?.(summary, { replay: options?.replay === true }) @@ -265,6 +270,24 @@ export class StructuredAgentSessionStatusFeed { } } + /** The adapter's full roster, decoded inside the guard: `undefined` is a session the + * adapter does not hold — absence of evidence, never an empty roster. */ + private sinkChildren(sessionId: string, session: StatusFeedSession): void { + try { + const state = this.deps.readBackgroundTasks?.(sessionId) + if (state === undefined) { + return + } + this.ownership.publishChildren( + sessionId, + decodeStructuredChildWorkEvidence(state), + session.params.provider + ) + } catch (error) { + console.warn('[structured-session-status] child work publish failed', error) + } + } + /** A failing sink must never cost the subscribers their status event. */ private sink( summary: AgentSessionStatusSummary, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts index 7e9e6da84a36..1115587ce9f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts @@ -21,7 +21,7 @@ const summary: AgentSessionStatusSummary = { describe('structured status owner address retention', () => { it('retains scope through record deletion and does not resurrect after forget', () => { - const sink = { publish: vi.fn(), forget: vi.fn() } + const sink = { publish: vi.fn(), forget: vi.fn(), publishChildren: vi.fn() } const owner = new StructuredAgentSessionStatusOwnership(() => sink) const subject = makeStructuredAgentStatusSubject(location, summary.sessionId) owner.publish(summary, location) @@ -36,7 +36,7 @@ describe('structured status owner address retention', () => { }) it('forgets the old exact scope before publishing a trusted location change', () => { - const sink = { publish: vi.fn(), forget: vi.fn() } + const sink = { publish: vi.fn(), forget: vi.fn(), publishChildren: vi.fn() } const owner = new StructuredAgentSessionStatusOwnership(() => sink) owner.publish(summary, location) const replacement = { ...location, executionHostId: 'ssh:second-host' as const } @@ -58,7 +58,8 @@ describe('structured status owner address retention', () => { publish: vi.fn().mockImplementationOnce(() => { throw new Error('store down') }), - forget: vi.fn() + forget: vi.fn(), + publishChildren: vi.fn() } const owner = new StructuredAgentSessionStatusOwnership(() => sink) expect(() => owner.publish(summary, location)).toThrow('store down') @@ -75,7 +76,8 @@ describe('structured status owner address retention', () => { publish: vi.fn(() => { throw new Error('observer failed') }), - forget: vi.fn() + forget: vi.fn(), + publishChildren: vi.fn() } const owner = new StructuredAgentSessionStatusOwnership(() => sink) expect(() => owner.publish(summary, location)).toThrow('observer failed') @@ -86,7 +88,7 @@ describe('structured status owner address retention', () => { }) it('does not fabricate location for an unknown session or an unavailable sink', () => { - const sink = { publish: vi.fn(), forget: vi.fn() } + const sink = { publish: vi.fn(), forget: vi.fn(), publishChildren: vi.fn() } const owner = new StructuredAgentSessionStatusOwnership(() => sink) owner.publish(summary) expect(sink.publish).not.toHaveBeenCalled() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts index 088879f8ddc2..d24058ae9130 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts @@ -1,5 +1,9 @@ -import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { + AgentSessionExecutionLocation, + AgentSessionRecord +} from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { StructuredChildWorkEvidence } from '../../../shared/agent-status-child-work-structured-evidence' import { parseAgentStatusSubject, serializeAgentStatusSubject, @@ -12,6 +16,14 @@ export type StructuredAgentSessionStatusSink = { subject: AgentStatusStructuredSessionSubject ) => void forget: (subject: AgentStatusStructuredSessionSubject) => void + /** The session's full child-work roster, addressed by the same trusted subject the + * parent row landed under. Separate from `publish` because a usage-only child change + * leaves the summary equal and must still reach the collection. */ + publishChildren: ( + subject: AgentStatusStructuredSessionSubject, + evidence: StructuredChildWorkEvidence, + provider: AgentSessionRecord['provider'] + ) => void } /** Retain the owner address because record removal may precede the final status callback. */ @@ -63,6 +75,20 @@ export class StructuredAgentSessionStatusOwnership { this.landed.add(summary.sessionId) } + /** Children ride the address the parent landed under: without that proof the store + * would refuse them anyway, and offering them earlier would race the parent row. */ + publishChildren( + sessionId: string, + evidence: StructuredChildWorkEvidence, + provider: AgentSessionRecord['provider'] + ): void { + const subject = this.subjects.get(sessionId) + if (!subject || !this.landed.has(sessionId)) { + return + } + this.sink()?.publishChildren(subject, evidence, provider) + } + forget(sessionId: string): void { const subject = this.subjects.get(sessionId) if (!subject) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts index 57218d0f4bdc..d831ae2b1b48 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts @@ -57,7 +57,10 @@ async function createFeed() { const server = new AgentHookServer() const statusSink: StructuredAgentSessionStatusSink = { publish: vi.fn((summary, subject) => server.ingestStructuredStatus(summary, subject)), - forget: vi.fn((subject) => server.dropStructuredStatus(subject)) + forget: vi.fn((subject) => server.dropStructuredStatus(subject)), + publishChildren: vi.fn((subject, evidence, provider) => + server.ingestStructuredChildWork(subject, evidence, provider) + ) } const feed = new StructuredAgentSessionStatusFeed({ sessions, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index e26e65d1db45..de2d2879b3c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -232,7 +232,7 @@ beforeEach(async () => { resetHostTestOperationIds() sink = null hostErrors = [] - statusSink = { publish: vi.fn(), forget: vi.fn() } + statusSink = { publish: vi.fn(), forget: vi.fn(), publishChildren: vi.fn() } let generation = 0 acquire = vi.fn(async ({ fence, spawnToken, events }) => { sink = events ?? null diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index fb22f92c392e..742e3b96b2c1 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -245,7 +245,9 @@ async function startOrcadRuntime( readObservedAgentStatusPaneIdentity: (paneKey) => observedPaneIdentities.read(paneKey), structuredAgentStatusSink: { publish: (summary, subject) => agentHookServer.ingestStructuredStatus(summary, subject), - forget: (subject) => agentHookServer.dropStructuredStatus(subject) + forget: (subject) => agentHookServer.dropStructuredStatus(subject), + publishChildren: (subject, evidence, provider) => + agentHookServer.ingestStructuredChildWork(subject, evidence, provider) }, reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), diff --git a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts index decaa5cc5994..3a81c471ae4d 100644 --- a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts +++ b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts @@ -73,7 +73,11 @@ describe('every host that constructs a runtime wires the agent-status store', () describe('structured status sink wiring', () => { it('hands the host the sink the runtime was constructed with', async () => { installed.deps = null - const sink: StructuredAgentSessionStatusSink = { publish: vi.fn(), forget: vi.fn() } + const sink: StructuredAgentSessionStatusSink = { + publish: vi.fn(), + forget: vi.fn(), + publishChildren: vi.fn() + } const runtime = new OrcaRuntimeService(null, undefined, { structuredAgentStatusSink: sink }) await runtime.ensureStructuredAgentSessionHost() diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index b1e43a9025d2..0209c73f8569 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -113,7 +113,9 @@ async function awaitingApproval() { published.push(summary) store.ingestStructuredStatus(summary, subject) }, - forget: (sessionId) => store.dropStructuredStatus(sessionId) + forget: (sessionId) => store.dropStructuredStatus(sessionId), + publishChildren: (subject, evidence, provider) => + store.ingestStructuredChildWork(subject, evidence, provider) }) }) feed.publish(SESSION, journal) diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 62716bbcdec3..dccadaf0ef1d 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -96,7 +96,9 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { // snapshot above then lists them for the CLI and mobile without a second store. structuredAgentStatusSink: { publish: (summary, subject) => agentHookServer.ingestStructuredStatus(summary, subject), - forget: (subject) => agentHookServer.dropStructuredStatus(subject) + forget: (subject) => agentHookServer.dropStructuredStatus(subject), + publishChildren: (subject, evidence, provider) => + agentHookServer.ingestStructuredChildWork(subject, evidence, provider) }, // Why captured rather than resolved at read: the fleet snapshot remints cached rows on every // read, so a row observed under one process otherwise acquires whatever the pane owns now. diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 1017c5e87b63..c0076399a61c 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -1,16 +1,9 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' import { useShallow } from 'zustand/react/shallow' import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' -import type { - AgentSessionBackgroundTask, - AgentSessionStatusSummary -} from '../../../../shared/agent-session-wire' -import { - AGENT_STATUS_MAX_SUBAGENTS, - agentSubagentsEqual, - type AgentSubagentSnapshot, - type AgentSubagentState -} from '../../../../shared/agent-status-types' +import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' +import { agentSubagentsEqual } from '../../../../shared/agent-status-types' +import { subagentSnapshotsFromTasks } from '../../../../shared/structured-session-legacy-subagent-projection' import { structuredAgentSessionPaneKey, structuredAgentSessionStatusState @@ -74,58 +67,6 @@ function useStructuredAgentSessionStatusSummary( return { summary, observation } } -/** Matches the wire-parse bound in `normalizeSubagentSnapshot`. */ -const SUBAGENT_ID_MAX_LENGTH = 64 - -function subagentStateFromTask(task: AgentSessionBackgroundTask): AgentSubagentState { - switch (task.state) { - case 'waiting': - return 'waiting' - case 'blocked': - return 'blocked' - case 'done': - case 'idle': - return 'idle' - case 'unverifiable': - return 'unverifiable' - // Absent state is an old host's live task; live means working here. - case 'working': - case 'monitoring': - case undefined: - return 'working' - } -} - -/** Sidebar children for a structured session: the agent-kind background tasks - * the host publishes, mapped to the sidebar's own subagent vocabulary rather - * than widening it. Kinds stay distinct — a backgrounded shell never counts - * as a subagent. */ -function subagentSnapshotsFromTasks( - tasks: AgentSessionBackgroundTask[] | undefined -): AgentSubagentSnapshot[] | undefined { - if (!tasks) { - return undefined - } - const snapshots: AgentSubagentSnapshot[] = [] - for (const task of tasks) { - const id = task.id.trim() - if (task.kind !== 'agent' || id.length === 0 || id.length > SUBAGENT_ID_MAX_LENGTH) { - continue - } - snapshots.push({ - id, - state: subagentStateFromTask(task), - startedAt: task.startedAt ?? 0, - ...(task.name ? { agentType: task.name } : {}), - ...(task.description ? { description: task.description } : {}) - }) - if (snapshots.length >= AGENT_STATUS_MAX_SUBAGENTS) { - break - } - } - return snapshots.length > 0 ? snapshots : undefined -} - function projectStatus( tab: StructuredTab, summary: AgentSessionStatusSummary | null, diff --git a/src/shared/agent-status-child-work-reconciliation.ts b/src/shared/agent-status-child-work-reconciliation.ts new file mode 100644 index 000000000000..626e8e8e6b16 --- /dev/null +++ b/src/shared/agent-status-child-work-reconciliation.ts @@ -0,0 +1,333 @@ +// Reconcile one parent's structured child work against the canonical collection. +// +// The provider roster is a full inventory, so this reconciles membership rather than +// applying edits: a task the roster stopped listing leaves the live set. It never +// claims an outcome the channel cannot carry, and it owns only the children its own +// producer admitted — another producer's rows are not its to settle. + +import type { + AgentChildWorkAdmission, + AgentChildWorkAdmissionResult, + AgentChildWorkObservationAlias, + AgentChildWorkObservationFields +} from './agent-status-child-work-admission' +import type { + AgentChildWorkAliasInput, + AgentChildWorkAliasRecord +} from './agent-status-child-work-alias' +import { + AGENT_CHILD_WORK_KINDS, + agentChildWorkFencesEqual, + type AgentChildWorkRecord +} from './agent-status-child-work' +import { + STRUCTURED_CHILD_WORK_MAX_TASKS, + STRUCTURED_CHILD_WORK_PRODUCER_ID, + STRUCTURED_CHILD_WORK_SEGMENT_ID, + type StructuredChildWorkEvidence, + type StructuredChildWorkObservation +} from './agent-status-child-work-structured-evidence' +import type { AgentStatusStore } from './agent-status-store' +import type { AgentStatusSubject } from './agent-status-subject' + +/** The invocation of a structured background child is named by its producer channel; + * only a proven revival — the provider reporting a settled id as live again — + * advances the generation. */ +const INVOCATION_ID = STRUCTURED_CHILD_WORK_SEGMENT_ID + +export type StructuredChildWorkReconcileInput = { + store: AgentStatusStore + admission: AgentChildWorkAdmission + parent: AgentStatusSubject + provider: string + evidence: StructuredChildWorkEvidence + observedAt: number +} + +export type StructuredChildWorkReconcileOutcome = { + announced: number + adopted: number + resumed: number + settled: number + /** Admissions the store or the guards refused, by reason. Never thrown: a refusal + * is a fact about one child, not a reason to drop the whole roster. */ + rejected: { childWorkId?: string; providerTaskId: string; reason: string }[] +} + +function probeAliases( + parent: AgentStatusSubject, + provider: string, + providerTaskIds: readonly string[] +): AgentChildWorkAliasInput[] { + const probes: AgentChildWorkAliasInput[] = [] + for (const alias of providerTaskIds) { + // The alias key carries `kind`, so a reclassified child answers under its old + // kind alone; probing every kind is what keeps promotion from reminting an id. + for (const kind of AGENT_CHILD_WORK_KINDS) { + probes.push({ + parent, + provider, + segmentId: STRUCTURED_CHILD_WORK_SEGMENT_ID, + kind, + aliasKind: 'task_id', + alias, + childWorkId: 'unresolved-child', + fence: { invocationId: INVOCATION_ID, generation: 0 } + }) + } + } + return probes +} + +function observationAlias(providerTaskId: string): AgentChildWorkObservationAlias { + return { + segmentId: STRUCTURED_CHILD_WORK_SEGMENT_ID, + aliasKind: 'task_id', + alias: providerTaskId + } +} + +function ownedByProducer(child: AgentChildWorkRecord): boolean { + return ( + child.provenance.source === 'structured-session' && + child.provenance.producerId === STRUCTURED_CHILD_WORK_PRODUCER_ID + ) +} + +function fields( + observation: StructuredChildWorkObservation, + observedAt: number +): AgentChildWorkObservationFields { + return { + kind: observation.kind, + state: observation.state, + membership: observation.membership, + // This channel has no failure or cancellation vocabulary, so a settled row + // records that its outcome was never reported rather than claiming success. + ...(observation.membership === 'settled' ? { outcome: 'unknown' as const } : {}), + ...(observation.name !== undefined ? { name: observation.name } : {}), + ...(observation.description !== undefined ? { description: observation.description } : {}), + ...(observation.totalTokens !== undefined ? { totalTokens: observation.totalTokens } : {}), + ...(observation.providerTiming !== undefined + ? { providerTiming: observation.providerTiming } + : {}), + observedAt, + stoppable: observation.stoppable, + provenance: { source: 'structured-session', producerId: STRUCTURED_CHILD_WORK_PRODUCER_ID } + } +} + +/** A record's own `observedAt` may not go backwards; a host clock that does must not + * cost the child its update. */ +function monotonic(observedAt: number, existing: AgentChildWorkRecord | null): number { + return existing ? Math.max(observedAt, existing.observedAt) : observedAt +} + +type AliasResolution = { + child: AgentChildWorkRecord | null + ambiguous: boolean + /** Highest generation any binding for this alias holds, live or retired. A retired + * binding outlives the record it named, and must fence a late observation for that + * lifetime without banning the provider id forever. */ + highestGeneration: number | null +} + +function resolveExisting( + store: AgentStatusStore, + bindingsByAlias: ReadonlyMap, + providerTaskId: string +): AliasResolution { + const bindings = bindingsByAlias.get(providerTaskId) ?? [] + const highestGeneration = bindings.reduce( + (highest, binding) => Math.max(highest ?? 0, binding.fence.generation), + bindings.length > 0 ? 0 : null + ) + const candidates = bindings + .map((binding) => store.getChild(binding.childWorkId)) + .filter((child): child is AgentChildWorkRecord => child !== null && ownedByProducer(child)) + const distinct = new Set(candidates.map((child) => child.childWorkId)) + if (distinct.size > 1) { + return { child: null, ambiguous: true, highestGeneration } + } + return { child: candidates[0] ?? null, ambiguous: false, highestGeneration } +} + +function admitObservation( + input: StructuredChildWorkReconcileInput, + observation: StructuredChildWorkObservation, + resolution: AliasResolution +): { result: AgentChildWorkAdmissionResult; operation: 'announced' | 'adopted' | 'resumed' } { + const { admission, parent, provider } = input + const existing = resolution.child + const observedAt = monotonic(input.observedAt, existing) + const aliases = [observationAlias(observation.providerTaskId)] + const common = { ...fields(observation, observedAt), parent, provider, aliases } + if (!existing) { + // Bindings with no record left are retired lifetimes — a parent this host forgot + // takes its children with it. Their provider id is free again, under a generation + // past the retired one so the old binding still fences its own late observations. + const retired = resolution.highestGeneration + return { + operation: 'announced', + result: admission.announce({ + ...common, + fence: { invocationId: INVOCATION_ID, generation: retired === null ? 0 : retired + 1 }, + lifetime: retired === null ? 'current' : 'proven-new' + }) + } + } + // Revival first: a settled child reported live again keeps its id and retains the + // previous invocation's outcome, and `resume` also carries a new classification. + if (existing.membership === 'settled' && observation.membership === 'live') { + return { + operation: 'resumed', + result: admission.resume({ + ...common, + childWorkId: existing.childWorkId, + expectedFence: existing.invocation, + nextFence: { + invocationId: existing.invocation.invocationId, + generation: existing.invocation.generation + 1 + } + }) + } + } + if (existing.kind !== observation.kind) { + return { + operation: 'adopted', + result: admission.adopt({ + ...common, + childWorkId: existing.childWorkId, + expectedFence: existing.invocation + }) + } + } + return { + operation: 'announced', + result: admission.announce({ ...common, fence: existing.invocation, lifetime: 'current' }) + } +} + +function settleAbsent( + input: StructuredChildWorkReconcileInput, + child: AgentChildWorkRecord, + outcome: StructuredChildWorkReconcileOutcome +): void { + const aliases = input.store + .getAliasesForChild(child.childWorkId) + .filter( + (alias) => + alias.aliasKind === 'task_id' && + alias.segmentId === STRUCTURED_CHILD_WORK_SEGMENT_ID && + agentChildWorkFencesEqual(alias.fence, child.invocation) + ) + .map((alias) => observationAlias(alias.alias)) + if (aliases.length === 0) { + outcome.rejected.push({ + childWorkId: child.childWorkId, + providerTaskId: '', + reason: 'unbound-child' + }) + return + } + const result = input.admission.announce({ + ...fields( + { + providerTaskId: aliases[0].alias, + kind: child.kind, + // The roster stopped listing it; that is loss of live membership, not an + // outcome and not proof of exit. + state: 'idle', + membership: 'settled', + stoppable: false, + ...(child.name !== undefined ? { name: child.name } : {}), + ...(child.description !== undefined ? { description: child.description } : {}), + ...(child.totalTokens !== undefined ? { totalTokens: child.totalTokens } : {}), + ...(child.providerTiming !== undefined ? { providerTiming: child.providerTiming } : {}) + }, + monotonic(input.observedAt, child) + ), + parent: input.parent, + provider: input.provider, + aliases, + fence: child.invocation, + lifetime: 'current' + }) + if (result.accepted) { + outcome.settled += 1 + return + } + outcome.rejected.push({ + childWorkId: child.childWorkId, + providerTaskId: aliases[0].alias, + reason: result.reason + }) +} + +/** + * Apply one decoded roster. The parent must already be published: the store refuses a + * child whose parent it does not hold, which is what keeps this producer from + * inventing a parent of its own. + */ +export function reconcileStructuredChildWork( + input: StructuredChildWorkReconcileInput +): StructuredChildWorkReconcileOutcome { + const outcome: StructuredChildWorkReconcileOutcome = { + announced: 0, + adopted: 0, + resumed: 0, + settled: 0, + rejected: [] + } + const { observations } = input.evidence + const bindings = input.store.resolveChildAliases( + probeAliases( + input.parent, + input.provider, + observations.map((observation) => observation.providerTaskId) + ) + ) + const bindingsByAlias = new Map() + for (const binding of bindings) { + const entry = bindingsByAlias.get(binding.alias) ?? [] + entry.push(binding) + bindingsByAlias.set(binding.alias, entry) + } + const owned = input.store.getChildren(input.parent).filter(ownedByProducer) + const matched = new Set() + for (const observation of observations) { + const resolution = resolveExisting(input.store, bindingsByAlias, observation.providerTaskId) + const child = resolution.child + if (resolution.ambiguous) { + outcome.rejected.push({ + providerTaskId: observation.providerTaskId, + reason: 'ambiguous' + }) + continue + } + if (!child && owned.length + outcome.announced >= STRUCTURED_CHILD_WORK_MAX_TASKS) { + outcome.rejected.push({ + providerTaskId: observation.providerTaskId, + reason: 'ingestion-limit' + }) + continue + } + const { result, operation } = admitObservation(input, observation, resolution) + if (!result.accepted) { + outcome.rejected.push({ + ...(child ? { childWorkId: child.childWorkId } : {}), + providerTaskId: observation.providerTaskId, + reason: result.reason + }) + continue + } + matched.add(result.childWorkId) + outcome[operation] += 1 + } + for (const child of owned) { + if (child.membership === 'live' && !matched.has(child.childWorkId)) { + settleAbsent(input, child, outcome) + } + } + return outcome +} diff --git a/src/shared/agent-status-child-work-structured-egress.ts b/src/shared/agent-status-child-work-structured-egress.ts new file mode 100644 index 000000000000..30b5704af76e --- /dev/null +++ b/src/shared/agent-status-child-work-structured-egress.ts @@ -0,0 +1,136 @@ +// Read-only legacy views over the canonical child collection. +// +// These are the projections the background-task channel and the status summary become +// at cutover; nothing here writes. The bridge's four semantics live in +// `agent-status-child-work-projection.ts` and are applied to canonical rows, so the old +// wire keeps its closed vocabulary while the collection keeps the richer record. + +import type { AgentSessionBackgroundTaskState } from './agent-session-background-task-wire' +import type { AgentChildWorkRecord } from './agent-status-child-work' +import { + projectAgentChildWorkLegacyBackgroundTasks, + projectAgentChildWorkLegacySubagents, + type AgentChildWorkLegacyProjectionCandidate +} from './agent-status-child-work-projection' +import { + STRUCTURED_CHILD_WORK_PRODUCER_ID, + STRUCTURED_CHILD_WORK_SEGMENT_ID +} from './agent-status-child-work-structured-evidence' +import type { AgentStatusStore } from './agent-status-store' +import { agentStatusSubjectsEqual, type AgentStatusSubject } from './agent-status-subject' +import type { AgentSubagentSnapshot } from './agent-status-types' + +export const STRUCTURED_SUPPORTS_TASK_STOP_FACT = 'structured.backgroundTasks.supportsTaskStop' +export const STRUCTURED_SUPPORTS_STOP_ALL_FACT = 'structured.backgroundTasks.supportsStopAll' + +/** The provider id this child currently answers to, from its own invocation's alias. + * A retired invocation's alias is not an address for the live row. */ +function currentProviderTaskId( + store: AgentStatusStore, + child: AgentChildWorkRecord +): string | null { + for (const alias of store.getAliasesForChild(child.childWorkId)) { + if ( + alias.aliasKind === 'task_id' && + alias.segmentId === STRUCTURED_CHILD_WORK_SEGMENT_ID && + alias.fence.invocationId === child.invocation.invocationId && + alias.fence.generation === child.invocation.generation + ) { + return alias.alias + } + } + return null +} + +/** + * This producer's children for one parent, as legacy projection candidates. Ordered by + * first observation then provider id: deterministic, and the same order the strip sorts + * into, so a bounded projection always keeps the same rows. + */ +export function structuredChildWorkCandidates( + store: AgentStatusStore, + parent: AgentStatusSubject +): AgentChildWorkLegacyProjectionCandidate[] { + const candidates: AgentChildWorkLegacyProjectionCandidate[] = [] + for (const child of store.getChildren(parent)) { + if ( + child.provenance.producerId !== STRUCTURED_CHILD_WORK_PRODUCER_ID || + !agentStatusSubjectsEqual(child.parent, parent) + ) { + continue + } + const providerId = currentProviderTaskId(store, child) + if (!providerId) { + continue + } + candidates.push({ + providerId, + child: { + kind: child.kind, + state: child.state, + membership: child.membership, + firstObservedAt: child.firstObservedAt, + ...(child.name !== undefined ? { name: child.name } : {}), + ...(child.description !== undefined ? { description: child.description } : {}), + ...(child.agentType !== undefined ? { agentType: child.agentType } : {}), + ...(child.model !== undefined ? { model: child.model } : {}), + ...(child.totalTokens !== undefined ? { totalTokens: child.totalTokens } : {}), + stoppable: child.stoppable + } + }) + } + return candidates.sort((left, right) => { + const delta = left.child.firstObservedAt - right.child.firstObservedAt + return delta !== 0 ? delta : left.providerId < right.providerId ? -1 : 1 + }) +} + +/** The sidebar's `subagents`. Live only: the summary the bridge reads carries live + * `tasks` alone, so settled history must not reach this closed vocabulary. */ +export function projectStructuredChildWorkSubagents( + store: AgentStatusStore, + parent: AgentStatusSubject +): AgentSubagentSnapshot[] | undefined { + return projectAgentChildWorkLegacySubagents( + structuredChildWorkCandidates(store, parent).filter( + (candidate) => candidate.child.membership === 'live' + ) + ) +} + +function readBooleanFact( + store: AgentStatusStore, + parent: AgentStatusSubject, + key: string +): boolean | undefined { + // The store exposes no keyed fact reader yet; cutover should add one rather than + // materialize a snapshot per publication. + for (const fact of store.getSnapshot().facts) { + if (fact.key === key && agentStatusSubjectsEqual(fact.subject, parent)) { + return typeof fact.value === 'boolean' ? fact.value : undefined + } + } + return undefined +} + +/** The strip's `backgroundTasks`, live and settled, with the session's own stop + * capability — a fact about the provider, not about any one row. */ +export function projectStructuredChildWorkBackgroundTaskState( + store: AgentStatusStore, + parent: AgentStatusSubject +): AgentSessionBackgroundTaskState | null { + const projected = projectAgentChildWorkLegacyBackgroundTasks( + structuredChildWorkCandidates(store, parent) + ) + if (!projected.tasks && !projected.settledTasks) { + return null + } + const supportsTaskStop = readBooleanFact(store, parent, STRUCTURED_SUPPORTS_TASK_STOP_FACT) + const supportsStopAll = readBooleanFact(store, parent, STRUCTURED_SUPPORTS_STOP_ALL_FACT) + return { + state: 'monitoring', + ...projected, + ...(supportsTaskStop !== undefined ? { supportsTaskStop } : {}), + ...(supportsStopAll !== undefined ? { supportsStopAll } : {}) + } +} diff --git a/src/shared/agent-status-child-work-structured-evidence.ts b/src/shared/agent-status-child-work-structured-evidence.ts new file mode 100644 index 000000000000..b6d89571ab71 --- /dev/null +++ b/src/shared/agent-status-child-work-structured-evidence.ts @@ -0,0 +1,164 @@ +// Structured provider task state, decoded into the canonical child-work vocabulary. +// +// The input is the adapter's FULL roster — live tasks, settled tasks, usage and stop +// capability. The status summary is not an ingest source: it keeps only live `tasks`, +// strips `totalTokens` and omits an empty list, so no decoder could recover settled +// history or usage from it. + +import type { + AgentSessionBackgroundTask, + AgentSessionBackgroundTaskState +} from './agent-session-background-task-wire' +import type { + AgentChildWorkKind, + AgentChildWorkMembership, + AgentChildWorkProviderTiming, + AgentChildWorkState +} from './agent-status-child-work' + +/** Alias segment for every structured background task, so a task id is scoped by + * parent subject and provider alone. A different producer observing the same work + * uses its own segment and needs positive evidence before the two may be joined. */ +export const STRUCTURED_CHILD_WORK_SEGMENT_ID = 'structured-background-tasks' +export const STRUCTURED_CHILD_WORK_PRODUCER_ID = 'structured-background-tasks' + +/** Named ingestion bound, sized to the provider trackers' own retention. */ +export const STRUCTURED_CHILD_WORK_MAX_TASKS = 256 + +/** Matches the background projection's provider-id bound and the alias part bound. */ +const PROVIDER_TASK_ID_MAX_LENGTH = 512 +const LABEL_MAX_LENGTH = 512 +const DESCRIPTION_MAX_LENGTH = 8_000 + +export type StructuredChildWorkObservation = { + providerTaskId: string + kind: AgentChildWorkKind + state: AgentChildWorkState + membership: AgentChildWorkMembership + name?: string + description?: string + totalTokens?: number + providerTiming?: AgentChildWorkProviderTiming + stoppable: boolean +} + +export type StructuredChildWorkEvidence = { + observations: StructuredChildWorkObservation[] + supportsTaskStop: boolean + supportsStopAll: boolean +} + +function boundedText(value: unknown, maxLength: number): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim() + if (trimmed.length === 0 || trimmed.length > maxLength) { + return undefined + } + for (let index = 0; index < trimmed.length; index += 1) { + const code = trimmed.charCodeAt(index) + if (code <= 0x1f || code === 0x7f) { + return undefined + } + } + return trimmed +} + +function usageTotalTokens(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined +} + +function providerTiming(value: unknown): AgentChildWorkProviderTiming | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? { startedAt: value } + : undefined +} + +/** The strip's own fallback for a host that reports no per-task state + * (`background-task-roster.ts` `effectiveState`), applied once here so the + * canonical record carries a state both surfaces already agree on. */ +export function structuredChildWorkState( + task: Pick, + settled: boolean +): AgentChildWorkState { + if (task.state) { + return task.state + } + if (settled) { + return 'done' + } + return task.kind === 'monitor' ? 'monitoring' : 'working' +} + +function observe( + task: AgentSessionBackgroundTask, + settled: boolean, + supportsTaskStop: boolean +): StructuredChildWorkObservation | null { + const providerTaskId = boundedText(task.id, PROVIDER_TASK_ID_MAX_LENGTH) + if (!providerTaskId) { + return null + } + const name = boundedText(task.name, LABEL_MAX_LENGTH) + const description = boundedText(task.description, DESCRIPTION_MAX_LENGTH) + const totalTokens = usageTotalTokens(task.totalTokens) + const timing = providerTiming(task.startedAt) + return { + providerTaskId, + kind: task.kind, + state: structuredChildWorkState(task, settled), + membership: settled ? 'settled' : 'live', + ...(name !== undefined ? { name } : {}), + ...(description !== undefined ? { description } : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}), + ...(timing !== undefined ? { providerTiming: timing } : {}), + // A positive host assertion, never absence: only the Claude adapter implements + // `stopBackgroundTasks`, and a row nothing can target must refuse its own stop. + stoppable: !settled && supportsTaskStop && task.stoppable !== false + } +} + +/** + * Decode one adapter roster. `null` is the provider's authoritative "no live work" + * and reconciles to an empty roster; `undefined` — no such session — is absence of + * evidence and is not decoded at all. + */ +export function decodeStructuredChildWorkEvidence( + state: AgentSessionBackgroundTaskState | null +): StructuredChildWorkEvidence { + const supportsTaskStop = state?.supportsTaskStop === true + const observations: StructuredChildWorkObservation[] = [] + const seen = new Set() + // Live last: a provider that reports one id in both rosters is stating it is live. + for (const [roster, settled] of [ + [state?.settledTasks, true], + [state?.tasks, false] + ] as const) { + for (const task of roster ?? []) { + const observation = observe(task, settled, supportsTaskStop) + if (!observation) { + continue + } + if (seen.has(observation.providerTaskId)) { + const index = observations.findIndex( + (candidate) => candidate.providerTaskId === observation.providerTaskId + ) + observations[index] = observation + continue + } + if (observations.length >= STRUCTURED_CHILD_WORK_MAX_TASKS) { + continue + } + seen.add(observation.providerTaskId) + observations.push(observation) + } + } + return { + observations, + supportsTaskStop, + // Absent means supported on this wire; the host says `false` when its provider + // exposes no honest stop-all. + supportsStopAll: state ? state.supportsStopAll !== false : false + } +} diff --git a/src/shared/agent-status-child-work-structured-producer.test-fixture.ts b/src/shared/agent-status-child-work-structured-producer.test-fixture.ts new file mode 100644 index 000000000000..6bfab439b3e6 --- /dev/null +++ b/src/shared/agent-status-child-work-structured-producer.test-fixture.ts @@ -0,0 +1,119 @@ +import type { AgentSessionBackgroundTaskState } from './agent-session-background-task-wire' +import { createAgentChildWorkAdmission } from './agent-status-child-work-admission' +import { + reconcileStructuredChildWork, + type StructuredChildWorkReconcileOutcome +} from './agent-status-child-work-reconciliation' +import { decodeStructuredChildWorkEvidence } from './agent-status-child-work-structured-evidence' +import { + projectStructuredChildWorkBackgroundTaskState, + projectStructuredChildWorkSubagents, + STRUCTURED_SUPPORTS_STOP_ALL_FACT, + STRUCTURED_SUPPORTS_TASK_STOP_FACT +} from './agent-status-child-work-structured-egress' +import { createAgentStatusStore, type AgentStatusStore } from './agent-status-store' +import { + makeStructuredAgentStatusSubject, + type AgentStatusExecutionScope, + type AgentStatusStructuredSessionSubject +} from './agent-status-subject' + +export const FIXTURE_SESSION_ID = 'session_11111111-1111-4111-8111-111111111111' + +export const FIXTURE_SCOPE: AgentStatusExecutionScope = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} + +export function fixtureSubject( + overrides: Partial = {}, + sessionId = FIXTURE_SESSION_ID +): AgentStatusStructuredSessionSubject { + return makeStructuredAgentStatusSubject({ ...FIXTURE_SCOPE, ...overrides }, sessionId) +} + +export type StructuredChildWorkProducerFixture = ReturnType< + typeof createStructuredChildWorkProducerFixture +> + +/** + * The real producer over a real authority store: decoder, reconciler and admission API, + * with only the id mint and the clock made deterministic. Tests that skip this and hand + * the store hand-written child rows prove nothing about the producer. + */ +export function createStructuredChildWorkProducerFixture(options?: { + store?: AgentStatusStore + parent?: AgentStatusStructuredSessionSubject + provider?: string + withParent?: boolean + /** Distinguish mints when two fixtures share one store, as two hosts would. */ + idPrefix?: string +}) { + const parent = options?.parent ?? fixtureSubject() + const provider = options?.provider ?? 'claude' + const store = options?.store ?? createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + let minted = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: () => `${options?.idPrefix ?? 'child'}-${++minted}` + }) + if (options?.withParent !== false) { + store.applyMutation({ parent: { subject: parent } }) + } + let clock = 1_000 + + return { + store, + parent, + provider, + get mintedCount() { + return minted + }, + now: () => clock, + /** Admit one full adapter roster. `null` is the provider's authoritative empty. */ + publish( + state: AgentSessionBackgroundTaskState | null, + at?: number + ): StructuredChildWorkReconcileOutcome { + clock = at ?? clock + 1 + const evidence = decodeStructuredChildWorkEvidence(state) + store.applyMutation({ + facts: [ + { + subject: parent, + key: STRUCTURED_SUPPORTS_TASK_STOP_FACT, + value: evidence.supportsTaskStop + }, + { + subject: parent, + key: STRUCTURED_SUPPORTS_STOP_ALL_FACT, + value: evidence.supportsStopAll + } + ] + }) + return reconcileStructuredChildWork({ + store, + admission, + parent, + provider, + evidence, + observedAt: clock + }) + }, + children: () => store.getChildren(parent), + childFor(providerTaskId: string) { + return ( + store + .getChildren(parent) + .find((child) => + store + .getAliasesForChild(child.childWorkId) + .some((alias) => alias.alias === providerTaskId) + ) ?? null + ) + }, + subagents: () => projectStructuredChildWorkSubagents(store, parent), + backgroundTaskState: () => projectStructuredChildWorkBackgroundTaskState(store, parent) + } +} diff --git a/src/shared/structured-session-legacy-subagent-projection.ts b/src/shared/structured-session-legacy-subagent-projection.ts new file mode 100644 index 000000000000..0828dc75496b --- /dev/null +++ b/src/shared/structured-session-legacy-subagent-projection.ts @@ -0,0 +1,63 @@ +// The renderer bridge's wire-to-sidebar child projection, lifted out of the React +// component so the host's canonical projection can be measured against this exact code +// rather than against a restatement of it. Shared because the host must now match it; +// deleted with the bridge at cutover. + +import type { AgentSessionBackgroundTask } from './agent-session-background-task-wire' +import { + AGENT_STATUS_MAX_SUBAGENTS, + type AgentSubagentSnapshot, + type AgentSubagentState +} from './agent-status-types' + +/** Matches the wire-parse bound in `normalizeSubagentSnapshot`. */ +export const SUBAGENT_ID_MAX_LENGTH = 64 + +export function subagentStateFromTask(task: AgentSessionBackgroundTask): AgentSubagentState { + switch (task.state) { + case 'waiting': + return 'waiting' + case 'blocked': + return 'blocked' + case 'done': + case 'idle': + return 'idle' + case 'unverifiable': + return 'unverifiable' + // Absent state is an old host's live task; live means working here. + case 'working': + case 'monitoring': + case undefined: + return 'working' + } +} + +/** Sidebar children for a structured session: the agent-kind background tasks + * the host publishes, mapped to the sidebar's own subagent vocabulary rather + * than widening it. Kinds stay distinct — a backgrounded shell never counts + * as a subagent. */ +export function subagentSnapshotsFromTasks( + tasks: AgentSessionBackgroundTask[] | undefined +): AgentSubagentSnapshot[] | undefined { + if (!tasks) { + return undefined + } + const snapshots: AgentSubagentSnapshot[] = [] + for (const task of tasks) { + const id = task.id.trim() + if (task.kind !== 'agent' || id.length === 0 || id.length > SUBAGENT_ID_MAX_LENGTH) { + continue + } + snapshots.push({ + id, + state: subagentStateFromTask(task), + startedAt: task.startedAt ?? 0, + ...(task.name ? { agentType: task.name } : {}), + ...(task.description ? { description: task.description } : {}) + }) + if (snapshots.length >= AGENT_STATUS_MAX_SUBAGENTS) { + break + } + } + return snapshots.length > 0 ? snapshots : undefined +}