@sema-agent/server 7.101.0 → 7.102.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.
Files changed (86) hide show
  1. package/MIGRATION.md +23 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/USAGE.md +58 -4
  5. package/dist/approval-card.d.ts +35 -2
  6. package/dist/approval-card.js +19 -1
  7. package/dist/background-session-reap.d.ts +55 -0
  8. package/dist/background-session-reap.js +90 -0
  9. package/dist/boot/config-center.d.ts +16 -4
  10. package/dist/boot/config-center.js +44 -15
  11. package/dist/boot/exit-boundary.js +17 -0
  12. package/dist/boot/runner-deps.js +1 -0
  13. package/dist/boot/runtime-caps.js +3 -2
  14. package/dist/boot/session-faces.js +3 -4
  15. package/dist/boot/shutdown.d.ts +2 -1
  16. package/dist/boot/shutdown.js +33 -12
  17. package/dist/boot/stage-07-capability-layer.d.ts +1 -1
  18. package/dist/boot/stage-07-capability-layer.js +6 -4
  19. package/dist/boot/stage-08-reapers.d.ts +1 -1
  20. package/dist/boot/stage-09-leader.d.ts +1 -1
  21. package/dist/boot/stage-10-http-server.d.ts +1 -1
  22. package/dist/boot/stage-10-http-server.js +9 -3
  23. package/dist/capabilities/builtin-tools.d.ts +14 -1
  24. package/dist/capabilities/builtin-tools.js +3 -0
  25. package/dist/capabilities/scenarios.d.ts +1 -1
  26. package/dist/capabilities/scenarios.js +3 -3
  27. package/dist/center-credential-renewer.d.ts +76 -0
  28. package/dist/center-credential-renewer.js +359 -0
  29. package/dist/center-credential.d.ts +76 -22
  30. package/dist/center-credential.js +119 -43
  31. package/dist/config-catalog.js +9 -4
  32. package/dist/config-center/apply-effective.js +5 -12
  33. package/dist/config-center/center-request.d.ts +13 -0
  34. package/dist/config-center/center-request.js +23 -3
  35. package/dist/config-center/facade.d.ts +1 -1
  36. package/dist/config-center/http-client.js +2 -5
  37. package/dist/config-center/read-warnings.d.ts +19 -48
  38. package/dist/config-center/read-warnings.js +7 -49
  39. package/dist/config-center/restart-signal.d.ts +8 -11
  40. package/dist/config-center/types.d.ts +4 -2
  41. package/dist/config-provider.js +1 -48
  42. package/dist/config-types.d.ts +77 -5
  43. package/dist/config-types.js +5 -0
  44. package/dist/config.js +49 -13
  45. package/dist/device-store.d.ts +1 -1
  46. package/dist/fleet/fleet-bus.d.ts +30 -4
  47. package/dist/fleet/fleet-bus.js +20 -4
  48. package/dist/fleet/fleet-reconciler.d.ts +29 -6
  49. package/dist/fleet/fleet-reconciler.js +109 -79
  50. package/dist/fleet-client.d.ts +21 -7
  51. package/dist/fleet-client.js +41 -12
  52. package/dist/fleet-lease.d.ts +1 -1
  53. package/dist/http/approval-sweeps.js +5 -20
  54. package/dist/http/dispatch.js +20 -14
  55. package/dist/http/readiness.d.ts +87 -0
  56. package/dist/http/readiness.js +54 -0
  57. package/dist/http/resume-legs.js +5 -4
  58. package/dist/http/route-table.d.ts +7 -0
  59. package/dist/http/route-table.js +6 -0
  60. package/dist/http/routes/a2a-serve.js +6 -14
  61. package/dist/http/routes/approvals-assistant.js +1 -0
  62. package/dist/http/routes/background.d.ts +80 -0
  63. package/dist/http/routes/background.js +94 -0
  64. package/dist/http/routes/capabilities.js +2 -0
  65. package/dist/http/server.d.ts +21 -8
  66. package/dist/http/server.js +2 -2
  67. package/dist/model-entry-refusal.d.ts +11 -10
  68. package/dist/model-entry-refusal.js +13 -10
  69. package/dist/observability/fail-open.d.ts +6 -2
  70. package/dist/observability/fail-open.js +6 -2
  71. package/dist/parked-decide.d.ts +11 -5
  72. package/dist/parked-decide.js +4 -1
  73. package/dist/parked-revive-surface.d.ts +47 -0
  74. package/dist/parked-revive-surface.js +20 -0
  75. package/dist/plugins/background-shell-support.d.ts +20 -1
  76. package/dist/plugins/background-shell-support.js +26 -0
  77. package/dist/plugins/host-platform.d.ts +11 -1
  78. package/dist/plugins/host-platform.js +11 -0
  79. package/dist/plugins/remote-env-host.d.ts +14 -1
  80. package/dist/plugins/remote-env-host.js +18 -5
  81. package/dist/tool-approval.d.ts +39 -1
  82. package/dist/tool-approval.js +37 -8
  83. package/dist/trace/core-keyset-guard.d.ts +3 -3
  84. package/dist/trace/engine-notice-wire.d.ts +1 -1
  85. package/dist/trace/engine-notice-wire.js +2 -0
  86. package/package.json +3 -3
@@ -1,3 +1,4 @@
1
+ import type { RestartSignal } from "@sema-agent/settings-schema/fleet";
1
2
  import type { EffectiveConfig } from "./types.js";
2
3
  export type RestartSlice = "skills" | "mcp" | "a2a" | "scenarios" | "degrade-route" | "read-face" | "memory-consolidation";
3
4
  /** What the CALLER must contribute about boot-baked catalog consumers that live OUTSIDE the Runner — the
@@ -31,18 +32,14 @@ export interface RestartSliceCtx {
31
32
  * = 装载器的出厂名单 `DEFAULT_PLUGIN_ALLOW_HOSTS`。 */
32
33
  pluginsAllowHosts?: readonly string[];
33
34
  }
34
- /** Structured restart signal an orchestrator consumes (GET /health → `restart`). Change-detected against the
35
- * process's BOOT config, NOT presence — so it stays absent when nothing baked-at-boot changed (presence would
36
- * re-fire every refresh → a restart LOOP). `restartRequired` is always true when this object exists. */
37
- export interface RestartSignal {
38
- restartRequired: true;
39
- /** Which restart-to-apply slices differ from the value baked at boot. */
35
+ /** S-741(车OG):本引擎发布的重启信号。形的唯一定义是 settings-schema 6.1.0 `RestartSignal`(`{ reasons, version, since }`,在场即信号,
36
+ * 不夹 `restartRequired` 布尔;本地手写的那份接口已删),这里只把 `reasons` 收窄到本文件的闭集 {@link RestartSlice} —— 写口
37
+ * (`boot/config-center.ts` `publishRestart`)按它收,拼错 / 已退役的词编译红(opus 对抗腿 #2:直接用上游型时 `reasons` 是
38
+ * `string[]`,闭集的编译期判据丢了)。`/health.restart` 与 fleet 心跳 `restart` 两个出口交 config-center 同一只 `restartState()`
39
+ * 的同一个对象,按契约形出门。 */
40
+ export type EngineRestartSignal = RestartSignal & {
40
41
  reasons: RestartSlice[];
41
- /** The config-center version that currently differs from boot (latest seen). */
42
- version: number;
43
- /** Epoch ms when this restart first became required (stable across refreshes while `reasons` is unchanged). */
44
- since: number;
45
- }
42
+ };
46
43
  /** Canonical, key-sorted JSON (array order preserved) so two semantically-equal effective slices fingerprint
47
44
  * identically regardless of object key order from the center serializer.
48
45
  *
@@ -265,9 +265,11 @@ export interface EffectiveConfig {
265
265
  tierGroups?: unknown;
266
266
  activeTierGroup?: string;
267
267
  /** 🔴 S-712 —— **本仓读腿铸的**载体(不是 wire 键:中心在模型域里写同名键会被 schema 当未知键剥掉并进候选门):这次读
268
- * 被 schema 逐条丢掉的**启用**目录条目的身份。模型面 stage 把它们铸成 `config.model_entry_invalid` 墓碑进 `modelRefusals`,
268
+ * 被 schema 逐条丢掉的**启用**、**有名**目录条目的身份(取 settings-schema 6.1.0 告警自带的 `identity`,见
269
+ * `read-warnings.ts` `splitFoldedReadWarnings`)。模型面 stage 把它们铸成 `config.model_entry_invalid` 墓碑进 `modelRefusals`,
269
270
  * 不变量 `refused-names-not-minted` 据此判增广有没有把它们的名字铸到别的条目上。挂在 effective 上(而不是读结果旁边)是为了
270
- * **随 effective 落 LKG**:重启从 LKG 起服时墓碑还在。形见 {@link DroppedModelIdentity}(纯身份,不含值)。 */
271
+ * **随 effective 落 LKG**:重启从 LKG 起服时墓碑还在。形见 {@link DroppedModelIdentity}(纯身份,不含值);盘上形不对
272
+ * (不是列表 / 行缺字符串 `name`)⇒ 模型面整组拒。 */
271
273
  droppedIdentities?: DroppedModelIdentity[];
