@sema-agent/server 7.138.0 → 7.139.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 (215) hide show
  1. package/MIGRATION.md +276 -7
  2. package/README.md +7 -4
  3. package/README.zh-CN.md +6 -4
  4. package/USAGE.md +73 -29
  5. package/deploy/sema-up/README.md +1 -1
  6. package/deploy/sema-up/lib/lanes.json +3 -2
  7. package/dist/adoption/wire.d.ts +3 -3
  8. package/dist/approval-card.d.ts +20 -20
  9. package/dist/bench/s1/arms.d.ts +1 -1
  10. package/dist/bench/s1/arms.js +1 -3
  11. package/dist/bench/s1/run-firm.js +2 -2
  12. package/dist/boot/device-lane.d.ts +8 -3
  13. package/dist/boot/device-lane.js +7 -2
  14. package/dist/boot/execution-env.d.ts +4 -2
  15. package/dist/boot/execution-env.js +16 -24
  16. package/dist/boot/execution-lane.d.ts +1 -1
  17. package/dist/boot/governance-seams.d.ts +3 -2
  18. package/dist/boot/governance-seams.js +9 -11
  19. package/dist/boot/memory-consolidation.js +2 -2
  20. package/dist/boot/resolve-spec.d.ts +1 -1
  21. package/dist/boot/resolve-spec.js +8 -9
  22. package/dist/boot/retention-lane.d.ts +5 -5
  23. package/dist/boot/retention-lane.js +0 -6
  24. package/dist/boot/runner-deps.d.ts +3 -1
  25. package/dist/boot/runner-deps.js +2 -1
  26. package/dist/boot/session-faces.d.ts +0 -3
  27. package/dist/boot/session-faces.js +15 -8
  28. package/dist/boot/stage-02-web-admin-pull.js +2 -2
  29. package/dist/boot/stage-05-execution-env.js +2 -2
  30. package/dist/boot/stage-06-runners.js +0 -1
  31. package/dist/boot/stage-09-leader.js +1 -2
  32. package/dist/boot/stage-10-http-server.js +2 -2
  33. package/dist/boot/stores.js +4 -4
  34. package/dist/boot/web-admin.js +2 -2
  35. package/dist/capabilities/mcp-probe.js +1 -1
  36. package/dist/capabilities/scenarios.d.ts +1 -1
  37. package/dist/capability-facts.d.ts +15 -7
  38. package/dist/capability-facts.js +8 -3
  39. package/dist/config-catalog.js +7 -7
  40. package/dist/config-invariants.d.ts +23 -3
  41. package/dist/config-invariants.js +42 -0
  42. package/dist/config-provider.d.ts +19 -24
  43. package/dist/config-provider.js +27 -12
  44. package/dist/config-types.d.ts +10 -5
  45. package/dist/config.d.ts +33 -0
  46. package/dist/config.js +44 -15
  47. package/dist/delegated-session-owner.d.ts +2 -2
  48. package/dist/deployment-shape.d.ts +1 -1
  49. package/dist/device-enrollment.d.ts +41 -2
  50. package/dist/device-enrollment.js +27 -12
  51. package/dist/device-store.d.ts +58 -85
  52. package/dist/device-store.js +19 -49
  53. package/dist/device-ws-hub.js +2 -1
  54. package/dist/env-name-allowlist-knobs.d.ts +5 -4
  55. package/dist/exec-cut.d.ts +60 -0
  56. package/dist/exec-cut.js +36 -0
  57. package/dist/execution-lane-caps.d.ts +19 -0
  58. package/dist/execution-lane-caps.js +10 -0
  59. package/dist/execution-placement.d.ts +259 -0
  60. package/dist/execution-placement.js +199 -0
  61. package/dist/field-conflict.d.ts +3 -2
  62. package/dist/field-conflict.js +1 -0
  63. package/dist/hosted-posture.d.ts +2 -2
  64. package/dist/http/admission.js +8 -19
  65. package/dist/http/checkpoint-ctx.d.ts +21 -12
  66. package/dist/http/checkpoint-ctx.js +4 -2
  67. package/dist/http/claimed-run.d.ts +1 -1
  68. package/dist/http/durable-run-leg.js +9 -5
  69. package/dist/http/execution-context.d.ts +218 -0
  70. package/dist/http/execution-context.js +330 -0
  71. package/dist/http/resume-legs.js +50 -20
  72. package/dist/http/route-ctx.d.ts +14 -8
  73. package/dist/http/routes/background.d.ts +29 -1
  74. package/dist/http/routes/background.js +29 -2
  75. package/dist/http/routes/devices.d.ts +13 -19
  76. package/dist/http/routes/devices.js +21 -26
  77. package/dist/http/routes/session-sync.js +110 -15
  78. package/dist/http/routes/sessions.d.ts +6 -0
  79. package/dist/http/routes/sessions.js +115 -11
  80. package/dist/http/routes/side-query.d.ts +12 -4
  81. package/dist/http/routes/side-query.js +4 -5
  82. package/dist/http/routes/tasks.js +37 -31
  83. package/dist/http/server.d.ts +35 -19
  84. package/dist/http/server.js +11 -0
  85. package/dist/http/session-inherit-env.d.ts +3 -4
  86. package/dist/http/session-inherit-env.js +1 -1
  87. package/dist/http/wire-types.d.ts +8 -12
  88. package/dist/index.d.ts +1 -0
  89. package/dist/memory-sync-client.d.ts +53 -14
  90. package/dist/memory-sync-client.js +196 -116
  91. package/dist/memory-sync.d.ts +1 -1
  92. package/dist/memory-sync.js +3 -2
  93. package/dist/observability/cost-taxonomy.d.ts +2 -2
  94. package/dist/observability/fail-open.d.ts +12 -0
  95. package/dist/observability/fail-open.js +12 -0
  96. package/dist/observability/run-terminal.js +0 -1
  97. package/dist/orchestration/workflow-notify-journal.js +4 -4
  98. package/dist/org-memory-admission.d.ts +4 -3
  99. package/dist/org-memory-admission.js +6 -6
  100. package/dist/parked-decide.d.ts +34 -9
  101. package/dist/parked-decide.js +12 -19
  102. package/dist/parked-revive-surface.d.ts +12 -15
  103. package/dist/parked-revive-surface.js +3 -6
  104. package/dist/peer-seat.d.ts +13 -7
  105. package/dist/peer-seat.js +37 -3
  106. package/dist/plugins/approval-ask-store-file.d.ts +7 -0
  107. package/dist/plugins/approval-ask-store-file.js +11 -0
  108. package/dist/plugins/approval-ask-store-memory.d.ts +6 -0
  109. package/dist/plugins/approval-ask-store-memory.js +12 -0
  110. package/dist/plugins/approval-ask-store-sql.d.ts +22 -5
  111. package/dist/plugins/approval-ask-store-sql.js +30 -12
  112. package/dist/plugins/caching-session-store.d.ts +6 -2
  113. package/dist/plugins/caching-session-store.js +9 -3
  114. package/dist/plugins/checkpoint-store-sql.d.ts +6 -5
  115. package/dist/plugins/checkpoint-store-sql.js +5 -7
  116. package/dist/plugins/device-store-sql.d.ts +25 -51
  117. package/dist/plugins/device-store-sql.js +49 -103
  118. package/dist/plugins/execution-placement-sql.d.ts +151 -0
  119. package/dist/plugins/execution-placement-sql.js +223 -0
  120. package/dist/plugins/file-history-store-sql.d.ts +7 -6
  121. package/dist/plugins/file-history-store-sql.js +5 -5
  122. package/dist/plugins/file-run-store.d.ts +6 -1
  123. package/dist/plugins/file-run-store.js +4 -0
  124. package/dist/plugins/fork-routing-session-store.d.ts +6 -0
  125. package/dist/plugins/fork-routing-session-store.js +16 -0
  126. package/dist/plugins/local-session-store.d.ts +149 -50
  127. package/dist/plugins/local-session-store.js +382 -107
  128. package/dist/plugins/memory-key-guards.d.ts +4 -0
  129. package/dist/plugins/memory-key-guards.js +1 -0
  130. package/dist/plugins/memory-run-store.d.ts +1 -1
  131. package/dist/plugins/one-time-migrations.d.ts +111 -3
  132. package/dist/plugins/one-time-migrations.js +380 -10
  133. package/dist/plugins/outcome-ledger-sql.d.ts +2 -2
  134. package/dist/plugins/permission-rule-store-sql.d.ts +13 -15
  135. package/dist/plugins/permission-rule-store-sql.js +13 -16
  136. package/dist/plugins/pg-pool.d.ts +7 -3
  137. package/dist/plugins/pg-session-storage.d.ts +31 -20
  138. package/dist/plugins/pg-session-storage.js +120 -52
  139. package/dist/plugins/remote-env-adb.d.ts +20 -4
  140. package/dist/plugins/remote-env-adb.js +70 -62
  141. package/dist/plugins/remote-env-device.d.ts +30 -12
  142. package/dist/plugins/remote-env-device.js +18 -17
  143. package/dist/plugins/remote-env-e2b.d.ts +18 -4
  144. package/dist/plugins/remote-env-e2b.js +41 -42
  145. package/dist/plugins/remote-env-file-error.d.ts +2 -0
  146. package/dist/plugins/remote-env-file-error.js +1 -1
  147. package/dist/plugins/remote-env-host.d.ts +21 -8
  148. package/dist/plugins/remote-env-host.js +66 -70
  149. package/dist/plugins/remote-env-k8s.d.ts +11 -16
  150. package/dist/plugins/remote-env-k8s.js +61 -54
  151. package/dist/plugins/remote-env-local-docker.d.ts +29 -6
  152. package/dist/plugins/remote-env-local-docker.js +46 -59
  153. package/dist/plugins/remote-env-ssh.d.ts +16 -4
  154. package/dist/plugins/remote-env-ssh.js +72 -56
  155. package/dist/plugins/remote-shell.d.ts +100 -3
  156. package/dist/plugins/remote-shell.js +166 -2
  157. package/dist/plugins/retention-store-sql.d.ts +9 -6
  158. package/dist/plugins/retention-store-sql.js +7 -1
  159. package/dist/plugins/run-store-sql.d.ts +13 -3
  160. package/dist/plugins/run-store-sql.js +5 -0
  161. package/dist/plugins/schema-consistency.d.ts +15 -5
  162. package/dist/plugins/schema-consistency.js +14 -8
  163. package/dist/plugins/schema-drift-sentence.d.ts +27 -0
  164. package/dist/plugins/schema-drift-sentence.js +38 -0
  165. package/dist/plugins/session-fork.d.ts +54 -0
  166. package/dist/plugins/session-fork.js +38 -1
  167. package/dist/plugins/session-row-sql.d.ts +22 -0
  168. package/dist/plugins/session-row-sql.js +81 -0
  169. package/dist/plugins/session-store.d.ts +1 -1
  170. package/dist/plugins/store-backend.d.ts +1 -1
  171. package/dist/plugins/store-backend.js +19 -7
  172. package/dist/plugins/store-contracts.d.ts +25 -5
  173. package/dist/plugins/tidb-pool.d.ts +12 -7
  174. package/dist/plugins/tidb-pool.js +9 -3
  175. package/dist/plugins/tidb-session-store.d.ts +57 -26
  176. package/dist/plugins/tidb-session-store.js +118 -50
  177. package/dist/plugins/tool-result-store-sql.d.ts +3 -2
  178. package/dist/plugins/tool-result-store-sql.js +18 -10
  179. package/dist/project-memory.d.ts +9 -15
  180. package/dist/project-memory.js +5 -4
  181. package/dist/request-key-closure.js +1 -1
  182. package/dist/request-value-closure.d.ts +6 -7
  183. package/dist/request-value-closure.js +1 -3
  184. package/dist/run-local.d.ts +6 -0
  185. package/dist/run-local.js +24 -20
  186. package/dist/runs.d.ts +2 -1
  187. package/dist/runs.js +3 -1
  188. package/dist/security.d.ts +38 -12
  189. package/dist/security.js +8 -2
  190. package/dist/server-secret-env.d.ts +9 -5
  191. package/dist/session-children-retire.d.ts +27 -9
  192. package/dist/session-children-retire.js +9 -5
  193. package/dist/session-incarnation.d.ts +19 -9
  194. package/dist/session-incarnation.js +4 -2
  195. package/dist/session-row.d.ts +114 -5
  196. package/dist/session-row.js +55 -5
  197. package/dist/session-titler.d.ts +2 -2
  198. package/dist/task-a2a.d.ts +1 -1
  199. package/dist/task-cwd.d.ts +6 -1
  200. package/dist/task-mcp.d.ts +3 -3
  201. package/dist/tool-approval.d.ts +6 -0
  202. package/dist/tool-approval.js +8 -1
  203. package/dist/trace/project.d.ts +1 -0
  204. package/dist/trace/project.js +1 -0
  205. package/dist/trace/self-minted-wire.d.ts +111 -2
  206. package/dist/trace/self-minted-wire.js +38 -1
  207. package/dist/url-refusal.d.ts +1 -1
  208. package/dist/web-admin-client/apply-effective.d.ts +17 -3
  209. package/dist/web-admin-client/apply-effective.js +20 -10
  210. package/dist/web-admin-client/skills-mcp.js +3 -2
  211. package/dist/web-admin-client/types.d.ts +6 -0
  212. package/package.json +3 -3
  213. package/wire-changes/server@7.139.0.json +348 -0
  214. package/dist/http/device-placement.d.ts +0 -175
  215. package/dist/http/device-placement.js +0 -145
