Skip to content

refactor(runtime): converge AgentRun metadata into the RuntimeInvocation event spine - #4631

Open
Astro-Han wants to merge 35 commits into
mainfrom
refactor/4311-invocation-event-spine
Open

refactor(runtime): converge AgentRun metadata into the RuntimeInvocation event spine#4631
Astro-Han wants to merge 35 commits into
mainfrom
refactor/4311-invocation-event-spine

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

A Run's facts lived in two places at once. The RuntimeEvent ledger recorded what happened; a mutable AgentRunHeader row recorded what the run was and how it ended. Every reader had to decide which one to trust, every writer that ended a run committed twice, and a whole layer existed only to keep the two in agreement — computeStatusConsistency, effectiveRunHeaderFromTerminalFact, terminalRunHeaderMatchesFact, the status_consistency_mismatch diagnostic, and an after_terminal_header_committed failpoint for the window between the two commits.

This retires the header. A Session is its RuntimeInvocations, and an invocation is an immutable opening fact, its events, and one terminal event:

  • The opening fact is a hidden system RuntimeEvent (invocation_opened_v1) carrying the route, configuration, root authority, open source and lineage the header used to hold. It is event 1 of every invocation.
  • RuntimeInvocationRecord is a query over the spine, not a table. runtimeInvocationsFromSessionEvents is the definition of a Session's inventory; the SQLite index exists to address and page it, and a rebuild from events alone must agree with it.
  • A Run ends exactly once, and its terminal RuntimeEvent is the only statement that it ended.

AgentRunStore keeps only what it is the authority for — the operational event ledger.

Fixes #4311

The write path and the read path, before and after

Red marks what this PR deletes and the failure it existed to paper over; the right-hand column has none of it.

What the second record cost, on both paths

同一张图的中文版:

第二份记录的代价,写路径和读路径各付一次

Where the diff goes

+8826 / −8450 is a net +376, which is not what a change that removes a record should look like. The account, against the merge base:

net
One-shot migration — legacy-run-header.ts (+445), its decode tests, the backfill test, and the backfill in sqlite-runtime-schema.ts +1101
Everything else, production −660
Everything else, tests −65
Total +376

Without the migration this removes about 720 lines. The migration exists only to read databases the old code wrote, and comes out whole once those are gone.

Why the rest is only −660, when a whole authority went away:

The facts moved; they did not disappear. Route, configuration, root authority, lineage and open source still have to be declared somewhere — now in the opening fact rather than in a header row. core/agent-run.ts (−557) pays for runtime-event.ts (+327, the closed content schema) and runtime-invocation.ts (+351). Part of that is a verbatim move: hosted-root matching was 139 lines and is 159 now under a new name, which git records as one addition and one deletion.

What actually went away was never a module. The reconciliation layer was a responsibility spread across six files — runtime-ledger-repair.ts −430, runtime/agent-run.ts −149, terminal-run-commit.ts −144, prior-run-context.ts −103, storage/agent-run-store.ts −103, runtime-read-model.ts −100. Roughly a thousand lines of writing both records, deciding which to trust, and repairing one from the other. No file was deleted outright; each kept the job it was actually for.

A derived inventory pays for what a table gave away. The header was a row, so addressing, ordering and paging came free. Deriving the inventory from events costs sqlite-runtime-store.ts (+191) and its schema. The derivation itself is 36 lines — runtimeInvocationsFromSessionEvents; making it addressable and pageable is the rest.

The count that matters is not lines: AgentRunHeader has no references left in the repository, the write path commits once instead of twice, and no reader has to decide which of two records is true.

Review focus

Not a diff to read front to back. Suggested order — each row is self-contained, and the behaviour rows are where judgement is actually needed:

Read Commits What to check
The shape define the invocation opening fact as a RuntimeEvent; open every invocation with a durable opening fact Is the opening fact complete? Anything the header held that it does not carry is a real gap.
The storage enumerate a Session's invocations from the event spine; keep a migrated invocation's opening beside the ones events carry; address, bound and page the invocation inventory Does a rebuild from events alone agree with the index, and does a migrated legacy header read the same as a native opening?
The switch retire the AgentRunHeader as a record of the run; read every run off the invocation spine; project every hosted Turn off the invocation spine Every reader now joins on the invocation. Look for one that still wants a mutable status.
The behaviour the four fix(runtime) commits, plus open an invocation on the spine, not on the operational ledger and finish reading every run off its own events Six decisions the single spine forced into the open — expanded below.
Behaviour changes (6) — what the header used to hide

