@sema-agent/core
Version:
Stateless, task-oriented AI agent core
386 lines (385 loc) • 15.2 kB
JavaScript
import { spawn } from "node:child_process";
import { killProcessTree } from "../execution-env/kill-tree.js";
import { open, readFile, stat } from "node:fs/promises";
import { basename, dirname, extname, resolve as resolvePath } from "node:path";
import { constants as fsConstants, existsSync } from "node:fs";
import { pathToUri } from "../../core/lsp-protocol.js";
import { LspDiagnosticsRegistry } from "../../core/lsp-diagnostics.js";
import { TransportLspSession } from "../../core/lsp-session.js";
import { StdioLspTransport } from "./stdio-lsp-transport.js";
import { MAX_LSP_FILE_BYTES, SharedAbortScope, settleOnAbort } from "../../core/lsp.js";
import { asThrownError, thrownText } from "../../core/thrown-value.js";
import { announceSecretEnvScrub, scrubSecretEnv } from "../../core/secret-env.js";
export const DEFAULT_LSP_SERVERS = {
typescript: "typescript-language-server --stdio",
javascript: "typescript-language-server --stdio",
python: "pyright-langserver --stdio",
go: "gopls",
rust: "rust-analyzer",
dart: "dart language-server",
kotlin: "kotlin-language-server",
java: "jdtls",
swift: "sourcekit-lsp",
c: "clangd",
cpp: "clangd",
};
const EXT_LANG = {
".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript",
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
".py": "python", ".pyi": "python",
".go": "go",
".rs": "rust",
".dart": "dart",
".kt": "kotlin", ".kts": "kotlin",
".java": "java",
".swift": "swift",
".c": "c", ".h": "c",
".cc": "cpp", ".cpp": "cpp", ".cxx": "cpp", ".hpp": "cpp", ".hh": "cpp", ".hxx": "cpp",
};
const ROOT_MARKERS = [
"package.json", "tsconfig.json", "jsconfig.json", "go.mod", "Cargo.toml", "pubspec.yaml",
"build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "pom.xml",
"Package.swift", "pyproject.toml", "setup.py", "requirements.txt", "compile_commands.json", ".git",
];
const CLIENT_CAPABILITIES = {
textDocument: {
definition: {}, references: {}, hover: {}, documentSymbol: {}, implementation: {}, callHierarchy: {},
publishDiagnostics: { versionSupport: true },
},
workspace: { symbol: {}, workspaceFolders: false },
};
const INIT_FAILURE_LATCH = 2;
export class NodeLspManager {
servers;
spawnFn;
readTextFn;
resolveRootFn;
log;
warmup;
diagnostics;
maxSessions;
idleTtlMs;
sweepTimer;
cache = new Map();
opening = new Map();
failed = new Map();
initFailures = new Map();
disposed = false;
constructor(opts = {}) {
this.servers = { ...DEFAULT_LSP_SERVERS, ...opts.servers };
this.spawnFn = opts.spawn ?? lspSpawnWithEnv(opts.env, opts.onNotice);
this.readTextFn = opts.readText ?? defaultLspReadText;
this.resolveRootFn = opts.resolveRoot ?? defaultResolveRoot;
this.log = opts.log ?? (() => { });
this.warmup = opts.warmup;
this.diagnostics = opts.diagnostics ?? new LspDiagnosticsRegistry();
this.maxSessions = Math.max(1, opts.maxSessions ?? 16);
this.idleTtlMs = Math.max(0, opts.idleTtlMs ?? 600_000);
if (this.idleTtlMs > 0) {
this.sweepTimer = setInterval(() => void this.sweepIdle(), Math.min(this.idleTtlMs, 60_000));
this.sweepTimer.unref?.();
}
}
sweepIdle() {
const cutoff = Date.now() - this.idleTtlMs;
for (const [key, e] of [...this.cache]) {
if (e.session.inFlight > 0) {
e.lastUsed = Date.now();
continue;
}
if (e.lastUsed < cutoff) {
this.cache.delete(key);
this.log("lsp_evict_idle", { key });
void e.session.close().catch(() => undefined);
}
}
}
async sessionFor(filePath, signal, env) {
const language = languageFor(filePath);
if (!language || !this.serverCmd(language))
return undefined;
const root = this.resolveRootFn(filePath, env);
const key = `${language} ${root}`;
const cached = this.cache.get(key);
if (cached && !cached.session.closed) {
cached.lastUsed = Date.now();
return cached.session;
}
const neg = this.failed.get(key);
if (neg !== undefined) {
neg.attempts += 1;
return undefined;
}
if (cached) {
this.cache.delete(key);
const carry = cached.session.openedFiles();
this.log("lsp_heal", { language, root, carried: carry.length });
return this.open(key, language, root, signal, env, carry);
}
return this.open(key, language, root, signal, env);
}
open(key, language, root, signal, env, carry = []) {
const inflight = this.opening.get(key);
if (inflight && !inflight.scope.signal.aborted) {
inflight.scope.join(signal);
return settleOnAbort(inflight.job, signal, () => undefined);
}
const scope = new SharedAbortScope();
scope.join(signal);
const entry = { root, cancelled: false, scope, job: undefined };
entry.job = this.openSession(language, root, scope.signal, env)
.then(async (session) => {
if (!session)
return undefined;
if (this.disposed || entry.cancelled) {
await session.close().catch(() => undefined);
return undefined;
}
if (carry.length) {
const carried = await session.warmOpen(carry, scope.signal).catch(() => ({ opened: 0, failed: carry.length }));
this.log("lsp_heal_carried", { language, root, ...carried });
if (this.disposed || entry.cancelled) {
await session.close().catch(() => undefined);
return undefined;
}
}
this.evictOverCap();
this.cache.set(key, { session, root, lastUsed: Date.now() });
return session;
})
.finally(() => {
if (this.opening.get(key) === entry)
this.opening.delete(key);
});
this.opening.set(key, entry);
return settleOnAbort(entry.job, signal, () => undefined);
}
evictOverCap() {
while (this.cache.size >= this.maxSessions) {
let lruKey;
let lruTime = Infinity;
for (const [k, e] of this.cache) {
if (e.session.inFlight > 0)
continue;
if (e.lastUsed < lruTime) {
lruTime = e.lastUsed;
lruKey = k;
}
}
if (lruKey === undefined) {
this.log("lsp_evict_deferred", { size: this.cache.size, maxSessions: this.maxSessions });
break;
}
const evicted = this.cache.get(lruKey);
this.cache.delete(lruKey);
this.log("lsp_evict_lru", { key: lruKey });
void evicted.session.close().catch(() => undefined);
}
}
async evict(root) {
const closing = [];
this.clearFailed(root);
for (const [key, e] of [...this.cache]) {
if (e.root === root) {
this.cache.delete(key);
this.log("lsp_evict", { root });
closing.push(e.session.close().catch(() => undefined));
}
}
for (const o of this.opening.values()) {
if (o.root === root) {
o.cancelled = true;
closing.push(o.job.then(() => undefined).catch(() => undefined));
}
}
await Promise.all(closing);
}
clearFailed(root) {
for (const [key, e] of [...this.failed]) {
if (root !== undefined && e.root !== root)
continue;
this.failed.delete(key);
this.initFailures.delete(key);
this.log("lsp_negative_cache_cleared", { key, attempts: e.attempts, kind: e.kind });
}
for (const [key, _n] of [...this.initFailures]) {
if (root === undefined || key.endsWith(` ${root}`))
this.initFailures.delete(key);
}
}
unavailableReason(filePath, env) {
const language = languageFor(filePath);
if (!language)
return undefined;
const cmd = this.serverCmd(language);
if (!cmd)
return `no ${language} language server is configured in this environment`;
const e = this.failed.get(`${language} ${this.resolveRootFn(filePath, env)}`);
if (e?.kind === "missing_binary")
return `the ${language} language server ("${e.command}") is not installed on this host`;
if (e?.kind === "initialize")
return `the ${language} language server ("${e.command}") is installed but failed to initialize`;
return undefined;
}
async openSession(language, root, signal, _env) {
const cmd = this.serverCmd(language);
if (!cmd)
return undefined;
const tokens = (cmd.match(/"[^"]*"|\S+/g) ?? []).map((t) => (t.startsWith('"') && t.endsWith('"') && t.length >= 2 ? t.slice(1, -1) : t));
const [command, ...args] = tokens.length > 0 ? tokens : [""];
const key = `${language} ${root}`;
let child;
try {
child = await this.spawnFn(command, args, root, signal);
}
catch (e) {
this.log("lsp_spawn_error", { language, root, command, err: thrownText(e) });
return undefined;
}
if (!child) {
if (signal?.aborted !== true)
this.failed.set(key, { root, attempts: 1, kind: "missing_binary", command, language });
this.log("lsp_spawn_degraded", { language, root, command });
return undefined;
}
const transport = new StdioLspTransport(child);
try {
const rootUri = pathToUri(root);
await transport.request("initialize", {
processId: null,
rootPath: root,
rootUri,
workspaceFolders: [{ uri: rootUri, name: basename(root) || root }],
capabilities: CLIENT_CAPABILITIES,
}, signal);
transport.notify("initialized", {});
}
catch (e) {
await transport.close();
const attempt = (this.initFailures.get(key) ?? 0) + 1;
const latched = attempt >= INIT_FAILURE_LATCH && signal?.aborted !== true;
if (latched) {
this.failed.set(key, { root, attempts: 1, kind: "initialize", command, language });
this.initFailures.delete(key);
}
else
this.initFailures.set(key, attempt);
this.log("lsp_init_degraded", { language, root, err: thrownText(e), attempt, latched });
return undefined;
}
this.initFailures.delete(key);
this.log("lsp_open", { language, root });
return new TransportLspSession(transport, language, this.readTextFn, this.log, this.warmup, this.diagnostics);
}
serverCmd(language) {
const cmd = this.servers[language];
return cmd ? cmd : undefined;
}
async dispose() {
this.disposed = true;
if (this.sweepTimer)
clearInterval(this.sweepTimer);
const opening = [...this.opening.values()].map((o) => o.job.catch(() => undefined));
const sessions = [...this.cache.values()].map((e) => e.session);
this.cache.clear();
await Promise.all([...sessions.map((s) => s.close().catch(() => undefined)), ...opening]);
}
}
export function languageFor(filePath) {
return EXT_LANG[extname(filePath).toLowerCase()];
}
function defaultResolveRoot(filePath, env) {
if (env?.cwd)
return env.cwd;
let dir = dirname(resolvePath(filePath));
for (;;) {
for (const m of ROOT_MARKERS)
if (existsSync(resolvePath(dir, m)))
return dir;
const parent = dirname(dir);
if (parent === dir)
break;
dir = parent;
}
return dirname(resolvePath(filePath));
}
export { MAX_LSP_FILE_BYTES } from "../../core/lsp.js";
export const defaultLspReadText = async (fp, signal) => {
const st = await stat(fp);
if (!st.isFile())
throw new Error(`not a regular file: ${fp}`);
if (st.size > MAX_LSP_FILE_BYTES)
throw new Error(`file too large for LSP analysis (${st.size} bytes exceeds ${MAX_LSP_FILE_BYTES})`);
const flags = fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0);
const fh = await open(fp, flags);
try {
const fst = await fh.stat();
if (!fst.isFile())
throw new Error(`not a regular file: ${fp}`);
if (fst.size > MAX_LSP_FILE_BYTES)
throw new Error(`file too large for LSP analysis (${fst.size} bytes exceeds ${MAX_LSP_FILE_BYTES})`);
return await readFile(fh, { encoding: "utf8", signal });
}
finally {
await fh.close().catch(() => undefined);
}
};
export function lspSpawnOptions(cwd) {
return process.platform === "win32"
? { cwd, stdio: ["pipe", "pipe", "ignore"], shell: true }
: { cwd, stdio: ["pipe", "pipe", "ignore"] };
}
export const defaultLspSpawn = (command, args, cwd, signal) => lspSpawnWithEnv(undefined)(command, args, cwd, signal);
const lspSpawnWithEnv = (env, onNotice) => (command, args, cwd, signal) => new Promise((resolve, reject) => {
let child;
try {
let scrubbedEnv = env;
if (scrubbedEnv == null) {
const findings = [];
scrubbedEnv = scrubSecretEnv(process.env, findings);
announceSecretEnvScrub(onNotice, findings);
}
child = spawn(command, args, { ...lspSpawnOptions(cwd), env: scrubbedEnv });
}
catch (e) {
return reject(asThrownError(e));
}
child.on("error", () => { });
const onSpawn = () => {
cleanup();
resolve(child);
};
const onError = (err) => {
cleanup();
if (err?.code === "ENOENT")
resolve(undefined);
else
reject(asThrownError(err));
};
const onAbort = () => {
cleanup();
try {
if (child.pid === undefined)
return resolve(undefined);
if (process.platform === "win32")
killProcessTree(child.pid, { force: true });
else
child.kill();
}
catch {
}
resolve(undefined);
};
const cleanup = () => {
child.removeListener("spawn", onSpawn);
child.removeListener("error", onError);
signal?.removeEventListener("abort", onAbort);
};
child.once("spawn", onSpawn);
child.once("error", onError);
if (signal) {
if (signal.aborted)
return onAbort();
signal.addEventListener("abort", onAbort, { once: true });
}
});