Add stop hook blocking cap#4208
Conversation
📋 Review SummaryThis PR introduces a configurable consecutive blocking cap for Stop/SubagentStop hooks to prevent turns from continuing indefinitely when blocking hooks keep firing. The implementation is well-structured with proper configuration wiring across CLI settings, core config, ACP integration, and subagent stop loops. The default cap of 8 iterations provides a reasonable safety net while allowing users to customize via settings or environment variable. 🔍 General Feedback
🎯 Specific Feedback🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
| } | ||
|
|
||
| const normalized = Math.floor(value); | ||
| return normalized >= 1 ? normalized : DEFAULT_STOP_HOOK_BLOCK_CAP; |
There was a problem hiding this comment.
[Critical] normalizeStopHookBlockingCap has no upper bound — only enforces >= 1. A large value like 99999 passes through. In client.ts, the Stop hook loop is recursive (yield* this.sendMessageStream(...)), so each blocking decision adds a stack frame. A large cap will cause a "Maximum call stack size exceeded" crash long before hitting the iteration cap.
| return normalized >= 1 ? normalized : DEFAULT_STOP_HOOK_BLOCK_CAP; | |
| const MAX_STOP_HOOK_CAP = 100; | |
| return normalized >= 1 ? Math.min(normalized, MAX_STOP_HOOK_CAP) : DEFAULT_STOP_HOOK_BLOCK_CAP; |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
|
|
||
| export function resolveStopHookBlockingCap(configValue?: number): number { | ||
| const envValue = process.env[STOP_HOOK_BLOCK_CAP_ENV]; | ||
| if (envValue !== undefined) { |
There was a problem hiding this comment.
[Suggestion] process.env[STOP_HOOK_BLOCK_CAP_ENV] returns "" (not undefined) when the env var is set to empty — a common pattern in Docker/k8s configs. "" !== undefined is true, so Number("") = 0, then normalizeStopHookBlockingCap(0) = 8. An empty env var should be treated as "not set" and fall through to the config value.
| if (envValue !== undefined) { | |
| if (envValue !== undefined && envValue !== '') { |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| const maxIterations = 5; | ||
| const maxIterations = this.config.getStopHookBlockingCap(); | ||
|
|
||
| for (let i = 0; i < maxIterations; i++) { |
There was a problem hiding this comment.
[Suggestion] Stop/SubagentStop cap semantics have an off-by-one inconsistency. Stop hook paths (client.ts, Session.ts) check currentIterationCount >= cap after increment, allowing cap-1 retries. SubagentStop paths (agent.ts, background-agent-resume.ts) use for (let i = 0; i < maxIterations; i++), allowing exactly cap retries. For cap=8: Stop gets 7 retries, SubagentStop gets 8. Consider unifying: change the loop condition to i < maxIterations - 1 or restructure to match Stop's count-and-check pattern.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| @@ -1014,7 +1015,10 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> { | |||
| } | |||
|
|
|||
| debugLogger.warn( | |||
There was a problem hiding this comment.
[Suggestion] When the SubagentStop cap is reached in agent.ts and background-agent-resume.ts, the warning is only logged via debugLogger.warn — never surfaced to the user. Contrast with client.ts (emits HookSystemMessage event visible in TUI) and Session.ts (calls emitAgentMessage to ACP clients). Users have no way to know their SubagentStop hook was overridden, breaking trust in hook transparency.
Consider emitting the warning through a user-visible channel (e.g., appending to the subagent's ToolResult.llmContent or returnDisplay), consistent with how Stop hook cap warnings reach the user.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
No additional review findings. Downgraded from Approve to Comment: CI failing: Post Coverage Comment, Test (windows-latest, Node 22.x). — gpt-5.5 via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
Test coverage gaps: Three integration points for the new cap have no dedicated tests:
abortGoalForStopHookCapingoalHook.ts— no test exists for either branch (active goal present vs absent)- ACP
Session.test.ts— no test exercises the cap-enforcement path inrunStopHookLoop background-agent-resume.test.ts— no test exercises the SubagentStop cap enforcement inhandleSubagentStopLoop
All targeted unit tests (stopHookCap, client, agent) pass: 226 tests green.
| @@ -1014,7 +1026,10 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> { | |||
| } | |||
There was a problem hiding this comment.
[Suggestion] Dead code: the debugLogger.warn after the for-loop is unreachable. The in-loop cap check currentIterationCount >= maxIterations always fires on the last iteration (i = maxIterations - 1) and executes return, so the loop can never exit naturally.
| } | |
| // (remove the post-loop debugLogger.warn block — it is unreachable) | |
| } |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| @@ -1022,7 +1034,10 @@ export class BackgroundAgentResumeService { | |||
| } | |||
There was a problem hiding this comment.
[Suggestion] Same dead-code pattern as agent.ts: the debugLogger.warn after the for-loop is unreachable because the in-loop cap check always returns on the final iteration.
| } | |
| // (remove the post-loop debugLogger.warn block — it is unreachable) | |
| } |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| export const DEFAULT_STOP_HOOK_BLOCK_CAP = 8; | ||
| export const MAX_STOP_HOOK_BLOCK_CAP = 100; | ||
| export const STOP_HOOK_BLOCK_CAP_ENV = 'QWEN_CODE_STOP_HOOK_BLOCK_CAP'; | ||
|
|
There was a problem hiding this comment.
[Suggestion] DEFAULT_STOP_HOOK_BLOCK_CAP = 8 is a significant drop from the old hardcoded limits (100 for Stop hook in client.ts/Session.ts, 5 for SubagentStop). Users with iterative Stop hooks that relied on >8 retries will see a silent behavioral regression after upgrading. The warning message does not mention the old limit or how to restore it (QWEN_CODE_STOP_HOOK_BLOCK_CAP=100). Consider either bumping the default for backward compatibility or surfacing a one-time deprecation notice on the first cap hit.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| @@ -1559,15 +1561,26 @@ export class GeminiClient { | |||
| continueReason, | |||
There was a problem hiding this comment.
[Suggestion] The design rationale comment explaining why StopHookLoop is emitted on the first blocking iteration (not just subsequent ones) was deleted. This context — /goal needing first-iteration visibility, and configured Stop hooks needing their blocking reason surfaced — helps future maintainers understand the intentional >= cap check placement relative to the yield.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| stopHookReasons = [...stopHookReasons, continueReason]; | ||
|
|
||
| // Emit StopHookLoop event for iterations after the first one | ||
| if (stopHookIterationCount >= stopHookBlockingCap) { |
There was a problem hiding this comment.
[Critical] #handleStopHookLoop 的 cap-enforcement 路径未测试。config mock 已添加 getStopHookBlockingCap: vi.fn().mockReturnValue(8),但没有测试将 cap 设为低值(如 1 或 2)来触发 stopHookIterationCount >= stopHookBlockingCap 分支。当前的两个 Stop hook 测试在 cap=8 下分别仅运行 1-2 次迭代,远未达到上限。
Session.ts 的 cap 逻辑独立处理 ACP 会话(VSCode companion)的 stop hook 循环。此路径的 bug 仅会在 ACP 会话中暴露,现有测试无法捕获。
| if (stopHookIterationCount >= stopHookBlockingCap) { | |
| // Add a test that sets up a blocking Stop hook and getStopHookBlockingCap.mockReturnValue(1), | |
| // then asserts the prompt completes with stopReason: 'end_turn' on cap hit | |
| // and that the warning message is emitted via messageEmitter.emitAgentMessage. |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| // model or subagent would otherwise finish, so keep user-facing labels | ||
| // explicit instead of accepting arbitrary hook names. | ||
| const hookName = hookLabel === 'Stop' ? 'Stop hook' : 'SubagentStop hook'; | ||
| return `${hookName} blocked continuation ${cap} consecutive times; overriding and ending the turn.`; |
There was a problem hiding this comment.
[Suggestion] formatStopHookBlockingCapWarning 在 cap=1 时输出语法错误:"1 consecutive times" 应为 "1 consecutive time"。cap=1 是完全合法的配置值。
| return `${hookName} blocked continuation ${cap} consecutive times; overriding and ending the turn.`; | |
| const hookName = hookLabel === 'Stop' ? 'Stop hook' : 'SubagentStop hook'; | |
| const timesWord = cap === 1 ? 'time' : 'times'; | |
| return `${hookName} blocked continuation ${cap} consecutive ${timesWord}; overriding and ending the turn.`; |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
|
|
||
| const debugLogger = createDebugLogger('AGENT'); | ||
|
|
||
| function appendSubagentStopWarning( |
There was a problem hiding this comment.
[Suggestion] appendSubagentStopWarning 辅助函数(4 行)和 runSubagentStopHookLoop 方法(~50 行)在 agent.ts 和 background-agent-resume.ts 中完全重复。两处代码结构相同——相同的循环、相同的 cap 解析、相同的 formatStopHookBlockingCapWarning 调用。
任何 bug 修复都需要在两处同步修改,极易出现分歧。建议将共享逻辑提取到 packages/core/src/hooks/stopHookCap.ts 或新建共享模块,从两个调用点导入。
— DeepSeek/deepseek-v4-pro via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
No new review findings beyond the existing open inline comments. All 332 focused tests pass.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
Incremental review: 3 prior issues addressed (grammar fix, deduplication of appendSubagentStopWarning → appendStopHookBlockingCapWarning, ACP cap tests). 8 existing inline comments remain open from previous review but are not reintroduced. No new issues found; all 318 tests pass, typecheck + lint clean.
LGTM for the incremental changes. ✅
— DeepSeek/deepseek-v4-pro via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
Verified locally on top of main.
Local test results
- PR-listed
npx vitest run src/hooks/stopHookCap.test.ts src/core/client.test.ts src/tools/agent/agent.test.ts(inpackages/core): 214 passed / 3 files (PR body lists 205 — count grew with the later test commits) - Full
packages/coresuite: 8302 passed / 307 files (4 skipped) - Touched cli tests (
Session.test.ts+goalCommand.test.ts+GoalStatusMessage.test.tsx): 85 passed / 3 files - Full
packages/clisuite: 6079 passed; the 2 failing files (doctorChecks.test.tsfor Node v22 check,server.test.tsfor missingsupertest) reproduce identically onmain— pre-existing local env, not caused by this PR npm run typecheckacross all workspaces: cleangit diff --check: clean- Remote CI: Lint / CodeQL / Test mac · ubuntu · windows (Node 22.x) all SUCCESS
Design notes
stopHookCap.tsis a small, well-factored module: invalid/0/negative → default 8, fractional → floor, clamped to MAX = 100; env (QWEN_CODE_STOP_HOOK_BLOCK_CAP) takes precedence over config; empty env string falls through.- The cap is enforced identically at all 4 hook-loop sites (
core/client.tsmain Stop,tools/agent/agent.tsforeground + worktree SubagentStop,agents/background-agent-resume.tsbackground SubagentStop,cli/acp-integration/session/Session.tsACP Stop), each going throughconfig.getStopHookBlockingCap()andformatStopHookBlockingCapWarning(...). abortGoalForStopHookCapcorrectly finalizes an active/goalasabortedwhen the main-loop / ACP cap fires, so cap-induced termination doesn't leak zombie goal state.- Settings schema is registered as Advanced /
requiresRestart: true/showInDialog: false, with default sourced fromDEFAULT_STOP_HOOK_BLOCK_CAPso the dialog and runtime stay in sync.
Two things worth flagging for the author
-
ACP Session cap default tightening (100 → 8).
cli/acp-integration/session/Session.tspreviously hard-codedMAX_STOP_HOOK_ITERATIONS = 100; with this PR ACP clients running long-blocking Stop hooks will hit the cap ~12.5× sooner. The escape hatch (stopHookBlockingCap/QWEN_CODE_STOP_HOOK_BLOCK_CAP) is in place and the PR body calls this out, but it's a meaningful default change worth a one-line note in release/migration text. -
Bundled
/goalUX + comment cleanup (commita7ea51488 fix(cli): show goal judge details) is outside the stop-hook-cap scope:GoalStatusMessagenow shows the goal condition and judge reason on everycheckingiteration (the previous code intentionally hid these to avoid per-turn chip clutter), and several files lose their provenance comments referencing Claude Code 2.1.140 internals. The technical "why" comments are preserved. Both changes look fine on their own; just noting they aren't implied by the PR title.
Minor: runSubagentStopHookLoop is duplicated between agent.ts and background-agent-resume.ts — this PR consistently updates both but doesn't dedupe. Pre-existing; potential follow-up cleanup.
LGTM.
|
Supplement to my approval review — the actual tmux session transcript from local verification. 1. PR-listed vitest (3 focused files)(PR body lists 205; later commits 2. typecheck — all workspaces3. git diff --check (whitespace)4. Full
|
…ask module Folds PR #4324's per-instance BackgroundTaskRegistry cap into the collapsed tasks/agent-task.ts module as kind-local module state. The new architecture's per-kind module is the natural home for an agent-only behavior — the generic TaskRegistry no longer needs to carry it. What moves into tasks/agent-task.ts: - MAX_CONCURRENT_BACKGROUND_AGENTS and the env-var resolver - agentAssertCanStartBackground(registry) free function - The race-guard inside agentRegister() (skips re-registers of already-running entries so resume can recover) - setAgentBackgroundCapForTest() for test override (mirrors the existing setAgentNotificationCallback pattern) What moves out of agent.ts: - The early preflight calls agentAssertCanStartBackground instead of registry.assertCanStartBackgroundAgent(). - The post-register error path is unchanged structurally — it still aborts the bgAbortController, fires SubagentStop, cleans up the worktree, and returns the cap message to the model. Resume path (background-agent-resume.ts) now wraps agentRegister in the same try/catch #4324 added, but routes to the new free function. Also addresses wenshao's review on #3982: 1. config.ts — moves the four registerTaskKind(...) calls below all imports, instead of dangling between two import groups. 2. dispatcher.ts — comment updated to describe actual init order (module-load side effect in config.ts), removes the fictional registerAllTaskKinds() / Config.initialize() references. 3. monitor.ts — fixes a stale comment that still referred to monitorRegister(registry, ) (dangling comma). 4. monitor-task.ts — monitorAbortAll now clears the module-level owner-routed callbacks so a daemon process recycling sessions doesn't leak handlers to dead owner agents. 5. useBackgroundTaskView.ts — moves the registry shape probe to getAll() BEFORE buildMerged() so activity bursts and monitor event bumps don't pay the listDreamTasks cost. Tests added: - tasks/agent-task.test.ts — covers env resolution, cap enforcement (running-only, foreground exclusion, paused exclusion, resume race exception), and module-level cap reset. Tests fixed (rebase fallout): - nonInteractiveCli.test.ts — six structured-output assertions migrated from mockBackgroundTaskRegistry.abortAll to the module-level mockAgentAbortAll mock. - shell.test.ts — 13 assertions migrated from registry.{cancel,complete,fail} to shellTaskModule.shell{Cancel,Complete,Fail} (called with (registry, ...)). - useBackgroundTaskView.test.ts — sort assertions updated to the new descending-startTime active-bucket order. - agent.test.ts — adds the getStopHookBlockingCap mock that was lost in the auto-merge with PR #4208. - clearCommand.test.ts — drops the SessionStart assertion that was lost in the auto-merge with PR #4115. - background-agent-resume.test.ts — monitorRegistry is now a set of vi.spyOn against the new module-level free functions. - InlineParallelAgentsDisplay.test.tsx — makeRegistryConfig exposes getTaskRegistry (matching the renamed Config method).
…ask module Folds PR #4324's per-instance BackgroundTaskRegistry cap into the collapsed tasks/agent-task.ts module as kind-local module state. The new architecture's per-kind module is the natural home for an agent-only behavior — the generic TaskRegistry no longer needs to carry it. What moves into tasks/agent-task.ts: - MAX_CONCURRENT_BACKGROUND_AGENTS and the env-var resolver - agentAssertCanStartBackground(registry) free function - The race-guard inside agentRegister() (skips re-registers of already-running entries so resume can recover) - setAgentBackgroundCapForTest() for test override (mirrors the existing setAgentNotificationCallback pattern) What moves out of agent.ts: - The early preflight calls agentAssertCanStartBackground instead of registry.assertCanStartBackgroundAgent(). - The post-register error path is unchanged structurally — it still aborts the bgAbortController, fires SubagentStop, cleans up the worktree, and returns the cap message to the model. Resume path (background-agent-resume.ts) now wraps agentRegister in the same try/catch #4324 added, but routes to the new free function. Also addresses wenshao's review on #3982: 1. config.ts — moves the four registerTaskKind(...) calls below all imports, instead of dangling between two import groups. 2. dispatcher.ts — comment updated to describe actual init order (module-load side effect in config.ts), removes the fictional registerAllTaskKinds() / Config.initialize() references. 3. monitor.ts — fixes a stale comment that still referred to monitorRegister(registry, ) (dangling comma). 4. monitor-task.ts — monitorAbortAll now clears the module-level owner-routed callbacks so a daemon process recycling sessions doesn't leak handlers to dead owner agents. 5. useBackgroundTaskView.ts — moves the registry shape probe to getAll() BEFORE buildMerged() so activity bursts and monitor event bumps don't pay the listDreamTasks cost. Tests added: - tasks/agent-task.test.ts — covers env resolution, cap enforcement (running-only, foreground exclusion, paused exclusion, resume race exception), and module-level cap reset. Tests fixed (rebase fallout): - nonInteractiveCli.test.ts — six structured-output assertions migrated from mockBackgroundTaskRegistry.abortAll to the module-level mockAgentAbortAll mock. - shell.test.ts — 13 assertions migrated from registry.{cancel,complete,fail} to shellTaskModule.shell{Cancel,Complete,Fail} (called with (registry, ...)). - useBackgroundTaskView.test.ts — sort assertions updated to the new descending-startTime active-bucket order. - agent.test.ts — adds the getStopHookBlockingCap mock that was lost in the auto-merge with PR #4208. - clearCommand.test.ts — drops the SessionStart assertion that was lost in the auto-merge with PR #4115. - background-agent-resume.test.ts — monitorRegistry is now a set of vi.spyOn against the new module-level free functions. - InlineParallelAgentsDisplay.test.tsx — makeRegistryConfig exposes getTaskRegistry (matching the renamed Config method).
…ask module Folds PR #4324's per-instance BackgroundTaskRegistry cap into the collapsed tasks/agent-task.ts module as kind-local module state. The new architecture's per-kind module is the natural home for an agent-only behavior — the generic TaskRegistry no longer needs to carry it. What moves into tasks/agent-task.ts: - MAX_CONCURRENT_BACKGROUND_AGENTS and the env-var resolver - agentAssertCanStartBackground(registry) free function - The race-guard inside agentRegister() (skips re-registers of already-running entries so resume can recover) - setAgentBackgroundCapForTest() for test override (mirrors the existing setAgentNotificationCallback pattern) What moves out of agent.ts: - The early preflight calls agentAssertCanStartBackground instead of registry.assertCanStartBackgroundAgent(). - The post-register error path is unchanged structurally — it still aborts the bgAbortController, fires SubagentStop, cleans up the worktree, and returns the cap message to the model. Resume path (background-agent-resume.ts) now wraps agentRegister in the same try/catch #4324 added, but routes to the new free function. Also addresses wenshao's review on #3982: 1. config.ts — moves the four registerTaskKind(...) calls below all imports, instead of dangling between two import groups. 2. dispatcher.ts — comment updated to describe actual init order (module-load side effect in config.ts), removes the fictional registerAllTaskKinds() / Config.initialize() references. 3. monitor.ts — fixes a stale comment that still referred to monitorRegister(registry, ) (dangling comma). 4. monitor-task.ts — monitorAbortAll now clears the module-level owner-routed callbacks so a daemon process recycling sessions doesn't leak handlers to dead owner agents. 5. useBackgroundTaskView.ts — moves the registry shape probe to getAll() BEFORE buildMerged() so activity bursts and monitor event bumps don't pay the listDreamTasks cost. Tests added: - tasks/agent-task.test.ts — covers env resolution, cap enforcement (running-only, foreground exclusion, paused exclusion, resume race exception), and module-level cap reset. Tests fixed (rebase fallout): - nonInteractiveCli.test.ts — six structured-output assertions migrated from mockBackgroundTaskRegistry.abortAll to the module-level mockAgentAbortAll mock. - shell.test.ts — 13 assertions migrated from registry.{cancel,complete,fail} to shellTaskModule.shell{Cancel,Complete,Fail} (called with (registry, ...)). - useBackgroundTaskView.test.ts — sort assertions updated to the new descending-startTime active-bucket order. - agent.test.ts — adds the getStopHookBlockingCap mock that was lost in the auto-merge with PR #4208. - clearCommand.test.ts — drops the SessionStart assertion that was lost in the auto-merge with PR #4115. - background-agent-resume.test.ts — monitorRegistry is now a set of vi.spyOn against the new module-level free functions. - InlineParallelAgentsDisplay.test.tsx — makeRegistryConfig exposes getTaskRegistry (matching the renamed Config method).
Summary
Validation
Scope / Risk
stopHookBlockingCaporQWEN_CODE_STOP_HOOK_BLOCK_CAP.Testing Matrix
Testing matrix notes:
Linked Issues / Bugs
Closes #4206