@sema-agent/server 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -353,7 +353,14 @@ export function createExecutionEnv(ctx) {
353
353
  // (no on-the-fly npm path on Kata; a miss degrades the lsp tool gracefully, same as E2B).
354
354
  const lspProvider = config.remoteExec?.provider;
355
355
  const lspManager = (lspProvider === "e2b" || lspProvider === "k8s") && config.lspEnabled
356
- ? createE2bLspManager({ log: (event, fields) => logger.info(event, fields), scheme: lspProvider === "k8s" ? "ws" : "wss" })
356
+ ? createE2bLspManager({
357
+ log: (event, fields) => logger.info(event, fields),
358
+ scheme: lspProvider === "k8s" ? "ws" : "wss",
359
+ // §DESIGN-V2 F11: pass the lane explicitly — the bridge metrics' `provider` label names the
360
+ // DEPLOYMENT lane, not the WS scheme (the two happen to correlate today but are different axes).
361
+ provider: lspProvider,
362
+ metrics,
363
+ })
357
364
  : // TOC local LSP (core 1.190): the host lane runs on THIS machine, so core's `NodeLspManager` spawns the
358
365
  // language server as a LOCAL child_process over stdio (CC `services/lsp` parity). Its default resolveRoot uses
359
366
  // `env.cwd` — and core passes each task's executionEnv (the per-agent WORKTREE env, so the server roots in the
@@ -466,6 +466,22 @@ export declare function validateUserSkills(skills: unknown): string | null;
466
466
  export declare function createHttpServer(rawDeps: ServiceDeps): http.Server & {
467
467
  denyExpiredApprovals: (now: number) => Promise<void>;
468
468
  };
469
- /** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`). */
469
+ /** SSE: replay durable events after Last-Event-ID, then tail until the run is terminal (or stale). */
470
+ /**
471
+ * The differences a concrete log (task_run | image_bake) feeds the ONE resumable SSE reader (P2.8). Everything
472
+ * the reader does — Last-Event-ID/`?from=` resume, the 416 retention boundary, the per-poll concurrent
473
+ * status+events read, the terminal re-fetch (the terminal event lands in the gap before the status flips), the
474
+ * stale fallback, the 15-min cap, the 15s idle heartbeat — is provider-agnostic and lives in `streamSseLog`.
475
+ * 🔴 The task_run provider MUST keep the existing wire bytes EXACTLY (center's relay + 730+ tests depend on it).
476
+ */
477
+ /** POST endpoints that trigger BILLABLE work — the fail-closed auth guard must cover ALL of them (council: the
478
+ * guard's inline list had drifted from the handlers and missed `/v1/approvals/:id/decide`, which resumes a run
479
+ * via resumeCheckpoint/store.decide → paid tokens). Keep this in sync when adding a billable POST route. */
480
+ export declare function isBillableSubmitPath(url: string): boolean;
481
+ /** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`).
482
+ * [#104] 字面量**先于**模式:此前模式先查,五条精确路由被形状桶吞掉(`/v1/approvals/stream`
483
+ * 落 ":id"、`/v1/images/{bakes,select,register}` 落 ":profile"、`/v1/images/bakes/claim` 落
484
+ * "bakes/:id")——长连接 stream 的 duration 混进 :id 桶正是 fleet/stream 案的同族错桶病。
485
+ * 精确匹配恒比形状匹配更对,序修零反例(billable-route-declaration 门逐样本钉)。 */
470
486
  export declare function routeLabel(_method: string, url: string): string;
471
487
  //# sourceMappingURL=server.d.ts.map
@@ -2427,7 +2427,9 @@ export function createHttpServer(rawDeps) {
2427
2427
  /** POST endpoints that trigger BILLABLE work — the fail-closed auth guard must cover ALL of them (council: the
2428
2428
  * guard's inline list had drifted from the handlers and missed `/v1/approvals/:id/decide`, which resumes a run
2429
2429
  * via resumeCheckpoint/store.decide → paid tokens). Keep this in sync when adding a billable POST route. */
2430
- function isBillableSubmitPath(url) {
2430
+ // [#104/#87-A3] exported for the declaration gate (test/billable-route-declaration.test.ts):每条路由
2431
+ // 标签必须显式申明 billable,与本谓词逐样本对账——「Keep this in sync」从注释请求变成机器断言。
2432
+ export function isBillableSubmitPath(url) {
2431
2433
  return (url === "/v1/side-query" || // [1469] one-shot brain call — runs the model, so it rides every billable-submit gate
2432
2434
  url === "/v1/tasks" ||
2433
2435
  url === "/v1/tasks/stream" ||
@@ -2503,13 +2505,17 @@ const ROUTE_LABEL_LITERALS = new Set([
2503
2505
  "/v1/fleet/stream",
2504
2506
  "/v1/memory/export", "/v1/sendfile-links",
2505
2507
  ]);
2506
- /** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`). */
2508
+ /** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`).
2509
+ * [#104] 字面量**先于**模式:此前模式先查,五条精确路由被形状桶吞掉(`/v1/approvals/stream`
2510
+ * 落 ":id"、`/v1/images/{bakes,select,register}` 落 ":profile"、`/v1/images/bakes/claim` 落
2511
+ * "bakes/:id")——长连接 stream 的 duration 混进 :id 桶正是 fleet/stream 案的同族错桶病。
2512
+ * 精确匹配恒比形状匹配更对,序修零反例(billable-route-declaration 门逐样本钉)。 */
2507
2513
  export function routeLabel(_method, url) {
2514
+ if (ROUTE_LABEL_LITERALS.has(url))
2515
+ return url;
2508
2516
  for (const [re, label] of ROUTE_LABEL_PATTERNS)
2509
2517
  if (re.test(url))
2510
2518
  return label;
2511
- if (ROUTE_LABEL_LITERALS.has(url))
2512
- return url;
2513
2519
  return "other";
2514
2520
  }
2515
2521
  /** CORS v2: allowlist semantics. A SINGLE configured origin keeps the v1 posture
@@ -3,16 +3,33 @@
3
3
  * **auth token** `LSP_TOKEN` (the endpoint is a public `wss://<port>-<id>.e2b.app` URL — the runner connects with
4
4
  * `?token=<secret>`; without it, anyone with the URL could read the workspace via LSP, council #4). Frame parsing
5
5
  * uses the unit-tested {@link FRAME_DECODER_JS} (council #1/#2/#5).
6
+ *
7
+ * redesign⑤ (#96 §DESIGN-V2 F4): the WS server now rides an explicit `http.createServer` instead of binding its
8
+ * own socket (`new WebSocketServer({host,port})`) — the same port also answers `GET /healthz` (host-side liveness
9
+ * probe, §DESIGN-V2 F2/C6) without a second port or dependency. The EADDRINUSE loud-exit handler MOVES with the
10
+ * bind onto the http server (`server.on('error', …)`, same `lsp-bridge wss error` text + `exit 1` — test/lsp-e2b.test.ts
11
+ * :256-269 pins this exact text/code); `ws` still forwards its underlying server's `'error'` to `wss`, so nothing
12
+ * downstream (per-connection handling) changes.
6
13
  */
7
14
  export declare const BRIDGE_SOURCE: string;
15
+ /** Bridge dir inside the sandbox(声明提前:tsserver 定值路径引用它)。 */
16
+ export declare const BRIDGE_DIR = "/home/user/.lsp-bridge";
17
+ /** tls 的 tsserver.js 定值路径(#96 真机门):install 腿保证在场,connect 腿经
18
+ * `initializationOptions.tsserver.path` 告知 tls(v4 起无 CLI 旗可用)。 */
19
+ export declare const TSSERVER_JS_PATH = "/home/user/.lsp-bridge/node_modules/typescript/lib/tsserver.js";
8
20
  /** Language server command per languageId. P1 = typescript/javascript; dart/kotlin land with their template servers.
9
21
  * `--stdio` only where the server needs it to pick stdio over its other transports (the node-based servers);
10
- * gopls/jdtls/kotlin-language-server/sourcekit-lsp/`dart language-server` speak LSP over stdio by default. */
22
+ * gopls/jdtls/kotlin-language-server/sourcekit-lsp/`dart language-server` speak LSP over stdio by default.
23
+ * #96 真机门发现(E2B 真沙箱逐帧取证,test/lsp-e2b-bridge-live.test.ts):typescript-language-server
24
+ * **不自动发现全局 typescript**(只认 workspace 内 node_modules/typescript 或显式 tsserver 路径),
25
+ * `npm i -g … typescript` 装成之后 initialize 仍答 "Could not find a valid TypeScript installation";
26
+ * 且 v4 起 `--tsserver-path` CLI 旗已移除(未知参数=进程退 1,第二轮真机实测)——路径只能走 LSP
27
+ * `initializationOptions.tsserver.path`(TSSERVER_JS_PATH,connectE2bLspBridge 对 ts/js 传入),
28
+ * typescript 本体由 lspInstallCommand 落进 BRIDGE_DIR 定值路径(烤好模板文件在场则秒过)。 */
11
29
  export declare const SERVER_CMD: Readonly<Record<string, string>>;
12
30
  /** npm packages a non-baked sandbox needs (on-the-fly install). Languages absent here (go) REQUIRE a baked template. */
13
31
  export declare const SERVER_NPM: Readonly<Record<string, string>>;
14
- /** Bridge dir + port inside the sandbox. One bridge process per language → distinct ports. */
15
- export declare const BRIDGE_DIR = "/home/user/.lsp-bridge";
32
+ /** One bridge process per language → distinct ports.(BRIDGE_DIR 声明已提前至 SERVER_CMD 上方。) */
16
33
  export declare function portForLanguage(language: string): number;
17
34
  /** FOREGROUND install (run via `env.exec` — must finish before the start). A baked template makes both checks
18
35
  * skip instantly; a non-baked sandbox npm-installs the server + ws here (slow once per sandbox). */
@@ -21,4 +38,20 @@ export declare function lspInstallCommand(language: string): string;
21
38
  * foreground run: E2B reaps the foreground process group on completion, killing an in-shell `&`/nohup child.
22
39
  * So node is the FOREGROUND here and E2B keeps the background command alive. */
23
40
  export declare function lspStartCommand(language: string, port: number, token: string): string;
41
+ /** Where the on-the-fly `npm i` output from {@link lspInstallCommand} lands — previously had zero readers
42
+ * (§DESIGN-V2 F7 bonus fix); an install failure now tails this for the `lastError`/event snippet. */
43
+ export declare const LSP_SETUP_LOG = "/tmp/lsp-setup.log";
44
+ /** BRIDGE_DIR-relative log the bridge process itself appends to (fatal errors, child exits) — §DESIGN-V2 C4. */
45
+ export declare function bridgeLogFile(): string;
46
+ /** Per-port pidfile the bridge writes ONLY after a confirmed bind (BRIDGE_SOURCE's `listen` callback) — see its
47
+ * header comment for why a losing (EADDRINUSE) second bridge must never clobber this. */
48
+ export declare function bridgePidFile(port: number): string;
49
+ /** §DESIGN-V2 F3: verify a prior bridge generation ACTUALLY stopped before the host mints a replacement.
50
+ * Exit 0 = confirmed dead (or the pidfile never existed — nothing was ever listening) → safe to start a new
51
+ * generation on this port. Exit 1 = still alive after the bound wait — the caller MUST NOT start a new bridge:
52
+ * an unconfirmed kill risks EADDRINUSE (the new generation's fresh token would then be permanently rejected by
53
+ * the still-listening old process, the token check in BRIDGE_SOURCE) — better to stay `dead` than double-burn
54
+ * the restart budget on a doomed attempt. POSIX `sh` only (no `seq`/bashisms — sandboxes vary in shell).
55
+ * Bound: 20 × 0.1s = 2s. */
56
+ export declare function terminateBridgeCommand(port: number): string;
24
57
  //# sourceMappingURL=e2b-bridge.d.ts.map
@@ -14,23 +14,60 @@ import { FRAME_DECODER_JS } from "./lsp-frames.js";
14
14
  * **auth token** `LSP_TOKEN` (the endpoint is a public `wss://<port>-<id>.e2b.app` URL — the runner connects with
15
15
  * `?token=<secret>`; without it, anyone with the URL could read the workspace via LSP, council #4). Frame parsing
16
16
  * uses the unit-tested {@link FRAME_DECODER_JS} (council #1/#2/#5).
17
+ *
18
+ * redesign⑤ (#96 §DESIGN-V2 F4): the WS server now rides an explicit `http.createServer` instead of binding its
19
+ * own socket (`new WebSocketServer({host,port})`) — the same port also answers `GET /healthz` (host-side liveness
20
+ * probe, §DESIGN-V2 F2/C6) without a second port or dependency. The EADDRINUSE loud-exit handler MOVES with the
21
+ * bind onto the http server (`server.on('error', …)`, same `lsp-bridge wss error` text + `exit 1` — test/lsp-e2b.test.ts
22
+ * :256-269 pins this exact text/code); `ws` still forwards its underlying server's `'error'` to `wss`, so nothing
23
+ * downstream (per-connection handling) changes.
17
24
  */
18
25
  export const BRIDGE_SOURCE = FRAME_DECODER_JS +
19
26
  String.raw `
20
27
  const { WebSocketServer } = require('ws');
21
28
  const { spawn } = require('child_process');
22
29
  const { URLSearchParams } = require('url');
30
+ const http = require('http');
31
+ const fs = require('fs');
23
32
  const PORT = Number(process.env.LSP_PORT || 8123);
24
33
  const CMD = (process.env.LSP_CMD || 'typescript-language-server --stdio').split(' ');
25
34
  const TOKEN = process.env.LSP_TOKEN || '';
26
35
  const MAX = 50 * 1024 * 1024;
27
36
  const HEARTBEAT_MS = Number(process.env.LSP_HEARTBEAT_MS || 30000);
28
- const wss = new WebSocketServer({ host: '0.0.0.0', port: PORT });
37
+ const START_MS = Date.now();
38
+ // Per-port pidfile (BRIDGE_DIR-relative — lspStartCommand cd's there first): the host reads this to verify a
39
+ // prior generation ACTUALLY stopped before minting a replacement (§DESIGN-V2 F3) — an unconfirmed kill risks a
40
+ // new generation's token being permanently rejected by a still-listening old process (the token check below).
41
+ // Written ONLY after a successful bind (inside the listen callback) — a SECOND bridge racing this same port
42
+ // must NOT clobber the FIRST (still listening) bridge's pidfile with its own (about-to-die) pid.
43
+ const PIDFILE = 'bridge-' + PORT + '.pid';
44
+ const LOGFILE = 'bridge.log';
45
+ function logLine(line) { try { fs.appendFileSync(LOGFILE, line + '\n'); } catch {} }
46
+ // Process-fatal hardening (§DESIGN-V2 C1/C2): an uncaught exception/rejection anywhere in this process previously
47
+ // died silently (no stderr line, no log) — the host's only signal was the WS going dead with no explanation.
48
+ process.on('uncaughtException', (err) => { const m = 'lsp-bridge fatal ' + (err && err.message); console.error(m); logLine(m); process.exit(1); });
49
+ process.on('unhandledRejection', (err) => { const m = 'lsp-bridge fatal ' + (err && err.message); console.error(m); logLine(m); process.exit(1); });
50
+ function tokenOf(url) { return new URLSearchParams(((url || '').split('?')[1]) || '').get('token'); }
51
+ const server = http.createServer((req, res) => {
52
+ const path = ((req && req.url) || '').split('?')[0];
53
+ if (path === '/healthz') {
54
+ // Same-origin auth as the WS gate below: TOKEN unset = open (dev/test), TOKEN set + mismatch = 401.
55
+ if (TOKEN && tokenOf(req.url) !== TOKEN) { res.writeHead(401); res.end(); return; }
56
+ const body = JSON.stringify({ pid: process.pid, uptimeMs: Date.now() - START_MS, connections: wss.clients.size, generationToken: TOKEN.slice(0, 8) });
57
+ res.writeHead(200, { 'Content-Type': 'application/json' });
58
+ res.end(body);
59
+ return;
60
+ }
61
+ const body = http.STATUS_CODES[426]; // preserves ws's own default "upgrade required" response for any other path
62
+ res.writeHead(426, { 'Content-Length': Buffer.byteLength(body), 'Content-Type': 'text/plain' });
63
+ res.end(body);
64
+ });
29
65
  // A zero-listener 'error' on ANY EventEmitter throws and crashes the process (Node default). Without this, a
30
66
  // bind failure (e.g. EADDRINUSE — a second bridge start racing the same port) took down the WHOLE bridge
31
67
  // process, killing every language's LSP session in the sandbox, not just the one that failed to start (HRD-LSP-4).
32
68
  // Exit loud + explicit instead of an uncaught-exception dump so the failure is at least attributable.
33
- wss.on('error', (err) => { console.error('lsp-bridge wss error', err && err.message); process.exit(1); });
69
+ server.on('error', (err) => { const m = 'lsp-bridge wss error ' + (err && err.message); console.error(m); logLine(m); process.exit(1); });
70
+ const wss = new WebSocketServer({ server });
34
71
  wss.on('connection', (ws, req) => {
35
72
  // Same zero-listener 'error' hazard as wss above, but per-connection: registered FIRST, before anything else
36
73
  // touches this socket. Close (don't crash) — the heartbeat reaper's own ws.terminate() below is a routine
@@ -51,7 +88,7 @@ wss.on('connection', (ws, req) => {
51
88
  // CALLER's process group instead of the (nonexistent) child (HRD-LSP-4; same hazard class as bake-runner's
52
89
  // spawner, src/bake-runner/main.ts).
53
90
  ws.on('close', () => { if (child.pid !== undefined) child.kill(); });
54
- child.on('exit', () => { try { ws.close(); } catch {} });
91
+ child.on('exit', (code, signal) => { logLine('lsp-bridge child exit code=' + code + ' signal=' + signal); try { ws.close(); } catch {} });
55
92
  });
56
93
  // Reap half-open connections: the E2B proxy kills a WS at ~60-75s and the kill can be SILENT on THIS side too —
57
94
  // no close frame, so the per-connection language server above would outlive its dead socket (one leaked server
@@ -63,11 +100,25 @@ setInterval(() => {
63
100
  try { ws.ping(); } catch {}
64
101
  }
65
102
  }, HEARTBEAT_MS);
66
- console.log('lsp-bridge listening', PORT);
103
+ server.listen(PORT, '0.0.0.0', () => {
104
+ try { fs.writeFileSync(PIDFILE, String(process.pid)); } catch (e) { logLine('lsp-bridge pidfile write failed ' + (e && e.message)); }
105
+ console.log('lsp-bridge listening', PORT);
106
+ });
67
107
  `;
108
+ /** Bridge dir inside the sandbox(声明提前:tsserver 定值路径引用它)。 */
109
+ export const BRIDGE_DIR = "/home/user/.lsp-bridge";
110
+ /** tls 的 tsserver.js 定值路径(#96 真机门):install 腿保证在场,connect 腿经
111
+ * `initializationOptions.tsserver.path` 告知 tls(v4 起无 CLI 旗可用)。 */
112
+ export const TSSERVER_JS_PATH = `${BRIDGE_DIR}/node_modules/typescript/lib/tsserver.js`;
68
113
  /** Language server command per languageId. P1 = typescript/javascript; dart/kotlin land with their template servers.
69
114
  * `--stdio` only where the server needs it to pick stdio over its other transports (the node-based servers);
70
- * gopls/jdtls/kotlin-language-server/sourcekit-lsp/`dart language-server` speak LSP over stdio by default. */
115
+ * gopls/jdtls/kotlin-language-server/sourcekit-lsp/`dart language-server` speak LSP over stdio by default.
116
+ * #96 真机门发现(E2B 真沙箱逐帧取证,test/lsp-e2b-bridge-live.test.ts):typescript-language-server
117
+ * **不自动发现全局 typescript**(只认 workspace 内 node_modules/typescript 或显式 tsserver 路径),
118
+ * `npm i -g … typescript` 装成之后 initialize 仍答 "Could not find a valid TypeScript installation";
119
+ * 且 v4 起 `--tsserver-path` CLI 旗已移除(未知参数=进程退 1,第二轮真机实测)——路径只能走 LSP
120
+ * `initializationOptions.tsserver.path`(TSSERVER_JS_PATH,connectE2bLspBridge 对 ts/js 传入),
121
+ * typescript 本体由 lspInstallCommand 落进 BRIDGE_DIR 定值路径(烤好模板文件在场则秒过)。 */
71
122
  export const SERVER_CMD = {
72
123
  typescript: "typescript-language-server --stdio",
73
124
  javascript: "typescript-language-server --stdio",
@@ -85,8 +136,7 @@ export const SERVER_NPM = {
85
136
  javascript: "typescript-language-server typescript",
86
137
  python: "pyright",
87
138
  };
88
- /** Bridge dir + port inside the sandbox. One bridge process per language → distinct ports. */
89
- export const BRIDGE_DIR = "/home/user/.lsp-bridge";
139
+ /** One bridge process per language → distinct ports.(BRIDGE_DIR 声明已提前至 SERVER_CMD 上方。) */
90
140
  export function portForLanguage(language) {
91
141
  // stable per-language port; extend the table as templates add servers (dev-mobile: dart/kotlin/java/swift)
92
142
  const ports = { typescript: 8123, javascript: 8123, dart: 8124, kotlin: 8125, python: 8126, go: 8127, java: 8128, swift: 8129 };
@@ -101,6 +151,13 @@ export function lspInstallCommand(language) {
101
151
  `mkdir -p ${BRIDGE_DIR}`,
102
152
  `[ -f ${BRIDGE_DIR}/node_modules/ws/package.json ] || (cd ${BRIDGE_DIR} && echo '{}' > package.json && npm i ws >/tmp/lsp-setup.log 2>&1)`,
103
153
  npm ? `command -v ${cmd.split(" ")[0]} >/dev/null || npm i -g ${npm} >>/tmp/lsp-setup.log 2>&1` : "true",
154
+ // #96 真机门:tls 只认 workspace typescript 或显式 tsserver 路径(全局装了也不认)——把 typescript
155
+ // 落到 BRIDGE_DIR 定值路径,与 TSSERVER_JS_PATH(initializationOptions 腿)同源。烤好模板文件在场则秒过。
156
+ // 🔴 钉 @5:裸 `typescript` 现装到 7.x(Go 原生港,lib/ 无 tsserver.js,真机实测)——tls 绑死
157
+ // TS5 的 JS tsserver。TS7 自带原生 LSP 是未来的换代方向(届时连 tls 一起退役),不是本车。
158
+ npm?.includes("typescript")
159
+ ? `[ -f ${TSSERVER_JS_PATH} ] || (cd ${BRIDGE_DIR} && npm i typescript@5 >>/tmp/lsp-setup.log 2>&1)`
160
+ : "true",
104
161
  ].join(" && ");
105
162
  }
106
163
  /** Start the bridge — run as a TRUE background command (`env.startBackground`), NOT `&`-backgrounded inside a
@@ -109,4 +166,34 @@ export function lspInstallCommand(language) {
109
166
  export function lspStartCommand(language, port, token) {
110
167
  return `cd ${BRIDGE_DIR} && LSP_PORT=${port} LSP_TOKEN='${token}' LSP_CMD='${SERVER_CMD[language]}' node bridge.cjs`;
111
168
  }
169
+ /** Where the on-the-fly `npm i` output from {@link lspInstallCommand} lands — previously had zero readers
170
+ * (§DESIGN-V2 F7 bonus fix); an install failure now tails this for the `lastError`/event snippet. */
171
+ export const LSP_SETUP_LOG = "/tmp/lsp-setup.log";
172
+ /** BRIDGE_DIR-relative log the bridge process itself appends to (fatal errors, child exits) — §DESIGN-V2 C4. */
173
+ export function bridgeLogFile() {
174
+ return `${BRIDGE_DIR}/bridge.log`;
175
+ }
176
+ /** Per-port pidfile the bridge writes ONLY after a confirmed bind (BRIDGE_SOURCE's `listen` callback) — see its
177
+ * header comment for why a losing (EADDRINUSE) second bridge must never clobber this. */
178
+ export function bridgePidFile(port) {
179
+ return `${BRIDGE_DIR}/bridge-${port}.pid`;
180
+ }
181
+ /** §DESIGN-V2 F3: verify a prior bridge generation ACTUALLY stopped before the host mints a replacement.
182
+ * Exit 0 = confirmed dead (or the pidfile never existed — nothing was ever listening) → safe to start a new
183
+ * generation on this port. Exit 1 = still alive after the bound wait — the caller MUST NOT start a new bridge:
184
+ * an unconfirmed kill risks EADDRINUSE (the new generation's fresh token would then be permanently rejected by
185
+ * the still-listening old process, the token check in BRIDGE_SOURCE) — better to stay `dead` than double-burn
186
+ * the restart budget on a doomed attempt. POSIX `sh` only (no `seq`/bashisms — sandboxes vary in shell).
187
+ * Bound: 20 × 0.1s = 2s. */
188
+ export function terminateBridgeCommand(port) {
189
+ const pidfile = bridgePidFile(port);
190
+ return [
191
+ `[ -f ${pidfile} ] || exit 0`,
192
+ `PID=$(cat ${pidfile})`,
193
+ `kill "$PID" 2>/dev/null`,
194
+ `i=0`,
195
+ `while [ $i -lt 20 ]; do kill -0 "$PID" 2>/dev/null || exit 0; i=$((i+1)); sleep 0.1; done`,
196
+ `exit 1`,
197
+ ].join("; ");
198
+ }
112
199
  //# sourceMappingURL=e2b-bridge.js.map
@@ -1,5 +1,5 @@
1
1
  import { connectWsLspTransport } from "./ws-transport.js";
2
- import { E2bLspManager, type LspCapableEnv } from "./manager.js";
2
+ import { E2bLspManager, type LspCapableEnv, type LspUnavailableReason } from "./manager.js";
3
3
  import type { LspTransport } from "./types.js";
4
4
  export interface E2bLspOptions {
5
5
  /** Injectable for tests (no E2B/ws). */
@@ -20,11 +20,74 @@ export interface E2bLspOptions {
20
20
  scheme?: "ws" | "wss";
21
21
  /** Observability hook (open/heal/degrade events) — prod wires the service logger. */
22
22
  log?: (event: string, fields: Record<string, unknown>) => void;
23
+ /** §DESIGN-V2 F11: metrics registry (structural — matches the narrow surface remote-env-e2b.ts's `cfg.metrics`
24
+ * already uses). `provider`/`generation`/`reason` labels are filled in by this module, never left freeform. */
25
+ metrics?: {
26
+ inc(name: string, labels?: Record<string, string>): void;
27
+ };
28
+ /** §DESIGN-V2 F11: the deployment lane feeding this manager (boot/execution-env.ts passes it explicitly —
29
+ * "e2b" or "k8s" — rather than this module guessing it back out of `scheme`, which is a WS-protocol detail
30
+ * that happens to correlate with the lane but isn't the same axis). Defaults to inferring from `scheme` only
31
+ * so existing callers that don't pass it (tests) keep working. */
32
+ provider?: string;
33
+ /** §DESIGN-V2 F2/C6: health-probe override for tests (no real sandbox network). Defaults to a real
34
+ * `GET /healthz` fetch (1s timeout) — `scheme` maps to the probe's URL scheme, F11 (`wss→https`, `ws→http`). */
35
+ probe?: (env: LspCapableEnv, port: number, token: string, scheme: "ws" | "wss") => Promise<boolean>;
23
36
  }
24
37
  /** Start (+ install on a non-baked template) the language server + bridge inside THIS env's sandbox, then connect.
25
38
  * Every call starts (or re-starts) the bridge unconditionally — the one production caller is
26
39
  * `createE2bLspManager`, which de-dupes per env+port before ever reaching here (see its doc). */
27
40
  export declare function openE2bLspTransport(language: string, env: LspCapableEnv, opts?: E2bLspOptions): Promise<LspTransport | undefined>;
41
+ /** §DESIGN-V2 A: `starting` while the write+install+startBackground sequence is in flight; `ready` once at
42
+ * least one WS connect through this generation has succeeded; `dead` once this generation is confirmed (or
43
+ * presumed, per its own failure) unusable — the ONLY way out of `dead` is minting a new generation. */
44
+ export type BridgeState = "starting" | "ready" | "dead";
45
+ /**
46
+ * §DESIGN-V2 A/F3/F6: one bridge RESOURCE per env+port (not per language — `portForLanguage` maps
47
+ * typescript/javascript onto the SAME port/process off the same `SERVER_CMD`, HRD-LSP-4). A crashed/never-
48
+ * booted bridge can be replaced by minting a new `generation` (bounded by `MAX_BRIDGE_GENERATIONS` — a
49
+ * crash-loop must degrade the language, not burn the sandbox indefinitely); the NEW generation is only ever
50
+ * minted after `terminateBridgeCommand` confirms the OLD one actually stopped (F3) — an unconfirmed kill risks
51
+ * EADDRINUSE permanently rejecting the new generation's fresh token (the old process is still the one
52
+ * listening, and its token check has no way to know a replacement was intended).
53
+ */
54
+ export interface E2bBridgeHandle {
55
+ readonly port: number;
56
+ /** Pinned once per generation — the shared bridge resource's auth secret. A per-LANGUAGE token would be
57
+ * rejected by the one bridge process actually listening whenever two languages share a port (see the
58
+ * module doc's HRD-LSP-4 reference): the second language mints a different token, the bridge only
59
+ * recognizes whichever token started it, auth fails, retries exhaust, and that language is permanently
60
+ * degraded to grep/read_file for the task. */
61
+ readonly token: string;
62
+ /** The `LSP_CMD` this generation's bridge process was launched with — identity documentation (ts/js sharing
63
+ * one value/port/process is legitimate, §DESIGN-V2 "不做"); not read back by this module. */
64
+ readonly serverCmd: string;
65
+ /** 1 at first mint; +1 every restart. Bounds the restart budget (`MAX_BRIDGE_GENERATIONS`). */
66
+ readonly generation: number;
67
+ state: BridgeState;
68
+ /** The most recent human-readable diagnostic (write/install/start/connect failure, or a probe verdict) —
69
+ * feeds `lastError`-style debugging; the MODEL-facing word is `deathReason` (via `unavailableReason`), not
70
+ * this free-text string. */
71
+ lastError?: string;
72
+ /** The SPECIFIC reason THIS generation died (unset while starting/ready). `unavailableReason`-facing
73
+ * reporting overrides this to `bridge-restart-budget-exhausted` once `generation >= MAX_BRIDGE_GENERATIONS`
74
+ * — "further restarts won't help" is a different (and, once true, more useful) word than "what killed the
75
+ * last one." */
76
+ deathReason?: LspUnavailableReason;
77
+ /** True once this generation has completed at least one real WS connect. Gates the fast healthz-probe heal
78
+ * path (§DESIGN-V2 F2/F6): a generation that has never connected yet is still cold-starting, not dead —
79
+ * treating an unreachable healthz as "dead" during that window would misjudge ordinary boot lag as a crash
80
+ * and burn a restart generation for nothing. A generation that HAS connected before, though, really should
81
+ * be reachable again quickly, so it skips the blind 6×1500ms retry loop and asks the bridge directly. */
82
+ everConnected: boolean;
83
+ /** Resolves once the write+install+startBackground sequence has been ATTEMPTED for this generation — `false`
84
+ * (with `state` already flipped to `dead` + `deathReason`/`lastError` set) means it failed before ever
85
+ * reaching a connect attempt. */
86
+ ready: Promise<boolean>;
87
+ }
88
+ /** A crash-loop must degrade the language, not retry the sandbox forever. 3 covers "an occasional OOM kill";
89
+ * a bridge that dies again immediately after 3 fresh starts is a standing failure, not a blip. */
90
+ export declare const MAX_BRIDGE_GENERATIONS = 3;
28
91
  /** The single shared `LspServerManager` for the deployment (`RunnerDeps.lspManager`). */
29
92
  export declare function createE2bLspManager(opts?: E2bLspOptions): E2bLspManager;
30
93
  //# sourceMappingURL=e2b-manager.d.ts.map
@@ -6,41 +6,99 @@
6
6
  * `openE2bLspTransport` does the per-sandbox start: write bridge.cjs from BRIDGE_SOURCE (single source of truth —
7
7
  * works on baked + un-baked templates) → foreground install (baked template skips instantly) → start the bridge
8
8
  * as a TRUE background command (a shell `&` child gets reaped when E2B completes the foreground command) → WS
9
- * connect via `wss://getHost(port)?token=…` (per-session auth — the URL is public).
9
+ * connect via `wss://getHost(port)?token=…` (per-session auth — the URL is public). It is a single-shot legacy
10
+ * primitive (still used standalone by the k8s wiring test) — NOT part of the bridge resource model below; it
11
+ * carries no generation/dead tracking of its own.
12
+ *
13
+ * redesign⑤ (#96 §DESIGN-V2 A/F3/F6): `createE2bLspManager`'s `transportFactory` owns the actual bridge
14
+ * RESOURCE model — one `E2bBridgeHandle` per env+port, surviving across heals, with a bounded restart budget
15
+ * (`MAX_BRIDGE_GENERATIONS`) so a bridge that crashes (or a first boot that never completes) degrades that
16
+ * language gracefully instead of burning sandbox round-trips forever.
10
17
  */
11
18
  import { randomUUID } from "node:crypto";
12
19
  import { connectWsLspTransport } from "./ws-transport.js";
13
20
  import { E2bLspManager } from "./manager.js";
14
- import { BRIDGE_DIR, BRIDGE_SOURCE, lspInstallCommand, lspStartCommand, portForLanguage, SERVER_CMD } from "./e2b-bridge.js";
21
+ import { BRIDGE_DIR, BRIDGE_SOURCE, TSSERVER_JS_PATH, LSP_SETUP_LOG, bridgeLogFile, lspInstallCommand, lspStartCommand, portForLanguage, SERVER_CMD, terminateBridgeCommand, } from "./e2b-bridge.js";
22
+ function describeErr(e) {
23
+ return e instanceof Error ? e.message : String(e);
24
+ }
25
+ /** Best-effort `tail -n <lines> <path>`, capped — used for both the install-failure snippet (§DESIGN-V2 F7,
26
+ * `LSP_SETUP_LOG` — previously had zero readers) and the connect-exhaustion postmortem (§DESIGN-V2 C5/F9,
27
+ * `bridge.log`). Returns `undefined` ONLY when the exec itself failed (never observed the log) — an empty
28
+ * log is still a meaningful (if boring) tail, distinct from "couldn't look." Caps to the LAST `capBytes` —
29
+ * a giant `Content-Length` framing dump truncated from the FRONT still keeps the most recent (relevant) lines. */
30
+ async function tailLog(env, path, lines = 40, capBytes = 4096) {
31
+ const r = await env.exec(`tail -n ${lines} ${path} 2>/dev/null`).catch(() => undefined);
32
+ if (!r?.ok)
33
+ return undefined;
34
+ const out = r.value?.stdout ?? "";
35
+ return out.length > capBytes ? out.slice(-capBytes) : out;
36
+ }
37
+ /** §DESIGN-V2 F11: the probe's URL scheme is the WS scheme's HTTP-family counterpart (`wss`'s TLS proxy →
38
+ * `https`; the k8s pod network's plain `ws` → plain `http` — same "TLS or not" the WS connect itself uses). */
39
+ function httpSchemeFor(scheme) {
40
+ return scheme === "wss" ? "https" : "http";
41
+ }
42
+ /** Real `GET /healthz?token=…` probe (§DESIGN-V2 F2/C6) — 1s bound, `fetch` (no new dependency). Any failure
43
+ * (no host, network error, non-2xx, timeout) reads as "not confirmed alive"; the caller's decision (skip the
44
+ * blind retry loop and go straight to the restart arm) only fires once `everConnected` is true (F6) — a cold
45
+ * first boot never reaches this probe. */
46
+ async function defaultProbeHealthz(env, port, token, scheme) {
47
+ const host = await env.getHost(port);
48
+ if (!host)
49
+ return false;
50
+ try {
51
+ const res = await fetch(`${httpSchemeFor(scheme)}://${host}/healthz?token=${token}`, { signal: AbortSignal.timeout(1000) });
52
+ return res.ok;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
15
58
  /** Write bridge.cjs + (foreground) install the language server, then start the bridge as a TRUE background
16
- * command. Returns whether the bridge is now up-or-starting (`false` only when the foreground install failed).
17
- * A single-shot primitive: it does NOT check whether a bridge is already listening on `port` — a caller that
18
- * can have two languages share one port (`createE2bLspManager`, since `portForLanguage` maps typescript AND
19
- * javascript onto 8123 off the same `SERVER_CMD`) MUST call this at most ONCE per env+port itself. A second
20
- * `node bridge.cjs` on an already-bound port surfaces as an EADDRINUSE `wss` 'error' (e2b-bridge.ts D6
21
- * hardening keeps that from crashing the sandbox, but the second attempt still fails and wastes a sandbox
22
- * round-trip for nothing HRD-LSP-4). */
59
+ * command. A single-shot primitive: it does NOT check whether a bridge is already listening on `port` a
60
+ * caller that can have two languages share one port (`createE2bLspManager`, since `portForLanguage` maps
61
+ * typescript AND javascript onto 8123 off the same `SERVER_CMD`) MUST call this at most ONCE per env+port
62
+ * itself. A second `node bridge.cjs` on an already-bound port surfaces as an EADDRINUSE `wss` 'error'
63
+ * (e2b-bridge.ts D6 hardening keeps that from crashing the sandbox, but the second attempt still fails and
64
+ * wastes a sandbox round-trip for nothing HRD-LSP-4). None of the three steps' failures are swallowed
65
+ * (§DESIGN-V2 F6)a silently-lost write means `node bridge.cjs` fails at runtime with no signal until
66
+ * every connect attempt exhausts; reporting it here is strictly faster AND gives a precise reason. */
23
67
  async function startE2bBridge(language, env, port, token) {
24
- await env.writeFile(`${BRIDGE_DIR}/bridge.cjs`, BRIDGE_SOURCE).catch(() => undefined);
68
+ const wrote = await env.writeFile(`${BRIDGE_DIR}/bridge.cjs`, BRIDGE_SOURCE).catch((e) => ({ ok: false, error: e }));
69
+ if (!wrote.ok) {
70
+ const detail = "error" in wrote && wrote.error !== undefined ? `bridge.cjs write failed: ${describeErr(wrote.error)}` : "bridge.cjs write failed";
71
+ return { ok: false, reason: "bridge-start-failed", detail };
72
+ }
25
73
  const installed = await env.exec(lspInstallCommand(language)).catch(() => undefined);
26
- if (!installed || !installed.ok)
27
- return false;
28
- await env.startBackground(lspStartCommand(language, port, token)).catch(() => undefined);
29
- return true;
74
+ if (!installed || !installed.ok || installed.value?.exitCode !== 0) {
75
+ const snippet = await tailLog(env, LSP_SETUP_LOG);
76
+ return { ok: false, reason: "install-failed", detail: snippet ? `install failed: ${snippet}` : "install failed (no setup log)" };
77
+ }
78
+ try {
79
+ await env.startBackground(lspStartCommand(language, port, token));
80
+ }
81
+ catch (e) {
82
+ return { ok: false, reason: "bridge-start-failed", detail: `background start failed: ${describeErr(e)}` };
83
+ }
84
+ return { ok: true };
30
85
  }
31
86
  /** Connect (with retry) to an already-started bridge. Each call opens its OWN WS connection — the bridge spawns
32
87
  * a FRESH child language-server process per connection (e2b-bridge.ts `wss.on('connection', …)`), so two
33
88
  * languages sharing one bridge port each still get an isolated language-server instance. */
34
- async function connectE2bLspBridge(env, port, token, opts) {
89
+ async function connectE2bLspBridge(env, port, token, opts, language) {
35
90
  const connect = opts?.connect ?? connectWsLspTransport;
36
91
  const retry = opts?.retry ?? { attempts: 6, delayMs: 1500 };
37
92
  const root = env.workspaceHandle().mountPath;
93
+ // #96 真机门:tls(v4 无 CLI 旗)只能经 initializationOptions 拿 tsserver 路径;install 腿已把
94
+ // typescript 落到 TSSERVER_JS_PATH 定值路径。非 tls 语言不发键(LSP 规范下多余键也会被忽略,但缺席=零风险)。
95
+ const initOptions = language === "typescript" || language === "javascript" ? { tsserver: { path: TSSERVER_JS_PATH } } : undefined;
38
96
  // the bridge starts as a background command → retry the WS connect (with the auth token) while it comes up
39
97
  const scheme = opts?.scheme ?? "wss";
40
98
  for (let attempt = 0; attempt < retry.attempts; attempt++) {
41
99
  const host = await env.getHost(port);
42
100
  if (host) {
43
- const transport = await connect(`${scheme}://${host}/?token=${token}`, root);
101
+ const transport = await connect(`${scheme}://${host}/?token=${token}`, root, undefined, initOptions);
44
102
  if (transport)
45
103
  return transport;
46
104
  }
@@ -57,10 +115,13 @@ export async function openE2bLspTransport(language, env, opts) {
57
115
  const port = portForLanguage(language);
58
116
  const token = opts?.token ?? randomUUID(); // per-session bridge auth — the getHost URL is public (council #4)
59
117
  const started = await startE2bBridge(language, env, port, token);
60
- if (!started)
118
+ if (!started.ok)
61
119
  return undefined;
62
- return connectE2bLspBridge(env, port, token, opts);
120
+ return connectE2bLspBridge(env, port, token, opts, language);
63
121
  }
122
+ /** A crash-loop must degrade the language, not retry the sandbox forever. 3 covers "an occasional OOM kill";
123
+ * a bridge that dies again immediately after 3 fresh starts is a standing failure, not a blip. */
124
+ export const MAX_BRIDGE_GENERATIONS = 3;
64
125
  /** The single shared `LspServerManager` for the deployment (`RunnerDeps.lspManager`). */
65
126
  export function createE2bLspManager(opts) {
66
127
  // One bridge PROCESS + token per env+PORT — not per env+language. `portForLanguage` maps typescript and
@@ -68,29 +129,137 @@ export function createE2bLspManager(opts) {
68
129
  // one listening bridge (HRD-LSP-4) and re-invoked `startBackground` on an already-bound port on every open.
69
130
  // WeakMap → a destroyed task's bridge bookkeeping is collectible with its env.
70
131
  const bridges = new WeakMap();
132
+ const scheme = opts?.scheme ?? "wss";
133
+ const provider = opts?.provider ?? (scheme === "ws" ? "k8s" : "e2b");
134
+ const log = opts?.log ?? (() => { });
135
+ const metrics = opts?.metrics;
136
+ const probe = opts?.probe ?? defaultProbeHealthz;
137
+ /** The model-facing word for this handle's CURRENT death — `deathReason` unless the restart budget is
138
+ * exhausted, in which case "further restarts won't help" supersedes whatever specifically killed the last
139
+ * generation. */
140
+ function reasonFor(handle) {
141
+ if (handle.generation >= MAX_BRIDGE_GENERATIONS)
142
+ return "bridge-restart-budget-exhausted";
143
+ return handle.deathReason ?? "bridge-connect-exhausted";
144
+ }
145
+ /** The ONE place a handle transitions into `dead` — fires the `lsp_bridge_dead_total{reason}` metric +
146
+ * `lsp_bridge_dead` event exactly once per transition (§DESIGN-V2 E), using the ALREADY-budget-aware
147
+ * reported reason (not the raw `deathReason`) so the metric bucket for a generation that just hit the cap
148
+ * reads as "budget exhausted," not as whatever technical failure happened to be the final straw.
149
+ * review finding ③ (independent re-review): idempotent — two concurrent callers CAN both conclude the
150
+ * SAME still-"ready" handle is dead (e.g. two languages sharing a port each running their own §DESIGN-V2
151
+ * F2 healthz probe before either has written `state="dead"`); without this guard the metric/event fired
152
+ * once per CALLER instead of once per TRANSITION. A handle already `dead` keeps its ORIGINAL
153
+ * deathReason/lastError (the first caller to observe the death is definitionally the one whose diagnostic
154
+ * is accurate — a second, redundant observation shouldn't overwrite it). */
155
+ function markDead(handle, reason, detail) {
156
+ if (handle.state === "dead")
157
+ return reasonFor(handle);
158
+ handle.state = "dead";
159
+ handle.deathReason = reason;
160
+ handle.lastError = detail;
161
+ const reported = reasonFor(handle);
162
+ metrics?.inc("lsp_bridge_dead_total", { reason: reported });
163
+ log("lsp_bridge_dead", { port: handle.port, generation: handle.generation, reason: reported, detail });
164
+ return reported;
165
+ }
166
+ /** §DESIGN-V2 C5/F9: best-effort postmortem — tail the bridge's OWN log after a connect exhausts (or the
167
+ * probe declares it dead) and before giving up on this generation. Silent on failure (getting the tail is
168
+ * itself best-effort; it must never become a SECOND failure mode). */
169
+ async function postmortem(env, port) {
170
+ const tail = await tailLog(env, bridgeLogFile());
171
+ if (tail !== undefined)
172
+ log("lsp_bridge_postmortem", { port, tail });
173
+ }
71
174
  return new E2bLspManager({
72
175
  log: opts?.log,
176
+ metrics,
177
+ hasServerCommand: (language) => Boolean(SERVER_CMD[language]),
73
178
  transportFactory: async (language, env) => {
74
179
  if (!SERVER_CMD[language])
75
- return undefined; // no server for this language → graceful degrade (mirrors
76
- // openE2bLspTransport's own guard; MUST run before touching `bridges` — `portForLanguage` falls back to
77
- // port 8123 for an unmapped language, which would otherwise silently borrow typescript's live bridge)
180
+ return { ok: false, reason: "no-server-command" }; // mirrors openE2bLspTransport's
181
+ // own guard; MUST run before touching `bridges` — `portForLanguage` falls back to port 8123 for an
182
+ // unmapped language, which would otherwise silently borrow typescript's live bridge.
78
183
  const port = portForLanguage(language);
79
184
  let perPort = bridges.get(env);
80
185
  if (!perPort) {
81
186
  perPort = new Map();
82
187
  bridges.set(env, perPort);
83
188
  }
84
- let bridge = perPort.get(port);
85
- if (!bridge) {
189
+ let handle = perPort.get(port);
190
+ if (!handle || handle.state === "dead") {
191
+ if (handle && handle.generation >= MAX_BRIDGE_GENERATIONS)
192
+ return { ok: false, reason: reasonFor(handle) };
193
+ // review finding ① (independent re-review): the termination-verification (§DESIGN-V2 F3) used to sit
194
+ // in an `await` HERE, between reading `perPort` above and writing it below — that broke the
195
+ // run-to-completion dedupe guarantee two languages sharing a port rely on (portForLanguage maps
196
+ // typescript/javascript onto the SAME port/SERVER_CMD): both could see the same dead handle, both
197
+ // race past the await, and both mint a COMPETING replacement generation (each calling
198
+ // startBackground) instead of joining one shared restart. The mint itself — capturing `generation`+
199
+ // `token` and registering `newHandle` into `perPort` — MUST stay synchronous relative to the
200
+ // `perPort.get(port)` read above (true again now: "no await above" below). The termination check
201
+ // moves INSIDE the `ready` chain instead: mint synchronously, verify asynchronously. A SECOND
202
+ // language arriving while this is in flight now sees `state==="starting"` (not "dead"), skips this
203
+ // whole branch, and joins the SAME `ready` — exactly the pre-existing dedupe every other caller of
204
+ // `bridges`/`perPort` already relies on.
205
+ const priorHandle = handle; // the dying generation (if any) — captured for the async verification below
206
+ const generation = handle ? handle.generation + 1 : 1;
86
207
  const token = opts?.token ?? randomUUID();
87
- bridge = { token, ready: startE2bBridge(language, env, port, token) };
88
- perPort.set(port, bridge); // synchronous — no await above (run-to-completion dedupe guard, see E2bBridgeHandle.ready doc)
208
+ const newHandle = {
209
+ port,
210
+ token,
211
+ serverCmd: SERVER_CMD[language],
212
+ generation,
213
+ state: "starting",
214
+ everConnected: false,
215
+ ready: Promise.resolve(false), // placeholder — overwritten synchronously below, before any await
216
+ };
217
+ newHandle.ready = (async () => {
218
+ if (priorHandle) {
219
+ // §DESIGN-V2 F3: verify the OLD generation actually stopped before this one starts — an
220
+ // unconfirmed kill risks EADDRINUSE permanently rejecting THIS generation's fresh token (the old
221
+ // process is still the one listening). This generation is already minted+registered by the time
222
+ // this runs (the dedupe above), so an unconfirmed termination marks IT dead directly rather than
223
+ // leaving the prior (already-replaced) handle in the map.
224
+ const released = await env.exec(terminateBridgeCommand(port)).catch(() => undefined);
225
+ if (!released?.ok || released.value?.exitCode !== 0) {
226
+ markDead(newHandle, "bridge-start-failed", "previous generation could not be confirmed stopped — held back from starting to avoid a doomed EADDRINUSE race");
227
+ log("lsp_bridge_restart_blocked", { port, generation });
228
+ return false;
229
+ }
230
+ }
231
+ const started = await startE2bBridge(language, env, port, token);
232
+ if (!started.ok) {
233
+ markDead(newHandle, started.reason ?? "bridge-start-failed", started.detail ?? "bridge start failed");
234
+ return false;
235
+ }
236
+ metrics?.inc("lsp_bridge_start_total", { provider, generation: String(generation) });
237
+ log("lsp_bridge_start", { port, generation });
238
+ return true;
239
+ })();
240
+ perPort.set(port, newHandle); // synchronous — no await above (run-to-completion dedupe guard RESTORED)
241
+ handle = newHandle;
89
242
  }
90
- const started = await bridge.ready;
243
+ const started = await handle.ready;
91
244
  if (!started)
92
- return undefined;
93
- return connectE2bLspBridge(env, port, bridge.token, opts);
245
+ return { ok: false, reason: reasonFor(handle) };
246
+ if (handle.everConnected) {
247
+ const alive = await probe(env, handle.port, handle.token, scheme);
248
+ if (!alive) {
249
+ const reason = markDead(handle, "bridge-connect-exhausted", "healthz probe reported the bridge unreachable");
250
+ await postmortem(env, port);
251
+ return { ok: false, reason };
252
+ }
253
+ }
254
+ const transport = await connectE2bLspBridge(env, handle.port, handle.token, opts, language);
255
+ if (!transport) {
256
+ const reason = markDead(handle, "bridge-connect-exhausted", "bridge connect exhausted after all retries");
257
+ await postmortem(env, port);
258
+ return { ok: false, reason };
259
+ }
260
+ handle.everConnected = true;
261
+ handle.state = "ready";
262
+ return { ok: true, transport };
94
263
  },
95
264
  });
96
265
  }
@@ -1,13 +1,17 @@
1
1
  import { LspDiagnosticsRegistry } from "@sema-agent/core";
2
2
  import type { ExecutionEnv } from "@sema-agent/core";
3
3
  import type { LspServerManager, LspSession, LspTransport } from "./types.js";
4
- /** The E2B surface the LSP needs beyond base ExecutionEnv (present on RemoteContainerExecutionEnv). */
4
+ /** The E2B surface the LSP needs beyond base ExecutionEnv (present on RemoteContainerExecutionEnv).
5
+ * `exec`'s `stdout` is optional/structural (§DESIGN-V2 F7) — additive to the existing `stderr`-only shape so
6
+ * the postmortem/install-failure tail reads (`tail -n … <log>`, whose output arrives on stdout) can use the
7
+ * SAME duck-typed surface without widening what a plain command result MUST carry. */
5
8
  export interface LspCapableEnv {
6
9
  exec(command: string): Promise<{
7
10
  ok: boolean;
8
11
  value?: {
9
12
  exitCode: number;
10
13
  stderr: string;
14
+ stdout?: string;
11
15
  };
12
16
  }>;
13
17
  startBackground(command: string): Promise<void>;
@@ -23,22 +27,56 @@ export interface LspCapableEnv {
23
27
  mountPath: string;
24
28
  };
25
29
  }
30
+ /**
31
+ * §DESIGN-V2 B/F5/F7: the fixed, model-facing failure vocabulary for `unavailableReason`. Three are STATIC
32
+ * (re-derivable synchronously from `filePath`+`env` alone, without ever having attempted an open —
33
+ * `env-not-lsp-capable` / `language-not-mapped` / `no-server-command`); the other five are DYNAMIC (only
34
+ * knowable after an actual open attempt, so they're recorded in `lastFailure` per env+language and read back).
35
+ * Short + stable by design — a model reads these directly (core lsp.js renders `why` straight into the tool's
36
+ * degrade text), so adding/renaming a word is a behavior-facing change, not a refactor.
37
+ */
38
+ export type LspUnavailableReason = "env-not-lsp-capable" | "language-not-mapped" | "no-server-command" | "install-failed" | "bridge-start-failed" | "bridge-connect-exhausted" | "bridge-restart-budget-exhausted" | "session-open-failed";
39
+ /** §DESIGN-V2 F5: the transport factory's discriminated result — replaces the old plain `LspTransport|undefined`
40
+ * so the bridge-resource owner (e2b-manager's `transportFactory`) can report WHY an attempt failed directly
41
+ * through its return value, instead of a side channel that could drift from what actually happened. */
42
+ export type LspFactoryResult = {
43
+ ok: true;
44
+ transport: LspTransport;
45
+ } | {
46
+ ok: false;
47
+ reason: LspUnavailableReason;
48
+ };
26
49
  /** Duck-type check: is this ExecutionEnv an E2B adapter with the LSP surface? */
27
50
  export declare function isLspCapable(env: ExecutionEnv): env is ExecutionEnv & LspCapableEnv;
28
51
  export interface E2bLspManagerOptions {
29
52
  /** Opens (+ `initialize`s) a WS transport to the language server inside THIS env's sandbox. */
30
- transportFactory: (language: string, env: LspCapableEnv) => Promise<LspTransport | undefined>;
53
+ transportFactory: (language: string, env: LspCapableEnv) => Promise<LspFactoryResult>;
31
54
  /** Override the file-extension → languageId map. */
32
55
  extToLang?: Readonly<Record<string, string>>;
33
56
  /** Observability hook (open/heal/degrade events) — prod wires the service logger; silent when unset. */
34
57
  log?: (event: string, fields: Record<string, unknown>) => void;
58
+ /** §DESIGN-V2 F5: synchronous re-derivation of the "no-server-command" static reason — owned by the factory
59
+ * (e2b-manager's `SERVER_CMD` table), which this generic manager doesn't import. `undefined` (option unset)
60
+ * or a `true` return ⇒ a server command IS configured, so a miss (if any) is dynamic (read from
61
+ * `lastFailure`) rather than this static one. */
62
+ hasServerCommand?: (language: string) => boolean;
63
+ /** §DESIGN-V2 E: `lsp_session_open_total{language,outcome}` / `lsp_session_heal_total{outcome}` — fired at
64
+ * the SAME point as the matching `log()` call (event and metric share one source, never drift apart). */
65
+ metrics?: {
66
+ inc(name: string, labels?: Record<string, string>): void;
67
+ };
35
68
  }
36
69
  export declare class E2bLspManager implements LspServerManager {
37
- /** Per-sandbox session cache: env (weak) → language → in-flight/ready session. */
70
+ /** Per-sandbox session cache: env (weak) → language → the (self-contained, see `startJob`) shared job. */
38
71
  private readonly perEnv;
72
+ /** §DESIGN-V2 B/F10: env (weak) → language → the reason the MOST RECENT open/heal attempt failed. Cleared
73
+ * on a successful open (F10) so a stale reason never outlives the failure that produced it. */
74
+ private readonly lastFailure;
39
75
  private readonly factory;
40
76
  private readonly extToLang;
41
77
  private readonly log;
78
+ private readonly hasServerCommand;
79
+ private readonly metrics;
42
80
  /** design/121 (core 1.220, board [S] ①): the workspace diagnostics registry — WORKSPACE-scoped (survives
43
81
  * heal/evict; diagnostics belong to files, not servers), drained by the Runner at turn boundaries. Every
44
82
  * session feeds it from `textDocument/publishDiagnostics` (the WS transport now dispatches notifications).
@@ -47,7 +85,36 @@ export declare class E2bLspManager implements LspServerManager {
47
85
  * fileEdited keying is per-uri, and sandbox workspace paths are per-task-unique). */
48
86
  readonly diagnostics: LspDiagnosticsRegistry;
49
87
  constructor(opts: E2bLspManagerOptions);
50
- sessionFor(filePath: string, _signal?: AbortSignal, env?: ExecutionEnv): Promise<LspSession | undefined>;
88
+ /**
89
+ * §DESIGN-V2 B: the core 1.86.2+ seam's optional method — WHY the most recent `sessionFor` miss for this
90
+ * file happened. Three reasons are STATIC (re-derived here fresh, every call, from `filePath`+`env` alone —
91
+ * never stored, since they don't need an open attempt to be knowable); the rest are DYNAMIC, read back from
92
+ * `lastFailure`. `undefined` = this exact (env, language) was never attempted (NOT "available" — a
93
+ * best-effort postmortem surface, per the seam's own contract).
94
+ */
95
+ unavailableReason(filePath: string, env?: ExecutionEnv): LspUnavailableReason | undefined;
96
+ sessionFor(filePath: string, signal?: AbortSignal, env?: ExecutionEnv): Promise<LspSession | undefined>;
97
+ /**
98
+ * §DESIGN-V2 F1 (aligned with core's `NodeLspManager.open`, node-lsp-manager.js :123-153 — same self-
99
+ * contained-job shape, not the same code: that manager also tracks a TTL/LRU cache and per-job
100
+ * `SharedAbortScope`, which this one has no equivalent of per F9). The returned job is SELF-completing:
101
+ *
102
+ * - self-caches: registers itself into `sessions` synchronously BEFORE returning (so a concurrent caller
103
+ * in the SAME microtask sees it — no dedupe race);
104
+ * - self-cleans: on failure, removes ITSELF from `sessions` (never a newer entry a racer already installed
105
+ * — the `sessions.get(language) === job` check) so the next request retries instead of caching a miss;
106
+ * - self-warms: a heal job (`heal` set) `warmOpen`s the carried files as part of settling, before anyone
107
+ * can observe it as "done" — no caller-side extra step, no window where a racer sees a warm-less session;
108
+ * - self-closes an orphan: if a CONCURRENT heal already replaced this job in `sessions` before it settled
109
+ * (this job "lost" the race), the just-opened transport is closed instead of leaked with no owner.
110
+ *
111
+ * Callers must NEVER wrap `settleOnAbort` around a naive `await` of a SHARED promise and then branch the
112
+ * logging/cache-eviction on THAT wrapped value — a caller whose OWN signal aborted would then look
113
+ * indistinguishable from the job itself failing, misfiring `lsp_session_*_failed` and evicting a still-live
114
+ * in-flight entry out from under a concurrent caller (§DESIGN-V2 F1). `sessionFor` only ever wraps the
115
+ * RETURN boundary; every cache/log decision above happens INSIDE the job, driven by the job's own outcome.
116
+ */
117
+ private startJob;
51
118
  private open;
52
119
  }
53
120
  //# sourceMappingURL=manager.d.ts.map
@@ -6,7 +6,9 @@
6
6
  * in a WeakMap, so a destroyed task's entries become collectible (its bridge dies with the sandbox).
7
7
  *
8
8
  * Any miss (no env / unknown language / env without the E2B surface / failed open) → `undefined`, which makes
9
- * core's tool degrade gracefully to grep/read_file.
9
+ * core's tool degrade gracefully to grep/read_file. redesign⑤ (#96 §DESIGN-V2 B/F5/F10): the miss now also comes
10
+ * with a reason (`unavailableReason`, the core 1.86.2+ seam's optional method) — a fixed, model-facing word list
11
+ * (§DESIGN-V2 F7: "reason 词表=模型可见面", short + stable — changing a word IS a behavior change).
10
12
  */
11
13
  import path from "node:path";
12
14
  import { TransportLspSession, LspDiagnosticsRegistry } from "@sema-agent/core";
@@ -33,12 +35,44 @@ const DEFAULT_EXT_TO_LANG = {
33
35
  ".swift": "swift",
34
36
  ".rs": "rust",
35
37
  };
38
+ /**
39
+ * §DESIGN-V2 F1/F8/F9: literal-aligned with core's (un-exported, `dist/core/lsp.js`) `settleOnAbort` — same
40
+ * signature as the PUBLIC `core/lsp.d.ts:47` declaration, so a future dedup (once core exports it) is a
41
+ * find-and-delete, not a rewrite. Bounds how long *this one caller* waits for a shared job; it must NEVER be
42
+ * threaded into the job itself (F9: the shared open/heal chain accepts no per-caller signal — see `open`'s
43
+ * doc). `job` is assumed never to reject (every failure path here resolves to `undefined`); the `.catch` on
44
+ * the already-aborted branch is defensive parity with core's version, not a behavior this file relies on.
45
+ */
46
+ function settleOnAbort(job, signal, onAbort) {
47
+ if (!signal)
48
+ return job;
49
+ if (signal.aborted) {
50
+ void job.catch(() => undefined);
51
+ return Promise.resolve(onAbort());
52
+ }
53
+ return new Promise((resolve, reject) => {
54
+ const onAbortEvent = () => resolve(onAbort());
55
+ signal.addEventListener("abort", onAbortEvent, { once: true });
56
+ job.then((v) => {
57
+ signal.removeEventListener("abort", onAbortEvent);
58
+ resolve(v);
59
+ }, (e) => {
60
+ signal.removeEventListener("abort", onAbortEvent);
61
+ reject(e);
62
+ });
63
+ });
64
+ }
36
65
  export class E2bLspManager {
37
- /** Per-sandbox session cache: env (weak) → language → in-flight/ready session. */
66
+ /** Per-sandbox session cache: env (weak) → language → the (self-contained, see `startJob`) shared job. */
38
67
  perEnv = new WeakMap();
68
+ /** §DESIGN-V2 B/F10: env (weak) → language → the reason the MOST RECENT open/heal attempt failed. Cleared
69
+ * on a successful open (F10) so a stale reason never outlives the failure that produced it. */
70
+ lastFailure = new WeakMap();
39
71
  factory;
40
72
  extToLang;
41
73
  log;
74
+ hasServerCommand;
75
+ metrics;
42
76
  /** design/121 (core 1.220, board [S] ①): the workspace diagnostics registry — WORKSPACE-scoped (survives
43
77
  * heal/evict; diagnostics belong to files, not servers), drained by the Runner at turn boundaries. Every
44
78
  * session feeds it from `textDocument/publishDiagnostics` (the WS transport now dispatches notifications).
@@ -50,8 +84,27 @@ export class E2bLspManager {
50
84
  this.factory = opts.transportFactory;
51
85
  this.extToLang = opts.extToLang ?? DEFAULT_EXT_TO_LANG;
52
86
  this.log = opts.log ?? (() => { });
87
+ this.hasServerCommand = opts.hasServerCommand ?? (() => true);
88
+ this.metrics = opts.metrics;
89
+ }
90
+ /**
91
+ * §DESIGN-V2 B: the core 1.86.2+ seam's optional method — WHY the most recent `sessionFor` miss for this
92
+ * file happened. Three reasons are STATIC (re-derived here fresh, every call, from `filePath`+`env` alone —
93
+ * never stored, since they don't need an open attempt to be knowable); the rest are DYNAMIC, read back from
94
+ * `lastFailure`. `undefined` = this exact (env, language) was never attempted (NOT "available" — a
95
+ * best-effort postmortem surface, per the seam's own contract).
96
+ */
97
+ unavailableReason(filePath, env) {
98
+ if (!env || !isLspCapable(env))
99
+ return "env-not-lsp-capable";
100
+ const language = this.extToLang[path.extname(filePath).toLowerCase()];
101
+ if (!language)
102
+ return "language-not-mapped";
103
+ if (!this.hasServerCommand(language))
104
+ return "no-server-command";
105
+ return this.lastFailure.get(env)?.get(language);
53
106
  }
54
- async sessionFor(filePath, _signal, env) {
107
+ async sessionFor(filePath, signal, env) {
55
108
  if (!env || !isLspCapable(env))
56
109
  return undefined; // no env passed (core <1.86.2) or not the E2B adapter → degrade
57
110
  const language = this.extToLang[path.extname(filePath).toLowerCase()];
@@ -62,43 +115,110 @@ export class E2bLspManager {
62
115
  sessions = new Map();
63
116
  this.perEnv.set(env, sessions);
64
117
  }
65
- let pending = sessions.get(language);
66
- const fresh = !pending; // capture BEFORE assigning, or the open/open_failed log is dead code (review 2026-06-10)
67
- if (!pending) {
68
- pending = this.open(language, env);
69
- sessions.set(language, pending);
70
- }
71
- let session = await pending;
72
- if (fresh)
73
- this.log(session ? "lsp_session_open" : "lsp_session_open_failed", { language });
118
+ let pending = sessions.get(language) ?? this.startJob(env, sessions, language);
119
+ // §DESIGN-V2 F9: the shared job never sees a per-caller `signal` it is a CONTRACT, not an accident, that
120
+ // the chain below has nothing to wire one into. `settleOnAbort` only bounds how long THIS caller waits for
121
+ // the (unaffected, still-running-for-everyone-else) shared job — Q16. Wiring a per-caller signal into the
122
+ // shared chain would let one caller's cancellation abort a session other callers are still waiting on.
123
+ let session = await settleOnAbort(pending, signal, () => undefined);
74
124
  if (session?.closed) {
75
125
  // Transport died between tool calls (the E2B proxy idle-kills silent WS connections — live-drill
76
126
  // finding 2026-06-10). Heal: evict + reopen ONCE — the bridge spawns a fresh server per connection.
77
127
  // Carry the dead session's opened files over: a fresh server with no project config only "sees" its
78
128
  // open files, so without the replay cross-file ops silently degrade to single-file (live-repro).
79
- // Guard against clobbering a concurrent heal: a racer that lost sees sessions.get !== its pending and
80
- // adopts the winner's reopen. ⚠️ Relies on JS run-to-completion there is NO await between the
81
- // delete and the set below (this.open() is CALLED but not awaited); do not insert one.
129
+ // Guard against clobbering a concurrent heal: a racer that lost this race adopts whichever job IS
130
+ // current in `sessions` (the winner's). `startJob` itself does the `sessions.set` synchronously (no
131
+ // `await` between creating the job and registering it) the run-to-completion dedupe guard `startJob`
132
+ // documents.
82
133
  const carry = session.openedFiles();
83
- if (sessions.get(language) === pending)
84
- sessions.delete(language);
85
- pending = sessions.get(language) ?? this.open(language, env);
86
- sessions.set(language, pending);
87
- session = await pending;
88
- if (session)
89
- await session.warmOpen(carry);
90
- this.log(session ? "lsp_session_heal" : "lsp_session_heal_failed", { language, carried: carry.length });
134
+ pending = sessions.get(language) === pending ? this.startJob(env, sessions, language, { carry }) : sessions.get(language);
135
+ session = await settleOnAbort(pending, signal, () => undefined);
91
136
  }
92
- // failed open → don't cache the miss (retry next request); guarded so a slow loser awaiting an old failed
93
- // pending can't evict a NEWER in-flight session a concurrent call already cached.
94
- if (!session && sessions.get(language) === pending)
95
- sessions.delete(language);
96
137
  return session;
97
138
  }
139
+ /**
140
+ * §DESIGN-V2 F1 (aligned with core's `NodeLspManager.open`, node-lsp-manager.js :123-153 — same self-
141
+ * contained-job shape, not the same code: that manager also tracks a TTL/LRU cache and per-job
142
+ * `SharedAbortScope`, which this one has no equivalent of per F9). The returned job is SELF-completing:
143
+ *
144
+ * - self-caches: registers itself into `sessions` synchronously BEFORE returning (so a concurrent caller
145
+ * in the SAME microtask sees it — no dedupe race);
146
+ * - self-cleans: on failure, removes ITSELF from `sessions` (never a newer entry a racer already installed
147
+ * — the `sessions.get(language) === job` check) so the next request retries instead of caching a miss;
148
+ * - self-warms: a heal job (`heal` set) `warmOpen`s the carried files as part of settling, before anyone
149
+ * can observe it as "done" — no caller-side extra step, no window where a racer sees a warm-less session;
150
+ * - self-closes an orphan: if a CONCURRENT heal already replaced this job in `sessions` before it settled
151
+ * (this job "lost" the race), the just-opened transport is closed instead of leaked with no owner.
152
+ *
153
+ * Callers must NEVER wrap `settleOnAbort` around a naive `await` of a SHARED promise and then branch the
154
+ * logging/cache-eviction on THAT wrapped value — a caller whose OWN signal aborted would then look
155
+ * indistinguishable from the job itself failing, misfiring `lsp_session_*_failed` and evicting a still-live
156
+ * in-flight entry out from under a concurrent caller (§DESIGN-V2 F1). `sessionFor` only ever wraps the
157
+ * RETURN boundary; every cache/log decision above happens INSIDE the job, driven by the job's own outcome.
158
+ */
159
+ startJob(env, sessions, language, heal) {
160
+ const job = this.open(language, env)
161
+ .then(async (session) => {
162
+ if (!session) {
163
+ if (sessions.get(language) === job)
164
+ sessions.delete(language); // don't cache a miss — retry next request
165
+ this.log(heal ? "lsp_session_heal_failed" : "lsp_session_open_failed", { language, ...(heal ? { carried: heal.carry.length } : {}) });
166
+ this.metrics?.inc(heal ? "lsp_session_heal_total" : "lsp_session_open_total", heal ? { outcome: "failed" } : { language, outcome: "failed" });
167
+ return undefined;
168
+ }
169
+ // review finding ② (independent re-review): `warmOpen` (unlike `syncOne`'s OWN internal `readText`
170
+ // guard) can still reject — e.g. `TransportLspSession.syncOne`'s unwrapped `this.transport.notify(...)`
171
+ // call (core lsp-session.js) — and this is the SHARED job every caller for this env+language awaits;
172
+ // an uncaught rejection here would otherwise propagate through `job` itself. Aligned with core's own
173
+ // precedent for this exact call (node-lsp-manager.js:137): the carry-over is best-effort — a failed
174
+ // resync degrades to "not carried," not an aborted heal.
175
+ if (heal)
176
+ await session.warmOpen(heal.carry).catch(() => ({ opened: 0, failed: heal.carry.length }));
177
+ if (sessions.get(language) !== job) {
178
+ // Orphaned: a concurrent heal replaced us in `sessions` before we settled — the winner's session is
179
+ // what everyone will use; don't leak this one.
180
+ await session.close().catch(() => undefined);
181
+ return undefined;
182
+ }
183
+ this.log(heal ? "lsp_session_heal" : "lsp_session_open", { language, ...(heal ? { carried: heal.carry.length } : {}) });
184
+ this.metrics?.inc(heal ? "lsp_session_heal_total" : "lsp_session_open_total", heal ? { outcome: "ok" } : { language, outcome: "ok" });
185
+ return session;
186
+ })
187
+ .catch((e) => {
188
+ // Defense in depth (review finding ②): ANY other unexpected throw in the callback above (not just
189
+ // warmOpen) must not leave a PERMANENTLY REJECTED promise cached in `sessions` — the self-clean-on-
190
+ // failure logic above only runs on the resolve-undefined path, never on a rejection, so without this
191
+ // every subsequent `sessionFor` for this env+language would throw instead of degrading.
192
+ if (sessions.get(language) === job)
193
+ sessions.delete(language);
194
+ this.log(heal ? "lsp_session_heal_failed" : "lsp_session_open_failed", {
195
+ language,
196
+ error: e instanceof Error ? e.message : String(e),
197
+ ...(heal ? { carried: heal.carry.length } : {}),
198
+ });
199
+ this.metrics?.inc(heal ? "lsp_session_heal_total" : "lsp_session_open_total", heal ? { outcome: "failed" } : { language, outcome: "failed" });
200
+ return undefined;
201
+ });
202
+ sessions.set(language, job); // synchronous — no `await` above (run-to-completion dedupe guard)
203
+ return job;
204
+ }
98
205
  async open(language, env) {
99
- const transport = await this.factory(language, env).catch(() => undefined);
100
- if (!transport)
206
+ // "session-open-failed" is the catch-all: the factory threw instead of returning its normal discriminated
207
+ // result — everything ELSE (install/start/connect/budget failures) is reported through that result, never
208
+ // through a rejection, so reaching this branch itself is already the diagnostic (the factory's own
209
+ // implementation is where a specific reason would have been attached).
210
+ const result = await this.factory(language, env).catch(() => ({ ok: false, reason: "session-open-failed" }));
211
+ let byLang = this.lastFailure.get(env);
212
+ if (!result.ok) {
213
+ if (!byLang) {
214
+ byLang = new Map();
215
+ this.lastFailure.set(env, byLang);
216
+ }
217
+ byLang.set(language, result.reason);
101
218
  return undefined;
219
+ }
220
+ byLang?.delete(language); // F10: a successful open clears any stale reason from a PRIOR failure
221
+ const transport = result.transport;
102
222
  const readText = async (fp) => {
103
223
  const r = await env.readTextFile(fp);
104
224
  if (!r.ok || r.value === undefined)
@@ -36,5 +36,5 @@ export declare class WsLspTransport implements LspTransport {
36
36
  * Returns `undefined` on any failure (→ the manager degrades that language gracefully). `wsUrl` is the
37
37
  * `wss://…e2b.dev` URL from `sandbox.getHost(port)`; `rootPath` is the in-sandbox workspace root.
38
38
  */
39
- export declare function connectWsLspTransport(wsUrl: string, rootPath: string, signal?: AbortSignal): Promise<LspTransport | undefined>;
39
+ export declare function connectWsLspTransport(wsUrl: string, rootPath: string, signal?: AbortSignal, initializationOptions?: unknown): Promise<LspTransport | undefined>;
40
40
  //# sourceMappingURL=ws-transport.d.ts.map
@@ -209,7 +209,7 @@ export class WsLspTransport {
209
209
  * Returns `undefined` on any failure (→ the manager degrades that language gracefully). `wsUrl` is the
210
210
  * `wss://…e2b.dev` URL from `sandbox.getHost(port)`; `rootPath` is the in-sandbox workspace root.
211
211
  */
212
- export async function connectWsLspTransport(wsUrl, rootPath, signal) {
212
+ export async function connectWsLspTransport(wsUrl, rootPath, signal, initializationOptions) {
213
213
  let socket;
214
214
  try {
215
215
  socket = new WebSocket(wsUrl);
@@ -245,6 +245,9 @@ export async function connectWsLspTransport(wsUrl, rootPath, signal) {
245
245
  processId: null,
246
246
  rootUri: pathToUri(rootPath),
247
247
  capabilities: { textDocument: { definition: {}, references: {}, hover: {}, documentSymbol: {}, implementation: {}, callHierarchy: {} }, workspace: { symbol: {} } },
248
+ // #96 真机门:tls 的 tsserver 路径只能走这里(v4 移除了 CLI 旗);非 tls 服务器按 LSP 规范忽略
249
+ // 未知 initializationOptions。缺席时不发键(老行为逐字保持)。
250
+ ...(initializationOptions !== undefined ? { initializationOptions } : {}),
248
251
  }, signal);
249
252
  transport.notify("initialized", {});
250
253
  return transport;
@@ -295,6 +295,12 @@ export function createMetrics() {
295
295
  // LOCAL floor(now/windowMs); replica clock skew splits a fleet window into disjoint buckets (soft-limit leak).
296
296
  m.gauge("fleet_counter_bucket_skew_ms", "DB clock minus local clock in ms (S10) — skew splits cross-replica counter windows");
297
297
  m.gauge("fleet_clock_probe_ok", "1 = last DB clock probe succeeded, 0 = failing (skew gauge is stale while 0)");
298
+ // #96 redesign⑤ (§DESIGN-V2 E): LSP sandbox-bridge lifecycle + session outcomes — previously zero metrics
299
+ // existed for the whole subsystem (a dead bridge or a stuck restart budget had no fleet-level signal).
300
+ m.counter("lsp_bridge_start_total", "LSP sandbox bridge (re)starts attempted, by provider and generation");
301
+ m.counter("lsp_bridge_dead_total", "LSP sandbox bridge generations that died, by reason (the word IS the model-facing unavailableReason)");
302
+ m.counter("lsp_session_open_total", "LSP session opens, by language and outcome (ok/failed)");
303
+ m.counter("lsp_session_heal_total", "LSP session heals (dead-transport reopen), by outcome (ok/failed)");
298
304
  // S21 — which backend snapshot blobs actually land on (minio|sql). Partial MINIO_* config silently falls to sql.
299
305
  m.gauge("snapshot_blob_backend", "1 on the active snapshot-blob backend series (S21), by backend (minio|sql)");
300
306
  // S22 — memory embed/KNN runtime failures (boot said vector; runtime silently degrades to lexical).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",