A start refused because the Interaction authority is draining now settles as cancelled, not failed. It used to be one or the other depending on which writer won the race with the Turn's stop fence. Shutdown is not a run failure; the classification is stated once, next to the errors that carry the reason.

Recovery now reports only what it repaired. It used to report every invocation it walked. That was harmlessly true while the walk also rewrote the header; with the header gone an already-terminal run makes the pass a no-op, and the false claim rewrote the Session status on every startup, bumping the header revision out from under a caller holding it.

A terminal event that omits its failure class or abort source reads as unknown instead of making the Session unreadable. The event is immutable and must be the ledger tail, so a detail it did not state can never be added later. Recovery used to write app_restarted into the header instead; with the header gone the read model refused the fact outright, and one such event took a whole Session down. This also removes recovery's incomplete_single_terminal case and the projection's duplicate diagnostics.

Two terminal events on one invocation leave it open, rather than letting the last write win. A Run ends once; two statements that it ended are no statement at all, so the ambiguity reaches a reader that can repair it.

Continuation crash boundaries. after_run_created named a header row written before the durable start — a continuation's opening rides its continuation-start event, so nothing is durable there any more and the failpoint, its startup repair and their tests are gone. A crash after the terminal event is likewise not an unfinished claim; the boundary now reports continuation_already_exists.

Imported transcripts. A turn whose source never stated how it ended was materialized as completed and then repaired to failed once the header noticed the missing terminal. The terminal event is now written at materialization and can never be corrected, so an inferred status is recorded as the failure it is — which is also why an adapter emits a cutoff of its own.

Migration

A persisted legacy run header is decoded into an opening. A run that never wrote a RuntimeEvent gets the real thing — the opening as event 1 of its own invocation. A run that already has events cannot, because it owns an immutable sequence whose position 1, digests and coverage other facts already point at; its opening is shelved in runtime_legacy_invocation_openings, which only this migration ever writes. Readers merge the two shelves, so nothing downstream knows which one an opening came off, and a header this cannot project fails closed: it is skipped, and its transcript and tool evidence stay exactly as readable as before.

Tests deleted rather than translated

Some tests were removed instead of migrated. Each asserted a header/ledger disagreement, a crash in the window between the two commits, or a read-time repair of one record from the other — states this change makes unrepresentable, so there is nothing left for the test to arrange. The commit that removes each one names it and says why. Everything else was translated in place.

Two of those came in on the rebase, from #4445: the read model's projection-cache backfill only ran for a terminal run whose ledger was empty, and an invocation is its opening event, so a run with no events is not one this model can see. Everything else #4445 added — the durable session order a read sorts by, and its failure path — is kept and asserted on the spine.

A second pass removed cases that only restate a schema the decoder already enforces — a protocol literal, an enum member, an empty lineage object, each empty field of a legacy continuation source. What is left states a rule: which routes may name a connection and which may not, which root authorities exist and that none of them mix, that a continuation names the boundary it resumes from, and that a malformed opening fails the whole RuntimeEvent decode.

The same pass folded every test's idea of an invocation onto one fixture. Eight near-identical record constructors and a dozen inline opening literals became calls to it; its configuration now merges field by field, so a test states only the setting it is about.

Verification

Suite Result
@maka/core 782 pass, 0 fail
@maka/storage 1100 pass, 8 skipped, 0 fail
@maka/runtime 3171 pass, 13 skipped, 0 fail
@maka/runtime-host 1652 pass, 12 skipped, 0 fail
@maka/desktop 2033 pass, 0 fail
npm run -ws typecheck, npm run format, npm run lint clean

Not run: the full-repo suite. Of the Playwright E2E suite, only workhub-reconstruction.spec.ts was run locally (3 passed). No user-visible surface changed, so no before/after screenshots.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code, used throughout — the refactor across all workspaces, the test migration, the behaviour changes above, and this description. Every commit carries a Generated-by trailer. All reasoning about what to delete and what to keep was reviewed by me against the issue.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — six changes, listed under Review focus
  • No

