feat(coding-agents): add maxParallelRetains config and 429-aware drain - #3390
feat(coding-agents): add maxParallelRetains config and 429-aware drain#3390seppaleinen wants to merge 2 commits into
Conversation
…park a drain Review follow-up on the retry path. `Retry-After` was honoured without a ceiling, so an hour-long value — an incident, a misconfigured limiter, a proxy inventing one — would park the drain for that hour, up to the whole `maxMs`, with the background seed frozen behind it. The header is a server's hint, not a budget we owe it. Capped at 60s. That keeps the signal (the floor and the header still lengthen the wait) without handing over the schedule: if the limit still applies, the next poll gets another 429 and backs off again. Also re-syncs the docs page and skill mirror from the README, which the rebase onto main left stale by a column width.
f7f1233 to
e6e06f5
Compare
|
Rebased onto current main and pushed one fix to the retry path. Not merging — leaving it for your re-review as asked. RebaseIt conflicted with #3415, which removed The diagnosis is the strong part
Right conclusion from the right evidence, and it identifies a genuinely bad loop: The implementation matches: raw What I pushed: a ceiling on the backoff
The gap I did NOT fix, because it needs a decisionThis makes polling 429-aware and leaves submission 429-blind. If Cloud is rate-limiting bursts, the retain POST is at least as exposed as the poll, so this is arguably the more important half. I did not implement it because a naive retry is worse than the current behaviour: hook processes run under host timeouts (30s for prompt hooks, 60s for Stop), and Happy to take that as a follow-up if you want it scoped that way. |
|
Sounds great, and I think you made the right call! |
|
Folded into #3423, with your commit carried over as-is — same authorship, same diagnosis, plus the drain-backoff ceiling from the review. It also picks up the other half your investigation pointed at: Thanks for the analysis. "A single GET returns 200 while the burst gets 429s, so the limiter is concurrency-triggered" is the sentence the whole change is built on, and it saved a lot of guessing. |
Problem
The coding-agents integration floods the Hindsight API with concurrent retain-related requests and receives HTTP 429s:
drain()—HindsightClient.drain()polls every pending operation withPromise.all([...pending].map(fetch)). When a session enqueues many async retains, every op is polled at once, every 5s cycle. No concurrency cap.if (!r.ok) return;), leaving the op pending and re-polling the full set again 5s later. The client hammers the API harder while it is rate-limiting.CONCURRENCY = 4that could not be tuned, and the drain it waits on was uncapped.Root-cause evidence: a single
GET /operations/<id>returns 200, so the rate limiter is burst/concurrency-triggered, not volume-triggered.drain()issues N concurrent GETs (N = pending ops) every cycle with no cap — exactly the burst pattern that trips it. There is noRetry-Afterhandling, so the client re-bursts 5s later instead of backing off.Change Summary
maxParallelRetainsoption (number, default10) — the cap on concurrent retain-related requests (drain op polls + deepen retain pools). A single request returning 200 while bursts get 429s means the server is rate-limiting concurrency, so this is the knob to turn down.maxParallelRetainsin~/.hindsight/coding-agent.jsonHINDSIGHT_MAX_PARALLEL_RETAINSENV_KEYSandENV_NUMBERSinsrc/core/config.ts; resolved viaresolveConfig().drain()(src/core/hindsight.ts): rewired the per-cycle poll fromPromise.allover every pending op to the existing boundedpool(items, n, fn)helper, capped atmaxParallelRetains. Added 429 handling:Retry-Afterheader (parsed as delta-seconds or HTTP-date), with a 10s floor when the header is absent or shorter.maxMsbound are preserved.retryAfterMs()helper parses the header; newDEFAULT_MAX_PARALLEL_RETAINSconstant.ClientOptsacceptsmaxParallelRetains(default 10); threaded from config at every construction site —deepen.ts,plugin-entry.ts(opencode/kilo),cline.ts,mcp-server.ts,status.ts, and thehook.ts/retain-hook.ts/session-start.tsmakeClientseams.src/deepen.ts): removed hardcodedCONCURRENCY = 4; chat ingestion and the git-diff retain pool now use the configuredcfg.maxParallelRetains(pool semantics unchanged).maxParallelRetains/HINDSIGHT_MAX_PARALLEL_RETAINSto the package README configuration reference.Testing Done
src/core/hindsight.test.ts, 11 tests):global.fetch, track in-flight count; 5 ids against a 2-wide pool hits exactly the cap).Retry-After: 30→ backs off 30s (not the 10s floor) before the next cycle.Retry-After: 2→ uses the 10s floor.Retry-After→ 10s floor.retryAfterMsparses delta-seconds / HTTP-date / garbage.src/core/config.test.ts, 4 tests): default 10, file override, env number parse, malformed env ignored.npm test(vitest): 448 passed, 1 failed — the single failure (knowledge-tools.test.tshindsight_diagnose) is pre-existing and environment-dependent: it assertsapi_token_configured: falsebut the test shell exportsHINDSIGHT_API_TOKEN, soloadConfigsees a token. Same failure on the untouched baseline (433 passed before this change).npm run build(tsup): success (ESM bundles + DTS).dist/is gitignored and not committed.npx tsc --noEmit: clean.Notes
HindsightClientclass is defined inhindsight-integrations/coding-agents/src/core/hindsight.tsitself (not imported from@vectorize-io/hindsight-all), so all changes are contained in the coding-agents package.node_modulessymlink tracked by the repo is untouched.