Files
fusion/scripts/reconcile-task-state-consistency.mjs
gsxdsm 3a016b1f17 fix(scripts): four FNXC stamps carried hour 26, and main has been red on them (#3010)
## `main` is currently red on `check-fnxc-future-dates`

Four stamps read `2026-07-30-26:10` — an hour that cannot exist.

They're exactly what #2995 taught this gate to catch. That PR landed the
hour validation (`00-23`) *after* #2999 had already merged these four,
so the gate started reporting a defect that was already sitting there
rather than one introduced afterwards. **The guard is working**; nothing
was checking before it.

```
scripts/lib/backend-db.mjs:41
scripts/reconcile-task-state-consistency.mjs:8, :51
scripts/__tests__/reconcile-task-state-consistency.test.mjs:109
```

Corrected by **literal normalisation** — 26:10 on the 30th *is* 02:10 on
the 31st — rather than flattening them to an arbitrary in-range hour.
AGENTS.md specifies `yyyy-MM-dd-hh:mm`, and the stamp exists to give a
readable why-does-this-exist trail, so the ordering is the part worth
preserving.

## The baseline tightening rides along, and it's a date rollover

Stamps written yesterday as `2026-07-31` were future *then* and were
baselined as such. Today they're past, so **176 files ratchet to zero**.
Nobody did anything.

The gate rewrites the baseline as a side effect and exits 0, so leaving
it uncommitted dirties the tree on every subsequent run **for everyone**
— which is why it belongs in this commit rather than a later one.
Re-recording on a decrease is the rule this gate and its siblings
already state.

Worth knowing about the design, since I wrote it: this churn recurs
whenever a day boundary passes with future-dated stamps in the baseline,
and it shrinks only as people stop writing them — which is the behaviour
the gate exists to produce. **93 files still carry a non-zero
allowance**, so the drain isn't finished. If it stays noisy once those
clear, the gate's fail-on-tighten contract is the thing to revisit, not
the stamps.

## Measured

| check | result |
|---|---|
| gate | red before, **exit 0 after**, stable across two consecutive
runs |
| baseline | −176/+25 entries, all date-rollover |
| inert-seam · sql-literal · lane-wiring · census | all green |
| reconciler's own suite | green |

## One correction to a claim I made earlier this session

While investigating I reported the gate as hanging for 600s. It wasn't —
the harness killed the process (exit 144) and the empty output made it
look like a stall. The gate completes in seconds. Noting it because I
nearly filed a performance bug against a healthy script.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:36:21 -07:00

136 lines
5.7 KiB
JavaScript

#!/usr/bin/env node
import process from "node:process";
import { openBackend, importCore } from "./lib/backend-db.mjs";
const DEFAULT_NOTE = "FN-4000 reconciliation: cleared stale transient failure state using TaskStore done-normalization so database and task JSON remain synchronized.";
/*
FNXC:OperatorScriptLaneAssumptions 2026-07-31-02:10:
Both checks ask this task's OWN lanes, because keyed on the literals they fail in BOTH directions.
`hasDoneTransient` gated on `column === "done"`. On a board whose complete lane is named anything
else it NEVER fires, so a finished card still carrying `status:"failed"`, a worktree, a blockedBy or
live recovery counters is never reported and never normalized — the exact stale state FN-4000 exists
to clear.
`failed-status-outside-in-review` gated on `column !== "in-review"`, and fails the OPPOSITE way: on a
renamed board no column equals the literal, so EVERY failed task is flagged. A reconciliation report
listing the whole board is as useless as one listing nothing, and it is the more dangerous of the
two because it looks like the tool is working.
Lanes arrive resolved from the caller rather than being resolved here, so this stays pure and testable
without a database or a built dist.
*/
export function findTaskStateInconsistencies(task, lanes = {}) {
/* DELIBERATE-LITERAL — the degraded default when the caller resolved no lanes. */
const completeColumn = lanes.complete ?? "done";
/* DELIBERATE-LITERAL — as above. */
const reviewColumn = lanes.review ?? "in-review";
const findings = [];
const hasDoneTransient = task.column === completeColumn && (
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 !== reviewColumn) {
findings.push("failed-status-outside-in-review");
}
return findings;
}
/*
FNXC:OperatorScriptLaneAssumptions 2026-07-31-02:10:
`resolveLanes` is INJECTED, not built here, and `main` below supplies the real one.
Resolving inside this function would drag `importCore()` — and therefore a built `packages/core/dist`
— into every unit test of a pure reconciliation loop. Injection keeps the tests database-free and
build-free while the production entry point still wires a real resolver, which is the wiring that
matters: an optional lane parameter no caller fills is the inert shape this migration keeps finding.
*/
export async function runReconciliation({ store, dryRun = true, noteByTaskId = {}, resolveLanes = null }) {
const tasks = await store.listTasks({ includeArchived: false });
const findings = [];
const actions = [];
for (const task of tasks) {
const lanes = resolveLanes ? ((await resolveLanes(task.id)) ?? {}) : {};
const issues = findTaskStateInconsistencies(task, lanes);
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 === (lanes.complete ?? "done")) {
/* Its OWN column: this move exists to trigger the store's done-normalization, so naming the
destination by literal was only ever a way of spelling "where it already is". */
await store.moveTask(task.id, task.column);
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];
}
async function buildLaneResolver(store) {
const { resolveTaskLifecycleColumns } = await importCore();
/* One resolution per WORKFLOW, not per task — the cache is what keeps this loop cheap on a big board. */
const irCache = new Map();
return (taskId) => resolveTaskLifecycleColumns(store, taskId, irCache);
}
export async function main(argv = process.argv.slice(2), deps = {}) {
const dryRun = !argv.includes("--apply");
const projectDir = readFlagValue(argv, "--project-dir") ?? process.cwd();
const backend = deps.store ? undefined : await openBackend(projectDir);
const store = deps.store ?? backend.store;
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.",
};
try {
/* FNXC:PostgresOperationalScripts 2026-07-14-18:18: Consistency reconciliation must inspect and repair the authoritative PostgreSQL rows. */
/* Wired only when we opened a real backend: a caller injecting its own store (tests) has no
staged dist to import, and falls back to the documented legacy literals. */
const resolveLanes = deps.resolveLanes ?? (backend ? await buildLaneResolver(store) : null);
const result = await runReconciliation({ store, dryRun, noteByTaskId, resolveLanes });
console.log(JSON.stringify({ dryRun, ...result }, null, 2));
return 0;
} finally {
await backend?.shutdown();
}
}
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;
});
}