feat(FN-4192): add task ID integrity detection and dashboard alerts

Implements task ID integrity detection across the stack (FN-4192): a new `task-id-integrity` module in core provides detector logic wired into the store, integrity health is surfaced via the dashboard API and legacy endpoint, and a banner component surfaces alerts in the UI — backed by comprehensive

Fusion-Task-Id: FN-4192
This commit is contained in:
Fusion
2026-05-12 19:06:19 -07:00
committed by gsxdsm
parent d0b1fd01ae
commit a3478718d7
17 changed files with 1217 additions and 7 deletions

View File

@@ -0,0 +1,111 @@
import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Database } from "../db.js";
import { TaskStore } from "../store.js";
import { makeTmpDir } from "./store-test-helpers.js";
async function seedIntegrityPrecondition(rootDir: string): Promise<void> {
const fusionDir = join(rootDir, ".fusion");
await mkdir(fusionDir, { recursive: true });
const db = new Database(fusionDir);
db.init();
const now = new Date().toISOString();
db.prepare(
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, '', 'todo', ?, ?)",
).run("FN-100", now, now);
db.prepare(
"INSERT INTO distributed_task_id_state (prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt) VALUES (?, ?, ?, ?, ?)",
).run("FN", 100, 0, null, now);
db.close();
}
describe("TaskStore task ID integrity wiring", () => {
let rootDir = "";
let globalDir = "";
beforeEach(() => {
rootDir = makeTmpDir();
globalDir = makeTmpDir();
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
it("constructs cleanly and exposes an ok integrity report by default", async () => {
const store = new TaskStore(rootDir, globalDir);
await store.init();
const report = store.getTaskIdIntegrityReport();
expect(report.status).toBe("ok");
expect(report.anomalies).toEqual([]);
expect(report.checkedAt).toEqual(expect.any(String));
store.close();
});
it("logs a structured core error and exposes anomaly status when startup detects corruption preconditions", async () => {
await seedIntegrityPrecondition(rootDir);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const store = new TaskStore(rootDir, globalDir);
await store.init();
const report = store.getTaskIdIntegrityReport();
expect(report.status).toBe("anomaly");
expect(report.anomalies).toContainEqual(
expect.objectContaining({
kind: "next_sequence_at_or_below_used",
prefix: "FN",
affectedIds: ["FN-100"],
}),
);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("[core] [task-id-integrity] anomaly detected"),
expect.objectContaining({
anomalies: expect.arrayContaining([
expect.objectContaining({
kind: "next_sequence_at_or_below_used",
affectedIds: ["FN-100"],
}),
]),
}),
);
store.close();
});
it("refreshTaskIdIntegrityReport picks up newly introduced anomalies", async () => {
const store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
const db = store.getDatabase();
const now = new Date().toISOString();
db.prepare(
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, '', 'todo', ?, ?)",
).run("FN-100", now, now);
db.prepare(
"INSERT OR REPLACE INTO distributed_task_id_state (prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt) VALUES (?, ?, ?, ?, ?)",
).run("FN", 100, 0, null, now);
const report = store.refreshTaskIdIntegrityReport();
expect(report.status).toBe("anomaly");
expect(report.anomalies).toContainEqual(
expect.objectContaining({
kind: "next_sequence_at_or_below_used",
prefix: "FN",
affectedIds: ["FN-100"],
}),
);
expect(store.getTaskIdIntegrityReport()).toEqual(report);
store.close();
});
});

View File

