Skip to content

fix(web-shell): restore the Managed approval retry budget on reload - #13294

Merged
yiliang114 merged 2 commits into
mainfrom
fix/12867-managed-approval-r5-follow-ups
Oct 4, 2026
Merged

yiliang114 merged 2 commits into
mainfrom
fix/12867-managed-approval-r5-follow-ups

Conversation

@yiliang114

@yiliang114 yiliang114 commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

Lands the two candidates from wenshao's round-5 real-stack verification of #13107, applied unchanged:

  • Refresh restores the approval retry budget (R1-6). A page Refresh now resets the Managed approval read's retry budget, so a read that fails right after the reload is retried and the pending approval comes back once the service recovers.
  • Three approval paths are pinned by tests. An answer refused with action_already_resolved (another tab or the REST API already decided) drops the card without a warning; and the card keeps to its own Turn when a later Turn reuses the same tool call ID, for both kinds of transcript rows.

Why it's needed

Since the R1-1 fix in #13107, Refresh drops the Session summary, so the Actions reader sees an unknown capability and returns early without resetting its failure counter. After the background retries had been spent, the reload's first failed read scheduled no retry: on the real stack, four 503s, Refresh, and one more 503 left the panel without the card for 15 seconds while the service was already healthy. With this fix the card came back after 2 seconds. The retries stay bounded, because only a reload, a user action, resets the budget.

The mutation run on #13107's final head left three real gaps: deleting action_already_resolved from the ended codes, which is the code Java actually returns for a stale answer, and removing the Action-Turn guard for either row kind, all kept every test green.

Reviewer Test Plan

How to verify

  • Exhaust the approval read's bounded retry ladder with four 503 responses. Refresh the Session summary, let the next read fail once more, then restore the service. Expect the pending approval to recover on the next retry after about 2 seconds without clicking Retry loading approvals.
  • If another client has already decided an approval, answering the stale card should remove it without an answer warning after the service returns 409 action_already_resolved.
  • When later Turns reuse a tool call ID, a Turn-scoped approval must show its own Turn's tool arguments in both transcript-row forms. The added unit regressions pin those cases; retrying an outage still has a bounded ladder.

Evidence (Before & After)

Historical candidate evidence from wenshao's round 5 on #13107's final head 72608f2c03 (evidence):

  • R1-6 on the real page: before, 5 reads and no card for 15 s; after, 6 reads and the card back after 2 s.
  • The new R1-6 test fails without the fix (expected "spy" to be called 6 times, but got 5 times). With the candidates, managed passed 199/199 and 200/200, and eslint, prettier and tsc were clean.
  • The test candidate kills mutants N1, A3 and A8.

R1-6

Current-head verification at 972aec3134: actual browser clicks with a real Managed Java API reproduced the baseline's five reads and 15.15-second load error; the current head made the sixth read and recovered the approval 2.15 seconds after Refresh. A real stale-answer browser request returned 409 and removed the card. Current-head test report and four inspected original screenshots records the baseline identity, fault injection, limits and cleanup. This verifies the UI/API boundary using seeded records; it does not claim Hosted worker settlement or model execution.

Tested on

OS Status
🍏 macOS ✅ (current-head local browser/API regression)
🪟 Windows ⚠️
🐧 Linux ⚠️ CI

Environment (optional)

The comparison uses the exact production hook from merge base 576689d073 and current head 972aec3134, with the same Session, service, product components and build stylesheet. The local current-head full build/typecheck and four focused Web Shell files passed 100/100 tests; replacing the hook with the baseline made the reload regression fail, and restoring candidate bytes made it pass. The UI connects to a real Spring service compiled from this head, an isolated H2 database and a test principal. The proxy injects five 503 responses and pauses SSE for the stale-card case; a fixture commits the authority decision after 202 admission. No Hosted worker executes a tool. Test processes and browser space were cleaned.

Risk & Scope

Linked Issues

Follow-up to #13107. Part of #12867.

中文说明

这个 PR 做了什么

原样合入 wenshao 对 #13107 第五轮真实环境验证中给出的两个候选补丁:

  • Refresh 恢复审批读取的重试次数(R1-6)。 页面 Refresh 现在会重置 Managed 审批读取的重试计数,因此刷新后紧接着失败的那次读取会继续重试,服务恢复后待处理的审批会重新显示。
  • 用测试锁住三条审批路径。 回答被 action_already_resolved 拒绝(另一个标签页或 REST API 已经作出决定)时,卡片直接消失且不报警;后面的 Turn 复用同一个工具调用 ID 时,卡片仍然匹配自己所属 Turn 的那一行,两种 transcript 行都覆盖。

为什么需要

