@sema-agent/server 3.0.0 → 3.1.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 +4 -0
- package/dist/boot/config-center.d.ts +2 -1
- package/dist/boot/config-center.js +27 -13
- package/dist/config-center/restart-signal.d.ts +10 -2
- package/dist/config-center/restart-signal.js +28 -4
- package/dist/plugins/remote-env-host.d.ts +32 -14
- package/dist/plugins/remote-env-host.js +30 -10
- package/dist/plugins/remote-env-local-docker.d.ts +14 -0
- package/dist/plugins/remote-env-local-docker.js +10 -3
- package/dist/plugins/run-store-sql.d.ts +8 -3
- package/dist/plugins/run-store-sql.js +8 -3
- package/dist/sema-registry.d.ts +18 -4
- package/dist/sema-registry.js +17 -3
- package/package.json +2 -2
package/USAGE.md
CHANGED
|
@@ -239,6 +239,10 @@ SEMA_REGISTRY_WORKER=<worker名> # 可选:拉取 /effective?worke
|
|
|
239
239
|
🪦 `LSP_ENABLED=false`(拆分前唯一的 host 腿逃生舱)自 3.0.0 起是墓碑:拒启并指路 `LSP_HOST_ENABLED`。
|
|
240
240
|
`LSP_ENABLED=true` 不受影响——那是沙箱腿自己的 opt-in,语义没变。
|
|
241
241
|
|
|
242
|
+
**`CONFIG_REQUIRE_ROSTER=true`(3.1.0,可选,缺省关)**:远程 registry 非 dryRun 车道的 fleet worker
|
|
243
|
+
可布防「roster 落地前拒接计费提交(503)」——3.0.0 起 MODEL_ID 必填,每个 worker 都带 boot 模型起服,
|
|
244
|
+
旧的「无 env 模型即等 roster」推断失效;要那个保护语义现在需显式声明。
|
|
245
|
+
|
|
242
246
|
---
|
|
243
247
|
|
|
244
248
|
## 1. 请求与响应的通用规则
|
|
@@ -47,7 +47,8 @@ export interface ConfigCenterRuntime {
|
|
|
47
47
|
} | undefined>) | undefined;
|
|
48
48
|
/** 晚绑取值:center 提示词面(热采用,新任务边界生效)。 */
|
|
49
49
|
getCenterPrompts(): PromptsDomainFaces | undefined;
|
|
50
|
-
/** boot ready
|
|
50
|
+
/** boot ready 门:布防(`CONFIG_REQUIRE_ROSTER=true`,远程 registry 非 dryRun)且 roster 未落地 =
|
|
51
|
+
* 计费提交 503 + /health ready:false。缺省未布防 ⇒ 恒 true。 */
|
|
51
52
|
modelReady(): boolean;
|
|
52
53
|
/** /health.restart(orchestrator 自动重启信号)。 */
|
|
53
54
|
restartState(): RestartSignal | undefined;
|
|
@@ -119,17 +119,24 @@ export async function createConfigCenterRuntime(ctx) {
|
|
|
119
119
|
...(config.configProvider === "local" ? { localDir: localRoot } : {}),
|
|
120
120
|
})
|
|
121
121
|
: undefined;
|
|
122
|
-
// boot ready 门 latch
|
|
123
|
-
//
|
|
124
|
-
//
|
|
122
|
+
// boot ready 门 latch:布防时等中心 roster 落地——首次 effective 落 ≥1 enabled 模型即翻 true(one-way);
|
|
123
|
+
// 未布防则恒 ready。守的危险=**registry 部署的 fleet worker 在 roster 落地前拿占位模型跑计费任务**
|
|
124
|
+
// (E3 废除 workers.model 之后,占位模型正是 fleet worker 的常态起服姿势)。
|
|
125
125
|
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
126
|
+
// 🔧 复审 2026-07-29 P1-6 —— 布防判据从**推断**改成**运营者声明**。原式是
|
|
127
|
+
// `!(cc && !cc.dryRun) || Boolean(process.env.MODEL_ID)`,其中 `Boolean(process.env.MODEL_ID)` 是
|
|
128
|
+
// 3.0.0 前「运营者手上除了中心 roster 什么都没有」的代理判据。**server 3.0.0 起 `MODEL_ID` 必填**
|
|
129
|
+
// (未设 = loadConfig throw,见 config.ts parseModelDomain)⇒ 走到这一行时它必非空 ⇒ 初值恒 true ⇒
|
|
130
|
+
// 「等 roster」那条臂不可达、markRosterLanded 的门体是死码、/health 的 ready:false 与计费提交的 503
|
|
131
|
+
// 全不可能发生。注意这不是「危险消失了」:必填 MODEL_ID 恰恰让**每个** fleet worker 都带着占位模型
|
|
132
|
+
// 起服,危险比以前更常见——只是那个代理判据从此写不出来了。
|
|
133
|
+
//
|
|
134
|
+
// 于是改成显式声明:`CONFIG_REQUIRE_ROSTER=true`(与 CONFIG_LKG_* / CONFIG_BOOT_FETCH_BUDGET_MS 同族,
|
|
135
|
+
// 直读 env,不进 ServiceConfig——纯 boot 期姿势旋钮)。**缺省关 = 现有部署逐字节零变化**;开了才真的
|
|
136
|
+
// fail-closed 等 roster。仅远程 registry 且非 dryRun 车道可布防:dryRun 是 compare-only(中心 roster
|
|
137
|
+
// 从不被应用,拿它当就绪判据无意义),local provider / 纯 env 部署则没有「中心」可等。
|
|
138
|
+
const rosterGateArmed = cc !== undefined && !cc.dryRun && process.env.CONFIG_REQUIRE_ROSTER === "true";
|
|
139
|
+
const modelReadyState = { ready: !rosterGateArmed };
|
|
133
140
|
const markRosterLanded = (eff) => {
|
|
134
141
|
if (!modelReadyState.ready && (eff.models?.models ?? []).some((m) => m.enabled !== false)) {
|
|
135
142
|
modelReadyState.ready = true;
|
|
@@ -463,12 +470,19 @@ export async function createConfigCenterRuntime(ctx) {
|
|
|
463
470
|
}
|
|
464
471
|
}
|
|
465
472
|
if (!modelReadyState.ready)
|
|
466
|
-
logger.warn("model_roster_pending", {
|
|
473
|
+
logger.warn("model_roster_pending", {
|
|
474
|
+
note: "CONFIG_REQUIRE_ROSTER=true and no center roster has landed yet — the env MODEL_ID is treated as a PLACEHOLDER, so billable submissions 503 (and /health reports ready:false) until the first effective pull lands ≥1 enabled model. Unset the knob if this worker's env model is authoritative.",
|
|
475
|
+
});
|
|
467
476
|
// Per-model API key (sema-registry `apiKeyEnv`): resolve each model's own upstream key per brain call /
|
|
468
477
|
// cascade rung. undefined when no per-model keys are configured → spec field stays unset (core default).
|
|
469
478
|
// `let` (not const): rebuilt on a sema-registry refresh so per-model key ADDITIONS hot-apply too (the
|
|
470
479
|
// resolver is undefined when no per-model keys exist, so in-place mutation alone wouldn't cover empty→non-empty).
|
|
471
480
|
let keyResolver;
|
|
481
|
+
// 复审 2026-07-29 P1-11:restart 片里唯一一块 EffectiveConfig **看不见**的输入 —— 反应式降级车道由 env
|
|
482
|
+
// (MODEL_DEGRADE_TO / MODEL_DEGRADE_REACTIVE)开关,而它焊死的是 CENTER 目录里那条模型的路由面。读成
|
|
483
|
+
// 函数而非常量:`config.degrade` 今天由 env 独占(applyEffective 不写它),但若哪天中心接管这面,这里
|
|
484
|
+
// 自动跟着走,不会退化成 boot 期快照。车道关(默认)⇒ ctx 空 ⇒ 该片恒 null ⇒ 零行为变化。
|
|
485
|
+
const restartCtx = () => (config.degrade?.reactive ? { reactiveDegradeTo: config.degrade.to } : {});
|
|
472
486
|
return {
|
|
473
487
|
providerKind: configProvider?.kind,
|
|
474
488
|
promptSource,
|
|
@@ -794,7 +808,7 @@ export async function createConfigCenterRuntime(ctx) {
|
|
|
794
808
|
// 顺序=先持久化(含 skill 正文预热,F1)再发信号;持久化失败=warn+照发(不发=配置永不生效;
|
|
795
809
|
// 「盘坏+中心挂」双故障下环重现,接受并点名)。
|
|
796
810
|
const lkgPersisted = await persistLkgDurable(r.effective, r.etag);
|
|
797
|
-
const reasons = restartReasons(effective, r.effective);
|
|
811
|
+
const reasons = restartReasons(effective, r.effective, restartCtx());
|
|
798
812
|
// codex R35: the sticky boot skill debt rides EVERY tick's reasons — change-detection vs boot is
|
|
799
813
|
// blind to it (the stale table IS the boot baseline). Under a no-handoff deferral it folds into
|
|
800
814
|
// blocked (promoted later); otherwise it keeps pendingRestart alive until the restart happens.
|
|
@@ -956,7 +970,7 @@ export async function createConfigCenterRuntime(ctx) {
|
|
|
956
970
|
latestEffective = r.effective;
|
|
957
971
|
ccEtag = r.etag;
|
|
958
972
|
const lkgPersistedLate = await persistLkgDurable(r.effective, r.etag); // F2 parity:信号可见前落盘+skill 正文预热
|
|
959
|
-
const reasons = restartReasons(undefined, r.effective); // (prompts 不在 restart slices——热采用已在上方 adopt)
|
|
973
|
+
const reasons = restartReasons(undefined, r.effective, restartCtx()); // (prompts 不在 restart slices——热采用已在上方 adopt)
|
|
960
974
|
// codex R15 (late-boot twin): tiered env boot + tier-less late candidate ⇒ deferral fires but BOTH slice
|
|
961
975
|
// fingerprints reduce to null (undefined baseline, tier-less candidate) — no models-tiers reason, plane
|
|
962
976
|
// deferred forever. Deferral is the ground truth; force the reason so the orchestrator restarts and the
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { EffectiveConfig } from "./types.js";
|
|
2
|
-
export type RestartSlice = "skills" | "mcp" | "scenarios" | "runtime-gates" | "models-tiers";
|
|
2
|
+
export type RestartSlice = "skills" | "mcp" | "scenarios" | "runtime-gates" | "models-tiers" | "degrade-route";
|
|
3
|
+
/** What the CALLER must contribute about boot-baked catalog consumers that live OUTSIDE the Runner — the
|
|
4
|
+
* slices cannot see them, because they are configured from env (`ServiceConfig`), not from the center's
|
|
5
|
+
* EffectiveConfig. Absent/empty = that lane is off ⇒ its slice is inert (null on both sides). */
|
|
6
|
+
export interface RestartSliceCtx {
|
|
7
|
+
/** `config.degrade.to` when REACTIVE degrade is on (env `MODEL_DEGRADE_TO` + `MODEL_DEGRADE_REACTIVE`);
|
|
8
|
+
* undefined = the lane is off ⇒ no degrading brain was composed ⇒ nothing is boot-frozen. */
|
|
9
|
+
reactiveDegradeTo?: string;
|
|
10
|
+
}
|
|
3
11
|
/** Structured restart signal an orchestrator consumes (GET /health → `restart`). Change-detected against the
|
|
4
12
|
* process's BOOT config, NOT presence — so it stays absent when nothing baked-at-boot changed (presence would
|
|
5
13
|
* re-fire every refresh → a restart LOOP). `restartRequired` is always true when this object exists. */
|
|
@@ -16,7 +24,7 @@ export interface RestartSignal {
|
|
|
16
24
|
* nothing baked-at-boot changed (only hot-apply slices moved, or nothing) → no restart needed. An orchestrator
|
|
17
25
|
* can auto rolling-restart on a non-empty result WITHOUT a restart loop, because the comparison is always
|
|
18
26
|
* against boot (a refresh that re-fires the same diff is idempotent, not a fresh trigger). */
|
|
19
|
-
export declare function restartReasons(boot: EffectiveConfig | undefined, current: EffectiveConfig): RestartSlice[];
|
|
27
|
+
export declare function restartReasons(boot: EffectiveConfig | undefined, current: EffectiveConfig, ctx?: RestartSliceCtx): RestartSlice[];
|
|
20
28
|
/** codex R10 (models-tiers 窗收口): TRUE when the MODEL PLANE (enabled models + active tier table + resolved
|
|
21
29
|
* default) of `next` differs from the last-APPLIED plane `prev`. Under a tier-frozen Runner (tiers non-empty at
|
|
22
30
|
* construction → private expanded copy) main.ts DEFERS the whole plane mutation until restart when this is true —
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { resolveActiveTiers } from "@sema-agent/registry-core";
|
|
9
9
|
import { RUNTIME_GATE_KEYS, runtimeGatePresent, resolveDefaultModelName } from "./apply-effective.js";
|
|
10
|
-
const RESTART_SLICES = ["skills", "mcp", "scenarios", "runtime-gates", "models-tiers"];
|
|
10
|
+
const RESTART_SLICES = ["skills", "mcp", "scenarios", "runtime-gates", "models-tiers", "degrade-route"];
|
|
11
11
|
/** Canonical, key-sorted JSON (array order preserved) so two semantically-equal effective slices fingerprint
|
|
12
12
|
* identically regardless of object key order from the center serializer. */
|
|
13
13
|
function stableStringify(v) {
|
|
@@ -31,7 +31,22 @@ function enabledOnly(rows) {
|
|
|
31
31
|
return null;
|
|
32
32
|
return rows.filter((r) => r.enabled !== false);
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
/** The degrade hop is built from the FULLY-DERIVED Model (`toModel`), so almost every catalog field on the
|
|
35
|
+
* target reaches it (id → body.model, baseUrl → the endpoint, provider → which brain, maxTokens/compat/
|
|
36
|
+
* extraBody/input → the request body, apiKeyEnv → the construction-time credential). We therefore fingerprint
|
|
37
|
+
* the WHOLE entry minus the two fields that are provably HOT — `cost` (refreshed via
|
|
38
|
+
* `mutateInPlace(pricing, buildPricing(config.models))`) and `quotaWeight` (via
|
|
39
|
+
* `mutateInPlace(config.modelQuotaWeights, …)`) — so a pure re-pricing never restarts a worker. A DENY-list
|
|
40
|
+
* (not an allow-list) on purpose: a catalog field added later rides the fingerprint automatically, so the
|
|
41
|
+
* failure mode of drift is a spurious restart, never a silently missed one. */
|
|
42
|
+
function degradeRouteFingerprint(entry) {
|
|
43
|
+
// `enabled` is dropped too, but for a different reason: `enabledOnly` already filtered on it, so among the
|
|
44
|
+
// entries that reach here it is `true`-or-absent — two encodings of ONE state. Keeping it would let a center
|
|
45
|
+
// serializer that starts/stops emitting the explicit `true` manufacture a restart out of nothing.
|
|
46
|
+
const { cost: _cost, quotaWeight: _quotaWeight, enabled: _enabled, ...route } = entry;
|
|
47
|
+
return route;
|
|
48
|
+
}
|
|
49
|
+
function restartSliceValue(eff, slice, ctx) {
|
|
35
50
|
if (!eff)
|
|
36
51
|
return null;
|
|
37
52
|
switch (slice) {
|
|
@@ -69,14 +84,23 @@ function restartSliceValue(eff, slice) {
|
|
|
69
84
|
const def = resolveDefaultModelName(eff, (n) => names.has(n), enabled?.[0]?.name ?? "");
|
|
70
85
|
return { models: enabled, tiers: active, default: def.name };
|
|
71
86
|
}
|
|
87
|
+
case "degrade-route": {
|
|
88
|
+
const to = ctx?.reactiveDegradeTo;
|
|
89
|
+
if (to === undefined)
|
|
90
|
+
return null; // lane off — no degrading brain exists to go stale
|
|
91
|
+
// Present↔absent is itself a change: with no enabled entry named `to`, createBrain composes NO
|
|
92
|
+
// degrading shell at all, so the flip only takes effect at the next boot.
|
|
93
|
+
const target = (enabledOnly(eff.models?.models) ?? []).find((m) => m.name === to);
|
|
94
|
+
return target ? degradeRouteFingerprint(target) : null;
|
|
95
|
+
}
|
|
72
96
|
}
|
|
73
97
|
}
|
|
74
98
|
/** Restart-to-apply slices that DIFFER between this process's BOOT config and a freshly-pulled one. Empty =
|
|
75
99
|
* nothing baked-at-boot changed (only hot-apply slices moved, or nothing) → no restart needed. An orchestrator
|
|
76
100
|
* can auto rolling-restart on a non-empty result WITHOUT a restart loop, because the comparison is always
|
|
77
101
|
* against boot (a refresh that re-fires the same diff is idempotent, not a fresh trigger). */
|
|
78
|
-
export function restartReasons(boot, current) {
|
|
79
|
-
return RESTART_SLICES.filter((s) => stableStringify(restartSliceValue(boot, s)) !== stableStringify(restartSliceValue(current, s)));
|
|
102
|
+
export function restartReasons(boot, current, ctx) {
|
|
103
|
+
return RESTART_SLICES.filter((s) => stableStringify(restartSliceValue(boot, s, ctx)) !== stableStringify(restartSliceValue(current, s, ctx)));
|
|
80
104
|
}
|
|
81
105
|
/** codex R10 (models-tiers 窗收口): TRUE when the MODEL PLANE (enabled models + active tier table + resolved
|
|
82
106
|
* default) of `next` differs from the last-APPLIED plane `prev`. Under a tier-frozen Runner (tiers non-empty at
|
|
@@ -280,22 +280,40 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
|
|
|
280
280
|
* `ctx.sessionId` into the workspace dir id so two concurrent tasks never share a working directory.
|
|
281
281
|
*/
|
|
282
282
|
export declare function hostExecutionEnvFactory(config?: HostEnvConfig): ExecutionEnvFactory;
|
|
283
|
-
export { localDockerExecutionEnvFactory, type LocalDockerEnvConfig, RemoteLocalDockerExecutionEnv } from "./remote-env-local-docker.js";
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
283
|
+
export { localDockerExecutionEnvFactory, type LocalDockerEnvConfig, type LocalDockerEnvDeps, RemoteLocalDockerExecutionEnv } from "./remote-env-local-docker.js";
|
|
284
|
+
import { type LocalDockerEnvConfig, type LocalDockerEnvDeps } from "./remote-env-local-docker.js";
|
|
285
|
+
/**
|
|
286
|
+
* Config for the `remote-docker` provider: run each task in a container on a REMOTE docker daemon
|
|
287
|
+
* (DUAL-MODE-DESIGN §5 — "a user-specified REMOTE host's docker"; registry-core `RemoteExecSpec`).
|
|
288
|
+
*
|
|
289
|
+
* It is {@link LocalDockerEnvConfig} with the endpoint made REQUIRED — that is the whole difference, by design:
|
|
290
|
+
* the container adapter is already daemon-agnostic (workspace `mkdir`'d INSIDE the container, never a host
|
|
291
|
+
* bind-mount; bytes over `docker cp` / `docker exec cat`, which stream through the daemon API), and the endpoint
|
|
292
|
+
* rides as `-H <dockerHost>` on every invocation. So every local-docker knob (memory/cpus/pidsLimit/dropAllCaps/
|
|
293
|
+
* network/timeouts/env/dockerPath) applies unchanged to the remote lane.
|
|
294
|
+
*/
|
|
295
|
+
export interface RemoteDockerEnvConfig extends Omit<LocalDockerEnvConfig, "dockerHost" | "providerLabel"> {
|
|
296
|
+
/** Remote docker daemon endpoint (e.g. `tcp://host:2376` or `ssh://user@host`). REQUIRED for remote-docker. */
|
|
292
297
|
dockerHost: string;
|
|
293
|
-
/** Out-of-band env injected into the container (secrets resolved by the control plane). */
|
|
294
|
-
env?: Record<string, string>;
|
|
295
298
|
}
|
|
296
299
|
/**
|
|
297
|
-
* `
|
|
298
|
-
*
|
|
300
|
+
* `ExecutionEnvFactory` for the TOC `remote-docker` backend (DUAL-MODE-DESIGN §5): a per-task container on
|
|
301
|
+
* SOMEONE ELSE'S docker daemon (offload — the operator's box stays free; isolation:true, suspendable:false).
|
|
302
|
+
* Same lifetime contract as local-docker: unconnected/lazy, one container per task, `destroy()` = `docker rm -f`.
|
|
303
|
+
*
|
|
304
|
+
* 🔴 **Endpoint credentials are the docker CLI's own** — this adapter deliberately carries none. The docker CLI
|
|
305
|
+
* reads `DOCKER_CERT_PATH` + `DOCKER_TLS_VERIFY` from the worker's environment (inherited by every spawn) for a
|
|
306
|
+
* `tcp://` + TLS daemon, and uses `ssh-agent` / `~/.ssh/config` for an `ssh://` one. That keeps key material out
|
|
307
|
+
* of this process's config objects entirely. Consequence to know: `tcp://…:2376` WITHOUT those env vars fails
|
|
308
|
+
* loud at connect (the daemon refuses plaintext) — it does not silently downgrade; a plaintext `tcp://…:2375`
|
|
309
|
+
* daemon is unauthenticated and remains the operator's decision, exactly as in the registry-core schema.
|
|
310
|
+
* (The registry-core `remote-docker` arm additionally models `tlsCertEnv`/`tlsKeyEnv`/`tlsCaEnv`/`sshKeyEnv` =
|
|
311
|
+
* env-NAMEs the control plane resolves to PEM BYTES. Materializing those bytes to short-lived 0600 files and
|
|
312
|
+
* passing `--tlsverify --tlscert/--tlskey/--tlscacert` is the remaining config-surface follow-up, together with
|
|
313
|
+
* the `provider:"remote-docker"` arm on `AppConfig.remoteExec` — see the note on that union in config-types.ts.)
|
|
314
|
+
*
|
|
315
|
+
* Throws at factory-BUILD time on a missing/blank endpoint: a "remote" lane with no endpoint would otherwise
|
|
316
|
+
* quietly become the LOCAL daemon — an isolation-topology surprise, so it fails loud instead.
|
|
299
317
|
*/
|
|
300
|
-
export declare function remoteDockerExecutionEnvFactory(
|
|
318
|
+
export declare function remoteDockerExecutionEnvFactory(config: RemoteDockerEnvConfig, deps?: LocalDockerEnvDeps): ExecutionEnvFactory;
|
|
301
319
|
//# sourceMappingURL=remote-env-host.d.ts.map
|
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
* (`{ ok:true, exitCode }`), never an error.
|
|
29
29
|
*
|
|
30
30
|
* Platforms: macOS + Linux (POSIX shell). Forward-compatible with the `remoteExec` superset: this file also
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* hosts the `local-docker` / `remote-docker` providers' factory entry points — both now IMPLEMENTED by the
|
|
32
|
+
* container adapter in remote-env-local-docker.ts (the two providers differ only in whether the `-H` daemon
|
|
33
|
+
* endpoint is off-box and required), re-exported/wrapped here so the discriminator has one canonical site.
|
|
33
34
|
*/
|
|
34
35
|
import { spawn } from "node:child_process";
|
|
35
36
|
import os from "node:os";
|
|
@@ -1647,19 +1648,38 @@ export function hostExecutionEnvFactory(config = {}) {
|
|
|
1647
1648
|
// ─────────────────────────────── remoteExec superset: docker providers ───────────────────────────────
|
|
1648
1649
|
//
|
|
1649
1650
|
// DUAL-MODE-DESIGN §5 defines a `remoteExec.provider` superset. `host` (above) is implemented; `local-docker`
|
|
1650
|
-
//
|
|
1651
|
-
// re-exported here so the discriminator entry
|
|
1652
|
-
//
|
|
1653
|
-
// implemented" so selecting it fails loud at wiring time instead of silently degrading isolation. NOT faked green.
|
|
1651
|
+
// (the isolation lane) and `remote-docker` (that same container lane aimed at a REMOTE daemon) are BOTH
|
|
1652
|
+
// implemented by remote-env-local-docker.ts and re-exported / wrapped here so the discriminator entry points +
|
|
1653
|
+
// the historical import sites stay stable.
|
|
1654
1654
|
// `local-docker` (the isolation lane): real implementation moved to remote-env-local-docker.ts. Re-export the
|
|
1655
1655
|
// factory + config from here so existing wiring (`import { localDockerExecutionEnvFactory } from "./remote-env-host"`)
|
|
1656
1656
|
// keeps working and the provider discriminator has one canonical entry point.
|
|
1657
1657
|
export { localDockerExecutionEnvFactory, RemoteLocalDockerExecutionEnv } from "./remote-env-local-docker.js";
|
|
1658
|
+
import { localDockerExecutionEnvFactory } from "./remote-env-local-docker.js";
|
|
1658
1659
|
/**
|
|
1659
|
-
* `
|
|
1660
|
-
*
|
|
1660
|
+
* `ExecutionEnvFactory` for the TOC `remote-docker` backend (DUAL-MODE-DESIGN §5): a per-task container on
|
|
1661
|
+
* SOMEONE ELSE'S docker daemon (offload — the operator's box stays free; isolation:true, suspendable:false).
|
|
1662
|
+
* Same lifetime contract as local-docker: unconnected/lazy, one container per task, `destroy()` = `docker rm -f`.
|
|
1663
|
+
*
|
|
1664
|
+
* 🔴 **Endpoint credentials are the docker CLI's own** — this adapter deliberately carries none. The docker CLI
|
|
1665
|
+
* reads `DOCKER_CERT_PATH` + `DOCKER_TLS_VERIFY` from the worker's environment (inherited by every spawn) for a
|
|
1666
|
+
* `tcp://` + TLS daemon, and uses `ssh-agent` / `~/.ssh/config` for an `ssh://` one. That keeps key material out
|
|
1667
|
+
* of this process's config objects entirely. Consequence to know: `tcp://…:2376` WITHOUT those env vars fails
|
|
1668
|
+
* loud at connect (the daemon refuses plaintext) — it does not silently downgrade; a plaintext `tcp://…:2375`
|
|
1669
|
+
* daemon is unauthenticated and remains the operator's decision, exactly as in the registry-core schema.
|
|
1670
|
+
* (The registry-core `remote-docker` arm additionally models `tlsCertEnv`/`tlsKeyEnv`/`tlsCaEnv`/`sshKeyEnv` =
|
|
1671
|
+
* env-NAMEs the control plane resolves to PEM BYTES. Materializing those bytes to short-lived 0600 files and
|
|
1672
|
+
* passing `--tlsverify --tlscert/--tlskey/--tlscacert` is the remaining config-surface follow-up, together with
|
|
1673
|
+
* the `provider:"remote-docker"` arm on `AppConfig.remoteExec` — see the note on that union in config-types.ts.)
|
|
1674
|
+
*
|
|
1675
|
+
* Throws at factory-BUILD time on a missing/blank endpoint: a "remote" lane with no endpoint would otherwise
|
|
1676
|
+
* quietly become the LOCAL daemon — an isolation-topology surprise, so it fails loud instead.
|
|
1661
1677
|
*/
|
|
1662
|
-
export function remoteDockerExecutionEnvFactory(
|
|
1663
|
-
|
|
1678
|
+
export function remoteDockerExecutionEnvFactory(config, deps = {}) {
|
|
1679
|
+
const dockerHost = config.dockerHost?.trim();
|
|
1680
|
+
if (!dockerHost) {
|
|
1681
|
+
throw new RemoteExecutionError("connect_failed", "remoteExec provider 'remote-docker' requires a non-empty dockerHost (the remote daemon endpoint, e.g. tcp://host:2376 or ssh://user@host) — use provider 'local-docker' for THIS machine's daemon");
|
|
1682
|
+
}
|
|
1683
|
+
return localDockerExecutionEnvFactory({ ...config, dockerHost, providerLabel: "remote-docker" }, deps);
|
|
1664
1684
|
}
|
|
1665
1685
|
//# sourceMappingURL=remote-env-host.js.map
|
|
@@ -17,8 +17,20 @@ export interface LocalDockerEnvConfig {
|
|
|
17
17
|
/**
|
|
18
18
|
* DOCKER_HOST override for the docker CLI (e.g. a non-default socket). Passed as `-H <value>` on EVERY docker
|
|
19
19
|
* invocation (not the process env) so it can't leak / drift. Absent = the daemon DOCKER_HOST points at.
|
|
20
|
+
*
|
|
21
|
+
* Pointing this OFF-BOX (`tcp://host:2376`, `ssh://user@host`) is exactly the DUAL-MODE-DESIGN §5
|
|
22
|
+
* **`remote-docker`** provider — see {@link remoteDockerExecutionEnvFactory} (remote-env-host.ts), which is
|
|
23
|
+
* this adapter with the endpoint required. Nothing else about the class is host-local: the workspace is
|
|
24
|
+
* `mkdir`'d INSIDE the container (never a bind-mount of a host path) and bytes move by `docker cp` /
|
|
25
|
+
* `docker exec cat`, which stream over the daemon API.
|
|
20
26
|
*/
|
|
21
27
|
dockerHost?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Provider NAME reported on the {@link WorkspaceHandle}. Default `"local-docker"`; the `remote-docker` arm
|
|
30
|
+
* passes `"remote-docker"` so a handle never mislabels which lane it is (the CLASS is shared — `remote-docker`
|
|
31
|
+
* IS this adapter aimed at a remote daemon). Cosmetic/observability only: it selects no behavior.
|
|
32
|
+
*/
|
|
33
|
+
providerLabel?: string;
|
|
22
34
|
/** Container memory limit (docker `--memory`, e.g. "2g"). Absent = no limit. */
|
|
23
35
|
memory?: string;
|
|
24
36
|
/** Container CPU limit (docker `--cpus`, e.g. 1.5). Absent = no limit. */
|
|
@@ -65,6 +77,8 @@ export declare class RemoteLocalDockerExecutionEnv implements RemoteExecutionEnv
|
|
|
65
77
|
private readonly spawnFn;
|
|
66
78
|
/** Stable container NAME (also our handle id). Created on connect; targeted by every docker exec/cp/rm. */
|
|
67
79
|
private readonly containerName;
|
|
80
|
+
/** Provider name put on the WorkspaceHandle ("local-docker" | "remote-docker"). Observability only. */
|
|
81
|
+
private readonly providerName;
|
|
68
82
|
/** Set once the container is up + the workspace dir created. */
|
|
69
83
|
private containerId?;
|
|
70
84
|
private handle?;
|
|
@@ -66,6 +66,8 @@ export class RemoteLocalDockerExecutionEnv {
|
|
|
66
66
|
spawnFn;
|
|
67
67
|
/** Stable container NAME (also our handle id). Created on connect; targeted by every docker exec/cp/rm. */
|
|
68
68
|
containerName;
|
|
69
|
+
/** Provider name put on the WorkspaceHandle ("local-docker" | "remote-docker"). Observability only. */
|
|
70
|
+
providerName;
|
|
69
71
|
/** Set once the container is up + the workspace dir created. */
|
|
70
72
|
containerId;
|
|
71
73
|
handle;
|
|
@@ -92,6 +94,7 @@ export class RemoteLocalDockerExecutionEnv {
|
|
|
92
94
|
const id = config.id ?? randomBytes(6).toString("hex");
|
|
93
95
|
const suffix = randomBytes(4).toString("hex"); // always-unique tail so a reused id never collides
|
|
94
96
|
this.containerName = `sema-docker-${sanitizeId(id)}-${suffix}`;
|
|
97
|
+
this.providerName = config.providerLabel?.trim() || PROVIDER;
|
|
95
98
|
}
|
|
96
99
|
resolve(p) {
|
|
97
100
|
return path.posix.isAbsolute(p) ? path.posix.normalize(p) : path.posix.normalize(path.posix.join(this.cwd, p));
|
|
@@ -165,7 +168,7 @@ export class RemoteLocalDockerExecutionEnv {
|
|
|
165
168
|
return { ok: false, error: new RemoteExecutionError("connect_failed", "execution env destroyed during connect") };
|
|
166
169
|
}
|
|
167
170
|
this.containerId = run.value.stdout.trim() || this.containerName;
|
|
168
|
-
this.handle = { sandboxId: this.containerName, provider:
|
|
171
|
+
this.handle = { sandboxId: this.containerName, provider: this.providerName, mountPath: this.cfg.mountPath, sessionToken: this.containerName };
|
|
169
172
|
if (config?.secrets?.length) {
|
|
170
173
|
// Secrets are injected per-command via cfg.env (resolved by the control plane), not at connect — record only.
|
|
171
174
|
}
|
|
@@ -197,7 +200,7 @@ export class RemoteLocalDockerExecutionEnv {
|
|
|
197
200
|
// re-run `docker run --name <same>` → name conflict → the failure path rm -f's the very container we just
|
|
198
201
|
// re-attached to (destroying the running workspace). The name IS our exec/handle target.
|
|
199
202
|
this.containerId = this.containerName;
|
|
200
|
-
this.handle ??= { sandboxId: this.containerName, provider:
|
|
203
|
+
this.handle ??= { sandboxId: this.containerName, provider: this.providerName, mountPath: this.cfg.mountPath, sessionToken: this.containerName };
|
|
201
204
|
return ok(this.handle);
|
|
202
205
|
}
|
|
203
206
|
return { ok: false, error: new RemoteExecutionError("connect_failed", `container '${this.containerName}' is not running (gone or stopped) — no managed snapshot to resume`) };
|
|
@@ -609,7 +612,11 @@ export class RemoteLocalDockerExecutionEnv {
|
|
|
609
612
|
return;
|
|
610
613
|
this.containerShellProbed = true;
|
|
611
614
|
try {
|
|
612
|
-
|
|
615
|
+
// 🔴 NO withHostFlag here — `docker()` already prepends it. A second `-H` is not a harmless repeat: the
|
|
616
|
+
// docker CLI rejects the whole command (`invalid argument … specify only one -H`), so on every
|
|
617
|
+
// REMOTE-daemon container (`dockerHost` set — always, on the `remote-docker` arm) the probe used to fail
|
|
618
|
+
// and silently degrade the user shell to /bin/sh for a reason unrelated to the image.
|
|
619
|
+
const r = await this.docker(["exec", this.containerName, "/bin/sh", "-c", "command -v bash"], { timeoutMs: this.cfg.controlTimeoutMs });
|
|
613
620
|
if (r.ok && r.value.exitCode === 0 && r.value.stdout.trim().length > 0)
|
|
614
621
|
this.containerShell = "bash";
|
|
615
622
|
}
|
|
@@ -126,9 +126,14 @@ export declare class SqlRunStore {
|
|
|
126
126
|
* suspend, an INTENTIONAL durable pause" (design/76 §2.5) — the service MUST park it the same way, else the run is
|
|
127
127
|
* terminalized + its lock released and the plan_review/dry_run_review resume can never run (it gets
|
|
128
128
|
* taskId=undefined and drives the model unprotected on an unlocked session). CAS on `running`/`needs_review`
|
|
129
|
-
* (reaper-revert guard, mirrors setSuspended).
|
|
130
|
-
*
|
|
131
|
-
*
|
|
129
|
+
* (reaper-revert guard, mirrors setSuspended). The park→resume→claim invariant is pinned on BOTH REAL engines by
|
|
130
|
+
* `test/run-store-reaper-integration.test.ts` (`inv#1` row ownership across park→resume→handover, `inv#2` two
|
|
131
|
+
* replicas racing one session ⇒ exactly one claim wins, `inv#3`/`inv#3'` stale-claim takeover vs fresh/parked
|
|
132
|
+
* claims), closing the former required-before-GA gap. Keep it that way: the P2 lease-SQL bug and this
|
|
133
|
+
* needs_review-park bug were both born from store SQL that mocked unit tests pass — measured, not asserted, when
|
|
134
|
+
* the invariant set was built: an upserting (non-exclusive) claim reddened all four real-DB cases while all 111
|
|
135
|
+
* mocked run-store unit tests stayed green. A mock cannot observe a unique-key claim race, a rolled-back loser,
|
|
136
|
+
* or a multi-table reaper DELETE.
|
|
132
137
|
*/
|
|
133
138
|
setNeedsReview(taskId: string): Promise<void>;
|
|
134
139
|
/**
|
|
@@ -206,9 +206,14 @@ export class SqlRunStore {
|
|
|
206
206
|
* suspend, an INTENTIONAL durable pause" (design/76 §2.5) — the service MUST park it the same way, else the run is
|
|
207
207
|
* terminalized + its lock released and the plan_review/dry_run_review resume can never run (it gets
|
|
208
208
|
* taskId=undefined and drives the model unprotected on an unlocked session). CAS on `running`/`needs_review`
|
|
209
|
-
* (reaper-revert guard, mirrors setSuspended).
|
|
210
|
-
*
|
|
211
|
-
*
|
|
209
|
+
* (reaper-revert guard, mirrors setSuspended). The park→resume→claim invariant is pinned on BOTH REAL engines by
|
|
210
|
+
* `test/run-store-reaper-integration.test.ts` (`inv#1` row ownership across park→resume→handover, `inv#2` two
|
|
211
|
+
* replicas racing one session ⇒ exactly one claim wins, `inv#3`/`inv#3'` stale-claim takeover vs fresh/parked
|
|
212
|
+
* claims), closing the former required-before-GA gap. Keep it that way: the P2 lease-SQL bug and this
|
|
213
|
+
* needs_review-park bug were both born from store SQL that mocked unit tests pass — measured, not asserted, when
|
|
214
|
+
* the invariant set was built: an upserting (non-exclusive) claim reddened all four real-DB cases while all 111
|
|
215
|
+
* mocked run-store unit tests stayed green. A mock cannot observe a unique-key claim race, a rolled-back loser,
|
|
216
|
+
* or a multi-table reaper DELETE.
|
|
212
217
|
*/
|
|
213
218
|
async setNeedsReview(taskId) {
|
|
214
219
|
await this.db.query(this.q("UPDATE task_run SET status = 'needs_review', updated_at = ? WHERE task_id = ? AND status IN ('running','needs_review')", "UPDATE task_run SET status = 'needs_review', updated_at = $1 WHERE task_id = $2 AND status IN ('running','needs_review')"), [new Date(), taskId]);
|
package/dist/sema-registry.d.ts
CHANGED
|
@@ -10,9 +10,23 @@
|
|
|
10
10
|
* (core 1.45), so each model/cascade-rung authenticates with its own upstream key.
|
|
11
11
|
*
|
|
12
12
|
* Per-model `baseUrl` IS transported (a catalog model may live on a different gateway;
|
|
13
|
-
* core brain honors model.baseUrl, absent = "" = boot-env endpoint).
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* core brain honors model.baseUrl, absent = "" = boot-env endpoint).
|
|
14
|
+
*
|
|
15
|
+
* Hot-reload status (复审 2026-07-29 P1-11 — 亲读判定,取代此处旧的 "models/roles are restart-to-apply"
|
|
16
|
+
* TODO, which went stale when `mutateInPlace` landed):
|
|
17
|
+
* - models/roles/roster/teams/projects/autonomy — **HOT**. `applyEffective` mutates `config.models`/
|
|
18
|
+
* `config.roles` IN PLACE, and core's Runner reads `this.deps.models/roles` per task off that very
|
|
19
|
+
* reference. Both brains re-resolve `model.baseUrl || config.baseUrl` inside `buildRequest()` on every
|
|
20
|
+
* call (core 2.1.0 `brain/openai.js` + `brain/anthropic.js`), so a moved gateway takes effect on the
|
|
21
|
+
* next task with no restart.
|
|
22
|
+
* - the same plane under a **tier-frozen** Runner — deferred, not hot: core expands a PRIVATE catalog copy
|
|
23
|
+
* at construction, so `main` defers the whole plane and the `models-tiers` restart slice signals.
|
|
24
|
+
* - **`degrade-route`** — the one genuinely boot-frozen catalog consumer left: reactive degrade
|
|
25
|
+
* (`MODEL_DEGRADE_REACTIVE`) bakes the target Model + its gateway + its key into the brain composition
|
|
26
|
+
* at boot. It cannot be hot-applied without rebuilding the brain, so it is registered on the existing
|
|
27
|
+
* restartRequired /health contract instead (see `restart-signal.ts`). Fail-loud beats serving a stale
|
|
28
|
+
* gateway silently on the rate-limit path.
|
|
29
|
+
* - skills/mcp/scenarios/runtime-gates — restart-to-apply by construction (baked into the boot wiring).
|
|
16
30
|
*
|
|
17
31
|
* (design/158 A13, internal-lossless) This module is now a FACADE: the implementation lives in
|
|
18
32
|
* `src/config-center/` split by responsibility group (HTTP client / EffectiveConfig application /
|
|
@@ -21,7 +35,7 @@
|
|
|
21
35
|
*/
|
|
22
36
|
export { fetchEffective, fetchPrincipalCaps, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./config-center/http-client.js";
|
|
23
37
|
export { mutateInPlace, applyEffective, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, runtimeHasActiveGate, } from "./config-center/apply-effective.js";
|
|
24
|
-
export { restartReasons, planeHasActiveTiers, modelPlaneChanged, type RestartSlice, type RestartSignal, } from "./config-center/restart-signal.js";
|
|
38
|
+
export { restartReasons, planeHasActiveTiers, modelPlaneChanged, type RestartSlice, type RestartSliceCtx, type RestartSignal, } from "./config-center/restart-signal.js";
|
|
25
39
|
export { applyCenterSkills, resolveMcpServers, mcpForScenario } from "./config-center/skills-mcp.js";
|
|
26
40
|
export type { CenterSkillManifest, CenterMcpServer, EffectiveConfig, ExecutionRuling, SessionMirrorRuling, } from "./config-center/types.js";
|
|
27
41
|
//# sourceMappingURL=sema-registry.d.ts.map
|
package/dist/sema-registry.js
CHANGED
|
@@ -10,9 +10,23 @@
|
|
|
10
10
|
* (core 1.45), so each model/cascade-rung authenticates with its own upstream key.
|
|
11
11
|
*
|
|
12
12
|
* Per-model `baseUrl` IS transported (a catalog model may live on a different gateway;
|
|
13
|
-
* core brain honors model.baseUrl, absent = "" = boot-env endpoint).
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* core brain honors model.baseUrl, absent = "" = boot-env endpoint).
|
|
14
|
+
*
|
|
15
|
+
* Hot-reload status (复审 2026-07-29 P1-11 — 亲读判定,取代此处旧的 "models/roles are restart-to-apply"
|
|
16
|
+
* TODO, which went stale when `mutateInPlace` landed):
|
|
17
|
+
* - models/roles/roster/teams/projects/autonomy — **HOT**. `applyEffective` mutates `config.models`/
|
|
18
|
+
* `config.roles` IN PLACE, and core's Runner reads `this.deps.models/roles` per task off that very
|
|
19
|
+
* reference. Both brains re-resolve `model.baseUrl || config.baseUrl` inside `buildRequest()` on every
|
|
20
|
+
* call (core 2.1.0 `brain/openai.js` + `brain/anthropic.js`), so a moved gateway takes effect on the
|
|
21
|
+
* next task with no restart.
|
|
22
|
+
* - the same plane under a **tier-frozen** Runner — deferred, not hot: core expands a PRIVATE catalog copy
|
|
23
|
+
* at construction, so `main` defers the whole plane and the `models-tiers` restart slice signals.
|
|
24
|
+
* - **`degrade-route`** — the one genuinely boot-frozen catalog consumer left: reactive degrade
|
|
25
|
+
* (`MODEL_DEGRADE_REACTIVE`) bakes the target Model + its gateway + its key into the brain composition
|
|
26
|
+
* at boot. It cannot be hot-applied without rebuilding the brain, so it is registered on the existing
|
|
27
|
+
* restartRequired /health contract instead (see `restart-signal.ts`). Fail-loud beats serving a stale
|
|
28
|
+
* gateway silently on the rate-limit path.
|
|
29
|
+
* - skills/mcp/scenarios/runtime-gates — restart-to-apply by construction (baked into the boot wiring).
|
|
16
30
|
*
|
|
17
31
|
* (design/158 A13, internal-lossless) This module is now a FACADE: the implementation lives in
|
|
18
32
|
* `src/config-center/` split by responsibility group (HTTP client / EffectiveConfig application /
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.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",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"sharp": "^0.35.3"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
|
-
"@sema-agent/sdk": "^0.0
|
|
70
|
+
"@sema-agent/sdk": "^1.0.0",
|
|
71
71
|
"@types/libsodium-wrappers": "^0.7.14",
|
|
72
72
|
"@types/node": "22.10.2",
|
|
73
73
|
"@types/pg": "^8.20.0",
|