From c52fac836cc85c93f8847673251630aa5e5466ac Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 29 Mar 2026 20:10:44 -0700 Subject: [PATCH] feat(tasks): add status health and maintenance command (#57423) * feat(tasks): add status health and maintenance command * fix(tasks): address status and maintenance review feedback --- .../register.status-health-sessions.test.ts | 15 ++ .../register.status-health-sessions.ts | 19 ++ src/commands/status.command.ts | 7 + src/commands/status.scan.json-core.ts | 2 + src/commands/status.scan.ts | 2 + src/commands/status.summary.redaction.test.ts | 14 ++ src/commands/status.summary.test.ts | 14 ++ src/commands/status.summary.ts | 5 +- src/commands/status.types.ts | 2 + src/commands/tasks.test.ts | 182 ++++++++++++++++++ src/commands/tasks.ts | 54 ++++++ src/tasks/task-registry.audit.ts | 2 +- src/tasks/task-registry.maintenance.ts | 62 +++++- src/tasks/task-registry.test.ts | 101 +++++++++- 14 files changed, 476 insertions(+), 5 deletions(-) diff --git a/src/cli/program/register.status-health-sessions.test.ts b/src/cli/program/register.status-health-sessions.test.ts index 86fb77eea58..1b7cf57c854 100644 --- a/src/cli/program/register.status-health-sessions.test.ts +++ b/src/cli/program/register.status-health-sessions.test.ts @@ -8,6 +8,7 @@ const sessionsCommand = vi.fn(); const sessionsCleanupCommand = vi.fn(); const tasksListCommand = vi.fn(); const tasksAuditCommand = vi.fn(); +const tasksMaintenanceCommand = vi.fn(); const tasksShowCommand = vi.fn(); const tasksNotifyCommand = vi.fn(); const tasksCancelCommand = vi.fn(); @@ -34,6 +35,7 @@ vi.mock("../../commands/sessions-cleanup.js", () => ({ vi.mock("../../commands/tasks.js", () => ({ tasksListCommand, tasksAuditCommand, + tasksMaintenanceCommand, tasksShowCommand, tasksNotifyCommand, tasksCancelCommand, @@ -70,6 +72,7 @@ describe("registerStatusHealthSessionsCommands", () => { sessionsCleanupCommand.mockResolvedValue(undefined); tasksListCommand.mockResolvedValue(undefined); tasksAuditCommand.mockResolvedValue(undefined); + tasksMaintenanceCommand.mockResolvedValue(undefined); tasksShowCommand.mockResolvedValue(undefined); tasksNotifyCommand.mockResolvedValue(undefined); tasksCancelCommand.mockResolvedValue(undefined); @@ -245,6 +248,18 @@ describe("registerStatusHealthSessionsCommands", () => { ); }); + it("runs tasks maintenance subcommand with apply forwarding", async () => { + await runCli(["tasks", "--json", "maintenance", "--apply"]); + + expect(tasksMaintenanceCommand).toHaveBeenCalledWith( + expect.objectContaining({ + json: true, + apply: true, + }), + runtime, + ); + }); + it("runs tasks audit subcommand with filters", async () => { await runCli([ "tasks", diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index 3cc9b8024a5..1467be8fe72 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -7,6 +7,7 @@ import { tasksAuditCommand, tasksCancelCommand, tasksListCommand, + tasksMaintenanceCommand, tasksNotifyCommand, tasksShowCommand, } from "../../commands/tasks.js"; @@ -305,6 +306,24 @@ export function registerStatusHealthSessionsCommands(program: Command) { }); }); + tasksCmd + .command("maintenance") + .description("Preview or apply task ledger maintenance") + .option("--json", "Output as JSON", false) + .option("--apply", "Apply reconciliation, cleanup stamping, and pruning", false) + .action(async (opts, command) => { + const parentOpts = command.parent?.opts() as { json?: boolean } | undefined; + await runCommandWithRuntime(defaultRuntime, async () => { + await tasksMaintenanceCommand( + { + json: Boolean(opts.json || parentOpts?.json), + apply: Boolean(opts.apply), + }, + defaultRuntime, + ); + }); + }); + tasksCmd .command("show") .description("Show one background task by task id, run id, or session key") diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index c5128fed085..4aaf982c1df 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -394,6 +394,13 @@ export async function statusCommand( summary.tasks.failures > 0 ? warn(`${summary.tasks.failures} issue${summary.tasks.failures === 1 ? "" : "s"}`) : muted("no issues"), + summary.taskAudit.errors > 0 + ? warn( + `audit ${summary.taskAudit.errors} error${summary.taskAudit.errors === 1 ? "" : "s"} · ${summary.taskAudit.warnings} warn`, + ) + : summary.taskAudit.warnings > 0 + ? muted(`audit ${summary.taskAudit.warnings} warn`) + : muted("audit clean"), `${summary.tasks.total} tracked`, ].join(" · ") : muted("none"); diff --git a/src/commands/status.scan.json-core.ts b/src/commands/status.scan.json-core.ts index 9fce3a206ef..8e05f7e5dad 100644 --- a/src/commands/status.scan.json-core.ts +++ b/src/commands/status.scan.json-core.ts @@ -3,6 +3,7 @@ import type { UpdateCheckResult } from "../infra/update-check.js"; import { loggingState } from "../logging/state.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; +import { createEmptyTaskAuditSummary } from "../tasks/task-registry.audit.js"; import { createEmptyTaskRegistrySummary } from "../tasks/task-registry.summary.js"; import type { getAgentLocalStatuses as getAgentLocalStatusesFn } from "./status.agent-local.js"; import type { StatusScanResult } from "./status.scan.js"; @@ -74,6 +75,7 @@ function buildColdStartStatusSummary(): Awaited { cron: 1, }, }, + taskAudit: { + total: 1, + warnings: 1, + errors: 0, + byCode: { + stale_queued: 0, + stale_running: 0, + lost: 0, + delivery_failed: 1, + missing_cleanup: 0, + inconsistent_timestamps: 0, + }, + }, sessions: { paths: ["/tmp/openclaw/sessions.json"], count: 1, @@ -76,5 +89,6 @@ describe("redactSensitiveStatusSummary", () => { expect(redacted.heartbeat).toEqual(input.heartbeat); expect(redacted.channelSummary).toEqual(input.channelSummary); expect(redacted.tasks).toEqual(input.tasks); + expect(redacted.taskAudit).toEqual(input.taskAudit); }); }); diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index 6425b2734d1..23fbf70778c 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -81,6 +81,19 @@ vi.mock("../tasks/task-registry.maintenance.js", () => ({ cron: 0, }, })), + getInspectableTaskAuditSummary: vi.fn(() => ({ + total: 1, + warnings: 1, + errors: 0, + byCode: { + stale_queued: 0, + stale_running: 0, + lost: 0, + delivery_failed: 1, + missing_cleanup: 0, + inconsistent_timestamps: 0, + }, + })), })); vi.mock("../routing/session-key.js", () => ({ @@ -121,6 +134,7 @@ describe("getStatusSummary", () => { expect(summary.heartbeat.defaultAgentId).toBe("main"); expect(summary.channelSummary).toEqual(["ok"]); expect(summary.tasks.active).toBe(0); + expect(summary.taskAudit.warnings).toBe(1); }); it("skips channel summary imports when no channels are configured", async () => { diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index c963a08387c..980f78d5cb6 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -144,7 +144,9 @@ export async function getStatusSummary( : []; const mainSessionKey = resolveMainSessionKey(cfg); const queuedSystemEvents = peekSystemEvents(mainSessionKey); - const tasks = (await loadTaskRegistryMaintenanceModule()).getInspectableTaskRegistrySummary(); + const taskMaintenanceModule = await loadTaskRegistryMaintenanceModule(); + const tasks = taskMaintenanceModule.getInspectableTaskRegistrySummary(); + const taskAudit = taskMaintenanceModule.getInspectableTaskAuditSummary(); const resolved = resolveConfiguredStatusModelRef({ cfg, @@ -273,6 +275,7 @@ export async function getStatusSummary( channelSummary, queuedSystemEvents, tasks, + taskAudit, sessions: { paths: Array.from(paths), count: totalSessions, diff --git a/src/commands/status.types.ts b/src/commands/status.types.ts index 7495e6512d3..61650340f43 100644 --- a/src/commands/status.types.ts +++ b/src/commands/status.types.ts @@ -1,4 +1,5 @@ import type { ChannelId } from "../channels/plugins/types.js"; +import type { TaskAuditSummary } from "../tasks/task-registry.audit.js"; import type { TaskRegistrySummary } from "../tasks/task-registry.types.js"; export type SessionStatus = { @@ -50,6 +51,7 @@ export type StatusSummary = { channelSummary: string[]; queuedSystemEvents: string[]; tasks: TaskRegistrySummary; + taskAudit: TaskAuditSummary; sessions: { paths: string[]; count: number; diff --git a/src/commands/tasks.test.ts b/src/commands/tasks.test.ts index 6ff8fcac761..0f154008e59 100644 --- a/src/commands/tasks.test.ts +++ b/src/commands/tasks.test.ts @@ -5,6 +5,10 @@ const reconcileInspectableTasksMock = vi.fn(); const reconcileTaskLookupTokenMock = vi.fn(); const listTaskAuditFindingsMock = vi.fn(); const summarizeTaskAuditFindingsMock = vi.fn(); +const previewTaskRegistryMaintenanceMock = vi.fn(); +const runTaskRegistryMaintenanceMock = vi.fn(); +const getInspectableTaskRegistrySummaryMock = vi.fn(); +const getInspectableTaskAuditSummaryMock = vi.fn(); const updateTaskNotifyPolicyByIdMock = vi.fn(); const cancelTaskByIdMock = vi.fn(); const getTaskByIdMock = vi.fn(); @@ -20,6 +24,16 @@ vi.mock("../tasks/task-registry.audit.js", () => ({ summarizeTaskAuditFindings: (...args: unknown[]) => summarizeTaskAuditFindingsMock(...args), })); +vi.mock("../tasks/task-registry.maintenance.js", () => ({ + previewTaskRegistryMaintenance: (...args: unknown[]) => + previewTaskRegistryMaintenanceMock(...args), + runTaskRegistryMaintenance: (...args: unknown[]) => runTaskRegistryMaintenanceMock(...args), + getInspectableTaskRegistrySummary: (...args: unknown[]) => + getInspectableTaskRegistrySummaryMock(...args), + getInspectableTaskAuditSummary: (...args: unknown[]) => + getInspectableTaskAuditSummaryMock(...args), +})); + vi.mock("../tasks/task-registry.js", () => ({ updateTaskNotifyPolicyById: (...args: unknown[]) => updateTaskNotifyPolicyByIdMock(...args), cancelTaskById: (...args: unknown[]) => cancelTaskByIdMock(...args), @@ -42,6 +56,7 @@ let tasksShowCommand: typeof import("./tasks.js").tasksShowCommand; let tasksNotifyCommand: typeof import("./tasks.js").tasksNotifyCommand; let tasksCancelCommand: typeof import("./tasks.js").tasksCancelCommand; let tasksAuditCommand: typeof import("./tasks.js").tasksAuditCommand; +let tasksMaintenanceCommand: typeof import("./tasks.js").tasksMaintenanceCommand; const taskFixture = { taskId: "task-12345678", @@ -73,6 +88,7 @@ beforeAll(async () => { tasksNotifyCommand, tasksCancelCommand, tasksAuditCommand, + tasksMaintenanceCommand, } = await import("./tasks.js")); }); @@ -96,6 +112,50 @@ describe("tasks commands", () => { inconsistent_timestamps: 0, }, }); + previewTaskRegistryMaintenanceMock.mockReturnValue({ + reconciled: 0, + cleanupStamped: 0, + pruned: 0, + }); + runTaskRegistryMaintenanceMock.mockReturnValue({ + reconciled: 0, + cleanupStamped: 0, + pruned: 0, + }); + getInspectableTaskRegistrySummaryMock.mockReturnValue({ + total: 0, + active: 0, + terminal: 0, + failures: 0, + byStatus: { + queued: 0, + running: 0, + succeeded: 0, + failed: 0, + timed_out: 0, + cancelled: 0, + lost: 0, + }, + byRuntime: { + subagent: 0, + acp: 0, + cli: 0, + cron: 0, + }, + }); + getInspectableTaskAuditSummaryMock.mockReturnValue({ + total: 0, + warnings: 0, + errors: 0, + byCode: { + stale_queued: 0, + stale_running: 0, + lost: 0, + delivery_failed: 0, + missing_cleanup: 0, + inconsistent_timestamps: 0, + }, + }); updateTaskNotifyPolicyByIdMock.mockReturnValue(undefined); cancelTaskByIdMock.mockResolvedValue({ found: false, cancelled: false, reason: "missing" }); getTaskByIdMock.mockReturnValue(undefined); @@ -210,4 +270,126 @@ describe("tasks commands", () => { expect(runtimeLogs.join("\n")).toContain("running task appears stuck"); expect(runtimeLogs.join("\n")).not.toContain("delivery_failed"); }); + + it("previews task maintenance without applying changes", async () => { + previewTaskRegistryMaintenanceMock.mockReturnValue({ + reconciled: 2, + cleanupStamped: 1, + pruned: 3, + }); + getInspectableTaskRegistrySummaryMock.mockReturnValue({ + total: 5, + active: 2, + terminal: 3, + failures: 1, + byStatus: { + queued: 1, + running: 1, + succeeded: 1, + failed: 1, + timed_out: 0, + cancelled: 0, + lost: 1, + }, + byRuntime: { + subagent: 1, + acp: 1, + cli: 1, + cron: 2, + }, + }); + getInspectableTaskAuditSummaryMock.mockReturnValue({ + total: 2, + warnings: 1, + errors: 1, + byCode: { + stale_queued: 0, + stale_running: 1, + lost: 1, + delivery_failed: 0, + missing_cleanup: 0, + inconsistent_timestamps: 0, + }, + }); + + await tasksMaintenanceCommand({}, runtime); + + expect(previewTaskRegistryMaintenanceMock).toHaveBeenCalled(); + expect(runTaskRegistryMaintenanceMock).not.toHaveBeenCalled(); + expect(runtimeLogs[0]).toContain( + "Task maintenance (preview): 2 reconcile · 1 cleanup stamp · 3 prune", + ); + expect(runtimeLogs[1]).toContain( + "Task health: 1 queued · 1 running · 1 audit errors · 1 audit warnings", + ); + expect(runtimeLogs[2]).toContain("Dry run only."); + }); + + it("shows before and after audit health when applying maintenance", async () => { + runTaskRegistryMaintenanceMock.mockReturnValue({ + reconciled: 2, + cleanupStamped: 1, + pruned: 3, + }); + getInspectableTaskRegistrySummaryMock.mockReturnValue({ + total: 4, + active: 2, + terminal: 2, + failures: 1, + byStatus: { + queued: 1, + running: 1, + succeeded: 1, + failed: 0, + timed_out: 0, + cancelled: 0, + lost: 1, + }, + byRuntime: { + subagent: 1, + acp: 1, + cli: 0, + cron: 2, + }, + }); + getInspectableTaskAuditSummaryMock + .mockReturnValueOnce({ + total: 3, + warnings: 2, + errors: 1, + byCode: { + stale_queued: 0, + stale_running: 1, + lost: 1, + delivery_failed: 0, + missing_cleanup: 1, + inconsistent_timestamps: 0, + }, + }) + .mockReturnValueOnce({ + total: 1, + warnings: 1, + errors: 0, + byCode: { + stale_queued: 0, + stale_running: 0, + lost: 1, + delivery_failed: 0, + missing_cleanup: 0, + inconsistent_timestamps: 0, + }, + }); + + await tasksMaintenanceCommand({ apply: true }, runtime); + + expect(previewTaskRegistryMaintenanceMock).not.toHaveBeenCalled(); + expect(runTaskRegistryMaintenanceMock).toHaveBeenCalled(); + expect(runtimeLogs[0]).toContain( + "Task maintenance (applied): 2 reconcile · 1 cleanup stamp · 3 prune", + ); + expect(runtimeLogs[1]).toContain( + "Task health after apply: 1 queued · 1 running · 0 audit errors · 1 audit warnings", + ); + expect(runtimeLogs[2]).toContain("Task health before apply: 1 audit errors · 2 audit warnings"); + }); }); diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index daeaedd479c..21d4634e90b 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -9,6 +9,12 @@ import { type TaskAuditSeverity, } from "../tasks/task-registry.audit.js"; import { cancelTaskById, getTaskById, updateTaskNotifyPolicyById } from "../tasks/task-registry.js"; +import { + getInspectableTaskAuditSummary, + getInspectableTaskRegistrySummary, + previewTaskRegistryMaintenance, + runTaskRegistryMaintenance, +} from "../tasks/task-registry.maintenance.js"; import { reconcileInspectableTasks, reconcileTaskLookupToken, @@ -376,3 +382,51 @@ export async function tasksAuditCommand( runtime.log(line); } } + +export async function tasksMaintenanceCommand( + opts: { json?: boolean; apply?: boolean }, + runtime: RuntimeEnv, +) { + const auditBefore = getInspectableTaskAuditSummary(); + const maintenance = opts.apply ? runTaskRegistryMaintenance() : previewTaskRegistryMaintenance(); + const summary = getInspectableTaskRegistrySummary(); + const auditAfter = opts.apply ? getInspectableTaskAuditSummary() : auditBefore; + + if (opts.json) { + runtime.log( + JSON.stringify( + { + mode: opts.apply ? "apply" : "preview", + maintenance, + tasks: summary, + auditBefore, + auditAfter, + }, + null, + 2, + ), + ); + return; + } + + runtime.log( + info( + `Task maintenance (${opts.apply ? "applied" : "preview"}): ${maintenance.reconciled} reconcile · ${maintenance.cleanupStamped} cleanup stamp · ${maintenance.pruned} prune`, + ), + ); + runtime.log( + info( + `${opts.apply ? "Task health after apply" : "Task health"}: ${summary.byStatus.queued} queued · ${summary.byStatus.running} running · ${auditAfter.errors} audit errors · ${auditAfter.warnings} audit warnings`, + ), + ); + if (opts.apply) { + runtime.log( + info( + `Task health before apply: ${auditBefore.errors} audit errors · ${auditBefore.warnings} audit warnings`, + ), + ); + } + if (!opts.apply) { + runtime.log("Dry run only. Re-run with `openclaw tasks maintenance --apply` to write changes."); + } +} diff --git a/src/tasks/task-registry.audit.ts b/src/tasks/task-registry.audit.ts index 0ff3b2504d2..c012ec19270 100644 --- a/src/tasks/task-registry.audit.ts +++ b/src/tasks/task-registry.audit.ts @@ -35,7 +35,7 @@ export type TaskAuditOptions = { const DEFAULT_STALE_QUEUED_MS = 10 * 60_000; const DEFAULT_STALE_RUNNING_MS = 30 * 60_000; -function createEmptyTaskAuditSummary(): TaskAuditSummary { +export function createEmptyTaskAuditSummary(): TaskAuditSummary { return { total: 0, warnings: 0, diff --git a/src/tasks/task-registry.maintenance.ts b/src/tasks/task-registry.maintenance.ts index e7b0f0379ee..fdd00a9ac65 100644 --- a/src/tasks/task-registry.maintenance.ts +++ b/src/tasks/task-registry.maintenance.ts @@ -1,6 +1,8 @@ import { readAcpSessionEntry } from "../acp/runtime/session-meta.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; +import { listTaskAuditFindings, summarizeTaskAuditFindings } from "./task-registry.audit.js"; +import type { TaskAuditSummary } from "./task-registry.audit.js"; import { deleteTaskRecordById, ensureTaskRegistryReady, @@ -19,6 +21,12 @@ const TASK_SWEEP_INTERVAL_MS = 60_000; let sweeper: NodeJS.Timeout | null = null; +export type TaskRegistryMaintenanceSummary = { + reconciled: number; + cleanupStamped: number; + pruned: number; +}; + function findSessionEntryByKey(store: Record, sessionKey: string): unknown { const direct = store[sessionKey]; if (direct) { @@ -90,6 +98,15 @@ function shouldPruneTerminalTask(task: TaskRecord, now: number): boolean { return now - terminalAt >= TASK_RETENTION_MS; } +function shouldStampCleanupAfter(task: TaskRecord): boolean { + return isTerminalTask(task) && typeof task.cleanupAfter !== "number"; +} + +function resolveCleanupAfter(task: TaskRecord): number { + const terminalAt = task.endedAt ?? task.lastEventAt ?? task.createdAt; + return terminalAt + TASK_RETENTION_MS; +} + function markTaskLost(task: TaskRecord, now: number): TaskRecord { const updated = updateTaskRecordById(task.taskId, { @@ -129,16 +146,44 @@ export function getInspectableTaskRegistrySummary(): TaskRegistrySummary { return summarizeTaskRecords(reconcileInspectableTasks()); } +export function getInspectableTaskAuditSummary(): TaskAuditSummary { + const tasks = reconcileInspectableTasks(); + return summarizeTaskAuditFindings(listTaskAuditFindings({ tasks })); +} + export function reconcileTaskLookupToken(token: string): TaskRecord | undefined { ensureTaskRegistryReady(); const task = resolveTaskForLookupToken(token); return task ? reconcileTaskRecordForOperatorInspection(task) : undefined; } -export function sweepTaskRegistry(): { reconciled: number; pruned: number } { +export function previewTaskRegistryMaintenance(): TaskRegistryMaintenanceSummary { ensureTaskRegistryReady(); const now = Date.now(); let reconciled = 0; + let cleanupStamped = 0; + let pruned = 0; + for (const task of listTaskRecords()) { + if (shouldMarkLost(task, now)) { + reconciled += 1; + continue; + } + if (shouldPruneTerminalTask(task, now)) { + pruned += 1; + continue; + } + if (shouldStampCleanupAfter(task)) { + cleanupStamped += 1; + } + } + return { reconciled, cleanupStamped, pruned }; +} + +export function runTaskRegistryMaintenance(): TaskRegistryMaintenanceSummary { + ensureTaskRegistryReady(); + const now = Date.now(); + let reconciled = 0; + let cleanupStamped = 0; let pruned = 0; for (const task of listTaskRecords()) { if (shouldMarkLost(task, now)) { @@ -150,9 +195,22 @@ export function sweepTaskRegistry(): { reconciled: number; pruned: number } { } if (shouldPruneTerminalTask(task, now) && deleteTaskRecordById(task.taskId)) { pruned += 1; + continue; + } + if ( + shouldStampCleanupAfter(task) && + updateTaskRecordById(task.taskId, { + cleanupAfter: resolveCleanupAfter(task), + }) + ) { + cleanupStamped += 1; } } - return { reconciled, pruned }; + return { reconciled, cleanupStamped, pruned }; +} + +export function sweepTaskRegistry(): TaskRegistryMaintenanceSummary { + return runTaskRegistryMaintenance(); } export function startTaskRegistryMaintenance() { diff --git a/src/tasks/task-registry.test.ts b/src/tasks/task-registry.test.ts index 47520038b12..69c3ba0ddcc 100644 --- a/src/tasks/task-registry.test.ts +++ b/src/tasks/task-registry.test.ts @@ -21,7 +21,14 @@ import { updateTaskRecordById, updateTaskStateByRunId, } from "./task-registry.js"; -import { reconcileInspectableTasks, sweepTaskRegistry } from "./task-registry.maintenance.js"; +import { + getInspectableTaskAuditSummary, + previewTaskRegistryMaintenance, + reconcileInspectableTasks, + runTaskRegistryMaintenance, + sweepTaskRegistry, +} from "./task-registry.maintenance.js"; +import { configureTaskRegistryRuntime } from "./task-registry.store.js"; const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR; const hoisted = vi.hoisted(() => { @@ -832,12 +839,104 @@ describe("task-registry", () => { expect(sweepTaskRegistry()).toEqual({ reconciled: 0, + cleanupStamped: 0, pruned: 1, }); expect(listTaskRecords()).toEqual([]); }); }); + it("previews and repairs missing cleanup timestamps during maintenance", async () => { + await withTempDir({ prefix: "openclaw-task-registry-" }, async (root) => { + process.env.OPENCLAW_STATE_DIR = root; + resetTaskRegistryForTests(); + const now = Date.now(); + configureTaskRegistryRuntime({ + store: { + loadSnapshot: () => + new Map([ + [ + "task-missing-cleanup", + { + taskId: "task-missing-cleanup", + runtime: "cron", + requesterSessionKey: "", + runId: "run-maintenance-cleanup", + task: "Finished cron", + status: "failed", + deliveryStatus: "not_applicable", + notifyPolicy: "silent", + createdAt: now - 120_000, + endedAt: now - 60_000, + lastEventAt: now - 60_000, + }, + ], + ]), + saveSnapshot: () => {}, + }, + }); + + expect(previewTaskRegistryMaintenance()).toEqual({ + reconciled: 0, + cleanupStamped: 1, + pruned: 0, + }); + + expect(runTaskRegistryMaintenance()).toEqual({ + reconciled: 0, + cleanupStamped: 1, + pruned: 0, + }); + expect(getTaskById("task-missing-cleanup")?.cleanupAfter).toBeGreaterThan(now); + }); + }); + + it("summarizes inspectable task audit findings", async () => { + await withTempDir({ prefix: "openclaw-task-registry-" }, async (root) => { + process.env.OPENCLAW_STATE_DIR = root; + resetTaskRegistryForTests(); + const now = Date.now(); + configureTaskRegistryRuntime({ + store: { + loadSnapshot: () => + new Map([ + [ + "task-audit-summary", + { + taskId: "task-audit-summary", + runtime: "acp", + requesterSessionKey: "agent:main:main", + runId: "run-audit-summary", + task: "Hung task", + status: "running", + deliveryStatus: "pending", + notifyPolicy: "done_only", + createdAt: now - 50 * 60_000, + startedAt: now - 40 * 60_000, + lastEventAt: now - 40 * 60_000, + }, + ], + ]), + saveSnapshot: () => {}, + }, + }); + + expect(getInspectableTaskAuditSummary()).toEqual({ + total: 1, + warnings: 0, + errors: 1, + byCode: { + stale_queued: 0, + stale_running: 1, + lost: 0, + delivery_failed: 0, + missing_cleanup: 0, + inconsistent_timestamps: 0, + }, + }); + }); + }); + it("delivers concise state-change updates only when notify policy requests them", async () => { await withTempDir({ prefix: "openclaw-task-registry-" }, async (root) => { process.env.OPENCLAW_STATE_DIR = root;