Skip to content

fix(llmadapters): fall back to foreground when a Claude background job fails - #577

Merged
piekstra merged 7 commits into
mainfrom
piekstra/claude-bg-foreground-fallback
Aug 31, 2026
Merged

fix(llmadapters): fall back to foreground when a Claude background job fails#577
piekstra merged 7 commits into
mainfrom
piekstra/claude-bg-foreground-fallback

Conversation

@piekstra

Copy link
Copy Markdown
Contributor

Why

The Claude job-service transport is the default for reviewer tasks, and subprocess.go already documents it as the fragile one:

Background mode depends on the Claude CLI's job-service layer (detached daemon, job state files, result polling), which has proven fragile in production: sessions intermittently die mid-turn without ever writing their state file, starving the review until its task deadline.

Observed failure, four consecutive runs of the same review: one reviewer's job entered blocked because the prompt file it was told to read was gone.

llm subprocess: Claude background job blocked: cr-prompt.txt path no longer exists; session dirs cleaned up
llm subprocess: Claude background job blocked: asked to read & follow instructions from deleted session directory

Its agent log held only the backgrounded · <id> banner. Every other reviewer in the same run completed normally, and re-running reproduced it. Running the same review with CR_CLAUDE_FOREGROUND=1 completed all reviewers on the first attempt.

Losing one reviewer costs more than one reviewer's findings. A review that reports no findings because a reviewer never ran is not the same as a review that found nothing, and a caller gating on coverage cannot tell them apart from the outside, so a flaky job service withholds approval on a change that has nothing wrong with it.

What this changes

Background failures that mean the transport never ran the task are marked with ErrClaudeBGTransport: the job blocked, stopped, failed, never registered an id, or completed without a session id. A task that fails that way is retried once on the foreground transport, which this file already describes as fully owned by cr.

What it deliberately does not change

  • A model failure is not retried. A job that completed and wrote an empty or missing result file is not marked, because retrying would only repeat it.
  • The original error is what surfaces when the retry also fails. The retry failing says nothing new, and burying the transport failure under it is how a job-service problem gets read as a reviewer problem.
  • CR_CLAUDE_FOREGROUND is unchanged and still selects foreground outright, without a background attempt first.
  • Session ids are preserved: a retry that produces no session id does not erase the primary's.

Tests

  • A background job that blocks recovers through foreground, returning the foreground result and session id.
  • A completed job with an empty result file is not retried and reports the original failure.
  • The existing terminal-state table (blocked / failed / waiting / stopped / missing / empty) still reports its own errors unchanged.

make lint clean, go test ./... green apart from TestPiRPCReviewerExtensionLoadsInInstalledPi, which fails on main here too (the locally installed pi rejects --no-builtin-tools).

Alternative considered

Flipping the default to foreground, which is what the existing comment argues for. That is a larger behavioral change and the background transport still has advantages this PR does not weigh, so this keeps the default and makes its failure recoverable instead.

…b fails

The Claude job-service transport is the default for reviewer tasks, and this
file already documents it as the fragile one: sessions die mid-turn without
writing a state file, and a job can enter a terminal state having never run
the task at all. Observed in production: a job reported `blocked` because the
prompt file it was told to read was gone, on four consecutive runs of the same
review, always taking one reviewer with it.

Losing a reviewer is worse than it looks from inside the adapter. A review
that reports no findings because a reviewer never ran is not the same as a
review that found nothing, and a caller gating on coverage cannot tell them
apart, so one flaky job service withholds approval on an unrelated change.

Background failures that mean "the transport never ran the task" are now
marked with ErrClaudeBGTransport, and such a task is retried once on the
foreground transport, which this file already describes as fully cr-owned.
A job that DID complete and wrote nothing usable is a model failure, is not
marked, and is not retried, since retrying would only repeat it. When the
retry fails too, the original transport failure is what surfaces: the retry
failing says nothing new, and burying the cause under it is how a job-service
problem gets read as a reviewer problem.

CR_CLAUDE_FOREGROUND is unchanged and still selects foreground outright.

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

Automated PR Review

Reviewed commit: dc07ea7fdc6d
Profile: claude-monit-reviewer - Posting as: monit-reviewer

Summary

Reviewer Findings
go:implementation-tests 1
architecture:solid-reviewer-agnostic 5
security:code-auditor 0
structure:harness-engineering 0
go:implementation-tests (1 finding)

Major - internal/llmadapters/subprocess_test.go:1635

TestSubprocessClaudeBackgroundStatesAndCleanup does not guard the new fallback behavior for the blocked/failed/waiting/stopped/stop-fails cases, even though those states are exactly the ones startClaude now retries on foreground (waitForClaudeBGState wraps them in ErrClaudeBGTransport). With CR_CLAUDE_FOREGROUND unset, each of those subtests currently launches a second (foreground) helper process via claudeFallbackStream.Wait, and that second launch happens to fail too because runClaudeForegroundHelper has no case for modes like "bg-blocked"/"bg-failed"/etc. -- so the original background error is what the test observes, purely because the fallback attempt errors out for an unrelated reason (unhandled mode string), not because the test asserts single-launch behavior. assertClaudeCleanup only scans records[1:] for stop/rm control calls matching job-1; it never asserts len(records) or that no foreground launch occurred. So a regression that makes the foreground fallback succeed for these states (or a regression that stops wrapping them in ErrClaudeBGTransport) would not necessarily be caught: the former could produce a passing test with the wrong final result if the substrings still line up, and the latter removes the retry with no test noticing the extra launch disappeared. Add an explicit assertion (e.g. len(readHelperRecords(...)) == 1, or a helper-record count keyed by CLAUDE_CONFIG_DIR presence indicating only the --bg transport ran) for the non-transport-failure states, and/or extend runClaudeForegroundHelper with real cases for these modes so the fallback path in this table is deliberately exercised rather than incidentally failing closed.

