Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,29 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v

## [Unreleased]

### Added
- **`--timeout` — bound a run, and raise the ceilings that used to end it early
(ADR-0038).** Every provider shipped a hard, invisible cap: the CLI-tool
providers killed their subprocess after 5 minutes, ollama's HTTP client after
5, and the Anthropic SDK refuses a non-streaming request that could exceed 10.
A large diff or a slow local model hit those, and there was no way to ask for
more time. `--timeout <duration>` now bounds the **whole command run** — diff,
provider call, render, and time spent at a confirmation prompt — and, crucially,
hands the value down to the provider, which is the only way to *lengthen* a run
(a context deadline can only cut one short). Accepts a Go duration (`90s`,
`10m`, `1h30m`) or a bare number of seconds (`600`) so CI can say
`--timeout 600`. Resolution is `--timeout` > `review.timeout` config > the
built-in, and `--timeout 0` restores the built-ins for a single run. Applies to
every command that can spend real time, including `doctor` (whose provider
probes otherwise fast-fail at 5 seconds) and `providers test`; on
`commitbrief mcp` it becomes a per-tool-call budget instead of a lifetime for
the long-lived server. Expiring is a normal exit-1 failure with a message that
names the duration and points back at the flag, so a self-inflicted deadline is
never mistaken for a provider outage.
- **`review.timeout` config key** — the persistent half of `--timeout`, settable
with `commitbrief config set review.timeout 15m`. Validated on write, so a
typo fails at `config set` rather than on every later run.

## [1.15.0] - 2026-07-26

### Added
Expand Down
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,9 +301,37 @@ then exit — no provider call, no cost; honours `--output`), `--no-flaky`
sandbox-rerun confirmation of flagged flaky tests; see below),
`--no-architecture` (skip
architecture-aware review; see below), `--update-baseline` /
`--no-baseline` (signal-control baseline; see below), `--color`. See
`--no-baseline` (signal-control baseline; see below), `--color`,
`--timeout <duration>` (bound the whole run; see below). See
`commitbrief --help`.

### Timeouts (`--timeout`, `review.timeout`)

Every provider ships a built-in ceiling: the CLI-tool providers
(`claude-cli` / `gemini-cli` / `codex-cli`) kill their subprocess after 5
minutes, ollama's HTTP client after 5, and the Anthropic SDK refuses a
non-streaming request that could run past 10. On a large diff or a slow
local model that is exactly when the run dies.

`--timeout` replaces those ceilings for one run:

```sh
commitbrief --staged --cli claude --timeout 20m # give the host CLI 20 minutes
commitbrief --staged --timeout 600 # bare integer = seconds
commitbrief config set review.timeout 15m # make it the default
```

The value is a Go duration (`90s`, `10m`, `1h30m`) or a whole number of
seconds. It bounds the **whole command run** — diff acquisition, provider
call, render, and any time you spend at a confirmation prompt — and it is
the one knob that can *lengthen* a run, since a deadline alone can only
cut one short. Resolution is `--timeout` → `review.timeout` → the
provider's built-in, so `--timeout 0` restores the built-ins for a single
run. Expiring is a normal failure: exit code 1 with a message naming the
duration. It also applies to `doctor` (whose provider probes otherwise
fast-fail at 5 seconds) and `providers test`; on `commitbrief mcp` it
becomes a per-tool-call budget rather than a lifetime for the server.

### Flaky-test detection (deterministic, ADR-0022)

