@sema-agent/server 7.49.0 → 7.51.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 +39 -0
- package/dist/boot/device-lane.d.ts +79 -0
- package/dist/boot/device-lane.js +63 -0
- package/dist/boot/engine-lease.d.ts +95 -0
- package/dist/boot/engine-lease.js +56 -0
- package/dist/boot/execution-env.d.ts +48 -2
- package/dist/boot/execution-env.js +57 -5
- package/dist/boot/resolve-spec.d.ts +3 -0
- package/dist/boot/resolve-spec.js +12 -9
- package/dist/boot/session-faces.d.ts +5 -0
- package/dist/boot/session-faces.js +4 -1
- package/dist/boot/shutdown.d.ts +10 -0
- package/dist/boot/shutdown.js +19 -1
- package/dist/boot/stores.js +9 -0
- package/dist/config-center/types.d.ts +10 -3
- package/dist/config-invariants.d.ts +2 -2
- package/dist/config-invariants.js +18 -0
- package/dist/config-types.d.ts +32 -1
- package/dist/config.js +69 -10
- package/dist/device-enrollment.d.ts +125 -0
- package/dist/device-enrollment.js +156 -0
- package/dist/device-store.d.ts +385 -0
- package/dist/device-store.js +407 -0
- package/dist/device-ws-hub.d.ts +182 -0
- package/dist/device-ws-hub.js +1012 -0
- package/dist/device-ws-protocol.d.ts +429 -0
- package/dist/device-ws-protocol.js +464 -0
- package/dist/env-facts.d.ts +4 -1
- package/dist/env-facts.js +1 -0
- package/dist/execution-lane-caps.d.ts +152 -0
- package/dist/execution-lane-caps.js +166 -0
- package/dist/fleet/subagent-tail-bus.d.ts +1 -1
- package/dist/fleet/subagent-tail-bus.js +10 -0
- package/dist/http/routes/approvals-assistant.js +1 -1
- package/dist/http/routes/capabilities.js +7 -2
- package/dist/http/routes/sessions.js +1 -1
- package/dist/http/routes/tasks.js +12 -6
- package/dist/http/send.d.ts +14 -0
- package/dist/http/send.js +5 -0
- package/dist/http/server.d.ts +19 -0
- package/dist/http/server.js +21 -2
- package/dist/http/wire-types.d.ts +1 -1
- package/dist/leader/wire.js +4 -2
- package/dist/main.js +10 -3
- package/dist/orchestration/hardened-vm-runner.d.ts +7 -0
- package/dist/orchestration/hardened-vm-runner.js +11 -1
- package/dist/orchestration/hardened-vm-worker-runner.js +2 -2
- package/dist/plugins/device-store-sql.d.ts +130 -0
- package/dist/plugins/device-store-sql.js +574 -0
- package/dist/plugins/remote-env-device.d.ts +271 -0
- package/dist/plugins/remote-env-device.js +727 -0
- package/dist/plugins/remote-scratchpad.js +1 -1
- package/dist/plugins/store-backend.d.ts +9 -0
- package/dist/plugins/store-backend.js +3 -0
- package/dist/plugins/usage-window-store-sql.d.ts +2 -2
- package/dist/plugins/usage-window-store-sql.js +17 -6
- package/dist/task-cwd.d.ts +25 -0
- package/dist/task-cwd.js +3 -0
- package/dist/task-settings.js +3 -1
- package/dist/trace/core-keyset-guard.d.ts +3 -3
- package/dist/trace/engine-notice-wire.d.ts +1 -1
- package/dist/trace/engine-notice-wire.js +1 -0
- package/package.json +3 -3
package/USAGE.md
CHANGED
|
@@ -554,6 +554,45 @@ SEMA_PARENT_PID=$$ # 壳把自己的 pid 传给它 spawn 出来的引擎
|
|
|
554
554
|
PID 1 是典型;终端 / launchd / systemd 不在此列),pid 表项仍在,探活同样判"活着"。两条同源:
|
|
555
555
|
`kill(pid, 0)` 是**弱身份**判据。这条腿是**尽力自愈**,不是强一致的父子生命周期绑定。
|
|
556
556
|
|
|
557
|
+
**可选 — 附着租约自退(`ENGINE_AUTO_EXIT`,默认关)**
|
|
558
|
+
|
|
559
|
+
上一节那条腿盯的是「**生我的那个 pid** 还在吗」。多个壳会话共享**同一只**引擎时它问错了问题:首壳退了、
|
|
560
|
+
peer 还在用。这条腿问的是那个真正的问题 —— **还有没有人附着**。
|
|
561
|
+
|
|
562
|
+
```bash
|
|
563
|
+
ENGINE_AUTO_EXIT=true # 壳 spawn 引擎时注入;服务器部署**不要**设
|
|
564
|
+
ENGINE_LINGER_MS=60000 # 可选,默认 60s,下界 1000
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
- **默认 `false` = 整件不装配**,存量部署逐字零变化。一台 k8s 上的 replica 半夜没人用是**正常低谷**,
|
|
568
|
+
不是「该退了」;自退只对**壳自 spawn 的本机引擎**有意义,所以由壳在 spawn 时显式打开。
|
|
569
|
+
- **自退判据 = 三合取,且必须连续满 `ENGINE_LINGER_MS`**:① 没有附着的 SSE 流;② 没有在飞的 run
|
|
570
|
+
(与 `/health` 的 `inflight` **同一个读数**);③ 没有非探针 HTTP 触点(`/health`、`/metrics*` 不算 ——
|
|
571
|
+
探针不是「有人在用」),**且**最后一条附着流断开也已满窗。任何一条不满足即把计时**清零**。
|
|
572
|
+
(最后那半句不是修辞:一条活得比 15s 拍频还短的流整个生命周期都落在两拍之间,自查腿从没看见过它 ——
|
|
573
|
+
只有在**关闭点**记一笔离场时刻,「已经没人附着满 N 秒」才是精确的。)
|
|
574
|
+
- **「run 在跑就不退」是结构性保护,没有旋钮可以关掉它** —— 最后一个壳关掉、后台 run 还在烧的场景里,
|
|
575
|
+
引擎必须活到那条 run 落地。
|
|
576
|
+
- **`ENGINE_LINGER_MS` 防的是壳重启窗**:壳崩了/重启的 1-3s 里附着数确实是 0,窗太短就在用户眼皮底下
|
|
577
|
+
把引擎杀了。窗内任何一次重连即清零重新起算。
|
|
578
|
+
- **实际等待 = `[ENGINE_LINGER_MS, ENGINE_LINGER_MS + 15s)`**:自查拍频固定 15s(不开旋钮),最后一条
|
|
579
|
+
租约灭在两拍之间时要到下一拍才被看见。方向是**保守**的(只会等更久)。
|
|
580
|
+
- 判死后走的是**和 `SIGTERM` 逐字同一条**优雅排空腿:拒新提交(503 + `Retry-After`)→ 候在途腿跑完 →
|
|
581
|
+
停机。in-flight 的 run 照常结算 / park,**不是**裸退出;而且因为是受控退,`crash-last.json` 会被清掉
|
|
582
|
+
⇒ 下次启动**不会**把这次自退误读成崩溃。
|
|
583
|
+
- 日志:装配时一行 `engine_lease_armed`(带 `lingerMs`),自退时一行 `engine_lease_auto_exit`
|
|
584
|
+
(带 `attached` / `inflight` / `idleForMs` 三个读数),随后就是那条 `draining_started`。
|
|
585
|
+
- **坏值拒启**:`ENGINE_LINGER_MS` 必须是落在 **`[1000, 86400000]`(1s…24h)** 的整数毫秒,**两端都承重**:
|
|
586
|
+
下界防 `ENGINE_LINGER_MS=60`(以为单位是秒)—— 60ms 的窗等于把整条防误杀腿静默摘掉;上界防 `1e100`
|
|
587
|
+
这类值 —— 它能过朴素的「是正整数吗」检查,而计时**永远追不上它**,于是配置看着好好的、自退其实整件
|
|
588
|
+
失效。两端一律**拒启并指路**而不是夹取。本键**没有**「关掉」的取值 —— 关自退的唯一开关是
|
|
589
|
+
`ENGINE_AUTO_EXIT=false`。校验**不看** `ENGINE_AUTO_EXIT` 的表态:否则手滑值可以在一台今天关着自退的
|
|
590
|
+
机器上一直躺着,等哪天有人打开时当场生效。
|
|
591
|
+
- 🔴 **已知收窄(成文,现版不收)**:**parked-待赎回** 的 run 不在判据里,只看 running。数 parked 要发
|
|
592
|
+
SQL,而这是一只每 15s 无限期跑下去的定时器 —— 不值得为它引入周期性 SQL 轮询。parked 行是 **durable**
|
|
593
|
+
的,引擎退掉不丢:下次起来(壳的下一次 spawn)仍可赎回,伤害面是**赎回被推迟**而不是丢失。
|
|
594
|
+
- 与 `SEMA_PARENT_PID` 是**两件**,可以同时设,各走各的判据。
|
|
595
|
+
|
|
557
596
|
**布尔旋钮的取值与极性(运维必读)**
|
|
558
597
|
|
|
559
598
|
布尔 env **只认 `true` / `false` 两个字面量**。写成 `1` / `yes` / `TRUE` ⇒ 该旋钮退回自己的缺省值,并在启动时
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/158 A10 分段 —— **device lane 的装配件**(device-executor-lane-v2 §4.6/§4.7/§5,车A-4)。
|
|
3
|
+
*
|
|
4
|
+
* 一处建齐三件活对象:**店**(四表双库的 `DeviceStore`)→ **准入链**(`DeviceEnrollment`,含跨副本
|
|
5
|
+
* 限速座)→ **WS 汇聚端**(`DeviceWsHub`)。三件的依赖是单向的(hub 吃 store+enrollment),所以它们
|
|
6
|
+
* 必须在同一处按序建 —— 拆到三处装配点会让「hub 拿到的是不是同一只店」变成一个要读三个文件才答得出
|
|
7
|
+
* 的问题(而答错的后果是 presence 与租约各写各的)。
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ **位置即契约**:必须在 `openStores`(拿 backend)之后、`createExecutionEnv`(第七臂要这三件)之前
|
|
10
|
+
* 调用;`attach(server)` 则要等 `createHttpServer` 造出裸 `http.Server` 之后(main.ts 的顺序)。
|
|
11
|
+
*
|
|
12
|
+
* 🔴 本模块**不**做车道选择:`REMOTE_EXEC=device` 与否由调用方判(main.ts)。它只回答一件事 ——
|
|
13
|
+
* 「这台机器上,device lane 的三件装配得起来吗」。装不起来就返回 `undefined`,由
|
|
14
|
+
* {@link assertDeviceLaneWired} 响亮拒启(#157:执行车道上没有静默降级臂)。
|
|
15
|
+
*/
|
|
16
|
+
import { type DeviceEnrollRateLimiter, type DeviceEnrollment } from "../device-enrollment.js";
|
|
17
|
+
import type { DeviceStore } from "../device-store.js";
|
|
18
|
+
import { type DeviceWsHub } from "../device-ws-hub.js";
|
|
19
|
+
import type { ServiceConfig } from "../config.js";
|
|
20
|
+
import type { Logger } from "../observability/logger.js";
|
|
21
|
+
import type { Metrics } from "../observability/metrics.js";
|
|
22
|
+
import type { RateGate } from "../observability/rate-limit.js";
|
|
23
|
+
import type { StoreBackend } from "../plugins/store-backend.js";
|
|
24
|
+
export interface DeviceLaneCtx {
|
|
25
|
+
config: ServiceConfig;
|
|
26
|
+
logger: Logger;
|
|
27
|
+
metrics: Metrics;
|
|
28
|
+
backend: StoreBackend | undefined;
|
|
29
|
+
/** 本副本 id(写进连接租约行 —— §4.6 的跨副本排障面)。 */
|
|
30
|
+
replicaId: string;
|
|
31
|
+
/**
|
|
32
|
+
* 既有的**跨副本** RateGate(SQL 后端形 = `TiDBRateLimiter`/`PgRateLimiter`;单副本形 = 进程内
|
|
33
|
+
* `RateLimiter`)。A-2 的移交义务③:enrollment 的限速不自建第二套计数面,适配这一只。
|
|
34
|
+
*
|
|
35
|
+
* 🔴 传的是**取值器**不是值:那一只建在 main.ts 的下游(它要 `counterDegradeHook`),而 device lane
|
|
36
|
+
* 必须建在 `createExecutionEnv` 之前 —— 顺序对不上。取值器让「谁先建」不再是正确性问题:boot 期
|
|
37
|
+
* 不可能有 enroll 请求,取不到 ⇒ 座**抛**(fail-closed,enrollment 翻成 429),不是放行。
|
|
38
|
+
*/
|
|
39
|
+
rateGate?: (() => RateGate | undefined) | undefined;
|
|
40
|
+
}
|
|
41
|
+
export interface DeviceLane {
|
|
42
|
+
store: DeviceStore;
|
|
43
|
+
enrollment: DeviceEnrollment;
|
|
44
|
+
hub: DeviceWsHub;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 把既有的**同步** {@link RateGate} 适配成 enrollment 要的异步座(A-2 移交义务③)。
|
|
48
|
+
*
|
|
49
|
+
* 🔴 为什么是适配而不是新建一只:限速面在本仓已有属主(`observability/rate-limit.ts` + 两支 SQL 孪生),
|
|
50
|
+
* 而 SQL 孪生是**跨副本**的 —— enrollment 自带的 `createFixedWindowRateLimiter` 诚实地只是每副本窗
|
|
51
|
+
* (它自己的头注就这么写),对 token 撞库来说「N 个副本 = N 倍配额」。适配既有的那一只,撞库预算才是
|
|
52
|
+
* 全局的一份。
|
|
53
|
+
*
|
|
54
|
+
* 键前缀 `device-enroll:` 是必须的:RateGate 的键空间与 HTTP 面的 principal 键**共用一张表**,不加前缀
|
|
55
|
+
* 会让一次 enrollment 尝试吃掉调用方的普通请求配额(反过来也一样)。
|
|
56
|
+
*/
|
|
57
|
+
export declare function createDeviceEnrollRateLimiter(gate: () => RateGate | undefined): DeviceEnrollRateLimiter;
|
|
58
|
+
/**
|
|
59
|
+
* 建齐 device lane 的三件。返回 `undefined` = **建不起来**(没有 SQL 后端 ⇒ 没有四表店)。
|
|
60
|
+
*
|
|
61
|
+
* 只在调用方判定本部署是 device 车道时调用;非 device 车道调用它是纯浪费(会白建一只 WS server)。
|
|
62
|
+
*/
|
|
63
|
+
export declare function createDeviceLane(ctx: DeviceLaneCtx): DeviceLane | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* **装配后**的拒启门(car A-1 的 `device-executor-unwired` 换来的真门;先例逐字 =
|
|
66
|
+
* `boot/leader.ts` 的 `assertLeaderDurableStore`)。
|
|
67
|
+
*
|
|
68
|
+
* config 层的门①判的是「运维配没配 SQL 坐标」——那句话回答的是**意图**。它回答不了「这次启动**真的
|
|
69
|
+
* 拿到**了店吗」:`openStoreBackendWithFallback` 在 `SESSION_BACKEND=auto`(默认)下,SQL 连不上 /
|
|
70
|
+
* 建表失败 ⇒ `backend=undefined` + 一条 warn 就继续启动。那次启动里 device lane 的四表整个不在,
|
|
71
|
+
* 而 `boot/execution-env.ts` 的第七臂拿不到三件 ⇒ `executionEnvFactory` 落 `undefined` ⇒ core 拿
|
|
72
|
+
* `StubExecutionEnv`:**一台自称在员工设备上执行的 worker 实际在自己进程里跑,而且回执同形**。
|
|
73
|
+
* 那正是 #157「执行车道禁静默降级」点名的样板病,也正是 A-1 那条临时门当初要拦的东西 —— 换执法点,
|
|
74
|
+
* 不是取消执法。
|
|
75
|
+
*
|
|
76
|
+
* 抛而不是 warn:降级的代价由用户承担(他以为命令跑在自己机器上、看到的却是云上一个空目录的结果)。
|
|
77
|
+
*/
|
|
78
|
+
export declare function assertDeviceLaneWired(isDeviceLane: boolean, lane: DeviceLane | undefined, hasFactory: boolean): void;
|
|
79
|
+
//# sourceMappingURL=device-lane.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createDeviceEnrollment, createFixedWindowRateLimiter } from "../device-enrollment.js";
|
|
2
|
+
import { createDeviceWsHub } from "../device-ws-hub.js";
|
|
3
|
+
export function createDeviceEnrollRateLimiter(gate) {
|
|
4
|
+
return {
|
|
5
|
+
check: async (key) => {
|
|
6
|
+
const g = gate();
|
|
7
|
+
if (!g)
|
|
8
|
+
throw new Error("device enrollment rate gate is not wired yet (boot window) — refusing rather than admitting unmetered enrollment attempts");
|
|
9
|
+
const verdict = g.check(`device-enroll:${key}`);
|
|
10
|
+
return { allowed: verdict.allowed, retryAfterSec: verdict.retryAfterSec };
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function createDeviceLane(ctx) {
|
|
15
|
+
const { config, logger, metrics, backend, replicaId } = ctx;
|
|
16
|
+
const store = backend?.device?.();
|
|
17
|
+
if (!store)
|
|
18
|
+
return undefined;
|
|
19
|
+
const knobs = config.remoteExec?.provider === "device" ? config.remoteExec : undefined;
|
|
20
|
+
const enrollment = createDeviceEnrollment({
|
|
21
|
+
store,
|
|
22
|
+
...(ctx.rateGate ? { rateLimiter: createDeviceEnrollRateLimiter(ctx.rateGate) } : { rateLimiter: createFixedWindowRateLimiter({ limit: 10, windowMs: 60_000 }) }),
|
|
23
|
+
});
|
|
24
|
+
const hub = createDeviceWsHub({
|
|
25
|
+
store,
|
|
26
|
+
enrollment,
|
|
27
|
+
replicaId,
|
|
28
|
+
logger: {
|
|
29
|
+
warn: (msg, fields) => logger.warn(msg, fields),
|
|
30
|
+
error: (msg, fields) => logger.error(msg, fields),
|
|
31
|
+
info: (msg, fields) => logger.info(msg, fields),
|
|
32
|
+
},
|
|
33
|
+
metrics: { inc: (name, labels) => metrics.inc(name, labels) },
|
|
34
|
+
...(knobs?.deliveryTimeoutMs != null ? { dispatchTimeoutMs: knobs.deliveryTimeoutMs } : {}),
|
|
35
|
+
...(knobs?.reconnectGraceMs != null ? { reconnectGraceMs: knobs.reconnectGraceMs } : {}),
|
|
36
|
+
...(knobs?.maxInflightPerDevice != null ? { maxInflightPerDevice: knobs.maxInflightPerDevice } : {}),
|
|
37
|
+
...(knobs?.supersede ? { supersede: knobs.supersede } : {}),
|
|
38
|
+
});
|
|
39
|
+
logger.info("device_lane_enabled", {
|
|
40
|
+
replicaId,
|
|
41
|
+
maxInflightPerDevice: knobs?.maxInflightPerDevice ?? 4,
|
|
42
|
+
crossReplicaEnrollRateLimit: ctx.rateGate !== undefined,
|
|
43
|
+
});
|
|
44
|
+
return { store, enrollment, hub };
|
|
45
|
+
}
|
|
46
|
+
export function assertDeviceLaneWired(isDeviceLane, lane, hasFactory) {
|
|
47
|
+
if (!isDeviceLane)
|
|
48
|
+
return;
|
|
49
|
+
if (!lane) {
|
|
50
|
+
throw new Error("REMOTE_EXEC=device but the device lane could not be wired at boot [device-lane-unwired]: the SQL backend is " +
|
|
51
|
+
"configured yet this replica did not get one (an unreachable DB / failed DDL degrades to the in-memory fallback " +
|
|
52
|
+
"when SESSION_BACKEND=auto), so the device registry / session↔device binding / audit tables are absent. " +
|
|
53
|
+
"Refusing to serve a lane that would silently fall back to the in-process stub env — a worker claiming to " +
|
|
54
|
+
"execute on the employee's device while actually running inside its own process (#157). Fix the database " +
|
|
55
|
+
"(or unset REMOTE_EXEC) and restart (device-executor-lane-v2 §4.7)");
|
|
56
|
+
}
|
|
57
|
+
if (!hasFactory) {
|
|
58
|
+
throw new Error("REMOTE_EXEC=device but no DeviceExecutionEnv factory was built [device-lane-unwired]: the lane's stores are up " +
|
|
59
|
+
"yet `boot/execution-env.ts` produced no factory, so core would fall back to the in-process stub env. This is " +
|
|
60
|
+
"an assembly bug, not a configuration one — refusing to start rather than silently execute in-process (#157)");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=device-lane.js.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #118(黑板 [5371]②,cli [5394] 认领消费)—— 引擎**附着租约自退**。
|
|
3
|
+
*
|
|
4
|
+
* ## 防的是什么
|
|
5
|
+
*
|
|
6
|
+
* 壳自 spawn 引擎的本机形里,壳正常退出之后引擎**继续活着**:占端口、占库连接池、占模型配额,而且
|
|
7
|
+
* 用户已经离开了、再没有人会关心它。#219 的 parent 监视只覆盖「生我的那个 pid 没了」这一形,而
|
|
8
|
+
* **多壳共享一只引擎**时它结构性地问错了问题 —— 首壳退了 peer 还在用,监视父 pid 要么误杀要么永不触发。
|
|
9
|
+
* 本件问的是那个真正的问题:**还有没有人附着**。
|
|
10
|
+
*
|
|
11
|
+
* ## 机制(逐条都是契约)
|
|
12
|
+
*
|
|
13
|
+
* 1. **opt-in**:`ENGINE_AUTO_EXIT` 默认 **false** ⇒ 整件不装配(`boot/shutdown.ts` 连本函数都不调)。
|
|
14
|
+
* 服务器部署形逐字零变化 —— 一台 k8s 上的 replica 半夜没人用不是「该退了」,是**正常的低谷**。
|
|
15
|
+
* 壳 spawn 形由壳在 spawn 时显式注入 `true`。**部署级旋钮无条件生效**:它不派生自任何客户端表态
|
|
16
|
+
* (本仓 operator-knob 铁律,#153 同病两犯史),`ENGINE_LINGER_MS` 的坏值校验也不挂在本键的表态上。
|
|
17
|
+
* 2. **自退判据 = 三合取,且必须持续满 `lingerMs`**:
|
|
18
|
+
* · 附着租约集空(`attachedStreams() === 0`);
|
|
19
|
+
* · 无在飞 run(`inflight() === 0` —— **与 /health 的 `inflight` 同一只 getter**,不另立第二判据);
|
|
20
|
+
* · HTTP 触点静默(`now() - lastActivityAt() >= lingerMs`),**且**最后一条附着流的**离场**
|
|
21
|
+
* 也已满窗(`now() - lastAttachEndedAt() >= lingerMs` —— codex R1-F1,见该 dep 的注)。
|
|
22
|
+
* 任何一条不满足即把计时清零(连续性判据,不是累计)。
|
|
23
|
+
* 3. **为什么 HTTP 触点是独立的第三条**(不是冗余):纯轮询壳(零 SSE,每 2s 一次 `GET /v1/sessions`)
|
|
24
|
+
* 的请求是毫秒级的,而本腿 15s 采样一拍 —— 采样点几乎必然落在两次轮询之间,`attached` 与
|
|
25
|
+
* `inflight` **同时读到 0**。只看那两条的实现会在一只**正被使用**的引擎底下把它关掉。租约的
|
|
26
|
+
* 建立面按设计就是「首个 HTTP/SSE 接触」,不只是 SSE。
|
|
27
|
+
* 4. **退出 = 走 `drain`**,即 `boot/shutdown.ts` 里 SIGTERM 用的那一条 `drainThenShutdown`:拒新提交
|
|
28
|
+
* (503+Retry-After)→ 候在途腿跑完 → `hardShutdown`。in-flight run 的结算 / park 语义与人工停机
|
|
29
|
+
* **逐字一致**。本模块自己**从不** `process.exit`。
|
|
30
|
+
* 5. **crash-last 判别位**([5371] ④「flush 判别位标 auto-exit 非崩溃」):走上面那条受控退,
|
|
31
|
+
* `hardShutdown` 一进来就 `clearCrashLast` ⇒ 遗言文件被删 ⇒「文件在场 ⇔ 上次退出=崩溃」这条契约
|
|
32
|
+
* 对自退**自动成立**。刻意**不**另写一条 `reason:"auto-exit"` 的遗言行:那会把该文件的语义从
|
|
33
|
+
* 「崩溃判别位」稀释成「上次退出记录」,而运维读它就是为了判前者(见 `crash-last.ts` 顶注)。
|
|
34
|
+
* 自退的可观察面是 `engine_lease_auto_exit` + 随后的 `draining_started` 两行日志。
|
|
35
|
+
* 6. **interval 必须 unref**:自退检查腿不得成为「进程本该退出却退不掉」的理由(与 drain tick 刻意
|
|
36
|
+
* 不 unref 的取向相反 —— 那一只 tick 就是停机驱动,这一只只是观察者)。
|
|
37
|
+
* 7. **装配即起算**:armed 那一刻若已空闲,计时**当场**开始。覆盖的是「壳 spawn 了引擎,自己却在
|
|
38
|
+
* 第一次接触之前就崩了」这一形 —— 那只引擎一辈子没有过租约,正是本件要收的孤儿。
|
|
39
|
+
* 8. **实际窗 = `[lingerMs, lingerMs + tickMs)`**(采样腿的固有滞后,成文而非隐藏):最后一条租约是在
|
|
40
|
+
* **两拍之间**灭的,本腿要到下一拍才看见,空闲计时从**那一拍**起表。⇒ 真实等待比 `lingerMs` 多出
|
|
41
|
+
* 0~1 拍。方向是**保守**的(等得更久,不会更早),所以不修 —— 要修就得让 SSE 关闭点反向通知本腿,
|
|
42
|
+
* 那是把一只自洽的观察者改成有状态的被通知者,不值当。运维读数以 `engine_lease_auto_exit.idleForMs`
|
|
43
|
+
* 为准(它报的是真实空闲时长,不是 `lingerMs`)。
|
|
44
|
+
*
|
|
45
|
+
* ## 已知收窄(成文,现版不收)
|
|
46
|
+
*
|
|
47
|
+
* · **parked-待赎回 run 不入判据**:设计稿 ② 原文含「无 running/parked-待赎回 run」,本版**只看
|
|
48
|
+
* running**。理由 = 廉价读口不存在:parked 是 store 里的**durable 行**,数它必须发 SQL,而本腿是一只
|
|
49
|
+
* 每 15s 无限期跑下去的定时器 —— 为它引入周期性 SQL 轮询,代价压过收益。**bg run 在跑不退**那一臂
|
|
50
|
+
* 已由 `inflight()` 结构性守住;parked 行是 durable 的,引擎退掉不丢:下次起来仍可赎回(spawn 形里
|
|
51
|
+
* 「下次」就是壳的下一次 spawn)。伤害面 = 一条 parked run 的赎回被推迟到下次启动,不是丢失。
|
|
52
|
+
* 这是**允许的收窄,不是静默**:有了廉价 parked 计数口(或本腿改成事件驱动)之后应当补上。
|
|
53
|
+
* · **`/health` 探针不算触点**:`lastActivityAt` 的属主(`http/server.ts` 的 `noteActivity`)按既有契约
|
|
54
|
+
* 把 `/health` 与 `/metrics*` 排除在活性时钟之外(常态采样噪音不得把空闲窗永远撑开)。⇒ 一只**只**
|
|
55
|
+
* 轮询 `/health` 的壳不构成租约。这与 SIGHUP 空闲窗的判据同源,刻意保持一致而非在本腿另开一份。
|
|
56
|
+
*/
|
|
57
|
+
/** 自退检查拍频。**固定值,不开 env 旋钮**(与 `PARENT_WATCH_INTERVAL_MS` 同纪律:有真需求再议,防旋钮增殖)。 */
|
|
58
|
+
export declare const ENGINE_LEASE_TICK_MS = 15000;
|
|
59
|
+
export interface EngineLeaseDeps {
|
|
60
|
+
/** 活附着数 = 本副本当前**打开着的 SSE 流**条数(`drainState.attachedStreams`,由 `createHttpServer` 自赋)。 */
|
|
61
|
+
attachedStreams: () => number;
|
|
62
|
+
/** 在飞腿数 —— **`/health` 的 `inflight` 同一只 getter**(`drainState.inflight`)。不自建第二套计数:
|
|
63
|
+
* 两套计数迟早分叉,而分叉的那一侧恰好是「以为没 run 了」就把引擎关掉。 */
|
|
64
|
+
inflight: () => number;
|
|
65
|
+
/** 最后一次**非探针** HTTP 触点的单调时刻(`drainState.lastActivityAt`)。
|
|
66
|
+
* ⚠️ 时基契约:必须与本模块的 `now` 同为 `performance.now()` 单调域(`server.ts` 的打点即该域)——
|
|
67
|
+
* 混入 `Date.now()` 会让 `now() - lastActivityAt` 差出 epoch 量级,静默判据恒真。 */
|
|
68
|
+
lastActivityAt: () => number;
|
|
69
|
+
/** 最后一条**附着流断开**的单调时刻(`drainState.lastAttachEndedAt`)。
|
|
70
|
+
*
|
|
71
|
+
* 🔴 codex 对抗复审 R1-F1(验真后修):`attachedStreams` 是**采样**面,`lastActivityAt` 记的是
|
|
72
|
+
* **入场**(admission)时刻 —— 两者合起来仍漏一个形:一条**整个生命周期都落在两拍之间**的流
|
|
73
|
+
* (活得比拍频短)从没被任何一拍看见,于是 `idleSince` 保持着这条流出现**之前**的旧值,而
|
|
74
|
+
* 「已经没人附着满 lingerMs」这句承诺可以提前至多一整拍兑现。补这条**离场**触点之后,判据变成
|
|
75
|
+
* 精确的「距最后一次附着结束已满 lingerMs」。缺席(0)= 本进程还没有过附着流结束。 */
|
|
76
|
+
lastAttachEndedAt: () => number;
|
|
77
|
+
/** 已有停机属主(`closing || draining`)时本腿站下:一次正在进行的优雅 drain 不该被「也没人附着了」
|
|
78
|
+
* 升级成第二信号硬停(那会斩掉 in-flight leg)。 */
|
|
79
|
+
isStopped: () => boolean;
|
|
80
|
+
/** 自退出口 = SIGTERM 那条 graceful drain(见顶注 4/5)。 */
|
|
81
|
+
drain: () => void;
|
|
82
|
+
/** 三合取**持续**满这么久才触发(`config.engineLingerMs`,默认 60s)。 */
|
|
83
|
+
lingerMs: number;
|
|
84
|
+
log: (event: string, fields: Record<string, unknown>) => void;
|
|
85
|
+
/** 检查拍频 seam(测试)。缺省 {@link ENGINE_LEASE_TICK_MS}。 */
|
|
86
|
+
tickMs?: number;
|
|
87
|
+
/** 时钟 seam,缺省 `() => performance.now()`(**单调**域)。用 `Date.now()` 会让 NTP/手动跳变吞掉或
|
|
88
|
+
* 拉长 linger 窗 —— 前跳把窗直接跳过去 = 在一只还有人用的引擎底下提前关停。 */
|
|
89
|
+
now?: () => number;
|
|
90
|
+
}
|
|
91
|
+
/** 装配并**立刻**起自退检查(装配即 `engine_lease_armed`)。返回停机链用的 `stop()`。 */
|
|
92
|
+
export declare function createEngineLeaseTracker(deps: EngineLeaseDeps): {
|
|
93
|
+
stop(): void;
|
|
94
|
+
};
|
|
95
|
+
//# sourceMappingURL=engine-lease.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const ENGINE_LEASE_TICK_MS = 15_000;
|
|
2
|
+
export function createEngineLeaseTracker(deps) {
|
|
3
|
+
const tickMs = deps.tickMs ?? ENGINE_LEASE_TICK_MS;
|
|
4
|
+
const now = deps.now ?? (() => performance.now());
|
|
5
|
+
let stopped = false;
|
|
6
|
+
let idleSince = now();
|
|
7
|
+
deps.log("engine_lease_armed", {
|
|
8
|
+
lingerMs: deps.lingerMs,
|
|
9
|
+
tickMs,
|
|
10
|
+
note: "engine self-exits once NO shell session is attached AND no run is in flight AND no HTTP touch, " +
|
|
11
|
+
"continuously for lingerMs — via the same graceful drain as SIGTERM. Set ENGINE_AUTO_EXIT=false to disable.",
|
|
12
|
+
});
|
|
13
|
+
const timer = setInterval(() => {
|
|
14
|
+
if (stopped)
|
|
15
|
+
return;
|
|
16
|
+
if (deps.isStopped()) {
|
|
17
|
+
stop();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const attached = deps.attachedStreams();
|
|
21
|
+
const inflight = deps.inflight();
|
|
22
|
+
if (attached > 0 || inflight > 0) {
|
|
23
|
+
idleSince = null;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const t = now();
|
|
27
|
+
if (idleSince === null) {
|
|
28
|
+
idleSince = t;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (t - deps.lastActivityAt() < deps.lingerMs)
|
|
32
|
+
return;
|
|
33
|
+
if (t - deps.lastAttachEndedAt() < deps.lingerMs)
|
|
34
|
+
return;
|
|
35
|
+
if (t - idleSince < deps.lingerMs)
|
|
36
|
+
return;
|
|
37
|
+
stop();
|
|
38
|
+
deps.log("engine_lease_auto_exit", {
|
|
39
|
+
attached,
|
|
40
|
+
inflight,
|
|
41
|
+
lingerMs: deps.lingerMs,
|
|
42
|
+
idleForMs: Math.round(t - Math.max(idleSince, deps.lastActivityAt(), deps.lastAttachEndedAt())),
|
|
43
|
+
note: "no attached shell session, no in-flight run and no HTTP touch for the full linger window — entering the SIGTERM graceful drain path",
|
|
44
|
+
});
|
|
45
|
+
deps.drain();
|
|
46
|
+
}, tickMs);
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
function stop() {
|
|
49
|
+
if (stopped)
|
|
50
|
+
return;
|
|
51
|
+
stopped = true;
|
|
52
|
+
clearInterval(timer);
|
|
53
|
+
}
|
|
54
|
+
return { stop };
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=engine-lease.js.map
|
|
@@ -5,19 +5,65 @@ import type { ServiceConfig } from "../config.js";
|
|
|
5
5
|
import type { Logger } from "../observability/logger.js";
|
|
6
6
|
import type { Metrics } from "../observability/metrics.js";
|
|
7
7
|
import { PerTaskImageRegistry } from "../per-task-image.js";
|
|
8
|
+
import type { DeviceEnrollment } from "../device-enrollment.js";
|
|
9
|
+
import type { DeviceStore } from "../device-store.js";
|
|
10
|
+
import type { DeviceWsHub } from "../device-ws-hub.js";
|
|
8
11
|
import { type TaskAttachmentStore } from "../plugins/task-attachment-store.js";
|
|
12
|
+
/**
|
|
13
|
+
* per-session 的 **cwd / shellEnv 登记簿**(TOC cwd seam + R-survey shellEnv seam)+ 它们的**租户围栏**。
|
|
14
|
+
*
|
|
15
|
+
* ── 为什么围栏不是可选的(codex R1-F1,判真)────────────────────────────────────────────────────
|
|
16
|
+
* 这两张表只按 `sessionId` 键控。在 host lane 上那是安全的:那条车道的闸带 `requirePrincipal !== true`,
|
|
17
|
+
* 整台机器就一个用户。**device lane 把这个前提撤掉了** —— 它的闸(`deviceCwdHonored`)刻意不要求单用户
|
|
18
|
+
* (员工各连各的设备)。于是一条真实的越权路径出现了:
|
|
19
|
+
* ① sessionId 由**调用方自选**,而且 `DELETE /v1/sessions/:id` 之后可以被**别人** `register` 重新登记
|
|
20
|
+
* (`boot/session-faces.ts` 的化身围栏注逐字写着这件事);
|
|
21
|
+
* ② 会话删除的级联删的是库里的行,**够不着这两张进程内的表**;
|
|
22
|
+
* ③ 新主如果没有重发 `settings.env`,工厂读到的就是**上一位主人的** shell env —— 那是秘密面(NPM_TOKEN
|
|
23
|
+
* 一类),而且会被注进新主自己设备上的指令;cwd 也会跨化身残留。
|
|
24
|
+
*
|
|
25
|
+
* 所以围栏做在**身份已知**的那一处:每次提交经 resolve-spec 调 {@link SessionScopedRegistries.fenceSessionOwner}
|
|
26
|
+
* 对拍,换主即清。删除侧再加一条 {@link SessionScopedRegistries.dropSession}(E21 级联)——那条不是为了
|
|
27
|
+
* 关这个洞(读面必经提交、提交必过围栏),而是**右侧删除权**:一次删除之后,秘密不该还留在进程内存里。
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ 诚实残余(如实登记,不假装关掉):两张表是**每副本进程内**的。围栏与清除都只在处理该请求/该删除的
|
|
30
|
+
* 那一个副本上发生。跨副本的陈值靠「读面必经本副本的一次提交 ⇒ 必过本副本的围栏」兜住 —— 这条推理成立
|
|
31
|
+
* 是因为 run 在受理它的那个副本上执行(ARCHITECTURE 副本模型)。device lane 的部署形本身也是单副本(§4.6)。
|
|
32
|
+
*/
|
|
33
|
+
export interface SessionScopedRegistries {
|
|
34
|
+
readonly perSessionCwd: Map<string, string>;
|
|
35
|
+
readonly perSessionShellEnv: Map<string, Record<string, string>>;
|
|
36
|
+
setSessionCwd(sid: string, cwd: string): void;
|
|
37
|
+
setSessionShellEnv(sid: string, env: Record<string, string>): void;
|
|
38
|
+
/** 记录/对拍本会话的主人。与上次不同 ⇒ **先清两张表**再记新主(换主即清)。`null` = 匿名会话,
|
|
39
|
+
* 它同样是一个**稳定**的身份值(不是「未知」),所以匿名重入不会被判成换主。 */
|
|
40
|
+
fenceSessionOwner(sid: string, owner: string | null): void;
|
|
41
|
+
/** 会话删除:两张表 + 身份记录一起清(留着身份记录会让同 id 的下一位与一个幽灵身份比对成「同主」)。 */
|
|
42
|
+
dropSession(sid: string): void;
|
|
43
|
+
}
|
|
44
|
+
export declare function createSessionScopedRegistries(maxEntries?: number): SessionScopedRegistries;
|
|
45
|
+
/** device lane 的三件活对象(店 / 汇聚端 / 准入链)。缺席 = 本部署没有 device 车道。 */
|
|
46
|
+
export interface DeviceLaneWiring {
|
|
47
|
+
store: DeviceStore;
|
|
48
|
+
hub: DeviceWsHub;
|
|
49
|
+
enrollment: DeviceEnrollment;
|
|
50
|
+
}
|
|
9
51
|
export interface ExecutionEnvCtx {
|
|
10
52
|
config: ServiceConfig;
|
|
11
53
|
logger: Logger;
|
|
12
54
|
metrics: Metrics;
|
|
13
55
|
taskAttachmentStore: TaskAttachmentStore | undefined;
|
|
56
|
+
/** 车A-4:device lane 的装配件(`REMOTE_EXEC=device` 且 SQL 店在场时由 main.ts 建好传进来)。 */
|
|
57
|
+
deviceLane?: DeviceLaneWiring;
|
|
14
58
|
}
|
|
15
59
|
export declare function createExecutionEnv(ctx: ExecutionEnvCtx): {
|
|
16
60
|
perTaskImage: PerTaskImageRegistry;
|
|
17
61
|
sessionEnvSelection: SessionEnvironmentSelection;
|
|
18
62
|
perSessionCwd: Map<string, string>;
|
|
19
|
-
setSessionCwd: (sid: string,
|
|
20
|
-
setSessionShellEnv: (sid: string,
|
|
63
|
+
setSessionCwd: (sid: string, cwd: string) => void;
|
|
64
|
+
setSessionShellEnv: (sid: string, env: Record<string, string>) => void;
|
|
65
|
+
fenceSessionOwner: (sid: string, owner: string | null) => void;
|
|
66
|
+
dropSessionScoped: (sid: string) => void;
|
|
21
67
|
executionEnvFactory: import("@sema-agent/core").ExecutionEnvFactory | undefined;
|
|
22
68
|
worktreeReap: (() => Promise<void>) | undefined;
|
|
23
69
|
sendUserFileTaskEnvs: TaskEnvRegistry | undefined;
|
|
@@ -7,6 +7,7 @@ import { createE2bLspManager } from "../lsp/e2b-manager.js";
|
|
|
7
7
|
import { evictLspOnDestroy } from "../lsp-evict.js";
|
|
8
8
|
import { PerTaskImageRegistry } from "../per-task-image.js";
|
|
9
9
|
import { adbExecutionEnvFactory } from "../plugins/remote-env-adb.js";
|
|
10
|
+
import { deviceExecutionEnvFactory } from "../plugins/remote-env-device.js";
|
|
10
11
|
import { e2bExecutionEnvFactory } from "../plugins/remote-env-e2b.js";
|
|
11
12
|
import { hostExecutionEnvFactory, RemoteHostExecutionEnv } from "../plugins/remote-env-host.js";
|
|
12
13
|
import { k8sExecutionEnvFactory } from "../plugins/remote-env-k8s.js";
|
|
@@ -27,15 +28,51 @@ function boundedSessionSetter(map, maxEntries) {
|
|
|
27
28
|
map.delete(map.keys().next().value);
|
|
28
29
|
};
|
|
29
30
|
}
|
|
31
|
+
const MAX_CWD_SESSIONS = 4096;
|
|
32
|
+
export function createSessionScopedRegistries(maxEntries = MAX_CWD_SESSIONS) {
|
|
33
|
+
const perSessionCwd = new Map();
|
|
34
|
+
const perSessionShellEnv = new Map();
|
|
35
|
+
const owners = new Map();
|
|
36
|
+
const dropSession = (sid) => {
|
|
37
|
+
perSessionCwd.delete(sid);
|
|
38
|
+
perSessionShellEnv.delete(sid);
|
|
39
|
+
owners.delete(sid);
|
|
40
|
+
};
|
|
41
|
+
const touch = (sid, owner) => {
|
|
42
|
+
const next = owner === undefined ? (owners.has(sid) ? owners.get(sid) : null) : owner;
|
|
43
|
+
owners.delete(sid);
|
|
44
|
+
owners.set(sid, next);
|
|
45
|
+
while (owners.size > maxEntries)
|
|
46
|
+
dropSession(owners.keys().next().value);
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
perSessionCwd,
|
|
50
|
+
perSessionShellEnv,
|
|
51
|
+
setSessionCwd: (sid, cwd) => {
|
|
52
|
+
perSessionCwd.set(sid, cwd);
|
|
53
|
+
touch(sid, undefined);
|
|
54
|
+
},
|
|
55
|
+
setSessionShellEnv: (sid, env) => {
|
|
56
|
+
perSessionShellEnv.set(sid, env);
|
|
57
|
+
touch(sid, undefined);
|
|
58
|
+
},
|
|
59
|
+
fenceSessionOwner: (sid, owner) => {
|
|
60
|
+
if (owners.has(sid) && owners.get(sid) !== owner) {
|
|
61
|
+
perSessionCwd.delete(sid);
|
|
62
|
+
perSessionShellEnv.delete(sid);
|
|
63
|
+
}
|
|
64
|
+
touch(sid, owner);
|
|
65
|
+
},
|
|
66
|
+
dropSession,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
30
69
|
export function createExecutionEnv(ctx) {
|
|
31
70
|
const { config, logger, metrics, taskAttachmentStore } = ctx;
|
|
32
71
|
const perTaskImage = new PerTaskImageRegistry();
|
|
33
72
|
const sessionEnvSelection = new SessionEnvironmentSelection();
|
|
34
|
-
const
|
|
35
|
-
const perSessionCwd =
|
|
36
|
-
const
|
|
37
|
-
const perSessionShellEnv = new Map();
|
|
38
|
-
const setSessionShellEnv = boundedSessionSetter(perSessionShellEnv, MAX_CWD_SESSIONS);
|
|
73
|
+
const sessionScoped = createSessionScopedRegistries();
|
|
74
|
+
const { perSessionCwd, setSessionCwd } = sessionScoped;
|
|
75
|
+
const { perSessionShellEnv, setSessionShellEnv } = sessionScoped;
|
|
39
76
|
let executionEnvFactory;
|
|
40
77
|
let worktreeReap;
|
|
41
78
|
if (config.remoteExec?.provider === "e2b") {
|
|
@@ -85,6 +122,19 @@ export function createExecutionEnv(ctx) {
|
|
|
85
122
|
...(config.remoteExec.hostKey !== undefined ? { hostKey: config.remoteExec.hostKey } : {}),
|
|
86
123
|
});
|
|
87
124
|
}
|
|
125
|
+
else if (config.remoteExec?.provider === "device") {
|
|
126
|
+
if (ctx.deviceLane) {
|
|
127
|
+
executionEnvFactory = deviceExecutionEnvFactory({
|
|
128
|
+
hub: ctx.deviceLane.hub,
|
|
129
|
+
store: ctx.deviceLane.store,
|
|
130
|
+
enrollment: ctx.deviceLane.enrollment,
|
|
131
|
+
...(config.remoteExec.execTimeoutMs != null ? { execTimeoutMs: config.remoteExec.execTimeoutMs } : {}),
|
|
132
|
+
perSessionCwd,
|
|
133
|
+
perSessionShellEnv,
|
|
134
|
+
logger,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
88
138
|
else if (config.remoteExec?.provider === "adb") {
|
|
89
139
|
executionEnvFactory = adbExecutionEnvFactory({
|
|
90
140
|
serial: config.remoteExec.serial,
|
|
@@ -236,6 +286,8 @@ export function createExecutionEnv(ctx) {
|
|
|
236
286
|
logger.info("lsp_enabled", { provider: lspProvider });
|
|
237
287
|
return {
|
|
238
288
|
perTaskImage, sessionEnvSelection, perSessionCwd, setSessionCwd, setSessionShellEnv,
|
|
289
|
+
fenceSessionOwner: sessionScoped.fenceSessionOwner,
|
|
290
|
+
dropSessionScoped: sessionScoped.dropSession,
|
|
239
291
|
executionEnvFactory, worktreeReap, sendUserFileTaskEnvs, lspManager,
|
|
240
292
|
};
|
|
241
293
|
}
|
|
@@ -40,6 +40,9 @@ export interface ResolveSpecCtx {
|
|
|
40
40
|
perSessionCwd: Map<string, string>;
|
|
41
41
|
setSessionCwd: (sid: string, cwd: string) => void;
|
|
42
42
|
setSessionShellEnv: (sid: string, env: Record<string, string>) => void;
|
|
43
|
+
/** 两张 per-session 登记簿的**租户围栏**(codex R1-F1):每次提交对拍本会话的主人,换主即清。
|
|
44
|
+
* 论证(为什么 device lane 把这条从「不需要」变成「必需」)逐字见 `createSessionScopedRegistries` 头注。 */
|
|
45
|
+
fenceSessionOwner: (sid: string, owner: string | null) => void;
|
|
43
46
|
hookLlm: ReturnType<typeof createHookLlm>["hookLlm"];
|
|
44
47
|
hookAgent: HookLlmCall;
|
|
45
48
|
fleetBus: FleetEventBus;
|
|
@@ -26,7 +26,7 @@ import { HttpError, encodeCheckpointScope } from "../security.js";
|
|
|
26
26
|
import { memorySpecForRequest } from "../memory-scope.js";
|
|
27
27
|
import { a2aForScenario, mcpForScenario } from "../config-center/facade.js";
|
|
28
28
|
import { normalizeAttachments, normalizeResilience, normalizeResumeAtMode, normalizeSuggestNextPrompts, promptProfileFromBody, resolveTaskLimits, retainBackgroundProcessesFromBody, taskAgentsSpecFragment, toolMaterializeStrategyFromBody, toolNameListFromBody } from "../spec-fields.js";
|
|
29
|
-
import { cwdHonored, effectiveHostWorkspace, inProcessSingleUserLane, isValidCwd, parseAdditionalDirectories, satisfiedByProcessCwd, shellEnvMismatchCount } from "../task-cwd.js";
|
|
29
|
+
import { cwdHonored, deviceCwdHonored, effectiveHostWorkspace, inProcessSingleUserLane, isValidCwd, parseAdditionalDirectories, satisfiedByProcessCwd, shellEnvMismatchCount } from "../task-cwd.js";
|
|
30
30
|
import { assertRequestA2aUnlocked, resolveRequestA2a } from "../task-a2a.js";
|
|
31
31
|
import { assertRequestMcpContentOrigin, assertRequestMcpUnlocked, resolveRequestMcp } from "../task-mcp.js";
|
|
32
32
|
import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, acceptAppendSystemPrompt, applyTaskSettings, effectivePermissionMode, effectiveThinking, hasConstitutionAnchors, parseTaskSettings, providerDropsAppend, shellGateForMode, withPermissionMode } from "../task-settings.js";
|
|
@@ -35,7 +35,7 @@ import { redactSecrets } from "../trace/redact.js";
|
|
|
35
35
|
import { DeferredSandboxPathEnv, isSandboxPathAdjudicationLane, sandboxPathEnvSlots } from "./deferred-sandbox-path-env.js";
|
|
36
36
|
import { effectiveMemoryPersistenceCapable } from "./memory-boundary.js";
|
|
37
37
|
export function createResolveSpec(ctx) {
|
|
38
|
-
const { config, logger, metrics, localRoot, scenarios, principalCaps, centerRuntimeCapsResolver, handsLanes, getCenterPrompts, getKeyResolver, taskAttachmentStore, perSessionCwd, setSessionCwd, setSessionShellEnv, hookLlm, hookAgent, fleetBus, hookWakeBus, resumeAnchorStore, ownerAware, taskLimitCaps, taskTimeoutSec, selectEnvTool, sendUserFileToolSpec, memoryEngine, durableEnabled, approvalExemptionStore, singleUserAutoAcceptBaseline, checkpointStore, deploymentHooks, imageIndex, perTaskImage, sessionEnvSelection, liveQuestionFace, lockedKeys, } = ctx;
|
|
38
|
+
const { config, logger, metrics, localRoot, scenarios, principalCaps, centerRuntimeCapsResolver, handsLanes, getCenterPrompts, getKeyResolver, taskAttachmentStore, perSessionCwd, setSessionCwd, setSessionShellEnv, fenceSessionOwner, hookLlm, hookAgent, fleetBus, hookWakeBus, resumeAnchorStore, ownerAware, taskLimitCaps, taskTimeoutSec, selectEnvTool, sendUserFileToolSpec, memoryEngine, durableEnabled, approvalExemptionStore, singleUserAutoAcceptBaseline, checkpointStore, deploymentHooks, imageIndex, perTaskImage, sessionEnvSelection, liveQuestionFace, lockedKeys, } = ctx;
|
|
39
39
|
assertGuardPatternsUsable(config);
|
|
40
40
|
const onlySensitiveBaseline = buildOnlySensitiveBaselineWarning(config, { durableEnabled, singleUserAutoAcceptBaseline });
|
|
41
41
|
if (onlySensitiveBaseline)
|
|
@@ -116,17 +116,20 @@ export function createResolveSpec(ctx) {
|
|
|
116
116
|
};
|
|
117
117
|
const bindSettingsCwdEnvAndModel = (body, auth, gated, effMode, opts) => {
|
|
118
118
|
const { objective, parsedSettings } = gated;
|
|
119
|
+
const cwdWriteGate = cwdHonored(config) || deviceCwdHonored(config);
|
|
119
120
|
const execLane = config.remoteExec?.provider ?? "in-process";
|
|
121
|
+
if (auth?.sessionId)
|
|
122
|
+
fenceSessionOwner(auth.sessionId, auth.principal ?? null);
|
|
120
123
|
if (typeof body.cwd === "string" && body.cwd.length > 0 && auth?.sessionId) {
|
|
121
|
-
if (
|
|
124
|
+
if (cwdWriteGate && isValidCwd(body.cwd))
|
|
122
125
|
setSessionCwd(auth.sessionId, body.cwd);
|
|
123
126
|
else if (inProcessSingleUserLane(config) && isValidCwd(body.cwd) && satisfiedByProcessCwd(body.cwd, realpathSync))
|
|
124
127
|
logger.debug("task_cwd_inherited", { lane: execLane, sessionId: auth.sessionId });
|
|
125
128
|
else
|
|
126
|
-
logger.warn("task_cwd_ignored", { honored:
|
|
129
|
+
logger.warn("task_cwd_ignored", { honored: cwdWriteGate, lane: execLane, sessionId: auth.sessionId });
|
|
127
130
|
}
|
|
128
131
|
if (parsedSettings.settings?.shellEnv && auth?.sessionId) {
|
|
129
|
-
if (
|
|
132
|
+
if (cwdWriteGate)
|
|
130
133
|
setSessionShellEnv(auth.sessionId, parsedSettings.settings.shellEnv);
|
|
131
134
|
else if (inProcessSingleUserLane(config)) {
|
|
132
135
|
const requested = parsedSettings.settings.shellEnv;
|
|
@@ -204,22 +207,22 @@ export function createResolveSpec(ctx) {
|
|
|
204
207
|
let resumeAtEntryId;
|
|
205
208
|
if (typeof body.resumeAt === "string" && body.resumeAt.length > 0) {
|
|
206
209
|
if (!auth?.sessionId)
|
|
207
|
-
throw new HttpError(422, "resumeAt requires a session to branch (resume_at.no_session)");
|
|
210
|
+
throw new HttpError(422, "resumeAt requires a session to branch (resume_at.no_session)", { code: "resume_at.no_session" });
|
|
208
211
|
if (!resumeAnchorStore || !ownerAware.getLeafId)
|
|
209
212
|
throw new HttpError(501, "resume-at is not available on this worker (no session-store backend for the anchor map)");
|
|
210
213
|
resumeAtEntryId = await resumeAnchorStore.resolve(auth.sessionId, body.resumeAt, auth.principal ?? null);
|
|
211
214
|
if (resumeAtEntryId === undefined)
|
|
212
|
-
throw new HttpError(404, "resumeAt: no such message in this session (resume_at.unknown_event)");
|
|
215
|
+
throw new HttpError(404, "resumeAt: no such message in this session (resume_at.unknown_event)", { code: "resume_at.unknown_event" });
|
|
213
216
|
}
|
|
214
217
|
let rewindFilesToEntryId;
|
|
215
218
|
if (resumeAtEntryId === undefined && typeof body.rewindFilesTo === "string" && body.rewindFilesTo.length > 0) {
|
|
216
219
|
if (!auth?.sessionId)
|
|
217
|
-
throw new HttpError(422, "rewindFilesTo requires a session (rewind_files_to.no_session)");
|
|
220
|
+
throw new HttpError(422, "rewindFilesTo requires a session (rewind_files_to.no_session)", { code: "rewind_files_to.no_session" });
|
|
218
221
|
if (!resumeAnchorStore || !ownerAware.getLeafId)
|
|
219
222
|
throw new HttpError(501, "rewind-files-to is not available on this worker (no session-store backend for the anchor map)");
|
|
220
223
|
rewindFilesToEntryId = await resumeAnchorStore.resolve(auth.sessionId, body.rewindFilesTo, auth.principal ?? null);
|
|
221
224
|
if (rewindFilesToEntryId === undefined)
|
|
222
|
-
throw new HttpError(404, "rewindFilesTo: no such message in this session (rewind_files_to.unknown_event)");
|
|
225
|
+
throw new HttpError(404, "rewindFilesTo: no such message in this session (rewind_files_to.unknown_event)", { code: "rewind_files_to.unknown_event" });
|
|
223
226
|
}
|
|
224
227
|
const s4ProjectId = auth?.resolvedProjectId ?? (typeof body.projectId === "string" && body.projectId ? body.projectId.toLowerCase() : undefined);
|
|
225
228
|
const s4DefaultScopes = s4ProjectId ? config.projects[s4ProjectId]?.defaultScopes : undefined;
|
|
@@ -6,6 +6,7 @@ import { PlanCacheProbe } from "../plan-cache-probe.js";
|
|
|
6
6
|
import type { CheckpointStoreFull, StoreBackend, ToolResultStoreFull } from "../plugins/store-backend.js";
|
|
7
7
|
import type { TaskAttachmentStore } from "../plugins/task-attachment-store.js";
|
|
8
8
|
import type { OwnerAwareSessionStore } from "../security.js";
|
|
9
|
+
import type { DeviceStore } from "../device-store.js";
|
|
9
10
|
import { SessionWatchRegistry } from "../session-watch.js";
|
|
10
11
|
import type { TaskListLane } from "./task-list-lane.js";
|
|
11
12
|
export interface SessionFacesCtx {
|
|
@@ -26,6 +27,10 @@ export interface SessionFacesCtx {
|
|
|
26
27
|
workflowCompletionInbox: WorkflowCompletionInbox | undefined;
|
|
27
28
|
/** #318:会话级任务清单车道(E21 级联的一条腿)。缺席 = 装配未接本车道 ⇒ 无清单可删。 */
|
|
28
29
|
taskListLane: TaskListLane | undefined;
|
|
30
|
+
/** device lane 的四表店(E21 级联的一条腿:`device_session` 绑定行随会话删)。缺席 = 非 SQL 后端。 */
|
|
31
|
+
deviceStore: DeviceStore | undefined;
|
|
32
|
+
/** per-session cwd/shellEnv 登记簿的清除口(E21 级联的一条腿,codex R1-F1)。缺席 = 装配未接。 */
|
|
33
|
+
dropSessionScoped: ((sid: string) => void) | undefined;
|
|
29
34
|
}
|
|
30
35
|
export declare function createSessionFaces(ctx: SessionFacesCtx): {
|
|
31
36
|
ownerAware: OwnerAwareSessionStore;
|
|
@@ -6,7 +6,7 @@ import { setLeafAdvanceListener } from "../session-leaf-bus.js";
|
|
|
6
6
|
import { SessionWatchRegistry, posIntEnv } from "../session-watch.js";
|
|
7
7
|
import { defaultTaskRegistry } from "@sema-agent/core";
|
|
8
8
|
export function createSessionFaces(ctx) {
|
|
9
|
-
const { config, logger, metrics, localRoot, backend, sessionStore, runStore, checkpointStore, toolResultStore, resumeAnchorStore, approvalExemptionStore, sessionPolicyStore, taskAttachmentStore, fileSnapshotStore, workflowCompletionInbox, taskListLane, } = ctx;
|
|
9
|
+
const { config, logger, metrics, localRoot, backend, sessionStore, runStore, checkpointStore, toolResultStore, resumeAnchorStore, approvalExemptionStore, sessionPolicyStore, taskAttachmentStore, fileSnapshotStore, workflowCompletionInbox, taskListLane, deviceStore, dropSessionScoped, } = ctx;
|
|
10
10
|
const mysqlPool = backend?.mysqlPool();
|
|
11
11
|
const auditPgPool = backend?.pgPool();
|
|
12
12
|
const auditLocalRoot = backend?.kind === "local" ? (config.localDataRoot ?? localRoot) : undefined;
|
|
@@ -63,6 +63,9 @@ export function createSessionFaces(ctx) {
|
|
|
63
63
|
await taskAttachmentStore.deleteBySession(sessionId);
|
|
64
64
|
if (taskListLane)
|
|
65
65
|
await taskListLane.deleteBySession(sessionId);
|
|
66
|
+
if (deviceStore)
|
|
67
|
+
await deviceStore.deleteByRootSession(sessionId);
|
|
68
|
+
dropSessionScoped?.(sessionId);
|
|
66
69
|
if (fileSnapshotStore?.deleteBySession)
|
|
67
70
|
await fileSnapshotStore.deleteBySession(sessionId);
|
|
68
71
|
if (workflowCompletionInbox)
|