@github-actions github-actions Bot added the effort/XL Over 1000 readable lines label Sep 3, 2026
@Astro-Han
Astro-Han requested a review from M4n5ter September 3, 2026 08:23
@Astro-Han
Astro-Han force-pushed the refactor/4311-invocation-event-spine branch from 1f6d52b to fc298bd Compare September 3, 2026 08:23
@Astro-Han
Astro-Han force-pushed the refactor/4311-invocation-event-spine branch from fc298bd to b0e3296 Compare September 3, 2026 08:45
Route provenance, execution configuration, root authority and lineage are
immutable the moment an invocation opens, but today they only exist on the
mutable AgentRunHeader, so every RuntimeEvent that needs its own route has to
join back through event.runId. That join is what removed compatible provider
reasoning in #4286.

Add `invocation_opened` as a closed, versioned RuntimeEvent content kind. It
carries the route once per invocation so readers join it by invocationId
instead of copying it onto every event, and it fails closed: a route whose
connection identity cannot be established decodes as `provenance: 'unknown'`
rather than as an authenticated route.

The root authority is a discriminated union instead of a bag of mutually
exclusive optional ids, so a reader names the root it wants rather than
asserting that every other root field is absent.

Refs #4311

Generated-by: Claude Code
Every run-kind invocation now commits its opening fact as its own first
RuntimeEvent, before the run row, before the run_created ledger row and before
any provider or tool dispatch. The store already requires a continuation's
start event to be event one of its target invocation, so for a continuation the
start event carries the opening fact instead of a second event preceding it.

The opening fact is projected from the Run header by one shared function, so
the two authorities cannot disagree while both exist, and the runtime protocol
marker moves with it: it has always belonged to the invocation's first event,
and that event is now the opening fact.

Run and invocation converge on one identity. All three sites that used to mint
them independently (continuation planning, conversation copy, imported
transcript repair) now emit the same value for both, and the derived
`invocation-` prefix is gone. Nothing renames a field yet; this only removes
the multiplicity that would have made a rename a lie.

The terminal event's own error message becomes the only source of a run's
failure text. It was already the source the header copy was written from, so
preferring the header only let a stale projection outlive its fact.

Refs #4311

Generated-by: Claude Code
Enumerating a Session's runs has only ever been possible through
`core_agent_runs`, which is also the only place the opening metadata lives.
That is what makes the Run header impossible to retire: it is simultaneously
the authority and the index.

Add `listSessionInvocations`, a query over `runtime_events` that reads each
invocation's opening fact and, where the invocation has ended, its terminal
event. Nothing writes it and nothing repairs it, so dropping the physical index
and rebuilding gives the same inventory. It sits beside `listSessionRuns` on
the same facades so consumers can move one at a time.

Runtime schema 16 adds the covering index for the opening lookup and gives
every header-only run the opening fact it never wrote. A run that already owns
an immutable sequence is left alone: its position one, digests and coverage are
already signed by other facts, so inserting into it would rewrite history
rather than record it. A header the projection cannot read fails closed and is
skipped, keeping its transcript and tool evidence exactly as readable as
before.

Refs #4311

Generated-by: Claude Code
…vents carry

The v16 backfill could only give an opening fact to runs that had never written
a RuntimeEvent. A run that already owns an immutable sequence cannot take one:
its position 1, digests and coverage are signed by other facts, so inserting
there would rewrite history. That left those invocations with their opening on
the Run header alone, which is exactly the authority this work is retiring.

Record their openings in `runtime_legacy_invocation_openings` instead. Only the
migration writes it, it is keyed by the invocation id the invocation's own
events already carry, and it holds the same projection the live writer emits,
produced by the same function.

`listSessionInvocations` merges the two shelves and says nothing about which one
a record came from. An opening is an opening; a consumer that could tell would
be encoding the migration window into its own logic, and would then have to be
changed again when the window closes.

Refs #4311

Generated-by: Claude Code
…he header

The composition was the one header field that was neither open-time nor
lifecycle: a late-bound, write-once snapshot committed as a patch. It forced the
header's whole mutable surface to stay open for a value that is by construction
a fact about one moment, and its immutability had to be defended inside
`updateRun`, which exists for values that do change.

Append it as `run_composition_recorded` instead. Same payload, same single
writer, same moment before provider dispatch, and the same guard: an identical
re-append is the writer retrying and is absorbed, a different one is refused.
The guard moves to where the record now lives.

Nothing outside the writer read `header.runComposition`, so the field and its
entry in the mutable-field set go with it, and reads go through one function
over the ledger.

Refs #4311

Generated-by: Claude Code
…not a Run header

The claim embedded a whole pre-provider Run header, a second durable copy of a
record that already exists, and then had to defend the copy against the original
with two deep-equality checks over live lifecycle fields. Those checks could only
ever fail for the wrong reason: the run's status and timestamps move as it runs,
the copy's never do.

What the claim is actually for is the start event. A continuation's start is
event 1 of its target, so it is also that invocation's opening fact, and the
claim has to say in advance exactly what that fact will be. So the claim carries
the opening and nothing else. Everything the old header said is either the
claim's own target identity, its `claimedAt`, or a restatement of its boundary,
so the header is rebuilt from the claim where a header is still needed, and
admission now round-trips through that rebuild — the run it computes must equal
the one the claim reconstructs, which makes losing information a build failure
rather than a silent drift.

The start-event rule was implemented twice, once in the store and once in the
runtime, and a fix to either left the other admitting what its twin rejected.
There is one implementation now, in core, called from both.

Lineage gains `resumedFromRunId` and `retriedFromRunId`. Without them the opening
cannot say that a run resumes or retries another, which is the only thing that
distinguishes a linked child's two admission kinds.

The claim's opening is decoded strictly, with no legacy widening. A claim whose
frozen opening cannot be read cannot authenticate the start it exists to
authenticate, so the migration drops such a row rather than leaving one that
would fail every later read and hold its boundary forever.

Refs #4311

Generated-by: Claude Code
… union

Matching a Run against the root the Host admitted took about 140 lines, almost
all of it asserting that every other optional lineage and root-authority field
was absent. That shape was forced by the header: an open bag of optionals where
"this is a Goal root" could only be said as "goalId is set and the other four
markers are not", and where adding one lineage field meant editing six
negative lists or silently weakening every one of them.

The opening fact names its root as a closed discriminated union, so each arm
names the root it wants. What is left of lineage is one exactness check: an
admitted root has the lineage its kind implies and no other edge, which is both
stronger than the old per-field negatives and immune to a new field being
forgotten.

The matcher now takes the invocation rather than the Run header. Runtime Host
still holds headers, so its one call site projects through the same mapping the
rest of this work uses; phase 2c hands it the real opening.

Refs #4311

Generated-by: Claude Code
`provider_request_captured`, `provider_request_attempt_recorded` and
`task_gate_decided` have no writer in this build. `AGENT_RUN_EVENT_TYPES` is the
catalogue of what this build may append, not what it may read, so keeping them
there only kept alive the copy rewriters that existed to move their payloads.

Persisted rows of these kinds keep working exactly as before, because the
ledger's `type` has always been an open string: the diagnostic reader that folds
a legacy provider attempt into a prompt composition still reads them, and a
conversation copy now drops them the way it already drops every type this build
cannot emit, rather than carrying source-owned identities into the target it
cannot check.

One thing does survive the deletion: the copy still harvests provider trace ids
from those rows. A copied RuntimeEvent may point at a trace only a retired writer
recorded, and pointing the target at a fresh id is right where pointing it at the
source's id would not be.

Refs #4311

Generated-by: Claude Code
The event spine could enumerate a Session's invocations but nothing else.
Every remaining Run-header read is one of three other shapes: one invocation
by id, a bounded identity search, and a newest-first page. Adding them here
lets consumers move off the header without inventing their own scans.

All four go through one ordered read of both opening shelves. Every writer of
an opening event stamps `committed_at` with the event's own timestamp, so that
column orders the event shelf by the same value the record reports as
`openedAt`, and ordering, cursors and limits stay in SQL. A bounded caller
therefore decodes only the openings it asked for.

Generated-by: Claude Code
The header was a third authority over facts the event spine already owns:
route, configuration, root, lineage and terminal status. Every reconciliation
path in the codebase exists because those three could disagree.

The opening RuntimeEvent is now the only record of how an invocation was
opened, and the terminal RuntimeEvent the only record of how it ended.
`@maka/core/runtime-invocation` owns the concept; `AgentRunStore` keeps only
the AgentRunEvent operational ledger, and its `core_agent_runs` row keeps only
what events hang off. The header's decoder moves to `@maka/storage` beside the
migration that consumes it, which is what stops it becoming a live authority
again.

Generated-by: Claude Code
The runtime read side still went through the AgentRun header for
everything a run was: its route, its configuration, its lineage, its
status and its failure class. Every one of those now lives on the
invocation's opening fact and its terminal event, so the header was a
second copy that had to be kept in step, and a repair pass existed only
to put the two back together when it was not.

Enumerating a Session's runs is now one query over the events, and the
facts a listing shows are derived from them in one place. What that
retires:

- the run-header/terminal-fact reconciliation: the read-model repair
  loop, `repairMissingTerminalFactOnce`, `firstRuntimeRepairRunId` and
  `effectiveRunHeaderFromTerminalFact`, plus the `repairRunRuntimeLedger`
  hook that carried it into every AgentRun;
- the continuation claim's second target record: a claim freezes the
  target's opening fact and nothing else, and the lineage walk reads its
  edges off the continuation-start event that authenticated them;
- the recovery classifier's status branches, which asked the header what
  the events already say;
- the conversation copy's cloned Run header, whose lineage rewriting now
  happens on the opening event like every other reference.

An admitted Turn that never reached a run is opened and closed on the
spine by recovery, so it ends up shaped like every other Turn.

Generated-by: Claude Code
…spine

The Host read its Turn state from the Run header: the canonical Turn
snapshot compared the header's status against the terminal RuntimeEvent,
recovery enumerated headers, inspection read one, and the revision path
walked header lineage. With the header gone, each of those reads the
events that already decide the answer.

Two duplicates go with it. A live Turn's `waiting_for_user` came from the
header restating what the pending-interaction store owns, so the snapshot
now asks that store directly. And the RuntimeEvent that carries an
opening fact was assembled by hand in four places; `buildInvocationOpenedEvent`
in @maka/core writes the envelope once, for the runtime, the recovery
closure, the transcript import and the storage migration alike.

Generated-by: Claude Code
The Session Manager suite read run state through `AgentRunHeader`, so it
asserted the header's copy of what the ledger already said and, in three
places, tested that a tampered header was detected.

Those tests describe a mechanism this change removes. The opening fact is
immutable and the terminal event is the only outcome, so there is nothing
left to tamper with and no second commit left to interrupt. The three
header-drift tests go with the drift; the rest read `runtimeInvocationOutcome`,
the terminal event, and the opening's route, configuration and lineage.

Generated-by: Claude Code
Conversation copy, context diagnostics, continuation planning and the
terminal-ledger suite all seeded runs by writing a header and then asserted
against that header. Each now opens an invocation and reads the invocation
back, which is where those facts live.

Two terminal-ledger tests went with the mechanism they covered. One proved the
read model prefers the terminal event when the header is stale; the other
proved recovery synthesizes a terminal event for a header whose ledger has
none. Neither state can occur once the events are the only record.

Generated-by: Claude Code
…tion

The read model, agent-graph coordinator and steering-recovery suites still
built `AgentRunHeader` values to hand to code that now takes a
`RuntimeInvocationRecord`. Each builds the invocation instead, and reads the
wake root off the opening rather than off two loose header fields.

Two steering-recovery tests covered the run-header status latch: one that a
failed best-effort write blocked a resume, one that the block lifted when the
header write later succeeded. There is no header write left to fail. The
surviving barrier is the durable settlement event, and the remaining test now
gates on that. The comment in `agent-run.ts` that still described the retired
three-step ordering is corrected to the two steps the code performs.

Generated-by: Claude Code
The compaction checkpoint, latest-context and ledger-repair readers now take
the session-inline run ids from their caller, because the header table that
used to enumerate them is gone. Their tests pass those ids.

The backend suites build the source route as an invocation opening, which is
where a run's connection, model and provider identity live.

Generated-by: Claude Code
These suites still built AgentRunHeader objects to say what a run was
routed to, what it was configured with, and how it ended. All three now
come from the invocation's opening fact and its terminal event, so the
tests state the same facts the runtime actually persists.

