@sema-agent/server 7.53.0 → 7.54.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 (47) hide show
  1. package/USAGE.md +5 -0
  2. package/dist/boot/config-center.js +2 -2
  3. package/dist/boot/leader.js +2 -0
  4. package/dist/boot/resolve-spec.js +1 -3
  5. package/dist/boot/runner-deps.d.ts +20 -0
  6. package/dist/boot/runner-deps.js +14 -8
  7. package/dist/boot/shutdown.js +1 -1
  8. package/dist/capabilities/team.d.ts +10 -9
  9. package/dist/config-center/apply-effective.d.ts +25 -26
  10. package/dist/config-center/apply-effective.js +28 -66
  11. package/dist/config-center/effective-keys.d.ts +30 -0
  12. package/dist/config-center/effective-keys.js +49 -0
  13. package/dist/config-center/facade.d.ts +3 -3
  14. package/dist/config-center/facade.js +1 -1
  15. package/dist/config-center/restart-signal.js +3 -9
  16. package/dist/config-center/types.d.ts +19 -40
  17. package/dist/config-lkg.js +7 -1
  18. package/dist/config-provider.js +1 -1
  19. package/dist/device-enrollment.d.ts +5 -3
  20. package/dist/device-store.d.ts +15 -1
  21. package/dist/device-store.js +2 -0
  22. package/dist/device-ws-hub.d.ts +7 -2
  23. package/dist/device-ws-hub.js +25 -5
  24. package/dist/http/server.js +19 -0
  25. package/dist/http/wire-types.d.ts +19 -0
  26. package/dist/leader/wire.d.ts +12 -0
  27. package/dist/leader/wire.js +6 -5
  28. package/dist/observability/fail-open.d.ts +16 -0
  29. package/dist/observability/fail-open.js +16 -0
  30. package/dist/plugins/pg-pool.d.ts +29 -2
  31. package/dist/plugins/pg-pool.js +45 -2
  32. package/dist/plugins/remote-env-device.d.ts +4 -2
  33. package/dist/plugins/remote-env-device.js +4 -1
  34. package/dist/plugins/roster-store-sql.d.ts +1 -3
  35. package/dist/plugins/store-backend.js +1 -1
  36. package/dist/plugins/tidb-pool.d.ts +13 -0
  37. package/dist/runtime-governance.d.ts +4 -0
  38. package/dist/runtime-governance.js +23 -1
  39. package/dist/tool-approval.d.ts +21 -28
  40. package/dist/tool-approval.js +220 -98
  41. package/dist/trace/engine-notice-wire.d.ts +1 -1
  42. package/dist/trace/engine-notice-wire.js +2 -0
  43. package/dist/trace/injection-tier.d.ts +11 -21
  44. package/dist/trace/injection-tier.js +3 -4
  45. package/dist/trace/project.d.ts +1 -0
  46. package/dist/trace/project.js +1 -0
  47. package/package.json +3 -3
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * config-center wire types — the config-center `/api/config/effective` payload shapes (`CenterModel`,
3
- * `CenterTeam`, `CenterSkillManifest`, `CenterMcpServer`, `EffectiveConfig`) plus the per-principal
3
+ * `CenterSkillManifest`, `CenterMcpServer`, `CenterA2aPeer`, `EffectiveConfig`) plus the per-principal
4
4
  * execution/session-mirror ruling shapes riding the caps view (`ExecutionRuling`/`SessionMirrorRuling`).
5
5
  * Pure type/interface declarations — no runtime logic. Split out of `facade.ts` ([ref] A13,
6
6
  * internal-lossless: the facade re-exports every symbol below unchanged).
@@ -71,24 +71,6 @@ export type CenterRoleTarget = {
71
71
  } | {
72
72
  select: unknown;
73
73
  };
74
- export interface CenterTeam {
75
- name: string;
76
- members: {
77
- role: string;
78
- modelRole?: string;
79
- model?: string;
80
- systemPrompt?: string;
81
- }[];
82
- rounds: number;
83
- synthesizer?: {
84
- role: string;
85
- modelRole?: string;
86
- systemPrompt?: string;
87
- };
88
- scenario?: string;
89
- maxTranscriptTokens?: number;
90
- enabled?: boolean;
91
- }
92
74
  /** Skill MANIFEST entry (content is NOT inline — fetched by hash; see `fetchSkillContent`). */
