Skip to content

feat(serve): add pollable daemon turn status - #9080

Open
BenGuanRan wants to merge 5 commits into
QwenLM:mainfrom
BenGuanRan:feat/daemon-turn-status-polling-v2
Open

feat(serve): add pollable daemon turn status#9080
BenGuanRan wants to merge 5 commits into
QwenLM:mainfrom
BenGuanRan:feat/daemon-turn-status-polling-v2

Conversation

@BenGuanRan

@BenGuanRan BenGuanRan commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This PR adds an always-on session_turn_status capability and two read-only, live-Session routes: GET /session/:id/turns/current and GET /session/:id/turns/:promptId. Callers can poll idle, queued, running, completed, cancelled, or error without maintaining an SSE subscription.

The exact prompt route returns the raw final parent-model answer from the last tool-free response block. Text before a tool call, tool output, thought text, subagent updates, diagnostics, background output, and optional rewritten presentation are not reported as resultText. Results are bounded at 32,768 UTF-16 code units and expose resultTruncated: true plus RESULT_TEXT_TRUNCATED when that bound is reached.

Live state comes from the owning bridge. Recent terminals use a fixed 64-entry in-process overlay while settled turns are appended once, best-effort, by the Session recorder and read from a bounded active-transcript window. A successful rewind clears the overlay, failed rewind keeps it, and forks do not inherit source prompt identities.

Why it's needed

Automation clients such as AgentRun receive a promptId from non-blocking prompt admission but currently need to keep an SSE stream open to learn the final state and main answer. This makes short-lived or reconnecting callers unnecessarily complex. The polling surface lets them recover the result for a live Session while preserving workspace ownership and client authorization.

This PR intentionally supersedes #8682 instead of extending it. That PR validated the problem and several important correctness cases, but after many review rounds its diff grew into strict teardown persistence, crash/shutdown transcript backfill, rewind indexing, rewrite-pipeline changes, and repeated conflict resolution. Those changes exceeded the requested polling contract and made review convergence harder. This is a clean rebuild from the latest main: it retains the validated API, final-answer semantics, bounded live/persisted lookup, and critical race fixes, while explicitly leaving crash durability and permanent result storage out of scope.

Reviewer Test Plan

How to verify

  1. Start the built daemon without additional flags and confirm /capabilities contains session_turn_status.
  2. Create a live Session, submit a non-blocking prompt, and poll its returned promptId; expect queued/running while live and a settled terminal afterward.
  3. Use a model response that emits visible text plus a tool call, then a final answer; expect resultText to contain only the answer after the tool boundary.
  4. Return more than 32,768 UTF-16 code units; expect the bounded prefix, resultTruncated: true, and resultCode: "RESULT_TEXT_TRUNCATED".
  5. Query an unknown promptId; expect 404 prompt_not_found. Confirm this is a bounded not-found result, not proof that the prompt never existed.
  6. Rewind a Session successfully and confirm discarded overlay results are not returned; force rewind failure and confirm the overlay is retained.
  7. Confirm a fork cannot query the source Session's promptIds and that a Session owned by another workspace runtime is never resolved through the primary runtime.

Evidence (Before & After)

Before: the installed global qwen 0.18.5 does not advertise session_turn_status and has no turn polling route.

After: a built daemon backed by the repository's fake OpenAI server passed capability discovery, the tool-boundary final-answer E2E, and the 32,768-code-unit truncation E2E. Full changed-file suites passed: Session 611/611, ACP agent 400/400, ACP bridge 593/593, core recording/session service 215/215, conversation branches 21/21, and serve server 938/938. Repository build, bundle, lint, and workspace typecheck also passed.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

macOS, Node.js 22-compatible repository toolchain, no sandbox, real qwen serve process with the local fake OpenAI-compatible server.

Risk & Scope

  • Main risk or tradeoff: settled lookup is deliberately bounded and best-effort. The live bridge overlay holds 64 terminals; transcript lookup scans at most 10 backward pages of 500 active records.
  • Deadline consistency boundary: while present, the 64-entry live overlay's caller-facing deadline terminal is authoritative. A child that settles later can persist a different execution outcome; after overlay eviction or restart, bounded transcript lookup may expose that child outcome. This PR intentionally does not add durable terminal reconciliation or a permanent task-result ledger.
  • Not validated / out of scope: no permanent or exactly-once result store; no daemon-side transcript writer, strict close/kill barrier, crash/shutdown backfill, offline Session lookup, deleted-JSONL recovery, or message-rewrite refactor. Restart lookup requires recording to be enabled, the append to succeed, the result to remain active and inside the bounded window, and the Session to be loaded live again.
  • Breaking changes / migration notes: none. The capability and routes are additive and require no configuration.

Linked Issues

Related to #8680

Supersedes #8682

中文说明

本 PR 做了什么

本 PR 新增默认开启的 session_turn_status 能力点,以及两个只读、仅面向存活 Session 的接口:GET /session/:id/turns/currentGET /session/:id/turns/:promptId。调用方无需保持 SSE 订阅,即可轮询 idlequeuedrunningcompletedcancellederror 状态。

精确 prompt 接口返回父模型最后一个不含工具调用的响应块中的原始最终回答。工具调用前的文本、工具输出、思考文本、subagent 更新、诊断信息、后台输出以及可选的改写展示文本都不会进入 resultText。结果上限为 32,768 个 UTF-16 code units,达到上限时返回 resultTruncated: trueRESULT_TEXT_TRUNCATED

实时状态来自 Session 所属的 bridge。最近的终态使用固定 64 条的进程内 overlay;已结束的 turn 由 Session recorder 单次、best-effort 写入,并从有界的 active transcript 窗口读取。rewind 成功后清空 overlay,rewind 失败时保留;fork 不会继承源 Session 的 prompt 身份。

为什么需要

AgentRun 等自动化调用方从非阻塞 prompt admission 获得 promptId 后,目前必须持续保持 SSE 才能获知最终状态和主回答,这让短生命周期或需要重连的调用方承担了不必要的复杂度。新增轮询接口让它们可以在 live Session 范围内恢复结果,同时保持工作空间归属与 client 授权边界。

本 PR 有意替代 #8682,而不是继续扩展它。#8682 已验证问题和多项重要正确性场景,但经过多轮评审后,其 diff 逐步扩展到了严格 teardown 持久化、crash/shutdown transcript 回填、rewind 索引、rewrite pipeline 改造以及反复的冲突处理。这些内容已经超出本次轮询契约,并提高了评审收敛难度。因此本 PR 基于最新 main 重新最小实现:保留已验证的 API、最终回答语义、有界实时/持久化查询和关键竞态修复,同时明确不处理 crash durability 和永久结果存储。

Reviewer 测试计划

如何验证

  1. 不增加任何参数启动构建后的 daemon,确认 /capabilities 包含 session_turn_status
  2. 创建 live Session,提交非阻塞 prompt,使用返回的 promptId 轮询;执行期间应看到 queued/running,结束后应看到终态。
  3. 让模型先输出可见文本并调用工具,再输出最终回答;resultText 应只包含工具边界之后的回答。
  4. 返回超过 32,768 个 UTF-16 code units;应获得有界前缀、resultTruncated: trueresultCode: "RESULT_TEXT_TRUNCATED"
  5. 查询未知 promptId;应返回 404 prompt_not_found。该结果仅表示在有界范围内未找到,并不证明 prompt 从未存在。
  6. 成功 rewind Session 后,已丢弃的 overlay 结果不应再返回;模拟 rewind 失败时,overlay 应保留。
  7. 确认 fork 无法查询源 Session 的 promptId,并确认属于其他 workspace runtime 的 Session 不会回退到 primary runtime 查询。

证据(Before & After)

Before:本机全局安装的 qwen 0.18.5 不会发布 session_turn_status,也没有 turn 轮询接口。

After:构建后的真实 daemon 使用仓库内 fake OpenAI server,已通过能力点发现、“工具边界后最终回答”E2E 和“32,768 code units 截断状态”E2E。完整变更文件测试均通过:Session 611/611、ACP agent 400/400、ACP bridge 593/593、core recording/session service 215/215、conversation branches 21/21、serve server 938/938。仓库 build、bundle、lint 和 workspace typecheck 也全部通过。

测试系统

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

macOS,兼容 Node.js 22 的仓库工具链,无 sandbox,真实 qwen serve 进程配合本地 fake OpenAI-compatible server。

风险与范围

  • 主要风险或取舍:settled 查询有意设计为有界且 best-effort。live bridge overlay 保存 64 条终态;transcript 查询最多向后扫描 10 页、每页 500 条 active records。
  • Deadline 一致性边界:在 64 条 live overlay 保留期间,面向调用方的 deadline 终态是权威结果。child 迟到结束时可能持久化不同的执行结果;overlay 淘汰或重启后,有界 transcript 查询可能返回该 child 结果。本 PR 有意不引入 durable terminal reconciliation 或永久任务结果账本。
  • 未验证 / 不在范围内:不提供永久或 exactly-once 结果存储;不新增 daemon transcript writer、严格 close/kill barrier、crash/shutdown 回填、offline Session 查询、JSONL 删除恢复或 message rewrite 重构。重启后查询要求 recording 已开启、append 成功、结果仍在 active branch 且位于有界窗口内,并且 Session 已重新加载为 live。
  • 破坏性变更 / 迁移说明:无。能力点与接口都是新增项,无需配置。

