UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

278 lines (277 loc) • 12.3 kB
import { uriToPath, buildRequest, callHierarchyMethod, parseResult, pathToUri } from "./lsp-protocol.js"; import { lspFailureOf, MAX_LSP_FILE_BYTES, SharedAbortScope, settleOnAbort } from "./lsp.js"; import { thrownText } from "./thrown-value.js"; const UTF8 = new TextDecoder("utf-8", { ignoreBOM: true }); async function readHeldText(held, signal) { const bytes = await held.readBytes(MAX_LSP_FILE_BYTES, signal); if (!bytes.ok) throw bytes.error; return UTF8.decode(bytes.value); } const SUPERSEDED = Symbol("superseded"); const WARMUP_MAX_ATTEMPTS = 3; export class TransportLspSession { transport; languageId; readText; log; warmup; opened = new Map(); openedUris = new Map(); carried = new Set(); syncGen = new Map(); syncing = new Map(); createdAt = Date.now(); warmupBarrier; warmupDone = false; warmupAttempts = 0; constructor(transport, languageId, readText, log = () => { }, warmup = { windowMs: 10_000, retryMs: 1_000 }, diagnostics) { this.transport = transport; this.languageId = languageId; this.readText = readText; this.log = log; this.warmup = warmup; if (diagnostics && transport.onNotification) { transport.onNotification((method, params) => { if (method !== "textDocument/publishDiagnostics") return; const p = params; if (!p || typeof p.uri !== "string" || !Array.isArray(p.diagnostics)) return; if (typeof p.version === "number") { const filePath = this.openedUris.get(p.uri) ?? uriToPath(p.uri); const known = this.opened.get(filePath); if (known && p.version < known.version) { this.log("lsp_diagnostics_stale_dropped", { uri: p.uri, version: p.version, current: known.version }); return; } } diagnostics.publish(p.uri, p.diagnostics); }); } } get inFlight() { return this.activeRequests; } activeRequests = 0; async request(op, params, signal, gate) { this.activeRequests += 1; try { return await this.requestOnce(op, params, signal, gate); } finally { this.activeRequests -= 1; } } async requestOnce(op, params, signal, gate) { const built = buildRequest(op, params); if (!built) return { kind: "none", reason: "unsupported_operation" }; const sync = await this.syncOpenedFiles(params.filePath, signal, gate); if (!sync.ok) { const reason = signal?.aborted ? "cancelled" : (sync.reason ?? "file_sync_failed"); this.log("lsp_op_degraded", { op, file: params.filePath, reason, err: sync.error }); return { kind: "none", reason, ...(reason === "file_sync_failed" ? { detail: sync.error } : {}) }; } if (this.warmup.windowMs > 0 && !this.warmupDone) { if (op === "goToDefinition") this.warmupDone = true; else await this.warmupBarrierOnce(params.filePath, signal); } for (let attempt = 0;; attempt++) { let raw; let noHierarchyItem = false; try { raw = await this.transport.request(built.method, built.params, signal); if (op === "incomingCalls" || op === "outgoingCalls") { const items = Array.isArray(raw) ? raw : raw != null ? [raw] : []; noHierarchyItem = items.length === 0; raw = noHierarchyItem ? null : await this.transport.request(callHierarchyMethod(op), { item: items[0] }, signal); } } catch (e) { const f = lspFailureOf(e) ?? TransportLspSession.classifyUnbranded(signal, this.transport); this.log("lsp_op_degraded", { op, file: params.filePath, reason: f.reason, err: thrownText(e) }); return { kind: "none", reason: f.reason, ...(f.detail !== undefined ? { detail: f.detail } : {}) }; } const result = noHierarchyItem ? { kind: "none", reason: "no_call_hierarchy_item" } : parseResult(op, raw, pathToUri(params.filePath)); if (result.kind === "none" && this.warmup.windowMs > 0 && Date.now() - this.createdAt < this.warmup.windowMs && !signal?.aborted) { await new Promise((r) => setTimeout(r, this.warmup.retryMs)); continue; } this.log("lsp_op", { op, file: params.filePath, ...(params.line !== undefined ? { line: params.line } : {}), ...(params.character !== undefined ? { character: params.character } : {}), result: result.kind, n: result.kind === "locations" ? result.locations.length : result.kind === "symbols" ? result.symbols.length : undefined, rawNull: raw == null, ...(attempt > 0 ? { warmupRetries: attempt } : {}), }); return result; } } async warmupBarrierOnce(filePath, signal) { let entry = this.warmupBarrier; if (entry && entry.scope.signal.aborted) entry = undefined; if (!entry) { if (this.warmupAttempts >= WARMUP_MAX_ATTEMPTS) return; this.warmupAttempts += 1; const scope = new SharedAbortScope(); const started = {}; const job = this.transport .request("textDocument/definition", { textDocument: { uri: pathToUri(filePath) }, position: { line: 0, character: 0 } }, scope.signal) .then(() => { this.warmupDone = true; }) .catch(() => { if (this.warmupBarrier === started.self) this.warmupBarrier = undefined; }); entry = { job, scope }; started.self = entry; this.warmupBarrier = entry; } entry.scope.join(signal); await settleOnAbort(entry.job, signal, () => undefined); } async syncOpenedFiles(queried, signal, gate) { const files = new Set([...this.opened.keys(), ...this.carried]); files.add(queried); const outcomes = await Promise.all([...files].map(async (fp) => [fp, await this.syncGated(fp, signal, gate)])); return outcomes.find(([fp]) => fp === queried)?.[1] ?? { ok: true }; } async syncGated(filePath, signal, gate) { const outcome = await this.syncInTurn(filePath, signal, gate); if (outcome.ok || signal?.aborted !== true) this.carried.delete(filePath); return outcome; } syncInTurn(filePath, signal, gate) { const aborted = () => ({ ok: false, error: "aborted by the caller" }); const newest = this.syncing.get(filePath); if (gate === undefined && newest?.byName === true && !newest.scope.signal.aborted) { newest.scope.join(signal); return settleOnAbort(newest.job, signal, aborted); } const gen = (this.syncGen.get(filePath) ?? 0) + 1; this.syncGen.set(filePath, gen); const isNewest = () => this.syncGen.get(filePath) === gen; const scope = new SharedAbortScope(); scope.join(signal); const entry = { scope, byName: gate === undefined, job: Promise.resolve({ ok: true }) }; entry.job = (async () => { let published; if (gate === undefined) published = await this.publishRead(filePath, () => this.readText(filePath, scope.signal), isNewest); else { const admission = await gate(filePath, scope.signal).catch((e) => ({ ok: false, reason: `the read boundary could not judge this file (${thrownText(e)})` })); if (!admission.ok) { if (isNewest() && !scope.signal.aborted) this.closeRefused(filePath); return { ok: false, error: admission.reason }; } const held = admission.object; published = await this.publishRead(filePath, () => readHeldText(held, scope.signal).finally(() => held.close().catch(() => undefined)), isNewest); } if (published !== SUPERSEDED) return published; if (scope.signal.aborted) return aborted(); const successor = this.syncing.get(filePath); if (successor !== undefined && successor !== entry && !successor.scope.signal.aborted) { successor.scope.join(scope.signal); return successor.job; } return this.syncInTurn(filePath, scope.signal, gate); })(); this.syncing.set(filePath, entry); const settled = entry.job.finally(() => { if (this.syncing.get(filePath) === entry) this.syncing.delete(filePath); }); return settleOnAbort(settled, signal, aborted); } closeRefused(filePath) { this.carried.delete(filePath); if (!this.opened.has(filePath)) return; const uri = pathToUri(filePath); this.opened.delete(filePath); this.openedUris.delete(uri); this.transport.notify("textDocument/didClose", { textDocument: { uri } }); this.log("lsp_doc_closed_refused", { file: filePath }); } async publishRead(filePath, read, isNewest) { let text; try { text = await read(); } catch (e) { const error = thrownText(e); this.log("lsp_sync_skipped", { file: filePath, error, opened: this.opened.has(filePath) }); return { ok: false, error }; } if (!isNewest()) return SUPERSEDED; const cur = this.opened.get(filePath); const uri = pathToUri(filePath); if (!cur) { this.transport.notify("textDocument/didOpen", { textDocument: { uri, languageId: this.languageId, version: 1, text }, }); if (this.transport.closed === true) { this.log("lsp_sync_notify_dropped", { file: filePath, kind: "didOpen" }); return { ok: false, error: "didOpen was not delivered (transport closed)", reason: "server_terminated" }; } this.opened.set(filePath, { version: 1, text }); this.openedUris.set(uri, filePath); } else if (cur.text !== text) { const version = cur.version + 1; this.transport.notify("textDocument/didChange", { textDocument: { uri, version }, contentChanges: [{ text }] }); if (this.transport.closed === true) { this.log("lsp_sync_notify_dropped", { file: filePath, kind: "didChange", version }); return { ok: false, error: "didChange was not delivered (transport closed)", reason: "server_terminated" }; } this.opened.set(filePath, { version, text }); this.log("lsp_file_resync", { file: filePath, version }); } return { ok: true }; } static classifyUnbranded(signal, transport) { if (signal?.aborted) return { reason: "cancelled" }; if (transport.closed === true) return { reason: "server_terminated" }; return { reason: "server_error" }; } get closed() { return this.transport.closed === true; } openedFiles() { return [...new Set([...this.opened.keys(), ...this.carried])]; } async warmOpen(filePaths, _signal) { let deferred = 0; for (const fp of filePaths) { if (this.opened.has(fp) || this.carried.has(fp)) continue; this.carried.add(fp); deferred += 1; } return { opened: 0, failed: 0, deferred }; } notifyFileChanged(filePath, signal, gate) { return this.syncGated(filePath, signal, gate).then(() => undefined); } close() { return this.transport.close(); } }