Skip to content

fix(web-shell): follow an accepted approval answer to its terminal operation - #13609

Merged
yiliang114 merged 2 commits into
QwenLM:mainfrom
yiliang114:feat/12867-d6-action-operation-tracking
Oct 7, 2026
Merged

yiliang114 merged 2 commits into
QwenLM:mainfrom
yiliang114:feat/12867-d6-action-operation-tracking

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

The WebShell's Java Managed Agent provider now follows an accepted approval answer until its operation settles. actions/respond answers 202 with an action_response command operation as soon as Java admits the answer. Until now the provider treated any non-failed 202 (pending or running) as applied, so the approval card disappeared at that point.

The provider now reads that same operation through operations/query with a bounded backoff (about 30 s in total) until it is terminal. It never re-sends the answer, so the original attempt and its idempotency key are preserved. A terminal failed, cancelled or recovery_blocked takes the existing error path, and the card comes back with the failure. An operation still pending/running when the budget runs out is reported as "not confirmed yet". The card returns, and answering again uses the same key (<actionId>:<optionId>), so Java replays the same operation and tracking resumes.

The client gains queryOperation. The provider refuses a read-back whose type is not action_response.

Why it's needed

This is the D6 gap the #12380 tracker lists as the clearest uncovered bounded slice. Java already returns the operation and serves /operations/query, but the client only looked at the immediate response. After admission an answer can still fail: R1-5 in #13163 is a revoked can_create during delivery backoff, which ends the operation FAILED workspace_unavailable. The design also keeps the operation running while the Action stays requested on a blocked Session (docs/design/2026-09-30-managed-agent-actions.md §6.2). In both cases the user saw the card vanish as if the answer had applied.

Reviewer Test Plan

How to verify

npx vitest run packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts:

  • The existing approval test now expects a running answer to be followed by an operations/query for the same {sessionId, operationId}, with no second actions/respond.
  • New, with fake timers: pending → running → failed(workspace_unavailable) rejects with that code after two reads.
  • New, with fake timers: an operation that stays running rejects as "not confirmed yet (operation op-1 is running)", and every call after the single answer is a read of that operation.

Evidence (Before & After)

N/A, no visual change. Before: a 202 running answer resolved immediately, and a later failure was invisible. After: the card stays hidden while the answer settles, and returns with the error if it fails or stays unconfirmed.

Tested on

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

Environment (optional)

Not run locally. Verification is the CI Test (ubuntu-latest) web-shell suite. Prettier (--experimental-cli, 3.6.1) is clean on the changed files.

Risk & Scope

  • Main risk or tradeoff: while an answer settles (at most ~30 s), its card stays hidden, as answered approvals already did. Each poll is one small authorized read. The 30 s budget is a client choice; Java's own delivery backoff is unchanged.
  • Not validated / out of scope: the policy for a fresh attempt after a definitive failure, input previews, and resuming tracking after a page reload. These stay separate D6 items in feat(managed-agent): Stage D follow-ups for durable lifecycle, Turns, Actions, durable admission and AgentDefinition #12867, as the tracker asks. No Java, contract or Harness change. Not exercised against a real Java stack.
  • Breaking changes / migration notes: none.

Linked Issues

Part of #12867 (D6 accepted-response operation tracking, from the #12380 snapshot).

中文说明

本 PR 做了什么

WebShell 的 Java Managed Agent provider 现在会一直跟踪一个已被接受的审批答复,直到它的 operation 结束。actions/respond 在 Java 接纳答复后就返回 202 和一个 action_response 命令 operation。此前 provider 把所有未失败的 202(pending 或 running)都当作已生效,审批卡片在这一刻就消失了。

现在 provider 会通过 operations/query 读取同一个 operation,带有有界退避(总共约 30 秒),直到它进入终态。它不会重发答复,因此原始那次尝试及其幂等键都被保留。终态为 failed、cancelled 或 recovery_blocked 时走已有的报错路径,卡片带着失败原因重新出现。预算耗尽时如果 operation 仍是 pending/running,会报告"尚未确认",卡片重新出现;再次作答使用同一个键(<actionId>:<optionId>),Java 会重放同一个 operation,跟踪随之继续。

客户端新增 queryOperation;provider 会拒绝 type 不是 action_response 的读回结果。

为什么需要

这是 #12380 跟踪表列出的、最清楚且尚无实现的有界 D6 切片。Java 已经返回 operation,也提供了 /operations/query,但客户端只看即时回复。答复被接纳之后仍然可能失败:#13163 的 R1-5 就是在投递退避期间撤销 can_create,operation 最终以 FAILED workspace_unavailable 结束。设计中还规定,Session 被阻塞、Action 仍处于 requested 时,operation 保持 running(docs/design/2026-09-30-managed-agent-actions.md §6.2)。这两种情况下,用户看到的都是卡片消失,好像答复已经生效。

评审测试计划

如何验证