Before the model is called, a **static pre-pass** scans the added lines of any
Expand Down Expand Up @@ -843,6 +871,7 @@ review:
baseline: true # apply the user-private signal-control baseline (ADR-0027); --no-baseline overrides per-run, --update-baseline rewrites it
architecture: true # architecture-aware review (ADR-0030): read architecture.json into the prompt; --no-architecture overrides per-run
architecture_file: "" # override the architecture.json discovery path (relative to repo root, or absolute); empty = auto-discover
timeout: "" # bound the whole run: "10m", "90s", "600" (seconds); empty/"0" = keep each provider's built-in cap; --timeout overrides per-run
```

### Default command (`command.default`)
Expand Down
9 changes: 6 additions & 3 deletions internal/cli/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,13 @@ func newCommitCmd() *cobra.Command {
}

func runCommit(cmd *cobra.Command) error {
ctx := cmd.Context()
app, err := resolveContext(true)
if err != nil {
return err
}
ctx, cancel := app.withTimeout(cmd.Context())
defer cancel()
cmd.SetContext(ctx)

// Resolve format + count (flag > config > built-in default) and validate
// up front so a typo fails before any provider call.
Expand Down Expand Up @@ -182,7 +184,7 @@ func runCommit(cmd *cobra.Command) error {
prog.Resume()

prog.Start(app.Catalog.T("progress.preparing"))
prov, err := provider.New(app.Config.Provider, app.Config.Providers[app.Config.Provider])
prov, err := newProviderWithTimeout(app.Config.Provider, app.Config.Providers[app.Config.Provider], app.Timeout)
if err != nil {
prog.Fail(err)
return err
Expand Down Expand Up @@ -258,8 +260,9 @@ func runCommit(cmd *cobra.Command) error {
FreeForm: true,
})
if callErr != nil {
callErr = wrapTimeoutErr(ctx, fmt.Errorf("provider %s: %w", prov.Name(), callErr), app.Catalog, app.Timeout)
prog.Fail(callErr)
return fmt.Errorf("provider %s: %w", prov.Name(), callErr)
return callErr
}
prog.Finish()
content = resp.Content
Expand Down
7 changes: 5 additions & 2 deletions internal/cli/compress.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"github.com/spf13/cobra"

"github.com/CommitBrief/commitbrief/internal/compress"
"github.com/CommitBrief/commitbrief/internal/provider"
"github.com/CommitBrief/commitbrief/internal/rules"
"github.com/CommitBrief/commitbrief/internal/ui"
)
Expand All @@ -39,6 +38,9 @@ func newCompressCmd() *cobra.Command {
if err != nil {
return err
}
ctx, cancel := app.withTimeout(cmd.Context())
defer cancel()
cmd.SetContext(ctx)

level, err := compress.ParseLevel(levelFlag)
if err != nil {
Expand All @@ -54,7 +56,7 @@ func newCompressCmd() *cobra.Command {
return fmt.Errorf("compress: read %s: %w", rulesPath, err)
}

prov, err := provider.New(app.Config.Provider, app.Config.Providers[app.Config.Provider])
prov, err := newProviderWithTimeout(app.Config.Provider, app.Config.Providers[app.Config.Provider], app.Timeout)
if err != nil {
return err
}
Expand All @@ -79,6 +81,7 @@ func newCompressCmd() *cobra.Command {
Model: model,
})
if err != nil {
err = wrapTimeoutErr(ctx, err, app.Catalog, app.Timeout)
prog.Fail(err)
return err
}
Expand Down
14 changes: 12 additions & 2 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,10 @@ func configFieldGet(cfg *config.Config, path string) (string, error) {
return strconv.Itoa(cfg.Review.SandboxRerun), nil
case "sandbox_command":
return strings.Join(cfg.Review.SandboxCommand, " "), nil
case "timeout":
return cfg.Review.Timeout, nil
default:
return "", fmt.Errorf("config: unknown field %q in review (allowed: flaky, baseline, architecture, architecture_file, sandbox_rerun, sandbox_command)", parts[1])
return "", fmt.Errorf("config: unknown field %q in review (allowed: flaky, baseline, architecture, architecture_file, sandbox_rerun, sandbox_command, timeout)", parts[1])
}

case "version":
Expand Down Expand Up @@ -477,8 +479,16 @@ func configFieldSet(cfg *config.Config, path, value string) error {
// the YAML directly, mirroring guard.secret_patterns and
// per-model pricing — the other non-scalar surfaces.
return errors.New("config: review.sandbox_command is a list of argv elements; edit the config file directly (commitbrief config show prints its path)")
case "timeout":
// Validate before writing so the YAML never grows a value that
// would fail every subsequent run. The stored form is the user's
// spelling ("10m", "600"); parseTimeout normalizes at read time.
if _, err := parseTimeout(value); err != nil {
return fmt.Errorf("config: review.timeout: %w", err)
}
cfg.Review.Timeout = strings.TrimSpace(value)
default:
return fmt.Errorf("config: unknown field %q in review (allowed: flaky, baseline, architecture, architecture_file, sandbox_rerun, sandbox_command)", parts[1])
return fmt.Errorf("config: unknown field %q in review (allowed: flaky, baseline, architecture, architecture_file, sandbox_rerun, sandbox_command, timeout)", parts[1])
}
return nil

Expand Down
31 changes: 31 additions & 0 deletions internal/cli/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,3 +437,34 @@ func loadCfg(t *testing.T, home string) *config.Config {
}
return &cfg
}

func TestConfigReviewTimeoutRoundTrips(t *testing.T) {
e := newCLIEnv(t)
if err := e.run("config", "get", "review.timeout"); err != nil {
t.Fatalf("config get review.timeout: %v", err)
}
if got := strings.TrimSpace(e.out.String()); got != "" {
t.Errorf("review.timeout default = %q, want empty (no deadline)", got)
}

if err := e.run("config", "set", "review.timeout", "10m"); err != nil {
t.Fatalf("config set review.timeout 10m: %v", err)
}
cfg := loadCfg(t, e.homeDir)
if cfg.Review.Timeout != "10m" {
t.Errorf("review.timeout = %q, want %q", cfg.Review.Timeout, "10m")
}
}

func TestConfigReviewTimeoutRejectsGarbage(t *testing.T) {
// Validating on write keeps the YAML from growing a value that would
// fail every later run — the failure belongs at `config set` time.
e := newCLIEnv(t)
err := e.run("config", "set", "review.timeout", "ten-minutes")
if err == nil {
t.Fatal("want error for an unparseable review.timeout, got nil")
}
if !strings.Contains(err.Error(), "review.timeout") {
t.Errorf("error %q should name the key", err.Error())
}
}
16 changes: 16 additions & 0 deletions internal/cli/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package cli
import (
"fmt"
"os"
"time"

"github.com/CommitBrief/commitbrief/internal/config"
"github.com/CommitBrief/commitbrief/internal/git"
Expand All @@ -22,6 +23,11 @@ type appContext struct {
RawGlobal *config.Config
Lang lang.Resolution
Catalog *i18n.Catalog
// Timeout is the resolved --timeout / review.timeout value; zero means
// "no deadline, keep every built-in provider cap" (the historical
// behavior). Consumed by appContext.withTimeout and
// newProviderWithTimeout.
Timeout time.Duration
}

func resolveContext(requireRepo bool) (*appContext, error) {
Expand Down Expand Up @@ -70,6 +76,15 @@ func resolveContext(requireRepo bool) (*appContext, error) {
cfg.Providers[cfg.Provider] = pc
}

// Timeout resolution is deliberately here, not at the call site: every
// command that can spend real time reads app.Timeout, and resolving it
// once means a malformed value fails the run before the diff is read,
// let alone sent to a provider.
timeout, err := resolveTimeout(global.timeout, cfg)
if err != nil {
return nil, err
}

// Language resolution (ADR-0021) is independent of the merged config: it
// reads the raw per-file configs so each level (--lang flag → repo → user
// → English) is judged on its own value, with invalid/empty values falling
Expand All @@ -95,6 +110,7 @@ func resolveContext(requireRepo bool) (*appContext, error) {
RawGlobal: rawGlobal,
Lang: langRes,
Catalog: cat,
Timeout: timeout,
}, nil
}

Expand Down
13 changes: 8 additions & 5 deletions internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@ run produces no output.`,
if err != nil {
return err
}
ctx, cancel := app.withTimeout(cmd.Context())
defer cancel()
runner := &doctor.Runner{
RepoRoot: app.RepoRoot,
Home: userHome(),
Config: app.Config,
Catalog: app.Catalog,
RepoRoot: app.RepoRoot,
Home: userHome(),
Config: app.Config,
Catalog: app.Catalog,
ConnTimeout: app.Timeout,
}
results := runner.RunAll(cmd.Context())
results := runner.RunAll(ctx)
summary := doctor.Summarize(results)

