Skip to content
Merged
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 @@ -32,6 +32,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- Installers can start or reuse a checkout's active watcher without replacing existing writers, and MCP launchers can preserve existing daemons across reconnects with `--preserve-existing`.

- Installers can verify daemon readiness and safely hand writer ownership across build promotion and rollback through a supported runtime-control API.

- `codegraph_explore` now finds quoted prose in script strings and template text through a capped source scan, ignoring case and punctuation without requiring a re-index.
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ C# property accessors and expression-bodied properties contribute calls and refe

`codegraph status` reports files that need re-indexing and files with recorded parse errors. `status --json` includes `index.filesNeedingReindex` and `index.filesWithParseErrors`; `files --json` includes each file's extraction errors. A transient parser failure preserves the previous graph and retries on the next sync.

The MCP launcher can replace a daemon from an older release when its hello confirms coordinated writer handover. Legacy daemons stay running while new sessions serve reads without auto-sync; stop the old MCP sessions and daemon, then reconnect with the current install. A daemon exits when its installation is deleted or its package version changes. Different managed builds of the same release retain the fork's version-identity checks.
The MCP launcher can replace a daemon from an older release when its hello confirms coordinated writer handover. `serve --mcp --path <root> --preserve-existing` opts out of replacement on initial connection and reconnect, requires an index at that exact root, and keeps fallback reads without a watcher. Legacy daemons stay running while new sessions serve reads without auto-sync; stop the old MCP sessions and daemon, then reconnect with the current install. A daemon exits when its installation is deleted or its package version changes. Different managed builds of the same release retain the fork's version-identity checks.

Dispatch and framework coverage the fork adds, by kind:

Expand Down Expand Up @@ -848,6 +848,14 @@ process. Keep the coordination module available separately from the executable
being replaced, including during rollback. Live legacy writers require explicit
session quiescence before cutover. See the [runtime control contract](site/src/content/docs/reference/api.md#installer-runtime-control).

For ordinary checkout startup, `startRuntimeWatcher(root, { expectedVersion,
cliPath, runtimePath?, timeoutMs? })` reuses or elects a shared daemon without
replacement. It returns `{ pid, version, projectRoot, watching: true }` only after
verifying the exact root, build, active watcher and writer ownership. An older,
uncertain, direct or promotion holder blocks startup. A readable index alone is
insufficient. The caller selects an absolute CLI path from the same validated
build and its runtime; this operation does not install, promote or move artifacts.

**Embedding requirements**

- Install from npm (`npm i @colbymchenry/codegraph`) so the matching
Expand Down
14 changes: 14 additions & 0 deletions __tests__/mcp-projectpath-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,20 @@ describe('MCP explicit projectPath lifecycle (#1835)', { timeout: 30_000 }, () =
finally { reader.close(); }
}

it('preserves uncertain explicit-project ownership during activation and retries', async () => {
await engine.stop();
engine = new MCPEngine({ preserveExisting: true });
engines.push(engine);
const file = path.join(serviceA, '.codegraph', 'writer.pid');
onOpen = () => fs.writeFileSync(file, 'uncertain');
expect(await search(serviceA, 'alphaOriginal')).toContain('alphaOriginal');
expect(opened).toHaveLength(1);
expect(opened[0]!.isWatching()).toBe(false);
await engine.getToolHandler().execute('codegraph_status', { projectPath: serviceA });
expect(fs.readFileSync(file, 'utf8')).toBe('uncertain');
expect(opened[0]!.isWatching()).toBe(false);
});

/**
* A daemon owner starts with idle exit OFF. Armed at startup, its 500ms
* timer raced this process's first connection and lost on a loaded machine,
Expand Down
321 changes: 312 additions & 9 deletions __tests__/runtime-control.test.ts

Large diffs are not rendered by default.

36 changes: 35 additions & 1 deletion __tests__/writer-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
* stale-dead-pid / live-holder refusal.
*/

import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { spawn, type ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import filesystem from 'fs';
import { syncBuiltinESMExports } from 'node:module';
import { MCPEngine } from '../src/mcp/engine';
import {
decodeWriterLockInfo,
Expand All @@ -23,6 +25,8 @@ describe('writer lock (#1740)', () => {
let holder: ChildProcess | null = null;

afterEach(() => {
vi.restoreAllMocks();
syncBuiltinESMExports();
try { holder?.kill('SIGKILL'); } catch { /* already gone */ }
holder = null;
for (const child of foreignHolders) {
Expand Down Expand Up @@ -61,6 +65,36 @@ describe('writer lock (#1740)', () => {
releaseWriterLock(root);
});

it('preserves an uncertain writer record at ordinary candidate acquisition', () => {
const root = makeProject();
const file = getWriterPidPath(root);
fs.writeFileSync(file, 'uncertain');
expect(tryAcquireWriterLock(root, 'daemon', 'writer.pid', { preserveUncertain: true }).kind).toBe('taken');
expect(fs.readFileSync(file, 'utf8')).toBe('uncertain');
});

it.each(['uncertain', 'invalid-pid', 'same-pid-successor'])(
'preserves a %s record arriving during stale-writer cleanup', (successor) => {
const root = makeProject();
const file = getWriterPidPath(root);
const deadPid = 2147483646;
fs.writeFileSync(file, JSON.stringify({ pid: deadPid, mode: 'daemon', startedAt: 1 }));
const next = successor === 'uncertain' ? 'uncertain' : JSON.stringify({
pid: successor === 'invalid-pid' ? 0 : deadPid, mode: 'direct', startedAt: 2,
});
const original = filesystem.readFileSync;
let reads = 0;
vi.spyOn(filesystem, 'readFileSync').mockImplementation(((target: fs.PathOrFileDescriptor, ...args: unknown[]) => {
if (target === file && ++reads === 2) fs.writeFileSync(file, next);
return Reflect.apply(original, filesystem, [target, ...args]);
}) as typeof filesystem.readFileSync);
syncBuiltinESMExports();
expect(tryAcquireWriterLock(root, 'daemon', 'writer.pid', { preserveUncertain: true }).kind).toBe('taken');
expect(fs.readFileSync(file, 'utf8')).toBe(next);
expect(reads).toBeGreaterThanOrEqual(2);
},
);

it('reports taken when a live foreign pid holds the lock', () => {
const root = makeProject();
// A foreign process that is genuinely alive, rather than a pid assumed to
Expand Down
25 changes: 23 additions & 2 deletions site/src/content/docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,33 @@ validated build. The same functions are exported from the package entry point.

| Function | Result |
|---|---|
| `getRuntimeIdentity(root)` | Hello-verified `{pid, version}`, or `null` |
| `getRuntimeIdentity(root, options?)` | Hello-verified `{pid, version}`, or `null`; `{timeoutMs, requireWatcher}` can require the exact active watcher in the hello |
| `stopRuntime(root, options?)` | `{root, pid, outcome, version?}` with verified signalling |
| `reserveRuntimeWriter(root, holderPid)` | Opaque JSON reservation for a live updater, or `null` when the slot is occupied |
| `claimRuntimeWriter(root, reservation)` | `true` when this bootstrap process claims that exact reservation |
| `releaseRuntimeWriter(root, reservation)` | `true` when the reservation is gone; preserves successor ownership |
| `checkRuntimeReady(root, identity, timeoutMs?)` | The expected identity after hello, MCP initialization and status verification |
| `checkRuntimeReady(root, identity, timeoutMs?, options?)` | The expected identity after hello, MCP initialization and status verification; `{requireWatcher: true}` additionally requires the exact active watcher and writer ownership |
| `startRuntimeWatcher(root, options)` | `{pid, version, projectRoot, watching: true}` after ordinary reuse/election and active-watcher verification |

Ordinary startup accepts `{expectedVersion, cliPath, runtimePath?, timeoutMs?}`.
The caller supplies an absolute CLI path from the validated coordination build;
`runtimePath` defaults to the calling runtime, and `timeoutMs` defaults to 120000.
The module must match `expectedVersion` before launch. An index must already
exist at the exact canonical root: no ancestor or child index is adopted.
The operation never stops an existing daemon, claims a promotion reservation or
starts a fallback watcher. Concurrent starters use existing daemon election and
writer guards; they reuse a verified winner or refuse with preserved ownership.
Legacy, uncertain, wrong-build, direct and promotion owners block startup.
Disabled, failed and initializing watchers cannot return ready.

`serve --mcp --path <root> --preserve-existing` applies the same preservation
policy on every connection attempt, including reconnect and detached election.
It may serve fallback reads without auto-sync; this is not watcher readiness.
`new MCPServer(root, {preserveExisting: true})` selects this policy for library
clients. Ordinary startup launches its detached candidate through this existing
path. A timeout leaves a possibly shared elected daemon alone; its usual
client/idle lifecycle retires an unused daemon. Installation, immutable artifact
selection and service limits remain the caller's responsibility.

Only `term`, `kill`, `not-running` and `no-daemon` stop outcomes allow a
reservation attempt. `unverified`, `still-running` and `legacy-writer` require
Expand Down
5 changes: 3 additions & 2 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2322,7 +2322,8 @@ program
.option('-p, --path <path>', 'Project path (optional for MCP mode, uses rootUri from client)')
.option('--mcp', 'Run as MCP server (stdio transport)')
.option('--no-watch', 'Disable the file watcher (no auto-sync; useful on slow filesystems like WSL2 /mnt drives)')
.action(async (options: { path?: string; mcp?: boolean; watch?: boolean }) => {
.option('--preserve-existing', 'Never replace an existing writer; require an exact indexed project path')
.action(async (options: { path?: string; mcp?: boolean; watch?: boolean; preserveExisting?: boolean }) => {
const projectPath = options.path ? resolveProjectPath(options.path) : undefined;

// Commander sets watch=false when --no-watch is passed. Route it through
Expand Down Expand Up @@ -2351,7 +2352,7 @@ program
}
// Start MCP server - it handles initialization lazily based on rootUri from client
const { MCPServer } = await import('../mcp/index');
const server = new MCPServer(projectPath);
const server = new MCPServer(projectPath, { preserveExisting: options.preserveExisting });
await server.start();
// Server will run until terminated
} else {
Expand Down
8 changes: 5 additions & 3 deletions src/mcp/daemon-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function canProbeDaemonIdentity(info: DaemonLockInfo): boolean {
* its socket. A bare PID liveness probe is insufficient because OSes reuse PIDs
* after an OOM/SIGKILL (#1553).
*/
export function probeDaemonIdentity(info: DaemonLockInfo, timeoutMs = 1_000, requireWriterProtocol = false): Promise<boolean> {
export function probeDaemonIdentity(info: DaemonLockInfo, timeoutMs = 1_000, requireWriterProtocol = false, watcherRoot?: string): Promise<boolean> {
if (!canProbeDaemonIdentity(info)) return Promise.resolve(false);
return new Promise<boolean>((resolve) => {
let socket: net.Socket;
Expand Down Expand Up @@ -152,12 +152,14 @@ export function probeDaemonIdentity(info: DaemonLockInfo, timeoutMs = 1_000, req
const newline = buffer.indexOf('\n');
if (newline < 0) return;
try {
const hello = JSON.parse(buffer.slice(0, newline)) as Record<string, unknown>;
const hello = JSON.parse(buffer.slice(0, newline));
finish(
hello.protocol === 1 &&
hello.pid === info.pid &&
(info.version === 'unknown' || hello.codegraph === info.version) &&
(!requireWriterProtocol || hello.writerProtocol === 1)
(!requireWriterProtocol || hello.writerProtocol === 1) &&
(watcherRoot === undefined || (hello.watcher?.projectRoot === watcherRoot &&
hello.watcher?.active === true && hello.watcher?.ready === true))
);
} catch {
finish(false);
Expand Down
17 changes: 14 additions & 3 deletions src/mcp/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
import * as fs from 'fs';
import * as net from 'net';
import * as path from 'path';
import { canonicalProjectRoot } from '../directory';
import { MCPEngine } from './engine';
import { MCPSession } from './session';
import { SocketTransport } from './transport';
Expand All @@ -56,6 +57,7 @@ import {
} from './daemon-paths';
import { CodeGraphPackageJsonPath, CodeGraphPackageReleaseVersion, CodeGraphPackageVersion } from './version';
import {
readWriterLock,
releaseWriterLock,
swapWriterLock,
tryAcquireWriterLock,
Expand Down Expand Up @@ -173,6 +175,7 @@ export interface DaemonHello {
socketPath: string; // echoed back so the proxy can log it
protocol: 1; // bump if the hello shape changes
writerProtocol?: 1; // ownership mutations use the OS coordination lock
watcher?: { projectRoot: string | null; active: boolean; ready: boolean };
}

/**
Expand Down Expand Up @@ -228,22 +231,24 @@ export class Daemon {
private pidPath: string;
/** The launcher holding the writer slot for this daemon, when it replaced an older one (#2335). */
private handoverFrom: number | null;
private preserveExisting: boolean;

constructor(
private projectRoot: string,
opts: { idleTimeoutMs?: number; maxIdleMs?: number; handoverFrom?: number | null } = {},
opts: { idleTimeoutMs?: number; maxIdleMs?: number; handoverFrom?: number | null; preserveExisting?: boolean } = {},
) {
this.socketPath = getDaemonSocketPath(projectRoot);
this.pidPath = getDaemonPidPath(projectRoot);
this.idleTimeoutMs = opts.idleTimeoutMs ?? resolveIdleTimeoutMs();
this.maxIdleMs = opts.maxIdleMs ?? resolveMaxIdleMs();
this.handoverFrom = opts.handoverFrom ?? null;
this.preserveExisting = opts.preserveExisting ?? false;
// Daemon mode serves many concurrent clients on one event loop, so off-load
// read-tool dispatch to a worker pool — otherwise concurrent explores
// serialize and starve the MCP transport (clients time out). Direct mode
// (one stdio client) leaves the pool off; `CODEGRAPH_QUERY_POOL_SIZE=0`
// disables it here too.
this.engine = new MCPEngine({ queryPool: true });
this.engine = new MCPEngine({ queryPool: true, preserveExisting: this.preserveExisting });
this.engine.setProjectPathHint(projectRoot);
}

Expand All @@ -256,7 +261,8 @@ export class Daemon {
// #1740: claim the project writer lock before opening/watching so a
// concurrent direct-mode serve --mcp cannot start a second watcher.
assertNoRebuild(this.projectRoot);
let writer = tryAcquireWriterLock(this.projectRoot, 'daemon');
let writer = tryAcquireWriterLock(this.projectRoot, 'daemon', 'writer.pid',
{ preserveUncertain: this.preserveExisting });
// The launcher that stopped an older daemon has held the slot for us since
// (#2335); take it over without letting it fall free.
if (writer.kind === 'taken' && this.handoverFrom !== null &&
Expand Down Expand Up @@ -449,12 +455,17 @@ export class Daemon {
socket.once('close', () => this.sockets.delete(socket));
// Hello first so the proxy can verify versions before piping any
// application bytes. The proxy reads exactly one line, then forwards.
const writer = readWriterLock(this.projectRoot);
const watcher = this.engine.getWatcherState();
const hello: DaemonHello = {
codegraph: CodeGraphPackageVersion,
pid: process.pid,
socketPath: this.socketPath,
protocol: 1,
writerProtocol: 1,
watcher: { ...watcher, projectRoot: watcher.projectRoot ? canonicalProjectRoot(watcher.projectRoot) : null,
ready: writer?.pid === process.pid &&
writer.mode === 'daemon' && writer.ready === true },
};
socket.write(JSON.stringify(hello) + '\n');

Expand Down
18 changes: 14 additions & 4 deletions src/mcp/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const loadCodeGraph = (): typeof import('../index').default =>
const RETRY_SUBSCAN_TTL_MS = 5_000;

export interface MCPEngineOptions {
/** Preserve uncertain writer records during project activation and retries. */
preserveExisting?: boolean;
/** Serve existing index contents without syncing, watching, or claiming a writer slot. */
readOnly?: boolean;
/**
Expand Down Expand Up @@ -94,7 +96,8 @@ export class MCPEngine {
private queryPool: QueryPool | null = null;

constructor(opts: MCPEngineOptions = {}) {
this.opts = { readOnly: opts.readOnly ?? false, watch: opts.watch ?? true, queryPool: opts.queryPool ?? false, queryPoolDefaultMax: opts.queryPoolDefaultMax };
this.opts = { readOnly: opts.readOnly ?? false, watch: opts.watch ?? true, queryPool: opts.queryPool ?? false,
queryPoolDefaultMax: opts.queryPoolDefaultMax, preserveExisting: opts.preserveExisting ?? false };
this.toolHandler = new ToolHandler(null);
this.toolHandler.setProjectLifecycle({
open: (root, open) => {
Expand All @@ -103,7 +106,7 @@ export class MCPEngine {
assertNoRebuild(root);
if (this.opts.readOnly) return loadCodeGraph().openSync(root, { readOnly: true });
if (!this.opts.watch) return open();
const lease = acquireProject(root, open, this.watchOptions());
const lease = acquireProject(root, open, this.watchOptions(), this.opts.preserveExisting);
this.explicitProjects.set(lease.cg, lease);
return lease.cg;
},
Expand All @@ -121,7 +124,8 @@ export class MCPEngine {
});
if (opts.writerLockRoot && !this.opts.readOnly) {
assertNoRebuild(opts.writerLockRoot);
const writer = tryAcquireWriterLock(opts.writerLockRoot, 'fallback');
const writer = tryAcquireWriterLock(opts.writerLockRoot, 'fallback', 'writer.pid',
{ preserveUncertain: this.opts.preserveExisting });
if (writer.kind === 'taken') {
throw new Error(writerLockHeldMessage(writer.existing, writer.pidPath));
}
Expand Down Expand Up @@ -178,6 +182,12 @@ export class MCPEngine {
return this.projectPath;
}

/** Actual default watcher state, never inferred from a readable index. */
getWatcherState(): { projectRoot: string | null; active: boolean } {
return { projectRoot: this.cg?.getProjectRoot() ?? null,
active: !this.closed && !this.opts.readOnly && (this.cg?.isWatching() ?? false) };
}

/** Shared ToolHandler — sessions delegate tool dispatch through this. */
getToolHandler(): ToolHandler {
return this.toolHandler;
Expand Down Expand Up @@ -426,7 +436,7 @@ export class MCPEngine {
if (this.opts.readOnly || !this.cg || this.watcherStarted || !this.opts.watch) return;

const opened = this.cg;
this.defaultLease = acquireProject(opened.getProjectRoot(), () => opened, this.watchOptions());
this.defaultLease = acquireProject(opened.getProjectRoot(), () => opened, this.watchOptions(), this.opts.preserveExisting);
this.cg = this.defaultLease.cg;
if (this.cg !== opened) opened.close();
this.toolHandler.setDefaultCodeGraph(this.cg);
Expand Down
Loading
Loading