feat(FN-4194): add reconciliation script for identity resolution

Adds a reconciliation script for FN-3909 identity issues, includes comprehensive tests verifying the workflow, and documents the process in the storage guide.

Fusion-Task-Id: FN-4194
This commit is contained in:
Fusion
2026-05-12 15:56:26 -07:00
committed by gsxdsm
parent 55663219c1
commit 0bca14fb72
3 changed files with 432 additions and 0 deletions

View File

@@ -0,0 +1,234 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
main,
RECONCILIATION_ACTION,
} from "../reconcile-fn-3909-identity.mjs";
const STALE_TITLE = "Implement heartbeat scope discipline for default agent promp";
const STALE_DESCRIPTION = "Pick up FN-3884 (Heartbeat scope discipline for default agent prompts) — it's fully unblocked, spec-approved, size S. Update HEARTBEAT_PROCEDURE and HEARTBEAT_NO_TASK_PROCEDURE constants with scope-discipline guidance.";
const CANONICAL_HEADING = "# Task: FN-3909 - Restore icons + width-aware labels on agent card buttons\n";
const cloneValue = globalThis.structuredClone
? (value) => globalThis.structuredClone(value)
: (value) => JSON.parse(JSON.stringify(value));
function createTempDir() {
return mkdtempSync(path.join(os.tmpdir(), "fn-4194-"));
}
function writeJson(filePath, value) {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, "utf8"));
}
function createTaskFixture() {
return {
id: "FN-3909",
title: STALE_TITLE,
description: STALE_DESCRIPTION,
column: "done",
log: [{ timestamp: "2026-05-10T05:40:52.625Z", action: "Task created" }],
steps: [{ name: "Preflight", status: "done" }],
mergeDetails: {
commitSha: "3cdc2d5a5d7525a2e142f351dbdc6353892be2f4",
mergeCommitMessage: "Added a details button icon to agent cards in the AgentsView with responsive container query styling for action labels, accompanied by regression tests.",
mergeConfirmed: true,
},
modifiedFiles: ["packages/dashboard/app/components/AgentsView.tsx"],
createdAt: "2026-05-10T05:40:52.625Z",
updatedAt: "2026-05-12T02:28:16.959Z",
};
}
function createFixture({ promptHeading = CANONICAL_HEADING, rawPrompt, task = createTaskFixture() } = {}) {
const root = createTempDir();
const fusionDir = path.join(root, ".fusion");
const taskDir = path.join(fusionDir, "tasks", "FN-3909");
mkdirSync(taskDir, { recursive: true });
writeFileSync(
path.join(taskDir, "PROMPT.md"),
rawPrompt ?? `${promptHeading}\n## Mission\n\nFix two related defects in AgentsView.\n`,
);
writeJson(path.join(taskDir, "task.json"), task);
const unrelatedTask = {
id: "FN-9999",
title: "Unrelated task",
description: "Should stay untouched",
column: "done",
log: [{ timestamp: "2026-05-12T00:00:00.000Z", action: "Task created" }],
createdAt: "2026-05-12T00:00:00.000Z",
updatedAt: "2026-05-12T00:00:00.000Z",
};
const tasks = new Map([
[task.id, cloneValue(task)],
[unrelatedTask.id, cloneValue(unrelatedTask)],
]);
const calls = { updateTask: 0, logEntry: 0 };
const store = {
calls,
async getTask(id) {
const taskState = tasks.get(id);
return taskState ? cloneValue(taskState) : null;
},
async updateTask(id, updates) {
calls.updateTask += 1;
const taskState = tasks.get(id);
assert.ok(taskState, `missing task ${id}`);
Object.assign(taskState, updates, { updatedAt: "2026-05-12T12:00:00.000Z" });
if (id === "FN-3909") {
writeJson(path.join(taskDir, "task.json"), taskState);
}
return cloneValue(taskState);
},
async logEntry(id, action, outcome) {
calls.logEntry += 1;
const taskState = tasks.get(id);
assert.ok(taskState, `missing task ${id}`);
taskState.log.push({ timestamp: "2026-05-12T12:00:01.000Z", action, outcome });
taskState.updatedAt = "2026-05-12T12:00:01.000Z";
if (id === "FN-3909") {
writeJson(path.join(taskDir, "task.json"), taskState);
}
return cloneValue(taskState);
},
};
return {
root,
taskDir,
taskPath: path.join(taskDir, "task.json"),
promptPath: path.join(taskDir, "PROMPT.md"),
store,
calls,
tasks,
cleanup() {
rmSync(root, { recursive: true, force: true });
},
};
}
function comparableTask(task) {
const clone = cloneValue(task);
delete clone.updatedAt;
return clone;
}
test("dry-run reports the diff without mutating the task", async () => {
const fixture = createFixture();
try {
const beforeTaskJson = readFileSync(fixture.taskPath, "utf8");
const unrelatedBefore = comparableTask(fixture.tasks.get("FN-9999"));
const result = await main(["--project-root", fixture.root], { store: fixture.store });
assert.equal(result.status, "would-apply");
assert.equal(result.dryRun, true);
assert.equal(result.diff.before.title, STALE_TITLE);
assert.match(result.diff.after.title, /Restore icons/);
assert.equal(fixture.calls.updateTask, 0);
assert.equal(fixture.calls.logEntry, 0);
assert.equal(readFileSync(fixture.taskPath, "utf8"), beforeTaskJson);
assert.deepEqual(comparableTask(fixture.tasks.get("FN-9999")), unrelatedBefore);
} finally {
fixture.cleanup();
}
});
test("apply updates only title, description, and log on FN-3909", async () => {
const fixture = createFixture();
try {
const before = readJson(fixture.taskPath);
const unrelatedBefore = comparableTask(fixture.tasks.get("FN-9999"));
const result = await main(["--project-root", fixture.root, "--apply"], { store: fixture.store });
const after = readJson(fixture.taskPath);
assert.equal(result.status, "applied");
assert.equal(fixture.calls.updateTask, 1);
assert.equal(fixture.calls.logEntry, 1);
assert.equal(after.title, "Restore icons + width-aware labels on agent card buttons");
assert.match(after.description, /AgentsView card action buttons/);
assert.equal(after.log.at(-1).action, RECONCILIATION_ACTION);
assert.match(after.log.at(-1).outcome, /FN-4194/);
const beforeComparable = comparableTask(before);
const afterComparable = comparableTask(after);
beforeComparable.title = afterComparable.title;
beforeComparable.description = afterComparable.description;
beforeComparable.log = afterComparable.log;
assert.deepEqual(afterComparable, beforeComparable);
assert.deepEqual(comparableTask(fixture.tasks.get("FN-9999")), unrelatedBefore);
} finally {
fixture.cleanup();
}
});
test("second apply is a no-op once reconciliation marker exists", async () => {
const fixture = createFixture();
try {
await main(["--project-root", fixture.root, "--apply"], { store: fixture.store });
const afterFirstApply = readFileSync(fixture.taskPath, "utf8");
const result = await main(["--project-root", fixture.root, "--apply"], { store: fixture.store });
assert.equal(result.status, "noop");
assert.equal(fixture.calls.updateTask, 1);
assert.equal(fixture.calls.logEntry, 1);
assert.equal(readFileSync(fixture.taskPath, "utf8"), afterFirstApply);
} finally {
fixture.cleanup();
}
});
test("refuses when the row already matches the prompt heading without reconciliation marker", async () => {
const task = createTaskFixture();
task.title = "Restore icons + width-aware labels on agent card buttons";
task.description = "Already canonical but not reconciled.";
const fixture = createFixture({ task });
try {
await assert.rejects(
main(["--project-root", fixture.root, "--apply"], { store: fixture.store }),
/already matches PROMPT heading but no FN-4194 reconciliation marker is present/,
);
assert.equal(fixture.calls.updateTask, 0);
assert.equal(fixture.calls.logEntry, 0);
} finally {
fixture.cleanup();
}
});
test("refuses when PROMPT heading is missing or unexpected", async (t) => {
await t.test("missing heading", async () => {
const fixture = createFixture({ rawPrompt: "No markdown heading here.\n\nJust body text.\n" });
try {
await assert.rejects(
main(["--project-root", fixture.root, "--apply"], { store: fixture.store }),
/PROMPT.md is missing a first heading/,
);
} finally {
fixture.cleanup();
}
});
await t.test("unexpected heading", async () => {
const fixture = createFixture({ promptHeading: "# Task: FN-3909 - Unexpected heading\n" });
try {
await assert.rejects(
main(["--project-root", fixture.root, "--apply"], { store: fixture.store }),
/unexpected PROMPT heading/,
);
} finally {
fixture.cleanup();
}
});
});