architecture:solid-reviewer-agnostic (5 findings)

Major - internal/llmadapters/subprocess.go:370

The launch path falls back on any error and drops it, which contradicts the classification this PR introduces. ErrClaudeBGTransport exists precisely so that only "the transport never ran the task" failures are retried (see the sentinel's own doc at line 72), and Wait at line 401 honors that. This branch does not: err is discarded unexamined, so a ErrUnsafeSubprocessConfig from validateArgs, a workspace-validation failure from invocationScratchDir, a non-empty scratch dir, or a claudeBGWorkingDir mkdir failure all silently become a foreground attempt.

The safety gate itself is not bypassed (startClaudeForeground re-runs validateArgs), but the error accounting is. When the foreground start also fails, the caller sees only the foreground error, so a genuine misconfiguration in the background arg construction is reported as a foreground problem, and a bug that only manifests on the bg path becomes invisible in production. That is a swallowed error with no explicit decision recorded (U-L2), and it makes the two retry decision points in the same file disagree about what is retryable (U-S1).

Suggested fix: gate this branch the same way Wait is gated — fall back only when the launch failure is transport-shaped (mark the launch-side failures with ErrClaudeBGTransport and test errors.Is, or at minimum return immediately on ErrUnsafeSubprocessConfig). When the fallback start then fails too, join the discarded background error into what is returned (errors.Join(err, fgErr)) rather than returning only the second one.

Minor - internal/llmadapters/subprocess.go:396

SessionID() can report a session that does not correspond to the returned response. When the fallback succeeds but its print-mode output does not parse (line 573 logs exactly that and leaves the session id unset), this returns the background job's session id while Wait returns the foreground response. runOnceAttempt (internal/llm/adapter.go:347) pairs those two as one result, so a later resume targets a dead background session that the PR describes as the broken transport, and the foreground run's result is recorded against it.

The PR body frames this as "session ids are preserved", but preservation is only correct while the primary's response is the one being returned; once the fallback's response wins, the primary's session id is stale state attached to someone else's output. The degradation is graceful (the resume fails with ErrMissingProviderSession and is handled at internal/llm/adapter.go:309), so the cost is a wasted attempt rather than a wrong review.

Suggested fix: record which stream produced the returned response (set a winner Stream under the existing mutex in Wait) and have SessionID() report only that stream's id, letting empty be empty.

Major - internal/llmadapters/subprocess.go:404

The foreground retry destroys the evidence of the failure it is retrying. startClaudeForeground passes the same req.LogPath to launchProcess, and openSubprocessLog (internal/llm/subprocess.go:294) opens it with os.Create, which truncates. The background stream has already flushed and closed that file by the time Wait returns, so the retry wipes it.

Two consequences. First, the diagnostic the PR body itself relies on is gone: the backgrounded · <id> banner and the blocked-state detail are exactly what identified this failure mode, and after a fallback the task log contains only the foreground attempt. Second, when the fallback also fails, line 418 deliberately reports the primary error, so the returned error and the on-disk artifact now describe different runs, with nothing in the log explaining the error the caller sees. A caller inspecting the log to explain a transport failure is silently handed the wrong run's output (U-L1: this Stream's observable side effects diverge from every other Stream, where the log at req.LogPath corresponds to the reported error).

Suggested fix: give the fallback its own log destination, e.g. copy s.req and set LogPath to a sibling path (<name>.foreground.log, or the same stem with a suffix before the extension) before calling startClaudeForeground, and note in the primary log that the task continued in the other file. Opening the retry's log in append mode instead would also preserve both, but a distinct path keeps the two transports' output separable.

Minor - internal/llmadapters/subprocess.go:413

The documented meaning of SubprocessOptions.Timeout no longer holds for Claude tasks. Lines 36-39 state it bounds "a single LLM task (one subprocess run, or one background job launch + result wait)", and defaultLLMTaskTimeout (line 48) justifies its value by converting "one worker hung" into a timely failure. With the fallback, the foreground retry gets a fresh full a.timeout from launchProcess, so a Claude task's worst case is now roughly twice the configured bound. It is reachable: a job that sits working for most of the window and then goes blocked, or the completed-without-session-id case above, both fail late and then start a fresh 14-minute attempt. Bg timeouts themselves do not fall back (they are not marked ErrClaudeBGTransport and Wait checks ctx.Err()), which limits this to the late-failing transport states.

A doc comment that states a bound the code no longer honors is worse than no comment, because callers size their own task deadlines from it (U-G1: the changed behavior is not reflected in the contract it changes).

Suggested fix: either derive one deadline in startClaude (context.WithTimeout(ctx, a.timeout) passed to both attempts, so the retry inherits the remaining budget) or amend the Timeout comment to say it bounds each transport attempt and that a Claude task may make two.

Major - internal/llmadapters/subprocess.go:1393

Marking "job completed without session id" as ErrClaudeBGTransport now triggers a full second model run for a job that already produced a usable result. waitForClaudeBGResult checks the session id (lines 1388-1394) before readFirstNonEmptyFile (line 1395), so a background job that ran to completion and wrote a valid cr-result.json is discarded and re-run end to end whenever its state.json never carried a session id. That contradicts the PR's own stated rule that a completed job is not retried, and the cost is not one wasted attempt but a whole duplicated reviewer task (time and tokens), for a job whose output was sitting on disk.

