@sema-agent/server 7.11.0 → 7.13.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 (78) hide show
  1. package/README.md +1 -1
  2. package/USAGE.md +86 -2
  3. package/dist/adoption/plan.d.ts +38 -4
  4. package/dist/adoption/plan.js +72 -0
  5. package/dist/adoption/quiesce.d.ts +70 -0
  6. package/dist/adoption/quiesce.js +148 -0
  7. package/dist/adoption/runner.js +63 -5
  8. package/dist/adoption/sql.d.ts +15 -0
  9. package/dist/adoption/sql.js +18 -0
  10. package/dist/adoption/wire.d.ts +7 -1
  11. package/dist/adoption/wire.js +6 -0
  12. package/dist/approval-card.d.ts +5 -0
  13. package/dist/approval-card.js +22 -0
  14. package/dist/auth-keys.d.ts +28 -4
  15. package/dist/auth-keys.js +60 -15
  16. package/dist/boot/parked-revive-gate.d.ts +18 -2
  17. package/dist/boot/parked-revive-gate.js +136 -14
  18. package/dist/boot/permission-rules-audit.d.ts +49 -0
  19. package/dist/boot/permission-rules-audit.js +85 -0
  20. package/dist/boot/reapers.d.ts +15 -0
  21. package/dist/boot/reapers.js +101 -44
  22. package/dist/boot/resolve-spec.js +43 -12
  23. package/dist/boot/runner-deps.d.ts +16 -2
  24. package/dist/boot/runner-deps.js +5 -4
  25. package/dist/budget.js +22 -0
  26. package/dist/config-types.d.ts +31 -11
  27. package/dist/config.d.ts +28 -2
  28. package/dist/config.js +348 -79
  29. package/dist/governance-ask-marks.js +8 -2
  30. package/dist/http/active-run-conflict.d.ts +33 -8
  31. package/dist/http/active-run-conflict.js +37 -2
  32. package/dist/http/route-ctx.d.ts +6 -3
  33. package/dist/http/routes/adoption.js +25 -2
  34. package/dist/http/routes/approvals-assistant.js +35 -4
  35. package/dist/http/routes/capabilities.js +69 -10
  36. package/dist/http/routes/images.js +18 -0
  37. package/dist/http/routes/rules.d.ts +19 -7
  38. package/dist/http/routes/rules.js +180 -4
  39. package/dist/http/routes/runs.js +21 -5
  40. package/dist/http/routes/tasks.js +18 -6
  41. package/dist/http/server.d.ts +30 -10
  42. package/dist/http/server.js +183 -19
  43. package/dist/http/wire-types.d.ts +48 -0
  44. package/dist/main.js +65 -7
  45. package/dist/observability/fail-open.d.ts +8 -0
  46. package/dist/observability/fail-open.js +8 -0
  47. package/dist/observability/metrics.js +2 -1
  48. package/dist/observability/tool-trace.d.ts +5 -1
  49. package/dist/observability/tool-trace.js +33 -6
  50. package/dist/parked-decide.d.ts +13 -3
  51. package/dist/parked-decide.js +10 -1
  52. package/dist/plugins/adoption-log-sql.d.ts +40 -0
  53. package/dist/plugins/adoption-log-sql.js +69 -2
  54. package/dist/plugins/file-run-store.d.ts +85 -1
  55. package/dist/plugins/file-run-store.js +450 -17
  56. package/dist/plugins/permission-rule-store-file.d.ts +83 -0
  57. package/dist/plugins/permission-rule-store-file.js +371 -0
  58. package/dist/plugins/permission-rule-store-sql.d.ts +52 -0
  59. package/dist/plugins/permission-rule-store-sql.js +71 -2
  60. package/dist/plugins/shared-memory-store-sql.d.ts +23 -9
  61. package/dist/plugins/shared-memory-store-sql.js +55 -18
  62. package/dist/plugins/sql-driver.d.ts +19 -0
  63. package/dist/plugins/sql-driver.js +12 -0
  64. package/dist/plugins/store-backend.d.ts +12 -6
  65. package/dist/plugins/store-backend.js +82 -10
  66. package/dist/rules-consent.d.ts +98 -1
  67. package/dist/rules-consent.js +84 -1
  68. package/dist/run-local.js +126 -15
  69. package/dist/runtime-governance.d.ts +33 -0
  70. package/dist/runtime-governance.js +41 -3
  71. package/dist/task-settings.d.ts +44 -0
  72. package/dist/task-settings.js +57 -1
  73. package/dist/tool-approval.d.ts +38 -1
  74. package/dist/tool-approval.js +125 -26
  75. package/dist/trace/core-keyset-guard.d.ts +14 -3
  76. package/dist/trace/project.d.ts +19 -2
  77. package/dist/trace/project.js +24 -4
  78. package/package.json +3 -3
package/README.md CHANGED
@@ -157,7 +157,7 @@ The server is configured entirely through environment variables. The most import
157
157
  | `DEFAULT_SCENARIO` | `code` | Default scenario when the request body names none |
158
158
  | `SANDBOX_PKG_SOURCE` | `global` | Package sources inside sandboxes: `global` (official upstreams) / `cn` (China mirrors) / `custom` / `none` |
159
159
  | `SENSITIVE_WRITE_PATTERNS` | core's recommended set | Sensitive-path write deny list; comma-separated value replaces the set, `off` **or an empty value** disables. Applied unconditionally at the governance layer (independent of client permission mode, lane or settings presence) — including on the `run-local` leg. A value that cannot compile into a guard set (e.g. `/`, a pattern with no path segment) refuses to start |
