@sema-agent/server 7.3.0 → 7.5.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 (78) hide show
  1. package/USAGE.md +63 -0
  2. package/dist/approval-card.d.ts +15 -3
  3. package/dist/approval-card.js +41 -7
  4. package/dist/approval-reconciler.d.ts +120 -16
  5. package/dist/approval-reconciler.js +146 -19
  6. package/dist/boot/coordinators.js +13 -3
  7. package/dist/boot/deferred-sandbox-path-env.d.ts +99 -0
  8. package/dist/boot/deferred-sandbox-path-env.js +279 -0
  9. package/dist/boot/execution-env.js +11 -1
  10. package/dist/boot/org-memory.d.ts +6 -0
  11. package/dist/boot/org-memory.js +1 -1
  12. package/dist/boot/reapers.d.ts +2 -0
  13. package/dist/boot/reapers.js +11 -4
  14. package/dist/boot/resolve-spec.d.ts +3 -2
  15. package/dist/boot/resolve-spec.js +175 -63
  16. package/dist/boot/runner-deps.d.ts +23 -1
  17. package/dist/boot/runner-deps.js +8 -11
  18. package/dist/boot/workflow-orchestration.d.ts +8 -3
  19. package/dist/boot/workflow-orchestration.js +23 -1
  20. package/dist/capabilities/center-prompts.js +4 -1
  21. package/dist/config-center/apply-effective.js +33 -10
  22. package/dist/config-types.d.ts +32 -9
  23. package/dist/config.d.ts +6 -1
  24. package/dist/config.js +65 -12
  25. package/dist/elicitation.js +5 -1
  26. package/dist/env-facts.d.ts +3 -1
  27. package/dist/env-facts.js +3 -1
  28. package/dist/fleet/fleet-bus.d.ts +6 -1
  29. package/dist/fleet/fleet-bus.js +25 -3
  30. package/dist/governance-ask-marks.d.ts +31 -0
  31. package/dist/governance-ask-marks.js +122 -0
  32. package/dist/hooks/hook-runner.d.ts +28 -0
  33. package/dist/hooks/hook-runner.js +180 -24
  34. package/dist/http/routes/diagnostics.d.ts +84 -0
  35. package/dist/http/routes/diagnostics.js +145 -0
  36. package/dist/http/routes/memory-policy.d.ts +2 -1
  37. package/dist/http/routes/memory-policy.js +77 -13
  38. package/dist/http/routes/runs.js +1 -1
  39. package/dist/http/routes/tasks.js +87 -29
  40. package/dist/http/server.d.ts +10 -0
  41. package/dist/http/server.js +29 -12
  42. package/dist/http/wire-types.d.ts +7 -2
  43. package/dist/main.js +51 -8
  44. package/dist/observability/fail-open.d.ts +109 -0
  45. package/dist/observability/fail-open.js +227 -0
  46. package/dist/observability/prompt-manifest.d.ts +17 -0
  47. package/dist/observability/prompt-manifest.js +8 -0
  48. package/dist/orchestration/workflow-notify-journal.d.ts +57 -1
  49. package/dist/orchestration/workflow-notify-journal.js +137 -32
  50. package/dist/parked-decide.js +9 -4
  51. package/dist/plugins/approval-ask-store-memory.d.ts +2 -2
  52. package/dist/plugins/approval-ask-store-memory.js +3 -2
  53. package/dist/plugins/approval-ask-store-sql.d.ts +27 -5
  54. package/dist/plugins/approval-ask-store-sql.js +9 -2
  55. package/dist/plugins/background-shell-support.d.ts +1 -1
  56. package/dist/plugins/background-shell-support.js +2 -2
  57. package/dist/plugins/checkpoint-store-sql.d.ts +62 -6
  58. package/dist/plugins/checkpoint-store-sql.js +71 -11
  59. package/dist/plugins/local-checkpoint-store.d.ts +20 -1
  60. package/dist/plugins/local-checkpoint-store.js +19 -0
  61. package/dist/plugins/mailbox-store-sql.d.ts +4 -10
  62. package/dist/plugins/mailbox-store-sql.js +57 -4
  63. package/dist/question.d.ts +18 -14
  64. package/dist/question.js +83 -34
  65. package/dist/runs.d.ts +8 -0
  66. package/dist/runs.js +15 -2
  67. package/dist/runtime-governance.d.ts +18 -0
  68. package/dist/runtime-governance.js +90 -3
  69. package/dist/task-settings.d.ts +16 -21
  70. package/dist/task-settings.js +22 -19
  71. package/dist/tool-approval.d.ts +33 -6
  72. package/dist/tool-approval.js +95 -30
  73. package/dist/trace/core-keyset-guard.d.ts +17 -3
  74. package/dist/trace/project.d.ts +36 -1
  75. package/dist/trace/project.js +55 -2
  76. package/package.json +3 -3
  77. package/dist/boot/lexical-path-env.d.ts +0 -14
  78. package/dist/boot/lexical-path-env.js +0 -116
@@ -86,9 +86,29 @@ export class WorkflowNotifyGate {
86
86
  this.deliver = deliver;
87
87
  this.opts = opts;
88
88
  }
89
+ /**
90
+ * The runIds whose journal entry THIS process recorded — the boot-orphan judgment's authoritative anchor
91
+ * (round-1 review, MEDIUM). A wall-clock cutoff alone is not sound: after a BACKWARD clock step the entries
92
+ * this process records land BELOW the boot cutoff, and the sweep would finalize runs whose in-process
93
+ * executor is alive and running (a false `failed` notify for a workflow that then keeps going — worse than
94
+ * the absent row it was fixing). Membership here is a fact about THIS incarnation, unforgeable by any clock.
95
+ * Retired on ack AND the moment a sweep observes the run terminal (the anchor only ever gates the `running`
96
+ * arm), so a delivery/ack outage cannot pile up entries for runs that are no longer executing.
97
+ */
98
+ recordedThisIncarnation = new Set();
89
99
  now() {
90
100
  return this.opts.now ? this.opts.now() : Date.now();
91
101
  }
