Skip to content

Commit c8200b4

Browse files
committed
fix(coding-agents): cap the 429 backoff so a long Retry-After cannot 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.
1 parent f1563bb commit c8200b4

4 files changed

Lines changed: 37 additions & 6 deletions

File tree

hindsight-docs/docs-integrations/coding-agents.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,8 @@ Environment variables are a **fallback**: the file wins wherever it sets a value
281281
an existing setup changes nothing. `retainTags` takes a comma-separated list
282282
(`HINDSIGHT_RETAIN_TAGS="project:{gitProject},env:work"`); entries are trimmed and blanks dropped.
283283
The map-valued settings (`mapPathToBank`, `harnesses`, `banks`, `retainMetadata`) are file-only —
284-
per-key branching doesn't survive flattening into one variable.
284+
per-key branching doesn't survive flattening into one variable. `maxParallelRetains` is available
285+
as `HINDSIGHT_MAX_PARALLEL_RETAINS` for containers and CI.
285286

286287
There is deliberately no repo-carried config file — per-repo bank routing is `mapPathToBank`,
287288
per-agent differences are `harnesses.<name>`.
@@ -321,6 +322,7 @@ hook by Codex...), so one shared config serves several agents side by side:
321322
| `surveyModel` | `haiku` | model for the survey — Claude recipe only (`claude -p --model`); other agents use their configured default |
322323
| `surveyBudgetUsd` | `2` | survey spend cap — Claude recipe only (`claude -p --max-budget-usd`); other agents rely on their read-only sandbox |
323324
| `retainSessions` | `true` | plugin-harness write-back (opencode, Kilo): async upsert of the session transcript every turn, plus an idle flush that captures the reply the per-turn pass can't see (set `false` to opt out; hook harnesses always write on Stop) |
325+
| `maxParallelRetains` | `10` | cap on concurrent retain-related requests: drain()'s per-op polls plus deepen's chat/git retain pools. The API rate-limits bursts, not single requests — if you see 429s, lower this rather than raising it |
324326
| `logLevel` | `"info"` | plugin-log verbosity (`"debug"` \| `"info"` \| `"warn"` \| `"error"`); `HINDSIGHT_LOG_LEVEL` env overrides |
325327
| `gitIngest` | `"message"` | git depth for seeding AND staying current (same engine): `"message"` = commit messages only (one doc, re-upserted when HEAD moves); `"full"` = messages + per-commit full diffs (progressive, newest first); `"none"` = git off |
326328
| `harnesses.<name>` || per-harness override of any field above |

hindsight-integrations/coding-agents/src/core/hindsight.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,23 @@ describe("HindsightClient.drain", () => {
6464
await p;
6565
});
6666