The foreground path in this same file takes the opposite position on the identical condition: line 571 says "The session id is only needed for resume, so a parse failure must not fail the task" and proceeds to read the result file with an empty session id. Two sibling transports disagreeing on whether a missing session id is fatal is the substitutability problem (U-L1), and here the stricter of the two is the one being retried.

Suggested fix: read the result file first. If readFirstNonEmptyFile succeeds, return the response with whatever session id was found (empty is already a valid answer that callers handle, per line 571). Reserve the ErrClaudeBGTransport marking for the case where there is both no session id and no result.

Reviewer Coverage

  • go:implementation-tests — complete (broad); skipped: none; constraints: none
  • architecture:solid-reviewer-agnostic — complete (broad); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: Could not run the project's build/test/lint here (command execution was restricted), so no red-check claims are made either way. Reviewed the head file contents plus the PR description and change map; a line-level diff was not available in this sandbox, so new-vs-pre-existing attribution is inferred (ErrClaudeBGTransport, startClaude fallback, claudeFallbackStream, and the bg error classifications treated as changed). internal/llmadapters/subprocess_test.go was not in the assigned file set; test coverage is judged only from the PR description's test summary.
  • security:code-auditor — complete (broad); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: none
  • structure:harness-engineering — complete (broad); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: none
Inspected files (2)
  • internal/llmadapters/subprocess.go
  • internal/llmadapters/subprocess_test.go

0 PR discussion threads considered. 0 summarized; 0 resolved.


Completed in 5m 51s | $5.78 | claude-sonnet-5, claude-opus-5 | cr 0.10.294
Field Value
Model claude-sonnet-5, claude-opus-5
Reviewers go:implementation-tests, architecture:solid-reviewer-agnostic, security:code-auditor, structure:harness-engineering
Engine claude_cli · claude-sonnet-5, claude-opus-5
Reviewed by cr · monit-reviewer
Duration 5m 51s wall · 11m 58s compute
Cost $5.78
Tokens 142 in / 51.1k out

Per-workstream usage

  • orchestrator-selection — claude-sonnet-5
    • In: 6
    • Out: 2.0k
    • Cache read: 115.4k
    • Cache create: 65.3k
    • Cost: $0.31
    • Duration: 26s
  • go:implementation-tests — claude-sonnet-5
    • In: 44
    • Out: 16.0k
    • Cache read: 2.3M
    • Cache create: 147.1k
    • Cost: $1.21
    • Duration: 3m 12s
  • architecture:solid-reviewer-agnostic — claude-opus-5
    • In: 40
    • Out: 18.4k
    • Cache read: 1.8M
    • Cache create: 133.2k
    • Cost: $2.68
    • Duration: 4m 25s
  • security:code-auditor — claude-sonnet-5
    • In: 16
    • Out: 2.0k
    • Cache read: 535.6k
    • Cache create: 89.6k
    • Cost: $0.49
    • Duration: 30s
  • structure:harness-engineering — claude-sonnet-5
    • In: 30
    • Out: 9.4k
    • Cache read: 1.2M
    • Cache create: 108.3k
    • Cost: $0.78
    • Duration: 2m 41s
  • orchestrator-rollup — claude-sonnet-5
    • In: 6
    • Out: 3.2k
    • Cache read: 154.7k
    • Cache create: 64.9k
    • Cost: $0.32
    • Duration: 42s

Comment thread internal/llmadapters/subprocess.go Outdated
Comment thread internal/llmadapters/subprocess.go
Comment thread internal/llmadapters/subprocess.go Outdated
Comment thread internal/llmadapters/subprocess.go
Comment thread internal/llmadapters/subprocess.go Outdated
Comment thread internal/llmadapters/subprocess_test.go
…t of evidence

Review findings.

A job that ran to completion and wrote its result is finished, whatever its
state file says about session ids. The session-id check ran before the result
read, so such a job was marked as a transport failure and re-run end to end,
spending a second model run on output already on disk — the opposite of this
change's own rule that a completed job is not retried. The result is read
first now, and an empty session id is returned as empty, which is the position
the foreground transport already takes on the same condition.

The launch-side fallback retried on any error, so a configuration failure in
background arg construction was reported as a foreground problem. It now
applies the same ErrClaudeBGTransport test the wait side does, and joins the
original error when the fallback launch fails too.

The retry no longer truncates the log of the failure it is retrying. Task logs
are opened with os.Create, so a shared path erased the background attempt —
including the banner and blocked-state detail that identify this failure mode,
and the only account of the error still reported when the retry also fails.
The retry writes to a sibling path instead.

Both attempts now share one task deadline rather than each starting a full
window, so the Timeout contract still bounds a task rather than an attempt.

SessionID reports the stream that produced the response and nothing else.
Pairing a foreground response with the background job's session sent a later
resume at a session the transport had already lost.

Tests: the states table now asserts launch counts per case, so a retry that
stops happening, or one that starts happening for a model failure, fails the
test rather than passing on an unhandled helper mode; a completed job with a
result and no session id is not retried; a fallback keeps both transports' logs.
monit-reviewer
monit-reviewer previously approved these changes Aug 31, 2026

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

Automated PR Review

Reviewed commit: d3def239dd0f
Profile: claude-monit-reviewer - Posting as: monit-reviewer

