Skip to content

fix(plan-gate): isolate gate agent AbortSignal from parent signal chain#5185

Merged
wenshao merged 6 commits into
QwenLM:mainfrom
CubeLander:fix/exit-plan-mode-gate-agent-signal-isolation
Jun 18, 2026
Merged

fix(plan-gate): isolate gate agent AbortSignal from parent signal chain#5185
wenshao merged 6 commits into
QwenLM:mainfrom
CubeLander:fix/exit-plan-mode-gate-agent-signal-isolation

Conversation

@CubeLander

@CubeLander CubeLander commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Isolates the Plan Approval Gate review agent from the parent abort-signal chain so transient parent-side aborts can no longer cancel it, and makes the gate's retry loop genuinely retry instead of collapsing to a single attempt. Three changes: (1) gateReviewAgents.tsrunGateAgent now runs the gate subagent under its own dedicated AbortController with a fixed 5-minute timeout (GATE_AGENT_TIMEOUT_MS) instead of the inherited parent signal; the parent-signal parameter is kept for signature stability but intentionally unused (renamed _parentSignal), and the finally block clears the timeout and aborts the controller if it hasn't already fired so no timers or listeners leak. (2) planApprovalGate.tsrunAgentWithRetry replaces the per-iteration signal.aborted check with a single entry-time check (skip launching a gate agent at all if the parent is already aborted), and adds an abort-aware delay(1000, signal) backoff between attempts that breaks the loop immediately if the parent is cancelled during the wait. (3) retry.ts — the existing delay() helper is exported so the gate can reuse it (additive, no behavior change).

Why it's needed

In AUTO/YOLO pre-plan mode, calling exit_plan_mode could leave the user permanently stuck in plan mode through an infinite retry loop. The gate review agent inherited the parent's abort signal all the way down the chain (masterAbortController → roundAbortController → processFunctionCalls → ExitPlanModeToolInvocation.execute → runPlanApprovalGate → runGateAgent → subagent.execute). When the parent hit a transient condition — e.g. a NO_FINISH_REASON stream retry or per-round cleanup — the abort cascaded into the gate agent and cancelled it (mode=CANCELLED, no usable output). Compounding this, runAgentWithRetry checked signal.aborted at the top of every loop iteration, so once the signal was aborted the remaining attempts were skipped instantly — "3 retries" effectively became "1 retry." The gate then reported unavailable, exit_plan_mode returned an error, the model re-called exit_plan_mode, and the same transient failure repeated indefinitely. Representative logs:

[WARN] [QWEN_CODE_CHAT] Invalid stream [NO_FINISH_REASON] (retry 1/2)
[WARN] [PLAN_APPROVAL_GATE] Gate agent attempt 1/3 failed: Gate agent terminated with mode=CANCELLED and no usable output
[WARN] [PLAN_APPROVAL_GATE] Gate agent skipped: signal already aborted
[REPAIR] synthesized 1 functionResponse(s): exit_plan_mode(...)

Giving the gate agent its own signal + timeout breaks the cascade, and the abort-aware backoff restores real retries while still honoring genuine cancellation at the loop boundaries.

Reviewer Test Plan

How to verify

This is non-user-visible signal/retry logic; confirm via the plan-gate suites, typecheck, and build (from repo root):

npx vitest run packages/core/src/plan-gate/planApprovalGate.test.ts packages/core/src/plan-gate/gateReviewAgents.test.ts
npm run typecheck --workspace=packages/core
npm run build --workspace=packages/core

Behaviorally: enter plan mode under AUTO/YOLO pre-plan mode and trigger exit_plan_mode while the parent stream hits a transient NO_FINISH_REASON retry. Before — the gate agent is cancelled, retries are skipped, the gate returns "unavailable," and exit_plan_mode loops without ever exiting plan mode. After — the gate agent runs under its own signal, survives the transient parent abort, completes its review, and plan mode exits.

A maintainer independently verified this on a real build — a real-code A/B that reproduces the exact bug on the merge-base and confirms the fix on the PR head, plus an integrated TUI run: #5185 (comment)

Evidence (Before & After)

Before — exit_plan_mode infinite-retry loop; Plan Approval Gate reports "unavailable"; user cannot leave plan mode (log signature above). After — the gate agent is isolated from transient parent aborts, completes, and plan mode exits cleanly. Internal signal-handling fix with no UI surface; the full before/after is captured in the maintainer A/B verification linked above.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

The signal/timeout logic is platform-agnostic (AbortController / setTimeout); CI exercises macOS, Windows, and Linux.

Environment (optional)

Plan-gate unit suites + tsc --noEmit + packages/core build. No special runtime required.

Risk & Scope

  • Main risk or tradeoff: Once the gate agent is actually running, it is intentionally not cancellable by the parent — only its own 5-minute timeout (or normal completion) ends it. Genuine user cancellation is still honored before launch (entry-time signal.aborted check) and between retries (abort-aware delay), but not mid-gate. This is the deliberate cost of making the gate resilient to transient parent aborts.
  • Not validated / out of scope: No new automated tests are added; the existing plan-gate suites cover the retry/gate paths. A reviewer (via Qwen Code /review) suggested follow-up tests for the new branches specifically — abort during the backoff delay, parent-signal isolation (gate keeps running when the parent aborts), asserting runGateAgent is not called when pre-aborted, and the 5-minute timeout path. Windows/Linux not exercised locally.
  • Breaking changes / migration notes: None. runGateAgent's third parameter is retained (renamed _parentSignal) so callers are unaffected; exporting delay from utils/retry.ts is additive.

Linked Issues

No separate tracking issue — the root cause and reproduction are documented above (the exit_plan_mode / Plan Approval Gate stuck-loop under AUTO/YOLO pre-plan mode).

中文说明

这个 PR 做了什么

让 Plan Approval Gate 评审 agent 从父级的 abort 信号链中隔离出来,使父级的瞬时 abort 不再能取消它,并让 gate 的重试循环真正重试,而不是退化成只试一次。共三处改动:(1) gateReviewAgents.ts —— runGateAgent 现在让 gate 子 agent 跑在它自己独立的 AbortController 下,带一个固定的 5 分钟超时(GATE_AGENT_TIMEOUT_MS),不再使用继承来的父级 signal;父级 signal 参数为保持签名稳定而保留,但被刻意弃用(重命名为 _parentSignal),并在 finally 中清除定时器、若控制器尚未触发则将其 abort,避免定时器/监听器泄漏。(2) planApprovalGate.ts —— runAgentWithRetry 把"每轮循环顶部检查 signal.aborted"改成单次的入口检查(如果进入时父级已 abort,就根本不启动 gate agent),并在两次尝试之间加入一个 abort 感知的 delay(1000, signal) 退避;若退避期间父级被取消,会立即中断循环。(3) retry.ts —— 把已有的 delay() 辅助函数导出,供 gate 复用(纯新增,无行为变化)。

