@sema-agent/server 7.138.0 → 7.139.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 (215) hide show
  1. package/MIGRATION.md +276 -7
  2. package/README.md +7 -4
  3. package/README.zh-CN.md +6 -4
  4. package/USAGE.md +73 -29
  5. package/deploy/sema-up/README.md +1 -1
  6. package/deploy/sema-up/lib/lanes.json +3 -2
  7. package/dist/adoption/wire.d.ts +3 -3
  8. package/dist/approval-card.d.ts +20 -20
  9. package/dist/bench/s1/arms.d.ts +1 -1
  10. package/dist/bench/s1/arms.js +1 -3
  11. package/dist/bench/s1/run-firm.js +2 -2
  12. package/dist/boot/device-lane.d.ts +8 -3
  13. package/dist/boot/device-lane.js +7 -2
  14. package/dist/boot/execution-env.d.ts +4 -2
  15. package/dist/boot/execution-env.js +16 -24
  16. package/dist/boot/execution-lane.d.ts +1 -1
  17. package/dist/boot/governance-seams.d.ts +3 -2
  18. package/dist/boot/governance-seams.js +9 -11
  19. package/dist/boot/memory-consolidation.js +2 -2
  20. package/dist/boot/resolve-spec.d.ts +1 -1
  21. package/dist/boot/resolve-spec.js +8 -9
  22. package/dist/boot/retention-lane.d.ts +5 -5
  23. package/dist/boot/retention-lane.js +0 -6
  24. package/dist/boot/runner-deps.d.ts +3 -1
  25. package/dist/boot/runner-deps.js +2 -1
  26. package/dist/boot/session-faces.d.ts +0 -3
  27. package/dist/boot/session-faces.js +15 -8
  28. package/dist/boot/stage-02-web-admin-pull.js +2 -2
  29. package/dist/boot/stage-05-execution-env.js +2 -2
  30. package/dist/boot/stage-06-runners.js +0 -1
  31. package/dist/boot/stage-09-leader.js +1 -2
  32. package/dist/boot/stage-10-http-server.js +2 -2
  33. package/dist/boot/stores.js +4 -4
  34. package/dist/boot/web-admin.js +2 -2
  35. package/dist/capabilities/mcp-probe.js +1 -1
  36. package/dist/capabilities/scenarios.d.ts +1 -1
  37. package/dist/capability-facts.d.ts +15 -7
  38. package/dist/capability-facts.js +8 -3
  39. package/dist/config-catalog.js +7 -7
  40. package/dist/config-invariants.d.ts +23 -3
  41. package/dist/config-invariants.js +42 -0
  42. package/dist/config-provider.d.ts +19 -24
  43. package/dist/config-provider.js +27 -12
  44. package/dist/config-types.d.ts +10 -5
  45. package/dist/config.d.ts +33 -0
  46. package/dist/config.js +44 -15
  47. package/dist/delegated-session-owner.d.ts +2 -2
  48. package/dist/deployment-shape.d.ts +1 -1
  49. package/dist/device-enrollment.d.ts +41 -2
  50. package/dist/device-enrollment.js +27 -12
  51. package/dist/device-store.d.ts +58 -85
  52. package/dist/device-store.js +19 -49
  53. package/dist/device-ws-hub.js +2 -1
  54. package/dist/env-name-allowlist-knobs.d.ts +5 -4
  55. package/dist/exec-cut.d.ts +60 -0
  56. package/dist/exec-cut.js +36 -0
  57. package/dist/execution-lane-caps.d.ts +19 -0
  58. package/dist/execution-lane-caps.js +10 -0
  59. package/dist/execution-placement.d.ts +259 -0
  60. package/dist/execution-placement.js +199 -0
  61. package/dist/field-conflict.d.ts +3 -2
  62. package/dist/field-conflict.js +1 -0
  63. package/dist/hosted-posture.d.ts +2 -2
  64. package/dist/http/admission.js +8 -19
  65. package/dist/http/checkpoint-ctx.d.ts +21 -12
  66. package/dist/http/checkpoint-ctx.js +4 -2
  67. package/dist/http/claimed-run.d.ts +1 -1
  68. package/dist/http/durable-run-leg.js +9 -5
  69. package/dist/http/execution-context.d.ts +218 -0
  70. package/dist/http/execution-context.js +330 -0
  71. package/dist/http/resume-legs.js +50 -20
  72. package/dist/http/route-ctx.d.ts +14 -8
  73. package/dist/http/routes/background.d.ts +29 -1
  74. package/dist/http/routes/background.js +29 -2
  75. package/dist/http/routes/devices.d.ts +13 -19
  76. package/dist/http/routes/devices.js +21 -26
  77. package/dist/http/routes/session-sync.js +110 -15
  78. package/dist/http/routes/sessions.d.ts +6 -0
  79. package/dist/http/routes/sessions.js +115 -11
  80. package/dist/http/routes/side-query.d.ts +12 -4
  81. package/dist/http/routes/side-query.js +4 -5
  82. package/dist/http/routes/tasks.js +37 -31
  83. package/dist/http/server.d.ts +35 -19
  84. package/dist/http/server.js +11 -0
  85. package/dist/http/session-inherit-env.d.ts +3 -4
  86. package/dist/http/session-inherit-env.js +1 -1
  87. package/dist/http/wire-types.d.ts +8 -12
  88. package/dist/index.d.ts +1 -0
  89. package/dist/memory-sync-client.d.ts +53 -14
  90. package/dist/memory-sync-client.js +196 -116
  91. package/dist/memory-sync.d.ts +1 -1
  92. package/dist/memory-sync.js +3 -2
  93. package/dist/observability/cost-taxonomy.d.ts +2 -2
  94. package/dist/observability/fail-open.d.ts +12 -0
  95. package/dist/observability/fail-open.js +12 -0
  96. package/dist/observability/run-terminal.js +0 -1
  97. package/dist/orchestration/workflow-notify-journal.js +4 -4
  98. package/dist/org-memory-admission.d.ts +4 -3
  99. package/dist/org-memory-admission.js +6 -6
  100. package/dist/parked-decide.d.ts +34 -9
  101. package/dist/parked-decide.js +12 -19
  102. package/dist/parked-revive-surface.d.ts +12 -15
  103. package/dist/parked-revive-surface.js +3 -6
  104. package/dist/peer-seat.d.ts +13 -7
  105. package/dist/peer-seat.js +37 -3
  106. package/dist/plugins/approval-ask-store-file.d.ts +7 -0
  107. package/dist/plugins/approval-ask-store-file.js +11 -0
  108. package/dist/plugins/approval-ask-store-memory.d.ts +6 -0
  109. package/dist/plugins/approval-ask-store-memory.js +12 -0
  110. package/dist/plugins/approval-ask-store-sql.d.ts +22 -5
  111. package/dist/plugins/approval-ask-store-sql.js +30 -12
  112. package/dist/plugins/caching-session-store.d.ts +6 -2
  113. package/dist/plugins/caching-session-store.js +9 -3
  114. package/dist/plugins/checkpoint-store-sql.d.ts +6 -5
  115. package/dist/plugins/checkpoint-store-sql.js +5 -7
  116. package/dist/plugins/device-store-sql.d.ts +25 -51
  117. package/dist/plugins/device-store-sql.js +49 -103
  118. package/dist/plugins/execution-placement-sql.d.ts +151 -0
  119. package/dist/plugins/execution-placement-sql.js +223 -0
  120. package/dist/plugins/file-history-store-sql.d.ts +7 -6
  121. package/dist/plugins/file-history-store-sql.js +5 -5
  122. package/dist/plugins/file-run-store.d.ts +6 -1
  123. package/dist/plugins/file-run-store.js +4 -0
  124. package/dist/plugins/fork-routing-session-store.d.ts +6 -0
  125. package/dist/plugins/fork-routing-session-store.js +16 -0
  126. package/dist/plugins/local-session-store.d.ts +149 -50
  127. package/dist/plugins/local-session-store.js +382 -107
  128. package/dist/plugins/memory-key-guards.d.ts +4 -0
  129. package/dist/plugins/memory-key-guards.js +1 -0
  130. package/dist/plugins/memory-run-store.d.ts +1 -1
  131. package/dist/plugins/one-time-migrations.d.ts +111 -3
  132. package/dist/plugins/one-time-migrations.js +380 -10
  133. package/dist/plugins/outcome-ledger-sql.d.ts +2 -2
  134. package/dist/plugins/permission-rule-store-sql.d.ts +13 -15
  135. package/dist/plugins/permission-rule-store-sql.js +13 -16
  136. package/dist/plugins/pg-pool.d.ts +7 -3
  137. package/dist/plugins/pg-session-storage.d.ts +31 -20
  138. package/dist/plugins/pg-session-storage.js +120 -52
  139. package/dist/plugins/remote-env-adb.d.ts +20 -4
  140. package/dist/plugins/remote-env-adb.js +70 -62
  141. package/dist/plugins/remote-env-device.d.ts +30 -12
  142. package/dist/plugins/remote-env-device.js +18 -17
  143. package/dist/plugins/remote-env-e2b.d.ts +18 -4
  144. package/dist/plugins/remote-env-e2b.js +41 -42
  145. package/dist/plugins/remote-env-file-error.d.ts +2 -0
  146. package/dist/plugins/remote-env-file-error.js +1 -1
  147. package/dist/plugins/remote-env-host.d.ts +21 -8
  148. package/dist/plugins/remote-env-host.js +66 -70
  149. package/dist/plugins/remote-env-k8s.d.ts +11 -16
  150. package/dist/plugins/remote-env-k8s.js +61 -54
  151. package/dist/plugins/remote-env-local-docker.d.ts +29 -6
  152. package/dist/plugins/remote-env-local-docker.js +46 -59
  153. package/dist/plugins/remote-env-ssh.d.ts +16 -4
  154. package/dist/plugins/remote-env-ssh.js +72 -56
  155. package/dist/plugins/remote-shell.d.ts +100 -3
  156. package/dist/plugins/remote-shell.js +166 -2
  157. package/dist/plugins/retention-store-sql.d.ts +9 -6
  158. package/dist/plugins/retention-store-sql.js +7 -1
  159. package/dist/plugins/run-store-sql.d.ts +13 -3
  160. package/dist/plugins/run-store-sql.js +5 -0
  161. package/dist/plugins/schema-consistency.d.ts +15 -5
  162. package/dist/plugins/schema-consistency.js +14 -8
  163. package/dist/plugins/schema-drift-sentence.d.ts +27 -0
  164. package/dist/plugins/schema-drift-sentence.js +38 -0
  165. package/dist/plugins/session-fork.d.ts +54 -0
  166. package/dist/plugins/session-fork.js +38 -1
  167. package/dist/plugins/session-row-sql.d.ts +22 -0
  168. package/dist/plugins/session-row-sql.js +81 -0
  169. package/dist/plugins/session-store.d.ts +1 -1
  170. package/dist/plugins/store-backend.d.ts +1 -1
  171. package/dist/plugins/store-backend.js +19 -7
  172. package/dist/plugins/store-contracts.d.ts +25 -5
  173. package/dist/plugins/tidb-pool.d.ts +12 -7
  174. package/dist/plugins/tidb-pool.js +9 -3
  175. package/dist/plugins/tidb-session-store.d.ts +57 -26
  176. package/dist/plugins/tidb-session-store.js +118 -50
  177. package/dist/plugins/tool-result-store-sql.d.ts +3 -2
  178. package/dist/plugins/tool-result-store-sql.js +18 -10
  179. package/dist/project-memory.d.ts +9 -15
  180. package/dist/project-memory.js +5 -4
  181. package/dist/request-key-closure.js +1 -1
  182. package/dist/request-value-closure.d.ts +6 -7
  183. package/dist/request-value-closure.js +1 -3
  184. package/dist/run-local.d.ts +6 -0
  185. package/dist/run-local.js +24 -20
  186. package/dist/runs.d.ts +2 -1
  187. package/dist/runs.js +3 -1
  188. package/dist/security.d.ts +38 -12
  189. package/dist/security.js +8 -2
  190. package/dist/server-secret-env.d.ts +9 -5
  191. package/dist/session-children-retire.d.ts +27 -9
  192. package/dist/session-children-retire.js +9 -5
  193. package/dist/session-incarnation.d.ts +19 -9
  194. package/dist/session-incarnation.js +4 -2
  195. package/dist/session-row.d.ts +114 -5
  196. package/dist/session-row.js +55 -5
  197. package/dist/session-titler.d.ts +2 -2
  198. package/dist/task-a2a.d.ts +1 -1
  199. package/dist/task-cwd.d.ts +6 -1
  200. package/dist/task-mcp.d.ts +3 -3
  201. package/dist/tool-approval.d.ts +6 -0
  202. package/dist/tool-approval.js +8 -1
  203. package/dist/trace/project.d.ts +1 -0
  204. package/dist/trace/project.js +1 -0
  205. package/dist/trace/self-minted-wire.d.ts +111 -2
  206. package/dist/trace/self-minted-wire.js +38 -1
  207. package/dist/url-refusal.d.ts +1 -1
  208. package/dist/web-admin-client/apply-effective.d.ts +17 -3
  209. package/dist/web-admin-client/apply-effective.js +20 -10
  210. package/dist/web-admin-client/skills-mcp.js +3 -2
  211. package/dist/web-admin-client/types.d.ts +6 -0
  212. package/package.json +3 -3
  213. package/wire-changes/server@7.139.0.json +348 -0
  214. package/dist/http/device-placement.d.ts +0 -175
  215. package/dist/http/device-placement.js +0 -145