Summary

Reviewer Findings
go:implementation-tests 1
architecture:solid-reviewer-agnostic 3
security:code-auditor 0
structure:harness-engineering 0
go:implementation-tests (1 finding)

Minor - internal/llmadapters/subprocess.go:395

taskDeadline has no direct test, and no test exercises the behavior it exists for: that a retry runs under what remains of the task's original budget rather than a fresh window. The function has four real branches (zero timeout with no ctx deadline, timeout only, ctx deadline only, and picking the earlier of the two when both are set), and it is exactly the mechanism the earlier review round asked for ("one budget for the task, not one per attempt"). None of the current fallback tests (TestSubprocessClaudeBackgroundFailureFallsBackToForeground, TestSubprocessClaudeFallbackKeepsBothTransportLogs) constrain the adapter timeout tightly enough to prove the retry actually inherits the primary's remaining budget instead of a full new one -- they all use a generous 5s timeout where either behavior would pass. Add a table test on taskDeadline itself (zero timeout, ctx-only deadline, timeout-only deadline, earliest-of-both) plus one fallback test with a short adapter timeout that asserts the foreground retry's context is bounded by what's left of the original window, not a fresh a.timeout.

architecture:solid-reviewer-agnostic (3 findings)

Minor - internal/llmadapters/subprocess.go:372

This branch cannot be taken. ErrClaudeBGTransport is only ever attached inside the wait path (lines 1095, 1474, 1501), all of which run after a successful launch; every return in startClaudeBG (lines 240-284) passes through a raw error from invocationScratchDir, validateScratchDir, writeClaudeBGPromptFile, buildArgsForSession, validateArgs, claudeBGWorkingDir, processEnv, or launchProcess, none of which wrap the sentinel. So errors.Is(err, ErrClaudeBGTransport) is always false here and lines 375-379 are dead. Nothing in subprocess_test.go references the sentinel, and no test covers this branch, which is consistent with it being unreachable.

That matters beyond tidiness for two reasons. A reader sees a handled case that is not handled, and the branch is where a future "mark this launch failure as transport" change would land silently. It also still passes req unchanged to startClaudeForeground, so the moment it did become reachable it would reuse the primary LogPath and reintroduce the truncation foregroundRetryLogPath was added to prevent (U-O1: an extension point with no reachable case; U-G1: new surface with no real consumer).

Suggested fix: delete the branch and return err directly, keeping the comment that a launch failure is configuration or a missing binary and would fail identically in foreground. If a launch failure is meant to be retryable, then wrap the launchProcess error at line 281-283 with ErrClaudeBGTransport, route it through foregroundRetryLogPath as Wait does, and add a case to the existing table test so the branch is exercised.

Nits - internal/llmadapters/subprocess.go:424

fallback is now write-only: it is assigned under the mutex at line 463 and never read anywhere in the package, since SessionID reads winner instead. The field's own doc at line 422 still describes it as carrying "the retry once started", a role nothing consumes.

Suggested fix: drop the field and the lock section at lines 462-464, and trim the comment to describe winner alone. Keep it only if something outside this file is expected to read it, in which case the comment should say what.

Minor - internal/llmadapters/subprocess.go:1471

An empty result file with no session id is marked as a transport failure and retried, which contradicts the rule this PR states for itself: "A job that completed and wrote an empty or missing result file is not marked, because retrying would only repeat it." readFirstNonEmptyFile returns an error for both "the file exists and is blank" and "no file at all" (lines 1528-1533 of the pre-existing helper), so this branch cannot distinguish them and treats the empty-file case as "the job left nothing behind". The comment at line 1472 asserts the stronger claim the code cannot check. The cost is a full duplicate model run for a job that did run and did write.

The corner is narrow (it needs both an empty result and a missing session id), which is why this is minor rather than major, and the fix should not be message matching, since branching on error strings is what the typed-error rule exists to avoid (U-L2).

Suggested fix: give the helper two sentinels, e.g. errClaudeBGEmptyResult and errClaudeBGMissingResult, wrap its two returns accordingly, and gate this branch on errors.Is(err, errClaudeBGMissingResult). An empty result then falls through to line 1476 and is reported as the model failure it is.

Reviewer Coverage

  • go:implementation-tests — complete (constrained); skipped: none; constraints: none
  • architecture:solid-reviewer-agnostic — complete (constrained); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: A line-level diff was not available in this sandbox; new-vs-pre-existing attribution is inferred from the head file, the change map, and the settled threads. Could not run the project's build/test/lint here (command execution was restricted), so no red-check claims are made either way. Re-review at d3def23. Verified the four settled threads against the head file: launch-path gating, single task deadline, sibling retry log path, and winner-based SessionID are all present and behave as described. The fifth thread (session id checked before the result file) is fixed at lines 1461-1476: the result is read first and the transport marking now applies only when neither a result nor a session exists. internal/llmadapters/subprocess_test.go was outside the allowed file set; it was read only as evidence about coverage of the paths below, not reviewed.
  • security:code-auditor — complete (constrained); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: none
  • structure:harness-engineering — complete (constrained); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: none
Inspected files (2)
  • internal/llmadapters/subprocess.go
  • internal/llmadapters/subprocess_test.go

0 PR discussion threads considered. 0 summarized; 0 resolved.


