feat(serve): backfill session PR bindings and refresh their merge state - #9729
Conversation
Legacy sessions predate the PR-binding feature, so the sidebar had no way to answer 'which session produced PR N'. An on-demand route scans every trusted workspace's persisted sessions, resolves PR numbers from the worktree slug/branch convention and from transcript gitBranch x gh headRefName intersections (the dominant source in practice), and writes the existing .pr.json sidecars. Bound PRs now carry a state snapshot (open/merged/closed) that a 5-minute daemon sweep advances via a slim gh pr list --state all query, and the sidebar badge dims merged PRs while the tooltip names merged/closed ones.
|
Thanks for the re-run — this pass reviews the one new commit that landed since the last approval. Template looks good ✓ Problem: observed, not theoretical — real operator data (6,454 persisted sessions across 25 workspaces with zero bindings before backfill; badges keeping their open accent long after a PR merged), follow-up to #9543 which is on main. Direction: aligned — completes the "which session produced PR N, and is it still open?" story for serve-mode operators. Size: 35 files, +6,529/−129 — ≈1,653 production lines (291 of them in Approach: unchanged and still sound — reuse-first (extends Risk: no matches against the revert-history high-risk paths. Since the last triage run: one commit — Moving on to code review. 🔍 中文说明感谢重新触发——本次审查的是上次批准之后新落的一个提交。 模板完整 ✓ 问题:已观测到的真实问题,不是理论性的——真实运营数据(25 个 workspace 的 6454 个存量会话回填前零绑定;PR 合入后 badge 长期保持 open 高亮),是已合入 main 的 #9543 的后续。 方向:对齐——补齐 serve 模式操作者"哪个会话产出了 PR N,它是否还开着"的闭环。 规模:35 个文件、+6,529/−129——约 1,653 行生产代码(其中 291 行在 方案:未变且仍然合理——优先复用(以 slim 字段集扩展 风险:未命中 revert 历史高风险路径。 上次 triage 之后:一个提交—— 进入代码审查 🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code review — delta passThe previous pass reviewed Independent sketch first: the two round-18 holes are (1) a runtime snapshotted from the registry before a long await can be retired mid-run (trust/env replacement), so The PR does exactly that. Verified against the code at this head:
Two non-blocking observations, neither worth changing:
No Critical findings in the delta. The dismissed approval on Testing evidenceEvidence carried: the PR's own CI on the reviewed head, fetched via the API (unattended run — no PR code executed here), plus the sandboxed The push triggered two duplicate CI batches one second apart; the concurrency group cancelled the first, and the second batch is fully green — 6/6
The cancelled entries in the check list all belong to the superseded duplicate batch; the only still-running check is the bot's own Sandboxed verification: the Not verified: Windows/Linux runtime behavior (author reports them unexercised locally; no OS-specific code paths beyond existing gh spawning). Badge dimming/tooltip labels rest on collocated unit tests plus the green Web-shell Visuals lane — no visual diff of the dimmed state was produced in this run. The author's real-daemon numbers (575 bindings, 272/342 coverage) remain the author's claim. 中文说明代码审查——增量轮上一轮完整审查了 先给独立方案: 第 18 轮的两个漏洞是(1)运行时在长 await 之前已从注册表快照,中途被替换(信任/环境更换)后,仍可能用旧环境跑 PR 正是这么做的。 已在此 head 的代码中核实:回填与定时刷新都在 gh 拉取前、排队写入内部(planner / 两条非阻断观察,均不值得改:定时刷新逐目标吞掉 增量中未发现 Critical。 测试证据本轮携带的证据:通过 API 获取的该 head 自身 CI(无人值守运行——此处不执行 PR 代码),以及沙箱 本次 push 间隔一秒触发了两批重复 CI;并发组取消了先到的那批,后到的一批全绿——6/6 个 沙箱验证:针对上一 head 未验证:Windows/Linux 运行时行为(作者未本地验证,除既有 gh 派生外无 OS 特有路径);badge 变暗/tooltip 标注依赖同置单测与绿色 Web-shell Visuals lane,本轮未产出变暗状态的视觉对比;作者真实数据(575 条绑定、272/342 覆盖)仍是作者声明。 — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 4/5 — the delta commit closes the last known holes the same way I would have (reusing the existing guard and lane, not inventing new synchronization), everything is green on this head, and what keeps this at 4 rather than 5 is a short list of tracked non-blocking deferrals plus a verification run still in flight. Stepping back: the previous pass approved The residual items are unchanged from the prior pass and remain Suggestions, not blockers: the merged badge is conveyed by color alone, the never-passed injection parameter, and full-transcript reads in the on-demand scan. Plus two new non-blocking observations from this pass (the sweep's per-target swallow of generation-closed, and drain-time per-workspace iteration in backfill) — both defensible as written, both documented above. Approving, pinned to the reviewed commit. 中文说明信心:4/5 —— 增量提交用我会采取的同样方式(复用既有的 guard 与通道,而不是发明新的同步机制)关闭了最后两个已知漏洞;此 head 全绿;之所以是 4 而不是 5,是因为仍有一份已跟踪的非阻断延期清单,且一轮验证仍在运行中。 整体回顾:上一轮以 4/5 批准了 遗留项与上一轮相同,仍是 Suggestion 而非阻塞:merged badge 仅以颜色传达、从未被传入的注入参数、按需扫描对整个 transcript 的读取。加上本轮两条非阻断观察(定时刷新逐目标吞掉代际关闭、drain 时回填逐 workspace 迭代)——两者按现状都站得住脚,已在上文说明。 批准,钉在已审查的提交上。 — Qwen Code · qwen3.8-max Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Needs a rebase onto current main before it can merge: the branch diverged before #9543 landed, so it conflicts with main and the displayed diff re-includes #9543's already-merged content (true contribution is ~25 files, not 58). The true delta reviews cleanly — one Suggestion on badge refresh propagation (the sweep should bump the bridge catalog revision), details in my notes above. @wenshao once rebased, re-run @qwen-code /triage and I'll review the new head. 🙏
8f09d60 to
0c0dbd2
Compare
|
Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration. 中文请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。 |
🖼️ web-shell visual previewRendered against a mock daemon (no real backend): the PR base vs this PR head Screenshots · before / afterFull-resolution recordings (.webm) are attached to the workflow run. — Qwen Code · web-shell visuals |
🩺 serve daemon A/BBuilt the PR base vs this PR head ✅ No response changes against the PR base across 12 scenario(s). — Qwen Code · serve A/B |
run-qwen-serve is a pre-listen bundle root whose static closure must stay free of the SessionService chain (glob et al.). Loading session-pr-refresh statically pulled that chain in; a dynamic import() of the core barrel from inside the refresh module was worse — it made the barrel's full namespace live and poisoned the shared chunk for every static barrel importer (ACP agent included). Load the whole refresh module through a dynamic import at timer start instead, guarded by a generation counter against dispose races.
process-env-guard scans serve sources for process.env reads; register QWEN_SESSION_PR_REFRESH_MINUTES as a documented process-scoped switch. GitDialog now sends state 'open' with the binding, so the dialog tests assert it.
|
@qwen-code /takeover |
|
🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the 中文说明🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
中文说明
仅完成部分审查,审查缺口已披露。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
— qwen3.8-max via Qwen Code /review (v0.22.0)
Review round 1 findings on the PR-state feature. The refresh interval resolver validated minutes but not the converted milliseconds: values above setInterval's 32-bit max clamp to 1 ms (a "monthly" interval becomes a continuous sweep hot loop), sub-minute values degenerate the same way, and a blank env value parsed as 0 and silently disabled the sweep. Fall back to the default for all three; keep 0 as the disable. parsePrNumberFromWorktree accepted `pr-0` (a legal user slug) and bound number 0, which isValidSessionPr rejects — invalidating the whole sidecar and permanently dropping prior bindings on the next upsert. Gate both slug and branch conventions on n > 0. Live-only sessions (bound before their first transcript flush) bypassed both the sidecar-wins state merge and the sweep, freezing their PR state at bind time. All three listing paths now build the live-only insertion through a shared helper that best-effort reads the sidecar with the same sidecar-wins merge rule, and the sweep enumerates `.pr.json` sidecars directly (new SessionService.listSessionIdsWithPrSidecar) instead of walking transcripts — discovering unflushed sessions and removing the pagination loop entirely. Backfill bound every resolved number and let upsertSessionPr evict past the cap, so sessions with more than SESSION_PR_LIST_LIMIT PRs re-bound the evicted numbers forever, rotating the badge's latest entry each run. Bind only the cap's tail (excess counted as overLimit), making repeat runs converge to bound: 0. Both the sweep and backfill write phases also isolate per-sidecar failures instead of aborting the rest.
|
🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下: Review round summary — PR #9729This round addresses all four Critical findings from the automated review plus the closely-coupled cheap Suggestions, and adds regression witnesses for two more. 25 remaining Suggestions are deferred to the next round with per-thread replies ( Critical findings — all four fixed (each witnessed by a test that fails on the pre-round code)
Suggestions addressed this round
Review bodies
Deferred to the next round25 Suggestions (error-message wording R1-1×2, bridge state-equality/coverage R1-31/R1-9/R1-10, backfill test hardening R1-7/R1-36/R1-37/R1-14, URL/remote corners R1-3, transcript parsing R1-2/R1-16, gh degradation visibility R1-33, branch→number staleness R1-4, archive/delete races R1-26/R1-39, negative memo R1-19, binding order R1-21, import Verification
中文说明审查轮次总结 — PR #9729本轮处理自动审查的全部 4 个 Critical 发现,以及与之紧密耦合的低成本 Suggestion,并为另外两个发现补充了回归见证测试。其余 25 个 Suggestion 延迟到下一轮,并逐线程附回复(见 Critical 发现 — 4 个全部修复(每个都有"在改动前代码上必然失败"的测试作见证)
本轮处理的 Suggestion
审查主体评论
延迟到下一轮25 个 Suggestion(错误消息措辞 R1-1×2、bridge state 相等/覆盖 R1-31/R1-9/R1-10、回填测试加固 R1-7/R1-36/R1-37/R1-14、URL/remote 边角 R1-3、transcript 解析 R1-2/R1-16、gh 降级可见性 R1-33、branch→number 陈旧 R1-4、归档/删除竞态 R1-26/R1-39、失败结果记忆 R1-19、绑定顺序 R1-21、import 验证
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。 🧠 Handled by Qwen Code · model/模型 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- R2-8 backfill never reserves sidecar capacity for existing bindings — dropped as overlapping the existing R1-32 comment (3836900701) at packages/cli/src/serve/routes/session-pr-backfill.ts:263
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查:反向审计——在 5 轮的反审轮数上限内未收敛。
— qwen3.8-max via Qwen Code /review (v0.22.0)
- Backfill branch-to-PR mapping is first-write-wins on the newest-first gh list, so a reused head branch binds the newest PR, not the oldest. - The over-cap slice reserves a slot for the convention (pr-<N>) number instead of evicting it first. - The refresh sweep re-checks project membership for sidecar-discovered sessions, so sanitized-cwd collisions cannot cross-rewrite sidecars. - The live-only list fast path merges the PR sidecar like the persisted paths, rendering the sweep-refreshed state instead of bind-time state. - The invalid-pr 400 message now names the state constraint; the permission-based sweep test gets the repo's win32/root guard.
|
🤖 Addressed the latest review feedback (round 2/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 2/100 轮)。改动内容与我反驳保留之处如下: Review feedback round summary — PR #9729Round outcome: 8 findings addressed in code (all 4 Critical findings plus 4 Suggestions), 14 Suggestions deferred to the next round by the per-round batch bound. No conflict resolution was requested ( Addressed findings
R1-5 — branch mapping bound the oldest PR on reused head branchesConfirmed the premise in code: the slim fetch omits R2-2 — live-only fast path rendered bind-time PR state
R2-1 — permission-based test failed on Windows and root runsThe chmod-based test R2-3 — sweep could rewrite sidecars of colliding projectsConfirmed: R1-2 — invalid-
|
| id | 级别 | 处置 |
|---|---|---|
| [rc:3837316195] R1-5 | Critical | 已修复:分支映射改为 first-write-wins + 见证测试 |
| [rc:3837316199] R2-2 | Critical | 已修复:快速路径合并 PR sidecar + 见证测试 |
| [rc:3837316201] R2-1 | Critical | 已修复:基于权限的测试加上 win32/root 守卫 |
| [rc:3837316202] R2-3 | Critical | 已修复:扫描恢复项目归属过滤 + 见证测试 |
| [rc:3837316235] R1-2 | Suggestion | 已修复:400 消息指明 state 约束 + 见证测试 |
| [rc:3837316226] R2-6 | Suggestion | 已修复:超限切片为约定编号保留槽位 + 见证测试 |
| [rc:3837316239] R2-9 | Suggestion | 已修复:种子为 closed 的兄弟用例钉住"缺页即跳过"不变量 |
| [rc:3837316206] R2-10 | Suggestion | 已修复:接缝断言钉住 { state: 'all', limit: 500, slim: true } |
R1-5 — 复用头分支时分支映射绑定到最旧 PR
已在代码中确认前提:slim 抓取不含 updatedAt,parseGhPrList 的排序是稳定的空操作,列表保持 gh 的最新在前顺序;无条件的 branchToNumber.set 使最旧 PR 赢得复用分支。映射现为 first-write-wins(!branchToNumber.has(...)),最新 PR 拥有该分支。新测试 binds the newest PR when several share one head branch 在同一条头分支上种子 [pr 250 open, pr 10 merged],断言只绑定 250。变异探针:该测试对修复前代码失败。
R2-2 — live-only 快速路径渲染绑定时刻的 PR 状态
listLiveWorkspaceSessionsForResponse 直接返回 bridge 原始行、从不读 sidecar,违反本 PR 用 liveOnlySummary 建立的不变量。快速路径现为 async,在解析后的 runtime 基目录内运行(路由传入 runtimeBaseDir 与 signal),并把每个携带 prs 的行送入与持久化路径相同的 liveOnlySummary 合并;无绑定的行保持零 I/O 形态。新测试 reads the PR sidecar for live-only sessions on the live-only fast path 种子 merged sidecar 与 open 的 live 条目,断言该行渲染为 merged。变异探针:该测试对修复前代码失败。121 个多工作区路由测试在改动后的门禁上仍全部通过。
R2-1 — 基于权限的测试在 Windows 与 root 下失败
基于 chmod 的测试 keeps sweeping archived sessions when a sidecar write fails 现采用仓库惯例 it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(与 conversation-runtime-ownership.test.ts、scratch-tree.test.ts 等同形)。无行为变化;该测试在非 root 的 POSIX 通道上仍正常运行并通过。
R2-3 — 扫描可能改写碰撞项目的 sidecar
已确认:sanitizeCwd 把所有非字母数字映射为 -,…/my-app 与 …/my.app 共享同一 chats 目录,而 sidecar 驱动的枚举丢掉了旧的 transcript 驱动路径所施加的 sessionBelongsToCurrentProject 过滤。在 core 中新增 SessionService.sessionPrSidecarBelongsToCurrentProject(sessionId, archiveState):transcript 存在时,其首记录必须通过与 listSessions 相同的归属规则;transcript 缺失则保持不可判定,使 flush 前的 sidecar 仍可刷新(这正是引入 sidecar 驱动枚举要保护的行为)。扫描在读取之前跳过外部项目的 sidecar。新测试 does not rewrite sidecars owned by a colliding project 构造两个 cwd 碰撞、共享同一基目录的 runtime,断言 { scanned: 0, updated: 0 }、未调用 gh、外部 sidecar 原样保留。变异探针:该测试对修复前代码失败。
R1-2 — 非法 state 的 400 从不提及 state
parseSessionPrBody 的拒绝消息现以 "and an optional state of open, merged, or closed" 结尾。新测试 400 message names the state constraint for an invalid pr state 以合法 number/url 提交 state: 'draft',断言 400 的 code/field 且消息包含 `state`。变异探针:该测试对修复前代码失败。bridge 校验器的孪生问题(R1-1)因不在本轮 diff 锚点范围内,维持不处理。
R2-6 — 超限切片最先丢弃约定编号
当会话解析出超过 SESSION_PR_LIST_LIMIT 个候选时,尾部切片从数组前端丢弃——而权威的 pr-<N> 约定编号恰在最前端。切片现为约定编号保留槽位,改为驱逐最旧的分支映射编号([convention, ...numbers.slice(1).slice(-(limit-1))];构造过程已去重,不会重复绑定)。新测试 keeps the convention number bound when candidates exceed the cap 种子 pr-50 slug 加 12 个分支映射 PR,断言 bound: 10, overLimit: 3 且 50 在列。变异探针:该测试对修复前代码失败。既有上限/幂等测试(无约定编号)覆盖不变的 else 分支,保持绿色。
R2-9 — "缺页即跳过"不变量缺少对变异敏感的见证
新增兄弟用例 keeps a closed binding closed when its number is missing from the gh page:种子 closed(而非 open)后,把 gh 缺页编号默认为 'open' 的回归将改写该条目并使测试变红——原先 open 种子的用例无法发现该变异。无源码变更。
R2-10 — 关键抓取参数无断言
首个回填测试现断言 fetchGitHubPullRequests 以 { state: 'all', limit: 500, slim: true } 被调用,回归为 state: 'open'(静默破坏已合入 PR 的 worktree 绑定)将令测试变红。
顺延的发现(下一轮)
仅因每轮约 8 条的批次上限顺延——四条 Critical 修复及其见证占用了本批额度——没有一条被拒绝,均仍有效且已排期:
- [rc:3837316209] R1-7 — 断言已绑定会话的 sidecar 逐字节未被改动
- [rc:3837316210] R2-11 — 扫描循环的分页(1001 会话)覆盖
- [rc:3837316212] R2-7 — 加强 writeErrors 测试(mock 拒绝、两个候选、已有 sidecar)
- [rc:3837316215] R1-37 — 路由级每-workspace 错误隔离测试
- [rc:3837316217] R1-14 — 以真实种子的受信任 workspace 做路由级测试
- [rc:3837316219] R1-3 — ssh://→https 重写时剥离 userinfo/端口
- [rc:3837316223] R1-16 — 候选判定前避免整读 transcript
- [rc:3837316224] R2-5 — 两个文件各加 draft→open 归一化测试
- [rc:3837316231] R1-19 — 缓存远端 URL 查询的失败结果
- [rc:3837316233] R2-4 — 为未解析约定编号分支补测试
- [rc:3837316237] R2-12 — liveOnlySummary 读取守卫的 EISDIR 测试
- [rc:3837316238] R1-35 — 扫描对损坏 sidecar 跳过分支的覆盖
- [rc:3837316241] R1-24 — 让
updated统计实际改写数 - [rc:3837316242] R1-11 — startSessionPrRefreshTimer 的 fake-timer 测试
增长说明:Critical 修复必须有见证测试,因此本计数窗口的测试行预算在下次度量时预计超限;新增行即见证本身,均经下方变异探针验证。
验证
本轮实际执行的命令(除注明外均在仓库根目录):
npm run build— 通过(exit 0)npm run typecheck— 通过(exit 0)npm run lint— 通过(exit 0,全仓库)- 对全部 8 个改动文件执行
npx prettier --write,最后编辑的文件再过prettier --check— 干净(提交的 pre-commit 钩子亦重跑 lint-staged 并通过) cd packages/cli && npx vitest run src/serve/routes/session-pr-backfill.test.ts src/serve/server/session-pr-refresh.test.ts src/serve/server.test.ts— 3 个文件,1122 通过(23 回填含 2 新增,16 刷新含 2 新增 + 1 守卫,1083 server 含 2 新增)cd packages/cli && npx vitest run src/serve/multi-workspace-sessions.test.ts— 121 通过(针对改动列表门禁的路由级回归网)cd packages/core && npx vitest run src/services/sessionService.test.ts src/services/session-pr-service.test.ts— 210 通过(被触碰的 core 服务)- 变异探针(还原修复前源码、保留新测试,随后恢复修复):
binds the newest PR when several share one head branch— 修复前失败(R1-5 见证)keeps the convention number bound when candidates exceed the cap— 修复前失败(R2-6 见证)does not rewrite sidecars owned by a colliding project— 修复前失败(R2-3 见证)reads the PR sidecar for live-only sessions on the live-only fast path— 修复前失败(R2-2 见证)400 message names the state constraint for an invalid pr state— 修复前失败(R1-2 见证)- 探针期间所有既有测试在修复前代码上保持绿色;恢复修复后全部套件重跑为绿
提交:b2ba72d035 fix(serve): correct PR binding map, sweep isolation, and fast-path state(8 个文件,+393/−79)。
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。
Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。
🧠 Handled by Qwen Code · model/模型 qwen3.8-max
|
@qwen-code /triage |
|
Sandboxed verification: ❌ not passed — the PR could not be built - workflow run The PR could not be built because 中文 — 判定:❌ 不通过 · PR 构建失败由于 Install/build log— Qwen Code · sandboxed verification |
yiliang114
left a comment
There was a problem hiding this comment.
Re-review at head f580c3b (new commit since my previous approval):
Both P1 blockers raised at 8e8d3fb are closed — verified against the current sources:
- Generation guard: the sweep captures the runtime's generation guard and calls
assertGenerationOpen()before the gh fetch, before each sidecar commit, and before bridge/catalog notifications (session-pr-refresh.ts:156,192,225), so a trust/env replacement retiring the runtime aborts the in-flight sweep instead of letting it write sidecars or notify the obsolete bridge. - Archive-lane serialization: backfill and refresh commits now run under the app-wide
SessionArchiveCoordinatorviarunSharedMany([sessionId], commit)(session-pr-backfill.ts:526,session-pr-refresh.ts:208; wired inserver.ts:1096,2177). The lane spans the rewrite and the live-entry sync, and archive/delete take the exclusive lane across their renames, so the check-then-rename window is gone.
The earlier Critical batch (timer clamp, tie-safe enumeration, canonical-URL identity, cap double-billing, pr-0 gate, membership guard) remains intact at this head. CI is re-running on the new head; approving on the verified fixes.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
#10220 and #9729 independently gave this suite's config mock a `getToolRegistry`, and merging them produced a clean Git merge and a broken object literal: the same key twice, which is TS1117 ("An object literal cannot have multiple properties with the same name"). PR CI does not catch it — it runs only `typecheck:integration`, not the workspace `npm run typecheck` — so it fails for the next person to run preflight rather than in a check anyone is watching. Keep #10220's copy: its comment names the call that needs the registry and the error an empty one avoids.
…ck (QwenLM#10252) QwenLM#10220 already added a getToolRegistry stub to the telemetry-swap client test's config mock; the QwenLM#9729 branch carried an identical copy, and the merge kept both. Duplicate property names in an object literal are a TS1117 error that breaks tsc --build (and thus npm run build) on main while vitest, which only transpiles, stayed green. Drop the duplicate; the mock behavior is unchanged.
The `Merge branch 'main'` in 1f670a1 brought main's TS1117 in with it: `client.telemetrySwap.test.ts` declares `getToolRegistry` twice in the same object literal, which fails `tsc` and kills `packages/core`'s build before a single test runs — the Test job dies in "Install dependencies". Not this branch's doing. Two main commits added the property independently and neither saw the other: 032b907 feat(serve): backfill session PR bindings ... (QwenLM#9729) 8241905 test(core): give the telemetry-swap client mock a getToolRegistry (QwenLM#10220) `upstream/main` at 053f17b still carries both — checking that exact file out here and running `tsc --noEmit -p packages/core` reproduces `client.telemetrySwap.test.ts(103,5): error TS1117` verbatim, so main is red on its own and every branch that merges it inherits this. Keeps QwenLM#10220's copy — it was added for this purpose and carries the explanation — and drops QwenLM#9729's incidental one. main needs the same removal; this only unblocks the branch. Verified: `tsc --noEmit -p packages/core` clean, `client.telemetrySwap.test.ts` 10 passed. Claude-Session: https://claude.ai/code/session_01M7z4PccYfDPyyfg3oGr8V1
Merging upstream/main (053f17b) clears the 9 `client.telemetrySwap` failures this branch had from predating QwenLM#10220, but main carries its own TS1117 on the same file: two commits added `getToolRegistry` to the same object literal independently and neither saw the other. 032b907 feat(serve): backfill session PR bindings ... (QwenLM#9729) 8241905 test(core): give the telemetry-swap client mock a getToolRegistry (QwenLM#10220) Checking that file out from upstream/main here and running `tsc --noEmit -p packages/core` reproduces `client.telemetrySwap.test.ts(103,5): error TS1117` verbatim, so taking the merge unmodified would have traded 9 test failures for a build that never reaches the tests at all. Removed as part of the merge rather than left for a follow-up: keeps QwenLM#10220's copy, which was added for this purpose and carries the explanation, and drops QwenLM#9729's incidental one. main still needs the same removal — this only keeps it out of the branch. Verified after the merge: `tsc --noEmit` clean for both packages/core and packages/cli; client.telemetrySwap 10 passed, Session.test.ts and acpAgent.test.ts 1260 passed together. Claude-Session: https://claude.ai/code/session_01M7z4PccYfDPyyfg3oGr8V1
Integrates the merged QwenLM#9729 backfill/refresh evolution with QwenLM#9739's gh-create binding and /review backfill source: - backfill sources: user-typed /review commands + worktree pr-<N> convention; transcript gh-create traces and bare gitBranch removed (forgery vector / measured noise) - cap planner (single locked RMW, canonical-URL identity, foreign-slot accounting) with repo-gated page consumption and async remote fallback - upsert keeps write-time shape validation and provenance source; re-bind moves to latest with fresh createdAt (planner seam detection relies on it) - summary merge stays eviction-aware; bridge re-bind canonical-URL based
|
Released in v0.22.3. |
…QwenLM#10119) * feat(review): emit the Step 3A fan-out as a generated workflow script `qwen review emit-workflow` builds the roster the same way `agent-prompt --roster` does — same plan, same `buildLaunch`, same briefs, same prompts, same recorded delivery evidence — and writes those prompts into a runnable workflow script instead of printing thirteen blocks for the orchestrator to copy. The script lives under the generated-scripts root the Workflow loader trusts since QwenLM#9987 (`$QWEN_CODE_PROJECT_DIR/workflows/ generated/review/<session>/`), so it is never a slash command and needs no cleanup sweep. The generated file is a fixed body plus three literals — the roster, the worktree pin, the subagent type. No logic is generated, only data, and the tests execute the generator's real output. A territory fan-out (Step 3B) and an unsized plan are refused before anything is written, because a workflow returns every agent through one tool result and a roster that grows with the diff is silently truncated there. Nothing routes through the command yet: the skill still builds its roster with `agent-prompt --roster`. Routing is its own change. Part of QwenLM#8769. Claude-Session: https://claude.ai/code/session_017cUwuTey4APA8wAyAM6ScS * test(review): mirror the sandbox runtime in fan-out script tests (QwenLM#10119) Address review feedback on the emit-workflow PR: - Run the generated fan-out script in a vm context that mirrors the workflow sandbox's execution shape: the meta block is stripped instead of executed, the body is wrapped in the runtime's strict-mode async IIFE, only the sandbox globals are bound, and the agent stub applies the runtime's option gates. - Extend the determinism guard to the sandbox's full Date surface (Date.parse, Date.UTC, bare Date calls). - Exercise the failed-write half of the temp-and-rename cleanup. - Cover the handler-level --rules happy path end to end. * fix(review): harden the generated fan-out path and fail closed (QwenLM#10119) Address the four review blockers on the emit-workflow PR: - Share the loader's canonical-containment policy on the write side: refuse a symlinked directory from the generated root down to the session dir, and prove the canonical session dir stays under the canonical root, before any brief, prompt record, or script is written. - Keep colliding sanitized session ids apart by appending a digest of the RAW session id to the readable prefix, so two concurrent sessions can never select the same script target for the same plan. - Canonicalize an existing plan path with realpath before hashing it, so one plan keeps one script name under divergent spellings of the same file (macOS /var vs /private/var, or a link). - Fail the fan-out whenever any required agent delivered nothing, instead of returning a shortened delivered list; a missing role is a failed step, not a shorter finding set. * test(review): canonicalize fixtures and pin the review-dir symlink guard (QwenLM#10119) * test(review): pin the emit-workflow cannot-read-the-plan guard (QwenLM#10119) * test(review): pin emit-workflow dispatch guidance and clean-tree silence (QwenLM#10119) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(core): drop the duplicated telemetry-swap mock property The `Merge branch 'main'` in 1f670a1 brought main's TS1117 in with it: `client.telemetrySwap.test.ts` declares `getToolRegistry` twice in the same object literal, which fails `tsc` and kills `packages/core`'s build before a single test runs — the Test job dies in "Install dependencies". Not this branch's doing. Two main commits added the property independently and neither saw the other: 032b907 feat(serve): backfill session PR bindings ... (QwenLM#9729) 8241905 test(core): give the telemetry-swap client mock a getToolRegistry (QwenLM#10220) `upstream/main` at 053f17b still carries both — checking that exact file out here and running `tsc --noEmit -p packages/core` reproduces `client.telemetrySwap.test.ts(103,5): error TS1117` verbatim, so main is red on its own and every branch that merges it inherits this. Keeps QwenLM#10220's copy — it was added for this purpose and carries the explanation — and drops QwenLM#9729's incidental one. main needs the same removal; this only unblocks the branch. Verified: `tsc --noEmit -p packages/core` clean, `client.telemetrySwap.test.ts` 10 passed. Claude-Session: https://claude.ai/code/session_01M7z4PccYfDPyyfg3oGr8V1 * fix(review): hermetic probe fixtures and honest fan-out failure messages (QwenLM#10119) The deterministic gate's `--changed` run collects test-efficacy.test.ts through this PR's lib/paths.ts change, and its skip-worktree guard test died on a persistent runner: the fixture's raw git calls inherited an ambient discovery redirect (GIT_INDEX_FILE reproduces the exact failure) while the guard reads a sanitized env, so the bit landed in another index and the refusal never fired. Run every fixture git call with the same sanitized env the guard uses. Address the maintainer verification of this PR: - A fan-out where EVERY agent failed prescribed re-running emit-workflow, which regenerates the identical script with the identical baked-in pin — a loop. Name the dispatch instead. - The territory refusal claimed results are "silently truncated away"; the scheduler persists large results and hands the model a pointer. Restate the real bound: the run's wall-clock caps and the fail-closed guard a per-chunk roster makes near-certain. - A refused plan no longer leaves the empty session directory (blocker check moved ahead of the mkdir). - The roster-key mismatch guard gains its missing test. Also drop the duplicate getToolRegistry the merge of main brought into client.telemetrySwap.test.ts (TS1117 broke `npm run build`; the two entries were byte-identical). * test(review): isolate test-efficacy fixtures from host git config (QwenLM#10119) --------- Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
…eport and wait out head-propagation lag (QwenLM#10117) * fix(autofix): surface thread-resolution guard refusals in the round report and wait out head-propagation lag The autofix thread-resolution pass could go completely dark on a PR: every skip condition logged only a run-log ::warning::, so on PR QwenLM#9729 0 of 90 review threads were resolved across ~16 rounds with nothing on the PR saying which guard refused (QwenLM#10106). Observability: each refusing guard now records a stable name (salvage merge, missing verified_head, verified_head mismatch, live-head drift, thread state unproven, mutation post-check ambiguous), and both round report arms carry one host-authored line naming the guard and counting the selected / resolved / left-behind threads. Healthy rounds report the positive count, so a dead mechanism is visible on the PR it fails on. The line is fixed host strings plus counters only. Root cause on QwenLM#9729: the PR read model is eventually consistent — a headRefOid read seconds after the round's own push still returns the previous head, so every pushed round declared live-head drift on one stale read and silently skipped. The initial live-head equality check now retries up to five times (bounded, digit-clamped test knob for the delay) before declaring drift; the per-mutation guards stay single-shot, because once the head was observed equal a later mismatch means it actually moved. Fixes QwenLM#10106 * fix(autofix): exact resolution counts; split drift from unreadable - Deduplicate resolved ids and subtract threads another actor resolved before the per-thread guard, so "left for a later round" reports the actual residual instead of overstating it - Name all-failed live-head reads live-head unreadable instead of live-head drift, so an expired PAT or API outage reads differently from a contributor push race on the PR-visible note - Share one id-grammar normalization between the selected-count and the resolve loop instead of two independent spellings - Pin the retry sleep and the zero-valid-id errexit guards in tests; every new guard verified by a mutation probe * fix(autofix): thread-accurate resolution counts; gate head-proof reads (QwenLM#10117) * fix(autofix): portable CR strip; thread-accurate counts on skip and break paths (QwenLM#10117) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…verify shard (QwenLM#10121) * perf(review): dedup candidates against the carried ledger before the verify shard On a re-review round the Step 3 finders re-derive findings earlier rounds already reported, and every one of them rode a verify shard before the posting layer dropped it as a duplicate — the most expensive point in the pipeline, repeated every round while the original threads stay open (measured on PR QwenLM#9729: rounds 12 and 13 confirmed 7 and 8 already-reported Suggestions each, all verified first). A new deterministic subcommand now runs between the finder union and the shard write: it matches the pooled candidates against the carried ledger — the recovered posted work list, plus the previous round's findings-artifact deferral entries when that artifact exists locally — by file, anchor proximity and claim similarity, and drops a match before any verifier is spent on it. Matching is deliberately conservative (a kept-in-error candidate just rides to verification where the posting-layer drop remains the backstop; a Critical candidate never drops against a non-Critical entry), and a dropped candidate's claim survives through the Step 6 ruling of the entry it matched. compose-review reads the report the command wrote, bound to the plan diff's hash, and discloses the set-aside count and ids in the posted body mechanically. Closes QwenLM#10105 * fix(review): harden the carried-ledger dedup against five review findings The artifact admission loop read only the D<round>-<n> id shape: a low-confidence terminal-only deferral (nothing downstream ever rules on it) or one the fix run already closed (fixed / no_change_needed) could absorb a matching candidate and vanish the claim — the unrecoverable direction the module header names. The loop now reads confidence and outcome and skips both excluded states. The irreversible drop ran before and independently of the diff hash, so an unhashable diff (fetch-pr's partition-failure fallback writes diffPathAbsolute: null) set candidates aside with no disclosure ledgerDedupFacts could ever bind to the round. The hash is now computed first and an unhashable diff keeps every candidate; the unused --out option that opened a second severance from the read side is dropped. A repeat invocation over the same candidates file also double-counted its drops in the posted disclosure; the accumulation merge now identity-dedupes on (file, line, title, matchedId). In compose-review, the set-aside disclosure shared trim rank 1 with the deferral list, so every trim surface keyed on the rank named "the deferred-findings list" and set bodyTrim.deferralList over rounds that held no deferral list at all. The block gets its own rank (1.5) with its own RANK_NAMES entry, shed after the deferral list and before the copy-less disclosures. The dedup-only APPROVE also tripped the lowSignal gate — "none of the N review agents reported a finding" over the very rounds disclosing re-derived candidates — so the carve-out now requires a zero set-aside count too. In SKILL.md the pair-transition parenthetical merged the pair's findings into the cumulative list before running the dedup, stranding every dropped candidate under its unverified tag for the tag backstop to relaunch. Both pair bullets (3A and 3B) now run the dedup before anything merges or shards, merge only the kept list, and pin the ordering with test assertions. * fix(review): harden the carried-ledger dedup against seven review findings * fix(review): exclude stand-in locations from deferral dedup and harden the report merge Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(review): fence deferral dedup to the side file's round and re-validate merged leftovers * fix(review): normalize carried paths before the stand-in exclusion in ledger dedup --------- Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…aker for the takeover loop (QwenLM#10122) * feat(autofix): consume the review convergence signals as a loop circuit breaker The review side has published a machine-readable convergence diagnosis since QwenLM#9623 (recommendation codes matched from measurement), but nothing on the autofix/takeover side consumed it: on a non-converging PR the review names the pathology round after round while the loop's own brakes (round counter, growth budgets, failure caps) bound only the loop's own telemetry — the brakes slow each side, neither can stop the pair (QwenLM#10107, measured on QwenLM#9729: first diagnosis in round 3, ~13 more rounds after it). Review CLI: the posted ledger marker now carries the round's matched recommendation codes as `rec` — written off the same derivation the result and the rendered paragraph use, shape-bounded on the streak rung, write-only (parseLedger deliberately does not read it back). Autofix workflow: a convergence-signal circuit breaker. The scan counts the trailing streak of review-bot rounds whose codes intersect CONVERGENCE_SIGNAL_CODES, bounded by max(window key, newest trusted-human activity); at CONVERGENCE_BREAK_ROUNDS (default 3, repo-var tunable) the PR parks — one bilingual notice, no dispatch, no label — and resumes when a maintainer responds (fresh N-round runway), on /retry, or on re-engagement. Prepare mirrors the reading live and discards via STALE, like the growth-audit conflict park. 'land-and-defer' is deliberately not a signal code: it is an exit, not a divergence. Tests: serializer/compose coverage for the marker field, behavioral replays of both workflow gates, a lockstep pin between them, and a vocabulary pin of the workflow's code list inside RECOMMENDATION_CODES. * fix(autofix): harden the convergence breaker per review round 1 (QwenLM#10122) Guard the streak/codes jq programs against non-object ledger marker payloads (a crash class verified on jq >= 1.7), reword the park notice to claim the union of codes it actually reports, and execute the trip branch in tests: threshold, park continue, once-per-boundary dedup, boundary arms (reviews/max/bot-login), and the mirror's rearm-key and malformed-knob fallbacks all gain mutation-probed witnesses. Also give the two slow string-processing tests explicit timeouts — the deferred- findings one timed out at the default 5s and failed the Test check. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(autofix): harden the convergence breaker per review round 2 (QwenLM#10122) * fix(autofix): restore the af-150 closing fence the round-1 insertion dropped (QwenLM#10122) * fix(autofix): harden the convergence breaker per review rounds 3-5 (QwenLM#10122) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(autofix): fail the convergence report guard closed on fetch failure (QwenLM#10122) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(autofix): re-bump the workflow size baseline after the report-guard hardening (QwenLM#10122) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
…ed concurrent spawn (QwenLM#10223) * fix(core): compensating write removes ghost members from persisted team roster When a concurrent teammate spawn fails after another spawn has already persisted the shared members array, the failed member remains in config.json as a ghost entry. Add a compensating writeTeamFile() on the rollback path so persisted membership matches committed membership. Fixes QwenLM#10208 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(core): serialize team-roster writes to close the ghost-member race window Route both the success-path write and the failed-spawn compensating write through a per-TeamManager promise-chain queue (persistTeamFile). Each queued task serializes the live roster when it runs, so commits land in call order and a stale snapshot can no longer out-commit a later compensating write and re-persist a removed member (QwenLM#10208). Also log via debug.warn when the best-effort compensating write itself fails, so a resurfaced ghost member is diagnosable. Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com> * test(core): witness roster write serialization and compensating-write failure - Add an out-of-order completion case: hold the first (stale) roster write until the failed spawn's compensating write is queued, then assert the persisted roster excludes the rolled-back member. Goes red without the persistTeamFile queue. - Add a case where the compensating write throws, asserting spawnTeammate still rejects with the original spawn error and the member is rolled back from in-memory state. - Fix the stale comment describing the pre-fix behaviour. Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com> * fix(core): drop the duplicated getToolRegistry mock key QwenLM#10220 and QwenLM#9729 independently gave this suite's config mock a `getToolRegistry`, and merging them produced a clean Git merge and a broken object literal: the same key twice, which is TS1117 ("An object literal cannot have multiple properties with the same name"). PR CI does not catch it — it runs only `typecheck:integration`, not the workspace `npm run typecheck` — so it fails for the next person to run preflight rather than in a check anyone is watching. Keep QwenLM#10220's copy: its comment names the call that needs the registry and the error an empty one avoids. * test(core): reuse TeamCoordinationHarness in ghost-member tests Replace the hand-rolled mkdtemp / teams-dir / TeamFile fixture / FakeBackend().init() / new TeamManager(...) setup in all four ghost-member regression tests with TeamCoordinationHarness.create(), matching the sibling TeamManager.plan-approval.test.ts. Assertions are unchanged; the deferred-spawn interception now targets h.backend, and teardown uses harness.cleanup(). This keeps the fixture shape in sync with the centrally maintained harness as TeamFile evolves. The triplicated Storage vi.mock factory extraction suggested in review is intentionally deferred: it spans 11+ test files and is out of scope for this PR. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * refactor(core): keep persistTeamFile and its JSDoc attached Move persistTeamFile (with its own JSDoc) above the spawnTeammate doc block so the "Spawn a new teammate..." JSDoc is no longer orphaned between the two and each comment stays attached to the method it describes. Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com> * fix(core): surface compensating-write failure to the leader Keep the debug.warn but also deliver a concise <team_error> notice through leaderMessageCallback (guarded by try/catch), mirroring the fireAndForget pattern, since debug logging alone is invisible in production. Also extract a single gateSpawns helper for the three pasted spawn-gating wrappers in the ghost-member suite, and extend the compensating-write-failure test to witness both the leader notification and that a rejected write does not poison the queue (a follow-up spawn persists normally afterwards). Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com> * fix(core): gate the failed-spawn compensating write on prior roster writes The compensating write serializes the live roster, which can still contain sibling members whose own spawnTeammate is pending. When the failed spawn is the first write to land, that persisted the in-flight sibling — a ghost member if the process exits before the sibling resolves, the QwenLM#10208 symptom in an interleaving that wrote nothing before this change. Track roster writes that have started (their snapshot point) in `teamFileWritesStarted`, capture the counter at member push, and skip the compensating write when no write could have persisted the failed member. Writes that started inside the member's window still trigger it, preserving the repair semantics. Witness test: concurrent alpha/beta spawns with deferred gates, reject alpha before any write lands, assert no roster write runs and the persisted roster stays empty; removing the gate turns the test red. The compensating-write-failure test switches to concurrent spawns so its compensating write stays necessary under the gate. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(core): snapshot team roster at the counted write start `persistTeamFile` counted a write as started before `writeTeamFile` serialized the roster: the real writer awaits `fs.mkdir` before stringifying, so a member pushed during that fs hop was persisted by a write the compensating-write gate counted as pre-push — the failed member's compensating write was wrongly skipped and the ghost member from QwenLM#10208 resurfaced on the next team load. Snapshot the roster synchronously at the counted point and hand the writer that snapshot. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(core): pin that a throwing leader notice cannot mask the spawn error The inner try/catch around the compensating-write failure notification had no coverage: every existing setLeaderMessageCallback site installs a non-throwing capture. If the callback throws while the compensating write fails, the guard keeps spawnTeammate rejecting with the original spawn error instead of the callback error. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(core): reuse getErrorMessage for the compensating-write notice The inner catch around the leader notification hand-rolled the cbErr stringification; for a thrown non-Error with a message field — a shape getErrorMessage explicitly handles — String(cbErr) logs [object Object], dropping the diagnostic trail for a failed compensating write. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: root <root@vscode-sqlx011163220057.na131> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: Qwen-Coder <qwen-coder@alibaba-cloud.com> Co-authored-by: yiliang114 <jinjing.zzj@gmail.com> Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
* feat(serve): expose workflow tasks and controls * fix(serve): align workflow capability gating * fix(cli): reject live workflow history deletion * fix(serve): start controlled workflows in background * fix(serve): address workflow control review blockers * fix(serve): make workflow history deletion race-safe * fix(serve): enforce workspace trust on the daemon Workflow surfaces (QwenLM#9546) * fix(serve): close cross-session workflow deletion races and untrusted task reads (QwenLM#9546) - Session history merge now treats a persisted snapshot as authoritative over a stale callback cache, and retires the cache once the runner confirms the snapshot write (new registry snapshot-persisted hook), so a sibling's deletion is not resurrected on refresh. - History deletion consults every sibling session's run registry (live entries and settling handles) before deleting from the shared store, and a successful deletion purges sibling terminal entries so retries cannot re-persist a deleted run. - Workflow holds mirror the registry's hasRunningEntries: paused runs no longer pin the session indefinitely. - The includeWorkflows opt-in is gated on workspace trust at the daemon boundary in both the ACP-HTTP dispatch and the REST tasks route, matching the fail-closed shape of the other workflow surfaces. * fix(cli): close the four cross-session workflow-history consistency holes Addresses R5-9, R7-4, R7-5 and R7-10 — all four Criticals open on this PR. They are one family: a run's history can be deleted, retried, or listed from any session, and each gate had a different idea of which runs exist. R5-9 — the mutation claim is task-global, not session-scoped. Keyed `sessionId\0taskId`, it serialized nothing that mattered: every session shares one snapshot store. A sibling's retry passed canStart (`failed`, no handle), took its own per-session claim, then awaited journal load/compile before `register()`; a delete-history landing in that structural window found the run terminal and handle-less in every registry, removed the journal directory and snapshot, and answered `{changed: true}` — after which the retry re-registered and its settlement re-persisted the history the user was told was deleted. The claim is now keyed by taskId alone and taken by delete-history, retry, rerun and run-saved. `run-saved` keys off a saved workflow's NAME, so it claims in its own `saved\0` namespace rather than colliding with runIds. R7-4 — deletion tests membership against the uncapped merged set. `buildSessionTasksStatus` serializes every registry entry unconditionally while `refreshWorkflowHistory` truncates to MAX_RETAINED_SNAPSHOTS by startTime, so a long run that settled after ~30 newer ones started stayed listed via the registry but fell out of the window — terminal, handle-free, live in no sibling, and permanently undeletable. `refreshWorkflowHistory` now records the merged id set before the cap, and deletion gates on that, the registry, or the unpersisted cache. `deleteWorkflowSnapshot` already tolerates an absent target, so the wider gate cannot delete what is not there. R7-5 — snapshot retirement is a latch. The registry's dispatch-drain callbacks emit status changes on TERMINAL entries with no status gate, and in-flight dispatches keep draining across the snapshot write, so a terminal emission routinely landed after `notifySnapshotPersisted` had retired the cache entry — re-inserting the run as "never persisted". A sibling's deletion was then undone by the next refresh, which reads "absent on disk, present in cache" as a pending write and republishes. Persistence is now remembered per runId and `#rememberWorkflowHistory` returns early for members; the latch releases when the runId goes active again, so a genuine re-run is still cached. R7-10 — the liveness gate sees runs whose session is gone. It iterated `this.sessions` only, but close/kill/shutdown use force semantics and a background run owns a detached controller, so after `removeStoredSessionEntry` a still-settling run was invisible to the gate and unreachable by the delete handler's sibling `removeTerminal` loop: a sibling delete-history removed the LIVE run's journal and snapshot, and the orphan's settlement write recreated it. Two halves — `Session.dispose()` now aborts its workflow registry the way it already aborts the agent registry (before the callbacks are torn down), and the registry of a removed session is retained here until its runs drain, so the gate still answers across the settlement window an abort cannot compress to zero. Retention is bookkeeping: a Config that cannot answer is logged, never turned into a shutdown failure. Regressions, each mutation-checked against its own repair: - acpAgent: a sibling's parked retry makes delete-history answer `{changed: false}` without reaching the store, and the deletion goes through once the claim releases - acpAgent: a live run's registry stays visible to the gate across its session's close, and is dropped once the handle is released - Session: a status emission after `snapshotPersisted` no longer resurrects a sibling-deleted run (reverting the latch reproduces the reviewer's probe verbatim), and a re-registered runId is remembered again - Session: a run with the oldest startTime behind 30 newer snapshots is deletable - Session: dispose aborts the workflow registry before clearing its callbacks Verification: Session.test.ts 733 passed, acpAgent.test.ts 514 passed, `tsc --noEmit -p packages/cli` clean, eslint and prettier clean. The 14 `packages/cli/src/serve` failures (fast-path import boundary, capabilities-docs contract, workspace fs/agents/memory, conversation runtime ownership) reproduce identically on the unmodified head — base skew, untouched by this change. Claude-Session: https://claude.ai/code/session_01M7z4PccYfDPyyfg3oGr8V1 * fix(core): drop the duplicated telemetry-swap mock property Merging upstream/main (053f17b) clears the 9 `client.telemetrySwap` failures this branch had from predating QwenLM#10220, but main carries its own TS1117 on the same file: two commits added `getToolRegistry` to the same object literal independently and neither saw the other. 032b907 feat(serve): backfill session PR bindings ... (QwenLM#9729) 8241905 test(core): give the telemetry-swap client mock a getToolRegistry (QwenLM#10220) Checking that file out from upstream/main here and running `tsc --noEmit -p packages/core` reproduces `client.telemetrySwap.test.ts(103,5): error TS1117` verbatim, so taking the merge unmodified would have traded 9 test failures for a build that never reaches the tests at all. Removed as part of the merge rather than left for a follow-up: keeps QwenLM#10220's copy, which was added for this purpose and carries the explanation, and drops QwenLM#9729's incidental one. main still needs the same removal — this only keeps it out of the branch. Verified after the merge: `tsc --noEmit` clean for both packages/core and packages/cli; client.telemetrySwap 10 passed, Session.test.ts and acpAgent.test.ts 1260 passed together. Claude-Session: https://claude.ai/code/session_01M7z4PccYfDPyyfg3oGr8V1 * fix(workflows): guard pending run lifecycle * fix(workflows): let a starting run be cancelled, and stop two slow leaks Four of the behavioural items the review deferred across rounds 8-9; the test-coverage-only entries stay deferred. - Cancel during the start window (R9 acpAgent.ts:10884 + workflow-runner.ts:181). Between `reserveStart` and `register` the runner loads the script and replays the journal — seconds, for a resume of a large one — and the registry has no entry yet. `sessionTaskCancel` answered `not_found` for a run the client could see starting, and `registry.cancel` could not reach the reserved controller either. New `cancelStarting` aborts it (the reservation stays the runner's to release, as after `abortAll`), and the cancel handler routes there when the liveness gate would say "starting". Doing that exposed the second half: the runner threw a bare `Error` for an abort during start, and the tool's catch only recognised the CALLER's signal — a registry-side abort surfaced as an unexplained failure. It is now a typed `WorkflowStartCancelledError`, mapped to the same "cancelled before it could start" result. - `detachedWorkflowRegistries` (R8 acpAgent.ts:3386) was pruned only inside the delete-history liveness check. A daemon that closes sessions mid-run and never deletes history retained every registry for its lifetime. Prune on session close as well. - Refresh/delete race (R9 Session.ts:3428). `refreshWorkflowHistory` reads the directory and merges without a claim; a delete landing between the read and the merge was overwritten by the stale listing and the run reappeared until the next refresh. Deletions are now sequenced, and a refresh drops any run deleted after its read began — keyed by runId and compared against the refresh's own mark, so a later retry that reuses the id is not suppressed. - The "Register a new run" JSDoc sat on `reserveStart` (R9 registry:594). Mutation-verified, all four at once against the full suites: disabling the starting-window branch, the prune-on-close, the deletion filter, and the typed-error mapping reddens exactly the four new tests and nothing else. Claude-Session: https://claude.ai/code/session_01VXsC4f71S6U6YkW82NRw7m * fix(core): close the round-5 review findings on workflow task controls - Key the starting-window cancel on a live reservation rather than on the absence of an entry: a retry reuses its runId, so its terminal entry shadowed the reservation and cancel answered `not_running` about a run that was actively starting. - Answer `changed: false` from retry when `execute()` reports a start that never registered (no `workflowRunId`), mirroring rerun and run-saved. - Classify a registry-side abort of the reserved controller as a cancel in foreground starts too, not only background ones; the tool maps `WorkflowStartCancelledError` in either mode. - Report reserved-but-unregistered runs as workflow active-work holds (`WorkflowRunRegistry.listStartingRunIds`), so a daemon conditional close cannot dispose the session under a start it just accepted. - Propagate a successful history deletion into every sibling session's deletion marker and cached history, symmetric to the `removeTerminal` sweep, so a sibling refresh that had already read the directory cannot republish the deleted run. Claude-Session: https://claude.ai/code/session_018dYE4LwSMeMPFchXk5UBdM * fix(serve): keep workflow retry and the workflowsEnabled flag consistent across sessions Two cross-session gaps in the workflow control surface: - A retry consulted only the requesting session's registry. Every session shares one journal/snapshot store and the task-global claim is released as soon as the background start returns, so a sibling whose registry still showed the run `failed` started a second runner under the same runId. Retry now refuses while the runId is live in any session (or in its own starting window), checked synchronously beside canStart so the answer cannot go stale before the claim is taken. - Workspace reload updated `tools.workflowsEnabled` for `/capabilities` but never told existing sessions; `Config.workflowsEnabled` was set once at construction. The reload's `tools` branch now propagates the flag and pushes an available-commands update when it flips. Claude-Session: https://claude.ai/code/session_01NkW1J2aBKcsKS62dkPcWbT * fix(serve): refuse to report a workflow history deletion whose registry entry survived `deleteWorkflowHistory` ignored `removeTerminal()`'s answer. The registry refuses to remove a live or handle-held entry — its own last word on whether the run is still active in this session — so a `false` for an entry that exists meant the run re-registered under the deletion and would re-persist the history the client was just told was gone. The entry is now retired before the store is touched, and a refusal fails the deletion; a persisted-only run has no entry and is unaffected. Claude-Session: https://claude.ai/code/session_01NkW1J2aBKcsKS62dkPcWbT * docs(serve): fix a stale method reference in the workflow history comment The persisted latch is released inline at the top of #rememberWorkflowHistory; no #forgetPersistedWorkflowRun exists. Claude-Session: https://claude.ai/code/session_01NkW1J2aBKcsKS62dkPcWbT * fix: report workflow lifecycle changes * fix(core): repair the workflow test doubles and converge every eviction The previous commit added a `listStartingRunIds()` call to `describeBlockingBackgroundWork` without teaching the registry test doubles about it, so seven tests across `clearCommand`, `useBranchCommand` and `useResumeCommand` threw `listStartingRunIds is not a function`. Add the reader to those stubs. Also move the eviction's status emission from `releaseHandle` into `evictTerminal` itself. Emitting only from `releaseHandle` left the same convergence gap at the four sweeping call sites that already emit: complete / fail / cancel / abortAll emit BEFORE they sweep, so a consumer that re-reads on the callback observes the pre-eviction list and keeps rendering a row that was just dropped. Emitting once after the sweep closes all five paths, and a future eviction site inherits the guarantee. New test pins the ordering; another pins that a release which evicts nothing stays silent. A starting reservation now reports `starting` rather than borrowing `running`, and its line no longer repeats the run id the bracket already carries. Claude-Session: https://claude.ai/code/session_01VENc5rZYmMJDdqvjwBEyZd * fix(cli): drop the duplicated mock keys and stop misdirecting a starting run R3-2: the previous commit's stub plumbing re-inserted a `list:` line into four `clearCommand.test.ts` registry mocks that already declared one. Last-wins made it behaviour-neutral, and neither eslint (`no-dupe-keys` is off here) nor tsc (the literal is not contextually typed, unlike the `useResumeCommand` mocks where TS1117 did fire) caught it. Remove the duplicates. R3-1: when the only blocker is a reserved-but-unregistered run, the blocked message ended with "Use /workflows to inspect them, then retry." — but `/workflows` renders `registry.list()`, which a reservation has not entered, so it named a surface that cannot show what is blocking. Track inspectability separately and fall back to a plain retry hint when nothing is listable; a registered run in the same set still points at `/workflows`. Claude-Session: https://claude.ai/code/session_01VENc5rZYmMJDdqvjwBEyZd * fix(cli): preserve starting workflow guidance * test(cli): pin background work hint guards * fix(serve): redact untrusted standalone workflow replay * test(web-shell): fix daemon SDK mock path --------- Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com> Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
What this PR does
Extends the session↔PR binding feature (#9543) in two directions. First, an on-demand daemon route backfills PR bindings onto sessions that predate the feature: for every trusted workspace it scans the persisted session catalog (active and archived), resolves each session's PR numbers from the worktree slug/branch convention and — the dominant source in practice — from the intersection of the git branches recorded in each session's transcript with the repository's PR head branches (one batched, slim
gh pr list --state allper workspace), then writes the existing per-session PR sidecars. Second, every binding now carries a merge-state snapshot (open / merged / closed): the creation dialog recordsopen, backfill records the state observed at query time, and a low-frequency daemon sweep (default 5 minutes, configurable, off-able via env) advances the snapshots by re-querying only workspaces that still hold non-merged bindings, rewriting state in place without touching binding order or timestamps. The sidebar badge dims merged PRs and the session details tooltip labels merged/closed rows.Why it's needed
Operators running dozens of concurrent sessions rely on "find the session by PR number", but sessions created before the binding feature had no binding, and even bound PRs went stale: a badge kept its open accent long after the PR merged. Real data showed the originally-planned backfill sources (worktree slug/branch convention) hit almost nothing — PRs are practically never submitted from worktree branches — while the transcript's recorded git branches intersect PR head branches for the large majority of sessions (272 of 342 in the primary workspace), so backfill now uses that intersection. The state snapshot plus the background sweep make the sidebar answer "which of my sessions produced PR N, and is it still open?" without any manual re-run and without putting network calls on the session-list polling path.
Reviewer Test Plan
How to verify
--state all/limit passthrough, MERGED/CLOSED mapping), the backfill route (convention binding, remote-URL fallback, transcript-branch binding, multi-PR binding, idempotency), the refresh sweep (open→merged rewrite, no gh call when everything is merged, gh failure swallowed, reopened-closed PR tracked back to open, interval env parsing), the list merge (sidecar state wins over the live bind-time state), and the badge (merged dimmed, open/stateless accent).qwen serveagainst a workspace with persisted sessions,POST /sessions/backfill-prs, and confirm the response reports scanned/bound counts and that the session list now returnsprswithstate; the Web Shell sidebar shows the badge and dims it once the sweep (or a manual wait past the interval) flips a binding to merged.Evidence (Before & After)
Before: 6454 persisted sessions across 25 registered workspaces carried zero bindings; the list returned no
prs. After running the backfill once on a real daemon: 575 bindings written (288 in the primary workspace, 157 in fastjson2, …), and the first page of the session list showedprson 99 of 100 rows. Before: a merged PR's badge kept the accent style and the tooltip showed no state. After: merged badges render dimmed and tooltip rows read e.g. "Pull Request #9517 · Merged" / "合并请求 #9517 · 已合入".Tested on
Environment
npm run dev -- serveagainst the operator's real daemon (25 workspaces, ~6.4k sessions) plus the repo's vitest suites; gh 2.91 authenticated.Risk & Scope
gh pr list --state all --limit 500per workspace per interval, but only for workspaces holding non-merged bindings; full-field queries at that size hit GitHub GraphQL 504s, hence the slim field set.stateis optional at every validation layer (route, bridge, SDK, sidecar reader), old sidecars withoutstatekeep working, and the sweep is disabled withQWEN_SESSION_PR_REFRESH_MINUTES=0.Linked Issues
Follow-up to #9543 (session↔PR binding).
中文说明
这个 PR 做了什么
在会话↔PR 绑定功能(#9543)基础上扩展两个方向。其一,新增按需 daemon 路由为存量会话回填 PR 绑定:遍历所有 trusted workspace 的持久化会话(active + archived),从 worktree slug/branch 约定、以及(实践中最主要的来源)会话 transcript 记录的 git 分支与仓库 PR head 分支的交集解析 PR 号(每 workspace 一次批量 slim
gh pr list --state all),写入既有的每会话 PR sidecar。其二,每个绑定现在携带合入状态快照(open / merged / closed):创建对话框记open,回填记查询时刻的状态,一个低频 daemon 定时任务(默认 5 分钟,可用环境变量配置间隔或关闭)只对仍含未合入绑定的 workspace 重新查询并原地回写状态,不改变绑定顺序与时间戳。侧栏 badge 对已合入 PR 弱化显示,会话详情 tooltip 为 merged/closed 行标注状态。为什么需要
同时运行数十个会话的操作者依赖"按 PR 号找会话",但绑定功能之前的存量会话没有绑定;即使绑定了,PR 合入后 badge 也长期保持 open 样式。真实数据显示原计划的回填来源(worktree slug/branch 约定)几乎零命中——PR 基本不从 worktree 分支提交——而 transcript 记录的 git 分支与 PR head 分支的交集能覆盖大多数会话(主 workspace 342 个会话命中 272 个),因此回填改用该交集。状态快照 + 后台刷新让侧栏无需手动重跑即可回答"哪个会话产出了 PR N,它合入了吗",且不在会话列表轮询热路径上放网络调用。
审查者测试计划
如何验证
--state all/limit 透传、MERGED/CLOSED 映射)、回填路由(约定绑定、remote URL 兜底、transcript 分支绑定、多 PR 绑定、幂等)、刷新任务(open→merged 回写、全 merged 时零 gh 调用、gh 失败静默、closed 重开回 open、间隔环境变量解析)、列表合并(sidecar state 优先于 live 绑定时 state)、badge(merged 弱化、open/无 state 保持高亮)。qwen serve,POST /sessions/backfill-prs,确认响应 scanned/bound 计数与会话列表返回带state的prs;Web Shell 侧栏显示 badge,合入后弱化。证据(前后对比)
之前:25 个注册 workspace 的 6454 个存量会话零绑定,列表无
prs。真实 daemon 跑一次回填后:写入 575 条绑定(主 workspace 288、fastjson2 157 等),列表首页 100 行中 99 行带prs。之前:已合入 PR 的 badge 保持高亮、tooltip 无状态。之后:merged badge 弱化显示,tooltip 显示"Pull Request #9517 · Merged"/"合并请求 #9517 · 已合入"。测试平台
macOS ✅;Windows/Linux⚠️ 未本地验证(无 OS 特有路径)。
环境
npm run dev -- serve操作者真实 daemon(25 workspace、约 6.4k 会话)+ 仓库 vitest 套件;gh 2.91 已认证。风险与范围
gh pr list --state all --limit 500,且仅对含未合入绑定的 workspace 发起;全字段查询在该规模下触发 GitHub GraphQL 504,故用 slim 字段。gh pr create的自动发现(需在定时器里重扫 transcript,刻意不做;对话框创建路径自绑定,覆盖主流)。Windows/Linux 未本地跑。state在所有校验层(route/bridge/SDK/sidecar 读取)均为可选,旧 sidecar 无state继续可用;QWEN_SESSION_PR_REFRESH_MINUTES=0可关闭定时刷新。关联
#9543(会话↔PR 绑定)的后续。