feat(FN-4898): reconcile task title-id drift in storage migrations
Adds a title-id drift detection and reconciliation system to prevent duplicate tasks when task titles change after initial creation. Core adds the `task-title-id-drift.ts` module with `markTitleIdStable` and `resolveTitleIdDrift` logic, wired through `db.ts`, `store.ts`, and `archive-db.ts`, with co Fusion-Task-Id: FN-4898
This commit is contained in:
committed by
gsxdsm
parent
3d0cce72f2
commit
10455b7992
5
.changeset/FN-4898-title-id-drift.md
Normal file
5
.changeset/FN-4898-title-id-drift.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Normalize task titles to strip foreign embedded `FN-<id>` tokens during create/update/duplicate/refine flows while preserving duplicate/refine provenance metadata. Also adds schema version 84 migration coverage to clean existing active/archived title-ID drift rows idempotently.
|
||||
@@ -204,6 +204,7 @@ Port 4040 is the production dashboard port. A user's live dashboard session is t
|
||||
|
||||
- Merge deadlock self-healing now layers `recoverAlreadyMergedReviewTasks()`, `clearStaleBlockedBy()`, and `reclaimSelfOwnedBranchConflicts()` in `packages/engine/src/self-healing.ts`, plus the paused-aware in-review scope filter in `packages/engine/src/scheduler.ts` (`inReviewWithWorktree` excludes `paused` tasks). `reclaimSelfOwnedBranchConflicts()` now also recovers paused `branch-conflict-unrecoverable` review rows when ownership is self-proven, auto-reclaims `fusion/<task-id>` branches that are still live-mapped but have zero unique commits vs main by force-removing the stale worktree and deleting the branch, and clears `task.worktree`/`task.branch` so retries recreate a fresh checkout. `inspectBranchConflict()` backs this with a patch-id fallback for degraded/empty `git cherry` output before declaring zero-unique-commit subsumption, treats ghost live-worktree admin entries (mapping path missing on disk) as `stale-resolved`, and classifies tips already reachable from the integration target as `tip-already-merged` so stale cached `baseCommitSha` values are invalidated instead of enumerating main's forward progress as stranded commits. FN-4546 adds a separate `reclaim-stale-active-branches` stage in startup and maintenance sweeps to close the gap where an active task owns a `fusion/<task-id>` branch with zero unique commits but has no usable worktree mapping; that stage now prunes the stale branch, clears task branch/worktree/base metadata, and emits `branch:stale-active-reclaim` audit telemetry. FN-4601 hardens executor recovery on this path: the no-`fn_task_done` retry loop now re-validates live worktree/branch bindings before recreating a session and treats missing/incomplete/unregistered-worktree session-start failures as recoverable by clearing stale metadata and requeueing to `todo` with preserved progress. Orphan `fusion/*` branches are still resolved by prune-or-rescue logic (subsumed branches pruned; unique-commit branches rescued into triage tasks instead of force delete). Completion fan-out now runs synchronously on `in-review → done` via `SelfHealingManager.reconcileCompletedTask()`, so downstream stale `blockedBy` links and residual `fusion/<task-id>` branch/worktree artifacts are reconciled immediately instead of waiting for periodic sweeps. Finalize integrity evidence classification now includes a benign `no-changes-finalized` outcome for verification-only done tasks with no owned commits and no surviving branch, allowing stale `modifiedFiles` snapshots to be cleared without raising `missing-evidence` warnings. `surfaceInReviewStalls()` also now auto-disposes repeated identical in-review stalls (same code + reason) once `inReviewStallDeadlockThreshold` is reached (default `3`) by pausing with `pausedReason: "in-review-stall-deadlock"`, setting `status: "failed"`, and emitting `task:in-review-stall-deadlock-disposed` audit telemetry. When `worktrunk.enabled` is true, self-healing maintenance defers native prune/idle cleanup/worktree-cap layout sweeps to the worktrunk backend while keeping branch-level reclaim and orphan-branch rescue native.
|
||||
- Restart recovery is coordinated through `RestartRecoveryCoordinator` (`packages/engine/src/restart-recovery-coordinator.ts`), which classifies interrupted `in-progress` runs at runtime startup: no-progress `fn_task_done` failures are safely requeued to `todo`, then remaining orphaned work is resumed via the executor. Session-start unusable-worktree detection now treats all `assertValidWorktreeSession()` variants (`missing worktree`, `incomplete worktree`, and `unregistered git worktree`) as recoverable for self-healing auto-requeue, including zero-progress `in-review` failures that now retry with a bounded `worktreeSessionRetryCount` cap (`MAX_WORKTREE_SESSION_RETRIES=3`) before escalating for human inspection.
|
||||
- Task title/ID integrity hardening (FN-4898): active task title writes now normalize foreign embedded `FN-NNN` tokens via `packages/core/src/task-title-id-drift.ts` before persistence (create/update/duplicate/refine/summarization paths), preserving lineage in `sourceParentTaskId`/description markers instead of title embeds. Core DB schema version advanced 83 → 84 with a deterministic active-task migration; archived title normalization runs in `ArchiveDatabase` and updates both `archived_tasks.title` and `taskJson.title` in lockstep.
|
||||
|
||||
## Engine Process Rules
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ArchiveDatabase } from "../archive-db.js";
|
||||
|
||||
describe("ArchiveDatabase title-id drift normalization", () => {
|
||||
it("normalizes archived title and taskJson title in lockstep and is idempotent", () => {
|
||||
const archiveDb = new ArchiveDatabase("/tmp/fusion-archive-drift-test", { inMemory: true });
|
||||
archiveDb.init();
|
||||
|
||||
const rawDb = (archiveDb as any).db;
|
||||
const archivedAt = new Date().toISOString();
|
||||
const entry = {
|
||||
id: "FN-200",
|
||||
title: "Refinement: FN-999: fix",
|
||||
description: "desc",
|
||||
comments: [],
|
||||
createdAt: archivedAt,
|
||||
updatedAt: archivedAt,
|
||||
archivedAt,
|
||||
columnMovedAt: archivedAt,
|
||||
};
|
||||
|
||||
rawDb.prepare(`
|
||||
INSERT INTO archived_tasks (id, taskJson, prompt, archivedAt, title, description, comments, createdAt, updatedAt, columnMovedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
entry.id,
|
||||
JSON.stringify(entry),
|
||||
null,
|
||||
archivedAt,
|
||||
entry.title,
|
||||
entry.description,
|
||||
"[]",
|
||||
archivedAt,
|
||||
archivedAt,
|
||||
archivedAt,
|
||||
);
|
||||
|
||||
(archiveDb as any).normalizeDriftedTitlesOnce();
|
||||
|
||||
const row = rawDb.prepare("SELECT title, taskJson FROM archived_tasks WHERE id = ?").get(entry.id) as {
|
||||
title: string | null;
|
||||
taskJson: string;
|
||||
};
|
||||
expect(row.title).toBe("Refinement: fix");
|
||||
expect(JSON.parse(row.taskJson).title).toBe("Refinement: fix");
|
||||
|
||||
const matches = rawDb.prepare("SELECT COUNT(*) as count FROM archived_tasks_fts WHERE archived_tasks_fts MATCH ?").get("Refinement") as { count: number };
|
||||
expect(matches.count).toBeGreaterThan(0);
|
||||
|
||||
(archiveDb as any).normalizeDriftedTitlesOnce();
|
||||
const second = rawDb.prepare("SELECT title, taskJson FROM archived_tasks WHERE id = ?").get(entry.id) as {
|
||||
title: string | null;
|
||||
taskJson: string;
|
||||
};
|
||||
expect(second.title).toBe("Refinement: fix");
|
||||
expect(JSON.parse(second.taskJson).title).toBe("Refinement: fix");
|
||||
|
||||
archiveDb.close();
|
||||
});
|
||||
});
|
||||
@@ -717,7 +717,7 @@ describe("schema migration", () => {
|
||||
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
|
||||
{ id: "WS-002", mode: "script", gateMode: "advisory" },
|
||||
]);
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -767,7 +767,7 @@ describe("schema migration", () => {
|
||||
reviewerContextRetryCount: 0,
|
||||
reviewerFallbackRetryCount: 0,
|
||||
});
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -796,7 +796,7 @@ describe("schema migration", () => {
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -831,7 +831,7 @@ describe("schema migration", () => {
|
||||
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
|
||||
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
|
||||
]);
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -291,7 +291,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
});
|
||||
|
||||
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
|
||||
@@ -319,7 +319,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
});
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
// Update the config
|
||||
@@ -1384,7 +1384,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1409,11 +1409,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1448,7 +1448,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("priority");
|
||||
@@ -1489,7 +1489,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1561,7 +1561,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1801,7 +1801,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||
@@ -1875,7 +1875,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -1899,7 +1899,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -2003,7 +2003,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -2222,7 +2222,7 @@ describe("schema migrations", () => {
|
||||
|
||||
localDb.init();
|
||||
|
||||
expect(localDb.getSchemaVersion()).toBe(83);
|
||||
expect(localDb.getSchemaVersion()).toBe(84);
|
||||
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
|
||||
|
||||
@@ -2533,7 +2533,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
@@ -2687,7 +2687,7 @@ describe("migration v77 task token budget columns", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(83);
|
||||
expect(migrated.getSchemaVersion()).toBe(84);
|
||||
const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const names = new Set(rows.map((row) => row.name));
|
||||
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
|
||||
@@ -2733,7 +2733,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(83);
|
||||
expect(migrated.getSchemaVersion()).toBe(84);
|
||||
const tables = migrated
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
@@ -2760,7 +2760,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
try {
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(83);
|
||||
expect(fresh.getSchemaVersion()).toBe(84);
|
||||
const tables = fresh
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
@@ -886,7 +886,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
||||
const db1 = createDatabase(legacyDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(83);
|
||||
expect(db1.getSchemaVersion()).toBe(84);
|
||||
db1.close();
|
||||
|
||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||
@@ -921,7 +921,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
||||
// Now run init — this triggers the v32→v33 migration
|
||||
db3.init();
|
||||
expect(db3.getSchemaVersion()).toBe(83);
|
||||
expect(db3.getSchemaVersion()).toBe(84);
|
||||
|
||||
// Step 4: Verify insight tables exist after migration
|
||||
const tablesAfter = db3.prepare(
|
||||
@@ -952,12 +952,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
try {
|
||||
const db1 = createDatabase(testDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(83);
|
||||
expect(db1.getSchemaVersion()).toBe(84);
|
||||
db1.close();
|
||||
|
||||
const db2 = createDatabase(testDir);
|
||||
expect(() => db2.init()).not.toThrow();
|
||||
expect(db2.getSchemaVersion()).toBe(83);
|
||||
expect(db2.getSchemaVersion()).toBe(84);
|
||||
db2.close();
|
||||
} finally {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
@@ -971,7 +971,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh DB and run migrations
|
||||
const db1 = createDatabase(compatDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(83);
|
||||
expect(db1.getSchemaVersion()).toBe(84);
|
||||
|
||||
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
|
||||
// table without them. This simulates a DB that was created before the
|
||||
|
||||
@@ -2801,7 +2801,7 @@ describe("MissionStore", () => {
|
||||
|
||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||
it("schema version is 40 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
});
|
||||
|
||||
it("mission_features table has loop state columns", () => {
|
||||
|
||||
@@ -584,7 +584,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("secrets schema migrations", () => {
|
||||
const version = db
|
||||
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
|
||||
.get() as { value: string };
|
||||
expect(version.value).toBe("83");
|
||||
expect(version.value).toBe("84");
|
||||
} finally {
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -105,7 +105,7 @@ describe("secrets schema migrations", () => {
|
||||
const version = db
|
||||
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
|
||||
.get() as { value: string };
|
||||
expect(version.value).toBe("83");
|
||||
expect(version.value).toBe("84");
|
||||
} finally {
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
@@ -155,7 +155,7 @@ describe("secrets schema migrations", () => {
|
||||
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
|
||||
.get() as { value: string };
|
||||
|
||||
expect(projectVersion.value).toBe("83");
|
||||
expect(projectVersion.value).toBe("84");
|
||||
expect(centralVersion.value).toBe("13");
|
||||
} finally {
|
||||
projectDb.close();
|
||||
|
||||
@@ -198,10 +198,12 @@ describe("TaskStore", () => {
|
||||
});
|
||||
|
||||
it("sets duplicate and refine provenance parent links", async () => {
|
||||
const source = await store.createTask({ description: "Original" });
|
||||
const source = await store.createTask({ title: "Fix FN-123 bug", description: "Original" });
|
||||
const duplicated = await store.duplicateTask(source.id);
|
||||
expect(duplicated.sourceType).toBe("task_duplicate");
|
||||
expect(duplicated.sourceParentTaskId).toBe(source.id);
|
||||
expect(duplicated.title).toBe("Fix bug");
|
||||
expect(duplicated.description).toContain(`(Duplicated from ${source.id})`);
|
||||
|
||||
await store.moveTask(source.id, "todo");
|
||||
await store.moveTask(source.id, "in-progress");
|
||||
@@ -210,6 +212,15 @@ describe("TaskStore", () => {
|
||||
const refined = await store.refineTask(source.id, "Needs polish");
|
||||
expect(refined.sourceType).toBe("task_refine");
|
||||
expect(refined.sourceParentTaskId).toBe(source.id);
|
||||
expect(refined.title).toBe("Refinement: Fix bug");
|
||||
});
|
||||
|
||||
it("FN-4898: prevents title/ID drift on duplicateTask", async () => {
|
||||
const source = await store.createTask({ title: "Finalize FN-4847: mark steps done", description: "x" });
|
||||
const duplicated = await store.duplicateTask(source.id);
|
||||
expect(duplicated.title).toBe("Finalize mark steps done");
|
||||
expect(duplicated.sourceParentTaskId).toBe(source.id);
|
||||
expect(duplicated.description).toContain(`(Duplicated from ${source.id})`);
|
||||
});
|
||||
|
||||
it("preserves provenance on updateTask", async () => {
|
||||
|
||||
@@ -109,6 +109,33 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema migration 84 title-id drift", () => {
|
||||
it("normalizes drifted active titles and is stable on rerun", async () => {
|
||||
await harness.reopenDiskBackedStore();
|
||||
const store = harness.store();
|
||||
const drifted = await store.createTask({ title: "temporary", description: "drift" });
|
||||
const own = await store.createTask({ title: "temporary", description: "own" });
|
||||
const untitled = await store.createTask({ description: "none" });
|
||||
|
||||
const db = store.getDatabase();
|
||||
db.prepare("UPDATE tasks SET title = ? WHERE id = ?").run("Finalize FN-9999: mark steps done", drifted.id);
|
||||
db.prepare("UPDATE tasks SET title = ? WHERE id = ?").run(`Keep ${own.id} title`, own.id);
|
||||
db.prepare("UPDATE tasks SET title = NULL WHERE id = ?").run(untitled.id);
|
||||
db.prepare("UPDATE __meta SET value = '83' WHERE key = 'schemaVersion'").run();
|
||||
|
||||
await harness.reopenDiskBackedStore();
|
||||
|
||||
expect((await harness.store().getTask(drifted.id)).title).toBe("Finalize mark steps done");
|
||||
expect((await harness.store().getTask(own.id)).title).toBe(`Keep ${own.id} title`);
|
||||
expect((await harness.store().getTask(untitled.id)).title).toBeUndefined();
|
||||
|
||||
const firstTitle = (await harness.store().getTask(drifted.id)).title;
|
||||
harness.store().getDatabase().prepare("UPDATE __meta SET value = '83' WHERE key = 'schemaVersion'").run();
|
||||
await harness.reopenDiskBackedStore();
|
||||
expect((await harness.store().getTask(drifted.id)).title).toBe(firstTitle);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FTS5 corruption recovery during create inserts", () => {
|
||||
it("rebuilds FTS5 and retries once when an insert fails with an FTS corruption error", async () => {
|
||||
const db = harness.store().getDatabase();
|
||||
|
||||
@@ -581,7 +581,7 @@ describe("TaskStore", () => {
|
||||
|
||||
const refined = await store.refineTask(task.id, "Feedback");
|
||||
|
||||
expect(refined.title).toBe(`Refinement: ${task.id}`);
|
||||
expect(refined.title).toBe("Refinement");
|
||||
});
|
||||
|
||||
it("PROMPT.md heading matches the refinement title", async () => {
|
||||
|
||||
@@ -771,6 +771,28 @@ describe("TaskStore", () => {
|
||||
});
|
||||
|
||||
|
||||
describe("title-id drift normalization", () => {
|
||||
it("normalizes foreign fn id and appends log entry", async () => {
|
||||
const task = await store.createTask({ description: "x" });
|
||||
const updated = await store.updateTask(task.id, { title: "Something FN-9999 something" });
|
||||
expect(updated.title).toBe("Something something");
|
||||
expect(updated.log.some((entry) => entry.action.includes("Title normalized"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not normalize when row id matches token", async () => {
|
||||
const task = await store.createTask({ description: "x" });
|
||||
const updated = await store.updateTask(task.id, { title: `Something ${task.id} something` });
|
||||
expect(updated.title).toBe(`Something ${task.id} something`);
|
||||
expect(updated.log.some((entry) => entry.action.includes("Title normalized"))).toBe(false);
|
||||
});
|
||||
|
||||
it("clears title when only foreign token is provided", async () => {
|
||||
const task = await store.createTask({ description: "x" });
|
||||
const updated = await store.updateTask(task.id, { title: "FN-9999" });
|
||||
expect(updated.title).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("noCommitsExpected persistence", () => {
|
||||
it("round-trips noCommitsExpected=true through create and reload", async () => {
|
||||
const created = await store.createTask({
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
56
packages/core/src/__tests__/task-title-id-drift.test.ts
Normal file
56
packages/core/src/__tests__/task-title-id-drift.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MAX_TITLE_LENGTH } from "../ai-summarize.js";
|
||||
import { extractTaskIdTokens, hasTitleIdDrift, normalizeTitleForTaskId } from "../task-title-id-drift.js";
|
||||
|
||||
describe("task-title-id-drift", () => {
|
||||
it("extracts uppercase fn ids", () => {
|
||||
expect(extractTaskIdTokens("foo fn-1 and FN-2")).toEqual(["FN-1", "FN-2"]);
|
||||
});
|
||||
|
||||
it("keeps title when it contains row id", () => {
|
||||
expect(normalizeTitleForTaskId("Fix FN-999 bug", "FN-999")).toEqual({ title: "Fix FN-999 bug", changed: false });
|
||||
});
|
||||
|
||||
it("keeps title when one of multiple ids matches row id", () => {
|
||||
expect(normalizeTitleForTaskId("Fix FN-100 and FN-999", "FN-999").changed).toBe(false);
|
||||
});
|
||||
|
||||
it("returns null when stripping empties title", () => {
|
||||
expect(normalizeTitleForTaskId("FN-123", "FN-999")).toEqual({ title: null, changed: true });
|
||||
});
|
||||
|
||||
it("handles refinement prefix", () => {
|
||||
expect(normalizeTitleForTaskId("Refinement: FN-4847: foo", "FN-9999")).toEqual({ title: "Refinement: foo", changed: true });
|
||||
});
|
||||
|
||||
it("matches case-insensitive and respects word boundaries", () => {
|
||||
expect(hasTitleIdDrift("Fix fn-123", "FN-999")).toBe(true);
|
||||
expect(normalizeTitleForTaskId("XFN-123Y", "FN-999").changed).toBe(false);
|
||||
});
|
||||
|
||||
it("passes through undefined/blank/non-fn", () => {
|
||||
expect(normalizeTitleForTaskId(undefined, "FN-1")).toEqual({ title: null, changed: false });
|
||||
expect(normalizeTitleForTaskId("", "FN-1")).toEqual({ title: "", changed: false });
|
||||
expect(normalizeTitleForTaskId("hello", "FN-1")).toEqual({ title: "hello", changed: false });
|
||||
});
|
||||
|
||||
it("caps title length", () => {
|
||||
const long = `prefix FN-100 ${"x".repeat(MAX_TITLE_LENGTH + 60)}`;
|
||||
const normalized = normalizeTitleForTaskId(long, "FN-999");
|
||||
expect(normalized.title!.length).toBeLessThanOrEqual(MAX_TITLE_LENGTH);
|
||||
});
|
||||
|
||||
it("collapses whitespace", () => {
|
||||
expect(normalizeTitleForTaskId("Fix FN-100 bug", "FN-999")).toEqual({ title: "Fix bug", changed: true });
|
||||
});
|
||||
|
||||
it("cleans trailing punctuation", () => {
|
||||
expect(normalizeTitleForTaskId("Foo FN-100:", "FN-999")).toEqual({ title: "Foo", changed: true });
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const first = normalizeTitleForTaskId("Refinement: FN-100: foo", "FN-200");
|
||||
const second = normalizeTitleForTaskId(first.title ?? "", "FN-200");
|
||||
expect(second.changed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { ArchivedTaskEntry } from "./types.js";
|
||||
import { probeFts5 } from "./db.js";
|
||||
import { hasTitleIdDrift, normalizeTitleForTaskId } from "./task-title-id-drift.js";
|
||||
|
||||
const BASE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS archived_tasks (
|
||||
@@ -81,6 +82,7 @@ export class ArchiveDatabase {
|
||||
this.db.exec(FTS5_SCHEMA_SQL);
|
||||
}
|
||||
this.addColumnIfMissing("archived_tasks", "prompt", "TEXT");
|
||||
this.normalizeDriftedTitlesOnce();
|
||||
}
|
||||
|
||||
upsert(entry: ArchivedTaskEntry): void {
|
||||
@@ -208,6 +210,38 @@ export class ArchiveDatabase {
|
||||
return rows.map((row) => JSON.parse(row.taskJson) as ArchivedTaskEntry);
|
||||
}
|
||||
|
||||
private normalizeDriftedTitlesOnce(): void {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT id, title, taskJson
|
||||
FROM archived_tasks
|
||||
WHERE title LIKE '%FN-%'
|
||||
`).all() as Array<{ id: string; title: string | null; taskJson: string }>;
|
||||
|
||||
const updateStmt = this.db.prepare("UPDATE archived_tasks SET title = ?, taskJson = ? WHERE id = ?");
|
||||
let normalizedCount = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.title || !hasTitleIdDrift(row.title, row.id)) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeTitleForTaskId(row.title, row.id);
|
||||
if (!normalized.changed) {
|
||||
continue;
|
||||
}
|
||||
let parsed: ArchivedTaskEntry;
|
||||
try {
|
||||
parsed = JSON.parse(row.taskJson) as ArchivedTaskEntry;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
parsed.title = normalized.title ?? undefined;
|
||||
updateStmt.run(normalized.title, JSON.stringify(parsed), row.id);
|
||||
normalizedCount += 1;
|
||||
}
|
||||
|
||||
console.log(`[title-id-drift] archive-db normalized ${normalizedCount} archived titles`);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { createHash, randomUUID } from "node:crypto";
|
||||
import { DEFAULT_PROJECT_SETTINGS } from "./types.js";
|
||||
import type { PluginOnSchemaInit } from "./plugin-types.js";
|
||||
import type { SteeringComment, TaskComment } from "./types.js";
|
||||
import { hasTitleIdDrift, normalizeTitleForTaskId } from "./task-title-id-drift.js";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -119,7 +120,7 @@ export function probeFts5(db: DatabaseSync): boolean {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 83;
|
||||
const SCHEMA_VERSION = 84;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -3369,6 +3370,36 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 84) {
|
||||
this.applyMigration(84, () => {
|
||||
if (!this.hasColumn("tasks", "title")) {
|
||||
console.log("[title-id-drift] db.ts migration normalized 0 active titles");
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = this.db.prepare("SELECT id, title FROM tasks WHERE title IS NOT NULL").all() as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
}>;
|
||||
const updateStmt = this.db.prepare("UPDATE tasks SET title = ? WHERE id = ?");
|
||||
let normalizedCount = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!hasTitleIdDrift(row.title, row.id)) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeTitleForTaskId(row.title, row.id);
|
||||
if (!normalized.changed) {
|
||||
continue;
|
||||
}
|
||||
updateStmt.run(normalized.title, row.id);
|
||||
normalizedCount += 1;
|
||||
}
|
||||
|
||||
console.log(`[title-id-drift] db.ts migration normalized ${normalizedCount} active titles`);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -152,6 +152,12 @@ export type { StalledReviewSignal } from "./stalled-review-detector.js";
|
||||
export {
|
||||
detectTaskIdIntegrityAnomalies,
|
||||
} from "./task-id-integrity.js";
|
||||
export {
|
||||
TASK_ID_TOKEN_RE,
|
||||
extractTaskIdTokens,
|
||||
hasTitleIdDrift,
|
||||
normalizeTitleForTaskId,
|
||||
} from "./task-title-id-drift.js";
|
||||
export type {
|
||||
TaskIdIntegrityAnomaly,
|
||||
TaskIdIntegrityAnomalyKind,
|
||||
|
||||
@@ -34,6 +34,7 @@ import { runCommandAsync } from "./run-command.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { validateNodeOverrideChange } from "./node-override-guard.js";
|
||||
import { sanitizeTitle } from "./ai-summarize.js";
|
||||
import { extractTaskIdTokens, normalizeTitleForTaskId } from "./task-title-id-drift.js";
|
||||
import { getErrorMessage } from "./error-message.js";
|
||||
import { getTaskCreatedHook } from "./task-creation-hooks.js";
|
||||
import { assertProjectRootDir } from "./project-root-guard.js";
|
||||
@@ -1715,6 +1716,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
/**
|
||||
* Upsert a task to the database. Update paths intentionally retain ON CONFLICT
|
||||
* semantics; create paths must use insertTask() instead.
|
||||
* FN-4898: this low-level persistence path intentionally does not normalize
|
||||
* titles because replication/restore flows may carry authoritative bytes.
|
||||
*/
|
||||
private upsertTask(task: Task): void {
|
||||
const values = this.getTaskPersistValues(task);
|
||||
@@ -2992,11 +2995,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
Promise.resolve().then(async () => {
|
||||
try {
|
||||
const generatedTitle = await options.onSummarize!(input.description);
|
||||
const normalizedTitle = sanitizeTitle(generatedTitle);
|
||||
if (normalizedTitle) {
|
||||
const sanitizedTitle = sanitizeTitle(generatedTitle);
|
||||
if (sanitizedTitle) {
|
||||
const currentTask = this.readTaskFromDb(id);
|
||||
if (currentTask && !currentTask.title) {
|
||||
await this.updateTask(id, { title: normalizedTitle });
|
||||
const normalizedTitle = normalizeTitleForTaskId(sanitizedTitle, id);
|
||||
if (normalizedTitle.title) {
|
||||
await this.updateTask(id, { title: normalizedTitle.title });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -3116,6 +3122,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
// Intentionally does not invoke the post-create hook. Replicated tasks mirror
|
||||
// state from an origin node; rerunning side effects here (e.g. GitHub issue
|
||||
// creation) would duplicate external artifacts.
|
||||
// FN-4898: replicated creates route via _createTaskInternal so drift normalization
|
||||
// is applied exactly once (same behavior as user-originated writes).
|
||||
const existing = this.readTaskFromDb(payload.taskId);
|
||||
if (existing) {
|
||||
const existingDetail = await this.getTask(payload.taskId);
|
||||
@@ -3154,10 +3162,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
},
|
||||
): Promise<Task> {
|
||||
const now = options?.createdAt ?? new Date().toISOString();
|
||||
const normalizedTitle = normalizeTitleForTaskId(title, id);
|
||||
const task: Task = {
|
||||
id,
|
||||
lineageId: input.lineageId ?? generateTaskLineageId(),
|
||||
title,
|
||||
title: normalizedTitle.title ?? undefined,
|
||||
description: input.description,
|
||||
priority: normalizeTaskPriority(input.priority),
|
||||
tokenUsage: input.tokenUsage,
|
||||
@@ -3203,6 +3212,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
updatedAt: options?.updatedAt ?? now,
|
||||
};
|
||||
|
||||
if (normalizedTitle.changed) {
|
||||
task.log.push({
|
||||
timestamp: now,
|
||||
action: "Title normalized: stripped legacy task-id reference",
|
||||
});
|
||||
const removed = extractTaskIdTokens(title ?? "").filter((token) => token !== id.toUpperCase());
|
||||
storeLog.log(`[title-id-drift] normalized title for ${id}: removed=[${removed.join(",")}]`);
|
||||
}
|
||||
|
||||
this.assertTaskIdAvailable(id);
|
||||
|
||||
const dir = this.taskDir(id);
|
||||
@@ -3256,10 +3274,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
return this.createTaskWithDistributedReservation({ description: sourceTask.description }, {
|
||||
createTaskWithId: async (newId) => {
|
||||
const normalizedTitle = normalizeTitleForTaskId(sourceTask.title, newId);
|
||||
if (normalizedTitle.changed) {
|
||||
const removed = extractTaskIdTokens(sourceTask.title ?? "").filter((token) => token !== newId.toUpperCase());
|
||||
storeLog.log(`[title-id-drift] normalized title for ${newId}: removed=[${removed.join(",")}]`);
|
||||
}
|
||||
const newTask: Task = {
|
||||
id: newId,
|
||||
lineageId: generateTaskLineageId(),
|
||||
title: sourceTask.title,
|
||||
title: normalizedTitle.title ?? undefined,
|
||||
description: `${sourceTask.description}\n\n(Duplicated from ${id})`,
|
||||
priority: normalizeTaskPriority(sourceTask.priority),
|
||||
column: "triage",
|
||||
@@ -3332,10 +3355,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
return this.createTaskWithDistributedReservation({ description: feedback.trim() }, {
|
||||
createTaskWithId: async (newId) => {
|
||||
const normalizedTitle = normalizeTitleForTaskId(`Refinement: ${sourceLabel}`, newId);
|
||||
if (normalizedTitle.changed) {
|
||||
const removed = extractTaskIdTokens(`Refinement: ${sourceLabel}`).filter((token) => token !== newId.toUpperCase());
|
||||
storeLog.log(`[title-id-drift] normalized title for ${newId}: removed=[${removed.join(",")}]`);
|
||||
}
|
||||
const newTask: Task = {
|
||||
id: newId,
|
||||
lineageId: generateTaskLineageId(),
|
||||
title: `Refinement: ${sourceLabel}`,
|
||||
title: normalizedTitle.title ?? "Refinement",
|
||||
description: `${feedback.trim()}\n\nRefines: ${id}`,
|
||||
priority: normalizeTaskPriority(sourceTask.priority),
|
||||
column: "triage",
|
||||
@@ -4389,7 +4417,22 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.log = [];
|
||||
}
|
||||
|
||||
if (updates.title !== undefined) task.title = updates.title;
|
||||
let titleNormalized = false;
|
||||
if (updates.title !== undefined) {
|
||||
task.title = updates.title;
|
||||
const normalizedTitle = normalizeTitleForTaskId(task.title, id);
|
||||
if (normalizedTitle.changed) {
|
||||
titleNormalized = true;
|
||||
const removed = extractTaskIdTokens(task.title ?? "").filter((token) => token !== id.toUpperCase());
|
||||
task.title = normalizedTitle.title ?? undefined;
|
||||
task.log.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "Title normalized: stripped legacy task-id reference",
|
||||
...(runContext ? { runContext } : {}),
|
||||
});
|
||||
storeLog.log(`[title-id-drift] normalized title for ${id}: removed=[${removed.join(",")}]`);
|
||||
}
|
||||
}
|
||||
if (updates.description !== undefined) task.description = updates.description;
|
||||
if (updates.priority === null) {
|
||||
task.priority = normalizeTaskPriority(undefined);
|
||||
@@ -4873,7 +4916,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
domain: "database",
|
||||
mutationType: "task:update",
|
||||
target: task.id,
|
||||
metadata: { updatedFields: Object.keys(updates).filter((k) => (updates as Record<string, unknown>)[k] !== undefined) },
|
||||
metadata: {
|
||||
updatedFields: Object.keys(updates).filter((k) => (updates as Record<string, unknown>)[k] !== undefined),
|
||||
...(titleNormalized ? { titleNormalized: true } : {}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
|
||||
55
packages/core/src/task-title-id-drift.ts
Normal file
55
packages/core/src/task-title-id-drift.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { MAX_TITLE_LENGTH } from "./ai-summarize.js";
|
||||
|
||||
export const TASK_ID_TOKEN_RE = /\bFN-(\d+)\b/gi;
|
||||
|
||||
const CONNECTOR_RE = /[:\-—–]/;
|
||||
|
||||
export function extractTaskIdTokens(title: string): string[] {
|
||||
const tokens = new Set<string>();
|
||||
for (const match of title.matchAll(TASK_ID_TOKEN_RE)) {
|
||||
const raw = match[0];
|
||||
if (raw) {
|
||||
tokens.add(raw.toUpperCase());
|
||||
}
|
||||
}
|
||||
return [...tokens];
|
||||
}
|
||||
|
||||
export function hasTitleIdDrift(title: string | undefined | null, rowId: string): boolean {
|
||||
if (!title) return false;
|
||||
const tokens = extractTaskIdTokens(title);
|
||||
if (tokens.length === 0) return false;
|
||||
const normalizedRowId = rowId.toUpperCase();
|
||||
return !tokens.some((token) => token === normalizedRowId);
|
||||
}
|
||||
|
||||
export function normalizeTitleForTaskId(
|
||||
title: string | undefined | null,
|
||||
rowId: string,
|
||||
): { title: string | null; changed: boolean } {
|
||||
const initial = title ?? null;
|
||||
if (initial === null || initial.length === 0) {
|
||||
return { title: initial, changed: false };
|
||||
}
|
||||
if (!hasTitleIdDrift(initial, rowId)) {
|
||||
return { title: initial, changed: false };
|
||||
}
|
||||
|
||||
let normalized = initial.replace(/\bFN-\d+\b\s*([:\-—–])?\s*/gi, " ");
|
||||
normalized = normalized
|
||||
.replace(/\s+([,:;.!?])/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
normalized = normalized
|
||||
.replace(new RegExp(`(?:\\s*${CONNECTOR_RE.source}\\s*)+$`), "")
|
||||
.replace(/[,:;.!?]+$/g, "")
|
||||
.trim();
|
||||
|
||||
if (normalized.length > MAX_TITLE_LENGTH) {
|
||||
normalized = normalized.slice(0, MAX_TITLE_LENGTH).trim();
|
||||
}
|
||||
|
||||
const nextTitle = normalized.length > 0 ? normalized : null;
|
||||
return { title: nextTitle, changed: nextTitle !== initial };
|
||||
}
|
||||
@@ -21,7 +21,7 @@ describeIfGit("reliability interactions: self-defeating dep reconciliation", ()
|
||||
});
|
||||
fixtures.push(fx);
|
||||
|
||||
await fx.store.updateTask(fx.task.id, { title: "Finalize FN-100: close loop" });
|
||||
fx.store.getDatabase().prepare("UPDATE tasks SET title = ? WHERE id = ?").run("Finalize FN-100: close loop", fx.task.id);
|
||||
|
||||
const recovered = await fx.manager.reconcileSelfDefeatingDependencies();
|
||||
expect(recovered).toBe(1);
|
||||
@@ -76,7 +76,7 @@ describeIfGit("reliability interactions: self-defeating dep reconciliation", ()
|
||||
});
|
||||
fixtures.push(fx);
|
||||
|
||||
await fx.store.updateTask(fx.task.id, { title: "Finalize FN-100" });
|
||||
fx.store.getDatabase().prepare("UPDATE tasks SET title = ? WHERE id = ?").run("Finalize FN-100", fx.task.id);
|
||||
|
||||
const recovered = await fx.manager.reconcileSelfDefeatingDependencies();
|
||||
expect(recovered).toBe(0);
|
||||
|
||||
@@ -743,8 +743,8 @@ describe("RoadmapStore", () => {
|
||||
});
|
||||
|
||||
describe("schema version", () => {
|
||||
it("schema version is 83 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(83);
|
||||
it("schema version is 84 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(84);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user