One shared subagent tracker for Claude, Codex and Grok chat rows (#27228)

* test(native-chat): pin each agent's subagent row writes before unifying the rosters

* refactor(native-chat): one shared subagent tracker owns every agent's subagent rows

Claude, Codex and ACP (Grok) each kept their own subagent roster with drifted
rules. They now only report what they observe; one tracker decides grouping,
runs, names, state, sweeps, retention, row writes and restart inheritance.
The golden row-write tests from the previous commit show each rule change.

* fix(native-chat): one entry per subagent, derived lookups, ACP end stamped in its session-end write

Review round 1: a resumed child keeps its one entry and reopens it where it is
listed (the chat draws one section per child, anchored at the first row naming
it), every lookup is read from the rows, the no-turn row is never released,
and ACP's session end stamps its live children inside the session-end write
instead of writing rows ahead of it.

* fix(native-chat): derive the no-turn group, end a foreground run with the turn that started it

Review round 2: the row no turn owns is recognised from its id (a stored flag
was lost when an earlier run's row was first reached through one of its
children), and a turn's end settles the foreground runs it started wherever
their child is listed.

* fix(native-chat): count an inherited subagent row as settled history when a lookup loads it

A read-only lookup could load an earlier run's settled row that retention never saw, so it was never
released and a fresh settled row was released in its place.
This commit is contained in:
Brennan Benson
2026-10-11 13:04:04 -07:00
committed by GitHub
parent e22fd9c901
commit 5b228ea71d
44 changed files with 5841 additions and 1404 deletions
@@ -0,0 +1,538 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ACP subagent timeline rows (characterization) > a group past 64 children drops the rest 1`] = `
{
"events": 1,
"has": [
true,
false,
],
"lastLabels": "l":"X 60","state":"working","startedAt":1010},{"id":"c60","label":"X 61","state":"working","startedAt":1010},{"id":"c61","label":"X 62","state":"working","startedAt":1010},{"id":"c62","label":"X 63","state":"working","startedAt":1010},{"id":"c63","label":"X 64","state":"working","startedAt":1010}]}]",
}
`;
exports[`ACP subagent timeline rows (characterization) > children sharing a label, a provider-named ordinal, and outcomes in one batch 1`] = `
[
[
{
"body": {
"blocks": [
{
"text": "Kicked off 4 subagents",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Audit",
"startedAt": 1010,
"state": "working",
},
{
"id": "a2",
"label": "Audit 2",
"startedAt": 1010,
"state": "working",
},
{
"id": "a3",
"label": "Audit 2 2",
"startedAt": 1010,
"state": "working",
},
{
"id": "a4",
"label": "subagent",
"startedAt": 1010,
"state": "working",
},
],
"groupId": "turn-1",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"subagentIdentities": [
"a1",
"a2",
"a3",
"a4",
],
"type": "item.update",
},
],
[
{
"body": {
"blocks": [
{
"text": "Kicked off 4 subagents (1 failed)",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Audit",
"settledAt": 1020,
"startedAt": 1010,
"state": "stopped",
},
{
"id": "a2",
"label": "Audit 2",
"settledAt": 1020,
"startedAt": 1010,
"state": "completed",
},
{
"id": "a3",
"label": "Audit 2 2",
"settledAt": 1020,
"startedAt": 1010,
"state": "failed",
},
{
"id": "a4",
"label": "subagent",
"startedAt": 1010,
"state": "working",
},
],
"groupId": "turn-1",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"subagentIdentities": [
"a1",
"a2",
"a3",
"a4",
],
"type": "item.update",
},
{
"body": {
"blocks": [
{
"text": "late",
"type": "text",
},
],
"kind": "message",
"role": "assistant",
},
"item": "subagent-result:a2",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"producer": {
"agentId": "a2",
"producerKind": "agent",
},
"type": "item.update",
},
],
]
`;
exports[`ACP subagent timeline rows (characterization) > knownOnly updates, turn from the update, and the no-turn group 1`] = `
{
"has": [
false,
true,
true,
],
"log": [
[],
[
{
"body": {
"blocks": [
{
"text": "Kicked off 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Spawned",
"startedAt": 1020,
"state": "working",
},
],
"groupId": "turn-0",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-0",
"join": {
"thread": "parent",
"turn": "turn-0",
},
"subagentIdentities": [
"a1",
],
"type": "item.update",
},
],
[
{
"body": {
"blocks": [
{
"text": "Kicked off 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "a2",
"label": "Loose",
"startedAt": 1030,
"state": "working",
},
],
"groupId": "thread",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:thread",
"join": {
"thread": "parent",
},
"subagentIdentities": [
"a2",
],
"type": "item.update",
},
],
[
{
"body": {
"blocks": [
{
"text": "Ran 1 subagent (1 stopped)",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Spawned",
"settledAt": 1040,
"startedAt": 1020,
"state": "stopped",
},
],
"groupId": "turn-0",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-0",
"join": {
"thread": "parent",
"turn": "turn-0",
},
"subagentIdentities": [
"a1",
],
"type": "item.update",
},
],
],
}
`;
exports[`ACP subagent timeline rows (characterization) > more than 32 settled groups, then a late update for an evicted child 1`] = `
{
"has": [
true,
true,
true,
],
"tail": [
[
{
"body": {
"blocks": [
{
"text": "Kicked off 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "live",
"label": "Live",
"startedAt": 1350,
"state": "working",
},
],
"groupId": "turn-live",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-live",
"join": {
"thread": "parent",
"turn": "turn-live",
},
"subagentIdentities": [
"live",
],
"type": "item.update",
},
],
[],
[],
],
}
`;
exports[`ACP subagent timeline rows (characterization) > one child: working, tokens, completed with its reply, replayed 1`] = `
[
[
{
"body": {
"blocks": [
{
"text": "Kicked off 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Explore",
"startedAt": 1010,
"state": "working",
},
],
"groupId": "turn-1",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"subagentIdentities": [
"a1",
],
"type": "item.update",
},
],
[
{
"body": {
"blocks": [
{
"text": "Kicked off 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Explore",
"startedAt": 1010,
"state": "working",
"tokens": 0,
},
],
"groupId": "turn-1",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"subagentIdentities": [
"a1",
],
"type": "item.update",
},
],
[
{
"body": {
"blocks": [
{
"text": "Kicked off 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Explore",
"startedAt": 1010,
"state": "working",
"tokens": 200,
},
],
"groupId": "turn-1",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"subagentIdentities": [
"a1",
],
"type": "item.update",
},
],
[
{
"body": {
"blocks": [
{
"text": "Ran 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Explore",
"settledAt": 1040,
"startedAt": 1010,
"state": "completed",
"tokens": 200,
},
],
"groupId": "turn-1",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"subagentIdentities": [
"a1",
],
"type": "item.update",
},
{
"body": {
"blocks": [
{
"text": "Found it",
"type": "text",
},
],
"kind": "message",
"role": "assistant",
},
"item": "subagent-result:a1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"producer": {
"agentId": "a1",
"producerKind": "agent",
},
"type": "item.update",
},
],
[],
[],
]
`;
exports[`ACP subagent timeline rows (characterization) > writes nothing after dispose 1`] = `
{
"has": false,
"log": [
[
{
"body": {
"blocks": [
{
"text": "Kicked off 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "A",
"startedAt": 1010,
"state": "working",
},
],
"groupId": "turn-1",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"subagentIdentities": [
"a1",
],
"type": "item.update",
},
],
[],
],
}
`;
@@ -157,8 +157,9 @@ describe('Grok child outcome during a full host Stop', () => {
const [unavailable] = await fixture.rosters()
expect(fixture.childWork.views()).toMatchObject([{ membership: 'settled', outcome: 'unknown' }])
expect(unavailable.row.itemId).toBe(fixture.original.row.itemId)
// Contact was lost when the session ended, a moment the host observed.
expect(unavailable.group.agents).toEqual([
{ ...fixture.original.group.agents[0], state: 'unverifiable' }
{ ...fixture.original.group.agents[0], state: 'unverifiable', settledAt: expect.any(Number) }
])
fixture.agent.notify(fixture.finished.method, fixture.finished.params)
expect((await fixture.rosters())[0].group).toEqual(unavailable.group)
+5 -3
View File
@@ -88,7 +88,8 @@ export function asReattachHistory(params: unknown): unknown {
export function closeAcpSessionJournal(
session: AcpStructuredSession,
reason: string,
verdict: StructuredAgentSessionTurnVerdict = UNVERIFIABLE_TURN_VERDICT
verdict: StructuredAgentSessionTurnVerdict = UNVERIFIABLE_TURN_VERDICT,
endedAt: number = Date.now()
): void {
if (session.journalClosed !== null) {
return
@@ -96,7 +97,7 @@ export function closeAcpSessionJournal(
session.journalClosed = reason
session.prompts.clear()
session.turns.end(reason)
session.lane.apply([{ type: 'session.ended', verdict }])
session.lane.apply([{ type: 'session.ended', verdict, endedAt }])
session.lane.flush()
session.lane.dispose()
session.unbindReadingControl?.()
@@ -129,7 +130,8 @@ export function endAcpStructuredSession(
reason,
session.closeRequested
? UNVERIFIABLE_TURN_VERDICT
: { state: 'interrupted', completedAt: observedAt }
: { state: 'interrupted', completedAt: observedAt },
observedAt
)
const detail = stderr ? providerDiagnostic(stderr, 'person') : undefined
onEvent?.({
@@ -0,0 +1,115 @@
// Golden timeline events from the ACP subagent roster for scripted update sequences, so moving it
// onto shared code cannot change a saved byte.
import { describe, expect, it } from 'vitest'
import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect'
import { AcpSubagentTimeline } from './acp-subagent-timeline'
import type { AcpTimelineEvent } from './acp-timeline-event'
function harness() {
const timeline = new AcpSubagentTimeline()
const log: AcpTimelineEvent[][] = []
let at = 1_000
return {
timeline,
log,
send: (updates: AcpSubagentUpdate[], join: { thread?: string; turn?: string }) => {
log.push(timeline.translate(updates, join, (at += 10)))
}
}
}
const JOIN = { thread: 'parent', turn: 'turn-1' }
describe('ACP subagent timeline rows (characterization)', () => {
it('one child: working, tokens, completed with its reply, replayed', () => {
const h = harness()
h.send([{ id: 'a1', label: 'Explore', state: 'working' }], JOIN)
h.send([{ id: 'a1', tokens: 0 }], JOIN)
h.send([{ id: 'a1', tokens: 200 }], JOIN)
h.send([{ id: 'a1', state: 'completed', result: 'Found it' }], JOIN)
h.send([{ id: 'a1', state: 'completed', result: 'Found it' }], JOIN)
h.send([{ id: 'a1', state: 'failed' }], JOIN)
expect(h.log).toMatchSnapshot()
})
it('children sharing a label, a provider-named ordinal, and outcomes in one batch', () => {
const h = harness()
h.send(
[
{ id: 'a1', label: 'Audit' },
{ id: 'a2', label: 'Audit' },
{ id: 'a3', label: 'Audit 2' },
{ id: 'a4' }
],
JOIN
)
h.send(
[
{ id: 'a1', state: 'stopped' },
{ id: 'a2', state: 'unverifiable' },
{ id: 'a2', state: 'completed', result: 'late' },
{ id: 'a3', state: 'failed' }
],
JOIN
)
expect(h.log).toMatchSnapshot()
})
it('knownOnly updates, turn from the update, and the no-turn group', () => {
const h = harness()
h.send([{ id: 'unknown', state: 'completed', knownOnly: true }], JOIN)
h.send([{ id: 'a1', label: 'Spawned', turn: 'turn-0' }], JOIN)
h.send([{ id: 'a2', label: 'Loose' }], { thread: 'parent' })
h.send([{ id: 'a1', state: 'stopped', knownOnly: true }], JOIN)
expect({
log: h.log,
has: ['unknown', 'a1', 'a2'].map((id) => h.timeline.has(id))
}).toMatchSnapshot()
})
it('a group past 64 children drops the rest', () => {
const h = harness()
h.send(
Array.from({ length: 66 }, (_, index): AcpSubagentUpdate => ({
id: `c${index}`,
label: 'X'
})),
JOIN
)
const last = h.log.at(-1)?.at(-1)
expect({
events: h.log.at(-1)?.length,
lastLabels:
last && 'body' in last && last.body.kind === 'message'
? JSON.stringify(last.body.blocks).slice(-300)
: null,
has: [h.timeline.has('c63'), h.timeline.has('c64')]
}).toMatchSnapshot()
})
it('more than 32 settled groups, then a late update for an evicted child', () => {
const h = harness()
for (let index = 0; index < 34; index++) {
h.send([{ id: `c${index}`, label: `Job ${index}`, state: 'completed' }], {
thread: 'parent',
turn: `turn-${index}`
})
}
h.send([{ id: 'live', label: 'Live' }], { thread: 'parent', turn: 'turn-live' })
h.send([{ id: 'c0', state: 'failed' }], JOIN)
h.send([{ id: 'c0', label: 'Job 0' }], JOIN)
expect({
tail: h.log.slice(-3),
has: [h.timeline.has('c0'), h.timeline.has('c1'), h.timeline.has('c33')]
}).toMatchSnapshot()
})
it('writes nothing after dispose', () => {
const h = harness()
h.send([{ id: 'a1', label: 'A' }], JOIN)
h.timeline.dispose()
h.send([{ id: 'a1', state: 'completed' }], JOIN)
expect({ log: h.log, has: h.timeline.has('a1') }).toMatchSnapshot()
})
})
+20 -48
View File
@@ -1,24 +1,10 @@
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary'
import { isSubagentGroupBlock, type NativeChatSubagentState } from '../../shared/native-chat-types'
import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect'
import { grokSubagentNotification } from './acp-dialects/grok-subagents'
import { AcpSubagentTimeline } from './acp-subagent-timeline'
const retainedSchema = z.object({
groups: z.map(
z.string(),
z.object({
entries: z.map(z.string(), z.object({ label: z.string() })),
labelCounts: z.map(z.string(), z.number()),
results: z.map(z.string(), z.string())
})
),
groupOf: z.map(z.string(), z.string()),
retention: z.object({ settledIdentities: z.object({ size: z.number() }) })
})
function spawnGroups(
timeline: AcpSubagentTimeline,
count: number,
@@ -49,7 +35,7 @@ describe('ACP subagent retention', () => {
}
expect(timeline.has('0:0')).toBe(true)
expect(timeline.has('32:63')).toBe(true)
expect(retainedSchema.parse(timeline).groupOf.size).toBe(33 * 64)
expect(timeline.retentionSizes().groups).toBe(33)
timeline.translate(
Array.from({ length: 63 }, (_, child): AcpSubagentUpdate => ({
id: `0:${child}`,
@@ -59,8 +45,7 @@ describe('ACP subagent retention', () => {
{ thread: 'parent' },
100
)
expect(retainedSchema.parse(timeline).groups.size).toBe(33)
expect(retainedSchema.parse(timeline).groups.get('turn-0')?.results.size).toBe(63)
expect(timeline.retentionSizes()).toMatchObject({ groups: 33, replies: 63 })
const events = timeline.translate(
[{ id: '0:63', state: 'completed', result: 'Last sibling' }],
{ thread: 'parent', turn: 'later' },
@@ -70,10 +55,8 @@ describe('ACP subagent retention', () => {
'turn-0',
'turn-0'
])
const retained = retainedSchema.parse(timeline)
expect(retained.groups.size).toBe(32)
expect(retained.groups.has('turn-0')).toBe(false)
expect(retained.groupOf.has('0:0')).toBe(false)
// The settled group is released with its replies; its children stay known as settled.
expect(timeline.retentionSizes()).toMatchObject({ groups: 32, replies: 0 })
expect(timeline.has('0:0')).toBe(true)
expect(timeline.translate([{ id: '0:0', state: 'working' }], {}, 102)).toEqual([])
})
@@ -93,20 +76,18 @@ describe('ACP subagent retention', () => {
group + 1
)
}
const retained = retainedSchema.parse(timeline)
expect(retained.groups.size).toBe(32)
expect(retained.groupOf.size).toBe(31 * 64 + 1)
expect(
[...retained.groups.values()].reduce((total, group) => total + group.results.size, 0)
).toBe(31 * 64)
expect(retained.retention.settledIdentities.size).toBe(32 * 64)
expect(timeline.retentionSizes()).toEqual({
groups: 32,
replies: 31 * 64,
settledIdentities: 32 * 64
})
expect(timeline.has('live')).toBe(true)
expect(timeline.has('0:0')).toBe(false)
expect(timeline.has('33:0')).toBe(true)
expect(timeline.translate([{ id: '33:0', state: 'working' }], {}, 2200)).toEqual([])
})
it('bounds retained descriptions and count keys, preserving ordinary duplicate labels and clipped distinctions', () => {
it('bounds retained descriptions, preserving ordinary duplicate labels and clipped distinctions', () => {
const timeline = new AcpSubagentTimeline()
const description = 'A'.repeat(1024 * 1024)
const updates = ['large-1', 'large-2'].flatMap((id) => {
@@ -122,23 +103,16 @@ describe('ACP subagent retention', () => {
{ turn: 'turn' },
0
)
const retained = retainedSchema.parse(timeline).groups.get('turn')
expect(retained).toBeDefined()
const labels = [...(retained?.entries.values() ?? [])].map((entry) => entry.label)
expect(labels.every((label) => label.length <= MAX_SUBAGENT_FIELD_CHARS)).toBe(true)
expect(
[...(retained?.labelCounts.keys() ?? [])].every(
(key) => key.length <= MAX_SUBAGENT_FIELD_CHARS
const labels = events
.flatMap((event) =>
event.type === 'item.update' && event.body.kind === 'message'
? event.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents)
: []
)
).toBe(true)
.map((entry) => entry.label)
expect(labels.every((label) => label.length <= MAX_SUBAGENT_FIELD_CHARS)).toBe(true)
expect(new Set(labels).size).toBe(4)
expect(labels.slice(2)).toEqual(['Review', 'Review 2'])
const emitted = events.flatMap((event) =>
event.type === 'item.update' && event.body.kind === 'message'
? event.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents)
: []
)
expect(emitted.map((entry) => entry.label)).toEqual(labels)
})
it('drops ownership and cached replies on disposal even if the producer continues', () => {
@@ -146,11 +120,9 @@ describe('ACP subagent retention', () => {
spawnGroups(timeline, 33)
timeline.translate([{ id: 'child-0', state: 'completed', result: 'Answer' }], {}, 100)
timeline.dispose()
const retained = retainedSchema.parse(timeline)
expect(retained.groups.size).toBe(0)
expect(retained.groupOf.size).toBe(0)
expect(retained.retention.settledIdentities.size).toBe(0)
const empty = { groups: 0, settledIdentities: 0, replies: 0 }
expect(timeline.retentionSizes()).toEqual(empty)
expect(timeline.translate([{ id: 'child-1', tokens: 9 }, { id: 'new' }], {}, 101)).toEqual([])
expect(retainedSchema.parse(timeline).groups.size).toBe(0)
expect(timeline.retentionSizes()).toEqual(empty)
})
})
+86 -157
View File
@@ -1,49 +1,47 @@
import {
canReplaceSubagentState,
isTerminalSubagentState
} from '../../shared/native-chat-subagent-summary'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import {
boundInlineText,
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import {
boundSubagentField,
subagentGroupJournalBody
} from '../native-chat/agent-session-journal/journal-subagent-group-body'
import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline/provider-timeline-event'
import { SubagentTracker } from '../native-chat/subagent-tracker/subagent-tracker'
import type { SubagentGroup } from '../native-chat/subagent-tracker/subagent-tracker-types'
import { acpSubagentIdentity, type AcpTimelineEvent } from './acp-timeline-event'
import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect'
import { SubagentRosterRetention } from '../native-chat/subagent-roster-retention'
/** Settled history budget; live ownership outlives it until an outcome or session close. */
const MAX_GROUPS = 32
const MAX_SUBAGENTS_PER_GROUP = 64
const UNLABELLED = 'subagent'
type RosterGroup = {
groupId: string
turn?: string
entries: Map<string, NativeChatSubagentEntry>
labelCounts: Map<string, number>
lastSerialized: string | null
results: Map<string, string>
}
/** The group a subagent reported outside any turn belongs to. */
const OUTSIDE_TURN = 'thread'
/** One roster row per spawning turn, revised in place, plus each completed subagent's reply as its
* own row, filed under its id so it opens beneath its roster entry. Lives as long as the provider
* child: a subagent an earlier run spawned is not known here, and its row is that run's. */
* own row, filed under its id so it opens beneath its roster entry. The rows follow the shared
* subagent tracker's rules; this module only reads the dialect's updates. Lives as long as the
* provider child: a subagent an earlier run spawned is not known here, and its row is that run's. */
export class AcpSubagentTimeline {
private readonly groups = new Map<string, RosterGroup>()
private readonly groupOf = new Map<string, string>()
private readonly retention = new SubagentRosterRetention(this.groups, {
maxGroups: MAX_GROUPS,
maxSettledIdentities: MAX_GROUPS * MAX_SUBAGENTS_PER_GROUP,
entries: (group) => group.entries.values(),
identities: (group) => group.entries.keys(),
/** Row events the tracker wrote during the current `translate`. */
private written: AcpTimelineEvent[] = []
private readonly results = new Map<string, string>()
/** The time of the updates being translated: when they were observed. */
private at = 0
private readonly tracker = new SubagentTracker<ProviderTimelineJoin>({
// The host admits the events later and retries a refused one itself.
outsideTurn: (groupId) => groupId === OUTSIDE_TURN,
port: {
write: (group, { body }) => {
if (body) {
this.written.push({
type: 'item.update',
item: `subagents:${group.groupId}`,
body,
subagentIdentities: [...group.entries.keys()].map(acpSubagentIdentity),
join: group.placement
})
}
return { accepted: true }
}
},
now: () => this.at,
onEvict: (group) => {
for (const id of group.entries.keys()) {
this.groupOf.delete(id)
this.results.delete(id)
}
}
})
@@ -51,15 +49,20 @@ export class AcpSubagentTimeline {
/** Known children include recently evicted outcomes, so they cannot become background tasks. */
has(id: string): boolean {
return this.groupOf.has(id) || this.retention.hasSettled(id)
return this.tracker.has(id)
}
/** Retention is not observable through the update API, so expose what it holds. */
retentionSizes(): { groups: number; settledIdentities: number; replies: number } {
return { ...this.tracker.sizes(), replies: this.results.size }
}
/** The journal owns session-end settlement; no later frame may reacquire working ownership. */
dispose(): void {
this.disposed = true
this.groups.clear()
this.groupOf.clear()
this.retention.clear()
this.tracker.dispose()
this.results.clear()
this.written = []
}
translate(
@@ -70,135 +73,61 @@ export class AcpSubagentTimeline {
if (this.disposed) {
return []
}
const changed = new Set<RosterGroup>()
const replies: { id: string; group: RosterGroup; text: string }[] = []
for (const update of updates) {
const group = this.apply(update, join, at)
if (!group) {
continue
}
changed.add(group)
const entry = group.entries.get(update.id)
if (update.result && entry?.state === 'completed') {
replies.push({ id: update.id, group, text: update.result })
const replies: AcpTimelineEvent[] = []
this.written = []
// One revision per changed row; replies are cached before the batch releases settled rows, so a
// released row's replies go with it.
this.tracker.batch(() => {
for (const update of updates) {
this.apply(update, join, at)
const located = this.tracker.locate(update.id)
if (update.result && located?.tracked.entry.state === 'completed') {
replies.push(...this.reply(update.id, located.group, update.result))
}
}
})
const events = [...this.written, ...replies]
this.written = []
return events
}
private reply(
id: string,
group: SubagentGroup<ProviderTimelineJoin>,
text: string
): AcpTimelineEvent[] {
const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
if (this.results.get(id) === bounded) {
return []
}
const events = [...changed].flatMap((group) => this.groupEvent(group, join))
for (const { id, group, text } of replies) {
const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
if (group.results.get(id) === bounded) {
continue
}
group.results.set(id, bounded)
events.push({
this.results.set(id, bounded)
return [
{
type: 'item.update',
item: `subagent-result:${id}`,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: bounded }] },
producer: { agentId: id, producerKind: 'agent' },
join: groupJoin(group, join)
})
}
// Emit the final roster and reply before releasing their cached ownership together.
this.retention.trim(changed)
return events
}
private apply(
update: AcpSubagentUpdate,
join: ProviderTimelineJoin,
at: number
): RosterGroup | undefined {
if (this.retention.hasSettled(update.id)) {
return undefined
}
const known = this.groups.get(this.groupOf.get(update.id) ?? '')
const current = known?.entries.get(update.id)
if (known && current) {
const state =
update.state && canReplaceSubagentState(current.state, update.state)
? update.state
: current.state
known.entries.set(update.id, {
...current,
state,
...(state !== current.state && isTerminalSubagentState(state) ? { settledAt: at } : {}),
...(update.tokens ? { tokens: update.tokens } : {})
})
return known
}
if (update.knownOnly) {
return undefined
}
const group = this.groupFor(update.turn ?? join.turn)
if (group.entries.size >= MAX_SUBAGENTS_PER_GROUP) {
return undefined
}
const state = update.state ?? 'working'
group.entries.set(update.id, {
id: update.id,
label: this.claimLabel(group, update.label ?? UNLABELLED),
state,
startedAt: at,
...(isTerminalSubagentState(state) ? { settledAt: at } : {}),
...(update.tokens ? { tokens: update.tokens } : {})
})
this.groupOf.set(update.id, group.groupId)
return group
}
private groupFor(turn: string | undefined): RosterGroup {
const groupId = turn ?? 'thread'
const existing = this.groups.get(groupId)
if (existing) {
return existing
}
const group: RosterGroup = {
groupId,
...(turn === undefined ? {} : { turn }),
entries: new Map(),
labelCounts: new Map(),
lastSerialized: null,
results: new Map()
}
this.groups.set(groupId, group)
return group
}
/** Two subagents with one description stay apart by ordinal, not by an invented name. */
private claimLabel(group: RosterGroup, label: string): string {
const index = group.entries.size
const clipped = boundSubagentField(label, index)
// Copy clipped text so a slice cannot keep the full provider description alive.
const bounded = clipped === label ? label : [...clipped].join('')
const seen = group.labelCounts.get(bounded) ?? 0
group.labelCounts.set(bounded, seen + 1)
return boundSubagentField(seen === 0 ? bounded : `${bounded} ${seen + 1}`, index)
}
private groupEvent(group: RosterGroup, join: ProviderTimelineJoin): AcpTimelineEvent[] {
const entries = [...group.entries.values()]
const body = subagentGroupJournalBody(group.groupId, entries)
const serialized = JSON.stringify(body)
if (serialized === group.lastSerialized) {
return []
}
// A repeated report must not burn a revision; the host retries a refused event itself.
group.lastSerialized = serialized
return [
{
type: 'item.update',
item: `subagents:${group.groupId}`,
body,
subagentIdentities: entries.map((entry) => acpSubagentIdentity(entry.id)),
join: groupJoin(group, join)
join: group.placement
}
]
}
}
function groupJoin(group: RosterGroup, join: ProviderTimelineJoin): ProviderTimelineJoin {
return {
...(join.thread === undefined ? {} : { thread: join.thread }),
...(group.turn ? { turn: group.turn } : {})
private apply(update: AcpSubagentUpdate, join: ProviderTimelineJoin, at: number): void {
const turn = update.turn ?? join.turn
const placement: ProviderTimelineJoin = {
...(join.thread === undefined ? {} : { thread: join.thread }),
...(turn === undefined ? {} : { turn })
}
this.at = at
this.tracker.report({
id: update.id,
group: { id: turn ?? OUTSIDE_TURN, placement: () => placement },
announces: update.knownOnly !== true,
...(update.label !== undefined ? { label: update.label } : {}),
...(update.state !== undefined ? { state: update.state } : {}),
...(update.tokens !== undefined ? { tokens: update.tokens } : {}),
// A subagent the provider runs outlives the prompt turn that spawned it.
backgrounded: true
})
}
}
@@ -1,144 +0,0 @@
// The journal row one Claude spawn group writes: its durable identity and the
// body it revises in place.
import type {
AgentJournalItemBody,
AgentJournalItemIdentity,
AgentJournalTurnScope
} from '../../shared/agent-session-journal-types'
import { subagentGroupFallbackText } from '../../shared/native-chat-subagent-summary'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { RosterGroup } from './claude-subagent-roster-state'
type GroupRowAttempts = (entryId: string) => number
/** A group row an earlier run journaled: its children and the turn it was written beside. */
export type JournaledClaudeSubagentGroup = {
entries: readonly NativeChatSubagentEntry[]
turnScope: AgentJournalTurnScope
}
/** Durable journal identity for the group's row — stable across revisions and
* across a restart, so replay finds the same row instead of appending a new one. */
export function claudeSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity {
return { provider: 'orca', clientMessageId: `claude-subagents:${groupId}` }
}
/**
* A group row an earlier provider run journaled, as this run's roster state.
*
* Inheriting writes nothing: the row is revised only when this run's own events
* change the group, and then it keeps every child the earlier run listed. Each
* child outlived the run that spawned it, so none is tied to this run's turns.
*/
export function inheritedClaudeSubagentGroup(
groupId: string,
{ entries, turnScope }: JournaledClaudeSubagentGroup,
attemptOf: GroupRowAttempts
): RosterGroup {
const group: RosterGroup = {
groupId,
identity: claudeSubagentGroupIdentity(groupId),
// The row keeps the turn it was created beside; a later run's writes never move it.
turnScope,
entries: new Map(),
admittedEntries: entries.length,
claimedLabels: new Set(),
lastSerialized: JSON.stringify(claudeSubagentGroupBody(groupId, entries))
}
for (const entry of entries) {
group.claimedLabels.add(entry.label)
group.entries.set(entry.id, {
entry: { ...entry },
backgrounded: true,
toolUseId: null,
invocationIds: new Set(),
labelBase: entry.label,
attempt: attemptOf(entry.id),
invokedInEarlierRun: true
})
}
return group
}
/** The roster row: the structured block plus the plain sentence an older client
* renders in its place. A message whose only block is the new variant would
* reach such a client with nothing it can draw. */
export function claudeSubagentGroupBody(
groupId: string,
agents: readonly NativeChatSubagentEntry[]
): AgentJournalItemBody {
return {
kind: 'message',
role: 'system',
blocks: [
{ type: 'text', text: subagentGroupFallbackText(agents) },
{ type: 'subagent-group', groupId, agents: [...agents] }
]
}
}
/**
* Revise one group's row to match its current children.
*
* Deliberately ROOT, and it is the one row in this lane where that needs saying:
* it is written from a child's frame but describes the PARENT's children, so it
* is the session's own agent reporting what it spawned. Stamping it as a child's
* would hide the roster from the very row that owns it.
*/
export function writeClaudeSubagentGroupRow(
sink: StructuredAgentSessionEventSink,
group: RosterGroup
): void {
const agents = [...group.entries.values()].map((tracked) => tracked.entry)
if (agents.length === 0) {
const serialized = JSON.stringify(claudeSubagentGroupBody(group.groupId, []))
if (serialized === group.lastSerialized) {
return
}
// Reclassification removes the row; refused tombstones remain retryable.
const admission = sink.tryAppendTombstone?.(group.identity)
if (admission && !admission.accepted) {
group.lastSerialized = null
return
}
if (!admission) {
sink.appendTombstone(group.identity)
}
const publication = sink.tryPublish?.()
if (publication && !publication.accepted) {
group.lastSerialized = null
return
}
if (!publication) {
sink.publish()
}
group.lastSerialized = serialized
return
}
const body = claudeSubagentGroupBody(group.groupId, agents)
const serialized = JSON.stringify(body)
if (serialized === group.lastSerialized) {
// Nothing changed — a duplicate delivery must not burn a revision.
return
}
const options = { turnScope: group.turnScope }
const admission = sink.tryAppendItem?.(group.identity, body, options)
if (admission && !admission.accepted) {
group.lastSerialized = null
return
}
if (!admission) {
sink.appendItem(group.identity, body, options)
}
const publication = sink.tryPublish?.()
if (publication && !publication.accepted) {
group.lastSerialized = null
return
}
if (!publication) {
sink.publish()
}
group.lastSerialized = serialized
}
@@ -7,8 +7,11 @@ import type {
} from '../../shared/agent-session-journal-types'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import type { StructuredAgentSessionLinkageJournal } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { claudeSubagentGroupBody, claudeSubagentGroupIdentity } from './claude-subagent-group-row'
import { ClaudeJournaledRoster } from './claude-subagent-journaled-roster'
import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body'
import {
ClaudeJournaledRoster,
claudeSubagentGroupIdentity
} from './claude-subagent-journaled-roster'
type Row = {
itemId: string
@@ -39,7 +42,7 @@ const groupRow = (
): Row => ({
itemId,
sequence,
body: claudeSubagentGroupBody(groupId, agents),
body: subagentGroupJournalBody(groupId, agents),
linkage: turnScope ? { turnScope } : {}
})
@@ -134,7 +137,7 @@ describe('ClaudeJournaledRoster', () => {
const claimed = journaled.claimGroup('turn-a')
expect(claimed?.entries.map((agent) => agent.id)).toEqual(['task-a', 'task-b'])
// The row keeps the turn it was created beside.
expect(claimed?.turnScope).toEqual(turnA)
expect(claimed?.placement).toEqual(turnA)
// After that the roster's own copy is the newer one.
expect(journaled.claimGroup('turn-a')).toBeNull()
})
@@ -1,120 +1,60 @@
// What earlier runs of this session left of the Claude subagent roster, re-derived
// from the rows they journaled.
//
// The roster lives in one provider process, but everything it writes outlives that
// process: a group row keeps a restart-stable identity, and every agent row names its
// canonical id beside the call its frames arrived under. A run that started from
// nothing re-rostered a resumed child in a second row, restarted its attempts, lost
// the alias its resumed frames still carry, and rewrote the one group row no turn
// owns from empty. So a run reads what earlier runs left instead: derived, never
// stored, so it cannot disagree with the rows it came from.
// Beyond the group rows every agent's tracker inherits, a Claude run needs two more
// facts earlier runs recorded on their child rows: the canonical task id each spawn
// call resolved to (a resumed child's frames still carry the old call), and the
// latest run of each child. Both come from the same read of the journal.
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentJournalProducerLinkage
import type {
AgentJournalItemIdentity,
AgentJournalProducerLinkage,
AgentJournalTurnScope
} from '../../shared/agent-session-journal-types'
import { isSubagentGroupBlock } from '../../shared/native-chat-types'
import type { StructuredAgentSessionLinkageJournal } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { JournaledSubagentGroups } from '../native-chat/subagent-tracker/journaled-subagent-groups'
import type { JournaledSubagentSource } from '../native-chat/subagent-tracker/subagent-tracker-types'
import { isBoundedClaudeTaskId } from './claude-background-task-tracker'
import {
claudeSubagentGroupIdentity,
type JournaledClaudeSubagentGroup
} from './claude-subagent-group-row'
/** Durable journal identity for a group's row — stable across revisions and
* across a restart, so replay finds the same row instead of appending a new one. */
export function claudeSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity {
return { provider: 'orca', clientMessageId: `claude-subagents:${groupId}` }
}
/** What the roster asks of earlier runs. */
export type ClaudeJournaledRosterSource = {
export type ClaudeJournaledRosterSource = JournaledSubagentSource<AgentJournalTurnScope> & {
/** The task id an earlier run resolved this spawn call to. */
canonical: (toolUseId: string) => string | null
/** The group row an earlier run last listed this child in. */
groupOf: (entryId: string) => string | null
/** An earlier run's row for one group, handed over once: after that the
* roster's own copy is the newer one. */
claimGroup: (groupId: string) => JournaledClaudeSubagentGroup | null
/** The latest run of this child any row records; 1 when none says more. */
attempt: (agentId: string) => number
}
type JournaledRosterReading = {
type ClaudeAgentRows = {
canonicalByToolUse: Map<string, string>
attemptByAgent: Map<string, number>
rowsByGroup: Map<string, JournaledClaudeSubagentGroup>
groupByEntry: Map<string, string>
}
/**
* Read once per bound journal epoch. Before the journal is bound nothing is read and
* nothing is kept, so the first read after bind still sees every row.
*/
export class ClaudeJournaledRoster implements ClaudeJournaledRosterSource {
private journal: StructuredAgentSessionLinkageJournal | null = null
private epoch: string | null = null
private reading: JournaledRosterReading | null = null
private readonly rows: JournaledSubagentGroups<ClaudeAgentRows>
constructor(private readonly bound: () => StructuredAgentSessionLinkageJournal | null) {}
constructor(bound: () => StructuredAgentSessionLinkageJournal | null) {
this.rows = new JournaledSubagentGroups(bound, claudeSubagentGroupIdentity, {
create: () => ({ canonicalByToolUse: new Map(), attemptByAgent: new Map() }),
visit: readAgentRow
})
}
canonical = (toolUseId: string): string | null =>
this.current()?.canonicalByToolUse.get(toolUseId) ?? null
this.rows.extra()?.canonicalByToolUse.get(toolUseId) ?? null
groupOf = (entryId: string): string | null => this.current()?.groupByEntry.get(entryId) ?? null
groupOf = (entryId: string): string | null => this.rows.groupOf(entryId)
claimGroup = (groupId: string): JournaledClaudeSubagentGroup | null => {
const reading = this.current()
const row = reading?.rowsByGroup.get(groupId) ?? null
reading?.rowsByGroup.delete(groupId)
return row
}
claimGroup = (groupId: string) => this.rows.claimGroup(groupId)
attempt = (agentId: string): number => this.current()?.attemptByAgent.get(agentId) ?? 1
private current(): JournaledRosterReading | null {
const journal = this.bound()
if (!journal) {
return null
}
if (!this.reading || journal !== this.journal || journal.epoch !== this.epoch) {
this.journal = journal
this.epoch = journal.epoch
this.reading = readJournaledRoster(journal)
}
return this.reading
}
}
function readJournaledRoster(
journal: StructuredAgentSessionLinkageJournal
): JournaledRosterReading {
const reading: JournaledRosterReading = {
canonicalByToolUse: new Map(),
attemptByAgent: new Map(),
rowsByGroup: new Map(),
groupByEntry: new Map()
}
// A child two rows list (only an older build wrote that) is the later-created row's: a turn's
// row is created with its turn, so that is where it last ran.
const listedAt = new Map<string, number>()
journal.visitItemsWithLinkage((itemId, sequence, body, attribution) => {
readAgentRow(reading, attribution)
const group = body.kind === 'message' ? body.blocks.find(isSubagentGroupBlock) : undefined
if (!group || itemId !== agentJournalItemKey(claudeSubagentGroupIdentity(group.groupId))) {
return
}
reading.rowsByGroup.set(group.groupId, {
entries: group.agents,
turnScope: attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE
})
for (const entry of group.agents) {
if ((listedAt.get(entry.id) ?? -1) < sequence) {
listedAt.set(entry.id, sequence)
reading.groupByEntry.set(entry.id, group.groupId)
}
}
})
return reading
attempt = (agentId: string): number => this.rows.extra()?.attemptByAgent.get(agentId) ?? 1
}
function readAgentRow(
reading: JournaledRosterReading,
reading: ClaudeAgentRows,
{ agentId, providerParentRef, producerKind, attempt }: AgentJournalProducerLinkage
): void {
if (producerKind !== 'agent' || agentId === undefined) {
@@ -15,7 +15,8 @@ import type { AgentSessionJournal } from '../native-chat/agent-session-journal/j
import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support'
import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
import { claudeSubagentGroupBody, claudeSubagentGroupIdentity } from './claude-subagent-group-row'
import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body'
import { claudeSubagentGroupIdentity } from './claude-subagent-journaled-roster'
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
@@ -210,7 +211,7 @@ async function journalAnOlderBuildListedTwice(): Promise<AgentSessionJournal> {
] as const) {
older.sink.appendItem(
claudeSubagentGroupIdentity(groupId),
claudeSubagentGroupBody(groupId, agents),
subagentGroupJournalBody(groupId, agents),
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
}
@@ -0,0 +1,196 @@
// Golden row writes for the Claude subagent roster: every append, tombstone and publish it makes
// for scripted frame sequences, so moving the roster onto shared code cannot change a saved byte.
import { describe, expect, it } from 'vitest'
import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import { recordingSubagentRowSink } from '../native-chat/subagent-row-sink-recorder-test-support'
import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster'
import { ClaudeSubagentRoster } from './claude-subagent-roster'
/** The clock moves once per delivered frame, never per read, so a row's times do not depend on how
* often the roster happens to read it. */
function harness(journaled?: ClaudeJournaledRosterSource) {
const recorder = recordingSubagentRowSink()
let clock = 1_000
let key: string | null = 's:turn-1'
const scope = (): AgentJournalTurnScope =>
key === null ? { kind: 'thread' } : { kind: 'turn', turnItemId: `turn-row:${key}` }
const inner = new ClaudeSubagentRoster({
sink: recorder.sink,
currentGroupKey: () => key,
currentTurnScope: scope,
...(journaled ? { journaled } : {}),
now: () => clock
})
const tick = <T>(deliver: () => T): T => {
clock += 10
return deliver()
}
const roster = {
observeSystemFrame: (message: Record<string, unknown>) =>
tick(() => inner.observeSystemFrame(message)),
observeChildActivity: (toolUseId: string) => tick(() => inner.observeChildActivity(toolUseId)),
observeToolResult: (toolUseId: string, failed: boolean) =>
tick(() => inner.observeToolResult(toolUseId, failed)),
settleTurn: (groupKey: string | null) => tick(() => inner.settleTurn(groupKey)),
settleSession: () => tick(() => inner.settleSession()),
dispose: () => tick(() => inner.dispose())
}
return {
...recorder,
roster,
turn: (next: string | null) => {
key = next
}
}
}
function frame(subtype: string, fields: Record<string, unknown>): Record<string, unknown> {
return { type: 'system', subtype, session_id: 's', ...fields }
}
const started = (fields: Record<string, unknown>) =>
frame('task_started', { task_type: 'local_agent', ...fields })
const notified = (fields: Record<string, unknown>) => frame('task_notification', fields)
describe('Claude subagent roster rows (characterization)', () => {
it('one foreground child settled by its spawn call result', () => {
const { roster, log } = harness()
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Audit' }))
roster.observeSystemFrame(frame('task_progress', { task_id: 't1', tool_use_id: 'u1' }))
roster.observeToolResult('u1', false)
roster.observeToolResult('u1', false)
roster.settleTurn('s:turn-1')
expect(log).toMatchSnapshot()
})
it('two children sharing a label, one backgrounded, through turn end and session end', () => {
const { roster, log } = harness()
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Audit' }))
roster.observeSystemFrame(
started({ task_id: 't2', tool_use_id: 'u2', description: 'Audit', is_backgrounded: true })
)
roster.observeSystemFrame(started({ task_id: 't3', tool_use_id: 'u3', description: 'Audit 2' }))
roster.settleTurn('s:turn-1')
roster.observeSystemFrame(notified({ task_id: 't3', tool_use_id: 'u3', status: 'completed' }))
roster.settleSession()
roster.dispose()
expect(log).toMatchSnapshot()
})
it('verdicts after lost contact, failures and stops', () => {
const { roster, log } = harness()
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'A' }))
roster.observeSystemFrame(started({ task_id: 't2', tool_use_id: 'u2', description: 'B' }))
roster.observeSystemFrame(started({ task_id: 't3', tool_use_id: 'u3', description: 'C' }))
roster.observeSystemFrame(notified({ task_id: 't2', tool_use_id: 'u2', status: 'killed' }))
roster.observeToolResult('u3', true)
roster.settleTurn('s:turn-1')
roster.observeSystemFrame(notified({ task_id: 't1', tool_use_id: 'u1', status: 'completed' }))
roster.observeSystemFrame(notified({ task_id: 't2', tool_use_id: 'u2', status: 'completed' }))
expect(log).toMatchSnapshot()
})
it('more than 32 settled groups, then a late frame for an evicted child', () => {
const { roster, log, turn } = harness()
for (let index = 0; index < 34; index++) {
turn(`s:turn-${index}`)
roster.observeSystemFrame(
started({ task_id: `t${index}`, tool_use_id: `u${index}`, description: `Job ${index}` })
)
roster.observeSystemFrame(
notified({ task_id: `t${index}`, tool_use_id: `u${index}`, status: 'completed' })
)
}
turn('s:turn-live')
roster.observeSystemFrame(started({ task_id: 'live', tool_use_id: 'ul', description: 'Live' }))
roster.observeSystemFrame(notified({ task_id: 't0', tool_use_id: 'u0', status: 'failed' }))
roster.observeSystemFrame(started({ task_id: 't0', tool_use_id: 'u0', description: 'Job 0' }))
expect(log.length).toMatchSnapshot()
expect(log.slice(-6)).toMatchSnapshot()
})
it('a provisional row from child traffic takes the announced name and canonical id', () => {
const { roster, log } = harness()
roster.observeChildActivity('u1')
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Named' }))
roster.observeChildActivity('u1')
roster.observeChildActivity('u-unknown')
roster.observeSystemFrame(notified({ task_id: 't1', tool_use_id: 'u1', status: 'completed' }))
expect(log).toMatchSnapshot()
})
it('an announcement that is not a subagent tombstones the provisional row', () => {
const { roster, log } = harness()
roster.observeChildActivity('u1')
roster.observeSystemFrame(
frame('task_started', { task_id: 't1', tool_use_id: 'u1', task_type: 'local_bash' })
)
roster.observeChildActivity('u1')
expect(log).toMatchSnapshot()
})
it('a resume under a new tool id in a later turn reopens the original row', () => {
const { roster, log, turn } = harness()
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Audit' }))
roster.observeToolResult('u1', false)
roster.settleTurn('s:turn-1')
turn('s:turn-2')
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1b', description: 'Audit' }))
roster.observeSystemFrame(notified({ task_id: 't1', tool_use_id: 'u1', status: 'failed' }))
roster.observeToolResult('u1b', false)
roster.settleTurn('s:turn-2')
expect(log).toMatchSnapshot()
})
it('a child announced outside any turn is not swept by a turn end', () => {
const { roster, log, turn } = harness()
turn(null)
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Loose' }))
turn('s:turn-9')
roster.settleTurn('s:turn-9')
roster.settleTurn(null)
expect(log).toMatchSnapshot()
})
it('a refused append or publish is retried by the next write of that row', () => {
const { roster, log, refuseNextAppend, refuseNextPublish } = harness()
refuseNextAppend()
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'A' }))
roster.observeSystemFrame(frame('task_progress', { task_id: 't1', tool_use_id: 'u1' }))
roster.observeSystemFrame(started({ task_id: 't2', tool_use_id: 'u2', description: 'B' }))
refuseNextPublish()
roster.observeToolResult('u2', false)
roster.observeToolResult('u2', false)
roster.settleTurn('s:turn-1')
expect(log).toMatchSnapshot()
})
it('a later run inherits the row an earlier run journaled and revises it', () => {
const earlier: NativeChatSubagentEntry[] = [
{ id: 't1', label: 'Audit', state: 'working', startedAt: 5 },
{ id: 't2', label: 'Audit 2', state: 'completed', startedAt: 6, settledAt: 9 }
]
let claimed = false
const journaled: ClaudeJournaledRosterSource = {
canonical: (toolUseId) => (toolUseId === 'u1' ? 't1' : null),
groupOf: (id) => (id === 't1' || id === 't2' ? 's:turn-0' : null),
claimGroup: (groupId) => {
if (groupId !== 's:turn-0' || claimed) {
return null
}
claimed = true
return { entries: earlier, placement: { kind: 'turn', turnItemId: 'turn-row:s:turn-0' } }
},
attempt: (id) => (id === 't1' ? 2 : 1)
}
const { roster, log, turn } = harness(journaled)
turn('s:turn-3')
roster.observeSystemFrame(notified({ task_id: 't1', status: 'completed' }))
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u9', description: 'Audit' }))
roster.observeSystemFrame(started({ task_id: 't4', tool_use_id: 'u4', description: 'Audit' }))
roster.settleSession()
expect(log).toMatchSnapshot()
})
})
@@ -1,152 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentJournalTurnScope
} from '../../shared/agent-session-journal-types'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import type { JournaledClaudeSubagentGroup } from './claude-subagent-group-row'
import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster'
import { ClaudeSubagentRosterGroups } from './claude-subagent-roster-groups'
import { inheritedClaudeSubagentGroup } from './claude-subagent-group-row'
const entry = (id: string): NativeChatSubagentEntry => ({
id,
label: `Child ${id}`,
state: 'unverifiable',
startedAt: 1,
settledAt: 2
})
const turnScope = (groupId: string): AgentJournalTurnScope => ({
kind: 'turn',
turnItemId: `claude:claude-session:${groupId}`
})
/** An older build listed `agent-a` in both rows; the reading places it in the later one. */
function journaledTwiceListed(): ClaudeJournaledRosterSource {
const rows = new Map<string, JournaledClaudeSubagentGroup>([
['turn-a', { entries: [entry('agent-a'), entry('agent-b')], turnScope: turnScope('turn-a') }],
['turn-b', { entries: [entry('agent-a')], turnScope: turnScope('turn-b') }]
])
const groupOf = new Map([
['agent-a', 'turn-b'],
['agent-b', 'turn-a']
])
return {
canonical: () => null,
groupOf: (id) => groupOf.get(id) ?? null,
claimGroup: (groupId) => {
const row = rows.get(groupId) ?? null
rows.delete(groupId)
return row
},
attempt: () => 1
}
}
describe('ClaudeSubagentRosterGroups', () => {
it('places a twice-listed child where the reading does, whichever row is reached first', () => {
for (const order of [
['agent-a', 'agent-b'],
['agent-b', 'agent-a']
]) {
const groups = new ClaudeSubagentRosterGroups({
journaled: journaledTwiceListed(),
currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE
})
for (const id of order) {
groups.locateOrInherit(id)
}
expect(groups.locate('agent-a')?.group.groupId).toBe('turn-b')
expect(groups.locate('agent-b')?.group.groupId).toBe('turn-a')
}
})
it('keeps a child placed in a held row when another row listing it is evicted', () => {
const groups = new ClaudeSubagentRosterGroups({
journaled: journaledTwiceListed(),
currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE
})
const inherited = groups.locateOrInherit('agent-b')
groups.locateOrInherit('agent-a')
if (inherited) {
groups.trim(inherited.group)
}
// Enough of this run's own rows to push the older inherited row out.
for (let turn = 0; turn < 31; turn += 1) {
groups.trim(groups.groupFor(`turn-${turn}-new`))
}
expect(groups.get('turn-a')).toBeUndefined()
expect(groups.locate('agent-b')).toBeNull()
expect(groups.locate('agent-a')?.group.groupId).toBe('turn-b')
})
it('keeps an inherited row beside the turn that wrote it, and puts a new row beside the open turn', () => {
const live = turnScope('turn-live')
const groups = new ClaudeSubagentRosterGroups({
journaled: journaledTwiceListed(),
currentTurnScope: () => live
})
expect(groups.locateOrInherit('agent-b')?.group.turnScope).toEqual(turnScope('turn-a'))
expect(groups.groupFor('turn-live').turnScope).toEqual(live)
})
})
it('bounds settled groups and invocation identities without evicting live groups', () => {
const groups = new ClaudeSubagentRosterGroups({
currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE
})
for (let index = 0; index < 35; index++) {
const group = groups.groupFor(`live-${index}`)
const tracked = inheritedClaudeSubagentGroup(
'seed',
{
entries: [{ ...entry(`live-child-${index}`), state: 'working' }],
turnScope: AGENT_JOURNAL_THREAD_SCOPE
},
() => 1
).entries.get(`live-child-${index}`)
if (!tracked) {
throw new Error('missing fixture child')
}
group.entries.set(tracked.entry.id, tracked)
group.lastSerialized = 'written'
groups.place(tracked.entry.id, group.groupId)
groups.trim(group)
}
for (let index = 0; index < 1100; index++) {
const group = groups.groupFor(`settled-${index}`)
const id = `settled-child-${index}`
const tracked = inheritedClaudeSubagentGroup(
'seed',
{
entries: [entry(id)],
turnScope: AGENT_JOURNAL_THREAD_SCOPE
},
() => 1
).entries.get(id)
if (!tracked) {
throw new Error('missing fixture child')
}
tracked.invocationIds = new Set([`tool-${index}`])
group.entries.set(id, tracked)
group.lastSerialized = 'written'
groups.place(id, group.groupId)
groups.trim(group)
}
expect([...groups.values()]).toHaveLength(35)
expect(groups.locate('live-child-0')?.tracked.entry.state).toBe('working')
expect(groups.locate('settled-child-1099')).toBeNull()
expect(groups.hasSettled('settled-child-1099', 'tool-1099')).toBe(true)
expect(groups.hasSettled('settled-child-0', 'tool-0')).toBe(false)
for (const group of groups.values()) {
for (const tracked of group.entries.values()) {
tracked.entry.state = 'completed'
}
groups.trim(group)
}
expect([...groups.values()]).toHaveLength(32)
groups.clear()
expect([...groups.values()]).toHaveLength(0)
expect(groups.hasSettled('settled-child-1099', 'tool-1099')).toBe(false)
})
@@ -1,150 +0,0 @@
// Which spawn group holds each Claude subagent: the groups this provider run
// wrote, and the ones earlier runs of the session journaled, inherited one at a
// time as this run's own events reach them.
import { SubagentRosterRetention } from '../native-chat/subagent-roster-retention'
import {
claudeSubagentGroupIdentity,
inheritedClaudeSubagentGroup
} from './claude-subagent-group-row'
import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types'
import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster'
import type { RosterGroup, TrackedEntry } from './claude-subagent-roster-state'
/** Settled history target; groups with unfinished children remain owned. */
const MAX_SUBAGENT_GROUPS = 32
export type LocatedClaudeSubagent = { group: RosterGroup; tracked: TrackedEntry }
export class ClaudeSubagentRosterGroups {
private readonly groups = new Map<string, RosterGroup>()
/** Canonical id → the group holding its entry, so a late update for a child
* from an earlier turn revises that turn's row instead of the live one. */
private readonly groupIdByEntry = new Map<string, string>()
private readonly retention = new SubagentRosterRetention(this.groups, {
maxGroups: MAX_SUBAGENT_GROUPS,
maxSettledIdentities: 2048,
entries: (group) => [...group.entries.values()].map((tracked) => tracked.entry),
identities: (group) =>
[...group.entries]
.filter(([id]) => this.groupIdByEntry.get(id) === group.groupId)
.flatMap(([id, tracked]) => [
claudeSubagentRetentionIdentity(id, null),
...[...new Set([...(tracked.invocationIds ?? []), tracked.toolUseId])].map((toolUseId) =>
claudeSubagentRetentionIdentity(id, toolUseId)
)
]),
onEvict: (group) => {
for (const id of group.entries.keys()) {
if (this.groupIdByEntry.get(id) === group.groupId) {
this.groupIdByEntry.delete(id)
}
}
}
})
constructor(
private readonly deps: {
journaled?: ClaudeJournaledRosterSource
/** The open turn's scope, which a group this run creates belongs to. */
currentTurnScope: () => AgentJournalTurnScope
}
) {}
get(groupId: string): RosterGroup | undefined {
return this.groups.get(groupId)
}
values(): Iterable<RosterGroup> {
return this.groups.values()
}
locate(id: string): LocatedClaudeSubagent | null {
const groupId = this.groupIdByEntry.get(id)
const group = groupId === undefined ? undefined : this.groups.get(groupId)
const tracked = group?.entries.get(id)
return group && tracked ? { group, tracked } : null
}
/** Finds a child, inheriting the group an earlier run last listed it in. */
locateOrInherit(id: string): LocatedClaudeSubagent | null {
const located = this.locate(id)
if (located) {
return located
}
const groupId = this.deps.journaled?.groupOf(id) ?? null
return groupId !== null && !this.groups.has(groupId) && this.inherit(groupId)
? this.locate(id)
: null
}
/** This run's group for a key, else the earlier run's row it continues — the
* key no turn owns is reused across runs — else a new one. */
groupFor(groupId: string): RosterGroup {
const existing = this.groups.get(groupId) ?? this.inherit(groupId)
if (existing) {
return existing
}
const group: RosterGroup = {
groupId,
identity: claudeSubagentGroupIdentity(groupId),
turnScope: this.deps.currentTurnScope(),
entries: new Map(),
admittedEntries: 0,
claimedLabels: new Set(),
lastSerialized: null
}
this.admit(group)
return group
}
place(id: string, groupId: string): void {
this.groupIdByEntry.set(id, groupId)
}
forget(id: string): void {
this.groupIdByEntry.delete(id)
}
hasSettled(id: string, toolUseId: string | null): boolean {
return this.retention.hasSettled(claudeSubagentRetentionIdentity(id, toolUseId))
}
trim(group: RosterGroup, retainAccessed = false): void {
this.retention.trim([group], retainAccessed ? group.groupId : undefined)
}
clear(): void {
this.retention.clear()
this.groups.clear()
this.groupIdByEntry.clear()
}
/** Once per group: after that this run's copy is the newer one. */
private inherit(groupId: string): RosterGroup | null {
const journaled = this.deps.journaled
const row = journaled?.claimGroup(groupId) ?? null
if (!journaled || !row) {
return null
}
const group = inheritedClaudeSubagentGroup(groupId, row, journaled.attempt)
this.admit(group)
for (const id of group.entries.keys()) {
// A child an older build listed in two rows lives only in the one the reading chose, whichever
// row this run's frames reach first; the other copy is history.
if (journaled.groupOf(id) === groupId) {
this.groupIdByEntry.set(id, groupId)
}
}
return group
}
private admit(group: RosterGroup): void {
this.groups.set(group.groupId, group)
}
}
function claudeSubagentRetentionIdentity(id: string, toolUseId: string | null): string {
return JSON.stringify([id, toolUseId])
}
@@ -159,7 +159,7 @@ describe('Claude shared settled-only roster retention', () => {
startedAt: 1,
settledAt: 2
})),
turnScope: AGENT_JOURNAL_THREAD_SCOPE
placement: AGENT_JOURNAL_THREAD_SCOPE
}
},
attempt: () => 4
@@ -1,117 +0,0 @@
import type {
AgentJournalItemIdentity,
AgentJournalTurnScope
} from '../../shared/agent-session-journal-types'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import type { ClaudeSubagentTaskFrame } from './claude-subagent-task-frames'
const MAX_INVOCATIONS_PER_SUBAGENT = 16
export type TrackedEntry = {
entry: NativeChatSubagentEntry
/** The only signal separating a child that dies with its turn from one told to
* outlive it. A turn-end sweep must leave a backgrounded child alone. */
backgrounded: boolean
toolUseId: string | null
invocationIds: Set<string> | null
/** Label before its ordinal suffix, so a later announcement can tell a
* provisional row from one that already carries the provider's own name. */
labelBase: string
/** Which run of this child. Identity survives a resume by design, so without
* this the retained rows of two runs read as one uninterrupted timeline. */
attempt: number
/** Inherited from a row an earlier provider run journaled, and not announced in
* this run yet. That run's calls are unknown here, so any announcement is a new
* invocation, and only an announcement reopens what that run settled. Any other
* frame can still give that run's outcome, such as Claude's "didn't finish before
* the previous session ended". */
invokedInEarlierRun: boolean
}
export type RosterGroup = {
groupId: string
identity: AgentJournalItemIdentity
/** The spawning turn's scope: the row reports its children beside that turn's work. */
turnScope: AgentJournalTurnScope
/** Insertion order is the display order; the map holds the state. */
entries: Map<string, TrackedEntry>
/** Lifetime admissions bound retained labels even when entries are removed. */
admittedEntries: number
/** Labels remain reserved after removal or provisional-name replacement. */
claimedLabels: Set<string>
/** Last body written, so an idempotent replay writes no new revision. */
lastSerialized: string | null
}
// Invocation history stays with the entry, independent of the evicting alias cache.
export function applyClaudeSubagentInvocation(
tracked: TrackedEntry,
frame: ClaudeSubagentTaskFrame,
now: () => number
): boolean {
if (tracked.invocationIds === null) {
return false
}
if (tracked.invokedInEarlierRun) {
if (!frame.announcement) {
// A verdict on that run, not an invocation of this one; the latch still guards it.
return true
}
reopen(tracked, frame, now)
if (frame.toolUseId) {
tracked.invocationIds.add(frame.toolUseId)
tracked.toolUseId = frame.toolUseId
}
return true
}
const newInvocation =
frame.announcement && frame.toolUseId !== null && !tracked.invocationIds.has(frame.toolUseId)
if (newInvocation && frame.toolUseId) {
if (tracked.invocationIds.size >= MAX_INVOCATIONS_PER_SUBAGENT) {
tracked.invocationIds = null
tracked.entry = { ...tracked.entry, state: 'unverifiable', settledAt: now() }
return true
}
tracked.invocationIds.add(frame.toolUseId)
if (tracked.toolUseId !== null && tracked.toolUseId !== frame.toolUseId) {
// THE reactivation: a new spawn alias reopening this entry. Gated on the
// observed alias change rather than on the counter, so a late duplicate
// cannot advance a settled run.
reopen(tracked, frame, now)
}
tracked.toolUseId = frame.toolUseId
} else if (tracked.toolUseId && frame.toolUseId && tracked.toolUseId !== frame.toolUseId) {
return false
}
if (tracked.toolUseId === null) {
tracked.toolUseId = frame.toolUseId
}
return true
}
/** The one place the attempt moves. A new run starts its own clock, so the
* idle time since the last run never reads as run time. */
function reopen(tracked: TrackedEntry, frame: ClaudeSubagentTaskFrame, now: () => number): void {
tracked.invokedInEarlierRun = false
tracked.attempt += 1
tracked.backgrounded = frame.backgrounded ?? false
tracked.entry = {
...tracked.entry,
state: frame.state ?? 'working',
startedAt: now(),
settledAt: undefined
}
}
/** Two children can share a description; the ordinal keeps their rows apart
* without inventing a name the provider never sent. The probe is over the
* labels actually rendered, not a per-base counter: a generated `Audit 2`
* must not collide with a provider that names its own child `Audit 2`. */
export function claimClaudeSubagentLabel(group: RosterGroup, base: string): string {
let candidate = base
for (let ordinal = 2; group.claimedLabels.has(candidate); ordinal++) {
candidate = `${base} ${ordinal}`
}
group.claimedLabels.add(candidate)
return candidate
}
@@ -589,17 +589,20 @@ describe('ClaudeSubagentRoster — invocation fences', () => {
expect(rolesIn(TURN_1)[0].state).toBe('completed')
})
it('bounds invocation history and refuses to reopen beyond the retained budget', () => {
it('keeps reopening a child resumed many times, and never revives a run it has forgotten', () => {
const { roster, roles } = harness()
for (let i = 0; i < 20; i++) {
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: `tool-${i}` }))
if (i >= 16) {
expect(roles()[0].state).toBe('unverifiable')
}
expect(roles()[0].state).toBe('working')
roster.observeToolResult(`tool-${i}`, false)
expect(roles()[0].state).toBe('completed')
}
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'tool-0' }))
expect(roles()[0].state).toBe('unverifiable')
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'tool-19' }))
expect(roles()[0].state).toBe('completed')
expect(roster.linkage.settledLinkageFor('tool-19')).toMatchObject({
linkage: { agentId: 'task-1', attempt: 20 }
})
})
})
+72 -233
View File
@@ -7,48 +7,31 @@
// no task frames.
//
// Claude re-announces a resumed task under a NEW `tool_use_id`, so `task_id` is
// the key and tool ids are aliases; keying on the tool id would duplicate the
// child on every resume. Outcomes latch within an invocation; a new spawn
// alias can reopen it, and authoritative evidence can correct lost contact.
//
// The state is one provider run's, but its rows are the session's. A run
// inherits what earlier runs journaled, one group at a time as its own events
// reach it, so a restart neither splits a child nor erases a row's children.
// the key and each tool id is one run of it; keying on the tool id would
// duplicate the child on every resume. Everything the row says follows the
// shared subagent tracker's rules; this module only reads Claude's frames.
import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types'
import {
canReplaceSubagentState,
isTerminalSubagentState
} from '../../shared/native-chat-subagent-summary'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { subagentRowSinkPort } from '../native-chat/subagent-tracker/subagent-row-sink-port'
import { SubagentTracker } from '../native-chat/subagent-tracker/subagent-tracker'
import type { SubagentReport } from '../native-chat/subagent-tracker/subagent-tracker-types'
import { isBoundedClaudeTaskId } from './claude-background-task-tracker'
import { writeClaudeSubagentGroupRow } from './claude-subagent-group-row'
import { ClaudeSubagentIds } from './claude-subagent-id-aliases'
import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster'
import { ClaudeSubagentRosterGroups } from './claude-subagent-roster-groups'
import {
claudeSubagentGroupIdentity,
type ClaudeJournaledRosterSource
} from './claude-subagent-journaled-roster'
import {
ClaudeSubagentLinkage,
type ClaudeAgentLinkageSource,
type ClaudeSubagentLinkageSource
} from './claude-subagent-linkage'
import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames'
import {
applyClaudeSubagentInvocation,
claimClaudeSubagentLabel,
type RosterGroup,
type TrackedEntry
} from './claude-subagent-roster-state'
/** Children per spawn-group row. Bounds an event-accumulated map that no
* provider snapshot ever prunes. */
const MAX_SUBAGENTS_PER_GROUP = 64
/** The turn a group belongs to when Claude reports a task outside any turn. */
const OUTSIDE_TURN = 'outside-turn'
const UNLABELLED_AGENT = 'subagent'
export type ClaudeSubagentRosterDeps = {
sink: StructuredAgentSessionEventSink
/** The turn that owns children spawned right now; null outside any turn. */
@@ -73,7 +56,7 @@ export type ClaudeSubagentRosterDeps = {
}
export class ClaudeSubagentRoster {
private readonly groups: ClaudeSubagentRosterGroups
private readonly tracker: SubagentTracker<AgentJournalTurnScope>
private readonly ids: ClaudeSubagentIds
/** Who produced a row, for every write site journaling this session. */
readonly linkage: ClaudeSubagentLinkageSource & ClaudeAgentLinkageSource
@@ -81,21 +64,22 @@ export class ClaudeSubagentRoster {
* this CLI has proven it declares its tasks, child traffic for an id it never
* announced is a nested tool or a grandchild, not a subagent. */
private announcesTasks = false
private readonly now: () => number
constructor(private readonly deps: ClaudeSubagentRosterDeps) {
this.now = deps.now ?? (() => Date.now())
this.groups = new ClaudeSubagentRosterGroups({
journaled: deps.journaled,
currentTurnScope: deps.currentTurnScope
this.tracker = new SubagentTracker({
port: subagentRowSinkPort(deps.sink, claudeSubagentGroupIdentity),
outsideTurn: (groupId) => groupId === OUTSIDE_TURN,
...(deps.journaled ? { journaled: deps.journaled } : {}),
...(deps.now ? { now: deps.now } : {})
})
this.ids = new ClaudeSubagentIds(deps.journaled?.canonical)
this.linkage = new ClaudeSubagentLinkage({
ids: this.ids,
trackedFor: (canonicalId) => this.groups.locate(canonicalId)?.tracked ?? null,
trackedFor: (canonicalId) =>
this.tracker.locate(canonicalId) ? { attempt: this.tracker.attempt(canonicalId) } : null,
isForwardedParentTool: deps.isForwardedParentTool,
childOwnerRefOf: deps.childOwnerRefOf,
spawnRefOf: (canonicalId) => this.groups.locate(canonicalId)?.tracked.toolUseId ?? null
spawnRefOf: (canonicalId) => this.tracker.locate(canonicalId)?.tracked.run ?? null
})
}
@@ -112,7 +96,7 @@ export class ClaudeSubagentRoster {
for (const id of [frame.taskId, frame.toolUseId]) {
if (id !== null) {
this.ids.exclude(id)
this.remove(id)
this.tracker.remove(id)
}
}
return true
@@ -122,36 +106,19 @@ export class ClaudeSubagentRoster {
}
if (frame.toolUseId) {
this.ids.alias(frame.toolUseId, frame.taskId)
}
if (
this.groups.hasSettled(frame.taskId, frame.toolUseId) &&
(frame.toolUseId !== null || !this.groups.locate(frame.taskId))
) {
return true
}
const located =
this.groups.locateOrInherit(frame.taskId) ??
(frame.toolUseId ? this.adopt(frame.toolUseId, frame.taskId) : null)
if (!located) {
if (frame.announcesSubagent) {
this.create(
frame.taskId,
frame.label,
frame.state ?? 'working',
frame.backgrounded ?? false,
frame.toolUseId
)
if (!this.tracker.locate(frame.taskId)) {
// A provisional row built under the tool id takes the canonical id the announcement names.
this.tracker.rekey(frame.toolUseId, frame.taskId)
}
return true
}
const tracked = located.group.entries.get(frame.taskId)
if (tracked && !applyClaudeSubagentInvocation(tracked, frame, this.now)) {
return true
}
this.revise(located.group, frame.taskId, {
this.tracker.report({
id: frame.taskId,
...(frame.toolUseId ? { run: frame.toolUseId } : {}),
group: this.currentGroup(),
announces: frame.announcesSubagent,
label: frame.label,
state: frame.state,
backgrounded: frame.backgrounded
...(frame.backgrounded !== null ? { backgrounded: frame.backgrounded } : {})
})
return true
}
@@ -165,35 +132,37 @@ export class ClaudeSubagentRoster {
const canonical = this.ids.canonical(parentToolUseId)
if (
this.ids.isExcluded(parentToolUseId, canonical) ||
this.groups.hasSettled(canonical, parentToolUseId) ||
(!this.groups.locate(canonical) && this.groups.hasSettled(canonical, null))
this.tracker.hasSettled(canonical, parentToolUseId)
) {
return
}
if (this.groups.locate(canonical)) {
return
}
const inherited = this.groups.locateOrInherit(canonical)
if (inherited) {
const located = this.tracker.locate(canonical)
if (located) {
// Child traffic can precede the announcement that reopens an inherited row.
this.groups.trim(inherited.group, true)
if (located.tracked.inherited) {
this.tracker.retain(located.group)
}
return
}
if (this.announcesTasks) {
// A nested Task, a workflow child, or a grandchild parented to a tool id
// inside the sidechain all reach here. This CLI announces what it spawns,
// so an id it never declared cannot be a subagent — and a row invented for
// one is unlabelled forever and can only ever end `unverifiable`. The
// bounded exclusion set cannot cover an id that was never announced.
if (
this.tracker.hasSettled(canonical, null) ||
// This CLI announces what it spawns, so an id it never declared is a nested
// Task, a workflow child, or a grandchild — not a subagent. A row invented
// for one is unlabelled forever and can only ever end `unverifiable`.
this.announcesTasks ||
// A provisional id becomes the same durable entry key, so it takes the
// bound an announced id does.
!isBoundedClaudeTaskId(canonical)
) {
return
}
if (!isBoundedClaudeTaskId(canonical)) {
// `claudeTaskId` rejects an over-long announced id rather than truncating
// it; a provisional id becomes the same durable entry key, so it cannot
// enter under a looser rule.
return
}
this.create(canonical, null, 'working', false, parentToolUseId)
this.tracker.report({
id: canonical,
run: parentToolUseId,
group: this.currentGroup(),
announces: true,
state: 'working'
})
}
/**
@@ -204,181 +173,51 @@ export class ClaudeSubagentRoster {
*/
observeToolResult(toolUseId: string, failed: boolean): void {
const canonical = this.ids.canonical(toolUseId)
const located = this.groups.locate(canonical)
const located = this.tracker.locate(canonical)
if (
!located ||
located.tracked.invocationIds === null ||
located.tracked.backgrounded ||
(located.tracked.toolUseId !== null && located.tracked.toolUseId !== toolUseId)
(located.tracked.run !== null && located.tracked.run !== toolUseId)
) {
return
}
this.revise(located.group, canonical, {
label: null,
state: failed ? 'failed' : 'completed',
backgrounded: false
this.tracker.report({
id: canonical,
run: toolUseId,
group: this.currentGroup(),
announces: false,
state: failed ? 'failed' : 'completed'
})
}
/**
* The parent turn ended. A foreground child still reported as working will
* never be settled by an event, so it becomes `unverifiable`: contact was
* lost, which is NOT evidence the child exited. A backgrounded child was
* explicitly told to outlive the turn and is left alone.
*/
/** The parent turn ended. A foreground child still working loses contact with
* it; a backgrounded one was told to outlive the turn. Only the group this key
* names: `OUTSIDE_TURN` belongs to no turn, so no turn's end says anything about it. */
settleTurn(groupKey: string | null): void {
// Only the group this key names. `OUTSIDE_TURN` belongs to no turn, so an
// unrelated turn ending is no evidence about a child announced outside it.
// `settleSession` reaches what no turn does.
this.sweep(this.groups.get(groupKey ?? OUTSIDE_TURN), false)
this.tracker.settleTurn(groupKey ?? OUTSIDE_TURN)
this.deps.onIdentitiesFinal?.()
}
/** The provider is gone. Nothing more will arrive for any child, backgrounded
* or not, so every one of them loses contact at once. */
settleSession(): void {
for (const group of this.groups.values()) {
this.sweep(group, true)
}
this.tracker.settleSession()
this.deps.onIdentitiesFinal?.()
}
dispose(): void {
// Teardown paths reach here without an `ended` event, so a row still
// reporting `working` would have nothing left to revise it. A session that
// did settle first leaves every child terminal, so this writes nothing.
// Teardown paths reach here without an `ended` event; a session that did
// settle first leaves every child terminal, so this writes nothing.
this.settleSession()
this.groups.clear()
this.tracker.dispose()
this.ids.clear()
this.announcesTasks = false
}
private sweep(group: RosterGroup | undefined, includeBackgrounded: boolean): void {
if (!group) {
return
private currentGroup(): SubagentReport<AgentJournalTurnScope>['group'] {
return {
id: this.deps.currentGroupKey() ?? OUTSIDE_TURN,
placement: this.deps.currentTurnScope
}
for (const [id, tracked] of group.entries) {
if (isTerminalSubagentState(tracked.entry.state)) {
continue
}
if (tracked.backgrounded && !includeBackgrounded) {
continue
}
group.entries.set(id, {
...tracked,
entry: { ...tracked.entry, state: 'unverifiable', settledAt: this.now() }
})
}
this.write(group)
}
private write(group: RosterGroup): void {
writeClaudeSubagentGroupRow(this.deps.sink, group)
this.groups.trim(group)
}
private create(
id: string,
label: string | null,
state: NativeChatSubagentEntry['state'],
backgrounded: boolean,
toolUseId: string | null
): void {
const group = this.groups.groupFor(this.deps.currentGroupKey() ?? OUTSIDE_TURN)
if (group.admittedEntries >= MAX_SUBAGENTS_PER_GROUP) {
this.write(group)
return
}
group.admittedEntries += 1
const now = this.now()
const labelBase = label ?? UNLABELLED_AGENT
group.entries.set(id, {
backgrounded,
toolUseId,
invocationIds: new Set(toolUseId ? [toolUseId] : []),
labelBase,
attempt: 1,
invokedInEarlierRun: false,
entry: {
id,
label: claimClaudeSubagentLabel(group, labelBase),
state,
startedAt: now,
...(isTerminalSubagentState(state) ? { settledAt: now } : {})
}
})
this.groups.place(id, group.groupId)
this.write(group)
}
private revise(
group: RosterGroup,
id: string,
change: {
label: string | null
state: NativeChatSubagentEntry['state'] | null
backgrounded: boolean | null
}
): void {
const tracked = group.entries.get(id)
if (!tracked) {
return
}
const next: TrackedEntry = {
...tracked,
backgrounded: change.backgrounded ?? tracked.backgrounded,
entry: { ...tracked.entry }
}
// A provisional row built from child traffic takes the real name the first
// announcement carries; an announced row keeps the name it was given.
if (
change.label &&
tracked.labelBase === UNLABELLED_AGENT &&
change.label !== UNLABELLED_AGENT
) {
next.labelBase = change.label
next.entry.label = claimClaudeSubagentLabel(group, change.label)
}
// Proven outcomes latch; lost contact can still receive a later verdict.
if (change.state && canReplaceSubagentState(tracked.entry.state, change.state)) {
next.entry.state = change.state
if (isTerminalSubagentState(change.state)) {
// An earlier run's child ended with that run, so this verdict says how, not
// when; a run that died unswept left no time, and now is not it.
next.entry.settledAt = tracked.invokedInEarlierRun ? tracked.entry.settledAt : this.now()
}
}
group.entries.set(id, next)
this.write(group)
}
/** Re-key a provisional entry from its tool id onto the canonical task id the
* announcement finally named, so the child does not appear twice. */
private adopt(toolUseId: string, taskId: string): { group: RosterGroup } | null {
if (toolUseId === taskId) {
return null
}
const located = this.groups.locate(toolUseId)
if (!located) {
return null
}
located.group.entries.delete(toolUseId)
located.group.entries.set(taskId, {
...located.tracked,
entry: { ...located.tracked.entry, id: taskId }
})
this.groups.forget(toolUseId)
this.groups.place(taskId, located.group.groupId)
return { group: located.group }
}
private remove(id: string): void {
const located = this.groups.locate(id)
if (!located) {
return
}
located.group.entries.delete(id)
this.groups.forget(id)
this.write(located.group)
}
}
File diff suppressed because it is too large Load Diff
@@ -190,9 +190,11 @@ describe('Codex child-work evidence', () => {
expect.objectContaining({ membership: 'settled', state: 'done', outcome: 'unknown' })
])
expect(tracker.state).toBeNull()
// The first ending a turn gets stands.
// Lost contact is no verdict: the turn's own ending still says how it went.
send(turn('turn/completed', CHILD, 'c1', 'completed'))
expect(records()).toEqual([expect.objectContaining({ outcome: 'unknown' })])
expect(records()).toEqual([expect.objectContaining({ outcome: 'succeeded' })])
send(turn('turn/completed', CHILD, 'c1', 'failed'))
expect(records()).toEqual([expect.objectContaining({ outcome: 'succeeded' })])
})
it('keeps a child working through a retried error and a systemError status: its turn runs on', () => {
@@ -554,7 +554,7 @@ describe('a helper whose spawn was never seen', () => {
])
})
it("puts a helper's run in the turn that sent it work, even after that turn ended", async () => {
it("reopens a helper's one entry when a later turn sends it work, even after that turn ended", async () => {
const run = await session()
run.send(
turn('turn/started', THREAD_ID, PARENT_TURN),
@@ -567,8 +567,9 @@ describe('a helper whose spawn was never seen', () => {
turn('turn/completed', THREAD_ID, 'parent-turn-2'),
turn('turn/started', HELPER, 'helper-turn-2')
)
// One entry per helper: a second row naming it would show it without its own section.
const groups = new Set(run.rosterRows().map((row) => row.groupId))
expect(groups.size).toBe(2)
expect(groups.size).toBe(1)
expect(run.rosterRows().at(-1)?.agents).toEqual([
expect.objectContaining({ id: HELPER, state: 'working' })
])
@@ -11,10 +11,9 @@ export const MAX_CODEX_PENDING_PROMPTS = 128
export const MAX_CODEX_IDENTITY_ENTRIES = 512
export const MAX_CODEX_DETAIL_ENTRIES = 512
export const MAX_CODEX_DETAIL_BYTES = 64 * 1024
/** Settled spawn-group history budget; live groups remain until their final write succeeds. */
export const MAX_CODEX_SUBAGENT_GROUPS = 32
/** Children admitted per spawn-group row. */
export const MAX_CODEX_SUBAGENTS_PER_GROUP = 64
/** The shared subagent tracker's bounds, which Codex's spawn-group rows follow. */
export { MAX_SUBAGENT_GROUPS as MAX_CODEX_SUBAGENT_GROUPS } from '../native-chat/subagent-tracker/subagent-tracker-groups'
export { MAX_SUBAGENTS_PER_GROUP as MAX_CODEX_SUBAGENTS_PER_GROUP } from '../native-chat/subagent-tracker/subagent-tracker'
/** Threads whose latest token total is retained. Usage frames arrive for
* threads that are not yet (or never become) roster children. */
export const MAX_CODEX_TOKEN_USAGE_THREADS = 256
@@ -118,7 +118,7 @@ describe('shared child execution projection', () => {
})
it.each(['parent-1', 'parent-2'])(
'reopens a child for real follow-up in %s and fences old execution events',
'reopens a child for real follow-up in %s in its own row and fences old execution events',
(parent) => {
const h = harness()
firstRun(h)
@@ -130,18 +130,19 @@ describe('shared child execution projection', () => {
expect(h.tracker.state).toBeNull()
h.send(turn('turn/started', CHILD, 'child-2'))
h.send(turn('turn/completed', PRIMARY, parent))
expect(h.state(parent)).toBe('working')
// One entry per child: the follow-up reopens the row that spawned it.
expect(h.state('parent-1')).toBe('working')
expect(h.tracker.state?.tasks).toHaveLength(1)
h.send(turn('turn/completed', CHILD, 'child-1'))
h.send(turn('turn/started', CHILD, 'child-1'))
h.send(activity('parent-1', 'completed'))
expect(h.state(parent)).toBe('working')
expect(h.state('parent-1')).toBe('working')
expect(h.tracker.state?.tasks).toHaveLength(1)
if (parent === 'parent-2') {
expect(h.state('parent-1')).toBe('completed')
expect(h.state('parent-2')).toBeUndefined()
}
h.send(turn('turn/completed', CHILD, 'child-2'))
expect(h.state(parent)).toBe('completed')
expect(h.state('parent-1')).toBe('completed')
expect(h.tracker.state).toBeNull()
h.dispose()
}
@@ -166,8 +167,8 @@ describe('shared child execution projection', () => {
h.send(activity('parent-2', 'interacted'))
h.send(activity('parent-1', 'completed'))
h.send(turn('turn/started', CHILD, 'child-2'))
expect(h.state('parent-1')).toBe('completed')
expect(h.state('parent-2')).toBe('working')
expect(h.state('parent-1')).toBe('working')
expect(h.state('parent-2')).toBeUndefined()
expect(h.tracker.state?.tasks).toHaveLength(1)
h.dispose()
})
+9 -2
View File
@@ -1,5 +1,8 @@
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary'
import {
MAX_SUBAGENT_FIELD_CHARS,
canReplaceSubagentState
} from '../../shared/native-chat-subagent-summary'
const MAX_CHILDREN = 128
const MAX_SETTLED_TURNS = 256
@@ -87,7 +90,11 @@ export class CodexSubagentExecutions {
) {
return null
}
const execution = { turnId, state: settled ?? state }
// A turn's first outcome latches, except lost contact: a later verdict still says how it ended.
const execution = {
turnId,
state: settled !== undefined && !canReplaceSubagentState(settled, state) ? settled : state
}
if (state !== 'working') {
this.settledTurns.set(key, execution.state)
while (this.settledTurns.size > MAX_SETTLED_TURNS) {
@@ -0,0 +1,189 @@
// Golden row writes for the Codex subagent roster: every append and publish it makes, and every
// admission it hands back, for scripted event sequences, so moving the roster onto shared code
// cannot change a saved byte.
import { describe, expect, it } from 'vitest'
import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types'
import { recordingSubagentRowSink } from '../native-chat/subagent-row-sink-recorder-test-support'
import type { CodexThreadItem } from './codex-structured-item-translation'
import { CodexSubagentRoster } from './codex-subagent-roster'
const PARENT = 'thread-parent'
/** The clock moves once per delivered event, never per read, so a row's times do not depend on how
* often the roster happens to read it. */
function harness() {
const recorder = recordingSubagentRowSink()
let clock = 1_000
let parentTurn: string | null = 'turn-1'
const roster = new CodexSubagentRoster({
sink: recorder.sink,
primaryThreadId: () => PARENT,
activeTurn: (threadId) => (threadId === PARENT ? parentTurn : null),
turnScopeFor: (threadId, turnId): AgentJournalTurnScope =>
turnId === null ? { kind: 'thread' } : { kind: 'turn', turnItemId: `${threadId}/${turnId}` },
now: () => clock
})
const admissions: boolean[] = []
const note = (admission: { accepted: boolean } | null) => {
admissions.push(admission?.accepted ?? true)
}
const tick = <T>(deliver: () => T): T => {
clock += 10
return deliver()
}
const activity = (kind: string, child: string, path: string | null): CodexThreadItem => ({
type: 'subAgentActivity',
id: `item-${child}-${kind}`,
kind,
agentThreadId: child,
agentPath: path
})
return {
...recorder,
roster,
admissions,
parentTurn: (next: string | null) => {
parentTurn = next
},
tick,
announce: (kind: string, child: string, path: string | null) => {
clock += 10
note(
roster.handleItem({
threadId: PARENT,
turnId: parentTurn,
item: activity(kind, child, path)
})
)
},
turnStarted: (child: string, turnId: string) => {
clock += 10
note(roster.handleTurnEvent({ method: 'turn/started', threadId: child, params: { turnId } }))
},
turnCompleted: (child: string, turnId: string, status: string) => {
clock += 10
note(
roster.handleTurnEvent({
method: 'turn/completed',
threadId: child,
params: { turn: { id: turnId, status } }
})
)
},
tokens: (child: string, totalTokens: number) => {
clock += 10
note(roster.handleTokenUsage({ threadId: child, tokenUsage: { total: { totalTokens } } }))
}
}
}
describe('Codex subagent roster rows (characterization)', () => {
it('one child: announced, runs a turn, reports tokens, completes', () => {
const h = harness()
h.announce('started', 'c1', '/root/reader')
h.turnStarted('c1', 'ct1')
h.announce('started', 'c1', '/root/reader')
h.tokens('c1', 120)
h.tokens('c1', 120)
h.tokens('c1', 340)
h.turnCompleted('c1', 'ct1', 'completed')
h.tokens('c1', 400)
expect({ log: h.log, admissions: h.admissions }).toMatchSnapshot()
})
it('two children sharing a path label, one failing and one interrupted', () => {
const h = harness()
h.turnStarted('c1', 'ct1')
h.announce('started', 'c1', '/root/worker')
h.turnStarted('c2', 'ct2')
h.announce('started', 'c2', '/root/team/worker')
h.turnStarted('c3', 'ct3')
h.announce('started', 'c3', '/root/worker 2')
h.turnCompleted('c1', 'ct1', 'failed')
h.turnCompleted('c2', 'ct2', 'interrupted')
h.turnCompleted('c3', 'ct3', 'mystery')
expect(h.log).toMatchSnapshot()
})
it('a child given new work in a later parent turn, and a late end of its earlier run', () => {
const h = harness()
h.turnStarted('c1', 'ct1')
h.announce('started', 'c1', '/root/reader')
h.turnCompleted('c1', 'ct1', 'completed')
h.parentTurn('turn-2')
h.announce('interacted', 'c1', '/root/reader')
h.turnStarted('c1', 'ct2')
h.turnCompleted('c1', 'ct1', 'failed')
h.turnStarted('c1', 'ct1')
h.turnCompleted('c1', 'ct2', 'completed')
expect(h.log).toMatchSnapshot()
})
it('a child running when the session ends, then a late verdict', () => {
const h = harness()
h.turnStarted('c1', 'ct1')
h.announce('started', 'c1', '/root/a')
h.turnStarted('c2', 'ct2')
h.announce('started', 'c2', '/root/b')
h.turnCompleted('c2', 'ct2', 'completed')
note(h.tick(() => h.roster.settleSession()))
h.turnCompleted('c1', 'ct1', 'completed')
expect(h.log).toMatchSnapshot()
function note(admission: { accepted: boolean }) {
h.admissions.push(admission.accepted)
}
})
it('a child thread closing mid-turn, then its turn completing', () => {
const h = harness()
h.turnStarted('c1', 'ct1')
h.announce('started', 'c1', '/root/a')
h.tick(() => h.roster.executions.closeThread('c1'))
h.turnCompleted('c1', 'ct1', 'completed')
expect(h.log).toMatchSnapshot()
})
it('a child announced outside any parent turn', () => {
const h = harness()
h.parentTurn(null)
h.turnStarted('c1', 'ct1')
h.announce('started', 'c1', '/root/loose')
h.parentTurn('turn-5')
h.turnCompleted('c1', 'ct1', 'completed')
expect(h.log).toMatchSnapshot()
})
it('more than 32 settled groups, then a late frame for an evicted run', () => {
const h = harness()
for (let index = 0; index < 34; index++) {
h.parentTurn(`turn-${index}`)
h.turnStarted(`c${index}`, `ct${index}`)
h.announce('started', `c${index}`, `/root/job${index}`)
h.turnCompleted(`c${index}`, `ct${index}`, 'completed')
}
h.parentTurn('turn-live')
h.turnStarted('live', 'ctl')
h.announce('started', 'live', '/root/live')
h.turnCompleted('c0', 'ct0', 'failed')
h.turnStarted('c0', 'ct0')
h.announce('started', 'c0', '/root/job0')
expect(h.log.length).toMatchSnapshot()
expect(h.log.slice(-6)).toMatchSnapshot()
expect(h.roster.retentionSizes()).toMatchSnapshot()
})
it('a refused append or publish is handed back, and the redelivered frame writes it', () => {
const h = harness()
h.turnStarted('c1', 'ct1')
h.refuseNextAppend()
h.announce('started', 'c1', '/root/a')
h.announce('started', 'c1', '/root/a')
h.refuseNextPublish()
h.turnCompleted('c1', 'ct1', 'completed')
h.turnCompleted('c1', 'ct1', 'completed')
h.tokens('c1', 50)
expect({ log: h.log, admissions: h.admissions }).toMatchSnapshot()
})
})
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { isSubagentGroupBlock } from '../../shared/native-chat-types'
import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body'
import { recordingSubagentRowSink } from '../native-chat/subagent-row-sink-recorder-test-support'
import {
CodexSubagentRoster,
codexSubagentGroupId,
codexSubagentGroupIdentity
} from './codex-subagent-roster'
const PARENT = 'thread-parent'
const OUTSIDE = codexSubagentGroupId(PARENT, null)
describe('Codex subagent roster after a provider restart', () => {
it('continues the row no turn owns instead of rewriting it from one child', () => {
const recorder = recordingSubagentRowSink()
const earlier = subagentGroupJournalBody(OUTSIDE, [
{ id: 'c1', label: 'one', state: 'completed', startedAt: 1, settledAt: 2 },
{ id: 'c2', label: 'two', state: 'unverifiable', startedAt: 1 }
])
const roster = new CodexSubagentRoster({
sink: {
...recorder.sink,
journalLinkage: () => ({
epoch: 'e1',
visitItemsWithLinkage: (visit) =>
visit(agentJournalItemKey(codexSubagentGroupIdentity(OUTSIDE)), 1, earlier, {
turnScope: { kind: 'thread' }
})
})
},
primaryThreadId: () => PARENT,
activeTurn: () => null,
turnScopeFor: () => ({ kind: 'thread' })
})
roster.handleTurn({ threadId: 'c3', turnId: 't3', state: 'working' })
roster.handleItem({
threadId: PARENT,
turnId: null,
item: {
type: 'subAgentActivity',
id: 'i3',
kind: 'started',
agentThreadId: 'c3',
agentPath: '/root/three'
}
})
const last = recorder.log.findLast((write) => write.op === 'append')
const agents =
last?.op === 'append' && last.body.kind === 'message'
? last.body.blocks.find(isSubagentGroupBlock)?.agents
: undefined
expect(last?.op === 'append' && last.identity).toEqual(codexSubagentGroupIdentity(OUTSIDE))
expect(agents?.map((agent) => [agent.id, agent.label, agent.state])).toEqual([
['c1', 'one', 'completed'],
['c2', 'two', 'unverifiable'],
['c3', 'three', 'working']
])
})
})
+9 -63
View File
@@ -1,73 +1,19 @@
// One spawn group's roster state, and the ids its journal row is keyed on.
// The ids a Codex spawn group's journal row is keyed on.
import type {
AgentJournalItemIdentity,
AgentJournalTurnScope
} from '../../shared/agent-session-journal-types'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary'
import { SubagentRosterRetention } from '../native-chat/subagent-roster-retention'
import {
MAX_CODEX_SUBAGENT_GROUPS,
MAX_CODEX_SUBAGENTS_PER_GROUP
} from './codex-structured-journal-limits'
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
/** The turn a group belongs to when Codex reports activity outside any turn.
* Mirrors the generic-frame bucket name so the two read alike in the journal. */
export type RosterGroup = {
groupId: string
identity: AgentJournalItemIdentity
/** The spawning turn's scope: the row reports its children beside that turn's work. */
turnScope: AgentJournalTurnScope
/** Insertion order is the display order; the map holds the state. */
entries: Map<string, NativeChatSubagentEntry>
executionTurns: Map<string, string | null>
/** Times each label has been claimed, so a repeat gets an ordinal suffix. */
labelCounts: Map<string, number>
/** Last body written, so an idempotent replay writes no new revision. */
lastSerialized: string | null
}
/** A reused child thread starts a new execution only when its turn id changes. */
export function codexSubagentExecutionIdentity(threadId: string, turnId: string): string {
return JSON.stringify([threadId, turnId])
}
export function retainSupersededCodexExecution(
group: RosterGroup,
threadId: string,
nextTurnId: string | null,
retention: SubagentRosterRetention<RosterGroup>
): void {
const previousTurnId = group.executionTurns.get(threadId)
const previous = group.entries.get(threadId)
if (
typeof previousTurnId === 'string' &&
previousTurnId !== nextTurnId &&
previous &&
isTerminalSubagentState(previous.state)
) {
retention.rememberSettled(codexSubagentExecutionIdentity(threadId, previousTurnId))
}
}
export function createCodexSubagentRosterRetention(groups: Map<string, RosterGroup>) {
return new SubagentRosterRetention(groups, {
maxGroups: MAX_CODEX_SUBAGENT_GROUPS,
maxSettledIdentities: MAX_CODEX_SUBAGENT_GROUPS * MAX_CODEX_SUBAGENTS_PER_GROUP,
entries: (group) => group.entries.values(),
identities: (group) =>
[...group.executionTurns].flatMap(([threadId, turnId]) =>
turnId === null ? [] : [codexSubagentExecutionIdentity(threadId, turnId)]
)
})
}
const OUTSIDE_TURN = 'outside-turn'
/** Group identity: the parent turn that spawned the children. `agentPath` is a
* tree rooted at the parent thread, so every child of one turn shares a row
* no matter which thread's stream carried its activity item. */
export function codexSubagentGroupId(threadId: string, turnId: string | null): string {
return `${threadId}:${turnId ?? 'outside-turn'}`
return `${threadId}:${turnId ?? OUTSIDE_TURN}`
}
/** The group of children reported outside any of the parent's turns. */
export function isCodexOutsideTurnGroup(groupId: string): boolean {
return groupId.endsWith(`:${OUTSIDE_TURN}`)
}
/** Durable journal identity for the group's row — stable across revisions and
+70 -195
View File
@@ -2,22 +2,22 @@
//
// An announcement supplies membership — a `subAgentActivity` item, or in
// Codex's default multi-agent mode any collab call naming the helper — and
// child turn events supply execution state.
//
// KNOWN LIMITATION: `groups` is process-local and is never seeded from the
// journal, while the row's identity is keyed on the group id alone. After a
// restart loses membership for `threadId:outside-turn`, the next activity
// rebuilds that row from one child — rewriting N down to one. This group id
// outlives the process: `thread/resume` returns the same thread, while real turn
// ids are assumed freshly minted per turn. Seeding from the journal is the fix.
// child turn events supply execution state: each child turn is one run of the
// child. Everything the row says follows the shared subagent tracker's rules;
// this module only reads Codex's events. Rows earlier provider runs journaled
// are inherited, so a restart neither splits a child nor rewrites the row no
// turn owns from one child.
import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types'
import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary'
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import type {
StructuredAgentSessionEventSink,
StructuredAgentSessionSinkAdmission
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { JournaledSubagentGroups } from '../native-chat/subagent-tracker/journaled-subagent-groups'
import { subagentRowSinkPort } from '../native-chat/subagent-tracker/subagent-row-sink-port'
import { SubagentTracker } from '../native-chat/subagent-tracker/subagent-tracker'
import type { SubagentReport } from '../native-chat/subagent-tracker/subagent-tracker-types'
import {
readCodexSubagentActivity,
readCodexSubagentAnnouncements,
@@ -32,21 +32,16 @@ import {
} from './codex-subagent-executions'
import { readRecord } from './codex-item-field-readers'
import { readCodexTurnId } from './codex-structured-thread-facts'
import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body'
import { CodexSubagentLinkage } from './codex-subagent-linkage'
export { subagentGroupJournalBody as codexSubagentGroupBody } from '../native-chat/agent-session-journal/journal-subagent-group-body'
export { codexSubagentGroupId, codexSubagentGroupIdentity } from './codex-subagent-roster-state'
import {
codexSubagentGroupId,
codexSubagentGroupIdentity,
codexSubagentExecutionIdentity as executionIdentity,
createCodexSubagentRosterRetention,
retainSupersededCodexExecution,
type RosterGroup
isCodexOutsideTurnGroup
} from './codex-subagent-roster-state'
import type { CodexThreadItem } from './codex-structured-item-translation'
import { MAX_CODEX_SUBAGENTS_PER_GROUP } from './codex-structured-journal-limits'
import { CodexThreadTokenTotals } from './codex-thread-token-totals'
export { subagentGroupJournalBody as codexSubagentGroupBody } from '../native-chat/agent-session-journal/journal-subagent-group-body'
export { codexSubagentGroupId, codexSubagentGroupIdentity } from './codex-subagent-roster-state'
const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true }
@@ -61,11 +56,9 @@ export type CodexSubagentRosterDeps = {
}
export class CodexSubagentRoster {
private readonly groups = new Map<string, RosterGroup>()
private readonly retention = createCodexSubagentRosterRetention(this.groups)
/** Every thread's total, members or not; children are selected at write time. */
private readonly tracker: SubagentTracker<AgentJournalTurnScope>
/** Every thread's total, members or not; a child shows its total once it is listed. */
private readonly tokensByThread = new CodexThreadTokenTotals()
private readonly now: () => number
/** The one owner of child membership and turn state; the rows of calls on a helper read it too. */
readonly executions: CodexSubagentExecutions
private readonly unfollow: () => void
@@ -73,11 +66,18 @@ export class CodexSubagentRoster {
readonly linkage: CodexSubagentLinkage
constructor(private readonly deps: CodexSubagentRosterDeps) {
this.now = deps.now ?? (() => Date.now())
this.tracker = new SubagentTracker({
port: subagentRowSinkPort(deps.sink, codexSubagentGroupIdentity),
outsideTurn: isCodexOutsideTurnGroup,
journaled: new JournaledSubagentGroups(
() => deps.sink.journalLinkage?.() ?? null,
codexSubagentGroupIdentity
),
...(deps.now ? { now: deps.now } : {})
})
this.executions = deps.executions ?? new CodexSubagentExecutions()
// The row follows the executions, so every frame that ends a child's turn — its own
// `turn/completed` (a failed one included) or its thread closing — settles it.
// A refused write clears `lastSerialized`, so the next write of the group retries it.
this.unfollow = this.executions.onExecutionChanged(
(child) => child.execution && this.follow(child, child.execution)
)
@@ -112,19 +112,9 @@ export class CodexSubagentRoster {
// Only a spawn names the spawner: other announcements ride whichever agent acted.
announcement.spawned ? input.threadId : undefined
)
if (
!child?.execution ||
this.retention.hasSettled(executionIdentity(child.agentThreadId, child.execution.turnId))
) {
return ADMITTED
}
const group =
this.executionGroup(child.agentThreadId, child.execution.turnId) ??
this.groupFor(input.threadId, input.turnId)
if (!group.entries.has(child.agentThreadId)) {
this.recordExecution(group, child, child.execution)
}
return this.write(group)
return child?.execution
? this.report(child, child.execution, true, this.groupFor(input.threadId, input.turnId))
: ADMITTED
}
handleTurnEvent(event: {
@@ -152,7 +142,7 @@ export class CodexSubagentRoster {
}): StructuredAgentSessionSinkAdmission {
if (
input.threadId === this.deps.primaryThreadId() ||
this.retention.hasSettled(executionIdentity(input.threadId, input.turnId))
this.tracker.hasSettled(input.threadId, input.turnId)
) {
return ADMITTED
}
@@ -161,35 +151,17 @@ export class CodexSubagentRoster {
return observed ? this.follow(observed.child, observed.execution) : ADMITTED
}
/** A child turn starting is a run starting; one ending only revises the row listing it. */
private follow(
child: Readonly<CodexExecutionChild>,
execution: CodexChildExecution
): StructuredAgentSessionSinkAdmission {
if (
!child.registered ||
this.retention.hasSettled(executionIdentity(child.agentThreadId, execution.turnId))
) {
if (!child.registered) {
return ADMITTED
}
if (execution.state === 'working') {
const parent = this.deps.primaryThreadId() ?? child.agentThreadId
const group =
this.executionGroup(child.agentThreadId, execution.turnId) ??
this.groupFor(parent, this.deps.activeTurn(parent) ?? child.parentTurnId)
this.recordExecution(group, child, execution)
return this.write(group)
}
for (const group of this.groups.values()) {
if (group.executionTurns.get(child.agentThreadId) !== execution.turnId) {
continue
}
this.recordExecution(group, child, execution)
const admission = this.write(group)
if (!admission.accepted) {
return admission
}
}
return ADMITTED
const parent = this.deps.primaryThreadId() ?? child.agentThreadId
const group = this.groupFor(parent, this.deps.activeTurn(parent) ?? child.parentTurnId)
return this.report(child, execution, execution.state === 'working', group)
}
/** Consume `thread/tokenUsage/updated`. Returns null when the params are not one. */
@@ -199,16 +171,15 @@ export class CodexSubagentRoster {
return null
}
this.tokensByThread.record(usage.threadId, usage.totalTokens)
for (const group of this.groups.values()) {
if (!group.entries.has(usage.threadId)) {
continue
}
const admission = this.write(group)
if (!admission.accepted) {
return admission
}
}
return ADMITTED
const located = this.tracker.locate(usage.threadId)
return located
? this.tracker.report({
id: usage.threadId,
group: { id: located.group.groupId, placement: () => located.group.placement },
announces: false,
tokens: usage.totalTokens
})
: ADMITTED
}
/**
@@ -221,146 +192,50 @@ export class CodexSubagentRoster {
*/
settleSession(): StructuredAgentSessionSinkAdmission {
this.executions.settleSession()
for (const group of this.groups.values()) {
const admission = this.sweep(group)
if (!admission.accepted) {
return admission
}
}
return ADMITTED
return this.tracker.settleSession()
}
dispose(): void {
this.unfollow()
this.groups.clear()
this.retention.clear()
this.tracker.dispose()
this.tokensByThread.clear()
}
retentionSizes(): { groups: number; settledIdentities: number } {
return this.retention.sizes()
return this.tracker.sizes()
}
private sweep(group: RosterGroup): StructuredAgentSessionSinkAdmission {
let changed = false
for (const [id, entry] of group.entries) {
if (isTerminalSubagentState(entry.state)) {
continue
}
group.entries.set(id, { ...entry, state: 'unverifiable', settledAt: this.now() })
changed = true
}
// A null `lastSerialized` means the previous write was refused part-way, so
// the settled roster's last revision is queued but never published. Nothing
// is guaranteed to write this group again, so retry here even when the sweep
// itself changed nothing.
return changed || group.lastSerialized === null ? this.write(group) : ADMITTED
private report(
child: Readonly<CodexExecutionChild>,
execution: CodexChildExecution,
announces: boolean,
group: SubagentReport<AgentJournalTurnScope>['group']
): StructuredAgentSessionSinkAdmission {
const tokens = this.tokensByThread.get(child.agentThreadId)
return this.tracker.report({
id: child.agentThreadId,
run: execution.turnId,
group,
announces,
label: child.label,
state: execution.state,
...(typeof tokens === 'number' ? { tokens } : {}),
// A spawned child may outlive the turn that spawned it.
backgrounded: true
})
}
private groupFor(threadId: string, turnId: string | null): RosterGroup {
/** The group a child spawned now belongs to: the session's own agent's current turn. */
private groupFor(
threadId: string,
turnId: string | null
): SubagentReport<AgentJournalTurnScope>['group'] {
const ownerThreadId = this.deps.primaryThreadId() ?? threadId
const ownerTurnId =
ownerThreadId === threadId ? turnId : (this.deps.activeTurn(ownerThreadId) ?? turnId)
const groupId = codexSubagentGroupId(ownerThreadId, ownerTurnId)
const existing = this.groups.get(groupId)
if (existing) {
return existing
return {
id: codexSubagentGroupId(ownerThreadId, ownerTurnId),
placement: () => this.deps.turnScopeFor(ownerThreadId, ownerTurnId)
}
const group: RosterGroup = {
groupId,
identity: codexSubagentGroupIdentity(groupId),
turnScope: this.deps.turnScopeFor(ownerThreadId, ownerTurnId),
entries: new Map(),
executionTurns: new Map(),
labelCounts: new Map(),
lastSerialized: null
}
this.groups.set(groupId, group)
return group
}
private executionGroup(threadId: string, turnId: string): RosterGroup | undefined {
return [...this.groups.values()].find((group) => group.executionTurns.get(threadId) === turnId)
}
/** Two children can share a trailing path segment; the ordinal keeps their
* rows apart without inventing a name the provider never sent. */
private claimLabel(group: RosterGroup, label: string | null): string {
const base = label ?? 'subagent'
const seen = group.labelCounts.get(base) ?? 0
group.labelCounts.set(base, seen + 1)
return seen === 0 ? base : `${base} ${seen + 1}`
}
private recordExecution(
group: RosterGroup,
child: CodexExecutionChild,
execution: CodexChildExecution | null
): void {
const existing = group.entries.get(child.agentThreadId)
if (!existing && group.entries.size >= MAX_CODEX_SUBAGENTS_PER_GROUP) {
return
}
const turnId = execution?.turnId ?? null
const state = execution?.state ?? 'unverifiable'
const sameTurn = existing && group.executionTurns.get(child.agentThreadId) === turnId
if (sameTurn && existing.state === state) {
return
}
const now = this.now()
retainSupersededCodexExecution(group, child.agentThreadId, turnId, this.retention)
group.executionTurns.set(child.agentThreadId, turnId)
group.entries.set(child.agentThreadId, {
id: child.agentThreadId,
label: existing?.label ?? this.claimLabel(group, child.label),
state,
startedAt: sameTurn ? existing.startedAt : now,
...(isTerminalSubagentState(state) ? { settledAt: now } : {}),
...(existing?.tokens !== undefined ? { tokens: existing.tokens } : {})
})
}
private write(group: RosterGroup): StructuredAgentSessionSinkAdmission {
const agents = [...group.entries].map(([id, entry]) => {
const tokens = this.tokensByThread.get(id)
if (typeof tokens !== 'number' || tokens === entry.tokens) {
return entry
}
// Persisted, not merely read: the thread map is LRU-capped, and reading it
// afresh each write would retract a count this row has already shown.
const merged = { ...entry, tokens }
group.entries.set(id, merged)
return merged
})
const body = subagentGroupJournalBody(group.groupId, agents)
const serialized = JSON.stringify(body)
if (serialized === group.lastSerialized) {
// Nothing changed — a duplicate delivery must not burn a revision.
return ADMITTED
}
group.lastSerialized = serialized
// Deliberately unstamped: a child's frame can trigger this write, but the
// row is the PARENT's roster of its children.
const options = { turnScope: group.turnScope }
const admission = this.deps.sink.tryAppendItem
? this.deps.sink.tryAppendItem(group.identity, body, options)
: (this.deps.sink.appendItem(group.identity, body, options), ADMITTED)
if (!admission.accepted) {
group.lastSerialized = null
this.retention.trim([group])
return admission
}
const published = this.deps.sink.tryPublish
? this.deps.sink.tryPublish()
: (this.deps.sink.publish(), ADMITTED)
if (!published.accepted) {
// Symmetric with the append refusal above: the suppression state may only
// advance once the revision is both queued AND published. Left set, an
// identical replay short-circuits and the last revision of a settled
// roster stays queued but never reaches the renderer.
group.lastSerialized = null
}
this.retention.trim([group])
return published
}
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import { claudeSubagentGroupBody } from './claude-subagent-group-row'
import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types'
import { subagentGroupJournalBody } from './journal-subagent-group-body'
function entry(id: string, state: NativeChatSubagentEntry['state']): NativeChatSubagentEntry {
return { id, label: id, state, startedAt: 1 }
@@ -9,12 +9,12 @@ function entry(id: string, state: NativeChatSubagentEntry['state']): NativeChatS
/** The fallback sentence is the WHOLE row on mobile and paired web, which have
* no roster renderer, so these assertions are the entire contract there. */
function sentence(agents: readonly NativeChatSubagentEntry[]): string {
const body = claudeSubagentGroupBody('turn-1', agents)
const body = subagentGroupJournalBody('turn-1', agents)
const block = body.kind === 'message' ? body.blocks[0] : undefined
return block && block.type === 'text' ? block.text : ''
}
describe('claudeSubagentGroupBody fallback sentence', () => {
describe('subagentGroupJournalBody fallback sentence', () => {
it('reads as a plain completion when every child completed', () => {
expect(sentence([entry('a', 'completed'), entry('b', 'completed')])).toBe('Ran 2 subagents')
})
@@ -68,12 +68,16 @@ export function staleSubagentRosterRevisions(
}
/** The row once the host lost the session running its live work: every working child and
* in-flight background task `unverifiable`, plain-text twins restated. Null when none is live. */
export function lostLiveWorkJournalBody(body: AgentJournalItemBody): AgentJournalItemBody | null {
* in-flight background task `unverifiable`, plain-text twins restated. Null when none is live.
* `lostAt` is when a live host saw the session end; a reopened journal cannot know it. */
export function lostLiveWorkJournalBody(
body: AgentJournalItemBody,
lostAt?: number
): AgentJournalItemBody | null {
if (body.kind !== 'message' || !body.blocks.some(hasStaleLiveWork)) {
return null
}
return { ...body, blocks: settleBlocks(body.blocks) }
return { ...body, blocks: settleBlocks(body.blocks, lostAt) }
}
function hasStaleLiveWork(block: NativeChatBlock): boolean {
@@ -94,15 +98,15 @@ function hasLiveBackgroundTask(block: NativeChatBlock): block is NativeChatBackg
)
}
/** No `settledAt`: the child stopped being observable at an unknown moment, and
* stamping the reopen would report the time the app was down as how long it
* ran. Readers already draw an unverifiable child with no stamp as having no
* known run length. */
function settleBlocks(blocks: readonly NativeChatBlock[]): NativeChatBlock[] {
/** Without `lostAt`, no `settledAt`: the child stopped being observable at an
* unknown moment, and stamping the reopen would report the time the app was
* down as how long it ran. Readers already draw an unverifiable child with no
* stamp as having no known run length. */
function settleBlocks(blocks: readonly NativeChatBlock[], lostAt?: number): NativeChatBlock[] {
const backgroundTaskTwinText = new Map<string, string>()
const settled = blocks.map((block) => {
if (hasWorkingChild(block)) {
return settleGroup(block)
return settleGroup(block, lostAt)
}
if (hasLiveBackgroundTask(block)) {
const next = settleBackgroundTask(block)
@@ -134,12 +138,19 @@ function settleBlocks(blocks: readonly NativeChatBlock[]): NativeChatBlock[] {
)
}
function settleGroup(block: NativeChatSubagentGroupBlock): NativeChatSubagentGroupBlock {
function settleGroup(
block: NativeChatSubagentGroupBlock,
lostAt?: number
): NativeChatSubagentGroupBlock {
return {
...block,
agents: block.agents.map((agent) =>
normalizeSubagentState(agent.state) === 'working'
? { ...agent, state: 'unverifiable' as const }
? {
...agent,
state: 'unverifiable' as const,
...(lostAt !== undefined ? { settledAt: lostAt } : {})
}
: agent
)
}
@@ -126,4 +126,9 @@ export type ProviderTimelineEvent =
/** Provider traffic no typed event covers; it becomes the shared bounded fallback row. */
| ({ type: 'provider.frame'; frameKind: string; payload: unknown } & Joined)
/** The provider child is gone. The verdict is what the host can prove about its end. */
| { type: 'session.ended'; verdict: StructuredAgentSessionTurnVerdict }
| {
type: 'session.ended'
verdict: StructuredAgentSessionTurnVerdict
/** When the host saw the end: the moment it lost contact with any child still running. */
endedAt?: number
}
@@ -73,6 +73,8 @@ export type ProviderTimelineSettlementScope = { turnItemId: string } | 'session'
export type ProviderTimelineSettlementEnding = {
turns: readonly { identity: AgentJournalItemIdentity; itemId: string }[]
end: ProviderTimelineTurnEnd
/** When the session's end was seen, which is when its live subagents lost contact. */
lostAt?: number
}
/** The settled rows of `scope`, then each ended turn's row while the journal holds it running. */
@@ -95,7 +97,7 @@ export function providerTimelineSettlement(
const settled = !covered
? null
: (terminalAgentJournalBody(body, end) ??
(scope === 'session' ? lostLiveWorkJournalBody(body) : null))
(scope === 'session' ? lostLiveWorkJournalBody(body, ending?.lostAt) : null))
const identity = settled ? parseAgentJournalItemKey(itemId) : null
if (settled && identity) {
// No linkage: a revision keeps the row's own producer.
@@ -264,7 +264,8 @@ export function decideSessionEnd(
resolve: (journal) =>
providerTimelineSettlement(journal, 'session', {
turns: runningProviderTimelineTurns(journal),
end: event.verdict
end: event.verdict,
...(event.endedAt !== undefined ? { lostAt: event.endedAt } : {})
})
},
commit: (next) => next.endSession()
@@ -133,7 +133,7 @@ it('keeps only the accessed inherited row during a trim and re-derives it on the
for (let index = 0; index < 70; index++) {
const inherited = group(`inherited-${index}`, 'completed', 1)
groups.set(inherited.groupId, inherited)
retention.trim([inherited], inherited.groupId)
retention.trim([inherited], (groupId) => groupId === inherited.groupId)
expect(groups.size).toBe(33)
expect(groups.has(inherited.groupId)).toBe(true)
}
@@ -142,7 +142,7 @@ it('keeps only the accessed inherited row during a trim and re-derives it on the
expect(retention.hasSettled('inherited-69:0')).toBe(true)
const resumed = group('resumed', 'completed', 1)
groups.set(resumed.groupId, resumed)
retention.trim([resumed], resumed.groupId)
retention.trim([resumed], (groupId) => groupId === resumed.groupId)
resumed.entries.set('resumed:0', { id: 'resumed:0', label: 'resumed', state: 'working' })
retention.trim([resumed])
expect(groups.has(resumed.groupId)).toBe(true)
@@ -34,7 +34,8 @@ export class SubagentRosterRetention<Group extends RetainedRosterGroup> {
this.settledIdentities.set(identity, true)
}
trim(changed: Iterable<Group>, retainedGroupId?: string): void {
/** `kept` names groups that are never released: rows a later report can name again. */
trim(changed: Iterable<Group>, kept?: (groupId: string) => boolean): void {
for (const group of changed) {
if (
group.lastSerialized !== null &&
@@ -49,7 +50,7 @@ export class SubagentRosterRetention<Group extends RetainedRosterGroup> {
if (this.groups.size <= this.options.maxGroups) {
break
}
if (groupId === retainedGroupId) {
if (kept?.(groupId)) {
continue
}
const group = this.groups.get(groupId)
@@ -0,0 +1,82 @@
import type {
AgentJournalItemBody,
AgentJournalItemIdentity,
AgentJournalTurnScope
} from '../../shared/agent-session-journal-types'
import type {
StructuredAgentSessionEventSink,
StructuredAgentSessionSinkAdmission
} from './agent-session-wire/structured-agent-session-event-sink'
export type RecordedSubagentRowWrite =
| {
op: 'append'
identity: AgentJournalItemIdentity
body: AgentJournalItemBody
turnScope: AgentJournalTurnScope | undefined
accepted: boolean
}
| { op: 'tombstone'; identity: AgentJournalItemIdentity; accepted: boolean }
| { op: 'publish'; accepted: boolean }
const ACCEPTED: StructuredAgentSessionSinkAdmission = { accepted: true }
const REFUSED: StructuredAgentSessionSinkAdmission = { accepted: false, reason: 'backpressure' }
/** A sink that records every row write in order, with refusals the test schedules. */
export function recordingSubagentRowSink(): {
sink: StructuredAgentSessionEventSink
log: RecordedSubagentRowWrite[]
refuseNextAppend: () => void
refuseNextPublish: () => void
} {
const log: RecordedSubagentRowWrite[] = []
let refuseAppend = false
let refusePublish = false
const admit = (refuse: boolean): StructuredAgentSessionSinkAdmission =>
refuse ? REFUSED : ACCEPTED
const sink: StructuredAgentSessionEventSink = {
appendItem: () => {
throw new Error('rows must use tryAppendItem')
},
appendTombstone: () => {
throw new Error('rows must use tryAppendTombstone')
},
publish: () => {
throw new Error('rows must use tryPublish')
},
tryAppendItem: (identity, body, options) => {
const admission = admit(refuseAppend)
refuseAppend = false
log.push({
op: 'append',
identity,
body: structuredClone(body),
turnScope: options.turnScope,
accepted: admission.accepted
})
return admission
},
tryAppendTombstone: (identity) => {
const admission = admit(refuseAppend)
refuseAppend = false
log.push({ op: 'tombstone', identity, accepted: admission.accepted })
return admission
},
tryPublish: () => {
const admission = admit(refusePublish)
refusePublish = false
log.push({ op: 'publish', accepted: admission.accepted })
return admission
}
}
return {
sink,
log,
refuseNextAppend: () => {
refuseAppend = true
},
refuseNextPublish: () => {
refusePublish = true
}
}
}
@@ -0,0 +1,112 @@
// What earlier provider runs of a session left of its subagent rows, re-derived from the rows they
// journaled.
//
// The tracker lives in one provider process, but every row it writes outlives that process under a
// restart-stable identity. A run that started from nothing would rewrite a row no turn owns from
// empty and list a resumed child twice, so a run reads what earlier runs left instead: derived,
// never stored, so it cannot disagree with the rows it came from.
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentJournalItemIdentity,
type AgentJournalProducerLinkage,
type AgentJournalTurnScope
} from '../../../shared/agent-session-journal-types'
import {
isSubagentGroupBlock,
type NativeChatSubagentEntry
} from '../../../shared/native-chat-types'
import type { StructuredAgentSessionLinkageJournal } from '../agent-session-wire/structured-agent-session-event-sink'
import type { JournaledSubagentSource } from './subagent-tracker-types'
type JournaledGroup = {
entries: readonly NativeChatSubagentEntry[]
placement: AgentJournalTurnScope
}
/** More an agent reads from the same pass, from who produced each row. */
export type JournaledSubagentExtension<Extra> = {
create: () => Extra
visit: (extra: Extra, linkage: AgentJournalProducerLinkage) => void
}
type Reading<Extra> = {
rowsByGroup: Map<string, JournaledGroup>
groupByEntry: Map<string, string>
extra: Extra | null
}
/** Read once per bound journal epoch. Before the journal is bound nothing is read and nothing is
* kept, so the first read after bind still sees every row. */
export class JournaledSubagentGroups<
Extra = never
> implements JournaledSubagentSource<AgentJournalTurnScope> {
private journal: StructuredAgentSessionLinkageJournal | null = null
private epoch: string | null = null
private reading: Reading<Extra> | null = null
constructor(
private readonly bound: () => StructuredAgentSessionLinkageJournal | null,
private readonly identityFor: (groupId: string) => AgentJournalItemIdentity,
private readonly extension?: JournaledSubagentExtension<Extra>
) {}
groupOf = (id: string): string | null => this.current()?.groupByEntry.get(id) ?? null
claimGroup = (groupId: string): JournaledGroup | null => {
const reading = this.current()
const row = reading?.rowsByGroup.get(groupId) ?? null
reading?.rowsByGroup.delete(groupId)
return row
}
/** The extension's reading of the bound journal; null before bind. */
extra(): Extra | null {
return this.current()?.extra ?? null
}
private current(): Reading<Extra> | null {
const journal = this.bound()
if (!journal) {
return null
}
if (!this.reading || journal !== this.journal || journal.epoch !== this.epoch) {
this.journal = journal
this.epoch = journal.epoch
this.reading = this.read(journal)
}
return this.reading
}
private read(journal: StructuredAgentSessionLinkageJournal): Reading<Extra> {
const reading: Reading<Extra> = {
rowsByGroup: new Map(),
groupByEntry: new Map(),
extra: this.extension?.create() ?? null
}
// A child two rows list (only an older build wrote that) is the later-created row's: a turn's
// row is created with its turn, so that is where it last ran.
const listedAt = new Map<string, number>()
journal.visitItemsWithLinkage((itemId, sequence, body, attribution) => {
if (this.extension && reading.extra !== null) {
this.extension.visit(reading.extra, attribution)
}
const group = body.kind === 'message' ? body.blocks.find(isSubagentGroupBlock) : undefined
if (!group || itemId !== agentJournalItemKey(this.identityFor(group.groupId))) {
return
}
reading.rowsByGroup.set(group.groupId, {
entries: group.agents,
placement: attribution.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE
})
for (const entry of group.agents) {
if ((listedAt.get(entry.id) ?? -1) < sequence) {
listedAt.set(entry.id, sequence)
reading.groupByEntry.set(entry.id, group.groupId)
}
}
})
return reading
}
}
@@ -0,0 +1,37 @@
// A subagent group's row written straight to the session's journal sink.
import type {
AgentJournalItemIdentity,
AgentJournalTurnScope
} from '../../../shared/agent-session-journal-types'
import type {
StructuredAgentSessionEventSink,
StructuredAgentSessionSinkAdmission
} from '../agent-session-wire/structured-agent-session-event-sink'
import type { SubagentRowPort } from './subagent-tracker-types'
const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true }
/**
* Deliberately ROOT: the row is written from a child's frame but describes the PARENT's children,
* so it is the session's own agent reporting what it spawned. Stamping it as a child's would hide
* the roster from the very row that owns it.
*/
export function subagentRowSinkPort(
sink: StructuredAgentSessionEventSink,
identityFor: (groupId: string) => AgentJournalItemIdentity
): SubagentRowPort<AgentJournalTurnScope> {
return {
write: (group, { body }) => {
const identity = identityFor(group.groupId)
const admission = body
? (sink.tryAppendItem?.(identity, body, { turnScope: group.placement }) ??
(sink.appendItem(identity, body, { turnScope: group.placement }), ADMITTED))
: (sink.tryAppendTombstone?.(identity) ?? (sink.appendTombstone(identity), ADMITTED))
if (!admission.accepted) {
return admission
}
return sink.tryPublish?.() ?? (sink.publish(), ADMITTED)
}
}
}
@@ -0,0 +1,29 @@
// Writing a group's row: one dedupe and one refusal rule for every agent.
import { subagentGroupJournalBody } from '../agent-session-journal/journal-subagent-group-body'
import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink'
import type { SubagentGroup, SubagentRowPort } from './subagent-tracker-types'
/** Writes each group whose row changed. The dedupe advances only once a revision is both queued and
* published, so a refused one is written again by the next write of that group; the first refusal
* is handed back after every group was tried. */
export function writeSubagentRows<Placement>(
groups: Iterable<SubagentGroup<Placement>>,
port: SubagentRowPort<Placement>
): StructuredAgentSessionSinkAdmission | null {
let refused: StructuredAgentSessionSinkAdmission | null = null
for (const group of groups) {
const agents = [...group.entries.values()].map((tracked) => tracked.entry)
const body = subagentGroupJournalBody(group.groupId, agents)
const serialized = JSON.stringify(body)
if (serialized === group.lastSerialized) {
// A duplicate delivery must not burn a revision.
continue
}
// A group that lost its last child removes its row.
const admission = port.write(group, { body: agents.length > 0 ? body : null, serialized })
group.lastSerialized = admission.accepted ? serialized : null
refused ??= admission.accepted ? null : admission
}
return refused
}
@@ -0,0 +1,69 @@
// How one listed child changes: its name, how it starts a run, and which states may replace which.
import {
MAX_SUBAGENT_FIELD_CHARS,
canReplaceSubagentState,
isTerminalSubagentState
} from '../../../shared/native-chat-subagent-summary'
import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types'
import { ownRetainedString } from '../../../shared/own-retained-string'
import type { SubagentGroup, SubagentReport, TrackedSubagent } from './subagent-tracker-types'
export const UNLABELLED_SUBAGENT = 'subagent'
/** Provider strings are kept to one unit past the row's bound, so the row still marks the clip. */
export function boundedSubagentLabel(label: string | null | undefined): string | null {
const trimmed = label?.trim()
return trimmed ? ownRetainedString(trimmed.slice(0, MAX_SUBAGENT_FIELD_CHARS + 1)) : null
}
/** Two children can share a name; the ordinal keeps their rows apart without inventing a name the
* provider never sent. The probe is over the labels actually claimed, so a generated `Audit 2`
* never collides with a child the provider itself named `Audit 2`. */
export function claimSubagentLabel(group: SubagentGroup<unknown>, base: string): string {
let candidate = base
for (let ordinal = 2; group.claimedLabels.has(candidate); ordinal++) {
candidate = `${base} ${ordinal}`
}
group.claimedLabels.add(candidate)
return candidate
}
/** A run starting now. A new run keeps the token count its child already showed. */
export function startedSubagentEntry(
report: SubagentReport<unknown>,
label: string,
now: number,
previous?: NativeChatSubagentEntry
): NativeChatSubagentEntry {
const state = report.state ?? 'working'
const tokens = report.tokens ?? previous?.tokens
return {
id: report.id,
label,
state,
startedAt: now,
...(isTerminalSubagentState(state) ? { settledAt: now } : {}),
...(tokens !== undefined ? { tokens } : {})
}
}
/** Proven outcomes latch; lost contact can still receive a later verdict. Null when `state` may not
* replace the current one. */
export function withSubagentState(
tracked: TrackedSubagent,
state: NativeChatSubagentEntry['state'],
now: number
): TrackedSubagent | null {
if (tracked.entry.state === state || !canReplaceSubagentState(tracked.entry.state, state)) {
return null
}
// Only a terminal state replaces another. A child an earlier provider process left ended with
// that process: a verdict says how, not when.
const settledAt = tracked.inherited ? tracked.entry.settledAt : now
const { settledAt: _previous, ...rest } = tracked.entry
return {
...tracked,
entry: { ...rest, state, ...(settledAt !== undefined ? { settledAt } : {}) }
}
}
@@ -0,0 +1,167 @@
// Which group lists each subagent: the groups this provider run wrote, and the ones earlier runs of
// the session journaled, inherited one at a time as this run's own reports reach them. Every lookup
// is read from the rows themselves, so no second index can disagree with what a row lists.
import { SubagentRosterRetention } from '../subagent-roster-retention'
import { subagentGroupJournalBody } from '../agent-session-journal/journal-subagent-group-body'
import { UNLABELLED_SUBAGENT } from './subagent-tracker-entries'
import type {
JournaledSubagentSource,
SubagentGroup,
SubagentReport,
TrackedSubagent
} from './subagent-tracker-types'
/** Settled history target; groups with unfinished children remain owned. */
export const MAX_SUBAGENT_GROUPS = 32
const MAX_SETTLED_IDENTITIES = 2048
export type LocatedSubagent<Placement> = {
group: SubagentGroup<Placement>
tracked: TrackedSubagent
}
export class SubagentTrackerGroups<Placement> {
private readonly groups = new Map<string, SubagentGroup<Placement>>()
private readonly retention = new SubagentRosterRetention(this.groups, {
maxGroups: MAX_SUBAGENT_GROUPS,
maxSettledIdentities: MAX_SETTLED_IDENTITIES,
entries: (group) => [...group.entries.values()].map((tracked) => tracked.entry),
// A released child's every run stays settled, so no replay of any of them lists it again.
identities: (group) =>
[...group.entries.values()].flatMap(({ entry, runs }) => [
settledIdentity(entry.id, null),
...runs.map((run) => settledIdentity(entry.id, run))
]),
onEvict: (group) => this.deps.onEvict?.(group)
})
constructor(
private readonly deps: {
journaled?: JournaledSubagentSource<Placement>
/** A group no turn owns: later reports name it again, so its row is never released, or a
* later child would rewrite it from empty. */
outsideTurn: (groupId: string) => boolean
onEvict?: (group: SubagentGroup<Placement>) => void
}
) {}
private get journaled(): JournaledSubagentSource<Placement> | undefined {
return this.deps.journaled
}
values(): Iterable<SubagentGroup<Placement>> {
return this.groups.values()
}
get(groupId: string): SubagentGroup<Placement> | undefined {
return this.groups.get(groupId)
}
/** The child's entry, inheriting the group an earlier provider run last listed it in. */
locate(id: string): LocatedSubagent<Placement> | null {
const listed = this.listed(id)
if (listed) {
return listed
}
const groupId = this.journaled?.groupOf(id) ?? null
return groupId !== null && !this.groups.has(groupId) && this.inherit(groupId)
? this.listed(id)
: null
}
/** This run's group for a key, else the earlier run's row it continues, else a new one. */
groupFor(group: SubagentReport<Placement>['group']): SubagentGroup<Placement> {
const existing = this.groups.get(group.id) ?? this.inherit(group.id)
if (existing) {
return existing
}
const created: SubagentGroup<Placement> = {
groupId: group.id,
placement: group.placement(),
entries: new Map(),
admittedEntries: 0,
claimedLabels: new Set(),
lastSerialized: null
}
this.groups.set(group.id, created)
return created
}
hasSettled(id: string, run: string | null): boolean {
return this.retention.hasSettled(settledIdentity(id, run))
}
/** A run an entry no longer remembers is settled for good. */
rememberSettledRun(id: string, run: string): void {
this.retention.rememberSettled(settledIdentity(id, run))
}
trim(changed: Iterable<SubagentGroup<Placement>>, retainedGroupId?: string): void {
this.retention.trim(
changed,
(groupId) => groupId === retainedGroupId || this.deps.outsideTurn(groupId)
)
}
sizes(): { groups: number; settledIdentities: number } {
return this.retention.sizes()
}
clear(): void {
this.retention.clear()
this.groups.clear()
}
/** An older build could list one child in two rows: the inherited copy that counts is the one
* the journal reading says it last ran in; the other is history. */
private listed(id: string): LocatedSubagent<Placement> | null {
const chosen = this.journaled?.groupOf(id) ?? null
for (const group of this.groups.values()) {
const tracked = group.entries.get(id)
if (tracked && !(tracked.inherited && chosen !== null && chosen !== group.groupId)) {
return { group, tracked }
}
}
return null
}
/** Once per group: after that this run's copy is the newer one. */
private inherit(groupId: string): SubagentGroup<Placement> | null {
const row = this.journaled?.claimGroup(groupId) ?? null
if (!row) {
return null
}
const group: SubagentGroup<Placement> = {
groupId,
// The row keeps the turn it was created beside; a later run's writes never move it.
placement: row.placement,
entries: new Map(),
admittedEntries: row.entries.length,
claimedLabels: new Set(row.entries.map((entry) => entry.label)),
lastSerialized: JSON.stringify(subagentGroupJournalBody(groupId, row.entries))
}
for (const entry of row.entries) {
group.entries.set(entry.id, {
entry: { ...entry },
run: null,
runs: [],
attempt: this.journaled?.attempt?.(entry.id) ?? 1,
turn: null,
// Each child outlived the run that spawned it, so none is tied to this run's turns.
backgrounded: true,
labelBase: entry.label,
provisional: entry.label === UNLABELLED_SUBAGENT,
inherited: true
})
}
this.groups.set(groupId, group)
// Counted as settled history now, even if only a lookup reached it; released by a later trim.
this.retention.trim([group], () => true)
return group
}
}
function settledIdentity(id: string, run: string | null): string {
return JSON.stringify([id, run])
}
@@ -0,0 +1,88 @@
// The one shape every agent reports its subagents in, and the state the shared tracker keeps.
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
import type {
NativeChatSubagentEntry,
NativeChatSubagentState
} from '../../../shared/native-chat-types'
import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink'
/** What an agent observed about one of its subagents. Started, updated and finished are the state it
* carries; the tracker decides what the row says. */
export type SubagentReport<Placement> = {
/** The child's canonical id: the entry key, stable across its runs. */
id: string
/** One execution of the child: Claude's spawn or resume call, Codex's child turn. Absent when the
* agent cannot tell, which means whichever run is current. */
run?: string
/** The group of the turn this report happens in: where a new child is listed. A known child
* keeps its one entry, run after run. */
group: { id: string; placement: () => Placement }
/** Only an announcement creates a child or starts a run; anything else revises a known one. */
announces: boolean
label?: string | null
state?: NativeChatSubagentState | null
/** Latest total the provider counted for this child. */
tokens?: number
/** Told to outlive the turn that spawned it, so that turn's end is no evidence about it. */
backgrounded?: boolean
}
export type TrackedSubagent = {
entry: NativeChatSubagentEntry
/** The run this entry shows; null when unknown (a provisional child, or one an earlier provider
* run journaled). */
run: string | null
/** Runs this entry has shown, oldest first and bounded; a report for one of them is stale. */
runs: string[]
/** Which run of the child this is: 1, then one more per run started. */
attempt: number
/** The group of the turn that started the current run, wherever the entry is listed: that turn's
* end is what a foreground run cannot outlive. Null for a run an earlier provider process left. */
turn: string | null
backgrounded: boolean
/** The name before its ordinal suffix; a placeholder until an announcement names the child. */
labelBase: string
provisional: boolean
/** Listed by an earlier provider run and not run again here: a verdict says how it ended, not when. */
inherited: boolean
}
export type SubagentGroup<Placement> = {
groupId: string
placement: Placement
/** Insertion order is the display order. */
entries: Map<string, TrackedSubagent>
/** Lifetime admissions: bounds the labels a row retains even after removals. */
admittedEntries: number
/** Labels stay reserved after removal or renaming, so an ordinal is never reused. */
claimedLabels: Set<string>
/** Last body written and accepted, so an identical revision is not written twice. */
lastSerialized: string | null
}
/** Where a group's row goes: the journal sink, or a provider timeline the host admits later. */
export type SubagentRowPort<Placement> = {
write: (
group: SubagentGroup<Placement>,
body: SubagentRowBody
) => StructuredAgentSessionSinkAdmission
}
export type SubagentRowBody = {
/** Null when the group lost its last child and its row is removed. */
body: AgentJournalMessageItem | null
serialized: string
}
/** What earlier provider runs of the session journaled, inherited one group at a time. */
export type JournaledSubagentSource<Placement> = {
/** The group row an earlier run last listed this child in. */
groupOf: (id: string) => string | null
/** That group's row, handed over once: after that the tracker's copy is the newer one. */
claimGroup: (
groupId: string
) => { entries: readonly NativeChatSubagentEntry[]; placement: Placement } | null
/** The latest run number any row records for this child; 1 when none says more. */
attempt?: (id: string) => number
}
@@ -0,0 +1,308 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types'
import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink'
import { SubagentTracker } from './subagent-tracker'
import type { JournaledSubagentSource, SubagentReport } from './subagent-tracker-types'
type Write = { groupId: string; agents: NativeChatSubagentEntry[] | null }
function harness(journaled?: JournaledSubagentSource<string>) {
const writes: Write[] = []
let refuse = false
let clock = 100
const tracker = new SubagentTracker<string>({
port: {
write: (group, { body }): StructuredAgentSessionSinkAdmission => {
if (refuse) {
return { accepted: false, reason: 'backpressure' }
}
const block = body?.blocks.find((candidate) => candidate.type === 'subagent-group')
writes.push({
groupId: group.groupId,
agents: block?.type === 'subagent-group' ? block.agents : null
})
return { accepted: true }
}
},
...(journaled ? { journaled } : {}),
outsideTurn: (groupId) => groupId.startsWith('outside'),
now: () => clock
})
const report = (
group: string,
fields: Omit<SubagentReport<string>, 'group' | 'announces'> & { announces?: boolean }
) => {
clock += 10
return tracker.report({
announces: true,
...fields,
group: { id: group, placement: () => group }
})
}
const row = (groupId: string) => writes.findLast((write) => write.groupId === groupId)?.agents
return {
tracker,
writes,
report,
row,
refuse: (value: boolean) => {
refuse = value
},
tick: () => (clock += 10)
}
}
describe('SubagentTracker', () => {
it('lists only an announced child, in the group its report names', () => {
const h = harness()
h.report('turn-1', { id: 'a', announces: false, state: 'completed' })
expect(h.writes).toEqual([])
h.report('turn-1', { id: 'a', label: 'Audit' })
h.report('turn-2', { id: 'a', state: 'completed', announces: false })
expect(h.row('turn-1')).toEqual([
{ id: 'a', label: 'Audit', state: 'completed', startedAt: 120, settledAt: 130 }
])
expect(h.row('turn-2')).toBeUndefined()
})
it('numbers shared names over the labels already claimed, provider-named ones included', () => {
const h = harness()
for (const [id, label] of [
['a', 'Audit'],
['b', 'Audit 2'],
['c', 'Audit'],
['d', null]
] as const) {
h.report('turn', { id, label })
}
expect(h.row('turn')?.map((entry) => entry.label)).toEqual([
'Audit',
'Audit 2',
'Audit 3',
'subagent'
])
h.report('turn', { id: 'd', label: 'Named later', announces: false })
expect(h.row('turn')?.at(-1)?.label).toBe('Named later')
})
it('latches proven outcomes but lets a verdict correct lost contact', () => {
const h = harness()
h.report('turn', { id: 'a' })
h.report('turn', { id: 'b' })
h.tracker.settleSession()
expect(h.row('turn')?.map((entry) => entry.state)).toEqual(['unverifiable', 'unverifiable'])
h.report('turn', { id: 'a', state: 'completed', announces: false })
h.report('turn', { id: 'a', state: 'failed', announces: false })
h.report('turn', { id: 'b', state: 'working', announces: false })
expect(h.row('turn')).toEqual([
expect.objectContaining({ id: 'a', state: 'completed', settledAt: 130 }),
expect.objectContaining({ id: 'b', state: 'unverifiable' })
])
})
it('reopens a resumed child in the one row that lists it, and ignores its earlier runs', () => {
const h = harness()
h.report('turn-1', { id: 'a', run: 'r1', label: 'Audit', tokens: 5 })
h.report('turn-1', { id: 'a', run: 'r1', state: 'completed', announces: false })
h.report('turn-2', { id: 'a', run: 'r2', label: 'Audit' })
expect(h.row('turn-2')).toBeUndefined()
expect(h.row('turn-1')).toEqual([
{ id: 'a', label: 'Audit', state: 'working', startedAt: 130, tokens: 5 }
])
// A late verdict or a replayed announcement of the earlier run changes nothing.
h.report('turn-2', { id: 'a', run: 'r1', state: 'failed', announces: false })
h.report('turn-2', { id: 'a', run: 'r1' })
expect(h.row('turn-1')).toEqual([expect.objectContaining({ state: 'working' })])
expect(h.tracker.attempt('a')).toBe(2)
})
it('names a placeholder child when a later run is announced with a name', () => {
const h = harness()
h.report('turn', { id: 'a', run: 'r1' })
h.report('turn', { id: 'a', run: 'r2', label: 'Audit' })
expect(h.row('turn')).toEqual([expect.objectContaining({ label: 'Audit', state: 'working' })])
})
it('never releases the row no turn owns, so a later child cannot rewrite it from empty', () => {
const h = harness()
h.report('outside', { id: 'loose', state: 'completed' })
for (let turn = 0; turn < 40; turn++) {
h.report(`turn-${turn}`, { id: `c${turn}`, state: 'completed' })
}
h.report('outside', { id: 'later' })
expect(h.row('outside')?.map((entry) => entry.id)).toEqual(['loose', 'later'])
})
it('reopens a run started again in the same turn, and never applies an old run to it', () => {
const h = harness()
h.report('turn', { id: 'a', run: 'r1' })
h.report('turn', { id: 'a', run: 'r1', state: 'completed', announces: false })
h.report('turn', { id: 'a', run: 'r2' })
h.report('turn', { id: 'a', run: 'r1', state: 'failed', announces: false })
expect(h.row('turn')).toEqual([
{ id: 'a', label: 'subagent', state: 'working', startedAt: 130 }
])
})
it('remembers runs aged out of a busy child, so none is revived', () => {
const h = harness()
for (let run = 0; run < 20; run++) {
h.report('turn', { id: 'a', run: `r${run}` })
h.report('turn', { id: 'a', run: `r${run}`, state: 'completed', announces: false })
}
h.report('turn', { id: 'a', run: 'r0' })
expect(h.row('turn')).toEqual([expect.objectContaining({ state: 'completed' })])
expect(h.tracker.attempt('a')).toBe(20)
})
it('ends a foreground run with the turn that started it, wherever its child is listed', () => {
const h = harness()
h.report('turn-1', { id: 'x', run: 'r1' })
h.report('turn-1', { id: 'x', run: 'r1', state: 'completed', announces: false })
h.report('turn-5', { id: 'x', run: 'r2' })
h.tracker.settleTurn('turn-1')
expect(h.row('turn-1')).toEqual([expect.objectContaining({ state: 'working' })])
h.tracker.settleTurn('turn-5')
expect(h.row('turn-1')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('keeps an inherited no-turn row first reached through one of its children', () => {
let claimed = false
const h = harness({
groupOf: (id) => (id === 'a' ? 'outside-turn' : null),
claimGroup: (groupId) => {
if (groupId !== 'outside-turn' || claimed) {
return null
}
claimed = true
return {
entries: [{ id: 'a', label: 'One', state: 'completed', startedAt: 1, settledAt: 2 }],
placement: 'outside-turn'
}
}
})
h.report('outside-turn', { id: 'a', tokens: 3, announces: false })
for (let turn = 0; turn < 40; turn++) {
h.report(`turn-${turn}`, { id: `c${turn}`, state: 'completed' })
}
h.report('outside-turn', { id: 'b' })
expect(h.row('outside-turn')?.map((entry) => entry.id)).toEqual(['a', 'b'])
})
it('releases settled rows an earlier run journaled even when only a lookup reached them', () => {
const h = harness({
groupOf: (id) => (id.startsWith('old-') ? `old-turn-${id.slice(4)}` : null),
claimGroup: (groupId) => ({
entries: [
{ id: `old-${groupId.slice(9)}`, label: 'Old', state: 'completed', startedAt: 1, settledAt: 2 }
],
placement: groupId
})
})
for (let index = 0; index < 100; index++) {
h.tracker.locate(`old-${index}`)
}
h.report('turn-new', { id: 'fresh', state: 'completed' })
expect(h.tracker.locate('fresh')).not.toBeNull()
expect(h.tracker.sizes().groups).toBeLessThanOrEqual(32)
})
it('ends only foreground children with their turn, and every child with the session', () => {
const h = harness()
h.report('turn', { id: 'fg' })
h.report('turn', { id: 'bg', backgrounded: true })
h.tracker.settleTurn('turn')
expect(h.row('turn')?.map((entry) => entry.state)).toEqual(['unverifiable', 'working'])
h.tracker.dispose()
expect(h.row('turn')?.map((entry) => entry.state)).toEqual(['unverifiable', 'unverifiable'])
})
it('writes a refused revision again on the next write, and hands the refusal back', () => {
const h = harness()
h.refuse(true)
expect(h.report('turn', { id: 'a' }).accepted).toBe(false)
expect(h.report('turn', { id: 'a' }).accepted).toBe(false)
h.refuse(false)
h.report('turn', { id: 'a' })
expect(h.writes).toHaveLength(1)
h.report('turn', { id: 'a' })
expect(h.writes).toHaveLength(1)
})
it('tries every group at session end before handing back the first refusal', () => {
const h = harness()
h.report('one', { id: 'a' })
h.report('two', { id: 'b' })
h.refuse(true)
expect(h.tracker.settleSession().accepted).toBe(false)
h.refuse(false)
expect(h.tracker.settleSession().accepted).toBe(true)
expect([h.row('one'), h.row('two')]).toEqual([
[expect.objectContaining({ state: 'unverifiable' })],
[expect.objectContaining({ state: 'unverifiable' })]
])
})
it('writes each group a batch changed once', () => {
const h = harness()
h.tracker.batch(() => {
h.report('turn', { id: 'a' })
h.report('turn', { id: 'b' })
h.report('turn', { id: 'a', tokens: 0, announces: false })
})
expect(h.writes).toEqual([
{
groupId: 'turn',
agents: [
expect.objectContaining({ id: 'a', tokens: 0 }),
expect.objectContaining({ id: 'b' })
]
}
])
})
it('removes a child that turned out not to be one, and the row with its last child', () => {
const h = harness()
h.report('turn', { id: 'a', label: 'Audit' })
h.tracker.remove('a')
expect(h.writes.at(-1)).toEqual({ groupId: 'turn', agents: null })
h.report('turn', { id: 'b', label: 'Audit' })
// A removed child's name stays claimed.
expect(h.row('turn')).toEqual([expect.objectContaining({ label: 'Audit 2' })])
})
it('continues the row an earlier provider run journaled instead of rewriting it from empty', () => {
let claimed = false
const earlier: NativeChatSubagentEntry[] = [
{ id: 'a', label: 'One', state: 'completed', startedAt: 1, settledAt: 2 },
{ id: 'b', label: 'Two', state: 'unverifiable', startedAt: 1 }
]
const h = harness({
groupOf: (id) => (id === 'a' || id === 'b' ? 'outside-turn' : null),
claimGroup: (groupId) => {
if (groupId !== 'outside-turn' || claimed) {
return null
}
claimed = true
return { entries: earlier, placement: 'outside-turn' }
},
attempt: (id) => (id === 'b' ? 3 : 1)
})
h.report('outside-turn', { id: 'c', label: 'Three' })
expect(h.row('outside-turn')?.map((entry) => entry.id)).toEqual(['a', 'b', 'c'])
// A verdict on a run the earlier process left says how it ended, not when.
h.report('outside-turn', { id: 'b', run: 'old-call', state: 'stopped', announces: false })
expect(h.row('outside-turn')?.[1]).toEqual({
id: 'b',
label: 'Two',
state: 'stopped',
startedAt: 1
})
h.report('turn-9', { id: 'b', run: 'resume' })
expect(h.tracker.attempt('b')).toBe(4)
expect(h.row('turn-9')).toBeUndefined()
expect(h.row('outside-turn')?.[1]).toEqual(
expect.objectContaining({ id: 'b', label: 'Two', state: 'working' })
)
})
})
@@ -0,0 +1,308 @@
// The one subagent tracker every native-chat agent reports into.
//
// An agent's own code only reads its provider's events and reports what it saw about a child in
// one shape. This tracker decides everything the user sees from that: which turn's row lists a
// child, its name, how its state may change, what a turn's or the session's end means for it, and
// how the row is written. A child keeps one entry, in the row of the turn that first spawned it;
// a later run reopens that entry. A child is released only when it settles (bounded settled
// history) or the chat ends — never by count while it may still run.
import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types'
import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink'
import { writeSubagentRows } from './subagent-row-writes'
import {
UNLABELLED_SUBAGENT,
boundedSubagentLabel,
claimSubagentLabel,
startedSubagentEntry,
withSubagentState
} from './subagent-tracker-entries'
import { SubagentTrackerGroups, type LocatedSubagent } from './subagent-tracker-groups'
import type {
JournaledSubagentSource,
SubagentGroup,
SubagentReport,
SubagentRowPort,
TrackedSubagent
} from './subagent-tracker-types'
/** Children one row lists. Bounds the row, which is rewritten on every change. */
export const MAX_SUBAGENTS_PER_GROUP = 64
/** Runs one entry remembers, so a late report for one cannot pass for a new run. */
const MAX_RUNS_PER_SUBAGENT = 16
const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true }
export class SubagentTracker<Placement> {
private readonly groups: SubagentTrackerGroups<Placement>
/** Groups changed inside `batch`, written once when it ends. */
private pending: Set<SubagentGroup<Placement>> | null = null
private readonly now: () => number
constructor(
private readonly deps: {
port: SubagentRowPort<Placement>
journaled?: JournaledSubagentSource<Placement>
/** Whether a group is the one no turn owns. */
outsideTurn: (groupId: string) => boolean
onEvict?: (group: SubagentGroup<Placement>) => void
now?: () => number
}
) {
this.now = deps.now ?? (() => Date.now())
this.groups = new SubagentTrackerGroups(deps)
}
report(report: SubagentReport<Placement>): StructuredAgentSessionSinkAdmission {
const run = report.run ?? null
const current = this.groups.locate(report.id)
if (this.groups.hasSettled(report.id, run) && (run !== null || !current)) {
return ADMITTED
}
if (!current) {
return report.announces ? this.create(report, run) : ADMITTED
}
const { tracked } = current
if (run === null || run === tracked.run) {
return this.revise(current, report)
}
if (tracked.runs.includes(run)) {
// A run since superseded: nothing it says changes the run shown now.
return ADMITTED
}
if (tracked.inherited && !report.announces) {
// A verdict on the run an earlier provider process left, under one of its calls.
return this.revise(current, report)
}
if (tracked.run === null && !tracked.inherited) {
// A child listed before its run was known learns it.
const learned = { ...tracked, run, runs: [run] }
current.group.entries.set(report.id, learned)
return this.revise({ group: current.group, tracked: learned }, report)
}
return report.announces ? this.startRun(current, report, run) : ADMITTED
}
/** Re-keys a provisional child onto the canonical id the provider finally named. */
rekey(from: string, to: string): void {
const located = this.groups.locate(from)
if (!located || from === to) {
return
}
const entries = [...located.group.entries].map(([id, tracked]): [string, TrackedSubagent] =>
id === from ? [to, { ...tracked, entry: { ...tracked.entry, id: to } }] : [id, tracked]
)
located.group.entries = new Map(entries)
}
/** The child turned out not to be a subagent at all; its row forgets it. */
remove(id: string): StructuredAgentSessionSinkAdmission {
const located = this.groups.locate(id)
if (!located) {
return ADMITTED
}
located.group.entries.delete(id)
return this.write(located.group)
}
locate(id: string): LocatedSubagent<Placement> | null {
return this.groups.locate(id)
}
/** Known now, or settled and released: either way not some other kind of work. */
has(id: string): boolean {
return this.groups.locate(id) !== null || this.groups.hasSettled(id, null)
}
hasSettled(id: string, run: string | null): boolean {
return this.groups.hasSettled(id, run)
}
/** Which run of the child its current one is. */
attempt(id: string): number {
return this.groups.locate(id)?.tracked.attempt ?? 1
}
/** Keeps a group a child's own traffic just reached, whatever retention would release. */
retain(group: SubagentGroup<Placement>): void {
this.groups.trim([group], group.groupId)
}
/** The turn ended: a run it started in the foreground can no longer report, wherever its child is
* listed, and contact with it is lost — not evidence it exited. A backgrounded run was told to
* outlive the turn. */
settleTurn(groupId: string): StructuredAgentSessionSinkAdmission {
const swept: SubagentGroup<Placement>[] = []
for (const group of this.groups.values()) {
let changed = false
for (const [id, tracked] of group.entries) {
if (tracked.turn === groupId && !tracked.backgrounded) {
changed = this.setState(group, id, tracked, 'unverifiable') || changed
}
}
if (changed || group.groupId === groupId) {
swept.push(group)
}
}
return this.write(...swept)
}
/** The provider is gone: nothing more arrives for any child. Every group is swept; the first
* refusal is handed back after all were tried. */
settleSession(): StructuredAgentSessionSinkAdmission {
let refused: StructuredAgentSessionSinkAdmission | null = null
for (const group of this.groups.values()) {
const admission = this.sweep(group)
refused ??= admission.accepted ? null : admission
}
return refused ?? ADMITTED
}
/** Writes each group the reports inside `apply` changed once, after all of them. */
batch(apply: () => void): StructuredAgentSessionSinkAdmission {
if (this.pending) {
apply()
return ADMITTED
}
const changed = new Set<SubagentGroup<Placement>>()
this.pending = changed
try {
apply()
} finally {
this.pending = null
}
return this.flush(changed)
}
sizes(): { groups: number; settledIdentities: number } {
return this.groups.sizes()
}
dispose(): void {
this.settleSession()
this.groups.clear()
}
private create(
report: SubagentReport<Placement>,
run: string | null
): StructuredAgentSessionSinkAdmission {
const group = this.groups.groupFor(report.group)
if (group.admittedEntries >= MAX_SUBAGENTS_PER_GROUP) {
return ADMITTED
}
group.admittedEntries += 1
const label = boundedSubagentLabel(report.label)
group.entries.set(report.id, {
entry: startedSubagentEntry(
report,
claimSubagentLabel(group, label ?? UNLABELLED_SUBAGENT),
this.now()
),
run,
runs: run === null ? [] : [run],
attempt: 1,
turn: report.group.id,
backgrounded: report.backgrounded ?? false,
labelBase: label ?? UNLABELLED_SUBAGENT,
provisional: label === null,
inherited: false
})
return this.write(group)
}
/** A new run of a known child reopens its one entry, wherever the row listing it is: its clock
* starts again, and it is the next attempt. */
private startRun(
current: LocatedSubagent<Placement>,
report: SubagentReport<Placement>,
run: string
): StructuredAgentSessionSinkAdmission {
const { group, tracked } = current
const runs = [...tracked.runs, run]
for (const forgotten of runs.splice(0, runs.length - MAX_RUNS_PER_SUBAGENT)) {
this.groups.rememberSettledRun(report.id, forgotten)
}
const label = boundedSubagentLabel(report.label)
const named = tracked.provisional && label !== null && label !== UNLABELLED_SUBAGENT
group.entries.set(report.id, {
entry: startedSubagentEntry(
report,
named ? claimSubagentLabel(group, label) : tracked.entry.label,
this.now(),
tracked.entry
),
run,
runs,
attempt: tracked.attempt + 1,
turn: report.group.id,
backgrounded: report.backgrounded ?? false,
labelBase: named ? label : tracked.labelBase,
provisional: tracked.provisional && !named,
inherited: false
})
return this.write(group)
}
private revise(
located: LocatedSubagent<Placement>,
report: SubagentReport<Placement>
): StructuredAgentSessionSinkAdmission {
const { group, tracked } = located
const id = tracked.entry.id
const next: TrackedSubagent = {
...tracked,
backgrounded: report.backgrounded ?? tracked.backgrounded,
entry: { ...tracked.entry }
}
const label = boundedSubagentLabel(report.label)
if (label !== null && tracked.provisional && label !== UNLABELLED_SUBAGENT) {
next.provisional = false
next.labelBase = label
next.entry.label = claimSubagentLabel(group, label)
}
if (typeof report.tokens === 'number') {
next.entry.tokens = report.tokens
}
group.entries.set(id, next)
if (report.state) {
this.setState(group, id, next, report.state)
}
return this.write(group)
}
private setState(
group: SubagentGroup<Placement>,
id: string,
tracked: TrackedSubagent,
state: NativeChatSubagentEntry['state']
): boolean {
const next = withSubagentState(tracked, state, this.now())
if (next) {
group.entries.set(id, next)
}
return next !== null
}
private sweep(group: SubagentGroup<Placement>): StructuredAgentSessionSinkAdmission {
for (const [id, tracked] of group.entries) {
this.setState(group, id, tracked, 'unverifiable')
}
// A null `lastSerialized` is a refused earlier write that nothing else may retry.
return this.write(group)
}
private write(...groups: SubagentGroup<Placement>[]): StructuredAgentSessionSinkAdmission {
if (this.pending) {
groups.forEach((group) => this.pending?.add(group))
return ADMITTED
}
return this.flush(groups)
}
private flush(groups: Iterable<SubagentGroup<Placement>>): StructuredAgentSessionSinkAdmission {
const written = [...groups]
const refused = writeSubagentRows(written, this.deps.port)
this.groups.trim(written)
return refused ?? ADMITTED
}
}