@sema-agent/server 7.76.0 → 7.76.2

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.
Files changed (46) hide show
  1. package/USAGE.md +47 -21
  2. package/dist/boot/engine-lease.d.ts +89 -11
  3. package/dist/boot/engine-lease.js +26 -11
  4. package/dist/boot/exit-last.d.ts +24 -0
  5. package/dist/boot/exit-last.js +46 -0
  6. package/dist/boot/shutdown.js +28 -4
  7. package/dist/config-catalog.js +3 -2
  8. package/dist/config-types.d.ts +23 -11
  9. package/dist/config.js +8 -8
  10. package/dist/http/principal-gate.d.ts +24 -1
  11. package/dist/http/principal-gate.js +16 -1
  12. package/dist/http/routes/a2a-serve.js +2 -2
  13. package/dist/http/routes/admin-config-refresh.js +2 -2
  14. package/dist/http/routes/admin-drain.js +2 -2
  15. package/dist/http/routes/admin-memory-optout.js +2 -2
  16. package/dist/http/routes/adoption.js +2 -2
  17. package/dist/http/routes/approvals-assistant.js +10 -6
  18. package/dist/http/routes/attachments.js +2 -2
  19. package/dist/http/routes/config-catalog.js +2 -2
  20. package/dist/http/routes/devices.js +2 -2
  21. package/dist/http/routes/fleet.js +2 -2
  22. package/dist/http/routes/leader.js +2 -2
  23. package/dist/http/routes/memory-bundle.js +2 -2
  24. package/dist/http/routes/memory-compliance.js +2 -2
  25. package/dist/http/routes/memory-consolidation.js +2 -2
  26. package/dist/http/routes/memory-origin.js +2 -2
  27. package/dist/http/routes/memory-policy.js +2 -2
  28. package/dist/http/routes/notify-wake.js +3 -3
  29. package/dist/http/routes/observability.js +2 -2
  30. package/dist/http/routes/retention-ops.js +2 -2
  31. package/dist/http/routes/run-memory-capture.js +2 -2
  32. package/dist/http/routes/runs.js +13 -13
  33. package/dist/http/routes/sessions.js +7 -7
  34. package/dist/http/routes/side-query.js +2 -2
  35. package/dist/http/routes/workflows.js +3 -3
  36. package/dist/http/server.js +2 -2
  37. package/dist/parent-watch.d.ts +32 -18
  38. package/dist/parent-watch.js +12 -10
  39. package/dist/plugins/tidb-session-storage.d.ts +4 -0
  40. package/dist/plugins/tidb-session-storage.js +7 -2
  41. package/dist/principal-jwt.d.ts +23 -3
  42. package/dist/principal-jwt.js +8 -0
  43. package/dist/tool-approval.js +2 -2
  44. package/dist/trace/redact.d.ts +23 -1
  45. package/dist/trace/redact.js +45 -11
  46. package/package.json +2 -2
package/USAGE.md CHANGED
@@ -752,7 +752,8 @@ A2A_SERVE_BLOCKING_WAIT_MS=45000 # blocking:true 的服务端等待
752
752
  会给它发 `SIGTERM`。设了这个键,引擎就自己盯着那个 pid:
753
753
 
754
754
  ```bash
755
- SEMA_PARENT_PID=$$ # 壳把自己的 pid 传给它 spawn 出来的引擎
755
+ SEMA_PARENT_PID=$$ # 壳把自己的 pid 传给它 spawn 出来的引擎
756
+ ENGINE_ORPHAN_LINGER_MS=2000 # 可选,默认 2s:成孤儿之后还要静多久才自退
756
757
  ```
757
758
 
