@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.
- package/USAGE.md +63 -0
- package/dist/approval-card.d.ts +15 -3
- package/dist/approval-card.js +41 -7
- package/dist/approval-reconciler.d.ts +120 -16
- package/dist/approval-reconciler.js +146 -19
- package/dist/boot/coordinators.js +13 -3
- package/dist/boot/deferred-sandbox-path-env.d.ts +99 -0
- package/dist/boot/deferred-sandbox-path-env.js +279 -0
- package/dist/boot/execution-env.js +11 -1
- package/dist/boot/org-memory.d.ts +6 -0
- package/dist/boot/org-memory.js +1 -1
- package/dist/boot/reapers.d.ts +2 -0
- package/dist/boot/reapers.js +11 -4
- package/dist/boot/resolve-spec.d.ts +3 -2
- package/dist/boot/resolve-spec.js +175 -63
- package/dist/boot/runner-deps.d.ts +23 -1
- package/dist/boot/runner-deps.js +8 -11
- package/dist/boot/workflow-orchestration.d.ts +8 -3
- package/dist/boot/workflow-orchestration.js +23 -1
- package/dist/capabilities/center-prompts.js +4 -1
- package/dist/config-center/apply-effective.js +33 -10
- package/dist/config-types.d.ts +32 -9
- package/dist/config.d.ts +6 -1
- package/dist/config.js +65 -12
- package/dist/elicitation.js +5 -1
- package/dist/env-facts.d.ts +3 -1
- package/dist/env-facts.js +3 -1
- package/dist/fleet/fleet-bus.d.ts +6 -1
- package/dist/fleet/fleet-bus.js +25 -3
- package/dist/governance-ask-marks.d.ts +31 -0
- package/dist/governance-ask-marks.js +122 -0
- package/dist/hooks/hook-runner.d.ts +28 -0
- package/dist/hooks/hook-runner.js +180 -24
- package/dist/http/routes/diagnostics.d.ts +84 -0
- package/dist/http/routes/diagnostics.js +145 -0
- package/dist/http/routes/memory-policy.d.ts +2 -1
- package/dist/http/routes/memory-policy.js +77 -13
- package/dist/http/routes/runs.js +1 -1
- package/dist/http/routes/tasks.js +87 -29
- package/dist/http/server.d.ts +10 -0
- package/dist/http/server.js +29 -12
- package/dist/http/wire-types.d.ts +7 -2
- package/dist/main.js +51 -8
- package/dist/observability/fail-open.d.ts +109 -0
- package/dist/observability/fail-open.js +227 -0
- package/dist/observability/prompt-manifest.d.ts +17 -0
- package/dist/observability/prompt-manifest.js +8 -0
- package/dist/orchestration/workflow-notify-journal.d.ts +57 -1
- package/dist/orchestration/workflow-notify-journal.js +137 -32
- package/dist/parked-decide.js +9 -4
- package/dist/plugins/approval-ask-store-memory.d.ts +2 -2
- package/dist/plugins/approval-ask-store-memory.js +3 -2
- package/dist/plugins/approval-ask-store-sql.d.ts +27 -5
- package/dist/plugins/approval-ask-store-sql.js +9 -2
- package/dist/plugins/background-shell-support.d.ts +1 -1
- package/dist/plugins/background-shell-support.js +2 -2
- package/dist/plugins/checkpoint-store-sql.d.ts +62 -6
- package/dist/plugins/checkpoint-store-sql.js +71 -11
- package/dist/plugins/local-checkpoint-store.d.ts +20 -1
- package/dist/plugins/local-checkpoint-store.js +19 -0
- package/dist/plugins/mailbox-store-sql.d.ts +4 -10
- package/dist/plugins/mailbox-store-sql.js +57 -4
- package/dist/question.d.ts +18 -14
- package/dist/question.js +83 -34
- package/dist/runs.d.ts +8 -0
- package/dist/runs.js +15 -2
- package/dist/runtime-governance.d.ts +18 -0
- package/dist/runtime-governance.js +90 -3
- package/dist/task-settings.d.ts +16 -21
- package/dist/task-settings.js +22 -19
- package/dist/tool-approval.d.ts +33 -6
- package/dist/tool-approval.js +95 -30
- package/dist/trace/core-keyset-guard.d.ts +17 -3
- package/dist/trace/project.d.ts +36 -1
- package/dist/trace/project.js +55 -2
- package/package.json +3 -3
- package/dist/boot/lexical-path-env.d.ts +0 -14
- 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.
|
|
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.
|
|
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.
|
|
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
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
package/dist/parked-decide.js
CHANGED
|
@@ -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
|
|
85
|
-
// deps.onQuestion` 为 undefined 或 QUESTION_AWAITS_RESUME
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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<
|
|
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
|
|
63
|
-
* (
|
|
64
|
-
*
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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 "
|
|
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 "
|
|
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});
|
|
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
|
|
118
|
-
*
|
|
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
|
-
*
|
|
273
|
-
*
|
|
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
|
/**
|