@sema-agent/server 7.136.0 → 7.138.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 (327) hide show
  1. package/MIGRATION.md +501 -139
  2. package/README.md +1 -0
  3. package/README.zh-CN.md +1 -0
  4. package/USAGE.md +149 -34
  5. package/deploy/sema-up/README.md +14 -0
  6. package/deploy/sema-up/chart/templates/sandbox-rbac.yaml +7 -1
  7. package/deploy/sema-up/stack/sema-stack-cluster.yaml +7 -1
  8. package/dist/approval-card.d.ts +21 -14
  9. package/dist/approval-card.js +1 -0
  10. package/dist/approval-content-kind.d.ts +6 -0
  11. package/dist/approval-content-kind.js +7 -0
  12. package/dist/approval-gate-currency.d.ts +48 -6
  13. package/dist/approval-gate-currency.js +16 -9
  14. package/dist/approval-hmac.d.ts +13 -1
  15. package/dist/approval-hmac.js +1 -1
  16. package/dist/approval.js +3 -3
  17. package/dist/audit.js +1 -0
  18. package/dist/auth-bridge.js +2 -2
  19. package/dist/background-agent-name.d.ts +5 -0
  20. package/dist/background-agent-name.js +5 -0
  21. package/dist/boot/budget-tracing.d.ts +1 -12
  22. package/dist/boot/deployment-mods.d.ts +46 -16
  23. package/dist/boot/deployment-mods.js +82 -6
  24. package/dist/boot/execution-env.d.ts +24 -1
  25. package/dist/boot/execution-env.js +69 -6
  26. package/dist/boot/governance-seams.d.ts +10 -17
  27. package/dist/boot/governance-seams.js +5 -17
  28. package/dist/boot/leader.d.ts +3 -0
  29. package/dist/boot/leader.js +1 -0
  30. package/dist/boot/org-delivery.js +15 -2
  31. package/dist/boot/reapers.d.ts +3 -0
  32. package/dist/boot/reapers.js +15 -1
  33. package/dist/boot/resolve-spec.d.ts +7 -0
  34. package/dist/boot/resolve-spec.js +36 -26
  35. package/dist/boot/retention-lane.d.ts +11 -5
  36. package/dist/boot/retention-lane.js +2 -2
  37. package/dist/boot/runner-deps.d.ts +7 -3
  38. package/dist/boot/runner-deps.js +1 -0
  39. package/dist/boot/stage-03-store-backend.d.ts +1 -1
  40. package/dist/boot/stage-04-stores.d.ts +2 -13
  41. package/dist/boot/stage-05-execution-env.d.ts +6 -13
  42. package/dist/boot/stage-05-execution-env.js +17 -5
  43. package/dist/boot/stage-06-runners.d.ts +6 -13
  44. package/dist/boot/stage-06-runners.js +10 -3
  45. package/dist/boot/stage-07-capability-layer.d.ts +7 -13
  46. package/dist/boot/stage-07-capability-layer.js +3 -3
  47. package/dist/boot/stage-08-reapers.d.ts +7 -13
  48. package/dist/boot/stage-08-reapers.js +2 -1
  49. package/dist/boot/stage-09-leader.d.ts +7 -13
  50. package/dist/boot/stage-09-leader.js +2 -2
  51. package/dist/boot/stage-10-http-server.d.ts +7 -13
  52. package/dist/boot/stage-10-http-server.js +6 -3
  53. package/dist/boot/stores.d.ts +1 -1
  54. package/dist/boot/web-admin.d.ts +5 -2
  55. package/dist/boot/web-admin.js +3 -3
  56. package/dist/brain.js +5 -5
  57. package/dist/broker/broker.d.ts +89 -0
  58. package/dist/broker/broker.js +287 -0
  59. package/dist/broker/constants.d.ts +39 -0
  60. package/dist/broker/constants.js +15 -0
  61. package/dist/broker/http1.d.ts +111 -0
  62. package/dist/broker/http1.js +256 -0
  63. package/dist/broker/lease-book.d.ts +87 -0
  64. package/dist/broker/lease-book.js +142 -0
  65. package/dist/broker/lease-file.d.ts +60 -0
  66. package/dist/broker/lease-file.js +127 -0
  67. package/dist/broker/main.d.ts +2 -0
  68. package/dist/broker/main.js +20 -0
  69. package/dist/budget.d.ts +17 -15
  70. package/dist/budget.js +2 -2
  71. package/dist/capabilities/composite-members.d.ts +2 -1
  72. package/dist/capabilities/composite-members.js +2 -1
  73. package/dist/capabilities/engine-capabilities.js +1 -0
  74. package/dist/capabilities/mcp-probe.d.ts +36 -11
  75. package/dist/capabilities/mcp-probe.js +44 -5
  76. package/dist/capabilities/scenarios.d.ts +23 -8
  77. package/dist/capabilities/scenarios.js +13 -8
  78. package/dist/capabilities/skills.d.ts +28 -5
  79. package/dist/capabilities/skills.js +8 -0
  80. package/dist/capability-facts.d.ts +6 -0
  81. package/dist/capability-facts.js +5 -0
  82. package/dist/center-credential-renewer.js +5 -1
  83. package/dist/checkpoint-ctx-seal-m28.d.ts +3 -10
  84. package/dist/checkpoint-ctx-seal-m28.js +1 -1
  85. package/dist/checkpoint-identity.d.ts +13 -6
  86. package/dist/config-catalog.js +16 -7
  87. package/dist/config-types.d.ts +24 -7
  88. package/dist/config.d.ts +1 -1
  89. package/dist/config.js +42 -13
  90. package/dist/deployment-governance.d.ts +2 -2
  91. package/dist/deployment-governance.js +7 -9
  92. package/dist/deployment-shape.d.ts +6 -6
  93. package/dist/device-ws-protocol.d.ts +1 -0
  94. package/dist/device-ws-protocol.js +2 -1
  95. package/dist/file-history-disclosure.d.ts +4 -2
  96. package/dist/file-history-disclosure.js +2 -0
  97. package/dist/gate-settlement.d.ts +71 -0
  98. package/dist/gate-settlement.js +40 -0
  99. package/dist/hooks/classic-stations.d.ts +12 -54
  100. package/dist/hooks/classic-stations.js +1 -0
  101. package/dist/hooks/command-spawn.d.ts +2 -2
  102. package/dist/hooks/hook-runner.js +14 -14
  103. package/dist/http/admission.js +14 -9
  104. package/dist/http/approval-sweeps.js +4 -3
  105. package/dist/http/caller-trust.d.ts +2 -1
  106. package/dist/http/caller-trust.js +1 -3
  107. package/dist/http/checkpoint-ctx.d.ts +48 -7
  108. package/dist/http/checkpoint-ctx.js +40 -0
  109. package/dist/http/dispatch.d.ts +4 -0
  110. package/dist/http/dispatch.js +18 -10
  111. package/dist/http/durable-run-leg.d.ts +5 -1
  112. package/dist/http/durable-run-leg.js +5 -3
  113. package/dist/http/health-self-report.d.ts +2 -2
  114. package/dist/http/memory-compliance-failure-reply.js +3 -0
  115. package/dist/http/principal-gate.d.ts +15 -10
  116. package/dist/http/principal-gate.js +48 -9
  117. package/dist/http/resume-legs.d.ts +7 -31
  118. package/dist/http/resume-legs.js +185 -126
  119. package/dist/http/route-ctx.d.ts +38 -53
  120. package/dist/http/route-ctx.js +2 -5
  121. package/dist/http/routes/a2a-serve.js +10 -4
  122. package/dist/http/routes/agents-roster.d.ts +1 -1
  123. package/dist/http/routes/agents-roster.js +5 -1
  124. package/dist/http/routes/approvals-assistant.js +24 -10
  125. package/dist/http/routes/capabilities.js +23 -11
  126. package/dist/http/routes/mcp-reconnect.d.ts +3 -2
  127. package/dist/http/routes/mcp-reconnect.js +7 -7
  128. package/dist/http/routes/memory-consolidation.d.ts +1 -1
  129. package/dist/http/routes/memory-origin.d.ts +1 -1
  130. package/dist/http/routes/rules.js +3 -3
  131. package/dist/http/routes/runs.js +13 -8
  132. package/dist/http/routes/session-sync.js +42 -6
  133. package/dist/http/routes/sessions.js +6 -3
  134. package/dist/http/routes/side-query.js +2 -3
  135. package/dist/http/routes/tasks.js +794 -780
  136. package/dist/http/server.d.ts +35 -25
  137. package/dist/http/stored-body-replay.d.ts +3 -3
  138. package/dist/http/stored-body-replay.js +6 -1
  139. package/dist/http/wire-types.d.ts +66 -33
  140. package/dist/identity-source.d.ts +65 -0
  141. package/dist/identity-source.js +45 -0
  142. package/dist/index.d.ts +2 -0
  143. package/dist/index.js +1 -0
  144. package/dist/leader/wire.d.ts +7 -0
  145. package/dist/leader/wire.js +5 -4
  146. package/dist/mcp-assertion-header.d.ts +51 -40
  147. package/dist/mcp-assertion-header.js +10 -11
  148. package/dist/mcp-authorization-face.d.ts +27 -0
  149. package/dist/mcp-authorization-face.js +15 -0
  150. package/dist/memory-operator-faces.d.ts +13 -4
  151. package/dist/memory-operator-faces.js +1 -17
  152. package/dist/memory-store-refusal.d.ts +3 -3
  153. package/dist/memory-sync-client.js +72 -57
  154. package/dist/memory-sync.d.ts +1 -1
  155. package/dist/model-entry-refusal.d.ts +2 -0
  156. package/dist/model-entry-refusal.js +11 -0
  157. package/dist/model-route-gap.d.ts +6 -22
  158. package/dist/model-route-gap.js +7 -5
  159. package/dist/model-select.d.ts +6 -17
  160. package/dist/model-select.js +0 -3
  161. package/dist/mods-declaration.d.ts +110 -0
  162. package/dist/mods-declaration.js +109 -0
  163. package/dist/namespaced-rule-name.d.ts +1 -1
  164. package/dist/observability/err-text.d.ts +5 -0
  165. package/dist/observability/err-text.js +9 -0
  166. package/dist/observability/fail-open.d.ts +17 -1
  167. package/dist/observability/fail-open.js +17 -1
  168. package/dist/observability/metrics.js +1 -1
  169. package/dist/observability/run-terminal.js +1 -0
  170. package/dist/observability/secret-env-scrub.d.ts +1 -1
  171. package/dist/observability/secret-env-scrub.js +1 -1
  172. package/dist/operator-ask.d.ts +2 -2
  173. package/dist/org-delivery-gate.js +1 -1
  174. package/dist/org-memory-admission.d.ts +2 -2
  175. package/dist/org-memory-admission.js +39 -2
  176. package/dist/parked-decide.d.ts +15 -2
  177. package/dist/parked-decide.js +17 -10
  178. package/dist/peer-directory.js +2 -0
  179. package/dist/peer-seat.d.ts +150 -0
  180. package/dist/peer-seat.js +239 -0
  181. package/dist/permission-rule-list.d.ts +2 -2
  182. package/dist/permission-rule-vocab.d.ts +7 -7
  183. package/dist/permission-rule-vocab.js +3 -3
  184. package/dist/plain-string-record.d.ts +38 -0
  185. package/dist/plain-string-record.js +38 -0
  186. package/dist/plugins/background-agent-store-sql.js +6 -3
  187. package/dist/plugins/caching-session-store.d.ts +5 -0
  188. package/dist/plugins/caching-session-store.js +6 -0
  189. package/dist/plugins/checkpoint-store-sql.d.ts +34 -29
  190. package/dist/plugins/checkpoint-store-sql.js +14 -14
  191. package/dist/plugins/file-history-store-sql.js +10 -16
  192. package/dist/plugins/file-run-store.js +8 -2
  193. package/dist/plugins/k8s-exec-protocol.d.ts +69 -13
  194. package/dist/plugins/k8s-exec-protocol.js +116 -28
  195. package/dist/plugins/k8s-pod-delete-failure.d.ts +25 -0
  196. package/dist/plugins/k8s-pod-delete-failure.js +17 -0
  197. package/dist/plugins/k8s-pod-reaper.d.ts +106 -0
  198. package/dist/plugins/k8s-pod-reaper.js +122 -0
  199. package/dist/plugins/local-checkpoint-store.d.ts +4 -17
  200. package/dist/plugins/local-checkpoint-store.js +3 -4
  201. package/dist/plugins/local-session-store.d.ts +66 -27
  202. package/dist/plugins/local-session-store.js +60 -28
  203. package/dist/plugins/mailbox-store-sql.d.ts +11 -3
  204. package/dist/plugins/mailbox-store-sql.js +103 -15
  205. package/dist/plugins/memory-run-store.js +2 -0
  206. package/dist/plugins/memory-sync-store-pg.d.ts +16 -5
  207. package/dist/plugins/memory-sync-store-tidb.d.ts +3 -3
  208. package/dist/plugins/one-time-migrations.d.ts +34 -12
  209. package/dist/plugins/one-time-migrations.js +51 -9
  210. package/dist/plugins/permission-rule-store-file.js +2 -2
  211. package/dist/plugins/permission-rule-store-sql.d.ts +17 -17
  212. package/dist/plugins/permission-rule-store-sql.js +37 -30
  213. package/dist/plugins/pg-pool.js +4 -0
  214. package/dist/plugins/pg-session-storage.d.ts +10 -2
  215. package/dist/plugins/pg-session-storage.js +20 -7
  216. package/dist/plugins/remote-env-adb.d.ts +6 -1
  217. package/dist/plugins/remote-env-adb.js +2 -1
  218. package/dist/plugins/remote-env-device.d.ts +6 -1
  219. package/dist/plugins/remote-env-device.js +5 -1
  220. package/dist/plugins/remote-env-e2b.d.ts +5 -0
  221. package/dist/plugins/remote-env-e2b.js +2 -1
  222. package/dist/plugins/remote-env-file-error.d.ts +8 -1
  223. package/dist/plugins/remote-env-file-error.js +7 -1
  224. package/dist/plugins/remote-env-host.d.ts +10 -2
  225. package/dist/plugins/remote-env-host.js +8 -2
  226. package/dist/plugins/remote-env-k8s.d.ts +140 -11
  227. package/dist/plugins/remote-env-k8s.js +277 -15
  228. package/dist/plugins/remote-env-local-docker.d.ts +5 -0
  229. package/dist/plugins/remote-env-local-docker.js +2 -1
  230. package/dist/plugins/remote-env-ssh.d.ts +6 -1
  231. package/dist/plugins/remote-env-ssh.js +2 -1
  232. package/dist/plugins/retention-store-sql.d.ts +14 -2
  233. package/dist/plugins/retention-store-sql.js +6 -0
  234. package/dist/plugins/run-store-sql.js +10 -4
  235. package/dist/plugins/session-fork.d.ts +10 -13
  236. package/dist/plugins/session-fork.js +5 -25
  237. package/dist/plugins/session-ownership.d.ts +24 -3
  238. package/dist/plugins/session-ownership.js +24 -5
  239. package/dist/plugins/session-workspace-cell.d.ts +17 -0
  240. package/dist/plugins/session-workspace-cell.js +38 -0
  241. package/dist/plugins/sql-errors.d.ts +23 -2
  242. package/dist/plugins/sql-errors.js +15 -0
  243. package/dist/plugins/store-backend.js +7 -7
  244. package/dist/plugins/store-contracts.d.ts +10 -1
  245. package/dist/plugins/store-fault.d.ts +2 -2
  246. package/dist/plugins/store-fault.js +4 -2
  247. package/dist/plugins/tidb-pool.js +13 -0
  248. package/dist/plugins/tidb-session-store.d.ts +17 -2
  249. package/dist/plugins/tidb-session-store.js +18 -7
  250. package/dist/plugins/tool-result-store-sql.d.ts +13 -6
  251. package/dist/plugins/tool-result-store-sql.js +8 -7
  252. package/dist/plugins/worktree-isolation.d.ts +3 -2
  253. package/dist/plugins/worktree-isolation.js +1 -7
  254. package/dist/project-identity.d.ts +1 -14
  255. package/dist/project-identity.js +0 -4
  256. package/dist/request-key-closure.js +1 -1
  257. package/dist/request-value-closure.d.ts +31 -2
  258. package/dist/request-value-closure.js +7 -1
  259. package/dist/rule-entry-list.d.ts +3 -2
  260. package/dist/rule-entry-list.js +2 -2
  261. package/dist/rules-consent.js +12 -7
  262. package/dist/run-local.d.ts +2 -2
  263. package/dist/run-local.js +12 -6
  264. package/dist/runs.d.ts +7 -1
  265. package/dist/runs.js +8 -2
  266. package/dist/runtime-governance.d.ts +5 -5
  267. package/dist/sandbox-git-lease.d.ts +49 -0
  268. package/dist/sandbox-git-lease.js +60 -0
  269. package/dist/sandbox-git-plane.d.ts +84 -0
  270. package/dist/sandbox-git-plane.js +105 -0
  271. package/dist/security.d.ts +16 -4
  272. package/dist/security.js +8 -3
  273. package/dist/server-secret-env.d.ts +12 -3
  274. package/dist/server-secret-env.js +18 -2
  275. package/dist/session-error-wire.d.ts +14 -6
  276. package/dist/session-error-wire.js +2 -1
  277. package/dist/session-incarnation.d.ts +4 -5
  278. package/dist/session-incarnation.js +2 -2
  279. package/dist/session-policy-wire.d.ts +1 -1
  280. package/dist/session-policy-wire.js +2 -2
  281. package/dist/session-sync.d.ts +1 -1
  282. package/dist/session-workspace.d.ts +130 -0
  283. package/dist/session-workspace.js +83 -0
  284. package/dist/spec-fields.d.ts +44 -14
  285. package/dist/spec-fields.js +33 -12
  286. package/dist/standing-answer-grant.d.ts +2 -13
  287. package/dist/standing-answer-grant.js +0 -7
  288. package/dist/stored-request-prompt-profile-m30.d.ts +49 -0
  289. package/dist/stored-request-prompt-profile-m30.js +25 -0
  290. package/dist/task-a2a.d.ts +44 -14
  291. package/dist/task-a2a.js +46 -20
  292. package/dist/task-mcp.d.ts +34 -21
  293. package/dist/task-mcp.js +46 -22
  294. package/dist/task-settings.d.ts +2 -2
  295. package/dist/tool-approval.d.ts +43 -9
  296. package/dist/tool-approval.js +57 -15
  297. package/dist/tool-rule-names.d.ts +5 -0
  298. package/dist/trace/core-keyset-guard.d.ts +34 -19
  299. package/dist/trace/engine-notice-wire.d.ts +1 -1
  300. package/dist/trace/engine-notice-wire.js +4 -0
  301. package/dist/trace/exit-seat.d.ts +20 -1
  302. package/dist/trace/exit-seat.js +5 -2
  303. package/dist/trace/extension-call-face.d.ts +33 -0
  304. package/dist/trace/extension-call-face.js +39 -0
  305. package/dist/trace/ledger-sink.d.ts +4 -5
  306. package/dist/trace/ledger-sink.js +3 -7
  307. package/dist/trace/project.d.ts +62 -10
  308. package/dist/trace/project.js +49 -9
  309. package/dist/trace/redact.d.ts +18 -6
  310. package/dist/trace/redact.js +154 -60
  311. package/dist/trace/self-minted-wire.d.ts +500 -7
  312. package/dist/trace/self-minted-wire.js +182 -1
  313. package/dist/trace/wire-projection-faces.d.ts +7 -5
  314. package/dist/trace/wire-projection-faces.js +9 -4
  315. package/dist/user-authored-text.d.ts +2 -2
  316. package/dist/user-authored-text.js +16 -1
  317. package/dist/web-admin-client/apply-effective.js +0 -1
  318. package/dist/web-admin-client/center-request.d.ts +23 -4
  319. package/dist/web-admin-client/center-request.js +42 -8
  320. package/dist/web-admin-client/http-client.js +6 -6
  321. package/dist/web-admin-client/skills-mcp.d.ts +8 -4
  322. package/dist/web-admin-client/skills-mcp.js +31 -18
  323. package/dist/web-admin-client/types.d.ts +10 -5
  324. package/docs/DEVICE-EXECUTOR-PROTOCOL.md +11 -5
  325. package/package.json +3 -3
  326. package/wire-changes/server@7.137.0.json +825 -0
  327. package/wire-changes/server@7.138.0.json +446 -0
