@sema-agent/server 7.43.0 → 7.44.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 (345) hide show
  1. package/USAGE.md +18 -0
  2. package/dist/adoption/plan.js +31 -192
  3. package/dist/adoption/quiesce.js +3 -86
  4. package/dist/adoption/runner.js +3 -140
  5. package/dist/adoption/sql.js +0 -74
  6. package/dist/adoption/wire.js +0 -80
  7. package/dist/approval-ask-machine.js +0 -75
  8. package/dist/approval-card.js +0 -323
  9. package/dist/approval-deny-reasons.js +0 -42
  10. package/dist/approval-hmac.js +0 -35
  11. package/dist/approval-reconciler.js +8 -166
  12. package/dist/approval.js +4 -67
  13. package/dist/audit.js +1 -44
  14. package/dist/auth-bridge.js +4 -47
  15. package/dist/auth-keys.js +0 -23
  16. package/dist/bake-runner/main.js +4 -65
  17. package/dist/bake-runner/protocol.js +3 -58
  18. package/dist/bake-runner/runner.js +5 -91
  19. package/dist/bench/l8/artifact.js +2 -61
  20. package/dist/bench/l8/escape.js +0 -25
  21. package/dist/bench/l8/index.js +0 -14
  22. package/dist/bench/l8/probes.js +0 -60
  23. package/dist/bench/l8/run-probes.js +2 -45
  24. package/dist/bench/s1/arms.js +14 -189
  25. package/dist/bench/s1/live-deps.js +11 -253
  26. package/dist/bench/s1/oracle.js +1 -36
  27. package/dist/bench/s1/repair-oracle-adapter.js +0 -30
  28. package/dist/bench/s1/reviewer.js +0 -34
  29. package/dist/bench/s1/row.js +0 -62
  30. package/dist/bench/s1/run-firm.js +4 -70
  31. package/dist/bench/s1/runner-ctx.js +0 -40
  32. package/dist/bench/s1/tasks.js +0 -115
  33. package/dist/boot/adoption.js +0 -20
  34. package/dist/boot/budget-tracing.js +3 -55
  35. package/dist/boot/config-center.js +43 -551
  36. package/dist/boot/coordinators.js +1 -69
  37. package/dist/boot/crash-last.js +0 -18
  38. package/dist/boot/deferred-sandbox-path-env.js +3 -119
  39. package/dist/boot/execution-env.js +5 -171
  40. package/dist/boot/governance-seams.js +0 -92
  41. package/dist/boot/leader.js +0 -69
  42. package/dist/boot/limit-sync.js +0 -2
  43. package/dist/boot/memory-boundary.js +3 -91
  44. package/dist/boot/org-memory.js +1 -25
  45. package/dist/boot/parked-revive-gate.js +0 -150
  46. package/dist/boot/permission-rules-audit.js +1 -56
  47. package/dist/boot/reapers.js +15 -273
  48. package/dist/boot/resolve-spec.js +9 -768
  49. package/dist/boot/retention-lane.js +4 -87
  50. package/dist/boot/runner-deps.js +3 -297
  51. package/dist/boot/runtime-caps.js +0 -40
  52. package/dist/boot/session-faces.js +2 -133
  53. package/dist/boot/shutdown.js +9 -83
  54. package/dist/boot/side-query-lane.js +2 -137
  55. package/dist/boot/stores.js +15 -215
  56. package/dist/boot/task-list-lane.js +0 -18
  57. package/dist/boot/webfetch-summarize-lane.js +0 -47
  58. package/dist/boot/workflow-orchestration.js +3 -99
  59. package/dist/boot-reclaim.js +0 -20
  60. package/dist/bounded-session-map.js +0 -19
  61. package/dist/brain.js +2 -139
  62. package/dist/budget.js +9 -244
  63. package/dist/capabilities/builtin-tools.js +0 -3
  64. package/dist/capabilities/center-plugins.js +6 -51
  65. package/dist/capabilities/center-prompts.js +6 -71
  66. package/dist/capabilities/code-review-council.js +7 -40
  67. package/dist/capabilities/collab-workflows.js +1 -44
  68. package/dist/capabilities/hands-lane.js +0 -65
  69. package/dist/capabilities/memory-notice.js +0 -70
  70. package/dist/capabilities/prompt.js +0 -14
  71. package/dist/capabilities/prompts/code-review.js +0 -14
  72. package/dist/capabilities/prompts/identity.js +0 -6
  73. package/dist/capabilities/prompts/team.js +0 -4
  74. package/dist/capabilities/repo-tools.js +0 -34
  75. package/dist/capabilities/sandbox-file-send.js +3 -92
  76. package/dist/capabilities/scenarios.d.ts +0 -1
  77. package/dist/capabilities/scenarios.js +18 -279
  78. package/dist/capabilities/select-environment-tool.js +0 -32
  79. package/dist/capabilities/send-user-file-tool.js +2 -66
  80. package/dist/capabilities/skills.d.ts +1 -2
  81. package/dist/capabilities/skills.js +3 -21
  82. package/dist/capabilities/team.d.ts +3 -11
  83. package/dist/capabilities/team.js +3 -51
  84. package/dist/capabilities/tool-defer.js +0 -3
  85. package/dist/config-center/apply-effective.js +15 -330
  86. package/dist/config-center/apply-ledger.js +5 -38
  87. package/dist/config-center/facade.js +0 -41
  88. package/dist/config-center/hot-keys-registry.js +0 -20
  89. package/dist/config-center/http-client.js +2 -125
  90. package/dist/config-center/mcp-revocation.js +2 -34
  91. package/dist/config-center/read-face.js +0 -59
  92. package/dist/config-center/restart-signal.js +1 -85
  93. package/dist/config-center/skills-mcp.d.ts +1 -1
  94. package/dist/config-center/skills-mcp.js +9 -76
  95. package/dist/config-center/stage-limits.js +8 -35
  96. package/dist/config-invariants.js +0 -16
  97. package/dist/config-lkg.js +0 -42
  98. package/dist/config-provider.js +3 -186
  99. package/dist/config-types.js +0 -5
  100. package/dist/config.js +107 -1145
  101. package/dist/degenerate-instrument.js +3 -67
  102. package/dist/deployment-governance.js +0 -124
  103. package/dist/digest-form.js +0 -12
  104. package/dist/elicitation.js +3 -86
  105. package/dist/env-facts.js +7 -75
  106. package/dist/fleet/fleet-bus.js +34 -507
  107. package/dist/fleet/fleet-reconciler.js +9 -149
  108. package/dist/fleet/fleet-terminal-window.js +10 -178
  109. package/dist/fleet/subagent-tail-bus.js +3 -72
  110. package/dist/fleet-client.js +10 -70
  111. package/dist/fleet-lease.js +5 -79
  112. package/dist/git-api-kind.js +0 -3
  113. package/dist/governance-ask-marks.js +2 -78
  114. package/dist/hooks/branch-transcript.js +0 -74
  115. package/dist/hooks/cc-agent-hook-prompt.js +0 -29
  116. package/dist/hooks/cc-stop-prompt.js +1 -46
  117. package/dist/hooks/hook-llm.js +1 -53
  118. package/dist/hooks/hook-runner.js +20 -414
  119. package/dist/http/active-run-conflict.js +4 -129
  120. package/dist/http/cursor-fingerprint.d.ts +5 -0
  121. package/dist/http/cursor-fingerprint.js +5 -0
  122. package/dist/http/idempotency.js +0 -37
  123. package/dist/http/principal-gate.js +3 -40
  124. package/dist/http/route-ctx.js +0 -9
  125. package/dist/http/routes/a2a-serve.js +7 -319
  126. package/dist/http/routes/admin-config-refresh.js +0 -4
  127. package/dist/http/routes/admin-drain.js +0 -10
  128. package/dist/http/routes/adoption.js +1 -29
  129. package/dist/http/routes/agents-roster.js +1 -47
  130. package/dist/http/routes/approvals-assistant.js +32 -395
  131. package/dist/http/routes/attachments.js +4 -22
  132. package/dist/http/routes/capabilities.js +3 -420
  133. package/dist/http/routes/diagnostics.js +1 -81
  134. package/dist/http/routes/fleet.js +9 -185
  135. package/dist/http/routes/images.js +14 -239
  136. package/dist/http/routes/leader.js +0 -13
  137. package/dist/http/routes/memory-bundle.js +1 -61
  138. package/dist/http/routes/memory-policy.js +9 -99
  139. package/dist/http/routes/notify-wake.js +3 -37
  140. package/dist/http/routes/observability.js +2 -19
  141. package/dist/http/routes/retention-ops.js +2 -34
  142. package/dist/http/routes/rules.js +1 -93
  143. package/dist/http/routes/runs.js +64 -873
  144. package/dist/http/routes/session-sync.js +19 -258
  145. package/dist/http/routes/sessions-list.js +17 -43
  146. package/dist/http/routes/sessions.js +34 -226
  147. package/dist/http/routes/shared-memory.js +5 -36
  148. package/dist/http/routes/side-query.js +1 -87
  149. package/dist/http/routes/tasks.js +52 -722
  150. package/dist/http/routes/trace-usage.js +32 -195
  151. package/dist/http/routes/workflows.js +19 -195
  152. package/dist/http/run-meta.js +0 -6
  153. package/dist/http/send.js +0 -32
  154. package/dist/http/server.js +92 -1597
  155. package/dist/http/sse-lifecycle.js +2 -13
  156. package/dist/http/sse-log.js +3 -48
  157. package/dist/http/tar.js +5 -21
  158. package/dist/http/verify-rounds.js +0 -5
  159. package/dist/http/wire-gate.js +0 -9
  160. package/dist/http/workspace-content.js +0 -10
  161. package/dist/images/bake-validate.js +1 -70
  162. package/dist/images/manifest.js +1 -6
  163. package/dist/index.js +0 -21
  164. package/dist/key-resolver.js +2 -17
  165. package/dist/leader/diffout.js +1 -20
  166. package/dist/leader/diffup.js +0 -47
  167. package/dist/leader/endpoint.js +2 -57
  168. package/dist/leader/fanout.js +3 -45
  169. package/dist/leader/grader-env-factory.js +3 -72
  170. package/dist/leader/leader.js +5 -156
  171. package/dist/leader/merge.js +8 -115
  172. package/dist/leader/planner.js +3 -54
  173. package/dist/leader/repair-oracle.js +1 -60
  174. package/dist/leader/repair-wire.js +2 -79
  175. package/dist/leader/wire.js +8 -307
  176. package/dist/lsp/e2b-bridge.js +4 -64
  177. package/dist/lsp/e2b-manager.js +6 -94
  178. package/dist/lsp/lsp-frames.js +0 -12
  179. package/dist/lsp/manager.js +4 -96
  180. package/dist/lsp/ws-transport.js +5 -55
  181. package/dist/lsp-evict.js +1 -15
  182. package/dist/main.js +57 -765
  183. package/dist/memory-bundle-engine.js +0 -55
  184. package/dist/memory-export.js +0 -4
  185. package/dist/memory-posture.js +1 -15
  186. package/dist/memory-scope.js +11 -148
  187. package/dist/memory-sync-client.js +2 -44
  188. package/dist/memory-sync.js +1 -80
  189. package/dist/model-select.js +3 -80
  190. package/dist/observability/cost-quota.js +1 -17
  191. package/dist/observability/cost-taxonomy.js +0 -34
  192. package/dist/observability/fail-open.js +7 -86
  193. package/dist/observability/logger.js +0 -6
  194. package/dist/observability/metrics.js +0 -94
  195. package/dist/observability/otel-exporter.js +3 -13
  196. package/dist/observability/principal-context.js +0 -9
  197. package/dist/observability/prompt-manifest.js +1 -37
  198. package/dist/observability/rate-limit.js +0 -4
  199. package/dist/observability/secret-env-scrub.js +2 -56
  200. package/dist/observability/tool-trace.js +1 -70
  201. package/dist/orchestration/hardened-vm-runner.js +4 -118
  202. package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
  203. package/dist/orchestration/hardened-vm-worker.js +0 -27
  204. package/dist/orchestration/subagent-steer.js +1 -45
  205. package/dist/orchestration/workflow-agent-steer.js +1 -80
  206. package/dist/orchestration/workflow-completion-inbox.js +32 -285
  207. package/dist/orchestration/workflow-notify-journal.js +16 -259
  208. package/dist/org-memory-admission.js +3 -47
  209. package/dist/parent-watch.js +2 -48
  210. package/dist/parked-decide.js +1 -109
  211. package/dist/per-task-image.js +0 -57
  212. package/dist/plan-cache-probe.js +3 -27
  213. package/dist/plugins/adoption-log-sql.js +2 -119
  214. package/dist/plugins/approval-ask-store-memory.js +3 -38
  215. package/dist/plugins/approval-ask-store-sql.js +6 -188
  216. package/dist/plugins/approval-exemption-store.js +2 -28
  217. package/dist/plugins/background-agent-store-sql.js +4 -105
  218. package/dist/plugins/background-shell-support.js +14 -122
  219. package/dist/plugins/blob-backend.js +6 -169
  220. package/dist/plugins/breaker-state-sql.js +8 -46
  221. package/dist/plugins/caching-session-store.js +4 -106
  222. package/dist/plugins/checkpoint-store-sql.js +18 -505
  223. package/dist/plugins/e2b-orphan-reclaim.js +0 -45
  224. package/dist/plugins/file-outcome-sink.js +0 -9
  225. package/dist/plugins/file-resume-anchor-store.js +4 -43
  226. package/dist/plugins/file-run-store.js +26 -364
  227. package/dist/plugins/file-snapshot-store-sql.js +10 -181
  228. package/dist/plugins/fork-routing-session-store.js +8 -111
  229. package/dist/plugins/host-platform.js +2 -91
  230. package/dist/plugins/image-bake-store-sql.js +7 -250
  231. package/dist/plugins/image-index-sql.js +4 -123
  232. package/dist/plugins/k8s-bg-scripts.js +4 -88
  233. package/dist/plugins/k8s-exec-protocol.js +0 -41
  234. package/dist/plugins/leader-run-store-sql.js +0 -101
  235. package/dist/plugins/local-checkpoint-store.js +8 -128
  236. package/dist/plugins/local-session-store.js +34 -296
  237. package/dist/plugins/local-task-attachment-store.js +2 -16
  238. package/dist/plugins/mailbox-store-sql.js +8 -65
  239. package/dist/plugins/memory-embedder-fingerprint.js +5 -166
  240. package/dist/plugins/memory-embedder.js +4 -70
  241. package/dist/plugins/memory-engine-pg.js +6 -170
  242. package/dist/plugins/memory-engine-tidb.js +7 -155
  243. package/dist/plugins/memory-engine-vector-util.js +0 -10
  244. package/dist/plugins/memory-key-guards.js +0 -34
  245. package/dist/plugins/memory-origin-law.js +0 -187
  246. package/dist/plugins/memory-resume-anchor-store.js +0 -17
  247. package/dist/plugins/memory-run-store.js +12 -89
  248. package/dist/plugins/memory-session-policy-store.js +0 -17
  249. package/dist/plugins/memory-sync-store-pg.js +4 -49
  250. package/dist/plugins/memory-sync-store-tidb.js +3 -35
  251. package/dist/plugins/outcome-ledger-sql.js +3 -97
  252. package/dist/plugins/permission-rule-store-file.js +5 -133
  253. package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
  254. package/dist/plugins/permission-rule-store-sql.js +50 -307
  255. package/dist/plugins/pg-cost-quota.js +0 -7
  256. package/dist/plugins/pg-pool.js +0 -92
  257. package/dist/plugins/pg-rate-limiter.js +2 -13
  258. package/dist/plugins/pg-safe-json.js +4 -40
  259. package/dist/plugins/pg-session-storage.js +25 -189
  260. package/dist/plugins/posix-shell-fs.js +1 -31
  261. package/dist/plugins/remote-env-adb.js +12 -101
  262. package/dist/plugins/remote-env-e2b.js +40 -371
  263. package/dist/plugins/remote-env-file-error.js +0 -37
  264. package/dist/plugins/remote-env-host.js +63 -483
  265. package/dist/plugins/remote-env-k8s.js +32 -326
  266. package/dist/plugins/remote-env-local-docker.js +24 -157
  267. package/dist/plugins/remote-env-ssh.js +20 -128
  268. package/dist/plugins/remote-scratchpad.js +2 -32
  269. package/dist/plugins/remote-shell.js +1 -32
  270. package/dist/plugins/resume-anchor-store-sql.js +0 -11
  271. package/dist/plugins/retention-lane-store-sql.js +0 -108
  272. package/dist/plugins/retention-store-sql.js +4 -383
  273. package/dist/plugins/roster-store-sql.js +0 -55
  274. package/dist/plugins/run-store-sql.js +14 -251
  275. package/dist/plugins/s3-presign.js +2 -49
  276. package/dist/plugins/scheduler-support.js +3 -80
  277. package/dist/plugins/send-file-ledger.js +4 -53
  278. package/dist/plugins/send-user-file.js +4 -94
  279. package/dist/plugins/session-placement.js +1 -89
  280. package/dist/plugins/session-policy-store-sql.js +3 -81
  281. package/dist/plugins/session-store.js +0 -57
  282. package/dist/plugins/shared-memory-store-sql.js +4 -167
  283. package/dist/plugins/sql-driver.js +0 -17
  284. package/dist/plugins/sql-errors.js +0 -7
  285. package/dist/plugins/sql-escape.js +0 -8
  286. package/dist/plugins/sql-row-helpers.js +0 -25
  287. package/dist/plugins/store-backend.js +42 -222
  288. package/dist/plugins/store-contracts.js +2 -46
  289. package/dist/plugins/task-attachment-store.js +3 -49
  290. package/dist/plugins/task-list-store-sql.js +0 -79
  291. package/dist/plugins/tidb-cost-quota.js +1 -4
  292. package/dist/plugins/tidb-pool.js +1 -207
  293. package/dist/plugins/tidb-rate-limiter.js +3 -9
  294. package/dist/plugins/tidb-session-storage.js +4 -70
  295. package/dist/plugins/tidb-session-store.js +19 -341
  296. package/dist/plugins/tool-result-store-sql.js +4 -179
  297. package/dist/plugins/usage-window-store-sql.js +0 -8
  298. package/dist/plugins/web-search.js +10 -132
  299. package/dist/plugins/workflow-journal-store-sql.js +2 -58
  300. package/dist/plugins/workflow-run-store-sql.js +6 -91
  301. package/dist/plugins/worktree-isolation.js +6 -126
  302. package/dist/plugins/write-behind-counter.js +16 -75
  303. package/dist/principal-jwt.js +5 -60
  304. package/dist/project-memory.js +15 -146
  305. package/dist/prompts-domain-validate.js +1 -51
  306. package/dist/question.js +2 -111
  307. package/dist/resource-suspend.js +0 -18
  308. package/dist/router/route-orchestration.js +0 -77
  309. package/dist/rules-consent.d.ts +44 -5
  310. package/dist/rules-consent.js +75 -215
  311. package/dist/run-local.js +14 -381
  312. package/dist/runs.js +29 -617
  313. package/dist/runtime-caps-resolver.js +6 -132
  314. package/dist/runtime-governance.js +1 -232
  315. package/dist/sandbox-pkg-source.js +0 -37
  316. package/dist/sealed-key.js +3 -68
  317. package/dist/security.js +6 -291
  318. package/dist/session-leaf-bus.js +0 -32
  319. package/dist/session-sync-content.js +1 -69
  320. package/dist/session-sync-kernel.js +3 -60
  321. package/dist/session-sync.js +3 -66
  322. package/dist/session-titler.js +7 -41
  323. package/dist/session-watch.js +9 -73
  324. package/dist/shared-memory-scope-authorizer.js +0 -18
  325. package/dist/sighup-idle.js +1 -12
  326. package/dist/spec-fields.js +4 -143
  327. package/dist/store-live-probe.js +3 -49
  328. package/dist/task-a2a.js +1 -126
  329. package/dist/task-cwd.js +1 -103
  330. package/dist/task-mcp.js +1 -89
  331. package/dist/task-settings.js +10 -300
  332. package/dist/task-workflow.js +8 -75
  333. package/dist/tool-approval.d.ts +28 -1
  334. package/dist/tool-approval.js +70 -1430
  335. package/dist/trace/artifacts.js +5 -20
  336. package/dist/trace/engine-notice-wire.js +6 -138
  337. package/dist/trace/ledger-sink.js +7 -96
  338. package/dist/trace/project.js +4 -448
  339. package/dist/trace/redact.js +9 -77
  340. package/dist/turn-activity.js +1 -27
  341. package/dist/usage-analytics.js +4 -34
  342. package/dist/wall-clock-jump-guard.js +1 -68
  343. package/package.json +3 -3
  344. package/dist/capabilities/scenario-alias.d.ts +0 -27
  345. package/dist/capabilities/scenario-alias.js +0 -61