@@ -15,8 +15,15 @@ import type { ParkedReviveSurfaceFactory } from "./parked-revive-surface.js";
15
15
  import type { RebuiltInheritedGate } from "./boot/parked-revive-gate.js";
16
16
  import { type WorkflowRunParkKey, type WorkflowAgentSessionIndex } from "./orchestration/workflow-agent-session-index.js";
17
17
  import type { WorkflowParkDecisionStore, WorkflowParkOutcome } from "./orchestration/workflow-park-decision.js";
18
- import { type HeadRead } from "./session-incarnation.js";
18
+ import type { SessionFace } from "./session-incarnation.js";
19
+ import { type ExecutionCommitDeps, type RootedExecutionContext } from "./http/execution-context.js";
19
20
  import type { GateIdentity } from "./checkpoint-identity.js";
21
+ /**
22
+ * 本腿的**装配前提**(`createHttpServer` 起服拒的拒句;规则见 {@link ParkedDecideDeps} 的 `execution`):接了本腿(`parkedReviveSurface` 在场)的部署,
23
+ * 会话店必须带执行席 —— 认领之后对根会话住客的唯一判官是执行席上的写口,无席 ⇒ 认领之后没有任何判官(安全轴上不留静默放开的臂,所以拒启,
24
+ * 不留「无席时照旧两读」的臂)。
25
+ */
26
+ export declare const PARKED_REVIVE_EXECUTION_SEAT_REQUIRED = "the parked-agent revive leg (parkedReviveSurface) needs a session store with an execution placement seat (sessionStorage.executionPlacement) \u2014 after the claim, the root session's occupant is judged only by the write on that seat; every built-in session store carries one (the MySQL-protocol and PostgreSQL stores and the local store, i.e. the session store a StoreBackend hands out; the caching and fork-routing wrappers forward it), and an in-memory test or embedding passes InMemoryExecutionPlacementSeat; refusing to start rather than revive parked agents with nobody judging the root session's occupant after the claim";
20
27
  export interface ParkedAgentMatch {
21
28
  handle: string;
22
29
  row: BackgroundAgentRecord;
@@ -151,14 +158,6 @@ export interface ParkedDecideDeps {
151
158
  * checkpointStore 同实例,[ref] 设计稿 §4),以及与它同出一张列表的继承座 `mountsCallerTool`(一层行才有;属主与全部判据
152
159
  * 在 `parked-revive-surface.ts`)。修前是 boot 期的一只裸 Agent 工具、从不交座 ⇒ 7.32.0 起停在继承工具上的 allow 恒 422。 */
153
160
  reviveSurface: ParkedReviveSurfaceFactory;
154
- /**
155
- * [ref]([ref] §4)—— 会话店按 id 读一行的唯一读口(`sessionStorage.headOf`:属主 + 化身 + 叶子一次读;活住客经 `sessionFaceOfHead`
156
- * 投影,空化身响亮拒)。**必填**:本腿对根会话读两次 ——
157
- * claim 之前一次、claim 成功之后一次 —— 会话不在或两次不是同一位住客 ⇒ 回滚 claim、答既有的 `409 decide.not_parked`;赎回面只在
158
- * 属主相等且两次是同一位住客时交继承座。读抛 ⇒ claim 前原样上抛(没有东西要回滚),claim 后先回滚再上抛。
159
- * ([ref] 那只可选的 `sessionOwnerOf` 退役:可选 ⇒ 缺席时静默不复核,正是 [ref] 那个窗的形。)
160
- */
161
- headOf: HeadRead;
162
161
  /** revive args 的 `subagent_type` 成员性判定集([ref] Q1 修正:非 roster 成员必须省略,否则
163
162
  * unknown subagent_type 拒;字面比对 general-purpose 在 roster 撞名时会错省)。缺省空集=恒省略。 */
164
163
  knownAgentTypes?: ReadonlySet<string>;
@@ -187,7 +186,33 @@ export interface ParkedDecideDeps {
187
186
  owner: string | null;
188
187
  taskId: string;
189
188
  sessionId: string;
189
+ executionLane: import("./execution-lane-caps.js").ExecutionLane;
190
190
  }) => NonNullable<ToolExecuteContext["onAsk"]>;