@@ -4,6 +4,7 @@ import type { SessionRowPatch } from "../session-row.js";
4
4
  import { type Logger } from "../observability/logger.js";
5
5
  import { type SessionFace } from "../session-incarnation.js";
6
6
  import { type InstructionSourceBaseline } from "../instruction-source-notice.js";
7
+ import type { SessionWorkspace } from "../session-workspace.js";
7
8
  import { type StagedCommitOptions, type StagingHandle, type SyncRelation } from "../session-sync-kernel.js";
8
9
  /**
9
10
  * local 车道会话存档的**唯一 repo 出口**:本仓里每一只读 `<root>/sessions/*.jsonl` 的 `FileSessionRepo` 都从这里取(`LocalSessionStore` 的底座与会话视图口
@@ -64,6 +65,9 @@ export declare class LocalSessionStore implements SessionStore {
64
65
  * run selected, encoded like the SQL twins' `session_meta.instruction_source` column ('' = none; missing key = never
65
66
  * recorded). Its own file for the same reason titles have theirs: one small map per concern, no format coupling. */
66
67
  private readonly instructionSources;
68
+ /** 会话行的位置与项目那一格(`session-workspaces.json`)—— SQL 孪生 `session_meta.workspace` 的 local 对位:`{ [sessionId]: 那一格的文本 }`,
69
+ * 文本与 SQL 列同一只编解码(`session-workspace-cell.ts`,一格读不出 ⇒ 读作没有记录并计一次)。缺键 = 没有记录。一事一档,理由同 titles。 */
70
+ private readonly workspaces;
67
71
  /**
68
72
  * 会话元数据(`session-incarnations.json`,一格的形与两种读法见 {@link SessionMetaCell}):**属主与化身的唯一真源**,SQL 孪生
69
73
  * `session_meta` 行上 `owner` / `incarnation` 两列在本店的对位。两样都**必须落盘**:
@@ -175,6 +179,13 @@ export declare class LocalSessionStore implements SessionStore {
175
179
  * 开始之前共享的那张表只含落过盘的值,判定永远不会建立在一个没落过盘的值上(codex che-NM r2 [medium])。
176
180
  */
