@sema-agent/server 7.148.0 → 7.149.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 (247) hide show
  1. package/MIGRATION.md +429 -0
  2. package/README.md +16 -1
  3. package/README.zh-CN.md +4 -0
  4. package/USAGE.md +89 -55
  5. package/dist/adoption/wire.d.ts +3 -3
  6. package/dist/approval-card.d.ts +32 -32
  7. package/dist/approval-leg-origin.d.ts +33 -0
  8. package/dist/approval-leg-origin.js +4 -0
  9. package/dist/auth-bridge.d.ts +9 -1
  10. package/dist/auth-bridge.js +4 -4
  11. package/dist/auth-keys.d.ts +10 -1
  12. package/dist/auth-keys.js +8 -0
  13. package/dist/boot/execution-env.js +10 -11
  14. package/dist/boot/execution-lane.d.ts +6 -4
  15. package/dist/boot/execution-lane.js +3 -1
  16. package/dist/boot/org-delivery.d.ts +3 -2
  17. package/dist/boot/resolve-spec.js +6 -4
  18. package/dist/boot/runner-deps.js +3 -1
  19. package/dist/boot/stage-01-config.d.ts +1 -0
  20. package/dist/boot/stage-01-config.js +4 -3
  21. package/dist/boot/stage-02-web-admin-pull.d.ts +1 -0
  22. package/dist/boot/stage-02-web-admin-pull.js +10 -2
  23. package/dist/boot/stage-03-store-backend.d.ts +1 -0
  24. package/dist/boot/stage-03-store-backend.js +2 -2
  25. package/dist/boot/stage-04-stores.d.ts +1 -0
  26. package/dist/boot/stage-05-execution-env.d.ts +1 -0
  27. package/dist/boot/stage-06-runners.d.ts +2 -1
  28. package/dist/boot/stage-07-capability-layer.d.ts +2 -1
  29. package/dist/boot/stage-08-reapers.d.ts +2 -1
  30. package/dist/boot/stage-09-leader.d.ts +2 -1
  31. package/dist/boot/stage-10-http-server.d.ts +2 -1
  32. package/dist/boot/stores.d.ts +3 -0
  33. package/dist/boot/stores.js +22 -19
  34. package/dist/capabilities/tool-defer.d.ts +1 -1
  35. package/dist/capability-facts.d.ts +5 -4
  36. package/dist/capability-facts.js +3 -14
  37. package/dist/card-settle-stored-scope.d.ts +56 -0
  38. package/dist/card-settle-stored-scope.js +27 -0
  39. package/dist/card-void.d.ts +7 -12
  40. package/dist/card-void.js +2 -7
  41. package/dist/config-catalog.d.ts +12 -0
  42. package/dist/config-catalog.js +36 -32
  43. package/dist/config-invariants.d.ts +5 -4
  44. package/dist/config-invariants.js +4 -4
  45. package/dist/config-types.d.ts +37 -5
  46. package/dist/config.d.ts +6 -5
  47. package/dist/config.js +35 -24
  48. package/dist/context-window-notice.d.ts +31 -0
  49. package/dist/context-window-notice.js +28 -0
  50. package/dist/deployment-shape.d.ts +20 -11
  51. package/dist/deployment-shape.js +1 -0
  52. package/dist/env-name-allowlist-knobs.d.ts +6 -5
  53. package/dist/env-name-allowlist-knobs.js +5 -0
  54. package/dist/execution-lane-caps.d.ts +20 -5
  55. package/dist/execution-lane-caps.js +18 -4
  56. package/dist/hooks/command-spawn.d.ts +7 -2
  57. package/dist/hooks/command-spawn.js +13 -6
  58. package/dist/hooks/hook-runner.d.ts +5 -0
  59. package/dist/hooks/hook-runner.js +11 -7
  60. package/dist/http/admission.js +3 -3
  61. package/dist/http/approval-sweeps.js +3 -2
  62. package/dist/http/claim-heartbeat.d.ts +3 -3
  63. package/dist/http/claim-heartbeat.js +3 -3
  64. package/dist/http/claimed-run.d.ts +16 -9
  65. package/dist/http/claimed-run.js +8 -0
  66. package/dist/http/dispatch.js +5 -3
  67. package/dist/http/durable-run-leg.d.ts +8 -1
  68. package/dist/http/durable-run-leg.js +30 -11
  69. package/dist/http/execution-context.d.ts +115 -14
  70. package/dist/http/execution-context.js +130 -40
  71. package/dist/http/owner-access.d.ts +3 -2
  72. package/dist/http/owner-access.js +2 -3
  73. package/dist/http/principal-gate.d.ts +18 -11
  74. package/dist/http/principal-gate.js +3 -1
  75. package/dist/http/resume-ctx-read.d.ts +14 -3
  76. package/dist/http/resume-ctx-read.js +8 -2
  77. package/dist/http/resume-legs.d.ts +35 -3
  78. package/dist/http/resume-legs.js +244 -92
  79. package/dist/http/route-ctx.d.ts +6 -0
  80. package/dist/http/routes/a2a-serve.js +2 -0
  81. package/dist/http/routes/approvals-assistant.js +3 -2
  82. package/dist/http/routes/capabilities.js +1 -1
  83. package/dist/http/routes/diagnostics.d.ts +3 -1
  84. package/dist/http/routes/diagnostics.js +30 -5
  85. package/dist/http/routes/principal-inbox.d.ts +3 -1
  86. package/dist/http/routes/principal-inbox.js +3 -2
  87. package/dist/http/routes/runs.js +10 -7
  88. package/dist/http/routes/session-sync.d.ts +24 -5
  89. package/dist/http/routes/session-sync.js +46 -34
  90. package/dist/http/routes/sessions-list.d.ts +1 -1
  91. package/dist/http/routes/tasks.js +127 -93
  92. package/dist/http/server.d.ts +4 -5
  93. package/dist/http/server.js +30 -5
  94. package/dist/http/submit-ctx.d.ts +1 -0
  95. package/dist/http/submit-ctx.js +2 -3
  96. package/dist/index.d.ts +3 -1
  97. package/dist/index.js +1 -0
  98. package/dist/leg-facts.d.ts +7 -2
  99. package/dist/leg-facts.js +1 -0
  100. package/dist/local-store-declaration.d.ts +54 -0
  101. package/dist/local-store-declaration.js +59 -0
  102. package/dist/mcp-assertion-header.d.ts +1 -1
  103. package/dist/mcp-assertion-header.js +1 -1
  104. package/dist/memory-project-layer.js +1 -1
  105. package/dist/memory-scope.d.ts +4 -4
  106. package/dist/memory-scope.js +4 -4
  107. package/dist/memory-sync-client.js +2 -2
  108. package/dist/model-context-window.d.ts +50 -11
  109. package/dist/model-context-window.js +20 -1
  110. package/dist/model-entry-refusal.d.ts +5 -4
  111. package/dist/model-entry-refusal.js +17 -3
  112. package/dist/model-reach-scope.d.ts +2 -1
  113. package/dist/observability/fail-open.d.ts +14 -2
  114. package/dist/observability/fail-open.js +14 -2
  115. package/dist/observability/metrics.js +1 -1
  116. package/dist/observability/run-terminal.js +1 -0
  117. package/dist/observability/secret-env-scrub.d.ts +1 -1
  118. package/dist/observability/secret-env-scrub.js +1 -1
  119. package/dist/org-delivery-gate.d.ts +31 -6
  120. package/dist/org-delivery-gate.js +7 -4
  121. package/dist/owner-faces.d.ts +3 -2
  122. package/dist/park-row-commit.d.ts +67 -0
  123. package/dist/park-row-commit.js +76 -0
  124. package/dist/parked-decide.d.ts +9 -7
  125. package/dist/parked-decide.js +25 -6
  126. package/dist/peer-directory.d.ts +1 -1
  127. package/dist/plugins/adoption-log-sql.d.ts +3 -3
  128. package/dist/plugins/adoption-log-sql.js +19 -19
  129. package/dist/plugins/approval-ask-store-sql.d.ts +1 -1
  130. package/dist/plugins/approval-ask-store-sql.js +3 -5
  131. package/dist/plugins/approval-exemption-skill-cutoff.d.ts +64 -0
  132. package/dist/plugins/approval-exemption-skill-cutoff.js +122 -0
  133. package/dist/plugins/approval-exemption-skill-m46.d.ts +0 -6
  134. package/dist/plugins/approval-exemption-skill-m46.js +6 -10
  135. package/dist/plugins/approval-exemption-store.d.ts +26 -1
  136. package/dist/plugins/approval-exemption-store.js +28 -19
  137. package/dist/plugins/ensure-schema-lock-policy.d.ts +36 -3
  138. package/dist/plugins/ensure-schema-lock-policy.js +75 -9
  139. package/dist/plugins/file-run-store.d.ts +22 -9
  140. package/dist/plugins/file-run-store.js +50 -22
  141. package/dist/plugins/local-checkpoint-store.d.ts +4 -0
  142. package/dist/plugins/local-checkpoint-store.js +2 -1
  143. package/dist/plugins/local-data-root-admission.d.ts +52 -0
  144. package/dist/plugins/local-data-root-admission.js +100 -0
  145. package/dist/plugins/local-engine-high-water.d.ts +28 -7
  146. package/dist/plugins/local-engine-high-water.js +27 -22
  147. package/dist/plugins/local-session-store.d.ts +134 -49
  148. package/dist/plugins/local-session-store.js +149 -108
  149. package/dist/plugins/memory-run-store.d.ts +14 -9
  150. package/dist/plugins/memory-run-store.js +47 -21
  151. package/dist/plugins/one-time-migration-marker.d.ts +19 -3
  152. package/dist/plugins/one-time-migration-marker.js +7 -1
  153. package/dist/plugins/one-time-migrations.d.ts +46 -20
  154. package/dist/plugins/one-time-migrations.js +102 -79
  155. package/dist/plugins/org-delivery-quota-store.d.ts +34 -11
  156. package/dist/plugins/org-delivery-quota-store.js +29 -14
  157. package/dist/plugins/permission-rule-store-sql.d.ts +1 -1
  158. package/dist/plugins/permission-rule-store-sql.js +5 -9
  159. package/dist/plugins/pg-pool.d.ts +1 -1
  160. package/dist/plugins/pg-pool.js +10 -12
  161. package/dist/plugins/remote-env-device.d.ts +3 -2
  162. package/dist/plugins/remote-env-e2b.d.ts +1 -7
  163. package/dist/plugins/remote-env-e2b.js +10 -7
  164. package/dist/plugins/remote-env-host.d.ts +50 -9
  165. package/dist/plugins/remote-env-host.js +77 -20
  166. package/dist/plugins/remote-env-k8s.js +4 -2
  167. package/dist/plugins/remote-env-local-docker.d.ts +6 -3
  168. package/dist/plugins/remote-env-local-docker.js +8 -23
  169. package/dist/plugins/run-store-sql.d.ts +26 -7
  170. package/dist/plugins/run-store-sql.js +44 -16
  171. package/dist/plugins/scenario-prefix-switch-m42.d.ts +3 -1
  172. package/dist/plugins/scenario-prefix-switch-m42.js +4 -3
  173. package/dist/plugins/schema-consistency.d.ts +89 -5
  174. package/dist/plugins/schema-consistency.js +176 -7
  175. package/dist/plugins/schema-drift-sentence.d.ts +6 -5
  176. package/dist/plugins/schema-drift-sentence.js +13 -5
  177. package/dist/plugins/session-placement.d.ts +5 -4
  178. package/dist/plugins/shell-sentinel-owner-m48.d.ts +22 -0
  179. package/dist/plugins/shell-sentinel-owner-m48.js +6 -0
  180. package/dist/plugins/stop-then-start-sentence.d.ts +16 -0
  181. package/dist/plugins/stop-then-start-sentence.js +7 -0
  182. package/dist/plugins/store-backend.d.ts +22 -8
  183. package/dist/plugins/store-backend.js +58 -24
  184. package/dist/plugins/store-contracts.d.ts +33 -3
  185. package/dist/plugins/store-contracts.js +16 -0
  186. package/dist/plugins/tidb-pool.d.ts +11 -6
  187. package/dist/plugins/tidb-pool.js +18 -12
  188. package/dist/plugins/workspace-custody.d.ts +44 -0
  189. package/dist/plugins/workspace-custody.js +47 -0
  190. package/dist/project-identity.d.ts +8 -7
  191. package/dist/project-identity.js +40 -17
  192. package/dist/project-layer-key.d.ts +13 -2
  193. package/dist/project-layer-key.js +9 -1
  194. package/dist/read-deny-floor-notice.d.ts +36 -0
  195. package/dist/read-deny-floor-notice.js +40 -0
  196. package/dist/{plain-string-record.d.ts → request-declaration-disposition.d.ts} +9 -23
  197. package/dist/request-declaration-disposition.js +39 -0
  198. package/dist/run-cancel-card.d.ts +28 -0
  199. package/dist/run-cancel-card.js +19 -0
  200. package/dist/run-cancel-context.d.ts +10 -0
  201. package/dist/run-cancel-context.js +1 -0
  202. package/dist/run-local.js +28 -13
  203. package/dist/runs.d.ts +31 -7
  204. package/dist/runs.js +34 -16
  205. package/dist/security.d.ts +2 -2
  206. package/dist/server-secret-env.d.ts +34 -20
  207. package/dist/server-secret-env.js +61 -18
  208. package/dist/session-agent.d.ts +3 -2
  209. package/dist/session-children-retire.d.ts +6 -3
  210. package/dist/session-children-retire.js +2 -5
  211. package/dist/session-workspace.d.ts +3 -3
  212. package/dist/store-admission.d.ts +33 -0
  213. package/dist/store-admission.js +49 -0
  214. package/dist/store-posture.d.ts +5 -0
  215. package/dist/store-posture.js +18 -1
  216. package/dist/stores-sql/checkpoint-store-sql.d.ts +1 -1
  217. package/dist/stores-sql/checkpoint-store-sql.js +3 -5
  218. package/dist/stores-sql/file-history-store-sql.d.ts +1 -1
  219. package/dist/stores-sql/file-history-store-sql.js +3 -5
  220. package/dist/stores-sql/tool-result-store-sql.d.ts +1 -1
  221. package/dist/stores-sql/tool-result-store-sql.js +4 -6
  222. package/dist/task-a2a.d.ts +3 -8
  223. package/dist/task-a2a.js +25 -27
  224. package/dist/task-cwd.d.ts +23 -7
  225. package/dist/task-cwd.js +3 -0
  226. package/dist/task-mcp.d.ts +3 -3
  227. package/dist/task-mcp.js +1 -1
  228. package/dist/task-settings.js +1 -0
  229. package/dist/terminal.d.ts +2 -0
  230. package/dist/terminal.js +21 -6
  231. package/dist/tool-approval.d.ts +44 -36
  232. package/dist/tool-approval.js +66 -47
  233. package/dist/trace/engine-notice-wire.d.ts +24 -21
  234. package/dist/trace/engine-notice-wire.js +7 -3
  235. package/dist/trace/ledger-sink.d.ts +3 -1
  236. package/dist/trace/project.d.ts +4 -2
  237. package/dist/trace/project.js +8 -7
  238. package/dist/trace/redact.d.ts +12 -0
  239. package/dist/trace/redact.js +5 -1
  240. package/dist/trace/self-minted-wire.d.ts +131 -52
  241. package/dist/trace/self-minted-wire.js +71 -39
  242. package/dist/web-admin-client/apply-effective.js +22 -9
  243. package/docs/error-text-lexicon.json +317 -0
  244. package/package.json +3 -1
  245. package/scripts/embed-minimal.mjs +183 -0
  246. package/wire-changes/server@7.149.0.json +372 -0
  247. package/dist/plain-string-record.js +0 -70
