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 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.1</code>
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.0</code>
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.1
1434
+ 15.1.2
1435
1435
  ~~~
1436
1436
 
1437
- Stable npm public hiện vẫn là <code>15.1.0</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.
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.1.tgz
1442
+ pi install .\opencode-agent-skill-15.1.2.tgz
1443
1443
  pi list
1444
1444
  ~~~
1445
1445
 
@@ -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([".git","node_modules",".next","dist","build","coverage",".venv","venv","target","Pods","DerivedData",".gradle",".ues-cache",".ues-work",".ues-sandboxes",".ues-traces",".ues-learning",".ues-dashboard",".ues-memory",".ues-evals",".ues-services"])
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 { querySemanticIndex } from "../semantic-index.mjs"
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 result = spawnSync(command, args, { cwd: path.resolve(root), encoding: "utf8", timeout: 20_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true })
64
- if (result.status !== 0) return { provider: command, results: [], error: String(result.stderr || ("ast-grep exit " + result.status)).trim().slice(0, 1000) }
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 semantic = await querySemanticIndex(root, query, { maxFiles: options.maxFiles || 6000, limit: maxResults })
74
- const structural = options.structuralPattern ? runAstGrep(root, options.structuralPattern, options.language, maxResults) : { provider: null, results: [], error: null }
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: { ...semantic, results: (semantic.results || []).slice(0, maxResults) },
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 found = []
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 (const item of found) {
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
- 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
- }
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 (this.child?.stdin?.writable) {
463
- this.child.stdin.write(encodeMessage({ jsonrpc: "2.0", method: "exit", params: null }))
504
+ if (child?.stdin?.writable) {
505
+ child.stdin.write(encodeMessage({ jsonrpc: "2.0", method: "exit", params: null }))
464
506
  }
465
507
  } catch {}
466
- if (this.child) terminateProcessTree(this.child, { graceMs: 500 })
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]) => session.busy === 0 && now - session.lastUsedAt >= options.idleTtlMs)
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 (sessionsForWorkspace(base).length >= options.maxPerWorkspace) {
513
- const evicted = await evictLru(sessionsForWorkspace(base), "workspace-lru")
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 ([...POOL.values()].filter((session) => session.alive()).length >= options.maxServers) {
518
- const evicted = await evictLru([...POOL.values()], "global-lru")
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
- 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")
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
- 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
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 resolveTypeScriptTsserverFallback(options = {}) {
45
- const explicit = String(options.explicitPath ?? process.env.UES_TYPESCRIPT_TSSERVER_PATH ?? "").trim()
46
- if (explicit && existsSync(path.resolve(explicit))) return path.resolve(explicit)
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
- const execution = options.execution || resolveWindowsCommand("tsserver")
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()