Two premises went away with the header rather than being translated:

- The continuation crash harness had a boundary between committing the
  terminal event and committing the terminal header. There is no second
  commit any more, so `after_terminal_header_committed` is gone and the
  two boundaries before the continuation-start commit now leave the
  target invocation unopened, because a continuation's opening fact
  rides that start event.
- The AgentRun inspect model no longer reconciles an operational status
  against the RuntimeEvent facts, so the test for their disagreement and
  the `status_consistency_mismatch` diagnostic it asserted are removed.

The invocation-index test compared the index against the header table.
It now compares the index against a rebuild from the Session's events,
which is what the index is defined to be.

Generated-by: Claude Code
The architecture chapters still taught the AgentRunHeader: a run's status,
route and continuation source lived on it, recovery reconciled it against
the ledger, and the terminal invariant was an ordering between two
commits. None of that is in the code any more.

They now say what the code does. A run opens with an immutable opening
fact, ends with exactly one terminal RuntimeEvent, and has no second
record of its outcome for a crash to leave disagreeing. Recovery reads
the invocations with no terminal event and commits one.

The desktop usage fixture seeded its model-call attempts behind a Run
header. It now seeds the opening fact the attempts hang off, which is
what the store requires and what production writes.

Generated-by: Claude Code
Generated-by: Claude Code
A start the Interaction authority refuses because it is draining used to end
as a failure or a cancellation depending on which writer won: if the Turn's
stop fence had already stopped the run, the run recorded a cancellation;
otherwise the same shutdown read as a Host fault and asked the Host to drain
again. Shutdown is not a run failure, and a race is not a classification.

State it once, where the errors that carry the reason are defined, and let
both the kernel and the Host coordinator ask the same question: a draining
authority cancels the run it refuses.

Generated-by: Claude Code
Recovery walks every invocation on a Session's spine and, for each, commits
the terminal fact and the terminal Turn state. It then reported every walk as
a recovery, so a Session whose runs had all ended cleanly still had its status
rewritten on every startup, bumping the header revision and invalidating the
revision a caller was holding across the restart.

The claim used to be at least literally true: that commit also wrote the Run
header. With the header gone there is no second record left to write, so an
already-terminal run makes the whole pass a no-op.

Report a recovery only when this pass supplied something the run was missing:
a terminal fact it did not have, or a terminal Turn state it did not carry.

Generated-by: Claude Code
The opening fact is now event 1 of every invocation, so a source Run's
RuntimeEvent high water sits one past where these fixtures expected it, and a
branch's copied invocation opens on its own spine and projects the copied Turn
as ended. Two Sessions that reuse a run id now open separate invocations, so
the inspect fixtures name the second one explicitly, and the shared evidence
budget accounts for the bytes the opening event itself occupies.

The Agent Graph provider fixture asserted the child run's status off
`agent_output`'s `header`, which is now `invocation`; assert it off the
invocation's terminal event instead.

Generated-by: Claude Code
…tcome

`TerminalAgentRunStatus` was left as a bare alias of `RuntimeInvocationOutcome`,
so the vocabulary this change retired survived as a second name for the same
three values. Use the one name.

The invocation fixture had been copied byte-for-byte into `runtime-host`, and
the `storage` fixture hand-rolled the opening event instead of building it.
Share the fixture through the `test-only` entry point this repo already uses
for cross-workspace test modules, and build the storage fixture's event with
`buildInvocationOpenedEvent` so no test can drift from how the runtime opens
an invocation.

Generated-by: Claude Code
…ings

A Run ends exactly once. When the inventory found two terminal events on one
invocation it kept whichever came last, so a ledger that contradicts itself read
back as a settled run and the contradiction never reached anyone.

Leave such an invocation without a terminal event instead, and let the readers
that can act on it — the inspect model and the read model — classify off the
events themselves, so the ambiguity surfaces as ambiguity rather than as a run
that merely has not finished.

Generated-by: Claude Code
…ional ledger

The opening fact is a RuntimeEvent now, so the store that decides whether it can
be written is the RuntimeEventStore. Gating it on the AgentRunStore left a run
with a spine and no operational ledger invisible to the inventory, and gated the
inventory on a store that no longer holds any part of it.