@@ -19,6 +19,7 @@ import { buildCapturedOutput, createCutError } from "../exec-cut.js";
19
19
  import { SANDBOX_IPV6_DEFAULT, sandboxEgressGaps, sandboxPodSysctls } from "../sandbox-egress-policy.js";
20
20
  import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
21
21
  import { capabilitiesFor } from "../execution-lane-caps.js";
22
+ import { DEPLOYMENT_SHAPE_PREDICATES } from "../deployment-shape.js";
22
23
  import { ADAPTER_START, BOOTSTRAP_START, buildFloorPodSeat, createSandboxReadFloor, FLOOR_K8S_CONFIGMAP_KEY, engineStart, floorArgv, } from "./sandbox-read-floor.js";
23
24
  import { fileErrorFromExec, classifyFsStderr } from "./remote-env-file-error.js";
24
25
  import { createPosixShellFs } from "./posix-shell-fs.js";
@@ -283,12 +284,13 @@ export class RemoteK8sExecutionEnv {
283
284
  this.floor = this.deps.readFloor ?? createSandboxReadFloor({ lane: PROVIDER, knob: config.sandboxReadFloor, ...(config.logger !== undefined ? { logger: config.logger } : {}) });
284
285
  this.capabilities = capabilitiesFor({ remoteExec: { provider: PROVIDER, s3Snapshot: this.cfg.s3Snapshot }, sandboxReadFloor: config.sandboxReadFloor });
285
286
  const bgMax = Math.max(1, Math.min(K8S_BG_MAX_TIMEOUT_SEC, Math.floor(this.cfg.timeoutMs / 1000)));
287
+ const backgroundShellOn = DEPLOYMENT_SHAPE_PREDICATES.backgroundShell.holds({ remoteExec: { provider: PROVIDER } });
286
288
  this.backgroundCapabilities = {
287
- supported: true,
289
+ supported: backgroundShellOn,
288
290
  maxConcurrent: K8S_BG_MAX_CONCURRENT,
289
291
  defaultBgTimeoutSec: Math.min(K8S_BG_DEFAULT_TIMEOUT_SEC, bgMax),
290
292
  maxBgTimeoutSec: bgMax,
291
- supportsDetach: true,
293
+ supportsDetach: backgroundShellOn,
292
294
  };
293
295
  }