177
181
  recordInstructionSource(sessionId: string, expected: InstructionSourceBaseline, source: string | null): Promise<void>;
182
+ /** 会话行的位置与项目那一格(local 孪生;读法与 SQL 两孪生同一只 `decodeSessionWorkspaceCell`)。缺键 ⇒ `undefined`。 */
183
+ readSessionWorkspace(sessionId: string): Promise<SessionWorkspace | undefined>;
184
+ /**
185
+ * 整格覆写(最后一帧胜;唯一调用方 = 腿的记录座)。与 SQL 孪生「`UPDATE … WHERE session_id = ? AND incarnation = ?`」同义:**会话不在、或同 id
186
+ * 已换了住客 ⇒ 不写**,判与写排在这只会话的写锁里(删除 / 换人同锁 —— 判过的住客在这一笔落盘之前换不掉)。落不了盘 ⇒ 内存不动、抛。
187
+ */
188
+ recordSessionWorkspace(sessionId: string, incarnation: string, workspace: SessionWorkspace): Promise<void>;
178
189
  /** Best-effort, one-shot hydrate of the persisted lastActivity map (no-op when not persisting / no sidecar yet). A
179
190
  * read/parse error degrades to an empty map (createdAt ordering) rather than failing the list — the picker still
180
191
  * works, just without restart-persisted recency. Memoized so concurrent listSessions share one read. */
@@ -235,11 +246,6 @@ export declare class LocalSessionStore implements SessionStore {
235
246
  * `REQUIRE_PRINCIPAL=true`,授权口也只在那时才把 principal 递过来认领(`security.ts` `createAuthorizer`)。
236
247
  */
237
248
  register(sessionId: string, owner: string | null): Promise<void>;
238
- /** 2c P1d-β — re-stamp the session's owner WITHOUT rewriting entries (the staged-commit `identical` path: the log is
239
- * already present & equal, so only the owner needs re-stamping to the importing principal, §9). Persisted like every
240
- * owner write; the incarnation is untouched (same occupant). No cell (no such session) ⇒ no-op. Only caller:
241
- * {@link commitStagedImport}, inside the session's write lock. */
242
- private restampOwner;
243
249
  touch(sessionId: string): Promise<void>;
244
250
  /** Durable history is retained by the repo; just drop the in-flight cache entry (mirrors TiDBSessionStore.release). */
245
251
  release(sessionId: string): Promise<void>;
@@ -322,27 +328,60 @@ export declare class LocalSessionStore implements SessionStore {
322
328
  afterSeq?: number;
323
329
  batchSize?: number;
324
330
  }): Promise<AsyncIterable<SessionTreeEntry> | null>;
325
- /** 2c session-sync — IMPORT a verbatim entry log into `sessionId` via core `SessionRepo.importEntries`
326
- * (which runs the `validateEntriesForImport` fail-closed gate before writing). `owner` is the authenticated
327
- * importing principal (re-stamped into the service-side owner map; core's local repo has no owner column, §9).
328
- * `owner=null` (single-user local) is passed as `undefined` to the core seam (its owner param is `string | undefined`).
329
- * The owner is recorded in the session-meta cell (persisted), together with the incarnation. */
331
+ /**
332
+ * 2c session-sync — IMPORT a verbatim entry log into `sessionId`(引擎的 `validateEntriesForImport` 闸在引擎仓的导入里先跑:坏包一个字节不写)。`owner` = 鉴权过的导入者。
333
+ * 与引擎 `SessionRepo.importEntries` 的导入律同一句,只是**属主在本店的属主格上判**(会话元数据那一格是本店唯一的属主真源;本店建的存档在引擎仓里
334
+ * 不带属主戳 —— `register` 只 `repo.create({ id })` —— 引擎仓的属主比较替这一格答不了):落在既有行上 ⇒ 只落在导入者自己的行上(读不中 ⇒ `conflict`,
335
+ * 一个字节不写),存档原地换日志、行原样;落在不存在的 id 上 ⇒ 新行(属主 = 导入者,铸一只化身)。整段在这只会话的写锁里;日志 = {@link swapLogHeld},行收尾 = {@link finishSwap}。
336
+ */
330
337
  importEntries(sessionId: string, owner: string | null, entries: SessionTreeEntry[]): Promise<void>;
