@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
@@ -4,27 +4,20 @@ import { pgSanitizeText } from "./pg-safe-json.js";
4
4
  import { parseJsonLenient as parseJson, toIso as iso } from "./sql-row-helpers.js";
5
5
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
6
6
  import { isDupKeyError } from "./sql-errors.js";
7
- /** Dual-dialect async run registry + event log. See the file header for the dialect-delta ledger. */
8
7
  export class SqlRunStore {
9
8
  db;
10
9
  constructor(db) {
11
10
  this.db = db;
12
11
  }
13
- /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
14
12
  q(tidb, pg) {
15
13
  return this.db.dialect === "tidb" ? tidb : pg;
16
14
  }
17
- /** Dup-key classification — single owner in `sql-errors.ts` (A-032 P1-①; the old local form read
18
- * only mysql2's string `code`, so an error carrying just `errno` fell through the claim-race arm). */
19
15
  isDupKey(err) {
20
16
  return isDupKeyError(this.db.dialect, err);
21
17
  }
22
- /** JSON column binding: TiDB stores the string verbatim; PG goes through the protocol-safe envelope
23
- * (a raw NUL byte in an event payload would otherwise throw 22P05 mid-write). */
24
18
  json(value) {
25
19
  return dialectJsonEncoder(this.db.dialect)(value);
26
20
  }
27
- /** Run statements atomically; rolls back on failure. */
28
21
  async tx(fn) {
29
22
  const conn = await this.db.connect();
30
23
  try {
@@ -37,7 +30,6 @@ export class SqlRunStore {
37
30
  await conn.rollback();
38
31
  }
39
32
  catch {
40
- /* ignore */
41
33
  }
42
34
  throw err;
43
35
  }
@@ -45,14 +37,6 @@ export class SqlRunStore {
45
37
  conn.release();
46
38
  }
47
39
  }
48
- /**
49
- * Create a run, but only if the session has no other active run (double-text guard).
50
- *
51
- * Mutual exclusion is a unique-key claim on `task_active(session_id)` — NOT a SELECT…FOR UPDATE,
52
- * which on TiDB (no gap locks) would fail to serialize two concurrent createRuns when the session
53
- * has no prior row. A plain INSERT either claims the session or throws a duplicate-key error (TiDB
54
- * does NOT report a reliable affectedRows for `ON DUPLICATE KEY UPDATE …=…`, so we catch dup-key).
55
- */
56
40
  async createRun(taskId, sessionId, owner, instanceId, meta = {}) {
57
41
  const conn = await this.db.connect();
58
42
  try {
@@ -78,8 +62,6 @@ export class SqlRunStore {
78
62
  instanceId,
79
63
  meta.jobId ?? null,
80
64
  meta.source ?? null,
81
- // PG-only (R12 同族): objective 预览=模型可控标量,走 pgSanitizeText(NUL/protocol-byte 防护)。
82
- // TiDB 存 verbatim(无该字节类)。
83
65
  this.db.dialect === "tidb"
84
66
  ? meta.objectivePreview ?? null
85
67
  : meta.objectivePreview == null
@@ -96,7 +78,6 @@ export class SqlRunStore {
96
78
  await conn.rollback();
97
79
  }
98
80
  catch {
99
- /* ignore */
100
81
  }
101
82
  throw err;
102
83
  }
@@ -104,79 +85,39 @@ export class SqlRunStore {
104
85
  conn.release();
105
86
  }
106
87
  }
107
- /** Request cancel of a RUNNING run (durable, cross-replica). Sets the flag the owning instance's heartbeat
108
- * tick polls; a same-replica caller additionally aborts the in-flight controller for instant effect. Only
109
- * flags a `running` row (terminal/suspended → 0 affected = no-op). Returns whether a running row was flagged.
110
- *
111
- * `owner` is a single-DB-fleet defense-in-depth guard: the WHERE pins the row to the authenticated owner
112
- * (null-safe compare so a legacy null-owner run matches owner=null), so even if the HTTP owner-gate has a bug
113
- * a caller can never flip another tenant's run. Pass the run's owner (from the looked-up RunRecord). */
114
88
  async requestCancel(taskId, owner) {
115
89
  const { affected } = await this.db.query(this.q("UPDATE task_run SET cancel_requested = 1 WHERE task_id = ? AND owner <=> ? AND status = 'running'", "UPDATE task_run SET cancel_requested = 1 WHERE task_id = $1 AND owner IS NOT DISTINCT FROM $2 AND status = 'running'"), [taskId, owner]);
116
90
  return affected > 0;
117
91
  }
118
- /** Whether a cancel has been requested for this run (polled by the owning instance's heartbeat tick).
119
- * `owner` is the single-DB-fleet null-safe owner guard (see {@link requestCancel}). */
120
92
  async isCancelRequested(taskId, owner) {
121
93
  const { rows } = await this.db.query(this.q("SELECT cancel_requested FROM task_run WHERE task_id = ? AND owner <=> ?", "SELECT cancel_requested FROM task_run WHERE task_id = $1 AND owner IS NOT DISTINCT FROM $2"), [taskId, owner]);
122
94
  return Boolean(rows[0]?.cancel_requested);
123
95
  }
124
- /** design/80 seam #2: request PREEMPT (graceful durable yield, ≠ cancel's kill) of a RUNNING run — durable +
125
- * cross-replica. Sets the flag the owning instance's heartbeat tick polls (it then aborts its preemptSignal →
126
- * the task suspends at the next clean turn boundary, gate resource_limit/reason preempt); a same-replica caller
127
- * additionally aborts the in-flight preempt controller for instant effect. Only flags a `running` row
128
- * (terminal/suspended → 0 affected = no-op). Returns whether a running row was flagged.
129
- * `owner` is the single-DB-fleet null-safe owner guard (see {@link requestCancel}). */
130
96
  async requestPreempt(taskId, owner) {
131
97
  const { affected } = await this.db.query(this.q("UPDATE task_run SET preempt_requested = 1 WHERE task_id = ? AND owner <=> ? AND status = 'running'", "UPDATE task_run SET preempt_requested = 1 WHERE task_id = $1 AND owner IS NOT DISTINCT FROM $2 AND status = 'running'"), [taskId, owner]);
132
98
  return affected > 0;
133
99
  }
134
- /** Whether a preempt has been requested for this run (polled by the owning instance's heartbeat tick).
135
- * `owner` is the single-DB-fleet null-safe owner guard (see {@link requestCancel}). */
136
100
  async isPreemptRequested(taskId, owner) {
137
101
  const { rows } = await this.db.query(this.q("SELECT preempt_requested FROM task_run WHERE task_id = ? AND owner <=> ?", "SELECT preempt_requested FROM task_run WHERE task_id = $1 AND owner IS NOT DISTINCT FROM $2"), [taskId, owner]);
138
102
  return Boolean(rows[0]?.preempt_requested);
139
103
  }
140
- /** Liveness heartbeat: refresh updated_at for a still-running run, independent of events. The
141
- * running instance calls this on a timer so a long-but-alive turn (e.g. a subagent that emits no
142
- * parent events for minutes) is not mistaken for a dead instance.
143
- * `owner` is the single-DB-fleet null-safe owner guard (see {@link requestCancel}); the heartbeat is
144
- * always issued by the owning instance with the run's own owner, so this only narrows, never blocks. */
145
104
  async heartbeat(taskId, owner) {
146
105
  await this.db.query(this.q("UPDATE task_run SET updated_at = ? WHERE task_id = ? AND owner <=> ? AND status = 'running'", "UPDATE task_run SET updated_at = $1 WHERE task_id = $2 AND owner IS NOT DISTINCT FROM $3 AND status = 'running'"), [new Date(), taskId, owner]);
147
106
  }
148
- /** Append one event. A single INSERT — `updated_at` (liveness) is owned by `heartbeat`, so there
149
- * is no second statement to keep atomic here. */
150
107
  async appendEvent(taskId, seq, type, data) {
151
108
  await this.db.query(this.q("INSERT INTO task_event (task_id, seq, type, data, ts) VALUES (?,?,?,?,?)", "INSERT INTO task_event (task_id, seq, type, data, ts) VALUES ($1,$2,$3,$4::jsonb,$5)"), [taskId, seq, type, data == null ? null : this.json(data), new Date()]);
152
109
  }
153
- /** Highest event seq for a task (0 if none). Lets a resumed leg CONTINUE the durable event log past the
154
- * suspend (core 1.70 `resumeStream`) — the suspend wrote events 1..K, the resume appends from K+1 without
155
- * colliding on the (task_id, seq) PK. */
156
110
  async maxSeq(taskId) {
157
111
  const { rows } = await this.db.query(this.q("SELECT MAX(seq) AS m FROM task_event WHERE task_id = ?", "SELECT MAX(seq) AS m FROM task_event WHERE task_id = $1"), [taskId]);
158
112
  const m = rows[0]?.m;
159
113
  return m == null ? 0 : Number(m);
160
114
  }
161
- /** Mark terminal AND release the single-active-run claim atomically — if the DELETE were a separate
162
- * statement that failed, the session would stay permanently locked. [888] FIRST terminal writer wins:
163
- * the UPDATE is CAS'd POSITIVELY on the live statuses (running/suspended/needs_review) — every call site
164
- * is a settle-once flow whose backstops may double-fire (cancel × leg-throw × reaper), and an
165
- * unconditional write let the LAST racer overwrite errorCode (a `cancelled` row flipping to a generic
166
- * abort text, or vice versa). Positive form ([1.207 codex M1]): the negative NOT IN missed the
167
- * blocked/timeout terminals — a first write of those could still be overwritten. The claim DELETE stays
168
- * unconditional (idempotent; the first writer already released it). */
169
115
  async setTerminal(taskId, status, result, error) {
170
- // Denormalize the structured code into its own column so operators can `WHERE error_code LIKE
171
- // 'budget.%'` without parsing every result JSON. Source of truth stays the result blob.
172
116
  const errorCode = result?.errorCode ?? null;
173
117
  await this.tx(async (conn) => {
174
118
  await conn.query(this.q("UPDATE task_run SET status = ?, result = ?, error = ?, error_code = ?, updated_at = ? WHERE task_id = ? AND status IN ('running','suspended','needs_review')", "UPDATE task_run SET status = $1, result = $2::jsonb, error = $3, error_code = $4, updated_at = $5 WHERE task_id = $6 AND status IN ('running','suspended','needs_review')"), [
175
119
  status,
176
120
  result == null ? null : this.json(result),
177
- // codex R12 (PG only): error/errorCode = 裸 TEXT 参数 — 真 NUL 直炸 22P05,UPDATE 抛 → 事务回滚 →
178
- // task_active 保持 → run 卡非终态到 reaper(错误文案带脏字节的形:tool 报错内嵌 raw 输出)。观测标量
179
- // 同族有损 sanitize(与 result jsonb 同臂同纪律)。TiDB 存 verbatim。
180
121
  this.db.dialect === "tidb" ? error : error == null ? null : pgSanitizeText(error),
181
122
  this.db.dialect === "tidb" ? errorCode : errorCode == null ? null : pgSanitizeText(errorCode),
182
123
  new Date(),
@@ -185,69 +126,20 @@ export class SqlRunStore {
185
126
  await conn.query(this.q("DELETE FROM task_active WHERE task_id = ?", "DELETE FROM task_active WHERE task_id = $1"), [taskId]);
186
127
  });
187
128
  }
188
- /**
189
- * Durable F4 (design/45): a task suspended on a policy `ask`. Sets the run NON-terminal `suspended` and
190
- * KEEPS the `task_active` claim — the session stays locked (no new run) until an operator resumes it to a
191
- * terminal state or the reaper expires it. The token-bearing result is NOT stored (the submitter polling
192
- * this row must never receive the capability token); only the status flips.
193
- */
194
129
  async setSuspended(taskId) {
195
- // CAS on `running` (idempotent on an already-suspended row): without it, a heartbeat-stall race where
196
- // `reapStale` already flipped the row to `failed` and DELETED its `task_active` claim would be reverted
197
- // here to `suspended` WITHOUT re-claiming `task_active` — leaving a suspended run with no session lock, so
198
- // a new run could start on the same session while the checkpoint is still pending. The CAS makes the
199
- // reaped→suspended revert a no-op, so the reaper's decision stands and the invariant holds.
200
130
  await this.db.query(this.q("UPDATE task_run SET status = 'suspended', updated_at = ? WHERE task_id = ? AND status IN ('running','suspended')", "UPDATE task_run SET status = 'suspended', updated_at = $1 WHERE task_id = $2 AND status IN ('running','suspended')"), [new Date(), taskId]);
201
131
  }
202
- /**
203
- * design/80 D-B: PARK a run that paused on a REVIEW gate — a `needs_review` result (a dry-run diff review OR a
204
- * pre-action `plan_review`; core assemble-result slot 8.6). Like {@link setSuspended} this is a NON-terminal park
205
- * that KEEPS the `task_active` session claim (it does NOT delete it — only {@link setTerminal} does), so the
206
- * resume path (`getActiveTaskId` → `markResuming`) can find + claim the row. core treats a review pause "like a
207
- * suspend, an INTENTIONAL durable pause" (design/76 §2.5) — the service MUST park it the same way, else the run is
208
- * terminalized + its lock released and the plan_review/dry_run_review resume can never run (it gets
209
- * taskId=undefined and drives the model unprotected on an unlocked session). CAS on `running`/`needs_review`
210
- * (reaper-revert guard, mirrors setSuspended). The park→resume→claim invariant is pinned on BOTH REAL engines by
211
- * `test/run-store-reaper-integration.test.ts` (`inv#1` row ownership across park→resume→handover, `inv#2` two
212
- * replicas racing one session ⇒ exactly one claim wins, `inv#3`/`inv#3'` stale-claim takeover vs fresh/parked
213
- * claims), closing the former required-before-GA gap. Keep it that way: the P2 lease-SQL bug and this
214
- * needs_review-park bug were both born from store SQL that mocked unit tests pass — measured, not asserted, when
215
- * the invariant set was built: an upserting (non-exclusive) claim reddened all four real-DB cases while all 111
216
- * mocked run-store unit tests stayed green. A mock cannot observe a unique-key claim race, a rolled-back loser,
217
- * or a multi-table reaper DELETE.
218
- */
219
132
  async setNeedsReview(taskId) {
220
133
  await this.db.query(this.q("UPDATE task_run SET status = 'needs_review', updated_at = ? WHERE task_id = ? AND status IN ('running','needs_review')", "UPDATE task_run SET status = 'needs_review', updated_at = $1 WHERE task_id = $2 AND status IN ('running','needs_review')"), [new Date(), taskId]);
221
134
  }
222
- /**
223
- * Durable F4 resume: a suspended run is being actively resumed. Flip it back to `running` (CAS — only if
224
- * still suspended) and refresh liveness, so the in-flight resume is protected by the running-run heartbeat
225
- * and is NOT killed by `reapSuspended` (which targets `status='suspended'` past the approval TTL). Without
226
- * this the row sits at `suspended`/updated_at=suspend-time for the WHOLE resume execution, so a resume that
227
- * crosses the TTL is marked `failed` mid-run and its session lock (`task_active`) is released. Returns true
228
- * if it claimed the flip (false ⇒ the reaper already expired it, or it isn't suspended).
229
- */
230
135
  async markResuming(taskId) {
231
- const { affected } = await this.db.query(
232
- // cancel_requested is RESET on resume: a cancel that raced the suspend (flag set but never honored —
233
- // the run parked first) must not silently kill an operator-APPROVED resume later. Cancelling a suspended
234
- // run is the deny-approval path; a cancel of the resumed leg sets the flag afresh. preempt_requested is
235
- // RESET for the same reason (design/80 seam #2): a preempt flag that survived into the resumed leg would
236
- // make the heartbeat re-preempt it immediately (you resume to make PROGRESS) — the durable twin of core's
237
- // pre-aborted-signal strip; a genuine re-preempt of the resumed leg sets the flag afresh.
238
- // design/80 D-B: a `needs_review` (plan_review / dry_run_review) park is resumable too — the CAS accepts it
239
- // alongside `suspended` so the plan_review resume can claim the parked row. The flag-reset rationale is
240
- // identical for both park kinds.
241
- this.q("UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0, updated_at = ? WHERE task_id = ? AND status IN ('suspended','needs_review')", "UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0, updated_at = $1 WHERE task_id = $2 AND status IN ('suspended','needs_review')"), [new Date(), taskId]);
136
+ const { affected } = await this.db.query(this.q("UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0, updated_at = ? WHERE task_id = ? AND status IN ('suspended','needs_review')", "UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0, updated_at = $1 WHERE task_id = $2 AND status IN ('suspended','needs_review')"), [new Date(), taskId]);
242
137
  return affected === 1;
243
138
  }
244
- /** The active run's taskId for a session (the `task_active` claim) — lets resume find the suspended run row. */
245
139
  async getActiveTaskId(sessionId) {
246
140
  const { rows } = await this.db.query(this.q("SELECT task_id FROM task_active WHERE session_id = ?", "SELECT task_id FROM task_active WHERE session_id = $1"), [sessionId]);
247
141
  return rows[0] ? String(rows[0].task_id) : undefined;
248
142
  }
249
- /** Shared row→record mapper — byte-identical between dialects (both drivers hand back the same column
250
- * names + comparable JS types after SqlDriver's result normalization). */
251
143
  rowToRun(r) {
252
144
  return {
253
145
  taskId: String(r.task_id),
@@ -269,18 +161,7 @@ export class SqlRunStore {
269
161
  const { rows } = await this.db.query(this.q("SELECT task_id, session_id, owner, status, result, error, error_code, job_id, source, instance_id, objective_preview, created_at, updated_at FROM task_run WHERE task_id = ?", "SELECT task_id, session_id, owner, status, result, error, error_code, job_id, source, instance_id, objective_preview, created_at, updated_at FROM task_run WHERE task_id = $1"), [taskId]);
270
162
  return rows[0] ? this.rowToRun(rows[0]) : undefined;
271
163
  }
272
- /** Task Trace list (S1): runs newest-first, keyset-paginated on (created_at, task_id). Optional status +
273
- * jobId filters (jobId = the work-view group, served by idx_job). Caller passes `limit+1` to detect a next
274
- * page. Cursor fields are bound as params (no SQL injection).
275
- *
276
- * 🔴 Genuinely-divergent BUILDER (not just SQL text): TiDB's `?` is position-agnostic, so the WHERE array is
277
- * built with plain string pushes in call order. PG's `$n` needs a NUMBERED placeholder per param, so that arm
278
- * keeps its own positional-counter closure (`p()`) — collapsing the two into one shared counter would hide
279
- * the very placeholder-numbering divergence this file exists to keep visible. */
280
164
  async listRuns(opts) {
281
- // 夜测 N1(2026-07-09,LOW):a non-date cursor.createdAt made the four backends diverge (PG threw a raw
282
- // DB error; the other three returned an empty page). HTTP's decodeCursor pre-validates, so this is
283
- // defence-in-depth for direct store callers — normalize ALL backends to the empty page.
284
165
  if (opts.cursor && Number.isNaN(Date.parse(opts.cursor.createdAt)))
285
166
  return [];
286
167
  if (this.db.dialect === "tidb") {
@@ -299,11 +180,11 @@ export class SqlRunStore {
299
180
  params.push(opts.source);
300
181
  }
301
182
  if (opts.owner !== undefined) {
302
- where.push("owner = ?"); // idx_owner — the per-user list view (portal scopes a normal user to their own); "" is a valid exact owner, not "no filter" (B10)
183
+ where.push("owner = ?");
303
184
  params.push(opts.owner);
304
185
  }
305
186
  if (opts.cursor) {
306
- where.push("(created_at < ? OR (created_at = ? AND task_id < ?))"); // keyset: strictly older, ties by task_id
187
+ where.push("(created_at < ? OR (created_at = ? AND task_id < ?))");
307
188
  params.push(new Date(opts.cursor.createdAt), new Date(opts.cursor.createdAt), opts.cursor.taskId);
308
189
  }
309
190
  const sql = "SELECT task_id, session_id, owner, status, result, error, error_code, job_id, source, objective_preview, created_at, updated_at FROM task_run" +
@@ -324,9 +205,9 @@ export class SqlRunStore {
324
205
  if (opts.jobId !== undefined)
325
206
  where.push(`job_id = ${p(opts.jobId)}`);
326
207
  if (opts.source !== undefined)
327
- where.push(`source = ${p(opts.source)}`); // "" exact-matches (B10, same family as owner)
208
+ where.push(`source = ${p(opts.source)}`);
328
209
  if (opts.owner !== undefined)
329
- where.push(`owner = ${p(opts.owner)}`); // "" is a valid exact owner, not "no filter" (B10)
210
+ where.push(`owner = ${p(opts.owner)}`);
330
211
  if (opts.cursor) {
331
212
  const a = p(new Date(opts.cursor.createdAt));
332
213
  const b = p(new Date(opts.cursor.createdAt));
@@ -339,35 +220,25 @@ export class SqlRunStore {
339
220
  const { rows } = await this.db.query(sql, params);
340
221
  return rows.map((r) => this.rowToRun(r));
341
222
  }
342
- /** GET /v1/sessions (CC /resume picker): DISTINCT sessions newest-first by last activity, keyset-paginated on
343
- * (last_activity, session_id). Aggregates task_run by session_id — one window pass picks the latest run per
344
- * session (rn=1) for the preview/status and rolls up first/last/count. Owner-scoped when set (the per-user view).
345
- * Bounded by per-tenant data today; a denormalized session table is the optimization if it ever goes hot.
346
- *
347
- * 🔴 Same builder divergence as {@link listRuns} — see its header note. */
348
223
  async listSessions(opts) {
349
224
  if (this.db.dialect === "tidb") {
350
225
  const params = [];
351
226
  let innerWhere = "";
352
227
  if (opts.owner !== undefined) {
353
- // [4198] 窄互认第二臂的第三条件(legacy task_run 聚合面的孪生;session_meta 面见 tidb-session-store.ts)
354
- innerWhere = opts.includeUnowned ? " WHERE (owner = ? OR owner IS NULL)" : " WHERE owner = ?"; // "" is a valid exact owner, not "no filter" (B10)
228
+ innerWhere = opts.includeUnowned ? " WHERE (owner = ? OR owner IS NULL)" : " WHERE owner = ?";
355
229
  params.push(opts.owner);
356
230
  }
357
231
  const outer = ["rn = 1"];
358
232
  if (opts.q) {
359
- // `?q=` — ANY-run preview match, session-granular (an outer LIKE on the rn=1 row alone would
360
- // miss sessions whose match lives in an older run). LOWER-LIKE = collation-proof ci; escapeLike pins wildcards.
361
- // Owner/sql_mode rationale: see tidb-session-store.ts listSessions.
362
233
  outer.push("EXISTS (SELECT 1 FROM task_run tr WHERE tr.session_id = t.session_id AND LOWER(tr.objective_preview) LIKE LOWER(?) ESCAPE '\\\\')");
363
234
  params.push(`%${escapeLike(opts.q)}%`);
364
235
  }
365
236
  if (opts.cursor) {
366
- outer.push("(last_activity < ? OR (last_activity = ? AND session_id < ?))"); // keyset: strictly older, ties by session_id
237
+ outer.push("(last_activity < ? OR (last_activity = ? AND session_id < ?))");
367
238
  params.push(new Date(opts.cursor.lastActivityAt), new Date(opts.cursor.lastActivityAt), opts.cursor.sessionId);
368
239
  }
369
240
  const sql = "SELECT session_id, owner, task_id, last_activity, first_activity, run_count, objective_preview, status FROM (" +
370
- "SELECT session_id, owner, task_id, objective_preview, status," + // K-5c: carry the rn=1 (latest) run's task_id
241
+ "SELECT session_id, owner, task_id, objective_preview, status," +
371
242
  " ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at DESC, task_id DESC) AS rn," +
372
243
  " MAX(created_at) OVER (PARTITION BY session_id) AS last_activity," +
373
244
  " MIN(created_at) OVER (PARTITION BY session_id) AS first_activity," +
@@ -383,9 +254,8 @@ export class SqlRunStore {
383
254
  params.push(v);
384
255
  return `$${params.length}`;
385
256
  };
386
- const innerWhere = opts.owner !== undefined ? (opts.includeUnowned ? ` WHERE (owner = ${p(opts.owner)} OR owner IS NULL)` : ` WHERE owner = ${p(opts.owner)}`) : ""; // "" is a valid exact owner, not "no filter" (B10)
257
+ const innerWhere = opts.owner !== undefined ? (opts.includeUnowned ? ` WHERE (owner = ${p(opts.owner)} OR owner IS NULL)` : ` WHERE owner = ${p(opts.owner)}`) : "";
387
258
  const outer = ["rn = 1"];
388
- // `?q=` — the PG twin of the TiDB EXISTS predicate above.
389
259
  if (opts.q)
390
260
  outer.push(`EXISTS (SELECT 1 FROM task_run tr WHERE tr.session_id = t.session_id AND LOWER(tr.objective_preview) LIKE LOWER(${p(`%${escapeLike(opts.q)}%`)}) ESCAPE '\\')`);
391
261
  if (opts.cursor) {
@@ -395,7 +265,7 @@ export class SqlRunStore {
395
265
  outer.push(`(last_activity < ${a} OR (last_activity = ${b} AND session_id < ${sid}))`);
396
266
  }
397
267
  const sql = "SELECT session_id, owner, task_id, last_activity, first_activity, run_count, objective_preview, status FROM (" +
398
- "SELECT session_id, owner, task_id, objective_preview, status," + // K-5c: carry the rn=1 (latest) run's task_id
268
+ "SELECT session_id, owner, task_id, objective_preview, status," +
399
269
  " ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at DESC, task_id DESC) AS rn," +
400
270
  " MAX(created_at) OVER (PARTITION BY session_id) AS last_activity," +
401
271
  " MIN(created_at) OVER (PARTITION BY session_id) AS first_activity," +
@@ -405,7 +275,6 @@ export class SqlRunStore {
405
275
  const { rows } = await this.db.query(sql, params);
406
276
  return rows.map((r) => this.rowToSession(r));
407
277
  }
408
- /** Shared row→summary mapper for listSessions (byte-identical between dialects). */
409
278
  rowToSession(r) {
410
279
  return {
411
280
  sessionId: String(r.session_id),
@@ -415,69 +284,22 @@ export class SqlRunStore {
415
284
  runCount: Number(r.run_count),
416
285
  objectivePreview: r.objective_preview ?? null,
417
286
  lastStatus: String(r.status),
418
- lastRunId: r.task_id ?? null, // K-5c: the latest run's id (the live-tail re-attach anchor)
419
- title: null, // the legacy task_run aggregate has no session_meta — honest degrade
287
+ lastRunId: r.task_id ?? null,
288
+ title: null,
420
289
  };
421
290
  }
422
- /**
423
- * E21 (§0.5 session delete) — purge the service-owned RUNS LEDGER for one session: task_event (the replayable
424
- * event log), task_active (the single-active-run claim), and task_run (the run rows). Idempotent — returns
425
- * `{ removed }` (the count of task_run rows removed; 0 = nothing to purge). The conversation HISTORY
426
- * (session_meta/session_event) is purged separately by the session store; the checkpoint/tool-result rows by
427
- * their stores — this method owns only the runs-ledger tables. Done in one TX so a partial purge can't leak a
428
- * task_active claim with no run row (a permanent session lock).
429
- *
430
- * 🔴 Scoped by `session_id` ALONE — deliberately NO per-row owner guard (F-A, 2026-08-17 合并码扫描;同一条
431
- * 判断 `boot/session-faces.ts` 的 anchors 腿早就写过,run 账本这条腿当时没跟上):`task_run.owner` 是**每次
432
- * 提交的 principal**,它与会话的 canonical owner 会分叉 —— 一条 `session_meta.owner = NULL` 的存量会话被
433
- * 带 principal 的壳续聊后(REQUIRE_PRINCIPAL=false),新 run 行的 owner 是 `"default"`,而 DELETE 路由递进来
434
- * 的是**会话行**的 owner(NULL)。行级 null-safe 属主门(`<=>` / `IS NOT DISTINCT FROM`)于是只删得掉 NULL
435
- * 那批,具名那批连同它们的 task_event 全部存活 —— 而 `session_meta` 已经删了,留存腿的 sessionCandidates
436
- * 再也枚举不到这棵树 ⇒ 孤儿行**没有任何清理路径**,路由却回 `{deleted:true}`。那就是一次**报成成功的
437
- * 右删失效**(anchors 腿的注逐字写着这句)。删除决策的属主门在**路由层**(DELETE /v1/sessions/:id 先
438
- * `ownerOf` 再比对 scope,fleet-wide 臂同门),本方法的唯一生产调用点是那条路由后面的 purge 协调器。
439
- *
440
- * 🔴 **化身围栏**(`expectedSessionOwner`;codex 对抗复审 R1-[high],验真后修)—— 属主门下来了,但会话
441
- * **身份**必须在同一个事务里重新确认。会话 id 由调用方自选、且删除之后**可被别人重新登记**
442
- * (`register` 是 INSERT … ON DUPLICATE KEY / ON CONFLICT,老行没了就是一条新行,新 owner):于是
443
- * 「路由 `ownerOf` 已验权」这句话在两只并发 purge 交错时会过期 —— A 与 A' 同时通过属主门,A' 跑完整条
444
- * purge 把 meta 删掉,租户 B 用同一个 uuid 重新登记并跑完一次 run,慢半拍的 A 才走到这里 ⇒ 按 session_id
445
- * 单键删就把 **B 的**行删了(修前的行级属主门恰好挡住这一形,因为 B 的行 owner≠A 的会话主)。
446
- * ⇒ 事务头部 `SELECT owner FROM session_meta WHERE session_id = ? FOR UPDATE`,行不在或 owner 与路由验过的
447
- * 那一位不符 ⇒ **一行不删**回 `{removed:0}`(诚实:属于调用方的那个化身已经不在了;协调器据此回
448
- * `{deleted:false}`,不会拿别人的行数冒充成功)。锁序与留存腿逐字一致(先 session_meta 后 task_*),
449
- * 不引入新的死锁环;锁在本事务提交时释放,其后的 checkpoint / history 两腿各自带属主门。
450
- * ⚠️ 本围栏只有 SQL 双生做得到 —— File/Memory 孪生的账本里没有 session_meta(local 车道的会话表在另一只
451
- * 店里),它们的同名参数因此只作**契约对齐**用,真围栏由协调器那条 pre-delete 复读 + local 数据根的
452
- * 独占 BootLock(一个数据根恒一个进程)承担;逐字见 memory-run-store 的同名方法注。
453
- *
454
- * 🔴 Active-run re-assert INSIDE the TX (adversarial-review LOW — TOCTOU): the route's pre-purge `getActiveTaskId`
455
- * 409 check is a non-transactional SELECT — a concurrent createRun can claim `task_active(session_id)` in the
456
- * window between that check and this purge, then have its LIVE rows deleted here. So we re-take the no-active-run
457
- * invariant transactionally: `SELECT task_id FROM task_active WHERE session_id=? FOR UPDATE` at the TOP of the TX.
458
- * `FOR UPDATE` serializes against createRun's unique-key INSERT on the SAME session_id (createRun either already
459
- * holds the row — we see it and abort — or blocks on our lock until commit, then fails its own unique-key claim
460
- * against the now-absent row). If a claim exists we return `{ active }` (the route maps it to 409) and delete
461
- * NOTHING. Returns `{ removed }` on a clean purge.
462
- *
463
- * 🔴 The multi-table DELETEs are dialect-divergent SQL FORMS (not just placeholders): TiDB's
464
- * `DELETE te FROM te JOIN tr ON …` vs PG's `DELETE FROM te USING tr WHERE …`.
465
- */
466
291
  async deleteBySession(sessionId, expectedSessionOwner) {
467
292
  let result = { removed: 0 };
468
293
  await this.tx(async (conn) => {
469
- // 化身围栏(见头注):路由验过的那个会话化身必须**此刻仍在**,否则一行不删。
470
294
  const { rows: meta } = await conn.query(this.q("SELECT owner FROM session_meta WHERE session_id = ? FOR UPDATE", "SELECT owner FROM session_meta WHERE session_id = $1 FOR UPDATE"), [sessionId]);
471
295
  const metaRow = meta[0];
472
296
  if (metaRow === undefined || (metaRow.owner ?? null) !== expectedSessionOwner)
473
- return; // {removed:0}
474
- // Transactional no-active-run gate (FOR UPDATE serializes against createRun's task_active claim).
297
+ return;
475
298
  const { rows: active } = await conn.query(this.q("SELECT task_id FROM task_active WHERE session_id = ? FOR UPDATE", "SELECT task_id FROM task_active WHERE session_id = $1 FOR UPDATE"), [sessionId]);
476
299
  if (active[0]) {
477
300
  result = { active: String(active[0].task_id) };
478
- return; // abort — delete nothing; the route 409s (don't purge a live run's rows)
301
+ return;
479
302
  }
480
- // task_event has no session_id column → scope it through this session's task_run set (session_id alone).
481
303
  await conn.query(this.q("DELETE te FROM task_event te JOIN task_run tr ON te.task_id = tr.task_id WHERE tr.session_id = ?", "DELETE FROM task_event te USING task_run tr WHERE te.task_id = tr.task_id AND tr.session_id = $1"), [sessionId]);
482
304
  await conn.query(this.q("DELETE ta FROM task_active ta JOIN task_run tr ON ta.task_id = tr.task_id WHERE tr.session_id = ?", "DELETE FROM task_active ta USING task_run tr WHERE ta.task_id = tr.task_id AND tr.session_id = $1"), [sessionId]);
483
305
  const { affected } = await conn.query(this.q("DELETE FROM task_run WHERE session_id = ?", "DELETE FROM task_run WHERE session_id = $1"), [sessionId]);
@@ -485,15 +307,10 @@ export class SqlRunStore {
485
307
  });
486
308
  return result;
487
309
  }
488
- /** Per-door usage rollup (center systems-UI input): COUNT by (source,status) since a cutoff. Reads the
489
- * denormalized columns only — no JSON parsing; served by full scan bounded by created_at (small windows). */
490
310
  async sourceSummary(sinceMs) {
491
311
  const { rows } = await this.db.query(this.q("SELECT source, status, COUNT(*) AS c FROM task_run WHERE created_at >= ? GROUP BY source, status", "SELECT source, status, COUNT(*) AS c FROM task_run WHERE created_at >= $1 GROUP BY source, status"), [new Date(sinceMs)]);
492
312
  return rows.map((r) => ({ source: r.source ?? null, status: String(r.status), count: Number(r.c) }));
493
313
  }
494
- /** 用量扫窗(usage-analytics):按时间窗取 {owner,created_at,status,result} 四列,行内投影
495
- * stats(JSON 提取在 JS,免 SQL JSON 方言分叉);LIMIT+1 探测截断(触顶=truncated:true 诚实上抛)。
496
- * 聚合在 src/usage-analytics.ts 纯函数(三后端同代码)。可选 owner 过滤=JWT 主体只看自己。 */
497
314
  async usageScan(fromMs, toMs, opts) {
498
315
  const limit = Math.min(opts?.limit ?? USAGE_SCAN_LIMIT, USAGE_SCAN_LIMIT);
499
316
  const where = [this.q("created_at >= ?", "created_at >= $1"), this.q("created_at < ?", "created_at < $2")];
@@ -515,14 +332,11 @@ export class SqlRunStore {
515
332
  })),
516
333
  };
517
334
  }
518
- /** Earliest retained event seq for a task (0 if none) → `retainedFrom` for /turns and the 416 boundary for
519
- * /stream (a resume point below this has been evicted past retention). */
520
335
  async retainedFrom(taskId) {
521
336
  const { rows } = await this.db.query(this.q("SELECT MIN(seq) AS m FROM task_event WHERE task_id = ?", "SELECT MIN(seq) AS m FROM task_event WHERE task_id = $1"), [taskId]);
522
337
  const m = rows[0]?.m;
523
338
  return m == null ? 0 : Number(m);
524
339
  }
525
- /** Events with seq strictly greater than `afterSeq` (use 0 for the whole stream). */
526
340
  async getEvents(taskId, afterSeq) {
527
341
  const { rows } = await this.db.query(this.q("SELECT seq, type, data, ts FROM task_event WHERE task_id = ? AND seq > ? ORDER BY seq ASC", "SELECT seq, type, data, ts FROM task_event WHERE task_id = $1 AND seq > $2 ORDER BY seq ASC"), [taskId, afterSeq]);
528
342
  return rows.map((r) => ({
@@ -532,12 +346,6 @@ export class SqlRunStore {
532
346
  ts: iso(r.ts),
533
347
  }));
534
348
  }
535
- /**
536
- * Mark stale `running` rows as failed (best-effort recovery for runs whose instance died), then
537
- * free any active-run claim whose run is no longer running (reaped here, or a terminal whose
538
- * DELETE was lost). `olderThanMs` must exceed the longest expected gap between a run's events
539
- * (each event refreshes `updated_at`), else a healthy slow run is wrongly reaped.
540
- */
541
349
  async reapStale(olderThanMs) {
542
350
  const cutoff = new Date(Date.now() - olderThanMs);
543
351
  let reaped = 0;
@@ -546,38 +354,11 @@ export class SqlRunStore {
546
354
  "WHERE status = 'running' AND updated_at < ?", "UPDATE task_run SET status = 'failed', error = 'run stalled (instance lost?)', updated_at = $1 " +
547
355
  "WHERE status = 'running' AND updated_at < $2"), [new Date(), cutoff]);
548
356
  reaped = affected;
549
- // Release task_active for TERMINAL rows only. A 'suspended' row (durable F4) intentionally KEEPS its
550
- // claim — the session stays locked until an operator resumes it or reapSuspended expires it. (Was
551
- // `<> 'running'`, which would have prematurely unlocked a parked suspended session.)
552
357
  await conn.query(this.q("DELETE ta FROM task_active ta JOIN task_run tr ON ta.task_id = tr.task_id WHERE tr.status NOT IN ('running','suspended','needs_review')", "DELETE FROM task_active ta USING task_run tr WHERE ta.task_id = tr.task_id AND tr.status NOT IN ('running','suspended','needs_review')"));
553
- // [2255]③ 孤儿 claim 清扫(纵深防御):claim 无对应 task_run 行时,三处 INNER JOIN 释放腿永远碰不到
554
- // 它。正常路径不产孤儿(createRun 单事务、setTerminal 无条件 DELETE、deleteBySession 同事务对称)——
555
- // 这形只来自带外(手工 SQL/半截迁移),但一旦出现该 session 只剩 cancel 一条自救路。挂在 reapStale
556
- // (每 tick 无条件跑)一处即可。同文双方言(关联 NOT EXISTS 指向**另一张**表,两引擎都合法);
557
- // 无竞态:createRun 的 claim+row 同事务提交,已提交的 claim 必有行。
558
358
  await conn.query(this.q("DELETE FROM task_active WHERE NOT EXISTS (SELECT 1 FROM task_run tr WHERE tr.task_id = task_active.task_id)", "DELETE FROM task_active WHERE NOT EXISTS (SELECT 1 FROM task_run tr WHERE tr.task_id = task_active.task_id)"));
559
359
  });
560
360
  return reaped;
561
361
  }
562
- /**
563
- * Durable F4 expiry: an approval parked beyond `olderThanMs` (the approval TTL) was never answered → fail
564
- * the run + RELEASE its task_active (unlock the session). Mirrors the checkpoint reaper's CAS-expire (≈ deny);
565
- * idempotent across replicas. Returns count.
566
- *
567
- * 🔴 design/80 D-D (adversarial blocker fix): SKIPS any row whose checkpoint is STILL pending — that row is
568
- * owned by a checkpoint-state-driven sweep (the SLA deny-sweep for human/irreversible_ask, or
569
- * failSuspendedWithExpiredCheckpoint once the checkpoint is expired). Without this guard, this time-based
570
- * UPDATE fired at the SAME instant as the deny-sweep (deadline == createdAt + ttl, both keyed on the TTL),
571
- * almost always committed FIRST (it is a single UPDATE), and deleted task_active + failed the run BEFORE the
572
- * deny-sweep's resumeCheckpoint ran — so resumeCheckpoint saw no task row (markResuming skipped) yet STILL ran
573
- * the model on a now-unlocked session (the pending checkpoint's resolve-CAS wins), orphaning a model leg and
574
- * opening a two-runs-one-session window. The NOT-EXISTS-pending guard (same shape as
575
- * failSuspendedWithExpiredCheckpoint) means this only ever reaps a row whose checkpoint is already gone/expired.
576
- *
577
- * 🔴 Alias-qualification divergence (kept EXPLICIT, never "fixed" to match): TiDB's SET clause QUALIFIES with
578
- * `tr.` (`SET tr.status = …`); PG's SET clause must NOT be alias-qualified (`SET status = …` — PG rejects
579
- * `SET a.col`). Both WHERE clauses use the `tr.` alias.
580
- */
581
362
  async reapSuspended(olderThanMs) {
582
363
  const cutoff = new Date(Date.now() - olderThanMs);
583
364
  let reaped = 0;
@@ -592,26 +373,10 @@ export class SqlRunStore {
592
373
  });
593
374
  return reaped;
594
375
  }
595
- /**
596
- * design/80 §3 inv#3 (crash-safe backstop — the run-row half of the checkpoint `terminal_at_ms` sweep): fail a
597
- * suspended run whose durable checkpoint was ALREADY EXPIRED by `reapExpired` (past its `deadline` or its
598
- * absolute `terminal_at_ms`), and release its task_active (unlock the session). Runs UNCONDITIONALLY (no
599
- * approval-TTL gate), CHECKPOINT-STATE-driven not time-driven, so it aligns EXACTLY with the per-row
600
- * `terminal_at_ms` (which never falls before an operator's >30d deadline) — unlike a uniform timer, which would
601
- * either be inert (gated off at APPROVAL_TIMEOUT_SEC=0) or prematurely kill a long gate. Safe against the two
602
- * windows a naive predicate mis-fires on: (a) the transient suspend-write window — a run suspended before its
603
- * checkpoint row exists has NO expired checkpoint, so it is not matched; (b) a re-suspended session that minted
604
- * a NEW pending checkpoint — the `NOT EXISTS pending` clause excludes it. Idempotent across replicas. Returns count.
605
- *
606
- * Same alias-qualification divergence as {@link reapSuspended} — see its header note.
607
- */
608
376
  async failSuspendedWithExpiredCheckpoint() {
609
377
  let reaped = 0;
610
378
  await this.tx(async (conn) => {
611
379
  const { affected } = await conn.query(this.q("UPDATE task_run tr SET tr.status = 'failed', tr.error = 'approval expired before decision', tr.error_code = 'approval.expired', tr.updated_at = ? " +
612
- // wq64gmm5e HIGH: a needs_review park is ALSO a non-terminal claim-keeping state; an abandoned review
613
- // park whose checkpoint reapExpired already expired must be failed + its task_active released, exactly
614
- // like a suspended park — else the run row + session lock leak forever (no other reaper targets it).
615
380
  "WHERE tr.status IN ('suspended','needs_review') " +
616
381
  "AND EXISTS (SELECT 1 FROM checkpoint ce WHERE ce.session_id = tr.session_id AND ce.status = 'expired') " +
617
382
  "AND NOT EXISTS (SELECT 1 FROM checkpoint cp WHERE cp.session_id = tr.session_id AND cp.status = 'pending')", "UPDATE task_run AS tr SET status = 'failed', error = 'approval expired before decision', error_code = 'approval.expired', updated_at = $1 " +
@@ -624,13 +389,11 @@ export class SqlRunStore {
624
389
  return reaped;
625
390
  }
626
391
  }
627
- /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */
628
392
  export class TiDBRunStore extends SqlRunStore {
629
393
  constructor(pool) {
630
394
  super(mysqlDriver(pool));
631
395
  }
632
396
  }
633
- /** PostgreSQL binding — historical class name + ctor shape preserved. */
634
397
  export class PgRunStore extends SqlRunStore {
635
398
  constructor(pool) {
636
399
  super(pgDriver(pool));
@@ -1,16 +1,4 @@
1
- /**
2
- * Minimal AWS SigV4 **presigned URL** generator for S3-compatible object stores (MinIO) — zero dependencies
3
- * (crypto only), matching the repo's no-SDK posture (OTLP exporter, the k8s adapter). We only need presign:
4
- * the k8s sandbox adapter uploads/downloads a workspace snapshot by handing the SANDBOX a short-lived,
5
- * single-object URL so the adapter's MinIO credentials never enter the sandbox (design/53 lethal-trifecta
6
- * red line — "adapter-held, sandbox sees only a presigned URL").
7
- *
8
- * Path-style addressing (`<endpoint>/<bucket>/<key>`) — MinIO's default and what an IP/cluster-DNS endpoint
9
- * needs (no virtual-host bucket subdomain). Payload is UNSIGNED-PAYLOAD so a streamed `curl -T -` body
10
- * (chunked, unknown length) verifies — the signature binds method+path+headers+expiry, not the bytes.
11
- */
12
1
  import { createHash, createHmac } from "node:crypto";
13
- /** URI-encode per RFC 3986 (S3 canonicalization): unreserved chars stay literal, everything else %XX. */
14
2
  function uriEncode(s, encodeSlash) {
15
3
  let out = "";
16
4
  for (const ch of Buffer.from(s, "utf8")) {
@@ -30,40 +18,22 @@ function uriEncode(s, encodeSlash) {
30
18
  function hmac(key, data) {
31
19
  return createHmac("sha256", key).update(data, "utf8").digest();
32
20
  }
33
- /** The shared SigV4 signing core: everything below the canonical-URI choice is identical for an
34
- * object URL and a bucket URL — one implementation so the two public faces can never drift.
35
- *
36
- * Endpoint PATH PREFIX (三路复审修1): an endpoint like `https://files.example.com/minio` (reverse-proxy
37
- * sub-path in front of MinIO) contributes its path to BOTH the canonical URI (what SigV4 signs) and the
38
- * returned URL — previously only `url.origin` was used, so the signed canonical URI (`/bucket/key`) never
39
- * matched the request path the store actually saw (`/minio/bucket/key`), and the presigned track silently
40
- * diverged from the permanent track's plain string concat (which DID keep the prefix). One rstrip'd
41
- * pathname, prepended to both, keeps signature and request path congruent for any endpoint shape.
42
- *
43
- * `signedHeaders` (修3 CopyObject support): extra headers to SIGN beyond `host` — presigned-URL (query)
44
- * auth requires every `x-amz-*` header the request carries to be in X-Amz-SignedHeaders, so a server-side
45
- * CopyObject (`x-amz-copy-source`) must sign it. Names are lowercased; canonical headers are sorted;
46
- * the CALLER must send exactly these header values on the request. */
47
21
  function presignCanonical(p) {
48
22
  const region = p.region ?? "us-east-1";
49
23
  const expires = p.expiresSec ?? 600;
50
24
  const now = p.now ?? new Date();
51
- const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ""); // YYYYMMDDTHHMMSSZ
25
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
52
26
  const dateStamp = amzDate.slice(0, 8);
53
27
  const url = new URL(p.endpoint);
54
- const host = url.host; // includes port
55
- // Endpoint path prefix (修1): "" for a root endpoint; "/minio" for a sub-path endpoint (trailing "/"
56
- // stripped; `new URL().pathname` is already percent-encoded, which is what the canonical URI wants).
28
+ const host = url.host;
57
29
  const basePath = url.pathname.replace(/\/+$/, "");
58
30
  const canonicalUri = `${basePath}${p.canonicalUri}`;
59
31
  const scope = `${dateStamp}/${region}/s3/aws4_request`;
60
- // Signed headers: `host` always; extras (lowercased, value-trimmed) merged in; canonical order = sorted.
61
32
  const headerMap = new Map([["host", host]]);
62
33
  for (const [k, v] of Object.entries(p.signedHeaders ?? {}))
63
34
  headerMap.set(k.toLowerCase(), v.trim());
64
35
  const headerNames = [...headerMap.keys()].sort();
65
36
  const signedHeaderList = headerNames.join(";");
66
- // Query params must be sorted; values URI-encoded (the credential's "/" too).
67
37
  const params = {
68
38
  ...(p.extraQuery ?? {}),
69
39
  "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
@@ -86,29 +56,18 @@ function presignCanonical(p) {
86
56
  const signature = createHmac("sha256", kSigning).update(stringToSign, "utf8").digest("hex");
87
57
  return `${url.origin}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
88
58
  }
89
- /** Build a SigV4 presigned **single-object** URL. Pure string math — no network. */
90
59
  export function presignS3Url(p) {
91
- // codex 1.189 F8: the contract here is SINGLE-OBJECT — an empty key would silently sign the BUCKET URI
92
- // (`/<bucket>/` = CreateBucket/DeleteBucket/ListObjects depending on method), a far broader capability
93
- // than any caller of this face intends. Refuse; bucket-level operations go through presignS3BucketUrl.
94
60
  if (p.key === "") {
95
61
  throw new Error("presignS3Url: refusing to sign an empty key (single-object contract) — use presignS3BucketUrl for bucket-level operations");
96
62
  }
97
- // Backstop (defense in depth): callers MUST pass a pre-sanitized key, but a path-traversal segment here would
98
- // sign a URL that escapes its intended prefix once the store normalizes the path — refuse rather than sign it.
99
63
  if (p.key.startsWith("/") || p.key.split("/").some((seg) => seg === "." || seg === "..")) {
100
64
  throw new Error(`presignS3Url: refusing to sign a key with a traversal segment: ${p.key}`);
101
65
  }
102
66
  return presignCanonical({ ...p, canonicalUri: `/${uriEncode(p.bucket, true)}/${uriEncode(p.key, false)}` });
103
67
  }
104
- /** Build a SigV4 presigned **bucket-level** URL (`/<bucket>/`, no key) — the explicit face for
105
- * CreateBucket/DeleteBucket (codex 1.189 F8: previously test fixtures smuggled this through
106
- * presignS3Url with key "", which the single-object contract now rejects). Same signing core. */
107
68
  export function presignS3BucketUrl(p) {
108
69
  return presignCanonical({ ...p, canonicalUri: `/${uriEncode(p.bucket, true)}/` });
109
70
  }
110
- /** ListObjectsV2 presign(D-1 孤儿对象 GC,clay 拍 2026-07-28):GET `/<bucket>/?list-type=2&prefix=…`。
111
- * bucket 级 GET 是**列举能力**——只给 server 端 sweep 用,URL 绝不外发(与 copy 面同纪律)。 */
112
71
  export function presignS3ListUrl(p) {
113
72
  return presignCanonical({
114
73
  endpoint: p.endpoint,
@@ -127,12 +86,6 @@ export function presignS3ListUrl(p) {
127
86
  },
128
87
  });
129
88
  }
130
- /** Build a SigV4 presigned **CopyObject** request (修3 staging-key two-phase): S3 CopyObject = `PUT
131
- * /<dstBucket>/<dstKey>` with `x-amz-copy-source: /<srcBucket>/<srcKey>`. Query auth requires every
132
- * `x-amz-*` request header to be SIGNED, so the copy-source header goes through the signing core's
133
- * signedHeaders seam; the caller MUST send `headers` verbatim on the fetch (the source key is
134
- * RFC-3986-encoded — MinIO/S3 both accept the encoded copy-source form, and header values must be
135
- * ASCII anyway). Same single-object guards as presignS3Url on BOTH sides. */
136
89
  export function presignS3CopyUrl(p) {
137
90
  for (const [label, key] of [
138
91
  ["destination", p.key],