@sema-agent/server 7.137.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 (209) hide show
  1. package/MIGRATION.md +328 -139
  2. package/README.md +1 -0
  3. package/README.zh-CN.md +1 -0
  4. package/USAGE.md +67 -20
  5. package/dist/approval-card.d.ts +21 -14
  6. package/dist/approval-card.js +1 -0
  7. package/dist/audit.js +1 -0
  8. package/dist/background-agent-name.d.ts +5 -0
  9. package/dist/background-agent-name.js +5 -0
  10. package/dist/boot/budget-tracing.d.ts +1 -12
  11. package/dist/boot/deployment-mods.d.ts +46 -16
  12. package/dist/boot/deployment-mods.js +82 -6
  13. package/dist/boot/governance-seams.d.ts +10 -17
  14. package/dist/boot/governance-seams.js +5 -17
  15. package/dist/boot/leader.d.ts +3 -0
  16. package/dist/boot/leader.js +1 -0
  17. package/dist/boot/org-delivery.js +15 -2
  18. package/dist/boot/resolve-spec.d.ts +3 -0
  19. package/dist/boot/resolve-spec.js +23 -12
  20. package/dist/boot/retention-lane.d.ts +11 -5
  21. package/dist/boot/retention-lane.js +2 -2
  22. package/dist/boot/runner-deps.d.ts +7 -3
  23. package/dist/boot/runner-deps.js +1 -0
  24. package/dist/boot/stage-04-stores.d.ts +1 -12
  25. package/dist/boot/stage-05-execution-env.d.ts +3 -12
  26. package/dist/boot/stage-05-execution-env.js +7 -4
  27. package/dist/boot/stage-06-runners.d.ts +3 -12
  28. package/dist/boot/stage-06-runners.js +5 -2
  29. package/dist/boot/stage-07-capability-layer.d.ts +3 -12
  30. package/dist/boot/stage-07-capability-layer.js +2 -2
  31. package/dist/boot/stage-08-reapers.d.ts +3 -12
  32. package/dist/boot/stage-09-leader.d.ts +3 -12
  33. package/dist/boot/stage-09-leader.js +2 -2
  34. package/dist/boot/stage-10-http-server.d.ts +3 -12
  35. package/dist/boot/stage-10-http-server.js +4 -2
  36. package/dist/boot/web-admin.d.ts +5 -2
  37. package/dist/boot/web-admin.js +3 -3
  38. package/dist/brain.js +5 -5
  39. package/dist/budget.d.ts +17 -15
  40. package/dist/budget.js +2 -2
  41. package/dist/capabilities/mcp-probe.d.ts +36 -11
  42. package/dist/capabilities/mcp-probe.js +44 -5
  43. package/dist/capability-facts.d.ts +6 -0
  44. package/dist/capability-facts.js +5 -0
  45. package/dist/checkpoint-identity.d.ts +13 -6
  46. package/dist/config-catalog.js +7 -1
  47. package/dist/config-types.d.ts +15 -7
  48. package/dist/config.js +8 -5
  49. package/dist/deployment-shape.d.ts +6 -6
  50. package/dist/gate-settlement.d.ts +71 -0
  51. package/dist/gate-settlement.js +40 -0
  52. package/dist/hooks/classic-stations.d.ts +1 -0
  53. package/dist/hooks/classic-stations.js +1 -0
  54. package/dist/hooks/command-spawn.d.ts +2 -2
  55. package/dist/hooks/hook-runner.js +14 -14
  56. package/dist/http/admission.js +4 -2
  57. package/dist/http/dispatch.d.ts +4 -0
  58. package/dist/http/dispatch.js +3 -0
  59. package/dist/http/durable-run-leg.js +4 -2
  60. package/dist/http/memory-compliance-failure-reply.js +3 -0
  61. package/dist/http/resume-legs.d.ts +5 -11
  62. package/dist/http/resume-legs.js +136 -91
  63. package/dist/http/route-ctx.d.ts +30 -51
  64. package/dist/http/route-ctx.js +2 -5
  65. package/dist/http/routes/a2a-serve.js +10 -4
  66. package/dist/http/routes/agents-roster.d.ts +1 -1
  67. package/dist/http/routes/agents-roster.js +5 -1
  68. package/dist/http/routes/approvals-assistant.js +6 -6
  69. package/dist/http/routes/capabilities.js +12 -10
  70. package/dist/http/routes/mcp-reconnect.d.ts +3 -2
  71. package/dist/http/routes/mcp-reconnect.js +7 -5
  72. package/dist/http/routes/memory-consolidation.d.ts +1 -1
  73. package/dist/http/routes/memory-origin.d.ts +1 -1
  74. package/dist/http/routes/rules.js +3 -3
  75. package/dist/http/routes/runs.js +11 -7
  76. package/dist/http/routes/sessions.js +6 -4
  77. package/dist/http/routes/side-query.js +2 -3
  78. package/dist/http/routes/tasks.js +792 -780
  79. package/dist/http/server.d.ts +31 -25
  80. package/dist/http/wire-types.d.ts +14 -6
  81. package/dist/identity-source.d.ts +1 -1
  82. package/dist/index.d.ts +2 -0
  83. package/dist/index.js +1 -0
  84. package/dist/leader/wire.d.ts +7 -0
  85. package/dist/leader/wire.js +5 -4
  86. package/dist/mcp-assertion-header.d.ts +51 -40
  87. package/dist/mcp-assertion-header.js +10 -11
  88. package/dist/mcp-authorization-face.d.ts +27 -0
  89. package/dist/mcp-authorization-face.js +15 -0
  90. package/dist/memory-operator-faces.d.ts +13 -4
  91. package/dist/memory-operator-faces.js +1 -17
  92. package/dist/memory-sync-client.js +72 -57
  93. package/dist/memory-sync.d.ts +1 -1
  94. package/dist/model-route-gap.d.ts +6 -22
  95. package/dist/model-route-gap.js +7 -5
  96. package/dist/mods-declaration.d.ts +110 -0
  97. package/dist/mods-declaration.js +109 -0
  98. package/dist/observability/err-text.d.ts +5 -0
  99. package/dist/observability/err-text.js +9 -0
  100. package/dist/observability/fail-open.d.ts +5 -1
  101. package/dist/observability/fail-open.js +5 -1
  102. package/dist/observability/secret-env-scrub.d.ts +1 -1
  103. package/dist/observability/secret-env-scrub.js +1 -1
  104. package/dist/parked-decide.d.ts +15 -2
  105. package/dist/parked-decide.js +14 -8
  106. package/dist/peer-seat.d.ts +150 -0
  107. package/dist/peer-seat.js +239 -0
  108. package/dist/permission-rule-vocab.d.ts +7 -7
  109. package/dist/permission-rule-vocab.js +3 -3
  110. package/dist/plain-string-record.d.ts +38 -0
  111. package/dist/plain-string-record.js +38 -0
  112. package/dist/plugins/background-agent-store-sql.js +6 -3
  113. package/dist/plugins/caching-session-store.d.ts +5 -0
  114. package/dist/plugins/caching-session-store.js +6 -0
  115. package/dist/plugins/checkpoint-store-sql.d.ts +15 -20
  116. package/dist/plugins/checkpoint-store-sql.js +8 -8
  117. package/dist/plugins/file-history-store-sql.js +3 -2
  118. package/dist/plugins/k8s-pod-reaper.d.ts +2 -8
  119. package/dist/plugins/k8s-pod-reaper.js +3 -3
  120. package/dist/plugins/local-checkpoint-store.d.ts +1 -11
  121. package/dist/plugins/local-checkpoint-store.js +0 -1
  122. package/dist/plugins/local-session-store.d.ts +66 -27
  123. package/dist/plugins/local-session-store.js +60 -28
  124. package/dist/plugins/mailbox-store-sql.d.ts +11 -3
  125. package/dist/plugins/mailbox-store-sql.js +103 -15
  126. package/dist/plugins/memory-sync-store-pg.d.ts +16 -5
  127. package/dist/plugins/memory-sync-store-tidb.d.ts +3 -3
  128. package/dist/plugins/one-time-migrations.d.ts +6 -3
  129. package/dist/plugins/one-time-migrations.js +3 -4
  130. package/dist/plugins/permission-rule-store-sql.js +21 -13
  131. package/dist/plugins/pg-session-storage.d.ts +10 -2
  132. package/dist/plugins/pg-session-storage.js +18 -5
  133. package/dist/plugins/remote-env-adb.d.ts +3 -1
  134. package/dist/plugins/remote-env-adb.js +1 -1
  135. package/dist/plugins/remote-env-device.d.ts +3 -1
  136. package/dist/plugins/remote-env-device.js +1 -1
  137. package/dist/plugins/remote-env-e2b.d.ts +2 -0
  138. package/dist/plugins/remote-env-e2b.js +1 -1
  139. package/dist/plugins/remote-env-host.d.ts +3 -1
  140. package/dist/plugins/remote-env-host.js +1 -1
  141. package/dist/plugins/remote-env-k8s.d.ts +3 -1
  142. package/dist/plugins/remote-env-k8s.js +1 -1
  143. package/dist/plugins/remote-env-local-docker.d.ts +2 -0
  144. package/dist/plugins/remote-env-local-docker.js +1 -1
  145. package/dist/plugins/remote-env-ssh.d.ts +3 -1
  146. package/dist/plugins/remote-env-ssh.js +1 -1
  147. package/dist/plugins/retention-store-sql.d.ts +14 -2
  148. package/dist/plugins/retention-store-sql.js +6 -0
  149. package/dist/plugins/session-fork.d.ts +10 -13
  150. package/dist/plugins/session-fork.js +5 -25
  151. package/dist/plugins/session-ownership.d.ts +7 -0
  152. package/dist/plugins/session-ownership.js +5 -0
  153. package/dist/plugins/session-workspace-cell.d.ts +17 -0
  154. package/dist/plugins/session-workspace-cell.js +38 -0
  155. package/dist/plugins/store-backend.js +7 -7
  156. package/dist/plugins/tidb-pool.js +6 -0
  157. package/dist/plugins/tidb-session-store.d.ts +17 -2
  158. package/dist/plugins/tidb-session-store.js +16 -5
  159. package/dist/plugins/tool-result-store-sql.d.ts +13 -6
  160. package/dist/plugins/tool-result-store-sql.js +8 -7
  161. package/dist/plugins/worktree-isolation.d.ts +3 -2
  162. package/dist/plugins/worktree-isolation.js +1 -7
  163. package/dist/project-identity.d.ts +1 -14
  164. package/dist/project-identity.js +0 -4
  165. package/dist/rules-consent.js +6 -4
  166. package/dist/run-local.js +12 -6
  167. package/dist/runs.d.ts +7 -1
  168. package/dist/runs.js +6 -1
  169. package/dist/sandbox-git-plane.d.ts +2 -2
  170. package/dist/server-secret-env.d.ts +6 -3
  171. package/dist/server-secret-env.js +4 -2
  172. package/dist/session-incarnation.d.ts +4 -5
  173. package/dist/session-incarnation.js +2 -2
  174. package/dist/session-workspace.d.ts +130 -0
  175. package/dist/session-workspace.js +83 -0
  176. package/dist/spec-fields.d.ts +1 -1
  177. package/dist/standing-answer-grant.d.ts +2 -13
  178. package/dist/standing-answer-grant.js +0 -7
  179. package/dist/task-a2a.d.ts +44 -14
  180. package/dist/task-a2a.js +46 -20
  181. package/dist/task-mcp.d.ts +33 -20
  182. package/dist/task-mcp.js +46 -22
  183. package/dist/tool-approval.d.ts +32 -8
  184. package/dist/tool-approval.js +49 -12
  185. package/dist/trace/core-keyset-guard.d.ts +33 -18
  186. package/dist/trace/engine-notice-wire.d.ts +1 -1
  187. package/dist/trace/engine-notice-wire.js +2 -0
  188. package/dist/trace/exit-seat.d.ts +20 -1
  189. package/dist/trace/exit-seat.js +3 -0
  190. package/dist/trace/extension-call-face.d.ts +33 -0
  191. package/dist/trace/extension-call-face.js +39 -0
  192. package/dist/trace/project.d.ts +25 -7
  193. package/dist/trace/project.js +29 -3
  194. package/dist/trace/redact.js +42 -13
  195. package/dist/trace/self-minted-wire.d.ts +129 -10
  196. package/dist/trace/self-minted-wire.js +62 -3
  197. package/dist/trace/wire-projection-faces.d.ts +6 -6
  198. package/dist/trace/wire-projection-faces.js +6 -5
  199. package/dist/user-authored-text.d.ts +2 -2
  200. package/dist/user-authored-text.js +16 -1
  201. package/dist/web-admin-client/center-request.d.ts +3 -2
  202. package/dist/web-admin-client/center-request.js +2 -3
  203. package/dist/web-admin-client/skills-mcp.d.ts +8 -4
  204. package/dist/web-admin-client/skills-mcp.js +31 -18
  205. package/dist/web-admin-client/types.d.ts +5 -0
  206. package/package.json +3 -3
  207. package/wire-changes/server@7.138.0.json +446 -0
  208. package/dist/http/mcp-remote-text.d.ts +0 -5
  209. package/dist/http/mcp-remote-text.js +0 -7
