@sema-agent/server 7.103.1 → 7.104.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 (177) hide show
  1. package/MIGRATION.md +362 -3
  2. package/README.md +6 -1
  3. package/README.zh-CN.md +5 -1
  4. package/USAGE.md +279 -18
  5. package/deploy/sema-up/README.md +57 -7
  6. package/deploy/sema-up/cluster-up.sh +18 -4
  7. package/deploy/sema-up/kube-up.sh +16 -0
  8. package/deploy/sema-up/lib/dataroot.sh +142 -0
  9. package/deploy/sema-up/lib/flagshape.sh +69 -0
  10. package/deploy/sema-up/lib/lanes.py +14 -4
  11. package/deploy/sema-up/lib/shell-access.sh +41 -0
  12. package/deploy/sema-up/preflight.sh +14 -1
  13. package/deploy/sema-up/sema-up.sh +134 -37
  14. package/deploy/sema-up/smoke.sh +5 -0
  15. package/dist/auto-mode-face.js +3 -4
  16. package/dist/boot/budget-tracing.js +1 -1
  17. package/dist/boot/config-center.d.ts +6 -2
  18. package/dist/boot/config-center.js +5 -59
  19. package/dist/boot/execution-env.d.ts +11 -0
  20. package/dist/boot/execution-env.js +14 -0
  21. package/dist/boot/execution-lane.d.ts +25 -0
  22. package/dist/boot/execution-lane.js +95 -0
  23. package/dist/boot/memory-consolidation.d.ts +185 -9
  24. package/dist/boot/memory-consolidation.js +192 -32
  25. package/dist/boot/resolve-spec.d.ts +2 -0
  26. package/dist/boot/resolve-spec.js +28 -24
  27. package/dist/boot/runner-deps.d.ts +6 -2
  28. package/dist/boot/runner-deps.js +4 -0
  29. package/dist/boot/session-faces.d.ts +5 -0
  30. package/dist/boot/session-faces.js +18 -2
  31. package/dist/boot/session-incarnation-seat.d.ts +41 -0
  32. package/dist/boot/session-incarnation-seat.js +69 -0
  33. package/dist/boot/stage-01-config.js +2 -2
  34. package/dist/boot/stage-02-registry-pull.js +3 -1
  35. package/dist/boot/stage-04-stores.d.ts +2 -0
  36. package/dist/boot/stage-04-stores.js +3 -1
  37. package/dist/boot/stage-05-execution-env.d.ts +4 -0
  38. package/dist/boot/stage-05-execution-env.js +2 -2
  39. package/dist/boot/stage-06-runners.d.ts +4 -1
  40. package/dist/boot/stage-06-runners.js +7 -14
  41. package/dist/boot/stage-07-capability-layer.d.ts +4 -1
  42. package/dist/boot/stage-08-reapers.d.ts +4 -1
  43. package/dist/boot/stage-09-leader.d.ts +6 -1
  44. package/dist/boot/stage-09-leader.js +2 -1
  45. package/dist/boot/stage-10-http-server.d.ts +6 -1
  46. package/dist/boot/stage-10-http-server.js +4 -1
  47. package/dist/brain.d.ts +1 -1
  48. package/dist/config-catalog.d.ts +16 -1
  49. package/dist/config-catalog.js +9 -9
  50. package/dist/config-center/restart-signal.d.ts +4 -4
  51. package/dist/config-invariants.d.ts +7 -1
  52. package/dist/config-invariants.js +37 -6
  53. package/dist/config-provider.d.ts +42 -10
  54. package/dist/config-provider.js +18 -16
  55. package/dist/config-types.d.ts +37 -16
  56. package/dist/config-types.js +1 -0
  57. package/dist/config.d.ts +13 -28
  58. package/dist/config.js +53 -70
  59. package/dist/env-facts.d.ts +13 -3
  60. package/dist/env-facts.js +15 -3
  61. package/dist/execution-lane-caps.d.ts +15 -0
  62. package/dist/execution-lane-caps.js +8 -0
  63. package/dist/file-history-capture.d.ts +42 -35
  64. package/dist/file-history-capture.js +15 -10
  65. package/dist/host-lsp-manager.d.ts +1 -1
  66. package/dist/http/admission.d.ts +11 -4
  67. package/dist/http/admission.js +5 -1
  68. package/dist/http/dispatch.js +3 -3
  69. package/dist/http/principal-gate.d.ts +22 -0
  70. package/dist/http/principal-gate.js +20 -0
  71. package/dist/http/resume-legs.js +9 -5
  72. package/dist/http/routes/agents-roster.d.ts +7 -0
  73. package/dist/http/routes/agents-roster.js +6 -2
  74. package/dist/http/routes/approvals-assistant.d.ts +48 -19
  75. package/dist/http/routes/approvals-assistant.js +117 -50
  76. package/dist/http/routes/capabilities.js +3 -2
  77. package/dist/http/routes/config-catalog.js +1 -0
  78. package/dist/http/routes/memory-consolidation.d.ts +3 -2
  79. package/dist/http/routes/memory-policy.js +3 -3
  80. package/dist/http/routes/rules.js +2 -2
  81. package/dist/http/routes/runs.js +27 -2
  82. package/dist/http/routes/session-sync.js +8 -14
  83. package/dist/http/routes/sessions-list.js +3 -2
  84. package/dist/http/routes/sessions.js +44 -23
  85. package/dist/http/routes/shared-memory.js +2 -2
  86. package/dist/http/routes/tasks.js +22 -19
  87. package/dist/http/routes/trace-usage.js +4 -4
  88. package/dist/http/send.d.ts +12 -0
  89. package/dist/http/send.js +12 -1
  90. package/dist/http/server.d.ts +21 -4
  91. package/dist/http/server.js +7 -4
  92. package/dist/http/sync-submit-body.d.ts +43 -0
  93. package/dist/http/sync-submit-body.js +4 -0
  94. package/dist/http/wire-types.d.ts +23 -5
  95. package/dist/index.d.ts +1 -0
  96. package/dist/index.js +1 -0
  97. package/dist/leader/merge.d.ts +8 -3
  98. package/dist/memory-incident-sink.js +2 -1
  99. package/dist/model-select.d.ts +9 -0
  100. package/dist/model-select.js +5 -1
  101. package/dist/observability/corrupt-read-seat.d.ts +151 -16
  102. package/dist/observability/corrupt-read-seat.js +158 -15
  103. package/dist/observability/fail-open.d.ts +12 -0
  104. package/dist/observability/fail-open.js +12 -0
  105. package/dist/observability/run-terminal.d.ts +15 -1
  106. package/dist/observability/run-terminal.js +4 -0
  107. package/dist/parked-decide.d.ts +8 -3
  108. package/dist/parked-decide.js +56 -20
  109. package/dist/parked-revive-surface.d.ts +14 -7
  110. package/dist/parked-revive-surface.js +5 -1
  111. package/dist/plugins/approval-ask-store-file.d.ts +4 -1
  112. package/dist/plugins/approval-ask-store-file.js +9 -12
  113. package/dist/plugins/approval-ask-store-sql.d.ts +4 -0
  114. package/dist/plugins/approval-ask-store-sql.js +4 -1
  115. package/dist/plugins/approval-exemption-store.d.ts +21 -1
  116. package/dist/plugins/approval-exemption-store.js +36 -7
  117. package/dist/plugins/approval-nonce-store.d.ts +16 -2
  118. package/dist/plugins/approval-nonce-store.js +17 -7
  119. package/dist/plugins/caching-session-store.d.ts +5 -0
  120. package/dist/plugins/caching-session-store.js +3 -0
  121. package/dist/plugins/file-resume-anchor-store.d.ts +7 -2
  122. package/dist/plugins/file-resume-anchor-store.js +19 -5
  123. package/dist/plugins/file-run-store.d.ts +21 -8
  124. package/dist/plugins/file-run-store.js +60 -38
  125. package/dist/plugins/local-session-store.d.ts +22 -0
  126. package/dist/plugins/local-session-store.js +74 -11
  127. package/dist/plugins/memory-run-store.d.ts +7 -5
  128. package/dist/plugins/memory-run-store.js +11 -4
  129. package/dist/plugins/one-time-migrations.d.ts +38 -1
  130. package/dist/plugins/one-time-migrations.js +143 -15
  131. package/dist/plugins/permission-rule-store-file.d.ts +9 -2
  132. package/dist/plugins/permission-rule-store-file.js +37 -51
  133. package/dist/plugins/pg-session-storage.d.ts +3 -0
  134. package/dist/plugins/pg-session-storage.js +19 -10
  135. package/dist/plugins/run-store-sql.d.ts +12 -4
  136. package/dist/plugins/run-store-sql.js +15 -13
  137. package/dist/plugins/send-file-ledger.d.ts +5 -1
  138. package/dist/plugins/send-file-ledger.js +11 -6
  139. package/dist/plugins/session-event-payload.d.ts +14 -4
  140. package/dist/plugins/session-event-payload.js +5 -2
  141. package/dist/plugins/store-backend.d.ts +7 -3
  142. package/dist/plugins/store-backend.js +10 -6
  143. package/dist/plugins/store-contracts.d.ts +7 -2
  144. package/dist/plugins/tidb-pool.js +8 -2
  145. package/dist/plugins/tidb-session-store.d.ts +6 -1
  146. package/dist/plugins/tidb-session-store.js +17 -10
  147. package/dist/project-identity.d.ts +4 -6
  148. package/dist/request-key-closure.js +1 -1
  149. package/dist/request-value-closure.d.ts +72 -3
  150. package/dist/request-value-closure.js +12 -4
  151. package/dist/roster-bound-http-status.d.ts +6 -0
  152. package/dist/roster-bound-http-status.js +6 -0
  153. package/dist/run-local.js +22 -27
  154. package/dist/runs.d.ts +22 -2
  155. package/dist/runs.js +29 -7
  156. package/dist/sealed-key.d.ts +31 -4
  157. package/dist/sealed-key.js +9 -0
  158. package/dist/security.d.ts +5 -1
  159. package/dist/server-secret-env.d.ts +2 -2
  160. package/dist/session-children-retire.d.ts +92 -0
  161. package/dist/session-children-retire.js +85 -0
  162. package/dist/session-error-wire.d.ts +58 -0
  163. package/dist/session-error-wire.js +38 -0
  164. package/dist/session-incarnation.d.ts +61 -0
  165. package/dist/session-incarnation.js +43 -0
  166. package/dist/session-records-skipped-notice.d.ts +20 -0
  167. package/dist/session-records-skipped-notice.js +27 -0
  168. package/dist/tool-approval.d.ts +65 -0
  169. package/dist/tool-approval.js +36 -9
  170. package/dist/trace/core-keyset-guard.d.ts +8 -7
  171. package/dist/trace/engine-notice-wire.d.ts +3 -1
  172. package/dist/trace/engine-notice-wire.js +2 -0
  173. package/dist/trace/project.d.ts +1 -1
  174. package/dist/trace/project.js +9 -0
  175. package/dist/trace/wire-projection-faces.d.ts +10 -3
  176. package/dist/trace/wire-projection-faces.js +8 -2
  177. package/package.json +3 -3