为什么需要它

在 AUTO/YOLO pre-plan 模式下,调用 exit_plan_mode 可能让用户永久卡在 plan 模式里,陷入无限重试循环。gate 评审 agent 沿整条链继承了父级的 abort 信号(masterAbortController → roundAbortController → processFunctionCalls → ExitPlanModeToolInvocation.execute → runPlanApprovalGate → runGateAgent → subagent.execute)。当父级遇到瞬时情况 —— 例如 NO_FINISH_REASON 流重试或每轮清理 —— abort 会级联进 gate agent 并把它取消(mode=CANCELLED,无可用输出)。雪上加霜的是,runAgentWithRetry 在每轮循环顶部检查 signal.aborted,所以信号一旦被 abort,剩余尝试就被瞬间跳过 —— "3 次重试"实际变成"1 次重试"。于是 gate 报告 unavailable,exit_plan_mode 返回错误,模型再次调用 exit_plan_mode,同样的瞬时失败无限重复。典型日志:

[WARN] [QWEN_CODE_CHAT] Invalid stream [NO_FINISH_REASON] (retry 1/2)
[WARN] [PLAN_APPROVAL_GATE] Gate agent attempt 1/3 failed: Gate agent terminated with mode=CANCELLED and no usable output
[WARN] [PLAN_APPROVAL_GATE] Gate agent skipped: signal already aborted
[REPAIR] synthesized 1 functionResponse(s): exit_plan_mode(...)

给 gate agent 自己的 signal + 超时打断了这条级联,abort 感知的退避恢复了真正的重试,同时仍在循环边界处尊重真实的取消。

评审者测试计划

如何验证

这是非用户可见的 signal/重试逻辑;通过 plan-gate 测试套件、typecheck、构建来确认(在仓库根目录执行):

npx vitest run packages/core/src/plan-gate/planApprovalGate.test.ts packages/core/src/plan-gate/gateReviewAgents.test.ts
npm run typecheck --workspace=packages/core
npm run build --workspace=packages/core

行为层面:在 AUTO/YOLO pre-plan 模式下进入 plan 模式,并在父级流遇到瞬时 NO_FINISH_REASON 重试时触发 exit_plan_mode。修复前 —— gate agent 被取消、重试被跳过、gate 返回 "unavailable",exit_plan_mode 一直循环、始终无法退出 plan 模式。修复后 —— gate agent 跑在自己的 signal 下,扛过瞬时的父级 abort,完成评审,plan 模式正常退出。

维护者已在真实构建上独立验证 —— 用真实代码 A/B 在 merge-base 上复现了该 bug、并在 PR head 上确认了修复,外加一次集成 TUI 运行:#5185 (comment)

证据(前后对比)

修复前 —— exit_plan_mode 无限重试循环;Plan Approval Gate 报告 "unavailable";用户无法离开 plan 模式(日志特征见上)。修复后 —— gate agent 与瞬时父级 abort 隔离、完成、plan 模式干净退出。这是内部 signal 处理的修复,无 UI 表层;完整前后对比见上方链接的维护者 A/B 验证。

测试平台

操作系统 状态
🍏 macOS ✅ 已测试
🪟 Windows ⚠️ 未测试
🐧 Linux ⚠️ 未测试

该 signal/超时逻辑与平台无关(AbortController / setTimeout);CI 会覆盖 macOS、Windows、Linux 三个平台。

环境(可选)

plan-gate 单元测试套件 + tsc --noEmit + packages/core 构建。无需特殊运行环境。

风险与范围

  • 主要风险或取舍:一旦 gate agent 真正开始运行,它就被刻意设计成可被父级取消 —— 只有它自己的 5 分钟超时(或正常完成)才能结束它。真实的用户取消仍会在启动前(入口处的 signal.aborted 检查)和两次重试之间(abort 感知的 delay)被尊重,但在 gate 运行中途不会。这是让 gate 对瞬时父级 abort 具备韧性所付出的有意代价。
  • 未验证 / 范围之外:未新增自动化测试;现有 plan-gate 套件覆盖了重试/gate 路径。一位评审者(通过 Qwen Code /review)建议为新增分支专门补测试 —— 退避 delay 期间的 abort、父级 signal 隔离(父级 abort 时 gate 仍继续运行)、断言预先 abort 时 runGateAgent 未被调用、以及 5 分钟超时路径。Windows/Linux 未在本地实测。
  • 破坏性变更 / 迁移说明:无。runGateAgent 的第三个参数被保留(重命名为 _parentSignal),调用方不受影响;把 delayutils/retry.ts 导出是纯新增。

关联 Issue

无单独的跟踪 issue —— 根因与复现已在上文说明(AUTO/YOLO pre-plan 模式下 exit_plan_mode / Plan Approval Gate 卡死循环)。

The Plan Approval Gate's review agent inherited the parent round's
AbortController signal. When transient parent-side issues occurred
(stream NO_FINISH_REASON retries, round cleanup), the abort cascaded
into the gate agent, causing it to terminate with mode=CANCELLED.

The runAgentWithRetry loop then saw signal.aborted=true and immediately
skipped all remaining retry attempts, leaving the user stuck in plan
mode with a repeated 'Gate review agent unavailable' message.

Changes:
- runGateAgent: create an independent AbortController with its own
  timeout (5 min) instead of inheriting the parent signal. This
  isolates the gate agent from transient parent-side aborts.
- runAgentWithRetry: add a 1s delay between retries to allow
  transient issues to settle.
- Improved log messages to distinguish attempt numbers in retry loop.

Fixes: exit_plan_mode getting stuck when NO_FINISH_REASON stream errors
cascade through the abort signal chain.
Copilot AI review requested due to automatic review settings June 16, 2026 04:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR adjusts the Plan Approval Gate agent’s cancellation/timeout behavior to reduce failures caused by parent-signal aborts and adds a small delay between retries.

Changes:

  • Add early “parent aborted” skip logic and a 1s delay between gate-agent retries.
  • Isolate the gate agent from the parent AbortSignal by using a dedicated AbortController with a timeout.
  • Update logging and inline documentation around abort/timeout behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
packages/core/src/plan-gate/planApprovalGate.ts Adds parent-abort pre-check logging and a fixed delay between retry attempts.
packages/core/src/plan-gate/gateReviewAgents.ts Introduces an isolated abort controller + timeout for the gate agent and updates related docs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +113 to +118
* Signal isolation: The gate agent creates its own independent AbortController
* with a timeout, rather than directly inheriting the parent's signal. This
* prevents transient parent-side issues (stream errors, round cleanup) from
* cascading into the gate agent. The parent signal is only monitored for
* genuine user-initiated cancellations.
*

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  • Fix docstring: parent signal is pre-checked only, not monitored
    during execution. Matches the actual isolation behavior.
  • Remove signal.aborted check from retry loop. The gate agent is now
    fully signal-isolated with its own timeout, so parent-side aborts
    (typically transient) should not short-circuit retries. The caller
    discards the result if the parent is truly gone.
  • Replace plain setTimeout delay with abortableSleep() that returns
    early if the parent signal aborts during the 1s wait. Prevents
    wasted shutdown time on genuine cancellation.
  • Fix timeout comment: gate agent typically completes in 1-2 minutes;
    the 5-minute ceiling matches runConfig.max_time_minutes.