@@ -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;
@@ -1,4 +1,3 @@
1
- import type { MemorySyncCursor } from "@sema-agent/core";
2
1
  import type { PgQueryFn } from "./pg-query.js";
3
2
  import { type IndexSpec } from "./ensure-index.js";
4
3
  /** Table names (single source). SAME names as the TiDB twin (the two dialects never share one DB). */
@@ -31,12 +30,24 @@ export interface ClaimedPushQueueItem {
31
30
  /** The lease deadline this claim holds the row until (nowMs + claimLeaseMs). */
32
31
  leasedUntilMs: number;
33
32
  }
33
+ /**
34
+ * 中心侧的同步游标 = 本服务作同步**中心**时,每个 (scope, peer) 的基线(`sync_cursors` 一行)—— 本服务自己的事实,本服务自己的型。
35
+ * 它**不是**引擎的 `MemorySyncCursor`:那一只是同步**客户端**的游标,引擎 7.59.0 起必带 `store`(客户端本地 File 店的物理根,`memorySyncStoreOf` 铸),
36
+ * 绑的是「这份基线是对着哪一只本地店取的」;中心这一侧的条目平面是部署的 DB 后端,没有本地店根可绑,游标也从不交给引擎判。
37
+ * 此前本文件借用了引擎那只型名(两者当时同形),引擎给它加了只属于客户端的成员之后,借用就成了两个事实共用一个名字 —— 本型收回。
38
+ */
39
+ export interface CentralSyncCursor {
40
+ readonly scope: string;
41
+ readonly peer: string;
42
+ readonly baseRevs: Readonly<Record<string, string>>;
43
+ readonly updatedAtMs: number;
44
+ }
34
45
  /** The dialect-neutral contract both twins implement (anti-drift: one shared integration suite,
35
46
  * test/memory-sync-store-db-integration.test.ts, runs the SAME scenarios against BOTH engines). */
