Skip to content

Reap leaked Claude subagent watchers with an idle ceiling - #517

Merged
alexeyzimarev merged 6 commits into
mainfrom
ai-claude-subagent-idle-ceiling
Aug 11, 2026
Merged

Reap leaked Claude subagent watchers with an idle ceiling#517
alexeyzimarev merged 6 commits into
mainfrom
ai-claude-subagent-idle-ceiling

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Member

Closes #514 (AI-1844).

The leak

A Claude subagent watcher had exactly two exit paths: the SubagentStop-driven StopWatcher
signal, and the parent-exit watchdog. ShouldEndOnIdle was gated to codex/antigravity/cursor,
so there was no third. Miss the SubagentStop and the watcher survives until the entire parent
claude process quits.

Found while diagnosing ~110 live kcap processes on one machine. A single 13-day-old session owned
9 watchers: 1 legitimate session watcher plus 8 whose subagents had finished between 1 and 12 days
earlier (~40 MB RSS each). Parent PID alive in every case, so the watchdog correctly never fired —
the parent being alive is precisely why they were stuck.

#140 / AI-820 fixed the dominant cause of a missed SubagentStop (fd inheritance hanging the
hook). This adds the missing backstop for when one is missed for any other reason — roughly 5% of
that session's ~170 subagents.

The fix

ShouldEndOnIdle admits Claude child watchers. The vendor gate became a switch that states each
vendor's eligible role rather than a flat vendor list:

vendor eligible role why
codex / antigravity session only shared GUI process never exits per-conversation
cursor both no reliable per-conversation parent-exit signal
claude child only session watcher has a working watchdog and a sessionEnd hook

Claude's session watcher is deliberately excluded — a ceiling there could end a live session out
from under a user who is just thinking.

