ast-mcp-server 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,7 +6,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.11.0] — local release candidate, pending release (2026-08-23)
9
+ ## [0.11.2] — local release candidate, pending release (2026-08-25)
10
+
11
+ ### Added
12
+
13
+ - Added an explicit per-connection supervised compiler-worker mode that can recycle an idle compiler child while the local stdio parent remains connected; `in_process` remains the default and rollback, and TTL `0` disables recycling.
14
+ - Added scoped Linux evidence on exact Node.js 22.13.0 and Node.js 24 for PSS reclamation, stable compiler fingerprints, SQLite reuse, bounded redaction, and parent-death cleanup without orphans.
15
+
16
+ ### Fixed
17
+
18
+ - Compiler-worker startup now waits for `ready` before replaying initialization, preventing cross-channel settlement ordering from rejecting the first forwarded request as stale.
19
+ - CLI project-discovery fixtures now create the external project directory before concurrent configuration writes, eliminating a nondeterministic `ENOENT` setup race.
20
+ - Public-registry verification now copies the isolated runner's complete local runtime dependency closure, including the shared managed-skill validator, before launching it outside the source tree.
21
+ - A deterministic copied-runner regression executes the isolated entry point and rejects any local import omitted by its production bootstrap.
22
+
23
+ ### Release status
24
+
25
+ - This local release candidate succeeds immutable npm versions `0.11.0` and `0.11.1`, neither of which was promotable: `0.11.0` verification exposed stale managed-skill predicates, and `0.11.1` verification exposed the copied runner's missing validator module. Version `0.11.2` remains pending its exact-tree matrix, CI, and separately authorized delivery sequence; it has not been published or assigned to any dist-tag.
26
+
27
+ ## [0.11.1] — published to `next`, verification failed, not promoted (2026-08-23)
28
+
29
+ ### Fixed
30
+
31
+ - Public- and local-registry consumer verification now share one dependency-free managed-skill validator that derives the expected release version from the bundled skill metadata, compares installed copies byte-for-byte, and rejects embedded managed guidance.
32
+ - The exact-tree release matrix now runs `test:local-registry` under both supported Node.js lanes, preventing managed-asset verifier drift from reaching publication again.
33
+
34
+ ### Release status
35
+
36
+ - npm published these immutable bytes under `next` with provenance and the expected Git commit. Public-registry verification then failed because the isolated registry runner omitted its newly imported managed-skill validator module; `0.11.1` was never promoted to `latest`, tagged in Git, or released on GitHub.
37
+
38
+ ## [0.11.0] — published to `next`, verification failed, not promoted (2026-08-23)
10
39
 
11
40
  ### Added
12
41
 
@@ -21,7 +50,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
21
50
 
22
51
  ### Release status
23
52
 
24
- - This local candidate is pending its exact-tree release matrix, CI, and the repository's separately authorized delivery sequence. It has not been published to npm, assigned to `next` or `latest`, tagged in Git, or released on GitHub; registry and GitHub readbacks remain authoritative.
53
+ - npm published these immutable bytes under `next` with provenance and the expected Git commit. Public-registry verification then failed because two consumer validators still required skill bundle `4.4.0` while the package correctly contained `4.5.0`; `0.11.0` was never promoted to `latest`, tagged in Git, or released on GitHub.
25
54
 
26
55
  ## [0.10.0] — published to `latest` and `next`, registry verified (2026-08-22)
27
56
 
package/README.md CHANGED
@@ -84,11 +84,23 @@ The included batch benchmark records a 50% reduction in model round-trips and a
84
84
 
85
85
  ## Supported environment and trust boundary
86
86
 
87
- The local `0.11.0` release candidate requires Node.js `>=22.13.0`; its evidence matrix targets exact Node.js 22.13.0 and the current Node.js 24 line. Published v0.10.0 retains its immutable historical Node.js 22.13.0/24 evidence. Managed setup-file publication additionally requires GNU coreutils 9.7 `mv` supporting `--update=none-fail`, `--exchange`, `--no-copy`, and `--no-target-directory`, GNU coreutils `ln -L -T`, procfs descriptor paths at `/proc/self/fd`, and `O_DIRECTORY`/`O_NOFOLLOW`. Other Linux architectures or systems without those filesystem primitives, macOS, and Windows remain unverified.
87
+ The local `0.11.2` release candidate requires Node.js `>=22.13.0`; its evidence matrix targets exact Node.js 22.13.0 and the current Node.js 24 line. Published v0.10.0 retains its immutable historical Node.js 22.13.0/24 evidence. Managed setup-file publication additionally requires GNU coreutils 9.7 `mv` supporting `--update=none-fail`, `--exchange`, `--no-copy`, and `--no-target-directory`, GNU coreutils `ln -L -T`, procfs descriptor paths at `/proc/self/fd`, and `O_DIRECTORY`/`O_NOFOLLOW`. Other Linux architectures or systems without those filesystem primitives, macOS, and Windows remain unverified.
88
88
 
89
89
  This is a local stdio server. It runs with the invoking user's filesystem permissions, and clients may request any `project_root` that user can access. It does not provide HTTP authentication, sandboxing, tenant isolation, or a remote-service security boundary. Remote, untrusted, and multi-tenant operation is unsupported.
90
90
 
91
- In published v0.10.0 and the local `0.11.0` release candidate, an absent `AST_SYMBOL_INDEX_PERSISTENCE` or explicit `enabled` selects the private SQLite symbol-index cache. `disabled` is the immediate memory-only rollback. `canary` requires an explicit absolute normalized `AST_SYMBOL_INDEX_CACHE_ROOT`. Invalid policy or storage fails closed to compiler-authoritative memory reads with bounded path-free status.
91
+ ### Optional supervised compiler worker
92
+
93
+ The compiler runs in process by default. Linux operators may explicitly keep the stdio parent connected while allowing an idle compiler child to exit and lazily respawn:
94
+
95
+ ```bash
96
+ AST_COMPILER_WORKER_MODE=supervised ast-mcp-server
97
+ ```
98
+
99
+ The parent waits for child readiness before replaying bounded initialization state. Requests and cancellation remain generation-affine; mutation history, live operation leases, and completion-critical apply work prevent unsafe recycling. Set `AST_COMPILER_WORKER_MODE=in_process` for the full rollback, or set `AST_COMPILER_WORKER_IDLE_TTL_MS=0` to retain the relay while disabling idle recycling.
100
+
101
+ The scoped Linux canary passed on exact Node.js 22.13.0 and Node.js 24 with repeatable PSS reclamation, stable compiler fingerprints, unchanged SQLite reuse, bounded redacted diagnostics, and no orphan after parent death. This is one child per connection, not a shared daemon, pool, or new default. See [ADR 0014](docs/adr/0014-supervised-compiler-worker.md).
102
+
103
+ In published v0.10.0 and the local `0.11.2` release candidate, an absent `AST_SYMBOL_INDEX_PERSISTENCE` or explicit `enabled` selects the private SQLite symbol-index cache. `disabled` is the immediate memory-only rollback. `canary` requires an explicit absolute normalized `AST_SYMBOL_INDEX_CACHE_ROOT`. Invalid policy or storage fails closed to compiler-authoritative memory reads with bounded path-free status.
92
104
 
93
105
  The default cache root is selected from `AST_SYMBOL_INDEX_CACHE_ROOT`, then `XDG_CACHE_HOME`, then `HOME`. Inspect or clear only derived cache artifacts through the bounded CLI:
94
106
 
package/SECURITY.md CHANGED
@@ -11,7 +11,7 @@ Security fixes are provided for the exact version currently identified by the np
11
11
  | Earlier npm versions | No |
12
12
  | Unpublished release candidates | No |
13
13
 
14
- This project supports trusted single-user local stdio use; the supported operating-system and architecture boundary remains Linux x64 only. The local `0.11.0` release candidate requires Node.js `>=22.13.0`, with evidence targeted at exact Node.js 22.13.0 and the governed Node.js 24 major. Published v0.10.0 retains its immutable Node.js 22.13.0/24 release matrix. Managed setup mutation requires GNU coreutils 9.7 `mv --update=none-fail --exchange --no-copy --no-target-directory`, GNU coreutils `ln -L -T`, and procfs descriptor-relative paths at `/proc/self/fd` with `O_DIRECTORY`/`O_NOFOLLOW`. The candidate's default SQLite cache also relies on Linux ownership, mode, no-follow, link-count and inode-identity checks; it does not defend against a malicious same-UID process. Other Linux architectures and systems without those primitives are unverified, as are macOS and Windows. Remote, untrusted, and multi-tenant use is outside the supported security boundary.
14
+ This project supports trusted single-user local stdio use; the supported operating-system and architecture boundary remains Linux x64 only. The local `0.11.2` release candidate requires Node.js `>=22.13.0`, with evidence targeted at exact Node.js 22.13.0 and the governed Node.js 24 major. Published v0.10.0 retains its immutable Node.js 22.13.0/24 release matrix. Managed setup mutation requires GNU coreutils 9.7 `mv --update=none-fail --exchange --no-copy --no-target-directory`, GNU coreutils `ln -L -T`, and procfs descriptor-relative paths at `/proc/self/fd` with `O_DIRECTORY`/`O_NOFOLLOW`. The candidate's default SQLite cache also relies on Linux ownership, mode, no-follow, link-count and inode-identity checks; it does not defend against a malicious same-UID process. Other Linux architectures and systems without those primitives are unverified, as are macOS and Windows. Remote, untrusted, and multi-tenant use is outside the supported security boundary.
15
15
 
