fix(core): make loop detection result-aware for task_list polls - #9492
fix(core): make loop detection result-aware for task_list polls#9492yiliang114 wants to merge 58 commits into
Conversation
Identical task_list arguments do not imply an identical result: teammates mutate the shared task board between calls. The argument-only loop guards falsely halted polling teammates with 'duplicate tool-call loop detected' while the board kept changing. Record executed tool results into LoopDetectionService (agent runtime after processFunctionCalls; main-session continuations via functionResponse callId pairing) as privacy-safe SHA-256 fingerprints. For the stateful read tool (task_list only), the consecutive-identical guard, the global-duplicate heuristic, the adaptive cap stuck signal, and action stagnation now require the observed results to be unchanged too. Missing result evidence fails safe and keeps the pre-fix behavior, preserving the DashScope #5019 protection. Loop stops become attributable: ReasoningLoopResult carries the exact LoopType, the interactive stop message and the headless FINISH event / telemetry completion record include it.
|
Re-run at Template ✓ — all sections present, bilingual. Problem: observed bug with real evidence — unchanged. #9450 carries structured local evidence (five identical Direction: aligned — sits squarely in the multi-agent roadmap area, and a safety guard that halts a coordinating teammate mid-flight is a real defect. Size: core paths ( Approach: unchanged from the last pass — per-key consecutive result counting for the oscillation axis and the stub full-output digest for the oversized axis are the right shape. The description matches the diff (hardening restored, daemon twin cut and tracked in #10161). What remains of @wenshao's note 7.2 is one line: the description still doesn't spell out that the Risk: no high-risk path matches against the revert-history patterns. Moving on to code review. 🔍 中文说明在 模板 ✓ —— 各节齐全,双语。 问题:已观测到的 bug,有真实证据 —— 结论不变。#9450 带有结构化本地证据(五次完全相同的 方向:对齐 —— 正处 multi-agent 路线图区域;会中途终止正在协作的 teammate 的安全守卫是真实缺陷。 规模:核心路径( 方案:与上次一致 —— 振荡轴采用按 key 的连续结果计数、超大结果轴采用 stub 全量输出摘要,形态正确。描述与 diff 一致(加固已恢复,daemon 孪生实现被砍并在 #10161 跟踪)。@wenshao 备注 7.2 还剩一句:描述仍未写明 风险:与回滚历史高风险路径无匹配。 进入代码审查 🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code review at
|
| File | What changed |
|---|---|
packages/core/src/services/loopDetectionService.ts |
Core of the fix: stateful-read registry, result fingerprints, the guards made result-aware, per-key consecutive result counting (oscillation fix), stub-envelope reduction, callId pairing |
packages/core/src/services/loopDetectionService.test.ts |
Result-aware guard suite: fail-safe directions, oscillation in both modes, oversized-stub directions, duplicate pairing, retry/reset semantics |
packages/core/src/agents/runtime/agent-core.ts |
Wires executed results into the detector after each batch with per-attempt callId feed dedup; carries loopType on the loop result |
packages/core/src/agents/runtime/agent-headless.test.ts |
End-to-end runtime tests: changing board survives, frozen halts with attribution, provider-duplicate id, no stale attribution on re-execution |
packages/core/src/core/client.ts |
Main-session wiring: records ToolResults by callId and halts like the event-loop guards; per-attempt guard-feed dedup on Retry/ModelFallback |
packages/core/src/core/client.test.ts |
Four tests driving the ToolResult branch through sendMessageStream turns — pins the wiring that was previously revert-able with a green suite |
packages/core/src/tools/truncation.ts |
buildStub embeds the full-output sha256 digest line; exports the label and preview size for the detector |
packages/core/src/agents/runtime/agent-headless.ts |
Carries loopType into the FINISH event and telemetry; resets it between runs |
packages/core/src/agents/runtime/agent-interactive.ts |
Stop message and lastRoundError name the exact detector |
packages/core/src/agents/runtime/agent-interactive.test.ts |
Pins the detector name in the interactive stop message |
packages/core/src/agents/runtime/agent-events.ts |
Optional loopType on the finish event |
packages/core/src/telemetry/types.ts |
Optional loop_type on the subagent execution event |
packages/core/src/telemetry/qwen-logger/qwen-logger.ts |
Journals loop_type when present |
packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts |
Pins journal propagation and omission of loop_type |
Testing evidence — the PR's own CI, quoted via the API
This is an unattended run: PR code is never built or executed here. At head 4bbf8eabd801 CI fully settled green — both pull_request workflow runs (Security Checks, and Qwen Code CI including the ubuntu unit suite) completed with no failures; re-read via the check-runs API this pass, unchanged since the table region was finalized. Not verified here: live multi-agent runs against a real model (author-declared out of scope), and the DashScope server-side re-emission concern from @wenshao's note 7.3 remains open (no provider credentials in any sandboxed lane).
Final CI results for 4bbf8ea (auto-updated by the triage finalize job after CI completed):
| Check | Conclusion |
|---|---|
Classify PR |
✅ success |
Dependency CVE audit |
✅ success |
Desktop Shell (ubuntu-22.04) |
✅ success |
Desktop Shell (windows-2022) |
✅ success |
Post Coverage Comment (ubuntu-latest, 22.x) |
✅ success |
Secret scan (TruffleHog) |
✅ success |
Test (ubuntu-latest, Node 22.x) |
✅ success |
web-shell E2E Smoke (ubuntu-latest, 22.x) |
✅ success |
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。
Sandboxed verification: landed green on exactly this head. The maintainer-triggered /verify run (actions/runs/32994114958) completed while this pass was preparing — verdict merge-ready, 60/60 scripted assertions. What prior passes were waiting on is now settled with evidence: the A/B proves the change load-bearing at 4bbf8eabd801 (base build still false-halts the #9450 oscillating poll @5; the PR build survives 120–150 polls; a frozen board still halts @5, base-parity, including the oversized-stub path that round-2's F2 escaped), and all four previous-round findings are fixed and test-pinned — mutation matrix 12/12 killed, 0 survivors, plus the flakiness gate (5 changed test files × 5 rounds, no divergence). One new nit-level Suggestion from that round (interleaved polling + missing result evidence leaves the cap's stuck signal unfed; bounded by the 1000-call hard backstop, unreachable with the current wiring) — non-blocking, noted in the risk section. A redundant second verify round (from the 18:21 /triage) is still in flight; it is advisory and covers the same head.
中文说明
在 4bbf8eabd801 上的代码审查(head 未变的第四次通过 —— 本次已重新追踪 diff 中的关键结论)
先给独立方案: 振荡轴我会按 repeat key 计连续相同结果数——结果与前驱不同即重置为 1——并把该计数同时喂给全局重复守卫与自适应上限的卡滞信号,这正是 f34098e6 的做法。超大结果轴我会把截断前完整输出的 sha256 嵌入持久化 stub、对摘要而非信封做指纹,并为没有摘要行的旧 stub 保留去路径预览回退——正是 4bbf8eabd801 的做法。我没有找到比 PR 更简的路径。
手工核实(静态审查——不执行 PR 代码):
- 振荡修复(
f34098e6)。 追踪recordToolResult:statefulConsecutiveResults在指纹与同 key 前驱不同时重置为 1,A/B/A/B 面板永远不超过 1,不会喂到capMaxKeyRepeat或受门控的全局重复计数;冻结面板即使与其他调用交错也持续累积(计数按 key 而非按相邻)。整回合的statefulPairCounts已从两个消费方移除。三个新回归测试钉住启发式开启模式、CLI 默认(skipLoopDetection=true)模式下越过 100 次软上限、以及交错冻结方向(仍触发TURN_TOOL_CALL_CAP)。 - 失败保持安全的数学不变 —— 本次重新推导。 第 N 次相同请求时,守卫要求
resultsObserved >= N-1才信任该连续段;逐步验算:无证据 → 与修复前一致终止;部分证据(5 个请求只有 4 条结果)→ 终止;全部未变化 → 在与修复前相同的请求数处终止;任一结果变化 → 连续段重启。新测试钉住全部方向;DashScope 长程任务下,出现大量工具重复调用情况,导致会话被终止 #5019 的防护对非状态化工具与接线缺口均未被放松。 - provider 重复 callId 去重(验证发现 3,已恢复)。 两个宿主都按 attempt 去重守卫喂入,与执行侧折叠对齐:
agent-core.ts的loopGuardStreamedCallIds(在checkSubagentLoop之前),client.ts的loopGuardFedCallIds(同时门控checkAlwaysOnSafeties与addAndCheckHeuristicLoops)——重复事件本身仍流向下游消费者。两个集合在 retry/fallback 时随 attempt 状态一起清空;无 id 的调用永不去重,与dedupeToolCallsById一致。两个运行时均有端到端测试钉住。 - 超大结果 stub 指纹(验证发现 2,已恢复)。
buildStub现在嵌入完整截断前内容的Full output sha256: <摘要>;stripPersistenceEnvelope在哈希前把 stub 归约为<persisted-stub>sha256:<摘要>。识别锚定在生产者前缀开头,摘要行必须位于行首且恰为 64 位十六进制——同伴任务板内容中引用的 stub 标记不会被当作信封,板内容无法伪造信封路径(最坏情况仍受上限硬兜底约束)。无摘要的旧 stub 回退到去路径预览;预览窗口之外的变化由摘要覆盖。四个方向均有单元测试钉住。 - 此前未被钉住的接线(验证发现 4 与第 16 轮评审的延迟项)现已钉住:
client.test.ts四个测试通过真实sendMessageStream回合驱动主会话 ToolResult 分支(冻结以global_tool_call_duplicate终止、变化存活、重复 id 去同步、去重后冻结仍终止);agent-interactive.test.ts钉住停止信息中的检测器名;qwen-logger.test.ts钉住日志loop_type的传播与无循环时的缺省。 - 一个非阻塞的微观备注: 连续段内重置后
lastFingerprint保留,新段首个结果若与重置前指纹相同会被计为"未变化",使unchangedStreak多 1。本次再次追踪确认:该膨胀只有在段内结果全部未变化时才能存活到终止判定(任何一次变化都会把计数重置为精确值),而那正是应当终止的情形。不存在可达的误杀;不值得改代码,但读这段代码时值得知道。
(时序图与文件清单见英文部分)
测试证据 —— PR 自身 CI,经 API 读取
无人值守运行:此处从不构建或执行 PR 代码。在 4bbf8eabd801 上 CI 已全部转绿——两个 pull_request 工作流运行(Security Checks 与含 ubuntu 单元套件的 Qwen Code CI)均成功、无失败;本次经 check-runs API 复核,与表格区域定稿后一致。此处未验证:真实模型的实时多 agent 运行(作者声明范围外);@wenshao 备注 7.3 的 DashScope 服务端重复发送问题仍未解决(任何沙箱通道都没有 provider 凭据)。
沙箱验证:已在当前 head 上落地为绿。 维护者触发的 /verify 运行(actions/runs/32994114958)在本轮准备期间完成——判定 merge-ready,脚本断言 60/60 全过。此前各次等待的内容现已有证据落地:A/B 证实该改动在 4bbf8eabd801 上载荷成立(基线构建对 #9450 的振荡轮询仍在第 5 次误杀;PR 构建 120–150 次轮询不终止;冻结面板仍在第 5 次终止、与基线一致,包括第二轮 F2 曾逃逸的超大结果 stub 路径),且上一轮全部 4 个发现均已修复并被测试钉住——变异矩阵 12/12 全杀、零幸存,抖动门亦通过(5 个变更测试文件 × 5 轮无分歧)。该轮新增一条微瑕级 Suggestion(交错轮询 + 缺失结果证据时上限卡滞信号不再喂入;受 1000 次硬兜底约束,现网接线下不可达)——非阻塞,已记入风险节。另有一轮冗余的第二次验证(来自 18:21 的 /triage)仍在进行;仅为咨询性证据,覆盖同一 head。
— Qwen Code · qwen3.8-max
Reviewed at 4bbf8eabd801010c1a2df4aad5a0bb313c2bd350 · re-run with @qwen-code /triage
|
Confidence: 4/5 — solid; approving. The remaining items are non-blocking nits (named below), and the two things that kept prior passes at 3/5 are both resolved this pass. Stepping back: this is the fourth pass on an unchanged head, and it's the first one where the evidence position is complete. The independent A/B I was waiting on landed green on exactly this head while this run was preparing — verdict The second change this pass is a policy correction. Prior passes capped this at 3/5 citing the 500-production-line maintainer-awareness threshold. That threshold is part of the two-tier core gate, which exists to protect core infrastructure from external PRs — maintainer-authored PRs are explicitly exempt, and this PR's author is a repo admin. Deferring a verified, green, maintainer-authored fix over a gate that doesn't apply to it protects nothing; it just parks the merge decision on a human for no reason. So the cap comes off. What's left, honestly named — none of it blocking:
For the maintainer taking the merge: main requires two approvals, so this bot approval is one of the two votes. The daemon-side twin of the cap's stuck signal is tracked in #10161. 中文说明置信度:4/5 —— 扎实;予以批准。剩余项均为非阻塞微瑕(列于下方);此前各次停在 3/5 的两件事本轮均已解决。 退一步看:这是 head 未变下的第四次通过,也是第一次证据面完整的一次。此前等待的独立 A/B 已在当前 head 上落地为绿——判定 本次的第二处变化是策略更正。此前各次以"500 行生产代码的维护者关注阈值"将本 PR 封顶在 3/5。该阈值属于两层核心门禁,其目的是保护核心基础设施免受外部 PR 影响——维护者自己提交的 PR 明确豁免,而本 PR 作者是仓库 admin。用一个不适用于本 PR 的门禁去推迟一个已验证、全绿、维护者亲自提交的修复,没有保护任何东西,只是无谓地把合并决定压给人工。因此取消该封顶。 如实列出剩余项——均不阻塞:
对接手合并的维护者:main 要求两个批准,机器人批准是两票之一。上限卡滞信号的 daemon 侧孪生实现在 #10161 跟踪。 — Qwen Code · qwen3.8-max Reviewed at |
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
|
Closeout summary for exact head
中文摘要已为 result-aware loop stop 补齐 Arena 错误上报,与相邻退出路径一致;聚焦测试通过。daemon ACP cap 属于既有且超出 #9450 范围的问题,未在本 PR 引入改动。当前等待 exact-head CI。 |
|
@qwen-code /review --timeout=360 |
|
Qwen Code review request accepted. Review is queued in workflow run. |
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.21.14)
- R1-1: declare loop_type on SubagentExecutionEvent and serialize it in the QwenLogger sink; previously the spread in agent-headless.ts was dropped at construction (excess-property spread), so LOOP_DETECTED stops reached the journal unattributable. - R1-2: reset loopType at the top of AgentHeadless.execute() so a re-executed instance (stop-hook continuation, resident turns) never carries a stale loop attribution into an ERROR/FINISH record. - R1-12: pair only stateful read tools in requestByCallId — every other tool is rejected by recordToolResult anyway, and the write-only entries pin full args objects (write_file contents) up to the eviction cap. - R1-3: drop the dead 'key' field from requestByCallId entries; the consumer reads only name/args and recordToolResult recomputes the key. - Tests: assert loop_type in the telemetry completion record (R1-5) and pin the re-execution attribution reset.
…9450) - Extend the changed-results global-duplicate phase past GLOBAL_DUPLICATE_THRESHOLD rounds so an args-only mutant cannot hide. - Add heuristic-gate tests that Retry and reset() clear result-aware pair counts (cross-prompt and replay false positives). - Add the adaptive-cap positive-side test: an interleaved frozen poller halts with TURN_TOOL_CALL_CAP just past the soft cap. - Add the streak-move fail-safe test: a resumed streak with insufficient fresh result evidence halts instead of trusting stale evidence. - client.test.ts: drive sendMessageStream with paired ToolResult ids — frozen board halts with global_tool_call_duplicate, changing board keeps running. - agent-headless: interleaved frozen task_list polling halts via the result-time guard with FINISH loopType attribution. - agent-interactive: LOOP_DETECTED results surface the exact detector in the stop message and lastRoundError.
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.
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.
Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:
packages/core/src/agents/runtime/agent-interactive.ts:538 — [review] Stop message hard-codes 'duplicate tool-call loop' for all 11 loop typespackages/core/src/services/loopDetectionService.test.ts:2367 (+3 locations) — [probe] Stateful-evidence clears (Retry, reset, restart) are unpinned — mutants survivepackages/core/src/services/loopDetectionService.ts:296 — [review] recordToolResult's in-session-disable early return is untestedpackages/core/src/agents/runtime/agent-core.ts:1294 — [probe] Provider-replay LOOP_DETECTED stop carries no loopType attribution
中文说明
仅完成部分审查,审查缺口已披露。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
未审查:反向审计——在 5 轮的反审轮数上限内未收敛。
收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 4 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.21.14)
|
Patrol closeout — round fixing R2-3 / R2-9 (test-only, no production changes)
Not requesting |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.
Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:
packages/core/src/agents/runtime/agent-interactive.ts:544 — [probe] LOOP_DETECTED stop message hard-codes 'duplicate tool-call loop detected' for all 11 loop typespackages/core/src/services/loopDetectionService.ts:456 — [probe] checkAlternatingPattern remains argument-only for stateful read tools (ABAB poller with changing results still halts under skipLoopDetection=false)packages/core/src/services/loopDetectionService.ts:647 — [probe] No test pins the resultsObserved/unchangedStreak reset in checkToolCallLoop's restart branch — the #5019 fail-safe is unpinnedpackages/core/src/agents/runtime/agent-headless.test.ts:2262 — [review] The headless keep-polling test stops one request short of re-testing the restart branch's second cycle (pollCount = 8, second crossing at request 9)
中文说明
仅完成部分审查,审查缺口已披露。
未审查:reverse audit — did not converge within the reverse-audit round cap of 5。
收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 4 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.21.15)
…d results (#9450) Oversized results are rewritten into persistence stubs whose envelope embeds a per-call unique file path (<toolResultsDir>/<callId>.txt), so fingerprinting the whole model-visible response made every fingerprint unique and silently disabled every result-aware guard for exactly the largest results: a frozen task_list board over the persistence gate was never halted. Fingerprint the semantic payload instead — strip the stub envelope and hash the preview/truncated content that follows the stable marker.
|
Closeout — round-2 Critical bUUGp fixed and pushed. Fixed (8b5b610): oversized tool results were defeating every result-aware guard because the model-visible finalized part embeds a persistence envelope with a per-call unique file path ( Tests: 4 new cases under Verification: One non-force push 2ea0eea..8b5b610. The bUUGp thread is replied with SHA evidence and resolved. Remaining unresolved are the R1-7 (daemon/ACP |
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.
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.
Deferred under the convergence posture (round 4, not a blocker) — recorded, not requested in this round:
packages/core/src/agents/runtime/agent-interactive.ts:544 — [review] Stop message hard-codes 'duplicate tool-call loop detected' for every loop type — 7 of the 10 detectors render a self-contradicting attribution (unchanged code; also defer…packages/core/src/services/loopDetectionService.ts:502 — [probe] checkAlternatingPattern remains argument-only for stateful read tools — ABAB task_list poller with changing results still halts under skipLoopDetection=false (unchanged code; …packages/core/src/services/loopDetectionService.ts:685 — [probe] Partial result evidence is treated like no evidence — one unrecorded result in a parallel batch halts a productive poller despite every recorded result changed (probe-reproduc…packages/core/src/services/loopDetectionService.test.ts:2269 — [probe] Flood test's eviction pressure comes from a hand-mirrored MAX_TRACKED_TOOL_REQUESTS constant — upward drift of the source constant silently unpins the && stateful guard …packages/core/src/agents/runtime/agent-events.ts:212 — [review] R1-15 AgentFinishEvent.loopType is still write-only at HEAD — author declined for this PR (telemetry purpose fulfilled via SubagentExecutionEvent); deferred, not re-posted
中文说明
仅完成部分审查,审查缺口已披露。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
未审查:reverse audit — did not converge within the reverse-audit round cap of 5。
收敛姿态下延后(第 4 轮,非阻断)——已记录,本轮不要求修改:共 5 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.21.15)
|
Fixed in f34098e — R16-1 verified real at dfadc01: Fix: per-key consecutive identical-result counting — the count restarts at 1 whenever the result differs from its predecessor for that key — replacing the turn-wide pair totals in both consumers (global-duplicate guard and the cap's stuck signal). A genuinely frozen board still accumulates even when interleaved with other calls. Regression tests (
Both oscillating tests fail against the pre-fix source (verified by temporarily reverting the fix). Full suite: 140/140 passed; core typecheck clean. |
|
@qwen-code /triage |
1 similar comment
|
@qwen-code /triage |
|
Sandboxed verification: The verification job was cancelled before producing a report. 中文 — 判定:
|
…l guards (#9450) The sandboxed verification of the trimmed PR reported four findings; this restores the three behavior fixes it proved live and pins the four coverage gaps its mutation matrix exposed: - Oversized (persisted) results escaped every result-aware guard: the stub envelope embeds a per-call unique path, so hashing it verbatim fingerprinted uniquely every poll and a frozen board read as "changed every time" (probe P2: no halt in 12 polls vs base halting at 5). buildStub now embeds a sha256 of the full pre-truncation output ("Full output sha256: <hex>"), and the guard's fingerprint reduces leading-producer stubs to that digest — with a path-free preview/ truncated-part payload fallback for digest-less stubs and verbatim treatment of non-stub text (a mid-content quoted marker never matches: recognition is prefix-gated and the digest label must start its line). Frozen oversized boards halt at the unchanged threshold regardless of the per-call path; mutations past the preview window stay visible. - Provider-duplicate call ids halted a productive poller fail-safe (probe P3): request counts fed pre-dedup while results land once per deduped executed call. Both reasoning-loop owners now feed the guards one ToolCallRequest per call id per attempt (a per-attempt Set mirroring dedupeToolCallsById; id-less calls never deduped), cleared on retry/fallback — agent-core's subagent stream loop and client.ts's main-session stream. - Coverage pins (finding F4): restored the client.ts recording-wiring tests (frozen halt, changed survival, duplicate-id population parity); added QwenLogger loop_type journal tests, the interactive stop-message attribution test, and the partial-evidence fail-safe fixture. Verification: packages/core tsc clean; loopDetectionService 145/145, agent-headless 68/68, client 370/370, agent-interactive 25/25, qwen-logger 43/43, truncation/shell/finalizer 368/368; prettier/eslint clean on touched files.
|
Addressing all four verification findings — fixes and pins are on this branch ( F1 — oscillating boards (fixed in F2 — oversized (persisted) results (fixed). The escape was real, not hypothetical — reversing the earlier scope cut. F3 — provider-duplicate call ids (fixed, both owners). The subagent stream loop (agent-core) and the main-session stream (client.ts) now feed the guards one F4 — coverage gaps (pinned).
Verification: 中文:四个 findings 全部处理(修复 + 钉住测试在 |
|
Triage re-run completed without a new review.
The stage comments above were updated with the latest result. View workflow run. 上方各阶段评论已更新为最新结果。查看工作流运行。 |
|
@qwen-code /triage |
1 similar comment
|
@qwen-code /triage |
|
Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 58 passed · 0 failed · 58 total Flakiness gate: ✅ 2 changed test file(s) x 5 identical rounds, no divergence 中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:58 通过 · 0 失败 · 58 总计 抖动门:✅ 2 changed test file(s) x 5 identical rounds, no divergence Verification reportPR #9492 deep verification (follow-up round) —
|
| # | Finding | Severity | Status at new head |
|---|---|---|---|
| 1 | Oscillating boards (two alternating states, every result differs from its predecessor) still halted — poll 11 with heuristics on (global_tool_call_duplicate), poll 101 under the CLI default (turn_tool_call_cap); suite pinned neither regime |
Suggestion | fixed — the delta commit replaces turn-wide (call, result) pair totals with per-key consecutive identical-result counts that restart at 1 whenever the result differs from its predecessor. Re-measured: oscillating board survives 24 polls with heuristics on (cell O1; base halts at 5) and 120 polls past the 100-call soft cap under the CLI default (cell O2); interleaved frozen boards still halt via the adaptive cap on both arms (O3). The delta's new tests are not vacuous: reverting the mechanism (mutation M7) fails 4 tests, including the E2E headless poller whose board alternates between exactly two states. |
| 2 | Oversized (>25 000 chars) task_list results are persisted into stubs whose envelope embeds a per-call unique path (<toolResultsDir>/<callId>.txt), so every fingerprint is unique and a frozen board escapes every result-aware guard — protection loosened vs base |
Suggestion | stands (declined-with-rationale) — re-measured: frozen board delivered as realistic per-call stubs → no halt in 12 polls at head (S1) while base halts at 5; the constant-path control halts at 5 (S2), isolating the per-call path as the cause. The scope note still lists this hardening as intentionally trimmed ("an unobserved edge case; revisit only with real evidence"); the description is unchanged. The escape is bounded only by the 1000-call hard backstop. |
| 3 | Provider-duplicate call ids halt a productive poller: request counts (raw provider stream) and result evidence (post-dedupe executions) come from different populations, so the fail-safe halts at the 5th request even with changing results | Suggestion | stands — re-measured (cell D1): 5 request events including a duplicated callId with 3 recorded changing results halts at the 5th request at head, exactly as at base. Safe direction (halts, never runs away); the trimmed fix (intermediate commit 1ac2a81, a per-round Set of provider call ids feeding the guard) was not restored. |
| 4 | Coverage gaps proven by the mutation matrix: client.ts ToolResult wiring, QwenLogger loop_type serialization, interactive stop message |
Suggestion | stands, refined — re-measured in the new matrix: M4 removing the entire client.ts recording block leaves client.test.ts 366/366 green (control M4c kills 4); M5 removing the loop_type serialization leaves qwen-logger + headless 108/108 green (control M5c kills 3); M8 removing the interactive stop-message suffix leaves agent-interactive.test.ts 24/24 green — the suite never exercises any mode that renders terminateModeMessage (only 'cancelled', which returns null). New granularity: the fail-safe evidence-completeness gate (resultsObserved >= expectedResults) is also unpinned (M2 survives 207/207; the shipped probe fixture is red on the mutant, green at head). |
Central claim + A/B
Central claim: for the stateful read tool task_list, the consecutive-identical guard becomes result-aware — identical calls with changing results are productive polling (no halt), identical calls with unchanged results still halt at the same request count, and missing result evidence fails safe (pre-fix behavior).
Harness: harness-ab-service.mjs drives the compiled dist/ LoopDetectionService (no mocks of the unit under test) on both trees; per-arm expectations are encoded so predicted base-arm reds count as passing assertions. Witnesses: 01-ab-base-arm.png, 02-ab-head-arm.png; raw logs logs-ab-base.txt, logs-ab-head.txt.
| Cell | Scenario (oracle) | base 83da7233a8 |
head f34098e680 |
Expectation |
|---|---|---|---|---|
| C1 | 20 identical task_list requests, board changes after each execution |
halts at request 5 (consecutive_identical_tool_calls) — the #9450 false positive |
no halt in 20 | base red / head green |
| C2 | frozen board, results recorded | halts at 5 | halts at 5, consecutive_identical_tool_calls |
parity |
| C3 | no results ever recorded (wiring-gap fail-safe) | halts at 5 | halts at 5 | parity (#5019 preserved) |
| C4 | deterministic tool (read_file), changing recorded results |
halts at 5 | halts at 5 | parity (no exemption leak) |
| C5 | heuristics on, interleaved calls, changing results | halts (global_tool_call_duplicate, request-time) |
no halt in 8 rounds | base red / head green |
| C6 | heuristics on, interleaved calls, frozen results | halts (global_tool_call_duplicate) |
halts at the 6th recorded result (result-time), same loop type | parity, new detection point |
| C7 | callId pairing API (recordToolResultByCallId) |
API absent | changing results via pairing keep the 5th request alive; unknown id → false |
head-only API works |
| O1 | F1: oscillating A/B board, heuristics on, 24 polls | halts at 5 (args-only) | no halt (was: halt at 11 pre-delta) | fixed |
| O2 | F1: oscillating A/B board, CLI default (skipLoopDetection=true), 120 polls past the 100 soft cap |
halts at 5 | no halt (was: halt at 101 pre-delta) | fixed |
| O3 | interleaved frozen board + fillers past the soft cap | halts, turn_tool_call_cap |
halts, turn_tool_call_cap (result-time stuck signal) |
parity |
| S1 | F2: frozen board as persisted stubs with per-call unique paths, 12 polls | halts at 5 | no halt (escape re-measured; trimmed hardening) | stands |
| S2 | F2 control: identical stubs with a constant path | halts at 5 | halts at 5 | escape is the per-call path |
| D1 | F3: 5 request events incl. a duplicated callId, 3 recorded changing results | halts at 5 (args-only) | halts at 5 (fail-safe; residual false positive) | stands, not a regression |
All 38 scripted assertions (19 per arm) passed. Base control purity: worktree at HEAD^1, only packages/core rebuilt; root node_modules symlinked (lockfile untouched — empty package.json/package-lock.json diff asserted); the nested packages/core/node_modules (ajv 8.20.0 etc.) symlinked from the identical-lockfile install, readlink -f verified; packages/core has no internal @qwen-code/* dependencies (its package.json mentions the scope only as its own name), so no workspace symlink can leak head code; base dist asserted free of result-aware code (grep -c recordToolResult → 0 vs 12 at head).
Targeted gates (head, unmutated): loopDetectionService.test.ts + agent-headless.test.ts 207/207; client.test.ts + qwen-logger.test.ts + agent-interactive.test.ts 431/431; tsc --noEmit (packages/core) clean. Full-suite parity: head 70 failed | 21 931 passed | 10 skipped vs base 70 failed | 21 913 passed | 10 skipped — the 64 unique failing test names are byte-identical on both arms (diff core-full-suite-{base,head}.fails.txt empty, same 8 environment files: ide-client 18, logger 31, memoryDiscovery 6, file-token-storage 5, skill-manager 4, subagent-manager 3, installationManager 2, rulesDiscovery 1), delta +17 passing = exactly the PR's 17 added tests (14 service + 3 headless), +0 failing. (The raw head count includes one extra passing test — this round's verification probe file, since removed from the tree; the +17 attribution above excludes it.)
Corrections
Carried from the previous round, still applicable: the PR body attributes the pre-existing packages/core failures to "system git predates git init -b". This container runs git 2.39.5 and the identical 70 failures reproduce on both arms at the new base tip — the identity claim is confirmed (no new failures introduced by the PR), the cause is still not (the failures are environment/mock-order issues in files the PR does not touch). No code change requested; correcting the record for the next reader.
Findings
No blockers. Three re-measured Suggestions carried from the previous round, in severity order.
F2 — Oversized (persisted) task_list results escape every result-aware guard — protection loosened vs base for results >25 000 chars (Suggestion, stands)
coreToolScheduler persists any tool result over DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD (25 000 chars) into a <persisted-output> stub whose header embeds a per-call unique path <toolResultsDir>/<callId>.txt (tools/truncation.ts buildStub). extractResultText hashes the whole model-visible response, so every poll fingerprints uniquely and a frozen board reads as "changed every time". Re-measured at the new head (cell S1/S2, harness-ab-service.mjs):
- frozen board delivered as realistic per-call stubs → no halt in 12 polls at head; base (args-only) halts at 5 — a loosening versus base for this result class;
- identical stubs with a constant path → halts at 5, isolating the per-call path as the cause;
- consequence: the only remaining bound for oversized stateful results is the 1000-call adaptive hard backstop.
Repro: node tmp/pr9492-verify-20260826-170042/harness-ab-service.mjs head (cells S1/S2).
This is the "truncated-result fingerprint hardening" the scope note says was cut as "an unobserved edge case; revisit only with real evidence" — the mechanism is observably live on the production path (any task_list result over 25K chars). The trimmed history contains the demonstrated fix direction (digest-anchored stub parsing, commits 8b5b610d/f4e85c74/167b0af5-family). Suggested resolution: if the cut stands by authorial decision, the description should say oversized stateful results are exempt (its "never loosened" sentence covers missing evidence only, while this is uniquely-varying evidence); otherwise restore the digest-based parsing.
F3 — Provider-duplicate call ids halt a productive poller (fail-safe) — the trimmed population fix is still missed (Suggestion, stands)
agent-core.ts feeds the guard one ToolCallRequest event per provider function call (raw chunkFunctionCalls loop, ~line 1120 — no per-round call-id Set exists at this head), while execution dedupes by call id (dedupeToolCallsById, ~line 1676) and records one result per unique id. Request counts and result evidence therefore come from different populations. Re-measured (cell D1): a changing-board poller whose second round emits the same call id twice halts at the 5th emitted request via the fail-safe (3 results observed < 4 expected → no exemption), exactly as at base — not a regression, but a residual false positive of exactly the class this PR exists to remove. Safe direction (halts, never runs away). The trimmed intermediate commit 1ac2a81 ("count deduped provider call ids once in the subagent loop guard", a per-round Set mirroring dedupeToolCallsById) fixed precisely this. Repro: node tmp/pr9492-verify-20260826-170042/harness-ab-service.mjs head (cell D1).
F4 — Coverage gaps re-proven at the new head: client.ts wiring, telemetry sink, interactive message — plus the fail-safe completeness gate (Suggestion, stands)
Mutation matrix on head (each row a real vitest run; witness 03-mutation-matrix-summary.png, live M1 run 04-m1-live-carve-out-removed.png; positive controls landed in the same file as their mutants):
| # | Mutation | Suite | Result |
|---|---|---|---|
| M1 | remove result-aware carve-out in checkToolCallLoop |
service + headless | KILLED — 8 failed | 199 passed; failures are the intended behavioral assertions (expected true to be false on changing boards) |
| M2 | drop evidence-completeness check (state && observed>=expected → state) |
service + headless + probe | SURVIVED — 207/207 green; probe fixture (partial evidence: 3 of 4 results recorded, changing) is red on the mutant (expected false to be true), green at head → coverage gap, fixture shipped (fixture-partial-evidence-failsafe.test.ts) |
| M3 | remove loopType spread from FINISH emit (agent-headless.ts) |
headless -t "issue #9450" |
KILLED — 2 failed, expected undefined to be 'consecutive_identical_tool_calls' |
| M4 | revert the entire client.ts ToolResult recordToolResultByCallId wiring |
client.test.ts |
SURVIVED — 366/366 green: the main-session recording owner the description cites is pinned by nothing |
| M4c | control: always-on LoopDetected yield → wrong event type |
client.test.ts |
KILLED (control) — 4 failed; the suite provably exercises client.ts |
| M5 | remove loop_type serialization in QwenLogger.logSubagentExecutionEvent |
qwen-logger + headless | SURVIVED — 108/108 green (the headless suite mocks logSubagentExecution, so journal attribution — issue requirement #7 — is unpinned end to end) |
| M5c | control: drop duration_ms in logHookCallEvent |
qwen-logger.test.ts |
KILLED (control) — 3 failed |
| M6 | control: threshold >= → > |
service | KILLED — 10 failed |
| M7 | delta revert: turn-wide pair accumulation instead of consecutive counts | service + headless | KILLED — 4 failed: both oscillation tests, the past-cap survival test, and the E2E headless poller → the delta's new tests pin exactly its mechanism (not vacuous) |
| M8 | remove the (loopType) suffix from the interactive stop message |
agent-interactive.test.ts |
SURVIVED — 24/24 green; the suite never exercises any terminate mode that renders terminateModeMessage (only 'cancelled', which returns null) — the whole function has zero test reachability |
Classification: all four survivors are coverage gaps (behavior correct at head, nothing asserts it) — not dead code (each mutant changes observable behavior, proven by the probes/controls), not redundant defence. Fixtures that would pin them: fixture-partial-evidence-failsafe.test.ts (M2, shipped and measured); a client.test.ts arm driving sendMessageStream with paired functionResponse ids and asserting the halt/record (the intermediate commit b0db4304 had one before the trim, M4); a qwen-logger.test.ts case asserting loop_type in the serialized properties (M5); an agent-interactive.test.ts case whose mocked core returns LOOP_DETECTED with a loopType and asserts the suffixed message/lastRoundError (M8).
Not covered
- Per-commit attribution: the checkout is depth-2 (3 commits reachable) while the snapshot lists 55 commits;
git rev-list HEAD^1..HEAD^2returns 1 at the shallow boundary (plausible-but-wrong number, per the shallow caveat). The aggregateHEAD^1..HEADdiff is what was verified; individual commits were not exercised separately. The delta since the previous round is exactly one commit (f34098e680), verified end to end. - Live multi-agent E2E with real model traffic — declared out of scope by the PR; the headless E2E tests mock the model stream (they do exercise the real agent runtime, scheduler, and loop-detector wiring).
- Alternating-pattern detector — declared unchanged; confirmed statically that
checkAlternatingPatterncarries no stateful carve-out at this head (an ABABtask_listshape is still counted args-only at request time). Not re-measured behaviorally. - Daemon/ACP path — declared deferred to Make daemon/ACP adaptive-cap stuck signal result-aware for stateful reads (follow-up from #9492) #10161; the diff touches no daemon code (diffstat verified).
packages/clisuite — diff is core-only; the CLI default regime (skipLoopDetection ?? true,packages/cli/src/config/config.ts:2372) was exercised at the service level (cells O2/O3). No cli-side probe was run.send_message/task_update— deliberately argument-only per the PR's declared narrow scope (STATEFUL_READ_TOOLS={'task_list'}); not probed.- Probes drive the service's public API exactly as the two recording owners call it; the runtime path is covered by the PR's mocked-model headless tests, not by a live
sendMessageStream. - The flakiness gate (changed test files × N rounds) is owned by the workflow, not this round.
Methodology
Environment: CI verify container (node:22-bookworm, node v22.23.2, git 2.39.5), merge ref refs/pull/9492/merge at depth 2; npm ci + full build completed before verification (environment contract). A/B: head dist from the workflow build; base control in tmp/base-tree (worktree at HEAD^1, only packages/core rebuilt — one rebuild obstacle fixed along the way: the worktree lacks the gitignored nested installs, so ajv/dist/2020.js and @lydell/node-pty types resolved wrong until the identical-lockfile nested packages/core/node_modules and the root node_modules were symlinked in; readlink -f verified, no internal @qwen-code/* links involved). The harness imports the compiled dist/src/services/loopDetectionService.js directly and drives checkAlwaysOnSafeties / addAndCheck / recordToolResult[ByCallId] with ToolCallRequest events and functionResponse parts shaped like production traffic (persisted-stub envelopes for F2 reproduced verbatim from tools/truncation.ts buildStub); loggers run real with getUsageStatisticsEnabled()=false (QwenLogger no-op, OTel uninitialized). Mutations were single-point source edits on head, run through vitest, reverted with git checkout (tree confirmed clean after each, probe fixture and base worktree removed at the end). Full-suite attribution compared stripped-ANSI × lines of both arms verbatim. Raw logs and rerunnable harness/fixtures live in this artifact dir (node harness-ab-service.mjs <base|head>).
Flakiness gate log
rounds=5 files=2 skipped=0
file packages/core/src/agents/runtime/agent-headless.test.ts: (cd packages/core) npx --no-install vitest run ./src/agents/runtime/agent-headless.test.ts
file packages/core/src/services/loopDetectionService.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/loopDetectionService.test.ts
per-file results (P=pass F=fail I=infra-exit, one letter per run):
packages/core/src/agents/runtime/agent-headless.test.ts: PPPPP
packages/core/src/services/loopDetectionService.test.ts: PPPPP
verdict: pass
summary: 2 changed test file(s) x 5 identical rounds, no divergence
--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 1 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 2 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 2 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 3 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 3 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 4 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 4 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 5 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 5 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 60 passed · 0 failed · 60 total Flakiness gate: ✅ 5 changed test file(s) x 5 identical rounds, no divergence 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:60 通过 · 0 失败 · 60 总计 抖动门:✅ 5 changed test file(s) x 5 identical rounds, no divergence Verification reportPR #9492 deep verification (follow-up round) —
|
| # | Finding (prev round) | Severity | Status at new head 4bbf8eabd8 |
|---|---|---|---|
| F1 | Oscillating boards still halted (heuristics-on @11 via global_tool_call_duplicate, default @101 via cap stuck signal), contrary to the description; suite unpinned |
Suggestion | fixed — consecutive (order-aware) result counts restored (statefulConsecutiveResults restarts at 1 on every changed result). Re-measured C5/C7: oscillating board survives 150 polls (default regime) and 30 polls (heuristics on); base halts @5 in both. Mutation M7 (fingerprint-blind counting) is killed by exactly the 3 oscillation/cap tests the delta added, with the frozen-board control staying green. |
| F2 | Oversized (persisted) results escape every result-aware guard via per-call unique stub paths (>25 000 chars ⇒ never halts; base halted @5) | Suggestion | fixed — buildStub now embeds Full output sha256: <sha256 of FULL content>; the guard reduces stubs to the anchored digest (stripPersistenceEnvelope/extractAnchoredStubDigest, line-anchored, 64 lowercase hex, producer-prefix-gated). Re-measured F2a–F2d + real-producer E2E: frozen oversized board halts @5 (base parity), mutations beyond the 2000-char preview keep polling alive, digest-less stubs fall back to the path-free preview, mid-content quoted markers fingerprint as ordinary text. Mutation M8 (digest disabled) is killed by exactly the beyond-preview test. |
| F3 | Provider duplicate call ids halt a productive poller via the fail-safe (request counts and result evidence from different populations) | Suggestion | fixed — guard-feed dedup restored on both owners: loopGuardStreamedCallIds in agent-core.ts and loopGuardFedCallIds in client.ts (events still flow to consumers; only the guard feed is deduped; id-less calls never deduped, mirroring execution). Mutations M9/M10 (dedup removed) are each killed by the new "counts a provider-duplicate call id once" tests (expected "spy" to be called 6 times, but got 3 times). |
| F4 | Coverage gaps: client.ts ToolResult wiring unpinned (M4 survived), QwenLogger loop_type unpinned (M5 survived), interactive stop message had zero tests |
Suggestion | fixed — the delta adds tests for all three plus the partial-evidence fail-safe. Re-measured matrix: M4 killed (2 red), M5 killed (1 red), M6/interactive killed (1 red), M2/partial-evidence killed (1 red). Matrix is now 12/12 killed, 0 survivors (03-mutation-matrix-summary.png). |
Central claim + A/B
Central claim (unchanged from the previous round): for the stateful read tool task_list, the consecutive-identical guard becomes result-aware — identical calls with changing results are productive polling (no halt), identical calls with unchanged results still halt at the same request count, and missing result evidence fails safe (pre-fix behavior, #5019 preserved).
Harness: harness-ab-service.mjs drives the compiled dist/ LoopDetectionService (no mocks of the unit under test) on both trees, mirroring the two recording owners (checkAlwaysOnSafeties/addAndCheckHeuristicLoops on requests, recordToolResult[ByCallId] after execution). Base control: worktree at HEAD^1 with only packages/core rebuilt; root node_modules reused — clean control because the PR leaves package.json/package-lock.json untouched (diff empty) and packages/core has zero @qwen-code/* dependencies (workspace symlinks cannot leak head code; readlink -f verified both links). Base dist asserted free of result-aware code (grep -c recordToolResult → 0 vs present at head; base proto lacks recordToolResult/recordToolResultByCallId). Witnesses: 01-ab-base-arm.png, 02-ab-head-arm.png; raw logs logs/ab-{base,head}.log.
| Cell | Scenario (oracle) | base a82a11a0a4 |
head 4bbf8eabd8 |
Expectation |
|---|---|---|---|---|
| API | recording API present | absent | present | arm-specific |
| C1 | 120 identical polls, board changes after each execution | halts @5 (the #9450 false positive) | no halt in 120 | base red / head green |
| C2 | frozen board, results recorded | halts @5 | halts @5 consecutive_identical_tool_calls |
parity |
| C3 | no results ever recorded (wiring-gap fail-safe) | halts @5 | halts @5 | parity (#5019 preserved) |
| C4 | deterministic tool (read_file), changing recorded results |
halts @5 | halts @5 | parity (no exemption leak) |
| C5 | F1 · oscillating board A/B, 150 polls, default regime | halts @5 | no halt in 150 | base red / head green |
| C6 | interleaved decoy + frozen board, results recorded | halts @101 calls turn_tool_call_cap |
halts @101 calls turn_tool_call_cap (result-fed signal) |
parity — halt point does not move |
| C6b | interleaved decoy + frozen board, no results recorded | halts @101 calls | halts @1001 calls (hard backstop only) | measured asymmetry, see F-new |
| C7 | F1 · oscillating board, 30 polls, heuristics ON | halts @5 | no halt in 30 | base red / head green |
| C8 | heuristics ON · interleaved decoy · changing results | halts @ task_list req 6 (global_tool_call_duplicate, request-time) |
no halt in 12 rounds | base red / head green |
| C9 | heuristics ON · interleaved decoy · FROZEN results | halts @ req 6 (request-time) | halts @ result 6 (global_tool_call_duplicate, result-time) |
parity, new detection point |
| C10 | partial evidence (3 of 4 results recorded) | halts @5 | halts @5 (fail-safe) | parity |
| C11 | callId pairing: 8 changing polls + unknown id | n/a (API absent) | no halt; unknown → false |
head-only |
| C12 | callId pairing: frozen board | n/a | halts @5 | head-only |
| F2a | F2 · frozen oversized board via per-call-unique stub paths (digest-bearing) | halts @5 | halts @5 | parity restored (old head: no halt) |
| F2b | F2 · oversized board mutating beyond the preview window | halts @5 | no halt in 12 | base red / head green |
| F2c | F2 · digest-less legacy stub, frozen preview | halts @5 | halts @5 (preview fallback) | parity |
| F2d | F2 · quoted digest marker mid-content, changing text | halts @5 | no halt in 8 (markers ignored mid-string) | base red / head green |
| NF | non-file stub branch (persistence unavailable), frozen | halts @5 | halts @5 | parity |
| RP | real persistAndTruncateToolResult E2E: frozen / mutating-beyond-preview |
halts @5 / halts @5 | halts @5 / no halt in 12 | F2 proven through the actual producer |
All 40 harness assertions passed (base 16 + head 18 + NF 2 + RP 4). Secondary claims re-verified: attribution (ReasoningLoopResult.loopType → FINISH event → telemetry loop_type → journal) killed by M3/M5 mutations; result recording fires from both owners (M4/M10 reverts each killed).
Targeted gates (head): loopDetectionService.test.ts 145/145, agent-headless.test.ts 68/68, client.test.ts 370/370, qwen-logger.test.ts 43/43, agent-interactive.test.ts 25/25 — all matching the PR body's claimed counts exactly; the Reviewer Test Plan's filtered run (-t "issue #9450") passes 4/4. Full packages/core suite: head 21 977 passed / 79 failed / 10 skipped vs base 21 947 passed / 79 failed / 10 skipped — the 73 unique failing test names are byte-identical on both arms (core-full-suite-fails.diff.txt empty), delta +30 passing (= the PR's new tests), +0 failing (04-full-suite-gate-both-arms.png). tsc --noEmit (packages/core) clean. (The 79 failures are pre-existing at this base tip — sampled client.telemetrySwap.test.ts: TypeError: this.config.getToolRegistry is not a function, a fixture issue in files this PR does not touch; the previous round saw 70 at the older base tip 4c51f8cb9c, the set moves with the base.)
Corrections
None new this round. The previous round corrected the PR body's attribution of the pre-existing failures to an old system git; the body no longer carries that claim, and this round's empty failure-list diff confirms the identity claim ("pre-existing failures reproduce identically on the unmodified baseline") at the new base tip.
Findings
F-new — The cap's stuck signal has no missing-evidence fail-safe for INTERLEAVED stateful polling; bounded at the 1000-call hard backstop instead of 101 (Suggestion, unreachable in current wiring)
trackCapKeyRepeat is skipped unconditionally for stateful tools (if (!stateful) in checkAlwaysOnSafeties), so the adaptive cap's stuck signal is fed only by recorded results. When no results are recorded (a wiring gap) AND the polls are interleaved with other calls (so the consecutive guard never reaches threshold), nothing accumulates and only the hard backstop remains. Measured (cell C6b, both arms): interleaved frozen task_list, results never recorded — base halts at total call 101 (request-fed stuck signal), head halts at total call 1001 (turn_tool_call_cap hard backstop). The heuristics-on twin shows the same shape: base halts at the 6th request via request-time global_tool_call_duplicate; with missing evidence the head-side result-time counterpart never fires, leaving the same 1000-call bound.
Bounding: unreachable in the current wiring — checkAlwaysOnSafeties has exactly two production consumers (agent-core.ts:938, client.ts:3774) and both record every executed task_list result (recordToolResult after processFunctionCalls; recordToolResultByCallId per functionResponse id); the daemon/ACP path runs its own per-turn tracker (acp-integration/session/Session.ts), not this service. The non-interleaved missing-evidence case remains fail-safe via the consecutive guard (halts @5, cells C3/C10). The description's Risk section already names the hard backstop as the residual bound for stuck polls, so the behavior is inside the stated envelope; the mismatch is only that the body's "missing result evidence fails safe" sentence is scoped to the DashScope/consecutive guard but reads universal. Suggested resolution (author's call): a one-line comment on the if (!stateful) skip noting the hard-backstop-only bound for the wiring-gap case, or a fail-safe fallback (request-time counting until the first result arrives for a key). No code change is required by this report.
Mutation matrix (12/12 killed · 0 survivors)
Each row a real vitest run on head source (single-point edit, reverted with git checkout after; tree confirmed clean each time). Witnesses 03-mutation-matrix-summary.png and 05-m1-carve-out-removed-live.png (live M1 run); raw logs logs/m*.log. Positive controls landed in the same files as their mutants.
| # | Mutation | Suite | Result |
|---|---|---|---|
| M1 | remove the result-aware carve-out (checkToolCallLoop) |
service 145 | KILLED — 10 failed; expected true to be false on the changing board (control) |
| M7 | fingerprint-blind consecutive counts (F1 revert) | service 145 | KILLED — 3 failed: exactly the oscillation/cap pins; frozen-board pin stays green |
| M8 | stub digest extraction disabled (F2 revert) | service 145 | KILLED — 1 failed: "mutations land beyond the preview window"; frozen-oversized pin stays green via the preview fallback |
| M2 | drop the evidence-completeness condition | service 145 | KILLED — 1 failed: "result evidence is only partial (fail-safe)" (prev round: SURVIVED) |
| MX | invert the corroboration comparison (exemption always applies) | service 145 | KILLED — 5 failed incl. all frozen-board halt pins (vacuity check: the halt is pinned by the comparison, not just the threshold) |
| M6c | threshold >= → > (positive control) |
service 145 | KILLED — 11 failed (control) |
| M3 | remove loopType from the FINISH emit |
headless 68 | KILLED — 2 failed, expected undefined to be 'consecutive_identical_tool_calls' |
| M9 | remove agent-core guard-feed dedup (F3 revert) | headless 68 | KILLED — 1 failed: "counts a provider-duplicate call id once" (expected "spy" to be called 6 times, but got 3 times) |
| M4 | disable client.ts recordToolResultByCallId wiring |
client 370 | KILLED — 2 failed: frozen-board halt + provider-dup pins (prev round: SURVIVED) |
| M10 | disable client guard-feed dedup (F3 revert) | client 370 | KILLED — 1 failed: "counts a provider-duplicate call id once" |
| M5 | remove loop_type serialization (QwenLogger) |
qwen-logger 43 | KILLED — 1 failed: "journals the loop detector attribution" (prev round: SURVIVED) |
| M6 | generic interactive stop message | interactive 25 | KILLED — 1 failed: "surfaces the exact loop detector" (prev round: zero tests) |
Every revert mutation of a restored fix (M7/M8/M9/M10) is killed by the tests this delta added, and all three previous survivors are now pinned — the suite holds down what the PR claims.
Not covered
- Per-commit attribution: the checkout is depth-2 (
git rev-list HEAD^1..HEAD^2returns 1 at the shallow boundary while the snapshot lists 56 commits) — only the merge, base tip, and PR head are reachable. The aggregateHEAD^1..HEADdiff is what was verified. The previous headdfadc01bobject happens to survive in the object store, so the delta since the previous round (+821/−23, 9 files) was diffed directly, but its intermediate commits (f34098e6etc.) remain unreachable. - Live multi-agent E2E with real model traffic — declared out of scope by the PR; the headless E2E tests mock the model stream. The C6b/F-new corner is therefore a wiring analysis plus service-level measurement, not an observed production failure.
- Alternating-pattern detector — declared unchanged (an ABAB shape of two alternating keys still trips it args-only; this PR's cells used varied-args decoys, which do not).
- Daemon/ACP path — declared deferred to Make daemon/ACP adaptive-cap stuck signal result-aware for stateful reads (follow-up from #9492) #10161; the diff touches no daemon code (verified by diffstat);
Session.tskeeps its own per-turn cap. packages/clisuite — diff is core-only; the cli build ran green as part of the workflow's pre-verification build. No cli-side probe was run.- The F2 stub shapes were exercised through the real
persistAndTruncateToolResult(file branch) and a faithful replica of the non-file branch; the shell-onlytruncateToolOutput"Truncated part of the output" shape is recognized by the stub grammar but is not reachable fortask_list(shell-tool path), so it was not probed. - The harness drives the service's public API exactly as the two recording owners call it; the transport (a live
sendMessageStream) is exercised by the PR's own client/headless tests, which the mutation matrix proves non-vacuous. - Flakiness gate (changed files × 5 rounds) is run by the workflow itself, as in the previous round.
Methodology
Environment: CI verify container (node:22-bookworm, node v22.23.2), merge ref refs/pull/9492/merge at depth 2; npm ci + full build completed before verification (dist freshness asserted by grepping the new symbols). A/B: head dist from the workflow build; base control built in tmp/base-tree (worktree at HEAD^1, only packages/core rebuilt via scripts/build_package.js with the root node_modules symlinked — realpath-verified; no internal @qwen-code/* deps in packages/core, lockfile untouched). The harness (harness-ab-service.mjs) imports the compiled dist/src/services/loopDetectionService.js directly and drives checkAlwaysOnSafeties / addAndCheckHeuristicLoops / recordToolResult[ByCallId] with production-shaped ToolCallRequest events and functionResponse parts; loggers run real with getUsageStatisticsEnabled()=false (QwenLogger no-ops) and OTel uninitialized. The oversized-stub cells were additionally verified through the real compiled persistAndTruncateToolResult (/tmp/probe-real-producer.mjs). Mutations were single-point source edits on head, run through vitest, and reverted (git checkout); the tree was confirmed clean after each. Full-suite attribution compared stripped FAIL lines of both arms verbatim (diff empty). Evidence images via scripts/verify-capture.mjs. Raw logs: logs/ab-{base,head}.log, logs/m*.log, logs/full-suite-{head,base}.log, core-full-suite-{head,base}.fails.txt; all harnesses are rerunnable (node harness-ab-service.mjs <tree> <base|head>).
Flakiness gate log
rounds=5 files=5 skipped=0
file packages/core/src/agents/runtime/agent-headless.test.ts: (cd packages/core) npx --no-install vitest run ./src/agents/runtime/agent-headless.test.ts
file packages/core/src/agents/runtime/agent-interactive.test.ts: (cd packages/core) npx --no-install vitest run ./src/agents/runtime/agent-interactive.test.ts
file packages/core/src/core/client.test.ts: (cd packages/core) npx --no-install vitest run ./src/core/client.test.ts
file packages/core/src/services/loopDetectionService.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/loopDetectionService.test.ts
file packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: (cd packages/core) npx --no-install vitest run ./src/telemetry/qwen-logger/qwen-logger.test.ts
per-file results (P=pass F=fail I=infra-exit, one letter per run):
packages/core/src/agents/runtime/agent-headless.test.ts: PPPPP
packages/core/src/agents/runtime/agent-interactive.test.ts: PPPPP
packages/core/src/core/client.test.ts: PPPPP
packages/core/src/services/loopDetectionService.test.ts: PPPPP
packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: PPPPP
verdict: pass
summary: 5 changed test file(s) x 5 identical rounds, no divergence
--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 1 · packages/core/src/agents/runtime/agent-interactive.test.ts: P (exit 0)
round 1 · packages/core/src/core/client.test.ts: P (exit 0)
round 1 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 1 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 2 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 2 · packages/core/src/agents/runtime/agent-interactive.test.ts: P (exit 0)
round 2 · packages/core/src/core/client.test.ts: P (exit 0)
round 2 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 2 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 3 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 3 · packages/core/src/agents/runtime/agent-interactive.test.ts: P (exit 0)
round 3 · packages/core/src/core/client.test.ts: P (exit 0)
round 3 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 3 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 4 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 4 · packages/core/src/agents/runtime/agent-interactive.test.ts: P (exit 0)
round 4 · packages/core/src/core/client.test.ts: P (exit 0)
round 4 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 4 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 5 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 5 · packages/core/src/agents/runtime/agent-interactive.test.ts: P (exit 0)
round 5 · packages/core/src/core/client.test.ts: P (exit 0)
round 5 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 5 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
|
Triage re-run completed without a new review.
The stage comments above were updated with the latest result. View workflow run. 上方各阶段评论已更新为最新结果。查看工作流运行。 |
|
@qwen-code /triage |
1 similar comment
|
@qwen-code /triage |
|
Triage re-run completed without a new review.
The stage comments above were updated with the latest result. View workflow run. 上方各阶段评论已更新为最新结果。查看工作流运行。 |
|
Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 52 passed · 0 failed · 52 total Flakiness gate: ✅ 5 changed test file(s) x 5 identical rounds, no divergence 中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:52 通过 · 0 失败 · 52 总计 抖动门:✅ 5 changed test file(s) x 5 identical rounds, no divergence Verification reportSandboxed verification: ❌ not passed — findings reported (agent verdict) Scripted assertions: 52 passed · 0 failed · 52 total (probes reported separately as findings) 中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、变异矩阵、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:52 通过 · 0 失败 · 52 总计 Verification reportPR #9492 deep verification (second follow-up round) —
|
| # | Finding | Severity | Status at new head |
|---|---|---|---|
| 1 | Oscillating boards still halted (poll 11 with heuristics on, poll 101 under the CLI default) | Suggestion | fixed, re-measured — cells O1/O2: oscillating board survives 24 polls with heuristics on and 120 polls past the 100-call soft cap under the CLI default at head; base halts at 5. Interleaved frozen boards still halt via the adaptive cap on both arms (O3, same halt point, poll 11). |
| 2 | Oversized (>25 000 chars) persisted stubs escape every result-aware guard via per-call unique paths — protection loosened vs base |
Suggestion | fixed, re-measured — cell S1 flips: frozen oversized board delivered as realistic per-call persisted stubs (produced by the real persistAndTruncateToolResult) now halts at request 5 at head (previous round: no halt in 12); base halts at 5. S2 (constant path) still halts at 5 on both arms; S3 shows the digest keeps mutations beyond the 2000-char preview window visible (no halt in 12); S4 shows digest-less truncated stubs halt at 5 via the path-free payload fallback. |
| 3 | Provider-duplicate call ids halt a productive poller fail-safe (request counts and result evidence from different populations) | Suggestion | fixed, re-measured — the fix lives in the two reasoning-loop owners (per-attempt callId Set mirroring dedupeToolCallsById in agent-core.ts and client.ts), not the service. Mutations M11 (agent-core dedup removed) and M11c (client dedup removed) are each killed by the new runtime tests ("counts a provider-duplicate call id once so result evidence stays in sync" / "…so changed-board polls never halt"); the unmutated tests pass (651/651). Service-level D1 documents the fail-safe is intentionally preserved when evidence is incomplete (5 requests incl. a duplicate id + 3 changing results halts at 5 on both arms). |
| 4 | Coverage gaps: client.ts wiring, QwenLogger loop_type, interactive stop message, fail-safe completeness gate |
Suggestion | fixed, re-measured — all four previous survivors are now killed: M2 (completeness gate) 3 failed, pinned by "still halts when result evidence is only partial (fail-safe)"; M4 (client wiring) 2 failed, pinned by the paired-ToolResult client tests; M5 (journal serialization) 1 failed, pinned by "journals the loop detector attribution on subagent loop stops"; M8 (interactive suffix) 1 failed, pinned by "surfaces the exact loop detector in the interactive stop message". Each kill is exactly the test the delta commit says it adds (matrix-names.txt). |
Central claim + A/B
Central claim: for the stateful read tool task_list, the guards become result-aware — identical calls with changing results are productive polling (no halt), identical calls with unchanged results still halt at the same request count, and missing result evidence fails safe (pre-fix behavior).
Harness: harness-ab-service.mjs drives the compiled dist/ LoopDetectionService of each tree (no mocks of the unit under test); oversized stubs are produced by the real truncation producers (persistAndTruncateToolResult for persisted shapes, truncateAndSaveToFile for digest-less wrapped shapes) so envelopes are wire-faithful. Per-arm expectations are encoded so predicted base-arm reds count as passing assertions. Witnesses: 01-ab-base-arm.png, 02-ab-head-arm.png; raw logs logs-ab-base.txt, logs-ab-head.txt.
| Cell | Scenario (oracle) | base a82a11a0a4 |
head 4bbf8eabd8 |
Expectation |
|---|---|---|---|---|
| C1 | 20 identical task_list requests, board changes after each execution |
halts at request 5 (consecutive_identical_tool_calls) — the #9450 false positive |
no halt in 20 | base red / head green |
| C2 | frozen board, results recorded | halts at 5 | halts at 5, same type | parity |
| C3 | no results ever recorded (wiring-gap fail-safe) | halts at 5 | halts at 5 | parity |
| C4 | deterministic tool (read_file), changing recorded results |
halts at 5 | halts at 5 | parity (no exemption leak) |
| C5 | heuristics on, interleaved unique fillers, changing results | halts (global_tool_call_duplicate, request-time) |
no halt in 8 rounds | base red / head green |
| C6 | heuristics on, interleaved, frozen results | halts at the 6th request (request-time) | halts at the 6th recorded result (result-time), same loop type | parity, new detection point |
| C7 | callId pairing API (recordToolResultByCallId) |
API absent | paired changing results keep 8 rounds alive; unknown id → false |
head-only API works |
| O1 | oscillating A/B board, heuristics on, 24 polls | halts at 5 | no halt | fixed (F1) |
| O2 | oscillating board, CLI default (skipLoopDetection=true), 120 polls past the 100 soft cap |
halts at 5 | no halt | fixed (F1) |
| O3 | interleaved frozen board + unique fillers, adaptive cap 20 | halts at poll 11, turn_tool_call_cap |
halts at poll 11, turn_tool_call_cap |
parity |
| S1 | F2: frozen oversized board as per-call persisted stubs (real producer: unique path + full-output digest), 12 polls | halts at 5 | halts at 5 (previous round: no halt in 12) | fixed |
| S2 | control: identical stubs with a constant path | halts at 5 | halts at 5 | parity |
| S3 | oversized stubs, identical preview, mutations beyond the 2000-char window (digest differs) | halts at 5 | no halt in 12 | digest sensitivity |
| S4 | digest-less truncated-output stubs, frozen payload, per-call unique spill path | halts at 5 | halts at 5 (payload fallback) | parity |
| S5a | non-stub text quoting a digest line mid-content, tail changes per poll | halts at 5 (args-only) | no halt in 6 (verbatim fingerprint) | prefix gate holds |
| S5b | probe: digest-less truncated stub whose payload quotes a line-start digest; real truncated tail changes per poll | — | halts at request 5 — false positive (see F6) | finding |
| D1 | 5 request events incl. a duplicated callId, 3 recorded changing results | halts at 5 (fail-safe) | halts at 5 (fail-safe; owner-side fix pinned by M11/M11c) | parity by design |
All 33 harness assertions (16 base + 17 head) passed. Base control purity: worktree at HEAD^1, only packages/core rebuilt via tsc; root node_modules symlinked (lockfile untouched — empty package.json/package-lock.json diff asserted); base dist asserted free of result-aware code (grep -c recordToolResult → 0 vs 12 at head; FULL_OUTPUT_DIGEST_LABEL → 0 vs 4 in truncation). packages/core has no internal @qwen-code/* dependencies, so no workspace symlink can leak head code into the control.
Targeted gates (head, unmutated): the five changed test files 651/651 (base: 621/621 — delta +30 = exactly the PR's added tests); tsc --noEmit (packages/core) clean. Full-suite parity at the new base tip: head 79 failed | 21 977 passed | 10 skipped vs base 79 failed | 21 947 passed | 10 skipped — the 73 unique failing test names are byte-identical on both arms (diff base.fails.txt head.fails.txt empty; same 9 environment files: logger 25, ide-client 18, client.telemetrySwap 9, memoryDiscovery 6, file-token-storage 5, skill-manager 4, subagent-manager 3, installationManager 2, rulesDiscovery 1), delta +30 passing, +0 failing.
Corrections
The previous round corrected the PR body's attribution of the pre-existing packages/core failures to "system git predates git init -b". The current PR body no longer contains that claim, so the correction is moot; the underlying fact is re-confirmed at the new tip: the identical 79 environment failures reproduce byte-identically on both arms (container git 2.39.5), none in files the PR touches.
Findings
No blockers. Two new Suggestion-level findings.
F5 — The producer side of the F2 fix is unpinned: no test asserts buildStub embeds the full-output digest (Suggestion)
The guard-side consumer is pinned (M9 — removing stripPersistenceEnvelope from extractResultText fails "halts a frozen oversized board despite per-call unique stub paths" and "falls back to the path-free preview for digest-less stubs", expected false to be true on the intended halt assertions). But M10 — deleting the digest line from buildStub in tools/truncation.ts — survives 185/185 (truncation + service suites): the service tests hand-build their stub envelopes with an imported label, and truncation.test.ts contains zero references to the digest, so a future regression of the producer would silently drop S3's beyond-preview sensitivity with every test green. Classification: coverage gap (behavior correct at head, nothing asserts the producer), not dead code (M9 proves the consumer path load-bearing) and not redundant defence. Repro: run-mutations.py row M10 (artifact dir). Measured fix: a one-test fixture driving the real persistAndTruncateToolResult and asserting the stub contains Full output sha256: <sha256(content)> — green at head (1/1), red on the M10 mutant (1/1); run it, then delete it (probe measured, tree clean after).
F6 — A quoted line-start digest inside a digest-less truncation stub collapses a changing board into "frozen" — false-positive halt (Suggestion, boundary)
stripPersistenceEnvelope recognizes stubs by leading producer prefix, then scans the whole value for a line-start Full output sha256: <64hex> line. The TOOL_OUTPUT_TRUNCATED_PREFIX producer (truncateAndSaveToFile) never embeds a digest, so any label found inside that shape is necessarily quoted peer content — yet it is honored: probe S5b (digest-less stub whose truncated head quotes a constant digest while the retained tail changes every poll) halts a productive poller at request 5 (04-s5b-false-frozen-and-fix.png). Reachability: any tool output truncated via truncateAndSaveToFile (shell tools; also persistAndTruncateToolResult's disk-failure fallback for task_list itself) whose content contains a literal line matching the digest format — e.g. a teammate's text quoting a previous stub. Safe direction (halts, never runs away), narrow preconditions, but it is the exact false-positive class this PR exists to remove, one level down. Digest-carrying stubs are not affected (the real digest line precedes the preview, so indexOf finds it first). Measured fix: skip digest extraction for that one stub shape — scratch-patched dist re-runs the full harness at 17/17 with S5b surviving 12 polls and every other cell byte-identical (S1/S2/S3/S4/S5a unchanged). Suggested source hunk: const digest = value.startsWith(TOOL_OUTPUT_TRUNCATED_PREFIX) ? null : extractAnchoredStubDigest(value); plus a service-test variant of S5b (quoted digest in a truncated shape, changing payload) to pin it.
Not covered
- Per-commit attribution: the checkout is depth-2 (3 commits reachable) while the snapshot lists 56 commits;
git rev-list HEAD^1..HEAD^2returns 1 at the shallow boundary (plausible-but-wrong). The previous headf34098e680is not locally fetchable, so the delta commit's own diff could not be computed; the aggregateHEAD^1..HEADdiff is what was verified. The delta since the previous round is exactly one commit per the snapshot's commit list, verified end to end by re-measurement. - Live multi-agent E2E with real model traffic — declared out of scope by the PR; the headless E2E tests mock the model stream but exercise the real agent runtime, scheduler, and loop-detector wiring, with seam-counted assertions (8 executed polls / 9 stream calls asserted; 8 streamed ToolCallRequests with 7 unique callIds in the client test).
- Alternating-pattern detector — declared unchanged; confirmed statically that
checkAlternatingPatterncarries no stateful carve-out at this head. Not re-measured behaviorally. - Daemon/ACP path — declared deferred to Make daemon/ACP adaptive-cap stuck signal result-aware for stateful reads (follow-up from #9492) #10161; the diff touches no daemon/ACP/
packages/clifiles (diffstat verified; the only "cli" match iscore/client.ts, the main-session owner). send_message/task_update— deliberately argument-only per the PR's declared narrow scope; not probed.packages/clisuite and the shell/finalizer suites — not run in full;truncation.test.ts(40 tests) ran green in the M9/M10 passes.- The flakiness gate (changed test files × N rounds) is owned by the workflow, not this round.
Methodology
Environment: CI verify container (node:22-bookworm, node v22.23.2, git 2.39.5), merge ref refs/pull/9492/merge at depth 2; npm ci + full build completed before verification. A/B: head dist from the workflow build; base control in a scratch worktree at HEAD^1 with only packages/core rebuilt (tsc -p .), node_modules symlinked and realpath-asserted. harness-ab-service.mjs imports the compiled dist/src/services/loopDetectionService.js of each tree and drives checkAlwaysOnSafeties / addAndCheckHeuristicLoops / recordToolResult[ByCallId] with ToolCallRequest events and functionResponse parts shaped like production traffic; oversized stubs are produced by each tree's real exported truncation producers (head producer used as the post-PR input on both arms). Mutations were single-point source edits run through scoped vitest, reverted with git checkout (tree confirmed clean after each). Full-suite parity compared ANSI-stripped FAIL lines of sequential, coverage-disabled runs on both arms. The F6 fix direction was validated by a scratch patch of the compiled dist only (backup/restore verified). Raw logs, harnesses, and the mutation runner live in this artifact dir; evidence images 01–04 are captured via scripts/verify-capture.mjs.
Flakiness gate log
rounds=5 files=5 skipped=0
file packages/core/src/agents/runtime/agent-headless.test.ts: (cd packages/core) npx --no-install vitest run ./src/agents/runtime/agent-headless.test.ts
file packages/core/src/agents/runtime/agent-interactive.test.ts: (cd packages/core) npx --no-install vitest run ./src/agents/runtime/agent-interactive.test.ts
file packages/core/src/core/client.test.ts: (cd packages/core) npx --no-install vitest run ./src/core/client.test.ts
file packages/core/src/services/loopDetectionService.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/loopDetectionService.test.ts
file packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: (cd packages/core) npx --no-install vitest run ./src/telemetry/qwen-logger/qwen-logger.test.ts
per-file results (P=pass F=fail I=infra-exit, one letter per run):
packages/core/src/agents/runtime/agent-headless.test.ts: PPPPP
packages/core/src/agents/runtime/agent-interactive.test.ts: PPPPP
packages/core/src/core/client.test.ts: PPPPP
packages/core/src/services/loopDetectionService.test.ts: PPPPP
packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: PPPPP
verdict: pass
summary: 5 changed test file(s) x 5 identical rounds, no divergence
--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 1 · packages/core/src/agents/runtime/agent-interactive.test.ts: P (exit 0)
round 1 · packages/core/src/core/client.test.ts: P (exit 0)
round 1 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 1 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 2 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 2 · packages/core/src/agents/runtime/agent-interactive.test.ts: P (exit 0)
round 2 · packages/core/src/core/client.test.ts: P (exit 0)
round 2 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 2 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 3 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 3 · packages/core/src/agents/runtime/agent-interactive.test.ts: P (exit 0)
round 3 · packages/core/src/core/client.test.ts: P (exit 0)
round 3 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 3 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 4 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 4 · packages/core/src/agents/runtime/agent-interactive.test.ts: P (exit 0)
round 4 · packages/core/src/core/client.test.ts: P (exit 0)
round 4 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 4 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 5 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 5 · packages/core/src/agents/runtime/agent-interactive.test.ts: P (exit 0)
round 5 · packages/core/src/core/client.test.ts: P (exit 0)
round 5 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 5 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
|
Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 65 passed · 0 failed · 65 total Flakiness gate: ✅ 5 changed test file(s) x 5 identical rounds, no divergence 中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:65 通过 · 0 失败 · 65 总计 抖动门:✅ 5 changed test file(s) x 5 identical rounds, no divergence Verification reportPR #9492 deep verification (third round) —
|
| # | Finding | Severity | Status at this head |
|---|---|---|---|
| 1 | Oscillating boards still halted (F1) | Suggestion | fixed, re-measured — cells O1/O2 re-run: oscillating board survives 24 polls with heuristics on and 120 polls past the 100-call soft cap under the CLI default at head; base halts at 5 on both. |
| 2 | Oversized persisted stubs escape the result-aware guards (F2) | Suggestion | fixed, re-measured — cell S1 re-run: frozen oversized board as realistic per-call persisted stubs (real persistAndTruncateToolResult) halts at request 5 at head, same as base; S3 re-run confirms digest sensitivity beyond the 2000-char preview window (no halt in 12). |
| 3 | Provider-duplicate call ids halt a productive poller (F3) | Suggestion | fixed — the owner-side dedup kills (M11/M11c) are carried under the proven-identical closure (zero commits were added since they were measured); the service-level population-mismatch behavior was re-measured fresh this round (cell D1: incomplete evidence fails safe at 5 on both arms). |
| 4 | Coverage gaps: client wiring, loop_type journal, interactive stop message, fail-safe gate (F4) |
Suggestion | fixed — the M2/M4/M5/M8 kills are carried under the proven-identical closure; the unmutated gate behind them was re-run fresh: the five changed test files pass 651/651 at head (base 621/621, delta exactly +30 = the PR's added tests). |
| 5 | Producer side of the F2 fix unpinned: no test asserts buildStub embeds the full-output digest (F5) |
Suggestion | stands, re-measured — M10 (delete the digest line from buildStub) survives 185/185 again; the pinning probe is red against M10 and green at head (see Findings). |
| 6 | Quoted line-start digest inside a digest-less truncation stub collapses a changing board into "frozen" (F6) | Suggestion | stands, re-measured — with a sharper fixture — the retained visible payload now provably changes every poll, yet the halt at request 5 reproduces; the measured fix direction re-verified (see Findings). |
Central claim + A/B
Central claim: for the stateful read tool task_list, the guards become result-aware — identical calls with changing results are productive polling (no halt), identical calls with unchanged results still halt at the same request count, and missing result evidence fails safe (pre-fix behavior).
Harness: harness-ab-service.mjs drives the compiled dist/ LoopDetectionService of each tree (no mocks of the unit under test); oversized stubs are produced by each arm's own exported truncation producers (persistAndTruncateToolResult / truncateAndSaveToFile) so envelopes are wire-faithful. Per-arm expectations are encoded so predicted base-arm reds count as passing assertions. Witnesses: 01-ab-base-arm.png, 02-ab-head-arm.png; raw logs logs-ab-base.txt, logs-ab-head.txt.
| Cell | Scenario (oracle) | base a82a11a0a4 |
head 4bbf8eabd8 |
Expectation |
|---|---|---|---|---|
| C1 | 20 identical task_list requests, board changes after each execution |
halts at request 5 (consecutive_identical_tool_calls) — the #9450 false positive |
no halt in 20 | base red / head green |
| C2 | frozen board, results recorded | halts at 5 | halts at 5, same type | parity |
| C3 | no results ever recorded (wiring-gap fail-safe) | halts at 5 | halts at 5 | parity |
| C4 | deterministic tool (read_file), changing recorded results (+ fresh-instance refusal probe C4b) |
halts at 5 | halts at 5; recordToolResult returns false for non-stateful tools |
parity (no exemption leak) |
| C5 | heuristics on, interleaved unique fillers, changing results | halts (global_tool_call_duplicate, request-time, 6th poll) |
no halt in 8 rounds | base red / head green |
| C6 | heuristics on, interleaved, frozen results | halts at the 6th request (request-time) | halts at the 6th recorded result (result-time), same loop type | parity, new detection point |
| C7 | callId pairing API (recordToolResultByCallId) |
API absent | paired changing results keep 8 rounds alive; unknown id → false |
head-only API works |
| O1 | oscillating A/B board, heuristics on, 24 polls | halts at 5 | no halt | fixed (F1) |
| O2 | oscillating board, CLI default (skipLoopDetection=true), 120 polls past the 100 soft cap |
halts at 5 | no halt | fixed (F1) |
| O3 | interleaved frozen board + unique fillers, adaptive cap 20 | halts at poll 11, turn_tool_call_cap |
halts at poll 11, turn_tool_call_cap |
parity |
| S1 | F2: frozen oversized board as per-call persisted stubs (real producer: unique path + full-output digest at head), 12 polls | halts at 5 | halts at 5 | fixed |
| S2 | control: identical stubs with a constant path | halts at 5 | halts at 5 | parity |
| S3 | oversized stubs, identical preview, mutations beyond the 2000-char window (digest differs) | halts at 5 | no halt in 12 | digest sensitivity |
| S4 | digest-less truncated-output stubs, frozen payload, per-call unique spill path | halts at 5 | halts at 5 (payload fallback) | parity |
| S5a | non-stub text quoting a digest line mid-content, tail changes per poll | halts at 5 (args-only) | no halt in 6 (prefix gate holds) | parity of intent |
| S5b | digest-less truncated stub whose retained head quotes a line-start digest (constant) while the retained tail changes every poll | halts at 5 (args-only) | halts at request 5 — false positive (see F6) | finding |
| D1 | request population incl. a duplicated callId, only 3 changing results recorded | halts at 5 (fail-safe) | halts at 5 (fail-safe) | parity by design |
All 37 harness assertions (17 base + 20 head) passed. Base control purity (methodology has the full wiring note): worktree at HEAD^1, only packages/core rebuilt (node ../../scripts/build_package.js, exit 0, 0 TS errors) against the shared root + per-package node_modules (lockfile untouched — git diff HEAD^1..HEAD -- package.json package-lock.json packages/core/package.json empty). Base dist asserted free of result-aware code: grep -c recordToolResult → 0 (head: 12); FULL_OUTPUT_DIGEST_LABEL/Full output sha256 in truncation → 0 (head: 4). packages/core has zero @qwen-code/* dependencies (deps and devDeps inspected) and its per-package node_modules contains no symlinks, so no workspace link can leak head code into the control.
Targeted gates (re-run fresh): the five changed test files 651/651 at head vs 621/621 at base (delta exactly +30 = the PR's added tests, +0 failing); tsc --noEmit (packages/core) clean. Full-suite parity is carried from the previous round under the proven-identical closure (see Not covered).
Corrections
None new this round. The previous round's correction (the PR body's attribution of pre-existing packages/core failures to a system-git limitation) is moot — the current body no longer contains that claim, and the underlying fact (79 environment failures reproducing identically on both arms) is the carried full-suite measurement.
Findings
No blockers. The two carried Suggestions stand, both re-measured at this head.
F5 — The producer side of the F2 fix remains unpinned: no shipped test asserts buildStub embeds the full-output digest (Suggestion, stands)
Re-measured: M10 — deleting the Full output sha256: line (and its computation) from buildStub in packages/core/src/tools/truncation.ts — survives 185/185 (the truncation + loop-detection suites; logs-m10-mutant.txt). The consumer side is pinned (positive control M9 — neutralizing stripPersistenceEnvelope in extractResultText — is killed: 2 failed | 183 passed, failing exactly "halts a frozen oversized board despite per-call unique stub paths" and "falls back to the path-free preview for digest-less stubs" with expected false to be true on the intended halt assertions; logs-m9-mutant.txt), so the harness is proven live on this file. Classification unchanged: coverage gap — behavior correct at head, nothing asserts the producer; a future regression would silently drop S3's beyond-preview sensitivity with every test green. Repro: apply M10, then cd packages/core && npx vitest run src/tools/truncation.test.ts src/services/loopDetectionService.test.ts → 185/185. Measured fix (re-confirmed this round): a scratch vitest probe — driving the real persistAndTruncateToolResult on ~33 KB of content and asserting the stub contains Full output sha256: <sha256(content)>, run during this round and deleted after, logs kept — is red against M10 (expected '<persisted-output>…' to contain 'Full output sha256: a2bad869…', logs-probe-vs-m10.txt) and green at head (1/1, logs-probe-vs-head.txt). Suggested: land that one test with the producer — its full body is kept in this artifact dir as probe-producer-digest.test.ts.
F6 — A quoted line-start digest inside a digest-less truncation stub still collapses a changing board into "frozen" — false-positive halt (Suggestion, stands; fixture now decisive)
stripPersistenceEnvelope recognizes stubs by leading producer prefix, then scans the whole value for a line-start Full output sha256: <64hex> line. The TOOL_OUTPUT_TRUNCATED_PREFIX producer (truncateAndSaveToFile) never embeds a digest, so any label found inside that shape is necessarily quoted peer content — yet it is honored. This round's fixture closes an underdeterminedness the previous cell had: the per-poll change now sits inside the retained tail (the truncated middle is what truncateAndSaveToFile removes), so the model-visible payload demonstrably differs every poll (v1…v12) while the quoted digest line stays constant — and the productive poller still halts at request 5 (ASSERT S5b in logs-ab-head.txt; witness 04-s5b-false-frozen-and-fix.png). Reachability unchanged from round 2: any tool output truncated via truncateAndSaveToFile (shell tools; also persistAndTruncateToolResult's disk-failure fallback for task_list itself) whose content contains a literal line matching the digest format. Safe direction (halts, never runs away), narrow preconditions, but it is the exact false-positive class this PR exists to remove, one level down. Re-measured fix direction: scratch-patch the compiled dist's stripPersistenceEnvelope with const digest = value.startsWith(TOOL_OUTPUT_TRUNCATED_PREFIX) ? null : extractAnchoredStubDigest(value); — the full harness then passes 20/20 with S5b surviving 12 polls and every other cell unchanged (S1/S2/S3/S4/S5a byte-identical; logs-ab-head-f6fix.txt), and the dist was restored byte-identical afterwards (sha256 da45a207… before == after). Suggested source hunk is that one line plus a service-test variant of S5b (quoted digest in a truncated shape, changing retained payload) to pin it.
Not covered
- Per-commit attribution: the checkout is depth-2 (3 commits reachable) while the snapshot lists 56 commits; the delta since the previous round is zero commits (same
HEAD^2), so there is no new per-commit surface to attribute. - Full-suite parity (both arms) — carried from the previous round under the proven-identical input closure. What was compared: the merge commit (
b8ef666db4), base tip (a82a11a0a4), and head (4bbf8eabd8) OIDs cited by the previous report are byte-for-byte the localHEAD,HEAD^1,HEAD^2— and a commit OID content-addresses its whole tree, so every file, the lockfile, and every fixture the full-suite runs consumed are identical; the runtime (node v22.23.2, git 2.39.5) matches the previous round's recorded environment. Carried numbers: head 79 failed | 21 977 passed | 10 skipped vs base 79 failed | 21 947 passed | 10 skipped, the 73 unique failing names byte-identical on both arms (9 environment files, none touched by the PR). - Full mutation matrix beyond M9/M10 (M2/M4/M5/M8/M11/M11c kills) — carried under the same identity proof; this round re-ran the consumer-side positive control (M9, killed) and the F5 mutant (M10, survives) fresh.
- Live multi-agent E2E with real model traffic — declared out of scope by the PR.
- Alternating-pattern detector — declared unchanged; re-confirmed statically at this head (
checkAlternatingPatterncarries no stateful carve-out in the source read this round). Not re-measured behaviorally. - Daemon/ACP path — declared deferred to Make daemon/ACP adaptive-cap stuck signal result-aware for stateful reads (follow-up from #9492) #10161;
git diff --stat HEAD^1..HEADre-confirmed this round touches no daemon/ACP/packages/clifiles (14 files, allpackages/core/src/**). send_message/task_update— deliberately argument-only per the PR's declared narrow scope; not probed.packages/clisuite and the shell/finalizer suites — not run.- The flakiness gate (changed test files × N rounds) is owned by the workflow, not this round.
- No injection attempts were observed in the PR metadata (title/body/commits read as untrusted input; nothing tried to steer the verification).
Methodology
Environment: CI verify container (node:22-bookworm, node v22.23.2, git 2.39.5), merge ref refs/pull/9492/merge at depth 2; npm ci + full build completed before verification. A/B: head dist from the workflow build; base control in a scratch worktree at HEAD^1 with only packages/core rebuilt. Wiring note: the worktree initially failed to build because resolution walked up to the root node_modules/ajv 6.15.0 (v6 has no dist/2020.js) while packages/core declares ^8 — the satisfying copy lives in the main tree's git-ignored packages/core/node_modules (ajv 8.20.0, readlink -f-verified), which was symlinked into the base worktree; this is a clean control because git diff HEAD^1..HEAD over package.json/package-lock.json/packages/core/package.json is empty and that directory contains no symlinks (all real external dirs). harness-ab-service.mjs imports each tree's compiled dist/src/services/loopDetectionService.js and drives checkAlwaysOnSafeties / addAndCheckHeuristicLoops / recordToolResult[ByCallId] with ToolCallRequest events and functionResponse parts shaped like production traffic; oversized stubs are produced by the arm's own real truncation producers into real temp dirs. Mutations were single-point source edits run through scoped vitest, reverted with git checkout (tree confirmed clean after each; M9 log logs-m9-mutant.txt, M10 log logs-m10-mutant.txt, probe logs logs-probe-vs-m10.txt / logs-probe-vs-head.txt). The F6 fix direction was validated by a scratch patch of the compiled dist only, with sha256 proof of byte-identical restore. Gates: five changed test files via npx vitest run on both trees (logs-gate-head.txt, logs-gate-base.txt) and tsc --noEmit (logs-typecheck.txt). Evidence images 01–04 rendered from the real run logs via scripts/verify-capture.mjs; base worktree removed after the last base-arm run.
Flakiness gate log
rounds=5 files=5 skipped=0
file packages/core/src/agents/runtime/agent-headless.test.ts: (cd packages/core) npx --no-install vitest run ./src/agents/runtime/agent-headless.test.ts
file packages/core/src/agents/runtime/agent-interactive.test.ts: (cd packages/core) npx --no-install vitest run ./src/agents/runtime/agent-interactive.test.ts
file packages/core/src/core/client.test.ts: (cd packages/core) npx --no-install vitest run ./src/core/client.test.ts
file packages/core/src/services/loopDetectionService.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/loopDetectionService.test.ts
file packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: (cd packages/core) npx --no-install vitest run ./src/telemetry/qwen-logger/qwen-logger.test.ts
per-file results (P=pass F=fail I=infra-exit, one letter per run):
packages/core/src/agents/runtime/agent-headless.test.ts: PPPPP
packages/core/src/agents/runtime/agent-interactive.test.ts: PPPPP
packages/core/src/core/client.test.ts: PPPPP
packages/core/src/services/loopDetectionService.test.ts: PPPPP
packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: PPPPP
verdict: pass
summary: 5 changed test file(s) x 5 identical rounds, no divergence
--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 1 · packages/core/src/agents/runtime/agent-interactive.test.ts: P (exit 0)
round 1 · packages/core/src/core/client.test.ts: P (exit 0)
round 1 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 1 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 2 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 2 · packages/core/src/agents/runtime/agent-interactive.test.ts: P (exit 0)
round 2 · packages/core/src/core/client.test.ts: P (exit 0)
round 2 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 2 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 3 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 3 · packages/core/src/agents/runtime/agent-interactive.test.ts: P (exit 0)
round 3 · packages/core/src/core/client.test.ts: P (exit 0)
round 3 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 3 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 4 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 4 · packages/core/src/agents/runtime/agent-interactive.test.ts: P (exit 0)
round 4 · packages/core/src/core/client.test.ts: P (exit 0)
round 4 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 4 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 5 · packages/core/src/agents/runtime/agent-headless.test.ts: P (exit 0)
round 5 · packages/core/src/agents/runtime/agent-interactive.test.ts: P (exit 0)
round 5 · packages/core/src/core/client.test.ts: P (exit 0)
round 5 · packages/core/src/services/loopDetectionService.test.ts: P (exit 0)
round 5 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
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.
Not reviewed: build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI; unit suites ran green on Linux in this review's build-test, the OS dimension is untested (author-disclosed).
Deferred under the convergence posture (round 17, not a blocker) — recorded, not requested in this round:
packages/core/src/agents/runtime/agent-interactive.test.ts:333 — [review] lastRoundError loopType branch has no witnesspackages/core/src/services/loopDetectionService.test.ts:2929 — [probe] persistedStub fixture hardcodes 2000 instead of PREVIEW_SIZE_CHARSpackages/core/src/services/loopDetectionService.ts:207 — [review] Producer envelope literals re-declared instead of importedpackages/core/src/tools/truncation.ts:532 — [review] buildStub digest line has no producer-side witnesspackages/core/src/services/loopDetectionService.test.ts:3242 — [probe] Retry-clear test survives its own deletion (vacuous witness)packages/core/src/services/loopDetectionService.test.ts:3273 — [probe] reset() clear test survives deletion of all three clearspackages/core/src/agents/runtime/agent-core.ts:1219 — [review] Agent-core result-time halt branch is never exercisedpackages/core/src/agents/runtime/agent-interactive.test.ts:343 — [probe] Null-loopType fallback branches have no witnesspackages/core/src/services/loopDetectionService.test.ts:3019 — [probe] Quoted-marker test does not discriminate the leading-prefix gate
Convergence: round 17 posted 3 inline comment(s), 3 of them reported for the first time; the previous round posted 1 (1 new). Findings keep coming back to the same files: packages/core/src/services/loopDetectionService.ts (findings in round 16; 2 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)
Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (3 Critical(s)), the rate of first-time findings is not falling (this round 3, previous 1), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):
| standing Critical | attack surface | attacker-dependency | blast radius |
|---|---|---|---|
| (each standing Critical) | … | … | … |
Advisory only — it does not block this review.
中文说明
仅完成部分审查,审查缺口已披露。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
未审查:build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI; unit suites ran green on Linux in this review's build-test, the OS dimension is untested (author-disclosed)。
收敛姿态下延后(第 17 轮,非阻断)——已记录,本轮不要求修改:共 9 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 17 轮发布了 3 条行内评论,其中 3 条是首次提出;上一轮发布了 1 条(其中 1 条首次提出)。发现反复回到同一批文件:packages/core/src/services/loopDetectionService.ts(第 16 轮已出过发现,本轮又有 2 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)
残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 3 条 Critical),首次发现的速率没有下降(本轮 3,上一轮 1),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| for (const marker of [STUB_PREVIEW_MARKER, STUB_TRUNCATED_PART_MARKER]) { | ||
| const payloadStart = value.indexOf(marker); | ||
| if (payloadStart !== -1) { |
There was a problem hiding this comment.
[Critical] R5-1: Still stands — re-proven at this head. The digest-less truncateAndSaveToFile stub shape degrades to head+tail fingerprinting in this fallback, so task_list boards in the (25,000, 28,000]-char band (above the truncate threshold, below the persistence gate; or any board under 28K chars with >1000 lines) whose mutations land in the truncated-away middle fingerprint identically on every poll — and the consecutive guard then halts a productive poller, re-shipping the exact #9450 false positive this PR removes. Round 5's fix (f4e85c7, digest in truncateAndSaveToFile) was reverted by the trim; buildStub got its digest back, this shape did not. Witness (probe at this head, real producer + real service): a 27,639-char board mutating only in the truncated middle halted at request 4 (haltedAt=4, consecutive_identical_tool_calls) while genuinely changing; adding the digest line to truncateAndSaveToFile's wrapped message flips the same probe to haltedAt=null.
F3-PROBE halted at request 4 ... haltedAt=4 (board changing every poll)
with digest line in truncateAndSaveToFile: haltedAt=null
Fix: emit a FULL_OUTPUT_DIGEST_LABEL line (sha256 of the full pre-truncation content, in scope there) in truncateAndSaveToFile's wrappedMessage and its disk-failure fallback, mirroring buildStub; the detector already prefers the anchored digest, so no guard change is needed. Fix witness: add a test driving identical task_list calls whose results are real truncateAndSaveToFile-shaped stubs mutating only in the truncated middle, asserting no halt within 4 × threshold — removing the digest line must turn it red (and a frozen-board variant of the same shape must still halt).
中文说明
[Critical] R5-1:仍然成立 —— 已在本 head 上重新证实。无摘要的 truncateAndSaveToFile 存根形态在此回退分支中退化为「头 + 尾」指纹,因此处于 (25,000, 28,000] 字符区间(超过截断阈值、低于持久化门槛;或任何低于 28K 字符但超过 1000 行)的 task_list 任务板,只要变化落在被截掉的中段,每次轮询的指纹都完全相同 —— 连续守卫会终止仍在正常产出的轮询者,使本 PR 要消除的 #9450 误杀在该尺寸区间重新出现。第 5 轮的修复(f4e85c7417,在 truncateAndSaveToFile 中嵌入摘要)已被瘦身回退;buildStub 的摘要被找回,此形态没有。证据(在本 head 上用真实生产者 + 真实服务的探针):一块 27,639 字符、仅在被截中段变化的任务板在第 4 次请求即被终止(haltedAt=4,consecutive_identical_tool_calls),而它每次轮询都在真实变化;在 truncateAndSaveToFile 的封装信息中加入摘要行后同一探针翻转为 haltedAt=null。
修复:在 truncateAndSaveToFile 的 wrappedMessage 及其写盘失败回退中嵌入 FULL_OUTPUT_DIGEST_LABEL 行(截断前完整内容的 sha256,该内容在此作用域内),与 buildStub 对齐;检测器已优先提取锚定摘要,守卫侧无需改动。修复见证:补一个测试,驱动结果仅在被截中段变化的真实 truncateAndSaveToFile 形态存根的相同 task_list 调用,断言 4 倍阈值内不终止 —— 移除摘要行必须使其变红(同形态的冻结任务板变体仍须终止)。
— qwen3.8-max via Qwen Code /review (v0.22.2)
There was a problem hiding this comment.
Patrol note (automated sweep): this PR has hit the scope fuse — cumulative additions are +1853 (>1500-line cap) and this finding shape has recurred across five-plus review rounds without converging. Per patrol policy no more code is added from the sweep; this thread stays open for a maintainer decision on direction (the digest/fingerprint gaps are real, but each round mints new entrance variants faster than they close).
| results.push({ | ||
| toolName: String(fc.name ?? ''), | ||
| args: (fc.args ?? {}) as Record<string, unknown>, | ||
| responseParts: finalized.responseParts, | ||
| }); |
There was a problem hiding this comment.
[Critical] R5-5: Still stands — re-proven at this head (also carries the round-7 R7-1 entrance). The guards fingerprint these post-finalization parts, and the batch-budget finalizer's fitText replacement carries no content digest: its header embeds a per-call unique artifact path and matches no STUB_PRODUCER_PREFIXES member, so the whole value fingerprints verbatim. Two directions, both probe-verified at this head: (1) missed halt — a FROZEN board whose round exceeds the default-on 200,000-char batch budget fingerprints uniquely on every poll, so unchangedStreak never grows and a genuinely stuck poller escapes every result-aware guard, running to the ~1000-call hard backstop; (2) false halt — two boards differing only beyond the retained head/tail produce byte-identical replacements, so a productive poller halts at the 5th identical request. The round-5/7 fixes (fitText digest + shared recognizer) were reverted by the trim. Witness (probe through the real enforceFunctionResponseBudget + real service at this head):
false-halt arm: boards differing only in the truncated middle ->
byte-identical replacements (t1 === t2, no digest label), halt at request 5
(full-board control never halts)
missed-halt arm: FROZEN board's fitted replacements all differ
(per-call unique artifact path) -> guard never accumulates, never halts
Fix: embed the FULL_OUTPUT_DIGEST_LABEL digest of the full pre-fit content in fitText's replacement (consistent with this PR's own buildStub change) so the guard reduces the shape to the content digest regardless of the per-call artifact path. Fix witness: a frozen-board-under-fit test that halts within the threshold (red when fitText carries no digest), plus a board mutating only past the fit window that must NOT halt.
中文说明
[Critical] R5-5:仍然成立 —— 已在本 head 上重新证实(同时承载第 7 轮 R7-1 入口)。守卫对这些 finalize 后的部件做指纹,而批预算终结器的 fitText 替换不携带内容摘要:其头部内嵌每次调用唯一的产物路径,且不匹配任何 STUB_PRODUCER_PREFIXES 成员,于是整个值被逐字指纹化。两个方向均已在本 head 上用探针证实:(1) 漏检 —— 任务板冻结且某轮输出超过默认开启的 200,000 字符批预算时,每次轮询的指纹都因唯一路径而不同,unchangedStreak 永远不增长,真正卡死的轮询者逃逸所有结果感知守卫、一路跑到约 1000 次硬兜底;(2) 误杀 —— 仅在保留头/尾之外有差异的两块任务板产生字节完全相同的替换,正常轮询者在第 5 次相同请求被终止。第 5/7 轮的修复(fitText 摘要 + 共享识别器)已被瘦身回退。证据(经真实 enforceFunctionResponseBudget + 真实服务的探针):误杀臂 —— 仅被截中段有差异的任务板产生字节相同的替换(t1 === t2,无摘要行),第 5 次请求即终止(整板对照不终止);漏检臂 —— 冻结任务板的拟合替换因逐次调用唯一路径而全部不同,守卫永远无法累积、永不终止。
修复:在 fitText 的替换中嵌入拟合前完整内容的 FULL_OUTPUT_DIGEST_LABEL 摘要(与本 PR 自己对 buildStub 的改动一致),使守卫无论逐次路径如何都把该形态归约为内容摘要。修复见证:补一个批预算拟合下冻结任务板在阈值内终止的测试(fitText 不带摘要时须变红),以及一块仅在拟合窗口之外变化的任务板不得终止的测试。
— qwen3.8-max via Qwen Code /review (v0.22.2)
There was a problem hiding this comment.
Patrol note (automated sweep): this PR has hit the scope fuse — cumulative additions are +1853 (>1500-line cap) and this finding shape has recurred across five-plus review rounds without converging. Per patrol policy no more code is added from the sweep; this thread stays open for a maintainer decision on direction (the digest/fingerprint gaps are real, but each round mints new entrance variants faster than they close).
| if (consecutiveCount > this.capMaxKeyRepeat) { | ||
| this.capMaxKeyRepeat = consecutiveCount; | ||
| } |
There was a problem hiding this comment.
[Critical] R7-2: Still stands — re-proven at this head (carries the round-6 high-water-mark thread and the round-7 R7-2 core thread). This ratchet raises capMaxKeyRepeat from the per-key consecutive-identical-result count before the skipLoopDetection gate, and nothing lowers it mid-turn when results start changing — only the Retry branch and reset() clear it; ModelFallback does not (probe-verified). The round-6 fix (a separate statefulCapKeyRepeat recomputed from current streaks, 61dd8c5) was reverted by the trim. Failure shape — the common teammate pattern of waiting on peers who then make progress: 8 interleaved task_list polls against a frozen board latch the peak at 8 without halting (interleaving breaks the consecutive guard; the result-time global duplicate is gated off under the CLI default), the board thaws and every subsequent call is productive, but past 100 total calls shouldHaltOnTurnToolCallCap sees stuck = (8 >= 6) and halts the productive turn as turn_tool_call_cap. The same latch fires when the key is abandoned after the frozen phase. Witness (probe at this head, production-shaped event order):
ARM-A freeze->thaw: firing={index: 101, loopType: "turn_tool_call_cap"}
(softCap=100, 8 freeze rounds; every call after the thaw productive)
ARM-B never-frozen control: firing={index: 1001} (hard backstop only)
candidate fix (recompute from current streaks): ARM-A moves 101 -> 1001,
all 145 existing service tests stay green
Fix: disarm the stateful contribution on changed results — keep it in a separate field recomputed from the keys' current consecutive streaks (the reverted fix's shape), or decay frozen keys' streaks at Finished boundaries — so a thawed or abandoned key cannot hold the prompt-wide stuck signal. Fix witness: a freeze→thaw regression — interleaved frozen results, then changed results and diverse calls past the soft cap, asserting no halt; removing the disarm must turn it red while a still-frozen interleaved board still halts just past the soft cap.
中文说明
[Critical] R7-2:仍然成立 —— 已在本 head 上重新证实(承载第 6 轮高水位线程与第 7 轮 R7-2 核心线程)。该棘轮在 skipLoopDetection 门控之前就用按键的连续相同结果计数抬高 capMaxKeyRepeat,而结果开始变化时没有任何机制在回合中途把它降下来 —— 只有 Retry 分支和 reset() 会清零;ModelFallback 不会(已用探针证实)。第 6 轮的修复(独立的 statefulCapKeyRepeat,结果变化时从当前连续段重算,61dd8c5535)已被瘦身回退。失败形态 —— teammate 等待 peer、peer 随后开始推进的常见模式:对冻结任务板做 8 次交错 task_list 轮询把峰值锁到 8 而不终止(交错打破连续守卫;CLI 默认下结果时全局重复被门控关闭),任务板解冻后每一次调用都是有效工作,但总数超过 100 时 shouldHaltOnTurnToolCallCap 见 stuck = (8 >= 6),以 turn_tool_call_cap 终止高产回合。冻结阶段后放弃该键的形态同样触发。证据(在本 head 上以生产事件顺序驱动的探针):冻结→解冻臂在第 101 次调用以 turn_tool_call_cap 终止(软上限 100,8 轮冻结,解冻后全部调用有效);从未冻结对照只在第 1001 次(硬兜底)终止;候选修复(从当前连续段重算)使该臂从 101 移到 1001,且现有 145 个服务测试保持全绿。
修复:在结果变化时解除有状态贡献 —— 把它放进独立字段、按键的当前连续段重算(即被回退修复的形态),或在 Finished 边界衰减冻结键的连续段 —— 使解冻或被放弃的键无法长期持有整个 prompt 的卡滞信号。修复见证:补一个冻结→解冻回归测试 —— 交错冻结结果后改为变化结果与多样化调用、越过软上限,断言不终止;移除解除逻辑必须使其变红,而仍然冻结的交错任务板仍须在软上限附近终止。
— qwen3.8-max via Qwen Code /review (v0.22.2)
There was a problem hiding this comment.
Patrol note (automated sweep): this PR has hit the scope fuse — cumulative additions are +1853 (>1500-line cap) and this finding shape has recurred across five-plus review rounds without converging. Per patrol policy no more code is added from the sweep; this thread stays open for a maintainer decision on direction (the digest/fingerprint gaps are real, but each round mints new entrance variants faster than they close).
Resolve conflicts from main's Gemini->Llm rename refactor while keeping this PR's loop-detection changes: - agent-headless.ts: keep the loopType field; use renamed LlmChat type. - loopDetectionService.ts: keep the Part import; import ServerLlmStreamEvent/LlmEventType. - Align PR-added code/tests with the new names (client.ts, client.test.ts, loopDetectionService.test.ts). Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
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.
Not reviewed: build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI; unit suites ran green on Linux in this review's build-test, the OS dimension is untested (author-disclosed).
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.
Not reviewed: test-efficacy probe — harness could not be validated (harnessValidated: null): the probe's isolated runner tripped the package's dist-prerequisite guard, so coverage-gap claims are neither confirmed nor refuted by mutation/hunk probes.
Not explored to full depth (tool budget reached): chunk 5: could not execute loopDetectionService.test.ts (worktree has no node_modules / dist ; full npm install + build exceeds the tool budget) — verification is by ….
Deferred under the convergence posture (round 18, not a blocker) — recorded, not requested in this round:
packages/core/src/agents/runtime/agent-interactive.test.ts:365 — [probe] lastRoundError loopType format asserted by no testpackages/core/src/tools/truncation.ts:537 — [probe] buildStub digest-line producer half untestedpackages/core/src/agents/runtime/agent-core.ts:1365 — [review] Provider-duplicate stop carries null loopType — attribution collapsespackages/core/src/agents/runtime/agent-interactive.ts:543 — [review] Stop message says 'duplicate tool-call loop' for all 11 detector typespackages/core/src/agents/runtime/agent-interactive.test.ts:354 — [review] FAILED settling after a loop stop pinned by nothingpackages/core/src/services/loopDetectionService.test.ts:3023 — [probe] Quoted-marker test does not discriminate the leading-prefix gatepackages/core/src/services/loopDetectionService.ts:23 — [probe] truncateAndSaveToFile shape recognition pinned by no testpackages/core/src/agents/runtime/agent-headless.test.ts:2411 — [probe] Agent-core result-time halt branch never exercisedpackages/core/src/agents/runtime/agent-headless.test.ts:2526 — [probe] Result-side single-count seam of the duplicate sync test unpinned
Convergence: round 18 posted 5 inline comment(s), 2 of them reported for the first time; the previous round posted 3 (3 new). Findings keep coming back to the same files: packages/core/src/services/loopDetectionService.ts (findings in round 17; 2 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)
中文说明
仅完成部分审查,审查缺口已披露。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
未审查:build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI; unit suites ran green on Linux in this review's build-test, the OS dimension is untested (author-disclosed)。
未审查:reverse audit — did not converge within the reverse-audit round cap of 5。
未审查:test-efficacy probe — harness could not be validated (harnessValidated: null): the probe's isolated runner tripped the package's dist-prerequisite guard, so coverage-gap claims are neither confirmed nor refuted by mutation/hunk probes。
未探索到全部深度(达到工具调用预算):chunk 5:could not execute loopDetectionService.test.ts (worktree has no node_modules / dist ; full npm install + build exceeds the tool budget) — verification is by …。
收敛姿态下延后(第 18 轮,非阻断)——已记录,本轮不要求修改:共 9 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 18 轮发布了 5 条行内评论,其中 2 条是首次提出;上一轮发布了 3 条(其中 3 条首次提出)。发现反复回到同一批文件:packages/core/src/services/loopDetectionService.ts(第 17 轮已出过发现,本轮又有 2 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)
— qwen3.8-max via Qwen Code /review (v0.22.2)
| // A retry replays the failed attempt's tool calls; drop the stateful | ||
| // result evidence too so the replayed attempt is judged on its own | ||
| // results (consecutive counts re-accumulate as results land, consistent | ||
| // with the capKeyCounts/globalToolCallCounts clears). | ||
| this.statefulConsecutiveResults.clear(); |
There was a problem hiding this comment.
[Critical] R18-1: The Retry branch deletes committed result evidence that a retry can never double-count. Tool results are recorded exactly once per executed call and only after the round's stream completes (client.ts records round N's result before round N+1 streams; agent-core records after processFunctionCalls), so this comment's rationale is correct for the request counters — replayed requests re-stream and would double-count — and wrong for the result counters: nothing about the replay ever re-records a result, so the clear purely deletes committed evidence. It also zeroes the adaptive cap's stuck signal for stateful tools (capMaxKeyRepeat is fed only from recordToolResult since trackCapKeyRepeat skips them), so the stuck condition cannot form between the 100 soft cap and the 1000 backstop either. Failure: a frozen task_list board commits 5 identical results (one short of the threshold-6 result-aware GLOBAL_TOOL_CALL_DUPLICATE); a transient provider Retry wipes the map; the count restarts at 1 and no re-stream of requests can rebuild it — with a retry inside any <=6-poll window the frozen poller evades the result-aware guards and runs to the 1000-call hard backstop instead of halting at ~5-6.
witness (probe): PR: firedAtTotalRecordedResult=11 (5 committed frozen results wiped by the Retry; the guard fires only after 6 fresh post-retry results) | MUTANT (clear removed): fires on the 6th committed result as designed — the probe flips.
Fix: remove this.statefulConsecutiveResults.clear(); from the Retry branch (keep the request-counter rollback) — the count re-accumulates correctly without the clear. Fix witness: record 5 identical frozen results, emit Retry, record one more identical result and assert recordToolResult returns true with GLOBAL_TOOL_CALL_DUPLICATE; with the clear in place the count restarts at 1 and the test goes red.
中文说明
[Critical] R18-1:Retry 分支删除了重试根本不可能重复计入的已提交结果证据。工具结果只在每次执行后、且仅在本轮流式完成之后记录一次(client.ts 在第 N+1 轮流式开始前记录第 N 轮的结果;agent-core 在 processFunctionCalls 之后记录),因此本处注释的理由对请求计数器成立——重放的请求会重新流式、会重复计入——但对结果计数器不成立:重放不会重新记录任何结果,这个 clear 纯粹是在删除已提交的证据。它同时把有状态工具的自适应上限卡滞信号清零(capMaxKeyRepeat 仅由 recordToolResult 喂入,trackCapKeyRepeat 跳过它们),于是卡滞条件在 100 软上限与 1000 兜底之间也无法形成。失败场景:冻结的 task_list 任务板已提交 5 次相同结果(距离阈值 6 的结果感知 GLOBAL_TOOL_CALL_DUPLICATE 还差 1 次);一次瞬态 provider Retry 清空该 map;计数从 1 重新开始且没有任何请求重流能重建它——只要任意 <=6 次轮询的窗口内出现一次 Retry,冻结的轮询者就会绕过结果感知守卫,一路跑到 1000 次硬兜底而不是在约 5-6 次处终止。
证据(探针):PR: firedAtTotalRecordedResult=11(5 次已提交的冻结结果被 Retry 清掉;守卫只在重试后又积累 6 次新结果后才触发)| MUTANT(移除 clear):按设计在第 6 次已提交结果处触发——探针翻转。
修复:从 Retry 分支移除 this.statefulConsecutiveResults.clear();(保留请求计数器的回滚)——没有这个 clear,计数也能正确重新累积。修复验收:记录 5 次相同冻结结果、发出 Retry、再记录 1 次相同结果,断言 recordToolResult 以 GLOBAL_TOOL_CALL_DUPLICATE 返回 true;保留 clear 时计数从 1 重来,测试变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
There was a problem hiding this comment.
New shape (R18-1): claims the Retry branch deletes committed result evidence that a retry can never double-count, since tool results are recorded once per executed call after the round's stream completes. This PR is scope-fused per patrol policy (cumulative additions +1854, no new code this round), so this stays human-gated for maintainer decision alongside the existing open Criticals.
| const digest = extractAnchoredStubDigest(value); | ||
| if (digest !== null) { | ||
| return `<persisted-stub>sha256:${digest}`; | ||
| } |
There was a problem hiding this comment.
[Critical] R18-2: A peer-quoted digest line inside a genuine digest-less stub hijacks the fingerprint. extractAnchoredStubDigest scans the WHOLE stub value: for a digest-less truncateAndSaveToFile stub (the R17-1 band — no envelope digest), a line-anchored Full output sha256: <64-hex> quoted inside the visible payload becomes the fingerprint. Peer-authored board text is interpolated verbatim (TeamManager.ts escapeEnvelopeTags defangs only <teammate_message> delimiter tokens), and teammate messages are appended at the board's end — squarely in the retained tail band. The leading-prefix gate correctly admits the value as a real stub; extraction then runs over envelope + payload, the quoted line wins, and every poll fingerprints to a constant even while the board changes — the productive poller false-halts at the 5th identical request. This is the #9450 false positive reintroduced through peer-authored content, defeating the protection the gate's own comment promises (quoted content "cannot collapse or vary the fingerprint"). Distinct from R17-1, which needs no injected content.
witness (probe through the real truncateAndSaveToFile producer): INJECTED arm fired=true at=4 (halt at the 5th identical request, consecutive_identical_tool_calls); INTERLEAVED injected arm fired=true at=5 (halt at the 6th identical result, global_tool_call_duplicate); CONTROL arm fired=false; FIX (digest extraction confined to the envelope region) fired=false on both injected arms with all 145 tests still green.
Fix: confine digest extraction to the envelope region — locate the payload marker (STUB_PREVIEW_MARKER / STUB_TRUNCATED_PART_MARKER) first and run extractAnchoredStubDigest only over the text before it, falling back to the path-free payload when no envelope digest exists. Fix witness: a digest-less stub whose payload carries a fixed digest line while the rest of the board varies per poll must not halt (fired === false); with the current whole-value search the guard halts, so the test is red today and pins the envelope-only restriction.
中文说明
[Critical] R18-2:真实无 digest stub 内部被引用的同侪内容 digest 行会劫持指纹。extractAnchoredStubDigest 扫描 stub 的完整值:对无 digest 的 truncateAndSaveToFile stub(即 R17-1 分支——信封中没有 digest),可见载荷中被引用的、行首锚定的 Full output sha256: <64-hex> 会成为指纹。同侪撰写的任务板文本被逐字插入(TeamManager.ts 的 escapeEnvelopeTags 只解除 <teammate_message> 分隔符 token),而 teammate 消息被追加在任务板末尾——恰好落在保留的尾部区间。前缀门正确地把该值当作真实 stub 放行;随后抽取在整个"信封 + 载荷"上运行,被引用的行胜出,即使任务板持续变化,每次轮询的指纹也是常量——高效的轮询者在第 5 次相同请求处被误杀。这是经同侪撰写内容重新引入的 #9450 误杀,击败了该门控自身注释承诺的保护(被引用内容"不得折叠或改变指纹")。与 R17-1 不同——后者不需要任何注入内容。
证据(经真实 truncateAndSaveToFile 生产者的探针):INJECTED 分支 fired=true at=4(第 5 次相同请求处终止,consecutive_identical_tool_calls);INTERLEAVED 注入分支 fired=true at=5(第 6 次相同结果处终止,global_tool_call_duplicate);CONTROL 分支 fired=false;FIX(digest 抽取限定在信封区域)在两个注入分支均 fired=false,且 145 个测试全部保持绿。
修复:把 digest 抽取限定在信封区域——先定位载荷标记(STUB_PREVIEW_MARKER / STUB_TRUNCATED_PART_MARKER),只在其之前的文本上运行 extractAnchoredStubDigest,无信封 digest 时回退到不含路径的载荷。修复验收:一个无 digest stub,其载荷携带固定 digest 行而任务板其余部分逐轮变化,必须不终止(fired === false);按当前的全值搜索守卫会终止,因此该测试今天即为红,正好钉住"仅限信封"的限制。
— qwen3.8-max via Qwen Code /review (v0.22.2)
There was a problem hiding this comment.
New shape (R18-2): claims a peer-quoted digest line inside a genuine digest-less stub can hijack the fingerprint, because extractAnchoredStubDigest scans the whole stub value. This PR is scope-fused per patrol policy (cumulative additions +1854, no new code this round), so this stays human-gated for maintainer decision alongside the existing open Criticals.
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.
Not explored to full depth (tool budget reached): chunk 2: executing agent-headless.test.ts to confirm the static trace — the review worktree has no node_modules , and npm ci plus the prerequisite workspace build e…; chunk 5: did not execute loopDetectionService.test.ts — the shared review worktree has no node_modules/dist, and a full install+build was not feasible without mutating….
Deferred under the convergence posture (round 19, not a blocker) — recorded, not requested in this round:
packages/core/src/agents/runtime/agent-interactive.test.ts:366 — [review] lastRoundError attribution unwitnessed — the test never asserts getLastRoundError()packages/core/src/services/loopDetectionService.test.ts:3025 — [review] quoted-stub-marker test cannot catch the collision directionpackages/core/src/tools/truncation.ts:528 — [review] buildStub digest producer zero coverage — producer/consumer contract never exercised end-to-endpackages/core/src/agents/runtime/agent-core.ts:1240 — [review] agent-core recordToolResult halt branch unwitnessedpackages/core/src/core/client.test.ts:8237 — [review] changed-board liveness test can pass on a silent turn deathpackages/core/src/core/client.test.ts:8230 — [review] frozen-board tests never assert requests stop after LoopDetectedpackages/core/src/core/client.ts:3615 — [review] id-less stateful calls lose all cap stuck-signal (fail-safe hole in the cap tier)packages/core/src/services/loopDetectionService.test.ts:3273 — [review] reset()/Retry stateful clears unwitnessed; cross-prompt count carry reachablepackages/core/src/agents/runtime/agent-interactive.ts:540 — [review] stop-message base label wrong for non-duplicate detectorspackages/core/src/services/loopDetectionService.test.ts:2985 — [review] TOOL_OUTPUT_TRUNCATED_PREFIX recognition layer has zero test witnesspackages/core/src/agents/runtime/agent-headless.ts:412 — [review] FINISH-event loopType has zero production read sitespackages/core/src/services/loopDetectionService.ts:617 — [review] checkAlternatingPattern not result-aware (heuristics-on false halt)packages/core/src/agents/runtime/agent-interactive.test.ts:355 — [review] status wait accepts both idle and failed, pins neither
Convergence: round 19 posted 6 inline comment(s), 2 of them reported for the first time; the previous round posted 5 (2 new). Findings keep coming back to the same files: packages/core/src/agents/runtime/agent-core.ts (findings in round 17; 1 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)
Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (6 Critical(s)), the rate of first-time findings is not falling (this round 2, previous 2), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):
| standing Critical | attack surface | attacker-dependency | blast radius |
|---|---|---|---|
| (each standing Critical) | … | … | … |
Advisory only — it does not block this review.
中文说明
仅完成部分审查,审查缺口已披露。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
未探索到全部深度(达到工具调用预算):chunk 2:executing agent-headless.test.ts to confirm the static trace — the review worktree has no node_modules , and npm ci plus the prerequisite workspace build e…;chunk 5:did not execute loopDetectionService.test.ts — the shared review worktree has no node_modules/dist, and a full install+build was not feasible without mutating…。
收敛姿态下延后(第 19 轮,非阻断)——已记录,本轮不要求修改:共 13 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 19 轮发布了 6 条行内评论,其中 2 条是首次提出;上一轮发布了 5 条(其中 2 条首次提出)。发现反复回到同一批文件:packages/core/src/agents/runtime/agent-core.ts(第 17 轮已出过发现,本轮又有 1 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)
残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 6 条 Critical),首次发现的速率没有下降(本轮 2,上一轮 2),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| for (const toolResult of toolCallResult.results) { | ||
| if ( | ||
| loopDetector.recordToolResult( |
There was a problem hiding this comment.
[Critical] R19-1: The result-aware exemption assumes "by the Nth identical request, the prior N−1 results have been recorded", but agent-core feeds results only after the whole batch executes (this loop runs after processFunctionCalls). Whenever the threshold-crossing request is not the first request of its round, its in-round predecessor's result does not exist yet at fire time (recordToolResult increments resultsObserved only for the current key, so an in-flight predecessor cannot count). Concretely: a teammate subagent batching 3 identical task_list({}) polls in round 1 and 2 in round 2 — 5 consecutive identical requests with the board changing between every poll — gets counts 1→3 in round 1 (3 changed results fed post-batch), then round 2's second request reaches count 5: expectedResults = 4 but resultsObserved = 3, the exemption is unreachable, and the fail-safe halts a productive poller with consecutive_identical_tool_calls — the exact #9450 false positive this PR removes. The 2+3 and 1+4 splits reach it too; only splits where the 5th request opens its round (4+1, 2+2+1) don't. Every service test interleaves a result after each request, an order production never presents for a round with ≥2 task_list calls, so the suite is green.
Witness — probe driving the real LoopDetectionService in agent-core's production order:
BASE: AssertionError — halt fires on 'round2-req2' despite 3 changed boards already recorded
controls on the same run: interleaved-order changed boards → no halt; frozen boards production order → halts; no-results fail-safe → halts
FIX arm (defer the stateful halt to the result feed): all 4 contract cases pass — probe flips
Fix: account for in-flight requests in the exemption — track outstanding requests per streak (requests streamed − results recorded) and evaluate resultsObserved >= toolCallRepetitionCount - 1 - outstanding — or defer the stateful-streak halt decision to the result feed / round boundary where all prior results have been fed. Verifier measurement: deferring the halt to recordToolResult preserved the #5019 no-evidence fail-safe in the probe, while folding outstanding into the exemption relaxed it — prefer the former.
Fix witness: add a production-order test — send 3 task_list requests, feed 3 changed results, send 2 more requests, assert the 5th checkAlwaysOnSafeties returns false; remove the fix and it must go red (please run that mutation to confirm).
中文说明
结果感知豁免依赖不变量"第 N 次相同请求时前 N−1 个结果已记录",但 agent-core 在整批执行完毕后才喂送结果(此循环位于 processFunctionCalls 之后)。当触发阈值的请求不是其所在轮的第一个请求时,同轮前序请求的结果在判定时必然缺失(recordToolResult 只对当前键累加 resultsObserved,在途前序无法计入)。具体场景:teammate 子 agent 第 1 轮批内轮询 3 次 task_list({})、第 2 轮批内轮询 2 次——共 5 次连续相同请求且任务板每次都在变化:第 1 轮计数 1→3(批后喂入 3 个已变化结果),第 2 轮第 2 次请求计数到 5:expectedResults = 4 而 resultsObserved = 3,豁免不可达,失败安全分支以 consecutive_identical_tool_calls 终止生产性轮询者——正是本 PR 要消除的 #9450 误杀。2+3、1+4 分布同样可触发;仅第 5 次请求开轮的分布(4+1、2+2+1)不会。所有服务层测试在每个请求后交替喂结果——生产环境在一轮 ≥2 次 task_list 时从不呈现该顺序,因此套件全绿。
修复:让豁免计入在途请求(按连续段跟踪未决请求数,判定 resultsObserved >= toolCallRepetitionCount - 1 - outstanding),或将状态性连续段的终止决定推迟到结果喂入/轮边界。验证者测得:把终止推迟到 recordToolResult 保留了 #5019 无证据失败安全,而把 outstanding 折进豁免会放松它——建议采用前者。
修复见证:新增生产顺序测试——连发 3 次 task_list 请求、喂入 3 个已变化结果、再连发 2 次请求,断言第 5 次 checkAlwaysOnSafeties 返回 false;移除修复必须变红(请运行该突变确认)。
— qwen3.8-max via Qwen Code /review (v0.22.2)
There was a problem hiding this comment.
Verified REAL at head 2351563 (read-only code trace; no code executed or changed):
- Request counting happens at stream time: every batched ToolCallRequest is fed to checkAlwaysOnSafeties (agent-core.ts:1142, deduped only by call id), incrementing toolCallRepetitionCount (loopDetectionService.ts:816).
- Results are fed only after the whole batch executes: processFunctionCalls (agent-core.ts:1214) runs first, then the recordToolResult loop (agent-core.ts:1233-1235). Nothing is fed mid-round.
- The exemption requires state.resultsObserved >= toolCallRepetitionCount - 1 (loopDetectionService.ts:846-847), and recordToolResult only increments resultsObserved for the current streak key (:465-466).
Trace: round 1 = 3 identical task_list({}) requests -> counts 1-3 (< TOOL_CALL_LOOP_THRESHOLD=5, :41), then 3 changed boards fed post-batch -> resultsObserved=3, unchangedStreak=0. Round 2: request 4 -> count 4; request 5 -> count 5 >= threshold: expectedResults=4 but resultsObserved=3 (round 2's batch has not executed yet), exemption unreachable -> halt with consecutive_identical_tool_calls despite every recorded board having changed. Any split where the 5th request does not open its round (3+2, 2+3, 1+4) hits this.
This PR is scope-fused and has been non-converging for 19 patrol rounds; recorded as needs-human-decision. No code changes this round. Leaving unresolved.
| ) { | ||
| hasToolCalls = false; | ||
| loopGuardFedCallIds.clear(); |
There was a problem hiding this comment.
[Critical] R19-2: The diff models Retry and ModelFallback identically as attempt restarts everywhere else — this branch clears loopGuardFedCallIds for both, turn.ts clears pendingToolCalls for both, both stream consumers zero their request arrays on fallback, and the heuristic tier in the same service resets for both — but checkAlwaysOnSafeties resets its counters only in its Retry branch. After a ModelFallback the failed attempt's counted-but-never-executed tool calls stay in toolCallRepetitionCount and can never produce results, so resultsObserved is permanently N behind expectedResults in checkToolCallLoop: the changed-board restart branch is unreachable for the rest of the streak, and a productive poller whose every executed poll returns a changed board halts with consecutive_identical_tool_calls — the #9450 false positive reintroduced through the fallback path.
Witness — probe (2 identical task_list requests → ModelFallback → productive re-polls with a changed board every round):
PR code: guard fired at post-2 with loopType=consecutive_identical_tool_calls (expected 'post-2' to be null)
FIXED (ModelFallback added to the reset branch): probe passed, firedAt=null
Fix: add a ModelFallback branch to checkAlwaysOnSafeties mirroring the Retry branch — roll turnToolCallTotal back to the committed floor, resetToolCallCount(), clear capKeyCounts/capMaxKeyRepeat, clear statefulConsecutiveResults, and zero resultsObserved/unchangedStreak for all statefulRepeatState entries (or extract the Retry branch body and call it for both event types).
Fix witness: feed TOOL_CALL_LOOP_THRESHOLD−1 identical task_list requests, dispatch a ModelFallback event, continue with identical requests whose recorded results change every poll, and assert no halt within 4×threshold; removing the ModelFallback reset must turn it red (please run that mutation to confirm).
中文说明
diff 在其余所有位置把 Retry 与 ModelFallback 同等建模为尝试重启——本分支对两者清空 loopGuardFedCallIds,turn.ts 对两者清空 pendingToolCalls,两个流消费者都在回退时清零请求数组,同一服务的启发式层也对两者重置——但 checkAlwaysOnSafeties 只在 Retry 分支重置计数器。ModelFallback 之后,失败尝试中已计数但永不执行的工具调用留在 toolCallRepetitionCount 中、永不产生结果,checkToolCallLoop 里 resultsObserved 永远落后 expectedResults N 个:变化板重启分支在该连续段剩余部分不可达,即使每次执行的轮询都返回变化板,生产性轮询者仍以 consecutive_identical_tool_calls 终止——#9450 误杀经回退路径复活。
修复:为 checkAlwaysOnSafeties 增加 ModelFallback 分支,镜像 Retry 分支——回滚 turnToolCallTotal 至已提交地板、resetToolCallCount()、清空 capKeyCounts/capMaxKeyRepeat、清空 statefulConsecutiveResults、清零所有 statefulRepeatState 条目的 resultsObserved/unchangedStreak(或抽取 Retry 分支体对两种事件共用)。
修复见证:喂入 TOOL_CALL_LOOP_THRESHOLD−1 次相同 task_list 请求、派发 ModelFallback 事件、继续以每次变化结果的相同请求轮询,断言 4×阈值内不终止;移除 ModelFallback 重置必须变红(请运行该突变确认)。
— qwen3.8-max via Qwen Code /review (v0.22.2)
There was a problem hiding this comment.
Verified REAL at head 2351563 (read-only code trace; no code executed or changed):
- checkAlwaysOnSafeties resets its counters only in the Retry branch (loopDetectionService.ts:728-744: cap rollback, resetToolCallCount, clears capKeyCounts/capMaxKeyRepeat/statefulConsecutiveResults, zeroes resultsObserved/unchangedStreak). ModelFallback has no branch: it falls through to
event.type !== LlmEventType.ToolCallRequest -> return false(:745) as a no-op, even though client.ts feeds every event into the guard (:3798). - Everywhere else the diff models Retry and ModelFallback identically as attempt restarts: client.ts:3752-3757 clears loopGuardFedCallIds for both; turn.ts:640 and :656 clear pendingToolCalls for both; the heuristic tier resets in both the Retry case (:625) and the ModelFallback case (:652, "Mirror the replay-retry resets").
Mechanism: the failed attempt's streamed requests stay in toolCallRepetitionCount but never execute (pendingToolCalls cleared on fallback), so they can never produce results. resultsObserved is then permanently < expectedResults = count - 1 (:846), the changed-board exemption at :847 is unreachable for the rest of the streak, and a poller whose every executed poll returns a changed board halts with consecutive_identical_tool_calls — the #9450 false positive via the fallback path.
This PR is scope-fused and has been non-converging for 19 patrol rounds; recorded as needs-human-decision. No code changes this round. Leaving unresolved.

















What this PR does
This PR makes the loop-detection guards result-aware for a narrow class of stateful read tools — currently only
task_list. For these tools identical arguments do not imply an identical result (other teammates can mutate the shared task board between calls), so the detector now records each executed result as a privacy-safe SHA-256 fingerprint and only treats repetition as a loop when the observed results stop changing too:task_listit requires result corroboration: it halts only when every result observed within the streak is unchanged, and missing result evidence fails safe and keeps the pre-fix behavior (the DashScope 长程任务下,出现大量工具重复调用情况,导致会话被终止 #5019 protection is never loosened by a wiring gap). A changed result restarts the streak instead of halting.(call, result fingerprint)pairs for these tools instead of raw requests, so productive polling does not accumulate toward either halt.processFunctionCalls) and the main-session continuation path inclient.ts(matched byfunctionResponsecall id). The daemon ACP path is unaffected: it has no consecutive-identical guard.ReasoningLoopResultcarries the exactLoopType, the interactive agent stop message includes it (Agent stopped: duplicate tool-call loop detected (consecutive_identical_tool_calls).), and the headlessFINISHevent plus the telemetry completion record carry it for journals.Why it's needed
Fixes the false positive in #9450: an Agent Team teammate polling
task_listwas stopped with "Agent stopped: duplicate tool-call loop detected." while its peers were actively completing tasks. The guards keyed on tool name + canonicalized args only and fired pre-execution, so a changed board between identical calls could never influence them — the service had no entry point for tool results at all. The report's evidence (five identical consecutivetask_list(status=in_progress, owner=<peer>, blockedBy="")calls) matches the consecutive guard's threshold exactly, and the team prompt actively encourages this polling pattern. The expected behavior per the issue: same query + changing state must not halt; same query + unchanged state must still halt; deterministic tools and hard caps keep all existing protection.Reviewer Test Plan
How to verify
Deterministic unit reproduction (no live team needed): feed five consecutive identical
task_listToolCallRequestevents intoLoopDetectionService.checkAlwaysOnSafeties(). Onmainthis firesCONSECUTIVE_IDENTICAL_TOOL_CALLSon the 5th request regardless of what the executed calls returned (there was no way to report results); with this PR, recorded results that keep changing prevent the halt, recorded unchanged results still halt at the same request count, and missing result evidence halts exactly as before.cd packages/core && npx vitest run src/services/loopDetectionService.test.ts— new describe block "Result-aware guards for stateful read tools (issue task_list can falsely trigger duplicate tool-call loop detection while team state changes #9450)": fail-safe, unchanged-still-halts, changing-state-survives (incl. past the adaptive 100-call cap), mid-streak change restarts, deterministic tools unchanged, callId pairing, result-aware global duplicate, result-aware action stagnation, retry/reset semantics.cd packages/core && npx vitest run src/agents/runtime/agent-headless.test.ts -t "issue #9450"— end-to-end through the agent runtime that produced the report: 8 identicaltask_listpolls with a changing board complete without LOOP_DETECTED; a frozen board still halts at the 5th request (4 executions) and the FINISH event carriesloopType: consecutive_identical_tool_calls.Evidence (Before & After)
Non-UI change (guard internals); the only user-visible string changes on an actual loop stop (detector name appended). Test output:
Before (main): repro test shows
checkAlwaysOnSafetiesfiresCONSECUTIVE_IDENTICAL_TOOL_CALLSon the 5th identicaltask_listrequest and the service exposes no result-recording API.After (this PR):
loopDetectionService.test.ts145/145,agent-headless.test.ts68/68,client.test.ts370/370,agent-interactive.test.ts25/25,qwen-logger.test.ts43/43; fullpackages/coresuite shows no new failures versus a clean baseline at the same commit (the pre-existing failures are machine-environment issues and reproduce identically on the unmodified baseline); typecheck clean.Tested on
Environment (optional)
Unit + integration tests only (
npm ci,npx vitest run,npx tsc --noEmiton packages/core and packages/cli). No live multi-agent run against a real model.Risk & Scope
task_listpoll whose results keep changing (e.g. an oscillating board) is no longer stopped by the consecutive/global guards — it remains bounded by the unchanged-state detection, the adaptive cap's hard backstop, and turn limits; the halt point for truly unchanged loops is unchanged (same request count as today, so the DashScope 长程任务下,出现大量工具重复调用情况,导致会话被终止 #5019 emission bound is preserved).send_message/task_updatedeliberately stay argument-only per the issue's narrow scope; the daemon ACP path has no consecutive-identical guard, and making its adaptive-cap stuck signal result-aware is deferred to Make daemon/ACP adaptive-cap stuck signal result-aware for stateful reads (follow-up from #9492) #10161 (issue requirement OpenAI API Error: 401 Incorecct API Key provided #6); the alternating-pattern detector is untouched.recordToolResult/recordToolResultByCallIdare additive,ReasoningLoopResult.loopTypeandAgentFinishEvent.loopTypeare optional, and all existing guard behavior for non-stateful tools is unchanged.Linked Issues
Closes #9450
中文说明
本 PR 做了什么
本 PR 让循环检测守卫对一窄类有状态读取工具(目前仅
task_list)变为结果感知:对这类工具,相同参数并不意味着相同结果(其他 teammate 可能在两次调用之间修改共享任务板),因此检测器现在会把每次执行结果记录为隐私安全的 SHA-256 指纹,只有当观察到的结果也不再变化时才把重复判定为循环:task_list需要结果佐证:仅当该连续段内观察到的结果全部未变化时才终止;缺少结果证据时失败保持安全(维持修复前行为),DashScope 长程任务下,出现大量工具重复调用情况,导致会话被终止 #5019 的防护不会因接线缺口而放松。结果发生变化时重置连续段而不是终止。processFunctionCalls之后)与主会话续接路径(client.ts,通过functionResponse的 callId 配对)。daemon ACP 路径不受影响:该路径没有连续相同守卫。ReasoningLoopResult携带精确的LoopType,交互式 agent 的停止信息包含它(Agent stopped: duplicate tool-call loop detected (consecutive_identical_tool_calls).),headless 的FINISH事件与遥测完成记录也携带它,供日志归因。为什么需要
修复 #9450 的误杀:Agent Team 的 teammate 在 peer 仍在持续完成任务时轮询
task_list,被 "Agent stopped: duplicate tool-call loop detected." 终止。守卫仅以工具名 + 规范化参数为键,且在执行前触发,两次调用之间任务板的变化根本无法影响判定——服务完全没有工具结果的入口。报告证据(五次连续相同的task_list(status=in_progress, owner=<peer>, blockedBy="")调用)与该连续守卫的阈值完全吻合,且团队提示词本身就鼓励这种轮询模式。按 issue 的期望行为:相同查询 + 状态变化不得终止;相同查询 + 状态不变仍要终止;确定性工具与硬上限保留全部既有保护。评审测试计划
如何验证
确定性单元复现(无需实时团队):向
LoopDetectionService.checkAlwaysOnSafeties()输入五次连续相同的task_listToolCallRequest事件。在main上,无论执行结果如何,第 5 次请求都会触发CONSECUTIVE_IDENTICAL_TOOL_CALLS(当时没有任何上报结果的途径);本 PR 之后,持续变化的已记录结果会阻止终止,全部未变化的已记录结果仍在同一请求数处终止,缺少结果证据时与修复前行为完全一致。cd packages/core && npx vitest run src/services/loopDetectionService.test.ts—— 新增 describe 块 "Result-aware guards for stateful read tools (issue task_list can falsely trigger duplicate tool-call loop detection while team state changes #9450)":失败保持安全、结果全不变仍终止、状态持续变化可存活(含超过自适应 100 次上限)、连续段中途变化后重置、确定性工具行为不变、callId 配对、结果感知的全局重复、结果感知的动作停滞、retry/reset 语义。cd packages/core && npx vitest run src/agents/runtime/agent-headless.test.ts -t "issue #9450"—— 端到端贯穿产生报告的 agent 运行时:8 次相同task_list轮询且任务板持续变化时完整跑完、不出现 LOOP_DETECTED;任务板冻结时仍在第 5 次请求处终止(执行 4 次),FINISH 事件携带loopType: consecutive_identical_tool_calls。证据(修复前/后)
非 UI 改动(守卫内部逻辑);唯一的用户可见字符串变化发生在真实循环终止时(附加检测器名称)。测试输出:
修复前(main):复现测试显示
checkAlwaysOnSafeties在第 5 次相同task_list请求时触发CONSECUTIVE_IDENTICAL_TOOL_CALLS,且服务不存在任何结果记录 API。修复后(本 PR):
loopDetectionService.test.ts145/145、agent-headless.test.ts68/68、client.test.ts370/370、agent-interactive.test.ts25/25、qwen-logger.test.ts43/43;packages/core全量套件相对同一 commit 的干净基线无新增失败(既有失败为本机环境问题,在未修改的基线上同样复现);typecheck 干净。测试环境
环境(可选)
仅单元测试 + 集成测试(
npm ci、npx vitest run、packages/core 与 packages/cli 的npx tsc --noEmit)。未做真实模型的实时多 agent 运行。风险与范围
task_list轮询(例如 oscillating 的任务板)不再被连续/全局守卫终止——仍受未变化状态检测、自适应上限硬兜底与回合数上限约束;真正未变化循环的终止点不变(与今天相同的请求数,DashScope 长程任务下,出现大量工具重复调用情况,导致会话被终止 #5019 的发送上限得以保持)。send_message/task_update刻意保持仅按参数判定;daemon ACP 路径没有连续相同守卫,其自适应上限卡滞信号的结果感知化延迟到 Make daemon/ACP adaptive-cap stuck signal result-aware for stateful reads (follow-up from #9492) #10161(issue 要求 OpenAI API Error: 401 Incorecct API Key provided #6);交替模式检测器未触碰。recordToolResult/recordToolResultByCallId为新增方法,ReasoningLoopResult.loopType与AgentFinishEvent.loopType为可选字段,非状态化工具的全部既有守卫行为不变。关联 Issue
Closes #9450