refactor(runtime): converge AgentRun metadata into the RuntimeInvocation event spine - #4631
refactor(runtime): converge AgentRun metadata into the RuntimeInvocation event spine#4631Astro-Han wants to merge 40 commits into
Conversation
1f6d52b to
fc298bd
Compare
99a8677 to
ee51697
Compare
ee51697 to
db26383
Compare
jackwener
left a comment
There was a problem hiding this comment.
I reviewed exact head ee51697a1ae3290d7f7291b855df80fa62383009. One P1. Not approving.
Two things are independent here, and only one of them is visible from the merge box. This branch is CONFLICTING against main — runtime-event.ts, runtime-kernel.ts, ai-sdk-backend.ts, ai-sdk-compaction.ts and one test. The P1 below is separate: resolving those conflicts will not fix it. Both need a pass.
P1 — a legacy run that recorded runComposition is dropped from the inventory, and its header is deleted immediately after
Three steps that are each defensible on their own, and lose data in sequence:
- The old
AgentRunHeadercarriesrunComposition, and the ordinary provider path on today'smainwrites it — dispatch goes throughexecution-model-composition.ts:328-345,453-456, and the oldruntime/src/agent-run.ts:438-455persists it into the header. - The new legacy shape does not accept that field:
packages/storage/src/legacy-run-header.ts:78-181. backfillInvocationOpeningFacts()swallows the decode error and continues past the row:sqlite-runtime-schema.ts:623-630. The core migration then dropsrecord_json:sqlite-core-execution-schema.ts:150-152.
So the first time a user opens a workspace that ran any provider turn on current main, that run has no opening event, no legacy shelf row, and no header any more. Its RuntimeEvents survive, but listSessionInvocations() cannot enumerate it — and prior context, host recovery, inspect, conversation copy and graph traversal all start from that inventory.
This was reproduced twice, independently. A schema-15 probe on this head with two otherwise identical legal headers differing only in runComposition: the control migrates, the one carrying composition is skipped, and record_json is confirmed gone afterwards. A second reviewer, working from the exact merge base rather than from a hand-written fixture, reproduced the same split — four paired cases, two passing and two failing.
That second run is worth a note for whoever fixes this. The first attempt at this area reported the migration as clean across twelve compatibility cases. It was wrong, and the reason is the fixture: it was written to the field set the new decoder accepts, so it only ever proved that new code can read what new code writes. Old-data fixtures have to come from the exact base's real write shape, or they are green by construction.
Fix: accept and validate the field in the legacy decoder so the opening projection completes, or backfill an equivalent fact before the header is dropped — and add a regression case built from a complete base-era header. If historical composition is genuinely disposable, that is a fine answer too, but it should be stated rather than left to a swallowed decode error.
Scope of this review
The reconciliation layer really is gone, not relocated: AgentRunHeader has no references left in the repository, and computeStatusConsistency, effectiveRunHeaderFromTerminalFact, terminalRunHeaderMatchesFact, status_consistency_mismatch and the after_terminal_header_committed failpoint are all absent. The premise of the change holds.
Not covered here: the 148-file diff was not read end to end, and the semantics of the conflicting files against current main were not merged and read. Hosted checks were terminal green on this SHA.
This conclusion binds to ee51697a only. After a rebase it needs re-checking — including whether the conflict resolution changes anything above.
简体中文
我审的是 ee51697a1ae3290d7f7291b855df80fa62383009。一条 P1。 不批准。
这里有两件独立的事,而合并框里只看得见其中一件。 这个分支相对 main 是 CONFLICTING(runtime-event.ts、runtime-kernel.ts、ai-sdk-backend.ts、ai-sdk-compaction.ts 和一个测试)。下面这条 P1 是另一回事:解掉冲突不会把它修好。 两件都要单独处理。
P1:记录过 runComposition 的旧 run 会从清单里消失,而它的 header 紧接着就被删除。
三步各自都说得通,连起来丢数据:
- 旧的
AgentRunHeader带runComposition,而今天main上的正常 provider 路径会写它——dispatch 经过execution-model-composition.ts:328-345,453-456,由旧的runtime/src/agent-run.ts:438-455持久化进 header。 - 新的 legacy 形状不接受这个字段:
packages/storage/src/legacy-run-header.ts:78-181。 backfillInvocationOpeningFacts()吞掉解码错误并跳过该行:sqlite-runtime-schema.ts:623-630。随后 core migration 删除record_json:sqlite-core-execution-schema.ts:150-152。
于是,当用户第一次用这个分支打开一个「在当前 main 上跑过任意 provider turn」的 workspace,那条 run 既没有 opening event,也没有 legacy 兜底行,header 也已不复存在。它的 RuntimeEvents 还在,但 listSessionInvocations() 枚举不到它——而 prior context、host recovery、inspect、会话复制和 graph 遍历,全都从这份清单起步。
这一条被独立复现了两次。 在这个 head 上做的 schema-15 探针:两个除 runComposition 外完全相同的合法 header,对照组迁移成功,带 composition 的被跳过,随后确认 record_json 已被删除。另一位审查者不用手写 fixture、而是从 exact merge base 出发,复现出同样的分裂——四组对照,两组通过两组失败。
第二次复现里有一点值得修这个问题的人留意。这一片最初的审查结论是「迁移干净,十二项兼容用例全过」。那个结论是错的,原因出在 fixture:它是照着新 decoder 接受的字段集写的,所以它只证明了「新代码能读新代码写的东西」。旧数据的 fixture 必须来自 exact base 的真实写入形态,否则它天然就是绿的。
修法:在 legacy decoder 里接受并校验该字段,让 opening 投影得以完成;或者在删掉 header 之前回填一条等价的事实——并补一条用 base 时代完整 header 构造的回归用例。如果历史 composition 确实可以丢弃,那也是个正当答案,但应该明说,而不是留给一个被吞掉的解码错误。
本次审查的范围:那层对账逻辑确实是被删掉了,不是挪了地方——仓库里 AgentRunHeader 已无任何引用,computeStatusConsistency、effectiveRunHeaderFromTerminalFact、terminalRunHeaderMatchesFact、status_consistency_mismatch 以及 after_terminal_header_committed 失败点全部不存在。这个改动的前提是成立的。
未覆盖:148 个文件没有通读;冲突文件相对当前 main 的语义没有合并后再读。托管检查在这个 SHA 上是终态通过。
本结论只绑定 ee51697a。 rebase 之后需要重新核对,包括冲突的解法本身是否影响上述任何一条。
Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.
jackwener
left a comment
There was a problem hiding this comment.
Re-reviewed at exact head db2638338e6991eea3f23b29a2eba5ec65989a1b. The rebase resolved the conflicts and the merge tree against main is clean. Two P1s and one P2. My previous comment was bound to ee51697a and is superseded by this one.
P1 (a) — runComposition still drops a legacy run from the inventory, and the mechanism is now pinned
Unchanged from the last head: the four files on this path — legacy-run-header.ts, sqlite-runtime-schema.ts, sqlite-core-execution-schema.ts, record-schema.ts — are byte-identical across ee51697a..db263833. The rebase did not touch this chain.
The earlier write-up said the field is missing from the legacy shape without saying why that is fatal, and reading the per-field type checks alone does not explain it — those checks never reject an unknown key. The actual step is a guard that runs before them. decodeLegacyRunHeader calls hasExactShape() first (legacy-run-header.ts:230-233), and hasExactShape() is Object.keys(value).every(key => shape.allowed.has(key)) (core/record-schema.ts:64-68). One extra key makes the exact shape false immediately, so the per-field validation never runs. Instrumenting the decoder on this head: the control header passes, the one carrying runComposition fails with Invalid AgentRun header schema, and the top of the stack is the exact-shape branch.
From there it is the same sequence: sqlite-runtime-schema.ts:623-630 swallows the error and continues, sqlite-core-execution-schema.ts:150-152 then drops record_json. A schema-15 probe on a real database confirms only the control's opening survives.
The field is not hypothetical. The new merge base and current main both still carry AgentRunHeader.runComposition in the exact allowed shape, and the ordinary provider path still produces it — execution-model-composition.ts:328-345 builds the snapshot and :453-456 commits it as beforeRunProviderDispatch.
Fix: accepting the field in the decoder is necessary but not sufficient. Without also preserving it — as the immutable run_composition_recorded fact the new live writer already emits — before the header is dropped, the invocation stops disappearing but the composition is still silently lost.
Worth noting for the regression test: the focused backfill suite is 3/3 green on this head with the bug present, because its fixtures are written to the field set the new decoder accepts. A fixture built that way can only ever prove that new code reads what new code writes. The case that catches this has to be built from a complete base-era header.
P1 (b) — the rebase dropped #4393 from ai-sdk-backend.ts
main carries bf5ca672d (#4393): MAX_SEALED_THINKING_RETRIES_PER_STEP = 1 at ai-sdk-backend.ts:899, a sealedThinkingRecovery arm in the retry loop, mutually exclusive with the idle-watchdog and truncated-stream arms, plus roughly 629 lines of tests including the ECONNRESET group.
None of that is on this head. The retry loop has only idleWatchdogRecovery and incompleteStreamRecovery. Of the four conflict-resolved files, the backend is the only one shorter than main (5068 against 5125), and the sole const in the main-only difference is that constant.
So a thinking-only stream cut by a retryable network error, before the idle watchdog fires, ends the Turn on this branch, where main flushes the partial thinking into its own message and retries once. Merging as-is reverts a fix already on main and reopens #4284.
Fix: merge #4393's backend change and its tests back in, rather than letting the PR-side file win whole.
P2 — the two-terminal rule lives only in the in-memory rebuild
Unchanged and byte-identical to the previous head. runtime-invocation.ts:74-87 clears terminalEvent when more than one terminal event is counted; the production enumeration in completeInvocationRecordSync (sqlite-runtime-store.ts:739-755) still takes ORDER BY event_seq DESC LIMIT 1 and does not count. The same ledger therefore reads as finished through the index and as open through the rebuild, and the reverse splits too when a suffix follows a single terminal.
This is the shape the PR sets out to remove — one fact, two records that can disagree — reappearing between two derivations rather than between two tables. The rest of the retirement holds: AgentRunHeader has no references left, and computeStatusConsistency, effectiveRunHeaderFromTerminalFact, terminalRunHeaderMatchesFact, status_consistency_mismatch and the after_terminal_header_committed failpoint are all gone.
Checks and scope
The required test job is red on apps/desktop/e2e/code-scroll.spec.ts, which this PR does not touch. That looks like the same class of pre-existing instability as the Runtime Host cases being fixed elsewhere, but attribution is not established here — either way the gate is not green.
The other three conflict-resolved files were checked for what main brought in rather than read end to end: runtime-event.ts keeps the form-interaction decode alongside the new opening fact; runtime-kernel.ts keeps drain-refused → cancelled and has no header references; ai-sdk-compaction.ts reads the route from invocation.opening.route and is not missing functions relative to main. Cross-process ensureTerminal contention remains untested by anyone.
简体中文
在 db2638338e6991eea3f23b29a2eba5ec65989a1b 上重审。rebase 解掉了冲突,相对 main 的合并树是干净的。两条 P1、一条 P2。 我上一条评论绑定的是 ee51697a,由本条取代。
P1(a):runComposition 仍会让旧 run 从清单中消失,而且失败机制现在定位到了。
和上个 head 相比没有变化:这条链上的四个文件(legacy-run-header.ts、sqlite-runtime-schema.ts、sqlite-core-execution-schema.ts、record-schema.ts)在 ee51697a..db263833 之间逐字节相同,rebase 没有碰它们。
先前的说法只讲了「新的 legacy 形状缺这个字段」,没讲清为什么这会致命——而且单看逐字段类型检查是解释不通的,那些检查从不拒绝未知字段。真正的那一步是排在它们之前的一道 guard。 decodeLegacyRunHeader 会先调用 hasExactShape()(legacy-run-header.ts:230-233),而 hasExactShape() 就是 Object.keys(value).every(key => shape.allowed.has(key))(core/record-schema.ts:64-68)。多出一个键,exact shape 立刻为假,逐字段校验根本没有机会运行。 在这个 head 上给 decoder 打点:对照 header 通过,带 runComposition 的那个以 Invalid AgentRun header schema 失败,栈顶正是 exact-shape 分支。
之后是同一串连锁:sqlite-runtime-schema.ts:623-630 吞掉这个错误并跳过,sqlite-core-execution-schema.ts:150-152 随后删除 record_json。在真实数据库上做的 schema-15 探针确认:最后只剩对照组的 opening。
这个字段不是假想的。新的 merge base 和当前 main 都仍在 exact allowed shape 里带着 AgentRunHeader.runComposition,普通 provider 路径也仍在产生它——execution-model-composition.ts:328-345 构造快照,:453-456 作为 beforeRunProviderDispatch 提交。
修法:在 decoder 里接受该字段是必要的,但不充分。如果不在 header 被删除之前把它保存下来——存成新的 live writer 已经在写的那条不可变事实 run_composition_recorded——那么 invocation 不再整条消失,但 composition 本身仍会被静默丢弃。
回归测试有一点值得记:在缺陷存在的情况下,聚焦的回填套件在这个 head 上仍然 3/3 全绿,因为它的 fixture 是照着新 decoder 接受的字段集写的。这样构造的 fixture 只能证明「新代码读得懂新代码写的东西」。真正能抓住这个问题的用例,必须用 base 时代的完整 header 来构造。
P1(b):rebase 把 #4393 从 ai-sdk-backend.ts 弄丢了。
main 上有 bf5ca672d(#4393):ai-sdk-backend.ts:899 的 MAX_SEALED_THINKING_RETRIES_PER_STEP = 1、retry 循环里与 idle-watchdog / truncated-stream 互斥的 sealedThinkingRecovery 分支,以及约 629 行测试(含 ECONNRESET 那组)。
这些在本 head 上全都不在。retry 循环只剩 idleWatchdogRecovery 和 incompleteStreamRecovery。四个冲突解决过的文件里,backend 是唯一比 main 更短的(5068 对 5125),而 main-only 的 const 差集里就只有那一个常量。
于是:一条 thinking-only 的流被可重试的网络错误切断、且 idle watchdog 尚未触发时,本分支会直接结束这个 Turn;而 main 会把部分 thinking 冲刷成独立消息并重试一次。照这样合入,等于撤销一个已经在 main 上的修复,并让 #4284 重新出现。
修法:把 #4393 的 backend 改动和它的测试重新合并回来,而不是让 PR 侧的整份文件覆盖掉。
P2:两条 terminal 的规则只存在于内存重建函数里。
未变,与上个 head 逐字节相同。runtime-invocation.ts:74-87 在数到多于一条 terminal 事件时清空 terminalEvent;而生产枚举所用的 completeInvocationRecordSync(sqlite-runtime-store.ts:739-755)仍然是 ORDER BY event_seq DESC LIMIT 1,不做计数。于是同一份账本,经索引读出来是「已结束」,经重建读出来是「仍开放」;当单条 terminal 后面跟着后续事件时,分裂方向反过来同样成立。
这正是本 PR 立意要消除的形状——一份事实、两份可能互相矛盾的记录——只不过它从「两张表之间」搬到了「两条派生路径之间」。 退役的其余部分是站得住的:仓库里 AgentRunHeader 已无引用,computeStatusConsistency、effectiveRunHeaderFromTerminalFact、terminalRunHeaderMatchesFact、status_consistency_mismatch 以及 after_terminal_header_committed 失败点全部不存在。
检查与范围:必需的 test 任务红在 apps/desktop/e2e/code-scroll.spec.ts,而本 PR 并未触及该文件。它看起来和别处正在修复的 Runtime Host 用例属于同一类既有不稳定,但归因在此并未确立——无论归因如何,门禁都不是绿的。
另外三个冲突解决过的文件,我核的是「main 带进来的东西还在不在」,不是通读:runtime-event.ts 在新的 opening fact 之外保留了 form-interaction 解码;runtime-kernel.ts 保留了 drain-refused → cancelled 且无 header 引用;ai-sdk-compaction.ts 从 invocation.opening.route 取 route,相对 main 没有丢失函数。跨进程 ensureTerminal 争用仍然无人验证。
Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.
|
Correcting one of my own findings, at P1 (b) — "the rebase dropped #4393" — is withdrawn. The observation was right and the inference was wrong. What is true: this head does not contain What does not follow is that merging would revert it. A three-way merge of current The real exposure moves to the next rebase: if a later resolution lets the PR-side file win whole, these lines would go then. Worth re-checking at that point rather than acting on now. One thing that does deserve attention, and that this correction surfaced. A clean merge tree is not the same as correct merged semantics. In The other findings stand unchanged: the 简体中文更正我自己的一条发现,针对 P1(b)——「rebase 丢掉了 #4393」——撤回。观察是对的,推论是错的。 成立的部分:这个 head 确实不含 不成立的是「合入会撤销它」。 用当前 真正的风险转移到了下一次 rebase:如果之后的冲突解决让 PR 侧的整份文件获胜,这些行才会在那时丢失。那时值得重新核对,而不是现在就动手。 有一点确实值得注意,而且是这次更正带出来的:合并树干净,不等于合并后的语义正确。在 其余发现不受影响,维持原判:
|
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
…uded The legacy decoder rejected any header carrying `runComposition`, which every run that reached a provider on main carries, because `hasExactShape` refuses unknown keys before a single field is checked. The backfill then swallowed the decode error and skipped the row, and the core-execution migration dropped `record_json` right after: the run had no opening, no shelf row and no header any more, so `listSessionInvocations` could not enumerate it while its events stayed behind as orphans. Three changes at the migration. The legacy shape accepts `runComposition` as an opaque record: nothing on the spine reads it back, so the migration only has to know a header carrying it is well formed. A header the migration cannot read now stops the migration — the transaction rolls back and the error names the row — because the alternative was a run silently ceasing to exist. And a header-only run whose header recorded an ending gets that terminal event as event 2, carrying the header's failure class, message and abort source; a completed legacy run migrated as an open one before, since the opening was the only fact projected. The synthetic terminal builder moves from runtime to core beside the opening builder so the migration and recovery state the same envelope, and `runtimeEventKind` moves to the schema module for the same reason. A partial unique index makes an opening unique per invocation by schema rather than by convention, and the anchor reader no longer guesses `sessionInline: false` for a run with no opening at all. The regression case is built from a base-era header with a real composition snapshot, not from the decoder's own accepted set: a fixture written to the new shape can only ever prove that new code reads what new code writes. Generated-by: Claude Code
The migration shelves the opening of any run that already owned events, since that run's sequence is immutable. A conversation copy cloned only the events, so the target Session had runs with no opening at all: `listSessionInvocations` returned nothing, and the branched Session's first send saw no history. The base cloned the header row explicitly, so this was a regression of the spine. The copy is a fresh sequence, so the run's opening can be event 1 there. `loadConversationCopyRunEvents` synthesizes it from the shelved record when the source events lack one, and the ledger copy inserts it ahead of the run's first source event. The regression test stages the shelved state the way the migration leaves it. Generated-by: Claude Code
The rebuild counted terminal events per invocation and, on two, declared the run ambiguous; two readers then branched on that ambiguity. But the store seals a run on its terminal event — `assertRunNotSealed` runs on every insert, in-process and across processes — so a second terminal cannot be written, and the only way to reach the branch was a test double arranging a state the index refuses. The rule also contradicted the index by construction. The rebuild now states the same rule as the index: an invocation's terminal event is its one terminal event, wherever a Session-ordered read places it relative to other invocations. The two reader branches and the test that arranged the unrepresentable state go with it. `acceptedInputBoundary` gains one sentence: an opening that could not prove its route never matches, even when the current run has none either. That is the one place the spine is stricter than the header comparison was, and it should be said where it is decided. Generated-by: Claude Code
`readRunIfPresent`, `readInvocationIfPresent` and `SessionManager.readInvocation` had become `listSessionInvocations()` followed by `find`, and a Turn calls them four to six times. The header era answered these with a keyed row read. `RuntimeEventStore` gains an optional `readRunInvocation(sessionId, runId)`; the SQLite store answers it off the opening index, and a helper in core answers it from the inventory for stores that do not implement it, so callers state one intent either way. Generated-by: Claude Code
The behaviour change was listed nowhere and had no test: work detached from an admission must take admissions of its own, queued behind the active one like any other caller. Generated-by: Claude Code
db26383 to
aa0d369
Compare
Summary
A Run's facts lived in two places at once. The RuntimeEvent ledger recorded what happened; a mutable
AgentRunHeaderrow 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, thestatus_consistency_mismatchdiagnostic, and anafter_terminal_header_committedfailpoint 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:
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.RuntimeInvocationRecordis a query over the spine, not a table.runtimeInvocationsFromSessionEventsis 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.AgentRunStorekeeps 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.
同一张图的中文版:
Where the diff goes
+8878 / −8501is a net +377, which is not what a change that removes a record should look like. The account, against the merge base:legacy-run-header.ts(+445), its decode tests, the backfill test, and the backfill insqlite-runtime-schema.tsWithout 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 −659, 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 forruntime-event.ts(+327, the closed content schema) andruntime-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:
AgentRunHeaderhas 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:
fix(runtime)commits, plus open an invocation on the spine, not on the operational ledger and finish reading every run off its own eventsBehaviour changes (6) — what the header used to hide
A start refused because the Interaction authority is draining now settles as
cancelled, notfailed. 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
unknowninstead 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 writeapp_restartedinto 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'sincomplete_single_terminalcase 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_creatednamed 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 reportscontinuation_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
configurationnow merges field by field, so a test states only the setting it is about.Verification
@maka/core@maka/storage@maka/runtime@maka/runtime-host@maka/desktopnpm run -ws typecheck,npm run format,npm run lintNot run: the full-repo suite. Of the Playwright E2E suite, only
workhub-reconstruction.spec.tswas run locally (3 passed). No user-visible surface changed, so no before/after screenshots.AI use
Select exactly one:
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-bytrailer. All reasoning about what to delete and what to keep was reviewed by me against the issue.Checklist
Does this PR entail a change in behavior?