diff --git a/CHANGELOG.md b/CHANGELOG.md index e80e2c34ce4..6bc7bf6f07f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai - Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo. - Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng. - Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev. +- Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026. ## 2026.3.8 diff --git a/src/cron/cron-protocol-conformance.test.ts b/src/cron/cron-protocol-conformance.test.ts index 51fe8f4767c..698f5e0038d 100644 --- a/src/cron/cron-protocol-conformance.test.ts +++ b/src/cron/cron-protocol-conformance.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js"; -import { CronDeliverySchema } from "../gateway/protocol/schema.js"; +import { CronDeliverySchema, CronJobStateSchema } from "../gateway/protocol/schema.js"; type SchemaLike = { anyOf?: Array; @@ -29,6 +29,16 @@ function extractDeliveryModes(schema: SchemaLike): string[] { return Array.from(new Set(unionModes)); } +function extractConstUnionValues(schema: SchemaLike): string[] { + return Array.from( + new Set( + (schema.anyOf ?? []) + .map((entry) => entry?.const) + .filter((value): value is string => typeof value === "string"), + ), + ); +} + const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"]; const SWIFT_MODEL_CANDIDATES = [`${MACOS_APP_SOURCES_DIR}/CronModels.swift`]; @@ -88,4 +98,19 @@ describe("cron protocol conformance", () => { expect(swift.includes("struct CronSchedulerStatus")).toBe(true); expect(swift.includes("let jobs:")).toBe(true); }); + + it("cron job state schema keeps the full failover reason set", () => { + const properties = (CronJobStateSchema as SchemaLike).properties ?? {}; + const lastErrorReason = properties.lastErrorReason as SchemaLike | undefined; + expect(lastErrorReason).toBeDefined(); + expect(extractConstUnionValues(lastErrorReason ?? {})).toEqual([ + "auth", + "format", + "rate_limit", + "billing", + "timeout", + "model_not_found", + "unknown", + ]); + }); }); diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 5320ffdf526..e12c4ae38e7 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -1,3 +1,4 @@ +import { resolveFailoverReasonFromError } from "../../agents/failover-error.js"; import type { CronConfig, CronRetryOn } from "../../config/types.cron.js"; import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js"; import { DEFAULT_AGENT_ID } from "../../routing/session-key.js"; @@ -322,6 +323,10 @@ export function applyJobResult( job.state.lastStatus = result.status; job.state.lastDurationMs = Math.max(0, result.endedAt - result.startedAt); job.state.lastError = result.error; + job.state.lastErrorReason = + result.status === "error" && typeof result.error === "string" + ? (resolveFailoverReasonFromError(result.error) ?? undefined) + : undefined; job.state.lastDelivered = result.delivered; const deliveryStatus = resolveDeliveryStatus({ job, delivered: result.delivered }); job.state.lastDeliveryStatus = deliveryStatus; @@ -670,7 +675,6 @@ export async function onTimer(state: CronServiceState) { if (completedResults.length > 0) { await locked(state, async () => { await ensureLoaded(state, { forceReload: true, skipRecompute: true }); - for (const result of completedResults) { applyOutcomeToStoredJob(state, result); } diff --git a/src/cron/types.ts b/src/cron/types.ts index ef5de924b02..2a93bc30311 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -1,3 +1,4 @@ +import type { FailoverReason } from "../agents/pi-embedded-helpers.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { CronJobBase } from "./types-shared.js"; @@ -105,7 +106,6 @@ type CronAgentTurnPayload = { type CronAgentTurnPayloadPatch = { kind: "agentTurn"; } & Partial; - export type CronJobState = { nextRunAtMs?: number; runningAtMs?: number; @@ -115,6 +115,8 @@ export type CronJobState = { /** Back-compat alias for lastRunStatus. */ lastStatus?: "ok" | "error" | "skipped"; lastError?: string; + /** Classified reason for the last error (when available). */ + lastErrorReason?: FailoverReason; lastDurationMs?: number; /** Number of consecutive execution errors (reset on success). Used for backoff. */ consecutiveErrors?: number; diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 41e7467bece..3cba5a65781 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -56,6 +56,15 @@ const CronDeliveryStatusSchema = Type.Union([ Type.Literal("unknown"), Type.Literal("not-requested"), ]); +const CronFailoverReasonSchema = Type.Union([ + Type.Literal("auth"), + Type.Literal("format"), + Type.Literal("rate_limit"), + Type.Literal("billing"), + Type.Literal("timeout"), + Type.Literal("model_not_found"), + Type.Literal("unknown"), +]); const CronCommonOptionalFields = { agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), sessionKey: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), @@ -219,6 +228,7 @@ export const CronJobStateSchema = Type.Object( lastRunStatus: Type.Optional(CronRunStatusSchema), lastStatus: Type.Optional(CronRunStatusSchema), lastError: Type.Optional(Type.String()), + lastErrorReason: Type.Optional(CronFailoverReasonSchema), lastDurationMs: Type.Optional(Type.Integer({ minimum: 0 })), consecutiveErrors: Type.Optional(Type.Integer({ minimum: 0 })), lastDelivered: Type.Optional(Type.Boolean()),