Skip to content

Commit 9cc6041

Browse files
committed
fix(cron): retry isolated setup timeouts
1 parent d4f11d3 commit 9cc6041

3 files changed

Lines changed: 56 additions & 1 deletion

File tree

src/cron/retry-hint.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ describe("resolveCronExecutionRetryHint", () => {
3737
});
3838
});
3939

40+
it("classifies cron pre-execution watchdog failures as timeout retries", () => {
41+
for (const message of [
42+
"cron: isolated agent setup timed out before runner start",
43+
"cron: isolated agent run stalled before execution start",
44+
]) {
45+
expect(resolveCronExecutionRetryHint(message, ["timeout"])).toEqual({
46+
retryable: true,
47+
category: "timeout",
48+
});
49+
}
50+
});
51+
4052
it("does not classify bare 5xx-looking numbers as server_error", () => {
4153
for (const message of [
4254
"context limit 512 exceeded",

src/cron/retry-hint.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const TRANSIENT_PATTERNS: Record<CronRetryOn, RegExp> = {
2525
/\b529\b|\boverloaded(?:_error)?\b|high demand|temporar(?:ily|y) overloaded|capacity exceeded/i,
2626
network:
2727
/(network|fetch failed|socket|econnreset|econnrefused|eai_again|enetdown|ehostunreach|ehostdown|enetreset|enetunreach|epipe)/i,
28-
timeout: /(timeout|etimedout)/i,
28+
timeout: /(timeout|timed out|stalled before execution start|etimedout)/i,
2929
server_error: SERVER_ERROR_PATTERN,
3030
};
3131

src/cron/service/timer.regression.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,49 @@ describe("cron service timer regressions", () => {
550550
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(2);
551551
});
552552

553+
it("retries recurring jobs after isolated setup timeouts before the next scheduled slot", async () => {
554+
const store = timerRegressionFixtures.makeStorePath();
555+
const scheduledAt = Date.parse("2026-06-08T13:00:00.000Z");
556+
const everySixHoursMs = 6 * 60 * 60 * 1_000;
557+
558+
const cronJob = createIsolatedRegressionJob({
559+
id: "recurring-setup-timeout-retry",
560+
name: "ShadowTrader Auto Channel Bug Monitor",
561+
scheduledAt,
562+
schedule: { kind: "every", everyMs: everySixHoursMs, anchorMs: scheduledAt },
563+
payload: { kind: "agentTurn", message: "monitor bugs" },
564+
state: { nextRunAtMs: scheduledAt },
565+
});
566+
await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
567+
568+
let now = scheduledAt;
569+
const runIsolatedAgentJob = vi.fn().mockResolvedValueOnce({
570+
status: "error",
571+
error: "cron: isolated agent setup timed out before runner start",
572+
});
573+
const state = createCronServiceState({
574+
cronEnabled: true,
575+
storePath: store.storePath,
576+
log: noopLogger,
577+
nowMs: () => now,
578+
enqueueSystemEvent: vi.fn(),
579+
requestHeartbeat: vi.fn(),
580+
runIsolatedAgentJob,
581+
cronConfig: {
582+
retry: { maxAttempts: 1, backoffMs: [1000], retryOn: ["timeout"] },
583+
},
584+
});
585+
586+
await onTimer(state);
587+
const jobAfterRetry = requireJob(state, "recurring-setup-timeout-retry");
588+
expect(jobAfterRetry.enabled).toBe(true);
589+
expect(jobAfterRetry.state.lastStatus).toBe("error");
590+
expect(jobAfterRetry.state.lastError).toContain("setup timed out before runner start");
591+
expect(jobAfterRetry.state.nextRunAtMs).toBeGreaterThan(scheduledAt);
592+
expect(jobAfterRetry.state.nextRunAtMs).toBeLessThan(scheduledAt + everySixHoursMs);
593+
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
594+
});
595+
553596
it("uses the normal recurring schedule after transient retry attempts are exhausted", async () => {
554597
const store = timerRegressionFixtures.makeStorePath();
555598
const scheduledAt = Date.parse("2026-05-29T02:28:00.000Z");

0 commit comments

Comments
 (0)