@@ -1,12 +1,76 @@
1
1
  import { acquireEnsureSchemaLock, releaseEnsureSchemaLock } from "./tidb-pool.js";
2
2
  import { acquirePgEnsureSchemaLock, releasePgEnsureSchemaLock } from "./pg-pool.js";
3
3
  import { jsonColumnTextExpr } from "./sql-json-column.js";
4
+ import { SESSION_META_INCARNATION_COLUMN, mintSessionIncarnation } from "../session-incarnation.js";
5
+ export const ONE_TIME_MIGRATIONS = [
6
+ {
7
+ id: "M1",
8
+ introducedIn: "7.74.0",
9
+ releasedOn: "2026-09-13",
10
+ removableAfter: "2026-10-13",
11
+ removal: "删 migrateOn 的 M1 步(`snapshot_manifest` 在场探针、删前计数、`DROP TABLE`)与 `RETIRED_SNAPSHOT_TABLE`;报告、日志行与 " +
12
+ "`NO_SQL_MIGRATIONS` 的 `retiredSnapshotTable` / `retiredSnapshotRows`;与 M2 / M3 同一次删(见本表头注)",
13
+ afterRemoval: "没有响亮臂:存量库里的 `snapshot_manifest` 成一张无人读写的孤儿表 —— 它的读腿(边界探针)已随 core 7.17.0 的 seam 退役删掉," +
14
+ "起服列册探针 `schema-consistency.ts` 只对账代码认识的表。只有从 7.73.x 及更早直接跳到删码版的库会遇到;不影响任何读腿," +
15
+ "运维可手工 `DROP TABLE snapshot_manifest`",
16
+ reportKeys: ["retiredSnapshotTable", "retiredSnapshotRows"],
17
+ },
18
+ {
19
+ id: "M2",
20
+ introducedIn: "7.74.0",
21
+ releasedOn: "2026-09-13",
22
+ removableAfter: "2026-10-13",
23
+ removal: "删 migrateOn 的 M2 拒启臂与 `DROP COLUMN` 步、`RETIRED_STEER_SEAT_COLUMN`、`DELIVERABLE_STEER_SEAT_WHERE`、`RetiredSteerSeatError`;" +
24
+ "报告、日志行与 `NO_SQL_MIGRATIONS` 的 `retiredSteerSeatColumn` / `retiredSteerSeatStaleRows`;与 M1 / M3 同一次删",
25
+ afterRemoval: "起服列册探针 `schema-consistency.ts`(S-547)的多列臂:存量库的 `checkpoint.pending_steer` 是代码不认识的列 ⇒ 拒启 " +
26
+ "`config.schema_mismatch`(响亮;⚠️ 那一臂的句子说「库比代码新」,对这一形方向说反了)。运维照拒句手工删列时,列上还送得出去的 " +
27
+ "steering 指令随之丢失 —— 删码之后没有任何东西再替它数一遍送得出去的格",
28
+ reportKeys: ["retiredSteerSeatColumn", "retiredSteerSeatStaleRows"],
29
+ },
30
+ {
31
+ id: "M3",
32
+ introducedIn: "7.74.0",
33
+ releasedOn: "2026-09-13",
34
+ removableAfter: "2026-10-13",
35
+ removal: "删 migrateOn 的 M3 两条整表 `UPDATE` 与它的「退役 schema 还在」跳过标记、`RETIRED_ASK_ORIGIN`、`GATE_BEARING_LEDGER_EVENT_TYPE`、" +
36
+ "`migrateRetiredAskOrigin`,以及 local 腿(`FileRunStore.migrateRetiredAskOriginRows` 与 `LocalBackend.runOneTimeMigrations` 里那一格);" +
37
+ "报告与日志行的 `askOriginRowsMigrated`;与 M1 / M2 同一次删",
38
+ afterRemoval: "读腿既有的 `server.trace.gate-outcome-defective`(`trace/project.ts` 的 `screenedGate`):残留旧拼法的 gate 与任何集外词同判 —— " +
39
+ "整条 withhold + 计数 + 一次 warn(与头注「M3 够不到的一格」同一条臂)",
40
+ reportKeys: ["askOriginRowsMigrated"],
41
+ },
42
+ {
43
+ id: "M4",
44
+ introducedIn: "7.93.5",
45
+ releasedOn: "2026-09-21",
46
+ removableAfter: "2026-10-21",
47
+ removal: "删 migrateOn 的 M4 步(`device_audit` 在场探针 + `UPDATE … SET detail = NULL`)与 `AUDIT_TABLE` / `AUDIT_DETAIL_COLUMN`;" +
48
+ "报告、日志行与 `NO_SQL_MIGRATIONS` 的 `auditDetailJsonNullRows`;它若是表里最后一条,按头注「窗」一节整个文件一起删",
49
+ afterRemoval: "`device-store-sql.ts` `listAudit` 的读形拒(S-550:一行读不出形 ⇒ 整页拒,刻意不逐行隔离):含一行 JSON 文本 `null` 的那一页" +
50
+ "读不出来,拒句点名行 id —— 响亮,但那一页会一直读不出来,直到运维手工把那些行的 `detail` 置成 SQL NULL",
51
+ reportKeys: ["auditDetailJsonNullRows"],
52
+ },
53
+ {
54
+ id: "M5",
55
+ introducedIn: "7.104.0",
56
+ releasedOn: "2026-09-28",
57
+ removableAfter: "2026-10-28",
58
+ removal: "删 migrateOn 的 M5 步(`session_meta` 在场探针、`incarnation` 列在场探针 + `ADD COLUMN`、空化身回填循环)与 `backfillSessionIncarnations`;" +
59
+ "报告、日志行与 `NO_SQL_MIGRATIONS` 的 `sessionIncarnationColumn` / `sessionIncarnationsBackfilled`;**列本身留在中央 DDL 里**(它不是迁移的一部分)",
60
+ afterRemoval: "① 列不在的存量库:起服列册探针 `schema-consistency.ts`(S-547)的缺列臂拒启 `config.schema_mismatch`,拒句里的 `ALTER TABLE … ADD COLUMN` " +
61
+ "由同一份 DDL 生成(响亮);② 列在但行上仍是空化身(跳过了带 M5 的版本直接升):会话店读口 `sessionFaceOf` 对那一行响亮拒 " +
62
+ "`session.incarnation_missing`(parked 决定腿因此拒、记忆座退回按 id 绑定并 warn)—— 运维手工给那些行填一只随机令牌即可",
63
+ reportKeys: ["sessionIncarnationColumn", "sessionIncarnationsBackfilled"],
64
+ },
65
+ ];
4
66
  export const NO_SQL_MIGRATIONS = {
5
67
  retiredSnapshotTable: "absent",
6
68
  retiredSnapshotRows: 0,
7
69
  retiredSteerSeatStaleRows: 0,
8
70
  retiredSteerSeatColumn: "absent",
9
71
  auditDetailJsonNullRows: 0,
72
+ sessionIncarnationColumn: "lazy-on-read",
73
+ sessionIncarnationsBackfilled: 0,
10
74
  };