@@ -1,33 +1,5 @@
1
- /**
2
- * Durable `CheckpointStore` (core 1.67 durable-checkpoint / design/45) — SINGLE-FILE DUAL-DIALECT
3
- * (design/158 A12 定型半场). ONE implementation, TWO dialects; the historical `TiDBCheckpointStore` /
4
- * `PgCheckpointStore` class names survive as thin ctor subclasses so every consumer (store-backend.ts,
5
- * local-checkpoint-store.ts, the chaos/ownership/blackbox suites) is untouched.
6
- *
7
- * This is the durable backend that makes a policy `ask` survive cross-replica/crash/time as a suspended
8
- * task an operator can `resume` from any instance. It implements core's `CheckpointStore` (put create-once
9
- * / get / atomic CAS resolve / reopen / reap), modeled on the proven approval-store CAS, plus two concerns
10
- * the core interface doesn't own but the service does (DURABLE-CHECKPOINT-PLAN §4.4a):
11
- * - `checkpoint_ctx` (sessionId-keyed): the service-side scenario rebuild inputs for `resume`'s taskConfig —
12
- * core's `put(token, cp)` can't carry it (it's service "profile", not the generic core checkpoint).
13
- * - operator-queue helpers (`listPending` / `findPendingTokenBySession`) that surface NON-secret fields and
14
- * map a session → its pending token INTERNALLY — the capability token never leaves the service (token-as-auth).
15
- *
16
- * ── Dialect deltas, kept EXPLICIT ────────────────────────────────────────────────────────────────────
17
- * - `?` placeholders vs `$n` (and the OPTIONAL `AND rev = ?` / `AND rev = $5` OCC tail)
18
- * - `ON DUPLICATE KEY UPDATE … VALUES(col)` vs `ON CONFLICT (session_id) DO UPDATE SET … EXCLUDED.col`
19
- * - dup-key errno `1062` (ER_DUP_ENTRY) vs SQLSTATE `23505`
20
- * - null-safe owner compare: `<=>` vs `IS NOT DISTINCT FROM` — AND the parameter arity that follows
21
- * (`?` cannot be reused, so the TiDB deleteBySession binds sessionId TWICE; PG reuses `$1`)
22
- * - affectedRows vs rowCount (via SqlDriver)
23
- * - JSON binding: TiDB `JSON.stringify` verbatim vs PG `pgProtocolJsonStringify` + `$n::jsonb` casts.
24
- * PG's envelope is LOSSLESS (codex R4-H1): tool_input is the operator's review surface and must agree
25
- * with `checkpoint.pendingAction.args` at the NUL position — redact/truncate is the display contract,
26
- * byte rewriting is not.
27
- * - schema ownership: TiDB DDL in tidb-pool.ts, PG DDL centrally in pg-pool.ts (neither store creates tables).
28
- */
29
1
  import { createHash } from "node:crypto";
30
- import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js"; // A-002.1 单一属主(SQL IN 片段从闭集数组派生)
2
+ import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
31
3
  import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, } from "@sema-agent/core";
32
4
  import { redactDeep, redactSecrets } from "../trace/redact.js";
33
5
  import { MAX_RULE_SUGGESTIONS, RuleSuggestionRawSchema } from "../approval-card.js";
@@ -36,77 +8,13 @@ import { recordFailOpen } from "../observability/fail-open.js";
36
8
  import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
37
9
  import { isDupKeyError } from "./sql-errors.js";
38
10
  import { MANAGED_RETENTION } from "./retention-store-sql.js";
39
- /** Cap the persisted/served pending-approval args so a pathological tool payload can't bloat the checkpoint row
40
- * or the operator-queue response (the BFF renders this in an approval card; a few KB is plenty). */
41
11
  const MAX_TOOL_INPUT_CHARS = 8192;
42
- /**
43
- * design/80 D-1 (§3 invariant #3 — crash-safe reaper backstop): an ABSOLUTE upper bound on a pending
44
- * checkpoint's lifetime, stamped at put() into `terminal_at_ms` INDEPENDENT of the per-approval `deadline`. The
45
- * SLA-timer (D-D) does the fine, per-gate-kind resolve-deny; THIS coarse backstop ensures even a pending row
46
- * with a NULL `deadline` (no approval TTL configured) is eventually GC'd if the SLA service dies — closing a
47
- * forever-leak of a never-resolved suspension (the current `reap`/`reapExpired` only catch non-NULL deadlines).
48
- * Generous (default 30d) so it never pre-empts a legitimately long-lived human/irreversible_ask gate;
49
- * env-overridable. Clamped ≥1min so a misconfig can't expire live suspensions instantly.
50
- */
51
- // A-067 C1-2(F2a):解析必须钳有限——`"Infinity"` 经 `Number(env) || def` 取 Infinity、Math.max 恒
52
- // Infinity ⇒ 本常量存在的唯一理由(SLA 服务死掉后 pending 行仍被 GC 的 crash-safe 绝对上限)整条失效。
53
- // 非有限/非法值落默认(与 remote-shell numEnvOr 同判据方向;此处零告警通道,静默落默认承原形)。
54
12
  export const TERMINAL_BACKSTOP_MS = Math.max(60_000, (Number.isFinite(Number(process.env.APPROVAL_TERMINAL_BACKSTOP_MS)) ? Number(process.env.APPROVAL_TERMINAL_BACKSTOP_MS) : 0) || 30 * 86_400_000);
55
- /**
56
- * design/80 D-D (adversarial fix): the crash-safe `terminal_at_ms` backstop must fall STRICTLY AFTER any SLA
57
- * `deadline`, never AT it. `terminal_at_ms = max(createdAt+backstop, deadline)` made the two coincide whenever an
58
- * operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's terminal_at_ms-branch (which
59
- * has NO gate_kind filter) then abort-EXPIRED a human/irreversible_ask gate in the SAME tick the deny-sweep
60
- * wanted to gracefully DENY it, racing it away. Adding this grace to the deadline term guarantees the deny-sweep
61
- * at least this window of clean ticks before the absolute backstop can fire. Far smaller than the backstop, so
62
- * it never meaningfully delays the eventual crash-safe GC.
63
- */
64
- export const TERMINAL_GRACE_MS = 3_600_000; // exported for the LOCAL twin's read-time terminal_at_ms derivation (anti-drift) // 1h — many reaper intervals of deny-sweep runway past the SLA deadline
65
- /**
66
- * The capability token IS the resume credential (token-as-auth) — anyone who reads it can impersonate a
67
- * resume, so it must never reach the logs (which fan out to a log-aggregation pipeline). For the diagnostic
68
- * resume-read-path log we emit a stable, non-reversible fingerprint instead, enough to correlate get↔resolve.
69
- */
13
+ export const TERMINAL_GRACE_MS = 3_600_000;
70
14
  export function tokenFingerprint(token) {
71
15
  return "sha256:" + createHash("sha256").update(token).digest("hex").slice(0, 12);
72
16
  }
73
- /** 窄读的**扫描窗**:最多看这么多条原料再判形(基数帽 {@link MAX_RULE_SUGGESTIONS} 扣在合形之后)。
74
- * 存在的理由是热读路径的有界性,不是基数语义 —— 所以它比基数帽宽得多。 */
75
17
  const MAX_RULE_SUGGESTION_SCAN = 32;