36
47
  export interface MemorySyncStore {
37
- getCursor(scope: string, peer: string): Promise<MemorySyncCursor | undefined>;
48
+ getCursor(scope: string, peer: string): Promise<CentralSyncCursor | undefined>;
38
49
  /** Whole-row replace (round semantics — baseRevs is swapped as one map, never merged). */
39
- putCursor(cursor: MemorySyncCursor): Promise<void>;
50
+ putCursor(cursor: CentralSyncCursor): Promise<void>;
40
51
  enqueue(item: PushQueueItem): Promise<void>;
41
52
  /** Lease-claim up to `limit` due rows (next_attempt_at_ms <= nowMs), oldest-due first. Claimed rows
42
53
  * are invisible to other claimers until `complete`/`fail` or lease expiry (crash self-heal). */
@@ -66,8 +77,8 @@ export declare class PgMemorySyncStore implements MemorySyncStore {
66
77
  private readonly query;
67
78
  private readonly claimLeaseMs;
68
79
  constructor(query: PgQueryFn, opts?: PgMemorySyncStoreOptions);
69
- getCursor(scope: string, peer: string): Promise<MemorySyncCursor | undefined>;
70
- putCursor(cursor: MemorySyncCursor): Promise<void>;
80
+ getCursor(scope: string, peer: string): Promise<CentralSyncCursor | undefined>;
81
+ putCursor(cursor: CentralSyncCursor): Promise<void>;
71
82
  enqueue(item: PushQueueItem): Promise<void>;
72
83
  claimDue(nowMs: number, limit: number): Promise<ClaimedPushQueueItem[]>;
73
84
  complete(id: string): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import type { ClaimedPushQueueItem, MemoryHistoryAppendRow, MemoryHistoryRow, MemoryHistoryStore, MemorySyncStore, PushQueueItem } from "./memory-sync-store-pg.js";
2
- import type { MemorySyncCursor } from "@sema-agent/core";
2
+ import { type CentralSyncCursor } from "./memory-sync-store-pg.js";
3
3
  import type { Pool } from "mysql2/promise";
4
4
  /** Table names (single source) — SAME names as PG_MEMORY_SYNC_TABLES (the two dialects never share
5
5
  * one database). */
@@ -21,8 +21,8 @@ export declare class TiDBMemorySyncStore implements MemorySyncStore {
21
21
  private readonly pool;
22
22
  private readonly claimLeaseMs;
23
23
  constructor(pool: Pool, opts?: TiDBMemorySyncStoreOptions);
24
- getCursor(scope: string, peer: string): Promise<MemorySyncCursor | undefined>;
25
- putCursor(cursor: MemorySyncCursor): Promise<void>;
24
+ getCursor(scope: string, peer: string): Promise<CentralSyncCursor | undefined>;
25
+ putCursor(cursor: CentralSyncCursor): Promise<void>;
26
26
  enqueue(item: PushQueueItem): Promise<void>;
27
27
  claimDue(nowMs: number, limit: number): Promise<ClaimedPushQueueItem[]>;
28
28
  complete(id: string): Promise<void>;
@@ -227,9 +227,12 @@
227
227
  * · **M25 单用户记忆项目层换键形**(7.133.0)—— 不在店里、不在起服里(与 M10 同为**窗内才存在的行为臂**,但它是改写):项目层键从 `repo:<sha256(项目根)[:24]>`
228
228
  * (7.98.0–7.132.x)换成带主体、跨设备的 `userrepo:<属主段>/<档>.<摘要>`(文法 `project-layer-key.ts`)。退役键是路径哈希、反推不出路径 ⇒ 起服没法枚举改写;
229
229
  * 改为**每个项目第一次跑任务时**由项目层准备一步(`memory-project-layer.ts` `createMemoryProjectLayers`)把该 checkout 的 `repo:` 层经 core `migrateScope`
230
- * 折进新键(File 与两只 SQL 孪生同一条;id 保留、CAS,冲突留原地下次再试)。同一窗同删的另一半:同步客户端腿的旧游标文件 `sync-cursor-<peer>.json`(只按 peer 分区)
231
- * 第一轮收编为同步层的新游标(`memory-sync-client.ts` `adoptLegacyCursor`)。本条不写起服读数(`reportKeys` 空,与 M7 / M10 同形);折了什么由运行期 info
232
- * `memory_project_layer_folded` / `memory_sync_cursor_adopted` 说。常设的另一条律(`r` 档时本 checkout 的 `p` 档折进当前档)不是迁移,不随本条删。
230
+ * 折进新键(File 与两只 SQL 孪生同一条;id 保留、CAS,冲突留原地下次再试)。本条不写起服读数(`reportKeys` 空,与 M7 / M10 同形);折了什么由运行期 info
231
+ * `memory_project_layer_folded` 说。常设的另一条律(`r` 档时本 checkout 的 `p` 档折进当前档)不是迁移,不随本条删。
232
+ * **曾属本条的另一半已退役(如实)**:同步客户端腿的旧游标文件 `sync-cursor-<peer>.json`(只按 peer 分区)的收编臂。引擎 7.59.0 起游标必带店绑定 `store`,
233
+ * 升级前落盘的旧文件都没有这一员,收编的形判从不认它 ⇒ 臂对任何存量文件都不可达,已整条删码(收编函数、守卫旗标、日志行一并删,零引用有钉:
234
+ * `test/memory-sync-client.test.ts`)。旧文件不再被当游标;它只回答「同步层有没有一份店绑定之前的基线」—— 新路径上没有文件而它是同步层的无绑定基线 ⇒
235
+ * 同步层不同步、运维通告点名它(与新路径上的无绑定游标同一条律,`memory-sync-client.ts` 头注 ⑤;不是本条的迁移臂,不随本条删)。
233
236
  * · **M26 删掉退役的共享断路器表 `circuit_breaker`**(7.133.0)—— 断路器键的路由段从 baseUrl **原串**换成路由身份的摘要(铸点与形 `breaker-key.ts`),
234
237
  * 新一代键写**新表** `breaker_state`(中央 DDL 建)。旧表 `circuit_breaker` 的行是旧键(`<provider>:<id>@<baseUrl>`,网关根原串 query 可带凭据),
235
238
  * 新代码不读不写它、它也没有 TTL 腿 ⇒ 不删就永远在共享库里。**整只删表**(`DROP TABLE IF EXISTS circuit_breaker`,不看任何一行的内容):
@@ -393,11 +393,10 @@ export const ONE_TIME_MIGRATIONS = [
393
393
  releasedOn: "2026-10-21",
394
394
  removableAfter: "2026-11-20",
395
395
  removal: "删 `project-identity.ts` 的 `mintRepoScopeKey`(与它的窗注;`RETIRED_PROJECT_KEY_PREFIX` 留给 `isServerMintedProjectKey` 的起服拒)与 `projectLayerForTaskRoot` 的 `foldFrom` 里它那一员" +
396
- "(`p` 档那一员留 —— 常设律);`memory-sync-client.ts` 的 `legacyMemorySyncCursorPath` / `adoptLegacyCursor` 与 runner 里调它的那一段(`legacyCursorChecked`);" +
397
- "`test/memory-project-layer-fold.test.ts` 的 ① 格与 `test/memory-project-layer-fold-db-integration.test.ts` 整份、`test/memory-sync-client.test.ts` 的旧游标收编格、" +
396
+ "(`p` 档那一员留 —— 常设律);" +
397
+ "`test/memory-project-layer-fold.test.ts` 的 ① 格与 `test/memory-project-layer-fold-db-integration.test.ts` 整份、" +
398
398
  "`test/project-identity.test.ts` 的 `repo:` 金样格(公式金样改钉 `p` 档摘要)",
399
- afterRemoval: "没有新的臂(如实):窗内一次都没跑过任务的项目,它的 `repo:` 层留在库里、不再被任何读列表包含(不读、不删;起服拒只管运维点名这一族);出路 = 升级时先过一次带本条的版本并在每个项目跑一次任务。" +
400
- "同步腿的旧游标文件不再收编:同步层从空基线首轮重收敛(两边都改过的条目按冲突上报,不静默丢)",
399
+ afterRemoval: "没有新的臂(如实):窗内一次都没跑过任务的项目,它的 `repo:` 层留在库里、不再被任何读列表包含(不读、不删;起服拒只管运维点名这一族);出路 = 升级时先过一次带本条的版本并在每个项目跑一次任务",
401
400
  reportKeys: [],
402
401
  },
403
402
  {
@@ -4,7 +4,6 @@ import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuar
4
4
  import { dialectProtocolJsonEncoder, sqlDbNowMs, SQL_DB_NOW_MS } from "./sql-driver.js";
5
5
  import { isMissingColumnError } from "./sql-errors.js";
6
6
  import { canonicalJsonStringify } from "@sema-agent/core";
7
- import { isPersistedRuleTool } from "../permission-rule-vocab.js";
8
7
  import { ensureIndexes } from "./ensure-index.js";
9
8
  import { createZodColumnReader, decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn, SqlJsonColumnError } from "./sql-json-column.js";
10
9
  import { redactSecrets } from "../trace/redact.js";
@@ -31,7 +30,7 @@ const PersistedRuleRowSchema = z
31
30
  .object({
32
31
  behavior: z.string().optional(),
33
32
  rule: z.string(),
34
- tool: z.string().min(1).refine(isPersistedRuleTool, { message: "not a tool the persisted-rule grammar speaks for" }),
33
+ tool: z.string().min(1),
35
34
  match: z.enum(PERSISTED_RULE_MATCHES),
36
35
  command: z.string(),
37
36
  scope: DurableRuleScopeSchema,
@@ -387,6 +386,19 @@ function decodeBucketRow(ownerKey, row) {
387
386
  const rules = rulesRaw.map((r) => ({ ...r, behavior: readBucketRowBehavior(r.behavior, "rule row", ownerKey) }));
388
387
  const tombstones = tombstonesRaw.map((t) => ({ ...t, behavior: readBucketRowBehavior(t.behavior, "tombstone", ownerKey) }));
389
388
  const quarantined = quarantinedRaw.map((q) => ({ ...q, behavior: readBucketRowBehavior(q.behavior, "quarantined row", ownerKey) }));
389
+ refuseRowsTheValidatorRefuses(ownerKey, rules, tombstones);
390
+ return {
391
+ actor: String(row.actor ?? ""),
392
+ counter: Number(row.counter ?? 0),
393
+ rev: Number(row.rev ?? 0),
394
+ rules,
395
+ tombstones,
396
+ quarantined,
397
+ ...(observedVector !== undefined ? { observedVector } : {}),
398
+ ...(grammar !== undefined ? { grammar } : {}),
399
+ };
400
+ }
401
+ function refuseRowsTheValidatorRefuses(ownerKey, rules, tombstones) {
390
402
  const badTombstone = tombstones.find((t) => {
391
403
  const parsed = parseRuleText(t.rule, t.behavior);
392
404
  return "reject" in parsed || parsed.rule.rule !== t.rule;
@@ -394,21 +406,11 @@ function decodeBucketRow(ownerKey, row) {
394
406
  if (badTombstone !== undefined) {
395
407
  throw new BucketRefusedError(`permission_rule bucket ${ownerKey} carries a tombstone whose rule text the single validator refuses or does not spell canonically`, `(${badTombstone.rule}) — refusing the whole bucket rather than keeping a rule alive under a tombstone that can never match it`);
396
408
  }
397
- const screened = screenRuleSyncState({ rules, tombstones });
409
+ const screened = screenRuleSyncState({ rules: [...rules], tombstones: [...tombstones] });
398
410
  if (screened.rejected.length > 0) {
399
411
  const names = screened.rejected.map((r) => `${r.rule}(${r.reason})`).join(", ");
400
412
  throw new BucketRefusedError(`permission_rule bucket ${ownerKey} carries rows the single validator refuses`, `[${names}] — refusing the whole bucket rather than authorizing a rule whose text and matcher metadata disagree`);
401
413
  }
402
- return {
403
- actor: String(row.actor ?? ""),
404
- counter: Number(row.counter ?? 0),
405
- rev: Number(row.rev ?? 0),
406
- rules,
407
- tombstones,
408
- quarantined,
409
- ...(observedVector !== undefined ? { observedVector } : {}),
410
- ...(grammar !== undefined ? { grammar } : {}),
411
- };
412
414
  }
413
415
  function fencedCasWhere(dialect, firstPlaceholder, ownerKey, cas) {
414
416
  const ph = (offset) => (dialect === "tidb" ? "?" : `$${firstPlaceholder + offset}`);
@@ -422,6 +424,12 @@ async function writeBucket(exec, dialect, ownerKey, cur, next, nowMs) {
422
424
  if (!writable.success) {
423
425
  throw new Error(`refusing to write a permission_rule bucket this backend could not read back: ${writable.error.message}`);
424
426
  }
427
+ try {
428
+ refuseRowsTheValidatorRefuses(ownerKey, next.rules, next.tombstones);
429
+ }
430
+ catch (e) {
431
+ throw new Error(`refusing to write a permission_rule bucket this backend could not read back: ${e.message}`);
432
+ }
425
433
  const body = { rules: next.rules, tombstones: next.tombstones, quarantined: cur.quarantined, ...(cur.observedVector !== undefined ? { observedVector: cur.observedVector } : {}) };
426
434
  const enc = dialectProtocolJsonEncoder(dialect);
427
435
  const where = fencedCasWhere(dialect, 7, ownerKey, { column: "rev", expected: cur.rev });
@@ -6,6 +6,8 @@ import { type PlacementAcquireOpts } from "./session-placement.js";
6
6
  import { type OrphanOwnerLog } from "../delegated-session-owner.js";
7
7
  import { type StagingHandle } from "../session-sync-kernel.js";
8
8
  import { type InstructionSourceBaseline } from "../instruction-source-notice.js";
9
+ import type { SessionWorkspace } from "../session-workspace.js";
10
+ import type { RetentionDeclaration } from "@sema-agent/core";
9
11
  import { type SessionFace } from "../session-incarnation.js";
10
12
  import { type IndexSpec } from "./ensure-index.js";
11
13
  /**
@@ -55,8 +57,8 @@ export declare class PgSessionStorage extends BaseSessionStorage {
55
57
  */
56
58
  export declare class PgSessionStore implements SessionStore {
57
59
  private readonly pool;
58
- /** [ref] 车1:托管留存声明(TiDB 孪生同格)。读法见 `retention-store-sql.ts` 的 {@link MANAGED_RETENTION}。 */
59
- readonly retention: import("@sema-agent/core").RetentionDeclaration;
60
+ /** 托管留存声明 = 装配交给它的词(TiDB 孪生同格)。读法见 `retention-store-sql.ts` 的 {@link MANAGED_RETENTION}。 */
61
+ readonly retention: RetentionDeclaration;
60
62
  /** [ref] C18(core 5.48.0 [ref]):placement 声明(TiDB 孪生同格,义务逐条见
61
63
  * `src/plugins/session-placement.ts` 顶注)。 */
62
64
  readonly placements: {
@@ -70,6 +72,7 @@ export declare class PgSessionStore implements SessionStore {
70
72
  private readonly log;
71
73
  constructor(pool: Pool, opts?: {
72
74
  readonly log?: OrphanOwnerLog;
75
+ readonly retention?: RetentionDeclaration;
73
76
  });
74
77
  acquire(sessionId?: string, opts?: PlacementAcquireOpts): Promise<AcquiredSession>;
75
78
  private load;
@@ -135,6 +138,11 @@ export declare class PgSessionStore implements SessionStore {
135
138
  * `query_timeout` rejects AND pg-pool destroys that client, so a lock-stuck baseline write does not keep a pooled
136
139
  * connection). */
137
140
  recordInstructionSource(sessionId: string, expected: InstructionSourceBaseline, source: string | null): Promise<void>;
141
+ /** @see TiDBSessionStore.readSessionWorkspace — the PG twin (same projection-as-text, same cell owner). */
142
+ readSessionWorkspace(sessionId: string): Promise<SessionWorkspace | undefined>;
143
+ /** @see TiDBSessionStore.recordSessionWorkspace — the PG twin (whole-cell overwrite fenced to the occupant; `pgSafeJsonStringify` + `::jsonb`, like every PG JSON write;
144
+ * the general query surface — the pool-wide `DB_QUERY_TIMEOUT_MS` server-side `statement_timeout` bounds it, no per-call client timer). */
145
+ recordSessionWorkspace(sessionId: string, incarnation: string, workspace: SessionWorkspace): Promise<void>;
138
146
  /** @see TiDBSessionStore.fork — copy the WHOLE durable history (full session_event log, compaction floor bypassed,
139
147
  * NOT wake's bounded tail) to a NEW uuidv7 session, re-seq dense from 0, ids/parents verbatim, owner=caller. */
140
148
  fork(sourceId: string, owner?: string | null): Promise<string | null>;
@@ -14,11 +14,13 @@ import { createLogger } from "../observability/logger.js";
14
14
  import { classifySyncRelationshipByIds, SyncConflictError, StagedCommitOwnerChangedError, stagingIdFor, STAGING_ID_MARKER, STAGING_GC_GRACE_MS, } from "../session-sync-kernel.js";
15
15
  import { isPgUniqueViolation } from "./sql-errors.js";
16
16
  import { BASELINE_IO_DEADLINE_MS, decodeInstructionSource, encodeInstructionSource } from "../instruction-source-notice.js";
17
- import { MANAGED_RETENTION } from "./retention-store-sql.js";
17
+ import { SESSION_META_WORKSPACE_COLUMN, decodeSessionWorkspaceCell, encodeSessionWorkspaceCell } from "./session-workspace-cell.js";
18
+ import { MANAGED_RETENTION, UNMANAGED_RETENTION } from "./retention-store-sql.js";
18
19
  import { SESSION_META_INCARNATION_COLUMN, carriedIncarnation, headFromRow, incarnationFromCell, mintSessionIncarnation, sessionFaceFromRow } from "../session-incarnation.js";
19
20
  import { ensureIndexes } from "./ensure-index.js";
20
21
  import { assertFirstOwnerWritable, forkOwnerOf, isSessionOwnerValue } from "./session-ownership.js";
21
- import { forkCopyOf, forkPointOf } from "./session-fork.js";
22
+ import { selectForkRows } from "@sema-agent/core";
23
+ import { FORK_LOG_ROW_SHAPE, forkPointOf } from "./session-fork.js";
22
24
  const isDupKey = isPgUniqueViolation;
23
25
  function baselineQuery(text, values) {
24
26
  return { text, values, query_timeout: BASELINE_IO_DEADLINE_MS };
@@ -39,6 +41,8 @@ export const PG_SESSION_SCHEMA = [
39
41
  pinned BOOLEAN NOT NULL DEFAULT FALSE,
40
42
  -- instruction_source(S-690):换源通告的基线 —— TiDB 孪生同名同宽,三态读法(NULL / '' / 链成员相对路径)见那里的列注。
41
43
  instruction_source VARCHAR(64) COLLATE "C",
44
+ -- workspace:本会话上一条腿自己的最后一帧 workspace_changed —— TiDB 孪生同名同义(列注与唯一属主见那里:src/plugins/session-workspace-cell.ts)。
45
+ ${SESSION_META_WORKSPACE_COLUMN.pg},
42
46
  -- #292 C18(core 5.48.0 design/323)subagent 转录持久化 —— placement 元组(TiDB 孪生同名同宽,
43
47
  -- 语义与六条义务见 src/plugins/session-placement.ts 顶注):创建时一次落行、first-write 不可变;
44
48
  -- placement_kind 在场 ⇒ 住在 placement 分区(不上宿主枚举 / claim 形 acquire 恒拒 / release=真删除 /
@@ -176,13 +180,14 @@ export class PgSessionStorage extends BaseSessionStorage {
176
180
  }
177
181
  export class PgSessionStore {
178
182
  pool;
179
- retention = MANAGED_RETENTION;
183
+ retention;
180
184
  placements = SQL_SESSION_PLACEMENTS;
181
185
  pending = new Map();
182
186
  log;
183
187
  constructor(pool, opts = {}) {
184
188
  this.pool = pool;
185
189
  this.log = opts.log ?? createLogger();
190
+ this.retention = opts.retention ?? UNMANAGED_RETENTION;
186
191
  }
187
192
  async acquire(sessionId, opts) {
188
193
  if (!sessionId) {
@@ -417,6 +422,14 @@ export class PgSessionStore {
417
422
  await this.pool.query(baselineQuery("UPDATE session_meta SET instruction_source = $1 WHERE session_id = $2 AND instruction_source IS NULL", [next, sessionId]));
418
423
  }
419
424
  }
425
+ async readSessionWorkspace(sessionId) {
426
+ const res = await this.pool.query(`SELECT ${jsonColumnText("pg", "workspace")} FROM session_meta WHERE session_id = $1`, [sessionId]);
427
+ const row = res.rows[0];
428
+ return row === undefined ? undefined : decodeSessionWorkspaceCell(row.workspace, sessionId);
429
+ }
430
+ async recordSessionWorkspace(sessionId, incarnation, workspace) {
431
+ await this.pool.query("UPDATE session_meta SET workspace = $1::jsonb WHERE session_id = $2 AND incarnation = $3", [encodeSessionWorkspaceCell(workspace, pgSafeJsonStringify, sessionId), sessionId, incarnation]);
432
+ }
420
433
  async fork(sourceId, owner) {
421
434
  const metaRes = await this.pool.query("SELECT leaf_id, owner FROM session_meta WHERE session_id = $1", [sourceId]);
422
435
  if (!metaRes.rows[0])
@@ -428,7 +441,7 @@ export class PgSessionStore {
428
441
  return null;
429
442
  const point = await forkPointOf(storage);
430
443
  const eventRes = await this.pool.query(`SELECT entry_id, parent_id, type, ${jsonColumnText("pg", "payload")}, ts FROM session_event WHERE session_id = $1 ORDER BY seq ASC`, [sourceId]);
431
- const { rows, leafId: forkLeafId } = forkCopyOf(eventRes.rows, leafId, point);
444
+ const { rows, leafId: forkLeafId } = selectForkRows(eventRes.rows, leafId, point, FORK_LOG_ROW_SHAPE);
432
445
  const newId = uuidv7();
433
446
  const now = new Date();
434
447
  const conn = await this.pool.connect();
@@ -523,7 +536,7 @@ export class PgSessionStore {
523
536
  }
524
537
  await conn.query("DELETE FROM session_event WHERE session_id = $1", [sessionId]);
525
538
  await conn.query("DELETE FROM session_meta WHERE session_id = $1 AND owner IS NOT DISTINCT FROM $2 AND placement_kind IS NULL", [sessionId, owner]);
526
- await this.insertValidatedLog(conn, sessionId, owner, validated, carriedIncarnation(sessionId, placedRes.rows[0], owner));
539
+ await this.insertValidatedLog(conn, sessionId, owner, validated, carriedIncarnation(sessionId, placedRes.rows[0]));
527
540
  await conn.query("COMMIT");
528
541
  }
529
542
  catch (err) {
@@ -58,14 +58,16 @@ export declare class RemoteAdbExecutionEnv implements RemoteExecutionEnv {
58
58
  * (RPC 断、传输掉),不是「一个它看不见的命名空间」⇒ 读边界的执行期复核**拒**,而不是让词法判决站着。
59
59
  * 缺席是今天每个适配器的读法(整链沉默 ⇒ 词法判决成立、命令照跑),所以这一声明是**收紧**方向。
60
60
  * 严格读法 `=== true`(core `canonicalPathIsAuthoritative`);`isSuspendable` 一族同样严格,对本仓的
61
- * 布尔字面量零影响(三键都写死字面 `true`/`false`,不存在「非布尔值被读成缺席」那一形 —— 那一形由
61
+ * 布尔字面量零影响(四键都写死字面 `true`/`false`,不存在「非布尔值被读成缺席」那一形 —— 那一形由
62
62
  * core 的 `config.execution_env_capability_invalid` 普查响亮播报,本仓的受众表已登记)。
63
63
  * 谁该声明由闭集门看着(`test/execution-lane-caps.test.ts` 的逐 lane 对账:新 lane 不表态 = 编译红)。
64
64
  */
65
+ /** `sharesProcessTable`(core 7.57.0,必填):命令跑在设备上(adb shell),不在本进程所在的进程表。 */
65
66
  readonly capabilities: {
66
67
  isolation: boolean;
67
68
  suspendable: boolean;
68
69
  canonicalPathAuthoritative: boolean;
70
+ sharesProcessTable: boolean;
69
71
  };
70
72
  cwd: string;
71
73
  private connected;
@@ -27,7 +27,7 @@ export function isAdbTransportLost(stderr) {
27
27
  return /device offline|device '.*' not found|no devices\/emulators found|connection reset|protocol fault|device disconnected/i.test(stderr);
28
28
  }
29
29
  export class RemoteAdbExecutionEnv {
30
- capabilities = { isolation: false, suspendable: false, canonicalPathAuthoritative: true };
30
+ capabilities = { isolation: false, suspendable: false, canonicalPathAuthoritative: true, sharesProcessTable: false };
31
31
  cwd;
32
32
  connected = false;
33
33
  destroyed = false;
@@ -104,14 +104,16 @@ export declare class RemoteDeviceExecutionEnv implements RemoteExecutionEnv {
104
104
  * (RPC 断、传输掉),不是「一个它看不见的命名空间」⇒ 读边界的执行期复核**拒**,而不是让词法判决站着。
105
105
  * 缺席是今天每个适配器的读法(整链沉默 ⇒ 词法判决成立、命令照跑),所以这一声明是**收紧**方向。
106
106
  * 严格读法 `=== true`(core `canonicalPathIsAuthoritative`);`isSuspendable` 一族同样严格,对本仓的
107
- * 布尔字面量零影响(三键都写死字面 `true`/`false`,不存在「非布尔值被读成缺席」那一形 —— 那一形由
107
+ * 布尔字面量零影响(四键都写死字面 `true`/`false`,不存在「非布尔值被读成缺席」那一形 —— 那一形由
108
108
  * core 的 `config.execution_env_capability_invalid` 普查响亮播报,本仓的受众表已登记)。
109
109
  * 谁该声明由闭集门看着(`test/execution-lane-caps.test.ts` 的逐 lane 对账:新 lane 不表态 = 编译红)。
110
110
  */
111
+ /** `sharesProcessTable`(core 7.57.0,必填):命令跑在注册设备上(设备执行器),不在本进程所在的进程表。 */
111
112
  readonly capabilities: {
112
113
  isolation: boolean;
113
114
  suspendable: boolean;
114
115
  canonicalPathAuthoritative: boolean;
116
+ sharesProcessTable: boolean;
115
117
  };
116
118
  readonly externalContentTarget = true;
117
119
  /** [ref]:设备路径不在 worker 宿主文件系统上 —— host-path 回执消费方据此拒绝把它当宿主路径呈现。 */
@@ -126,7 +126,7 @@ export class RemoteDeviceExecutionEnv {
126
126
  homeDir;
127
127
  cdPath = "unknown";
128
128
  withheldEnv = [];
129
- capabilities = { isolation: false, suspendable: false, canonicalPathAuthoritative: true };
129
+ capabilities = { isolation: false, suspendable: false, canonicalPathAuthoritative: true, sharesProcessTable: false };
130
130
  externalContentTarget = true;
131
131
  hostLocalPaths = false;
132
132
  cwd;
@@ -135,10 +135,12 @@ export declare class RemoteContainerExecutionEnv implements RemoteExecutionEnv,
135
135
  constructor(config: RemoteContainerEnvConfig, deps?: RemoteContainerEnvDeps);
136
136
  /** [ref] §9: E2B is a FULL RemoteExecutionEnv — an isolated, snapshot-suspendable microVM. (SSH/ADB
137
137
  * adapters declare {isolation:false, suspendable:false} — real machines/devices, not snapshotable.) */
138
+ /** `sharesProcessTable`(core 7.57.0,必填):命令跑在云沙箱里,不在本进程所在的进程表。 */
138
139
  readonly capabilities: {
139
140
  isolation: boolean;
140
141
  suspendable: boolean;
141
142
  canonicalPathAuthoritative: boolean;
143
+ sharesProcessTable: boolean;
142
144
  };
143
145
  workspaceHandle(): WorkspaceHandle;
144
146
  /**