@sema-agent/server 7.149.0 → 7.150.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 (194) hide show
  1. package/MIGRATION.md +122 -9
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/USAGE.md +39 -15
  5. package/dist/approval-card.d.ts +1 -1
  6. package/dist/approval-gate-currency.d.ts +1 -1
  7. package/dist/audit.d.ts +1 -1
  8. package/dist/audit.js +2 -2
  9. package/dist/background-session-reap.d.ts +1 -1
  10. package/dist/boot/deferred-sandbox-path-env.d.ts +5 -3
  11. package/dist/boot/deferred-sandbox-path-env.js +1 -2
  12. package/dist/boot/parked-revive-gate.d.ts +5 -5
  13. package/dist/boot/parked-revive-gate.js +2 -3
  14. package/dist/boot/resolve-spec.d.ts +4 -5
  15. package/dist/boot/resolve-spec.js +61 -25
  16. package/dist/boot/retention-lane.d.ts +1 -1
  17. package/dist/boot/session-faces.js +21 -21
  18. package/dist/boot/stage-03-store-backend.js +2 -2
  19. package/dist/boot/stage-06-runners.js +1 -2
  20. package/dist/boot/stage-07-capability-layer.js +1 -1
  21. package/dist/boot/stage-10-http-server.d.ts +33 -1
  22. package/dist/boot/stage-10-http-server.js +9 -4
  23. package/dist/boot/web-admin.d.ts +1 -1
  24. package/dist/capability-facts.d.ts +65 -56
  25. package/dist/capability-facts.js +42 -35
  26. package/dist/config-catalog.js +1 -1
  27. package/dist/config-types.d.ts +12 -11
  28. package/dist/deployment-governance.d.ts +7 -28
  29. package/dist/deployment-governance.js +1 -14
  30. package/dist/device-ws-hub.d.ts +1 -0
  31. package/dist/device-ws-hub.js +64 -28
  32. package/dist/device-ws-protocol.d.ts +20 -6
  33. package/dist/device-ws-protocol.js +9 -2
  34. package/dist/engine-identity.d.ts +7 -1
  35. package/dist/engine-identity.js +5 -0
  36. package/dist/field-conflict.d.ts +2 -2
  37. package/dist/fleet/engine-signal.d.ts +14 -1
  38. package/dist/fleet/engine-signal.js +5 -1
  39. package/dist/fleet/fleet-reconciler.d.ts +1 -1
  40. package/dist/fleet/serving-lease.d.ts +18 -3
  41. package/dist/fleet/serving-lease.js +81 -35
  42. package/dist/fleet-client.d.ts +13 -11
  43. package/dist/fleet-client.js +53 -33
  44. package/dist/history-anchor.js +1 -1
  45. package/dist/hitl-delivery.d.ts +1 -3
  46. package/dist/hitl-delivery.js +1 -1
  47. package/dist/http/active-run-conflict.d.ts +3 -7
  48. package/dist/http/active-run-conflict.js +2 -2
  49. package/dist/http/admission.js +2 -2
  50. package/dist/http/approval-sweeps.js +2 -1
  51. package/dist/http/caller-trust.d.ts +2 -2
  52. package/dist/http/caller-trust.js +3 -3
  53. package/dist/http/checkpoint-ctx.d.ts +18 -63
  54. package/dist/http/checkpoint-ctx.js +1 -24
  55. package/dist/http/claim-heartbeat.d.ts +4 -4
  56. package/dist/http/claim-heartbeat.js +3 -3
  57. package/dist/http/dispatch.js +2 -2
  58. package/dist/http/durable-run-leg.js +4 -4
  59. package/dist/http/owner-access.d.ts +243 -19
  60. package/dist/http/owner-access.js +184 -5
  61. package/dist/http/readiness.d.ts +4 -2
  62. package/dist/http/resume-ctx-read.js +16 -1
  63. package/dist/http/resume-legs.js +18 -14
  64. package/dist/http/route-ctx.d.ts +23 -11
  65. package/dist/http/routes/a2a-serve.js +15 -14
  66. package/dist/http/routes/approvals-assistant.d.ts +3 -2
  67. package/dist/http/routes/approvals-assistant.js +8 -9
  68. package/dist/http/routes/capabilities.js +4 -3
  69. package/dist/http/routes/config-catalog.d.ts +1 -1
  70. package/dist/http/routes/mcp-reconnect.js +2 -2
  71. package/dist/http/routes/memory-origin.d.ts +1 -1
  72. package/dist/http/routes/notify-wake.js +2 -3
  73. package/dist/http/routes/projects.d.ts +1 -0
  74. package/dist/http/routes/projects.js +5 -3
  75. package/dist/http/routes/retention-ops.d.ts +1 -1
  76. package/dist/http/routes/run-permission-mode.d.ts +1 -1
  77. package/dist/http/routes/run-permission-mode.js +2 -2
  78. package/dist/http/routes/runs.d.ts +1 -1
  79. package/dist/http/routes/runs.js +23 -30
  80. package/dist/http/routes/session-memory-sync.js +4 -5
  81. package/dist/http/routes/session-sync.js +3 -7
  82. package/dist/http/routes/sessions.d.ts +1 -1
  83. package/dist/http/routes/sessions.js +18 -44
  84. package/dist/http/routes/tasks.js +9 -8
  85. package/dist/http/routes/trace-usage.js +14 -14
  86. package/dist/http/routes/workflows.d.ts +1 -0
  87. package/dist/http/routes/workflows.js +20 -38
  88. package/dist/http/server.d.ts +8 -4
  89. package/dist/http/server.js +5 -1
  90. package/dist/http/stored-body-replay.d.ts +3 -24
  91. package/dist/http/stored-body-replay.js +2 -12
  92. package/dist/http/submit-ctx.d.ts +1 -2
  93. package/dist/http/submit-ctx.js +3 -2
  94. package/dist/http/submit-replay.d.ts +15 -2
  95. package/dist/http/submit-replay.js +10 -4
  96. package/dist/http/wire-types.d.ts +6 -3
  97. package/dist/leader/endpoint.js +9 -8
  98. package/dist/leg-facts.d.ts +8 -2
  99. package/dist/leg-facts.js +1 -0
  100. package/dist/mcp-authorization-face.d.ts +18 -14
  101. package/dist/mcp-authorization-face.js +3 -2
  102. package/dist/memory-store-refusal.d.ts +1 -1
  103. package/dist/observability/corrupt-read-seat.d.ts +1 -1
  104. package/dist/observability/fail-open.d.ts +5 -5
  105. package/dist/observability/fail-open.js +5 -5
  106. package/dist/observability/logger.d.ts +7 -0
  107. package/dist/observability/logger.js +3 -1
  108. package/dist/observability/run-terminal.d.ts +1 -1
  109. package/dist/owner-faces.d.ts +27 -3
  110. package/dist/owner-faces.js +2 -0
  111. package/dist/peer-seat.d.ts +1 -1
  112. package/dist/peer-seat.js +52 -59
  113. package/dist/plugins/exec-path-law.d.ts +34 -0
  114. package/dist/plugins/exec-path-law.js +13 -0
  115. package/dist/plugins/file-run-store.js +1 -1
  116. package/dist/plugins/local-checkpoint-store.d.ts +4 -1
  117. package/dist/plugins/local-checkpoint-store.js +3 -0
  118. package/dist/plugins/memory-run-store.js +1 -1
  119. package/dist/plugins/one-time-migrations.d.ts +74 -49
  120. package/dist/plugins/one-time-migrations.js +134 -69
  121. package/dist/plugins/pg-pool.js +4 -0
  122. package/dist/plugins/posix-shell-fs.js +4 -2
  123. package/dist/plugins/remote-env-adb.js +3 -2
  124. package/dist/plugins/remote-env-device.js +8 -6
  125. package/dist/plugins/remote-env-e2b.d.ts +3 -1
  126. package/dist/plugins/remote-env-e2b.js +11 -8
  127. package/dist/plugins/remote-env-host.d.ts +1 -1
  128. package/dist/plugins/remote-env-host.js +20 -11
  129. package/dist/plugins/remote-env-k8s.js +3 -2
  130. package/dist/plugins/remote-env-local-docker.js +3 -2
  131. package/dist/plugins/remote-env-ssh.js +4 -3
  132. package/dist/plugins/remote-shell.d.ts +3 -1
  133. package/dist/plugins/remote-shell.js +3 -3
  134. package/dist/plugins/replica-registration.d.ts +35 -0
  135. package/dist/plugins/replica-registration.js +48 -0
  136. package/dist/plugins/retention-lane-store-sql.d.ts +1 -1
  137. package/dist/plugins/run-store-sql.d.ts +1 -1
  138. package/dist/plugins/run-store-sql.js +2 -2
  139. package/dist/plugins/scenario-prefix-switch-m42.d.ts +6 -12
  140. package/dist/plugins/scenario-prefix-switch-m42.js +7 -5
  141. package/dist/plugins/schema-consistency.d.ts +7 -5
  142. package/dist/plugins/schema-consistency.js +29 -16
  143. package/dist/plugins/schema-drift-sentence.js +1 -1
  144. package/dist/plugins/serving-requirement.d.ts +49 -0
  145. package/dist/plugins/serving-requirement.js +53 -0
  146. package/dist/plugins/stop-then-start-sentence.d.ts +42 -3
  147. package/dist/plugins/stop-then-start-sentence.js +44 -0
  148. package/dist/plugins/store-backend.d.ts +1 -1
  149. package/dist/plugins/store-contracts.d.ts +12 -2
  150. package/dist/plugins/store-contracts.js +3 -0
  151. package/dist/plugins/tidb-pool.js +4 -0
  152. package/dist/process-instance.d.ts +3 -0
  153. package/dist/process-instance.js +7 -0
  154. package/dist/project-context.d.ts +66 -0
  155. package/dist/project-context.js +70 -0
  156. package/dist/question.d.ts +9 -21
  157. package/dist/question.js +28 -19
  158. package/dist/run-cancel-card.d.ts +1 -1
  159. package/dist/run-cancel-card.js +2 -1
  160. package/dist/security.d.ts +1 -1
  161. package/dist/session-agent.d.ts +1 -10
  162. package/dist/session-agent.js +0 -6
  163. package/dist/session-incarnation.d.ts +18 -4
  164. package/dist/session-incarnation.js +4 -1
  165. package/dist/session-notify-door.d.ts +4 -4
  166. package/dist/session-notify-door.js +5 -6
  167. package/dist/session-rules-seat.d.ts +4 -3
  168. package/dist/session-rules-seat.js +3 -11
  169. package/dist/session-sync.js +3 -3
  170. package/dist/stores-sql/checkpoint-store-sql.d.ts +21 -2
  171. package/dist/stores-sql/checkpoint-store-sql.js +106 -18
  172. package/dist/stores-sql/retention-store-sql.d.ts +1 -1
  173. package/dist/task-cwd.d.ts +4 -3
  174. package/dist/task-settings.d.ts +13 -0
  175. package/dist/task-settings.js +13 -7
  176. package/dist/tool-approval.d.ts +7 -6
  177. package/dist/tool-approval.js +41 -37
  178. package/dist/trace/engine-notice-wire.d.ts +14 -0
  179. package/dist/trace/engine-notice-wire.js +18 -1
  180. package/dist/trace/project.d.ts +4 -3
  181. package/dist/trace/project.js +3 -2
  182. package/dist/trace/projection-table.d.ts +33 -2
  183. package/dist/trace/self-minted-wire.d.ts +150 -17
  184. package/dist/trace/self-minted-wire.js +54 -16
  185. package/dist/trace/tool-card.d.ts +3 -0
  186. package/dist/trace/tool-card.js +24 -0
  187. package/dist/trace/tool-output-media.d.ts +2 -1
  188. package/dist/unattended-permission-mode.d.ts +1 -1
  189. package/dist/web-admin-client/apply-effective.d.ts +1 -1
  190. package/dist/web-admin-client/effective-keys.d.ts +1 -1
  191. package/dist/web-admin-client/skills-mcp.d.ts +1 -1
  192. package/docs/DEVICE-EXECUTOR-PROTOCOL.md +57 -29
  193. package/package.json +2 -2
  194. package/wire-changes/server@7.150.0.json +335 -0