758
759
  `DB_BACKEND=local` 上还多一层痛:那只孤儿抱着 `<数据根>/LOCK` 不放(判定持锁人是否还在用的就是
@@ -761,20 +762,34 @@ SEMA_PARENT_PID=$$ # 壳把自己的 pid 传给它 spawn 出来的引擎
761
762
 
762
763
  - **缺席 = 整件不装配**(opt-in)。不设 = 和以前**逐字节一致**,存量部署零变化。systemd / launchd /
763
764
  容器托管的引擎本来就 reparent 到 1,**不要**给它们设这个键。
764
- - **判死 = 两支,任一成立即判死**(拍频固定 **2s**):
765
+ - **判据 = 两支,任一成立即把引擎标成「孤儿」**(拍频固定 **2s**):
765
766
  - **被过继走**(强身份):`process.ppid` 不再是引擎启动那一刻的那个值 —— 生我的进程没了,内核就把我
766
767
  过继给 init(或容器里的 subreaper)。判据刻意**不是** `ppid == 1`:收养者未必是 1,而且壳自己就是
767
- pid 1 的容器形里 `== 1` 会在开机第一拍误杀一台健康引擎。
768
+ pid 1 的容器形里 `== 1` 会在开机第一拍误判一台健康引擎。
768
769
  - **pid 不存在**(弱身份):`process.kill(pid, 0)` 抛 `ESRCH`(不投递任何信号,只问"这个 pid 在不在")。
769
770
  **`EPERM` = 父还活着**(进程在,只是本进程无权给它发信号 —— 父跑在另一个 uid 下时很常见)。
770
- - **单拍即判,不去抖**:两支读的都是内核直答,不是会抖的采样值。判死后走的是**和 `SIGTERM` 逐字同一条**
771
+ - 🔴 **成孤儿 ≠ 停机**:这条腿**只探不判**。父没了之后引擎会不会退,由下一节那条**同一条**附着租约律
772
+ 说了算 —— 无附着 ∧ 无在飞 run ∧ 无非探针 HTTP 触点,**持续**满 `ENGINE_ORPHAN_LINGER_MS`(默认 2s)。
773
+ 也就是说:**有人挂着流、或者还有 run 在跑的时候,孤儿引擎继续服务**(`/health` 的 `draining` 仍是
774
+ `false`,新提交照收);没人用了才退。多壳共享一只引擎(A 起、B 按 `engine.port` 复用、A 正常退出)时,
775
+ B 的引擎不会再被推进排空(此前:`draining` 当场翻真 ⇒ B 的下一次提交吃 `503 + Retry-After`)。
776
+ - **单拍即判,不去抖**:两支读的都是内核直答,不是会抖的采样值。自退走的是**和 `SIGTERM` 逐字同一条**
771
777
  优雅排空腿(`DRAIN_GRACE_MS` 那条):in-flight 的 run 照常结算 / park,不是裸退出;收尾链里的
772
- `backend.close()` 才是把 `LOCK` 真正还回去的那一步。**实测**:壳挨 `kill -9` 到引擎退干净(退出码 0)
773
- 且 `LOCK` 消失 ≈ **2s**,部署面按 **≤5s** 用。
778
+ `backend.close()` 才是把 `LOCK` 真正还回去的那一步。**部署面预算**:壳挨 `kill -9` 到引擎退干净
779
+ (退出码 0)且 `LOCK` 消失,按 **≤10s** 用 —— 拆开看是「发现父没了 ≤2s + 孤儿窗 2s + 自查腿一拍 2s +
780
+ 停机收尾」,前提是那一刻没人附着、没有在飞的 run(有的话按设计**继续活着**)。**实测**:壳挨刀到
781
+ 引擎进程消失 2.0s、`LOCK` 归还 1.96s。最后那一段「停机收尾」本身还有自己的闸:`LOCK` 归还在收尾链
782
+ 尾部的 `backend.close()`,正常亚秒级,但卡死的连接池会被 `hardShutdown` 的 **15s 终局闸**兜住 ⇒ 最坏
783
+ 上界是「≤10s + 收尾闸」。自动重起的壳请按「撞锁就退避重试」写,别把 10s 当硬保证。
784
+ - **自退留痕 `<数据根>/exit-last.json`**:自退在 **drain 开始那一刻**就把判据写进数据根(`orphan` /
785
+ `reason` / `idleForMs` / `attached` / `inflight` / `lingerMs` + `version` / `at` / `pid`)。壳的 exit
786
+ watch 往往在流断之后才跑,只看退出码分不清"它自己退的"与"它崩了";读到这个文件就能把这个 pid 归入
787
+ **预期退出**。退出码仍是 **0**,不另立专用码。引擎下次在同一数据根启动时读到它 ⇒ 一行 `exit_last_found`
788
+ 然后**删掉**(在场 ⇔ **上一次**退出是自退;它与 `crash-last.json` 是对偶的两只判别位)。
774
789
  - **拍频不开旋钮**(2s 写死)。有真需求再议 —— 这里刻意不增殖一个只有一个人会调的键。
775
- - 日志:装配时一行 `parent_watch_armed`(`info`,带 `pid` / `ppidAtArm` / `intervalMs`),判死时一行
776
- `parent_watch_exit`(**`warn`** —— 非计划内的停机,带 `reason: "reparented" | "esrch"`),随后就是那条
777
- `draining_started`。
790
+ - 日志:装配时一行 `parent_watch_armed`(`info`,带 `pid` / `ppidAtArm` / `intervalMs`),成孤儿时一行
791
+ `parent_watch_orphaned`(**`warn`** —— 非计划内的生命周期事件,带 `reason: "reparented" | "esrch"`),
792
+ 真退的时候才是 `engine_lease_auto_exit`(带 `orphan: true` + 同一个 `reason`)+ `draining_started`。
778
793
  - 坏值(`0` / 负数 / 小数 / 非数字 / 超过 `2147483647`)**拒启并指路**:`0` 和负数在 `process.kill` 里的
779
794
  语义是**进程组**,超出 int32 的值 `process.kill` 根本收不下(会变成"armed 了但永远探不动")。要关掉就
780
795
  **不设**这个键。探活若给出既不是 `ESRCH` 也不是 `EPERM` 的 errno,会打一条(仅一条)**`warn` 档**
@@ -783,9 +798,9 @@ SEMA_PARENT_PID=$$ # 壳把自己的 pid 传给它 spawn 出来的引擎
783
798
  - 🔴 **已知残余(不收窄,两条)**:① 引擎**还在 boot**(监视腿装在 `listen()` 之后)时父就死了**且**成了
784
799
  **僵尸**(它自己的上级不回收子进程 —— 容器里的朴素 PID 1 是典型;终端 / launchd / systemd 不在此列)
785
800
  —— 强身份支没有"变化"可看,弱身份支被僵尸的 pid 表项骗住,两支都不触发;② 壳经一层 **wrapper** spawn
786
- 引擎却仍传自己的 pid 时,wrapper 先退会让强身份支判死一台壳还活着的引擎(约定是**壳直接 spawn 引擎**,
787
- 别把这个键设成非直接父的 pid)。这条腿是**尽力自愈**,不是强一致的父子生命周期绑定 —— 要强一致,该由
788
- 壳侧开一条持有型管道并在断开时收尸。
801
+ 引擎却仍传自己的 pid 时,wrapper 先退会让强身份支把一台壳还活着的引擎标成孤儿(约定是**壳直接 spawn
802
+ 引擎**,别把这个键设成非直接父的 pid)。这条腿是**尽力自愈**,不是强一致的父子生命周期绑定 —— 要强
803
+ 一致,该由壳侧开一条持有型管道并在断开时收尸。
789
804
 
790
805
  **可选 — 附着租约自退(`ENGINE_AUTO_EXIT`,默认关)**
791
806
 
@@ -793,13 +808,18 @@ SEMA_PARENT_PID=$$ # 壳把自己的 pid 传给它 spawn 出来的引擎
793
808
  peer 还在用。这条腿问的是那个真正的问题 —— **还有没有人附着**。
794
809
 
795
810
  ```bash
796
- ENGINE_AUTO_EXIT=true # 壳 spawn 引擎时注入;服务器部署**不要**设
797
- ENGINE_LINGER_MS=60000 # 可选,默认 60s,下界 1000
811
+ ENGINE_AUTO_EXIT=true # 壳 spawn 引擎时注入;服务器部署**不要**设
812
+ ENGINE_LINGER_MS=60000 # 可选,默认 60s,下界 1000(父还活着时的窗)
813
+ ENGINE_ORPHAN_LINGER_MS=2000 # 可选,默认 2s(父没了之后的窗;见上一节)
798
814
  ```
799
815
 
800
- - **默认 `false` = 整件不装配**,存量部署逐字零变化。一台 k8s 上的 replica 半夜没人用是**正常低谷**,
816
+ - **默认 `false` = 不自退**,存量部署逐字零变化。一台 k8s 上的 replica 半夜没人用是**正常低谷**,
801
817
  不是「该退了」;自退只对**壳自 spawn 的本机引擎**有意义,所以由壳在 spawn 时显式打开。
802
- - **自退判据 = 三合取,且必须连续满 `ENGINE_LINGER_MS`**:① 没有附着的 SSE 流;② 没有在飞的 run
818
+ 🔴 自退腿在「本键为真 **或** `SEMA_PARENT_PID` 在场」时装配(S-306:两件并成一件)。本键关着而
819
+ `SEMA_PARENT_PID` 设了 ⇒ 父还活着期间**永不自退**(窗 = ∞,与以前逐字同),父没了才用孤儿窗。
820
+ 两键都缺席 ⇒ 整件不装配,零定时器。
821
+ - **自退判据 = 三合取,且必须连续满**「当前窗」(父还活着 = `ENGINE_LINGER_MS`;父没了 =
822
+ `ENGINE_ORPHAN_LINGER_MS`。判据一条,只有窗长两态):① 没有附着的 SSE 流;② 没有在飞的 run
803
823
  (与 `/health` 的 `inflight` **同一个读数**);③ 没有非探针 HTTP 触点(`/health`、`/metrics*` 不算 ——
804
824
  探针不是「有人在用」),**且**最后一条附着流断开也已满窗。任何一条不满足即把计时**清零**。
805
825
  (最后那半句不是修辞:一条活得比 15s 拍频还短的流整个生命周期都落在两拍之间,自查腿从没看见过它 ——
@@ -808,13 +828,18 @@ ENGINE_LINGER_MS=60000 # 可选,默认 60s,下界 1000
808
828
  引擎必须活到那条 run 落地。
809
829
  - **`ENGINE_LINGER_MS` 防的是壳重启窗**:壳崩了/重启的 1-3s 里附着数确实是 0,窗太短就在用户眼皮底下
810
830
  把引擎杀了。窗内任何一次重连即清零重新起算。
811
- - **实际等待 = `[ENGINE_LINGER_MS, ENGINE_LINGER_MS + 15s)`**:自查拍频固定 15s(不开旋钮),最后一条
812
- 租约灭在两拍之间时要到下一拍才被看见。方向是**保守**的(只会等更久)。
831
+ - **实际等待 = `[窗, 窗 + 一拍)`**:最后一条租约灭在两拍之间时要到下一拍才被看见。方向是**保守**的
832
+ (只会等更久)。**拍频由窗推导、不是第二个旋钮**:`min(15s, 生效得到的每一个窗)` —— 没装父监视的
833
+ 服务器形仍是 15s 一拍(一拍都没多跳),装了父监视的壳形按 2s 采样(否则「孤儿 2s 内退」这句承诺会被
834
+ 15s 的采样滞后压掉)。
813
835
  - 判死后走的是**和 `SIGTERM` 逐字同一条**优雅排空腿:拒新提交(503 + `Retry-After`)→ 候在途腿跑完 →
814
836
  停机。in-flight 的 run 照常结算 / park,**不是**裸退出;而且因为是受控退,`crash-last.json` 会被清掉
815
837
  ⇒ 下次启动**不会**把这次自退误读成崩溃。
816
- - 日志:装配时一行 `engine_lease_armed`(带 `lingerMs`),自退时一行 `engine_lease_auto_exit`
817
- (带 `attached` / `inflight` / `idleForMs` 三个读数),随后就是那条 `draining_started`。
838
+ - 日志:装配时一行 `engine_lease_armed`(带 `lingerMs` / `orphanLingerMs` / `tickMs`;`lingerMs: null`
839
+ = 这一档窗永不到期),自退时一行 `engine_lease_auto_exit`(带 `orphan` / `reason` / `attached` /
840
+ `inflight` / `idleForMs` / `lingerMs`,孤儿还带 `orphanedForMs`),随后就是那条 `draining_started`。
841
+ `reason` 是闭集:`"idle"`(没人用了)/ `"reparented"` / `"esrch"`(后两者 = 父没了那一路,`orphan:true`)。
842
+ 同一份读数会写进 `<数据根>/exit-last.json`(见上一节)。
818
843
  - **坏值拒启**:`ENGINE_LINGER_MS` 必须是落在 **`[1000, 86400000]`(1s…24h)** 的整数毫秒,**两端都承重**:
819
844
  下界防 `ENGINE_LINGER_MS=60`(以为单位是秒)—— 60ms 的窗等于把整条防误杀腿静默摘掉;上界防 `1e100`
820
845
  这类值 —— 它能过朴素的「是正整数吗」检查,而计时**永远追不上它**,于是配置看着好好的、自退其实整件
@@ -824,7 +849,8 @@ ENGINE_LINGER_MS=60000 # 可选,默认 60s,下界 1000
824
849
  - 🔴 **已知收窄(成文,现版不收)**:**parked-待赎回** 的 run 不在判据里,只看 running。数 parked 要发
825
850
  SQL,而这是一只每 15s 无限期跑下去的定时器 —— 不值得为它引入周期性 SQL 轮询。parked 行是 **durable**
826
851
  的,引擎退掉不丢:下次起来(壳的下一次 spawn)仍可赎回,伤害面是**赎回被推迟**而不是丢失。
827
- - 与 `SEMA_PARENT_PID` 是**两件**,可以同时设,各走各的判据。
852
+ - 与 `SEMA_PARENT_PID` 的关系(S-306 起):**不是两件**,是**一件 + 一个输入** —— 那个键决定「父没了
853
+ 算不算数」以及此后用哪个窗,自退判据只有本节这一条。
828
854
 
829
855
  **布尔旋钮的取值与极性(运维必读)**
830
856
 
@@ -8,13 +8,25 @@
8
8
  * **多壳共享一只引擎**时它结构性地问错了问题 —— 首壳退了 peer 还在用,监视父 pid 要么误杀要么永不触发。
9
9
  * 本件问的是那个真正的问题:**还有没有人附着**。
10
10
  *
11
+ * ## S-306:自退律只剩这一条(硬 breaking)
12
+ *
13
+ * `SEMA_PARENT_PID` 看门狗的 drain 臂**整条删**了:父死只是把引擎标成**孤儿**(`parent_watch_orphaned`),
14
+ * 孤儿走的是**本腿这一条**判据,只有一处不同 —— 窗长从 {@link EngineLeaseDeps.lingerMs} 换成
15
+ * {@link EngineLeaseOrphanLane.lingerMs}(`ENGINE_ORPHAN_LINGER_MS`,默认 2s)。于是:
16
+ * · 规则集 **−1**(「父死即退」那条判据没了),参数 +1(孤儿窗);
17
+ * · 「有人附着 / 有 run 在飞就不退」这条结构性保护**对孤儿同样成立** —— cli 拒绝 S-292 的那一形
18
+ * (A 起引擎、B 按 engine.port 复用、A 正常退)不会再在 B 的 turn 中间把引擎拆掉;
19
+ * · 装配判据随之变成 `parentPid 在场 ∨ engineAutoExit`;`ENGINE_AUTO_EXIT` 关着的 owned 引擎窗长
20
+ * 是 **`Infinity`** —— 父还活着就**永不自退**(与 S-306 前逐字同),父一死才由孤儿窗接手。
21
+ *
11
22
  * ## 机制(逐条都是契约)
12
23
  *
13
- * 1. **opt-in**:`ENGINE_AUTO_EXIT` 默认 **false** ⇒ 整件不装配(`boot/shutdown.ts` 连本函数都不调)。
14
- * 服务器部署形逐字零变化 —— 一台 k8s 上的 replica 半夜没人用不是「该退了」,是**正常的低谷**。
15
- * 壳 spawn 形由壳在 spawn 时显式注入 `true`。**部署级旋钮无条件生效**:它不派生自任何客户端表态
16
- * (本仓 operator-knob 铁律,[ref] 同病两犯史),`ENGINE_LINGER_MS` 的坏值校验也不挂在本键的表态上。
17
- * 2. **自退判据 = 三合取,且必须持续满 `lingerMs`**:
24
+ * 1. **opt-in**:`ENGINE_AUTO_EXIT` 与 `SEMA_PARENT_PID` **都**缺席 ⇒ 整件不装配(`boot/shutdown.ts`
25
+ * 连本函数都不调)。服务器部署形逐字零变化 —— 一台 k8s 上的 replica 半夜没人用不是「该退了」,
26
+ * 是**正常的低谷**。壳 spawn 形由壳在 spawn 时显式注入。**部署级旋钮无条件生效**:它不派生自任何
27
+ * 客户端表态(本仓 operator-knob 铁律,[ref] 同病两犯史),两只 linger 键的坏值校验也不挂在
28
+ * `ENGINE_AUTO_EXIT` / `SEMA_PARENT_PID` 的表态上。
29
+ * 2. **自退判据 = 三合取,且必须持续满 `linger`**(`linger` = 孤儿态 `orphan.lingerMs`,否则 `lingerMs`):
18
30
  * · 附着租约集空(`attachedStreams() === 0`);
19
31
  * · 无在飞 run(`inflight() === 0` —— **与 /health 的 `inflight` 同一只 getter**,不另立第二判据);
20
32
  * · HTTP 触点静默(`now() - lastActivityAt() >= lingerMs`),**且**最后一条附着流的**离场**
@@ -33,7 +45,12 @@
33
45
  * 「崩溃判别位」稀释成「上次退出记录」,而运维读它就是为了判前者(见 `crash-last.ts` 顶注)。
34
46
  * 自退的可观察面是 `engine_lease_auto_exit` + 随后的 `draining_started` 两行日志。
35
47
  * 6. **interval 必须 unref**:自退检查腿不得成为「进程本该退出却退不掉」的理由(与 drain tick 刻意
36
- * 不 unref 的取向相反 —— 那一只 tick 就是停机驱动,这一只只是观察者)。
48
+ * 不 unref 的取向相反 —— 那一只 tick 就是停机驱动,这一只只是观察者)。**恒一只 interval**:
49
+ * 孤儿态不另起第二只定时器(两只定时器 = 两条自退律,正是本件要消灭的那件事)。
50
+ * 9. **拍频由窗推导,不是第二个旋钮**(S-306):`tickMs = min(`{@link ENGINE_LEASE_TICK_MS}`, 本腿可能
51
+ * 执行的每一个窗)`。理由是顶注 8 那句话的逆命题 —— 实际窗 `[linger, linger+tickMs)` 里,采样滞后
52
+ * 不得压过窗本身:15s 一拍去执行一个 2s 的孤儿窗,「2s」这句承诺就只是个装饰。所以装了孤儿车道
53
+ * (= 壳 spawn 形)的引擎按 2s 采样,没装的(纯服务器形)仍是 15s —— 存量部署一拍都没多跳。
37
54
  * 7. **装配即起算**:armed 那一刻若已空闲,计时**当场**开始。覆盖的是「壳 spawn 了引擎,自己却在
38
55
  * 第一次接触之前就崩了」这一形 —— 那只引擎一辈子没有过租约,正是本件要收的孤儿。
39
56
  * 8. **实际窗 = `[lingerMs, lingerMs + tickMs)`**(采样腿的固有滞后,成文而非隐藏):最后一条租约是在
@@ -50,12 +67,69 @@
50
67
  * 已由 `inflight()` 结构性守住;parked 行是 durable 的,引擎退掉不丢:下次起来仍可赎回(spawn 形里
51
68
  * 「下次」就是壳的下一次 spawn)。伤害面 = 一条 parked run 的赎回被推迟到下次启动,不是丢失。
52
69
  * 这是**允许的收窄,不是静默**:有了廉价 parked 计数口(或本腿改成事件驱动)之后应当补上。
70
+ * · **活过父 run 的后台子代理不入判据**(codex R1 [high] 验真后**如实登记**,不是本件引入:S-306 之前
71
+ * 父死走的是**当场** drain,而 `drainThenShutdown` 读的是同一只 `inflight()` ⇒ 同一个缺口,只是那时
72
+ * 连这 2s 都没有)。`inflight()` 的口径是**本副本的 HTTP 腿 ∪ leader 腿**(`http/server.ts` 的
73
+ * `inflightRuns`/`steerableRuns`/两只计数器),而 core 侧 `backgroundScope:"session"` 的子代理可以
74
+ * **活过**生它的那条 HTTP 腿 —— 那之后它在本腿眼里是 0。伤害面 = 壳没了且没人附着时,一条还在跑的
75
+ * 后台子代理会随引擎的受控退被中断(它的账本行照常落终态,不是静默丢)。收口需要一只**廉价**的
76
+ * 「本副本活跃子代理数」读口(与 parked 那条同族:要么 core 给计数,要么本腿改事件驱动),归属
77
+ * `inflight()` 的属主而不是本腿 —— 在那之前这条收窄**成文在此**,不许再被读成「bg 全被守住」。
53
78
  * · **`/health` 探针不算触点**:`lastActivityAt` 的属主(`http/server.ts` 的 `noteActivity`)按既有契约
54
79
  * 把 `/health` 与 `/metrics*` 排除在活性时钟之外(常态采样噪音不得把空闲窗永远撑开)。⇒ 一只**只**
55
80
  * 轮询 `/health` 的壳不构成租约。这与 SIGHUP 空闲窗的判据同源,刻意保持一致而非在本腿另开一份。
56
81
  */
57
- /** 自退检查拍频。**固定值,不开 env 旋钮**(与 `PARENT_WATCH_INTERVAL_MS` 同纪律:有真需求再议,防旋钮增殖)。 */
82
+ import type { ParentOrphanReason } from "../parent-watch.js";
83
+ /** 自退检查拍频的**上限**。**不开 env 旋钮**(与 `PARENT_WATCH_INTERVAL_MS` 同纪律:有真需求再议,
84
+ * 防旋钮增殖);实际拍频由顶注 9 的推导取更小者。 */
58
85
  export declare const ENGINE_LEASE_TICK_MS = 15000;
86
+ /**
87
+ * 孤儿态读数(装配层把看门狗的 `onOrphaned` 落成这一笔)。
88
+ *
89
+ * 两个字段必须**同一笔**给出,不拆成两只 getter:拆开之后「已经孤儿了但 reason 还没写」是一个可达的
90
+ * 中间态,而它恰好落在自退这条承重路径上。
91
+ */
92
+ export interface EngineOrphanState {
93
+ /** 成孤儿的时刻(**单调**钟,与本模块的 `now` 同域)。 */
94
+ since: number;
95
+ /** 哪一支判的(看门狗的闭集词表)。 */
96
+ reason: ParentOrphanReason;
97
+ }
98
+ /**
99
+ * 孤儿车道 —— `SEMA_PARENT_PID` 在场才装(缺席 ⇒ 本字段整条缺席,孤儿态**结构上**不可达)。
100
+ *
101
+ * 为什么窗长与读口**捆在一起**而不是两只平行 dep:平行时「孤儿窗配着、却永远不会成孤儿」是一个
102
+ * 可表达的状态,拍频推导(顶注 9)会据此把一台纯服务器形的引擎无故提速到 2s 一拍。捆在一起之后,
103
+ * 「本腿会不会执行孤儿窗」就是一句**类型**能回答的话。
104
+ */
105
+ export interface EngineLeaseOrphanLane {
106
+ /** 孤儿态窗长(`config.engineOrphanLingerMs`,默认 2s)。 */
107
+ lingerMs: number;
108
+ /** 活读(与 `attachedStreams`/`inflight` 同姿势 —— 本腿的每一只输入都是 pull getter,没有被通知者)。
109
+ * `null` = 父还活着。 */
110
+ read: () => EngineOrphanState | null;
111
+ }
112
+ /** 自退缘由闭集:`"idle"` = 没人用了(owned 态);另两个词 = 看门狗判的孤儿那两支。
113
+ * `orphan === (reason !== "idle")` 是不变量 —— 两者在**同一处**铸出(见 {@link createEngineLeaseTracker}),
114
+ * 不存在各算一次的可能。 */
115
+ export type EngineLeaseExitReason = "idle" | ParentOrphanReason;
116
+ /**
117
+ * 自退那一刻的**判据读数**(一个对象,三个消费者:`engine_lease_auto_exit` 日志行、数据根上的
118
+ * `exit-last.json` 留痕、以及 drain 本身)。一处铸出 ⇒ 日志与留痕永远不会各说各话。
119
+ */
120
+ export interface EngineLeaseExitReport {
121
+ /** 父已经不在了那一路退的吗(壳的 exit watch 读这一键把 pid 归入**预期**退出)。 */
122
+ orphan: boolean;
123
+ reason: EngineLeaseExitReason;
124
+ /** 真实空闲时长(不是配置的窗长 —— 见顶注 8)。 */
125
+ idleForMs: number;
126
+ attached: number;
127
+ inflight: number;
128
+ /** 本次**生效**的窗长(孤儿态 = 孤儿窗)。 */
129
+ lingerMs: number;
130
+ /** 成孤儿到自退之间隔了多久;非孤儿退出时键缺席(不编 0)。 */
131
+ orphanedForMs?: number;
132
+ }
59
133
  export interface EngineLeaseDeps {
60
134
  /** 活附着数 = 本副本当前**打开着的 SSE 流**条数(`drainState.attachedStreams`,由 `createHttpServer` 自赋)。 */
61
135
  attachedStreams: () => number;
@@ -77,12 +151,16 @@ export interface EngineLeaseDeps {
77
151
  /** 已有停机属主(`closing || draining`)时本腿站下:一次正在进行的优雅 drain 不该被「也没人附着了」
78
152
  * 升级成第二信号硬停(那会斩掉 in-flight leg)。 */
79
153
  isStopped: () => boolean;
80
- /** 自退出口 = SIGTERM 那条 graceful drain(见顶注 4/5)。 */
81
- drain: () => void;
82
- /** 三合取**持续**满这么久才触发(`config.engineLingerMs`,默认 60s)。 */
154
+ /** 自退出口 = SIGTERM 那条 graceful drain + 自退留痕(见顶注 4/5;装配层接线)。判据读数随调用递出,
155
+ * 日志那一行由本模块打 —— 两者同一个 report 对象。 */
156
+ selfExit: (report: EngineLeaseExitReport) => void;
157
+ /** owned 态(父还活着 / 没被监视)下三合取**持续**满这么久才触发(`config.engineLingerMs`,默认 60s)。
158
+ * **`Infinity`** = 永不自退(`ENGINE_AUTO_EXIT` 关着的 owned 引擎;窗永远追不上 ⇒ 无需第二条分支)。 */
83
159
  lingerMs: number;
160
+ /** 孤儿车道(`SEMA_PARENT_PID` 在场才给)。缺席 ⇒ 这台引擎不可能成孤儿,窗恒为 `lingerMs`。 */
161
+ orphan?: EngineLeaseOrphanLane;
84
162
  log: (event: string, fields: Record<string, unknown>) => void;
85
- /** 检查拍频 seam(测试)。缺省 {@link ENGINE_LEASE_TICK_MS}。 */
163
+ /** 检查拍频 seam(测试)。缺省 = 顶注 9 的推导(`min(ENGINE_LEASE_TICK_MS, 本腿可能执行的每个窗)`)。 */
86
164
  tickMs?: number;
87
165
  /** 时钟 seam,缺省 `() => performance.now()`(**单调**域)。用 `Date.now()` 会让 NTP/手动跳变吞掉或
88
166
  * 拉长 linger 窗 —— 前跳把窗直接跳过去 = 在一只还有人用的引擎底下提前关停。 */
@@ -1,14 +1,18 @@
1
1
  export const ENGINE_LEASE_TICK_MS = 15_000;
2
2
  export function createEngineLeaseTracker(deps) {
3
- const tickMs = deps.tickMs ?? ENGINE_LEASE_TICK_MS;
3
+ const orphanLane = deps.orphan;
4
+ const tickMs = deps.tickMs ?? Math.min(ENGINE_LEASE_TICK_MS, deps.lingerMs, orphanLane?.lingerMs ?? Number.POSITIVE_INFINITY);
4
5
  const now = deps.now ?? (() => performance.now());
5
6
  let stopped = false;
6
7
  let idleSince = now();
7
8
  deps.log("engine_lease_armed", {
8
- lingerMs: deps.lingerMs,
9
+ lingerMs: Number.isFinite(deps.lingerMs) ? deps.lingerMs : null,
10
+ orphanLingerMs: orphanLane?.lingerMs ?? null,
9
11
  tickMs,
10
12
  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.",
13
+ "continuously for the linger window — via the same graceful drain as SIGTERM. The window is " +
14
+ "ENGINE_LINGER_MS while the parent is alive (or the watch is not armed) and ENGINE_ORPHAN_LINGER_MS " +
15
+ "once SEMA_PARENT_PID's process is gone. Set ENGINE_AUTO_EXIT=false and unset SEMA_PARENT_PID to disable.",
12
16
  });
13
17
  const timer = setInterval(() => {
14
18
  if (stopped)
@@ -28,21 +32,32 @@ export function createEngineLeaseTracker(deps) {
28
32
  idleSince = t;
29
33
  return;
30
34
  }
31
- if (t - deps.lastActivityAt() < deps.lingerMs)
35
+ const orphaned = orphanLane ? orphanLane.read() : null;
36
+ const linger = orphanLane && orphaned !== null ? orphanLane.lingerMs : deps.lingerMs;
37
+ if (t - deps.lastActivityAt() < linger)
32
38
  return;
33
- if (t - deps.lastAttachEndedAt() < deps.lingerMs)
39
+ if (t - deps.lastAttachEndedAt() < linger)
34
40
  return;
35
- if (t - idleSince < deps.lingerMs)
41
+ if (t - idleSince < linger)
36
42
  return;
37
43
  stop();
38
- deps.log("engine_lease_auto_exit", {
44
+ const report = {
45
+ orphan: orphaned !== null,
46
+ reason: orphaned?.reason ?? "idle",
47
+ idleForMs: Math.round(t - Math.max(idleSince, deps.lastActivityAt(), deps.lastAttachEndedAt())),
39
48
  attached,
40
49
  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",
50
+ lingerMs: linger,
51
+ ...(orphaned !== null ? { orphanedForMs: Math.round(t - orphaned.since) } : {}),
52
+ };
53
+ deps.log("engine_lease_auto_exit", {
54
+ ...report,
55
+ note: orphaned !== null
56
+ ? "the parent that spawned this engine is gone AND nobody is attached, no run is in flight and no HTTP touch " +
57
+ "for the full orphan linger window — entering the SIGTERM graceful drain path (exit code 0; see exit-last.json)"
58
+ : "no attached shell session, no in-flight run and no HTTP touch for the full linger window — entering the SIGTERM graceful drain path",
44
59
  });
45
- deps.drain();
60
+ deps.selfExit(report);
46
61
  }, tickMs);
47
62
  timer.unref?.();
48
63
  function stop() {
@@ -0,0 +1,24 @@
1
+ import { z } from "zod";
2
+ /** 读回 schema(宪法 [ref] §2:边界读一律 safeParse,不裸铸)。写侧同形。 */
3
+ declare const ExitLastSchema: z.ZodObject<{
4
+ at: z.ZodString;
5
+ version: z.ZodString;
6
+ pid: z.ZodNumber;
7
+ orphan: z.ZodBoolean;
8
+ reason: z.ZodString;
9
+ idleForMs: z.ZodNumber;
10
+ attached: z.ZodNumber;
11
+ inflight: z.ZodNumber;
12
+ lingerMs: z.ZodNumber;
13
+ orphanedForMs: z.ZodOptional<z.ZodNumber>;
14
+ }, z.core.$strip>;
15
+ export type ExitLastInfo = z.infer<typeof ExitLastSchema>;
16
+ export declare function exitLastPath(dataRoot: string): string;
17
+ /** 自退那一刻写留痕(同步;自身绝不抛 —— 留痕写失败不得阻塞停机)。 */
18
+ export declare function writeExitLast(dataRoot: string, info: Omit<ExitLastInfo, "at" | "pid">): void;
19
+ /** boot 读回:在场读回、缺席/坏字节/坏形回 `undefined`(绝不抛 —— 留痕坏了不能挡启动)。 */
20
+ export declare function readExitLast(dataRoot: string): ExitLastInfo | undefined;
21
+ /** 读回并报过之后即清(幂等,缺席零抛):在场 ⇔ **上一次**退出是自退。 */
22
+ export declare function clearExitLast(dataRoot: string): void;
23
+ export {};
24
+ //# sourceMappingURL=exit-last.d.ts.map
@@ -0,0 +1,46 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ const ExitLastSchema = z.object({
5
+ at: z.string(),
6
+ version: z.string(),
7
+ pid: z.number(),
8
+ orphan: z.boolean(),
9
+ reason: z.string(),
10
+ idleForMs: z.number(),
11
+ attached: z.number(),
12
+ inflight: z.number(),
13
+ lingerMs: z.number(),
14
+ orphanedForMs: z.number().optional(),
15
+ });
16
+ export function exitLastPath(dataRoot) {
17
+ return join(dataRoot, "exit-last.json");
18
+ }
19
+ export function writeExitLast(dataRoot, info) {
20
+ try {
21
+ const body = { at: new Date().toISOString(), pid: process.pid, ...info };
22
+ writeFileSync(exitLastPath(dataRoot), JSON.stringify(body) + "\n");
23
+ }
24
+ catch {
25
+ }
26
+ }
27
+ export function readExitLast(dataRoot) {
28
+ try {
29
+ const p = exitLastPath(dataRoot);
30
+ if (!existsSync(p))
31
+ return undefined;
32
+ const parsed = ExitLastSchema.safeParse(JSON.parse(readFileSync(p, "utf8")));
33
+ return parsed.success ? parsed.data : undefined;
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ export function clearExitLast(dataRoot) {
40
+ try {
41
+ unlinkSync(exitLastPath(dataRoot));
42
+ }
43
+ catch {
44
+ }
45
+ }
46
+ //# sourceMappingURL=exit-last.js.map
@@ -3,6 +3,7 @@ import { writeCrashLast, clearCrashLast, readCrashLast } from "./crash-last.js";
3
3
  import { createSighupIdleHandler } from "../sighup-idle.js";
4
4
  import { createParentWatch } from "../parent-watch.js";
5
5
  import { createEngineLeaseTracker } from "./engine-lease.js";
6
+ import { writeExitLast, readExitLast, clearExitLast } from "./exit-last.js";
6
7
  import { isScriptRealmRejection, describeRejectionReason } from "../orchestration/hardened-vm-runner.js";
7
8
  export function installShutdownHandlers(ctx) {
8
9
  const { config, logger, server, reaper, fleetReconcile, retentionLane, releaseRetentionLease, otelExporter, breakerState, costQuota, rateLimiter, runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState, storeLiveProbe, configCenter, version, deviceHub, } = ctx;
@@ -11,6 +12,19 @@ export function installShutdownHandlers(ctx) {
11
12
  const prior = readCrashLast(dataRoot);
12
13
  if (prior)
13
14
  logger.warn("crash_last_found", { at: prior.at, version: prior.version, pid: prior.pid, uptimeMs: prior.uptimeMs, ...(prior.inflight !== undefined ? { inflight: prior.inflight } : {}), err: prior.err });
15
+ const lastExit = readExitLast(dataRoot);
16
+ if (lastExit) {
17
+ logger.info("exit_last_found", {
18
+ at: lastExit.at, version: lastExit.version, pid: lastExit.pid, orphan: lastExit.orphan, reason: lastExit.reason,
19
+ idleForMs: lastExit.idleForMs, attached: lastExit.attached, inflight: lastExit.inflight, lingerMs: lastExit.lingerMs,
20
+ ...(lastExit.orphanedForMs !== undefined ? { orphanedForMs: lastExit.orphanedForMs } : {}),
21
+ crashedDuringExit: prior !== undefined && prior.pid === lastExit.pid,
22
+ note: "the previous engine on this data root STARTED a self-exit (attachment lease / orphan; intended exit code 0). " +
23
+ "crashedDuringExit says whether a crash-last from the SAME pid was also found — true means that shutdown did " +
24
+ "NOT finish cleanly (see crash_last_found above), so do not read this as a clean exit.",
25
+ });
26
+ clearExitLast(dataRoot);
27
+ }
14
28
  }
15
29
  const writeLastWords = (err) => {
16
30
  if (dataRoot)
@@ -142,6 +156,7 @@ export function installShutdownHandlers(ctx) {
142
156
  log: (event, fields) => logger.info(event, fields),
143
157
  });
144
158
  process.on("SIGHUP", onSighup);
159
+ let orphanState = null;
145
160
  if (config.parentPid !== undefined) {
146
161
  const parentWatchSink = {
147
162
  info: (event, fields) => logger.info(event, fields),
@@ -150,19 +165,28 @@ export function installShutdownHandlers(ctx) {
150
165
  parentWatch = createParentWatch({
151
166
  pid: config.parentPid,
152
167
  isStopped: () => closing || draining,
153
- drain: drainThenShutdown,
168
+ onOrphaned: (reason) => {
169
+ orphanState = { since: performance.now(), reason };
170
+ },
154
171
  log: (level, event, fields) => parentWatchSink[level](event, fields),
155
172
  });
156
173
  }
157
- if (config.engineAutoExit) {
174
+ if (config.parentPid !== undefined || config.engineAutoExit) {
158
175
  engineLease = createEngineLeaseTracker({
159
176
  attachedStreams: () => drainState.attachedStreams?.() ?? 0,
160
177
  inflight: () => drainState.inflight?.() ?? 0,
161
178
  lastActivityAt: () => drainState.lastActivityAt?.() ?? 0,
162
179
  lastAttachEndedAt: () => drainState.lastAttachEndedAt?.() ?? 0,
163
180
  isStopped: () => closing || draining,
164
- drain: drainThenShutdown,
165
- lingerMs: config.engineLingerMs,
181
+ selfExit: (report) => {
182
+ if (dataRoot)
183
+ writeExitLast(dataRoot, { version, ...report });
184
+ drainThenShutdown();
185
+ },
186
+ lingerMs: config.engineAutoExit ? config.engineLingerMs : Number.POSITIVE_INFINITY,
187
+ ...(config.parentPid !== undefined
188
+ ? { orphan: { lingerMs: config.engineOrphanLingerMs, read: () => orphanState } }
189
+ : {}),
166
190
  log: (event, fields) => logger.info(event, fields),
167
191
  });
168
192
  }
@@ -136,7 +136,8 @@ export const CONFIG_CATALOG = [
136
136
  r("E2B_TEMPLATE", "orchestration", "string", "e2b 模板名(缺省 adapter 基础模板)"),
137
137
  r("E2B_TIMEOUT_MS", "orchestration", "number", "e2b 单命令总超时(ms)"),
138
138
  r("ENGINE_AUTO_EXIT", "limitsHttp", "boolean", "引擎附着租约自退总开关(壳 spawn 形注 true;三合取持续 linger 窗才自退,走 SIGTERM 同一条 drain 腿)", { staticDefault: "false", configKey: "engineAutoExit" }),
139
- r("ENGINE_LINGER_MS", "limitsHttp", "number", "自退前三合取须持续成立的静默窗(ms;[1000,24h] 拒启带界)", { staticDefault: "60000", configKey: "engineLingerMs" }),
139
+ r("ENGINE_LINGER_MS", "limitsHttp", "number", "父还活着时自退前三合取须持续成立的静默窗(ms;[1000,24h] 拒启带界)", { staticDefault: "60000", configKey: "engineLingerMs" }),
140
+ r("ENGINE_ORPHAN_LINGER_MS", "limitsHttp", "number", "父进程已不在(孤儿)时的静默窗(ms;[1000,24h] 拒启带界;SEMA_PARENT_PID 缺席则不生效)", { staticDefault: "2000", configKey: "engineOrphanLingerMs" }),
140
141
  r("EXPERIMENTAL_OBSERVER_AGENTS", "orchestration", "boolean", "observer 开闸线(实验面 ship-dark;多租户只认 center caps,env 被忽略)", { staticDefault: "false", configKey: "experimentalObserverAgents" }),
141
142
  r("FILE_HISTORY_RETENTION_KEEP", "store", "number", "per-file rewind 历史的保留上限(与 core FileStorageBackend 同域键形,三条车道一套语义)", { danger: DATA, derivedDefaultNote: "缺席 ⇒ core 缺省 100" }),
142
143
  r("FLEET_ADVERTISE_ADDRESS", "integrations", "url", "fleet 面向 center 公告的本 worker 可达地址(与 center 腿同在 ⇒ 启 fleet client;坏形拒启)"),
@@ -380,7 +381,7 @@ export const CONFIG_CATALOG = [
380
381
  r("SEMA_FAILOPEN_PROBE", "observability", "path", "F 类 fail-open 命中的探针文件路径(在场时逐次追加 fail-open 行;缺席只走计数+一次性 warn)"),
381
382
  r("SEMA_NODE_MIRROR", "orchestration", "url", "SANDBOX_PKG_SOURCE=custom 的显式 node 分发镜像地址"),
382
383
  r("SEMA_NPM_REGISTRY", "orchestration", "url", "SANDBOX_PKG_SOURCE=custom 的显式 npm registry 地址"),
383
- r("SEMA_PARENT_PID", "limitsHttp", "number", "引擎 parent 监视(壳 pid;被过继走或该 pid 不存在即判死,走 SIGTERM 同一条 drain 腿;0/负数语义完全不同 ⇒ 拒启)", { derivedDefaultNote: "缺席 ⇒ 整件不装配", configKey: "parentPid" }),
384
+ r("SEMA_PARENT_PID", "limitsHttp", "number", "引擎 parent 监视(壳 pid;被过继走或该 pid 不存在即把引擎标成孤儿——不直接停机,改用 ENGINE_ORPHAN_LINGER_MS 窗走同一条租约自退;0/负数语义完全不同 ⇒ 拒启)", { derivedDefaultNote: "缺席 ⇒ 看门狗不装配", configKey: "parentPid" }),
384
385
  r("SEMA_PIP_INDEX", "orchestration", "url", "SANDBOX_PKG_SOURCE=custom 的显式 pip index 地址"),
385
386
  r("SEMA_REGISTRY_ADMIN_TOKEN", "integrations", "secret", "写 center 的管理凭证(仅 boot 上报执行公钥用;刻意不入 ServiceConfig —— 不进 configHash/日志面)", { danger: SEC, scope: "bootstrap-only" }),
386
387
  r("SEMA_REGISTRY_DRY_RUN", "integrations", "boolean", "center 腿 dry-run(拉而不应用)", { staticDefault: "false" }),
@@ -1492,15 +1492,17 @@ export interface ServiceConfigFlat {
1492
1492
  * T11 stands, just bounded instead of immediate. A SECOND SIGHUP (or SIGTERM) escalates to the normal drain
1493
1493
  * path. Default 120s, floor 5s. Env: SIGHUP_IDLE_GRACE_MS. */
1494
1494
  sighupIdleGraceMs: number;
1495
- /** [ref](判据 S-292 改版):引擎 **parent 监视**。壳自 spawn 引擎的同机形里,壳被 SIGKILL(崩溃/强杀,
1496
- * 没有任何清理钩子)后引擎被 reparent 到 init 而**继续活着** —— 孤儿引擎占端口、占库连接、占模型
1497
- * 配额、抱着 `LOCAL_DATA_ROOT/LOCK`(活孤儿是合法持锁人 ⇒ 下一只壳的引擎必撞锁),而且再没有人会给
1495
+ /** [ref](判据 S-292 / 出口 S-306 改版):引擎 **parent 监视**。壳自 spawn 引擎的同机形里,壳被 SIGKILL
1496
+ * (崩溃/强杀,没有任何清理钩子)后引擎被 reparent 到 init 而**继续活着** —— 孤儿引擎占端口、占库连接、
1497
+ * 占模型配额、抱着 `LOCAL_DATA_ROOT/LOCK`(活孤儿是合法持锁人 ⇒ 下一只壳的引擎必撞锁),而且再没有人会给
1498
1498
  * 它发 SIGTERM(T11 的进程组腿只覆盖信号送得到的形)。设了本键 = 引擎每 2s 自查一拍,**两支任一成立
1499
- * 即判死**:① `process.ppid` 不再是启动那一刻的值(被过继走,强身份);② `process.kill(pid, 0)` 抛
1500
- * `ESRCH`(弱身份)。`EPERM` = 进程在、只是本进程无权给它发信号 ⇒ **算活着**。判死后走与 SIGTERM
1501
- * **逐字同一条** graceful drain 腿(in-flight run 的结算 / park 语义与人工停机一致),不是裸
1502
- * `process.exit`。
1503
- * **缺席 ⇒ 整件不装配**(opt-in;存量部署逐字零变化);**非正整数 ⇒ 拒启**(带指路文案)。
1499
+ * 即把自己标成孤儿**:① `process.ppid` 不再是启动那一刻的值(被过继走,强身份);② `process.kill(pid, 0)` 抛
1500
+ * `ESRCH`(弱身份)。`EPERM` = 进程在、只是本进程无权给它发信号 ⇒ **算活着**。
1501
+ * 🔴 **S-306(硬 breaking)**:成孤儿**不等于**停机 —— 看门狗的 drain 臂整条删。退不退由**同一条**
1502
+ * 附着租约律判(无附着 ∧ 无在飞 ∧ 静默满窗),孤儿态只是把窗从 {@link engineLingerMs} 换成
1503
+ * {@link engineOrphanLingerMs}。于是「A 起引擎、B 按 engine.port 复用、A 正常退」不会再在 B 的 turn
1504
+ * 中间把引擎拆掉;自退仍走与 SIGTERM **逐字同一条** graceful drain 腿,不是裸 `process.exit`。
1505
+ * **缺席 ⇒ 看门狗不装配**(opt-in;存量部署逐字零变化);**非正整数 ⇒ 拒启**(带指路文案)。
1504
1506
  * 拍频固定 2s,不开 interval 旋钮(有真需求再议,防旋钮增殖)。
1505
1507
  * 已知残余(成文,不收窄)见 `src/parent-watch.ts` 头注(boot 窗内的僵尸父 / 中间 wrapper 进程两形)。
1506
1508
  * 监视是**尽力自愈**、不是强一致的父子生命周期绑定。 Env: `SEMA_PARENT_PID`。 */
@@ -1511,8 +1513,10 @@ export interface ServiceConfigFlat {
1511
1513
  * 才自退,且自退走的是与 SIGTERM **逐字同一条** graceful drain 腿(不是裸 `process.exit`)。
1512
1514
  * **默认 false** —— 服务器部署形永不自退(k8s replica 半夜没人用是正常低谷,不是「该退了」);
1513
1515
  * 壳 spawn 形由壳显式注入 `true`。**部署级旋钮无条件生效**,不派生自任何客户端表态。
1514
- * 与 {@link parentPid} 是**两件**:那条问「生我的 pid 还在吗」(多壳共享一引擎时结构性问错),
1515
- * 这条问「还有没有人附着」。两条可同时装,各走各的判据。 Env: `ENGINE_AUTO_EXIT`(默认 off)。 */
1516
+ * 🔴 **S-306**:与 {@link parentPid} 不再是「各走各判据的两件」,而是**一件 + 一个输入** —— 租约腿在
1517
+ * 「本键为真 ∨ `parentPid` 在场」时装配,本键只决定 **owned 态**(父还活着 / 没被监视)的窗:为真 ⇒
1518
+ * {@link engineLingerMs},为假 ⇒ **永不自退**(窗 = `Infinity`,与 S-306 前逐字同)。父死之后一律
1519
+ * 改用 {@link engineOrphanLingerMs}。 Env: `ENGINE_AUTO_EXIT`(默认 off)。 */
1516
1520
  engineAutoExit: boolean;
1517
1521
  /** [ref]:自退前三合取必须**持续**成立的静默窗。默认 60s —— 防的是**壳重启窗**:壳崩了/重启的 1-3s
1518
1522
  * 里附着数确实是 0,窗太短就在用户眼皮底下把引擎杀了,窗内任何一次重连即把计时清零。
@@ -1524,6 +1528,14 @@ export interface ServiceConfigFlat {
1524
1528
  * **永远追不上它** ⇒ 看着配好了、自退其实整件失效。要「永不自退」请用 `ENGINE_AUTO_EXIT=false`。
1525
1529
  * Env: `ENGINE_LINGER_MS`(默认 60000,闭区间 [1000, 86400000])。 */
1526
1530
  engineLingerMs: number;
1531
+ /** S-306:**孤儿态**的静默窗 —— `SEMA_PARENT_PID` 那个进程已经不在了之后,三合取还要**持续**成立多久
1532
+ * 才自退。默认 **2s**:部署面的判据是「壳挂掉后数据根秒级重新可用」(B-117 的原病 = 孤儿引擎抱着
1533
+ * `LOCAL_DATA_ROOT/LOCK` 不放),而此时已经没有「壳重启窗」要防 —— 那只壳不会回来了(要回来的是**新**
1534
+ * 壳,它走的是新 boot)。窗仍然不是 0:有活附着 / 有在飞 turn 时这条窗压根不开始计时(三合取先不成立)。
1535
+ * 与 {@link engineLingerMs} **同一只校验器、同一对上下界**,坏值同样**拒启**;解析**无条件**,不看
1536
+ * `SEMA_PARENT_PID` 在不在场(operator-knob 铁律)。 Env: `ENGINE_ORPHAN_LINGER_MS`(默认 2000,闭区间
1537
+ * [1000, 86400000])。 */
1538
+ engineOrphanLingerMs: number;
1527
1539
  /** E23 (shell-host contract): inbound MCP elicitation HITL. When enabled, the service mounts `RunnerDeps.onElicit` + the
1528
1540
  * `POST /v1/elicitations/:id/respond` route so an opted-in MCP server (`McpServerSpec.elicitation`, itself default
1529
1541
  * OFF) can ask the END USER for input mid-tool-call. LIVE-ONLY (no durable suspend; lost on crash/replica-change).
@@ -1815,7 +1827,7 @@ export type ServiceAuthConfig = Pick<ServiceConfigFlat, "authToken" | "authToken
1815
1827
  /** 组:orchestration(编排 + 执行车道 + 沙箱面 + workflow/后台 agent 存留)。 */
1816
1828
  export type ServiceOrchestrationConfig = Pick<ServiceConfigFlat, "remoteExec" | "remoteExecHomeDir" | "worktreeIsolation" | "leaderEnabled" | "leaderFanoutEnabled" | "routerEnabled" | "selfOrchestrationEnabled" | "selfOrchestrationModels" | "selfOrchestrationWorkerIsolation" | "forkEnabled" | "experimentalObserverAgents" | "schedulerEnabled" | "schedulerSessionWakeup" | "schedulerSessionLifetime" | "schedulerStorePath" | "planModeEnabled" | "hooksTimeoutMs" | "workflowRunStoreBackend" | "workflowOrphanGraceMs" | "workflowJournalRetentionMs" | "workflowRunRetentionMs" | "usageWindows" | "workflowAgentsReadOnly" | "workflowSizeGuideline" | "backgroundAgentRetentionMs" | "backgroundAgentStaleRunningMs" | "backgroundAgentParkClaimStaleMs" | "delegationEntryCaps" | "rosterRetentionMs" | "leaderRunStaleMs" | "scratchpadSweepTtlMs" | "sandboxPkgSource" | "sessionAutoTitle" | "sessionAutoTitleSource" | "selectEnvironmentTool" | "envFactsEnabled" | "toolDeferLongtail" | "lspEnabled" | "lspHostEnabled" | "imageBakes" | "readFace" | "readFaceSource" | "readDenyPatterns" | "readDenyBuiltinTiers" | "readDenyBuiltinExclude">;
1817
1829
  /** 组:limitsHttp(HTTP 面 + 各类上限/配额/回收窗)。 */
1818
- export type ServiceLimitsHttpConfig = Pick<ServiceConfigFlat, "port" | "attachmentOrphanGraceMs" | "workspaceFileMaxBytes" | "attachmentMaxBytes" | "attachmentMimeAllowlist" | "attachmentUnboundTtlMs" | "infraCostRates" | "drainGraceMs" | "sighupIdleGraceMs" | "parentPid" | "engineAutoExit" | "engineLingerMs" | "rateLimitPerMin" | "maxTaskCostUsd" | "maxTaskTokens" | "maxPrincipalCostUsd" | "costQuotaWindowSec" | "reapIntervalSec" | "runStaleSec" | "toolResultTtlSec">;
1830
+ export type ServiceLimitsHttpConfig = Pick<ServiceConfigFlat, "port" | "attachmentOrphanGraceMs" | "workspaceFileMaxBytes" | "attachmentMaxBytes" | "attachmentMimeAllowlist" | "attachmentUnboundTtlMs" | "infraCostRates" | "drainGraceMs" | "sighupIdleGraceMs" | "parentPid" | "engineAutoExit" | "engineLingerMs" | "engineOrphanLingerMs" | "rateLimitPerMin" | "maxTaskCostUsd" | "maxTaskTokens" | "maxPrincipalCostUsd" | "costQuotaWindowSec" | "reapIntervalSec" | "runStaleSec" | "toolResultTtlSec">;
1819
1831
  /** 组:observability(可观测)。 */
1820
1832
  export type ServiceObservabilityConfig = Pick<ServiceConfigFlat, "metricsToken" | "traceToken" | "toolTrace" | "traceThinking" | "logLevel" | "otel">;
1821
1833
  /** 组:integrations(外部集成)。`mcpServers` / `a2aPeers` 由 sema-registry 适配器填(无 env 腿),归本组。 */
package/dist/config.js CHANGED
@@ -1263,19 +1263,18 @@ function parentPidEnv() {
1263
1263
  }
1264
1264
  const ENGINE_LINGER_FLOOR_MS = 1_000;
1265
1265
  const ENGINE_LINGER_CEIL_MS = 24 * 60 * 60 * 1_000;
1266
- function engineLingerMsEnv() {
1267
- const raw = process.env.ENGINE_LINGER_MS;
1266
+ function lingerMsEnv(name, raw, defaultMs, disableHint) {
1268
1267
  if (raw === undefined || raw.trim() === "")
1269
- return 60_000;
1270
- const n = parseNumOrFail("ENGINE_LINGER_MS", raw);
1268
+ return defaultMs;
1269
+ const n = parseNumOrFail(name, raw);
1271
1270
  if (!Number.isSafeInteger(n) || n < ENGINE_LINGER_FLOOR_MS || n > ENGINE_LINGER_CEIL_MS) {
1272
- throw new Error(`env ENGINE_LINGER_MS="${raw}" must be an INTEGER number of MILLISECONDS in ` +
1271
+ throw new Error(`env ${name}="${raw}" must be an INTEGER number of MILLISECONDS in ` +
1273
1272
  `[${ENGINE_LINGER_FLOOR_MS}, ${ENGINE_LINGER_CEIL_MS}] (1s … 24h) — it is how long ` +
1274
1273
  `"nobody attached, no run in flight, no HTTP touch" must hold CONTINUOUSLY before the engine self-exits. ` +
1275
1274
  `A value like "60" is the seconds-for-milliseconds slip: a 60ms window kills the engine inside a shell's own ` +
1276
1275
  `restart gap. A value like "1e100" passes a naive "is it a positive integer" check but the elapsed clock can ` +
1277
1276
  `NEVER reach it, which silently turns self-exit off while looking configured. ` +
1278
- `There is no "disable" value here — set ENGINE_AUTO_EXIT=false to turn self-exit off entirely.`);
1277
+ `There is no "disable" value here — ${disableHint}`);
1279
1278
  }
1280
1279
  return n;
1281
1280
  }
@@ -1821,7 +1820,8 @@ function parseLimitsHttpDomain() {
1821
1820
  sighupIdleGraceMs: clampEnvWithWarn("SIGHUP_IDLE_GRACE_MS", String(120_000), 5_000),
1822
1821
  ...parentPidEnv(),
1823
1822
  engineAutoExit: boolEnv("ENGINE_AUTO_EXIT", false),
1824
- engineLingerMs: engineLingerMsEnv(),
1823
+ engineLingerMs: lingerMsEnv("ENGINE_LINGER_MS", process.env.ENGINE_LINGER_MS, 60_000, "set ENGINE_AUTO_EXIT=false to turn self-exit off entirely."),
1824
+ engineOrphanLingerMs: lingerMsEnv("ENGINE_ORPHAN_LINGER_MS", process.env.ENGINE_ORPHAN_LINGER_MS, 2_000, "this window only applies once the SEMA_PARENT_PID process is gone — unset SEMA_PARENT_PID to stop watching the parent at all."),
1825
1825
  rateLimitPerMin: numEnv("RATE_LIMIT_RPM", "0"),
1826
1826
  maxTaskCostUsd: numEnv("MAX_TASK_COST_USD", "0"),
1827
1827
  maxTaskTokens: numEnv("MAX_TASK_TOKENS", "0"),
@@ -2019,7 +2019,7 @@ const ORCHESTRATION_GROUP_KEYS = [
2019
2019
  const LIMITS_HTTP_GROUP_KEYS = [
2020
2020
  "port", "attachmentOrphanGraceMs", "workspaceFileMaxBytes", "attachmentMaxBytes", "attachmentMimeAllowlist",
2021
2021
  "attachmentUnboundTtlMs", "infraCostRates", "drainGraceMs", "sighupIdleGraceMs", "parentPid",
2022
- "engineAutoExit", "engineLingerMs",
2022
+ "engineAutoExit", "engineLingerMs", "engineOrphanLingerMs",
2023
2023
  "rateLimitPerMin", "maxTaskCostUsd",
2024
2024
  "maxTaskTokens", "maxPrincipalCostUsd", "costQuotaWindowSec", "reapIntervalSec", "runStaleSec", "toolResultTtlSec",
2025
2025
  ];