mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 20:14:30 +00:00
fix(cron): enforce timeout for manual cron runs
This commit is contained in:
@@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Cron/Isolation: force fresh session IDs for isolated cron runs so `sessionTarget="isolated"` executions never reuse prior run context. (#23470) Thanks @echoVic.
|
- Cron/Isolation: force fresh session IDs for isolated cron runs so `sessionTarget="isolated"` executions never reuse prior run context. (#23470) Thanks @echoVic.
|
||||||
- Cron/Service: execute manual `cron.run` jobs outside the cron lock (while still persisting started/finished state atomically) so `cron.list` and `cron.status` remain responsive during long forced runs. (#23628) Thanks @dsgraves.
|
- Cron/Service: execute manual `cron.run` jobs outside the cron lock (while still persisting started/finished state atomically) so `cron.list` and `cron.status` remain responsive during long forced runs. (#23628) Thanks @dsgraves.
|
||||||
- Cron/Timer: keep a watchdog recheck timer armed while `onTimer` is actively executing so the scheduler continues polling even if a due-run tick stalls for an extended period. (#23628) Thanks @dsgraves.
|
- Cron/Timer: keep a watchdog recheck timer armed while `onTimer` is actively executing so the scheduler continues polling even if a due-run tick stalls for an extended period. (#23628) Thanks @dsgraves.
|
||||||
|
- Cron/Run: enforce the same per-job timeout guard for manual `cron.run` executions as timer-driven runs, including abort propagation for isolated agent jobs, so forced runs cannot wedge indefinitely. (#23704) Thanks @tkuehnl.
|
||||||
- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.
|
- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.
|
||||||
- Feishu/Media: for inbound video messages that include both `file_key` (video) and `image_key` (thumbnail), prefer `file_key` when downloading media so video attachments are saved instead of silently failing on thumbnail keys. (#23633)
|
- Feishu/Media: for inbound video messages that include both `file_key` (video) and `image_key` (thumbnail), prefer `file_key` when downloading media so video attachments are saved instead of silently failing on thumbnail keys. (#23633)
|
||||||
- Hooks/Cron: suppress duplicate main-session events for delivered hook turns and mark `SILENT_REPLY_TOKEN` (`NO_REPLY`) early exits as delivered to prevent hook context pollution. (#20678) Thanks @JonathanWorks.
|
- Hooks/Cron: suppress duplicate main-session events for delivered hook turns and mark `SILENT_REPLY_TOKEN` (`NO_REPLY`) early exits as delivered to prevent hook context pollution. (#20678) Thanks @JonathanWorks.
|
||||||
|
|||||||
@@ -732,6 +732,54 @@ describe("Cron issue regressions", () => {
|
|||||||
expect(job?.state.lastError).toContain("timed out");
|
expect(job?.state.lastError).toContain("timed out");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies timeoutSeconds to manual cron.run isolated executions", async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
const store = await makeStorePath();
|
||||||
|
let observedAbortSignal: AbortSignal | undefined;
|
||||||
|
|
||||||
|
const cron = await startCronForStore({
|
||||||
|
storePath: store.storePath,
|
||||||
|
runIsolatedAgentJob: vi.fn(async ({ abortSignal }) => {
|
||||||
|
observedAbortSignal = abortSignal;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (!abortSignal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (abortSignal.aborted) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
||||||
|
});
|
||||||
|
return { status: "ok" as const, summary: "late" };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const job = await cron.add({
|
||||||
|
name: "manual timeout",
|
||||||
|
enabled: true,
|
||||||
|
schedule: { kind: "every", everyMs: 60_000, anchorMs: Date.now() },
|
||||||
|
sessionTarget: "isolated",
|
||||||
|
wakeMode: "next-heartbeat",
|
||||||
|
payload: { kind: "agentTurn", message: "work", timeoutSeconds: 0.01 },
|
||||||
|
delivery: { mode: "none" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await cron.run(job.id, "force");
|
||||||
|
expect(result).toEqual({ ok: true, ran: true });
|
||||||
|
expect(observedAbortSignal).toBeDefined();
|
||||||
|
expect(observedAbortSignal?.aborted).toBe(true);
|
||||||
|
|
||||||
|
const updated = (await cron.list({ includeDisabled: true })).find(
|
||||||
|
(entry) => entry.id === job.id,
|
||||||
|
);
|
||||||
|
expect(updated?.state.lastStatus).toBe("error");
|
||||||
|
expect(updated?.state.lastError).toContain("timed out");
|
||||||
|
expect(updated?.state.runningAtMs).toBeUndefined();
|
||||||
|
|
||||||
|
cron.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("retries cron schedule computation from the next second when the first attempt returns undefined (#17821)", () => {
|
it("retries cron schedule computation from the next second when the first attempt returns undefined (#17821)", () => {
|
||||||
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
|
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
|
||||||
const cronJob = createIsolatedRegressionJob({
|
const cronJob = createIsolatedRegressionJob({
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { locked } from "./locked.js";
|
|||||||
import type { CronServiceState } from "./state.js";
|
import type { CronServiceState } from "./state.js";
|
||||||
import { ensureLoaded, persist, warnIfDisabled } from "./store.js";
|
import { ensureLoaded, persist, warnIfDisabled } from "./store.js";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_JOB_TIMEOUT_MS,
|
||||||
applyJobResult,
|
applyJobResult,
|
||||||
armTimer,
|
armTimer,
|
||||||
emit,
|
emit,
|
||||||
@@ -247,8 +248,40 @@ export async function run(state: CronServiceState, id: string, mode?: "due" | "f
|
|||||||
status: "error";
|
status: "error";
|
||||||
error: string;
|
error: string;
|
||||||
};
|
};
|
||||||
|
const configuredTimeoutMs =
|
||||||
|
prepared.executionJob.payload.kind === "agentTurn" &&
|
||||||
|
typeof prepared.executionJob.payload.timeoutSeconds === "number"
|
||||||
|
? Math.floor(prepared.executionJob.payload.timeoutSeconds * 1_000)
|
||||||
|
: undefined;
|
||||||
|
const jobTimeoutMs =
|
||||||
|
configuredTimeoutMs !== undefined
|
||||||
|
? configuredTimeoutMs <= 0
|
||||||
|
? undefined
|
||||||
|
: configuredTimeoutMs
|
||||||
|
: DEFAULT_JOB_TIMEOUT_MS;
|
||||||
try {
|
try {
|
||||||
coreResult = await executeJobCore(state, prepared.executionJob);
|
const runAbortController = typeof jobTimeoutMs === "number" ? new AbortController() : undefined;
|
||||||
|
coreResult =
|
||||||
|
typeof jobTimeoutMs === "number"
|
||||||
|
? await (async () => {
|
||||||
|
let timeoutId: NodeJS.Timeout | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
executeJobCore(state, prepared.executionJob, runAbortController?.signal),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
runAbortController?.abort(new Error("cron: job execution timed out"));
|
||||||
|
reject(new Error("cron: job execution timed out"));
|
||||||
|
}, jobTimeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: await executeJobCore(state, prepared.executionJob);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
coreResult = { status: "error", error: String(err) };
|
coreResult = { status: "error", error: String(err) };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user