From d915704223c68f2a0291774dcf2dfb243b5a4f6c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 15:12:56 +0000 Subject: [PATCH] fix(graph): sweep orphan owned-prefix files after leased maintenance Remove leftover graph.db.candidate-*, rollback-*, and recovery-* files once a refresh, repair, or rebuild holds the exclusive lease. graph status warns while one is present and does not mutate the tree. Fixes #205 Co-authored-by: David --- CHANGELOG.md | 1 + src/graph/__tests__/maintenance.test.ts | 86 +++++++++++++++++++++++++ src/graph/__tests__/status.test.ts | 33 ++++++++++ src/graph/maintenance.ts | 24 +++++-- src/graph/owned-database.ts | 47 ++++++++++++++ src/graph/status.ts | 37 ++++++++++- src/hub/services.ts | 1 + 7 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 src/graph/owned-database.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 989d8b3b..e780b0b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to this project will be documented in this file. - Project Hub Overview now opens with a compact Context card above the atlas instead of a header button. It links to Context when the Wiki index is fresh and to Health otherwise, so a stale or unavailable index no longer leads to a page that cannot load. ### Fixed +- Interrupted `mex graph rebuild` leftover `graph.db.candidate-*`, `graph.db.rollback-*`, and `graph.db.recovery-*` files are removed after the next leased refresh, repair, or rebuild. `mex graph status` warns while one is present and does not delete it (#205). - Agent population failures retain the real copyable manual prompt for retry or manual continuation. Integration pointer notes are visible as non-blocking guidance. - Setup and Overview share the computer's contact preference so completing or skipping the invitation does not immediately trigger another request. diff --git a/src/graph/__tests__/maintenance.test.ts b/src/graph/__tests__/maintenance.test.ts index 17928989..adb41f39 100644 --- a/src/graph/__tests__/maintenance.test.ts +++ b/src/graph/__tests__/maintenance.test.ts @@ -88,6 +88,24 @@ function ownedArtifacts(root: string): string[] { .sort(); } +function plantOwnedPrefixOrphans(root: string): { + candidate: string; + rollback: string; + recovery: string; + keep: string; +} { + const mexDir = join(root, ".mex"); + const candidate = join(mexDir, "graph.db.candidate-orphan01"); + const rollback = join(mexDir, "graph.db.rollback-orphan01"); + const recovery = join(mexDir, "graph.db.recovery-orphan01"); + const keep = join(mexDir, "unrelated-notes.txt"); + writeFileSync(candidate, "leftover-candidate"); + writeFileSync(rollback, "leftover-rollback"); + writeFileSync(recovery, "leftover-recovery"); + writeFileSync(keep, "do-not-delete"); + return { candidate, rollback, recovery, keep }; +} + function git(root: string, ...args: string[]): string { return execFileSync("git", args, { cwd: root, @@ -1052,4 +1070,72 @@ describe("graph maintenance", () => { expect((await inspectGraphStatus({ projectRoot: root })).status).toBe("fresh"); expect(ownedArtifacts(root)).toEqual([]); }); + + it.each([ + ["refresh", refreshGraph], + ["repair", repairGraph], + ["rebuild", rebuildGraph], + ] as const)("removes orphan owned-prefix files after leased %s and leaves unrelated .mex files", async (_command, run) => { + const root = temporaryRoot(); + source(root, "src/service.ts", "export const service = 1;\n"); + await buildBaseline(root); + const planted = plantOwnedPrefixOrphans(root); + + const result = await run(root); + + expect(result.state).toBe("succeeded"); + expect(existsSync(planted.candidate)).toBe(false); + expect(existsSync(planted.rollback)).toBe(false); + expect(existsSync(planted.recovery)).toBe(false); + expect(readFileSync(planted.keep, "utf8")).toBe("do-not-delete"); + expect(existsSync(join(root, ".mex", "graph.db"))).toBe(true); + expect((await inspectGraphStatus({ projectRoot: root })).diagnostics) + .not.toContainEqual(expect.objectContaining({ code: "GRAPH_INDEX_ORPHAN_OWNED_DATABASE" })); + }, 30_000); + + it("sweeps orphans only after the maintenance lease is held", async () => { + const root = temporaryRoot(); + source(root, "src/service.ts", "export const service = 1;\n"); + await buildBaseline(root); + const planted = plantOwnedPrefixOrphans(root); + const options = { + __internal: { + afterLockAcquired() { + expect(existsSync(planted.candidate)).toBe(true); + writeFileSync(join(root, ".mex", "graph.db.candidate-after-lock"), "planted-under-lease"); + }, + }, + } as GraphMaintenanceOptions; + + await refreshGraph(root, options); + + expect(existsSync(planted.candidate)).toBe(false); + expect(existsSync(planted.rollback)).toBe(false); + expect(existsSync(planted.recovery)).toBe(false); + expect(existsSync(join(root, ".mex", "graph.db.candidate-after-lock"))).toBe(false); + expect(readFileSync(planted.keep, "utf8")).toBe("do-not-delete"); + }, 30_000); + + it("does not sweep orphan owned-prefix files while another lease is held", async () => { + const root = temporaryRoot(); + source(root, "src/service.ts", "export const service = 1;\n"); + await buildBaseline(root); + const lease = acquireGraphMaintenanceLease(root, "refresh"); + try { + const planted = plantOwnedPrefixOrphans(root); + + await expect(refreshGraph(root)).rejects.toMatchObject({ + code: "GRAPH_MAINTENANCE_LOCKED", + }); + + expect(existsSync(planted.candidate)).toBe(true); + expect(existsSync(planted.rollback)).toBe(true); + expect(existsSync(planted.recovery)).toBe(true); + expect(readFileSync(planted.keep, "utf8")).toBe("do-not-delete"); + expect((await inspectGraphStatus({ projectRoot: root })).diagnostics) + .toContainEqual(expect.objectContaining({ code: "GRAPH_INDEX_ORPHAN_OWNED_DATABASE" })); + } finally { + lease.release(); + } + }, 30_000); }); diff --git a/src/graph/__tests__/status.test.ts b/src/graph/__tests__/status.test.ts index b6dc79b4..9d6a678f 100644 --- a/src/graph/__tests__/status.test.ts +++ b/src/graph/__tests__/status.test.ts @@ -1595,4 +1595,37 @@ describe("inspectGraphStatus", () => { remediation: [{ label: "Republish graph for this branch", command: "mex graph refresh" }], })); }); + + it("warns about orphan owned-prefix files without mutating the tree", async () => { + const root = temporaryRoot("mex-graph-status-orphan-"); + source(root, "src/service.ts", "export const service = 1;\n"); + await build(root); + const mexDir = join(root, ".mex"); + const candidate = join(mexDir, "graph.db.candidate-deadc0de"); + const rollback = join(mexDir, "graph.db.rollback-deadc0de"); + const recovery = join(mexDir, "graph.db.recovery-deadc0de"); + writeFileSync(candidate, "orphan-candidate"); + writeFileSync(rollback, "orphan-rollback"); + writeFileSync(recovery, "orphan-recovery"); + const before = treeState(root); + + const status = await inspect(root); + + expect(status.status).toBe("fresh"); + expect(status.diagnostics).toContainEqual(expect.objectContaining({ + code: "GRAPH_INDEX_ORPHAN_OWNED_DATABASE", + severity: "warning", + path: ".mex/graph.db.candidate-deadc0de", + message: expect.stringContaining("graph.db.candidate-deadc0de"), + })); + expect(status.diagnostics.find((entry) => entry.code === "GRAPH_INDEX_ORPHAN_OWNED_DATABASE")?.message) + .toEqual(expect.stringContaining("graph.db.rollback-deadc0de")); + expect(status.diagnostics.find((entry) => entry.code === "GRAPH_INDEX_ORPHAN_OWNED_DATABASE")?.message) + .toEqual(expect.stringContaining("graph.db.recovery-deadc0de")); + expect(executableRemediations(status)).toContain("mex graph refresh"); + expect(treeState(root)).toEqual(before); + expect(existsSync(candidate)).toBe(true); + expect(existsSync(rollback)).toBe(true); + expect(existsSync(recovery)).toBe(true); + }); }); diff --git a/src/graph/maintenance.ts b/src/graph/maintenance.ts index 23a5143c..10ad2158 100644 --- a/src/graph/maintenance.ts +++ b/src/graph/maintenance.ts @@ -36,6 +36,7 @@ import { inspectGraphStatus, } from "./status.js"; import { GRAPH_SNAPSHOT_METADATA_KEY } from "./snapshot.js"; +import { isOwnedDatabaseBasename, listOwnedDatabaseArtifacts } from "./owned-database.js"; import { tryEnsureSetupIgnoreProtection } from "../setup/ignore.js"; import { GraphCandidateProcessError, runGraphCandidateProcess, type GraphCandidateProcessOptions } from "./candidate-process.js"; @@ -43,11 +44,6 @@ const LOCK_FILE = "graph.db.lock"; const LOCK_GATE_FILE = "graph.db.lock.gate"; const MAX_LOCK_BYTES = 4 * 1024; const HASH_CHUNK_BYTES = 1024 * 1024; -const OWNED_DATABASE_PREFIXES = [ - "graph.db.candidate-", - "graph.db.rollback-", - "graph.db.recovery-", -] as const; export type GraphMaintenanceErrorCode = | "GRAPH_INDEX_MISSING" @@ -285,6 +281,8 @@ export function acquireGraphMaintenanceLease( } as InternalMaintenanceOptions; await merged.__internal?.afterLockAcquired?.(); assertMaintenanceDirectoryUnchanged(paths); + // Exclusive owner only. Sweeping before the lease would race a live run. + sweepOrphanOwnedDatabases(paths); if (operation === "refresh") return await refreshGraphWithLease(paths, merged); if (operation === "repair") return await repairGraphWithLease(paths, merged); return await rebuildGraphWithLease(paths, merged); @@ -2016,6 +2014,20 @@ function ownedPath(paths: MaintenancePaths, kind: "candidate" | "rollback" | "re return path; } +function sweepOrphanOwnedDatabases(paths: MaintenancePaths, keepPath?: string | null): void { + assertMaintenanceDirectoryUnchanged(paths); + const keep = new Set(); + if (keepPath) { + const keepName = basename(keepPath); + keep.add(keepName); + for (const suffix of ["-wal", "-shm", "-journal"] as const) keep.add(`${keepName}${suffix}`); + } + for (const name of listOwnedDatabaseArtifacts(paths.mexDir)) { + if (keep.has(name)) continue; + cleanupOwnedDatabasePath(paths, join(paths.mexDir, name)); + } +} + function cleanupOwnedDatabase(paths: MaintenancePaths, path: string): void { assertMaintenanceDirectoryUnchanged(paths); if (dirname(path) !== paths.mexDir) return; @@ -2098,7 +2110,7 @@ function cleanupDiscardedOwnedSidecars(paths: MaintenancePaths, databasePath: st } function assertOwnedDatabasePath(path: string): void { - if (!OWNED_DATABASE_PREFIXES.some((prefix) => basename(path).startsWith(prefix))) { + if (!isOwnedDatabaseBasename(basename(path))) { throw new GraphMaintenanceError( "GRAPH_MAINTENANCE_PATH_UNSAFE", "Refusing to modify a path not owned by graph maintenance.", diff --git a/src/graph/owned-database.ts b/src/graph/owned-database.ts new file mode 100644 index 00000000..2739edc0 --- /dev/null +++ b/src/graph/owned-database.ts @@ -0,0 +1,47 @@ +import { lstatSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +/** Same-directory files owned by graph maintenance publication/recovery. */ +export const OWNED_DATABASE_PREFIXES = [ + "graph.db.candidate-", + "graph.db.rollback-", + "graph.db.recovery-", +] as const; + +export function isOwnedDatabaseBasename(name: string): boolean { + return OWNED_DATABASE_PREFIXES.some((prefix) => name.startsWith(prefix)); +} + +/** + * Regular files in `.mex/` whose names start with an owned maintenance prefix. + * Read-only: does not follow a symlink `.mex` directory or any symlink entry. + */ +export function listOwnedDatabaseArtifacts(mexDir: string): string[] { + let directoryStats; + try { + directoryStats = lstatSync(mexDir); + } catch { + return []; + } + if (!directoryStats.isDirectory() || directoryStats.isSymbolicLink()) return []; + + let names: string[]; + try { + names = readdirSync(mexDir); + } catch { + return []; + } + + const found: string[] = []; + for (const name of names) { + if (!isOwnedDatabaseBasename(name)) continue; + try { + const stats = lstatSync(join(mexDir, name)); + if (!stats.isFile() || stats.isSymbolicLink()) continue; + found.push(name); + } catch { + // An entry that disappears between readdir and lstat is not reported. + } + } + return found.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); +} diff --git a/src/graph/status.ts b/src/graph/status.ts index c12d0fef..f0b18ae0 100644 --- a/src/graph/status.ts +++ b/src/graph/status.ts @@ -11,7 +11,7 @@ import { statSync, } from "node:fs"; import { lstat as lstatAsync, open as openAsync } from "node:fs/promises"; -import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { isSameResolvedPath, resolveRealPath, resolveRealPathAsync } from "../paths.js"; import { promisify } from "node:util"; import type { @@ -38,6 +38,7 @@ import { } from "./corpus-policy.js"; import { graphManifest, graphManifestDiffersOnlyByConfig } from "./engine-impl.js"; import { isSupportedSourceFile } from "./extraction/grammars.js"; +import { listOwnedDatabaseArtifacts } from "./owned-database.js"; import { bandHashInts, decodeMinhash } from "./fingerprint.js"; import type { Fingerprint } from "./reconcile.js"; import { @@ -462,7 +463,7 @@ export async function inspectGraphStatusWithFreshObservation( lastAttempt = inspected; if (!inspected.retry) { return { - graphStatus: inspected.status, + graphStatus: attachOrphanOwnedDatabaseWarning(projectRoot, inspected.status), freshObservation: inspected.freshObservation ?? null, degradedObservation: inspected.degradedObservation ?? null, configDriftTolerated: inspected.configDriftTolerated === true, @@ -470,7 +471,7 @@ export async function inspectGraphStatusWithFreshObservation( } } return { - graphStatus: lastAttempt!.status, + graphStatus: attachOrphanOwnedDatabaseWarning(projectRoot, lastAttempt!.status), freshObservation: null, degradedObservation: null, configDriftTolerated: lastAttempt!.configDriftTolerated === true, @@ -1309,6 +1310,36 @@ function emptySourceChanges(): GraphSourceChanges { }; } +const ORPHAN_OWNED_DATABASE_LIST_LIMIT = 8; + +function attachOrphanOwnedDatabaseWarning(projectRoot: string, status: GraphStatus): GraphStatus { + const extra = orphanOwnedDatabaseDiagnostics(projectRoot); + if (extra.length === 0) return status; + return { + ...status, + diagnostics: [...status.diagnostics, ...extra], + }; +} + +function orphanOwnedDatabaseDiagnostics(projectRoot: string): Diagnostic[] { + const names = listOwnedDatabaseArtifacts(join(projectRoot, ".mex")); + if (names.length === 0) return []; + const shown = names.slice(0, ORPHAN_OWNED_DATABASE_LIST_LIMIT); + const omitted = names.length - shown.length; + const listed = shown.join(", "); + const more = omitted > 0 ? `, and ${omitted} more` : ""; + return [{ + code: "GRAPH_INDEX_ORPHAN_OWNED_DATABASE", + severity: "warning", + message: `Orphan graph maintenance file(s) remain from an interrupted rebuild or recovery (${listed}${more}). A later mex graph refresh, repair, or rebuild will remove them.`, + path: toPosix(join(".mex", names[0]!)), + remediation: [{ + label: "Remove leftover graph maintenance files", + command: "mex graph refresh", + }], + }]; +} + function sidecarDiagnostic(probe: GraphSidecarProbe): Diagnostic { const paths = probe.paths.join(", "); const repairableWal = probe.state === "active" diff --git a/src/hub/services.ts b/src/hub/services.ts index bdb6c062..648515f4 100644 --- a/src/hub/services.ts +++ b/src/hub/services.ts @@ -1851,6 +1851,7 @@ function graphDiagnosticMessage(code: string): string { GRAPH_INDEX_REBUILD_REQUIRED: "The graph requires an explicit rebuild.", GRAPH_INDEX_CORRUPT: "The graph failed an integrity check.", GRAPH_INDEX_SIDECAR_ACTIVE: "Graph maintenance is currently publishing local changes.", + GRAPH_INDEX_ORPHAN_OWNED_DATABASE: "An interrupted graph rebuild left a leftover candidate, rollback, or recovery file.", GRAPH_STATUS_OBSERVATION_RACE: "Repository state changed during graph inspection.", }; return messages[code] ?? "The graph reported a bounded local health diagnostic.";