#13107 的 R1-1 修复之后,Refresh 会先丢掉 Session 摘要,Actions 读取看到能力未知就提前返回,不会重置失败计数。后台重试用完之后,刷新后的第一次读取如果失败,就不会再安排重试:真实环境里连续 4 次 503、点 Refresh、再失败一次,服务其实已经恢复,面板却有 15 秒看不到卡片。修复后卡片 2 秒就回来了。重试仍然有上限,因为只有用户主动刷新才会重置计数。

对 #13107 最终 head 做的变异测试留下三处真实缺口:从已结束错误码里删掉 action_already_resolved(这正是 Java 对过期回答实际返回的错误码),或者对任一种行去掉 Action 所属 Turn 的校验,所有测试都仍然通过。

Reviewer 测试计划

如何验证

  • 用四次503 耗尽审批读取的有界重试阶梯。刷新 Session 摘要,让下一次读取再失败一次,然后恢复服务。应在约2 秒后的下一次重试恢复待处理审批,无需点击“重试加载审批”。
  • 另一个客户端已决定审批时,回答过期卡片,服务应返回409 action_already_resolved,卡片随后消失且不留下回答错误提示。
  • 后面的 Turn 复用工具调用 ID 时,带 Turn 的审批仍应显示自己所属 Turn 的工具参数,两种 transcript 行都适用。新增单测锁住这些路径;服务故障时的重试仍有上限。

证据(修复前后)

历史候选证据来自 wenshao 在 #13107 最终 head 72608f2c03 上的第五轮(证据):

  • 真实页面上的 R1-6:修复前 5 次读取、15 秒无卡片;修复后 6 次读取、2 秒后卡片恢复。
  • 新的 R1-6 测试在没有修复时失败(expected "spy" to be called 6 times, but got 5 times)。应用候选补丁后 managed 分别为 199/199 和 200/200,eslint、prettier、tsc 均通过。
  • 测试补丁能杀死变异 N1、A3 和 A8。

当前提交 972aec3134 的验证使用真实浏览器点击和 Managed Java API:基线停在五次读取并保持15.15 秒加载错误;当前提交发出第六次读取,在刷新后2.15 秒恢复审批。真实过期回答请求返回409 并移除卡片。当前提交测试报告与四张已检查原始截图 记录了基线身份、故障注入、限制与清理。这使用预置记录验证 UI/API 边界,不代表已验证 Hosted worker 结算或模型执行。

测试平台

macOS:✅(当前提交本地浏览器/API 回归);Windows:⚠️ 未本地验证;Linux:⚠️ 以 CI 为准。

环境

比较使用合并基线 576689d073 与当前提交 972aec3134 的精确生产 hook,复用同一 Session、服务、产品组件和构建样式。本地当前提交完整构建与类型检查通过,四个 Web Shell 文件100/100 测试通过;换回基线 hook 后刷新回归变红,恢复当前字节后通过。UI 连接由该提交编译的真实 Spring 服务,使用隔离 H2 数据库与测试身份。代理注入五次503,并为过期卡片场景暂停 SSE;fixture 在202 接纳后提交权威裁决。没有 Hosted worker 执行工具。测试进程与浏览器空间已清理。

风险与范围

关联 Issue

#13107 的后续。属于 #12867。

Since R1-1, a page Refresh drops the Session summary, so the Actions reader
sees enabled === undefined and returns early, never reaching the branch that
resets loadFailures. After the retry ladder had been spent, the reload's
first failed read scheduled no retry and the card stayed away until the next
event, even though the service was healthy again.

Reset the budget in the unknown-capability branch too. Only a reload, a user
action, resets it, so the ladder stays bounded.

Candidate patch from wenshao's round-5 real-stack verification of #13107.
- action_already_resolved, the code Java returns when a stale tab answers an
  Action another client already decided, drops the card without a warning.
- findManagedApprovalTool keeps to the Action's Turn when a later Turn reuses
  the call ID, for both callId-keyed and itemId-keyed rows.

Candidate tests from wenshao's round-5 mutation run of #13107.

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

APPROVE at 972aec31. The production change is five lines and it completes a policy the adjacent branch already documented rather than introducing one. 0 threads were filed before this review; CI is 14 pass / 0 fail with one check queued.

The inconsistency this closes was already named in the file

The branch immediately above (:113-117) resets the budget when the reader is withdrawn, with the reasoning spelled out:

A withdrawn reader is not a failed read: a restored reader gets the whole retry budget back instead of a single attempt with no ladder.

enabled === undefined is the same kind of early return — the hook's own doc comment says so at :59-61: "enabled is undefined while the Session summary is unknown, for example during a reload: the shown approval stays and can be answered, and reads resume once the capability is known." And it is a genuinely separate branch, not a subset of the first: reader = enabled === false ? undefined : provider.actions (:70), so with enabled undefined the reader is still present and !reader does not fire. Two early returns that both mean "nothing failed here", only one of which restored the budget.