Open it whenever finalize runs too. A run that ends before it ever started would
otherwise leave a terminal event on an invocation nothing had opened, which is
an ending the inventory cannot see.

Generated-by: Claude Code
A terminal RuntimeEvent is immutable and must be the ledger tail, so a failure
class or abort source it did not state can never be added afterwards. The header
used to hold it, and recovery wrote 'app_restarted' there; with the header gone
the read model refused the fact instead, and one such event made the whole
Session unreadable.

Read the event as what it is: the run ended, and the detail it omitted is
`unknown`. The terminal-fact classifier keeps the diagnostic, the projection
already rendered `unknown`, and recovery no longer has an incomplete-terminal
case to repair — so `incomplete_single_terminal` and the projection's duplicate
diagnostics go with it.

Generated-by: Claude Code
Both tests reached for state the spine no longer keeps: one repaired a steering
message on an invocation nothing had opened, the other awaited a settlement
barrier that lives in acceptMappedEvent rather than in recordSessionEvent. Seed
the opening and drive the real barrier.

Generated-by: Claude Code
These tests wrote operational rows for a run nothing had opened, and read
back inventories keyed by an invocation id two runs shared. Both worked
only because the run header stood in for the opening; with the header gone
the seeds have to state what they always meant.

The checkpoint-unavailability test waited on a trace-failure row that the
retired `run_created` write used to produce. It now says directly what it
was arranging: the store goes unavailable just before the checkpoint write.

Generated-by: Claude Code
The last places still asking the header what a run was are gone.

`inspectAgentRunReadModel` looked its invocation up by the id it was given
as an invocation id, which is right only while a run and its invocation
share one identity — a continuation is a new run on the invocation it
resumes. It now finds the invocation by run, and the store's own
`readInvocation` fast path goes with the mistake.

The conversation-copy guard against "a retained AgentRun without RuntimeEvent
facts" describes an invocation with no events. An invocation is its opening
event, so that state no longer exists; the guard and its test are removed.

`after_run_created` named the header row that a continuation wrote before its
durable start. A continuation's opening rides its continuation-start event, so
nothing is durable there any more and the failpoint names no boundary. A crash
after the terminal event is likewise not an unfinished claim: the event is the
continuation's ending, so the boundary already has one.

An imported transcript that never stated how a turn ended used to be repaired
to failed once the header noticed the missing terminal. The terminal event is
now written when the turn is materialized and can never be corrected, so an
inferred status is recorded as the failure it is, which keeps an adapter's
reason to emit its own cutoff true.

Generated-by: Claude Code
Generated-by: Claude Code
`classifyAgentRunRecovery` still scanned the operational ledger for
`run_completed`, `run_failed` and `run_cancelled`. Those types no longer
exist, and its own contract already says the caller established there is no
terminal event, so the scan could only ever answer the same way twice.

Generated-by: Claude Code
Rebasing onto main brought in `readSessionRuntimeEventEntries`, the durable
session order a read now sorts by. The doubles that stand in for a store have
to answer it, and the read model no longer takes a run store at all.

The upstream tests for the read model's projection-cache backfill go: that path
only ran for a terminal run whose ledger was empty, and an invocation is its
opening event, so a run with no events is not a run this model can see.

Generated-by: Claude Code
Opening the invocation no longer awaits a header write, so onRunStarted
now fires while the admission that started the Turn is still open, and
its refreshCanonical is rejected as a nested admission. The Turn is not
admission work: detach it from the admission context so its own
admissions queue normally.

Generated-by: Claude Code
Every test that needed an invocation record built its own copy of the
opening fact: eight near-identical constructors plus a dozen inline
literals, all restating defaults nothing asserts on. Fold them onto the
one fixture, which now merges configuration field by field so a test
states only the setting it is about, and writes failureClass where the
read model looks for it.

Drop the cases that only restate a schema — protocol literal, enum
member, empty lineage, each empty legacy field — and keep the ones that
carry a rule: which routes may name a connection, which roots exist,
that a continuation names its boundary, that a malformed opening fails
the whole decode.

Generated-by: Claude Code
The stop test from #4439 read the run's status and abort source off the
header. Both are the terminal event's to state, and the test already
asserts on it.

Generated-by: Claude Code
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Over 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(runtime): converge AgentRun metadata into the RuntimeInvocation event spine

1 participant