w := cmd.OutOrStdout()
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ func runGuard(cmd *cobra.Command) error {
if err != nil {
return err
}
// Bounds the whole gate, including the review it drives through the MCP
// seam (which reads cmd.Context()).
ctx, cancel := app.withTimeout(cmd.Context())
defer cancel()
cmd.SetContext(ctx)

policyPath := resolveGuardPolicyPath(cmd, app.RepoRoot)
pol, err := policy.Load(policyPath)
Expand Down
58 changes: 58 additions & 0 deletions internal/cli/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2559,3 +2559,61 @@ func TestMapExitsZeroOnSuccess(t *testing.T) {
t.Fatalf("map must exit 0 on a successful render: %v", err)
}
}

// ---------- --timeout ----------

func TestTimeoutFlagRunsCleanWhenGenerous(t *testing.T) {
// A generous budget must be invisible: the review completes exactly as
// it does without the flag.
e := newCLIEnv(t)
if err := e.run("--staged", "--no-cache", "--timeout", "10m"); err != nil {
t.Fatalf("--timeout 10m should not disturb the pipeline: %v\nstderr:\n%s", err, e.errOut.String())
}
}

func TestTimeoutFlagAcceptsBareSeconds(t *testing.T) {
// `--timeout 600` is what a CI author types; rejecting it for a
// missing unit would be a papercut.
e := newCLIEnv(t)
if err := e.run("--staged", "--no-cache", "--timeout", "600"); err != nil {
t.Fatalf("--timeout 600 should parse as 600s: %v\nstderr:\n%s", err, e.errOut.String())
}
}

