@sema-agent/core
Version:
Stateless, task-oriented AI agent core
97 lines (96 loc) • 6.76 kB
TypeScript
import type { LspSession, LspOperation, LspRequestParams, LspResult, LspTransport, LspReadText } from "./lsp.js";
import type { LspDiagnosticsRegistry } from "./lsp-diagnostics.js";
export interface SessionWarmup {
/** Empty results within this window after session creation are retried (a freshly-spawned server answers
* non-blocking ops with EMPTY results while its project is still loading — the model may call
* hover/documentSymbol FIRST and get non-null-but-empty answers; goToDefinition happens to block on load,
* which is why op-order made it look random). 0 disables. */
windowMs: number;
retryMs: number;
}
export declare class TransportLspSession implements LspSession {
private readonly transport;
private readonly languageId;
private readonly readText;
/** Observability hook for swallowed degrades (graceful "none" hides real transport faults otherwise). */
private readonly log;
private readonly warmup;
/** filePath → the last text SENT to the server (didOpen/didChange) + its document version. */
private readonly opened;
/** HRD-LSP-19: the URI we actually SENT for each opened file → that file's `opened` key. The diagnostics
* staleness guard used to re-derive the key with `uriToPath(publishedUri)`, which normalizes a drive path
* to backslash form regardless of how the caller spelled it — so with any other spelling the lookup missed
* and a stale (older-version) publish sailed through the guard onto the model's screen. Keying on the URI
* we ourselves sent removes the round-trip entirely (`uriToPath` stays the fallback for a server that
* re-spells the URI). */
private readonly openedUris;
/** In-flight per-file syncs — concurrent ops on the same new file share ONE read/didOpen. Each entry owns a
* {@link SharedAbortScope} so ONE caller's abort no longer cancels the read the others are waiting on
* (HRD-LSP-12). */
private readonly syncing;
private readonly createdAt;
/** HRD-LSP-11: the warm-up barrier as a shared JOB, not a boolean flag set before the await (which let the
* second concurrent first-op skip the barrier entirely, and permanently disabled it whenever the barrier
* request failed or was cancelled). */
private warmupBarrier?;
private warmupDone;
private warmupAttempts;
constructor(transport: LspTransport, languageId: string, readText: LspReadText,
/** Observability hook for swallowed degrades (graceful "none" hides real transport faults otherwise). */
log?: (event: string, fields: Record<string, unknown>) => void, warmup?: SessionWarmup,
/** design/121: when set (and the transport dispatches notifications), the session feeds this
* registry from `textDocument/publishDiagnostics` — with version-staleness protection: a
* notification versioned OLDER than the text this session last SENT for that file is dropped
* (the server is still churning on stale content). Absent ⇒ pre-121 behavior byte-for-byte. */
diagnostics?: LspDiagnosticsRegistry);
/** HRD-LSP-16: ops in flight on this session. The manager consults it before LRU/idle-evicting a server —
* closing one mid-op turns a healthy answer into `server_terminated` for a caller that did nothing wrong. */
get inFlight(): number;
private activeRequests;
request(op: LspOperation, params: LspRequestParams, signal?: AbortSignal): Promise<LspResult>;
private requestOnce;
/**
* HRD-LSP-11: run (or join) this session's ONE warm-up barrier.
*
* The old form set a boolean BEFORE awaiting the barrier request, so a second concurrent first-op read
* `true` and fired its real request into a still-loading project (an empty answer retries, but a PARTIAL
* non-empty symbol/reference list is accepted as complete — the silent half). And because the flag was
* never cleared, any failure/abort of that one request disabled the barrier for the session's whole life.
* Now: joiners await the same job, a failed barrier is re-armed (bounded by {@link WARMUP_MAX_ATTEMPTS}),
* and one caller's abort neither cancels the shared barrier nor makes the others wait for it.
*/
private warmupBarrierOnce;
/** Re-sync every opened file (+ the queried one) with its CURRENT on-disk text, in parallel. Returns the
* QUERIED file's outcome — the one the op's honesty depends on (HRD-LSP-7). */
private syncOpenedFiles;
/** didOpen a new file / didChange (full-text replace) an opened one whose disk content moved. Never throws:
* it REPORTS failure instead (`{ok:false}`) — an unreadable file no longer looks like a clean sync to the
* caller. Concurrent callers share one in-flight sync, each keeping its OWN cancellation (HRD-LSP-12). */
private syncOne;
/** Structural fallback classification for errors carrying no {@link lspFailureOf} brand (a
* third-party transport, e.g. the TOB WS lane) — abort signal and transport liveness are the only
* transport-neutral facts available; everything else is a server fault. Cannot produce
* "unsupported_operation" until that transport brands its JSON-RPC error codes (cross-repo slip). */
private static classifyUnbranded;
/** Underlying connection gone (e.g. the server crashed / the WS was idle-killed) → the manager evicts + reopens. */
get closed(): boolean;
/** Files didOpen'd on this server — the manager carries them to a HEALED session: a fresh server with no
* project config only "sees" its open files (e.g. tsserver inferred project), so without the carry-over,
* cross-file ops (references/calls) silently degrade to single-file after a heal. */
openedFiles(): string[];
/** Re-open carried files on a fresh session (heal). Best-effort per file (a deleted file just skips).
* Returns how many files did NOT make it, so the manager's heal log can say when a carry-over was lost
* instead of leaving a silently single-file server behind (HRD-LSP-1 disclosure half). */
warmOpen(filePaths: string[], signal?: AbortSignal): Promise<{
opened: number;
failed: number;
}>;
/**
* design/121: push the file's CURRENT text to the server WITHOUT an op (didOpen/didChange as
* appropriate). The agent's edit/write tools call this fire-and-forget so the server re-analyzes
* and pushes publishDiagnostics — the session's normal lazy sync only runs on the next LSP op,
* which may never come. Never throws (same best-effort contract as syncOne).
*/
notifyFileChanged(filePath: string, signal?: AbortSignal): Promise<void>;
close(): Promise<void>;
}