improve(cron): show consecutive failure count and last error in cron CLI output (#99602)

* improve(cron): show consecutive failure count and last error in cron list/show

* improve(cron): show consecutive failure count and last error in cron CLI output

* fix(clownfish): address review for live-pr-inventory-20260706T074223-002 (1)

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
This commit is contained in:
Masato Hoshino
2026-07-06 01:44:51 -07:00
committed by GitHub
co-authored by openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
parent 1b44efec8c
commit 310c2f58b9
4 changed files with 98 additions and 4 deletions
+1
View File
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
- **Android PTT microphone ownership:** pause realtime relay capture while push-to-talk owns the microphone, then resume only for the same completed capture. (#99986) Thanks @NianJiuZst.
- **Bundled channel loading:** resolve source-only bundled channel entries from their current registry root and canonicalize active-release aliases before enforcing module boundaries, avoiding false setup-entry warnings in mixed source/dist deployments without admitting paths outside the active package boundary.
- **Cron CLI diagnostics:** show repeated failure counts and the last error in human `cron list`/`cron show` output while keeping JSON status fields undecorated. (#99602) Thanks @masatohoshino.
- **Small-context compaction:** cap the effective reserve against the known model context window so small local models do not enter compaction from the first token. (#100621) Thanks @vincentkoc.
- **Plugin install diagnostics:** suppress the misleading hook-pack fallback after plugin install failures only when the hook manifest is absent, while preserving actionable malformed hook-pack errors. (#100554) Thanks @vincentkoc.
- **Config validation diagnostics:** emit each unchanged sanitized validation-warning payload once per config path, reset deduplication after a clean validation, and preserve the warning fingerprint across transient invalid reads and failed refreshes. (#100569, #25574) Thanks @vincentkoc.
+1 -1
View File
@@ -302,7 +302,7 @@ openclaw cron runs --id <job-id> --run-id <run-id>
`openclaw cron get <job-id>` returns the stored job JSON directly. Use `cron show <job-id>` when you want the human-readable view with delivery-route preview.
`cron list --json` and `cron show <job-id> --json` include a top-level `status` field on each job, computed from `enabled`, `state.runningAtMs`, and `state.lastRunStatus`. Values: `disabled`, `running`, `ok`, `error`, `skipped`, or `idle`. This mirrors the human-readable status column so external tooling can read job state without re-deriving it.
`cron list --json` and `cron show <job-id> --json` include a top-level `status` field on each job, computed from `enabled`, `state.runningAtMs`, and `state.lastRunStatus`. Values: `disabled`, `running`, `ok`, `error`, `skipped`, or `idle`. JSON status stays canonical and undecorated so external tooling can read job state without re-deriving it; human output may decorate repeated `error` statuses with a failure count.
`cron runs` entries include delivery diagnostics with the intended cron target, the resolved target, message-tool sends, fallback use, and delivered state.
+73
View File
@@ -174,6 +174,79 @@ describe("printCronList", () => {
expectLogsToInclude(show.logs, "schedule: on-exit pnpm build @ /repo");
});
it("shows the consecutive failure count for chronically failing jobs", () => {
const failing = createBaseJob({
id: "failing-job",
name: "Failing",
state: { lastRunStatus: "error", consecutiveErrors: 12, lastError: "boom" },
});
const singleFailure = createBaseJob({
id: "single-failure-job",
name: "Failed Once",
state: { lastRunStatus: "error", consecutiveErrors: 1, lastError: "boom" },
});
const { logs, runtime } = createRuntimeLogCapture();
printCronList([failing, singleFailure], runtime);
expectLogsToInclude(logs, "error (12x)");
// A single failure keeps the bare status token; the count only marks repeats.
const singleLine = logs.find((line) => line.includes("single-failure-job")) ?? "";
expect(singleLine).toContain("error");
expect(singleLine).not.toContain("(1x)");
});
it("caps the failure count so the status column never overflows", () => {
const { logs, runtime } = createRuntimeLogCapture();
printCronList(
[
createBaseJob({
id: "minute-cron-job",
state: { lastRunStatus: "error", consecutiveErrors: 1440, lastError: "boom" },
}),
],
runtime,
);
expectLogsToInclude(logs, "error (99+x)");
expect(logs.join("\n")).not.toContain("1440");
});
it("keeps the --json status field free of the failure-count decoration", () => {
const job = createBaseJob({
id: "json-job",
state: { lastRunStatus: "error", consecutiveErrors: 12, lastError: "boom" },
});
const enriched = enrichCronJsonWithStatus({
jobs: [job],
}) as { jobs: Array<{ status?: string }> };
expect(enriched.jobs[0]?.status).toBe("error");
expect(enrichCronJsonWithStatus(job)).toMatchObject({
status: "error",
state: { consecutiveErrors: 12, lastError: "boom" },
});
});
it("shows last error and failure count in cron show output", () => {
const failing = createRuntimeLogCapture();
printCronShow(
createBaseJob({
id: "show-failing-job",
state: { lastRunStatus: "error", consecutiveErrors: 3, lastError: "provider exploded" },
}),
failing.runtime,
);
expectLogsToInclude(failing.logs, "status: error (3x)");
expectLogsToInclude(failing.logs, "last error: provider exploded");
const healthy = createRuntimeLogCapture();
printCronShow(
createBaseJob({ id: "healthy-job", state: { lastRunStatus: "ok" } }),
healthy.runtime,
);
expectLogsToInclude(healthy.logs, "status: ok");
expectLogsToInclude(healthy.logs, "last error: -");
});
it("shows dash for unset agentId instead of default", () => {
const { logs, runtime } = createRuntimeLogCapture();
const job = createBaseJob({
+23 -3
View File
@@ -165,6 +165,24 @@ function computeStatus(job: CronJob): string {
return state.lastRunStatus ?? state.lastStatus ?? "idle";
}
// Human-facing decoration only: enrichCronJsonWithStatus() emits computeStatus()
// verbatim as the --json `status` field, so the failure count must stay out of it.
// consecutiveErrors resets to 0 on the next successful run, so the count is live.
function decorateStatusWithFailures(status: string, consecutiveErrors: number | undefined): string {
const failures = consecutiveErrors ?? 0;
if (status !== "error" || failures <= 1) {
return status;
}
// Capped so the Status column never overflows (a minute cron failing for a day
// reaches 4 digits); past 99 the exact figure adds nothing over "chronic".
return failures > 99 ? `${status} (99+x)` : `${status} (${failures}x)`;
}
function formatCronStatusForDisplay(job: CronJob): string {
const state = job.state ?? {};
return decorateStatusWithFailures(computeStatus(job), state.consecutiveErrors);
}
export function handleCronCliError(err: unknown) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -318,7 +336,7 @@ const CRON_NAME_PAD = 24;
const CRON_SCHEDULE_PAD = 32;
const CRON_NEXT_PAD = 10;
const CRON_LAST_PAD = 10;
const CRON_STATUS_PAD = 9;
const CRON_STATUS_PAD = 12;
const CRON_TARGET_PAD = 9;
const CRON_DELIVERY_PAD = 64;
const CRON_AGENT_PAD = 10;
@@ -473,7 +491,7 @@ export function printCronList(
);
const lastLabel = pad(formatRelative(state.lastRunAtMs, now), CRON_LAST_PAD);
const statusRaw = computeStatus(job);
const statusLabel = pad(statusRaw, CRON_STATUS_PAD);
const statusLabel = pad(formatCronStatusForDisplay(job), CRON_STATUS_PAD);
const targetLabel = pad(job.sessionTarget ?? "-", CRON_TARGET_PAD);
const deliveryPreview = opts?.deliveryPreviews?.get(job.id);
const deliveryText = deliveryPreview
@@ -560,7 +578,9 @@ export function printCronShow(
runtime.log(`delivery: ${preview.label} (${preview.detail})`);
runtime.log(`next: ${formatRelative(job.state.nextRunAtMs, Date.now())}`);
runtime.log(`last: ${formatRelative(job.state.lastRunAtMs, Date.now())}`);
runtime.log(`status: ${computeStatus(job)}`);
runtime.log(`status: ${formatCronStatusForDisplay(job)}`);
// lastError is the run/schedule failure message; the diagnostic line below is
// the run-diagnostics summary and can be empty when only lastError is set.
runtime.log(`last error: ${job.state.lastError ?? "-"}`);
runtime.log(`last delivery: ${job.state.lastDeliveryStatus ?? "-"}`);
runtime.log(`last delivery error: ${job.state.lastDeliveryError ?? "-"}`);