@sema-agent/server 4.1.3 → 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.
- package/dist/boot/execution-env.js +8 -1
- package/dist/http/routes/runs.js +1 -1
- package/dist/http/server.d.ts +17 -1
- package/dist/http/server.js +10 -4
- package/dist/lsp/e2b-bridge.d.ts +36 -3
- package/dist/lsp/e2b-bridge.js +94 -7
- package/dist/lsp/e2b-manager.d.ts +64 -1
- package/dist/lsp/e2b-manager.js +197 -28
- package/dist/lsp/manager.d.ts +71 -4
- package/dist/lsp/manager.js +149 -29
- package/dist/lsp/ws-transport.d.ts +1 -1
- package/dist/lsp/ws-transport.js +4 -1
- package/dist/observability/metrics.js +6 -0
- package/dist/orchestration/workflow-notify-journal.js +4 -1
- package/package.json +2 -2
|
@@ -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({
|
|
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
|
package/dist/http/routes/runs.js
CHANGED
|
@@ -670,7 +670,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
670
670
|
leafId,
|
|
671
671
|
gate: { kind: "task_done" },
|
|
672
672
|
pendingAction: { kind: "task_done" },
|
|
673
|
-
state: { activeTools: [], nestedStats: { tokens: 0, turns: 0, tasks: 0, costMicroUsd: 0 } },
|
|
673
|
+
state: { activeTools: [], nestedStats: { tokens: 0, turns: 0, tasks: 0, costMicroUsd: 0, anyUnpriced: false } },
|
|
674
674
|
status: "pending",
|
|
675
675
|
createdAt: Date.now(),
|
|
676
676
|
sourceTaskId: taskId,
|
package/dist/http/server.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
package/dist/http/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/lsp/e2b-bridge.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
package/dist/lsp/e2b-bridge.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
package/dist/lsp/e2b-manager.js
CHANGED
|
@@ -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.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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(() =>
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
76
|
-
//
|
|
77
|
-
//
|
|
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
|
|
85
|
-
if (!
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
243
|
+
const started = await handle.ready;
|
|
91
244
|
if (!started)
|
|
92
|
-
return
|
|
93
|
-
|
|
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
|
}
|
package/dist/lsp/manager.d.ts
CHANGED
|
@@ -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<
|
|
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 →
|
|
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
|
-
|
|
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
|
package/dist/lsp/manager.js
CHANGED
|
@@ -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 →
|
|
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,
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
80
|
-
//
|
|
81
|
-
//
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
package/dist/lsp/ws-transport.js
CHANGED
|
@@ -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).
|
|
@@ -412,7 +412,10 @@ export class JournalingWorkflowRunStore {
|
|
|
412
412
|
if (!this.fleetBus)
|
|
413
413
|
return;
|
|
414
414
|
const agents = run.agents ?? [];
|
|
415
|
-
|
|
415
|
+
// [2336] doneCount 与 failedCount **不相交**:done 只数 completed。契约以此为前提(fleet-bus.ts
|
|
416
|
+
// startedCount 注的回退式 done+failed ≤ started);把 failed 也计进 done 会让全失败 workflow 的
|
|
417
|
+
// 终帧渲成 "N done"(cli 4.1.3 实测 done=2 failed=2 started=2)。
|
|
418
|
+
const done = agents.filter((a) => a.status === "completed").length;
|
|
416
419
|
const failed = agents.filter((a) => a.status === "failed").length;
|
|
417
420
|
// cli [1726] 二①:CC 规模告警的分母是 **started**(已启动),而 `totalCount`(= agents.length)是**计划总数**
|
|
418
421
|
// (含排队中)。判别口径走 core 导出的 `deriveAgentDisplayStatus` —— 一个 agent 已在 `run.agents` 里但
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/server",
|
|
3
|
-
"version": "4.
|
|
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",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@sema-agent/core": "^
|
|
57
|
+
"@sema-agent/core": "^4.0.0",
|
|
58
58
|
"@sema-agent/registry-core": "^0.13.0",
|
|
59
59
|
"e2b": "^2.28.0",
|
|
60
60
|
"libsodium-wrappers": "^0.8.4",
|