Completed in 3m 15s | $5.39 | claude-sonnet-5, claude-opus-5 | cr 0.10.294
Field Value
Model claude-sonnet-5, claude-opus-5
Reviewers go:implementation-tests, architecture:solid-reviewer-agnostic, security:code-auditor, structure:harness-engineering
Engine claude_cli · claude-sonnet-5, claude-opus-5
Reviewed by cr · monit-reviewer
Duration 3m 15s wall · 5m 26s compute
Cost $5.39
Tokens 84 in / 20.6k out

Per-workstream usage

  • go:implementation-tests — claude-sonnet-5
    • In: 26
    • Out: 5.5k
    • Cache read: 1.9M
    • Cache create: 170.5k
    • Cost: $1.12
    • Duration: 1m 16s
  • architecture:solid-reviewer-agnostic — claude-opus-5
    • In: 22
    • Out: 10.2k
    • Cache read: 1.5M
    • Cache create: 162.8k
    • Cost: $2.64
    • Duration: 2m 36s
  • security:code-auditor — claude-sonnet-5
    • In: 12
    • Out: 1.5k
    • Cache read: 492.8k
    • Cache create: 104.6k
    • Cost: $0.53
    • Duration: 27s
  • structure:harness-engineering — claude-sonnet-5
    • In: 18
    • Out: 2.6k
    • Cache read: 999.4k
    • Cache create: 134.5k
    • Cost: $0.76
    • Duration: 52s
  • orchestrator-rollup — claude-sonnet-5
    • In: 6
    • Out: 693
    • Cache read: 177.0k
    • Cache create: 72.1k
    • Cost: $0.33
    • Duration: 14s

Comment thread internal/llmadapters/subprocess.go Outdated
Comment thread internal/llmadapters/subprocess.go Outdated
Comment thread internal/llmadapters/subprocess.go Outdated
Comment thread internal/llmadapters/subprocess.go
…y result from a missing one

Review findings.

The launch-side fallback could never run: nothing on the launch path wraps
ErrClaudeBGTransport, so the branch was dead, and a future change marking a
launch failure would have landed there silently, reusing the primary log path
and reintroducing the truncation the sibling path was added to prevent. A
launch fails on configuration, a missing binary, or a scratch dir that could
not be made, and foreground fails the same way, so the error is returned.

An empty result file with no session id was being marked as a transport
failure and retried, which the stated rule excludes: the job ran, and running
it again repeats it. readFirstNonEmptyFile now returns errClaudeBGEmptyResult
or errClaudeBGMissingResult, and only the missing case can mean the transport
left nothing behind. The two are distinguished by sentinel rather than by
message text.

The fallback field was write-only once SessionID started reading the winning
stream, so it is gone.

Tests: taskDeadline covers its four branches (no bound, timeout only, context
only, and the earlier of both), and a spent budget is shown to launch no retry
at all rather than starting a fresh window.
monit-reviewer
monit-reviewer previously approved these changes Aug 31, 2026

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

Automated PR Review

Reviewed commit: 2335eeeac765
Profile: claude-monit-reviewer - Posting as: monit-reviewer

Summary

Reviewer Findings
go:implementation-tests 0
architecture:solid-reviewer-agnostic 1
security:code-auditor 0
structure:harness-engineering 0
architecture:solid-reviewer-agnostic (1 finding)

Nits - internal/llmadapters/subprocess.go:1589

These sentinels are shared by both transports but are named and documented for one. readFirstNonEmptyFile is called from the foreground path at line 636 as well as the background path at line 1456, so errClaudeBGMissingResult reaches a foreground caller that wraps it at line 645, producing "Claude foreground task completed without a result file: llm subprocess: Claude background job completed without writing result file". The docstring likewise explains the values purely in background terms ("Only the second can mean the transport never ran the task"), which is the background caller's rule rather than a property of the sentinel.

The doubled wording is not a regression: the same strings were returned inline from this helper before the diff and the same call site wrapped them. What is new is that they are now a named contract, which is where the transport-specific framing gets locked in for whoever reads the symbol next (U-I1: a shared contract should be described in terms both consumers can hold).

Suggested fix: name them for what they describe rather than which transport noticed, e.g. errClaudeEmptyResultFile and errClaudeMissingResultFile with messages like "result file is empty" / "no result file", and let each call site keep supplying its own transport prefix as both already do. Move the "only missing can mean the transport never ran the task" sentence to the branch at line 1460 that actually applies it.

Reviewer Coverage

  • go:implementation-tests — complete (constrained); skipped: none; constraints: none
  • architecture:solid-reviewer-agnostic — complete (constrained); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: A line-level diff was not available in this sandbox; new-vs-pre-existing attribution is inferred from the head file, the change map, and the settled threads. Could not run the project's build/test/lint here (command execution was restricted), so no red-check claims are made either way. Re-review at 2335eee. All three findings from the previous round are fixed in the head file: the unreachable launch-path branch is deleted (lines 366-373), the write-only fallback field is gone (lines 407-418), and the empty/missing result cases are separated by sentinels (lines 1456-1466, 1589-1... The earlier rounds' fixes still hold at this head: single task deadline (lines 382, 388-403, 440-445), sibling retry log path (lines 447, 468-479), winner-based SessionID (lines 420-432), result read before the session id (lines 1450-1458). internal/llmadapters/subprocess_test.go was outside the allowed file set and was not reviewed.
  • security:code-auditor — complete (constrained); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: none
  • structure:harness-engineering — complete (constrained); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: none
Inspected files (2)
  • internal/llmadapters/subprocess.go
  • internal/llmadapters/subprocess_test.go

0 PR discussion threads considered. 0 summarized; 0 resolved.


