@akira-tl/forgerelay 0.4.3 → 0.4.5

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.
@@ -0,0 +1,102 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { pathToFileURL } from "node:url";
3
+ import { DidChangeTextDocumentNotification, DidCloseTextDocumentNotification, DidOpenTextDocumentNotification, TextDocumentSyncKind, } from "vscode-languageserver-protocol";
4
+ import { wholeDocumentRange } from "../position-encoding.js";
5
+ export class DocumentSynchronizer {
6
+ definition;
7
+ documents = new Map();
8
+ queue = Promise.resolve();
9
+ constructor(definition) {
10
+ this.definition = definition;
11
+ }
12
+ get(uri) {
13
+ return this.documents.get(uri);
14
+ }
15
+ get size() {
16
+ return this.documents.size;
17
+ }
18
+ async sync(sourcePath, connection, synchronizationValue, positionEncoding) {
19
+ const operation = this.queue.then(() => this.syncNow(sourcePath, connection, synchronizationValue, positionEncoding), () => this.syncNow(sourcePath, connection, synchronizationValue, positionEncoding));
20
+ this.queue = operation.then(() => undefined, () => undefined);
21
+ return operation;
22
+ }
23
+ async closeAll(connection) {
24
+ await this.queue;
25
+ for (const document of this.documents.values()) {
26
+ if (!document.openNotified)
27
+ continue;
28
+ try {
29
+ await connection.sendNotification(DidCloseTextDocumentNotification.type, {
30
+ textDocument: { uri: document.uri },
31
+ });
32
+ }
33
+ catch {
34
+ // The server may already be gone.
35
+ }
36
+ }
37
+ }
38
+ clear() {
39
+ this.documents.clear();
40
+ this.queue = Promise.resolve();
41
+ }
42
+ async syncNow(sourcePath, connection, synchronizationValue, positionEncoding) {
43
+ const uri = pathToFileURL(sourcePath).href;
44
+ const text = await readFile(sourcePath, "utf8");
45
+ const existing = this.documents.get(uri);
46
+ const languageId = languageIdForPath(this.definition, sourcePath);
47
+ const synchronization = textDocumentSynchronization(synchronizationValue);
48
+ if (!existing) {
49
+ const document = { uri, languageId, version: 1, text, openNotified: false };
50
+ this.documents.set(uri, document);
51
+ if (synchronization.openClose) {
52
+ await connection.sendNotification(DidOpenTextDocumentNotification.type, {
53
+ textDocument: {
54
+ uri: document.uri,
55
+ languageId: document.languageId,
56
+ version: document.version,
57
+ text: document.text,
58
+ },
59
+ });
60
+ document.openNotified = true;
61
+ }
62
+ return document;
63
+ }
64
+ if (existing.text !== text) {
65
+ const previousText = existing.text;
66
+ existing.version += 1;
67
+ existing.text = text;
68
+ if (synchronization.change === TextDocumentSyncKind.Full) {
69
+ await connection.sendNotification(DidChangeTextDocumentNotification.type, {
70
+ textDocument: { uri, version: existing.version },
71
+ contentChanges: [{ text }],
72
+ });
73
+ }
74
+ else if (synchronization.change === TextDocumentSyncKind.Incremental) {
75
+ await connection.sendNotification(DidChangeTextDocumentNotification.type, {
76
+ textDocument: { uri, version: existing.version },
77
+ contentChanges: [{
78
+ range: wholeDocumentRange(previousText, positionEncoding),
79
+ text,
80
+ }],
81
+ });
82
+ }
83
+ }
84
+ return existing;
85
+ }
86
+ }
87
+ function languageIdForPath(definition, path) {
88
+ const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
89
+ return definition.languageIdByExtension[extension] ?? definition.languages[0];
90
+ }
91
+ function textDocumentSynchronization(value) {
92
+ if (typeof value === "number") {
93
+ return {
94
+ openClose: value !== TextDocumentSyncKind.None,
95
+ change: value,
96
+ };
97
+ }
98
+ return {
99
+ openClose: value?.openClose === true,
100
+ change: value?.change ?? TextDocumentSyncKind.None,
101
+ };
102
+ }
@@ -1,5 +1,5 @@
1
1
  import { realpath } from "node:fs/promises";
2
- import { resolve } from "node:path";
2
+ import { resolve, sep } from "node:path";
3
3
  import { CodeIntelligenceError, LanguageService, languageServiceKey, } from "../code-intelligence.js";
4
4
  import { LanguageServerConfigurationError, resolveLanguageProject, } from "../language-server-config.js";
5
5
  const LANGUAGE_SERVICE_IDLE_MS = 10 * 60 * 1_000;
@@ -8,15 +8,25 @@ const MAX_LANGUAGE_SERVICES = 16;
8
8
  const LANGUAGE_SERVICE_START_TIMEOUT_MS = 15_000;
9
9
  const LANGUAGE_REQUEST_TIMEOUT_MS = 10_000;
10
10
  const LANGUAGE_SERVICE_SHUTDOWN_TIMEOUT_MS = 2_000;
11
+ const MAX_CONCURRENT_SEMANTIC_REQUESTS = 4;
12
+ const MAX_QUEUED_SEMANTIC_REQUESTS = 16;
13
+ const MAX_DIAGNOSTIC_DOCUMENTS = 128;
14
+ const MAX_DIAGNOSTICS_PER_DOCUMENT = 1000;
15
+ const LANGUAGE_SERVICE_CRASH_COOLDOWN_MS = 5_000;
11
16
  export class CodeIntelligenceManager {
12
17
  config;
13
18
  services = new Map();
14
19
  serviceCreations = new Map();
20
+ invalidatedServiceKeys = new Set();
21
+ crashStates = new Map();
22
+ retiredWorkspaceRoots = new Set();
15
23
  serviceCreationQueue = Promise.resolve();
16
24
  cleanupTimer;
17
25
  policy;
26
+ crashCooldownMs;
18
27
  constructor(config, options = {}) {
19
28
  this.config = config;
29
+ this.crashCooldownMs = positiveInteger(options.crashCooldownMs, LANGUAGE_SERVICE_CRASH_COOLDOWN_MS, "crashCooldownMs");
20
30
  this.policy = {
21
31
  idleMs: positiveInteger(options.idleMs, LANGUAGE_SERVICE_IDLE_MS, "idleMs"),
22
32
  cleanupIntervalMs: positiveInteger(options.cleanupIntervalMs, LANGUAGE_SERVICE_CLEANUP_INTERVAL_MS, "cleanupIntervalMs"),
@@ -24,17 +34,22 @@ export class CodeIntelligenceManager {
24
34
  startTimeoutMs: positiveInteger(options.startTimeoutMs, LANGUAGE_SERVICE_START_TIMEOUT_MS, "startTimeoutMs"),
25
35
  requestTimeoutMs: positiveInteger(options.requestTimeoutMs, LANGUAGE_REQUEST_TIMEOUT_MS, "requestTimeoutMs"),
26
36
  shutdownTimeoutMs: positiveInteger(options.shutdownTimeoutMs, LANGUAGE_SERVICE_SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs"),
37
+ maxConcurrentSemanticRequests: positiveInteger(options.maxConcurrentSemanticRequests, MAX_CONCURRENT_SEMANTIC_REQUESTS, "maxConcurrentSemanticRequests"),
38
+ maxQueuedSemanticRequests: positiveInteger(options.maxQueuedSemanticRequests, MAX_QUEUED_SEMANTIC_REQUESTS, "maxQueuedSemanticRequests"),
39
+ maxDiagnosticDocuments: positiveInteger(options.maxDiagnosticDocuments, MAX_DIAGNOSTIC_DOCUMENTS, "maxDiagnosticDocuments"),
40
+ maxDiagnosticsPerDocument: positiveInteger(options.maxDiagnosticsPerDocument, MAX_DIAGNOSTICS_PER_DOCUMENT, "maxDiagnosticsPerDocument"),
27
41
  };
28
42
  this.cleanupTimer = setInterval(() => {
29
43
  void this.closeIdle();
30
44
  }, this.policy.cleanupIntervalMs);
31
45
  this.cleanupTimer.unref();
32
46
  }
33
- async run(workspaceRoot, input) {
47
+ async run(workspaceRoot, input, options = {}) {
34
48
  let project;
35
49
  let canonicalWorkspaceRoot;
36
50
  try {
37
51
  canonicalWorkspaceRoot = await realpath(resolve(workspaceRoot));
52
+ this.assertWorkspaceRootAvailable(canonicalWorkspaceRoot);
38
53
  project = await resolveLanguageProject({
39
54
  workspaceRoot: canonicalWorkspaceRoot,
40
55
  sourcePath: input.path,
@@ -47,24 +62,42 @@ export class CodeIntelligenceManager {
47
62
  }
48
63
  throw error;
49
64
  }
50
- const service = await this.acquireService(canonicalWorkspaceRoot, project);
51
- try {
52
- switch (input.operation) {
53
- case "definition":
54
- return await service.definition(input);
55
- case "hover":
56
- return await service.hover(input);
57
- case "references":
58
- return await service.references(input);
59
- case "documentSymbols":
60
- return await service.documentSymbols(input);
61
- case "workspaceSymbols":
62
- return await service.workspaceSymbols(input);
65
+ await this.invalidateChangedServices(project);
66
+ const identity = languageServiceKey(project);
67
+ this.assertNotCoolingDown(identity, project);
68
+ let lastCrash;
69
+ for (let attempt = 0; attempt < 2; attempt += 1) {
70
+ this.assertWorkspaceRootAvailable(canonicalWorkspaceRoot);
71
+ const service = await this.acquireService(canonicalWorkspaceRoot, project);
72
+ let crashed = false;
73
+ try {
74
+ const result = await this.executeOperation(service, input, options.signal);
75
+ this.crashStates.delete(identity);
76
+ return result;
77
+ }
78
+ catch (error) {
79
+ if (!(error instanceof CodeIntelligenceError) || error.code !== "code.server_crashed") {
80
+ throw error;
81
+ }
82
+ crashed = true;
83
+ lastCrash = error;
84
+ }
85
+ finally {
86
+ await this.releaseService(service);
87
+ }
88
+ if (crashed) {
89
+ await this.discardService(service);
90
+ const failures = (this.crashStates.get(identity)?.failures ?? 0) + 1;
91
+ if (attempt === 0) {
92
+ this.crashStates.set(identity, { failures, cooldownUntil: 0 });
93
+ continue;
94
+ }
95
+ const cooldownUntil = Date.now() + this.crashCooldownMs;
96
+ this.crashStates.set(identity, { failures, cooldownUntil });
97
+ throw new CodeIntelligenceError("code.language_service_cooldown", `Language server ${project.definition.id} crashed repeatedly; retry after ${this.crashCooldownMs}ms. Last error: ${lastCrash.message}`);
63
98
  }
64
99
  }
65
- finally {
66
- service.release();
67
- }
100
+ throw lastCrash ?? new CodeIntelligenceError("code.server_crashed", `Language server ${project.definition.id} failed without a recoverable result.`);
68
101
  }
69
102
  async shutdown() {
70
103
  clearInterval(this.cleanupTimer);
@@ -72,11 +105,150 @@ export class CodeIntelligenceManager {
72
105
  this.serviceCreations.clear();
73
106
  const services = [...this.services.values()];
74
107
  this.services.clear();
108
+ this.invalidatedServiceKeys.clear();
109
+ this.crashStates.clear();
110
+ this.retiredWorkspaceRoots.clear();
75
111
  await Promise.allSettled(services.map((service) => service.shutdown()));
76
112
  }
77
113
  get size() {
78
114
  return this.services.size;
79
115
  }
116
+ stats() {
117
+ const services = [...this.services.values()];
118
+ const now = Date.now();
119
+ return services.reduce((stats, service) => {
120
+ const serviceStats = service.runtimeStats;
121
+ stats.servicesActive += service.isIdle ? 0 : 1;
122
+ stats.servicesIdle += service.isIdle ? 1 : 0;
123
+ stats.processesRunning += serviceStats.processRunning ? 1 : 0;
124
+ stats.operationsInFlight += serviceStats.operationInFlight;
125
+ stats.semanticRequestsActive += serviceStats.semanticRequestsActive;
126
+ stats.semanticRequestsQueued += serviceStats.semanticRequestsQueued;
127
+ stats.openDocuments += serviceStats.openDocuments;
128
+ stats.diagnosticSnapshots += serviceStats.diagnosticSnapshots;
129
+ stats.diagnosticsRetained += serviceStats.diagnosticsRetained;
130
+ stats.stderrBytes += serviceStats.stderrBytes;
131
+ return stats;
132
+ }, {
133
+ servicesTotal: services.length,
134
+ servicesActive: 0,
135
+ servicesIdle: 0,
136
+ processesRunning: 0,
137
+ operationsInFlight: 0,
138
+ semanticRequestsActive: 0,
139
+ semanticRequestsQueued: 0,
140
+ openDocuments: 0,
141
+ diagnosticSnapshots: 0,
142
+ diagnosticsRetained: 0,
143
+ stderrBytes: 0,
144
+ pendingCreations: this.serviceCreations.size,
145
+ crashCooldowns: [...this.crashStates.values()].filter((state) => state.cooldownUntil > now).length,
146
+ invalidatedServices: this.invalidatedServiceKeys.size,
147
+ retiredWorkspaceRoots: this.retiredWorkspaceRoots.size,
148
+ });
149
+ }
150
+ async retireWorkspaceRoot(workspaceRoot) {
151
+ const canonicalRoot = await realpath(resolve(workspaceRoot));
152
+ this.retiredWorkspaceRoots.add(canonicalRoot);
153
+ await Promise.allSettled(this.serviceCreations.values());
154
+ const matching = [...this.services.entries()].filter(([, service]) => resolve(service.workspaceRoot) === canonicalRoot);
155
+ const active = matching.filter(([, service]) => !service.isIdle);
156
+ if (active.length > 0) {
157
+ this.retiredWorkspaceRoots.delete(canonicalRoot);
158
+ throw new CodeIntelligenceError("code.language_service_busy", `Cannot finalize Workspace ${canonicalRoot} while ${active.length} Language service request(s) are still active.`);
159
+ }
160
+ let releasedServices = 0;
161
+ for (const [key, service] of matching) {
162
+ if (this.services.get(key) === service)
163
+ this.services.delete(key);
164
+ this.invalidatedServiceKeys.delete(key);
165
+ this.crashStates.delete(key);
166
+ await service.shutdown();
167
+ releasedServices += 1;
168
+ }
169
+ this.clearIdentityStateForWorkspaceRoot(canonicalRoot);
170
+ return { root: canonicalRoot, releasedServices };
171
+ }
172
+ restoreWorkspaceRoot(root) {
173
+ this.retiredWorkspaceRoots.delete(resolve(root));
174
+ }
175
+ assertWorkspaceRootAvailable(workspaceRoot) {
176
+ if (!this.retiredWorkspaceRoots.has(resolve(workspaceRoot)))
177
+ return;
178
+ throw new CodeIntelligenceError("code.language_service_unavailable", "Code intelligence is unavailable because this managed-worktree Workspace is being finalized.");
179
+ }
180
+ clearIdentityStateForWorkspaceRoot(workspaceRoot) {
181
+ for (const key of [...this.crashStates.keys()]) {
182
+ if (identityBelongsToWorkspaceRoot(key, workspaceRoot))
183
+ this.crashStates.delete(key);
184
+ }
185
+ for (const key of [...this.invalidatedServiceKeys]) {
186
+ if (identityBelongsToWorkspaceRoot(key, workspaceRoot))
187
+ this.invalidatedServiceKeys.delete(key);
188
+ }
189
+ }
190
+ async executeOperation(service, input, signal) {
191
+ switch (input.operation) {
192
+ case "definition":
193
+ return service.definition(input, signal);
194
+ case "hover":
195
+ return service.hover(input, signal);
196
+ case "references":
197
+ return service.references(input, signal);
198
+ case "documentSymbols":
199
+ return service.documentSymbols(input, signal);
200
+ case "workspaceSymbols":
201
+ return service.workspaceSymbols(input, signal);
202
+ case "diagnostics":
203
+ return service.diagnostics(input, signal);
204
+ }
205
+ }
206
+ assertNotCoolingDown(identity, project) {
207
+ const state = this.crashStates.get(identity);
208
+ if (!state?.cooldownUntil)
209
+ return;
210
+ const remaining = state.cooldownUntil - Date.now();
211
+ if (remaining <= 0) {
212
+ this.crashStates.delete(identity);
213
+ return;
214
+ }
215
+ throw new CodeIntelligenceError("code.language_service_cooldown", `Language server ${project.definition.id} is cooling down after repeated crashes; retry in ${remaining}ms.`);
216
+ }
217
+ async invalidateChangedServices(project) {
218
+ const root = resolve(project.projectRoot);
219
+ const invalidated = [];
220
+ for (const [key, service] of this.services) {
221
+ if (resolve(service.project.projectRoot) === root &&
222
+ service.project.definition.id === project.definition.id &&
223
+ service.project.definition.fingerprint !== project.definition.fingerprint) {
224
+ this.invalidatedServiceKeys.add(key);
225
+ this.crashStates.delete(key);
226
+ if (service.isIdle)
227
+ invalidated.push([key, service]);
228
+ }
229
+ }
230
+ for (const [key, service] of invalidated) {
231
+ if (this.services.get(key) === service)
232
+ this.services.delete(key);
233
+ this.invalidatedServiceKeys.delete(key);
234
+ await service.shutdown();
235
+ }
236
+ }
237
+ async releaseService(service) {
238
+ service.release();
239
+ if (!this.invalidatedServiceKeys.has(service.key) || !service.isIdle)
240
+ return;
241
+ if (this.services.get(service.key) === service)
242
+ this.services.delete(service.key);
243
+ this.invalidatedServiceKeys.delete(service.key);
244
+ await service.shutdown();
245
+ }
246
+ async discardService(service) {
247
+ if (this.services.get(service.key) === service)
248
+ this.services.delete(service.key);
249
+ this.invalidatedServiceKeys.delete(service.key);
250
+ await service.shutdown();
251
+ }
80
252
  async acquireService(workspaceRoot, project) {
81
253
  const key = languageServiceKey(project);
82
254
  const existing = this.services.get(key);
@@ -127,7 +299,7 @@ export class CodeIntelligenceManager {
127
299
  }
128
300
  }
129
301
  async closeIdle(now = Date.now()) {
130
- const stale = [...this.services.entries()].filter(([, service]) => service.inFlight === 0 && now - service.lastUsedAt >= this.policy.idleMs);
302
+ const stale = [...this.services.entries()].filter(([, service]) => service.isIdle && now - service.lastUsedAt >= this.policy.idleMs);
131
303
  for (const [key, service] of stale) {
132
304
  this.services.delete(key);
133
305
  await service.shutdown();
@@ -137,7 +309,7 @@ export class CodeIntelligenceManager {
137
309
  if (this.services.size < this.policy.maxServices)
138
310
  return;
139
311
  const idle = [...this.services.entries()]
140
- .filter(([, service]) => service.inFlight === 0)
312
+ .filter(([, service]) => service.isIdle)
141
313
  .sort((left, right) => left[1].lastUsedAt - right[1].lastUsedAt);
142
314
  const candidate = idle[0];
143
315
  if (!candidate) {
@@ -147,6 +319,21 @@ export class CodeIntelligenceManager {
147
319
  await candidate[1].shutdown();
148
320
  }
149
321
  }
322
+ function identityBelongsToWorkspaceRoot(identity, workspaceRoot) {
323
+ try {
324
+ const parsed = JSON.parse(identity);
325
+ const projectRoot = Array.isArray(parsed) && typeof parsed[0] === "string"
326
+ ? resolve(parsed[0])
327
+ : undefined;
328
+ if (!projectRoot)
329
+ return false;
330
+ const root = resolve(workspaceRoot);
331
+ return projectRoot === root || projectRoot.startsWith(`${root}${sep}`);
332
+ }
333
+ catch {
334
+ return false;
335
+ }
336
+ }
150
337
  function positiveInteger(value, fallback, label) {
151
338
  const resolvedValue = value ?? fallback;
152
339
  if (!Number.isInteger(resolvedValue) || resolvedValue < 1) {
@@ -0,0 +1,116 @@
1
+ import { CancellationTokenSource } from "vscode-jsonrpc";
2
+ import { CodeIntelligenceError } from "../code-intelligence-error.js";
3
+ export class SemanticRequestCoordinator {
4
+ options;
5
+ active = 0;
6
+ queue = [];
7
+ constructor(options) {
8
+ this.options = options;
9
+ }
10
+ async run(label, signal, operation) {
11
+ const startedAt = Date.now();
12
+ await this.acquire(label, signal, startedAt);
13
+ const source = new CancellationTokenSource();
14
+ let settledForCaller = false;
15
+ let timeout;
16
+ let onAbort;
17
+ const cancellation = new Promise((_resolve, reject) => {
18
+ const rejectOnce = (error) => {
19
+ if (settledForCaller)
20
+ return;
21
+ settledForCaller = true;
22
+ try {
23
+ source.cancel();
24
+ }
25
+ catch {
26
+ // The Language-server connection may already have closed or crashed.
27
+ }
28
+ reject(error);
29
+ };
30
+ const remaining = Math.max(1, this.options.deadlineMs - (Date.now() - startedAt));
31
+ timeout = setTimeout(() => rejectOnce(new CodeIntelligenceError("code.request_timeout", `${label} exceeded the ${this.options.deadlineMs}ms semantic request deadline.`)), remaining);
32
+ timeout.unref();
33
+ if (signal) {
34
+ onAbort = () => rejectOnce(new CodeIntelligenceError("code.request_cancelled", `${label} was cancelled by the Host.`));
35
+ if (signal.aborted)
36
+ onAbort();
37
+ else
38
+ signal.addEventListener("abort", onAbort, { once: true });
39
+ }
40
+ });
41
+ const underlying = operation(source.token);
42
+ underlying.finally(() => {
43
+ source.dispose();
44
+ this.release();
45
+ }).catch(() => undefined);
46
+ try {
47
+ const value = await Promise.race([underlying, cancellation]);
48
+ settledForCaller = true;
49
+ return value;
50
+ }
51
+ finally {
52
+ if (timeout)
53
+ clearTimeout(timeout);
54
+ if (signal && onAbort)
55
+ signal.removeEventListener("abort", onAbort);
56
+ }
57
+ }
58
+ get activeCount() {
59
+ return this.active;
60
+ }
61
+ get queuedCount() {
62
+ return this.queue.length;
63
+ }
64
+ async acquire(label, signal, startedAt) {
65
+ if (signal?.aborted) {
66
+ throw new CodeIntelligenceError("code.request_cancelled", `${label} was cancelled by the Host.`);
67
+ }
68
+ if (this.active < this.options.maxConcurrent) {
69
+ this.active += 1;
70
+ return;
71
+ }
72
+ if (this.queue.length >= this.options.maxQueued) {
73
+ throw new CodeIntelligenceError("code.request_capacity", `Semantic request queue capacity reached (${this.options.maxQueued}) for this Language service.`);
74
+ }
75
+ await new Promise((resolve, reject) => {
76
+ const waiter = { resolve, reject, signal };
77
+ const remaining = Math.max(1, this.options.deadlineMs - (Date.now() - startedAt));
78
+ const remove = () => {
79
+ const index = this.queue.indexOf(waiter);
80
+ if (index >= 0)
81
+ this.queue.splice(index, 1);
82
+ };
83
+ waiter.timer = setTimeout(() => {
84
+ remove();
85
+ reject(new CodeIntelligenceError("code.request_timeout", `${label} exceeded the ${this.options.deadlineMs}ms semantic request deadline while queued.`));
86
+ }, remaining);
87
+ waiter.timer.unref();
88
+ if (signal) {
89
+ waiter.onAbort = () => {
90
+ remove();
91
+ if (waiter.timer)
92
+ clearTimeout(waiter.timer);
93
+ reject(new CodeIntelligenceError("code.request_cancelled", `${label} was cancelled by the Host.`));
94
+ };
95
+ signal.addEventListener("abort", waiter.onAbort, { once: true });
96
+ }
97
+ this.queue.push(waiter);
98
+ });
99
+ }
100
+ release() {
101
+ this.active = Math.max(0, this.active - 1);
102
+ while (this.queue.length > 0 && this.active < this.options.maxConcurrent) {
103
+ const waiter = this.queue.shift();
104
+ if (waiter.timer)
105
+ clearTimeout(waiter.timer);
106
+ if (waiter.signal && waiter.onAbort)
107
+ waiter.signal.removeEventListener("abort", waiter.onAbort);
108
+ if (waiter.signal?.aborted) {
109
+ waiter.reject(new CodeIntelligenceError("code.request_cancelled", "Semantic request was cancelled by the Host."));
110
+ continue;
111
+ }
112
+ this.active += 1;
113
+ waiter.resolve();
114
+ }
115
+ }
116
+ }
@@ -53,7 +53,7 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
53
53
  await close();
54
54
  await rm(root, { recursive: true, force: true });
55
55
  });
56
- return { client, project, close };
56
+ return { client, project, codeIntelligence, close };
57
57
  }
58
58
  export async function callOpen(client, path, conversationScopeId) {
59
59
  return client.callTool({
package/dist/server.js CHANGED
@@ -755,10 +755,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
755
755
  inspectHooks: (workspaceRoot) => checkHookConfiguration(workspaceRoot, config.hooks),
756
756
  codeIntelligence: {
757
757
  available: true,
758
- run: async (input, context) => {
758
+ run: async (input, context, options) => {
759
759
  try {
760
760
  return {
761
- value: await codeIntelligence.run(context.workspaceRoot, input),
761
+ value: await codeIntelligence.run(context.workspaceRoot, input, { signal: options.signal }),
762
762
  };
763
763
  }
764
764
  catch (error) {
@@ -1254,7 +1254,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1254
1254
  idempotentHint: false,
1255
1255
  openWorldHint: true,
1256
1256
  },
1257
- }, async ({ workspaceId, name, action, arguments: capabilityArguments, file }) => {
1257
+ }, async ({ workspaceId, name, action, arguments: capabilityArguments, file }, extra) => {
1258
1258
  const workspace = workspaces.getWorkspace(workspaceId);
1259
1259
  let changedPaths = [];
1260
1260
  return runToolWithHooks(hooks, {
@@ -1289,7 +1289,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1289
1289
  });
1290
1290
  return result;
1291
1291
  }
1292
- const execution = await capabilityRegistry.run(name, capabilityArguments ?? {}, capabilityContextFor(workspace), { nativeFile: file });
1292
+ const execution = await capabilityRegistry.run(name, capabilityArguments ?? {}, capabilityContextFor(workspace), { nativeFile: file, signal: extra.signal });
1293
1293
  changedPaths = execution.changedPaths ?? [];
1294
1294
  const result = {
1295
1295
  content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(execution.value, null, 2)}`)],
@@ -1389,7 +1389,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1389
1389
  throw new Error(`Cannot close this worktree-backed workspace while logical workspace processes are still running or awaiting completion delivery: ${busyWorkspaceIds.join(", ")}.`);
1390
1390
  }
1391
1391
  const startedAt = performance.now();
1392
- const closed = await workspaces.closeWorktree(workspaceId, commitMessage);
1392
+ const retirement = await codeIntelligence.retireWorkspaceRoot(workspace.root);
1393
+ let closed;
1394
+ try {
1395
+ closed = await workspaces.closeWorktree(workspaceId, commitMessage);
1396
+ }
1397
+ finally {
1398
+ codeIntelligence.restoreWorkspaceRoot(retirement.root);
1399
+ }
1393
1400
  await Promise.all(physicalWorkspaceIds.map((id) => reviewCheckpoints.releaseWorkspace(id)));
1394
1401
  const result = [
1395
1402
  `Closed managed-worktree-backed workspace ${workspaceId}.`,
@@ -2161,6 +2168,7 @@ export function createServer(config = loadConfig(), options = {}) {
2161
2168
  const logRuntimeResources = () => {
2162
2169
  const memory = process.memoryUsage();
2163
2170
  const processStats = processSessions.stats();
2171
+ const codeStats = codeIntelligence.stats();
2164
2172
  logEvent(config.logging, "debug", "runtime_resources", {
2165
2173
  rssBytes: memory.rss,
2166
2174
  heapUsedBytes: memory.heapUsed,
@@ -2173,7 +2181,20 @@ export function createServer(config = loadConfig(), options = {}) {
2173
2181
  processesCompleted: processStats.completed,
2174
2182
  cachedWorkspaces: workspaces.cachedWorkspaceCount,
2175
2183
  reviewStates: reviewCheckpoints.stateCount,
2176
- languageServices: codeIntelligence.size,
2184
+ languageServices: codeStats.servicesTotal,
2185
+ languageServicesActive: codeStats.servicesActive,
2186
+ languageProcessesRunning: codeStats.processesRunning,
2187
+ languageOperationsInFlight: codeStats.operationsInFlight,
2188
+ languageRequestsActive: codeStats.semanticRequestsActive,
2189
+ languageRequestsQueued: codeStats.semanticRequestsQueued,
2190
+ languageOpenDocuments: codeStats.openDocuments,
2191
+ languageDiagnosticSnapshots: codeStats.diagnosticSnapshots,
2192
+ languageDiagnosticsRetained: codeStats.diagnosticsRetained,
2193
+ languageStderrBytes: codeStats.stderrBytes,
2194
+ languagePendingCreations: codeStats.pendingCreations,
2195
+ languageCrashCooldowns: codeStats.crashCooldowns,
2196
+ languageInvalidatedServices: codeStats.invalidatedServices,
2197
+ languageRetiredWorkspaceRoots: codeStats.retiredWorkspaceRoots,
2177
2198
  });
2178
2199
  };
2179
2200
  const transportCleanupTimer = setInterval(() => {
@@ -154,8 +154,9 @@ force a Host to invalidate its cached schema.
154
154
  ### LSP code intelligence
155
155
 
156
156
  ForgeRelay advertises `code.intelligence` through the Capability Gateway; it does
157
- not add language-specific top-level MCP tools. ForgeRelay 0.4.3 supports
158
- `definition`, `hover`, `references`, `documentSymbols`, and `workspaceSymbols`.
157
+ not add language-specific top-level MCP tools. ForgeRelay 0.4.5 supports
158
+ `definition`, `hover`, `references`, `documentSymbols`, `workspaceSymbols`, and
159
+ `diagnostics`.
159
160
  Position-based operations accept the same workspace-relative source position. Hover
160
161
  results normalize plaintext, Markdown, and supported legacy LSP payloads into one
161
162
  `contents` string with optional `language` and normalized `range` metadata.
@@ -164,12 +165,34 @@ returned locations, and accept a `limit` from 1 through 1000. Document symbols u
164
165
  `path` plus optional `limit`, preserve server hierarchy when present, and keep flat
165
166
  legacy symbol responses flat. Workspace symbols use `path` to select the Language
166
167
  project/service, then apply a `query` with an optional bounded `limit`; ForgeRelay
167
- does not silently merge results from multiple nested Language services. Bounded
168
- collection results report `returned`, `truncated`, and the real `total` when the
169
- complete Language-server response makes it known. Language Servers are external
168
+ does not silently merge results from multiple nested Language services. Diagnostics
169
+ use `path` plus optional `limit`, prefer LSP pull diagnostics when the selected server
170
+ advertises them, and otherwise consume the latest bounded `publishDiagnostics`
171
+ snapshot. Push and pull use one normalized result shape with `provider`,
172
+ `returned`/`truncated`/`total`, and freshness metadata tied to ForgeRelay's synchronized
173
+ filesystem document version. Bounded collection results report `returned`, `truncated`,
174
+ and the real `total` when the complete Language-server response makes it known. Language Servers are external
170
175
  dependencies: ForgeRelay may discover an executable already installed on the
171
176
  machine, but it never downloads or installs one automatically.
172
177
 
178
+ ForgeRelay 0.4.5 hardens this shared Language-service runtime. Semantic requests
179
+ have one internal bounded deadline, Host cancellation propagates to LSP cancellation,
180
+ and each service has finite concurrent and queued request budgets rather than
181
+ Agent-configurable timeouts. An unexpected server crash is retried at most once;
182
+ repeated crashes enter a short cooldown. Effective server-definition fingerprints
183
+ invalidate only the affected project/service on the next resolution without adding
184
+ a recursive filesystem watcher.
185
+
186
+ Language services are keyed by physical Language project identity, so logical
187
+ workspaces over the same checkout reuse one process. Truly idle services are
188
+ reclaimed after a bounded TTL and the global service cap evicts the least-recently-
189
+ used safe idle service. A server request that ignores cancellation still counts as
190
+ active until the underlying JSON-RPC request settles. Managed-worktree finalization
191
+ releases services rooted in that worktree before removal and refuses finalization
192
+ while semantic work is active. Debug `runtime_resources` telemetry includes only
193
+ aggregate Language-service/process/request/document/diagnostic/stderr counts; it
194
+ does not log source contents or source paths.
195
+
173
196
  Effective Language-server definitions resolve in this order:
174
197
 
175
198
  1. project configuration in `.forgerelay/language-servers.json`;