The failure chain follows mechanically. loadFailures is a useRef (:84) that indexes the ladder at :164 (LOAD_RETRY_DELAYS_MS[loadFailures.current]), and when the index runs past the array delay is undefined so no timer is scheduled and retries stop for good. So: an earlier outage exhausts the ladder → the Session summary becomes unknown on reload → the effect re-runs and returns early without resetting → the summary resolves, enabled becomes true, the effect re-runs → listPending fails once → LOAD_RETRY_DELAYS_MS[N] is undefined → no further attempt. The user reloaded precisely to recover and gets one shot with no ladder, which is the exact outcome the neighbouring comment calls wrong. The manual Retry button (:264-267) does reset, so this was not permanently unrecoverable — but reload is the gesture a user actually makes, and it silently did nothing.

No unbounded-retry hazard, for two independent reasons

Resetting a budget invites the question of whether it can now retry forever, so I checked rather than assumed:

  1. The reset sits on a branch that returns without issuing a read, so it cannot itself start any attempt. Restoring the ladder only matters once enabled resolves to true, and enabled is driven by the Session summary becoming known — a one-shot resolution per reload, not a poll this effect feeds.
  2. The case the ladder exists to protect against is handled independently of the budget: if (isNonRetryableClientError(failure)) return; at :163 short-circuits a deterministic 4xx before any delay is computed, with the comment naming why ("retrying it only burns requests, and the Retry button would keep offering an attempt that cannot succeed"). So a backend that answers the same way every time still gets no retries however often the budget is restored.

The test fails if the fix is reverted

restores the retry budget after a reload of the Session summary exhausts the ladder first (toHaveBeenCalledTimes(4) — one attempt plus three retries), then hook.rerender({ enabled: undefined }) to model the reload, then asserts the count reaches 5 and 6 with loadError undefined at the end. That last step is the load-bearing one: without the reset at :122 the counter is still past the array, so the post-reload read fails once (call 5), LOAD_RETRY_DELAYS_MS[4] is undefined, and call 6 never happens. Asserting the count rather than the internal counter is also the right choice — it observes the retry actually being scheduled instead of the ref's value.

The two companions (ignores a call with the same ID in a later Turn, drops an approval another client already answered) match the second commit's stated purpose of pinning paths where mutants survived, and at +91/-1 the test weight is proportionate to a five-line production change.

Vote effect

All three files are under packages/web-shell/client/components/managed/, which no CODEOWNERS rule covers, and reviewRequests is empty — so unlike a /packages/core/ change this one has no outstanding request pinning the decision. required_approving_review_count is 1.

One state note, since it is visible on the PR: @qwen-code-ci-bot approved at 14:03, yet reviewDecision was still REVIEW_REQUIRED twenty minutes later with no request outstanding. I have not determined whether that is a stale decision awaiting recomputation or that approval not counting toward the requirement, so I am not claiming this review is redundant — with the count at 1 and no request pinning it, it should be sufficient on its own either way.

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

Code review — round 1

No blocking findings. Approval blockers: none.


Scope

Reviewed: use-managed-actions.ts production fix (+6/-1) and all three new tests (+85 lines across two test files). No generated files, lockfiles, or docs in this diff.

Execution: working tree unavailable, so mutation probes not run locally. The PR body supplies wenshao's third-party mutation run (N1, A3, A8 killed). That evidence covers the environment it was run on (macOS, wenshao's stack); Linux/Windows behavior of the retry timer not independently exercised here.


Production code

use-managed-actions.ts — the enabled === undefined branch now resets loadFailures.current = 0.

The fix is structurally symmetric with the reset that already exists six lines above it in the !reader || !sessionId branch. The docblock and inline comment correctly describe the invariant: enabled is undefined while the Session summary is unknown (e.g., during a page Refresh), and returning from that state is treated the same way as the reader being restored — the hook gets a fresh retry ladder rather than resuming from an exhausted one.

Traced the causal chain end-to-end:

  1. reader is provider.actions when enabled !== false, so the !reader guard fires only when enabled === false, not undefined.
  2. With the retry ladder exhausted (loadFailures.current = 3, all of LOAD_RETRY_DELAYS_MS consumed), re-entering with enabled === true after a reload would reach the .catch path, compute LOAD_RETRY_DELAYS_MS[3] === undefined, and schedule no retry — confirmed by the delay table in the source.
  3. The new loadFailures.current = 0 assignment in the enabled === undefined branch intercepts the reload cycle before step 2 fires.