npx vitest run packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts:

  • 原有的审批用例现在要求:答复返回 running 后,接着用同一个 {sessionId, operationId} 调用 operations/query,并且不会再发第二次 actions/respond。
  • 新增用例(假定时器):pending → running → failed(workspace_unavailable) 在两次读取后以该错误码拒绝。
  • 新增用例(假定时器):一直处于 running 的 operation 会以"not confirmed yet (operation op-1 is running)"拒绝,并且在唯一一次答复之后,每次调用都是对该 operation 的读取。

前后对比

N/A,无界面变化。修改前:答复返回 202 running 就立即视为完成,之后的失败无从得知。修改后:答复结算期间卡片保持隐藏;如果失败或一直未确认,卡片会带着错误重新出现。

已测试平台

本地未运行,以 CI 的 Test (ubuntu-latest) web-shell 测试为准。改动文件已用 Prettier(--experimental-cli,3.6.1)检查通过。

风险与范围

  • 主要风险/权衡:答复结算期间(最长约 30 秒)卡片保持隐藏,与已答复的审批原本的行为一致。每次轮询都是一次轻量的已授权读取。30 秒预算是客户端的选择,Java 自身的投递退避不变。
  • 未验证/范围外:确定性失败后的新尝试策略、输入预览、页面刷新后恢复跟踪,按跟踪表的要求作为 feat(managed-agent): Stage D follow-ups for durable lifecycle, Turns, Actions, durable admission and AgentDefinition #12867 中独立的 D6 事项,不在本 PR 范围内。不涉及 Java、契约或 Harness 改动;没有在真实 Java 栈上验证。
  • 破坏性变更/迁移说明:无。

关联 Issue

#12867 的一部分(D6 已接受答复的 operation 跟踪,来自 #12380 的快照)。

…eration

actions/respond answers 202 once the answer is admitted, but delivery to
the Harness can still fail (a revoked Workspace grant), and the operation
stays running while the Session is blocked. The Java provider treated any
non-failed 202 as applied, so a later failure left the approval silently
unanswered. Read the same operation through operations/query until it is
terminal, keeping the original attempt and idempotency key; report an
answer still unsettled after ~30s as unconfirmed so the card returns and a
second click replays the same operation.
@qwen-code-review-bot

qwen-code-review-bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head b428c8c. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

collab-team-panel-light before/after

Full-resolution recordings (.webm) are attached to the workflow run.

— Qwen Code · web-shell visuals

…sertions

Both assertions are awaited only after the fake timers advance, so the
expect chain has to be stored in a variable. Mark them with the same
eslint-disable convention this repo already uses for that deferred-await
pattern (responses-pipeline.test.ts, acp-bridge bridge.test.ts) instead of
restructuring the tests, which would leave the rejection unhandled while
the timers run.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-conflict/jmuy7yk8zcm
@qwen-code-review-bot

qwen-code-review-bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Qwen Triage finished — CI landed green on b428c8c and the deferred approval was posted. finalize run

✅ Qwen Triage 已完成 —— b428c8c 的 CI 全绿,延迟审批已提交。查看 finalize 运行

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — all nine sections present, and the "Not validated / out of scope" list is unusually honest about what this does not cover.

Problem: real, and I verified it in the base code rather than taking the description's word. use-managed-actions.ts adds the actionId to answered before the await, and that set is only ever cleared on a sessionId change or for ids recorded in endedAnswers (i.e. answers that failed with a structured action_expired/action_cancelled/action_already_resolved code). The displayed card is actions.find(entry => !answered.has(entry.actionId)). So a 202 whose operation is still pending/running resolves as success, and if delivery later fails the card stays hidden for the rest of the session while the Action is still requested server-side. The user has no way to act. That is an observable defect, not theoretical hardening. docs/design/2026-09-30-managed-agent-actions.md §6.2 backs the mechanism ("A worker forwards it to the Harness route and returns a failed attempt to pending with the dispatch backoff"; "While the Action stays requested, for example on a recovery-blocked Session, the operation stays running"), and #12867 lists D6 as an open slice.

