Skip to content

fix(serve): detect stale SSE cursors across daemon restarts via epoch token; preserve turn attribution and surface compaction failures in replay - #7458

Merged
wenshao merged 8 commits into
QwenLM:mainfrom
doudouOUC:fix/daemon-recovery-epoch-compaction
Jul 23, 2026
Merged

fix(serve): detect stale SSE cursors across daemon restarts via epoch token; preserve turn attribution and surface compaction failures in replay#7458
wenshao merged 8 commits into
QwenLM:mainfrom
doudouOUC:fix/daemon-recovery-epoch-compaction

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

What this PR does

This PR hardens the daemon's event replay/reconnect chain in three ways. First, every session event bus now mints a random epoch token when it is constructed, and that token travels with every surface a client learns a resume cursor from: session load/resume/create responses, the non-blocking 202 prompt envelope, and an X-Qwen-Event-Epoch response header on both SSE surfaces (REST events stream and the /acp session stream). Clients echo the token back alongside Last-Event-ID on reconnect; when it doesn't match the bus's current epoch, the daemon deterministically forces the existing resync path instead of trusting event-id arithmetic. The resync frame carries a detail: 'epoch_mismatch' discriminator so operators can tell the token-based trigger apart from the numeric heuristic. Second, the turn-boundary compaction engine now preserves turn attribution: merged text/thought events re-stamp the latest promptId/originatorClientId/data.sessionId captured from their source chunks, and folded tool-call events switch to latest-wins attribution, mirroring how the event id is already merged. Third, compaction failures are no longer invisible: the bus latches a degraded flag on the first ingest/seed failure, replay snapshots built after that point are marked degraded, session load responses surface it as replayDegraded, and the daemon logs one operator breadcrumb per session plus a warning when an /acp initial replay serves a degraded snapshot. The TypeScript SDK learns the epoch from restore responses and response headers, persists it next to the cursor, and echoes it on every reconnect — including the prompt-envelope-driven subscribe inside prompt().

Why it's needed