191
+ /**
192
+ * 边界 9 的执行上下文(设计 §3.2;与提交腿 / 续跑家族同一只解析器、同一只写口)—— 本腿对根会话的住客**一读一判**:
193
+ * · `resolve` —— **认领之前**按行的根取上下文:解析器那一次行读(执行席:住客 + 格)就是本腿对根会话的**唯一一读**;根行不在 ⇒ `gone`
194
+ * (本腿答既有的 `decide.not_parked`);其余拒(mirror / 车道不再服务 / 主体的车道裁定)按续跑家族的折叠律答好交回(`answer`)—— 不认领、不驱动。
195
+ * 成功形的 `occupant` **必有**:接本腿的部署必须带执行席(无席的会话店交不出这一读;`createHttpServer` 在装配时拒无席形);
196
+ * · `commitDeps` —— 认领之后 `commitExecutionContext` 读的部署件:唯一写口按那一读的住客定址写 / 再读(化身 ∧ 属主),这就是认领之后对根住客的
197
+ * **唯一一判**(拒 ⇒ 先回滚认领)。写口放行的那一位就是赎回面的根住客见证;写口之后本腿不再读根。
198
+ * 复活子代的审批席带这只上下文的车道(`approverSeatFor` 的 `executionLane`)。
199
+ */
200
+ execution: {
201
+ resolve: (root: string) => Promise<{
202
+ readonly ok: true;
203
+ readonly ctx: RootedExecutionContext & {
204
+ readonly occupant: SessionFace;
205
+ };
206
+ } | {
207
+ readonly ok: false;
208
+ readonly gone: true;
209
+ } | {
210
+ readonly ok: false;
211
+ readonly gone: false;
212
+ readonly answer: ParkedDecideResult;
213
+ }>;
214
+ commitDeps: ExecutionCommitDeps;
215
+ };
191
216
  warn?: (event: string, fields: Record<string, unknown>) => void;
192
217
  }
