@sema-agent/server 7.43.0 → 7.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (345) hide show
  1. package/USAGE.md +18 -0
  2. package/dist/adoption/plan.js +31 -192
  3. package/dist/adoption/quiesce.js +3 -86
  4. package/dist/adoption/runner.js +3 -140
  5. package/dist/adoption/sql.js +0 -74
  6. package/dist/adoption/wire.js +0 -80
  7. package/dist/approval-ask-machine.js +0 -75
  8. package/dist/approval-card.js +0 -323
  9. package/dist/approval-deny-reasons.js +0 -42
  10. package/dist/approval-hmac.js +0 -35
  11. package/dist/approval-reconciler.js +8 -166
  12. package/dist/approval.js +4 -67
  13. package/dist/audit.js +1 -44
  14. package/dist/auth-bridge.js +4 -47
  15. package/dist/auth-keys.js +0 -23
  16. package/dist/bake-runner/main.js +4 -65
  17. package/dist/bake-runner/protocol.js +3 -58
  18. package/dist/bake-runner/runner.js +5 -91
  19. package/dist/bench/l8/artifact.js +2 -61
  20. package/dist/bench/l8/escape.js +0 -25
  21. package/dist/bench/l8/index.js +0 -14
  22. package/dist/bench/l8/probes.js +0 -60
  23. package/dist/bench/l8/run-probes.js +2 -45
  24. package/dist/bench/s1/arms.js +14 -189
  25. package/dist/bench/s1/live-deps.js +11 -253
  26. package/dist/bench/s1/oracle.js +1 -36
  27. package/dist/bench/s1/repair-oracle-adapter.js +0 -30
  28. package/dist/bench/s1/reviewer.js +0 -34
  29. package/dist/bench/s1/row.js +0 -62
  30. package/dist/bench/s1/run-firm.js +4 -70
  31. package/dist/bench/s1/runner-ctx.js +0 -40
  32. package/dist/bench/s1/tasks.js +0 -115
  33. package/dist/boot/adoption.js +0 -20
  34. package/dist/boot/budget-tracing.js +3 -55
  35. package/dist/boot/config-center.js +43 -551
  36. package/dist/boot/coordinators.js +1 -69
  37. package/dist/boot/crash-last.js +0 -18
  38. package/dist/boot/deferred-sandbox-path-env.js +3 -119
  39. package/dist/boot/execution-env.js +5 -171
  40. package/dist/boot/governance-seams.js +0 -92
  41. package/dist/boot/leader.js +0 -69
  42. package/dist/boot/limit-sync.js +0 -2
  43. package/dist/boot/memory-boundary.js +3 -91
  44. package/dist/boot/org-memory.js +1 -25
  45. package/dist/boot/parked-revive-gate.js +0 -150
  46. package/dist/boot/permission-rules-audit.js +1 -56
  47. package/dist/boot/reapers.js +15 -273
  48. package/dist/boot/resolve-spec.js +9 -768
  49. package/dist/boot/retention-lane.js +4 -87
  50. package/dist/boot/runner-deps.js +3 -297
  51. package/dist/boot/runtime-caps.js +0 -40
  52. package/dist/boot/session-faces.js +2 -133
  53. package/dist/boot/shutdown.js +9 -83
  54. package/dist/boot/side-query-lane.js +2 -137
  55. package/dist/boot/stores.js +15 -215
  56. package/dist/boot/task-list-lane.js +0 -18
  57. package/dist/boot/webfetch-summarize-lane.js +0 -47
  58. package/dist/boot/workflow-orchestration.js +3 -99
  59. package/dist/boot-reclaim.js +0 -20
  60. package/dist/bounded-session-map.js +0 -19
  61. package/dist/brain.js +2 -139
  62. package/dist/budget.js +9 -244
  63. package/dist/capabilities/builtin-tools.js +0 -3
  64. package/dist/capabilities/center-plugins.js +6 -51
  65. package/dist/capabilities/center-prompts.js +6 -71
  66. package/dist/capabilities/code-review-council.js +7 -40
  67. package/dist/capabilities/collab-workflows.js +1 -44
  68. package/dist/capabilities/hands-lane.js +0 -65
  69. package/dist/capabilities/memory-notice.js +0 -70
  70. package/dist/capabilities/prompt.js +0 -14
  71. package/dist/capabilities/prompts/code-review.js +0 -14
  72. package/dist/capabilities/prompts/identity.js +0 -6
  73. package/dist/capabilities/prompts/team.js +0 -4
  74. package/dist/capabilities/repo-tools.js +0 -34
  75. package/dist/capabilities/sandbox-file-send.js +3 -92
  76. package/dist/capabilities/scenarios.d.ts +0 -1
  77. package/dist/capabilities/scenarios.js +18 -279
  78. package/dist/capabilities/select-environment-tool.js +0 -32
  79. package/dist/capabilities/send-user-file-tool.js +2 -66
  80. package/dist/capabilities/skills.d.ts +1 -2
  81. package/dist/capabilities/skills.js +3 -21
  82. package/dist/capabilities/team.d.ts +3 -11
  83. package/dist/capabilities/team.js +3 -51
  84. package/dist/capabilities/tool-defer.js +0 -3
  85. package/dist/config-center/apply-effective.js +15 -330
  86. package/dist/config-center/apply-ledger.js +5 -38
  87. package/dist/config-center/facade.js +0 -41
  88. package/dist/config-center/hot-keys-registry.js +0 -20
  89. package/dist/config-center/http-client.js +2 -125
  90. package/dist/config-center/mcp-revocation.js +2 -34
  91. package/dist/config-center/read-face.js +0 -59
  92. package/dist/config-center/restart-signal.js +1 -85
  93. package/dist/config-center/skills-mcp.d.ts +1 -1
  94. package/dist/config-center/skills-mcp.js +9 -76
  95. package/dist/config-center/stage-limits.js +8 -35
  96. package/dist/config-invariants.js +0 -16
  97. package/dist/config-lkg.js +0 -42
  98. package/dist/config-provider.js +3 -186
  99. package/dist/config-types.js +0 -5
  100. package/dist/config.js +107 -1145
  101. package/dist/degenerate-instrument.js +3 -67
  102. package/dist/deployment-governance.js +0 -124
  103. package/dist/digest-form.js +0 -12
  104. package/dist/elicitation.js +3 -86
  105. package/dist/env-facts.js +7 -75
  106. package/dist/fleet/fleet-bus.js +34 -507
  107. package/dist/fleet/fleet-reconciler.js +9 -149
  108. package/dist/fleet/fleet-terminal-window.js +10 -178
  109. package/dist/fleet/subagent-tail-bus.js +3 -72
  110. package/dist/fleet-client.js +10 -70
  111. package/dist/fleet-lease.js +5 -79
  112. package/dist/git-api-kind.js +0 -3
  113. package/dist/governance-ask-marks.js +2 -78
  114. package/dist/hooks/branch-transcript.js +0 -74
  115. package/dist/hooks/cc-agent-hook-prompt.js +0 -29
  116. package/dist/hooks/cc-stop-prompt.js +1 -46
  117. package/dist/hooks/hook-llm.js +1 -53
  118. package/dist/hooks/hook-runner.js +20 -414
  119. package/dist/http/active-run-conflict.js +4 -129
  120. package/dist/http/cursor-fingerprint.d.ts +5 -0
  121. package/dist/http/cursor-fingerprint.js +5 -0
  122. package/dist/http/idempotency.js +0 -37
  123. package/dist/http/principal-gate.js +3 -40
  124. package/dist/http/route-ctx.js +0 -9
  125. package/dist/http/routes/a2a-serve.js +7 -319
  126. package/dist/http/routes/admin-config-refresh.js +0 -4
  127. package/dist/http/routes/admin-drain.js +0 -10
  128. package/dist/http/routes/adoption.js +1 -29
  129. package/dist/http/routes/agents-roster.js +1 -47
  130. package/dist/http/routes/approvals-assistant.js +32 -395
  131. package/dist/http/routes/attachments.js +4 -22
  132. package/dist/http/routes/capabilities.js +3 -420
  133. package/dist/http/routes/diagnostics.js +1 -81
  134. package/dist/http/routes/fleet.js +9 -185
  135. package/dist/http/routes/images.js +14 -239
  136. package/dist/http/routes/leader.js +0 -13
  137. package/dist/http/routes/memory-bundle.js +1 -61
  138. package/dist/http/routes/memory-policy.js +9 -99
  139. package/dist/http/routes/notify-wake.js +3 -37
  140. package/dist/http/routes/observability.js +2 -19
  141. package/dist/http/routes/retention-ops.js +2 -34
  142. package/dist/http/routes/rules.js +1 -93
  143. package/dist/http/routes/runs.js +64 -873
  144. package/dist/http/routes/session-sync.js +19 -258
  145. package/dist/http/routes/sessions-list.js +17 -43
  146. package/dist/http/routes/sessions.js +34 -226
  147. package/dist/http/routes/shared-memory.js +5 -36
  148. package/dist/http/routes/side-query.js +1 -87
  149. package/dist/http/routes/tasks.js +52 -722
  150. package/dist/http/routes/trace-usage.js +32 -195
  151. package/dist/http/routes/workflows.js +19 -195
  152. package/dist/http/run-meta.js +0 -6
  153. package/dist/http/send.js +0 -32
  154. package/dist/http/server.js +92 -1597
  155. package/dist/http/sse-lifecycle.js +2 -13
  156. package/dist/http/sse-log.js +3 -48
  157. package/dist/http/tar.js +5 -21
  158. package/dist/http/verify-rounds.js +0 -5
  159. package/dist/http/wire-gate.js +0 -9
  160. package/dist/http/workspace-content.js +0 -10
  161. package/dist/images/bake-validate.js +1 -70
  162. package/dist/images/manifest.js +1 -6
  163. package/dist/index.js +0 -21
  164. package/dist/key-resolver.js +2 -17
  165. package/dist/leader/diffout.js +1 -20
  166. package/dist/leader/diffup.js +0 -47
  167. package/dist/leader/endpoint.js +2 -57
  168. package/dist/leader/fanout.js +3 -45
  169. package/dist/leader/grader-env-factory.js +3 -72
  170. package/dist/leader/leader.js +5 -156
  171. package/dist/leader/merge.js +8 -115
  172. package/dist/leader/planner.js +3 -54
  173. package/dist/leader/repair-oracle.js +1 -60
  174. package/dist/leader/repair-wire.js +2 -79
  175. package/dist/leader/wire.js +8 -307
  176. package/dist/lsp/e2b-bridge.js +4 -64
  177. package/dist/lsp/e2b-manager.js +6 -94
  178. package/dist/lsp/lsp-frames.js +0 -12
  179. package/dist/lsp/manager.js +4 -96
  180. package/dist/lsp/ws-transport.js +5 -55
  181. package/dist/lsp-evict.js +1 -15
  182. package/dist/main.js +57 -765
  183. package/dist/memory-bundle-engine.js +0 -55
  184. package/dist/memory-export.js +0 -4
  185. package/dist/memory-posture.js +1 -15
  186. package/dist/memory-scope.js +11 -148
  187. package/dist/memory-sync-client.js +2 -44
  188. package/dist/memory-sync.js +1 -80
  189. package/dist/model-select.js +3 -80
  190. package/dist/observability/cost-quota.js +1 -17
  191. package/dist/observability/cost-taxonomy.js +0 -34
  192. package/dist/observability/fail-open.js +7 -86
  193. package/dist/observability/logger.js +0 -6
  194. package/dist/observability/metrics.js +0 -94
  195. package/dist/observability/otel-exporter.js +3 -13
  196. package/dist/observability/principal-context.js +0 -9
  197. package/dist/observability/prompt-manifest.js +1 -37
  198. package/dist/observability/rate-limit.js +0 -4
  199. package/dist/observability/secret-env-scrub.js +2 -56
  200. package/dist/observability/tool-trace.js +1 -70
  201. package/dist/orchestration/hardened-vm-runner.js +4 -118
  202. package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
  203. package/dist/orchestration/hardened-vm-worker.js +0 -27
  204. package/dist/orchestration/subagent-steer.js +1 -45
  205. package/dist/orchestration/workflow-agent-steer.js +1 -80
  206. package/dist/orchestration/workflow-completion-inbox.js +32 -285
  207. package/dist/orchestration/workflow-notify-journal.js +16 -259
  208. package/dist/org-memory-admission.js +3 -47
  209. package/dist/parent-watch.js +2 -48
  210. package/dist/parked-decide.js +1 -109
  211. package/dist/per-task-image.js +0 -57
  212. package/dist/plan-cache-probe.js +3 -27
  213. package/dist/plugins/adoption-log-sql.js +2 -119
  214. package/dist/plugins/approval-ask-store-memory.js +3 -38
  215. package/dist/plugins/approval-ask-store-sql.js +6 -188
  216. package/dist/plugins/approval-exemption-store.js +2 -28
  217. package/dist/plugins/background-agent-store-sql.js +4 -105
  218. package/dist/plugins/background-shell-support.js +14 -122
  219. package/dist/plugins/blob-backend.js +6 -169
  220. package/dist/plugins/breaker-state-sql.js +8 -46
  221. package/dist/plugins/caching-session-store.js +4 -106
  222. package/dist/plugins/checkpoint-store-sql.js +18 -505
  223. package/dist/plugins/e2b-orphan-reclaim.js +0 -45
  224. package/dist/plugins/file-outcome-sink.js +0 -9
  225. package/dist/plugins/file-resume-anchor-store.js +4 -43
  226. package/dist/plugins/file-run-store.js +26 -364
  227. package/dist/plugins/file-snapshot-store-sql.js +10 -181
  228. package/dist/plugins/fork-routing-session-store.js +8 -111
  229. package/dist/plugins/host-platform.js +2 -91
  230. package/dist/plugins/image-bake-store-sql.js +7 -250
  231. package/dist/plugins/image-index-sql.js +4 -123
  232. package/dist/plugins/k8s-bg-scripts.js +4 -88
  233. package/dist/plugins/k8s-exec-protocol.js +0 -41
  234. package/dist/plugins/leader-run-store-sql.js +0 -101
  235. package/dist/plugins/local-checkpoint-store.js +8 -128
  236. package/dist/plugins/local-session-store.js +34 -296
  237. package/dist/plugins/local-task-attachment-store.js +2 -16
  238. package/dist/plugins/mailbox-store-sql.js +8 -65
  239. package/dist/plugins/memory-embedder-fingerprint.js +5 -166
  240. package/dist/plugins/memory-embedder.js +4 -70
  241. package/dist/plugins/memory-engine-pg.js +6 -170
  242. package/dist/plugins/memory-engine-tidb.js +7 -155
  243. package/dist/plugins/memory-engine-vector-util.js +0 -10
  244. package/dist/plugins/memory-key-guards.js +0 -34
  245. package/dist/plugins/memory-origin-law.js +0 -187
  246. package/dist/plugins/memory-resume-anchor-store.js +0 -17
  247. package/dist/plugins/memory-run-store.js +12 -89
  248. package/dist/plugins/memory-session-policy-store.js +0 -17
  249. package/dist/plugins/memory-sync-store-pg.js +4 -49
  250. package/dist/plugins/memory-sync-store-tidb.js +3 -35
  251. package/dist/plugins/outcome-ledger-sql.js +3 -97
  252. package/dist/plugins/permission-rule-store-file.js +5 -133
  253. package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
  254. package/dist/plugins/permission-rule-store-sql.js +50 -307
  255. package/dist/plugins/pg-cost-quota.js +0 -7
  256. package/dist/plugins/pg-pool.js +0 -92
  257. package/dist/plugins/pg-rate-limiter.js +2 -13
  258. package/dist/plugins/pg-safe-json.js +4 -40
  259. package/dist/plugins/pg-session-storage.js +25 -189
  260. package/dist/plugins/posix-shell-fs.js +1 -31
  261. package/dist/plugins/remote-env-adb.js +12 -101
  262. package/dist/plugins/remote-env-e2b.js +40 -371
  263. package/dist/plugins/remote-env-file-error.js +0 -37
  264. package/dist/plugins/remote-env-host.js +63 -483
  265. package/dist/plugins/remote-env-k8s.js +32 -326
  266. package/dist/plugins/remote-env-local-docker.js +24 -157
  267. package/dist/plugins/remote-env-ssh.js +20 -128
  268. package/dist/plugins/remote-scratchpad.js +2 -32
  269. package/dist/plugins/remote-shell.js +1 -32
  270. package/dist/plugins/resume-anchor-store-sql.js +0 -11
  271. package/dist/plugins/retention-lane-store-sql.js +0 -108
  272. package/dist/plugins/retention-store-sql.js +4 -383
  273. package/dist/plugins/roster-store-sql.js +0 -55
  274. package/dist/plugins/run-store-sql.js +14 -251
  275. package/dist/plugins/s3-presign.js +2 -49
  276. package/dist/plugins/scheduler-support.js +3 -80
  277. package/dist/plugins/send-file-ledger.js +4 -53
  278. package/dist/plugins/send-user-file.js +4 -94
  279. package/dist/plugins/session-placement.js +1 -89
  280. package/dist/plugins/session-policy-store-sql.js +3 -81
  281. package/dist/plugins/session-store.js +0 -57
  282. package/dist/plugins/shared-memory-store-sql.js +4 -167
  283. package/dist/plugins/sql-driver.js +0 -17
  284. package/dist/plugins/sql-errors.js +0 -7
  285. package/dist/plugins/sql-escape.js +0 -8
  286. package/dist/plugins/sql-row-helpers.js +0 -25
  287. package/dist/plugins/store-backend.js +42 -222
  288. package/dist/plugins/store-contracts.js +2 -46
  289. package/dist/plugins/task-attachment-store.js +3 -49
  290. package/dist/plugins/task-list-store-sql.js +0 -79
  291. package/dist/plugins/tidb-cost-quota.js +1 -4
  292. package/dist/plugins/tidb-pool.js +1 -207
  293. package/dist/plugins/tidb-rate-limiter.js +3 -9
  294. package/dist/plugins/tidb-session-storage.js +4 -70
  295. package/dist/plugins/tidb-session-store.js +19 -341
  296. package/dist/plugins/tool-result-store-sql.js +4 -179
  297. package/dist/plugins/usage-window-store-sql.js +0 -8
  298. package/dist/plugins/web-search.js +10 -132
  299. package/dist/plugins/workflow-journal-store-sql.js +2 -58
  300. package/dist/plugins/workflow-run-store-sql.js +6 -91
  301. package/dist/plugins/worktree-isolation.js +6 -126
  302. package/dist/plugins/write-behind-counter.js +16 -75
  303. package/dist/principal-jwt.js +5 -60
  304. package/dist/project-memory.js +15 -146
  305. package/dist/prompts-domain-validate.js +1 -51
  306. package/dist/question.js +2 -111
  307. package/dist/resource-suspend.js +0 -18
  308. package/dist/router/route-orchestration.js +0 -77
  309. package/dist/rules-consent.d.ts +44 -5
  310. package/dist/rules-consent.js +75 -215
  311. package/dist/run-local.js +14 -381
  312. package/dist/runs.js +29 -617
  313. package/dist/runtime-caps-resolver.js +6 -132
  314. package/dist/runtime-governance.js +1 -232
  315. package/dist/sandbox-pkg-source.js +0 -37
  316. package/dist/sealed-key.js +3 -68
  317. package/dist/security.js +6 -291
  318. package/dist/session-leaf-bus.js +0 -32
  319. package/dist/session-sync-content.js +1 -69
  320. package/dist/session-sync-kernel.js +3 -60
  321. package/dist/session-sync.js +3 -66
  322. package/dist/session-titler.js +7 -41
  323. package/dist/session-watch.js +9 -73
  324. package/dist/shared-memory-scope-authorizer.js +0 -18
  325. package/dist/sighup-idle.js +1 -12
  326. package/dist/spec-fields.js +4 -143
  327. package/dist/store-live-probe.js +3 -49
  328. package/dist/task-a2a.js +1 -126
  329. package/dist/task-cwd.js +1 -103
  330. package/dist/task-mcp.js +1 -89
  331. package/dist/task-settings.js +10 -300
  332. package/dist/task-workflow.js +8 -75
  333. package/dist/tool-approval.d.ts +28 -1
  334. package/dist/tool-approval.js +70 -1430
  335. package/dist/trace/artifacts.js +5 -20
  336. package/dist/trace/engine-notice-wire.js +6 -138
  337. package/dist/trace/ledger-sink.js +7 -96
  338. package/dist/trace/project.js +4 -448
  339. package/dist/trace/redact.js +9 -77
  340. package/dist/turn-activity.js +1 -27
  341. package/dist/usage-analytics.js +4 -34
  342. package/dist/wall-clock-jump-guard.js +1 -68
  343. package/package.json +3 -3
  344. package/dist/capabilities/scenario-alias.d.ts +0 -27
  345. package/dist/capabilities/scenario-alias.js +0 -61
