@sema-agent/server 7.137.0 → 7.139.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. package/MIGRATION.md +600 -142
  2. package/README.md +7 -3
  3. package/README.zh-CN.md +6 -3
  4. package/USAGE.md +132 -41
  5. package/deploy/sema-up/README.md +1 -1
  6. package/deploy/sema-up/lib/lanes.json +3 -2
  7. package/dist/adoption/wire.d.ts +3 -3
  8. package/dist/approval-card.d.ts +23 -16
  9. package/dist/approval-card.js +1 -0
  10. package/dist/audit.js +1 -0
  11. package/dist/background-agent-name.d.ts +5 -0
  12. package/dist/background-agent-name.js +5 -0
  13. package/dist/bench/s1/arms.d.ts +1 -1
  14. package/dist/bench/s1/arms.js +1 -3
  15. package/dist/bench/s1/run-firm.js +2 -2
  16. package/dist/boot/budget-tracing.d.ts +1 -12
  17. package/dist/boot/deployment-mods.d.ts +46 -16
  18. package/dist/boot/deployment-mods.js +82 -6
  19. package/dist/boot/device-lane.d.ts +8 -3
  20. package/dist/boot/device-lane.js +7 -2
  21. package/dist/boot/execution-env.d.ts +4 -2
  22. package/dist/boot/execution-env.js +16 -24
  23. package/dist/boot/execution-lane.d.ts +1 -1
  24. package/dist/boot/governance-seams.d.ts +12 -18
  25. package/dist/boot/governance-seams.js +10 -24
  26. package/dist/boot/leader.d.ts +3 -0
  27. package/dist/boot/leader.js +1 -0
  28. package/dist/boot/memory-consolidation.js +2 -2
  29. package/dist/boot/org-delivery.js +15 -2
  30. package/dist/boot/resolve-spec.d.ts +4 -1
  31. package/dist/boot/resolve-spec.js +29 -19
  32. package/dist/boot/retention-lane.d.ts +14 -8
  33. package/dist/boot/retention-lane.js +2 -8
  34. package/dist/boot/runner-deps.d.ts +10 -4
  35. package/dist/boot/runner-deps.js +2 -0
  36. package/dist/boot/session-faces.d.ts +0 -3
  37. package/dist/boot/session-faces.js +15 -8
  38. package/dist/boot/stage-02-web-admin-pull.js +2 -2
  39. package/dist/boot/stage-04-stores.d.ts +1 -12
  40. package/dist/boot/stage-05-execution-env.d.ts +3 -12
  41. package/dist/boot/stage-05-execution-env.js +8 -5
  42. package/dist/boot/stage-06-runners.d.ts +3 -12
  43. package/dist/boot/stage-06-runners.js +5 -3
  44. package/dist/boot/stage-07-capability-layer.d.ts +3 -12
  45. package/dist/boot/stage-07-capability-layer.js +2 -2
  46. package/dist/boot/stage-08-reapers.d.ts +3 -12
  47. package/dist/boot/stage-09-leader.d.ts +3 -12
  48. package/dist/boot/stage-09-leader.js +2 -3
  49. package/dist/boot/stage-10-http-server.d.ts +3 -12
  50. package/dist/boot/stage-10-http-server.js +5 -3
  51. package/dist/boot/stores.js +4 -4
  52. package/dist/boot/web-admin.d.ts +5 -2
  53. package/dist/boot/web-admin.js +5 -5
  54. package/dist/brain.js +5 -5
  55. package/dist/budget.d.ts +17 -15
  56. package/dist/budget.js +2 -2
  57. package/dist/capabilities/mcp-probe.d.ts +36 -11
  58. package/dist/capabilities/mcp-probe.js +44 -5
  59. package/dist/capabilities/scenarios.d.ts +1 -1
  60. package/dist/capability-facts.d.ts +21 -7
  61. package/dist/capability-facts.js +13 -3
  62. package/dist/checkpoint-identity.d.ts +13 -6
  63. package/dist/config-catalog.js +14 -8
  64. package/dist/config-invariants.d.ts +23 -3
  65. package/dist/config-invariants.js +42 -0
  66. package/dist/config-provider.d.ts +19 -24
  67. package/dist/config-provider.js +27 -12
  68. package/dist/config-types.d.ts +25 -12
  69. package/dist/config.d.ts +33 -0
  70. package/dist/config.js +52 -20
  71. package/dist/delegated-session-owner.d.ts +2 -2
  72. package/dist/deployment-shape.d.ts +7 -7
  73. package/dist/device-enrollment.d.ts +41 -2
  74. package/dist/device-enrollment.js +27 -12
  75. package/dist/device-store.d.ts +58 -85
  76. package/dist/device-store.js +19 -49
  77. package/dist/device-ws-hub.js +2 -1
  78. package/dist/env-name-allowlist-knobs.d.ts +5 -4
  79. package/dist/exec-cut.d.ts +60 -0
  80. package/dist/exec-cut.js +36 -0
  81. package/dist/execution-lane-caps.d.ts +19 -0
  82. package/dist/execution-lane-caps.js +10 -0
  83. package/dist/execution-placement.d.ts +259 -0
  84. package/dist/execution-placement.js +199 -0
  85. package/dist/field-conflict.d.ts +3 -2
  86. package/dist/field-conflict.js +1 -0
  87. package/dist/gate-settlement.d.ts +71 -0
  88. package/dist/gate-settlement.js +40 -0
  89. package/dist/hooks/classic-stations.d.ts +1 -0
  90. package/dist/hooks/classic-stations.js +1 -0
  91. package/dist/hooks/command-spawn.d.ts +2 -2
  92. package/dist/hooks/hook-runner.js +14 -14
  93. package/dist/hosted-posture.d.ts +2 -2
  94. package/dist/http/admission.js +10 -19
  95. package/dist/http/checkpoint-ctx.d.ts +21 -12
  96. package/dist/http/checkpoint-ctx.js +4 -2
  97. package/dist/http/claimed-run.d.ts +1 -1
  98. package/dist/http/dispatch.d.ts +4 -0
  99. package/dist/http/dispatch.js +3 -0
  100. package/dist/http/durable-run-leg.js +12 -6
  101. package/dist/http/execution-context.d.ts +218 -0
  102. package/dist/http/execution-context.js +330 -0
  103. package/dist/http/memory-compliance-failure-reply.js +3 -0
  104. package/dist/http/resume-legs.d.ts +5 -11
  105. package/dist/http/resume-legs.js +179 -104
  106. package/dist/http/route-ctx.d.ts +44 -59
  107. package/dist/http/route-ctx.js +2 -5
  108. package/dist/http/routes/a2a-serve.js +10 -4
  109. package/dist/http/routes/agents-roster.d.ts +1 -1
  110. package/dist/http/routes/agents-roster.js +5 -1
  111. package/dist/http/routes/approvals-assistant.js +6 -6
  112. package/dist/http/routes/background.d.ts +29 -1
  113. package/dist/http/routes/background.js +29 -2
  114. package/dist/http/routes/capabilities.js +12 -10
  115. package/dist/http/routes/devices.d.ts +13 -19
  116. package/dist/http/routes/devices.js +21 -26
  117. package/dist/http/routes/mcp-reconnect.d.ts +3 -2
  118. package/dist/http/routes/mcp-reconnect.js +7 -5
  119. package/dist/http/routes/memory-consolidation.d.ts +1 -1
  120. package/dist/http/routes/memory-origin.d.ts +1 -1
  121. package/dist/http/routes/rules.js +3 -3
  122. package/dist/http/routes/runs.js +11 -7
  123. package/dist/http/routes/session-sync.js +110 -15
  124. package/dist/http/routes/sessions.d.ts +6 -0
  125. package/dist/http/routes/sessions.js +119 -13
  126. package/dist/http/routes/side-query.d.ts +12 -4
  127. package/dist/http/routes/side-query.js +6 -8
  128. package/dist/http/routes/tasks.js +800 -782
  129. package/dist/http/server.d.ts +61 -39
  130. package/dist/http/server.js +11 -0
  131. package/dist/http/session-inherit-env.d.ts +3 -4
  132. package/dist/http/session-inherit-env.js +1 -1
  133. package/dist/http/wire-types.d.ts +22 -18
  134. package/dist/identity-source.d.ts +1 -1
  135. package/dist/index.d.ts +3 -0
  136. package/dist/index.js +1 -0
  137. package/dist/leader/wire.d.ts +7 -0
  138. package/dist/leader/wire.js +5 -4
  139. package/dist/mcp-assertion-header.d.ts +51 -40
  140. package/dist/mcp-assertion-header.js +10 -11
  141. package/dist/mcp-authorization-face.d.ts +27 -0
  142. package/dist/mcp-authorization-face.js +15 -0
  143. package/dist/memory-operator-faces.d.ts +13 -4
  144. package/dist/memory-operator-faces.js +1 -17
  145. package/dist/memory-sync-client.d.ts +53 -14
  146. package/dist/memory-sync-client.js +187 -92
  147. package/dist/memory-sync.d.ts +1 -1
  148. package/dist/memory-sync.js +3 -2
  149. package/dist/model-route-gap.d.ts +6 -22
  150. package/dist/model-route-gap.js +7 -5
  151. package/dist/mods-declaration.d.ts +110 -0
  152. package/dist/mods-declaration.js +109 -0
  153. package/dist/observability/cost-taxonomy.d.ts +2 -2
  154. package/dist/observability/err-text.d.ts +5 -0
  155. package/dist/observability/err-text.js +9 -0
  156. package/dist/observability/fail-open.d.ts +17 -1
  157. package/dist/observability/fail-open.js +17 -1
  158. package/dist/observability/run-terminal.js +0 -1
  159. package/dist/observability/secret-env-scrub.d.ts +1 -1
  160. package/dist/observability/secret-env-scrub.js +1 -1
  161. package/dist/orchestration/workflow-notify-journal.js +4 -4
  162. package/dist/org-memory-admission.d.ts +4 -3
  163. package/dist/org-memory-admission.js +6 -6
  164. package/dist/parked-decide.d.ts +49 -11
  165. package/dist/parked-decide.js +25 -26
  166. package/dist/parked-revive-surface.d.ts +12 -15
  167. package/dist/parked-revive-surface.js +3 -6
  168. package/dist/peer-seat.d.ts +156 -0
  169. package/dist/peer-seat.js +273 -0
  170. package/dist/permission-rule-vocab.d.ts +7 -7
  171. package/dist/permission-rule-vocab.js +3 -3
  172. package/dist/plain-string-record.d.ts +38 -0
  173. package/dist/plain-string-record.js +38 -0
  174. package/dist/plugins/approval-ask-store-file.d.ts +7 -0
  175. package/dist/plugins/approval-ask-store-file.js +11 -0
  176. package/dist/plugins/approval-ask-store-memory.d.ts +6 -0
  177. package/dist/plugins/approval-ask-store-memory.js +12 -0
  178. package/dist/plugins/approval-ask-store-sql.d.ts +22 -5
  179. package/dist/plugins/approval-ask-store-sql.js +30 -12
  180. package/dist/plugins/background-agent-store-sql.js +6 -3
  181. package/dist/plugins/caching-session-store.d.ts +11 -2
  182. package/dist/plugins/caching-session-store.js +15 -3
  183. package/dist/plugins/checkpoint-store-sql.d.ts +21 -25
  184. package/dist/plugins/checkpoint-store-sql.js +13 -15
  185. package/dist/plugins/device-store-sql.d.ts +25 -51
  186. package/dist/plugins/device-store-sql.js +49 -103
  187. package/dist/plugins/execution-placement-sql.d.ts +151 -0
  188. package/dist/plugins/execution-placement-sql.js +223 -0
  189. package/dist/plugins/file-history-store-sql.d.ts +7 -6
  190. package/dist/plugins/file-history-store-sql.js +8 -7
  191. package/dist/plugins/file-run-store.d.ts +6 -1
  192. package/dist/plugins/file-run-store.js +4 -0
  193. package/dist/plugins/fork-routing-session-store.d.ts +6 -0
  194. package/dist/plugins/fork-routing-session-store.js +16 -0
  195. package/dist/plugins/k8s-pod-reaper.d.ts +2 -8
  196. package/dist/plugins/k8s-pod-reaper.js +3 -3
  197. package/dist/plugins/local-checkpoint-store.d.ts +1 -11
  198. package/dist/plugins/local-checkpoint-store.js +0 -1
  199. package/dist/plugins/local-session-store.d.ts +183 -45
  200. package/dist/plugins/local-session-store.js +412 -105
  201. package/dist/plugins/mailbox-store-sql.d.ts +11 -3
  202. package/dist/plugins/mailbox-store-sql.js +103 -15
  203. package/dist/plugins/memory-key-guards.d.ts +4 -0
  204. package/dist/plugins/memory-key-guards.js +1 -0
  205. package/dist/plugins/memory-run-store.d.ts +1 -1
  206. package/dist/plugins/memory-sync-store-pg.d.ts +16 -5
  207. package/dist/plugins/memory-sync-store-tidb.d.ts +3 -3
  208. package/dist/plugins/one-time-migrations.d.ts +117 -6
  209. package/dist/plugins/one-time-migrations.js +383 -14
  210. package/dist/plugins/outcome-ledger-sql.d.ts +2 -2
  211. package/dist/plugins/permission-rule-store-sql.d.ts +13 -15
  212. package/dist/plugins/permission-rule-store-sql.js +34 -29
  213. package/dist/plugins/pg-pool.d.ts +7 -3
  214. package/dist/plugins/pg-session-storage.d.ts +41 -22
  215. package/dist/plugins/pg-session-storage.js +136 -55
  216. package/dist/plugins/remote-env-adb.d.ts +22 -4
  217. package/dist/plugins/remote-env-adb.js +70 -62
  218. package/dist/plugins/remote-env-device.d.ts +32 -12
  219. package/dist/plugins/remote-env-device.js +18 -17
  220. package/dist/plugins/remote-env-e2b.d.ts +19 -3
  221. package/dist/plugins/remote-env-e2b.js +41 -42
  222. package/dist/plugins/remote-env-file-error.d.ts +2 -0
  223. package/dist/plugins/remote-env-file-error.js +1 -1
  224. package/dist/plugins/remote-env-host.d.ts +23 -8
  225. package/dist/plugins/remote-env-host.js +66 -70
  226. package/dist/plugins/remote-env-k8s.d.ts +13 -16
  227. package/dist/plugins/remote-env-k8s.js +61 -54
  228. package/dist/plugins/remote-env-local-docker.d.ts +30 -5
  229. package/dist/plugins/remote-env-local-docker.js +46 -59
  230. package/dist/plugins/remote-env-ssh.d.ts +18 -4
  231. package/dist/plugins/remote-env-ssh.js +72 -56
  232. package/dist/plugins/remote-shell.d.ts +100 -3
  233. package/dist/plugins/remote-shell.js +166 -2
  234. package/dist/plugins/retention-store-sql.d.ts +23 -8
  235. package/dist/plugins/retention-store-sql.js +13 -1
  236. package/dist/plugins/run-store-sql.d.ts +13 -3
  237. package/dist/plugins/run-store-sql.js +5 -0
  238. package/dist/plugins/schema-consistency.d.ts +15 -5
  239. package/dist/plugins/schema-consistency.js +14 -8
  240. package/dist/plugins/schema-drift-sentence.d.ts +27 -0
  241. package/dist/plugins/schema-drift-sentence.js +38 -0
  242. package/dist/plugins/session-fork.d.ts +63 -12
  243. package/dist/plugins/session-fork.js +38 -21
  244. package/dist/plugins/session-ownership.d.ts +7 -0
  245. package/dist/plugins/session-ownership.js +5 -0
  246. package/dist/plugins/session-row-sql.d.ts +22 -0
  247. package/dist/plugins/session-row-sql.js +81 -0
  248. package/dist/plugins/session-store.d.ts +1 -1
  249. package/dist/plugins/session-workspace-cell.d.ts +17 -0
  250. package/dist/plugins/session-workspace-cell.js +38 -0
  251. package/dist/plugins/store-backend.d.ts +1 -1
  252. package/dist/plugins/store-backend.js +26 -14
  253. package/dist/plugins/store-contracts.d.ts +25 -5
  254. package/dist/plugins/tidb-pool.d.ts +12 -7
  255. package/dist/plugins/tidb-pool.js +15 -3
  256. package/dist/plugins/tidb-session-store.d.ts +74 -28
  257. package/dist/plugins/tidb-session-store.js +132 -53
  258. package/dist/plugins/tool-result-store-sql.d.ts +16 -8
  259. package/dist/plugins/tool-result-store-sql.js +26 -17
  260. package/dist/plugins/worktree-isolation.d.ts +3 -2
  261. package/dist/plugins/worktree-isolation.js +1 -7
  262. package/dist/project-identity.d.ts +1 -14
  263. package/dist/project-identity.js +0 -4
  264. package/dist/project-memory.d.ts +9 -15
  265. package/dist/project-memory.js +5 -4
  266. package/dist/request-key-closure.js +1 -1
  267. package/dist/request-value-closure.d.ts +6 -7
  268. package/dist/request-value-closure.js +1 -3
  269. package/dist/rules-consent.js +6 -4
  270. package/dist/run-local.d.ts +6 -0
  271. package/dist/run-local.js +36 -26
  272. package/dist/runs.d.ts +9 -2
  273. package/dist/runs.js +9 -2
  274. package/dist/sandbox-git-plane.d.ts +2 -2
  275. package/dist/security.d.ts +38 -12
  276. package/dist/security.js +8 -2
  277. package/dist/server-secret-env.d.ts +15 -8
  278. package/dist/server-secret-env.js +4 -2
  279. package/dist/session-children-retire.d.ts +27 -9
  280. package/dist/session-children-retire.js +9 -5
  281. package/dist/session-incarnation.d.ts +19 -10
  282. package/dist/session-incarnation.js +5 -3
  283. package/dist/session-row.d.ts +114 -5
  284. package/dist/session-row.js +55 -5
  285. package/dist/session-titler.d.ts +2 -2
  286. package/dist/session-workspace.d.ts +130 -0
  287. package/dist/session-workspace.js +83 -0
  288. package/dist/spec-fields.d.ts +1 -1
  289. package/dist/standing-answer-grant.d.ts +2 -13
  290. package/dist/standing-answer-grant.js +0 -7
  291. package/dist/task-a2a.d.ts +44 -14
  292. package/dist/task-a2a.js +46 -20
  293. package/dist/task-cwd.d.ts +6 -1
  294. package/dist/task-mcp.d.ts +33 -20
  295. package/dist/task-mcp.js +46 -22
  296. package/dist/tool-approval.d.ts +38 -8
  297. package/dist/tool-approval.js +57 -13
  298. package/dist/trace/core-keyset-guard.d.ts +33 -18
  299. package/dist/trace/engine-notice-wire.d.ts +1 -1
  300. package/dist/trace/engine-notice-wire.js +2 -0
  301. package/dist/trace/exit-seat.d.ts +20 -1
  302. package/dist/trace/exit-seat.js +3 -0
  303. package/dist/trace/extension-call-face.d.ts +33 -0
  304. package/dist/trace/extension-call-face.js +39 -0
  305. package/dist/trace/project.d.ts +26 -7
  306. package/dist/trace/project.js +30 -3
  307. package/dist/trace/redact.js +42 -13
  308. package/dist/trace/self-minted-wire.d.ts +240 -12
  309. package/dist/trace/self-minted-wire.js +100 -4
  310. package/dist/trace/wire-projection-faces.d.ts +6 -6
  311. package/dist/trace/wire-projection-faces.js +6 -5
  312. package/dist/url-refusal.d.ts +1 -1
  313. package/dist/user-authored-text.d.ts +2 -2
  314. package/dist/user-authored-text.js +16 -1
  315. package/dist/web-admin-client/apply-effective.d.ts +17 -3
  316. package/dist/web-admin-client/apply-effective.js +20 -10
  317. package/dist/web-admin-client/center-request.d.ts +3 -2
  318. package/dist/web-admin-client/center-request.js +2 -3
  319. package/dist/web-admin-client/skills-mcp.d.ts +8 -4
  320. package/dist/web-admin-client/skills-mcp.js +32 -18
  321. package/dist/web-admin-client/types.d.ts +11 -0
  322. package/package.json +3 -3
  323. package/wire-changes/server@7.138.0.json +446 -0
  324. package/wire-changes/server@7.139.0.json +348 -0
  325. package/dist/http/device-placement.d.ts +0 -175
  326. package/dist/http/device-placement.js +0 -145
  327. package/dist/http/mcp-remote-text.d.ts +0 -5
  328. package/dist/http/mcp-remote-text.js +0 -7