Comment on lines +126 to +129
// Fast-fail if the parent context is already cancelled before we start
if (parentSignal.aborted) {
throw new Error('Parent signal already aborted before gate agent start');
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  • Fix docstring: parent signal is pre-checked only, not monitored
    during execution. Matches the actual isolation behavior.
  • Remove signal.aborted check from retry loop. The gate agent is now
    fully signal-isolated with its own timeout, so parent-side aborts
    (typically transient) should not short-circuit retries. The caller
    discards the result if the parent is truly gone.
  • Replace plain setTimeout delay with abortableSleep() that returns
    early if the parent signal aborts during the 1s wait. Prevents
    wasted shutdown time on genuine cancellation.
  • Fix timeout comment: gate agent typically completes in 1-2 minutes;
    the 5-minute ceiling matches runConfig.max_time_minutes.

Comment on lines 145 to 156
// Check if the parent signal is already aborted before starting.
// Note: This check is for genuine user-initiated cancellations.
// Transient parent-side aborts (e.g., stream errors) should not
// prevent retries since runGateAgent now isolates the gate agent
// from parent signal propagation.
if (signal.aborted) {
debugLogger.warn('Gate agent skipped: signal already aborted');
debugLogger.warn(
`Gate agent skipped on attempt ${attempt}: parent signal already aborted`,
);
// If the signal is aborted, don't retry — the user/session is gone
return null;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  • Fix docstring: parent signal is pre-checked only, not monitored
    during execution. Matches the actual isolation behavior.
  • Remove signal.aborted check from retry loop. The gate agent is now
    fully signal-isolated with its own timeout, so parent-side aborts
    (typically transient) should not short-circuit retries. The caller
    discards the result if the parent is truly gone.
  • Replace plain setTimeout delay with abortableSleep() that returns
    early if the parent signal aborts during the 1s wait. Prevents
    wasted shutdown time on genuine cancellation.
  • Fix timeout comment: gate agent typically completes in 1-2 minutes;
    the 5-minute ceiling matches runConfig.max_time_minutes.

Comment on lines +170 to +171
// Add a small delay between retries to allow transient issues to settle
await new Promise((resolve) => setTimeout(resolve, 1000));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  • Fix docstring: parent signal is pre-checked only, not monitored
    during execution. Matches the actual isolation behavior.
  • Remove signal.aborted check from retry loop. The gate agent is now
    fully signal-isolated with its own timeout, so parent-side aborts
    (typically transient) should not short-circuit retries. The caller
    discards the result if the parent is truly gone.
  • Replace plain setTimeout delay with abortableSleep() that returns
    early if the parent signal aborts during the 1s wait. Prevents
    wasted shutdown time on genuine cancellation.
  • Fix timeout comment: gate agent typically completes in 1-2 minutes;
    the 5-minute ceiling matches runConfig.max_time_minutes.

Comment on lines +17 to +21
/**
* Timeout for the gate agent in milliseconds. The gate agent should complete
* within a reasonable time (typically 1-2 minutes for a plan review).
*/
const GATE_AGENT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes, matching runConfig.max_time_minutes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  • Fix docstring: parent signal is pre-checked only, not monitored
    during execution. Matches the actual isolation behavior.
  • Remove signal.aborted check from retry loop. The gate agent is now
    fully signal-isolated with its own timeout, so parent-side aborts
    (typically transient) should not short-circuit retries. The caller
    discards the result if the parent is truly gone.
  • Replace plain setTimeout delay with abortableSleep() that returns
    early if the parent signal aborts during the 1s wait. Prevents
    wasted shutdown time on genuine cancellation.
  • Fix timeout comment: gate agent typically completes in 1-2 minutes;
    the 5-minute ceiling matches runConfig.max_time_minutes.

- Fix docstring: parent signal is pre-checked only, not monitored
  during execution. Matches the actual isolation behavior.
- Remove signal.aborted check from retry loop. The gate agent is now
  fully signal-isolated with its own timeout, so parent-side aborts
  (typically transient) should not short-circuit retries. The caller
  discards the result if the parent is truly gone.
- Replace plain setTimeout delay with abortableSleep() that returns
  early if the parent signal aborts during the 1s wait. Prevents
  wasted shutdown time on genuine cancellation.
- Fix timeout comment: gate agent typically completes in 1-2 minutes;
  the 5-minute ceiling matches runConfig.max_time_minutes.
@CubeLander
CubeLander requested a review from Copilot June 16, 2026 06:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment on lines +126 to +131
parentSignal: AbortSignal,
): Promise<GateAgentResult> {
// Fast-fail if the parent context is already cancelled before we start
if (parentSignal.aborted) {
throw new Error('Parent signal already aborted before gate agent start');
}
Comment on lines +174 to +187
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.resolve();
return new Promise<void>((resolve) => {
const onAbort = () => {
clearTimeout(timer);
resolve();
};
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal.addEventListener('abort', onAbort, { once: true });
});
}
- Remove parentSignal.aborted pre-check from runGateAgent. The hard-fail
  conflicted with the isolation goal: a transient parent abort would throw,
  abortableSleep would resolve immediately, and retries would spin in a
  rapid loop. The gate agent's own 5-minute timeout is the sole cancellation
  mechanism; the parent signal is intentionally unused.

- Fix race window in abortableSleep: attach the abort listener first, then
  re-check signal.aborted to handle the case where abort fires between the
  initial check and listener registration. Eliminates the edge case where
  the sleep would wait the full duration despite an already-aborted signal.
@CubeLander
CubeLander requested a review from Copilot June 16, 2026 06:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment on lines +163 to +165
// Abort-aware delay: wait 1s between retries, but bail out early
// if the parent signal is aborted during the wait.
await abortableSleep(1000, signal);
Replace custom abortableSleep with the existing delay() utility from
utils/retry.ts, which was already battle-tested for exactly this pattern:

- delay() REJECTS (not resolves) when the signal is aborted, so the
  retry loop can catch the rejection and cleanly exit — no rapid-fire
  retry spin after cancellation.
- delay() already handles the TOCTOU race window (attach listener first,
  re-check signal.aborted after) — addresses the second review comment.
- Also exports delay() from retry.ts since planApprovalGate needs it.

This eliminates the risk of abortableSleep resolving early during abort
and immediately proceeding to another gate agent attempt, which would
cause rapid successive retries and log spam.
@CubeLander
CubeLander requested a review from Copilot June 16, 2026 07:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comment on lines +168 to +176
try {
await delay(1000, signal);
} catch {
// Signal aborted during delay — stop retrying
debugLogger.warn(
`Gate agent retry loop cancelled during backoff delay (attempt ${attempt}/${MAX_AGENT_RETRIES})`,
);
return null;
}
Comment on lines +145 to +150
for (let attempt = 1; attempt <= MAX_AGENT_RETRIES; attempt++) {
if (signal.aborted) {
debugLogger.warn('Gate agent skipped: signal already aborted');
return null;
}
// Note: We deliberately do NOT check signal.aborted here.
// The gate agent is signal-isolated (see runGateAgent), so parent-side
// aborts are typically transient (stream errors, round cleanup). The
// gate agent has its own 5-minute timeout as the safety net. If the
// parent is truly gone, the caller will discard the result anyway.
Comment on lines +18 to +22
* Timeout for the gate agent in milliseconds. The gate agent typically
* completes within 1–2 minutes; this 5-minute ceiling accounts for the
* runConfig.max_time_minutes limit and provides a safety net against hangs.
*/
const GATE_AGENT_TIMEOUT_MS = 5 * 60 * 1000;
- Add entry-time signal.aborted check to avoid launching gate agent on obvious cancellations
- Only delay between retries when attempt < MAX_AGENT_RETRIES (not after final failure)
- Clarify timeout comment: 5-min ceiling is fixed, coincides with runConfig but independent
@CubeLander
CubeLander requested a review from Copilot June 16, 2026 07:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

Comment on lines 157 to 160
for (let attempt = 1; attempt <= MAX_AGENT_RETRIES; attempt++) {
if (signal.aborted) {
debugLogger.warn('Gate agent skipped: signal already aborted');
return null;
}
try {
return await runGateAgent(config, bundle, signal);
} catch (error) {
Comment on lines +175 to +183
try {
await delay(1000, signal);
} catch {
// Signal aborted during delay — stop retrying
debugLogger.warn(
`Gate agent retry loop cancelled during backoff delay (attempt ${attempt}/${MAX_AGENT_RETRIES})`,
);
return null;
}
Comment on lines +160 to +168
const gateAbortController = new AbortController();

// Add a timeout so the gate agent doesn't hang indefinitely
const timeoutId = setTimeout(() => {
debugLogger.warn(
`[runGateAgent] Gate agent timed out after ${GATE_AGENT_TIMEOUT_MS}ms`,
);
gateAbortController.abort(new Error('Gate agent timeout'));
}, GATE_AGENT_TIMEOUT_MS);
Comment on lines +183 to +184
// Pass the isolated gate signal instead of the parent signal
await subagent.execute(contextState, gateAbortController.signal);
@CubeLander

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. I'd like to address these comments, but I want to flag up front that the four comments contain contradictory suggestions that would undo the bug fix if applied together. I'll walk through each one.


The contradiction

The comments fall into two groups:

Group A (planApprovalGate.ts): "Reintroduce an abort check at the top of each loop iteration" — i.e., check signal.aborted per attempt, fail fast.

Group B (gateReviewAgents.ts): "Propagate parent abort into gateAbortController" — i.e., let the parent signal cancel the gate agent.

If we do both A and B, the system behaves exactly like the pre-fix code — the very bug this PR is fixing. Here's why:

  1. Parent stream encounters a transient error (e.g., NO_FINISH_REASON).
  2. Parent abort propagates into gateAbortController (B).
  3. Gate agent terminates with mode=CANCELLED.
  4. runGateAgent throws.
  5. Retry loop catches the error, checks signal.aborted at the top of the next iteration (A).
  6. Signal is still aborted → returns null immediately.
  7. Retries 2 and 3 are skipped.
  8. User gets "Plan Approval Gate: unavailable" and is stuck in plan mode.

This is the exact failure sequence from the bug report. The debug logs in the PR description show this happening repeatedly.

The two groups of comments are pulling in opposite directions because they're each independently correct in isolation, but together they recreate the cascading-abort failure mode.


The design rationale

Why signal isolation is intentional (Group B)

The gate agent uses an independent AbortController with its own 5-minute timeout. This mirrors an established pattern in this codebase: background agents in agent.ts (line 2205) do exactly the same thing:

// agent.ts line 2205
const bgAbortController = new AbortController();
// ...
await bgSubagent.execute(contextState, bgAbortController.signal);

The comment there says: "Create an independent AbortController — background agents survive ESC cancellation of the parent's current turn."

The gate agent has the same requirement: it must survive transient parent-side aborts. The difference is only the reason for the abort, but AbortSignal is a boolean flag with no semantic distinction between:

  • User explicitly pressed Ctrl+C (should stop)
  • Stream returned NO_FINISH_REASON and the chat layer aborted the round (transient, should retry)
  • Round cleanup fired during normal turn transitions (transient, should retry)

Without a way to distinguish these, we have to choose: either isolate (resilient to transient aborts) or propagate (responsive to explicit cancellation). We cannot do both with a bare AbortSignal.

Why per-iteration abort checks are intentionally absent (Group A)

The retry loop deliberately does not check signal.aborted per iteration. This was removed in commit 9e8662a7d specifically because it conflicted with signal isolation.

If we check per iteration, a transient parent abort during or between attempts immediately terminates the retry loop — even though the gate agent itself is isolated and would have completed successfully on a retry. The entry-time check (lines 148–154) catches obvious pre-existing cancellations without interfering with retries:

// Entry-time check only — not per-iteration
if (signal.aborted) {
  debugLogger.warn('Gate agent skipped: parent signal already aborted at entry');
  return null;
}

for (let attempt = 1; attempt <= MAX_AGENT_RETRIES; attempt++) {
  // No signal.aborted check here — intentionally
  try {
    return await runGateAgent(config, bundle, signal);
  } catch (error) {
    // ...
    try { await delay(1000, signal); } catch { return null; }
  }
}

The delay() call between retries serves a dual purpose: it provides a backoff and acts as the only mid-loop cancellation point. If the parent signal aborts during the 1-second wait, delay() rejects and the loop exits cleanly.

The 5-minute wait tradeoff

The Group B comment raises a valid concern: if the parent is genuinely cancelled (user closes the session), the gate agent will continue running for up to 5 minutes. This is an acknowledged tradeoff.

In practice:

  1. Most gate agent runs complete in 1–2 minutes (the typical case).
  2. The 5-minute timeout is a ceiling, not an expected duration.
  3. Explicit user cancellation during a gate agent run is uncommon compared to transient stream aborts.
  4. The pre-fix behavior was strictly worse: the user couldn't exit plan mode at all.

If this tradeoff proves unacceptable in production, the correct fix is not to propagate the parent signal (which reintroduces the bug) but to add abort reason semantics — either via AbortSignal.reason (Node 17+) or a separate cancellation channel. That's a broader architectural change beyond the scope of this PR.


Summary

Concern Current behavior Why
Transient parent abort (stream error) Gate agent completes, retry loop proceeds Signal isolation prevents cascading failure
Parent already aborted at entry Returns null immediately Entry-time check catches obvious cancellations
Parent aborts during retry delay Loop exits immediately delay() rejects on abort, catch block returns null
Parent aborts during gate agent execution Gate agent runs to completion or timeout Isolation prevents transient aborts from cancelling a valid review
User explicitly cancels mid-flight Waits up to 5 minutes (timeout) Acknowledged tradeoff; requires abort-reason semantics to fix properly

The current implementation deliberately trades responsiveness-to-cancellation for resilience-to-transient-aborts. This is the same tradeoff the codebase already makes for background agents. I believe this is the correct choice for now.

@CubeLander
CubeLander requested a review from Copilot June 16, 2026 07:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

Comment thread packages/core/src/plan-gate/planApprovalGate.ts
Comment on lines +145 to +149
// Entry-time check: if the parent signal is already aborted before we start,
// respect it to avoid launching a 5-minute gate agent for an obvious cancellation.
// This is the ONLY place we check signal.aborted — during execution, the gate
// agent is signal-isolated (see runGateAgent) to prevent transient parent-side
// aborts from cascading.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, fixed in b1baf06. Reworded to clarify this is the only synchronous pre-run check; delay() still provides abort-awareness between retries.

Comment thread packages/core/src/plan-gate/gateReviewAgents.ts
Comment thread packages/core/src/plan-gate/gateReviewAgents.ts Outdated
- Clarify entry-time comment: it's the only synchronous pre-run check;
  delay() still provides abort-awareness between retries.
- Guard gateAbortController.abort() in finally: only abort if not
  already aborted, avoiding unnecessary abort events on success.
@CubeLander
CubeLander requested a review from Copilot June 16, 2026 07:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

Comment on lines 157 to 160
for (let attempt = 1; attempt <= MAX_AGENT_RETRIES; attempt++) {
if (signal.aborted) {
debugLogger.warn('Gate agent skipped: signal already aborted');
return null;
}
try {
return await runGateAgent(config, bundle, signal);
} catch (error) {
Comment on lines +171 to 185
// Abort-aware delay: wait 1s between retries (not after the final attempt).
// Uses the existing `delay()` from utils/retry.ts, which rejects when the
// signal is aborted. A cancellation during the wait breaks the loop
// immediately rather than proceeding to another rapid-fire attempt.
try {
await delay(1000, signal);
} catch {
// Signal aborted during delay — stop retrying
debugLogger.warn(
`Gate agent retry loop cancelled during backoff delay (attempt ${attempt}/${MAX_AGENT_RETRIES})`,
);
return null;
}
}
}
Comment on lines 124 to 130
export async function runGateAgent(
config: Config,
bundle: EvidenceBundle,
signal: AbortSignal,
// parentSignal is accepted for API consistency but intentionally unused:
// the gate agent is fully isolated from parent-side aborts.
_parentSignal: AbortSignal,
): Promise<GateAgentResult> {
Comment on lines +160 to +168
const gateAbortController = new AbortController();

// Add a timeout so the gate agent doesn't hang indefinitely
const timeoutId = setTimeout(() => {
debugLogger.warn(
`[runGateAgent] Gate agent timed out after ${GATE_AGENT_TIMEOUT_MS}ms`,
);
gateAbortController.abort(new Error('Gate agent timeout'));
}, GATE_AGENT_TIMEOUT_MS);
Comment on lines +183 to +184
// Pass the isolated gate signal instead of the parent signal
await subagent.execute(contextState, gateAbortController.signal);

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

[Suggestion] Missing test coverage for new behavior introduced by this PR:

  • (a) Abort during retry backoff delay (delay(1000, signal) catch block) — the scenario the PR is explicitly designed to handle
  • (b) Parent signal isolation — no test verifies the gate agent continues running when parent signal aborts
  • (c) Pre-aborted signal test doesn't assert runGateAgent was never called (the key optimization is unverified — add expect(mockRunGateAgent).not.toHaveBeenCalled())
  • (d) Gate agent 5-minute timeout (setTimeout + abort)

— qwen3.7-max via Qwen Code /review

}
try {
return await runGateAgent(config, bundle, signal);
} catch (error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Timeout errors are retried identically to transient errors. When the gate agent's 5-minute timeout fires (gateReviewAgents.ts:165), the error propagates here and the retry loop launches another 5-minute attempt. With MAX_AGENT_RETRIES=3, worst case = ~15 minutes of blocked waiting.

A timeout signals the operation is too expensive — retrying it at the same budget defeats the safety net. Consider distinguishing timeout errors:

Suggested change
} catch (error) {
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes('Gate agent timeout')) {
debugLogger.error(
`[runAgentWithRetry] Gate agent timed out; not retrying (attempt ${attempt}/${MAX_AGENT_RETRIES})`,
);
return null;
}

— qwen3.7-max via Qwen Code /review

// because:
// 1. The gate agent is a critical review step that should complete if possible
// 2. Parent aborts are often transient (stream retries, round cleanup)
// 3. The gate agent's own timeout provides a safety net

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] _parentSignal is accepted but never observed. If the parent aborts during gate agent execution, there's zero log output connecting the parent abort to the gate agent's continued execution. For debugging "I cancelled but it kept going" reports, oncall needs a breadcrumb.

Consider adding a one-time listener before the AbortController:

Suggested change
// 3. The gate agent's own timeout provides a safety net
_parentSignal.addEventListener(
'abort',
() => {
debugLogger.warn(
'[runGateAgent] Parent signal aborted — gate agent continuing independently (own timeout is sole cancellation)',
);
},
{ once: true },
);
const gateAbortController = new AbortController();

— qwen3.7-max via Qwen Code /review

@doudouOUC doudouOUC 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! ✅

The signal isolation design is sound — independent AbortController with its own timeout correctly prevents transient parent-side aborts from cascading into the gate agent, while the entry-time signal.aborted check and abort-aware delay() between retries still provide cancellation points. Timer/listener cleanup in the finally block is correct. The delay export from retry.ts is clean.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

✅ Maintainer verification — bug reproduced on merge-base, fixed on PR head (real-code A/B) + integrated TUI run

This fixes the exit_plan_mode infinite-retry / stuck-in-plan-mode loop. The PR ships no tests and the description notes the author couldn't run any locally (npm install timed out), so I verified it three ways on a real build: ran the existing plan-gate suites + typecheck, wrote a real-code A/B test that reproduces the exact bug on the merge-base and confirms the fix on the PR head, and drove the real exit_plan_mode → gate path through a TUI under tmux.

Setup: PR head b1baf061, fresh npm run bundledist/cli.js (v0.18.1), Node v22, isolated HOME. (The only npm run build failure is the unrelated web-shell/katex asset on this box; this PR is packages/core-only and core + bundle are clean.)

The fix in one paragraph

runGateAgent now runs the gate subagent under its own AbortController (+ a 5-min timeout) instead of the inherited parent signal; runAgentWithRetry drops the per-attempt parent-abort check (keeping only an entry-time check) and adds an abort-aware 1 s backoff (delay() is now exported from utils/retry.ts). Net effect: a transient parent abort (NO_FINISH_REASON stream retry / round cleanup) no longer cancels the gate mid-run, so the 3 retries actually work instead of collapsing to unavailable.

1. Real-code A/B — reproduces the bug, proves the fix (the decisive evidence)

I wrote a behaviour test that drives the real runGateAgent / runPlanApprovalGate; only the subagent boundary (the actual LLM call) and the approval-mode override are mocked. The fake subagent behaves like the real one — it terminates CANCELLED iff the signal it was handed is aborted — and a transient parent abort fires mid-run:

execute: async (_ctx, signal) => {
  parentToAbort.abort();                 // transient parent-side abort during the gate run
  await tick;
  seen.abortedAtDecision = signal.aborted;
  terminateMode = signal.aborted ? CANCELLED : GOAL;   // real subagent observes ITS OWN signal
}
// ...
const decision = await runPlanApprovalGate(config, bundle, parent.signal);
expect(decision.kind).toBe('approved');  // pre-fix this was 'unavailable'

Same test, run against both trees:

Tree runGateAgent (isolation) runPlanApprovalGate (user-visible)
PR head b1baf061 ✅ gate runs under an isolated signal (!== parent, not aborted) → returns pass kind: 'approved', 1 attempt
merge-base 1cb0d7194 (pre-fix) ❌ gate inherits the parent signal → aborted → CANCELLED (throws) expected 'unavailable' to be 'approved'

The merge-base failure is the reported bug: the gate is cancelled by the inherited transient abort, retries are skipped, and the call returns unavailable — which is exactly what makes the model re-call exit_plan_mode and get stuck. The PR flips both to green. (My verify test is maintainer-authored scaffolding, not part of the PR.)

2. Existing suites + typecheck (PR head)

File Tests
plan-gate/planApprovalGate.test.ts 22
plan-gate/gateReviewAgents.test.ts 12
utils/retry.test.ts 84
tsc --noEmit (core, PR code only) clean ✅

All green (the author had never run them). Note: planApprovalGate.test.ts now takes ~4 s wall because two retry tests exercise the PR's real 1 s backoff — functionally fine, just slower; worth confirming you're OK with real delay() in tests vs. fake timers.

3. Integrated TUI run — real exit_plan_mode → gate → approve

Drove the real binary in tmux (YOLO). The model entered plan mode (prePlanMode=YOLO, gate state created), called exit_plan_mode, the real gate subagent spawned under the isolated controller, reviewed via a mock model, returned pass, and the gate approved + restored YOLO:

✓  EnterPlanMode  Entered plan mode.
✓  ExitPlanMode   Gate approved.
                  1. Create src/Button.tsx with a Button component
                  2. Wire it into App.tsx
                  3. Add a unit test in Button.test.tsx
✦ ✅ Plan approved by the gate and plan mode exited.

This exercises the real integrated path (ExitPlanModeTool → runPlanApprovalGate → runGateAgent → SubagentManager.createAgentHeadless) with the isolation change in place — the happy path is unbroken. (The abort race itself is proven deterministically in §1; the mock-model returned instantly here so no transient abort occurs.)

4. One design trade-off to weigh (not a correctness bug)

The fix fully isolates the gate from the parent signal, so a genuine user cancellation (ESC) during a gate run is no longer observed — the gate runs until its own 5-minute timeout. The author frames this as deliberate, matching the background-agent isolation pattern in agent.ts and noting that AbortSignal is a boolean that can't distinguish a transient abort (NO_FINISH_REASON/round cleanup) from a real user cancel. That reasoning is sound, but the worst case is a ~5-minute non-cancellable gate after a real ESC — worth a conscious sign-off, and ideally a follow-up that distinguishes reason/type if the runtime ever exposes it.

Verdict

The fix resolves the reported bug (A/B-proven: merge-base reproduces unavailable, PR head returns approved), passes all existing plan-gate/retry tests + typecheck, and the integrated exit_plan_mode → gate path works end-to-end on the PR build. Mergeable on correctness — the only thing to decide consciously is the ESC-during-gate isolation trade-off in §4. 👍

🇨🇳 中文版(点击展开)

✅ 维护者验证 —— 在 merge-base 上复现 bug、在 PR head 上确认修复(真实代码 A/B)+ 集成 TUI 跑通

本 PR 修复 exit_plan_mode 的「无限重试 / 卡在 plan 模式」死循环。PR 没有附带测试,且描述里作者说本地跑不了(npm install 超时),所以我在真实构建上用三种方式验证:跑了既有 plan-gate 套件 + typecheck;写了一个真实代码 A/B 测试,在 merge-base 上复现确切的 bug、在 PR head 上确认修复;并在 tmux 下通过 TUI 驱动真实的 exit_plan_mode → gate 路径。

环境: PR head b1baf061,全新 npm run bundledist/cli.js(v0.18.1),Node v22,隔离 HOME。(npm run build 唯一失败在无关的 web-shell/katex 资源上;本 PR 仅改 packages/core,core 与 bundle 均干净。)

修复一句话概括

runGateAgent 现在让 gate 子代理在自己的 AbortController(+5 分钟超时)下运行,而非继承父 signal;runAgentWithRetry 去掉了每次尝试的父 abort 检查(仅保留入口检查),并加入了 abort-aware 的 1 秒退避(delay() 现从 utils/retry.ts 导出)。净效果:瞬态父 abort(NO_FINISH_REASON 流重试 / round cleanup)不再中途取消 gate,于是 3 次重试真正生效,而不是塌缩为 unavailable

1. 真实代码 A/B —— 复现 bug、证明修复(决定性证据)

我写了一个行为测试,驱动真实runGateAgent / runPlanApprovalGate;只 mock 子代理边界(真正的 LLM 调用)与 approval-mode override。假子代理行为与真实一致 —— 当且仅当它收到的 signal 被 abort 时终止为 CANCELLED —— 并在运行中途触发一个瞬态父 abort

execute: async (_ctx, signal) => {
  parentToAbort.abort();                 // gate 运行期间的瞬态父 abort
  await tick;
  terminateMode = signal.aborted ? CANCELLED : GOAL;   // 真实子代理看的是「自己的」signal
}
const decision = await runPlanApprovalGate(config, bundle, parent.signal);
expect(decision.kind).toBe('approved');  // 修复前这里是 'unavailable'

同一个测试,跑在两棵树上:

runGateAgent(隔离) runPlanApprovalGate(用户可见)
PR head b1baf061 ✅ gate 在隔离signal 下运行(!== parent、未 abort)→ 返回 pass kind: 'approved',1 次尝试
merge-base 1cb0d7194(修复前) ❌ gate 继承父 signal → 被 abort → CANCELLED(抛错) expected 'unavailable' to be 'approved'

merge-base 的失败正是所报告的 bug:gate 被继承的瞬态 abort 取消,重试被跳过,调用返回 unavailable —— 这正是导致模型反复重新调用 exit_plan_mode 而卡住的根因。PR 把两者都翻成绿。(我的 verify 测试是维护者编写的脚手架,不属于 PR。)

2. 既有套件 + typecheck(PR head)

文件 测试数
plan-gate/planApprovalGate.test.ts 22
plan-gate/gateReviewAgents.test.ts 12
utils/retry.test.ts 84
tsc --noEmit(core,仅 PR 代码) 无报错 ✅

全绿(作者从未跑过)。注意:planApprovalGate.test.ts 现在墙钟约 4 秒,因为两个重试测试会触发 PR 真实的 1 秒退避 —— 功能没问题,只是更慢;可以考虑是否接受测试中用真实 delay()(而非 fake timers)。

3. 集成 TUI 跑通 —— 真实 exit_plan_mode → gate → 批准

tmux 下驱动真实二进制(YOLO)。模型进入 plan 模式(prePlanMode=YOLO、创建 gate state),调用 exit_plan_mode真实 gate 子代理在隔离 controller 下被 spawn,经 mock 模型评审返回 pass,gate 批准并恢复 YOLO:

✓  EnterPlanMode  Entered plan mode.
✓  ExitPlanMode   Gate approved.
                  1. Create src/Button.tsx with a Button component
                  2. Wire it into App.tsx
                  3. Add a unit test in Button.test.tsx
✦ ✅ Plan approved by the gate and plan mode exited.

这跑通了真实集成路径(ExitPlanModeTool → runPlanApprovalGate → runGateAgent → SubagentManager.createAgentHeadless)且隔离改动已生效 —— happy path 未被破坏。(abort 竞态本身由 §1 确定性证明;此处 mock 模型瞬时返回,不发生瞬态 abort。)

4. 一个需要权衡的设计取舍(非正确性 bug)

该修复完全隔离了 gate 与父 signal,因此 gate 运行期间真正的用户取消(ESC)不再被感知 —— gate 会一直跑到它自己的 5 分钟超时。作者将其定位为有意为之,与 agent.ts 中后台代理的隔离模式一致,并指出 AbortSignal 是布尔量,无法区分瞬态 abort(NO_FINISH_REASON/round cleanup)与真实用户取消。该论证成立,但最坏情况是真实 ESC 后出现约 5 分钟不可取消的 gate —— 值得明确签字确认,并最好在运行时将来能暴露 reason/type 时做个后续区分。

结论

该修复解决了所报告的 bug(A/B 证明:merge-base 复现 unavailable,PR head 返回 approved),通过全部既有 plan-gate/retry 测试 + typecheck,且集成 exit_plan_mode → gate 路径在 PR 构建上端到端可用。就正确性而言可合并 —— 唯一需要明确决定的是 §4 中「gate 运行期间 ESC」的隔离取舍。👍

Verified locally in an isolated environment at head b1baf061. The A/B test drives the real runGateAgent/runPlanApprovalGate; only the subagent (LLM) boundary is mocked. Bug reproduced on merge-base 1cb0d7194, fixed on PR head.

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

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

Hi @CubeLander — thanks for the PR and the detailed root-cause analysis. The fix itself looks well-reasoned.

Before we can proceed with review, the PR body needs to follow our PR template. The current description uses custom headings (Bug Description, Fix, Changes, Testing, Patch File) that don't match the required structure. Could you restructure the body to include these sections?

  • What this PR does — describe the change in prose
  • Why it's needed — the motivation / user-facing problem (your root-cause analysis fits well here)
  • Reviewer Test Plan — how a reviewer can verify the fix: reproduction steps, expected vs observed behavior, and evidence (before/after). The Testing section you already have is a good start.
  • Risk & Scope — main tradeoff (you already discuss the ESC-during-gate 5-min wait — this belongs here), what's out of scope, breaking changes
  • Linked Issues — any related issue references
  • 中文说明 — Chinese translation of the above in a <details> block

The technical content is solid — this is purely a formatting step so reviewers can navigate consistently. Happy to help if anything in the template is unclear.

中文说明

@CubeLander 你好——感谢 PR 和详尽的根因分析,修复本身思路清晰。

在进入代码审查之前,PR 正文需要按照我们的 PR 模板 来组织。当前描述使用了自定义标题(Bug DescriptionFixChangesTestingPatch File),与模板要求的结构不匹配。请补充以下章节:

  • What this PR does — 用文字描述改动
  • Why it's needed — 动机 / 用户侧问题(你的根因分析很适合放在这里)
  • Reviewer Test Plan — 审查者如何验证:复现步骤、期望与实际行为、证据(已有 Testing 部分是好的起点)
  • Risk & Scope — 主要取舍(你已讨论了 ESC 期间 5 分钟等待——放在这里),不在范围内的内容,破坏性变更
  • Linked Issues — 相关 issue 引用
  • 中文说明 — 以上内容的中文翻译,放在 <details> 折叠块中

技术内容很好——这纯粹是格式调整,方便审查者统一浏览。如模板有疑问随时沟通。

Qwen Code · qwen3.7-max

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR, @CubeLander!

Template looks good ✓

On direction: this is a clear-cut bug fix — users getting permanently stuck in plan mode under AUTO/YOLO pre-plan is a real, reproducible problem. The root cause (transient parent aborts cascading into the gate agent via shared AbortSignal) is well-diagnosed, and the fix follows an established codebase pattern: background-agent-resume.ts uses the same isolated-AbortController approach for its subagents. CHANGELOG doesn't have a direct reference, but the plan-gate system is core infrastructure and this is squarely within scope.

On approach: the scope is tight — 3 files, 74 additions, every change directly serves the stated goal. The three-part split (signal isolation in runGateAgent, retry logic in runAgentWithRetry, additive delay export) is clean and minimal. The ESC-during-gate tradeoff is a conscious design choice, not a bug, and the author addresses it honestly. No scope creep, no drive-by refactors.

Moving on to code review. 🔍

中文说明

感谢贡献,@CubeLander

模板完整 ✓

方向:这是一个明确的 bug 修复 —— AUTO/YOLO pre-plan 下用户永久卡在 plan 模式是真实的、可复现的问题。根因(瞬态父级 abort 通过共享 AbortSignal 级联进 gate agent)诊断准确,修复遵循了代码库已有的模式:background-agent-resume.ts 对其子 agent 使用了相同的隔离 AbortController 方案。CHANGELOG 无直接引用,但 plan-gate 系统是核心基础设施,此修复完全在范围内。

方案:范围精简 —— 3 个文件,74 行新增,每处改动都直接服务于声明的目标。三部分拆分(runGateAgent 中的 signal 隔离、runAgentWithRetry 中的重试逻辑、新增 delay 导出)干净且最小化。gate 运行期间 ESC 的取舍是有意的设计选择而非 bug,作者也坦诚地讨论了这一点。无范围蔓延,无顺手重构。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

No critical blockers found. The implementation matches what I'd independently propose — isolate the gate agent with its own AbortController + timeout, move the abort check to entry-time, add abort-aware backoff between retries. The signal isolation pattern in gateReviewAgents.ts is clean: timeout gets cleared in finally, the controller is aborted if it hasn't already fired (preventing lingering listeners), and the parent signal parameter is preserved for API stability.

One observation for the maintainer to weigh in on: the PR ships no tests for the new branches — abort during the 1s backoff delay, parent-signal isolation (gate keeps running when parent aborts mid-flight), pre-aborted entry path, and the 5-minute timeout path. The existing suites still pass, but those new code paths are uncovered. The previous review round already flagged this; the author's position is that the existing tests cover the retry/gate paths. Worth a maintainer call on whether follow-up tests should block merge or land separately.

Test Results

✓ src/plan-gate/gateReviewAgents.test.ts (12 tests)     10ms
✓ src/plan-gate/planApprovalGate.test.ts  (22 tests)  4018ms
✓ src/utils/retry.test.ts               (84 tests)   103ms
  Test Files  3 passed (3)
       Tests  118 passed (118)

✓ tsc --noEmit (core) — clean
✓ npm run build (core) — clean

Note: planApprovalGate.test.ts takes ~4s wall clock because two retry tests exercise the PR's real 1s delay() backoff. Functionally correct, just worth being aware of.

Real-Scenario Testing

This PR fixes internal signal/retry logic — the bug requires a transient NO_FINISH_REASON stream error during exit_plan_mode, which cannot be deterministically triggered from the TUI. A tmux before/after would only show the happy path, which doesn't exercise the bug.

Maintainer @wenshao independently verified this with three methods:

  • Real-code A/B test: reproduced the bug on merge-base (unavailable), confirmed fix on PR head (approved)
  • Integrated TUI run: drove real exit_plan_mode → gate → approve under YOLO mode
  • All existing suites + typecheck: green

That verification is the decisive evidence for this PR.

中文说明

代码审查

未发现关键阻断问题。实现与我的独立方案一致 —— 用独立的 AbortController + 超时隔离 gate agent,将 abort 检查移至入口时,重试间加入 abort 感知的退避。gateReviewAgents.ts 中的 signal 隔离模式干净:超时在 finally 中清除,控制器在尚未触发时被 abort(防止残留监听器),父级 signal 参数为 API 稳定性而保留。

一个需要维护者权衡的观察:PR 未为新分支补测试 —— 1 秒退避 delay 期间的 abort、父级 signal 隔离(父级中途 abort 时 gate 仍继续运行)、预先 abort 的入口路径、以及 5 分钟超时路径。既有套件仍然通过,但这些新代码路径未被覆盖。上一轮审查已提出此问题;作者的立场是既有测试覆盖了重试/gate 路径。需要维护者决定后续测试应该阻断合并还是可以单独提交。

测试结果

✓ src/plan-gate/gateReviewAgents.test.ts (12 个测试)     10ms
✓ src/plan-gate/planApprovalGate.test.ts  (22 个测试)  4018ms
✓ src/utils/retry.test.ts               (84 个测试)   103ms
  测试文件  3 通过 (3)
  测试    118 通过 (118)

✓ tsc --noEmit (core) — 无报错
✓ npm run build (core) — 成功

注意:planApprovalGate.test.ts 墙钟约 4 秒,因为两个重试测试会触发 PR 真实的 1 秒 delay() 退避。功能正确,仅供注意。

真实场景测试

本 PR 修复内部 signal/重试逻辑 —— bug 需要在 exit_plan_mode 期间触发瞬态 NO_FINISH_REASON 流错误,这无法从 TUI 确定性触发。tmux 前后对比只能展示 happy path,无法复现该 bug。

维护者 @wenshao 已用三种方法独立验证:

  • 真实代码 A/B 测试:在 merge-base 上复现 bug(unavailable),在 PR head 上确认修复(approved
  • 集成 TUI 运行:在 YOLO 模式下驱动真实的 exit_plan_mode → gate → 批准
  • 全部既有套件 + typecheck:通过

该验证是本 PR 的决定性证据。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Stepping back: this is a well-executed bug fix for a critical issue. The exit_plan_mode infinite-retry loop under AUTO/YOLO pre-plan mode is a genuine user-facing problem — getting permanently stuck in plan mode is not a minor inconvenience, it blocks all work.

The implementation is exactly what I'd have proposed independently. Three focused changes, each serving the stated goal, no scope creep. The signal isolation pattern mirrors what the codebase already does for background agents in background-agent-resume.ts. The 5-minute ESC-during-gate tradeoff is a conscious design choice, not an oversight, and the author has addressed it honestly with a clear path to improvement (abort-reason semantics) if it proves problematic.

All 118 tests pass, typecheck is clean, build succeeds. The maintainer A/B verification is thorough and decisive — bug reproduced on merge-base, fixed on PR head, integrated TUI run confirms the happy path.

The one open question is test coverage for the new branches (abort during backoff, parent-signal isolation, timeout path). That's a real gap worth addressing, but it doesn't block this fix — the correctness is proven by the maintainer's A/B test, and follow-up tests can land separately.

Approving. ✅

中文说明

退后一步看:这是一个针对关键问题的出色 bug 修复。AUTO/YOLO pre-plan 模式下的 exit_plan_mode 无限重试循环是一个真实的用户问题 —— 永久卡在 plan 模式不是小不便,它阻断了所有工作。

实现与我的独立方案完全一致。三处聚焦的改动,每处服务于声明的目标,无范围蔓延。signal 隔离模式与代码库中 background-agent-resume.ts 对后台 agent 的做法一致。5 分钟的 ESC-during-gate 取舍是有意识的设计选择而非疏忽,作者坦诚地讨论并提供了一条清晰的改进路径(abort-reason 语义)。

全部 118 个测试通过,typecheck 干净,构建成功。维护者的 A/B 验证彻底且决定性 —— bug 在 merge-base 上复现,在 PR head 上修复,集成 TUI 运行确认了 happy path。

唯一的开放问题是新分支的测试覆盖(退避期间 abort、父级 signal 隔离、超时路径)。这是一个值得解决的真实缺口,但不应阻断此修复 —— 正确性已由维护者的 A/B 测试证明,后续测试可以单独提交。

批准 ✅

Qwen Code · qwen3.7-max

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

@wenshao
wenshao merged commit 3c8eef5 into QwenLM:main Jun 18, 2026
63 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.

5 participants