A workspace whose tool ledger carries one pre-existing issue — anywhere, in any session, possibly written months ago by an older build — cannot end a run cleanly. The first tool-bearing append meets the health scan, throws, latches the store, and commitTerminalRun then returns early. The run stays at running with no terminal RuntimeEvent. That is #2234's exact shape, still standing for the already-damaged population.
The sharp part is that the ledger would have accepted the terminal fact. The latch, not the damage, is what loses it.
The mechanism
assertWorkspaceToolLedgerHealthy (packages/storage/src/sqlite-runtime-store.ts:2198) throws ToolLedgerCorruptionError when refreshToolLedgerHealth finds any issue. That scan (:2211-2228) is SELECT … FROM runtime_events ORDER BY … — no WHERE clause. One damaged operation anywhere in the workspace file poisons every append in it, and one workspace file holds every session (packages/storage/src/runtime-event-persistence.ts:48).
It is only ever consulted for tool-bearing events: importRuntimeEventSync gates it behind if (isToolLedgerBearingEvent(canonicalEvent)) (:2391), and that predicate (:2871) is true only for function_call / function_response content or actions.toolDispatch / actions.toolRecovery.
A run's terminal RuntimeEvent is none of those — buildSyntheticTerminalRuntimeEvent produces role: 'system', a terminal status, and actions: { endInvocation: true }. So it never reaches the health gate. On a corrupt ledger, the terminal fact would land.
But enqueueRuntimeEventStore (packages/runtime/src/agent-run.ts:1669) latches on it:
if(!(errorinstanceofToolLedgerRejectionError)){this.runtimeEventStoreAvailable=false;this.runtimeEventStoreFailure=error;}ToolLedgerCorruptionError is a sibling class, not a subclass, so it latches.
commitTerminalRun (agent-run.ts:1483-1490) returns before touching any store when !this.runtimeEventStoreAvailable. No terminal event, and the run-header projection is skipped too, so the header stays running.
Why now
This is not a regression — before the ToolLedgerRejectionError split, every append failure latched, so a damaged workspace behaved exactly this way. What changed is that the split made the question askable: #2240 exempts one class from the latch on the argument that the store is healthy and the run should still be able to end. That argument applies here too, and the code currently says the opposite.
#2240 carried a comment claiming a corrupt ledger means "nothing this run emits next can be trusted to land". That is false — point 3 above — and the comment has been corrected. This issue is the behaviour the corrected comment defers.
The decision
Should a run that cannot write its tool facts still be allowed to write that it ended?
Arguments for yes:
Arguments for no:
- A damaged ledger arguably should stop the world, and a clean-looking terminal fact on a run whose tool history is broken could be read as "this run completed normally".
- If corruption stops latching, the two error classes behave identically at the latch and the split becomes purely documentary — which is a fair question to ask of the split itself.
My read is that the middle position is the right one and it is not about the latch at all: keep corruption latching for ordinary appends, and let the terminal write through, because a run must always be able to say it ended. That is a change to commitTerminalRun's guard rather than to the error classification. But this is a judgement call about what fail-closed should mean here, which is why it is an issue and not a patch.
Worth deciding alongside #2311, which is the same shape from the other direction: a refusal that is correct in itself being escalated into "the store is sick".
State
简体中文要点
一个 workspace 的工具账本只要有一处既有损坏——任何 session、可能是几个月前旧版本写下的——这个 workspace 里的 run 就再也无法干净收尾。第一条带工具事实的追加撞上健康扫描抛错,latch 掉 store,commitTerminalRun 随即提前返回:run 停在 running,没有终态事件。这正是 #2234 的形状,在"账本已损坏"这部分人群身上原样保留。
扎心的地方在于:这个账本本来会接受终态事实。丢掉它的是 latch,不是损坏。
机制:健康扫描(sqlite-runtime-store.ts:2211-2228)没有 WHERE 子句,一处损坏污染整个 workspace 文件,而一个文件装着所有 session;扫描只在 isToolLedgerBearingEvent 为真时被查询(:2391),而终态事件(role: 'system'、终态 status、actions.endInvocation)不属于其中任何一类——所以它根本到不了这道门;但 enqueueRuntimeEventStore(agent-run.ts:1669)会 latch,commitTerminalRun(:1483-1490)在 latch 下直接返回,连 run header 投影也一并跳过。
这不是回归:在拆出 ToolLedgerRejectionError 之前,所有追加失败都 latch,损坏 workspace 一直是这个行为。变化的是这个问题现在可以被问出来了——#2240 豁免一个错误类型的理由是"store 是健康的,这一轮应当仍能收尾",这个理由在这里同样成立,而代码目前说的是反话。#2240 里那句"损坏账本意味着这一轮之后写什么都落不了地"是假的(见上),注释已改正,本 issue 就是那句注释所推迟的行为决定。
要决定的是:一个写不了工具事实的 run,该不该仍然被允许写下它结束了?
支持"该":终态写入按构造就是安全的(不含工具事实,无法加深损坏);停在 running 是隐形失败,正是 #2234 的危害;latch 对真正不安全的写入毫无增益——带工具事实的追加无论 latch 与否都会被健康门拒掉,latch 唯一独占的效果作用在那些本来安全的写入上。
支持"不该":损坏账本或许就该停机;而且如果 corruption 也不 latch,两个错误类型在 latch 处行为就完全一致,拆分只剩文档意义——这对拆分本身也是个公道的质问。
我的看法是中间路线才对,而且它根本不在 latch 上:普通追加继续 fail-closed,唯独放行终态写入,因为一个 run 必须永远有能力说出自己结束了。这是改 commitTerminalRun 的守卫,不是改错误分类。但这属于"fail-closed 在这里该是什么意思"的判断,所以是 issue 不是 patch。建议和 #2311 一起定——那是同一形状的另一个方向:一次本身正确的拒绝被升级成了"store 病了"。
状态:机制是按当前代码读出的,行号如上;#2240 新增的测试显式钉住了今天的行为连同代价(断言终态事件确实没落库),无论怎么决定这个测试都必须改,这正是那样写它的用意;未在实际环境中观察到——触发条件是 workspace 已有工具账本损坏,我没遇到过;一旦触发,后果就是 #2234 那种挂起。
A workspace whose tool ledger carries one pre-existing issue — anywhere, in any session, possibly written months ago by an older build — cannot end a run cleanly. The first tool-bearing append meets the health scan, throws, latches the store, and
commitTerminalRunthen returns early. The run stays atrunningwith no terminal RuntimeEvent. That is #2234's exact shape, still standing for the already-damaged population.The sharp part is that the ledger would have accepted the terminal fact. The latch, not the damage, is what loses it.
The mechanism
assertWorkspaceToolLedgerHealthy(packages/storage/src/sqlite-runtime-store.ts:2198) throwsToolLedgerCorruptionErrorwhenrefreshToolLedgerHealthfinds any issue. That scan (:2211-2228) isSELECT … FROM runtime_events ORDER BY …— noWHEREclause. One damaged operation anywhere in the workspace file poisons every append in it, and one workspace file holds every session (packages/storage/src/runtime-event-persistence.ts:48).It is only ever consulted for tool-bearing events:
importRuntimeEventSyncgates it behindif (isToolLedgerBearingEvent(canonicalEvent))(:2391), and that predicate (:2871) is true only forfunction_call/function_responsecontent oractions.toolDispatch/actions.toolRecovery.A run's terminal RuntimeEvent is none of those —
buildSyntheticTerminalRuntimeEventproducesrole: 'system', a terminalstatus, andactions: { endInvocation: true }. So it never reaches the health gate. On a corrupt ledger, the terminal fact would land.But
enqueueRuntimeEventStore(packages/runtime/src/agent-run.ts:1669) latches on it:ToolLedgerCorruptionErroris a sibling class, not a subclass, so it latches.commitTerminalRun(agent-run.ts:1483-1490) returns before touching any store when!this.runtimeEventStoreAvailable. No terminal event, and the run-header projection is skipped too, so the header staysrunning.Why now
This is not a regression — before the
ToolLedgerRejectionErrorsplit, every append failure latched, so a damaged workspace behaved exactly this way. What changed is that the split made the question askable: #2240 exempts one class from the latch on the argument that the store is healthy and the run should still be able to end. That argument applies here too, and the code currently says the opposite.#2240 carried a comment claiming a corrupt ledger means "nothing this run emits next can be trusted to land". That is false — point 3 above — and the comment has been corrected. This issue is the behaviour the corrected comment defers.
The decision
Should a run that cannot write its tool facts still be allowed to write that it ended?
Arguments for yes:
runningis invisible failure — the specific harm bug(runtime): every pre-dispatch tool refusal kills the turn — the synthetic result lands as an orphan_response the ledger rejects #2234 was filed for. Later turns drop it from model context and nothing surfaces the cause.Arguments for no:
My read is that the middle position is the right one and it is not about the latch at all: keep corruption latching for ordinary appends, and let the terminal write through, because a run must always be able to say it ended. That is a change to
commitTerminalRun's guard rather than to the error classification. But this is a judgement call about what fail-closed should mean here, which is why it is an issue and not a patch.Worth deciding alongside #2311, which is the same shape from the other direction: a refusal that is correct in itself being escalated into "the store is sick".
State
session-manager-terminal-ledger.test.ts, 'an already-corrupt ledger latches the store, and the latch is what costs the terminal fact') that pins today's behaviour explicitly, including the collateral: it asserts the terminal event does not land. Whichever way this is decided, that test has to change, which is the point of writing it that way.简体中文要点
一个 workspace 的工具账本只要有一处既有损坏——任何 session、可能是几个月前旧版本写下的——这个 workspace 里的 run 就再也无法干净收尾。第一条带工具事实的追加撞上健康扫描抛错,latch 掉 store,
commitTerminalRun随即提前返回:run 停在running,没有终态事件。这正是 #2234 的形状,在"账本已损坏"这部分人群身上原样保留。扎心的地方在于:这个账本本来会接受终态事实。丢掉它的是 latch,不是损坏。
机制:健康扫描(
sqlite-runtime-store.ts:2211-2228)没有 WHERE 子句,一处损坏污染整个 workspace 文件,而一个文件装着所有 session;扫描只在isToolLedgerBearingEvent为真时被查询(:2391),而终态事件(role: 'system'、终态 status、actions.endInvocation)不属于其中任何一类——所以它根本到不了这道门;但enqueueRuntimeEventStore(agent-run.ts:1669)会 latch,commitTerminalRun(:1483-1490)在 latch 下直接返回,连 run header 投影也一并跳过。这不是回归:在拆出
ToolLedgerRejectionError之前,所有追加失败都 latch,损坏 workspace 一直是这个行为。变化的是这个问题现在可以被问出来了——#2240 豁免一个错误类型的理由是"store 是健康的,这一轮应当仍能收尾",这个理由在这里同样成立,而代码目前说的是反话。#2240 里那句"损坏账本意味着这一轮之后写什么都落不了地"是假的(见上),注释已改正,本 issue 就是那句注释所推迟的行为决定。要决定的是:一个写不了工具事实的 run,该不该仍然被允许写下它结束了?
支持"该":终态写入按构造就是安全的(不含工具事实,无法加深损坏);停在
running是隐形失败,正是 #2234 的危害;latch 对真正不安全的写入毫无增益——带工具事实的追加无论 latch 与否都会被健康门拒掉,latch 唯一独占的效果作用在那些本来安全的写入上。支持"不该":损坏账本或许就该停机;而且如果 corruption 也不 latch,两个错误类型在 latch 处行为就完全一致,拆分只剩文档意义——这对拆分本身也是个公道的质问。
我的看法是中间路线才对,而且它根本不在 latch 上:普通追加继续 fail-closed,唯独放行终态写入,因为一个 run 必须永远有能力说出自己结束了。这是改
commitTerminalRun的守卫,不是改错误分类。但这属于"fail-closed 在这里该是什么意思"的判断,所以是 issue 不是 patch。建议和 #2311 一起定——那是同一形状的另一个方向:一次本身正确的拒绝被升级成了"store 病了"。状态:机制是按当前代码读出的,行号如上;#2240 新增的测试显式钉住了今天的行为连同代价(断言终态事件确实没落库),无论怎么决定这个测试都必须改,这正是那样写它的用意;未在实际环境中观察到——触发条件是 workspace 已有工具账本损坏,我没遇到过;一旦触发,后果就是 #2234 那种挂起。