67+
it("caps the backoff however long Retry-After asks for", async () => {
68+
// The header is a hint, not a budget we owe the server: an hour-long value would otherwise
69+
// park the drain — and the background seed behind it — for that hour.
70+
vi.useFakeTimers();
71+
const client = new HindsightClient({ apiUrl: "http://x", bank: "b" });
72+
const fetchMock = vi.fn(async () => jsonResponse(429, {}, { "Retry-After": "3600" }));
73+
vi.stubGlobal("fetch", fetchMock);
74+
75+
const p = client.drain(["1"], "test", 300_000);
76+
await vi.advanceTimersByTimeAsync(0);
77+
expect(fetchMock).toHaveBeenCalledTimes(1);
78+
await vi.advanceTimersByTimeAsync(60_000); // capped at 60s, not 3600s
79+
expect(fetchMock).toHaveBeenCalledTimes(2);
80+
await vi.advanceTimersByTimeAsync(300_000);
81+
await p;
82+
});
83+
6784
it("uses the 10s floor when Retry-After is shorter than it", async () => {
6885
vi.useFakeTimers();
6986
const client = new HindsightClient({ apiUrl: "http://x", bank: "b" });

hindsight-integrations/coding-agents/src/core/hindsight.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,17 @@ const POLL_CYCLE_MS = 5000;
9595
/** Minimum backoff after a 429 that carried no (or a shorter) Retry-After. */
9696
const RETRY_AFTER_FLOOR_MS = 10 * 1000;
9797

98+
/**
99+
* Ceiling on a single backoff, however long `Retry-After` asks for.
100+
*
101+
* The header is a server's hint, not a budget we owe it: a large value (an incident, a
102+
* misconfigured limiter, a proxy inventing one) would otherwise park a drain for as long as it
103+
* says — up to the whole `maxMs`, with the background seed frozen behind it. Capping keeps the
104+
* signal without handing over the schedule; if the limit still applies, the next poll simply gets
105+
* another 429 and backs off again.
106+
*/
107+
const RETRY_AFTER_CEILING_MS = 60 * 1000;
108+
98109
/** Bank-level missions the template seeds once and then leaves alone (#2492). */
99110
const MISSION_FIELDS = ["reflect_mission", "retain_mission", "observations_mission"] as const;
100111

@@ -323,10 +334,9 @@ export class HindsightClient {
323334
try {
324335
const r = await fetch(this.bankUrl(`/operations/${id}`), { headers: this.headers() });
325336
if (r.status === 429) {
326-
backoffMs = Math.max(
327-
backoffMs,
328-
RETRY_AFTER_FLOOR_MS,
329-
retryAfterMs(r.headers.get("retry-after"))
337+
backoffMs = Math.min(
338+
RETRY_AFTER_CEILING_MS,
339+
Math.max(backoffMs, RETRY_AFTER_FLOOR_MS, retryAfterMs(r.headers.get("retry-after")))
330340
);
331341
return; // op stays pending — retried after the backoff
332342
}

skills/hindsight-docs/references/sdks/integrations/coding-agents.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,8 @@ Environment variables are a **fallback**: the file wins wherever it sets a value
276276
an existing setup changes nothing. `retainTags` takes a comma-separated list
277277
(`HINDSIGHT_RETAIN_TAGS="project:{gitProject},env:work"`); entries are trimmed and blanks dropped.
278278
The map-valued settings (`mapPathToBank`, `harnesses`, `banks`, `retainMetadata`) are file-only —
279-
per-key branching doesn't survive flattening into one variable.
279+
per-key branching doesn't survive flattening into one variable. `maxParallelRetains` is available
280+
as `HINDSIGHT_MAX_PARALLEL_RETAINS` for containers and CI.
280281

281282
There is deliberately no repo-carried config file — per-repo bank routing is `mapPathToBank`,
282283
per-agent differences are `harnesses.<name>`.
@@ -316,6 +317,7 @@ hook by Codex...), so one shared config serves several agents side by side:
316317
| `surveyModel` | `haiku` | model for the survey — Claude recipe only (`claude -p --model`); other agents use their configured default |
317318
| `surveyBudgetUsd` | `2` | survey spend cap — Claude recipe only (`claude -p --max-budget-usd`); other agents rely on their read-only sandbox |
318319
| `retainSessions` | `true` | plugin-harness write-back (opencode, Kilo): async upsert of the session transcript every turn, plus an idle flush that captures the reply the per-turn pass can't see (set `false` to opt out; hook harnesses always write on Stop) |
320+
| `maxParallelRetains` | `10` | cap on concurrent retain-related requests: drain()'s per-op polls plus deepen's chat/git retain pools. The API rate-limits bursts, not single requests — if you see 429s, lower this rather than raising it |
319321
| `logLevel` | `"info"` | plugin-log verbosity (`"debug"` \| `"info"` \| `"warn"` \| `"error"`); `HINDSIGHT_LOG_LEVEL` env overrides |
320322
| `gitIngest` | `"message"` | git depth for seeding AND staying current (same engine): `"message"` = commit messages only (one doc, re-upserted when HEAD moves); `"full"` = messages + per-commit full diffs (progressive, newest first); `"none"` = git off |
321323
| `harnesses.<name>` || per-harness override of any field above |

0 commit comments

Comments
 (0)