Skip to content

Commit 6648add

Browse files
feat(index): cross-process lock, unlock CLI, and errors.log (#129)
Acquire <state-dir>/index.lock before indexing with in-process serialization, fail-fast when another live indexer holds the lock, and append parse failures to errors.log. Adds codemap unlock for stale lock recovery.
1 parent 4318cc4 commit 6648add

13 files changed

Lines changed: 440 additions & 23 deletions

.changeset/index-lock-error-log.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stainless-code/codemap": minor
3+
---
4+
5+
Add cross-process index lock with `codemap unlock`, and append parse failures to `<state-dir>/errors.log`.

docs/agents.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ All integrations reuse the **same** bundled content under **`.agents/`**. Symlin
6060

6161
When the file watcher is off (WSL `/mnt/*` mounts, `CODEMAP_WATCH=0`, etc.), **`codemap agents init --git-hooks`** installs marker-delimited blocks in **`post-commit`**, **`post-merge`**, and **`post-checkout`** that run `( codemap >/dev/null 2>&1 & )` — non-blocking background incremental index. **`--no-git-hooks`** removes only codemap-marked blocks. Interactive init offers hooks automatically when [`watch-policy.ts`](../src/application/watch-policy.ts) would disable the watcher for the project root.
6262

63+
Concurrent indexers (CLI, MCP `--watch`, git hooks) coordinate via **`<state-dir>/index.lock`**. If indexing fails with “Index already running” after a crash, run **`codemap unlock`**. Per-file parse failures append to **`<state-dir>/errors.log`**.
64+
6365
## Pointer files
6466

6567
Root / Copilot **pointer** files (**`CLAUDE.md`**, **`AGENTS.md`**, **`GEMINI.md`**, **`.github/copilot-instructions.md`**) use a **managed section** between **`<!-- codemap-pointer:begin -->`** and **`<!-- codemap-pointer:end -->`** (HTML comments — usually hidden in rendered Markdown):

docs/plans/agent-surface-delivery.md

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010

1111
## Quick resume
1212

13-
| Next action | Detail |
14-
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15-
| **Review / merge P0** | PR [#126](https://github.com/stainless-code/codemap/pull/126) (MCP instructions + allowlist), PR [#127](https://github.com/stainless-code/codemap/pull/127) (WSL watch + git hooks) |
16-
| **Start next** | **PR 3**[`index-lock-and-error-log`](./index-lock-and-error-log.md) on branch from fresh `main` |
17-
| **Do not start yet** | PR 6 (MCP trace tools) until PR 1 + PR 4 land; PR 9 (eval harness) until PR 1 + PR 8 |
13+
| Next action | Detail |
14+
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
15+
| **Review / merge** | PR 3 — index lock + error log (branch `feat/index-lock`) when open |
16+
| **Start next** | **PR 4**trace recipes (`call-path`, `symbol-neighborhood`) or **PR 5**`affected-tests-recipe` (parallel with 4 after 3 merges) |
17+
| **Do not start yet** | PR 6 (MCP trace tools) until PR 4 land; PR 9 (eval harness) until PR 8 |
1818

1919
Update the table below when a PR merges or a new branch opens.
2020

@@ -26,24 +26,24 @@ Merge each PR to `main` directly. No long-lived integration branch (`feat/agent-
2626

2727
### Wave 1 — P0 (~1 week wall clock, 2 PRs)
2828

29-
| PR | Plans bundled | Status | Branch / link | Notes |
30-
| ----- | ----------------------------------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
31-
| **1** | [`mcp-server-instructions`](./mcp-server-instructions.md) + [`mcp-tool-allowlist`](./mcp-tool-allowlist.md) | open | [`feat/mcp-instructions-allowlist`](https://github.com/stainless-code/codemap/pull/126) | Same hot files (`mcp-server.ts`, `agent-content`); ~3–4 days |
32-
| **2** | [`wsl-watch-policy`](./wsl-watch-policy.md)[`git-hook-auto-sync`](./git-hook-auto-sync.md) | open | [`feat/wsl-watch-git-hooks`](https://github.com/stainless-code/codemap/pull/127) | `watch-policy.ts` first; hooks reference it in diagnostics. Lock deferred to PR 3 — note concurrent hook + MCP in PR 2 body |
29+
| PR | Plans bundled | Status | Branch / link | Notes |
30+
| ----- | ----------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
31+
| **1** | [`mcp-server-instructions`](./mcp-server-instructions.md) + [`mcp-tool-allowlist`](./mcp-tool-allowlist.md) | merged | [#126](https://github.com/stainless-code/codemap/pull/126) | Same hot files (`mcp-server.ts`, `agent-content`); ~3–4 days |
32+
| **2** | [`wsl-watch-policy`](./wsl-watch-policy.md)[`git-hook-auto-sync`](./git-hook-auto-sync.md) | merged | [#127](https://github.com/stainless-code/codemap/pull/127) | `watch-policy.ts` first; hooks reference it in diagnostics. Lock deferred to PR 3 — note concurrent hook + MCP in PR 2 body |
3333

3434
### Wave 2 — P1 (~2–3 weeks, parallel tracks)
3535

3636
Max **3 parallel tracks** at once.
3737

38-
| PR | Plans | Status | Blocked by | Parallel with |
39-
| ----- | ----------------------------------------------------------------------------------------------------------------------------- | ------- | ----------------------------------- | --------------------------------- |
40-
| **3** | [`index-lock-and-error-log`](./index-lock-and-error-log.md)[`parse-worker-hardening`](./parse-worker-hardening.md) (stack) | planned | PR 2 merged (doc note on hook+lock) | 4, 5 |
41-
| **4** | Recipe half of [`mcp-trace-explore-tools`](./mcp-trace-explore-tools.md) (`call-path`, `symbol-neighborhood` SQL + tests) | planned | | 3, 5 |
42-
| **5** | [`affected-tests-recipe`](./affected-tests-recipe.md) | planned | | 3, 4 |
43-
| **6** | MCP half of trace (`trace` / `explore` / `node` tools) + update instructions | planned | PR 1, PR 4 ||
44-
| **7** | [`field-qualified-search`](./field-qualified-search.md) | planned | PR 1 | 4, 5 if `mcp-server.ts` untouched |
45-
| **8** | [`agents-init-mcp-wiring`](./agents-init-mcp-wiring.md) | planned | PR 1 | 3–5 |
46-
| **9** | [`agent-eval-harness`](./agent-eval-harness.md) | planned | PR 1, PR 8, allowlist | **last P1** |
38+
| PR | Plans | Status | Blocked by | Parallel with |
39+
| ----- | ----------------------------------------------------------------------------------------------------------------------------- | ------- | --------------------- | --------------------------------- |
40+
| **3** | [`index-lock-and-error-log`](./index-lock-and-error-log.md)[`parse-worker-hardening`](./parse-worker-hardening.md) (stack) | open | PR 2 merged | 4, 5 |
41+
| **4** | Recipe half of [`mcp-trace-explore-tools`](./mcp-trace-explore-tools.md) (`call-path`, `symbol-neighborhood` SQL + tests) | planned || 3, 5 |
42+
| **5** | [`affected-tests-recipe`](./affected-tests-recipe.md) | planned || 3, 4 |
43+
| **6** | MCP half of trace (`trace` / `explore` / `node` tools) + update instructions | planned | PR 1, PR 4 ||
44+
| **7** | [`field-qualified-search`](./field-qualified-search.md) | planned | PR 1 | 4, 5 if `mcp-server.ts` untouched |
45+
| **8** | [`agents-init-mcp-wiring`](./agents-init-mcp-wiring.md) | planned | PR 1 | 3–5 |
46+
| **9** | [`agent-eval-harness`](./agent-eval-harness.md) | planned | PR 1, PR 8, allowlist | **last P1** |
4747

4848
**Parallelization constraints**
4949

src/application/error-log.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { appendFileSync, mkdirSync } from "node:fs";
2+
import { join } from "node:path";
3+
4+
/** Append-only per-file index failure log inside `<state-dir>/`. */
5+
export const ERROR_LOG_NAME = "errors.log";
6+
7+
export function errorLogPath(stateDir: string): string {
8+
return join(stateDir, ERROR_LOG_NAME);
9+
}
10+
11+
/** One TSV line: ISO timestamp, file path, reason (tabs flattened). */
12+
export function appendIndexError(
13+
stateDir: string,
14+
filePath: string,
15+
reason: string,
16+
): void {
17+
mkdirSync(stateDir, { recursive: true });
18+
const safeReason = reason.replace(/\t/g, " ").replace(/\n/g, " ");
19+
const line = `${new Date().toISOString()}\t${filePath}\t${safeReason}\n`;
20+
appendFileSync(errorLogPath(stateDir), line, "utf-8");
21+
}

src/application/index-engine.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
getFts5Enabled,
5858
getIncludePatterns,
5959
getProjectRoot,
60+
getStateDir,
6061
isPathExcluded,
6162
} from "../runtime";
6263
import { parseFilesParallel } from "../worker-pool";
@@ -67,6 +68,7 @@ import {
6768
resolveBindings,
6869
} from "./bindings-engine";
6970
import { persistModuleCycles } from "./cycles-engine";
71+
import { appendIndexError } from "./error-log";
7072
import { persistFileBarrelFlags } from "./file-graph-flags";
7173
import { persistJsxElementsAndAttributes } from "./jsx-persist";
7274
import type { QueryBindValue } from "./query-engine";
@@ -273,6 +275,15 @@ export function getCurrentCommit(): string {
273275
return result.stdout.toString().trim();
274276
}
275277

278+
function reportParseError(relPath: string, reason: string): void {
279+
console.error(` Parse error in ${relPath}: ${reason}`);
280+
try {
281+
appendIndexError(getStateDir(), relPath, reason);
282+
} catch {
283+
// logging must not fail the index run
284+
}
285+
}
286+
276287
function insertParsedResults(
277288
db: CodemapDatabase,
278289
results: ParsedFile[],
@@ -283,7 +294,14 @@ function insertParsedResults(
283294

284295
const transaction = db.transaction(() => {
285296
for (const parsed of results) {
286-
if (parsed.error) continue;
297+
if (parsed.error) {
298+
reportParseError(parsed.relPath, "file read failed");
299+
continue;
300+
}
301+
302+
if (parsed.parseError) {
303+
reportParseError(parsed.relPath, parsed.parseError);
304+
}
287305

288306
if (parsed.hasSideEffects) {
289307
parsed.fileRow.has_side_effects = parsed.hasSideEffects;
@@ -364,8 +382,9 @@ function insertParsedResults(
364382
if (parsed.suppressions?.length)
365383
insertSuppressions(db, parsed.suppressions);
366384
} catch (err) {
367-
console.error(
368-
` Parse error in ${parsed.relPath}: ${err instanceof Error ? err.message : err}`,
385+
reportParseError(
386+
parsed.relPath,
387+
err instanceof Error ? err.message : String(err),
369388
);
370389
}
371390

@@ -600,8 +619,9 @@ export async function indexFiles(
600619
const suppressions = extractSuppressions(source, relPath);
601620
if (suppressions.length) insertSuppressions(db, suppressions);
602621
} catch (err) {
603-
console.error(
604-
` Parse error in ${relPath}: ${err instanceof Error ? err.message : err}`,
622+
reportParseError(
623+
relPath,
624+
err instanceof Error ? err.message : String(err),
605625
);
606626
}
607627

src/application/index-lock.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { afterEach, describe, expect, it } from "bun:test";
2+
import {
3+
existsSync,
4+
mkdirSync,
5+
mkdtempSync,
6+
readFileSync,
7+
rmSync,
8+
writeFileSync,
9+
} from "node:fs";
10+
import { join } from "node:path";
11+
12+
import { appendIndexError, errorLogPath } from "./error-log";
13+
import {
14+
IndexLockHeldError,
15+
acquireIndexLock,
16+
indexLockPath,
17+
isStaleLock,
18+
readIndexLock,
19+
removeIndexLock,
20+
} from "./index-lock";
21+
22+
function scratchDir(prefix: string): string {
23+
const base = join(process.cwd(), "fixtures", "tmp");
24+
mkdirSync(base, { recursive: true });
25+
return mkdtempSync(join(base, prefix));
26+
}
27+
28+
describe("index-lock", () => {
29+
let dir: string;
30+
31+
afterEach(() => {
32+
if (dir) rmSync(dir, { recursive: true, force: true });
33+
});
34+
35+
it("acquireIndexLock creates JSON lock and release removes it", () => {
36+
dir = scratchDir("index-lock-");
37+
const release = acquireIndexLock(dir);
38+
const lockPath = indexLockPath(dir);
39+
expect(existsSync(lockPath)).toBe(true);
40+
const payload = readIndexLock(lockPath);
41+
expect(payload?.pid).toBe(process.pid);
42+
release();
43+
expect(existsSync(lockPath)).toBe(false);
44+
});
45+
46+
it("second acquire fails fast while lock is held", () => {
47+
dir = scratchDir("index-lock-held-");
48+
const release = acquireIndexLock(dir);
49+
try {
50+
expect(() => acquireIndexLock(dir)).toThrow(IndexLockHeldError);
51+
} finally {
52+
release();
53+
}
54+
});
55+
56+
it("isStaleLock treats dead PID as stale", () => {
57+
expect(
58+
isStaleLock({ pid: 999_999_999, started_at: new Date().toISOString() }),
59+
).toBe(true);
60+
});
61+
62+
it("removeIndexLock refuses live lock without force", () => {
63+
dir = scratchDir("index-lock-live-");
64+
const release = acquireIndexLock(dir);
65+
try {
66+
expect(() => removeIndexLock(dir)).toThrow(IndexLockHeldError);
67+
} finally {
68+
release();
69+
}
70+
});
71+
72+
it("removeIndexLock with force clears a live lock", () => {
73+
dir = scratchDir("index-lock-force-");
74+
const release = acquireIndexLock(dir);
75+
expect(removeIndexLock(dir, { force: true })).toBe(true);
76+
expect(existsSync(indexLockPath(dir))).toBe(false);
77+
release();
78+
});
79+
80+
it("auto-steals stale lock on acquire", () => {
81+
dir = scratchDir("index-lock-stale-");
82+
const lockPath = indexLockPath(dir);
83+
mkdirSync(dir, { recursive: true });
84+
writeFileSync(
85+
lockPath,
86+
`${JSON.stringify({
87+
pid: 999_999_999,
88+
started_at: new Date().toISOString(),
89+
})}\n`,
90+
"utf-8",
91+
);
92+
const release = acquireIndexLock(dir);
93+
expect(readIndexLock(lockPath)?.pid).toBe(process.pid);
94+
release();
95+
});
96+
});
97+
98+
describe("error-log", () => {
99+
let dir: string;
100+
101+
afterEach(() => {
102+
if (dir) rmSync(dir, { recursive: true, force: true });
103+
});
104+
105+
it("appendIndexError writes TSV lines", () => {
106+
dir = scratchDir("error-log-");
107+
appendIndexError(dir, "src/a.ts", "boom");
108+
const text = readFileSync(errorLogPath(dir), "utf-8");
109+
expect(text).toContain("\tsrc/a.ts\tboom\n");
110+
});
111+
});

0 commit comments

Comments
 (0)