fix(cron): show skipped automation tasks as failed (#123787)

* fix(cron): report skipped runs as failed tasks

* test(cron): align skipped task expectations

* fix(cron): keep skipped tasks failed
This commit is contained in:
Peter Steinberger
2026-08-14 12:42:51 -07:00
committed by GitHub
parent 986be4558f
commit 80e046d30e
6 changed files with 86 additions and 18 deletions
+1 -3
View File
@@ -1450,9 +1450,7 @@ describe("cron service ops seam coverage", () => {
expect(restored?.state.lastRunAtMs).toBe(startedAt);
expect(restored?.state.lastRunStatus).toBe(status);
expect(runIsolatedAgentJob).not.toHaveBeenCalled();
expect(findTaskByRunId(taskRunId)?.status).toBe(
status === "error" ? "failed" : "succeeded",
);
expect(findTaskByRunId(taskRunId)?.status).toBe(status === "ok" ? "succeeded" : "failed");
} finally {
stop(state);
}
+15 -3
View File
@@ -164,9 +164,21 @@ describe("cron task run terminal records", () => {
tryFinishCronTaskRunWithoutHistory(state, {
taskRunId: runIds[0],
status: "skipped",
error: "cron: job execution timed out",
endedAt: 1_501,
});
expect(childSessionKey(systemEventJob)).toBeUndefined();
expect(
listTaskRegistryRecordsByRuntimeSourceIdFromSqlite({
runtime: "cron",
sourceId: systemEventJob.id,
}),
).toEqual([
expect.objectContaining({
status: "failed",
error: "cron: job execution timed out",
}),
]);
},
);
});
@@ -429,7 +441,7 @@ describe("cron task run terminal records", () => {
action: "finished",
job,
status: "skipped",
error: "trigger condition not met",
error: "cron: job execution timed out",
runId: "manual:skipped-job:1",
runAtMs: startedAt,
durationMs: 0,
@@ -446,10 +458,10 @@ describe("cron task run terminal records", () => {
runtime: "cron",
sourceId: job.id,
agentId: "finn",
status: "succeeded",
status: "failed",
startedAt,
endedAt: startedAt,
error: "trigger condition not met",
error: "cron: job execution timed out",
detail: {
kind: "cron-run",
status: "skipped",
+5 -8
View File
@@ -5,7 +5,6 @@ import type { DatabaseSync } from "node:sqlite";
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { resolveCronJobEffectiveAgentId } from "../agent-id.js";
import { isCronTimeoutErrorText } from "../execution-error-constants.js";
function requireCronAgentId(agentId: string | undefined): string {
if (!agentId?.trim()) {
@@ -321,7 +320,10 @@ export function tryFinishCronTaskRunWithoutHistory(
if (!result.taskRunId) {
return;
}
const error = result.status === "error" ? normalizeCronRunErrorText(result.error) : undefined;
const error =
result.status !== "ok" && result.error !== undefined
? normalizeCronRunErrorText(result.error)
: undefined;
const quietTriggerEval =
result.triggerEval?.fired === false
? { ...result.triggerEval, fired: false as const }
@@ -330,12 +332,7 @@ export function tryFinishCronTaskRunWithoutHistory(
finalizeTaskRunByRunIdCore({
runId: result.taskRunId,
runtime: "cron",
status:
result.status === "ok" || result.status === "skipped"
? "succeeded"
: isCronTimeoutErrorText(error)
? "timed_out"
: "failed",
status: cronRunStatusToTaskStatus({ status: result.status, error }),
endedAt: result.endedAt,
lastEventAt: result.endedAt,
error,
+1 -1
View File
@@ -2822,7 +2822,7 @@ describe("cron service timer regressions", () => {
outcome: "skip",
status: "skipped",
error: "agent skipped after removal",
taskStatus: "succeeded",
taskStatus: "failed",
},
] as const)(
"finalizes a removed job's $outcome outcome in operator history",
+3 -3
View File
@@ -262,12 +262,12 @@ export function cronTaskRecordToScriptRunResult(
/** Maps the cron outcome vocabulary onto generic task terminal states. */
export function cronRunStatusToTaskStatus(
entry: CronRunLogEntry,
entry: Pick<CronRunLogEntry, "status" | "error"> & Partial<CronRunLogEntry>,
): Extract<TaskStatus, "succeeded" | "failed" | "timed_out"> {
if (entry.status === "ok" || entry.status === "skipped") {
if (entry.status === "ok") {
return "succeeded";
}
return isCronTimeoutErrorText(entry.error) ? "timed_out" : "failed";
return entry.status === "error" && isCronTimeoutErrorText(entry.error) ? "timed_out" : "failed";
}
/** Reconstructs the unchanged CronRunLogEntry wire shape from a cron task row. */
+61
View File
@@ -1632,6 +1632,67 @@ describe("gateway server cron", () => {
}
});
test("reports skipped isolated cron runs as failed tasks", async () => {
const { prevSkipCron } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-run-skipped-task-",
cronEnabled: false,
});
cronIsolatedRun.mockResolvedValueOnce({
status: "skipped",
error: "model endpoint unavailable",
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
try {
const addRes = await rpcReq(ws, "cron.add", {
name: "skipped task projection",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "do work" },
delivery: { mode: "none" },
});
const jobId = expectCronJobIdFromResponse(addRes);
const finished = waitForCronEvent(
ws,
(payload) => payload?.jobId === jobId && payload?.action === "finished",
);
await runCronJobForce(ws, jobId);
expect(await finished).toMatchObject({
jobId,
status: "skipped",
error: "model endpoint unavailable",
});
const history = await rpcReq(ws, "cron.runs", { id: jobId, limit: 1 });
expect(history.ok).toBe(true);
expect(history.payload).toMatchObject({
entries: [
expect.objectContaining({
jobId,
status: "skipped",
error: "model endpoint unavailable",
}),
],
});
const taskList = await rpcReq(ws, "tasks.list", {});
expect(taskList.ok).toBe(true);
const tasks = (taskList.payload as { tasks?: Array<Record<string, unknown>> } | undefined)
?.tasks;
expect(tasks?.find((task) => task.sourceId === jobId)).toMatchObject({
runtime: "cron",
status: "failed",
error: "model endpoint unavailable",
});
} finally {
await cleanupCronTestRun({ ws, server, prevSkipCron });
}
});
test("returns already-running without starting background work", async () => {
const now = Date.now();
let resolveRun: ((result: { status: "ok"; summary: string }) => void) | undefined;