@@ -30,7 +30,9 @@ import { isUnusableArchive } from "../execution-placement.js";
30
30
  import { IDENTITY_COLUMN_NAME, SCENARIO_PREFIX_LIKE, SCENARIO_PREFIX_MARKER_ID, ScenarioPrefixSwitchRefusedError } from "./scenario-prefix-switch-m42.js";
31
31
  import { MIGRATION_MARKER_TABLE, SKILL_EXEMPTION_CUTOFF_ID, markIfAbsentSql, migrationMarkerDdl } from "./one-time-migration-marker.js";
32
32
  import { describeUpgradeDriftTail, expectedTableShapes } from "./schema-consistency.js";
33
- import { stopThenStartAction } from "./stop-then-start-sentence.js";
33
+ import { olderReplicaEvidenceSentence, stopThenStartAction } from "./stop-then-start-sentence.js";
34
+ import { buildThisReplicaRegistration, knowsCapability, readReplicaRegistrations, registerReplica } from "./replica-registration.js";
35
+ import { assertReplicaTablesPresent, assertServingRequirementsMet } from "./serving-requirement.js";
34
36
  const RETIRED_SESSION_RULE_BUCKETS = ["commandAllow", "commandDeny"];
35
37
  export function buildMigrationReceipts() {
36
38
  return {
@@ -197,20 +199,6 @@ const OLD_GRAMMAR_SESSION_RULES_COLUMN = "rule_entries_old_grammar";
197
199
  const RETIRED_ASK_CAUSE_COLUMN = "denied_reason";
198
200
  const ASK_EXECUTION_LANE_COLUMN = "execution_lane";
199
201
  export const ONE_TIME_MIGRATIONS = [
200
- {
201
- id: "M12",
202
- introducedIn: "7.122.0",
203
- releasedOn: "2026-10-09",
204
- removableAfter: "2026-11-08",
205
- removal: "删模式词读法记号的判据与拒臂:`http/stored-body-replay.ts` 的 `permissionModeReadingUnstated`;`http/checkpoint-ctx.ts` 的 `permissionModeReadingStated` / `permissionModeReadingRefusal` / `PERMISSION_MODE_READINGS`、" +
206
- "`StoredBodyReplayRefusal` 的 `permission_mode_reading` 臂及其日志字段与答复臂、`buildCheckpointCtx` 与 `withPermissionModeRewritten` 两处记号写点" +
207
- "(后者删后 `resume-legs.ts` `drivePlanReview` 里对它的那一处调用回到只换体的 `{ ...ctx, body }`)、`CheckpointCtx.permissionModeReading`、" +
208
- "`http/stored-body-replay.ts` 里 `StoredReplayDeployment` 的 `autonomy` 位与 `storedBodyReplayRefusal` 读 `deployment.autonomy` 的那一句(这一位只服务这一问;四个问点传 `deps.config` 整份,home 位还服务 `permissions` 一问,不动);" +
209
- "`test/stored-request-prompt-profile-m30.test.ts` 的 SHARED 表里 `StoredBodyReplayRefusal` 那一行(rows 含 M12;M30 的登记行组已先删时该表已不在,这一句无事可做):rows 去掉 M12、整行并入同文件的 SITES(值 = 该行的 files 数组)",
210
- afterRemoval: "没有响亮臂:删码之后仍停驻、在 7.121.x 及以前写下的行(把 `APPROVAL_TERMINAL_BACKSTOP_MS` 调长的部署,或跳过了带本条的版本直接升的库)在设了 " +
211
- "`AUTONOMY=plan` 的部署上按体里的原词重放(运维的 plan 不再被按段重钳)—— 升级前把这些停驻的 run 决掉 / 取消,或升级窗内设 `AUTONOMY=read-only`",
212
- reportKeys: [],
213
- },
214
202
  {
215
203
  id: "M13",
216
204
  introducedIn: "7.123.0",
@@ -630,15 +618,18 @@ export const ONE_TIME_MIGRATIONS = [
630
618
  introducedIn: "7.147.0",
631
619
  releasedOn: "2026-11-07",
632
620
  removableAfter: "2026-12-07",
633
- removal: "删 `src/plugins/execution-lane-rename-m40.ts` 整只文件;本文件的 M40 步(`renameRetiredExecutionLaneWordsM40` / `refuseWhileRunsHeartbeat`〔它读的 `readLiveRunHeartbeat` / `LiveRunHeartbeat` 由 M41 / M42 共读,两条也已删码时随之删〕/ `ExecutionLaneRenameRefusedError` / " +
634
- "`EXECUTION_LANE_RENAME_REFUSED_CODE` / `ExecutionLaneRenameInput`,`migrateStepsOn` 第一步那一行与 `SqlOneTimeMigrationInput` 里的那一半)、报告 / 收据 / 日志 / `NO_SQL_MIGRATIONS` 的 `executionLaneWordsRenamed`、头注 M40 段;" +
635
- "`store-backend.ts` 的 `LocalBackend.ensureSchema` 第一步与 `executionLaneWordsRenamed` 字段、两只 SQL 后端递 `liveRunWindowMs` 的那一半;" +
621
+ removal: "删 `src/plugins/execution-lane-rename-m40.ts` 整只文件;本文件的 M40 步(`renameRetiredExecutionLaneWordsM40` / `refuseWhileRunsHeartbeat`〔读证据的那一处调用;证据读法 `readOlderReplicaEvidence` 与判活窗输入归 M52 行〕/ `ExecutionLaneRenameRefusedError` / " +
622
+ "`EXECUTION_LANE_RENAME_REFUSED_CODE`,`migrateStepsOn` 第一步那一行)、报告 / 收据 / 日志 / `NO_SQL_MIGRATIONS` 的 `executionLaneWordsRenamed`、头注 M40 段;" +
623
+ "`store-backend.ts` 的 `LocalBackend.ensureSchema` 第一步与 `executionLaneWordsRenamed` 字段;" +
636
624
  "`local-session-store.ts` 的内核 `renameRetiredExecutionLaneWordsInCellsM40` 与数据根准入的具名读者 `readSessionMetaSidecarInWindow`(在内存里套的那一步;准入的解码点改用 `readSessionMetaSidecar`);" +
637
625
  "`LocalSessionStore.renameRetiredExecutionLaneWordM40`、`FileApprovalAskStore.renameRetiredExecutionLaneWordM40` 与内核的 `renameExecutionLaneWord`;" +
638
626
  "`approval-ask-store-sql.ts` / `execution-placement-sql.ts` 拒句里的 `retiredExecutionLaneHintM40(…)` 补句;`test/execution-lane-rename-m40.test.ts` 整只文件与 `test/execution-placement-switch-db-integration.test.ts` 的 M40 组",
639
627
  afterRemoval: "既有读口的拒臂接住(响亮,不再改名):仍带旧词的行 —— 跳过了带本条的版本直接升的库、窗关之后还在写的更早版本副本 —— SQL 问行读口拒、会话执行格读口拒(`ExecutionPlacementRowError`)," +
640
628
  "local 会话元数据边档整只严格拒启(`config.schema_mismatch`),local 问账日志由 M32 回填的词表外拒臂拒启(M32 删码之后:那只问行原样装回)。出路 = 升级时先过一次带本条的版本并起服",
641
- stopThenStart: "renames the retired execution-lane word \"in-process\" to \"none\" on stored approval asks and sessions; a replica of an earlier version would keep writing the old word after the rename",
629
+ stopThenStart: {
630
+ reason: "renames the retired execution-lane word \"in-process\" to \"none\" on stored approval asks and sessions; a replica of an earlier version would keep writing the old word after the rename",
631
+ owedOn: owesExecutionLaneRenameM40,
632
+ },
642
633
  reportKeys: ["executionLaneWordsRenamed"],
643
634
  },
644
635
  {
@@ -649,10 +640,13 @@ export const ONE_TIME_MIGRATIONS = [
649
640
  removal: "删本文件的 M41 步(`moveSessionCellsToSideTable` / `refuseSessionExtMoveWhileRunsHeartbeat` / `deploymentColumnsOnMeta` / `SessionExtMoveRefusedError` / `SESSION_EXT_MOVE_REFUSED_CODE` / " +
650
641
  "`SessionExtMoveRefusalReason` / `sameCell` / `SESSION_EXT_MOVE_PAGE`,`migrateStepsOn` 里那两行)、报告 / 收据 / 日志 / `NO_SQL_MIGRATIONS` 的 `sessionExtRowsMoved` / `sessionMetaColumnsDropped`、头注 M41 段;" +
651
642
  "M32 读形里的 `cellsOnSideTable` 判(它读本条的 `deploymentColumnsOnMeta`):M32 还在时改成恒旁表形 —— 删 M32 写 `session_meta` 八列的那些臂(② ④ ④b ⑥ 与 ③ 的 `migrateDeviceBindingsStatement` 臂)," +
652
- "留 ③ 的旁表臂(`migrateDeviceBindingsOntoSideTableStatements`:围栏拒句的「采旧」在任何库上仍要能落到执行格);M32 先于本条删码时这些随 M32 一并删;`readLiveRunHeartbeat` 在 M40 与 M42 也已删码时随之删;`test/session-ext-move-db-integration.test.ts` 整只文件",
643
+ "留 ③ 的旁表臂(`migrateDeviceBindingsOntoSideTableStatements`:围栏拒句的「采旧」在任何库上仍要能落到执行格);M32 先于本条删码时这些随 M32 一并删;`test/session-ext-move-db-integration.test.ts` 整只文件。能力名单 `REPLICA_CAPABILITIES` 里的 `M41` 不删(只增)",
653
644
  afterRemoval: "跳过了带本条的版本直接升上来的库:`session_meta` 上仍带部署格列 ⇒ 起服列册对账拒启(`config.schema_mismatch`,那几列是本树之外的多余列,拒句不给 ALTER —— 出路是带本条的版本);" +
654
645
  "部署格从旁表读,旧列上的值不被任何读者读。出路 = 升级时先过一次带本条的版本并起服",
655
- stopThenStart: "moves the session deployment cells from session_meta to session_ext and then retires the old columns; a cell an earlier replica writes between the copy and the retirement is lost, and every cell write it makes afterwards fails",
646
+ stopThenStart: {
647
+ reason: "moves the session deployment cells from session_meta to session_ext and then retires the old columns; a cell an earlier replica writes between the copy and the retirement is lost, and every cell write it makes afterwards fails",
648
+ owedOn: owesSessionExtMoveM41,
649
+ },
656
650
  reportKeys: ["sessionExtRowsMoved", "sessionMetaColumnsDropped"],
657
651
  shapeChanges: [{ kind: "drop", table: "session_meta", columns: SESSION_EXT_MOVED_COLUMNS.map((c) => c.name) }],
658
652
  },
@@ -662,11 +656,14 @@ export const ONE_TIME_MIGRATIONS = [
662
656
  releasedOn: "2026-11-08",
663
657
  removableAfter: "2026-12-08",
664
658
  removal: "删 `src/plugins/scenario-prefix-switch-m42.ts` 整只文件;本文件的 M42 步(`switchScenarioPrefixM42` / `scannedIdentityColumnsM42`,`migrateStepsOn` 第一步那一行)、报告 / 收据 / 日志的 `scenarioPrefixSourcesScanned`、头注 M42 段;" +
665
- "`readLiveRunHeartbeat` / `LiveRunHeartbeat` 在 M40 与 M41 也已删码时随之删;`store-backend.ts` 的 `LocalBackend.runOneTimeMigrations` 里 M42 那一腿(`switchLocalScenarioPrefixM42`)与四只 File 店的 `scenarioPrefixOwnerRowsM42`(run / 会话 / 问 / 附件);`test/scenario-prefix-switch-m42.test.ts` 与 `test/scenario-prefix-switch-db-integration.test.ts` 两只文件。" +
659
+ "`store-backend.ts` 的 `LocalBackend.runOneTimeMigrations` 里 M42 那一腿(`switchLocalScenarioPrefixM42`)与四只 File 店的 `scenarioPrefixOwnerRowsM42`(run / 会话 / 问 / 附件);`test/scenario-prefix-switch-m42.test.ts` 与 `test/scenario-prefix-switch-db-integration.test.ts` 两只文件。" +
666
660
  "标记表 `one-time-migration-marker.ts` 不随删(常设:中央建表语句建它,`Skill` 豁免截点那一行的家);库里留下的 `M42` 标记行 / 数据根里的标记文件无人再读,不删",
667
661
  afterRemoval: "没有响亮臂(如实):跳过了带本条的版本直接升的库,存量里以 `_scn:` 开头的身份被恢复管线读作某个人的场景(拆不出合文法的场景与人的 ⇒ `owner.malformed_subject` 响亮)。" +
668
662
  "出路 = 升级时先过一次带本条的版本并起服(它扫一次、命中即拒启点名)",
669
- stopThenStart: "scans stored identities for the reserved scenario prefix and records a marker; an earlier replica does not reserve the prefix and would keep accepting such identities after the scan",
663
+ stopThenStart: {
664
+ reason: "scans stored identities for the reserved scenario prefix and records a marker; an earlier replica does not reserve the prefix and would keep accepting such identities after the scan",
665
+ owedOn: owesScenarioPrefixSwitchM42,
666
+ },
670
667
  reportKeys: ["scenarioPrefixSourcesScanned"],
671
668
  },
672
669
  {
@@ -789,9 +786,25 @@ export const ONE_TIME_MIGRATIONS = [
789
786
  "报告 / 收据 / `changedAnything` / `readingFields` / `NO_SQL_MIGRATIONS` 的 `sessionDeletingSwitchMarked`、头注 M51 段;`test/session-deleting-state-db-integration.test.ts` 的 M51 组;" +
790
787
  "库里留下的 `M51` 标记行无人再读,不删。**不随本条删**:在删态本身(列、围栏、规则 L 的在删半、终刀判令牌 —— 常设)、标记表 `one-time-migration-marker.ts`(常设)",
791
788
  afterRemoval: "没有响亮臂(也无需):窗 30 天之后更早版本的副本早已不在;跳过了带本条的版本直接升上来的库 —— 跨两版以上的混跑本就不被支持(7.147.0 起的停-迁-起)。出路 = 升级时先过一次带本条的版本并起服",
792
- stopThenStart: "switches session deletion to a deleting state that every replica must read; an earlier replica does not read it and would accept a submission on a session this version is deleting",
789
+ stopThenStart: {
790
+ reason: "switches session deletion to a deleting state that every replica must read; an earlier replica does not read it and would accept a submission on a session this version is deleting",
791
+ owedOn: owesSessionDeletingSwitchM51,
792
+ },
793
793
  reportKeys: ["sessionDeletingSwitchMarked"],
794
794
  },
795
+ {
796
+ id: "M52",
797
+ introducedIn: "7.150.0",
798
+ releasedOn: "2026-11-09",
799
+ removableAfter: "2026-12-09",
800
+ removal: "删 `readOlderReplicaEvidence` 的心跳一半(按 `instance_id` 分组读 `task_run` 心跳的那一句、减去登记为知道该迁移的持有者那一步)与它的判活窗输入:`LiveRunWindowInput`、`SqlOneTimeMigrationInput` 里的那一半、" +
801
+ "`store-backend.ts` 的 `buildSqlOneTimeMigrationInput` 读 `runStaleSec` 的那一半、夹具 `test/helpers/db-schema-boot.ts` 的 `liveRunWindowMs`;停-迁-起守卫若已全部删码(M40 / M41 / M42 / M43 / M51 的窗都早于本条关)," +
802
+ "连同 `readOlderReplicaEvidence` 与 `stop-then-start-sentence.ts` 的 `OlderReplicaEvidence` / `OlderReplicaHolder` / `olderReplicaEvidenceSentence` 一并删;" +
803
+ "`test/run-liveness-single-source-gate.test.ts` ② 的 `one-time-migrations.ts` 白名单行与 ③ `store-backend.ts` 理由里「一次性迁移守卫」半句;`test/older-replica-evidence-db-integration.test.ts` 的证据组;头注 M52 段。" +
804
+ "**不随本条删**:副本能力登记(`replica-registration.ts`,能力名单只增)与起服准入(`serving-requirement.ts`)—— 常设",
805
+ afterRemoval: "没有响亮臂(如实):早于副本登记的副本不登记,删码之后它们对停-迁-起守卫完全不可见,忙与闲同 —— 发布日之后的天数不证明旧进程不在;出路只有运维:起带停-迁-起迁移的版本之前停光早于本条的副本",
806
+ reportKeys: [],
807
+ },
795
808
  ];
796
809
  export function migrationWindowOpen(window, today) {
797
810
  return window.removableAfter === null || today <= window.removableAfter;
@@ -808,7 +821,7 @@ export function migrationCarried(id) {
808
821
  return ONE_TIME_MIGRATIONS.some((w) => w.id === id);
809
822
  }
810
823
  export function stopThenStartMigrations(registry = ONE_TIME_MIGRATIONS) {
811
- return registry.flatMap((row) => (row.stopThenStart === undefined ? [] : [{ id: row.id, reason: row.stopThenStart }]));
824
+ return registry.flatMap((row) => (row.stopThenStart === undefined ? [] : [{ id: row.id, ...row.stopThenStart }]));
812
825
  }
813
826
  export const NO_SQL_MIGRATIONS = {
814
827
  sessionRuleColumn: "file-store",
@@ -898,16 +911,27 @@ async function columnExists(db, dialect, table, column) {
898
911
  export function createSqlImportedLayerRootSource(exec) {
899
912
  return { candidateOwners: async () => ({ owners: await listRuleBucketOwners(exec), unnamedBuckets: 0 }), durable: new SqlDurableRulePartitionProvider(exec), isWholeBucketRefusal: isSqlBucketRefusal, isVanishedBucket: isSqlBucketVanished };
900
913
  }
901
- export async function runSqlOneTimeMigrations(dialect, pool, input) {
902
- return dialect === "mysql" ? withMysqlLock(pool, (db) => migrateOn(db, "mysql", input)) : withPgLock(pool, (db) => migrateOn(db, "pg", input));
914
+ export async function runSqlOneTimeMigrations(dialect, pool, input, me = buildThisReplicaRegistration()) {
915
+ const head = async (db) => {
916
+ await assertReplicaTablesPresent(db, dialect, me);
917
+ await registerReplica(db, dialect, me, Date.now());
918
+ await assertServingRequirementsMet(db, me);
919
+ };
920
+ const locked = async (db) => {
921
+ await assertServingRequirementsMet(db, me);
922
+ return migrateOn(db, dialect, input);
923
+ };
924
+ return dialect === "mysql" ? withMysqlLock(pool, head, locked) : withPgLock(pool, head, locked);
903
925
  }
904
- async function withMysqlLock(pool, run) {
926
+ async function withMysqlLock(pool, head, run) {
905
927
  const conn = await pool.getConnection();
906
928
  let locked = false;
907
929
  try {
930
+ const db = mysqlDb(conn);
931
+ await head(db);
908
932
  await acquireEnsureSchemaLock(conn, "oneTimeMigrations");
909
933
  locked = true;
910
- return await run(mysqlDb(conn));
934
+ return await run(db);
911
935
  }
912
936
  finally {
913
937
  if (locked)
@@ -915,13 +939,15 @@ async function withMysqlLock(pool, run) {
915
939
  conn.release();
916
940
  }
917
941
  }
918
- async function withPgLock(pool, run) {
942
+ async function withPgLock(pool, head, run) {
919
943
  const client = await pool.connect();
920
944
  let locked = false;
921
945
  try {
946
+ const db = pgDb(client);
947
+ await head(db);
922
948
  await acquirePgEnsureSchemaLock(client, "oneTimeMigrations");
923
949
  locked = true;
924
- return await run(pgDb(client));
950
+ return await run(db);
925
951
  }
926
952
  finally {
927
953
  if (locked)
@@ -1096,20 +1122,37 @@ async function markWithdrawEnabledM43(db, dialect, receipts, input) {
1096
1122
  const marked = await db.rows(dialect === "mysql" ? `SELECT 1 AS present FROM ${MIGRATION_MARKER_TABLE} WHERE migration_id = ?` : `SELECT 1 AS present FROM ${MIGRATION_MARKER_TABLE} WHERE migration_id = $1`, [WITHDRAW_MARK_ID]);
1097
1123
  if (marked.length > 0)
1098
1124
  return;
1099
- if ((await readLiveRunHeartbeat(db, dialect, input)) !== undefined)
1125
+ if ((await readOlderReplicaEvidence(db, dialect, input, "M43")) !== undefined)
1100
1126
  return;
1101
1127
  receipts.withdrawMarkWritten += await db.exec(markIfAbsentSql(dialect), [WITHDRAW_MARK_ID, Date.now()]);
1102
1128
  }
1129
+ async function migrationMarked(read, dialect, id) {
1130
+ if (!(await tableExists({ rows: read }, dialect, MIGRATION_MARKER_TABLE)))
1131
+ return false;
1132
+ return (await read(`SELECT 1 AS present FROM ${MIGRATION_MARKER_TABLE} WHERE migration_id = '${id}'`)).length > 0;
1133
+ }
1134
+ async function owesExecutionLaneRenameM40(read, dialect) {
1135
+ return (await executionLaneWordRowsM40(read, dialect)).pendingRows > 0;
1136
+ }
1137
+ async function owesSessionExtMoveM41(read, dialect) {
1138
+ return (await deploymentColumnsOnMeta({ rows: read }, dialect)).length > 0;
1139
+ }
1140
+ async function owesScenarioPrefixSwitchM42(read, dialect) {
1141
+ return !(await migrationMarked(read, dialect, SCENARIO_PREFIX_MARKER_ID));
1142
+ }
1143
+ async function owesSessionDeletingSwitchM51(read, dialect) {
1144
+ return !(await migrationMarked(read, dialect, SESSION_DELETING_MARK_ID));
1145
+ }
1103
1146
  export const SESSION_DELETING_MARK_ID = "M51";
1104
1147
  export const SESSION_DELETING_SWITCH_REFUSED_CODE = "config.session_deleting_switch_refused";
1105
1148
  export class SessionDeletingSwitchRefusedError extends StoreBootRefusalError {
1106
1149
  code = SESSION_DELETING_SWITCH_REFUSED_CODE;
1107
- reason = "live_runs";
1108
- constructor(live, tail = "") {
1109
- super(`${SESSION_DELETING_SWITCH_REFUSED_CODE} (reason: live_runs): this version deletes a session through a deleting state that every replica must read ` +
1110
- `(a submission on a session being deleted is refused), but ${live.liveRuns} running run(s) heartbeated on this database within the last ${live.windowSec}s (REAP_RUN_STALE_SEC) — ` +
1150
+ reason = "older_replica";
1151
+ constructor(evidence, tail = "") {
1152
+ super(`${SESSION_DELETING_SWITCH_REFUSED_CODE} (reason: older_replica): this version deletes a session through a deleting state that every replica must read ` +
1153
+ `(a submission on a session being deleted is refused), but ${olderReplicaEvidenceSentence(evidence)} — ` +
1111
1154
  "a replica of an earlier version may still be serving; it does not read that state and would accept a submission on a session this version is deleting. Nothing was changed and no marker was written. " +
1112
- stopThenStartAction(live.after) +
1155
+ stopThenStartAction(evidence.after) +
1113
1156
  tail);
1114
1157
  this.name = "SessionDeletingSwitchRefusedError";
1115
1158
  }
@@ -1117,12 +1160,11 @@ export class SessionDeletingSwitchRefusedError extends StoreBootRefusalError {
1117
1160
  async function switchSessionDeletingM51(db, dialect, receipts, input) {
1118
1161
  if (!(await tableExists(db, dialect, MIGRATION_MARKER_TABLE)))
1119
1162
  await execUntil(db, migrationMarkerDdl(dialect), () => tableExists(db, dialect, MIGRATION_MARKER_TABLE));
1120
- const marked = await db.rows(dialect === "mysql" ? `SELECT 1 AS present FROM ${MIGRATION_MARKER_TABLE} WHERE migration_id = ?` : `SELECT 1 AS present FROM ${MIGRATION_MARKER_TABLE} WHERE migration_id = $1`, [SESSION_DELETING_MARK_ID]);
1121
- if (marked.length > 0)
1163
+ if (!(await owesSessionDeletingSwitchM51((sql) => db.rows(sql), dialect)))
1122
1164
  return;
1123
- const heartbeat = await readLiveRunHeartbeat(db, dialect, input);
1124
- if (heartbeat !== undefined)
1125
- throw new SessionDeletingSwitchRefusedError(heartbeat, await describeUpgradeDriftTail((sql) => db.rows(sql), dialect));
1165
+ const evidence = await readOlderReplicaEvidence(db, dialect, input, "M51");
1166
+ if (evidence !== undefined)
1167
+ throw new SessionDeletingSwitchRefusedError(evidence, await describeUpgradeDriftTail((sql) => db.rows(sql), dialect));
1126
1168
  receipts.sessionDeletingSwitchMarked += await db.exec(markIfAbsentSql(dialect), [SESSION_DELETING_MARK_ID, Date.now()]);
1127
1169
  }
1128
1170
  export async function scannedIdentityColumnsM42(db, dialect) {
@@ -1142,8 +1184,7 @@ export async function scannedIdentityColumnsM42(db, dialect) {
1142
1184
  export async function switchScenarioPrefixM42(db, dialect, receipts, input) {
1143
1185
  if (!(await tableExists(db, dialect, MIGRATION_MARKER_TABLE)))
1144
1186
  await execUntil(db, migrationMarkerDdl(dialect), () => tableExists(db, dialect, MIGRATION_MARKER_TABLE));
1145
- const marked = await db.rows(dialect === "mysql" ? `SELECT 1 AS present FROM ${MIGRATION_MARKER_TABLE} WHERE migration_id = ?` : `SELECT 1 AS present FROM ${MIGRATION_MARKER_TABLE} WHERE migration_id = $1`, [SCENARIO_PREFIX_MARKER_ID]);
1146
- if (marked.length > 0)
1187
+ if (!(await owesScenarioPrefixSwitchM42((sql) => db.rows(sql), dialect)))
1147
1188
  return;
1148
1189
  const columns = await scannedIdentityColumnsM42(db, dialect);
1149
1190
  const hits = [];
@@ -1156,9 +1197,9 @@ export async function switchScenarioPrefixM42(db, dialect, receipts, input) {
1156
1197
  receipts.scenarioPrefixSourcesScanned += columns.length;
1157
1198
  if (hits.length > 0)
1158
1199
  throw new ScenarioPrefixSwitchRefusedError("stored_prefix", hits, undefined, await describeUpgradeDriftTail((sql) => db.rows(sql), dialect));
1159
- const live = await readLiveRunHeartbeat(db, dialect, input);
1160
- if (live !== undefined)
1161
- throw new ScenarioPrefixSwitchRefusedError("live_runs", [], live, await describeUpgradeDriftTail((sql) => db.rows(sql), dialect));
1200
+ const evidence = await readOlderReplicaEvidence(db, dialect, input, "M42");
1201
+ if (evidence !== undefined)
1202
+ throw new ScenarioPrefixSwitchRefusedError("older_replica", [], evidence, await describeUpgradeDriftTail((sql) => db.rows(sql), dialect));
1162
1203
  await db.exec(markIfAbsentSql(dialect), [SCENARIO_PREFIX_MARKER_ID, Date.now()]);
1163
1204
  }
1164
1205
  export const EXECUTION_LANE_RENAME_REFUSED_CODE = "config.execution_lane_rename_refused";
@@ -1191,16 +1232,20 @@ export class ExecutionLaneRenameWriteRefusedError extends StoreBootRefusalError
1191
1232
  }
1192
1233
  }
1193
1234
  const EXECUTION_LANE_TABLES = [APPROVAL_ASK_TABLE, "session_meta", SESSION_EXT_TABLE];
1194
- export async function renameRetiredExecutionLaneWordsM40(db, dialect, receipts, input) {
1235
+ async function executionLaneWordRowsM40(read, dialect) {
1195
1236
  const tables = [];
1196
1237
  for (const table of EXECUTION_LANE_TABLES)
1197
- if (await columnExists(db, dialect, table, "execution_lane"))
1238
+ if (await columnExists({ rows: read }, dialect, table, "execution_lane"))
1198
1239
  tables.push(table);
1199
1240
  let pendingRows = 0;
1200
1241
  for (const table of tables) {
1201
- const [row] = await db.rows(`SELECT COUNT(*) AS n FROM ${table} WHERE execution_lane = ${dialect === "mysql" ? "?" : "$1"}`, [RETIRED_EXECUTION_LANE_WORD_M40]);
1242
+ const [row] = await read(`SELECT COUNT(*) AS n FROM ${table} WHERE execution_lane = '${RETIRED_EXECUTION_LANE_WORD_M40}'`);
1202
1243
  pendingRows += Number(row?.["n"] ?? 0);
1203
1244
  }
1245
+ return { tables, pendingRows };
1246
+ }
1247
+ export async function renameRetiredExecutionLaneWordsM40(db, dialect, receipts, input) {
1248
+ const { tables, pendingRows } = await executionLaneWordRowsM40((sql) => db.rows(sql), dialect);
1204
1249
  if (pendingRows === 0)
1205
1250
  return;
1206
1251
  await refuseWhileRunsHeartbeat(db, dialect, pendingRows, input);
@@ -1208,34 +1253,54 @@ export async function renameRetiredExecutionLaneWordsM40(db, dialect, receipts,
1208
1253
  receipts.executionLaneWordsRenamed += await db.exec(dialect === "mysql" ? `UPDATE ${table} SET execution_lane = ? WHERE execution_lane = ?` : `UPDATE ${table} SET execution_lane = $1 WHERE execution_lane = $2`, [RENAMED_EXECUTION_LANE_M40, RETIRED_EXECUTION_LANE_WORD_M40]);
1209
1254
  }
1210
1255
  }
1211
- async function readLiveRunHeartbeat(db, dialect, input) {
1256
+ export async function readOlderReplicaEvidence(db, dialect, input, migrationId) {
1212
1257
  if (!(await tableExists(db, dialect, "task_run")))
1213
1258
  return undefined;
1214
1259
  const since = new Date(Date.now() - input.liveRunWindowMs);
1215
- const [row] = await db.rows(`SELECT COUNT(*) AS n, MAX(updated_at) AS newest FROM task_run WHERE status = 'running' AND updated_at >= ${dialect === "mysql" ? "?" : "$1"}`, [since]);
1216
- const liveRuns = Number(row?.["n"] ?? 0);
1260
+ const groups = (await db.rows(`SELECT instance_id, COUNT(*) AS n, MAX(updated_at) AS newest FROM task_run WHERE status = 'running' AND updated_at >= ${dialect === "mysql" ? "?" : "$1"} GROUP BY instance_id`, [since]))
1261
+ .map((r) => ({ holder: typeof r["instance_id"] === "string" ? r["instance_id"] : undefined, runs: Number(r["n"] ?? 0), newest: r["newest"] }))
1262
+ .filter((g) => g.runs > 0);
1263
+ if (groups.length === 0)
1264
+ return undefined;
1265
+ const registered = await readReplicaRegistrations({ query: async (sql, params) => ({ rows: await db.rows(sql, params), affected: 0 }) }, dialect, groups.flatMap((g) => (g.holder === undefined ? [] : [g.holder])));
1266
+ let unregisteredRuns = 0;
1267
+ let newestMs = Number.NaN;
1268
+ const registeredWithout = [];
1269
+ for (const g of groups) {
1270
+ const r = g.holder === undefined ? undefined : registered.get(g.holder);
1271
+ if (r !== undefined && knowsCapability(r.capabilities, migrationId))
1272
+ continue;
1273
+ if (r === undefined)
1274
+ unregisteredRuns += g.runs;
1275
+ else
1276
+ registeredWithout.push({ instanceId: r.instanceId, releaseLine: r.releaseLine, hostName: r.hostName, pid: r.pid, runs: g.runs });
1277
+ const cell = g.newest instanceof Date ? g.newest : new Date(String(g.newest));
1278
+ if (!Number.isNaN(cell.getTime()) && !(cell.getTime() <= newestMs))
1279
+ newestMs = cell.getTime();
1280
+ }
1281
+ const liveRuns = unregisteredRuns + registeredWithout.reduce((n, h) => n + h.runs, 0);
1217
1282
  if (liveRuns === 0)
1218
1283
  return undefined;
1219
- const newestCell = row?.["newest"];
1220
- const newest = newestCell instanceof Date ? newestCell : new Date(String(newestCell));
1221
1284
  const now = Date.now();
1222
- const restartAtMs = (Number.isNaN(newest.getTime()) ? now : newest.getTime()) + input.liveRunWindowMs;
1285
+ const restartAtMs = (Number.isNaN(newestMs) ? now : newestMs) + input.liveRunWindowMs;
1223
1286
  return {
1287
+ migration: migrationId,
1224
1288
  liveRuns,
1225
1289
  windowSec: Math.round(input.liveRunWindowMs / 1000),
1226
1290
  restartAfter: new Date(restartAtMs).toISOString(),
1227
1291
  after: `${new Date(restartAtMs).toISOString()} (${Math.max(0, Math.ceil((restartAtMs - now) / 1000))}s from now)`,
1292
+ unregisteredRuns,
1293
+ registeredWithout: registeredWithout.sort((a, b) => a.instanceId.localeCompare(b.instanceId)),
1228
1294
  };
1229
1295
  }
1230
1296
  async function refuseWhileRunsHeartbeat(db, dialect, pendingRows, input) {
1231
- const heartbeat = await readLiveRunHeartbeat(db, dialect, input);
1232
- if (heartbeat === undefined)
1297
+ const evidence = await readOlderReplicaEvidence(db, dialect, input, "M40");
1298
+ if (evidence === undefined)
1233
1299
  return;
1234
- const { liveRuns, windowSec, restartAfter, after } = heartbeat;
1235
- throw new ExecutionLaneRenameRefusedError(pendingRows, liveRuns, restartAfter, `${pendingRows} stored rows (approval asks / sessions) carry the execution lane word "${RETIRED_EXECUTION_LANE_WORD_M40}", the name an earlier version gave the lane with no execution environment, ` +
1236
- `which this version calls "${RENAMED_EXECUTION_LANE_M40}" and does not read; this boot renames them (one-time migration M40), but ${liveRuns} running runs on this database heartbeat within the last ${windowSec}s ` +
1237
- `(REAP_RUN_STALE_SEC) — a replica of an earlier version is still serving, and it would keep writing the old word after the rename. No stored row was changed. ` +
1238
- stopThenStartAction(after) +
1300
+ throw new ExecutionLaneRenameRefusedError(pendingRows, evidence.liveRuns, evidence.restartAfter, `${pendingRows} stored rows (approval asks / sessions) carry the execution lane word "${RETIRED_EXECUTION_LANE_WORD_M40}", the name an earlier version gave the lane with no execution environment, ` +
1301
+ `which this version calls "${RENAMED_EXECUTION_LANE_M40}" and does not read; this boot renames them (one-time migration M40), but ${olderReplicaEvidenceSentence(evidence)} ` +
1302
+ `— a replica of an earlier version may still be serving, and it would keep writing the old word after the rename. No stored row was changed. ` +
1303
+ stopThenStartAction(evidence.after) +
1239
1304
  (await describeUpgradeDriftTail((sql) => db.rows(sql), dialect)));
1240
1305
  }
1241
1306
  export const SESSION_EXT_MOVE_REFUSED_CODE = "config.session_ext_move_refused";
@@ -1266,13 +1331,13 @@ export async function refuseSessionExtMoveWhileRunsHeartbeat(db, dialect, input)
1266
1331
  const columns = await deploymentColumnsOnMeta(db, dialect);
1267
1332
  if (columns.length === 0)
1268
1333
  return;
1269
- const heartbeat = await readLiveRunHeartbeat(db, dialect, input);
1270
- if (heartbeat === undefined)
1334
+ const evidence = await readOlderReplicaEvidence(db, dialect, input, "M41");
1335
+ if (evidence === undefined)
1271
1336
  return;
1272
- throw new SessionExtMoveRefusedError("live_runs", `${columns.length} session cells (${columns.map((c) => c.name).join(", ")}) still live on session_meta; this version keeps them on the side table ${SESSION_EXT_TABLE} and moves them on this boot ` +
1273
- `(one-time migration M41: copy, verify, then drop the columns), but ${heartbeat.liveRuns} running runs on this database heartbeat within the last ${heartbeat.windowSec}s (REAP_RUN_STALE_SEC) — ` +
1274
- "a replica of an earlier version is still serving; a cell it writes between the copy and the drop would be lost, and every cell write it makes after the drop fails. Nothing was changed. " +
1275
- stopThenStartAction(heartbeat.after) +
1337
+ throw new SessionExtMoveRefusedError("older_replica", `${columns.length} session cells (${columns.map((c) => c.name).join(", ")}) still live on session_meta; this version keeps them on the side table ${SESSION_EXT_TABLE} and moves them on this boot ` +
1338
+ `(one-time migration M41: copy, verify, then drop the columns), but ${olderReplicaEvidenceSentence(evidence)} — ` +
1339
+ "a replica of an earlier version may still be serving; a cell it writes between the copy and the drop would be lost, and every cell write it makes after the drop fails. Nothing was changed. " +
1340
+ stopThenStartAction(evidence.after) +
1276
1341
  (await describeUpgradeDriftTail((sql) => db.rows(sql), dialect)));
1277
1342
  }
1278
1343
  export async function moveSessionCellsToSideTable(db, dialect, receipts) {
@@ -27,6 +27,8 @@ import { INBOX_PAYLOAD_COLUMN } from "./inbox-payload-column.js";
27
27
  import { CENTRAL_SCHEMA_INDEXES } from "./schema-indexes.js";
28
28
  import { PRINCIPAL_MAX_BYTES } from "../principal-shape.js";
29
29
  import { SKILL_EXEMPTION_CUTOFF_ID, markIfAbsentSql, migrationMarkerDdl } from "./one-time-migration-marker.js";
30
+ import { replicaRegistrationDdl } from "./replica-registration.js";
31
+ import { servingRequirementDdl } from "./serving-requirement.js";
30
32
  export const PG_SCHEMA_STATEMENTS = [
31
33
  `CREATE TABLE IF NOT EXISTS task_run (
32
34
  task_id VARCHAR(64) COLLATE "C" NOT NULL,
@@ -421,6 +423,8 @@ export const PG_SCHEMA_STATEMENTS = [
421
423
  PRIMARY KEY (store_id, path)
422
424
  )`,
423
425
  migrationMarkerDdl("pg"),
426
+ replicaRegistrationDdl("pg"),
427
+ servingRequirementDdl("pg"),
424
428
  ];
425
429
  export function pgPoolOptions(pgCfg, dbQueryTimeoutMs) {
426
430
  return {
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { anchorUnder } from "./exec-path-law.js";
2
3
  import { FileError, } from "@sema-agent/core";
3
4
  import { runSymlinkChain, shellQuote, kindFromMode } from "./remote-shell.js";
4
5
  import { classifyFsStderr, fileErrorFromExec } from "./remote-env-file-error.js";
@@ -60,11 +61,12 @@ export function createPosixShellFs(deps) {
60
61
  const names = r.value.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
61
62
  const out = [];
62
63
  for (const name of names) {
63
- const info = await fs.statFileInfo(path.posix.join(abs, name), abortSignal);
64
+ const entry = anchorUnder(path.posix, abs, name);
65
+ const info = await fs.statFileInfo(entry, abortSignal);
64
66
  if (info.ok)
65
67
  out.push(info.value);
66
68
  else
67
- out.push({ name, path: path.posix.join(abs, name), kind: "file", size: 0, mtimeMs: 0 });
69
+ out.push({ name, path: entry, kind: "file", size: 0, mtimeMs: 0 });
68
70
  }
69
71
  return ok(out);
70
72
  },
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { anchoredPath, joinedPath } from "./exec-path-law.js";
2
3
  import os from "node:os";
3
4
  import fs from "node:fs/promises";
4
5
  import { spawn } from "node:child_process";
@@ -65,7 +66,7 @@ export class RemoteAdbExecutionEnv {
65
66
  this.injectedConnectOnce = deps?.connectOnce;
66
67
  }
67
68
  resolve(p) {
68
- return path.posix.isAbsolute(p) ? path.posix.normalize(p) : path.posix.normalize(path.posix.join(this.cwd, p));
69
+ return anchoredPath(path.posix, this.cwd, p);
69
70
  }
70
71
  workspaceHandle() {
71
72
  return { sandboxId: this.cfg.serial, provider: PROVIDER, mountPath: this.cwd };
@@ -328,7 +329,7 @@ export class RemoteAdbExecutionEnv {
328
329
  return ok(this.resolve(p));
329
330
  }
330
331
  async joinPath(parts) {
331
- return ok(path.posix.join(...parts));
332
+ return ok(joinedPath(path.posix, parts));
332
333
  }
333
334
  async fsGate(p) {
334
335
  if (this.destroyed)
@@ -1,4 +1,5 @@
1
1
  import { posix } from "node:path";
2
+ import { anchoredPath, anchorUnder, joinedPath } from "./exec-path-law.js";
2
3
  import { ExecutionError, FileError, RemoteExecutionError, } from "@sema-agent/core";
3
4
  import { capabilitiesFor } from "../execution-lane-caps.js";
4
5
  import { DEVICE_ERROR_CODES, isDeviceAbsolutePath, } from "../device-store.js";
@@ -174,7 +175,7 @@ export class RemoteDeviceExecutionEnv {
174
175
  "performing a NON-atomic write here is exactly the silent downgrade guarded writes exist to prevent (#157). Refusing; the per-task factory mints a correctly-shaped env on the next task.", absPath);
175
176
  }
176
177
  resolve(p) {
177
- return isDeviceAbsolutePath(p) ? posix.normalize(p) : posix.normalize(posix.join(this.cwd, p));
178
+ return anchoredPath(DEVICE_PATH_FAMILY, this.cwd, p);
178
179
  }
179
180
  instructionCwd(requested) {
180
181
  return requested === undefined ? this.cwd : this.resolve(requested);
@@ -183,7 +184,7 @@ export class RemoteDeviceExecutionEnv {
183
184
  return ok(this.resolve(p));
184
185
  }
185
186
  async joinPath(parts) {
186
- return ok(posix.join(...parts));
187
+ return ok(joinedPath(DEVICE_PATH_FAMILY, parts));
187
188
  }
188
189
  workspaceHandle() {
189
190
  return {
@@ -557,7 +558,7 @@ export class RemoteDeviceExecutionEnv {
557
558
  return this.wrongOutcome("stat", o.kind, abs);
558
559
  if (!o.info)
559
560
  return { ok: false, error: new FileError("unknown", "device returned a stat outcome without info — protocol violation", abs) };
560
- return ok(toFileInfo(o.info));
561
+ return ok(toFileInfo(o.info, abs));
561
562
  });
562
563
  }
563
564
  async doReadLink(p, abortSignal) {
@@ -642,7 +643,7 @@ export class RemoteDeviceExecutionEnv {
642
643
  return this.wrongOutcome("stat", o.kind, abs);
643
644
  if (!o.entries)
644
645
  return { ok: false, error: new FileError("unknown", "device returned a stat outcome without entries — protocol violation", abs) };
645
- return ok(o.entries.map(toFileInfo));
646
+ return ok(o.entries.map((w) => toFileInfo(w, anchorUnder(DEVICE_PATH_FAMILY, abs, posix.basename(w.path)))));
646
647
  });
647
648
  }
648
649
  async writeFile(p, content, abortSignal) {
@@ -738,8 +739,9 @@ function mergedEnv(taskEnv, commandEnv) {
738
739
  function markTruncated(text, truncated) {
739
740
  return truncated === true ? `${text}\n[output truncated by the device executor]` : text;
740
741
  }
741
- function toFileInfo(w) {
742
- return { name: posix.basename(w.path), path: w.path, kind: w.kind === "dir" ? "directory" : w.kind === "symlink" ? "symlink" : "file", size: w.size, mtimeMs: w.mtimeMs };
742
+ const DEVICE_PATH_FAMILY = { sep: posix.sep, isAbsolute: isDeviceAbsolutePath };
743
+ function toFileInfo(w, at) {
744
+ return { name: posix.basename(at), path: at, kind: w.kind === "dir" ? "directory" : w.kind === "symlink" ? "symlink" : "file", size: w.size, mtimeMs: w.mtimeMs };
743
745
  }
744
746
  function toWireExpectation(e) {
745
747
  return {
@@ -45,7 +45,7 @@ export interface RemoteContainerEnvConfig extends RemoteEnvObservers, LaneHomeCo
45
45
  template?: string;
46
46
  /** Sandbox lifetime in ms (E2B billing window). Default {@link DEFAULT_TIMEOUT_MS}. */
47
47
  timeoutMs?: number;
48
- /** Workspace root inside the VM; remote paths normalize against it. Default {@link DEFAULT_MOUNT_PATH}. */
48
+ /** Workspace root inside the VM; a relative path is anchored at it (`exec-path-law.ts`: anchored, never folded). Default {@link DEFAULT_MOUNT_PATH}. */
49
49
  mountPath?: string;
50
50
  /** Outbound network for the sandbox (clay: service needs external-network capability). Default `true`. */
51
51
  allowInternetAccess?: boolean;
@@ -365,6 +365,8 @@ export declare class RemoteContainerExecutionEnv implements RemoteExecutionEnv,
365
365
  private ensureConnected;
366
366
  private fsReady;
367
367
  private tempResult;
368
+ /** 提供方的条目 → `FileInfo`。`path` 取本环境的拼法(`at`:被问的那只路径 / 列目录时锚在它之下的条目),不取提供方回报的那一份
369
+ * —— 提供方可能已把 `..` 折掉,而执行缝路径律(`exec-path-law.ts`)要求条目名在交来的路径之下、能原样再交回来。 */
368
370
  private toFileInfo;
369
371
  private fileError;
370
372
  /** A thrown provider error → this exec's error. The two cuts (aborted, the provider's own command deadline) are a command that RAN: they carry what it had
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { anchoredPath, anchorUnder, joinedPath } from "./exec-path-law.js";
2
3
  import { Sandbox, CommandExitError, FileType } from "e2b";
3
4
  import { FileError, ExecutionError, RemoteExecutionError, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
4
5
  import { capabilitiesFor } from "../execution-lane-caps.js";
@@ -862,7 +863,7 @@ export class RemoteContainerExecutionEnv {
862
863
  return ok(this.resolve(p));
863
864
  }
864
865
  async joinPath(parts) {
865
- return ok(path.posix.join(...parts));
866
+ return ok(joinedPath(path.posix, parts));
866
867
  }
867
868
  async readTextFile(p, abortSignal) {
868
869
  const sbx = await this.fsReady(p);
@@ -934,8 +935,9 @@ export class RemoteContainerExecutionEnv {
934
935
  if (!sbx.ok)
935
936
  return sbx;
936
937
  try {
937
- const info = await this.rpc("files.getInfo", (signal) => sbx.value.files.getInfo(this.resolve(p), { signal }), abortSignal);
938
- return ok(this.toFileInfo(info));
938
+ const abs = this.resolve(p);
939
+ const info = await this.rpc("files.getInfo", (signal) => sbx.value.files.getInfo(abs, { signal }), abortSignal);
940
+ return ok(this.toFileInfo(info, abs));
939
941
  }
940
942
  catch (e) {
941
943
  return { ok: false, error: this.fileError(e, this.resolve(p), abortSignal) };
@@ -946,8 +948,9 @@ export class RemoteContainerExecutionEnv {
946
948
  if (!sbx.ok)
947
949
  return sbx;
948
950
  try {
949
- const entries = await this.rpc("files.list", (signal) => sbx.value.files.list(this.resolve(p), { signal }), abortSignal);
950
- return ok(entries.map((e) => this.toFileInfo(e)));
951
+ const abs = this.resolve(p);
952
+ const entries = await this.rpc("files.list", (signal) => sbx.value.files.list(abs, { signal }), abortSignal);
953
+ return ok(entries.map((e) => this.toFileInfo(e, anchorUnder(path.posix, abs, e.name))));
951
954
  }
952
955
  catch (e) {
953
956
  return { ok: false, error: this.fileError(e, this.resolve(p), abortSignal) };
@@ -1044,7 +1047,7 @@ export class RemoteContainerExecutionEnv {
1044
1047
  return this.tempResult(r, "mktemp");
1045
1048
  }
1046
1049
  resolve(p) {
1047
- return path.posix.isAbsolute(p) ? path.posix.normalize(p) : path.posix.normalize(path.posix.join(this.cwd, p));
1050
+ return anchoredPath(path.posix, this.cwd, p);
1048
1051
  }
1049
1052
  async rpc(label, op, userSignal, timeoutMs = this.cfg.rpcTimeoutMs) {
1050
1053
  const deadline = new AbortController();
@@ -1163,11 +1166,11 @@ export class RemoteContainerExecutionEnv {
1163
1166
  return { ok: false, error: new FileError("unknown", r.value.stderr.trim() || `${op} failed`) };
1164
1167
  return ok(r.value.stdout.trim());
1165
1168
  }
1166
- toFileInfo(e) {
1169
+ toFileInfo(e, at) {
1167
1170
  const kind = e.symlinkTarget ? "symlink" : e.type === FileType.DIR ? "directory" : "file";
1168
1171
  return {
1169
1172
  name: e.name,
1170
- path: e.path,
1173
+ path: at,
1171
1174
  kind,
1172
1175
  size: e.size ?? 0,
1173
1176
  mtimeMs: e.modifiedTime ? e.modifiedTime.getTime() : 0,
@@ -112,7 +112,7 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
112
112
  /**
113
113
  * core 7.56.0 [ref]:shell 子进程继承的环境里**收不到**的条目(名 + 规则,从不带值)—— 与 {@link cdPath} 同一只组合的读面
114
114
  * (`server-secret-env.ts` 的 `withheldFromHostSubprocess`:继承组合扣下的发现,减去 `cfg.env` 这条显式授予重供的键)。逐命令的 `options.env`
115
- * 是引擎自己交的显式授予,它知道(core KL-1963-b 同一残余)。只读:不披露、不计数、不碰 shell。
115
+ * 是引擎自己交的显式授予,它知道(core [ref] 同一残余)。只读:不披露、不计数、不碰 shell。
116
116
  */
117
117
  get withheldEnv(): readonly SecretEnvFinding[];
118
118
  private readonly workspaceDir;