@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,43 +1,11 @@
1
- /**
2
- * LOCAL session store — the DB-less twin of {@link TiDBSessionStore} (clay 2026-06-25 seamless local↔cloud). Wraps a
3
- * core `SessionRepo` (in-memory `InMemorySessionRepo` for v1; `FileSessionRepo` for the file-backed follow-on — both
4
- * expose create/open/list/delete/fork) and projects it into the service's `SessionStore` + `OwnerAwareSessionStore`
5
- * shape, so a locally-run HTTP service serves the SAME session contract a cloud worker does: acquire (the run path) +
6
- * the §0.5 ownership ops (ownerOf/register/fork/deleteSession).
7
- *
8
- * Single-user by construction: a local file/in-memory deployment has no multi-tenant owner column (core's
9
- * SessionMetadata is `{id, createdAt}`), so REQUIRE_PRINCIPAL must be OFF (main.ts enforces this) and owner is tracked
10
- * here only as the service-side scope (null/"local").
11
- *
12
- * P0.5 variant-2 (clay 2026-06-26): `listSessions` IS implemented — it enumerates the repo's persisted sessions
13
- * (`repo.list()`) and synthesizes a {@link SessionSummary} per id from the SESSION abstraction (no run ledger), so
14
- * GET /v1/sessions (server.ts: `sessionStorage.listSessions ?? runStore.listSessions`) takes the FIRST branch and the
15
- * resume picker is restart-visible when the repo is the file-backed `FileSessionRepo`. Run-ledger-only fields
16
- * (`runCount`/`lastStatus`) are honestly DEGRADED (the session abstraction has no runs); preview is synthesized from
17
- * the last user-text entry. The in-memory `InMemorySessionRepo` works too (process-local; lost on restart) — the
18
- * persistence comes from the repo, not this wrapper.
19
- */
20
1
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
21
2
  import { join, resolve } from "node:path";
22
- // 🔴 生产级兼容性 bug 撞获(2026-07-25,sema-desktop PoC 全新 npm install 复现):`Session` 在这个文件里
23
- // 只当类型用(见下方 `as Session` 断言),此前却写成值导入——本地锁定 core@1.401.0 时 `Session` 恰好还是
24
- // runtime 导出,掩盖了这个问题;但 core 1.403+(黑板 [1628]/[1634])把 `Session` 降级成纯类型、改用
25
- // `StoredSession` 作 runtime class。任何新装 `@sema-agent/server` 在这行 import 会直接
26
- // `SyntaxError: does not provide an export named 'Session'` 崩溃启动。改成 `import type` 消除。
27
- // ✅ 尾声(design/158 F-5 核账,2026-07-28):上文历史版本提到的「三处 runtime `new Session(...)`
28
- // 待迁移 + 依赖收紧 >=1.401.0 <1.403.0」**均已完成**——全仓 `new Session(` 现为零命中(三处都已迁
29
- // `StoredSession`),依赖范围现为 `^1.444.0`。此注释保留前半段是因为「值导入 vs 类型导入被本地锁定
30
- // 版本掩盖」这个缺陷机制仍值得后人读;宣称"待办"的部分已按实况改写(注释宣称≠代码实做是本仓
31
- // 已立案的病族,过期待办比没有更有害)。
32
3
  import { SessionError, validateEntriesForImport } from "@sema-agent/core";
33
4
  import { contentForkRelation, fastForwardSharedContentDiverged, identicalIdsAlsoIdenticalContent } from "../session-sync-content.js";
34
5
  import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
35
6
  import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, } from "../session-sync-kernel.js";
36
7
  const ownerEq = (a, b) => (a ?? null) === (b ?? null);
37
8
  const isNotFound = (e) => e instanceof SessionError && e.code === "not_found";
38
- /** Extract the plain text of a USER message entry (the objective preview source) — `null` for any other entry
39
- * type or a non-user message, so {@link LocalSessionStore.previewOf} walks past tool/assistant turns to the last
40
- * thing the human asked. content may be a bare string or a TextContent[] (join the text parts). */
41
9
  function userText(entry) {
42
10
  if (entry.type !== "message")
43
11
  return null;
@@ -57,93 +25,35 @@ function userText(entry) {
57
25
  }
58
26
  return null;
59
27
  }
60
- /** 🔴 [1684] MODULE-level, keyed by the RESOLVED sidecar path: the authority is the file, so every store instance
61
- * pointing at that file must share one view + one lock. Keyed by `resolve()`d path so `./x` and `x` collapse too. */
62
28
  const TITLE_STATE_BY_PATH = new Map();