@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import { Database } from "../db.js";
import { detectTaskIdIntegrityAnomalies } from "../task-id-integrity.js";
function createDb(): Database {
const db = new Database("/tmp/fusion-task-id-integrity-test", { inMemory: true });
db.init();
return db;
}
function insertTask(db: Database, id: string): void {
const now = new Date().toISOString();
db.prepare(
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, '', 'todo', ?, ?)",
).run(id, now, now);
}
describe("detectTaskIdIntegrityAnomalies", () => {
it("returns ok for a clean database", () => {
const db = createDb();
const report = detectTaskIdIntegrityAnomalies(db);
expect(report.status).toBe("ok");
expect(report.checkedAt).toEqual(expect.any(String));
expect(report.anomalies).toEqual([]);
});
it("returns ok when allocator tables are missing", () => {
const db = createDb();
db.exec("DROP TABLE distributed_task_id_reservations");
db.exec("DROP TABLE distributed_task_id_state");
const report = detectTaskIdIntegrityAnomalies(db);
expect(report.status).toBe("ok");
expect(report.anomalies).toEqual([]);
});
it("detects duplicate active task IDs", () => {
const db = createDb();
db.exec("ALTER TABLE tasks RENAME TO tasks_original");
db.exec("CREATE TABLE tasks (id TEXT NOT NULL, description TEXT, \"column\" TEXT, createdAt TEXT, updatedAt TEXT)");
db.exec(`
INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES
('FN-101', '', 'todo', '2026-05-12T00:00:00.000Z', '2026-05-12T00:00:00.000Z'),
('FN-101', '', 'todo', '2026-05-12T00:00:01.000Z', '2026-05-12T00:00:01.000Z')
`);
const report = detectTaskIdIntegrityAnomalies(db);
expect(report.status).toBe("anomaly");
expect(report.anomalies).toContainEqual(
expect.objectContaining({
kind: "duplicate_active_id",
prefix: "FN",
affectedIds: ["FN-101"],
}),
);
});
it("detects IDs present in both active and archived storage", () => {
const db = createDb();
insertTask(db, "FN-102");
db.prepare("INSERT INTO archivedTasks (id, data, archivedAt) VALUES (?, ?, ?)").run(
"FN-102",
JSON.stringify({ id: "FN-102" }),
new Date().toISOString(),
);
const report = detectTaskIdIntegrityAnomalies(db);
expect(report.anomalies).toContainEqual(
expect.objectContaining({
kind: "id_in_active_and_archived",
prefix: "FN",
affectedIds: ["FN-102"],
}),
);
});
it("detects stale nextSequence values at or below an existing used sequence", () => {
const db = createDb();
const now = new Date().toISOString();
insertTask(db, "FN-100");
db.prepare(
"INSERT INTO distributed_task_id_state (prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt) VALUES (?, ?, ?, ?, ?)",
).run("FN", 100, 0, null, now);
const report = detectTaskIdIntegrityAnomalies(db);
expect(report.anomalies).toContainEqual(
expect.objectContaining({
kind: "next_sequence_at_or_below_used",
prefix: "FN",
affectedIds: ["FN-100"],
}),
);
});
it("detects committed reservations that target existing task IDs", () => {
const db = createDb();
const now = new Date().toISOString();
insertTask(db, "FN-103");
db.prepare(
"INSERT INTO distributed_task_id_state (prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt) VALUES (?, ?, ?, ?, ?)",
).run("FN", 104, 1, "FN-103", now);
db.prepare(
`INSERT INTO distributed_task_id_reservations (
reservationId, prefix, nodeId, sequence, taskId, status, reason, expiresAt, committedAt, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, 'committed', NULL, ?, ?, ?, ?)`,
).run(
"res-103",
"FN",
"node-a",
103,
"FN-103",
new Date(Date.now() + 60_000).toISOString(),
now,
now,
now,
);
const report = detectTaskIdIntegrityAnomalies(db);
expect(report.anomalies).toContainEqual(
expect.objectContaining({
kind: "committed_reservation_for_existing_id",
prefix: "FN",
affectedIds: ["FN-103"],
}),
);
});
it("detects active task rows whose prefix is outside distributed state", () => {
const db = createDb();
const now = new Date().toISOString();
insertTask(db, "KB-001");
db.prepare(
"INSERT INTO distributed_task_id_state (prefix, nextSequence, committedClusterTaskCount, lastCommittedTaskId, updatedAt) VALUES (?, ?, ?, ?, ?)",
).run("FN", 2, 0, null, now);
const report = detectTaskIdIntegrityAnomalies(db);
expect(report.anomalies).toContainEqual(
expect.objectContaining({
kind: "task_row_outside_known_prefix",
prefix: "KB",
affectedIds: ["KB-001"],
}),
);
});
});

View File

