Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- **MCP clients now know when an index needs a rebuild.** The first successful tool response for a project whose index predates the running extraction engine carries a one-time warning with the available build details and the `codegraph index` command. Each project is tracked independently in each MCP session, including projects selected with `projectPath`; `codegraph_status` always reports the index-build version, running version, and whether rebuilding is recommended. Detection is local and never starts a rebuild automatically. (#1852)

- **Codex and Astra read project guidance from `AGENTS.md`.** The canonical agent guide now lives in `AGENTS.md` (with a nested `docs/AGENTS.md` for long validation notes); `CLAUDE.md` is a thin `@AGENTS.md` wrapper for Claude Code. Codex/Astra no longer miss the old CLAUDE-only instructions.

- **A big screen's picture stops wrapping into a column.** How wide a screen's lines run before they wrap was worked out with a formula, and the formula was wrong for the way these pictures are actually drawn: a part of a screen spends lines on its own structure — a step that fires things gets a line to itself, and what it fires starts another — so estimating the lines from the boxes alone badly undercounted them, and one screen's 98 boxes wrapped into a 4,356px column. Laying a picture out is cheap and exact, so the widths are now simply tried and the one that comes out closest to the shape of a window is kept. Across one app's 51 screens the tallest picture went from 4,356px to 3,796px, total height fell 8%, and — because a shorter picture is also a picture whose lines have less far to go — lines running over other boxes fell by a third and lines crossing each other went from 13 to 5.
Expand Down
111 changes: 111 additions & 0 deletions __tests__/mcp-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import * as path from 'path';
import { CodeGraph } from '../src';
import { getDaemonSocketPath } from '../src/mcp/daemon-paths';
import { CodeGraphPackageVersion } from '../src/mcp/version';
import { EXTRACTION_VERSION } from '../src/extraction/extraction-version';

const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');

Expand Down Expand Up @@ -162,6 +163,17 @@ function countListeningLines(root: string): number {
return readDaemonLog(root).split('\n').filter((l) => l.includes('[CodeGraph daemon] Listening on')).length;
}

function stampIndexStale(cg: CodeGraph, version: string): void {
const metadata = cg as unknown as {
queries: { setMetadata(key: string, value: string): void };
};
metadata.queries.setMetadata('indexed_with_version', version);
metadata.queries.setMetadata(
'indexed_with_extraction_version',
String(EXTRACTION_VERSION - 1),
);
}

function killTree(...procs: ChildProcessWithoutNullStreams[]): void {
for (const p of procs) {
if (!p.killed) { try { p.kill('SIGKILL'); } catch { /* gone */ } }
Expand Down Expand Up @@ -238,6 +250,105 @@ describe('Shared MCP daemon (issue #411)', () => {
expect(readLockPid(realRoot)).toBe(daemonPid);
}, 40000);

it('warns once per proxied client when the shared project index is stale (#1852)', async () => {
fs.writeFileSync(path.join(tempDir, 'alpha.ts'), 'export function alpha() { return 1; }\n');
const indexed = await CodeGraph.open(tempDir);
await indexed.indexAll();
stampIndexStale(indexed, '0.1.0');
indexed.close();

const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' };
const first = spawnServer(tempDir, env);
const second = spawnServer(tempDir, env);
servers.push(first, second);
sendInitialize(first.child, `file://${tempDir}`, 1);
sendInitialize(second.child, `file://${tempDir}`, 1);
await waitFor(() => findResponse(first.stdout, 1), 10000);
await waitFor(() => findResponse(second.stdout, 1), 10000);
await waitFor(() => first.stderr.some((line) => line.includes('Attached to shared daemon')), 8000);
await waitFor(() => second.stderr.some((line) => line.includes('Attached to shared daemon')), 8000);

const callSearch = (server: SpawnedServer, id: number) => {
sendMessage(server.child, {
jsonrpc: '2.0',
id,
method: 'tools/call',
params: { name: 'codegraph_search', arguments: { query: 'alpha' } },
});
};
const responseText = (response: any): string => response.result.content[0].text;

callSearch(first, 2);
const firstInitial = await waitFor(() => findResponse(first.stdout, 2), 10000);

callSearch(first, 3);
callSearch(second, 2);
const firstRepeat = await waitFor(() => findResponse(first.stdout, 3), 10000);
const secondInitial = await waitFor(() => findResponse(second.stdout, 2), 10000);

expect(responseText(firstInitial)).toMatch(/index predates the running extraction engine/i);
expect(responseText(firstRepeat)).not.toMatch(/index predates/i);
expect(responseText(secondInitial)).toMatch(/index predates the running extraction engine/i);
expect(countListeningLines(realRoot)).toBe(1);
}, 45000);

it('does not repeat a daemon-delivered warning after proxy failover (#1852)', async () => {
fs.writeFileSync(path.join(tempDir, 'alpha.ts'), 'export function alpha() { return 1; }\n');
const indexed = await CodeGraph.open(tempDir);
await indexed.indexAll();
stampIndexStale(indexed, '0.1.0');
indexed.close();

const otherRoot = path.join(tempDir, 'other');
fs.mkdirSync(otherRoot);
fs.writeFileSync(path.join(otherRoot, 'bravo.ts'), 'export function bravo() { return 2; }\n');
const other = await CodeGraph.init(otherRoot, { index: false });
await other.indexAll();
stampIndexStale(other, '0.2.0');
other.close();

const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000', CODEGRAPH_PPID_POLL_MS: '5000' };
const server = spawnServer(tempDir, env);
servers.push(server);
sendInitialize(server.child, `file://${tempDir}`, 1);
await waitFor(() => findResponse(server.stdout, 1), 10000);
await waitFor(() => server.stderr.some((line) => line.includes('Attached to shared daemon')), 8000);
await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000);
const daemonPid = readLockPid(realRoot)!;

sendMessage(server.child, {
jsonrpc: '2.0', id: 2, method: 'tools/call',
params: { name: 'codegraph_search', arguments: { query: 'alpha' } },
});
const throughDaemon = await waitFor(() => findResponse(server.stdout, 2), 10000);
expect(throughDaemon.result.content[0].text).toMatch(/index predates/i);

process.kill(daemonPid, 'SIGTERM');
expect(await waitProcessExit(daemonPid, 8000)).toBe(true);
await waitFor(
() => server.stderr.some((line) => line.includes('serving this session in-process')),
8000,
);

sendMessage(server.child, {
jsonrpc: '2.0', id: 3, method: 'tools/call',
params: { name: 'codegraph_search', arguments: { query: 'alpha' } },
});
const afterFailover = await waitFor(() => findResponse(server.stdout, 3), 10000);
expect(afterFailover.result.content[0].text).not.toMatch(/index predates/i);

sendMessage(server.child, {
jsonrpc: '2.0', id: 4, method: 'tools/call',
params: {
name: 'codegraph_search',
arguments: { query: 'bravo', projectPath: otherRoot },
},
});
const otherProject = await waitFor(() => findResponse(server.stdout, 4), 10000);
expect(otherProject.result.content[0].text).toMatch(/index predates/i);
expect(otherProject.result.content[0].text).toContain('CodeGraph v0.2.0');
}, 45000);

it('concurrent launchers converge on a single daemon (lockfile race — must-fix 1)', async () => {
const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' };

Expand Down
Loading