func TestTimeoutFlagRejectsGarbageBeforeAnyProviderCall(t *testing.T) {
// resolveContext validates the value, so the run dies before the diff
// is even read — no tokens, no cost.
e := newCLIEnv(t)
err := e.run("--staged", "--no-cache", "--timeout", "abc")
if err == nil {
t.Fatal("want error for an unparseable --timeout, got nil")
}
if !strings.Contains(err.Error(), "abc") {
t.Errorf("error %q should quote the offending value", err.Error())
}
}

func TestTimeoutFlagRejectsNegative(t *testing.T) {
e := newCLIEnv(t)
// `--` stops cobra flag parsing so the negative value reaches
// validation as the flag's value rather than a shorthand flag.
err := e.run("--staged", "--no-cache", "--timeout=-5s")
if err == nil {
t.Fatal("want error for a negative --timeout, got nil")
}
if !strings.Contains(err.Error(), "negative") {
t.Errorf("error %q should say it cannot be negative", err.Error())
}
}

func TestTimeoutConfigDrivesRunWithoutFlag(t *testing.T) {
// review.timeout is the persistent half of the pair; a bad stored
// value must fail the run the same way a bad flag does.
e := newCLIEnv(t)
if err := e.run("config", "set", "review.timeout", "10m"); err != nil {
t.Fatalf("config set review.timeout: %v", err)
}
if err := e.run("--staged", "--no-cache"); err != nil {
t.Fatalf("a configured review.timeout should not disturb the pipeline: %v\nstderr:\n%s", err, e.errOut.String())
}
}
3 changes: 3 additions & 0 deletions internal/cli/leaks.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ func runLeaks(cmd *cobra.Command, f leaksFlags, args []string) error {
if err != nil {
return err
}
tctx, tcancel := app.withTimeout(cmd.Context())
defer tcancel()
cmd.SetContext(tctx)
if f.noWorktree && f.noHistory {
return errors.New(app.Catalog.T("leaks.nothing_to_scan"))
}
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/map.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ func runMap(cmd *cobra.Command, f mapFlags, args []string) error {
return err
}

ctx, cancel := app.withTimeout(cmd.Context())
defer cancel()
cmd.SetContext(ctx)

// map draws a graph, not findings. Rejecting the findings-oriented output
// flags up front beats emitting something that isn't what the flag
// promised. A graph JSON would be a new semver-locked schema; that is
Expand Down
6 changes: 6 additions & 0 deletions internal/cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,12 @@ func runReviewForMCP(ctx context.Context, args reviewToolArgs) (string, string,
// tool error, which is the correct, safe behavior.
global.json = true
global.quiet = true
// --timeout survives the reset. For `guard` it is the flag the user just
// typed; for a long-lived `commitbrief mcp --timeout 10m` it becomes a
// PER-TOOL-CALL budget, which is the only sane reading — the server
// itself must never carry a deadline. When neither set it, runReview
// still falls back to review.timeout from config.
global.timeout = savedGlobal.timeout
// The host is an agent, not a person at a TTY. Executing repository code
// (sandbox rerun) requires a human in the loop, so it is forced off here
// regardless of --sandbox-rerun or review.sandbox_rerun. This is a
Expand Down
Loading
Loading