@sema-agent/server 7.138.0 → 7.139.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/MIGRATION.md +276 -7
  2. package/README.md +7 -4
  3. package/README.zh-CN.md +6 -4
  4. package/USAGE.md +73 -29
  5. package/deploy/sema-up/README.md +1 -1
  6. package/deploy/sema-up/lib/lanes.json +3 -2
  7. package/dist/adoption/wire.d.ts +3 -3
  8. package/dist/approval-card.d.ts +20 -20
  9. package/dist/bench/s1/arms.d.ts +1 -1
  10. package/dist/bench/s1/arms.js +1 -3
  11. package/dist/bench/s1/run-firm.js +2 -2
  12. package/dist/boot/device-lane.d.ts +8 -3
  13. package/dist/boot/device-lane.js +7 -2
  14. package/dist/boot/execution-env.d.ts +4 -2
  15. package/dist/boot/execution-env.js +16 -24
  16. package/dist/boot/execution-lane.d.ts +1 -1
  17. package/dist/boot/governance-seams.d.ts +3 -2
  18. package/dist/boot/governance-seams.js +9 -11
  19. package/dist/boot/memory-consolidation.js +2 -2
  20. package/dist/boot/resolve-spec.d.ts +1 -1
  21. package/dist/boot/resolve-spec.js +8 -9
  22. package/dist/boot/retention-lane.d.ts +5 -5
  23. package/dist/boot/retention-lane.js +0 -6
  24. package/dist/boot/runner-deps.d.ts +3 -1
  25. package/dist/boot/runner-deps.js +2 -1
  26. package/dist/boot/session-faces.d.ts +0 -3
  27. package/dist/boot/session-faces.js +15 -8
  28. package/dist/boot/stage-02-web-admin-pull.js +2 -2
  29. package/dist/boot/stage-05-execution-env.js +2 -2
  30. package/dist/boot/stage-06-runners.js +0 -1
  31. package/dist/boot/stage-09-leader.js +1 -2
  32. package/dist/boot/stage-10-http-server.js +2 -2
  33. package/dist/boot/stores.js +4 -4
  34. package/dist/boot/web-admin.js +2 -2
  35. package/dist/capabilities/mcp-probe.js +1 -1
  36. package/dist/capabilities/scenarios.d.ts +1 -1
  37. package/dist/capability-facts.d.ts +15 -7
  38. package/dist/capability-facts.js +8 -3
  39. package/dist/config-catalog.js +7 -7
  40. package/dist/config-invariants.d.ts +23 -3
  41. package/dist/config-invariants.js +42 -0
  42. package/dist/config-provider.d.ts +19 -24
  43. package/dist/config-provider.js +27 -12
  44. package/dist/config-types.d.ts +10 -5
  45. package/dist/config.d.ts +33 -0
  46. package/dist/config.js +44 -15
  47. package/dist/delegated-session-owner.d.ts +2 -2
  48. package/dist/deployment-shape.d.ts +1 -1
  49. package/dist/device-enrollment.d.ts +41 -2
  50. package/dist/device-enrollment.js +27 -12
  51. package/dist/device-store.d.ts +58 -85
  52. package/dist/device-store.js +19 -49
  53. package/dist/device-ws-hub.js +2 -1
  54. package/dist/env-name-allowlist-knobs.d.ts +5 -4
  55. package/dist/exec-cut.d.ts +60 -0
  56. package/dist/exec-cut.js +36 -0
  57. package/dist/execution-lane-caps.d.ts +19 -0
  58. package/dist/execution-lane-caps.js +10 -0
  59. package/dist/execution-placement.d.ts +259 -0
  60. package/dist/execution-placement.js +199 -0
  61. package/dist/field-conflict.d.ts +3 -2
  62. package/dist/field-conflict.js +1 -0
  63. package/dist/hosted-posture.d.ts +2 -2
  64. package/dist/http/admission.js +8 -19
  65. package/dist/http/checkpoint-ctx.d.ts +21 -12
  66. package/dist/http/checkpoint-ctx.js +4 -2
  67. package/dist/http/claimed-run.d.ts +1 -1
  68. package/dist/http/durable-run-leg.js +9 -5
  69. package/dist/http/execution-context.d.ts +218 -0
  70. package/dist/http/execution-context.js +330 -0
  71. package/dist/http/resume-legs.js +50 -20
  72. package/dist/http/route-ctx.d.ts +14 -8
  73. package/dist/http/routes/background.d.ts +29 -1
  74. package/dist/http/routes/background.js +29 -2
  75. package/dist/http/routes/devices.d.ts +13 -19
  76. package/dist/http/routes/devices.js +21 -26
  77. package/dist/http/routes/session-sync.js +110 -15
  78. package/dist/http/routes/sessions.d.ts +6 -0
  79. package/dist/http/routes/sessions.js +115 -11
  80. package/dist/http/routes/side-query.d.ts +12 -4
  81. package/dist/http/routes/side-query.js +4 -5
  82. package/dist/http/routes/tasks.js +37 -31
  83. package/dist/http/server.d.ts +35 -19
  84. package/dist/http/server.js +11 -0
  85. package/dist/http/session-inherit-env.d.ts +3 -4
  86. package/dist/http/session-inherit-env.js +1 -1
  87. package/dist/http/wire-types.d.ts +8 -12
  88. package/dist/index.d.ts +1 -0
  89. package/dist/memory-sync-client.d.ts +53 -14
  90. package/dist/memory-sync-client.js +196 -116
  91. package/dist/memory-sync.d.ts +1 -1
  92. package/dist/memory-sync.js +3 -2
  93. package/dist/observability/cost-taxonomy.d.ts +2 -2
  94. package/dist/observability/fail-open.d.ts +12 -0
  95. package/dist/observability/fail-open.js +12 -0
  96. package/dist/observability/run-terminal.js +0 -1
  97. package/dist/orchestration/workflow-notify-journal.js +4 -4
  98. package/dist/org-memory-admission.d.ts +4 -3
  99. package/dist/org-memory-admission.js +6 -6
  100. package/dist/parked-decide.d.ts +34 -9
  101. package/dist/parked-decide.js +12 -19
  102. package/dist/parked-revive-surface.d.ts +12 -15
  103. package/dist/parked-revive-surface.js +3 -6
  104. package/dist/peer-seat.d.ts +13 -7
  105. package/dist/peer-seat.js +37 -3
  106. package/dist/plugins/approval-ask-store-file.d.ts +7 -0
  107. package/dist/plugins/approval-ask-store-file.js +11 -0
  108. package/dist/plugins/approval-ask-store-memory.d.ts +6 -0
  109. package/dist/plugins/approval-ask-store-memory.js +12 -0
  110. package/dist/plugins/approval-ask-store-sql.d.ts +22 -5
  111. package/dist/plugins/approval-ask-store-sql.js +30 -12
  112. package/dist/plugins/caching-session-store.d.ts +6 -2
  113. package/dist/plugins/caching-session-store.js +9 -3
  114. package/dist/plugins/checkpoint-store-sql.d.ts +6 -5
  115. package/dist/plugins/checkpoint-store-sql.js +5 -7
  116. package/dist/plugins/device-store-sql.d.ts +25 -51
  117. package/dist/plugins/device-store-sql.js +49 -103
  118. package/dist/plugins/execution-placement-sql.d.ts +151 -0
  119. package/dist/plugins/execution-placement-sql.js +223 -0
  120. package/dist/plugins/file-history-store-sql.d.ts +7 -6
  121. package/dist/plugins/file-history-store-sql.js +5 -5
  122. package/dist/plugins/file-run-store.d.ts +6 -1
  123. package/dist/plugins/file-run-store.js +4 -0
  124. package/dist/plugins/fork-routing-session-store.d.ts +6 -0
  125. package/dist/plugins/fork-routing-session-store.js +16 -0
  126. package/dist/plugins/local-session-store.d.ts +149 -50
  127. package/dist/plugins/local-session-store.js +382 -107
  128. package/dist/plugins/memory-key-guards.d.ts +4 -0
  129. package/dist/plugins/memory-key-guards.js +1 -0
  130. package/dist/plugins/memory-run-store.d.ts +1 -1
  131. package/dist/plugins/one-time-migrations.d.ts +111 -3
  132. package/dist/plugins/one-time-migrations.js +380 -10
  133. package/dist/plugins/outcome-ledger-sql.d.ts +2 -2
  134. package/dist/plugins/permission-rule-store-sql.d.ts +13 -15
  135. package/dist/plugins/permission-rule-store-sql.js +13 -16
  136. package/dist/plugins/pg-pool.d.ts +7 -3
  137. package/dist/plugins/pg-session-storage.d.ts +31 -20
  138. package/dist/plugins/pg-session-storage.js +120 -52
  139. package/dist/plugins/remote-env-adb.d.ts +20 -4
  140. package/dist/plugins/remote-env-adb.js +70 -62
  141. package/dist/plugins/remote-env-device.d.ts +30 -12
  142. package/dist/plugins/remote-env-device.js +18 -17
  143. package/dist/plugins/remote-env-e2b.d.ts +18 -4
  144. package/dist/plugins/remote-env-e2b.js +41 -42
  145. package/dist/plugins/remote-env-file-error.d.ts +2 -0
  146. package/dist/plugins/remote-env-file-error.js +1 -1
  147. package/dist/plugins/remote-env-host.d.ts +21 -8
  148. package/dist/plugins/remote-env-host.js +66 -70
  149. package/dist/plugins/remote-env-k8s.d.ts +11 -16
  150. package/dist/plugins/remote-env-k8s.js +61 -54
  151. package/dist/plugins/remote-env-local-docker.d.ts +29 -6
  152. package/dist/plugins/remote-env-local-docker.js +46 -59
  153. package/dist/plugins/remote-env-ssh.d.ts +16 -4
  154. package/dist/plugins/remote-env-ssh.js +72 -56
  155. package/dist/plugins/remote-shell.d.ts +100 -3
  156. package/dist/plugins/remote-shell.js +166 -2
  157. package/dist/plugins/retention-store-sql.d.ts +9 -6
  158. package/dist/plugins/retention-store-sql.js +7 -1
  159. package/dist/plugins/run-store-sql.d.ts +13 -3
  160. package/dist/plugins/run-store-sql.js +5 -0
  161. package/dist/plugins/schema-consistency.d.ts +15 -5
  162. package/dist/plugins/schema-consistency.js +14 -8
  163. package/dist/plugins/schema-drift-sentence.d.ts +27 -0
  164. package/dist/plugins/schema-drift-sentence.js +38 -0
  165. package/dist/plugins/session-fork.d.ts +54 -0
  166. package/dist/plugins/session-fork.js +38 -1
  167. package/dist/plugins/session-row-sql.d.ts +22 -0
  168. package/dist/plugins/session-row-sql.js +81 -0
  169. package/dist/plugins/session-store.d.ts +1 -1
  170. package/dist/plugins/store-backend.d.ts +1 -1
  171. package/dist/plugins/store-backend.js +19 -7
  172. package/dist/plugins/store-contracts.d.ts +25 -5
  173. package/dist/plugins/tidb-pool.d.ts +12 -7
  174. package/dist/plugins/tidb-pool.js +9 -3
  175. package/dist/plugins/tidb-session-store.d.ts +57 -26
  176. package/dist/plugins/tidb-session-store.js +118 -50
  177. package/dist/plugins/tool-result-store-sql.d.ts +3 -2
  178. package/dist/plugins/tool-result-store-sql.js +18 -10
  179. package/dist/project-memory.d.ts +9 -15
  180. package/dist/project-memory.js +5 -4
  181. package/dist/request-key-closure.js +1 -1
  182. package/dist/request-value-closure.d.ts +6 -7
  183. package/dist/request-value-closure.js +1 -3
  184. package/dist/run-local.d.ts +6 -0
  185. package/dist/run-local.js +24 -20
  186. package/dist/runs.d.ts +2 -1
  187. package/dist/runs.js +3 -1
  188. package/dist/security.d.ts +38 -12
  189. package/dist/security.js +8 -2
  190. package/dist/server-secret-env.d.ts +9 -5
  191. package/dist/session-children-retire.d.ts +27 -9
  192. package/dist/session-children-retire.js +9 -5
  193. package/dist/session-incarnation.d.ts +19 -9
  194. package/dist/session-incarnation.js +4 -2
  195. package/dist/session-row.d.ts +114 -5
  196. package/dist/session-row.js +55 -5
  197. package/dist/session-titler.d.ts +2 -2
  198. package/dist/task-a2a.d.ts +1 -1
  199. package/dist/task-cwd.d.ts +6 -1
  200. package/dist/task-mcp.d.ts +3 -3
  201. package/dist/tool-approval.d.ts +6 -0
  202. package/dist/tool-approval.js +8 -1
  203. package/dist/trace/project.d.ts +1 -0
  204. package/dist/trace/project.js +1 -0
  205. package/dist/trace/self-minted-wire.d.ts +111 -2
  206. package/dist/trace/self-minted-wire.js +38 -1
  207. package/dist/url-refusal.d.ts +1 -1
  208. package/dist/web-admin-client/apply-effective.d.ts +17 -3
  209. package/dist/web-admin-client/apply-effective.js +20 -10
  210. package/dist/web-admin-client/skills-mcp.js +3 -2
  211. package/dist/web-admin-client/types.d.ts +6 -0
  212. package/package.json +3 -3
  213. package/wire-changes/server@7.139.0.json +348 -0
  214. package/dist/http/device-placement.d.ts +0 -175
  215. package/dist/http/device-placement.js +0 -145
