Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
658 changes: 658 additions & 0 deletions ATTRIBUTION-parent-recency.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ describe('claude journal translation — subagents', () => {
})

describe('producer attribution', () => {
it("stamps a subagent frame's message, tool call and tool result, and leaves the roster row alone", () => {
it("stamps a subagent frame's message, tool call and tool result, and the roster row too", () => {
const { translator, appended } = harness()
translator.handle(userTurn('user-1'))
translator.handle(assistantFrame('root-1', null, [{ type: 'text', text: 'delegating' }]))
Expand All @@ -343,6 +343,8 @@ describe('producer attribution', () => {

const stamped = appended()
.filter((item) => item.options?.producedBySubagent === true)
// The roster row is stamped too, and is asserted on its own below.
.filter((item) => orcaClientMessageId(item.identity) !== GROUP_ITEM_ID)
.map((item) => item.body.kind)
expect(stamped).toEqual(['message', 'tool-call', 'tool-call'])

Expand All @@ -353,13 +355,17 @@ describe('producer attribution', () => {
)
expect(rootProse?.options?.producedBySubagent).toBeUndefined()

// The roster group row is the PARENT's own display of its children, so it must
// stay root — stamping it would hide the subagent list from the parent.
// The roster group row holds nothing but children's state and is rewritten on every
// child transition, so it is child-produced even though the parent's code writes it.
// Leaving it root let it move the session's recency clock on its own, which is the
// defect with the attribution already in place. Nothing is hidden by stamping it: the
// transcript renders every producer's rows, and the sidebar's child list comes from the
// live roster the host publishes, not from this row.
const rosterRow = appended().findLast(
(item) => orcaClientMessageId(item.identity) === GROUP_ITEM_ID
)
expect(rosterRow).toBeDefined()
expect(rosterRow?.options?.producedBySubagent).toBeUndefined()
expect(rosterRow?.options?.producedBySubagent).toBe(true)
})

it("stamps a subagent's STREAMED prose, which carries no message envelope when it persists", () => {
Expand Down
26 changes: 24 additions & 2 deletions src/main/claude/claude-subagent-roster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
import {
createDeferredStructuredAgentSessionEventSink,
type StructuredAgentSessionAppendOptions,
type StructuredAgentSessionEventSink
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { ClaudeSubagentRoster } from './claude-subagent-roster'
Expand All @@ -31,10 +32,16 @@ function isGroupRow(identity: AgentJournalItemIdentity, groupId: string): boolea
}

function harness(groupKey: string | null = TURN_1) {
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
// Options are captured, not dropped: producer attribution rides on them, and a harness
// that discards the argument reports every append as root and passes.
const items: {
identity: AgentJournalItemIdentity
body: AgentJournalItemBody
options: StructuredAgentSessionAppendOptions | undefined
}[] = []
const tombstones: AgentJournalItemIdentity[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity, body) => items.push({ identity, body }),
appendItem: (identity, body, options) => items.push({ identity, body, options }),
appendTombstone: (identity) => tombstones.push(identity),
publish: vi.fn()
}
Expand Down Expand Up @@ -139,6 +146,21 @@ describe('ClaudeSubagentRoster', () => {
expect(items).toHaveLength(1)
})

it('marks every roster row child-produced, on the first write and on each revision', () => {
const { roster, items } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Audit' })
)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } })
)
expect(items.length).toBeGreaterThan(1)
// The row is written from the parent's context and no `parent_tool_use_id` is near it,
// so nothing else would attribute it — yet it holds only children's state and is
// rewritten on every child transition. Unmarked, it alone re-stamps the session.
expect(items.map((item) => item.options?.producedBySubagent)).toEqual(items.map(() => true))
})

it('does not duplicate a resumed task re-announced under a new tool_use_id', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
Expand Down
10 changes: 8 additions & 2 deletions src/main/claude/claude-subagent-roster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,12 +363,18 @@ export class ClaudeSubagentRoster {
private write(group: RosterGroup): void {
const agents = [...group.entries.values()].map((tracked) => tracked.entry)
const options = { coalescingKey: `claude-subagents:${group.groupId}` }
// Child-produced, though it is written from the parent's context and no
// `parent_tool_use_id` comes near it: every byte of the row is children's state and it
// is rewritten on every child transition. Unmarked it moves the session's recency
// clock by itself. Removal is child-produced too: it is the final lifecycle
// transition of this child-only row, not activity from the parent.
const childRow = { ...options, producedBySubagent: true as const }
if (agents.length === 0) {
// The row's last child turned out not to be a subagent. An empty roster is
// not a roster of nothing, so the row goes rather than reading "Ran 0".
if (group.lastSerialized !== null) {
group.lastSerialized = null
this.deps.sink.appendTombstone(group.identity, options)
this.deps.sink.appendTombstone(group.identity, childRow)
this.deps.sink.publish()
}
return
Expand All @@ -380,7 +386,7 @@ export class ClaudeSubagentRoster {
return
}
group.lastSerialized = serialized
this.deps.sink.appendItem(group.identity, body, options)
this.deps.sink.appendItem(group.identity, body, childRow)
// Publish keeps the sink's own coalescing slot: sharing the row's key makes
// each queued publish evict the append it was meant to flush.
this.deps.sink.publish()
Expand Down
24 changes: 20 additions & 4 deletions src/main/codex/codex-subagent-roster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import type {
} from '../../shared/agent-session-journal-types'
import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary'
import { isSubagentGroupBlock, type NativeChatSubagentEntry } from '../../shared/native-chat-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type {
StructuredAgentSessionAppendOptions,
StructuredAgentSessionEventSink
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
CodexSubagentRoster,
codexSubagentGroupIdentity,
Expand All @@ -22,7 +25,11 @@ import {
const THREAD = 'thread-parent'
const TURN = 'turn-1'

type Appended = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }
type Appended = {
identity: AgentJournalItemIdentity
body: AgentJournalItemBody
options?: StructuredAgentSessionAppendOptions
}

function createHarness(options: { threadId?: string | null } = {}): {
roster: CodexSubagentRoster
Expand All @@ -36,8 +43,8 @@ function createHarness(options: { threadId?: string | null } = {}): {
appendItem: () => {},
appendTombstone: () => {},
publish: () => {},
tryAppendItem: (identity, body) => {
appended.push({ identity, body })
tryAppendItem: (identity, body, options) => {
appended.push({ identity, body, options })
return { accepted: true }
},
tryPublish: () => ({ accepted: true })
Expand Down Expand Up @@ -169,6 +176,15 @@ describe('CodexSubagentRoster', () => {
expect(appended).toHaveLength(1)
})

it('marks Codex roster rows as child-produced', () => {
const { roster, appended } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(appended[0]?.options?.producedBySubagent).toBe(true)
})

it('counts a /morpheus agent as a child — only /root is the turn itself', () => {
const { roster, agents } = createHarness()

Expand Down
5 changes: 4 additions & 1 deletion src/main/codex/codex-subagent-roster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,10 @@ export class CodexSubagentRoster {
// The publish must NOT reuse that key: the queue coalesces by key alone,
// with no op-kind check, so a publish carrying it would splice out the
// still-queued append and the row would never reach the journal.
const options = { coalescingKey: `codex-subagents:${group.groupId}` }
const options = {
coalescingKey: `codex-subagents:${group.groupId}`,
producedBySubagent: true as const
}
const admission = this.deps.sink.tryAppendItem
? this.deps.sink.tryAppendItem(group.identity, body, options)
: (this.deps.sink.appendItem(group.identity, body, options), ADMITTED)
Expand Down
65 changes: 65 additions & 0 deletions src/main/native-chat/agent-session-journal/journal-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,3 +718,68 @@ describe('producer attribution round-trips through the reducer', () => {
expect(renderJournalState(state).items[0]?.producedBySubagent).toBeUndefined()
})
})

describe("the session's recency clock", () => {
it("advances on the session's own rows and not on a subagent's", () => {
const state = fold([
{ kind: 'item', itemId: 'root-1', revision: 1, body: text('delegating'), ...base(1) },
{
kind: 'item',
itemId: 'child-1',
revision: 1,
body: text('the child is reading files'),
...base(2),
producedBySubagent: true
},
{
kind: 'item',
itemId: 'child-2',
revision: 1,
body: text('the child is still reading files'),
...base(3),
producedBySubagent: true
}
])
// Ordering still counts every row; only the clock is the session's own.
expect(state.lastSequence).toBe(3)
expect(state.lastActivityAt).toBe(base(1).ts)
})

it("resumes on the session's next own row", () => {
const state = fold([
{ kind: 'item', itemId: 'root-1', revision: 1, body: text('delegating'), ...base(1) },
{
kind: 'item',
itemId: 'child-1',
revision: 1,
body: text('the child reported back'),
...base(2),
producedBySubagent: true
},
{ kind: 'item', itemId: 'root-2', revision: 1, body: text('done'), ...base(3) }
])
expect(state.lastActivityAt).toBe(base(3).ts)
})

it('advances on a lifecycle batch a subagent did not produce, and holds on one it did', () => {
const own = fold([
{
kind: 'lifecycle-batch',
settlementId: 'settle-1',
mutations: [{ kind: 'item', itemId: 'i-1', revision: 1, body: text('settled') }],
...base(1)
}
])
expect(own.lastActivityAt).toBe(base(1).ts)
const child = fold([
{
kind: 'lifecycle-batch',
settlementId: 'settle-2',
mutations: [{ kind: 'item', itemId: 'i-2', revision: 1, body: text('settled') }],
...base(1),
producedBySubagent: true
}
])
expect(child.lastActivityAt).toBe(0)
})
})
11 changes: 10 additions & 1 deletion src/main/native-chat/agent-session-journal/journal-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
agentJournalSubmissionKey,
parseAgentJournalItemKey
} from '../../../shared/agent-session-journal-item-key'
import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer'
import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation'
import { journalItemRevisionIsStale } from './journal-item-revision'
import type { JournalRow } from './journal-row-schema'
Expand Down Expand Up @@ -67,7 +68,15 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo
if (row.kind === 'epoch') {
return
}
state.lastActivityAt = Math.max(state.lastActivityAt, row.ts)
// Recency is the SESSION'S OWN agent's. A subagent's rows share this journal and are
// the newest ones in it while a child runs, so a journal-wide clock stamps an idle
// parent "now" on every child frame — and that clock becomes the row's
// `stateStartedAt`, which is the acknowledgement clock, so the parent also went
// unread again each time. A child's work reaches the parent's row as a status
// rollup instead; see the status feed.
if (isRootAgentJournalItem(row)) {
state.lastActivityAt = Math.max(state.lastActivityAt, row.ts)
}
if (row.kind === 'item') {
if (journalItemRevisionIsStale(state, row.itemId, row.revision)) {
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,11 @@ export function journalItemRowBuilder(
export function journalTombstoneRowBuilder(
state: () => JournalReducerState,
itemId: string,
fence: number
fence: number,
producedBySubagent?: true
): RowBuilder<JournalTombstoneRow> {
return (seq, ts) => buildJournalTombstoneRow({ state: state(), itemId, seq, fence, ts })
return (seq, ts) =>
buildJournalTombstoneRow({ state: state(), itemId, seq, fence, ts, producedBySubagent })
}

export function journalSubmissionRowBuilder(
Expand Down Expand Up @@ -193,6 +195,7 @@ export function buildJournalTombstoneRow(input: {
seq: number
fence: number
ts: number
producedBySubagent?: true
}): JournalTombstoneRow {
const resolved = input.state.aliases.get(input.itemId) ?? input.itemId
return {
Expand All @@ -207,7 +210,8 @@ export function buildJournalTombstoneRow(input: {
input.state.items.get(resolved)?.revision ?? 0,
input.state.tombstones.get(resolved) ?? 0
) + 1,
...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts)
...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts),
...(input.producedBySubagent ? { producedBySubagent: input.producedBySubagent } : {})
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export type JournalItemAppendOptions = {
recovered?: true
producedBySubagent?: true
}
export type JournalTombstoneInput = { fence: number }
export type JournalTombstoneInput = { fence: number; producedBySubagent?: true }

export type JournalLifecycleBatchInput = {
settlementId: string
Expand Down
11 changes: 8 additions & 3 deletions src/main/native-chat/agent-session-journal/journal-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,14 @@ export class AgentSessionJournal {
options: JournalTombstoneInput
): Promise<AgentJournalCursor> {
const itemId = agentJournalItemKey(identity)
return this.enqueue(journalTombstoneRowBuilder(() => this.state, itemId, options.fence)).then(
(row) => ({ epoch: row.epoch, sequence: row.seq })
)
return this.enqueue(
journalTombstoneRowBuilder(
() => this.state,
itemId,
options.fence,
options.producedBySubagent
)
).then((row) => ({ epoch: row.epoch, sequence: row.seq }))
}

appendLifecycleBatch(input: JournalLifecycleBatchInput): Promise<AgentJournalCursor> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,16 @@ type Recorded = {
fence?: number
ordinal?: number
settlementId?: string
activity?: AgentSessionTurnActivity | null
producedBySubagent?: true
activity?: AgentSessionTurnActivity | null
}

function target(
fence: number,
log: Recorded[],
failOn?: number
): StructuredAgentSessionEventTarget {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fake implements every journal method exercised by the sink.
const journal = {
appendItem: vi.fn(async (id: AgentJournalItemIdentity, _body: AgentJournalItemBody) => {
const ordinal = id.provider === 'codex' ? id.ordinal : -1
Expand All @@ -44,14 +45,17 @@ function target(
log.push({ call: 'appendItem', fence, ordinal })
return { cursor: { epoch: 'e', sequence: ordinal } }
}),
appendTombstone: vi.fn(async (id: AgentJournalItemIdentity) => {
log.push({
call: 'appendTombstone',
fence,
ordinal: id.provider === 'codex' ? id.ordinal : -1
})
return { epoch: 'e', sequence: 0 }
}),
appendTombstone: vi.fn(
async (id: AgentJournalItemIdentity, options: { producedBySubagent?: true }) => {
log.push({
call: 'appendTombstone',
fence,
ordinal: id.provider === 'codex' ? id.ordinal : -1,
...(options.producedBySubagent ? { producedBySubagent: options.producedBySubagent } : {})
})
return { epoch: 'e', sequence: 0 }
}
),
appendLifecycleBatch: vi.fn(async (input: { settlementId: string }) => {
log.push({ call: 'appendLifecycleBatch', fence, settlementId: input.settlementId })
return { epoch: 'e', sequence: 0 }
Expand Down Expand Up @@ -117,6 +121,18 @@ describe('deferred structured agent-session event sink', () => {
expect(log).toEqual([{ call: 'appendItem', fence: 2, ordinal: 0 }])
})

it('preserves child attribution on tombstones', async () => {
const log: Recorded[] = []
const deferred = createDeferredStructuredAgentSessionEventSink()
deferred.bind(target(1, log))
deferred.sink.appendTombstone(identity(0), { producedBySubagent: true })
await deferred.drained()

expect(log).toEqual([
{ call: 'appendTombstone', fence: 1, ordinal: 0, producedBySubagent: true }
])
})

it('resolves a lifecycle transition after journal bind and skips an existing state', async () => {
const log: Recorded[] = []
const deferred = createDeferredStructuredAgentSessionEventSink()
Expand Down
Loading
Loading