关联 Issue

Related to #8680

Supersedes #8682

Add GET /session/:id/turns/current and GET /session/:id/turns/:promptId
so external callers can poll a turn's lifecycle state (queued / running /
completed / cancelled / error) and result instead of holding the SSE
stream for the whole turn lifetime.

- Live state comes from the bridge's pending prompt queue; settled
  outcomes from persisted turn_result transcript records, so results
  survive daemon restarts and the daemon keeps no per-turn memory
- Each prompt captures its own recording and settles exactly that one,
  so overlapping turns (DAEMON-003 deadline overlap) can never
  misattribute one turn's outcome to another promptId
- Enforces the same client authorization as POST /session/:id/prompt

Refs QwenLM#8680
@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

E2E test report

Tested the final bundled CLI on macOS with QWEN_SANDBOX=false, a real qwen serve child process, and the repository's local fake OpenAI-compatible server.

  • Capability discovery: session_turn_status is advertised without a flag or setting.
  • Final-answer contract: a model response containing visible pre-tool text plus read_file, followed by a final model response, completed with resultText equal to only The strict final answer is 42..
  • Result bound: a response longer than 32,768 UTF-16 code units completed with a 32,768-code-unit resultText, resultTruncated: true, and resultCode: RESULT_TEXT_TRUNCATED.
  • Focused integration result: 2 passed, 0 failed.

Additional verification on the final source:

  • Session: 611 passed.
  • ACP agent: 400 passed.
  • ACP bridge: 593 passed.
  • Core recording/session service: 215 passed.
  • Conversation branches: 21 passed.
  • Serve server: 938 passed.
  • Build, bundle, lint, workspace typecheck, and git diff --check: passed.

Known boundary: this report does not claim crash/shutdown backfill, deleted-JSONL recovery, offline Session lookup, or permanent result retention. Those are explicitly outside this PR.

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Aug 13, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — and for the disciplined scope reduction compared to PR 8682.

Template looks good ✓

Problem: real and grounded. Since POST /session/:id/prompt became non-blocking (202 + promptId, PR 4585), callers have had no way to learn a turn's terminal state and final answer without holding the SSE stream open. Linked issue #8680 (already triaged, labeled roadmap/background-automation) documents the gap with a concrete response contract, and superseded PR 8682 validated it through many review rounds. This is an observed API-surface gap, not theoretical hardening.

Direction: aligned. Issue #8680 was accepted for exploration in triage; a polling surface is the direct complement of the merged non-blocking admission — read-only and additive, no behavior change for existing clients. CHANGELOG has no direct turn-status precedent, but the background-automation direction is active and this fits it.

Size: core paths are touched (core services/utils + cli serve/acp-integration + acp-bridge, cross-package). Roughly 1,076 production lines vs ~1,931 test lines vs ~46 doc lines. That crosses both the 500-line maintainer-awareness bar and the 1,000-line large-PR advisory, so this is flagged for maintainer attention. Mitigating context: this is the deliberately minimal rebuild after PR 8682 grew to ~7,900 lines, with crash durability and permanent result storage explicitly cut. Splitting further doesn't look natural — the Session settle hooks, bridge overlay, and routes form one contract.

Approach: matches what I'd propose independently — capability flag, two GET routes scoped to the live owning runtime with the same client-id auth as /prompt, live queue plus a bounded 64-entry terminal overlay in the bridge, best-effort bounded backward transcript scan for settled turns, and final answer = last tool-free parent-model response block capped at 32,768 UTF-16 code units. Two housekeeping points: (1) PR 8682 is still open — it should be closed in favor of this one; (2) the getPendingPrompts filter gains a !terminalPublished condition, a small behavior change to an existing route — it looks consistent with "pending" semantics and is detailed in the code review.

Risk: Stage 1e matches the acp-integration paths (acpAgent.ts, session/Session.ts), which this repo's revert history flags as elevated-risk. Not a blocker, but review depth is full, CI evidence is required before approval, and a sandboxed lane should be named before merge.

Moving on to code review. 🔍

中文说明

感谢贡献——也感谢相比 PR 8682 所做的严格的范围收敛。

模板完整 ✓

问题:真实且有依据。自 POST /session/:id/prompt 改为非阻塞(202 + promptId,PR 4585)之后,调用方若不保持 SSE 长连接就无法获知轮次终态与最终回答。关联 issue #8680(已完成 triage,带 roadmap/background-automation 标签)给出了具体的响应契约,被替代的 PR 8682 也经过多轮评审验证了该问题。这是已观测到的 API 面缺口,而非理论性加固。

方向:对齐。issue #8680 在 triage 中已"接受探索";轮询接口是已合入的非阻塞 admission 的直接补充——只读、增量,不改变现有客户端行为。CHANGELOG 没有完全对应的先例,但 background-automation 方向是活跃的,本 PR 契合该方向。

规模:触及核心路径(core services/utils + cli serve/acp-integration + acp-bridge,跨包)。约 1,076 生产行,对比约 1,931 测试行、约 46 文档行。同时越过 500 行"维护者关注"线与 1,000 行大 PR 建议线,因此标记请维护者关注。缓解背景:本 PR 是在 PR 8682 膨胀到约 7,900 行之后刻意做的最小重建,crash durability 与永久结果存储已被明确砍掉。进一步拆分看起来不自然——Session settle 钩子、bridge overlay 与路由共同构成一个契约。

方案:与我独立的设想一致——能力点、两个仅面向 live owning runtime 且复用 /prompt client-id 鉴权的 GET 路由、bridge 内实时队列 + 有界 64 条终态 overlay、对已落盘终态做有界的 transcript 回扫,以及"最后一个不含工具调用的父模型响应块"作为最终回答、上限 32,768 个 UTF-16 code units。两个事务性提醒:(1) PR 8682 仍处于 open 状态——应关闭它以让位给本 PR;(2) getPendingPrompts 的过滤条件新增了 !terminalPublished,属于对现有路由的小行为变更——看起来与 "pending" 语义一致,详见代码审查。

风险:Stage 1e 命中 acp-integration 路径(acpAgent.tssession/Session.ts),在本仓库的 revert 历史中属于较高风险区。不构成阻塞,但 review 深度为全量、批准前需要 CI 证据,且合入前应指定沙箱验证通道。

进入代码审查 🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Code review

I formed an independent proposal before reading the diff (capability flag, two live-runtime-scoped GET routes, live queue + bounded terminal overlay, bounded transcript scan for settled turns, last-tool-free-block final answer). The PR matches it, and goes further than I would have on the hard parts: the bridge re-reads the overlay after every awaited child read — including failed ones — so a terminal published mid-lookup can never regress to queued or 404, and terminal publication is first-writer-wins. Those races have dedicated tests, not just assertions in prose.

No critical blockers found. What I verified against the surrounding code:

  • Route registration order is correct (/turns/current before /turns/:promptId), resolution goes through the live owning runtime with no primary-runtime fallback, and client-id authorization reuses the same resolveTrustedClientId path as /prompt.
  • The backward transcript scan reads pages newest-first and iterates each page from the tail, so the first turn_result match really is the newest; structured transcript errors (invalid cursor, snapshot unavailable, oversized page/snapshot) stay structured instead of collapsing into prompt_not_found.
  • Session is the only transcript writer; recordTurnResult uses the non-strict append path and never perturbs the turn lifecycle. normalizeTurnResultError reads message/code through guarded property access, so hostile getters can't throw during settlement.
  • The riskiest hunk is the Session.prompt() restructure needed to settle the recording on every exit path. I walked old vs new path by path: semantics are preserved (cleanup errors still override a successful result, releasePendingSend call sites unchanged), and the new settle hooks cover validation failures, admission cancellation, goal-reservation failures, thrown errors, and user-cancel classification.
  • Forks exclude turn_result records, so a forked Session can't inherit source prompt identities; turn_result joins NEUTRAL_TAIL_SUBTYPES so tail records don't skew branch classification.

Two observations worth a maintainer's eye, neither blocking:

  • Drive-by on an existing route: getPendingPrompts now also filters out entries whose terminal was already published. That looks consistent with "pending" semantics (and matches the new liveTurnStatus filter), but it does change what webui/web-shell queue reconciliation sees. It's covered indirectly by the existing bridge tests, just calling it out explicitly.
  • Non-blocking notes: the 6-line truncateTurnText helper is duplicated in bridge and Session (acceptable at this size); no SDK client helper yet — integration tests use raw fetch, which is fine for now; truncation slices at UTF-16 code units, so an astral character straddling the 32,768 boundary splits — documented contract, cosmetic.

The flow being added, for reviewers navigating the diff:

sequenceDiagram
    participant P1 as Client
    participant P2 as serve route
    participant P3 as Bridge
    participant P4 as ACP child
    participant P5 as Transcript JSONL
    P1->>P2: GET turns by promptId or current
    P2->>P3: getSessionTurnStatus (client-id auth)
    P3->>P3: check live queue and 64-entry terminal overlay
    alt not resolved live
        P3->>P4: ext sessionTurnStatus
        P4->>P5: bounded backward scan (10 pages of 500)
        P5-->>P4: turn_result record or null
        P4-->>P3: payload
    end
    P3->>P3: re-check overlay, concurrent terminal wins
    P3-->>P2: status or not found
    P2-->>P1: 200 status or 404 prompt_not_found