@@ -1,5 +1,6 @@
1
- import { ExecutionError, SYMLINK_CHAIN_MAX_HOPS } from "@sema-agent/core";
2
- import { classifyFsStderr, fileErrorFromExec } from "./remote-env-file-error.js";
1
+ import { ExecutionError, FileError, SYMLINK_CHAIN_MAX_HOPS } from "@sema-agent/core";
2
+ import { NAME_MAX_BYTES, classifyFsStderr, fileErrorFromExec } from "./remote-env-file-error.js";
3
+ import { recordFailOpen } from "../observability/fail-open.js";
3
4
  import { randomBytes } from "node:crypto";
4
5
  export const EXEC_FORCE_SETTLE_GRACE_MS = 3000;
5
6
  export function armPipeDestroyGrace(child) {
@@ -153,4 +154,167 @@ export async function runSymlinkChain(absPath, run) {
153
154
  return { ok: false, error: classifyFsStderr(r.value.stderr, absPath, "unknown") };
154
155
  return { ok: true, value: { hops: symlinkChainHopsFromStdout(r.value.stdout, absPath) } };
155
156
  }
157
+ const STAGED_FILE = "data";
158
+ const STAGE_DIR_REFUSED_EXIT = 3;
159
+ const STAGE_DIR_UNSEALED_EXIT = 4;
160
+ const STAGE_DIR_UNSEALED_MESSAGE = "sema: the private staging directory could not be made private (chmod 700 failed or did not take effect, or the mode or owner read back differently — the filesystem ignores permission bits or the parent directory carries a default ACL); nothing was staged";
161
+ export const STAGE_DIR_LEFT_MARK = "sema: staging directory left behind:";
162
+ export function buildStagingPaths(abs, nonce = randomBytes(6).toString("hex")) {
163
+ const cut = abs.lastIndexOf("/");
164
+ const parent = abs.slice(0, cut + 1);
165
+ const suffix = `.stage-${nonce}`;
166
+ const name = Array.from(abs.slice(cut + 1));
167
+ while (name.length > 0 && Buffer.byteLength(`.${name.join("")}${suffix}`, "utf8") > NAME_MAX_BYTES)
168
+ name.pop();
169
+ const dir = `${parent}.${name.join("")}${suffix}`;
170
+ return { dir, file: `${dir}/${STAGED_FILE}` };
171
+ }
172
+ function privateMkdir(dir) {
173
+ return `(umask 077 && mkdir -- ${dir})`;
174
+ }
175
+ function hardenPrivate(dir) {
176
+ const statOf = (gnu, bsd) => `$(stat -c ${gnu} -- ${dir} 2>/dev/null || stat -f ${bsd} -- ${dir} 2>/dev/null)`;
177
+ return `( chmod -- 700 ${dir} 2>/dev/null && m=${statOf("%a", "%Lp")} && u=${statOf("%u", "%u")} && case "$m" in 700|[1-7]700) [ "$u" = "$(id -u)" ];; *) false;; esac )`;
178
+ }
179
+ function emptyPrivate(dir) {
180
+ return `[ ! -e ${dir}/${STAGED_FILE} ] && [ ! -L ${dir}/${STAGED_FILE} ]`;
181
+ }
182
+ function sealPrivate(dir) {
183
+ return `${hardenPrivate(dir)} && ${emptyPrivate(dir)}`;
184
+ }
185
+ function removalOf(dir) {
186
+ return `{ [ ! -L ${dir} ] && rm -f -- ${dir}/${STAGED_FILE} && { rmdir -- ${dir} 2>/dev/null || [ ! -e ${dir} ]; }; }`;
187
+ }
188
+ function leftMark(dir) {
189
+ return `printf '%s %s\\0' ${shellQuote(STAGE_DIR_LEFT_MARK)} ${dir}`;
190
+ }
191
+ function removePrivate(dir) {
192
+ return `{ ${removalOf(dir)} || ${leftMark(dir)}; }`;
193
+ }
194
+ function abandonPrivate(dir) {
195
+ return `{ rmdir -- ${dir} 2>/dev/null || ${leftMark(dir)}; }`;
196
+ }
197
+ export function buildStagingPrepareCommand(stage) {
198
+ const parent = stage.dir.slice(0, stage.dir.lastIndexOf("/")) || "/";
199
+ const dir = shellQuote(stage.dir);
200
+ return (`{ mkdir -p -- ${shellQuote(parent)} || exit 1; ${privateMkdir(dir)} || exit ${STAGE_DIR_REFUSED_EXIT}; ` +
201
+ `${hardenPrivate(dir)} || { ${abandonPrivate(dir)}; printf '%s\\n' ${shellQuote(STAGE_DIR_UNSEALED_MESSAGE)} >&2; exit ${STAGE_DIR_UNSEALED_EXIT}; }; ` +
202
+ `${emptyPrivate(dir)} || { ${abandonPrivate(dir)}; printf '%s\\n' ${shellQuote("sema: the private staging directory gained an entry before it was made private; not used")} >&2; exit 1; }; }`);
203
+ }
204
+ function takeAndWrite(redirect) {
205
+ return (`[ -r "$s" ] || chmod -- u+r "$s" 2>/dev/null; exec 3< "$s" || exit 1; { [ ! -L "$d" ] && rm -f -- "$s"; } || { ${leftMark(`"$d"`)}; exit 1; }; ` +
206
+ `cat <&3 ${redirect} "$t"; w=$?; exec 3<&-; ${removePrivate(`"$d"`)}; exit $w; `);
207
+ }
208
+ export function buildStagedCommitCommand(stage, target) {
209
+ return (`{ d=${shellQuote(stage.dir)}; s="$d/${STAGED_FILE}"; t=${shellQuote(target)}; ` +
210
+ `if [ -L "$t" ]; then h=$(readlink -n -- "$t" && printf .) || h=; h=\${h%.}; ` +
211
+ `case "$h" in "") ;; /*) ;; *) l="\${t%/*}"; case "$l" in "") h="/$h" ;; */) h="$l$h" ;; *) h="$l/$h" ;; esac ;; esac; ` +
212
+ `if [ -n "$h" ] && [ ! -L "$h" ] && { [ ! -e "$h" ] || [ -f "$h" ]; }; then t="$h"; fi; fi; ` +
213
+ `if [ ! -L "$t" ] && { [ ! -e "$t" ] || [ -f "$t" ]; }; then ` +
214
+ `if [ "\${t%/*}" != "\${d%/*}" ]; then e="\${t%/*}/\${d##*/}"; ` +
215
+ `if { ${privateMkdir(`"$e"`)}; } 2>/dev/null; then ` +
216
+ `if { ${sealPrivate(`"$e"`)}; } 2>/dev/null; then ` +
217
+ `if cat -- "$s" > "$e/${STAGED_FILE}" 2>/dev/null; then o="$d"; d="$e"; s="$e/${STAGED_FILE}"; ${removePrivate(`"$o"`)}; else ${removePrivate(`"$e"`)}; fi; ` +
218
+ `else ${abandonPrivate(`"$e"`)}; fi; fi; fi; ` +
219
+ `if [ -f "$t" ]; then m=$(stat -c %a -- "$t" 2>/dev/null || stat -f %Lp -- "$t" 2>/dev/null) && chmod "$m" "$s" 2>/dev/null; fi; ` +
220
+ `if mv -f -- "$s" "$t" 2>/dev/null; then ${removePrivate(`"$d"`)}; exit 0; fi; fi; ` +
221
+ `${takeAndWrite(">")}}`);
222
+ }
223
+ export function buildStagedAppendCommand(stage, target) {
224
+ return `{ d=${shellQuote(stage.dir)}; s="$d/${STAGED_FILE}"; t=${shellQuote(target)}; ${takeAndWrite(">>")}}`;
225
+ }
226
+ export function buildStagingDiscardCommand(stage) {
227
+ return removalOf(shellQuote(stage.dir));
228
+ }
229
+ async function discardStaged(lane, stage) {
230
+ const r = await lane.run(buildStagingDiscardCommand(stage));
231
+ if (!r.ok)
232
+ return r.error.message;
233
+ return r.value.exitCode === 0 ? undefined : r.value.stderr.trim() || "it holds something this write did not put there, or it was replaced by a link";
234
+ }
235
+ function withResidue(e, stage, why) {
236
+ return why === undefined ? e : new FileError(e.code, `${e.message} (the staging directory ${stage.dir} could not be removed: ${why})`, e.path, e);
237
+ }
238
+ async function landStaged(lane, abs, abortSignal, commitOf, direct) {
239
+ if (abortSignal?.aborted)
240
+ return { ok: false, error: new FileError("aborted", "aborted before the write started; the target was not touched", abs) };
241
+ const stage = buildStagingPaths(abs);
242
+ const prep = await lane.run(buildStagingPrepareCommand(stage), abortSignal);
243
+ if (!prep.ok) {
244
+ const left = await discardStaged(lane, stage);
245
+ return { ok: false, error: withResidue(fileErrorFromExec(prep.error, abs), stage, left) };
246
+ }
247
+ if (prep.value.exitCode !== 0) {
248
+ noteLeftDirs(prep.value.stdout);
249
+ if (abortSignal?.aborted)
250
+ return { ok: false, error: new FileError("aborted", "aborted before the write started; the target was not touched", abs) };
251
+ const e = classifyFsStderr(prep.value.stderr, abs, "unknown");
252
+ if (prep.value.exitCode === STAGE_DIR_REFUSED_EXIT && e.code === "permission_denied")
253
+ return direct(createDirectContext(abs, abortSignal, "the directory does not accept new names"));
254
+ if (prep.value.exitCode === STAGE_DIR_UNSEALED_EXIT) {
255
+ recordFailOpen("server.remote-write.stage-seal-fallback", `${abs}: ${prep.value.stderr.trim()}`);
256
+ return direct(createDirectContext(abs, abortSignal, "the private staging directory could not be made private"));
257
+ }
258
+ return { ok: false, error: e };
259
+ }
260
+ if (abortSignal?.aborted) {
261
+ const left = await discardStaged(lane, stage);
262
+ return { ok: false, error: withResidue(new FileError("aborted", "aborted before the write started; the target was not touched", abs), stage, left) };
263
+ }
264
+ const up = await lane.upload(stage.file, abortSignal);
265
+ if (!up.ok) {
266
+ const left = await discardStaged(lane, stage);
267
+ return { ok: false, error: withResidue(up.error, stage, left) };
268
+ }
269
+ if (abortSignal?.aborted) {
270
+ const left = await discardStaged(lane, stage);
271
+ return { ok: false, error: withResidue(new FileError("aborted", "aborted before the commit; the target was not touched", abs), stage, left) };
272
+ }
273
+ const c = await lane.run(commitOf(stage));
274
+ if (!c.ok) {
275
+ const left = await discardStaged(lane, stage);
276
+ const e = fileErrorFromExec(c.error, abs);
277
+ return { ok: false, error: withResidue(new FileError("unknown", `${e.message} (the commit's round trip failed, so whether it ran is unknown — re-read the target before retrying)`, abs, e), stage, left) };
278
+ }
279
+ noteLeftDirs(c.value.stdout);
280
+ if (c.value.exitCode !== 0) {
281
+ const left = await discardStaged(lane, stage);
282
+ return { ok: false, error: withResidue(classifyFsStderr(c.value.stderr, abs, "unknown"), stage, left) };
283
+ }
284
+ return { ok: true, value: undefined };
285
+ }
286
+ function noteLeftDirs(stdout) {
287
+ const head = `${STAGE_DIR_LEFT_MARK} `;
288
+ for (const rec of stdout.split("\0")) {
289
+ const at = rec.indexOf(head);
290
+ if (at >= 0)
291
+ recordFailOpen("server.remote-write.staging-dir-left", rec.slice(at + head.length));
292
+ }
293
+ }
294
+ function createDirectContext(abs, abortSignal, why) {
295
+ return {
296
+ signal: abortSignal,
297
+ inPlace: async (step) => {
298
+ if (abortSignal?.aborted)
299
+ return { ok: false, error: new FileError("aborted", `aborted before the in-place write started (${why}); the target was not touched`, abs) };
300
+ const r = await step(abortSignal);
301
+ if (r.ok || r.error.code !== "aborted")
302
+ return r;
303
+ return { ok: false, error: new FileError("unknown", `cancelled during an in-place write (${why}, so the bytes went straight to the target); the target may be partially written — re-read it before retrying`, abs, r.error) };
304
+ },
305
+ };
306
+ }
307
+ export async function rewriteAppend(read, write, add, ctx) {
308
+ const existing = await read(ctx.signal);
309
+ if (!existing.ok && existing.error.code !== "not_found")
310
+ return existing;
311
+ const merged = Buffer.concat([existing.ok ? existing.value : new Uint8Array(0), typeof add === "string" ? Buffer.from(add, "utf8") : add]);
312
+ return ctx.inPlace((signal) => write(merged, signal));
313
+ }
314
+ export function writeFileStaged(lane, abs, abortSignal) {
315
+ return landStaged(lane, abs, abortSignal, (stage) => buildStagedCommitCommand(stage, abs), (ctx) => ctx.inPlace((signal) => lane.upload(abs, signal)));
316
+ }
317
+ export function appendFileStaged(lane, abs, abortSignal, direct) {
318
+ return landStaged(lane, abs, abortSignal, (stage) => buildStagedAppendCommand(stage, abs), direct);
319
+ }
156
320
  //# sourceMappingURL=remote-shell.js.map
@@ -52,7 +52,11 @@ import { type SqlDriver, type SqlTxConn } from "./sql-driver.js";
52
52
  import { type IndexSpec } from "./ensure-index.js";
53
53
  import type { EndedSpace } from "../workspace-space.js";
54
54
  /**
55
- * 三只**被托管**的 SQL 店(session / checkpoint / tool-result)共用的 `retention` 声明常量。
55
+ * 三只**被托管**的 SQL 店(session / checkpoint / tool-result)在扫车道开着时的 `retention` 声明常量。
56
+ *
57
+ * 🔴 **谁说这个词**(引擎 7.58.0 起:店说 `"managed"` 而部署不跑执行器 ⇒ 锁不锁都拒启):「这只店的行有没有人按期删」不是店类自己知道的事,
58
+ * 是装配它的部署知道的事。唯一事实 = 扫车道开不开({@link retentionSweepRuns});后端铸店时按它给词({@link sqlStoreRetention}),
59
+ * 交给引擎启动检查的执行器读同一个事实(开 ⇒ 执行器,关 ⇒ `null`)。店构造不给词 ⇒ `"none"`(引擎对缺席的同一读法)。
56
60
  *
57
61
  * 🔴 为什么是一个共享常量而不是各写各的字面量:这一格是 core `assertRetentionCapability` 的**唯一**读点,
58
62
  * 三只店的答案必须同进同退 —— 哪天本能力从某只店的行上撤了(比如 tool_result 改由别处清),漏改一处
@@ -66,8 +70,16 @@ import type { EndedSpace } from "../workspace-space.js";
66
70
  * file/in-memory 形没有任何东西会按期删它们的行 ⇒ 显式 `"none"`(缺席也读 none,但显式是文档义务)。
67
71
  */
68
72
  export declare const MANAGED_RETENTION: RetentionDeclaration;
69
- /** file/in-memory 形的诚实声明(见 {@link MANAGED_RETENTION} 的读法说明)。 */
73
+ /** file/in-memory 形、以及扫车道关着的 SQL 店的诚实声明(见 {@link MANAGED_RETENTION} 的读法说明)。 */
70
74
  export declare const UNMANAGED_RETENTION: RetentionDeclaration;
75
+ /** 本部署跑不跑托管留存的扫车道(`RETENTION_SWEEP_INTERVAL_SEC > 0`)—— 三只 SQL 店的声明与交给引擎启动检查的执行器只读这一处。 */
76
+ export declare function retentionSweepRuns(sweep: {
77
+ readonly intervalSec: number;
78
+ }): boolean;
79
+ /** 后端铸三只被托管的 SQL 店时交给店的词:扫车道开 ⇒ `"managed"`,关 ⇒ `"none"`。 */
80
+ export declare function sqlStoreRetention(sweep: {
81
+ readonly intervalSec: number;
82
+ }): RetentionDeclaration;
71
83
  /** 追加式审计表(设计稿 §6)。 */
72
84
  export declare const RETENTION_AUDIT_TABLE = "retention_audit";
73
85
  /** per-domain 法务保留标记(设计稿 §5)——**行在 = 该域整体冻结**。 */
@@ -392,6 +404,7 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
392
404
  * ① `retention_hold` 哨兵行的**锁读** —— `held=1` ⇒ 零删、全 skip 返回;锁持有到 COMMIT;
393
405
  * ② **一条**加锁读同时完成「候选 + 活引用重查」(谓词下推 + `FOR UPDATE`,见
394
406
  * {@link SqlRetentionStore.sessionCandidates});
407
+ * ②′ 读写序律的写者半:锁住候选行之后,逐只用**本事务的连接、新语句**锁定读一次认领(`lockRootAndReadClaim`);有认领的树整棵跳过;
395
408
  * ③ 按 E21 顺序逐表删 → 墓碑 → 审计行(同事务)→ COMMIT。
396
409
  *
397
410
  * 🔴 活引用重查为什么是**候选查询本身**而不是第二条查询(codex 交叉复审 R1 的两条 high 合并采纳):
@@ -399,12 +412,14 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
399
412
  * · `FOR UPDATE` 让这些会话行在本事务期间不可被 `touch()` 拉回视界、也不可被 session-sync 改写属主 ——
400
413
  * 旧形(不加锁 + 尾部无条件删 `session_event`)可以在属主被并发改写后,把**别的租户**的历史删掉;
401
414
  * · 旧形还在删完 `task_active` **之后**才去复核它,那道复核因此恒真(自己刚把证据删了)—— 一段恒真的
402
- * "防护"比没有更坏。现在删除前的最后一次判据就是候选查询,删除后不再假装复核。
403
- * ⚠️ **残余**(如实成文,不假装消灭):`createRun` 只写 `task_active`(亲读 run-store-sql 坐实,它不碰
404
- * `session_meta`),所以本事务的会话行锁**不**与它构成互斥。一次恰好在候选查询之后、本事务提交之前
405
- * 提交的新 claim 仍会被这一轮删掉。窗口 = 级联本身的耗时,且与**在线** purge 协调器的同款窗口一模一样
406
- * (它那条 `SELECT … FOR UPDATE task_active` 同样锁不住缺席行)。真正的收口是让 `createRun` 也去锁
407
- * `session_meta` 那一行(共同锁序)—— 那是在线热路径的改动,归属另一次裁定,已进交接清单。
415
+ * "防护"比没有更坏。删除之前的判据是候选查询 + ②′ 那一读,删除后不再假装复核。
416
+ * 与认领的互斥(共同锁序,读写序律两半):
417
+ * · 认领半:`createRun` 在认领事务里**先锁根会话行**(`session_meta … FOR UPDATE`)再插 `task_active`(`run-store-sql.ts` 的 `createRun` 顶注)
418
+ * ⇒ 候选查询锁住会话行之后才来的认领等在那一行上,本事务提交后它锁定读到零行 ⇒ `sessionAbsent`,不建行;
419
+ * · 写者半(②′):候选查询开始时**已经持着**会话行锁、尚未提交的认领 —— 候选查询等它提交后才拿到行锁,而那条 `NOT EXISTS task_active`
420
+ * 读的是语句开始时的快照(PG 读已提交下非目标表按语句快照读),看不见刚提交的认领;②′ 是行锁之后的**新语句**锁定读(PG 新快照、TiDB 当前读),
421
+ * 认领已提交就读得到 ⇒ 这棵树跳过。被锁住的根行上不会再有新认领(认领先锁根行),所以 ②′ 之后到提交之间没有窗。
422
+ * `skipped` 仍由 {@link SqlRetentionStore.countProtectedSessions} 独立测得(非锁定读:PG 新语句看得见这一只,TiDB 读事务快照可能少计它一只 —— 回执的计数口径,不是删除判据)。
408
423
  *
409
424
  * 活引用的四条腿(与设计稿 §2 的清单对表,并如实标注一条**结构上不存在**的边):
410
425
  * · `task_active` —— 活跑 claim(在线协调器的第一道门,同一张表同一条判据);
@@ -3,8 +3,15 @@ import { TASK_LIST_ITEM_TABLE, TASK_LIST_META_TABLE, taskListKeyFor } from "./ta
3
3
  import { USER_INPUT_ACCEPTANCE_TABLE } from "./user-input-ledger-sql.js";
4
4
  import { ensureIndexes } from "./ensure-index.js";
5
5
  import { incarnationFromCell } from "../session-incarnation.js";
6
+ import { lockRootAndReadClaim, rowsOnSqlExec } from "./execution-placement-sql.js";
6
7
  export const MANAGED_RETENTION = "managed";
7
8
  export const UNMANAGED_RETENTION = "none";
9
+ export function retentionSweepRuns(sweep) {
10
+ return sweep.intervalSec > 0;
11
+ }
12
+ export function sqlStoreRetention(sweep) {
13
+ return retentionSweepRuns(sweep) ? MANAGED_RETENTION : UNMANAGED_RETENTION;
14
+ }
8
15
  export const RETENTION_AUDIT_TABLE = "retention_audit";
9
16
  export const RETENTION_HOLD_TABLE = "retention_hold";
10
17
  export const RETENTION_LEASE_TABLE = "retention_lease";
@@ -330,7 +337,12 @@ export class SqlRetentionStore {
330
337
  skippedOnAbort = (await this.sessionCandidates(conn, input)).length + (await this.countProtectedSessions(conn, input));
331
338
  throw new RetentionAborted("hold_in_force");
332
339
  }
333
- const doomed = await this.sessionCandidates(conn, input, true);
340
+ const candidates = await this.sessionCandidates(conn, input, true);
341
+ const rowsOn = rowsOnSqlExec(conn);
342
+ const doomed = [];
343
+ for (const c of candidates)
344
+ if ((await lockRootAndReadClaim(rowsOn, this.db.dialect, c.id)).activeTaskId === null)
345
+ doomed.push(c);
334
346
  const skipped = await this.countProtectedSessions(conn, input);
335
347
  skippedOnAbort = doomed.length + skipped;
336
348
  let deleted = 0;
@@ -42,6 +42,14 @@ export declare class SqlRunStore {
42
42
  * `task_id` ⇒ `{ ok:false, existing }` (no new row; the submission surface renders the receipt); otherwise the
43
43
  * session claim was the collision ⇒ `{ ok:false, activeTaskId }`. A dup on the ROW insert that names no
44
44
  * observable row is not attributable to either key ⇒ rethrown (loud), never reported as a busy session.
45
+ *
46
+ * 🔴 **根会话行锁(读写序律)**:认领之前,同一事务先锁根会话行(`session_meta … FOR UPDATE`,两方言同一句)。改根行住客 / 放置的写者
47
+ * (删除 / 换装 / 留存 …)也在自己的事务里先锁这一行 ⇒ 认领与它们在根行上按同一锁序串行:写者持锁期间认领等锁,写者提交后认领读到的是新行。
48
+ * **锁定读零行**(根行在受理之后被删)⇒ 不插认领、不建行,答 `{ ok:false, sessionAbsent:true }` —— 否则认领会挂在一只没有会话行的根上,
49
+ * 随后同 id 登记的新住客就带着别人的认领出生。全局锁序:根 `session_meta` → `task_active` / `task_run`(删除 / 留存同序;认领持有者的
50
+ * `whileHoldingClaim` 只取 `task_run` → `task_active`,从不碰 `session_meta` —— 它的回调**不许**写 `session_meta`:回调持 `task_active`
51
+ * 等根行、认领持根行等 `task_active`,即成环;初始放置写走自己的事务、按全局锁序取锁)。fresh 根行由 authorizer 在受理时登记(唯一铸点即登记点),
52
+ * 续跑与 wake 复用 checkpoint 的会话 —— 所以零行只在「受理之后被删」这一种竞争里出现。
45
53
  */
46
54
  createRun(taskId: string, sessionId: string, owner: string | null, instanceId: string, meta?: NewRunMeta): Promise<CreateRunOutcome>;
47
55
  /** Request cancel of a RUNNING run (durable, cross-replica). Sets the flag the owning instance's heartbeat
@@ -165,10 +173,12 @@ export declare class SqlRunStore {
165
173
  * ① 锁读这条 run 行且它在跑(`task_run … FOR UPDATE`);② 锁读会话的认领行且它是这条 run 的(`task_active … FOR UPDATE`);
166
174
  * ③ 刷新这条 run 的活性戳;④ `write` 在**同一个事务连接**上跑;提交即放锁。任一没读到 ⇒ 不持有,不刷新、`write` 不调。`write` 抛或提交失败 ⇒ 整只抛。
167
175
  * 🔴 **取锁序 = task_run → task_active**,与 `setTerminal` / `reapStale` / `reapSuspended` 同序(它们先改 run 行、再删认领行):两行用两条语句按序锁,
168
- * 不用连表锁读(那一形的先后由计划器定,与终局 / 收割相反时两只事务各攥一行成环)。`createRun` 只插认领行、`deleteBySession` 见认领行在就中止,
169
- * 都不与本序成环。锁住之后:收割的改行 / 终局与收割的删认领 / 别人的建行都排在本事务之后,收割器提交后重判 `updated_at` 看到的是刚刷的。
176
+ * 不用连表锁读(那一形的先后由计划器定,与终局 / 收割相反时两只事务各攥一行成环)。`createRun` 先锁根会话行再插认领行(本序从不碰 `session_meta`)、
177
+ * `deleteBySession` 先锁根会话行、见认领行在就中止,都不与本序成环。锁住之后:收割的改行 / 终局与收割的删认领 / 别人的建行都排在本事务之后,收割器提交后重判 `updated_at` 看到的是刚刷的。
170
178
  * 锁读没命中时 InnoDB 可能取一把间隙锁;本事务此后不往这两张表插任何行(见 space-commit 那条锁形注)。
171
179
  * 担保止于提交:判出「持有」的是这只事务;提交确认在路上迟到、这一跑随后失去认领,不在本栅栏的射程里(调用方的已知边界)。
180
+ * 🔴 **`write` 不许写 `session_meta`**:本事务持着 `task_active`,而 `createRun` 与改根行的写者都先锁根会话行再碰 `task_*`(全局锁序见 {@link createRun})——
181
+ * 回调去锁根行就是反序,与等着本会话认领的 `createRun` 各攥一行成环。门:`test/submission-root-contract.test.ts` 扫每个调用点的回调体。
172
182
  */
173
183
  whileHoldingClaim<T>(sessionId: string, taskId: string, write: (tx: SqlTxConn) => Promise<T>): Promise<ClaimFenced<T>>;
174
184
  /** The active run's taskId for a session (the `task_active` claim) — lets resume find the suspended run row. */
@@ -187,7 +197,7 @@ export declare class SqlRunStore {
187
197
  * `uq_task_run_idem` makes it at most one row. */
188
198
  getRunByIdem(idemKey: string): Promise<RunRecord | undefined>;
189
199
  /** Undo THIS submission's binding (`idem_key` + `idem_body_fingerprint` → NULL) — called only where the submit surface refuses
190
- * the submission before any work and terminalizes the row it just claimed (the sync leg's zero-work refusal, a device-placement
200
+ * the submission before any work and terminalizes the row it just claimed (the sync leg's zero-work refusal, an execution-placement
191
201
  * reject): the error answer invites a retry, and a retry with the same key must run, not replay the refusal. The row stays (it
192
202
  * is the refusal's record); only the binding is freed. */
193
203
  releaseIdemKey(taskId: string): Promise<void>;
@@ -81,6 +81,11 @@ export class SqlRunStore {
81
81
  let stage = "claim";
82
82
  try {
83
83
  await conn.begin();
84
+ const { rows: root } = await conn.query(this.q("SELECT session_id FROM session_meta WHERE session_id = ? FOR UPDATE", "SELECT session_id FROM session_meta WHERE session_id = $1 FOR UPDATE"), [sessionId]);
85
+ if (root.length === 0) {
86
+ await conn.rollback();
87
+ return { ok: false, sessionAbsent: true };
88
+ }
84
89
  const now = new Date();
85
90
  try {
86
91
  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]);
@@ -8,14 +8,14 @@
8
8
  * `errorCode` 直接是裸 `42703` / `ER_BAD_FIELD_ERROR`。
9
9
  *
10
10
  * ── 与 SCHEMA POLICY(`tidb-pool.ts` 顶注,clay 裁 2026-07-26)不冲突 ─────────────────────────────
11
- * 那条口径禁的是 **ALTER seam**:「代码是 schema 的唯一真源、存量库删库重建、`ensure*` 里不许再出现
12
- * `ALTER TABLE`」。本探针**一条 DDL 都不发**,它是只读的 information_schema 对账 —— 恰恰是那条口径的
11
+ * 那条口径禁的是 **ALTER seam**:「代码是 schema 的唯一真源、不做增量(缺列由部署者按拒启句打印的 ALTER 自己执行)、
12
+ * `ensure*` 里不许再出现 `ALTER TABLE`」。本探针**一条 DDL 都不发**,它是只读的 information_schema 对账 —— 恰恰是那条口径的
13
13
  * **执行面**:口径说「库必须等于代码」,在此之前没有任何东西检查过这句话是不是真的。两者一个是禁令
14
14
  * (不许代码偷改库)、一个是对账(库偏离了就别起),同向不同面。
15
15
  *
16
16
  * ── 为什么两个方向都拒(缺列 **和** 多列)──────────────────────────────────────────────────────
17
- * 缺列 = 库比代码旧(升级没重建库):读写会在用户路径上炸。多列 = 库比代码**新** —— 滚动升级窗里一个
18
- * **旧副本**连上了已被新副本重建过的库;它按旧列集读写,新列上的不变量(本例:`gen` 血统)在它眼里
17
+ * 缺列 = 库比代码旧(升级时库没跟上):读写会在用户路径上炸。多列 = 库比代码**新** —— 滚动升级窗里一个
18
+ * **旧副本**连上了已被新副本升过的库;它按旧列集读写,新列上的不变量(本例:`gen` 血统)在它眼里
19
19
  * 根本不存在,于是它会悄悄写出违反新契约的行。后者比前者更隐蔽,所以不做「幽灵列只警告」。
20
20
  * ⇒ 运维顺序因此是**先升库再升副本**(见 `docs/DEPLOY-PREREQS.md` 滚动升级窗那一节)。
21
21
  *
@@ -85,7 +85,17 @@ export interface TableMismatch {
85
85
  }
86
86
  /** 对账(纯函数:期望 × 实况 → 不符表清单)。 */
87
87
  export declare function diffSchema(expected: Map<string, ParsedTable>, live: Map<string, Set<string>>): TableMismatch[];
88
- /** 拒启文案(表名 + 缺/多列 + 两条路;ADD COLUMN 句由解析结果**生成**)。 */
88
+ /**
89
+ * 逐表段 + 闭集出路段(多行):整库对账的拒启句与单表探针的拒因共用这一份句形(`schema-drift-sentence.ts` 的 `describeProbedTableDrift` 读它)。
90
+ * ADD COLUMN 句由解析结果**生成**,不手写。
91
+ *
92
+ * 出路段 = {@link SCHEMA_MISMATCH_WAYS_OUT}(**闭集三条**,常量;句子的属主在 `schema-drift-sentence.ts`):
93
+ * ① 部署与这个库匹配的构建;② 库比构建旧(缺列)⇒ 部署者自己执行上面逐字打印的 `ALTER TABLE … ADD COLUMN …`(列集对账与拒启句永不改库);
94
+ * ③ 库比构建新(多列)⇒ 部署持有那些列的构建(先升库、再升副本)。
95
+ * 🔴 **没有销毁性的一条**:对带数据的库,任何「删库 / 重建」的指路都是教人销毁数据 —— 这里不写,拒启句的钉也守着(`test/schema-consistency.test.ts` ⑤)。
96
+ */
97
+ export declare function describeMismatchedTables(mismatches: readonly TableMismatch[]): string[];
98
+ /** 拒启文案(表头 + 逐表段与闭集三条出路 {@link describeMismatchedTables})。 */
89
99
  export declare function describeSchemaMismatch(dialect: SchemaDialect, mismatches: readonly TableMismatch[]): string;
90
100
  /**
91
101
  * 启动期对账:**建表之后**跑,不符即抛(= 拒启)。两方言 + InnoDB 同腿(MySQL 协议共用 `mysql` 臂)。
@@ -1,5 +1,6 @@
1
1
  import { recordEnsureStatements } from "./schema-ensure-legs.js";
2
2
  import { readLiveColumns } from "./schema-live-columns.js";
3
+ import { SCHEMA_MISMATCH_WAYS_OUT } from "./schema-drift-sentence.js";
3
4
  import { StoreBootRefusalError } from "./store-contracts.js";
4
5
  const QUOTES = new Set(["'", '"', "`"]);
5
6
  function stripLineComments(sql) {
@@ -120,13 +121,8 @@ export function diffSchema(expected, live) {
120
121
  }
121
122
  return out;
122
123
  }
123
- export function describeSchemaMismatch(dialect, mismatches) {
124
- const lines = [
125
- `${SCHEMA_MISMATCH_ERROR_CODE}: the ${dialect === "pg" ? "PostgreSQL" : "MySQL-protocol"} database does not match this build's schema — refusing to start.`,
126
- "This repo ships no ALTER seam (schema policy: the code is the single source of truth), so a drifted database is",
127
- "a deployment-order problem, not something the process may quietly work around.",
128
- "",
129
- ];
124
+ export function describeMismatchedTables(mismatches) {
125
+ const lines = [];
130
126
  for (const m of mismatches) {
131
127
  if (m.missing.length > 0) {
132
128
  lines.push(` table ${m.table}: MISSING column(s) ${m.missing.map((c) => c.name).join(", ")} — the database is OLDER than this build.`);
@@ -138,7 +134,17 @@ export function describeSchemaMismatch(dialect, mismatches) {
138
134
  lines.push(` No ALTER is suggested for this direction: deploy the build that owns those columns (upgrade the database FIRST, then the replicas).`);
139
135
  }
140
136
  }
141
- lines.push("", " Two ways out: (1) deploy the build whose code matches this database; (2) rebuild the database from this build", " (DROP DATABASE <name>; CREATE DATABASE <name>; restart — every table is recreated by ensure*Schema at boot).");
137
+ lines.push("", ...SCHEMA_MISMATCH_WAYS_OUT);
138
+ return lines;
139
+ }
140
+ export function describeSchemaMismatch(dialect, mismatches) {
141
+ const lines = [
142
+ `${SCHEMA_MISMATCH_ERROR_CODE}: the ${dialect === "pg" ? "PostgreSQL" : "MySQL-protocol"} database does not match this build's schema — refusing to start.`,
143
+ "Schema policy: the code is the single source of truth, and the column check that produced this refusal never alters a database —",
144
+ "so a drifted database is a deployment-order problem, not something the process may quietly work around.",
145
+ "",
146
+ ...describeMismatchedTables(mismatches),
147
+ ];
142
148
  return lines.join("\n");
143
149
  }
144
150
  export async function assertSchemaMatchesCode(read, dialect) {
@@ -0,0 +1,27 @@
1
+ import type { SqlDialect } from "./sql-driver.js";
2
+ /** 「库比构建旧」那一条出路的两个可变件:旧在哪(括注)、部署者要自己做的那件事。其余字一个不变。 */
3
+ export interface OlderWayOut {
4
+ /** 括注:库旧在哪(例:`missing columns`)。 */
5
+ readonly when: string;
6
+ /** 部署者自己执行的动作(接在冒号后;句尾的「列集对账不改库」声明由本函数补)。 */
7
+ readonly action: string;
8
+ }
9
+ /**
10
+ * 出路段:**闭集三条**,编号 (1)(2)(3) 与顺序固定,与不符的方向无关(每一条自带适用条件)。
11
+ * 🔴 **没有销毁性的一条**:对带数据的库,任何「删库 / 重建」的指路都是教人销毁数据。
12
+ */
13
+ export declare function schemaWaysOut(older: OlderWayOut): readonly string[];
14
+ /** 缺列方向的出路段(整库对账与单表探针共用;逐字钉在 `test/schema-consistency.test.ts` ⑤)。 */
15
+ export declare const SCHEMA_MISMATCH_WAYS_OUT: readonly string[];
16
+ /** 探针读口:一条 SQL → `{ rows }`(各 `assert*Schema` 的 `query` 形)。 */
17
+ export type ProbeQuery = (sql: string) => Promise<{
18
+ rows: Record<string, unknown>[];
19
+ }>;
20
+ /**
21
+ * 单表探针抓到可证缺列后,拒因里「哪几列缺、该跑哪几条 ALTER」+ 闭集出路这一整段(多行文本,不含探针自己的首句与底层错误)。
22
+ *
23
+ * 读不出库的列册(权限 / 超时)或对账看不到探针点名的表有缺列(探针与建表语句互相矛盾)⇒ **响亮**地说「ALTER 没能打印」及原因,
24
+ * 出路仍是同一份闭集(旧一条改成「按建表语句自己加」)—— 绝不退回销毁性指路,也不悄悄吞掉这一段。
25
+ */
26
+ export declare function describeProbedTableDrift(query: ProbeQuery, dialect: SqlDialect, tables: readonly string[]): Promise<string>;
27
+ //# sourceMappingURL=schema-drift-sentence.d.ts.map
@@ -0,0 +1,38 @@
1
+ import { readLiveColumns } from "./schema-live-columns.js";
2
+ export function schemaWaysOut(older) {
3
+ return [
4
+ " Ways out (these three and no others):",
5
+ " (1) deploy the build whose code matches this database;",
6
+ ` (2) the database is OLDER than this build (${older.when}): ${older.action} — the column check and this refusal never alter a database (the only DDL this service runs by itself on a table that already exists is idempotent index creation and the registered one-time migrations, see MIGRATION.md);`,
7
+ " (3) the database is NEWER than this build (unknown columns): deploy the build that owns those columns — upgrade the database first, then the replicas.",
8
+ ];
9
+ }
10
+ export const SCHEMA_MISMATCH_WAYS_OUT = schemaWaysOut({
11
+ when: "missing columns",
12
+ action: "run the ALTER TABLE … ADD COLUMN statements printed above yourself",
13
+ });
14
+ export async function describeProbedTableDrift(query, dialect, tables) {
15
+ const d = dialect === "pg" ? "pg" : "mysql";
16
+ const read = async (sql) => (await query(sql)).rows;
17
+ let why;
18
+ try {
19
+ const sc = await import("./schema-consistency.js");
20
+ const diff = sc.diffSchema(await sc.expectedTableShapes(d), await readLiveColumns(read, d));
21
+ const missingOnly = diff.filter((m) => tables.includes(m.table) && m.missing.length > 0).map((m) => ({ ...m, ghost: [] }));
22
+ if (missingOnly.length > 0)
23
+ return sc.describeMismatchedTables(missingOnly).join("\n");
24
+ why = "this build's table definitions list no column that the database lacks on that table, so the probe and the definitions disagree";
25
+ }
26
+ catch (err) {
27
+ why = `information_schema.columns could not be read (${err instanceof Error ? err.message : String(err)})`;
28
+ }
29
+ return [
30
+ ` table ${tables.join(", ")}: a column this build reads is MISSING — the database is OLDER than this build; the ALTER statements could not be printed: ${why}.`,
31
+ "",
32
+ ...schemaWaysOut({
33
+ when: "missing columns",
34
+ action: "add each missing column yourself with an ALTER TABLE … ADD COLUMN statement built from this build's definition of the table",
35
+ }),
36
+ ].join("\n");
37
+ }
38
+ //# sourceMappingURL=schema-drift-sentence.js.map
@@ -3,32 +3,83 @@
3
3
  *
4
4
  * ## 规则(一句)
5
5
  * 「fork 拷到哪一条」只有引擎一只判官:`settledForkOptions(源会话)` 答 `{}`(整份日志)或 `{ entryId: <在飞轮的父>, position: "at" }`(拷到那一条为止)。
6
- * 本仓的店不写第二份「什么算在飞」—— local 店把它的答案原样交给引擎自己的 `repo.fork`;两只 SQL 孪生没有 `repo.fork`(它们按行搬字节),所以这里只管**按答案拷**:
6
+ * 本仓的店不写第二份「什么算在飞」—— local 店把它的答案原样交给引擎自己的 `repo.fork`;两只 SQL 孪生没有 `repo.fork`(它们按行搬字节),按答案挑行也是引擎的一只
7
+ * (`selectForkRows`,引擎 7.59.0 包根导出:同一条父链走法、同一组拒词);本文件只给它这两只孪生的行形(`entry_id` / `parent_id` 两列):
7
8
  * · `{}` ⇒ 整份日志(含别的分支),叶 = 源会话的叶(与引擎 `getEntriesToFork` 的无 `entryId` 臂同);
8
9
  * · `at` 点 ⇒ 根到分叉点那一条**路径**上的条目(与引擎 `getPathToRoot(entryId)` 同:路径,不是日志前缀,别的分支不随),叶 = 分叉点。
9
10
  *
10
11
  * ## 为什么拷贝仍读整份日志、判官却读唤醒出来的会话
11
12
  * 拷贝要的是**整份**持久日志(压缩地板绕过 —— 唤醒只装 `[地板..叶]`,从唤醒里拷会丢地板之前的全部历史);判官要的只是叶链上最近几条(它是引擎的一份纯函数,
12
13
  * 吃一只 `Session`):唤醒出来的会话 `getEntry` 在地板之下会回读持久日志,所以判官在压缩过的会话上也看得见整条叶链。
14
+ *
15
+ * ## fork 续写的店契约(本文件下半)
16
+ * 三店同一只私有体 `forkTo(sourceId, owner, { id, key? })`:目标行原子新建(唯一创建点)· 撞已在按属主与 fork 键分类({@link classifyForkTarget})·
17
+ * local 崩溃中间态 `forking` 只许同键重试补齐 · fork 键此后每个写携带(整段换装经 `carriedRowCells`)。core 契约 `fork(sourceId, owner?)` = {@link forkByFreshId}。
13
18
  */
14
- import { settledForkOptions, type SessionStorage } from "@sema-agent/core";
15
- /** 引擎答的分叉点(`{}` = 整份;`entryId` + `position: "at"` = 拷到这一条为止)。 */
16
- export type ForkPoint = Awaited<ReturnType<typeof settledForkOptions>>;
19
+ import { type ForkPoint, type ForkRowShape, type SessionStorage } from "@sema-agent/core";
17
20
  /** 问引擎:这只(已唤醒的)源会话从哪里拷。SQL 孪生用它;local 店手里就是引擎的 `Session`,直接调 `settledForkOptions`。 */
18
21
  export declare function forkPointOf(storage: SessionStorage): Promise<ForkPoint>;
19
- /** 整份日志里一行的父链两键(其余列原样搬,不读内容)。 */
22
+ /** 两只 SQL 孪生整份日志里的一行(两孪生选同一组列):父链两键交引擎挑行,其余三列原样搬进新会话、不读内容。 */
20
23
  export interface ForkLogRow {
21
24
  readonly entry_id: string;
22
25
  readonly parent_id: string | null;
26
+ readonly type: string;
27
+ readonly payload: unknown;
28
+ readonly ts: unknown;
23
29
  }
24
30
  /**
25
- * 按引擎的分叉点从整份日志(oldest-first)里挑出要拷的行,与新会话的叶。行的相对顺序保留(调用方按顺序重铸 seq)。
26
- *
27
- * 分叉点不在日志里 ⇒ `invalid_fork_target`、路径中途的父不在日志里 ⇒ `invalid_session`(引擎 `getEntriesToFork` / `getPathToRoot` 同词同句);分叉点之上的父链成环 ⇒ `invalid_session`、
28
- * 句子与引擎的父链守卫同(守卫类不在包根,走法是同一个意思:走过的条目再踩一次 = 环;沿用它的句子,让同一件事在三种后端上说同一句话);`position` 不是 `at` ⇒ 响亮拒(本函数只认引擎今天答的两形)。
31
+ * 两只 SQL 孪生的行怎么交给引擎的 `selectForkRows`(按答案挑行 + 新会话的叶;分叉点不在日志里 ⇒ `invalid_fork_target`、路径中途悬空父 / 父链成环 ⇒ `invalid_session`,
32
+ * 引擎的原词原句)。本仓不再自写父链走法 —— 那曾是与引擎 `getPathToRoot` 同义的第二份。
33
+ */
34
+ export declare const FORK_LOG_ROW_SHAPE: ForkRowShape<ForkLogRow>;
35
+ /**
36
+ * `forkTo` 的目标:调用方给的新会话 id 与(可选)fork 键。`key` 是服务端拼的不透明串(路由按「源 + 调用方 nonce」派生),存进新行那一格
37
+ * `fork_key`(SQL)/ `forkKey`(local):一个事实 = 「建这一行的那次 fork 请求的键」。缺 = 非键控 fork(core 契约的现铸形)。
38
+ */
39
+ export interface ForkTarget {
40
+ readonly id: string;
41
+ readonly key?: string;
42
+ }
43
+ /**
44
+ * fork 键的**唯一**拼法(路由带键 ⇒ `forkTo(…, { id: forkId, key: forkKeyOf(源, forkNonce) })`):服务端拼的不透明串 —— 源 id 与调用方 nonce 的摘要,
45
+ * 定长(远短于 `fork_key` 的 255),源进摘要 ⇒ 「异源同 nonce」拼出不同的键(撞同一只目标即 409,不当重放)。调用方不读它,只靠同键重试同答。
29
46
  */
30
- export declare function forkCopyOf<R extends ForkLogRow>(rows: readonly R[], sourceLeafId: string | null, point: ForkPoint): {
31
- rows: R[];
32
- leafId: string | null;
47
+ export declare function forkKeyOf(sourceId: string, forkNonce: string): string;
48
+ /**
49
+ * `forkTo` 的结果(`null` = 源不在,与 core `fork` 的 `null` 同义):
50
+ * · `created` —— 目标行由这一次新建(唯一创建点:SQL 事务首句 INSERT 主键排他;local 两把写锁内「目标格不在 ∧ 目标存档不在」才写目标格);
51
+ * · `replayed` —— 目标已在,属主 = 本次新属主 ∧ fork 键相等(同键重试同答;local `forking` 中间态在两把锁内补拷存档后也答它);
52
+ * · `occupied` —— 目标已在而不是这次请求的那一行:`sameOwner: false` ⇒ 路由答与未知源同形的 404(不泄存在),`true` ⇒ 409 `conflict.fork_id_taken`(终局)。
53
+ */
54
+ export type ForkToOutcome = {
55
+ readonly outcome: "created";
56
+ readonly id: string;
57
+ } | {
58
+ readonly outcome: "replayed";
59
+ readonly id: string;
60
+ } | {
61
+ readonly outcome: "occupied";
62
+ readonly sameOwner: boolean;
33
63
  };
64
+ /** 撞上已在的目标时的唯一分类(三店同调):属主不同 ⇒ `occupied{sameOwner:false}`;属主同 ∧ 本次带键 ∧ 键相等 ⇒ `replayed`;其余(含无键的普通会话、无键请求)⇒ `occupied{sameOwner:true}`。 */
65
+ export declare function classifyForkTarget(target: ForkTarget, existing: {
66
+ readonly owner: string | null | undefined;
67
+ readonly forkKey: string | null | undefined;
68
+ }, forkOwner: string | null | undefined): ForkToOutcome;
69
+ /**
70
+ * SQL 输方回滚后在新视图里读不到目标(赢方在分类之前回滚或被删),整笔重跑一次之后仍读不到 —— 不猜,响亮抛;路由答 503 `permanence: "transient"`。
71
+ * 具名类型:按类型认,不按文案。
72
+ */
73
+ export declare class ForkTargetUnsettledError extends Error {
74
+ readonly targetId: string;
75
+ readonly code = "state.fork_target_unsettled";
76
+ constructor(targetId: string);
77
+ }
78
+ /** 三店的 `forkTo` 形(私有体,经店面 `forkTo` 交出)。 */
79
+ export type ForkToFn = (sourceId: string, owner: string | null, target: ForkTarget) => Promise<ForkToOutcome | null>;
80
+ /**
81
+ * core 契约 `fork(sourceId, owner?) → string | null` = `forkTo(…, { id: uuidv7() })` 的投影(三店同一只):`created` ⇒ 新 id;源不在 ⇒ `null`。
82
+ * 新铸的 uuidv7 撞上已在的行(`occupied` / `replayed`)不是一个答案,是不变量破 ⇒ 响亮抛 —— 无键请求永不答 `replayed`。
83
+ */
84
+ export declare function forkByFreshId(forkTo: ForkToFn, sourceId: string, owner: string | null | undefined): Promise<string | null>;
34
85
  //# sourceMappingURL=session-fork.d.ts.map