11
75
  const RETIRED_SNAPSHOT_TABLE = "snapshot_manifest";
12
76
  const RETIRED_STEER_SEAT_COLUMN = "pending_steer";
@@ -37,24 +101,24 @@ export class RetiredSteerSeatError extends Error {
37
101
  }
38
102
  function mysqlDb(conn) {
39
103
  return {
40
- async rows(sql) {
41
- const [rows] = await conn.query(sql);
104
+ async rows(sql, params) {
105
+ const [rows] = params === undefined ? await conn.query(sql) : await conn.query(sql, [...params]);
42
106
  return rows;
43
107
  },
44
- async exec(sql) {
45
- const [r] = await conn.query(sql);
108
+ async exec(sql, params) {
109
+ const [r] = params === undefined ? await conn.query(sql) : await conn.query(sql, [...params]);
46
110
  return Number(r.affectedRows ?? 0);
47
111
  },
48
112
  };
49
113
  }
50
114
  function pgDb(client) {
51
115
  return {
52
- async rows(sql) {
53
- const r = await client.query(sql);
116
+ async rows(sql, params) {
117
+ const r = params === undefined ? await client.query(sql) : await client.query(sql, [...params]);
54
118
  return r.rows;
55
119
  },
56
- async exec(sql) {
57
- const r = await client.query(sql);
120
+ async exec(sql, params) {
121
+ const r = params === undefined ? await client.query(sql) : await client.query(sql, [...params]);
58
122
  return r.rowCount ?? 0;
59
123
  },
60
124
  };
@@ -139,6 +203,7 @@ async function migrateOn(db, dialect) {
139
203
  ? 0
140
204
  : await db.exec(`UPDATE ${AUDIT_TABLE} SET ${AUDIT_DETAIL_COLUMN} = NULL ` +
141
205
  `WHERE ${AUDIT_DETAIL_COLUMN} IS NOT NULL AND ${jsonColumnTextExpr(dialect, AUDIT_DETAIL_COLUMN)} = 'null'`);
206
+ const { sessionIncarnationColumn, sessionIncarnationsBackfilled } = await migrateSessionIncarnations(db, dialect);
142
207
  let retiredSnapshotTable = "absent";
143
208
  let retiredSnapshotRows = 0;
144
209
  let retiredSteerSeatColumn = "absent";
@@ -169,7 +234,64 @@ async function migrateOn(db, dialect) {
169
234
  throw new Error(`one-time migration failed AFTER completing destructive steps — record these before retrying, a retry cannot ` +
170
235
  `report them again: ${destroyed.join("; ")}. Underlying failure: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
171
236
  }
172
- return { retiredSnapshotTable, retiredSnapshotRows, retiredSteerSeatColumn, retiredSteerSeatStaleRows, askOriginRowsMigrated, auditDetailJsonNullRows };
237
+ return {
238
+ retiredSnapshotTable, retiredSnapshotRows, retiredSteerSeatColumn, retiredSteerSeatStaleRows, askOriginRowsMigrated, auditDetailJsonNullRows,
239
+ sessionIncarnationColumn, sessionIncarnationsBackfilled,
240
+ };
241
+ }
242
+ const INCARNATION_BACKFILL_BATCH = 500;
243
+ async function migrateSessionIncarnations(db, dialect) {
244
+ if (!(await tableExists(db, dialect, "session_meta")))
245
+ return { sessionIncarnationColumn: "no-table", sessionIncarnationsBackfilled: 0 };
246
+ const col = SESSION_META_INCARNATION_COLUMN;
247
+ let sessionIncarnationColumn = "present";
248
+ if (!(await columnExists(db, dialect, "session_meta", col.name))) {
249
+ try {
250
+ await db.exec(`ALTER TABLE session_meta ADD COLUMN ${dialect === "tidb" ? col.tidb : col.pg}`);
251
+ sessionIncarnationColumn = "added";
252
+ }
253
+ catch (e) {
254
+ if (!(await columnExists(db, dialect, "session_meta", col.name)))
255
+ throw e;
256
+ }
257
+ }
258
+ return { sessionIncarnationColumn, sessionIncarnationsBackfilled: await backfillSessionIncarnations(db, dialect) };
259
+ }
260
+ async function backfillSessionIncarnations(db, dialect) {
261
+ const remaining = async () => Number((await db.rows("SELECT COUNT(*) AS n FROM session_meta WHERE incarnation = ''"))[0]?.n ?? 0);
262
+ const initial = await remaining();
263
+ if (initial === 0)
264
+ return 0;
265
+ const ceiling = Math.ceil(initial / INCARNATION_BACKFILL_BATCH) * 2 + 2;
266
+ let written = 0;
267
+ let after;
268
+ for (let round = 0; round < ceiling; round++) {
269
+ const ids = (after === undefined
270
+ ? await db.rows(`SELECT session_id FROM session_meta WHERE incarnation = '' ORDER BY session_id LIMIT ${INCARNATION_BACKFILL_BATCH}`)
271
+ : await db.rows(`SELECT session_id FROM session_meta WHERE incarnation = '' AND session_id > ${dialect === "pg" ? "$1" : "?"} ORDER BY session_id LIMIT ${INCARNATION_BACKFILL_BATCH}`, [after])).map((r) => String(r.session_id));
272
+ if (ids.length === 0) {
273
+ if (after === undefined)
274
+ return written;
275
+ after = undefined;
276
+ continue;
277
+ }
278
+ const params = [];
279
+ const bind = (v) => {
280
+ params.push(v);
281
+ return dialect === "pg" ? `$${params.length}` : "?";
282
+ };
283
+ const cases = ids.map((id) => `WHEN ${bind(id)} THEN ${bind(mintSessionIncarnation())}`).join(" ");
284
+ const inList = ids.map((id) => bind(id)).join(", ");
285
+ written += await db.exec(`UPDATE session_meta SET incarnation = CASE session_id ${cases} END WHERE incarnation = '' AND session_id IN (${inList})`, params);
286
+ after = ids.length < INCARNATION_BACKFILL_BATCH ? undefined : ids[ids.length - 1];
287
+ }
288
+ const left = await remaining();
289
+ if (left > 0) {
290
+ throw new Error(`one-time migration M5: ${left} session_meta row(s) still carry an empty incarnation after ${ceiling} backfill round(s) ` +
291
+ `(${written} written) — refusing to start rather than serve sessions whose occupant cannot be told apart. Retry the boot; ` +
292
+ `if it repeats, something keeps writing rows without an incarnation (an older replica still inserting?).`);
293
+ }
294
+ return written;
173
295
  }
174
296
  export function migrateRetiredAskOrigin(type, data) {
175
297
  if (type !== GATE_BEARING_LEDGER_EVENT_TYPE)
@@ -186,7 +308,8 @@ export function migrateRetiredAskOrigin(type, data) {
186
308
  return { ...d, gate: { ...g, origin: RETIRED_ASK_ORIGIN.to } };
187
309
  }
188
310
  export function logOneTimeMigrations(log, backend, report) {
189
- const changed = report.retiredSnapshotTable === "dropped" || report.retiredSteerSeatColumn === "dropped" || report.askOriginRowsMigrated > 0 || report.auditDetailJsonNullRows > 0;
311
+ const changed = report.retiredSnapshotTable === "dropped" || report.retiredSteerSeatColumn === "dropped" || report.askOriginRowsMigrated > 0 || report.auditDetailJsonNullRows > 0 ||
312
+ report.sessionIncarnationColumn === "added" || report.sessionIncarnationsBackfilled > 0;
190
313
  (changed ? log.warn : log.info).call(log, "one_time_migrations_applied", {
191
314
  backend,
192
315
  retiredSnapshotTable: report.retiredSnapshotTable,
@@ -195,11 +318,16 @@ export function logOneTimeMigrations(log, backend, report) {
195
318
  retiredSteerSeatStaleRows: report.retiredSteerSeatStaleRows,
196
319
  askOriginRowsMigrated: report.askOriginRowsMigrated,
197
320
  auditDetailJsonNullRows: report.auditDetailJsonNullRows,
198
- note: "one-time persisted-data migrations for this version's retirements (retired whole-tree snapshot index table " +
199
- "dropped; retired single-seat steering column dropped; retired ask-origin spelling rewritten in place; audit " +
200
- "details written as the JSON text `null` by an earlier version folded to a SQL NULL, so \"no detail\" has exactly " +
201
- "one on-disk spelling). This " +
202
- "whole migration step is removed in the next minor — start this version at least once before upgrading past it.",
321
+ sessionIncarnationColumn: report.sessionIncarnationColumn,
322
+ sessionIncarnationsBackfilled: report.sessionIncarnationsBackfilled,
323
+ removableAfter: Object.fromEntries(ONE_TIME_MIGRATIONS.map((w) => [w.id, w.removableAfter])),
324
+ note: "one-time persisted-data migrations (M1: retired whole-tree snapshot index table dropped; M2: retired single-seat " +
325
+ "steering column dropped; M3: retired ask-origin spelling rewritten in place; M4: audit details written as the JSON " +
326
+ "text `null` by an earlier version folded to a SQL NULL, so \"no detail\" has exactly one on-disk spelling; M5: the " +
327
+ "session incarnation column added and every session row without one minted its own token). Each step " +
328
+ "runs on every boot until its window closes (the release that introduced it + 30 days; the per-step date is in " +
329
+ "removableAfter, null while that release is not published yet) and is deleted in the first minor after that — start a " +
330
+ "version that still carries a step at least once before upgrading past its deletion.",
203
331
  });
204
332
  }
205
333
  //# sourceMappingURL=one-time-migrations.js.map
@@ -1,6 +1,7 @@
1
1
  import { FileDurableRulePartitionProvider, type RuleApprovalRecord, type RuleApprovalRecordStore, type StaleRuleApprovalRecord } from "@sema-agent/core";
2
2
  import { type RuleImportTicket, type RuleTicketPurpose, type RuleTicketReclaimResult, type RuleTicketRedeemResult, type RuleTicketSnapshot } from "./permission-rule-store-sql.js";
3
3
  import type { PermissionRuleStoreBundle } from "../rules-consent.js";
4
+ import type { Logger } from "../observability/logger.js";
4
5
  import { type RuleCandidate } from "@sema-agent/core";
5
6
  /**
6
7
  * `RuleApprovalRecordStore` 的 File 形。
@@ -11,8 +12,10 @@ import { type RuleCandidate } from "@sema-agent/core";
11
12
  export declare class FileRuleApprovalRecordStore implements RuleApprovalRecordStore {
12
13
  private readonly rows;
13
14
  private readonly log;
15
+ /** 本面的损坏闸(S-582 起由重放属主交回;置位后每个方法第一行响亮拒)。 */
14
16
  private readonly gate;
15
- constructor(dir: string, onError?: (message: string) => void);
17
+ /** @param warn 腐读披露座(S-582;缺席 ⇒ 属主退回 `console.warn`)。 */
18
+ constructor(dir: string, warn?: Logger["warn"]);
16
19
  /** 取证/自证用:本面是否因内部损坏而 fail-closed。 */
17
20
  get corrupt(): boolean;
18
21
  /** 归还本面 eager 持有的日志描述符(束的 `dispose()` 唯一调用点)。`AppendLog` 上没有 finalizer,
@@ -39,10 +42,12 @@ export declare class FileRuleApprovalRecordStore implements RuleApprovalRecordSt
39
42
  export declare class FileRuleImportTicketStore {
40
43
  private readonly rows;
41
44
  private readonly log;
45
+ /** 本面的损坏闸(S-582 起由重放属主交回)。 */
42
46
  private readonly gate;
43
47
  /** 单调钟(判定用)。与 {@link now}(墙钟,记账用)是两只不同的钟,别混。 */
44
48
  private readonly monotonic;
45
- constructor(dir: string, now?: () => number, onError?: (message: string) => void, monotonic?: () => number);
49
+ /** @param warn 腐读披露座(S-582;缺席 ⇒ 属主退回 `console.warn`)。 */
50
+ constructor(dir: string, now?: () => number, warn?: Logger["warn"], monotonic?: () => number);
46
51
  private readonly now;
47
52
  /** 取证/自证用:本面是否因内部损坏而 fail-closed。 */
48
53
  get corrupt(): boolean;
@@ -100,9 +105,11 @@ export interface FilePermissionRuleStores extends PermissionRuleStoreBundle {
100
105
  * `onError` 接的是 core 规则文件的**披露面**(读不出来 / 校验和不符 / 撞上符号链接时它答零规则并
101
106
  * 说出来)。接住它打一条 warn 是**必须**的:那条路径上「零规则」与「真的没有规则」在读面上同形,
102
107
  * 不留痕就变成一次静默的 fail-closed(用户会突然被反复询问,却没有任何线索)。
108
+ * `warn`(S-582)接的是本仓审批记录 / 导入票两份日志的**腐读座**(逐行披露 + 计数;缺席 ⇒ `console.warn`)。
103
109
  */
104
110
  export declare function createFilePermissionRuleStores(root: string, opts?: {
105
111
  onError?: (message: string) => void;
112
+ warn?: Logger["warn"];
106
113
  now?: () => number;
107
114
  monotonic?: () => number;
108
115
  }): FilePermissionRuleStores;
@@ -2,10 +2,10 @@ import { join } from "node:path";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { readdirSync } from "node:fs";
4
4
  import { performance } from "node:perf_hooks";
5
- import { FileDurableRulePartitionProvider, AppendLog, ensureDir, readJsonlRecords, } from "@sema-agent/core";
5
+ import { FileDurableRulePartitionProvider, AppendLog, ensureDir, } from "@sema-agent/core";
6
6
  import { z } from "zod";
7
7
  import { buildRulePayloadHash } from "./permission-rule-store-sql.js";
8
- import { createSkippedRecordSink } from "../observability/corrupt-read-seat.js";
8
+ import { replayJsonlGuarded } from "../observability/corrupt-read-seat.js";
9
9
  import { EDITED_RULE_BREADTH_WARNING_CODES, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
10
10
  import { RULE_BEHAVIORS } from "@sema-agent/core";
11
11
  const RuleScopeSchema = z.union([
@@ -109,53 +109,44 @@ const TicketLineSchema = z.union([
109
109
  z.object({ op: z.literal("settle"), ticketId: z.string().min(1), outcome: z.unknown() }).strict(),
110
110
  ]);
111
111
  const LEGACY_TICKET_PURPOSE = "cc-import";
112
- class CorruptionGate {
113
- face;
114
- path;
115
- onError;
116
- reason;
117
- constructor(face, path, onError) {
118
- this.face = face;
119
- this.path = path;
120
- this.onError = onError;
121
- }
122
- trip(reason) {
123
- this.reason ??= reason;
124
- this.onError?.(`${this.face} at ${this.path} is corrupt: ${reason}`);
125
- }
126
- get tripped() {
127
- return this.reason !== undefined;
128
- }
129
- assertUsable() {
130
- if (this.reason === undefined)
131
- return;
132
- throw new Error(`refusing to use a corrupt ${this.face} (${this.path}): ${this.reason} — ` +
133
- "the permission-rule lane is fail-closed until a person inspects the file (moving it aside restarts the lane with an empty log)");
134
- }
135
- }
112
+ const APPROVAL_LOG_SHAPE = {
113
+ what: "permission-rule approval line that is neither a current-form put/discard nor a pre-form put",
114
+ read(record) {
115
+ const parsed = ApprovalLineSchema.safeParse(record);
116
+ if (parsed.success)
117
+ return { kind: "line", line: parsed.data };
118
+ const stale = StaleApprovalLineSchema.safeParse(record);
119
+ if (stale.success && stale.data.record.schema !== RULE_APPROVAL_RECORD_SCHEMA)
120
+ return { kind: "stale", envelope: toStaleEnvelope(stale.data.record) };
121
+ return undefined;
122
+ },
123
+ };
124
+ const TICKET_LOG_SHAPE = {
125
+ what: "permission-rule import-ticket line outside the mint/consume/release/settle shapes",
126
+ read(record) {
127
+ const parsed = TicketLineSchema.safeParse(record);
128
+ return parsed.success ? parsed.data : undefined;
129
+ },
130
+ };
136
131
  export class FileRuleApprovalRecordStore {
137
132
  rows = new Map();
138
133
  log;
139
134
  gate;
140
- constructor(dir, onError) {
135
+ constructor(dir, warn) {
141
136
  ensureDir(dir);
142
137
  const path = join(dir, "approvals.jsonl");
143
- this.gate = new CorruptionGate("permission-rule approval log", path, onError);
144
- for (const raw of readJsonlRecords(path, createSkippedRecordSink((info) => this.gate.trip(info.reason)))) {
145
- const parsed = ApprovalLineSchema.safeParse(raw);
146
- if (!parsed.success) {
147
- const stale = StaleApprovalLineSchema.safeParse(raw);
148
- if (stale.success && stale.data.record.schema !== RULE_APPROVAL_RECORD_SCHEMA) {
149
- this.rows.set(stale.data.record.id, toStaleEnvelope(stale.data.record));
150
- continue;
151
- }
152
- this.gate.trip(`a replayed record does not carry a readable shape: ${parsed.error.message}`);
138
+ const { records, gate } = replayJsonlGuarded(path, "permission-rule-approvals", APPROVAL_LOG_SHAPE, warn);
139
+ this.gate = gate;
140
+ for (const entry of records) {
141
+ if (entry.kind === "stale") {
142
+ this.rows.set(entry.envelope.id, entry.envelope);
153
143
  continue;
154
144
  }
155
- if (parsed.data.op === "discard")
156
- this.rows.delete(parsed.data.id);
145
+ const line = entry.line;
146
+ if (line.op === "discard")
147
+ this.rows.delete(line.id);
157
148
  else
158
- this.rows.set(parsed.data.record.id, toApprovalRecord(parsed.data.record));
149
+ this.rows.set(line.record.id, toApprovalRecord(line.record));
159
150
  }
160
151
  this.log = new AppendLog(path);
161
152
  }
@@ -204,19 +195,14 @@ export class FileRuleImportTicketStore {
204
195
  log;
205
196
  gate;
206
197
  monotonic;
207
- constructor(dir, now, onError, monotonic) {
198
+ constructor(dir, now, warn, monotonic) {
208
199
  this.now = now ?? Date.now;
209
200
  this.monotonic = monotonic ?? (() => performance.now());
210
201
  ensureDir(dir);
211
202
  const path = join(dir, "import-tickets.jsonl");
212
- this.gate = new CorruptionGate("permission-rule import-ticket log", path, onError);
213
- for (const raw of readJsonlRecords(path, createSkippedRecordSink((info) => this.gate.trip(info.reason)))) {
214
- const parsed = TicketLineSchema.safeParse(raw);
215
- if (!parsed.success) {
216
- this.gate.trip(`a replayed record does not carry a readable shape: ${parsed.error.message}`);
217
- continue;
218
- }
219
- const line = parsed.data;
203
+ const { records, gate } = replayJsonlGuarded(path, "permission-rule-tickets", TICKET_LOG_SHAPE, warn);
204
+ this.gate = gate;
205
+ for (const line of records) {
220
206
  if (line.op === "mint") {
221
207
  this.rows.set(line.ticketId, {
222
208
  ownerKey: line.ownerKey,
@@ -344,8 +330,8 @@ export function createFilePermissionRuleStores(root, opts) {
344
330
  const dir = join(root, "permission-rules");
345
331
  ensureDir(dir);
346
332
  const durable = opts?.onError ? new FileDurableRulePartitionProvider(dir, opts.onError) : new FileDurableRulePartitionProvider(dir);
347
- const approvals = new FileRuleApprovalRecordStore(dir, opts?.onError);
348
- const tickets = new FileRuleImportTicketStore(dir, opts?.now, opts?.onError, opts?.monotonic);
333
+ const approvals = new FileRuleApprovalRecordStore(dir, opts?.warn);
334
+ const tickets = new FileRuleImportTicketStore(dir, opts?.now, opts?.warn, opts?.monotonic);
349
335
  return {
350
336
  durable,
351
337
  approvals,
@@ -4,6 +4,7 @@ import type { SessionSummary } from "./store-contracts.js";
4
4
  import { type PlacementAcquireOpts } from "./session-placement.js";
5
5
  import { type StagingHandle } from "../session-sync-kernel.js";
6
6
  import { type InstructionSourceBaseline } from "../instruction-source-notice.js";
7
+ import { type SessionFace } from "../session-incarnation.js";
7
8
  import { type IndexSpec } from "./ensure-index.js";
8
9
  /**
9
10
  * PG-dialect DDL for the L1 session log, translated from tidb-pool.ts SCHEMA_STATEMENTS:
@@ -80,6 +81,8 @@ export declare class PgSessionStore implements SessionStore {
80
81
  }): Promise<PlacedSessionRow[]>;
81
82
  /** Owner principal of a session, `null` if registered ownerless, `undefined` if no such session. */
82
83
  ownerOf(sessionId: string): Promise<string | null | undefined>;
84
+ /** S-790:属主 + 化身一次读(TiDB 孪生 {@link TiDBSessionStore.sessionFaceOf} 同义;空化身 ⇒ 响亮拒)。 */
85
+ sessionFaceOf(sessionId: string): Promise<SessionFace | undefined>;
83
86
  touch(sessionId: string): Promise<void>;
84
87
  /** E18 — the session's current leaf SessionTreeEntry.id (session_meta.leaf_id). @see TiDBSessionStore.getLeafId
85
88
  * (single SELECT, no acquire/lock, cache-bypassing — safe to read mid-run for the resume-at turn-capture). */
@@ -11,6 +11,7 @@ import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING
11
11
  import { isPgUniqueViolation } from "./sql-errors.js";
12
12
  import { BASELINE_IO_DEADLINE_MS, decodeInstructionSource, encodeInstructionSource } from "../instruction-source-notice.js";
13
13
  import { MANAGED_RETENTION } from "./retention-store-sql.js";
14
+ import { SESSION_META_INCARNATION_COLUMN, carriedIncarnation, mintSessionIncarnation, sessionFaceFromRow } from "../session-incarnation.js";
14
15
  import { ensureIndexes } from "./ensure-index.js";
15
16
  const isDupKey = isPgUniqueViolation;
16
17
  function baselineQuery(text, values) {
@@ -40,6 +41,8 @@ export const PG_SESSION_SCHEMA = [
40
41
  placement_root_session_id VARCHAR(190) COLLATE "C",
41
42
  placement_handle VARCHAR(190) COLLATE "C",
42
43
  placed_at TIMESTAMPTZ(3),
44
+ -- S-790 / S-506:会话化身(TiDB 孪生同名同义;列定义唯一属主 = src/session-incarnation.ts 的 SESSION_META_INCARNATION_COLUMN)。
45
+ ${SESSION_META_INCARNATION_COLUMN.pg},
43
46
  PRIMARY KEY (session_id)
44
47
  )`,
45
48
  `CREATE TABLE IF NOT EXISTS session_event (
@@ -217,9 +220,9 @@ export class PgSessionStore {
217
220
  }
218
221
  async register(sessionId, owner, placement) {
219
222
  const now = new Date();
220
- await this.pool.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq, " +
223
+ await this.pool.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq, incarnation, " +
221
224
  "placement_kind, placement_scope, placement_parent_session_id, placement_root_session_id, placement_handle, placed_at) " +
222
- "VALUES ($1,$2,$3,$4,NULL,0,$5,$6,$7,$8,$9,$10) ON CONFLICT (session_id) DO NOTHING", [sessionId, owner, now, now, ...placementInsertValues(placement, now)]);
225
+ "VALUES ($1,$2,$3,$4,NULL,0,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT (session_id) DO NOTHING", [sessionId, owner, now, now, mintSessionIncarnation(), ...placementInsertValues(placement, now)]);
223
226
  }
224
227
  async placementOf(sessionId) {
225
228
  const res = await this.pool.query(`SELECT ${PLACEMENT_SELECT_COLUMNS} FROM session_meta WHERE session_id = $1`, [sessionId]);
@@ -246,6 +249,12 @@ export class PgSessionStore {
246
249
  return undefined;
247
250
  return res.rows[0].owner ?? null;
248
251
  }
252
+ async sessionFaceOf(sessionId) {
253
+ const res = await this.pool.query("SELECT owner, incarnation FROM session_meta WHERE session_id = $1", [sessionId]);
254
+ if (res.rows.length === 0)
255
+ return undefined;
256
+ return sessionFaceFromRow(sessionId, res.rows[0].owner ?? null, res.rows[0].incarnation);
257
+ }
249
258
  async touch(sessionId) {
250
259
  await this.pool.query("UPDATE session_meta SET updated_at = $1 WHERE session_id = $2", [
251
260
  new Date(),
@@ -384,7 +393,7 @@ export class PgSessionStore {
384
393
  const payload = String(r.payload);
385
394
  await conn.query("INSERT INTO session_event (session_id, seq, entry_id, parent_id, type, payload, ts) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7)", [newId, seq, r.entry_id, r.parent_id ?? null, r.type, payload, r.ts]);
386
395
  }
387
- await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq) VALUES ($1,$2,$3,$4,$5,$6)", [newId, owner, now, now, leafId, rows.length]);
396
+ await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq, incarnation) VALUES ($1,$2,$3,$4,$5,$6,$7)", [newId, owner, now, now, leafId, rows.length, mintSessionIncarnation()]);
388
397
  await conn.query("COMMIT");
389
398
  return newId;
390
399
  }
@@ -438,7 +447,7 @@ export class PgSessionStore {
438
447
  const conn = await this.pool.connect();
439
448
  try {
440
449
  await conn.query("BEGIN");
441
- await this.insertValidatedLog(conn, sessionId, owner, validated);
450
+ await this.insertValidatedLog(conn, sessionId, owner, validated, mintSessionIncarnation());
442
451
  await conn.query("COMMIT");
443
452
  }
444
453
  catch (err) {
@@ -461,14 +470,14 @@ export class PgSessionStore {
461
470
  const conn = await this.pool.connect();
462
471
  try {
463
472
  await conn.query("BEGIN");
464
- const placedRes = await conn.query("SELECT placement_kind FROM session_meta WHERE session_id = $1 FOR UPDATE", [sessionId]);
473
+ const placedRes = await conn.query("SELECT placement_kind, owner, incarnation FROM session_meta WHERE session_id = $1 FOR UPDATE", [sessionId]);
465
474
  if (placedRes.rows[0]?.placement_kind != null) {
466
475
  await conn.query("ROLLBACK");
467
476
  throw new SessionError("placement_refused", `session ${sessionId} belongs to the ${placedRes.rows[0].placement_kind} placement partition — a session-sync replace cannot overwrite a delegation transcript`);
468
477
  }
469
478
  await conn.query("DELETE FROM session_event WHERE session_id = $1", [sessionId]);
470
479
  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]);
471
- await this.insertValidatedLog(conn, sessionId, owner, validated);
480
+ await this.insertValidatedLog(conn, sessionId, owner, validated, carriedIncarnation(placedRes.rows[0], owner));
472
481
  await conn.query("COMMIT");
473
482
  }
474
483
  catch (err) {
@@ -485,7 +494,7 @@ export class PgSessionStore {
485
494
  conn.release();
486
495
  }
487
496
  }
488
- async insertValidatedLog(conn, sessionId, owner, validated) {
497
+ async insertValidatedLog(conn, sessionId, owner, validated, incarnation) {
489
498
  let leafId = null;
490
499
  for (const e of validated)
491
500
  leafId = leafIdAfterEntry(e);
@@ -494,7 +503,7 @@ export class PgSessionStore {
494
503
  const e = validated[seq];
495
504
  await conn.query("INSERT INTO session_event (session_id, seq, entry_id, parent_id, type, payload, ts) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7)", [sessionId, seq, e.id, e.parentId ?? null, e.type, pgSafeJsonStringify(e), new Date(e.timestamp)]);
496
505
  }
497
- await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq) VALUES ($1,$2,$3,$4,$5,$6)", [sessionId, owner, now, now, leafId, validated.length]);
506
+ await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq, incarnation) VALUES ($1,$2,$3,$4,$5,$6,$7)", [sessionId, owner, now, now, leafId, validated.length, incarnation]);
498
507
  }
499
508
  beginImportStaging(realSessionId, token) {
500
509
  return new PgStagingHandle(this.pool, realSessionId, stagingIdFor(realSessionId, token));
@@ -614,8 +623,8 @@ class PgStagingHandle {
614
623
  if (lastRes.rows[0])
615
624
  leafId = leafIdAfterEntry(decodeSessionEventPayload(lastRes.rows[0].payload, this.realSessionId, "last staged row"));
616
625
  const now = new Date();
617
- await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq) VALUES ($1,$2,$3,$4,$5,$6) " +
618
- "ON CONFLICT (session_id) DO UPDATE SET owner = EXCLUDED.owner, updated_at = EXCLUDED.updated_at, leaf_id = EXCLUDED.leaf_id, leaf_seq = EXCLUDED.leaf_seq", [this.realSessionId, owner, now, now, leafId, srcIds.length]);
626
+ await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq, incarnation) VALUES ($1,$2,$3,$4,$5,$6,$7) " +
627
+ "ON CONFLICT (session_id) DO UPDATE SET owner = EXCLUDED.owner, updated_at = EXCLUDED.updated_at, leaf_id = EXCLUDED.leaf_id, leaf_seq = EXCLUDED.leaf_seq", [this.realSessionId, owner, now, now, leafId, srcIds.length, mintSessionIncarnation()]);
619
628
  const afterMeta = await conn.query("SELECT placement_kind FROM session_meta WHERE session_id = $1", [this.realSessionId]);
620
629
  if (afterMeta.rows[0]?.placement_kind != null) {
621
630
  await conn.query("ROLLBACK");
@@ -83,10 +83,14 @@ export declare class SqlRunStore {
83
83
  /**
84
84
  * Durable F4 ([ref]): a task suspended on a policy `ask`. Sets the run NON-terminal `suspended` and
85
85
  * KEEPS the `task_active` claim — the session stays locked (no new run) until an operator resumes it to a
86
- * terminal state or the reaper expires it. The token-bearing result is NOT stored (the submitter polling
87
- * this row must never receive the capability token); only the status flips.
86
+ * terminal state or the reaper expires it.
87
+ *
88
+ * 🔴 S-173:park 也落 `result` 列 —— 存的是**摘掉恢复凭据、脱过敏的投影**(`sealRunRowResult`,与 `setTerminal`
89
+ * 同一只入口、同列同序列化),不是引擎交出的原件:`paused.token` / `failed.nestedPause.token` 两座在落列前摘掉,
90
+ * 所以 poll 这一行的调用方(`GET /v1/runs/:id → result`)拿到的是「停在哪扇门上」而拿不到 resume 能力。
91
+ * `result = null` = 这条腿没观察到 park 结果(断连竞态 / 重开 re-park),列写空 —— 与修前行形同。
88
92
  */
89
- setSuspended(taskId: string): Promise<void>;
93
+ setSuspended(taskId: string, result: PersistedTaskResult | null): Promise<void>;
90
94
  /**
91
95
  * [ref] D-B: PARK a run that paused on a REVIEW gate — a `needs_review` result (a dry-run diff review OR a
92
96
  * pre-action `plan_review`; core assemble-result slot 8.6). Like {@link setSuspended} this is a NON-terminal park
@@ -104,7 +108,7 @@ export declare class SqlRunStore {
104
108
  * mocked run-store unit tests stayed green. A mock cannot observe a unique-key claim race, a rolled-back loser,
105
109
  * or a multi-table reaper DELETE.
106
110
  */
107
- setNeedsReview(taskId: string): Promise<void>;
111
+ setNeedsReview(taskId: string, result: PersistedTaskResult | null): Promise<void>;
108
112
  /**
109
113
  * Durable F4 resume: a suspended run is being actively resumed. Flip it back to `running` (CAS — only if
110
114
  * still suspended) and refresh liveness, so the in-flight resume is protected by the running-run heartbeat
@@ -288,6 +292,10 @@ export declare class SqlRunStore {
288
292
  * a NEW pending checkpoint — the `NOT EXISTS pending` clause excludes it. Idempotent across replicas. Returns count.
289
293
  *
290
294
  * Same alias-qualification divergence as {@link reapSuspended} — see its header note.
295
+ *
296
+ * S-173:两条收割腿都把 `result` 列写回 NULL —— park 行上那份 park 结果(`terminal.kind:"paused"`)说的是
297
+ * 「停在门上」,而这一刻行被打成 `failed`/`approval.expired`;留着它 = 一行两句互相矛盾的话(读面还会从它
298
+ * 投出 `review.pending` 之类的 park 码)。收割后的行形因此与 S-173 之前逐字同(结果空,码在 error_code 列)。
291
299
  */
292
300
  failSuspendedWithExpiredCheckpoint(): Promise<number>;
293
301
  }