@@ -87,6 +87,14 @@ export type {
AgentProvisioningPolicyDecision,
} from "./agent-provisioning-policy.js";
export { TaskStore } from "./store.js";
export {
detectTaskIdIntegrityAnomalies,
} from "./task-id-integrity.js";
export type {
TaskIdIntegrityAnomaly,
TaskIdIntegrityAnomalyKind,
TaskIdIntegrityReport,
} from "./task-id-integrity.js";
export {
FUSION_TASK_LINEAGE_TRAILER_KEY,
buildTaskLineageTrailer,

View File

@@ -29,6 +29,10 @@ import { sanitizeTitle } from "./ai-summarize.js";
import { assertProjectRootDir } from "./project-root-guard.js";
import { generateTaskLineageId, normalizeTaskCommitAssociation } from "./task-lineage.js";
import { createDistributedTaskIdAllocator, reconcileTaskIdState, resolveLocalNodeId, type DistributedTaskIdAllocator } from "./distributed-task-id.js";
import {
detectTaskIdIntegrityAnomalies,
type TaskIdIntegrityReport,
} from "./task-id-integrity.js";
import {
buildBootstrapPrompt,
replicationCollisionError,
@@ -279,6 +283,7 @@ const AGENT_LOG_TOOL_DETAIL_TRUNCATION_NOTICE =
"\n\n[tool output truncated to keep dashboard log views responsive]";
const AGENT_LOG_TOOL_TYPES = new Set<AgentLogEntry["type"]>(["tool", "tool_result", "tool_error"]);
const storeLog = createLogger("task-store");
const coreLog = createLogger("core");
/**
* Reject branch names that would be unsafe to interpolate into a shell command.
@@ -587,6 +592,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private configLock: Promise<void> = Promise.resolve();
/** Startup/open guard for distributed_task_id_state reconciliation. */
private taskIdStateReconciled = false;
/** Cached startup/refresh integrity report for allocator-related task ID anomalies. */
private taskIdIntegrityReport: TaskIdIntegrityReport = {
status: "ok",
checkedAt: new Date().toISOString(),
anomalies: [],
};
/** Prevent duplicate anomaly logs when the report content has not changed. */
private lastTaskIdIntegrityLogSignature: string | null = null;
/** Cached workflow steps — invalidated on create/update/delete */
private workflowStepsCache: import("./types.js").WorkflowStep[] | null = null;
/** Plugin-contributed workflow step templates injected by engine runtime. */
@@ -708,11 +721,74 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return this._archiveDb;
}
private buildTaskIdIntegrityFallbackReport(): TaskIdIntegrityReport {
return {
status: "ok",
checkedAt: new Date().toISOString(),
anomalies: [],
};
}
private detectAndCacheTaskIdIntegrityReport(): TaskIdIntegrityReport {
const report = detectTaskIdIntegrityAnomalies(this.db);
this.taskIdIntegrityReport = report;
const signature = report.status === "anomaly" ? JSON.stringify(report.anomalies) : null;
if (report.status === "anomaly" && signature !== this.lastTaskIdIntegrityLogSignature) {
coreLog.error("[task-id-integrity] anomaly detected", { anomalies: report.anomalies });
}
this.lastTaskIdIntegrityLogSignature = signature;
return report;
}
private mergeTaskIdIntegrityReports(...reports: TaskIdIntegrityReport[]): TaskIdIntegrityReport {
const checkedAt = reports[reports.length - 1]?.checkedAt ?? new Date().toISOString();
const seen = new Set<string>();
const anomalies = reports.flatMap((report) => report.anomalies).filter((anomaly) => {
const key = JSON.stringify(anomaly);
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
return {
status: anomalies.length > 0 ? "anomaly" : "ok",
checkedAt,
anomalies,
};
}
refreshTaskIdIntegrityReport(): TaskIdIntegrityReport {
try {
return this.detectAndCacheTaskIdIntegrityReport();
} catch (error) {
const fallback = this.buildTaskIdIntegrityFallbackReport();
this.taskIdIntegrityReport = fallback;
this.lastTaskIdIntegrityLogSignature = null;
coreLog.warn("[task-id-integrity] detector failed; degrading to healthy report", {
error: error instanceof Error ? error.message : String(error),
});
return fallback;
}
}
getTaskIdIntegrityReport(): TaskIdIntegrityReport {
return this.taskIdIntegrityReport;
}
private reconcileDistributedTaskIdStateOnOpen(): void {
if (this.taskIdStateReconciled) {
return;
}
const previousReport = this.taskIdIntegrityReport;
const preReconcileReport = this.refreshTaskIdIntegrityReport();
reconcileTaskIdState(this.db);
const postReconcileReport = this.refreshTaskIdIntegrityReport();
this.taskIdIntegrityReport = this.mergeTaskIdIntegrityReports(
previousReport,
preReconcileReport,
postReconcileReport,
);
this.taskIdStateReconciled = true;
}

View File

@@ -0,0 +1,240 @@
import type { Database } from "./db.js";
const TASK_ID_PATTERN = /^([A-Z][A-Z0-9]*)-(\d+)$/;
export type TaskIdIntegrityAnomalyKind =
| "duplicate_active_id"
| "id_in_active_and_archived"
| "next_sequence_at_or_below_used"
| "committed_reservation_for_existing_id"
| "task_row_outside_known_prefix";
export interface TaskIdIntegrityAnomaly {
kind: TaskIdIntegrityAnomalyKind;
prefix: string;
affectedIds: string[];
details: string;
}
export interface TaskIdIntegrityReport {
status: "ok" | "anomaly";
checkedAt: string;
anomalies: TaskIdIntegrityAnomaly[];
}
type TaskRow = { id: string; source: "tasks" | "archivedTasks" };
type StateRow = { prefix: string; nextSequence: number };
type ReservationRow = { prefix: string; taskId: string; status: string };
type DuplicateRow = { id: string; duplicateCount: number };
function parseTaskId(taskId: string): { prefix: string; sequence: number } | null {
const match = taskId.trim().toUpperCase().match(TASK_ID_PATTERN);
if (!match) {
return null;
}
const sequence = Number.parseInt(match[2], 10);
if (!Number.isFinite(sequence)) {
return null;
}
return { prefix: match[1], sequence };
}
function hasTable(db: Database, table: string): boolean {
try {
const rows = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.all(table) as Array<{ name: string }>;
return rows.length > 0;
} catch {
return false;
}
}
function readTaskRows(db: Database, table: "tasks" | "archivedTasks"): TaskRow[] {
if (!hasTable(db, table)) {
return [];
}
try {
return db
.prepare(`SELECT id FROM ${table}`)
.all()
.map((row) => ({ id: String((row as { id?: unknown }).id ?? ""), source: table }));
} catch {
return [];
}
}
function readStateRows(db: Database): StateRow[] {
if (!hasTable(db, "distributed_task_id_state")) {
return [];
}
try {
return db.prepare("SELECT prefix, nextSequence FROM distributed_task_id_state").all() as StateRow[];
} catch {
return [];
}
}
function readCommittedReservations(db: Database): ReservationRow[] {
if (!hasTable(db, "distributed_task_id_reservations")) {
return [];
}
try {
return db
.prepare("SELECT prefix, taskId, status FROM distributed_task_id_reservations WHERE status = 'committed'")
.all() as ReservationRow[];
} catch {
return [];
}
}
function readDuplicateActiveIds(db: Database): DuplicateRow[] {
if (!hasTable(db, "tasks")) {
return [];
}
try {
return db.prepare("SELECT id, COUNT(*) AS duplicateCount FROM tasks GROUP BY id HAVING COUNT(*) > 1").all() as DuplicateRow[];
} catch {
return [];
}
}
function uniqueSorted(values: Iterable<string>): string[] {
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
}
function buildReport(checkedAt: string, anomalies: TaskIdIntegrityAnomaly[]): TaskIdIntegrityReport {
return {
status: anomalies.length > 0 ? "anomaly" : "ok",
checkedAt,
anomalies,
};
}
export function detectTaskIdIntegrityAnomalies(db: Database): TaskIdIntegrityReport {
const checkedAt = new Date().toISOString();
try {
const anomalies: TaskIdIntegrityAnomaly[] = [];
const activeRows = readTaskRows(db, "tasks");
const archivedRows = readTaskRows(db, "archivedTasks");
const allRows = [...activeRows, ...archivedRows];
for (const row of readDuplicateActiveIds(db)) {
const parsed = parseTaskId(row.id);
anomalies.push({
kind: "duplicate_active_id",
prefix: parsed?.prefix ?? "unknown",
affectedIds: [row.id],
details: `Active tasks contains ${row.duplicateCount} rows for ${row.id}.`,
});
}
const archivedIds = new Set(archivedRows.map((row) => row.id));
const activeAndArchived = uniqueSorted(activeRows.map((row) => row.id).filter((id) => archivedIds.has(id)));
if (activeAndArchived.length > 0) {
const byPrefix = new Map<string, string[]>();
for (const taskId of activeAndArchived) {
const prefix = parseTaskId(taskId)?.prefix ?? "unknown";
byPrefix.set(prefix, [...(byPrefix.get(prefix) ?? []), taskId]);
}
for (const [prefix, affectedIds] of byPrefix) {
anomalies.push({
kind: "id_in_active_and_archived",
prefix,
affectedIds,
details: `Task IDs exist in both active and archived storage for prefix ${prefix}.`,
});
}
}
const maxUsedSequenceByPrefix = new Map<string, { maxSequence: number; taskIds: string[] }>();
for (const row of allRows) {
const parsed = parseTaskId(row.id);
if (!parsed) {
continue;
}
const existing = maxUsedSequenceByPrefix.get(parsed.prefix);
if (!existing || parsed.sequence > existing.maxSequence) {
maxUsedSequenceByPrefix.set(parsed.prefix, { maxSequence: parsed.sequence, taskIds: [row.id] });
continue;
}
if (parsed.sequence === existing.maxSequence) {
existing.taskIds.push(row.id);
}
}
for (const stateRow of readStateRows(db)) {
const prefix = stateRow.prefix.trim().toUpperCase();
const maxUsed = maxUsedSequenceByPrefix.get(prefix);
if (!maxUsed) {
continue;
}
if (stateRow.nextSequence <= maxUsed.maxSequence) {
anomalies.push({
kind: "next_sequence_at_or_below_used",
prefix,
affectedIds: uniqueSorted(maxUsed.taskIds),
details: `distributed_task_id_state.nextSequence=${stateRow.nextSequence} is at or below existing sequence ${maxUsed.maxSequence} for prefix ${prefix}.`,
});
}
}
if (hasTable(db, "distributed_task_id_state")) {
const knownPrefixes = new Set(
readStateRows(db)
.map((row) => row.prefix.trim().toUpperCase())
.filter((prefix) => prefix.length > 0),
);
if (knownPrefixes.size > 0) {
const outsideKnownPrefix = new Map<string, string[]>();
for (const row of activeRows) {
const parsed = parseTaskId(row.id);
const prefix = parsed?.prefix ?? "unknown";
if (!parsed || !knownPrefixes.has(prefix)) {
outsideKnownPrefix.set(prefix, [...(outsideKnownPrefix.get(prefix) ?? []), row.id]);
}
}
for (const [prefix, affectedIds] of outsideKnownPrefix) {
anomalies.push({
kind: "task_row_outside_known_prefix",
prefix,
affectedIds: uniqueSorted(affectedIds),
details:
prefix === "unknown"
? "Active task rows contain IDs that do not match the expected PREFIX-123 format."
: `Active task rows use prefix ${prefix}, which is not declared in distributed_task_id_state.`,
});
}
}
}
const existingIds = new Set(allRows.map((row) => row.id));
const committedReservationsByPrefix = new Map<string, string[]>();
for (const row of readCommittedReservations(db)) {
if (!existingIds.has(row.taskId)) {
continue;
}
const prefix = row.prefix.trim().toUpperCase() || parseTaskId(row.taskId)?.prefix || "unknown";
committedReservationsByPrefix.set(prefix, [...(committedReservationsByPrefix.get(prefix) ?? []), row.taskId]);
}
for (const [prefix, affectedIds] of committedReservationsByPrefix) {
anomalies.push({
kind: "committed_reservation_for_existing_id",
prefix,
affectedIds: uniqueSorted(affectedIds),
details: `Committed reservation rows still reference task IDs that already exist for prefix ${prefix}.`,
});
}
return buildReport(checkedAt, anomalies);
} catch {
return buildReport(checkedAt, []);
}
}