opencode-agent-skill 15.1.1 → 15.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/lib/affected-tests.mjs +7 -3
- package/lib/code-intelligence/index.mjs +47 -9
- package/lib/code-intelligence/lsp-pool.mjs +191 -54
- package/lib/code-intelligence/lsp-provider.mjs +44 -7
- package/lib/context-manifest.mjs +55 -20
- package/lib/execution-contract.mjs +2 -2
- package/lib/memory-engine.mjs +14 -9
- package/lib/pi-rpc-pool.mjs +55 -24
- package/lib/repo-graph.mjs +2 -2
- package/lib/repo-inspect.mjs +3 -2
- package/lib/semantic-index.mjs +2 -1
- package/lib/task-engine.mjs +28 -9
- package/lib/workspace-fingerprint.mjs +17 -9
- package/lib/workspace-hygiene.mjs +2 -2
- package/lib/worktree-sandbox.mjs +14 -8
- package/package.json +1 -1
- package/pi/extensions/ues-child-runtime.ts +38 -2
- package/pi/extensions/ues.ts +95 -10
- package/scripts/check-source-integrity.mjs +60 -7
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
**Package:** <code>opencode-agent-skill</code>
|
|
6
6
|
**Host chính:** Pi Agent
|
|
7
|
-
**Phiên bản package hiện tại:** <code>15.1.
|
|
7
|
+
**Phiên bản package hiện tại:** <code>15.1.2</code>
|
|
8
8
|
**Nhánh phát triển hiện tại:** V15.19 Finalization Hardening
|
|
9
|
-
**Stable npm hiện tại:** <code>15.1.
|
|
9
|
+
**Stable npm hiện tại:** <code>15.1.1</code>
|
|
10
10
|
**Runtime:** Node.js 22.19+
|
|
11
11
|
**License:** MIT
|
|
12
12
|
|
|
@@ -1431,15 +1431,15 @@ npm pack
|
|
|
1431
1431
|
Package version hiện tại trên nhánh <code>main</code> là:
|
|
1432
1432
|
|
|
1433
1433
|
~~~text
|
|
1434
|
-
15.1.
|
|
1434
|
+
15.1.2
|
|
1435
1435
|
~~~
|
|
1436
1436
|
|
|
1437
|
-
Stable npm public hiện
|
|
1437
|
+
Stable npm public hiện là <code>15.1.1</code>. V15.3 DEEP Speed giảm exploration trùng lặp ở long-horizon bằng diagnosis deduplication và bounded specialist exploration, nhưng giữ nguyên plan/verifier/integration gates. V15.4 ACP-safe child runtime ngăn UES spawn lại ACP/Zed entrypoint; child specialist luôn được chạy bằng Pi CLI thật. V15.5 Per-Leaf Turbo cho phép leaf task nhỏ trong DEEP plan tự xuống FAST, giữ failure delta task-local, và tái sử dụng context theo root namespace + workspace fingerprint để giảm retry/rebuild latency. V15.6 Fast Planning đặt soft-steer/hard-idle budget riêng cho architect/plan-checker, recovery từ warm context thay vì scan lại, và lease/cleanup sandbox orphan an toàn sau crash/Stop. V15.7 Lightweight Sandbox Cleanup thu hồi worktree theo trace ngay khi /ues-run kết thúc, dọn metadata mồ côi, giảm legacy grace xuống 30 phút và cung cấp /ues-clean để dọn stale artifacts an toàn mà không đụng sandbox đang active. V15.8 Plan Gate Recovery bổ sung soft-steer + bounded recovery cho plan-checker và quét cả detached physical sandbox folders mà Git worktree registry đã quên. V15.9 Runtime Artifact Isolation loại .ues-traces/.ues-cache/.ues-services/.ues-work và runtime state khác khỏi task delta, write-scope conflict và sandbox integration, nhưng vẫn giữ safety gate cho source/config thật như apps/mobile/package.json. V15.10 Adaptive Stability Runtime thay hard-timeout tuyệt đối bằng activity-aware bounded deadlines, giữ absolute cap chống treo, salvage plan JSON đã validate từ partial output, phát graph trước prose, và role-bound context cho read-only planner trong task high-risk mà không giảm evidence budget của executor/verifier. V15.1 bắt đầu bằng deterministic <code>/ues-run</code> admission và Managed Background Services để model yếu không thể bỏ qua controller và không bị treo bởi dev server foreground.
|
|
1438
1438
|
|
|
1439
1439
|
Test file packed với Pi:
|
|
1440
1440
|
|
|
1441
1441
|
~~~cmd
|
|
1442
|
-
pi install .\opencode-agent-skill-15.1.
|
|
1442
|
+
pi install .\opencode-agent-skill-15.1.2.tgz
|
|
1443
1443
|
pi list
|
|
1444
1444
|
~~~
|
|
1445
1445
|
|
package/lib/affected-tests.mjs
CHANGED
|
@@ -3,10 +3,14 @@ import { readFile, readdir, stat } from "node:fs/promises"
|
|
|
3
3
|
import path from "node:path"
|
|
4
4
|
import { spawnSync } from "node:child_process"
|
|
5
5
|
import { runtimeWorkspaceFingerprint } from "./workspace-fingerprint.mjs"
|
|
6
|
+
import { UES_RUNTIME_DIRS, isUesRuntimeArtifactPath, sourceGitPathspecs } from "./runtime-artifacts.mjs"
|
|
6
7
|
|
|
7
8
|
const TEST_RE = /(?:^|[/.\\])(?:__tests__|tests?|spec)(?:[/.\\]|$)|\.(?:test|spec|e2e-spec)\.[cm]?[jt]sx?$/i
|
|
8
9
|
const SOURCE_EXT = new Set([".js",".jsx",".mjs",".cjs",".ts",".tsx",".py",".java",".kt",".kts",".cs",".go",".rs",".dart",".swift"])
|
|
9
|
-
const SKIP = new Set([
|
|
10
|
+
const SKIP = new Set([
|
|
11
|
+
".git","node_modules",".next","dist","build","coverage",".venv","venv","target","Pods","DerivedData",".gradle",
|
|
12
|
+
...UES_RUNTIME_DIRS,
|
|
13
|
+
])
|
|
10
14
|
const AFFECTED_TEST_CACHE = new Map()
|
|
11
15
|
const AFFECTED_TEST_INFLIGHT = new Map()
|
|
12
16
|
const TEST_CONTENT_CACHE = new Map()
|
|
@@ -65,7 +69,7 @@ async function walk(root, options = {}) {
|
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
async function projectFiles(root, options = {}) {
|
|
68
|
-
const indexed = git(root, ["ls-files", "-co", "--exclude-standard", "-z"])
|
|
72
|
+
const indexed = git(root, ["ls-files", "-co", "--exclude-standard", "-z", "--", ...sourceGitPathspecs()])
|
|
69
73
|
if (indexed.status === 0) {
|
|
70
74
|
const files = String(indexed.stdout || "")
|
|
71
75
|
.split("\0")
|
|
@@ -110,7 +114,7 @@ export function gitChangedFiles(root = process.cwd()) {
|
|
|
110
114
|
if (result.status !== 0) continue
|
|
111
115
|
for (const row of String(result.stdout || "").split(/\r?\n/)) {
|
|
112
116
|
const file = normalize(row.trim())
|
|
113
|
-
if (file) names.add(file)
|
|
117
|
+
if (file && !isUesRuntimeArtifactPath(file)) names.add(file)
|
|
114
118
|
}
|
|
115
119
|
}
|
|
116
120
|
return [...names]
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { lstat, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"
|
|
2
|
-
import { spawnSync } from "node:child_process"
|
|
3
2
|
import path from "node:path"
|
|
4
3
|
import { commandExists } from "../executable-probe.mjs"
|
|
5
|
-
import {
|
|
4
|
+
import { runSupervisedProcess } from "../process-supervisor.mjs"
|
|
5
|
+
import { resolveWindowsCommand } from "../windows-shim.mjs"
|
|
6
|
+
import { buildSemanticIndexCached, querySemanticIndex } from "../semantic-index.mjs"
|
|
6
7
|
import { anchoredLines, applyAnchoredEdits } from "./edit-anchor.mjs"
|
|
8
|
+
import { runtimeWorkspaceSnapshot } from "../workspace-fingerprint.mjs"
|
|
7
9
|
import { diagnoseCode, lspOperation, lspPoolStatus, lspProviderStatus, shutdownLspPool, LSP_OPERATIONS } from "./lsp-provider.mjs"
|
|
8
10
|
|
|
9
11
|
const AST_COMMANDS = ["ast-grep", "sg"]
|
|
@@ -54,29 +56,65 @@ export async function applyAnchoredFileEdits(root, relative, edits, options = {}
|
|
|
54
56
|
return { ...result, file: relativeFile, dryRun: false }
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
function runAstGrep(root, pattern, language, limit) {
|
|
59
|
+
async function runAstGrep(root, pattern, language, limit) {
|
|
58
60
|
const command = AST_COMMANDS.find(commandExists) || null
|
|
59
61
|
if (!command || !String(pattern || "").trim()) return { provider: null, results: [], error: null }
|
|
60
62
|
const args = ["run", "--pattern", String(pattern), "--json=stream"]
|
|
61
63
|
if (language) args.push("--lang", String(language))
|
|
62
64
|
args.push(".")
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
+
const resolved = process.platform === "win32" ? resolveWindowsCommand(command) : null
|
|
66
|
+
const executable = resolved?.executable || command
|
|
67
|
+
const executionArgs = [...(resolved?.argsPrefix || []), ...args]
|
|
68
|
+
const result = await runSupervisedProcess(executable, executionArgs, {
|
|
69
|
+
cwd: path.resolve(root),
|
|
70
|
+
hardTimeoutMs: 20_000,
|
|
71
|
+
idleTimeoutMs: 10_000,
|
|
72
|
+
stdoutLimit: 4 * 1024 * 1024,
|
|
73
|
+
stderrLimit: 64 * 1024,
|
|
74
|
+
drainTimeoutMs: 500,
|
|
75
|
+
})
|
|
76
|
+
if (result.exitCode !== 0) {
|
|
77
|
+
const reason = result.stopReason ? `ast-grep ${result.stopReason}` : `ast-grep exit ${result.exitCode}`
|
|
78
|
+
return {
|
|
79
|
+
provider: command,
|
|
80
|
+
results: [],
|
|
81
|
+
error: String(result.stderr || reason).trim().slice(0, 1000),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
65
84
|
const rows = String(result.stdout || "").split(/\r?\n/).filter(Boolean).slice(0, limit).map((line) => {
|
|
66
85
|
try { return JSON.parse(line) } catch { return { raw: line } }
|
|
67
86
|
})
|
|
68
|
-
return { provider: command, results: rows, error: null }
|
|
87
|
+
return { provider: command, results: rows, error: null, truncated: result.stdoutTruncated === true }
|
|
69
88
|
}
|
|
70
89
|
|
|
71
90
|
export async function searchCodeIntelligence(root, query, options = {}) {
|
|
91
|
+
root = path.resolve(root)
|
|
72
92
|
const maxResults = Math.max(1, Math.min(50, Number(options.maxResults || 12)))
|
|
73
|
-
const
|
|
74
|
-
const
|
|
93
|
+
const maxFiles = options.maxFiles || 6000
|
|
94
|
+
const workspace = runtimeWorkspaceSnapshot(root, {
|
|
95
|
+
workspaceState: options.workspaceState,
|
|
96
|
+
})
|
|
97
|
+
const semanticSnapshot = await buildSemanticIndexCached(root, {
|
|
98
|
+
maxFiles,
|
|
99
|
+
workspaceFingerprint: workspace.cacheable ? workspace.fingerprint : "",
|
|
100
|
+
})
|
|
101
|
+
const semantic = await querySemanticIndex(root, query, {
|
|
102
|
+
maxFiles,
|
|
103
|
+
limit: maxResults,
|
|
104
|
+
builtIndex: semanticSnapshot,
|
|
105
|
+
})
|
|
106
|
+
const structural = options.structuralPattern ? await runAstGrep(root, options.structuralPattern, options.language, maxResults) : { provider: null, results: [], error: null }
|
|
75
107
|
return {
|
|
76
108
|
schemaVersion: 1,
|
|
77
109
|
query: String(query || ""),
|
|
110
|
+
workspaceFingerprint: workspace.cacheable ? workspace.fingerprint : null,
|
|
78
111
|
providers: probeCodeIntelligence(options.file || ""),
|
|
79
|
-
semantic: {
|
|
112
|
+
semantic: {
|
|
113
|
+
...semantic,
|
|
114
|
+
runtimeCacheHit: semanticSnapshot.runtimeCacheHit === true,
|
|
115
|
+
runtimeCacheCoalesced: semanticSnapshot.runtimeCacheCoalesced === true,
|
|
116
|
+
results: (semantic.results || []).slice(0, maxResults),
|
|
117
|
+
},
|
|
80
118
|
structural,
|
|
81
119
|
}
|
|
82
120
|
}
|
|
@@ -15,14 +15,19 @@ const DEFAULT_MAX_RESTARTS = 1
|
|
|
15
15
|
const CONFIG_MAX_BYTES = 512 * 1024
|
|
16
16
|
|
|
17
17
|
const POOL = new Map()
|
|
18
|
+
const STARTUPS = new Map()
|
|
19
|
+
let poolMutationTail = Promise.resolve()
|
|
18
20
|
const METRICS = {
|
|
19
21
|
coldStarts: 0,
|
|
20
22
|
warmHits: 0,
|
|
23
|
+
startupJoins: 0,
|
|
21
24
|
restarts: 0,
|
|
22
25
|
evictions: 0,
|
|
23
26
|
fallbacks: 0,
|
|
24
27
|
operations: 0,
|
|
28
|
+
acquisitionLatencyMs: 0,
|
|
25
29
|
operationLatencyMs: 0,
|
|
30
|
+
totalLatencyMs: 0,
|
|
26
31
|
}
|
|
27
32
|
let sweepTimer = null
|
|
28
33
|
|
|
@@ -32,6 +37,19 @@ function bounded(value, fallback, min, max) {
|
|
|
32
37
|
return Math.max(min, Math.min(max, Math.trunc(number)))
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
async function withPoolMutation(fn) {
|
|
41
|
+
const previous = poolMutationTail
|
|
42
|
+
let release
|
|
43
|
+
const gate = new Promise((resolve) => { release = resolve })
|
|
44
|
+
poolMutationTail = previous.catch(() => {}).then(() => gate)
|
|
45
|
+
await previous.catch(() => {})
|
|
46
|
+
try {
|
|
47
|
+
return await fn()
|
|
48
|
+
} finally {
|
|
49
|
+
release()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
function configured(options = {}) {
|
|
36
54
|
return {
|
|
37
55
|
enabled: options.persistent !== false && String(process.env.UES_LSP_PERSISTENT || "1") !== "0",
|
|
@@ -86,41 +104,48 @@ function inside(base, target) {
|
|
|
86
104
|
|
|
87
105
|
async function configurationFingerprint(base, file, provider) {
|
|
88
106
|
const names = providerConfigNames(provider?.id)
|
|
89
|
-
const
|
|
107
|
+
const candidates = []
|
|
90
108
|
let dir = path.dirname(file)
|
|
91
109
|
|
|
92
110
|
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
|
-
}
|
|
111
|
+
for (const name of names) candidates.push(path.join(dir, name))
|
|
99
112
|
if (dir === base) break
|
|
100
113
|
const parent = path.dirname(dir)
|
|
101
114
|
if (parent === dir || !inside(base, parent)) break
|
|
102
115
|
dir = parent
|
|
103
116
|
}
|
|
104
117
|
|
|
118
|
+
// Config discovery is on every managed-LSP acquisition. Probe independent
|
|
119
|
+
// paths concurrently, then restore deterministic path order before hashing.
|
|
120
|
+
// This preserves exact fingerprint semantics while avoiding serial Windows
|
|
121
|
+
// filesystem round trips in deep monorepos.
|
|
122
|
+
const found = (await Promise.all(candidates.map(async (candidate) => {
|
|
123
|
+
const info = await stat(candidate).catch(() => null)
|
|
124
|
+
if (!info?.isFile()) return null
|
|
125
|
+
return { file: candidate, size: info.size, mtimeMs: Math.trunc(info.mtimeMs) }
|
|
126
|
+
}))).filter(Boolean)
|
|
105
127
|
found.sort((a, b) => a.file.localeCompare(b.file))
|
|
128
|
+
|
|
129
|
+
const contents = await Promise.all(found.map(async (item) => {
|
|
130
|
+
if (item.size > CONFIG_MAX_BYTES) return null
|
|
131
|
+
return readFile(item.file).catch(() => null)
|
|
132
|
+
}))
|
|
133
|
+
|
|
106
134
|
const hash = createHash("sha256")
|
|
107
135
|
hash.update(String(provider?.id || "unknown"))
|
|
108
136
|
hash.update("\0")
|
|
109
137
|
hash.update(String(provider?.command || ""))
|
|
110
138
|
hash.update("\0")
|
|
111
139
|
hash.update(JSON.stringify(provider?.initializationOptions || null))
|
|
112
|
-
for (
|
|
140
|
+
for (let index = 0; index < found.length; index += 1) {
|
|
141
|
+
const item = found[index]
|
|
113
142
|
hash.update(path.relative(base, item.file).replaceAll("\\", "/"))
|
|
114
143
|
hash.update("\0")
|
|
115
144
|
hash.update(String(item.size))
|
|
116
145
|
hash.update("\0")
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
else hash.update(String(item.mtimeMs))
|
|
121
|
-
} else {
|
|
122
|
-
hash.update(String(item.mtimeMs))
|
|
123
|
-
}
|
|
146
|
+
const content = contents[index]
|
|
147
|
+
if (content) hash.update(content)
|
|
148
|
+
else hash.update(String(item.mtimeMs))
|
|
124
149
|
hash.update("\0")
|
|
125
150
|
}
|
|
126
151
|
return hash.digest("hex").slice(0, 24)
|
|
@@ -443,6 +468,22 @@ class ManagedLspSession {
|
|
|
443
468
|
|
|
444
469
|
async stop(reason = "shutdown") {
|
|
445
470
|
if (this.closed) return
|
|
471
|
+
|
|
472
|
+
const child = this.child
|
|
473
|
+
// LSP protocol requires shutdown request before exit notification. Attempt
|
|
474
|
+
// this only when no operation is in flight; teardown must never wait behind
|
|
475
|
+
// a busy request forever.
|
|
476
|
+
if (
|
|
477
|
+
child &&
|
|
478
|
+
child.exitCode == null &&
|
|
479
|
+
this.busy === 0 &&
|
|
480
|
+
this.state === "READY"
|
|
481
|
+
) {
|
|
482
|
+
try {
|
|
483
|
+
await this.request("shutdown", null, Math.min(750, this.options.requestTimeoutMs))
|
|
484
|
+
} catch {}
|
|
485
|
+
}
|
|
486
|
+
|
|
446
487
|
this.state = "EVICTING"
|
|
447
488
|
this.closed = true
|
|
448
489
|
const error = errorWithCode("LSP session closed: " + reason, "LSP_SESSION_CLOSED")
|
|
@@ -458,12 +499,38 @@ class ManagedLspSession {
|
|
|
458
499
|
}
|
|
459
500
|
}
|
|
460
501
|
this.notificationWaiters.clear()
|
|
502
|
+
|
|
461
503
|
try {
|
|
462
|
-
if (
|
|
463
|
-
|
|
504
|
+
if (child?.stdin?.writable) {
|
|
505
|
+
child.stdin.write(encodeMessage({ jsonrpc: "2.0", method: "exit", params: null }))
|
|
464
506
|
}
|
|
465
507
|
} catch {}
|
|
466
|
-
|
|
508
|
+
|
|
509
|
+
let closedNaturally = !child || child.exitCode != null || child.signalCode != null
|
|
510
|
+
if (child && !closedNaturally) {
|
|
511
|
+
closedNaturally = await new Promise((resolve) => {
|
|
512
|
+
let settled = false
|
|
513
|
+
const finish = () => {
|
|
514
|
+
if (settled) return
|
|
515
|
+
settled = true
|
|
516
|
+
clearTimeout(timer)
|
|
517
|
+
child.removeListener("close", finish)
|
|
518
|
+
resolve(true)
|
|
519
|
+
}
|
|
520
|
+
child.once("close", finish)
|
|
521
|
+
const timer = setTimeout(() => {
|
|
522
|
+
if (settled) return
|
|
523
|
+
settled = true
|
|
524
|
+
child.removeListener("close", finish)
|
|
525
|
+
resolve(false)
|
|
526
|
+
}, 350)
|
|
527
|
+
timer.unref?.()
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (child && !closedNaturally) {
|
|
532
|
+
terminateProcessTree(child, { graceMs: 500 })
|
|
533
|
+
}
|
|
467
534
|
this.state = "CLOSED"
|
|
468
535
|
}
|
|
469
536
|
}
|
|
@@ -476,6 +543,22 @@ function sessionsForWorkspace(base) {
|
|
|
476
543
|
return [...POOL.values()].filter((session) => session.base === base && session.alive())
|
|
477
544
|
}
|
|
478
545
|
|
|
546
|
+
function occupiesCapacity(session) {
|
|
547
|
+
return Boolean(
|
|
548
|
+
session &&
|
|
549
|
+
!session.closed &&
|
|
550
|
+
!["CRASHED", "CLOSED", "EVICTING"].includes(session.state),
|
|
551
|
+
)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function capacitySessions() {
|
|
555
|
+
return [...POOL.values()].filter(occupiesCapacity)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function capacitySessionsForWorkspace(base) {
|
|
559
|
+
return capacitySessions().filter((session) => session.base === base)
|
|
560
|
+
}
|
|
561
|
+
|
|
479
562
|
async function removeSession(key, reason) {
|
|
480
563
|
const session = POOL.get(key)
|
|
481
564
|
if (!session) return
|
|
@@ -486,7 +569,11 @@ async function removeSession(key, reason) {
|
|
|
486
569
|
async function evictExpired(options) {
|
|
487
570
|
const now = Date.now()
|
|
488
571
|
const targets = [...POOL.entries()]
|
|
489
|
-
.filter(([, session]) =>
|
|
572
|
+
.filter(([, session]) =>
|
|
573
|
+
session.state !== "STARTING" &&
|
|
574
|
+
session.busy === 0 &&
|
|
575
|
+
now - session.lastUsedAt >= options.idleTtlMs
|
|
576
|
+
)
|
|
490
577
|
for (const [key] of targets) {
|
|
491
578
|
METRICS.evictions += 1
|
|
492
579
|
await removeSession(key, "idle-ttl")
|
|
@@ -495,7 +582,7 @@ async function evictExpired(options) {
|
|
|
495
582
|
|
|
496
583
|
async function evictLru(candidates, reason) {
|
|
497
584
|
const idle = candidates
|
|
498
|
-
.filter((session) => session.busy === 0)
|
|
585
|
+
.filter((session) => session.state !== "STARTING" && session.busy === 0)
|
|
499
586
|
.sort((a, b) => a.lastUsedAt - b.lastUsedAt)
|
|
500
587
|
const victim = idle[0]
|
|
501
588
|
if (!victim) return false
|
|
@@ -509,13 +596,13 @@ async function evictLru(candidates, reason) {
|
|
|
509
596
|
async function ensureCapacity(base, options) {
|
|
510
597
|
await evictExpired(options)
|
|
511
598
|
|
|
512
|
-
while (
|
|
513
|
-
const evicted = await evictLru(
|
|
599
|
+
while (capacitySessionsForWorkspace(base).length >= options.maxPerWorkspace) {
|
|
600
|
+
const evicted = await evictLru(capacitySessionsForWorkspace(base), "workspace-lru")
|
|
514
601
|
if (!evicted) return false
|
|
515
602
|
}
|
|
516
603
|
|
|
517
|
-
while (
|
|
518
|
-
const evicted = await evictLru(
|
|
604
|
+
while (capacitySessions().length >= options.maxServers) {
|
|
605
|
+
const evicted = await evictLru(capacitySessions(), "global-lru")
|
|
519
606
|
if (!evicted) return false
|
|
520
607
|
}
|
|
521
608
|
return true
|
|
@@ -525,7 +612,7 @@ function ensureSweeper() {
|
|
|
525
612
|
if (sweepTimer) return
|
|
526
613
|
sweepTimer = setInterval(() => {
|
|
527
614
|
const options = configured()
|
|
528
|
-
void evictExpired(options).catch(() => {})
|
|
615
|
+
void withPoolMutation(() => evictExpired(options)).catch(() => {})
|
|
529
616
|
}, 30_000)
|
|
530
617
|
sweepTimer.unref?.()
|
|
531
618
|
}
|
|
@@ -534,38 +621,70 @@ async function acquire(target, provider, options) {
|
|
|
534
621
|
ensureSweeper()
|
|
535
622
|
const fingerprint = await configurationFingerprint(target.base, target.file, provider)
|
|
536
623
|
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
624
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
session.busy === 0
|
|
551
|
-
) {
|
|
552
|
-
METRICS.evictions += 1
|
|
553
|
-
await removeSession(otherKey, "config-changed")
|
|
625
|
+
const decision = await withPoolMutation(async () => {
|
|
626
|
+
const pending = STARTUPS.get(key)
|
|
627
|
+
if (pending) {
|
|
628
|
+
METRICS.startupJoins += 1
|
|
629
|
+
return { kind: "startup-join", promise: pending }
|
|
554
630
|
}
|
|
555
|
-
}
|
|
556
631
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
await
|
|
567
|
-
|
|
632
|
+
const existing = POOL.get(key)
|
|
633
|
+
if (existing?.alive() && existing.state !== "STARTING") {
|
|
634
|
+
METRICS.warmHits += 1
|
|
635
|
+
existing.lastUsedAt = Date.now()
|
|
636
|
+
return {
|
|
637
|
+
kind: "ready",
|
|
638
|
+
acquired: { key, session: existing, poolHit: true, startupJoin: false },
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (existing) await removeSession(key, "stale-session")
|
|
642
|
+
|
|
643
|
+
for (const [otherKey, session] of POOL) {
|
|
644
|
+
if (
|
|
645
|
+
session.base === target.base &&
|
|
646
|
+
session.provider.id === provider.id &&
|
|
647
|
+
session.configFingerprint !== fingerprint &&
|
|
648
|
+
session.state !== "STARTING" &&
|
|
649
|
+
session.busy === 0
|
|
650
|
+
) {
|
|
651
|
+
METRICS.evictions += 1
|
|
652
|
+
await removeSession(otherKey, "config-changed")
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (!await ensureCapacity(target.base, options)) {
|
|
657
|
+
return { kind: "capacity" }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const session = new ManagedLspSession(target.base, provider, fingerprint, options)
|
|
661
|
+
POOL.set(key, session)
|
|
662
|
+
let startupPromise
|
|
663
|
+
startupPromise = (async () => {
|
|
664
|
+
try {
|
|
665
|
+
await session.start()
|
|
666
|
+
METRICS.coldStarts += 1
|
|
667
|
+
return { key, session, poolHit: false, startupJoin: false }
|
|
668
|
+
} catch (error) {
|
|
669
|
+
if (POOL.get(key) === session) POOL.delete(key)
|
|
670
|
+
await session.stop("startup-failed").catch(() => {})
|
|
671
|
+
throw error
|
|
672
|
+
} finally {
|
|
673
|
+
if (STARTUPS.get(key) === startupPromise) STARTUPS.delete(key)
|
|
674
|
+
}
|
|
675
|
+
})()
|
|
676
|
+
STARTUPS.set(key, startupPromise)
|
|
677
|
+
return { kind: "startup", promise: startupPromise }
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
if (decision.kind === "ready") return decision.acquired
|
|
681
|
+
if (decision.kind === "capacity") return null
|
|
682
|
+
|
|
683
|
+
const acquired = await decision.promise
|
|
684
|
+
if (decision.kind === "startup-join") {
|
|
685
|
+
return { ...acquired, poolHit: true, startupJoin: true }
|
|
568
686
|
}
|
|
687
|
+
return acquired
|
|
569
688
|
}
|
|
570
689
|
|
|
571
690
|
export async function withManagedLspSession(target, provider, inputOptions = {}, operation) {
|
|
@@ -575,11 +694,15 @@ export async function withManagedLspSession(target, provider, inputOptions = {},
|
|
|
575
694
|
return { ok: false, reason: "persistent-disabled", error: null }
|
|
576
695
|
}
|
|
577
696
|
|
|
697
|
+
const totalStartedAt = Date.now()
|
|
578
698
|
let attempt = 0
|
|
579
699
|
while (attempt <= options.maxRestarts) {
|
|
580
700
|
let acquired
|
|
701
|
+
let acquisitionDurationMs = 0
|
|
581
702
|
try {
|
|
703
|
+
const acquisitionStartedAt = Date.now()
|
|
582
704
|
acquired = await acquire(target, provider, options)
|
|
705
|
+
acquisitionDurationMs = Date.now() - acquisitionStartedAt
|
|
583
706
|
if (!acquired) {
|
|
584
707
|
METRICS.fallbacks += 1
|
|
585
708
|
return { ok: false, reason: "pool-capacity-busy", error: null }
|
|
@@ -603,19 +726,25 @@ export async function withManagedLspSession(target, provider, inputOptions = {},
|
|
|
603
726
|
return operation(adapter)
|
|
604
727
|
})
|
|
605
728
|
|
|
729
|
+
const totalDurationMs = Date.now() - totalStartedAt
|
|
606
730
|
METRICS.operations += 1
|
|
731
|
+
METRICS.acquisitionLatencyMs += acquisitionDurationMs
|
|
607
732
|
METRICS.operationLatencyMs += executed.durationMs
|
|
733
|
+
METRICS.totalLatencyMs += totalDurationMs
|
|
608
734
|
return {
|
|
609
735
|
ok: true,
|
|
610
736
|
result: executed.result,
|
|
611
737
|
meta: {
|
|
612
738
|
persistent: true,
|
|
613
739
|
poolHit: acquired.poolHit,
|
|
614
|
-
warm: acquired.poolHit,
|
|
740
|
+
warm: acquired.poolHit && !acquired.startupJoin,
|
|
741
|
+
startupJoin: Boolean(acquired.startupJoin),
|
|
615
742
|
sessionId: session.id,
|
|
616
743
|
state: session.state,
|
|
617
744
|
coldStartMs: acquired.poolHit ? 0 : session.coldStartMs,
|
|
745
|
+
acquisitionDurationMs,
|
|
618
746
|
operationDurationMs: executed.durationMs,
|
|
747
|
+
totalDurationMs,
|
|
619
748
|
requestCount: session.requestCount,
|
|
620
749
|
configFingerprint: session.configFingerprint,
|
|
621
750
|
},
|
|
@@ -662,11 +791,19 @@ export function lspPoolStatus(options = {}) {
|
|
|
662
791
|
},
|
|
663
792
|
active: sessions.length,
|
|
664
793
|
busy: sessions.filter((session) => session.busy > 0).length,
|
|
794
|
+
sessionCount: sessions.length,
|
|
795
|
+
sessionsIncluded: options.includeSessions !== false,
|
|
665
796
|
metrics: {
|
|
666
797
|
...METRICS,
|
|
798
|
+
averageAcquisitionLatencyMs: METRICS.operations
|
|
799
|
+
? Number((METRICS.acquisitionLatencyMs / METRICS.operations).toFixed(2))
|
|
800
|
+
: null,
|
|
667
801
|
averageOperationLatencyMs: METRICS.operations
|
|
668
802
|
? Number((METRICS.operationLatencyMs / METRICS.operations).toFixed(2))
|
|
669
803
|
: null,
|
|
804
|
+
averageTotalLatencyMs: METRICS.operations
|
|
805
|
+
? Number((METRICS.totalLatencyMs / METRICS.operations).toFixed(2))
|
|
806
|
+
: null,
|
|
670
807
|
},
|
|
671
808
|
sessions: options.includeSessions === false ? [] : sessions.map((session) => session.publicStatus()),
|
|
672
809
|
}
|
|
@@ -7,6 +7,9 @@ import { resolveWindowsCommand } from "../windows-shim.mjs"
|
|
|
7
7
|
import { lspPoolStatus as managedLspPoolStatus, shutdownLspPool as shutdownManagedLspPool, withManagedLspSession } from "./lsp-pool.mjs"
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
9
9
|
|
|
10
|
+
const TYPESCRIPT_FALLBACK_CACHE_TTL_MS = 60_000
|
|
11
|
+
let typescriptFallbackCache = null
|
|
12
|
+
|
|
10
13
|
const PROVIDERS = [
|
|
11
14
|
{ id: "typescript-language-server", extensions: new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]), commands: [["typescript-language-server", ["--stdio"]]], languageId: (ext) => [".ts", ".tsx"].includes(ext) ? "typescript" : "javascript" },
|
|
12
15
|
{ id: "pyright-langserver", extensions: new Set([".py"]), commands: [["pyright-langserver", ["--stdio"]], ["pylsp", []]], languageId: () => "python" },
|
|
@@ -41,14 +44,11 @@ async function safeFile(root, relative) {
|
|
|
41
44
|
return { base, file: actual, info }
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
export function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const platform = String(options.platform || process.platform)
|
|
49
|
-
if (platform !== "win32") return null
|
|
47
|
+
export function clearTypeScriptTsserverFallbackCache() {
|
|
48
|
+
typescriptFallbackCache = null
|
|
49
|
+
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
function deriveTypeScriptTsserverFallback(execution) {
|
|
52
52
|
const candidates = [
|
|
53
53
|
execution?.entry,
|
|
54
54
|
...(Array.isArray(execution?.argsPrefix) ? execution.argsPrefix : []),
|
|
@@ -67,6 +67,43 @@ export function resolveTypeScriptTsserverFallback(options = {}) {
|
|
|
67
67
|
return null
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
export function resolveTypeScriptTsserverFallback(options = {}) {
|
|
71
|
+
const explicit = String(options.explicitPath ?? process.env.UES_TYPESCRIPT_TSSERVER_PATH ?? "").trim()
|
|
72
|
+
if (explicit && existsSync(path.resolve(explicit))) return path.resolve(explicit)
|
|
73
|
+
|
|
74
|
+
const platform = String(options.platform || process.platform)
|
|
75
|
+
if (platform !== "win32") return null
|
|
76
|
+
|
|
77
|
+
// Explicit injected executions are deterministic probes used by tests/callers;
|
|
78
|
+
// do not mix them into the host PATH cache.
|
|
79
|
+
if (options.execution) return deriveTypeScriptTsserverFallback(options.execution)
|
|
80
|
+
|
|
81
|
+
const cacheKey = [
|
|
82
|
+
explicit,
|
|
83
|
+
String(process.env.PATH || ""),
|
|
84
|
+
String(process.env.PATHEXT || ""),
|
|
85
|
+
].join("\u0000")
|
|
86
|
+
const ttlMs = Math.max(0, Math.min(
|
|
87
|
+
5 * 60_000,
|
|
88
|
+
Number(options.cacheTtlMs ?? process.env.UES_LSP_PROVIDER_PROBE_TTL_MS ?? TYPESCRIPT_FALLBACK_CACHE_TTL_MS),
|
|
89
|
+
))
|
|
90
|
+
const now = Date.now()
|
|
91
|
+
if (
|
|
92
|
+
typescriptFallbackCache &&
|
|
93
|
+
typescriptFallbackCache.key === cacheKey &&
|
|
94
|
+
now - typescriptFallbackCache.checkedAt < ttlMs
|
|
95
|
+
) {
|
|
96
|
+
return typescriptFallbackCache.value
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const resolver = typeof options.resolveCommand === "function"
|
|
100
|
+
? options.resolveCommand
|
|
101
|
+
: resolveWindowsCommand
|
|
102
|
+
const value = deriveTypeScriptTsserverFallback(resolver("tsserver"))
|
|
103
|
+
typescriptFallbackCache = { key: cacheKey, checkedAt: now, value }
|
|
104
|
+
return value
|
|
105
|
+
}
|
|
106
|
+
|
|
70
107
|
function providerRuntimeOptions(provider) {
|
|
71
108
|
if (provider?.id !== "typescript-language-server") return {}
|
|
72
109
|
const fallbackPath = resolveTypeScriptTsserverFallback()
|