102
+ /** Record a journal entry AND remember that this incarnation is the one that recorded it (see the field). */
103
+ async recordPending(entry) {
104
+ await this.journal.record(entry);
105
+ this.recordedThisIncarnation.add(entry.runId);
106
+ }
107
+ /** Ack an entry + drop its incarnation mark (acked ⇒ never scanned again, so the mark has no further use). */
108
+ async ackDelivered(runId) {
109
+ await this.journal.ack(runId, this.now());
110
+ this.recordedThisIncarnation.delete(runId);
111
+ }
92
112
  /**
93
113
  * Journal a STARTED run as pending-notify. Call this with the synchronous `runId` from `startWorkflow` /
94
114
  * `run_workflow` — BEFORE the workflow can reach terminal — so a crash mid-run still leaves a recoverable
@@ -97,7 +117,7 @@ export class WorkflowNotifyGate {
97
117
  */
98
118
  async onWorkflowStart(input) {
99
119
  try {
100
- await this.journal.record({
120
+ await this.recordPending({
101
121
  runId: input.runId,
102
122
  scope: input.scope,
103
123
  ...(input.sourceTaskId ? { sourceTaskId: input.sourceTaskId } : {}),
@@ -142,7 +162,7 @@ export class WorkflowNotifyGate {
142
162
  if (!existing) {
143
163
  // The start hook didn't journal it (e.g. a run started before this gate existed, or the hook was skipped).
144
164
  // Record it now so the deliver-then-ack ordering still holds and a crash mid-delivery is recoverable.
145
- await this.journal.record({
165
+ await this.recordPending({
146
166
  runId: p.runId,
147
167
  scope,
148
168
  ...(p.sourceTaskId ? { sourceTaskId: p.sourceTaskId } : {}),
@@ -158,7 +178,7 @@ export class WorkflowNotifyGate {
158
178
  this.opts.onError?.("deliver", p.runId, err);
159
179
  return;
160
180
  }
161
- await this.journal.ack(p.runId, this.now());
181
+ await this.ackDelivered(p.runId);
162
182
  }
163
183
  /**
164
184
  * RECOVERY sweep — run at boot AND PERIODICALLY (wired into the service reaper), BEFORE/while serving traffic.
@@ -171,11 +191,32 @@ export class WorkflowNotifyGate {
171
191
  * workflow died) + ack, instead of leaking the entry forever. This closes the exact crash topology SVC-1
172
192
  * exists for (a replica SIGKILLed mid-run).
173
193
  * - `running` and FRESH (within the grace window): genuinely in flight → leave pending (the owning process
174
- * delivers its terminal notify, or the next sweep catches it once it goes terminal or stale).
194
+ * delivers its terminal notify, or the next sweep catches it once it goes terminal or stale). The sweep
195
+ * publishes NOTHING here — see the single-writer invariant below.
175
196
  * - MISSING (reaped / never persisted): ack-as-abandoned so the journal doesn't chase a ghost forever.
176
197
  * `orphanGraceMs` MUST exceed the max expected workflow runtime (the run store has no cross-replica liveness
177
198
  * signal, so age is the only orphan proxy). Returns a tally. A per-entry throw is isolated so one bad entry
178
199
  * can't abort the sweep.
200
+ *
201
+ * BOTH abandoned arms (boot-orphan + stale-past-grace) flip the DURABLE row to `failed` FIRST (CAS on rev),
202
+ * so the run store, the fleet panel and the delivered notify give ONE answer — previously the notify said
203
+ * `failed` while `/workflows` kept saying `running` forever ([2999]: republishing without finalizing would
204
+ * have turned "panel empty" into "panel shows a row that never moves"). A lost CAS means the run moved under
205
+ * the sweep (e.g. its real terminal landed concurrently) — skip this pass; the entry stays pending and the
206
+ * next sweep handles the NEW state.
207
+ *
208
+ * 🔴 SINGLE-WRITER INVARIANT for the fleet row (round-1 review, two HIGH findings): the sweep only ever
209
+ * publishes a **TERMINAL** fleet frame (the flip above, via `publishTerminalFleetRow` → final frame + remove).
210
+ * It NEVER publishes a `running` row. Two reasons, both "a row we mint here can become one nobody can retire":
211
+ * 1. cross-replica (SQL journal): a pending `running` entry may belong to ANOTHER replica. Its terminal
212
+ * update lands on that replica's own (replica-local) fleet bus, and its ack removes the entry from the
213
+ * shared journal — so this replica would never see the run again and its minted row would sit `running`
214
+ * forever. (The terminal-redelivery arm deliberately doesn't publish either — it is a notify path.)
215
+ * 2. same-replica: `runStore.get` is a READ-TIME SNAPSHOT. If the live run commits its terminal (and the
216
+ * wrapper removes the row) between that read and the publish, a `running` republish resurrects a row
217
+ * that will never be removed again.
218
+ * A live row's ONE writer is this replica's `put`/`update` observation point ({@link JournalingWorkflowRunStore}),
219
+ * which by construction sees every transition including the terminal one.
179
220
  */
180
221
  async recover(opts = {}) {
181
222
  const orphanGraceMs = opts.orphanGraceMs ?? 24 * 60 * 60 * 1000; // 24h default — well beyond any normal workflow
@@ -183,12 +224,28 @@ export class WorkflowNotifyGate {
183
224
  let redelivered = 0;
184
225
  let stillRunning = 0;
185
226
  let abandoned = 0;
227
+ /** Was this entry recorded by a PREVIOUS incarnation of this process? Only meaningful when the caller granted
228
+ * the replica-local finalize authority (a shared/SQL journal carries other replicas' entries). Both halves
229
+ * are required — see `finalizeStartedBeforeMs` + {@link recordedThisIncarnation}. */
230
+ const isPreBootEntry = (entry) => opts.finalizeStartedBeforeMs !== undefined &&
231
+ !this.recordedThisIncarnation.has(entry.runId) &&
232
+ entry.createdAt < opts.finalizeStartedBeforeMs;
186
233
  for (const entry of pending) {
187
234
  try {
188
235
  const run = await this.runStore.get(entry.runId);
189
236
  if (!run) {
190
- // No durable run — reaped or never persisted. Don't keep it pending forever.
191
- await this.journal.ack(entry.runId, this.now());
237
+ // No durable run — USUALLY a ghost (the row was reaped, or its `put` threw). But it is ALSO the
238
+ // transient shape of a run being started RIGHT NOW: JournalingWorkflowRunStore journals BEFORE it
239
+ // persists (deliberately — a persist that throws must still leave a recoverable entry), so there is a
240
+ // window where the entry exists and the row does not. Acking that window is unrecoverable: the owner's
241
+ // own terminal `deliverOnce` then sees `acked` and drops the completion for good — the exact loss SVC-1
242
+ // exists to prevent (round-2 review, HIGH). So only ack a ghost that PROVABLY isn't that window: one
243
+ // that predates this incarnation, or one older than the grace the whole sweep already trusts.
244
+ if (!isPreBootEntry(entry) && this.now() - entry.createdAt <= orphanGraceMs) {
245
+ stillRunning++; // "left pending this pass" — a start-in-progress is exactly that
246
+ continue;
247
+ }
248
+ await this.ackDelivered(entry.runId);
192
249
  abandoned++;
193
250
  continue;
194
251
  }
@@ -207,15 +264,43 @@ export class WorkflowNotifyGate {
207
264
  const originatingSessionId = run.originatingSessionId;
208
265
  if (run.status === "running") {
209
266
  const startedAt = run.startedAt || entry.createdAt;
210
- if (this.now() - startedAt <= orphanGraceMs) {
267
+ // [2995] boot-orphan judgment (replica-local journal only): the entry predates this process, so the
268
+ // in-process executor that owned it died with the previous incarnation — nothing will ever flip it.
269
+ // (Conditions in `isPreBootEntry`: replica-local authority + not recorded by US + predates boot.)
270
+ const bootOrphan = isPreBootEntry(entry);
271
+ if (!bootOrphan && this.now() - startedAt <= orphanGraceMs) {
211
272
  stillRunning++;
212
- continue; // fresh → genuinely in flight; the owner (or a later sweep) delivers terminal
273
+ // fresh → genuinely in flight; the owner (or a later sweep) delivers terminal. Publish NOTHING here:
274
+ // a `running` row minted from a read-time snapshot can outlive every chance to retire it (the
275
+ // single-writer invariant on the doc-comment above).
276
+ continue;
213
277
  }
214
- // Stale `running` crash-orphan (nothing will ever flip it terminal). Finalize as abandoned/failed.
278
+ // Orphaned `running` boot-orphan (executor died with the previous process) or stale past grace
279
+ // (nothing will ever flip it terminal). Finalize as abandoned/failed: flip the DURABLE row first
280
+ // ([2999] honesty half — store/fleet/notify must give one answer), then deliver + ack.
281
+ const abandonSummary = bootOrphan
282
+ ? "workflow abandoned — the engine restarted while it was running (its in-process executor did not survive the restart)"
283
+ : "workflow abandoned — still `running` past the orphan grace window (replica likely crashed mid-run)";
284
+ const flipped = { ...run, status: "failed", error: abandonSummary, endedAt: this.now() };
285
+ const flippedOk = await this.runStore.update(entry.runId, run.scope, flipped, run.rev !== undefined ? { rev: run.rev } : undefined);
286
+ if (!flippedOk)
287
+ continue; // the run moved under the sweep — leave pending; next sweep sees the NEW state
288
+ this.recordedThisIncarnation.delete(entry.runId); // terminal now — the anchor only ever gates `running`
289
+ // ORDER: retire the fleet row FIRST — immediately after the durable flip, before anything is awaited.
290
+ // Same order as the live leg (the wrapper publishes the terminal frame, then core fires its notify), and
291
+ // it is the only order where "a finalized run leaves the active fleet" survives every delivery outcome
292
+ // (rounds 4+5): the delivery may throw at its ack, or never settle at all, and a `finally` cannot help
293
+ // with the latter — while the NEXT sweep takes the terminal-redelivery arm, which deliberately publishes
294
+ // nothing, so a row skipped here would be stranded `running` with nobody left to retire it.
295
+ // The converse risk is bounded and self-healing: if the projection throws, this pass delivers nothing
296
+ // and the entry stays pending — the next sweep's terminal arm delivers it. Deferred by one sweep, never
297
+ // lost. (Subscriber throws are already isolated inside the fleet bus — per-callback try/catch with a
298
+ // registered fail-open tag — so a projection throw here means the publish machinery itself broke.)
299
+ opts.publishTerminalFleetRow?.(entry.runId, flipped); // final frame then remove
215
300
  await this.deliverOnce({
216
301
  runId: entry.runId,
217
302
  status: "failed",
218
- summary: "workflow abandoned — still `running` past the orphan grace window (replica likely crashed mid-run)",
303
+ summary: abandonSummary,
219
304
  ...(sourceTaskId ? { sourceTaskId } : {}),
220
305
  ...(principal ? { principal } : {}),
221
306
  ...(originatingSessionId ? { originatingSessionId } : {}),
@@ -225,6 +310,10 @@ export class WorkflowNotifyGate {
225
310
  continue;
226
311
  }
227
312
  // Terminal but not acked ⇒ the crash dropped its notify. Re-derive the bounded summary + re-deliver.
313
+ // Drop the incarnation mark FIRST (round-2 review, MEDIUM): the anchor only ever gates the `running` arm,
314
+ // so a terminal run has no use for it — retiring here (not only on a successful ack) keeps the registry
315
+ // tracking runs that are actually in flight even through a prolonged delivery outage.
316
+ this.recordedThisIncarnation.delete(entry.runId);
228
317
  await this.deliverOnce({
229
318
  runId: entry.runId,
230
319
  status: run.status,
@@ -423,28 +512,44 @@ export class JournalingWorkflowRunStore {
423
512
  // 必然漂移(本仓刚在 coarse 门镜像上吃过同款),用它的函数则 core 一改、两端同时跟随。
424
513
  const started = agents.filter((a) => deriveAgentDisplayStatus(a, run.status) !== "queued").length;
425
514
  const terminal = run.status === "completed" || run.status === "failed";
426
- this.fleetBus.publishWorkflow({
427
- id,
428
- // [WF2-A parity] redact the workflow label surfaces for parity with the run + subagent-child names (fleet-bus.ts):
429
- // a tool-launched (LLM-authored) workflow's meta.name/description is task-controlled and could carry a secret shape.
430
- name: redactSecrets(run.name ?? "Dynamic workflow"),
431
- ...(run.description ? { description: redactSecrets(run.description) } : {}),
432
- scope: run.scope,
433
- // codex-6 F2:sessionId 必须随行——streamFleet 对无 sessionId 的行按「同 principal 全会话可见」
434
- // 兜底,漏发=A 会话的 workflow 名/进度/token 泄进 B 会话的 ?session= 过滤流。
435
- ...(run.originatingSessionId ? { sessionId: run.originatingSessionId } : {}),
436
- status: run.status,
437
- doneCount: done,
438
- totalCount: agents.length,
439
- failedCount: failed,
440
- startedCount: started,
441
- tokens: (run.stats?.tokens ?? 0) + (run.stats?.nested?.tokens ?? 0),
442
- // [1294]:跑动中也带时长(1.232 只在 endedAt 后带——clay 验收轮实锚面板恒显 0s)。终态用
443
- // endedAt 定格,活跑用 now-startedAt(每次 put/update 观察点刷新,壳侧读帧即当前时长)。
444
- elapsedMs: (run.endedAt ?? Date.now()) - run.startedAt,
445
- });
446
- if (terminal)
447
- this.fleetBus.removeWorkflow(id); // terminal workflow leaves the active fleet (the shell saw the final frame)
515
+ // 撤行**不挂在发帧成功上**(复审第 2 轮 MEDIUM):「终态行必须离场」是不变量、发终帧是尽力而为。
516
+ // try/finally ⇒ 发布本体抛错照样撤行(异常仍向上抛,不吞:吞了就是一条未登记的 fail-open 臂)。
517
+ // 订阅方抛错已在 fleet bus subscribe 隔离层被吞并记 fail-open——本 finally 守的是发布机器本身
518
+ // 的抛错(理论缝级,fresh 复审盘点后判近死防御但保留:防未来发布本体改动回退)。两腿一处收口。
519
+ try {
520
+ this.fleetBus.publishWorkflow({
521
+ id,
522
+ // [WF2-A parity] redact the workflow label surfaces for parity with the run + subagent-child names (fleet-bus.ts):
523
+ // a tool-launched (LLM-authored) workflow's meta.name/description is task-controlled and could carry a secret shape.
524
+ name: redactSecrets(run.name ?? "Dynamic workflow"),
525
+ ...(run.description ? { description: redactSecrets(run.description) } : {}),
526
+ scope: run.scope,
527
+ // codex-6 F2:sessionId 必须随行——streamFleet 对无 sessionId 的行按「同 principal 全会话可见」
528
+ // 兜底,漏发=A 会话的 workflow 名/进度/token 泄进 B 会话的 ?session= 过滤流。
529
+ ...(run.originatingSessionId ? { sessionId: run.originatingSessionId } : {}),
530
+ status: run.status,
531
+ doneCount: done,
532
+ totalCount: agents.length,
533
+ failedCount: failed,
534
+ startedCount: started,
535
+ tokens: (run.stats?.tokens ?? 0) + (run.stats?.nested?.tokens ?? 0),
536
+ // [1294]:跑动中也带时长(1.232 只在 endedAt 后带——clay 验收轮实锚面板恒显 0s)。终态用
537
+ // endedAt 定格,活跑用 now-startedAt(每次 put/update 观察点刷新,壳侧读帧即当前时长)。
538
+ elapsedMs: (run.endedAt ?? Date.now()) - run.startedAt,
539
+ });
540
+ }
541
+ finally {
542
+ if (terminal)
543
+ this.fleetBus.removeWorkflow(id); // terminal workflow leaves the active fleet (the shell saw the final frame)
544
+ }
545
+ }
546
+ /** [2995] recovery projection seam: the same fleet projection as the put/update observation points, exposed so
547
+ * the recovery sweep can RETIRE a row for a run it just finalized (terminal input ⇒ final frame, then remove).
548
+ * Wire it as `recover`'s `publishTerminalFleetRow` hook — and only ever hand it a TERMINAL run: a `running`
549
+ * row published from outside the wrapper's own write path has no guaranteed retirement (see the invariant on
550
+ * {@link WorkflowNotifyGate.recover}). */
551
+ republishFleet(id, run) {
552
+ this.publishFleet(id, run);
448
553
  }
449
554
  async put(id, run) {
450
555
  // Journal FIRST (best-effort, swallowed inside onWorkflowStart) so a started run is always recoverable, THEN
@@ -81,10 +81,15 @@ export async function decideParkedAgent(deps, req) {
81
81
  // RB-459(core 5.7.0,F1/F6 翻案后唯一剩下的 claim 前拒):问题门的 approve 必须携 answer——
82
82
  // parked 腿无 live answering face,消费 claim 后只会空跑一次 revive;claim 前拒是免损前置。带 answer 的
83
83
  // approve 与任何 deny 都放行透传,core 是语义权威(header 逐字匹配等)。
84
- // 🔴 #152:本判据**不再**有 core 侧同判兜底。core 的 pre-CAS 拒条件是「`taskConfig.onQuestion ??
85
- // deps.onQuestion` 为 undefined 或 QUESTION_AWAITS_RESUME」;ASK_QUESTION_ENABLED 部署上 spec 不再 stamp
86
- // 哨兵、deps.onQuestion 是 coordinator,条件恒不成立 ⇒ core 放行,赎回腿会拿 coordinator 的空答当人答
87
- // 并消费掉 checkpoint。这一手现在是该腿唯一的拒绝点(任务级孪生:src/http/server.ts decide 路由)。
84
+ // 🔴 #152 当年写这条时的机制理由是:core 的 pre-CAS 拒条件(「`taskConfig.onQuestion ??
85
+ // deps.onQuestion` 为 undefined 或 QUESTION_AWAITS_RESUME」)在 ASK_QUESTION_ENABLED 部署上恒不成立
86
+ // ⇒ core 放行,而赎回腿会拿 coordinator 的**空答**当人答并消费掉 checkpoint。
87
+ // ⚠️ 那个理由已被后续两处证伪,别再据它推理:①#166 QuestionCoordinator 的每一条无人应答臂(无
88
+ // ALS ctx / abort / 限流 / 呈现不了 / TTL)一律返 `{kind:"unavailable"}`,类头逐字承诺「It NEVER
89
+ // synthesizes an empty answer set」——赎回腿无 ctx,拿到的是 unavailable 而不是空答;②core 5.16 对
90
+ // 赎回中的 ask 走 redeemsApproval 臂返 isError(「will not silently self-answer」)。
91
+ // 本判据**保留**,理由换成免损前置:放过去也只会空跑一次 revive(claim 已被消费),claim 前拒是
92
+ // 更便宜、错误信息也更准的那一手(任务级孪生:src/http/server.ts 的 decide 路由)。
88
93
  return { status: 400, body: { error: "pending action is AskUserQuestion — approve requires body.answer (the operator's answers[]); deny needs none", errorCode: "decide.parked_answer_required", taskId: match.handle } };
89
94
  }
90
95
  if (row.name === undefined) {
@@ -12,11 +12,11 @@
12
12
  * - resolveProvisional 故意不经 canAskTransition(同 SQL twin 头注:这是版本化补偿的例外通道)。
13
13
  */
14
14
  import { type AskState, type BatchState } from "../approval-ask-machine.js";
15
- import type { AskDecision, AskRow, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, DecideResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
15
+ import type { AskDecision, AskRow, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, EnsureAskResult, DecideResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
16
16
  export declare class InMemoryApprovalAskStore implements ApprovalAskStore {
17
17
  private readonly asks;
18
18
  private readonly batches;
19
- ensureAsk(row: NewAskRow): Promise<AskRow>;
19
+ ensureAsk(row: NewAskRow): Promise<EnsureAskResult>;
20
20
  transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
21
21
  decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
22
22
  expireAsk(askId: string, batchId: string): Promise<ExpireResult>;
@@ -50,8 +50,9 @@ export class InMemoryApprovalAskStore {
50
50
  });
51
51
  }
52
52
  const existing = this.asks.get(row.askId);
53
+ // 幂等命中:行是**别人**插的 ⇒ `inserted: false`(收尾权限判别位,语义见 `EnsureAskResult` 顶注)。
53
54
  if (existing)
54
- return { ...existing };
55
+ return { row: { ...existing }, inserted: false };
55
56
  // 🔴 车4 §12-E:`idempotency_key` 已归**回决专用**,`NewAskRow` 的同名字段随之摘除 ⇒ 铸行不再有
56
57
  // 唯一性可撞(列恒 NULL)。原先在这里镜像 UNIQUE 的那段扫描也随之下车;撞键判定搬到 `decideAsk`
57
58
  // (SQL twin 那侧是 `(task_id, idempotency_key)` 索引在库层拒 ⇒ typed `idempotency_conflict`)。
@@ -85,7 +86,7 @@ export class InMemoryApprovalAskStore {
85
86
  updatedAtMs: row.createdAtMs,
86
87
  };
87
88
  this.asks.set(row.askId, created);
88
- return { ...created };
89
+ return { row: { ...created }, inserted: true };
89
90
  }
90
91
  async transitionAsk(askId, from, to, patch) {
91
92
  if (!canAskTransition(from, to)) {
@@ -59,9 +59,13 @@ export interface AskRow {
59
59
  * 🔴 车5 §9 C2:铸卡时呈给人看的那份 input 的**服务端摘要**(`AskRequest.boundInputHash`,core 侧已在场)。
60
60
  * 与下面 PARKED 坐标里的 `gateBoundInputHash` 是**两件不同的东西**,别混:
61
61
  * - `boundInputHash`(本列)= ask **铸行时**的入参摘要,一次写定永不改;对账收敛器判据 1 用它跟
62
- * checkpoint 行的同名列做**硬相等**(identity 四元组之外的第二道等式)。缺席 ⇒ 判据 1 不命中
63
- * (禁「能取到时才比」的可选谓词——同 session toolCallId 会被网关重用,只靠 identity 会把旧 ask
64
- * PARK 到别人的 resume 坐标上,而 PARKED 是不可回滚的终态)。
62
+ * checkpoint 行的同名列做**硬相等**——它是**身份三元组之外的第二道等式**,身份本身是
63
+ * (`sourceTaskId`, `toolCallId`, 因果下界)三维(#168 件1 换轴,判据属主 = `approval-reconciler.ts`
64
+ * `classifyGateMatch`;本注上一版写的「identity 四元组」是换轴前的旧口径,`sessionId` 从来不是
65
+ * 内存判据维)。任一侧缺席 ⇒ 判据 1 **不命中**;归因看走到哪一层:身份先判(候选集非空却没有同身份
66
+ * 的一条 ⇒ `identity_miss`),身份这层还够得着时缺席才记 `single_mint`(禁「能取到时才比」的可选谓词
67
+ * ——同 session 内 toolCallId 会被网关重用,只靠身份会把旧 ask PARK 到别人的 resume 坐标上,而
68
+ * PARKED 是不可回滚的终态)。
65
69
  * - `gateBoundInputHash`(下面)= 真 **PARK 成功那一刻**从 checkpoint 抄回来的坐标之一,bindBatch 才写。
66
70
  * 本车只落店面承载(列 + 行形 + 读写),铸行调用点的供值归车2/3b。
67
71
  */
@@ -114,6 +118,23 @@ export interface NewAskRow {
114
118
  expiresAtMs: number;
115
119
  createdAtMs: number;
116
120
  }
121
+ /**
122
+ * `ensureAsk` 的产出 —— 行 **+ 这一次调用到底插没插**(#168 件2)。
123
+ *
124
+ * 🔴 为什么判别位必须由**店**给:`ensureAsk` 是幂等 upsert(`askId` 是确定性派生,重试 / failover /
125
+ * 闭包再入天然指向同一行),所以「拿到一条 STREAM_PENDING 行」有两种成因 —— 本次插的,或幂等命中了
126
+ * **别人正持有**的那条活行。两者的收尾权限完全相反:前者本次可以收(放弃时把孤儿行 VOID 掉),后者
127
+ * 一个字都不许动(动了就是把真属主正在等的那张卡作废)。调用侧此前只能拿 `createdAtMs === 本次传入值`
128
+ * 去**猜**归属,而那把尺在同一毫秒的两次并发插入上会给出假阳性(旧注里如实记着的残余)。
129
+ * `INSERT IGNORE` / `ON CONFLICT DO NOTHING` 的 affected 行数是引擎对同一个问题的**权威**回答,
130
+ * 两方言都有;把它如实带出来,猜就退休了。
131
+ *
132
+ * `inserted: true` ⇒ 这条行是本次调用写下的(可收尾);`false` ⇒ 幂等命中既有行(只读,不许收尾)。
133
+ */
134
+ export interface EnsureAskResult {
135
+ row: AskRow;
136
+ inserted: boolean;
137
+ }
117
138
  /** `transitionAsk`/`resolveProvisional` 的可选补丁——只有出现的字段才落 SQL(未出现 = 该列不变),
118
139
  * `updatedAtMs` 恒必填(调用方是未来的协调器,时间戳由它按事件时钟决定,店不偷偷用 `Date.now()`)。 */
119
140
  export interface AskTransitionPatch {
@@ -206,7 +227,8 @@ export interface BatchRow {
206
227
  }
207
228
  /** design 定稿 §4 的持久层接口。协调器接线(车2)、恢复扫描消费(车5)不在本车范围——本车只落这些方法。 */
208
229
  export interface ApprovalAskStore {
209
- ensureAsk(row: NewAskRow): Promise<AskRow>;
230
+ /** 幂等 upsert。返回**行 + 本次是否真插入**(判别位语义见 {@link EnsureAskResult}) */
231
+ ensureAsk(row: NewAskRow): Promise<EnsureAskResult>;
210
232
  transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
211
233
  decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
212
234
  expireAsk(askId: string, batchId: string): Promise<ExpireResult>;
@@ -303,7 +325,7 @@ export declare class SqlApprovalAskStore implements ApprovalAskStore {
303
325
  private getAskOn;
304
326
  /** {@link getAskOn} 的批侧同形(同一条纪律:失败臂回读走本连接)。 */
305
327
  private getBatchOn;
306
- ensureAsk(row: NewAskRow): Promise<AskRow>;
328
+ ensureAsk(row: NewAskRow): Promise<EnsureAskResult>;
307
329
  transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
308
330
  decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
309
331
  expireAsk(askId: string, batchId: string): Promise<ExpireResult>;
@@ -330,13 +330,17 @@ export class SqlApprovalAskStore {
330
330
  }
331
331
  async ensureAsk(row) {
332
332
  const now = row.createdAtMs;
333
+ /** 本次 ask 插入的 affected 行数(1 = 真插入 / 0 = 幂等命中)——判别位的**唯一**来源,见
334
+ * {@link EnsureAskResult}。两方言各自的语句形不同(`INSERT IGNORE` vs `ON CONFLICT DO NOTHING`),
335
+ * 但 affected 的语义是同一个,`SqlDriver` 已把 mysql2 的 `affectedRows` 与 pg 的 `rowCount` 归一。 */
336
+ let insertedRows = 0;
333
337
  const conn = await this.db.connect();
334
338
  try {
335
339
  await conn.begin();
336
340
  await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_BATCHES_TABLE} (batch_id, task_id, state, version, created_at_ms, updated_at_ms) VALUES (?, ?, 'OPEN', 0, ?, ?)`, `INSERT INTO ${APPROVAL_BATCHES_TABLE} (batch_id, task_id, state, version, 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]);
337
341
  // 🔴 `idempotency_key` **不在 INSERT 列清单里**(车4 §12-E 双写者收口):该列已归回决专用,只有
338
342
  // `decideAsk` 赢 CAS 时写它,铸行恒 NULL。留在这里的 NULL 字面量就是这条纪律的落笔处。
339
- await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_ASKS_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, version, decision, decision_actor, decision_note, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
343
+ const askInsert = await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_ASKS_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, version, decision, decision_actor, decision_note, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
340
344
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?)", `INSERT INTO ${APPROVAL_ASKS_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, version, decision, decision_actor, decision_note, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
341
345
  "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, $11, $12, $13, $14, $15) ON CONFLICT (ask_id) DO NOTHING"), [
342
346
  row.askId,
@@ -355,6 +359,7 @@ export class SqlApprovalAskStore {
355
359
  now,
356
360
  now,
357
361
  ]);
362
+ insertedRows = askInsert.affected;
358
363
  await conn.commit();
359
364
  }
360
365
  catch (err) {
@@ -372,7 +377,9 @@ export class SqlApprovalAskStore {
372
377
  const out = await this.getAsk(row.askId);
373
378
  if (!out)
374
379
  throw new Error(`ensureAsk: row vanished after insert (askId=${row.askId})`);
375
- return out;
380
+ // 判别位取**提交那一刻**引擎报的 affected,不是事后回读推断的:回读发生在提交之后,那时并发的
381
+ // 第二个调用可能已经改过行,任何「从行上反推谁插的」都是又一次猜。
382
+ return { row: out, inserted: insertedRows > 0 };
376
383
  }
377
384
  async transitionAsk(askId, from, to, patch) {
378
385
  // machine 层先判(非法转移=编程错误,throw 而非返回 false——设计定稿 §4 原话)。
@@ -11,7 +11,7 @@
11
11
  * - **owned-id isolation** — `poll`/`kill` look the shellId up in THIS manager's registry; an id from another env
12
12
  * (or a forged one) is `not_found`, never resolved against the provider (design/103 §3.8 / interface 越权契约).
13
13
  * - **maxConcurrent** — counts only `running` shells (terminal entries are kept for residual polling but free their
14
- * slot, so "KillShell one first" is actionable — matches the TOC `maxConcurrent 终态占槽` review fix).
14
+ * slot, so "TaskStop one first" is actionable — matches the TOC `maxConcurrent 终态占槽` review fix).
15
15
  * - **timeout hard wall** — a control-plane timer kills the shell and flips it to `killed` at the bounded BG
16
16
  * timeout (design/103 §3.6); the driver may set an additional provider-level backstop ≥ this wall.
17
17
  * - **dispose** — kills + cleans up EVERY shell, best-effort, MUST NOT throw, idempotent (design/103 §3.7).
@@ -11,7 +11,7 @@
11
11
  * - **owned-id isolation** — `poll`/`kill` look the shellId up in THIS manager's registry; an id from another env
12
12
  * (or a forged one) is `not_found`, never resolved against the provider (design/103 §3.8 / interface 越权契约).
13
13
  * - **maxConcurrent** — counts only `running` shells (terminal entries are kept for residual polling but free their
14
- * slot, so "KillShell one first" is actionable — matches the TOC `maxConcurrent 终态占槽` review fix).
14
+ * slot, so "TaskStop one first" is actionable — matches the TOC `maxConcurrent 终态占槽` review fix).
15
15
  * - **timeout hard wall** — a control-plane timer kills the shell and flips it to `killed` at the bounded BG
16
16
  * timeout (design/103 §3.6); the driver may set an additional provider-level backstop ≥ this wall.
17
17
  * - **dispose** — kills + cleans up EVERY shell, best-effort, MUST NOT throw, idempotent (design/103 §3.7).
@@ -207,7 +207,7 @@ export class BackgroundShellManager {
207
207
  /** The shared spawn/adopt body: limit check, opaque id, terminal-latch buffering, BG-timeout hard wall. */
208
208
  async register(launch, timeoutSec) {
209
209
  if (this.liveCount() >= this.caps.maxConcurrent) {
210
- return fail(new BackgroundShellError("limit_exceeded", `Too many running background shells (max ${this.caps.maxConcurrent}); KillShell one first.`));
210
+ return fail(new BackgroundShellError("limit_exceeded", `Too many running background shells (max ${this.caps.maxConcurrent}); TaskStop one first.`));
211
211
  }
212
212
  const bgTimeoutSec = this.boundedBgTimeoutSec(timeoutSec); // #131-T0:与 adoptSync 同座(NaN 当缺席)
213
213
  // Opaque brand — NOT derived from the provider job/pid (design/103 §3.8 越权红线).
@@ -1,6 +1,6 @@
1
1
  import type { Pool as MySqlPool } from "mysql2/promise";
2
2
  import type { Pool as PgPool } from "pg";
3
- import { type Checkpoint, type CheckpointGate, type CheckpointState, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ResumeOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor } from "@sema-agent/core";
3
+ import { type Checkpoint, type CheckpointGate, type CheckpointState, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ResumeOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor, type StoreDurability, type StoreFidelity } from "@sema-agent/core";
4
4
  import { type SqlDriver } from "./sql-driver.js";
5
5
  /**
6
6
  * design/80 D-1 (§3 invariant #3 — crash-safe reaper backstop): an ABSOLUTE upper bound on a pending
@@ -77,6 +77,16 @@ export interface CheckpointAskCandidate {
77
77
  /** `checkpoint.status` 列(权威;`pending` | `resolved` | `expired`)。不做过滤,由收敛器判活性。 */
78
78
  status: string;
79
79
  createdAtMs: number;
80
+ /**
81
+ * 🔴 和解三元组的第一维(#168 件1,黑板 [2897]③①)—— checkpoint 自己带的 `sourceTaskId`(core 在
82
+ * suspendAsk 铸行时恒填该腿的 `sessionId`)。**没有对应的列**,只能从 blob 窄读;读不出/缺席 = null。
83
+ *
84
+ * 为什么必须进候选集:读口按 `(scope, session_id, tool_call_id)` 查,而委派子代那条腿的 ask 落行时
85
+ * `session_id` 记的是**投递上下文**(根会话),park 却发生在**子代自己**的 sessionId 上 —— 只靠
86
+ * (toolCallId, hash) 两维,一条根腿的 park 会被一只子代的 ask 认领(摘要相同的两次同 args 调用完全
87
+ * 正常)。三元组全同才算同一件事。
88
+ */
89
+ sourceTaskId: string | null;
80
90
  /** 该 park 绑定的 tool call(= `pendingAction.toolCallId`)。`unparseable` 行为 null。 */
81
91
  boundCallId: string | null;
82
92
  /** 服务端铸的入参摘要——判据 1 的第二道硬等式(与 `approval_asks.bound_input_hash` 相等才算命中)。 */
@@ -109,13 +119,54 @@ export declare class SqlCheckpointStore implements CheckpointStore {
109
119
  protected readonly logger?: {
110
120
  info?(msg: string, meta?: unknown): void;
111
121
  } | undefined;
122
+ /**
123
+ * `CheckpointStore.durability` 声明(#167 欠账,#168 件5)—— 行落在 MySQL-protocol / PostgreSQL 的
124
+ * `checkpoint` 表里,进程重启、副本轮换、整机重建都不丢 ⇒ `"durable"`,如实。
125
+ *
126
+ * 🔴 为什么这一格空着是有代价的:core 的 `resolveDeclaredDurability` 把**缺席**折成 `"process-local"`
127
+ * (它不能替一个没表态的店猜),于是静态装配面对每一个真持久部署都读出 `process_local`,
128
+ * `GET /v1/diagnostics/wiring` 的 park 车道读数与启动自检的那条警告都因此不可信 —— 而 park 正是流内
129
+ * 审批协议的降级目的地,「重启后还赎不赎得回」是运维必须能一眼看见的事。声明是店自己的责任,不是
130
+ * 消费侧靠 backend.kind 猜出来的。
131
+ */
132
+ readonly durability: StoreDurability;
133
+ /**
134
+ * `CheckpointStore.fidelity` 声明(core 5.17.0 [3052] 提货批 #172)——**如实按介质判**:本店把整个
135
+ * checkpoint 经 {@link SqlCheckpointStore.json} 编码进一个 JSON 列(TiDB 逐字文本 / PG lossless
136
+ * `::jsonb` 协议信封),读侧 `parseJson` 还原 ⇒ 能扛过 round-trip 的只有 JSON 值域,`"json"`。
137
+ *
138
+ * 🔴 为什么必须显式写、哪怕缺席也折向 json:core 的 `resolveDeclaredFidelity` 对缺席是 fail-closed
139
+ * (读 json),所以沉默不会立刻错——但沉默**表达不出**「我核对过我的介质就是这个宽度」。park 铸行
140
+ * 的 args / preview / 风险描述 / `boundInputHash` 全部从这一格算出的投影铸;哪天这四行编码里任何
141
+ * 一处改了介质(换存储格式、换列类型),声明在场才有东西可以红,沉默那格只会安静地按错宽度铸出
142
+ * 一份「审批人看到的 ≠ 盘上躺着的 ≠ resume 执行的」。同 `durability` 的 #168 件5 教训:表态是店
143
+ * 自己的责任,不是消费侧靠 backend.kind 猜。
144
+ *
145
+ * ⚠️ **已知残余(不是遮掩,是这两个词表达不了的那一格)**:PG 臂比 `"json"` 字面**略窄** ——
146
+ * {@link pgProtocolJsonStringify} 对含 NUL / lone surrogate 的串 fail-loud 拒绝(R4-H1 有意裁定:
147
+ * 复核面必须与真执行的 args 在 NUL 那一位上一致,「悄悄清洗再存」是不可接受的那一支),而 core 的
148
+ * json 宽度收下这些码位。`StoreFidelity` 的闭集只有 `"structured-clone" | "json"`,没有第三个词能说
149
+ * 「json 减去本介质存不下的码位」——声明 `"structured-clone"` 是大得多的谎,所以 `"json"` 仍是两者
150
+ * 里唯一诚实的选择。后果有界且 fail-closed:core 5.17.0 起 park 铸行失败会把 cause 带到 gate、追加
151
+ * 到 fallback 的 deny 上,这条 args 退回**同步门**由人判(不静默漏批、不挂死)。边界钉在
152
+ * `wiring-governance-operator.test.ts` 的 #172 组;已上报上游求一个能表达该宽度的词。
153
+ */
154
+ readonly fidelity: StoreFidelity;
112
155
  constructor(db: SqlDriver, logger?: {
113
156
  info?(msg: string, meta?: unknown): void;
114
157
  } | undefined);
115
158
  /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
116
159
  private q;
117
- /** JSON column binding: TiDB stores the string verbatim; PG goes through the LOSSLESS protocol envelope
118
- * (R4-H1 the operator's review surface must agree with the executed args at the NUL position). */
160
+ /** JSON column binding. TiDB: plain `JSON.stringify`, stored verbatim. PG: `pgProtocolJsonStringify`
161
+ * ordinary `JSON.stringify` that **refuses** (throws `PgUnstorableError`) when the value carries a code
162
+ * point PG cannot hold (NUL / lone surrogate); the `::jsonb` at the call site is just the bind cast, not
163
+ * an escaping layer.
164
+ *
165
+ * "LOSSLESS" in R4-H1 means exactly **"never silently lossy"**, not "encodes everything": the contrast is
166
+ * with `pgSanitizeText`, the lossy U+FFFD-scarring path used for CONTENT faces. An approval row is not a
167
+ * content face — the operator's review surface has to agree with the executed args AT the NUL position, so
168
+ * scrubbing the byte and storing the scrubbed row is the unacceptable arm; refusing loudly is the chosen one.
169
+ * That refusal is why {@link SqlCheckpointStore.fidelity} carries a documented residual (see it). */
119
170
  private json;
120
171
  private isDupKey;
121
172
  /** Create-once. core mints the token (`mintCheckpointToken`) and calls this during suspend. */
@@ -268,9 +319,14 @@ export declare class SqlCheckpointStore implements CheckpointStore {
268
319
  * 扫描(§8 C-6)。什么算读不出:blob 的 `version` 超出本 build 支持(`get()` 那条前向兼容门在这里
269
320
  * 不能 throw,否则一条超前行会让整个 session 的对账永久卡死)、或 blob JSON 坏。
270
321
  *
271
- * 匹配是两段的:`tool_call_id` **列**是 `put()` 从 `pendingAction.toolCallId` 盖下来的权威投影,命中它
272
- * 的行零解析即候选;列为 NULL 的行(无工具动作的 park,或列存在之前的旧行)才回落去解 blob——解得出且
273
- * 相等才算候选,解不出就标 `unparseable`。
322
+ * 匹配是两段的:`tool_call_id` **列**是 `put()` 从 `pendingAction.toolCallId` 盖下来的权威投影,SQL 谓词
323
+ * 先按它(或 NULL)收窄;列为 NULL 的行(无工具动作的 park,或列存在之前的旧行)靠解 blob 补判——解得出
324
+ * 且相等才算候选,解不出就标 `unparseable`。
325
+ * 🔴 **没有「列命中即零解析」的快路径**(原注写过,已作废,别照它优化):函数体对**每一行**无条件解
326
+ * blob,原因有二 ——(a) 前向兼容门与 blob 可读性门必须门在**所有**命中路径之前(codex F3 + 确认轮:
327
+ * 列长得对不代表 blob 读得出,放行一条读不出的行去 `bindBatch` 会把 ask 钉成 PARKED + 一张本进程读不出
328
+ * 的 resume 坐标,而 PARKED 不可回滚);(b) `#168` 之后 blob 顶层的 `sourceTaskId` 是和解三元组的第一
329
+ * 维,列命中行结构上也必须解 blob 才拿得到它。
274
330
  */
275
331
  findCheckpointCandidatesForAsk(scope: string, sessionId: string, toolCallId: string, sinceMs: number): Promise<CheckpointAskCandidate[]>;
276
332
  /**