## `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>
136 lines
5.7 KiB
JavaScript
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;
|
|
});
|
|
}
|