cron: record lastErrorReason in job state (#14382)

Merged via squash.

Prepared head SHA: baa6b5d566
Co-authored-by: futuremind2026 <258860756+futuremind2026@users.noreply.github.com>
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Reviewed-by: @BunsDev
This commit is contained in:
futuremind2026
2026-03-10 13:01:45 +08:00
committed by GitHub
parent da4fec6641
commit 382287026b
5 changed files with 45 additions and 3 deletions

View File

@@ -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

View File

@@ -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<SchemaLike>;
@@ -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",
]);
});
});

View File

@@ -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);
}

View File

@@ -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<CronAgentTurnPayloadFields>;
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;

View File

@@ -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()),