Loading
Files changed (25 of 25 shown)
File What changed
docs/design/daemon-turn-status-endpoint.md New design doc: scope, result semantics, live vs persisted sources, non-goals
docs/developers/qwen-serve-protocol.md Protocol doc paragraph for the two polling routes
integration-tests/cli/qwen-serve-routes.test.ts Capability envelope now expects session_turn_status
integration-tests/cli/qwen-serve-streaming.test.ts Real-daemon E2Es: tool-boundary final answer and 32,768 truncation
packages/acp-bridge/src/bridge.test.ts 733 lines covering overlay, races, queued/running, rewind, auth
packages/acp-bridge/src/bridge.ts getSessionTurnStatus, terminal overlay (64), startedAt, rewind clears overlay
packages/acp-bridge/src/bridgeTypes.ts BridgeTurnStatus type, startedAt on PendingPromptEntry, bridge method
packages/acp-bridge/src/status.ts New ext method name qwen/control/session/turn_status
packages/cli/src/acp-integration/acpAgent.test.ts Tests for the child-side ext handler and bounded scan
packages/cli/src/acp-integration/acpAgent.ts Ext handler: flush recorder, bounded backward scan, structured errors
packages/cli/src/acp-integration/session/Session.test.ts 575 lines on turn_result recording across settle paths
packages/cli/src/acp-integration/session/Session.ts Turn recording hooks, final-answer capture reuse, prompt() settle restructure
packages/cli/src/serve/acp-session-bridge.ts Re-exports BridgeTurnStatus
packages/cli/src/serve/capabilities.ts Registers session_turn_status capability
packages/cli/src/serve/routes/session.ts The two GET routes: resolution, auth, 404 prompt_not_found
packages/cli/src/serve/server.test.ts Route tests: 200 current, 200 by id, 404 unknown prompt/session, 400 bad client
packages/cli/src/serve/server/telemetry-catalog.test.ts Drift guard count 54 to 56
packages/cli/src/serve/server/telemetry.ts Registers both routes in the telemetry catalog
packages/core/src/services/chatRecordingService.test.ts recordTurnResult, hostile error normalization, payload validation
packages/core/src/services/chatRecordingService.ts turn_result subtype, bounded payload type, validator, best-effort append
packages/core/src/services/sessionService.test.ts Fork must not copy source turn_result identities
packages/core/src/services/sessionService.ts Fork filter drops turn_result records
packages/core/src/utils/conversation-branches.test.ts turn_result treated as neutral tail
packages/core/src/utils/conversation-branches.ts Adds turn_result to NEUTRAL_TAIL_SUBTYPES
packages/core/src/utils/transcript-records.ts Registers turn_result as a known subtype

Test evidence

The PR's own CI at the reviewed commit, fetched via API (per the static-review rule I did not build or run any PR code; the live-behavior lane is the sandboxed trigger below). Real daemon E2E / Java 11 passing is the meaningful early signal — it exercises a real daemon harness. The Node unit suite and the Serve A/B integration run (which is where the new qwen-serve-streaming E2Es execute) are still in progress; the finalize workflow updates the table below in place once CI settles. macOS/Windows unit jobs are skipped by the matrix on this run — noted as-is, not a finding.

Final CI results for 9c726c7 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Test (ubuntu-latest, Node 22.x) ❌ failure
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
macos-latest / Java 21 ✅ success
Real daemon E2E / Java 11 ✅ success
Serve A/B (ubuntu-latest, Node 22.x) ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
windows-latest / Java 21 ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

The E2E report in this thread (capability discovery, tool-boundary final answer, truncation bound) is the author's self-reported result on macOS — attributed as a claim, not re-run here.

Sandboxed verification would settle this: @qwen-code /verify — the central claims are behavioural (resultText returns only the post-tool-boundary parent answer, the 32,768 truncation bound and RESULT_TEXT_TRUNCATED code hold end-to-end, overlay/rewind semantics survive a real daemon), and while the PR's own suite covers these against a fake OpenAI server, a suite can pass with the very code it was written next to; an A/B run against the base build would prove the new routes actually pin the behaviour. The author has write access, so a maintainer (or the author) can trigger it directly on this head.

中文说明

代码审查

读 diff 之前我先独立写了方案(能力点、两个仅限 live runtime 的 GET 路由、实时队列 + 有界终态 overlay、有界 transcript 回扫、"最后一个无工具调用响应块"作为最终回答)。PR 与之吻合,而且在最难的地方做得更细:bridge 在每次 await 子进程读取之后(包括读取失败时)重新检查 overlay,因此 lookup 中途出现的终态不会回退成 queued404;终态发布是 first-writer-wins。这些竞态都有专门的测试。

未发现关键阻塞项。对照周边代码核实过的点:

  • 路由注册顺序正确(/turns/current 先于 /turns/:promptId),解析走 live owning runtime、不回退 primary runtime,client-id 鉴权复用 /prompt 的 resolveTrustedClientId
  • transcript 回扫按页从新到旧、页内从尾向头迭代,首个匹配的 turn_result 确实是最新的;结构化 transcript 错误(非法 cursor、快照不可用、页/快照超限)保持结构化,不会塌缩成 prompt_not_found
  • Session 是唯一的 transcript 写入者;recordTurnResult 走非严格追加路径,绝不影响轮次生命周期。normalizeTurnResultError 用受保护的属性读取,恶意 getter 不会在 settle 时抛错。
  • 风险最高的是 Session.prompt() 为在所有退出路径上 settle 记录而做的重构。逐路径对比新旧语义:保持一致(清理错误仍然会覆盖成功结果,releasePendingSend 调用点不变),新的 settle 钩子覆盖校验失败、admission 取消、goal 预留失败、抛错与用户取消分类。
  • fork 排除 turn_result 记录,fork 出的 Session 不会继承源 prompt 身份;turn_result 加入 NEUTRAL_TAIL_SUBTYPES,尾部记录不影响分支分类。

两点提请维护者留意,均不阻塞:

  • 对现有路由的顺手变更getPendingPrompts 现在还会过滤掉终态已发布的条目。与 "pending" 语义一致(也与新的 liveTurnStatus 过滤一致),但确实改变了 webui/web-shell 队列对账看到的内容。现有 bridge 测试间接覆盖,此处显式指出。
  • 非阻塞备注:6 行的 truncateTurnText 在 bridge 与 Session 各有一份(此规模可接受);SDK 暂无对应客户端方法——集成测试直接用 fetch,当前可接受;截断按 UTF-16 code units 切片,恰好跨 32,768 边界的 astral 字符会被切开——契约已注明,属外观问题。

新增流程(对应英文版时序图):客户端 → serve 路由 → bridge:先查实时队列与 64 条终态 overlay;未命中则经 ext 方法调 ACP 子进程做有界回扫(10 页、每页 500 条);读取返回后重查 overlay(并发终态优先),最后返回状态或 404。

测试证据

以上为被审提交在 CI 上的真实状态(API 拉取;按静态审查规则未构建/运行任何 PR 代码)。Real daemon E2E / Java 11 通过是早期最有意义的信号。Node 单测套件与 Serve A/B 集成(新 E2E 所在)仍在进行,finalize 工作流会在 CI 结束后就地更新表格。macOS/Windows 单测本次被矩阵跳过,如实记录、不作为发现项。

线程中的 E2E 报告(能力点发现、工具边界最终回答、截断上限)是作者自述的 macOS 结果——作为声明引用,未在此复核。

沙箱验证可以定案:@qwen-code /verify —— 核心主张是行为性的(resultText 只返回工具边界之后的父模型回答、32,768 截断与 RESULT_TEXT_TRUNCATED 端到端成立、overlay/rewind 语义在真实 daemon 上成立);PR 自带测试基于 fake OpenAI server,而测试与代码同批编写可能同错,对 base 构建的 A/B 运行才能证明新路由真正钉住了行为。作者有写权限,维护者(或作者)可在当前 head 上直接触发。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — clean review and unusually thorough tests, but this is a 1,000+ production-line feature across core session/recording internals, so the Stage 0 maintainer-awareness escalation caps auto-approval; it needs a human sign-off, not bot doubt.

Stepping back: this is the version of PR 8682 that should have landed. The rebuild restored scope discipline — 7.9k lines down to 3k, explicit non-goals instead of teardown persistence and crash backfill creeping in — and the result is a contract a reviewer can actually hold in their head. The approach matches my independent proposal and exceeds it exactly where daemon code usually hurts: the races between polling, settlement, and transcript visibility are handled deliberately and tested individually. If I were maintaining this in six months I'd thank the author, not curse them — the design doc, the bounded everything, and the first-writer-wins publication are the kind of care that ages well.

Why not approve, then:

  • Policy cap, not findings: ~1,076 production lines across packages/core services, cli acp-integration/serve, and acp-bridge puts this past the maintainer-awareness bar, so the call belongs to a maintainer regardless of how clean the review reads.
  • CI is not settled yet: the Node unit suite and the Serve A/B integration run (where the new serve E2Es execute) are still in progress at the reviewed commit; Real daemon E2E and precheck are green so far.
  • Elevated-risk paths: acp-integration (acpAgent.ts, session/Session.ts) is correlated with post-merge reverts in this repo's history, and the Session.prompt() settle restructure — faithful as it is — concentrates that risk.

