fix(tasks): repair legacy delivery statuses (#103946)

(cherry picked from commit 91ac7ca700)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Bek
2026-07-10 16:04:45 -07:00
committed by Vincent Koc
parent d670d3fdb5
commit fa798ed24a
6 changed files with 164 additions and 2 deletions
@@ -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);
+3 -1
View File
@@ -796,6 +796,8 @@ function normalizeLegacyTaskRow(row: Record<string, unknown>): 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<string, unknown>): 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),
+63
View File
@@ -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({
+14
View File
@@ -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");
}
+24
View File
@@ -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(() => ({
+1 -1
View File
@@ -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) });
}
}