@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
@@ -9,83 +9,38 @@ export class SqlWorkflowJournalStore {
9
9
  this.db = db;
10
10
  this.resumeClaimTtlMs = opts?.resumeClaimTtlMs ?? DEFAULT_RESUME_CLAIM_TTL_MS;
11
11
  }
12
- /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
13
12
  q(tidb, pg) {
14
13
  return this.db.dialect === "tidb" ? tidb : pg;
15
14
  }
16
- /** Record one agent's result. Idempotent per (run_id, ordinal): the PK upsert OVERWRITES (last-write-wins), so a
17
- * resumed run re-appending its replayed prefix verbatim leaves no duplicate. callKeyOrdinal is the SOLE ordinal
18
- * source (core's parser — a malformed key collapses to 0, matching InMemory). Throws on a REAL failure
19
- * (LOAD-BEARING), EXCEPT an oversize result is skip-journaled (see below) rather than throwing the workflow dead. */
20
15
  async append(runId, scope, entry) {
21
- // codex R10 定谳:result 列=TEXT(PG)/MEDIUMTEXT(TiDB),存的是 stringify 输出——NUL/lone surrogate
22
- // 在输出里恒为 \uXXXX 六字符**转义形**(纯 ASCII),TEXT 列可精确存取;22P05 只咬 ::jsonb cast。
23
- // 故 journal 无需任何 NUL 守卫:直存=逐位回放(R8 skip/R9 invalidate 均为过度修,已撤——那会把
24
- // 可精确回放的完成结果作废成不必要的活跑重执行,外部副作用可能重复)。
25
16
  const serialized = JSON.stringify(entry.result);
26
- // TiDB rejects a row over the ~6 MiB txn-entry-size-limit ("entry too large"); a large agent TaskResult would
27
- // make this LOAD-BEARING append THROW + kill the workflow — and the PG twin (no such limit) would NOT, a
28
- // dialect divergence. DEGRADE identically on both: skip-journal an oversize result (no-op, never insert/throw)
29
- // → its ordinal isn't cached, so a resume re-runs that one agent live (resume is an optimization).
30
17
  if (oversizeJournalResult(serialized))
31
18
  return;
32
19
  await this.db.query(this.q("INSERT INTO workflow_journal (run_id, ordinal, scope, call_key, result, created_at_ms) VALUES (?,?,?,?,?,?) " +
33
- // scope NOT in the UPDATE: a re-append keeps the FIRST writer's scope (fail-closed — a re-append must never
34
- // reassign tenancy). The owner is the sole writer + runId is unique per scope, so this never fires
35
- // cross-scope in practice; the secure choice over matching InMemory's last-write-wins on scope.
36
20
  "ON DUPLICATE KEY UPDATE call_key = VALUES(call_key), result = VALUES(result)", "INSERT INTO workflow_journal (run_id, ordinal, scope, call_key, result, created_at_ms) VALUES ($1,$2,$3,$4,$5,$6) " +
37
21
  "ON CONFLICT (run_id, ordinal) DO UPDATE SET call_key = EXCLUDED.call_key, result = EXCLUDED.result"), [runId, callKeyOrdinal(entry.callKey), scope, entry.callKey, serialized, Date.now()]);
38
22
  }
39
- /** Entries for `runId` IF its recorded scope === `scope`, ASCENDING by ordinal; otherwise EMPTY. CORE-9 audit
40
- * BLOCKER: scope in the WHERE (twin of WorkflowRunStore) — the LLM controls `resumeFromRunId`, so a cross-scope
41
- * runId resolves to an empty journal (resume safely diverges to live, never discloses another tenant's results).
42
- * Each row's `result` is the persisted JSON.stringify(TaskResult) → JSON.parse back. Throws on failure (LOAD-BEARING). */
43
23
  async load(runId, scope) {
44
24
  const { rows } = await this.db.query(this.q("SELECT call_key, result FROM workflow_journal WHERE run_id = ? AND scope = ? ORDER BY ordinal ASC", "SELECT call_key, result FROM workflow_journal WHERE run_id = $1 AND scope = $2 ORDER BY ordinal ASC"), [runId, scope]);
45
25
  return rows.map((r) => ({ callKey: String(r.call_key), result: JSON.parse(String(r.result)) }));
46
26
  }
47
- /** [1412] locator 云形(core 1.353 WorkflowJournalStore.locator additive):SQL 店的 journal 坐标=
48
- * [1402] HTTP 读面路由(file 店返 jsonl 路径=CC 本地形;此处返云形,core 铸 diagnostics 教句时单源引用)。 */
49
27
  locator(runId, _scope) {
50
28
  return `/v1/workflows/${runId}/journal`;
51
29
  }
52
- /** codex R1-H2:分页投影读——LIMIT/OFFSET 界行数,字节门界单行字节(超限行不取 result 全文,只回字节数;
53
- * 读面标 truncated)。ORDER BY ordinal 与 load 同轴。
54
- * 🔴 param ORDER differs by dialect (see file header): TiDB's `?`s bind in text order (maxResultBytes first,
55
- * inside the CASE); PG's `$n` are positional, so its array keeps runId/scope first. Never unify — a swap
56
- * silently mis-binds the OTHER dialect's placeholders. */
57
30
  async loadPage(runId, scope, opts) {
58
31
  const params = this.db.dialect === "tidb"
59
32
  ? [opts.maxResultBytes, runId, scope, opts.limit, opts.offset]
60
33
  : [runId, scope, opts.maxResultBytes, opts.limit, opts.offset];
61
- const { rows } = await this.db.query(this.q("SELECT call_key, CASE WHEN LENGTH(result) > ? THEN NULL ELSE result END AS result_json, LENGTH(result) AS result_bytes FROM workflow_journal WHERE run_id = ? AND scope = ? ORDER BY ordinal ASC LIMIT ? OFFSET ?",
62
- // codex R3: PG's LENGTH(text) is CHARACTER count (TiDB's LENGTH is byte count) — a multi-byte UTF-8 row
63
- // would slip past the byte-budget door on plain LENGTH; OCTET_LENGTH keeps this the byte count PG-side
64
- // too (real dual-DB-suite-confirmed anti-drift).
65
- "SELECT call_key, CASE WHEN OCTET_LENGTH(result) > $3 THEN NULL ELSE result END AS result_json, OCTET_LENGTH(result) AS result_bytes FROM workflow_journal WHERE run_id = $1 AND scope = $2 ORDER BY ordinal ASC LIMIT $4 OFFSET $5"), params);
34
+ const { rows } = await this.db.query(this.q("SELECT call_key, CASE WHEN LENGTH(result) > ? THEN NULL ELSE result END AS result_json, LENGTH(result) AS result_bytes FROM workflow_journal WHERE run_id = ? AND scope = ? ORDER BY ordinal ASC LIMIT ? OFFSET ?", "SELECT call_key, CASE WHEN OCTET_LENGTH(result) > $3 THEN NULL ELSE result END AS result_json, OCTET_LENGTH(result) AS result_bytes FROM workflow_journal WHERE run_id = $1 AND scope = $2 ORDER BY ordinal ASC LIMIT $4 OFFSET $5"), params);
66
35
  return rows.map((r) => ({ callKey: String(r.call_key), resultJson: r.result_json === null ? null : String(r.result_json), resultBytes: Number(r.result_bytes) }));
67
36
  }