63
29
  export class LocalSessionStore {
64
30
  repo;
65
- /** #270 车1:托管留存声明 —— **诚实的 none**。本店的行没有任何按期删除的腿(留存 lane 的三方法只实现在
66
- * SQL 聚合店上),所以 locked policy + 本店 ⇒ core 的 `assertRetentionCapability` 拒启,这是契约的
67
- * **正确行为**不是缺口(要留存治理先上 DB 后端;设计稿 §1「不做清单」逐字)。缺席也会被读成 none,
68
- * 显式写出来是文档义务:沉默表达不出「我核对过我的介质就是删不了」。 */
69
31
  retention = UNMANAGED_RETENTION;
70
- /** In-flight acquisitions keyed by id, so concurrent acquire(sameId) in one process share one (mirrors TiDBSessionStore). */
71
32
  pending = new Map();
72
- /** Service-side owner scope per session (core's repo has no owner column). Registered-ownerless ⇒ null; absent ⇒ undefined. */
73
33
  owners = new Map();
74
- /** Last-activity marker (touch) for ordering — the keyset sort key for {@link listSessions}. PERSISTED to a JSON
75
- * sidecar under {@link dataDir} (when given) so the /resume picker's newest-first order SURVIVES a service restart:
76
- * without persistence the map starts EMPTY on boot and every session degrades to `createdAt` ordering (the picker
77
- * shows the wrong "most recent" — TOC-review #10). In-memory only (no sidecar) when `dataDir` is absent (the
78
- * process-local `InMemorySessionRepo` case, where the sessions themselves are lost on restart anyway). */
79
34
  lastActivity = new Map();
80
- /** K-5c (core 1.155 SessionStore.noteTaskRun seam): the latest task run per session, recorded by core's Runner at
81
- * runTask START (so a running/suspended run surfaces too) → projected as `lastRunId` on listSessions. The cloud
82
- * TiDB/PG twins derive it from `task_run`; the local backend has no runs ledger, so this is an in-memory map.
83
- * ⚠️ IN-MEMORY BEST-EFFORT (honest degrade, NOT sidecar-persisted — review Q6): it covers the PRIMARY /resume use
84
- * case (re-attach to a RUNNING run — recorded at the run's start, the process can't have restarted while that run
85
- * is still live), and is LOST on a process restart. That's acceptable: a session whose last run predates a restart
86
- * has only TERMINAL runs (no live tail to re-attach), so `lastRunId` falls back to null and the shell uses its
87
- * no-anchor path — same posture as the other local degrades here (runCount:0, lastStatus:""). Persisting it would
88
- * need a sidecar-format change (the #10 clobber area); deferred until a real local-file-backed need. */
89
35
  lastTaskRun = new Map();
90
- /** 2c P1d-β — live staged-import handles keyed by stagingId, so a local staging's in-memory buffer is readable via
91
- * readStagedEntries (the durable twins re-read the staged session_event rows; local has no such rows). */
92
36
  stagings = new Map();
93
- /** Lazy one-shot load of the persisted lastActivity sidecar (memoized; only the first reader pays the read). */
94
37
  loaded;
95
- /** A pending debounced sidecar flush (coalesces a burst of touch() into one write). undefined ⇒ no flush queued. */
96
38
  flushTimer;
97
- /** Serializes sidecar writes (bug B): the in-flight write promise. A flush() while one is running CHAINS after it
98
- * (re-checking the dirty flag) so an older snapshot can never rename after a newer one (lost-update). */
99
39
  writeChain = Promise.resolve();
100
- /** Set by touch/delete when the map mutated; cleared by a write that captured the change. A write that ran while this
101
- * is set re-runs (a touch that landed during an active write isn't dropped). Drives the chain's re-run guard (bug B). */
102
40
  dirty = false;
103
- /** Monotonic per-write counter — feeds a UNIQUE tmp filename per write so overlapping writes can't share one tmp path
104
- * and tear each other's bytes (bug B). */
105
41
  writeSeq = 0;
106
- /** Last time a sidecar-write error was warned (bug D): throttle the best-effort warn so a persistently-failing disk
107
- * doesn't spam the log on every debounced flush. 0 ⇒ never warned. */
108
42
  lastWarnAt = 0;
109
- /** Absolute path to the lastActivity JSON sidecar, or undefined when not persisting (in-memory-only deployment). */
110
43
  sidecarPath;
111
- /** Auto-title map + its own LIGHT sidecar (`session-titles.json`). Separate file from the
112
- * lastActivity sidecar ON PURPOSE (that format is a #10 clobber area); titles are write-once + ultra-low-rate
113
- * (one per session, ever), so a plain read-modify-write with tmp+rename per write is enough — no debounce.
114
- *
115
- * 🔴 CROSS-INSTANCE (fixed 2026-07-25, blackboard [1684] — core's "per-instance state" defect class):
116
- * these three used to be INSTANCE fields, which is exactly the shape core described — «the backend is shared
117
- * more widely than the object, but the state and the lock hang off the object». Two `LocalSessionStore`s over
118
- * the SAME dataDir each held their own `titles` map and their own write chain, so a real concurrency probe
119
- * (`test/file-stores-cross-instance-concurrency.test.ts`) showed **BOTH winning** the write-once IS-NULL gate
120
- * (`wins=2`): each checked its own map, both saw absent, both returned `true`, and the second whole-map
121
- * snapshot silently clobbered the first title. That is core's A-class (CAS double-win) and B-class
122
- * (lost update) stacked. Fix per core's prescription: state + lock live at MODULE level, keyed by the resolved
123
- * sidecar path, so any number of instances over one dataDir collapse to a single authority.
124
- * Sharing key = the resolved path; NO sidecar (in-memory deployment) ⇒ no shared backend exists ⇒ that case
125
- * keeps genuinely per-instance state (see {@link titleState}). */
126
44
  titleSidecarPath;
127
- /** Per-instance fallback used ONLY when there is no sidecar path (nothing is shared, so nothing to collapse). */
128
45
  ownTitleState = { titles: new Map(), writeChain: Promise.resolve() };
129
- /** @param repo the core SessionRepo this store projects into the §0.5 SessionStore shape.
130
- * @param dataDir the SAME data dir the file-backed repo persists under (LocalBackend passes `fileBackend.root`).
131
- * When given, lastActivity is persisted to `<dataDir>/session-last-activity.json` so it survives a
132
- * restart (the file-backed deployment); omit it for the in-memory deployment (nothing survives). */
133
46
  constructor(repo, dataDir) {
134
47
  this.repo = repo;
135
48
  this.sidecarPath = dataDir ? join(dataDir, "session-last-activity.json") : undefined;
136
- // [1684] resolve() 规范化后当共享键:`./x` 与 `x` 指同一文件,必须塌缩到同一权威。
137
49
  this.titleSidecarPath = dataDir ? resolve(join(dataDir, "session-titles.json")) : undefined;
138
50
  if (this.titleSidecarPath && !TITLE_STATE_BY_PATH.has(this.titleSidecarPath)) {
139
51
  TITLE_STATE_BY_PATH.set(this.titleSidecarPath, { titles: new Map(), writeChain: Promise.resolve() });
140
52
  }
141
53
  }
142
- /** 该实例应当使用的 title 状态:有 sidecar ⇒ 按路径共享的那份;无 sidecar ⇒ 自己那份(无共享后端)。 */
143
54
  get titleState() {
144
55
  return this.titleSidecarPath ? TITLE_STATE_BY_PATH.get(this.titleSidecarPath) : this.ownTitleState;
145
56
  }
146
- /** Lazy one-shot hydrate of the persisted titles (mirrors ensureLoaded's posture: missing file silent, torn warn). */
147
57
  async ensureTitlesLoaded() {
148
58
  if (!this.titleSidecarPath)
149
59
  return;
@@ -155,7 +65,7 @@ export class LocalSessionStore {
155
65
  const parsed = JSON.parse(await readFile(path, "utf8"));
156
66
  for (const [id, t] of Object.entries(parsed)) {
157
67
  if (typeof t === "string" && !st.titles.has(id))
158
- st.titles.set(id, t); // live writes win over disk
68
+ st.titles.set(id, t);
159
69
  }
160
70
  }
161
71
  catch (e) {
@@ -166,8 +76,6 @@ export class LocalSessionStore {
166
76
  }
167
77
  return st.loaded;
168
78
  }
169
- /** Chained snapshot write of the titles sidecar (the chain guarantees the LAST rename carries the
170
- * NEWEST map state: each link re-snapshots the SHARED map at write time, so an overtaken writer is harmless). */
171
79
  persistTitles() {
172
80
  if (!this.titleSidecarPath)
173
81
  return Promise.resolve();
@@ -185,15 +93,10 @@ export class LocalSessionStore {
185
93
  });
186
94
  return st.writeChain;
187
95
  }
188
- /** Cheap pre-LLM probe (local twin). The map write needs no session_meta row, so there is
189
- * no "none" (register-lag) dimension here — two states only. */
190
96
  async probeTitle(sessionId) {
191
97
  await this.ensureTitlesLoaded();
192
98
  return this.titleState.titles.has(sessionId) ? "titled" : "untitled";
193
99
  }
194
- /** Write-once auto-title (the local twin of the SQL setTitleIfNull). Persists the whole
195
- * (small) map via a SERIALIZED tmp+rename snapshot; a write failure keeps the in-memory title (degrades to
196
- * re-title after restart). */
197
100
  async setTitleIfNull(sessionId, title) {
198
101
  await this.ensureTitlesLoaded();
199
102
  const st = this.titleState;
@@ -203,9 +106,6 @@ export class LocalSessionStore {
203
106
  await this.persistTitles();
204
107
  return true;
205
108
  }
206
- /** Best-effort, one-shot hydrate of the persisted lastActivity map (no-op when not persisting / no sidecar yet). A
207
- * read/parse error degrades to an empty map (createdAt ordering) rather than failing the list — the picker still
208
- * works, just without restart-persisted recency. Memoized so concurrent listSessions share one read. */
209
109
  async ensureLoaded() {
210
110
  if (!this.sidecarPath)
211
111
  return;
@@ -216,14 +116,11 @@ export class LocalSessionStore {
216
116
  const raw = await readFile(path, "utf8");
217
117
  const parsed = JSON.parse(raw);
218
118
  for (const [id, ts] of Object.entries(parsed)) {
219
- // Don't clobber an in-process touch() that landed before the lazy load ran (live writes win over disk).
220
119
  if (typeof ts === "string" && !this.lastActivity.has(id))
221
120
  this.lastActivity.set(id, ts);
222
121
  }
223
122
  }
224
123
  catch (e) {
225
- // missing sidecar (first boot) is EXPECTED — silent; a torn file / permission error degrades to an empty map
226
- // (createdAt ordering) but is WARNED (throttled, bug D) so disk-full/permission isn't hidden. Never throws.
227
124
  if (e?.code !== "ENOENT")
228
125
  this.warnSidecar("read", e);
229
126
  }
@@ -231,59 +128,42 @@ export class LocalSessionStore {
231
128
  }
232
129
  return this.loaded;
233
130
  }
234
- /** Queue a debounced, best-effort atomic write of the lastActivity map to the sidecar (coalesces touch bursts). The
235
- * write is fire-and-forget: a persistence failure must NEVER break the run path (touch is on the hot acquire path),
236
- * so errors are swallowed — the worst case is a restart falling back to createdAt ordering for un-flushed touches. */
237
131
  scheduleFlush() {
238
- this.dirty = true; // a mutation landed — even mid-write, so the active write re-runs (bug B: don't drop it)
132
+ this.dirty = true;
239
133
  if (!this.sidecarPath || this.flushTimer)
240
134
  return;
241
135
  this.flushTimer = setTimeout(() => {
242
136
  this.flushTimer = undefined;
243
137
  void this.flush();
244
138
  }, 250);
245
- this.flushTimer.unref?.(); // don't keep the process alive solely for a pending flush
246
- }
247
- /** Atomic best-effort write of the lastActivity map. SERIALIZED via {@link writeChain} (bug B): overlapping flushes
248
- * chain instead of racing, so an older snapshot can never rename after a newer one (lost-update), and a unique tmp
249
- * name per write means two writes can't tear each other's bytes. Returns the chain so callers (dispose) can await the
250
- * final write (bug C). Each link HYDRATES the disk sidecar first (bug A) and writes the UNION of disk + in-process —
251
- * the `has(id)` live-wins guard in ensureLoaded() keeps in-process touches authoritative — so a fresh-boot flush
252
- * before any list never CLOBBERS the prior boot's recency for not-yet-touched sessions. */
139
+ this.flushTimer.unref?.();
140
+ }
253
141
  flush() {
254
142
  if (!this.sidecarPath)
255
143
  return Promise.resolve();
256
144
  const next = this.writeChain.then(() => this.doFlush());
257
- this.writeChain = next.catch(() => { }); // a failed write must not poison the chain (next flush still runs)
145
+ this.writeChain = next.catch(() => { });
258
146
  return next;
259
147
  }
260
- /** One serialized sidecar write: hydrate-then-merge (bug A), snapshot, atomic tmp+rename with a UNIQUE tmp name (bug
261
- * B). Clears {@link dirty} for the change it captured; re-runs once if a touch landed mid-write so it's not dropped. */
262
148
  async doFlush() {
263
149
  const path = this.sidecarPath;
264
150
  if (!path)
265
151
  return;
266
- // bug A: merge the on-disk sidecar BEFORE snapshotting so we never overwrite another boot's recency with an empty
267
- // (or partial) in-process map. ensureLoaded()'s `!has(id)` guard keeps live in-process touches authoritative.
268
152
  await this.ensureLoaded();
269
- this.dirty = false; // capture: a touch AFTER this point flips it back on → we re-run below
153
+ this.dirty = false;
270
154
  const snapshot = Object.fromEntries(this.lastActivity);
271
155
  try {
272
156
  await mkdir(join(path, ".."), { recursive: true });
273
- // bug B: unique tmp per write (pid + monotonic counter) — overlapping/successive writes can't share one tmp path.
274
157
  const tmp = `${path}.${process.pid}.${++this.writeSeq}.tmp`;
275
158
  await writeFile(tmp, JSON.stringify(snapshot), "utf8");
276
- await rename(tmp, path); // atomic swap (POSIX rename) — readers see the whole file or the prior one
159
+ await rename(tmp, path);
277
160
  }
278
161
  catch (e) {
279
- this.warnSidecar("write", e); // bug D: surface disk-full/permission (throttled) instead of silent swallow
280
- // best-effort: a persistence failure degrades restart-recency, never breaks the run path.
162
+ this.warnSidecar("write", e);
281
163
  }
282
164
  if (this.dirty)
283
- await this.doFlush(); // a touch landed during the write — re-run so it isn't lost (bug B)
165
+ await this.doFlush();
284
166
  }
285
- /** Throttled best-effort warn for a sidecar I/O failure (bug D): never throws, at most once per ~minute so a
286
- * persistently-failing disk doesn't spam the log on every debounced flush. */
287
167
  warnSidecar(op, e) {
288
168
  const now = Date.now();
289
169
  if (now - this.lastWarnAt < 60_000)
@@ -298,9 +178,6 @@ export class LocalSessionStore {
298
178
  this.owners.set(id, null);
299
179
  return { session: session, sessionId: id };
300
180
  }
301
- // design/114 Phase3 (F1): a `requireExisting` acquire NEVER creates and can REJECT (not_found) — it MUST bypass
302
- // the shared create-dedup `pending`, else a concurrent create-capable waiter awaiting that same promise would
303
- // inherit the not_found rejection instead of creating-on-miss. (Twin of TiDBSessionStore.acquire.)
304
181
  if (opts?.requireExisting)
305
182
  return this.load(sessionId, true);
306
183
  const inflight = this.pending.get(sessionId);
@@ -311,8 +188,6 @@ export class LocalSessionStore {
311
188
  return p;
312
189
  }
313
190
  async load(sessionId, requireExisting = false) {
314
- // open() throws SessionError("not_found") for an unknown id — create it ownerless then (a caller-chosen brand-new
315
- // id the service didn't pre-register; only the caller who chose it can reach it, and it holds no prior data).
316
191
  try {
317
192
  const session = await this.repo.open({ id: sessionId, createdAt: "" });
318
193
  if (!this.owners.has(sessionId))
@@ -322,28 +197,17 @@ export class LocalSessionStore {
322
197
  catch (e) {
323
198
  if (!isNotFound(e))
324
199
  throw e;
325
- // design/114 Phase3: `requireExisting` ⇒ FAIL LOUD on a genuinely missing session, never the silent
326
- // create-on-miss. Re-throw the store's own `not_found` SessionError; core maps it to `resume.session_not_found`.
327
200
  if (requireExisting)
328
201
  throw e;
329
202
  const session = await this.repo.create({ id: sessionId });
330
- // GUARD: mirror the open branch — NEVER clobber an owner a prior register() recorded
331
- // (the TiDB twin's load() re-registers ownerless via an idempotent no-op upsert). Without the guard, a
332
- // register(id, principal)-then-acquire(id) [the §0.5 auth flow] would reset owner→null on first acquire →
333
- // ownership gate goes silent → cross-principal attach. (register() now also creates the repo session, so this
334
- // catch only fires for a genuinely-unregistered id — but guard it defensively regardless.)
335
203
  if (!this.owners.has(sessionId))
336
204
  this.owners.set(sessionId, null);
337
205
  return { session: session, sessionId };
338
206
  }
339
207
  }
340
- /** Idempotently record a session's owner — never overwrites an existing owner (mirrors TiDBSessionStore.register).
341
- * TiDB parity: register INSERTs a session_meta row so the session EXISTS (forkable) even before its
342
- * first acquire/run — so we also ensure an EMPTY repo session exists (open if present → never overwrite history;
343
- * create if not), else `ownerOf` would say a registered-but-never-acquired session exists while `fork` 404s it. */
344
208
  async register(sessionId, owner) {
345
209
  if (this.owners.has(sessionId))
346
- return; // idempotent — never touch an already-known session's owner
210
+ return;
347
211
  this.owners.set(sessionId, owner);
348
212
  try {
349
213
  await this.repo.open({ id: sessionId, createdAt: "" });
@@ -355,13 +219,6 @@ export class LocalSessionStore {
355
219
  throw e;
356
220
  }
357
221
  }
358
- /** Owner of a session: `null` if registered ownerless, `undefined` if no such session (mirrors TiDBSessionStore.ownerOf).
359
- *
360
- * S3 修(clay 裁 2026-07-26:**local=单用户,多租户走云** ⇒ 跨重启不承诺 owner 隔离):`owners` 是纯内存
361
- * Map,重启后失忆——曾把「失忆」答成 `undefined`(=「无此会话」),让 DELETE /v1/sessions 的 route 门短路成
362
- * already-gone、checkpoint 的 EXISTS 镜像门 return 0 ⇒ **E21 对盘上老会话整体 no-op 且无人知道**。
363
- * 现在 Map miss 时探 repo:盘上在 ⇒ 按 load() 两处懒回填早已写下的同一判据回填 `null`(registered
364
- * ownerless);repo 真没有才 `undefined`。SQL 孪生无此形(owner 在 session_meta 行,重启无损)。 */
365
222
  async ownerOf(sessionId) {
366
223
  if (this.owners.has(sessionId))
367
224
  return this.owners.get(sessionId) ?? null;
@@ -376,30 +233,23 @@ export class LocalSessionStore {
376
233
  throw e;
377
234
  }
378
235
  }
379
- /** [1196] 六轮复审:owner+leafId 原子快照——单线程 JS 内两同步/顺序读之间无 await 打断 owner 判定
380
- * (owner 先取快照,leaf 读后 owner 复核,变了=undefined 视作删除中,调用方按删处理)。 */
381
236
  async getHead(sessionId) {
382
- // S3 同根:Map miss ≠ 无此会话 —— 经 ownerOf 的 repo 探在腿判定(盘上在 ⇒ 回填 ownerless 后继续)。
383
237
  if (!this.owners.has(sessionId) && (await this.ownerOf(sessionId)) === undefined)
384
238
  return undefined;
385
239
  const owner = this.owners.get(sessionId) ?? null;
386
240
  const leafId = await this.getLeafId(sessionId);
387
241
  if (!this.owners.has(sessionId) || (this.owners.get(sessionId) ?? null) !== owner)
388
- return undefined; // 读间被删/换主
242
+ return undefined;
389
243
  return { owner, leafId };
390
244
  }
391
- /** 2c P1d-β — re-stamp the service-side owner scope WITHOUT rewriting entries (the staged-commit `identical` path:
392
- * the log is already present & equal, so only the owner needs re-stamping to the importing principal, §9). */
393
245
  restampOwner(sessionId, owner) {
394
246
  if (this.owners.has(sessionId))
395
247
  this.owners.set(sessionId, owner);
396
248
  }
397
249
  async touch(sessionId) {
398
250
  this.lastActivity.set(sessionId, new Date().toISOString());
399
- this.scheduleFlush(); // persist the recency marker so the /resume picker's order survives a restart (TOC #10)
251
+ this.scheduleFlush();
400
252
  }
401
- /** E18 — the session's current leaf SessionTreeEntry.id (@see TiDBSessionStore.getLeafId). Reads the repo's storage
402
- * leaf (no acquire-lock; InMemory/File repo reads don't lock). null when the session doesn't exist / has no leaf. */
403
253
  async getLeafId(sessionId) {
404
254
  try {
405
255
  const session = await this.repo.open({ id: sessionId, createdAt: "" });
@@ -411,40 +261,18 @@ export class LocalSessionStore {
411
261
  throw e;
412
262
  }
413
263
  }
414
- /** Durable history is retained by the repo; just drop the in-flight cache entry (mirrors TiDBSessionStore.release). */
415
264
  async release(sessionId) {
416
265
  this.pending.delete(sessionId);
417
266
  }
418
- /**
419
- * P0.5 variant-2 (E16, clay 2026-06-26) — enumerate the caller's sessions (CC /resume picker) from the SESSION
420
- * abstraction (`repo.list()`), keyset-paginated newest-first by last activity. Session IDENTITY comes from the
421
- * repo (one entry per persisted session); a {@link SessionSummary} is SYNTHESIZED per id so the wire shape is
422
- * byte-identical to {@link TiDBSessionStore.listSessions} (the run picker doesn't drift):
423
- * - `owner` — the service-side owner map (process-local; null when unknown/ownerless). Owner FILTERING
424
- * is best-effort here (the in-memory map IS the source of truth; a single-user local
425
- * deployment runs REQUIRE_PRINCIPAL=off so the principal path passes owner=null and every
426
- * null-owner session lists). The route already owner-gates at the HTTP layer.
427
- * - `firstActivityAt` — the session's `createdAt` (repo metadata).
428
- * - `lastActivityAt` — the touch marker (falls back to `createdAt`); the keyset sort key.
429
- * - `objectivePreview`— the last USER-text entry, synthesized from the session tree (no run ledger).
430
- * - `runCount`/`lastStatus` — DEGRADED (0 / ""): the session abstraction has no runs ledger; the TiDB twin
431
- * LEFT-JOINs `task_run` for these, which the local backend (MemoryRunStore, separate
432
- * ledger) intentionally does NOT correlate here (variant-2 is session-abstraction-only).
433
- *
434
- * Keyset matches the contract: STRICTLY older than the cursor, ties broken by id DESC; `limit` is honored after
435
- * the in-memory sort. Cheap (a local single-user store holds few sessions). Errors reading one session's entries
436
- * degrade that row's preview to null rather than failing the whole list.
437
- */
438
267
  async listSessions(opts) {
439
- await this.ensureLoaded(); // hydrate persisted lastActivity BEFORE reading it — else a restart loses recency order (TOC #10)
268
+ await this.ensureLoaded();
440
269
  await this.ensureTitlesLoaded();
441
270
  const metas = await this.repo.list();
442
- // Build a summary per persisted session, owner from the process-local map (degrade to null when unknown).
443
271
  const all = [];
444
272
  for (const meta of metas) {
445
273
  const owner = this.owners.has(meta.id) ? (this.owners.get(meta.id) ?? null) : null;
446
274
  if (opts.owner !== undefined && owner !== opts.owner && !(opts.includeUnowned === true && owner === null))
447
- continue; // owner-scope (best-effort, process-local map) + [4198] 窄臂第三条件
275
+ continue;
448
276
  const firstActivityAt = meta.createdAt || new Date(0).toISOString();
449
277
  const lastActivityAt = this.lastActivity.get(meta.id) ?? firstActivityAt;
450
278
  all.push({
@@ -452,43 +280,31 @@ export class LocalSessionStore {
452
280
  owner,
453
281
  lastActivityAt,
454
282
  firstActivityAt,
455
- runCount: 0, // session abstraction has no runs ledger (honest degrade; TiDB twin LEFT-JOINs task_run)
283
+ runCount: 0,
456
284
  objectivePreview: await this.previewOf(meta),
457
- lastStatus: "", // no run status without the runs ledger (honest degrade — matches TiDB's null→"" mapping)
458
- lastRunId: this.lastTaskRun.get(meta.id) ?? null, // K-5c: latest run id from the noteTaskRun seam (null if none)
459
- title: this.titleState.titles.get(meta.id) ?? null, // auto-title
285
+ lastStatus: "",
286
+ lastRunId: this.lastTaskRun.get(meta.id) ?? null,
287
+ title: this.titleState.titles.get(meta.id) ?? null,
460
288
  });
461
289
  }
462
- // `?q=` — the local twin matches the synthesized objectivePreview (the last user text; the
463
- // SQL twins match ANY run's preview via the runs ledger — each backend searches what it durably has).
464
- // Filter BEFORE the sort/keyset slice so pagination stays correct.
465
290
  const qlc = opts.q?.toLowerCase();
466
291
  const filtered = qlc ? all.filter((r) => r.objectivePreview?.toLowerCase().includes(qlc)) : all;
467
- // newest-first by (lastActivityAt, sessionId) — same ORDER BY the SQL twin emits.
468
292
  filtered.sort((a, b) => a.lastActivityAt !== b.lastActivityAt
469
293
  ? (a.lastActivityAt < b.lastActivityAt ? 1 : -1)
470
294
  : (a.sessionId < b.sessionId ? 1 : -1));
471
- // keyset: keep rows STRICTLY older than the cursor (ties by id DESC), then honor limit (mirrors the SQL WHERE).
472
295
  const c = opts.cursor;
473
296
  const after = c
474
297
  ? filtered.filter((r) => r.lastActivityAt < c.lastActivityAt || (r.lastActivityAt === c.lastActivityAt && r.sessionId < c.sessionId))
475
298
  : filtered;
476
299
  return after.slice(0, opts.limit);
477
300
  }
478
- /** K-5c (core 1.155 SessionStore seam): core's Runner calls this at runTask START with the run's taskId, so the
479
- * latest run surfaces as `lastRunId` on listSessions even while it is still running/suspended (the local backend
480
- * has no runs ledger to derive it from, unlike the TiDB/PG twins). In-memory, last-write-wins per session. */
481
301
  noteTaskRun(sessionId, taskId) {
482
302
  this.lastTaskRun.set(sessionId, taskId);
483
303
  }
484
- /** Synthesize a session's objective preview = the most-recent USER text message (the objective the run last saw).
485
- * Reads the session's entries through the repo; any read error degrades to null (never fails the list). */
486
304
  async previewOf(meta) {
487
305
  try {
488
306
  const session = await this.repo.open({ id: meta.id, createdAt: meta.createdAt });
489
307
  const entries = await session.getEntries();
490
- // Walk newest-first; the most recent USER message is the objective (mirrors task_run.objective_preview). A
491
- // session with no user turn yet (e.g. registered-but-never-run) → null preview (honest empty, like the picker).
492
308
  for (let i = entries.length - 1; i >= 0; i--) {
493
309
  const text = userText(entries[i]);
494
310
  if (text)
@@ -497,12 +313,9 @@ export class LocalSessionStore {
497
313
  return null;
498
314
  }
499
315
  catch {
500
- // best-effort: a missing/torn/unreadable session lists without a preview rather than failing the whole picker.
501
316
  return null;
502
317
  }
503
318
  }
504
- /** E17 — fork a session's WHOLE history to a NEW id owned by `owner` (core SessionRepo.fork). Returns the new id,
505
- * or null if the source does not exist (the route maps that to 404). */
506
319
  async fork(sourceId, owner) {
507
320
  try {
508
321
  const session = await this.repo.fork({ id: sourceId, createdAt: "" }, {});
@@ -516,104 +329,60 @@ export class LocalSessionStore {
516
329
  throw e;
517
330
  }
518
331
  }
519
- /** 2c session-sync — EXPORT a session's full durable entry log (core `SessionRepo.exportEntries`; the
520
- * in-memory/file repo holds the whole log so there is no floor to bypass). null if the source is unknown / the
521
- * underlying repo lacks the optional seam. The owner is NOT exported — import re-stamps it. */
522
332
  async exportEntries(sessionId) {
523
333
  if (!this.repo.exportEntries)
524
- return null; // a repo without the optional seam can't export (older core)
334
+ return null;
525
335
  try {
526
336
  return await this.repo.exportEntries(sessionId);
527
337
  }
528
338
  catch (e) {
529
339
  if (isNotFound(e))
530
- return null; // unknown source ⇒ null (mirrors fork's 404 semantics)
340
+ return null;
531
341
  throw e;
532
342
  }
533
343
  }
534
- /** 2c session-sync P1d-α (PULL streaming) — the IDS-ONLY projection of the full durable log (@see
535
- * TiDBSessionStore.listEntryIds). The local repo holds the whole log in memory/file so there is no floor to bypass;
536
- * ids are the `exportEntries` ids in order. null if the source is unknown / the repo lacks the export seam. */
537
344
  async listEntryIds(sessionId) {
538
345
  const entries = await this.exportEntries(sessionId);
539
346
  return entries === null ? null : entries.map((e) => e.id);
540
347
  }
541
- /** 2c session-sync P1d-α (PULL streaming) — STREAM the full durable log as an async generator (@see
542
- * TiDBSessionStore.exportEntriesStream). The LOCAL twin has no cross-instance buffering hazard (an in-memory map /
543
- * single-process file repo), so it reads the whole log via the repo's `exportEntries` then yields it lazily, SLICED
544
- * after `opts.afterSeq` (the LOCAL backend's native seq = the 0-based dense ARRAY INDEX of the entry in the
545
- * oldest-first log; afterSeq=k → entries at index > k = `slice(k+1)`). afterSeq is a BACKEND-NATIVE seq cursor (a
546
- * resume is always same-backend), so the local 0-based-index base needn't match the durable backends' `seq` column
547
- * base. Resolves to null (NOT an empty iterable) for an unknown session — mirrors {@link exportEntries}.
548
- * `opts.batchSize` is accepted for API parity but has no effect locally (the whole log is already in hand). */
549
348
  async exportEntriesStream(sessionId, opts) {
550
349
  const entries = await this.exportEntries(sessionId);
551
350
  if (entries === null)
552
- return null; // unknown session — NOT an empty iterable
553
- const afterSeq = opts?.afterSeq ?? -1; // 0-based dense → > -1 = from index 0
554
- const slice = entries.slice(afterSeq + 1); // afterSeq=k yields entries k+1..end (seq = array index for the local repo)
351
+ return null;
352
+ const afterSeq = opts?.afterSeq ?? -1;
353
+ const slice = entries.slice(afterSeq + 1);
555
354
  async function* gen() {
556
355
  for (const e of slice)
557
356
  yield e;
558
357
  }
559
358
  return gen();
560
359
  }
561
- /** 2c session-sync — IMPORT a verbatim entry log into `sessionId` via core `SessionRepo.importEntries`
562
- * (which runs the `validateEntriesForImport` fail-closed gate before writing). `owner` is the authenticated
563
- * importing principal (re-stamped into the service-side owner map; core's local repo has no owner column, §9).
564
- * `owner=null` (single-user local) is passed as `undefined` to the core seam (its owner param is `string | undefined`). */
565
360
  async importEntries(sessionId, owner, entries) {
566
361
  if (!this.repo.importEntries)
567
362
  throw new SessionError("storage", "session import is not supported by this local backend");
568
- await this.repo.importEntries(sessionId, owner ?? undefined, entries); // core gate runs inside; throws invalid_session on violation
569
- this.owners.set(sessionId, owner); // re-stamp the service-side owner scope to the importing principal
570
- }
571
- /** 2c session-sync (§7/§8) — IDEMPOTENT REPLACE. The local twin's replace is DELETE-then-IMPORT: core's
572
- * InMemorySessionRepo / FileSessionRepo expose no single replace primitive, but the local backend is single-writer
573
- * with NO compaction floor / cross-instance race (a process-local in-memory map / a single-process file repo), so
574
- * deleteSession-then-importEntries is effectively atomic for the local case (no concurrent reader can observe the
575
- * gap, unlike the durable cross-instance backends which do it in ONE SQL txn). Both halves are best-effort feature-
576
- * gated: a repo lacking either seam throws the same clear error importEntries does (no silent half-write). */
363
+ await this.repo.importEntries(sessionId, owner ?? undefined, entries);
364
+ this.owners.set(sessionId, owner);
365
+ }
577
366
  async replaceEntries(sessionId, owner, entries) {
578
367
  if (!this.repo.importEntries)
579
368
  throw new SessionError("storage", "session import is not supported by this local backend");
580
369
  if (!this.repo.delete)
581
370
  throw new SessionError("storage", "session replace is not supported by this local backend (no delete seam)");
582
- // VALIDATE-FIRST (before the delete): the durable twins run validateEntriesForImport INSIDE a txn that ROLLs BACK on
583
- // a bad import, so a failed import can't destroy the existing session. The local twin is delete-then-import (no
584
- // single-writer txn), so an invalid bundle — the COMMON failure — must be rejected BEFORE deleteSession purges the
585
- // prior session; otherwise a bad import would purge-then-fail and the existing session would be gone. (A post-delete
586
- // fs I/O failure on importEntries remains a residual single-writer risk the durable twins don't have — noted.)
587
371
  validateEntriesForImport(entries);
588
- // We purge first so the subsequent import can't hit a duplicate-id collision over an existing session. delete on an
589
- // absent session is a no-op (deleteSession swallows not_found below), so replace over a fresh session is just an
590
- // import. importEntries re-runs the (now-known-valid) core gate before writing.
591
- await this.deleteSession(sessionId, owner); // owner-guarded purge (defense-in-depth, single-user: owner=null matches)
592
- await this.importEntries(sessionId, owner, entries); // core gate runs inside + re-stamps the owner scope
593
- }
594
- /** 2c session-sync P1d-β (PUSH streaming) — begin a STAGED import. The LOCAL backend is single-writer with no
595
- * cross-instance / cross-replica concurrency story (the durable twins carry that), so the staging handle simply
596
- * ACCUMULATES the streamed batches in memory then, on commit, runs the SAME validate-first → replaceEntries the
597
- * whole-bundle local import uses (effectively atomic locally — no concurrent reader can observe the gap). Keep it
598
- * simple per §1: the durable twins are where the SHADOW-id swap + bounded-memory + concurrency matter. */
372
+ await this.deleteSession(sessionId, owner);
373
+ await this.importEntries(sessionId, owner, entries);
374
+ }
599
375
  beginImportStaging(realSessionId, token) {
600
376
  const h = new LocalStagingHandle(this, realSessionId, stagingIdFor(realSessionId, token));
601
377
  this.stagings.set(h.stagingId, h);
602
378
  return h;
603
379
  }
604
- /** 2c P1d-β (staged-row inspection) — the in-memory staged entries for a local staging id. No longer the import gate
605
- * (the per-line StreamingImportValidator validates during the Phase-B stream); a tests/diagnostics seam. [] for an
606
- * unknown/finished staging id (mirrors the durable twins' empty-set return). */
607
380
  async readStagedEntries(stagingId) {
608
381
  return this.stagings.get(stagingId)?.snapshotEntries() ?? [];
609
382
  }
610
- /** Drop a finished/aborted local staging handle from the registry (called by the handle on commit/abort). */
611
383
  forgetStaging(stagingId) {
612
384
  this.stagings.delete(stagingId);
613
385
  }
614
- /** E21 — purge a session's conversation history (core SessionRepo.delete), owner-guarded (single-user: owner=null
615
- * matches). Idempotent: a missing/wrong-owner session is a no-op returning false. The runs-ledger / checkpoint /
616
- * tool-result rows are purged separately by the route's purgeSession coordinator. */
617
386
  async deleteSession(sessionId, owner) {
618
387
  const known = this.owners.has(sessionId);
619
388
  if (!known || !ownerEq(this.owners.get(sessionId) ?? null, owner))
@@ -627,50 +396,30 @@ export class LocalSessionStore {
627
396
  }
628
397
  this.owners.delete(sessionId);
629
398
  this.lastActivity.delete(sessionId);
630
- this.lastTaskRun.delete(sessionId); // K-5c (review): drop the run anchor too — else the map grows on delete churn
631
- // and a re-acquire(sameId) would surface the PRIOR incarnation's lastRunId (stale anchor).
632
- // Drop the auto-title too (E21 parity with the SQL twins' session_meta row delete) and
633
- // persist the removal through the SAME serialized chain (an in-flight grant snapshot must not rename
634
- // AFTER this removal and resurrect it — the chain re-snapshots the map, so the delete always wins).
635
- // 🔴 hydrate FIRST (workflow audit 2026-07-13): after a restart the map starts empty — an un-hydrated
636
- // delete() returns false and the sidecar keeps the title (an E21 right-to-delete residue that would
637
- // RESURRECT onto a same-id re-import). ensureTitlesLoaded is memoized — first caller pays, rest no-op.
399
+ this.lastTaskRun.delete(sessionId);
638
400
  await this.ensureTitlesLoaded();
639
401
  if (this.titleState.titles.delete(sessionId))
640
402
  await this.persistTitles();
641
- this.scheduleFlush(); // persist the removal so a deleted session doesn't linger in the sidecar across a restart
403
+ this.scheduleFlush();
642
404
  return true;
643
405
  }
644
406
  get size() {
645
407
  return this.pending.size;
646
408
  }
647
- /** Graceful shutdown: AWAIT the final sidecar write (bug C) so a restart doesn't lose the last debounced-but-unwritten
648
- * touch. Cancels any queued debounce, then awaits a flush (which chains after any in-flight write and merges disk). */
649
409
  async dispose() {
650
410
  this.pending.clear();
651
411
  if (this.flushTimer) {
652
412
  clearTimeout(this.flushTimer);
653
413
  this.flushTimer = undefined;
654
414
  }
655
- // Always do a final flush when persisting (a pending debounce OR an in-flight write may hold the last touch); when
656
- // not persisting it's a cheap no-op. AWAIT it (and the chain) so close() can't release the data-dir lock first.
657
415
  if (this.sidecarPath)
658
416
  await this.flush();
659
417
  }
660
418
  }
661
- /**
662
- * 2c session-sync P1d-β — the LOCAL {@link StagingHandle}. Unlike the durable twins (which stage into a shadow
663
- * session_event partition + atomically rename it), the local backend is single-writer with no cross-instance race, so
664
- * this handle ACCUMULATES the streamed batches in memory (idempotent on entry id — a re-streamed batch is deduped) and,
665
- * on commit, runs the SAME validate-first → replaceEntries the whole-bundle local import uses (the existing
666
- * delete-then-import that is effectively atomic for the single-process local case). It still re-classifies AT COMMIT
667
- * (against the dst's current ids) so an unconsented fork/stale is refused, matching the durable twins' contract.
668
- */
669
419
  class LocalStagingHandle {
670
420
  store;
671
421
  realSessionId;
672
422
  stagingId;
673
- /** Accumulated staged entries, deduped by entry id (resume re-streams from the start → dedup keeps it idempotent). */
674
423
  staged = [];
675
424
  seen = new Set();
676
425
  constructor(store, realSessionId, stagingId) {
@@ -681,47 +430,36 @@ class LocalStagingHandle {
681
430
  async appendBatch(entries) {
682
431
  for (const e of entries) {
683
432
  if (this.seen.has(e.id))
684
- continue; // idempotent (resume re-stream) — dedup by entry id
433
+ continue;
685
434
  this.seen.add(e.id);
686
435
  this.staged.push(e);
687
436
  }
688
437
  }
689
- /** The staged entries, oldest-first (readStagedEntries reads these). A snapshot copy (callers don't mutate). */
690
438
  snapshotEntries() {
691
439
  return [...this.staged];
692
440
  }
693
- /** Commit = in-commit re-classify (dst current ids vs staged) → refuse an unconsented fork/stale → validate + the
694
- * idempotent replaceEntries (validate-first-then-delete-then-import, effectively atomic locally). */
695
441
  async commit(owner, opts) {
696
442
  const dstEntries = await this.store.exportEntries(this.realSessionId);
697
443
  const rel = classifySyncRelationshipByIds(this.staged.map((e) => e.id), dstEntries === null ? null : dstEntries.map((e) => e.id));
698
444
  if ((rel.relation === "fork" || rel.relation === "stale") && opts?.resolution !== "overwrite-dst") {
699
445
  throw new SyncConflictError(this.realSessionId, rel);
700
446
  }
701
- // 🔴 [2557] A5/F2(2026-08-05):fast_forward 的**载荷**盲区 —— dst ⊆ src 按 id,但共享 id 的
702
- // 内容可能已分叉(目的端纠正过某条、源端拿旧版+追加)。整段换装会静默销毁目的端的纠正,
703
- // §7 在载荷层同样要求 409(除非显式 overwrite-dst)。判据与铸形见 session-sync-content.ts。
704
447
  if (rel.relation === "fast_forward" && opts?.resolution !== "overwrite-dst" && dstEntries !== null && dstEntries.length > 0) {
705
448
  const dstSet = new Set(dstEntries.map((e) => e.id));
706
449
  const diverged = fastForwardSharedContentDiverged(this.staged.filter((e) => dstSet.has(e.id)), dstEntries);
707
450
  if (diverged)
708
451
  throw new SyncConflictError(this.realSessionId, contentForkRelation(diverged, rel.newEntryIds));
709
452
  }
710
- // 🔴 2026-07-26:`identical` 不再等于"内容相同"。id 是 uuidv7、不是内容寻址 ⇒ 同 id 异载荷完全可能,
711
- // 而 commit 这里**手上同时有两份日志**,所以内容比较不需要调用方配合(见 `session-sync-content.ts` 顶注:
712
- // 我在黑板 [1701] 说过"server 单方面修不了",那句只对 Phase A 成立,对这一半是错的)。
713
- // 判据不符 ⇒ **照常改写**(不抛错、不 409 —— core 定的口径是"不符只意味着多干一趟活")。
714
453
  const contentEqual = rel.relation === "identical" && identicalIdsAlsoIdenticalContent(this.staged, dstEntries);
715
454
  if (rel.relation !== "identical" || !contentEqual) {
716
- await this.store.replaceEntries(this.realSessionId, owner, this.staged); // validate-first + delete-then-import (atomic locally)
455
+ await this.store.replaceEntries(this.realSessionId, owner, this.staged);
717
456
  }
718
457
  else {
719
- this.store.restampOwner(this.realSessionId, owner); // 内容也相同 ⇒ 只重戳 owner(此时跳过写入是对的)
458
+ this.store.restampOwner(this.realSessionId, owner);
720
459
  }
721
460
  this.store.forgetStaging(this.stagingId);
722
461
  return { relation: rel.relation };
723
462
  }
724
- /** Abort = drop the in-memory staged buffer (no durable rows were ever written locally) + forget the handle. */
725
463
  async abort() {
726
464
  this.staged.length = 0;
727
465
  this.seen.clear();