@@ -8,7 +8,7 @@ import { CENTRAL_SCHEMA_INDEXES } from "./schema-indexes.js";
8
8
  import { ensureIndexes } from "./ensure-index.js";
9
9
  import { createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, isJsonColumnBodyProblem, jsonColumnText, jsonColumnTextExpr, readNonNullJsonValueColumn, readOpaqueJsonValueColumn, } from "./sql-json-column.js";
10
10
  import { incarnationFromCell, sessionFaceFromRow } from "../session-incarnation.js";
11
- import { RUN_HOLDER_DEATH_CODES, RUN_ORPHANED_ERROR, RUN_STALLED_ERROR } from "./store-contracts.js";
11
+ import { RUN_HOLDER_DEATH_CODES, RUN_ORPHANED_ERROR, RUN_STALLED_ERROR, StoreBootRefusalError } from "./store-contracts.js";
12
12
  import { PERMISSION_RULE_TABLE, purgeUndurableScopeRows } from "./permission-rule-store-sql.js";
13
13
  import { SESSION_RULES_COLUMN } from "./session-rules-column.js";
14
14
  import { readJsonObjectColumn } from "./sql-json-column.js";
@@ -16,6 +16,9 @@ import { retireStoredCardWords } from "../retired-card-words.js";
16
16
  import { redactSecrets } from "../trace/redact.js";
17
17
  import { CLAIM_OWNERLESS_PLACED_SQL, claimOwnerlessParams, isOrphanSessionOwner, judgeDelegatedOwner } from "../delegated-session-owner.js";
18
18
  import { MAILBOX_KEYED_TABLES, mailboxKeyDeleteSql, storedMailboxKeyless } from "./mailbox-store-sql.js";
19
+ import { SESSION_META_PLACEMENT_COLUMNS, backfillWriterStatement, countOrphanBindingsStatement, migrateDeviceBindingsStatement } from "./execution-placement-sql.js";
20
+ import { APPROVAL_ASK_EXECUTION_LANE_COLUMN, APPROVAL_ASK_TABLE } from "./approval-ask-store-sql.js";
21
+ import { isUnusableArchive } from "../execution-placement.js";
19
22
  const RETIRED_SESSION_RULE_BUCKETS = ["commandAllow", "commandDeny"];
20
23
  export function buildMigrationReceipts() {
21
24
  return {
@@ -45,6 +48,13 @@ export function buildMigrationReceipts() {
45
48
  parkedApprovalCardsBackfilled: 0,
46
49
  parkedApprovalCardRowsUnreadable: [],
47
50
  parkedApprovalCardRowsTooLong: [],
51
+ executionPlacementColumnsAdded: 0,
52
+ deviceBindingsMoved: 0,
53
+ deviceBindingsOrphaned: 0,
54
+ deviceBindingTablesDropped: 0,
55
+ executionPlacementsBackfilled: 0,
56
+ askExecutionLanesBackfilled: 0,
57
+ executionPlacementSessionsUnreadable: [],
48
58
  };
49
59
  }
50
60
  function completedReport(r) {
@@ -83,7 +93,14 @@ function changedAnything(r) {
83
93
  r.mailboxRejectedKeyRowsRemoved > 0 ||
84
94
  r.parkedApprovalCardsBackfilled > 0 ||
85
95
  r.parkedApprovalCardRowsUnreadable.length > 0 ||
86
- r.parkedApprovalCardRowsTooLong.length > 0);
96
+ r.parkedApprovalCardRowsTooLong.length > 0 ||
97
+ r.executionPlacementColumnsAdded > 0 ||
98
+ r.deviceBindingsMoved > 0 ||
99
+ r.deviceBindingsOrphaned > 0 ||
100
+ r.deviceBindingTablesDropped > 0 ||
101
+ r.executionPlacementsBackfilled > 0 ||
102
+ r.askExecutionLanesBackfilled > 0 ||
103
+ r.executionPlacementSessionsUnreadable.length > 0);
87
104
  }