68
- /** GC — purge ALL journal entries for a run (the run reaper calls this once the run is terminal + retained). Scoped
69
- * by run_id alone; idempotent (affected-row count). Mirrors the resume-anchor store's deleteBySession. */
70
37
  async deleteByRun(runId) {
71
38
  const res = await this.db.query(this.q("DELETE FROM workflow_journal WHERE run_id = ?", "DELETE FROM workflow_journal WHERE run_id = $1"), [runId]);
72
39
  return res.affected;
73
40
  }
74
- /** RB-242 / WF2([1981] core 拍板 a 形,签名逐字)—— cross-replica resume admission lease。
75
- *
76
- * 语义(bake-store `idem_key UNIQUE` 先例):PK (source_run_id, scope) 上的原子赢或观察。四步,每步
77
- * 单语句原子,并发交叉在任一步都收敛到「恰一个持有者」:
78
- * ① 抢空位:INSERT..DO NOTHING / ON DUP KEY 无操作 —— affected=1 即赢;
79
- * ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的 claimed_at_ms 刷新;
80
- * ③ TTL 崩溃兜底接管:claimed_at_ms < now-ttl 守卫下的原子改持有者(engine 终态会显式释放,
81
- * 走到这步=上一持有 engine 崩了没释放;两层分工见 SqlWorkflowJournalStoreOptions 注);
82
- * ④ 都没赢 ⇒ 读在位者返 {granted:false, holder}(holder 进 engine 的拒绝文案供归因)。 */
83
41
  async resumeClaim(input) {
84
42
  const now = Date.now();
85
- const ins = await this.db.query(this.q(
86
- // INSERT IGNORE(非 ON DUP KEY 无操作形):TiDB 对「无变化的 DUP KEY UPDATE」affected 报 1
87
- // (MySQL 报 0)——真双库跑出来的方言差;IGNORE 形两家都在冲突时报 0。
88
- "INSERT IGNORE INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES (?, ?, ?, ?)", "INSERT INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES ($1, $2, $3, $4) ON CONFLICT (source_run_id, scope) DO NOTHING"), [input.sourceRunId, input.scope, input.newRunId, now]);
43
+ const ins = await this.db.query(this.q("INSERT IGNORE INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES (?, ?, ?, ?)", "INSERT INTO workflow_resume_claim (source_run_id, scope, new_run_id, claimed_at_ms) VALUES ($1, $2, $3, $4) ON CONFLICT (source_run_id, scope) DO NOTHING"), [input.sourceRunId, input.scope, input.newRunId, now]);
89
44
  if (ins.affected === 1)
90
45
  return { granted: true };
91
46
  const refresh = await this.db.query(this.q("UPDATE workflow_resume_claim SET claimed_at_ms = ? WHERE source_run_id = ? AND scope = ? AND new_run_id = ?", "UPDATE workflow_resume_claim SET claimed_at_ms = $1 WHERE source_run_id = $2 AND scope = $3 AND new_run_id = $4"), [now, input.sourceRunId, input.scope, input.newRunId]);
@@ -95,36 +50,25 @@ export class SqlWorkflowJournalStore {
95
50
  if (takeover.affected >= 1)
96
51
  return { granted: true };
97
52
  const holder = await this.db.query(this.q("SELECT new_run_id FROM workflow_resume_claim WHERE source_run_id = ? AND scope = ?", "SELECT new_run_id FROM workflow_resume_claim WHERE source_run_id = $1 AND scope = $2"), [input.sourceRunId, input.scope]);
98
- // 行在①-③间被释放的窄窗:holder 读空 ⇒ 如实返 denied 无 holder(engine 下一次重试会在①赢)。
99
53
  const row = holder.rows[0];
100
54
  const holderId = row?.new_run_id === undefined ? undefined : String(row.new_run_id);
101
- // ②的 UPDATE 在 MySQL 缺省协议下只数**被改变**的行——同毫秒重入(claimed_at_ms 未变)会 affected=0
102
- // 掉到这里;持有者==自己仍是 granted(幂等重入语义不依赖 affected 的方言细节)。
103
55
  if (holderId === input.newRunId)
104
56
  return { granted: true };
105
57
  return { granted: false, holder: holderId };
106
58
  }
107
- /** 只有 holder 能释放(new_run_id 守卫)—— engine 在 newRunId 终态/放弃时调;非 holder 调用=no-op。 */
108
59
  async releaseResumeClaim(input) {
109
60
  await this.db.query(this.q("DELETE FROM workflow_resume_claim WHERE source_run_id = ? AND scope = ? AND new_run_id = ?", "DELETE FROM workflow_resume_claim WHERE source_run_id = $1 AND scope = $2 AND new_run_id = $3"), [input.sourceRunId, input.scope, input.newRunId]);
110
61
  }
111
- /** GC — time-based sweep: purge journal entries older than `maxAgeMs` (created_at_ms < now - maxAgeMs). The
112
- * per-run deleteByRun has no production caller (a run-store reap gives no per-run hook), so this bounded sweep
113
- * — wired into the service reaper — is what keeps the heaviest table (TaskResult-bearing) from growing without
114
- * bound. A resume of a journal older than the retention window simply re-runs live (resume is an optimization).
115
- * Idempotent; returns the rows purged. */
116
62
  async reapExpired(now, maxAgeMs) {
117
63
  const res = await this.db.query(this.q("DELETE FROM workflow_journal WHERE created_at_ms < ?", "DELETE FROM workflow_journal WHERE created_at_ms < $1"), [now - maxAgeMs]);
118
64
  return res.affected;
119
65
  }
120
66
  }
121
- /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */
122
67
  export class TiDBWorkflowJournalStore extends SqlWorkflowJournalStore {
123
68
  constructor(pool, opts) {
124
69
  super(mysqlDriver(pool), opts);
125
70
  }
126
71
  }
