Skip to content

Commit a4c1593

Browse files
authored
fix(coding-agents): bound the 429 retry by the caller's clock, not a constant (#3425)
The retry budget was a flat 6s, so a Retry-After longer than that was never honoured. That was defensible mid-session — the next write-back replaces the whole document — but wrong on a session's LAST Stop, where there is no next one, and wrong for the persistent-plugin harnesses, which have no host timer at all and could easily have waited. The budget is now a deadline the caller supplies, because only the caller knows its clock: - Hook harnesses pass the host's own kill timeout, which is per-harness and not a constant: 60s for Claude Code, Codex, Copilot, Devin and Grok, but 30s for Cursor and Antigravity. RetainHookSpec carries `hostTimeoutSec` — the same number the installer writes into the hook registration — and the deadline is process start plus that, less a 2s margin for the response to come back. - The persistent-plugin runtime has no external timer, so it takes the default 60s window and can ride out a rate limit a hook could not. A retry now also has to leave room for the request itself (`req` aborts at 15s): starting a wait that cannot finish before the deadline buys nothing. The rule that a Retry-After is honoured in full or not at all is unchanged — waiting less than the server asked just earns another 429.
1 parent 229eefb commit a4c1593

5 files changed

Lines changed: 98 additions & 25 deletions

File tree

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

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -367,16 +367,53 @@ describe("retainLiveSession — incremental write-back", () => {
367367
expect(retain).toHaveBeenCalledTimes(1);
368368
});
369369

370+
it("honours a long Retry-After when the caller's clock has room for it", async () => {
371+
// A persistent-plugin runtime is not on a host's kill timer, so a 20s rate limit is worth
372+
// waiting out rather than deferring — the fixed 6s budget this replaced could not express that.
373+
vi.useFakeTimers();
374+
try {
375+
const { retain, client } = stubClient();
376+
retain.mockRejectedValueOnce(new RateLimitedError(20_000));
377+
const done = retainLiveSession(client, "s1", turns(3), "2026-01-01T00:00:00Z", "opencode", {
378+
cursors: memoryCursorStore(),
379+
retryUntil: Date.now() + 120_000,
380+
});
381+
await vi.advanceTimersByTimeAsync(20_000);
382+
await done;
383+
expect(retain).toHaveBeenCalledTimes(2);
384+
} finally {
385+
vi.useRealTimers();
386+
}
387+
});
388+
389+
it("does not start a wait its caller's clock cannot finish", async () => {
390+
// Same 20s rate limit, but a hook with ~10s of host timeout left: waiting would be killed
391+
// mid-write. Defer instead — the next write-back replaces the whole document.
392+
const { retain, client } = stubClient();
393+
retain.mockRejectedValue(new RateLimitedError(20_000));
394+
395+
await expect(
396+
retainLiveSession(client, "s1", turns(3), "2026-01-01T00:00:00Z", "codex", {
397+
cursors: memoryCursorStore(),
398+
retryUntil: Date.now() + 10_000,
399+
})
400+
).rejects.toBeInstanceOf(RateLimitedError);
401+
expect(retain).toHaveBeenCalledTimes(1);
402+
});
403+
370404
it("does not retry when Retry-After exceeds what a hook can wait", async () => {
371405
// Waiting less than the server asked would just earn another 429, and waiting the full 60s
372406
// risks the harness killing the hook mid-write. Leave it to the next write-back, which
373407
// replaces the whole document.
374408
const { retain, client } = stubClient();
375409
retain.mockRejectedValue(new RateLimitedError(60_000));
376410

377-
await expect(write(client, turns(3), memoryCursorStore())).rejects.toBeInstanceOf(
378-
RateLimitedError
379-
);
411+
await expect(
412+
retainLiveSession(client, "s1", turns(3), "2026-01-01T00:00:00Z", "codex", {
413+
cursors: memoryCursorStore(),
414+
retryUntil: Date.now() + 20_000,
415+
})
416+
).rejects.toBeInstanceOf(RateLimitedError);
380417
expect(retain).toHaveBeenCalledTimes(1);
381418
});
382419

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

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -109,17 +109,15 @@ async function supportsAppend(client: HindsightClient): Promise<boolean> {
109109
}
110110
}
111111