331
- /** importEntries / replaceEntries 的共用写半场:导日志 + 一格写全属主与化身(新建铸的那只,或换装沿用的那只)。调用方持这只会话的写锁。 */
332
- private importEntriesAs;
333
- /** 2c session-sync (§7/§8) — IDEMPOTENT REPLACE. The local twin's replace is DELETE-then-IMPORT: core's
334
- * InMemorySessionRepo / FileSessionRepo expose no single replace primitive, but the local backend is single-writer
335
- * with NO compaction floor / cross-instance race (a process-local in-memory map / a single-process file repo), so
336
- * deleteSession-then-importEntries is effectively atomic for the local case (no concurrent reader can observe the
337
- * gap, unlike the durable cross-instance backends which do it in ONE SQL txn). Both halves are best-effort feature-
338
- * gated: a repo lacking either seam throws the same clear error importEntries does (no silent half-write). */
338
+ /**
339
+ * 2c session-sync (§7/§8) — IDEMPOTENT REPLACE,按接口句(`SessionStorage.replaceEntries`:先清这只会话既有的对话日志,再导入 `entries`)。
340
+ * 与 {@link importEntries} 同一个入口、同一只属主判、同一个行收尾,只差日志那一半:先清内容存档再导入(`purge_first`,见 {@link LogSwap})——
341
+ * 所以一只会话头读不出的存档也换得掉;存档是重建的那一只,建档时刻 = 换装时刻(SQL 孪生 replace 重插行、`created_at` 重记,同义)。
342
+ */
339
343
  replaceEntries(sessionId: string, owner: string | null, entries: SessionTreeEntry[]): Promise<void>;
340
- /** {@link replaceEntries} 的锁内一形(调用方持这只会话的写锁)。 */
341
- private replaceEntriesHeld;
344
+ /**
345
+ * 换装三条写腿(公开 {@link importEntries} / {@link replaceEntries} 与暂存提交 {@link commitStagedImport})的**同一个入口**:调用方给的属主先过
346
+ * {@link importerOwnerOf}(主体名或 `null`;否则 `TypeError`,什么都没读、一个字节没写),再进这只会话的写锁 —— 锁里的写体只见判过的那一个值,
347
+ * 写进行的属主就是它。引擎导入口的属主判管不到这一格:换装交给引擎的是存档自己的戳(见 {@link swapLogHeld}),不是调用方的属主。
348
+ */
349
+ private withImportLock;
350
+ /**
351
+ * 公开导入口({@link importEntries} / {@link replaceEntries})的锁内一形(调用方持这只会话的写锁)。属主判 —— 这条路径上**唯一**一次读那一格:
352
+ * 行在而它的属主格不是导入者 ⇒ `conflict`,一个字节不写;判过才交给写体(写体不读那一格、不判属主)。
353
+ */
354
+ private importHeld;
355
+ /**
356
+ * 换装的**日志**一半(公开导入 / 换装与暂存提交三条腿共用;行那一半在 {@link finishSwap})。调用方持这只会话的写锁,并已在本店的属主格上判过 ——
357
+ * 这里不读那一格、不判属主。只换**内容**;会话元数据那一格、标题 / 置顶 / 指令来源 / 工作区四只行边档、新近,这里一个字节不碰。
358
+ * 一次引擎仓导入,落在 {@link archiveToSwap} 交回的那一份存档上(怎么对待既有存档由写腿的动词定,{@link LogSwap}):
359
+ * 落在既有存档上 = 整份日志换成 `entries`、存档的元数据整只留着(其中的建档时刻是列表行的 firstActivityAt,与 SQL 孪生留在行上的 `created_at` 同义);
360
+ * 校验(`validateEntriesForImport`)、存档判与写在它里面是一步,抛在哪一处都一个字节没写。
361
+ * 存档上的属主戳不是本店的事实(属主只在那一格;本店建的存档一律不带戳,存量存档可能带着此前导入者的戳),只是引擎仓原地换的条件:
362
+ * 按那一份存档自己的戳导入,并以它为 `expect`(戳 + 存档的令牌;没有存档 ⇒ `row: null`,导入新建一只不带戳的存档)——
363
+ * 换的就是这一份;读与写之间它换了一代 ⇒ 引擎仓拒 `conflict`,一个字节不写。
364
+ */
365
+ private swapLogHeld;
366
+ /**
367
+ * 换装落在哪一份存档上(`null` = 没有存档,导入新建一只)—— {@link LogSwap} 的唯一读法。
368
+ * `in_place`:锁里读到的那一份(读不出 ⇒ 引擎的故障原样上抛,一个字节没写)。
369
+ * `purge_first`:先清掉内容存档(不带 `expect` —— 带了就得先读它的头,读不出的存档就清不掉),再交回 `null`;清不可撤,所以引擎导入里的那一只闸
370
+ * (`validateEntriesForImport`)在清之前先跑一遍,坏包什么都不清。清过之后导入再抛(I/O 故障),内容就没了、行与四只边档还在 —— 已发版本的 replace 同形(删后导入)。
371
+ */
372
+ private archiveToSwap;
373
+ /**
374
+ * 推送的**行**收尾 —— 换了日志的与内容相同只重戳的,都经这**一处**(暂存提交两支之后、公开两口的写体之后;调用方持写锁,`owner` 的形已在入口
375
+ * {@link withImportLock} 判过、归属已在锁里判过):
376
+ * 会话元数据那一格属主重戳为导入者;化身经 {@link carriedIncarnation}(三只店同一句)读那一格自己 —— 行在 ⇒ 沿用(同一行 = 同一位住客),
377
+ * 刚建的行 ⇒ 铸一只;然后记新近 = 此刻:推送即活动,不因内容相同分叉。标题 / 置顶 / 指令来源 / 工作区四只行边档不碰。
378
+ * SQL 孪生暂存提交那一句 `UPDATE session_meta SET owner = ?, updated_at = now, leaf_id, leaf_seq` 的 local 对位(那一句两支同写)。
379
+ */
380
+ private finishSwap;
342
381
  /** 2c session-sync P1d-β (PUSH streaming) — begin a STAGED import. The LOCAL backend is single-writer with no
343
382
  * cross-instance / cross-replica concurrency story (the durable twins carry that), so the staging handle simply
344
- * ACCUMULATES the streamed batches in memory then, on commit, runs the SAME validate-first → replaceEntries the
345
- * whole-bundle local import uses (effectively atomic locally — no concurrent reader can observe the gap). Keep it
383
+ * ACCUMULATES the streamed batches in memory then, on commit, runs the SAME in-place log swap ({@link swapLogHeld}: one
384
+ * engine-repo import onto the archive as it stands — validate, judge and write in one step) the whole-bundle local import uses. Keep it
346
385
  * simple per §1: the durable twins are where the SHADOW-id swap + bounded-memory + concurrency matter. */
347
386
  beginImportStaging(realSessionId: string, token: string): StagingHandle;
348
387
  /** 2c P1d-β (staged-row inspection) — the in-memory staged entries for a local staging id. No longer the import gate
@@ -354,7 +393,7 @@ export declare class LocalSessionStore implements SessionStore {
354
393
  /**
355
394
  * {@link LocalStagingHandle.commit} 的整段 —— **一次持锁**(这只会话的写锁,{@link withSessionWriteLock}):属主 CAS(此刻的属主格 = 这次导入的门判过的那一格,
356
395
  * 否则抛 {@link StagedCommitOwnerChangedError},一个字节不写;在判目的端日志之前判,SQL 孪生同序)→ 提交时重新分类(目的端此刻的日志 vs staged)
357
- * → 拒未经同意的 fork / stale / 载荷分叉 → 换装(validate-first → 删 → 导入,化身按换装律沿用)或 identical 且内容相同时只重戳属主 → **在同一次持锁里**
396
+ * → 拒未经同意的 fork / stale / 载荷分叉 → 换日志({@link swapLogHeld},原地换;属主只在 CAS 那一读上判)或 identical 且内容相同时日志不动 → 两支同一收尾 {@link finishSwap}(重戳属主 + 记新近)→ **在同一次持锁里**
358
397
  * 读出落定的会话面交回。锁放下之前谁也换不掉这一位住客,所以交回的面就是这次提交落下的那一位(SQL 孪生在提交事务里读同一行,同义);
359
398
  * 提交之后按住客条件写的重放(会话行两位)只认这只面,不再重读。改属主的每一条写腿(登记 / 删除 / 导入 / 换装)都在同一把锁里,所以锁内这一读到锁放下都成立。
360
399
  */