@@ -1,34 +1,14 @@
1
- /**
2
- * TiDB-backed Session event-log center (L1) — the durable "persistence center".
3
- *
4
- * Implements core's `SessionStore`. `acquire` rebuilds a session from its append-only event log
5
- * (= wake/resume), so any stateless runner instance can continue it. Cross-instance write safety is
6
- * enforced by the storage layer's compare-and-set (core's F2 seam); crash consistency (orphan
7
- * tool-call after a mid-turn crash) is handled by the Runner calling `reconcileInterruptedSession`
8
- * on acquire (core's F1).
9
- *
10
- * Persistence only. **Tenant authorization (who may wake which session) lives in the service layer**
11
- * (`src/security.ts`), which uses `ownerOf` / `register` below. The store does not infer identity.
12
- */
13
- // 🔴 core 1.403 迁移(2026-07-25):runtime 构造用法,`Session` → `StoredSession`(见 audit.ts 同款注释)。
14
1
  import { StoredSession, SessionError, uuidv7, validateEntriesForImport, leafIdAfterEntry } from "@sema-agent/core";
15
2
  import { TiDBSessionStorage } from "./tidb-session-storage.js";
16
3
  import { escapeLike } from "./sql-escape.js";
17
4
  import { MANAGED_RETENTION } from "./retention-store-sql.js";