112-
/**
113-
* Attempts a rate-limited write-back may make, and the most total time it may spend waiting.
114-
*
115-
* A hook process is on the host's clock — Claude Code allows 60s for a Stop hook, and a cold
116-
* daemon can already have eaten most of it — so honouring a long `Retry-After` here would trade a
117-
* deferred retain for a killed hook, which is strictly worse. Short limits mean a brief rate limit
118-
* is ridden out and a serious one is left to the next write-back, which replaces the whole
119-
* document anyway.
120-
*/
112+
/** Retry window for a caller that did not supply one — a long-lived host with no external clock. */
113+
const DEFAULT_RETRY_WINDOW_MS = 60_000;
114+
115+
/** Attempts a rate-limited write-back may make before giving up. */
121116
const RETAIN_RETRY_ATTEMPTS = 2;
122-
const RETAIN_RETRY_BUDGET_MS = 6000;
117+
118+
/** What a retry must still leave room for: `req` aborts a request at 15s. Waiting past the point
119+
* where the retry itself could not finish buys nothing. */
120+
const REQUEST_BUDGET_MS = 15_000;
123121

124122
/**
125123
* Submit a write-back, retrying while the API is rate-limiting us AND our write is still the
@@ -136,20 +134,20 @@ const RETAIN_RETRY_BUDGET_MS = 6000;
136134
*/
137135
async function submitWithRetry(
138136
send: () => Promise<void>,
139-
isStillNewest: () => boolean
137+
isStillNewest: () => boolean,
138+
retryUntil: number
140139
): Promise<void> {
141-
let spent = 0;
142140
for (let attempt = 0; ; attempt++) {
143141
try {
144142
return await send();
145143
} catch (e) {
146144
const limited = e instanceof RateLimitedError;
147145
if (!limited || attempt >= RETAIN_RETRY_ATTEMPTS || !isStillNewest()) throw e;
148-
// Either honour Retry-After or do not retry: waiting less than the server asked would just
149-
// earn another 429, so a wait that does not fit the budget means "not on this hook's clock".
146+
// Either honour Retry-After in full or do not retry at all: waiting less than the server
147+
// asked would just earn another 429. Whether it fits is the CALLER's clock, not a constant —
148+
// a hook process is killed by its host at a known deadline, a long-lived runtime is not.
150149
const wait = e.retryAfterMs || 1000;
151-
if (wait > RETAIN_RETRY_BUDGET_MS - spent) throw e;
152-
spent += wait;
150+
if (Date.now() + wait + REQUEST_BUDGET_MS > retryUntil) throw e;
153151
await sleep(wait);
154152
}
155153
}
@@ -209,13 +207,23 @@ export async function retainLiveSession(
209207
turns: TransportTurn[],
210208
startTs: string,
211209
harness?: string,
212-
opts: { cursors?: RetainCursorStore; stamp?: RetainStamp } = {}
210+
opts: { cursors?: RetainCursorStore; stamp?: RetainStamp; retryUntil?: number } = {}
213211
): Promise<void> {
214212
const cursors = opts.cursors;
215-
if (!cursors) return writeSession(client, sessionId, turns, startTs, harness, opts.stamp);
213+
if (!cursors)
214+
return writeSession(
215+
client,
216+
sessionId,
217+
turns,
218+
startTs,
219+
harness,
220+
opts.stamp,
221+
undefined,
222+
opts.retryUntil
223+
);
216224
// Serialised so the plan is made against the previous write-back's CONFIRMED cursor (see above).
217225
return serialize(cursors, sessionId, () =>
218-
writeSession(client, sessionId, turns, startTs, harness, opts.stamp, cursors)
226+
writeSession(client, sessionId, turns, startTs, harness, opts.stamp, cursors, opts.retryUntil)
219227
);
220228
}
221229

@@ -226,7 +234,9 @@ async function writeSession(
226234
startTs: string,
227235
harness?: string,
228236
stamp?: RetainStamp,
229-
cursors?: RetainCursorStore
237+
cursors?: RetainCursorStore,
238+
/** Absolute time this write-back may keep retrying until; the caller owns its own clock. */
239+
retryUntil = Date.now() + DEFAULT_RETRY_WINDOW_MS
230240
): Promise<void> {
231241
const refId = `conversation:${sessionId}`;
232242
const appendSupported = Boolean(cursors) && (await supportsAppend(client));
@@ -287,7 +297,8 @@ async function writeSession(
287297
if (!cursors) return true;
288298
const now = cursors.read(sessionId);
289299
return now?.turns === claimed.turns && now?.fingerprint === claimed.fingerprint;
290-
}
300+
},
301+
retryUntil
291302
);
292303
cursors?.write(sessionId, next);
293304
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@ describe("runRetainHook anti-recursion guard", () => {
127127
// No stdin is provided/mocked here — if the guard didn't return before `readFileSync(0, ...)`,
128128
// this call would attempt to read the real process stdin. Resolving without calling makeClient
129129
// proves the guard fired first.
130-
await runRetainHook({ harness: "claude-code", parse: () => ({}) }, makeClient);
130+
await runRetainHook(
131+
{ harness: "claude-code", hostTimeoutSec: 60, parse: () => ({}) },
132+
makeClient
133+
);
131134
expect(makeClient).not.toHaveBeenCalled();
132135
});
133136
});

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ import type { RetainCursorStore } from "./retain-cursor";
2424
import { buildRetainStamp, type RetainStamp } from "./retain-stamp";
2525
import { fileCursorStore } from "./session-cache";
2626
import { readClaudeTranscript } from "./transcript";
27+
28+
/** Headroom left before the host's kill: the response still has to come back after the last wait. */
29+
const HOST_DEADLINE_MARGIN_MS = 2000;
2730
import type { TransportTurn } from "./chat";
2831

2932
export interface RetainHookEventFields {
@@ -39,6 +42,11 @@ export type TranscriptReader = (path: string) => TransportTurn[];
3942
export interface RetainHookSpec {
4043
/** Harness name — config `harnesses.<name>` section, {harness} template field, diag records. */
4144
harness: string;
45+
/** Seconds the HOST allows this hook before killing it — the same number the installer writes
46+
* into its hook registration. It varies (60s for Claude Code and Codex, 30s for Cursor and
47+
* Antigravity), and it is the only honest basis for deciding how long a rate-limited write-back
48+
* may wait: past it the process is killed mid-write, which is worse than deferring. */
49+
hostTimeoutSec: number;
4250
/** Read the fields out of the harness's stdin event (shapes differ per harness). */
4351
parse(event: Record<string, unknown>): RetainHookEventFields;
4452
/** Harness-specific transcript parser. Defaults to the Claude JSONL reader. */
@@ -68,6 +76,8 @@ export async function buildRetain(args: {
6876
stamp?: RetainStamp;
6977
/** Injectable for tests; defaults to the per-session temp file (a Stop hook has no memory). */
7078
cursors?: RetainCursorStore;
79+
/** Absolute time the host will kill this process; bounds any rate-limit retry. */
80+
retryUntil?: number;
7181
}): Promise<void> {
7282
const { harness, sessionId, transcriptPath, client } = args;
7383
const readTranscript = args.readTranscript ?? readClaudeTranscript;
@@ -81,6 +91,7 @@ export async function buildRetain(args: {
8191
await retainLiveSession(client as HindsightClient, sessionId, turns, startTs, harness, {
8292
cursors: args.cursors ?? fileCursorStore(harness),
8393
stamp: args.stamp,
94+
retryUntil: args.retryUntil,
8495
});
8596
diag(harness, "retain_ok", { ms: Date.now() - t0, turns: turns.length, session: sessionId });
8697
} catch (e) {
@@ -103,6 +114,9 @@ export async function runRetainHook(
103114
// Anti-recursion: the codebase survey's own headless claude session (core/survey.ts) sets this
104115
// so its hooks are a no-op — it must not retain its own survey session's transcript.
105116
if (process.env.HINDSIGHT_DISABLE_HOOKS) return;
117+
// The host started counting when it spawned us, which is near enough to now: everything above
118+
// is synchronous. A margin keeps the kill from landing between our last wait and its response.
119+
const hostDeadline = Date.now() + spec.hostTimeoutSec * 1000 - HOST_DEADLINE_MARGIN_MS;
106120

107121
let ev: Record<string, unknown> = {};
108122
try {
@@ -142,6 +156,7 @@ export async function runRetainHook(
142156
transcriptPath,
143157
client,
144158
readTranscript: spec.readTranscript,
159+
retryUntil: hostDeadline,
145160
stamp: buildRetainStamp(cfg, {
146161
directory: cwd,
147162
harness: spec.harness,

hindsight-integrations/coding-agents/src/harness/hook-lifecycle.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export const HOOK_HARNESSES: Record<HookHarnessName, HookHarnessSpec> = {
150150
sessionStart: standardSessionStart("claude-code"),
151151
prompt: claudePrompt,
152152
retain: {
153+
hostTimeoutSec: 60,
153154
harness: "claude-code",
154155
parse: (ev) => ({
155156
sessionId: ev.session_id as string | undefined,
@@ -168,6 +169,7 @@ export const HOOK_HARNESSES: Record<HookHarnessName, HookHarnessSpec> = {
168169
sessionStart: standardSessionStart("codex"),
169170
prompt: codexPrompt,
170171
retain: {
172+
hostTimeoutSec: 60,
171173
harness: "codex",
172174
parse: (ev) => ({
173175
sessionId: ev.session_id as string | undefined,
@@ -196,6 +198,7 @@ export const HOOK_HARNESSES: Record<HookHarnessName, HookHarnessSpec> = {
196198
},
197199
prompt: antigravityPrompt,
198200
retain: {
201+
hostTimeoutSec: 30,
199202
harness: "antigravity-cli",
200203
parse: (ev) => ({
201204
sessionId: ev.conversationId as string | undefined,
@@ -225,6 +228,7 @@ export const HOOK_HARNESSES: Record<HookHarnessName, HookHarnessSpec> = {
225228
},
226229
prompt: cursorPrompt,
227230
retain: {
231+
hostTimeoutSec: 30,
228232
harness: "cursor-cli",
229233
parse: (ev) => ({
230234
sessionId:
@@ -259,6 +263,7 @@ export const HOOK_HARNESSES: Record<HookHarnessName, HookHarnessSpec> = {
259263
},
260264
prompt: copilotPrompt,
261265
retain: {
266+
hostTimeoutSec: 60,
262267
harness: "copilot-cli",
263268
parse: (ev) => ({
264269
sessionId: ev.sessionId as string | undefined,
@@ -287,6 +292,7 @@ export const HOOK_HARNESSES: Record<HookHarnessName, HookHarnessSpec> = {
287292
},
288293
prompt: devinPrompt,
289294
retain: {
295+
hostTimeoutSec: 60,
290296
harness: "devin-cli",
291297
parse: (ev) => {
292298
const sessionId = ev.session_id as string | undefined;
@@ -326,6 +332,7 @@ export const HOOK_HARNESSES: Record<HookHarnessName, HookHarnessSpec> = {
326332
}),
327333
},
328334
retain: {
335+
hostTimeoutSec: 60,
329336
harness: "grok-build",
330337
parse: (ev) => ({
331338
sessionId: ev.sessionId as string | undefined,

0 commit comments

Comments
 (0)