fix: add bounded jittered backoff to StartableToolSet retry path - #4062
fix: add bounded jittered backoff to StartableToolSet retry path#4062aheritier wants to merge 1 commit into
Conversation
aheritier
left a comment
There was a problem hiding this comment.
Reviewed at head 5974d12. CI is green (11 checks pass, rest skipped; no legacy statuses). I also re-ran locally on the PR head: go build ./..., task lint (0 issues), go test ./pkg/tools/..., and go test -race -count=1 ./pkg/tools/ ./pkg/agent/... ./pkg/runtime/... — all clean. Linked context (#4060 + its remediation comment) is fully accessible; there are no Jira references, so no external context is missing.
What's good: the gate is wall-clock only — no timers, no goroutines, no sleeping under the lifecycle lock — state lives under the existing mu, lifecycleMutex/unlock/stop-reaping are untouched, the partial-start latch is preserved, context cancellation is excluded, the doubling loop is overflow-safe, and the failure-streak semantics are correctly left alone (the gated early-return happens before startStreak.fail(), so warnings aren't re-queued). That's the right shape for this seam.
However, I don't think this delivers PR1's contract yet. Findings below are evidence-backed; I reproduced the error chains with a throwaway test that replays RAG's exact wrapping (classifyModelCallError → vector_store → Manager.Initialize → rag.ToolSet.Start).
[blocking] The fix is not generic — only model-provider errors are ever paced
startBackoffRetryable hard-requires *modelerrors.StatusError. That type is produced in exactly five places, all model-provider adapters:
pkg/model/provider/{gemini,bedrock,oaistream,anthropic}/wrap.go → modelerrors.WrapHTTPError(...)
grep -rln modelerrors pkg/tools/ matches only the two new files in this PR. So MCP, LSP and A2A start failures — including a real 503 — never arm the gate. Confirmed: an MCP-shaped 503 Service Unavailable error does not arm it.
This contradicts the requirement in #4060's remediation comment ("Ensure the implementation is generic and reusable by RAG, MCP, LSP, and other startable tool sets") and the plan's explicit rejection of "changing only RAG (would leave the same generic amplification in MCP/LSP/A2A)". The code is generic in location but RAG-only in effect, which is the outcome PR1 was scoped to avoid.
[blocking] The issue's headline trigger — indexing exceeding the start budget — is unpaced
#4060's mechanism (1)+(2) is a start aborted by the 30s budget. That returns context.DeadlineExceeded, which startBackoffRetryable excludes up front, so the gate never arms for the timeout-driven phase — the very phase that generates the 429s in the causal chain.
Worse, the exclusion short-circuits before StatusError extraction, so a 429 that races the deadline also disarms the gate:
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false // wins even when a 429 StatusError is also in the chain
}Verified: a chain carrying both a 429 StatusError and DeadlineExceeded does not arm the gate. This is reachable in production — Manager.Initialize races case <-ctx.Done(): return ctx.Err() against its results channel, so under load the deadline can mask the rate-limit signal precisely when pacing matters most. Suggest narrowing the exclusion to caller cancellation and still honouring a StatusError found elsewhere in the chain.
[blocking] Backoff bounds are ~10–20× weaker than the authorized PR1 defaults, with no stated rationale
The plan authorizes 15s initial, ×2, 5min base cap, additive-only 0–20% jitter (effective max 6min). This PR ships base 1s, cap 30s, equal jitter [nominal/2, nominal]. Measured sequence:
attempt 1 → ~0.9s attempt 4 → ~5.0s
attempt 2 → ~1.6s attempt 5 → ~13.0s
attempt 3 → ~3.5s attempt 6 → ~28.5s (cap; ~24–52s cumulative)
Steady state is therefore one full concurrent indexing burst every 15–30s, indefinitely while the KB stays unindexed — each burst being max_indexing_concurrency × max_embedding_concurrency concurrent LLM calls. Against an incident that saturated a shared gateway and failed the agent's own conversational turns, a 30s ceiling still permits substantial sustained load; the authorized 5min cap yields one burst per 5–6 min. The first retry at ~0.5–1s is effectively no pacing at all.
If these bounds were changed deliberately, please state the evidence and record the deviation — right now neither the PR body nor the commit message mentions that the authorized defaults were changed.
[blocking] Gating inside startLocked was an explicitly rejected alternative, and it does gate explicit starts
The plan enforces the schedule only in the non-blocking TryStart/TryStartWithTimeout, and rejects gating startLocked because it "would delay explicit starts and break mcpcatalog enable". The gate is in startLocked, so blocking Start is gated too — and blocking Start is what user/model-driven paths use:
pkg/tools/builtin/mcpcatalog/mcpcatalog.go:168—startToolsetdefaults tots.Start(ctx), reached fromhandleEnable(:765), whose whole contract is a same-turn deterministic answer.pkg/runtime/loop.goskill sub-session startup —startable.Start(ctx).
This is currently latent only because the classifier is so narrow that MCP errors never arm the gate — i.e. two defects are masking each other. The moment the classifier is broadened (Follow-up A, or fixing the finding above), explicit enable silently starts returning a stale cached error without attempting a connect. Please move enforcement to the TryStart paths as specified.
[should-fix] Stale window survives a successful /toolset-restart
LocalRuntime.RestartToolset dispatches to the inner Restartable (pkg/runtime/runtime.go:1156-1157); the wrapper never sees it. After a total failure the wrapper has started == false, so startLocked skips the if s.started { ... reporter.IsStarted() ... } recovery block entirely and hits the gate, returning the retained error without ever consulting the now-live StartReporter. Tools stay unlisted for up to 30s after a successful manual recovery. The plan requires this explicitly: "a later TryStart observation that a latched StartReporter is live clears failure count/schedule … makes direct /toolset-restart recovery immediately authoritative rather than leaving a stale window." Checking a live reporter before the gate would close it.
[should-fix] No test seam for PR2's mandated cross-package tests
ExportedSetJitter lives in export_test.go, so it is visible only to package tools' own tests — and there is no clock seam at all. PR2 requires deterministic RAG/MCP/LSP regression tests in other packages driving a "fake retry clock"; those packages can neither control jitter nor advance policy time. The plan specified exported constructor options (WithStartRetryClock, WithStartRetryJitter via NewStartable(ts, opts...)) precisely for this. Adding them now avoids reworking this file in PR2.
[should-fix] A test's doc comment claims coverage it doesn't have
TestStartableToolSet_BackoffNoDoubleStartWithinWindow states it holds "even under concurrent callers", but the test is strictly sequential — four in-order s.Start calls, no goroutines. Please either make it concurrent or drop the claim. Relatedly, PR1's gate list requires a concurrent exact-boundary test proving many simultaneous TryStart calls invoke the inner Start exactly once, plus an at-boundary test; neither is present.
[optional] Stale test doc name
The comment block above TestStartableToolSet_BackoffDormantForPlainErrors opens with TestStartableToolSet_RetryableBackoffWithExistingFlappyTests — leftover from a rename.
Scope note
PR1 as planned also includes sentinel handling in pkg/agent/agent.go and pkg/runtime/runtime.go plus focused agent_test.go/runtime_test.go partial-composite cases, described as merge gates for the compatibility contract. This PR returns the raw retained error instead of a scheduled-outcome sentinel, so those changes are absent. That's self-consistent, but it means the planned compatibility gates aren't demonstrated. Worth confirming that's an intentional simplification rather than an omission.
Happy to re-review promptly once the classifier reach, the deadline/429 precedence, and the enforcement point are addressed. No schema, config or docs changes are needed for PR1, and I agree with deferring DefaultStartTimeout.
…rt, align bounds Addresses all blocking and should-fix findings from the aheritier review on PR #4062: [blocking #1 + #2] Generic classifier with HTTP-status precedence: - Add modelerrors.RetryableHTTPStatus(err) — catches any error carrying a retryable HTTP status (429/408/5xx) via *StatusError or message regex, without string-pattern heuristics ('connection refused' stays non-retryable). - startBackoffRetryable becomes: return err != nil && RetryableHTTPStatus(err). A StatusError{429} coexisting with context.DeadlineExceeded now arms the gate (HTTP wins), fixing the deadline-masks-rate-limit race. [blocking #3] Bounds aligned with remediation plan: - base = 15s, cap = 5min (was 1s/30s). - Additive jitter [d, 1.2d] (was equal jitter [d/2, d]), guaranteeing the full nominal wait is always respected. [blocking #4] Gate enforced only in the TryStart path: - Move gate check from startLocked into new tryStartLocked (called by TryStart/TryStartWithTimeout only). - Start() calls startLocked directly — mcpcatalog enable and skill sub-session startup are never delayed. [should-fix #5] External recovery via StartReporter: - tryStartLocked checks reporter.IsStarted() when started==false; a live reporter (e.g. after /toolset-restart) clears the gate and latches the wrapper without calling the underlying Start. - New test: TestStartableToolSet_ExternalRecoveryClearsBackoffGate. [should-fix #6] Exported constructor options for cross-package tests: - NewStartable(ts, opts...) with StartableOption, WithStartRetryJitter, WithStartRetryClock. - nowFn() clock seam; zero-value StartableToolSet still usable. [should-fix #7] Concurrent and at-boundary tests: - TestStartableToolSet_BackoffNoDoubleStartWithinWindow: 20 goroutines calling TryStart, assert underlying Start invoked exactly once. - TestStartableToolSet_BackoffAtBoundary: fake clock, gate open at expiry. [optional] Stale comment name in BackoffDormantForPlainErrors fixed. Also: - RetryableHTTPStatus test cases include plain-text regex fallback. - ExportedSetClock removed (unused; WithStartRetryClock preferred). - All gating tests converted from s.Start() to s.TryStart(). - Jitter-bounds assertions updated to [nominal, 1.2×nominal]. PR2 (#4065) will need rebasing and test updates after this lands.
aheritier
left a comment
There was a problem hiding this comment.
Re-reviewed at head 072cc4a (delta 5974d12..072cc4a, +415/−163 across 6 files). Thanks — this is a big step forward: all four blockers and all three should-fixes from my previous review are addressed. CI is green at this head (10 checks success, 4 skipped, no legacy statuses); mergeable=MERGEABLE, mergeStateStatus=BLOCKED only for the missing required approving review. I re-validated locally on the head: go build ./..., golangci-lint run ./pkg/tools/... ./pkg/modelerrors/... (0 issues), and go test -race -count=1 ./pkg/tools/... ./pkg/agent/... ./pkg/runtime/... — all clean. Linked context (#4060 + its remediation comment, unchanged since 08:36) is fully accessible; no Jira references, so no external context is missing.
Prior findings — resolved
- [was blocking] classifier not generic → fixed.
modelerrors.RetryableHTTPStatus(pkg/modelerrors/modelerrors.go:802-808) routes throughextractHTTPStatusCode, so any HTTP-status-bearing start failure arms the gate regardless of Go type. MCP/LSP/A2A/RAG are now paced consistently, with table coverage inTestRetryableHTTPStatus. - [was blocking] deadline short-circuited the 429 signal → fixed. The context pre-check is gone and precedence is now explicit and tested:
429 StatusError + DeadlineExceeded in chain → true, bare context errors →false. (See the residual scope note below.) - [was blocking] backoff bounds 10–20× weaker than authorized → fixed exactly to plan:
startBackoffBase = 15s,startBackoffMax = 5m(pkg/tools/startable_backoff.go:18-19), additive jitter[nominal, 1.2×nominal](:82-86). Sequence 15s/30s/60s/120s/240s/300s, effective max 6min, pinned byTestStartBackoffExponentialGrowthandTestStartBackoffBoundedAndJittered. - [was blocking] gate in
startLockeddelayed explicit starts → fixed. Enforcement moved totryStartLocked(pkg/tools/startable.go:503-522); blockingStartcallsstartLockeddirectly (:390-394), so mcpcatalog enable and skill sub-session startup are never delayed. (One coverage gap, below.) - [was should-fix] stale window after a successful
/toolset-restart→ fixed by the live-reporter adoption intryStartLocked(:509-517), covered byTestStartableToolSet_ExternalRecoveryClearsBackoffGate(startable_backoff_test.go:492). - [was should-fix] no test seam for PR2 → fixed.
NewStartable(ts, opts...)plus exportedWithStartRetryClock/WithStartRetryJitter(:322,:329) give other packages a fake clock and deterministic jitter. - [was should-fix] doc comment claimed concurrency it didn't have → fixed:
TestStartableToolSet_BackoffNoDoubleStartWithinWindow(:231) now really runs 20 goroutines and asserts exactly one innerStart, andTestStartableToolSet_BackoffAtBoundary(:256) adds the 1ns-before / exactly-at boundary contract. - [was optional] stale test doc name → fixed.
[blocking] Partial-start clears the gate, which leaves the storm unpaced for code-mode agents
startLocked resets all backoff state on a PartialStartError (pkg/tools/startable.go:601-604), with the rationale that "the failed subset is retried via the composite's own recovery mechanism, not the cold-start gate". That recovery mechanism is the unpaced per-turn retry:
codemode.Wrap(toolSets...)collapses every toolset into one composite whencode_modeis set on the agent or globally (pkg/teamloader/teamloader.go:859-860), and that composite is what gets wrapped byNewStartable(:932).codeModeTool.Startretries each failed inner on every call and returnsPartialStartErrorwhenever at least one inner is healthy (pkg/tools/codemode/codemode.go:222-259).
So for a code-mode agent whose RAG toolset 429s while other toolsets are fine: turn N arms nothing (partial → reset), turn N+1 re-enters codeModeTool.Start → RAG Manager.Initialize → a fresh full-speed indexing burst. That is exactly #4060, unfixed, in a supported configuration — and it is the one path where the current comment tells a future reader it's handled. TestStartableToolSet_PartialStartClearsBackoffGate (:392) pins the exemption as intended behaviour, so nothing flags it.
The plan's contract avoids this: pace the failed members while preserving the latch — "a latched partial composite remains usable while its failed members are paced" — via the ErrStartRetryScheduled/RetryAt sentinel returned as (s.started, scheduledErr), with the agent/runtime sentinel handling and partial-composite tests listed as PR1 merge gates. Either of these resolutions works for me:
- Arm the gate when a partial cause is retryable, keep
s.started = true, and return the scheduled sentinel sopkg/agent/agent.goandpkg/runtime/runtime.gostill list the healthy subset (plus the two focused partial tests the plan calls merge gates); or - Keep the exemption deliberately, but say so in the comment (naming the code-mode composite as the known unpaced path), note it in the PR body, and open the follow-up issue — rather than leaving a comment that implies coverage.
[should-fix] The regex fallback arms the gate on errors that carry no HTTP status, and the doc comment says it can't
RetryableHTTPStatus's doc claims it "performs NO string-pattern heuristics, so plain network errors such as 'connection refused' or 'no such host' always return false". extractHTTPStatusCode does fall back to statusCodeRegex = \b([45]\d{2})\b over the whole message (pkg/modelerrors/modelerrors.go:289-319), which matches any bare 4xx/5xx-looking number. Reproduced against that exact regex:
match=true Post "http://localhost:503/mcp": dial tcp 127.0.0.1:503: connect: connection refused
match=true rag: indexing aborted: chunk 429 of 812 failed
match=true mcp: server exited: signal: killed (pid 504)
match=false dial tcp 10.0.0.5:8080: i/o timeout
match=false context deadline exceeded after 500ms
The regex itself is pre-existing, but this PR widens its input domain from provider HTTP response bodies to arbitrary toolset start errors (MCP stderr, LSP spawn failures, RAG progress text). A local MCP server on port 503 that is simply down now gets paced 15s → 5min instead of retrying each turn — the opposite of the stated design intent for local process failures. TestRetryableHTTPStatus's "connection refused" case passes only because of the port it happens to use, so it generalises a property that doesn't hold.
Minimum: correct the doc comment and add the false-positive cases to the table so the behaviour is at least honest. Better: require an HTTP-ish context for the fallback (adjacent status text or an HTTP/status token), or accept only *StatusError for the toolset classifier and treat the regex path as model-provider-only.
[should-fix] Nothing pins the blocking-Start bypass — the fix for the last blocker is untested
startable_backoff_test.go never calls s.Start(...) (only s.Stop at :126 and :215); every case drives TryStart. The invariant that blocking Start must skip the gate is exactly what makes mcpcatalog enable and skill sub-session startup deterministic in-turn, and it is now a one-line-refactor away from silent regression (route Start through tryStartLocked and all tests still pass). Please add: arm the gate with a retryable failure, then assert a blocking Start reaches the inner (inner.starts increments) while a concurrent-window TryStart does not.
[should-fix] Live-reporter adoption is unconditional, changing non-gated TryStart semantics
The adoption block in tryStartLocked (pkg/tools/startable.go:509-517) runs whenever !s.started and the inner StartReporter is live — not only when a window is armed. Previously that state fell through to startable.Start(ctx); now the wrapper latches started = true and returns nil without ever invoking the inner Start, for every StartReporter toolset (MCP after a background supervisor reconnect, LSP after a supervisor retry), whether or not backoff is involved. That's a broader behavioural change than the stale-window fix needs. Guarding it with !s.startBackoffUntil.IsZero() keeps the fix scoped to the case it was written for.
[should-fix] Stale "retry on next turn" messages the plan asked to correct
Retries can now be up to ~6 minutes apart, but these still promise next-turn behaviour:
pkg/agent/agent.go:648—"Toolset start failed; will retry on next turn"pkg/agent/agent.go:651—"Toolset still unavailable; retrying next turn"pkg/tools/mcp/mcp.go:99—"MCP command not yet available, will retry on next turn"
Log-only (the TUI warning string is unaffected), but the plan's PR1 step explicitly includes grepping and correcting these plus the contract comments in startable.go / agent.go / runtime.go.
[optional] ExportedSetJitter is now dead code
ExportedSetJitter (pkg/tools/export_test.go) has no remaining callers — every test uses WithStartRetryJitter. Since it also carries the "writes startJitter without mu" caveat, dropping it removes both the dead code and the caveat.
Scope notes (no action required in this PR)
- Bare-deadline starts remain unpaced, and that is plan-conformant — acceptance gate 2 says a timeout alone must not schedule. Worth knowing precisely what that leaves behind:
Manager.Initializereturns barectx.Err()on deadline (pkg/rag/manager.go:227-229), with no HTTP code, so a large corpus that always exceeds the 30s budget still starts one fresh indexing burst per turn. The justification inpkg/tools/startable_backoff.go:36-39("already naturally paced to ~budget intervals while it holds the single-flight lock") doesn't hold here: RAG honours the context and returns at the deadline, releasing the single-flight lock immediately. Please soften that comment and let the deferredDefaultStartTimeoutfollow-up own the gap explicitly. - The
ErrStartRetryScheduled/RetryAtsentinel and the focusedagent_test.go/runtime_test.gopartial-composite cases are still absent. That's self-consistent with returning the raw retained error, and it's benign today (a gated attempt never reachesstartStreak.fail(), soShouldReportFailurestays false and users don't get a warning per turn) — but it is coupled to the blocking finding above: implementing resolution 1 requires the sentinel.
Overall the core 429/5xx pacing path now looks right, and the bounds, gate placement, test seams and boundary/concurrency coverage all match the plan. Filing this as a comment rather than a formal request-for-changes only because I'm the PR author and GitHub won't let me review my own PR; please treat the blocking finding as a merge gate. Happy to re-review promptly once the partial-composite path is either paced or explicitly scoped.
[blocking] partial-start comment corrected; known limitation for code-mode composites documented with reference to follow-up issue #4067 [SF1] startBackoffRetryable requires *StatusError — regex fallback excluded to prevent false positives on port numbers and chunk counters; RetryableHTTPStatus doc corrected to describe the actual regex-fallback behaviour [SF2] TestStartableToolSet_BlockingStartSkipsGate: arm gate, assert blocking Start() invokes underlying (not gated), assert TryStart() is gated [SF3] tryStartLocked: reporter-adoption guarded by !startBackoffUntil.IsZero() — fix is now scoped to gated state; non-gated TryStart semantics unchanged [SF4] stale 'next turn' log messages updated in agent.go and mcp.go [optional] ExportedSetJitter removed (dead code; WithStartRetryJitter preferred)
…rt, align bounds Addresses all blocking and should-fix findings from the aheritier review on PR #4062: [blocking #1 + #2] Generic classifier with HTTP-status precedence: - Add modelerrors.RetryableHTTPStatus(err) — catches any error carrying a retryable HTTP status (429/408/5xx) via *StatusError or message regex, without string-pattern heuristics ('connection refused' stays non-retryable). - startBackoffRetryable becomes: return err != nil && RetryableHTTPStatus(err). A StatusError{429} coexisting with context.DeadlineExceeded now arms the gate (HTTP wins), fixing the deadline-masks-rate-limit race. [blocking #3] Bounds aligned with remediation plan: - base = 15s, cap = 5min (was 1s/30s). - Additive jitter [d, 1.2d] (was equal jitter [d/2, d]), guaranteeing the full nominal wait is always respected. [blocking #4] Gate enforced only in the TryStart path: - Move gate check from startLocked into new tryStartLocked (called by TryStart/TryStartWithTimeout only). - Start() calls startLocked directly — mcpcatalog enable and skill sub-session startup are never delayed. [should-fix #5] External recovery via StartReporter: - tryStartLocked checks reporter.IsStarted() when started==false; a live reporter (e.g. after /toolset-restart) clears the gate and latches the wrapper without calling the underlying Start. - New test: TestStartableToolSet_ExternalRecoveryClearsBackoffGate. [should-fix #6] Exported constructor options for cross-package tests: - NewStartable(ts, opts...) with StartableOption, WithStartRetryJitter, WithStartRetryClock. - nowFn() clock seam; zero-value StartableToolSet still usable. [should-fix #7] Concurrent and at-boundary tests: - TestStartableToolSet_BackoffNoDoubleStartWithinWindow: 20 goroutines calling TryStart, assert underlying Start invoked exactly once. - TestStartableToolSet_BackoffAtBoundary: fake clock, gate open at expiry. [optional] Stale comment name in BackoffDormantForPlainErrors fixed. Also: - RetryableHTTPStatus test cases include plain-text regex fallback. - ExportedSetClock removed (unused; WithStartRetryClock preferred). - All gating tests converted from s.Start() to s.TryStart(). - Jitter-bounds assertions updated to [nominal, 1.2×nominal]. PR2 (#4065) will need rebasing and test updates after this lands.
Regression suite for the backoff gate introduced in PR1 (#4062). Test files: - pkg/tools/startable_backoff_regression_test.go: 9 consumer-shaped regression tests using fakes modelled on real RAG/MCP/LSP error shapes — RAG-shaped failure+recovery, MCP/LSP compatibility (fail-fast, no backoff), HTTP 408 gate, concurrent starts no-multiplication, no timer/goroutine leak, cancellation no-window, jitter de-synchronization, already-started no-restart. - pkg/tools/builtin/rag/rag_backoff_test.go: 2 real-toolset RAG tests using rag.New + countingStatusErrStrategy; proves the real toolset's StatusError wrapping chain is traversable by errors.As and that plain errors fail fast. Docs: - docs/tools/rag/index.md: authoritative 'Indexing failures, retries and backoff' section — retry policy (1s base, 30s cap, equal jitter), what triggers backoff (429, 408, 5xx) vs fail-fast (other 4xx, cancellation), operational impact and troubleshooting guidance. - docs/tools/mcp/index.md: short note under Lifecycle clarifying MCP local startup failures fail fast; links to RAG page for full policy. - docs/tools/lsp/index.md: matching note under Auto-Restart and Lifecycle. No production code changes.
ce13fa7 to
ab320cf
Compare
[blocking] partial-start comment corrected; known limitation for code-mode composites documented with reference to follow-up issue #4067 [SF1] startBackoffRetryable requires *StatusError — regex fallback excluded to prevent false positives on port numbers and chunk counters; RetryableHTTPStatus doc corrected to describe the actual regex-fallback behaviour [SF2] TestStartableToolSet_BlockingStartSkipsGate: arm gate, assert blocking Start() invokes underlying (not gated), assert TryStart() is gated [SF3] tryStartLocked: reporter-adoption guarded by !startBackoffUntil.IsZero() — fix is now scoped to gated state; non-gated TryStart semantics unchanged [SF4] stale 'next turn' log messages updated in agent.go and mcp.go [optional] ExportedSetJitter removed (dead code; WithStartRetryJitter preferred)
Fixes issue #4060: RAG semantic-embeddings indexing triggered a rate-limit retry storm because repeated toolset-start attempts had no pacing — every agentic loop iteration re-entered Manager.Initialize at full speed after a 429 from the embedding provider. Implementation: - Add modelerrors.RetryableHTTPStatus(err): HTTP-status classifier that recognises 429, 408, and 5xx signals via *StatusError (no regex fallback, so port numbers / chunk counters cannot trigger backoff) - Add pkg/tools/startable_backoff.go: bounded exponential backoff with additive 0-20% jitter (base=15s, cap=5min, delay∈[d,1.2d]) that de-synchronises concurrent toolset sources and guarantees the full nominal wait as a floor - Wire gate into tryStartLocked (called only by TryStart/ TryStartWithTimeout): blocking Start() bypasses the gate so mcpcatalog enable and skill sub-session startup remain immediate - Gate also detects live StartReporter after a /toolset-restart and adopts the recovery without waiting for the window to expire - Add WithStartRetryJitter / WithStartRetryClock constructor options (NewStartable variadic) for deterministic test control - Update stale 'retry on next turn' log messages in agent.go / mcp.go - Document the partial-start exemption (code-mode composites remain unpaced; tracked at issue #4067) Scope: DefaultStartTimeout (30s) is unchanged — deferred.
ab320cf to
6310940
Compare
Closes #4060 (PR1)
When a toolset start fails with a retryable HTTP-status error (429 rate-limit or 5xx/overload), the next attempt within a cooldown window returns the retained error without invoking the underlying
Start— stopping the retry storm where a failing RAG toolset was restarted on every agentic loop iteration at full speed.Design: wall-clock gate inside
startLocked— no sleep, no timers, never holds the lifecycle lock during a wait. Bounded exponential backoff with equal jitter (base 1s, cap 30s, [nominal/2, nominal]) de-synchronises concurrent toolsets. Gate clears on success, partial-start, or Stop.Classifier: conservatively restricts to
*modelerrors.StatusErrorwith 5xx/429 codes. Plain string-matched errors are excluded to preserve today's retry-every-turn behaviour for non-structured failures. Context cancellation/deadline are never throttled.Compatibility: all 44 existing tests pass unchanged.
DefaultStartTimeout(30s) deferred to a later PR per the remediation plan.Changed files:
pkg/tools/startable_backoff.go— backoff constants,computeStartBackoff(bounded equal jitter),startBackoffRetryablepkg/tools/startable.go— backoff state onStartableToolSet, gate instartLocked, reset instopLockedpkg/tools/export_test.go— test hooks for deterministic jitterpkg/tools/startable_backoff_test.go— focused tests (429/5xx backoff, window expiry, non-retryable fast-fail, context-cancel no-backoff, stop-clears-backoff, partial-start gate-clear, jitter bounds)