272
274
  };
273
275
  /** registry-core 0.7 collab domain (teams' successor) — consumed by the slice-1.5 projection
@@ -1,7 +1,6 @@
1
1
  import { readMcpSourceLabel } from "./mcp-source-label.js";
2
2
  import { createHmac, randomBytes } from "node:crypto";
3
3
  import { splitFoldedReadWarnings, withDroppedIdentities } from "./config-center/read-warnings.js";
4
- import { readFile, stat } from "node:fs/promises";
5
4
  import { FileConfigStore } from "@sema-agent/settings-schema/node";
6
5
  import { findSkillContent, skillContentHash, refIntegrityIssues, createSiblingResolver, formatConfigPath, REF_CHECKED_DOMAINS, } from "@sema-agent/settings-schema";
7
6
  import { redactSecrets } from "./trace/redact.js";
@@ -12,24 +11,6 @@ const OPERAND_FINGERPRINT_KEY = randomBytes(16);
12
11
  function redactConfigError(text) {
13
12
  return redactSecrets(text).replace(/"[^"]*"|'[^']*'/g, (m) => `"#${createHmac("sha256", OPERAND_FINGERPRINT_KEY).update(m.slice(1, -1)).digest("hex").slice(0, 8)}"`);
14
13
  }
15
- async function readRawDomainDoc(path) {
16
- try {
17
- const doc = JSON.parse(await readFile(path, "utf8"));
18
- return { ok: true, doc };
19
- }
20
- catch (e) {
21
- return { ok: false, why: e instanceof Error ? e.message : String(e) };
22
- }
23
- }
24
- async function statSignature(path) {
25
- try {
26
- const st = await stat(path, { bigint: true });
27
- return { ok: true, sig: `${st.ino}:${st.size}:${st.mtimeNs}:${st.ctimeNs}` };
28
- }
29
- catch (e) {
30
- return { ok: false, why: e instanceof Error ? e.message : String(e) };
31
- }
32
- }
33
14
  export const BOOT_FETCH_DEFERRED = Symbol("boot-fetch-deferred");
34
15
  export function raceBootFetch(fetch, budgetMs) {
35
16
  if (budgetMs <= 0)
@@ -101,9 +82,7 @@ export class LocalConfigProvider {
101
82
  if (issues.length > 0)
102
83
  refErrors.push({ domain: d, error: issues.map((i) => `${formatConfigPath(i.path)}: ${redactConfigError(i.message)}`).join("; ") });
103
84
  }
104
- const modelDropped = readWarnings.some((w) => w.kind === "entry-dropped" && w.domain === "models");
105
- const rawModels = modelDropped ? await this.#provenRawModelsDoc(store, eff.version) : undefined;
106
- const folded = splitFoldedReadWarnings(readWarnings, { raw: rawModels, parsed: eff.models.models ?? [] });
85
+ const folded = splitFoldedReadWarnings(readWarnings);
107
86
  const parseErrors = folded.domainErrors.map((w) => ({ domain: w.domain, error: redactConfigError(w.error) }));
108
87
  const droppedEntries = folded.droppedEntries.map((d) => ({ ...d, error: redactConfigError(d.error) }));
109
88
  const errors = [...domainErrors.map((de) => ({ domain: de.domain, error: redactConfigError(de.error) })), ...parseErrors, ...refErrors];
@@ -115,32 +94,6 @@ export class LocalConfigProvider {
115
94
  ...(droppedEntries.length > 0 ? { droppedEntries } : {}),
116
95
  };
117
96
  }
118
- async #provenRawModelsDoc(store, version) {
119
- const path = store.domainPath("models");
120
- const unproven = (why) => {
121
- logger.warn("config_models_raw_unproven", { dir: this.root, why: redactConfigError(why), note: "the dropped model entries of this read cannot be identified from the exact snapshot that was parsed — the candidate's model plane is refused as a group (no tombstone is taken from another version of the file); the next poll re-reads" });
122
- return undefined;
123
- };
124
- const s1 = await statSignature(path);
125
- const raw = await readRawDomainDoc(path);
126
- let again;
127
- try {
128
- again = (await (this.#injectedStore ?? new FileConfigStore(this.root)).getEffective({ tolerant: true })).effective.version;
129
- }
130
- catch (e) {
131
- again = `re-parse failed: ${e instanceof Error ? e.message : String(e)}`;
132
- }
133
- const s2 = await statSignature(path);
134
- if (!raw.ok)
135
- return unproven(`config.d/models.json unreadable on the identity re-read: ${raw.why}`);
136
- if (typeof again !== "number")
137
- return unproven(again);
138
- if (!s1.ok || !s2.ok || s1.sig !== s2.sig)
139
- return unproven("config.d/models.json changed (or could not be stat-ed) while its identity re-read was being proven");
140
- if (again !== version)
141
- return unproven("config.d changed between the parse and the identity re-read (content version differs)");
142
- return raw.doc;
143
- }
144
97
  async fetchSkillContent(contentHash) {
145
98
  const skills = await this.store.getDomain("skills");
146
99
  if (!skills)
@@ -62,6 +62,56 @@ export interface RetentionSweepConfig {
62
62
  * 那道拒之后它即刻生效 —— 留着是为了「那天不必有人再猜一个数」。
63
63
  */
64
64
  export declare const MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC = 300;
65
+ /**
66
+ * Node 的 `setTimeout` delay 上界(2^31−1 ms ≈ 24.8 天)。**超过它的 delay 被静默折成 1ms** —— 任何「一个时长
67
+ * 旋钮的值最终会变成一次 `setTimeout` 的 delay」的键都必须在上界处拒启,不能夹取([ref] + codex r1-[high]:夹取
68
+ * 只是把一句谎换成另一句)。S-757 起从 `config.ts` 的模块私有常量提到本纯叶:解析器与配置目录的合法域句读
69
+ * 同一个数([ref]:边界不手抄)。
70
+ */
71
+ export declare const MAX_TIMER_DELAY_MS = 2147483647;
72
+ /**
73
+ * S-757(test [ref] / core #1125)—— `AUTO_MODE_CLASSIFIER_TIMEOUT_MS`(→ core `RunnerDeps.autoMode.timeoutMs`)
74
+ * 的**合法域**,闭区间、整数 ms。解析器(`config.ts`)与目录行(`config-catalog.ts`)共读这一处。
75
+ *
76
+ * · **下界 1000**:亚秒值是「秒写成毫秒」的手滑形(`180` 想说三分钟,落地 180 ms ⇒ 每一级立即判「没裁」⇒ 门控
77
+ * 调用整条拒)。`0` 同拒:core 对这一座**没有**「关」的语义(d.ts 未写 0 的含义;分类器不判就走下一级 / 拒),
78
+ * 写 0 表达不出任何可执行的意图。
79
+ * · **上界 = {@link MAX_TIMER_DELAY_MS}**:core 拿这个数直接武装 `setTimeout`(7.32.1 `dist/core/auto-mode.js`
80
+ * `withDeadline`,座上不做值校验),更大的值被 Node 折成 1 ms —— 「抬上限」变成「立即超时」,方向正好反了。
81
+ * **不另设更紧的政策上界**:这是**一级**梯子的等待上限(不是整次分类;`classifierFallback=main` 时最坏 =
82
+ * 两级 × 上限 + 一次装配),而本级调用同时收到 run 的中止信号(`withDeadline` 把外层 signal 接进本级),
83
+ * run 被取消 / 硬 abort 时不靠这个数收尾。
84
+ */
85
+ export declare const AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS: {
86
+ readonly minMs: 1000;
87
+ readonly maxMs: 2147483647;
88
+ };
89
+ /**
90
+ * `DRAIN_GRACE_MS` 的**地板**(10s)—— 夹取(env,[ref] B 档)与中心 stage 校验(`stage-limits.ts` 的 `atLeast`)两条写入腿
91
+ * 都以它为下界,于是「排空窗」在任何写入通道上都不会短于它。S-752 起从 `config.ts` 的 `LIMITS_HTTP_FLOORS` 字面提到本
92
+ * 纯叶:{@link DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS} 的上界与配置目录的合法域句共读这一个数([ref]:边界不手抄;
93
+ * 目录模块不能值引 `config.ts` —— `config → server-secret-env → config-catalog` 已成链)。`LIMITS_HTTP_FLOORS` 仍是
94
+ * 那张表,只是这一格改为引用本常量,零语义变化。
95
+ */
96
+ export declare const DRAIN_GRACE_FLOOR_MS = 10000;
97
+ /**
98
+ * S-752(test [ref])—— `DRAIN_SECOND_SIGNAL_MIN_GAP_MS` 的**合法域**(闭区间、整数 ms)与缺省。解析器(`config.ts`)
99
+ * 与目录行(`config-catalog.ts`)共读这一处。
100
+ *
101
+ * · **语义**:排空期里一发停止信号(SIGTERM / SIGINT)只有在与**排空起点**相隔 ≥ 本值时才算硬停意图;更近的是同一拍的
102
+ * 重复投递(壳心跳误判 / 编排器重试),记一行就忽略。
103
+ * · **缺省 250**:人手连按两次的最短间隔量级 —— 同一毫秒的重复投递(事故形)远在其内,有意的第二发远在其外。
104
+ * · **下界 0 = 关**:恒按「第二发 = 硬停」,即 7.101.x 行为逐字。0 在这里有真含义(不同于 `AUTO_MODE_CLASSIFIER_TIMEOUT_MS`
105
+ * 的 0),所以放行。
106
+ * · **上界 = {@link DRAIN_GRACE_FLOOR_MS}**:本闸不能比**最短**可能的排空窗还宽 —— 更宽的值会让「排空期的第二发」在整个窗
107
+ * 里都不算数(信号面的硬梯子被静默摘掉,只剩 SIGKILL)。取地板而不是「≤ 本进程的 `DRAIN_GRACE_MS`」:后者是中心热改键,
108
+ * boot 期跨键校验会被一次热改在运行期推翻;地板则在所有写入腿上都成立,无需第二处校验。
109
+ */
110
+ export declare const DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS: {
111
+ readonly minMs: 0;
112
+ readonly maxMs: 10000;
113
+ };
114
+ export declare const DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS = 250;
65
115
  /**
66
116
  * S-220 —— **`resilience` 块缺省的唯一属主**(S-202 的单源从一根扩到整族)。
67
117
  *
@@ -1556,6 +1606,15 @@ export interface ServiceConfigFlat {
1556
1606
  * 真源。所以缺席时这台机器跟随 core 的 `main`,运维面由 DEPLOY-PREREQS 的升级段点名(「默认变了」
1557
1607
  * 是升级须知,不是一个可以静默过去的实现细节)。 */
1558
1608
  autoModeClassifierFallback?: "main" | "none";
1609
+ /** 7.102.0 S-757(test [ref] / core #1125)—— **分类器单级判决上限**(`AUTO_MODE_CLASSIFIER_TIMEOUT_MS`,
1610
+ * 部署级数值旋钮,整数 ms,合法域 {@link AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS})→ core `RunnerDeps.autoMode.timeoutMs`。
1611
+ *
1612
+ * 为什么要一根旋钮:这一级到点没裁 = 梯子往下走,走完仍没裁 ⇒ 这次门控调用**整条拒**、不出卡。自建慢网关
1613
+ * (排队 + prefill 以分钟计)在 core 缺省上限内答不出 ⇒ auto 模式在这类部署上等于「工具一律拒」。
1614
+ *
1615
+ * 🔴 **缺席 = 不铸键**(与 {@link autoModeClassifierFallback} 同律):本仓不复制上游缺省数字,core 改缺省之日
1616
+ * 本仓不会成为第二份真源。旋钮**无条件**:不挂任何客户端 / 请求面派生腿。坏值(非十进制整数 / 越界)boot 拒启。 */
1617
+ autoModeClassifierTimeoutMs?: number;
1559
1618
  /** S-111([ref] §4.4)—— CC `settings.crossSessionInbound` 的**部署/组织层**(引擎层形的 `managed` 层,
1560
1619
  * CC `policySettings` 对位):本部署对入站跨会话消息的治理三态。`accept` 投递 / `hold` 停在收件会话的待审
1561
1620
  * 队列 / `refuse` 整体退出车道。**未设 = 本层不设**(不是 accept):引擎读作「managed 层未表态」,由其余层
@@ -1607,8 +1666,14 @@ export interface ServiceConfigFlat {
1607
1666
  * instance) to finish before the hard shutdown. During drain, new billable submissions get 503+Retry-After and
1608
1667
  * /health carries `draining:true`. Default 10min (an interactive turn is minutes, not seconds — a
1609
1668
  * 10s hard-cut would kill long turns); floor 10s. Env: DRAIN_GRACE_MS. SIGINT (Ctrl-C dev
1610
- * ergonomics) and a SECOND SIGTERM skip the drain and hard-stop immediately. */
1669
+ * ergonomics) hard-stops immediately; DURING a drain, a second SIGTERM / SIGINT hard-stops only when it arrives
1670
+ * at least {@link drainSecondSignalMinGapMs} after the drain began (S-752). */
1611
1671
  drainGraceMs: number;
1672
+ /** S-752(test [ref]):排空期「第二发信号 = 硬停」的最小间隔(ms,锚 = 排空起点)。不足 ⇒ 同一拍的重复投递,一行
1673
+ * info `drain_duplicate_signal_ignored` 后忽略、排空继续;`0` = 关(7.101.x 行为)。合法域
1674
+ * {@link DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS},缺省 {@link DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS};坏值拒启([ref] A 档)。
1675
+ * 部署级旋钮,**无条件**解析。 Env: `DRAIN_SECOND_SIGNAL_MIN_GAP_MS`。 */
1676
+ drainSecondSignalMinGapMs: number;
1612
1677
  /** B3 (shell [ref]②): SIGHUP idle window. SIGHUP means "the process-group owner's terminal went away" — but
1613
1678
  * under the shared-engine shape (several shell sessions on one config, engine rides the FIRST shell's process
1614
1679
  * group per the T11 anti-orphan call) other live sessions may still be using this engine, so an unconditional
@@ -1955,13 +2020,20 @@ export interface ServiceConfigFlat {
1955
2020
  * that worker's roster via `/effective?worker=<name>`. Unset → the global roster (backward compatible).
1956
2021
  * `credential`(S-651,BREAKING:取代 7.98.0 及以前的 `token: string` 启动快照):凭证来源活对象
1957
2022
  * (`center-credential.ts`),每条出站请求经它取一次值 —— `SEMA_REGISTRY_TOKEN`(值)或
1958
- * `SEMA_REGISTRY_TOKEN_FILE`(每次读文件,轮换 / 换账号不重启)二选一。 */
2023
+ * `SEMA_REGISTRY_TOKEN_FILE`(每次读文件,轮换 / 换账号不重启)二选一(两键名与判官的属主 = settings-schema
2024
+ * `CENTER_CREDENTIAL_ENV` / `judgeCenterCredentialDeclaration`,S-742)。 */
1959
2025
  configCenter?: {
1960
2026
  baseUrl: string;
1961
2027
  credential: CenterCredentialSource;
1962
2028
  dryRun: boolean;
1963
2029
  worker?: string;
1964
2030
  };
2031
+ /** S-698(design-325 §2):中心凭证**续签提前量**(ms)—— `SEMA_REGISTRY_TOKEN_FILE` 指向人的登录会话信封
2032
+ * (`kind:"user-session"`)时,访问令牌离到期 ≤ 本值即由引擎续签并原子改写同一文件(`center-credential-renewer.ts`)。
2033
+ * 缺省 5 min;`0` = 关续签、只读信封(壳负责续);其余必须是整数毫秒且落在 `CENTER_CREDENTIAL_RENEW_LEAD_MIN_MS`(一次续签请求的
2034
+ * 传输上界)与 `CENTER_CREDENTIAL_RENEW_LEAD_MAX_MS`(访问令牌寿命之半)之间,坏值拒启(解析口 `parseCenterCredentialRenewLeadMs`)。
2035
+ * 裸行文件 / `SEMA_REGISTRY_TOKEN` 值形不受它影响(不续签)。`CENTER_CREDENTIAL_RENEW_LEAD_MS`。 */
2036
+ centerCredentialRenewLeadMs: number;
1965
2037
  /** Dual-mode config SOURCE (DUAL-MODE-DESIGN §4): "remote" (sema-registry HTTP) | "local" (.env + config.d
1966
2038
  * via @sema-agent/settings-schema FileConfigStore) | unset → remote if SEMA_REGISTRY_URL (legacy CONFIG_CENTER_URL now boot-rejects, no dual-read) is set, else pure-env.
1967
2039
  * Env: CONFIG_PROVIDER. The same resolver/EffectiveConfig either way (can't drift). */
@@ -1975,7 +2047,7 @@ export type ServiceStoreConfig = Pick<ServiceConfigFlat, "sessionBackend" | "ses
1975
2047
  export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" | "gatewayApiKey" | "gatewayFallbackUrls" | "gatewayMaxRetries" | "anthropic" | "resilience" | "model" | "envModel" | "models" | "modelApiKeyEnv" | "modelApiKeys" | "modelRefusals" | "modelQuotaWeights" | "tiers" | "projects" | "roles" | "atModelAllowlist" | "cascadeLadder" | "degrade">;
1976
2048
  /** 组:approval(审批 / HITL 门)。`directDoorActive` 无 env 解析腿(装配层三域合取的产物),但语义上
1977
2049
  * 就是本组的门状态,故进组;`parseApprovalDomain` 的返回类型相应是 `Omit<…, "directDoorActive">`。 */
1978
- export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "durableApprovalSource" | "streamAskWindowMsSource" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "resourceSuspendTotalTokens" | "resourceSuspendTotalBudgetUsd" | "resourceSuspendMaxSlices" | "askQuestionEnabled" | "questionThrottle" | "toolApprovalEnabled" | "permissionRulesEnabled" | "permissionRulesEnabledExplicit" | "streamAskWindowMarginMs" | "streamApproval" | "unattendedApprovalPolicy" | "mcpElicitation" | "sensitiveWritePatterns" | "writeProtectedPaths" | "manualModeShellGate" | "permissionsDisableAutoMode" | "autoModeClassifierFallback" | "crossSessionInbound" | "crossSessionDialogExpiry">;
2050
+ export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "durableApprovalSource" | "streamAskWindowMsSource" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "resourceSuspendTotalTokens" | "resourceSuspendTotalBudgetUsd" | "resourceSuspendMaxSlices" | "askQuestionEnabled" | "questionThrottle" | "toolApprovalEnabled" | "permissionRulesEnabled" | "permissionRulesEnabledExplicit" | "streamAskWindowMarginMs" | "streamApproval" | "unattendedApprovalPolicy" | "mcpElicitation" | "sensitiveWritePatterns" | "writeProtectedPaths" | "manualModeShellGate" | "permissionsDisableAutoMode" | "autoModeClassifierFallback" | "autoModeClassifierTimeoutMs" | "crossSessionInbound" | "crossSessionDialogExpiry">;
1979
2051
  /** 组:memory(记忆面 + TOC 同步腿)。 */
1980
2052
  export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled" | "memoryEngineDir" | "memoryEngineRemoteLaneAllowed" | "memoryEngineBackend" | "memoryScope" | "memoryPersistenceCapable" | "memorySync" | "memoryEmbedder" | "memoryOrgAdmissionMode" | "memoryDelegationEvidence" | "memoryProvenance" | "memoryCapturePolicy" | "memoryConsolidationDriver" | "memoryOrgDirectoryJson" | "memoryOrgGrantTtlMs" | "memoryOrgUnavailableBackoffMs" | "projectMemoryEnabled" | "syncImportLeaseStaleSec">;
1981
2053
  /** 组:auth(鉴权 / 身份 / 治理棒)。`commandPolicy` 只有 sema-registry 腿(无 env 标量形),故 env 解析
@@ -1988,11 +2060,11 @@ export type ServiceAuthConfig = Pick<ServiceConfigFlat, "authToken" | "authToken
1988
2060
  /** 组:orchestration(编排 + 执行车道 + 沙箱面 + workflow/后台 agent 存留)。 */
1989
2061
  export type ServiceOrchestrationConfig = Pick<ServiceConfigFlat, "remoteExec" | "remoteExecHomeDir" | "requireExecutionLane" | "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" | "lspEnvAllow" | "imageBakes" | "readFace" | "readFaceSource" | "taskWriteFaceEscalation" | "readDenyPatterns" | "readDenyBuiltinTiers" | "readDenyBuiltinExclude">;
1990
2062
  /** 组:limitsHttp(HTTP 面 + 各类上限/配额/回收窗)。 */
1991
- 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">;
2063
+ export type ServiceLimitsHttpConfig = Pick<ServiceConfigFlat, "port" | "attachmentOrphanGraceMs" | "workspaceFileMaxBytes" | "attachmentMaxBytes" | "attachmentMimeAllowlist" | "attachmentUnboundTtlMs" | "infraCostRates" | "drainGraceMs" | "drainSecondSignalMinGapMs" | "sighupIdleGraceMs" | "parentPid" | "engineAutoExit" | "engineLingerMs" | "engineOrphanLingerMs" | "rateLimitPerMin" | "maxTaskCostUsd" | "maxTaskTokens" | "maxPrincipalCostUsd" | "costQuotaWindowSec" | "reapIntervalSec" | "runStaleSec" | "toolResultTtlSec">;
1992
2064
  /** 组:observability(可观测)。 */
1993
2065
  export type ServiceObservabilityConfig = Pick<ServiceConfigFlat, "metricsToken" | "traceToken" | "toolTrace" | "traceThinking" | "logLevel" | "otel">;
1994
2066
  /** 组:integrations(外部集成)。`mcpServers` / `a2aPeers` 由 sema-registry 适配器填(无 env 腿),归本组。 */
1995
- export type ServiceIntegrationsConfig = Pick<ServiceConfigFlat, "pluginsAllowHosts" | "mcpServers" | "a2aPeers" | "a2aServe" | "configBootFetchBudgetMs" | "gitApiBaseUrl" | "gitApiToken" | "gitApiKind" | "defaultScenario" | "skillsDir" | "configCenter" | "configProvider" | "configLocalDir">;
2067
+ export type ServiceIntegrationsConfig = Pick<ServiceConfigFlat, "pluginsAllowHosts" | "mcpServers" | "a2aPeers" | "a2aServe" | "configBootFetchBudgetMs" | "gitApiBaseUrl" | "gitApiToken" | "gitApiKind" | "defaultScenario" | "skillsDir" | "centerCredentialRenewLeadMs" | "configCenter" | "configProvider" | "configLocalDir">;
1996
2068
  /** 九个组槽。每组恒在场(`loadConfig` / `attachConfigGroups` 装好才交出配置),故不可选——新代码写
1997
2069
  * `config.modelPlane.model` 不需要 `?.`(可选组会把 `Model` 污染成 `Model | undefined`)。 */
1998
2070
  export interface ServiceConfigGroups {
@@ -1,6 +1,11 @@
1
1
  export const TASK_WRITE_FACE_ESCALATIONS = ["allow", "refuse"];
2
2
  export const RETENTION_MODES = ["audit-only", "enforce"];
3
3
  export const MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC = 300;
4
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647;
5
+ export const AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS = { minMs: 1_000, maxMs: MAX_TIMER_DELAY_MS };
6
+ export const DRAIN_GRACE_FLOOR_MS = 10_000;
7
+ export const DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS = { minMs: 0, maxMs: DRAIN_GRACE_FLOOR_MS };
8
+ export const DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS = 250;
4
9
  export const RESILIENCE_DEFAULTS = {
5
10
  connectTimeoutMs: { env: "MODEL_CONNECT_TIMEOUT_MS", value: 30_000 },
6
11
  firstTokenTimeoutMs: { env: "MODEL_FIRST_TOKEN_TIMEOUT_MS", value: 600_000 },
package/dist/config.js CHANGED
@@ -4,13 +4,13 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { BUILTIN_COMPLIANCE_DENIES, COMPLIANCE_CAPABILITIES, CROSS_SESSION_INBOUND_SETTINGS, CROSS_SESSION_DIALOG_EXPIRY_VALUES, compileReadDeny, resolveReadDenyBuiltins, CODE_AGENT_PROMPT, formatUserScope, isModelPriced, PROTOCOL_TABLE, protocolOf, RECOMMENDED_SENSITIVE_PATTERNS, RETIRED_TOOL_NAMES, resolveFileHistoryRetention, resolveLockedKeys, validatePermissionRules, WRITE_PROTECTED_DEFAULT_TABLE, resolveWriteProtectedTable, parseRuleText } from "@sema-agent/core";
7
- import { ROSTER_PRIMARY_ROLES, ROSTER_CHEAP_ROLES } from "@sema-agent/settings-schema";
7
+ import { ROSTER_PRIMARY_ROLES, ROSTER_CHEAP_ROLES, CENTER_CREDENTIAL_ENV } from "@sema-agent/settings-schema";
8
8
  import { parseApprovalHmacKeys, parsePrincipalJwks } from "./auth-keys.js";
9
9
  import { DEFAULT_ELICITATION_THROTTLE } from "./elicitation.js";
10
10
  import { isV2ScopeKey, memoryReplicationCoverage } from "./memory-scope.js";
11
11
  import { DEFAULT_QUESTION_THROTTLE } from "./question.js";
12
12
  import { GIT_API_KINDS, isGitApiKind } from "./git-api-kind.js";
13
- import { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC, RESILIENCE_DEFAULTS, TASK_WRITE_FACE_ESCALATIONS } from "./config-types.js";
13
+ import { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC, RESILIENCE_DEFAULTS, TASK_WRITE_FACE_ESCALATIONS, MAX_TIMER_DELAY_MS, AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS, DRAIN_GRACE_FLOOR_MS, DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS, DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS } from "./config-types.js";
14
14
  import { buildWriteProtectionPosture } from "./write-protection.js";
15
15
  import { normalizeSshFingerprint } from "./ssh-host-key.js";
16
16
  import { readFaceForPosture } from "./read-face-posture.js";
@@ -20,6 +20,7 @@ import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
20
20
  import { REMOTE_EXEC_PROVIDER_WORDS, executionLaneOf } from "./execution-lane-caps.js";
21
21
  import { AUTONOMY_MODES } from "./runtime-governance.js";
22
22
  import { createCenterCredentialSource, readCenterCredentialDeclaration } from "./center-credential.js";
23
+ import { parseCenterCredentialRenewLeadMs } from "./center-credential-renewer.js";
23
24
  import { modelProviderRefusal } from "./model-provider.js";
24
25
  import { modelRouteUrlRefusal } from "./model-route-url.js";
25
26
  import { modelRouteEndpointRefusal } from "./model-route-endpoint.js";
@@ -106,7 +107,7 @@ function defaultSkillsDir() {
106
107
  const RETIRED_ENVS = [
107
108
  ["TIDB_HOST", "MYSQL_HOST", "renamed"], ["TIDB_PORT", "MYSQL_PORT", "renamed"], ["TIDB_USER", "MYSQL_USER", "renamed"],
108
109
  ["TIDB_PASSWORD", "MYSQL_PASSWORD", "renamed"], ["TIDB_DATABASE", "MYSQL_DATABASE", "renamed"], ["TIDB_POOL_SIZE", "MYSQL_POOL_SIZE", "renamed"],
109
- ["CONFIG_CENTER_URL", "SEMA_REGISTRY_URL", "renamed"], ["CONFIG_CENTER_TOKEN", "SEMA_REGISTRY_TOKEN", "renamed"],
110
+ ["CONFIG_CENTER_URL", "SEMA_REGISTRY_URL", "renamed"], ["CONFIG_CENTER_TOKEN", CENTER_CREDENTIAL_ENV.token, "renamed"],
110
111
  ["CONFIG_CENTER_DRY_RUN", "SEMA_REGISTRY_DRY_RUN", "renamed"], ["CONFIG_CENTER_WORKER", "SEMA_REGISTRY_WORKER", "renamed"],
111
112
  ["ANTHROPIC_BASEURL", "ANTHROPIC_BASE_URL", "renamed"],
112
113
  ["REGISTRY_ADMIN_TOKEN", "SEMA_REGISTRY_ADMIN_TOKEN", "renamed"],
@@ -664,6 +665,21 @@ function optionalEnumKey(key, envName, raw, allowed) {
664
665
  }
665
666
  return { [key]: raw };
666
667
  }
668
+ function parseAutoModeClassifierTimeoutMs(raw) {
669
+ if (raw === undefined || raw === "")
670
+ return {};
671
+ const { minMs, maxMs } = AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS;
672
+ const n = /^[1-9]\d*$/.test(raw) ? Number(raw) : Number.NaN;
673
+ if (!Number.isSafeInteger(n) || n < minMs || n > maxMs) {
674
+ const shown = raw.length > 40 ? `${raw.slice(0, 40)}…(${raw.length} chars)` : raw;
675
+ throw new Error(`env AUTO_MODE_CLASSIFIER_TIMEOUT_MS=${JSON.stringify(shown)} must be a whole number of milliseconds in [${minMs}, ${maxMs}] ` +
676
+ `(decimal digits only; unset = core's own default). It caps ONE rung of the auto-mode classifier ladder — a rung past the cap ` +
677
+ `did not rule, and with no rung left the gated call is DENIED. "0" has no "off" meaning on this seat; a sub-second value is the ` +
678
+ `seconds-for-milliseconds slip; above ${maxMs} Node collapses the timer to 1ms. Refusing to boot rather than running the core ` +
679
+ `default while the operator believes their value is in force (#210).`);
680
+ }
681
+ return { autoModeClassifierTimeoutMs: n };
682
+ }
667
683
  function headerNameEnv(name, fallback) {
668
684
  const v = env(name, fallback);
669
685
  if (!/^[A-Za-z0-9_-]+$/.test(v))
@@ -1167,7 +1183,6 @@ export function formatUnmatchableToolNames(source, bad) {
1167
1183
  return (`${source} names tool(s) that can never match a live tool (these lists are RAW whole-name comparisons — core 5.0.0 RB-476 removed the alias/auto-prefix folds): ` +
1168
1184
  bad.map((b) => `${presentToolName(b.name)} → ${b.guidance}`).join("; ")).replace(CONTROL_AND_BIDI_STRIP_RE, "");
1169
1185
  }
1170
- const MAX_TIMER_DELAY_MS = 2_147_483_647;
1171
1186
  function streamApprovalConfig(gates) {
1172
1187
  const cfg = {
1173
1188
  enabled: boolEnv("STREAM_APPROVAL_ENABLED", true),
@@ -1284,6 +1299,7 @@ function parseApprovalDomain(ctx) {
1284
1299
  })(process.env.MANUAL_MODE_SHELL_GATE),
1285
1300
  permissionsDisableAutoMode: boolEnv("PERMISSIONS_DISABLE_AUTO_MODE", false),
1286
1301
  ...optionalEnumKey("autoModeClassifierFallback", "AUTO_MODE_CLASSIFIER_FALLBACK", process.env.AUTO_MODE_CLASSIFIER_FALLBACK, ["main", "none"]),
1302
+ ...parseAutoModeClassifierTimeoutMs(process.env.AUTO_MODE_CLASSIFIER_TIMEOUT_MS),
1287
1303
  ...optionalEnumKey("crossSessionInbound", "CROSS_SESSION_INBOUND", process.env.CROSS_SESSION_INBOUND, CROSS_SESSION_INBOUND_SETTINGS),
1288
1304
  ...optionalEnumKey("crossSessionDialogExpiry", "CROSS_SESSION_DIALOG_EXPIRY", process.env.CROSS_SESSION_DIALOG_EXPIRY, CROSS_SESSION_DIALOG_EXPIRY_VALUES),
1289
1305
  mcpElicitation: {
@@ -1400,6 +1416,22 @@ function lingerMsEnv(name, raw, defaultMs, disableHint) {
1400
1416
  }
1401
1417
  return n;
1402
1418
  }
1419
+ function parseDrainSecondSignalMinGapMs(raw) {
1420
+ if (raw === undefined || raw === "")
1421
+ return DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS;
1422
+ const { minMs, maxMs } = DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS;
1423
+ const n = /^(0|[1-9]\d*)$/.test(raw) ? Number(raw) : Number.NaN;
1424
+ if (!Number.isSafeInteger(n) || n < minMs || n > maxMs) {
1425
+ const shown = raw.length > 40 ? `${raw.slice(0, 40)}…(${raw.length} chars)` : raw;
1426
+ throw new Error(`env DRAIN_SECOND_SIGNAL_MIN_GAP_MS=${JSON.stringify(shown)} must be a whole number of milliseconds in [${minMs}, ${maxMs}] ` +
1427
+ `(decimal digits only; unset = ${DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS}). During a drain, a second SIGTERM / SIGINT hard-stops ` +
1428
+ `only when it arrives at least this long after the drain began — closer ones are the same beat delivered twice and are ` +
1429
+ `ignored. "0" turns that filter off (every second signal hard-stops, the 7.101.x behavior); above ${maxMs} (the ` +
1430
+ `DRAIN_GRACE_MS floor) a second signal could never count inside the shortest drain window. Refusing to boot rather ` +
1431
+ `than running the default while the operator believes their value is in force (#210).`);
1432
+ }
1433
+ return n;
1434
+ }
1403
1435
  function parseMemoryEmbedder(ctx) {
1404
1436
  const endpoint = process.env.MEMORY_EMBEDDER_ENDPOINT || undefined;
1405
1437
  const model = process.env.MEMORY_EMBEDDER_MODEL || undefined;
@@ -1926,7 +1958,7 @@ function parseOrchestrationDomain(ctx) {
1926
1958
  export const LIMITS_HTTP_FLOORS = {
1927
1959
  attachmentMaxBytes: 1024,
1928
1960
  attachmentUnboundTtlMs: 60_000,
1929
- drainGraceMs: 10_000,
1961
+ drainGraceMs: DRAIN_GRACE_FLOOR_MS,
1930
1962
  };
1931
1963
  function parseLimitsHttpDomain() {
1932
1964
  return {
@@ -1957,6 +1989,7 @@ function parseLimitsHttpDomain() {
1957
1989
  })(),
1958
1990
  },
1959
1991
  drainGraceMs: clampEnvWithWarn("DRAIN_GRACE_MS", String(10 * 60 * 1000), LIMITS_HTTP_FLOORS.drainGraceMs),
1992
+ drainSecondSignalMinGapMs: parseDrainSecondSignalMinGapMs(process.env.DRAIN_SECOND_SIGNAL_MIN_GAP_MS),
1960
1993
  sighupIdleGraceMs: clampEnvWithWarn("SIGHUP_IDLE_GRACE_MS", String(120_000), 5_000),
1961
1994
  ...parentPidEnv(),
1962
1995
  engineAutoExit: boolEnv("ENGINE_AUTO_EXIT", false),
@@ -2021,7 +2054,8 @@ function parseGitApiKind(raw, baseUrl) {
2021
2054
  }
2022
2055
  return kind;
2023
2056
  }
2024
- function parseIntegrationsDomain() {
2057
+ function parseIntegrationsDomain(ctx) {
2058
+ const { requirePrincipal } = ctx;
2025
2059
  return {
2026
2060
  pluginsAllowHosts: (process.env.PLUGINS_ALLOW_HOSTS ?? "github.com").split(",").map((h) => h.trim()).filter(Boolean),
2027
2061
  configBootFetchBudgetMs: clampEnvWithWarn("CONFIG_BOOT_FETCH_BUDGET_MS", "1500", 0),
@@ -2030,17 +2064,19 @@ function parseIntegrationsDomain() {
2030
2064
  gitApiKind: parseGitApiKind(process.env.GIT_API_KIND, process.env.GIT_API_BASEURL),
2031
2065
  defaultScenario: env("DEFAULT_SCENARIO", "code"),
2032
2066
  skillsDir: env("SKILLS_DIR", defaultSkillsDir()),
2033
- configCenter: process.env.SEMA_REGISTRY_URL ? parseConfigCenter(env("SEMA_REGISTRY_URL")) : undefined,
2067
+ centerCredentialRenewLeadMs: parseCenterCredentialRenewLeadMs(process.env.CENTER_CREDENTIAL_RENEW_LEAD_MS),
2068
+ configCenter: process.env.SEMA_REGISTRY_URL ? parseConfigCenter(env("SEMA_REGISTRY_URL"), requirePrincipal) : undefined,
2034
2069
  configProvider: process.env.CONFIG_PROVIDER || undefined,
2035
2070
  configLocalDir: process.env.CONFIG_LOCAL_DIR || undefined,
2036
2071
  a2aServe: parseA2aServe(),
2037
2072
  };
2038
2073
  }
2039
- function parseConfigCenter(baseUrl) {
2074
+ function parseConfigCenter(baseUrl, requirePrincipal) {
2040
2075
  const worker = process.env.SEMA_REGISTRY_WORKER || undefined;
2076
+ const declared = readCenterCredentialDeclaration();
2041
2077
  return {
2042
2078
  baseUrl,
2043
- credential: createCenterCredentialSource(readCenterCredentialDeclaration(), worker),
2079
+ credential: createCenterCredentialSource(declared.from === "file" ? { ...declared, multiTenant: requirePrincipal } : declared, worker),
2044
2080
  dryRun: boolEnv("SEMA_REGISTRY_DRY_RUN", false),
2045
2081
  worker,
2046
2082
  };
@@ -2141,7 +2177,7 @@ const APPROVAL_GROUP_KEYS = [
2141
2177
  "approvalRequire", "approvalDeny", "approvalTimeoutSec", "approvalAutoBudget", "approvalNeverAuto",
2142
2178
  "approvalHmacKeys", "durableApproval", "durableApprovalSource", "streamAskWindowMsSource", "directApprovalDoor", "directDoorActive", "resourceSuspend",
2143
2179
  "resourceSuspendTtlSec", "resourceSuspendTotalTokens", "resourceSuspendTotalBudgetUsd", "resourceSuspendMaxSlices", "askQuestionEnabled", "questionThrottle", "toolApprovalEnabled", "permissionRulesEnabled", "permissionRulesEnabledExplicit", "streamAskWindowMarginMs", "streamApproval", "unattendedApprovalPolicy", "mcpElicitation",
2144
- "sensitiveWritePatterns", "writeProtectedPaths", "manualModeShellGate", "permissionsDisableAutoMode", "autoModeClassifierFallback", "crossSessionInbound", "crossSessionDialogExpiry",
2180
+ "sensitiveWritePatterns", "writeProtectedPaths", "manualModeShellGate", "permissionsDisableAutoMode", "autoModeClassifierFallback", "autoModeClassifierTimeoutMs", "crossSessionInbound", "crossSessionDialogExpiry",
2145
2181
  ];
2146
2182
  const MEMORY_GROUP_KEYS = [
2147
2183
  "memoryEngineEnabled", "memoryEngineDir", "memoryEngineRemoteLaneAllowed", "memoryEngineBackend", "memoryScope",
@@ -2166,7 +2202,7 @@ const ORCHESTRATION_GROUP_KEYS = [
2166
2202
  ];
2167
2203
  const LIMITS_HTTP_GROUP_KEYS = [
2168
2204
  "port", "attachmentOrphanGraceMs", "workspaceFileMaxBytes", "attachmentMaxBytes", "attachmentMimeAllowlist",
2169
- "attachmentUnboundTtlMs", "infraCostRates", "drainGraceMs", "sighupIdleGraceMs", "parentPid",
2205
+ "attachmentUnboundTtlMs", "infraCostRates", "drainGraceMs", "drainSecondSignalMinGapMs", "sighupIdleGraceMs", "parentPid",
2170
2206
  "engineAutoExit", "engineLingerMs", "engineOrphanLingerMs",
2171
2207
  "rateLimitPerMin", "maxTaskCostUsd",
2172
2208
  "maxTaskTokens", "maxPrincipalCostUsd", "costQuotaWindowSec", "reapIntervalSec", "runStaleSec", "toolResultTtlSec",
@@ -2174,7 +2210,7 @@ const LIMITS_HTTP_GROUP_KEYS = [
2174
2210
  const OBSERVABILITY_GROUP_KEYS = ["metricsToken", "traceToken", "toolTrace", "traceThinking", "logLevel", "otel"];
2175
2211
  const INTEGRATIONS_GROUP_KEYS = [
2176
2212
  "pluginsAllowHosts", "mcpServers", "a2aPeers", "a2aServe", "configBootFetchBudgetMs", "gitApiBaseUrl", "gitApiToken", "gitApiKind", "defaultScenario",
2177
- "skillsDir", "configCenter", "configProvider", "configLocalDir",
2213
+ "skillsDir", "centerCredentialRenewLeadMs", "configCenter", "configProvider", "configLocalDir",
2178
2214
  ];
2179
2215
  export const CONFIG_GROUP_KEYS = {
2180
2216
  store: STORE_GROUP_KEYS,
@@ -2234,7 +2270,7 @@ export function loadConfig() {
2234
2270
  const orchestration = parseOrchestrationDomain({ postureOn, singleUserTurnkey });
2235
2271
  const limitsHttp = parseLimitsHttpDomain();
2236
2272
  const observability = parseObservabilityDomain();
2237
- const integrations = parseIntegrationsDomain();
2273
+ const integrations = parseIntegrationsDomain({ requirePrincipal });
2238
2274
  const { directApprovalDoor, durableApproval: dgDurable, approvalHmacKeys: dgHmacKeys } = approval;
2239
2275
  const { principalJwtPubkeys, principalJwtIss, principalJwtAud, operatorPrincipals: dgOperators, authTokens } = auth;
2240
2276
  const { enabled: imageBakesEnabled, runnerPrincipal: bakeRunnerPrincipal } = orchestration.imageBakes;
@@ -50,7 +50,7 @@ export declare const DEVICE_RATE_LIMIT_CODE: "limit.rate_exceeded";
50
50
  * 排空/停机拒绝复用**既有**的通用码(`draining`,`docs/ASSISTANT-WIRE-CONTRACT.md` 附录 A 已收录,503)。
51
51
  *
52
52
  * 🔴 刻意不铸 `device.draining`([ref]/[ref]):§4.8 的 device 闭集**冻结**且无 draining 成员,而排空
53
- * 语义面全部署早有属主词 —— HTTP 计费提交拒臂(`sendError(res, 503, "draining", …)`,SDK spec 逐字引用
53
+ * 语义面全部署早有属主词 —— HTTP 计费提交拒臂(`http/readiness.ts` 的 `billableRefusalOf` 唯一铸点,S-739 前是 `sendError(res, 503, "draining", …)`;SDK spec 逐字引用
54
54
  * 的冻结字面)、drain 期新 upgrade 拒(503)、WS hello 拒帧 / bye 帧的 `draining` 分支键。为同一语义再铸
55
55
  * `device.*` 同义码 = 消费端要认两套(上面 rate-limit 条的同一判据);且 `device.draining` 在 HTTP 层
56
56
  * 零发送点,写进附录 A 即幽灵行(`error-code-catalog-live` 的「文档多码」臂当场红)。
@@ -248,13 +248,19 @@ export type FleetFrame = {
248
248
  * durable 行压根不存在 —— 三形共同点是**没有终局可投影**,而本副本的行必然陈旧。此时造一个终态
249
249
  * 就是编:判死的属主始终是 durable 侧的 reaper。所以只陈述「这条行离场了」,不逼消费端结算。
250
250
  * (cli 侧裁量 [ref]:渲「离场」**不清行** —— 行保留、中性离场形、active 扣除、折叠排后。)
251
+ * 🔴 S-751:子代行的陈述级退场之前,对账腿先问过它的属主(bg registry 非终态 ∨ 有待决审批 ⇒ 留行);
252
+ * 本帧因此只在「静默满一周期 ∧ 属主不作保」时出现。而它**不封被退那一代**(见 {@link cycleSeq})——
253
+ * 之后同一代(或更大代)的帧到达 = 那一行其实还活着,行**复活**;更小的代照旧是前代迟到帧。
251
254
  * ADDITIVE / tolerate-absent。
252
255
  */
253
256
  removeReason?: "orphaned";
254
257
  /** [ref] §2 判死×判代合成规则:退场帧带**被退那一代**的 {@link FleetTaskRow.cycleSeq}。
255
258
  * 消费端因此能把 `retire(gen=N)` 之后的 `frame(gen=N+1)` 读成**复活**(行重生,退休不粘住新代),
256
- * 而 `retire(gen=N)` 之后的 `frame(gen=N)` 读成**前代迟到帧,忽略**。缺席同 {@link FleetTaskRow.cycleSeq}
257
- * ——「无此概念」,不是「第一代」。 */
259
+ * 而**结算级** `retire(gen=N)`(本帧无 `removeReason`)之后的 `frame(gen=N)` 读成**前代迟到帧,忽略**。
260
+ * 🔴 S-751:**陈述级**退场(`removeReason:"orphaned"`)之后的 `frame(gen=N)` = **复活**,不是迟到帧 ——
261
+ * 陈述级退场从不断言「这一代结束了」(它说的只是「本副本一个周期没听到这一行」),所以同代的下一帧
262
+ * 就是它还活着的证据(本仓 {@link FleetEventBus.removeTask} 对这一形不封代,消费端同读);更小的代照旧忽略。
263
+ * 缺席同 {@link FleetTaskRow.cycleSeq} ——「无此概念」,不是「第一代」。 */
258
264
  cycleSeq?: number;
259
265
  } | {
260
266
  type: "workflow";
@@ -452,7 +458,10 @@ export declare class FleetEventBus {
452
458
  private readonly tasks;
453
459
  private readonly workflows;
454
460
  /** [ref] / codex R1-F2:**代际墓碑** —— 已离场行的最后已知 `cycleSeq`(行本身已从 `tasks` 删掉,
455
- * 代际状态却必须活得比行久一点,否则前代迟到帧会在空 Map 上把幽灵行重铸出来)。有界 LRU。 */
461
+ * 代际状态却必须活得比行久一点,否则前代迟到帧会在空 Map 上把幽灵行重铸出来)。有界 LRU。
462
+ * 🔴 S-751:碑上多记一位 `ended` = 「**这一代结束了**」是否被断言过。离场**恒**保住代际状态(`gen` 之前的帧
463
+ * 永远是前代迟到帧 —— 与行还在 Map 里时那道闸同一个判据);只有作「这一代结束了」断言的离场(结算级 / 投影级
464
+ * 常规清场)才把 `gen` **这一代本身**也封掉。陈述级退场(`removeReason:"orphaned"`)不作这个断言 ⇒ 同代帧 = 复活。 */
456
465
  private readonly retiredGen;
457
466
  private static readonly RETIRED_GEN_MAX;
458
467
  constructor(now?: () => number);
@@ -472,7 +481,7 @@ export declare class FleetEventBus {
472
481
  /** Drop a task row (terminal + swept) + fan out the removal. Idempotent (a no-op if already gone).
473
482
  *
474
483
  * `opts`([ref],两键均 ADDITIVE):`removeReason:"orphaned"` = **陈述级退场**(没有结算语义,见
475
- * {@link FleetFrame} 的 `task_remove` 臂);`cycleSeq` = 被退那一代的代际号。发布方的常规清场
484
+ * {@link FleetFrame} 的 `task_remove` 臂;S-751 起**不封被退那一代**,同代帧 = 复活);`cycleSeq` = 被退那一代的代际号。发布方的常规清场
476
485
  * (`onTerminal`/`onChildTerminal`)不传 opts ⇒ 帧逐字节与本改动之前相同。 */
477
486
  removeTask(id: string, opts?: {
478
487
  removeReason?: "orphaned";
@@ -489,6 +498,23 @@ export declare class FleetEventBus {
489
498
  backgroundChildLaneRow(scope: string | undefined, childUuid: string): string | undefined;
490
499
  /** [ref] 让位迁移料的读面:迁移「只补缺席位」需要看得见存活行现有什么(BCE 是权威车道,不覆盖)。 */
491
500
  taskRow(id: string): Readonly<FleetTaskRow> | undefined;
501
+ /**
502
+ * S-751:一条子代行在它两只**属主账**上的键。三个 id 域互不相同,换算只有本类做得了(两种行 id 都是本文件
503
+ * 铸的,两域之间唯一的 join —— 让位登记簿 —— 也住在这里),所以换算只写这一处:
504
+ * · 行 id —— BCE 车道 = registry handle 本身(`e.taskId`);run-leg 车道 = `${runId} ${子代 task_progress taskId}`
505
+ * (`fleetRunPublisher` 的 `childId`);
506
+ * · `handle` —— core bg registry / `BackgroundAgentStore` 的行键,**只认** `DURABLE_AGENT_HANDLE_RE` 形
507
+ * (`wa*` 不在 bg 账上);
508
+ * · `sessionIds` —— 子代自己的 core session id(uuid 域)= 待决审批 `originTaskId` 的值域
509
+ * (core `AskRequest.sourceTaskId`:「the issuing task's session id」),**不是** handle。是**集合**:同一行名下
510
+ * 可以先后挂过几个子代 session(`wa*` 重试换 session、复活换 session),让位登记簿对每一个都留着 claim ——
511
+ * 只取其一(此前取最早那个)会漏掉当前这一次的卡(codex r1 F3)。
512
+ * 换不出的键就缺席 / 空(不猜):父链接行 / 顶层 run 行两者皆无;BCE 行在本车道学到子代 uuid 之前 `sessionIds` 为空。
513
+ */
514
+ childOwnerKeys(row: Pick<FleetTaskRow, "id" | "scope">): {
515
+ handle?: string;
516
+ sessionIds: readonly string[];
517
+ };
492
518
  /** Upsert a workflow row (MERGE by `id`) + fan out. */
493
519
  publishWorkflow(delta: Partial<FleetWorkflowRow> & {
494
520
  id: string;
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
- import { deriveAgentDisplayStatus, isDelegatedAgentTerminal } from "@sema-agent/core";
2
+ import { DURABLE_AGENT_HANDLE_RE, deriveAgentDisplayStatus, isDelegatedAgentTerminal } from "@sema-agent/core";
3
3
  import { persistedPlaneOf } from "../terminal.js";
4
4
  import { recordFailOpen } from "../observability/fail-open.js";
5
5
  import { redactSecrets } from "../trace/redact.js";
@@ -74,7 +74,7 @@ export class FleetEventBus {
74
74
  return;
75
75
  if (existing === undefined && delta.retiredBy === undefined) {
76
76
  const tomb = this.retiredGen.get(delta.id);
77
- if (tomb !== undefined && delta.cycleSeq !== undefined && delta.cycleSeq <= tomb)
77
+ if (tomb !== undefined && delta.cycleSeq !== undefined && (delta.cycleSeq < tomb.gen || (tomb.ended && delta.cycleSeq === tomb.gen)))
78
78
  return;
79
79
  }
80
80
  const merged = { ...(existing ?? { id: delta.id, status: "running" }), ...delta };
@@ -97,10 +97,12 @@ export class FleetEventBus {
97
97
  if (existing !== undefined && opts?.cycleSeq !== undefined && existing.cycleSeq !== undefined && existing.cycleSeq > opts.cycleSeq)
98
98
  return;
99
99
  if (this.tasks.delete(id)) {
100
- const gen = existing?.cycleSeq ?? opts?.cycleSeq;
100
+ const prior = this.retiredGen.get(id);
101
+ const gen = existing?.cycleSeq ?? opts?.cycleSeq ?? prior?.gen;
101
102
  if (gen !== undefined) {
103
+ const ended = opts?.removeReason !== "orphaned" || (prior !== undefined && prior.gen === gen && prior.ended);
102
104
  this.retiredGen.delete(id);
103
- this.retiredGen.set(id, gen);
105
+ this.retiredGen.set(id, { gen, ended });
104
106
  if (this.retiredGen.size > FleetEventBus.RETIRED_GEN_MAX) {
105
107
  const oldest = this.retiredGen.keys().next();
106
108
  if (!oldest.done)
@@ -138,6 +140,20 @@ export class FleetEventBus {
138
140
  taskRow(id) {
139
141
  return this.tasks.get(id);
140
142
  }
143
+ childOwnerKeys(row) {
144
+ const sp = row.id.lastIndexOf(" ");
145
+ if (sp > 0) {
146
+ const sessionId = row.id.slice(sp + 1);
147
+ const claimed = this.bceClaims.get(FleetEventBus.bceKey(row.scope, sessionId));
148
+ return { sessionIds: [sessionId], ...(claimed !== undefined && DURABLE_AGENT_HANDLE_RE.test(claimed) ? { handle: claimed } : {}) };
149
+ }
150
+ const prefix = FleetEventBus.bceKey(row.scope, "");
151
+ const sessionIds = [];
152
+ for (const [k, rowId] of this.bceClaims)
153
+ if (rowId === row.id && k.startsWith(prefix))
154
+ sessionIds.push(k.slice(prefix.length));
155
+ return { sessionIds, ...(DURABLE_AGENT_HANDLE_RE.test(row.id) ? { handle: row.id } : {}) };
156
+ }
141
157
  publishWorkflow(delta) {
142
158
  const merged = { ...(this.workflows.get(delta.id) ?? { id: delta.id, status: "running" }), ...delta };
143
159
  this.workflows.set(delta.id, merged);