bug(runtime): a corrupt ledger costs a run its terminal fact via the latch, not via the corruption #2313

Description

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

  1. 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).

  2. 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.

  3. 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.

  4. 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.

  5. 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)不属于其中任何一类——所以它根本到不了这道门;但 enqueueRuntimeEventStoreagent-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 那种挂起。

Metadata

Metadata

Assignees

No one assigned

    Labels

    help wantedExtra attention is needed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
       blocks
      (function() {
      function addCopyButtons() {
      document.querySelectorAll('pre code').forEach(function(codeBlock) {
      if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
      codeBlock.parentElement.setAttribute('data-copy-added', 'true');
      var btn = document.createElement('button');
      btn.textContent = 'Copy';
      btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
      btn.onmouseover = function() { this.style.opacity = '1'; };
      btn.onmouseout = function() { this.style.opacity = '0.7'; };
      btn.onclick = function() {
      navigator.clipboard.writeText(codeBlock.textContent).then(function() {
      btn.textContent = 'Copied!';
      setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
      });
      };
      codeBlock.parentElement.style.position = 'relative';
      codeBlock.parentElement.appendChild(btn);
      });
      }
      addCopyButtons();
      // Re-run on dynamic content
      var observer = new MutationObserver(addCopyButtons);
      observer.observe(document.body, { childList: true, subtree: true });
      })();
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      
      Skip to content

      bug(runtime): a corrupt ledger costs a run its terminal fact via the latch, not via the corruption #2313

      Description

      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

      1. 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).

      2. 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.

      3. 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.

      4. 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.

      5. 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)不属于其中任何一类——所以它根本到不了这道门;但 enqueueRuntimeEventStoreagent-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 那种挂起。

      Metadata

      Metadata

      Assignees

      No one assigned

        Labels

        help wantedExtra attention is needed

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          Skip to content

          bug(runtime): a corrupt ledger costs a run its terminal fact via the latch, not via the corruption #2313

          Description

          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

          1. 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).

          2. 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.

          3. 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.

          4. 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.

          5. 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)不属于其中任何一类——所以它根本到不了这道门;但 enqueueRuntimeEventStoreagent-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 那种挂起。

          Metadata

          Metadata

          Assignees

          No one assigned

            Labels

            help wantedExtra attention is needed

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              bug(runtime): a corrupt ledger costs a run its terminal fact via the latch, not via the corruption #2313

              Description

              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

              1. 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).

              2. 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.

              3. 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.

              4. 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.

              5. 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)不属于其中任何一类——所以它根本到不了这道门;但 enqueueRuntimeEventStoreagent-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 那种挂起。

              Metadata

              Metadata

              Assignees

              No one assigned

                Labels

                help wantedExtra attention is needed

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  Skip to content

                  bug(runtime): a corrupt ledger costs a run its terminal fact via the latch, not via the corruption #2313

                  Description

                  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

                  1. 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).

                  2. 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.

                  3. 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.

                  4. 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.

                  5. 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)不属于其中任何一类——所以它根本到不了这道门;但 enqueueRuntimeEventStoreagent-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 那种挂起。

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Labels

                    help wantedExtra attention is needed

                    Type

                    No type

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      Skip to content

                      bug(runtime): a corrupt ledger costs a run its terminal fact via the latch, not via the corruption #2313

                      Description

                      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

                      1. 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).

                      2. 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.

                      3. 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.

                      4. 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.

                      5. 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)不属于其中任何一类——所以它根本到不了这道门;但 enqueueRuntimeEventStoreagent-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 那种挂起。

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Labels

                        help wantedExtra attention is needed

                        Type

                        No type

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          Skip to content

                          bug(runtime): a corrupt ledger costs a run its terminal fact via the latch, not via the corruption #2313

                          Description

                          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

                          1. 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).

                          2. 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.

                          3. 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.

                          4. 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.

                          5. 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)不属于其中任何一类——所以它根本到不了这道门;但 enqueueRuntimeEventStoreagent-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 那种挂起。

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Labels

                            help wantedExtra attention is needed

                            Type

                            No type

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              Skip to content

                              bug(runtime): a corrupt ledger costs a run its terminal fact via the latch, not via the corruption #2313

                              Description

                              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

                              1. 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).

                              2. 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.

                              3. 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.

                              4. 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.

                              5. 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)不属于其中任何一类——所以它根本到不了这道门;但 enqueueRuntimeEventStoreagent-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 那种挂起。

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Labels

                                help wantedExtra attention is needed

                                Type

                                No type

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions