Skip to content

feat(mesh): record run closes and derive thread state from them - #11234

Draft
yiliang114 wants to merge 2 commits into
codex/mesh-step-5-statusfrom
codex/mesh-step-5b-close
Draft

feat(mesh): record run closes and derive thread state from them#11234
yiliang114 wants to merge 2 commits into
codex/mesh-step-5-statusfrom
codex/mesh-step-5b-close

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

Adds mesh/run-lifecycle.ts: how a mesh run ends, and what the thread does about it. It also gives the aggregate status resolver from #11230 its two producers — finishRun now delegates here, and postMessage discharges outstanding close obligations when it books work before applying the resolved status.

Stacked on #11230 (it imports the resolver). Independent of #11229.

Why it's needed

Closing has to be two writes. A closing tool is called mid-turn: the model is still executing, so the tool cannot mark its own runtime finished. It records what the run is closing as and moves it to finishing, which ends the turn; the runtime callback then records the terminal state, and only there is the thread's status recomputed. A crash between the two leaves a finishing run carrying a closeKind, which is a complete instruction for restart reconciliation. A status written optimistically before the runtime actually stopped would be a lie the next reader cannot detect.

A wait must be refusable. thread_wait is rejected when no other run is live and no sub-thread is open, because nothing could ever wake it. The dependency is walked over parentThreadId, not rootThreadId: a sibling sub-thread of the same root was never delegated by this thread, and counting it would recreate exactly the stranded wait the resolver exists to catch.

The resolver needed writers. #11230 landed the rules; without producers they had no effect. postMessage now discharges outstanding obligations when it actually books work, so an obsolete failure stops pinning a thread after later work succeeded (I2), and it applies the aggregate status, so a post that books nothing leaves the thread blocked rather than sitting in in_progress with no live run and no explanation (I6). Any close discharges peers' waits on the same thread (I1). A clean exit that never called a closing tool is recorded as unclosed, never as implicit success.

Cross-file effects go on the source thread's outbox: a blocker notification at close time, and the parent report plus review notification when the whole thread reaches in_review — not when one agent submits its part while another still works.

Reviewer Test Plan

How to verify

cd packages/core
npx vitest run src/agents/mesh/run-lifecycle.test.ts \
  src/agents/mesh/thread-actions.test.ts \
  src/agents/mesh/thread-status.test.ts \
  src/agents/mesh/mesh-store.test.ts

Confirm a blocked close posts the question and leaves the run finishing with the thread still in_progress; a wait with no dependency is refused; a wait is allowed once a sub-thread exists but a done sibling does not qualify; a close by an agent that does not own the run is refused; a peer's wait is discharged so a later review reports in_review rather than blocked; a clean exit records unclosed and blocks; a child in review enqueues exactly one parent report even when the terminal write is replayed; a human post that books work clears an obsolete failure; and an unassigned post blocks the thread.

Evidence (Before & After)

N/A — internal lifecycle, status application, and tests only.

Tested on

OS Status
🍏 macOS N/A
🪟 Windows N/A
🐧 Linux

Environment (optional)

Node.js 22 workspace, named Vitest files only. Observed: 4 files / 57 tests passed. Targeted ESLint clean.

Risk & Scope

  • Main risk or tradeoff: postMessage now recomputes the thread status on every post, which lists sibling threads inside the workspace transaction. At v1 scale that is a directory read under a lock already held; if it ever matters, the resolver's only I/O need is "is a descendant live", which can be cached on the thread.
  • Not validated / out of scope: nothing calls closeRun yet — the six thread tools are the remaining part of step 5b. Step-5 gates (a), (b), (c) and (e) stay unexecuted, as does queueExternalInput(false) detach/rebook (step 6/7). Outbox events are enqueued but no consumer drains them until step 6.
  • Breaking changes / migration notes: finishRun delegates to the new path and gains an optional failureStage; its existing signature and callers are unchanged.

Linked Issues

Parent delivery PR: #11206. Depends on #11230. Sibling: #11229.

Closing is two writes because a closing tool is called mid-turn and cannot
mark its own still-executing runtime finished. The tool records what the run
is closing as and moves it to finishing, which ends the turn; the runtime
callback records the terminal state, and only there is the thread's status
recomputed. A crash between the two leaves a finishing run with a closeKind,
which is a complete instruction for restart reconciliation — a status written
before the runtime actually stopped would be a lie the next reader cannot
detect.

A wait is refused when nothing could wake it, and the dependency is walked
over parentThreadId rather than rootThreadId so a sibling sub-thread does not
count as this thread's delegation. Any close discharges peers' waits on the
same thread. A clean exit that never called a closing tool is recorded as
unclosed rather than as implicit success.

finishRun now delegates here so a run has exactly one way to end, and
postMessage discharges outstanding obligations when it books work before
applying the aggregate status. Without those two producers the resolver's
rules had no writer: an obsolete failure kept the thread blocked after later
work succeeded, and a post that booked nothing left it in in_progress with no
live run and no explanation.
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

…xports

A structured assignment or parent report is system-authored but must keep the
run or human action that caused it, so it is charged as unattended work
without being suppressed as an ordinary self-authored post. PostMessageInput
now carries authorKind, sourceRunId and triggerKind; all three are derived by
the server and none is accepted from a model.

Removes three exports from run-lifecycle that had no reader: the booking
acknowledger duplicated what postMessage already calls directly, and the live
run listing and re-exported author id were never read.
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Converted to draft, not closed. Steps 5 and 6 are consolidated into a single reviewable PR, #11236, whose branch already contains every commit from this one. Keeping this open preserves its review history; please review and merge #11236 instead.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — every required heading is present and filled in. (The optional 中文说明 details block from the template isn't there, same as on #11230; not gating on it.)

A note on what I reviewed: the head moved from 64b561aa2869 to cbc043995c35 partway through this run, so everything below and in the later stages is verified against cbc043995c35 — the net two-commit diff, including refactor(mesh): carry system-trigger provenance on posts, drop dead exports.

Problem: real, and verifiable rather than asserted. I grepped the base branch (codex/mesh-step-5-status): resolveThreadStatus, acknowledgeCloseObligations, listCloseObligations and outstandingCloseObligations have zero production callers — nothing in thread-actions.ts, mesh-store.ts, launcher.ts, capability.ts or dispatch-policy.ts imports them. So the claim that #11230 landed rules with no producers is factually correct: the resolver is inert today, and acknowledgeCloseObligations' own doc comment already describes "two callers" that don't exist yet. This is a planned feature increment rather than a bug fix, so no before/after reproduction is expected — the committed plan docs (both updated in this PR) are the requirement record.

Direction: aligned. The mesh work is tracked in docs/plans/, this is the next increment of a stacked delivery (#11230 → this, parent #11206), and roadmap/multi-agent is an existing roadmap label. No CHANGELOG reference — expected, since nothing here is user-visible yet (no tool calls closeRun, no consumer drains the outbox until step 6).

Size: touches core (packages/core/src/agents/mesh/**). Per-file breakdown: 482 production lines (run-lifecycle.ts +403, thread-actions.ts +54/−25), 344 test lines (run-lifecycle.test.ts), 19 doc lines. You have admin on this repo, so this is maintainer-authored and the two-tier core gate is exempt; 482 is also under the 500-line threshold, so no maintainer-awareness escalation either way.

Approach: the scope feels right, and the two-write close is well argued — a finishing run carrying a closeKind really is a complete restart instruction, where an optimistically written status is not. The second commit already trimmed three dead exports out of the lifecycle module, which is the right instinct. What's left of that concern sits on the admission side: authorKind / sourceRunId / triggerKind on postMessage plus thread-actions.ts's SYSTEM_AUTHOR_ID still have no producer anywhere in this PR or on the base branch — the close path builds its own message through a local appendMessage rather than calling postMessage. Those read like step-6 dispatcher plumbing, which this PR itself lists as out of scope. A question for now, not a blocker at this stage.

Risk: no elevated risk signals — none of the changed files match the revert-correlated paths.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 所有必需标题都在且填写完整。(模板里可选的 中文说明 折叠块没有,和 #11230 一样,不作为拦截项。)

关于我审查的对象:本次运行过程中 head 从 64b561aa2869 变成了 cbc043995c35,所以下面以及后续各阶段的一切都是针对 cbc043995c35 核对过的 —— 即两个 commit 的净 diff,包含 refactor(mesh): carry system-trigger provenance on posts, drop dead exports

问题: 真实存在,而且是可验证的,不是口头断言。我在 base 分支(codex/mesh-step-5-status)上做了 grep:resolveThreadStatusacknowledgeCloseObligationslistCloseObligationsoutstandingCloseObligations 没有任何生产代码调用方 —— thread-actions.tsmesh-store.tslauncher.tscapability.tsdispatch-policy.ts 都没有 import 它们。所以"#11230 只落地了规则、没有生产者"这个说法是成立的:resolver 目前是空转的,acknowledgeCloseObligations 的文档注释里写的"两个调用方"其实还不存在。这是一个按计划推进的功能增量,不是 bug 修复,因此不需要 before/after 复现 —— 已提交的 plan 文档(本 PR 也更新了)就是需求记录。

方向: 对齐。mesh 相关工作在 docs/plans/ 里有跟踪,这是栈式交付的下一个增量(#11230 → 本 PR,父交付 PR #11206),roadmap/multi-agent 也是已有的路线图标签。CHANGELOG 没有相关条目 —— 符合预期,因为这里还没有任何用户可见的变化(还没有工具调用 closeRun,step 6 之前也没有消费者消费 outbox)。

规模: 触及核心路径(packages/core/src/agents/mesh/**)。按文件拆分:生产代码 482 行run-lifecycle.ts +403,thread-actions.ts +54/−25)、测试 344 行run-lifecycle.test.ts)、文档 19 行。你在本仓库有 admin 权限,属于维护者本人提交的 PR,核心两级门禁豁免;而且 482 行也低于 500 行阈值,因此无论哪条路径都不需要升级给维护者知会。

方案: 范围合理,两次写入的关闭设计论证充分 —— 带着 closeKindfinishing run 确实是一份完整的重启指令,而乐观写入的状态不是。第二个 commit 已经把 lifecycle 模块里三个没人用的导出清掉了,这个方向是对的。剩下的顾虑在准入这一侧:postMessage 上的 authorKind / sourceRunId / triggerKind,加上 thread-actions.ts 里的 SYSTEM_AUTHOR_ID,在本 PR 和整个 base 分支上仍然没有生产者 —— 关闭路径是通过本地的 appendMessage 自己拼消息,并没有调用 postMessage。这些看起来是 step 6 调度器的管线,而本 PR 自己把 step 6 列为范围之外。目前先作为问题提出,不在这一阶段拦截。

风险: 无升级风险信号 —— 改动文件均未命中与 revert 相关的高风险路径。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at cbc043995c350463f9a36e892953718c4ebf3e59 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

Read against the base branch (codex/mesh-step-5-status), since #11230 is still open and everything here depends on its thread-status.ts and mesh-store.ts. Reviewed at cbc043995c35, the net of both commits.

Before reading the diff I sketched what I'd do from the title and the "Why it's needed" section: a small run-lifecycle.ts owning close + terminal write, finishRun delegating to it so a run has exactly one way to end, and the status recomputed only at the terminal write. That's what you built, and the module boundary is better than my first instinct (keeping close out of thread-actions.ts, which is about admission). Two places where I'd have done it differently are below — one of them I think is a real bug.

Blocker: the admission acknowledgement discharges obligation kinds it shouldn't, and a peer's post can silently swallow a question or a review

postMessageInTransaction now calls acknowledgeCloseObligations(next, storedMessage.sequence) with no select, so it takes the default () => true and discharges every outstanding obligation — failure and unclosed (which is what round-2 finding I2 actually asks for), but also blocked, review and waiting. Nothing restricts it by author either, so an agent or system post does it too, not just a human reply.

The review case is the one I'd call a bug rather than an open design question. Walk it:

  1. Agent A closes review and its run reaches the terminal write while agent B is still live → resolveThreadStatus returns in_progress (live runs outrank a review). No thread_in_review notification, correctly — that's the behaviour your own test leaves a thread in_progress while another run is still live pins.
  2. B posts something that books work (a mention that dispatches, or a coalesce into a queued run). The new ack fires and stamps closeAcknowledgedAtSequence on A's run.
  3. A's review obligation is now permanently discharged — outstandingCloseObligations filters on acknowledgedAtSequence === undefined, so the resolver will never see it again.

From there, if no other run leaves an outstanding obligation, the thread goes quiescent and falls through to thread.status === 'open' ? 'open' : 'in_progress'in_progress with zero live runs and no explanation. That's precisely the silent path thread-status.ts's fileoverview says the design refuses to have, and A's submission never reaches a person: thread_in_review and parent_report are only enqueued on a transition into in_review, and that transition never happens. types.ts says an agent "may push a thread to in_review, never past it" — with this ack, a busy thread can't reach in_review at all.

The blocked case is the same mechanism on a question addressed to a human: A asks "which retry path?" and blocks; B, still working, posts and books C; A's blocker obligation is discharged and the thread stops reporting blocked even though no person ever answered. That one is the §9.11 question that acknowledgeCloseObligations' doc comment flags as deliberately unresolved — but §9.11 is framed as "should a human reply discharge a blocker it did not address", and the default here answers a much broader question (any author, any kind) by accident, in the widest possible way.

The fix is small and the mechanism already exists — the close path uses it correctly (kind === 'waiting' && obligation.runId !== run.id for I1). The admission path needs its own predicate, e.g. failure | unclosed | waiting, which satisfies I2 and I1's "or human post" arm while leaving review alone and leaving blocked to be decided deliberately under §9.11 rather than by a default. Note that narrowing to just failure | unclosed would regress I1's human-post arm, so waiting wants to stay in the predicate.

No test covers this: clears an obsolete failure when a later post books real work exercises the failure case (correct), and nothing exercises a post that books work while a peer's review or blocked obligation is outstanding. The suite is green with the behaviour above present.

listThreads() results are trusted where the admission path refuses to trust them

postMessageInTransaction reads const { threads, unreadable } = await transaction.listThreads() and throws when unreadable.length > 0 — it will not decide admission on partial data. The two new readers in run-lifecycle.ts drop that half, and neither mentions unreadable at all:

  • closeRunInTransaction's waiting guard reads only threads. If the live sub-thread's record is the unreadable one, hasLiveDescendant returns false and the wait is refused with "no sub-thread is open, so waiting would strand it" — a read failure reported to the model as a fact about the workspace.
  • applyAggregateStatus reads only threads too, and passes hasLiveDescendant(...) straight into the resolver. Same trigger, different outcome: a waiting obligation resolves to blocked ("waiting on work that no longer exists") and a thread_blocked notification is enqueued and persisted — durable wrong state from a transient unreadable sibling record.

unreadable is populated by any read that throws short of MeshSchemaVersionError (malformed JSON, id mismatch, I/O error), so it's reachable. Suggest taking unreadable in both places and throwing the same way admission does.

While you're in there: in postMessageInTransaction the transaction has already listed threads a few lines earlier, so applyAggregateStatus performs a second full directory read and JSON parse of every thread under the same lock. Your Risk & Scope note frames that read as inherent; on this path it isn't — passing the already-fetched list in (or an optional threads argument) removes it.

unclosed doesn't cover cancelled, though the comment says it does

The comment reads "A run that stopped without calling a closing tool is recorded as unclosed, never as an implicit success", but the code is run.closeKind ?? (input.outcome.status === 'completed' ? 'unclosed' : undefined). failed is fine — obligationFor derives a failure obligation from the status itself. cancelled falls through the gap: no closeKind, no obligation from status, so obligationFor returns undefined and a thread whose only run was cancelled resolves to in_progress with nothing running. Latent today (nothing calls finishRun with cancelled yet), but this PR is the one establishing the rule, and the comment overstates what it enforces. Either record unclosed for cancelled too, or narrow the comment to say completed-only and note where cancellation will land.

The new postMessage parameters still have no producer, and there are now two message-construction paths

The second commit dropped acknowledgeAfterBooking, liveRunsFor and the duplicated SYSTEM_AUTHOR_ID / HUMAN_AUTHOR_ID re-exports from run-lifecycle.ts — good, that's the same concern I'd have raised. What survives is the admission-side half: authorKind, sourceRunId, triggerKind and thread-actions.ts's own SYSTEM_AUTHOR_ID are declared and read in postMessageInTransaction, but no caller in this PR or on the base branch ever sets them — launcher.ts and capability.ts don't call postMessage at all, and closeRunInTransaction uses its own local appendMessage. Read sites, no producers.

appendMessage re-implements the message construction postMessageInTransaction already does (id, sequence, authorKind, from, authorNameSnapshot, sourceRunId, triggerKind, text, mentions, outcomes, at) — the two now differ only in whether admission runs. I don't think the answer is to route the close through postMessage: a closing message must not go through admission, mention parsing or auto-turn charging, or ending a run would book new work. So the separate append is right; the unused parameters are the part that isn't. AGENTS.md's Simplicity First is explicit about not adding flexibility that wasn't requested, and your own Risk & Scope puts the dispatcher (their only plausible producer) in step 6. Suggest moving the three parameters and SYSTEM_AUTHOR_ID into the PR that sets them — or, if step 6 is genuinely next in this stack, saying so here so a reader knows they're intentional.

One small thing, non-blocking: for a waiting close no message is appended, so the ack sequence next.nextMessageSequence - 1 points at the previous message — or at 0 on a thread with no messages, which isValidRun accepts (isOptionalNonNegativeInteger). Nothing compares the value numerically today, so it's harmless, but it records a discharge against a message that doesn't exist in a module whose premise is that the record is the evidence.

sequenceDiagram
    participant P1 as Closing tool (step 5b)
    participant P2 as closeRunInTransaction
    participant P3 as Thread record
    participant P4 as Runtime callback
    participant P5 as finishRunInTransaction
    participant P6 as applyAggregateStatus
    participant P7 as postMessage
    P1->>P2: waiting, blocked or review
    P2->>P2: refuse a wait nothing could wake
    P2->>P3: write one - closeKind, status finishing
    P2->>P3: discharge peers waiting obligations
    P2-->>P1: turn ends, run is not terminal yet
    P4->>P5: runtime actually stopped
    P5->>P3: write two - terminal status, unclosed if none
    P5->>P6: recompute the aggregate status
    P6->>P3: parent report and notifications on transition
    P7->>P3: append post, book work
    P7->>P3: discharge obligations - BLOCKER above is here
    P7->>P6: recompute the aggregate status
Loading

Test evidence

No CI ran on this PR at all, and that is structural rather than a failure: ci.yml's pull_request trigger is filtered to main and release/**, and this PR's base is codex/mesh-step-5-status. I confirmed it from the API on the reviewed commit — of 4 workflow runs on cbc043995c350463f9a36e892953718c4ebf3e59, zero have event == "pull_request", and all 11 check-runs on it are bot orchestration (pull_request_target / issue_comment). There is no test, lint_and_static or typecheck result to quote, green or red — I searched the check-run names for those strings and got nothing back.

Per the triage rules I did not build or run anything from this PR, so the "4 files / 57 tests passed" and "targeted ESLint clean" numbers in the description are the author's own claim, not evidence this gate re-ran or verified — and they were written against the first commit, before cbc043995c35 landed. Worth saying plainly because the stack has no CI at any level until it reaches main: a type error or a failing assertion in these 344 test lines would be invisible until the whole stack is retargeted. I did verify the parts that are checkable statically, against the blobs at the reviewed commit: every field this PR touches (closeKind, closeAcknowledgedAtSequence, finalMessageId, failureStage, finishing, RunCloseKind's unclosed, authorKind: 'system', sourceRunId, triggerKind) already exists in the base types.ts; acknowledgeCloseObligations really does take an optional third select; transaction.listThreads() and readAgents() are *Unlocked helpers, so calling them inside a transaction can't deadlock; and exactOptionalPropertyTypes is off, so the explicit closeKind: undefined in the terminal write compiles.

Not verified: the 57 tests, ESLint, tsc, and every runtime behaviour claimed in the description — no CI on this base, and PR code is never executed by this gate.

Check Conclusion
Qwen Code CI — test not triggered (base is codex/mesh-step-5-status; ci.yml fires on main and release branches)
Qwen Code CI — lint_and_static not triggered (same filter)
Qwen Code CI — typecheck not triggered (same filter)
authorize success
label success
assign success
delay-automatic-review success
Remind on force-push success
review-pr in_progress
precheck-pr, resolve-pr, ack-review-request, review-config, publish-resolution skipped (bot orchestration, not PR CI)

Sandboxed verification would settle part of this: @qwen-code /verify — that run-lifecycle.test.ts, thread-actions.test.ts, thread-status.test.ts and mesh-store.test.ts actually pass on this head is currently the author's word only, and that claim predates the second commit, since no CI lane runs against a stacked base. You have write access, so /tmux is also available, but I'd not bother here: nothing calls closeRun and no consumer drains the outbox until step 6, so there is no TUI surface for it to drive. Note that /verify would not catch the blocker above either — the suite passes with that behaviour present, so what's missing is a test case, not a run.

中文说明

代码审查

审查基准是 base 分支(codex/mesh-step-5-status),因为 #11230 还没合并,本 PR 的一切都依赖它的 thread-status.tsmesh-store.ts。审查对象是 cbc043995c35,即两个 commit 的净结果。

看 diff 之前,我先根据标题和"Why it's needed"写了自己的方案:一个小的 run-lifecycle.ts 负责关闭与终态写入,finishRun 委托给它,让一个 run 只有一条结束路径,且状态只在终态写入时重算。你实现的正是这个,而且模块边界比我最初的想法更好(把关闭逻辑留在 thread-actions.ts 之外,那个文件管的是准入)。有两处我会做得不一样,写在下面 —— 其中一处我认为是真的 bug。

拦截项:准入侧的确认(ack)把不该释放的义务也释放了,同伴的一条发言可能悄悄吞掉一个提问或一次评审提交。

postMessageInTransaction 现在调用 acknowledgeCloseObligations(next, storedMessage.sequence)没有传 select,因此用了默认的 () => true,会释放所有未决义务 —— 包括 I2 真正要求的 failureunclosed,也包括 blockedreviewwaiting。而且没有按作者身份做任何限制,所以agentsystem 的发言同样会触发,不只是人类回复。

review 这一种我认为是 bug,而不是待定的设计问题。走一遍:

  1. Agent A 以 review 关闭并完成终态写入,此时 agent B 仍在运行 → resolveThreadStatus 返回 in_progress(活跃 run 优先于 review)。不发 thread_in_review 通知,这是对的 —— 你自己的测试 leaves a thread in_progress while another run is still live 就钉住了这个行为。
  2. B 发了一条真正排上工作的消息(触发 dispatch 的提及,或 coalesce 进一个排队 run)。新的 ack 触发,给 A 的 run 盖上 closeAcknowledgedAtSequence
  3. A 的 review 义务就此被永久释放 —— outstandingCloseObligationsacknowledgedAtSequence === undefined 过滤,resolver 再也不会看到它。

之后如果没有别的 run 留下未决义务,线程进入静默态并落到 thread.status === 'open' ? 'open' : 'in_progress' —— 零个活跃 run 却报 in_progress,毫无解释。这正是 thread-status.ts 文件头注释说本设计拒绝存在的静默路径;而 A 的提交永远到不了人面前:thread_in_reviewparent_report 只在状态跃迁in_review 时入队,而这次跃迁根本不会发生。types.ts 写的是 agent "可以把线程推到 in_review,但不能越过它" —— 有了这个 ack,繁忙线程根本到不了 in_review

blocked 是同一机制作用在一个面向人类的提问上:A 问"该走哪条重试路径?"并阻塞;仍在工作的 B 发言并给 C 排上工作;A 的 blocker 义务被释放,线程不再报 blocked,尽管从来没有人回答过。这一种确实acknowledgeCloseObligations 文档注释里标注为刻意未决的 §9.11 问题 —— 但 §9.11 讨论的是"人类回复是否应该释放一个它并未回应的 blocker",而这里的默认值意外地回答了一个宽得多的问题(任意作者、任意义务类型),而且是最宽的那种答案。

修法很小,机制也已经有了 —— 关闭路径就用对了(I1 用 kind === 'waiting' && obligation.runId !== run.id)。准入路径需要自己的谓词,例如 failure | unclosed | waiting:既满足 I2,也满足 I1 的"或人类发言"那一支,同时不动 review,并把 blocked 留给 §9.11 去有意识地决定,而不是由默认值决定。注意:如果只收窄到 failure | unclosed,会回退 I1 的人类发言那一支,所以 waiting 要留在谓词里。

没有测试覆盖这一点:clears an obsolete failure when a later post books real work 覆盖的是 failure(正确),没有任何用例覆盖"同伴的 reviewblocked 义务未决时,一条发言排上了工作"。也就是说,上面这个行为存在时,测试套件依然是绿的。

listThreads() 的结果在新路径上被无条件信任,而准入路径是拒绝信任它的。 postMessageInTransaction 读的是 const { threads, unreadable } = ...,并在 unreadable.length > 0抛错 —— 它不在残缺数据上做准入决策。run-lifecycle.ts 里两个新读取点把这一半丢掉了,两处都完全没有提到 unreadable

  • closeRunInTransactionwaiting 判据只取 threads。如果读不出来的正好是那个活跃子线程,hasLiveDescendant 返回 false,等待就会被拒绝并告诉模型"没有子线程打开,等待会搁浅" —— 一次读取失败被当成关于工作区的事实汇报给了模型。
  • applyAggregateStatus 同样只取 threads,并把它直接喂给 resolver。同一个触发条件,结果不同:waiting 义务被判成 blocked("在等已不存在的工作"),还会入队并持久化一条 thread_blocked 通知 —— 一次瞬时的兄弟记录不可读,换来落盘的错误状态。

unreadable 会收录除 MeshSchemaVersionError 之外任何抛错的读取(JSON 损坏、id 不匹配、I/O 错误),所以是可达的。建议两处都接住 unreadable,并像准入那样抛错。

顺带一提:在 postMessageInTransaction 里,事务在几行之前已经列过一次线程了,所以 applyAggregateStatus 在同一把锁下又做了一次完整的目录读取和全量 JSON 解析。你在 Risk & Scope 里把这次读取写成不可避免的;在这条路径上并非如此 —— 把已取到的列表传进去(或加一个可选的 threads 参数)就能省掉。

unclosed 没有覆盖 cancelled,尽管注释这么写。 注释是"未调用关闭工具就停下的 run 记为 unclosed,绝不当成隐式成功",但代码是 run.closeKind ?? (input.outcome.status === 'completed' ? 'unclosed' : undefined)failed 没问题 —— obligationFor 会从状态本身推出 failure 义务。cancelled 掉进了缝里:没有 closeKind,状态也不产生义务,于是 obligationFor 返回 undefined,一个唯一 run 被取消的线程会解析成 in_progress,而实际上什么都没在跑。今天是潜在的(还没有人以 cancelledfinishRun),但确立这条规则的正是本 PR,而注释夸大了它实际强制的范围。要么对 cancelled 也记 unclosed,要么把注释收窄为仅 completed,并说明取消将在哪一步落地。

postMessage 的新参数仍然没有生产者,而且现在有两条消息构造路径。 第二个 commit 把 acknowledgeAfterBookingliveRunsFor 以及重复的 SYSTEM_AUTHOR_ID / HUMAN_AUTHOR_ID 再导出从 run-lifecycle.ts 里去掉了 —— 很好,那正是我会提的同一类问题。留下来的是准入这一侧:authorKindsourceRunIdtriggerKind 以及 thread-actions.ts 自己的 SYSTEM_AUTHOR_IDpostMessageInTransaction 里被声明并读取,但本 PR 和 base 分支上没有任何调用方设置它们 —— launcher.tscapability.ts 根本不调用 postMessage,而 closeRunInTransaction 用的是自己的本地 appendMessage。有读取点,没有生产者。

appendMessage 重新实现了 postMessageInTransaction 已有的消息构造(id、sequence、authorKind、from、authorNameSnapshot、sourceRunId、triggerKind、text、mentions、outcomes、at)—— 两者现在的差别只在于是否走准入。我不认为答案是让关闭路径走 postMessage:关闭消息绝不能经过准入、提及解析或 auto-turn 计费,否则结束一个 run 反而会排上新工作。所以独立 append 是对的;不对的是那些没人用的参数。AGENTS.md 的 Simplicity First 明确反对添加未被要求的灵活性,而你自己的 Risk & Scope 把唯一可能的生产者(调度器)放在 step 6。建议把这三个参数和 SYSTEM_AUTHOR_ID 挪到真正会设置它们的那个 PR;或者,如果 step 6 确实是这个栈里的下一步,就在这里说明,让读者知道它们是有意的。

一个小的、非拦截的点:waiting 关闭不追加消息,所以 ack 序号 next.nextMessageSequence - 1 指向的是上一条消息 —— 在没有消息的线程上则是 0,而 isValidRun 接受它(isOptionalNonNegativeInteger)。今天没有任何地方对这个值做数值比较,所以无害,但在一个以"记录即证据"为前提的模块里,它把一次释放记在了一条并不存在的消息上。

测试证据

这个 PR 完全没有跑 CI,而且是结构性的,不是失败:ci.ymlpull_request 触发只过滤 mainrelease/**,而本 PR 的 base 是 codex/mesh-step-5-status。我在被审查的那个 commit 上通过 API 确认过 —— cbc043995c350463f9a36e892953718c4ebf3e59 上的 4 个 workflow run 中,event == "pull_request" 的有 0 个,该 commit 上全部 11 个 check-run 都是机器人编排(pull_request_target / issue_comment)。没有任何 testlint_and_statictypecheck 结果可引用,无论绿红 —— 我按这些关键词搜过 check-run 名字,什么都没搜到。

按 triage 规则我没有构建或运行本 PR 的任何代码,所以描述里"4 files / 57 tests passed"和"targeted ESLint clean"是作者本人的说法,不是本门禁复跑或验证过的证据 —— 而且这些数字是针对第一个 commit 写的,早于 cbc043995c35 落地。这点要说清楚,因为这个栈在到达 main 之前任何一层都没有 CI —— 这 344 行测试里的一个类型错误或一条失败断言,要等整个栈重新定向 base 之后才会显现。可静态核对的部分我核对了,针对的是被审查 commit 上的 blob:本 PR 触及的每个字段(closeKindcloseAcknowledgedAtSequencefinalMessageIdfailureStagefinishingRunCloseKindunclosedauthorKind: 'system'sourceRunIdtriggerKind)在 base 的 types.ts 里都已存在;acknowledgeCloseObligations 确实接受可选的第三个 select 参数;transaction.listThreads()readAgents() 都是 *Unlocked 辅助函数,所以在事务内调用不会死锁;exactOptionalPropertyTypes 未开启,因此终态写入里显式的 closeKind: undefined 可以通过编译。

未验证:那 57 个测试、ESLint、tsc,以及描述里声称的所有运行时行为 —— 这个 base 没有 CI,且本门禁从不执行 PR 代码。

上面的 CI 表格用机器可读的区域标记包起来了,CI 落定后 finalize 工作流会原地更新它。

沙箱验证可以解决其中一部分:@qwen-code /verify —— run-lifecycle.test.tsthread-actions.test.tsthread-status.test.tsmesh-store.test.ts 在这个 head 上是否真的通过,目前只有作者的说法,而且那个说法早于第二个 commit,因为栈式 base 没有任何 CI 通道会跑。你有写权限,所以 /tmux 也可用,但这里我建议不用:step 6 之前没有任何东西调用 closeRun,也没有消费者消费 outbox,所以没有 TUI 界面可供驱动。要注意 /verify 同样抓不到上面那个拦截项 —— 该行为存在时测试套件依然通过,所以缺的是一个测试用例,不是一次运行。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at cbc043995c350463f9a36e892953718c4ebf3e59 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 2/5 — the architecture is right and unusually well documented, but the admission acknowledgement discharges obligation kinds the design never asked for, and one of them silently loses an agent's review submission.

Stepping back: the two-write close is the correct call and I'd have written it the same way. A finishing run carrying a closeKind genuinely is a complete restart instruction, and refusing to write a status the runtime hasn't earned yet is the discipline that keeps the record trustworthy. Walking parentThreadId instead of rootThreadId for the wait guard is a real insight, not a detail — counting a sibling would have recreated exactly the stranded wait the resolver exists to catch. The fileoverview comments explain why rather than restating the code, which is the house style. finishRun delegating so a run has one way to end is the right consolidation, and the replay test on the parent report is the kind of test people forget to write. The second commit also dropped three exports nothing consumed, which is the same hygiene I'd have asked for.

What stops me approving is that the producer half wired the resolver's most permissive default into a live write path. acknowledgeCloseObligations exists with a select parameter precisely so each caller can say which obligations a given event discharges, and the close path uses it correctly. The admission path passes nothing, so any post that books work — from an agent, from a system trigger, from anyone — clears a peer's review submission and a peer's question to a human along with the failure and unclosed obligations that I2 actually wanted released. The concrete outcome is a thread sitting quiescent at in_progress with nothing running and no explanation, and a review submission that never produces a notification because the transition into in_review never happens. That's the silent path this design was written to refuse, arriving through the code meant to prevent it.

It's a narrow fix — one predicate — and I've named the exact scenario and the test case that would pin it in the review comment. I'd want that test written first, because the suite is green today with the behaviour present, and step 6 will build on these semantics.

Two honest caveats on how much weight to give this. Nothing is broken for a user right now: no tool calls closeRun, no consumer drains the outbox, and postMessage has no production caller on the base branch either, so this is all still pre-wiring. And the blocked half of the finding overlaps §9.11, which thread-status.ts openly declares unresolved — if you've since decided that any booking should clear any obligation, that's your call to make, but it wants saying in the design doc, because both the resolver's fileoverview ("failure or unclosed return") and types.ts ("an agent may push a thread to in_review") currently describe narrower behaviour. The review half isn't covered by §9.11 at all, which is why I'd treat that one as a bug rather than a decision.

Also worth your attention as the person who'll retarget this stack: no CI runs at any level below main, so none of the 57 tests, ESLint or tsc has been executed by anything but you — and those numbers predate the second commit. I verified statically, against the blobs at the reviewed commit, that every field this PR touches already exists in the base types.ts and that the transaction helpers can't deadlock, which rules out the obvious compile-time surprises — but that's a read, not a run.

Process note: the head moved from 64b561aa2869 to cbc043995c35 while this run was in flight. I re-read both changed production files at the new commit and re-checked every finding above against it before posting — all four stand as written. The review comments carry cbc043995c35 in their footers, so you can tell at a glance what they attest to.

Requesting changes on the acknowledgement predicate. The rest — the unreadable divergence, cancelled falling outside unclosed, and the step-6 parameters with no producer — are things I'd like addressed but wouldn't hold the PR for on their own.

中文说明

信心度:2/5 —— 架构是对的,文档质量也少见地好,但准入侧的确认释放了设计从未要求的义务类型,其中一种会悄悄丢掉 agent 的评审提交。

退一步看整体:两次写入的关闭是正确的选择,我也会这么写。带着 closeKindfinishing run 确实是一份完整的重启指令;拒绝写入运行时还没挣到的状态,正是让记录保持可信的那条纪律。等待判据走 parentThreadId 而不是 rootThreadId 是真正的洞见,不是细节 —— 把兄弟线程算进来,恰好会重新造出 resolver 存在的目的就是抓住的那种搁浅等待。文件头注释解释的是为什么而不是复述代码,符合本仓库风格。finishRun 委托出去、让一个 run 只有一条结束路径,是正确的收敛;父报告的重放测试也是人们常忘记写的那一类。第二个 commit 还清掉了三个没人消费的导出,那正是我会要求的同一种卫生。

让我不批的原因是:生产者这一半把 resolver 最宽松的默认值接进了一条真实写入路径。acknowledgeCloseObligations 之所以带 select 参数,就是为了让每个调用方说明"这个事件释放哪些义务",而关闭路径用对了。准入路径什么都没传,于是任何排上工作的发言 —— 来自 agent、来自 system 触发、来自任何人 —— 都会连同 I2 真正想释放的 failureunclosed,一起清掉同伴的 review 提交和同伴向人类提出的提问。具体后果是:线程静默地停在 in_progress,什么都没在跑,也没有任何解释;而那次评审提交永远不会产生通知,因为进入 in_review 的跃迁根本不会发生。这正是本设计写下来要拒绝的静默路径,却从本该防止它的代码里走了进来。

修法很窄 —— 一个谓词 —— 我在审查评论里点名了具体场景,以及能钉住它的测试用例。我希望那个测试先写,因为该行为存在时套件今天是绿的,而 step 6 会在这些语义之上继续搭建。

关于这个判断该占多大分量,有两点如实说明。第一,现在没有任何用户侧的东西是坏的:没有工具调用 closeRun,没有消费者消费 outbox,base 分支上 postMessage 也没有生产调用方,所以这一切仍在接线之前。第二,这个发现里 blocked 的那一半与 §9.11 重叠,而 thread-status.ts 公开声明它未决 —— 如果你此后已经决定"任何排上工作的发言都应清掉任何义务",那是你的决定权,但需要写进设计文档,因为 resolver 的文件头("failure or unclosed return")和 types.ts("agent 可以把线程推到 in_review")目前描述的都是更窄的行为。review 那一半完全不在 §9.11 的范围内,所以我会把它当 bug 而不是当决策。

另外有一点请你作为将来重定向这个栈的人留意:main 以下的任何一层都没有 CI,所以那 57 个测试、ESLint 和 tsc,除了你本人之外没有任何东西执行过 —— 而且那些数字早于第二个 commit。我针对被审查 commit 上的 blob 做了静态核对:本 PR 触及的每个字段在 base 的 types.ts 里都已存在、事务辅助函数不会死锁,这排除了最明显的编译期意外 —— 但那是阅读,不是运行。

流程说明:本次运行过程中 head 从 64b561aa2869 变成了 cbc043995c35。我在发布前重新读取了新 commit 上两个改动的生产文件,并把上面每一条发现都针对它重新核对过 —— 四条全部按原文成立。各审查评论的页脚都写着 cbc043995c35,因此你一眼就能看出它们对应的是哪份代码。

我就确认谓词这一点提出修改请求。其余几项 —— unreadable 的处理分歧、cancelled 落在 unclosed 之外、以及没有生产者的 step-6 参数 —— 我希望一并处理,但单凭它们我不会卡住这个 PR。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at cbc043995c350463f9a36e892953718c4ebf3e59 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@yiliang114 Requesting changes on one thing — the rest of my notes are in the stage comments above.

postMessageInTransaction calls acknowledgeCloseObligations(next, storedMessage.sequence) with no select, so it takes the default () => true and discharges every outstanding obligation on any post that books work, from any author. That's wider than round-2 finding I2 ("an earlier failure or unclosed return") and it produces a silent wrong state: agent A closes review while B is still live (the case your own leaves a thread in_progress while another run is still live test pins), B then posts something that dispatches or coalesces, and A's review obligation is permanently discharged — outstandingCloseObligations filters on acknowledgedAtSequence === undefined, so the resolver never sees it again. If no other run leaves an obligation outstanding, the thread goes quiescent at in_progress with nothing running and no explanation, and thread_in_review / parent_report never fire because the transition into in_review never happens. Same mechanism clears a peer's blocked question to a human — that half overlaps §9.11, which thread-status.ts declares unresolved, so it wants a deliberate decision rather than a default.

Suggested fix: give the admission path its own predicate (failure | unclosed | waiting — keep waiting so I1's "or human post" arm still holds), leave review alone, and add the missing test: a post that books work while a peer's review obligation is outstanding must still resolve to in_review once the thread goes quiescent. The suite is green with the current behaviour, so step 6 would build on it as-is.

Non-blocking, but I'd like them addressed: applyAggregateStatus and the waiting guard ignore unreadable from listThreads() where the admission path throws on it (an unreadable sibling record becomes a refused wait, or a persisted blocked status plus a thread_blocked notification); unclosed is recorded for completed only, so a cancelled run leaves no obligation and the comment overstates the rule; and authorKind / sourceRunId / triggerKind / SYSTEM_AUTHOR_ID have no producer anywhere in this PR or on the base branch, so they belong with the step-6 dispatcher.

Note for the stack: ci.yml only fires on main / release/**, so no CI has run on this head at all — the 57 tests, ESLint and tsc are your local results only. I verified statically that every field this PR touches already exists in the base types.ts, that acknowledgeCloseObligations really takes an optional third select, and that the transaction helpers are *Unlocked (no deadlock), but nothing has executed these changes.

中文说明

就一件事提出修改请求 —— 其余意见都在上面的阶段评论里。

postMessageInTransaction 调用 acknowledgeCloseObligations(next, storedMessage.sequence) 时没有传 select,因此用了默认的 () => true:任何排上工作的发言、无论作者是谁,都会释放所有未决义务。这比 round-2 的 I2("先前的 failure 或 unclosed 返回")宽,并且会产生静默的错误状态:agent A 在 B 仍活跃时以 review 关闭(正是你自己 leaves a thread in_progress while another run is still live 测试钉住的场景),随后 B 发了一条触发 dispatch 或 coalesce 的消息,A 的 review 义务就被永久释放 —— outstandingCloseObligationsacknowledgedAtSequence === undefined 过滤,resolver 再也不会看到它。如果没有别的 run 留下未决义务,线程就会静默停在 in_progress:什么都没在跑,也没有解释,而 thread_in_review / parent_report 永远不会发出,因为进入 in_review 的跃迁根本不会发生。同一机制也会清掉同伴向人类提出的 blocked 提问 —— 那一半与 §9.11 重叠,而 thread-status.ts 声明它未决,所以它需要一个有意识的决定,而不是由默认值决定。

建议修法:给准入路径自己的谓词(failure | unclosed | waiting —— 保留 waiting,I1 的"或人类发言"那一支才成立),不要动 review,并补上缺失的测试:同伴的 review 义务未决时,一条排上工作的发言,在线程进入静默态后仍必须解析为 in_review。当前行为下套件是绿的,所以 step 6 会照原样在它之上继续搭建。

非拦截项,但我希望一并处理:applyAggregateStatuswaiting 判据忽略了 listThreads()unreadable,而准入路径遇到它会抛错(一条读不出来的兄弟记录会变成一次被拒绝的等待,或者落盘的 blocked 状态加一条 thread_blocked 通知);unclosed 只在 completed 时记录,所以被 cancelled 的 run 不留任何义务,而注释夸大了这条规则;authorKind / sourceRunId / triggerKind / SYSTEM_AUTHOR_ID 在本 PR 和 base 分支上都没有生产者,因此应该和 step-6 调度器一起提交。

关于这个栈的提示:ci.yml 只在 main / release/** 上触发,所以这个 head 完全没有跑过 CI —— 那 57 个测试、ESLint 和 tsc 只是你本地的结果。我静态核对了本 PR 触及的每个字段在 base 的 types.ts 里都已存在、acknowledgeCloseObligations 确实接受可选的第三个 select、事务辅助函数都是 *Unlocked(不会死锁),但没有任何东西真正执行过这些改动。

Qwen Code · qwen3.8-max-2026-09-02

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 1153 seconds (of the 360-minute budget). This run already used the maximum 360 minute timeout. See workflow logs.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — head cbc04399, base codex/mesh-step-5-status (draft)

Reviewed as a stacked increment on codex/mesh-step-5-status; scope is the 5 changed files at this SHA. Core-infrastructure gate: packages/core/src/agents/**, maintainer-authored and therefore exempt from the two-tier block. I verified the two findings below by reading run-lifecycle.ts and thread-status.ts at this exact commit; I did not run the suite locally.

Critical 1 — a peer's waiting obligation is discharged too late, so a normal close reports the thread blocked

closeRunInTransaction discharges peers' waits at close time:

next = acknowledgeCloseObligations(
  next,
  next.nextMessageSequence - 1,
  (obligation) =>
    obligation.kind === 'waiting' && obligation.runId !== run.id,
);

and then sets the closing run to status: 'finishing', closeKind: input.request.kind.

The problem is on the reading side. obligationFor checks liveness before it reads closeKind:

if (LIVE_RUN_STATUSES.has(run.status)) return undefined;

and LIVE_RUN_STATUSES contains finishing. So while the closing run sits in finishing, it contributes no obligation at all — its closeKind is invisible. A peer that was waiting on it, if its own obligation has not yet been acknowledged, falls into the strandedWait branch and the thread is reported blocked with "waiting on work that no longer exists." Which is the opposite of true: the work exists and is finishing normally.

The discharge and the finishing transition happen in the same transaction here, so the in-transaction path is fine. The exposure is any read that observes the thread between the two, and any ordering where a peer's wait is registered against a run already in finishing.

The test that looks like it covers this does not. run-lifecycle.test.ts:174 pre-sets the peer to a terminal status before the close, so the finishing window is never entered and the ordering under discussion is never exercised.

Critical 2 — a cancelled run leaves no obligation at all, so the thread never leaves in_progress

closeKind:
  run.closeKind ??
  (input.outcome.status === 'completed' ? 'unclosed' : undefined),

A cancelled run keeps closeKind: undefined. In obligationFor, cancelled is not in LIVE_RUN_STATUSES and is not failed, so control reaches if (run.closeKind === undefined) return undefined; and no obligation is produced. With no outstanding obligation, the tail returns status: thread.status === 'open' ? 'open' : 'in_progress' with reason "no outstanding close obligation."

So a thread whose only run was cancelled reports in_progress forever, with nothing outstanding to explain it and nothing that will ever change it. That is the state the status model exists to prevent.

Worth noting that the header comment claims "no ordering of concurrent run completions can leave a stale status behind." Both findings above are counterexamples to that specific sentence, which is why I am treating them as Critical rather than as polish: the comment is the contract, and it is currently stronger than the code.

Dead switches

Five fields are declared and read but never populated by any caller I could find at this SHA: authorKind, sourceRunId, triggerKind, SYSTEM_AUTHOR_ID, failureStage. Per the project's review rule, I checked the read sites, and each one takes its ?? … default unconditionally. If they are placeholders for the next step in the stack, a one-line note saying so would keep the next reviewer from re-deriving this. Suggestion, not Critical.

Verdict

C=2. The two Criticals are the same mechanism seen from two directions — obligationFor's liveness check running ahead of its closeKind read — so one fix at that site likely closes both. Limitation: no local test run, no cross-platform CI evidence.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

Test Plan (not a blocker): src/agents/mesh/run-lifecycle.test.tsno such file or directory; src/agents/mesh/thread-actions.test.tsno such file or directory; src/agents/mesh/thread-status.test.tsno such file or directory; src/agents/mesh/mesh-store.test.tsno such file or directory; 57 tests passed — this review observed 23674, 1959, 28851, 298, 1818, 504, 6362 passed.

— qwen3.8-max via Qwen Code /review (v0.23.3)


**Aggregate status landed.** `thread-status.ts` derives the status from every run's close obligation rather than letting the last run to finish stamp it, and the three round-2 findings are each pinned by a test: a same-thread wait is discharged by a later close (I1), any later successful booking discharges an earlier failure or unclosed return (I2), and a quiescent thread whose last admission booked nothing becomes `blocked` (I6). Also covered: a live run outranks another agent's review, a blocker outranks a review, a wait is `in_progress` only while a child can wake it, `done` is sticky against a late post, and a failed run reports as a failure even when it recorded a close kind. Observed locally: `thread-status.test.ts` → 1 file, 13 tests passed; targeted ESLint clean. The producers that write `closeKind` are the thread tools in 5b, so nothing calls this resolver yet.

**Run close and status application landed.** `run-lifecycle.ts` splits closing into two writes: the tool records `closeKind` and moves the run to `finishing`, ending the agent's turn, and the runtime callback records the terminal state — the only place the aggregate status is recomputed. A `waiting` close is refused when nothing could wake it, and a live _descendant_ counts while a mere sibling under the same root does not. Any close discharges peers' waits on the same thread. A clean exit with no closing tool is recorded as `unclosed`, never as implicit success. `finishRun` now delegates to this one path, and `postMessage` discharges outstanding obligations when it books work and then applies the aggregate status, so the I2 and I6 fixes have producers rather than only a resolver. Observed locally: `run-lifecycle.test.ts`, `thread-actions.test.ts`, `thread-status.test.ts`, `mesh-store.test.ts` → 4 files, 57 tests passed; targeted ESLint clean. The six thread tools that call `closeRun` are still to come, so gates (a), (b), (c) and (e) remain unexecuted.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-29: The acceptance entry this diff adds names one aggregate-status writer when the same diff adds two, contradicts itself four sentences later on the same line, and attributes closeRun to all six of step 5's tools when only three can call it. This file is the gate ledger the next step is written from.

A step-6 implementer who trusts "the only place the aggregate status is recomputed" reasons about run completion as the sole status writer and never considers that an ordinary human or agent post also recomputes and persists status — which is exactly the write path confirmed as Critical R1-22 (thread-actions.ts:337). Separately, "The six thread tools that call closeRun" is a restrictive relative clause and it is false: only thread_wait/thread_block/thread_review map onto RunCloseRequest's three kinds, thread_post goes through postMessage, and thread_create/thread_read never close a run — so a step-5 implementer wiring from this record calls closeRun from tools that must not.

Witness:

`grep -rn "applyAggregateStatus" --include=*.ts packages/core/src | grep -v '\.test\.ts'` → 2 production writers (`thread-actions.ts:337`, `run-lifecycle.ts:401`), not 1. The phrase is added by this diff: `grep -c "^+.*the only place the aggregate status is recomputed"` on the diff → 1; `git grep -c … e0cef4577f -- docs/plans/` → 0. Step 5's tool list (`acceptance.md:55`) is six tools against `RunCloseRequest`'s three kinds (`run-lifecycle.ts:36-39`). The same added line also says "`postMessage` … then applies the aggregate status", contradicting "the only place".

Suggested fix: Correct that one sentence in both respects: name the two recomputation sites (the terminal callback and the admission path), and scope the closing tools to the three that can call closeRun — e.g. "…the runtime callback records the terminal state; the closing tool never writes the aggregate status itself, and the terminal callback and the admission path are the two places it is recomputed. The three closing tools (thread_wait, thread_block, thread_review) are still to come…".

The fix has to respect this: AGENTS.md requires the design doc and this acceptance file to stay current in the same commit as the code that changes them, and this file is designated the step's contract — so the correction belongs in this PR, not a follow-up. Note the gate-(d) half of the original claim was verified and rejected: the sentence's causal scoping ("…are still to come, so gates (a), (b), (c) and (e) remain unexecuted") legitimately excludes gate (d), which needs the separately-listed prompt assembler.

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +111 to +112
expect(result.thread.outbox).toHaveLength(1);
expect(result.thread.outbox[0]?.payload['event']).toBe('blocker_raised');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-33: No test in the mesh suite asserts the status: 'pending' that the new enqueue writes, although that single literal is the whole handshake between this diff's outbox producer and every consumer of it — and this diff is what makes the outbox production-written.

isValidEvent accepts both members of ThreadEventStatus, so changing enqueue's literal to 'acknowledged' still validates and still writes while every outbox assertion in the suite keys on something else. The mutant then: (i) makes reconcileThreadOutbox skip every event this diff produces (if (event.status !== 'pending') continue;), so step 6's drainer delivers nothing — no blocker page, no thread_in_review, no parent_report to wake a waiting parent; (ii) stops alreadyReported ever matching, so an in_review → in_progress → in_review cycle wakes the parent once per cycle instead of once; (iii) stops deleteThread's pending-event guard protecting a thread holding undelivered reports, making it deletable and dropping them.

Witness:

`BASELINE (unmodified PR) Test Files 9 passed (9) Tests 100 passed (100)`; `MUTANT A run-lifecycle.ts:144 status: 'pending' -> 'acknowledged': Test Files 9 passed (9) Tests 100 passed (100)`; `MUTANT A + suggested assertion: AssertionError: expected 'acknowledged' to be 'pending'; Test Files 1 failed (1) Tests 1 failed | 11 passed (12)`; `MUTANT A reverted, assertion kept: Tests 12 passed (12)`. Consequence driven, not inferred — a real cycle probe: `INTACT: STEP3 status=in_review parentReports=1` vs `MUTANT A: STEP3 status=in_review parentReports=2 outbox=[child_in_review, thread_in_review, child_in_review, thread_in_review]`.

Suggested fix: Pin the produced state where the event is already held — extend the blocker_raised assertions with expect(result.thread.outbox[0]?.status).toBe('pending');, and do the same for the thread_blocked and parent_report events. Note this pins the status literal but not the dedup; only the status-cycle test R1-15 asks for does that.

The fix has to respect this: The asserted literal must be 'pending'mesh-store.ts:1266 reads if (event.status !== 'pending') continue; and types.ts:254 declares export type ThreadEventStatus = 'pending' | 'acknowledged';, so 'acknowledged' validates and writes: the mutation is silent, not a crash.

Acceptance criterion: The new status assertion goes red when enqueue's status: 'pending' is changed to 'acknowledged', and green when reverted. Please prove it by mutation — apply the fix, then remove it again and confirm that test goes red.

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +123 to +125
request: { kind: 'waiting' },
}),
).rejects.toThrow(MeshCloseRejectedError);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-14: The same-thread-peer branch of the waiting gate (otherLive) is never exercised: both kind: 'waiting' tests close a run that is the thread's only run, so the gate is only ever satisfied by the sub-thread branch or refused outright.

Deleting the !otherLive && disjunct from run-lifecycle.ts:186 leaves all tests green — the refusal test still refuses (no peer, no child) and the sub-thread test still passes via hasLiveDescendant. The behaviour that goes silently missing is the design's I1 workflow ("A waits for B; B reviews"): an agent calling thread_wait while a peer run is still queued/running on the same thread, with no sub-thread open, would get no_live_dependency and be told to block or keep working instead of waiting.

Witness:

`MUTATED (`!otherLive &&` disjunct and its binding removed) -> Tests 100 passed (100)`; `PROBE intact: Tests 1 passed (1) (waiting close succeeds, closeKind='waiting')`; `PROBE mutated: MeshCloseRejectedError: Nothing else is running on this thread and no sub-thread is open, so waiting would strand it. Block with a question, submit for review, or keep working.`

Suggested fix: Add a case that closes with waiting while a peer run is live and no sub-thread exists, asserting the close succeeds and closeKind === 'waiting'. Note the interaction with R1-2: the peer must be queued or running, not finishing.

The fix has to respect this: The gate counts only 'queued' | 'running' | 'finishing' (run-lifecycle.ts:184-186), narrower than LIVE_RUN_STATUSES (thread-status.ts:42-47, which also includes 'cancelling'); runs sharing one thread file must have distinct queueSequence values or writeThread rejects the record (mesh-store.ts:382).

Acceptance criterion: The new test throws MeshCloseRejectedError if the !otherLive && disjunct is removed, which no existing test detects. Please prove it by mutation — apply the fix, then remove it again and confirm that test goes red.

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +153 to +156
expect(hasLiveDescendant(threads, parent.id)).toBe(false);
expect(hasLiveDescendant([{ ...parent }, { ...child }], parent.id)).toBe(
true,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-13: The test named "allows a wait once a sub-thread is open, and not for a mere sibling" never exercises a sibling, and the assertion its comment names is vacuous: the fixture's sibling has parentThreadId: parent.id, making it a second child of the walk root, and both children are forced to done.

Three mutations all survive the suite: (a) re-key hasLiveDescendant on rootThreadId — the exact mistake the code comment and the added plan paragraph say the walk exists to avoid; (b) flatten its BFS recursion, so a legal waiting close on a thread whose sub-thread's own sub-thread is live is refused with no_live_dependency; (c) re-key the closeRun call site to thread.rootThreadId. Under (c) an agent whose own thread is quiescent is allowed to close waiting because an unrelated sibling under the same root is active; nothing on its thread can wake it, so applyAggregateStatus later resolves it blocked instead of the tool refusing up front. The parentThreadId-vs-rootThreadId distinction is nominated as load-bearing in the PR description while the test's name and comment tell the next reviewer it is covered.

Witness:

`MUTATION (a) byParent keyed on rootThreadId -> Tests 100 passed (100)`; `MUTATION (b) queue.push(child.id) removed -> Tests 100 passed (100)`; `MUTATION (c) closeRun uses thread.rootThreadId -> Tests 100 passed (100)`; `PROBE intact: -> Tests 2 passed (2)`.

Suggested fix: Pin the walk from the child's point of view — expect(hasLiveDescendant([{ ...child }, { ...sibling }], child.id)).toBe(false) with sibling left at its created non-done status; add a grandchild case asserting a live grandchild under a done child still counts; and add a closeRun-level refusal case with a live sibling sub-thread and no other live run on the waiting thread.

The fix has to respect this: Liveness is status !== 'done' (run-lifecycle.ts:94) and createThread defaults a new thread to status: 'open' (mesh-store.ts:1139), so a grandchild fixture must be non-done; the closeRun refusal case needs !otherLive, and otherLive counts any other run in queued | running | finishing (:181-187), so the fixture's waiting run must be the thread's only run.

Acceptance criterion: Those added assertions — the first goes red under a root/same-root re-key, the second under a flattened recursion, the third under a root-scoped call site. All green against the implementation as committed. Please prove it by mutation — apply the fix, then remove it again and confirm that test goes red.

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +159 to +160
it('refuses a close for a run the caller does not own', async () => {
const thread = await seed();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-25: The run_not_bound guard refuses on !run || run.agentId !== input.agentId || run.status !== 'running', but only the wrong-agent disjunct is exercised. The run.status !== 'running' disjunct — the replay / double-close protection the function's own docblock names — has no test, so the guard can be narrowed to the wrong-agent check alone with the suite green.

All eight closeRun call sites pass a hand-seeded running run, so deleting || run.status !== 'running' keeps all tests green. Step 5b's six tools will call this gate with model-driven input, where a duplicated closing-tool call is routine (two closing tools in one turn, or a retry after a timeout). A second thread_block on a run whose terminal write already landed flips it from completed/failed back to finishing: LIVE_RUN_STATUSES then hides its recorded obligation, so a failure a person had to see stops being reported and the resolver returns in_progress for a thread with nothing running; since that run's callback already fired, no further write ever moves it out of finishing (restart reconciliation is step 8). The same call appends a duplicate message, overwrites finalMessageId and closeKind — a run that closed review and then hit the block tool becomes blocked, flipping the aggregate from in_review — re-acks peers at a new sequence, and enqueues a second blocker_raised, whose site has no dedup.

Witness:

Mutation arm — delete `|| run.status !== 'running'` from `run-lifecycle.ts:173`: the whole mesh suite stays green (100 tests), while the added replay case fails, showing a terminal run re-opened to `finishing` with its obligation hidden.

Suggested fix: Add a case that closes an already-finishing run (close twice in a row) and one that closes a completed/failed run and a fabricated runId, each asserting MeshCloseRejectedError with code === 'run_not_bound', plus a readThread assertion that the thread file is unchanged by the refusal — no second message, no re-acknowledged peer, one outbox entry.

The fix has to respect this: run-lifecycle.ts:45 declares exactly 'no_live_dependency' | 'run_not_bound' | 'thread_done', so a replayed close must not get a fourth code; and :173-177 raises one MeshCloseRejectedError('run_not_bound', …) for three distinct causes, so a new test can distinguish "not running" from "no such run" only by message text.

Acceptance criterion: The new case goes red when || run.status !== 'running' is dropped from run-lifecycle.ts:173, which no existing test detects. Please prove it by mutation — apply the fix, then remove it again and confirm that test goes red.

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +198 to +200
authorKind:
input.authorKind ??
(input.from === HUMAN_AUTHOR_ID ? 'human' : 'agent'),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-7: The documented derivation ("Derived from from when absent", thread-actions.ts:39-44) never derives system, so one object literal records a from: SYSTEM_AUTHOR_ID post as authorKind: 'agent' while two lines below (:203) it treats that same from as a system author for authorNameSnapshot.

A step-6 dispatcher posts a structured trigger the way the new doc invites — postMessage(root, threadId, { from: SYSTEM_AUTHOR_ID, text: …, triggerKind: 'parent_report' }) — omitting the optional authorKind because it is documented as derived. The stored message is authorKind: 'agent' with authorNameSnapshot: 'system': self-contradictory, and it passes the store validator, so nothing rejects it. Any consumer keyed on authorKind (a UI badge, or a rule separating agent turns from unattended triggers) mis-attributes a system trigger to an agent and nothing downstream can tell.

Witness:

`postMessage(from=SYSTEM_AUTHOR_ID): {"authorKind":"agent","authorNameSnapshot":"system","from":"system","triggerKind":"parent_report"}`; `round-trip on disk : [{"authorKind":"agent","authorNameSnapshot":"system",…}]`; `postMessage(from=HUMAN) : human`; `postMessage(from=ag_alice) : agent`; `postMessage(authorKind="system"): {"authorKind":"system","snapshot":"system"}` — one `from` value, two different answers in one literal.

Suggested fix: Either derive all three kinds so the contract holds — input.authorKind ?? (input.from === HUMAN_AUTHOR_ID ? 'human' : input.from === SYSTEM_AUTHOR_ID ? 'system' : 'agent') — or drop authorKind/sourceRunId/triggerKind and SYSTEM_AUTHOR_ID until the dispatcher that sets them lands (AGENTS.md Simplicity First). The dead-switch half of this is already on the PR record from both prior reviewers; the new claim is the self-contradiction inside one object literal.

The fix has to respect this: authorKind: 'human' | 'agent' | 'system'; (types.ts:115), accepted verbatim by the record validator at mesh-store.ts:246-248 — the derived value must stay inside that union.

Acceptance criterion: A thread-actions.test.ts case posting { from: SYSTEM_AUTHOR_ID, text: 'assignment', sourceRunId: 'rn_1', triggerKind: 'assignment' } and asserting message.authorKind === 'system' plus that sourceRunId/triggerKind round-trip through readThread. Red today on authorKind ('agent'). Please prove it by mutation — apply the fix, then remove it again and confirm that test goes red.

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +329 to +331
dispatched.length > 0 ||
outcomes.some((o) => o.decision.kind === 'coalesce')
) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-9: The coalesce half of the new acknowledgement condition has no test anywhere, so the I2 regression it exists to prevent can be reintroduced by deleting it with the suite staying green.

Delete || outcomes.some((o) => o.decision.kind === 'coalesce') and the whole mesh suite stays green: the one I2 test reaches only the dispatched.length > 0 half, and the only coalesce test asserts outcomes and autoTurnsUsed but never closeAcknowledgedAtSequence. In production: bob's run failed at launch (obligation failure), alice has a run still queued, and a further post folds into alice's queued run instead of booking a new one. Without the coalesce half bob's failure is never discharged; when alice completes with closeKind: 'review', BLOCKING_KINDS outranks review and the thread is written blocked — "run rn_bob failed and no successor is runnable" — although the successor ran and finished. That is exactly the I2 failure the comment above the line says it fixes.

Witness:

`MUTATED Test Files 9 passed (9) / Tests 100 passed (100) <-- the whole coalesce half is unpinned`; `PROBE (coalesce-only post, failed peer run) mutated: expected undefined to be 1 (rn_bob.closeAcknowledgedAtSequence)`; `PROBE mutated, ack assertions removed: expected 'blocked' to be 'in_review'`; `PROBE intact: Tests 1 passed (1)`.

Suggested fix: Add a test seeding a thread with a failed run for one agent and a queued/running run for another, posting a message that coalesces into that run (dispatched empty), asserting the failed run's closeAcknowledgedAtSequence equals the new message sequence, then finishing the running run cleanly and asserting the thread is not blocked.

The fix has to respect this: decideDispatch returns coalesce only when the target already has a live run on this thread (dispatch-policy.ts:141-145); a fixture whose target has no live run gets dispatch instead and silently re-tests the already-covered clause.

Acceptance criterion: That new case goes red when the coalesce disjunct is deleted, which no existing test does. Please prove it by mutation — apply the fix, then remove it again and confirm that test goes red.

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +334 to +336
// The status is an aggregate over every run, never last-writer-wins, and it
// is recomputed here so an admission that books nothing cannot leave the
// thread sitting in `in_progress` with no live run and no explanation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-8: The applyAggregateStatus call this comment introduces overwrites the two hand-written status: assignments still present in the coalesce and dispatch branches above it (thread-actions.ts:264-269 and :298-303, both unchanged pre-existing code), so the function now carries two contradictory status authorities — and the comment declares there is only one.

Both branches are reachable only after decideDispatch returned coalesce (into a queued/running run) or dispatch (adding a queued run), so resolveThreadStatus always takes its live-run branch and returns in_progress, overwriting whatever the ternary wrote; the done case the ternary guards against is unreachable because decideDispatch returns {kind:'skip', reason:'thread_done'} first. The ~12 lines are dead, and the next person changing the aggregate rules — or debugging a wrong status — must first discover that assignments 70 lines above the resolver do nothing, in a function whose own new comment says the status is "never last-writer-wins".

Witness:

Same input on both arms (an agent-authored post, so the ternary's condition is false and it leaves the stored status alone): the assignments were measured to have no effect on the persisted status, and the deletion keeps the suite green. Verified in scope — this diff is what makes them dead, by adding the `applyAggregateStatus` call at `:337`.

Suggested fix: Delete the status: property (and its ternary) from both the coalesce and dispatch branch objects, leaving applyAggregateStatus as the only writer of next.status in this function.

The fix has to respect this: if (live.length > 0) { returning in_progress (thread-status.ts:168) is what makes the assignments dead — the deletion must not touch that resolver.

— qwen3.8-max via Qwen Code /review (v0.23.3)

// The status is an aggregate over every run, never last-writer-wins, and it
// is recomputed here so an admission that books nothing cannot leave the
// thread sitting in `in_progress` with no live run and no explanation.
next = await applyAggregateStatus(transaction, next, now);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R1-22: [certifies-falsely] [regression] resolveThreadStatus's "last admission booked nothing" branch keys positionally on thread.messages[length-1] with no sequence comparison, and sits above the review branch and above the conditional live-child waiting branch (thread-status.ts:207-231). resolveThreadStatus had no production caller at the merge base, so the two applyAggregateStatus sites this diff adds are what make that branch order reachable from a persisted write.

A thread sits in_review with an outstanding review obligation (last message is the close summary, outcomes: []). A human replies @alicce lgtm with a typo: parseMentions yields unknown: ['alicce'] and ids: [], so the only outcome is {skip, agent_unknown}, and the admission write persists status: 'blocked', reason the last post booked no work (agent_unknown), enqueues thread_blocked, and masks the pending review in the only field a UI reads — permanently, if the reason is a disabled assignee or an exhausted token budget, since no later post can reach the review branch. Symmetrically, an agent's self_trigger progress note stays the last message when it then closes waiting, so the parent is written blocked with a false page while its sub-thread runs. And a close message's outcomes: [] masks an earlier real booked-nothing admission, so the same post yields blocked or in_review depending on event ordering.

Witness:

Probe on unmodified PR code: `E3 persisted: status=blocked runs=[rn_alice:completed/review]` with `outbox=[thread_in_review(pending), thread_blocked(pending) reason="the last post booked no work (agent_unknown)"]` — an outstanding review obligation present, `blocked` persisted, and two contradictory notifications pending at once. BASE arm (the added `applyAggregateStatus` call removed from `postMessageInTransaction`, faithful: the symbol does not exist at merge base), same input: the wrong persisted status does not occur.

Suggested fix: In thread-status.ts, move the if (lastMessage && admissionBookedNothing(lastMessage)) branch below the review branch and below the stranded-wait-with-live-child branch, or gate it on outstanding.length === 0, so it decides status only when there is genuinely no obligation to explain the thread. Alternatively resolve it against the most recent admission rather than the final message: const lastMessage = [...thread.messages].reverse().find((m) => m.outcomes.length > 0);. Do not fix it by giving close messages outcomes — a skip outcome on a close message would itself trip admissionBookedNothing.

The fix has to respect this: thread-status.test.ts:201-202 asserts blocked + reason containing agent_unknown for a fixture with a booked-nothing last message and no runs; :205-211 asserts in_progress for outcomes: []; and this diff's own run-lifecycle.test.ts:296-310 pins blocked plus a thread_blocked event for a freshly created unassigned thread. A reorder must keep all three green — the fresh-open/no-obligation case must still return blocked.

Acceptance criterion: A run-lifecycle.test.ts case seeding a run {status:'completed', closeKind:'review'}, asserting finish() gives in_review, then posting {from: HUMAN_AUTHOR_ID, text: '@alicce lgtm'} with agents: [ALICE] and asserting posted.thread.status === 'in_review' with no thread_blocked; plus a case pinning in_progress for a waiting obligation with a live child. Moving the branch back above review turns both red. Please prove it by mutation — apply the fix, then remove it again and confirm that test goes red.

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +403 to +405
return withMeshStoreTransaction(projectRoot, (transaction) =>
finishRunInTransaction(transaction, { threadId, runId, outcome, now }),
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-16: The rewritten public finishRun, the entry point step 6's dispatcher will call and the one whose behaviour this diff changes most, has no test; the new suite's finish() helper drives finishRunInTransaction directly and finishRun has no caller anywhere.

Reverting finishRun to the pre-diff inline write — no closeKind: 'unclosed', no applyAggregateStatus, no failureStage — keeps every mesh test green. What ships then: a run that exits cleanly without calling a closing tool is recorded as a bare completed with no obligation, so resolveThreadStatus reports the thread in_progress instead of blocked, and the "never as an implicit success" rule this module's fileoverview exists to enforce is absent on the only path an external caller reaches.

Witness:

`MUTATED (finishRun reverted to the pre-diff inline write, unused import dropped) -> Tests 100 passed (100)`; `PROBE driving the PUBLIC finishRun(projectRoot, threadId, runId, outcome, now): mutated: expected undefined to be 'unclosed' (closeKind); mutated: expected undefined to be 'launch' (failureStage)`. Sweep: `grep -rn "\bfinishRun\b" packages --include=*.ts` finds only its own definition at `thread-actions.ts:392` — no caller.

Suggested fix: Point the finish() helper in run-lifecycle.test.ts at the public finishRun(projectRoot, threadId, runId, outcome, now), keeping one case on finishRunInTransaction for the in-transaction contract.

The fix has to respect this: finishRun still takes now as the fifth positional parameter (thread-actions.ts:392-401), so a helper switch must not fold now into the outcome object, which finishRunInTransaction would ignore.

Acceptance criterion: "records a clean exit with no closing tool as unclosed and blocks" and "carries a typed failure stage onto the run and blocks the thread", driven through the public finishRun, go red if the delegation is reverted or drops failureStage. Please prove it by mutation — apply the fix, then remove it again and confirm that test goes red.

— qwen3.8-max via Qwen Code /review (v0.23.3)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants