diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index 35bbbb40f18a..9bc5a9276ef0 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -648,6 +648,35 @@ function appendLegacyCrossAgentTask(taskRunsPath: string): void { } } +function appendLegacyTaskWithObsoleteDeliveryStatus(taskRunsPath: string): void { + const sqlite = requireNodeSqlite(); + const db = new sqlite.DatabaseSync(taskRunsPath); + try { + db.prepare( + ` + INSERT INTO task_runs ( + task_id, runtime, requester_session_key, agent_id, run_id, task, + status, delivery_status, notify_policy, created_at, last_event_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + "legacy-not-requested", + "cron", + "", + "ops", + "legacy-not-requested-run", + "Legacy cancelled task", + "cancelled", + "not-requested", + "silent", + 150, + 160, + ); + } finally { + db.close(); + } +} + async function detectAndRunMigrations(params: { root: string; cfg: OpenClawConfig; @@ -2972,6 +3001,36 @@ describe("doctor legacy state migrations", () => { }); }); + it("normalizes obsolete task delivery status before archiving the legacy sidecar", async () => { + const root = await makeTempRoot(); + const { taskRunsPath } = writeLegacyTaskStateSidecars(root); + appendLegacyTaskWithObsoleteDeliveryStatus(taskRunsPath); + + const result = await autoMigrateLegacyTaskStateSidecars({ + env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toContain("Migrated 2 task registry sidecar rows → shared SQLite state"); + expect(fs.existsSync(taskRunsPath)).toBe(false); + expect(fs.existsSync(`${taskRunsPath}.migrated`)).toBe(true); + + const shared = openOpenClawStateDatabase({ + env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv, + }); + expect( + shared.db + .prepare("SELECT delivery_status FROM task_runs WHERE task_id = ?") + .get("legacy-not-requested"), + ).toEqual({ delivery_status: "not_applicable" }); + + await withStateDir(root, async () => { + const tasks = loadTaskRegistryStateFromSqlite().tasks; + expect(tasks.get("legacy-not-requested")?.deliveryStatus).toBe("not_applicable"); + expect(tasks.get("legacy-task")?.deliveryStatus).toBe("not_applicable"); + }); + }); + it("canonicalizes cross-agent attribution while importing task sidecars", async () => { const root = await makeTempRoot(); const { taskRunsPath } = writeLegacyTaskStateSidecars(root); diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 5d969d7a5d40..d25a0ce4543a 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -796,6 +796,8 @@ function normalizeLegacyTaskRow(row: Record): SqliteBindRow { (childAgentId && persistedAgentId !== childAgentId ? persistedAgentId : "")) : ""); const executorAgentId = requesterAgentId ? childAgentId || persistedAgentId : persistedAgentId; + const deliveryStatus = + row.delivery_status === "not-requested" ? "not_applicable" : row.delivery_status; return { task_id: taskId, runtime, @@ -813,7 +815,7 @@ function normalizeLegacyTaskRow(row: Record): SqliteBindRow { label: legacyBindValue(row.label), task: legacyBindValue(row.task ?? ""), status: legacyBindValue(row.status ?? ""), - delivery_status: legacyBindValue(row.delivery_status ?? ""), + delivery_status: legacyBindValue(deliveryStatus ?? ""), notify_policy: legacyBindValue(row.notify_policy ?? ""), created_at: normalizeLegacySqliteInteger(row.created_at as number | bigint | null) ?? 0, started_at: normalizeLegacySqliteInteger(row.started_at as number | bigint | null), diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index f47e7ea783bb..b834cf6b2953 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -14,6 +14,8 @@ import { import { requireNodeSqlite } from "../infra/node-sqlite.js"; import { listOpenFileDescriptorsForPath } from "../infra/open-file-descriptors.test-support.js"; import { readSqliteNumberPragma } from "../infra/sqlite-pragma.test-support.js"; +import { loadTaskRegistryStateFromSqlite } from "../tasks/task-registry.store.sqlite.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js"; import { closeOpenClawStateDatabaseForTest, @@ -385,6 +387,67 @@ describe("openclaw state database", () => { }); }); + it("normalizes obsolete task delivery statuses in existing state databases", async () => { + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-state-task-delivery-status-" }, + async ({ stateDir }) => { + const database = openOpenClawStateDatabase({ + env: { OPENCLAW_STATE_DIR: stateDir }, + }); + const insert = database.db.prepare( + `INSERT INTO task_runs ( + task_id, runtime, requester_session_key, owner_key, scope_kind, task, status, + delivery_status, notify_policy, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const [taskId, deliveryStatus] of [ + ["obsolete", "not-requested"], + ["canonical", "not_applicable"], + ["pending", "pending"], + ] as const) { + insert.run( + taskId, + "cron", + "", + `system:cron:${taskId}`, + "system", + `Task ${taskId}`, + "cancelled", + deliveryStatus, + "silent", + 100, + ); + } + closeOpenClawStateDatabaseForTest(); + + const readStatuses = () => + openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir } }) + .db.prepare("SELECT task_id, delivery_status FROM task_runs ORDER BY task_id") + .all(); + const expectedStatuses = [ + { task_id: "canonical", delivery_status: "not_applicable" }, + { task_id: "obsolete", delivery_status: "not_applicable" }, + { task_id: "pending", delivery_status: "pending" }, + ]; + + expect(readStatuses()).toEqual(expectedStatuses); + expect( + [...loadTaskRegistryStateFromSqlite().tasks.values()].map((task) => ({ + taskId: task.taskId, + deliveryStatus: task.deliveryStatus, + })), + ).toEqual([ + { taskId: "canonical", deliveryStatus: "not_applicable" }, + { taskId: "obsolete", deliveryStatus: "not_applicable" }, + { taskId: "pending", deliveryStatus: "pending" }, + ]); + + closeOpenClawStateDatabaseForTest(); + expect(readStatuses()).toEqual(expectedStatuses); + closeOpenClawStateDatabaseForTest(); + }, + ); + }); it("rolls back the requester attribution column when its backfill fails", () => { const stateDir = createTempStateDir(); const database = openOpenClawStateDatabase({ diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index 57f5427d9902..c46d37ff2560 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -192,6 +192,19 @@ function repairLegacyTaskAgentAttribution(db: DatabaseSync): void { `); } +function repairLegacyTaskDeliveryStatuses(db: DatabaseSync): void { + if (!tableExists(db, "task_runs") || !tableHasColumn(db, "task_runs", "delivery_status")) { + return; + } + // Successful sidecar imports archive their source, so database open must + // also canonicalize rows already copied by released migrations. + db.exec(` + UPDATE task_runs + SET delivery_status = 'not_applicable' + WHERE delivery_status = 'not-requested'; + `); +} + function hasCanonicalAgentDatabasesPrimaryKey(db: DatabaseSync): boolean { if (!tableExists(db, "agent_databases")) { return true; @@ -920,6 +933,7 @@ function ensureAdditiveStateColumns(db: DatabaseSync): void { if (addedTaskRequesterAgentId) { repairLegacyTaskAgentAttribution(db); } + repairLegacyTaskDeliveryStatuses(db); }); ensureColumn(db, "subagent_runs", "task_name TEXT"); } diff --git a/src/tasks/task-registry.store.test.ts b/src/tasks/task-registry.store.test.ts index 5a745bef834e..ec9e41d5555f 100644 --- a/src/tasks/task-registry.store.test.ts +++ b/src/tasks/task-registry.store.test.ts @@ -7,6 +7,7 @@ import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely, } from "../infra/kysely-sync.js"; +import { createWarnLogCapture } from "../logging/test-helpers/warn-log-capture.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; import { closeOpenClawStateDatabase, @@ -153,6 +154,29 @@ describe("task-registry store runtime", () => { expect(latestSnapshot.tasks.get("task-restored")?.task).toBe("Restored task"); }); + it("logs restore parser failures and keeps the registry empty", async () => { + const warnLogs = createWarnLogCapture("openclaw-task-registry-restore-test"); + const invalidValue = "not-requested"; + try { + configureTaskRegistryRuntime({ + store: { + loadSnapshot: () => { + throw new Error( + `Invalid persisted task delivery status: ${JSON.stringify(invalidValue)}`, + ); + }, + saveSnapshot: () => {}, + }, + }); + + expect(findTaskByRunId("run-restored")).toBeUndefined(); + expect(await warnLogs.findText(invalidValue)).toContain(invalidValue); + expect(getTaskById("task-restored")).toBeUndefined(); + } finally { + warnLogs.cleanup(); + } + }); + it("uses scoped owner lookups for fresh owner task reads", () => { const storedTask = createStoredTask(); const loadSnapshot = vi.fn(() => ({ diff --git a/src/tasks/task-registry.ts b/src/tasks/task-registry.ts index cd5999f980cf..096f4aa33a60 100644 --- a/src/tasks/task-registry.ts +++ b/src/tasks/task-registry.ts @@ -1216,7 +1216,7 @@ function restoreTaskRegistryOnce() { tasks: snapshotTaskRecords(tasks), })); } catch (error) { - log.warn("Failed to restore task registry", { error }); + log.warn("Failed to restore task registry", { error: formatErrorMessage(error) }); } }