@sema-agent/server 4.2.0 → 5.0.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/USAGE.md +5 -3
- package/dist/approval-hmac.js +1 -1
- package/dist/approval.d.ts +5 -37
- package/dist/approval.js +12 -112
- package/dist/auth-bridge.d.ts +0 -1
- package/dist/auth-bridge.js +0 -1
- package/dist/auth-keys.d.ts +1 -1
- package/dist/auth-keys.js +1 -1
- package/dist/bench/s1/run-firm.js +1 -1
- package/dist/boot/config-center.d.ts +1 -1
- package/dist/boot/config-center.js +3 -2
- package/dist/boot/execution-env.js +8 -1
- package/dist/boot/reapers.d.ts +1 -2
- package/dist/boot/reapers.js +4 -5
- package/dist/boot/resolve-spec.d.ts +1 -3
- package/dist/boot/resolve-spec.js +21 -35
- package/dist/boot/stores.js +1 -1
- package/dist/brain.js +3 -1
- package/dist/capabilities/center-prompts.d.ts +2 -3
- package/dist/capabilities/center-prompts.js +2 -3
- package/dist/capabilities/sandbox-file-send.d.ts +0 -2
- package/dist/capabilities/sandbox-file-send.js +2 -5
- package/dist/capabilities/scenarios.d.ts +0 -9
- package/dist/capabilities/scenarios.js +1 -20
- package/dist/capabilities/send-user-file-tool.js +3 -3
- package/dist/config-center/apply-effective.js +8 -8
- package/dist/config-center/types.d.ts +10 -3
- package/dist/config-provider.js +6 -11
- package/dist/config-types.d.ts +12 -26
- package/dist/config.js +95 -68
- package/dist/fleet/fleet-bus.js +25 -33
- package/dist/fleet-client.js +1 -1
- package/dist/http/active-run-conflict.d.ts +2 -0
- package/dist/http/active-run-conflict.js +1 -0
- package/dist/http/routes/approvals-assistant.d.ts +2 -1
- package/dist/http/routes/approvals-assistant.js +10 -96
- package/dist/http/routes/capabilities.js +6 -5
- package/dist/http/routes/fleet.js +4 -1
- package/dist/http/routes/images.js +10 -4
- package/dist/http/routes/runs.js +34 -49
- package/dist/http/routes/session-sync.d.ts +1 -1
- package/dist/http/routes/session-sync.js +2 -1
- package/dist/http/routes/tasks.js +7 -7
- package/dist/http/routes/trace-usage.js +1 -1
- package/dist/http/server.d.ts +18 -7
- package/dist/http/server.js +25 -14
- package/dist/http/verify-rounds.d.ts +2 -2
- package/dist/http/wire-types.d.ts +1 -1
- package/dist/images/bake-validate.d.ts +0 -22
- package/dist/images/bake-validate.js +0 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -5
- 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/main.js +5 -9
- package/dist/memory-scope.js +2 -2
- package/dist/observability/metrics.js +6 -0
- package/dist/orchestration/workflow-completion-inbox.d.ts +6 -4
- package/dist/orchestration/workflow-completion-inbox.js +6 -4
- package/dist/plugins/breaker-state-sql.d.ts +2 -3
- package/dist/plugins/image-index-sql.d.ts +0 -1
- package/dist/plugins/image-index-sql.js +0 -1
- package/dist/plugins/local-checkpoint-store.d.ts +1 -1
- package/dist/plugins/local-checkpoint-store.js +1 -1
- package/dist/plugins/memory-engine-pg.js +2 -1
- package/dist/plugins/memory-engine-tidb.js +1 -1
- package/dist/plugins/memory-engine-vector-util.d.ts +2 -3
- package/dist/plugins/memory-engine-vector-util.js +2 -3
- package/dist/plugins/memory-run-store.d.ts +1 -1
- package/dist/plugins/memory-session-policy-store.d.ts +1 -1
- package/dist/plugins/memory-session-policy-store.js +1 -1
- package/dist/plugins/memory-sync-store-pg.js +1 -1
- package/dist/plugins/pg-pool.js +8 -10
- package/dist/plugins/pg-rate-limiter.d.ts +1 -4
- package/dist/plugins/pg-rate-limiter.js +0 -1
- package/dist/plugins/roster-store-sql.d.ts +1 -3
- package/dist/plugins/roster-store-sql.js +3 -5
- package/dist/plugins/store-backend.d.ts +7 -17
- package/dist/plugins/store-backend.js +14 -30
- package/dist/plugins/tidb-pool.js +4 -28
- package/dist/plugins/workflow-journal-store-sql.d.ts +1 -1
- package/dist/plugins/workflow-journal-store-sql.js +1 -1
- package/dist/principal-jwt.js +2 -2
- package/dist/prompts-domain-validate.d.ts +4 -3
- package/dist/prompts-domain-validate.js +7 -8
- package/dist/router/route-orchestration.js +3 -3
- package/dist/run-local.js +5 -4
- package/dist/runs.js +8 -6
- package/dist/runtime-caps-resolver.d.ts +3 -2
- package/dist/runtime-caps-resolver.js +4 -4
- package/dist/runtime-governance.d.ts +4 -7
- package/dist/runtime-governance.js +12 -17
- package/dist/sealed-key.d.ts +1 -1
- package/dist/sealed-key.js +1 -1
- package/dist/security.d.ts +0 -5
- package/dist/security.js +8 -12
- package/dist/session-sync.d.ts +0 -2
- package/dist/session-sync.js +10 -17
- package/dist/spec-fields.js +6 -6
- package/dist/task-settings.js +7 -7
- package/dist/tool-approval.d.ts +3 -3
- package/dist/tool-approval.js +10 -10
- package/dist/trace/artifacts.d.ts +11 -0
- package/dist/trace/artifacts.js +8 -19
- package/dist/trace/ledger-sink.js +7 -1
- package/dist/trace/project.d.ts +17 -0
- package/dist/trace/project.js +55 -5
- package/package.json +2 -2
- package/dist/capabilities/oa-tools.d.ts +0 -15
- package/dist/capabilities/oa-tools.js +0 -54
- package/dist/plugins/approval-store-sql.d.ts +0 -116
- package/dist/plugins/approval-store-sql.js +0 -150
- package/dist/plugins/file-workflow-journal-store.d.ts +0 -12
- package/dist/plugins/file-workflow-journal-store.js +0 -12
- package/dist/plugins/pg-approval-store.d.ts +0 -9
- package/dist/plugins/pg-approval-store.js +0 -9
- package/dist/plugins/pg-breaker-state.d.ts +0 -8
- package/dist/plugins/pg-breaker-state.js +0 -8
- package/dist/plugins/pg-checkpoint-store.d.ts +0 -10
- package/dist/plugins/pg-checkpoint-store.js +0 -10
- package/dist/plugins/pg-file-snapshot-store.d.ts +0 -8
- package/dist/plugins/pg-file-snapshot-store.js +0 -8
- package/dist/plugins/pg-image-bake.d.ts +0 -12
- package/dist/plugins/pg-image-bake.js +0 -11
- package/dist/plugins/pg-image-index.d.ts +0 -12
- package/dist/plugins/pg-image-index.js +0 -11
- package/dist/plugins/pg-outcome-ledger.d.ts +0 -12
- package/dist/plugins/pg-outcome-ledger.js +0 -11
- package/dist/plugins/pg-resume-anchor-store.d.ts +0 -7
- package/dist/plugins/pg-resume-anchor-store.js +0 -7
- package/dist/plugins/pg-run-store.d.ts +0 -9
- package/dist/plugins/pg-run-store.js +0 -9
- package/dist/plugins/pg-session-policy-store.d.ts +0 -7
- package/dist/plugins/pg-session-policy-store.js +0 -7
- package/dist/plugins/pg-session-store.d.ts +0 -12
- package/dist/plugins/pg-session-store.js +0 -12
- package/dist/plugins/pg-tool-result-store.d.ts +0 -9
- package/dist/plugins/pg-tool-result-store.js +0 -9
- package/dist/plugins/pg-workflow-journal-store.d.ts +0 -9
- package/dist/plugins/pg-workflow-journal-store.js +0 -9
- package/dist/plugins/pg-workflow-run-store.d.ts +0 -9
- package/dist/plugins/pg-workflow-run-store.js +0 -9
- package/dist/plugins/tidb-approval-store.d.ts +0 -8
- package/dist/plugins/tidb-approval-store.js +0 -8
- package/dist/plugins/tidb-breaker-state.d.ts +0 -7
- package/dist/plugins/tidb-breaker-state.js +0 -7
- package/dist/plugins/tidb-checkpoint-store.d.ts +0 -9
- package/dist/plugins/tidb-checkpoint-store.js +0 -9
- package/dist/plugins/tidb-file-snapshot-store.d.ts +0 -8
- package/dist/plugins/tidb-file-snapshot-store.js +0 -8
- package/dist/plugins/tidb-image-bake.d.ts +0 -12
- package/dist/plugins/tidb-image-bake.js +0 -11
- package/dist/plugins/tidb-image-index.d.ts +0 -12
- package/dist/plugins/tidb-image-index.js +0 -11
- package/dist/plugins/tidb-outcome-ledger.d.ts +0 -12
- package/dist/plugins/tidb-outcome-ledger.js +0 -12
- package/dist/plugins/tidb-resume-anchor-store.d.ts +0 -7
- package/dist/plugins/tidb-resume-anchor-store.js +0 -7
- package/dist/plugins/tidb-run-store.d.ts +0 -10
- package/dist/plugins/tidb-run-store.js +0 -9
- package/dist/plugins/tidb-session-policy-store.d.ts +0 -7
- package/dist/plugins/tidb-session-policy-store.js +0 -7
- package/dist/plugins/tidb-tool-result-store.d.ts +0 -8
- package/dist/plugins/tidb-tool-result-store.js +0 -10
- package/dist/plugins/tidb-workflow-journal-store.d.ts +0 -9
- package/dist/plugins/tidb-workflow-journal-store.js +0 -9
- package/dist/plugins/tidb-workflow-run-store.d.ts +0 -10
- package/dist/plugins/tidb-workflow-run-store.js +0 -10
- package/dist/plugins/workflow-journal-limits.d.ts +0 -12
- package/dist/plugins/workflow-journal-limits.js +0 -12
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
|