Event ids restart from 1 on every daemon restart, and the only stale-cursor detection was a numeric heuristic (lastEventId >= nextId). Once the new epoch's event count catches up with a stale cursor, that heuristic is defeated: a client reconnecting with Last-Event-ID: 50 against a bus that has already emitted 60 fresh events looks like a perfectly valid suffix resume, so it silently skips the new epoch's first 50 events and applies deltas on top of reducer state from the dead epoch. Separately, clients rebuilding state from a compacted replay snapshot lost the ability to correlate merged events to their prompt or filter by originator, because compaction dropped those stamps. And when the compaction engine threw, the bus correctly swallowed the error to keep publish() never-throwing — but nothing recorded that the snapshot was now incomplete, so every later consumer served silently-truncated replay data with no signal to the operator or the client. These are items DAEMON-001, DAEMON-007, and DAEMON-008 from the daemon/SDK reliability audit ( https://github.com/doudouOUC/code_agent/blob/main/qwen-code/feature/daemon-serve-mode/12-daemon-sdk-reliability-audit.md ), following the earlier batches #7386 and #7400 .

All changes are backward compatible in both directions: an old client that never sends the epoch header gets today's numeric-heuristic behavior, and a new client talking to an old daemon simply never learns an epoch and falls back the same way. The new response fields are optional and additive.

Reviewer Test Plan

How to verify

  • Epoch mismatch forces resync: start qwen serve, create a session, prompt it, and note the SSE cursor. Restart the daemon, load the session again, then subscribe to GET /session/:id/events with the OLD Last-Event-ID and the OLD X-Qwen-Event-Epoch value. Expected: the stream opens with a state_resync_required frame with reason: 'epoch_reset' and detail: 'epoch_mismatch', followed by a full replay — even when the new bus has already emitted more events than the stale cursor (the case the numeric heuristic misses). Without the header, behavior is unchanged.
  • Epoch is advertised everywhere a cursor is: check that load/resume responses carry eventEpoch next to lastEventId, that a non-blocking prompt 202 envelope carries eventEpoch, and that both SSE surfaces respond with an X-Qwen-Event-Epoch header (also on the first, cursor-less subscribe).
  • Attribution survives compaction: run a multi-turn session with enough streamed text to trigger turn-boundary compaction, then load it with replay. Expected: merged agent_message_chunk/agent_thought_chunk events in compactedReplay still carry top-level promptId/originatorClientId and data.sessionId; folded tool_call events carry the latest stamp.
  • Degraded snapshot is visible: unit-level — see the eventBus tests that force the compaction engine to throw; the snapshot gains degraded: true, load responses gain replayDegraded: true, and the daemon writes a single compaction degraded for session=… stderr line.
  • Unit coverage: packages/acp-bridge (eventBus epoch + degradation, compactionEngine attribution, bridge restore payloads), packages/cli (REST SSE header/query plumbing, /acp transport epoch pairing, 202 envelope), packages/sdk-typescript (transport header send/learn, session client seeding/refresh).

Evidence (Before & After)

N/A (protocol/replay-layer change; no TUI surface). Full test runs after rebasing onto latest main: packages/acp-bridge 541 passed, packages/cli serve suites 771 + 409 passed, packages/sdk-typescript 423 passed; npm run build and npm run typecheck clean.

Tested on

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

Environment (optional)

Unit tests via vitest per package; build + typecheck from the repo root.

Risk & Scope

  • Main risk or tradeoff: the epoch comparison only engages when a client presents both a cursor and an epoch, so the blast radius of a wrong match is limited to forcing a resync (which is also the recovery path for ring eviction today). The compaction re-stamping only adds optional fields to merged events ("present only if set"), so consumers that ignored attribution before see no change.
  • Not validated / out of scope: WebSocket /acp transport intentionally ignores the epoch option (it has no resume mechanism, so there is no stale-cursor problem — documented inline); DAEMON-009/010/011 (resource hardening) are a separate batch.
  • Breaking changes / migration notes: none. New fields and headers are optional and additive; old daemons ignore the request header, old clients ignore the response fields.

Linked Issues

Fixes #7457

中文说明

本 PR 做了什么

本 PR 从三方面加固 daemon 的事件重放/重连链路。其一,每个会话事件总线在构造时生成一个随机 epoch token,并随所有客户端可获取续传游标的通道下发:load/resume/create 响应、非阻塞 202 prompt envelope,以及两个 SSE 通道(REST 事件流与 /acp 会话流)的 X-Qwen-Event-Epoch 响应头。客户端重连时随 Last-Event-ID 一起回传该 token;与总线当前 epoch 不一致时,daemon 确定性地走既有的 resync 路径而不再依赖事件 id 的数字推断。resync 帧携带 detail: 'epoch_mismatch' 判别字段,方便运维区分 token 触发与数字启发式触发。其二,turn 边界压缩引擎现在保留 turn 归属:合并后的 text/thought 事件重新盖上源 chunk 中捕获的最新 promptId/originatorClientId/data.sessionId,折叠后的 tool_call 事件改为 latest-wins,与事件 id 的合并方式一致。其三,压缩失败不再不可见:总线在首次 ingest/seed 失败时锁存 degraded 标志,此后构建的重放快照标记为 degraded,load 响应以 replayDegraded 透出,daemon 每会话写一条运维日志,/acp 初始重放使用降级快照时也会告警。TypeScript SDK 从 restore 响应和响应头学习 epoch,与游标一起保存,并在每次重连(包括 prompt() 内部由 202 envelope 驱动的订阅)时回传。

为什么需要

daemon 每次重启后事件 id 从 1 重新开始,此前唯一的过期游标检测是数字启发式(lastEventId >= nextId)。一旦新纪元的事件数追上旧游标,该启发式即失效:客户端带着 Last-Event-ID: 50 重连、而新总线已发出 60 个事件时,看起来是完全合法的后缀续传,于是客户端静默跳过新纪元前 50 个事件,并把增量应用在死纪元的 reducer 状态之上。另外,从压缩重放快照重建状态的客户端无法再做 prompt 关联和 originator 过滤,因为压缩丢掉了这些标记。压缩引擎抛错时总线为维持 publish() 永不抛错而正确地吞掉了异常——但没有任何地方记录快照已不完整,之后所有消费方都把被静默截断的重放数据当完整的下发,运维和客户端都得不到信号。这些对应 daemon/SDK 可靠性审计文档( https://github.com/doudouOUC/code_agent/blob/main/qwen-code/feature/daemon-serve-mode/12-daemon-sdk-reliability-audit.md )中的 DAEMON-001、DAEMON-007、DAEMON-008,是 #7386#7400 之后的第三批。

所有改动双向向后兼容:旧客户端不发 epoch 头则保持现有数字启发式行为;新客户端连旧 daemon 学不到 epoch,同样回落。新增响应字段均为可选、纯增量。

审阅者验证计划

如何验证

  • epoch 不一致强制 resync:启动 qwen serve,创建会话并 prompt,记下 SSE 游标。重启 daemon 并重新 load 会话,然后带旧的 Last-Event-ID 和旧的 X-Qwen-Event-Epoch 订阅 GET /session/:id/events。预期:流以 state_resync_requiredreason: 'epoch_reset'detail: 'epoch_mismatch')开头并全量重放——即使新总线的事件数已超过旧游标(数字启发式漏掉的场景)。不带该头则行为不变。
  • 有游标处即有 epoch:确认 load/resume 响应在 lastEventId 旁携带 eventEpoch、非阻塞 prompt 202 envelope 携带 eventEpoch、两个 SSE 通道均返回 X-Qwen-Event-Epoch 响应头(首个无游标订阅也返回)。
  • 归属跨压缩存活:跑一个流式文本足够多、触发 turn 边界压缩的多轮会话,再带 replay load。预期:compactedReplay 中合并的 agent_message_chunk/agent_thought_chunk 仍携带顶层 promptId/originatorClientIddata.sessionId;折叠的 tool_call 携带最新标记。
  • 降级快照可见:单测层面——见强制压缩引擎抛错的 eventBus 测试;快照获得 degraded: true,load 响应获得 replayDegraded: true,daemon 写一条 compaction degraded for session=… stderr 日志。
  • 单测覆盖:packages/acp-bridge(eventBus epoch + 降级、compactionEngine 归属、bridge restore 载荷)、packages/cli(REST SSE 头/查询串联、/acp transport epoch 配对、202 envelope)、packages/sdk-typescript(transport 头收发、session client 播种/刷新)。

证据(Before & After)

N/A(协议/重放层改动,无 TUI 表面)。rebase 到最新 main 后全量测试:packages/acp-bridge 541 通过、packages/cli serve 套件 771 + 409 通过、packages/sdk-typescript 423 通过;npm run buildnpm run typecheck 干净。

风险与范围

  • 主要风险/权衡:epoch 比较仅在客户端同时提供游标与 epoch 时生效,误判的影响面仅限强制 resync(这也是今天 ring 淘汰的恢复路径)。压缩重新盖章仅向合并事件添加可选字段("present only if set"),此前忽略归属的消费方行为无变化。
  • 未验证/范围外:WebSocket /acp transport 有意忽略 epoch 选项(无续传机制即无过期游标问题——已内联注释说明);DAEMON-009/010/011(资源硬化)属于另一批。
  • 破坏性变更/迁移说明:无。新字段与新头均为可选、纯增量;旧 daemon 忽略请求头,旧客户端忽略响应字段。

关联 Issue

Fixes #7457

…nd degraded-snapshot signaling (DAEMON-001/007/008)
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Jul 21, 2026
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed bug with clear evidence. Issue #7457 documents three concrete defects in the daemon's event replay/reconnect chain. DAEMON-001 (stale cursor) is a provable logic bug — the numeric heuristic lastEventId >= nextId is demonstrably defeated once a restarted bus emits enough events to catch up with a stale cursor. DAEMON-007 (attribution loss) and DAEMON-008 (silent degradation) are likewise concrete: compaction drops stamps that clients need for prompt correlation, and compaction failures produce silently-truncated snapshots with no signal to anyone.

Direction: aligned. Daemon serve-mode reliability is core infrastructure, and these fixes harden the reconnect/replay path without changing the public contract. All changes are backward compatible in both directions (old client ↔ new daemon, new client ↔ old daemon). CHANGELOG: no direct reference to epoch tokens, but the daemon/SDK reliability series (#7386, #7400) is established prior art in this area.

Size: not applicable — no core module paths (packages/core/src/**, etc.) are touched. Changes span packages/acp-bridge, packages/cli/src/serve, and packages/sdk-typescript. Production logic: ~458 lines; test code: ~991 lines.

Approach: the scope feels right. Three related fixes in one PR is reasonable here — they share the same code paths (eventBus, compactionEngine, bridge) and are part of an established batch series. Each fix is minimal: epoch is a randomUUID() field + one comparison in subscribe(), attribution is two capture helpers + re-stamping in the merge path, degradation is a boolean latch + one spread. No unrelated changes or drive-by refactors.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的 bug,有明确证据。Issue #7457 记录了 daemon 事件重放/重连链路上的三个具体缺陷。DAEMON-001(过期游标)是可证明的逻辑缺陷——数字启发式 lastEventId >= nextId 在重启后的总线发出足够多事件追上旧游标时即被击穿。DAEMON-007(归属丢失)和 DAEMON-008(静默降级)同样是具体问题:压缩丢掉了客户端做 prompt 关联所需的标记,压缩失败则产生被静默截断的快照,运维和客户端都得不到信号。

方向:对齐。Daemon serve 模式可靠性属于核心基础设施,这些修复加固了重连/重放路径而不改变公共契约。所有改动双向向后兼容(旧客户端 ↔ 新 daemon,新客户端 ↔ 旧 daemon)。CHANGELOG:无 epoch token 的直接引用,但 daemon/SDK 可靠性系列(#7386#7400)是该方向的已有先例。

规模:不适用——未触及核心模块路径(packages/core/src/** 等)。改动分布在 packages/acp-bridgepackages/cli/src/servepackages/sdk-typescript。生产逻辑约 458 行;测试代码约 991 行。

方案:范围合理。三个相关修复放在一个 PR 里在此处是合理的——它们共享相同的代码路径(eventBus、compactionEngine、bridge),且属于已确立的批次系列。每个修复都是最小化的:epoch 是一个 randomUUID() 字段 + subscribe() 中的一次比较,归属是两个捕获辅助函数 + 合并路径中的重新盖章,降级是一个布尔锁存 + 一次展开。无无关改动或顺手重构。

进入代码审查 🔍

Qwen Code · qwen3.7-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

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

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal (before reading the diff): for the stale-cursor problem, I'd add a random token to EventBus on construction, advertise it via response headers and restore payloads, and force resync on mismatch. For attribution, carry latest stamps through compaction merges. For degradation, latch a flag on first compaction error and surface it in snapshots. Touch eventBus.ts, compactionEngine.ts, bridge.ts, bridgeTypes.ts, CLI serve routes, SDK transport/client.

Comparison with the diff: the PR's approach matches my independent proposal almost exactly. No simpler path missed.

No critical blockers found. A few observations:

  • The epoch check (opts.epoch !== undefined && opts.epoch !== this.epoch) is correctly nested inside the lastEventId !== undefined block — epoch is meaningless without a cursor, and the SDK enforces this by only sending the header alongside Last-Event-ID.
  • parseEventEpochHeader accepts [A-Za-z0-9_-]{1,64} — the 64-char bound guards against log/header abuse while accommodating randomUUID() (36 chars). Invalid values degrade to "not provided" with an operator log, never aborting the subscription.
  • The onCompactionError callback is wrapped in try/catch inside markCompactionDegraded, preserving publish()'s never-throws contract even if the diagnostics callback itself throws.
  • Attribution helpers (captureTurnFields, captureSessionId) return undefined when the source event carries no stamps, so ?? fallbacks keep an earlier capture — no invented fields.
  • The stream.open() call in acp-http/index.ts was correctly moved after the epoch header setup so the header is flushed with the SSE handshake.
  • WS transport explicitly documents why it ignores epoch (no resume mechanism) — good inline rationale.

Conventions: ESM ✓, no any ✓, kebab-case filenames ✓, collocated tests ✓, JSDoc on new public SDK API fields (appropriate for a public interface).

Real-Scenario Testing

Protocol/replay-layer change — no TUI surface. Tested the daemon serve mode directly via REST/SSE endpoints.

Server startup

qwen serve listening on http://127.0.0.1:18923 (mode=http-bridge, workspace=...)
qwen serve: bearer auth disabled (loopback default). Set QWEN_SERVER_TOKEN to enable.
qwen serve: /acp WebSocket transport enabled on /acp

Test 1: SSE response advertises X-Qwen-Event-Epoch

$ curl -s -D- -N "http://127.0.0.1:18923/session/$SESS/events" \
    -H "Accept: text/event-stream" --max-time 3

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache, no-transform
X-Accel-Buffering: no
X-Qwen-Event-Epoch: d4261351-4327-4ee5-a0a7-9b8a2dc8cc25

retry: 3000

Test 2: Matching epoch → normal resume (no resync frame)

$ curl -s -N ".../events" -H "Last-Event-ID: 0" \
    -H "X-Qwen-Event-Epoch: d4261351-4327-4ee5-a0a7-9b8a2dc8cc25"

retry: 3000

id: 1
event: session_update
data: {"id":1,"v":1,"type":"session_update","data":{"sessionId":"7826387d-...",...}}

No state_resync_required frame — normal suffix resume.

Test 3: Stale epoch → forced resync with detail=epoch_mismatch

$ curl -s -N ".../events" -H "Last-Event-ID: 0" \
    -H "X-Qwen-Event-Epoch: dead-epoch-token"

retry: 3000

event: state_resync_required
data: {"v":1,"type":"state_resync_required","data":{"reason":"epoch_reset","detail":"epoch_mismatch","lastDeliveredId":0,"earliestAvailableId":1},...}

id: 1
event: session_update
data: {"id":1,"v":1,"type":"session_update",...}

Epoch mismatch deterministically forces resync — even though Last-Event-ID: 0 with 1 event in the bus would pass the numeric heuristic.

Test 4: Invalid epoch → degrades gracefully

$ curl -s -N ".../events" -H "Last-Event-ID: 0" \
    -H "X-Qwen-Event-Epoch: not a valid token!"

retry: 3000

id: 1
event: session_update
data: {"id":1,"v":1,"type":"session_update",...}

No resync frame — invalid token rejected, falls back to numeric heuristic. Server log:

qwen serve: rejected X-Qwen-Event-Epoch not a valid token! (expected [A-Za-z0-9_-]{1,64})

Test 5: Load response carries eventEpoch

$ curl -s -X POST ".../session/$SESS/load" -d '{"workspaceCwd":"..."}'

{
  "sessionId": "7826387d-dede-49ba-802e-200012bed1cf",
  "lastEventId": 1,
  "eventEpoch": "d4261351-4327-4ee5-a0a7-9b8a2dc8cc25"
}

Test 6: Server log for epoch mismatch resync

qwen serve: SSE ring eviction detected (session 7826387d-...): lastEventId=0, earliestInRing=1, gap=0 events, reason=epoch_reset, detail=epoch_mismatch. Consumer must call loadSession to recover.

Unit tests

All changed test files pass:

Package Test file Tests
acp-bridge eventBus.test.ts 49 ✓
acp-bridge compactionEngine.test.ts 64 ✓
acp-bridge bridge.test.ts 428 ✓
cli sse-last-event-id.test.ts 25 ✓
cli acp-http/transport.test.ts 276 ✓
sdk-typescript RestSseTransport.test.ts 44 ✓
sdk-typescript AcpHttpTransport.test.ts 48 ✓
sdk-typescript DaemonClient.test.ts 284 ✓
sdk-typescript DaemonSessionClient.test.ts 47 ✓
中文说明

代码审查

独立方案(读 diff 之前):对过期游标问题,我会在 EventBus 构造时加一个随机 token,通过响应头和 restore 载荷下发,不匹配时强制 resync。对归属问题,在压缩合并时保留最新标记。对降级问题,在首次压缩失败时锁存标志并在快照中透出。涉及 eventBus.ts、compactionEngine.ts、bridge.ts、bridgeTypes.ts、CLI serve 路由、SDK transport/client。

与 diff 对比: PR 方案与我的独立方案几乎完全一致。没有遗漏更简路径。

未发现关键阻塞问题。几个观察:

  • epoch 检查正确嵌套在 lastEventId !== undefined 块内——没有游标时 epoch 无意义,SDK 也只在发送 Last-Event-ID 时才附带该头。
  • parseEventEpochHeader 接受 [A-Za-z0-9_-]{1,64}——64 字符上限防止日志/头滥用,同时容纳 randomUUID()(36 字符)。无效值降级为"未提供"并写运维日志,不会中止订阅。
  • onCompactionError 回调在 markCompactionDegraded 内被 try/catch 包裹,即使诊断回调本身抛错也维持 publish() 的永不抛错契约。
  • 归属辅助函数在源事件无标记时返回 undefined?? 回退保留先前的捕获——不会凭空发明字段。
  • acp-http/index.tsstream.open() 正确移到 epoch 头设置之后,确保头随 SSE 握手一起刷出。
  • WS transport 明确注释了忽略 epoch 的原因(无续传机制)——好的内联说明。

规范:ESM ✓、无 any ✓、kebab-case 文件名 ✓、测试共置 ✓、新公共 SDK API 字段有 JSDoc(公共接口适当)。

真实场景测试

协议/重放层改动——无 TUI 表面。通过 REST/SSE 端点直接测试 daemon serve 模式。

  • SSE 响应正确携带 X-Qwen-Event-Epoch
  • 匹配 epoch → 正常续传(无 resync 帧)
  • 过期 epoch → 强制 resync,携带 detail=epoch_mismatch
  • 无效 epoch → 优雅降级,回退到数字启发式
  • Load 响应携带 eventEpoch
  • 服务端日志正确记录 epoch 不匹配和无效 token 拒绝

所有变更测试文件通过(共 1265 个测试)。

Qwen Code · qwen3.7-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean across every stage; would merge without hesitation.

The problem is real and provable: the numeric heuristic lastEventId >= nextId is defeated the moment a restarted bus emits enough events to catch up with a stale cursor. This isn't a "could theoretically happen" — it's a deterministic logic gap. The epoch token closes it with one randomUUID() field and one comparison, which is about as minimal as a fix gets.

The implementation matches my independent proposal almost line-for-line. Each of the three fixes (epoch, attribution, degradation) is self-contained, adds no unnecessary abstraction, and carries comprehensive tests (1265 across the changed files). The real-scenario verification confirmed all three behaviors end-to-end: epoch header advertised on every SSE surface, mismatch forces resync with the epoch_mismatch discriminator, invalid tokens degrade gracefully, and load responses carry eventEpoch alongside lastEventId.

Backward compatibility is clean in both directions — old clients never send the header, new clients talking to old daemons never learn an epoch. The WS transport's explicit "not applicable" comment is the right call.

If I had to maintain this in six months, I'd thank the author: the code is well-structured, the JSDoc on public SDK fields is appropriate, and the inline comments explain why (DAEMON-001 references, backward-compat rationale) rather than narrating what.

中文说明

置信度:5/5 —— 每个阶段都干净;毫不犹豫地合并。

问题是真实且可证明的:数字启发式 lastEventId >= nextId 在重启后的总线发出足够多事件追上旧游标时即被击穿。这不是"理论上可能发生"——而是确定性的逻辑缺口。epoch token 用一个 randomUUID() 字段和一次比较就堵上了它,这几乎是最小化的修复。

实现与我的独立方案几乎逐行一致。三个修复(epoch、归属、降级)各自独立,不添加不必要的抽象,并配有全面的测试(变更文件共 1265 个)。真实场景验证端到端确认了所有三个行为:epoch 头在每个 SSE 表面下发、不匹配强制 resync 并携带 epoch_mismatch 判别字段、无效 token 优雅降级、load 响应在 lastEventId 旁携带 eventEpoch

双向向后兼容干净——旧客户端从不发送该头,新客户端连旧 daemon 学不到 epoch。WS transport 明确的"不适用"注释是正确的做法。

如果六个月后我要维护这段代码,我会感谢作者:代码结构良好,公共 SDK 字段的 JSDoc 恰当,内联注释解释为什么(DAEMON-001 引用、向后兼容理由)而非叙述做了什么

Qwen Code · qwen3.7-max

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point:

No action taken

No actionable feedback was found on PR #7458:

  • Reviews: None
  • Inline comments: None
  • Failed checks: None
  • Issue-level comments: One automated A/B test result from @qwen-code-ci-bot — ✅ no response changes against the PR base across 4 scenarios (informational only, no action required)

No code changes were made.

中文说明

未采取任何操作

PR #7458 上没有发现需要处理的反馈:

  • 代码审查:
  • 行内评论:
  • 失败的检查:
  • Issue 级别评论: 来自 @qwen-code-ci-bot 的一条自动化 A/B 测试结果——✅ 在 4 个场景中,与 PR 基础分支相比没有响应变化(仅供参考,无需操作)

未进行任何代码更改。

Base-conflict check: no conflict with main.


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestions are inline. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

— qwen3.7-max via Qwen Code /review

Comment thread packages/acp-bridge/src/compactionEngine.ts Outdated
Comment thread packages/acp-bridge/src/bridge.ts
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on:

Review feedback addressed

[rc:3624409595] captureTurnFields atomic replacement → field-level merge (Suggestion — implemented)

Finding: captureTurnFields returned a partial object when an event carried only one of promptId/originatorClientId, and the ?? fallback replaced the entire previous capture — silently dropping the other field from an earlier chunk. This was inconsistent with the tool_call merge path which uses per-field ?? fallback.

Decision: Implemented. The fix makes the text/thought merge path consistent with the tool_call path.

Changes:

  • packages/acp-bridge/src/compactionEngine.ts: captureTurnFields now accepts an optional previous parameter and merges each field independently (event.promptId ?? previous?.promptId). The two merge call sites pass the existing lastTurn as the fallback. New-slot call sites are unchanged (no previous value).
  • packages/acp-bridge/src/compactionEngine.test.ts: Added two tests — one for the top-level path (chunk 2 carries only promptId, originatorClientId survives from chunk 1) and one for the subagent path (reverse: chunk 2 carries only originatorClientId, promptId survives).

[rc:3624409621] Missing bridge-level replayDegraded integration test (Suggestion — implemented)

Finding: No bridge-level test exercised the path where a compaction failure causes loadSession to carry replayDegraded: true. A future refactor of buildReplayResponse could strip the field with no test catching it.

Decision: Implemented. Added a bridge-level integration test.

Changes:

  • packages/acp-bridge/src/bridge.test.ts: Added test 'surfaces replayDegraded on loadSession when compaction fails' — creates a session via spawnOrAttach, spies on TurnBoundaryCompactionEngine.prototype.ingest to throw, triggers an event publish via sendPrompt, restores the spy, then calls loadSession on the same session and asserts replayDegraded: true in the response.

Verification

  • npm run build
  • npm run typecheck
  • npm run lint
  • npx vitest run src/compactionEngine.test.ts — 66 tests passed ✅
  • npx vitest run src/eventBus.test.ts — 49 tests passed ✅
  • npx vitest run src/bridge.test.ts — 429 tests passed ✅
中文说明

已处理的评审反馈

[rc:3624409595] captureTurnFields 原子替换 → 字段级合并(建议 — 已实现)

发现: 当事件仅携带 promptId/originatorClientId 中的一个时,captureTurnFields 返回一个部分对象,而 ?? 回退会替换整个先前的捕获——静默丢失了先前 chunk 中的另一个字段。这与 tool_call 合并路径使用逐字段 ?? 回退的做法不一致。

决定: 已实现。此修复使 text/thought 合并路径与 tool_call 路径保持一致。

变更:

  • packages/acp-bridge/src/compactionEngine.tscaptureTurnFields 现在接受一个可选的 previous 参数,并独立合并每个字段(event.promptId ?? previous?.promptId)。两个合并调用点将现有的 lastTurn 作为回退传递。新 slot 的调用点不变(无先前值)。
  • packages/acp-bridge/src/compactionEngine.test.ts:新增两个测试——一个用于顶层路径(chunk 2 仅携带 promptIdoriginatorClientId 从 chunk 1 保留),一个用于子代理路径(反向:chunk 2 仅携带 originatorClientIdpromptId 保留)。

[rc:3624409621] 缺少桥接层 replayDegraded 集成测试(建议 — 已实现)

发现: 没有桥接层测试验证压缩失败导致 loadSession 携带 replayDegraded: true 的路径。未来对 buildReplayResponse 的重构可能会移除该字段而不会有测试捕获。

决定: 已实现。添加了桥接层集成测试。

变更:

  • packages/acp-bridge/src/bridge.test.ts:新增测试 'surfaces replayDegraded on loadSession when compaction fails'——通过 spawnOrAttach 创建会话,spy TurnBoundaryCompactionEngine.prototype.ingest 使其抛出异常,通过 sendPrompt 触发事件发布,恢复 spy,然后对同一会话调用 loadSession 并断言响应中包含 replayDegraded: true

验证结果

  • npm run build
  • npm run typecheck
  • npm run lint
  • npx vitest run src/compactionEngine.test.ts — 66 个测试通过 ✅
  • npx vitest run src/eventBus.test.ts — 49 个测试通过 ✅
  • npx vitest run src/bridge.test.ts — 429 个测试通过 ✅

Base-conflict check: no conflict with main.

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human.


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

✅ Local runtime verification — LGTM, recommend merge

I built this PR's head into a real dist and drove a real qwen serve daemon (mock OpenAI backend, isolated HOME) to verify all three fixes end-to-end on the wire, then added mutation "teeth" and re-ran every changed test file. Everything checks out.

Setup: PR head 60f682cd7 → fresh npm install in an isolated worktree → daemon launched as node packages/cli/dist/index.js serve so @qwen-code/acp-bridge resolves from the compiled PR code in packages/acp-bridge/dist (not a stale bundle). SDK driven from source. Node 22.22.2 + Bun 1.3.14.

verification report card


DAEMON-001 — stale SSE cursor across a daemon restart (headline)

I constructed the exact case the numeric heuristic misses: epoch‑1 yields an old cursor Last-Event-ID=6; after a restart the fresh bus's high‑water climbs to 14, so lastEventId >= nextId6 >= 14false — a dead-epoch cursor that looks like a perfectly valid suffix resume. Then I subscribed to GET /session/:id/events three ways on the same post-restart daemon, all with the identical stale cursor Last-Event-ID: 6:

Arm X-Qwen-Event-Epoch first SSE frame result
A · fix (old epoch) d2f56a15…5863d1 state_resync_required detail=epoch_mismatch ✅ full replay id 4→14
B · legacy (no header) session_update id=7 ❌ silent stale resume — the bug
C · control (current epoch) dbad093b…0ff6 session_update id=7 ✅ correct suffix resume (no false-trip)
  • Daemon also emits the operator breadcrumb on stderr: … reason=epoch_reset, detail=epoch_mismatch.
  • Mutation teeth: neutering the epoch check in the compiled bus (epochMismatch=false) + restart flips Arm A back to 0 resync frames (resumes at id=7) — i.e. the pre-PR bug is reproduced, confirming the epoch token is exactly what's load-bearing.
  • Advertised on every cursor surface: the 202 prompt envelope, the load/resume response body, and the REST SSE response header all carry the same eventEpoch, regenerated (randomUUID(), never persisted) on each restart; replayDegraded is correctly absent on healthy sessions. The /acp SSE surface uses the same shared parseEventEpochHeader + bus and is covered by the passing transport unit suites.

SDK end-to-end (real RestSseTransport imported from source; identical PASS on Node + Bun): subscribe({lastEventId, epoch: OLD}) forces the resync (echo path → sends the header), and subscribe({lastEventId}) learns the daemon's current epoch via onEpoch (learn path → reads the response header).

raw console evidence

DAEMON-007 — turn attribution survives compaction

Drove the compiled TurnBoundaryCompactionEngine directly (10/10): merged agent_message_chunk/agent_thought_chunk keep top-level promptId/originatorClientId and data.sessionId; the field-level merge keeps both fields when chunks carry them one at a time; folded tool_call uses latest-wins attribution; and a control with unattributed source events produces no spurious keys. Mutation (drop the re-stamp) → the 4 attribution checks fail.

DAEMON-008 — degraded snapshot is visible

Drove the compiled EventBus with a throwing compaction engine (7/7): the first ingest throw fires onCompactionError exactly once, the snapshot latches degraded:true, publish() still never throws, and a healthy engine never degrades (no false positive). Mutation (no-op the latch) → the 2 degraded checks fail (and the callback then fires on every throw, confirming the once-only latch guard).

Unit suites — all 12 changed test files (PR worktree, real npm install)

package changed files result
@qwen-code/acp-bridge eventBus · compactionEngine · bridge 544 passed
@qwen-code/sdk-typescript RestSse · AcpHttp · DaemonClient · DaemonSessionClient 423 passed
@qwen-code/qwen-code (cli serve) transport · server · sse-last-event-id · multi-workspace · create-sub-session 1180 passed

2147 passed / 0 failed.

Verdict

All three fixes behave exactly as described; backward-compat holds in both directions (a legacy no-epoch client keeps today's numeric heuristic — Arm B; the epoch check never false-trips when it matches — Arm C); and the runtime observations have mutation teeth. LGTM — recommend merge.

Verified with a real daemon + mock OpenAI on an isolated HOME; screenshots are renders of the actual captured SSE frames / probe output.

🇨🇳 中文版

✅ 本地真实构建验证 —— LGTM,建议合并

我把本 PR head 构建成真实 dist,用真实的 qwen serve daemon(mock OpenAI 后端、隔离 HOME)在网络层端到端验证了三处修复,并做了变异(mutation)测试确认"咬合力",最后重跑了全部改动的测试文件。全部通过。

环境: PR head 60f682cd7 → 隔离 worktree 里全新 npm install → daemon 以 node packages/cli/dist/index.js serve 启动,使得 @qwen-code/acp-bridge 解析到 packages/acp-bridge/dist编译后的 PR 代码(而非过期 bundle)。SDK 直接跑源码。Node 22.22.2 + Bun 1.3.14。

DAEMON-001 —— daemon 重启后过期 SSE 游标(核心)

我构造了数字启发式会漏掉的场景:纪元 1 得到旧游标 Last-Event-ID=6;重启后新总线高水位涨到 14,于是 lastEventId >= nextId6 >= 14false——一个来自死纪元、却看起来像合法后缀续传的游标。随后在同一个重启后的 daemon 上,用完全相同的旧游标 Last-Event-ID: 6 以三种方式订阅 GET /session/:id/events

Arm X-Qwen-Event-Epoch 首个 SSE 帧 结果
A · 修复(旧 epoch) d2f56a15…5863d1 state_resync_required detail=epoch_mismatch ✅ 全量重放 id 4→14
B · 旧客户端(无该头) session_update id=7 ❌ 静默过期续传 —— 即 bug
C · 对照(当前 epoch) dbad093b…0ff6 session_update id=7 ✅ 正确后缀续传(不误触发)
  • daemon 同时在 stderr 打运维面包屑:… reason=epoch_reset, detail=epoch_mismatch
  • 变异测试: 在编译后的总线里把 epoch 检查废掉(epochMismatch=false)+ 重启,Arm A 退回0 个 resync 帧(从 id=7 续传)—— 复现了 PR 前的 bug,证明 epoch token 正是起作用的关键。
  • 所有带游标的通道都下发: 202 prompt envelope、load/resume 响应体、REST SSE 响应头都携带同一个 eventEpoch,每次重启用 randomUUID() 重新生成、从不持久化;健康会话上 replayDegraded 正确缺省。/acp SSE 通道复用同一个 parseEventEpochHeader 与总线,由通过的 transport 单测覆盖。

SDK 端到端(从源码引入真实 RestSseTransport;Node 与 Bun 结果一致 PASS):subscribe({lastEventId, epoch: OLD}) 触发 resync(echo 路径 → 发送该头),subscribe({lastEventId})onEpoch 学到 daemon 当前 epoch(learn 路径 → 读响应头)。

DAEMON-007 —— 归属跨压缩存活

直接驱动编译后的 TurnBoundaryCompactionEngine10/10):合并后的 agent_message_chunk/agent_thought_chunk 保留顶层 promptId/originatorClientId data.sessionId;字段级合并保证 chunk 分别只带一个字段时两者都不丢;折叠的 tool_call 采用 latest-wins;对照组(源事件无归属)不产生多余字段。变异(去掉重新盖章)→ 4 项归属检查失败。

DAEMON-008 —— 降级快照可见

用会抛错的压缩引擎驱动编译后的 EventBus7/7):首次 ingest 抛错时 onCompactionError 恰好触发一次,快照锁存 degraded:truepublish() 仍永不抛错;健康引擎从不降级(无误报)。变异(把锁存改成空操作)→ 2 项降级检查失败(且回调会在每次抛错时都触发,反证一次性锁存的作用)。

单测套件 —— 全部 12 个改动测试文件(PR worktree,真实 npm install

改动文件 结果
@qwen-code/acp-bridge eventBus · compactionEngine · bridge 544 passed
@qwen-code/sdk-typescript RestSse · AcpHttp · DaemonClient · DaemonSessionClient 423 passed
@qwen-code/qwen-code(cli serve) transport · server · sse-last-event-id · multi-workspace · create-sub-session 1180 passed

2147 passed / 0 failed。

结论

三处修复行为与描述完全一致;双向向后兼容成立(旧的无 epoch 客户端保持现有数字启发式 —— Arm B;epoch 匹配时绝不误触发 —— Arm C);运行时观测均具备变异咬合力。LGTM —— 建议合并。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix could not start evaluation — it crashed or timed out before reading the feedback, so no fix was attempted. This PR is now marked terminal and future scans (including forced dispatch) will skip it. To recover: delete this bot's terminal autofix-eval marker comment, then re-trigger if the failure looked transient.

AutoFix failed before producing a verified commit (the run crashed or timed out before it could explain why).

Run log: https://github.com/QwenLM/qwen-code/actions/runs/29889165690


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

⏸️ Takeover paused: this PR reached its round cap (100/100). Comment @qwen-code /takeover to re-arm a fresh window and continue management, or @qwen-code /takeover stop to release.

中文说明

⏸️ 托管已暂停:本 PR 达到轮次上限(100/100)。评论 @qwen-code /takeover 可重新武装、开启新窗口继续托管;或评论 @qwen-code /takeover stop 释放。

wenshao
wenshao previously approved these changes Jul 22, 2026
@wenshao

wenshao commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

…outing

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

Copy link
Copy Markdown
Collaborator

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

Merge Resolution Summary — PR #7458

Root cause

The base branch (main) gained virtual subagent session routing (the VirtualSubagentSessions module and the virtualKey ? subscribe-virtual : subscribe-bridge ternary in the SSE route) while PR #7458 added epoch-token stale-cursor detection (parseEventEpochHeader + epoch option on subscribeEvents). Both modified the same subscribeEvents call site in packages/cli/src/serve/routes/sse-events.ts.

Textual or semantic

Textual. The two features touched adjacent lines of the same call expression but did not alter the same logic path. The resolution is a clean composition: the ternary dispatch from main wraps the subscribe call, and the epoch option from the PR is added only to the runtime.bridge.subscribeEvents arm (the non-virtual path). Virtual subagent sessions use their own EventBus and do not participate in daemon-epoch tracking, so the epoch parameter is intentionally omitted from the virtual-subagent subscribe call.

Resolved block:

const iterable = virtualKey
  ? await deps.virtualSubagentSessions?.subscribe(runtime, sessionId, {
      signal: abort.signal,
      lastEventId,
      ...(maxQueued !== undefined ? { maxQueued } : {}),
    })
  : runtime.bridge.subscribeEvents(sessionId, {
      signal: abort.signal,
      lastEventId,
      ...(eventEpoch !== undefined ? { epoch: eventEpoch } : {}),
      ...(maxQueued !== undefined ? { maxQueued } : {}),
      ...(snapshot ? { snapshot: true } : {}),
    });
if (!iterable) {
  res.status(404).json({ error: 'Subagent session not found', code: 'session_not_found', sessionId });
  return;
}

What is load-bearing

  • The epoch option must only be passed to the bridge path, not the virtual-subagent path. The virtual-subagent subscribe signature is { signal, lastEventId?, maxQueued? } — it has no epoch parameter.
  • The route handler must remain async (main changed it from sync to async for await deps.virtualSubagentSessions?.subscribe).
  • The virtualKey?.parentSessionId ?? sessionId resolution for requireSessionRuntime must stay — virtual sessions are looked up via the parent session's runtime.

What you could not verify

  • getSessionEventEpoch(sessionId) for virtual sessions. This line (auto-merged, outside conflict markers) calls runtime.bridge.getSessionEventEpoch(sessionId) which throws SessionNotFoundError when sessionId is a virtual subagent ID (e.g. subagent.xxx.yyy), since the bridge only tracks real sessions. The enclosing try/catch would catch this and send an error response, breaking virtual-subagent SSE streams. This needs a guard like virtualKey ? undefined : runtime.bridge.getSessionEventEpoch(sessionId) — but it is outside the conflict zone and I must not fix it here.
  • No build or tests were run per the merge-only contract.
中文说明

合并解决摘要 — PR #7458

根因

基线分支 (main) 新增了虚拟子代理会话路由(VirtualSubagentSessions 模块和 SSE 路由中的 virtualKey 三元分支),而 PR #7458 新增了 epoch 令牌过期游标检测(parseEventEpochHeader + subscribeEventsepoch 选项)。两者都修改了 packages/cli/src/serve/routes/sse-events.ts 中的同一个 subscribeEvents 调用点。

文本冲突还是语义冲突

文本冲突。 两个功能修改了同一调用表达式的相邻行,但没有修改同一逻辑路径。解决方案是干净地组合:main 的三元分发包裹 subscribe 调用,PR 的 epoch 选项仅添加到 runtime.bridge.subscribeEvents 分支(非虚拟路径)。虚拟子代理会话使用自己的 EventBus,不参与 daemon epoch 跟踪,因此虚拟子代理的 subscribe 调用中故意省略了 epoch 参数。

关键约束

  • epoch 选项必须仅传递给 bridge 路径,而非虚拟子代理路径。
  • 路由处理程序必须保持 async(main 将其从同步改为异步)。
  • virtualKey?.parentSessionId ?? sessionId 的运行时解析必须保留。

无法验证的内容

  • 虚拟会话的 getSessionEventEpoch(sessionId) 调用。 此行(自动合并,不在冲突标记内)对虚拟子代理 ID 会抛出 SessionNotFoundError,因为 bridge 只跟踪真实会话。需要添加保护条件,但这不在冲突区域内,此处不能修复。
  • 按照仅合并的约定,未运行构建或测试。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not reviewed: reverse audit — an auditor ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and what the agent was actually asked is not what this skill certifies.

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/serve/routes/sse-events.ts Outdated
Comment thread packages/sdk-typescript/src/daemon/DaemonClient.ts
Comment thread packages/sdk-typescript/src/daemon/DaemonSessionClient.ts Outdated
Comment thread packages/cli/src/serve/acp-http/transport.test.ts
Await each session/load reply frame before opening the session stream so
the GET cannot race conn.ownSession() into a 403; addresses the review
Critical on the deg-0 arm.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point:

No action required

All review findings on PR #7458 have already been addressed in commits 3bab15bfb and c0372951b. No further changes are needed.

Finding triage

# Finding Type Status
rc:3629219670 Degraded-snapshot stderr breadcrumb has no dispatch-level test Suggestion ✅ Fixed in 3bab15bfb — transport test covers both healthy (no breadcrumb) and degraded (breadcrumb fires, names session) paths
rc:3629219675 replayDegraded declared on DaemonRestoredSession but never exposed by DaemonSessionClient Suggestion ✅ Fixed in 3bab15bfbDaemonSessionClient now has readonly replayDegraded: boolean (default false), destructured from load response like historyHasMore
rc:3629219680 POST /session/:id/continue missing eventEpoch in 202 envelope Suggestion ✅ Fixed in 3bab15bfb — bridge continueSession captures liveEntry.events.epoch and returns eventEpoch; route forwards the bridge result directly via res.json(await bridge.continueSession(...))
rc:3630137724 Degraded-breadcrumb test races conn.ownSession() → 403 → timeout Critical ✅ Fixed in c0372951b — uses frameReader + await replies.next() to wait for each session/load reply before opening that session's stream, eliminating the race
中文说明

无需操作

PR #7458 上的所有评审意见已在提交 3bab15bfbc0372951b 中解决,无需进一步修改。

意见分类

# 意见 类型 状态
rc:3629219670 降级快照 stderr 面包屑日志缺少 dispatch 级别测试 建议 ✅ 已在 3bab15bfb 中修复 — transport 测试覆盖了正常(无面包屑)和降级(面包屑触发并包含会话名)两种路径
rc:3629219675 replayDegradedDaemonRestoredSession 上声明但 DaemonSessionClient 未暴露 建议 ✅ 已在 3bab15bfb 中修复 — DaemonSessionClient 新增 readonly replayDegraded: boolean(默认 false),从 load 响应中解构,与 historyHasMore 模式一致
rc:3629219680 POST /session/:id/continue 的 202 响应缺少 eventEpoch 建议 ✅ 已在 3bab15bfb 中修复 — bridge 的 continueSession 捕获 liveEntry.events.epoch 并返回 eventEpoch;路由通过 res.json(await bridge.continueSession(...)) 直接透传
rc:3630137724 降级面包屑测试与 conn.ownSession() 存在竞态 → 403 → 超时 严重 ✅ 已在 c0372951b 中修复 — 使用 frameReader + await replies.next() 等待每个 session/load 回复后再打开对应会话的流,消除竞态

Base-conflict check: no conflict with main.


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@wenshao

wenshao commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Code review — DAEMON-001 / 007 / 008 (epoch token, attribution, degraded replay)

Reviewed the full diff (30 files, +1710/-30). This is a clean, well-scoped reliability PR. No blocking issues found — the analysis below is verification notes plus a few optional observations.

What it does

Three independent daemon-replay hardenings, each gated so it's additive and bidirectionally backward-compatible:

  1. DAEMON-001 — every EventBus mints a randomUUID() epoch; it travels with every client-facing cursor surface (load/resume/create, 202 prompt envelope, continueSession, and an X-Qwen-Event-Epoch header on both SSE surfaces). A mismatched epoch on reconnect forces the existing epoch_reset resync deterministically, closing the hole where the numeric lastEventId >= nextId heuristic is defeated once the new epoch's id count catches up with a stale cursor. detail: 'epoch_mismatch' discriminates the two triggers.
  2. DAEMON-007 — turn-boundary compaction re-stamps promptId/originatorClientId/data.sessionId onto merged text/thought events and folded tool_calls, so resync consumers keep prompt-correlation and originator-filtering after compaction.
  3. DAEMON-008 — the bus latches a degraded flag on the first ingest/seedReplayEvents throw, surfaces it as replayDegraded on load and via operator stderr breadcrumbs, while keeping publish()'s never-throws contract.

Correctness — verified

  • Epoch-mismatch replay is right. epochReset = epochMismatch || lastEventId >= nextId sits in the if (epochReset) {…} else {ring_evicted/seeded…} branch, so the frames are mutually exclusive, and replayFrom = epochReset ? 0 : lastEventId replays the whole fresh ring instead of filtering out the low ids — exactly what a dead-epoch cursor needs. The whole block is gated on lastEventId presence, and the "ignores a mismatching epoch when no lastEventId is presented" test locks that in.
  • DAEMON-007 is a genuine fix, not a no-op. Confirmed the real publish helpers stamp data: { sessionId, … }, top-level promptId, and top-level originatorClientId on live events (bridge.ts), so pre-PR compaction really did drop them and this restores them. The field-level ?? merge in captureTurnFields (vs. atomic replace) and the "present only if set" spreads mean nothing is invented when sources lack the fields — and the merged/live event shapes stay consistent.
  • Degradation latch is safe. Fires onCompactionError exactly once (guarded by the compactionDegraded early-return), and the callback is wrapped in try/catch so a throwing diagnostics hook can't break publish(). Tests cover the throwing-callback path.
  • Every client-facing cursor surface carries the epoch. The one getSessionLastEventId caller that doesn't (create-sub-session.ts) is an in-process subscribe on the same live bus — epoch can't drift there, so omitting it is correct, not a gap.
  • Transports nest the request header under if (lastEventId !== undefined) (epoch is meaningless without a cursor), the WS transport documents why it ignores epoch, and DaemonSessionClient seeds from the response then lets the X-Qwen-Event-Epoch header supersede — all test-locked.

Backward compatibility

Genuinely bidirectional: old client → new daemon keeps the numeric heuristic (no header sent); new client → old daemon never learns an epoch and falls back the same way. New fields/headers are optional and additive; DaemonStateResyncRequiredData already carries an index signature, so detail pass-through is modeled.

Test coverage — excellent

Negative and boundary cases are all present: invalid epoch header degrades to "not provided" and logs without aborting the stream, 64/65-char boundary, no-cursor ignores epoch, healthy path stays undegraded, older-daemon-omits-epoch, header-learned supersedes seeded, and the virtual-subagent path skips the throwing getSessionEventEpoch lookup.

Security

parseEventEpochHeader bounds the token to [A-Za-z0-9_-]{1,64} and safeLogValues rejects, so the value echoed into stderr can't inject. The epoch is a non-secret identity token, and the worst outcome of any mismatch (attacker-supplied or otherwise) is a forced resync — the same recovery path ring-eviction already uses. No timing/authz concern.

Optional observations (non-blocking)

  • Degradation is permanently latched. A single transient compaction throw marks the session degraded for the life of the bus even if every later ingest succeeds, so clients keep preferring a full-transcript reload. This is the conservative/safe choice and is documented ("prefer the full transcript"), but worth calling out as an intentional tradeoff rather than an oversight.
  • /acp degraded breadcrumb fires per initial-replay-serve (i.e. can repeat across reconnects to a degraded session), whereas the bus-level onCompactionError breadcrumb fires once. Bounded and operator-useful, but if log volume is a concern you could latch the /acp-side log per session too.
  • Two breadcrumb wordings for related conditionscompaction degraded for session=… (bus-level) vs /acp initial replay used a DEGRADED snapshot … session=…. Fine given the distinct trigger points; a shared substring would help operators grep both at once.
  • Nit: the 202-prompt path calls getSessionEventEpoch without the try/catch the two SSE surfaces use. No new failure mode in practice (it's synchronous, immediately after getSessionLastEventId, which would throw first), so no change needed — just noting the asymmetry.

Nice work — the comment density and DAEMON-xxx traceability match the surrounding code, and the test matrix is thorough.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/serve/routes/sse-events.ts
Cross-origin SSE clients must send the epoch header through preflight and
read it from the response, or stale-cursor detection (DAEMON-001) is
silently disabled for every CORS client.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point:

Review feedback assessment — PR #7458

No changes made this round. All feedback has been triaged below.

Inline suggestion: CORS headers for X-Qwen-Event-Epoch (rc:3630528262)

Already implemented in commit c1f7e79fe (prior round). X-Qwen-Event-Epoch is present in both Access-Control-Allow-Headers and Access-Control-Expose-Headers in allowOriginCors (auth.ts), with four updated CORS assertions across auth.test.ts and server.test.ts. No further action needed.

Maintainer review observations (@wenshao) — all explicitly non-blocking

  1. Permanent degradation latch — Declined. The reviewer confirms this is "the conservative/safe choice" and "an intentional tradeoff rather than an oversight." A single compaction failure marks the session degraded for the bus lifetime so clients prefer a full-transcript reload. Relaxing the latch would weaken the safety guarantee DAEMON-008 provides.

  2. /acp degraded breadcrumb fires per reconnect — Declined. The reviewer notes it is "bounded and operator-useful." Repetition is bounded by reconnect count and gives operators visibility into each reconnect against a degraded session. Adding a per-session latch for the /acp-side log would add state tracking for a cosmetic log-volume concern — not worth the diff growth.

  3. Two breadcrumb wordings for related conditions — Declined. The reviewer says "Fine given the distinct trigger points." The bus-level breadcrumb (bridge.ts) fires on the first compaction throw; the /acp-level breadcrumb (dispatch.ts) fires when a degraded snapshot is served. Different trigger points justify different wordings. A shared grep substring would be a minor convenience but not worth a change.

  4. 202-prompt path lacks try/catch around getSessionEventEpoch — Declined. The reviewer confirms "No new failure mode in practice" — the call is synchronous and immediately follows getSessionLastEventId, which would throw first on the same missing-session path. Adding a redundant try/catch would be dead error handling.

中文说明

审查反馈评估 — PR #7458

本轮未做任何更改。所有反馈已在下方分类处理。

内联建议:X-Qwen-Event-Epoch 的 CORS 头 (rc:3630528262)

已在先前轮次实现,见提交 c1f7e79feX-Qwen-Event-Epoch 已同时出现在 allowOriginCorsauth.ts)的 Access-Control-Allow-HeadersAccess-Control-Expose-Headers 中,并在 auth.test.tsserver.test.ts 中更新了四项 CORS 断言。无需进一步操作。

维护者审查意见(@wenshao)— 均明确标注为非阻塞

  1. 永久降级锁存 — 拒绝修改。审查者确认这是"保守/安全的选择"和"有意为之的权衡,而非疏忽"。一次压缩失败即会在总线生命周期内将会话标记为降级,使客户端优先选择完整转录重载。放宽锁存会削弱 DAEMON-008 提供的安全保障。

  2. /acp 降级面包屑在每次重连时触发 — 拒绝修改。审查者指出其"有界且对运维有用"。重复次数受重连次数限制,能让运维人员看到每次针对降级会话的重连。为 /acp 侧日志添加按会话锁存会增加状态跟踪,仅为了日志量的美观问题——不值得增加 diff。

  3. 相关条件使用两种面包屑措辞 — 拒绝修改。审查者表示"鉴于触发点不同,这样没问题"。总线级面包屑(bridge.ts)在首次压缩抛出时触发;/acp 级面包屑(dispatch.ts)在提供降级快照时触发。不同触发点证明不同措辞是合理的。共享 grep 子串只是小便利,不值得修改。

  4. 202-prompt 路径的 getSessionEventEpoch 缺少 try/catch — 拒绝修改。审查者确认"实际上没有新的失败模式"——该调用是同步的,且紧跟在 getSessionLastEventId 之后,后者会在相同的会话缺失路径上先抛出异常。添加冗余的 try/catch 属于无效的错误处理。

Base-conflict check: no conflict with main.


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

…poch-compaction

# Conflicts:
#	packages/sdk-typescript/scripts/build.js
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 2/100). What changed, and what I pushed back on:

Review feedback

No actionable feedback was present in this round: the feedback file contained no reviews, no inline comments, no issue-level comments, no failed checks, and no still-red checks. There were therefore no findings to triage or implement, and resolved-comments.txt is empty.

Action taken: merged origin/main (conflict resolution)

--conflict true was set, so origin/main was merged into fix/daemon-recovery-epoch-compaction.

  • All files auto-merged cleanly except one.
  • Conflict: packages/sdk-typescript/scripts/build.js — a comment-only conflict in the daemon browser bundle-budget log. Both sides independently documented a 167KB → 168KB budget bump for different features (HEAD: the daemon event-bus epoch token fields eventEpoch / onEpoch; origin/main: workspace-level streaming generation). The budget constant MAX_DAEMON_BROWSER_BUNDLE_BYTES was identical (168 * 1024) on both sides, so only the rationale comment conflicted — not the constant or any code.
  • Resolution: kept both rationale comment lines so neither feature's budget justification is lost. No code or constant was changed.

The merge preserves all of this PR's epoch / replayDegraded / X-Qwen-Event-Epoch changes (verified present across the SDK daemon files: DaemonClient.ts, DaemonSessionClient.ts, DaemonTransport.ts, AcpHttpTransport.ts, types.ts) and combines them with main's workspace-level generation surface.

One merge commit was created: 55f4613fc Merge remote-tracking branch 'origin/main' into fix/daemon-recovery-epoch-compaction.

Verification

  • npm run build — passed (exit 0). The SDK build's bundle-size assertion confirms the merged daemon browser bundle still fits the 168KB budget with both features combined (the key risk of this merge).
  • npm run typecheck — passed (exit 0).
  • npm run lint — passed (exit 0).
  • vitest packages/acp-bridge (touched) — 916 passed (19 files).
  • vitest packages/sdk-typescript test/unit (touched) — 1413 passed (29 files).
  • vitest packages/cli serve tests (touched: acp-http/transport, auth, create-sub-session, multi-workspace-sessions, server, sse-last-event-id) — 1219 passed (6 files).
  • Integration tests after npm run bundle — not run: the only manual conflict was a comment in the SDK build script, and the merged behavioral surface is fully covered by the package unit suites above (all green).
中文说明

评审反馈

本轮没有可处理的反馈:反馈文件中没有评审意见、没有行内评论、没有 issue 级评论、没有失败的检查,也没有持续飘红的检查。因此没有需要分类或实现的发现,resolved-comments.txt 为空。

采取的操作:合并 origin/main(冲突解决)

由于设置了 --conflict true,已将 origin/main 合并进 fix/daemon-recovery-epoch-compaction

  • 除一个文件外,其余文件均自动合并成功。
  • 冲突: packages/sdk-typescript/scripts/build.js —— 这是 daemon 浏览器 bundle 体积预算日志中的一处纯注释冲突。两侧各自独立记录了一次 167KB → 168KB 的预算上调,分别对应不同的功能(HEAD:daemon 事件总线 epoch token 字段 eventEpoch / onEpochorigin/main:workspace 级流式生成)。预算常量 MAX_DAEMON_BROWSER_BUNDLE_BYTES 在两侧完全相同(均为 168 * 1024),因此发生冲突的只是说明性注释,而非常量或任何代码。
  • 解决方式: 保留了两条说明性注释,以免丢失任一功能的预算理由。未改动任何代码或常量。

本次合并完整保留了本 PR 的 epoch / replayDegraded / X-Qwen-Event-Epoch 相关改动(已确认这些改动仍存在于 SDK daemon 各文件中:DaemonClient.tsDaemonSessionClient.tsDaemonTransport.tsAcpHttpTransport.tstypes.ts),并与 main 上的 workspace 级流式生成功能合并在一起。

创建了一个合并提交:55f4613fc Merge remote-tracking branch 'origin/main' into fix/daemon-recovery-epoch-compaction

验证

  • npm run build —— 通过(退出码 0)。SDK 构建中的 bundle 体积断言确认:合并后的 daemon 浏览器 bundle 在同时包含两个功能的情况下仍在 168KB 预算之内(这是本次合并的关键风险点)。
  • npm run typecheck —— 通过(退出码 0)。
  • npm run lint —— 通过(退出码 0)。
  • vitest packages/acp-bridge(受影响)—— 916 通过(19 个文件)。
  • vitest packages/sdk-typescript test/unit(受影响)—— 1413 通过(29 个文件)。
  • vitest packages/cli serve 测试(受影响:acp-http/transportauthcreate-sub-sessionmulti-workspace-sessionsserversse-last-event-id)—— 1219 通过(6 个文件)。
  • npm run bundle 之后的集成测试 —— 未运行:唯一的手动冲突只是 SDK 构建脚本中的一处注释,而合并后的行为面已被上述各包的单元测试套件完整覆盖(全部为绿)。

Base-conflict check: conflicted with main — resolved in this push.

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human.


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

🔬 Maintainer E2E Verification Report

Branch: fix/daemon-recovery-epoch-compaction @ 55f4613
Environment: macOS (darwin), Node.js >=22, local build from source
Test artifacts: wenshao/qwen-code@e2e/pr-7458-epoch-test-report


1. Build & Typecheck

Check Result
npm run build ✅ clean
npm run typecheck ✅ clean
npm run bundle ✅ clean

2. Unit Tests (all three affected packages)

Package Files Tests Status
packages/acp-bridge 19 895 passed
packages/sdk-typescript 31 1403 passed
packages/cli (serve suites) 6 1199 passed, 1 timeout ✅*

* The single timeout (rejects invalid export format) is a pre-existing flake on main — confirmed not in this PR's diff (gh pr diff 7458 shows no changes to that test).

Unit test results

3. E2E Real Daemon Tests (9/9 passed)

Started a real qwen serve daemon on port 14170 and verified the full epoch lifecycle via curl:

# Test Result
1 POST /session/:id/load response carries eventEpoch
2 GET /session/:id/events SSE stream returns X-Qwen-Event-Epoch header
3 Matching epoch + Last-Event-ID → normal suffix resume (no resync)
4 Stale epoch + Last-Event-IDstate_resync_required with detail: "epoch_mismatch"
5 POST /session/:id/prompt 202 envelope carries eventEpoch
6 Daemon restart → load returns new eventEpoch (differs from pre-restart)
7 Reconnect with old epoch after restart → epoch_mismatch resync
8 SSE without epoch header → backward compatible (no error, no crash)
9 Epoch value is consistent across load / SSE header / prompt 202 within same daemon instance

Key evidence

Epoch advertised on every cursor surface:

Epoch advertised

Stale epoch deterministically forces resync (the core DAEMON-001 fix):

Epoch mismatch resync

Summary:

Summary

4. Verdict

All three DAEMON audit items (001 epoch-token restart detection, 007 compaction attribution, 008 degraded-snapshot signaling) are covered by the unit tests. The E2E run confirms the epoch token flows correctly through every client-facing surface (load, SSE header, prompt 202) and that a stale epoch deterministically forces a full resync — including across a real daemon restart. Backward compatibility is preserved (no epoch header → legacy numeric heuristic).

Recommendation: ready to merge


中文版本

🔬 维护者 E2E 验证报告

分支: fix/daemon-recovery-epoch-compaction @ 55f4613
环境: macOS (darwin),Node.js >=22,本地源码构建
测试产物: wenshao/qwen-code@e2e/pr-7458-epoch-test-report

1. 构建与类型检查

npm run build ✅ | npm run typecheck ✅ | npm run bundle

2. 单元测试(三个受影响包)

文件数 测试数 状态
packages/acp-bridge 19 895 通过
packages/sdk-typescript 31 1403 通过
packages/cli(serve 套件) 6 1199 通过,1 超时 ✅*

* 唯一超时的测试(rejects invalid export format)是 main 上已有的 flake,已确认不在本 PR diff 中。

3. E2E 真实 Daemon 测试(9/9 通过)

在端口 14170 启动真实 qwen serve daemon,通过 curl 验证完整 epoch 生命周期:

# 测试项 结果
1 POST /session/:id/load 响应携带 eventEpoch
2 GET /session/:id/events SSE 流返回 X-Qwen-Event-Epoch
3 匹配 epoch + Last-Event-ID → 正常后缀续传(无 resync)
4 过期 epoch + Last-Event-IDstate_resync_requireddetail: "epoch_mismatch"
5 POST /session/:id/prompt 202 envelope 携带 eventEpoch
6 重启 daemon → load 返回 eventEpoch(与重启前不同)
7 重启后用 epoch 重连 → epoch_mismatch resync
8 不带 epoch 头的 SSE → 向后兼容(无报错、无崩溃)
9 同一 daemon 实例内 load / SSE 头 / prompt 202 的 epoch 值一致

4. 结论

三个 DAEMON 审计项(001 epoch-token 重启检测、007 压缩归属保留、008 降级快照信号)均有单测覆盖。E2E 运行确认 epoch token 正确流经所有客户端可见通道(load、SSE 头、prompt 202),且过期 epoch 确定性地强制全量 resync——包括真实 daemon 重启场景。向后兼容得到保持(不带 epoch 头 → 回落到数字启发式)。

建议:可以合入

@wenshao
wenshao added this pull request to the merge queue Jul 23, 2026
Merged via the queue into QwenLM:main with commit 947b632 Jul 23, 2026
54 checks passed
@wenshao

wenshao commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

✅ Local runtime verification (round 2, current head) — LGTM, recommend merge

Re-verified this PR against the current head 55f4613 (the merge-with-main + autofix state, newer than my earlier LGTM which ran against 60f682cd7). Built the PR into a real dist and drove all three fixes through the real code: a live qwen serve route over real HTTP for the headline, and the compiled packages/acp-bridge/dist artifacts for the rest. Everything checks out, including the virtual-subagent REST-SSE regression I flagged as High earlier — it is now fixed and locked in by a shipped test.

Setup: PR head 55f4613 → fresh npm install in an isolated worktree → npm run build (incl. tsc --build) clean → tests resolve the compiled acp-bridge dist (not source). Node 22.23.1.

verification report card


DAEMON-001 — stale SSE cursor across a daemon restart (headline)

Drove the real runQwenServe /session/:id/events route + the compiled EventBus over real fetch(), plus a direct compiled-dist EventBus.subscribe drive for the raw wire frames. Modeled a daemon restart as an EventBus rebuild (fresh epoch, ids reset to 1) and let the fresh epoch's id count climb past the stale cursor — the exact case the numeric heuristic misses: cursor 6, fresh high-water 15, so lastEventId >= nextId6 >= 15false. Three arms, same stale cursor Last-Event-ID: 6, same post-restart bus:

Arm X-Qwen-Event-Epoch first frame result
A · fix (old epoch) 6d32af4f… state_resync_required reason=epoch_reset detail=epoch_mismatch ✅ forced resync + full replay from id 1
B · legacy (no header) session_update id=7 ❌ silent stale resume onto dead-epoch state — the bug
C · control (current epoch) a4ce6ca6… session_update id=7 ✅ epoch matches → no false-trip
  • The daemon emits the operator breadcrumb on stderr: … reason=epoch_reset, detail=epoch_mismatch. Consumer must call loadSession to recover.
  • The X-Qwen-Event-Epoch response header carries the current (fresh) epoch on every arm — the SDK's "learn" path.
  • Mutation teeth: Arm A ↔ Arm B is the load-bearing A/B — identical stale cursor and bus, the only difference is presence of the epoch header, and it flips resync on/off. The epoch token is exactly what closes the hole.

DAEMON-007 — turn attribution survives compaction

Drove the compiled TurnBoundaryCompactionEngine (5/5): merged agent_message_chunk/agent_thought_chunk keep top-level promptId/originatorClientId and data.sessionId; the merge is field-level (an earlier originatorClientId survives a later chunk that carries only promptId); folded tool_call uses latest-wins; and a control with unattributed sources produces no spurious attribution keys (nothing invented).

DAEMON-008 — degraded snapshot is visible

Drove the compiled EventBus with a throwing compaction engine (4/4): the first ingest/seedReplayEvents throw latches degraded:true and fires onCompactionError exactly once; publish() keeps its never-throws contract even when the diagnostics callback itself throws; a healthy engine never degrades (no false positive).

Regression fixed — virtual-subagent REST SSE (my earlier High finding)

GET /session/<virtual-subagent-id>/events no longer aborts when getSessionEventEpoch throws for a compound id — the route now skips the lookup on the !virtualKey path (with a try/catch fallback for real sessions). The shipped regression test that starts a real daemon and asserts the stream still opens (200 + frames) is green.

raw wire evidence

Changed-suite totals — fresh real build

package changed test files result
@qwen-code/acp-bridge eventBus · compactionEngine · bridge 552 passed
@qwen-code/qwen-code (cli serve) server · sse-last-event-id · auth · create-sub-session · multi-workspace · acp-http/transport 1220 passed
@qwen-code/sdk (typescript) RestSse · AcpHttp · DaemonClient · DaemonSessionClient 428 passed

2200 passed / 0 failed. (1220 includes the one live-HTTP DAEMON-001 E2E I added locally for this verification — it is not part of the PR diff.)

Verdict

All three fixes behave exactly as described on the current head; backward-compat holds in both directions (Arm B legacy path unchanged, Arm C never false-trips); the headline has mutation teeth; and the previously-flagged virtual-subagent regression is fixed and test-locked. LGTM — recommend merge.

Verified with the real daemon route + the compiled dist artifacts on an isolated worktree; screenshots are renders of the actual captured SSE frames / stderr breadcrumb / drive output.

🇨🇳 中文版

✅ 本地真实构建验证(第 2 轮,当前 head)—— LGTM,建议合并

针对当前 head 55f4613(merge main + autofix 之后的状态,比我上一次针对 60f682cd7 的 LGTM 更新)重新验证。把本 PR 构建成真实 dist,用真实代码驱动三处修复:核心项用真实 qwen serve 路由 + 真实 HTTP,其余项用编译后的 packages/acp-bridge/dist 产物。全部通过,包括我之前标为 High 的 virtual-subagent REST-SSE 回归——现已修复并被一条已合入的测试锁定。

环境: PR head 55f4613 → 隔离 worktree 全新 npm installnpm run build(含 tsc --build)干净 → 测试解析到编译后的 acp-bridge dist(非源码)。Node 22.23.1。

DAEMON-001 —— daemon 重启后过期 SSE 游标(核心)

真实 runQwenServe/session/:id/events 路由 + 编译后的 EventBus,经真实 fetch() 驱动;另用直接驱动编译 distEventBus.subscribe 抓取原始 wire 帧。把 daemon 重启建模为 EventBus 重建(新 epoch、id 归 1),并让新纪元的 id 数超过旧游标——正是数字启发式漏掉的场景:游标 6、新高水位 15,故 lastEventId >= nextId6 >= 15false。同一旧游标 Last-Event-ID: 6、同一重启后总线,三种订阅方式:

Arm X-Qwen-Event-Epoch 首帧 结果
A · 修复(旧 epoch) 6d32af4f… state_resync_required reason=epoch_reset detail=epoch_mismatch ✅ 强制 resync + 从 id 1 全量重放
B · 旧客户端(无该头) session_update id=7 ❌ 静默过期续传到死纪元状态 —— 即 bug
C · 对照(当前 epoch) a4ce6ca6… session_update id=7 ✅ epoch 一致 → 不误触发
  • daemon 在 stderr 打运维面包屑:… reason=epoch_reset, detail=epoch_mismatch. Consumer must call loadSession to recover.
  • 每个 arm 的 X-Qwen-Event-Epoch 响应头都携带当前(新)epoch —— 即 SDK 的 "learn" 路径。
  • 变异咬合: Arm A ↔ Arm B 就是关键 A/B —— 游标与总线完全相同,唯一差别是有无 epoch 头,就此翻转 resync 的开关。epoch token 正是补上漏洞的那一环。

DAEMON-007 —— 归属跨压缩存活

驱动编译后的 TurnBoundaryCompactionEngine5/5):合并的 agent_message_chunk/agent_thought_chunk 保留顶层 promptId/originatorClientId data.sessionId;合并是字段级的(较早的 originatorClientId 在只带 promptId 的后续 chunk 下仍存活);折叠的 tool_call 采用 latest-wins;对照组(源事件无归属)不产生多余归属字段(不凭空捏造)。

DAEMON-008 —— 降级快照可见

用会抛错的压缩引擎驱动编译后的 EventBus4/4):首次 ingest/seedReplayEvents 抛错即锁存 degraded:true恰好一次触发 onCompactionErrorpublish() 维持永不抛错契约(即便诊断回调本身抛错);健康引擎从不降级(无误报)。

回归已修 —— virtual-subagent REST SSE(我此前的 High 项)

GET /session/<virtual-subagent-id>/events 不再因 getSessionEventEpoch 对复合 id 抛错而中断——路由在 !virtualKey 分支跳过该查询(真实会话用 try/catch 兜底)。启动真实 daemon 并断言流仍能打开(200 + 帧)的那条已合入回归测试为绿

改动测试套件总计 —— 全新真实构建

改动测试文件 结果
@qwen-code/acp-bridge eventBus · compactionEngine · bridge 552 通过
@qwen-code/qwen-code(cli serve) server · sse-last-event-id · auth · create-sub-session · multi-workspace · acp-http/transport 1220 通过
@qwen-code/sdk(typescript) RestSse · AcpHttp · DaemonClient · DaemonSessionClient 428 通过

2200 通过 / 0 失败。1220 含我为本次验证本地新增的 1 条 DAEMON-001 实时 HTTP E2E,不属于 PR diff。)

结论

三处修复在当前 head 的行为与描述完全一致;双向向后兼容成立(Arm B 旧路径不变、Arm C 绝不误触发);核心项具备变异咬合;此前标记的 virtual-subagent 回归已修复并被测试锁定。LGTM —— 建议合并。

@wenshao

wenshao commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Code Review — fix(serve): epoch token + compaction attribution/degradation (#7458)

Review anchored at 55f4613fc。开始 review 时 PR 还是 OPEN,写完时已 MERGED —— 下面的问题按合入后 follow-up看待,不是拦截意见。

概述

三件事打包:(1) 每个 EventBus 构造时生成 randomUUID() epoch,随所有游标下发通道(load/resume 响应、202 envelope、continueSession、两个 SSE 通道的 X-Qwen-Event-Epoch 响应头)下发,客户端回传后 mismatch 直接强制走既有 resync 路径;(2) 压缩引擎在合并 text/thought、折叠 tool_call 时保留 promptId/originatorClientId/data.sessionId;(3) 压缩失败 latch 一个 degraded 标志,经 replayDegraded + 一条 stderr 面包屑透出。

方向和落点都对:epoch 触发直接复用了已有的 replayFrom = epochReset ? 0 : lastEventIdeventBus.ts),服务端改动面极小;双向兼容成立(旧客户端不发头 → 保持数字启发式;新客户端连旧 daemon → 学不到 epoch,同样回落)。

我在 worktree 里跑了改动涉及的全部套件,全绿:acp-bridge bridge 437 / eventBus+compactionEngine 115、cli server.test 780 / acp-http transport 277 / sse-last-event-id+auth+create-sub-session 72、sdk-typescript 4 个文件 428。

下面 6 点,其中 #1 是我实测复现的功能缺口。


🔴 1. SDK 侧 epoch 与 cursor 解耦,DAEMON-001 要消灭的场景仍可复现

DaemonSessionClient.iterateEventsDaemonSessionClient.ts:791)无条件更新 epoch:

onEpoch: (learned) => {
  this.lastSeenEpoch = learned;   // 响应头一到就更新
  callerOnEpoch?.(learned);
},

而游标只在收到带 id 的事件时才前进(this.lastSeenEventId = Math.max(this.lastSeenEventId ?? 0, event.id))。两者不再来自同一个纪元,而整个设计的前提恰恰是"epoch 必须和它铸造的那个游标绑在一起"。

复现路径:daemon 重启后客户端重连 → 响应头到达,lastSeenEpoch := E2 → 流在任何带 id 的帧到达前断开(0 个事件被消费)→ 游标仍是死纪元的 50。下次重连发出的是 (Last-Event-ID: 50, X-Qwen-Event-Epoch: E2)死纪元的游标配上活纪元的 token

服务端此时 epochMismatch === false,回落数字启发式;只要新总线已经发出 ≥ 51 个事件,50 >= nextId 为假 → 静默从 51 续传,跳过新纪元的 1..50,把增量叠在死纪元的 reducer 状态上 —— 就是 PR 描述里"数字启发式被追平后失效"的那个 bug。

两端我都写了 probe(不是 PR 里的测试,是我自己加的),都通过:

// SDK 侧:learned epoch 覆盖了 seeded epoch,但游标没动
// 第一次订阅:headers 带 x-qwen-event-epoch: E2-new,body 空(0 事件)
expect(calls[1]?.headers['last-event-id']).toBe('50');        // 死纪元游标
expect(calls[1]?.headers['x-qwen-event-epoch']).toBe('E2-new'); // 活纪元 token

// 服务端:这一对被当成合法后缀续传
const bus = new EventBus(200);
for (let i = 1; i <= 60; i++) bus.publish({ type: 'foo', data: i });
const iter = bus.subscribe({ lastEventId: 50, epoch: bus.epoch, signal });
// → 无 state_resync_required,直接重放 51..60

顺带一个相关点:Math.max 让游标永远不会回退,所以即使 epoch_reset 之后 daemon 全量重放了新纪元的 1..3,客户端跟踪的游标依然停在 50(我的第二个 probe 断言了这一条)。

建议:epoch 变了就意味着此前所有游标作废,在 onEpoch 里一并作废它 ——

onEpoch: (learned) => {
  if (this.lastSeenEpoch !== undefined && learned !== this.lastSeenEpoch) {
    // 纪元换了:此前的游标属于死纪元,0 = 从 ring 头重放
    this.lastSeenEventId = 0;
  }
  this.lastSeenEpoch = learned;
  callerOnEpoch?.(learned);
},

lastSeenEpoch === undefined(旧 daemon 播种、或首次订阅)时保持现状,不影响既有回放语义。

🟡 2. /acp 把「客户端给的 epoch」和「服务端重算的游标」配成一对

dispatch.tspumpSessionEvents:initial replay 分支会把游标覆盖成当前纪元的快照水位(:4139),但传给 bus 的 epoch:4164)仍是客户端请求头带来的那个:

subscribeFromEventId = Math.max(subscribeFromEventId ?? 0, snapshot.lastEventId);
...
const iterable = this.bridge.subscribeEvents(sessionId, {
  ...(subscribeFromEventId !== undefined ? { lastEventId: subscribeFromEventId } : {}),
  ...(epoch !== undefined ? { epoch } : {}),   // 已经不是这个游标的 epoch 了
});

一旦 mismatch,bus 走 replayFrom = 0 重放整个 ring;而 /acp 的 live loop(:4171)没有快照循环里那个 event.id <= lastDeliveredId 去重,于是刚 translate 出去的快照区间会被原样重发一遍。

这条路径对第三方 /acp 客户端不是边角:/acpsession/load 回复只带 ACP state + configOptions/models/modes,既没有 lastEventId 也没有 eventEpoch,所以它们唯一能拿到 epoch 的地方就是上一条流的响应头 —— 重启后必然是旧值,"session/load + 带旧 epoch 的 GET" 正是它们的主线恢复路径。SDK 走 ACP transport 不受影响(DaemonSessionClient.load 拿不到 eventEpoch,压根不发这个头)。

建议subscribeFromEventId 被快照水位覆盖时,把 epoch 一起丢掉 —— 游标已经不是客户端提供的那个了,用它去做 epoch 判定没有意义。

🟢 3. DaemonSession.eventEpoch / create() 播种目前是死代码

服务端只有三处下发 eventEpochreplayFieldsFor(load/resume)、202 envelope、continueSessionPOST /session(create/attach)返回的是 BridgeSession该类型没有 eventEpoch 字段,所以 DaemonSessionClient.ts:219eventEpoch: session.eventEpoch 对着本 PR 的 daemon 恒为 undefined

功能上无害(首次订阅会从响应头学到),但两处不一致值得收一下:

  • PR 描述写的是 "session load/resume/create responses" 都带 epoch —— 与代码不符;
  • AGENTS.md 的 "Simplicity First / nothing speculative":这是一个当前无人产出的字段。

二选一:给 create 响应补上 eventEpoch(和 202 一样一行),或者删掉 SDK 侧字段并修正描述。

🔵 4. scripts/build.js 的预算 ledger 记了一次没发生的 bump

// Bumped from 167KB to 168KB for workspace-level streaming generation.
// Bumped from 167KB to 168KB for the daemon event-bus epoch token fields  ← 新增
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 168 * 1024;   // 值没变

这个文件的注释是一条严格的"每次抬预算记一行"流水账,本次并没有抬(上一条已经是 168KB)。建议改成 "no bump needed: the epoch fields fit inside the existing 168KB budget",否则下一个人按流水账倒推会得出 169KB 的错误结论。

🔵 5. events.ts:407 detail 的注释与声明不符

注释说 "Optional trigger discriminator carried via the index signature on the wire",但它就是一个显式声明的可选属性 detail?: string,没走 index signature。措辞会误导下一个读者。

🔵 6. degraded 面包屑里的 sessionId 未做日志转义

bridge.ts:3561 直接插值:

`qwen serve: compaction degraded for session=${sessionId}; ...`

/acp 侧同一语义的那条用了 logSafe(sessionId)。而 createSessionEventBus(req.sessionId) 在 restore 路径上拿到的是客户端提供的 :id 原值。acp-bridge 包内没有 logSafe,邻近日志确实也有原样插值的先例(:2647/:2680),所以这只是一致性 nit —— 包内已有 JSON.stringify(entry.sessionId) 的写法(:2051/:3431),沿用即可。


测试覆盖

新增测试的杀伤力是够的,不是走过场:

  • does not invent attribution fields when source events carry none —— 负向断言,防止无脑盖章;
  • numeric-heuristic epoch_reset does NOT carry detail + ring_evicted resync does NOT carry detail —— 两条负向断言把 detail 的判别语义锁死了;
  • ignores a mismatching epoch when no lastEventId is presented —— 覆盖了"无游标即无需保护"的边界;
  • survives a throwing onCompactionError callback —— 直接打在 publish() never-throws 契约上;
  • does not look up the bus epoch for virtual subagent SSE streams —— 这条尤其好,虚拟子会话的复合 id 不在 byId 里,直查会抛错并把订阅打断,测试把它钉住了。

缺口:没有路由级断言 POST /session/:id/load 的响应真的带 eventEpoch / replayDegraded。目前只有 bridge 层断言 + 路由 res.status(200).json(session) 整体序列化兜底 —— 恰好这是本 feature 对外宣传的首要通道,加一条 supertest 断言成本极低。

做得好的点

  • 复用既有 resync 路径而不是新造一条恢复语义,服务端 diff 极小。✅
  • parseEventEpochHeader 单点实现(注释里明确说了是为了不重蹈 parseLastEventId 双份拷贝的覆辙),严格字符集 + 64 长度上界,非法值降级为"未提供"而不是 400 —— 对一个纯优化性的头来说是正确取舍。✅
  • CORS 同时更新了 allow-headersexpose-headers。后者最容易漏,漏了跨域客户端就静默失去这个能力。✅
  • WS transport 明确注释"故意忽略 epoch/onEpoch"并给了理由(无续传机制 → 无过期游标问题),而不是悄悄误用。✅
  • 压缩归属用字段级 ?? 合并而非整体替换,且与 mergeToolCallEvent 的既有语义对齐;captureTurnFields 在两个字段都缺失时返回 undefined,不产生空对象。✅
  • degraded 只 latch 一次,onCompactionError 外面包了 try/catch —— 诊断回调炸了也不会破坏 publish() 的 never-throws 契约。✅

风险评估

中低。绝大部分是 additive(可选字段 + 可选头),不带 epoch 的客户端行为完全不变。主要待办是 #1 —— 它不会让现状比合入前更糟(缺口是"回落到合入前的行为",不是新引入的破坏),但它意味着 DAEMON-001 在这条路径上没有被真正关闭。#2 是同一个"epoch 必须与游标同源"不变量在服务端的另一处破口。

另注(非本 PR 引入、仅作记录):强制全量重放走的是 forcePush,绕过 maxQueuedBytes;epoch 头给这条放大路径新增了一个客户端可控的触发器,但同等效果通过 Last-Event-ID: <极大值> 早就能达到,所以不构成回归。

chiga0 pushed a commit that referenced this pull request Jul 23, 2026
… token; preserve turn attribution and surface compaction failures in replay (#7458)

* fix(daemon): epoch-token restart detection, compaction attribution, and degraded-snapshot signaling (DAEMON-001/007/008)

* fix(acp-bridge): field-level turn attribution merge and replayDegraded bridge test (#7458)

* fix(serve): skip bus epoch lookup for virtual subagent SSE streams (#7458)

The REST SSE route looked up the bus epoch for every session id, but
virtual subagent sessions ride their own bus and their compound ids are
not in the bridge's byId map, so the lookup threw and aborted the
subscription — breaking subagent event streams. Skip the lookup for the
virtual path and degrade a torn-down real session to a headerless stream
(mirrors the /acp route). Also bumps the daemon browser SDK bundle budget
(167KB -> 168KB) for the epoch fields and declares eventEpoch on
DaemonSession so the create/attach path drops its inline type cast.

* fix(serve): stamp eventEpoch on accepted continuations and surface replayDegraded in the SDK (#7458)

Address three review suggestions:
- POST /session/:id/continue now returns eventEpoch alongside lastEventId,
  mirroring the prompt 202 envelope so continuation-seeded SSE cursors
  detect daemon restarts (DAEMON-001)
- DaemonSessionClient exposes replayDegraded from the load response so SDK
  consumers can prefer the full transcript over a degraded snapshot
- add /acp dispatch-level regression test for the degraded-snapshot stderr
  breadcrumb (fires only when snapshot.degraded is set)

* test(cli): fix load-reply race in the degraded-breadcrumb transport test

Await each session/load reply frame before opening the session stream so
the GET cannot race conn.ownSession() into a 403; addresses the review
Critical on the deg-0 arm.

* fix(serve): allow and expose X-Qwen-Event-Epoch in CORS headers

Cross-origin SSE clients must send the epoch header through preflight and
read it from the response, or stale-cursor detection (DAEMON-001) is
silently disabled for every CORS client.

---------

Co-authored-by: qwen-code-bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen Autofix <qwen-autofix[bot]@users.noreply.github.com>
yiliang114 added a commit to he-yufeng/qwen-code that referenced this pull request Jul 23, 2026
)

* fix(cli): correct queued message display style and ordering

Mid-turn steer messages (user input queued while the model is
responding) had two display bugs:

1. They rendered with notification styling (● icon) instead of
   user-input styling (> prefix) because accept() added them to
   UI history as MessageType.NOTIFICATION.

2. They appeared below the model's reply because accept() was
   only called in the finally block after the entire response
   stream completed, appending the user message after all model
   response items.

Fix: use MessageType.USER with sentToModel: true for steer
messages, and settle the steer input on the first stream event
(after the user-content push lands but before model-response
events are committed to UI history). Pass steer inputs through
to recursive sendMessageStream calls so all takeSteerInput paths
benefit from early settlement. Add a WeakSet guard to
settleSteerInput for idempotency across recursive invocations.

* test(core): add ordering test for early steer settlement

Verify that accept() is called after the first stream event is
pulled but before subsequent events reach the consumer, pinning
the settle-before-content timing that ensures queued user
messages render above the model's reply.

* fix(cli): use sentToModel: false for steer messages, address review

- Use sentToModel: false instead of true: steer messages are injected
  into an existing tool-result turn, not standalone user turns.
  sentToModel: true would make isRealUserTurn() count them as real
  turns, inflating the rewind turn index.
- Remove unnecessary as HistoryItemWithoutId cast.
- Add post-cleanup assertion in ordering test to verify the WeakSet
  guard prevents double-settlement.

* fix(cli): align resumed mid-turn steer display with live session (#7381)

Resume path now renders mid_turn_user_message as MessageType.USER with
sentToModel: false, matching the live-session styling. Add a comment
documenting the intentional sentToModel: false choice.

* fix(cli): exclude steer messages from user-turn filters (#7381)

Steer messages (sentToModel: false) were counted as real user turns by
five downstream consumers that filter on type === 'user' without checking
sentToModel, breaking cancel auto-restore, telemetry turn count, prompt
recall, away-recap thresholds, and resume collapse boundaries.

Add sentToModel !== false guards at each site.

* test(cli): add coverage for sentToModel !== false guards (#7381)

* test(cli): add coverage for sentToModel !== false guard in input-history filter (#7381)

* test(cli): add coverage for sentToModel !== false guard in YOLO turn-count telemetry (#7381)

* fix(cli): restore corrupted docs and classify steer items as synthetic (#7381)

* fix(docs): restore corrupted autogenerated input names in GitHub Action docs (#7381)

* fix(cli): deduplicate findLastUserItemIndex and add steerInput forwarding test (#7381)

* fix(cli): keep code-block copy numbering continuous across steer items (#7381)

* test(core): add Hook continuation steerInput forwarding test

Verify that steerInput is forwarded through the Stop-hook
continuation path and settled early on the first content event
of the continuation turn, matching the existing Steer
continuation coverage.

* fix(cli): sync selection test fixtures with ink FrameCell/ReadonlyFrame types (#7381)

* fix(core): align cron day wildcard semantics (#7464)

Co-authored-by: destire-mio <248462155+destire-mio@users.noreply.github.com>

* feat(core): keep completed background agents resident (#7426)

* feat(core): keep background agents resident

* fix(core): harden background continuation boundaries

* docs(core): move per-spawn cleanup comment to subagentDispose

The comment describing the per-spawn cleanup (which stays undefined on
the fork-resume path) had drifted above the launchModel declaration,
where it no longer applied and could mislead readers. Relocate it to the
subagentDispose assignment in the non-fork branch it actually documents.

* fix(core): close finishing window and release resident on error in background GOAL path

- Non-worktree GOAL completion drained the message queue but never called
  registry.beginFinishing(), unlike the worktree path. A send_message racing
  the terminal transition could be accepted (status still running,
  finishingAgents empty) and then orphaned by complete(). Call beginFinishing()
  after the empty drain to reject the racing message instead.
- The completion catch block never reset keepResident, so a throw from
  patchAgentMeta/registry.complete left the runtime resident but finalized as
  failed — a zombie that cleanupRuntime never reclaimed. Reset keepResident in
  the catch so the finally block disposes it.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* ci(autofix): continue environment-specific fixes (#7444)

* ci(autofix): continue environment-specific fixes

* docs(autofix): align verification wording

* docs(autofix): require bundle before integration tests

* docs(autofix): scope surrogate verification rules

* docs(autofix): require focused tests before integration checks

* docs(autofix): clarify review verification guidance

* fix(acp-bridge): close prompt-terminal follow-ups from the PR #7400 self-review (#7453)

* fix(acp-bridge): close prompt-terminal follow-ups from PR #7400 self-review

Keep a removed RUNNING prompt visible to the teardown flush via a removed flag so its terminal still publishes when the session closes before the agent cooperates; gate broadcastTurnError's session turn-state mutation to running prompts; propagate the typed PromptDeadlineExceededError from the pre-dispatch abort check; document the deadline FIFO-release overlap trade-off, the trailing prompt_cancelled after flush, and the result.then/finally ordering invariant; route the dedup log to the debug channel; drop the prompt-deadline re-export that pulled the bridge into a leaf module.

Fixes #7451

* test(acp-bridge): cover promote-then-remove-then-settle duplicate completed guard (#7453)

---------

Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>

* fix(core): strip Qwen-internal daemon secrets from agent-spawned child env (#7256)

* fix(core): strip Qwen-internal daemon secrets from agent-spawned child env

Shell subprocesses (and the monitor tool and stdio MCP servers) inherited
the full daemon process.env, including QWEN_SERVER_TOKEN (the serve-daemon
bearer credential), so an agent-run command like printenv QWEN_SERVER_TOKEN
could read an internal secret. Add a shared sanitizeChildEnv() that removes
Qwen-internal daemon/server tokens (QWEN_SERVER_TOKEN, QWEN_DAEMON_TOKEN)
before spawning, and apply it at the shell child_process + PTY paths,
monitor.ts, and the mcp-client stdio transport.

The denylist is deliberately narrow: it does NOT strip third-party
credentials (GH_TOKEN, AWS_*, NPM_TOKEN, ...) that real shell workflows
legitimately inherit -- only Qwen-internal secrets. Exported from the
package root so the desktop denylists can consolidate onto it later.

Fixes #6601.

* test(core): cover daemon-secret stripping on monitor and mcp-client spawn sites

* test(core): replace process.env instead of mutating in shell sanitization tests

The file restores process.env by reference in afterEach, so in-place key
mutations leaked into later tests. Use the replacement pattern already used
by setupConflictingPathEnv.

* docs(core): align JSDoc @param names with actual function signatures (#7492)

Fix 6 instances where JSDoc @param tags had drifted from their
corresponding function signatures — parameters were renamed, removed,
or undocumented over time but the doc blocks were not updated.

Closes #7446

* feat(serve): support forced MCP reconnects (#7488)

* feat(serve): support forced MCP reconnects

* test(serve): cover forced MCP reconnect options

---------

Co-authored-by: 克竟 <dingbingzhi.dbz@alibaba-inc.com>

* fix(cli): insert newline on Shift+Enter and stop streaming thinking-block flicker (#7397)

* fix(cli): re-push Kitty keyboard flags onto the alternate screen in VP mode

In VP mode the app renders on the alternate screen (`alternateScreen: true`),
but the Kitty keyboard progressive-enhancement flags were pushed only once at
startup on the main screen. The Kitty spec tracks these flags per screen
buffer, so the alternate screen's stack stays empty and the terminal never
reports modifiers: Shift+Enter arrives as a bare Enter (submit) or, when the
terminal emits an ESC-prefixed variant, as an orphaned Escape that trips the
empty-buffer double-Esc rewind prompt — so Shift+Enter can never insert a
newline in VP mode even on Kitty-capable terminals (e.g. cmux).

Re-push the flags onto the alternate screen right after Ink enters it (Ink
writes the enter-alt-screen sequence synchronously inside render(), so the
push is correctly ordered). Ink discards the alternate screen and its flag
stack on unmount, leaving the startup main-screen push balanced by the
existing disableKittyProtocol() on cleanup.

Generated with AI

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

* fix(cli): stabilize streaming thinking block height to stop flicker

The pending "Thinking…" block renders the tail of the reasoning stream in a
content-sized box. As the model emits paragraph separators, a blank line
enters and leaves the tail window (and `trimEnd` drops trailing blanks), so the
visible line count oscillates and the block flickers 2→3→5 rows during
streaming.

Track the tallest height the block has reached for the current thought and
never render fewer rows than that (capped at the streaming window size),
padding at the top so the newest line stays pinned to the bottom. The tracker
resets when streaming ends or when the buffer shrinks (a new thought replaced
it), so height is monotonic within a thought without leaking across thoughts.

Generated with AI

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

* fix(cli): decode xterm modifyOtherKeys Shift/Ctrl/Alt+Enter so it inserts a newline

Terminals such as Ghostty report Shift+Enter as the xterm modifyOtherKeys
sequence `ESC [ 27 ; <mods> ; <key> ~` (e.g. `ESC [ 27 ; 2 ; 13 ~`) when the
Kitty keyboard protocol is not negotiated — which is the default, since Kitty
detection does not always succeed. Two bugs kept this from inserting a newline:

1. The CSI-u parser read the leading `27` marker as the key code (matching the
   Escape key code 27) instead of the real key code in the third parameter, so
   with Kitty enabled Shift+Enter was mistaken for Escape and tripped the
   double-Esc rewind prompt.
2. The reassembly path that stitches readline's shredded CSI fragments back
   together was gated behind `kittyProtocolEnabled`, so with Kitty disabled the
   `ESC [ 27 ; 2 ;` head plus the stray `13~` tail leaked into the composer as
   literal text and no newline was inserted.

Decode the third parameter as the real key code for the `27;…~` form, and route
those sequences through the reassembly buffer even when Kitty is off (only the
`ESC [ 27` marker opts in, so keys readline already parses cleanly are
untouched). Shift/Ctrl/Alt+Enter now insert a newline in both VP and non-VP
mode regardless of Kitty negotiation.

Generated with AI

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

* fix(cli): anchor VP viewport to the top until a conversation turn exists

On a fresh VP-mode session the virtualized list holds the banner plus startup
notices (tips / MOTD / info), so it is longer than one item. Keying the initial
scroll anchor off list length alone selected scroll-to-end, which pinned the
banner to the bottom of the full-height viewport and left the top half of the
screen blank.

Anchor to the top until there is an actual conversation turn (a user/user_shell
history item or a pending response), then resume scroll-to-end so the latest
output stays in view. Startup notices no longer count as content that forces
bottom alignment.

Generated with AI

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

* fix(cli): stabilize streaming thinking window against availableTerminalHeight drift

The grow-only streaming thinking window still flickered because its line cap was
derived from availableTerminalHeight. While a thought streams the terminal keeps
constrainHeight on, so availableTerminalHeight (and the derived maxLines) drifts
up and down as sibling pending content grows, and the grow-only clamp
`min(maxLines, …)` shrank the block whenever it dipped.

Use a constant window height (MAX_STREAMING_THINKING_VISUAL_LINES) for the
pending window instead. The window is only a few lines, so a fixed cap cannot
meaningfully overflow (VP scrolls anyway), and the height stays stable while
still growing monotonically within a thought.

Generated with AI

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

* Revert "fix(cli): anchor VP viewport to the top until a conversation turn exists"

This reverts commit fbe86a9e159b75ea1f5b689cc327599c9dc91090.

* fix(cli): guard modifyOtherKeys detection against keypresses without a sequence

The modifyOtherKeys prefix check ran on every keypress, but some synthetic
keypresses (and the useKeypress test harness) emit a key with no `sequence`,
so `key.sequence.startsWith(...)` threw an unhandled rejection. Use optional
chaining so a missing sequence is simply not a modifyOtherKeys start.

Generated with AI

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

* test(cli): mock pushKittyProtocolFlags in gemini.test.tsx kitty mock

The kittyProtocolDetector mock omitted the newly added pushKittyProtocolFlags
export. Add it so the mock stays in sync with the real module and a VP-mode
startup path exercised through this suite cannot hit an undefined call.

Generated with AI

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

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(web-shell): open singleton subagent details (#7495)

Co-authored-by: ytahdn <ytahdn@gmail.com>

* fix(web-shell): avoid redundant git status requests (#7496)

Co-authored-by: ytahdn <ytahdn@gmail.com>

* fix(agent): ignore empty working_dir placeholders (#7343)

* fix(agent): ignore empty working_dir placeholders

* test(agent): align empty working_dir expectations

* feat(prompts): allow overriding core identity via QWEN_SYSTEM_IDENTITY_MD (#7478)

* feat(prompts): update prompts.ts for QWEN_SYSTEM_IDENTITY_MD

* feat(prompts): update prompts.test.ts for QWEN_SYSTEM_IDENTITY_MD

* fix(prompts): address CR on QWEN_SYSTEM_IDENTITY_MD

Keep getDefaultCoreIdentitySentence private, fail loud on path
resolution errors, use trimEnd, and resolve identity only on the
default-prompt branch.

* test(prompts): align identity override tests with CR feedback

Sample default identity from live prompt, cover trimEnd trailing
whitespace, and assert homedir resolution failures throw.

---------

Co-authored-by: 易良 <1204183885@qq.com>

* fix(cli): yield to single-slot background agents (#7258)

Co-authored-by: hogeheer <267467744+hogeheer499-commits@users.noreply.github.com>

* docs(autofix): require evidenced pre-commit verification, not a bare "verified" (#7486)

* docs(autofix): require evidenced pre-commit verification, not a bare "verified"

The skill already said to run build/typecheck/lint/Vitest before
committing, but softly — and #7408 committed a fix with a TS error the
gate then rejected while its summary claimed "verified all 3 commits".
A self-assessment the gate contradicts wastes a whole round.

Strengthens the address-review contract from "run the checks" to:
- actually run them, do not assert them from reading the diff;
- if typecheck or a touched-package test fails, do NOT commit — treat
  the feedback as unresolved (failure.md);
- end address-summary.md with a `## Verification` section listing each
  command run and its result; a bare "verified" is not acceptable.

The framing is structural, not etiquette: the deterministic gate re-runs
the same commands and discards the round on any failure, so skipping them
only moves the rejection later. Pinned by a test so it cannot soften back.

This is the checkable half of "audit before committing" — the
undirected/reverse-audit-until-clean practice does not transfer to an
unsupervised agent (no verifiable stopping condition, and it worsens the
timeouts seen on large PRs), but "run the gate's own checks first and
show the evidence" does.

* fix(autofix): clarify Verification section precedes collapsed Chinese translation (#7486)

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>

* feat(autofix): stop a PR that fails to push for N rounds in a row (#7482)

* feat(autofix): stop a PR that fails to push for N rounds in a row

Under takeover the round cap is 100, which is right for a PR that needs
many PRODUCTIVE rounds. It is wrong for one that fails every round: #6723
ran 7 consecutive failed rounds (3 agent timeouts at 50 min, 4 gate
rejections whose fix broke tests) over 8 hours, heading for round 100,
because it is a 5700-line, 47-file, 5-day-old PR racing a fast-moving
main — every round re-resolves a conflict it cannot finish or that fails
the gate. Retrying at the same per-round budget will not converge; a
human has to rebase or split it.

Adds CONSECUTIVE_FAILURE_CAP (5), distinct from the total round cap. The
handoff step already runs only when a round did NOT push, so it counts
the unbroken run of prior failure markers — stopping at the first push
("Addressed the latest review feedback") or legitimate no-op ("no
changes needed"), either of which proves progress and resets the streak.
At the cap it forces the terminal round even under takeover, with a
handoff that names the real fix (rebase/split, then /retry). Cause-
agnostic: a timeout and a gate rejection both count.

* fix(autofix): address review feedback on consecutive-failure circuit breaker (#7482)

- Fix misleading comment: the walk is oldest-first (API order) with
  reset-on-success, not newest-first with early stop
- Prefer the already-fetched ic.json over a redundant gh api call,
  falling back to the API only when the file is missing
- Filter eval markers by re-arm window (win=) so pre-re-arm failures
  do not immediately re-terminate a re-armed PR
- Add test coverage for the MARK_ROUND == MAX_ROUNDS guard and for
  window-scoped streak counting

* fix(autofix): exempt transient model errors from consecutive-failure breaker (#7482)

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>

* feat(core): restore background agent roster (#7459)

* feat(core): restore background agent roster

* fix(web-shell): add list_agents to TOOL_DISPLAY_NAMES

The new list_agents core wire tool was added to core's ToolNames but not
to the web-shell TOOL_DISPLAY_NAMES map, causing toolFormatting.drift.test.ts
to fail (expected ['list_agents'] to deeply equal []). Add the missing
'ListAgents' display-name entry so the browser panel shows a friendly name
instead of the raw wire name and the drift guard passes.

* fix(cli): reload old-session background agents on failed resume rollback

When /resume fails after core has swapped but before the UI swap, the catch
block rolls core back to the old session via startNewSession(oldSessionId).
However the forward path already called resetBackgroundStateForSessionSwitch,
which cleared the old session's in-memory background agents. The rollback did
not reload them, so list_agents returned empty for the old session (whose
sidecars are still on disk) until the next process start or successful resume.

Reload the old session's paused background agents after rolling core back, so
the restored roster matches on-disk state. Placed after startNewSession so the
loadPausedBackgroundAgents current-session guard is satisfied; best-effort via
.catch so it never blocks the rollback path.

* fix(web-shell): add zh translation for list_agents tool name

The toolFormatting test 'has a zh translation for every tool in the
display-name map' failed with expected ['list_agents'] to deeply equal []
because list_agents was added to TOOL_DISPLAY_NAMES without a matching
toolName.list_agents zh-CN entry. Add the translation to restore parity.

* fix(cli): resolve CI failures for background-agent roster restore

- Add toolDisplayName.ListAgents translations (en, zh, zh-TW, ca) so the
  new list_agents tool has a zh entry; fixes i18n/index.test.ts.
- Add loadPausedBackgroundAgents and consumePendingRecoveredAgentsNotice
  to the acpAgent worktree test config mock, which loadSession now calls
  via #restoreBackgroundAgentsOnResume; fixes acpAgent.worktree.test.ts.

* refactor(core): extract incompatible-isolation blocked reason to a const

Move the incompatible-isolation blocked-reason string out of an inline
literal into a module-level INCOMPATIBLE_ISOLATION_BLOCKED_REASON const,
matching its four sibling reasons so the text is discoverable by
constant-name grep and edited alongside the others.

* fix(core): preserve retained activity state on failed agent revive

Address review feedback on the background-agent roster restore:

- On a failed completed-agent revive, restore UI state with a non-empty
  guard instead of `??`. Because `restorePausedEntry` resets the paused
  entry's `recentActivities` to `[]`, the previous `failedEntry?.field ??
  completedEntry.field` kept that empty array and dropped the pre-revive
  snapshot (the UI Progress section rendered empty). Applied consistently
  to pendingMessages, recentActivities, and pendingApprovals.

Add regression coverage for previously untested paths:

- failed revive preserves pre-revive recentActivities
- terminal-agent cap admits only the newest MAX_RETAINED_TERMINAL_AGENTS
  completed sidecars on restore
- /resume rollback reloads the old session's background agents
- headless resume prepends the recovered-agents notice to the prompt

* test(cli): cover interrupted-turn continuation not consuming recovered-agents notice

Add ACP and headless regression tests asserting an interrupted-turn
continuation does not consume the one-shot recovered-agents notice
(the !isContinue / !continueInterrupted guards), so it is delivered on
the user's next ordinary prompt. Mirrors the existing slash-command
coverage.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(cli): support custom skill directories via settings (#7395)

* feat(cli): support custom skill directories via settings (#7394)

Add skills.directories setting that accepts an array of additional
directory paths to scan for skills (SKILL.md files). Paths support
~ expansion. Directories are scanned recursively at user level,
after the default ~/.qwen/skills/ directory.

Example settings.json:
{
  "skills": {
    "directories": ["~/.agent/skills", "~/.claude/skills"]
  }
}

Changes:
- settingsSchema.ts: add skills.directories array setting
- core Config: add customSkillDirs param and getCustomSkillDirs()
- SkillManager: append custom dirs to user-level skill base dirs
- CLI config: read skills.directories and pass to core Config

* fix(cli): regenerate settings schema for skills.directories (#7394)

* fix(core): address review feedback for custom skill directories (#7395)

- Use optional chaining for getCustomSkillDirs() to prevent TypeError
  on partial Config mocks (workspace-skill-management, workspace-skills-status)
- Reuse expandHomeDir utility instead of inline tilde expansion
- Fix inaccurate 'scanned recursively' wording to 'one level deep'
- Correct JSDoc: paths are raw, expansion happens in SkillManager
- Trim whitespace from custom dir entries in CLI layer
- Add tests for custom dir expansion, dedup, and partial config safety

* fix(core): address review feedback for custom skill directories (#7395)

* fix(core): address review feedback for custom skill directories (#7395)

* test(core): add relative path resolution test for custom skill dirs (#7395)

* fix(cli): add Array.isArray guard for skills.directories and safe mode test (#7395)

* fix(skills): address review feedback on custom skill directories (#7395)

- Add bare mode test for skills.directories guard
- Include resolved absolute path in relative directory warning
- Clarify that dedup applies to default user dirs, not bundled skills
- Regenerate settings schema

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>

* fix(core): add image modality support for qwen3.8-max and kimi-k3 models (#7491)

* fix(core): add image modality support for qwen3.8-max models

qwen3.8-max-preview supports image input but was falling through to the
catch-all text-only rule because no pattern matched it. This caused the
vision bridge to unnecessarily transcribe images via a secondary model
instead of sending them directly to the primary model.

* fix(core): also add image modality for kimi-k3

Kimi K3 officially supports image + video input but was falling through
to the catch-all text-only rule, same issue as qwen3.8-max.

* fix(dingtalk): preserve non-bot mention context (#7473)

* fix(dingtalk): preserve non-bot mention context

* test(dingtalk): cover plural mentions, staffId fallback, and edge cases (#7473)

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>

* fix(core): harden the usage salvage around session deletion (#7425)

Post-merge review follow-ups on #7391 (three findings):

- Salvage the archived transcript in the active-branch deletion too:
  when both copies co-exist (an interrupted archive) and the fresh
  active transcript carries no telemetry, the archived copy holds the
  session's usage history and was deleted unsalvaged. The dedup guard
  makes the extra call a no-op whenever the active copy already wrote.
- Enforce the "never blocks deletion" contract at the call site: a
  salvageUsageBestEffort wrapper catches and warns, so the guarantee is
  structural rather than an implementation detail of
  persistUsageBeforeTranscriptDeletion. The new failure-tolerance test
  (salvage rejects -> deletion still succeeds) fails without the
  wrapper — the bare await let the rejection escape through
  removeSessionFiles' rethrowing catch.
- Clear the salvage module mock in beforeEach so the wiring test's
  invocationCallOrder assertions can never read stale calls.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(core): make fork subagents discoverable (#7460)

* test(core): cover Shell truncation without an artifact (#7470)

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

* fix(ci): autofix route checks existing labels on non-trigger label events (#7481)

* fix(ci): autofix route checks existing labels on non-trigger label events

When triage adds multiple labels in sequence, per-issue concurrency
cancels earlier runs. If the last label is not a trigger label
(e.g. scope/build-system), the surviving run skips the issue phase
even though the issue already has autofix/approved +
status/ready-for-agent.

Before ignoring a non-trigger label event, check ISSUE_LABELS_JSON
for both required labels. If present and the issue is open, proceed
with the issue phase. Trust was already established when the trigger
labels were applied (both require triage+ permission).

* fix(ci): require trusted sender for label fallback

* feat(cli): preserve semantic text when copying VP selections (#7286)

* docs(cli): define semantic copy fidelity scope

* docs(cli): address semantic frame review gaps

* docs(cli): preserve soft-wrap source separators

* feat(cli): preserve semantic selection copy

* fix(cli): address semantic copy review findings

* fix(cli): preserve clipped semantic boundaries

* fix(cli): limit separator carrier joiner to visible width in wrap metadata

The greedy /\s+/ match in wrapTextWithMetadata could capture more
source whitespace than the separator carrier row actually consumed
(e.g. a tab following a space), causing duplicated whitespace in
semantic copy. Limit the match to visibleLine.length characters and
add a mixed space/tab regression test.

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>

* test(core): stub the registry methods agent.ts actually calls (#7538)

The shared stubRegistry in agent.test.ts was missing six methods that
agent.ts reaches: bridgeApprovalEvents, getQueuedCount,
registerResidentAgent, restartCompletedAgent, unregisterResidentAgent and
waitForMessages.

That is not a benign omission. The background body wraps its work in a
try/catch that routes any throw into registry.fail(), so a missing method
never surfaces as 'not a function' — it silently converts a successful
run into a failed one. On the GOAL completion path
unregisterResidentAgent is called immediately before complete(), so the
TypeError replaced the completion entirely:

  registry.fail('fork-...', 'registry2.unregisterResidentAgent is not a
  function', ...)

That is what broke 'runs a non-interactive fork through the background
registry' on main. #7460 added the registry.complete assertion, which
exposed the incomplete stub — before it, nothing checked whether the
background body finished successfully and the TypeError was swallowed.

Stub all six with their real return shapes (unregisterResidentAgent
returns boolean, bridgeApprovalEvents returns the unsubscribe callback
agent.ts later invokes, waitForMessages resolves to a list) and assert
registry.fail was not called before asserting completion, so a future
gap reports the actual error instead of 'complete: 0 calls'.

* perf(startup): lazy-load Google GenAI SDK on first use (#7512)

* perf(startup): lazy-load Google GenAI SDK on first use

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

* codex: address PR review feedback (#7512)

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

* codex: address PR review feedback (#7512)

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

---------

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

* fix(vscode): use file picker image paths for vision input (#7493)

* fix(vscode): use image paths from file picker

* fix(vscode): keep image picker paths raw

* fix(vscode): resolve image picker paths on submit

* fix(vscode): send picked images as vision context

* fix(vscode): encode prompt image file URIs

* fix(vscode): address image path review comments

* test(vscode): cover image file reference edge cases

* fix(cli): open the actual serve fallback port (#7501)

* fix(cli): open actual serve fallback port

* test(cli): match serve URL to fallback listener

* docs(cli): clarify serve listen error handling

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(ci): don't let one failing scenario sink the whole visual preview (#7511)

The web-shell visuals render runs every screenshot and flow in a single
`test:e2e:visuals`, and that step had no `continue-on-error`, while the compose
and upload steps had no `if: always()`. So one failing or timing-out scenario
failed the job, the artifact was never uploaded, and the publish workflow had
nothing to post — the entire preview vanished even when every other scenario
passed and its PNG was already on disk. A flow (a long multi-click sequence) is
the most fragile scenario kind, so the fragile one silently takes down the
deterministic screenshots. PR #7498 hit exactly this: 29 scenarios passed, one
new channel-management flow timed out, and the PR got no preview and no comment
at all.

Make the after-capture step `continue-on-error` so the passing captures survive
and the later steps still compose and upload them. The publish job only runs on
a `success` conclusion, so the job must stay green — but a masked failure must
not read as a clean preview. Ship the step's real `.outcome` (which
continue-on-error does NOT mask, unlike `.conclusion`) to the publisher as
`render-status.txt`, and have the comment builder use it: an empty preview whose
render failed says "one or more scenarios failed to render" and is explicitly
NOT the reassuring green check or the coverage-gap prompt (both imply the render
ran); a partial preview is labelled partial above the shots that did render. A
missing status file (older run) defaults to complete, so this only ever adds a
warning, never suppresses a real preview.

The failing scenario still needs fixing — it's now surfaced in the comment
rather than by silently deleting everyone else's preview.

Co-authored-by: wenshao <wenshao@example.com>

* feat(web-shell): add selective shadow DOM isolation (#7551)

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>

* feat(web-shell): add renderChatHeader slot for custom session header (#7553)

* fix(cli): say review coverage gaps in the author's units, not chunk ids (#7550)

The posted review body rendered coverage disclosures with the run's own
bookkeeping as subjects: bare chunk ids, unsorted, one per subject. On a
run that certified nothing (PR #7268) the body enumerated all 49 chunk ids
across two sentences while opening with "Reviewed. Suggestions are
inline." — the opener certified the exact thing every following sentence
took back, and nothing on the PR page maps a chunk id to code.

Three changes, all render-time — the structural entries, the caps, the
caller-echo dedup and the stderr remediation still key on chunk ids, which
is where the id is the selector a reader can act on:

- Coverage now returns the plan's chunk→files table (DiffChunk.files was
  already in the plan JSON; the coverage type slice dropped it).
- compose-review renders chunk gaps through describeChunkGap: every
  planned chunk collapses to "the entire diff", a narrow gap with known
  files names the files, and anything wider is counted against the plan's
  total. Applied to the receipt sentence, the uncoverable sentence (bare
  CLI entries only — caller-authored entries render verbatim) and the
  grouped per-cause sentences.
- The COMMENT opener may no longer say "Reviewed." over a disclosure set
  that denies it: when no chunk is both covered and undisclosed — or no
  chunk universe could be read at all — it opens with a zero-certified
  warning instead. A rewritten launch demonstrably read its chunk, so
  coverage alone is not the test; certified is covered with no disclosure
  against it.

Co-authored-by: verify <verify@local>

* fix(autofix): retry a skipped-Prepare instead of stranding the PR terminal (#7490)

* fix(autofix): retry a skipped-Prepare instead of stranding the PR terminal

A base/infra failure BEFORE the agent runs was misread as an agent crash
and terminated the PR forever. When an early step fails — installing or
building the trusted base, checkout, node setup — the `Prepare branch and
feedback` step is skipped, so NEWEST is empty, and the report step's
"crashed before reading feedback" branch fired: MARK_ROUND=MAX_ROUNDS,
terminal, scan skips it on every future tick.

Observed: a web-shell TypeScript break on `main` failed `Install
dependencies and build` (which builds the trusted base) across a whole
scan batch, and SIX healthy PRs were stranded terminal at round=100 in
one run — including ones at round 9 and 11 that had nothing to do with
the break. `round=100` there is a terminal sentinel, not 100 attempts.

NEWEST-empty now splits on steps.prepare.outcome:
- 'skipped' (an earlier step failed, the agent never ran) is infra/base
  and transient: retry with a sentinel ts so the feedback stays live,
  incrementing the round so a PERSISTENTLY broken base is still bounded
  and stops at the cap (recoverable with /retry).
- 'success'/'failure' (Prepare ran, no feedback produced) is a genuine
  pre-read agent crash: unchanged terminal behaviour.

This is the reverse of the asymmetry #7482 addresses: that bounds a
crash AFTER reading that retried forever; this stops a transient failure
BEFORE reading from going terminal after one.

* docs(autofix): note a pre-Prepare cancel also retries intentionally (#7490)

* fix(autofix): also retry a cancelled/empty prepare outcome, not just skipped

A previous review comment on this PR noted that a job cancelled before
Prepare should retry too. It was right about the intent but the code did
not do it: `steps.prepare.outcome` is 'cancelled' for a cancel and '' for
a job that stopped before Prepare entered the step context — both DISTINCT
from 'skipped', so `== 'skipped'` sent them to the terminal branch, the
same over-termination this PR exists to fix.

Match on "not a real Prepare run" (`!= 'success' && != 'failure'`)
instead, so skipped, cancelled, and empty all retry; only a Prepare that
actually ran to a verdict (success/failure) with no feedback stays
terminal — the genuine pre-read agent crash. Test extended to drive the
cancelled and empty cases (retry) and both real-run outcomes (terminal);
mutation-verified that reverting to `== 'skipped'` reddens the cancelled
case.

* test(autofix): update the pre-read-crash case for the broadened retry

The prior commit broadened NEWEST-empty retry to skipped/cancelled/empty
but left the older 'replays the handoff decision' test asserting the old
terminal behaviour for an unset PREPARE_OUTCOME (which now retries). That
test's terminal cases now set PREPARE_OUTCOME=success/failure explicitly —
the only outcomes that still terminate — so it exercises the genuine
pre-read agent crash rather than the infra/cancel path.

* test(autofix): anchor the skipped-Prepare extraction past the CONSEC block

CI reddened `retries a skipped-Prepare` after main's consecutive-failure
cap (#7482) merged into this branch: that block was inserted between this
decision block and the report `{`, and it calls `gh api`. The test's
`{`-anchored regex over-captured through it, so the extracted script ran
the unstubbed `gh api` and failed. Anchor the end on the same
`# Consecutive-failure` comment the sibling gate-crash test already uses,
so the extraction stops at this decision block's own closing `fi`.

* fix(autofix): exempt skipped-Prepare from the consecutive-failure breaker

A broken base build skips Prepare, producing no API error file — so the
consecutive-failure breaker ran on the new retry path and, after 5
scans, re-introduced the exact mass-stranding this PR exists to prevent.
Exempt pre-agent infra failures (skipped/cancelled/empty outcome) from
the breaker, mirroring the transient 429/5xx exemption: same failure
class (not the PR's fault, self-heals, hits the whole batch). The round
cap + sentinel-ts /retry recovery already bounds a persistently broken
base.

Also trim "checkout" from the retry headlines (checkout failures do not
land in this branch) and hoist the duplicated MARK_TS assignment.

* fix(autofix): reset the consecutive-failure streak on prior infra-failure markers

The streak walker counted prior infra-failure headlines ("AutoFix could
not start —…") as failures, inflating the consecutive-failure count on
subsequent rounds.  A PR with 3 real agent failures, then 3 rounds of
base-build infra failures, then 1 more real failure would trip the
cap-5 breaker even though only 4 rounds were the PR's fault.

Add the two infra-failure headline patterns as reset strings in the
streak walker, alongside the existing push and no-op resets.  The
genuine agent-crash headline ("AutoFix could not start evaluation —…")
is deliberately excluded — it is a real failure and must still count.

* fix(autofix): clarify infra-failure headlines and else-branch comment (#7490)

Address review nits: the retry headline now mentions cancelled runs,
the cap headline says 'reached the round cap' instead of overstating
'could not start for N rounds', the else-branch comment says 'prepare
itself crashed' instead of 'agent crash', and the streak-reset pattern
is simplified now that both infra headlines share the same prefix.

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>

* fix(cli): keep role codenames and brief paths out of the posted review body (#7560)

The posted body still carried two operator registers #7550 left in place:
roster role subjects rendered their internal codenames ("Agent 1c:
Cross-file tracer", "Test coverage matrix (whole-diff)"), and an unread
brief's disclosure interpolated its filesystem path. And when verify and
the reverse audit failed the same way, the body said it twice, in two
near-identical sentences.

- Every Brief now carries a publicLabel — the dimension said as what it
  checks ("the cross-file consistency pass") — and coverage's structural
  disclosures carry it as publicSubject beside the internal subject, plus
  a path-free publicReason for unread briefs. The internal label and the
  path stay on stderr, where they are the selector an operator acts on;
  every dedup and certification check still keys on the internal subject.
- compose-review renders the public fields and groups by the reason the
  body PRINTS, so two unread briefs share one path-free sentence instead
  of repeating it per role.
- verificationGaps merges verify and reverse-audit failures of the same
  delivery shape into one sentence with both subjects and both
  consequences; mixed shapes keep their precise per-role texts, and the
  per-role rebuild commands stay on stderr either way.

Co-authored-by: verify <verify@local>

* fix(autofix): retry an agent timeout instead of advancing past its feedback (#7563)

A timeout evaluated NOTHING — the agent ran out of budget before finishing,
so nothing was committed and the feedback is unaddressed. It was treated as
an evaluated verdict (real ts, watermark advances), which strands that
feedback: the next scan sees "nothing new" and never retries. Observed on
#7471 (round 13/100), a heavily-reviewed 1871-line PR: rounds 11 and 13
timed out, but round 12 pushed — so a timeout is transient far more often
than not, and advancing past it left the round-13 feedback unhandled.

run-agent.mjs now drops an `agent-timeout` signal on result.timedOut, and
the handoff routes it like a pre-verdict crash: sentinel ts (feedback stays
live) and a retry, with a headline that names the real fix at the cap
(split the PR or raise the budget). A PR that PERSISTENTLY times out is
bounded by the round cap and the consecutive-failure cap, so this cannot
loop forever — it just stops treating a one-off budget blip as a verdict.

The loop guard stays terminal (a tool-call loop is a real defect, not a
budget blip). An API error still routes to its own model-key handoff; the
timeout signal is written only when NOT an API error.

Co-authored-by: wenshao <wenshao@example.com>

* feat(serve): add workspace-level generation (#7552)

* feat(serve): add workspace-level generation

* docs(serve): document workspace generation capability

* fix(serve): align workspace generation contracts

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>

* ci: matrix ECS runner update + sudo install + repository_dispatch trigger (#7513)

* ci: matrix ECS runner update with sudo install

- Use matrix strategy (ecs-update-sg, ecs-update-64c) to update both
  physical ECS hosts in parallel (fail-fast: false).
- Always use sudo npm install -g so the package lands in /usr/local
  (system-wide PATH) instead of the runner user's home directory.
- Move concurrency to job level (matrix context not available at
  workflow level per actionlint).
- Add repository_dispatch trigger for release-driven updates.
- Register new runner labels in actionlint.yaml.

* fix(ci): use dispatch version for runner update

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(web-shell): include managed id in artifact open requests (#7570)

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>

* feat(serve): persist workspace channel configuration (#7514)

* feat(serve): persist workspace channel configuration

* fix(serve): harden channel settings snapshots

* fix(serve): validate startup channel names

* fix(serve): reserve all channel name

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(sdk-python): require canonical form in validate_session_id (#7532)

uuid.UUID() accepts several non-canonical spellings — braced
{...}, urn:uuid:..., and dash-less hex — so validate_session_id let them
through after the RFC 4122 variant check. The value is then forwarded to
the CLI verbatim as --session-id/--resume, producing a malformed session
id downstream rather than a clear error at the SDK boundary.

Reject anything whose canonical form differs from the input. Case is
deliberately not part of the comparison: UUID() lowercases, and an
all-uppercase spelling is still valid canonical input.

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(web-shell): sync background agent status (#7561)

* fix(web-shell): sync background agent status

* fix(web-shell): harden background agent reconciliation

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>

* feat(core): propagate trusted daemon invocation context (#7279)

* feat(core): propagate trusted daemon invocation context

* test(cli): update ACP startup expectation

* refactor(core): centralize ACP capability env key

* test(cli): update worktree ACP core mock

* test(integration): run daemon context smoke on PRs

* test(ci): update no-AK smoke expectation

* test(core): cover invocation context isolation

* fix(cli): compare ACP capability safely

* fix(docs): restore GitHub action input names

* fix(core): sanitize private ACP capability from child env

* fix(core): reuse private ACP capability env constant

* test(cli): cover malformed trusted invocation context

* test(acp-bridge): assert exact child environment

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: 易良 <1204183885@qq.com>

* fix(feishu): await stream cancels in media download teardown (#7465)

* fix(feishu): await stream cancels in media download teardown

downloadMedia left two reject paths' stream teardown unawaited:

- the oversize-stream path called reader.cancel() without awaiting, so a
  cancel error during teardown became an unhandled rejection (fatal under
  Node's default --unhandled-rejections=throw);
- the Content-Length reject path returned without cancelling resp.body,
  leaving the connection pinned until GC.

Both were already fixed for the sibling DingTalk downloader in #7361 (which
was itself modelled on this Feishu code), so this brings Feishu to parity.
Adds a regression test that pins the reader.cancel() await via a rejecting
cancel, plus an assertion that the Content-Length path releases the body.

* test(feishu): cover a rejecting body.cancel() on the Content-Length path

Mirrors the existing reader.cancel() teardown test for the other reject
path, per review feedback. Removing the await on resp.body?.cancel()
flips execution onto the 'rejected: size ... exceeds' branch and the
test fails.

* fix(autofix): make the review-address report wrapper lines bilingual (#7569)

The agent's address-summary.md / no-action.md already ends with a
collapsed Chinese translation, but the workflow-appended wrapper lines
around it — the "Addressed/Reviewed the latest feedback" lead-in, the
"Base-conflict check" line, and the "Re-review when you have a moment"
footer — were English-only and sat outside that block. So the posted
comment was only half translated, unlike the takeover-ack comments
(full collapsed Chinese block) and the "model/模型" sign-off in this
same report (already inline-bilingual).

Give each wrapper line an inline Chinese translation, matching the
model/模型 idiom. The English halves are preserved verbatim — the
streak-reset detector globs on "Addressed the latest review feedback"
and "no changes needed", and a test extracts these lines — so behaviour
is unchanged and old English-only comments still match. A new test pins
each English-Chinese pair so a future reword that drops the Chinese
fails. The terminal handoff/failure comment is left English-only for
now (SKILL.md keeps it so by design); that is a separate change.

Co-authored-by: wenshao <wenshao@example.com>

* feat(cli): post the review body bilingually when the PR description is Chinese (#7564)

When the PR author writes Chinese, the posted /review body was
English-only. fetch-pr now records whether the PR description contains
Han characters (prDescriptionHasHan, detected from the same gh pr view
call and stamped into the plan report), and compose-review renders the
body bilingually off that flag: the English body leads, the complete
Chinese version rides collapsed in a <details><summary>中文说明</summary>
block, and the model footer stays outside the fold. The signal is the
CLI's own — the caller cannot toggle the register of a certified body —
and a local plan has no field, so nothing changes for terminal-only
reviews.

Every deterministic body fragment carries an en/zh pair end to end:
compose-review's clause templates and describeChunkGap phrases, the
coverage disclosures (reasons, publicLabel role subjects via a new
publicLabelZh, the path-free unread-brief reason) and the Step 4/5 gap
texts including the combined same-shape sentence. Fragments with no
deterministic translation — model-written findings, caller echoes,
interpolated errors — ride verbatim in both halves. verificationGaps now
returns structural {subject, reason, subjectZh, reasonZh} entries, which
also removes compose-review's last recover-the-boundary-from-prose parse.

SKILL.md instructs the same format for the model-authored inline
comments: English finding first (marker and suggestion block stay in the
English half — tooling filters on them), full Chinese translation
collapsed beneath, footer last.

Co-authored-by: verify <verify@local>

* feat(autofix): auto-rerun a check that died on infrastructure, once (#7562)

* feat(autofix): auto-rerun a check that died on infrastructure, once

A failed check can be red because the machine died, not the code — a
self-hosted runner losing the server, the disk filling. #7490's E2E
failed with "runner lost communication with the server" and went green
on a rerun. The scan now reruns such a check's failed jobs automatically.

Detection is a conservative annotation whitelist (INFRA_FAILURE_SIGNATURES)
— only unambiguous machine failures, never a test-level timeout, which
could be a real regression. The one-shot guard is run_attempt, not a
marker: a run already retried to attempt 2 and still infra-failing is
persistent, so it is left for a human; after a rerun the attempt
increments, so the next scan will not rerun it. Every step is fail-safe
(any API error → no rerun), it runs only when the PR actually has a
failed check, and the gate carries the same review-address carve-out as
the other check selectors so the loop never reruns its own runs.

This is the transient-infra sibling of #7554 (stale-base): that merges
current main when a check is base-inherited; this reruns when a check
died on the runner. Neither touches a check that is a genuine failure.

Note: rerun-failed-jobs needs the PAT to hold `actions: write`.

* fix(autofix): use POSIX ERE groups in infra-failure regex, cover all signatures in tests (#7562)

* fix(autofix): also treat a git fetch/clone transport death as infra

#6506's checkout died mid-transfer — "fetch-pack: invalid index-pack
output" and "RPC failed; curl 92 ... CANCEL" — which then hung the job
into the 20m limit. That is infra, not the PR (it only touches a doc),
and a re-run made it green. But the infra-signature whitelist did not
cover it, so the auto-rerun did not fire and it waited on a human.

Add `invalid index-pack output` and `RPC failed` — the two canonical
git-transport-death phrases — to INFRA_FAILURE_SIGNATURES. A co-present
job-timeout line does not block the match (one matching line classifies
the run), and a BARE timeout with no transport signature is still left
alone, since it can be a real regression. Both new signatures are pinned
in the test's per-signature loop, plus a case on #6506's real composite
annotation and a bare-timeout-is-not-rerun guard.

* fix(autofix): paginate annotations and filter Autofix runs in infra-rerun loop (#7562)

---------

Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>

* fix(serve): detect stale SSE cursors across daemon restarts via epoch token; preserve turn attribution and surface compaction failures in replay (#7458)

* fix(daemon): epoch-token restart detection, compaction attribution, and degraded-snapshot signaling (DAEMON-001/007/008)

* fix(acp-bridge): field-level turn attribution merge and replayDegraded bridge test (#7458)

* fix(serve): skip bus epoch lookup for virtual subagent SSE streams (#7458)

The REST SSE route looked up the bus epoch for every session id, but
virtual subagent sessions ride their own bus and their compound ids are
not in the bridge's byId map, so the lookup threw and aborted the
subscription — breaking subagent event streams. Skip the lookup for the
virtual path and degrade a torn-down real session to a headerless stream
(mirrors the /acp route). Also bumps the daemon browser SDK bundle budget
(167KB -> 168KB) for the epoch fields and declares eventEpoch on
DaemonSession so the create/attach path drops its inline type cast.

* fix(serve): stamp eventEpoch on accepted continuations and surface replayDegraded in the SDK (#7458)

Address three review suggestions:
- POST /session/:id/continue now returns eventEpoch alongside lastEventId,
  mirroring the prompt 202 envelope so continuation-seeded SSE cursors
  detect daemon restarts (DAEMON-001)
- DaemonSessionClient exposes replayDegraded from the load response so SDK
  consumers can prefer the full transcript over a degraded snapshot
- add /acp dispatch-level regression test for the degraded-snapshot stderr
  breadcrumb (fires only when snapshot.degraded is set)

* test(cli): fix load-reply race in the degraded-breadcrumb transport test

Await each session/load reply frame before opening the session stream so
the GET cannot race conn.ownSession() into a 403; addresses the review
Critical on the deg-0 arm.

* fix(serve): allow and expose X-Qwen-Event-Epoch in CORS headers

Cross-origin SSE clients must send the epoch header through preflight and
read it from the response, or stale-cursor detection (DAEMON-001) is
silently disabled for every CORS client.

---------

Co-authored-by: qwen-code-bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen Autofix <qwen-autofix[bot]@users.noreply.github.com>

* feat(core): Align GenAI telemetry with ARMS (#7536)

* feat(core): align GenAI telemetry with ARMS

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

* fix(core): remove estimated token usage splits

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

* fix(core): address GenAI telemetry review feedback

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

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(serve): avoid TOCTOU race dropping live sessions from list response (#7556)

* Initial plan

* fix(serve): avoid TOCTOU race dropping live sessions from list response

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: 易良 <1204183885@qq.com>

* fix(cli): prevent monitor turns after task_stop (#7573)

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix[bot]@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: destire-mio <qppque@gmail.com>
Co-authored-by: destire-mio <248462155+destire-mio@users.noreply.github.com>
Co-authored-by: Dragon <52599892+DragonnZhang@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: 易良 <1204183885@qq.com>
Co-authored-by: jinye <djy1989418@126.com>
Co-authored-by: chinesepowered <nlai@rediffmail.com>
Co-authored-by: ovochouovo <18212194+ovochouovo@users.noreply.github.com>
Co-authored-by: Edenman <67549719+BZ-D@users.noreply.github.com>
Co-authored-by: 克竟 <dingbingzhi.dbz@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: ytahdn <1294726970@qq.com>
Co-authored-by: ytahdn <ytahdn@gmail.com>
Co-authored-by: Truraly <94105924+Truraly@users.noreply.github.com>
Co-authored-by: zjgzx1988 <zjgzx1988@hotmail.com>
Co-authored-by: hogeheer499-commits <hogeheer499@gmail.com>
Co-authored-by: hogeheer <267467744+hogeheer499-commits@users.noreply.github.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: wenshao <wenshao@example.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Nothing Chan <chenliu.cl@alibaba-inc.com>
Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: yuanyuanAli <135116774+yuanyuanAli@users.noreply.github.com>
Co-authored-by: verify <verify@local>
Co-authored-by: qqqys <qys177@gmail.com>
Co-authored-by: callmeYe <512217680@qq.com>
Co-authored-by: Qwen Autofix <qwen-autofix[bot]@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Thanks for the deep post-merge review — verified all six findings against the code and confirmed your analysis. Status, point by point:

Fixed on a follow-up branch (fix/daemon-epoch-cursor-followup, based on current main; PR incoming):

  • pre-release: fix ci #1 (🟡 SDK cursor survives an epoch swap) — confirmed real: onEpoch only refreshed lastSeenEpoch while the cursor is forward-only via Math.max, so a reconnect window with zero id-bearing frames leaves a dead-epoch cursor vouched for by the live token, and epochMismatch === false hands the decision back to the defeatable numeric heuristic. Fixed exactly as you suggested: onEpoch now resets lastSeenEventId = 0 when the learned epoch differs from a previously tracked one (undefined seed keeps existing semantics). Regression test drives the three-stream sequence (cursor→50 under E1, restart teaches E2 with 0 events, third reconnect) and asserts ('0', 'epoch-e2') instead of the buggy ('50', 'epoch-e2') — mutation-checked (fails with the fix reverted).

  • Where is the config saved? #2 (🟡 /acp pairs the client epoch with a server-recomputed cursor) — confirmed: the snapshot path overwrites the cursor with the snapshot high-water mark but still forwards the request-header epoch, and the live loop has no id <= lastDeliveredId skip, so a mismatch re-replays the just-translated snapshot range. Fixed by dropping the epoch alongside whenever the snapshot high-water mark overwrites the cursor. Regression test asserts subscribeEvents receives {lastEventId: <snapshot hwm>, epoch: undefined} when reconnecting with a stale cursor + epoch — also mutation-checked.

  • 如何自定义密钥文件 .env可能与其他文件冲突 #3 (🟢 DaemonSession.eventEpoch create seed is dead code) — confirmed (BridgeSession never carried the field). Took the 'stamp it on create' branch of your either/or since the SDK destructuring, the type, and the PR description all pointed that way: doSpawn and the attach-existing returns now stamp entry.events.epoch (the coalesce path spreads the doSpawn result; restore already carries it via replayFieldsFor), plus a test asserting spawn and attach report the same bus epoch.

Planned for the same follow-up PR: #4 (budget ledger comment — will reword to 'no bump needed'), #5 (detail doc vs. declaration), #6 (breadcrumb sessionId escaping via the in-package JSON.stringify idiom), and the route-level supertest asserting POST /session/:id/load actually serializes eventEpoch/replayDegraded — agreed that's the cheapest missing lock on the headline surface.

The forcePush note is recorded as pre-existing amplification, not addressed here.

qwen-code-dev-bot added a commit to he-yufeng/qwen-code that referenced this pull request Jul 23, 2026
PR QwenLM#7458 on main adopted a more structured approach to the same problem
PR QwenLM#7499 solves (preserving event attribution through turn compaction).
Resolved in favour of main's structured lastTurn/lastSessionId fields
plus captureTurnFields/captureSessionId helpers, removing the duplicate
spread operators both sides independently added to
makeMergedSessionUpdateEvent and mergeToolCallEvent.
@doudouOUC
doudouOUC deleted the fix/daemon-recovery-epoch-compaction branch July 23, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

serve: SSE resume cursor from a dead daemon epoch can silently mis-resume; compaction drops turn attribution and hides its own failures

5 participants