18
5
  import { contentForkRelation, fastForwardSharedContentDiverged } from "../session-sync-content.js";
19
6
  import { assertPlacementAdmission, LIST_PLACED_BATCH, PLACEMENT_SELECT_COLUMNS, placedRowFromRow, placementFromRow, placementInsertValues, SQL_SESSION_PLACEMENTS, } from "./session-placement.js";
20
- import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING_ID_MARKER, STAGING_GC_GRACE_MS, // 两孪生店同源(理由见 kernel 处顶注:同值双抄单边改值 ⇒ 两后端 GC 判定分家)
21
- } from "../session-sync-kernel.js";
7
+ import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING_ID_MARKER, STAGING_GC_GRACE_MS, } from "../session-sync-kernel.js";
22
8
  export class TiDBSessionStore {
23
9
  pool;
24
- /** #270 车1:托管留存声明。读法与「为什么 SQL 店答 managed 而实现体在别处」逐字见
25
- * `retention-store-sql.ts` 的 {@link MANAGED_RETENTION}。 */
26
10
  retention = MANAGED_RETENTION;
27
- /** #292 C18(core 5.48.0 design/323):placement **声明**。六条义务的实装点逐条见
28
- * {@link SQL_SESSION_PLACEMENTS} 顶注与本类的 acquire / listSessions / release / listPlaced /
29
- * placementOf 各处的 `#292` 注。 */
30
11
  placements = SQL_SESSION_PLACEMENTS;
31
- /** In-flight acquisitions keyed by id, so concurrent acquire(sameId) in one process share one. */
32
12
  pending = new Map();
33
13
  constructor(pool) {
34
14
  this.pool = pool;
@@ -36,18 +16,11 @@ export class TiDBSessionStore {
36
16
  async acquire(sessionId, opts) {
37
17
  if (!sessionId) {
38
18
  const id = uuidv7();
39
- // #292 义务:placement 与行**同一条 INSERT** 落库(创建时一次落行,first-write 不可变)。
40
19
  await this.register(id, null, opts?.placement);
41
20
  return this.load(id, false, opts);
42
21
  }
43
- // design/114 Phase3 (F1): a `requireExisting` acquire NEVER creates and can REJECT (not_found) — it MUST bypass
44
- // the shared create-dedup `pending`, else a concurrent create-capable waiter awaiting that same promise would
45
- // inherit the not_found rejection instead of creating-on-miss. Two concurrent requireExisting opens each just
46
- // wake independently (both resolve or both fail loud) — harmless (it only opens, never double-creates).
47
22
  if (opts?.requireExisting)
48
23
  return this.load(sessionId, true, opts);
49
- // #292:准入判据**按调用方逐一判**(core `TtlSessionStore` 的同款纪律)——共享的 in-flight promise
50
- // 只共享「会话被唤醒」这件事,绝不让一个 claim 形等待者继承可信创建方的准入,或反过来。
51
24
  const inflight = this.pending.get(sessionId);
52
25
  if (inflight) {
53
26
  const acquired = await inflight;
@@ -61,55 +34,29 @@ export class TiDBSessionStore {
61
34
  async load(sessionId, requireExisting = false, opts) {
62
35
  let storage = await TiDBSessionStorage.wake(this.pool, sessionId);
63
36
  if (storage) {
64
- // #292 义务 6(claim 关门)+ 镜像方向:判据吃的是**已持久化**的元组(wake 顺路带回,零额外往返)。
65
37
  assertPlacementAdmission(sessionId, (await storage.getMetadata()).placement, opts);
66
38
  return { session: new StoredSession(storage), sessionId };
67
39
  }
68
- // design/114 Phase3: `requireExisting` ⇒ FAIL LOUD on a genuinely missing session (no session_meta row),
69
- // never the silent create-on-miss that turns a reuse-style warm-resume into a fresh empty run. core's
70
- // prepare-task maps this store `not_found` to the caller-facing `resume.session_not_found`.
71
40
  if (requireExisting)
72
41
  throw new SessionError("not_found", `session ${sessionId} not found`);
73
- // No meta row yet (a caller-chosen brand-new id the service didn't pre-register). Create it
74
- // ownerless; only the caller who chose this id can reach it, and it holds no prior data.
75
42
  await this.register(sessionId, null, opts?.placement);
76
43
  storage = await TiDBSessionStorage.wake(this.pool, sessionId);
77
- // 刚 register 完却唤不醒 = 有人在这两步之间把行删了(带外 purge / 留存腿)。旧形在这里是一个
78
- // `storage!` 非空断言,真撞上时炸成一条无信息的 `TypeError: reading 'getMetadata' of null`
79
- // (本批实测撞到过一次:邻车在同一测试库上整表 DELETE)。改成响亮且**说得出发生了什么**的错。
80
44
  if (!storage) {
81
45
  throw new SessionError("storage", `session ${sessionId} vanished between register and wake (a concurrent purge won the race) — retry`);
82
46
  }
83
- // #292 镜像方向(pre-claim squat):带 placement 的创建必须**回读**到持久化的同组元组——并发的
84
- // claim 形若抢先建出普通行,我们的 INSERT 是 no-op,回读即缺席 ⇒ 响亮拒(不是静默降级成普通会话)。
85
47
  assertPlacementAdmission(sessionId, (await storage.getMetadata()).placement, opts);
86
48
  return { session: new StoredSession(storage), sessionId };
87
49
  }
88
- /** Idempotently create a session_meta row with an owner. Existing owner is never overwritten.
89
- * #292:`placement` 在场时与行同一条 INSERT 落六列;ON DUPLICATE 臂是 no-op ⇒ 已存在的行的元组
90
- * **永不被重写**(first-write 不可变,义务 5 的写侧半场)。 */
91
50
  async register(sessionId, owner, placement) {
92
51
  const now = new Date();
93
52
  await this.pool.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq, " +
94
53
  "placement_kind, placement_scope, placement_parent_session_id, placement_root_session_id, placement_handle, placed_at) " +
95
54
  "VALUES (?,?,?,?,NULL,0,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE session_id = session_id", [sessionId, owner, now, now, ...placementInsertValues(placement, now)]);
96
55
  }
97
- /**
98
- * #292 义务 6 的廉价探针 —— 一行 PK SELECT:`undefined` = 普通会话或无此行。
99
- * (release 的冷缓存腿、包装店的暖命中准入判、诊断面共用这一只。)
100
- */
101
56
  async placementOf(sessionId) {
102
57
  const [rows] = await this.pool.query(`SELECT ${PLACEMENT_SELECT_COLUMNS} FROM session_meta WHERE session_id = ?`, [sessionId]);
103
58
  return placementFromRow(rows[0]);
104
59
  }
105
- /**
106
- * #292 义务 5 —— placed 分区的**自有枚举**(联合 reap 的分区腿输入)。
107
- *
108
- * 年龄锚 = `COALESCE(updated_at, placed_at)`:`updated_at` 是本店的「最后活动」标记(register 时置、
109
- * 每次 append/touch 推进),取不到才回落 `placed_at` —— 与 core File 店「mtime 优先、placedAt 回落」
110
- * 的读法同义。行按年龄升序、上界 {@link LIST_PLACED_BATCH}(理由见那里)。
111
- * join 半缺(scope/handle 任一为 NULL)⇒ `tupleIncomplete` 诚实变体,消费方对它 fail-closed。
112
- */
113
60
  async listPlaced(kind, opts) {
114
61
  const params = [kind];
115
62
  let sql = `SELECT session_id, updated_at, ${PLACEMENT_SELECT_COLUMNS} FROM session_meta WHERE placement_kind = ?`;
@@ -126,7 +73,6 @@ export class TiDBSessionStore {
126
73
  const [rows] = await this.pool.query(sql, params);
127
74
  return rows.map((r) => placedRowFromRow(String(r.session_id), r, r.updated_at instanceof Date ? r.updated_at.getTime() : undefined));
128
75
  }
129
- /** Owner principal of a session, `null` if registered ownerless, `undefined` if no such session. */
130
76
  async ownerOf(sessionId) {
131
77
  const [rows] = await this.pool.query("SELECT owner FROM session_meta WHERE session_id = ?", [sessionId]);
132
78
  if (rows.length === 0)
@@ -139,51 +85,26 @@ export class TiDBSessionStore {
139
85
  sessionId,
140
86
  ]);
141
87
  }
142
- /** E18 — the session's current leaf `SessionTreeEntry.id` (`session_meta.leaf_id`, set transactionally on every
143
- * persist). A single SELECT, NO acquire/lock/CAS — safe to call mid-run (write-safety is the append-time CAS, not
144
- * an acquire lease) and cache-bypassing (the resume-at turn-capture reads it without touching the warm session).
145
- * Returns the leaf id, or null when the session has none / no such session. */
146
88
  async getLeafId(sessionId) {
147
89
  const [rows] = await this.pool.query("SELECT leaf_id FROM session_meta WHERE session_id = ?", [sessionId]);
148
90
  return rows[0] ? (rows[0].leaf_id ?? null) : null;
149
91
  }
150
- /** [1196] 六轮复审:owner+leafId **单行原子快照**(SSE watch 探针的租户围栏读)——两独立 SELECT 在
151
- * delete/reclaim 缝里可拼出「旧 owner+新 leaf」混世代组合;单 SELECT 单行=天然一致。undefined=无此会话。 */
152
92
  async getHead(sessionId) {
153
93
  const [rows] = await this.pool.query("SELECT owner, leaf_id FROM session_meta WHERE session_id = ?", [sessionId]);
154
94
  if (rows.length === 0)
155
95
  return undefined;
156
96
  return { owner: rows[0].owner ?? null, leafId: rows[0].leaf_id ?? null };
157
97
  }
158
- /**
159
- * Durable history is retained; just drop any in-flight cache entry.
160
- *
161
- * 🔴 #292 义务 4 —— **placed 会话例外:release 是真删除**(durable 史一并)。联合 reap 先按 rev 条件删
162
- * 掉 agent 行、赢了才调本方法终结转录;若这里还按「归还租约」走,一条 30 天的转录就会失去属主行地
163
- * 悬着(正是这条义务要关死的 strand)。判据取**持久化元组**(冷缓存/重启后照样成立,不依赖任何进程
164
- * 内状态);删除本身是 ABA 安全的**条件删**(`placement_kind IS NOT NULL`)——探针与删除之间若真有
165
- * 「行被删掉又以同 id 重建成普通会话」的世代翻转,条件删当场落空,不会误杀那只无辜的新会话。
166
- * 删除失败**响亮上浮**(`SessionError("storage")`):吞掉失败等于让 reap 数一条「已终结」而字节还在
167
- * (core File 店同批的 rmSync 失败上浮是同一条法)。
168
- */
169
98
  async release(sessionId) {
170
99
  this.pending.delete(sessionId);
171
100
  const placed = await this.placementOf(sessionId);
172
101
  if (placed === undefined)
173
- return; // 普通会话:acquire/release 语义逐字节不变(义务 3)
102
+ return;
174
103
  await this.deletePlacedSession(sessionId);
175
104
  }
176
- /**
177
- * core B-17 的 `forget` 面 —— **丢引用,不动 durable 史**(与 {@link release} 的分工:release 是
178
- * 生命周期终结口,forget 是「我用完了这只句柄」)。#292 之后这条分工是承重的:release 对 placed
179
- * 会话已是真删除,于是每一处「只为验存在 / 写完收工」的收尾都必须落在 forget 上(preflight 纪律,
180
- * [4743] 义务①)。本店的进程内状态只有 in-flight 去重表,故实现就是丢那一格。
181
- */
182
105
  forget(sessionId) {
183
106
  this.pending.delete(sessionId);
184
107
  }
185
- /** 真删除一只 placed 会话的两张表(条件删门在 `placement_kind IS NOT NULL`;事务内 meta 先删=提交点,
186
- * 与 {@link deleteSession} 同序)。任何 I/O 失败上浮成 `SessionError("storage")`,绝不吞。 */
187
108
  async deletePlacedSession(sessionId) {
188
109
  const conn = await this.pool.getConnection();
189
110
  try {
@@ -198,7 +119,6 @@ export class TiDBSessionStore {
198
119
  await conn.rollback();
199
120
  }
200
121
  catch {
201
- /* ignore */
202
122
  }
203
123
  throw err instanceof SessionError
204
124
  ? err
@@ -208,58 +128,26 @@ export class TiDBSessionStore {
208
128
  conn.release();
209
129
  }
210
130
  }
211
- // ───────────────────────── SessionRepo seam (§0.5 session-ownership) ─────────────────────────
212
- // E16/E17/E21: the service EXTENDS core's `SessionRepo` (list/fork/delete) over its OWN session_meta /
213
- // session_event tables — a `TiDBSessionRepo`-style implementation (core's `PgSessionRepo` targets core's
214
- // own `agent_*` tables, so it can't be consumed directly; the dossier §0.5 prescribes a backend-mirrored
215
- // impl here). Enumeration + lifecycle now come from the SESSION abstraction (session identity), not the
216
- // `task_run` runs ledger; `task_run` retreats to a pure runs ledger that only SUPPLIES run-level preview.
217
- /**
218
- * E16 — list the caller's sessions (CC /resume picker), keyset-paginated newest-first by last activity.
219
- * Session IDENTITY/enumeration comes from `session_meta` (owner-filtered on the service's OWN owner column);
220
- * a LEFT JOIN onto the `task_run` runs ledger supplies run-level PREVIEW (latest run's objective/status,
221
- * run count) but never gates which sessions exist — a session with zero runs still lists (preview null/0).
222
- * Keyset on `(updated_at, session_id)`; `updated_at` is the session's last-activity marker (touch on every
223
- * run/append). The shipped {@link SessionSummary} shape is preserved (additive): `lastActivityAt` =
224
- * `session_meta.updated_at`, `firstActivityAt` = `session_meta.created_at`.
225
- */
226
131
  async listSessions(opts) {
227
132
  const params = [];
228
- // 🔴 #292 义务 2:placed 会话**永不上宿主枚举面**——壳的 /resume 选择器不许把一条子代转录当成可
229
- // 续聊的会话摆出来(而且它一旦被选中,claim 形 acquire 还会当场拒)。谓词打在**持久化列**上 ⇒
230
- // 重启后照样成立(义务的「restart-stable」一半)。
231
133
  const where = ["sm.placement_kind IS NULL"];
232
134
  if (opts.owner) {
233
- // [4198] 窄互认第二臂的第三条件在此:`includeUnowned` 时谓词放宽到「本人的 无主的」。
234
- // 仍然**不是** `owner IS NOT DISTINCT FROM ?`——具名的别人一行都不多给。
235
- where.push(opts.includeUnowned ? "(sm.owner = ? OR sm.owner IS NULL)" : "sm.owner = ?"); // idx_owner
135
+ where.push(opts.includeUnowned ? "(sm.owner = ? OR sm.owner IS NULL)" : "sm.owner = ?");
236
136
  params.push(opts.owner);
237
137
  }
238
138
  if (opts.q) {
239
- // `?q=` search: match sessions where ANY run's objective preview contains q (EXISTS on the
240
- // runs ledger — better recall than latest-run-only, still session-granular). LOWER-LIKE = collation-proof
241
- // case-insensitivity (TiDB table collation may be _bin); escapeLike + ESCAPE pins the wildcard semantics
242
- // (deployment baseline: default sql_mode — under NO_BACKSLASH_ESCAPES the '\\' literal would break, a
243
- // known escapeLike-family boundary shared with the tool-result store).
244
- // No tr.owner predicate BY DESIGN: the outer WHERE already proves the session is the caller's;
245
- // a same-session_id foreign-owner run requires DB write access to fabricate (session_meta PK + uuidv7),
246
- // and an owner predicate would drop the LEGITIMATE mixed-owner form (anonymous run → principal attach,
247
- // the E21 diverged-owner case) — recall loss for a 1-bit defense against an attacker who can already write.
248
139
  where.push("EXISTS (SELECT 1 FROM task_run tr WHERE tr.session_id = sm.session_id AND LOWER(tr.objective_preview) LIKE LOWER(?) ESCAPE '\\\\')");
249
140
  params.push(`%${escapeLike(opts.q)}%`);
250
141
  }
251
142
  if (opts.cursor) {
252
- where.push("(sm.updated_at < ? OR (sm.updated_at = ? AND sm.session_id < ?))"); // keyset: strictly older, ties by id
143
+ where.push("(sm.updated_at < ? OR (sm.updated_at = ? AND sm.session_id < ?))");
253
144
  params.push(new Date(opts.cursor.lastActivityAt), new Date(opts.cursor.lastActivityAt), opts.cursor.sessionId);
254
145
  }
255
- // The run-preview JOIN: the LATEST run per session (rn=1, ties broken by task_id so EXACTLY one row joins —
256
- // a bare MAX(created_at) JOIN would fan out the outer row when two runs share a created_at) + a count rollup.
257
- // A LEFT JOIN keeps sessions with NO runs (preview NULL, run_count 0). Bounded by per-tenant data today.
258
146
  const sql = "SELECT sm.session_id, sm.owner, sm.updated_at AS last_activity, sm.created_at AS first_activity, sm.title, " +
259
147
  " COALESCE(r.run_count, 0) AS run_count, latest.objective_preview, latest.status, latest.task_id AS last_run_id " +
260
148
  "FROM session_meta sm " +
261
149
  "LEFT JOIN (SELECT session_id, COUNT(*) AS run_count FROM task_run GROUP BY session_id) r ON r.session_id = sm.session_id " +
262
- "LEFT JOIN (SELECT session_id, objective_preview, status, task_id FROM (" + // K-5c: carry the rn=1 run's task_id
150
+ "LEFT JOIN (SELECT session_id, objective_preview, status, task_id FROM (" +
263
151
  " SELECT session_id, objective_preview, status, task_id, " +
264
152
  " ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at DESC, task_id DESC) AS rn FROM task_run" +
265
153
  " ) lr WHERE rn = 1) latest ON latest.session_id = sm.session_id " +
@@ -275,55 +163,33 @@ export class TiDBSessionStore {
275
163
  runCount: Number(r.run_count),
276
164
  objectivePreview: r.objective_preview ?? null,
277
165
  lastStatus: r.status == null ? "" : String(r.status),
278
- lastRunId: r.last_run_id ?? null, // K-5c: latest run id = the live-tail re-attach anchor (null if no runs)
279
- title: r.title ?? null, // auto-generated title (null = not yet titled)
166
+ lastRunId: r.last_run_id ?? null,
167
+ title: r.title ?? null,
280
168
  }));
281
169
  }
282
- /** Cheap pre-LLM probe: "none" = session_meta row not yet REGISTERED (createRun fires
283
- * before the Runner's session acquire — the titler retries), "untitled" / "titled" as read. */
284
170
  async probeTitle(sessionId) {
285
171
  const [rows] = await this.pool.query("SELECT title FROM session_meta WHERE session_id = ?", [sessionId]);
286
172
  if (!rows[0])
287
173
  return "none";
288
174
  return rows[0].title == null ? "untitled" : "titled";
289
175
  }
290
- /** Write-once auto-title: lands ONLY when title IS NULL (a concurrent/prior title wins;
291
- * the user-facing rename layer is BFF-side). Returns whether this call set it. */
292
176
  async setTitleIfNull(sessionId, title) {
293
177
  const [r] = await this.pool.query("UPDATE session_meta SET title = ? WHERE session_id = ? AND title IS NULL", [title, sessionId]);
294
178
  return r.affectedRows > 0;
295
179
  }
296
- /**
297
- * E17 — fork a session: copy its WHOLE durable history to a NEW uuidv7 session owned by `owner`, mirroring
298
- * core's `PgSessionRepo.fork`. Entry ids / parent links are per-session and copy VERBATIM; only `seq` is
299
- * re-densified from 0 in the new session. The new leaf = the source leaf (session_meta.leaf_id).
300
- * Returns the new sessionId, or null if the source does not exist (the route maps that to 404).
301
- *
302
- * 🔴 FULL history, NOT `wake().getEntries()` (adversarial-review MEDIUM): `wake` loads only the bounded tail
303
- * `[floor..leaf]` from the latest compaction floor (F3), so forking via it would copy only the active tail of a
304
- * COMPACTED session and SILENTLY LOSE all pre-compaction history (contradicting the whole-history-copy intent).
305
- * We instead SELECT the entire `session_event` log ordered by seq — every durable entry, compaction floor
306
- * bypassed — and re-seq it dense from 0 into the new session, ids/parents/payload byte-verbatim.
307
- */
308
180
  async fork(sourceId, owner) {
309
- // The source's authoritative leaf (and existence check) come from session_meta — null meta ⇒ unknown source.
310
181
  const [metaRows] = await this.pool.query("SELECT leaf_id FROM session_meta WHERE session_id = ?", [sourceId]);
311
182
  if (!metaRows[0])
312
183
  return null;
313
184
  const leafId = metaRows[0].leaf_id ?? null;
314
- // FULL durable history (compaction floor bypassed) — every entry, oldest first. payload is the stored entry blob.
315
185
  const [eventRows] = await this.pool.query("SELECT entry_id, parent_id, type, payload, ts FROM session_event WHERE session_id = ? ORDER BY seq ASC", [sourceId]);
316
186
  const newId = uuidv7();
317
187
  const now = new Date();
318
188
  const conn = await this.pool.getConnection();
319
189
  try {
320
190
  await conn.beginTransaction();
321
- // Entries land BEFORE the session_meta row (mirrors core BUG-1): the meta row is the commit point —
322
- // a mid-fork failure leaves orphan events with NO meta row (invisible to wake), never a half-session.
323
191
  for (let seq = 0; seq < eventRows.length; seq++) {
324
192
  const r = eventRows[seq];
325
- // Copy the payload blob VERBATIM (string or already-parsed object); re-stringify only an object so the
326
- // INSERT binds a JSON string. entry_id/parent_id/type/ts are carried over unchanged; only seq is re-densified.
327
193
  const payload = typeof r.payload === "string" ? r.payload : JSON.stringify(r.payload);
328
194
  await conn.query("INSERT INTO session_event (session_id, seq, entry_id, parent_id, type, payload, ts) VALUES (?,?,?,?,?,?,?)", [newId, seq, r.entry_id, r.parent_id ?? null, r.type, payload, r.ts]);
329
195
  }
@@ -336,7 +202,6 @@ export class TiDBSessionStore {
336
202
  await conn.rollback();
337
203
  }
338
204
  catch {
339
- /* ignore */
340
205
  }
341
206
  throw err instanceof SessionError ? err : new SessionError("storage", `fork of session ${sourceId} failed`, err instanceof Error ? err : undefined);
342
207
  }
@@ -344,100 +209,46 @@ export class TiDBSessionStore {
344
209
  conn.release();
345
210
  }
346
211
  }
347
- /**
348
- * 2c session-sync — EXPORT a session's full durable entry log for cross-backend migration. This is the
349
- * READ half of {@link fork}, surfaced as the core `SessionRepo.exportEntries` seam: the WHOLE `session_event` log,
350
- * oldest-first, ids/parents/payload/ts VERBATIM, with the compaction floor BYPASSED (NOT `wake().getEntries()`,
351
- * which returns only the bounded `[floor..leaf]` tail and would silently truncate pre-floor history — see fork's
352
- * note at :149-152). Returns `null` if the source does not exist (no `session_meta` row), mirroring fork's null
353
- * semantics; an existing-but-empty session returns `[]`. The owner is NOT exported — import re-stamps it (§9).
354
- */
355
212
  async exportEntries(sessionId) {
356
- // Existence check = session_meta (a session with zero events but a meta row is a real, empty session ⇒ []).
357
213
  const [metaRows] = await this.pool.query("SELECT 1 FROM session_meta WHERE session_id = ?", [sessionId]);
358
214
  if (!metaRows[0])
359
215
  return null;
360
- // FULL durable log (compaction floor bypassed) — every entry, oldest first. payload is the stored entry blob.
361
216
  const [eventRows] = await this.pool.query("SELECT entry_id, parent_id, type, payload, ts FROM session_event WHERE session_id = ? ORDER BY seq ASC", [sessionId]);
362
- // The export contract is `SessionTreeEntry[]` (the full entry blob), not the column projection. The stored
363
- // `payload` IS the verbatim entry (id/parentId/timestamp + variant fields) — parse a string blob, pass an
364
- // already-parsed object (mysql2 returns JSON columns parsed) through.
365
217
  return eventRows.map((r) => (typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload));
366
218
  }
367
- /**
368
- * 2c session-sync P1d-α (PULL streaming) — the IDS-ONLY projection of {@link exportEntries}: the entry ids of the
369
- * WHOLE durable log, oldest-first (`SELECT entry_id … ORDER BY seq ASC`), with no payloads. Small even for a huge
370
- * session, so the PULL `GET /sync/manifest` can carry it (and feed it to `classifySyncRelationshipByIds`) instead of
371
- * the entries. Returns `null` if the source does not exist (no `session_meta` row), mirroring {@link exportEntries}.
372
- */
373
219
  async listEntryIds(sessionId) {
374
220
  const [metaRows] = await this.pool.query("SELECT 1 FROM session_meta WHERE session_id = ?", [sessionId]);
375
221
  if (!metaRows[0])
376
- return null; // no such session (mirror exportEntries' null)
222
+ return null;
377
223
  const [rows] = await this.pool.query("SELECT entry_id FROM session_event WHERE session_id = ? ORDER BY seq ASC", [sessionId]);
378
224
  return rows.map((r) => String(r.entry_id));
379
225
  }
380
- /**
381
- * 2c session-sync P1d-α (PULL streaming) — STREAM the WHOLE durable log (compaction floor bypassed), oldest-first,
382
- * as an async generator that KEYSET-PAGES the `session_event` table: mysql2 buffers a plain SELECT, so a 1M-row
383
- * session would OOM if read in one shot. Instead the generator loops `… WHERE seq > cursor ORDER BY seq ASC LIMIT K`
384
- * (K = `opts.batchSize ?? 500`), advancing the cursor to the last `seq` of each batch, until a SHORT page (fewer than
385
- * K rows) ends the stream. Each batch is its OWN `pool.query` (a pooled connection acquired + released per batch), so
386
- * a slow NDJSON consumer can NEVER pin one connection across the whole stream (the clay-mandated concurrency
387
- * discipline). Start cursor = `opts.afterSeq ?? -1` (seq is 0-based dense, so `> -1` = from seq 0).
388
- *
389
- * Resolves to `null` (NOT an empty async-iterable) when the session does not exist — checked via `session_meta`
390
- * EAGERLY here (before returning the iterable), exactly like {@link exportEntries}, so a missing session is distinct
391
- * from a real-but-empty one (an empty stream). payload is parsed like {@link exportEntries} (string → JSON.parse,
392
- * object passthrough). The check is eager (the method is async) because a generator body is lazy — it can't decide
393
- * `null` vs. iterable on its own first pull.
394
- */
395
226
  async exportEntriesStream(sessionId, opts) {
396
227
  const [metaRows] = await this.pool.query("SELECT 1 FROM session_meta WHERE session_id = ?", [sessionId]);
397
228
  if (!metaRows[0])
398
- return null; // no such session (mirror exportEntries' null) — NOT an empty iterable
229
+ return null;
399
230
  const pool = this.pool;
400
231
  const batchSize = opts?.batchSize ?? 500;
401
232
  async function* gen() {
402
- let cursor = opts?.afterSeq ?? -1; // seq is 0-based dense → > -1 starts at seq 0
233
+ let cursor = opts?.afterSeq ?? -1;
403
234
  for (;;) {
404
- // ONE pooled query per batch (NOT a held connection across the whole stream) — keyset on (session_id, seq).
405
235
  const [rows] = await pool.query("SELECT seq, payload FROM session_event WHERE session_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?", [sessionId, cursor, batchSize]);
406
236
  for (const r of rows) {
407
237
  const p = r.payload;
408
238
  yield (typeof p === "string" ? JSON.parse(p) : p);
409
239
  }
410
240
  if (rows.length < batchSize)
411
- return; // short page = end of log (no further round-trip)
412
- cursor = Number(rows[rows.length - 1].seq); // advance the keyset cursor to the last seq seen
241
+ return;
242
+ cursor = Number(rows[rows.length - 1].seq);
413
243
  }
414
244
  }
415
245
  return gen();
416
246
  }
417
- /**
418
- * 2c session-sync — IMPORT a verbatim entry log (from another backend's {@link exportEntries}) into a
419
- * (typically NEW) `sessionId`. This is the WRITE half of {@link fork}, surfaced as `SessionRepo.importEntries`,
420
- * with two 2c-mandated differences from fork:
421
- * (a) OWNER RE-STAMP (§9): `owner` is the AUTHENTICATED importing principal passed by the caller; the bundle's
422
- * own owner is NEVER trusted (a local push is always owner=null → would become an ownerless cloud session
423
- * any anonymous caller could attach to). Mirrors the fork route's server-side `scope.gateOwner`.
424
- * (b) the single INVARIANT GATE is core's {@link validateEntriesForImport} (unique ids / parent-before-child /
425
- * exactly-one-root / leaf-resolvable / structural refs present) — we persist its VERBATIM output and do NOT
426
- * re-implement any of those checks in SQL ([[core-service-boundary]]; the same discipline fork's hand-SQL
427
- * was warned about). A violation throws `SessionError("invalid_session")` and NOTHING is written.
428
- * The write itself mirrors fork: re-seq dense from 0, entry_id/parent_id/payload/ts verbatim, in ONE txn with
429
- * `session_meta` LAST as the commit point (a mid-import crash leaves orphan events with no meta = invisible to
430
- * wake, never a half-session). The leaf is the replayed leaf (core's `leafIdAfterEntry`), matching core's import.
431
- */
432
247
  async importEntries(sessionId, owner, entries) {
433
- // Fail-CLOSED invariant gate FIRST (before any write) — returns the entries verbatim or throws invalid_session.
434
248
  const validated = validateEntriesForImport(entries);
435
249
  const conn = await this.pool.getConnection();
436
250
  try {
437
251
  await conn.beginTransaction();
438
- // The shared insert body (entries re-seq dense from 0, then session_meta LAST as the commit point). A plain
439
- // INSERT — assumes a FRESH sessionId; a duplicate (session already present) crashes on the PK. replaceEntries
440
- // is the idempotent-over-existing path (it DELETEs first inside the same txn).
441
252
  await this.insertValidatedLog(conn, sessionId, owner, validated);
442
253
  await conn.commit();
443
254
  }
@@ -446,50 +257,28 @@ export class TiDBSessionStore {
446
257
  await conn.rollback();
447
258
  }
448
259
  catch {
449
- /* ignore */
450
260
  }
451
261
  if (err instanceof SessionError)
452
- throw err; // invalid_session (gate) / conflict — pass through
262
+ throw err;
453
263
  throw new SessionError("storage", `import of session ${sessionId} failed`, err instanceof Error ? err : undefined);
454
264
  }
455
265
  finally {
456
266
  conn.release();
457
267
  }
458
268
  }
459
- /**
460
- * 2c session-sync (§7/§8) — IDEMPOTENT REPLACE: purge any existing conversation log for `sessionId` then re-import
461
- * `entries` verbatim under `owner`, ALL in ONE txn. Unlike {@link importEntries} (a plain INSERT that crashes on a
462
- * duplicate PK when the session already exists at the destination), replace is safe to run OVER an existing session
463
- * — the §7 fast-forward path and a §8 retry-to-completion both land on it. The validate gate runs FIRST (fail-closed,
464
- * before any write), then DELETE events + the OWNER-GUARDED meta delete (defense-in-depth, mirrors {@link deleteSession}
465
- * — a wrong-owner caller can never replace another tenant's session), then the SAME shared insert body importEntries
466
- * uses (so there is ONE source of the insert logic, no copy-paste drift). session_meta is still written LAST = commit
467
- * point, so a mid-replace crash (post-delete, pre-insert) leaves orphan-free state (no meta = invisible to wake).
468
- */
469
269
  async replaceEntries(sessionId, owner, entries) {
470
- // 🔴 #292:placed 会话不接受 session-sync 的整段换装。两条理由各自独立成立:①这条路径 DELETE 掉
471
- // meta 行再重插,会**重写掉** placement 元组(first-write 不可变的写侧破口);②即便元组保住,一条
472
- // 子代转录的内容被外部 push 覆盖,等于 agent 行还指着它、里面已经是别人的历史。⇒ 响亮拒。
473
- await this.assertNotPlaced(sessionId, "replace"); // 快路(零工:命中即拒,不进事务)
474
- const validated = validateEntriesForImport(entries); // fail-closed gate BEFORE any write (mirrors importEntries)
270
+ await this.assertNotPlaced(sessionId, "replace");
271
+ const validated = validateEntriesForImport(entries);
475
272
  const conn = await this.pool.getConnection();
476
273
  try {
477
274
  await conn.beginTransaction();
478
- // 🔴 #292(codex 对抗复审 R1-[high] 的同族窗,验真后修):上面那道快路是**事务外**的 ⇒ 与本事务
479
- // 之间有 TOCTOU 窗。真门在事务内:①锁读一次元组(存在的行被行锁住;MySQL 的 gap lock 还会挡住
480
- // 同键的并发 INSERT);②meta 的 DELETE 加 `placement_kind IS NULL` 硬条件 —— 即便判据被绕过,
481
- // 一条 placed 行也**不可能**被这条路径删掉(placed 行的 owner 恒 NULL,而 replace(owner=null)
482
- // 的属主门恰好匹配它 —— 没有这个条件,一次匿名 push 就能把子代转录整只换掉)。
483
275
  const [placedRows] = await conn.query("SELECT placement_kind FROM session_meta WHERE session_id = ? FOR UPDATE", [sessionId]);
484
276
  if (placedRows[0]?.placement_kind != null) {
485
277
  await conn.rollback();
486
278
  throw new SessionError("placement_refused", `session ${sessionId} belongs to the ${String(placedRows[0].placement_kind)} placement partition — a session-sync replace cannot overwrite a delegation transcript`);
487
279
  }
488
- // Purge the existing log first (idempotent: a missing session deletes 0 rows). Events first, then the
489
- // owner-guarded meta delete (`owner <=> ?` — a legacy null-owner session matches owner=null), defense-in-depth.
490
280
  await conn.query("DELETE FROM session_event WHERE session_id = ?", [sessionId]);
491
281
  await conn.query("DELETE FROM session_meta WHERE session_id = ? AND owner <=> ? AND placement_kind IS NULL", [sessionId, owner]);
492
- // …then re-insert the validated log (entries + meta-as-commit-point) via the SHARED body.
493
282
  await this.insertValidatedLog(conn, sessionId, owner, validated);
494
283
  await conn.commit();
495
284
  }
@@ -498,77 +287,41 @@ export class TiDBSessionStore {
498
287
  await conn.rollback();
499
288
  }
500
289
  catch {
501
- /* ignore */
502
290
  }
503
291
  if (err instanceof SessionError)
504
- throw err; // invalid_session (gate) / conflict — pass through
292
+ throw err;
505
293
  throw new SessionError("storage", `replace of session ${sessionId} failed`, err instanceof Error ? err : undefined);
506
294
  }
507
295
  finally {
508
296
  conn.release();
509
297
  }
510
298
  }
511
- /**
512
- * Shared insert half of {@link importEntries} / {@link replaceEntries} (ONE source of the insert logic, no drift):
513
- * write the validated entries re-seq dense from 0 (entry_id/parent_id/type/ts verbatim), THEN session_meta LAST as
514
- * the commit point. Runs inside the caller's open transaction (`conn`) so the caller owns begin/commit/rollback.
515
- * `validated` is the OUTPUT of {@link validateEntriesForImport} (the caller runs the gate before opening the txn).
516
- */
517
299
  async insertValidatedLog(conn, sessionId, owner, validated) {
518
- // The replayed leaf (last entry's leaf target, mirroring core resolveLeafId / validateEntriesForImport).
519
300
  let leafId = null;
520
301
  for (const e of validated)
521
- leafId = leafIdAfterEntry(e); // replayed leaf — core's exported helper (no service copy)
302
+ leafId = leafIdAfterEntry(e);
522
303
  const now = new Date();
523
- // Entries land BEFORE session_meta (mirrors fork / core BUG-1): the meta row is the commit point.
524
304
  for (let seq = 0; seq < validated.length; seq++) {
525
305
  const e = validated[seq];
526
- // payload = the verbatim entry blob (stringified for the JSON column bind); entry_id/parent_id/type/ts carried
527
- // over unchanged from the entry; only seq is re-densified. ts comes from the entry's own ISO timestamp.
528
306
  await conn.query("INSERT INTO session_event (session_id, seq, entry_id, parent_id, type, payload, ts) VALUES (?,?,?,?,?,?,?)", [sessionId, seq, e.id, e.parentId ?? null, e.type, JSON.stringify(e), new Date(e.timestamp)]);
529
307
  }
530
- // session_meta LAST = commit point. owner = the RE-STAMPED authenticated principal (never the bundle's).
531
308
  await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq) VALUES (?,?,?,?,?,?)", [sessionId, owner, now, now, leafId, validated.length]);
532
309
  }
533
- /**
534
- * 2c session-sync P1d-β (PUSH streaming, §15) — begin a STAGED import into `realSessionId` under the shadow id
535
- * `${realSessionId}#stg-${token}`. The route mints the uuidv7 `token` (the store never calls uuidv7/Date.now itself
536
- * → the staging id is deterministic + the route owns the lease key). Returns a {@link TiDBStagingHandle} the route
537
- * streams entry batches into ({@link StagingHandle.appendBatch}) then commits ({@link StagingHandle.commit}, the
538
- * atomic swap) or aborts. No row is touched here — the handle is a stateless cursor over this pool.
539
- */
540
310
  beginImportStaging(realSessionId, token) {
541
311
  return new TiDBStagingHandle(this.pool, realSessionId, stagingIdFor(realSessionId, token));
542
312
  }
543
- /** #292:「这个 id 是 placed ⇒ 该写路径不适用」的共用拒绝(session-sync 的两条换装腿共用)。 */
544
313
  async assertNotPlaced(sessionId, what) {
545
314
  const placed = await this.placementOf(sessionId);
546
315
  if (placed !== undefined) {
547
316
  throw new SessionError("placement_refused", `session ${sessionId} belongs to the ${placed.kind} placement partition — a session-sync ${what} cannot overwrite a delegation transcript`);
548
317
  }
549
318
  }
550
- /**
551
- * 2c session-sync P1d-β (staged-row inspection) — read the rows STAGED under `stagingId` (NO session_meta probe — a
552
- * staging id has no meta row by construction, so {@link exportEntries} would return null), oldest-first. The Phase-B
553
- * import gate is now the per-line {@link StreamingImportValidator} (core 1.143.0) run during the stream, so this
554
- * is no longer the validation path; it remains the staged-row inspection seam (tests/diagnostics). Returns `[]` for an
555
- * empty/unknown staging id.
556
- */
557
319
  async readStagedEntries(stagingId) {
558
320
  const [rows] = await this.pool.query("SELECT payload FROM session_event WHERE session_id = ? ORDER BY seq ASC", [stagingId]);
559
321
  return rows.map((r) => (typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload));
560
322
  }
561
- /**
562
- * 2c session-sync P1d-β (§4) — reaper for ABANDONED staging sessions: DELETE session_event rows under a staging id
563
- * (`session_id LIKE '%#stg-%'`) that has NO session_meta (never committed → invisible orphans) AND whose OLDEST
564
- * staged row is older than the grace window (an IN-FLIGHT stream keeps appending fresh `ts` rows → its MIN(ts) is
565
- * recent → it is NOT reaped, exactly like sweepOrphanBlobs' created_at grace). Per-staging-id, so a slow but live
566
- * stream is never reaped while another abandoned one is. Returns the affected row count. Driven by main.ts's reaper.
567
- */
568
323
  async sweepStagingSessions() {
569
324
  const cutoff = new Date(Date.now() - STAGING_GC_GRACE_MS);
570
- // Candidate staging ids = those with rows older than the cutoff AND no meta row. GROUP BY so an in-flight id (its
571
- // MIN(ts) recent) is excluded; LEFT JOIN session_meta so a (defensively) committed staging id is never touched.
572
325
  const [cands] = await this.pool.query("SELECT se.session_id FROM session_event se LEFT JOIN session_meta sm ON sm.session_id = se.session_id " +
573
326
  "WHERE se.session_id LIKE ? AND sm.session_id IS NULL " +
574
327
  "GROUP BY se.session_id HAVING MIN(se.ts) < ?", [`%${STAGING_ID_MARKER}%`, cutoff]);
@@ -579,28 +332,11 @@ export class TiDBSessionStore {
579
332
  }
580
333
  return removed;
581
334
  }
582
- /**
583
- * E21 — purge a session's CONVERSATION HISTORY (session_meta + session_event). Idempotent: a missing
584
- * session is a no-op (returns false). The service's OWN run-ledger / checkpoint / tool-result rows are
585
- * purged separately by the route's `purgeSession` coordinator — this method owns only the session
586
- * abstraction's tables (the `SessionRepo.delete` half).
587
- *
588
- * `owner` is the single-DB-fleet null-safe SQL owner guard on `session_meta.owner` (defense-in-depth — the
589
- * route already owner-gates; `<=>` so a legacy null-owner session matches owner=null). session_event carries
590
- * no owner column, so its purge is GATED on the owner-matched meta delete: if the meta DELETE matched 0 rows
591
- * (wrong owner / already gone) we skip the event delete — so a wrong-owner caller can never strand-delete
592
- * another tenant's events. Meta is deleted FIRST (mirrors core BUG-2: once it's gone, wake is `null`
593
- * regardless of leftover events; if a crash interleaves, orphan events with NO meta are invisible, never a
594
- * half-session), and the event purge only runs after that owner-matched commit point.
595
- */
596
335
  async deleteSession(sessionId, owner) {
597
336
  const conn = await this.pool.getConnection();
598
337
  try {
599
338
  await conn.beginTransaction();
600
- // Meta row FIRST + owner-guarded: once it's gone, wake is `null` regardless of leftover events.
601
339
  const [metaRes] = await conn.query("DELETE FROM session_meta WHERE session_id = ? AND owner <=> ?", [sessionId, owner]);
602
- // Only purge events when the OWNER-matched meta row was removed — gates the un-owner-columned event table
603
- // through the meta owner check (a wrong-owner / already-gone session removes 0 meta rows → skip the events).
604
340
  if (metaRes.affectedRows > 0)
605
341
  await conn.query("DELETE FROM session_event WHERE session_id = ?", [sessionId]);
606
342
  await conn.commit();
@@ -611,7 +347,6 @@ export class TiDBSessionStore {
611
347
  await conn.rollback();
612
348
  }
613
349
  catch {
614
- /* ignore */
615
350
  }
616
351
  throw err;
617
352
  }
@@ -620,53 +355,30 @@ export class TiDBSessionStore {
620
355
  }
621
356
  }
622
357
  get size() {
623
- // Best-effort: durable store reloads per acquire (no long-lived cache), so report in-flight only.
624
358
  return this.pending.size;
625
359
  }
626
360
  dispose() {
627
- // The pool is owned by main.ts and closed there; nothing process-local to release.
628
361
  this.pending.clear();
629
362
  }
630
363
  }
