opencode-agent-skill 15.0.0 → 15.1.1
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/README.md +5 -5
- package/docs/V15-MANAGED-RUNTIME.md +18 -0
- package/global-config/agents/verifier.md +6 -2
- package/lib/affected-tests.mjs +58 -8
- package/lib/capability-fabric.mjs +107 -48
- package/lib/code-intelligence/index.mjs +4 -15
- package/lib/code-intelligence/lsp-pool.mjs +694 -0
- package/lib/code-intelligence/lsp-provider.mjs +481 -67
- package/lib/compaction-resume-guard.mjs +327 -0
- package/lib/completion-auditor.mjs +20 -0
- package/lib/context-engine-v11.mjs +20 -7
- package/lib/context-manifest.mjs +38 -21
- package/lib/decision-policy.mjs +40 -2
- package/lib/evidence-store.mjs +121 -31
- package/lib/executable-probe.mjs +69 -0
- package/lib/execution-contract.mjs +29 -16
- package/lib/hierarchical-context.mjs +31 -3
- package/lib/learning-engine.mjs +164 -65
- package/lib/memory-engine.mjs +348 -98
- package/lib/model-config.mjs +86 -7
- package/lib/performance-fabric.mjs +354 -107
- package/lib/permission-policy.mjs +232 -0
- package/lib/pi-rpc-pool.mjs +170 -5
- package/lib/provider-recovery.mjs +51 -0
- package/lib/repo-graph.mjs +38 -5
- package/lib/runtime-events.mjs +31 -10
- package/lib/semantic-index.mjs +67 -20
- package/lib/skill-compiler.mjs +84 -18
- package/lib/subagent-artifacts.mjs +182 -0
- package/lib/task-policy.mjs +80 -7
- package/lib/trajectory.mjs +25 -1
- package/lib/untrusted-output.mjs +106 -0
- package/lib/verification-broker.mjs +32 -27
- package/lib/workspace-fingerprint.mjs +43 -8
- package/lib/workspace-hygiene.mjs +481 -0
- package/package.json +8 -6
- package/pi/extensions/ues-child-runtime.ts +103 -5
- package/pi/extensions/ues.ts +984 -95
- package/scripts/benchmark-lsp-pool.mjs +128 -0
- package/scripts/check-release-consistency.mjs +16 -4
- package/scripts/check-source-integrity.mjs +60 -1
- package/scripts/run-test-suite.mjs +143 -0
- package/scripts/smoke-package-closure.mjs +4 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
2
|
+
import { spawn } from "node:child_process"
|
|
3
|
+
import { readFile, stat } from "node:fs/promises"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { pathToFileURL } from "node:url"
|
|
6
|
+
import { terminateProcessTree } from "../process-supervisor.mjs"
|
|
7
|
+
import { resolveWindowsCommand } from "../windows-shim.mjs"
|
|
8
|
+
|
|
9
|
+
const DEFAULT_IDLE_TTL_MS = 3 * 60_000
|
|
10
|
+
const DEFAULT_MAX_SERVERS = 4
|
|
11
|
+
const DEFAULT_MAX_PER_WORKSPACE = 2
|
|
12
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000
|
|
13
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 12_000
|
|
14
|
+
const DEFAULT_MAX_RESTARTS = 1
|
|
15
|
+
const CONFIG_MAX_BYTES = 512 * 1024
|
|
16
|
+
|
|
17
|
+
const POOL = new Map()
|
|
18
|
+
const METRICS = {
|
|
19
|
+
coldStarts: 0,
|
|
20
|
+
warmHits: 0,
|
|
21
|
+
restarts: 0,
|
|
22
|
+
evictions: 0,
|
|
23
|
+
fallbacks: 0,
|
|
24
|
+
operations: 0,
|
|
25
|
+
operationLatencyMs: 0,
|
|
26
|
+
}
|
|
27
|
+
let sweepTimer = null
|
|
28
|
+
|
|
29
|
+
function bounded(value, fallback, min, max) {
|
|
30
|
+
const number = Number(value)
|
|
31
|
+
if (!Number.isFinite(number)) return fallback
|
|
32
|
+
return Math.max(min, Math.min(max, Math.trunc(number)))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function configured(options = {}) {
|
|
36
|
+
return {
|
|
37
|
+
enabled: options.persistent !== false && String(process.env.UES_LSP_PERSISTENT || "1") !== "0",
|
|
38
|
+
idleTtlMs: bounded(options.idleTtlMs ?? process.env.UES_LSP_IDLE_TTL_MS, DEFAULT_IDLE_TTL_MS, 5_000, 60 * 60_000),
|
|
39
|
+
maxServers: bounded(options.maxServers ?? process.env.UES_LSP_MAX_SERVERS, DEFAULT_MAX_SERVERS, 1, 12),
|
|
40
|
+
maxPerWorkspace: bounded(options.maxPerWorkspace ?? process.env.UES_LSP_MAX_PER_WORKSPACE, DEFAULT_MAX_PER_WORKSPACE, 1, 6),
|
|
41
|
+
requestTimeoutMs: bounded(options.timeoutMs ?? process.env.UES_LSP_REQUEST_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS, 500, 30_000),
|
|
42
|
+
startupTimeoutMs: bounded(options.startupTimeoutMs ?? process.env.UES_LSP_STARTUP_TIMEOUT_MS, DEFAULT_STARTUP_TIMEOUT_MS, 1_000, 60_000),
|
|
43
|
+
maxRestarts: bounded(options.maxRestarts ?? process.env.UES_LSP_MAX_RESTARTS, DEFAULT_MAX_RESTARTS, 0, 2),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function errorWithCode(message, code) {
|
|
48
|
+
const error = new Error(message)
|
|
49
|
+
error.code = code
|
|
50
|
+
return error
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function transientError(error) {
|
|
54
|
+
return new Set([
|
|
55
|
+
"LSP_REQUEST_TIMEOUT",
|
|
56
|
+
"LSP_NOTIFICATION_TIMEOUT",
|
|
57
|
+
"LSP_SESSION_CLOSED",
|
|
58
|
+
"LSP_SERVER_EXIT",
|
|
59
|
+
"LSP_SPAWN_ERROR",
|
|
60
|
+
"LSP_WRITE_ERROR",
|
|
61
|
+
]).has(error?.code)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function encodeMessage(payload) {
|
|
65
|
+
const body = Buffer.from(JSON.stringify(payload), "utf8")
|
|
66
|
+
return Buffer.concat([Buffer.from("Content-Length: " + body.length + "\r\n\r\n", "ascii"), body])
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function contentHash(value) {
|
|
70
|
+
return createHash("sha256").update(String(value || "")).digest("hex")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function providerConfigNames(providerId) {
|
|
74
|
+
const id = String(providerId || "")
|
|
75
|
+
if (id.includes("typescript")) return ["tsconfig.json", "jsconfig.json", "package.json"]
|
|
76
|
+
if (id.includes("pyright") || id.includes("pylsp")) return ["pyrightconfig.json", "pyproject.toml", "setup.cfg", "setup.py", "requirements.txt"]
|
|
77
|
+
if (id === "gopls") return ["go.mod", "go.sum", "go.work"]
|
|
78
|
+
if (id === "rust-analyzer") return ["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "rust-toolchain"]
|
|
79
|
+
if (id === "clangd") return [".clangd", "compile_commands.json", "CMakeLists.txt"]
|
|
80
|
+
return ["package.json", "pyproject.toml", "go.mod", "Cargo.toml"]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function inside(base, target) {
|
|
84
|
+
return target === base || target.startsWith(base + path.sep)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function configurationFingerprint(base, file, provider) {
|
|
88
|
+
const names = providerConfigNames(provider?.id)
|
|
89
|
+
const found = []
|
|
90
|
+
let dir = path.dirname(file)
|
|
91
|
+
|
|
92
|
+
while (inside(base, dir)) {
|
|
93
|
+
for (const name of names) {
|
|
94
|
+
const candidate = path.join(dir, name)
|
|
95
|
+
const info = await stat(candidate).catch(() => null)
|
|
96
|
+
if (!info?.isFile()) continue
|
|
97
|
+
found.push({ file: candidate, size: info.size, mtimeMs: Math.trunc(info.mtimeMs) })
|
|
98
|
+
}
|
|
99
|
+
if (dir === base) break
|
|
100
|
+
const parent = path.dirname(dir)
|
|
101
|
+
if (parent === dir || !inside(base, parent)) break
|
|
102
|
+
dir = parent
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
found.sort((a, b) => a.file.localeCompare(b.file))
|
|
106
|
+
const hash = createHash("sha256")
|
|
107
|
+
hash.update(String(provider?.id || "unknown"))
|
|
108
|
+
hash.update("\0")
|
|
109
|
+
hash.update(String(provider?.command || ""))
|
|
110
|
+
hash.update("\0")
|
|
111
|
+
hash.update(JSON.stringify(provider?.initializationOptions || null))
|
|
112
|
+
for (const item of found) {
|
|
113
|
+
hash.update(path.relative(base, item.file).replaceAll("\\", "/"))
|
|
114
|
+
hash.update("\0")
|
|
115
|
+
hash.update(String(item.size))
|
|
116
|
+
hash.update("\0")
|
|
117
|
+
if (item.size <= CONFIG_MAX_BYTES) {
|
|
118
|
+
const content = await readFile(item.file).catch(() => null)
|
|
119
|
+
if (content) hash.update(content)
|
|
120
|
+
else hash.update(String(item.mtimeMs))
|
|
121
|
+
} else {
|
|
122
|
+
hash.update(String(item.mtimeMs))
|
|
123
|
+
}
|
|
124
|
+
hash.update("\0")
|
|
125
|
+
}
|
|
126
|
+
return hash.digest("hex").slice(0, 24)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
class ManagedLspSession {
|
|
130
|
+
constructor(base, provider, configFingerprint, options) {
|
|
131
|
+
this.id = randomUUID()
|
|
132
|
+
this.base = base
|
|
133
|
+
this.provider = provider
|
|
134
|
+
this.configFingerprint = configFingerprint
|
|
135
|
+
this.options = options
|
|
136
|
+
this.child = null
|
|
137
|
+
this.buffer = Buffer.alloc(0)
|
|
138
|
+
this.stderr = ""
|
|
139
|
+
this.nextId = 1
|
|
140
|
+
this.pending = new Map()
|
|
141
|
+
this.notificationWaiters = new Map()
|
|
142
|
+
this.notifications = new Map()
|
|
143
|
+
this.documents = new Map()
|
|
144
|
+
this.state = "STARTING"
|
|
145
|
+
this.startedAt = Date.now()
|
|
146
|
+
this.readyAt = null
|
|
147
|
+
this.lastUsedAt = Date.now()
|
|
148
|
+
this.requestCount = 0
|
|
149
|
+
this.busy = 0
|
|
150
|
+
this.closed = false
|
|
151
|
+
this.queueTail = Promise.resolve()
|
|
152
|
+
this.coldStartMs = null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
alive() {
|
|
156
|
+
return Boolean(
|
|
157
|
+
!this.closed &&
|
|
158
|
+
this.child &&
|
|
159
|
+
this.child.exitCode == null &&
|
|
160
|
+
!["CRASHED", "CLOSED", "EVICTING"].includes(this.state),
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
publicStatus() {
|
|
165
|
+
return {
|
|
166
|
+
id: this.id,
|
|
167
|
+
workspace: this.base,
|
|
168
|
+
provider: this.provider.id,
|
|
169
|
+
command: this.provider.command,
|
|
170
|
+
state: this.state,
|
|
171
|
+
pid: this.child?.pid || null,
|
|
172
|
+
busy: this.busy,
|
|
173
|
+
requestCount: this.requestCount,
|
|
174
|
+
startedAt: new Date(this.startedAt).toISOString(),
|
|
175
|
+
lastUsedAt: new Date(this.lastUsedAt).toISOString(),
|
|
176
|
+
idleMs: Math.max(0, Date.now() - this.lastUsedAt),
|
|
177
|
+
configFingerprint: this.configFingerprint,
|
|
178
|
+
documents: this.documents.size,
|
|
179
|
+
coldStartMs: this.coldStartMs,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async start() {
|
|
184
|
+
const started = Date.now()
|
|
185
|
+
try {
|
|
186
|
+
const execution = process.platform === "win32"
|
|
187
|
+
? resolveWindowsCommand(this.provider.command)
|
|
188
|
+
: { executable: this.provider.command, argsPrefix: [] }
|
|
189
|
+
if (!execution?.executable) {
|
|
190
|
+
throw new Error("unable to resolve LSP executable: " + String(this.provider.command || ""))
|
|
191
|
+
}
|
|
192
|
+
this.child = spawn(execution.executable, [...(execution.argsPrefix || []), ...(this.provider.args || [])], {
|
|
193
|
+
cwd: this.base,
|
|
194
|
+
env: this.provider.env && typeof this.provider.env === "object"
|
|
195
|
+
? { ...process.env, ...Object.fromEntries(Object.entries(this.provider.env).map(([key, value]) => [String(key), String(value)])) }
|
|
196
|
+
: process.env,
|
|
197
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
198
|
+
windowsHide: true,
|
|
199
|
+
shell: false,
|
|
200
|
+
detached: process.platform !== "win32",
|
|
201
|
+
})
|
|
202
|
+
} catch (error) {
|
|
203
|
+
this.state = "CRASHED"
|
|
204
|
+
throw errorWithCode("LSP spawn failed: " + String(error?.message || error), "LSP_SPAWN_ERROR")
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
this.child.stdout?.on("data", (chunk) => {
|
|
208
|
+
this.buffer = Buffer.concat([this.buffer, Buffer.from(chunk)])
|
|
209
|
+
this.parse()
|
|
210
|
+
})
|
|
211
|
+
this.child.stderr?.on("data", (chunk) => {
|
|
212
|
+
if (this.stderr.length < 64 * 1024) this.stderr += chunk.toString()
|
|
213
|
+
})
|
|
214
|
+
this.child.on("error", (error) => this.crash("LSP spawn error: " + String(error?.message || error), "LSP_SPAWN_ERROR"))
|
|
215
|
+
this.child.on("close", (code) => {
|
|
216
|
+
if (!this.closed && this.state !== "EVICTING") {
|
|
217
|
+
this.crash("LSP server exited: " + String(code ?? "unknown"), "LSP_SERVER_EXIT")
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const rootUri = pathToFileURL(this.base + path.sep).href
|
|
222
|
+
await this.request("initialize", {
|
|
223
|
+
processId: process.pid,
|
|
224
|
+
rootUri,
|
|
225
|
+
capabilities: {
|
|
226
|
+
textDocument: {
|
|
227
|
+
publishDiagnostics: { relatedInformation: true },
|
|
228
|
+
definition: { linkSupport: true },
|
|
229
|
+
references: {},
|
|
230
|
+
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
|
|
231
|
+
hover: { contentFormat: ["markdown", "plaintext"] },
|
|
232
|
+
rename: { prepareSupport: true },
|
|
233
|
+
callHierarchy: {},
|
|
234
|
+
},
|
|
235
|
+
workspace: { workspaceFolders: true, applyEdit: false },
|
|
236
|
+
},
|
|
237
|
+
workspaceFolders: [{ uri: rootUri, name: path.basename(this.base) || "workspace" }],
|
|
238
|
+
initializationOptions: this.provider.initializationOptions || undefined,
|
|
239
|
+
}, this.options.startupTimeoutMs)
|
|
240
|
+
this.notify("initialized", {})
|
|
241
|
+
this.readyAt = Date.now()
|
|
242
|
+
this.coldStartMs = this.readyAt - started
|
|
243
|
+
this.state = "READY"
|
|
244
|
+
this.lastUsedAt = Date.now()
|
|
245
|
+
return this
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
crash(message, code) {
|
|
249
|
+
if (this.closed) return
|
|
250
|
+
this.state = "CRASHED"
|
|
251
|
+
const error = errorWithCode(message, code)
|
|
252
|
+
for (const waiter of this.pending.values()) {
|
|
253
|
+
clearTimeout(waiter.timer)
|
|
254
|
+
waiter.reject(error)
|
|
255
|
+
}
|
|
256
|
+
this.pending.clear()
|
|
257
|
+
for (const list of this.notificationWaiters.values()) {
|
|
258
|
+
for (const waiter of list) {
|
|
259
|
+
clearTimeout(waiter.timer)
|
|
260
|
+
waiter.reject(error)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
this.notificationWaiters.clear()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
send(payload) {
|
|
267
|
+
if (!this.alive() || !this.child.stdin?.writable) {
|
|
268
|
+
throw errorWithCode("LSP stdin unavailable", "LSP_SESSION_CLOSED")
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
this.child.stdin.write(encodeMessage(payload))
|
|
272
|
+
} catch (error) {
|
|
273
|
+
throw errorWithCode("LSP write failed: " + String(error?.message || error), "LSP_WRITE_ERROR")
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
request(method, params, timeoutMs = this.options.requestTimeoutMs) {
|
|
278
|
+
return new Promise((resolve, reject) => {
|
|
279
|
+
const id = this.nextId++
|
|
280
|
+
const timer = setTimeout(() => {
|
|
281
|
+
this.pending.delete(id)
|
|
282
|
+
reject(errorWithCode("LSP request timed out: " + method, "LSP_REQUEST_TIMEOUT"))
|
|
283
|
+
}, bounded(timeoutMs, this.options.requestTimeoutMs, 250, 60_000))
|
|
284
|
+
timer.unref?.()
|
|
285
|
+
this.pending.set(id, { resolve, reject, method, timer })
|
|
286
|
+
try {
|
|
287
|
+
this.send({ jsonrpc: "2.0", id, method, params })
|
|
288
|
+
} catch (error) {
|
|
289
|
+
clearTimeout(timer)
|
|
290
|
+
this.pending.delete(id)
|
|
291
|
+
reject(error)
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
notify(method, params) {
|
|
297
|
+
this.send({ jsonrpc: "2.0", method, params })
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
waitForNotification(method, options = {}) {
|
|
301
|
+
const predicate = typeof options.predicate === "function" ? options.predicate : () => true
|
|
302
|
+
const afterAt = Number(options.afterAt || 0)
|
|
303
|
+
const cached = (this.notifications.get(method) || [])
|
|
304
|
+
.slice()
|
|
305
|
+
.reverse()
|
|
306
|
+
.find((item) => item.at >= afterAt && predicate(item.params))
|
|
307
|
+
if (cached) return Promise.resolve(cached.params)
|
|
308
|
+
|
|
309
|
+
return new Promise((resolve, reject) => {
|
|
310
|
+
const timeoutMs = bounded(options.timeoutMs, this.options.requestTimeoutMs, 250, 60_000)
|
|
311
|
+
const timer = setTimeout(() => {
|
|
312
|
+
const list = this.notificationWaiters.get(method) || []
|
|
313
|
+
this.notificationWaiters.set(method, list.filter((item) => item !== waiter))
|
|
314
|
+
reject(errorWithCode("LSP notification timed out: " + method, "LSP_NOTIFICATION_TIMEOUT"))
|
|
315
|
+
}, timeoutMs)
|
|
316
|
+
timer.unref?.()
|
|
317
|
+
const waiter = { resolve, reject, predicate, afterAt, timer }
|
|
318
|
+
const list = this.notificationWaiters.get(method) || []
|
|
319
|
+
list.push(waiter)
|
|
320
|
+
this.notificationWaiters.set(method, list)
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
dispatch(message) {
|
|
325
|
+
if (message?.id != null && this.pending.has(message.id)) {
|
|
326
|
+
const waiter = this.pending.get(message.id)
|
|
327
|
+
this.pending.delete(message.id)
|
|
328
|
+
clearTimeout(waiter.timer)
|
|
329
|
+
if (message.error) {
|
|
330
|
+
const error = errorWithCode(String(message.error.message || "LSP request failed"), "LSP_REQUEST_REJECTED")
|
|
331
|
+
waiter.reject(error)
|
|
332
|
+
} else {
|
|
333
|
+
waiter.resolve(message.result)
|
|
334
|
+
}
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!message?.method) return
|
|
339
|
+
if (message.id != null) {
|
|
340
|
+
try {
|
|
341
|
+
const result = message.method === "workspace/configuration"
|
|
342
|
+
? (Array.isArray(message.params?.items) ? message.params.items.map(() => null) : [])
|
|
343
|
+
: null
|
|
344
|
+
this.send({ jsonrpc: "2.0", id: message.id, result })
|
|
345
|
+
} catch {}
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const event = { params: message.params, at: Date.now() }
|
|
350
|
+
const cached = this.notifications.get(message.method) || []
|
|
351
|
+
cached.push(event)
|
|
352
|
+
if (cached.length > 24) cached.splice(0, cached.length - 24)
|
|
353
|
+
this.notifications.set(message.method, cached)
|
|
354
|
+
|
|
355
|
+
const waiters = this.notificationWaiters.get(message.method) || []
|
|
356
|
+
const remaining = []
|
|
357
|
+
for (const waiter of waiters) {
|
|
358
|
+
if (event.at >= waiter.afterAt && waiter.predicate(event.params)) {
|
|
359
|
+
clearTimeout(waiter.timer)
|
|
360
|
+
waiter.resolve(event.params)
|
|
361
|
+
} else {
|
|
362
|
+
remaining.push(waiter)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (remaining.length) this.notificationWaiters.set(message.method, remaining)
|
|
366
|
+
else this.notificationWaiters.delete(message.method)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
parse() {
|
|
370
|
+
while (!this.closed) {
|
|
371
|
+
const headerEnd = this.buffer.indexOf("\r\n\r\n")
|
|
372
|
+
if (headerEnd < 0) return
|
|
373
|
+
const header = this.buffer.subarray(0, headerEnd).toString("ascii")
|
|
374
|
+
const match = header.match(/Content-Length:\s*(\d+)/i)
|
|
375
|
+
if (!match) {
|
|
376
|
+
this.buffer = this.buffer.subarray(headerEnd + 4)
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
const length = Number(match[1])
|
|
380
|
+
const bodyStart = headerEnd + 4
|
|
381
|
+
if (this.buffer.length < bodyStart + length) return
|
|
382
|
+
const body = this.buffer.subarray(bodyStart, bodyStart + length).toString("utf8")
|
|
383
|
+
this.buffer = this.buffer.subarray(bodyStart + length)
|
|
384
|
+
try { this.dispatch(JSON.parse(body)) } catch {}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async syncDocument(target, languageId, options = {}) {
|
|
389
|
+
const maxBytes = Math.max(8 * 1024, Math.min(4 * 1024 * 1024, Number(options.maxBytes || 1024 * 1024)))
|
|
390
|
+
const info = target.info?.isFile?.() ? target.info : await stat(target.file)
|
|
391
|
+
if (info.size > maxBytes) {
|
|
392
|
+
throw new Error("LSP file exceeds limit (" + info.size + " > " + maxBytes + " bytes)")
|
|
393
|
+
}
|
|
394
|
+
const source = await readFile(target.file, "utf8")
|
|
395
|
+
const hash = contentHash(source)
|
|
396
|
+
const uri = pathToFileURL(target.file).href
|
|
397
|
+
const previous = this.documents.get(uri)
|
|
398
|
+
const syncedAt = Date.now()
|
|
399
|
+
let changed = false
|
|
400
|
+
let opened = false
|
|
401
|
+
let version = previous?.version || 0
|
|
402
|
+
|
|
403
|
+
if (!previous) {
|
|
404
|
+
opened = true
|
|
405
|
+
changed = true
|
|
406
|
+
version = 1
|
|
407
|
+
this.notify("textDocument/didOpen", {
|
|
408
|
+
textDocument: { uri, languageId, version, text: source },
|
|
409
|
+
})
|
|
410
|
+
} else if (previous.hash !== hash) {
|
|
411
|
+
changed = true
|
|
412
|
+
version += 1
|
|
413
|
+
this.notify("textDocument/didChange", {
|
|
414
|
+
textDocument: { uri, version },
|
|
415
|
+
contentChanges: [{ text: source }],
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
this.documents.set(uri, { hash, version, languageId, syncedAt })
|
|
420
|
+
return { uri, sourceHash: hash, version, changed, opened, syncedAt }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async runExclusive(fn) {
|
|
424
|
+
const previous = this.queueTail
|
|
425
|
+
let release
|
|
426
|
+
const gate = new Promise((resolve) => { release = resolve })
|
|
427
|
+
this.queueTail = previous.catch(() => {}).then(() => gate)
|
|
428
|
+
await previous.catch(() => {})
|
|
429
|
+
this.busy += 1
|
|
430
|
+
this.state = "BUSY"
|
|
431
|
+
const started = Date.now()
|
|
432
|
+
try {
|
|
433
|
+
const result = await fn()
|
|
434
|
+
this.requestCount += 1
|
|
435
|
+
return { result, durationMs: Date.now() - started }
|
|
436
|
+
} finally {
|
|
437
|
+
this.busy = Math.max(0, this.busy - 1)
|
|
438
|
+
this.lastUsedAt = Date.now()
|
|
439
|
+
if (this.alive()) this.state = "READY"
|
|
440
|
+
release()
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async stop(reason = "shutdown") {
|
|
445
|
+
if (this.closed) return
|
|
446
|
+
this.state = "EVICTING"
|
|
447
|
+
this.closed = true
|
|
448
|
+
const error = errorWithCode("LSP session closed: " + reason, "LSP_SESSION_CLOSED")
|
|
449
|
+
for (const waiter of this.pending.values()) {
|
|
450
|
+
clearTimeout(waiter.timer)
|
|
451
|
+
waiter.reject(error)
|
|
452
|
+
}
|
|
453
|
+
this.pending.clear()
|
|
454
|
+
for (const list of this.notificationWaiters.values()) {
|
|
455
|
+
for (const waiter of list) {
|
|
456
|
+
clearTimeout(waiter.timer)
|
|
457
|
+
waiter.reject(error)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
this.notificationWaiters.clear()
|
|
461
|
+
try {
|
|
462
|
+
if (this.child?.stdin?.writable) {
|
|
463
|
+
this.child.stdin.write(encodeMessage({ jsonrpc: "2.0", method: "exit", params: null }))
|
|
464
|
+
}
|
|
465
|
+
} catch {}
|
|
466
|
+
if (this.child) terminateProcessTree(this.child, { graceMs: 500 })
|
|
467
|
+
this.state = "CLOSED"
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function sessionKey(base, provider, fingerprint) {
|
|
472
|
+
return [base, provider.id, provider.command, fingerprint].join("\u0000")
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function sessionsForWorkspace(base) {
|
|
476
|
+
return [...POOL.values()].filter((session) => session.base === base && session.alive())
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function removeSession(key, reason) {
|
|
480
|
+
const session = POOL.get(key)
|
|
481
|
+
if (!session) return
|
|
482
|
+
POOL.delete(key)
|
|
483
|
+
await session.stop(reason).catch(() => {})
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function evictExpired(options) {
|
|
487
|
+
const now = Date.now()
|
|
488
|
+
const targets = [...POOL.entries()]
|
|
489
|
+
.filter(([, session]) => session.busy === 0 && now - session.lastUsedAt >= options.idleTtlMs)
|
|
490
|
+
for (const [key] of targets) {
|
|
491
|
+
METRICS.evictions += 1
|
|
492
|
+
await removeSession(key, "idle-ttl")
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function evictLru(candidates, reason) {
|
|
497
|
+
const idle = candidates
|
|
498
|
+
.filter((session) => session.busy === 0)
|
|
499
|
+
.sort((a, b) => a.lastUsedAt - b.lastUsedAt)
|
|
500
|
+
const victim = idle[0]
|
|
501
|
+
if (!victim) return false
|
|
502
|
+
const entry = [...POOL.entries()].find(([, session]) => session === victim)
|
|
503
|
+
if (!entry) return false
|
|
504
|
+
METRICS.evictions += 1
|
|
505
|
+
await removeSession(entry[0], reason)
|
|
506
|
+
return true
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function ensureCapacity(base, options) {
|
|
510
|
+
await evictExpired(options)
|
|
511
|
+
|
|
512
|
+
while (sessionsForWorkspace(base).length >= options.maxPerWorkspace) {
|
|
513
|
+
const evicted = await evictLru(sessionsForWorkspace(base), "workspace-lru")
|
|
514
|
+
if (!evicted) return false
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
while ([...POOL.values()].filter((session) => session.alive()).length >= options.maxServers) {
|
|
518
|
+
const evicted = await evictLru([...POOL.values()], "global-lru")
|
|
519
|
+
if (!evicted) return false
|
|
520
|
+
}
|
|
521
|
+
return true
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function ensureSweeper() {
|
|
525
|
+
if (sweepTimer) return
|
|
526
|
+
sweepTimer = setInterval(() => {
|
|
527
|
+
const options = configured()
|
|
528
|
+
void evictExpired(options).catch(() => {})
|
|
529
|
+
}, 30_000)
|
|
530
|
+
sweepTimer.unref?.()
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function acquire(target, provider, options) {
|
|
534
|
+
ensureSweeper()
|
|
535
|
+
const fingerprint = await configurationFingerprint(target.base, target.file, provider)
|
|
536
|
+
const key = sessionKey(target.base, provider, fingerprint)
|
|
537
|
+
const existing = POOL.get(key)
|
|
538
|
+
if (existing?.alive()) {
|
|
539
|
+
METRICS.warmHits += 1
|
|
540
|
+
existing.lastUsedAt = Date.now()
|
|
541
|
+
return { key, session: existing, poolHit: true }
|
|
542
|
+
}
|
|
543
|
+
if (existing) await removeSession(key, "stale-session")
|
|
544
|
+
|
|
545
|
+
for (const [otherKey, session] of POOL) {
|
|
546
|
+
if (
|
|
547
|
+
session.base === target.base &&
|
|
548
|
+
session.provider.id === provider.id &&
|
|
549
|
+
session.configFingerprint !== fingerprint &&
|
|
550
|
+
session.busy === 0
|
|
551
|
+
) {
|
|
552
|
+
METRICS.evictions += 1
|
|
553
|
+
await removeSession(otherKey, "config-changed")
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (!await ensureCapacity(target.base, options)) return null
|
|
558
|
+
const session = new ManagedLspSession(target.base, provider, fingerprint, options)
|
|
559
|
+
POOL.set(key, session)
|
|
560
|
+
try {
|
|
561
|
+
await session.start()
|
|
562
|
+
METRICS.coldStarts += 1
|
|
563
|
+
return { key, session, poolHit: false }
|
|
564
|
+
} catch (error) {
|
|
565
|
+
POOL.delete(key)
|
|
566
|
+
await session.stop("startup-failed").catch(() => {})
|
|
567
|
+
throw error
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export async function withManagedLspSession(target, provider, inputOptions = {}, operation) {
|
|
572
|
+
const options = configured(inputOptions)
|
|
573
|
+
if (!options.enabled) {
|
|
574
|
+
METRICS.fallbacks += 1
|
|
575
|
+
return { ok: false, reason: "persistent-disabled", error: null }
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
let attempt = 0
|
|
579
|
+
while (attempt <= options.maxRestarts) {
|
|
580
|
+
let acquired
|
|
581
|
+
try {
|
|
582
|
+
acquired = await acquire(target, provider, options)
|
|
583
|
+
if (!acquired) {
|
|
584
|
+
METRICS.fallbacks += 1
|
|
585
|
+
return { ok: false, reason: "pool-capacity-busy", error: null }
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const session = acquired.session
|
|
589
|
+
const executed = await session.runExclusive(async () => {
|
|
590
|
+
const documentSync = await session.syncDocument(target, provider.languageId, inputOptions)
|
|
591
|
+
const adapter = {
|
|
592
|
+
request: (method, params) => session.request(method, params, options.requestTimeoutMs),
|
|
593
|
+
notify: (method, params) => session.notify(method, params),
|
|
594
|
+
waitForNotification: (method, waitOptions = {}) => session.waitForNotification(method, {
|
|
595
|
+
timeoutMs: options.requestTimeoutMs,
|
|
596
|
+
...waitOptions,
|
|
597
|
+
}),
|
|
598
|
+
uri: documentSync.uri,
|
|
599
|
+
base: target.base,
|
|
600
|
+
file: path.relative(target.base, target.file).replaceAll("\\", "/"),
|
|
601
|
+
documentSync,
|
|
602
|
+
}
|
|
603
|
+
return operation(adapter)
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
METRICS.operations += 1
|
|
607
|
+
METRICS.operationLatencyMs += executed.durationMs
|
|
608
|
+
return {
|
|
609
|
+
ok: true,
|
|
610
|
+
result: executed.result,
|
|
611
|
+
meta: {
|
|
612
|
+
persistent: true,
|
|
613
|
+
poolHit: acquired.poolHit,
|
|
614
|
+
warm: acquired.poolHit,
|
|
615
|
+
sessionId: session.id,
|
|
616
|
+
state: session.state,
|
|
617
|
+
coldStartMs: acquired.poolHit ? 0 : session.coldStartMs,
|
|
618
|
+
operationDurationMs: executed.durationMs,
|
|
619
|
+
requestCount: session.requestCount,
|
|
620
|
+
configFingerprint: session.configFingerprint,
|
|
621
|
+
},
|
|
622
|
+
stderr: session.stderr.trim().slice(0, 2000) || null,
|
|
623
|
+
}
|
|
624
|
+
} catch (error) {
|
|
625
|
+
const transient = transientError(error)
|
|
626
|
+
const canRestart = transient && attempt < options.maxRestarts
|
|
627
|
+
if (transient && acquired?.key) {
|
|
628
|
+
await removeSession(acquired.key, canRestart ? "bounded-restart" : "operation-failed")
|
|
629
|
+
}
|
|
630
|
+
if (!canRestart) {
|
|
631
|
+
METRICS.fallbacks += 1
|
|
632
|
+
return {
|
|
633
|
+
ok: false,
|
|
634
|
+
reason: transient ? "managed-lsp-failed" : "managed-lsp-request-rejected",
|
|
635
|
+
error: String(error instanceof Error ? error.message : error).slice(0, 1200),
|
|
636
|
+
errorCode: error?.code || null,
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
METRICS.restarts += 1
|
|
640
|
+
attempt += 1
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
METRICS.fallbacks += 1
|
|
645
|
+
return { ok: false, reason: "restart-budget-exhausted", error: null }
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function lspPoolStatus(options = {}) {
|
|
649
|
+
const config = configured(options)
|
|
650
|
+
const sessions = [...POOL.values()].filter((session) => session.alive())
|
|
651
|
+
return {
|
|
652
|
+
schemaVersion: 2,
|
|
653
|
+
kind: "ues-managed-lsp-pool",
|
|
654
|
+
enabled: config.enabled,
|
|
655
|
+
limits: {
|
|
656
|
+
maxServers: config.maxServers,
|
|
657
|
+
maxPerWorkspace: config.maxPerWorkspace,
|
|
658
|
+
idleTtlMs: config.idleTtlMs,
|
|
659
|
+
requestTimeoutMs: config.requestTimeoutMs,
|
|
660
|
+
startupTimeoutMs: config.startupTimeoutMs,
|
|
661
|
+
maxRestarts: config.maxRestarts,
|
|
662
|
+
},
|
|
663
|
+
active: sessions.length,
|
|
664
|
+
busy: sessions.filter((session) => session.busy > 0).length,
|
|
665
|
+
metrics: {
|
|
666
|
+
...METRICS,
|
|
667
|
+
averageOperationLatencyMs: METRICS.operations
|
|
668
|
+
? Number((METRICS.operationLatencyMs / METRICS.operations).toFixed(2))
|
|
669
|
+
: null,
|
|
670
|
+
},
|
|
671
|
+
sessions: options.includeSessions === false ? [] : sessions.map((session) => session.publicStatus()),
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export async function shutdownLspPool(root = null) {
|
|
676
|
+
const base = root ? path.resolve(root) : null
|
|
677
|
+
const entries = [...POOL.entries()].filter(([, session]) => !base || session.base === base)
|
|
678
|
+
for (const [key] of entries) await removeSession(key, "shutdown")
|
|
679
|
+
if (!POOL.size && sweepTimer) {
|
|
680
|
+
clearInterval(sweepTimer)
|
|
681
|
+
sweepTimer = null
|
|
682
|
+
}
|
|
683
|
+
return { schemaVersion: 2, stopped: entries.length, remaining: POOL.size }
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export async function evictIdleLspSessions(options = {}) {
|
|
687
|
+
const before = POOL.size
|
|
688
|
+
await evictExpired(configured(options))
|
|
689
|
+
return { schemaVersion: 2, evicted: Math.max(0, before - POOL.size), remaining: POOL.size }
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export function resetLspPoolMetrics() {
|
|
693
|
+
for (const key of Object.keys(METRICS)) METRICS[key] = 0
|
|
694
|
+
}
|