@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
  import { posix } from "node:path";
2
2
  import { ExecutionError, FileError, RemoteExecutionError, } from "@sema-agent/core";
3
- import { DEVICE_ERROR_CODES, DEVICE_RATE_LIMIT_CODE, } from "../device-store.js";
3
+ import { DEVICE_ERROR_CODES, } from "../device-store.js";
4
4
  export const DEVICE_PROVIDER = "device";
5
5
  const ok = (value) => ({ ok: true, value });
6
6
  const STREAM_PENDING_MAX_BYTES = 2 * 1024 * 1024;
@@ -60,6 +60,7 @@ export const DEVICE_REJECT_TO_EXEC_ERROR = {
60
60
  "device.enrollment_invalid": "auth_failed",
61
61
  "not_found.device": "auth_failed",
62
62
  "limit.rate_exceeded": "target_unavailable",
63
+ "draining": "target_unavailable",
63
64
  };
64
65
  export const DEVICE_REJECT_TO_FILE_ERROR = {
65
66
  "device.not_bound": "transport_lost",
@@ -73,6 +74,7 @@ export const DEVICE_REJECT_TO_FILE_ERROR = {
73
74
  "device.enrollment_invalid": "permission_denied",
74
75
  "not_found.device": "permission_denied",
75
76
  "limit.rate_exceeded": "transport_lost",
77
+ "draining": "transport_lost",
76
78
  };
77
79
  const DEVICE_REJECT_TO_REMOTE_ERROR = {
78
80
  "device.not_bound": "connect_failed",
@@ -86,6 +88,7 @@ const DEVICE_REJECT_TO_REMOTE_ERROR = {
86
88
  "device.enrollment_invalid": "auth_failed",
87
89
  "not_found.device": "auth_failed",
88
90
  "limit.rate_exceeded": "connect_failed",
91
+ "draining": "connect_failed",
89
92
  };
90
93
  function rejectText(reject) {
91
94
  return `[${reject.code}] ${reject.message}`;
@@ -21,8 +21,7 @@
21
21
  * 是「core 变语义必须先过迁移裁量」的告警钉)。
22
22
  */
23
23
  import type { Pool } from "mysql2/promise";
24
- import { type RosterStore, type RosterEntry } from "@sema-agent/core";
25
- type RosterAccess = Parameters<RosterStore["resolve"]>[1];
24
+ import { type RosterStore, type RosterEntry, type RosterAccess } from "@sema-agent/core";
26
25
  import type { PgQueryFn } from "./pg-query.js";
27
26
  export declare const ROSTER_TABLE = "agent_roster";
28
27
  export declare function ensureTiDBRosterSchema(pool: Pool): Promise<void>;
@@ -51,5 +50,4 @@ export declare class PgRosterStore implements RosterStore {
51
50
  /** [ref] MED2(TiDB twin 同案注)。 */
52
51
  reapOlderThan(cutoffMs: number): Promise<number>;
53
52
  }
54
- export {};
55
53
  //# sourceMappingURL=roster-store-sql.d.ts.map
@@ -319,7 +319,7 @@ export function createStoreBackend(config, onWarn, onError) {
319
319
  if (config.dbBackend === "pg") {
320
320
  if (!config.pg)
321
321
  return undefined;
322
- const pool = createPgPool(pgPoolOptions(config.pg, config.dbQueryTimeoutMs));
322
+ const pool = createPgPool(pgPoolOptions(config.pg, config.dbQueryTimeoutMs), onWarn);
323
323
  return new PgBackend(pool, config);
324
324
  }
325
325
  if (!config.tidb)
@@ -10,6 +10,19 @@ import type { ServiceConfig } from "../config-types.js";
10
10
  */
11
11
  /** Build the mysql2 pool options from TiDB config (pure → unit-testable apart from the live connection). */
12
12
  export declare function tidbPoolOptions(tidb: NonNullable<ServiceConfig["tidb"]>): mysql.PoolOptions;
13
+ /** ── [ref] 同查结论:mysql2 与 pg-pool **不同病**,本池不需要(也不应)挂进程保命监听 ────────────────
14
+ * pg 侧的病是「idle client 错误 emit 到 Pool 且无监听 ⇒ 未捕获 ⇒ 整进程 exit 1」(修在 pg-pool.ts
15
+ * createPgPool 头注)。mysql2@3.22.6 亲读(不信文档,以实现为准):
16
+ * · `lib/pool_connection.js:14-16` —— PoolConnection 构造器**恒挂** `once('error', () =>
17
+ * this._removeFromPool())`:每条池连接天生有 error 监听(**借出期同在**——不像 pg-pool 借出时
18
+ * 摘监听,mysql2 的 checked-out 连接也无裸奔窗),EventEmitter 不会把 idle 死转 throw,
19
+ * 坏连接就地淘汰;
20
+ * · `lib/base/connection.js` `_notifyError` —— fatal 闩(`if (this._fatalError) return`)挡同一
21
+ * 连接的二次 emit;`_handleFatalError` 摘 stream data 监听 ⇒ 闩后 `protocolError` 结构不可达;
22
+ * · `lib/base/pool.js` / `lib/promise/pool.js` —— 池层与 promise 包装层都**不**把连接 error 再
23
+ * emit 到池对象上(池层只 emit acquire/connection/enqueue/release)。
24
+ * ⇒ 空闲连接被 KILL / 故障切换杀掉 = 库内吞并淘汰,进程不死、池自愈。真 TiDB KILL 钉在
25
+ * `test/pool-idle-error-db-integration.test.ts` 第③格。升 mysql2 大版时按上面三坐标复核。 */
13
26
  export declare function createTidbPool(config: ServiceConfig): Pool;
14
27
  /**
15
28
  * Idempotent schema. `session_event` is the append-only L1 event log (positional slicing by
@@ -126,6 +126,10 @@ export declare function governanceMandatesShellGateAlways(governance: {
126
126
  autonomy?: Autonomy;
127
127
  manualModeShellGate?: "always" | "classify";
128
128
  }): boolean;
129
+ /** spec 序里的秩(0 起,越大越严)。入参收 `string`(fail-loud 边界:schema `trustRank` 同款姿势)——词表外 ⇒
130
+ * RangeError,绝不 undefined/NaN 让比较静默成 false。导出只为对表钉(test/safety-merge-spec-consumption.test.ts,
131
+ * [ref] 件4)——运行时消费仍走上表(键类型闭集)。 */
132
+ export declare function shellGateRank(word: string): number;
129
133
  /**
130
134
  * Apply the operator's runtime governance (autonomy + commandPolicy + manualModeShellGate) onto a base
131
135
  * `TaskSpec`, TIGHTEN-ONLY, in a SINGLE {@link tightenTaskSpec} call: commandPolicy compiles to a `toolPolicy`
@@ -1,4 +1,5 @@
1
1
  import { combinePolicies, COARSE_SHELL_TOOLS as CORE_COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, DEFAULT_SUBAGENT_TOOL_NAME, parseLeadingCommandName, tightenTaskSpec, } from "@sema-agent/core";
2
+ import { SAFETY_MERGE_SPEC } from "@sema-agent/settings-schema";
2
3
  import { currentGovernanceAskMarks } from "./governance-ask-marks.js";
3
4
  export function isDelegationToolName(name) {
4
5
  return name === DEFAULT_SUBAGENT_TOOL_NAME;
@@ -116,7 +117,28 @@ function createGovernanceShellGateMarkPolicy(marksOf, tools = COARSE_SHELL_TOOLS
116
117
  },
117
118
  };
118
119
  }
119
- const SHELL_GATE_RANK = { off: 0, classify: 1, always: 2 };
120
+ const SHELL_GATE_RANK = (() => {
121
+ const tighten = SAFETY_MERGE_SPEC.shellGate.tighten;
122
+ const rule = tighten.rule;
123
+ if (tighten.rule !== "enumRank")
124
+ throw new Error(`SAFETY_MERGE_SPEC.shellGate.tighten.rule=${rule} — 不是 enumRank,shellGate 秩无从派生`);
125
+ const table = { off: -1, classify: -1, always: -1 };
126
+ for (const [i, w] of tighten.order.entries()) {
127
+ if (!(w in table))
128
+ throw new Error(`SAFETY_MERGE_SPEC.shellGate 序里有本仓词表外的词:${w}`);
129
+ table[w] = i;
130
+ }
131
+ for (const [w, r] of Object.entries(table))
132
+ if (r < 0)
133
+ throw new Error(`SAFETY_MERGE_SPEC.shellGate 序缺词:${w}`);
134
+ return table;
135
+ })();
136
+ export function shellGateRank(word) {
137
+ const r = SHELL_GATE_RANK[word];
138
+ if (r === undefined)
139
+ throw new RangeError(`unknown shellGate word: ${word}`);
140
+ return r;
141
+ }
120
142
  export function applyRuntimeGovernance(base, governance) {
121
143
  const marksOf = () => governance.askMarks ?? currentGovernanceAskMarks();
122
144
  const overrides = { ...autonomyOverrides(governance.autonomy) };
@@ -842,30 +842,6 @@ export declare class ToolApprovalCoordinator {
842
842
  * 面)—— 把它混进本名单会让「同 askId 的重复本地注册」拿到裸 deny,而赢家拿的是 park 路由:同一条
843
843
  * 持久行、两种 live 终局。那一支改走 {@link settleSameAskIdParkRoute}。 */
844
844
  private settleVoidedSiblings;
845
- /**
846
- * [ref] codex R3-F1 —— 「行上此刻有没有一个**真决议**」的一次性窄读(窗到期臂的店报错支专用)。
847
- *
848
- * 返回 `true`/`false` = 行是 `DECIDED` 且决议是 approve/deny(**人的**裁决,或车4 端点代人落的那一次);
849
- * `undefined` = 其它一切(读失败、行不在、行是 STREAM_PENDING/PARKING/PARKED/DENIED/VOID)——调用方按
850
- * 自己的臂继续。判别复用 {@link replayTerminalAskRow}(不造第二套映射),这里只把它的**布尔臂**取出来:
851
- * `"unavailable"`(PARKING/PARKED)刻意**不**在本口消费,那属于路由面、归 `unattendedPolicy` 管。
852
- *
853
- * 一次读 + 墙钟 deadline(不重试):店已经在报错的语境里,重试只会把窗到期的收尾拖成秒级,而这一口要
854
- * 回答的问题(「有没有人已经答过了」)读一次就够 —— 读不到就按「不知道」处置,与本臂原语义一致。
855
- *
856
- * ⚠️ **已登记的残余(codex R4-F1/F2,验真后不在本批修)**:本读与随后的本地 settle 之间没有持久 CAS,
857
- * 所以「读到 STREAM_PENDING、另一副本**随后**才提交 DECIDED」这条窄窗仍会得到 park/deny 终局(行是
858
- * approve 而本腿不执行);`emit` 全灭臂与取消臂的 store-未知态 catch 有**同形**缺口(两者都早于本件、
859
- * 各自有既有判据钉着)。方向仍是 fail-closed(没有任何东西在未经人批时执行),补偿是 core 侧那张仍然
860
- * pending 的 checkpoint —— 人再批一次就走。真正的收口 = 把窗/emit-全灭/取消三条 ambiguous-CAS catch
861
- * 统一到一次**持久**恢复流程(带 CAS 与终态回读),那是店语义级改动,须由本模块属主立设计件,施工车
862
- * 不私开(本批已试过整段复用 `convergeFromDurableState`,被自己的全量跑打回,理由见其顶注)。
863
- *
864
- * ✅ **第二条残余已收口([ref],2026-08-19 登记 → 2026-08-31 [ref] 销)**:本口读出的放行此前交给
865
- * `settle` 时第三形参 `updatedInput` 恒缺席(行上没有那一列),core 落回原始实参执行 = 执行面**宽于**
866
- * 人所批准。现在行上有 `updated_input` 列,本口回 `{allowed, updatedInput?}` 两件,`settle` 拿到的就是
867
- * 人批准的那份编辑;P-DEBT tag 随列撤线(fail-open.ts 撤线备注 + census 第 31 行终态)。
868
- */
869
845
  /**
870
846
  * 🔴 session grant 短路前的**持久出处复核**([ref] 的 codex R1-[high] 补丁;[ref] 换判据)。
871
847
  *
@@ -904,8 +880,24 @@ export declare class ToolApprovalCoordinator {
904
880
  * 多半头一次到,那等于把一揽子放行整只废掉。窗的上界由本判据定死:行**一提交**窗即关。
905
881
  */
906
882
  private sessionGrantUncontestedByRow;
907
- /** [ref]:返回值从裸布尔改成「方向 + 编辑载荷」(行带 `updatedInput` ⇒ 一并交出;`undefined` = 不知道)。 */
908
- private readDecidedForRecovery;
883
+ /**
884
+ * [ref] 恢复读口:一次带墙钟的 `getAsk`,把行翻译成 {@link RecoveryRead} 七态。**只读不写**(单赢者:终局
885
+ * 写点只有各臂的 CAS)。两处消费 —— 干净输后的 `convergeFromDurableState`(在 pending/unreadable 上退避)
886
+ * 与店未知态后的 `recoverFromUnknownStore`(只在 unreadable 上退避)—— 共用这**一份**映射,不各自
887
+ * `switch` 一遍(`replayTerminalAskRow` 仍是唯一的三态映射,这里只把它接到结算动作上)。
888
+ *
889
+ * 沿革:前身 `readDecidedForRecovery`([ref] codex R3-F1,窗到期臂店报错支专用)只答「有没有真决议」,
890
+ * 且把「读失败」与「行不是 DECIDED」折成同一个 `undefined` —— 调用臂分不清「店真不可用」与「行就是没定」,
891
+ * 于是取消臂 / emit 全灭臂的同形缺口一直没法接同一条路(R4-F1/F2 登记的残余)。[ref] 把两种「不知道」
892
+ * 拆开(设计件 §2 K1/K2),三臂 catch 才统一得起来。[ref] 那条(行派生 approve 丢编辑)已随 [ref] 落列
893
+ * 收口:`approved` 臂带行上的 `updatedInput`。
894
+ *
895
+ * 行派生 approve 的两道守卫**在这里**施加(五处消费一份判据,见 {@link guardRowDerivedApprove}):
896
+ * · 弃单墓碑在场 ⇒ `parked`(park 路由,可逆可再批 —— 绝不把一个证不出载荷的放行交出去);
897
+ * · 编辑在飞 ⇒ `edit-in-flight`(codex R4-[critical] 同族口:载荷还在别人手里的那一拍按行结算 = 用裸
898
+ * `true` 抢在它前面;持有者随后必定自己结算)。
899
+ */
900
+ private readRowForRecovery;
909
901
  /** [ref] codex R3-F2:同一 askId 下**其余本地注册**(重复注册,见 {@link pendingByAskId} 顶注)按
910
902
  * **park 路由**收尾 —— 行已 PARKING,它们看的是同一条行,终局必须与赢家同形(`unattendedPolicy`
911
903
  * 由各自的闭包读同一个口,deny 部署上一起是 deny)。赢家自己此刻已 settle 过、已从集合里摘除,
@@ -1252,8 +1244,9 @@ export declare class ToolApprovalCoordinator {
1252
1244
  *
1253
1245
  * R6 把弃单墓碑的兑现点收在 {@link notifyExternalDecision} 上,并自称「公共咽喉」—— 那句话**不成立**:
1254
1246
  * 树上还有三处**直接** `settle(true, …)` / 直接把 `true` 交回 core 的行派生回放(`convergeFromDurableState`
1255
- * 的收敛臂、`ensureAsk` 幂等命中终局行的回放、窗到期报错臂的 `readDecidedForRecovery`),它们跳过那个
1256
- * 口。于是弃单之后从这三条路上仍能放出原始命令。
1247
+ * 的收敛臂、`ensureAsk` 幂等命中终局行的回放、窗到期报错臂的 `readDecidedForRecovery` —— [ref] 起前者与
1248
+ * 后者合并成 `readRowForRecovery` 一个读口、三臂共用),它们跳过那个口。于是弃单之后从这三条路上仍能放出
1249
+ * 原始命令。
1257
1250
  *
1258
1251
  * 收口形 = **一个判据函数、五处消费**(上面三处 + 轮询 + HTTP 观察者):把「行说 approve」这件事
1259
1252
  * 在弃单墓碑在场时翻译成 `"unavailable"`(park 路由,可逆可再批),其余取值逐字透传。
@@ -46,6 +46,20 @@ export const ASK_DECISION_CONSUMER_ABSENT = "absent";
46
46
  const DURABLE_CONVERGE_BACKOFF_MS = [50, 100, 200, 400, 800, 1600];
47
47
  const DURABLE_CONVERGE_READ_TIMEOUT_MS = 2_000;
48
48
  const DURABLE_CALL_TIMEOUT_MS = 5_000;
49
+ function ambiguousCasFailOpenTag(intent) {
50
+ switch (intent) {
51
+ case "expire":
52
+ return "server.approval.ambiguous-cas.expire";
53
+ case "cancel":
54
+ return "server.approval.ambiguous-cas.cancel";
55
+ case "unreachable":
56
+ return "server.approval.ambiguous-cas.unreachable";
57
+ default: {
58
+ const unreachable = intent;
59
+ throw new Error(`ambiguousCasFailOpenTag: unknown intent ${String(unreachable)}`);
60
+ }
61
+ }
62
+ }
49
63
  class ApprovalStoreDeadlineError extends Error {
50
64
  constructor(label, timeoutMs) {
51
65
  super(`approval store call ${label} timed out after ${timeoutMs}ms`);
@@ -440,26 +454,32 @@ export class ToolApprovalCoordinator {
440
454
  return false;
441
455
  }
442
456
  }
443
- async readDecidedForRecovery(askId) {
457
+ async readRowForRecovery(askId, where) {
444
458
  if (!this.askStore)
445
- return undefined;
459
+ return { kind: "absent" };
460
+ let row;
446
461
  try {
447
- const row = await this.withStoreDeadline(this.askStore.getAsk(askId), "getAsk(expire-error-recovery)", DURABLE_CONVERGE_READ_TIMEOUT_MS);
448
- if (row === null || row.state !== "DECIDED")
449
- return undefined;
450
- if (replayTerminalAskRow(row) === true && this.abandonedEditedDecisionPending(askId)) {
451
- this.guardRowDerivedApprove(askId, true, "expire-error-recovery");
452
- return undefined;
453
- }
454
- if (this.editedDecisionInFlight(askId))
455
- return undefined;
456
- const replayed = settleArgsOf(replayTerminalAskRow(row));
457
- return replayed === "unavailable" ? undefined : replayed;
462
+ row = await this.withStoreDeadline(this.askStore.getAsk(askId), `getAsk(${where})`, DURABLE_CONVERGE_READ_TIMEOUT_MS);
458
463
  }
459
464
  catch (err) {
460
- this.noteStoreError(err, "getAsk(expire-error-recovery)");
461
- return undefined;
465
+ this.noteStoreError(err, `getAsk(${where})`);
466
+ return { kind: "unreadable" };
467
+ }
468
+ if (row === null)
469
+ return { kind: "absent" };
470
+ if (row.state === "STREAM_PENDING") {
471
+ return this.editedDecisionInFlight(askId) ? { kind: "edit-in-flight" } : { kind: "pending" };
462
472
  }
473
+ const replayed = settleArgsOf(this.guardRowDerivedApprove(askId, replayTerminalAskRow(row), where));
474
+ if (replayed === "unavailable")
475
+ return { kind: "parked" };
476
+ if (!replayed.allowed)
477
+ return { kind: "denied" };
478
+ if (replayed.updatedInput !== undefined)
479
+ return { kind: "approved", updatedInput: replayed.updatedInput };
480
+ if (this.editedDecisionInFlight(askId))
481
+ return { kind: "edit-in-flight" };
482
+ return { kind: "approved" };
463
483
  }
464
484
  hasOtherSuspendedOwner(askId, self) {
465
485
  if (askId === undefined)
@@ -490,13 +510,17 @@ export class ToolApprovalCoordinator {
490
510
  const now = Date.now();
491
511
  let ownCount = 0;
492
512
  let ownExpiredVictim;
513
+ let ownBridgeVictim;
493
514
  let ownPlainVictim;
494
515
  let ownEvictable;
495
516
  let globalExpiredVictim;
517
+ let globalBridgeVictim;
496
518
  for (const [k, t] of this.parkTombstones) {
497
519
  const expired = now - t.parkedAtMs > this.parkTombstoneTtlMs;
498
520
  if (globalExpiredVictim === undefined && expired && t.inflight === undefined)
499
521
  globalExpiredVictim = k;
522
+ if (globalBridgeVictim === undefined && t.kind === "voidBridge" && t.inflight === undefined)
523
+ globalBridgeVictim = k;
500
524
  if (t.owner !== tomb.owner)
501
525
  continue;
502
526
  ownCount++;
@@ -506,13 +530,16 @@ export class ToolApprovalCoordinator {
506
530
  ownEvictable = k;
507
531
  if (ownExpiredVictim === undefined && expired)
508
532
  ownExpiredVictim = k;
509
- if (ownPlainVictim === undefined && t.redeemed === undefined)
533
+ if (ownBridgeVictim === undefined && t.kind === "voidBridge")
534
+ ownBridgeVictim = k;
535
+ if (ownPlainVictim === undefined && t.kind === "park" && t.redeemed === undefined)
510
536
  ownPlainVictim = k;
511
537
  }
538
+ const insertingBridge = tomb.kind === "voidBridge";
512
539
  if (ownCount >= effectivePerPrincipalMax) {
513
- const victim = ownExpiredVictim ?? ownPlainVictim ?? ownEvictable;
540
+ const victim = insertingBridge ? (ownExpiredVictim ?? ownBridgeVictim) : (ownExpiredVictim ?? ownBridgeVictim ?? ownPlainVictim ?? ownEvictable);
514
541
  if (victim === undefined) {
515
- recordFailOpen("server.approval.park-tombstone-global-cap-refused", `owner=${tomb.owner ?? "<none>"} reason=all-inflight`);
542
+ recordFailOpen("server.approval.park-tombstone-global-cap-refused", `owner=${tomb.owner ?? "<none>"} reason=${insertingBridge ? "bridge-would-evict-redeemable" : "all-inflight"}`);
516
543
  return;
517
544
  }
518
545
  this.parkTombstones.delete(victim);
@@ -520,8 +547,11 @@ export class ToolApprovalCoordinator {
520
547
  else if (this.parkTombstones.size >= this.parkTombstoneMax && globalExpiredVictim !== undefined) {
521
548
  this.parkTombstones.delete(globalExpiredVictim);
522
549
  }
550
+ else if (this.parkTombstones.size >= this.parkTombstoneMax && !insertingBridge && globalBridgeVictim !== undefined) {
551
+ this.parkTombstones.delete(globalBridgeVictim);
552
+ }
523
553
  else if (this.parkTombstones.size >= this.parkTombstoneMax) {
524
- recordFailOpen("server.approval.park-tombstone-global-cap-refused", `owner=${tomb.owner ?? "<none>"}`);
554
+ recordFailOpen("server.approval.park-tombstone-global-cap-refused", `owner=${tomb.owner ?? "<none>"}${insertingBridge ? " reason=bridge-refused-at-global-cap" : ""}`);
525
555
  return;
526
556
  }
527
557
  this.parkTombstones.set(approvalId, tomb);
@@ -878,7 +908,7 @@ export class ToolApprovalCoordinator {
878
908
  sig.removeEventListener("abort", fn);
879
909
  this.pending.delete(id);
880
910
  if (windowRouteUnavailable && askId !== undefined && batchId !== undefined && this.unattendedOutcome() !== false) {
881
- this.recordParkTombstone(wireApprovalId, { askId, batchId, owner: origin.owner, parkedAtMs: Date.now() });
911
+ this.recordParkTombstone(wireApprovalId, { kind: "park", askId, batchId, owner: origin.owner, parkedAtMs: Date.now() });
882
912
  parkRouted = true;
883
913
  }
884
914
  if (askId !== undefined) {
@@ -898,39 +928,37 @@ export class ToolApprovalCoordinator {
898
928
  const convergeFromDurableState = async (where) => {
899
929
  if (!this.askStore || askId === undefined)
900
930
  return;
901
- const store = this.askStore;
902
931
  const pinnedAskId = askId;
903
- for (let attempt = 0; attempt < DURABLE_CONVERGE_BACKOFF_MS.length; attempt++) {
932
+ for (let attempt = 0; attempt <= DURABLE_CONVERGE_BACKOFF_MS.length; attempt++) {
904
933
  if (done)
905
934
  return;
906
- try {
907
- const row = await this.withStoreDeadline(store.getAsk(pinnedAskId), `getAsk(${where})`, DURABLE_CONVERGE_READ_TIMEOUT_MS);
908
- if (done)
909
- return;
910
- if (row === null) {
935
+ const read = await this.readRowForRecovery(pinnedAskId, `converge:${where}`);
936
+ if (done)
937
+ return;
938
+ switch (read.kind) {
939
+ case "absent":
911
940
  settle(false, "expired");
912
941
  return;
913
- }
914
- if (row.state === "STREAM_PENDING" || this.editedDecisionInFlight(pinnedAskId)) {
915
- await new Promise((r) => setTimeout(r, DURABLE_CONVERGE_BACKOFF_MS[attempt]).unref?.());
942
+ case "pending":
943
+ case "edit-in-flight":
944
+ case "unreadable":
945
+ if (attempt < DURABLE_CONVERGE_BACKOFF_MS.length)
946
+ await new Promise((r) => setTimeout(r, DURABLE_CONVERGE_BACKOFF_MS[attempt]).unref?.());
916
947
  continue;
917
- }
918
- const outcome = settleArgsOf(this.guardRowDerivedApprove(pinnedAskId, replayTerminalAskRow(row), `converge:${where}`));
919
- if (outcome === "unavailable") {
948
+ case "parked":
920
949
  windowRouteUnavailable = true;
921
950
  settle(false, "expired");
922
- }
923
- else if (outcome.allowed) {
924
- settle(true, "allowed", outcome.updatedInput);
925
- }
926
- else {
951
+ return;
952
+ case "approved":
953
+ settle(true, "allowed", read.updatedInput);
954
+ return;
955
+ case "denied":
927
956
  settle(false, "denied");
957
+ return;
958
+ default: {
959
+ const unreachable = read;
960
+ throw new Error(`convergeFromDurableState: unknown recovery read ${String(unreachable)}`);
928
961
  }
929
- return;
930
- }
931
- catch (err) {
932
- this.noteStoreError(err, `getAsk(${where})`);
933
- await new Promise((r) => setTimeout(r, DURABLE_CONVERGE_BACKOFF_MS[attempt]).unref?.());
934
962
  }
935
963
  }
936
964
  if (!done) {
@@ -943,6 +971,90 @@ export class ToolApprovalCoordinator {
943
971
  }
944
972
  }
945
973
  };
974
+ const recoverFromUnknownStore = async (arm, casStillInFlight) => {
975
+ if (askId === undefined) {
976
+ arm.settleByIntent();
977
+ return;
978
+ }
979
+ const pinnedAskId = askId;
980
+ for (let attempt = 0; attempt <= DURABLE_CONVERGE_BACKOFF_MS.length; attempt++) {
981
+ if (done)
982
+ return;
983
+ const read = await this.readRowForRecovery(pinnedAskId, `recovery:${arm.intent}`);
984
+ if (done)
985
+ return;
986
+ switch (read.kind) {
987
+ case "approved":
988
+ settle(true, "allowed", read.updatedInput);
989
+ return;
990
+ case "denied":
991
+ settle(false, "denied");
992
+ return;
993
+ case "parked":
994
+ windowRouteUnavailable = true;
995
+ settle(false, "expired");
996
+ return;
997
+ case "pending": {
998
+ if (casStillInFlight) {
999
+ arm.settleByIntent();
1000
+ return;
1001
+ }
1002
+ let result;
1003
+ try {
1004
+ result = await arm.cas();
1005
+ }
1006
+ catch (err) {
1007
+ this.noteStoreError(err, arm.storeLabel);
1008
+ arm.settleByIntent();
1009
+ return;
1010
+ }
1011
+ if (arm.won(result)) {
1012
+ arm.onWon(result);
1013
+ return;
1014
+ }
1015
+ await convergeFromDurableState(arm.where);
1016
+ return;
1017
+ }
1018
+ case "absent":
1019
+ arm.settleByIntent();
1020
+ return;
1021
+ case "edit-in-flight":
1022
+ case "unreadable":
1023
+ if (attempt < DURABLE_CONVERGE_BACKOFF_MS.length)
1024
+ await new Promise((r) => setTimeout(r, DURABLE_CONVERGE_BACKOFF_MS[attempt]).unref?.());
1025
+ continue;
1026
+ default: {
1027
+ const unreachable = read;
1028
+ throw new Error(`recoverFromUnknownStore: unknown recovery read ${String(unreachable)}`);
1029
+ }
1030
+ }
1031
+ }
1032
+ if (done)
1033
+ return;
1034
+ if (this.editedDecisionInFlight(pinnedAskId)) {
1035
+ windowRouteUnavailable = true;
1036
+ settle(false, "expired");
1037
+ return;
1038
+ }
1039
+ recordFailOpen(ambiguousCasFailOpenTag(arm.intent), `askId=${pinnedAskId} reads=${DURABLE_CONVERGE_BACKOFF_MS.length + 1}`);
1040
+ arm.settleByIntent();
1041
+ };
1042
+ const resolveLocalOutcome = async (arm) => {
1043
+ let result;
1044
+ try {
1045
+ result = await arm.cas();
1046
+ }
1047
+ catch (err) {
1048
+ this.noteStoreError(err, arm.storeLabel);
1049
+ await recoverFromUnknownStore(arm, err instanceof ApprovalStoreDeadlineError);
1050
+ return;
1051
+ }
1052
+ if (arm.won(result)) {
1053
+ arm.onWon(result);
1054
+ return;
1055
+ }
1056
+ await convergeFromDurableState(arm.where);
1057
+ };
946
1058
  const settleParkRouteIfUnsettled = (hostSettledBy) => {
947
1059
  if (done)
948
1060
  return;
@@ -955,34 +1067,30 @@ export class ToolApprovalCoordinator {
955
1067
  settleParkRouteIfUnsettled(hostSelfReport);
956
1068
  return;
957
1069
  }
958
- try {
959
- const res = await this.withStoreDeadline(this.askStore.expireAsk(askId, batchId), "expireAsk(window)", DURABLE_CALL_TIMEOUT_MS, (late) => {
1070
+ const store = this.askStore;
1071
+ const pinnedAskId = askId;
1072
+ const pinnedBatchId = batchId;
1073
+ await resolveLocalOutcome({
1074
+ intent: "expire",
1075
+ storeLabel: "expireAsk",
1076
+ where: "windowExpired",
1077
+ cas: () => this.withStoreDeadline(store.expireAsk(pinnedAskId, pinnedBatchId), "expireAsk(window)", DURABLE_CALL_TIMEOUT_MS, (late) => {
960
1078
  if (!late.won)
961
1079
  return;
962
- this.settleSameAskIdParkRoute(askId);
1080
+ this.settleSameAskIdParkRoute(pinnedAskId);
963
1081
  this.settleVoidedSiblings(late.voidedSiblings);
964
- this.emitRevokeTo(aliveCtxs, buildRevokeFrame(batchId, late.voidedSiblings, "superseded_by_park", Date.now()));
965
- });
966
- if (res.won) {
1082
+ this.emitRevokeTo(aliveCtxs, buildRevokeFrame(pinnedBatchId, late.voidedSiblings, "superseded_by_park", Date.now()));
1083
+ }),
1084
+ won: (res) => res.won,
1085
+ onWon: (res) => {
967
1086
  windowRouteUnavailable = true;
968
1087
  settle(false, "expired");
969
- this.settleSameAskIdParkRoute(askId);
1088
+ this.settleSameAskIdParkRoute(pinnedAskId);
970
1089
  this.settleVoidedSiblings(res.voidedSiblings);
971
- this.emitRevokeTo(aliveCtxs, buildRevokeFrame(batchId, res.voidedSiblings, "superseded_by_park", Date.now()));
972
- }
973
- else {
974
- await convergeFromDurableState("windowExpired");
975
- }
976
- }
977
- catch (err) {
978
- this.noteStoreError(err, "expireAsk");
979
- const decided = await this.readDecidedForRecovery(askId);
980
- if (decided !== undefined && !done) {
981
- settle(decided.allowed, decided.allowed ? "allowed" : "denied", decided.updatedInput);
982
- return;
983
- }
984
- settleParkRouteIfUnsettled(hostSelfReport);
985
- }
1090
+ this.emitRevokeTo(aliveCtxs, buildRevokeFrame(pinnedBatchId, res.voidedSiblings, "superseded_by_park", Date.now()));
1091
+ },
1092
+ settleByIntent: () => settleParkRouteIfUnsettled(hostSelfReport),
1093
+ });
986
1094
  })();
987
1095
  };
988
1096
  const forceParkNow = () => {
@@ -1005,26 +1113,29 @@ export class ToolApprovalCoordinator {
1005
1113
  settle(false, "expired");
1006
1114
  return;
1007
1115
  }
1008
- try {
1009
- const won = await this.withStoreDeadline(this.askStore.transitionAsk(askId, "STREAM_PENDING", "VOID", { updatedAtMs: Date.now() }), "transitionAsk(cancel)", DURABLE_CALL_TIMEOUT_MS, (lateWon) => {
1116
+ const store = this.askStore;
1117
+ const pinnedAskId = askId;
1118
+ const pinnedBatchId = batchId;
1119
+ await resolveLocalOutcome({
1120
+ intent: "cancel",
1121
+ storeLabel: "transitionAsk(cancel)",
1122
+ where: "runCancel",
1123
+ cas: () => this.withStoreDeadline(store.transitionAsk(pinnedAskId, "STREAM_PENDING", "VOID", { updatedAtMs: Date.now() }), "transitionAsk(cancel)", DURABLE_CALL_TIMEOUT_MS, (lateWon) => {
1010
1124
  if (!lateWon)
1011
1125
  return;
1012
- this.settleVoidedSiblings([askId]);
1013
- this.emitRevokeTo(aliveCtxs, buildRevokeFrame(batchId, [askId], "aborted", Date.now()));
1014
- });
1015
- if (won) {
1126
+ this.settleVoidedSiblings([pinnedAskId]);
1127
+ this.recordParkTombstone(wireApprovalId, { kind: "voidBridge", askId: pinnedAskId, batchId: pinnedBatchId, owner: origin.owner, parkedAtMs: Date.now() });
1128
+ this.emitRevokeTo(aliveCtxs, buildRevokeFrame(pinnedBatchId, [pinnedAskId], "aborted", Date.now()));
1129
+ }),
1130
+ won: (won) => won,
1131
+ onWon: () => {
1016
1132
  settle(false, "expired");
1017
- this.settleVoidedSiblings([askId]);
1018
- this.emitRevokeTo(aliveCtxs, buildRevokeFrame(batchId, [askId], "aborted", Date.now()));
1019
- }
1020
- else {
1021
- await convergeFromDurableState("runCancel");
1022
- }
1023
- }
1024
- catch (err) {
1025
- this.noteStoreError(err, "transitionAsk(cancel)");
1026
- settle(false, "expired");
1027
- }
1133
+ this.settleVoidedSiblings([pinnedAskId]);
1134
+ this.recordParkTombstone(wireApprovalId, { kind: "voidBridge", askId: pinnedAskId, batchId: pinnedBatchId, owner: origin.owner, parkedAtMs: Date.now() });
1135
+ this.emitRevokeTo(aliveCtxs, buildRevokeFrame(pinnedBatchId, [pinnedAskId], "aborted", Date.now()));
1136
+ },
1137
+ settleByIntent: () => settle(false, "expired"),
1138
+ });
1028
1139
  };
1029
1140
  if (signal) {
1030
1141
  const onTaskAbort = () => void runCancel();
@@ -1114,31 +1225,42 @@ export class ToolApprovalCoordinator {
1114
1225
  const emitAllP = (async () => {
1115
1226
  const results = await Promise.all(openByCtx.values());
1116
1227
  const anySucceeded = reachability === "unreached" || this.hasOtherSuspendedOwner(askId, selfEntry) || results.some((r) => r.legacy || r.card);
1228
+ emitSettled = true;
1117
1229
  if (!anySucceeded && !done) {
1118
1230
  if (this.askStore && askId !== undefined && batchId !== undefined) {
1119
- try {
1120
- const res = await this.withStoreDeadline(this.askStore.expireAsk(askId, batchId), "expireAsk(emit-failed)");
1121
- if (res.won && !done) {
1122
- emitFailed = true;
1123
- settle(false, "expired");
1124
- this.settleSameAskIdParkRoute(askId);
1231
+ const store = this.askStore;
1232
+ const pinnedAskId = askId;
1233
+ const pinnedBatchId = batchId;
1234
+ const settleUnreachable = () => {
1235
+ if (done)
1236
+ return;
1237
+ emitFailed = true;
1238
+ settle(false, "expired");
1239
+ };
1240
+ await resolveLocalOutcome({
1241
+ intent: "unreachable",
1242
+ storeLabel: "expireAsk(emit-failed)",
1243
+ where: "emitFailed",
1244
+ cas: () => this.withStoreDeadline(store.expireAsk(pinnedAskId, pinnedBatchId), "expireAsk(emit-failed)", DURABLE_CALL_TIMEOUT_MS, (late) => {
1245
+ if (!late.won)
1246
+ return;
1247
+ this.settleSameAskIdParkRoute(pinnedAskId);
1248
+ this.settleVoidedSiblings(late.voidedSiblings);
1249
+ }),
1250
+ won: (res) => res.won,
1251
+ onWon: (res) => {
1252
+ settleUnreachable();
1253
+ this.settleSameAskIdParkRoute(pinnedAskId);
1125
1254
  this.settleVoidedSiblings(res.voidedSiblings);
1126
- }
1127
- }
1128
- catch (err) {
1129
- this.noteStoreError(err, "expireAsk(emit-failed)");
1130
- if (!done) {
1131
- emitFailed = true;
1132
- settle(false, "expired");
1133
- }
1134
- }
1255
+ },
1256
+ settleByIntent: settleUnreachable,
1257
+ });
1135
1258
  }
1136
1259
  else if (!done) {
1137
1260
  emitFailed = true;
1138
1261
  settle(false, "expired");
1139
1262
  }
1140
1263
  }
1141
- emitSettled = true;
1142
1264
  })();
1143
1265
  await Promise.race([emitAllP, allowedP]);
1144
1266
  const settled = await allowedP;
@@ -16,7 +16,7 @@
16
16
  * 同文件 G1 e2e 的全码扇出夹具、以及契约文档附录 D.3 那张表(含**小节标题里的码数** —— 那道门本批
17
17
  * 刚立,立完就在下一次加码时自己咬住了)。
18
18
  */
19
- export declare const ENGINE_NOTICE_WIRE_CODES: readonly ["memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "task.turn_interrupted", "steering.parked_input_blocked", "task.user_steer_undrained", "task.user_followup_undrained", "memory.capture_opted_out", "memory.capture_optout_unpersisted"];
19
+ export declare const ENGINE_NOTICE_WIRE_CODES: readonly ["memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "task.turn_interrupted", "steering.parked_input_blocked", "task.user_steer_undrained", "task.user_followup_undrained", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "task.halt_unconsumed", "task.late_approval"];
20
20
  export type EngineNoticeWireCode = (typeof ENGINE_NOTICE_WIRE_CODES)[number];
21
21
  /** 白名单谓词(单点):路由与门都读这一个,不许第二处手抄码串。 */
22
22
  export declare function isEngineNoticeWireCode(code: string): code is EngineNoticeWireCode;