631
- /**
632
- * 2c session-sync P1d-β (PUSH streaming, §15 + §4) — a TiDB {@link StagingHandle}: stream entry batches into a SHADOW
633
- * `session_event` partition (the staging id, NO meta row → invisible to wake), then atomically SWAP them into the real
634
- * session id. Server memory is bounded by the in-flight batch (one txn per batch), not the whole log. The swap is a
635
- * row-key RENAME (`UPDATE session_event SET session_id`) — metadata-only, NO payload re-copy — so the commit cost is
636
- * the rename + a DELETE of the (purged) dst rows, in ONE txn, with session_meta written LAST = the sole commit point.
637
- */
638
364
  class TiDBStagingHandle {
639
365
  pool;
640
366
  realSessionId;
641
367
  stagingId;
642
- /** Running DENSE seq counter for the staging rows. Advanced ONLY for a logically-NEW entry id (tracked in `seen`), so
643
- * a re-streamed entry (Phase-B resume) is skipped and the staged seqs stay dense 0..N-1 (NO holes that would collide
644
- * with a future F2 leaf_seq append on the committed session). */
645
368
  nextSeq = 0;
646
- /** Entry ids already staged by THIS handle — a resume re-streams from the start, so dedup keeps seqs dense + idempotent. */
647
369
  seen = new Set();
648
370
  constructor(pool, realSessionId, stagingId) {
649
371
  this.pool = pool;
650
372
  this.realSessionId = realSessionId;
651
373
  this.stagingId = stagingId;
652
374
  }
653
- /** Append a batch to the staging id, re-seq'd DENSE from the running counter (skipping ids already staged), IDEMPOTENTLY
654
- * (ON DUPLICATE KEY UPDATE on the `(session_id, entry_id)` uk is the durable backstop too). One txn per batch
655
- * (bounded). NO meta row is written (the staging id stays invisible). ts is the entry's own ISO timestamp (verbatim). */
656
375
  async appendBatch(entries) {
657
- // Build the NEW rows (skip already-staged ids — resume dedup keeps seqs dense), then ONE multi-row INSERT for the
658
- // whole batch (bounded → bounded packet/txn) instead of N round-trips. ON DUPLICATE KEY UPDATE is the durable
659
- // backstop for the rare case a dup slips past `seen` (e.g. a concurrent re-stream).
660
376
  const rows = [];
661
377
  const tuples = [];
662
378
  for (const e of entries) {
663
379
  if (this.seen.has(e.id))
664
380
  continue;
665
381
  tuples.push("(?,?,?,?,?,?,?)");
666
- // 对抗复查 A-1(CRITICAL):staging 行的 ts 必须是「行插入时间」——写源 entry 的历史 timestamp 会让
667
- // reaper 的 MIN(ts)<cutoff 判据把「正在导入的历史会话」当成弃置 staging 删掉(导入 1 小时前的会话
668
- // =导入中被 reap)。真时间源恒在 payload(e.timestamp);ts 列的唯一读消费=fork 的行拷贝(carry-over
669
- // 一致)与 reaper 活性判据,写 now 零语义破坏。
670
382
  rows.push(this.stagingId, this.nextSeq, e.id, e.parentId ?? null, e.type, JSON.stringify(e), new Date());
671
383
  this.seen.add(e.id);
672
384
  this.nextSeq++;
@@ -681,46 +393,24 @@ class TiDBStagingHandle {
681
393
  throw err instanceof SessionError ? err : new SessionError("storage", `staged append for ${this.realSessionId} failed`, err instanceof Error ? err : undefined);
682
394
  }
683
395
  }
684
- /**
685
- * The ATOMIC swap (ONE txn), §15 commit:
686
- * 1. IN-COMMIT RE-CLASSIFY (closes the Phase-A plan→commit TOCTOU): read the dst's CURRENT entry ids + the staged
687
- * ids under `FOR UPDATE` (row-locks serialize a concurrent commit/run) → classifySyncRelationshipByIds. A flip
688
- * to fork/stale WITHOUT `resolution:"overwrite-dst"` → ROLLBACK → throw SyncConflictError (route → 409).
689
- * 2. DELETE the dst's existing session_event rows (the replaceEntries purge).
690
- * 3. UPDATE session_event SET session_id = real WHERE session_id = staging (the row-key RENAME — metadata only).
691
- * 4. INSERT/UPSERT session_meta(real, owner, leaf_id=replayedLeaf, leaf_seq=N) LAST = the sole commit point.
692
- * A crash before COMMIT: the DELETE+rename roll back (dst untouched) and the staging rows keep no meta = invisible
693
- * orphans (reaped by sweepStagingSessions). `owner` is the re-stamped importing principal (§9).
694
- */
695
396
  async commit(owner, opts) {
696
397
  const conn = await this.pool.getConnection();
697
398
  try {
698
399
  await conn.beginTransaction();
699
- // (1) in-commit re-classify — read BOTH logs' ids oldest-first under FOR UPDATE (serialize against a racing
700
- // commit/append to the dst). The staged ids ARE the source (already dense 0..N-1 under the staging id).
701
400
  const [stagedRows] = await conn.query("SELECT entry_id FROM session_event WHERE session_id = ? ORDER BY seq ASC FOR UPDATE", [this.stagingId]);
702
401
  const [dstRows] = await conn.query("SELECT entry_id FROM session_event WHERE session_id = ? ORDER BY seq ASC FOR UPDATE", [this.realSessionId]);
703
- // `placement_kind` 捎带在既有的存在性探针上(#292:placed 目的地拒绝整段换装,理由与
704
- // `replaceEntries` 的同款注逐字同——两条腿是同一件事的两种写法)。这里**刻意不加 `FOR UPDATE`**:
705
- // 它对**缺席行**在 PG 上什么都锁不到(窗关不上),在 MySQL 上又会 gap-lock 住整段键区间(把一次
706
- // 本可以合法先到的 placement 创建堵成锁等待)——两边都不划算。真门在提交前的那次权威复读(见下)。
707
402
  const [dstMeta] = await conn.query("SELECT placement_kind FROM session_meta WHERE session_id = ?", [this.realSessionId]);
708
403
  if (dstMeta[0]?.placement_kind != null) {
709
404
  await conn.rollback();
710
405
  throw new SessionError("placement_refused", `session ${this.realSessionId} belongs to the ${String(dstMeta[0].placement_kind)} placement partition — a session-sync staged commit cannot overwrite a delegation transcript`);
711
406
  }
712
407
  const srcIds = stagedRows.map((r) => String(r.entry_id));
713
- const dstIds = dstMeta[0] ? dstRows.map((r) => String(r.entry_id)) : null; // no dst meta ⇒ fresh (no dst log to relate to)
408
+ const dstIds = dstMeta[0] ? dstRows.map((r) => String(r.entry_id)) : null;
714
409
  const rel = classifySyncRelationshipByIds(srcIds, dstIds);
715
410
  if ((rel.relation === "fork" || rel.relation === "stale") && opts?.resolution !== "overwrite-dst") {
716
411
  await conn.rollback();
717
412
  throw new SyncConflictError(this.realSessionId, rel);
718
413
  }
719
- // (1b) 🔴 [2557] A5/F2(2026-08-05):fast_forward 的**载荷**判据 —— dst ⊆ src 按 id,但共享 id 的
720
- // 内容可能已分叉(目的端纠正过某条、源端拿旧版+追加);整段换装会静默销毁目的端版本,§7 在载荷层
721
- // 同样要求 409(除非显式 overwrite-dst)。共享段载荷只在这条臂上读(commit 本就是 O(total) 行操作
722
- // ——DELETE dst + rename staged——所以这次成比例的载荷读不改变量级);行已被上面的 FOR UPDATE 锁住,
723
- // 同 txn 重读载荷是一致读。判据与 fork 铸形见 session-sync-content.ts。
724
414
  if (rel.relation === "fast_forward" && opts?.resolution !== "overwrite-dst" && dstIds !== null && dstIds.length > 0) {
725
415
  const [dstFull] = await conn.query("SELECT payload FROM session_event WHERE session_id = ? ORDER BY seq ASC", [this.realSessionId]);
726
416
  const [stagedFull] = await conn.query("SELECT entry_id, payload FROM session_event WHERE session_id = ? ORDER BY seq ASC", [this.stagingId]);
@@ -734,13 +424,8 @@ class TiDBStagingHandle {
734
424
  throw new SyncConflictError(this.realSessionId, contentForkRelation(diverged, rel.newEntryIds));
735
425
  }
736
426
  }
737
- // (2) purge the dst's existing log (the replaceEntries purge; idempotent — fresh deletes 0).
738
427
  await conn.query("DELETE FROM session_event WHERE session_id = ?", [this.realSessionId]);
739
- // (3) RENAME the staged rows onto the real session id (row-key UPDATE — metadata only, NO payload re-copy). seq is
740
- // already dense 0..N-1 under the staging id (appendBatch re-seq'd it), so no re-densify is needed on rename.
741
428
  await conn.query("UPDATE session_event SET session_id = ? WHERE session_id = ?", [this.realSessionId, this.stagingId]);
742
- // (4) session_meta LAST = the sole commit point. The replayed leaf = the last staged entry's leaf target — read
743
- // the last staged payload (now renamed) to compute leafIdAfterEntry (core's helper; no service re-statement).
744
429
  const [lastRow] = await conn.query("SELECT payload FROM session_event WHERE session_id = ? ORDER BY seq DESC LIMIT 1", [this.realSessionId]);
745
430
  let leafId = null;
746
431
  if (lastRow[0]) {
@@ -751,12 +436,6 @@ class TiDBStagingHandle {
751
436
  const now = new Date();
752
437
  await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq) VALUES (?,?,?,?,?,?) " +
753
438
  "ON DUPLICATE KEY UPDATE owner = VALUES(owner), updated_at = VALUES(updated_at), leaf_id = VALUES(leaf_id), leaf_seq = VALUES(leaf_seq)", [this.realSessionId, owner, now, now, leafId, srcIds.length]);
754
- // 🔴 #292(codex 对抗复审 R1-[high],验真后修)—— **提交前的最后一道门**:上面那次 upsert 之后,
755
- // 这一行**一定**被本事务锁住(不管它是我们刚插的、还是并发方先插我们随后 UPDATE 的),于是这次
756
- // 复读是**权威**的。病:目标行在(1)的探针时**还不存在** ⇒ 探针无行可锁(PG 的 FOR UPDATE 对
757
- // 缺席行不锁任何东西),另一事务可以在此期间把同 id 铸成 placed 转录;没有这道复读,本事务会把
758
- // 它的事件整段换掉、并把 owner/leaf 改写成 push 方的 —— agent 行还指着那条会话,里面已经是别人
759
- // 的历史。命中即整事务回滚(事件的 DELETE/rename 一并撤销),响亮 `placement_refused`。
760
439
  const [afterMeta] = await conn.query("SELECT placement_kind FROM session_meta WHERE session_id = ?", [this.realSessionId]);
761
440
  if (afterMeta[0]?.placement_kind != null) {
762
441
  await conn.rollback();
@@ -769,7 +448,7 @@ class TiDBStagingHandle {
769
448
  try {
770
449
  await conn.rollback();
771
450
  }
772
- catch { /* ignore */ }
451
+ catch { }
773
452
  if (err instanceof SyncConflictError || err instanceof SessionError)
774
453
  throw err;
775
454
  throw new SessionError("storage", `staged commit for ${this.realSessionId} failed`, err instanceof Error ? err : undefined);
@@ -778,7 +457,6 @@ class TiDBStagingHandle {
778
457
  conn.release();
779
458
  }
780
459
  }
781
- /** Drop the staging rows (Phase B aborted before commit). Idempotent (a missing staging id deletes 0). */
782
460
  async abort() {
783
461
  await this.pool.query("DELETE FROM session_event WHERE session_id = ?", [this.stagingId]);
784
462
  }