Completed in 2m 34s | $5.58 | claude-sonnet-5, claude-opus-5 | cr 0.10.294
Field Value
Model claude-sonnet-5, claude-opus-5
Reviewers go:implementation-tests, architecture:solid-reviewer-agnostic, security:code-auditor, structure:harness-engineering
Engine claude_cli · claude-sonnet-5, claude-opus-5
Reviewed by cr · monit-reviewer
Duration 2m 34s wall · 4m 41s compute
Cost $5.58
Tokens 86 in / 18.1k out

Per-workstream usage

  • go:implementation-tests — claude-sonnet-5
    • In: 46
    • Out: 9.4k
    • Cache read: 4.2M
    • Cache create: 187.0k
    • Cost: $1.68
    • Duration: 1m 59s
  • architecture:solid-reviewer-agnostic — claude-opus-5
    • In: 14
    • Out: 4.9k
    • Cache read: 1.1M
    • Cache create: 172.3k
    • Cost: $2.38
    • Duration: 1m 20s
  • security:code-auditor — claude-sonnet-5
    • In: 8
    • Out: 948
    • Cache read: 352.1k
    • Cache create: 98.0k
    • Cost: $0.47
    • Duration: 20s
  • structure:harness-engineering — claude-sonnet-5
    • In: 12
    • Out: 2.3k
    • Cache read: 735.5k
    • Cache create: 132.2k
    • Cost: $0.70
    • Duration: 50s
  • orchestrator-rollup — claude-sonnet-5
    • In: 6
    • Out: 466
    • Cache read: 189.6k
    • Cache create: 77.3k
    • Cost: $0.35
    • Duration: 11s

Comment thread internal/llmadapters/subprocess.go Outdated
…e transport

Both transports read result files through readFirstNonEmptyFile, so sentinels
named and documented for the background one made the foreground path report
"foreground task completed without a result file: ...background job completed
without writing result file". They now describe the file ("no result file",
"result file is empty") and each call site supplies its own prefix, which is
what both already did. The rule about which case can mean the transport never
ran the task moved to the branch that applies it.

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

Automated PR Review

Reviewed commit: 900c1ab92295
Profile: claude-monit-reviewer - Posting as: monit-reviewer

Summary

Reviewer Findings
go:implementation-tests 1
architecture:solid-reviewer-agnostic 0
security:code-auditor 0
structure:harness-engineering 0
go:implementation-tests (1 finding)

Major - internal/llmadapters/subprocess_test.go:369

The one branch in waitForClaudeBGResult that actually returns ErrClaudeBGTransport for a missing-result job (subprocess.go:1460, sessionID == "" && errors.Is(err, errClaudeMissingResultFile)) has no test. "missing result" (bg-missing-result, line 369) sets sessionId: "session-missing" in the helper state, so it takes the non-transport branch at subprocess.go:1468 and never exercises the retry condition; "empty result" carries the other sentinel; and TestSubprocessClaudeBackgroundResultWithoutSessionIsNotRetried (line 2258) covers the opposite combination (no session id, but a valid result). No case in either the table or a dedicated test drives a job that finishes with neither a session id nor a result file, which is the exact scenario this round's fix (900c1ab, and the thread at subprocess.go:1466 before it) was about: "the job left nothing behind, which is the only shape here that can mean the transport never ran the task." A regression that stopped marking this case as ErrClaudeBGTransport (or started marking bg-missing-result's case, which has a session id) would pass every existing test. Add a helper mode with state "done", no sessionId, and writeResult=false, and assert both errors.Is(err, ErrClaudeBGTransport) and assertClaudeLaunchCount(t, records, true). Note this path also falls through waitForClaudeBGSessionID's claudeBGSessionIDGrace (a hardcoded 10s const), so a naive test would block for the full grace window before failing closed; either inject/shorten the grace for this test or make it an adapter-configurable duration, which would also make the constant a properly testable seam per the reviewer's own preserve-testable-seams guidance.

Reviewer Coverage

  • go:implementation-tests — complete (constrained); skipped: none; constraints: none
  • architecture:solid-reviewer-agnostic — complete (constrained); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: A line-level diff was not available in this sandbox; new-vs-pre-existing attribution is inferred from the head file, the change map, and the settled threads. Also still intact: launch failure returned unchanged (366-373), single task deadline (382, 388-403, 440-445), sibling retry log path (447, 468-479), winner-based SessionID (414-432), result read before session id (1450-1468). Could not run the project's build/test/lint here (command execution was restricted), so no red-check claims are made either way. Not reported, as pre-existing and unchanged: the foreground wrapper at 645 reads "completed without a result file" even when the wrapped cause is an empty file. The base composed the same mismatch with different wording. Re-review at 900c1ab. No findings: every finding this reviewer raised across the earlier heads is fixed in the head file, and the remaining changed lines raise nothing new. Verified at this head: sentinels renamed to errClaudeEmptyResultFile / errClaudeMissingResultFile with file-describing messages (1590-1598), each call site supplying its own transport prefix (645, 1468), and errors.Is gating rather than message text (1460). internal/llmadapters/subprocess_test.go was outside the allowed file set and was not reviewed.
  • security:code-auditor — complete (constrained); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: none
  • structure:harness-engineering — complete (constrained); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: none
Inspected files (2)
  • internal/llmadapters/subprocess.go
  • internal/llmadapters/subprocess_test.go

0 PR discussion threads considered. 0 summarized; 0 resolved.


