@sema-agent/server 3.22.0 → 3.24.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/bake-runner/main.d.ts +2 -2
- package/dist/bake-runner/main.js +32 -9
- package/dist/boot/config-center.js +9 -2
- package/dist/capabilities/center-plugins.js +1 -1
- package/dist/capabilities/skills.d.ts +12 -1
- package/dist/capabilities/skills.js +31 -6
- package/dist/config-center/apply-effective.d.ts +4 -18
- package/dist/config-center/apply-effective.js +140 -47
- package/dist/config-center/http-client.d.ts +9 -2
- package/dist/config-center/http-client.js +20 -2
- package/dist/config-provider.js +8 -2
- package/dist/config.d.ts +12 -0
- package/dist/config.js +17 -0
- package/dist/fleet/fleet-bus.js +4 -1
- package/dist/hooks/hook-llm.js +1 -1
- package/dist/hooks/hook-runner.d.ts +4 -0
- package/dist/hooks/hook-runner.js +4 -4
- package/dist/http/routes/runs.js +6 -1
- package/dist/http/server.js +8 -4
- package/dist/lsp/e2b-bridge.js +14 -1
- package/dist/lsp/e2b-manager.d.ts +8 -4
- package/dist/lsp/e2b-manager.js +55 -23
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ImageApiClient, type ChildSpawner, type HostOps } from "./runner.js";
|
|
1
|
+
import { type ImageApiClient, type ChildSpawner, type HostOps, type BakeRunnerLogger } from "./runner.js";
|
|
2
2
|
interface RunnerEnv {
|
|
3
3
|
imageApiBase: string;
|
|
4
4
|
token: string;
|
|
@@ -14,7 +14,7 @@ interface RunnerEnv {
|
|
|
14
14
|
declare function loadEnv(): RunnerEnv;
|
|
15
15
|
/** The real image-api HTTP client. Authenticates claim/heartbeat with the runner bearer; ingest additionally
|
|
16
16
|
* carries the per-bake ingest secret header (so image-api can reject a stale/rogue runner — §P2.4e). */
|
|
17
|
-
declare function makeApiClient(env: RunnerEnv): ImageApiClient;
|
|
17
|
+
declare function makeApiClient(env: RunnerEnv, log: BakeRunnerLogger): ImageApiClient;
|
|
18
18
|
/** The real child spawner: `setsid`-style detached process GROUP so a cancel/deadline kill signals the whole tree
|
|
19
19
|
* (channel-close / a plain kill of the immediate child does NOT stop a detached build — §P2.11). */
|
|
20
20
|
declare function makeSpawner(): ChildSpawner;
|
package/dist/bake-runner/main.js
CHANGED
|
@@ -11,6 +11,7 @@ import { spawn, exec as execCb } from "node:child_process";
|
|
|
11
11
|
import { readFile } from "node:fs/promises";
|
|
12
12
|
import { promisify } from "node:util";
|
|
13
13
|
import { createLogger } from "../observability/logger.js";
|
|
14
|
+
import { parseNumOrFailNonNegative } from "../config.js";
|
|
14
15
|
import { BakeRunner, } from "./runner.js";
|
|
15
16
|
const exec = promisify(execCb);
|
|
16
17
|
function loadEnv() {
|
|
@@ -28,14 +29,22 @@ function loadEnv() {
|
|
|
28
29
|
recipeDir: process.env.RECIPE_DIR || "/opt/recipes",
|
|
29
30
|
buildShPath: process.env.BUILD_SH_PATH || "e2b-template/dev-sandbox/build.sh",
|
|
30
31
|
dataDir: process.env.BAKE_DATA_DIR || "/data",
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// A2/E5 (gap-sweep 2026-08-01): these were bare `Number(process.env.X || default)` — a unit-suffixed or
|
|
33
|
+
// typo'd value (`"30s"`) silently became NaN, and NaN survives every downstream `??`/`?? default` guard
|
|
34
|
+
// (those only catch null/undefined). The fallout is a SILENT-LOOSENING pair: `clock.sleep(NaN)` resolves
|
|
35
|
+
// ~immediately (`setTimeout(fn, NaN)` behaves like a 0ms timer) turning the lease heartbeat into a tight
|
|
36
|
+
// POST loop against image-api, and `freeGb < NaN` is always false so the pre-flight disk guard silently
|
|
37
|
+
// never fires. `parseNumOrFailNonNegative` (config.ts, shared with leader/wire.ts's four resource knobs)
|
|
38
|
+
// fails loud at bake-runner startup instead — non-numeric OR negative both throw; unset/empty still falls
|
|
39
|
+
// back to the documented default (zero behavior change on the happy path).
|
|
40
|
+
heartbeatMs: parseNumOrFailNonNegative("BAKE_HEARTBEAT_MS", process.env.BAKE_HEARTBEAT_MS || "30000"),
|
|
41
|
+
idlePollMs: parseNumOrFailNonNegative("BAKE_IDLE_POLL_MS", process.env.BAKE_IDLE_POLL_MS || "5000"),
|
|
42
|
+
minFreeGb: parseNumOrFailNonNegative("IMAGE_BAKE_MIN_FREE_GB", process.env.IMAGE_BAKE_MIN_FREE_GB || "20"),
|
|
34
43
|
};
|
|
35
44
|
}
|
|
36
45
|
/** The real image-api HTTP client. Authenticates claim/heartbeat with the runner bearer; ingest additionally
|
|
37
46
|
* carries the per-bake ingest secret header (so image-api can reject a stale/rogue runner — §P2.4e). */
|
|
38
|
-
function makeApiClient(env) {
|
|
47
|
+
function makeApiClient(env, log) {
|
|
39
48
|
const auth = { authorization: `Bearer ${env.token}`, "content-type": "application/json" };
|
|
40
49
|
return {
|
|
41
50
|
// Long-poll claim (§P2.12): the bare `POST …/bakes/claim` finds the oldest queued bake AND leases it in one
|
|
@@ -46,13 +55,27 @@ function makeApiClient(env) {
|
|
|
46
55
|
headers: auth,
|
|
47
56
|
body: JSON.stringify({ runnerId: env.runnerId }),
|
|
48
57
|
});
|
|
49
|
-
if (res.status === 204
|
|
50
|
-
return null; // empty queue
|
|
51
|
-
|
|
58
|
+
if (res.status === 204)
|
|
59
|
+
return null; // empty queue — the routine "nothing to do" case, no log line
|
|
60
|
+
// C6/C1 (2026-08-01): 401 (bad runner credential) / 409 (lost the single-flight CAS to another runner
|
|
61
|
+
// replica) / 5xx previously fell into this SAME silent-`null` branch as an empty queue — a misconfigured
|
|
62
|
+
// BAKE_RUNNER_TOKEN and a healthy idle runner were indistinguishable from the outside (both just idle-poll
|
|
63
|
+
// forever with zero log lines). `bake_claim_error` (loop()'s catch) only fires on a THROWN error — a non-ok
|
|
64
|
+
// HTTP response never throws — so this warn is the only trace a non-2xx claim response leaves anywhere.
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
log.warn("bake_claim_rejected", { status: res.status });
|
|
52
67
|
return null;
|
|
68
|
+
}
|
|
53
69
|
const c = (await res.json());
|
|
54
|
-
if (!c.bakeId || !Array.isArray(c.argv) || !c.ingestSecret)
|
|
70
|
+
if (!c.bakeId || !Array.isArray(c.argv) || !c.ingestSecret) {
|
|
71
|
+
// A malformed 2xx body is worse than a rejection: image-api's claim call ATOMICALLY leases the bake
|
|
72
|
+
// server-side before returning it, so a shape we refuse to trust here is a bake already checked OUT
|
|
73
|
+
// from the queue that we are about to drop on the floor. We still can't safely act on an untrusted
|
|
74
|
+
// shape (no bakeId ⇒ no way to release/fail it), so this warn is the only surfacing point until
|
|
75
|
+
// image-api's stale-lease reaper reclaims it (§P2.7) — see runner.ts module doc for the reaper backstop.
|
|
76
|
+
log.warn("bake_claim_malformed", { status: res.status, bakeId: typeof c.bakeId === "string" ? c.bakeId : undefined });
|
|
55
77
|
return null;
|
|
78
|
+
}
|
|
56
79
|
return {
|
|
57
80
|
bakeId: c.bakeId,
|
|
58
81
|
argv: c.argv.map(String),
|
|
@@ -223,7 +246,7 @@ async function main() {
|
|
|
223
246
|
minFreeGb: env.minFreeGb,
|
|
224
247
|
}); // NB: the token + per-bake ingest secret are NEVER logged (§P2.4e)
|
|
225
248
|
const runner = new BakeRunner({
|
|
226
|
-
api: makeApiClient(env),
|
|
249
|
+
api: makeApiClient(env, log),
|
|
227
250
|
spawner: makeSpawner(),
|
|
228
251
|
host: makeHostOps(env),
|
|
229
252
|
clock: makeClock(),
|
|
@@ -780,7 +780,11 @@ export async function createConfigCenterRuntime(ctx) {
|
|
|
780
780
|
// expanded admission gate while core throws "Unknown model ref" until restart. Hot-apply is safe
|
|
781
781
|
// only when BOTH generations are tier-less.
|
|
782
782
|
const planeDeferred = (runnerTierFrozen || planeHasActiveTiers(r.effective)) && modelPlaneChanged(appliedPlaneEff, r.effective);
|
|
783
|
-
applyEffective(config, r.effective, logger, { teamsOnly: true, sealedKeys, ...(planeDeferred ? { deferModelPlane: true } : {}) });
|
|
783
|
+
const committed = applyEffective(config, r.effective, logger, { teamsOnly: true, sealedKeys, ...(planeDeferred ? { deferModelPlane: true } : {}) });
|
|
784
|
+
// [2283]③:CAS 拒绝(更旧世代)⇒ 本拍整体跳过——prompts 采用/pricing/keyResolver/restart
|
|
785
|
+
// 对账/etag 推进/LKG 落盘都不得从旁路半应用同一个被拒世代(拒绝 warn 已在 applyEffective 留痕)。
|
|
786
|
+
if (!committed)
|
|
787
|
+
return;
|
|
784
788
|
if (planeDeferred) {
|
|
785
789
|
logger.warn("models_tiers_plane_deferred", { version: r.effective.version, note: "tier-frozen Runner: the changed model plane (models/roles/tiers/default) is NOT hot-applied — admission stays on the Runner's generation; restart applies the new plane (models-tiers restart signal rides /health)" });
|
|
786
790
|
}
|
|
@@ -954,7 +958,10 @@ export async function createConfigCenterRuntime(ctx) {
|
|
|
954
958
|
// this late arrival — if it froze a tier-expanded copy, the arriving center plane must not hot-apply
|
|
955
959
|
// (admission/Runner split). Tier-less env boot (the common deferred-boot shape) keeps true hot-apply.
|
|
956
960
|
const planeDeferredLate = (runnerTierFrozen || planeHasActiveTiers(r.effective)) && modelPlaneChanged(appliedPlaneEff, r.effective);
|
|
957
|
-
applyEffective(config, r.effective, logger, { teamsOnly: true, sealedKeys, ...(planeDeferredLate ? { deferModelPlane: true } : {}) });
|
|
961
|
+
const committedLate = applyEffective(config, r.effective, logger, { teamsOnly: true, sealedKeys, ...(planeDeferredLate ? { deferModelPlane: true } : {}) });
|
|
962
|
+
// [2283]③(refresh 腿同款):CAS 拒绝 ⇒ 迟到 boot 拍整体跳过,旁路消费与 etag/LKG 都不动。
|
|
963
|
+
if (!committedLate)
|
|
964
|
+
return;
|
|
958
965
|
if (planeDeferredLate)
|
|
959
966
|
logger.warn("models_tiers_plane_deferred", { version: r.effective.version, note: "tier-frozen Runner (env tiers): the late-boot center model plane is NOT hot-applied — restart applies it" });
|
|
960
967
|
else {
|
|
@@ -180,7 +180,7 @@ export async function applyCenterPlugins(baseline, eff, opts) {
|
|
|
180
180
|
}
|
|
181
181
|
if (!rootCheck.dir)
|
|
182
182
|
continue; // 无 skills 目录:合法(commands-only 插件,cli 壳的半场)
|
|
183
|
-
const loaded = loadSkills(rootCheck.dir);
|
|
183
|
+
const loaded = loadSkills(rootCheck.dir, { confineTo: liveDir }); // 不可信 lane:逐条目约束在 clone root 内(根守卫只守了 skills 根,根下 symlink 可逃逸) // 不可信 lane:逐条目约束在 clone root 内(根守卫只守了 skills 根,根下 symlink 可逃逸) // 不可信 lane:逐条目约束在 clone root 内(根守卫只守了 skills 根,根下 symlink 可逃逸)
|
|
184
184
|
for (const skill of loaded) {
|
|
185
185
|
if (taken.has(skill.spec.name)) {
|
|
186
186
|
// 收紧④:plugin 让位(方向与 center-wins 相反,理由见模块顶注)。
|
|
@@ -31,7 +31,18 @@ export interface LoadedSkill {
|
|
|
31
31
|
/** Scenarios this skill applies to; empty = global (every scenario). */
|
|
32
32
|
scenarios: string[];
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
/** 加载 lane 的信任级。缺席(受控 lane:烤制进镜像的 skills 目录)= 维持既有语义,symlink 照跟
|
|
35
|
+
* ——那个便利是有意的(`foo.md -> 共享文件`)。`confineTo` 在场(**不可信 lane**:第三方 git checkout,
|
|
36
|
+
* 见 center-plugins 的 plugin 装载)= 每个被读条目 realpath 后必须仍在该根之内,逃逸即抛。
|
|
37
|
+
* 🔴 为什么信任级必须是**参数**而不是注释里的假设:本函数的头注原写着「skills dir 全受控」,而
|
|
38
|
+
* center-plugins 把它原样复用在 attacker-controlled 的 git checkout 上——git 原样保留仓里的 symlink,
|
|
39
|
+
* 于是 checkout 里某个 skill 条目(`任意 .md -> /宿主/任意文件`)会被读进 spec.files 进入提示词。resolvePluginSkillsRoot
|
|
40
|
+
* 只 realpath 守了 skills **根**,根下逐条目没守。 */
|
|
41
|
+
export interface LoadSkillsOptions {
|
|
42
|
+
/** 不可信 lane 的约束根(通常=clone root)。缺席=受控 lane,不做逐条目约束。 */
|
|
43
|
+
confineTo?: string;
|
|
44
|
+
}
|
|
45
|
+
export declare function loadSkills(dir: string, opts?: LoadSkillsOptions): LoadedSkill[];
|
|
35
46
|
/** Skills applicable to a scenario: those tagged with it, plus untagged (global) ones. */
|
|
36
47
|
export declare function skillsForScenario(loaded: LoadedSkill[], scenario: string): SkillSpec[];
|
|
37
48
|
export declare function parseFrontmatter(raw: string): {
|
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
|
|
1
|
+
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { join, sep } from "node:path";
|
|
3
|
+
/** `p` 的 realpath 必须落在 `rootReal` 之内(含自身),否则抛。`sep` 边界判定防 `/a/root-evil` 撞
|
|
4
|
+
* `/a/root` 前缀。目标不存在 ⇒ realpath 抛 ENOENT,同样是拒绝(悬空链接不该被读)。 */
|
|
5
|
+
function assertConfined(p, rootReal) {
|
|
6
|
+
let real;
|
|
7
|
+
try {
|
|
8
|
+
real = realpathSync(p);
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
throw new Error(`skill entry ${p} is unreadable (dangling symlink or missing): ${e instanceof Error ? e.message : String(e)}`);
|
|
12
|
+
}
|
|
13
|
+
if (real !== rootReal && !real.startsWith(rootReal + sep)) {
|
|
14
|
+
throw new Error(`skill entry ${p} escapes the plugin root (resolves outside ${rootReal}) — untrusted checkouts may not link to host files`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function loadSkills(dir, opts) {
|
|
4
18
|
let entries;
|
|
5
19
|
try {
|
|
6
20
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
@@ -21,17 +35,22 @@ export function loadSkills(dir) {
|
|
|
21
35
|
// sort 保证加载顺序稳定(冲突报错的"先到者"也因此确定)。dotfile/dot 目录跳过(.DS_Store/.git 类
|
|
22
36
|
// 环境杂物,不是 skill 内容);isFileLike 兼容 symlink(Dirent.isFile() 对 symlink 恒 false,而旧扁平
|
|
23
37
|
// 实现 readFileSync 是跟链接的——不兼容会让 skills 目录下 `foo.md -> 共享文件` 这种软链无声消失)。
|
|
38
|
+
const confineRoot = opts?.confineTo !== undefined ? realpathSync(opts.confineTo) : undefined;
|
|
24
39
|
const isFileLike = (entry, p) => entry.isFile() || (entry.isSymbolicLink() && statSync(p).isFile());
|
|
25
40
|
const isDirLike = (entry, p) => entry.isDirectory() || (entry.isSymbolicLink() && statSync(p).isDirectory());
|
|
26
41
|
for (const entry of entries.slice().sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
27
42
|
if (entry.name.startsWith("."))
|
|
28
43
|
continue;
|
|
29
44
|
if (isFileLike(entry, join(dir, entry.name)) && entry.name.endsWith(".md")) {
|
|
45
|
+
if (confineRoot !== undefined)
|
|
46
|
+
assertConfined(join(dir, entry.name), confineRoot);
|
|
30
47
|
const raw = readFileSync(join(dir, entry.name), "utf8");
|
|
31
48
|
push(parseSkill(raw, entry.name.replace(/\.md$/, "")), join(dir, entry.name));
|
|
32
49
|
}
|
|
33
50
|
else if (isDirLike(entry, join(dir, entry.name))) {
|
|
34
51
|
const skillDir = join(dir, entry.name);
|
|
52
|
+
if (confineRoot !== undefined)
|
|
53
|
+
assertConfined(skillDir, confineRoot);
|
|
35
54
|
let raw;
|
|
36
55
|
try {
|
|
37
56
|
raw = readFileSync(join(skillDir, "SKILL.md"), "utf8");
|
|
@@ -41,8 +60,10 @@ export function loadSkills(dir) {
|
|
|
41
60
|
// 整个 skill 无声丢掉 → fail-loud
|
|
42
61
|
throw new Error(`skill directory ${skillDir} has no SKILL.md`);
|
|
43
62
|
}
|
|
63
|
+
if (confineRoot !== undefined)
|
|
64
|
+
assertConfined(join(skillDir, "SKILL.md"), confineRoot);
|
|
44
65
|
const skill = parseSkill(raw, entry.name);
|
|
45
|
-
const files = collectAttachments(skillDir, "");
|
|
66
|
+
const files = collectAttachments(skillDir, "", confineRoot);
|
|
46
67
|
if (files.length > 0)
|
|
47
68
|
skill.spec.files = files;
|
|
48
69
|
push(skill, join(skillDir, "SKILL.md"));
|
|
@@ -53,7 +74,7 @@ export function loadSkills(dir) {
|
|
|
53
74
|
/** 目录形态附件:递归收 SKILL.md 之外的一切文件,path=相对 skill 目录(posix 斜杠),sort 稳定。
|
|
54
75
|
* dotfile/dot 目录跳过(交叉评审 M3):skill 目录若从别的 checkout 整拷,.git/config、.env 类隐藏物
|
|
55
76
|
* 会连凭据一起进 spec.files 变成提示词可见附件——附件只收显式内容文件。symlink 跟链接(同扁平兼容)。 */
|
|
56
|
-
function collectAttachments(skillDir, rel) {
|
|
77
|
+
function collectAttachments(skillDir, rel, confineRoot) {
|
|
57
78
|
const files = [];
|
|
58
79
|
const here = rel === "" ? skillDir : join(skillDir, rel);
|
|
59
80
|
for (const entry of readdirSync(here, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
|
|
@@ -64,9 +85,13 @@ function collectAttachments(skillDir, rel) {
|
|
|
64
85
|
const dirLike = entry.isDirectory() || (entry.isSymbolicLink() && statSync(abs).isDirectory());
|
|
65
86
|
const fileLike = entry.isFile() || (entry.isSymbolicLink() && statSync(abs).isFile());
|
|
66
87
|
if (dirLike) {
|
|
67
|
-
|
|
88
|
+
if (confineRoot !== undefined)
|
|
89
|
+
assertConfined(abs, confineRoot);
|
|
90
|
+
files.push(...collectAttachments(skillDir, relPath, confineRoot));
|
|
68
91
|
}
|
|
69
92
|
else if (fileLike && relPath !== "SKILL.md") {
|
|
93
|
+
if (confineRoot !== undefined)
|
|
94
|
+
assertConfined(abs, confineRoot);
|
|
70
95
|
files.push({ path: relPath, content: readFileSync(abs, "utf8") });
|
|
71
96
|
}
|
|
72
97
|
}
|
|
@@ -8,11 +8,14 @@ import type { EffectiveConfig } from "./types.js";
|
|
|
8
8
|
* `this.deps.models/roles/pricing` LIVE per-task and `/v1/models` reads `config.models` — both share the
|
|
9
9
|
* reference captured at boot, so mutating it (vs reassigning) updates both with no split, no Runner rebuild. */
|
|
10
10
|
export declare function mutateInPlace<V>(target: Record<string, V>, source: Record<string, V>): void;
|
|
11
|
+
/** 返回值=是否 COMMIT(false 仅在 [2283]③ 世代序 CAS 拒绝时)。caller 收到 false 必须把**同一世代的
|
|
12
|
+
* 旁路消费**(prompts 采用/pricing/keyResolver/etag 推进/LKG 落盘)一并跳过——否则 applyEffective 拒了
|
|
13
|
+
* 主面、旁路却半应用同一个被拒世代,混合世代从侧门回来。首次 apply(live 未登记)恒 true。 */
|
|
11
14
|
export declare function applyEffective(config: ServiceConfig, eff: EffectiveConfig, logger?: Logger, opts?: {
|
|
12
15
|
teamsOnly?: boolean;
|
|
13
16
|
sealedKeys?: SealedKeyOpener;
|
|
14
17
|
deferModelPlane?: boolean;
|
|
15
|
-
}):
|
|
18
|
+
}): boolean;
|
|
16
19
|
/**
|
|
17
20
|
* Apply the center `runtime` governance/limit gates OVER the env-derived config (mutates `config`).
|
|
18
21
|
*
|
|
@@ -42,23 +45,6 @@ export type RuntimeGateKey = (typeof RUNTIME_GATE_KEYS)[number];
|
|
|
42
45
|
* divides by it; center's `.positive()` already enforces, belt-and-suspenders here). */
|
|
43
46
|
export declare function runtimeGatePresent(rt: NonNullable<EffectiveConfig["runtime"]>, key: RuntimeGateKey): boolean;
|
|
44
47
|
export declare function applyRuntimeGates(config: ServiceConfig, rt: EffectiveConfig["runtime"], logger?: Logger): void;
|
|
45
|
-
/**
|
|
46
|
-
* Apply the runtime governance "second baton" (center §10): `autonomy` + `commandPolicy`. UNLIKE the 6 gates in
|
|
47
|
-
* {@link applyRuntimeGates}, these are per-request HOT (read live in main.ts `resolveSpec` via
|
|
48
|
-
* `applyRuntimeGovernance`), so they apply on BOTH boot and refresh (NOT via RUNTIME_GATE_KEYS / restart-to-apply).
|
|
49
|
-
*
|
|
50
|
-
* 🔴 NON-STICKY (differs from the restart-to-apply gates on purpose — adversarial-review HIGH): a hot field that
|
|
51
|
-
* center STOPS publishing must REVERT to the env baseline, not keep the last center value. The restart-to-apply
|
|
52
|
-
* gates can be sticky because a refresh never touches them (the running middleware holds boot values until a
|
|
53
|
-
* restart re-reads env+center); a HOT field has no such reset, so "absent ⇒ keep" would silently freeze a stale
|
|
54
|
-
* center override (e.g. center published `auto`, then un-published it — the gate would stay OFF forever). So we
|
|
55
|
-
* recompute every call as `present ? center : envBaseline`. The env baseline = `AUTONOMY` (re-derived, env is
|
|
56
|
-
* immutable at runtime) for autonomy; `undefined` (no env scalar source) for commandPolicy.
|
|
57
|
-
*
|
|
58
|
-
* 🔴 commandPolicy is VALIDATED here (adversarial-review HIGH): an invalid command (glob/path/operator — which
|
|
59
|
-
* core's EXACT-name matcher would silently never match → a hole) is rejected FAIL-LOUD and the prior good policy
|
|
60
|
-
* is KEPT (a broken publish never half-applies a silently-weakened gate).
|
|
61
|
-
*/
|
|
62
48
|
export declare function applyRuntimeHot(config: ServiceConfig, rt: EffectiveConfig["runtime"], logger?: Logger): void;
|
|
63
49
|
/**
|
|
64
50
|
* [865]① 显式默认解析——applyEffective 与 dry-run(logEffectiveDiff.wouldDefaultModel)共用的单源。优先级
|
|
@@ -78,7 +78,74 @@ export function mutateInPlace(target, source) {
|
|
|
78
78
|
delete target[k];
|
|
79
79
|
Object.assign(target, source);
|
|
80
80
|
}
|
|
81
|
+
/** 每个 config 对象上一次 COMMIT 的世代号([2283]③ CAS 的 live 端)。WeakMap——config 回收即回收;
|
|
82
|
+
* version 0(未发布/本地一次性 lane)不参与登记。 */
|
|
83
|
+
const appliedGeneration = new WeakMap();
|
|
84
|
+
/** 返回值=是否 COMMIT(false 仅在 [2283]③ 世代序 CAS 拒绝时)。caller 收到 false 必须把**同一世代的
|
|
85
|
+
* 旁路消费**(prompts 采用/pricing/keyResolver/etag 推进/LKG 落盘)一并跳过——否则 applyEffective 拒了
|
|
86
|
+
* 主面、旁路却半应用同一个被拒世代,混合世代从侧门回来。首次 apply(live 未登记)恒 true。 */
|
|
81
87
|
export function applyEffective(config, eff, logger, opts = {}) {
|
|
88
|
+
const staged = stageEffective(config, eff, logger, opts);
|
|
89
|
+
const live = appliedGeneration.get(config);
|
|
90
|
+
if (staged.version > 0 && live !== undefined && staged.version < live) {
|
|
91
|
+
// [2283]③:两次拉取的 staging 并发/乱序完成时,慢的旧世代不得整体覆盖快的新世代——每次都是
|
|
92
|
+
// 「整世代」,但方向反了。拒绝必须留痕(C6);caller 的 etag 纪律本就只在 apply 成功后推进,
|
|
93
|
+
// 下一拍拉到的自然是更新的世代。等版本重放(etag 未推进的幂等重放)照常放行。
|
|
94
|
+
logger?.warn("sema_registry_stale_generation_refused", { staged: staged.version, live, note: "an older staged generation must not overwrite a newer committed one — refused whole; next poll replays" });
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
commitStaged(config, staged);
|
|
98
|
+
// ── post-commit notifications(全部世代描述性通知在最后一笔赋值之后,[2283]②)──
|
|
99
|
+
if (staged.modelPlane)
|
|
100
|
+
logger?.info("sema_registry_models", staged.modelPlane.infoLine);
|
|
101
|
+
if (staged.gatesInfo)
|
|
102
|
+
logger?.info("sema_registry_runtime", staged.gatesInfo);
|
|
103
|
+
if (Object.keys(staged.hot.infoLine).length > 0)
|
|
104
|
+
logger?.info("sema_registry_runtime_hot", staged.hot.infoLine);
|
|
105
|
+
for (const n of staged.teamsPlane?.notices ?? []) {
|
|
106
|
+
if (n.level === "warn")
|
|
107
|
+
logger?.warn(n.msg, n.fields);
|
|
108
|
+
else
|
|
109
|
+
logger?.info(n.msg, n.fields);
|
|
110
|
+
}
|
|
111
|
+
if (Object.keys(staged.projects).length > 0)
|
|
112
|
+
logger?.info("sema_registry_projects", { projects: Object.keys(staged.projects) });
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
/** COMMIT 段:纯赋值,零 await/零回调/零发射(源码钉守着——[2283]①②)。mutateInPlace/整对象重赋值/
|
|
116
|
+
* registry 表整体换装(registerTeams/registerCollabWorkflows 皆为过滤+swap 的赋值形,无回调面)。 */
|
|
117
|
+
function commitStaged(config, staged) {
|
|
118
|
+
const mp = staged.modelPlane;
|
|
119
|
+
if (mp) {
|
|
120
|
+
config.modelApiKeyEnv = mp.modelApiKeyEnv; // reassign ok — keyResolver is rebuilt from it on refresh (main.ts)
|
|
121
|
+
config.modelApiKeys = mp.modelApiKeys; // plaintext values — memory-only; poison markers ride the same map
|
|
122
|
+
mutateInPlace(config.modelQuotaWeights, mp.quotaWeights);
|
|
123
|
+
mutateInPlace(config.models, mp.models); // IN PLACE: keep the ref the Runner + /v1/models share (hot-apply)
|
|
124
|
+
config.model = config.models.default;
|
|
125
|
+
if (Object.keys(mp.roles).length > 0)
|
|
126
|
+
mutateInPlace(config.roles, mp.roles); // IN PLACE: Runner reads this.deps.roles live
|
|
127
|
+
mutateInPlace(config.tiers, mp.activeTiers);
|
|
128
|
+
}
|
|
129
|
+
if (staged.gateAssignments) {
|
|
130
|
+
for (const [k, v] of staged.gateAssignments)
|
|
131
|
+
config[k] = v;
|
|
132
|
+
}
|
|
133
|
+
if (staged.hot.setAutonomy)
|
|
134
|
+
config.autonomy = staged.hot.autonomyNext;
|
|
135
|
+
if (staged.hot.setCommandPolicy)
|
|
136
|
+
config.commandPolicy = staged.hot.commandPolicyNext;
|
|
137
|
+
const tp = staged.teamsPlane;
|
|
138
|
+
if (tp) {
|
|
139
|
+
registerTeams(tp.teams);
|
|
140
|
+
registerCollabWorkflows(tp.workflows);
|
|
141
|
+
}
|
|
142
|
+
mutateInPlace(config.projects, staged.projects); // IN PLACE: keep the ref per-request consumers captured at boot
|
|
143
|
+
if (staged.version > 0)
|
|
144
|
+
appliedGeneration.set(config, staged.version);
|
|
145
|
+
}
|
|
146
|
+
/** STAGE 段:一切计算/校验/解封/投影(可抛;抛=候选整体拒绝,活配置零触碰)。内容判定性 warn/error
|
|
147
|
+
* (描述 eff 真伪,与是否 commit 无关)在此发;世代描述性 info 只装载荷,post-commit 发。 */
|
|
148
|
+
function stageEffective(config, eff, logger, opts) {
|
|
82
149
|
// version 0 / empty effective = the config-center has nothing for us yet — typically CONFIG_PUBLISH_MODE
|
|
83
150
|
// is ON but nothing has been published. We do NOT wipe: models/roles fall back to env (the enabled>0
|
|
84
151
|
// guard below), teams to BUILTIN_TEAMS (registerTeams resets to built-ins). Warn once at boot so the
|
|
@@ -96,6 +163,7 @@ export function applyEffective(config, eff, logger, opts = {}) {
|
|
|
96
163
|
// split, no Runner rebuild, no core change. Caller (main.ts refresh) additionally refreshes `pricing` (same
|
|
97
164
|
// ref) + rebuilds `keyResolver`. (Was startup-only when reassignment split the Runner's ref from config.)
|
|
98
165
|
const enabled = (eff.models?.models ?? []).filter((m) => m.enabled !== false);
|
|
166
|
+
let modelPlane;
|
|
99
167
|
// codex R10: `deferModelPlane` skips the WHOLE model plane (models/roles/tiers + per-model keys) — main.ts
|
|
100
168
|
// sets it when the Runner is tier-frozen (private expanded copy) and the plane changed: hot-applying would
|
|
101
169
|
// split admission (/v1/models, catalog gates) from the Runner's frozen generation, letting a same-key retarget
|
|
@@ -189,11 +257,6 @@ export function applyEffective(config, eff, logger, opts = {}) {
|
|
|
189
257
|
logger?.warn("sema_registry_model_no_brain", { model: m.name, provider: m.provider, hint: "set ANTHROPIC_API_KEY in this service's env, else it mis-routes to the gateway brain" });
|
|
190
258
|
}
|
|
191
259
|
}
|
|
192
|
-
config.modelApiKeyEnv = modelApiKeyEnv; // reassign ok — keyResolver is rebuilt from it on refresh (main.ts)
|
|
193
|
-
// same rebuild contract; plaintext values — memory-only, never logged. Poison markers ride the same
|
|
194
|
-
// map (they ARE per-model key state: "configured but broken"); the cast bridges config.ts's
|
|
195
|
-
// plaintext-only field type until it is widened to Record<string, string | SealedKeyPoison>.
|
|
196
|
-
config.modelApiKeys = modelApiKeys; // 类型已放真(string | SealedKeyPoison),桥接 cast 退役
|
|
197
260
|
// weight-at-burn 源:quotaWeight 按 name+id 双键索引(tracer 的 brain.call e.model=模型 id,
|
|
198
261
|
// 目录键=name——双键免猜);非法值(≤0/NaN)按缺省 1 丢弃。IN PLACE 与 models 同批 hot-apply。
|
|
199
262
|
const quotaWeights = {};
|
|
@@ -205,7 +268,6 @@ export function applyEffective(config, eff, logger, opts = {}) {
|
|
|
205
268
|
quotaWeights[m.id] = qw;
|
|
206
269
|
}
|
|
207
270
|
}
|
|
208
|
-
mutateInPlace(config.modelQuotaWeights, quotaWeights);
|
|
209
271
|
const roles = {};
|
|
210
272
|
for (const [role, tgt] of Object.entries(eff.models.roles ?? {})) {
|
|
211
273
|
if ("model" in tgt)
|
|
@@ -225,15 +287,16 @@ export function applyEffective(config, eff, logger, opts = {}) {
|
|
|
225
287
|
// 静默翻转,default 必须消费 wire 里已有的显式意图。解析单源 = resolveDefaultModelName(dry-run 的
|
|
226
288
|
// wouldDefaultModel 共用同一只,报数与真 apply 永不撕裂——codex M2)。
|
|
227
289
|
const picked = resolveDefaultModelName(eff, (n) => models[n] !== undefined, enabled[0].name, (source, name) => logger?.warn("sema_registry_default_dangling", { source, name, hint: "explicit default names a model that is not in the enabled catalog — falling to the next source" }));
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
290
|
+
models.default = models[picked.name];
|
|
291
|
+
modelPlane = {
|
|
292
|
+
models,
|
|
293
|
+
modelApiKeyEnv,
|
|
294
|
+
modelApiKeys,
|
|
295
|
+
quotaWeights,
|
|
296
|
+
roles,
|
|
297
|
+
activeTiers,
|
|
298
|
+
infoLine: { count: enabled.length, default: models.default.id, defaultSource: picked.source, roles: Object.keys(roles), tiers: Object.keys(activeTiers), sealedKeys: Object.values(modelApiKeys).filter((v) => typeof v === "string").length, sealedPoisoned: Object.values(modelApiKeys).filter((v) => typeof v !== "string").length },
|
|
299
|
+
};
|
|
237
300
|
}
|
|
238
301
|
// Runtime governance/limit gates (phase-2): STARTUP only (restart-to-apply) — the live RateLimiter / CostQuota
|
|
239
302
|
// / approval gate are built from `config` AFTER this in main.ts, so mutating it here before they're constructed
|
|
@@ -241,18 +304,18 @@ export function applyEffective(config, eff, logger, opts = {}) {
|
|
|
241
304
|
// governance 切新位:治理三件优先读 governance 域(真值),runtime 旧位(双写镜像)fallback——
|
|
242
305
|
// 双写期两处逐键相等语义不变;center 撤双写后 governance 即唯一来源。限额残余(rateLimit/cost 五件)仍在 runtime。
|
|
243
306
|
const gatesView = eff.governance ? { ...eff.runtime, ...eff.governance } : eff.runtime;
|
|
244
|
-
|
|
245
|
-
applyRuntimeGates(config, gatesView, logger);
|
|
307
|
+
const gates = opts.teamsOnly ? undefined : stageRuntimeGates(gatesView);
|
|
246
308
|
// Runtime governance "second baton" (center §10): autonomy + commandPolicy are per-request HOT (read live in
|
|
247
309
|
// resolveSpec), NOT baked into boot middleware → apply on BOTH boot and refresh (outside the teamsOnly guard) so
|
|
248
310
|
// they hot-reload. No restart-to-apply signal (they take effect on the next task without a restart).
|
|
249
|
-
|
|
311
|
+
const hot = stageRuntimeHot(config, gatesView, logger);
|
|
250
312
|
// Teams → registry (hot-reloadable; center overrides/extends the built-ins).
|
|
251
313
|
// codex R11: teams + collab workflows carry MODEL REFERENCES (member.model / workflow model args) — when the
|
|
252
314
|
// model plane is deferred (tier-frozen Runner, see the deferModelPlane guard above) these faces must defer
|
|
253
315
|
// WITH it, or a candidate that atomically adds/retargets a model AND updates a team/workflow to reference it
|
|
254
316
|
// publishes a mixed generation: the new template goes live while config.models/the Runner stay old (a new ref
|
|
255
317
|
// fails as unknown; a same-name retarget silently executes stale). One catalog generation = one visibility.
|
|
318
|
+
let teamsPlane;
|
|
256
319
|
if (opts.deferModelPlane !== true) {
|
|
257
320
|
const teams = {};
|
|
258
321
|
for (const t of (eff.teams?.teams ?? []).filter((t) => t.enabled !== false)) {
|
|
@@ -265,22 +328,22 @@ export function applyEffective(config, eff, logger, opts = {}) {
|
|
|
265
328
|
synthesizer: t.synthesizer ? { role: t.synthesizer.role, modelRole: t.synthesizer.modelRole, systemPrompt: t.synthesizer.systemPrompt } : undefined,
|
|
266
329
|
};
|
|
267
330
|
}
|
|
268
|
-
registerTeams(teams);
|
|
269
|
-
if (Object.keys(teams).length > 0)
|
|
270
|
-
logger?.info("sema_registry_teams", { teams: Object.keys(teams) });
|
|
271
331
|
// collab → named-workflow projection(切片 1.5,design/140 统一解;切片① 的
|
|
272
332
|
// TeamTemplate 投影已整体替换——纪律「别留双路径降级」)。可投影子集翻译成命名 workflow 注册条目
|
|
273
333
|
// (执行体=core 内置 team-discussion 脚本,center 模板=defaultArgs 合并链第二级;键=collab id,shell
|
|
274
334
|
// `/team`/LLM 经 Workflow({name}) 调用);子集外整条跳过+结构化上报,绝不静默降级。HOT:boot+refresh
|
|
275
|
-
// 双腿整表替换(非粘——center 停发即空表,内置 workflow 不受影响)。
|
|
335
|
+
// 双腿整表替换(非粘——center 停发即空表,内置 workflow 不受影响)。register* 在 commit 段成对换装。
|
|
276
336
|
const projected = projectCollabToWorkflows(eff.collab?.templates);
|
|
277
|
-
|
|
337
|
+
const notices = [];
|
|
338
|
+
if (Object.keys(teams).length > 0)
|
|
339
|
+
notices.push({ level: "info", msg: "sema_registry_teams", fields: { teams: Object.keys(teams) } });
|
|
278
340
|
if (Object.keys(projected.workflows).length > 0)
|
|
279
|
-
|
|
341
|
+
notices.push({ level: "info", msg: "sema_registry_collab_workflows", fields: { workflows: Object.keys(projected.workflows) } });
|
|
280
342
|
if (projected.skipped.length > 0)
|
|
281
|
-
|
|
343
|
+
notices.push({ level: "warn", msg: "sema_registry_collab_skipped", fields: { skipped: projected.skipped } });
|
|
282
344
|
if (projected.notes.length > 0)
|
|
283
|
-
|
|
345
|
+
notices.push({ level: "info", msg: "sema_registry_collab_notes", fields: { notes: projected.notes } });
|
|
346
|
+
teamsPlane = { teams, workflows: projected.workflows, notices };
|
|
284
347
|
}
|
|
285
348
|
// 142-S4 projects 域(registry-core 0.10.0):center 项目登记簿 → config.projects(键=
|
|
286
349
|
// projectId)。消费是 per-request 查表(memoryScope 派生 + defaultScopes 种子,security.ts/main.ts),
|
|
@@ -304,9 +367,14 @@ export function applyEffective(config, eff, logger, opts = {}) {
|
|
|
304
367
|
...(Array.isArray(r.defaultScopes) ? { defaultScopes: r.defaultScopes.filter((x) => typeof x === "string") } : {}),
|
|
305
368
|
};
|
|
306
369
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
370
|
+
return {
|
|
371
|
+
version: typeof eff.version === "number" && Number.isFinite(eff.version) ? eff.version : 0,
|
|
372
|
+
...(modelPlane !== undefined ? { modelPlane } : {}),
|
|
373
|
+
...(gates !== undefined ? { gateAssignments: gates.assignments, ...(gates.info !== undefined ? { gatesInfo: gates.info } : {}) } : {}),
|
|
374
|
+
hot,
|
|
375
|
+
...(teamsPlane !== undefined ? { teamsPlane } : {}),
|
|
376
|
+
projects,
|
|
377
|
+
};
|
|
310
378
|
}
|
|
311
379
|
/**
|
|
312
380
|
* Apply the center `runtime` governance/limit gates OVER the env-derived config (mutates `config`).
|
|
@@ -342,18 +410,28 @@ export function runtimeGatePresent(rt, key) {
|
|
|
342
410
|
return typeof v === "number" && v > 0;
|
|
343
411
|
return typeof v === "number";
|
|
344
412
|
}
|
|
345
|
-
|
|
413
|
+
/** [2283] stage 半场:六闸的赋值清单(纯计算,零变异)。commit 段照单赋值,通知载荷 post-commit 发。 */
|
|
414
|
+
function stageRuntimeGates(rt) {
|
|
346
415
|
if (!rt)
|
|
347
|
-
return;
|
|
416
|
+
return { assignments: [] };
|
|
417
|
+
const assignments = [];
|
|
348
418
|
const applied = {};
|
|
349
419
|
for (const key of RUNTIME_GATE_KEYS) {
|
|
350
420
|
if (!runtimeGatePresent(rt, key))
|
|
351
421
|
continue; // undefined / sentinel → keep env
|
|
352
|
-
|
|
422
|
+
assignments.push([key, rt[key]]); // present (incl. explicit 0/[]) → override
|
|
353
423
|
applied[key] = rt[key];
|
|
354
424
|
}
|
|
355
|
-
|
|
356
|
-
|
|
425
|
+
return { assignments, ...(Object.keys(applied).length > 0 ? { info: applied } : {}) };
|
|
426
|
+
}
|
|
427
|
+
export function applyRuntimeGates(config, rt, logger) {
|
|
428
|
+
// 独立调用面的兼容壳(测试/外部):stage → 就地赋值 → 通知。applyEffective 不走这里——它把
|
|
429
|
+
// assignments 并进自己的 commit 段以保住整世代原子性([2283]②)。
|
|
430
|
+
const s = stageRuntimeGates(rt);
|
|
431
|
+
for (const [k, v] of s.assignments)
|
|
432
|
+
config[k] = v;
|
|
433
|
+
if (s.info)
|
|
434
|
+
logger?.info("sema_registry_runtime", s.info);
|
|
357
435
|
}
|
|
358
436
|
/**
|
|
359
437
|
* Apply the runtime governance "second baton" (center §10): `autonomy` + `commandPolicy`. UNLIKE the 6 gates in
|
|
@@ -372,34 +450,49 @@ export function applyRuntimeGates(config, rt, logger) {
|
|
|
372
450
|
* core's EXACT-name matcher would silently never match → a hole) is rejected FAIL-LOUD and the prior good policy
|
|
373
451
|
* is KEPT (a broken publish never half-applies a silently-weakened gate).
|
|
374
452
|
*/
|
|
375
|
-
|
|
376
|
-
|
|
453
|
+
/** [2283] stage 半场:hot 二件的赋值决定(计算+校验;`sema_registry_commandpolicy_invalid` 是内容判定,
|
|
454
|
+
* stage 期发——它描述 eff 的真伪,与是否 commit 无关)。对比基准=stage 时刻的 config 现值:stage 与
|
|
455
|
+
* commit 同一同步 tick,期间无人能改 config(单线程),对比不失效。 */
|
|
456
|
+
function stageRuntimeHot(config, rt, logger) {
|
|
457
|
+
const infoLine = {};
|
|
377
458
|
// autonomy: present ⇒ center value; absent ⇒ revert to the env baseline (never a stale center override).
|
|
378
459
|
const envAutonomy = parseAutonomy(process.env.AUTONOMY);
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
460
|
+
const autonomyNext = rt?.autonomy !== undefined ? rt.autonomy : envAutonomy;
|
|
461
|
+
const setAutonomy = config.autonomy !== autonomyNext;
|
|
462
|
+
if (setAutonomy)
|
|
463
|
+
infoLine.autonomy = autonomyNext ?? "(env-baseline)";
|
|
384
464
|
// commandPolicy: present+valid ⇒ apply; present+invalid ⇒ fail-loud + keep prior; absent ⇒ revert to baseline
|
|
385
465
|
// (undefined — no env scalar source for structured command rules). Only re-set + log on an ACTUAL change
|
|
386
466
|
// (deep-equal compare) — refresh runs every ~60s and the policy is usually unchanged; logging every tick = noise.
|
|
467
|
+
let setCommandPolicy = false;
|
|
468
|
+
let commandPolicyNext;
|
|
387
469
|
if (rt?.commandPolicy !== undefined) {
|
|
388
470
|
const errors = validateCommandRules(rt.commandPolicy);
|
|
389
471
|
if (errors.length > 0) {
|
|
390
472
|
logger?.error("sema_registry_commandpolicy_invalid", { errors, kept: config.commandPolicy?.length ?? 0 });
|
|
391
473
|
}
|
|
392
474
|
else if (JSON.stringify(config.commandPolicy) !== JSON.stringify(rt.commandPolicy)) {
|
|
393
|
-
|
|
394
|
-
|
|
475
|
+
setCommandPolicy = true;
|
|
476
|
+
commandPolicyNext = rt.commandPolicy;
|
|
477
|
+
infoLine.commandPolicy = rt.commandPolicy.length; // log the COUNT, not the rules (avoid leaking on every refresh)
|
|
395
478
|
}
|
|
396
479
|
}
|
|
397
480
|
else if (config.commandPolicy !== undefined) {
|
|
398
|
-
|
|
399
|
-
|
|
481
|
+
setCommandPolicy = true;
|
|
482
|
+
commandPolicyNext = undefined; // center stopped managing → revert to baseline (no env source)
|
|
483
|
+
infoLine.commandPolicy = "(env-baseline)";
|
|
400
484
|
}
|
|
401
|
-
|
|
402
|
-
|
|
485
|
+
return { setAutonomy, autonomyNext, setCommandPolicy, commandPolicyNext, infoLine };
|
|
486
|
+
}
|
|
487
|
+
export function applyRuntimeHot(config, rt, logger) {
|
|
488
|
+
// 独立调用面的兼容壳(测试/外部)——applyEffective 不走这里(同 applyRuntimeGates 的注)。
|
|
489
|
+
const s = stageRuntimeHot(config, rt, logger);
|
|
490
|
+
if (s.setAutonomy)
|
|
491
|
+
config.autonomy = s.autonomyNext;
|
|
492
|
+
if (s.setCommandPolicy)
|
|
493
|
+
config.commandPolicy = s.commandPolicyNext;
|
|
494
|
+
if (Object.keys(s.infoLine).length > 0)
|
|
495
|
+
logger?.info("sema_registry_runtime_hot", s.infoLine);
|
|
403
496
|
}
|
|
404
497
|
/** [865]①/H3:activeTierGroup 档绑定当默认时的档梯,与 core 单一语义源逐字对齐(core roles.js:
|
|
405
498
|
* `ROLE_TIER_DEFAULTS.default = "pro"` + `resolveTier` 从本档位置**只向低档**扫 DEFAULT_TIER_ORDER
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EntitlementRuntimeCaps } from "@sema-agent/registry-core";
|
|
2
2
|
import type { ScenarioRuling } from "../capabilities/scenarios.js";
|
|
3
3
|
import type { EffectiveConfig, ExecutionRuling } from "./types.js";
|
|
4
|
-
/** GET the effective config (Bearer + ETag). null = 304 (unchanged). Throws on transport/HTTP error
|
|
4
|
+
/** GET the effective config (Bearer + ETag). null = 304 (unchanged). Throws on transport/HTTP error, and on a
|
|
5
|
+
* payload that is not an effective config at all(非对象 / version 非有限数 / gate 域坏形——[2281] 裁B)。
|
|
6
|
+
* `domainErrors`:坏 catalog 域(schema default 已落)逐域单列——与本地腿 `FetchEffectiveResult` 同键同义,
|
|
7
|
+
* boot/refresh 的候选门直接消费。 */
|
|
5
8
|
export declare function fetchEffective(baseUrl: string, token: string, etag: string | undefined, fetchImpl?: typeof fetch, worker?: string): Promise<{
|
|
6
9
|
effective: EffectiveConfig;
|
|
7
10
|
etag?: string;
|
|
11
|
+
domainErrors?: Array<{
|
|
12
|
+
domain: string;
|
|
13
|
+
error: string;
|
|
14
|
+
}>;
|
|
8
15
|
} | null>;
|
|
9
16
|
/**
|
|
10
17
|
* design/99 §K (core 1.157 `RunnerDeps.runtimeCapsResolver`) — GET the PER-PRINCIPAL runtime
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
* facade re-exports every symbol below unchanged).
|
|
6
6
|
*/
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
|
-
|
|
8
|
+
import { readEffectiveWire } from "@sema-agent/registry-core";
|
|
9
|
+
/** GET the effective config (Bearer + ETag). null = 304 (unchanged). Throws on transport/HTTP error, and on a
|
|
10
|
+
* payload that is not an effective config at all(非对象 / version 非有限数 / gate 域坏形——[2281] 裁B)。
|
|
11
|
+
* `domainErrors`:坏 catalog 域(schema default 已落)逐域单列——与本地腿 `FetchEffectiveResult` 同键同义,
|
|
12
|
+
* boot/refresh 的候选门直接消费。 */
|
|
9
13
|
export async function fetchEffective(baseUrl, token, etag, fetchImpl = fetch, worker) {
|
|
10
14
|
// Defensive scheme guard: Node's fetch supports file:// — a mis-set
|
|
11
15
|
// SEMA_REGISTRY_URL must not turn into a local-file read. Reject anything but http(s).
|
|
@@ -23,7 +27,21 @@ export async function fetchEffective(baseUrl, token, etag, fetchImpl = fetch, wo
|
|
|
23
27
|
return null;
|
|
24
28
|
if (!res.ok)
|
|
25
29
|
throw new Error(`config-center HTTP ${res.status}`);
|
|
26
|
-
|
|
30
|
+
// [2281] 裁B(§M1):wire 载荷在**这个消费端边界**过 registry-core `readEffectiveWire`(0.12.0)真校验,
|
|
31
|
+
// 不再裸断言——「两个契约共用一个拼写不是一个被检查的契约」。判据与本地腿同源(parseDomain +
|
|
32
|
+
// DOMAIN_READ_FALLBACK):catalog 域坏形 → 该域 schema default + 下面折进 domainErrors(候选门在
|
|
33
|
+
// refresh 拒候选、boot 逐域 warn);gate 域坏形/垃圾载荷 → throw(caller 整包 catch 回落 env/LKG)。
|
|
34
|
+
// 未知顶层键 verbatim 透传(open-world:新 center 新域、legacy teams 键都不丢)。grandfather 类
|
|
35
|
+
// 警告(值已被收编接受)不算 error,不进 domainErrors——与本地店同口径。
|
|
36
|
+
const warnings = [];
|
|
37
|
+
const wire = readEffectiveWire(await res.json(), (w) => warnings.push(w));
|
|
38
|
+
const domainErrors = warnings
|
|
39
|
+
.filter((w) => w.kind === "domain-defaulted")
|
|
40
|
+
.map((w) => ({ domain: w.domain, error: (w.error instanceof Error ? w.error.message : String(w.error)).slice(0, 600) }));
|
|
41
|
+
// 经真校验后的 wire 值到 service 拼写副本的换装:两型同一契约面(EffectiveWire 是 service 型的同源超集,
|
|
42
|
+
// service 型只声明自己消费的域且全 optional)——此断言的前提正是上面那次校验,不再是裸信任。
|
|
43
|
+
const effective = wire;
|
|
44
|
+
return { effective, etag: res.headers.get("etag") ?? undefined, ...(domainErrors.length > 0 ? { domainErrors } : {}) };
|
|
27
45
|
}
|
|
28
46
|
/**
|
|
29
47
|
* design/99 §K (core 1.157 `RunnerDeps.runtimeCapsResolver`) — GET the PER-PRINCIPAL runtime
|
package/dist/config-provider.js
CHANGED
|
@@ -91,10 +91,16 @@ export class RemoteConfigProvider {
|
|
|
91
91
|
this.cc = cc;
|
|
92
92
|
this.deps = deps;
|
|
93
93
|
}
|
|
94
|
-
fetchEffective(etag) {
|
|
94
|
+
async fetchEffective(etag) {
|
|
95
95
|
const fn = this.deps.fetchEffective ?? remoteFetchEffective;
|
|
96
96
|
// 5th arg = worker scope; transport (fetchImpl) stays the config-center's default.
|
|
97
|
-
|
|
97
|
+
const r = await fn(this.cc.baseUrl, this.cc.token, etag, undefined, this.cc.worker);
|
|
98
|
+
if (r === null || r.domainErrors === undefined)
|
|
99
|
+
return r;
|
|
100
|
+
// [2281] 裁B:远程腿从此也产 domainErrors(http-client 消费端校验)。与本地腿同一条产出边界纪律:
|
|
101
|
+
// error 文本先过 redactConfigError(zod 错误会回声违规值——operand 指纹化,防受控值经
|
|
102
|
+
// config_candidate_rejected/config_domain_invalid 进结构化日志流)。
|
|
103
|
+
return { ...r, domainErrors: r.domainErrors.map((de) => ({ domain: de.domain, error: redactConfigError(de.error) })) };
|
|
98
104
|
}
|
|
99
105
|
async fetchSkillContent(contentHash) {
|
|
100
106
|
const fn = this.deps.fetchSkillContent ?? remoteFetchSkillContent;
|
package/dist/config.d.ts
CHANGED
|
@@ -25,6 +25,18 @@ export declare function numEnv(name: string, fallback: string): number;
|
|
|
25
25
|
* `undefined`/空串 ⇒ 交给调用方的 fallback 语义处理(本函数只判「给了值但不是数」)。
|
|
26
26
|
*/
|
|
27
27
|
export declare function parseNumOrFail(name: string, raw: string | undefined): number;
|
|
28
|
+
/**
|
|
29
|
+
* `parseNumOrFail` layered with a non-negative floor — the SAME judgment `leader/wire.ts`'s (private,
|
|
30
|
+
* file-local) `parseNumOrFailNonNegative` already applies to its four resource knobs, exported here so a
|
|
31
|
+
* second call site (bake-runner's heartbeat/idle-poll/disk-guard knobs, gap-sweep 2026-08-01) can reuse the
|
|
32
|
+
* one shared primitive instead of re-deriving its own "reject a bad number" scheme. A negative value is
|
|
33
|
+
* JS-truthy, so a bare `|| default` fallback lets it straight through; for a duration knob that means
|
|
34
|
+
* `clock.sleep(-N)` fires ~immediately (the same tight-loop failure NaN causes), and for a threshold knob
|
|
35
|
+
* (`freeGb < minFreeGb`) a negative floor makes the comparison vacuously true/false depending on sign —
|
|
36
|
+
* either way the guard it configures goes silently slack. `undefined`/unset stays NaN (unchanged) so
|
|
37
|
+
* existing `|| fallback` chains built on `parseNumOrFail` are untouched by this stricter sibling.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseNumOrFailNonNegative(name: string, raw: string | undefined): number;
|
|
28
40
|
export declare function drainConfigWarnings(): Array<{
|
|
29
41
|
env: string;
|
|
30
42
|
raw: string;
|
package/dist/config.js
CHANGED
|
@@ -94,6 +94,23 @@ export function parseNumOrFail(name, raw) {
|
|
|
94
94
|
throw new Error(`env ${name}="${raw}" must be a number`);
|
|
95
95
|
return n;
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* `parseNumOrFail` layered with a non-negative floor — the SAME judgment `leader/wire.ts`'s (private,
|
|
99
|
+
* file-local) `parseNumOrFailNonNegative` already applies to its four resource knobs, exported here so a
|
|
100
|
+
* second call site (bake-runner's heartbeat/idle-poll/disk-guard knobs, gap-sweep 2026-08-01) can reuse the
|
|
101
|
+
* one shared primitive instead of re-deriving its own "reject a bad number" scheme. A negative value is
|
|
102
|
+
* JS-truthy, so a bare `|| default` fallback lets it straight through; for a duration knob that means
|
|
103
|
+
* `clock.sleep(-N)` fires ~immediately (the same tight-loop failure NaN causes), and for a threshold knob
|
|
104
|
+
* (`freeGb < minFreeGb`) a negative floor makes the comparison vacuously true/false depending on sign —
|
|
105
|
+
* either way the guard it configures goes silently slack. `undefined`/unset stays NaN (unchanged) so
|
|
106
|
+
* existing `|| fallback` chains built on `parseNumOrFail` are untouched by this stricter sibling.
|
|
107
|
+
*/
|
|
108
|
+
export function parseNumOrFailNonNegative(name, raw) {
|
|
109
|
+
const n = parseNumOrFail(name, raw);
|
|
110
|
+
if (n < 0)
|
|
111
|
+
throw new Error(`env ${name}=${n} must not be negative`);
|
|
112
|
+
return n;
|
|
113
|
+
}
|
|
97
114
|
/** Numeric env with a [min,max] bound (BL-17): an out-of-range value FAILS at startup instead of being
|
|
98
115
|
* silently clamped/applied (e.g. a multi-hour MCP_ELICITATION_TTL_MS would silently never expire). */
|
|
99
116
|
function numEnvBounded(name, fallback, min, max) {
|
package/dist/fleet/fleet-bus.js
CHANGED
|
@@ -203,9 +203,12 @@ export function fleetRunPublisher(bus, run) {
|
|
|
203
203
|
elapsedDonor.add(cid);
|
|
204
204
|
const elapsed = elapsedDonor.has(cid) ? Date.now() - childStartedAt.get(cid) : undefined;
|
|
205
205
|
if (donorName !== undefined || elapsed !== undefined) {
|
|
206
|
+
// cli [2297]①:donorName 只进 `name` 位。run-leg tick 手上没有独立的类型名([1364]③/core 1.350:
|
|
207
|
+
// name=display 标签、agentType=类型名),把 display 值同时写进 agentType 会让 fleet 的 TYPE 列显示
|
|
208
|
+
// description。缺源的键不铸(与「匿名行不铸假名」同一条原则);BCE 行自己的帧带真 agentType 时自然补上。
|
|
206
209
|
bus.publishTask({
|
|
207
210
|
id: bceRowId,
|
|
208
|
-
...(donorName !== undefined ? { name: donorName
|
|
211
|
+
...(donorName !== undefined ? { name: donorName } : {}),
|
|
209
212
|
...(elapsed !== undefined ? { elapsedMs: elapsed } : {}),
|
|
210
213
|
});
|
|
211
214
|
}
|
package/dist/hooks/hook-llm.js
CHANGED
|
@@ -160,7 +160,7 @@ export function createHookLlm(deps) {
|
|
|
160
160
|
text = j.choices?.[0]?.message?.content ?? undefined;
|
|
161
161
|
}
|
|
162
162
|
metrics.inc("hook_llm_calls_total", { type: "prompt" });
|
|
163
|
-
return typeof text === "string" ? { ok: true, text } : { ok: false, error: "hook llm returned no content" };
|
|
163
|
+
return typeof text === "string" ? { ok: true, text } : { ok: false, error: "hook llm returned no content", code: "no_content" };
|
|
164
164
|
}
|
|
165
165
|
catch (e) {
|
|
166
166
|
// 网络层失败原样只有 "TypeError: fetch failed"(不是人话):带上端点与 cause(ECONNREFUSED 等),
|
|
@@ -91,7 +91,11 @@ export type HookLlmCall = (opts: {
|
|
|
91
91
|
} | {
|
|
92
92
|
ok: false;
|
|
93
93
|
error: string;
|
|
94
|
+
code?: HookLlmFailureCode;
|
|
94
95
|
}>;
|
|
96
|
+
/** 失败判别码(B8:判别一律走码,禁按 error 文案分支——v3.1 批2,统检第二波 high)。error 仍是给人看的
|
|
97
|
+
* 自由文本;code 是给控制流的。缺席=未分类失败(不重试、不特判)。 */
|
|
98
|
+
export type HookLlmFailureCode = "no_content" | "hard_timeout";
|
|
95
99
|
/**
|
|
96
100
|
* 阶段三a:`http` 条目——契约语义 = POST hook 输入 JSON 到 `url`;headers 里的 `$NAME` 仅当 NAME 列在
|
|
97
101
|
* `allowedEnvVars` 才从 worker 进程 env 插值(配置本身绝不携带密钥值,契约同边界)。
|
|
@@ -383,7 +383,7 @@ async function runLlmHook(entry, payload, ctx, extra) {
|
|
|
383
383
|
// 顺序是承重的 —— 包装句逐字预设会话在上文,放反了那句话本身就是在骗模型。
|
|
384
384
|
const prompt = extra ? `${extra.transcript}\n\n---\n\n${wrapCondition("Stop", substituted)}` : substituted;
|
|
385
385
|
// 载体外再包一层硬顶:契约说载体自己兜超时,但一个部署组装 bug 不该能挂死工具门(纵深)。
|
|
386
|
-
const hardTop = new Promise((r) => setTimeout(() => r({ ok: false, error: "
|
|
386
|
+
const hardTop = new Promise((r) => setTimeout(() => r({ ok: false, error: "hook llm hard-timeout backstop fired (carrier did not settle within timeoutMs+5s — deployment assembly bug)", code: "hard_timeout" }), timeoutMs + 5_000).unref?.());
|
|
387
387
|
const invoke = () => Promise.race([
|
|
388
388
|
call({
|
|
389
389
|
prompt,
|
|
@@ -397,17 +397,17 @@ async function runLlmHook(entry, payload, ctx, extra) {
|
|
|
397
397
|
// 🔴 **"一个字都没吐出来"重试一次**(仅 CC 评估者路)。这个失败形的后果是**判词被丢弃 ⇒ fail-open**
|
|
398
398
|
// (该拦没拦),而它是**瞬态**的(推理档模型偶尔把额度用在 thinking 上)。
|
|
399
399
|
// ⚠️ 只重试**无内容**,不重试"有内容但读不懂" —— 后者重试一次多半还是读不懂,而且那一格按设计就该放行。
|
|
400
|
-
if (extra && !res.ok && res.
|
|
400
|
+
if (extra && !res.ok && res.code === "no_content") { // B8:判别走码不走文案(v3.1 批2)
|
|
401
401
|
ctx.logger.warn("hook_llm_no_content_retry", { event: "Stop" });
|
|
402
402
|
res = await invoke();
|
|
403
403
|
// 重试之后**仍然**没有内容 ⇒ 这一轮的守卫确实没能评估。发观测帧(纯 observe,不改变运行)——
|
|
404
404
|
// 方向仍是 fail-open,但用户/壳侧要能知道「这轮没看住」,否则那个放行与「已达成」无法区分。
|
|
405
|
-
if (!res.ok && res.
|
|
405
|
+
if (!res.ok && res.code === "no_content") {
|
|
406
406
|
ctx.onHookNotice?.({ kind: "hook_decision_unavailable", event: "Stop", reason: "no_content", detail: "carrier returned no content (after one retry)" });
|
|
407
407
|
}
|
|
408
408
|
}
|
|
409
409
|
if (!res.ok) {
|
|
410
|
-
return res.
|
|
410
|
+
return res.code === "hard_timeout"
|
|
411
411
|
? { code: null, stdout: "", stderr: "", timedOut: true }
|
|
412
412
|
: { code: null, stdout: "", stderr: "", timedOut: false, spawnError: clip(res.error, 500) };
|
|
413
413
|
}
|
package/dist/http/routes/runs.js
CHANGED
|
@@ -1134,7 +1134,12 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1134
1134
|
const parkArbiterUnreachable = details?.error === "park_arbiter_unreachable";
|
|
1135
1135
|
const parkResumeWon = details?.error === "park_resume_won";
|
|
1136
1136
|
const stillParked = details?.error === "parked_pending_approval" || details?.status === "parked";
|
|
1137
|
-
|
|
1137
|
+
// v3.1 批2(统检第二波 high):判别方向翻转——旧形是 (status,error) 的**失败白名单**(status==="running"
|
|
1138
|
+
// || 三个具名码),core 新增失败码且行状态不在 {running,parked} 时整个分支被跳过落 200,与上面注释自陈
|
|
1139
|
+
// 的不变量(kill 没落地绝不读成功)正相悖(B5 联合非穷尽的 default-放行形;not_local/park 族/arbiter
|
|
1140
|
+
// 三次历史新增全靠人追认)。新形:stop 动词的 error **在场即失败**(not_found 已在上方早退),具名臂
|
|
1141
|
+
// 保留各自 errorCode,未知码落 stop.not_landed 兜底 409——新失败码默认可见,不默认成功。
|
|
1142
|
+
if (stopVerb && details?.error !== undefined) {
|
|
1138
1143
|
// 1.250:durable 回落臂(core stopTask agentStore 分支)对他实例 running 行应答 error="not_local"
|
|
1139
1144
|
// (没有 kill 被尝试)——与「kill 尝试了没落地」(stop.not_landed)是不同的失败形,各给各的
|
|
1140
1145
|
// errorCode(同 409 家族,additive)。
|
package/dist/http/server.js
CHANGED
|
@@ -1671,16 +1671,20 @@ export function createHttpServer(rawDeps) {
|
|
|
1671
1671
|
if (!rs || !taskId)
|
|
1672
1672
|
return;
|
|
1673
1673
|
const t = e.type;
|
|
1674
|
+
// C2/C5(v3.1 批2):写失败留痕——同函数 onTaskNotification 的 park-enqueue 早按 2026-07-11
|
|
1675
|
+
// 对抗评审补了 warn(false park 指纹),这三处同构 fire-and-forget 此前裸吞:子代 tool 生命周期
|
|
1676
|
+
// 事件从 durable log 永久消失且零信号。结构上仍不能 await(同步回调),留痕不改调用形。
|
|
1677
|
+
const warnAppend = (kind) => (err) => deps.logger?.warn?.("forward_event_append_failed", { type: kind, taskId, err: err instanceof Error ? err.message : String(err) });
|
|
1674
1678
|
if (t === "task_progress") {
|
|
1675
|
-
void append("task_progress", taskProgressEventData(e)).catch(()
|
|
1679
|
+
void append("task_progress", taskProgressEventData(e)).catch(warnAppend(t));
|
|
1676
1680
|
}
|
|
1677
1681
|
else if (t === "tool_start") {
|
|
1678
1682
|
// C1 (core 1.219): a delegated child's forwarded tool lifecycle — durable via the SAME shared
|
|
1679
1683
|
// whitelist+redact builders as the top stream (parity with the bg leg; deltas not persisted per-chunk).
|
|
1680
|
-
void append("tool_start", toolStartEventData(e)).catch(()
|
|
1684
|
+
void append("tool_start", toolStartEventData(e)).catch(warnAppend(t));
|
|
1681
1685
|
}
|
|
1682
1686
|
else if (t === "tool_end") {
|
|
1683
|
-
void append("tool_end", toolEndEventData(e)).catch(()
|
|
1687
|
+
void append("tool_end", toolEndEventData(e)).catch(warnAppend(t));
|
|
1684
1688
|
}
|
|
1685
1689
|
},
|
|
1686
1690
|
// background-completion observer — flip the child's fleet row +
|
|
@@ -2559,7 +2563,7 @@ function cors(res, req, origins, principalHeader) {
|
|
|
2559
2563
|
// 后五项都是**真被 handler 读**的自定义头:`idempotency-key`(tasks/runs/images)、
|
|
2560
2564
|
// `x-detach-on-disconnect`(tasks 断连不中止)、direct-door 决策证明三件套(approvals /decide 的
|
|
2561
2565
|
// crypto 绑定腿 —— 缺了它,direct-door worker 上的 web 审批面在浏览器里根本用不了)。
|
|
2562
|
-
res.setHeader("access-control-allow-headers", `content-type, authorization, last-event-id, ${principalHeader}, idempotency-key, x-detach-on-disconnect, x-approval-principal-token, x-approval-mac, x-approval-mac-kid`);
|
|
2566
|
+
res.setHeader("access-control-allow-headers", `content-type, authorization, last-event-id, ${principalHeader}, idempotency-key, x-detach-on-disconnect, x-approval-principal-token, x-approval-mac, x-approval-mac-kid, if-none-match`);
|
|
2563
2567
|
// 🔴 expose-headers 此前**整个缺席** ⇒ fetch 类客户端读不到 `X-Task-Id`(routes/tasks.ts 经 sseHeaders
|
|
2564
2568
|
// 下发的 durable rewind handle)。tasks.ts 的 G15 meta 帧注释把这归因为「EventSource 与部分代理读不到
|
|
2565
2569
|
// 响应头」—— 对 EventSource 成立,但对 fetch 客户端真因是这里缺 expose,meta 帧只是绕过。
|
package/dist/lsp/e2b-bridge.js
CHANGED
|
@@ -26,7 +26,16 @@ const TOKEN = process.env.LSP_TOKEN || '';
|
|
|
26
26
|
const MAX = 50 * 1024 * 1024;
|
|
27
27
|
const HEARTBEAT_MS = Number(process.env.LSP_HEARTBEAT_MS || 30000);
|
|
28
28
|
const wss = new WebSocketServer({ host: '0.0.0.0', port: PORT });
|
|
29
|
+
// A zero-listener 'error' on ANY EventEmitter throws and crashes the process (Node default). Without this, a
|
|
30
|
+
// bind failure (e.g. EADDRINUSE — a second bridge start racing the same port) took down the WHOLE bridge
|
|
31
|
+
// process, killing every language's LSP session in the sandbox, not just the one that failed to start (HRD-LSP-4).
|
|
32
|
+
// Exit loud + explicit instead of an uncaught-exception dump so the failure is at least attributable.
|
|
33
|
+
wss.on('error', (err) => { console.error('lsp-bridge wss error', err && err.message); process.exit(1); });
|
|
29
34
|
wss.on('connection', (ws, req) => {
|
|
35
|
+
// Same zero-listener 'error' hazard as wss above, but per-connection: registered FIRST, before anything else
|
|
36
|
+
// touches this socket. Close (don't crash) — the heartbeat reaper's own ws.terminate() below is a routine
|
|
37
|
+
// source of a socket 'error' on an already-half-open connection (HRD-LSP-4).
|
|
38
|
+
ws.on('error', () => { try { ws.terminate(); } catch {} });
|
|
30
39
|
const q = ((req && req.url) || '').split('?')[1] || '';
|
|
31
40
|
if (TOKEN && new URLSearchParams(q).get('token') !== TOKEN) { ws.close(); return; } // auth (council #4)
|
|
32
41
|
ws.isAlive = true;
|
|
@@ -37,7 +46,11 @@ wss.on('connection', (ws, req) => {
|
|
|
37
46
|
const decode = makeFrameDecoder(MAX);
|
|
38
47
|
child.stdout.on('data', (d) => { for (const f of decode(d)) ws.send(f); });
|
|
39
48
|
ws.on('message', (data) => { const p = Buffer.from(data.toString(), 'utf8'); child.stdin.write('Content-Length: ' + p.length + '\r\n\r\n'); child.stdin.write(p); });
|
|
40
|
-
|
|
49
|
+
// pid guard: the child 'error' handler above routes spawn FAILURES (missing binary — pid never assigned)
|
|
50
|
+
// through ws.close() → this 'close' handler. Killing a child whose pid was never assigned can signal the
|
|
51
|
+
// CALLER's process group instead of the (nonexistent) child (HRD-LSP-4; same hazard class as bake-runner's
|
|
52
|
+
// spawner, src/bake-runner/main.ts).
|
|
53
|
+
ws.on('close', () => { if (child.pid !== undefined) child.kill(); });
|
|
41
54
|
child.on('exit', () => { try { ws.close(); } catch {} });
|
|
42
55
|
});
|
|
43
56
|
// Reap half-open connections: the E2B proxy kills a WS at ~60-75s and the kill can be SILENT on THIS side too —
|
|
@@ -8,9 +8,11 @@ export interface E2bLspOptions {
|
|
|
8
8
|
attempts: number;
|
|
9
9
|
delayMs: number;
|
|
10
10
|
};
|
|
11
|
-
/** Bridge auth token override. The manager pins ONE token per env+
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
/** Bridge auth token override. The manager pins ONE token per env+PORT — the bridge RESOURCE, not the
|
|
12
|
+
* language (`portForLanguage` maps several languages, e.g. typescript/javascript, onto the SAME port/process
|
|
13
|
+
* — see `createE2bLspManager`). On a RE-open (transport heal after the E2B proxy idle-killed the WS) the
|
|
14
|
+
* original bridge process is still listening with the FIRST token — a fresh random token would be rejected
|
|
15
|
+
* and the reconnect would always fail. */
|
|
14
16
|
token?: string;
|
|
15
17
|
/** WS scheme for the bridge URL. E2B's `getHost` returns a public TLS proxy host → `wss` (default);
|
|
16
18
|
* the k8s adapter returns a cluster-internal `podIP:port` → plain `ws` (no TLS on the pod network;
|
|
@@ -19,7 +21,9 @@ export interface E2bLspOptions {
|
|
|
19
21
|
/** Observability hook (open/heal/degrade events) — prod wires the service logger. */
|
|
20
22
|
log?: (event: string, fields: Record<string, unknown>) => void;
|
|
21
23
|
}
|
|
22
|
-
/** Start (+ install on a non-baked template) the language server + bridge inside THIS env's sandbox, then connect.
|
|
24
|
+
/** Start (+ install on a non-baked template) the language server + bridge inside THIS env's sandbox, then connect.
|
|
25
|
+
* Every call starts (or re-starts) the bridge unconditionally — the one production caller is
|
|
26
|
+
* `createE2bLspManager`, which de-dupes per env+port before ever reaching here (see its doc). */
|
|
23
27
|
export declare function openE2bLspTransport(language: string, env: LspCapableEnv, opts?: E2bLspOptions): Promise<LspTransport | undefined>;
|
|
24
28
|
/** The single shared `LspServerManager` for the deployment (`RunnerDeps.lspManager`). */
|
|
25
29
|
export declare function createE2bLspManager(opts?: E2bLspOptions): E2bLspManager;
|
package/dist/lsp/e2b-manager.js
CHANGED
|
@@ -12,19 +12,28 @@ import { randomUUID } from "node:crypto";
|
|
|
12
12
|
import { connectWsLspTransport } from "./ws-transport.js";
|
|
13
13
|
import { E2bLspManager } from "./manager.js";
|
|
14
14
|
import { BRIDGE_DIR, BRIDGE_SOURCE, lspInstallCommand, lspStartCommand, portForLanguage, SERVER_CMD } from "./e2b-bridge.js";
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
/** Write bridge.cjs + (foreground) install the language server, then start the bridge as a TRUE background
|
|
16
|
+
* command. Returns whether the bridge is now up-or-starting (`false` only when the foreground install failed).
|
|
17
|
+
* A single-shot primitive: it does NOT check whether a bridge is already listening on `port` — a caller that
|
|
18
|
+
* can have two languages share one port (`createE2bLspManager`, since `portForLanguage` maps typescript AND
|
|
19
|
+
* javascript onto 8123 off the same `SERVER_CMD`) MUST call this at most ONCE per env+port itself. A second
|
|
20
|
+
* `node bridge.cjs` on an already-bound port surfaces as an EADDRINUSE `wss` 'error' (e2b-bridge.ts D6
|
|
21
|
+
* hardening keeps that from crashing the sandbox, but the second attempt still fails and wastes a sandbox
|
|
22
|
+
* round-trip for nothing — HRD-LSP-4). */
|
|
23
|
+
async function startE2bBridge(language, env, port, token) {
|
|
22
24
|
await env.writeFile(`${BRIDGE_DIR}/bridge.cjs`, BRIDGE_SOURCE).catch(() => undefined);
|
|
23
25
|
const installed = await env.exec(lspInstallCommand(language)).catch(() => undefined);
|
|
24
26
|
if (!installed || !installed.ok)
|
|
25
|
-
return
|
|
26
|
-
const token = opts?.token ?? randomUUID(); // per-session bridge auth — the getHost URL is public (council #4)
|
|
27
|
+
return false;
|
|
27
28
|
await env.startBackground(lspStartCommand(language, port, token)).catch(() => undefined);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
/** Connect (with retry) to an already-started bridge. Each call opens its OWN WS connection — the bridge spawns
|
|
32
|
+
* a FRESH child language-server process per connection (e2b-bridge.ts `wss.on('connection', …)`), so two
|
|
33
|
+
* languages sharing one bridge port each still get an isolated language-server instance. */
|
|
34
|
+
async function connectE2bLspBridge(env, port, token, opts) {
|
|
35
|
+
const connect = opts?.connect ?? connectWsLspTransport;
|
|
36
|
+
const retry = opts?.retry ?? { attempts: 6, delayMs: 1500 };
|
|
28
37
|
const root = env.workspaceHandle().mountPath;
|
|
29
38
|
// the bridge starts as a background command → retry the WS connect (with the auth token) while it comes up
|
|
30
39
|
const scheme = opts?.scheme ?? "wss";
|
|
@@ -39,26 +48,49 @@ export async function openE2bLspTransport(language, env, opts) {
|
|
|
39
48
|
}
|
|
40
49
|
return undefined;
|
|
41
50
|
}
|
|
51
|
+
/** Start (+ install on a non-baked template) the language server + bridge inside THIS env's sandbox, then connect.
|
|
52
|
+
* Every call starts (or re-starts) the bridge unconditionally — the one production caller is
|
|
53
|
+
* `createE2bLspManager`, which de-dupes per env+port before ever reaching here (see its doc). */
|
|
54
|
+
export async function openE2bLspTransport(language, env, opts) {
|
|
55
|
+
if (!SERVER_CMD[language])
|
|
56
|
+
return undefined; // no server for this language → graceful degrade
|
|
57
|
+
const port = portForLanguage(language);
|
|
58
|
+
const token = opts?.token ?? randomUUID(); // per-session bridge auth — the getHost URL is public (council #4)
|
|
59
|
+
const started = await startE2bBridge(language, env, port, token);
|
|
60
|
+
if (!started)
|
|
61
|
+
return undefined;
|
|
62
|
+
return connectE2bLspBridge(env, port, token, opts);
|
|
63
|
+
}
|
|
42
64
|
/** The single shared `LspServerManager` for the deployment (`RunnerDeps.lspManager`). */
|
|
43
65
|
export function createE2bLspManager(opts) {
|
|
44
|
-
// One bridge token per env+
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
66
|
+
// One bridge PROCESS + token per env+PORT — not per env+language. `portForLanguage` maps typescript and
|
|
67
|
+
// javascript onto the SAME port (they share `SERVER_CMD`): keying by language minted two auth tokens for the
|
|
68
|
+
// one listening bridge (HRD-LSP-4) and re-invoked `startBackground` on an already-bound port on every open.
|
|
69
|
+
// WeakMap → a destroyed task's bridge bookkeeping is collectible with its env.
|
|
70
|
+
const bridges = new WeakMap();
|
|
48
71
|
return new E2bLspManager({
|
|
49
72
|
log: opts?.log,
|
|
50
|
-
transportFactory: (language, env) => {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
73
|
+
transportFactory: async (language, env) => {
|
|
74
|
+
if (!SERVER_CMD[language])
|
|
75
|
+
return undefined; // no server for this language → graceful degrade (mirrors
|
|
76
|
+
// openE2bLspTransport's own guard; MUST run before touching `bridges` — `portForLanguage` falls back to
|
|
77
|
+
// port 8123 for an unmapped language, which would otherwise silently borrow typescript's live bridge)
|
|
78
|
+
const port = portForLanguage(language);
|
|
79
|
+
let perPort = bridges.get(env);
|
|
80
|
+
if (!perPort) {
|
|
81
|
+
perPort = new Map();
|
|
82
|
+
bridges.set(env, perPort);
|
|
55
83
|
}
|
|
56
|
-
let
|
|
57
|
-
if (!
|
|
58
|
-
token = opts?.token ?? randomUUID();
|
|
59
|
-
|
|
84
|
+
let bridge = perPort.get(port);
|
|
85
|
+
if (!bridge) {
|
|
86
|
+
const token = opts?.token ?? randomUUID();
|
|
87
|
+
bridge = { token, ready: startE2bBridge(language, env, port, token) };
|
|
88
|
+
perPort.set(port, bridge); // synchronous — no await above (run-to-completion dedupe guard, see E2bBridgeHandle.ready doc)
|
|
60
89
|
}
|
|
61
|
-
|
|
90
|
+
const started = await bridge.ready;
|
|
91
|
+
if (!started)
|
|
92
|
+
return undefined;
|
|
93
|
+
return connectE2bLspBridge(env, port, bridge.token, opts);
|
|
62
94
|
},
|
|
63
95
|
});
|
|
64
96
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.24.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",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@sema-agent/core": "^2.13.0",
|
|
58
|
-
"@sema-agent/registry-core": "^0.
|
|
58
|
+
"@sema-agent/registry-core": "^0.12.0",
|
|
59
59
|
"e2b": "^2.28.0",
|
|
60
60
|
"libsodium-wrappers": "^0.8.4",
|
|
61
61
|
"mysql2": "^3.22.4",
|