76
- /**
77
- * `pendingAction.ruleSuggestions` 的**铸行侧**投影(mint 面,与 {@link boundedToolInput} 同位同待遇)。
78
- *
79
- * 三件事,一件都不能少:
80
- * 1. **结构窄读**(宪法 [2704] 边界必 schema / 禁裸 as-cast):blob 里的这一格是 `unknown`(旧 core /
81
- * 坏行 / 未来加字段都可能)。形不合的**整条丢弃**(不是整只丢弃)——一条坏候选不该让另一条真候选消失。
82
- * 判据只看 `rule`/`match`/`command` 三键合形(`RuleSuggestionRawSchema` 是**默认 strip 形** `.strip()`,
83
- * 多余键**剥掉**、且不拒收 —— 重扫二轮更正:旧注写 `.loose()`,而 zod 4 的 loose = passthrough,
84
- * 未知键会**留在** `parsed.data` 里,与「剥掉」相反)。上游 additive 加一键是 core 的常规做法,拿 strict 去窄读会让那一次加字段把整只
85
- * 候选静默清零 —— 「有供给」当场变「无供给」,与本键的存在理由正相反(顶注第三段)。
86
- * 2. **脱敏**:候选文本是从**命令原文**铸出来的(`Bash(curl -H "Authorization: Bearer …")` 这种 exact
87
- * 形拼得出秘密),而 `GET /v1/approvals` 是**跨租户可见**的运维队列。落列前过 `redactSecrets` ——
88
- * 与同行 `tool_input` 在 mint 就 `redactDeep` 是**同一条**纪律(#209 件4 把 `shadowedRule` 修在读面
89
- * 是因为那条路的行早已落库、改铸点救不了存量;本键是**新**列,没有存量行要救,修在铸点更彻底:
90
- * 秘密从此连**落库**都不落,不是落了库再在两条读面各洗一遍)。
91
- * 3. **脱敏之后**才截长(codex 对抗复审 [medium],验真后修):`redactSecrets` 会**变长**,先按限长校验
92
- * 原文再脱敏会产出超限文本,而 SQL 腿有**回读**(列 → 本函数再跑一遍)、LOCAL 腿没有 ⇒ 同一份素材
93
- * 两个后端给出不同卡面。顺序反过来之后本函数对自己的输出**幂等**,两条腿同形。完整论证见
94
- * `RuleSuggestionRawSchema` 顶注。
95
- * 4. **限基数**:core 契约今天 ≤2,这里按 {@link MAX_RULE_SUGGESTIONS}(=4,server 侧容忍帽,两个数的
96
- * 分工逐字见该常量顶注)截 —— 一条被改坏/未来放宽的上游不该把运维队列的一行撑爆。
97
- * 🔴 **扣预算在合形之后**(合并码重扫):旧序先 `slice` 再判形,于是 ≥MAX 条坏候选能把唯一的真候选
98
- * 挤出预算,读数是 `undefined` —— 与上面第 1 条自证的不变量(坏的丢、好的照投)正相反。扫描窗
99
- * {@link MAX_RULE_SUGGESTION_SCAN} 仍是有界的:一条被改坏成上万条的行不该让每 3s 一拍的读路径去逐条判形。
100
- *
101
- * 🔴 `opts.countRedactions`:**两条腿都照常脱敏,只有回读腿不计数**(合并码重扫;codex 复审第一轮
102
- * 指出「回读不脱敏」会把纵深防御一起关掉,验真后按这条收)。`redactSecrets` 对自己的输出是**值幂等、
103
- * 计数不幂等**(URL-userinfo / JSON 秘密字段两条 pattern 会命中自己的输出并再计一次),而回读腿正是
104
- * `/v1/approvals/stream` 每 3s 一拍的热路径 ⇒ 每条订阅每行每拍给 `redactions_applied_total` +1,把那个
105
- * 用来识别「过度脱敏 / pattern 从不触发」的指标灌成不可读。关掉的是**计数**:重扫仍在,手改过的行 /
106
- * 将来某条写路径漏了脱敏,读面照样兜住(运维队列跨租户可见,少脱一次的代价永远不能省)。
107
- *
108
- * 返回 `undefined` = **无供给**(键不落列):core 没给、给了空数组、或整只形不合。
109
- */
110
18
  export function boundedRuleSuggestions(raw, opts = {}) {
111
19
  if (!Array.isArray(raw))
112
20
  return undefined;
@@ -114,11 +22,10 @@ export function boundedRuleSuggestions(raw, opts = {}) {
114
22
  const out = [];
115
23
  for (const item of raw.slice(0, MAX_RULE_SUGGESTION_SCAN)) {
116
24
  if (out.length >= MAX_RULE_SUGGESTIONS)
117
- break; // 基数帽扣在**合形之后**
25
+ break;
118
26
  const parsed = RuleSuggestionRawSchema.safeParse(item);
119
27
  if (!parsed.success)
120
- continue; // 坏的那条丢掉,好的那条照投
121
- // 先脱敏、再截 —— 截完必然满足 `RuleSuggestionSchema` 的 `.max()`,于是回读再跑一遍是恒等操作。
28
+ continue;
122
29
  out.push({
123
30
  rule: redactSecrets(parsed.data.rule, { observe }).slice(0, MAX_RULE_TEXT_CHARS),
124
31
  match: parsed.data.match,
@@ -127,19 +34,6 @@ export function boundedRuleSuggestions(raw, opts = {}) {
127
34
  }
128
35
  return out.length > 0 ? out : undefined;
129
36
  }
130
- /**
131
- * `checkpoint.rule_suggestions` 这一格的**回读口**(合并码重扫)。
132
- *
133
- * 🔴 为什么不是直接 `parseJson`:本文件的 `parseJson` 是 **strict** flavor(畸形 JSON 文本直接抛),而
134
- * `listPending` 是 `rows.map(...)` —— 一处抛就中止整只映射 ⇒ **一行**手改坏的 cell 打掉整个租户的运维
135
- * 队列(`GET /v1/approvals` 500;`/v1/approvals/stream` 落进它的 catch,每 3s 一次静默重试,心跳照常而
136
- * 队列永远空)。而 {@link boundedRuleSuggestions} 的顶注承诺的是「手改过的行在这里当场落成**无供给**」——
137
- * 承诺只对「合法 JSON 但错形」成立,对「JSON 文本本身坏了」不成立。这里把两者补齐:坏 cell ⇒ 这一行
138
- * 这一格缺席,其余行照常读出来。
139
- *
140
- * 🔴 **响亮**:F 类兜底(丢的是一格展示/分诊材料,不是判据),按仓内纪律走 `recordFailOpen` —— 登记 tag
141
- * + 逐次计数 + 逐次探针 + 每进程一次 warn。静默 `catch` 在这条路上正是本仓要消灭的形。
142
- */
143
37
  function readRuleSuggestionsCell(cell) {
144
38
  let parsed;
145
39
  try {
@@ -151,25 +45,6 @@ function readRuleSuggestionsCell(cell) {
151
45
  }
152
46
  return boundedRuleSuggestions(parsed, { countRedactions: false });
153
47
  }
154
- /**
155
- * `checkpoint.risk_descriptor` 这一格的**回读口**(重扫二轮)——与 {@link readRuleSuggestionsCell} 同族同待遇。
156
- *
157
- * 🔴 病(红先复现):上一格补了容错,而**同一个 `rows.map`** 里这一格还走 strict `parseJson`。两个后端里
158
- * 这两列是同一种存储形(`tidb-pool.ts` / `pg-pool.ts` 双方言都是 TEXT),坏法完全同族(运维手改 / 回滚
159
- * 残留 / 列语义漂)⇒ 坏的若是这一格,`listPending` 照样整只 reject,上一格顶注与 FAIL-OPEN-CENSUS 宣称的
160
- * 「一行坏 cell 不再打掉整只队列」在「手改过的行」这个真实场景上并不成立。
161
- *
162
- * 🔴 **失败方向自证**(能力语义一致性,#157 纪律):这一格在本读面上只有三个**展示/分诊**消费者 ——
163
- * ① severity 分诊排序(缺席折 0,排在有 descriptor 的行之后)② `shadowedRule` 的读面脱敏 ③ 读面派生的
164
- * `governanceForced` 徽章。三者**都不是执法判据**:门早已 park,队列只负责把它显示给人;真按
165
- * `riskDescriptor.shellGateDoctrine` 判的 `active-run-conflict.ts` 读的是 checkpoint blob 的 `get()`,
166
- * 不经本读面。而 `governanceForced` 的成文语义本就是「缺席 = 没有治理来源的**证据**」(恒不写 `false`),
167
- * 缺席是保守读法。对照另一支:整只队列 500 时这三样连同**其余所有行**一起消失。⇒ 行级容忍严格优于整只
168
- * 毒化,不是把保护型判据静默放行。
169
- *
170
- * tag **单列不并计**:两格的缺席后果不同(候选=「不再询问」那一格;本格=分诊排序与出身徽章),并计会让
171
- * 「哪一格在坏」在遥测里读不出来。
172
- */
173
48
  function readRiskDescriptorCell(cell) {
174
49
  try {
175
50
  return parseJson(cell);
@@ -179,33 +54,17 @@ function readRiskDescriptorCell(cell) {
179
54
  return null;
180
55
  }
181
56
  }
182
- /** Redact + size-bound a pending tool call's args for persistence/serving. JSON-roundtrips so the stored shape
183
- * matches what the operator queue serves; oversized payloads collapse to a marker rather than bloating the row.
184
- * Exported for the LOCAL twin (local-checkpoint-store.ts) so the operator-queue projection can't drift. */
185
57
  export function boundedToolInput(args) {
186
58
  if (args === undefined)
187
59
  return null;
188
60
  const redacted = redactDeep(args);
189
61
  const json = JSON.stringify(redacted);
190
62
  if (json === undefined)
191
- return null; // non-serializable (e.g. a bare function) — nothing to surface
63
+ return null;
192
64
  if (json.length > MAX_TOOL_INPUT_CHARS)
193
65
  return { truncated: true, bytes: json.length };
194
66
  return redacted;
195
67
  }
196
- /**
197
- * checkpoint blob → `pendingAction.toolCallId`。
198
- *
199
- * 🔴 三态、**不是**两态(真双库实跑抓到的缺陷,2026-08-06):「读不出」与「读出来了、但这条 park 本来就
200
- * 没有 toolCallId」是两件事,压成同一个 `undefined` 会把每一条 `plan_review`/`task_done`/`resource_limit`
201
- * 腿(core 的 `PendingAction` 联合里三个成员结构上就没有 toolCallId)都误报成坏行。前者应标 `unparseable`
202
- * 交给收敛器当「不确定」,后者是**确定的不匹配**——干净地不是候选。
203
- * - `{ readable: false }` —— JSON 坏 / 形状根本不是对象:真的读不出。
204
- * - `{ readable: true, toolCallId: null }` —— 读出来了,这条 park 无工具动作:确定不匹配。
205
- * - `{ readable: true, toolCallId: "…" }` —— 读出来了,拿去比。
206
- *
207
- * 逐层 `typeof` 收窄,不做裸 `as` 断言:blob 是持久层读回来的 `unknown`,是信任边界(宪法 [2704])。
208
- */
209
68
  function pendingActionToolCallId(blob) {
210
69
  let parsed;
211
70
  try {
@@ -216,15 +75,12 @@ function pendingActionToolCallId(blob) {
216
75
  }
217
76
  if (typeof parsed !== "object" || parsed === null)
218
77
  return { readable: false };
219
- // 🔴 `sourceTaskId` 是 checkpoint 的**顶层**字段(不在 pendingAction 里),没有对应的列 ⇒ 只能从
220
- // blob 读。它是和解三元组的第一维(#168 件1);形不合(有这个键但不是字符串)= 结构漂移,按本函数
221
- // 一贯的收窄纪律判 `readable: false`,不静默折成缺席。
222
78
  const rawSource = "sourceTaskId" in parsed ? parsed.sourceTaskId : null;
223
79
  if (rawSource !== null && rawSource !== undefined && typeof rawSource !== "string")
224
80
  return { readable: false };
225
81
  const sourceTaskId = typeof rawSource === "string" ? rawSource : null;
226
82
  if (!("pendingAction" in parsed))
227
- return { readable: true, toolCallId: null, boundInputHash: null, kind: null, sourceTaskId }; // 无 pendingAction 的 park:确定不匹配
83
+ return { readable: true, toolCallId: null, boundInputHash: null, kind: null, sourceTaskId };
228
84
  const pa = parsed.pendingAction;
229
85
  if (typeof pa !== "object" || pa === null)
230
86
  return { readable: false };
@@ -232,17 +88,12 @@ function pendingActionToolCallId(blob) {
232
88
  if (kind !== null && typeof kind !== "string")
233
89
  return { readable: false };
234
90
  if (!("toolCallId" in pa))
235
- return { readable: true, toolCallId: null, boundInputHash: null, kind, sourceTaskId }; // plan_review / task_done / resource_limit 腿
91
+ return { readable: true, toolCallId: null, boundInputHash: null, kind, sourceTaskId };
236
92
  const id = pa.toolCallId;
237
93
  if (typeof id !== "string")
238
94
  return { readable: false };
239
- // 🔴(属主兜底轮 F1)带 toolCallId 却挂着词表外的 kind = 结构漂移,不是「读得出」——这条 blob 声称
240
- // 自己是一次工具审批 park(有 callId)又否认(kind 不是 tool_approval)。让它带着匹配的 callId 混进
241
- // 健康候选,等于给「外部改库 / schema 偏斜」开一条错配 PARK 的路。词表判据窄取 "tool_approval":
242
- // 其余合法 kind(plan_review 等)在 core 的类型里结构上**没有** toolCallId,走不到这里。
243
95
  if (kind !== "tool_approval")
244
96
  return { readable: false };
245
- // hash 只做逐层收窄,不在这层与列比对(比对是调用处的列/blob 一致性门)。缺席 = null(旧行/无 hash 形)。
246
97
  const bih = "boundInputHash" in pa ? pa.boundInputHash : null;
247
98
  if (bih !== null && typeof bih !== "string")
248
99
  return { readable: false };
@@ -258,8 +109,6 @@ function readActorAssertion(v) {
258
109
  const issuer = "issuer" in v ? v.issuer : undefined;
259
110
  return { id, hostAsserted, ...(typeof issuer === "string" ? { issuer } : {}) };
260
111
  }
261
- /** 队列化之前的**裸单座**形(`{text,trusted}`)—— 旧行原样躺在库里,core 的 `readPendingSteerQueue`
262
- * 会把它折成 member 0,所以读出来照原样保留、不改写、不迁移。 */
263
112
  function readLegacySeat(v) {
264
113
  if (typeof v !== "object" || v === null)
265
114
  return undefined;
@@ -269,8 +118,6 @@ function readLegacySeat(v) {
269
118
  return undefined;
270
119
  return { text, trusted };
271
120
  }
272
- /** 队列条目的逐层窄读(持久层读回的是 `unknown`,是信任边界 —— 禁裸 `as`)。四个必备键任一不合形 ⇒
273
- * 整条不认(返回 undefined,由调用方判 lossy);`actor`/`priority` 可选,不合形即缺席。 */
274
121
  function readQueueEntry(v) {
275
122
  if (typeof v !== "object" || v === null)
276
123
  return undefined;
@@ -295,14 +142,6 @@ function readQueueEntry(v) {
295
142
  ...(priority === "now" || priority === "next" || priority === "later" ? { priority } : {}),
296
143
  };
297
144
  }
298
- /**
299
- * 两个 steering 列 → `{pendingSteer?, pendingSteerQueue?}`(= core `appendPendingSteer`/`readPendingSteerQueue`
300
- * 的入参形)。**两列各管一形**,不是一列两形:
301
- * · `pending_steer`(旧列,本服务只读不写)—— 裸单座 `{text, trusted}`。来源有二:队列化之前的存量行,
302
- * 以及**滚动升级窗里仍在跑的旧副本**写下的座。core 的 `readPendingSteerQueue` 把它折成 member 0。
303
- * · `pending_steer_queue`(新列)—— `PendingSteerEntry[]`。
304
- * 任一列有内容却读不出 ⇒ `lossy`(两个调用点对它的处置见 {@link SteerColumnRead})。
305
- */
306
145
  export function readPendingSteerColumns(seatRaw, queueRaw) {
307
146
  let lossy = false;
308
147
  const parse = (raw) => {
@@ -343,97 +182,31 @@ export function readPendingSteerColumns(seatRaw, queueRaw) {
343
182
  lossy,
344
183
  };
345
184
  }
346
- /** 读-改-写的 CAS 重试上限。队列本身只有 `MAX_PENDING_STEER_ENTRIES`(= 3)个位子,再多就 fail-loud,
347
- * 所以真实争用者数量天然很小;给足冗余后仍然全败 ⇒ 是异常,必须 fail-loud(见 setPendingSteer 尾注)。 */
348
185
  const STEER_CAS_ATTEMPTS = 8;
349
- /** Dual-dialect durable CheckpointStore. See the file header for the dialect-delta ledger. */
350
186
  export class SqlCheckpointStore {
351
187
  db;
352
188
  logger;
353
- /** #270 车1:托管留存声明。读法与「为什么 SQL 店答 managed 而三方法的实现体在 `retention-store-sql.ts`」
354
- * 逐字见 {@link MANAGED_RETENTION}(本店的 `checkpoint`/`checkpoint_ctx` 两表由那只聚合店按期清)。 */
355
189
  retention = MANAGED_RETENTION;
356
- /**
357
- * `CheckpointStore.durability` 声明(#167 欠账,#168 件5)—— 行落在 MySQL-protocol / PostgreSQL 的
358
- * `checkpoint` 表里,进程重启、副本轮换、整机重建都不丢 ⇒ `"durable"`,如实。
359
- *
360
- * 🔴 为什么这一格空着是有代价的:core 的 `resolveDeclaredDurability` 把**缺席**折成 `"process-local"`
361
- * (它不能替一个没表态的店猜),于是静态装配面对每一个真持久部署都读出 `process_local`,
362
- * `GET /v1/diagnostics/wiring` 的 park 车道读数与启动自检的那条警告都因此不可信 —— 而 park 正是流内
363
- * 审批协议的降级目的地,「重启后还赎不赎得回」是运维必须能一眼看见的事。声明是店自己的责任,不是
364
- * 消费侧靠 backend.kind 猜出来的。
365
- */
366
190
  durability = "durable";
367
- /**
368
- * `CheckpointStore.fidelity` 声明(core 5.17.0 [3052] 提货批 #172)——**如实按介质判**:本店把整个
369
- * checkpoint 经 {@link SqlCheckpointStore.json} 编码进一个 JSON 列(TiDB 逐字文本 / PG lossless
370
- * `::jsonb` 协议信封),读侧 `parseJson` 还原 ⇒ 能扛过 round-trip 的只有 JSON 值域,`"json"`。
371
- *
372
- * 🔴 为什么必须显式写、哪怕缺席也折向 json:core 的 `resolveDeclaredFidelity` 对缺席是 fail-closed
373
- * (读 json),所以沉默不会立刻错——但沉默**表达不出**「我核对过我的介质就是这个宽度」。park 铸行
374
- * 的 args / preview / 风险描述 / `boundInputHash` 全部从这一格算出的投影铸;哪天这四行编码里任何
375
- * 一处改了介质(换存储格式、换列类型),声明在场才有东西可以红,沉默那格只会安静地按错宽度铸出
376
- * 一份「审批人看到的 ≠ 盘上躺着的 ≠ resume 执行的」。同 `durability` 的 #168 件5 教训:表态是店
377
- * 自己的责任,不是消费侧靠 backend.kind 猜。
378
- *
379
- * ⚠️ **已知残余(不是遮掩,是这两个词表达不了的那一格)**:PG 臂比 `"json"` 字面**略窄** ——
380
- * {@link pgProtocolJsonStringify} 对含 NUL / lone surrogate 的串 fail-loud 拒绝(R4-H1 有意裁定:
381
- * 复核面必须与真执行的 args 在 NUL 那一位上一致,「悄悄清洗再存」是不可接受的那一支),而 core 的
382
- * json 宽度收下这些码位。`StoreFidelity` 的闭集只有 `"structured-clone" | "json"`,没有第三个词能说
383
- * 「json 减去本介质存不下的码位」——声明 `"structured-clone"` 是大得多的谎,所以 `"json"` 仍是两者
384
- * 里唯一诚实的选择。后果有界且 fail-closed:core 5.17.0 起 park 铸行失败会把 cause 带到 gate、追加
385
- * 到 fallback 的 deny 上,这条 args 退回**同步门**由人判(不静默漏批、不挂死)。边界钉在
386
- * `wiring-governance-operator.test.ts` 的 #172 组;已上报上游求一个能表达该宽度的词。
387
- */
388
191
  fidelity = "json";
389
- /**
390
- * `CheckpointStore.redecision` 声明(core 5.22.0 F-012 L2/L3;[3342] clay 裁放行)——**如实按能力判**:
391
- * 本店的 {@link SqlCheckpointStore.reopen} 是真 CAS 实现(`resolved`→`pending` 原子翻回 + `reopen_reason`
392
- * 权威列,design/80 D-1 的两条重开腿都走它),声明 `reopen: true` 是读数不是抬举。core 的沙箱准入
393
- * 模式(pre-flight 要求 `redecision.reopen === true`,declaration never duck-typing——方法在场不算数)
394
- * 由这一格武装:Kata 腿(`capabilities.isolation` 真声明)+ durable park 部署下,`sandbox_local` ask
395
- * 自动放行并以 `permission.sandbox_admitted` durable 事件披露(缺席披露=core 侧变异恰红)。
396
- * `validatingLease` **不声明**:本店没有 durable validating-lease 纪律,不承诺没有的东西。
397
- * 翻向钉:`wiring-governance-operator.test.ts`(修前恰红,与本声明同 commit)。
398
- */
399
192
  redecision = { reopen: true };
400
193
  constructor(db, logger) {
401
194
  this.db = db;
402
195
  this.logger = logger;
403
196
  }
404
- /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
405
197
  q(tidb, pg) {
406
198
  return this.db.dialect === "tidb" ? tidb : pg;
407
199
  }
408
- /** JSON column binding. TiDB: plain `JSON.stringify`, stored verbatim. PG: `pgProtocolJsonStringify` —
409
- * ordinary `JSON.stringify` that **refuses** (throws `PgUnstorableError`) when the value carries a code
410
- * point PG cannot hold (NUL / lone surrogate); the `::jsonb` at the call site is just the bind cast, not
411
- * an escaping layer.
412
- *
413
- * "LOSSLESS" in R4-H1 means exactly **"never silently lossy"**, not "encodes everything": the contrast is
414
- * with `pgSanitizeText`, the lossy U+FFFD-scarring path used for CONTENT faces. An approval row is not a
415
- * content face — the operator's review surface has to agree with the executed args AT the NUL position, so
416
- * scrubbing the byte and storing the scrubbed row is the unacceptable arm; refusing loudly is the chosen one.
417
- * That refusal is why {@link SqlCheckpointStore.fidelity} carries a documented residual (see it). */
418
200
  json(value, label) {
419
201
  return dialectProtocolJsonEncoder(this.db.dialect)(value, label);
420
202
  }
421
- /** 判据属主 = `sql-errors.ts`(A-032 P1-①;旧形 tidb 臂只认 errno ⇒ 只带 `code` 的 dup 会把
422
- * `checkpoint.already_exists` 这条 typed 契约错误退化成裸驱动对象上抛)。 */
423
203
  isDupKey(e) {
424
204
  return isDupKeyError(this.db.dialect, e);
425
205
  }
426
- /** Create-once. core mints the token (`mintCheckpointToken`) and calls this during suspend. */
427
206
  async put(token, cp) {
428
207
  const pa = cp.pendingAction;
429
- const version = checkpointVersionOf(cp); // core helper: cp.version ?? 0 (1.67/1.68-era no-field = legacy 0)
430
- // Persist the (redacted, bounded) tool args alongside so the operator queue can serve the approval-card
431
- // payload without an N+1 trace.turns fetch. Stored as a stringified JSON column value.
208
+ const version = checkpointVersionOf(cp);
432
209
  const toolInput = boundedToolInput(pa?.args);
433
- // [3684]②:归因键之一——core 挂在 pendingAction 上的规则候选,**在 mint 这一刻**落成自己的列。
434
- // 为什么是列而不是 listPending 里现拆 blob:`checkpoint` 列是整只 suspend 快照(会话状态全在里面),
435
- // 运维队列每行拖一份那个 blob 是数量级错误的读放大;同行的 `tool_input`/`bound_input_hash`/`gate_kind`/
436
- // `risk_descriptor` 四列都是同一条「铸行时反范式一格,读口零 blob」的先例。
437
210
  const ruleSuggestions = boundedRuleSuggestions(pa?.ruleSuggestions);
438
211
  try {
439
212
  await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor, rule_suggestions) " +
@@ -449,16 +222,15 @@ export class SqlCheckpointStore {
449
222
  this.json(cp, "checkpoint"),
450
223
  cp.deadline ?? null,
451
224
  cp.createdAt,
452
- Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS), // D-1 §3 inv#3 backstop — STRICTLY after any SLA deadline (grace) so the terminal_at_ms-branch never races the D-D deny-sweep, and never pre-empts an operator's longer TTL
453
- cp.gate?.kind ?? null, // D-D SLA split: human/irreversible_ask deadline → resolve-deny; others → expire
454
- pa?.boundInputHash ?? null, // D-1: the opaque hash the portal must echo on /decide (surfaced via listPending so the TOCTOU binding is reachable)
455
- ((g) => (g?.riskDescriptor ? this.json(g.riskDescriptor, "risk descriptor") : null))(cp.gate), // riskDescriptor inbox: stamp core's INERT descriptor for triage-sort
456
- ruleSuggestions === undefined ? null : this.json(ruleSuggestions, "rule suggestions"), // [3684]②:无供给 ⇒ 列 NULL(读口据它省键,绝不铸 [])
225
+ Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS),
226
+ cp.gate?.kind ?? null,
227
+ pa?.boundInputHash ?? null,
228
+ ((g) => (g?.riskDescriptor ? this.json(g.riskDescriptor, "risk descriptor") : null))(cp.gate),
229
+ ruleSuggestions === undefined ? null : this.json(ruleSuggestions, "rule suggestions"),
457
230
  ]);
458
231
  }
459
232
  catch (e) {
460
233
  if (this.isDupKey(e)) {
461
- // a reused token would clobber a live suspension — never a silent overwrite (checkpoint-store contract)
462
234
  throw new CheckpointError("checkpoint.already_exists", `checkpoint token already exists`);
463
235
  }
464
236
  throw e;
@@ -469,41 +241,19 @@ export class SqlCheckpointStore {
469
241
  const r = rows[0];
470
242
  if (!r)
471
243
  return null;
472
- // Forward-compat guard: never hand a too-new checkpoint to a resume that can't
473
- // parse it. Compare with `<= MAX` (never `=== CURRENT`) using core's exported constant so this
474
- // stays aligned when core bumps the format. This durable-store get-path is the STRONGEST guard (protects a
475
- // worker that doesn't check version itself); core's resume has a second pre-CAS guard.
476
244
  const version = Number(r.version);
477
245
  if (version > MAX_SUPPORTED_CHECKPOINT_VERSION) {
478
246
  throw new Error(`checkpoint format version ${version} is newer than this build supports (max ${MAX_SUPPORTED_CHECKPOINT_VERSION}) — upgrade the service to resume it`);
479
247
  }
480
248
  const cp = parseJson(r.checkpoint);
481
- // the COLUMN status is authoritative (resolve/reap update it, not the blob) — reflect it on the returned cp.
482
249
  cp.status = r.status;
483
- // design/80 D-1: surface the reopen-by-reason + OCC fields off the AUTHORITATIVE columns (the blob is the
484
- // suspend-time snapshot; resolve/reopen mutate only the columns). Core's resume reads these to validate an
485
- // env_failed re-resume against the persisted winner, and to require the `rev` it observed is still live (OCC).
486
- // resolvedOutcome is DERIVED from the stored full outcome by core's `winnerFromOutcome`, so it is preserved
487
- // across a reopen for free (reopen leaves `outcome` untouched). Do NOT read a key list into this line: the
488
- // derived shape is core's and it GROWS (`answer` since 5.7.0, `reason` since 5.16.0 — the operator's deny
489
- // note IS persisted; an older copy of this comment enumerated only {boundCallId,decision,updatedInput?} and
490
- // read as if it weren't). We store the whole outcome blob and round-trip it, so additive keys ride for free
491
- // — `ResolvedOutcome` in core's `checkpoint-store.d.ts` is the single owner of that list.
492
250
  const outcomeRaw = parseJson(r.outcome);
493
251
  if (outcomeRaw)
494
252
  cp.resolvedOutcome = winnerFromOutcome(outcomeRaw);
495
253
  cp.reopenReason = r.reopen_reason ?? undefined;
496
- cp.rev = r.rev == null ? 0 : Number(r.rev); // legacy/pre-migration rows ⇒ 0 (the .d.ts "Absent ⇒ legacy 0")
497
- // design/80 D-A: overlay the parked steer off the AUTHORITATIVE column (set by setPendingSteer AFTER the
498
- // suspend-time blob was written) so core injects it on resume. The blob never carries it.
499
- // core 5.14.0(#147):列现在承载**两个**字段 —— 队列本体 + 旧行折下来的裸单座。两者都要盖上去,
500
- // core 的 `readPendingSteerQueue` 才能把「legacy 座 = member 0,其余按 seq」拼成完整投递序。
501
- // 🔴 坏列在这里**只丢 steer、不抛**:resume 是这条挂起的 run 唯一的出路,为一条读不出的转向把整条
502
- // run 变成不可 resume 是错误的失败方向(写侧相反,见 setPendingSteer)。
254
+ cp.rev = r.rev == null ? 0 : Number(r.rev);
503
255
  const steer = readPendingSteerColumns(r.pending_steer, r.pending_steer_queue);
504
256
  if (steer.lossy) {
505
- // 「静默」到此为止:读不出的部分确实丢了(投递腿不能为一条坏列把整条 run 变成不可 resume),
506
- // 但它必须**留痕** —— 丢的是操作员指令,而 #147 的全部意义就是这类丢失不许无声。
507
257
  this.logger?.info?.("checkpoint_pending_steer_unreadable", { tokenFp: tokenFingerprint(token) });
508
258
  }
509
259
  if (cp.state) {
@@ -512,11 +262,8 @@ export class SqlCheckpointStore {
512
262
  if (steer.state.pendingSteerQueue !== undefined)
513
263
  cp.state.pendingSteerQueue = steer.state.pendingSteerQueue;
514
264
  }
515
- // Diagnostic: log what the REAL runner.resume store.get returns for the remote
516
- // workspaceHandle (the isolated round-trip test can't prove the real-path get). undefined here ⇒ store/get
517
- // drops it; present ⇒ core's 910→614 threading drops it. Cheap, on the resume read-path only.
518
265
  this.logger?.info?.("checkpoint_get", {
519
- tokenFp: tokenFingerprint(token), // NEVER the raw token — it is the resume credential (token-as-auth)
266
+ tokenFp: tokenFingerprint(token),
520
267
  status: cp.status,
521
268
  version,
522
269
  hasWorkspaceHandle: !!cp.state?.workspaceHandle,
@@ -524,25 +271,6 @@ export class SqlCheckpointStore {
524
271
  });
525
272
  return cp;
526
273
  }
527
- /**
528
- * Atomic CAS: pending → resolved, scoped. Returns true for the single winner, false if already
529
- * resolved/expired (AlreadyResolved → caller no-ops, never re-executes). `outcome` is persisted atomically
530
- * with the flip (R20 #1 — a future v2 resumable-resume can recover a crash-after-CAS; v1 runner write-only).
531
- *
532
- * design/80 D-1 (atomicity): the winner-persist (the full `outcome` is stored; the winner
533
- * {boundCallId,decision,updatedInput?} is derived from it on read) + the `rev` bump happen ATOMICALLY in the
534
- * SAME UPDATE as the pending→resolved CAS — one statement, no get→write window. When the caller passes the
535
- * `rev` it observed at get() (ResolveExpectation), the CAS additionally requires the LIVE rev to still equal
536
- * it: ANY intervening resolve/reopen cycle bumps rev, so a cycle in the get→resolve window loses the CAS
537
- * (returns false → core re-inspects → checkpoint.reopened_concurrently). Omitting `expect` keeps the legacy
538
- * unconditional CAS (a first resume of a never-reopened checkpoint is unconstrained by rev).
539
- * 🔴 reopen_reason = NULL on a winning resolve — parity with the core reference InMemoryCheckpointStore
540
- * (MINOR-12: "a freshly-resolved row carries no stale reopen reason"). Without this, an env_failed reopen
541
- * followed by a correct winner-replay resolve leaves reopen_reason='env_failed' stale on the now-resolved
542
- * row; a later DUPLICATE resume of that token would hit core's pre-CAS env_failed winner-replay guard
543
- * against the stale reason and throw checkpoint.reopen_revote (which the service classifies RETRIABLE →
544
- * re-parks the run suspended) instead of the terminal checkpoint.already_resolved — stranding the run.
545
- */
546
274
  async resolve(token, scope, outcome, expect) {
547
275
  const params = [this.json(outcome, "checkpoint outcome"), Date.now(), token, scope];
548
276
  if (expect)
@@ -552,147 +280,56 @@ export class SqlCheckpointStore {
552
280
  (expect ? " AND rev = $5" : "")), params);
553
281
  return res.affected === 1;
554
282
  }
555
- /**
556
- * Inverse of `resolve` (core `CheckpointStore.reopen`): CAS `resolved → pending`. Returns the CAS winner
557
- * (true = was resolved → now pending again). Compensates a post-CAS env-restore failure
558
- * (`resume.env_failed`): `resumeStream` consumes the checkpoint BEFORE `resumeVM`, so a restore failure
559
- * would otherwise strand the suspended work as `resolved` + unfindable (`findPendingTokenBySession` only
560
- * matches `pending`). Reopening lets a retry re-resume the SAME work.
561
- *
562
- * design/80 D-1 (reopen-by-reason): the `reason` distinguishes the two triggers (both fire at core's
563
- * runtask.ts) so the re-resume can validate per reason (§3 inv #1) — `env_failed` = the action is still
564
- * valid, the re-resume MUST replay the persisted winner (core rejects a different decision with
565
- * `checkpoint.reopen_revote`); `tool_unavailable` = the action could not run and may now be stale, so a
566
- * FRESH decision is allowed (P-7). We RECORD the reason on the row and bump `rev` (so a concurrent resume's
567
- * resolve CAS loses — atomicity). 🔴 We DELIBERATELY leave the persisted `outcome` UNTOUCHED (NOT stale junk
568
- * as a pre-D-1 reopen treated it): `resolvedOutcome` derives from it on get(), and an `env_failed` re-resume
569
- * is validated against it — clearing it would defeat the winner-replay invariant.
570
- */
571
283
  async reopen(token, scope, reason) {
572
284
  const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'pending', reopen_reason = ?, rev = rev + 1 WHERE token = ? AND scope = ? AND status = 'resolved'", "UPDATE checkpoint SET status = 'pending', reopen_reason = $1, rev = rev + 1 WHERE token = $2 AND scope = $3 AND status = 'resolved'"), [reason, token, scope]);
573
285
  return res.affected === 1;
574
286
  }
575
- /**
576
- * design/80 D-A (durable steering) —— **core 5.14.0(#147 BREAKING)起是「追加进一条有界有序队列」**,
577
- * 不再是「盖掉单座」。两个人同时给一条挂起的 run 转向,旧语义会让第二条**静默销毁**第一条(一条没人
578
- * 知道丢了的操作员指令);新语义是两条都在,resume 按 `seq` 全数投递。
579
- *
580
- * 语义与边界**全部铸在 core**,本 store 一个都不自铸(契约原文:`appendPendingSteer` is "the shared
581
- * append every backend must build its next queue with: bounds + `seq` mint live there, not once per store"):
582
- * · `validatePendingSteer` —— 每个 impl 内部必跑的 fail-closed 不变式(M2,接入审计 2026-07-08):内容
583
- * 越狱/控制字符 `steering.invalid_content`;**未知字段现在是 REFUSE 而不是丢弃**(领先一个版本的
584
- * producer 必须知道自己的新字段活不过这条挂起的腿);缺席的 `inputId` 在这里铸(uuidv7)。
585
- * · `appendPendingSteer` —— 铸 `seq`、判 `inputId` 幂等(同 id 同载荷 = no-op 返回 true;同 id **不同**
586
- * 载荷 = `steering.duplicate_input_id`,吞掉它就等于把队列要消灭的那种静默丢失原样请回来)、
587
- * 执行两条上界(条数 + 48000 字节整信封),越界一律 `steering.queue_full` fail-loud,**绝不驱逐**
588
- * 一条已被接受的指令。
589
- *
590
- * 🔴 从「一条无条件 UPDATE」变成「读-改-写」,中间那道窗必须自己有 CAS:两个并发 steer 读到同一份
591
- * 队列、后写者整个盖掉前者 —— 就是 #147 要消灭的静默丢失换了个位置复发。守卫是 `pending_steer_rev`
592
- * **专用整数列**(不是拿旧列值比:整数按构造唯一,而长 JSON 文本的相等性取决于排序规则/字段序/空白与
593
- * 转义形,任一处判等松一点就是一次 CAS 假命中 = 悄悄丢一条 steer。#192 A11 更正:本注原先的理由写的是
594
- * "本表无显式 COLLATE,*_ci 会把只差大小写的两份信封判等",那半已不成立——`checkpoint` 现在钉了
595
- * `COLLATE utf8mb4_bin`/PG 侧 `COLLATE "C"`;结论不变,理由回到上面那条更根本的;
596
- * 也不是复用 resolve 的 `rev`:bump 它会让并发 resume 的 `resolve(expect)` 白白落败)。
597
- * CAS 输了就重读重算(队列只有 3 个位子,争用者天然极少);给足冗余仍全败 ⇒ **抛**,不返回 false ——
598
- * `false` 在调用方的语义是「没有 pending 行可转向」(HTTP 409「已不再挂起」),把争用报成那个是撒谎。
599
- *
600
- * 返回:`true` = 落在一条活的 pending 行上(含幂等重放);`false` = 没有该 (token,scope) 的 pending 行。
601
- */
602
287
  async setPendingSteer(token, scope, steer) {
603
- const clean = validatePendingSteer(steer); // throws CheckpointError(steering.invalid_content) — per-impl 一致拒绝
288
+ const clean = validatePendingSteer(steer);
604
289
  for (let attempt = 0; attempt < STEER_CAS_ATTEMPTS; attempt++) {
605
290
  const { rows } = await this.db.query(this.q("SELECT status, pending_steer, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = ? AND scope = ?", "SELECT status, pending_steer, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = $1 AND scope = $2"), [token, scope]);
606
291
  const row = rows[0];
607
292
  if (!row || row.status !== "pending")
608
- return false; // 没有活的 pending 行(与旧的 CAS 落空同义)
293
+ return false;
609
294
  const read = readPendingSteerColumns(row.pending_steer, row.pending_steer_queue);
610
295
  if (read.lossy) {
611
- // 🔴 写侧与 get() 的失败方向刻意相反:往一份读不全的队列上追加,写回时就会把读不出的那部分
612
- // **永久抹掉** —— 那正是 #147 存在的理由(静默丢一条已 park 的操作员指令)。宁可这一次转向失败。
613
296
  throw new Error(`the parked steering queue on checkpoint ${tokenFingerprint(token)} is unreadable — refusing to append onto it ` +
614
297
  `(appending would silently drop the entries this build cannot parse)`);
615
298
  }
616
299
  const before = read.state.pendingSteerQueue?.length ?? 0;
617
- const next = appendPendingSteer(read.state, clean); // throws steering.queue_full / steering.duplicate_input_id
618
- // 幂等重放(同 inputId 同载荷):core 原样奉还既有队列 ⇒ 一个字都不用写,也不必烧一个 rev。
300
+ const next = appendPendingSteer(read.state, clean);
619
301
  if (next.length === before)
620
302
  return true;
621
303
  const rev = Number(row.pending_steer_rev ?? 0);
622
- // 🔴 只写队列列 —— `pending_steer`(旧单座列)本服务**从不写**:滚动升级窗里旧副本对那一列是无条件
623
- // 覆盖写,两边写同一列 = 互相清零(codex 复审 F3)。分列之后旧副本的座与新队列共存,读侧合并。
624
304
  const res = await this.db.query(this.q("UPDATE checkpoint SET pending_steer_queue = ?, pending_steer_rev = pending_steer_rev + 1 " +
625
305
  "WHERE token = ? AND scope = ? AND status = 'pending' AND pending_steer_rev = ?", "UPDATE checkpoint SET pending_steer_queue = $1, pending_steer_rev = pending_steer_rev + 1 " +
626
306
  "WHERE token = $2 AND scope = $3 AND status = 'pending' AND pending_steer_rev = $4"), [this.json(next, "pending steer queue"), token, scope, rev]);
627
307
  if (res.affected > 0)
628
308
  return true;
629
- // CAS 输了:另一个 steer 抢先追加(或这行刚被 resolve/expire)⇒ 重读重算。下一轮的 SELECT 会
630
- // 分辨这两种情形:行还 pending 就再追加一次,不 pending 就返回 false。
631
309
  }
632
310
  throw new Error(`setPendingSteer lost the pending_steer_rev CAS ${STEER_CAS_ATTEMPTS} times on checkpoint ${tokenFingerprint(token)} — ` +
633
311
  `refusing to report it as "no pending checkpoint" (that would silently discard an operator instruction)`);
634
312
  }
635
- /**
636
- * CAS-expire a SINGLE checkpoint by token (core 1.71 `CheckpointStore.expire`, for `TaskStream.destroy`'s
637
- * suspended-worker reap — core [R33] {delta}). `pending → expired`, returns the CAS winner (true = we
638
- * expired it; false = already resolved/expired). Same UPDATE as `reap`, keyed by **token** not deadline.
639
- * It races the SAME `pending` row as `resolve`, so the two are mutually exclusive: destroy() must `expire`
640
- * (fence) BEFORE destroying the env — if a concurrent resume already won `resolve`, expire returns false and
641
- * destroy leaves the env for that resume; if expire wins, the resume's resolve fails → never resume-to-dead-env.
642
- * Uses `expired` (not a `resolve`-deny) so a CANCELLED checkpoint never pollutes resolved-count / outcome.
643
- */
644
313
  async expire(token, scope) {
645
314
  const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? WHERE token = ? AND scope = ? AND status = 'pending'", "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 WHERE token = $2 AND scope = $3 AND status = 'pending'"), [Date.now(), token, scope]);
646
315
  return res.affected === 1;
647
316
  }
648
- /** Interface reap: CAS-expire pending checkpoints in `scope` past `cutoff`. Returns count. */
649
317
  async reap(scope, cutoff) {
650
318
  const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? WHERE scope = ? AND status = 'pending' AND deadline IS NOT NULL AND deadline <= ?", "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 WHERE scope = $2 AND status = 'pending' AND deadline IS NOT NULL AND deadline <= $3"), [Date.now(), scope, cutoff]);
651
319
  return res.affected;
652
320
  }
653
- /**
654
- * GLOBAL sweep for the service's per-replica TTL reaper (expiry isn't tenant-
655
- * sensitive — only `resolve` is scoped). Idempotent across replicas (DB serializes; no election). Returns count.
656
- * Called with `cutoff = Date.now()` (deadline/terminal_at_ms are ABSOLUTE epoch-ms), so it expires any pending row
657
- * whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `terminal_at_ms` crash-safe backstop has passed —
658
- * the latter closes the forever-leak of a pending row with a NULL `deadline` (no approval TTL was configured).
659
- *
660
- * design/80 D-D: the deadline-branch EXPIRES (≈ abort) every kind EXCEPT a tool-approval human/irreversible_ask
661
- * gate — those are resolve-DENIED gracefully by the SLA deny-sweep (listExpiredApprovalGates →
662
- * resumeCheckpoint(deny)), so expiring them here too would race + lose the graceful denial. 🔴 A durable
663
- * AskUserQuestion ALSO mints gate.kind='human' (no question-specific kind in core) — but DENYING a question is
664
- * incoherent (the model gets a "denied" tool-result, not an answer), so it is carved BACK INTO the expire path
665
- * (COALESCE(tool_name,'')='AskUserQuestion') to abort-expire on timeout instead. Legacy rows (gate_kind NULL)
666
- * stay on the expire path. The terminal_at_ms-branch is the crash-safe backstop for ANY kind (incl. a human gate
667
- * whose deny-resume keeps failing) — it always abort-expires past the absolute cap (which is now STRICTLY after
668
- * the deadline, so it never races the deny-sweep at the deadline instant).
669
- */
670
321
  async reapExpired(cutoff) {
671
322
  const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? " +
672
323
  `WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= ? AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at_ms IS NOT NULL AND terminal_at_ms <= ?))`, "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 " +
673
324
  `WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= $2 AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at_ms IS NOT NULL AND terminal_at_ms <= $3))`), [Date.now(), cutoff, cutoff]);
674
325
  return res.affected;
675
326
  }
676
- /**
677
- * design/80 D-D (SLA-timer): the sessions of pending human/irreversible_ask checkpoints past their `deadline`
678
- * (the per-approval SLA). The reaper resolve-DENIES each (re-resume with decision=deny → the model continues
679
- * with the denial) — graceful, vs the abort that expire() gives resource_limit/needs_review. Excludes legacy
680
- * rows (gate_kind NULL — they predate the split and fall through to reapExpired's expire path). LIMIT bounds
681
- * the per-tick deny-resume work (each is a model run). Scope is read off the COMMITTED row (invariant #4).
682
- * 🔴 EXCLUDES AskUserQuestion gates (which also mint gate.kind='human'): denying a question is incoherent —
683
- * the model would get a "denied" tool-result instead of an answer — so a timed-out durable question falls to
684
- * reapExpired's abort-expire path instead of this graceful-deny path.
685
- */
686
327
  async listExpiredApprovalGates(cutoff, limit = 100) {
687
328
  const { rows } = await this.db.query(this.q(`SELECT session_id, scope FROM checkpoint WHERE status = 'pending' AND gate_kind IN ${APPROVAL_GATE_KINDS_SQL_IN} ` +
688
329
  "AND COALESCE(tool_name,'') <> 'AskUserQuestion' AND deadline IS NOT NULL AND deadline <= ? ORDER BY deadline ASC LIMIT ?", `SELECT session_id, scope FROM checkpoint WHERE status = 'pending' AND gate_kind IN ${APPROVAL_GATE_KINDS_SQL_IN} ` +
689
330
  "AND COALESCE(tool_name,'') <> 'AskUserQuestion' AND deadline IS NOT NULL AND deadline <= $1 ORDER BY deadline ASC LIMIT $2"), [cutoff, limit]);
690
331
  return rows.map((r) => ({ sessionId: String(r.session_id), scope: String(r.scope) }));
691
332
  }
692
- // ─────────────────────── operator approval queue (non-secret; token stays internal) ───────────────────────
693
- /** Pending checkpoints for the operator queue — NON-secret fields only (never the token). `taskId` is
694
- * joined from task_active (the JOIN KEY to the run/trace — a suspended run KEEPS its
695
- * session claim, so the join is live for every pending row; null only in pathological windows). */
696
333
  async listPending(scope) {
697
334
  const base = "SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, c.tool_input, c.bound_input_hash, c.risk_descriptor, c.rule_suggestions, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id " +
698
335
  "FROM checkpoint c LEFT JOIN task_active ta ON ta.session_id = c.session_id WHERE c.status='pending'";
@@ -703,60 +340,29 @@ export class SqlCheckpointStore {
703
340
  const toolCallId = r.tool_call_id ?? null;
704
341
  const boundInputHash = r.bound_input_hash ?? null;
705
342
  const gateKind = r.gate_kind ?? null;
706
- // [3684]②:回读也走**同一个** `boundedRuleSuggestions`(不是裸 parse 直投)—— 形的属主只有一个,
707
- // 手改过的行/未来列语义漂了都在这里当场落成「无供给」而不是把垃圾投上运维队列。列已在 mint 面脱敏,
708
- // 这一遍照常脱敏(纵深防御:手改过的行也不许把凭证投上跨租户队列),只是**不计数**
709
- // (值幂等但计数不幂等,见该函数 `opts.countRedactions` 顶注)。
710
343
  const ruleSuggestions = readRuleSuggestionsCell(r.rule_suggestions);
711
344
  return {
712
345
  sessionId: String(r.session_id),
713
346
  scope: String(r.scope),
714
347
  toolName: r.tool_name ?? null,
715
348
  toolCallId,
716
- // D-1: boundCallId === the pending tool call id; boundInputHash is the server-minted opaque hash. The
717
- // portal echoes BOTH on /decide so the resume binds to the exact action shown (TOCTOU guard reachable).
718
- // [1995]②: a park with no tool action (plan_review / resource_limit / task_done — both columns NULL)
719
- // OMITS the keys entirely; the wire contract says absent, never `null` (LOCAL twin does the same).
720
349
  ...(toolCallId !== null ? { boundCallId: toolCallId } : {}),
721
350
  ...(boundInputHash !== null ? { boundInputHash } : {}),
722
- // [1995]③ additive: the gate kind the row is parked on (NULL only on a pre-`gate_kind` legacy row) —
723
- // lets a durable-recovery consumer route plan / tool / resource off this ONE queue.
724
351
  ...(gateKind !== null ? { gateKind } : {}),
725
352
  taskId: r.task_id ?? null,
726
- // Both drivers return the JSON column already parsed; null for pre-migration rows. PG 库内恒干净
727
- // (拒绝式)——直读即审阅面=执行面。
728
353
  input: r.tool_input ?? null,
729
354
  createdAt: Number(r.created_at_ms),
730
355
  deadline: r.deadline == null ? null : Number(r.deadline),
731
- // 重扫二轮:与下一格同族的**容错回读**(strict 形会把一行手改坏的 cell 变成整只队列 500,
732
- // 判据与失败方向逐字见 {@link readRiskDescriptorCell} 顶注)。
733
356
  riskDescriptor: readRiskDescriptorCell(r.risk_descriptor),
734
- // [3684]②:无供给 ⇒ **省键**(同 boundCallId/gateKind 的 [1995]② 纪律,绝不 null/[])。
735
357
  ...(ruleSuggestions !== undefined ? { ruleSuggestions } : {}),
736
358
  };
737
359
  });
738
- // riskDescriptor inbox: triage-sort by severity DESC (highest-risk asks first), then oldest-first within a
739
- // tier. A row with no descriptor (pre-migration / non-gate) sorts to severity 0 (after the descriptored ones).
740
360
  return out.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
741
361
  }
742
- /**
743
- * design/80 #2 (scheduler seam, core 1.105): a scope's PENDING checkpoints as lightweight
744
- * {@link CheckpointSummary} rows in ONE query — the cross-task triage view a supervisor scheduler needs
745
- * (gate kind + risk severity + budget spent + deadline per suspended task), no N+1 `get`s. The projection is
746
- * core's shared {@link summarizeCheckpoint} run over the persisted blob (the `checkpoint` column = the same full
747
- * {@link Checkpoint} `get()` parses), so this stays byte-identical to core's InMemory/Pg/File impls. Order =
748
- * created_at_ms ASC; callers (the inbox/scheduler) sort by severity. The COLUMN `status` is authoritative (the blob
749
- * is the suspend-time snapshot), so it overrides the blob's status before the summary is derived.
750
- *
751
- * LIMIT bounds the fan-out (review w16yqkkxv): a triage view never needs more than a few — 500 is a generous
752
- * ceiling that still protects memory/latency if a scope ever accumulates pathologically many pending gates.
753
- */
754
362
  async listByScope(scope) {
755
363
  const { rows } = await this.db.query(this.q("SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at_ms ASC LIMIT 500", "SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at_ms ASC LIMIT 500"), [scope]);
756
364
  const out = [];
757
365
  for (const r of rows) {
758
- // A corrupt/missing blob skips THAT row rather than crashing the whole scheduler view (review w16yqkkxv):
759
- // unlike get() (a single-token read where a null blob is a hard error), a bulk triage list must degrade.
760
366
  const cp = parseJson(r.checkpoint);
761
367
  if (!cp)
762
368
  continue;
@@ -765,52 +371,18 @@ export class SqlCheckpointStore {
765
371
  }
766
372
  return out;
767
373
  }
768
- /**
769
- * Map a session (the non-secret handle the operator decides by) → its single pending checkpoint token,
770
- * INTERNALLY. A suspended session has exactly one pending checkpoint. `scope` is enforced so an operator
771
- * can only resolve within its own tenant. Returns null if there is no pending checkpoint for that session/scope.
772
- */
773
374
  async findPendingTokenBySession(sessionId, scope) {
774
375
  const { rows } = scope
775
376
  ? await this.db.query(this.q("SELECT token FROM checkpoint WHERE session_id=? AND scope=? AND status='pending' LIMIT 1", "SELECT token FROM checkpoint WHERE session_id=$1 AND scope=$2 AND status='pending' LIMIT 1"), [sessionId, scope])
776
377
  : await this.db.query(this.q("SELECT token FROM checkpoint WHERE session_id=? AND status='pending' LIMIT 1", "SELECT token FROM checkpoint WHERE session_id=$1 AND status='pending' LIMIT 1"), [sessionId]);
777
378
  return rows[0] ? String(rows[0].token) : null;
778
379
  }
779
- /**
780
- * 🔴 #151 车5 §9 C4 —— 流内审批对账收敛器判据 1 的**窄读口**(店面承载;收敛器本体不在本车)。
781
- *
782
- * 「这条 PARKING 的 ask 究竟 park 成了哪张 checkpoint?」的唯一读法。为什么不是「按 session 翻历史页」:
783
- * 分页宽读会漏匹配,而漏匹配在收敛器那侧的后果是**假阴性 ⇒ 落一条不可逆的 DENIED**。所以这里改成
784
- * 谓词精确查——`(scope, session_id, tool_call_id, created_at_ms ≥ sinceMs)` 这组条件下的行数天然极小,
785
- * 一次全量返回,结构上没有分页假阴性。
786
- *
787
- * 三条口径,逐条都是判据:
788
- * - **`scope` 必填**(不是 optional):对账是跨腿读,租户门是读口自己的责任,不靠调用方记得过滤
789
- * (同 `listPendingBySession` 姿势;`resolve`/`reap` 的 scope enforced 同族)。
790
- * - **不按 status 过滤**:已 `resolved`/`expired` 的行照样返回。后到的 resume 会把行翻成 resolved,
791
- * 若只看 pending 就会把「park 成功过」这个既成事实读成「没 park 过」⇒ 又是一条假阴性 DENIED
792
- * (对账三约束③)。park 成功是事实,gate 的活性由 `status` 表达,交给收敛器自己判。
793
- * - **单行读不出 ⇒ 标记返回,不抛**:`unparseable` 的行由收敛器视同「不匹配」,一条坏行不许打断整段
794
- * 扫描(§8 C-6)。什么算读不出:blob 的 `version` 超出本 build 支持(`get()` 那条前向兼容门在这里
795
- * 不能 throw,否则一条超前行会让整个 session 的对账永久卡死)、或 blob JSON 坏。
796
- *
797
- * 匹配是两段的:`tool_call_id` **列**是 `put()` 从 `pendingAction.toolCallId` 盖下来的权威投影,SQL 谓词
798
- * 先按它(或 NULL)收窄;列为 NULL 的行(无工具动作的 park,或列存在之前的旧行)靠解 blob 补判——解得出
799
- * 且相等才算候选,解不出就标 `unparseable`。
800
- * 🔴 **没有「列命中即零解析」的快路径**(原注写过,已作废,别照它优化):函数体对**每一行**无条件解
801
- * blob,原因有二 ——(a) 前向兼容门与 blob 可读性门必须门在**所有**命中路径之前(codex F3 + 确认轮:
802
- * 列长得对不代表 blob 读得出,放行一条读不出的行去 `bindBatch` 会把 ask 钉成 PARKED + 一张本进程读不出
803
- * 的 resume 坐标,而 PARKED 不可回滚);(b) `#168` 之后 blob 顶层的 `sourceTaskId` 是和解三元组的第一
804
- * 维,列命中行结构上也必须解 blob 才拿得到它。
805
- */
806
380
  async findCheckpointCandidatesForAsk(scope, sessionId, toolCallId, sinceMs) {
807
381
  const { rows } = await this.db.query(this.q("SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
808
382
  "WHERE scope=? AND session_id=? AND created_at_ms>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at_ms ASC", "SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
809
383
  "WHERE scope=$1 AND session_id=$2 AND created_at_ms>=$3 AND (tool_call_id=$4 OR tool_call_id IS NULL) ORDER BY created_at_ms ASC"), [scope, sessionId, sinceMs, toolCallId]);
810
384
  const out = [];
811
385
  for (const r of rows) {
812
- // `sourceTaskId: null` 是**所有 unparseable 臂的共同底**(读不出的行不许带出一个可用于身份比对的
813
- // 值);健康臂在下面用 blob 解出来的值覆盖它。
814
386
  const base = {
815
387
  token: String(r.token),
816
388
  status: String(r.status),
@@ -818,67 +390,37 @@ export class SqlCheckpointStore {
818
390
  boundInputHash: r.bound_input_hash == null ? null : String(r.bound_input_hash),
819
391
  sourceTaskId: null,
820
392
  };
821
- // 🔴 前向兼容门必须在**任何**命中路径之前(codex 复审 F3,2026-08-06 真缺陷):版本超前的行是
822
- // `get()` 明确拒读的行,而 `tool_call_id` 列命中与否跟能不能读懂 blob 毫无关系。若让列的快路径
823
- // 绕过这道门,收敛器就会把它当健康候选去 `bindBatch` ⇒ ask 被钉成 **PARKED + 一张本进程读不出的
824
- // checkpoint 的 resume 坐标**,而 PARKED 不可回滚。判据只能是「宁可不命中,绝不错配」(§8 D-2)。
825
- // 门在这里**不抛**(见头注):标 unparseable 交给收敛器当「不确定」,单行不打断整段扫描。
826
393
  if (Number(r.version) > MAX_SUPPORTED_CHECKPOINT_VERSION) {
827
394
  out.push({ ...base, boundCallId: null, unparseable: true });
828
395
  continue;
829
396
  }
830
- // 🔴 blob 可读性同样门在**所有**命中路径之前(codex 确认轮补抓):`tool_call_id` 列长得对,不代表
831
- // 这张 checkpoint 读得出来——列是 put() 时的投影,blob 才是 resume 真正要吃的东西。让「列命中」
832
- // 跳过可读性检查,等于给「blob 损坏 / schema 偏斜」留了一条与 F3 完全相同的不可逆后果:
833
- // 收敛器把 ask 钉成 PARKED + 这张 checkpoint 的坐标,而后续 `get()` 必然失败。
834
- // 读口的契约只有一条——**读不出就标记**,没有「有一列长得对就豁免」的例外。
835
397
  const derived = pendingActionToolCallId(r.checkpoint);
836
398
  if (!derived.readable) {
837
399
  out.push({ ...base, boundCallId: null, unparseable: true });
838
400
  continue;
839
401
  }
840
402
  const col = r.tool_call_id == null ? null : String(r.tool_call_id);
841
- // 列与 blob **互相矛盾** ⇒ 判别不出谁对,标 unparseable(「宁可不命中,绝不错配」,§8 D-2)。
842
- // 🔴 矛盾的定义是「列说得出、而 blob 不同意」——同时覆盖两形:blob 给了**别的** callId,以及
843
- // blob 说这条 park **根本没有工具动作**(`toolCallId === null`)。后者一样是漂移(列声称有一次
844
- // 工具调用、blob 否认),早先只判「两边都非空且不等」会把它漏成健康候选。
845
- // 现网 put() 把列与 blob 同源同刻写下,矛盾只可能来自外部改库或 schema 偏斜——正是该保守的场合。
846
403
  if (col !== null && col !== derived.toolCallId) {
847
404
  out.push({ ...base, boundCallId: null, unparseable: true });
848
405
  continue;
849
406
  }
850
- // 🔴 bound_input_hash 的列/blob 一致性门(属主兜底轮 F1)——与上面 toolCallId 的矛盾门同款:
851
- // 返回给收敛器的 hash 是「identity ∧ hash 双等」硬谓词的**输入**,列被外部改成漂移值(乃至恰好
852
- // 改成另一只 ask 的 hash)时,只信列 = 把双等谓词架空成单等。两边都在场且不等 ⇒ unparseable;
853
- // 列 NULL 而 blob 有值 ⇒ 用 blob 值补齐(旧行回落,与 toolCallId 的「列 NULL 回落 blob」同规)。
854
407
  if (base.boundInputHash !== null && derived.boundInputHash !== null && base.boundInputHash !== derived.boundInputHash) {
855
408
  out.push({ ...base, boundCallId: null, unparseable: true });
856
409
  continue;
857
410
  }
858
411
  const effectiveHash = base.boundInputHash ?? derived.boundInputHash;
859
- // 列在场时以列为准(权威投影);列为 NULL 的旧行回落用 blob 解出来的值。
860
412
  const effective = col ?? derived.toolCallId;
861
413
  if (effective === toolCallId)
862
414
  out.push({ ...base, boundInputHash: effectiveHash, sourceTaskId: derived.sourceTaskId, boundCallId: effective });
863
- // 解得出、但不是这只 ask 的 callId(或这条 park 本来就没有工具动作)⇒ **确定**不是候选:
864
- // 既不返回也不标坏行(标坏行会让收敛器把一条明确的「不匹配」当成「不确定」)。
865
415
  }
866
416
  return out;
867
417
  }
868
- /**
869
- * The owner SCOPE of a session's pending checkpoint (the multi-tenant key === the owner principal in the
870
- * BFF/non-operator flow, the same key listPending filters by). For the /decide owner-gate: a non-operator
871
- * caller may only decide a checkpoint in their OWN scope. Returns `undefined` (no pending row), `null`
872
- * (anonymous/dev session — open), or the owner scope string.
873
- */
874
418
  async peekPendingScope(sessionId) {
875
419
  const { rows } = await this.db.query(this.q("SELECT scope FROM checkpoint WHERE session_id=? AND status='pending' LIMIT 1", "SELECT scope FROM checkpoint WHERE session_id=$1 AND status='pending' LIMIT 1"), [sessionId]);
876
420
  if (!rows[0])
877
421
  return undefined;
878
422
  return rows[0].scope ?? null;
879
423
  }
880
- // ─────────────────────── checkpoint_ctx (service rebuild inputs; sessionId-keyed) ───────────────────────
881
- /** Upsert the resume rebuild inputs for a session (written at SUBMIT when durable mode is on). */
882
424
  async putCtx(sessionId, ctx) {
883
425
  await this.db.query(this.q("INSERT INTO checkpoint_ctx (session_id, ctx, updated_at_ms) VALUES (?,?,?) " +
884
426
  "ON DUPLICATE KEY UPDATE ctx = VALUES(ctx), updated_at_ms = VALUES(updated_at_ms)", "INSERT INTO checkpoint_ctx (session_id, ctx, updated_at_ms) VALUES ($1,$2::jsonb,$3) " +
@@ -888,17 +430,6 @@ export class SqlCheckpointStore {
888
430
  const { rows } = await this.db.query(this.q("SELECT ctx FROM checkpoint_ctx WHERE session_id=?", "SELECT ctx FROM checkpoint_ctx WHERE session_id=$1"), [sessionId]);
889
431
  return rows[0] ? parseJson(rows[0].ctx) : null;
890
432
  }
891
- /**
892
- * GC ctx rows whose lifecycle is over (bound to the checkpoint, don't leak):
893
- * a session with NO pending checkpoint, NO active run, AND not touched since `cutoff`.
894
- *
895
- * The extra `task_active` guard closes a multi-gate window: between a resume's CAS (pending→resolved) and
896
- * the task hitting its NEXT gate (writing a new pending checkpoint), the session has no pending checkpoint —
897
- * yet `putCtx` only stamps `updated_at_ms` at submit, so the ctx could be older than `cutoff` and was being
898
- * reaped, making the next resume 409 ("resume context missing"). `task_active` holds the session lock for
899
- * the WHOLE submit→last-resume-complete span (running OR suspended), so excluding sessions with an active
900
- * claim keeps the ctx exactly as long as a run still needs it, while still GC'ing it once the run is terminal.
901
- */
902
433
  async reapCtx(cutoff) {
903
434
  const res = await this.db.query(this.q("DELETE FROM checkpoint_ctx WHERE updated_at_ms < ? " +
904
435
  "AND session_id NOT IN (SELECT session_id FROM checkpoint WHERE status='pending') " +
@@ -907,22 +438,6 @@ export class SqlCheckpointStore {
907
438
  "AND session_id NOT IN (SELECT session_id FROM task_active)"), [cutoff]);
908
439
  return res.affected;
909
440
  }
910
- /**
911
- * E21 (§0.5 session delete) — purge ALL checkpoint state for one session: the durable checkpoint rows
912
- * (suspended-task gates) AND their service-side resume-context rows (checkpoint_ctx). Keyed on the
913
- * `session_id` column both tables carry. Idempotent (returns the count of `checkpoint` rows removed).
914
- * Unconditional by design: a session being deleted has no resumable future, so any pending gate is moot.
915
- *
916
- * `owner` is a single-DB-fleet defense-in-depth guard (adversarial-review MEDIUM). Neither table carries an
917
- * owner column, so the guard threads through `session_meta.owner` via an EXISTS sub-select (null-safe compare:
918
- * `<=>` on TiDB, `IS NOT DISTINCT FROM` on PG). The purge coordinator runs this BEFORE deleting session_meta,
919
- * so the meta row (with its authoritative owner) is still present here — a wrong-owner caller's EXISTS fails
920
- * and purges nothing. `owner=null` (legacy/dev, never threaded by the route) keeps the historical
921
- * unconditional behaviour via the null-safe match.
922
- *
923
- * 🔴 Placeholder ARITY differs, not just the syntax: `?` cannot be reused, so the TiDB arm binds `sessionId`
924
- * TWICE (row key + guard sub-select) while PG reuses `$1`.
925
- */
926
441
  async deleteBySession(sessionId, owner) {
927
442
  const ownerGuard = this.q("EXISTS (SELECT 1 FROM session_meta sm WHERE sm.session_id = ? AND sm.owner <=> ?)", "EXISTS (SELECT 1 FROM session_meta sm WHERE sm.session_id = $1 AND sm.owner IS NOT DISTINCT FROM $2)");
928
443
  const params = this.db.dialect === "tidb" ? [sessionId, sessionId, owner] : [sessionId, owner];
@@ -931,13 +446,11 @@ export class SqlCheckpointStore {
931
446
  return res.affected;
932
447
  }
933
448
  }
934
- /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */
935
449
  export class TiDBCheckpointStore extends SqlCheckpointStore {
936
450
  constructor(pool, logger) {
937
451
  super(mysqlDriver(pool), logger);
938
452
  }
939
453
  }
940
- /** PostgreSQL binding — historical class name + ctor shape preserved. */
941
454
  export class PgCheckpointStore extends SqlCheckpointStore {
942
455
  constructor(pool, logger) {
943
456
  super(pgDriver(pool), logger);