@@ -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
@@ -404,6 +404,7 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
404
404
  * ① `retention_hold` 哨兵行的**锁读** —— `held=1` ⇒ 零删、全 skip 返回;锁持有到 COMMIT;
405
405
  * ② **一条**加锁读同时完成「候选 + 活引用重查」(谓词下推 + `FOR UPDATE`,见
406
406
  * {@link SqlRetentionStore.sessionCandidates});
407
+ * ②′ 读写序律的写者半:锁住候选行之后,逐只用**本事务的连接、新语句**锁定读一次认领(`lockRootAndReadClaim`);有认领的树整棵跳过;
407
408
  * ③ 按 E21 顺序逐表删 → 墓碑 → 审计行(同事务)→ COMMIT。
408
409
  *
409
410
  * 🔴 活引用重查为什么是**候选查询本身**而不是第二条查询(codex 交叉复审 R1 的两条 high 合并采纳):
@@ -411,12 +412,14 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
411
412
  * · `FOR UPDATE` 让这些会话行在本事务期间不可被 `touch()` 拉回视界、也不可被 session-sync 改写属主 ——
412
413
  * 旧形(不加锁 + 尾部无条件删 `session_event`)可以在属主被并发改写后,把**别的租户**的历史删掉;
413
414
  * · 旧形还在删完 `task_active` **之后**才去复核它,那道复核因此恒真(自己刚把证据删了)—— 一段恒真的
