mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user