fix(tasks): recover terminal lost cron rows (#86088)
Some checks are pending
CI / runner-admission (push) Waiting to run
CI / preflight (push) Blocked by required conditions
CI / security-fast (push) Blocked by required conditions
CI / pnpm-store-warmup (push) Blocked by required conditions
CI / build-artifacts (push) Blocked by required conditions
CI / (push) Blocked by required conditions
CI / -1 (push) Blocked by required conditions
CI / -2 (push) Blocked by required conditions
CI / checks-node-compat-node22 (push) Blocked by required conditions
CI / -3 (push) Blocked by required conditions
CI / check-bundled-channel-config-metadata (push) Blocked by required conditions
CI / check-dependencies (push) Blocked by required conditions
CI / check-guards (push) Blocked by required conditions
CI / check-lint (push) Blocked by required conditions
Workflow Sanity / actionlint (push) Waiting to run
CI / check-prod-types (push) Blocked by required conditions
CI / check-shrinkwrap (push) Blocked by required conditions
CI / check-test-types (push) Blocked by required conditions
CI / check-additional-boundaries-a (push) Blocked by required conditions
CI / check-additional-boundaries-bcd (push) Blocked by required conditions
CI / check-additional-extension-bundled (push) Blocked by required conditions
CI / check-additional-extension-channels (push) Blocked by required conditions
CI / check-additional-extension-package-boundary (push) Blocked by required conditions
CI / check-additional-runtime-topology-architecture (push) Blocked by required conditions
CI / check-session-accessor-boundary (push) Blocked by required conditions
CI / check-session-transcript-reader-boundary (push) Blocked by required conditions
CI / check-docs (push) Blocked by required conditions
CI / skills-python (push) Blocked by required conditions
CI / -4 (push) Blocked by required conditions
CI / -5 (push) Blocked by required conditions
CI / macos-swift (push) Blocked by required conditions
CI / ios-build (push) Blocked by required conditions
CI / -6 (push) Blocked by required conditions
CI / ci-timings-summary (push) Blocked by required conditions
ClawSweeper Dispatch / dispatch (push) Waiting to run
CodeQL / Security High (actions) (push) Waiting to run
CodeQL / Security High (channel-runtime-boundary) (push) Waiting to run
CodeQL / Security High (core-auth-secrets) (push) Waiting to run
CodeQL / Security High (mcp-process-tool-boundary) (push) Waiting to run
CodeQL / Security High (network-ssrf-boundary) (push) Waiting to run
CodeQL / Security High (plugin-trust-boundary) (push) Waiting to run
Docs Sync Publish Repo / sync-publish-repo (push) Waiting to run
Docs / docs (push) Waiting to run
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Waiting to run
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Blocked by required conditions
Plugin Init Scaffold Validation / Validate provider scaffold (push) Waiting to run
Plugin NPM Release / preview_plugins_npm (push) Waiting to run
Plugin NPM Release / Validate release publish approval (push) Blocked by required conditions
Plugin NPM Release / preview_plugin_pack (push) Blocked by required conditions
Plugin NPM Release / publish_plugins_npm (push) Blocked by required conditions
Workflow Sanity / no-tabs (push) Waiting to run
Workflow Sanity / generated-doc-baselines (push) Waiting to run

* fix(tasks): recover terminal lost cron rows

* fix(tasks): require backing-session lost recovery signal

* fix(tasks): satisfy has-own lint rule

---------

Co-authored-by: Altay <altay@hey.com>
This commit is contained in:
Andi Liao 2026-06-29 20:32:47 +08:00 committed by GitHub
parent 355c43fe0c
commit be94853de0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 196 additions and 7 deletions

View file

@ -152,11 +152,17 @@ function createTaskRegistryMaintenanceHarness(params: {
status: patch.status,
endedAt: patch.endedAt,
lastEventAt: patch.lastEventAt ?? patch.endedAt,
...(patch.error !== undefined ? { error: patch.error } : {}),
...(patch.terminalSummary !== undefined
? { terminalSummary: patch.terminalSummary ?? undefined }
: {}),
} satisfies TaskRecord;
if (Object.hasOwn(patch, "error")) {
if (patch.error === undefined) {
delete next.error;
} else {
next.error = patch.error;
}
}
currentTasks.set(patch.taskId, next);
return next;
},
@ -532,6 +538,134 @@ describe("task-registry maintenance issue #60299", () => {
expectTaskStatus(currentTasks, task.taskId, "lost");
});
it("recovers terminal lost cron tasks from durable run logs", async () => {
const startedAt = Date.now() - GRACE_EXPIRED_MS;
const task = makeStaleTask({
runtime: "cron",
sourceId: "cron-job-terminal-lost-ok",
runId: `cron:cron-job-terminal-lost-ok:${startedAt}`,
status: "lost",
error: "backing session missing",
startedAt,
endedAt: startedAt + 60_000,
lastEventAt: startedAt + 60_000,
cleanupAfter: Date.now() + 60_000,
});
const { currentTasks } = createTaskRegistryMaintenanceHarness({
tasks: [task],
cronRunLogEntries: {
"cron-job-terminal-lost-ok": [
{
ts: startedAt + 1250,
jobId: "cron-job-terminal-lost-ok",
action: "finished",
status: "ok",
summary: "done",
runAtMs: startedAt,
durationMs: 1250,
},
],
},
});
const reconciled = reconcileInspectableTasks();
expect(reconciled).toEqual([
expect.objectContaining({
taskId: task.taskId,
status: "succeeded",
endedAt: startedAt + 1250,
terminalSummary: "done",
}),
]);
expect(reconciled[0]).not.toHaveProperty("error");
expect(previewTaskRegistryMaintenance()).toMatchObject({ reconciled: 0, recovered: 1 });
expect(await runTaskRegistryMaintenance()).toMatchObject({ reconciled: 0, recovered: 1 });
const recoveredTask = currentTasks.get(task.taskId);
expect(recoveredTask).toMatchObject({
status: "succeeded",
endedAt: startedAt + 1250,
terminalSummary: "done",
});
expect(recoveredTask).not.toHaveProperty("error");
});
it("does not recover terminal lost cron tasks without a backing-session error", async () => {
const startedAt = Date.now() - GRACE_EXPIRED_MS;
const task = makeStaleTask({
runtime: "cron",
sourceId: "cron-job-terminal-lost-no-error",
runId: `cron:cron-job-terminal-lost-no-error:${startedAt}`,
status: "lost",
startedAt,
endedAt: startedAt + 60_000,
lastEventAt: startedAt + 60_000,
cleanupAfter: Date.now() + 60_000,
});
const { currentTasks } = createTaskRegistryMaintenanceHarness({
tasks: [task],
cronRunLogEntries: {
"cron-job-terminal-lost-no-error": [
{
ts: startedAt + 1250,
jobId: "cron-job-terminal-lost-no-error",
action: "finished",
status: "ok",
summary: "done",
runAtMs: startedAt,
durationMs: 1250,
},
],
},
});
expect(previewTaskRegistryMaintenance()).toMatchObject({ recovered: 0 });
expect(await runTaskRegistryMaintenance()).toMatchObject({ recovered: 0 });
expect(currentTasks.get(task.taskId)).toMatchObject({
status: "lost",
});
});
it("does not recover terminal lost cron tasks with non-backing-session errors", async () => {
const startedAt = Date.now() - GRACE_EXPIRED_MS;
const task = makeStaleTask({
runtime: "cron",
sourceId: "cron-job-terminal-lost-other-error",
runId: `cron:cron-job-terminal-lost-other-error:${startedAt}`,
status: "lost",
error: "operator marked lost",
startedAt,
endedAt: startedAt + 60_000,
lastEventAt: startedAt + 60_000,
cleanupAfter: Date.now() + 60_000,
});
const { currentTasks } = createTaskRegistryMaintenanceHarness({
tasks: [task],
cronRunLogEntries: {
"cron-job-terminal-lost-other-error": [
{
ts: startedAt + 1250,
jobId: "cron-job-terminal-lost-other-error",
action: "finished",
status: "ok",
summary: "done",
runAtMs: startedAt,
durationMs: 1250,
},
],
},
});
expect(previewTaskRegistryMaintenance()).toMatchObject({ recovered: 0 });
expect(await runTaskRegistryMaintenance()).toMatchObject({ recovered: 0 });
expect(currentTasks.get(task.taskId)).toMatchObject({
status: "lost",
error: "operator marked lost",
});
});
it("recovers interrupted cron tasks from durable cron job state when run logs are absent", async () => {
const startedAt = Date.now() - GRACE_EXPIRED_MS;
const task = makeStaleTask({

View file

@ -357,6 +357,14 @@ function isTimeoutCronError(error: string | undefined): boolean {
return error === "cron: job execution timed out";
}
function isRecoverableLostCronTask(task: TaskRecord): boolean {
if (task.status !== "lost") {
return false;
}
const error = task.error?.trim().toLowerCase();
return Boolean(error?.includes("backing session missing"));
}
function mapCronTerminalStatus(status: unknown, error?: string): CronTerminalRecovery["status"] {
if (status === "ok" || status === "skipped") {
return "succeeded";
@ -454,7 +462,7 @@ function resolveDurableCronTaskRecovery(
task: TaskRecord,
context: CronRecoveryContext,
): CronTerminalRecovery | undefined {
if (task.runtime !== "cron" || !isActiveTask(task)) {
if (task.runtime !== "cron" || (!isActiveTask(task) && !isRecoverableLostCronTask(task))) {
return undefined;
}
const execution = parseCronExecutionId(task);
@ -808,7 +816,7 @@ function markTaskRecovered(task: TaskRecord, recovery: CronTerminalRecovery): Ta
status: recovery.status,
endedAt: recovery.endedAt,
lastEventAt: recovery.lastEventAt,
...(recovery.error !== undefined ? { error: recovery.error } : {}),
error: recovery.error,
...(recovery.terminalSummary !== undefined
? { terminalSummary: recovery.terminalSummary }
: {}),
@ -823,11 +831,14 @@ function projectTaskRecovered(task: TaskRecord, recovery: CronTerminalRecovery):
status: recovery.status,
endedAt: recovery.endedAt,
lastEventAt: recovery.lastEventAt,
...(recovery.error !== undefined ? { error: recovery.error } : {}),
error: recovery.error,
...(recovery.terminalSummary !== undefined
? { terminalSummary: recovery.terminalSummary }
: {}),
};
if (recovery.error === undefined) {
delete projected.error;
}
return {
...projected,
...(typeof projected.cleanupAfter === "number"

View file

@ -616,6 +616,44 @@ describe("task-registry", () => {
});
});
it("clears terminal errors when explicitly updated without an error", async () => {
await withTaskRegistryTempDir(async (root) => {
process.env.OPENCLAW_STATE_DIR = root;
resetTaskRegistryForTests();
const task = createTaskRecord({
runtime: "cron",
ownerKey: "system:cron:test",
scopeKind: "system",
runId: "run-terminal-error-clear",
task: "Recover cron task",
status: "running",
deliveryStatus: "not_applicable",
startedAt: 100,
});
markTaskTerminalById({
taskId: task.taskId,
status: "failed",
endedAt: 200,
error: "backing session missing",
});
markTaskTerminalById({
taskId: task.taskId,
status: "succeeded",
endedAt: 250,
error: undefined,
});
const recoveredTask = getTaskById(task.taskId);
expect(recoveredTask).toMatchObject({
status: "succeeded",
endedAt: 250,
});
expect(recoveredTask).not.toHaveProperty("error");
});
});
it("keeps stronger run-scoped terminal states when a late success arrives", async () => {
await withTaskRegistryTempDir(async () => {
resetTaskRegistryMemoryForTest();

View file

@ -1180,6 +1180,9 @@ function updateTask(taskId: string, patch: Partial<TaskRecord>): TaskRecord | nu
return null;
}
const next = normalizeTaskTimestamps({ ...current, ...patch });
if (Object.hasOwn(patch, "error") && patch.error === undefined) {
delete next.error;
}
if (isTerminalTaskStatus(next.status) && typeof next.cleanupAfter !== "number") {
next.cleanupAfter = resolveTaskCleanupAfter({
...next,
@ -1553,11 +1556,10 @@ export function markTaskTerminalById(params: {
terminalOutcome?: TaskTerminalOutcome | null;
}): TaskRecord | null {
ensureTaskRegistryReady();
return updateTask(params.taskId, {
const patch: Partial<TaskRecord> = {
status: params.status,
endedAt: params.endedAt,
lastEventAt: params.lastEventAt ?? params.endedAt,
...(params.error !== undefined ? { error: params.error } : {}),
...(params.terminalSummary !== undefined
? { terminalSummary: normalizeTaskSummary(params.terminalSummary) }
: {}),
@ -1569,7 +1571,11 @@ export function markTaskTerminalById(params: {
}),
}
: {}),
});
};
if (Object.hasOwn(params, "error")) {
patch.error = params.error;
}
return updateTask(params.taskId, patch);
}
export function markTaskLostById(params: {