@sema-agent/server 7.125.1 → 7.127.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 (275) hide show
  1. package/MIGRATION.md +229 -2
  2. package/README.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/USAGE.md +346 -145
  5. package/deploy/sema-up/README.md +1 -1
  6. package/deploy/sema-up/chart/templates/networkpolicy-sandbox.yaml +57 -0
  7. package/deploy/sema-up/chart/templates/sandbox-rbac.yaml +32 -1
  8. package/deploy/sema-up/chart/templates/server-v4pro.yaml +3 -2
  9. package/deploy/sema-up/chart/templates/server.yaml +2 -1
  10. package/deploy/sema-up/chart/values.yaml +22 -0
  11. package/deploy/sema-up/cluster-up.sh +10 -10
  12. package/deploy/sema-up/kube-up.sh +20 -13
  13. package/deploy/sema-up/lib/dataroot.sh +1 -1
  14. package/deploy/sema-up/lib/envf.sh +5 -5
  15. package/deploy/sema-up/lib/flagshape.sh +5 -5
  16. package/deploy/sema-up/lib/lanes.py +1 -1
  17. package/deploy/sema-up/lib/node-addrs.sh +42 -0
  18. package/deploy/sema-up/lib/parse-machines.py +2 -2
  19. package/deploy/sema-up/lib/shell-access.sh +1 -1
  20. package/deploy/sema-up/preflight.sh +4 -4
  21. package/deploy/sema-up/sema-up.sh +33 -33
  22. package/deploy/sema-up/smoke.sh +3 -3
  23. package/deploy/sema-up/stack/compose.yaml +1 -1
  24. package/deploy/sema-up/stack/sema-stack-cluster.yaml +26 -3
  25. package/dist/approval-card.d.ts +20 -4
  26. package/dist/approval-content-kind.d.ts +38 -22
  27. package/dist/approval-content-kind.js +20 -4
  28. package/dist/audit.js +3 -0
  29. package/dist/bench/s1/arms.d.ts +9 -37
  30. package/dist/bench/s1/arms.js +4 -61
  31. package/dist/bench/s1/live-deps.d.ts +4 -10
  32. package/dist/bench/s1/live-deps.js +1 -222
  33. package/dist/bench/s1/oracle.d.ts +1 -1
  34. package/dist/bench/s1/reviewer.d.ts +1 -1
  35. package/dist/bench/s1/row.d.ts +5 -7
  36. package/dist/bench/s1/row.js +0 -2
  37. package/dist/bench/s1/run-firm.d.ts +2 -2
  38. package/dist/bench/s1/run-firm.js +1 -4
  39. package/dist/bench/s1/runner-ctx.d.ts +17 -29
  40. package/dist/bench/s1/runner-ctx.js +3 -7
  41. package/dist/bench/s1/tasks.d.ts +9 -46
  42. package/dist/bench/s1/tasks.js +4 -63
  43. package/dist/boot/execution-env.d.ts +38 -26
  44. package/dist/boot/execution-env.js +87 -41
  45. package/dist/boot/leader.d.ts +2 -1
  46. package/dist/boot/memory-boundary.d.ts +1 -1
  47. package/dist/boot/memory-consolidation.d.ts +37 -3
  48. package/dist/boot/memory-consolidation.js +18 -2
  49. package/dist/boot/parked-revive-gate.d.ts +0 -3
  50. package/dist/boot/parked-revive-gate.js +2 -2
  51. package/dist/boot/resolve-spec.d.ts +0 -2
  52. package/dist/boot/resolve-spec.js +12 -17
  53. package/dist/boot/retention-lane.d.ts +12 -1
  54. package/dist/boot/retention-lane.js +2 -0
  55. package/dist/boot/runner-deps.d.ts +7 -6
  56. package/dist/boot/runner-deps.js +5 -16
  57. package/dist/boot/session-faces.d.ts +7 -2
  58. package/dist/boot/session-faces.js +37 -1
  59. package/dist/boot/stage-03-store-backend.d.ts +1 -0
  60. package/dist/boot/stage-03-store-backend.js +2 -2
  61. package/dist/boot/stage-04-stores.d.ts +1 -0
  62. package/dist/boot/stage-05-execution-env.d.ts +2 -2
  63. package/dist/boot/stage-05-execution-env.js +7 -4
  64. package/dist/boot/stage-06-runners.d.ts +2 -3
  65. package/dist/boot/stage-06-runners.js +13 -9
  66. package/dist/boot/stage-07-capability-layer.d.ts +2 -3
  67. package/dist/boot/stage-07-capability-layer.js +3 -4
  68. package/dist/boot/stage-08-reapers.d.ts +2 -3
  69. package/dist/boot/stage-08-reapers.js +2 -2
  70. package/dist/boot/stage-09-leader.d.ts +2 -3
  71. package/dist/boot/stage-09-leader.js +2 -1
  72. package/dist/boot/stage-10-http-server.d.ts +2 -3
  73. package/dist/boot/stage-10-http-server.js +2 -2
  74. package/dist/boot/stores.d.ts +1 -0
  75. package/dist/boot/stores.js +4 -3
  76. package/dist/boot/web-admin.d.ts +5 -0
  77. package/dist/boot/web-admin.js +28 -14
  78. package/dist/brain.d.ts +2 -2
  79. package/dist/brain.js +1 -1
  80. package/dist/capabilities/builtin-tools.d.ts +2 -1
  81. package/dist/capabilities/composite-members.d.ts +1 -1
  82. package/dist/capabilities/composite-members.js +1 -1
  83. package/dist/capabilities/hands-lane.d.ts +9 -6
  84. package/dist/capabilities/hands-lane.js +2 -2
  85. package/dist/capabilities/memory-notice.d.ts +27 -22
  86. package/dist/capabilities/memory-notice.js +15 -17
  87. package/dist/capabilities/scenarios.d.ts +9 -14
  88. package/dist/capabilities/scenarios.js +5 -50
  89. package/dist/capability-facts.d.ts +22 -11
  90. package/dist/capability-facts.js +13 -6
  91. package/dist/config-catalog.js +14 -5
  92. package/dist/config-provider.d.ts +5 -1
  93. package/dist/config-provider.js +23 -4
  94. package/dist/config-types.d.ts +19 -2
  95. package/dist/config.js +41 -6
  96. package/dist/deployment-governance.d.ts +19 -21
  97. package/dist/deployment-governance.js +7 -8
  98. package/dist/env-name-allowlist-knobs.d.ts +5 -5
  99. package/dist/env-name-allowlist-knobs.js +1 -0
  100. package/dist/execution-lane-caps.d.ts +12 -0
  101. package/dist/execution-lane-caps.js +18 -4
  102. package/dist/fleet/subagent-tail-bus.js +15 -5
  103. package/dist/hooks/hook-runner.d.ts +84 -30
  104. package/dist/hooks/hook-runner.js +74 -41
  105. package/dist/http/admission.js +17 -10
  106. package/dist/http/durable-run-leg.d.ts +5 -4
  107. package/dist/http/durable-run-leg.js +2 -4
  108. package/dist/http/idempotency.d.ts +28 -6
  109. package/dist/http/idempotency.js +10 -2
  110. package/dist/http/principal-gate.d.ts +14 -0
  111. package/dist/http/principal-gate.js +7 -0
  112. package/dist/http/request-guards.d.ts +2 -1
  113. package/dist/http/request-guards.js +14 -14
  114. package/dist/http/resume-legs.d.ts +2 -2
  115. package/dist/http/resume-legs.js +20 -10
  116. package/dist/http/route-ctx.d.ts +15 -7
  117. package/dist/http/routes/a2a-serve.js +11 -7
  118. package/dist/http/routes/approvals-assistant.js +18 -3
  119. package/dist/http/routes/background.d.ts +1 -1
  120. package/dist/http/routes/background.js +1 -1
  121. package/dist/http/routes/capabilities.js +10 -2
  122. package/dist/http/routes/mcp-reconnect.js +1 -1
  123. package/dist/http/routes/runs.d.ts +4 -3
  124. package/dist/http/routes/runs.js +35 -14
  125. package/dist/http/routes/session-sync.js +14 -11
  126. package/dist/http/routes/sessions.js +9 -9
  127. package/dist/http/routes/side-query.js +6 -19
  128. package/dist/http/routes/tasks.js +29 -19
  129. package/dist/http/routes/workflows.js +8 -0
  130. package/dist/http/server.d.ts +4 -0
  131. package/dist/http/server.js +3 -5
  132. package/dist/http/submit-replay.d.ts +15 -8
  133. package/dist/http/submit-replay.js +6 -0
  134. package/dist/http/wire-types.d.ts +7 -9
  135. package/dist/index.d.ts +1 -1
  136. package/dist/index.js +1 -1
  137. package/dist/internal-run-env.d.ts +30 -0
  138. package/dist/internal-run-env.js +11 -0
  139. package/dist/leader/wire.js +3 -2
  140. package/dist/lsp/manager.js +1 -1
  141. package/dist/memory-scope.d.ts +19 -7
  142. package/dist/memory-scope.js +25 -8
  143. package/dist/observability/fail-open.d.ts +31 -3
  144. package/dist/observability/fail-open.js +31 -3
  145. package/dist/observability/metrics.d.ts +0 -1
  146. package/dist/observability/metrics.js +1 -4
  147. package/dist/observability/run-terminal.js +72 -4
  148. package/dist/permission-rule-vocab.d.ts +3 -2
  149. package/dist/permission-rule-vocab.js +1 -1
  150. package/dist/plan-cache-probe.d.ts +3 -2
  151. package/dist/plugins/approval-ask-store-sql.d.ts +1 -1
  152. package/dist/plugins/caching-session-store.d.ts +42 -22
  153. package/dist/plugins/caching-session-store.js +19 -9
  154. package/dist/plugins/checkpoint-store-sql.d.ts +42 -28
  155. package/dist/plugins/checkpoint-store-sql.js +57 -11
  156. package/dist/plugins/file-run-store.d.ts +25 -21
  157. package/dist/plugins/file-run-store.js +70 -34
  158. package/dist/plugins/k8s-exec-protocol.js +3 -0
  159. package/dist/plugins/local-checkpoint-store.d.ts +8 -5
  160. package/dist/plugins/local-checkpoint-store.js +5 -15
  161. package/dist/plugins/local-session-store.d.ts +66 -7
  162. package/dist/plugins/local-session-store.js +168 -101
  163. package/dist/plugins/mailbox-recipient-lifecycle.d.ts +1 -1
  164. package/dist/plugins/memory-run-store.d.ts +19 -3
  165. package/dist/plugins/memory-run-store.js +38 -3
  166. package/dist/plugins/one-time-migrations.d.ts +165 -39
  167. package/dist/plugins/one-time-migrations.js +51 -144
  168. package/dist/plugins/pg-pool.js +11 -1
  169. package/dist/plugins/pg-session-storage.d.ts +2 -0
  170. package/dist/plugins/pg-session-storage.js +9 -5
  171. package/dist/plugins/remote-env-adb.d.ts +3 -0
  172. package/dist/plugins/remote-env-adb.js +1 -0
  173. package/dist/plugins/remote-env-device.d.ts +3 -0
  174. package/dist/plugins/remote-env-device.js +1 -0
  175. package/dist/plugins/remote-env-e2b.d.ts +4 -0
  176. package/dist/plugins/remote-env-e2b.js +2 -1
  177. package/dist/plugins/remote-env-host.d.ts +23 -1
  178. package/dist/plugins/remote-env-host.js +20 -2
  179. package/dist/plugins/remote-env-k8s.d.ts +264 -41
  180. package/dist/plugins/remote-env-k8s.js +551 -82
  181. package/dist/plugins/remote-env-local-docker.d.ts +4 -0
  182. package/dist/plugins/remote-env-local-docker.js +2 -1
  183. package/dist/plugins/remote-env-ssh.d.ts +3 -0
  184. package/dist/plugins/remote-env-ssh.js +1 -0
  185. package/dist/plugins/retention-store-sql.d.ts +4 -1
  186. package/dist/plugins/retention-store-sql.js +11 -9
  187. package/dist/plugins/run-store-sql.d.ts +44 -6
  188. package/dist/plugins/run-store-sql.js +92 -27
  189. package/dist/plugins/session-capture-record-store-sql.d.ts +2 -3
  190. package/dist/plugins/session-store.d.ts +4 -3
  191. package/dist/plugins/session-store.js +1 -1
  192. package/dist/plugins/sql-driver.d.ts +2 -1
  193. package/dist/plugins/store-backend.d.ts +12 -5
  194. package/dist/plugins/store-backend.js +13 -4
  195. package/dist/plugins/store-contracts.d.ts +20 -3
  196. package/dist/plugins/store-open-wait.js +3 -0
  197. package/dist/plugins/tidb-pool.js +15 -3
  198. package/dist/plugins/tidb-session-storage.js +3 -2
  199. package/dist/plugins/tidb-session-store.d.ts +2 -0
  200. package/dist/plugins/tidb-session-store.js +6 -2
  201. package/dist/request-key-closure.d.ts +32 -7
  202. package/dist/request-key-closure.js +25 -9
  203. package/dist/router/route-orchestration.d.ts +3 -11
  204. package/dist/router/route-orchestration.js +0 -4
  205. package/dist/rule-entry-list.js +0 -4
  206. package/dist/rules-consent.js +3 -0
  207. package/dist/run-acceptance.d.ts +51 -0
  208. package/dist/run-acceptance.js +27 -0
  209. package/dist/run-local.d.ts +5 -4
  210. package/dist/run-local.js +23 -17
  211. package/dist/runs.d.ts +7 -11
  212. package/dist/runs.js +6 -16
  213. package/dist/sandbox-egress-policy.d.ts +61 -0
  214. package/dist/sandbox-egress-policy.js +235 -0
  215. package/dist/security.d.ts +7 -0
  216. package/dist/security.js +5 -0
  217. package/dist/server-secret-env.d.ts +10 -4
  218. package/dist/server-secret-env.js +4 -1
  219. package/dist/session-error-wire.d.ts +2 -2
  220. package/dist/session-incarnation.d.ts +46 -2
  221. package/dist/session-incarnation.js +14 -3
  222. package/dist/session-sync-kernel.d.ts +10 -3
  223. package/dist/session-titler.d.ts +5 -3
  224. package/dist/spec-fields.d.ts +24 -8
  225. package/dist/spec-fields.js +21 -2
  226. package/dist/task-workflow.d.ts +6 -4
  227. package/dist/task-workflow.js +3 -2
  228. package/dist/tool-approval.d.ts +78 -6
  229. package/dist/tool-approval.js +7 -3
  230. package/dist/trace/core-keyset-guard.d.ts +6 -5
  231. package/dist/trace/engine-notice-wire.d.ts +26 -1
  232. package/dist/trace/engine-notice-wire.js +7 -2
  233. package/dist/trace/ledger-events.d.ts +9 -3
  234. package/dist/trace/ledger-sink.d.ts +7 -0
  235. package/dist/trace/ledger-sink.js +14 -4
  236. package/dist/trace/project.d.ts +57 -11
  237. package/dist/trace/project.js +54 -8
  238. package/dist/trace/self-minted-wire.d.ts +94 -2
  239. package/dist/trace/self-minted-wire.js +28 -0
  240. package/dist/trace/tool-output-media.d.ts +26 -0
  241. package/dist/trace/tool-output-media.js +57 -0
  242. package/dist/trace/wire-projection-faces.d.ts +7 -1
  243. package/dist/trace/wire-projection-faces.js +2 -1
  244. package/dist/web-admin-client/apply-effective.d.ts +13 -7
  245. package/dist/web-admin-client/apply-effective.js +11 -26
  246. package/dist/web-admin-client/apply-ledger.d.ts +2 -2
  247. package/dist/web-admin-client/candidate-gate.d.ts +39 -4
  248. package/dist/web-admin-client/candidate-gate.js +9 -2
  249. package/dist/web-admin-client/facade.d.ts +2 -2
  250. package/dist/web-admin-client/http-client.d.ts +3 -1
  251. package/dist/web-admin-client/http-client.js +4 -3
  252. package/dist/web-admin-client/read-warnings.d.ts +33 -5
  253. package/dist/web-admin-client/read-warnings.js +41 -6
  254. package/dist/web-admin-client/types.d.ts +11 -5
  255. package/dist/workspace-cache.d.ts +45 -0
  256. package/dist/workspace-cache.js +30 -0
  257. package/dist/workspace-space-commit.d.ts +116 -0
  258. package/dist/workspace-space-commit.js +117 -0
  259. package/dist/workspace-space.d.ts +75 -9
  260. package/dist/workspace-space.js +52 -1
  261. package/package.json +4 -4
  262. package/dist/boot/session-incarnation-seat.d.ts +0 -41
  263. package/dist/boot/session-incarnation-seat.js +0 -69
  264. package/dist/capabilities/code-review-council.d.ts +0 -49
  265. package/dist/capabilities/code-review-council.js +0 -111
  266. package/dist/capabilities/collab-wire.d.ts +0 -36
  267. package/dist/capabilities/collab-wire.js +0 -2
  268. package/dist/capabilities/collab-workflows.d.ts +0 -47
  269. package/dist/capabilities/collab-workflows.js +0 -90
  270. package/dist/capabilities/prompts/code-review.d.ts +0 -25
  271. package/dist/capabilities/prompts/code-review.js +0 -39
  272. package/dist/capabilities/prompts/team.d.ts +0 -10
  273. package/dist/capabilities/prompts/team.js +0 -6
  274. package/dist/capabilities/team.d.ts +0 -113
  275. package/dist/capabilities/team.js +0 -70
@@ -73,11 +73,15 @@ export declare class RemoteLocalDockerExecutionEnv implements RemoteExecutionEnv
73
73
  readonly capabilities: {
74
74
  isolation: boolean;
75
75
  suspendable: boolean;
76
+ canonicalPathAuthoritative: boolean;
76
77
  };
77
78
  /** Working directory inside the container; relative paths resolve against it (ExecutionEnv contract). */
78
79
  cwd: string;
79
80
  /** core `ExecutionEnv.homeDir`([ref]):本沙箱/目标机上执行用户的 home。见配置同名字段。 */
80
81
  readonly homeDir?: string | undefined;
82
+ /** core 7.49.0 #1554:shell 子进程真拿到的 `CDPATH` 条目 —— 本车道的命令跑在容器里(镜像的 env),本类不读那份 env ⇒ `"unknown"`(core 契约:说不出就说 unknown,
83
+ * 不许报一张没读过的空表;删除守卫因此对裸 `cd <相对名>` 之后的删除问人,而不是按「没有 CDPATH」放行)。 */
84
+ readonly cdPath: "unknown";
81
85
  private readonly cfg;
82
86
  private readonly spawnFn;
83
87
  /** Stable container NAME (also our handle id). Created on connect; targeted by every docker exec/cp/rm. */
@@ -18,9 +18,10 @@ const unsupported = (op) => ({
18
18
  error: new RemoteExecutionError("unsupported", `${op} is not supported on a local-docker target (v1: a container is an isolation boundary but not a managed memory snapshot; capabilities.suspendable=false)`),
19
19
  });
20
20
  export class RemoteLocalDockerExecutionEnv {
21
- capabilities = { isolation: true, suspendable: false };
21
+ capabilities = { isolation: true, suspendable: false, canonicalPathAuthoritative: true };
22
22
  cwd;
23
23
  homeDir;
24
+ cdPath = "unknown";
24
25
  cfg;
25
26
  spawnFn;
26
27
  containerName;
@@ -94,6 +94,9 @@ export declare class RemoteSshExecutionEnv implements RemoteExecutionEnv {
94
94
  private inFlight?;
95
95
  /** core `ExecutionEnv.homeDir`([ref]):本沙箱/目标机上执行用户的 home。见配置同名字段。 */
96
96
  readonly homeDir?: string | undefined;
97
+ /** core 7.49.0 #1554:shell 子进程真拿到的 `CDPATH` 条目 —— 本车道的命令跑在远端登录 shell 里(目标机的 profile / env),本类不读那份 env ⇒ `"unknown"`(core 契约:说不出就说 unknown,
98
+ * 不许报一张没读过的空表;删除守卫因此对裸 `cd <相对名>` 之后的删除问人,而不是按「没有 CDPATH」放行)。 */
99
+ readonly cdPath: "unknown";
97
100
  private readonly cfg;
98
101
  private readonly injectedConnectOnce?;
99
102
  constructor(config: SshEnvConfig, deps?: SshEnvDeps);
@@ -24,6 +24,7 @@ export class RemoteSshExecutionEnv {
24
24
  connEpoch = 0;
25
25
  inFlight;
26
26
  homeDir;
27
+ cdPath = "unknown";
27
28
  cfg;
28
29
  injectedConnectOnce;
29
30
  constructor(config, deps) {
@@ -50,6 +50,7 @@ import type { Pool as MySqlPool } from "mysql2/promise";
50
50
  import type { Pool as PgPool } from "pg";
51
51
  import { type SqlDriver, type SqlTxConn } from "./sql-driver.js";
52
52
  import { type IndexSpec } from "./ensure-index.js";
53
+ import type { EndedSpace } from "../workspace-space.js";
53
54
  /**
54
55
  * 三只**被托管**的 SQL 店(session / checkpoint / tool-result)共用的 `retention` 声明常量。
55
56
  *
@@ -419,7 +420,9 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
419
420
  * ——后者跨十余张表、随部署形态漂(附件表可能不在场),做不成稳定判据;而「幂等重跑 ⇒ 0」这条契约在
420
421
  * 两种口径下同真。`tombstones` = 本次**新落地**的墓碑数(重跑时既有墓碑不重复计)。
421
422
  */
422
- deleteExpiredSessions(input: RetentionCallInput): Promise<RetentionReceipt>;
423
+ deleteExpiredSessions(input: RetentionCallInput): Promise<RetentionReceipt & {
424
+ readonly endedSpaces?: readonly EndedSpace[];
425
+ }>;
423
426
  /**
424
427
  * E21 顺序契约的**事务化搬入**:同一组 DELETE、同一个顺序,一次性发在一条事务连接上。
425
428
  * 顺序的两条承重理由(其余是可读性):
@@ -68,7 +68,7 @@ export const TIDB_RETENTION_STATEMENTS = [
68
68
  row_key VARCHAR(518) NOT NULL,
69
69
  deleted_at_ms BIGINT NOT NULL,
70
70
  -- 🔴 主键**带 domain**(codex 对抗复审 R3-[high],验真后改):行键是 sessionId / tool_result.ref,而
71
- -- 会话 id 是**调用方可自选**的(server.ts 逐字:「a client MAY supply an ARBITRARY id … LENGTH-ONLY」)
71
+ -- 会话 id 是**调用方提供**的(7.125.x 及更早任意 ≤64 字符串,7.126.0 起 canonical uuid —— 调用方仍可挑任意 uuid)
72
72
  -- ⇒ 两个租户先后用同一个 id 完全合法。旧形 PK(kind,row_key) 全局唯一,后果有两条,都不轻:
73
73
  -- ① 租户 B 删同名会话时墓碑 INSERT IGNORE 空转 ⇒ 回执 deleted=1 而 tombstones=0(账对不上),
74
74
  -- 且 B 的域从此**枚举不到**它的孤儿(F4 的病灶原样复发);
@@ -222,7 +222,7 @@ export class SqlRetentionStore {
222
222
  async previewRetention(input) {
223
223
  const [checkpoints, sessions, toolResults] = await Promise.all([
224
224
  this.expiryCandidates(this.db, input).then((tokens) => tokens.length),
225
- this.sessionCandidates(this.db, input).then((ids) => ids.length),
225
+ this.sessionCandidates(this.db, input).then((rows) => rows.length),
226
226
  this.countOrphanToolResultCandidates(this.db, input),
227
227
  ]);
228
228
  return { checkpoints, sessions, toolResults };
@@ -299,10 +299,10 @@ export class SqlRetentionStore {
299
299
  async sessionCandidates(exec, input, forUpdate = false) {
300
300
  const w = this.domainWhere("sm.owner", input.domain, 1);
301
301
  const suffix = forUpdate ? " FOR UPDATE" : "";
302
- const { rows } = await exec.query(this.q(`SELECT sm.session_id FROM session_meta sm WHERE ${w.sql} AND sm.updated_at < ? AND NOT ${this.liveReferenceExists()} ` +
303
- `ORDER BY sm.updated_at ASC LIMIT ?${suffix}`, `SELECT sm.session_id FROM session_meta sm WHERE ${w.sql} AND sm.updated_at < $2 AND NOT ${this.liveReferenceExists()} ` +
302
+ const { rows } = await exec.query(this.q(`SELECT sm.session_id, sm.incarnation FROM session_meta sm WHERE ${w.sql} AND sm.updated_at < ? AND NOT ${this.liveReferenceExists()} ` +
303
+ `ORDER BY sm.updated_at ASC LIMIT ?${suffix}`, `SELECT sm.session_id, sm.incarnation FROM session_meta sm WHERE ${w.sql} AND sm.updated_at < $2 AND NOT ${this.liveReferenceExists()} ` +
304
304
  `ORDER BY sm.updated_at ASC LIMIT $3${suffix}`), [...w.params, new Date(input.cutoffMs), RETENTION_SESSION_BATCH]);
305
- return rows.map((r) => String(r.session_id));
305
+ return rows.map((r) => ({ id: String(r.session_id), incarnation: typeof r.incarnation === "string" ? r.incarnation : "" }));
306
306
  }
307
307
  async countOrphanToolResultCandidates(exec, input) {
308
308
  return (await this.orphanToolResultCandidates(exec, input)).length;
@@ -331,12 +331,14 @@ export class SqlRetentionStore {
331
331
  skippedOnAbort = doomed.length + skipped;
332
332
  let deleted = 0;
333
333
  let tombstones = 0;
334
- if (doomed.length > 0) {
335
- deleted = await this.purgeSessionTrees(conn, input.domain, doomed);
336
- tombstones = await this.writeTombstones(conn, input.domain, "session", doomed);
334
+ const ids = doomed.map((d) => d.id);
335
+ if (ids.length > 0) {
336
+ deleted = await this.purgeSessionTrees(conn, input.domain, ids);
337
+ tombstones = await this.writeTombstones(conn, input.domain, "session", ids);
337
338
  }
338
339
  await this.writeAuditRow(conn, { domain: input.domain, action: "deleted_sessions", audit, deleted, skipped, tombstones });
339
- return { domain: input.domain, deleted, skipped, tombstones };
340
+ const endedSpaces = doomed.filter((d) => d.incarnation !== "").map((d) => ({ sessionId: d.id, spaceKey: d.incarnation }));
341
+ return { domain: input.domain, deleted, skipped, tombstones, ...(endedSpaces.length > 0 ? { endedSpaces } : {}) };
340
342
  });
341
343
  }
342
344
  catch (err) {
@@ -2,9 +2,10 @@ import type { TaskStatus } from "@sema-agent/core";
2
2
  import type { Pool as MySqlPool } from "mysql2/promise";
3
3
  import type { Pool as PgPool } from "pg";
4
4
  import { type UsageRow } from "../usage-analytics.js";
5
- import { type CreateRunOutcome, type NewRunMeta, type RunRecord, type SessionSummary, type RunEvent, type PersistedTaskResult, type LatestRunRef } from "./store-contracts.js";
5
+ import { type CreateRunOutcome, type NewRunMeta, type RunIdemBinding, type RunRecord, type SessionSummary, type RunEvent, type PersistedTaskResult, type LatestRunRef } from "./store-contracts.js";
6
6
  import { type SqlDriver } from "./sql-driver.js";
7
7
  import type { LedgerEventType } from "../trace/ledger-events.js";
8
+ import { type SpaceCommitClaim, type SpaceCommitReading, type SpaceCommitState } from "../workspace-space-commit.js";
8
9
  export type { RunRecord, SessionSummary, RunEvent, LatestRunRef } from "./store-contracts.js";
9
10
  export declare class SqlRunStore {
10
11
  protected readonly db: SqlDriver;
@@ -30,7 +31,8 @@ export declare class SqlRunStore {
30
31
  *
31
32
  * Submission idempotency rides the SAME mechanism on the row itself: `task_run` carries two unique keys a
32
33
  * submission can name — `task_id` (a caller-minted `body.taskId`) and `idem_key` (the scoped
33
- * `Idempotency-Key`; NULL = none, which neither dialect's UNIQUE constrains). Claim + row are ONE
34
+ * `Idempotency-Key`; NULL = none, which neither dialect's UNIQUE constrains) — and, beside `idem_key`, the fingerprint
35
+ * of the request that bound it (`idem_body_fingerprint`; compared by the submit surface, never here). Claim + row are ONE
34
36
  * transaction, so a concurrent same-key submit on another replica either commits first (we collide on the
35
37
  * unique index — after its commit, PG/InnoDB/TiDB-pessimistic block on the uncommitted entry; TiDB-optimistic
36
38
  * reports it at COMMIT, which is why the commit sits inside the same classification) or loses to us.
@@ -84,6 +86,35 @@ export declare class SqlRunStore {
84
86
  * blocked/timeout terminals — a first write of those could still be overwritten. The claim DELETE stays
85
87
  * unconditional (idempotent; the first writer already released it). */
86
88
  setTerminal(taskId: string, status: TaskStatus, result: PersistedTaskResult | null, error: string | null): Promise<void>;
89
+ /** The space's row, or `undefined`. */
90
+ readSpaceCommit(spaceKey: string): Promise<SpaceCommitReading | undefined>;
91
+ /** Make the space's row this run's (its own transaction; the terminal write runs the same step inside its own). */
92
+ claimSpaceCommit(spaceKey: string, taskId: string, state: SpaceCommitState, claim: SpaceCommitClaim): Promise<boolean>;
93
+ /** Delete this run's row (never another run's). */
94
+ endSpaceCommit(spaceKey: string, taskId: string): Promise<void>;
95
+ /** Abandon run `taskId`'s commit (the reader's write, `workspace-space-commit.ts`): one transaction — lock the row, the
96
+ * clock read under that lock, the shared rule, then a DELETE of that run's row. A writer's renewal in flight holds the
97
+ * same lock, so the abandon judges the row it committed. */
98
+ abandonSpaceCommit(spaceKey: string, taskId: string): Promise<boolean>;
99
+ /** Dual-use read helper: pool-level plain read, or (`forUpdate`) the locking read inside a transaction. The row's
100
+ * deadline, THEN the database clock on the same executor (`sqlDbNowMs`): the MySQL protocol's `NOW()` is its statement's
101
+ * start, which a lock wait leaves behind — a deadline judged inside the locking statement would call a row that died
102
+ * while this claim waited for its lock alive, and the claim would revive it. */
103
+ private readSpaceCommitIn;
104
+ /**
105
+ * One claim inside a transaction: the row made to exist when this claim may create it, a locking read, the shared
106
+ * decision, one conditional write (the deadline = db now + THIS run's `claim.boundMs`).
107
+ * Lock shape — never a locking read of a key that may be ABSENT followed by an insert: on InnoDB (REPEATABLE READ) a
108
+ * `FOR UPDATE` that finds no row takes a GAP lock, and this table holds only in-flight commits (mostly empty ⇒ every
109
+ * space key falls into one gap), so two terminal transactions of DIFFERENT spaces would each hold that gap and wait on
110
+ * the other's insert ⇒ `ER_LOCK_DEADLOCK` rolls back a terminal write. So a claim that may create the row
111
+ * (`!requireOwn`: {@link decideSpaceCommitClaim}'s `insert` on an absent row) creates it FIRST — form A of the lock-shape
112
+ * gate: the insert, or an exclusive lock on the row already there (MySQL `ON DUPLICATE KEY UPDATE pk = pk`, PG
113
+ * `ON CONFLICT … DO UPDATE` — never a gap lock, never a shared lock to upgrade) — and only then reads it under that lock.
114
+ * A claim that may not create it (`requireOwn`) only reads: its own short transaction, and a gap lock it may take is
115
+ * never followed by an insert.
116
+ */
117
+ private claimSpaceCommitIn;
87
118
  /**
88
119
  * Durable F4 ([ref]): a task suspended on a policy `ask`. Sets the run NON-terminal `suspended` and
89
120
  * KEEPS the `task_active` claim — the session stays locked (no new run) until an operator resumes it to a
@@ -143,11 +174,18 @@ export declare class SqlRunStore {
143
174
  /** The run a submission's `Idempotency-Key` names (`idem_key` = the scoped key, `idemKeyOf(raw, caller, "run")`), if any.
144
175
  * `uq_task_run_idem` makes it at most one row. */
145
176
  getRunByIdem(idemKey: string): Promise<RunRecord | undefined>;
146
- /** Undo THIS submission's key binding (`idem_key` → NULL) — called only where the submit surface refuses the submission
147
- * before any work and terminalizes the row it just claimed (the sync leg's zero-work refusal, a device-placement reject):
148
- * the error answer invites a retry, and a retry with the same key must run, not replay the refusal. The row stays (it
149
- * is the refusal's record); only the key is freed. */
177
+ /** Undo THIS submission's binding (`idem_key` + `idem_body_fingerprint` → NULL) — called only where the submit surface refuses
178
+ * the submission before any work and terminalizes the row it just claimed (the sync leg's zero-work refusal, a device-placement
179
+ * reject): the error answer invites a retry, and a retry with the same key must run, not replay the refusal. The row stays (it
180
+ * is the refusal's record); only the binding is freed. */
150
181
  releaseIdemKey(taskId: string): Promise<void>;
182
+ /** The body fingerprint this row's binding of `idem.key` holds — binding `idem.bodyFingerprint` first when it holds none (a key
183
+ * bound before the column existed): first arrival binds, everyone after reads. The fingerprint belongs to the KEY that bound
184
+ * the row, so a row this key is not bound to (hit by its `task_id` under another key, or made without a key) is never written
185
+ * and answers `null` — as does a missing row. One conditional UPDATE (atomic on the row on both dialects: a concurrent binder
186
+ * re-evaluates `IS NULL` after the winner commits and matches nothing), then a read of what the binding holds. The comparison
187
+ * is the submit surface's (`http/submit-replay.ts`). */
188
+ bindIdemBodyFingerprint(taskId: string, idem: RunIdemBinding): Promise<string | null>;
151
189
  /** Task Trace list (S1): runs newest-first, keyset-paginated on (created_at, task_id). Optional status +
152
190
  * jobId filters (jobId = the work-view group, served by idx_job). Caller passes `limit+1` to detect a next
153
191
  * page. Cursor fields are bound as params (no SQL injection).
@@ -8,8 +8,9 @@ import { recordFailOpen } from "../observability/fail-open.js";
8
8
  import { createObjectColumnReader, decodeJsonColumn, decodeNullableJsonColumn, foldJsonColumn, isJsonColumnBodyProblem, jsonColumnText, readJsonObjectColumn, readNonNullJsonValueColumn } from "./sql-json-column.js";
9
9
  import { RUN_HOLDER_DEATH_CODES, RUN_STALLED_ERROR, readLegacyHolderDeathCode } from "./store-contracts.js";
10
10
  import { notifyRunTerminal, runTerminalLogLevel } from "../observability/run-terminal.js";
11
- import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
11
+ import { mysqlDriver, pgDriver, dialectJsonEncoder, SQL_DB_NOW_MS, sqlDbNowMs } from "./sql-driver.js";
12
12
  import { isDupKeyError } from "./sql-errors.js";
13
+ import { decideSpaceCommitAbandon, decideSpaceCommitClaim, fenceSpaceCommitAtTerminal, spaceCommitStateOf } from "../workspace-space-commit.js";
13
14
  function runRowCols(dialect) {
14
15
  return (`task_id, session_id, owner, status, ${jsonColumnText(dialect, "result")}, error, error_code, job_id, source, ` +
15
16
  "instance_id, cancel_requested, objective_preview, created_at, updated_at");
@@ -74,7 +75,7 @@ export class SqlRunStore {
74
75
  }
75
76
  }
76
77
  async createRun(taskId, sessionId, owner, instanceId, meta = {}) {
77
- const idemKey = meta.idemKey ?? null;
78
+ const idemKey = meta.idem?.key ?? null;
78
79
  const conn = await this.db.connect();
79
80
  let stage = "claim";
80
81
  try {
@@ -83,9 +84,9 @@ export class SqlRunStore {
83
84
  try {
84
85
  await conn.query(this.q("INSERT INTO task_active (session_id, task_id, since) VALUES (?,?,?)", "INSERT INTO task_active (session_id, task_id, since) VALUES ($1,$2,$3)"), [sessionId, taskId, now]);
85
86
  stage = "row";
86
- await conn.query(this.q("INSERT INTO task_run (task_id, session_id, owner, status, instance_id, job_id, source, objective_preview, idem_key, created_at, updated_at) " +
87
- "VALUES (?,?,?,'running',?,?,?,?,?,?,?)", "INSERT INTO task_run (task_id, session_id, owner, status, instance_id, job_id, source, objective_preview, idem_key, created_at, updated_at) " +
88
- "VALUES ($1,$2,$3,'running',$4,$5,$6,$7,$8,$9,$10)"), [
87
+ await conn.query(this.q("INSERT INTO task_run (task_id, session_id, owner, status, instance_id, job_id, source, objective_preview, idem_key, idem_body_fingerprint, created_at, updated_at) " +
88
+ "VALUES (?,?,?,'running',?,?,?,?,?,?,?,?)", "INSERT INTO task_run (task_id, session_id, owner, status, instance_id, job_id, source, objective_preview, idem_key, idem_body_fingerprint, created_at, updated_at) " +
89
+ "VALUES ($1,$2,$3,'running',$4,$5,$6,$7,$8,$9,$10,$11)"), [
89
90
  taskId,
90
91
  sessionId,
91
92
  owner,
@@ -98,6 +99,7 @@ export class SqlRunStore {
98
99
  ? null
99
100
  : pgSanitizeText(meta.objectivePreview),
100
101
  idemKey,
102
+ meta.idem?.bodyFingerprint ?? null,
101
103
  now,
102
104
  now,
103
105
  ]);
@@ -163,27 +165,33 @@ export class SqlRunStore {
163
165
  const errorCode = result === null ? null : (outwardPlane(result.terminal).errorCode ?? null);
164
166
  let won = false;
165
167
  let observed;
166
- await this.tx(async (conn) => {
167
- const w = await conn.query(this.q("UPDATE task_run SET status = ?, result = ?, error = ?, error_code = ?, updated_at = ? WHERE task_id = ? AND status IN ('running','suspended','needs_review')", "UPDATE task_run SET status = $1, result = $2::jsonb, error = $3, error_code = $4, updated_at = $5 WHERE task_id = $6 AND status IN ('running','suspended','needs_review')"), [
168
- status,
169
- result == null ? null : this.json(result),
170
- this.db.dialect === "tidb" ? error : error == null ? null : pgSanitizeText(error),
171
- this.db.dialect === "tidb" ? errorCode : errorCode == null ? null : pgSanitizeText(errorCode),
172
- new Date(),
173
- taskId,
174
- ]);
175
- won = w.affected > 0;
176
- if (won && runTerminalLogLevel(status, errorCode ?? undefined) !== undefined) {
177
- const { rows } = await conn.query(this.q("SELECT owner, session_id, created_at FROM task_run WHERE task_id = ?", "SELECT owner, session_id, created_at FROM task_run WHERE task_id = $1"), [taskId]);
178
- const row = rows[0];
179
- const createdAt = row ? Date.parse(iso(row.created_at)) : NaN;
180
- observed = {
181
- owner: row?.owner ?? null,
182
- sessionId: String(row?.session_id ?? result?.sessionId ?? ""),
183
- createdAt: Number.isFinite(createdAt) ? createdAt : undefined,
184
- };
185
- }
186
- await conn.query(this.q("DELETE FROM task_active WHERE task_id = ?", "DELETE FROM task_active WHERE task_id = $1"), [taskId]);
168
+ await fenceSpaceCommitAtTerminal(taskId, async (spaceCommit) => {
169
+ let fenced = false;
170
+ await this.tx(async (conn) => {
171
+ const w = await conn.query(this.q("UPDATE task_run SET status = ?, result = ?, error = ?, error_code = ?, updated_at = ? WHERE task_id = ? AND status IN ('running','suspended','needs_review')", "UPDATE task_run SET status = $1, result = $2::jsonb, error = $3, error_code = $4, updated_at = $5 WHERE task_id = $6 AND status IN ('running','suspended','needs_review')"), [
172
+ status,
173
+ result == null ? null : this.json(result),
174
+ this.db.dialect === "tidb" ? error : error == null ? null : pgSanitizeText(error),
175
+ this.db.dialect === "tidb" ? errorCode : errorCode == null ? null : pgSanitizeText(errorCode),
176
+ new Date(),
177
+ taskId,
178
+ ]);
179
+ won = w.affected > 0;
180
+ if (won && runTerminalLogLevel(status, errorCode ?? undefined) !== undefined) {
181
+ const { rows } = await conn.query(this.q("SELECT owner, session_id, created_at FROM task_run WHERE task_id = ?", "SELECT owner, session_id, created_at FROM task_run WHERE task_id = $1"), [taskId]);
182
+ const row = rows[0];
183
+ const createdAt = row ? Date.parse(iso(row.created_at)) : NaN;
184
+ observed = {
185
+ owner: row?.owner ?? null,
186
+ sessionId: String(row?.session_id ?? result?.sessionId ?? ""),
187
+ createdAt: Number.isFinite(createdAt) ? createdAt : undefined,
188
+ };
189
+ }
190
+ if (spaceCommit !== undefined)
191
+ fenced = await this.claimSpaceCommitIn(conn, spaceCommit.spaceKey, taskId, "pending", spaceCommit.claim);
192
+ await conn.query(this.q("DELETE FROM task_active WHERE task_id = ?", "DELETE FROM task_active WHERE task_id = $1"), [taskId]);
193
+ });
194
+ return fenced;
187
195
  });
188
196
  if (won) {
189
197
  notifyRunTerminal({
@@ -198,6 +206,57 @@ export class SqlRunStore {
198
206
  });
199
207
  }
200
208
  }
209
+ async readSpaceCommit(spaceKey) {
210
+ return this.readSpaceCommitIn(this.db, spaceKey, false);
211
+ }
212
+ async claimSpaceCommit(spaceKey, taskId, state, claim) {
213
+ let claimed = false;
214
+ await this.tx(async (conn) => {
215
+ claimed = await this.claimSpaceCommitIn(conn, spaceKey, taskId, state, claim);
216
+ });
217
+ return claimed;
218
+ }
219
+ async endSpaceCommit(spaceKey, taskId) {
220
+ await this.db.query(this.q("DELETE FROM workspace_space_commit WHERE space_key = ? AND task_id = ?", "DELETE FROM workspace_space_commit WHERE space_key = $1 AND task_id = $2"), [spaceKey, taskId]);
221
+ }
222
+ async abandonSpaceCommit(spaceKey, taskId) {
223
+ let gone = false;
224
+ await this.tx(async (conn) => {
225
+ if (!decideSpaceCommitAbandon(await this.readSpaceCommitIn(conn, spaceKey, true), taskId))
226
+ return;
227
+ const w = await conn.query(this.q("DELETE FROM workspace_space_commit WHERE space_key = ? AND task_id = ?", "DELETE FROM workspace_space_commit WHERE space_key = $1 AND task_id = $2"), [spaceKey, taskId]);
228
+ gone = w.affected > 0;
229
+ });
230
+ return gone;
231
+ }
232
+ async readSpaceCommitIn(exec, spaceKey, forUpdate) {
233
+ const suffix = forUpdate ? " FOR UPDATE" : "";
234
+ const { rows } = await exec.query(this.q(`SELECT task_id, state, expires_at_ms FROM workspace_space_commit WHERE space_key = ?${suffix}`, `SELECT task_id, state, expires_at_ms FROM workspace_space_commit WHERE space_key = $1${suffix}`), [spaceKey]);
235
+ const row = rows[0];
236
+ if (row === undefined)
237
+ return undefined;
238
+ const expiresAt = Number(row["expires_at_ms"]);
239
+ if (typeof row["task_id"] !== "string" || !Number.isFinite(expiresAt))
240
+ throw new Error(`workspace_space_commit row for a space is unreadable (task_id ${String(row["task_id"])}, deadline ${String(row["expires_at_ms"])})`);
241
+ const now = await sqlDbNowMs({ dialect: this.db.dialect, query: (sql, params) => exec.query(sql, params) }, "workspace_space_commit");
242
+ return { taskId: row["task_id"], state: spaceCommitStateOf(row["state"]), remainingMs: expiresAt - now };
243
+ }
244
+ async claimSpaceCommitIn(conn, spaceKey, taskId, state, claim) {
245
+ if (!claim.requireOwn) {
246
+ await conn.query(this.q(`INSERT INTO workspace_space_commit (space_key, task_id, state, expires_at_ms) VALUES (?,?,?,${SQL_DB_NOW_MS.tidb} + ?) ON DUPLICATE KEY UPDATE space_key = space_key`, `INSERT INTO workspace_space_commit (space_key, task_id, state, expires_at_ms) VALUES ($1,$2,$3,${SQL_DB_NOW_MS.pg} + $4::bigint) ON CONFLICT (space_key) DO UPDATE SET space_key = EXCLUDED.space_key`), [spaceKey, taskId, state, claim.boundMs]);
247
+ }
248
+ const row = await this.readSpaceCommitIn(conn, spaceKey, true);
249
+ switch (decideSpaceCommitClaim(row, taskId, claim)) {
250
+ case "refuse":
251
+ return false;
252
+ case "insert":
253
+ throw new Error("workspace_space_commit: a space's row vanished under this transaction's own lock");
254
+ case "update": {
255
+ const w = await conn.query(this.q(`UPDATE workspace_space_commit SET task_id = ?, state = ?, expires_at_ms = ${SQL_DB_NOW_MS.tidb} + ? WHERE space_key = ? AND task_id = ?`, `UPDATE workspace_space_commit SET task_id = $1, state = $2, expires_at_ms = ${SQL_DB_NOW_MS.pg} + $3::bigint WHERE space_key = $4 AND task_id = $5`), [taskId, state, claim.boundMs, spaceKey, row.taskId]);
256
+ return w.affected > 0;
257
+ }
258
+ }
259
+ }
201
260
  async setSuspended(taskId, result) {
202
261
  const sealed = sealRunRowResult(result);
203
262
  await this.db.query(this.q("UPDATE task_run SET status = 'suspended', result = ?, updated_at = ? WHERE task_id = ? AND status IN ('running','suspended')", "UPDATE task_run SET status = 'suspended', result = $1::jsonb, updated_at = $2 WHERE task_id = $3 AND status IN ('running','suspended')"), [sealed == null ? null : this.json(sealed), new Date(), taskId]);
@@ -250,7 +309,13 @@ export class SqlRunStore {
250
309
  return rows[0] ? this.rowToRun(rows[0]) : undefined;
251
310
  }
252
311
  async releaseIdemKey(taskId) {
253
- await this.db.query(this.q("UPDATE task_run SET idem_key = NULL WHERE task_id = ?", "UPDATE task_run SET idem_key = NULL WHERE task_id = $1"), [taskId]);
312
+ await this.db.query(this.q("UPDATE task_run SET idem_key = NULL, idem_body_fingerprint = NULL WHERE task_id = ?", "UPDATE task_run SET idem_key = NULL, idem_body_fingerprint = NULL WHERE task_id = $1"), [taskId]);
313
+ }
314
+ async bindIdemBodyFingerprint(taskId, idem) {
315
+ await this.db.query(this.q("UPDATE task_run SET idem_body_fingerprint = ? WHERE task_id = ? AND idem_key = ? AND idem_body_fingerprint IS NULL", "UPDATE task_run SET idem_body_fingerprint = $1 WHERE task_id = $2 AND idem_key = $3 AND idem_body_fingerprint IS NULL"), [idem.bodyFingerprint, taskId, idem.key]);
316
+ const { rows } = await this.db.query(this.q("SELECT idem_body_fingerprint FROM task_run WHERE task_id = ? AND idem_key = ?", "SELECT idem_body_fingerprint FROM task_run WHERE task_id = $1 AND idem_key = $2"), [taskId, idem.key]);
317
+ const held = rows[0]?.idem_body_fingerprint;
318
+ return typeof held === "string" ? held : null;
254
319
  }
255
320
  async listRuns(opts) {
256
321
  if (opts.cursor && Number.isNaN(Date.parse(opts.cursor.createdAt)))
@@ -25,9 +25,8 @@
25
25
  * 一条作者要求扣留的内容)。
26
26
  *
27
27
  * ── 键形法:**编码规范形**(codex R2-[high] 验真后改形;grant 店的「形状拒」在这里是错的)────────
28
- * 提交面把 sessionId 定为**任意不透明串**(server.ts submit 门:「LENGTH-ONLY — NOT a uuidv7 shape
29
- * check」,≤64 字符,run-local `--session` / 匿名分享 id 等都合法)——带空白/控制字符的 id 是**在契约内**
30
- * 的真会话,店若按字节形状拒它,后果=该会话在 SQL 部署上「opt-out 永远落不住(unpersisted)+ 采集态
28
+ * 7.125.x 及更早的提交面把 sessionId 定为**任意不透明串**(≤64 字符,run-local `--session` / 匿名分享 id 等都合法;7.126.0 起
29
+ * 只收 canonical uuid)——存量里带空白/控制字符的 id 是**在契约内建出来**的真会话,店若按字节形状拒它,后果=该会话在 SQL 部署上「opt-out 永远落不住(unpersisted)+ 采集态
31
30
  * 永远 indeterminate(commits 全抑制)」,是部署契约不兼容,不是输入加固。所以键列**不存生字节**,存
32
31
  * `encodeURIComponent(sessionId)` 规范形 —— 与 **core 文件三腿给文件名做的编码逐字同源**(layout.js
33
32
  * `captureOptOutPath` 亲读),两载体一个键法:
@@ -7,10 +7,11 @@ import type { StoreBackend } from "./store-backend.js";
7
7
  * - `memory` (default): core's in-memory `TtlSessionStore` (lost on restart, 7-day idle TTL).
8
8
  * - `tidb`: the durable "Session event-log center" (L1) — append-only events + wake/resume +
9
9
  * cross-instance CAS (F2), on whichever SQL engine `DB_BACKEND` selects (TiDB or PG, via the store backend).
10
- * Optionally wrapped in a warm cache (reuse a hot session across turns; requires session affinity) —
11
- * set `SESSION_CACHE_TTL_SEC=0` to disable.
10
+ * Optionally wrapped in a warm cache (reuse a hot session across turns; a hit is served only while the store's
11
+ * head still names the cached leaf, so it is correct under any routing across replicas) — set
12
+ * `SESSION_CACHE_TTL_SEC=0` to disable.
12
13
  */
13
- export declare function createSessionStore(config: ServiceConfig, backend?: StoreBackend, metrics?: {
14
+ export declare function createSessionStore(config: Pick<ServiceConfig, "sessionBackend" | "sessionCacheTtlSec">, backend?: Pick<StoreBackend, "session">, metrics?: {
14
15
  inc(name: string, labels?: Record<string, string>, n?: number): void;
15
16
  }): SessionStore;
16
17
  /**
@@ -6,7 +6,7 @@ export function createSessionStore(config, backend, metrics) {
6
6
  throw new Error("a DB session backend requires a store backend (DB_BACKEND=mysql|pg + a reachable DB).");
7
7
  const store = backend.session();
8
8
  return config.sessionCacheTtlSec > 0
9
- ? new CachingSessionStore(store, { ttlSec: config.sessionCacheTtlSec, onConflictEvict: metrics ? () => metrics.inc("stale_cache_conflict_total") : undefined })
9
+ ? new CachingSessionStore(store, { ttlSec: config.sessionCacheTtlSec, onStaleHit: metrics ? () => metrics.inc("session_cache_stale_hit_total") : undefined })
10
10
  : store;
11
11
  }
12
12
  return new TtlSessionStore({ defaultTtlDays: 7 });
@@ -183,8 +183,9 @@ export declare const SQL_DB_NOW_MS: {
183
183
  *
184
184
  * `subject` 只进错误文案(出问题时运维要知道是哪一张表的判决卡住了)。读不出数 ⇒ **响亮抛**:
185
185
  * 一次「钟读不出来」在授权面上是运维事件,不是一个可以猜的默认值,更不是掉回 `Date.now()` 的理由。
186
+ * `db` 只要方言与一个执行口:池,或事务里的那条连接(锁读之后要「拿到锁那一刻」的钟时,在同一条连接上问)。
186
187
  */
187
- export declare function sqlDbNowMs(db: SqlDriver, subject: string): Promise<number>;
188
+ export declare function sqlDbNowMs(db: Pick<SqlDriver, "dialect" | "query">, subject: string): Promise<number>;
188
189
  export interface SqlDriver extends SqlExec {
189
190
  readonly dialect: SqlDialect;
190
191
  /**
@@ -5,6 +5,7 @@ import { type OutcomeSink } from "./file-outcome-sink.js";
5
5
  import { type InboxWarn } from "./workflow-run-store-sql.js";
6
6
  import { FileRunStore } from "./file-run-store.js";
7
7
  import { type OneTimeMigrationReport } from "./one-time-migrations.js";
8
+ import type { SessionHeadReader } from "./caching-session-store.js";
8
9
  import { LocalCheckpointStore } from "./local-checkpoint-store.js";
9
10
  import { FileResumeAnchorStore } from "./file-resume-anchor-store.js";
10
11
  import { type ApprovalExemptionStore } from "./approval-exemption-store.js";
@@ -144,7 +145,11 @@ export interface StoreBackend {
144
145
  /** Protocol-truth kind (clay 2026-07-06 wording): "mysql" = any MySQL-protocol server (MySQL / TiDB /
145
146
  * MariaDB) behind the one mysql2 pool — the startup summary prints durable(mysql) for all of them. */
146
147
  readonly kind: "mysql" | "pg" | "local";
147
- /** Connect + create schema (also the reachability probe). */
148
+ /** Connect + create schema (also the reachability probe). On the SQL backends it first checks the database for the
149
+ * schema that the one-time migrations of server 7.74.0–7.125.x retired and **refuses** with `RetiredSchemaPresentError`
150
+ * (code `config.schema_mismatch`, exported from the package root) before it issues the first CREATE; only then does it create.
151
+ * The check lives in this method itself, so the public sequence `createStoreBackend()` → `ensureSchema()` → `runOneTimeMigrations()`
152
+ * needs no step of its own. The LOCAL backend has no SQL pool and nothing to probe. */
148
153
  ensureSchema(): Promise<void>;
149
154
  close(): Promise<void>;
150
155
  run(): RunStore;
@@ -337,8 +342,9 @@ export interface StoreBackend {
337
342
  breaker?(onWriteFail?: (streak: number) => void): BreakerStateStore;
338
343
  costQuota?(limitMicroUsd: number, windowMs: number, onDegraded?: CounterDegradeHook): CostQuotaStore;
339
344
  rateLimiter?(limit: number, onDegraded?: CounterDegradeHook): RateLimiterStore;
340
- /** Session store (caller wraps caching). DB-backed on tidb/pg; the in-memory/file `LocalSessionStore` on local. */
341
- session(): SessionStore;
345
+ /** Session store (caller wraps caching). DB-backed on tidb/pg; the in-memory/file `LocalSessionStore` on local. Carries the
346
+ * head read the warm cache checks every hit against. */
347
+ session(): SessionStore & SessionHeadReader;
342
348
  /** The raw mysql2 pool — ONLY on the TiDB backend (undefined on PG). For the few TiDB-specific raw-SQL
343
349
  * consumers outside the store abstraction (session audit, degenerate-output instrument); those features are
344
350
  * no-ops on PG until ported. */
@@ -381,8 +387,9 @@ export declare function assertCloudSnapshotBlobPosture(kind: "mysql" | "pg" | "l
381
387
  * 而兄弟行都写 `shared(${kind})`。)
382
388
  */
383
389
  export declare function counterStoreLabel(store: unknown, kind: string | undefined): string;
384
- /** Build the durable-store backend for `config.dbBackend`. Does NOT connect (the pool connects lazily; the
385
- * reachability probe is the caller's first `ensureSchema`). Returns undefined when no DB is configured.
390
+ /** Build the durable-store backend for `config.dbBackend`. Does NOT connect (the pool connects lazily); the next
391
+ * step is `ensureSchema()` (retired-schema check, then the CREATEs; it is also the reachability probe), then
392
+ * `runOneTimeMigrations()`. Returns undefined when no DB is configured.
386
393
  *
387
394
  * `onWarn` (additive, optional) is the boot-time warning sink the LOCAL backend needs for core's
388
395
  * permission-rule DISCLOSURE face ([ref] §1): an unreadable / checksum-mismatched / symlinked rule file
@@ -12,7 +12,7 @@ import { FileRunStore } from "./file-run-store.js";
12
12
  import { StoreBootRefusalError } from "./store-contracts.js";
13
13
  import { buildStoreOpenRefusalTail } from "./store-open-refusal.js";
14
14
  import { acquireLocalBackendWithWait } from "./store-lock-wait.js";
15
- import { NO_SQL_MIGRATIONS, runSqlOneTimeMigrations } from "./one-time-migrations.js";
15
+ import { NO_SQL_MIGRATIONS, assertRetiredSchemaAbsentOnPool, runSqlOneTimeMigrations } from "./one-time-migrations.js";
16
16
  import { LocalSessionStore, createLocalSessionRepo } from "./local-session-store.js";
17
17
  import { LocalCheckpointStore } from "./local-checkpoint-store.js";
18
18
  import { FileResumeAnchorStore } from "./file-resume-anchor-store.js";
@@ -265,7 +265,7 @@ class LocalBackend {
265
265
  }
266
266
  async ensureSchema() { }
267
267
  async runOneTimeMigrations() {
268
- return { ...NO_SQL_MIGRATIONS, askOriginRowsMigrated: await this.runStore.migrateRetiredAskOriginRows(), holderDeathCodeRowsBackfilled: this.runStore.migrateLegacyHolderDeathCodes() };
268
+ return { ...NO_SQL_MIGRATIONS, holderDeathCodeRowsBackfilled: this.runStore.migrateLegacyHolderDeathCodes() };
269
269
  }
270
270
  async close() {
271
271
  await this.sessionStore.dispose();
@@ -337,11 +337,20 @@ export function createStoreBackend(config, onWarn, onError) {
337
337
  if (!config.pg)
338
338
  return undefined;
339
339
  const pool = createPgPool(pgPoolOptions(config.pg, config.dbQueryTimeoutMs), onWarn);
340
- return new PgBackend(pool, config);
340
+ return withRetiredSchemaProbe(new PgBackend(pool, config), { dialect: "pg", pool });
341
341
  }
342
342
  if (!config.tidb)
343
343
  return undefined;
344
- return new TiDBBackend(createTidbPool(config), config);
344
+ const pool = createTidbPool(config);
345
+ return withRetiredSchemaProbe(new TiDBBackend(pool, config), { dialect: "tidb", pool });
346
+ }
347
+ function withRetiredSchemaProbe(inner, target) {
348
+ const guarded = Object.create(inner);
349
+ guarded.ensureSchema = async () => {
350
+ await assertRetiredSchemaAbsentOnPool(target);
351
+ await inner.ensureSchema();
352
+ };
353
+ return guarded;
345
354
  }
346
355
  export async function openStoreBackendWithFallback(config, logger) {
347
356
  const sqlEngine = config.dbBackend === "mysql" || config.dbBackend === "pg" ? config.dbBackend : undefined;
@@ -36,6 +36,9 @@ import type { PeerSessionTempo, TaskResult, TaskStatus } from "@sema-agent/core"
36
36
  * 收法:**一个基类,一条重抛**。新 File 店的拒启只要 `extends StoreBootRefusalError` 就自动落在正确的
37
37
  * 一侧;忘了继承是**可见**的(它是一个必须显式选择的父类),而忘了往 if 链里加一行是不可见的。
38
38
  * (core 的 `AdoptionError` 不在本仓,改不了它的继承链 ⇒ 降级臂保留它那一条 `instanceof`,共两条。)
39
+ *
40
+ * 族的含义是「**确定的拒启表态**」,不限 File 店:SQL `ensureSchema()` 里库带着已退役 schema 的拒启(`RetiredSchemaPresentError`)也属此族。三处读它,读法一致 ——
41
+ * 降级臂不吞(穿过 `mayFallback`)、SQL 开库的等待窗不重试它(`store-open-wait.ts`:再等也等不来另一个答案)、「库打不开」的拒句不改写它(它自己的拒句才是真话)。
39
42
  */
40
43
  export declare class StoreBootRefusalError extends Error {
41
44
  }
@@ -221,17 +224,31 @@ export interface RunRecord {
221
224
  createdAt: string;
222
225
  updatedAt: string;
223
226
  }
227
+ /**
228
+ * 一次提交在 run 账本上的**绑定** —— 幂等键 + 绑定它的那次请求的体指纹。两样同铸(提交面读到 `Idempotency-Key` 的同一处)、
229
+ * 同落一行(`task_run.idem_key` / `idem_body_fingerprint`)、同清(`releaseIdemKey`),在飞合流也按这一对分区。
230
+ *
231
+ * · `key` —— `idemKeyOf(raw, caller, "run")`(`http/idempotency.ts`;sha256 hex 定宽 64)。
232
+ * · `bodyFingerprint` —— 绑定它的那次请求的体指纹(sha256 hex 定宽 64):HTTP 三条提交腿 = `submissionFingerprintOf`
233
+ * (同一只规范化函数);A2A `message/send` = 这条消息的内容指纹(它同时是 A2A 键的原始键 ⇒ 同键必同体)。
234
+ *
235
+ * 幂等 = 同一调用方、同一键、**同一请求**:命中账本而指纹不同 ⇒ 提交面的唯一回执函数答 409(`http/submit-replay.ts`)。
236
+ */
237
+ export interface RunIdemBinding {
238
+ readonly key: string;
239
+ readonly bodyFingerprint: string;
240
+ }
224
241
  /**
225
242
  * `RunStore.createRun` 的行元数据(写进 `task_run` 的附加列)。
226
243
  *
227
- * `idemKey` = 这次提交的幂等键(唯一铸法 `idemKeyOf(raw, caller, "run")`,`http/idempotency.ts`;sha256 hex 定宽 64)。
228
- * 缺席 / `null` = 调用方没带 `Idempotency-Key` ⇒ 列 NULL,不参与去重(UNIQUE 不约束 NULL,两方言同形)。
244
+ * `idem` = 这次提交的账本绑定({@link RunIdemBinding})。缺席 / `null` = 调用方没带 `Idempotency-Key` ⇒ 两列 NULL,不参与去重
245
+ * (UNIQUE 不约束 NULL,两方言同形)。
229
246
  */
230
247
  export interface NewRunMeta {
231
248
  jobId?: string | null;
232
249
  source?: string | null;
233
250
  objectivePreview?: string | null;
234
- idemKey?: string | null;
251
+ idem?: RunIdemBinding | null;
235
252
  }
236
253
  /**
237
254
  * `RunStore.createRun` 的结局 —— 三形,一形一句话:
@@ -1,4 +1,5 @@
1
1
  import { STORE_LOCK_POLL_MS } from "./store-lock-wait.js";
2
+ import { StoreBootRefusalError } from "./store-contracts.js";
2
3
  const REAL_CLOCK = {
3
4
  now: () => performance.now(),
4
5
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -46,6 +47,8 @@ export async function openWithinWait(open, waitMs, onWait, clock = REAL_CLOCK) {
46
47
  throw lastFailure.error;
47
48
  throw new StoreOpenWindowExpired(waitMs);
48
49
  case "failed": {
50
+ if (outcome.error instanceof StoreBootRefusalError)
51
+ throw outcome.error;
49
52
  if (lastFailure === undefined)
50
53
  onWait({ waitMs, error: outcome.error instanceof Error ? outcome.error.message : String(outcome.error) });
51
54
  lastFailure = { error: outcome.error };
@@ -170,6 +170,11 @@ export const SCHEMA_STATEMENTS = [
170
170
  -- runs: uq_task_run_idem makes createRun an atomic win-or-observe across replicas and restarts. NULL = the caller
171
171
  -- sent no key (UNIQUE does not constrain NULL; same on both dialects).
172
172
  idem_key VARCHAR(64) NULL,
173
+ -- idem_body_fingerprint: the fingerprint of the request that bound idem_key (sha256 hex, fixed width 64; HTTP legs =
174
+ -- http/idempotency.ts submissionFingerprintOf, A2A = its message fingerprint). Idempotency = same caller, same key, SAME
175
+ -- request: a hit whose fingerprint differs is answered 409 conflict.idempotency_payload_mismatch. NULL = no key, or a key
176
+ -- bound before this column existed (the first request to hit it binds its fingerprint).
177
+ idem_body_fingerprint VARCHAR(64) NULL,
173
178
  created_at DATETIME(3) NOT NULL,
174
179
  updated_at DATETIME(3) NOT NULL,
175
180
  PRIMARY KEY (task_id),
@@ -234,6 +239,13 @@ export const SCHEMA_STATEMENTS = [
234
239
  since DATETIME(3) NOT NULL,
235
240
  PRIMARY KEY (session_id),
236
241
  KEY idx_task_active_task (task_id)
242
+ ) COLLATE utf8mb4_bin`,
243
+ `CREATE TABLE IF NOT EXISTS workspace_space_commit (
244
+ space_key VARCHAR(64) NOT NULL,
245
+ task_id VARCHAR(64) NOT NULL,
246
+ state VARCHAR(16) NOT NULL,
247
+ expires_at_ms BIGINT NOT NULL,
248
+ PRIMARY KEY (space_key)
237
249
  ) COLLATE utf8mb4_bin`,
238
250
  `CREATE TABLE IF NOT EXISTS checkpoint (
239
251
  token VARCHAR(120) NOT NULL,
@@ -278,9 +290,9 @@ export const SCHEMA_STATEMENTS = [
278
290
  bound_input_hash VARCHAR(190) NULL,
279
291
  -- 🔴 队列化之前的**单座**列 pending_steer 已**退役删列**:那个席位在引擎侧没了(core 7.17.0 #717 d
280
292
  -- 对带席的行 PRE-CAS 拒 seat_retired),留着一列没人读、没人写的字节只会让下一个人以为还有两种读法。
281
- -- 存量库由启动期的一次性迁移处置(plugins/one-time-migrations.ts 的 M2,窗口见该文件登记表):列上还送得出去的格 ⇒
282
- -- **响亮拒启**并给出两条出路(先在 server 7.73.x 上把那些 run 决掉或取消,让座位送达 / 运维手工清列),
283
- -- 清空之后 DROP COLUMN。刻意**不**自动把裸单座折进队列:那等于本仓替引擎铸一条它从没写过的队列条目。
293
+ -- 存量库里仍带着这一列的(没经过带迁移的版本的跳版升级库)起服**响亮拒**(plugins/one-time-migrations.ts 的退役 schema 拒启臂,
294
+ -- config.schema_mismatch):出路是先在 server 7.125.x 上起服一次,让当年的一次性迁移把它处置掉,再升。刻意**不**自动
295
+ -- 把裸单座折进队列:那等于本仓替引擎铸一条它从没写过的队列条目。
284
296
  -- pending_steer_queue:core 5.14.0(#147)的**有界有序队列**(PendingSteerEntry[] 的 JSON),现在是本表上
285
297
  -- durable steering 的唯一载体。Never touches status/resolve (design/80 inv #4)。
286
298
  -- 容量:core 的 PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES = 48000 字节是整信封的硬上界(越界 fail-loud