Transcript silence alone does not reap. A subagent running a long build writes nothing between
its tool_use and the matching tool_result, and toolInFlight was Codex/Antigravity-only
(UpdateCodexPendingToolCalls parses Codex's response_item shape — a permanent no-op for Claude).
So this adds UpdateClaudePendingToolCalls, tracking tool_use.idtool_result.tool_use_id
inside message.content[], shapes taken verbatim from a real subagent transcript.

Two layers, because either alone is insufficient:

  1. Tool tracking — an in-flight tool suppresses the ceiling regardless of how long it runs.
  2. A generous 6h window (KCAP_CLAUDE_SUBAGENT_IDLE_MINUTES) — so a parser mis-read cannot reap
    a live subagent, and a subagent quiet for a reason the tracker cannot see is still safe.

6h rather than the 60m used elsewhere because this is a leak backstop, not an end-of-conversation
detector. Nothing depends on reaping promptly: a child watcher posts no session-end (the
agentId is null gate on PostSessionEndOnParentExitAsync), so reaping early costs only the tail
of that transcript, while the leak it fixes lasts days.

Two extractions so the wiring is covered rather than buried in RunWatch:

  • ResolveIdleWindow(vendor, isSessionWatcher, env) — the per-vendor window switch, with the env
    lookup injected. Each vendor keeps reading its own knob; a Claude subagent is not retunable via
    the Codex knob.
  • TracksClaudeToolCalls(vendor, isSessionWatcher) — the drain-loop gate. If this drifts out of
    step with the watchers the ceiling applies to, PendingClaudeToolCalls stays empty, toolInFlight
    is permanently false, and the suppression silently vanishes. That is the dangerous regression, so
    it is a predicate with its own test rather than an inline condition.

Testing

TDD throughout — the eligibility test was watched failing with Expected to be true but found False
against the vendor gate before the gate changed.

  • WatchCommandTests 77/77, UpdateClaudePendingToolCallsTests 7/7.
  • Covered: child eligibility without the threshold gate, tool-in-flight suppression, the session
    watcher staying ineligible, the composed policy at its real 6h default, per-vendor knob isolation,
    and the tracker against multi-tool messages, string content, a Codex line, and malformed JSON.
  • CursorTailingWatcherTests integration 5/5.
  • AOT publish clean — no IL3050/IL2026.
  • Full unit suite: 55 failures vs 56 on the pre-change commit. The four that differ are all
    AgentOrchestratorVendorTests daemon-teardown tests; that class runs 228/228 in isolation on this
    branch, and fails an identical 5 under load at baseline. Pre-existing load flakiness, not
    regressions.

Not addressed

This bounds the damage; it does not fix why SubagentStop gets missed. That root cause is still
open.

🤖 Generated with Claude Code

A Claude subagent watcher's only exits were the SubagentStop-driven
StopWatcher signal and the parent-exit watchdog, so a missed SubagentStop
leaked the watcher for the entire life of the parent session. Observed on
a 13-day-old session: 8 watchers still alive against subagents that had
finished between 1 and 12 days earlier, ~40 MB RSS each.

ShouldEndOnIdle now admits Claude CHILD watchers. Its session watcher is
deliberately excluded — that one has a working parent-exit watchdog and a
sessionEnd hook, so a ceiling there could end a live session out from
under a thinking user.

Transcript silence alone is not enough to reap: a subagent running a long
build writes nothing between its tool_use and the matching tool_result.
UpdateClaudePendingToolCalls tracks those ids so an in-flight tool
suppresses the ceiling, and the window itself is a generous 6h
(KCAP_CLAUDE_SUBAGENT_IDLE_MINUTES) because this is a leak backstop
rather than an end-of-conversation detector. Nothing depends on reaping
promptly — a child watcher posts no session-end — whereas reaping a live
subagent would drop the rest of its transcript.

The per-vendor idle window moves out of RunWatch into ResolveIdleWindow
so the Claude child mapping is covered, and the drain-loop gate becomes
TracksClaudeToolCalls so it cannot silently drift out of step with the
watchers the ceiling applies to.

Closes #514

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qodo-code-review

qodo-code-review Bot commented Aug 10, 2026

Copy link
Copy Markdown

PR Summary by Qodo

Reap leaked Claude subagent watchers via idle ceiling + tool-in-flight guard

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add a 6h idle ceiling for Claude subagent watchers to prevent long-lived leaked processes.
• Track Claude tool_use/tool_result pairs so in-flight tools suppress idle reaping.
• Document and test the new vendor/role idle policy and configuration knob.
Diagram

graph TD
  A["kcap watch"] --> B["Drain transcript"] --> C["Update Claude tools"] --> D[("Pending tool ids")]
  A --> E["Backfill on resume"] --> D
  A --> W["Resolve idle window"] --> X["ShouldEndOnIdle"] --> Y{"Idle & no tool?"} --> Z["Exit watcher"]
  B --> T[/"Claude transcript"/]
  W --> V["KCAP_CLAUDE_SUBAGENT_IDLE_MINUTES"]

  subgraph Legend
    direction LR
    _fn["Function"] ~~~ _file[/"Transcript"/] ~~~ _state[("State")] ~~~ _dec{"Decision"}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Server-side lease sweep for orphaned watchers
  • ➕ Centralized cleanup that doesn’t rely on local transcript parsing
  • ➕ Can enforce hard TTLs regardless of client state
  • ➖ Requires server orchestration/leases for subagent watchers
  • ➖ Harder to make “never reap live tool calls” safe without richer signals
2. Guarantee SubagentStop delivery (stronger hook reliability)
  • ➕ Keeps the primary intended lifecycle signal authoritative
  • ➕ Avoids introducing idle-based heuristics
  • ➖ Still risks edge-case misses (crashes, IO issues, unexpected transcript shapes)
  • ➖ Does not address already-leaked processes without an additional backstop

Recommendation: Keep the PR’s approach: a Claude child-only idle ceiling provides a robust backstop when SubagentStop is missed, and the added tool-in-flight tracking + generous 6h window makes the heuristic safe (avoids cutting off long-running builds). The vendor/role switch and centralized ResolveIdleWindow also reduce the risk of future policy drift.

Files changed (4) +473 / -46

Bug fix (2) +167 / -46
Models.csTrack in-flight Claude tool calls in WatchState +6/-0

Track in-flight Claude tool calls in WatchState

• Introduces PendingClaudeToolCalls to record Claude tool_use ids awaiting tool_result. This state is used to suppress the new idle ceiling for live subagents.

src/Capacitor.Cli.Core/Models.cs

WatchCommand.csAdd Claude subagent idle ceiling with tool tracking + resume backfill +161/-46

Add Claude subagent idle ceiling with tool tracking + resume backfill

• Refactors idle window selection into ResolveIdleWindow and adds ResolveClaudeSubagentIdleCeiling (default 6h) keyed by watcher role. Extends ShouldEndOnIdle eligibility to include Claude child watchers only, and adds parsing/backfill logic to track Claude tool_use/tool_result pairs using raw (unredacted) lines to avoid stranded ids.

src/Capacitor.Cli/Commands/WatchCommand.cs

Tests (1) +296 / -0
WatchCommandTests.csAdd tests for Claude idle ceiling, tool tracking, and resume/backfill behavior +296/-0

Add tests for Claude idle ceiling, tool tracking, and resume/backfill behavior

• Adds unit tests covering Claude child eligibility, session watcher exclusion, env parsing, per-vendor knob mapping, and tool-in-flight suppression. Adds tests ensuring raw-line parsing clears oversized tool results and that backfill recovers in-flight tools across resumes.

test/Capacitor.Cli.Tests.Unit/WatchCommandTests.cs

Documentation (1) +10 / -0
README.mdDocument Claude subagent idle reaping knob +10/-0

Document Claude subagent idle reaping knob

• Adds KCAP_CLAUDE_SUBAGENT_IDLE_MINUTES documentation and explains why only Claude subagent watchers are eligible. Describes tool-in-flight suppression and clarifies that the exit does not post session-end.

README.md

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bc1042cf9b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1628 to +1629
foreach (var line in newLines) {
UpdateClaudePendingToolCalls(state.PendingClaudeToolCalls, line);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Track tool IDs before replacing oversized lines

Track Claude tool calls from the raw drainRead.Lines, not newLines: newLines has already passed through SecretRedactor.RedactLine, which replaces every line over 64 KiB with OversizeLinePlaceholder. Consequently, an oversized tool_result never removes its ID and permanently suppresses this new idle ceiling, so a missed SubagentStop still leaks the watcher indefinitely; conversely, an oversized tool_use is never added and a genuinely running tool can be reaped after the ceiling.

Useful? React with 👍 / 👎.

@qodo-code-review

qodo-code-review Bot commented Aug 10, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Unbounded backfill scan ✓ Resolved 🐞 Bug ➹ Performance ⭐ New
Description
BackfillClaudePendingToolCallsAsync linearly reads and JSON-parses every transcript line from the
file start up to the server resume cursor (state.LinesProcessed), which can be very large for
long-running sessions and delay watcher startup/resume. This adds O(N) work on the critical path
before the main watch loop runs.
Code

src/Capacitor.Cli/Commands/WatchCommand.cs[R1247-1250]

+            for (var lineNumber = 0; lineNumber < upToLine; lineNumber++) {
+                if (await reader.ReadLineAsync(ct) is not { } line) break;
+
+                UpdateClaudePendingToolCalls(pending, line);
Evidence
The resume cursor comes from the server via WatcherConnect and is passed directly into the backfill,
which loops from 0..upToLine-1 and parses each line to rebuild tool state.

src/Capacitor.Cli/Commands/WatchCommand.cs[607-638]
src/Capacitor.Cli/Commands/WatchCommand.cs[1241-1251]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`BackfillClaudePendingToolCallsAsync` scans from the beginning of the transcript to `upToLine` (which is the server resume cursor) and parses each line as JSON. For very large transcripts this can significantly slow watcher startup/resume.

### Issue Context
The backfill is best-effort and only exists to prevent falsely reaping a live Claude subagent when resuming mid-tool. Because in-flight tools produce transcript silence, the relevant `tool_use` is typically near the cursor.

### Fix Focus Areas
- src/Capacitor.Cli/Commands/WatchCommand.cs[633-638]
- src/Capacitor.Cli/Commands/WatchCommand.cs[1241-1251]

### Suggested direction
- Add a bounded scan (e.g., only scan the last N lines before `upToLine`, or cap by bytes/time budget).
- Alternatively, persist pending-tool state in watcher state across reconnect/start so backfill is unnecessary.
- Consider a faster extractor for `tool_use_id`/`id` (e.g., `Utf8JsonReader` over bytes) if JSON DOM parsing becomes expensive.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Verbose Claude ceiling comments ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The PR adds lengthy explanatory comments/XML docs for the Claude subagent idle ceiling logic, which
reduces readability and increases maintenance burden. This conflicts with the guideline to keep
comments minimal and prefer self-explanatory code.
Code

src/Capacitor.Cli/Commands/WatchCommand.cs[R1029-1032]

+    /// <summary>
+    /// Reap ceiling for a Claude CHILD (subagent) watcher, whose only other exits are the
+    /// SubagentStop-driven StopWatcher signal and the parent-exit watchdog — so a missed
+    /// SubagentStop leaks the watcher for the entire life of the parent session (days, for a
Evidence
PR Compliance ID 8 requires comments to be concise and used sparingly. The added sections include
extended rationale and detailed narrative in code comments (e.g., multi-paragraph XML docs and long
inline policy explanations), indicating comment verbosity beyond what is needed to understand the
code.

CLAUDE.md: Keep code comments minimal and prefer self-explanatory code
src/Capacitor.Cli/Commands/WatchCommand.cs[1029-1041]
src/Capacitor.Cli/Commands/WatchCommand.cs[1103-1113]
test/Capacitor.Cli.Tests.Unit/WatchCommandTests.cs[448-452]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New code introduces overly verbose comments (including long XML documentation blocks and multi-paragraph rationale) where shorter comments and clearer naming/structure would likely suffice.

## Issue Context
Compliance guidance asks to keep comments minimal and prefer self-explanatory code to improve maintainability.

## Fix Focus Areas
- src/Capacitor.Cli/Commands/WatchCommand.cs[1029-1041]
- src/Capacitor.Cli/Commands/WatchCommand.cs[1103-1113]
- test/Capacitor.Cli.Tests.Unit/WatchCommandTests.cs[448-452]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Tool state lost on resume ✓ Resolved 🐞 Bug ≡ Correctness
Description
UpdateClaudePendingToolCalls only processes newly drained transcript lines, so a Claude subagent
watcher that resumes mid-tool (non-zero WatcherConnect position) will not record the earlier
tool_use id. ShouldEndOnIdle can then reap after KCAP_CLAUDE_SUBAGENT_IDLE_MINUTES even though a
tool is still running, truncating the subagent transcript.
Code

src/Capacitor.Cli/Commands/WatchCommand.cs[R1627-1630]

+            if (TracksClaudeToolCalls(vendor, isSessionWatcher: agentId is null)) {
+                foreach (var line in newLines) {
+                    UpdateClaudePendingToolCalls(state.PendingClaudeToolCalls, line);
+                }
Evidence
The watcher resumes from a server-provided line cursor and only reads lines beyond that cursor; the
Claude tool tracker is updated only from newly read lines, while idle suppression depends on
PendingClaudeToolCalls.Count. Therefore, a restart/resume after a tool_use but before tool_result
can leave PendingClaudeToolCalls empty and allow idle reaping during a long-running tool.

src/Capacitor.Cli/Commands/WatchCommand.cs[607-610]
src/Capacitor.Cli/Commands/WatchCommand.cs[704-719]
src/Capacitor.Cli/Commands/WatchCommand.cs[1616-1631]
src/Capacitor.Cli/Commands/WatchCommand.cs[3344-3351]
src/Capacitor.Cli/Commands/WatchCommand.cs[3448-3453]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Claude subagent tool-in-flight suppression relies on `PendingClaudeToolCalls`, but that set is only updated from `newLines`. When a watcher resumes from a non-zero server cursor (e.g., watcher restart while a long tool is running), the earlier `tool_use` line is never re-parsed, leaving the pending set empty. The idle ceiling may then reap a live subagent after the configured window.

### Issue Context
- `WatcherConnect` sets `state.LinesProcessed` to a resume position.
- `ReadNewCompleteLinesAsync` only returns lines with index >= `LinesProcessed`.
- Claude tool tracking is updated only from the newly read lines, so it cannot reconstruct in-flight state that began before the resume cursor.

### Fix Focus Areas
- src/Capacitor.Cli/Commands/WatchCommand.cs[607-610]
- src/Capacitor.Cli/Commands/WatchCommand.cs[1616-1631]
- src/Capacitor.Cli/Commands/WatchCommand.cs[3344-3351]
- src/Capacitor.Cli/Commands/WatchCommand.cs[3448-3453]

### Suggested approach
- When `TracksClaudeToolCalls(vendor, isSessionWatcher: agentId is null)` is true and `state.LinesProcessed > 0`, perform a one-time backfill scan to reconstruct `PendingClaudeToolCalls` before enabling idle reaping.
 - Practical options:
   - Scan transcript from the beginning up to the current cursor once at startup (subagent transcripts should be relatively bounded), calling `UpdateClaudePendingToolCalls` for each line.
   - Or scan a bounded recent window (if you can prove tool_use/tool_result pairs can’t be separated by more than N lines), otherwise prefer full scan for correctness.
- Add a regression unit test simulating resume with `LinesProcessed > 0` where `tool_use` occurs before the cursor and no new lines arrive for > idle window; assert the watcher would *not* be considered idle while tool is effectively in-flight (after backfill).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

4. Hard-coded oversize payload ✓ Resolved 🐞 Bug ⚙ Maintainability ⭐ New
Description
ClaudeToolTrackingSourceTests hard-codes a 70KiB tool_result payload to exceed SecretRedactor’s size
threshold, so changing MaxRedactableLineChars will break the test even if behavior is correct. Build
the payload size relative to MaxRedactableLineChars to keep the test aligned with the
implementation.
Code

test/Capacitor.Cli.Tests.Unit/WatchCommandTests.cs[R856-859]

+    static string OversizedToolResult() =>
+        "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[{\"tool_use_id\":\"toolu_big\",\"type\":\"tool_result\",\"content\":\""
+      + new string('x', 70 * 1024)
+      + "\"}]}}";
Evidence
The test uses a fixed 70KiB payload, while production redaction is keyed to MaxRedactableLineChars;
if that constant changes, the test no longer reliably targets the redaction branch.

test/Capacitor.Cli.Tests.Unit/WatchCommandTests.cs[850-880]
src/Capacitor.Cli/SecretRedactor.cs[14-25]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The test uses a fixed `new string('x', 70 * 1024)` to trigger the oversize redaction behavior. This couples the test to today’s threshold and will fail if the threshold is legitimately adjusted.

### Issue Context
`SecretRedactor.RedactLine` redacts only when `rawJsonlLine.Length > MaxRedactableLineChars`.

### Fix Focus Areas
- test/Capacitor.Cli.Tests.Unit/WatchCommandTests.cs[850-860]

### Suggested direction
- Construct the oversized payload as `SecretRedactor.MaxRedactableLineChars + margin` (if accessible to the test assembly), or expose a helper to fetch the limit.
- Alternatively, assert the payload length is `> MaxRedactableLineChars` inside the test before exercising the behavior, so failures are self-explanatory.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Backfill logs on cancel ✓ Resolved 🐞 Bug ◔ Observability ⭐ New
Description
RunWatch can cancel cts when SeedCursorByteOffsetAsync fails but still invokes
BackfillClaudePendingToolCallsAsync, whose broad catch logs OperationCanceledException as a generic
“backfill skipped” message. This adds misleading noise during intentional shutdown/failure paths and
swallows cancellation instead of cleanly returning.
Code

src/Capacitor.Cli/Commands/WatchCommand.cs[R635-638]

+        if (TracksClaudeToolCalls(vendor, isSessionWatcher: agentId is null) && state.LinesProcessed > 0) {
+            await BackfillClaudePendingToolCallsAsync(
+                state.PendingClaudeToolCalls, transcriptPath, state.LinesProcessed, cts.Token);
+        }
Evidence
The code cancels the token on SeedCursorByteOffsetAsync failure and still proceeds to call backfill
with that token; backfill catches all exceptions and logs only the message, which will include
cancellation messages.

src/Capacitor.Cli/Commands/WatchCommand.cs[622-638]
src/Capacitor.Cli/Commands/WatchCommand.cs[1252-1254]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Backfill is invoked even when `cts` has already been cancelled, and `BackfillClaudePendingToolCallsAsync` logs any caught exception message (including cancellation) as a skip.

### Issue Context
This path occurs when initial resume seeding fails (cursor rewrite quarantine) or shutdown races with startup. The backfill is best-effort and should not emit error-like logs for expected cancellation.

### Fix Focus Areas
- src/Capacitor.Cli/Commands/WatchCommand.cs[633-638]
- src/Capacitor.Cli/Commands/WatchCommand.cs[1252-1254]

### Suggested direction
- Before calling backfill: `if (cts.Token.IsCancellationRequested) return/skip;`
- In backfill: catch `OperationCanceledException` separately and return silently when `ct.IsCancellationRequested`.
- Keep logging for unexpected exceptions (file open failures etc.), optionally include exception type for clarity.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can group findings by type and pick your Finding display, from Minimal to Full

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous review results

Review updated until commit 50e5fb0

Results up to commit bc1042c ⚖️ Balanced


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Remediation recommended
1. Tool state lost on resume ✓ Resolved 🐞 Bug ≡ Correctness
Description
UpdateClaudePendingToolCalls only processes newly drained transcript lines, so a Claude subagent
watcher that resumes mid-tool (non-zero WatcherConnect position) will not record the earlier
tool_use id. ShouldEndOnIdle can then reap after KCAP_CLAUDE_SUBAGENT_IDLE_MINUTES even though a
tool is still running, truncating the subagent transcript.
Code

src/Capacitor.Cli/Commands/WatchCommand.cs[R1627-1630]

+            if (TracksClaudeToolCalls(vendor, isSessionWatcher: agentId is null)) {
+                foreach (var line in newLines) {
+                    UpdateClaudePendingToolCalls(state.PendingClaudeToolCalls, line);
+                }
Evidence
The watcher resumes from a server-provided line cursor and only reads lines beyond that cursor; the
Claude tool tracker is updated only from newly read lines, while idle suppression depends on
PendingClaudeToolCalls.Count. Therefore, a restart/resume after a tool_use but before tool_result
can leave PendingClaudeToolCalls empty and allow idle reaping during a long-running tool.

src/Capacitor.Cli/Commands/WatchCommand.cs[607-610]
src/Capacitor.Cli/Commands/WatchCommand.cs[704-719]
src/Capacitor.Cli/Commands/WatchCommand.cs[1616-1631]
src/Capacitor.Cli/Commands/WatchCommand.cs[3344-3351]
src/Capacitor.Cli/Commands/WatchCommand.cs[3448-3453]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Claude subagent tool-in-flight suppression relies on `PendingClaudeToolCalls`, but that set is only updated from `newLines`. When a watcher resumes from a non-zero server cursor (e.g., watcher restart while a long tool is running), the earlier `tool_use` line is never re-parsed, leaving the pending set empty. The idle ceiling may then reap a live subagent after the configured window.

### Issue Context
- `WatcherConnect` sets `state.LinesProcessed` to a resume position.
- `ReadNewCompleteLinesAsync` only returns lines with index >= `LinesProcessed`.
- Claude tool tracking is updated only from the newly read lines, so it cannot reconstruct in-flight state that began before the resume cursor.

### Fix Focus Areas
- src/Capacitor.Cli/Commands/WatchCommand.cs[607-610]
- src/Capacitor.Cli/Commands/WatchCommand.cs[1616-1631]
- src/Capacitor.Cli/Commands/WatchCommand.cs[3344-3351]
- src/Capacitor.Cli/Commands/WatchCommand.cs[3448-3453]

### Suggested approach
- When `TracksClaudeToolCalls(vendor, isSessionWatcher: agentId is null)` is true and `state.LinesProcessed > 0`, perform a one-time backfill scan to reconstruct `PendingClaudeToolCalls` before enabling idle reaping.
 - Practical options:
   - Scan transcript from the beginning up to the current cursor once at startup (subagent transcripts should be relatively bounded), calling `UpdateClaudePendingToolCalls` for each line.
   - Or scan a bounded recent window (if you can prove tool_use/tool_result pairs can’t be separated by more than N lines), otherwise prefer full scan for correctness.
- Add a regression unit test simulating resume with `LinesProcessed > 0` where `tool_use` occurs before the cursor and no new lines arrive for > idle window; assert the watcher would *not* be considered idle while tool is effectively in-flight (after backfill).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Verbose Claude ceiling comments ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The PR adds lengthy explanatory comments/XML docs for the Claude subagent idle ceiling logic, which
reduces readability and increases maintenance burden. This conflicts with the guideline to keep
comments minimal and prefer self-explanatory code.
Code

src/Capacitor.Cli/Commands/WatchCommand.cs[R1029-1032]

+    /// <summary>
+    /// Reap ceiling for a Claude CHILD (subagent) watcher, whose only other exits are the
+    /// SubagentStop-driven StopWatcher signal and the parent-exit watchdog — so a missed
+    /// SubagentStop leaks the watcher for the entire life of the parent session (days, for a
Evidence
PR Compliance ID 8 requires comments to be concise and used sparingly. The added sections include
extended rationale and detailed narrative in code comments (e.g., multi-paragraph XML docs and long
inline policy explanations), indicating comment verbosity beyond what is needed to understand the
code.

CLAUDE.md: Keep code comments minimal and prefer self-explanatory code
src/Capacitor.Cli/Commands/WatchCommand.cs[1029-1041]
src/Capacitor.Cli/Commands/WatchCommand.cs[1103-1113]
test/Capacitor.Cli.Tests.Unit/WatchCommandTests.cs[448-452]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New code introduces overly verbose comments (including long XML documentation blocks and multi-paragraph rationale) where shorter comments and clearer naming/structure would likely suffice.

## Issue Context
Compliance guidance asks to keep comments minimal and prefer self-explanatory code to improve maintainability.

## Fix Focus Areas
- src/Capacitor.Cli/Commands/WatchCommand.cs[1029-1041]
- src/Capacitor.Cli/Commands/WatchCommand.cs[1103-1113]
- test/Capacitor.Cli.Tests.Unit/WatchCommandTests.cs[448-452]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Qodo Logo

Comment thread src/Capacitor.Cli/Commands/WatchCommand.cs Outdated
Comment thread src/Capacitor.Cli/Commands/WatchCommand.cs
@alexeyzimarev

Copy link
Copy Markdown
Member Author

Both bugs were real and are fixed in 2ca06b5. Thanks — the first one defeated the entire feature.

Tool ids tracked from redacted lines (codex P1) — confirmed, fixed.
RedactLine returns OversizeLinePlaceholder for any line over MaxRedactableLineChars (64 KiB),
and that placeholder has no message.content[] at all. Oversized tool_results are not an edge
case — a large file read or a build log clears 64 KiB routinely. The id was therefore never
removed, toolInFlight was pinned true forever, and the ceiling could never fire. The net effect
was that the leak stayed exactly as it was, and specifically on the busiest sessions, which are the
ones that leak most. Now reads drainRead.Lines. Only ids are parsed, so no raw content leaves the
process — redaction still governs everything sent to the server.

Regression test RedactedLines_StrandThePendingId_ButRawLinesClearIt pins the invariant: it asserts
the id is stranded when the redacted line is used, and cleared when the raw one is.

Tool state lost on resume (qodo) — confirmed, fixed.
Agreed on both the mechanism and the suggested approach. BackfillClaudePendingToolCallsAsync
rebuilds the set from [0, LinesProcessed) once, right after WatcherConnect resolves the resume
cursor — before the main loop, so there is no interleaving with the drain and no double-application
of lines at or past the cursor. It opens FileShare.ReadWrite (the agent is still writing that
file) and fails soft: losing the backstop is better than failing a watcher's startup over it.
Covered by four tests including the stop-at-cursor boundary and the missing-file no-op.

I took the full-scan option rather than a bounded window — a tool_use/tool_result pair has no
bounded line distance, so the window would have been a guess.

Verbose comments (qodo) — fair, trimmed.
Cut the flagged blocks at WatchCommand.cs[1029-1041] and [1103-1113] and the test comment by
roughly half, keeping only the rationale that isn't recoverable from the code: why the ceiling is
child-only, and why 6h rather than the 60m the other vendors use.

One thing I did not change: UpdateCodexPendingToolCalls reads the same redacted list and has
the identical exposure — an oversized function_call_output never clears its call_id, which
would suppress Codex's idle timeout indefinitely. That is pre-existing rather than introduced here,
so I have left it alone rather than widen this PR. Worth its own issue.

Two defects in the idle ceiling, both found by the automated reviewers.

The tracker was fed the redacted line list. RedactLine swaps any line over
64 KiB for a placeholder carrying no tool ids, and oversized tool_results
are routine — a big file read or a build log. That id was then never
cleared, toolInFlight stayed true forever, and the ceiling never fired:
the leak silently reinstated on exactly the busy sessions that leak most.
Feed it drainRead.Lines instead. Only ids are read; no raw content leaves
the process.

A watcher that reconnects mid-tool resumes at the server's line cursor and
never sees the tool_use that opened before it, so the pending set came up
empty and a live subagent was eligible for reaping — the one outcome the
generous window exists to prevent. BackfillClaudePendingToolCallsAsync
rebuilds the set from [0, cursor) once at startup, opened FileShare.ReadWrite
because the agent is still writing the file, and failing soft: losing the
backstop beats failing a watcher's startup.

Also trims the comments added in the previous commit, per the repo's
keep-comments-minimal rule.

Note the same redaction exposure exists for UpdateCodexPendingToolCalls,
which also reads the redacted list. Left alone here — pre-existing and
outside this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alexeyzimarev
alexeyzimarev force-pushed the ai-claude-subagent-idle-ceiling branch from 2ca06b5 to cf26c0b Compare August 11, 2026 09:32
Comment thread src/Capacitor.Cli/Commands/WatchCommand.cs Outdated
Comment thread src/Capacitor.Cli/Commands/WatchCommand.cs
Comment thread test/Capacitor.Cli.Tests.Unit/WatchCommandTests.cs
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 2ca06b5

alexeyzimarev and others added 3 commits August 11, 2026 11:54
…le-ceiling

# Conflicts:
#	src/Capacitor.Cli/Commands/WatchCommand.cs
…le-ceiling

# Conflicts:
#	src/Capacitor.Cli.Core/Models.cs
#	src/Capacitor.Cli/Commands/WatchCommand.cs
Review follow-ups.

The backfill parsed every line from the file start to the resume cursor,
putting O(N) JSON parsing on the watcher's startup path for what is a
best-effort backstop. It now parses only the last 512 lines before the
cursor. That window is sound rather than arbitrary: an unfinished tool sits
at the tail by construction, since nothing is appended between its tool_use
and the matching tool_result. It also stops a stale unmatched tool_use from
an interrupted turn stranding an id and suppressing the ceiling forever —
the same failure shape as the redaction bug. Lines before the window are
still read (there is no cheap seek to line N) but never parsed.

Cancellation is no longer reported as a failure: the seed-quarantine path
cancels and then calls straight into the backfill, so an expected shutdown
logged a misleading "backfill skipped". Returns early when already
cancelled and catches OperationCanceledException separately.

The oversize test payload is now sized off SecretRedactor.MaxRedactableLineChars
instead of a hard-coded 70 KiB, so changing the threshold cannot quietly
turn those into tests of the small-line path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alexeyzimarev

Copy link
Copy Markdown
Member Author

All three addressed in 50e5fb0, plus the merge with #526.

1. Unbounded backfill scan — fixed, and it was hiding a correctness bug.
Bounded to the last ClaudeToolBackfillWindowLines (512) before the cursor. I'd previously argued
against a bounded window on the grounds that tool_use/tool_result distance is unbounded. That was
wrong in the way that matters here: an unfinished tool sits at the tail by construction, because
nothing is appended between its tool_use and the matching tool_result. Anything older is settled.

So the window isn't just cheaper, it's more correct. A full scan would keep any stale unmatched
tool_use — an interrupted turn — pending forever, pinning toolInFlight true and disabling the
ceiling permanently. That is the same failure shape as the redaction bug from the last round, and I'd
reintroduced it. Lines before the window are still read (no cheap seek to line N) but never parsed;
the JSON parse was the cost.

2. Backfill logs on cancel — fixed. Early return when the token is already cancelled, and
OperationCanceledException caught separately from real failures. The seed-quarantine path cancels
and then calls straight into the backfill, so this was firing on every quarantine exit. I put the
guard in the method rather than at both sites, since the method is the thing that must be safe.

3. Hard-coded oversize payload — fixed. Now SecretRedactor.MaxRedactableLineChars + 1024.

One thing I checked and deliberately did not build. The above made me suspect a broader hole: an
orphaned tool_use (subagent killed mid-tool) would suppress the ceiling forever through the live
drain path too, not just the backfill — and that's exactly the disrupted-flow scenario that leaks
watchers. I checked the four transcripts whose watchers actually leaked on the diagnosed session:

agent-ab955e3bf976781ed  tool_use=110  tool_result=110
agent-a3eff18723ea815a1  tool_use=209  tool_result=209
agent-a489aaf9809ffa2c7  tool_use=210  tool_result=210
agent-a3d9875ac9a1bd2c2  tool_use=75   tool_result=75

Perfectly balanced in every case — the subagents finished their tools and it was the stop signal
that went missing, so the pending set drains to empty and the ceiling fires. A hard cap on
in-flight suppression would be speculative complexity against a case the data says doesn't occur,
and it would risk reaping a genuinely long tool. Noting it as a known limitation instead: if a
subagent is killed mid-tool, its watcher is not reaped by this ceiling. That's the status quo, not
a regression.

Merge with #526. Both conflicts were additive collisions, kept both sides: PendingClaudeToolCalls
alongside CodexSubagentTurn in WatchState, and the two per-vendor drain blocks. #526 doesn't touch
ShouldEndOnIdle, so the eligibility switch is unchanged. Worth noting the two changes are
complementary — #526 gives Codex children a live subagent-stop on task_complete, which is exactly
the finalizer Claude children lack and that this ceiling backstops.

@alexeyzimarev
alexeyzimarev merged commit 72759b5 into main Aug 11, 2026
6 checks passed
@alexeyzimarev
alexeyzimarev deleted the ai-claude-subagent-idle-ceiling branch August 11, 2026 15:44
alexeyzimarev added a commit that referenced this pull request Aug 11, 2026
)

* Feed the Codex watcher state trackers raw lines, not redacted ones

RedactLine replaces any line over 64 KiB with a placeholder carrying no
call_id and a type neither tracker recognises, and an oversized
function_call_output — a build log, a large file read, a verbose test run —
is routine rather than exceptional.

UpdateCodexPendingToolCalls therefore never removed the call_id, leaving
PendingCodexToolCalls non-empty for the life of the watcher. toolInFlight
was then permanently true and ShouldEndOnIdle could never fire. For Codex
that is not a degraded backstop: the desktop app's shared app-server never
exits per conversation, so the idle timeout is the ONLY per-conversation
session-end path. The session stays Active in the read model indefinitely
and the watcher never exits.

The same stranded call_id also permanently blocks the live subagent-stop
added in #526, which requires no tool call in flight — reinstating the
symptom it fixed, a finished child's chat card spinning for the parent's
whole lifetime.

CodexSubagentTurn.Observe read the same list and fails in the other
direction: it treats any response_item as the turn re-opening, so an
oversized one is not recognised and a child that re-engaged after
task_complete could be reported stopped while still working.

Both now read drainRead.Lines, matching what SeedCodexSubagentTurnState
already does when it folds this state from disk — the seed was correct and
only the live drain path was inconsistent. Neither tracker emits anything;
they read call_id and type only, so no unredacted content leaves the
process and redaction still governs everything sent to the server.

Same defect and same fix as the Claude tracker in #517.

Closes #528

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Gate the Codex tool tracker on vendor now that it reads raw lines

Both reviewers caught the same regression. The tracker's loop was never
gated on vendor — it ran for every watcher on the grounds that a non-Codex
line has no response_item and so is a cheap no-op. That held while it read
the redacted list, where any oversized line arrived as a ~90-byte
placeholder. Reading raw lines removes that bound, so a Claude or Gemini
watcher would JsonDocument.Parse a multi-megabyte tool output on every
drain to discover, again, that it is not a Codex record.

TracksCodexToolCalls mirrors TracksClaudeToolCalls and makes the decision
testable rather than an inline condition. Not gated on watcher role: a
collab child needs the tracking for ShouldPostSubagentStop.

Also trims the comments added in the previous commit, per the repo's
keep-comments-minimal rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

Claude subagent watchers never idle-exit: leaked kcap watch processes accumulate for the life of the parent session

1 participant