@@ -367,10 +406,10 @@ export declare class LocalSessionStore implements SessionStore {
367
406
  * tool-result rows are purged separately by the route's purgeSession coordinator. 整段在这只会话的写锁里(换人)。 */
368
407
  deleteSession(sessionId: string, owner: string | null): Promise<boolean>;
369
408
  /**
370
- * {@link deleteSession} 的锁内一形(调用方持这只会话的写锁;换装 {@link replaceEntriesHeld} 也走它)。
371
- * 删的是 SQL 孪生 `session_meta` 那一行在本店的全部对位:会话存档、三只行边档(标题 / 置顶 / [ref] 的指令来源基线)、属主 + 化身那一格。
372
- * 三只边档的那一键**都经各自的写链删**(`commit`:草稿上删、落盘、再发布 —— 与写它们的那一笔同一条链、同一把会话锁),落不了盘 ⇒ 抛:
373
- * 留在盘上的值会挂到同 id 的下一位住客身上(E21 的删除权残留)。元数据格**最后**删:三只边档任一只落不了而抛时,那一格还在 ⇒ 会话对属主门仍
409
+ * {@link deleteSession} 的锁内一形(调用方持这只会话的写锁;换装不走它 —— 换装只换内容存档,行原样,见 {@link swapLogHeld})。
410
+ * 删的是 SQL 孪生 `session_meta` 那一行在本店的全部对位:会话存档、四只行边档(标题 / 置顶 / 指令来源基线 / 工作区)、新近标记、属主 + 化身那一格。
411
+ * 四只边档的那一键**都经各自的写链删**(`commit`:草稿上删、落盘、再发布 —— 与写它们的那一笔同一条链、同一把会话锁),落不了盘 ⇒ 抛:
412
+ * 留在盘上的值会挂到同 id 的下一位住客身上(E21 的删除权残留)。元数据格**最后**删:四只边档任一只落不了而抛时,那一格还在 ⇒ 会话对属主门仍
374
413
  * 「在」,重发删除照样走得进来、收敛;反过来先删格,重发就成了 no-op,残留永远留下。
375
414
  * 🔴 hydrate FIRST (workflow audit 2026-07-13): after a restart the maps start empty — an un-hydrated delete would find nothing and
376
415
  * the sidecar would keep the value (an E21 right-to-delete residue that would RESURRECT onto a same-id re-import). ensureLoaded is
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
- import { assertFirstOwnerWritable, forkOwnerOf } from "./session-ownership.js";
3
+ import { assertFirstOwnerWritable, forkOwnerOf, importerOwnerOf } from "./session-ownership.js";
4
4
  import { FileSessionRepo, SessionError, StoredSession, settledForkOptions, validateEntriesForImport } from "@sema-agent/core";
5
5
  import { z } from "zod";
6
6
  import { contentForkRelation, fastForwardSharedContentDiverged, identicalIdsAlsoIdenticalContent } from "../session-sync-content.js";
@@ -11,6 +11,7 @@ import { createLogger } from "../observability/logger.js";
11
11
  import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
12
12
  import { UnreadableLeaf, carriedIncarnation, headFromRow, mintSessionIncarnation, sessionFaceFromRow } from "../session-incarnation.js";
13
13
  import { decodeInstructionSource, encodeInstructionSource, sameInstructionSourceBaseline } from "../instruction-source-notice.js";
14
+ import { decodeSessionWorkspaceCell, encodeSessionWorkspaceCell } from "./session-workspace-cell.js";
14
15
  import { noteReplayInSessionOpenScope, openSessionForItself } from "../session-records-skipped-notice.js";
15
16
  import { createCorruptReadFailOpenSeat } from "../observability/corrupt-read-seat.js";
16
17
  import { buildObjectivePreview, isAttachmentFrame } from "../user-authored-text.js";
@@ -35,7 +36,7 @@ function userText(entry) {
35
36
  }
36
37
  return null;
37
38
  }
38
- const SIDECAR_DEGRADES = { lastActivity: "recency", title: "title", pinned: "pin state", "instruction-source": "instruction-source baseline", "session-meta": "session owner + incarnation (the write that needed it failed loudly)" };
39
+ const SIDECAR_DEGRADES = { lastActivity: "recency", title: "title", pinned: "pin state", "instruction-source": "instruction-source baseline", workspace: "each session's last recorded place and project (the next frame records it again)", "session-meta": "session owner + incarnation (the write that needed it failed loudly)" };
39
40
  const readPinnedSidecar = createZodColumnReader(z.record(z.string(), z.literal(true)));
40
41
  function readSessionMetaCell(cell) {
41
42
  return typeof cell === "string" ? { incarnation: cell, owner: undefined } : cell;
@@ -180,6 +181,7 @@ export class LocalSessionStore {
180
181
  titles;
181
182
  pinned;
182
183
  instructionSources;
184
+ workspaces;
183
185
  meta;
184
186
  log;
185
187
  sessionWriteLocks;
@@ -192,6 +194,7 @@ export class LocalSessionStore {
192
194
  this.titles = sidecar("session-titles.json", "title", readStringRecordColumn);
193
195
  this.pinned = sidecar("session-pinned.json", "pinned", readPinnedSidecar);
194
196
  this.instructionSources = sidecar("session-instruction-sources.json", "instruction-source", readStringRecordColumn);
197
+ this.workspaces = sidecar("session-workspaces.json", "workspace", readStringRecordColumn);
195
198
  this.meta = sidecar("session-incarnations.json", "session-meta", readSessionMetaSidecar, true);
196
199
  const root = dataDir ? resolve(dataDir) : undefined;
197
200
  if (root !== undefined && !SESSION_WRITE_LOCKS_BY_ROOT.has(root))
@@ -352,6 +355,26 @@ export class LocalSessionStore {
352
355
  if (!committed)
353
356
  throw new Error("LocalSessionStore: instruction-source sidecar write failed — baseline not recorded");
354
357
  }
358
+ async readSessionWorkspace(sessionId) {
359
+ await this.workspaces.ensureLoaded();
360
+ return decodeSessionWorkspaceCell(this.workspaces.state.entries.get(sessionId) ?? null, sessionId);
361
+ }
362
+ async recordSessionWorkspace(sessionId, incarnation, workspace) {
363
+ const text = encodeSessionWorkspaceCell(workspace, JSON.stringify, sessionId);
364
+ await this.withSessionWriteLock(sessionId, async () => {
365
+ if ((await this.metaOf(sessionId))?.incarnation !== incarnation)
366
+ return;
367
+ await this.workspaces.ensureLoaded();
368
+ const committed = await this.workspaces.commit((draft) => {
369
+ if (draft.get(sessionId) === text)
370
+ return false;
371
+ draft.set(sessionId, text);
372
+ return true;
373
+ });
374
+ if (!committed)
375
+ throw new SessionError("storage", `LocalSessionStore: workspace sidecar write failed for session ${sessionId} — its place was not recorded`);
376
+ });
377
+ }
355
378
  async ensureLoaded() {
356
379
  if (!this.sidecarPath)
357
380
  return;
@@ -480,9 +503,6 @@ export class LocalSessionStore {
480
503
  this.log.info("session_owner_claimed", { sessionId });
481
504
  });
482
505
  }
483
- async restampOwner(sessionId, owner) {
484
- await this.writeMeta(sessionId, (before) => (before === undefined ? undefined : { incarnation: readSessionMetaCell(before).incarnation, owner }));
485
- }
486
506
  async touch(sessionId) {
487
507
  this.lastActivity.set(sessionId, new Date().toISOString());
488
508
  this.scheduleFlush();
@@ -607,28 +627,42 @@ export class LocalSessionStore {
607
627
  return gen();
608
628
  }
609
629
  async importEntries(sessionId, owner, entries) {
610
- await this.withSessionWriteLock(sessionId, () => this.importEntriesAs(sessionId, owner, entries, mintSessionIncarnation()));
611
- }
612
- async importEntriesAs(sessionId, owner, entries, incarnation) {
613
- if (!this.repo.importEntries)
614
- throw new SessionError("storage", "session import is not supported by this local backend");
615
- await this.meta.ensureLoaded();
616
- await this.repo.importEntries(sessionId, owner ?? undefined, entries);
617
- await this.writeMeta(sessionId, () => ({ incarnation, owner }));
630
+ await this.withImportLock(sessionId, owner, (importer) => this.importHeld(sessionId, importer, entries, "in_place"));
618
631
  }
619
632
  async replaceEntries(sessionId, owner, entries) {
620
- await this.withSessionWriteLock(sessionId, () => this.replaceEntriesHeld(sessionId, owner, entries));
633
+ await this.withImportLock(sessionId, owner, (importer) => this.importHeld(sessionId, importer, entries, "purge_first"));
634
+ }
635
+ async withImportLock(sessionId, owner, body) {
636
+ const importer = importerOwnerOf(owner);
637
+ return this.withSessionWriteLock(sessionId, () => body(importer));
621
638
  }
622
- async replaceEntriesHeld(sessionId, owner, entries) {
639
+ async importHeld(sessionId, owner, entries, swap) {
640
+ const row = await this.metaOf(sessionId);
641
+ if (row !== undefined && !ownerEq(row.owner ?? null, owner)) {
642
+ throw new SessionError("conflict", `session ${sessionId} is not the importer's row (its owner cell differs) — nothing was written`);
643
+ }
644
+ await this.swapLogHeld(sessionId, entries, swap);
645
+ await this.finishSwap(sessionId, owner);
646
+ }
647
+ async swapLogHeld(sessionId, entries, swap) {
623
648
  if (!this.repo.importEntries)
624
649
  throw new SessionError("storage", "session import is not supported by this local backend");
625
- if (!this.repo.delete)
626
- throw new SessionError("storage", "session replace is not supported by this local backend (no delete seam)");
627
- validateEntriesForImport(entries);
628
- const current = await this.metaOf(sessionId);
629
- const prior = current === undefined ? undefined : { owner: current.owner ?? null, incarnation: current.incarnation };
630
- await this.deleteSessionHeld(sessionId, owner);
631
- await this.importEntriesAs(sessionId, owner, entries, carriedIncarnation(sessionId, prior, owner));
650
+ const archive = await this.archiveToSwap(sessionId, entries, swap);
651
+ await this.repo.importEntries(sessionId, archive?.owner ?? undefined, entries, { expect: { row: archive } });
652
+ }
653
+ async archiveToSwap(sessionId, entries, swap) {
654
+ switch (swap) {
655
+ case "in_place":
656
+ return (await this.repo.headOf(sessionId)) ?? null;
657
+ case "purge_first":
658
+ validateEntriesForImport(entries);
659
+ await this.repo.delete({ id: sessionId, createdAt: "" });
660
+ return null;
661
+ }
662
+ }
663
+ async finishSwap(sessionId, owner) {
664
+ await this.writeMeta(sessionId, (before) => ({ incarnation: carriedIncarnation(sessionId, before === undefined ? undefined : readSessionMetaCell(before)), owner }));
665
+ await this.touch(sessionId);
632
666
  }
633
667
  beginImportStaging(realSessionId, token) {
634
668
  const h = new LocalStagingHandle(this, realSessionId, stagingIdFor(realSessionId, token));
@@ -642,7 +676,7 @@ export class LocalSessionStore {
642
676
  this.stagings.delete(stagingId);
643
677
  }
644
678
  async commitStagedImport(sessionId, owner, staged, opts) {
645
- return this.withSessionWriteLock(sessionId, async () => {
679
+ return this.withImportLock(sessionId, owner, async (importer) => {
646
680
  const current = await this.metaOf(sessionId);
647
681
  if (!ownerCellIs(opts.expectedOwner, current === undefined ? undefined : (current.owner ?? null)))
648
682
  throw new StagedCommitOwnerChangedError(sessionId);
@@ -659,11 +693,9 @@ export class LocalSessionStore {
659
693
  }
660
694
  const contentEqual = rel.relation === "identical" && identicalIdsAlsoIdenticalContent(staged, dstEntries);
661
695
  if (rel.relation !== "identical" || !contentEqual) {
662
- await this.replaceEntriesHeld(sessionId, owner, staged);
663
- }
664
- else {
665
- await this.restampOwner(sessionId, owner);
696
+ await this.swapLogHeld(sessionId, staged, "in_place");
666
697
  }
698
+ await this.finishSwap(sessionId, importer);
667
699
  const meta = await this.metaOf(sessionId);
668
700
  if (meta === undefined)
669
701
  throw new SessionError("storage", `LocalSessionStore: staged commit for session ${sessionId} left no session face — nothing was settled`);
@@ -685,7 +717,7 @@ export class LocalSessionStore {
685
717
  if (!isNotFound(e))
686
718
  throw e;
687
719
  }
688
- for (const [map, which] of [[this.titles, "title"], [this.pinned, "pinned"], [this.instructionSources, "instruction-source"]]) {
720
+ for (const [map, which] of [[this.titles, "title"], [this.pinned, "pinned"], [this.instructionSources, "instruction-source"], [this.workspaces, "workspace"]]) {
689
721
  await map.ensureLoaded();
690
722
  if (!(await map.deleteKey(sessionId)))
691
723
  throw new SessionError("storage", `LocalSessionStore: ${which} sidecar write failed while deleting session ${sessionId} — its ${which} is still on disk; retry the delete`);
@@ -1,13 +1,13 @@
1
1
  import type { Pool as MySqlPool } from "mysql2/promise";
2
2
  import type { Pool as PgPool } from "pg";
3
- import type { MailboxAckResult, MailboxAppendMessage, MailboxLease, MailboxStore } from "@sema-agent/core";
3
+ import type { MailboxAckResult, MailboxAppendMessage, MailboxDecision, MailboxDecisionOutcome, MailboxLease, MailboxStore } from "@sema-agent/core";
4
4
  import { type IndexSpec } from "./ensure-index.js";
5
5
  import type { MailboxRecipientLifecycle, MailboxRecipientTombstone } from "./mailbox-recipient-lifecycle.js";
6
6
  export declare const MAILBOX_TABLE = "mailbox";
7
7
  export declare const MAILBOX_MSG_TABLE = "mailbox_message";
8
8
  /** [ref] 收件人墓碑表(预删除态的**唯一**真源;形与理由见 `mailbox-recipient-lifecycle.ts` 模块头)。
9
- * 🔴 为什么是**新表**而不是给 `mailbox` 加两列:加列走的是本仓「删库重建、不开 ALTER seam」那条政策
10
- * (存量库首跑必红);而 `CREATE TABLE IF NOT EXISTS` 一张新表在存量库上零破坏、零运维动作。语义上也更干净
9
+ * 🔴 为什么是**新表**而不是给 `mailbox` 加两列:加列走的是本仓「启动 DDL 不开 ALTER seam」那条政策
10
+ * (存量库缺列起服列册对账拒启、要运维先手工 `ALTER`);而 `CREATE TABLE IF NOT EXISTS` 一张新表在存量库上零破坏、零运维动作。语义上也更干净
11
11
  * —— `mailbox` 行是**盒**事实(next_seq / lease),墓碑是**收件人**事实,两者的生命周期本来就不同
12
12
  * (`drop` 同时终结两者 —— 墓碑随箱亡,理由全文见 `mailbox-recipient-lifecycle.ts` 模块头)。 */
13
13
  export declare const MAILBOX_TOMBSTONE_TABLE = "mailbox_tombstone";
@@ -69,6 +69,12 @@ export declare class TiDBMailboxStore implements MailboxStore, MailboxRecipientL
69
69
  append(scope: string, handle: string, msg: MailboxAppendMessage): Promise<number>;
70
70
  claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>;
71
71
  ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<MailboxAckResult>;
72
+ /**
73
+ * core 7.57.0 #972b —— 一条记录的留置决定从 `expect`(缺席 = 从未 held)移到 `next`:比较并设置,对每一次别的移动与 `ack` 原子
74
+ * (锁序与 ack / drop / reap 同为「盒行 → 消息行」,当前读);**不受租约围栏**(人从哪个终端答都行)。坏调用(词不在闭集 / seq 不是正整数 /
75
+ * 决定律不许的边)先于键判官与任何副作用以 `mailbox.invalid_decision_move` 拒(core 的唯一验证器 `assertMailboxDecisionMove`);判官拒的键没有箱 ⇒ gone。
76
+ */
77
+ moveDecision(scope: string, handle: string, seq: number, expect: MailboxDecision | undefined, next: MailboxDecision): Promise<MailboxDecisionOutcome>;
72
78
  releaseLease(scope: string, handle: string, owner: string): Promise<void>;
73
79
  peekCount(scope: string, handle: string): Promise<number>;
74
80
  drop(scope: string, handle: string): Promise<void>;
@@ -93,6 +99,8 @@ export declare class PgMailboxStore implements MailboxStore, MailboxRecipientLif
93
99
  append(scope: string, handle: string, msg: MailboxAppendMessage): Promise<number>;
94
100
  claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>;
95
101
  ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<MailboxAckResult>;
102
+ /** core 7.57.0 #972b 留置决定的比较并设置(TiDB 孪生同注:验证先于一切、锁序盒行 → 消息行、不受租约围栏、判官拒的键 ⇒ gone)。 */
103
+ moveDecision(scope: string, handle: string, seq: number, expect: MailboxDecision | undefined, next: MailboxDecision): Promise<MailboxDecisionOutcome>;
96
104
  releaseLease(scope: string, handle: string, owner: string): Promise<void>;
97
105
  peekCount(scope: string, handle: string): Promise<number>;
98
106
  drop(scope: string, handle: string): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import { isUtf8 } from "node:buffer";
2
- import { MAILBOX_INVALID_PEER_META_CODE, MAILBOX_TOMBSTONED_RECIPIENT_CODE, MailboxStoreError, readMailboxPeerMeta } from "@sema-agent/core";
2
+ import { MAILBOX_INVALID_PEER_META_CODE, MAILBOX_RECORD_ID_MAX_CHARS, MAILBOX_TOMBSTONED_RECIPIENT_CODE, MailboxStoreError, assertMailboxDecisionMove, judgeMailboxAppendById, readMailboxAppend, readMailboxPeerMeta, readStoredMailboxDecision } from "@sema-agent/core";
3
3
  import { pgHasUnstorable, pgSanitizeText, PgUnstorableError } from "./pg-safe-json.js";
4
4
  import { isWellFormedUnicode } from "../principal-jwt.js";
5
5
  import { ensureIndexes } from "./ensure-index.js";
@@ -25,15 +25,36 @@ export const MAILBOX_KEY_MAX_UTF8_BYTES = 190;
25
25
  export function mailboxKeyProblem(what, v) {
26
26
  if (what === "scope" && (v === undefined || v === ""))
27
27
  return "MailboxStore: refusing a message without a scope (an empty scope addresses no box)";
28
+ const unaddressable = byteExactIdentityProblem(what, v);
29
+ if (unaddressable !== undefined)
30
+ return unaddressable;
31
+ if (Buffer.byteLength(v, "utf8") > MAILBOX_KEY_MAX_UTF8_BYTES) {
32
+ return `MailboxStore: ${what} exceeds the ${MAILBOX_KEY_MAX_UTF8_BYTES}-byte key column (refusing — a truncated key would desync from its byte-exact lookups)`;
33
+ }
34
+ return undefined;
35
+ }
36
+ function byteExactIdentityProblem(what, v) {
28
37
  if (!isWellFormedUnicode(v))
29
38
  return `MailboxStore: ${what} is not well-formed Unicode (lone surrogate) (refusing — the driver would store it as U+FFFD and alias a different key)`;
30
39
  if ([...v].some((ch) => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f))
31
40
  return `MailboxStore: ${what} contains control characters (refusing — identity keys must be byte-exact addressable)`;
32
- if (Buffer.byteLength(v, "utf8") > MAILBOX_KEY_MAX_UTF8_BYTES) {
33
- return `MailboxStore: ${what} exceeds the ${MAILBOX_KEY_MAX_UTF8_BYTES}-byte key column (refusing — a truncated key would desync from its byte-exact lookups)`;
34
- }
35
41
  return undefined;
36
42
  }
43
+ function appendSnapshot(msg) {
44
+ const rec = readMailboxAppend(msg);
45
+ if (rec.id !== undefined) {
46
+ const problem = byteExactIdentityProblem("record id", rec.id);
47
+ if (problem !== undefined)
48
+ throw new Error(problem);
49
+ }
50
+ return rec;
51
+ }
52
+ function replayedSeq(held, scope, handle, logger, asStored) {
53
+ return judgeMailboxAppendById(rowsToMessages(held, scope, handle, logger), asStored);
54
+ }
55
+ function asUtf8Stored(v) {
56
+ return Buffer.from(v, "utf8").toString("utf8");
57
+ }
37
58
  function assertMailboxKey(what, v) {
38
59
  const problem = mailboxKeyProblem(what, v);
39
60
  if (problem !== undefined)
@@ -130,6 +151,7 @@ function rowsToMessages(rows, scope, handle, logger) {
130
151
  };
131
152
  const hopChain = read("hop_chain", () => decodeHopChain(r.hop_chain, r.seq));
132
153
  const peerMeta = read("peer_meta", () => decodePeerMeta(r.peer_meta, r.seq));
154
+ const decision = decisionOfColumn(r.decision);
133
155
  out.push({
134
156
  seq: Number(r.seq),
135
157
  ...(r.from_id !== null && r.from_id !== undefined ? { from: bufToStr(r.from_id) } : {}),
@@ -137,10 +159,21 @@ function rowsToMessages(rows, scope, handle, logger) {
137
159
  sentAt: Number(r.sent_at_ms),
138
160
  ...(hopChain !== undefined ? { hopChain } : {}),
139
161
  ...(peerMeta !== undefined ? { peerMeta } : {}),
162
+ ...(decision !== undefined ? { decision } : {}),
163
+ ...(r.record_id !== null && r.record_id !== undefined ? { id: String(r.record_id) } : {}),
140
164
  });
141
165
  }
142
166
  return out;
143
167
  }
168
+ function decisionOfColumn(raw) {
169
+ return raw === null || raw === undefined ? undefined : readStoredMailboxDecision(bufToStr(raw));
170
+ }
171
+ function decisionMoveVerdict(rows, expect) {
172
+ if (rows.length === 0)
173
+ return { ok: false, current: "gone" };
174
+ const current = decisionOfColumn(rows[0].decision);
175
+ return current === expect ? "write" : { ok: false, current: current ?? "unset" };
176
+ }
144
177
  export async function ensureTiDBMailboxSchema(pool) {
145
178
  await pool.query(`CREATE TABLE IF NOT EXISTS ${MAILBOX_TABLE} (
146
179
  scope_key VARBINARY(190) NOT NULL,
@@ -166,8 +199,15 @@ export async function ensureTiDBMailboxSchema(pool) {
166
199
  -- peer_meta(S-90;core fc52b4d4 design/385 片2b):drain 点权威判断读的 typed peer 记录,JSON 文本;
167
200
  -- NULL = 缺席(**有语义**:pre-385/foreign 记录,drain 永不回溯准入;与空对象绝不互折)。写入面先过 core
168
201
  -- readMailboxPeerMeta(坏值带码拒于任何副作用前)+ PEER_META_MAX_BYTES 守卫关掉静默截断类。
169
- -- 新列按 2026-07-26 schema policy 直接折进 CREATE(不开 ALTER seam,存量库删库重建)。
202
+ -- 新列直接折进 CREATE(启动 DDL 不开 ALTER seam);存量库缺列 ⇒ 起服列册对账拒启并给出可粘贴的 ALTER 语句,由运维在升级前执行。
170
203
  peer_meta TEXT NULL,
204
+ -- decision(core 7.57.0 #972b):收件人设置留置的这条记录的**决定**(七词闭集 MAILBOX_DECISIONS);NULL = 从未 held。
205
+ -- 只经 moveDecision 的比较并设置移动,从不随 append 写;随记录生随记录亡(ack / reap / drop 删行即删它)—— 决定是记录自己的字段。
206
+ decision VARCHAR(16) NULL,
207
+ -- record_id(core 7.59.0):生产者铸的记录身份;NULL = 没说身份(每次 append 都是一条新记录)。盒持有一条这身份的记录时,append 经引擎的
208
+ -- judgeMailboxAppendById 判(同记录 ⇒ 答它的 seq、零写;不同 ⇒ mailbox.duplicate_record_id),判在 append 的盒行锁之内 —— 盒行 FOR UPDATE 串行了
209
+ -- 同一只盒的全部 append,「(scope, handle, id) 在持有行里至多一行」由它成立;ack / reap / drop 删行即还身份。宽 = 引擎的 MAILBOX_RECORD_ID_MAX_CHARS(列宽读那只常量,不抄数)。
210
+ record_id VARCHAR(${MAILBOX_RECORD_ID_MAX_CHARS}) NULL,
171
211
  PRIMARY KEY (scope_key, handle, seq),
172
212
  KEY idx_mbm_sent (scope_key, handle, sent_at_ms)
173
213
  ) COLLATE utf8mb4_bin`);
@@ -204,6 +244,8 @@ export async function ensurePgMailboxSchema(q) {
204
244
  sent_at_ms BIGINT NOT NULL,
205
245
  hop_chain TEXT COLLATE "C", -- core 5.17.0 design/176(TiDB twin 同注:NULL = 缺席且有语义)
206
246
  peer_meta TEXT COLLATE "C", -- S-90 core fc52b4d4 design/385 片2b(TiDB twin 同注:typed peer 记录 JSON;NULL = 缺席且有语义)
247
+ decision VARCHAR(16) COLLATE "C", -- core 7.57.0 #972b(TiDB twin 同注:留置决定,NULL = 从未 held;只经 moveDecision 移动,随记录亡)
248
+ record_id VARCHAR(${MAILBOX_RECORD_ID_MAX_CHARS}) COLLATE "C", -- core 7.59.0(TiDB twin 同注:生产者铸的记录身份,NULL = 没说身份;盒行锁之内经引擎判,随记录亡)
207
249
  PRIMARY KEY (scope_key, handle, seq)
208
250
  )`);
209
251
  await q(`CREATE TABLE IF NOT EXISTS ${MAILBOX_TOMBSTONE_TABLE} (
@@ -242,8 +284,9 @@ export class TiDBMailboxStore {
242
284
  async append(scope, handle, msg) {
243
285
  assertMailboxKey("scope", scope);
244
286
  assertMailboxKey("handle", handle);
245
- const hopChain = encodeHopChain(msg.hopChain);
246
- const peerMeta = encodePeerMeta(msg.peerMeta);
287
+ const rec = appendSnapshot(msg);
288
+ const hopChain = encodeHopChain(rec.hopChain);
289
+ const peerMeta = encodePeerMeta(rec.peerMeta);
247
290
  return this.tx(async (c) => {
248
291
  await c.query(`INSERT INTO ${MAILBOX_TABLE} (scope_key, handle, scope, next_seq) VALUES (?, ?, ?, 1) ON DUPLICATE KEY UPDATE scope_key = scope_key`, [scope, handle, scope]);
249
292
  let [rows] = (await c.query(`SELECT next_seq FROM ${MAILBOX_TABLE} WHERE scope_key = ? AND handle = ? FOR UPDATE`, [scope, handle]));
@@ -254,9 +297,15 @@ export class TiDBMailboxStore {
254
297
  const [tomb] = await c.query(`SELECT 1 FROM ${MAILBOX_TOMBSTONE_TABLE} WHERE scope_key = ? AND handle = ? FOR UPDATE`, [scope, handle]);
255
298
  if (tomb.length > 0)
256
299
  throw tombstonedRecipientError(scope, handle);
300
+ if (rec.id !== undefined) {
301
+ const [held] = await c.query(`SELECT seq, from_id, content, sent_at_ms, ${jsonColumnText("tidb", "hop_chain")}, ${jsonColumnText("tidb", "peer_meta")}, decision, record_id FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = ? AND handle = ? AND record_id = ? FOR UPDATE`, [scope, handle, rec.id]);
302
+ const replay = replayedSeq(held, scope, handle, this.logger, { ...rec, content: asUtf8Stored(rec.content), ...(rec.from !== undefined ? { from: asUtf8Stored(rec.from) } : {}) });
303
+ if (replay !== undefined)
304
+ return replay;
305
+ }
257
306
  const seq = Number(rows[0].next_seq);
258
307
  await c.query(`UPDATE ${MAILBOX_TABLE} SET next_seq = ? WHERE scope_key = ? AND handle = ?`, [seq + 1, scope, handle]);
259
- await c.query(`INSERT INTO ${MAILBOX_MSG_TABLE} (scope_key, handle, seq, from_id, content, sent_at_ms, hop_chain, peer_meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [scope, handle, seq, msg.from ?? null, msg.content, msg.sentAt, hopChain, peerMeta]);
308
+ await c.query(`INSERT INTO ${MAILBOX_MSG_TABLE} (scope_key, handle, seq, from_id, content, sent_at_ms, hop_chain, peer_meta, record_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [scope, handle, seq, rec.from ?? null, rec.content, rec.sentAt, hopChain, peerMeta, rec.id ?? null]);
260
309
  return seq;
261
310
  });
262
311
  }
@@ -274,7 +323,7 @@ export class TiDBMailboxStore {
274
323
  bufToStr(box.lease_owner) !== owner) {
275
324
  return null;
276
325
  }
277
- const [msgs] = (await c.query(`SELECT seq, from_id, content, sent_at_ms, ${jsonColumnText("tidb", "hop_chain")}, ${jsonColumnText("tidb", "peer_meta")} FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = ? AND handle = ? ORDER BY seq FOR UPDATE`, [scope, handle]));
326
+ const [msgs] = (await c.query(`SELECT seq, from_id, content, sent_at_ms, ${jsonColumnText("tidb", "hop_chain")}, ${jsonColumnText("tidb", "peer_meta")}, decision, record_id FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = ? AND handle = ? ORDER BY seq FOR UPDATE`, [scope, handle]));
278
327
  if (msgs.length === 0)
279
328
  return null;
280
329
  const maxSeq = Number(msgs[msgs.length - 1].seq);
@@ -295,6 +344,22 @@ export class TiDBMailboxStore {
295
344
  return { acked: true };
296
345
  });
297
346
  }
347
+ async moveDecision(scope, handle, seq, expect, next) {
348
+ assertMailboxDecisionMove(seq, expect, next);
349
+ if (keyless(scope, handle))
350
+ return { ok: false, current: "gone" };
351
+ return this.tx(async (c) => {
352
+ const [boxes] = await c.query(`SELECT 1 FROM ${MAILBOX_TABLE} WHERE scope_key = ? AND handle = ? FOR UPDATE`, [scope, handle]);
353
+ if (boxes.length === 0)
354
+ return { ok: false, current: "gone" };
355
+ const [rows] = await c.query(`SELECT decision FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = ? AND handle = ? AND seq = ? FOR UPDATE`, [scope, handle, seq]);
356
+ const verdict = decisionMoveVerdict(rows, expect);
357
+ if (verdict !== "write")
358
+ return verdict;
359
+ await c.query(`UPDATE ${MAILBOX_MSG_TABLE} SET decision = ? WHERE scope_key = ? AND handle = ? AND seq = ?`, [next, scope, handle, seq]);
360
+ return { ok: true };
361
+ });
362
+ }
298
363
  async releaseLease(scope, handle, owner) {
299
364
  if (keyless(scope, handle, owner))
300
365
  return;
@@ -384,11 +449,12 @@ export class PgMailboxStore {
384
449
  async append(scope, handle, msg) {
385
450
  assertMailboxKey("scope", scope);
386
451
  assertMailboxKey("handle", handle);
387
- if (msg.from !== undefined && pgHasUnstorable(msg.from))
452
+ const rec = appendSnapshot(msg);
453
+ if (rec.from !== undefined && pgHasUnstorable(rec.from))
388
454
  throw new PgUnstorableError("mailbox from");
389
- const content = pgSanitizeText(msg.content);
390
- const hopChain = encodeHopChain(msg.hopChain);
391
- const peerMeta = encodePeerMeta(msg.peerMeta);
455
+ const content = pgSanitizeText(rec.content);
456
+ const hopChain = encodeHopChain(rec.hopChain);
457
+ const peerMeta = encodePeerMeta(rec.peerMeta);
392
458
  return this.tx(async (c) => {
393
459
  await c.query(`INSERT INTO ${MAILBOX_TABLE} (scope_key, handle, scope, next_seq) VALUES ($1, $2, $3, 1) ON CONFLICT (scope_key, handle) DO NOTHING`, [
394
460
  scope,
@@ -407,9 +473,15 @@ export class PgMailboxStore {
407
473
  const { rows: tomb } = await c.query(`SELECT 1 FROM ${MAILBOX_TOMBSTONE_TABLE} WHERE scope_key = $1 AND handle = $2 FOR UPDATE`, [scope, handle]);
408
474
  if (tomb.length > 0)
409
475
  throw tombstonedRecipientError(scope, handle);
476
+ if (rec.id !== undefined) {
477
+ const { rows: held } = await c.query(`SELECT seq, from_id, content, sent_at_ms, ${jsonColumnText("pg", "hop_chain")}, ${jsonColumnText("pg", "peer_meta")}, decision, record_id FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = $1 AND handle = $2 AND record_id = $3 FOR UPDATE`, [scope, handle, rec.id]);
478
+ const replay = replayedSeq(held, scope, handle, this.logger, { ...rec, content });
479
+ if (replay !== undefined)
480
+ return replay;
481
+ }
410
482
  const seq = Number(rows[0].next_seq);
411
483
  await c.query(`UPDATE ${MAILBOX_TABLE} SET next_seq = $1 WHERE scope_key = $2 AND handle = $3`, [seq + 1, scope, handle]);
412
- await c.query(`INSERT INTO ${MAILBOX_MSG_TABLE} (scope_key, handle, seq, from_id, content, sent_at_ms, hop_chain, peer_meta) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [scope, handle, seq, msg.from ?? null, content, msg.sentAt, hopChain, peerMeta]);
484
+ await c.query(`INSERT INTO ${MAILBOX_MSG_TABLE} (scope_key, handle, seq, from_id, content, sent_at_ms, hop_chain, peer_meta, record_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [scope, handle, seq, rec.from ?? null, content, rec.sentAt, hopChain, peerMeta, rec.id ?? null]);
413
485
  return seq;
414
486
  });
415
487
  }
@@ -424,7 +496,7 @@ export class PgMailboxStore {
424
496
  const box = boxes[0];
425
497
  if (box.lease_owner !== null && Number(box.lease_expires_at_ms) > now && String(box.lease_owner) !== owner)
426
498
  return null;
427
- const { rows: msgs } = await c.query(`SELECT seq, from_id, content, sent_at_ms, ${jsonColumnText("pg", "hop_chain")}, ${jsonColumnText("pg", "peer_meta")} FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = $1 AND handle = $2 ORDER BY seq FOR UPDATE`, [scope, handle]);
499
+ const { rows: msgs } = await c.query(`SELECT seq, from_id, content, sent_at_ms, ${jsonColumnText("pg", "hop_chain")}, ${jsonColumnText("pg", "peer_meta")}, decision, record_id FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = $1 AND handle = $2 ORDER BY seq FOR UPDATE`, [scope, handle]);
428
500
  if (msgs.length === 0)
429
501
  return null;
430
502
  const maxSeq = Number(msgs[msgs.length - 1].seq);
@@ -445,6 +517,22 @@ export class PgMailboxStore {
445
517
  return { acked: true };
446
518
  });
447
519
  }
520
+ async moveDecision(scope, handle, seq, expect, next) {
521
+ assertMailboxDecisionMove(seq, expect, next);
522
+ if (keyless(scope, handle))
523
+ return { ok: false, current: "gone" };
524
+ return this.tx(async (c) => {
525
+ const { rows: boxes } = await c.query(`SELECT 1 FROM ${MAILBOX_TABLE} WHERE scope_key = $1 AND handle = $2 FOR UPDATE`, [scope, handle]);
526
+ if (boxes.length === 0)
527
+ return { ok: false, current: "gone" };
528
+ const { rows } = await c.query(`SELECT decision FROM ${MAILBOX_MSG_TABLE} WHERE scope_key = $1 AND handle = $2 AND seq = $3 FOR UPDATE`, [scope, handle, seq]);
529
+ const verdict = decisionMoveVerdict(rows, expect);
530
+ if (verdict !== "write")
531
+ return verdict;
532
+ await c.query(`UPDATE ${MAILBOX_MSG_TABLE} SET decision = $1 WHERE scope_key = $2 AND handle = $3 AND seq = $4`, [next, scope, handle, seq]);
533
+ return { ok: true };
534
+ });
535
+ }
448
536
  async releaseLease(scope, handle, owner) {
449
537
  if (keyless(scope, handle, owner))
450
538
  return;
@@ -43,6 +43,7 @@ export class MemoryRunStore {
43
43
  errorCode: r.errorCode,
44
44
  jobId: r.jobId,
45
45
  source: r.source,
46
+ identity: r.identity,
46
47
  instanceId: r.instanceId,
47
48
  cancelRequested: r.cancelRequested,
48
49
  objectivePreview: r.objectivePreview,
@@ -75,6 +76,7 @@ export class MemoryRunStore {
75
76
  objectivePreview: meta.objectivePreview ?? null,
76
77
  idemKey,
77
78
  idemBodyFingerprint: meta.idem?.bodyFingerprint ?? null,
79
+ identity: meta.identity ?? null,
78
80
  createdAt: now,
79
81
  updatedAt: now,
80
82
  cancelRequested: false,