414
- * "防护"比没有更坏。现在删除前的最后一次判据就是候选查询,删除后不再假装复核。
415
- * ⚠️ **残余**(如实成文,不假装消灭):`createRun` 只写 `task_active`(亲读 run-store-sql 坐实,它不碰
416
- * `session_meta`),所以本事务的会话行锁**不**与它构成互斥。一次恰好在候选查询之后、本事务提交之前
417
- * 提交的新 claim 仍会被这一轮删掉。窗口 = 级联本身的耗时,且与**在线** purge 协调器的同款窗口一模一样
418
- * (它那条 `SELECT … FOR UPDATE task_active` 同样锁不住缺席行)。真正的收口是让 `createRun` 也去锁
419
- * `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 读事务快照可能少计它一只 —— 回执的计数口径,不是删除判据)。
420
423
  *
421
424
  * 活引用的四条腿(与设计稿 §2 的清单对表,并如实标注一条**结构上不存在**的边):
422
425
  * · `task_active` —— 活跑 claim(在线协调器的第一道门,同一张表同一条判据);
@@ -3,6 +3,7 @@ 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";
8
9
  export function retentionSweepRuns(sweep) {
@@ -336,7 +337,12 @@ export class SqlRetentionStore {
336
337
  skippedOnAbort = (await this.sessionCandidates(conn, input)).length + (await this.countProtectedSessions(conn, input));
337
338
  throw new RetentionAborted("hold_in_force");
338
339
  }
339
- 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);
340
346
  const skipped = await this.countProtectedSessions(conn, input);
341
347
  skippedOnAbort = doomed.length + skipped;
342
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
@@ -11,6 +11,10 @@
11
11
  * ## 为什么拷贝仍读整份日志、判官却读唤醒出来的会话
12
12
  * 拷贝要的是**整份**持久日志(压缩地板绕过 —— 唤醒只装 `[地板..叶]`,从唤醒里拷会丢地板之前的全部历史);判官要的只是叶链上最近几条(它是引擎的一份纯函数,
13
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}。
14
18
  */
15
19
  import { type ForkPoint, type ForkRowShape, type SessionStorage } from "@sema-agent/core";
16
20
  /** 问引擎:这只(已唤醒的)源会话从哪里拷。SQL 孪生用它;local 店手里就是引擎的 `Session`,直接调 `settledForkOptions`。 */
@@ -28,4 +32,54 @@ export interface ForkLogRow {
28
32
  * 引擎的原词原句)。本仓不再自写父链走法 —— 那曾是与引擎 `getPathToRoot` 同义的第二份。
29
33
  */
30
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,不当重放)。调用方不读它,只靠同键重试同答。
46
+ */
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;
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>;
31
85
  //# sourceMappingURL=session-fork.d.ts.map
@@ -1,4 +1,5 @@
1
- import { StoredSession, settledForkOptions } from "@sema-agent/core";
1
+ import { SessionError, StoredSession, settledForkOptions, uuidv7 } from "@sema-agent/core";
2
+ import { createHash } from "node:crypto";
2
3
  export async function forkPointOf(storage) {
3
4
  return settledForkOptions(new StoredSession(storage));
4
5
  }
@@ -6,4 +7,40 @@ export const FORK_LOG_ROW_SHAPE = {
6
7
  idOf: (row) => row.entry_id,
7
8
  parentOf: (row) => row.parent_id,
8
9
  };
10
+ export function forkKeyOf(sourceId, forkNonce) {
11
+ return `fork1:${createHash("sha256").update(sourceId).update("\u0000").update(forkNonce).digest("hex")}`;
12
+ }
13
+ export function classifyForkTarget(target, existing, forkOwner) {
14
+ if ((existing.owner ?? null) !== (forkOwner ?? null))
15
+ return { outcome: "occupied", sameOwner: false };
16
+ if (target.key !== undefined && existing.forkKey === target.key)
17
+ return { outcome: "replayed", id: target.id };
18
+ return { outcome: "occupied", sameOwner: true };
19
+ }
20
+ export class ForkTargetUnsettledError extends Error {
21
+ targetId;
22
+ code = "state.fork_target_unsettled";
23
+ constructor(targetId) {
24
+ super(`fork target ${targetId} collided with another writer twice and could not be read back either time — retry the fork`);
25
+ this.targetId = targetId;
26
+ this.name = "ForkTargetUnsettledError";
27
+ }
28
+ }
29
+ export async function forkByFreshId(forkTo, sourceId, owner) {
30
+ const id = uuidv7();
31
+ const out = await forkTo(sourceId, owner ?? null, { id });
32
+ if (out === null)
33
+ return null;
34
+ switch (out.outcome) {
35
+ case "created":
36
+ return out.id;
37
+ case "replayed":
38
+ case "occupied":
39
+ throw new SessionError("storage", `fork of session ${sourceId}: a freshly minted id ${id} collided with an existing session (${out.outcome}) — nothing was written`);
40
+ default: {
41
+ const unknownOutcome = out;
42
+ throw new Error(`forkByFreshId: no arm for ${JSON.stringify(unknownOutcome)}`);
43
+ }
44
+ }
45
+ }
9
46
  //# sourceMappingURL=session-fork.js.map
@@ -0,0 +1,22 @@
1
+ /**
2
+ * **会话行格写原语的 SQL 半** —— 两孪生(`tidb-session-store.ts` / `pg-session-storage.ts`)同一只实现,方言只在语句文本里。规则在 `src/session-row.ts`
3
+ * 头注「写原语」;判序只在那里的判官 `judgeSessionRowWrite`,本模块只说事务与语句。
4
+ *
5
+ * ## 一只事务(经 `SqlDriver.connect()`)
6
+ * ① `SELECT … FROM session_meta WHERE session_id = ? FOR UPDATE` —— 判定读 = 锁读(当前读;连接初始化钉住读语义:TiDB 悲观、设不上拒启,
7
+ * `sql-driver.ts` 的 `@contract txn.read-semantics`);行期望带 `log` 时,同锁下读日志的 mark:长度 = 锁住的行上的 `leaf_seq`(每次追加与
8
+ * `leaf_id` 同句 +1,导入 / 换装 / fork 写成条目数),末条 = `session_event` 按 `seq` 倒序第一行(锁读)。追加者先锁这一行再插事件,所以持锁期间 mark 不动。
9
+ * ② 判官(行期望 → 写者身份 → 标签);不是 `write` ⇒ 回滚、一个字节不写。
10
+ * ③ `write` ⇒ 按 `session_id` 写(行已被本事务锁住)→ 提交。`updated_at` 不动(改名不是活动 —— 列表的新近序不变)。
11
+ * 应答行在**提交 / 回滚之后**由调用方的列表行读另读(新语句、新快照 —— 不在事务内用普通 `SELECT` 读:TiDB 悲观事务的普通读钉 `start_ts`,
12
+ * 等锁之后会读回判定之前的旧值),再交 `settleSessionRowWrite` 复核住客。
13
+ * 全局锁序:只锁根 `session_meta` 一行(读日志 mark 时再锁它的末条事件),不碰 `task_*`,不进放置写者「根 meta → `task_*`」锁序的后段。
14
+ */
15
+ import type { SqlDriver } from "./sql-driver.js";
16
+ import { type SessionRowAnswer, type SessionRowPatch, type SessionRowWrite, type SessionRowWriteOutcome } from "../session-row.js";
17
+ /**
18
+ * 写原语(头注):一只事务里锁根行 → 判官 → 写 / 不写;提交 / 回滚之后经 `readAnswer`(调用方店的列表行读 + 行上的住客)另读应答行。
19
+ * `db` = 店自己的池包成的 SqlDriver(连接经它的初始化);`readAnswer` 只在判官答 `write` / `changed` 时被调。
20
+ */
21
+ export declare function writeSessionRowCellsSql<Row>(db: Pick<SqlDriver, "dialect" | "connect">, sessionId: string, cells: SessionRowPatch, how: SessionRowWrite, readAnswer: (sessionId: string) => Promise<SessionRowAnswer<Row> | undefined>): Promise<SessionRowWriteOutcome<Row>>;
22
+ //# sourceMappingURL=session-row-sql.d.ts.map