@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.
- package/CHANGELOG.md +25 -0
- package/capabilities/code-intelligence/GUIDE.md +9 -1
- package/dist/capability-registry.js +7 -2
- package/dist/logger.js +11 -1
- package/dist/lsp/code-intelligence.js +125 -93
- package/dist/lsp/normalization/diagnostics.js +29 -0
- package/dist/lsp/runtime/diagnostic-snapshots.js +113 -0
- package/dist/lsp/runtime/document-synchronizer.js +102 -0
- package/dist/lsp/runtime/manager.js +207 -20
- package/dist/lsp/runtime/semantic-requests.js +116 -0
- package/dist/lsp/test-support/server-fixture.js +1 -1
- package/dist/server.js +27 -6
- package/docs/configuration.md +28 -5
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +2 -2
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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:
|
|
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(() => {
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|
|
158
|
-
`definition`, `hover`, `references`, `documentSymbols`,
|
|
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.
|
|
168
|
-
|
|
169
|
-
|
|
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`;
|