# review: make per-inspector timeout detection race-free; clarify matcher/defer

`f8b130a`→[main](/content/gh/entireio/cli/commits/main/index.html)·

dipree·1mo ago·5 files·+64 added/-12 removed

Timeout detection (run.go + run_multi.go) sampled both the agent context
and the parent context (ac.Err()==DeadlineExceeded && ctx.Err()==nil),
which races: a parent cancel landing just after a real timeout would
misclassify it as a cancellation. context.Err() is immutable once set, so
the agent context alone is authoritative — DeadlineExceeded means this
inspector's deadline fired first; a parent cancel propagates as Canceled.
Drop the parent-ctx read entirely.

Also address two review notes that were false positives, with comments +
tests rather than behavior changes:
- modelComponentsMatch's len(short) >= len(long) guard is correct:
identical ids match via reviewRunModelMatches's want==got short-circuit
before this helper runs, and distinct equal-length ids are different
models. Documented; added identical/equal-length/thinking-suffix tests.
- run.go's defer cancelAgent() runs at function return (after Wait), and
deferring at WithTimeout (not after Start) avoids leaking the timer on
the Start-error path. Documented.

Added TestRun_ParentCancelIsNotTimeout (parent cancel -> Cancelled, not
timed out).

## Sessions

0b9319108cb5View transcript

## Changes

5

- cmd/entire/cli/review

- Mmanifest.go+10/-4

- Mmanifest_test.go+6

- Mrun.go+9/-4

- Mrun_multi.go+6/-4

- Mrun_test.go+33

```go
446 unmodified lines

// modelComponentsMatch reports whether the shorter component list `short`
// identifies the same model as the longer `long`: `short` must appear as a
// contiguous run of whole components in `long`, and the component immediately
// after that run must be purely numeric (a version or date). Requiring a
// numeric boundary is what lets "sonnet"/"claude-sonnet" match
// identifies the same model as the strictly longer `long`: `short` must appear
// as a contiguous run of whole components in `long`, and the component
// immediately after that run must be purely numeric (a version or date).
// Requiring a numeric boundary is what lets "sonnet"/"claude-sonnet" match
// "claude-sonnet-4-5" while rejecting variant suffixes like "gpt-4o-mini" and
// bare version fragments like "4-5".
//
// Equal-length cases are intentionally rejected here (`len(short) >= len(long)`):
// identical ids already returned true via reviewRunModelMatches's `want == got`
// short-circuit before this helper runs, and two *distinct* equal-length ids
// (e.g. "claude-sonnet" vs "claude-opus") are different models that must not
// match. Legitimate matches are always strict subsets, so `short` is shorter.
func modelComponentsMatch(short, long []string) bool {
	if len(short) == 0 || len(short) >= len(long) {
		return false
	}
}
```

Mcmd/entire/cli/review/manifest.go+10/-4

```go
772 unmodified lines

{"version fragment does not match", "4-5", "claude-sonnet-4-5", false},
	{"different families do not match", "gpt-4o-mini", "claude-sonnet-4-5", false},
	{"opus does not match sonnet", "opus", "claude-sonnet-4-5", false},
	// Identical ids match regardless of component count (via the want==got
	// short-circuit), but two distinct equal-length ids must not.
	{"identical multi-component ids match", "claude-sonnet-4-5", "claude-sonnet-4-5", true},
	{"thinking-suffix-only difference matches", "claude-sonnet:high", "claude-sonnet:low", true},
	{"equal-length different family does not match", "claude-sonnet", "claude-opus", false},
	{"equal-length different version does not match", "claude-sonnet-4", "claude-sonnet-5", false},
}
for _, c := range cases {
	t.Run(c.name, func(t *testing.T) {
```

Mcmd/entire/cli/review/manifest_test.go+6

