From 80e046d30ecaf39e184cb9e22aff932a1aaaee08 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 12:42:51 -0700 Subject: [PATCH] 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 --- src/cron/service/ops.test.ts | 4 +- src/cron/service/task-runs.test.ts | 18 +++++-- src/cron/service/task-runs.ts | 13 ++--- src/cron/service/timer.regression.test.ts | 2 +- src/cron/task-run-detail.ts | 6 +-- src/gateway/server.cron.test.ts | 61 +++++++++++++++++++++++ 6 files changed, 86 insertions(+), 18 deletions(-) diff --git a/src/cron/service/ops.test.ts b/src/cron/service/ops.test.ts index 25f8c684d9c1..74801e3c8e53 100644 --- a/src/cron/service/ops.test.ts +++ b/src/cron/service/ops.test.ts @@ -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); } diff --git a/src/cron/service/task-runs.test.ts b/src/cron/service/task-runs.test.ts index 25adbcf610e4..0fb60d14bc8c 100644 --- a/src/cron/service/task-runs.test.ts +++ b/src/cron/service/task-runs.test.ts @@ -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", diff --git a/src/cron/service/task-runs.ts b/src/cron/service/task-runs.ts index 839fa7e86535..4ea39ab64e6b 100644 --- a/src/cron/service/task-runs.ts +++ b/src/cron/service/task-runs.ts @@ -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, diff --git a/src/cron/service/timer.regression.test.ts b/src/cron/service/timer.regression.test.ts index 7218749eedaf..6ab08c5cdf72 100644 --- a/src/cron/service/timer.regression.test.ts +++ b/src/cron/service/timer.regression.test.ts @@ -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", diff --git a/src/cron/task-run-detail.ts b/src/cron/task-run-detail.ts index 8dfe957cb45d..41706c064fc6 100644 --- a/src/cron/task-run-detail.ts +++ b/src/cron/task-run-detail.ts @@ -262,12 +262,12 @@ export function cronTaskRecordToScriptRunResult( /** Maps the cron outcome vocabulary onto generic task terminal states. */ export function cronRunStatusToTaskStatus( - entry: CronRunLogEntry, + entry: Pick & Partial, ): Extract { - 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. */ diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index da6fe2bdc244..ee793621809d 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -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> } | 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;