Skip to content

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

Open
Astro-Han wants to merge 40 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 40 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

+8878 / −8501 is a net +377, 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 −659
Everything else, tests −65
Total +377

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 −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 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 797 pass, 0 fail
@maka/storage 1105 pass, 8 skipped, 0 fail
@maka/runtime 3192 pass, 13 skipped, 0 fail
@maka/runtime-host 1666 pass, 12 skipped, 0 fail
@maka/desktop 2061 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 2 times, most recently from 99a8677 to ee51697 Compare September 3, 2026 09:58
@Astro-Han
Astro-Han marked this pull request as ready for review September 3, 2026 10:43
@Astro-Han
Astro-Han force-pushed the refactor/4311-invocation-event-spine branch from ee51697 to db26383 Compare September 3, 2026 17:31

@jackwener jackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 mainruntime-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:

  1. The old AgentRunHeader carries runComposition, and the ordinary provider path on today's main writes it — dispatch goes through execution-model-composition.ts:328-345,453-456, and the old runtime/src/agent-run.ts:438-455 persists it into the header.
  2. The new legacy shape does not accept that field: packages/storage/src/legacy-run-header.ts:78-181.
  3. backfillInvocationOpeningFacts() swallows the decode error and continues past the row: sqlite-runtime-schema.ts:623-630. The core migration then drops record_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。 不批准。

这里有两件独立的事,而合并框里只看得见其中一件。 这个分支相对 mainCONFLICTING(runtime-event.tsruntime-kernel.tsai-sdk-backend.tsai-sdk-compaction.ts 和一个测试)。下面这条 P1 是另一回事:解掉冲突不会把它修好。 两件都要单独处理。

P1:记录过 runComposition 的旧 run 会从清单里消失,而它的 header 紧接着就被删除。

三步各自都说得通,连起来丢数据:

  1. 旧的 AgentRunHeaderrunComposition,而今天 main 上的正常 provider 路径会写它——dispatch 经过 execution-model-composition.ts:328-345,453-456,由旧的 runtime/src/agent-run.ts:438-455 持久化进 header。
  2. 新的 legacy 形状不接受这个字段:packages/storage/src/legacy-run-header.ts:78-181
  3. 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 已无任何引用,computeStatusConsistencyeffectiveRunHeaderFromTerminalFactterminalRunHeaderMatchesFactstatus_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 jackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.tssqlite-runtime-schema.tssqlite-core-execution-schema.tsrecord-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 把 #4393ai-sdk-backend.ts 弄丢了。

main 上有 bf5ca672d(#4393):ai-sdk-backend.ts:899MAX_SEALED_THINKING_RETRIES_PER_STEP = 1、retry 循环里与 idle-watchdog / truncated-stream 互斥的 sealedThinkingRecovery 分支,以及约 629 行测试(含 ECONNRESET 那组)。

这些在本 head 上全都不在。retry 循环只剩 idleWatchdogRecoveryincompleteStreamRecovery。四个冲突解决过的文件里,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 已无引用,computeStatusConsistencyeffectiveRunHeaderFromTerminalFactterminalRunHeaderMatchesFactstatus_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.tsinvocation.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.

@jackwener

Copy link
Copy Markdown
Member

Correcting one of my own findings, at db2638338e6991eea3f23b29a2eba5ec65989a1b.

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 bf5ca672d, ai-sdk-backend.ts is shorter than main's, and MAX_SEALED_THINKING_RETRIES_PER_STEP and its tests are absent here.

What does not follow is that merging would revert it. A three-way merge of current main with this head produces no content conflict in that file, and the resulting tree carries main's implementation and its tests — I checked the merged tree directly, and the constant is present there exactly as it is on main. The branch never deleted those lines; it is simply based on an older point, and a three-way merge keeps what only one side added. So nothing is lost by merging as it stands, and #4393 does not need to be re-merged into this branch. I should have tested the merge result rather than reasoning from the head's contents; my apologies for the noise.

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 runtime-event-read-model.ts, main's change is expressed against the old header and this branch's against the new invocation spine. The two texts merge without conflict because they touch different lines, but automatic merging cannot judge whether the combination is right. That one needs a compile and a behaviour check on the post-rebase head — from whoever does the next rebase, not from the merge tool.

The other findings stand unchanged: the runComposition migration P1, whose mechanism is the hasExactShape() guard ahead of the per-field checks, and the P2 where the two-terminal rule lives only in the in-memory rebuild while completeInvocationRecordSync still reads the last event. Neither is affected by this correction.

简体中文

更正我自己的一条发现,针对 db2638338e6991eea3f23b29a2eba5ec65989a1b

P1(b)——「rebase 丢掉了 #4393」——撤回。观察是对的,推论是错的。

成立的部分:这个 head 确实不含 bf5ca672d,ai-sdk-backend.tsmain 的短,MAX_SEALED_THINKING_RETRIES_PER_STEP 及其测试在这里都不存在。

不成立的是「合入会撤销它」。 用当前 main 与这个 head 做三方合并,该文件没有内容冲突,而且合成树里带着 main 的实现和测试——我直接检查了合并后的树,那个常量在其中,和 main 上一模一样。这个分支从未删除那些行,它只是基于一个较旧的起点,而三方合并会保留只有一侧新增的内容。所以照现状合入不会丢东西,#4393 也不需要重新合并进这个分支。 我应该去检验合并结果,而不是从 head 的内容去推断;为这次噪音致歉。

真正的风险转移到了下一次 rebase:如果之后的冲突解决让 PR 侧的整份文件获胜,这些行才会在那时丢失。那时值得重新核对,而不是现在就动手。

有一点确实值得注意,而且是这次更正带出来的:合并树干净,不等于合并后的语义正确。在 runtime-event-read-model.ts 里,main 的改动是针对旧 header 口径写的,而本分支是针对新的 invocation spine 写的。两段文本因为触及不同的行而无冲突地合在一起,但自动合并无法判断这个组合是否正确。这一处需要在 rebase 之后的 head 上做一次编译和行为复核——由做下一次 rebase 的人来做,而不是交给合并工具。

其余发现不受影响,维持原判:runComposition 迁移那条 P1(其机制是排在逐字段校验之前的 hasExactShape() guard),以及那条 P2(两条 terminal 的规则只存在于内存重建中,而 completeInvocationRecordSync 仍读取最后一条事件)。


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.

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
@Astro-Han
Astro-Han force-pushed the refactor/4311-invocation-event-spine branch from db26383 to aa0d369 Compare September 3, 2026 18:38
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

2 participants