Files
orca/src
Brennan Benson 9ece273056 fix(native-chat): journal rows name the agent that produced them (#22299)
* feat(native-chat): carry producer linkage on every journal row

A journal is the durable record of one agent SESSION, and a session that runs
subagents journals their rows into the same timeline with nothing on the row
saying which agent wrote it. Add that: a per-row linkage bundle naming the
producing agent, its parent, the provider's raw parent reference as provenance,
the kind of work, and which run of the agent produced the row.

The bundle rides the row BASE, not the body: two nested prompt shapes are
strict, so an unknown key on a body makes the whole row parse as malformed. It
is deliberately not a schema-version bump either — an unknown `v` makes a row
unreadable and latches the host read-only, while an unknown key is ignored, so
an older host reads a stamped row and behaves exactly as it does today.

One reader predicate interprets absence, by presence and not by truthiness: an
id that failed to resolve is still an id, and a truthy test would read it as
root and put the child's content back on the parent. The parent-facing status
scans — thinking, the running tool call, the latest assistant line and the
quoted prompt — now skip rows a subagent produced. The transcript is left
unscoped on purpose: it shows every agent's output.

No producer stamps anything yet; this is the carrier and the reader.

* fix(claude): attribute a subagent's journal rows to the subagent

The Claude translator already parsed `parent_tool_use_id` on every envelope and
threw it away. It now resolves that reference to the producing agent's canonical
task id — never to the reference itself, which names the tool CALL and is
re-minted on every resume, so a row stamped with it would split one child into
two the moment it resumed. The raw reference is kept beside it as provenance.

Resolution is its own module rather than more roster: the roster maintains the
spawn-group row a user reads, while this answers, for one frame's parent
reference, whether the rows it produces are the session's own agent's, some
child's, or nobody's yet. It reads the alias table directly to tell "an
announcement named this spawn call" from "this id is simply unknown", which
comparing the canonical id against the raw one cannot do when the two match.

Where the identity is not final the row waits rather than guessing. A top-level
spawn whose `task_started` has not landed is the one case that can still
resolve, so its rows are held — bounded at 64, oldest written first — and
released when the announcement arrives or when nothing can name the producer any
more. Nothing is dropped and nothing is written as the parent's.

A release that announces no tasks at all is decided immediately instead of held:
nothing stable is ever reachable for its children, and an id that rotates is
worse than no id because it is silently wrong rather than visibly absent. Those
rows read as the session's own, exactly as they do today. Holding them instead
would strand every row that REVISES an earlier one — a tool result would leave
its tool row reading "running" for the rest of the turn.

The attempt counter moves in exactly one place, the existing reactivation branch
where a new spawn alias reopens an entry, and is gated on that observed alias
change rather than on the counter, so a late duplicate cannot advance a settled
run. The first run carries no attempt at all.

The group row keeps its own module's write path, now named there, because it is
the one row written from a child's frame that is deliberately the parent's.

* test(native-chat): pin producer linkage end to end, and fix the harnesses first

Four harnesses in this area silently discarded the append options they were
handed, so every assertion about attribution would have passed against
`undefined`. Two are fixed here — the journal double behind the real deferred
sink, and the Claude subagent translator's sink — and each records the options
beside its existing call log rather than on it, so the assertions about call
order stay about call order.

The read side is pinned first, because linkage correct in the store and never
read by the projection is the way this ships looking finished and fixing
nothing. The three defects are asserted through the live-turn and projection
readers: a parent no longer reads as thinking because its child is reasoning, no
longer shows its child's running tool, and no longer quotes its child's prose or
prompt. The opposite direction is pinned too — the transcript still renders the
child's output, and a parent's own line is never suppressed.

Also covered: a resumed child keeps one identity while its spawn call id
rotates; a child row arriving before its announcement is held and then written
linked rather than dropped; a held row is written under the raw reference when
no announcement ever comes; the buffer's bound writes the oldest row rather than
losing it; an unresolvable id reads as a child rather than as the parent; a row
with no linkage reads as root; the schema version is unchanged; and a strict
prompt shape still parses, with a positive control proving that strictness is
real and is why linkage rides the row rather than a body.

* test(native-chat): give the journal double's cast its SAFETY rationale

Editing inside the object literal re-attributes the pre-existing assertion to
changed lines, and the changed-code gate requires a line-specific rationale.

* fix(claude): name the agent that spawned a nested subagent

A grandchild's rows carried an agent id but no parent, and under this
journal's semantics an absent parent is not silence — it is the claim that
the session's own agent spawned the row's producer. For a task spawned from
inside another subagent's sidechain that claim was simply false.

The frames from such a task carry exactly one handle: the nested call's tool
id. That id was journaled once already, as a tool-use block on the row of the
child that made the call, so the child is recoverable from it — but only if
something remembers which row carried it. The registry that already tracks
which tool calls reached the top-level transcript now records the sidechain
ones too, against the reference naming their owner, and the resolver follows
that reference to name a row's parent.

The reference is recorded, not an identity, and it is resolved through the
same path the owner's own rows resolve through, so a parent id always matches
the agent id the parent's rows carry however either was settled. A row now
persists only once BOTH its producer and its parent are final; a grandchild
whose child has not been announced yet waits in the same buffer, and leaves
it through the same three doors.

* test(claude): pin the streamed-text lane's attribution instead of only capturing it

The checkpoint harness was fixed to record the append options, and then nothing
asserted them: all five of its tests passed unchanged against an implementation
that resolves no producer at all, so the lane's attribution was covered by a
capture and no claim.

These assert it: a block streamed inside a child carries that child's linkage,
the session's own carries no keys at all, a checkpoint is held rather than
written while the producing agent is provisional, the flush before settlement
writes a held block under the raw reference rather than losing it or filing it
as the parent's, and a block's producer is resolved once and kept — every
checkpoint rewrites the same row, so a producer that moved would file one
agent's prose under two identities.

* refactor(journal): name the linkage row fields for their role, not their shape

The anti-slop gate rejects "Shape" in a symbol name. These are the linkage
fields a render item carries, and the name now matches the sibling helper
that builds them.

* fix(claude): hold the session's first subagent instead of filing it as the parent

A child's first frames can arrive before the `task_started` that names it, and
the resolver treated "this release has announced no task" as a settled fact
about the CLI. Before its own first announcement every session looks exactly
like that, so the FIRST subagent's pre-announcement rows were written straight
out as the session's own — the whole defect, for the first child of every
session, persisted with no backfill to repair it.

A spawn call the session forwarded at top level is positive evidence that an
announcement is still expected, so it now outranks the release check. A release
that genuinely announces nothing is unchanged: its rows reach the release
verdict at settle and still read as root, just written a little later.

Also stops a malformed owner chain that loops back from naming an agent its own
parent; the depth guard bounded that walk but could not make its answer mean
anything, and absence is the truthful claim.

* fix(claude): read a tool result as its caller's row, not a child's

Every top-level tool call is a forwarded tool id, not just a spawn, so a result
frame naming its own call as parent resolved as a child awaiting an
announcement that is never coming. The row was parked until the turn settled
and the tool sat `running` in the meantime.

A frame delivering the result of the very call it names is the caller consuming
its own output; only a spawn call ever gets a sidechain. Pins added for that and
for the first-subagent hold, and the never-announced case now asserts the row
was WRITTEN as root rather than that it carries no agent id, which an absent row
also satisfied.

* fix(journal): refuse an empty producer id, and drop a bad one without losing the row

The reader that scopes a parent's surfaces tests PRESENCE, so `agentId: ''` is
present: a row carrying it reads as a subagent's and disappears from its own
author's surfaces for good. Neither validator caught it — the wire schema
accepted any string, and the persisted-row guard type-checked nothing in the
bundle at all, against that file's own stated policy.

The wire schema now requires a non-empty id. The persisted side sanitises
instead: a bad linkage field is DROPPED and the row is kept. Rejecting there
would turn a tightened validator into a whole-store kill switch, and degrading
a row to the session's own agent is what every row said before linkage existed.

* fix(native-chat): answer the turn activity line for the session's own agent

`selectStructuredAgentTurnActivity` is a "what is this agent doing right now"
reader and was not scoped by producer. It builds a label set from every
tool-call row in the turn — a subagent's included — and both readers below it
use that set to suppress a line that repeats it. So a CHILD's tool label could
blank the PARENT's activity line: child data deciding the parent's surface.

Live, not latent: the provider-activity branch is populated in this lane, and
it consults the label set without ever consulting `providerFrame`, which is
what the status fallback loop relies on. Scoped once at the top, so both
readers share one interpretation point. Renderer and mobile share this
function, so both are covered.

* fix(journal): stop a lifecycle batch stamping one producer onto N mutations

A lifecycle-batch row carries N mutations but stamped linkage at ROW level, so
a future mixed-producer batch would silently attribute every mutation to
whoever opened it. Both callers are single-producer today, so this was latent.

The write path no longer accepts linkage for a batch, which removes the failure
mode by construction rather than guarding it. The reducer still READS linkage
off a batch row — a row may arrive from a host that writes one — and a genuinely
mixed batch would have to stamp per mutation, which nothing needs yet. Chosen
over adding a per-mutation field because that would persist a new key forever
with no writer and no reader.

* docs(journal): say why each unlinked write site is unlinked, and drop two false claims

Completes the write-site audit the PR claims. Prompt rows carry no linkage and
CANNOT: a prompt arrives through the SDK's permission callback, whose options
carry a request id and the tool awaiting approval and no parent reference of
any kind — unattributable at that site, not deliberately root. Turn rows are
deliberately root and now say so.

Two comments justified decisions by mechanisms this store does not have. The
linkage docblock cited compaction dropping a start row and a pagination
boundary; there is no compaction, and pagination is complete-or-reset. Per-row
repetition is still right, for the reason that is actually true: every reader
scans back from the tail and stops at the turn. A test carried the same false
framing. `claudeFrameParentRef` claimed to read the field by the same rule as
`isRootClaudeFrame`; it is deliberately stricter on the empty string.

* refactor(claude): write a child's rows through, then correct the attribution

Four misattribution paths shared one cause: the lane committed to an
attribution verdict at write time and could never revise it. That followed from
"the journal has no backfill", which is false — re-appending an `itemId` bumps
its revision, the reducer rebuilds linkage from the newest row, and it pins
`sequence`/`observedAt` so a correction does not move the bubble. This lane
already relied on that twice.

So the order inverts. A row whose producer is still provisional is written
immediately, stamped with the spawn call's own id, and re-attributed in place
when the announcement names it. Bookkeeping no longer gates a user's view of
what an agent said.

The hold buffer is deleted rather than left as a pass-through. Corrections are
bounded and die four ways: the announcement, turn settle, teardown, or the
bound. Passing the bound gives up on that producer WHOLESALE — correcting some
of a child's rows and not the rest splits one child across two ids, which is
worse than correcting none. A correction that would change nothing is dropped
rather than burning a revision.

The streamed lane loses its producer latch, which pinned the first verdict
permanently and is why an announcement one frame later could never reach the
row. Every checkpoint rewrites the same identity, so there is one row per block
and re-resolving can only revise it; the latch was guarding against a split
that cannot happen on this path. A block that stops streaming before its
announcement is re-attributed explicitly, since nothing else revisits it, and
the announcement is now observed BEFORE the forced flush that would otherwise
stamp it a line too early.

Also narrows the no-announcements-at-all escape so it no longer swallows a
forwarded spawn call. That escape now applies only to a sidechain id no spawn
call ever forwarded, where there is genuinely no handle to stamp.

* fix(journal): move two test doubles onto the signatures they pin

Both failed typecheck while passing at runtime, which is what a test double
gets to do: vitest never typechecks them.

The sink's lifecycle-batch double still read producer linkage off the batch
input after that input stopped carrying any, so it had no property in common
with the linkage type. The fence is now all it records, which is what the
narrowed contract actually forwards — and what the test beside it already
asserts.

The row-schema helper returned the whole six-arm `JournalRow` union while every
caller reads `body`. It now narrows to the item arm it always builds, so the
assertions read it directly rather than through a cast.

* fix(claude): resolve a tool result to its real caller, not to the session root

A nested tool row could end stuck `running` with its result content dropped.

Cause was in the result-frame attribution, not in the correction ledger. A frame
delivering the result of the call it names as parent is the CALLER consuming its
own output — but the code read "the caller" as "the session's own agent", which
is only true when the caller is the root. A call a child made is owned by that
child. Collapsing it to root both misattributed the row and made the result's
write resolve through a different reference than the call's, so the correction
owed to that row was left holding the body it had BEFORE the result landed, and
re-attribution then reverted the row.

The caller is now resolved through the registry that already records which agent
journaled a tool call, so both writes to one row resolve through the same
reference and the newest body wins.

A settled write also supersedes any correction owed to its row. One `itemId` is
legitimately written under two references — `claudeToolIdentity` is keyed on the
tool id alone — and a settled write already carries a final verdict, so an
outstanding correction could only restamp it from a reference that write did not
use. Dropped rather than re-bodied for that reason.

Adds the ledger's first unit tests, including the invariant this defect broke: a
correction changes a row's attribution and never its content.

* fix(claude): keep a correction owed when the sink refuses it

A correction went out through the plain append, which discards the queue's
admission. Under backpressure the write was refused and `retry` had already
dropped the entry, so the obligation died with nothing re-deriving it — the
failure class this work exists to refuse. It is self-feeding too: a correction
costs a commit on the same serialized writer that carries live rows, so the
burst that generates many corrections is what builds the backlog that drops
them.

It now uses the admission-returning path the sink already exposes, keeps the
entry outstanding on a refusal, and lets `abandon` try once more. A refusal
there ends it: the row keeps the spawn call's own id, which is usable, and an
obligation with no exit is worse than one that settles for less. `settle` also
reports what actually happened instead of always claiming it wrote, so publish
no longer fires for a write nobody accepted.

Also records why the live-turn scans may read the turn record before checking
the producer. A turn is the session's unit of work and no producer of a
turn-bearing body stamps linkage: Claude's turn rows carry none, Codex has no
linkage concept, the compact row passes only a fence, and the stale-turn sweep
goes through the lifecycle-batch path, which cannot carry linkage by type. The
ordering is safe by construction rather than by accident, and the comment says
so, so a future producer knows what it would break.

* fix(claude): never read a row naming a parent as the session's own

A non-null `parent_tool_use_id` names a child, always. The resolver still had
one branch that read such rows as the session's own agent's — a release that
had announced no task, where the comment claimed "there is no handle to stamp".
There is one: the reference itself. The branch was buying a false attribution to
avoid an id nothing joins on, which is the trade already reversed once for
forwarded spawn calls, and every reader of this field is a presence test.

So the branch goes, and with it the `root` arm of the verdict and the resolver's
whole dependency on whether the release announces tasks. Two states remain:
linked now, or linked now and owed a correction. The type deleted a stale test
double on sight, which is the argument for removing the arm rather than the
branch alone.

This also closes the severe half of the tool-origin eviction exposure. A spawn
id evicted from the bounded top-level set used to flip the release check on and
stamp a child's rows as the parent's; with nothing returning root that cannot
happen. What remains is a missed correction, which splits one child across two
ids — the same end state as passing the correction bound, benign in kind and
disclosed.

`isForwardedParentTool` stays where it gates PENDINGNESS. It now decides only
whether a correction is owed, never whether a row is a child's, so a stale
answer costs precision rather than correctness.
2026-09-22 13:57:46 -07:00
..