⏸️ Deferring to @wenshao — the review itself found no blockers (Stage 2), but a core-surface feature of this size warrants a maintainer's sign-off on the contract before merge. Two housekeeping asks for @BenGuanRan: close PR 8682 in favor of this one, and consider running @qwen-code /verify on this head to settle the behavioural claims (final-answer boundary, truncation, overlay/rewind) with A/B evidence. Once CI is green and a maintainer has weighed in, this looks ready.

中文说明

置信度:3/5 —— review 干净、测试异常充分,但这是一个横跨 core session/recording 内部、1,000+ 生产行的 feature,Stage 0 的"维护者关注"升级决定了不能自动批准;这是流程要求,而非 review 存疑。

整体来看:这才是 PR 8682 本该落地的形态。重建恢复了范围纪律——从 7.9k 行收敛到 3k 行,用明确的 non-goals 取代了逐步膨胀的 teardown 持久化与 crash 回填——最终契约是评审者能完整把握的。方案与我的独立设想一致,并且恰好在 daemon 代码最容易出问题的地方做得更好:轮询、settle 与 transcript 可见性之间的竞态被刻意处理并逐一测试。半年后维护这段代码,只会感谢作者——设计文档、处处有界、first-writer-wins 发布,都是经得起时间的细致。

为什么不直接批准:

  • 流程上限,而非发现项:约 1,076 生产行横跨 packages/core services、cli acp-integration/serveacp-bridge,越过维护者关注线,合入与否应由 maintainer 决定。
  • CI 尚未收敛:被审提交上 Node 单测套件与 Serve A/B 集成(新 serve E2E 所在)仍在运行;Real daemon E2E 与 precheck 已通过。
  • 高风险路径acp-integrationacpAgent.tssession/Session.ts)与本仓库合入后 revert 的历史相关;Session.prompt() 的 settle 重构虽然语义忠实,仍是风险集中点。

⏸️ 转交 @wenshao —— review 本身未发现阻塞项(见 Stage 2),但此规模的核心面 feature 在合入前应有 maintainer 对契约的确认。请 @BenGuanRan 处理两件事务:关闭 PR 8682 让位给本 PR;考虑在当前 head 上运行 @qwen-code /verify,用 A/B 证据定案行为性主张(最终回答边界、截断、overlay/rewind)。CI 转绿且维护者确认后,本 PR 看起来可以合入。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head bbf2443, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

capabilities

field PR base (before) this PR (after)
features[] "session_turn_status"

Qwen Code · serve A/B

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

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite (integration-tests/cli, incl. the new turn-status E2Es) did not run locally.

Not explored to full depth (tool budget reached): "This PR adds pollable daemon turn-status routes to qwen…": none — all checks above completed within budget.; "This PR adds pollable daemon turn-status routes to qwen…": none — all checks above completed within budget.; "This PR adds pollable daemon turn-status routes to qwen…": did not benchmark the record-count at which finding 1's collapse becomes reachable in a real session transcript (mechanism verified by code trace only).; "This PR adds pollable daemon turn-status routes to qwen…": I did not benchmark cold index build time against the 10s budget on a real transcript (no node_modules in the worktree / out of budget) — this is why Finding 1 …; "This PR adds pollable daemon turn-status routes to qwen…": did not benchmark cold buildIndex wall-time against the 10s budget on a real large transcript (worktree has no installed deps) — this is why the finding below…, and 21 more.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite (integration-tests/cli, incl. the new turn-status E2Es) did not run locally。

未探索到全部深度(达到工具调用预算):"This PR adds pollable daemon turn-status routes to qwen…"none — all checks above completed within budget."This PR adds pollable daemon turn-status routes to qwen…"none — all checks above completed within budget."This PR adds pollable daemon turn-status routes to qwen…"did not benchmark the record-count at which finding 1's collapse becomes reachable in a real session transcript (mechanism verified by code trace only)."This PR adds pollable daemon turn-status routes to qwen…"I did not benchmark cold index build time against the 10s budget on a real transcript (no node_modules in the worktree / out of budget) — this is why Finding 1 …"This PR adds pollable daemon turn-status routes to qwen…"did not benchmark cold buildIndex wall-time against the 10s budget on a real large transcript (worktree has no installed deps) — this is why the finding below…,另有 21 条。

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

Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/acp-bridge/src/bridge.ts Outdated
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment on lines +9753 to +9755
if (terminal && persisted) {
return enrichTerminalTurnStatus(terminal, persisted);
}

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-4: the overlay/persisted merge assumes both sources agree on a turn's terminal state, but the prompt-deadline path guarantees disagreement: the bridge latches state:'error'/prompt_deadline_exceeded in the overlay (first-writer-wins), while the child — with zero deadline awareness (no deadlineMs references in acp-integration) — persists its own completed/cancelled record for the same promptId. — Failure scenario: probe-reproduced — a wedged agent exceeds deadlineMs; the bridge latches the error terminal and releases the FIFO; the agent later completes and the child persists completed. During the overlay window the poll returns state:'error' enriched with the completed answer's resultText (a self-contradictory payload); after 64-entry eviction or daemon restart, the identical poll returns state:'completed'. One promptId flips error→completed purely as a function of poll timing; the "not an exactly-once store" disclaimer answers loss (404), not contradiction.

Suggested direction: reconcile the deadline case explicitly — prefer the persisted state once it exists and surface the deadline as metadata (or document that error/prompt_deadline_exceeded is superseded once the transcript settles). At minimum, never emit state:'error' enriched with a successful resultText.

中文说明

overlay/持久化的合并假设两个来源对某轮终态一致,但 prompt-deadline 路径必然造成不一致:bridge 以 first-writer-wins 在 overlay 中锁定 state:'error'/prompt_deadline_exceeded,而子进程完全无 deadline 概念(acp-integration 中没有任何 deadlineMs 引用),会为同一个 promptId 落盘自己的 completed/cancelled 记录。

失败场景:已探针复现——卡死的 agent 超过 deadlineMs;bridge 锁定 error 终态并释放 FIFO;agent 随后完成,子进程落盘 completed。在 overlay 窗口内轮询返回 state:'error' 却被 enrich 上成功回答的 resultText(自相矛盾的载荷);64 条驱逐或 daemon 重启之后,同样的轮询返回 state:'completed'。同一个 promptId 仅因轮询时机不同就在 error→completed 之间翻转;"非 exactly-once 存储" 的免责声明只覆盖丢失(404),不覆盖矛盾。

建议方向:显式调和 deadline 场景——持久化记录存在时以其状态为准、把 deadline 作为元数据呈现(或在文档中声明 transcript 落盘后 error/prompt_deadline_exceeded 被取代)。至少不要输出带成功 resultTextstate:'error'

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified as a real fault-path disagreement, not treated as fixed. The PR description now explicitly states that the caller-facing deadline terminal is authoritative while retained in the 64-entry live overlay, while a child that settles later may persist a different execution outcome; after overlay eviction or restart the bounded transcript may expose that child outcome. Eliminating this would require durable terminal reconciliation or a permanent task-result ledger, which is outside this bounded polling contract. Leaving this thread open for maintainer confirmation of that contract.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified as a real fault-path disagreement, not treated as fixed. The PR description now explicitly states that the caller-facing deadline terminal is authoritative while retained in the 64-entry live overlay, while a child that settles later may persist a different execution outcome; after overlay eviction or restart the bounded transcript may expose that child outcome. Eliminating this would require durable terminal reconciliation or a permanent task-result ledger, which is outside this bounded polling contract. Leaving this thread open for maintainer confirmation of that contract.

Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread packages/acp-bridge/src/bridge.test.ts
Comment thread packages/acp-bridge/src/bridge.test.ts
Comment thread packages/cli/src/acp-integration/acpAgent.ts
Comment thread packages/cli/src/acp-integration/acpAgent.test.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
@wenshao

wenshao commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Merge resolution for PR #9080

Root cause

Main's c0e649b53cperf(serve): Restore large sessions selectively (#9055) — rewrote the replay path in acpAgent.ts, deleting the bulk-replay helpers (relocated to session/history-replay-page.ts) and their core imports findBoundaryAtOrBefore / isReplayTurnStartType. This PR inserted isTurnResultRecordPayload between those same two import lines for its findSettledTurnResult scanner. One conflict, in that import list; everything else auto-merged.

Textual or semantic

Semantic in the import list: both sides edited the same four lines with opposing intent (main deleted two lines the PR kept as context; the PR added a line between them). Resolution — keep the PR's import, drop the two whose only users #9055 deleted:

  encodeSessionTranscriptCursor,
  isTurnResultRecordPayload,
  subagentGenerator,

Dropping the pair is mandatory: no usage of either remains in the merged file, so keeping them would fail noUnusedLocals. isTurnResultRecordPayload is still used in findSettledTurnResult (~L441) and stays exported via export * from chatRecordingService.js in the core index.

What is load-bearing

  • The merged acpAgent.ts is the exact union of both sides: vs origin/main it diffs by precisely the PR's +137/−0 (the import, type TurnResultRecordPayload, findSettledTurnResult, and the SERVE_CONTROL_EXT_METHODS.sessionTurnStatus case); vs the PR head it contains only perf(serve): Restore large sessions selectively #9055's deletions. Any future edit that re-adds the replay-helper imports or removes the core export breaks this file.
  • Turn-status wiring order: bridge getSessionTurnStatus checks live/terminal status before and after the ACP ext call sessionTurnStatus, which scans persisted transcript pages. The GET /session/:id/turns/current route must stay registered before turns/:promptId (comment in routes/session.ts).
  • Telemetry catalog counts: legacySessionTelemetryRoutes must contain both PR turns routes — merged tree has 56 routes, 49 handler_resolved / 7 pre_resolved, matching the test the PR updated. Main never touched telemetry.ts since the PR base, so the counts survive.

