feat(FN-4000): add task state reconciliation script to fix stuck or inconsi
Added a task state reconciliation script (`scripts/reconcile-task-state-consistency.mjs`) with tests to detect and resolve inconsistencies between the task database and git worktree status, with support for explicit project directories and documented usage in the task management docs. Fusion-Task-Id: FN-4000
This commit is contained in:
106
scripts/__tests__/reconcile-task-state-consistency.test.mjs
Normal file
106
scripts/__tests__/reconcile-task-state-consistency.test.mjs
Normal file
@@ -0,0 +1,106 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { findTaskStateInconsistencies, runReconciliation } from "../reconcile-task-state-consistency.mjs";
|
||||
|
||||
function createStore(tasks) {
|
||||
const state = new Map(tasks.map((task) => [task.id, { ...task }]));
|
||||
const calls = { moveTask: 0, logEntry: 0 };
|
||||
|
||||
return {
|
||||
calls,
|
||||
async listTasks() {
|
||||
return Array.from(state.values()).map((task) => ({ ...task }));
|
||||
},
|
||||
async moveTask(id, toColumn) {
|
||||
calls.moveTask += 1;
|
||||
const task = state.get(id);
|
||||
assert.ok(task);
|
||||
assert.equal(toColumn, "done");
|
||||
if (task.column === "done") {
|
||||
task.status = undefined;
|
||||
task.error = undefined;
|
||||
task.worktree = undefined;
|
||||
task.blockedBy = undefined;
|
||||
task.recoveryRetryCount = undefined;
|
||||
task.nextRecoveryAt = undefined;
|
||||
}
|
||||
return { ...task };
|
||||
},
|
||||
async logEntry(id, action, outcome) {
|
||||
calls.logEntry += 1;
|
||||
assert.equal(action, "FN-4000 reconciliation");
|
||||
assert.match(outcome, /FN-4000 reconciliation/);
|
||||
return { id };
|
||||
},
|
||||
getTask(id) {
|
||||
return state.get(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("detects known bad done/failed fixture", () => {
|
||||
const issues = findTaskStateInconsistencies({
|
||||
id: "FN-X",
|
||||
column: "done",
|
||||
status: "failed",
|
||||
error: "oops",
|
||||
worktree: "wt",
|
||||
});
|
||||
|
||||
assert.deepEqual(issues, [
|
||||
"done-task-has-transient-failure-state",
|
||||
"failed-status-outside-in-review",
|
||||
]);
|
||||
});
|
||||
|
||||
test("dry-run reports inconsistency without mutating", async () => {
|
||||
const store = createStore([
|
||||
{ id: "FN-1", column: "done", status: "failed", error: "bad" },
|
||||
{ id: "FN-2", column: "in-review", status: "failed" },
|
||||
]);
|
||||
|
||||
const result = await runReconciliation({ store, dryRun: true });
|
||||
|
||||
assert.equal(result.findings.length, 1);
|
||||
assert.equal(result.findings[0].taskId, "FN-1");
|
||||
assert.equal(store.calls.moveTask, 0);
|
||||
assert.equal(store.calls.logEntry, 0);
|
||||
assert.equal(store.getTask("FN-1").status, "failed");
|
||||
});
|
||||
|
||||
test("apply reconciles done task and emits exactly one note", async () => {
|
||||
const store = createStore([
|
||||
{
|
||||
id: "FN-3990",
|
||||
column: "done",
|
||||
status: "failed",
|
||||
error: "stale",
|
||||
worktree: "worktrees/old",
|
||||
blockedBy: "FN-1",
|
||||
recoveryRetryCount: 2,
|
||||
nextRecoveryAt: "2026-05-11T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await runReconciliation({
|
||||
store,
|
||||
dryRun: false,
|
||||
noteByTaskId: {
|
||||
"FN-3990": "FN-4000 reconciliation: custom note",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.findings.length, 1);
|
||||
assert.equal(result.actions[0].action, "reconciled");
|
||||
assert.equal(store.calls.moveTask, 1);
|
||||
assert.equal(store.calls.logEntry, 1);
|
||||
|
||||
const task = store.getTask("FN-3990");
|
||||
assert.equal(task.status, undefined);
|
||||
assert.equal(task.error, undefined);
|
||||
assert.equal(task.worktree, undefined);
|
||||
assert.equal(task.blockedBy, undefined);
|
||||
assert.equal(task.recoveryRetryCount, undefined);
|
||||
assert.equal(task.nextRecoveryAt, undefined);
|
||||
});
|
||||
90
scripts/reconcile-task-state-consistency.mjs
Normal file
90
scripts/reconcile-task-state-consistency.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
import process from "node:process";
|
||||
|
||||
const DEFAULT_NOTE = "FN-4000 reconciliation: cleared stale transient failure state using TaskStore done-normalization so database and task JSON remain synchronized.";
|
||||
|
||||
export function findTaskStateInconsistencies(task) {
|
||||
const findings = [];
|
||||
const hasDoneTransient = task.column === "done" && (
|
||||
task.status === "failed"
|
||||
|| Boolean(task.error)
|
||||
|| Boolean(task.worktree)
|
||||
|| Boolean(task.blockedBy)
|
||||
|| typeof task.recoveryRetryCount === "number"
|
||||
|| Boolean(task.nextRecoveryAt)
|
||||
);
|
||||
|
||||
if (hasDoneTransient) {
|
||||
findings.push("done-task-has-transient-failure-state");
|
||||
}
|
||||
|
||||
if (task.status === "failed" && task.column !== "in-review") {
|
||||
findings.push("failed-status-outside-in-review");
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export async function runReconciliation({ store, dryRun = true, noteByTaskId = {} }) {
|
||||
const tasks = await store.listTasks({ includeArchived: false });
|
||||
const findings = [];
|
||||
const actions = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
const issues = findTaskStateInconsistencies(task);
|
||||
if (issues.length === 0) continue;
|
||||
|
||||
findings.push({ taskId: task.id, column: task.column, status: task.status ?? null, issues });
|
||||
|
||||
if (dryRun) {
|
||||
actions.push({ taskId: task.id, action: "would-reconcile", issues });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (task.column === "done") {
|
||||
await store.moveTask(task.id, "done");
|
||||
const note = noteByTaskId[task.id] ?? DEFAULT_NOTE;
|
||||
await store.logEntry(task.id, "FN-4000 reconciliation", note);
|
||||
actions.push({ taskId: task.id, action: "reconciled", issues });
|
||||
continue;
|
||||
}
|
||||
|
||||
actions.push({ taskId: task.id, action: "flagged-no-safe-auto-fix", issues });
|
||||
}
|
||||
|
||||
return { findings, actions };
|
||||
}
|
||||
|
||||
function readFlagValue(argv, flag) {
|
||||
const index = argv.indexOf(flag);
|
||||
if (index < 0) return undefined;
|
||||
return argv[index + 1];
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2), deps = {}) {
|
||||
const dryRun = !argv.includes("--apply");
|
||||
const projectDir = readFlagValue(argv, "--project-dir") ?? process.cwd();
|
||||
const store = deps.store ?? (await (async () => {
|
||||
const { TaskStore } = await import("../packages/core/dist/index.js");
|
||||
const taskStore = new TaskStore(projectDir);
|
||||
await taskStore.init();
|
||||
return taskStore;
|
||||
})());
|
||||
|
||||
const noteByTaskId = {
|
||||
"FN-3990": "FN-4000 reconciliation: cleared stale failed-state metadata after shipped lineage work landed in b89471aa5 and dashboard/doc follow-through completed in FN-3998.",
|
||||
};
|
||||
|
||||
const result = await runReconciliation({ store, dryRun, noteByTaskId });
|
||||
console.log(JSON.stringify({ dryRun, ...result }, null, 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().then((code) => {
|
||||
process.exitCode = code;
|
||||
}).catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user