```go
73 unmodified lines

// Bound the inspector so a stuck agent can't hang the review forever. The
	// deadline applies only to this agent; cancellation kills its process.
	// defer runs at function return (after proc.Wait below), so agentCtx stays
	// live for the whole run; deferring here — not after Start — also releases
	// the timer on the Start-error path instead of leaking it.
	timeout := inspectorTimeout(cfg)
	agentCtx, cancelAgent := context.WithTimeout(ctx, timeout)
	defer cancelAgent()
	53 unmodified lines
	waitErr := proc.Wait()
	finished := time.Now()
	// A per-inspector timeout fired iff this agent's deadline elapsed while the
	// parent run context is still live (a parent cancellation is a user Ctrl+C,
	// classified as Cancelled instead).
timedOut := agentCtx.Err() == context.DeadlineExceeded && ctx.Err() == nil
	// agentCtx.Err() is immutable once set, so DeadlineExceeded means this
	// inspector's own deadline fired first; a parent cancellation (user Ctrl+C)
	// propagates as Canceled instead. Reading only agentCtx is therefore
	// race-free — no need to also sample the parent ctx, which could change
	// between the two reads and misclassify a real timeout.
timedOut := agentCtx.Err() == context.DeadlineExceeded
	if shouldEmitSyntheticRunError(ctx, waitErr) {
		synthEvent := reviewtypes.RunError{Err: waitErr}
		buffer = append(buffer, synthEvent)
	}
```

Mcmd/entire/cli/review/run.go+9/-4

```go
155 unmodified lines

finishedAt := time.Now()
	states[idx].waitErr = waitErr
	states[idx].finishedAt = finishedAt
	// A per-inspector timeout fired iff this agent's deadline elapsed
	// while the parent run context is still live (a parent cancellation
	// is a user Ctrl+C, classified as Cancelled).
states[idx].timedOut = ac.Err() == context.DeadlineExceeded && ctx.Err() == nil
	// ac.Err() is immutable once set, so DeadlineExceeded means THIS
	// agent's deadline fired first; a parent cancellation (user Ctrl+C)
	// propagates as Canceled instead. Reading only ac is race-free —
	// also sampling the parent ctx could change between the two reads and
	// misclassify a real timeout as a cancellation.
states[idx].timedOut = ac.Err() == context.DeadlineExceeded
	if shouldEmitSyntheticRunError(ctx, waitErr) {
		fanIn <- taggedEvent{agentIdx: idx, ev: reviewtypes.RunError{Err: waitErr}}
	}
```

Mcmd/entire/cli/review/run_multi.go+6/-4

```go
590 unmodified lines

t.Errorf("fast status = %v, want Succeeded", byName["fast"].Status)
	}
}

// TestRun_ParentCancelIsNotTimeout pins the timeout-vs-cancel distinction: when
// the parent context is cancelled (user Ctrl+C) before an inspector's deadline
// can fire, the inspector is classified Cancelled, not failed-by-timeout. The
// detection reads only the agent context, whose Err() is immutable once set.
func TestRun_ParentCancelIsNotTimeout(t *testing.T) {
	t.Parallel()
	ctx, cancel := context.WithCancel(context.Background())
	cancel() // cancel before the (1h) inspector deadline can elapse
	rec := &stubSinkRecorder{}
	summary, err := Run(
		ctx,
		&ctxReviewer{name: "claude-code"},
		reviewtypes.RunConfig{InspectorTimeout: time.Hour},
		[]reviewtypes.Sink{rec},
	)
	if err != nil && strings.Contains(err.Error(), "timed out") {
		t.Errorf("returned err = %v, must not be a timeout for a parent cancel", err)
	}
	if !summary.Cancelled {
		t.Error("expected Cancelled=true for a parent-cancelled run")
	}
	if len(summary.AgentRuns) != 1 {
		t.Fatalf("expected 1 AgentRun, got %d", len(summary.AgentRuns))
	}
	run := summary.AgentRuns[0]
	if run.Status != reviewtypes.AgentStatusCancelled {
		t.Errorf("status = %v, want Cancelled", run.Status)
	}
	if run.Err != nil && strings.Contains(run.Err.Error(), "timed out") {
		t.Errorf("err = %v, must not be a timeout for a parent cancel", run.Err)
	}
}
```

Mcmd/entire/cli/review/run_test.go+33