What I could not verify

No build, typecheck, or tests were run here. Both #9055 and this PR modify Session.ts, bridge.ts, and chatRecordingService.ts; those auto-merged cleanly and I spot-checked the turn-status call chain, but the runtime interaction between #9055's selective-restore lease gating and this PR's transcript scan is only provable by the PR's CI. Only the one conflicted file was edited; all other changes are git's auto-merge.

中文说明

冲突根因:main 上的 c0e649b53c#9055 大会话选择性恢复)重写了 acpAgent.ts 的回放路径,删除了批量回放辅助函数及其仅有的两个 core 导入 findBoundaryAtOrBefore / isReplayTurnStartType;而本 PR 恰好在这两行导入之间插入了新导入 isTurnResultRecordPayload,导致同一导入列表冲突。

语义冲突及解决:保留 PR 的新导入(findSettledTurnResult 仍在使用,core 仍通过 export * 导出),删除 main 已移除使用方的两个导入——保留它们会因未使用而无法通过编译。

关键点:合并后的 acpAgent.ts 是两侧的精确并集(相对 main 恰为 PR 的 +137 行,相对 PR 头部仅含 #9055 的删除)。turns/current 路由必须先于 turns/:promptId 注册;遥测目录计数为 56 条路由(49/7 归属拆分),与 PR 更新后的测试一致。

未能验证:本次未运行构建或测试。#9055 与本 PR 同时修改了 Session.tsbridge.tschatRecordingService.ts,均自动合并且抽查了调用链,但选择性恢复的租约门控与本 PR 转录扫描之间的运行时交互需由 PR 自身 CI 证明。除冲突文件外未改动任何其他文件。

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

Not explored to full depth (tool budget reached): "PR 9080 reverse audit round 5 (cap round): hunt only gaps…": none — finished within budget, no check left incomplete.; "PR 9080 reverse audit round 5 (cap round): hunt only gaps…": none — all checks above completed within budget.; "PR 9080 reverse audit round 5 (cap round): hunt only gaps…": none — finished within budget.; "Second-round reverse audit of PR 9080 (pollable daemon…": none — all checks above completed within budget.; "PR 9080 reverse audit round 3: hunt only gaps all prior…": did not quantify real-world throw rates of #settleGoalTurn / releaseTurn / refreshSystemInstruction beyond confirming they are unguarded awaits with throwing …, and 19 more.

中文说明

未探索到全部深度(达到工具调用预算):"PR 9080 reverse audit round 5 (cap round): hunt only gaps…"none — finished within budget, no check left incomplete."PR 9080 reverse audit round 5 (cap round): hunt only gaps…"none — all checks above completed within budget."PR 9080 reverse audit round 5 (cap round): hunt only gaps…"none — finished within budget."Second-round reverse audit of PR 9080 (pollable daemon…"none — all checks above completed within budget."PR 9080 reverse audit round 3: hunt only gaps all prior…"did not quantify real-world throw rates of #settleGoalTurn / releaseTurn / refreshSystemInstruction beyond confirming they are unguarded awaits with throwing …,另有 19 条。

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

Comment on lines +1935 to +1938
function enrichTerminalTurnStatus(
terminal: BridgeTurnStatus,
persisted: BridgeTurnStatus,
): BridgeTurnStatus {

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-4: the overlay/persisted merge assumes both sources agree on a turn's terminal state, but the prompt-deadline path guarantees disagreement: the bridge latches state:'error'/prompt_deadline_exceeded in the overlay (first-writer-wins, publishPromptTerminal ~7585-7591), while the child — with zero deadline awareness (no deadlineMs references anywhere in packages/cli/src/acp-integration) — persists its own completed/cancelled record for the same promptId. The merge keeps the overlay's state while enriching with the persisted resultText. Still stands at head 24e98dd (re-checked; no fix since the round-1 report). — Failure scenario: probe-reproduced — a wedged agent exceeds deadlineMs; the bridge latches the error terminal and releases the FIFO; the agent later completes and the child persists completed with the answer. During the overlay window the poll returns state:'error' enriched with the completed answer's resultText (a self-contradictory payload); after 64-entry eviction or daemon restart, the identical poll returns state:'completed'. One promptId flips error→completed purely as a function of poll timing; the "not an exactly-once store" disclaimer answers loss (404), not contradiction.

Suggested direction: reconcile the deadline case explicitly — prefer the persisted state once it exists and surface the deadline as metadata (or document that error/prompt_deadline_exceeded is superseded once the transcript settles). At minimum, never emit state:'error' enriched with a successful resultText.

中文说明

overlay/持久化的合并假设两个来源对某轮终态一致,但 prompt-deadline 路径必然造成不一致:bridge 以 first-writer-wins 在 overlay 中锁定 state:'error'/prompt_deadline_exceededpublishPromptTerminal 约 7585-7591 行),而子进程完全无 deadline 概念(packages/cli/src/acp-integration 中没有任何 deadlineMs 引用),会为同一个 promptId 落盘自己的 completed/cancelled 记录。合并时保留 overlay 的 state,却用持久化的 resultText 去 enrich。

失败场景:已探针复现——卡死的 agent 超过 deadlineMs;bridge 锁定 error 终态并释放 FIFO;agent 随后完成,子进程落盘 completed 及答案。在 overlay 窗口内轮询返回 state:'error' 却被 enrich 上成功回答的 resultText(自相矛盾的载荷);64 条驱逐或 daemon 重启之后,同样的轮询返回 state:'completed'。同一个 promptId 仅因轮询时机不同就在 error→completed 之间翻转;"非 exactly-once 存储" 的免责声明只覆盖丢失(404),不覆盖矛盾。

建议方向:显式调和 deadline 场景——持久化记录存在时以其状态为准、把 deadline 作为元数据呈现(或在文档中声明 transcript 落盘后 error/prompt_deadline_exceeded 被取代)。至少不要输出带成功 resultTextstate:'error'

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified as a real fault-path disagreement, not treated as fixed. The PR description now explicitly states that the caller-facing deadline terminal is authoritative while retained in the 64-entry live overlay, while a child that settles later may persist a different execution outcome; after overlay eviction or restart the bounded transcript may expose that child outcome. Eliminating this would require durable terminal reconciliation or a permanent task-result ledger, which is outside this bounded polling contract. Leaving this thread open for maintainer confirmation of that contract.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified as a real fault-path disagreement, not treated as fixed. The PR description now explicitly states that the caller-facing deadline terminal is authoritative while retained in the 64-entry live overlay, while a child that settles later may persist a different execution outcome; after overlay eviction or restart the bounded transcript may expose that child outcome. Eliminating this would require durable terminal reconciliation or a permanent task-result ledger, which is outside this bounded polling contract. Leaving this thread open for maintainer confirmation of that contract.

Comment thread packages/acp-bridge/src/bridge.ts Outdated
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread docs/design/daemon-turn-status-endpoint.md
Comment thread packages/acp-bridge/src/bridge.test.ts
Comment thread packages/acp-bridge/src/bridge.test.ts
Comment thread packages/cli/src/acp-integration/session/Session.test.ts
Comment thread packages/acp-bridge/src/bridge.ts
@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

Updated this existing PR to bbf24434c3, based on current main 8517fa9d47. This carries the verified review fixes while keeping the bounded polling contract; it does not add permanent result storage, strict close/kill persistence, crash/shutdown transcript backfill, offline workspace scanning, a promptId index, or rewrite-pipeline refactoring.

Post-merge verification on the exact pushed head: Session 631/631, ACP bridge 661/661, recording service 80/80, Session service 139/139; workspace build, typecheck, lint, bundle, and diff check passed. Bundled daemon E2E passed 3/3 for final parent answer after a tool boundary, stable truncation status, and normal Session reload lookup; capability E2E passed 1/1.

The supported restart boundary remains explicit: recording must be enabled, append must succeed, the result must remain on the active branch and within the bounded scan window, and the Session must be loaded live again. Deleted JSONL, disabled/failed recording, unexpected process crash, daemon shutdown, or results outside the window may return prompt_not_found.

@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

Exact-head E2E test report

Tested commit: bbf24434c390c6ee1fd1856013937196ae0f60d1

Environment: macOS, Node.js 22-compatible repository toolchain, QWEN_SANDBOX=false, the built dist/cli.js, a real qwen serve process, and the repository fake OpenAI-compatible server.

  • Repository build: passed.
  • Bundle and asset copy: passed.
  • Pollable turn-result E2E: 3/3 passed.
    • A response with visible pre-tool text and a tool call returned only the final parent answer after the tool boundary.
    • A result above 32,768 UTF-16 code units returned the bounded text, resultTruncated: true, and RESULT_TEXT_TRUNCATED.
    • A normally recorded settled result remained queryable after closing and loading the Session again.
  • Capability E2E: 1/1 passed; session_turn_status is advertised without an additional flag or setting.

The generic Integration Tests (CLI, No Sandbox) CI job is skipped by the workflow for this PR; the focused scenarios above were run locally against the exact-head built bundle. This evidence does not claim deleted-JSONL recovery, recording-failure recovery, crash/shutdown backfill, offline Session lookup, or permanent result retention; those remain explicitly outside this PR.

@github-actions github-actions Bot removed the review/self-reported The linked issue was opened by the PR author (self-reported) label Aug 14, 2026
@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

Maintainer handoff

Current head: bbf24434c390c6ee1fd1856013937196ae0f60d1.

  • All previously reported correctness fixes have been verified against the current head and replied to in their original threads.
  • 48 of 50 review threads are resolved. The only two open threads are duplicate R1-4 reports for the same deadline overlay vs late child-settlement boundary. The disagreement is real and is now disclosed in the PR Risk & Scope; eliminating it would require durable terminal reconciliation or a permanent task-result ledger. Maintainer confirmation of the bounded contract is requested.
  • The PR now uses Related to #8680 rather than claiming to close the stronger issue contract.
  • Exact-head repository build and bundle passed. Exact-head built-bundle real-daemon E2E passed 4/4: capability discovery 1/1 and pollable turn results 3/3 (tool-boundary final answer, stable truncation signaling, and normal Session reload).
  • Current required CI checks that have completed are green, including Ubuntu Test, Serve A/B, Real daemon E2E, and web-shell E2E. The automated review-pr and route workflow are still pending.

Under the documented bounded, best-effort contract, there is no known remaining production-code blocker. Please confirm whether the deadline boundary is acceptable for this PR; if stronger cross-restart terminal consistency is required, it should be designed as a separate durable task-result subsystem rather than extending this polling diff.

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

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite (integration-tests/cli, incl. the new turn-status E2Es) did not run locally.

Not explored to full depth (tool budget reached): chunk 4: none — all checks I needed completed within budget.; "You are review agent reverse-audit — Reverse audit agent…": none — all checks I started were completed within budget.; "You are review agent reverse-audit — Reverse audit agent…": none — all checks I started completed within budget.; "You are review agent reverse-audit — Reverse audit agent…": none — all checks above completed within budget.; "You are review agent reverse-audit — Reverse audit agent…": none — all checks I started were completed within budget., and 8 more.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite (integration-tests/cli, incl. the new turn-status E2Es) did not run locally。

未探索到全部深度(达到工具调用预算):chunk 4:none — all checks I needed completed within budget."You are review agent reverse-audit — Reverse audit agent…"none — all checks I started were completed within budget."You are review agent reverse-audit — Reverse audit agent…"none — all checks I started completed within budget."You are review agent reverse-audit — Reverse audit agent…"none — all checks above completed within budget."You are review agent reverse-audit — Reverse audit agent…"none — all checks I started were completed within budget.,另有 8 条。

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

Comment on lines +10164 to +10167
if (promptId !== undefined) {
if (terminal && persisted) {
return enrichTerminalTurnStatus(terminal, persisted);
}

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-4: the overlay/persisted merge assumes both sources agree on a turn's terminal state, but the prompt-deadline path guarantees disagreement: the bridge latches state:'error'/prompt_deadline_exceeded in the overlay (first-writer-wins, publishPromptTerminalrememberTerminalTurnStatus), while the child — with zero deadline awareness (no deadlineMs references anywhere in packages/cli/src/acp-integration) — persists its own completed record for the same promptId. The merge keeps the overlay's state while enriching with the persisted resultText. Unfixed since the round-1 report; git diff 24e98dd..bbf2443 changed nothing here. — Failure scenario: probe-reproduced end-to-end at this head — a wedged agent exceeds deadlineMs; the bridge latches the error terminal and releases the FIFO; the agent later completes and the child persists completed with the answer. During the overlay window the poll returns state:'error' enriched with the successful answer's resultText (a self-contradictory payload); after 64-entry eviction or daemon restart, the identical poll returns state:'completed'. One promptId flips error→completed purely as a function of poll timing.

Suggested direction: reconcile the deadline case explicitly — prefer the persisted state once it exists and surface the deadline as metadata (or document that error/prompt_deadline_exceeded is superseded once the transcript settles). The minimal variant below takes the persisted record when it disagrees with an error terminal; at minimum, never emit state:'error' enriched with a successful resultText.

Suggested change
if (promptId !== undefined) {
if (terminal && persisted) {
return enrichTerminalTurnStatus(terminal, persisted);
}
if (promptId !== undefined) {
if (terminal && persisted) {
if (terminal.state === 'error' && persisted.state !== 'error') {
return persisted;
}
return enrichTerminalTurnStatus(terminal, persisted);
}
中文说明

overlay/持久化的合并假设两个来源对某轮终态一致,但 prompt-deadline 路径必然造成不一致:bridge 以 first-writer-wins 在 overlay 中锁定 state:'error'/prompt_deadline_exceededpublishPromptTerminalrememberTerminalTurnStatus),而子进程完全无 deadline 概念(packages/cli/src/acp-integration 中没有任何 deadlineMs 引用),会为同一个 promptId 落盘自己的 completed 记录。合并时保留 overlay 的 state,却用持久化的 resultText 去 enrich。自第 1 轮报告以来未修复;git diff 24e98dd..bbf2443 在此处没有任何变化。

失败场景:已在当前 head 端到端探针复现——卡死的 agent 超过 deadlineMs;bridge 锁定 error 终态并释放 FIFO;agent 随后完成,子进程落盘 completed 及答案。在 overlay 窗口内轮询返回 state:'error' 却被 enrich 上成功回答的 resultText(自相矛盾的载荷);64 条驱逐或 daemon 重启之后,同样的轮询返回 state:'completed'。同一个 promptId 仅因轮询时机不同就在 error→completed 之间翻转。

建议方向:显式调和 deadline 场景——持久化记录存在时以其状态为准、把 deadline 作为元数据呈现(或在文档中声明 transcript 落盘后 error/prompt_deadline_exceeded 被取代)。下方的最小变体在 error 终态与持久化记录不一致时采用持久化记录;至少不要输出带成功 resultTextstate:'error'

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

pending: PendingPromptEntry,
terminal: PromptTerminal,
): void {
const promptText = truncateTurnText(pending.text);

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] R2-2: the settle-time truncation in rememberTerminalTurnStatus is still never exercised by any endpoint test (no reply, no fix since the round-2 report). The new truncation E2E covers settled resultText (persisted side) and the bridge unit 'caps live promptText at TURN_RESULT_TEXT_MAX_CHARS' covers the live overlay, but no test drives an oversized prompt through the overlay-terminal path, so this truncateTurnText(pending.text) call has no pin. — Concrete cost: dropping or bypassing this call ships green — during the overlay window the settled status for a >32,768-char prompt carries an unbounded promptText (and no promptTextTruncated flag), diverging from the live path's capped projection for the same promptId; nothing turns red.

Suggested fix: extend 'caps live promptText…' (or add a sibling) to settle the prompt and assert the overlay-terminal status carries promptText capped at TURN_RESULT_TEXT_MAX_CHARS with promptTextTruncated: true.

中文说明

rememberTerminalTurnStatus 中 settle 时的截断仍然没有任何接口测试覆盖(第 2 轮报告后无回复、无修复)。新的截断 E2E 覆盖了 settled 的 resultText(持久化侧),bridge 单测 'caps live promptText at TURN_RESULT_TEXT_MAX_CHARS' 覆盖了实时 overlay,但没有测试用超长 prompt 走 overlay 终态路径,因此这个 truncateTurnText(pending.text) 调用没有钉住。

具体代价:删除或绕过该调用可以绿灯上线——overlay 窗口内,超长 prompt 的 settled 状态会带无上限的 promptText(且没有 promptTextTruncated 标志),与同一个 promptId 实时路径的有上限投影互相矛盾;没有任何测试变红。

建议修复:扩展 'caps live promptText…'(或新增一个同款测试),让 prompt settle 后断言 overlay 终态的 promptText 被截到 TURN_RESULT_TEXT_MAX_CHARS 且带 promptTextTruncated: true

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

Comment on lines +14493 to +14495
onPromptAdmitted: () => {
expect(handle.agent.promptCalls).toHaveLength(0);
admittedStatus = bridge.getSessionTurnStatus(

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] R2-3: the dispatch-boundary test's load-bearing assertion still runs INSIDE the onPromptAdmitted callback, and the bridge still swallows callback exceptions into an optional diagnostic sink (try { context?.onPromptAdmitted?.(); } catch { … onDiagnosticLine … }, bridge.ts ~7804-7811). No reply, no fix since the round-2 report. — Failure scenario: if a future change dispatches to the channel before admission fires, toHaveLength(0) throws inside the callback, the bridge swallows it, admittedStatus is never assigned, and the test then fails only via expect(undefined).resolves with vitest's "received value must be a Promise" — pointing the debugger at an async-contract phantom instead of the real ordering regression. The regression is still caught (never a silent pass), but through a misleading failure.

Suggested change
onPromptAdmitted: () => {
expect(handle.agent.promptCalls).toHaveLength(0);
admittedStatus = bridge.getSessionTurnStatus(
onPromptAdmitted: () => {
admittedPromptCallCount = handle.agent.promptCalls.length;
admittedStatus = bridge.getSessionTurnStatus(

(capture in the callback, then assert expect(admittedPromptCallCount).toBe(0) outside, after the admittedStatus expectations)

中文说明

dispatch 边界测试的关键断言仍然写在 onPromptAdmitted 回调内部,而 bridge 仍然把回调异常吞进可选的 diagnostic sink(try { context?.onPromptAdmitted?.(); } catch { … onDiagnosticLine … },bridge.ts 约 7804-7811 行)。第 2 轮报告后无回复、无修复。

失败场景:未来若有改动让 channel 在 admission 触发前就被 dispatch,toHaveLength(0) 会在回调内抛错、被 bridge 吞掉,admittedStatus 永远不会被赋值,测试最终只会经由 expect(undefined).resolves 以 vitest 的 "received value must be a Promise" 失败——把调试者引向一个 async 契约的假象,而不是真正的顺序回退。回退仍会被捕获(不会静默通过),但失败信息具有误导性。

建议在回调内只做采集(admittedPromptCallCount = handle.agent.promptCalls.length;),在回调外、admittedStatus 断言之后再断言 expect(admittedPromptCallCount).toBe(0)

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

Comment on lines +4729 to +4730
it('keeps overlapping turn records attributed to their own promptIds', async () => {
// DAEMON-003 overlap: the bridge releases the FIFO on deadline

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] R2-4: this DAEMON-003 overlap test still steers the predecessor down a path the real overlap never takes — the hand-rolled stream ignores the abort signal and completes cleanly, so the cancelled settle is asserted via the iteration-start abort check rather than the throw-from-model-await path that real supersession produces. No reply, no fix since the round-2 report. (The R1-3 fix removed the behavioral divergence between the two abort landings — both now settle cancelled — so what remains is the test-realism gap: the throw variant of the overlap is still unexercised.) — Failure scenario: a future regression affecting only the abort-throws-from-model-await path (the common one — the deadline supersede fires while the predecessor is wedged in a model await) keeps this test green, because the test's predecessor never throws.

Suggested fix: add an overlap variant whose predecessor stream rejects with an AbortError when the successor's install-time abort lands, asserting the predecessor still settles state:'cancelled' under its own promptId.

中文说明

这个 DAEMON-003 重叠测试仍然把前序轮引到真实重叠不会走的路径上——手写 stream 忽略 abort 信号并干净地完成,因此 cancelled 的 settle 是经由每轮迭代开头的中止检查断言的,而不是真实顶替产生的 model await 抛错路径。第 2 轮报告后无回复、无修复。(R1-3 的修复已消除两种中止落点的行为分歧——现在两者都 settle 为 cancelled——因此剩下的是测试真实性缺口:重叠的抛错变体仍未被演练。)

失败场景:未来某个只影响 model await 抛错路径的回退(这才是常见情形——deadline 顶替恰好发生在前一轮卡死在 model await 期间)不会让这个测试变红,因为测试里的前序轮从不抛错。

建议修复:新增一个重叠变体,让前序轮 stream 在 successor 安装时中止落地时以 AbortError 拒绝,并断言前序轮仍在自己的 promptId 下 settle 为 state:'cancelled'

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

Comment on lines +2053 to +2057
const live = entry.pendingPromptList.filter(
(pending) =>
!pending.terminalPublished &&
(!pending.removed || pending.state === 'running'),
);

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] the documented projection "Removed queued entries become terminal and are no longer projected as queued" has zero test coverage — the new getSessionTurnStatus describe block covers the removed-RUNNING case ('keeps a removed running prompt visible until it settles') but never removes a QUEUED prompt and polls its status. Verification probed the current behavior: it is correct (removePendingPrompt splices the queued entry and publishes cancelled via the DAEMON-004 path) — this is a pure pin gap. — Failure scenario: if rememberTerminalTurnStatus were skipped for the queued-removal terminal path (or the overlay lookup bypassed), GET /session/:id/turns/:promptId for a prompt the daemon just cancelled via removePendingPrompt would return 404 prompt_not_found instead of cancelled; no test turns red.

Suggested fix: add a bridge test: two prompts (one running, one queued), removePendingPrompt the queued one, then assert getSessionTurnStatus(sessionId, undefined, queuedId) resolves to { state: 'cancelled' } and getSessionTurnStatus(sessionId) does not report it as queued.

中文说明

文档承诺的投影"被删除的 queued 条目变为终态、不再投影为 queued"完全没有测试覆盖——新的 getSessionTurnStatus 测试块覆盖了 removed-RUNNING 情形('keeps a removed running prompt visible until it settles'),但从未删除一个 QUEUED prompt 再轮询其状态。验证阶段已用探针确认当前行为是正确的(removePendingPrompt 会 splice 掉 queued 条目并经 DAEMON-004 路径发布 cancelled)——这是一个纯粹的钉住缺口。

失败场景:若 queued 删除的终态路径跳过 rememberTerminalTurnStatus(或 overlay 查找被绕过),对刚被 removePendingPrompt 取消的 prompt 调 GET /session/:id/turns/:promptId 会返回 404 prompt_not_found 而不是 cancelled;没有任何测试变红。

建议修复:新增 bridge 测试——两个 prompt(一个 running、一个 queued),对 queued 的执行 removePendingPrompt,然后断言 getSessionTurnStatus(sessionId, undefined, queuedId) 解析为 { state: 'cancelled' },且 getSessionTurnStatus(sessionId) 不再把它报告为 queued。

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

Comment on lines +715 to +717
!optionalString('stopReason') ||
!optionalTimestamp('startedAt') ||
!optionalString('promptText', TURN_RESULT_TEXT_MAX_CHARS) ||

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] R3-3 (pattern, 2 locations — this validator-cap side and the writer-type side at ~670-672): the read-side bounded contract caps promptText/resultText (32,768), error.message (4,096) and error.code (256), but leaves promptId, stopReason, and originatorClientId unbounded (optionalString without maxChars; promptId only checked non-empty at ~692), and settledTurnStatus (bridge.ts ~2068-2100) forwards all three verbatim into the GET /session/:id/turns/:promptId response. Probe-verified: the validator accepts 4,000,000-char values in all three fields while rejecting oversized resultText/promptText. The trigger is the validator's own untrusted-disk threat model (a corrupted or hand-edited transcript; production writers cannot produce such values, and reads are bounded by the 4 MiB page cap). — Failure scenario: a corrupted transcript carrying multi-megabyte values in any of those three fields passes validation and is echoed unbounded through the endpoint — the exact payload bloat the cap constants in this same diff were built to prevent, through the fields the contract forgot.

Suggested change
!optionalString('stopReason') ||
!optionalTimestamp('startedAt') ||
!optionalString('promptText', TURN_RESULT_TEXT_MAX_CHARS) ||
!optionalString('stopReason', TURN_RESULT_ERROR_CODE_MAX_CHARS) ||
!optionalTimestamp('startedAt') ||
!optionalString('promptText', TURN_RESULT_TEXT_MAX_CHARS) ||

(apply the same treatment to promptId at ~692 and originatorClientId at ~721, with a small dedicated cap)

中文说明

R3-3(模式,2 处——这里是校验器上限侧,另一处写入类型侧在约 670-672 行):读取侧的有界契约给 promptText/resultText(32,768)、error.message(4,096)、error.code(256)设了上限,却让 promptIdstopReasonoriginatorClientId 无上限(optionalString 不带 maxCharspromptId 在约 692 行只检查非空),而 settledTurnStatus(bridge.ts 约 2068-2100 行)会把这三个字段原样转发进 GET /session/:id/turns/:promptId 响应。已用探针验证:校验器接受这三个字段的 4,000,000 字符值,同时拒绝超长的 resultText/promptText。触发条件正是校验器自己的不可信磁盘威胁模型(损坏或手工编辑的 transcript;生产写入方不可能产生这种值,且读取受 4 MiB 页上限约束)。

失败场景:损坏的 transcript 在这三个字段中携带数 MB 的值,通过校验后被接口无上限地回显——恰恰是同一个 diff 里那些上限常量本想防止的载荷膨胀,只是从契约遗漏的字段漏了进来。

建议对约 692 行的 promptId 与约 721 行的 originatorClientId 做同样处理(用一个小的专用上限)。

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

data: 'aGVsbG8=',
},
],
_meta: { 'qwen.daemon.promptDisplayText': '[image]' },

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] 'records the image placeholder used by the live turn status' supplies the placeholder via the qwen.daemon.promptDisplayText meta override, so the new extractTurnPromptText fallback branches it is named after (image-only → '[image]', no-text → '') are never exercised by any test. The meta is not always present in production (routes/session.ts forwards it only when the channel worker is authorized; the bridge sets it only for channel sessions), so an image-only daemon prompt without it falls through to extractTurnPromptText (Session.ts ~5904-5913). The bridge-side structurally-identical extractPromptText (bridge.ts:2008) drives the live status; only it is pinned. — Failure scenario: a future edit to extractTurnPromptText silently changes persisted turn_result.promptText — the field served by GET /session/:id/turns/:promptId after overlay eviction or restart — and makes it diverge from the live status the client saw for that promptId. No test turns red; the divergence surfaces only as an empty prompt text for an image turn after a restart.

Suggested fix: add cases sending an image-only prompt (and a text-less prompt) WITHOUT the meta, asserting promptText: '[image]' (resp. ''); ideally assert parity between the session-side extractor and the bridge's extractPromptText, or share one implementation.

中文说明

'records the image placeholder used by the live turn status' 是通过 qwen.daemon.promptDisplayText meta 覆盖来提供占位符的,因此它以之命名的新 extractTurnPromptText 兜底分支(仅图片 → '[image]'、无文本 → '')没有任何测试演练。生产中该 meta 并非总存在(routes/session.ts 仅在 channel worker 已授权时转发;bridge 仅对 channel 会话设置它),因此不带 meta 的仅图片 daemon prompt 会落到 extractTurnPromptText(Session.ts 约 5904-5913 行)。bridge 侧结构相同的 extractPromptText(bridge.ts:2008)驱动实时状态;只有它被钉住了。

失败场景:未来对 extractTurnPromptText 的修改会悄悄改变持久化的 turn_result.promptText——overlay 驱逐或重启之后 GET /session/:id/turns/:promptId 提供的正是该字段——使其与客户端就该 promptId 看到的实时状态产生分歧。没有任何测试变红;分歧只会在重启之后以图片轮次的 prompt 文本为空的形式暴露。

建议修复:新增不带 meta 的仅图片 prompt(以及无文本 prompt)用例,断言 promptText: '[image]'(分别为 '');理想情况下断言 session 侧提取器与 bridge 的 extractPromptText 一致性,或共用一个实现。

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

Comment on lines +4680 to +4684
expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith(
expect.objectContaining({
promptId: 'daemon-prompt-id',
state: 'error',
error: { message: 'model exploded' },

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] post-start failure turns never pin startedAt presence. The suite deliberately pins its ABSENCE for pre-start failures ('records admission errors without a startedAt timestamp' asserts expect(payload.startedAt).toBeUndefined()), but this test — a turn that started and then failed — asserts only state/error. startedAt is set at Session.ts:3743 after admission and forwarded to the turn-status API at bridge.ts:2080, so the started/failed distinction is intentional semantics with no guard here. Mutation-tested during verification: moving the assignment off the failure path leaves all 15 existing tests green; only a probe assertion catches it. — Failure scenario: a refactor relocating turnRecording.startedAt = Date.now() off the failure path makes every settled-after-start error record lose startedAt; after overlay eviction or daemon restart, GET /session/:id/turns/:promptId for a failed turn silently loses its start time and the pre-start/post-start failure distinction; no test turns red.

Suggested change
expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith(
expect.objectContaining({
promptId: 'daemon-prompt-id',
state: 'error',
error: { message: 'model exploded' },
expect(mockChatRecordingService.recordTurnResult).toHaveBeenCalledWith(
expect.objectContaining({
promptId: 'daemon-prompt-id',
state: 'error',
error: { message: 'model exploded' },
}),
);
const errorPayload =
mockChatRecordingService.recordTurnResult.mock.calls[0][0];
expect(typeof errorPayload.startedAt).toBe('number');
expect(errorPayload.startedAt!).toBeLessThanOrEqual(
errorPayload.endedAt!,
);
中文说明

启动后失败的轮次从未钉住 startedAt 的存在性。测试套件刻意钉住了启动前失败时它的缺失('records admission errors without a startedAt timestamp' 断言 expect(payload.startedAt).toBeUndefined()),但本测试——一个已启动随后失败的轮次——只断言 state/errorstartedAt 在 Session.ts:3743 的 admission 之后设置、在 bridge.ts:2080 转发进 turn-status 接口,因此 started/failed 的区分是有意的语义,这里却没有防护。验证阶段做了变异测试:把赋值挪出失败路径后,现有 15 个测试全部保持绿色;只有探针断言能捕获。

失败场景:把 turnRecording.startedAt = Date.now() 挪出失败路径的重构,会让所有启动后 settle 的 error 记录丢失 startedAt;overlay 驱逐或 daemon 重启之后,失败轮次的 GET /session/:id/turns/:promptId 会悄悄丢失启动时间以及启动前/启动后失败的区分;没有任何测试变红。

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

Comment on lines +4690 to +4694
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createFailingStream('Request was aborted.', () => {
void session.cancelPendingPrompt();
}),
);

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] the cancel × error-shape discrimination matrix is pinned in only one of its informative cells, and this test's title masks the hole. The title says 'user cancel races a NON-ABORT stream error' but the body uses the abort-shaped message 'Request was aborted.' — the genuine non-abort-message + concurrent-cancel cell has zero coverage, and the complementary negative cell (abort-shaped error, NO cancel → state:'error') has none either (the plain-error test uses the non-abort-shaped 'model exploded'). The settle mapping is flag-based today (Session.ts:3494-3506 never inspects the error message). Mutation-tested during verification: OR-ing message-based cancel detection into controlledAbort leaves all 15 existing tests green; only a probe catches it. Note 'Request was aborted.' is the real APIUserAbortError message, and message-based cancel detection is an existing pattern in sibling code (desktop SessionManager, vscode SessionMessageHandler) — the drift is a live pattern, not an idle hypothetical. — Failure scenario: a future change adding message-based cancel detection would settle genuine provider/network aborts with no user cancel as state:'cancelled' — the turn-status API reporting real API failures as user cancellations — and user cancels racing plain provider failures as state:'error'; no test turns red in either direction.

Suggested fix: rename this test to match its body (cancel racing an abort-shaped error), and add both missing cells: (a) createFailingStream('Request was aborted.') with no cancel hook → assert { state: 'error' }; (b) createFailingStream('model exploded', () => { void session.cancelPendingPrompt(); }) → assert { state: 'cancelled', stopReason: 'cancelled' }.

中文说明

取消 × 错误形状的判别矩阵只在其一个有信息量的格子上被钉住,而且这个测试的标题掩盖了缺口。标题写的是 'user cancel races a NON-ABORT stream error',但测试体使用的是 abort 形状的消息 'Request was aborted.'——真正的非 abort 消息 + 并发取消的格子零覆盖,互补的负向格子(abort 形状错误、无取消 → state:'error')也没有覆盖(纯错误测试用的是非 abort 形状的 'model exploded')。当前的 settle 映射是基于标志的(Session.ts:3494-3506 从不检查错误消息)。验证阶段做了变异测试:把基于消息的取消检测并入 controlledAbort 后,现有 15 个测试全部保持绿色;只有探针能捕获。注意 'Request was aborted.' 正是真实 APIUserAbortError 的消息,而且基于消息的取消检测在兄弟代码中已是既有模式(desktop SessionManager、vscode SessionMessageHandler)——这种漂移是现实中活着的模式,不是空想的假设。

失败场景:未来加入基于消息的取消检测后,真正的 provider/network 中止(无用户取消)会被 settle 为 state:'cancelled'——turn-status 接口把真实的 API 失败报告成用户取消——而与纯 provider 失败竞态的用户取消会被 settle 为 state:'error';两个方向都没有测试变红。

建议修复:把本测试改名为与测试体一致(取消与 abort 形状错误竞态),并补上缺失的两个格子:(a) 不带取消钩子的 createFailingStream('Request was aborted.') → 断言 { state: 'error' };(b) createFailingStream('model exploded', () => { void session.cancelPendingPrompt(); }) → 断言 { state: 'cancelled', stopReason: 'cancelled' }

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

Comment on lines +14693 to +14695
promptText: 'settled prompt',
resultText: 'settled answer',
originatorClientId: 'client-9',

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] no persisted record in any test carries resultTruncated: true, so settledTurnStatus' defaulting branch (resultCode: record.resultCode ?? TURN_RESULT_CODE_TEXT_TRUNCATED, bridge.ts:2095) is unreached by the suite — grep for resultTruncated/resultCode in bridge.test.ts returns zero matches; the writer always emits both fields as a pair, so the ?? default never executes even in the E2E. The branch is reachable, not dead defense: the validator only fails resultCode when it is present and inconsistent, so a {resultTruncated: true} record with no resultCode passes validation and flows through the child response into settledTurnStatus. — Failure scenario: a refactor removing the ?? TURN_RESULT_CODE_TEXT_TRUNCATED default makes validator-valid truncated records surface with resultTruncated: true but no resultCode, silently dropping the truncation marker clients key on; no test turns red.

Suggested fix: extend one persisted-record test (e.g. this fixture) with a variant { …, resultTruncated: true } (no resultCode) and assert resultCode === TURN_RESULT_CODE_TEXT_TRUNCATED in the returned status.

中文说明

所有测试中的持久化记录都不带 resultTruncated: true,因此 settledTurnStatus 的默认值分支(resultCode: record.resultCode ?? TURN_RESULT_CODE_TEXT_TRUNCATED,bridge.ts:2095)从未被测试套件走到——在 bridge.test.ts 中 grep resultTruncated/resultCode 均为零匹配;写入方总是成对写出两个字段,所以即使在 E2E 中 ?? 默认值也从不执行。该分支是可达的,不是死防御:校验器只在 resultCode 存在且不一致时才判失败,因此一条不带 resultCode{resultTruncated: true} 记录可以通过校验、经子进程响应流入 settledTurnStatus

失败场景:删除 ?? TURN_RESULT_CODE_TEXT_TRUNCATED 默认值的重构,会让校验器合法的截断记录以 resultTruncated: true 但没有 resultCode 的形式出现,悄悄丢掉客户端赖以判断的截断标记;没有任何测试变红。

建议修复:在某个持久化记录测试(例如本 fixture)中加一个变体 { …, resultTruncated: true }(不带 resultCode),断言返回状态中 resultCode === TURN_RESULT_CODE_TEXT_TRUNCATED

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

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