16
16
  ## Reporting a vulnerability
17
17
 
@@ -0,0 +1,51 @@
1
+ import fs from "node:fs";
2
+ import { createInterface } from "node:readline";
3
+ import { PassThrough } from "node:stream";
4
+ import { fileURLToPath } from "node:url";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { decodeCompilerWorkerEnvelope } from "./services/compiler-worker-protocol.js";
7
+ async function runDefaultCompilerRuntime(input = process.stdin) {
8
+ return (await import("./worker-runtime.js")).runStdioServer({
9
+ transport: new StdioServerTransport(input, process.stdout),
10
+ });
11
+ }
12
+ export function runCompilerWorkerChild(runRuntime = runDefaultCompilerRuntime) {
13
+ return runRuntime();
14
+ }
15
+ // prettier-ignore
16
+ export function installPrivateControl(runtime, input) {
17
+ let sequence = 0, generation = 0, writeSequence = 0;
18
+ const send = (generation, type, payload) => process.send?.({ v: 1, generation, sequence: ++sequence, type, payload });
19
+ const arm = (handle) => { const force = () => { if (handle.snapshot?.().completion_critical_operations > 0)
20
+ setTimeout(force, 50).unref();
21
+ else
22
+ process.exit(1); }; setTimeout(force, (handle.shutdownGraceMs ?? 10_000) + 2_000).unref(); };
23
+ const shutdown = (handle, trigger) => { if (trigger === "parent_exit")
24
+ arm(handle); void handle.shutdown(trigger).finally(() => process.disconnect()); };
25
+ process.on("message", (message) => { const decoded = decodeCompilerWorkerEnvelope(JSON.stringify(message)); if (!decoded.ok)
26
+ return; if (decoded.value.type === "handshake") {
27
+ generation = decoded.value.generation;
28
+ void runtime.then(() => send(generation, "ready", {}));
29
+ } if (decoded.value.type === "snapshot")
30
+ void runtime.then((handle) => send(generation, "snapshot", (handle.snapshot?.() ?? {}))); if (decoded.value.type === "shutdown") {
31
+ const trigger = decoded.value.payload.trigger === "parent_exit" ? "parent_exit" : "requested";
32
+ void runtime.then((handle) => shutdown(handle, trigger));
33
+ } });
34
+ if (input) {
35
+ const lines = createInterface({ input: process.stdin });
36
+ lines.on("line", (line) => input.write(`${line}\n`, () => send(generation, "settled", { write_sequence: ++writeSequence })));
37
+ lines.once("close", () => input.end());
38
+ }
39
+ process.once("disconnect", () => void runtime.then((handle) => { arm(handle); void handle.shutdown("parent_exit").then(() => process.exit(0), () => process.exit(1)); }));
40
+ }
41
+ const entry = process.argv[1];
42
+ if (entry && fs.realpathSync(entry) === fs.realpathSync(fileURLToPath(import.meta.url))) {
43
+ const input = process.send ? new PassThrough() : undefined;
44
+ const runtime = input ? runDefaultCompilerRuntime(input) : runCompilerWorkerChild();
45
+ if (process.send)
46
+ installPrivateControl(runtime, input);
47
+ runtime.catch(() => {
48
+ process.stderr.write("Fatal compiler worker startup error.\n");
49
+ process.exit(1);
50
+ });
51
+ }
package/dist/index.js CHANGED
@@ -1,131 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
- import path from "node:path";
4
3
  import { fileURLToPath } from "node:url";
5
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
- import { createServer } from "./server.js";
7
- import { beginProjectShutdown, closeDrainedProjectSessions, getProjectRuntimeShutdownSnapshot, prepareProjectRuntimeForStartup, waitForProjectCompletionCriticalOperationsToDrain, waitForProjectOperationsToDrain, } from "./services/project.js";
8
- import { RuntimeActivityTracker } from "./services/runtime-activity.js";
9
4
  import { readRuntimePolicy } from "./services/runtime-policy.js";