Completed in 1m 49s | $5.41 | claude-sonnet-5, claude-opus-5 | cr 0.10.294
Field Value
Model claude-sonnet-5, claude-opus-5
Reviewers go:implementation-tests, architecture:solid-reviewer-agnostic, security:code-auditor, structure:harness-engineering
Engine claude_cli · claude-sonnet-5, claude-opus-5
Reviewed by cr · monit-reviewer
Duration 1m 49s wall · 3m 05s compute
Cost $5.41
Tokens 56 in / 10.5k out

Per-workstream usage

  • go:implementation-tests — claude-sonnet-5
    • In: 20
    • Out: 5.1k
    • Cache read: 2.0M
    • Cache create: 206.5k
    • Cost: $1.27
    • Duration: 1m 11s
  • architecture:solid-reviewer-agnostic — claude-opus-5
    • In: 14
    • Out: 3.0k
    • Cache read: 1.2M
    • Cache create: 190.0k
    • Cost: $2.56
    • Duration: 58s
  • security:code-auditor — claude-sonnet-5
    • In: 6
    • Out: 651
    • Cache read: 258.7k
    • Cache create: 108.0k
    • Cost: $0.49
    • Duration: 12s
  • structure:harness-engineering — claude-sonnet-5
    • In: 10
    • Out: 1.2k
    • Cache read: 637.9k
    • Cache create: 142.7k
    • Cost: $0.71
    • Duration: 30s
  • orchestrator-rollup — claude-sonnet-5
    • In: 6
    • Out: 538
    • Cache read: 200.5k
    • Cache create: 82.9k
    • Cost: $0.38
    • Duration: 11s

Comment thread internal/llmadapters/subprocess_test.go
The one branch that marks a background failure as ErrClaudeBGTransport had no
test: the table's missing-result case carries a session id and takes the
non-transport path, the empty-result case carries the other sentinel, and the
result-without-session case is the opposite combination. A regression that
stopped marking this case, or started marking one with a session id, passed
everything.

The new case is a job that finishes with neither a session id nor a result,
and it asserts both the sentinel and that the retry was attempted. The
session-id grace is an adapter field defaulting to the existing constant, so
the test drives that path without waiting out a ten-second window.

Verified the test fails with the branch disabled.
monit-reviewer
monit-reviewer previously approved these changes Aug 31, 2026

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

Automated PR Review

Reviewed commit: 60ada627c9c7
Profile: claude-monit-reviewer - Posting as: monit-reviewer

Summary

Reviewer Findings
go:implementation-tests 0
architecture:solid-reviewer-agnostic 1
security:code-auditor 0
structure:harness-engineering 0
architecture:solid-reviewer-agnostic (1 finding)

Nits - internal/llmadapters/subprocess.go:107

sessionIDGrace is the one adapter field no constructor sets. newSubprocessAdapter (lines 123-146) populates every other field, and this file already has a seam for exactly this kind of test-only injection: SubprocessOptions.commandArgsPrefix is unexported and threaded through at line 139. Adding the grace as a struct field written directly by tests routes around that seam, so the package now has two ways to inject a test value into the same adapter (U-D1: the dependency is reachable only by poking the struct rather than arriving through the constructor).

The defaulting also differs from its nearest neighbor. timeout resolves zero to defaultLLMTaskTimeout once in the constructor (lines 132-135), so every reader sees a settled value; sessionIDGrace resolves at the use site (lines 1516-1519), so the field's stored value is not the value in effect. With one read site that is harmless, and it stops being harmless the first time a second reader forgets the fallback.

Suggested fix: add an unexported sessionIDGrace time.Duration to SubprocessOptions beside commandArgsPrefix, apply the claudeBGSessionIDGrace default in newSubprocessAdapter the way timeout is applied, and have waitForClaudeBGSessionID read a.sessionIDGrace directly.

Reviewer Coverage

  • go:implementation-tests — complete (constrained); skipped: none; constraints: none
  • architecture:solid-reviewer-agnostic — complete (constrained); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: A line-level diff was not available in this sandbox; new-vs-pre-existing attribution is inferred from the head file, the change map, and the settled threads. Could not run the project's build/test/lint here (command execution was restricted), so no red-check claims are made either way. Every finding this reviewer raised across the earlier heads remains fixed at this head. Re-review at 60ada62. The only production change since 900c1ab is the sessionIDGrace field (104-107) and its use-site default (1516-1519); everything else in the changed surface is unchanged and was verified in earlier rounds. Verified still intact: launch failure returned unchanged (366-377), single task deadline (386-407), sibling retry log path (451, 472-483), winner-based SessionID (418-436), result read before session id (1454-1472), file-describing result sentinels with per-caller prefixes (649, 1472). internal/llmadapters/subprocess_test.go was outside the allowed file set and was not reviewed, so the new bg-nothing-left-behind coverage is taken from the settled thread rather than verified.
  • security:code-auditor — complete (constrained); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: none
  • structure:harness-engineering — complete (constrained); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: none
Inspected files (2)
  • internal/llmadapters/subprocess.go
  • internal/llmadapters/subprocess_test.go

0 PR discussion threads considered. 0 summarized; 0 resolved.


Completed in 1m 36s | $5.76 | claude-sonnet-5, claude-opus-5 | cr 0.10.294
Field Value
Model claude-sonnet-5, claude-opus-5
Reviewers go:implementation-tests, architecture:solid-reviewer-agnostic, security:code-auditor, structure:harness-engineering
Engine claude_cli · claude-sonnet-5, claude-opus-5
Reviewed by cr · monit-reviewer
Duration 1m 36s wall · 2m 40s compute
Cost $5.76
Tokens 54 in / 8.8k out