160
- | `MANUAL_MODE_SHELL_GATE` | unset (off) | `always`\|`classify` — tighten `Bash` into the approval chain, applied unconditionally at the governance layer (≥7.1.0: independent of client permission mode, lane, or settings presence) |
160
+ | `MANUAL_MODE_SHELL_GATE` | unset | `always`\|`classify` — tighten `Bash` into the approval chain, applied unconditionally at the governance layer (≥7.1.0: independent of client permission mode, lane, or settings presence). **Unset is not "off"**: since 7.12.0 the caller's explicit `permissionMode` supplies the baseline this knob tightens from (`bypassPermissions` → `off`, `auto`/`default`/`acceptEdits`/`plan` → `classify`; **no** mode stated → no gate, core's `off` default). This knob only ever raises that baseline — it has no relax half, so `off` is accepted as an explicit **no-op** (a boot line says so; not symmetric with `SENSITIVE_WRITE_PATTERNS=off`, which really does clear a set). **Any other value refuses to start** (7.12.0, BREAKING for a deployment that had a typo: it was previously treated as unset, i.e. silently no gate at all) |
161
161
  | `SCRATCHPAD_SWEEP_TTL_MS` | 7 days | Idle-reap window for per-session scratchpad dirs (by dir mtime; `0` disables). The scratchpad is **ephemeral by contract**: replica-local disk, NOT part of the durable-suspend persistence set — a resume on a different replica, or after a sweep, starts with an empty dir (same two-track posture as the Agent SDK hosting doc: conversation persists, working-directory artifacts don't). Raise/disable only on single-replica deployments that park approvals for longer than the window |
162
162
  | `MODEL_CONNECT_TIMEOUT_MS` | `30000` | Gateway connect timeout |
163
163
  | `MODEL_FIRST_TOKEN_TIMEOUT_MS` | `120000` | First-token timeout |
package/USAGE.md CHANGED
@@ -266,6 +266,62 @@ QUESTION_TTL_MS=300000
266
266
  - 窗用尽 / 到期 **不是替人作答**:服务只报「此刻无人可答」,落点由引擎选——**durable 部署**(`DURABLE_APPROVAL=true` 且这条腿没有活的流)把问题 **park** 成待办,运维随后经审批面带答案补答;**无 durable 的部署**则让模型被告知"没人在"后按自己的判断继续(run 永不挂死)。
267
267
  - 想让人有更长时间答就调大 `QUESTION_TTL_MS`;想让 agent 少打扰人就调小 `QUESTION_MAX_TOTAL_PER_RUN`。
268
268
 
269
+ **流内审批协议(`approval_request` 帧族,design/172)—— 总开关 + 九个调优钮**
270
+
271
+ > 🔴 **默认已是 ON**(clay 裁 2026-08-08;7.3.0/7.4.0 的**已发布**线上仍是 OFF,翻转落在其后的下一个发布)。
272
+ > 开关只是发帧五合取里的一项:还要**活卡腿开着 ∧ 非 local 的持久 store backend ∧ park 设施在场**
273
+ > (`DURABLE_APPROVAL=true` + checkpoint 能力的 backend)。所以一台什么都没配的默认 worker 仍是零帧 +
274
+ > 启动一行 `stream_approval_disabled`;而**已经配齐那套前置的部署,升级后不必再显式开这个开关就会开始发帧**
275
+ > —— 壳/SDK 不实现消费就等于用户看不到审批卡。帧格式、消费端契约、五合取全表在
276
+ > [`docs/ASSISTANT-WIRE-CONTRACT.md` §4a-quint](docs/ASSISTANT-WIRE-CONTRACT.md)(错误码 `feature.approval_ask_disabled` 在附录 A)。
277
+
278
+ ```bash
279
+ # 协议总开关。默认 true;显式 =false 是**唯一干净还原键**(全链逐字回到翻转前:不发 approval_request、
280
+ # 不落 ask 行、不起收敛器腿,窗回到 DEFAULT_APPROVAL_TTL_MS)
281
+ STREAM_APPROVAL_ENABLED=true
282
+ # 活卡窗(毫秒,默认 300000=5min)。0 = 运维显式关窗 ⇒ 恒 park(不是"还原",还原用上面那个键)
283
+ STREAM_ASK_WINDOW_MS=300000
284
+ # 协调器窗长三元的安全余量(毫秒,默认 10000):有效窗 = min(ttl, 本 leg 余量 − 本值)
285
+ STREAM_ASK_WINDOW_MARGIN_MS=10000
286
+ # 开流重放:一次最多投几张未决卡(默认 50)。超出只投最新的并记一次 warn,开流不失败
287
+ STREAM_APPROVAL_REPLAY_MAX=50
288
+ # 写侧准入帽:单个 task / 单个 owner 的未决 ask 上限(默认 32 / 256)。超限走 park,**永不 deny**
289
+ STREAM_APPROVAL_ADMIT_MAX_PER_TASK=32
290
+ STREAM_APPROVAL_ADMIT_MAX_PER_OWNER=256
291
+ # 对账收敛器每 tick 每段最多处理的行数(默认 200,有界 [1,10000],且必须是**整数**)
292
+ STREAM_APPROVAL_RECONCILE_BATCH=200
293
+ # 崩溃恢复扫描的宽限(毫秒,默认 30000,有界 [0,3600000]):只有 expiresAtMs+本值 已过的孤儿行才被代打过期
294
+ STREAM_APPROVAL_PENDING_GRACE_MS=30000
295
+ # adhoc 腿的窗后宽限(毫秒,默认 60000,有界 [0,86400000])
296
+ STREAM_APPROVAL_ADHOC_GRACE_MS=60000
297
+ # 遗孤最终可判上界(毫秒,默认 604800000=7d,有界 [60000, 7776000000=90d]),量的是 createdAtMs
298
+ STREAM_APPROVAL_ORPHAN_TTL_MS=604800000
299
+ ```
300
+ - **坏形一律启动期炸,不静默折默认**:五个 `numEnv` 键(`STREAM_ASK_WINDOW_MS` / `STREAM_ASK_WINDOW_MARGIN_MS` /
301
+ `STREAM_APPROVAL_REPLAY_MAX` / 两个 `ADMIT_MAX_*`)非数字即拒启;四个收敛器键额外**有界**,越界拒启
302
+ (不夹取);`STREAM_APPROVAL_RECONCILE_BATCH` 再加一道整数门(`200.5` 会一路走到 SQL `LIMIT` 上)。
303
+ - **🔴 跨旋钮不变量**:`STREAM_ASK_WINDOW_MS + STREAM_APPROVAL_ADHOC_GRACE_MS` 必须**严格小于**
304
+ `STREAM_APPROVAL_ORPHAN_TTL_MS`,否则**拒启**并点名。理由是归因诚实:adhoc 判据在
305
+ `(创建 + 窗 + 宽限)` 触发、遗孤兜底在 `(创建 + TTL)` 触发,兜底若先到,每条 adhoc 腿都会被记成
306
+ `orphan_ttl_exceeded`(「遗孤」)而不是 `adhoc_leg_no_durable_domain`(「结构上无对账域」),审计面从此读不出真成因。
307
+ 默认值(300s + 60s vs 7d)自然满足,只有显式改坏才会撞上。
308
+ - **调参方向**:想让人有更长时间点审批卡 → 调大 `STREAM_ASK_WINDOW_MS`;卡太多刷屏 → 调小两个 `ADMIT_MAX_*`
309
+ (代价是超限的 ask 走 park,要有人去审批队列捞);库压大 → 调小 `STREAM_APPROVAL_RECONCILE_BATCH`
310
+ (代价是收敛变慢,靠队列轮转保证下轮接着扫)。
311
+
312
+ **可选 — MCP 入站表单(elicitation,E23):默认关**
313
+
314
+ ```bash
315
+ MCP_ELICITATION_ENABLED=true # 总开关,默认 **false**(fail-closed:不开则 MCP 服务器的表单请求根本不上场)
316
+ MCP_ELICITATION_MAX_CONCURRENT=2 # 每条 run 同时挂几张表单(默认 2,有界 [1,64])
317
+ MCP_ELICITATION_MAX_TOTAL=20 # 每条 run 一共能弹几张(默认 20,有界 [1,10000])
318
+ MCP_ELICITATION_MIN_INTERVAL_MS=1000 # 同一个 MCP server 两张表单之间的最小间隔(默认 1000,有界 [0,600000])
319
+ MCP_ELICITATION_TTL_MS=300000 # 一张没人填的表单挂多久后释放(默认 300000=5min,有界 [1000,3600000])
320
+ ```
321
+ - 四个节流钮都是 `numEnvBounded`:**越界启动期响亮拒,不静默夹取**(一个手滑的多小时 TTL 会让表单实际上永不过期)。
322
+ - 总开关关着时四个节流钮解析照跑但无消费者——不设=行为不变。帧格式见
323
+ [`docs/ASSISTANT-WIRE-CONTRACT.md` §4a-quater](docs/ASSISTANT-WIRE-CONTRACT.md)(`elicitation` / `elicitation_complete`)。
324
+
269
325
  **布尔旋钮的取值与极性(运维必读)**
270
326
 
271
327
  布尔 env **只认 `true` / `false` 两个字面量**。写成 `1` / `yes` / `TRUE` ⇒ 该旋钮退回自己的缺省值,并在启动时
@@ -323,6 +379,10 @@ QUESTION_TTL_MS=300000
323
379
  | `Authorization: Bearer <SERVICE_AUTH_TOKEN>` | 设了 `SERVICE_AUTH_TOKEN` 时,所有非 `/health` 请求 | **谁有权调本服务**(OA 后端持有,服务到服务) |
324
380
  | `x-agent-principal: user:42` | 设了 `REQUIRE_PRINCIPAL=true` 时 | **代表哪个终端用户**(决定 session 归属 + 记忆隔离;**绝不从 body 取**) |
325
381
 
382
+ | env 键 | 缺省 | 说明 |
383
+ |---|---|---|
384
+ | `PRINCIPAL_HEADER` | `x-agent-principal` | 上面那个身份头的**名字**。🔴 **缺省值是跨仓 wire 常量,不是给部署方自定义的**:这个名字**不在任何 wire 面上广播**(`/v1/capabilities`、`/health` 都没有它),所以没有下游能在运行期问 server「你叫它什么」——SDK 有一个 `principalHeader` 逃生舱但 cli/client-core 都没接线,浏览器端 BFF 是逐字硬编码,`mcp-oa` 侧车自己读同名 env(各读各的)。本旋钮只服务于「入口网关已经把身份写进别的头名」这一种特殊部署(让 server 迁就既有网关),不是给部署方起新名字用的。**改名即断下游**,但后果分四形(逐条对过代码):`REQUIRE_PRINCIPAL=true` 下提交腿(`POST /v1/tasks|/v1/runs`)是 `401` + **粗码 `auth.unauthorized`**(authorizer 抛的无 code HttpError 走状态码兜底映射),属主寻址的读/动词面是 `401 auth.principal_required`(路由级细码);两者的文案都逐字回显**你配的头名**,那是现场唯一能指认改名的线索。`REQUIRE_PRINCIPAL` 未开时更坏:**新会话照常成功**、每一条被当成**匿名**(session 归属 / 记忆隔离 / 规则车道 / operator 判定静默走无身份分支,无任何错误码),而**接续一条已属主的会话**会 `401`(`session is principal-owned; missing principal header …`)。⚠️ **SSO 与 direct-door 两条身份来源不经过这个头**(`verifiedPrincipal`),改名对它们无影响 —— 混合形部署因此会出现「一半调用方有身份、一半静默变匿名」的混着长。改了后果自负,且必须同批把每一家下游改到同名。取值须是**单个**合法 header 名(带空格/逗号 ⇒ 启动期拒启:它会撕裂 CORS `allow-headers`)。成文契约见 [`docs/ASSISTANT-WIRE-CONTRACT.md` §0.5](docs/ASSISTANT-WIRE-CONTRACT.md) |
385
+
326
386
  > ⚠️ **Bearer 是"每个请求",不只是 POST。** 配了 `SERVICE_AUTH_TOKEN` 后,**`GET /v1/runs/:id`、`GET /v1/runs/:id/events`(SSE)、`/metrics`** 等所有非 `/health` 路由都要带 `Authorization: Bearer`——漏带一律 `401`。下面示例为简洁**省略了 Bearer**,真实调用请逐个补上。
327
387
 
328
388
  **请求体字段(你能传的全部):**
@@ -507,6 +567,28 @@ curl -N http://<host>:8090/v1/tasks/stream -H 'content-type: application/json' \
507
567
  > 今天仍有效的预防旋钮 = **在开放 `settings.hooks.PreToolUse` 的部署上不依赖后台子代的 durable 审批**
508
568
  > (或用 `REQUIRE_PRINCIPAL` 关掉该面),同样只对**此后**新铸的行生效。
509
569
  >
570
+ > **🔧 升级到 7.10.0 前:必须删库重建(BREAKING,两条同窗)。** ① pg 侧 memory 两表的
571
+ > `agent_memory_engine_entry.scope/slug` 与 `agent_memory_engine_cursor.scope` 由 `text` 收窄为
572
+ > `varchar(190)` / `varchar(512)`(与 MySQL-protocol 方言同宽);② 七个 epoch-BIGINT 列补 `_ms` 后缀,
573
+ > 两方言同窗改:`checkpoint.created_at|decided_at|terminal_at`、`workflow_resume_claim.claimed_at`、
574
+ > `workflow_run.ended_at`、`workflow_notify_journal.created_at|acked_at`。
575
+ > **本服务的 schema 契约 = 启动 DDL 是唯一真源,不发 `ALTER` 增量 seam**,而建表语句是
576
+ > `CREATE TABLE IF NOT EXISTS` ⇒ 对已存在的旧表**一字不改**:旧列名的存量表在新代码下每一次读写都直接
577
+ > 报错,**没有静默降级路径**。滚版前重建这几张表(或整库),基线见 `docs/schema/baseline-*.sql`。
578
+ > wire/HTTP 面零变化——改的只是列名,对外字段仍是 `createdAt` / `decidedAt` / `endedAt` / `ackedAt`
579
+ > 等 camelCase 形。
580
+ >
581
+ > **🔧 升级到 7.8.0 前:必须删库重建(BREAKING,SQL 命名三轴归一化)。** ①**表名单数化 9 张**:
582
+ > `approval_asks`→`approval_ask`、`approval_batches`→`approval_batch`、
583
+ > `background_agents`→`background_agent`、`mailboxes`→`mailbox`、`mailbox_messages`→`mailbox_message`、
584
+ > `task_list_items`→`task_list_item`、`agent_memory_engine_entries|cursors|sync_cursors`→
585
+ > `…entry|cursor|sync_cursor`(表限定形索引名的表段随改);②五个 epoch 毫秒 BIGINT 列补 `_ms` 后缀
586
+ > ×双方言:`checkpoint_ctx.updated_at_ms`、`workflow_journal.created_at_ms`、`workflow_run.created_at_ms`、
587
+ > `workflow_completion_inbox.enqueued_at_ms`、`agent_memory_engine_push_queue.next_attempt_at_ms`;
588
+ > ③OCC 词归一:approval 两表 `version`→`rev`。同上——`CREATE TABLE IF NOT EXISTS` 不改存量表,
589
+ > **旧表名/旧列名在新代码下读写即报错**,滚版前删库重建(零存量用户窗口,不做增量迁移)。
590
+ > wire 面零变化(approval 店的列名不出 wire)。
591
+ >
510
592
  > **🔧 升级到 7.7.0(引擎 core 5.20.0)前:检查数值旋钮的写法。** 5.20.0 把「坏数值旋钮被接受、然后
511
593
  > 悄悄做**相反**的事」这一类全部改成响亮拒或响亮钳位。两条与运维直接相关:
512
594
  >
@@ -541,10 +623,12 @@ curl -N http://<host>:8090/v1/tasks/stream -H 'content-type: application/json' \
541
623
  > 需要动作的只有一种行:**升级前就已经 pending 的那些**——它们带的是旧版本铸的哈希与(可能已降级的)
542
624
  > 参数,新引擎不会追认改写。**滚版前把它们批/否掉**(`GET /v1/approvals` 列出来,逐个 `/decide`),
543
625
  > 或明确接受那批老行仍按旧语义结算。新铸的行不受影响。
544
- > **同一次升级还要重建 mailbox 两表。** `mailbox_messages` 新增 `hop_chain` 列(引擎的 peer 消息守卫),
626
+ > **同一次升级还要重建 mailbox 两表。** 消息表新增 `hop_chain` 列(引擎的 peer 消息守卫),
545
627
  > 而建表语句是 `CREATE TABLE IF NOT EXISTS` —— 对已存在的旧表**一字不改**,升级后每一次 teammate 消息
546
628
  > 投递都会报 unknown column 并失败。按本服务的 schema 契约(删库重建、不做增量迁移),滚版时
547
- > **重建 `mailboxes` / `mailbox_messages` 两表**(或整库),基线见 `docs/schema/baseline-*.sql`。
629
+ > **重建 mailbox 两表**(或整库),基线见 `docs/schema/baseline-*.sql`。
630
+ > ⚠️ 表名按**你要升到的版本**读:7.8.0 之前叫 `mailboxes` / `mailbox_messages`,7.8.0 的单数化归一
631
+ > (见上「升级到 7.8.0」条)之后叫 **`mailbox` / `mailbox_message`** —— 后者是现役名。
548
632
  > 该表是带 TTL 的短命投递队列而非账本,重建只丢排队中的 teammate 消息;介意就先让在飞的 peer 会话收敛。
549
633
  >
550
634
  > 顺带一提,新引擎会在铸点**直接拒绝 park**(点名后端、退回同步门)的只有两类值,而且都只可能由
@@ -1,12 +1,23 @@
1
1
  import type { AdoptionLegAction, AdoptionNotMigrated } from "./wire.js";
2
- /** 身份值的编码:`verbatim` = 列里存的就是 principal 本身;`memory-scope` = core 的 v2 typed scope 键。 */
3
- export type IdentityEncoding = "verbatim" | "memory-scope";
2
+ /**
3
+ * 身份值的编码。
4
+ * · `verbatim` —— 列里存的就是 principal 本身;
5
+ * · `memory-scope` —— core 的 v2 typed scope 键(`user:<enc>` / `proj:<enc>/…`);
6
+ * · `rule-owner-key` —— 规则桶键 `sha256("principal:" + principal)` 的十六进制(#154 车二的
7
+ * `buildRuleOwnerKey`)。身份在**hash 原像**里,所以「旧值 → 新值」这一对必须**算**出来,
8
+ * 不能拿 principal 本身去比 —— 这正是 A-010.16 里残留计数恒报 0 的根因:旧式按明文 principal
9
+ * 查 `owner_key`,那个谓词在这张表上**永远匹配不到任何一行**。
10
+ */
11
+ export type IdentityEncoding = "verbatim" | "memory-scope" | "rule-owner-key";
4
12
  /**
5
13
  * 腿的执行形。
6
14
  * · `bulk-rebind` —— 通用的等值 UPDATE(绝大多数腿);
7
15
  * · `session-policy-rekey` —— 逐行**重算主键**(policy_key = sha256([sessionId, principal]),身份在键的
8
16
  * hash 原像里,等值 UPDATE 够不着);
9
- * · `blob-rewrite` —— 逐行改写 **JSON 载荷列**里的身份字段。
17
+ * · `blob-rewrite` —— 逐行改写 **JSON 载荷列**里的身份字段;
18
+ * · `rule-owner-rekey` —— 规则桶的**整桶改键**(owner_key = sha256 原像里带身份,与 session-policy-rekey
19
+ * 同族)。与那条腿的差别:桶键**不含**任何逐行变量(只有 principal),所以不必逐行扫出来重算 ——
20
+ * 一对 (fromKey, toKey) 算一次,一条等值 UPDATE 走完全表。
10
21
  *
11
22
  * 🔴 `blob-rewrite` 为什么必须存在(codex 对抗复审 R1-F2,亲核属实):本仓有三张表的 JSON 载荷是**真源**,
12
23
  * 身份列只是它的投影,而读面读的是载荷:
@@ -18,7 +29,7 @@ export type IdentityEncoding = "verbatim" | "memory-scope";
18
29
  * 只迁投影 = 库里两份身份说法不一致,而**说了算的那份没迁**。这一形在空表上完全无声(集成种子若把载荷
19
30
  * 写成 `{}` 就永远测不出来)。
20
31
  */
21
- export type LegKind = "bulk-rebind" | "session-policy-rekey" | "blob-rewrite";
32
+ export type LegKind = "bulk-rebind" | "session-policy-rekey" | "blob-rewrite" | "rule-owner-rekey";
22
33
  /** `blob-rewrite` 腿的坐标。 */
23
34
  export interface BlobRewriteSpec {
24
35
  /** JSON 载荷列(真源)。 */
@@ -121,6 +132,29 @@ export declare const IDENTITY_WIDTH_LIMITS: readonly {
121
132
  }[];
122
133
  /** memory scope 列宽(两方言 VARCHAR(190) 同宽;段编码后才是真长度)。 */
123
134
  export declare const MEMORY_SCOPE_MAX_CHARS = 190;
135
+ /**
136
+ * 收编身份的**键空间卫生门**(A-010.15,验真后修)。纯函数、零 I/O ⇒ 在铸任何行之前判。
137
+ *
138
+ * ── 病 ──────────────────────────────────────────────────────────────────────────────────────────
139
+ * `adoption_log.from_principal` 上的 UNIQUE 是 183 D7「同一个源不许被收编两次」的**数据库强形**,
140
+ * 而这张表的 MySQL 臂是 `VARCHAR(190) … COLLATE utf8mb4_bin`。`utf8mb4_bin` 虽然是 binary collation,
141
+ * **却仍然是 PAD SPACE 的**(MySQL 只有 `utf8mb4_0900_*` 族是 NO PAD)—— 等值比较与唯一键检查会把两侧
142
+ * 右填充到等长。于是 `"user:a"` 与 `"user:a "` 在 MySQL 腿上是**同一个键**,在 PG 腿(`COLLATE "C"`,
143
+ * 无填充)上是**两个键**。同一份代码、同一个请求,两个后端上结局不同:
144
+ * · MySQL:第二次收编被 `adoption.source_already_bound` 拒 —— 一个运维完全看不懂的 409;
145
+ * · PG:落第二行,弧照跑,却**一行都迁不到**(别的表里没有任何列存着带尾空格的那个身份)。
146
+ *
147
+ * ── 为什么修在边界而不是把列改成 VARBINARY(本仓已有的字节孪生形)─────────────────────────────
148
+ * 本仓对**同一族**问题已经裁过一次,逐字在 `plugins/approval-ask-store-sql.ts` 的
149
+ * `assertIdempotencyKeyShape` 头注里:「在边界上 fail-loud 拒掉,方言分歧面整个消失(比把列改
150
+ * VARBINARY 更窄、且对三形同时成立)」。这里的判据比那次还硬一层 —— **带首尾空白的 principal 在本仓
151
+ * 根本不可能是一个真身份**:`security.ts` 的 `principalFrom` 读头时逐字 `.trim()`,所以没有任何
152
+ * owner/scope 列里存得下带尾空格的值。收编一个这样的源身份,能迁到的行恒为零。
153
+ * 拒 = 把一个注定空转(或注定撞出误导性 409)的请求在**入库之前**变成一句说得清的 400。
154
+ * 改列则要动 schema + 基线 + 双库,却只把 MySQL 腿对齐到 PG 腿,对「这个值本来就不是合法身份」这件事
155
+ * 一个字都没说 —— 那才是治标。
156
+ */
157
+ export declare function checkAdoptionPrincipalShape(fromPrincipal: string, toPrincipal: string): string | undefined;
124
158
  /**
125
159
  * 目的地身份能不能被每一根被写列容纳?返回**人可读的拒绝理由**,`undefined` = 可以。
126
160
  * 纯函数(零 I/O),所以校验发生在任何库动作之前。
@@ -382,6 +382,32 @@ export const REBIND_LEGS = [
382
382
  residualKey: ["session_id"], // policy_key = sha256([session_id, principal]) ⇒ 同 session 的目的地行即冲突
383
383
  why: "E6 逐会话逐 principal 的工具收紧规则:身份在**主键的 hash 原像**里,等值 UPDATE 够不着 ⇒ 逐行读出→重算 policy_key→改写",
384
384
  },
385
+ // ── 规则桶的整桶改键(A-010.4 / A-010.16 / A-010.22)────────────────────────────────────────────
386
+ {
387
+ leg: "permission_rule#owner_key",
388
+ table: "permission_rule",
389
+ kind: "rule-owner-rekey",
390
+ action: "bucket-rebind",
391
+ // 两列同腿:`owner_key` 是**派生**的桶键、`principal` 是它的明文原像列(店按前者寻址、运维按后者读)。
392
+ // 只改一列 = 库里两份身份说法不一致,而寻址用的那份与人读的那份互相矛盾。
393
+ columns: ["owner_key", "principal"],
394
+ matchColumn: "owner_key",
395
+ encoding: "rule-owner-key",
396
+ // PK(owner_key) ⇒ 唯一键**就是**这根轴本身 ⇒ 目的地已有桶即冲突(退化成两侧各有任意一行)。
397
+ // 与 agent_memory_engine_cursor 的 PK(scope) 同形,故 `[]` 而不是 undefined。
398
+ //
399
+ // 🔴 连带的一条真语义(真双库上实测出来的,不是推的):**迟到桶清扫不掉,而这是对的**。
400
+ // 终态之后旧身份下若又长出一只桶(= 部署配置没随迁,还在按旧身份导规则),改键式清扫会撞上新身份
401
+ // 已经占住的那把 PK ⇒ 腿抛 ⇒ `sweepLate` 捕获 + 响亮 warn,残留读数继续报非零。
402
+ // 不给它加「合并」臂:两只桶合并是一次 **CRDT 语义 join**(core `applySyncJoin` 的管辖面),
403
+ // 收编弧自己发明一个 join 就是在放行面上编语义 —— 本车的既定口径是「不做逐表 merge,拒比合安全
404
+ // 且可重来」。运维侧的可行动信号是三处同时指向同一个真因:一行 warn + 不肯归零的
405
+ // `residualSourceRows` + 非空的 `outstandingConfigs`。
406
+ residualKey: [],
407
+ why: "#154 车二的**常驻放行桶** —— 一条 allow 规则就是「这个人对这条命令的持久同意」,是本仓最直白的**活授权轴**。" +
408
+ "core 5.23.0 form a 把 permission rules 列为身份轴重绑腿之首(A-010.4:server form b 此前与它有实质分歧);" +
409
+ "#203 把 `PERMISSION_RULES_ENABLED` 默认翻 ON 之后,不迁 = 收编完成而**旧身份名下仍躺着一桶活着的放行规则**",
410
+ },
385
411
  ];
386
412
  /**
387
413
  * server 侧**按设计不迁**的面(183 §12 裁决号在案)。这份清单进回执的 `notMigratedByDesign`,
@@ -406,6 +432,22 @@ export const NOT_MIGRATED_BY_DESIGN = [
406
432
  { face: "approval-exemption-grantor", ruling: "D2", reason: "approval_exemption.granted_by is a historical fact (who granted this exemption), not a live authorization axis" },
407
433
  { face: "approval-ask-decision-actor", ruling: "D2", reason: "approval_ask.decision_actor / decision_note record who decided — historical audit fields, never rewritten" },
408
434
  { face: "image-bake-requested-by", ruling: "D2", reason: "image_bake.requested_by records who requested the build — historical audit field" },
435
+ // 🔴 A-010.22:规则店族的**分家表态**。同一族三张表,三个不同的结局,回执必须逐个说清楚:
436
+ // · `permission_rule`(活桶) → **真迁**,见 REBIND_LEGS 的 `permission_rule#owner_key` 腿;
437
+ // · `permission_rule_approval`(审批史)→ D2,下面这一员;
438
+ // · `permission_rule_ticket`(导入票) → D1,下面那一员。
439
+ // 判据与既有 D1/D2 六员逐字同源,不是为这一族新造的标准:改写「谁批准过哪条规则」= 伪造审计;
440
+ // 分钟级 TTL 的一次性导入票是治理瞬态,过期即死,搬过去没有任何可兑付的价值。
441
+ {
442
+ face: "permission-rule-approval-history",
443
+ ruling: "D2",
444
+ reason: "permission_rule_approval records WHICH principal approved WHICH rule candidates — a historical audit fact, never rewritten (the live grants those approvals produced DO migrate, on the permission_rule#owner_key leg)",
445
+ },
446
+ {
447
+ face: "permission-rule-import-ticket",
448
+ ruling: "D1",
449
+ reason: "permission_rule_ticket is a minute-TTL single-use CC-import ticket bound to the minting principal — a governance transient that dies on expiry; an in-flight ticket at adoption time means the engine was not stopped first (183 I1), so it is left to expire rather than re-bound",
450
+ },
409
451
  ];
410
452
  export const AFFECTED_CONFIG_TEMPLATES = [
411
453
  { deployment: "web-client-bff", key: "BFF injected principal (bff/server.mjs)", requiredValue: "%TO%" },
@@ -451,6 +493,36 @@ function scopeSegmentRejection(label, principal) {
451
493
  }
452
494
  /** memory scope 列宽(两方言 VARCHAR(190) 同宽;段编码后才是真长度)。 */
453
495
  export const MEMORY_SCOPE_MAX_CHARS = 190;
496
+ /**
497
+ * 收编身份的**键空间卫生门**(A-010.15,验真后修)。纯函数、零 I/O ⇒ 在铸任何行之前判。
498
+ *
499
+ * ── 病 ──────────────────────────────────────────────────────────────────────────────────────────
500
+ * `adoption_log.from_principal` 上的 UNIQUE 是 183 D7「同一个源不许被收编两次」的**数据库强形**,
501
+ * 而这张表的 MySQL 臂是 `VARCHAR(190) … COLLATE utf8mb4_bin`。`utf8mb4_bin` 虽然是 binary collation,
502
+ * **却仍然是 PAD SPACE 的**(MySQL 只有 `utf8mb4_0900_*` 族是 NO PAD)—— 等值比较与唯一键检查会把两侧
503
+ * 右填充到等长。于是 `"user:a"` 与 `"user:a "` 在 MySQL 腿上是**同一个键**,在 PG 腿(`COLLATE "C"`,
504
+ * 无填充)上是**两个键**。同一份代码、同一个请求,两个后端上结局不同:
505
+ * · MySQL:第二次收编被 `adoption.source_already_bound` 拒 —— 一个运维完全看不懂的 409;
506
+ * · PG:落第二行,弧照跑,却**一行都迁不到**(别的表里没有任何列存着带尾空格的那个身份)。
507
+ *
508
+ * ── 为什么修在边界而不是把列改成 VARBINARY(本仓已有的字节孪生形)─────────────────────────────
509
+ * 本仓对**同一族**问题已经裁过一次,逐字在 `plugins/approval-ask-store-sql.ts` 的
510
+ * `assertIdempotencyKeyShape` 头注里:「在边界上 fail-loud 拒掉,方言分歧面整个消失(比把列改
511
+ * VARBINARY 更窄、且对三形同时成立)」。这里的判据比那次还硬一层 —— **带首尾空白的 principal 在本仓
512
+ * 根本不可能是一个真身份**:`security.ts` 的 `principalFrom` 读头时逐字 `.trim()`,所以没有任何
513
+ * owner/scope 列里存得下带尾空格的值。收编一个这样的源身份,能迁到的行恒为零。
514
+ * 拒 = 把一个注定空转(或注定撞出误导性 409)的请求在**入库之前**变成一句说得清的 400。
515
+ * 改列则要动 schema + 基线 + 双库,却只把 MySQL 腿对齐到 PG 腿,对「这个值本来就不是合法身份」这件事
516
+ * 一个字都没说 —— 那才是治标。
517
+ */
518
+ export function checkAdoptionPrincipalShape(fromPrincipal, toPrincipal) {
519
+ for (const [label, p] of [["fromPrincipal", fromPrincipal], ["toPrincipal", toPrincipal]]) {
520
+ if (p.trim() !== p) {
521
+ return `${label} has leading or trailing whitespace (${JSON.stringify(p)}). A verified principal never does — the identity header is trimmed at the door (security.ts principalFrom), so no owner/scope column in this deployment can hold this value and the adoption would migrate nothing. It is also not portable: the MySQL-protocol arm's utf8mb4_bin collation is PAD SPACE, so this key is EQUAL to the trimmed one there while PostgreSQL's COLLATE "C" treats them as two distinct keys.`;
522
+ }
523
+ }
524
+ return undefined;
525
+ }
454
526
  /**
455
527
  * 目的地身份能不能被每一根被写列容纳?返回**人可读的拒绝理由**,`undefined` = 可以。
456
528
  * 纯函数(零 I/O),所以校验发生在任何库动作之前。
@@ -0,0 +1,70 @@
1
+ /**
2
+ * A-010.18 —— 收编弧跑动期间的**进程内静默闸**(design/183 I1「引擎先停」在本进程里的执法面)。
3
+ *
4
+ * ── 病 ──────────────────────────────────────────────────────────────────────────────────────────
5
+ * 183 I1 写的是「发起收编之前必须先把引擎停掉」。那是一条**运维前提**,而本进程此前对它**零执法**:
6
+ * `POST /v1/adoption` 收下请求、弧开始逐表 UPDATE 的同时,本副本的维护 tick 照常每几秒跑一轮,
7
+ * 而那一轮里好几条腿写的正是被迁的那些表。最锋利的一条是**审批收敛器**(`approval-reconciler.ts`):
8
+ * 它按 `owner` 读 PARKING/STREAM_PENDING 行并 CAS 结算。收编把 `approval_ask.owner` 从 A 改成 B 的
9
+ * 那一瞬,收敛器手里可能正握着按**旧** owner 读出来的一批行 —— 结算下去就是把一只本该 PARKED 的 ask
10
+ * 写成 **DENIED**,而 DENIED 是终局:收编回滚不了它,重跑也追认不回来。
11
+ *
12
+ * ── 修 ──────────────────────────────────────────────────────────────────────────────────────────
13
+ * 弧跑动期间把维护 tick 整轮让开。判据不是「挑几条腿避让」而是**整轮**:
14
+ * ① 维护 tick 是 best-effort 的周期动作(每条腿都幂等、都能等下一轮),让开几轮零代价;而逐腿判
15
+ * 「这条腿碰不碰被迁的表」会立刻变成第二份需要与 `REBIND_LEGS` 同步的表 —— 那正是本仓反复吃过亏的
16
+ * 「两份判据必然漂」形(漏判一条新腿是**静默**的:它照写不误)。
17
+ * ② 弧本身是毫秒级的几条 UPDATE,库里常态在飞行数为 0 —— 让开的窗极短。
18
+ *
19
+ * ── 射程边界(如实登记,不假称已解)──────────────────────────────────────────────────────────────
20
+ * 两条边界,都写在明处:
21
+ * ① **进程内**。这道闸只挡得住**运行这条弧的那个副本**;多副本部署里别的副本的 tick 照跑,那部分仍然
22
+ * 只能靠 183 I1 的运维前提(以及弧内那些 rev-CAS:载荷改写撞上并发写会响亮抛、整体回滚)。
23
+ * 更强的形(跨副本静默)需要一枚库上的 quiesce 租约 + 每条腿的取租,那是一次独立设计,不在本条射程。
24
+ * ② **排空有界**。等不到就带着读数继续(见下),所以「本进程零腿在写被迁的表」是**通常**成立而不是
25
+ * 恒成立 —— 超时那一次由 `onDrainTimeout` 响亮说出来,不许静默当成已排空。
26
+ * 本闸因此是**纵深防御**,不是把 I1 变成机器保证。
27
+ *
28
+ * ── 形态 ────────────────────────────────────────────────────────────────────────────────────────
29
+ * 模块级计数器而不是布尔:并发同参 POST 是本车明确支持的形(UNIQUE 仲裁),两条弧可以叠在一起,
30
+ * 布尔会被先结束的那条**提前**放开闸。计数器 + `try/finally` ⇒ 抛错也一定归零。
31
+ */
32
+ /** 本进程此刻是否有收编弧在跑(维护 tick 的让路判据)。 */
33
+ export declare function isAdoptionQuiescing(): boolean;
34
+ /**
35
+ * 登记一条**刚起飞**的维护腿。调用方仍然 `void` 它自己的链(错误处置不变);本函数只借用它的落地时刻。
36
+ *
37
+ * `catch` 是必须的:登记的这一份引用绝不能变成第二个未处理拒绝源(腿自己的链已经有 guard 接住了)。
38
+ */
39
+ export declare function trackMaintenanceLeg(leg: Promise<unknown>): void;
40
+ /** 取证口(测试用):此刻有几只等待方挂着。生产不读 —— 它存在是为了让「重复超时不泄漏」可被断言。 */
41
+ export declare function drainWaiterCountForTest(): number;
42
+ /** 排空结果:`drained` = 全部落地;否则 `pending` = 超时时仍在飞的条数。 */
43
+ export interface DrainReading {
44
+ drained: boolean;
45
+ pending: number;
46
+ }
47
+ /** 有界排空(见 {@link airborneLegs} 的注)。`timeoutMs <= 0` ⇒ 不等,直接如实回报在飞数。 */
48
+ export declare function drainMaintenanceLegs(timeoutMs: number): Promise<DrainReading>;
49
+ /** 排空的默认上界。取值理由:维护腿的常态是毫秒级的几条 SQL;5s 足够让它们全部落地,又不会把一次
50
+ * 收编吊死在一条卡住的腿上(卡住的那一条由它自己的重入守卫与 throttled-warn 负责被看见)。 */
51
+ export declare const ADOPTION_DRAIN_TIMEOUT_MS = 5000;
52
+ export interface QuiesceOpts {
53
+ /** 排空上界(测试可调小);缺省 {@link ADOPTION_DRAIN_TIMEOUT_MS}。 */
54
+ drainTimeoutMs?: number;
55
+ /** 没排空时的**响亮**出口(生产接 logger.warn)。缺席 ⇒ 不复述(读数仍由返回值带出)。 */
56
+ onDrainTimeout?: (reading: DrainReading) => void;
57
+ }
58
+ /**
59
+ * 在闸内跑一条收编弧:**先关闸,再排空,最后才动数据**。
60
+ *
61
+ * 三步的次序是承重的 —— 先关闸(此后不会再有新 tick 起飞新腿),再等已经在飞的那些落地,然后才把
62
+ * 控制权交给弧。反过来(先排空再关闸)之间那道窗里正好可以起飞新的一轮。
63
+ *
64
+ * `finally` 归零是硬的:抛出去的异常照常上抛(调用方的错误语义一个字不改),但闸绝不能因为一次失败
65
+ * 就永久关着 —— 那会把一次收编失败升级成「这台副本从此不再做任何后台维护」。
66
+ */
67
+ export declare function withAdoptionQuiesce<T>(fn: () => Promise<T>, opts?: QuiesceOpts): Promise<T>;
68
+ /** 测试用的复位口(用例之间零串味)。生产路径**不调** —— 它会把别的在飞弧的计数一起抹掉。 */
69
+ export declare function resetAdoptionQuiesceForTest(): void;
70
+ //# sourceMappingURL=quiesce.d.ts.map
@@ -0,0 +1,148 @@
1
+ /**
2
+ * A-010.18 —— 收编弧跑动期间的**进程内静默闸**(design/183 I1「引擎先停」在本进程里的执法面)。
3
+ *
4
+ * ── 病 ──────────────────────────────────────────────────────────────────────────────────────────
5
+ * 183 I1 写的是「发起收编之前必须先把引擎停掉」。那是一条**运维前提**,而本进程此前对它**零执法**:
6
+ * `POST /v1/adoption` 收下请求、弧开始逐表 UPDATE 的同时,本副本的维护 tick 照常每几秒跑一轮,
7
+ * 而那一轮里好几条腿写的正是被迁的那些表。最锋利的一条是**审批收敛器**(`approval-reconciler.ts`):
8
+ * 它按 `owner` 读 PARKING/STREAM_PENDING 行并 CAS 结算。收编把 `approval_ask.owner` 从 A 改成 B 的
9
+ * 那一瞬,收敛器手里可能正握着按**旧** owner 读出来的一批行 —— 结算下去就是把一只本该 PARKED 的 ask
10
+ * 写成 **DENIED**,而 DENIED 是终局:收编回滚不了它,重跑也追认不回来。
11
+ *
12
+ * ── 修 ──────────────────────────────────────────────────────────────────────────────────────────
13
+ * 弧跑动期间把维护 tick 整轮让开。判据不是「挑几条腿避让」而是**整轮**:
14
+ * ① 维护 tick 是 best-effort 的周期动作(每条腿都幂等、都能等下一轮),让开几轮零代价;而逐腿判
15
+ * 「这条腿碰不碰被迁的表」会立刻变成第二份需要与 `REBIND_LEGS` 同步的表 —— 那正是本仓反复吃过亏的
16
+ * 「两份判据必然漂」形(漏判一条新腿是**静默**的:它照写不误)。
17
+ * ② 弧本身是毫秒级的几条 UPDATE,库里常态在飞行数为 0 —— 让开的窗极短。
18
+ *
19
+ * ── 射程边界(如实登记,不假称已解)──────────────────────────────────────────────────────────────
20
+ * 两条边界,都写在明处:
21
+ * ① **进程内**。这道闸只挡得住**运行这条弧的那个副本**;多副本部署里别的副本的 tick 照跑,那部分仍然
22
+ * 只能靠 183 I1 的运维前提(以及弧内那些 rev-CAS:载荷改写撞上并发写会响亮抛、整体回滚)。
23
+ * 更强的形(跨副本静默)需要一枚库上的 quiesce 租约 + 每条腿的取租,那是一次独立设计,不在本条射程。
24
+ * ② **排空有界**。等不到就带着读数继续(见下),所以「本进程零腿在写被迁的表」是**通常**成立而不是
25
+ * 恒成立 —— 超时那一次由 `onDrainTimeout` 响亮说出来,不许静默当成已排空。
26
+ * 本闸因此是**纵深防御**,不是把 I1 变成机器保证。
27
+ *
28
+ * ── 形态 ────────────────────────────────────────────────────────────────────────────────────────
29
+ * 模块级计数器而不是布尔:并发同参 POST 是本车明确支持的形(UNIQUE 仲裁),两条弧可以叠在一起,
30
+ * 布尔会被先结束的那条**提前**放开闸。计数器 + `try/finally` ⇒ 抛错也一定归零。
31
+ */
32
+ /** 当前正在本进程里跑的收编弧条数。**只**由 {@link withAdoptionQuiesce} 增减。 */
33
+ let inFlight = 0;
34
+ /** 本进程此刻是否有收编弧在跑(维护 tick 的让路判据)。 */
35
+ export function isAdoptionQuiescing() {
36
+ return inFlight > 0;
37
+ }
38
+ /**
39
+ * **已经起飞**的维护腿(codex 对抗复审 R1 [high],验真后修)。
40
+ *
41
+ * 🔴 病:第一版的闸只在 tick 的**入口**判一次。可维护 tick 的每条腿都是 `void <promise>` 异步起飞的
42
+ * —— tick 回调本身立刻返回。于是这个次序完全可能:`t0` tick 起飞了审批收敛器(它按旧 owner 读出一批
43
+ * 行,正等库回话)→ `t0+ε` 收编弧开始改 `approval_ask.owner` → 收敛器拿着旧读数回来 CAS,把一只本该
44
+ * PARKED 的 ask 结算成 **DENIED**。也就是说:**这正是本条声称要消灭的那一形**,闸只是让它更难撞上。
45
+ * 我原来的钉也测不出来 —— 它先开闸再推进时钟,探针又是立即完成的,反向次序根本没被覆盖。
46
+ *
47
+ * 🔴 修:闸不止「拦住下一轮」,还要**排空这一轮**。每条腿起飞时在这里登记,收编弧进库之前先等它们落地。
48
+ *
49
+ * ⏱ **有界等待**是硬的:维护腿里有会长时间挂住的(`worktreeReap` 的 `git worktree prune` 随 worktree
50
+ * 数增长,它自己的重入守卫就是为此而设)。无界 await 会把「一条腿卡住」升级成「收编永远起不来」——
51
+ * 比原病更坏。所以排空是**尽力而为 + 响亮**:超时就带着读数继续,让运维看得见「这次收编没能等到 N 条
52
+ * 腿落地」,而不是既没等到也没人知道。
53
+ */
54
+ const airborneLegs = new Set();
55
+ /**
56
+ * 登记一条**刚起飞**的维护腿。调用方仍然 `void` 它自己的链(错误处置不变);本函数只借用它的落地时刻。
57
+ *
58
+ * `catch` 是必须的:登记的这一份引用绝不能变成第二个未处理拒绝源(腿自己的链已经有 guard 接住了)。
59
+ */
60
+ export function trackMaintenanceLeg(leg) {
61
+ const tracked = leg.catch(() => undefined);
62
+ airborneLegs.add(tracked);
63
+ void tracked.finally(() => {
64
+ airborneLegs.delete(tracked);
65
+ // 集合空了才是「排空」——通知所有在等的收编弧。这是**唯一**挂在腿身上的 handler(每条腿恰一个,
66
+ // 在它起飞时挂),排空等待方一律等下面那只**自己的** waiter,不再往腿上加第二个 handler。
67
+ if (airborneLegs.size === 0) {
68
+ for (const wake of drainWaiters)
69
+ wake();
70
+ drainWaiters.clear();
71
+ }
72
+ });
73
+ }
74
+ /**
75
+ * 正在等排空的收编弧(codex 对抗复审 R2 [medium],验真后修)。
76
+ *
77
+ * 🔴 病:第一版每次排空都现铸 `Promise.allSettled([...airborneLegs])`。**超时那一路**里,那只 allSettled
78
+ * 永远不会兑现,而它已经把 handler 挂在了每一条挂死的腿上 —— 且 `clearTimeout` 清不掉这些 promise
79
+ * reaction。于是「有一条腿永久挂住」这个**本来就是有界逃逸要处理的**场景,会让每一次 POST / 每一次 boot
80
+ * 续跑再挂一批清不掉的闭包上去:逃逸机制自己变成了泄漏源。
81
+ *
82
+ * 🔴 修:等待方注册一只**自己的** waiter,超时时把它注销掉(`finally` 里 delete)。腿身上的 handler 恒为
83
+ * 「起飞时挂的那一个」,与等待次数无关 ⇒ 重复超时不再增长任何东西。
84
+ */
85
+ const drainWaiters = new Set();
86
+ /** 取证口(测试用):此刻有几只等待方挂着。生产不读 —— 它存在是为了让「重复超时不泄漏」可被断言。 */
87
+ export function drainWaiterCountForTest() {
88
+ return drainWaiters.size;
89
+ }
90
+ /** 有界排空(见 {@link airborneLegs} 的注)。`timeoutMs <= 0` ⇒ 不等,直接如实回报在飞数。 */
91
+ export async function drainMaintenanceLegs(timeoutMs) {
92
+ if (airborneLegs.size === 0)
93
+ return { drained: true, pending: 0 };
94
+ if (timeoutMs <= 0)
95
+ return { drained: false, pending: airborneLegs.size };
96
+ let wake;
97
+ const drained = new Promise((resolve) => {
98
+ wake = () => resolve("drained");
99
+ });
100
+ drainWaiters.add(wake);
101
+ let timer;
102
+ const deadline = new Promise((resolve) => {
103
+ timer = setTimeout(() => resolve("timeout"), timeoutMs);
104
+ timer.unref?.(); // 排空的等待绝不该把进程吊住
105
+ });
106
+ try {
107
+ const outcome = await Promise.race([drained, deadline]);
108
+ return outcome === "drained" ? { drained: true, pending: 0 } : { drained: false, pending: airborneLegs.size };
109
+ }
110
+ finally {
111
+ if (timer !== undefined)
112
+ clearTimeout(timer);
113
+ // 🔴 注销是本修的**全部内容**:超时之后这只 waiter 不许继续挂着(见 drainWaiters 的注)。
114
+ drainWaiters.delete(wake);
115
+ }
116
+ }
117
+ /** 排空的默认上界。取值理由:维护腿的常态是毫秒级的几条 SQL;5s 足够让它们全部落地,又不会把一次
118
+ * 收编吊死在一条卡住的腿上(卡住的那一条由它自己的重入守卫与 throttled-warn 负责被看见)。 */
119
+ export const ADOPTION_DRAIN_TIMEOUT_MS = 5_000;
120
+ /**
121
+ * 在闸内跑一条收编弧:**先关闸,再排空,最后才动数据**。
122
+ *
123
+ * 三步的次序是承重的 —— 先关闸(此后不会再有新 tick 起飞新腿),再等已经在飞的那些落地,然后才把
124
+ * 控制权交给弧。反过来(先排空再关闸)之间那道窗里正好可以起飞新的一轮。
125
+ *
126
+ * `finally` 归零是硬的:抛出去的异常照常上抛(调用方的错误语义一个字不改),但闸绝不能因为一次失败
127
+ * 就永久关着 —— 那会把一次收编失败升级成「这台副本从此不再做任何后台维护」。
128
+ */
129
+ export async function withAdoptionQuiesce(fn, opts) {
130
+ inFlight += 1; // ① 关闸:此后的 tick 一律让路
131
+ try {
132
+ // ② 排空这一轮已经起飞的腿(有界;超时如实响亮)。
133
+ const reading = await drainMaintenanceLegs(opts?.drainTimeoutMs ?? ADOPTION_DRAIN_TIMEOUT_MS);
134
+ if (!reading.drained)
135
+ opts?.onDrainTimeout?.(reading);
136
+ return await fn(); // ③ 动数据
137
+ }
138
+ finally {
139
+ inFlight -= 1;
140
+ }
141
+ }
142
+ /** 测试用的复位口(用例之间零串味)。生产路径**不调** —— 它会把别的在飞弧的计数一起抹掉。 */
143
+ export function resetAdoptionQuiesceForTest() {
144
+ inFlight = 0;
145
+ airborneLegs.clear();
146
+ drainWaiters.clear();
147
+ }
148
+ //# sourceMappingURL=quiesce.js.map