UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

169 lines (168 loc) • 12.9 kB
import { type SessionWarmup } from "../../core/lsp-session.js"; import { type LspChildProcess } from "./stdio-lsp-transport.js"; import { type LspServerManager, type LspSession, type LspReadText } from "../../core/lsp.js"; import type { ExecutionEnv } from "../../internal/harness-types.js"; import type { EngineNotice } from "../../core/engine-notice.js"; /** * Default language → server command table (the DETERMINED supported set). Each value is a `command [args…]` * whose binary must be on the host `PATH`; absent ⇒ that language degrades. Mirrors the servers service bakes * into its sandbox templates (so TOC & TOB expose the SAME languages when installed), plus the common * rust/c/cpp servers a local dev box usually has. Override/extend via {@link NodeLspManagerOptions.servers} * (a `null` value disables a language). All of these speak LSP over stdio by default. */ export declare const DEFAULT_LSP_SERVERS: Readonly<Record<string, string>>; /** Spawn a child (default). Resolves the child on a successful `spawn`, or `undefined` on `error` (ENOENT = * server not installed → the manager degrades that language). Waiting for the 'spawn'/'error' race before use * is CRITICAL: `spawn()` returns synchronously but ENOENT fires 'error' asynchronously (CC's LSPClient §1.5). */ export type LspSpawn = (command: string, args: string[], cwd: string, signal?: AbortSignal) => Promise<LspChildProcess | undefined>; export interface NodeLspManagerOptions { /** Override/extend {@link DEFAULT_LSP_SERVERS} (merged over it; a `null` value disables that language). */ servers?: Record<string, string | null>; /** Injectable spawn (tests supply a fake server; default = {@link defaultLspSpawn} over `child_process.spawn`). */ spawn?: LspSpawn; /** The language server's environment for the default spawn, verbatim. Omitted ⇒ the host environment scrubbed of * credential-shaped names (`scrubSecretEnv`, a tool child's scrub). Unread when {@link spawn} is supplied. */ env?: Record<string, string>; /** #1081 — the withheld-keys notice sink, SAME shared once-per-process latch/code as the shell-exec * site's `NodeExecutionEnv.onNotice`. Absent ⇒ console only. Unread when {@link spawn} is supplied. */ onNotice?: (notice: EngineNotice) => void; /** Read a file's CURRENT text for the session's didOpen/didChange re-sync (default = local `fs.readFile` utf8). */ readText?: LspReadText; /** Resolve the server `cwd`/`rootUri` for a file (default = env.cwd ?? nearest project marker ?? file dir). */ resolveRoot?: (filePath: string, env?: ExecutionEnv) => string; /** Observability hook (open/heal/degrade/evict events). */ log?: (event: string, fields: Record<string, unknown>) => void; /** Warm-up window override (see {@link TransportLspSession}); default 10s/1s. */ warmup?: SessionWarmup; /** Max concurrently-cached language servers before LRU eviction (default 16, min 1). A safety net bounding * the cache when a deployment can't call {@link NodeLspManager.evict} at task-end (e.g. ephemeral/worktree * roots). Each cached server is a real process (jdtls = a JVM), so keep this modest. */ maxSessions?: number; /** Idle-TTL (ms) after which an unused server is auto-reclaimed by a background sweep (default 600_000 = 10 * min; 0 disables). Self-healing complement to {@link NodeLspManager.evict}: a deployment that can't call * `evict` at task-end still won't leak — an abandoned server dies on its own. An actively reused server never * goes idle (its last-used stamp refreshes on every lookup), so a modest TTL doesn't churn the happy path. */ idleTtlMs?: number; /** design/121: the workspace diagnostics registry every session feeds from publishDiagnostics. * WORKSPACE-scoped, not server-scoped — it survives heal/evict (diagnostics belong to files, not to a * server instance). Absent ⇒ notifications stay dropped (pre-121 behavior byte-for-byte). */ diagnostics?: import("../../core/lsp-diagnostics.js").LspDiagnosticsRegistry; } export declare class NodeLspManager implements LspServerManager { private readonly servers; private readonly spawnFn; private readonly readTextFn; private readonly resolveRootFn; private readonly log; private readonly warmup?; /** design/121: the workspace diagnostics registry (see {@link NodeLspManagerOptions.diagnostics}) — * public so the Runner drains it at turn boundaries via {@link LspServerManager.diagnostics}. */ readonly diagnostics: import("../../core/lsp-diagnostics.js").LspDiagnosticsRegistry; private readonly maxSessions; private readonly idleTtlMs; private sweepTimer?; /** `<language> <root>` → the live session + its root + last-used stamp (reused across ops; healed when its * transport dies; idle-TTL swept; LRU-evicted over {@link maxSessions}; `evict(root)` drops a task's servers * at task-end). Space-separated key is unambiguous — a languageId never contains a space. */ private readonly cache; /** In-flight opens (concurrent ops on the same language+root share ONE spawn/initialize). Tracks each open's * `root` + a `cancelled` flag so {@link evict}/{@link dispose} can reclaim a server whose open resolves AFTER * the teardown already ran (else its `.then` caches a live child that nothing reclaims — permanently after * dispose, since the sweep timer is cleared). */ private readonly opening; /** RB-376 — the NEGATIVE spawn cache, the missing twin of {@link cache}: a `(language, root)` key * whose real `spawn()` already failed (binary not on PATH). Without it, every successful * Edit/Write/NotebookEdit re-triggered a full spawn attempt for the same missing server — a * production leg recorded 205 `lsp_spawn_degraded` events with the model never calling LSP once. * Same key construction as the positive cache. NO TTL by design: a missing binary does not appear * mid-task, and a time-based retry would just resume the re-spawn storm — {@link clearFailed} is * the explicit recovery valve. `attempts` counts suppressed hits (first failure logs once via * `lsp_spawn_degraded`; cache hits stay silent; the count surfaces on the cleared log line). */ private readonly failed; /** HRD-LSP-14: consecutive `initialize` failures per key, BEFORE the latch (see {@link INIT_FAILURE_LATCH}). */ private readonly initFailures; /** Set by {@link dispose}: an in-flight open that resolves after dispose self-closes instead of caching. */ private disposed; constructor(opts?: NodeLspManagerOptions); /** Close + drop every server idle past {@link idleTtlMs}. Runs on a timer; best-effort. */ private sweepIdle; sessionFor(filePath: string, signal?: AbortSignal, env?: ExecutionEnv): Promise<LspSession | undefined>; /** Spawn + initialize a fresh session for `key`, deduping concurrent opens. `carry` = files to re-open * before the session becomes visible (heal). */ private open; /** LRU-evict (close) the least-recently-used server(s) until there is room for one more under {@link maxSessions}. * A safety net: without an explicit {@link evict}, a long-lived process spawning tasks with EPHEMERAL roots * (worktree / per-task cwd) would otherwise leak one heavyweight language server per (task × language). */ private evictOverCap; /** * Evict + kill every cached language server rooted at `root`. Call this at TASK-END for an ephemeral/worktree * cwd so a heavyweight server (e.g. a jdtls JVM) doesn't outlive the task that spawned it — the deterministic * complement to the LRU cap. `root` must match what `resolveRoot` produced for the task (the host lane uses the * task's `env.cwd`). Best-effort + idempotent (an unknown/already-evicted root is a no-op). */ evict(root: string): Promise<void>; /** * RB-376 — drop negative spawn-cache entries so the NEXT op may retry the real spawn: the explicit * recovery valve for "I just installed that language server" (the entries deliberately have no TTL — * see the `failed` field doc). No arg ⇒ clear everything; `root` ⇒ only that root's entries (the * {@link evict} shape). Each cleared entry logs its suppressed-attempt count (evict-log parity). * Idempotent; an unknown root is a no-op. */ clearFailed(root?: string): void; /** * HRD-LSP-14 (optional {@link LspServerManager} member): WHY the last `sessionFor` for this file degraded. * `openSession` collapses three very different situations into one `undefined` — the language has no * server configured, the binary is not installed, or the server IS installed and failed to initialize — * and the tool rendered the same "no language server for its language in this environment" for all of * them. The first is a fact about the deployment; the third is an incident the operator can fix, and * telling the model (and the transcript) they are the same thing is how "we have no LSP for Go" survives * for months in an environment where gopls is installed and crashing. */ unavailableReason(filePath: string, env?: ExecutionEnv): string | undefined; private openSession; private serverCmd; /** Kill every running server + stop the idle sweep (call on runner/deployment shutdown). Best-effort. */ dispose(): Promise<void>; } /** Resolve a file path to a languageId via its extension (undefined = no mapping → degrade). */ export declare function languageFor(filePath: string): string | undefined; /** CC 2.1.220's LSP file cap — single-sourced in core (`src/core/lsp.ts`), where the tool-layer check * enforces it model-visibly; re-exported here so engine-path importers keep working. */ export { MAX_LSP_FILE_BYTES } from "../../core/lsp.js"; /** * RB-232: the local default {@link LspReadText}, hardened with a stat-first precondition (CC * 2.1.220 validateInput does the same before ever reading — "Path is not a file", (pretty220.js:474700, historical; not relocated in 250 — see cc-250 anchors)). * The stat check is the LOAD-BEARING part, do not "simplify" it away: a plain `readFile` on a FIFO/socket * blocks forever on the FIRST read and PINS a libuv threadpool thread (default pool = 4) for the process * lifetime — measured: `readFile(fifo, { signal })` with an abort fired at 2s was still unsettled at 10s, * because Node only checks the signal between chunk reads. `{ signal }` is kept as a bonus for regular * files; it cannot rescue the FIFO case on its own. (The tool layer additionally rejects such paths via * `env.fileInfo` before any read — this is defense in depth for direct/embedder callers.) */ export declare const defaultLspReadText: LspReadText; /** ask ②: on Windows, npm-global language servers materialize as `.cmd` shims and * Node 20+ `spawn(cmd, args)` without a shell rejects them (EINVAL, CVE-2024-27980 hardening) — * every DEFAULT_LSP_SERVERS entry would silently degrade to "no LSP". `shell:true` routes through * cmd.exe so PATHEXT/.cmd resolution works. Injection surface: `command`/`args` come from * LspServerSpec (deployment configuration, trusted) or DEFAULT_LSP_SERVERS constants — never from * the model. Exported as a pure function so the win32 branch is testable off-platform. */ export declare function lspSpawnOptions(cwd: string): { cwd: string; stdio: ["pipe", "pipe", "ignore"]; shell?: boolean; }; /** * Default spawn over `child_process.spawn`, resolving on 'spawn' / `undefined` on 'error' (ENOENT = degrade) * / REJECTING with the errno error for any other spawn failure (see {@link NodeLspManager}'s `failed` map: * "not installed" is permanent and worth caching, EACCES/EMFILE/a transient cwd is not — HRD-LSP-15). * * 🔴 HRD-LSP-4 — the ABSORBER below is load-bearing, do not "simplify" it away. `ChildProcess` is an * `EventEmitter`, and an 'error' emitted with ZERO 'error' listeners is re-thrown by Node as an uncaught * exception — in a library that means TAKING THE HOST PROCESS DOWN. `spawn()` returns synchronously while * ENOENT arrives asynchronously (CC's LSPClient §1.5), so every path that removes our own listener before * that event lands is a live crash: `onAbort` (cancel a spawn that is about to fail = a routine task * cancellation) and `onSpawn` (the window between resolving and `StdioLspTransport`'s constructor attaching * its handler). A permanent no-op listener, attached before anything can fire and never removed, makes the * emitter unable to throw; the transport's own handler still receives every runtime error afterwards. */ export declare const defaultLspSpawn: LspSpawn;