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, []);
}
}

View File

@@ -15,6 +15,7 @@ import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
import { SetupWarningBanner } from "./components/SetupWarningBanner";
import { TaskIdIntegrityBanner } from "./components/TaskIdIntegrityBanner";
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
import { ApprovalNotificationBanner } from "./components/ApprovalNotificationBanner";
import { OnboardingResumeCard } from "./components/OnboardingResumeCard";
@@ -64,8 +65,8 @@ import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingMo
import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager";
import { ShellConnectionStatus } from "./components/ShellConnectionStatus";
import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native";
import type { AiSessionSummary } from "./api";
import { api, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps } from "./api";
import type { AiSessionSummary, DashboardHealthResponse } from "./api";
import { api, fetchDashboardHealth, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps } from "./api";
import { getScopedItem, setScopedItem } from "./utils/projectStorage";
import { subscribeSse } from "./sse-bus";
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth";
@@ -651,6 +652,7 @@ function AppInner() {
const [milestoneSliceResumeSessionId, setMilestoneSliceResumeSessionId] = useState<string | undefined>(undefined);
const [quickChatOpen, setQuickChatOpen] = useState(false);
const [authTokenRecoveryOpen, setAuthTokenRecoveryOpen] = useState(false);
const [dashboardHealth, setDashboardHealth] = useState<DashboardHealthResponse | null>(null);
const [setupWarningDismissed, setSetupWarningDismissed] = useState(
() => getScopedItem(SETUP_WARNING_DISMISSED_KEY, currentProject?.id) === "true",
);
@@ -661,6 +663,26 @@ function AppInner() {
);
}, [currentProject?.id]);
useEffect(() => {
let cancelled = false;
fetchDashboardHealth()
.then((health) => {
if (!cancelled) {
setDashboardHealth(health);
}
})
.catch(() => {
if (!cancelled) {
setDashboardHealth(null);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const handleDaemonAuthFailure = () => {
setAuthTokenRecoveryOpen(true);
@@ -1602,6 +1624,27 @@ function AppInner() {
onDismiss={dismissUpdateBanner}
/>
)}
{viewMode === "project" && currentProject && dashboardHealth?.taskIdIntegrity.status === "anomaly" && dashboardHealth.taskIdIntegrity.recommendedAction && (
<TaskIdIntegrityBanner
report={dashboardHealth.taskIdIntegrity}
recommendedAction={dashboardHealth.taskIdIntegrity.recommendedAction}
onRefresh={(report, recommendedAction) => {
setDashboardHealth((current) => {
if (!current) {
return null;
}
return {
...current,
status: report.status === "anomaly" || !current.database.healthy ? "degraded" : "ok",
taskIdIntegrity: {
...report,
recommendedAction,
},
};
});
}}
/>
)}
{viewMode === "project" && currentProject && !setupReadinessLoading && hasWarnings && !setupWarningDismissed && (
<SetupWarningBanner
hasAiProvider={hasAiProvider}

View File

@@ -74,6 +74,7 @@ import type {
DockerNodeStatus,
ProjectNodePathMapping,
ApprovalRequestStatus,
TaskIdIntegrityReport,
} from "@fusion/core";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
@@ -204,12 +205,19 @@ export interface DashboardHealthResponse {
lastCheckedAt: string | null;
isRunning: boolean;
};
taskIdIntegrity: TaskIdIntegrityReport & {
recommendedAction: string | null;
};
}
export function fetchDashboardHealth(): Promise<DashboardHealthResponse> {
return api<DashboardHealthResponse>("/health");
}
export function refreshDashboardHealth(): Promise<DashboardHealthResponse> {
return api<DashboardHealthResponse>("/health/refresh", { method: "POST" });
}
export function checkForUpdates(): Promise<UpdateCheckResponse> {
return api<UpdateCheckResponse>("/updates/check");
}

View File

@@ -0,0 +1,98 @@
/* === TaskIdIntegrityBanner === */
.task-id-integrity-banner {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-lg);
margin-bottom: var(--space-md);
border-radius: var(--radius-md);
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-error) 24%, transparent);
border-inline-start: var(--space-xs) solid var(--color-error);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
box-shadow: var(--shadow-md);
}
.task-id-integrity-banner__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
}
.task-id-integrity-banner__headline-wrap {
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 0;
}
.task-id-integrity-banner__headline {
margin: 0;
font-size: 1rem;
color: var(--text);
}
.task-id-integrity-banner__body,
.task-id-integrity-banner__footer,
.task-id-integrity-banner__error,
.task-id-integrity-banner__item-detail {
margin: 0;
color: var(--text-muted);
}
.task-id-integrity-banner__list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin: 0;
padding-inline-start: calc(var(--space-lg) + var(--space-xs));
}
.task-id-integrity-banner__item {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.task-id-integrity-banner__item-title {
color: var(--text);
}
.task-id-integrity-banner__ids {
font-family: var(--font-mono);
color: var(--text);
white-space: normal;
overflow-wrap: anywhere;
}
.task-id-integrity-banner__refresh {
flex-shrink: 0;
}
.task-id-integrity-banner__refresh-icon {
flex-shrink: 0;
}
.task-id-integrity-banner__refresh-icon--spinning {
animation: status-dot-pulse var(--transition-slow) ease-in-out infinite;
}
.task-id-integrity-banner__error {
color: var(--color-error);
}
@media (max-width: 768px) {
.task-id-integrity-banner {
padding: var(--space-md);
}
.task-id-integrity-banner__header {
flex-direction: column;
align-items: stretch;
}
.task-id-integrity-banner__refresh {
width: 100%;
justify-content: center;
}
}

View File

@@ -0,0 +1,93 @@
import { AlertTriangle, RefreshCw } from "lucide-react";
import { useState } from "react";
import type { TaskIdIntegrityReport } from "@fusion/core";
import { refreshDashboardHealth } from "../api";
import "./TaskIdIntegrityBanner.css";
interface TaskIdIntegrityBannerProps {
report: TaskIdIntegrityReport;
recommendedAction: string;
onRefresh?: (report: TaskIdIntegrityReport, recommendedAction: string | null) => void;
}
const ANOMALY_LABELS: Record<TaskIdIntegrityReport["anomalies"][number]["kind"], string> = {
duplicate_active_id: "Duplicate active task ID",
id_in_active_and_archived: "Task ID present in active and archived storage",
next_sequence_at_or_below_used: "Allocator next sequence overlaps an existing task ID",
committed_reservation_for_existing_id: "Committed reservation still points at an existing task ID",
task_row_outside_known_prefix: "Task row uses a prefix outside allocator state",
};
function formatAffectedIds(affectedIds: string[]): string {
if (affectedIds.length <= 5) {
return affectedIds.join(", ");
}
const visible = affectedIds.slice(0, 5).join(", ");
return `${visible} +${affectedIds.length - 5} more`;
}
export function TaskIdIntegrityBanner({ report, recommendedAction, onRefresh }: TaskIdIntegrityBannerProps) {
const [refreshing, setRefreshing] = useState(false);
const [refreshError, setRefreshError] = useState<string | null>(null);
if (report.status !== "anomaly") {
return null;
}
const handleRefresh = async () => {
setRefreshing(true);
setRefreshError(null);
try {
const health = await refreshDashboardHealth();
onRefresh?.(health.taskIdIntegrity, health.taskIdIntegrity.recommendedAction);
} catch (error) {
setRefreshError(error instanceof Error ? error.message : "Failed to refresh integrity status.");
} finally {
setRefreshing(false);
}
};
return (
<section className="task-id-integrity-banner" role="alert" aria-live="assertive">
<div className="task-id-integrity-banner__header">
<div className="task-id-integrity-banner__headline-wrap">
<span className="status-dot status-dot--error" aria-hidden="true" />
<AlertTriangle aria-hidden="true" />
<h2 className="task-id-integrity-banner__headline">Task ID integrity anomaly detected</h2>
</div>
<button
type="button"
className="btn btn-sm task-id-integrity-banner__refresh"
onClick={() => {
void handleRefresh();
}}
disabled={refreshing}
>
<RefreshCw className={refreshing ? "task-id-integrity-banner__refresh-icon task-id-integrity-banner__refresh-icon--spinning" : "task-id-integrity-banner__refresh-icon"} aria-hidden="true" />
{refreshing ? "Re-checking…" : "Re-check"}
</button>
</div>
<p className="task-id-integrity-banner__body">
Fusion found allocator state that can cause task IDs to be reused or overwrite live task records.
</p>
<ul className="task-id-integrity-banner__list">
{report.anomalies.map((anomaly) => (
<li
key={`${anomaly.kind}:${anomaly.prefix}:${anomaly.affectedIds.join(",")}`}
className="task-id-integrity-banner__item"
>
<strong className="task-id-integrity-banner__item-title">{ANOMALY_LABELS[anomaly.kind]}</strong>
<span className="task-id-integrity-banner__item-detail">{anomaly.details}</span>
<code className="task-id-integrity-banner__ids">{formatAffectedIds(anomaly.affectedIds)}</code>
</li>
))}
</ul>
<p className="task-id-integrity-banner__footer">{recommendedAction}</p>
{refreshError ? <p className="task-id-integrity-banner__error">{refreshError}</p> : null}
</section>
);
}

View File

@@ -50,6 +50,13 @@ vi.mock("../../api", async (importOriginal) => {
fetchAgents: vi.fn(() => Promise.resolve([])),
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })),
fetchUnreadCount: vi.fn(() => Promise.resolve({ unreadCount: 0 })),
fetchDashboardHealth: vi.fn(() => Promise.resolve({
status: "ok",
version: "1.0.0",
uptime: 1,
database: { healthy: true, lastCheckedAt: null, isRunning: false },
taskIdIntegrity: { status: "ok", checkedAt: "2026-05-12T00:00:00.000Z", anomalies: [], recommendedAction: null },
})),
fetchPluginDashboardViews: vi.fn(() => Promise.resolve([])),
fetchExecutorStats: vi.fn(() => Promise.resolve({
globalPause: false,

View File

@@ -0,0 +1,93 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { TaskIdIntegrityBanner } from "../TaskIdIntegrityBanner";
const mockRefreshDashboardHealth = vi.fn();
vi.mock("../../api", async (importOriginal) => {
const original = await importOriginal<typeof import("../../api")>();
return {
...original,
refreshDashboardHealth: (...args: unknown[]) => mockRefreshDashboardHealth(...args),
};
});
const anomalyReport = {
status: "anomaly" as const,
checkedAt: "2026-05-12T10:00:00.000Z",
anomalies: [
{
kind: "next_sequence_at_or_below_used" as const,
prefix: "FN",
affectedIds: ["FN-100", "FN-101", "FN-102", "FN-103", "FN-104", "FN-105"],
details: "Allocator state overlaps an existing task.",
},
],
};
describe("TaskIdIntegrityBanner", () => {
it("renders nothing when the report is healthy", () => {
const { container } = render(
<TaskIdIntegrityBanner
report={{ status: "ok", checkedAt: "2026-05-12T10:00:00.000Z", anomalies: [] }}
recommendedAction="Pause task delegation."
/>,
);
expect(container).toBeEmptyDOMElement();
});
it("renders the alert headline, affected IDs, and recommended action for anomalies", () => {
render(
<TaskIdIntegrityBanner
report={anomalyReport}
recommendedAction="Pause task delegation, inspect the affected task IDs, and run the allocator audit before creating new tasks."
/>,
);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText("Task ID integrity anomaly detected")).toBeInTheDocument();
expect(screen.getByText("FN-100, FN-101, FN-102, FN-103, FN-104 +1 more")).toBeInTheDocument();
expect(
screen.getByText(
"Pause task delegation, inspect the affected task IDs, and run the allocator audit before creating new tasks.",
),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /dismiss/i })).toBeNull();
});
it("re-checks health on demand", async () => {
mockRefreshDashboardHealth.mockResolvedValueOnce({
status: "ok",
version: "1.0.0",
uptime: 1,
database: { healthy: true, lastCheckedAt: null, isRunning: false },
taskIdIntegrity: {
status: "ok",
checkedAt: "2026-05-12T10:05:00.000Z",
anomalies: [],
recommendedAction: null,
},
});
const onRefresh = vi.fn();
render(
<TaskIdIntegrityBanner
report={anomalyReport}
recommendedAction="Pause task delegation."
onRefresh={onRefresh}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Re-check" }));
await waitFor(() => {
expect(mockRefreshDashboardHealth).toHaveBeenCalledTimes(1);
expect(onRefresh).toHaveBeenCalledWith(
{ status: "ok", checkedAt: "2026-05-12T10:05:00.000Z", anomalies: [], recommendedAction: null },
null,
);
});
});
});

View File

@@ -72,6 +72,18 @@ class MockStore extends EventEmitter {
};
}
getTaskIdIntegrityReport() {
return {
status: "ok",
checkedAt: "2026-05-12T00:00:00.000Z",
anomalies: [],
};
}
refreshTaskIdIntegrityReport() {
return this.getTaskIdIntegrityReport();
}
getMissionStore() {
return {
listMissions: vi.fn().mockResolvedValue([]),

View File

@@ -3,14 +3,16 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import http from "node:http";
import { createHmac } from "node:crypto";
import { readFileSync } from "node:fs";
import { mkdtempSync, readFileSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import express from "express";
import { createServer, setupTerminalWebSocket } from "../server.js";
import { toSessionTag } from "../terminal-websocket-diagnostics.js";
import { RATE_LIMITS } from "../rate-limit.js";
import type { TaskStore } from "@fusion/core";
import { Database, TaskStore } from "@fusion/core";
import { get as performGet, request as performRequest } from "../test-request.js";
// Mock terminal-service before any imports that use it
@@ -44,6 +46,24 @@ const CLI_PACKAGE_VERSION = (() => {
return typeof packageJson.version === "string" ? packageJson.version : "";
})();
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fn-server-test-"));
}
async function seedTaskIdIntegrityPrecondition(rootDir: string): Promise<void> {
const fusionDir = join(rootDir, ".fusion");
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();
}
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn(),
@@ -73,6 +93,16 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
isRunning: false,
lastCheckedAt: null,
}),
getTaskIdIntegrityReport: vi.fn().mockReturnValue({
status: "ok",
checkedAt: "2026-05-12T00:00:00.000Z",
anomalies: [],
}),
refreshTaskIdIntegrityReport: vi.fn().mockReturnValue({
status: "ok",
checkedAt: "2026-05-12T00:00:00.000Z",
anomalies: [],
}),
getMissionStore: vi.fn().mockReturnValue({
listMissions: vi.fn().mockReturnValue([]),
createMission: vi.fn(),
@@ -317,6 +347,12 @@ describe("createServer health and headless mode", () => {
isRunning: false,
lastCheckedAt: null,
},
taskIdIntegrity: {
status: "ok",
checkedAt: "2026-05-12T00:00:00.000Z",
anomalies: [],
recommendedAction: null,
},
});
});
@@ -342,9 +378,172 @@ describe("createServer health and headless mode", () => {
isRunning: false,
lastCheckedAt: "2026-05-11T10:00:00.000Z",
},
taskIdIntegrity: {
status: "ok",
checkedAt: "2026-05-12T00:00:00.000Z",
anomalies: [],
recommendedAction: null,
},
});
});
it("reports degraded status when task ID integrity anomalies are present", async () => {
const store = createMockStore({
getTaskIdIntegrityReport: vi.fn().mockReturnValue({
status: "anomaly",
checkedAt: "2026-05-12T10:00:00.000Z",
anomalies: [
{
kind: "next_sequence_at_or_below_used",
prefix: "FN",
affectedIds: ["FN-100"],
details: "state drift",
},
],
}),
});
const app = createServer(store);
const res = await GET(app, "/api/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({
status: "degraded",
version: CLI_PACKAGE_VERSION,
uptime: expect.any(Number),
database: {
healthy: true,
isRunning: false,
lastCheckedAt: null,
},
taskIdIntegrity: {
status: "anomaly",
checkedAt: "2026-05-12T10:00:00.000Z",
anomalies: [
{
kind: "next_sequence_at_or_below_used",
prefix: "FN",
affectedIds: ["FN-100"],
details: "state drift",
},
],
recommendedAction:
"Pause task delegation, inspect the affected task IDs, and run the allocator audit before creating new tasks.",
},
});
});
it("refreshes task ID integrity health on demand", async () => {
const store = createMockStore({
refreshTaskIdIntegrityReport: vi.fn().mockReturnValue({
status: "anomaly",
checkedAt: "2026-05-12T11:00:00.000Z",
anomalies: [
{
kind: "committed_reservation_for_existing_id",
prefix: "FN",
affectedIds: ["FN-103"],
details: "reservation collision",
},
],
}),
});
const app = createServer(store);
const res = await REQUEST(app, "POST", "/api/health/refresh");
expect(res.status).toBe(200);
expect(store.refreshTaskIdIntegrityReport).toHaveBeenCalledTimes(1);
expect(res.body).toEqual({
status: "degraded",
version: CLI_PACKAGE_VERSION,
uptime: expect.any(Number),
database: {
healthy: true,
isRunning: false,
lastCheckedAt: null,
},
taskIdIntegrity: {
status: "anomaly",
checkedAt: "2026-05-12T11:00:00.000Z",
anomalies: [
{
kind: "committed_reservation_for_existing_id",
prefix: "FN",
affectedIds: ["FN-103"],
details: "reservation collision",
},
],
recommendedAction:
"Pause task delegation, inspect the affected task IDs, and run the allocator audit before creating new tasks.",
},
});
});
it("surfaces startup-detected nextSequence collisions end-to-end through /api/health", async () => {
const rootDir = makeTmpDir();
const globalDir = makeTmpDir();
await seedTaskIdIntegrityPrecondition(rootDir);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const store = new TaskStore(rootDir, globalDir);
try {
await store.init();
const app = createServer(store, { headless: true });
expect(store.getTaskIdIntegrityReport()).toMatchObject({
status: "anomaly",
anomalies: [
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"],
}),
]),
}),
);
const res = await GET(app, "/api/health");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
status: "degraded",
database: {
healthy: true,
isRunning: expect.any(Boolean),
lastCheckedAt: null,
},
taskIdIntegrity: {
status: "anomaly",
anomalies: [
{
kind: "next_sequence_at_or_below_used",
prefix: "FN",
affectedIds: ["FN-100"],
details: expect.any(String),
},
],
recommendedAction:
"Pause task delegation, inspect the affected task IDs, and run the allocator audit before creating new tasks.",
},
});
} finally {
store.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
});
it("does not return stale hardcoded 0.4.0 unless package version is 0.4.0", async () => {
const store = createMockStore();
const app = createServer(store);

View File

@@ -5,7 +5,17 @@ import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { createSecureServer as createHttp2SecureServer, type Http2SecureServer } from "node:http2";
import type { Server as HttpServer } from "node:http";
import type { Task, TaskStore, MergeResult, AutomationStore, RoutineStore, CentralCore, MessageStore, AgentLogEntry } from "@fusion/core";
import type {
Task,
TaskStore,
MergeResult,
AutomationStore,
RoutineStore,
CentralCore,
MessageStore,
AgentLogEntry,
TaskIdIntegrityReport,
} from "@fusion/core";
import { AgentStore, ChatStore } from "@fusion/core";
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
import { createApiRoutes } from "./routes.js";
@@ -61,6 +71,30 @@ function parseVersion(version: string): number[] {
.map((value) => (Number.isFinite(value) ? value : 0));
}
function buildTaskIdIntegrityHealth(report: TaskIdIntegrityReport) {
return {
status: report.status,
checkedAt: report.checkedAt,
anomalies: report.anomalies,
recommendedAction:
report.status === "anomaly"
? "Pause task delegation, inspect the affected task IDs, and run the allocator audit before creating new tasks."
: null,
};
}
function buildHealthPayload(store: TaskStore, cliPackageVersion: string) {
const database = store.getDatabaseHealth();
const taskIdIntegrity = buildTaskIdIntegrityHealth(store.getTaskIdIntegrityReport());
return {
status: !database.healthy || taskIdIntegrity.status === "anomaly" ? "degraded" : "ok",
version: cliPackageVersion,
uptime: Math.floor(process.uptime()),
database,
taskIdIntegrity,
};
}
function isRemoteVersionNewer(remoteVersion: string, currentVersion: string): boolean {
const remote = parseVersion(remoteVersion);
const current = parseVersion(currentVersion);
@@ -1062,12 +1096,18 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
}
app.get("/api/health", (_req, res) => {
res.json(buildHealthPayload(store, cliPackageVersion));
});
app.post("/api/health/refresh", (_req, res) => {
const report = store.refreshTaskIdIntegrityReport();
const database = store.getDatabaseHealth();
res.json({
status: database.healthy ? "ok" : "degraded",
status: !database.healthy || report.status === "anomaly" ? "degraded" : "ok",
version: cliPackageVersion,
uptime: Math.floor(process.uptime()),
database,
taskIdIntegrity: buildTaskIdIntegrityHealth(report),
});
});

View File

@@ -11,7 +11,7 @@ const qualityAppTests = [
"app/api/**/*.test.ts",
// Representative workflow/component coverage. Exhaustive modal/view suites
// stay available in the full `dashboard-app` project.
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,ChatView,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ResearchView,StashRecoveryView,TaskCard,TaskChangesTab,TaskComments,TaskDetailModal.github-tracking-header,TaskDocumentsTab,TaskForm,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx",
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,ChatView,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ResearchView,StashRecoveryView,TaskCard,TaskChangesTab,TaskComments,TaskDetailModal.github-tracking-header,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx",
// Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",