193
218
  export interface ParkedDecideRequest {
@@ -1,14 +1,15 @@
1
- import { compareOccupants } from "@sema-agent/core";
2
1
  import { readClosedWordSeat } from "./approval-card.js";
3
2
  import { routeWorkflowPark } from "./orchestration/workflow-agent-session-index.js";
4
3
  import { recordFailOpen } from "./observability/fail-open.js";
5
4
  import { defaultSubagentTailBus } from "./fleet/subagent-tail-bus.js";
6
5
  import { backgroundScopesForCheckpointScope, decodeCheckpointScope, ownerOfCheckpointScope } from "./security.js";
7
6
  import { ASK_USER_QUESTION_TOOL_NAME, isEngineQuestionRow } from "./approval-content-kind.js";
8
- import { sessionFaceOfHead } from "./session-incarnation.js";
9
7
  import { refusalBody, resumeRefusalFacts } from "./http/refusal-body.js";
10
8
  import { backgroundAgentNameOf } from "./background-agent-name.js";
11
9
  import { buildSettlementEvidence } from "./gate-settlement.js";
10
+ import { commitExecutionContext } from "./http/execution-context.js";
11
+ import { EXECUTION_PLACEMENT_SEAT_CARRIERS } from "./execution-placement.js";
12
+ export const PARKED_REVIVE_EXECUTION_SEAT_REQUIRED = `the parked-agent revive leg (parkedReviveSurface) needs a session store with an execution placement seat (sessionStorage.executionPlacement) — after the claim, the root session's occupant is judged only by the write on that seat; ${EXECUTION_PLACEMENT_SEAT_CARRIERS}; refusing to start rather than revive parked agents with nobody judging the root session's occupant after the claim`;
12
13
  export async function findParkOrigin(sources, cp) {
13
14
  if (sources.agentStore !== undefined) {
14
15
  let match;
@@ -270,9 +271,10 @@ export async function decideParkedAgent(deps, req, match) {
270
271
  if (revivability.kind === "unrevivable")
271
272
  return req.decision === "deny" ? voidUnrevivableCard(deps, req, match) : revivability.approveRefusal;
272
273
  const root = revivability.root;
273
- const faceBefore = sessionFaceOfHead(root, await deps.headOf(root));
274
- if (faceBefore === undefined)
275
- return sessionGone(match.handle);
274
+ const resolved = await deps.execution.resolve(root);
275
+ if (!resolved.ok)
276
+ return resolved.gone ? sessionGone(match.handle) : resolved.answer;
277
+ const execution = resolved.ctx;
276
278
  const inheritedGate = deps.rebuildInheritedGate ? await deps.rebuildInheritedGate(row, { principal: decodeCheckpointScope(req.scope) }) : undefined;
277
279
  const stores = { agentStore: deps.agentStore, checkpointStore: deps.checkpointStore };
278
280
  const claim = await deps.registry.claimParkedAgent(stores, match.handle, { scope: row.scope, owner: row.owner });
@@ -312,21 +314,12 @@ export async function decideParkedAgent(deps, req, match) {
312
314
  await rollback("claimed_token_moved");
313
315
  return gateMoved(match.handle);
314
316
  }
315
- let faceAfter;
316
- try {
317
- faceAfter = sessionFaceOfHead(root, await deps.headOf(root));
318
- }
319
- catch (e) {
320
- await rollback("session_face_unreadable");
321
- throw e;
322
- }
323
- if (compareOccupants(faceBefore, faceAfter) !== "same") {
324
- await rollback("session_occupant_changed");
325
- return sessionGone(match.handle);
326
- }
317
+ const placementRefusal = await commitExecutionContext(deps.execution.commitDeps, { kind: "parked", taskId: match.handle, rollback, goneAnswer: sessionGone(match.handle) }, execution);
318
+ if (placementRefusal)
319
+ return placementRefusal;
327
320
  let surface;
328
321
  try {
329
- surface = deps.reviveSurface(row, { principal: decodeCheckpointScope(req.scope), root: { before: faceBefore, after: faceAfter } });
322
+ surface = deps.reviveSurface(row, { principal: decodeCheckpointScope(req.scope), rootOccupant: execution.occupant });
330
323
  }
331
324
  catch (e) {
332
325
  await rollback("revive_surface_threw");
@@ -350,7 +343,7 @@ export async function decideParkedAgent(deps, req, match) {
350
343
  toolCallId: `drv-${ticket.claimId}`,
351
344
  ...(surface.mountsCallerTool !== undefined ? { mountsCallerTool: surface.mountsCallerTool } : {}),
352
345
  ...(deps.approverSeatFor !== undefined && seatRoot !== undefined
353
- ? { onAsk: deps.approverSeatFor({ owner: ownerOfCheckpointScope(req.scope), taskId: match.handle, sessionId: seatRoot }) }
346
+ ? { onAsk: deps.approverSeatFor({ owner: ownerOfCheckpointScope(req.scope), taskId: match.handle, sessionId: seatRoot, executionLane: execution.lane }) }
354
347
  : {}),
355
348
  reviveClaim: {
356
349
  row: { ...ticket.row, parkClaimId: ticket.claimId },
@@ -11,12 +11,12 @@
11
11
  * 给那个会话的 run 组 `spec.tools` 的同一只工厂、同一个会话分区(任务清单店 `taskListStoreFor(root)` 与领队那一张是同一张;
12
12
  * 多租户的 WebFetch 过滤同样生效 ⇒ 子代同样拿不到)。座 = `(t) => tools.includes(t)`(身份判:同名副本不算)。
13
13
  * · **禁**用 boot 期单例填座:那只 bundle 没有会话,它的任务清单是一张私有表 —— 填了座,每个会话 / 租户的复活队友都会共用它。
14
- * · **根会话现在还是这张卡的那一位住客**才填座(codex r1 [high] + [ref] 加严):任务清单店只按会话 id 分区,而会话 id 由调用方自选、
15
- * 删后可被别人(或同一个人)重领 —— 根会话删了(或删了重建)之后赎回一张旧卡,按 id 重解出来的是**下一位住客**的清单。判据两条都要:
16
- * ① 根会话的属主与卡的权威 principal 逐字相等;② 赎回腿 claim 前 / 后两次读到的是**同一位住客**(化身逐字相等,
17
- * core 的 `compareOccupants` 答 `same`;`unknown` 不算)—— 只比属主比不出「同一个人删了重建」。任一条不成立 / 读数缺席 ⇒ 不交座
18
- * (fail-closed:子代不继承,继承工具上的 allow 回 422、审批待决,deny 照结)。赎回腿自己在两次读不一致时整条拒(`409 decide.not_parked`),
19
- * 这里的第 ② 条是同一条事实在交座这一处的判据(规则只写在这里,腿只负责读两次)。根删除时把子孙的 parked 卡一并作废 = [ref] 件 B
14
+ * · **根会话属于这张卡的租户**才填座(codex r1 [high]):任务清单店只按会话 id 分区,而会话 id 由调用方自选、删后可被别人(或同一个人)重领 ——
15
+ * 根会话删了(或删了重建)之后赎回一张旧卡,按 id 重解出来的是**下一位住客**的清单。两件事,两处判,各判一次:
16
+ * ① **住客判**(根会话现在还是这次决定判过的那一位:化身 ∧ 属主)只在赎回腿认领之后的唯一写口(执行放置,按解析器那一读的住客定址)——
17
+ * 不是那一位 ⇒ 赎回腿整条拒(`409 decide.not_parked`),走不到这里;只比属主比不出「同一个人删了重建」,所以住客判比化身;
18
+ * ② **租户判**在这里:写口放行的那一位(`rootOccupant`)的属主与卡的权威 principal 逐字相等(匿名会话 `null` ≡ 无 principal)。
19
+ * 不成立 ⇒ 不交座(fail-closed:子代不继承,继承工具上的 allow 回 422、审批待决,deny 照结)。根删除时把子孙的 parked 卡一并作废 = [ref] 件 B
20
20
  * (`boot/session-faces.ts` 的删除协调器)。
21
21
  * · 只对**一层**行填座(`rootSessionId` 在场且 = `parentSessionId`:派它的 run 就是根会话的 run,列表正是那个 run 的挂载)。更深的行
22
22
  * (孙代:派它的是一个子代,它的挂载是继承裁剪后的子集,行上读不出来)不填 —— 无座 = core 的 fail-closed 臂;
@@ -25,9 +25,9 @@
25
25
  * 座只判 bundle 实例的成员性,继承面是 `[...web, ...scaffolds]`,那两项差异不改变谁在面内。
26
26
  *
27
27
  * **属主来源([ref] / [ref])与交座是两个独立判据**:赎回工具的停驻挂点(复活的子代再要审批时停驻,`plugins/session-store.ts`)按委派继承律定宿主行属主,
28
- * 律要的是**委派树根的见证**:根 id(行上本来就带 `rootSessionId`)+ 判断那一刻读到的根行住客(赎回腿 claim 前后两读的那一位 —— 两读不是同一位时赎回腿整条拒,
29
- * 走不到这里;读数缺席 ⇒ 见证不带住客,挂点那一刻根行在 ⇒ 停驻否决)。所以**每一行**都交出它({@link ScenarioContext} 的 `delegationRoot`):交座的一层行以
30
- * `{ sessionId: root, delegationRoot }` 重解,不交座的行(更深的行、根住客已变的一层行、读数缺席)以 `{ delegationRoot }` 重解 —— 只喂停驻挂点,不挂任何会话级设施、
28
+ * 律要的是**委派树根的见证**:根 id(行上本来就带 `rootSessionId`)+ 判断那一刻的根行住客 = 写口放行的那一位(赎回腿解析器那一读;不是那一位时
29
+ * 写口整条拒,走不到这里)—— 见证恒带住客。所以**每一行**都交出它({@link ScenarioContext} 的 `delegationRoot`):交座的一层行以
30
+ * `{ sessionId: root, delegationRoot }` 重解,不交座的行(更深的行、根属主不是卡的租户的一层行)以 `{ delegationRoot }` 重解 —— 只喂停驻挂点,不挂任何会话级设施、
31
31
  * 不带 principal,交座判据(上面三条)一个字不动。行上没有根 ⇒ boot 期那只裸 Agent(没有根可交 ⇒ 律给哨兵)。挂点那一刻根行已不在 ⇒ 律给哨兵;在而不是见证的那一位
32
32
  * (批准之后删了、同 id 被重新登记)⇒ 停驻否决 —— 两件都在挂点那一侧判,与这里无关。
33
33
  */
@@ -41,15 +41,12 @@ export interface ParkedReviveSurface {
41
41
  readonly mountsCallerTool?: (tool: ToolSpec) => boolean;
42
42
  }
43
43
  /**
44
- * 按行解析赎回面的入参身份:`principal` = 权威 principal(取 checkpoint 的 scope 列,不取行上有损的 `row.scope`);`root` = 赎回腿对行的
45
- * 根会话读到的两次会话面(claim 之前 / 之后;属主 `null` = 匿名会话)。缺席 = 没有读数 ⇒ 不交座。
44
+ * 按行解析赎回面的入参身份:`principal` = 权威 principal(取 checkpoint 的 scope 列,不取行上有损的 `row.scope`);`rootOccupant` = 写口放行的那一位
45
+ * 根会话住客(赎回腿认领之前解析器那一次行读;属主 `null` = 匿名会话)—— 型上恒在:接赎回腿的部署必须带执行席。
46
46
  */
47
47
  export interface ParkedReviveSurfaceIdentity {
48
48
  readonly principal: string | undefined;
49
- readonly root?: {
50
- readonly before: SessionFace;
51
- readonly after: SessionFace;
52
- };
49
+ readonly rootOccupant: SessionFace;
53
50
  }
54
51
  /** 按行解析赎回面。 */
55
52
  export type ParkedReviveSurfaceFactory = (row: BackgroundAgentRecord, identity: ParkedReviveSurfaceIdentity) => ParkedReviveSurface;
@@ -1,4 +1,4 @@
1
- import { DEFAULT_SUBAGENT_TOOL_NAME, compareOccupants } from "@sema-agent/core";
1
+ import { DEFAULT_SUBAGENT_TOOL_NAME } from "@sema-agent/core";
2
2
  const agentOf = (tools) => tools.find((t) => t.name === DEFAULT_SUBAGENT_TOOL_NAME);
3
3
  export function createParkedReviveSurface(defaultScenario) {
4
4
  const bootTool = agentOf(defaultScenario({}).tools);
@@ -9,7 +9,7 @@ export function createParkedReviveSurface(defaultScenario) {
9
9
  if (root === undefined)
10
10
  return { reviveTool: bootTool };
11
11
  const seated = holdsSeat(row.parentSessionId === root, identity);
12
- const delegationRoot = { sessionId: root, incarnation: identity.root?.after.incarnation ?? null };
12
+ const delegationRoot = { sessionId: root, incarnation: identity.rootOccupant.incarnation };
13
13
  const tools = (seated ? defaultScenario({}, identity.principal, { sessionId: root, delegationRoot }) : defaultScenario({}, undefined, { delegationRoot })).tools;
14
14
  const reviveTool = agentOf(tools);
15
15
  if (reviveTool === undefined)
@@ -18,9 +18,6 @@ export function createParkedReviveSurface(defaultScenario) {
18
18
  };
19
19
  }
20
20
  function holdsSeat(oneLevel, identity) {
21
- const face = identity.root;
22
- if (!oneLevel || face === undefined || compareOccupants(face.before, face.after) !== "same")
23
- return false;
24
- return (face.before.owner ?? undefined) === identity.principal && (face.after.owner ?? undefined) === identity.principal;
21
+ return oneLevel && (identity.rootOccupant.owner ?? undefined) === identity.principal;
25
22
  }
26
23
  //# sourceMappingURL=parked-revive-surface.js.map
@@ -1,4 +1,4 @@
1
- import type { A2aServerSpec, McpServerSpec, PeerAnswer, PeerQuestion, PeerSeatTarget } from "@sema-agent/core";
1
+ import type { A2aServerSpec, McpServerSpec, PeerAnswer, PeerQuestion, PeerSeat, PeerSeatTarget } from "@sema-agent/core";
2
2
  import { type Logger } from "./observability/logger.js";
3
3
  import { type SafeText } from "./trace/exit-seat.js";
4
4
  type StdioArm = Extract<McpServerSpec["transport"], {
@@ -95,16 +95,22 @@ export interface PeerLease {
95
95
  /** Stop holding. Entries no other holder holds are dropped now. A second call does nothing. */
96
96
  release(): void;
97
97
  }
98
- /** What the engine asks: the pieces for one send. The seat holds nothing itself — an entry's holders are host leases only. */
99
- export interface PeerDeskSeat {
100
- /** The pieces for one send, or a throw (refused: logged as one `peer_seat_refused` row naming protocol, peer and reason only). */
101
- answer(question: PeerQuestion): Promise<PeerAnswer>;
102
- }
103
- /** Live entries and the host leases holding them, summed across entries — the registry's whole observable state. */
98
+ /** Live entries and their holders — host leases and engine holds — summed across entries: the registry's whole observable state. */
104
99
  export interface PeerDeskSize {
105
100
  readonly entries: number;
106
101
  readonly holders: number;
107
102
  }
103
+ /**
104
+ * The engine's seat as the desk is it (`RunnerDeps.peerSeat`, the probe's and the panels' `peerSeat`): `answer` the pieces for one send, or a
105
+ * throw (refused: logged as one `peer_seat_refused` row naming protocol, peer and reason only) — it reads no signal (the desk does no IO);
106
+ * `hold` / `release` one engine holder of a reference this desk minted. Every hold the desk cannot count throws — a reference this desk did not
107
+ * mint (any kind but `handle`), a handle whose entry is gone — and so does a release with no counted hold: the engine reads a hold that returns
108
+ * as held, so a quiet return would be a hold nobody keeps (security axis, fail-closed). The engine reports each throw once as `peer-seat-hold`;
109
+ * a refused hold holds nothing and gets no release, a refused release changes nothing.
110
+ */
111
+ export interface PeerDeskSeat extends PeerSeat {
112
+ answer(question: PeerQuestion, signal?: AbortSignal): Promise<PeerAnswer>;
113
+ }
108
114
  export interface PeerDesk {
109
115
  readonly seat: PeerDeskSeat;
110
116
  /**
package/dist/peer-seat.js CHANGED
@@ -104,7 +104,7 @@ export function createPeerDesk(opts = {}) {
104
104
  return entry;
105
105
  };
106
106
  const dropIfUnheld = (id, entry) => {
107
- if (entry.hosts.size === 0)
107
+ if (entry.hosts.size === 0 && entry.engineHolds === 0)
108
108
  table.delete(id);
109
109
  };
110
110
  const liveState = (lease) => {
@@ -151,7 +151,41 @@ export function createPeerDesk(opts = {}) {
151
151
  return { kind: "carry", env: entry.pieces };
152
152
  }
153
153
  };
154
+ const handleIdOf = (ref) => {
155
+ switch (ref.kind) {
156
+ case "handle":
157
+ return ref.id;
158
+ case "env":
159
+ case "vault":
160
+ case "assertion":
161
+ return undefined;
162
+ default: {
163
+ const unknownKind = ref;
164
+ void unknownKind;
165
+ return undefined;
166
+ }
167
+ }
168
+ };
154
169
  const seat = Object.freeze({
170
+ hold: (ref) => {
171
+ const id = handleIdOf(ref);
172
+ if (id === undefined)
173
+ throw new Error("peer desk: hold of a reference this desk did not mint — not held");
174
+ const entry = table.get(id);
175
+ if (entry === undefined)
176
+ throw new Error("peer desk: hold of a handle whose entry is gone — not held (a hold never revives an entry)");
177
+ entry.engineHolds += 1;
178
+ },
179
+ release: (ref) => {
180
+ const id = handleIdOf(ref);
181
+ if (id === undefined)
182
+ throw new Error("peer desk: release of a reference this desk did not mint — nothing released");
183
+ const entry = table.get(id);
184
+ if (entry === undefined || entry.engineHolds === 0)
185
+ throw new Error("peer desk: release of a handle with no counted engine hold — nothing released");
186
+ entry.engineHolds -= 1;
187
+ dropIfUnheld(id, entry);
188
+ },
155
189
  answer: async (question) => {
156
190
  const target = question.peer;
157
191
  const ref = target.credentials;
@@ -193,7 +227,7 @@ export function createPeerDesk(opts = {}) {
193
227
  const id = mintId(canonical);
194
228
  const known = table.get(id);
195
229
  if (known === undefined) {
196
- table.set(id, { binding, lane, pieces, hosts: new Set([holder.id]) });
230
+ table.set(id, { binding, lane, pieces, hosts: new Set([holder.id]), engineHolds: 0 });
197
231
  }
198
232
  else if (canonicalOf(known.binding, known.lane, known.pieces) !== canonical) {
199
233
  throw new PeerDeskError("peer_binding_conflict", "peer desk: two different declarations were minted one id; the registered one stands");
@@ -212,7 +246,7 @@ export function createPeerDesk(opts = {}) {
212
246
  size: () => {
213
247
  let holders = 0;
214
248
  for (const entry of table.values())
215
- holders += entry.hosts.size;
249
+ holders += entry.hosts.size + entry.engineHolds;
216
250
  return { entries: table.size, holders };
217
251
  },
218
252
  });
@@ -1,3 +1,4 @@
1
+ import { type ExecutionLane } from "../execution-lane-caps.js";
1
2
  import type { AskState } from "../approval-ask-machine.js";
2
3
  import type { SettleCause } from "../approval-deny-reasons.js";
3
4
  import type { AskCallCoordinate, AskRow, AskTerminalClaimIntent, AskTerminalClaimOpts, AskTerminalClaimOutcome, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, DecideResult, EnsureAskResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
@@ -209,6 +210,12 @@ export declare class FileApprovalAskStore implements ApprovalAskStore {
209
210
  };
210
211
  /** 释放追加 fd(`LocalBackend.close` → 优雅重启重开)。不包 try/catch 的理由同
211
212
  * {@link FileApprovalNonceStore.dispose}:core 的 `AppendLog.close()` 自己就是全函数。 */
213
+ /**
214
+ * 执行放置切换 ④b 的 local 腿(一次性迁移登记表 M32;`LocalBackend.runOneTimeMigrations` 在任何读者之前调):升级前写下的问(日志 `ensureAsk` 记录行 /
215
+ * 压实快照行没有 `executionLane`)记成本部署车道表的唯一项,然后**一次压实**把整本账重写成带车道的快照(压实 = 既有整本原子重写)。
216
+ * 没有缺车道的行 ⇒ 零写。车道表不是一元而还有缺车道的行 ⇒ 抛与 SQL 同一只拒(`backfill_ambiguous`,由调用方铸),一个字节没写。
217
+ */
218
+ backfillExecutionLanes(lanes: readonly ExecutionLane[], refuse: (pending: number) => never): number;
212
219
  dispose(): void;
213
220
  }
214
221
  export {};
@@ -1,4 +1,5 @@
1
1
  import { closeSync, copyFileSync, openSync, readSync, statSync, existsSync } from "node:fs";
2
+ import { isExecutionLane } from "../execution-lane-caps.js";
2
3
  import { join } from "node:path";
3
4
  import { AppendLog, atomicWriteFile, ensureDir } from "@sema-agent/core";
4
5
  import { replayJsonlSkipping } from "../observability/corrupt-read-seat.js";
@@ -295,6 +296,16 @@ export class FileApprovalAskStore {
295
296
  replayStats() {
296
297
  return { rejected: this.replayRejectedCount, corruptLines: this.corruptLineCount, records: this.records };
297
298
  }
299
+ backfillExecutionLanes(lanes, refuse) {
300
+ const pending = this.core.exportRows().asks.filter((r) => !isExecutionLane(String(r.executionLane ?? ""))).length;
301
+ if (pending === 0)
302
+ return 0;
303
+ if (lanes.length !== 1)
304
+ refuse(pending);
305
+ const filled = this.core.fillMissingExecutionLanes(lanes[0]);
306
+ this.compact();
307
+ return filled;
308
+ }
298
309
  dispose() {
299
310
  this.log?.close();
300
311
  this.log = undefined;
@@ -29,6 +29,7 @@
29
29
  * - resolveProvisional 故意不经 canAskTransition(同 SQL twin 头注:这是版本化补偿的例外通道)。
30
30
  */
31
31
  import { type AskState, type BatchState } from "../approval-ask-machine.js";
32
+ import { type ExecutionLane } from "../execution-lane-caps.js";
32
33
  import type { AskCallCoordinate, AskDecision, AskRow, AskTerminalClaimIntent, AskTerminalClaimOpts, AskTerminalClaimOutcome, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, EnsureAskResult, DecideResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
33
34
  /**
34
35
  * [ref] 的 `updatedInput` **入店编码**——两 twin 共用的那一只(本函数是唯一真源)。
@@ -72,6 +73,11 @@ export declare class InMemoryApprovalAskStore implements ApprovalAskStore {
72
73
  * 两种可变性)。
73
74
  */
74
75
  exportRows(): ApprovalAskRowsSnapshot;
76
+ /**
77
+ * 执行放置切换 ④b 的 local 腿(File 形的内核口;`FileApprovalAskStore.backfillExecutionLanes` 调):出处车道还没记下的行(升级前写下的日志 / 快照行
78
+ * 装回后这一格缺席)记成 `lane`(一元车道表的唯一项 —— 调用方判过)。返回改了的行数;只碰缺这一格的行。
79
+ */
80
+ fillMissingExecutionLanes(lane: ExecutionLane): number;
75
81
  /** [ref]:一行的**同步**点读(拷贝;纯读,零判定)—— File 形在写口的同步前缀里用它(见类顶注)。 */
76
82
  peekAsk(askId: string): AskRow | null;
77
83
  /** [ref]:把快照里的行**原样**装回两张 Map(同键覆盖)。零谓词、零状态机 —— 它不是一条转移,是重放
@@ -1,4 +1,5 @@
1
1
  import { ASK_TERMINAL, canAskTransition } from "../approval-ask-machine.js";
2
+ import { isExecutionLane } from "../execution-lane-caps.js";
2
3
  import { APPROVAL_ENDING_CAUSES } from "../approval-deny-reasons.js";
3
4
  import { askRowCarriesOwner, askRowOnCall, assertDecideSettledBy, assertIdempotencyKeyShape, assertIntegralEpochMs, assertReapBounds, ApprovalAskReadAbortedError, claimTargetState, POLICY_REFUSAL_DECISION, projectAskTerminalClaimTruth, projectDecideReading } from "./approval-ask-store-sql.js";
4
5
  function applyPatch(row, patch) {
@@ -41,6 +42,16 @@ export class InMemoryApprovalAskStore {
41
42
  exportRows() {
42
43
  return { asks: [...this.asks.values()].map((r) => ({ ...r })), batches: [...this.batches.values()].map((b) => ({ ...b })) };
43
44
  }
45
+ fillMissingExecutionLanes(lane) {
46
+ let filled = 0;
47
+ for (const [askId, row] of this.asks) {
48
+ if (isExecutionLane(String(row.executionLane ?? "")))
49
+ continue;
50
+ this.asks.set(askId, { ...row, executionLane: lane });
51
+ filled += 1;
52
+ }
53
+ return filled;
54
+ }
44
55
  peekAsk(askId) {
45
56
  const row = this.asks.get(askId);
46
57
  return row ? { ...row } : null;
@@ -74,6 +85,7 @@ export class InMemoryApprovalAskStore {
74
85
  sourceTaskId: row.sourceTaskId,
75
86
  sessionId: row.sessionId,
76
87
  owner: row.owner ?? null,
88
+ executionLane: row.executionLane,
77
89
  batchId: row.batchId,
78
90
  toolCallId: row.toolCallId,
79
91
  legKey: row.legKey,
@@ -44,6 +44,7 @@ import { type AskState, type BatchState } from "../approval-ask-machine.js";
44
44
  import type { HostSettledBy } from "../host-decision.js";
45
45
  import { type AskRowCause, type SettleCause } from "../approval-deny-reasons.js";
46
46
  import { type IndexSpec } from "./ensure-index.js";
47
+ import { type ExecutionLane } from "../execution-lane-caps.js";
47
48
  export type AskDecision = "approve" | "deny";
48
49
  export interface AskRow {
49
50
  askId: string;
@@ -52,6 +53,11 @@ export interface AskRow {
52
53
  sessionId: string;
53
54
  /** 租户 scope——哨兵归一在消费层(车2+),店里存原值(设计定稿 §3)。 */
54
55
  owner: string | null;
56
+ /**
57
+ * 造这只问的那条执行的车道(审批出处带车道:问行唯一铸点 `ensureAsk` 与 `taskId` / `sessionId` 同处写;既有行命中一个字节不改)。
58
+ * 读根授权只读问自己记下的车道。存量行随执行放置切换的一次性迁移回填(SQL `approval_ask.execution_lane`、local 问账日志压实)。
59
+ */
60
+ executionLane: ExecutionLane;
55
61
  batchId: string;
56
62
  toolCallId: string;
57
63
  /** 车3 §3.2′ 换轴:数值 `leg` → `legKey`(= `sha256(resume token)` 的 hex,首腿空串)。语义与派生
@@ -174,6 +180,8 @@ export interface NewAskRow {
174
180
  sourceTaskId: string;
175
181
  sessionId: string;
176
182
  owner?: string | null;
183
+ /** 出处的执行车道(必填;语义见 {@link AskRow.executionLane})。 */
184
+ executionLane: ExecutionLane;
177
185
  batchId: string;
178
186
  toolCallId: string;
179
187
  legKey: string;
@@ -562,6 +570,14 @@ export interface ApprovalAskStore {
562
570
  getBatch(batchId: string): Promise<BatchRow | null>;
563
571
  }
564
572
  export declare const APPROVAL_ASK_TABLE = "approval_ask";
573
+ /**
574
+ * `approval_ask.execution_lane` 的列定义(两方言;唯一属主)—— 建表 DDL 内联它,存量库的加列只由执行放置切换的一次性迁移做(`one-time-migrations.ts` M32 步 ②),
575
+ * 那一步也读这里的定义。`DEFAULT ''` 只为存量库一句加列;回填(同迁移步 ④b)之后没有 '' 行,读口对 '' 与词表外的值一律响亮拒({@link executionLaneColumnOf})。
576
+ */
577
+ export declare const APPROVAL_ASK_EXECUTION_LANE_COLUMN: {
578
+ readonly mysql: "execution_lane VARCHAR(32) NOT NULL DEFAULT ''";
579
+ readonly pg: "execution_lane VARCHAR(32) COLLATE \"C\" NOT NULL DEFAULT ''";
580
+ };
565
581
  export declare const APPROVAL_BATCH_TABLE = "approval_batch";
566
582
  /**
567
583
  * TiDB/MySQL-protocol 侧的两条建表语句,**抽成导出数组**(车3 刀 3a):`tidb-pool.ts` 的
@@ -571,7 +587,8 @@ export declare const APPROVAL_BATCH_TABLE = "approval_batch";
571
587
  * 下面的 `ensureTiDBApprovalAskSchema` 保留为遍历这个数组的薄壳(集成测试直接调它建表,
572
588
  * 不必为了建两张表去拉起整套中央 schema)。
573
589
  *
574
- * SCHEMA POLICY 同 `tidb-pool.ts` 头注:纯 CREATE,禁 ALTER 增量 seam,改列直接改这里 + 重建库
590
+ * SCHEMA POLICY 同 `tidb-pool.ts` 头注:纯 CREATE,禁 ALTER 增量 seam,加列直接改这里(新建库随它到货;带数据的存量库由列集对账拒启并打印 `ALTER`,部署者自己执行;
591
+ * 改列类型 / 可空性列集对账看不见,另走一次性迁移;只有空库 / 开发机可以整库重建〔销毁全部数据〕)
575
592
  * (`docs/schema/baseline-mysql.sql` 是由这些文本机器录制出的**产物**,永不手改)。
576
593
  *
577
594
  * ── 列宽的**测量依据**([ref] A10;U2 逐列取证在档)──────────────────────────────────────────────────
@@ -619,18 +636,18 @@ export declare function ensurePgApprovalAskSchema(q: PgQueryFn): Promise<void>;
619
636
  * [ref] 升级前置断言 —— **拒启**,不是 warn(形照 [ref] 的 `assertPermissionRuleApprovalSchema` /
620
637
  * [ref] 的 `assertToolResultProvenanceSchema` 两条先例)。
621
638
  *
622
- * 病:本仓不发 `ALTER TABLE`(SCHEMA POLICY:改列就改 CREATE + 删库重建)。于是一台**没删表**就升上来
639
+ * 病:本仓不发 `ALTER TABLE`(SCHEMA POLICY:代码是唯一真源,存量库缺列由部署者按拒启句打印的 ALTER 自己执行)。于是一台**没跟上**就升上来
623
640
  * 的部署,`CREATE TABLE IF NOT EXISTS` 对存量 `approval_ask` 是空操作 —— 本车新加的
624
641
  * `rule_command` / `rule_scope_root` 两列不在,而 `ensureAsk` 的 INSERT 与 `askCols` 的每一条读
625
642
  * **无条件**引用它们。后果不是「少一个新功能」,是**整条流内审批协议**在那台机器上是坏的:
626
643
  * · 每一只 ask 的 `ensureAsk` 撞 unknown column ⇒ 被 `noteStoreError` 吞掉 ⇒ 整只 ask 退回 park 路由
627
644
  * (卡不落库、`GET /v1/approvals` 的重放面恒空、迟到决议腿恒 404);
628
645
  * · 而 `/v1/capabilities` 的 `streamApproval` 仍然报 active ⇒「says yes ⟺ route works」整句为假。
629
- * 没有这道断言,运维得不到**任何**「这次升级必须重建表」的信号 —— 正是 [ref]「禁静默降级」要挡的形。
646
+ * 没有这道断言,运维得不到**任何**「这次升级必须先给表加列」的信号 —— 正是 [ref]「禁静默降级」要挡的形。
630
647
  *
631
648
  * 判据 = **能力探测**(恒空的 `WHERE 1=0` 读)+ **方言的缺列错误码**(判据属主 `sql-errors.ts` 的
632
- * {@link isMissingColumnError});其余错误**原样 rethrow**,一个字都不加工 —— 一句破坏性的
633
- * 「去 DROP TABLE」指路比它要挡的缺陷更贵([ref] 那两轮收窄的结论逐字继承)。
649
+ * {@link isMissingColumnError});其余错误**原样 rethrow**,一个字都不加工 —— 把一次瞬时故障诊断成「缺列」的拒因
650
+ * 比它要挡的缺陷更贵([ref] 那两轮收窄的结论逐字继承)。拒因的出路是**闭集三条**(与整库对账同一份,{@link describeProbedTableDrift}),没有销毁性的一条。
634
651
  *
635
652
  * 🔴 位置与两条先例同款:**不在** `ensureSchema` 里(那条通道的契约是「只发 CREATE」,有运行时门看着),
636
653
  * 放在 DDL 之后、任何路由装配之前;且**只在流内审批协议真会上场时**跑(判据 = 与协调器同一个符号
@@ -2,8 +2,10 @@ import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
2
2
  import { ASK_TERMINAL, canAskTransition } from "../approval-ask-machine.js";
3
3
  import { APPROVAL_ENDING_CAUSES, causeOfAskRow } from "../approval-deny-reasons.js";
4
4
  import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
5
+ import { describeProbedTableDrift } from "./schema-drift-sentence.js";
5
6
  import { decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn, readTriStateJsonValueColumn } from "./sql-json-column.js";
6
7
  import { ensureIndexes } from "./ensure-index.js";
8
+ import { isExecutionLane } from "../execution-lane-caps.js";
7
9
  export function assertIntegralEpochMs(field, value) {
8
10
  if (!Number.isSafeInteger(value)) {
9
11
  throw new TypeError(`approval-ask store: ${field} must be an integer epoch-ms (BIGINT column), got ${String(value)}`);
@@ -99,6 +101,17 @@ export function projectDecideReading(row, owner) {
99
101
  return askRowCarriesOwner(row, owner) ? row : null;
100
102
  }
101
103
  export const APPROVAL_ASK_TABLE = "approval_ask";
104
+ export const APPROVAL_ASK_EXECUTION_LANE_COLUMN = {
105
+ mysql: "execution_lane VARCHAR(32) NOT NULL DEFAULT ''",
106
+ pg: `execution_lane VARCHAR(32) COLLATE "C" NOT NULL DEFAULT ''`,
107
+ };
108
+ function executionLaneColumnOf(raw, askId) {
109
+ const lane = typeof raw === "string" ? raw : "";
110
+ if (!isExecutionLane(lane)) {
111
+ throw new Error(`approval-ask-store: ask ${askId} carries execution_lane ${JSON.stringify(lane)}, which is not an execution lane — the execution placement switch migration backfills every row; refusing rather than guess the lane this ask was raised on`);
112
+ }
113
+ return lane;
114
+ }
102
115
  export const APPROVAL_BATCH_TABLE = "approval_batch";
103
116
  export const TIDB_APPROVAL_ASK_STATEMENTS = [
104
117
  `CREATE TABLE IF NOT EXISTS ${APPROVAL_BATCH_TABLE} (
@@ -118,6 +131,9 @@ export const TIDB_APPROVAL_ASK_STATEMENTS = [
118
131
  source_task_id VARCHAR(255) NOT NULL,
119
132
  session_id VARCHAR(64) NOT NULL,
120
133
  owner VARCHAR(190) NULL,
134
+ -- execution_lane:造这只问的那条执行的车道(审批出处带车道;ensureAsk 铸行时写,既有行命中不改)。DEFAULT '' 只为存量库一句加列
135
+ -- (一次性迁移加列并按根会话放置回填,迁移后没有 '' 行)。
136
+ ${APPROVAL_ASK_EXECUTION_LANE_COLUMN.mysql},
121
137
  batch_id VARCHAR(64) NOT NULL,
122
138
  tool_call_id VARCHAR(255) NOT NULL,
123
139
  -- leg_key: 车3 §3.2′ 身份轴——sha256(resume checkpoint token) 的 hex(64 字符),首腿存空串。
@@ -172,7 +188,7 @@ export const TIDB_APPROVAL_ASK_STATEMENTS = [
172
188
  UNIQUE KEY uq_approval_ask_idem (task_id, idempotency_key),
173
189
  KEY idx_ask_task_state (task_id, state),
174
190
  -- idx_ask_session_state: 车3 §5.1 的 sync 腿开流重放读口(listPendingBySession)的支撑索引。
175
- -- 表尚未进生产 + SCHEMA POLICY 是删库重建 ⇒ 此刻加索引零迁移成本,晚一车加就要动生产表。
191
+ -- 表尚未进生产 ⇒ 此刻加索引零迁移成本,晚一车加就要动生产表。
176
192
  KEY idx_ask_session_state (session_id, state),
177
193
  KEY idx_ask_batch (batch_id),
178
194
  KEY idx_ask_state (state)
@@ -206,6 +222,8 @@ export async function ensurePgApprovalAskSchema(q) {
206
222
  source_task_id VARCHAR(255) COLLATE "C" NOT NULL,
207
223
  session_id VARCHAR(64) COLLATE "C" NOT NULL,
208
224
  owner VARCHAR(190) COLLATE "C",
225
+ -- execution_lane —— MySQL twin 逐字同形(语义见那侧注)。
226
+ ${APPROVAL_ASK_EXECUTION_LANE_COLUMN.pg},
209
227
  batch_id VARCHAR(64) COLLATE "C" NOT NULL,
210
228
  tool_call_id VARCHAR(255) COLLATE "C" NOT NULL,
211
229
  -- leg_key: 车3 §3.2′ 身份轴——sha256(resume checkpoint token) 的 hex(64 字符),首腿存空串
@@ -258,18 +276,16 @@ export async function assertApprovalAskRuleMaterialSchema(query, dialect) {
258
276
  `persisted on the row too, so a replica that does not hold the waiting leg replays the approved bytes; every ask-row ` +
259
277
  `write and read names these columns unconditionally, so on this table the whole in-stream approval protocol degrades silently (no card ` +
260
278
  `is persisted, the replay list stays empty, late decisions 404) while /v1/capabilities still reports it active. ` +
261
- `This repository ships no ALTER TABLE migrations, so the table must be recreated: run ` +
262
- `\`DROP TABLE ${APPROVAL_ASK_TABLE}; DROP TABLE ${APPROVAL_BATCH_TABLE};\` on the ` +
263
- `${dialect === "pg" ? "PostgreSQL" : "MySQL-protocol"} backend and restart — the schema is recreated at boot. ` +
264
- `The cost is bounded: these two tables hold IN-FLIGHT approval asks (cards awaiting an answer) plus settled ` +
265
- `bookkeeping; the durable park face (checkpoint rows) and the persisted rules themselves are untouched, so an ` +
266
- `in-flight card falls back to the durable gate (\`POST /v1/approvals/:sessionId/decide\`). ` +
267
- `Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
279
+ `The table holds IN-FLIGHT approval asks (cards awaiting an answer) plus settled bookkeeping; the durable park face ` +
280
+ `(checkpoint rows) and the persisted rules themselves are untouched, so an in-flight card falls back to the durable gate ` +
281
+ `(\`POST /v1/approvals/:sessionId/decide\`) until the columns exist.\n` +
282
+ `Underlying probe error: ${err instanceof Error ? err.message : String(err)}\n\n` +
283
+ (await describeProbedTableDrift(query, dialect, [APPROVAL_ASK_TABLE])));
268
284
  }
269
285
  }
270
286
  function askCols(dialect) {
271
287
  return [
272
- "ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state",
288
+ "ask_id, task_id, source_task_id, session_id, owner, execution_lane, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state",
273
289
  "provisional, rev, decision",
274
290
  jsonColumnText(dialect, "decision_actor"),
275
291
  "decision_note, settled_by",
@@ -307,6 +323,7 @@ function mapAskRowBody(r, askId) {
307
323
  sourceTaskId: String(r.source_task_id),
308
324
  sessionId: String(r.session_id),
309
325
  owner: strOrNull(r.owner),
326
+ executionLane: executionLaneColumnOf(r.execution_lane, askId),
310
327
  batchId: String(r.batch_id),
311
328
  toolCallId: String(r.tool_call_id),
312
329
  legKey: String(r.leg_key),
@@ -410,14 +427,15 @@ export class SqlApprovalAskStore {
410
427
  try {
411
428
  await conn.begin();
412
429
  await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_BATCH_TABLE} (batch_id, task_id, state, rev, created_at_ms, updated_at_ms) VALUES (?, ?, 'OPEN', 0, ?, ?)`, `INSERT INTO ${APPROVAL_BATCH_TABLE} (batch_id, task_id, state, rev, created_at_ms, updated_at_ms) VALUES ($1, $2, 'OPEN', 0, $3, $4) ON CONFLICT (batch_id) DO NOTHING`), [row.batchId, row.taskId, now, now]);
413
- const askInsert = await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, settled_by, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
414
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)", `INSERT INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, settled_by, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
415
- "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, $11, $12, $13, $14, $15, $16, $17) ON CONFLICT (ask_id) DO NOTHING"), [
430
+ const askInsert = await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, execution_lane, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, settled_by, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
431
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)", `INSERT INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, execution_lane, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, settled_by, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
432
+ "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, $12, $13, $14, $15, $16, $17, $18) ON CONFLICT (ask_id) DO NOTHING"), [
416
433
  row.askId,
417
434
  row.taskId,
418
435
  row.sourceTaskId,
419
436
  row.sessionId,
420
437
  row.owner ?? null,
438
+ row.executionLane,
421
439
  row.batchId,
422
440
  row.toolCallId,
423
441
  row.legKey,