294
296
  get sandboxLeaseRef() {
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { type LaneHomeConfig } from "../lane-home-probe.js";
3
3
  import { type LaneLogger } from "./remote-shell.js";
4
+ import { type CustodyMember } from "./workspace-custody.js";
4
5
  import { FileError, ExecutionError, RemoteExecutionError, type ExecutionEnv, type RemoteExecutionEnv, type WorkspaceHandle, type FileInfo, type Result, type SymlinkChain, type OutputChunk, type ExecStreamOptions, type RemoteConnectConfig, type SnapshotId, type SessionToken, type VmLifecycleOptions, type ExecutionEnvFactory, type ExecutionEnvCapabilities } from "@sema-agent/core";
5
6
  import { type SandboxReadFloorKnob } from "../execution-lane-caps.js";
6
7
  import { type SandboxReadFloor } from "./sandbox-read-floor.js";
@@ -105,7 +106,7 @@ export interface LocalDockerEnvDeps {
105
106
  /** 这只环境手里的读地板模块(缺席 ⇒ `createSandboxReadFloor` 按旋钮选;钉注入守地板的模块)。能力块的 `keepsReadFloor` 读它的状态。 */
106
107
  readFloor?: SandboxReadFloor;
107
108
  }
108
- export declare class RemoteLocalDockerExecutionEnv implements RemoteExecutionEnv {
109
+ export declare class RemoteLocalDockerExecutionEnv implements CustodyMember<LocalDockerLeftover>, RemoteExecutionEnv {
109
110
  /** 能力块的唯一作者 = `capabilitiesFor`(`execution-lane-caps.ts`;逐键表态与理由在那里,本类零能力字面量)。读地板旋钮是它的输入,{@link floor} 由同一只判据、同一个旋钮值选出。 */
110
111
  readonly capabilities: ExecutionEnvCapabilities;
111
112
  /** 读拒表字节地板模块:容器里每一次 `docker exec` 起动都先经它的 `wrap`({@link startArgv},唯一接缝)。 */
@@ -155,8 +156,10 @@ export declare class RemoteLocalDockerExecutionEnv implements RemoteExecutionEnv
155
156
  get leftover(): LocalDockerLeftover | undefined;
156
157
  /** 收场的进度:活着 ⇒ 缺席;{@link destroy} 开始 ⇒ 那一次收场(容器删除证实或放弃之后落定)。工厂读它:活着就交回、收场中就等它落定。 */
157
158
  get releasing(): Promise<void> | undefined;
158
- /** 收场已落定(工厂从同身份表里清掉它)。 */
159
+ /** 收场已落定。 */
159
160
  get released(): boolean;
161
+ /** 收场落定、不欠删除 ⇒ 出托管表(欠着的由同一身份的下一只拿走)。容器名由身份派生,一只身份同一时刻只有一只没收场的环境 ⇒ 它收场即删,不存在第二个持有者。 */
162
+ get settled(): boolean;
160
163
  private resolve;
161
164
  /** 收据句柄。还没连上(懒环境:续跑腿的引擎在恢复相**之前**先取它比车道)⇒ 未连接形 `{ sandboxId: "(unconnected)", provider, mountPath }`(与 k8s / e2b 车道同形),
162
165
  * 不抛 —— 抛了,续跑腿在恢复相就失败。 */
@@ -393,7 +396,7 @@ export declare function localDockerIdentityOfName(workerLabel: string, name: str
393
396
  * owns the lifetime and calls `destroy()` (= `docker rm -f`) on task end.
394
397
  *
395
398
  * 身份 = `ctx.taskId ?? ctx.sessionId`(与引擎交给工具的宿主任务号同一读法:顶层 run = run 号,委派子代 = 它自己的会话号 —— 子代从不带 `taskId`)。
396
- * 一只身份一只工作区:本工厂对同一身份交回**那一只**还没销毁的环境(停驻腿的环境引擎还拿着;续跑腿拿到的就是它 —— 引擎 park-only 契约要的「重连到同一工作区」,
399
+ * 一只身份一只工作区(工作区托管表 `workspace-custody.ts`,与 host 车道同一只):本工厂对同一身份交回**那一只**还没销毁的环境(停驻腿的环境引擎还拿着;续跑腿拿到的就是它 —— 引擎 park-only 契约要的「重连到同一工作区」,
397
400
  * 在同一进程里就是同一只环境,读地板的中继也只有一只);上一只正在收场 ⇒ 等它的删除落定再铸;没有 ⇒ 铸一只(它连接时在跑的同名容器就附着 —— 上一只进程起的)。
398
401
  * 收场落定的从表里清掉。
399
402
  * 无主容器:停驻中取消 / 停驻过期(本服务在店层结清,不经引擎的仲裁器,停驻腿的环境没有销毁者)与重起之后才结清的停驻,容器一直在跑 ——
@@ -4,6 +4,7 @@ import { hostSubprocessEnv } from "../server-secret-env.js";
4
4
  import { LANE_HOME_PROBE_COMMAND, LaneHomeSeat } from "../lane-home-probe.js";
5
5
  import { createHash, randomBytes } from "node:crypto";
6
6
  import { shellQuote, armPipeDestroyGrace, appendFileStaged, readyUpload, writeFileStaged } from "./remote-shell.js";
7
+ import { createWorkspaceCustody } from "./workspace-custody.js";
7
8
  import { signalNumber } from "./host-platform.js";
8
9
  import { buildLivenessCut, commandBoundsOf } from "../exec-bounds.js";
9
10
  import { buildCapturedOutput, createCutError } from "../exec-cut.js";
@@ -84,6 +85,9 @@ export class RemoteLocalDockerExecutionEnv {
84
85
  get released() {
85
86
  return this.teardownSettled;
86
87
  }
88
+ get settled() {
89
+ return this.teardownSettled && this.leftover === undefined;
90
+ }
87
91
  resolve(p) {
88
92
  return path.posix.isAbsolute(p) ? path.posix.normalize(p) : path.posix.normalize(path.posix.join(this.cwd, p));
89
93
  }
@@ -974,30 +978,11 @@ function errMsg(e) {
974
978
  }
975
979
  export function localDockerExecutionEnvFactory(config, deps = {}) {
976
980
  const { workspaceFillOf, ...base } = config;
977
- const live = new Map();
978
- const envOf = async (identity, ctx) => {
979
- sweep();
980
- let leftover;
981
- for (let held = live.get(identity); held !== undefined; held = live.get(identity)) {
982
- const releasing = held.releasing;
983
- if (releasing === undefined)
984
- return held;
985
- await releasing;
986
- if (live.get(identity) === held) {
987
- leftover = held.leftover;
988
- live.delete(identity);
989
- }
990
- }
981
+ const custody = createWorkspaceCustody();
982
+ const envOf = (identity, ctx) => custody.envOf(identity, (leftover) => {
991
983
  const workspaceFill = ctx !== undefined ? workspaceFillOf?.(ctx) : undefined;
992
- const env = new RemoteLocalDockerExecutionEnv({ ...base, id: identity, ...(workspaceFill !== undefined ? { workspaceFill } : {}), ...(leftover !== undefined ? { leftover } : {}) }, deps);
993
- live.set(identity, env);
994
- return env;
995
- };
996
- const sweep = () => {
997
- for (const [identity, env] of live)
998
- if (env.released && env.leftover === undefined)
999
- live.delete(identity);
1000
- };
984
+ return new RemoteLocalDockerExecutionEnv({ ...base, id: identity, ...(workspaceFill !== undefined ? { workspaceFill } : {}), ...(leftover !== undefined ? { leftover } : {}) }, deps);
985
+ });
1001
986
  let lister;
1002
987
  const reapPort = {
1003
988
  label: base.workerLabel,
@@ -170,8 +170,16 @@ export declare class SqlRunStore {
170
170
  * 同一只入口、同列同序列化),不是引擎交出的原件:`paused.token` / `failed.nestedPause.token` 两座在落列前摘掉,
171
171
  * 所以 poll 这一行的调用方(`GET /v1/runs/:id → result`)拿到的是「停在哪扇门上」而拿不到 resume 能力。
172
172
  * `result = null` = 这条腿没观察到 park 结果(断连竞态 / 重开 re-park),列写空 —— 与修前行形同。
173
+ *
174
+ * 🔴 **停驻写的 CAS = 「行仍归 `legId` 这条腿 ∧ 仍在跑(或已停在本词上)∧ 没挂取消请求」**(内存 / 文件孪生同一句):
175
+ * · 持有腿(`leg_id`,认领铸、交回认领者):一笔丢了回执的停驻写之后卡被决、下一条腿认领了这条行 ⇒ 前一条腿的再提交落不到接手腿的行上;
176
+ * · `running`:失联收割已把行落成 `failed` 并删掉 `task_active` 认领时,这一写若把它翻回停驻,会话就没了锁、卡还待决 ⇒ 收割的判决立住;
177
+ * · 取消旗:取消动词对在跑行只置旗(`requestCancel`,CAS `status = 'running'`);停驻写认这面旗 ⇒ 同一行上两只写各 CAS 对方的事实,恰一个赢
178
+ * (旗先 ⇒ 停驻不落,提交点以取消收场;停驻先 ⇒ 置旗落空,取消路由按停驻结算)。
179
+ * 交回 CAS 的答:命中行数 > 0 ⇔ 这一写之后行停在这个词上(MySQL 协议缺省带 FOUND_ROWS ⇒ 计的是匹配行,同值重写照算;PG 的 rowCount 同义);
180
+ * 0 ⇔ 上面三件有一件不成立(或行不在)—— 是哪一件由提交点读行答(`park-row-commit.ts`)。
173
181
  */
174
- setSuspended(taskId: string, result: PersistedTaskResult | null): Promise<void>;
182
+ setSuspended(taskId: string, result: PersistedTaskResult | null, legId: string): Promise<boolean>;
175
183
  /**
176
184
  * [ref] D-B: PARK a run that paused on a REVIEW gate — a `needs_review` result (a dry-run diff review OR a
177
185
  * pre-action `plan_review`; core assemble-result slot 8.6). Like {@link setSuspended} this is a NON-terminal park
@@ -179,8 +187,8 @@ export declare class SqlRunStore {
179
187
  * resume path (`getActiveTaskId` → `markResuming`) can find + claim the row. core treats a review pause "like a
180
188
  * suspend, an INTENTIONAL durable pause" ([ref] §2.5) — the service MUST park it the same way, else the run is
181
189
  * terminalized + its lock released and the plan_review/dry_run_review resume can never run (it gets
182
- * taskId=undefined and drives the model unprotected on an unlocked session). CAS on `running`/`needs_review`
183
- * (reaper-revert guard, mirrors setSuspended). The park→resume→claim invariant is pinned on BOTH REAL engines by
190
+ * taskId=undefined and drives the model unprotected on an unlocked session). CAS = {@link setSuspended}'s three conditions
191
+ * (holding leg · `running`/`needs_review` · no cancel request). The park→resume→claim invariant is pinned on BOTH REAL engines by
184
192
  * `test/run-store-reaper-integration.test.ts` (`inv#1` row ownership across park→resume→handover, `inv#2` two
185
193
  * replicas racing one session ⇒ exactly one claim wins, `inv#3`/`inv#3'` stale-claim takeover vs fresh/parked
186
194
  * claims), closing the former required-before-GA gap. Keep it that way: the P2 lease-SQL bug and this
@@ -189,14 +197,15 @@ export declare class SqlRunStore {
189
197
  * mocked run-store unit tests stayed green. A mock cannot observe a unique-key claim race, a rolled-back loser,
190
198
  * or a multi-table reaper DELETE.
191
199
  */
192
- setNeedsReview(taskId: string, result: PersistedTaskResult | null): Promise<void>;
200
+ setNeedsReview(taskId: string, result: PersistedTaskResult | null, legId: string): Promise<boolean>;
193
201
  /**
194
202
  * Durable F4 resume: a suspended run is being actively resumed. Flip it back to `running` (CAS — only if
195
203
  * still suspended) and refresh liveness, so the in-flight resume is protected by the running-run heartbeat
196
204
  * and is NOT killed by `reapSuspended` (which targets `status='suspended'` past the approval TTL). Without
197
205
  * this the row sits at `suspended`/updated_at=suspend-time for the WHOLE resume execution, so a resume that
198
- * crosses the TTL is marked `failed` mid-run and its session lock (`task_active`) is released. Returns true
199
- * if it claimed the flip (false ⇒ the reaper already expired it, or it isn't suspended).
206
+ * crosses the TTL is marked `failed` mid-run and its session lock (`task_active`) is released. Returns the
207
+ * HOLDING LEG this claim minted ({@link RunRecord.legId}; the resumed leg's park writes carry it) when it claimed
208
+ * the flip, `undefined` otherwise (the reaper already expired it, or it isn't suspended).
200
209
  *
201
210
  * [ref](codex R1 [medium],红先双库证):认领 = **接管 `instance_id`**。此前该列只在 createRun 铸、此处不动,
202
211
  * 于是 A 创建、C 认领 resume 的行永远记 A —— 而 steer/interrupt 的「他副本」臂与 sendHandleMiss 都拿这列当
@@ -204,7 +213,7 @@ export declare class SqlRunStore {
204
213
  * 语义自此成文:`instance_id` = **当前持有副本**(createRun 铸、markResuming 认领接管);CAS 输不改列
205
214
  * (一个输掉的认领不许覆盖赢家的持有记录——同一条条件 UPDATE 天然保证)。
206
215
  */
207
- markResuming(taskId: string, instanceId: string): Promise<boolean>;
216
+ markResuming(taskId: string, instanceId: string): Promise<string | undefined>;
208
217
  /**
209
218
  * 认领当写栅栏用(进程内两孪生的契约 = `store-contracts.ts` 的 `InProcessClaimFence`;本孪生是它的事务形):一个事务 ——
210
219
  * ① 锁读这条 run 行且它在跑(`task_run … FOR UPDATE`);② 锁读会话的认领行且它是这条 run 的(`task_active … FOR UPDATE`);
@@ -238,6 +247,16 @@ export declare class SqlRunStore {
238
247
  * reject): the error answer invites a retry, and a retry with the same key must run, not replay the refusal. The row stays (it
239
248
  * is the refusal's record); only the binding is freed. */
240
249
  releaseIdemKey(taskId: string): Promise<void>;
250
+ /** 放一条行上提交幂等键绑定的那一句(`releaseIdemKey` 与 `withdrawMintedRun` 的非在跑臂同读)。 */
251
+ private releaseIdemKeySql;
252
+ /**
253
+ * 撤回本请求铸的这一条 run(认领没放行,行从没交给引擎)—— 回到认领之前「没有这一行」的样子。唯一调用口 = 撤回表 `withdrawClaim` 的 `run` 臂
254
+ * (经 `http/claimed-run.ts` 的 `withdrawClaimedRun`)。本请求的行 = `task_id` 与建行交回的持有腿 `leg_id` 这一对(与停驻写的 CAS 同一只身份读法):
255
+ * 行上的持有腿不是 `legId`(没有这一行 / 别的腿持着 / 列之前的行)⇒ 一个字节不动。一个事务、取锁序 `task_run` → `task_active`(与终局 / 收割 / 栅栏同序):行仍在跑(`status = 'running'`:
256
+ * 没被收割、没终局)⇒ 删它的事件、它的认领、行本身(幂等键与指纹在行上,随行去);已终局 ⇒ 行零删 —— 死亡判决或别的终局写先到,那一份就是这条行的结局 ——
257
+ * 只放本请求在这一行上绑的提交幂等键;停驻 ⇒ 一个字节不动(撤回只对本请求铸的行,而它从没停驻过;口径与另两孪生同)(撤回之后本请求答的是「原样再发」,同键再发要真跑,不重放那一份结局)。交回删没删。
258
+ */
259
+ withdrawMintedRun(taskId: string, legId: string): Promise<boolean>;
241
260
  /** The body fingerprint this row's binding of `idem.key` holds — binding `idem.bodyFingerprint` first when it holds none (a key
242
261
  * bound before the column existed): first arrival binds, everyone after reads. The fingerprint belongs to the KEY that bound
243
262
  * the row, so a row this key is not bound to (hit by its `task_id` under another key, or made without a key) is never written
@@ -5,7 +5,7 @@ import { pgSanitizeText } from "./pg-safe-json.js";
5
5
  import { toIso as iso } from "./sql-row-helpers.js";
6
6
  import { recordFailOpen } from "../observability/fail-open.js";
7
7
  import { createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText, readJsonObjectColumn, readNonNullJsonValueColumn } from "./sql-json-column.js";
8
- import { RUN_HOLDER_DEATH_CODES, RUN_STALLED_ERROR, RUN_STALL_DEFAULT_WIRING, RUN_STALL_VERDICT_UNLANDED_TAG, isParkedRunStatus, runLivenessOf, settledLegObservationOf } from "./store-contracts.js";
8
+ import { RUN_HOLDER_DEATH_CODES, RUN_STALLED_ERROR, RUN_STALL_DEFAULT_WIRING, RUN_STALL_VERDICT_UNLANDED_TAG, isParkedRunStatus, isTerminalRunStatus, mintRunLegId, runLivenessOf, settledLegObservationOf } from "./store-contracts.js";
9
9
  import { notifyRunTerminal, runTerminalLogLevel } from "../observability/run-terminal.js";
10
10
  import { mysqlDriver, pgDriver, dialectJsonEncoder, SQL_DB_NOW_MS, sqlDbNowMs } from "./sql-driver.js";
11
11
  import { isDupKeyError } from "./sql-errors.js";
@@ -14,7 +14,7 @@ import { readRunIdentityCells, runIdentityCells } from "../identity-source.js";
14
14
  import { decideSpaceCommitAbandon, decideSpaceCommitClaim, fenceSpaceCommitAtTerminal, spaceCommitStateOf } from "../workspace-space-commit.js";
15
15
  function runRowCols(dialect) {
16
16
  return (`task_id, session_id, owner, status, ${jsonColumnText(dialect, "result")}, error, error_code, job_id, source, ` +
17
- "identity_source, identity_scope, identity_principal_inc, instance_id, cancel_requested, objective_preview, created_at, updated_at");
17
+ "identity_source, identity_scope, identity_principal_inc, instance_id, leg_id, cancel_requested, objective_preview, created_at, updated_at");
18
18
  }
19
19
  const readPersistedTaskResultBody = createObjectColumnReader();
20
20
  function decodeResultColumn(v, taskId) {
@@ -101,18 +101,19 @@ export class SqlRunStore {
101
101
  }
102
102
  }
103
103
  async createRun(taskId, sessionId, owner, instanceId, meta = {}) {
104
+ const legId = mintRunLegId();
104
105
  const conn = await this.db.connect();
105
106
  try {
106
- const first = await this.createRunOn(conn, taskId, sessionId, owner, instanceId, meta);
107
+ const first = await this.createRunOn(conn, taskId, sessionId, owner, instanceId, legId, meta);
107
108
  if (first.activeTaskId === undefined || !(await this.settleDueHolderOn(conn, first.activeTaskId)))
108
109
  return first;
109
- return await this.createRunOn(conn, taskId, sessionId, owner, instanceId, meta);
110
+ return await this.createRunOn(conn, taskId, sessionId, owner, instanceId, legId, meta);
110
111
  }
111
112
  finally {
112
113
  conn.release();
113
114
  }
114
115
  }
115
- async createRunOn(conn, taskId, sessionId, owner, instanceId, meta) {
116
+ async createRunOn(conn, taskId, sessionId, owner, instanceId, legId, meta) {
116
117
  const idemKey = meta.idem?.key ?? null;
117
118
  let stage = "claim";
118
119
  try {
@@ -127,13 +128,14 @@ export class SqlRunStore {
127
128
  await conn.query(this.q("INSERT INTO task_active (session_id, task_id, since) VALUES (?,?,?)", "INSERT INTO task_active (session_id, task_id, since) VALUES ($1,$2,$3)"), [sessionId, taskId, now]);
128
129
  stage = "row";
129
130
  const cells = runIdentityCells(meta.identity ?? null);
130
- await conn.query(this.q("INSERT INTO task_run (task_id, session_id, owner, status, instance_id, job_id, source, identity_source, identity_scope, identity_principal_inc, objective_preview, idem_key, idem_body_fingerprint, created_at, updated_at) " +
131
- "VALUES (?,?,?,'running',?,?,?,?,?,?,?,?,?,?,?)", "INSERT INTO task_run (task_id, session_id, owner, status, instance_id, job_id, source, identity_source, identity_scope, identity_principal_inc, objective_preview, idem_key, idem_body_fingerprint, created_at, updated_at) " +
132
- "VALUES ($1,$2,$3,'running',$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)"), [
131
+ await conn.query(this.q("INSERT INTO task_run (task_id, session_id, owner, status, instance_id, leg_id, job_id, source, identity_source, identity_scope, identity_principal_inc, objective_preview, idem_key, idem_body_fingerprint, created_at, updated_at) " +
132
+ "VALUES (?,?,?,'running',?,?,?,?,?,?,?,?,?,?,?,?)", "INSERT INTO task_run (task_id, session_id, owner, status, instance_id, leg_id, job_id, source, identity_source, identity_scope, identity_principal_inc, objective_preview, idem_key, idem_body_fingerprint, created_at, updated_at) " +
133
+ "VALUES ($1,$2,$3,'running',$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)"), [
133
134
  taskId,
134
135
  sessionId,
135
136
  owner,
136
137
  instanceId,
138
+ legId,
137
139
  meta.jobId ?? null,
138
140
  meta.source ?? null,
139
141
  cells.identitySource,
@@ -150,7 +152,7 @@ export class SqlRunStore {
150
152
  now,
151
153
  ]);
152
154
  await conn.commit();
153
- return { ok: true };
155
+ return { ok: true, legId };
154
156
  }
155
157
  catch (err) {
156
158
  if (!this.isDupKey(err))
@@ -363,17 +365,20 @@ export class SqlRunStore {
363
365
  }
364
366
  }
365
367
  }
366
- async setSuspended(taskId, result) {
368
+ async setSuspended(taskId, result, legId) {
367
369
  const sealed = sealRunRowResult(result);
368
- await this.db.query(this.q("UPDATE task_run SET status = 'suspended', result = ?, error_code = ?, updated_at = ? WHERE task_id = ? AND status IN ('running','suspended')", "UPDATE task_run SET status = 'suspended', result = $1::jsonb, error_code = $2, updated_at = $3 WHERE task_id = $4 AND status IN ('running','suspended')"), [sealed == null ? null : this.json(sealed), runRowErrorCodeOf(sealed), new Date(), taskId]);
370
+ const { affected } = await this.db.query(this.q("UPDATE task_run SET status = 'suspended', result = ?, error_code = ?, updated_at = ? WHERE task_id = ? AND leg_id = ? AND status IN ('running','suspended') AND cancel_requested = 0", "UPDATE task_run SET status = 'suspended', result = $1::jsonb, error_code = $2, updated_at = $3 WHERE task_id = $4 AND leg_id = $5 AND status IN ('running','suspended') AND cancel_requested = 0"), [sealed == null ? null : this.json(sealed), runRowErrorCodeOf(sealed), new Date(), taskId, legId]);
371
+ return affected > 0;
369
372
  }
370
- async setNeedsReview(taskId, result) {
373
+ async setNeedsReview(taskId, result, legId) {
371
374
  const sealed = sealRunRowResult(result);
372
- await this.db.query(this.q("UPDATE task_run SET status = 'needs_review', result = ?, error_code = ?, updated_at = ? WHERE task_id = ? AND status IN ('running','needs_review')", "UPDATE task_run SET status = 'needs_review', result = $1::jsonb, error_code = $2, updated_at = $3 WHERE task_id = $4 AND status IN ('running','needs_review')"), [sealed == null ? null : this.json(sealed), runRowErrorCodeOf(sealed), new Date(), taskId]);
375
+ const { affected } = await this.db.query(this.q("UPDATE task_run SET status = 'needs_review', result = ?, error_code = ?, updated_at = ? WHERE task_id = ? AND leg_id = ? AND status IN ('running','needs_review') AND cancel_requested = 0", "UPDATE task_run SET status = 'needs_review', result = $1::jsonb, error_code = $2, updated_at = $3 WHERE task_id = $4 AND leg_id = $5 AND status IN ('running','needs_review') AND cancel_requested = 0"), [sealed == null ? null : this.json(sealed), runRowErrorCodeOf(sealed), new Date(), taskId, legId]);
376
+ return affected > 0;
373
377
  }
374
378
  async markResuming(taskId, instanceId) {
375
- const { affected } = await this.db.query(this.q("UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0, result = NULL, error_code = NULL, instance_id = ?, updated_at = ? WHERE task_id = ? AND status IN ('suspended','needs_review')", "UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0, result = NULL, error_code = NULL, instance_id = $1, updated_at = $2 WHERE task_id = $3 AND status IN ('suspended','needs_review')"), [instanceId, new Date(), taskId]);
376
- return affected === 1;
379
+ const legId = mintRunLegId();
380
+ const { affected } = await this.db.query(this.q("UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0, result = NULL, error_code = NULL, instance_id = ?, leg_id = ?, updated_at = ? WHERE task_id = ? AND status IN ('suspended','needs_review')", "UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0, result = NULL, error_code = NULL, instance_id = $1, leg_id = $2, updated_at = $3 WHERE task_id = $4 AND status IN ('suspended','needs_review')"), [instanceId, legId, new Date(), taskId]);
381
+ return affected === 1 ? legId : undefined;
377
382
  }
378
383
  async whileHoldingClaim(sessionId, taskId, write) {
379
384
  let fenced = { held: false };
@@ -409,6 +414,7 @@ export class SqlRunStore {
409
414
  source: r.source ?? null,
410
415
  identity: readRunIdentityCells(r.identity_source, r.identity_scope, r.identity_principal_inc),
411
416
  instanceId: r.instance_id ?? null,
417
+ legId: r.leg_id ?? null,
412
418
  cancelRequested: Boolean(r.cancel_requested),
413
419
  objectivePreview: r.objective_preview ?? null,
414
420
  createdAt: iso(r.created_at),
@@ -433,7 +439,29 @@ export class SqlRunStore {
433
439
  return rows[0] ? this.rowToRun(rows[0]) : undefined;
434
440
  }
435
441
  async releaseIdemKey(taskId) {
436
- await this.db.query(this.q("UPDATE task_run SET idem_key = NULL, idem_body_fingerprint = NULL WHERE task_id = ?", "UPDATE task_run SET idem_key = NULL, idem_body_fingerprint = NULL WHERE task_id = $1"), [taskId]);
442
+ await this.db.query(this.releaseIdemKeySql(), [taskId]);
443
+ }
444
+ releaseIdemKeySql() {
445
+ return this.q("UPDATE task_run SET idem_key = NULL, idem_body_fingerprint = NULL WHERE task_id = ?", "UPDATE task_run SET idem_key = NULL, idem_body_fingerprint = NULL WHERE task_id = $1");
446
+ }
447
+ async withdrawMintedRun(taskId, legId) {
448
+ let withdrawn = false;
449
+ await this.tx(async (conn) => {
450
+ const { rows } = await conn.query(this.q("SELECT status FROM task_run WHERE task_id = ? AND leg_id = ? FOR UPDATE", "SELECT status FROM task_run WHERE task_id = $1 AND leg_id = $2 FOR UPDATE"), [taskId, legId]);
451
+ if (rows.length === 0)
452
+ return;
453
+ const status = rows[0].status;
454
+ if (status !== "running") {
455
+ if (isTerminalRunStatus(status))
456
+ await conn.query(this.releaseIdemKeySql(), [taskId]);
457
+ return;
458
+ }
459
+ await conn.query(this.q("DELETE FROM task_event WHERE task_id = ?", "DELETE FROM task_event WHERE task_id = $1"), [taskId]);
460
+ await conn.query(this.q("DELETE FROM task_active WHERE task_id = ?", "DELETE FROM task_active WHERE task_id = $1"), [taskId]);
461
+ await conn.query(this.q("DELETE FROM task_run WHERE task_id = ?", "DELETE FROM task_run WHERE task_id = $1"), [taskId]);
462
+ withdrawn = true;
463
+ });
464
+ return withdrawn;
437
465
  }
438
466
  async bindIdemBodyFingerprint(taskId, idem) {
439
467
  await this.db.query(this.q("UPDATE task_run SET idem_body_fingerprint = ? WHERE task_id = ? AND idem_key = ? AND idem_body_fingerprint IS NULL", "UPDATE task_run SET idem_body_fingerprint = $1 WHERE task_id = $2 AND idem_key = $3 AND idem_body_fingerprint IS NULL"), [idem.bodyFingerprint, taskId, idem.key]);
@@ -37,7 +37,9 @@ export declare class ScenarioPrefixSwitchRefusedError extends StoreBootRefusalEr
37
37
  readonly hits: readonly ScenarioPrefixHit[];
38
38
  readonly live?: ScenarioPrefixLiveRuns | undefined;
39
39
  readonly code = "config.scenario_prefix_switch_refused";
40
- constructor(reason: ScenarioPrefixRefusalReason, hits: readonly ScenarioPrefixHit[], live?: ScenarioPrefixLiveRuns | undefined);
40
+ constructor(reason: ScenarioPrefixRefusalReason, hits: readonly ScenarioPrefixHit[], live?: ScenarioPrefixLiveRuns | undefined,
41
+ /** 库此刻还差的列(SQL 腿经 `describeUpgradeDriftTail` 读到的;列齐 / local 腿 ⇒ 空串):一次起服读全。 */
42
+ tail?: string);
41
43
  private static sentence;
42
44
  }
43
45
  /** local 腿的一处来源:店名 + 它的计数口(以保留前缀开头的属主行数)。 */
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { SCENARIO_SUBJECT_PREFIX } from "../owner-brands.js";
3
3
  import { atomicWriteFileSync } from "./owner-only-file-mode.js";
4
4
  import { StoreBootRefusalError } from "./store-contracts.js";
5
+ import { stopThenStartAction } from "./stop-then-start-sentence.js";
5
6
  export const SCENARIO_PREFIX_SWITCH_REFUSED_CODE = "config.scenario_prefix_switch_refused";
6
7
  export const SCENARIO_PREFIX_MARKER_ID = "M42";
7
8
  export const LOCAL_MARKER_FILE = "one-time-migration-M42.json";
@@ -12,8 +13,8 @@ export class ScenarioPrefixSwitchRefusedError extends StoreBootRefusalError {
12
13
  hits;
13
14
  live;
14
15
  code = SCENARIO_PREFIX_SWITCH_REFUSED_CODE;
15
- constructor(reason, hits, live) {
16
- super(ScenarioPrefixSwitchRefusedError.sentence(reason, hits, live));
16
+ constructor(reason, hits, live, tail = "") {
17
+ super(ScenarioPrefixSwitchRefusedError.sentence(reason, hits, live) + tail);
17
18
  this.reason = reason;
18
19
  this.hits = hits;
19
20
  this.live = live;
@@ -30,7 +31,7 @@ export class ScenarioPrefixSwitchRefusedError extends StoreBootRefusalError {
30
31
  case "live_runs":
31
32
  return (`${SCENARIO_PREFIX_SWITCH_REFUSED_CODE}: no stored identity starts with the reserved prefix "${SCENARIO_SUBJECT_PREFIX}", but ${live?.liveRuns ?? 0} run(s) heartbeated on this database ` +
32
33
  `within the last ${live?.windowSec ?? 0}s — a replica of an earlier version may still be serving, and it accepts principals with that prefix, so a clean check now would not stay clean. ` +
33
- `Nothing was changed and no marker was written. Stop every replica of the earlier version, then start this version after ${live?.after ?? "the heartbeat window"} (this check is stop-then-start, not rolling).`);
34
+ `Nothing was changed and no marker was written. ${stopThenStartAction(live?.after)}`);
34
35
  default: {
35
36
  const unhandled = reason;
36
37
  throw new Error(`scenario-prefix-switch: unknown refusal reason ${String(unhandled)}`);
@@ -36,6 +36,12 @@
36
36
  * · `key` —— 主键成员:给存量行加主键列等于改它们的身份,**不出语句**,只一句话(出路走「部署匹配的构建」)。
37
37
  * 反方向(构建可空、库里非空)不判:现网库里可能有,判了会让数量不明的库起不来。
38
38
  *
39
+ * ── 位置:建表之后、一次性迁移之前;比的是「待跑迁移跑完之后」的形 ─────────────────────────────────────
40
+ * 起服的每一只拒启判都先于第一笔一次性迁移写(拒启的那一次起服对存量一笔没改写;迁移读数只出现在起得来的那一次)。本探针因此在迁移段**之前**跑,
41
+ * 而库上可能还有本构建带着的迁移待跑(它们删 / 改名 / 补 / 重建的列):登记表每一行用 `shapeChanges` 声明它对表形做的事,{@link projectPendingShape}
42
+ * 是唯一一只投影 —— 先把库形投影成迁移之后的样子再对账。迁移要动的列不算不符;别的缺列 / 多列 / 可空列照拒。发车门 1.10 第三档读同一只投影。
43
+ * 迁移段之后起服再调一次、待跑集为空(收口):声明多报了迁移没做的改动 ⇒ 这一次拒(构建缺陷;不收口则投影在窗内每次起服都把那一处抹平)。
44
+ *
39
45
  * ── 失败方向:读不到就拒,不跳过 ─────────────────────────────────────────────────────────────
40
46
  * information_schema 查询抛错、或整库一行都读不出来 ⇒ **拒启**。探针的语义是「我证明了库等于代码」,
41
47
  * 证不出来时放行等于把一条保护性断言静默降级成祝福([ref]:保护型缺席必须 fail-closed)。
@@ -54,6 +60,8 @@
54
60
  * (探针先于它的第一条 CREATE)—— 一次会拒的起服不许先在库里建出几十张本版新表(`task_run` 还带着新列),否则拒句里「没动过库」就是假话,
55
61
  * 按拒句指路去 7.125.x 起服的人会撞上「库比 7.125.x 新」的第二道拒。嵌入方走公开序列 `createStoreBackend()` → `ensureSchema()` → `runOneTimeMigrations()`
56
62
  * 即得到这一问,零动作。
63
+ * 同一处(建表之前、只读)还有本族的另一只跳版臂:`Skill` 豁免截点(`approval-exemption-skill-cutoff.ts`)—— 证据不是 schema 而是行(截点之前的 `Skill` 豁免行),
64
+ * 截点、判与拒(`LegacySkillExemptionsPresentError`,同码)住在那一只模块;调用点同在 `withRetiredSchemaProbe`。
57
65
  * 残余(如实):运维在第一次起带迁移的版本之前就把两件退役 schema 都手工删掉的库,残留的旧拼法由门记录的读腿接住 —— 7.129.0 起(core 7.51.0 [ref])
58
66
  * 那只读腿按 core 的档案读法读,`unresolvable` 是 core 退役词注册表里的词 ⇒ 记录原词原样读回、不改写(`trace/project.ts` `screenedGate`);local 文件车道
59
67
  * 没有表与列,没有这一问 —— 引擎 File 店里已决 checkpoint 的记录按契约物理不可变,本仓没有合法缝改写它们,同由那条读腿接住。
@@ -113,6 +121,54 @@ export interface TableMismatch {
113
121
  }
114
122
  /** 对账(纯函数:期望 × 实况 → 不符表清单)。实况 = {@link readLiveColumns} 的读数(列在不在 + 可不可空,一次读)。 */
115
123
  export declare function diffSchema(expected: Map<string, ParsedTable>, live: LiveColumns): TableMismatch[];
124
+ /**
125
+ * 一条一次性迁移起服时对**存量表形**做的事(登记表 `plugins/one-time-migrations.ts` 一行的 `shapeChanges` 里的一项;纯数据,列名取自迁移语句读的那一份常量)。闭集四种,
126
+ * 条件都只读**迁移之前**的库形(与迁移步自己的判据同一个事实:列在不在):
127
+ * · `drop` —— 列在 ⇒ 删;
128
+ * · `rename` —— `to` 不在 ∧ `from` 里**恰有一列**在 ⇒ 那一列改名成 `to`(改名之后的可空性按方言:MySQL 协议的改名句是 `CHANGE COLUMN <旧名> <本构建的列定义>`,
129
+ * 列按本构建的声明重定义;PG 的 `RENAME COLUMN` 不动列定义,可空性随旧列走);别的形(两列都在 / 新名已在)迁移自己会撞库,不投影 ⇒ 对账照拒;
130
+ * · `add` —— 列不在 ⇒ 按本构建的声明加上;`whilePresent` 在场 ⇒ 只在那张表上还有其中任一列时才加(迁移步的判据就是那几列在不在);
131
+ * · `rebuild` —— `whilePresent` 列在 ∧ 本构建声明的列里除 `supplies`(迁移自己给值的那几列)以外每一列都在(迁移的搬运句从旧表读的就是这几列)⇒ 整张表按本构建的建表语句重建
132
+ * (列集与可空性 = 本构建的声明;旧表上别的列随重建丢掉 —— 迁移真落下的形就是这样);缺搬运要读的列 ⇒ 不投影(迁移会在搬运那一步失败)⇒ 对账照拒,拒在迁移写之前。
133
+ */
134
+ export type PendingShapeChange = {
135
+ readonly kind: "drop";
136
+ readonly table: string;
137
+ readonly columns: readonly string[];
138
+ } | {
139
+ readonly kind: "rename";
140
+ readonly table: string;
141
+ readonly from: readonly string[];
142
+ readonly to: string;
143
+ } | {
144
+ readonly kind: "add";
145
+ readonly table: string;
146
+ readonly column: string;
147
+ readonly whilePresent?: {
148
+ readonly table: string;
149
+ readonly anyOf: readonly string[];
150
+ };
151
+ } | {
152
+ readonly kind: "rebuild";
153
+ readonly table: string;
154
+ readonly whilePresent: string;
155
+ readonly supplies: readonly string[];
156
+ };
157
+ /** 一条迁移的表形声明(登记表的行结构上满足它:`id` + 可选的 `shapeChanges`)。 */
158
+ export interface PendingShapeSource {
159
+ readonly id: string;
160
+ readonly shapeChanges?: readonly PendingShapeChange[];
161
+ }
162
+ /** 投影的结果:迁移跑完之后的库形 + 真改了表形的那几条迁移(按登记序)。 */
163
+ export interface PendingShapeProjection {
164
+ readonly projected: LiveColumns;
165
+ readonly appliedBy: readonly string[];
166
+ }
167
+ /**
168
+ * 待跑的一次性迁移把库形改成什么(纯函数;**唯一**一只投影 —— 起服列册对账与发车门 1.10 第三档都读它)。条件读 `live`(迁移之前的形),改动落在一份拷贝上 ⇒ 与声明的先后无关。
169
+ * 一个(表 × 列)只许一条声明认领(`rebuild` 认领整张表):两条声明碰同一处 ⇒ 响亮抛(一件事一个属主,不挑一个)。
170
+ */
171
+ export declare function projectPendingShape(expected: Map<string, ParsedTable>, live: LiveColumns, sources: readonly PendingShapeSource[], dialect: SqlDialect): PendingShapeProjection;
116
172
  /** 缺一列时的配方(按 {@link columnFill} 三类);拒启句里这一列的那几行就是它的返回值,真库钉据此取期望而不手抄句子。 */
117
173
  export declare function missingColumnLines(dialect: SqlDialect, table: string, c: ParsedColumn): string[];
118
174
  /**
@@ -126,14 +182,42 @@ export declare function missingColumnLines(dialect: SqlDialect, table: string, c
126
182
  * 🔴 **没有销毁性的一条**:对带数据的库,任何「删库 / 重建」的指路都是教人销毁数据 —— 这里不写,拒启句的钉也守着(`test/schema-consistency.test.ts` ⑤)。
127
183
  */
128
184
  export declare function describeMismatchedTables(dialect: SqlDialect, mismatches: readonly TableMismatch[]): string[];
129
- /** 拒启文案(表头 + 逐表段与闭集三条出路 {@link describeMismatchedTables})。 */
130
- export declare function describeSchemaMismatch(dialect: SqlDialect, mismatches: readonly TableMismatch[]): string;
131
185
  /**
132
- * 启动期对账:**建表之后**跑,不符即抛(= 拒启)。两方言 + InnoDB 同腿(MySQL 协议共用 `mysql` 臂)。
186
+ * **升级清单**(多行;`[]` = 没有语句要部署者执行,清单不出):一次起服的拒句里「这只库此刻对本构建还差什么、先后顺序」一段。
187
+ * 缺列拒、判活拒、一次性迁移的拒本来各报各的一段 —— 运维补一句再起、才撞上下一族(列册对账在迁移段之后时,第四次起服才拒出其余全部 ALTER)。
188
+ * 清单把本构建对存量库的全部动作写成一张:① 逐表的 ALTER(其下 {@link describeMismatchedTables} 的逐表段,全库一次列全)② 本构建带的停-迁-起迁移(登记表 `stopThenStart` 一格,
189
+ * 行在 ⇒ 码在;一次性迁移文件静态依赖本文件,所以惰性载入)③ 起服。第 ② 步对「已经走过这些迁移」的库是空操作(迁移在自己的守卫处判),清单不替守卫判「这一只库欠不欠」。
190
+ */
191
+ export declare function describeUpgradeChecklist(mismatches: readonly TableMismatch[]): Promise<string[]>;
192
+ /** 拒启文案(表头 + {@link describeUpgradeChecklist}〔有语句要执行时〕+ 逐表段与闭集三条出路 {@link describeMismatchedTables})。`checklist` 由调用方 `await describeUpgradeChecklist(…)` 交来(本函数保持同步纯函数)。 */
193
+ export declare function describeSchemaMismatch(dialect: SqlDialect, mismatches: readonly TableMismatch[], checklist?: readonly string[]): string;
194
+ /**
195
+ * 迁移段**之前 / 之中**读列册的拒句(单表探针的拒因、判活守卫的句尾)要的那一份:库经本构建**全部待跑迁移**之后({@link projectPendingShape},登记表各行的 `shapeChanges`,
196
+ * 与起服对账同一只投影)对本构建的不符里,只留**要部署者执行语句**的方向(缺列、声明非空而库里允许 NULL):
197
+ * · 待跑迁移要删 / 改名走的旧列、自己要建的列,投影已经按迁移自己的条件换成迁移之后的形 —— 不读成「库比构建新」,也不列成部署者的 `ALTER`
198
+ * (部署者照句子先加了,迁移的改名 / 加列反而撞上已存在的目标列、起服卡死);条件不成立的(如改名的源列不在)照列,迁移不会替它建;
199
+ * · 投影之后还剩的幽灵列〔库里有、代码没有〕去掉 —— 没有语句可给,名字又来自库里、不受信,不进「给运维的指令」。
200
+ * 迁移段之中读:已跑过的迁移的条件读到的是迁移之后的形、不再投影,所以读数与迁移段之前同一份。登记表惰性载入(一次性迁移文件静态依赖本文件)。
201
+ */
202
+ export declare function statementBearing(expected: Map<string, ParsedTable>, live: LiveColumns, dialect: SqlDialect): Promise<TableMismatch[]>;
203
+ /** 清单 + 逐表段 + 闭集三条出路(多行):单表探针的拒因与判活守卫的句尾共用的正文(列册对账的拒句是 {@link describeSchemaMismatch},同一份清单与逐表段)。 */
204
+ export declare function describeUpgradeBody(dialect: SqlDialect, mismatches: readonly TableMismatch[]): Promise<string[]>;
205
+ /** 句尾读列册的期限(ms):这一段是拒句上的附加,不许把已经成立的拒拖住 —— 读不回来就如实说没读到,拒照出。 */
206
+ export declare const DRIFT_TAIL_DEADLINE_MS = 10000;
207
+ /**
208
+ * 别的拒启(一次性迁移的判活守卫)句尾上的「这只库此刻还差的列」一段:读整库列册对账同一份读数({@link statementBearing} 之后),没有语句要执行 ⇒ `""`;否则一个空行 + 一行引子 +
209
+ * {@link describeUpgradeBody}。读不出列册 / 超过 {@link DRIFT_TAIL_DEADLINE_MS} ⇒ 响亮地说没读到及原因(不静默吞:吞了运维以为列是齐的;也不无限等:拒已经成立,
210
+ * 这一段只是附加 —— 读卡住的那条语句留在连接上,和迁移段里任何一条卡住的语句同一个去处)。`deadlineMs` 是测试缝。
211
+ */
212
+ export declare function describeUpgradeDriftTail(read: SchemaProbeRead, dialect: SqlDialect, deadlineMs?: number): Promise<string>;
213
+ /**
214
+ * 启动期对账:**建表之后、一次性迁移之前**跑,不符即抛(= 拒启;拒的时候迁移一笔没写)。两方言 + InnoDB 同腿(MySQL 协议共用 `mysql` 臂)。
215
+ * 比的是库**经待跑的一次性迁移之后**的形({@link projectPendingShape};`pending` = 本构建带着的迁移的表形声明,起服读登记表交来):迁移要删 / 改名 / 补的列不算不符,
216
+ * 别的缺列 / 多列 / 可空列照拒 —— 漏了升级前 DDL 的库在迁移改写任何一行之前就被拒。
133
217
  *
134
- * 失败方向见文件头注:读抛 / 整库零行 ⇒ 拒。
218
+ * 失败方向见文件头注:读抛 / 整库零行 ⇒ 拒。三条拒(读不出 / 整库零行 / 列集不符)与单表探针的拒启同一只出口 {@link SchemaMismatchRefusedError}(`code` = `config.schema_mismatch`)。
135
219
  */
136
- export declare function assertSchemaMatchesCode(read: SchemaProbeRead, dialect: SqlDialect): Promise<void>;
220
+ export declare function assertSchemaMatchesCode(read: SchemaProbeRead, dialect: SqlDialect, pending: readonly PendingShapeSource[]): Promise<void>;
137
221
  /**
138
222
  * 跳版升级的库的拒启:退役 schema(快照索引表 / 单座列)任一仍在。**具名类型**:锚类型不锚文案(日志消费端与测试按 `code` / 类型分派);
139
223
  * 码取列册对账同一个 `config.schema_mismatch`,运维面一个拒启码一个读法。拒句点名在场的坐标、说明第三条(旧拼法改写)同样没跑过、给出路。