fix(agent): cap fork turns and bubble fork permission prompts#5737
Conversation
A detached fork subagent runs fire-and-forget in the background with no
inline UI. Two robustness gaps followed from that:
- It was launched with an empty RunConfig (`{} as RunConfig`), so its
reasoning loop had no max_turns cap and could burn tokens unbounded.
Add FORK_DEFAULT_MAX_TURNS=200 and pass it to AgentHeadless.create.
- FORK_AGENT used approvalMode 'default', so in the background launch
path shouldBubble was false and permission-gated tool calls were
silently auto-denied (the fork couldn't even run its own "commit
before reporting"). Switch to 'bubble' so prompts surface to the
parent's Background-tasks UI; non-interactive forks are already gated
off, so there is no headless regression.
Closes QwenLM#5734.
Co-authored-by: Qwen-Coder <[email protected]>
| // Turn cap for a detached fork — fire-and-forget background work nobody awaits, | ||
| // so an unbounded reasoning loop burns tokens silently. Matches claude-code's | ||
| // fork cap of 200. | ||
| export const FORK_DEFAULT_MAX_TURNS = 200; |
There was a problem hiding this comment.
[Suggestion] The constant name FORK_DEFAULT_MAX_TURNS implies overridability ("default" suggests other values are possible), but the cap is applied unconditionally in createForkSubagent with no escape hatch — no environment variable, no config setting, no tool parameter. A user whose legitimate fork task exceeds 200 turns (e.g., bulk file edits across hundreds of files) will hit a silent termination with no way to raise the limit.
Either rename to FORK_MAX_TURNS (dropping the "DEFAULT" implication) to signal it is an absolute cap, or accept an optional max_turns from the AgentTool params and clamp it to a reasonable range, falling back to 200 when absent.
— Claude 3.5 Sonnet via Qwen Code /review
| promptConfig, | ||
| {}, | ||
| {} as RunConfig, | ||
| { max_turns: FORK_DEFAULT_MAX_TURNS }, |
There was a problem hiding this comment.
[Suggestion] The RunConfig for forks includes only max_turns with no max_time_minutes. A fork doing 199 turns of expensive tool calls (long shell commands, large file I/O, network requests) could still run for hours and accumulate significant API costs. The workflow orchestrator applies both a turn cap (50) and a time cap (10 minutes) for defense in depth; the fork path has only one axis of protection.
Consider adding a time cap as a second safety net:
| { max_turns: FORK_DEFAULT_MAX_TURNS }, | |
| { max_turns: FORK_DEFAULT_MAX_TURNS, max_time_minutes: 30 }, |
— Claude 3.5 Sonnet via Qwen Code /review
|
Thanks for the PR, @qqqys — and for the follow-up commit fixing the resume path. Template headings diverge from the PR template ("## What" / "## Test" instead of "## What this PR does" / "## Why it's needed" / "## Reviewer Test Plan"), but all substantive content is present. Not blocking — just a heads-up for next time. On direction: this is squarely in scope. The fork subagent runs fire-and-forget in the background with no inline UI — shipping it without a turn cap and with auto-deny on permission prompts was a clear robustness gap. Both fixes address real failure modes (unbounded token burn and silently denied tool calls), and closing #5734 confirms the user impact. On approach: the scope is minimal — a constant, a field change, and two call-site updates. Nothing to cut. The Moving on to code review. 🔍 中文说明感谢 PR,@qqqys——以及修复 resume 路径的后续提交。 模板标题与 PR 模板 不一致(用了 "## What" / "## Test" 而不是 "## What this PR does" / "## Why it's needed" / "## Reviewer Test Plan"),但实质内容齐全,不阻塞——下次注意即可。 方向:完全在范围内。fork subagent 在后台 fire-and-forget 运行且无内联 UI——发布时缺少 turn 上限和权限提示自动拒绝是明显的健壮性缺口。两个修复都针对真实失败模式(无限 token 消耗和静默拒绝的工具调用),关闭 #5734 确认了用户影响。 方案:范围最小化——一个常量、一个字段更改和两个调用点更新。没有可砍的部分。 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
Code ReviewIndependent proposal: Before reading the diff, I'd solve this with (1) a Diff comparison: The PR matches this approach exactly. Both call sites are covered (the second commit Findings:
No blockers found. TestingThese changes are purely internal to fork subagent dispatch — no TUI-visible behavior to exercise in tmux. Verification is through the unit test suite:
The PR author reports 中文说明代码审查独立方案: 读 diff 之前,我会用 (1) 在 Diff 对比: PR 与方案完全一致。两个调用点都已覆盖(第二个 commit 发现:
未发现阻塞项。 测试这些更改完全在 fork subagent 调度内部——无 TUI 可见行为可在 tmux 中测试。通过单元测试套件验证:
PR 作者报告 — Qwen Code · qwen3.7-max |
|
This PR does exactly what it says — two focused robustness fixes for the fork subagent, each addressing a real failure mode. The scope is minimal (a constant, a field change, two call-site updates, two test assertions), and there's no drive-by refactoring or scope creep. My independent proposal matched the PR's approach exactly, which is a good sign — the solution is the obvious one. The second commit fixing the resume path shows the author is responsive to review feedback. The code is straightforward, the comments explain why (not what), and the tests assert on the exact contract that matters. If I had to maintain this in six months, I'd thank the author. Approving. ✅ 中文说明此 PR 做了它所说的——两个聚焦的 fork subagent 健壮性修复,每个都针对真实失败模式。范围最小化(一个常量、一个字段更改、两个调用点更新、两个测试断言),无顺手重构或范围蔓延。 我的独立方案与 PR 方案完全一致,这是好信号——解决方案是显而易见的那个。修复 resume 路径的第二个 commit 表明作者对审查反馈积极响应。 代码直截了当,注释解释为什么(而非做了什么),测试断言了重要的精确契约。如果六个月后需要维护此代码,我会感谢作者。 批准 ✅ — Qwen Code · qwen3.7-max |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
[Critical] Missed call site: resumed forks bypass the 200-turn cap
createResumedForkSubagent in background-agent-resume.ts:1168 still passes {} as RunConfig — no max_turns. The PR caps the fresh-launch path (agent.ts:1301), but the resume path is a separate call site that was not patched.
When a backgrounded fork is paused (user exits, process interrupted) and later resumed via BackgroundAgentResume, it runs with no turn limit — the exact scenario the turn cap was designed to prevent.
{ max_turns: FORK_DEFAULT_MAX_TURNS },
Import FORK_DEFAULT_MAX_TURNS from ../tools/agent/fork-subagent.js in background-agent-resume.ts and apply it at line 1168, matching the fix already in agent.ts:1301.
— qwen3.7-max via Qwen Code /review
CI failure root cause —
|
wenshao
left a comment
There was a problem hiding this comment.
No additional findings from this pass.
I re-checked the fork launch change and focused on the surrounding resume path. The fresh fork path now carries max_turns: 200 and the new agent.test.ts case passes locally. I also reproduced the existing background-agent-resume.test.ts failures, and the underlying resume-path/test-mock issues are already covered by the existing PR discussion, so I am not adding duplicate inline comments here.
— GPT-5 Codex via Qwen Code /review
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
Both fixes are well-scoped and correctly implemented — the turn cap is properly wired through both the fresh-launch and resume paths, and the bubble approval mode change is gated on isInteractive() to avoid headless regressions. Tests cover the structural contracts.
— qwen3.7-max via Qwen Code /review
✅ E2E verification — fork turn cap & permission bubblingBuilt the CLI from this PR's HEAD ( Fix 2 — bubble fork permission prompts (
|
pre-PR approvalMode: 'default' |
this PR approvalMode: 'bubble' |
|
|---|---|---|
| Gated tool call | silently auto-denied | prompt surfaces in the parent's Background-tasks UI |
| Fork transcript | 200 × Tool "write_file" requires permission, but background agents cannot prompt for confirmation. The tool call was denied. |
1 turn, then pauses awaiting approval — 0 denials |
| Outcome | file never written; the fork fails having accomplished nothing (it cannot even run its mandated "commit before reporting") | approve in the UI → tool executes → fork_marker.txt written ✔ |
The bubbled prompt, exactly as it appears in the parent session (↓ opens the background-tasks panel → Enter for detail):
fork › create the marker file please
1 tool call
Progress
> WriteFile(Writing to fork_marker.txt)
Background agent needs approval
1 FORK_E2E_MARKER
Apply this change?
› 1. Yes, allow once
2. No
Footer pill in default view: 1 local agent ⚠ needs approval. Selecting Yes lets the fork finish the write — confirming permission prompts now reach the user instead of being silently swallowed.
Fix 1 — cap fork turns (max_turns: FORK_DEFAULT_MAX_TURNS = 200)
Non-converging fork (model keeps requesting an auto-approved tool); approvalMode: 'bubble' held constant in both arms:
pre-PR {} as RunConfig |
this PR { max_turns: 200 } |
|
|---|---|---|
| Behavior | unbounded — 686 turns in ~11 s and still running when forcibly killed | terminates at exactly 200 turns |
| Termination record | none (would burn tokens indefinitely) | status: "failed", lastError: "Agent terminated with mode: MAX_TURNS" |
Deterministic layer
npm run build✅ (0 errors)- fork unit tests ✅ —
agent.test.ts(111) +background-agent-resume.test.ts(28), 139 passing, including the newmax_turns: 200andapprovalMode === BUBBLE_APPROVAL_MODEassertions.
Scope notes
- The launch path (
agent.ts) andapprovalMode(fork-subagent.ts) were exercised end-to-end. The resume path cap (background-agent-resume.ts) is the identical one-line change and is covered by the unit suite, not separately driven in the live harness. - A mock model was used only to make the fork deterministically emit tool calls; all code under test is the real built artifact, unmodified except for the A/B dist swaps described above.
Verdict: both fixes behave exactly as described — the gated-tool prompt now bubbles to the user, and a runaway fork is bounded at 200 turns. LGTM for merge. ✅
🇨🇳 中文版本
✅ 端到端验证 —— fork 轮次上限 & 权限弹窗冒泡
从本 PR 的 HEAD(a65273a0)构建出真实 CLI,并通过 tmux 驱动真实交互式 TUI,后端接一个确定性的 mock OpenAI server(让游离的 fork 稳定地发起工具调用)。每个修复都通过仅替换构建产物 dist 在「本 PR 行为」与「PR 之前行为」之间做 A/B 对比,且固定另一处改动不变 —— 因此下面每一处差异都可精确归因到 diff 中的某一行。
修复 2 —— fork 权限弹窗冒泡(FORK_AGENT.approvalMode)
让 fork 调用一个需要授权的工具(write_file),两组都固定 max_turns: 200:
PR 之前 approvalMode: 'default' |
本 PR approvalMode: 'bubble' |
|
|---|---|---|
| 受限工具调用 | 被静默自动拒绝 | 提示冒泡到父会话的 Background-tasks UI |
| fork transcript | 200 次 Tool "write_file" requires permission, but background agents cannot prompt for confirmation. The tool call was denied. |
仅 1 轮,随后暂停等待授权 —— 0 次拒绝 |
| 结果 | 文件从未写入;fork 一事无成地失败(连它被强制要求的「报告前先提交」都做不了) | 在 UI 中批准 → 工具执行 → 成功写入 fork_marker.txt ✔ |
冒泡出的授权提示在父会话中的真实样子(↓ 打开后台任务面板 → Enter 查看详情):
fork › create the marker file please
1 tool call
Progress
> WriteFile(Writing to fork_marker.txt)
Background agent needs approval
1 FORK_E2E_MARKER
Apply this change?
› 1. Yes, allow once
2. No
默认视图底部提示:1 local agent ⚠ needs approval。选择 Yes 后 fork 即可完成写入 —— 证明权限提示现在能送达用户,而不再被静默吞掉。
修复 1 —— fork 轮次上限(max_turns: FORK_DEFAULT_MAX_TURNS = 200)
构造一个无法收敛的 fork(模型持续请求一个自动放行的工具),两组都固定 approvalMode: 'bubble':
PR 之前 {} as RunConfig |
本 PR { max_turns: 200 } |
|
|---|---|---|
| 行为 | 无上限 —— 约 11 秒内跑了 686 轮,被强制 kill 时仍在运行 | 恰好在第 200 轮终止 |
| 终止记录 | 无(会无限消耗 token) | status: "failed", lastError: "Agent terminated with mode: MAX_TURNS" |
确定性层
npm run build✅(0 error)- fork 单测 ✅ ——
agent.test.ts(111)+background-agent-resume.test.ts(28),139 个全部通过,含新增的max_turns: 200与approvalMode === BUBBLE_APPROVAL_MODE断言。
范围说明
- 启动路径(
agent.ts)与approvalMode(fork-subagent.ts)已端到端验证。**恢复(resume)**路径的上限(background-agent-resume.ts)是完全相同的一行改动,由单测覆盖,未在实机 harness 中单独驱动。 - mock 模型仅用于让 fork 确定性地发出工具调用;被测代码全部是真实构建产物,除上述 A/B dist 替换外未作任何改动。
结论:两处修复的行为与描述完全一致 —— 受限工具的提示现在会冒泡给用户,失控的 fork 被限制在 200 轮。建议合并。✅
What
Two robustness fixes for the detached fork subagent (
subagent_type: "fork"//fork), both stemming from the fork running fire-and-forget in the background with no inline UI. See #5734.1. Cap fork turns
createForkSubagentlaunched the fork with{} as RunConfig, leavingmax_turnsunset — a fire-and-forget fork that fails to converge could loop and burn tokens with no upper bound. AddsFORK_DEFAULT_MAX_TURNS = 200(matching claude-code's fork cap) and passes it toAgentHeadless.create.2. Bubble fork permission prompts
FORK_AGENTusedapprovalMode: 'default', so in the background launch pathshouldBubblewas false andgetShouldAvoidPermissionPrompts()returnedtrue→ any permission-gated tool call was silently auto-denied (the fork couldn't even run its boilerplate-mandated "commit before reporting"). Switches to'bubble'so prompts surface to the parent's Background-tasks UI via the existing bridge. Non-interactive forks are already gated off (isForkSubagentEnabled = config.isInteractive()), so there is no headless regression.Test
agent.test.tscase: fork dispatch passes{ max_turns: 200 }toAgentHeadless.create, andFORK_AGENT.approvalMode === BUBBLE_APPROVAL_MODE.packages/coretypecheck + tests green (159 passing);packages/clitypecheck green.Closes #5734.