10
- import { emitIncompleteShutdownEvent, ShutdownCoordinator, } from "./services/shutdown.js";
11
- function emitShutdownFailureEvent() {
12
- process.stderr.write(`${JSON.stringify({ event: "shutdown_failed", version: 1 })}\n`);
5
+ export async function loadRuntimeModule(policy, loaders) {
6
+ return {
7
+ mode: policy.compilerWorkerMode,
8
+ module: await (policy.compilerWorkerMode === "supervised"
9
+ ? loaders.supervised()
10
+ : loaders.inProcess()),
11
+ };
13
12
  }
14
13
  export async function runStdioServer(options = {}) {
15
- if (options.server && !options.runtimeActivity) {
16
- throw new Error("An injected MCP server requires its runtime activity tracker.");
17
- }
18
- prepareProjectRuntimeForStartup();
19
- const runtimeActivity = options.runtimeActivity ?? new RuntimeActivityTracker();
20
- runtimeActivity.prepareForStartup();
21
- const server = options.server ?? createServer({ runtimeActivity });
22
- const transport = options.transport ?? new StdioServerTransport();
23
- const send = transport.send.bind(transport);
24
- transport.send = (message, sendOptions) => runtimeActivity.trackSend(() => send(message, sendOptions));
25
- const runtimePolicy = options.runtimePolicy ?? readRuntimePolicy();
26
- const installProcessHandlers = options.installProcessHandlers ?? true;
27
- const exit = options.exit ?? ((code) => process.exit(code));
28
- const protocol = server.server;
29
- const previousTransportClose = protocol.onclose;
30
- let listenersInstalled = false;
31
- let terminalHandlerInstalled = false;
32
- const onSigint = () => requestFromRuntime("sigint");
33
- const onSigterm = () => requestFromRuntime("sigterm");
34
- const onStdinEnd = () => requestFromRuntime("stdin_eof");
35
- const onStdinClose = () => requestFromRuntime("stdin_eof");
36
- const onTransportClose = () => {
37
- previousTransportClose?.();
38
- requestFromRuntime("transport_closed");
39
- };
40
- const removeLifecycleListeners = () => {
41
- if (listenersInstalled) {
42
- process.off("SIGINT", onSigint);
43
- process.off("SIGTERM", onSigterm);
44
- process.stdin.off("end", onStdinEnd);
45
- process.stdin.off("close", onStdinClose);
46
- listenersInstalled = false;
47
- }
48
- if (protocol.onclose === onTransportClose)
49
- protocol.onclose = previousTransportClose;
50
- };
51
- const coordinator = new ShutdownCoordinator({
52
- drainTimeoutMs: runtimePolicy.shutdownDrainTimeoutMs,
53
- stopAdmissionAndCancel: () => {
54
- runtimeActivity.beginShutdown();
55
- return beginProjectShutdown();
56
- },
57
- getDrainSnapshot: getProjectRuntimeShutdownSnapshot,
58
- waitForDrain: async () => {
59
- await Promise.all([waitForProjectOperationsToDrain(), runtimeActivity.waitForIdle()]);
60
- },
61
- waitForCompletionCriticalOperationsToDrain: waitForProjectCompletionCriticalOperationsToDrain,
62
- closeMcp: async () => {
63
- await server.close();
64
- },
65
- closeProjects: closeDrainedProjectSessions,
66
- emitIncompleteShutdown: emitIncompleteShutdownEvent,
67
- onCompletionCriticalWait: options.onCompletionCriticalWait,
68
- });
69
- let lifecycleCompletion;
70
- function requestShutdown(trigger) {
71
- lifecycleCompletion ??= coordinator.shutdown(trigger).finally(removeLifecycleListeners);
72
- return lifecycleCompletion;
73
- }
74
- function requestFromRuntime(trigger) {
75
- try {
76
- options.onRuntimeTrigger?.(trigger);
77
- }
78
- catch {
79
- // Runtime observation is non-authoritative and must never interrupt shutdown.
80
- }
81
- const completion = requestShutdown(trigger);
82
- if (terminalHandlerInstalled)
83
- return;
84
- terminalHandlerInstalled = true;
85
- void completion
86
- .then((result) => {
87
- if (result.state === "forced_noncritical")
88
- exit(1);
89
- })
90
- .catch(() => {
91
- emitShutdownFailureEvent();
92
- exit(1);
93
- });
94
- }
95
- protocol.onclose = onTransportClose;
96
- if (installProcessHandlers) {
97
- process.on("SIGINT", onSigint);
98
- process.on("SIGTERM", onSigterm);
99
- process.stdin.once("end", onStdinEnd);
100
- process.stdin.once("close", onStdinClose);
101
- listenersInstalled = true;
102
- }
103
- try {
104
- await server.connect(transport);
105
- }
106
- catch (error) {
107
- await requestShutdown("requested").catch(() => undefined);
108
- throw error;
109
- }
110
- if (options.logStartup ?? true)
111
- process.stderr.write("ast-mcp-server running over stdio.\n");
112
- return Object.freeze({
113
- shutdown: (trigger = "requested") => requestShutdown(trigger),
114
- });
14
+ return (await import("./worker-runtime.js")).runStdioServer(options);
115
15
  }
116
- function isMainModule() {
117
- const entry = process.argv[1];
118
- if (!entry)
119
- return false;
120
- try {
121
- return fs.realpathSync(entry) === fs.realpathSync(fileURLToPath(import.meta.url));
122
- }
123
- catch {
124
- return path.resolve(entry) === path.resolve(fileURLToPath(import.meta.url));
125
- }
16
+ async function runConfiguredStdioServer() {
17
+ const policy = readRuntimePolicy();
18
+ const selected = await loadRuntimeModule(policy, {
19
+ inProcess: async () => ({ run: () => runStdioServer({ runtimePolicy: policy }) }),
20
+ supervised: async () => ({
21
+ run: () => import("./services/compiler-worker-host.js").then((host) => host.runSupervisedStdioServer()),
22
+ }),
23
+ });
24
+ await selected.module.run();
126
25
  }
127
- if (isMainModule()) {
128
- runStdioServer().catch(() => {
26
+ const entry = process.argv[1];
27
+ if (entry && fs.realpathSync(entry) === fs.realpathSync(fileURLToPath(import.meta.url))) {
28
+ runConfiguredStdioServer().catch(() => {
129
29
  process.stderr.write("Fatal ast-mcp-server startup error.\n");
130
30
  process.exit(1);
131
31
  });
@@ -0,0 +1,422 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { spawn } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import { createInterface } from "node:readline";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { fileURLToPath } from "node:url";
7
+ import { decodeCompilerWorkerEnvelope, startCompilerWorker } from "./compiler-worker-protocol.js";
8
+ import { createCompilerWorkerSpawnSpec, readRuntimePolicy, } from "./runtime-policy.js";
9
+ import { emitCompilerWorkerEvent } from "./runtime-logger.js";
10
+ import { PublicOperationalError, renderPublicError } from "./public-errors.js";
11
+ export class CompilerWorkerHostError extends Error {
12
+ kind;
13
+ recovery;
14
+ constructor(kind, recovery) {
15
+ super("Compiler worker request failed.");
16
+ this.kind = kind;
17
+ this.recovery = recovery;
18
+ }
19
+ }
20
+ const bounded = (value) => Buffer.byteLength(JSON.stringify(value)) <= 256 * 1024;
21
+ const within = (promise, ms) => Promise.race([
22
+ promise,
23
+ delay(ms, undefined, { ref: false }).then(() => {
24
+ throw new CompilerWorkerHostError("startup");
25
+ }),
26
+ ]);
27
+ function deferred() {
28
+ let resolve;
29
+ let reject;
30
+ const promise = new Promise((yes, no) => {
31
+ resolve = yes;
32
+ reject = no;
33
+ });
34
+ return { promise, resolve, reject };
35
+ }
36
+ export function spawnCompilerWorkerProcess(options) {
37
+ const spec = createCompilerWorkerSpawnSpec(options.workerEntryPath, options.environment);
38
+ if (!spec.ok || !spec.command)
39
+ throw new CompilerWorkerHostError("startup");
40
+ const child = spawn(spec.command, spec.args, {
41
+ ...spec.options,
42
+ stdio: ["pipe", "pipe", "inherit", "ipc"],
43
+ });
44
+ const ready = deferred();
45
+ const closed = new Promise((resolve) => child.once("close", () => resolve()));
46
+ const pending = new Map();
47
+ const snapshots = [];
48
+ const acknowledgements = new Map();
49
+ let outbound = 0;
50
+ let inbound = 0;
51
+ let controlInbound = 0;
52
+ let writes = 0;
53
+ const fail = (error) => {
54
+ ready.reject(error);
55
+ for (const request of pending.values())
56
+ request.reject(error);
57
+ pending.clear();
58
+ for (const snapshot of snapshots.splice(0))
59
+ snapshot.reject(error);
60
+ for (const acknowledged of acknowledgements.values())
61
+ acknowledged.reject(error);
62
+ acknowledgements.clear();
63
+ };
64
+ child.once("close", () => fail(new CompilerWorkerHostError("worker_exit")));
65
+ child.on("message", (message) => {
66
+ const frame = JSON.stringify(message);
67
+ const decoded = decodeCompilerWorkerEnvelope(frame);
68
+ if (!decoded.ok ||
69
+ decoded.value.generation !== options.generation ||
70
+ decoded.value.sequence <= controlInbound ||
71
+ !["ready", "snapshot", "settled"].includes(decoded.value.type))
72
+ return fail(new CompilerWorkerHostError("protocol"));
73
+ controlInbound = decoded.value.sequence;
74
+ if (decoded.value.type === "settled") {
75
+ const writeSequence = decoded.value.payload.write_sequence;
76
+ if (!Number.isSafeInteger(writeSequence))
77
+ return fail(new CompilerWorkerHostError("protocol"));
78
+ acknowledgements.get(writeSequence)?.resolve();
79
+ acknowledgements.delete(writeSequence);
80
+ }
81
+ else if (decoded.value.type === "ready") {
82
+ inbound = 1;
83
+ ready.resolve(frame);
84
+ }
85
+ else
86
+ snapshots.shift()?.resolve(decoded.value);
87
+ });
88
+ createInterface({ input: child.stdout }).on("line", (line) => {
89
+ if (Buffer.byteLength(line) > 256 * 1024)
90
+ return fail(new CompilerWorkerHostError("protocol"));
91
+ let message;
92
+ try {
93
+ message = JSON.parse(line);
94
+ }
95
+ catch {
96
+ return fail(new CompilerWorkerHostError("protocol"));
97
+ }
98
+ if (!("id" in message) || message.id === undefined)
99
+ return;
100
+ const request = pending.get(message.id);
101
+ if (!request)
102
+ return;
103
+ pending.delete(message.id);
104
+ request.resolve({
105
+ generation: options.generation,
106
+ sequence: ++inbound,
107
+ payload: { request_id: message.id, message },
108
+ });
109
+ });
110
+ const write = (message, acknowledge = false) => {
111
+ if (!bounded(message) || !child.stdin?.writable)
112
+ return Promise.reject(new CompilerWorkerHostError("protocol"));
113
+ options.beforeWrite?.(message);
114
+ const writeSequence = ++writes, processed = acknowledge ? deferred() : undefined;
115
+ if (processed) {
116
+ acknowledgements.set(writeSequence, processed);
117
+ void processed.promise.catch(() => undefined);
118
+ }
119
+ const written = new Promise((resolve, reject) => child.stdin.write(`${JSON.stringify(message)}\n`, (error) => error ? (acknowledgements.delete(writeSequence), reject(error)) : resolve()));
120
+ return processed ? written.then(() => processed.promise) : written;
121
+ };
122
+ const control = (type, payload) => {
123
+ const frame = { v: 1, generation: options.generation, sequence: ++outbound, type, payload };
124
+ if (!bounded(frame) || !child.connected)
125
+ return Promise.reject(new CompilerWorkerHostError("protocol"));
126
+ return new Promise((resolve, reject) => child.send(frame, (error) => (error ? reject(error) : resolve())));
127
+ };
128
+ return {
129
+ generation: options.generation,
130
+ ready: ready.promise,
131
+ async replay(frames) {
132
+ await control("handshake", {});
133
+ await ready.promise;
134
+ for (const frame of frames)
135
+ await write(frame);
136
+ },
137
+ forward(message) {
138
+ if (!("id" in message))
139
+ return write(message, true).then(() => undefined);
140
+ if (message.id === undefined)
141
+ return Promise.reject(new CompilerWorkerHostError("protocol"));
142
+ const result = deferred();
143
+ const id = message.id;
144
+ pending.set(id, result);
145
+ void write(message).catch((error) => {
146
+ pending.delete(id);
147
+ result.reject(error);
148
+ });
149
+ return result.promise;
150
+ },
151
+ cancel(id) {
152
+ if (!pending.has(id))
153
+ return false;
154
+ void write({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: id } }, true).catch(() => undefined);
155
+ return true;
156
+ },
157
+ async snapshot() {
158
+ await Promise.all([...acknowledgements.values()].map(({ promise }) => promise));
159
+ const result = deferred();
160
+ snapshots.push(result);
161
+ await control("snapshot", {});
162
+ return (await result.promise).payload;
163
+ },
164
+ terminate: () => {
165
+ child.kill("SIGKILL");
166
+ },
167
+ reap: () => closed,
168
+ async shutdown(trigger) {
169
+ await control("shutdown", { trigger }).catch(() => undefined);
170
+ child.stdin?.end();
171
+ await closed;
172
+ return "complete";
173
+ },
174
+ };
175
+ }
176
+ export class CompilerWorkerHost {
177
+ spawn;
178
+ timeoutMs;
179
+ lifecycle;
180
+ generation = 0;
181
+ sequence = 0;
182
+ child;
183
+ starting;
184
+ active = new Map();
185
+ leases = new Map();
186
+ initialization = [];
187
+ activity = 0;
188
+ parentWork = 0;
189
+ parentAdmission = "open";
190
+ idleTimer;
191
+ retiring;
192
+ constructor(spawn, timeoutMs = 10_000, lifecycle = {}) {
193
+ this.spawn = spawn;
194
+ this.timeoutMs = timeoutMs;
195
+ this.lifecycle = lifecycle;
196
+ }
197
+ now() {
198
+ return (this.lifecycle.now ?? Date.now)();
199
+ }
200
+ // prettier-ignore
201
+ pruneLeases() { const now = this.now(); for (const [id, expiry] of this.leases)
202
+ if (expiry <= now)
203
+ this.leases.delete(id); }
204
+ // prettier-ignore
205
+ addLease(id) { if (typeof id !== "string" || !id || !bounded(id))
206
+ return; this.pruneLeases(); this.leases.set(id, this.now() + (this.lifecycle.leaseTtlMs ?? 15 * 60_000)); while (this.leases.size > (this.lifecycle.leaseLimit ?? 128))
207
+ this.leases.delete(this.leases.keys().next().value); }
208
+ // prettier-ignore
209
+ scheduleIdle() { clearTimeout(this.idleTimer); const ttl = this.lifecycle.idleTtlMs ?? 0; if (ttl > 0 && this.child)
210
+ this.idleTimer = setTimeout(() => { this.retiring = this.recycle(this.activity).catch(async () => { const child = this.child; await child?.reap().catch(() => undefined); if (this.child === child)
211
+ this.child = undefined; }).finally(() => { this.retiring = undefined; }); }, ttl); }
212
+ // prettier-ignore
213
+ static quiescent(value) { return value.runtime_admission === "open" && value.project_admission === "open" && ["active_requests", "active_sends", "active_operations", "queued_operations", "completion_critical_operations"].every((key) => value[key] === 0) && value.mutation_history === false; }
214
+ // prettier-ignore
215
+ async recycle(activity) { this.pruneLeases(); const child = this.child, generation = this.generation, sequence = this.sequence; if (!child || this.parentWork || this.leases.size || child.generation !== generation)
216
+ return this.scheduleIdle(); const first = await child.snapshot(); if (!CompilerWorkerHost.quiescent(first) || activity !== this.activity || sequence !== this.sequence)
217
+ return this.scheduleIdle(); this.parentAdmission = "closed"; const second = await child.snapshot(); if (child !== this.child || generation !== this.generation || sequence !== this.sequence || activity !== this.activity || !CompilerWorkerHost.quiescent(second)) {
218
+ this.parentAdmission = "open";
219
+ return this.scheduleIdle();
220
+ } await this.stopChild(child, "requested"); emitCompilerWorkerEvent({ kind: "idle", generation }); this.parentAdmission = "open"; }
221
+ // prettier-ignore
222
+ async stopChild(child, trigger) { const completion = child.shutdown(trigger); try {
223
+ await within(completion, this.timeoutMs);
224
+ if (this.child === child)
225
+ this.child = undefined;
226
+ return "complete";
227
+ }
228
+ catch {
229
+ const snapshot = await child.snapshot().catch(() => undefined);
230
+ if (snapshot?.completion_critical_operations) {
231
+ await completion;
232
+ if (this.child === child)
233
+ this.child = undefined;
234
+ return "complete";
235
+ }
236
+ child.terminate();
237
+ await child.reap().catch(() => undefined);
238
+ if (this.child === child)
239
+ this.child = undefined;
240
+ return "forced";
241
+ } }
242
+ async start(initialization) {
243
+ if (this.child)
244
+ return { generation: this.child.generation, sequence: this.sequence };
245
+ if (this.starting)
246
+ return this.starting;
247
+ this.starting = (async () => {
248
+ let child;
249
+ try {
250
+ const envelope = initialization.length === 2
251
+ ? await startCompilerWorker({
252
+ frames: initialization,
253
+ timeoutMs: this.timeoutMs,
254
+ spawn: () => (child = this.spawn(++this.generation)),
255
+ })
256
+ : await (async () => {
257
+ child = this.spawn(++this.generation);
258
+ await child.replay([]);
259
+ const decoded = decodeCompilerWorkerEnvelope(await within(child.ready, this.timeoutMs));
260
+ if (!decoded.ok || decoded.value.type !== "ready")
261
+ throw new Error();
262
+ return decoded.value;
263
+ })();
264
+ if (!child || envelope.generation !== child.generation)
265
+ throw new Error();
266
+ this.child = child;
267
+ this.sequence = envelope.sequence;
268
+ this.parentAdmission = "open";
269
+ this.scheduleIdle();
270
+ return { generation: child.generation, sequence: this.sequence };
271
+ }
272
+ catch {
273
+ child?.terminate();
274
+ await child?.reap().catch(() => undefined);
275
+ throw new CompilerWorkerHostError("startup");
276
+ }
277
+ })().finally(() => {
278
+ this.starting = undefined;
279
+ });
280
+ return this.starting;
281
+ }
282
+ async forward(message) {
283
+ if (!bounded(message))
284
+ throw new CompilerWorkerHostError("protocol");
285
+ await this.retiring;
286
+ this.activity += 1;
287
+ await this.start(this.initialization.length === 2 ? this.initialization : []);
288
+ this.parentWork += 1;
289
+ const id = "id" in message ? message.id : undefined;
290
+ if (id !== undefined)
291
+ this.active.set(id, this.generation);
292
+ try {
293
+ const settled = await this.child.forward(message);
294
+ if ("method" in message && message.method === "initialize")
295
+ this.initialization = [message];
296
+ if ("method" in message &&
297
+ message.method === "notifications/initialized" &&
298
+ this.initialization.length === 1)
299
+ this.initialization.push(message);
300
+ if (!settled)
301
+ return undefined;
302
+ const payload = settled.payload;
303
+ if (settled.generation !== this.generation ||
304
+ settled.sequence <= this.sequence ||
305
+ payload.request_id !== id ||
306
+ !payload.message)
307
+ throw new CompilerWorkerHostError("protocol");
308
+ this.sequence = settled.sequence;
309
+ const response = payload.message;
310
+ const result = "result" in response ? response.result : undefined;
311
+ this.addLease(result?.operation_id ??
312
+ result?.structuredContent?.operation_id);
313
+ return response;
314
+ }
315
+ catch (error) {
316
+ if (error instanceof CompilerWorkerHostError && error.kind === "worker_exit") {
317
+ const child = this.child;
318
+ await child?.reap().catch(() => undefined);
319
+ if (this.child === child)
320
+ this.child = undefined;
321
+ emitCompilerWorkerEvent({
322
+ kind: "crash",
323
+ generation: child?.generation ?? this.generation,
324
+ });
325
+ const params = "params" in message
326
+ ? message.params
327
+ : undefined;
328
+ const operationId = params?.name === "ast_apply_operation" ? params.arguments?.operation_id : undefined;
329
+ if (typeof operationId === "string") {
330
+ this.addLease(operationId);
331
+ const correlationId = randomUUID();
332
+ emitCompilerWorkerEvent({
333
+ kind: "ambiguity",
334
+ generation: child?.generation ?? this.generation,
335
+ correlationId,
336
+ count: this.leases.size,
337
+ });
338
+ throw new CompilerWorkerHostError("ambiguous_apply", {
339
+ correlation_id: correlationId,
340
+ expires_at: this.leases.get(operationId),
341
+ });
342
+ }
343
+ }
344
+ throw error instanceof CompilerWorkerHostError
345
+ ? error
346
+ : new CompilerWorkerHostError("worker_exit");
347
+ }
348
+ finally {
349
+ if (id !== undefined)
350
+ this.active.delete(id);
351
+ this.parentWork -= 1;
352
+ this.scheduleIdle();
353
+ }
354
+ }
355
+ cancel(id) {
356
+ if (this.active.get(id) !== this.generation || !this.child)
357
+ return "not_found";
358
+ return this.child.cancel(id) ? "forwarded" : "not_found";
359
+ }
360
+ async shutdown(trigger) {
361
+ clearTimeout(this.idleTimer);
362
+ await this.starting?.catch(() => undefined);
363
+ if (!this.child)
364
+ return "complete";
365
+ const child = this.child;
366
+ this.parentAdmission = "closed";
367
+ return this.stopChild(child, trigger);
368
+ }
369
+ snapshot() {
370
+ this.pruneLeases();
371
+ return {
372
+ generation: this.generation,
373
+ sequence: this.sequence,
374
+ state: this.child ? "ready" : this.starting ? "starting" : "idle",
375
+ active_requests: this.active.size,
376
+ parent_work: this.parentWork,
377
+ parent_admission: this.parentAdmission,
378
+ lease_tombstones: this.leases.size,
379
+ };
380
+ }
381
+ }
382
+ export async function runSupervisedStdioServer() {
383
+ const entry = fileURLToPath(new URL("../compiler-worker-entry.js", import.meta.url));
384
+ const policy = readRuntimePolicy(process.env);
385
+ const host = new CompilerWorkerHost((generation) => spawnCompilerWorkerProcess({ generation, workerEntryPath: entry, environment: process.env }), 10_000, { idleTtlMs: policy.compilerWorkerIdleTtlMs });
386
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
387
+ const relay = async (line) => {
388
+ let message;
389
+ try {
390
+ message = JSON.parse(line);
391
+ }
392
+ catch {
393
+ return;
394
+ }
395
+ // prettier-ignore
396
+ if ("method" in message && message.method === "notifications/cancelled") {
397
+ const id = message.params?.requestId;
398
+ if ((typeof id === "string" || typeof id === "number") && bounded(id))
399
+ host.cancel(id);
400
+ return;
401
+ }
402
+ try {
403
+ const response = await host.forward(message);
404
+ if (response)
405
+ process.stdout.write(`${JSON.stringify(response)}\n`);
406
+ }
407
+ catch (error) {
408
+ if ("id" in message) {
409
+ const ambiguous = error instanceof CompilerWorkerHostError &&
410
+ error.kind === "ambiguous_apply" &&
411
+ error.recovery;
412
+ const rendered = ambiguous
413
+ ? renderPublicError(new PublicOperationalError("AMBIGUOUS_APPLY", "The apply outcome is ambiguous."), ambiguous.correlation_id).envelope.error
414
+ : undefined;
415
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32603, message: rendered?.message ?? "Compiler worker request failed.", ...(ambiguous ? { data: { code: "AMBIGUOUS_APPLY", ...ambiguous } } : {}) } })}\n`);
416
+ }
417
+ }
418
+ };
419
+ lines.on("line", (line) => void relay(line));
420
+ lines.once("close", () => void host.shutdown("parent_exit"));
421
+ return { shutdown: () => host.shutdown("requested"), snapshot: () => host.snapshot() };
422
+ }
@@ -0,0 +1,95 @@
1
+ import { Buffer } from "node:buffer";
2
+ const FIELDS = new Set(["v", "generation", "sequence", "type", "payload"]);
3
+ const TYPES = new Set(["handshake", "snapshot", "shutdown", "ready", "settled", "lease", "exit"]);
4
+ function isRecord(value) {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+ export function decodeCompilerWorkerEnvelope(frame) {
8
+ if (Buffer.byteLength(frame) > 256 * 1024) {
9
+ return { ok: false, reason: "oversized" };
10
+ }
11
+ let value;
12
+ try {
13
+ value = JSON.parse(frame);
14
+ }
15
+ catch {
16
+ return { ok: false, reason: "malformed" };
17
+ }
18
+ if (!isRecord(value))
19
+ return { ok: false, reason: "invalid_envelope" };
20
+ if (Object.keys(value).some((key) => !FIELDS.has(key)))
21
+ return { ok: false, reason: "unknown_field" };
22
+ if (value.v !== 1)
23
+ return { ok: false, reason: "version_drift" };
24
+ if (typeof value.type !== "string" || !TYPES.has(value.type)) {
25
+ return { ok: false, reason: "unknown_type" };
26
+ }
27
+ if (!Number.isSafeInteger(value.generation) ||
28
+ value.generation < 1 ||
29
+ !Number.isSafeInteger(value.sequence) ||
30
+ value.sequence < 1 ||
31
+ !isRecord(value.payload)) {
32
+ return { ok: false, reason: "invalid_envelope" };
33
+ }
34
+ return { ok: true, value };
35
+ }
36
+ export function validateInitializationReplay(frames) {
37
+ if (frames.length < 2)
38
+ return { ok: false, reason: "missing_replay" };
39
+ if (frames.length > 2)
40
+ return { ok: false, reason: "unexpected_replay" };
41
+ const [request, notification] = frames;
42
+ if (!isRecord(request) ||
43
+ request.method !== "initialize" ||
44
+ !("id" in request) ||
45
+ !isRecord(notification) ||
46
+ notification.method !== "notifications/initialized" ||
47
+ "id" in notification) {
48
+ return { ok: false, reason: "missing_replay" };
49
+ }
50
+ if (Buffer.byteLength(JSON.stringify(frames)) > 256 * 1024) {
51
+ return { ok: false, reason: "oversized_replay" };
52
+ }
53
+ return { ok: true, frames: [request, notification] };
54
+ }
55
+ export class CompilerWorkerStartupError extends Error {
56
+ reason;
57
+ constructor(reason) {
58
+ super("Compiler worker startup failed.");
59
+ this.reason = reason;
60
+ }
61
+ }
62
+ async function attempt(options, frames) {
63
+ const child = await options.spawn();
64
+ let timeout;
65
+ try {
66
+ await child.replay(frames);
67
+ const timedOut = new Promise((_, reject) => {
68
+ timeout = setTimeout(() => reject(new CompilerWorkerStartupError("timeout")), options.timeoutMs ?? 10_000);
69
+ });
70
+ const decoded = decodeCompilerWorkerEnvelope(await Promise.race([child.ready, timedOut]));
71
+ if (!decoded.ok || decoded.value.type !== "ready")
72
+ throw new CompilerWorkerStartupError("protocol");
73
+ return decoded.value;
74
+ }
75
+ catch (error) {
76
+ child.terminate();
77
+ await child.reap();
78
+ throw error;
79
+ }
80
+ finally {
81
+ if (timeout !== undefined)
82
+ clearTimeout(timeout);
83
+ }
84
+ }
85
+ export async function startCompilerWorker(options) {
86
+ const replay = validateInitializationReplay(options.frames);
87
+ if (!replay.ok)
88
+ throw new CompilerWorkerStartupError(replay.reason);
89
+ try {
90
+ return await attempt(options, replay.frames);
91
+ }
92
+ catch {
93
+ return attempt(options, replay.frames);
94
+ }
95
+ }
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { createTwoFilesPatch } from "diff";
6
6
  import { LanguageVariant, Node, ScriptTarget, SyntaxKind, ts } from "ts-morph";
7
7
  import { compareDiagnostics, normalizeDiagnostic, } from "./diagnostics.js";
8
- import { createFreshProject, findDeclarationByName, getSourceFileOrThrow, invalidateProjectIfIdle, withProjectOperation, } from "./project.js";
8
+ import { createFreshProject, findDeclarationByName, getSourceFileOrThrow, invalidateProjectIfIdle, recordProjectMutationHistory, withProjectOperation, } from "./project.js";
9
9
  import { NO_REQUEST_CONTEXT } from "./request-context.js";
10
10
  import { executableDeclaration } from "./symbols.js";
11
11
  import { buildClassScaffold } from "./scaffold.js";
@@ -290,6 +290,7 @@ async function createPlan(projectRootInput, kind, allowNewErrors, mutate, reques
290
290
  };
291
291
  operationContext.checkpoint();
292
292
  operations.set(operationId, record);
293
+ recordProjectMutationHistory();
293
294
  retainedOperationId = operationId;
294
295
  await operationTestHooks.afterRetain?.(operationId);
295
296
  return publicOperation(record);
@@ -846,6 +847,7 @@ export function importOperationRecord(persisted, expectedPlanHash) {
846
847
  }
847
848
  pruneOperations();
848
849
  operations.set(record.operation_id, record);
850
+ recordProjectMutationHistory();
849
851
  return publicOperation(record);
850
852
  }
851
853
  export function setOperationTestHooksForTests(hooks) {
@@ -23,6 +23,7 @@ const projectRoots = new WeakMap();
23
23
  let projectSessionAccessSequence = 0n;
24
24
  let projectRuntimePolicy;
25
25
  let projectRuntimeAdmission = "open";
26
+ let projectMutationHistory = false;
26
27
  function nextProjectSessionAccessSequence() {
27
28
  projectSessionAccessSequence += 1n;
28
29
  return projectSessionAccessSequence;
@@ -791,12 +792,17 @@ export function clearProjectSessions() {
791
792
  projectSessionAccessSequence = 0n;
792
793
  projectRuntimePolicy = undefined;
793
794
  projectRuntimeAdmission = "open";
795
+ projectMutationHistory = false;
796
+ }
797
+ export function recordProjectMutationHistory() {
798
+ projectMutationHistory = true;
794
799
  }
795
800
  export function prepareProjectRuntimeForStartup() {
796
801
  if (projectSessions.size !== 0) {
797
802
  throw new Error("Cannot start a project runtime while sessions remain open.");
798
803
  }
799
804
  projectRuntimeAdmission = "open";
805
+ projectMutationHistory = false;
800
806
  }
801
807
  export function getProjectRuntimeShutdownSnapshot() {
802
808
  let activeOperations = 0;
@@ -814,6 +820,7 @@ export function getProjectRuntimeShutdownSnapshot() {
814
820
  active_operations: activeOperations,
815
821
  queued_operations: queuedOperations,
816
822
  completion_critical_operations: completionCriticalOperations,
823
+ mutation_history: projectMutationHistory,
817
824
  });
818
825
  }
819
826
  export function beginProjectShutdown() {
@@ -15,6 +15,7 @@ export const PUBLIC_ERROR_CODES = Object.freeze([
15
15
  "STALE_WORKSPACE",
16
16
  "INCOMPLETE_EVIDENCE",
17
17
  "MUTATION_BLOCKED",
18
+ "AMBIGUOUS_APPLY",
18
19
  "CONFLICT",
19
20
  "INTERNAL_ERROR",
20
21
  ]);
@@ -38,6 +39,7 @@ const FIXED_PUBLIC_MESSAGES = Object.freeze({
38
39
  STALE_WORKSPACE: "The workspace changed. Retry the operation.",
39
40
  INCOMPLETE_EVIDENCE: "The required compiler evidence is incomplete.",
40
41
  MUTATION_BLOCKED: "The mutation is blocked.",
42
+ AMBIGUOUS_APPLY: "The apply outcome is ambiguous. Inspect the operation receipt before retrying.",
41
43
  CONFLICT: "The operation conflicts with current state.",
42
44
  INTERNAL_ERROR: INTERNAL_ERROR_MESSAGE,
43
45
  });
@@ -178,7 +180,9 @@ function codedError(error) {
178
180
  return null;
179
181
  return {
180
182
  code: code,
181
- message: sanitizePublicText(message),
183
+ message: code === "AMBIGUOUS_APPLY"
184
+ ? FIXED_PUBLIC_MESSAGES.AMBIGUOUS_APPLY
185
+ : sanitizePublicText(message),
182
186
  };
183
187
  }
184
188
  if (!(error instanceof Error))
@@ -236,9 +240,8 @@ function serializePublicError(code, message, correlationId) {
236
240
  };
237
241
  return { envelope, text: JSON.stringify(envelope) };
238
242
  }
239
- export function renderPublicError(error) {
243
+ export function renderPublicError(error, correlationId = randomUUID()) {
240
244
  const classified = classifyPublicError(error);
241
- const correlationId = randomUUID();
242
245
  const initial = serializePublicError(classified.code, classified.message, correlationId);
243
246
  if (Buffer.byteLength(initial.text, "utf8") <= MAX_PUBLIC_ERROR_RESPONSE_BYTES) {
244
247
  return initial;
@@ -48,3 +48,8 @@ export function renderToolFailureEvent(input) {
48
48
  export function emitToolFailureEvent(input) {
49
49
  process.stderr.write(`${renderToolFailureEvent(input)}\n`);
50
50
  }
51
+ // prettier-ignore
52
+ export function emitCompilerWorkerEvent(input) {
53
+ const event = { event: "compiler_worker", version: 1, kind: input.kind, generation: Number.isSafeInteger(input.generation) ? input.generation : 0, correlation_id: input.correlationId && UUID_PATTERN.test(input.correlationId) ? input.correlationId : undefined, count: input.count === undefined ? undefined : Math.max(0, Math.min(128, Math.trunc(input.count))) };
54
+ process.stderr.write(`${JSON.stringify(event)}\n`);
55
+ }
@@ -1,9 +1,12 @@
1
+ import path from "node:path";
1
2
  export const RUNTIME_POLICY_ENV_KEYS = Object.freeze([
2
3
  "AST_MAX_PROJECT_SESSIONS",
3
4
  "AST_MAX_QUEUED_OPERATIONS_PER_PROJECT",
4
5
  "AST_QUEUE_WAIT_TIMEOUT_MS",
5
6
  "AST_OPERATION_DEADLINE_MS",
6
7
  "AST_SHUTDOWN_DRAIN_TIMEOUT_MS",
8
+ "AST_COMPILER_WORKER_MODE",
9
+ "AST_COMPILER_WORKER_IDLE_TTL_MS",
7
10
  ]);
8
11
  function parseBoundedInteger(rawValue, defaultValue, minimum, maximum) {
9
12
  if (rawValue === undefined)
@@ -26,12 +29,22 @@ export function parseRuntimePolicy(environment) {
26
29
  const queueWaitTimeoutMs = parseBoundedInteger(environment.AST_QUEUE_WAIT_TIMEOUT_MS, 30_000, 100, 300_000);
27
30
  const operationDeadlineMs = parseBoundedInteger(environment.AST_OPERATION_DEADLINE_MS, 120_000, 1_000, 900_000);
28
31
  const shutdownDrainTimeoutMs = parseBoundedInteger(environment.AST_SHUTDOWN_DRAIN_TIMEOUT_MS, 10_000, 100, 60_000);
32
+ const rawWorkerMode = environment.AST_COMPILER_WORKER_MODE;
33
+ const compilerWorkerMode = rawWorkerMode === "supervised" ? "supervised" : "in_process";
34
+ const compilerWorkerModeReason = rawWorkerMode === undefined
35
+ ? "default"
36
+ : rawWorkerMode === "in_process" || rawWorkerMode === "supervised"
37
+ ? "configured"
38
+ : "invalid_mode";
39
+ const compilerWorkerIdleTtlMs = parseBoundedInteger(environment.AST_COMPILER_WORKER_IDLE_TTL_MS, 60_000, 0, 86_400_000);
29
40
  const reasons = Object.freeze({
30
41
  AST_MAX_PROJECT_SESSIONS: maxProjectSessions.reason,
31
42
  AST_MAX_QUEUED_OPERATIONS_PER_PROJECT: maxQueuedOperationsPerProject.reason,
32
43
  AST_QUEUE_WAIT_TIMEOUT_MS: queueWaitTimeoutMs.reason,
33
44
  AST_OPERATION_DEADLINE_MS: operationDeadlineMs.reason,
34
45
  AST_SHUTDOWN_DRAIN_TIMEOUT_MS: shutdownDrainTimeoutMs.reason,
46
+ AST_COMPILER_WORKER_MODE: compilerWorkerModeReason,
47
+ AST_COMPILER_WORKER_IDLE_TTL_MS: compilerWorkerIdleTtlMs.reason,
35
48
  });
36
49
  return Object.freeze({
37
50
  maxProjectSessions: maxProjectSessions.value,
@@ -39,9 +52,57 @@ export function parseRuntimePolicy(environment) {
39
52
  queueWaitTimeoutMs: queueWaitTimeoutMs.value,
40
53
  operationDeadlineMs: operationDeadlineMs.value,
41
54
  shutdownDrainTimeoutMs: shutdownDrainTimeoutMs.value,
55
+ compilerWorkerMode,
56
+ compilerWorkerIdleTtlMs: compilerWorkerIdleTtlMs.value,
42
57
  reasons,
43
58
  });
44
59
  }
60
+ const WORKER_PATH_KEYS = ["HOME", "XDG_CACHE_HOME", "AST_SYMBOL_INDEX_CACHE_ROOT"];
61
+ const WORKER_ENV_KEYS = [
62
+ ...WORKER_PATH_KEYS,
63
+ "AST_SYMBOL_INDEX_PERSISTENCE",
64
+ "AST_SYMBOL_INDEX_BUSY_TIMEOUT_MS",
65
+ ];
66
+ const MAX_WORKER_ENV_VALUE_LENGTH = 4096;
67
+ function validEnvironmentString(value) {
68
+ return (typeof value === "string" &&
69
+ value.length <= MAX_WORKER_ENV_VALUE_LENGTH &&
70
+ !value.includes("\0"));
71
+ }
72
+ export function createCompilerWorkerSpawnSpec(workerEntryPath, environment) {
73
+ const projected = {};
74
+ for (const key of WORKER_ENV_KEYS) {
75
+ const value = environment[key];
76
+ if (value === undefined)
77
+ continue;
78
+ if (!validEnvironmentString(value))
79
+ return { ok: false, reason: "invalid_environment" };
80
+ const isPath = WORKER_PATH_KEYS.includes(key);
81
+ const invalidMode = key === "AST_SYMBOL_INDEX_PERSISTENCE" && !["disabled", "canary", "enabled"].includes(value);
82
+ const invalidTimeout = key === "AST_SYMBOL_INDEX_BUSY_TIMEOUT_MS" &&
83
+ parseBoundedInteger(value, 1_000, 1, 60_000).reason !== "configured";
84
+ if ((isPath && (!path.isAbsolute(value) || path.normalize(value) !== value)) ||
85
+ invalidMode ||
86
+ invalidTimeout) {
87
+ return { ok: false, reason: "invalid_environment" };
88
+ }
89
+ projected[key] = value;
90
+ }
91
+ const policy = parseRuntimePolicy(environment);
92
+ Object.assign(projected, {
93
+ AST_MAX_PROJECT_SESSIONS: String(policy.maxProjectSessions),
94
+ AST_MAX_QUEUED_OPERATIONS_PER_PROJECT: String(policy.maxQueuedOperationsPerProject),
95
+ AST_QUEUE_WAIT_TIMEOUT_MS: String(policy.queueWaitTimeoutMs),
96
+ AST_OPERATION_DEADLINE_MS: String(policy.operationDeadlineMs),
97
+ AST_SHUTDOWN_DRAIN_TIMEOUT_MS: String(policy.shutdownDrainTimeoutMs),
98
+ });
99
+ return {
100
+ ok: true,
101
+ command: process.execPath,
102
+ args: [workerEntryPath],
103
+ options: { shell: false, env: projected },
104
+ };
105
+ }
45
106
  export function readRuntimePolicy(environment = process.env) {
46
107
  return parseRuntimePolicy(environment);
47
108
  }
@@ -0,0 +1,126 @@
1
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
+ import { createServer } from "./server.js";
3
+ import { beginProjectShutdown, closeDrainedProjectSessions, getProjectRuntimeShutdownSnapshot, prepareProjectRuntimeForStartup, waitForProjectCompletionCriticalOperationsToDrain, waitForProjectOperationsToDrain, } from "./services/project.js";
4
+ import { RuntimeActivityTracker } from "./services/runtime-activity.js";
5
+ import { readRuntimePolicy } from "./services/runtime-policy.js";
6
+ import { emitIncompleteShutdownEvent, ShutdownCoordinator, } from "./services/shutdown.js";
7
+ function emitShutdownFailureEvent() {
8
+ process.stderr.write(`${JSON.stringify({ event: "shutdown_failed", version: 1 })}\n`);
9
+ }
10
+ export async function runStdioServer(options = {}) {
11
+ if (options.server && !options.runtimeActivity) {
12
+ throw new Error("An injected MCP server requires its runtime activity tracker.");
13
+ }
14
+ prepareProjectRuntimeForStartup();
15
+ const runtimeActivity = options.runtimeActivity ?? new RuntimeActivityTracker();
16
+ runtimeActivity.prepareForStartup();
17
+ const server = options.server ?? createServer({ runtimeActivity });
18
+ const transport = options.transport ?? new StdioServerTransport();
19
+ const send = transport.send.bind(transport);
20
+ transport.send = (message, sendOptions) => runtimeActivity.trackSend(() => send(message, sendOptions));
21
+ const runtimePolicy = options.runtimePolicy ?? readRuntimePolicy();
22
+ const installProcessHandlers = options.installProcessHandlers ?? true;
23
+ const exit = options.exit ?? ((code) => process.exit(code));
24
+ const protocol = server.server;
25
+ const previousTransportClose = protocol.onclose;
26
+ let listenersInstalled = false;
27
+ let terminalHandlerInstalled = false;
28
+ const onSigint = () => requestFromRuntime("sigint");
29
+ const onSigterm = () => requestFromRuntime("sigterm");
30
+ const onStdinEnd = () => requestFromRuntime("stdin_eof");
31
+ const onStdinClose = () => requestFromRuntime("stdin_eof");
32
+ const onTransportClose = () => {
33
+ previousTransportClose?.();
34
+ requestFromRuntime("transport_closed");
35
+ };
36
+ const removeLifecycleListeners = () => {
37
+ if (listenersInstalled) {
38
+ process.off("SIGINT", onSigint);
39
+ process.off("SIGTERM", onSigterm);
40
+ process.stdin.off("end", onStdinEnd);
41
+ process.stdin.off("close", onStdinClose);
42
+ listenersInstalled = false;
43
+ }
44
+ if (protocol.onclose === onTransportClose)
45
+ protocol.onclose = previousTransportClose;
46
+ };
47
+ const coordinator = new ShutdownCoordinator({
48
+ drainTimeoutMs: runtimePolicy.shutdownDrainTimeoutMs,
49
+ stopAdmissionAndCancel: () => {
50
+ runtimeActivity.beginShutdown();
51
+ return beginProjectShutdown();
52
+ },
53
+ getDrainSnapshot: getProjectRuntimeShutdownSnapshot,
54
+ waitForDrain: async () => {
55
+ await Promise.all([waitForProjectOperationsToDrain(), runtimeActivity.waitForIdle()]);
56
+ },
57
+ waitForCompletionCriticalOperationsToDrain: waitForProjectCompletionCriticalOperationsToDrain,
58
+ closeMcp: async () => {
59
+ await server.close();
60
+ },
61
+ closeProjects: closeDrainedProjectSessions,
62
+ emitIncompleteShutdown: emitIncompleteShutdownEvent,
63
+ onCompletionCriticalWait: options.onCompletionCriticalWait,
64
+ });
65
+ let lifecycleCompletion;
66
+ function requestShutdown(trigger) {
67
+ lifecycleCompletion ??= coordinator.shutdown(trigger).finally(removeLifecycleListeners);
68
+ return lifecycleCompletion;
69
+ }
70
+ function requestFromRuntime(trigger) {
71
+ try {
72
+ options.onRuntimeTrigger?.(trigger);
73
+ }
74
+ catch {
75
+ // Runtime observation is non-authoritative and must never interrupt shutdown.
76
+ }
77
+ const completion = requestShutdown(trigger);
78
+ if (terminalHandlerInstalled)
79
+ return;
80
+ terminalHandlerInstalled = true;
81
+ void completion
82
+ .then((result) => {
83
+ if (result.state === "forced_noncritical")
84
+ exit(1);
85
+ })
86
+ .catch(() => {
87
+ emitShutdownFailureEvent();
88
+ exit(1);
89
+ });
90
+ }
91
+ protocol.onclose = onTransportClose;
92
+ if (installProcessHandlers) {
93
+ process.on("SIGINT", onSigint);
94
+ process.on("SIGTERM", onSigterm);
95
+ process.stdin.once("end", onStdinEnd);
96
+ process.stdin.once("close", onStdinClose);
97
+ listenersInstalled = true;
98
+ }
99
+ try {
100
+ await server.connect(transport);
101
+ }
102
+ catch (error) {
103
+ await requestShutdown("requested").catch(() => undefined);
104
+ throw error;
105
+ }
106
+ if (options.logStartup ?? true)
107
+ process.stderr.write("ast-mcp-server running over stdio.\n");
108
+ return Object.freeze({
109
+ shutdown: (trigger = "requested") => requestShutdown(trigger),
110
+ shutdownGraceMs: runtimePolicy.shutdownDrainTimeoutMs,
111
+ snapshot: () => {
112
+ const runtime = runtimeActivity.snapshot(), project = getProjectRuntimeShutdownSnapshot();
113
+ return Object.freeze({
114
+ runtime_admission: runtime.admission,
115
+ active_requests: runtime.active_requests,
116
+ active_sends: runtime.active_sends,
117
+ project_admission: project.admission,
118
+ session_count: project.session_count,
119
+ active_operations: project.active_operations,
120
+ queued_operations: project.queued_operations,
121
+ completion_critical_operations: project.completion_critical_operations,
122
+ mutation_history: project.mutation_history,
123
+ });
124
+ },
125
+ });
126
+ }
package/docs/support.md CHANGED
@@ -1,20 +1,20 @@
1
1
  # Support policy
2
2
 
3
- This document defines the supported platform, runtime, persistence, and operational boundary for published `ast-mcp-server` v0.10.0 and the local `0.11.0` release candidate.
3
+ This document defines the supported platform, runtime, persistence, and operational boundary for published `ast-mcp-server` v0.10.0 and the local `0.11.2` release candidate.
4
4
 
5
5
  ## Supported release target
6
6
 
7
7
  | Environment | Status | Contract |
8
8
  | ----------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------- |
9
9
  | Linux x64 with required GNU coreutils and procfs, Node.js 22.5.0 | Historical | Immutable v0.8.1 release evidence; not the runtime floor of the published or candidate lines. |
10
- | Linux x64 with required GNU coreutils and procfs, exact Node.js 22.13.0 | Supported | Published v0.10.0 and local 0.11.0 matrices, each bound to its own package and tree bytes. |
11
- | Linux x64 with required GNU coreutils and procfs, current Node.js 24 line | Supported | Published v0.10.0 and local 0.11.0 matrices, each bound to its own package and tree bytes. |
10
+ | Linux x64 with required GNU coreutils and procfs, exact Node.js 22.13.0 | Supported | Published v0.10.0 and local 0.11.2 matrices, each bound to its own package and tree bytes. |
11
+ | Linux x64 with required GNU coreutils and procfs, current Node.js 24 line | Supported | Published v0.10.0 and local 0.11.2 matrices, each bound to its own package and tree bytes. |
12
12
  | Other Linux architectures or systems without the required filesystem features | Unverified | Not supported until equivalent architecture, mutation, and filesystem-publication gates pass. |
13
13
  | macOS | Unverified | Not supported until equivalent filesystem, process, package, mutation, and canary gates pass. |
14
14
  | Windows | Unverified | Not supported until equivalent filesystem, process, package, mutation, and canary gates pass. |
15
15
  | Remote, untrusted, or multi-tenant service use | Unsupported | No network authentication, sandbox, or tenant-isolation boundary is provided. |
16
16
 
17
- The local `0.11.0` candidate requires Node.js `>=22.13.0`; its matrix targets exact Node.js 22.13.0 and the governed Node.js 24 major. Published v0.10.0 retains its immutable `>=22.13.0` metadata and exact Node.js 22.13.0/24 release evidence. The earlier v0.8.1 Node.js 22.5.0/24 matrix remains historical evidence for those bytes only. A future runtime satisfying an engine range is not automatically a verified release target.
17
+ The local `0.11.2` release candidate requires Node.js `>=22.13.0`; its matrix targets exact Node.js 22.13.0 and the governed Node.js 24 major. Published v0.10.0 retains its immutable `>=22.13.0` metadata and exact Node.js 22.13.0/24 release evidence. The earlier v0.8.1 Node.js 22.5.0/24 matrix remains historical evidence for those bytes only. A future runtime satisfying an engine range is not automatically a verified release target.
18
18
 
19
19
  The checked-evidence freezer requires GNU coreutils 9.7 `mv` with `--update=none-fail`, `--no-copy`, and `--no-target-directory`. Managed setup-file mutation additionally requires that same `mv` with `--exchange`, GNU coreutils `ln -L -T`, procfs descriptor paths at `/proc/self/fd`, `O_DIRECTORY`, and `O_NOFOLLOW`. It fails closed when any verified Linux primitive is unavailable. Equivalent publication, exchange, descriptor-link, and descriptor-relative mutation semantics have not been verified on other operating systems or architectures.
20
20
 
@@ -32,9 +32,25 @@ The server does not provide:
32
32
 
33
33
  Operation locks coordinate cooperating same-user processes. They do not stop editors, network filesystems, or hostile same-user writers. Report-set freezer coordination likewise assumes cooperating same-UID processes and cannot defend checked evidence against a malicious process with the same filesystem authority. The freezer guarantees no-replace atomic visibility of the checked directory; it does not claim persistence across sudden power loss because parent-directory `fsync` durability is not established.
34
34
 
35
+ ## Optional supervised compiler worker
36
+
37
+ The default and rollback remain `AST_COMPILER_WORKER_MODE=in_process`. Explicit `supervised` mode keeps one lightweight stdio parent connected to one disposable compiler child per connection:
38
+
39
+ ```bash
40
+ AST_COMPILER_WORKER_MODE=supervised \
41
+ AST_COMPILER_WORKER_IDLE_TTL_MS=60000 \
42
+ ast-mcp-server
43
+ ```
44
+
45
+ The parent waits for `ready` before replaying bounded initialization state. Generation-affine forwarding and cancellation reject stale settlements and do not retry forwarded calls. Recycling requires stable parent and child quiescence; mutation history, live leases, and completion-critical work keep the child pinned. Parent death closes admission and permits only completion-critical drain before exit.
46
+
47
+ Set the TTL to `0` to disable recycling without removing the relay, or select `in_process` to remove the worker boundary. No shared daemon, pool, cross-client deduplication, default promotion, or automatic mutation-plan repair is provided.
48
+
49
+ The scoped Linux evidence passed on exact Node.js 22.13.0 and the governed Node.js 24 line: three parents each completed three load/idle/respawn cycles with at least 80% PSS-delta reclamation and no upward trend. Respawns retained the compiler fingerprint, recorded six SQLite hits, reused exactly 400 files, rebuilt zero, and returned equivalent reads. Diagnostics remained bounded and redacted, and parent-death inspection found zero orphan processes. These results do not establish support for macOS, Windows, or other Linux environments.
50
+
35
51
  ## Symbol-index persistence
36
52
 
37
- Published v0.10.0 and the local `0.11.0` candidate select native SQLite when the persistence setting is absent or explicitly `enabled`; operators requiring no persistent index state must set `disabled` explicitly:
53
+ Published v0.10.0 and the local `0.11.2` release candidate select native SQLite when the persistence setting is absent or explicitly `enabled`; operators requiring no persistent index state must set `disabled` explicitly:
38
54
 
39
55
  ```bash
40
56
  AST_SYMBOL_INDEX_PERSISTENCE=disabled \
@@ -64,7 +80,7 @@ Both commands are bounded and path-free. Clear preflights the tree, refuses unsa
64
80
 
65
81
  ## Production-readiness acceptance
66
82
 
67
- The four retained Linux x64 production-readiness reports cover `ast-mcp-server` and `x-scraper` under Node.js 22.5.0 and Node.js 24 for package version 0.6.0, commit `2d0b21bbb80fae1acfca6a85d5891d87e68b59c1`, and tree `af931d49769fabdf06f623965a6cfe1f9afb8a81`. They are historical integration evidence only: they are not reattributed to v0.8.1, published-next v0.9.0 or v0.9.1, the local 0.9.2 candidate, published v0.10.0, or the local v0.11.0 candidate. The exact 0.9.0 and 0.9.1 release matrices remain historical evidence for their immutable published-next bytes, the exact 0.9.2 matrix remains historical local-candidate evidence, and the exact 0.10.0 matrix remains historical published-release evidence for its own bytes. A separate exact 0.11.0 release matrix is the candidate-specific gate for current repository bytes under both supported runtime lines, including managed setup; none of these matrices rebinds the older retained reports. MCP-PROD-404 remains normative for admission:
83
+ The four retained Linux x64 production-readiness reports cover `ast-mcp-server` and `x-scraper` under Node.js 22.5.0 and Node.js 24 for package version 0.6.0, commit `2d0b21bbb80fae1acfca6a85d5891d87e68b59c1`, and tree `af931d49769fabdf06f623965a6cfe1f9afb8a81`. They are historical integration evidence only: they are not reattributed to v0.8.1, published-next v0.9.0 or v0.9.1, the local 0.9.2 candidate, published v0.10.0, published-next v0.11.0 or v0.11.1, or the local v0.11.2 release candidate. The exact 0.9.0 and 0.9.1 release matrices remain historical evidence for their immutable published-next bytes, the exact 0.9.2 matrix remains historical local-candidate evidence, the exact 0.10.0 matrix remains historical published-release evidence, and the exact 0.11.0 and 0.11.1 matrices remain historical evidence for their immutable published-next bytes before public-registry verification exposed their distinct verifier defects. A separate exact 0.11.2 release matrix is the release-candidate gate for current repository bytes under both supported runtime lines, including supervised-worker lifecycle and reclamation evidence, deterministic CLI project-discovery fixture setup, local-registry managed-asset verification, and the public copied-runner dependency-closure regression; none of these matrices rebinds the older retained reports. MCP-PROD-404 remains normative for admission:
68
84
 
69
85
  - zero semantic mismatches across disabled, cold, warm, restart, and rollback reads;
70
86
  - byte-identical real-repository status and worktree trees;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ast-mcp-server",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Structural code MCP server and batch CLI for compact TypeScript/JavaScript reads and hash-bound AST edits.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -49,7 +49,7 @@
49
49
  "scripts": {
50
50
  "build": "tsc",
51
51
  "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
52
- "test": "vitest run",
52
+ "test": "vitest run --exclude test/supervised-worker.integration.test.ts && yarn build && vitest run test/supervised-worker.integration.test.ts --no-file-parallelism",
53
53
  "test:watch": "vitest",
54
54
  "test:mcp": "yarn build && node scripts/mcp-smoke.mjs",
55
55
  "test:lifecycle": "yarn build && node scripts/mcp-lifecycle-smoke.mjs",
@@ -97,5 +97,5 @@
97
97
  "typescript-eslint": "^8.65.0",
98
98
  "vitest": "^4.1.10"
99
99
  },
100
- "gitHead": "f0c8a66d72db71ffdf8ef0014f4b7b28f1d8852e"
100
+ "gitHead": "f7b429670093bd9f43195979e395ccf418eb4ba2"
101
101
  }
@@ -18,7 +18,7 @@ Cargar este skill antes de navegar semánticamente, analizar impacto o referenci
18
18
 
19
19
  No forzar un roundtrip AST para Markdown, configuración, comentarios ni una edición textual trivial en un archivo ya conocido. Si las tools `ast_*` no están disponibles, declarar el fallback y no presentar búsquedas textuales como evidencia compiler-backed.
20
20
 
21
- The local 0.11.0 release candidate requires Node.js `>=22.13.0` and is verified with exact Node.js 22.13.0 plus the governed Node.js 24 line. Published v0.10.0 retains its immutable Node.js 22.13.0/24 release evidence.
21
+ The local 0.11.2 release candidate requires Node.js `>=22.13.0` and is verified with exact Node.js 22.13.0 plus the governed Node.js 24 line. Published v0.10.0 retains its immutable Node.js 22.13.0/24 release evidence.
22
22
 
23
23
  ## Hard Rules
24
24
 
@@ -6,7 +6,7 @@
6
6
  "files": [
7
7
  {
8
8
  "path": "SKILL.md",
9
- "sha256": "be757e8bd98733c7ee952d63a2805dcd9877becf651b4fcb6ab63d20f6546f21"
9
+ "sha256": "18132b4b747d135e4ccfbca9e2190a9afb41e144cc06c31c5e6bc0f1571e11da"
10
10
  },
11
11
  {
12
12
  "path": "references/operations.md",