Skip to content

feat: add --timeout to bound a run and raise provider caps - #27

Merged
muhammetsafak merged 1 commit into
mainfrom
feature/timeout-flag
Jul 30, 2026
Merged

feat: add --timeout to bound a run and raise provider caps#27
muhammetsafak merged 1 commit into
mainfrom
feature/timeout-flag

Conversation

@muhammetsafak

Copy link
Copy Markdown
Member

Problem

Long reviews die and the user has no recourse. Every provider ships an invisible hard cap:

Where Cap Enforced by
claude-cli / gemini-cli / codex-cli 5 min clireview.Spec.Timeoutexec.CommandContext
ollama 5 min http.Client.Timeout (whole request)
anthropic 10 min SDK refuses rather than waits: streaming is required for operations that may take longer than 10 minutes
doctor per-provider probe 5 s doctor.connectionTimeout

On a large diff, or a mid-size local model on modest hardware, those are exactly the runs that die — and the only workaround was to shrink the diff.

Why a deadline alone is not the fix

context.WithTimeout gives a deadline, and a deadline can only ever cut a run short. clireview derives its own child context from the caller's, so min(5m, 20m) still dies at five; http.Client.Timeout fires independently of ctx; the Anthropic SDK's ceiling is a pre-flight refusal, not a wait. A user who types --timeout 20m and still gets killed at 5 has been actively misled.

So the resolved value is also handed down to the provider, through a new optional interface:

type TimeoutSetter interface {
    Provider
    SetTimeout(d time.Duration)
}

Same additive marker shape as the existing PlainTextEmitter, so Provider and Factory — both semver-locked — are untouched and no existing implementation breaks. Implemented by exactly the three providers with a cap to raise (clireview, ollama, anthropic); deliberately not by openai + its OpenAI-compatible siblings, gemini, or mock, which purely follow ctx. Callers reach it through two seams — cli.newProviderWithTimeout and setup.TestConnectionTimeout — never by type-asserting ad hoc.

Surface

commitbrief --staged --cli claude --timeout 20m   # host CLI gets 20m, not 5
commitbrief --staged --timeout 600                # bare integer = seconds
commitbrief config set review.timeout 15m         # persistent
commitbrief --staged --timeout 0                  # cancel that for one run
  • Accepts a Go duration (90s, 10m, 1h30m) or a bare whole number of seconds — --timeout 600 is what CI authors type, and rejecting it for a missing unit is a papercut with no upside.
  • Resolution: --timeout > review.timeout > built-in. --timeout 0 restores the built-ins for a single run.
  • Invalid/negative fails in resolveContext, before the diff is read.
  • Unset, behavior is byte-for-byte unchanged.
  • review.timeout is stored as a string (timeout: "10m") so the YAML stays readable, and is validated on write — a typo fails at config set, not on every later run.

Scope decisions

  • Whole run, not just the provider round-trip: diff, prompt, call, render, and time spent at a confirmation prompt (cost preflight, secret guard). Stated, not accidental — a review parked on a prompt nobody is there to answer is exactly as stuck as one parked on a provider.
  • doctor keeps its impatient 5s default but it is now overridable. On a high-latency link, 5 seconds reports merely-slow as unreachable — the wrong diagnosis for someone debugging their setup.
  • mcp gets a per-tool-call budget, never a deadline on the long-lived stdio server, by preserving global.timeout across the runReviewForMCP flag reset. That also fixes guard --timeout silently losing the flag.
  • Expiry checks the run's own ctx (providers report deadlines in three different dialects) and rewrites the error, so a self-inflicted deadline is never mistaken for a provider outage. Exit code stays 1; the cache key is unchanged.

Verification

make check green — gofmt, vet, golangci-lint, go test ./..., release-check, i18n-check, spdx-check, gosec.

Manual, against an unreachable ollama in a scratch repo:

  • fast-fail: --timeout abc and --timeout=-5s error before any work
  • deadline fires at exactly 3.0s, exit 1, message names the duration and points back at the flag
  • doctor 5s default → 12s with --timeout 12s
  • precedence: review.timeout: 6s → 6s run; --timeout 2s → 2s; --timeout 0 → ran past 6s until killed at 25s
  • the lengthening path is covered by a unit test with a real subprocess: a 50ms spec cap fails, SetTimeout(5s) then succeeds

New tests: internal/cli/timeout_test.go (parse table, precedence chain, withTimeout, wrapTimeoutErr), SetTimeout tests for all three providers, doctor.ConnTimeout, config get/set review.timeout round-trip, and five --timeout integration tests.

Docs

README (global flags + a Timeouts section + the config block), CHANGELOG (Unreleased). Wiki pages (Global-flags, Configuration-files, Doctor-command, Providers-command, MCP-server, Provider-CLI-tools, Provider-Ollama) are updated locally and pushed separately, as always.

🤖 Generated with Claude Code

Every provider shipped an invisible hard cap and a long review had no
recourse: the CLI-tool providers kill their subprocess after 5 minutes,
ollama's http.Client after 5, and the Anthropic SDK refuses outright past
10 ("streaming is required for operations that may take longer than 10
minutes"). doctor's per-provider probe fast-fails at 5 seconds.

A context deadline alone does not fix this — it can only shorten a run.
clireview derives its child context from the caller's, so min(5m, 20m)
still dies at five; http.Client.Timeout fires independently of ctx; and
the SDK's ceiling is a pre-flight refusal, not a wait. So the resolved
value is also handed down to the provider through a new optional
provider.TimeoutSetter (the same additive marker shape as
PlainTextEmitter), implemented by exactly clireview, ollama and anthropic
— the three with a cap to raise — and reached only through the
newProviderWithTimeout / setup.TestConnectionTimeout seams.

--timeout accepts a Go duration (90s, 10m, 1h30m) or a bare whole number
of seconds (600), since that is what CI authors type. Resolution is
--timeout > review.timeout > built-in, so --timeout 0 restores the
built-ins for a single run. Invalid or negative values fail in
resolveContext, before the diff is read. Unset, behavior is unchanged.

The budget covers the whole run — diff, provider call, render, and time
spent at a confirmation prompt. doctor keeps its impatient 5s default but
it is now overridable, because on a slow link 5 seconds reports merely
slow as unreachable. mcp gets a per-tool-call budget rather than a
deadline on the long-lived server, by preserving global.timeout across
the runReviewForMCP flag reset — which also fixes guard --timeout
silently losing the flag.

Expiry checks the run's own ctx (providers report deadlines in three
different dialects) and rewrites the error, so a self-inflicted deadline
is never mistaken for a provider outage. Exit code stays 1; the cache key
is unchanged.

See ADR-0038.
@muhammetsafak
muhammetsafak merged commit bb1e3f1 into main Jul 30, 2026
9 checks passed
@muhammetsafak
muhammetsafak deleted the feature/timeout-flag branch July 30, 2026 16:41
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.

1 participant