Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
86 changes: 86 additions & 0 deletions src/graph/__tests__/maintenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
33 changes: 33 additions & 0 deletions src/graph/__tests__/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
24 changes: 18 additions & 6 deletions src/graph/maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,14 @@ 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";

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"
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string>();
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;
Expand Down Expand Up @@ -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.",
Expand Down
47 changes: 47 additions & 0 deletions src/graph/owned-database.ts
Original file line number Diff line number Diff line change
@@ -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));
}
37 changes: 34 additions & 3 deletions src/graph/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -462,15 +463,15 @@ 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,
};
}
}
return {
graphStatus: lastAttempt!.status,
graphStatus: attachOrphanOwnedDatabaseWarning(projectRoot, lastAttempt!.status),
freshObservation: null,
degradedObservation: null,
configDriftTolerated: lastAttempt!.configDriftTolerated === true,
Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/hub/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down