Direction: aligned. This closes a documented gap in an active workstream instead of inventing one, and it is client-side only — /operations/query already exists (WebShellAgentController.queryOperation, SurfaceRegistry, and the generated schema all carry it), so no Java, contract or Harness surface moves. Nothing here touches auth, sandbox, model selection, telemetry, release or a public contract, so no direction escalation. CHANGELOG: no direct reference, but the managed-agent Actions area is clearly current (design docs 2026-09-30 / 2026-10-02 / 2026-10-07, #12867 and #12380 both open).

Size: the core-module gate does not apply — all three files sit under packages/web-shell/client/components/managed/, which is not a core path, and the change stays inside one package. For context the split is 68 production lines (+8 client, +60 provider) against 113 test lines, so the diff is mostly tests.

Approach: scope feels right and I would not cut anything. Reading the same operation back is the minimal way to learn the outcome, and refusing to re-send the answer is what keeps the idempotency key and the original attempt intact — a retry-loop that re-answered would have been the obvious wrong turn here. Related D6 items (fresh-attempt policy after a definitive failure, input previews, resuming across a reload) are explicitly left in the tracker rather than folded in, which is the right call. The one alternative I considered was settling over the existing event stream instead of polling; that avoids the poll budget entirely but needs stream correlation and is well past the bounded slice #12867 asked for, so polling is the better trade.

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

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 九个章节齐全,"未验证/范围外"一节对没有覆盖的内容交代得相当诚实。

问题: 真实存在,我在基线代码里核实过,没有只采信描述。use-managed-actions.ts 在 await 之前就把 actionId 加入 answered,而这个集合只在 sessionId 变化时清空,或针对记录在 endedAnswers 里的 id(即带有结构化 action_expired/action_cancelled/action_already_resolved code 的失败答复)清空。展示的卡片是 actions.find(entry => !answered.has(entry.actionId))。因此一个 operation 仍为 pending/running 的 202 会被当作成功返回;如果投递随后失败,卡片在本会话内会一直隐藏,而服务端 Action 仍是 requested,用户无从操作。这是可观测的缺陷,不是理论性加固。docs/design/2026-09-30-managed-agent-actions.md §6.2 印证了机制("worker 把答复转发给 Harness,失败的尝试以投递退避退回 pending";"当 Action 仍为 requested(例如 Session 被恢复阻塞)时,operation 保持 running"),#12867 也把 D6 列为未完成切片。

方向: 对齐。这是在补齐一个有文档记录的缺口,而不是新造需求;且纯客户端改动 —— /operations/query 已经存在(WebShellAgentController.queryOperation、SurfaceRegistry、生成的 schema 都有),因此 Java、契约、Harness 三个面都没有变动。不涉及 auth、sandbox、模型选择、遥测、发布或公开契约,无需方向升级。CHANGELOG:无直接条目,但 managed-agent Actions 领域显然是当前活跃区(设计文档 2026-09-30 / 2026-10-02 / 2026-10-07,#12867 与 #12380 均为 open)。

规模: 核心模块门禁不适用 —— 三个文件都在 packages/web-shell/client/components/managed/ 下,不属于核心路径,且改动只在单个 package 内。供参考:生产代码 68 行(client +8、provider +60),测试 113 行,diff 主体是测试。

方案: 范围合理,我不会砍任何东西。回读同一个 operation 是获知结果的最小做法;而不重发答复正是保住幂等键与原始那次尝试的关键 —— 这里最容易走错的方向就是做一个重新作答的重试循环。相关的 D6 事项(确定性失败后的新尝试策略、输入预览、刷新后恢复跟踪)被明确留在跟踪表里而非塞进本 PR,这个取舍是对的。我唯一考虑过的替代方案是走已有事件流结算而不是轮询;那能完全省掉轮询预算,但需要流关联,远超 #12867 要求的有界切片,所以轮询是更好的权衡。

风险: 无升级风险信号 —— 改动文件都不匹配高回滚相关性路径。

进入代码审查 🔍

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

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

@qwen-code-review-bot

qwen-code-review-bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Code review

I wrote down my own fix before reading the diff: add a queryOperation to the client, poll the operation the 202 returned until it is terminal, bound the budget, never re-send so the idempotency key survives, and reuse the terminal-status error path that already exists. That is what this PR does, so I have no competing approach to argue for. The details hold up too:

  • The happy path costs nothing. The status check sits at the top of the loop body, so an admission response that is already terminal returns before any sleep or read. That is the easy thing to get wrong in a poll loop and it is right here.
  • The budget matches the claim. Nine delays totalling 29,750 ms — "about 30 s" is accurate, not aspirational.
  • The type guard is load-bearing, not decoration. /operations/query is declared to return WebShellOperation, which the generated schema defines as WebShellCwdOperation | WebShellCommandOperation, and the Java side returns Object branching on operation kind. So a cwd_change body is a real shape this call can produce. Rejecting read.type !== 'action_response' also gives TypeScript the discrimination it needs to narrow the union down to WebShellCommandOperation before the assignment to current.
  • The status test covers the whole enum. !== 'pending' && !== 'running' is exactly the complement of the non-terminal members of "pending" | "running" | "completed" | "failed" | "cancelled" | "recovery_blocked", and all three failure states already route through the existing throw.
  • The tests pin the claim that matters. ['/actions/respond', '/operations/query', '/operations/query'] proves the answer goes out exactly once, which is the whole point of reading back instead of retrying. The unconfirmed case additionally asserts every call after the first is a read. The eslint-disable vitest/valid-expect comments explain themselves.
  • Reuse is good throughout: the new client method follows respondAction/queryActions exactly, goes through the existing post() helper, and types itself off the generated WebShellOperationRequest/WebShellOperation rather than declaring a parallel shape. JavaCommandOperation is derived with Awaited<ReturnType<...>> instead of restating the schema type.

Three things worth raising, none of them blocking:

1. A definitive end-state can now reach the user as "retry the same option", and the classifier that exists to prevent that will not catch it. §6.2 says an operation "completes … with its end state (action_expired, action_cancelled or action_already_resolved) otherwise, exposed as failure_code (failureCode on WebShell)". The provider throws a plain Error with that code only interpolated into the message (Managed Agent approval answer failed (action_expired)), while endedAction() in use-managed-actions.ts reads a structured failure.code — the shape the client produces for an HTTP 409. So the mismatch is not new, but this PR widens it a lot: before, the admission 202 itself had to come back terminal, which is rare because §6.3 routes a late answer to a 409 (structured code, classifier fires). Now any Action that expires, is cancelled, or is answered elsewhere during the ≤30 s poll lands here instead — the card comes back saying "could not be confirmed, retry the same option" for a retry that cannot succeed, and stays until an unrelated re-read fires (action_expired self-heals via the EXPIRY_GRACE_MS timer; action_already_resolved does not). Attaching code: result.failureCode to the thrown error would let the existing classifier do its job. Small, and arguably its own D6 item — flagging so it is a decision rather than an accident.

2. Nothing tests the action_response guard. Given the union above, a cwd_change read-back is a shape the endpoint can genuinely return, and the guard is the only thing standing between it and a bogus status interpretation. One test with a cwd_change body would pin it.

3. Abort can lag by up to 8 s. signal?.throwIfAborted() runs after the sleep, so an abort during the longest delay is not noticed until that delay elapses. The in-flight read does abort, since the signal is forwarded to queryOperation. Cosmetic for a UI component; only worth changing if a caller ever needs prompt teardown.

Nit: the pre-existing approval test that gained a third mock sits outside the new fake-timers describe, so it now pays a real 250 ms wall-clock wait before the first query. Harmless, just a little slower.

One thing I checked because it looked like a UX regression and turned out not to be: on a recovery-blocked Session the operation can legitimately stay running past the budget (§6.2), so every answer there will now hide the card, wait ~30 s, and return it with an error. I expected the generic failure string to misdescribe that, but managed.approval.failed already reads "The approval answer could not be confirmed. Retry the same option or refresh to check its status." — which is precisely right for an unconfirmed answer, and "retry the same option" is exactly the same-key replay the PR relies on. The new outcome lands in a slot whose wording was already correct for it. That is the same idiom the composer uses for an unconfirmed submission, so it is consistent rather than novel.

The reshaped handshake, since the "admitted ≠ applied" distinction is the whole change:

sequenceDiagram
    participant P1 as WebShell approval card
    participant P2 as provider respond
    participant P3 as Java service
    P1->>P2: user picks an option (card hides immediately)
    P2->>P3: POST actions respond
    P3-->>P2: 202 with operation op-1, status running
    Note over P2: admitted, not applied - the old code stopped here
    loop bounded reads, 9 attempts, about 30s total
        P2->>P3: POST operations query (same sessionId and operationId)
        P3-->>P2: latest durable status
    end
    alt completed
        P2-->>P1: resolve, card stays hidden
    else failed, cancelled or recovery_blocked
        P2-->>P1: reject, card returns with the reason
    else still pending or running at budget end
        P2-->>P1: reject as not confirmed yet, same key replays on retry
    end
Loading

Testing evidence

This is an unattended CI run, so I did not build or execute anything from this PR — the evidence below is the PR's own CI on the reviewed commit, read through the API.

Test (ubuntu-latest, Node 22.x) is green, and that is the suite the test plan names (java-managed-agent-provider.test.ts lives in the web-shell package it runs). Lint & Static, Integration Tests (no-AK, No Sandbox) and Capture web-shell visuals are green too. web-shell E2E Smoke was still in flight at the time of writing; Qwen Code CI has one pending run, so this table is not final and the finalize job will refresh it.

The two Desktop Shell jobs are red and I could not classify them — see the caveat below the table, it is the one open item in this review.

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

Check Conclusion
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

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

Not verified: why Desktop Shell is red. Both jobs failed, and their logs and step details are not readable yet — gh run view --job 112863990457 --log-failed returns "run 37642027796 is still in progress; logs will be available when it is complete", and the jobs API returns an empty steps array for both. So I can offer the surrounding evidence but not the failure itself:

  • That job compiles and tests the Tauri crate under packages/desktop (cargo test --manifest-path src-tauri/Cargo.toml, plus scripts/test-release.js). Its own change filter only trips on ^packages/desktop/, the two create-*-manifest.mjs scripts, ci.yml and desktop-release.yml. This PR changes none of those — three TypeScript files under packages/web-shell/client/components/managed/ — so on the workflow's own logic every compile step should have been skipped and the job should have concluded success. That the job reached a failure at all is the anomaly, and it points at the job's detection or environment rather than at this diff. There is no mechanism I can find by which a provider polling change breaks a Rust crate.
  • It is not a repo-wide outage: the same two jobs are green on four concurrent PR runs (37642965340, 37642569789, 37642466887, 37641461342), and the job is skipped on main (PR-CI only).

I am deliberately not calling this pre-existing infra noise, because the concurrency evidence argues against that, and not calling it PR-caused, because the diff gives no mechanism. It needs a human eye on the log once the run finishes.

Sandboxed verification would settle the behavioural claim: @qwen-code /verify. Every test in this PR drives a mocked fetch, so what the suite proves is the shape of the polling — one answer, then reads — not that Java behaves the way the design assumes. Specifically unverified: that /operations/query for an action_response operationId really returns the command-operation shape rather than the cwd_change member of the union the guard defends against; that a delivery failure during the worker's dispatch backoff actually surfaces as terminal failed with a populated failureCode (the R1-5 path from #13163); and that ~30 s is a realistic budget against real Harness delivery rather than a guess that will mostly time out into "not confirmed yet". The PR itself states it was "Not exercised against a real Java stack" and "Not run locally" — that is the gap, and the author has write access, so /verify (or /tmux for the card's visible behaviour) can be triggered directly without a sponsored run.

中文说明

代码审查

我在读 diff 之前先写下了自己的方案:给 client 加 queryOperation,轮询 202 返回的那个 operation 直到终态,限定预算,不重发以保住幂等键,并复用已有的终态报错路径。这个 PR 做的正是这件事,所以我没有可争论的替代方案。细节也站得住:

  • 正常路径零成本。 状态检查放在循环体开头,所以入场响应本身已是终态时会在任何 sleep 或读取之前直接返回。轮询循环最容易写错的就是这一点,这里是对的。
  • 预算与描述一致。 九次延迟合计 29,750 ms —— "约 30 秒"是准确说法,不是愿景。
  • 类型守卫是承重的,不是装饰。 /operations/query 声明返回 WebShellOperation,生成的 schema 把它定义为 WebShellCwdOperation | WebShellCommandOperation,Java 侧返回 Object 并按 operation kind 分支。所以 cwd_change 响应体是这个调用真实可能产出的形态。拒绝 read.type !== 'action_response' 同时给了 TypeScript 收窄联合类型所需的判别依据,之后才能赋给 current。
  • 状态判断覆盖了整个枚举。 !== 'pending' && !== 'running' 恰好是 "pending" | "running" | "completed" | "failed" | "cancelled" | "recovery_blocked" 中非终态成员的补集,三个失败态都已走既有的 throw。
  • 测试钉住了关键主张。 ['/actions/respond', '/operations/query', '/operations/query'] 证明答复只发出一次 —— 这正是"回读而非重试"的全部意义。未确认用例还额外断言首次之后的每次调用都是读取。两处 eslint-disable vitest/valid-expect 都自带说明。
  • 复用做得好:新的 client 方法完全比照 respondAction/queryActions,走既有 post() 辅助函数,类型直接取自生成的 WebShellOperationRequest/WebShellOperation 而非另造一套;JavaCommandOperation 用 Awaited<ReturnType<...>> 推导,没有重述 schema 类型。

三点值得提出,都不构成阻塞:

1. 确定性终态现在可能以"重试同一选项"呈现给用户,而为此存在的分类器抓不到它。 §6.2 写明 operation "以其结束态(action_expired、action_cancelled 或 action_already_resolved)完成,并暴露为 failure_code(WebShell 上是 failureCode)"。provider 抛出的是普通 Error,code 只被插值进消息文本(Managed Agent approval answer failed (action_expired)),而 use-managed-actions.ts 里的 endedAction() 读的是结构化的 failure.code —— 也就是 client 为 HTTP 409 产出的形态。所以这个不匹配不是新问题,但本 PR 大幅拓宽了它:此前必须入场 202 本身就是终态,而这很罕见,因为 §6.3 会把迟到的答复路由到 409(结构化 code,分类器生效)。现在只要 Action 在这 ≤30 秒轮询期间过期、被取消或在别处被回答,就会落到这里 —— 卡片带着"无法确认,请重试同一选项"回来,而这次重试不可能成功,并且会一直挂着直到某次无关的重新读取触发(action_expired 能靠 EXPIRY_GRACE_MS 定时器自愈,action_already_resolved 不能)。给抛出的错误附上 code: result.failureCode 就能让既有分类器正常工作。改动很小,也可以作为独立的 D6 事项 —— 提出来是为了让它成为一个决定,而不是一个意外。

2. action_response 守卫没有测试。 鉴于上面的联合类型,cwd_change 回读是该端点确实可能返回的形态,而这个守卫是它与"错误解读状态"之间唯一的屏障。一个用 cwd_change 响应体的测试就能钉住。

3. abort 最多滞后 8 秒。 signal?.throwIfAborted() 在 sleep 之后执行,所以在最长那次延迟期间发生的 abort 要等延迟结束才会被发现。进行中的读取确实会 abort,因为 signal 被转发给了 queryOperation。对一个 UI 组件来说属于表面问题;只有当某个调用方需要迅速拆除时才值得改。

小问题:那个新增了第三个 mock 的既有审批用例位于新的假定时器 describe 之外,所以现在会真实等待 250 ms 才发出首次查询。无害,只是稍慢。

有一处我因为看起来像 UX 回归而专门核查、结果发现不是:在被恢复阻塞的 Session 上,operation 可以合法地在预算耗尽后仍为 running(§6.2),所以那里每次作答都会先隐藏卡片、等约 30 秒、再带着错误把卡片还回来。我原以为通用的失败文案会误述这种情况,但 managed.approval.failed 本身就是"无法确认审批回答的结果。请重试同一选项,或刷新以查看状态。"—— 对一个未确认的答复来说恰好准确,而"重试同一选项"正是本 PR 依赖的同键重放。新的结果落在一个措辞本就正确的槽位里。这与 composer 对未确认提交所用的说法是同一套习语,所以是一致而非新造。

改动重塑后的握手流程如下,因为"已接纳 ≠ 已生效"这个区分正是整个变更的核心:

(时序图见上,中文不重复)

测试证据

这是无人值守的 CI 运行,所以我没有构建或执行本 PR 的任何代码 —— 下面的证据是通过 API 读到的、该 PR 自己在被审查 commit 上的 CI 结果。

Test (ubuntu-latest, Node 22.x) 为绿,而这正是测试计划点名的套件(java-managed-agent-provider.test.ts 属于它所运行的 web-shell package)。Lint & Static、Integration Tests (no-AK, No Sandbox)、Capture web-shell visuals 也都是绿的。撰写时 web-shell E2E Smoke 仍在运行,Qwen Code CI 还有一个未完成的 run,因此这张表不是最终状态,finalize 任务会刷新它。

两个 Desktop Shell 任务是红的,而我无法归类 —— 见表格下方的说明,这是本次审查唯一悬而未决的事项。

未验证:Desktop Shell 为何变红。 两个任务都失败了,而它们的日志和步骤详情目前读不到 —— gh run view --job 112863990457 --log-failed 返回"run 37642027796 is still in progress; logs will be available when it is complete",两个任务的 jobs API 也都返回空的 steps 数组。所以我只能给出周边证据,给不出失败本身:

  • 该任务编译并测试 packages/desktop 下的 Tauri crate(cargo test --manifest-path src-tauri/Cargo.toml,外加 scripts/test-release.js)。它自己的变更过滤器只在 ^packages/desktop/、两个 create-*-manifest.mjs 脚本、ci.yml 和 desktop-release.yml 上触发。本 PR 一个都没改 —— 只改了 packages/web-shell/client/components/managed/ 下的三个 TypeScript 文件 —— 所以按工作流自身的逻辑,所有编译步骤都该被跳过、任务应以 success 结束。这个任务能走到失败本身就是异常,指向任务的检测或环境,而不是这份 diff。我找不到任何机制能让一个 provider 轮询改动弄坏一个 Rust crate。
  • 这不是仓库级故障:同样这两个任务在四个并发的 PR run 上是绿的(37642965340、37642569789、37642466887、37641461342),而在 main 上该任务被跳过(仅 PR CI 运行)。

我刻意不把它称为既有基础设施噪声,因为并发证据不支持;也不称其为本 PR 引起,因为 diff 给不出机制。run 结束后需要人看一眼日志。

沙箱验证可以坐实这个行为主张: @qwen-code /verify。本 PR 的每个测试都驱动 mock 的 fetch,所以套件证明的是轮询的形状(一次答复,随后是读取),而不是 Java 的行为符合设计假设。具体未验证的有:对一个 action_response 的 operationId 调用 /operations/query 是否真的返回 command-operation 形态,而不是守卫所防的联合类型中的 cwd_change 成员;worker 投递退避期间的失败是否真的以终态 failed 且 failureCode 非空的形式浮现(即 #13163 的 R1-5 路径);以及约 30 秒相对真实 Harness 投递是否是现实预算,还是一个大概率超时落到"not confirmed yet"的猜测。PR 自己也写明"没有在真实 Java 栈上验证""本地未运行"—— 这就是缺口所在。作者有写权限,因此可以直接触发 /verify(或用 /tmux 看卡片的可见行为),无需 sponsored run。

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

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

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the code is solid and I found no blocker; the missing fifth is a CI signal I could not read, not a doubt about the change.

Stepping back: this is what a well-scoped slice of a tracked workstream looks like. I went looking for the usual problems and did not find them. The diff is 68 production lines against 113 test lines, every one of which serves the stated goal — no drive-by refactor, no formatting churn, no opportunistic "while I was in here". The adjacent D6 items that would have been tempting to bundle (fresh-attempt policy, input previews, reload-resume) are explicitly left in #12867 instead. The new code reuses what exists rather than standing up a parallel mechanism: same client method shape as its two neighbours, same post() helper, types taken from the generated schema, and the terminal-status throw that was already there.

I also checked the part I was most suspicious of, because "poll until settled" is where a change like this usually goes wrong. It does not. The status check sits at the top of the loop body, so an already-terminal admission response returns without a single sleep — the common case pays nothing. The budget is nine reads totalling 29,750 ms, so "about 30 s" is a measured claim rather than a round number. And the thing that makes the whole approach safe — never re-sending the answer, so the original attempt and its idempotency key survive — is exactly what the tests pin, via a URL sequence that proves one answer and nothing but reads afterwards.

The bug itself is real, and I confirmed it in the base code rather than accepting the framing: answered is only reset on a session change or for ids that failed with a structured end-state code, so a 202 that is still running hides the card for the rest of the session even when the answer never applies. §6.2 of the Actions design confirms the operation legitimately stays running while the Action is requested. Nothing theoretical about it.

What keeps this at 4 rather than 5 is honest uncertainty, not a defect I am waving through:

  • Two Desktop Shell checks are red and I could not classify them. Logs and step details are unavailable while the run is in progress. The evidence cuts both ways — the job's own path filter does not match any file this PR touches and there is no mechanism I can find from a TypeScript polling change to a Rust Tauri crate, but the same job is green on four concurrent PRs, so I cannot write it off as infra noise either. Someone should read that log.
  • The behavioural claim rests entirely on mocked fetch. The suite proves the polling shape; it cannot prove Java returns the command-operation member of the union, that a backoff failure surfaces as terminal failed with a populated failureCode, or that ~30 s is realistic against a real Harness. The PR says so plainly, which I appreciate, but saying so is not the same as settling it — @qwen-code /verify would.
  • Finding 1 in the review above is a real, newly-widened gap (a definitive action_expired/action_cancelled/action_already_resolved end-state arriving as a plain Error bypasses endedAction(), so the user is offered a retry that cannot succeed). It is small and reasonably a follow-up, but I would not want it silently dropped — worth a line in feat(managed-agent): Stage D follow-ups for durable lifecycle, Turns, Actions, durable admission and AgentDefinition #12867 either way.

Would I maintain this in six months without cursing the author? Yes. The polling budget is a named constant with a comment explaining why it is shaped that way, the doc comment on settleActionResponse records the reasoning and the tracker reference, and the failure modes are enumerated rather than discovered later. That is the part that usually rots, and it is handled here.

Verdict: approve, deferred until CI lands green on b428c8c7cb8c55d1627eebfbb26b9e26ad9924d1. One pull_request run is still in flight (Qwen Code CI, with web-shell E2E Smoke running) and two checks are already red, so I am not posting an approval in this run — approving now would attest to a result that does not exist yet. The marker below lets the finalize job post the same commit-pinned approval once every check on that SHA is green, and it withholds and flags if anything stays red or the head moves. Given the Desktop Shell reds, the likely outcome is that this needs a human to read that log rather than an automatic approval — which is the correct result, not a failure of the gate.

中文说明

信心度:4/5 —— 代码扎实,我没有发现阻塞项;少的那一分来自一个我读不到的 CI 信号,而不是对这个改动本身的怀疑。

退一步看:这是一个被跟踪的工作流里、切片划分得当的 PR 该有的样子。我按常见的毛病逐条去找,都没有找到。diff 是 68 行生产代码对 113 行测试,每一行都服务于既定目标 —— 没有顺手重构,没有格式抖动,没有"既然改到这里就顺便"。邻近那些很容易被捆绑进来的 D6 事项(确定性失败后的新尝试策略、输入预览、刷新后恢复)被明确留在 #12867 里。新代码复用既有实现而不是另起一套机制:client 方法与它的两个邻居形态一致,走同一个 post() 辅助函数,类型取自生成的 schema,终态报错沿用原本就有的那个 throw。

我最怀疑的部分也专门查了,因为"轮询到结算"正是这类改动最容易出错的地方。它没有出错。状态检查位于循环体开头,所以入场响应本身已是终态时不会有任何 sleep —— 常见路径零成本。预算是九次读取合计 29,750 ms,因此"约 30 秒"是实测说法而非凑整。而让整个做法成立的那一点 —— 绝不重发答复,从而保住原始尝试及其幂等键 —— 正是测试钉住的东西:URL 序列证明只有一次答复,其后全是读取。

bug 本身是真实的,我在基线代码里确认过,没有直接采信 PR 的表述:answered 只在会话切换时重置,或针对以结构化终态 code 失败的 id 重置,所以一个仍为 running 的 202 会让卡片在余下的会话里一直隐藏,即使答复从未生效。Actions 设计文档 §6.2 确认:当 Action 仍为 requested 时,operation 合法地保持 running。这里没有任何理论成分。

让它停在 4 分而不是 5 分的,是诚实的不确定性,而不是我放水的缺陷:

  • 两个 Desktop Shell 检查是红的,而我无法归类。 run 仍在进行,日志与步骤详情都读不到。证据是双向的 —— 该任务自己的路径过滤器不匹配本 PR 改动的任何文件,我也找不到从 TypeScript 轮询改动到 Rust Tauri crate 的机制;但同一个任务在四个并发 PR 上是绿的,所以我也不能把它当作基础设施噪声一笔勾销。需要有人去看那份日志。
  • 行为主张完全建立在 mock 的 fetch 上。 套件证明的是轮询形状;它无法证明 Java 返回的是联合类型中的 command-operation 成员,无法证明退避失败会以终态 failed 且 failureCode 非空的形式浮现,也无法证明约 30 秒相对真实 Harness 是现实的。PR 自己坦率说明了这一点,我很认可,但"说明"与"坐实"不是一回事 —— @qwen-code /verify 才能坐实。
  • 上文审查中的第 1 条发现是一个真实且被新近拓宽的缺口(确定性的 action_expired/action_cancelled/action_already_resolved 终态以普通 Error 抵达,绕过 endedAction(),于是用户被提供一个不可能成功的重试)。它很小,作为后续事项处理是合理的,但我不希望它被悄悄丢掉 —— 无论如何值得在 feat(managed-agent): Stage D follow-ups for durable lifecycle, Turns, Actions, durable admission and AgentDefinition #12867 里记一行。

六个月后我来维护这份代码,会骂作者还是会谢作者?会谢。轮询预算是一个具名常量,带注释解释了它为何是这个形状;settleActionResponse 的文档注释记录了推理过程和跟踪表引用;失败模式是被列举出来的,而不是日后才被发现。这正通常最容易腐坏的部分,这里处理好了。

结论:批准,但延迟到 CI 在 b428c8c7cb8c55d1627eebfbb26b9e26ad9924d1 上变绿。 还有一个 pull_request run 在跑(Qwen Code CI,其中 web-shell E2E Smoke 正在执行),且已有两个检查是红的,所以本次运行我不会发批准 —— 现在批准等于为一个尚不存在的结果背书。下面的标记让 finalize 任务在该 SHA 上所有检查变绿后发出同样的、绑定 commit 的批准;若有检查保持红或 head 发生移动,它会拒绝发出并做标记。鉴于 Desktop Shell 的红,最可能的结果是这件事需要人去看那份日志,而不是自动批准 —— 这是正确的结果,而不是门禁失效。

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

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

@qwen-code-review-bot qwen-code-review-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 — CI landed green after the review. ✅

@yiliang114
yiliang114 enabled auto-merge October 7, 2026 16:21

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — round 1

Tier: Standard (feature — new polling loop in a web-shell client path; no persisted format, no trust-boundary change, no auth handling).

Verdict: No blocking findings. Approval blockers: none.


Scope

Reviewed: (new settleActionResponse function + respond changes), java-managed-agent-client.ts (new queryOperation method), java-managed-agent-provider.test.ts (updated existing test + two new tests). Cross-file: managed-agent-provider.ts (interface contract), use-managed-actions.ts (only caller of respond), managed-approval.ts, package.json exports.

Not reviewed: managed-agent-api.ts schema (reviewed only the relevant WebShellOperation, WebShellCommandOperation, WebShellOperationRequest entries). Execution rungs: not run locally; no working tree available. See disclosure below.


Checks performed

Class 1 — writer/reader contract: respondAction returns WebShellCommandOperation; queryOperation returns WebShellOperation (the union WebShellCwdOperation | WebShellCommandOperation). The read.type !== 'action_response' guard at provider.ts line 72 correctly narrows the union and rejects a cwd_change operation being accepted as an action-response. Downstream in respond, after settleActionResponse returns, only terminal statuses can have been returned; the failed | cancelled | recovery_blocked check is complete for the error path; completed falls through silently, which is the correct success path.

Class 2 — API compatibility: queryOperation is an additive method on the already-exported JavaManagedAgentClient. No barrel re-export was changed, no existing method signature was altered. No breaking change to any external consumer.

Class 3 — error handling: settleActionResponse propagates errors from queryOperation without swallowing. No init-promise cached on failure. Abort signal is checked (signal?.throwIfAborted()) immediately after each timer fires and before each network request.

Class 6 — resource lifecycle: setTimeout is not retained; no listener is attached. No leak path.

Class 7 — sibling entrances: Only one site in the provider calls respondAction (the respond function on line 111). settleActionResponse is only reachable from there.

Stated intent vs. code (class 10):

  • PR says "never re-sends the answer": confirmed — settleActionResponse calls only queryOperation, never respondAction again.
  • PR says "bounded backoff ~30 s": delays 250+500+1000+2000+2000+4000+4000+8000+8000 = 29,750 ms. ✓
  • PR says "refuses a read-back whose type is not action_response": confirmed at provider.ts line 72.

Test validity:

  • Reverted-fix probe (static): without settleActionResponse, respondAction returns {status:'pending'} → respond returns void → the new tests' .rejects.toThrow(…) assertions fail because the promise resolves. Both new tests are efficacious.
  • Existing modified test: the actions/respond mock now returns {status:'running'}, requiring one real 250 ms poll cycle before completed is returned. No fake-timer setup in that test path; the 250 ms real-time wait is within normal test-suite tolerance.

Cross-check: One prior review from qwen-code-review-bot (APPROVED, "LGTM, looks ready to ship — CI landed green"). No findings to reconcile; their conclusion agrees with mine.


Unreviewed dimensions

  • Local execution was not run (no working tree). Rung 1–3 not run. Not added to approvalBlockers because no open finding required execution to resolve, and the PR author noted CI as the verification path.

Reviewed with AI assistance.

@yiliang114
yiliang114 added this pull request to the merge queue Oct 7, 2026
Merged via the queue into QwenLM:main with commit 57e347f Oct 7, 2026
60 of 62 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants