Skip to content

Commit 60413c7

Browse files
author
Cadbury
committed
fix(cron): keep runner entry under pre-exec guard
1 parent 57d9452 commit 60413c7

2 files changed

Lines changed: 91 additions & 1 deletion

File tree

src/cron/service/agent-watchdog.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ export function createCronAgentWatchdog(params: {
115115
}
116116
activeExecution = { ...activeExecution, ...info };
117117
const stage = info.phase ? CRON_AGENT_PHASE_WATCHDOG_STAGE[info.phase] : undefined;
118-
if (stage !== undefined || info.firstModelCallStarted) {
118+
const observedProgressAfterRunnerEntry =
119+
info.phase !== undefined && info.phase !== "runner_entered";
120+
if (observedProgressAfterRunnerEntry || info.firstModelCallStarted) {
119121
state = "executing";
120122
clearPreExecutionTimeout();
121123
}

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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2939,6 +2939,94 @@ describe("cron service timer regressions", () => {
29392939
}
29402940
});
29412941

2942+
it("keeps the pre-execution watchdog armed for bare runner entry (#93912)", async () => {
2943+
vi.useFakeTimers();
2944+
try {
2945+
const store = timerRegressionFixtures.makeStorePath();
2946+
const scheduledAt = Date.parse("2026-05-10T09:06:00.000Z");
2947+
const cronJob = createIsolatedRegressionJob({
2948+
id: "isolated-runner-entry-only-93912",
2949+
name: "runner entry only regression",
2950+
scheduledAt,
2951+
schedule: { kind: "at", at: new Date(scheduledAt).toISOString() },
2952+
payload: { kind: "agentTurn", message: "work", timeoutSeconds: 1_200 },
2953+
state: { nextRunAtMs: scheduledAt },
2954+
});
2955+
await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
2956+
2957+
vi.setSystemTime(scheduledAt);
2958+
let now = scheduledAt;
2959+
const started = createDeferred<void>();
2960+
let abortObserved = false;
2961+
let abortReason: unknown;
2962+
const cleanupTimedOutAgentRun = vi.fn(async () => {});
2963+
const onIsolatedAgentSetupTimeout = vi.fn();
2964+
const state = createCronServiceState({
2965+
cronEnabled: true,
2966+
storePath: store.storePath,
2967+
log: noopLogger,
2968+
nowMs: () => now,
2969+
enqueueSystemEvent: vi.fn(),
2970+
requestHeartbeat: vi.fn(),
2971+
cleanupTimedOutAgentRun,
2972+
onIsolatedAgentSetupTimeout,
2973+
runIsolatedAgentJob: vi.fn(
2974+
async ({
2975+
abortSignal,
2976+
onExecutionStarted,
2977+
}: {
2978+
abortSignal?: AbortSignal;
2979+
onExecutionStarted?: (info?: CronAgentExecutionStarted) => void;
2980+
}) => {
2981+
onExecutionStarted?.({
2982+
jobId: "isolated-runner-entry-only-93912",
2983+
agentId: "main",
2984+
sessionId: "cron-run-session",
2985+
sessionKey: "agent:main:cron:isolated-runner-entry-only-93912:run:cron-run-session",
2986+
phase: "runner_entered",
2987+
});
2988+
started.resolve();
2989+
abortSignal?.addEventListener(
2990+
"abort",
2991+
() => {
2992+
abortObserved = true;
2993+
abortReason = abortSignal.reason;
2994+
},
2995+
{ once: true },
2996+
);
2997+
return await new Promise<never>(() => {});
2998+
},
2999+
),
3000+
});
3001+
3002+
const timerPromise = onTimer(state);
3003+
await started.promise;
3004+
await vi.advanceTimersByTimeAsync(60_100);
3005+
now += 60_100;
3006+
await timerPromise;
3007+
3008+
const job = requireJob(state, "isolated-runner-entry-only-93912");
3009+
expect(abortObserved).toBe(true);
3010+
expect(job.state.lastStatus).toBe("error");
3011+
expect(job.state.lastError).toContain("stalled before execution start");
3012+
expect(job.state.lastError).toContain("runner-entered");
3013+
expect(abortReason).toMatchObject({
3014+
name: "TimeoutError",
3015+
message: expect.stringContaining("runner-entered"),
3016+
});
3017+
expect(cleanupTimedOutAgentRun).toHaveBeenCalledTimes(1);
3018+
const cleanupArgs = requireRecord(firstMockArg(cleanupTimedOutAgentRun));
3019+
expect(requireRecord(cleanupArgs.job).id).toBe("isolated-runner-entry-only-93912");
3020+
expect(cleanupArgs.timeoutMs).toBe(1_200_000);
3021+
const execution = requireRecord(cleanupArgs.execution);
3022+
expect(execution.jobId).toBe("isolated-runner-entry-only-93912");
3023+
expect(execution.phase).toBe("runner_entered");
3024+
expect(onIsolatedAgentSetupTimeout).not.toHaveBeenCalled();
3025+
} finally {
3026+
vi.useRealTimers();
3027+
}
3028+
});
3029+
29423030
it("keeps isolated runs alive when setup phases keep progressing (#93530)", async () => {
29433031
vi.useFakeTimers();
29443032
try {

0 commit comments

Comments
 (0)