88
105
  function readingsSnapshot(r) {
89
106
  return {
@@ -95,6 +112,7 @@ function readingsSnapshot(r) {
95
112
  sessionRuleRowsWithUnreadableDeny: [...r.sessionRuleRowsWithUnreadableDeny],
96
113
  parkedApprovalCardRowsUnreadable: [...r.parkedApprovalCardRowsUnreadable],
97
114
  parkedApprovalCardRowsTooLong: [...r.parkedApprovalCardRowsTooLong],
115
+ executionPlacementSessionsUnreadable: [...r.executionPlacementSessionsUnreadable],
98
116
  };
99
117
  }
100
118
  function receiptSentence(r) {
@@ -408,7 +426,7 @@ export const ONE_TIME_MIGRATIONS = [
408
426
  "`test/one-time-migrations.test.ts` 的 S-491 组与 `test/one-time-migrations-db-integration.test.ts` 的 M26 格。" +
409
427
  "**不随本条删**:键的铸点 `breakerKeyOf` / `routeDigestOf`(常设的键形)与新表 `breaker_state`",
410
428
  afterRemoval: "没有新的臂(如实):跳过了带本条的版本直接升的库,退役表 `circuit_breaker` 留在库里、没有读者也没有写者(断路器只读写 `breaker_state`)—— " +
411
- "出路 = 升级时先过一次带本条的版本,或手工 `DROP TABLE circuit_breaker`(建议性状态,删表安全)",
429
+ "出路 = 升级时先过一次带本条的版本(它起服删掉退役表);不先过也无害 —— 退役表没有读者也没有写者,留在库里只占空间(建议性状态,无数据价值)",
412
430
  reportKeys: ["retiredBreakerTablesDropped"],
413
431
  },
414
432
  {
@@ -466,7 +484,7 @@ export const ONE_TIME_MIGRATIONS = [
466
484
  "改后句:「this session's stored request still carries a request key this version retired and nothing rewrites it any more; it is not replayed — the task stays suspended until someone cancels it and resubmits it as a new task (promptLean in place of promptProfile)」);" +
467
485
  "同族注释(每个半句只归本条或 M28 条之一,不重复改;标〔另一条的〕的几处住在另一条迁移的文件 / 头注段里,那条已先删则宿主已不在,跳过):`src/http/checkpoint-ctx.ts`:「两条出路(运维在一次性迁移的窗内重启本服务 ⇒ 起服改写存体、同一请求可再发;否则作为新任务重提,仍停驻的先取消)」改成「出路(作为新任务重提,仍停驻的先取消)」、「**窗的日期不手写**」起到「删法见登记表 M30 的」止整段删、「存体里的旧键由一次性迁移在起服时原地改写」改成「存体里的旧键没有迁移改写」、「(M30,`stored-request-prompt-profile-m30.ts`,行改写经本模块的 {@link rewriteStoredBodyKeys})。改写不到的残留行」改成「仍带旧键的残留行」、「;存体由 M30 起服改写」删、「与 {@link rewriteStoredBodyKeys}」删、「(存量行非凭据顶层体键的改写,凭据位与读法记号原样随行)」删;`src/http/wire-types.ts`:「由一次性迁移 M30 在起服时按同一张映射原地改写」改成「没有迁移改写」、「(`stored-request-prompt-profile-m30.ts`;M30 · 窗口见 `plugins/one-time-migrations.ts` 登记表)」删;`src/spec-fields.ts`:「由一次性迁移 M30 在起服时改写成 `promptLean`(`stored-request-prompt-profile-m30.ts`),」改成「不改写,」;`src/boot/stage-06-runners.ts`:「M30(存体里的退役请求键 `promptProfile` 原地改写)同在这里、」删、「(同一只 ctx 行改写店腿;不要封签器),同样早于任何续跑入口。」删;`src/plugins/checkpoint-store-sql.ts`:「、M30(退役请求键改写)」删;`src/plugins/one-time-migrations.ts`:〔另一条的〕「M30 用同一只;」删;`src/checkpoint-ctx-seal-m28.ts`:〔另一条的〕「(M30 用同一只)」删;`test/task-agents-876.test.ts`:「体由一次性迁移 M30 在起服时原地改写」改成「体没有迁移改写」;`test/allow-list-reading-park-refusal.test.ts`:「一次性迁移 M30 没改写到的残留」改成「没有迁移改写的残留」;`test/api-error-text-freeze.test.ts`:「一次性迁移 M30 没改写到的残留」改成「没有迁移改写的残留」、「洞 = 窗的最后一天(绝对日期)」起到「不手写。」止整段删;" +
468
486
  "`http/stored-body-replay.ts` 里 `RETIRED_KEY_REWRITE_MIGRATION` 的 JSDoc 随表删;" +
469
- "`http/stored-body-replay.ts` 的 `RETIRED_KEY_REWRITE_MIGRATION`(`satisfies Record<RetiredRequestKey, string>`:M30 删后 `promptProfile` 仍是退役键,这张表只能整张删)、" +
487
+ "`http/stored-body-replay.ts` 的 `RETIRED_KEY_REWRITE_MIGRATION`(`satisfies Record<ReplayRefusedRetiredKey, string>`:M30 删后 `promptProfile` 仍是退役键,这张表只能整张删)、" +
470
488
  "判据里 `rewriteWindowEnd: migrationWindowEnd(…)` 那一项与 `migrationWindowEnd` 的引入行;`plugins/one-time-migrations.ts` 导出的 `migrationWindowEnd` 在无别的读者时一起删;" +
471
489
  "冻结表 `test/api-error-text-freeze.test.ts` 的 `FROZEN_RESULT_BODY_TEMPLATE` 里那一行搬回字面表(删完半句后拒句是整句字面);" +
472
490
  "`test/stored-request-prompt-profile-m30.test.ts` 的起服改写组、登记行组、「M28 登记行」组(M28 没先删时它还在:它读本文件顶部的引入与 COMMENTS 表,与登记行组同命)、「M30 起服接线」组(它钉的就是被删的那处调用与起服 info 行)与「M30 的混跑与回滚」组;「M30 的混跑与回滚」组随删:它读登记表头注 M30 段(随本行一起删)与 MIGRATION 7.137.0 节第 4 条(已发布的历史文字,留着不动);doc-claims ㊵ 的「嵌入方一段分清两只迁移的窗」格同样读本行的窗末日,随删(它与 M28 行共读,哪一行先删就先删这一格);「窗关后的残留」组保留,但它里面 (d) 读登记行的 `m30Row()` 用处(期望里的 `rewriteWindowEnd`)与 (d′) 整格随删,`m30Row` 助手随之删;" +
@@ -480,6 +498,39 @@ export const ONE_TIME_MIGRATIONS = [
480
498
  "(判据 `retired_request_key`,`http/stored-body-replay.ts`)、行字节不动 —— 出路 = 作为新任务重提(`promptLean` 代替 `promptProfile`;仍停驻的先取消),或升级时先过一次带本条的版本并在窗内起服",
481
499
  reportKeys: [],
482
500
  },
501
+ {
502
+ id: "M31",
503
+ introducedIn: "7.139.0",
504
+ releasedOn: "2026-10-29",
505
+ removableAfter: "2026-11-28",
506
+ removal: "删 `src/memory-sync-client.ts` 里的 `relocateSyncCursorsM31` / `SyncCursorRelocationM31Report` / `m31Window` / `scopeOfLegacyCursor` / `LEGACY_PEER_SEGMENT` / `LEGACY_ROOT_CURSOR_FILE`、" +
507
+ "`createMemorySyncRunner` 里的 `relocated` 旗标与 `round` 开头对 `relocateSyncCursorsM31` 的那一处调用(连同它上面那一行注释、info `memory_sync_cursor_relocated` 与 warn `memory_sync_cursor_not_relocated` 两行日志)、" +
508
+ "文件头注的 ⑤ 段与 `ONE_TIME_MIGRATIONS` / `migrationWindowOpen` / `OneTimeMigrationWindow` 的引入行;本文件头注的 M31 段;" +
509
+ "`test/memory-sync-client.test.ts` 里标题以 `🔴 M31:` 或 `M31 ` 起头的格、对 `relocateSyncCursorsM31` 的引入与常量 `LEGACY_PEER_DIR`,常量 `CURSOR_FILE` 的行注改成「没有读者」(用它的格随删后无人用它 ⇒ 常量一起删)",
510
+ afterRemoval: "没有新的臂(如实):跳过了带本条的版本直接升的数据根,升级前的游标还在 `sync-cursors/central/` 与 `sync-cursor-central.json`,没有读者 ⇒ 那几层以空基线首轮同步" +
511
+ "(两边都改过的条目按冲突上报,上一轮之后在一侧删掉的条目从另一侧回来)。出路 = 升级时先过一次带本条的版本并在窗内跑一轮同步,或升级前在旧版上跑完一轮同步、再删掉这些文件",
512
+ reportKeys: [],
513
+ },
514
+ {
515
+ id: "M32",
516
+ introducedIn: "7.139.0",
517
+ releasedOn: "2026-10-29",
518
+ removableAfter: "2026-11-28",
519
+ removal: "删 migrateStepsOn 的 M32 步(`switchExecutionPlacement`、步骤表 `EXECUTION_PLACEMENT_SWITCH_STEPS`、回填前提表 `EXECUTION_PLACEMENT_BACKFILL_NEEDS`、" +
520
+ "`readSwitchShape` / `switchStarted` / `switchComplete` / `refuseOnSwitchFence` / `refuseOnAmbiguousBackfill` / `sessionRowsAwaitingBackfill` / `bindingRootKey`、" +
521
+ "`RETIRED_DEVICE_BINDING_TABLE` / `PARKED_DEVICE_BINDING_TABLE` / `FENCE_ROOTS_SHOWN`、`ExecutionPlacementSwitchRefusedError` / `EXECUTION_PLACEMENT_SWITCH_REFUSED_CODE` / `ExecutionPlacementSwitchRefusalReason`)" +
522
+ "与 DDL 前探针(`assertExecutionPlacementSwitchAdmissible` / `assertExecutionPlacementSwitchAdmissibleOnPool`,`store-backend.ts` 的 `withRetiredSchemaProbe` 里调它的那一行);" +
523
+ "`runSqlOneTimeMigrations` 的第三参 `ExecutionPlacementSwitchInput`(若再无别的条目用它)与它在两只 SQL 后端 / 测试夹具上的实参;local 腿(`LocalBackend.runOneTimeMigrations` 里的 `backfillLocalExecutionPlacements` 与 `FileRunStore.sessionIdsWithRuns`);" +
524
+ "`execution-placement-sql.ts` 的 `backfillWriterStatement` / `migrateDeviceBindingsStatement` / `countOrphanBindingsStatement`;报告、日志行与 `NO_SQL_MIGRATIONS` 的 `executionPlacementColumnsAdded` / `deviceBindingsMoved` / " +
525
+ "`deviceBindingsOrphaned` / `deviceBindingTablesDropped` / `executionPlacementsBackfilled` / `executionPlacementSessionsUnreadable` 与 `device_bindings_orphaned` / `execution_placement_session_unreadable` 两行 warn;`test/execution-placement-switch-db-integration.test.ts` 整份与 `test/one-time-migrations.test.ts` 的 M32 组。" +
526
+ "**local 腿的回填席与它的点名词一并删**:`LocalSessionStore` 的 `executionPlacementBackfill`(属性与构造里的那一臂)、`execution-placement.ts` 的 `LocalPlacementBackfillSeat` / `UnusableArchive` / `UnusableArchiveWord`(含它的 `storage` 词 —— 读存档文件时抛了别的故障、`errno` 随带,该词只有回填点名用)/ `isUnusableArchive`、" +
527
+ "`ExecutionPlacementSessionUnreadable`;`local-session-store.ts` 里 `placementRead` 并回 `readExecutionPlacement`、`metaRead` 的 unusable 臂与 `forkingOf` 的 `storage` 臂改成就地上抛 `probe.fault`(`archiveFaultWord` / `unusableArchiveOf` 随词删,探针只留「不在」与已知在而用不了的读法);" +
528
+ "`test/execution-placement-switch-local.test.ts` 与 `test/execution-placement-local-boot-unreadable-e2e.test.ts` 整份。" +
529
+ "**不随本条删**:执行放置八列、会话店的执行席、设备店的首绑 / 换绑两只写(常设的数据形)",
530
+ afterRemoval: "起服对账一族的跳版库拒启臂接住:删码同版把 `device_session` / `device_session_retired` 两张表加进退役 schema 判据(`schema-consistency.ts` 的跳版库探针,先于建表)⇒ " +
531
+ "跳过了带本条的版本直接升的库响亮拒启,出路 = 先在带本条的版本起服一次让切换走完,再升;缺执行放置列的库由列集对账缺列拒启(`config.schema_mismatch`,拒句给 ALTER),同一出路",
532
+ reportKeys: ["executionPlacementColumnsAdded", "deviceBindingsMoved", "deviceBindingsOrphaned", "deviceBindingTablesDropped", "executionPlacementsBackfilled", "askExecutionLanesBackfilled", "executionPlacementSessionsUnreadable"],
533
+ },
483
534
  ];
484
535
  export function migrationWindowOpen(window, today) {
485
536
  return window.removableAfter === null || today <= window.removableAfter;
@@ -521,6 +572,10 @@ export const NO_SQL_MIGRATIONS = {
521
572
  parkedApprovalCardsBackfilled: 0,
522
573
  parkedApprovalCardRowsUnreadable: [],
523
574
  parkedApprovalCardRowsTooLong: [],
575
+ executionPlacementColumnsAdded: 0,
576
+ deviceBindingsMoved: 0,
577
+ deviceBindingsOrphaned: 0,
578
+ deviceBindingTablesDropped: 0,
524
579
  };
525
580
  function mysqlDb(conn) {
526
581
  return {
@@ -566,8 +621,8 @@ async function columnExists(db, dialect, table, column) {
566
621
  const sql = `SELECT 1 AS present FROM information_schema.columns WHERE table_schema = ${scope} AND table_name = '${table}' AND column_name = '${column}'`;
567
622
  return (await db.rows(sql)).length > 0;
568
623
  }
569
- export async function runSqlOneTimeMigrations(dialect, pool) {
570
- return dialect === "tidb" ? withMysqlLock(pool, (db) => migrateOn(db, "tidb")) : withPgLock(pool, (db) => migrateOn(db, "pg"));
624
+ export async function runSqlOneTimeMigrations(dialect, pool, input) {
625
+ return dialect === "tidb" ? withMysqlLock(pool, (db) => migrateOn(db, "tidb", input)) : withPgLock(pool, (db) => migrateOn(db, "pg", input));
571
626
  }
572
627
  async function withMysqlLock(pool, run) {
573
628
  const conn = await pool.getConnection();
@@ -597,17 +652,17 @@ async function withPgLock(pool, run) {
597
652
  client.release();
598
653
  }
599
654
  }
600
- async function migrateOn(db, dialect) {
655
+ async function migrateOn(db, dialect, input) {
601
656
  const receipts = buildMigrationReceipts();
602
657
  try {
603
- await migrateStepsOn(db, dialect, receipts);
658
+ await migrateStepsOn(db, dialect, receipts, input);
604
659
  }
605
660
  catch (e) {
606
661
  throw changedAnything(receipts) ? new OneTimeMigrationInterruptedError(e, readingsSnapshot(receipts)) : e;
607
662
  }
608
663
  return completedReport(receipts);
609
664
  }
610
- async function migrateStepsOn(db, dialect, receipts) {
665
+ async function migrateStepsOn(db, dialect, receipts, input) {
611
666
  receipts.holderDeathCodeRowsBackfilled += await db.exec(dialect === "tidb"
612
667
  ? "UPDATE task_run SET error_code = ? WHERE status = 'failed' AND error_code IS NULL AND error = ?"
613
668
  : "UPDATE task_run SET error_code = $1 WHERE status = 'failed' AND error_code IS NULL AND error = $2", [RUN_HOLDER_DEATH_CODES.stalled, RUN_STALLED_ERROR]);
@@ -628,6 +683,289 @@ async function migrateStepsOn(db, dialect, receipts) {
628
683
  if (await tableExists(db, dialect, RETIRED_BREAKER_TABLE))
629
684
  await dropRetiredBreakerTable(db, dialect, receipts);
630
685
  await dropRejectedMailboxKeys(db, dialect, receipts);
686
+ await switchExecutionPlacement(db, dialect, receipts, input);
687
+ }
688
+ const RETIRED_DEVICE_BINDING_TABLE = "device_session";
689
+ const PARKED_DEVICE_BINDING_TABLE = "device_session_retired";
690
+ const BINDING_COLUMNS = "root_session_id, device_id, owner_tenant, owner_subject, bound_at, rev";
691
+ const FENCE_ROOTS_SHOWN = 20;
692
+ export const EXECUTION_PLACEMENT_SWITCH_REFUSED_CODE = "config.execution_placement_switch_refused";
693
+ export class ExecutionPlacementSwitchRefusedError extends StoreBootRefusalError {
694
+ reason;
695
+ code = EXECUTION_PLACEMENT_SWITCH_REFUSED_CODE;
696
+ constructor(reason, detail) {
697
+ super(`${EXECUTION_PLACEMENT_SWITCH_REFUSED_CODE} (reason: ${reason}): ${detail}`);
698
+ this.reason = reason;
699
+ this.name = "ExecutionPlacementSwitchRefusedError";
700
+ }
701
+ }
702
+ async function readSwitchShape(db, dialect) {
703
+ const sessionMeta = await tableExists(db, dialect, "session_meta");
704
+ const placementColumns = new Set();
705
+ if (sessionMeta)
706
+ for (const c of SESSION_META_PLACEMENT_COLUMNS)
707
+ if (await columnExists(db, dialect, "session_meta", c.name))
708
+ placementColumns.add(c.name);
709
+ return {
710
+ sessionMeta,
711
+ placementColumns,
712
+ bindingTable: await tableExists(db, dialect, RETIRED_DEVICE_BINDING_TABLE),
713
+ parkedTable: await tableExists(db, dialect, PARKED_DEVICE_BINDING_TABLE),
714
+ taskRun: sessionMeta && (await tableExists(db, dialect, "task_run")),
715
+ ...(await askTableShape(db, dialect)),
716
+ };
717
+ }
718
+ async function askTableShape(db, dialect) {
719
+ const askTable = await tableExists(db, dialect, APPROVAL_ASK_TABLE);
720
+ return { askTable, askLaneColumn: askTable && (await columnExists(db, dialect, APPROVAL_ASK_TABLE, "execution_lane")) };
721
+ }
722
+ function switchStarted(s) {
723
+ return s.placementColumns.has("execution_role") || s.parkedTable;
724
+ }
725
+ const SWITCH_COMPLETION_COLUMN = "fork_key";
726
+ function switchComplete(s) {
727
+ return s.placementColumns.size === SESSION_META_PLACEMENT_COLUMNS.length && (!s.askTable || s.askLaneColumn) && !s.bindingTable && !s.parkedTable;
728
+ }
729
+ function askLaneBackfillSql(dialect) {
730
+ return dialect === "tidb"
731
+ ? `UPDATE ${APPROVAL_ASK_TABLE} a LEFT JOIN session_meta sm ON sm.session_id = a.session_id AND sm.placement_kind IS NULL SET a.execution_lane = COALESCE(sm.execution_lane, ?) WHERE a.execution_lane = ''`
732
+ : `UPDATE ${APPROVAL_ASK_TABLE} a SET execution_lane = COALESCE((SELECT sm.execution_lane FROM session_meta sm WHERE sm.session_id = a.session_id AND sm.placement_kind IS NULL), $1) WHERE a.execution_lane = ''`;
733
+ }
734
+ async function parkedRelationNamesWithRetiredPrefix(db) {
735
+ const rows = await db.rows(`SELECT i.relname AS name FROM pg_index x JOIN pg_class i ON i.oid = x.indexrelid JOIN pg_class t ON t.oid = x.indrelid WHERE t.relname = '${PARKED_DEVICE_BINDING_TABLE}' AND t.relnamespace = current_schema()::regnamespace AND i.relname LIKE '${RETIRED_DEVICE_BINDING_TABLE}\\_%'`);
736
+ return rows.map((r) => String(r["name"])).filter((name) => name.startsWith(`${RETIRED_DEVICE_BINDING_TABLE}_`) && !name.startsWith(PARKED_DEVICE_BINDING_TABLE));
737
+ }
738
+ const bindingRootKey = (dialect, alias) => (dialect === "tidb" ? `CONVERT(${alias}.root_session_id USING utf8mb4) COLLATE utf8mb4_bin` : `${alias}.root_session_id`);
739
+ async function refuseOnSwitchFence(read, dialect, shape) {
740
+ if (!switchStarted(shape) || !shape.bindingTable)
741
+ return;
742
+ const rows = await read(`SELECT ${bindingRootKey(dialect, "ds")} AS root FROM ${RETIRED_DEVICE_BINDING_TABLE} ds LIMIT ${FENCE_ROOTS_SHOWN + 1}`);
743
+ if (rows.length === 0)
744
+ return;
745
+ const roots = rows.slice(0, FENCE_ROOTS_SHOWN).map((r) => (Buffer.isBuffer(r["root"]) ? r["root"].toString("utf8") : String(r["root"])));
746
+ const more = rows.length > FENCE_ROOTS_SHOWN ? ` (and more — the table holds over ${FENCE_ROOTS_SHOWN} rows)` : "";
747
+ const rename = dialect === "tidb" ? `RENAME TABLE ${RETIRED_DEVICE_BINDING_TABLE} TO ${PARKED_DEVICE_BINDING_TABLE};` : `ALTER TABLE ${RETIRED_DEVICE_BINDING_TABLE} RENAME TO ${PARKED_DEVICE_BINDING_TABLE};`;
748
+ const adopt = shape.parkedTable
749
+ ? `DELETE FROM ${PARKED_DEVICE_BINDING_TABLE} WHERE root_session_id IN (SELECT root_session_id FROM ${RETIRED_DEVICE_BINDING_TABLE}); INSERT INTO ${PARKED_DEVICE_BINDING_TABLE} (${BINDING_COLUMNS}) SELECT ${BINDING_COLUMNS} FROM ${RETIRED_DEVICE_BINDING_TABLE}; DELETE FROM ${RETIRED_DEVICE_BINDING_TABLE};`
750
+ : rename;
751
+ throw new ExecutionPlacementSwitchRefusedError("fence", `the switch of session↔device bindings onto the root session rows has started on this database, yet the retired binding table ${RETIRED_DEVICE_BINDING_TABLE} holds rows again — ` +
752
+ `an earlier server version still writing it after the switch began, or rows written by hand. This server cannot tell whether they are newer than the placements ` +
753
+ `already on the session rows and refuses to pick: nothing was changed (this check runs before any DDL). Root sessions in that table: ${roots.join(", ")}${more}. ` +
754
+ `Stop every earlier-version replica first, then choose one: adopt those rows (they overwrite the device placement of their root sessions on the next start): \`${adopt}\` — ` +
755
+ `or discard them (the session rows keep what they hold): \`DELETE FROM ${RETIRED_DEVICE_BINDING_TABLE};\` — then start this version again; its own migration removes the emptied table on that start`);
756
+ }
757
+ async function sessionRowsAwaitingBackfill(read, dialect, shape) {
758
+ if (!shape.sessionMeta || !shape.taskRun)
759
+ return false;
760
+ const bindingSource = shape.parkedTable ? PARKED_DEVICE_BINDING_TABLE : shape.bindingTable ? RETIRED_DEVICE_BINDING_TABLE : undefined;
761
+ const sql = "SELECT 1 AS hit FROM session_meta sm WHERE sm.placement_kind IS NULL" +
762
+ (shape.placementColumns.has("execution_role") ? " AND sm.execution_role IS NULL" : "") +
763
+ " AND EXISTS (SELECT 1 FROM task_run tr WHERE tr.session_id = sm.session_id)" +
764
+ (bindingSource !== undefined ? ` AND NOT EXISTS (SELECT 1 FROM ${bindingSource} ds WHERE ${bindingRootKey(dialect, "ds")} = sm.session_id)` : "") +
765
+ " LIMIT 1";
766
+ return (await read(sql)).length > 0;
767
+ }
768
+ async function askRowsAwaitingLane(read, _dialect, shape) {
769
+ if (!shape.askTable)
770
+ return false;
771
+ return (await read(`SELECT 1 AS hit FROM ${APPROVAL_ASK_TABLE}${shape.askLaneColumn ? " WHERE execution_lane = ''" : ""} LIMIT 1`)).length > 0;
772
+ }
773
+ const EXECUTION_PLACEMENT_BACKFILL_NEEDS = [
774
+ { what: "session rows that have runs but no execution placement (they would be placed on the only declared lane)", holds: sessionRowsAwaitingBackfill },
775
+ { what: "approval ask rows that do not record the execution lane they were raised on (they would record their root session's lane, or the only declared lane)", holds: askRowsAwaitingLane },
776
+ ];
777
+ async function refuseOnAmbiguousBackfill(read, dialect, shape, lanes) {
778
+ if (!shape.sessionMeta || switchComplete(shape) || lanes.length === 1)
779
+ return;
780
+ const pending = [];
781
+ for (const need of EXECUTION_PLACEMENT_BACKFILL_NEEDS)
782
+ if (await need.holds(read, dialect, shape))
783
+ pending.push(need.what);
784
+ if (pending.length === 0)
785
+ return;
786
+ throw new ExecutionPlacementSwitchRefusedError("backfill_ambiguous", `this database still holds rows the switch must place on an execution lane (${pending.join("; ")}), and the deployment declares ${lanes.length === 0 ? "no lane" : `${lanes.length} lanes (${lanes.join(", ")})`} — ` +
787
+ "with more than one lane there is no single right answer, and this server will not guess one. Nothing was changed (this check runs before any DDL). " +
788
+ "Start this version once with a single declared lane (REMOTE_EXEC set to one lane) so the switch completes, then declare the additional lanes");
789
+ }
790
+ export async function assertExecutionPlacementSwitchAdmissible(read, dialect, input) {
791
+ const shape = await readSwitchShape({ rows: (sql) => read(sql) }, dialect);
792
+ if (!shape.sessionMeta)
793
+ return;
794
+ await refuseOnSwitchFence(read, dialect, shape);
795
+ await refuseOnAmbiguousBackfill(read, dialect, shape, input.lanes);
796
+ }
797
+ export async function assertExecutionPlacementSwitchAdmissibleOnPool(dialect, pool, input) {
798
+ if (dialect === "tidb") {
799
+ const conn = await pool.getConnection();
800
+ try {
801
+ await assertExecutionPlacementSwitchAdmissible(async (sql) => (await conn.query(sql))[0], "tidb", input);
802
+ }
803
+ finally {
804
+ conn.release();
805
+ }
806
+ return;
807
+ }
808
+ const client = await pool.connect();
809
+ try {
810
+ await assertExecutionPlacementSwitchAdmissible(async (sql) => (await client.query(sql)).rows, "pg", input);
811
+ }
812
+ finally {
813
+ client.release();
814
+ }
815
+ }
816
+ const EXECUTION_PLACEMENT_SWITCH_STEPS = [
817
+ {
818
+ id: "⓪ drop the empty retired binding table",
819
+ run: async ({ db, receipts, shape }) => {
820
+ const s = await shape();
821
+ if (!switchStarted(s) || !s.bindingTable)
822
+ return;
823
+ await execUntil(db, `DROP TABLE ${RETIRED_DEVICE_BINDING_TABLE}`, async () => !(await shape()).bindingTable);
824
+ receipts.deviceBindingTablesDropped += 1;
825
+ },
826
+ },
827
+ {
828
+ id: "① park the retired binding table",
829
+ run: async ({ db, dialect, shape }) => {
830
+ const s = await shape();
831
+ if (!s.bindingTable || s.placementColumns.has("execution_role"))
832
+ return;
833
+ const sql = dialect === "tidb" ? `RENAME TABLE ${RETIRED_DEVICE_BINDING_TABLE} TO ${PARKED_DEVICE_BINDING_TABLE}` : `ALTER TABLE ${RETIRED_DEVICE_BINDING_TABLE} RENAME TO ${PARKED_DEVICE_BINDING_TABLE}`;
834
+ await execUntil(db, sql, async () => {
835
+ const after = await shape();
836
+ return after.parkedTable && !after.bindingTable;
837
+ });
838
+ },
839
+ },
840
+ {
841
+ id: "①′ rename the parked table's constraints and indexes (PG)",
842
+ run: async ({ db, dialect, shape }) => {
843
+ if (dialect !== "pg" || !(await shape()).parkedTable)
844
+ return;
845
+ const stale = async () => parkedRelationNamesWithRetiredPrefix(db);
846
+ for (const name of await stale()) {
847
+ const renamed = `${PARKED_DEVICE_BINDING_TABLE}${name.slice(RETIRED_DEVICE_BINDING_TABLE.length)}`;
848
+ await execUntil(db, `ALTER INDEX "${name}" RENAME TO "${renamed}"`, async () => !(await stale()).includes(name));
849
+ }
850
+ },
851
+ },
852
+ {
853
+ id: "② add the execution placement columns",
854
+ run: async ({ db, dialect, receipts, shape }) => {
855
+ for (const column of SESSION_META_PLACEMENT_COLUMNS) {
856
+ if (column.name === SWITCH_COMPLETION_COLUMN)
857
+ continue;
858
+ if ((await shape()).placementColumns.has(column.name))
859
+ continue;
860
+ await execUntil(db, `ALTER TABLE session_meta ADD COLUMN ${column[dialect]}`, async () => (await shape()).placementColumns.has(column.name));
861
+ receipts.executionPlacementColumnsAdded += 1;
862
+ }
863
+ const ask = await shape();
864
+ if (ask.askTable && !ask.askLaneColumn) {
865
+ await execUntil(db, `ALTER TABLE ${APPROVAL_ASK_TABLE} ADD COLUMN ${APPROVAL_ASK_EXECUTION_LANE_COLUMN[dialect === "tidb" ? "mysql" : "pg"]}`, async () => (await shape()).askLaneColumn);
866
+ receipts.executionPlacementColumnsAdded += 1;
867
+ }
868
+ },
869
+ },
870
+ {
871
+ id: "③ move the bindings onto the root session rows",
872
+ run: async ({ db, dialect, receipts, shape }) => {
873
+ if (!(await shape()).parkedTable)
874
+ return;
875
+ const orphans = countOrphanBindingsStatement(dialect, PARKED_DEVICE_BINDING_TABLE);
876
+ receipts.deviceBindingsOrphaned += Number((await db.rows(orphans.text, orphans.values))[0]?.["orphans"] ?? 0);
877
+ const move = migrateDeviceBindingsStatement(dialect, PARKED_DEVICE_BINDING_TABLE);
878
+ receipts.deviceBindingsMoved += await db.exec(move.text, move.values);
879
+ },
880
+ },
881
+ {
882
+ id: "④ backfill placements of sessions with runs",
883
+ run: async ({ db, dialect, input, receipts, shape }) => {
884
+ const s = await shape();
885
+ if (input.lanes.length !== 1) {
886
+ await refuseOnAmbiguousBackfill((sql) => db.rows(sql), dialect, s, input.lanes);
887
+ return;
888
+ }
889
+ const lane = input.lanes[0];
890
+ const backfill = backfillWriterStatement(dialect, lane, new Date());
891
+ receipts.executionPlacementsBackfilled += await db.exec(backfill.text, backfill.values);
892
+ },
893
+ },
894
+ {
895
+ id: "④b backfill the execution lane of approval ask rows",
896
+ run: async ({ db, dialect, input, receipts, shape }) => {
897
+ const s = await shape();
898
+ if (!s.askTable || !s.askLaneColumn)
899
+ return;
900
+ if (input.lanes.length !== 1) {
901
+ await refuseOnAmbiguousBackfill((sql) => db.rows(sql), dialect, s, input.lanes);
902
+ return;
903
+ }
904
+ receipts.askExecutionLanesBackfilled += await db.exec(askLaneBackfillSql(dialect), [input.lanes[0]]);
905
+ },
906
+ },
907
+ {
908
+ id: "⑤ drop the parked binding table",
909
+ run: async ({ db, receipts, shape }) => {
910
+ if (!(await shape()).parkedTable)
911
+ return;
912
+ await execUntil(db, `DROP TABLE ${PARKED_DEVICE_BINDING_TABLE}`, async () => !(await shape()).parkedTable);
913
+ receipts.deviceBindingTablesDropped += 1;
914
+ },
915
+ },
916
+ {
917
+ id: "⑥ add the completion marker column",
918
+ run: async ({ db, dialect, receipts, shape }) => {
919
+ if ((await shape()).placementColumns.has(SWITCH_COMPLETION_COLUMN))
920
+ return;
921
+ const marker = SESSION_META_PLACEMENT_COLUMNS.find((c) => c.name === SWITCH_COMPLETION_COLUMN);
922
+ await execUntil(db, `ALTER TABLE session_meta ADD COLUMN ${marker[dialect]}`, async () => (await shape()).placementColumns.has(SWITCH_COMPLETION_COLUMN));
923
+ receipts.executionPlacementColumnsAdded += 1;
924
+ },
925
+ },
926
+ ];
927
+ export async function switchExecutionPlacement(db, dialect, receipts, input) {
928
+ const shape = () => readSwitchShape(db, dialect);
929
+ const start = await shape();
930
+ if (!start.sessionMeta || switchComplete(start))
931
+ return;
932
+ await refuseOnSwitchFence((sql) => db.rows(sql), dialect, start);
933
+ for (const step of EXECUTION_PLACEMENT_SWITCH_STEPS)
934
+ await step.run({ db, dialect, input, receipts, shape });
935
+ }
936
+ export async function backfillLocalExecutionPlacements(sessionIds, seat, input) {
937
+ const absent = [];
938
+ const unreadable = [];
939
+ for (const root of sessionIds) {
940
+ const reading = await seat.read(root);
941
+ if (reading === undefined)
942
+ continue;
943
+ if (isUnusableArchive(reading))
944
+ unreadable.push({ sessionId: root, fault: reading.word, ...(reading.errno !== undefined ? { errno: reading.errno } : {}) });
945
+ else if (reading.cell.role === "absent")
946
+ absent.push({ root, occupant: reading.occupant });
947
+ }
948
+ if (absent.length === 0)
949
+ return { backfilled: 0, unreadable };
950
+ if (input.lanes.length !== 1) {
951
+ throw new ExecutionPlacementSwitchRefusedError("backfill_ambiguous", `the local data root holds sessions that have runs but no execution placement (${absent.length}), and the deployment declares ${input.lanes.length === 0 ? "no lane" : `${input.lanes.length} lanes (${input.lanes.join(", ")})`} — ` +
952
+ "with more than one lane there is no single right answer, and this server will not guess one. Nothing was changed. " +
953
+ "Start this version once with a single declared lane (REMOTE_EXEC set to one lane), then declare the additional lanes");
954
+ }
955
+ const lane = input.lanes[0];
956
+ let placed = 0;
957
+ for (const { root, occupant } of absent) {
958
+ if ((await seat.place(root, occupant, { role: "writer", lane })).outcome === "placed")
959
+ placed += 1;
960
+ }
961
+ return { backfilled: placed, unreadable };
962
+ }
963
+ export function backfillLocalAskExecutionLanes(store, input) {
964
+ return store.backfillExecutionLanes(input.lanes, (pending) => {
965
+ throw new ExecutionPlacementSwitchRefusedError("backfill_ambiguous", `the local approval ask journal holds asks that do not record the execution lane they were raised on (${pending}), and the deployment declares ${input.lanes.length === 0 ? "no lane" : `${input.lanes.length} lanes (${input.lanes.join(", ")})`} — ` +
966
+ "with more than one lane there is no single right answer, and this server will not guess one. Nothing was changed. " +
967
+ "Start this version once with a single declared lane (REMOTE_EXEC set to one lane), then declare the additional lanes");
968
+ });
631
969
  }
632
970
  const MAILBOX_KEY_SCAN_PAGE = 1000;
633
971
  async function dropRejectedMailboxKeys(db, dialect, receipts) {
@@ -1257,6 +1595,13 @@ function readingFields(r) {
1257
1595
  parkedApprovalCardsBackfilled: r.parkedApprovalCardsBackfilled,
1258
1596
  parkedApprovalCardRowsUnreadable: r.parkedApprovalCardRowsUnreadable.length,
1259
1597
  parkedApprovalCardRowsTooLong: r.parkedApprovalCardRowsTooLong.length,
1598
+ executionPlacementColumnsAdded: r.executionPlacementColumnsAdded,
1599
+ deviceBindingsMoved: r.deviceBindingsMoved,
1600
+ deviceBindingsOrphaned: r.deviceBindingsOrphaned,
1601
+ deviceBindingTablesDropped: r.deviceBindingTablesDropped,
1602
+ executionPlacementsBackfilled: r.executionPlacementsBackfilled,
1603
+ askExecutionLanesBackfilled: r.askExecutionLanesBackfilled,
1604
+ executionPlacementSessionsUnreadable: r.executionPlacementSessionsUnreadable.length,
1260
1605
  };
1261
1606
  }
1262
1607
  export function logOneTimeMigrations(log, backend, report) {
@@ -1301,7 +1646,11 @@ export function logOneTimeMigrations(log, backend, report) {
1301
1646
  "delivery, reading nor session deletion would ever have touched them again; only the count is logged, never a key; M29: suspended rows " +
1302
1647
  "parked before the checkpoint column approval_card existed and still pending get that column — the approval card word on their gate, " +
1303
1648
  "derived by the same function the write path uses (the engine's checkpointIndexFacts); a row whose document cannot be read as a checkpoint " +
1304
- "is left NULL and counted, named by a parked_approval_card_row_unreadable line, and so is a row whose card word is longer than the column can hold, named by a parked_approval_card_row_too_long line). Each step " +
1649
+ "is left NULL and counted, named by a parked_approval_card_row_unreadable line, and so is a row whose card word is longer than the column can hold, named by a parked_approval_card_row_too_long line; " +
1650
+ "M32: session↔device bindings moved from the retired table device_session onto the root session rows (the table is first renamed device_session_retired, " +
1651
+ "the eight execution placement columns are added to session_meta, every binding overwrites the device placement of its root session row — a binding whose " +
1652
+ "root session row is gone is dropped and counted, named by a device_bindings_orphaned line — and the renamed table is dropped), and session rows that have " +
1653
+ "runs but no execution placement placed on the only declared lane; once the switch is complete this step does nothing). Each step " +
1305
1654
  "runs on every boot until its window closes (the release that introduced it + 30 days; the per-step date is in " +
1306
1655
  "removableAfter, null while that release is not published yet) and is deleted in the first minor after that — start a " +
1307
1656
  "version that still carries a step at least once before upgrading past its deletion.",
@@ -1309,6 +1658,27 @@ export function logOneTimeMigrations(log, backend, report) {
1309
1658
  logReceiptRows(log, backend, report);
1310
1659
  }
1311
1660
  function logReceiptRows(log, backend, r) {
1661
+ if (r.deviceBindingsOrphaned > 0) {
1662
+ log.warn("device_bindings_orphaned", {
1663
+ backend,
1664
+ count: r.deviceBindingsOrphaned,
1665
+ note: "session↔device bindings whose root session row no longer exists (or is a delegated child row) were dropped while moving bindings onto the root session rows — no session can run on them; nothing else changed",
1666
+ });
1667
+ }
1668
+ for (const row of r.executionPlacementSessionsUnreadable) {
1669
+ log.warn("execution_placement_session_unreadable", {
1670
+ backend,
1671
+ sessionId: row.sessionId,
1672
+ fault: row.fault,
1673
+ ...(row.errno !== undefined ? { errno: row.errno } : {}),
1674
+ note: "a local session that has runs (and either no session-metadata cell — it was written before the cell existed and has not been read since — or a fork-created cell) has an archive the server cannot read, " +
1675
+ "so its execution placement could not be backfilled. The session was skipped and nothing was written or changed: boot is not blocked, and every request that needs the " +
1676
+ "session keeps answering the archive's own fault (fault names it: corrupt_session = the session header cannot be read; invalid_session = the stored log's structure is broken; " +
1677
+ "storage = reading the archive file itself failed — errno names it, e.g. EACCES = no permission, EIO = disk I/O error, ERR_STRING_TOO_LONG = the file is too large to read) until the " +
1678
+ "archive file under the data root's sessions directory is readable again — restored from a kept copy, or (storage) its permissions or disk repaired; the next boot then backfills its placement as for any other session. " +
1679
+ "(Removing the archive file ends the warning but places nothing: the session then no longer exists on this server — except a fork-created session, whose cell stays and reads as a fork that has not landed: retry the same fork, or delete the session.) The session is named on every boot until then",
1680
+ });
1681
+ }
1312
1682
  for (const row of r.parkedApprovalCardRowsUnreadable) {
1313
1683
  log.warn("parked_approval_card_row_unreadable", {
1314
1684
  backend,
@@ -85,8 +85,8 @@ export declare function coreOutcomeToLedgerRow(o: CoreTaskOutcome): TaskOutcome;
85
85
  * JSON→JSONB, DATETIME(3)→TIMESTAMPTZ(3), TINYINT(1)→SMALLINT, INT→INTEGER, inline KEY→separate CREATE INDEX.
86
86
  * The table is DISJOINT from every other store's tables, so this self-contained set is safe (no shadowing).
87
87
  *
88
- * SCHEMA POLICY: see the header of pg-pool.ts — the code is the single source of truth, schema changes are
89
- * drop-and-recreate, and NO new `ALTER TABLE` seams are added here; fold into the CREATE instead.
88
+ * SCHEMA POLICY: see the header of pg-pool.ts — the code is the single source of truth, there is no in-place
89
+ * migration, and NO new `ALTER TABLE` seams are added here; fold into the CREATE instead.
90
90
  */
91
91
  export declare const PG_OUTCOME_LEDGER_SCHEMA: string[];
92
92
  /** [ref]:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgOutcomeLedgerSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */
@@ -73,7 +73,7 @@ export declare function ensureTiDBPermissionRuleSchema(pool: MySqlPool): Promise
73
73
  * [ref] 升级前置断言 —— **拒启**,不是 warn(codex 交叉复审 [high],验真后修;形照 [ref] 的
74
74
  * `assertToolResultProvenanceSchema` 先例)。
75
75
  *
76
- * 病:本仓不发 `ALTER TABLE` 迁移(SCHEMA POLICY:改列就改 CREATE + 删库重建)。于是一台**没删表**就升
76
+ * 病:本仓不发 `ALTER TABLE` 迁移(SCHEMA POLICY:代码是唯一真源,存量库缺列由部署者按拒启句打印的 ALTER 自己执行)。于是一台**没跟上**就升
77
77
  * 上来的部署,`CREATE TABLE IF NOT EXISTS` 对存量 `permission_rule_approval` 是空操作 —— 本车新加的
78
78
  * `command` / `edited_json` 两列不在,而 `get` / `create` / `cas` 三条语句**无条件**引用它们。
79
79
  * 后果不是「少一个新功能」,是**整条审批记录面**在那台机器上是坏的:
@@ -81,16 +81,16 @@ export declare function ensureTiDBPermissionRuleSchema(pool: MySqlPool): Promise
81
81
  * `rule_store_error` —— 裁决照常 200,服务看起来完全健康;
82
82
  * · CC 导入的 prepare 连 pending 记录都落不下;
83
83
  * · 而 `/v1/capabilities` 的 `permissionRules` 仍然报 true ⇒ 本仓「says yes ⟺ route works」整句为假。
84
- * 没有这道断言,运维得不到**任何**「这次升级必须重建表」的信号 —— 那正是 [ref]「禁静默降级」要挡的形态。
84
+ * 没有这道断言,运维得不到**任何**「这次升级必须先给表加列」的信号 —— 那正是 [ref]「禁静默降级」要挡的形态。
85
85
  *
86
86
  * 判据用**能力探测**而不是版本号/information_schema:发一条恒空的 `WHERE 1=0` 读,列不在就报错。
87
87
  * 只判「五列在不在」(command / edited_json / offers_json 三列 TEXT 族 + version / selected_offer 两列
88
88
  * INT),不判宽度(TEXT 族无截断轴,INT 无宽度轴)。
89
89
  *
90
- * 🔴 **只有可证的缺列才给破坏性指路**(codex 交叉复审 round2/round3 [high],两轮收窄后的终形):
90
+ * 🔴 **只有可证的缺列才给「缺列」的拒因**(codex 交叉复审 round2/round3 [high],两轮收窄后的终形):
91
91
  * 「探针失败」**不等于**「列不在」。超时、连接被重置、取消、资源不足、列级权限被拒、表整个不存在 ——
92
- * 每一种都会让这条读抛错,而把它们诊断成「去 DROP TABLE」是一条**会真的毁掉审计记录**的建议(比它要
93
- * 挡的缺陷更贵)。round2 的两步探(先读一列老列)也不够:两次读之间同样可以插进一次瞬时故障。
92
+ * 每一种都会让这条读抛错,而把它们诊断成「缺列」是一条比它要挡的缺陷更贵的诊断(当年这句拒因还带着
93
+ * 一条会真的毁掉审计记录的指路;现在的出路是闭集三条,没有销毁性的一条)。round2 的两步探(先读一列老列)也不够:两次读之间同样可以插进一次瞬时故障。
94
94
  * 终形判据 = **方言的缺列错误码**,一行一方言的闭集(见 {@link isMissingColumnError});其余错误
95
95
  * **原样 rethrow**,一个字都不加工。真库两条腿各自跑过这条路径(db-integration 的存量表格),所以
96
96
  * 这张码表不是抄手册抄来的,是实测钉住的。
@@ -111,7 +111,7 @@ export declare function assertPermissionRuleApprovalSchema(query: (sql: string)
111
111
  * 那条头注写了这道门为什么必须是拒启而不是 warn:本仓不发 `ALTER TABLE`,`CREATE TABLE IF NOT EXISTS`
112
112
  * 对存量表是空操作,而新列被**无条件**引用)。
113
113
  *
114
- * 病:一台没删表就升上来的部署,`permission_rule_ticket` 上没有 `redeemed_outcome` 列,而 A7 的三态
114
+ * 病:一台没跟上就升上来的部署,`permission_rule_ticket` 上没有 `redeemed_outcome` 列,而 A7 的三态
115
115
  * 协议(`settle` / `peek` / `reclaim`)三条语句全都引用它。后果不是"少一个新功能":
116
116
  * · `POST /v1/rules/local-import/redeem` 的落定那一跳撞 unknown column ⇒ 票停在 CLAIMED 永不结算,
117
117
  * 属主的重放只能拿到 409,一次人的确认就此卡死到票过期;
@@ -119,12 +119,11 @@ export declare function assertPermissionRuleApprovalSchema(query: (sql: string)
119
119
  * 的同意**被报成没发生。
120
120
  * 所以判据放在启动期、拒启,而不是等第一位用户去踩。
121
121
  *
122
- * 判据同款:发一条恒空的 `WHERE 1=0` 读;**只有可证的缺列**才给破坏性指路(其余错误原样 rethrow ——
123
- * 超时/连接重置/权限/表不在都会让这条读抛,把它们诊断成"去 DROP TABLE"是一条比缺陷更贵的建议)。
122
+ * 判据同款:发一条恒空的 `WHERE 1=0` 读;**只有可证的缺列**才给「缺列」的拒因(其余错误原样 rethrow ——
123
+ * 超时/连接重置/权限/表不在都会让这条读抛,把它们诊断成「缺列」是一条比缺陷更贵的诊断)。出路是**闭集三条**
124
+ * (与整库对账同一份,{@link describeProbedTableDrift}),没有销毁性的一条。
124
125
  *
125
- * 🔴 破坏性指路的代价在本表上**比审批表小得多**,文案照实说:票是分钟级 TTL 的治理瞬态(过期即死),
126
- * 删表最多让**正在预览、还没按确认**的那几个人重走一次 prepare;持久规则在 `permission_rule` 里,
127
- * 一行都不动。
126
+ * 文案照实说本表的分量:票是分钟级 TTL 的治理瞬态(过期即死),持久规则在 `permission_rule` 里,一行都不动。
128
127
  */
129
128
  export declare function assertPermissionRuleTicketSchema(query: (sql: string) => Promise<{
130
129
  rows: Record<string, unknown>[];
@@ -440,10 +439,9 @@ export declare const RULE_PENDING_APPROVAL_RETENTION_MS: number;
440
439
  * 处理整个积压。500 的取值:一轮的最坏工作量钉在「几百行删除」这个数量级,而常态每轮的真实行数是个位数
441
440
  * (一次 prepare 留一行);积压按轮渐进清空,每轮都是完整语义,绝不留半干净状态。
442
441
  *
443
- * ⚠️ 两方言的**索引到货方式不对称**,如实登记:PG 侧是独立的 `CREATE INDEX IF NOT EXISTS`,存量库
444
- * 下次 `ensureSchema` 就补建;MySQL 侧索引写在 `CREATE TABLE` 里,而本仓 schema 口径是「启动 DDL 是唯一
445
- * 真源、不发 ALTER」⇒ **存量表拿不到这两个索引**,要靠一次删库重建(与 7.8.0 / 7.10.0 两次 BREAKING 窗
446
- * 同口径)。在那之前,MySQL 存量库上这条腿仍是全表扫 —— 但每轮 500 行的上界让它的**单次**代价仍然有界。
442
+ * 索引到货:两方言的存量库都由幂等加索引原语(`ensureIndexes`)在启动时补建 —— PG 侧是独立的 `CREATE INDEX`,
443
+ * MySQL 协议侧先探测、缺才发在线 `ALTER TABLE … ADD INDEX`(中央 schema 的索引列表含 `PERMISSION_RULE_INDEXES`)。
444
+ * 补建完成之前(或某个副本还没起过新版),这条腿在存量库上仍是全表扫 —— 但每轮 500 行的上界让它的**单次**代价仍然有界。
447
445
  */
448
446
  export declare const RULE_REAP_BATCH = 500;
449
447
  /**