127
- /** PostgreSQL binding — historical class name + ctor shape preserved. */
128
72
  export class PgWorkflowJournalStore extends SqlWorkflowJournalStore {
129
73
  constructor(pool, opts) {
130
74
  super(pgDriver(pool), opts);
@@ -2,19 +2,8 @@ import { summarizeWorkflowRun, isTerminalWorkflowStatus, WorkflowRunStoreError }
2
2
  import { foldKeyFamily, MAX_PENDING_PER_SESSION, PURGE_FENCE_MS, SERVED_FENCE_MS, } from "../orchestration/workflow-completion-inbox.js";
3
3
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
4
4
  import { isDupKeyError } from "./sql-errors.js";
5
- /** Row cap guard: a run blob beyond this is NOT stored on update (the prior revision stays) — TiDB's
6
- * ~6 MiB txn-entry limit would otherwise throw the (best-effort) progress write into the workflow loop.
7
- * `put` (create, small) still throws on a real failure. Mirrors the journal's oversize skip-degrade. */
8
5
  export const MAX_RUN_BLOB_BYTES = 4 * 1024 * 1024;
9
6
  const onWarnType = (msg, meta) => void [msg, meta];
10
- /** Review fix (1.108, lens④ HIGH): an oversize blob must NOT fail the update outright — core's persist chain
11
- * treats `false` as a CAS loss and does NOT advance its tracked rev, so once a run outgrows the cap EVERY later
12
- * write (including the TERMINAL one) keeps failing → the durable row is stuck `running` forever and the 24h
13
- * orphan sweep finalizes a genuinely-completed run as `abandoned`. Instead degrade the PAYLOAD, not the write:
14
- * step 1 truncate the two unbounded strings (result/error); step 2 drop the heavy arrays (phases/agents/groups)
15
- * with an explicit marker. Returns null only when even the skeleton is oversize (physics — caller keeps the
16
- * prior revision, matching the old behavior for that corner). Dialect-neutral (pure JS, no SQL) — shared
17
- * verbatim by both twins. */
18
7
  export function slimOversizeRun(run, maxBytes) {
19
8
  const MARK = "…[truncated: run blob exceeded the store cap]";
20
9
  const cut = (s) => s.slice(0, 4000) + MARK;
@@ -30,9 +19,6 @@ export function slimOversizeRun(run, maxBytes) {
30
19
  blob = JSON.stringify(step2);
31
20
  return Buffer.byteLength(blob) <= maxBytes ? blob : null;
32
21
  }
33
- /** Dual-dialect durable `WorkflowRunStore`. See the file header for the dialect-delta ledger.
34
- * `onWarn` (optional, same shape as the completion-inbox's {@link InboxWarn}) is the C5 trace channel for
35
- * `update`'s oversize-slim degrade (see there) — omit it and construction/behavior is unchanged (additive). */
36
22
  export class SqlWorkflowRunStore {
37
23
  db;
38
24
  onWarn;
@@ -40,18 +26,15 @@ export class SqlWorkflowRunStore {
40
26
  this.db = db;
41
27
  this.onWarn = onWarn;
42
28
  }
43
- /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
44
29
  q(tidb, pg) {
45
30
  return this.db.dialect === "tidb" ? tidb : pg;
46
31
  }
47
32
  async put(id, run) {
48
- const stored = { ...run, id, rev: run.rev ?? 0 }; // key authoritative + observed-rev base (InMemory parity)
33
+ const stored = { ...run, id, rev: run.rev ?? 0 };
49
34
  try {
50
35
  await this.db.query(this.q("INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms, ended_at_ms) VALUES (?,?,?,?,?,?,?)", "INSERT INTO workflow_run (id, scope, status, run, rev, created_at_ms, ended_at_ms) VALUES ($1,$2,$3,$4,$5,$6,$7)"), [id, run.scope, run.status, JSON.stringify(stored), stored.rev, run.createdAt, run.endedAt ?? null]);
51
36
  }
52
37
  catch (e) {
53
- // create-once error every backend throws (contract) — the dialect classification has a single
54
- // owner (`sql-errors.ts`, A-032 P1-①); the old local form read only mysql2's string `code`.
55
38
  if (isDupKeyError(this.db.dialect, e)) {
56
39
  throw new WorkflowRunStoreError("workflow_run.already_exists", "workflow run id already exists");
57
40
  }
@@ -64,36 +47,24 @@ export class SqlWorkflowRunStore {
64
47
  if (!r)
65
48
  return null;
66
49
  const run = JSON.parse(String(r.run));
67
- run.rev = Number(r.rev); // the COLUMN is the OCC authority — overlay it (the blob's rev may predate a CAS bump)
50
+ run.rev = Number(r.rev);
68
51
  return run;
69
52
  }
70
53
  async update(id, scope, run, expect) {
71
- // id/scope immutable in the stored payload (InMemory parity: key + partition are authoritative). The blob's
72
- // rev is NOT trusted (see get's overlay) — write it as-is; the column bump below is the real OCC state.
73
54
  let blob = JSON.stringify({ ...run, id, scope });
74
55
  if (Buffer.byteLength(blob) > MAX_RUN_BLOB_BYTES) {
75
- // Oversize degrade (1.108 review fix): slim the payload so the write — critically the TERMINAL one —
76
- // still lands (a `false` here would never be retried with a smaller payload; see slimOversizeRun).
77
- // C5 (2026-07-31): the degrade drops real data (phases/agents/groups → []) — that must leave a trace
78
- // reaching whoever can act on it, not just a same-shaped `true` return. `onWarn` is optional so a
79
- // caller that hasn't wired a sink yet keeps building/running unchanged.
80
56
  this.onWarn?.("workflow_run_blob_oversize_slimmed", { id, scope, fullBytes: Buffer.byteLength(blob), capBytes: MAX_RUN_BLOB_BYTES });
81
57
  const slim = slimOversizeRun({ ...run, id, scope }, MAX_RUN_BLOB_BYTES);
82
58
  if (slim === null)
83
- return false; // even the skeleton is oversize — keep the prior revision
59
+ return false;
84
60
  blob = slim;
85
61
  }
86
62
  const res = await this.db.query(this.q(`UPDATE workflow_run SET run = ?, rev = rev + 1, status = ?, ended_at_ms = ? WHERE id = ? AND scope = ?${expect !== undefined ? " AND rev = ?" : ""}`, `UPDATE workflow_run SET run = $1, rev = rev + 1, status = $2, ended_at_ms = $3 WHERE id = $4 AND scope = $5${expect !== undefined ? " AND rev = $6" : ""}`), expect !== undefined
87
63
  ? [blob, run.status, run.endedAt ?? null, id, scope, expect.rev]
88
64
  : [blob, run.status, run.endedAt ?? null, id, scope]);
89
- return res.affected === 1; // rev always bumps → affected is a faithful CAS verdict
65
+ return res.affected === 1;
90
66
  }
91
67
  async listByScope(scope, opts) {
92
- // 🔴 genuinely-divergent CONSTRUCTION (not just SQL text): PG's `$n` placeholders are POSITIONAL and must
93
- // track `params.length` as each optional clause is appended, while TiDB's `?` is positionless — a single
94
- // `q()` pair can't express that, so the where/limit assembly forks per dialect. The FILTER logic itself
95
- // (status → session → limit, session dropping unmatched/absent-key rows via JSON extraction, limit applied
96
- // last) is identical; only the placeholder bookkeeping differs.
97
68
  const params = [scope];
98
69
  let where;
99
70
  let limit = "";
@@ -104,9 +75,6 @@ export class SqlWorkflowRunStore {
104
75
  params.push(opts.status);
105
76
  }
106
77
  if (opts?.session !== undefined) {
107
- // core 1.368([1516]②)session 下推臂:limit 前应用、严格等值、无字段行剔除(JSON path 缺席 →
108
- // NULL → `= ?` 恒假 ✓)。JSON 提取而非投影列:对存量行零迁移直接生效;行量有 retention 界,
109
- // 与本函数固有的全行 JSON.parse 同量级。
110
78
  where += " AND JSON_UNQUOTE(JSON_EXTRACT(run, '$.originatingSessionId')) = ?";
111
79
  params.push(opts.session);
112
80
  }
@@ -122,7 +90,6 @@ export class SqlWorkflowRunStore {
122
90
  where += ` AND status = $${params.length}`;
123
91
  }
124
92
  if (opts?.session !== undefined) {
125
- // core 1.368([1516]②)session 下推臂(TiDB twin 同案注释):JSON 提取、limit 前、缺席键 → NULL → 拒 ✓
126
93
  params.push(opts.session);
127
94
  where += ` AND run::json->>'originatingSessionId' = $${params.length}`;
128
95
  }
@@ -131,7 +98,6 @@ export class SqlWorkflowRunStore {
131
98
  limit = ` LIMIT $${params.length}`;
132
99
  }
133
100
  }
134
- // Sort in SQL (newest first), summarize via core's SHARED projection (anti-drift).
135
101
  const { rows } = await this.db.query(`SELECT run, rev FROM workflow_run WHERE ${where} ORDER BY created_at_ms DESC, id DESC${limit}`, params);
136
102
  return rows.map((r) => {
137
103
  const run = JSON.parse(String(r.run));
@@ -139,11 +105,6 @@ export class SqlWorkflowRunStore {
139
105
  return summarizeWorkflowRun(run);
140
106
  });
141
107
  }
142
- /** Retention (the store never auto-purges; reap is explicit): the contract's `reap` is scope-keyed and the
143
- * contract deliberately has NO cross-scope enumeration — but the SQL twin CAN enumerate (DISTINCT on the
144
- * extracted scope column), which is exactly what a periodic deployment sweep needs. Walks every scope through
145
- * the contract-faithful `reap` (terminal-only; running rows are the orphan-grace sweep's business). Service
146
- * extension, not a contract member. Byte-identical text on both dialects (no params) — no q() needed. */
147
108
  async reapAllScopes(now, opts) {
148
109
  const { rows } = await this.db.query("SELECT DISTINCT scope FROM workflow_run");
149
110
  let total = 0;
@@ -153,9 +114,7 @@ export class SqlWorkflowRunStore {
153
114
  }
154
115
  async reap(scope, now, opts) {
155
116
  if (opts?.maxAgeMs === undefined && opts?.keep === undefined)
156
- return 0; // retention is always explicit
157
- // Cheap columns only; terminal-set + keep-N semantics computed in JS to match InMemory EXACTLY
158
- // (isTerminalWorkflowStatus is core's — no status list duplicated into SQL).
117
+ return 0;
159
118
  const { rows } = await this.db.query(this.q("SELECT id, status, created_at_ms, ended_at_ms FROM workflow_run WHERE scope = ? ORDER BY created_at_ms DESC, id DESC", "SELECT id, status, created_at_ms, ended_at_ms FROM workflow_run WHERE scope = $1 ORDER BY created_at_ms DESC, id DESC"), [scope]);
160
119
  const terminal = rows.filter((r) => isTerminalWorkflowStatus(String(r.status)));
161
120
  const doomed = [];
@@ -169,16 +128,12 @@ export class SqlWorkflowRunStore {
169
128
  }
170
129
  if (doomed.length === 0)
171
130
  return 0;
172
- // Bulk delete-by-id-set: TiDB expands a dynamic `IN (?,?,…)` (one placeholder per id, spread params);
173
- // PG binds the whole set as one array via `= ANY($2::text[])`. Same result set, different statement shape.
174
131
  const res = await this.db.query(this.q(`DELETE FROM workflow_run WHERE scope = ? AND id IN (${doomed.map(() => "?").join(",")})`, "DELETE FROM workflow_run WHERE scope = $1 AND id = ANY($2::text[])"), this.db.dialect === "tidb" ? [scope, ...doomed] : [scope, doomed]);
175
132
  return res.affected;
176
133
  }
177
134
  }
178
- /** The SQL fence row kinds. `run_id = ''` on a purge fence (session-wide). */
179
135
  const KIND_SERVED = "served";
180
136
  const KIND_PURGE = "purge";
181
- /** Dual-dialect `WorkflowCompletionInbox`. See the file header for the dialect-delta ledger. */
182
137
  export class SqlWorkflowCompletionInbox {
183
138
  db;
184
139
  onWarn;
@@ -195,43 +150,26 @@ export class SqlWorkflowCompletionInbox {
195
150
  async enqueue(entry) {
196
151
  const now = Date.now();
197
152
  await this.sweepFences(now);
198
- // Served fence FIRST (per-runId — cross-replica: a poll served on ANOTHER replica fences us here).
199
153
  const served = await this.db.query(this.q("SELECT 1 FROM workflow_inbox_fence WHERE session_id = ? AND kind = ? AND run_id IN (?, ?) AND until_ms >= ?", "SELECT 1 FROM workflow_inbox_fence WHERE session_id = $1 AND kind = $2 AND run_id IN ($3, $4) AND until_ms >= $5"), [entry.sessionId, KIND_SERVED, entry.runId, foldKeyFamily(entry.runId), now]);
200
154
  if (served.rows.length > 0) {
201
155
  this.onWarn?.("workflow_completion_enqueue_after_poll_served", { sessionId: entry.sessionId, runId: entry.runId });
202
156
  return;
203
157
  }
204
- // Purge fence: owner-scoped exactly like the File impl (owner_known=0 → conservative fence-all;
205
- // owner_known=1 → fences only the SAME owner; a different owner = a new legitimate claimant, passes).
206
158
  const purge = await this.db.query(this.q("SELECT owner, owner_known FROM workflow_inbox_fence WHERE session_id = ? AND kind = ? AND run_id = '' AND until_ms >= ?", "SELECT owner, owner_known FROM workflow_inbox_fence WHERE session_id = $1 AND kind = $2 AND run_id = '' AND until_ms >= $3"), [entry.sessionId, KIND_PURGE, now]);
207
159
  const pf = purge.rows[0];
208
160
  if (pf && (Number(pf.owner_known) === 0 || (pf.owner === null ? entry.owner === null : String(pf.owner) === entry.owner))) {
209
161
  this.onWarn?.("workflow_completion_enqueue_after_purge", { sessionId: entry.sessionId, runId: entry.runId });
210
162
  return;
211
163
  }
212
- // Atomic move-to-tail (1.108 review fix — the prior DELETE-then-INSERT had a crash window that dropped the
213
- // old entry; self-healing via the notify journal, but atomic is free here). Genuinely divergent statement:
214
- // TiDB `REPLACE` = delete-conflicting-row + insert atomically, and omitting `seq` mints a fresh
215
- // AUTO_INCREMENT = tail. PG has no REPLACE, so the twin is an upsert that explicitly re-mints `seq` off the
216
- // BIGSERIAL's own sequence — same outcome (idempotent-on-runId + move-to-tail in ONE statement).
217
164
  await this.db.query(this.q("REPLACE INTO workflow_completion_inbox (session_id, run_id, owner, status, summary, enqueued_at_ms, kind, payload) VALUES (?,?,?,?,?,?,?,?)", "INSERT INTO workflow_completion_inbox (session_id, run_id, owner, status, summary, enqueued_at_ms, kind, payload) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) " +
218
165
  "ON CONFLICT (session_id, run_id) DO UPDATE SET seq = nextval(pg_get_serial_sequence('workflow_completion_inbox','seq')), " +
219
166
  "owner = EXCLUDED.owner, status = EXCLUDED.status, summary = EXCLUDED.summary, enqueued_at_ms = EXCLUDED.enqueued_at_ms, kind = EXCLUDED.kind, payload = EXCLUDED.payload"), [entry.sessionId, entry.runId, entry.owner, entry.status, entry.summary, entry.enqueuedAt, entry.kind ?? null, entry.payload ?? null]);
220
- // 1.108 review (lens① MAJOR ×2): the File impl's check-then-set is atomic on one event loop; here the
221
- // fence check and the INSERT are two network statements, so a fence written IN BETWEEN (a poll served on
222
- // another replica, or a racing purge) would leave fence + pending row coexisting → the next drain re-pushes
223
- // an already-served/purged completion. Close the window by RE-CHECKING after the insert and self-revoking —
224
- // ordering guarantees the loser is always visible to someone: markTerminalServed/purge write their fence
225
- // BEFORE their delete, so either their delete kills our row, or our re-check sees their fence.
226
- // (Residual, documented: a purge racing a NEW claimant's enqueue between fence-write and session-DELETE can
227
- // still drop that one push — ms window + sessionId reclaim in it is contrived; the WorkflowStatus poll floor
228
- // + notify-journal recovery cover it. File parity: purge drops the whole session queue there too.)
229
167
  const confirm = await this.db.query(this.q("SELECT kind, owner, owner_known FROM workflow_inbox_fence WHERE session_id = ? AND until_ms >= ? AND (run_id IN (?, ?) OR (kind = ? AND run_id = ''))", "SELECT kind, owner, owner_known FROM workflow_inbox_fence WHERE session_id = $1 AND until_ms >= $2 AND (run_id IN ($3, $4) OR (kind = $5 AND run_id = ''))"), [entry.sessionId, now, entry.runId, foldKeyFamily(entry.runId), KIND_PURGE]);
230
168
  for (const f of confirm.rows) {
231
169
  const isServed = String(f.kind) === KIND_SERVED;
232
170
  const purgeFencesUs = !isServed && (Number(f.owner_known) === 0 || (f.owner === null ? entry.owner === null : String(f.owner) === entry.owner));
233
171
  if (isServed || purgeFencesUs) {
234
- await this.ack(entry.sessionId, entry.runId); // self-revoke: the fence won the race
172
+ await this.ack(entry.sessionId, entry.runId);
235
173
  this.onWarn?.(isServed ? "workflow_completion_enqueue_after_poll_served" : "workflow_completion_enqueue_after_purge", {
236
174
  sessionId: entry.sessionId,
237
175
  runId: entry.runId,
@@ -239,21 +177,14 @@ export class SqlWorkflowCompletionInbox {
239
177
  return;
240
178
  }
241
179
  }
242
- // Cap: drop-oldest beyond MAX_PENDING_PER_SESSION (warn per drop, File parity). TiDB requires a LIMIT
243
- // alongside OFFSET (MySQL-protocol syntax); PG's OFFSET stands alone.
244
180
  const over = await this.db.query(this.q("SELECT seq, run_id FROM workflow_completion_inbox WHERE session_id = ? ORDER BY seq DESC LIMIT 100000 OFFSET ?", "SELECT seq, run_id FROM workflow_completion_inbox WHERE session_id = $1 ORDER BY seq DESC OFFSET $2"), [entry.sessionId, MAX_PENDING_PER_SESSION]);
245
181
  if (over.rows.length > 0) {
246
- // Bulk delete-by-seq-set: dynamic `IN (?,?,…)` on TiDB vs array-bound `= ANY($2::bigint[])` on PG.
247
182
  await this.db.query(this.q(`DELETE FROM workflow_completion_inbox WHERE session_id = ? AND seq IN (${over.rows.map(() => "?").join(",")})`, "DELETE FROM workflow_completion_inbox WHERE session_id = $1 AND seq = ANY($2::bigint[])"), this.db.dialect === "tidb" ? [entry.sessionId, ...over.rows.map((r) => r.seq)] : [entry.sessionId, over.rows.map((r) => r.seq)]);
248
183
  for (const r of over.rows)
249
184
  this.onWarn?.("workflow_completion_inbox_overflow", { sessionId: entry.sessionId, droppedRunId: String(r.run_id) });
250
185
  }
251
186
  }
252
187
  async pending(sessionId) {
253
- // 2026-07-11(同一缺陷的 SQL 半场):filter SERVED-fenced rows like the in-memory
254
- // inbox does — markTerminalServed's fence-INSERT and ack-DELETE are two statements, and a drain on
255
- // another replica between them re-emitted an already-served completion (the in-memory pending()
256
- // filters by servedAt; this brings the SQL twin to the same semantics).
257
188
  const { rows } = await this.db.query(this.q("SELECT i.run_id, i.owner, i.status, i.summary, i.enqueued_at_ms, i.kind, i.payload FROM workflow_completion_inbox i " +
258
189
  "WHERE i.session_id = ? AND NOT EXISTS (SELECT 1 FROM workflow_inbox_fence f WHERE f.session_id = i.session_id AND f.kind = ? AND f.run_id IN (i.run_id, REGEXP_REPLACE(i.run_id, '#[0-9]+$', '')) AND f.until_ms >= ?) " +
259
190
  "ORDER BY i.seq ASC", "SELECT i.run_id, i.owner, i.status, i.summary, i.enqueued_at_ms, i.kind, i.payload FROM workflow_completion_inbox i " +
@@ -276,7 +207,6 @@ export class SqlWorkflowCompletionInbox {
276
207
  async purge(sessionId, purgedOwner) {
277
208
  const now = Date.now();
278
209
  await this.sweepFences(now);
279
- // Fence FIRST (an enqueue racing the purge must lose), then clear. Upsert = a re-purge refreshes the window.
280
210
  await this.db.query(this.q("INSERT INTO workflow_inbox_fence (session_id, kind, run_id, owner, owner_known, until_ms) VALUES (?,?,?,?,?,?) " +
281
211
  "ON DUPLICATE KEY UPDATE owner = VALUES(owner), owner_known = VALUES(owner_known), until_ms = VALUES(until_ms)", "INSERT INTO workflow_inbox_fence (session_id, kind, run_id, owner, owner_known, until_ms) VALUES ($1,$2,'',$3,$4,$5) " +
282
212
  "ON CONFLICT (session_id, kind, run_id) DO UPDATE SET owner = EXCLUDED.owner, owner_known = EXCLUDED.owner_known, until_ms = EXCLUDED.until_ms"), this.db.dialect === "tidb"
@@ -291,15 +221,9 @@ export class SqlWorkflowCompletionInbox {
291
221
  "ON DUPLICATE KEY UPDATE until_ms = VALUES(until_ms)", "INSERT INTO workflow_inbox_fence (session_id, kind, run_id, owner, owner_known, until_ms) VALUES ($1,$2,$3,NULL,0,$4) " +
292
222
  "ON CONFLICT (session_id, kind, run_id) DO UPDATE SET until_ms = EXCLUDED.until_ms"), [sessionId, KIND_SERVED, runId, now + SERVED_FENCE_MS]);
293
223
  await this.ack(sessionId, runId);
294
- // design/144 家族 ack(与 in-memory 语义对齐):fleet 帧无 seq 传家族前缀,park 行是 seq 键——
295
- // 精确 DELETE 必 miss。剥行键尾部 #digits 与传入前缀比对(REGEXP_REPLACE/regexp_replace 语义=foldKeyFamily)。
296
224
  await this.db.query(this.q("DELETE FROM workflow_completion_inbox WHERE session_id = ? AND run_id <> ? AND REGEXP_REPLACE(run_id, '#[0-9]+$', '') = ?", "DELETE FROM workflow_completion_inbox WHERE session_id = $1 AND run_id <> $2 AND regexp_replace(run_id, '#[0-9]+$', '') = $3"), [sessionId, runId, runId]);
297
225
  }
298
226
  }
299
- /**
300
- * Dual-dialect `WorkflowNotifyJournalStore` — the crash-safe recovery backlog for at-least-once completion
301
- * notify. See the file header for the dialect-delta ledger.
302
- */
303
227
  export class SqlWorkflowNotifyJournalStore {
304
228
  db;
305
229
  constructor(db) {
@@ -309,8 +233,6 @@ export class SqlWorkflowNotifyJournalStore {
309
233
  return this.db.dialect === "tidb" ? tidb : pg;
310
234
  }
311
235
  async record(input) {
312
- // Idempotent-on-runId (a second record for the same run — incl. an acked one — is a no-op): TiDB
313
- // `INSERT IGNORE` vs PG `ON CONFLICT (run_id) DO NOTHING`.
314
236
  await this.db.query(this.q("INSERT IGNORE INTO workflow_notify_journal (run_id, scope, acked, source_task_id, principal, created_at_ms) VALUES (?,?,0,?,?,?)", "INSERT INTO workflow_notify_journal (run_id, scope, acked, source_task_id, principal, created_at_ms) VALUES ($1,$2,0,$3,$4,$5) ON CONFLICT (run_id) DO NOTHING"), [input.runId, input.scope, input.sourceTaskId ?? null, input.principal ?? null, input.createdAt]);
315
237
  }
316
238
  async ack(runId, ackedAt) {
@@ -327,9 +249,6 @@ export class SqlWorkflowNotifyJournalStore {
327
249
  createdAt: Number(r.created_at_ms),
328
250
  }));
329
251
  }
330
- /** Retention (same sweep as reapAllScopes): ACKED rows are pure history — without this the twin re-opens
331
- * the unbounded-growth hole the same release closed for workflow_run. Pending rows are NEVER reaped (they
332
- * are the recovery backlog; the orphan-grace sweep is what retires a stuck pending run). */
333
252
  async reapAcked(before) {
334
253
  const res = await this.db.query(this.q("DELETE FROM workflow_notify_journal WHERE acked = 1 AND acked_at_ms < ?", "DELETE FROM workflow_notify_journal WHERE acked = 1 AND acked_at_ms < $1"), [before]);
335
254
  return res.affected;
@@ -350,8 +269,6 @@ export class SqlWorkflowNotifyJournalStore {
350
269
  };
351
270
  }
352
271
  }
353
- /** MySQL-protocol (TiDB) bindings — historical class names preserved; `onWarn` is an ADDITIVE optional 2nd
354
- * ctor arg (existing 1-arg call sites are unaffected — see {@link SqlWorkflowRunStore}'s C5 trace channel). */
355
272
  export class TiDBWorkflowRunStore extends SqlWorkflowRunStore {
356
273
  constructor(pool, onWarn) {
357
274
  super(mysqlDriver(pool), onWarn);
@@ -367,8 +284,6 @@ export class TiDBWorkflowNotifyJournalStore extends SqlWorkflowNotifyJournalStor
367
284
  super(mysqlDriver(pool));
368
285
  }
369
286
  }
370
- /** PostgreSQL bindings — historical class names preserved; `onWarn` is an ADDITIVE optional 2nd ctor arg
371
- * (existing 1-arg call sites are unaffected — see {@link SqlWorkflowRunStore}'s C5 trace channel). */
372
287
  export class PgWorkflowRunStore extends SqlWorkflowRunStore {
373
288
  constructor(pool, onWarn) {
374
289
  super(pgDriver(pool), onWarn);
@@ -1,83 +1,17 @@
1
- /**
2
- * SVC-3 — git-WORKTREE ISOLATION wrapper for the deployment-level `executionEnvFactory` (design/97
3
- * CORE-6). A higher-order factory that mints a per-AGENT isolated git worktree when (and ONLY
4
- * when) core threads `ctx.isolation === "worktree"` through the TRUSTED `RunInternals` channel.
5
- *
6
- * WHY this lives in service, not core: core ships the env-AGNOSTIC primitive (`addWorktree` / `pruneWorktrees`
7
- * on the base `ExecutionEnv` seam, plus the `ExecutionEnvFactoryContext.isolation` hint). The env-SPECIFIC
8
- * bits — which concrete adapter roots an env at the worktree dir, which repo is the operator-trusted base,
9
- * where the reaper runs — are deployment wiring, so they belong here (铁律 core-service-boundary). We consume
10
- * the core export; we do NOT reimplement the git plumbing.
11
- *
12
- * SHAPE (per the task contract):
13
- * const factory = withWorktreeIsolation(baseFactory, opts);
14
- * - ctx.isolation === "worktree": `addWorktree(baseEnvForGit, { repoRoot, sessionId: ctx.sessionId,
15
- * rootEnvAt, commit? })` → a worktree-rooted env WITH `destroy()` (the Runner calls it on task end →
16
- * `git worktree remove --force`, which tears down ONLY that worktree, never the base repo).
17
- * - otherwise: `baseFactory(ctx)` verbatim — the SHARED base env, NO `destroy` (the Runner must never tear
18
- * the base down). Non-isolated tasks behave exactly as before this wrapper existed.
19
- *
20
- * 🔴 TRUST GATE (the whole point of SVC-3): the ONLY input that turns isolation ON is core's
21
- * `ctx.isolation` — populated from `ctx.agent({ isolation })` via the TRUSTED `RunInternals`, which core
22
- * guarantees is NEVER sourced from the untrusted `TaskSpec` (see core remote-env.ts §isolation). This wrapper
23
- * reads NOTHING else from `ctx` to decide isolation, and `repoRoot` comes ENTIRELY from the operator-supplied
24
- * closure (config), never from any task/`ctx` field — so an untrusted caller can neither flip isolation on a
25
- * repo it shouldn't touch nor inject a `repoRoot` path to escape the allowed roots. We additionally assert the
26
- * configured `repoRoot` is contained within an operator-trusted `allowedRoots` set at wrap time (defence in
27
- * depth: a mis-wired deployment fails loud rather than git-worktree-ing an arbitrary path).
28
- *
29
- * 🔴 ISOLATE-ONLY (clay): the runtime NEVER merges a worktree back. The orchestrator script reads each
30
- * worktree's result (via the worktree dir / the snapshot) and decides verify+merge in USERLAND. We only mint
31
- * and reap; merge policy is not ours.
32
- *
33
- * ⚠️ BOUNDARY CAVEATS (from core's CORE-6 doc — kept here so a future wirer doesn't reintroduce
34
- * a footgun):
35
- * - durable-suspend is INCOMPATIBLE with a worktree env: core's prepare-task rejects a NON-remote factory env
36
- * for a suspendable run (a host/NodeExecutionEnv-rooted worktree is not snapshotable). So an isolated agent
37
- * MUST run to completion without parking — do NOT combine `isolation:"worktree"` with a suspend/HITL park
38
- * on the host lane. (On an isolated lane like e2b/k8s the worktree would have to root a REMOTE env; that is
39
- * a follow-on, see `rootEnvAt` below — host lane is v1.)
40
- * - `git worktree` isolates the git WORKING COPY, not arbitrary shell access: bash inside the worktree can
41
- * still reach the host filesystem/network. The worktree is a coordination boundary, NOT a security sandbox
42
- * — pair it with a real sandbox lane (e2b/k8s/local-docker) when isolation from the host is required.
43
- * - UNCOMMITTED changes in the base repo are ABSENT in the child worktree (core creates it `--detach` at
44
- * `HEAD`/`commit`). An isolated agent sees the committed tree only — commit (or stash→apply, in userland)
45
- * before fanning out if the base's dirty state must be visible.
46
- * - a worktree orphaned by a process CRASH (the Runner never reached `destroy`) is reaped by
47
- * {@link pruneWorktrees} — wired into the service reaper (best-effort, userland; core ships no post-kill
48
- * Runner hook).
49
- */
50
1
  import path from "node:path";
51
2
  import { addWorktree, pruneWorktrees, } from "@sema-agent/core";
52
- /** Normalize a path for containment checks: resolve to absolute + drop a trailing separator. */
53
3
  function norm(p) {
54
4
  const r = path.resolve(p);
55
5
  return r.length > 1 && r.endsWith(path.sep) ? r.slice(0, -1) : r;
56
6
  }
57
- /** True when `child` is `root` itself or a descendant of it (segment-aware: `/a/b` is NOT under `/a/bc`). */
58
7
  function isWithin(root, child) {
59
8
  const r = norm(root);
60
9
  const c = norm(child);
61
10
  if (c === r)
62
11
  return true;
63
12
  const rel = path.relative(r, c);
64
- // path.relative returns "" for equal, a non-"../"-leading relative for a descendant, and a "../"-leading
65
- // (or absolute, on Windows drive change) path for an outside/sibling target.
66
13
  return rel.length > 0 && !rel.startsWith("..") && !path.isAbsolute(rel);
67
14
  }
68
- /**
69
- * SERIALIZE git worktree ADMIN ops per repo (MODULE-level, keyed by normalized repoRoot). `git worktree
70
- * add`/`remove`/`prune` all mutate the same repo's `.git/worktrees` registry; running any two CONCURRENTLY
71
- * against one repo races. Two callers share this registry:
72
- * - the isolation wrapper's mint (`add`) and teardown (`remove`) legs;
73
- * - the service reaper's `prune` leg (#62 svc3 race: prune used to bypass the wrapper's closure-local
74
- * lock entirely — the adverse arm is prune observing a mid-mint "registered, dir not yet populated"
75
- * window and DEREGISTERING the half-minted worktree, handing the task a dir git no longer tracks and
76
- * making its eventual `git worktree remove` fail loud on a healthy run).
77
- * A promise-chain mutex per repoRoot: each op runs after the prior one SETTLES (success OR failure — never
78
- * deadlock). Git ops are ~ms and serial-safe; agents still run fully parallel. The map is bounded by the
79
- * number of distinct configured repoRoots (operator config, typically 1) — no eviction needed.
80
- */
81
15
  const gitAdminLocks = new Map();
82
16
  function serializeGitAdmin(repoRoot, fn) {
83
17
  const key = norm(repoRoot);
@@ -85,38 +19,21 @@ function serializeGitAdmin(repoRoot, fn) {
85
19
  gitAdminLocks.set(key, run.then(() => undefined, () => undefined));
86
20
  return run;
87
21
  }
88
- /**
89
- * Wrap a deployment's `executionEnvFactory` so an agent with `ctx.isolation === "worktree"` runs in its own
90
- * detached git worktree. See the file header for the full contract + trust gate + caveats.
91
- *
92
- * Throws at WRAP time (fail-loud, not faked) if `repoRoot` is not contained within `allowedRoots` — a
93
- * mis-wired deployment must not silently git-worktree an arbitrary path.
94
- */
95
22
  export function withWorktreeIsolation(baseFactory, opts) {
96
23
  const repoRoot = norm(opts.repoRoot);
97
24
  const allowedRoots = (opts.allowedRoots?.length ? opts.allowedRoots : [repoRoot]).map(norm);
98
- // Trust gate (defence in depth): the configured repoRoot must live inside an operator-trusted root. This is
99
- // a CONFIG invariant (repoRoot is never a task input), so a violation = mis-wire → fail loud at boot.
100
25
  if (!allowedRoots.some((root) => isWithin(root, repoRoot))) {
101
26
  throw new Error(`withWorktreeIsolation: repoRoot ${repoRoot} is not within any allowedRoots [${allowedRoots.join(", ")}] — refusing to git-worktree an untrusted path`);
102
27
  }
103
28
  const serialize = (fn) => serializeGitAdmin(repoRoot, fn);
104
29
  return async (ctx) => {
105
- // TRUST GATE: the ONLY trigger is core's RunInternals-sourced ctx.isolation. We read nothing else from ctx
106
- // (and never anything from a TaskSpec) to decide isolation — see file header.
107
30
  if (ctx.isolation !== "worktree") {
108
- // Non-isolated: the SHARED base env, verbatim, NO destroy attached → the Runner never tears the base
109
- // down (core's hasDestroy() returns false for a plain base env). Behaviour identical to no wrapper.
110
31
  return baseFactory(ctx);
111
32
  }
112
- // S3([2113] core 裁定的 server 半场):keep-if-changed 需要在保留分支跑 inner teardown(LSP evict
113
- // 链等)而不跑 `git worktree remove` —— core addWorktree 把两者焊成一个复合 destroy 拆不开,所以在
114
- // rootEnvAt 铸造点捕获 inner env 的**原** destroy 引用(per-mint 变量;core 稍后才把复合 destroy
115
- // 装回同一对象上)。
116
33
  let innerDestroy;
117
34
  const { env, worktreeDir } = await serialize(() => addWorktree(opts.baseEnvForGit, {
118
35
  repoRoot,
119
- sessionId: ctx.sessionId, // core sanitizes this into the worktree dir name (human-readable orphan diag)
36
+ sessionId: ctx.sessionId,
120
37
  rootEnvAt: async (dir) => {
121
38
  const inner = await opts.rootEnvAt(dir);
122
39
  const d = inner.destroy;
@@ -126,8 +43,6 @@ export function withWorktreeIsolation(baseFactory, opts) {
126
43
  },
127
44
  ...(opts.commit ? { commit: opts.commit } : {}),
128
45
  }));
129
- // keep-if-changed 判定基点:mint 后立刻解析 worktree 的 HEAD(=detach 基点)。判定轴与 core Agent
130
- // 车道逐字同款(subagent.js finish):dirty 文件 >0 ∥ base..HEAD commits >0 ⇒ changed。
131
46
  let baseSha;
132
47
  {
133
48
  const r = await opts.baseEnvForGit.exec("git rev-parse HEAD", { cwd: worktreeDir });
@@ -135,7 +50,6 @@ export function withWorktreeIsolation(baseFactory, opts) {
135
50
  baseSha = r.value.stdout.trim();
136
51
  }
137
52
  const isChanged = async () => {
138
- // 判定失败 ⇒ 保守保留(Agent 车道 changed=true 缺省同款:删错=用户产出蒸发,留错=一个目录)。
139
53
  const status = await opts.baseEnvForGit.exec("git status --porcelain", { cwd: worktreeDir });
140
54
  if (!status.ok || status.value.exitCode !== 0)
141
55
  return true;
@@ -154,37 +68,18 @@ export function withWorktreeIsolation(baseFactory, opts) {
154
68
  ...(ctx.taskId ? { taskId: ctx.taskId } : {}),
155
69
  worktreeDir,
156
70
  });
157
- // Wrap core's `destroy()` (runs `git worktree remove`) through the SAME mutex so a concurrent teardown can't
158
- // race the registry lock and orphan the worktree. The other env methods (exec/fs) keep their own binding —
159
- // we only override `destroy`. Idempotent at the git level (remove of an already-gone worktree is a no-op).
160
- //
161
- // POST-MORTEM + RETRY (2026-07-26, the deterministic pin for the 30-round reaper-race intermittent):
162
- // core's destroy is FAIL-OPEN on the remove — `git-worktree-env.js` neither checks the exec Result nor lets a
163
- // rejection out (`.catch(() => undefined)`), so one transient failure (spawn EAGAIN/EMFILE under full-suite
164
- // load) resolves destroy() normally while leaving dir+registration behind — and `git worktree prune` can NEVER
165
- // reap a worktree whose dir still exists, so the orphan is permanent. Until core checks the result (reported),
166
- // we verify the dir is actually gone after each destroy, retry ONCE on residue, and FAIL LOUD if it survives
167
- // both — a thrown teardown is diagnosable; a silently-accumulating orphan tree is not.
168
71
  const coreDestroy = env.destroy.bind(env);
169
72
  const residue = async () => {
170
73
  const r = await opts.baseEnvForGit.exists(worktreeDir);
171
- return r.ok ? r.value : false; // can't verify (base env unhealthy) → don't invent a residue; destroy stays best-effort
74
+ return r.ok ? r.value : false;
172
75
  };
173
- // IDEMPOTENCY (core 1.423): the remove leg is now FAIL-LOUD ([1796]§二 → [1801] — the fail-open this wrapper's
174
- // post-mortem was built against is fixed), so a REPEAT destroy would hit `git worktree remove` on an
175
- // already-removed worktree and reject. A completed destroy + no residue ⇒ short-circuit (the goal state is
176
- // already reached; double/concurrent destroy is a legal Runner shape). The first call always goes through
177
- // coreDestroy (the inner env's own teardown must run).
178
76
  let destroyedOnce = false;
179
77
  let keptOnce = false;
180
78
  env.destroy = () => serialize(async () => {
181
79
  if (keptOnce)
182
- return; // kept 是终态:二次 destroy 不得把保留的产出删掉
80
+ return;
183
81
  if (destroyedOnce && !(await residue()))
184
- return; // idempotent repeat — done is done(原语义:残留时放行重试)
185
- // S3 keep-if-changed:有改动(或判不出来)⇒ 保留目录+registration 给 userland verify+merge,
186
- // 只跑 inner teardown(LSP evict/host 收尾;rootEnvAt 铸的是 persistent 形,不 rm 目录),
187
- // 并披露坐标 —— 不披露的保留=只是换了个姿势的蒸发。残留检查在本分支让位:目录在=目的。
82
+ return;
188
83
  if (await isChanged()) {
189
84
  keptOnce = true;
190
85
  try {
@@ -205,31 +100,16 @@ export function withWorktreeIsolation(baseFactory, opts) {
205
100
  if (!(await residue()))
206
101
  return;
207
102
  opts.logger?.warn?.("worktree_destroy_residue_retry", { worktreeDir, sessionId: ctx.sessionId });
208
- await coreDestroy(); // second shot on a live registration/dir — safe; a missing one is short-circuited above
103
+ await coreDestroy();
209
104
  if (await residue()) {
210
105
  throw new Error(`worktree destroy left residue at ${worktreeDir} (git worktree remove failed twice — see core git-worktree-env fail-open)`);
211
106
  }
212
107
  });
213
- return env; // carries the serialized destroy() → core's Runner removes the worktree (ONLY this one) on task end.
108
+ return env;
214
109
  };
215
110
  }
216
- /**
217
- * Best-effort reaper hook: deregister worktrees orphaned by a process crash (the Runner never reached
218
- * `destroy`). Core's {@link pruneWorktrees} runs `git worktree prune` against the base repo — it cleans up
219
- * registrations whose worktree dirs are already gone. Wired into the service reaper (userland; core ships no
220
- * post-kill Runner hook). NEVER throws (a prune failure is logged, never fatal).
221
- *
222
- * NOTE: `git worktree prune` only deregisters worktrees whose DIRECTORY is missing. A crashed run that left
223
- * the dir intact is NOT reaped by prune alone — that is acceptable for v1 (the worktree lives under the known
224
- * `.sema-worktrees/` parent and is human-/script-reapable; a stricter age-based sweep is a follow-on).
225
- */
226
111
  export async function reapOrphanWorktrees(baseEnvForGit, repoRoot, logger) {
227
112
  try {
228
- // #62:prune 动的是与 add/remove 同一张 `.git/worktrees` 注册表 ⇒ 必须过同一把 per-repoRoot 锁
229
- // (曾绕锁直跑;恶劣臂见 serializeGitAdmin 注)。
230
- // core 3.0.0(#94 提货):pruneWorktrees 从「失败 throw」改为回执 `{ok:true}|{ok:false,detail}`——
231
- // 只靠下面的 catch 发警在 3.0.0 下= prune 失败**静默化**(CI svc3 钉抓出)。回执臂与 throw 臂
232
- // (serializeGitAdmin/env 传输层仍可抛)两路都进同一条 warn,判据不变:失败必须留痕。
233
113
  const r = await serializeGitAdmin(repoRoot, () => pruneWorktrees(baseEnvForGit, norm(repoRoot)));
234
114
  if (r.ok === false)
235
115
  logger?.warn?.("worktree_prune_failed", { repoRoot: norm(repoRoot), error: r.detail });