Skip to content

fix(ai): stop a buffered-CLI timeout burning the full retry budget, and two subprocess leaks (#9476, #9479) - #9505

Merged
JSONbored merged 1 commit into
mainfrom
fix/ai-pipeline-reliability
Jul 28, 2026
Merged

fix(ai): stop a buffered-CLI timeout burning the full retry budget, and two subprocess leaks (#9476, #9479)#9505
JSONbored merged 1 commit into
mainfrom
fix/ai-pipeline-reliability

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

Three AI-subprocess defects from the 2026-07-27 audit: a retry-break that never matched the error it was written for, and two ways a review subprocess leaks or kills its worker.

Closes #9476
Partially addresses #9479 (see Scope below)

#9476 — every timed-out Claude review burned the full retry budget

claude --output-format json buffers its entire response, so any run that exceeds its effort timeout has produced zero stdout bytes when the deadline lands. That trips the first-output watchdog (resolveClaudeFirstOutputTimeoutMs, deliberately clamped to timeoutMs - 1 by #5053) rather than the plain timeout, so the adapter throws claude_stalled_no_output: <detail> — never subscription_cli_timeout.

The retry-break tested for that with strict string equality, so it never matched. Consequences:

  • Every timed-out review burned all three attempts before the fallback model was tried: 3 × 180s ≈ 9 minutes at default medium effort, 3 × 600s = 30 minutes at the top tier.
  • With QUEUE_CONCURRENCY defaulting to 8, a provider slowdown parks the whole queue in that state.
  • The per-provider circuit breaker needs three consecutive full-length failures before it trips, so it can't help quickly either.
  • subscription_cli_timeout is effectively unreachable for claude-code, making fix(selfhost): stop retrying a CLI provider after it times out once #3987's fix dead code on this deployment.

Now matched by prefix, because these errors carry a : detail suffix — the strict equality is precisely the bug. Covers the codex_stalled_no_output sibling too.

The existing test suite pinned the error classification but nothing pinned the retry behaviour, which is why this survived.

#9479a — unhandled stdin EPIPE killed the whole worker

child.on("error") catches spawn failures only; it never receives stdio stream errors. If the CLI exits before draining stdin — an unknown flag on an upgraded binary, an immediate auth abort, an OOM kill — the ~250KB stdin write fails with EPIPE on an emitter with no "error" listener. Node escalates that to an uncaught exception → installSelfHostCrashHandlersexit(1), taking down every in-flight queue job in the container.

One listener fixes it. The real failure is already surfaced by the exit-code and empty-output guards, so nothing diagnostic is lost.

#9479b — per-call temp dirs were never removed

Every AI review call mints mkdtemp(/tmp/loopover-ai-*), and where repo review instructions are configured it writes the composed system prompt into that directory. Exhaustive search found no removal anywhere: no finally, no retention sweep, no tmpfiles.d, no tmpfs mount for the service, no TMPDIR in the Dockerfile. They accumulate on the container's writable overlay layer until it is recreated — and leave repo review instructions on disk indefinitely.

Removed in the same finally that already records CLI usage metrics, for both the claude and codex paths. Best-effort by design: a cleanup failure must never turn a completed review into a thrown error, and container recreation still collects anything missed.

Scope

#9479 also bundles force-push debounce (every dedup layer is head-SHA-keyed, so N pushes in a minute cost N full prologues and N LLM calls). That is a coalescing/scheduling change of a different shape and size, and it interacts with the ordering work in #9499 — it stays open for that PR rather than being half-done here. #9479 is therefore partially addressed; I've left it open deliberately rather than closing it on two of three.

#9477 (cache fingerprint omitting the prompt version) is not in this PR either — it needs a fingerprint bump plus a drift check, and belongs with its own migration of cached rows.

Validation

  • npx tsc --noEmit -p tsconfig.json — clean
  • test/unit/ai-review.test.ts + test/unit/selfhost-ai.test.ts449 passed

New tests:

  • Both claude_stalled_no_output and codex_stalled_no_output stop after one attempt and fall through to the fallback (parameterised, using realistic messages with their detail suffix so prefix matching is genuinely exercised).
  • A guard against over-broadening the break: a transient ECONNRESET still gets the full 3-attempt budget.

One existing test changed: the systemAppend test read system-append.txt after the call returned, which now correctly finds the directory gone. It reads the file inside the spawn stub instead — which also matches reality better, since the file exists exactly for the CLI's lifetime — and additionally asserts the directory does not outlive the call, pinning the new cleanup.

…nd two subprocess leaks (#9476, #9479)

claude --output-format json buffers its whole response, so ANY run exceeding its effort
timeout has produced zero stdout when the deadline lands -- which trips the first-output
watchdog and throws claude_stalled_no_output rather than subscription_cli_timeout. The
retry-break tested for the latter by strict string equality, so it never matched: every
timed-out review burned all three attempts (3x180s at default effort, 3x600s at the top tier)
before the fallback model was tried at all. With QUEUE_CONCURRENCY defaulting to 8 that parks
the whole queue during a provider slowdown, and the per-provider circuit breaker needs three
FULL-LENGTH failures before it trips. Matched by prefix, since these errors carry a detail
suffix -- the strict equality is the original bug. subscription_cli_timeout was effectively
unreachable for claude-code, making #3987's fix dead code on this deployment.

child.on("error") catches spawn failures only; it never receives stdio stream errors. A CLI
that exits before draining stdin (an unknown flag on an upgraded binary, an auth abort, an OOM
kill) made the ~250KB stdin write fail with EPIPE on an emitter with no error listener, which
Node escalates to an uncaught exception -> exit(1), taking down every in-flight queue job in
the container.

Per-call temp dirs were never removed. Every AI review minted one and, where repo review
instructions are configured, wrote the composed system prompt into it -- so they accumulated on
the container's writable overlay layer until recreation, leaving those instructions on disk
indefinitely. Removed in the same finally that records CLI usage metrics, best-effort so a
cleanup failure can never turn a completed review into a thrown error.

The systemAppend test now reads the appended-prompt file inside the spawn stub rather than
after the call returns, and additionally asserts the directory does not outlive the call --
which is both the new behaviour and a closer match to how the file is actually used.
@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@github-actions

Copy link
Copy Markdown
Contributor

Logic backtest

Replayed 0 historical case(s) for linked_issue_scope_mismatch through the base (0f6c6b8) and head (f08da24) versions of its detection logic (corpus checksum 4f53cda18c2b).

Backtest comparison: linked_issue_scope_mismatch

Verdict: unchanged — no comparable axis moved.

Advisory only — this check never blocks merge (#8105).

@loopover-orb

loopover-orb Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Caution

🛑 LoopOver review result - fixes required

Review updated: 2026-07-28 00:43:41 UTC

4 files · 1 AI reviewer · no blockers · CI failing · unstable

🛑 Suggested Action - Manual Review

  • Required AI review was skipped by a submitter-reputation downgrade: This repository requires blocking AI review, and the submitter's recent-submission signal downgraded this PR to deterministic-only checks. Those checks do not read code semantics, so the gate is held for human review instead of passing automatically.

Review summary
This PR fixes a real bug: `isSubscriptionCliTimeout` used strict equality against `subscription_cli_timeout`, but the actual error thrown on a buffered-CLI timeout is `claude_stalled_no_output: <detail>` (a prefixed message), so the retry-break never fired and burned the full 3-attempt budget. The new `isStalledNoOutput` prefix-matcher at src/services/ai-review.ts:1401-1404 is wired into the break condition at line ~1670, and the regression test confirms `primaryAttempts === 1` for both claude/codex variants while a genuinely transient error (ECONNRESET) still gets the full 3 attempts, correctly bounding the fix's blast radius. The two subprocess-leak fixes (stdin EPIPE listener, temp-dir cleanup in `finally`) are straightforward, low-risk, and each has a corresponding test verifying the fixed behavior (existsSync check for dir removal, no crash on EPIPE).

Nits — 6 non-blocking
  • The magic numbers `3`/`8` cited in the review brief (queue concurrency, retry count) are prose in a doc comment, not new logic constants, so this is a non-issue — the comment itself could still cite `AI_PROVIDER_FAILURE_THRESHOLD` by name for consistency (src/services/ai-review.ts:1391).
  • `removeIsolatedCliCwd` in src/selfhost/ai.ts dynamically imports `node:fs/promises` inside the function rather than a top-level import alongside the rest of the file's fs usage — verify this isn't done to avoid a circular import, otherwise a static import would be more conventional.
  • codecov/patch is at 91.66% against a 99% target; given the PR is only 1 commit behind default and CI otherwise passed, this is likely a real coverage gap in the diff (e.g. the `catch { }` best-effort branch in `removeIsolatedCliCwd` or the EPIPE listener) rather than an artifact of drift — worth checking which lines are uncovered before merging.
  • Confirm which lines codecov/patch flagged as uncovered (likely the swallowed-error branches in `removeIsolatedCliCwd` and the stdin `on("error")` handler) and add a targeted test if it's a genuine gap rather than noise from generated/doc-only lines.
  • PR author also opened the linked issue — Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.
  • Required AI review was skipped by a submitter-reputation downgrade — Review this PR manually, or run AI review with a trusted override, before merging.

CI checks failing

  • codecov/patch — 91.66% of diff hit (target 99.00%)

Decision drivers

  • ✅ Code review — No blockers (1 reviewer)
  • ⚠️ Gate result — Not blocking (Advisory; not blocking this PR.)
Context & advisory signals — never blocks the verdict
Signal Result Evidence
Linked issue ✅ Linked #9476
Related work ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ✅ 20/20 Low review scope from cached public metadata (1 linked issue).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 14 registered-repo PR(s), 13 merged, 346 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 14 PR(s), 346 issue(s).
Improvement ✅ Minor risk: clean · value: minor
Linked issue satisfaction

Partially addressed
The PR fixes the core bug (prefix-matching claude_stalled_no_output/codex_stalled_no_output in the retry break, with regression tests proving one-attempt-then-fallback and that genuine transient errors still retry), but it omits several explicit deliverables: the ollama request-abort case is not added to the break, no metric distinguishing stopped-vs-retried timeouts is emitted, and the reachabili

Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository is configured for direct PR review.
  • Public profile languages: not available
  • Official Gittensor activity: 14 PR(s), 346 issue(s).
  • PR-specific overlap: none found.
Contributor next steps
  • Start here: Treat this as maintainer-lane context rather than normal contributor-lane activity.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.
🧪 Chat with LoopOver

Ask LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.

  • @loopover ask &lt;question&gt; answers contribution-quality Q&A with source citations and freshness.
  • @loopover chat &lt;question&gt; answers in natural prose from cached decision-pack facts via local inference (maintainer/collaborator; read-only).
  • A plain-language @loopover mention with a real question is routed to the closest matching read-only command automatically — no exact syntax required.

Full command reference: https://loopover.ai/docs/loopover-commands

🧪 Experimental — new and may change.

Decision record
  • action: hold · clause: ai_review_inconclusive
  • config: 57ace228f9abf0c936f8b16a774f54d6567bc8e0814c260a4c5ec8985243a8c2 · pack: oss-anti-slop · ci: failed
  • record: 40a71a59f6b0edefd7608eb8570dfd709455013cb413059f33085ad569782225 (schema v5, head f08da24)

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.

  • Re-run LoopOver review

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.66667% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 88.65%. Comparing base (0f6c6b8) to head (f08da24).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/services/ai-review.ts 66.66% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9505      +/-   ##
==========================================
- Coverage   89.55%   88.65%   -0.91%     
==========================================
  Files         843      843              
  Lines      110073   110083      +10     
  Branches    26194    26197       +3     
==========================================
- Hits        98573    97591     -982     
- Misses      10238    11520    +1282     
+ Partials     1262      972     -290     
Flag Coverage Δ
backend 93.62% <91.66%> (-1.65%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/selfhost/ai.ts 98.31% <100.00%> (+0.02%) ⬆️
src/services/ai-review.ts 96.89% <66.66%> (-0.12%) ⬇️

... and 3 files with indirect coverage changes

@loopover-orb loopover-orb Bot added the gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. label Jul 28, 2026
@loopover-orb loopover-orb Bot added the manual-review Gittensor contributor context label Jul 28, 2026
@JSONbored
JSONbored merged commit bf1d500 into main Jul 28, 2026
8 of 9 checks passed
@JSONbored
JSONbored deleted the fix/ai-pipeline-reliability branch July 28, 2026 00:48
JSONbored added a commit that referenced this pull request Jul 28, 2026
…9504/#9505 merges (#9509)

* test(gate): cover the fail-safe marker-clear path (#9462)

Mirrors the sibling visual-capture-satisfied fail-safe test: a rejecting clear must not throw
out of the pass, since a failed clear only means the gate stays deferred until the head moves.

(cherry picked from commit efa6472)

* refactor(gate): drop an unreachable spread arm and annotate the defensive head-SHA guard (#9462)

screenshotTableEvidenceUnresolved is computed as a plain boolean, so the undefined arm of its
conditional spread was dead code -- pass it directly. The head-SHA guard in the exhausted branch
is genuinely defensive (a recapture-preview job is only minted for a PR that had one), matching
the sibling mark write, so it carries a reason rather than a contrived test.

(cherry picked from commit 4f59499)

* test(ai): exclude two unreachable best-effort cleanup arms from coverage (#9479)

rm with force:true does not throw for a missing path, so the catch needs a filesystem-level
failure no unit test can portably induce; the unset-cwd guard is only reachable when mkdtemp
itself threw, i.e. no directory was ever created. Both annotated with their reason, matching
the file's existing convention.

(cherry picked from commit bf3efe6)

* test(ai): drive the real subprocess EPIPE path and the non-Error stall guard (#9476, #9479)

The EPIPE regression uses the existing real-subprocess harness rather than a stubbed spawn, so
the stdin error listener is genuinely exercised: a fake CLI exits immediately without draining
stdin, and the test asserts no uncaughtException reaches the process. Verified to fail without
the listener (it captures an uncaught EPIPE), which is precisely the crash that took down every
in-flight queue job in the container.

Also covers isStalledNoOutput rejecting a non-Error throw -- a provider adapter can reject with
a string, and misclassifying that as a deadline signal would silently skip the retry budget.

(cherry picked from commit c770cc1)

* test(rate-limit): cover the inline-retry break for an over-budget Retry-After (#9494)

Asserts the attempt COUNT, not just the classification: a 60s Retry-After yields exactly one
attempt instead of four, and a short one still uses the full inline budget so the break stays
narrow.

(cherry picked from commit 92813bf)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. manual-review Gittensor contributor context

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ai: claude_stalled_no_output escapes the no-retry guard — every Claude timeout costs 3 full attempts and parks the queue

1 participant