No other path appears to miss a reset: the sessionId-change useEffect resets the counter on session switch, the retry() callback resets it on user-initiated retry, and the success branch zeroes it on a clean read.


Tests

use-managed-actions.test.tsx — restores the retry budget after a reload of the Session summary

Valid efficacy probe. The test mock returns five failures followed by one success. After four calls (the three retries plus the initial), the ladder is exhausted. The test then cycles enabled through undefined → true, triggering the fix, and asserts a fifth call fires immediately and a sixth (the success) fires after 2 000 ms. Without the fix the hook stops at four calls, which the PR description confirms with an explicit expected-vs-actual count. The delay sequence [0, 2 000, 5 000, 10 000, 60 000] ms correctly lines up with the LOAD_RETRY_DELAYS_MS = [2_000, 5_000, 10_000] ladder.

use-managed-actions.test.tsx — drops an approval another client already answered

Tests the endedAction(failure) branch in respond: a 409 with code: 'action_already_resolved' must hide the card (action === undefined), leave no answerError, and trigger a re-read. The ENDED_ACTION_CODES set already includes 'action_already_resolved' at this head; the test pins that the code path is exercised and does not regress if the code is altered.

managed-approval.test.ts — ignores a call with the same ID in a later Turn

Tests both message-key forms. For call-ID-keyed rows, findManagedApprovalTool uses the exact ${turnId}:${callId} match, so turn-3:call-1 cannot match an action whose exact = 'turn-2:call-1'. For item-ID-keyed rows, the guard tool.callId.startsWith(${action.turnId}:) eliminates the turn-3 item when the action's turnId is turn-2. Both cases are covered and the assertions are specific.


Unreviewed dimensions

  • Linux / Windows retry-timer behavior: no execution rung run locally; the PR author explicitly marks Windows as ⚠️ and Linux as CI-only. The timer behavior is pure JS (setTimeout), and the test environment (jsdom + Vitest fake timers) is platform-independent, so this is a low-risk gap.
  • No terminal-dependent (OS filesystem, platform branch) changes in this diff; rung 3 not applicable.

Reviewed with AI assistance.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Verified the approval reload fix at 972aec3134f36c3bd972c359d906e5656b85cefb with actual browser interaction and the real Managed Java API. No additional product changes were needed.

The baseline uses the exact hook from merge base 576689d07342dd2ba80d60ec00df23ea53251945. This PR changes only that production hook; the other changed files are tests. Both browser variants used the same Session, service, product components and build stylesheet.

Case Before After / expected result
Retry exhaustion, then Refresh Four injected 503 responses exhaust the 2/5/10-second retry ladder. Refresh receives the fifth 503; the baseline stays at five reads with the load-error message for another 15.15 seconds. Current head makes the sixth read against the real Java API, receives 200, and displays WriteFile approval options 2.15 seconds after Refresh. No click on “Retry loading approvals” was needed.
Answer already handled elsewhere A second client's real request receives 202; the fixture authority then commits the decided record. Paused SSE keeps the first browser's approval stale. Clicking “Yes, allow once” sends an actual request that receives Java HTTP 409. The card and answer-error state disappear; subsequent real pending queries return an empty list.

Baseline after Refresh vs current head after Refresh:

Baseline Current head
Baseline retains the approval load error Current head recovers the WriteFile approval

Current-head stale approval, before and after answering it:

Before stale response After HTTP 409
Stale approval remains visible before response Stale approval is removed after response

Validation: full build and typecheck passed; four focused Web Shell files passed 100/100 tests. The reload regression fails with the actual baseline hook (expected 6 calls, got 5) and passes after restoring current-head bytes. Cross-Turn tool matching and both transcript-row forms are covered by unit tests. Independent native API checks passed before and after the decision, and an additional refused stale request returned 409 action_already_resolved; that body capture is separate from the browser-click evidence.

Method and limits: the current TypeScript UI connects to a real Spring service compiled from this head, with an isolated H2 database and a test principal. Approval records are seeded through the existing test journal; no Hosted model or worker executes a tool in this run. The proxy injects five 503 responses per variant and temporarily pauses SSE for the stale-client case. The fixture commits the authority decision after real 202 admission, so this verifies API refusal and UI cleanup rather than end-to-end worker settlement. The UI states are native captures, not edited images. All four were inspected for private data and uploaded unchanged; the displayed fixed-commit mirror bytes match their original SHA-256 hashes.

The product worktree is clean. The browser TaskSpace is closed, and the fixture, proxy and two UI servers have been stopped. Existing approval reviews remain at this head; the previously observed pending automatic review is not part of the functional acceptance claim.

@yiliang114
yiliang114 added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 8866017 Oct 4, 2026
187 of 188 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