@sema-agent/core
Version:
Stateless, task-oriented AI agent core
169 lines (168 loc) • 12.9 kB
TypeScript
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;