93
75
  export interface CenterSkillManifest {
94
76
  name: string;
@@ -250,14 +232,8 @@ export interface EffectiveConfig {
250
232
  tierGroups?: unknown;
251
233
  activeTierGroup?: string;
252
234
  };
253
- /** OPTIONAL since registry-core 0.7 deleted the teams domain: a 0.7+ center /effective payload
254
- * has NO teams key (collab is its successor). Absent/empty registerTeams resets to BUILTIN_TEAMS (the
255
- * permanent builtin base). */
256
- teams?: {
257
- teams: CenterTeam[];
258
- };
259
- /** registry-core 0.7 collab domain (teams' successor) — consumed by the slice-① read-only
260
- * projection (projectCollabToTeams → registerTeams). Both lanes carry it: the remote worker /effective view
235
+ /** registry-core 0.7 collab domain (teams' successor) consumed by the slice-1.5 projection
236
+ * (projectCollabToWorkflows registerCollabWorkflows). Both lanes carry it: the remote worker /effective view
261
237
  * ships collab, and local FileConfigStore reads config.d/collab.json (PORTABLE_DOMAINS includes collab). */
262
238
  collab?: {
263
239
  templates: CollabTemplateWire[];
@@ -285,22 +261,23 @@ export interface EffectiveConfig {
285
261
  enabled?: boolean;
286
262
  }>;
287
263
  };
288
- /** Fleet governance/limit gates (phase-2, center SERVICE-INTEGRATION「Runtime 治理/限额闸」). All restart-to-apply,
289
- * non-secret, all center `.optional()` (center `f95438a`): absent = unmanaged → keep env; present (incl. an
290
- * explicit 0/[] = "publish OFF") = override. Consumption precedence is in `applyRuntimeGates`. */
264
+ /** `runtime` = **限额残余五键**,与 settings-schema `RuntimeConfig` 的键集逐字相等([ref] 件3 对账门
265
+ * test/settings-schema-consumption-gates.test.ts dist `RuntimeConfig.shape` 钉集合等式)。全部 center
266
+ * `.optional()`(center `f95438a`):缺席 = 不管 → 保 env;在场(含显式 0 = "publish OFF")= 覆盖。
267
+ * [ref] 批1 起五键全是**热**键(属主 stage-limits,boot+refresh 双腿);`limits` 域是它们的继任发布位
268
+ * (两处同现 ⇒ limits 赢,见 {@link EffectiveConfig.limits})。
269
+ *
270
+ * 🔴 治理三件(autonomy / commandPolicy / approvalRequire)**不在这里**([ref] 件3 退役键清除):它们自
271
+ * settings-schema 0.6 起住 `governance` 域,0.7.1 撤了 runtime 位的双写镜像;此后 `RuntimeConfig` 是 strip
272
+ * 的 zod object,两条腿(远端 `readEffectiveWire` / 本地 FileConfigStore→`buildEffective`)都把旧位 strip
273
+ * 掉、由 `legacyGovernanceFromRuntime` 抬进 governance ⇒ 本仓在 runtime 位上读它们的每一行都是死码。
274
+ * 旧类型面上留着这三键,让 `runtime-gates` 重启片一直读着一个生产上恒缺席的位(审批闸收紧静默不生效)。 */
291
275
  runtime?: {
292
276
  rateLimitPerMin?: number;
293
- approvalRequire?: string[];
294
277
  maxTaskCostUsd?: number;
295
278
  maxTaskTokens?: number;
296
279
  maxPrincipalCostUsd?: number;
297
280
  costQuotaWindowSec?: number;
298
- /** Runtime governance second baton (center §10): autonomy mode + per-command rules. UNLIKE the 6 gates above,
299
- * these are per-request HOT (read live in resolveSpec, not baked into boot middleware) → applied on boot AND
300
- * refresh by `applyRuntimeHot` (NOT in RUNTIME_GATE_KEYS / restart-to-apply). Same presence rule (undefined =
301
- * unmanaged → keep). */
302
- autonomy?: Autonomy;
303
- commandPolicy?: CommandRule[];
304
281
  };
305
282
  /**
306
283
  * [ref] 批1 —— **limitsHttp 组的热更新发布域**([ref] §6 批1)。
@@ -330,9 +307,11 @@ export interface EffectiveConfig {
330
307
  projects?: {
331
308
  projects?: Record<string, unknown>;
332
309
  };
333
- /** registry-core 0.6:治理三件(autonomy/commandPolicy/approvalRequire)拆新 governance 域=真值;
334
- * 过渡期 effective 在 runtime 位双写镜像。读取优先 governance 新位、runtime 旧位 fallback(顺序契约:
335
- * 消费端先切换、确认后 center 才撤双写,治理闸零 fail-open 窗口)。 */
310
+ /** settings-schema 0.6 起治理三件(autonomy/commandPolicy/approvalRequire)的**唯一**承运域(0.7.1
311
+ * runtime 位双写镜像;顺序契约当年已走完:消费端先切、center 再撤)。键集与 schema `GovernanceConfig.shape`
312
+ * 逐字相等([ref] 件3 对账门钉)。消费:autonomy/commandPolicy 逐请求热(`stageRuntimeHot`,boot+refresh);
313
+ * approvalRequire boot-baked(`stageApprovalGate`,refresh 不写)⇒ 变更由 `runtime-gates` 重启片通告。
314
+ * 同一 presence 规则(undefined = 不管 → 保 env / 回基线)。 */
336
315
  governance?: {
337
316
  autonomy?: Autonomy;
338
317
  commandPolicy?: CommandRule[];
@@ -47,7 +47,7 @@ export async function loadLkg(path, worker) {
47
47
  const eff = p.effective;
48
48
  if (typeof eff.version !== "number")
49
49
  return { reason: "bad effective.version" };
50
- for (const [domain, arrayKey] of [["models", "models"], ["teams", "teams"], ["skills", "skills"], ["mcp", "servers"], ["scenarios", "scenarios"], ["runtime", null]]) {
50
+ for (const [domain, arrayKey] of [["models", "models"], ["skills", "skills"], ["mcp", "servers"], ["scenarios", "scenarios"], ["runtime", null]]) {
51
51
  const v = eff[domain];
52
52
  if (v === undefined)
53
53
  continue;
@@ -59,6 +59,12 @@ export async function loadLkg(path, worker) {
59
59
  return { reason: `bad effective.${domain}.${arrayKey}` };
60
60
  }
61
61
  }
62
+ {
63
+ const rt = eff.runtime;
64
+ if (rt && (rt.autonomy !== undefined || rt.commandPolicy !== undefined || rt.approvalRequire !== undefined)) {
65
+ return { reason: "legacy runtime governance keys (pre-0.7.1 mirror shape; no contract producer since LKG shipped) — refusing rather than silently dropping governance" };
66
+ }
67
+ }
62
68
  const promptsRaw = p.effective.prompts;
63
69
  if (promptsRaw !== undefined && !validatePromptsDomain(promptsRaw).ok)
64
70
  return { reason: "prompts face fails re-validation" };
@@ -126,8 +126,8 @@ export function mapToServiceEffective(eff, version) {
126
126
  ...(eff.models.tierGroups !== undefined ? { tierGroups: eff.models.tierGroups } : {}),
127
127
  ...(eff.models.activeTierGroup !== undefined ? { activeTierGroup: eff.models.activeTierGroup } : {}),
128
128
  },
129
- teams: { teams: [] },
130
129
  ...(eff.collab?.templates?.length ? { collab: { templates: eff.collab.templates } } : {}),
130
+ ...(eff.plugins?.plugins?.length ? { plugins: { plugins: eff.plugins.plugins } } : {}),
131
131
  skills: {
132
132
  skills: (eff.skills.skills ?? []).map((s) => ({
133
133
  name: s.name,
@@ -23,13 +23,15 @@
23
23
  * 判定);②吊销同事务把连接租约作废并把 `conn_generation` 推进一格 ⇒ 在途连接的每一次带世代谓词的写
24
24
  * 都会影响 0 行,设备侧因此立刻失权(§4.6 步 2);③重新 enrollment = 新 deviceId,不复活旧行。
25
25
  */
26
- import { DEVICE_RATE_LIMIT_CODE, type DeviceErrorCode, type DeviceOwner, type DeviceRow, type DeviceSessionRow, type DeviceStore, type NewDeviceInput } from "./device-store.js";
26
+ import { type DeviceOwner, type DeviceRejectCode, type DeviceRow, type DeviceSessionRow, type DeviceStore, type NewDeviceInput } from "./device-store.js";
27
27
  /** typed 拒绝 —— 端点层只负责把它翻成 HTTP(status 已在体内,不许调用方另算)。 */
28
28
  export interface DeviceRejection {
29
- code: DeviceErrorCode | typeof DEVICE_RATE_LIMIT_CODE;
29
+ /** [ref]:含两个借用位(`limit.rate_exceeded` 429 / `draining` 503);enrollment 本文件只铸前者,
30
+ * `draining` 的唯一铸造点在 device-ws-hub 的排空/停机两臂。 */
31
+ code: DeviceRejectCode;
30
32
  status: number;
31
33
  message: string;
32
- /** 限流专用:`Retry-After` 的秒值(附录 A 的 429 族纪律)。 */
34
+ /** 限流/排空专用:`Retry-After` 的秒值(附录 A 的 429/503 族纪律)。 */
33
35
  retryAfterSec?: number;
34
36
  }
35
37
  export type DeviceOutcome<T> = {
@@ -39,8 +39,22 @@ export type DeviceErrorCode = (typeof DEVICE_ERROR_CODES)[keyof typeof DEVICE_ER
39
39
  * 同义码 = 消费端要认两套。`device.busy`(429)留给**在途指令数**上限,不是这里。
40
40
  */
41
41
  export declare const DEVICE_RATE_LIMIT_CODE: "limit.rate_exceeded";
42
+ /**
43
+ * 排空/停机拒绝复用**既有**的通用码(`draining`,`docs/ASSISTANT-WIRE-CONTRACT.md` 附录 A 已收录,503)。
44
+ *
45
+ * 🔴 刻意不铸 `device.draining`([ref]/[ref]):§4.8 的 device 闭集**冻结**且无 draining 成员,而排空
46
+ * 语义面全部署早有属主词 —— HTTP 计费提交拒臂(`sendError(res, 503, "draining", …)`,SDK spec 逐字引用
47
+ * 的冻结字面)、drain 期新 upgrade 拒(503)、WS hello 拒帧 / bye 帧的 `draining` 分支键。为同一语义再铸
48
+ * `device.*` 同义码 = 消费端要认两套(上面 rate-limit 条的同一判据);且 `device.draining` 在 HTTP 层
49
+ * 零发送点,写进附录 A 即幽灵行(`error-code-catalog-live` 的「文档多码」臂当场红)。
50
+ * 与 `device.busy` 的分工自此可判:`device.busy`(429)= 在途满,稍后重试**同设备**;`draining`(503)=
51
+ * **本副本**在退,重试同副本不会好 —— 该去替换副本/等设备重连。
52
+ */
53
+ export declare const DEVICE_DRAINING_CODE: "draining";
54
+ /** device 拒绝面的码全集 = §4.8 闭集 + 两个借用位(限流 429 / 排空 503)。 */
55
+ export type DeviceRejectCode = DeviceErrorCode | typeof DEVICE_RATE_LIMIT_CODE | typeof DEVICE_DRAINING_CODE;
42
56
  /** 拒绝码 → HTTP 状态。**穷举 map**(缺一个成员 = 编译错误),不是运行时 switch 的默认臂。 */
43
- export declare const DEVICE_ERROR_STATUS: Readonly<Record<DeviceErrorCode | typeof DEVICE_RATE_LIMIT_CODE, number>>;
57
+ export declare const DEVICE_ERROR_STATUS: Readonly<Record<DeviceRejectCode, number>>;
44
58
  /**
45
59
  * `device_audit.event` 的闭集(v2 §6 DDL 的 CHECK 清单 + §4.8「标审计行者」,两处逐字对齐)。
46
60
  *
@@ -12,6 +12,7 @@ export const DEVICE_ERROR_CODES = {
12
12
  NOT_FOUND: "not_found.device",
13
13
  };
14
14
  export const DEVICE_RATE_LIMIT_CODE = "limit.rate_exceeded";
15
+ export const DEVICE_DRAINING_CODE = "draining";
15
16
  export const DEVICE_ERROR_STATUS = {
16
17
  "device.not_bound": 409,
17
18
  "device.offline": 409,
@@ -24,6 +25,7 @@ export const DEVICE_ERROR_STATUS = {
24
25
  "device.enrollment_invalid": 403,
25
26
  "not_found.device": 404,
26
27
  "limit.rate_exceeded": 429,
28
+ "draining": 503,
27
29
  };
28
30
  export const DEVICE_AUDIT_EVENTS = [
29
31
  "device_enrolled",
@@ -162,8 +162,13 @@ export interface DeviceWsHub {
162
162
  dispatch(input: DeviceDispatchInput): Promise<DeviceDispatchResult>;
163
163
  /** 幂等:重复 cancel 同一指令 = no-op(§5.3)。 */
164
164
  cancel(instructionId: string): Promise<void>;
165
- /** §8-R7:停发新指令 + 拒新 upgrade,但**保持既有 WS**,在途结果帧照收。 */
166
- beginDrain(): void;
165
+ /** §8-R7:停发新指令 + 拒新 upgrade,但**保持既有 WS**,在途结果帧照收。
166
+ * [ref]:装配层可带 `graceMs`(= shutdown 的 drainGraceMs 上界)——排空拒体借它给出「drain 窗最迟
167
+ * 何时收窗」的指路;getter 形每次活读(drainGraceMs 是热改键,一次性快照会在窗中热调后失真,
168
+ * codex-S09-F2);不带 = 拒体不造数(测试宿主/嵌入形没有这个事实)。 */
169
+ beginDrain(opts?: {
170
+ graceMs?: number | (() => number);
171
+ }): void;
167
172
  isDraining(): boolean;
168
173
  connectedDeviceIds(): string[];
169
174
  /**
@@ -1,10 +1,11 @@
1
1
  import http from "node:http";
2
2
  import { createHash } from "node:crypto";
3
3
  import { WebSocketServer } from "ws";
4
- import { DEVICE_ERROR_CODES, DEVICE_ERROR_STATUS, assertDeviceIdShape, } from "./device-store.js";
4
+ import { DEVICE_DRAINING_CODE, DEVICE_ERROR_CODES, DEVICE_ERROR_STATUS, assertDeviceIdShape, } from "./device-store.js";
5
5
  import { DEVICE_MIN_PROTOCOL_VERSION, DEVICE_WS_LIMITS, DEVICE_WS_PATH, DEVICE_PROTOCOL_VERSION, asInstructionKind, buildHelloSignaturePayload, formatGeneration, mintChallengeNonce, mintInstructionId, parseDeviceFrameText, verifyHelloSignature, } from "./device-ws-protocol.js";
6
6
  const OUTCOME_UNKNOWN_SENTENCE = "outcome unknown — the command may have already executed on the device; verify its effect before re-running";
7
7
  const SETTLED_MEMO_MAX = 512;
8
+ const DRAIN_RETRY_AFTER_SEC = 15;
8
9
  function rejection(code, message, retryAfterSec) {
9
10
  return { code, status: DEVICE_ERROR_STATUS[code], message, ...(retryAfterSec === undefined ? {} : { retryAfterSec }) };
10
11
  }
@@ -73,6 +74,8 @@ export function createDeviceWsHub(options) {
73
74
  const settledMemo = new Map();
74
75
  let draining = false;
75
76
  let closed = false;
77
+ let drainGraceMs;
78
+ let drainStartMono;
76
79
  function auditFrame(deviceId, owner, reason, detail) {
77
80
  metrics?.inc("device_frame_unexpected_total", { reason });
78
81
  void store
@@ -775,6 +778,12 @@ export function createDeviceWsHub(options) {
775
778
  }
776
779
  sendServerFrame(conn, { t: "payload", gen: conn.genStr, instructionId, part: "commit", totalLen: body.length, sha256 });
777
780
  }
781
+ function drainingRejectResult() {
782
+ const graceNow = typeof drainGraceMs === "function" ? drainGraceMs() : drainGraceMs;
783
+ const remainMs = graceNow === undefined || drainStartMono === undefined ? undefined : Math.max(0, graceNow - (performance.now() - drainStartMono));
784
+ const drainHint = remainMs === undefined ? "" : `; the drain window ends within ~${Math.ceil(remainMs / 1000)}s`;
785
+ return { kind: "rejected", reject: rejection(DEVICE_DRAINING_CODE, "this replica is draining for shutdown/upgrade and no longer issues new device instructions — the instruction was never dispatched (safe to retry once the device reconnects on a replacement replica)" + drainHint, DRAIN_RETRY_AFTER_SEC) };
786
+ }
778
787
  async function dispatch(input) {
779
788
  if (input.payloadPartBytes !== undefined) {
780
789
  const n = input.payloadPartBytes;
@@ -793,9 +802,9 @@ export function createDeviceWsHub(options) {
793
802
  if (abortedNow())
794
803
  return abortedBeforeCommit();
795
804
  if (closed)
796
- return { kind: "rejected", reject: rejection(DEVICE_ERROR_CODES.BUSY, "this device hub is shutting down", 1) };
805
+ return { kind: "rejected", reject: rejection(DEVICE_DRAINING_CODE, "this device hub is closed for shutdown — the instruction was never dispatched (safe to retry once the device reconnects on a replacement replica)", DRAIN_RETRY_AFTER_SEC) };
797
806
  if (draining)
798
- return { kind: "rejected", reject: rejection(DEVICE_ERROR_CODES.BUSY, "this replica is draining for shutdown/upgrade and no longer issues new device instructions", 1) };
807
+ return drainingRejectResult();
799
808
  const admitted = await enrollment.admitSession({ rootSessionId: input.rootSessionId, requester: input.requester });
800
809
  if (!admitted.ok)
801
810
  return { kind: "rejected", reject: admitted.reject };
@@ -820,12 +829,18 @@ export function createDeviceWsHub(options) {
820
829
  }
821
830
  const granted = await acquireSlot(deviceId);
822
831
  if (!granted) {
832
+ if (closed || draining)
833
+ return drainingRejectResult();
823
834
  return { kind: "rejected", reject: rejection(DEVICE_ERROR_CODES.BUSY, `the device already has ${maxInflightPerDevice} instructions in flight`, 1) };
824
835
  }
825
836
  if (abortedNow()) {
826
837
  releaseSlot(deviceId);
827
838
  return abortedBeforeCommit();
828
839
  }
840
+ if (closed) {
841
+ releaseSlot(deviceId);
842
+ return drainingRejectResult();
843
+ }
829
844
  {
830
845
  const drift = driftNow();
831
846
  if (drift.length > 0) {
@@ -975,9 +990,14 @@ export function createDeviceWsHub(options) {
975
990
  deliverCancel(p);
976
991
  return Promise.resolve();
977
992
  }
978
- function beginDrain() {
993
+ function beginDrain(opts) {
994
+ if (!draining) {
995
+ drainStartMono = performance.now();
996
+ drainGraceMs = opts?.graceMs;
997
+ }
979
998
  draining = true;
980
- logger?.info?.("device_hub_draining", { connections: conns.size, pending: pending.size });
999
+ const graceNow = typeof drainGraceMs === "function" ? drainGraceMs() : drainGraceMs;
1000
+ logger?.info?.("device_hub_draining", { connections: conns.size, pending: pending.size, ...(graceNow === undefined ? {} : { graceMs: graceNow }) });
981
1001
  }
982
1002
  async function close() {
983
1003
  closed = true;
@@ -1079,6 +1079,25 @@ export function createHttpServer(rawDeps) {
1079
1079
  },
1080
1080
  };
1081
1081
  }
1082
+ const s57RowCallId = cp.pendingAction.kind === "tool_approval" ? cp.pendingAction.toolCallId : undefined;
1083
+ if (binding?.boundCallId !== undefined && s57RowCallId && binding.boundCallId !== s57RowCallId) {
1084
+ let s57CurrentPending;
1085
+ if (decider !== undefined && cp.status === "pending" && cp.sessionId === sessionId && cp.pendingAction.kind === "tool_approval") {
1086
+ s57CurrentPending = {
1087
+ toolName: cp.pendingAction.toolName,
1088
+ boundCallId: s57RowCallId,
1089
+ ...(cp.pendingAction.boundInputHash ? { boundInputHash: cp.pendingAction.boundInputHash } : {}),
1090
+ };
1091
+ }
1092
+ return {
1093
+ status: 409,
1094
+ body: {
1095
+ error: "the approval you are deciding is no longer current (already resolved or superseded since you viewed it)",
1096
+ errorCode: "approval_stale",
1097
+ ...(s57CurrentPending ? { currentPending: s57CurrentPending } : {}),
1098
+ },
1099
+ };
1100
+ }
1082
1101
  const paForGrant = cp.pendingAction.kind === "tool_approval" ? cp.pendingAction : undefined;
1083
1102
  const loadedGrantTool = paForGrant !== undefined && paForGrant.toolName && paForGrant.toolName !== "AskUserQuestion"
1084
1103
  ? paForGrant.toolName
@@ -350,6 +350,25 @@ export interface TaskRequestBody {
350
350
  * through the same way: a boolean rides, absent/garbage ⇒ key omitted (core's default = interactive). Rides the
351
351
  * persisted body onto resume legs. */
352
352
  oneShot?: boolean;
353
+ /** [ref]①/[ref]② (core 1.296 three-state knob): per-request mount toggle for the interactive ask tools —
354
+ * `false` unmounts AskUserQuestion for this submission (the headless `-p` posture; `oneShot` above is its
355
+ * sibling, same passthrough), `true` forces the mount, absent = core's own default (delivery-face probing).
356
+ * A boolean rides the spec unchanged (boot/resolve-spec.ts); a present non-boolean is a fail-loud 400 on
357
+ * submit (server.ts prepareSpec), and resume legs replay the persisted body through the same defensive
358
+ * typeof read. Declared per [ref] 件1 ([ref] boundary-must-schema): the submit chain reads this key, and an
359
+ * undeclared read is a bare cast the exported type cannot audit (embedders could not even write it). */
360
+ interactiveTools?: boolean;
361
+ /** The caller's per-request settings stamp (the client-side `SemaSettings` shape). The KEY SET is deliberately
362
+ * NOT mirrored here — a hand-written second shape on this interface would be the classic mirror-drift form.
363
+ * Per-key ownership is split, not single: `parseTaskSettings` (src/task-settings.ts) defensively owns the
364
+ * shared settings keys on every path, while `settings.webSearch` has its OWN validator on the single-user
365
+ * scenario lane (capabilities/scenarios.ts `webSearchConfigFromSettings` — the multi-tenant lane ignores it
366
+ * by design). The declared type is the INTENDED caller form (sibling convention: `oneShot` declares boolean,
367
+ * runtime drops garbage): a non-array object bag, or omit the key. Runtime is more tolerant than the type on
368
+ * purpose — a `null` reads as absence on both the fresh-submit wall (prepareSpec) and replay
369
+ * (parseTaskSettings), and malformed forms 400 on submit / no-op on replay. ([ref] 件1 leftover key,
370
+ * declared 2026-08-31; ownership wording per codex adversarial round.) */
371
+ settings?: Record<string, unknown>;
353
372
  /** [R3] Caller-supplied per-request MCP servers (the TOC client's local `.mcp.json`), aligned to core
354
373
  * `McpServerSpec`. 🔒 honored on any SINGLE-USER deployment (task-mcp.ts `mcpInjectionHonored` = `requirePrincipal!==true`)
355
374
  * — the requester is the super-admin of their OWN worker (CC-parity), on ANY execution lane (the stdio MCP runs on the
@@ -1,6 +1,7 @@
1
1
  import { type Brain, type Model, type ModelRoles, type ModelPricing, type TaskSpec, type ExecutionEnvFactory, type RemoteExecutionEnv, type ToolResultStore, type SessionStore, type RunnerDeps } from "@sema-agent/core";
2
2
  import type { CheckpointStoreFull } from "../plugins/store-backend.js";
3
3
  import { type LeaderDeps, type LeaderResult } from "./leader.js";
4
+ import { type DeploymentPostureSeats } from "../boot/runner-deps.js";
4
5
  import type { LeaderRequestBody } from "./endpoint.js";
5
6
  export interface LeaderWireConfig {
6
7
  /** Static-env compat mode (E2B only): the leader provisions/owns each env. Required when `envFactory` unset. */
@@ -36,6 +37,17 @@ export interface LeaderWireConfig {
36
37
  * 搬过来:一个进程里「这台部署禁哪些能力、锁了哪些键」只能有一个答案。缺席 = 与接线前逐字相同。
37
38
  */
38
39
  governance?: Pick<RunnerDeps, "compliancePostureResolver" | "lockedConfig" | "retentionPolicy">;
40
+ /**
41
+ * [ref](黑板 [ref])—— **部署姿态座席族**(readFace 四席 `readFace`/`readDenyPatterns`/`readDenyBuiltinTiers`/
42
+ * `readDenyBuiltinExclude` + `memoryDelegationEvidence`/`memoryProvenance`/`memoryCapturePolicy`/
43
+ * `delegationEntryCaps`),**原样递给本车道的每一只 Runner**。唯一属主 = `boot/runner-deps.ts` 的
44
+ * `buildDeploymentPostureSeats(config)`(主 runner / subRunner / run-local 的共享基座展开的就是它),
45
+ * `boot/leader.ts` 把**同一函数、同一 config** 的产物搬过来:一个进程里「这台部署的 READ 面 / 记忆姿态 /
46
+ * 委派 caps」只能有一个答案。缺席 = 展开空对象 = 五处字面量键缺席(与接线前逐字相同)。
47
+ * 修前(≤7.53)这五只 Runner 一席都没有:运维写的 READ_DENY_PATTERNS / MEMORY_DELEGATION_EVIDENCE 等对
48
+ * leader 编排的 planner / worker / repair / conflict 四类任务**静默不生效**(core 内建 deny 表仍在)。
49
+ */
50
+ deploymentPosture?: DeploymentPostureSeats;
39
51
  /** Worker model brain + catalog (the same the service runs tasks on). */
40
52
  brain: Brain;
41
53
  models: Record<string, Model>;
@@ -129,6 +129,7 @@ export function createLeaderRunner(cfg) {
129
129
  const { repairRounds, repairBudgetUsd, conflictRounds, repairLoopOn, measureGatesOn, repairLoopAttempts, oracleFlakyK, replanBudgetUsd, } = leaderLoopConfig({ repairRounds: cfg.repairRounds, conflictRounds: cfg.conflictRounds });
130
130
  const onNoticeSeat = createEngineNoticeSeat(cfg.logger);
131
131
  const governanceSeat = cfg.governance ?? {};
132
+ const deploymentPostureSeat = cfg.deploymentPosture ?? {};
132
133
  const mkRepair = (rawEnv, repoDir, oracleFiles) => {
133
134
  if (repairRounds <= 0)
134
135
  return undefined;
@@ -142,7 +143,7 @@ export function createLeaderRunner(cfg) {
142
143
  throw new Error(`oracle still present after remove (${f.path}) — refusing repair (measurement integrity)`);
143
144
  }
144
145
  try {
145
- const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, executionEnv: rawEnv, rootPath: repoDir });
146
+ const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, ...deploymentPostureSeat, executionEnv: rawEnv, rootPath: repoDir });
146
147
  const objective = [
147
148
  `The integrated project at ${repoDir} fails its build/test. Make the MINIMAL change to the working tree so this command exits 0 (cd into the repo and run it yourself to confirm):`,
148
149
  ` ${testCmd}`,
@@ -185,7 +186,7 @@ export function createLeaderRunner(cfg) {
185
186
  throw new Error(`oracle still present after remove (${f.path}) — refusing conflict-resolve (measurement integrity)`);
186
187
  }
187
188
  try {
188
- const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, executionEnv: rawEnv, rootPath: repoDir });
189
+ const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, ...deploymentPostureSeat, executionEnv: rawEnv, rootPath: repoDir });
189
190
  const objective = [
190
191
  `A parallel worker '${workerId}' ported a module on its own branch, but applying its patch onto the already-integrated tree at ${repoDir} produced a MERGE CONFLICT (git apply --3way). The OTHER workers' patches already applied cleanly — integrate THIS worker's changes too, resolving the overlap.`,
191
192
  `Resolve EVERY conflict in the working tree: open each file containing conflict markers (<<<<<<< / ======= / >>>>>>>) and merge BOTH sides' real intent (keep both workers' behaviour — never drop one side just to make it apply). Then apply any rejected hunks recorded in *.rej files by hand, and DELETE every *.rej and *.orig file.`,
@@ -248,7 +249,7 @@ export function createLeaderRunner(cfg) {
248
249
  if (Array.isArray(body.subtasks) && body.subtasks.length > 0) {
249
250
  return validateSubtasks(body.subtasks);
250
251
  }
251
- const planRunner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat });
252
+ const planRunner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, ...deploymentPostureSeat });
252
253
  const runRoute = async (prompt) => {
253
254
  const res = await planRunner.runTaskStream({
254
255
  objective: prompt,
@@ -338,7 +339,7 @@ export function createLeaderRunner(cfg) {
338
339
  workerId: sub.workerId, sessionId, branch: sub.branch,
339
340
  baseSha,
340
341
  runner: keepCtxWarm(new Runner({
341
- brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat,
342
+ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, ...deploymentPostureSeat,
342
343
  ...(cfg.toolResultStore ? { toolResultStore: cfg.toolResultStore } : {}),
343
344
  ...(cfg.sessionStore ? { sessionStore: cfg.sessionStore } : {}),
344
345
  executionEnvFactory: (ctx) => withStaging(envFactory(ctx), stage, async (e) => {
@@ -358,7 +359,7 @@ export function createLeaderRunner(cfg) {
358
359
  const baseSha = await sh(env)(`cd ${repo} && git rev-parse HEAD`);
359
360
  return {
360
361
  workerId: sub.workerId, sessionId, branch: sub.branch, baseSha,
361
- runner: keepCtxWarm(new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, ...(cfg.toolResultStore ? { toolResultStore: cfg.toolResultStore } : {}), ...(cfg.sessionStore ? { sessionStore: cfg.sessionStore } : {}), executionEnv: env })),
362
+ runner: keepCtxWarm(new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, ...deploymentPostureSeat, ...(cfg.toolResultStore ? { toolResultStore: cfg.toolResultStore } : {}), ...(cfg.sessionStore ? { sessionStore: cfg.sessionStore } : {}), executionEnv: env })),
362
363
  diffEnv: env,
363
364
  destroy: () => env.destroy().then(() => { }),
364
365
  spec: { ...workerLimits, ...sub.spec, ...durableSpec, ...resourceSpec },
@@ -149,6 +149,22 @@ export declare const FAIL_OPEN_TAGS: {
149
149
  readonly cls: "F";
150
150
  readonly note: "S-15 twin 保留条款(core 7.0.0 design/381 §片2 的 SQL 孪生):boundary 提交后的自剪 pass(core 共享选择函数 `fileHistoryBoundariesToKeep` + 既有 `reap` 级联)失败 ⇒ 本轮 boundary **已提交**、rewind 座照常,只是这一轮没剪。放行的是「GC 失败不失败用户这一轮」(core InMemory/File 参照同形 `reap(...).catch(() => {})`,但参照是无声吞,本仓走留痕三件套);最坏后果 = 该 scope 暂时超上限一个 boundary,下一次提交的 pass 按**当前全集**重算、连它一起剪(不累积欠账)。detail 带 scope / entry / 异常文案。";
151
151
  };
152
+ readonly "server.approval.ambiguous-cas.expire": {
153
+ readonly cls: "P-DEBT";
154
+ readonly note: "S-25(设计件 2026-08-31 §3 步骤 3;DEBTS S-25 / #290):流内 ask 的**窗到期臂**打 `expireAsk` CAS 抛错或超墙钟(不知道做没做成)之后,恢复读口 6 格退避 7 次读(≈3.1s;末格睡后仍再读一次,末格不是盲窗 —— codex R1-F1)都读不出行 ⇒ 按本臂意图投影结算 = park 路由(`\"unavailable\"`;`deny` 政策下 `{allow:false,settledBy:\"timeout\"}`)并留 park 墓碑。方向 fail-closed(无 DECIDED=approve 行绝不执行工具),但行**可能**已被另一副本判成 DECIDED(approve) 而本腿没兑现 —— 人批了、工具不跑、run 再挂起要人再批一次 = 丢一次人类决议,故记债不当合法兜底。补偿:core 侧仍 pending 的 checkpoint(人再批一次就走)+ 跨副本收敛器兜底(它落地后本 tag 降 F)。**不计**的形:行读得出、只是仍 STREAM_PENDING / 行不在 —— 那是本臂的期望态(K1),零退避直接投影(codex-R2-2 / R3-1 钉;throw 类 K2 的 pending 投影前重打一把本臂幂等 CAS 换持久背书,R3-F2);编辑在飞标记在场 ⇒ 有界让路、用尽走 park 路由(标记的事不是店的债,codex R1-F2/R2-F2;负终态与带载荷 approve 不受标记压制,R2-F1/R3-F1);行读出 DECIDED ⇒ 按人的决议结算,也不计。detail 带 askId 与读次数。";
155
+ };
156
+ readonly "server.approval.ambiguous-cas.cancel": {
157
+ readonly cls: "P-DEBT";
158
+ readonly note: "S-25 同族第二员:**取消臂**(task signal abort / 连接全灭同路)打 `transitionAsk(STREAM_PENDING→VOID)` CAS 抛错或超墙钟之后,恢复读口 7 次读都读不出行 ⇒ 按本臂意图投影结算 = 裸 `settle(false,\"expired\")`(D1 纯进程内语义,不留墓碑:run 已拆,迟到的 Yes 无处兑)。S-25 之前这条 catch **不读行**直接 fail-open —— 行已 DECIDED(approve) 时把人的批准翻成拒绝、回决端点只能如实 404(round6 钉曾把这一形记成「局部竞态残影」);现在只有店持续读不出才走到这里,债的语义与 expire 员同(行可能已 DECIDED 而本腿没兑现),按臂分计是为了让「哪条臂在丢决议」在遥测里读得出。";
159
+ };
160
+ readonly "server.approval.ambiguous-cas.unreachable": {
161
+ readonly cls: "P-DEBT";
162
+ readonly note: "S-25 同族第三员:**emit 全灭臂**(开卡帧两族都没送到任何连接)打 `expireAsk` CAS 抛错或超墙钟之后,恢复读口 7 次读都读不出行 ⇒ 按本臂意图投影结算 = `emitFailed` ⇒ `\"unavailable\"`(park 路由;`deny` 政策下 deny),不留墓碑(卡没到任何人手上,无人持有这把 approvalId)。S-25 之前这条 catch 不读行直接强改 —— 正是 round2 finding A「durable 行已批准、活终局被 emit 全灭强改成 unavailable」的 catch 同形缺口;现在行读出 DECIDED ⇒ 按人的决议结算,只有店持续读不出才计数。";
163
+ };
164
+ readonly "server.pg-pool.idle-client-error": {
165
+ readonly cls: "F";
166
+ readonly note: "S-55(test [5864] 独立复现):pg 连接池里一条连接被外部终止(pg_terminate_backend / 云端故障切换 / LB 空闲回收)或自身网络错误。**两态一 tag,detail 判别**(`state=idle` / `state=checked-out`;先例=park 墓碑行的两臂 detail 判别):①idle 态 —— node-postgres 语义(pg-pool@3.14.0 makeIdleListener):池在 emit 'error' **之前**已 `_remove` 掉该 client,坏连接自然淘汰,下次 acquire 发新连接,在飞查询零影响;修前 Pool 上无 'error' 监听 ⇒ EventEmitter 把事件转 throw ⇒ uncaughtException ⇒ 整进程 exit 1。②checked-out 态(codex R1-[high] 补,同批修)—— pg-pool 借出时摘 idle 监听(index.js:344),`pool.connect()` 显式持有的连接(事务)在 checkout 期死时 client 直接 emit 'error' 同样打死进程;守卫只观察:中毒 client 的后续查询由 pg 以 `_queryable=false` 响亮拒,归还时池淘汰;同一次 checkout 的死可能双发事件(FATAL 消息 + socket end),warn 逐枚、计数按 checkout 去重。放行的最坏后果 = 下一次 acquire 多付一次建连,故 F 类。必须留痕:「连接不断被外部杀」(故障切换风暴 / 回收策略过激 / 有人在库上清连接)与「一切正常」在服务面同形;每次事件另有逐次结构化 warn(`pg_pool_idle_client_error` / `pg_pool_checked_out_client_error`,含 code/severity),本计数让频率曲线可读。`pool.query()` 快路不在洞内(pg-pool 自带 checkout 期 once('error') 守卫)。mysql2 孪生(tidb-pool)**不同病**:PoolConnection 构造器恒挂 once('error') 库内吞并淘汰、借出期同在(坐标成文在 createTidbPool 头注),故无同族 tag。";
167
+ };
152
168
  };
153
169
  /** 词表键推导的闭集类型——未登记的 tag 传不进 {@link recordFailOpen}(编译期拒)。 */
154
170
  export type FailOpenTag = keyof typeof FAIL_OPEN_TAGS;
@@ -140,6 +140,22 @@ export const FAIL_OPEN_TAGS = {
140
140
  cls: "F",
141
141
  note: "S-15 twin 保留条款(core 7.0.0 design/381 §片2 的 SQL 孪生):boundary 提交后的自剪 pass(core 共享选择函数 `fileHistoryBoundariesToKeep` + 既有 `reap` 级联)失败 ⇒ 本轮 boundary **已提交**、rewind 座照常,只是这一轮没剪。放行的是「GC 失败不失败用户这一轮」(core InMemory/File 参照同形 `reap(...).catch(() => {})`,但参照是无声吞,本仓走留痕三件套);最坏后果 = 该 scope 暂时超上限一个 boundary,下一次提交的 pass 按**当前全集**重算、连它一起剪(不累积欠账)。detail 带 scope / entry / 异常文案。",
142
142
  },
143
+ "server.approval.ambiguous-cas.expire": {
144
+ cls: "P-DEBT",
145
+ note: "S-25(设计件 2026-08-31 §3 步骤 3;DEBTS S-25 / #290):流内 ask 的**窗到期臂**打 `expireAsk` CAS 抛错或超墙钟(不知道做没做成)之后,恢复读口 6 格退避 7 次读(≈3.1s;末格睡后仍再读一次,末格不是盲窗 —— codex R1-F1)都读不出行 ⇒ 按本臂意图投影结算 = park 路由(`\"unavailable\"`;`deny` 政策下 `{allow:false,settledBy:\"timeout\"}`)并留 park 墓碑。方向 fail-closed(无 DECIDED=approve 行绝不执行工具),但行**可能**已被另一副本判成 DECIDED(approve) 而本腿没兑现 —— 人批了、工具不跑、run 再挂起要人再批一次 = 丢一次人类决议,故记债不当合法兜底。补偿:core 侧仍 pending 的 checkpoint(人再批一次就走)+ 跨副本收敛器兜底(它落地后本 tag 降 F)。**不计**的形:行读得出、只是仍 STREAM_PENDING / 行不在 —— 那是本臂的期望态(K1),零退避直接投影(codex-R2-2 / R3-1 钉;throw 类 K2 的 pending 投影前重打一把本臂幂等 CAS 换持久背书,R3-F2);编辑在飞标记在场 ⇒ 有界让路、用尽走 park 路由(标记的事不是店的债,codex R1-F2/R2-F2;负终态与带载荷 approve 不受标记压制,R2-F1/R3-F1);行读出 DECIDED ⇒ 按人的决议结算,也不计。detail 带 askId 与读次数。",
146
+ },
147
+ "server.approval.ambiguous-cas.cancel": {
148
+ cls: "P-DEBT",
149
+ note: "S-25 同族第二员:**取消臂**(task signal abort / 连接全灭同路)打 `transitionAsk(STREAM_PENDING→VOID)` CAS 抛错或超墙钟之后,恢复读口 7 次读都读不出行 ⇒ 按本臂意图投影结算 = 裸 `settle(false,\"expired\")`(D1 纯进程内语义,不留墓碑:run 已拆,迟到的 Yes 无处兑)。S-25 之前这条 catch **不读行**直接 fail-open —— 行已 DECIDED(approve) 时把人的批准翻成拒绝、回决端点只能如实 404(round6 钉曾把这一形记成「局部竞态残影」);现在只有店持续读不出才走到这里,债的语义与 expire 员同(行可能已 DECIDED 而本腿没兑现),按臂分计是为了让「哪条臂在丢决议」在遥测里读得出。",
150
+ },
151
+ "server.approval.ambiguous-cas.unreachable": {
152
+ cls: "P-DEBT",
153
+ note: "S-25 同族第三员:**emit 全灭臂**(开卡帧两族都没送到任何连接)打 `expireAsk` CAS 抛错或超墙钟之后,恢复读口 7 次读都读不出行 ⇒ 按本臂意图投影结算 = `emitFailed` ⇒ `\"unavailable\"`(park 路由;`deny` 政策下 deny),不留墓碑(卡没到任何人手上,无人持有这把 approvalId)。S-25 之前这条 catch 不读行直接强改 —— 正是 round2 finding A「durable 行已批准、活终局被 emit 全灭强改成 unavailable」的 catch 同形缺口;现在行读出 DECIDED ⇒ 按人的决议结算,只有店持续读不出才计数。",
154
+ },
155
+ "server.pg-pool.idle-client-error": {
156
+ cls: "F",
157
+ note: "S-55(test [5864] 独立复现):pg 连接池里一条连接被外部终止(pg_terminate_backend / 云端故障切换 / LB 空闲回收)或自身网络错误。**两态一 tag,detail 判别**(`state=idle` / `state=checked-out`;先例=park 墓碑行的两臂 detail 判别):①idle 态 —— node-postgres 语义(pg-pool@3.14.0 makeIdleListener):池在 emit 'error' **之前**已 `_remove` 掉该 client,坏连接自然淘汰,下次 acquire 发新连接,在飞查询零影响;修前 Pool 上无 'error' 监听 ⇒ EventEmitter 把事件转 throw ⇒ uncaughtException ⇒ 整进程 exit 1。②checked-out 态(codex R1-[high] 补,同批修)—— pg-pool 借出时摘 idle 监听(index.js:344),`pool.connect()` 显式持有的连接(事务)在 checkout 期死时 client 直接 emit 'error' 同样打死进程;守卫只观察:中毒 client 的后续查询由 pg 以 `_queryable=false` 响亮拒,归还时池淘汰;同一次 checkout 的死可能双发事件(FATAL 消息 + socket end),warn 逐枚、计数按 checkout 去重。放行的最坏后果 = 下一次 acquire 多付一次建连,故 F 类。必须留痕:「连接不断被外部杀」(故障切换风暴 / 回收策略过激 / 有人在库上清连接)与「一切正常」在服务面同形;每次事件另有逐次结构化 warn(`pg_pool_idle_client_error` / `pg_pool_checked_out_client_error`,含 code/severity),本计数让频率曲线可读。`pool.query()` 快路不在洞内(pg-pool 自带 checkout 期 once('error') 守卫)。mysql2 孪生(tidb-pool)**不同病**:PoolConnection 构造器恒挂 once('error') 库内吞并淘汰、借出期同在(坐标成文在 createTidbPool 头注),故无同族 tag。",
158
+ },
143
159
  };
144
160
  export function failOpenTagForDroppedFrame(frameType) {
145
161
  switch (frameType) {
@@ -39,9 +39,36 @@ export declare const PG_SCHEMA_STATEMENTS: readonly string[];
39
39
  * Unset = both off (byte-compat: legit heavyweight statements — boot DDL, ≤64 MiB snapshot-blob rows — make a
40
40
  * universal default unsafe; the S9 write-behind counter face carries its own always-on per-query timeout). */
41
41
  export declare function pgPoolOptions(pgCfg: NonNullable<ServiceConfig["pg"]>, dbQueryTimeoutMs?: number): pg.PoolConfig;
42
+ /** 池连接错误的结构化摘要([ref];idle 态与 checked-out 态两监听共用)。纯函数,单测直打。字段
43
+ * **诚实缺席**:code/severity 只在错误对象上真有 string 值时才出键(pg 的 DatabaseError 把两者
44
+ * 平铺在错误对象上;裸网络错误没有)—— 不 String() 硬铸,「不知道 ≠ 铸一个假值」([ref] 同族纪律)。 */
45
+ export interface PgPoolClientErrorMeta {
46
+ message: string;
47
+ code?: string;
48
+ severity?: string;
49
+ }
50
+ export declare function pgPoolClientErrorMeta(err: unknown): PgPoolClientErrorMeta;
51
+ /** `createPgPool` 的 warn 座(形=store-backend 的 `BootLogSink`;不 import 它——那是反向依赖)。 */
52
+ export type PgPoolWarnSink = (msg: string, meta?: Record<string, unknown>) => void;
42
53
  /** Create the PG pool from a connection string (or pg.PoolConfig). Thin — production backend-selection wiring
43
- * (a ServiceConfig.pg sibling of .tidb) is a tracked follow-on. */
44
- export declare function createPgPool(opts: string | pg.PoolConfig): pg.Pool;
54
+ * (a ServiceConfig.pg sibling of .tidb) is a tracked follow-on.
55
+ *
56
+ * ── [ref](DEBTS;test [ref] 独立复现 FATAL 57P01→exit)——池必须挂 'error' 监听 ─────────────────
57
+ * node-postgres 语义(pg-pool@3.14.0 `makeIdleListener`):一条**空闲**连接出错
58
+ * (`pg_terminate_backend` / 云端故障切换 / LB 空闲回收 / 网络断)⇒ 池先 `_remove(client)` 再
59
+ * `pool.emit('error', err, client)`。Pool 是 EventEmitter ⇒ 无 'error' 监听时 Node 把这枚事件转
60
+ * throw(ERR_UNHANDLED_ERROR)⇒ `boot/shutdown.ts` 的 uncaughtException 钩无条件 exit 1 ⇒
61
+ * **一条空闲连接的死换整进程的死**。修在源头监听,三件明确**不做**:
62
+ * · 不 exit —— 坏连接在 emit **之前**已被池淘汰,下次 acquire 发新连接,在飞查询(在别的
63
+ * client 上)零影响,这不是进程级故障;
64
+ * · 不静默 —— 每次事件一条结构化 warn(含 code/severity)+ `recordFailOpen`(F 类,census 第
65
+ * 52 行):「连接不断被外部杀」(故障切换风暴 / 回收策略过激)与「一切正常」在服务面同形,
66
+ * 不留痕则只能靠间接症状发现;
67
+ * · 不动 shutdown.ts 的 uncaughtException 钩 —— exit 1 对**真**未捕获仍是正确姿势,旁路兜底
68
+ * 会吞掉别人的崩溃([ref] 纪律:修源头,不下游兜)。
69
+ * `onIdleClientError` 缺省 console.warn(与本文件 `ensurePgSchema` 的既有告警姿势一致)——生产
70
+ * 装配(store-backend `createStoreBackend`)递结构化 logger 的 warn 座。 */
71
+ export declare function createPgPool(opts: string | pg.PoolConfig, onIdleClientError?: PgPoolWarnSink): pg.Pool;
45
72
  /** Idempotent central schema apply (the PG twin of tidb-pool.ts ensureSchema): the run/checkpoint tables here +
46
73
  * every PG store's own (disjoint) tables. Order is irrelevant (CREATE IF NOT EXISTS, no cross-table FKs).
47
74
  *
@@ -1,4 +1,5 @@
1
1
  import pg from "pg";
2
+ import { recordFailOpen } from "../observability/fail-open.js";
2
3
  import { ensureSchema as ensureSessionSchema } from "./pg-session-storage.js";
3
4
  import { ensureSchema as ensureToolResultSchema } from "./tool-result-store-sql.js";
4
5
  import { ensureSchema as ensureCostQuotaSchema } from "./pg-cost-quota.js";
@@ -341,8 +342,50 @@ export function pgPoolOptions(pgCfg, dbQueryTimeoutMs) {
341
342
  ...(dbQueryTimeoutMs ? { statement_timeout: dbQueryTimeoutMs, query_timeout: dbQueryTimeoutMs + 5_000 } : {}),
342
343
  };
343
344
  }
344
- export function createPgPool(opts) {
345
- return typeof opts === "string" ? new pg.Pool({ connectionString: opts }) : new pg.Pool(opts);
345
+ export function pgPoolClientErrorMeta(err) {
346
+ const bag = typeof err === "object" && err !== null ? err : {};
347
+ return {
348
+ message: err instanceof Error ? err.message : String(err),
349
+ ...(typeof bag.code === "string" ? { code: bag.code } : {}),
350
+ ...(typeof bag.severity === "string" ? { severity: bag.severity } : {}),
351
+ };
352
+ }
353
+ export function createPgPool(opts, onIdleClientError) {
354
+ const pool = typeof opts === "string" ? new pg.Pool({ connectionString: opts }) : new pg.Pool(opts);
355
+ const warn = onIdleClientError ?? ((msg, meta) => console.warn(`[pg-pool] ${msg}`, meta));
356
+ const report = (msgName, state, err, count) => {
357
+ const meta = pgPoolClientErrorMeta(err);
358
+ try {
359
+ warn(msgName, { ...meta });
360
+ }
361
+ catch {
362
+ }
363
+ if (!count)
364
+ return;
365
+ const firstLine = meta.message.split("\n", 1)[0] ?? "";
366
+ recordFailOpen("server.pg-pool.idle-client-error", `state=${state} ${meta.code ?? "uncoded"}/${meta.severity ?? "?"} ${firstLine.slice(0, 200)}`);
367
+ };
368
+ pool.on("error", (err) => report("pg_pool_idle_client_error", "idle", err, true));
369
+ const checkoutGuards = new WeakMap();
370
+ pool.on("acquire", (client) => {
371
+ if (checkoutGuards.has(client))
372
+ return;
373
+ let counted = false;
374
+ const guard = (err) => {
375
+ report("pg_pool_checked_out_client_error", "checked-out", err, !counted);
376
+ counted = true;
377
+ };
378
+ checkoutGuards.set(client, guard);
379
+ client.on("error", guard);
380
+ });
381
+ pool.on("release", (_err, client) => {
382
+ const guard = checkoutGuards.get(client);
383
+ if (guard !== undefined) {
384
+ client.removeListener("error", guard);
385
+ checkoutGuards.delete(client);
386
+ }
387
+ });
388
+ return pool;
346
389
  }
347
390
  const ENSURE_SCHEMA_LOCK_KEY = 4906;
348
391
  export async function ensurePgSchema(pool) {
@@ -1,5 +1,5 @@
1
1
  import { ExecutionError, FileError, RemoteExecutionError, type ExecutionEnv, type ExecutionEnvFactory, type ExecStreamOptions, type FileInfo, type OutputChunk, type RemoteConnectConfig, type RemoteExecutionEnv, type Result, type SessionToken, type SnapshotId, type VmLifecycleOptions, type WorkspaceHandle, type WriteExpectation, type WriteReceipt } from "@sema-agent/core";
2
- import { DEVICE_RATE_LIMIT_CODE, type DeviceErrorCode, type DeviceOwner, type DeviceRow, type DeviceStore } from "../device-store.js";
2
+ import { type DeviceOwner, type DeviceRejectCode, type DeviceRow, type DeviceStore } from "../device-store.js";
3
3
  import type { DeviceEnrollment } from "../device-enrollment.js";
4
4
  import type { DeviceWsHub } from "../device-ws-hub.js";
5
5
  import type { DeviceInstructionKind, DeviceOutcomeErrorCode } from "../device-ws-protocol.js";
@@ -34,7 +34,7 @@ export declare const DEVICE_OUTCOME_TO_FILE_ERROR: Readonly<Record<DeviceOutcome
34
34
  * **从未启动**,因为可执行面解析不了」,与「设备没有这条指令面」同侧;never-started 语义保住)。
35
35
  */
36
36
  export declare const DEVICE_OUTCOME_TO_EXEC_ERROR: Readonly<Record<DeviceOutcomeErrorCode, ExecutionError["code"]>>;
37
- type RejectCode = DeviceErrorCode | typeof DEVICE_RATE_LIMIT_CODE;
37
+ type RejectCode = DeviceRejectCode;
38
38
  /**
39
39
  * 汇聚端的 **typed 拒**(投递门**之前**,§5.3 投递边界:设备从未可能见过这条指令)→ core 错误码。
40
40
  *
@@ -44,6 +44,8 @@ type RejectCode = DeviceErrorCode | typeof DEVICE_RATE_LIMIT_CODE;
44
44
  * 例外 `identity_mismatch` / `revoked` / `not_found`:那是**永久**拒(换设备/吊销/不是你的),重试
45
45
  * 只会同样失败 —— exec 面落 `auth_failed`(NEVER retry 的那一格),file 面无对位词落 `permission_denied`
46
46
  * (「这条路你走不通,而且不是暂时的」是它最近的语义)。
47
+ * `draining`([ref] 借用位)与 busy 同侧(commit 前拒,从未启动)—— 判别靠 rejectText 把 `[draining]`
48
+ * 整句带进文案:重试该去**替换副本**,不是稍后打同一个。
47
49
  */
48
50
  export declare const DEVICE_REJECT_TO_EXEC_ERROR: Readonly<Record<RejectCode, ExecutionError["code"]>>;
49
51
  export declare const DEVICE_REJECT_TO_FILE_ERROR: Readonly<Record<RejectCode, FileError["code"]>>;