Skip to content

Commit 24196e0

Browse files
committed
fix: unwind timeout-disabled cron cancellation
1 parent 93313c9 commit 24196e0

2 files changed

Lines changed: 112 additions & 1 deletion

File tree

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,88 @@ describe("cron service timer regressions", () => {
743743
expect(job?.state.lastStatus).toBe("ok");
744744
});
745745

746+
it("cancels timeout-disabled cron task runs without waiting for the runner", async () => {
747+
vi.useFakeTimers();
748+
try {
749+
resetTaskRegistryForTests();
750+
resetActiveCronTaskRunsForTests();
751+
const store = timerRegressionFixtures.makeStorePath();
752+
const scheduledAt = Date.parse("2026-02-15T13:10:00.000Z");
753+
const cronJob = createIsolatedRegressionJob({
754+
id: "no-timeout-cancel",
755+
name: "no timeout cancel",
756+
scheduledAt,
757+
schedule: { kind: "at", at: new Date(scheduledAt).toISOString() },
758+
payload: { kind: "agentTurn", message: "work", timeoutSeconds: 0 },
759+
state: { nextRunAtMs: scheduledAt },
760+
});
761+
await saveCronStore(store.storePath, { version: 1, jobs: [cronJob] });
762+
763+
let now = scheduledAt;
764+
let abortObserved = false;
765+
let timerSettled = false;
766+
const runnerStarted = createDeferred<void>();
767+
const state = createCronServiceState({
768+
cronEnabled: true,
769+
storePath: store.storePath,
770+
log: noopLogger,
771+
nowMs: () => now,
772+
enqueueSystemEvent: vi.fn(),
773+
requestHeartbeat: vi.fn(),
774+
runIsolatedAgentJob: vi.fn(async ({ abortSignal, onExecutionStarted }) => {
775+
onExecutionStarted?.();
776+
runnerStarted.resolve();
777+
abortSignal?.addEventListener(
778+
"abort",
779+
() => {
780+
abortObserved = true;
781+
},
782+
{ once: true },
783+
);
784+
return await new Promise<never>(() => {});
785+
}),
786+
});
787+
788+
const timerPromise = onTimer(state).then(() => {
789+
timerSettled = true;
790+
});
791+
await runnerStarted.promise;
792+
793+
const runId = `cron:no-timeout-cancel:${scheduledAt}`;
794+
const task = listTaskRecords().find(
795+
(entry) => entry.runtime === "cron" && entry.runId === runId,
796+
);
797+
if (!task) {
798+
throw new Error("Expected timeout-disabled cron task row");
799+
}
800+
801+
const cancelResult = await cancelTaskById({
802+
cfg: {} as never,
803+
taskId: task.taskId,
804+
});
805+
expect(cancelResult.found).toBe(true);
806+
expect(cancelResult.cancelled).toBe(true);
807+
expect(abortObserved).toBe(true);
808+
809+
for (let attempt = 0; attempt < 5 && !timerSettled; attempt += 1) {
810+
await vi.advanceTimersByTimeAsync(0);
811+
await Promise.resolve();
812+
}
813+
expect(timerSettled).toBe(true);
814+
await timerPromise;
815+
816+
const finalTask = listTaskRecords().find((entry) => entry.taskId === task.taskId);
817+
const job = requireJob(state, "no-timeout-cancel");
818+
expect(finalTask?.status).toBe("cancelled");
819+
expect(job.state.lastStatus).toBe("error");
820+
expect(job.state.lastError).toBe("Cancelled by operator.");
821+
} finally {
822+
vi.useRealTimers();
823+
resetActiveCronTaskRunsForTests();
824+
resetTaskRegistryForTests();
825+
}
826+
});
827+
746828
it("does not time out agentTurn jobs at the default 10-minute safety window", async () => {
747829
const store = timerRegressionFixtures.makeStorePath();
748830
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");

src/cron/service/timer.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,36 @@ export async function executeJobCoreWithTimeout(
138138
const jobTimeoutMs = resolveCronJobTimeoutMs(job);
139139
try {
140140
if (typeof jobTimeoutMs !== "number") {
141-
return await executeJobCore(state, job, runAbortController.signal);
141+
const cancellationMarker = Symbol("cron-cancelled");
142+
const cancellationPromise = new Promise<typeof cancellationMarker>((resolve) => {
143+
const resolveCancelled = () => resolve(cancellationMarker);
144+
if (runAbortController.signal.aborted) {
145+
resolveCancelled();
146+
return;
147+
}
148+
runAbortController.signal.addEventListener("abort", resolveCancelled, { once: true });
149+
});
150+
const corePromise = executeJobCore(state, job, runAbortController.signal);
151+
void corePromise.catch((err: unknown) => {
152+
if (runAbortController.signal.aborted) {
153+
state.deps.log.warn(
154+
{ jobId: job.id, err: String(err) },
155+
"cron: job core rejected after cancellation abort",
156+
);
157+
}
158+
});
159+
const first = await Promise.race([corePromise, cancellationPromise]);
160+
if (first !== cancellationMarker) {
161+
return first;
162+
}
163+
const error = abortErrorMessage(runAbortController.signal);
164+
return {
165+
status: "error",
166+
error,
167+
diagnostics: createCronRunDiagnosticsFromError("cron-setup", error, {
168+
nowMs: state.deps.nowMs,
169+
}),
170+
};
142171
}
143172

144173
let timeoutReason: string | undefined;

0 commit comments

Comments
 (0)