Per-workstream usage

  • go:implementation-tests — claude-sonnet-5
    • In: 22
    • Out: 3.1k
    • Cache read: 2.4M
    • Cache create: 222.5k
    • Cost: $1.39
    • Duration: 49s
  • architecture:solid-reviewer-agnostic — claude-opus-5
    • In: 12
    • Out: 3.5k
    • Cache read: 1.1M
    • Cache create: 207.9k
    • Cost: $2.70
    • Duration: 1m 04s
  • security:code-auditor — claude-sonnet-5
    • In: 6
    • Out: 630
    • Cache read: 278.8k
    • Cache create: 118.1k
    • Cost: $0.53
    • Duration: 12s
  • structure:harness-engineering — claude-sonnet-5
    • In: 8
    • Out: 1.0k
    • Cache read: 518.1k
    • Cache create: 154.0k
    • Cost: $0.73
    • Duration: 23s
  • orchestrator-rollup — claude-sonnet-5
    • In: 6
    • Out: 442
    • Cache read: 211.4k
    • Cache create: 88.2k
    • Cost: $0.40
    • Duration: 9s

Comment thread internal/llmadapters/subprocess.go
The field was the one adapter dependency written directly by tests, which
routed around the unexported SubprocessOptions seam that commandArgsPrefix
already uses, and it resolved its default at the use site rather than once in
the constructor the way timeout does, so the stored value was not the value in
effect.

It is an unexported option now, defaulted in newSubprocessAdapter, and the
wait reads the field directly.

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

Automated PR Review

Reviewed commit: 5afb105ea4f7
Profile: claude-monit-reviewer - Posting as: monit-reviewer

Summary

Reviewer Findings
go:implementation-tests 0
architecture:solid-reviewer-agnostic 0
security:code-auditor 0
structure:harness-engineering 0

Reviewer Coverage

  • go:implementation-tests — complete (constrained); skipped: none; constraints: none
  • architecture:solid-reviewer-agnostic — complete (constrained); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: A line-level diff was not available in this sandbox; new-vs-pre-existing attribution is inferred from the head file, the change map, and the settled threads. Could not run the project's build/test/lint here (command execution was restricted), so no red-check claims are made either way. Not reported: SubprocessOptions.sessionIDGrace treats a negative as "use the default" while the adjacent Timeout treats a negative as "disable the bound". The field is unexported and test-only, so no caller can reach the difference. Re-review at 5afb105. No findings: every finding this reviewer raised across the earlier heads is fixed in the head file, and the only change since 60ada62 resolves the last one. Verified still intact: launch failure returned unchanged (378-385), single task deadline (394, 400-415, 452-457), sibling retry log path (459, 480-491), winner-based SessionID (426-444), result read before session id with errors.Is gating (1462-1480). Verified the sessionIDGrace fix: unexported SubprocessOptions field beside commandArgsPrefix (44-48), default resolved once in newSubprocessAdapter like timeout (139-142, 152), and waitForClaudeBGSessionID reads a.sessionIDGrace directly (1524). internal/llmadapters/subprocess_test.go was outside the allowed file set and was not reviewed; the new coverage is taken from the settled threads rather than verified.
  • security:code-auditor — complete (constrained); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: none
  • structure:harness-engineering — complete (constrained); inspected 1 assigned file (2 inspected across reviewers): internal/llmadapters/subprocess.go; skipped: none; constraints: none
Inspected files (2)
  • internal/llmadapters/subprocess.go
  • internal/llmadapters/subprocess_test.go

0 PR discussion threads considered. 0 summarized; 0 resolved.


Completed in 1m 55s | $6.84 | claude-sonnet-5, claude-opus-5 | cr 0.10.294
Field Value
Model claude-sonnet-5, claude-opus-5
Reviewers go:implementation-tests, architecture:solid-reviewer-agnostic, security:code-auditor, structure:harness-engineering
Engine claude_cli · claude-sonnet-5, claude-opus-5
Reviewed by cr · monit-reviewer
Duration 1m 55s wall · 3m 14s compute
Cost $6.84
Tokens 72 in / 10.1k out

Per-workstream usage

  • go:implementation-tests — claude-sonnet-5
    • In: 34
    • Out: 4.9k
    • Cache read: 4.1M
    • Cache create: 249.3k
    • Cost: $1.87
    • Duration: 1m 17s
  • architecture:solid-reviewer-agnostic — claude-opus-5
    • In: 16
    • Out: 2.9k
    • Cache read: 1.6M
    • Cache create: 227.8k
    • Cost: $3.17
    • Duration: 58s
  • security:code-auditor — claude-sonnet-5
    • In: 8
    • Out: 818
    • Cache read: 437.0k
    • Cache create: 128.8k
    • Cost: $0.61
    • Duration: 19s
  • structure:harness-engineering — claude-sonnet-5
    • In: 8
    • Out: 1.0k
    • Cache read: 544.6k
    • Cache create: 164.4k
    • Cost: $0.78
    • Duration: 29s
  • orchestrator-rollup — claude-sonnet-5
    • In: 6
    • Out: 411
    • Cache read: 221.5k
    • Cache create: 92.9k
    • Cost: $0.42
    • Duration: 9s

@piekstra
piekstra merged commit 27e1be2 into main Aug 31, 2026
10 checks passed
@piekstra
piekstra deleted the piekstra/claude-bg-foreground-fallback branch August 31, 2026 21:31
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.

2 participants