@sema-agent/server 7.43.0 → 7.44.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 +18 -0
- package/dist/adoption/plan.js +31 -192
- package/dist/adoption/quiesce.js +3 -86
- package/dist/adoption/runner.js +3 -140
- package/dist/adoption/sql.js +0 -74
- package/dist/adoption/wire.js +0 -80
- package/dist/approval-ask-machine.js +0 -75
- package/dist/approval-card.js +0 -323
- package/dist/approval-deny-reasons.js +0 -42
- package/dist/approval-hmac.js +0 -35
- package/dist/approval-reconciler.js +8 -166
- package/dist/approval.js +4 -67
- package/dist/audit.js +1 -44
- package/dist/auth-bridge.js +4 -47
- package/dist/auth-keys.js +0 -23
- package/dist/bake-runner/main.js +4 -65
- package/dist/bake-runner/protocol.js +3 -58
- package/dist/bake-runner/runner.js +5 -91
- package/dist/bench/l8/artifact.js +2 -61
- package/dist/bench/l8/escape.js +0 -25
- package/dist/bench/l8/index.js +0 -14
- package/dist/bench/l8/probes.js +0 -60
- package/dist/bench/l8/run-probes.js +2 -45
- package/dist/bench/s1/arms.js +14 -189
- package/dist/bench/s1/live-deps.js +11 -253
- package/dist/bench/s1/oracle.js +1 -36
- package/dist/bench/s1/repair-oracle-adapter.js +0 -30
- package/dist/bench/s1/reviewer.js +0 -34
- package/dist/bench/s1/row.js +0 -62
- package/dist/bench/s1/run-firm.js +4 -70
- package/dist/bench/s1/runner-ctx.js +0 -40
- package/dist/bench/s1/tasks.js +0 -115
- package/dist/boot/adoption.js +0 -20
- package/dist/boot/budget-tracing.js +3 -55
- package/dist/boot/config-center.js +43 -551
- package/dist/boot/coordinators.js +1 -69
- package/dist/boot/crash-last.js +0 -18
- package/dist/boot/deferred-sandbox-path-env.js +3 -119
- package/dist/boot/execution-env.js +5 -171
- package/dist/boot/governance-seams.js +0 -92
- package/dist/boot/leader.js +0 -69
- package/dist/boot/limit-sync.js +0 -2
- package/dist/boot/memory-boundary.js +3 -91
- package/dist/boot/org-memory.js +1 -25
- package/dist/boot/parked-revive-gate.js +0 -150
- package/dist/boot/permission-rules-audit.js +1 -56
- package/dist/boot/reapers.js +15 -273
- package/dist/boot/resolve-spec.js +9 -768
- package/dist/boot/retention-lane.js +4 -87
- package/dist/boot/runner-deps.js +3 -297
- package/dist/boot/runtime-caps.js +0 -40
- package/dist/boot/session-faces.js +2 -133
- package/dist/boot/shutdown.js +9 -83
- package/dist/boot/side-query-lane.js +2 -137
- package/dist/boot/stores.js +15 -215
- package/dist/boot/task-list-lane.js +0 -18
- package/dist/boot/webfetch-summarize-lane.js +0 -47
- package/dist/boot/workflow-orchestration.js +3 -99
- package/dist/boot-reclaim.js +0 -20
- package/dist/bounded-session-map.js +0 -19
- package/dist/brain.js +2 -139
- package/dist/budget.js +9 -244
- package/dist/capabilities/builtin-tools.js +0 -3
- package/dist/capabilities/center-plugins.js +6 -51
- package/dist/capabilities/center-prompts.js +6 -71
- package/dist/capabilities/code-review-council.js +7 -40
- package/dist/capabilities/collab-workflows.js +1 -44
- package/dist/capabilities/hands-lane.js +0 -65
- package/dist/capabilities/memory-notice.js +0 -70
- package/dist/capabilities/prompt.js +0 -14
- package/dist/capabilities/prompts/code-review.js +0 -14
- package/dist/capabilities/prompts/identity.js +0 -6
- package/dist/capabilities/prompts/team.js +0 -4
- package/dist/capabilities/repo-tools.js +0 -34
- package/dist/capabilities/sandbox-file-send.js +3 -92
- package/dist/capabilities/scenarios.d.ts +0 -1
- package/dist/capabilities/scenarios.js +18 -279
- package/dist/capabilities/select-environment-tool.js +0 -32
- package/dist/capabilities/send-user-file-tool.js +2 -66
- package/dist/capabilities/skills.d.ts +1 -2
- package/dist/capabilities/skills.js +3 -21
- package/dist/capabilities/team.d.ts +3 -11
- package/dist/capabilities/team.js +3 -51
- package/dist/capabilities/tool-defer.js +0 -3
- package/dist/config-center/apply-effective.js +15 -330
- package/dist/config-center/apply-ledger.js +5 -38
- package/dist/config-center/facade.js +0 -41
- package/dist/config-center/hot-keys-registry.js +0 -20
- package/dist/config-center/http-client.js +2 -125
- package/dist/config-center/mcp-revocation.js +2 -34
- package/dist/config-center/read-face.js +0 -59
- package/dist/config-center/restart-signal.js +1 -85
- package/dist/config-center/skills-mcp.d.ts +1 -1
- package/dist/config-center/skills-mcp.js +9 -76
- package/dist/config-center/stage-limits.js +8 -35
- package/dist/config-invariants.js +0 -16
- package/dist/config-lkg.js +0 -42
- package/dist/config-provider.js +3 -186
- package/dist/config-types.js +0 -5
- package/dist/config.js +107 -1145
- package/dist/degenerate-instrument.js +3 -67
- package/dist/deployment-governance.js +0 -124
- package/dist/digest-form.js +0 -12
- package/dist/elicitation.js +3 -86
- package/dist/env-facts.js +7 -75
- package/dist/fleet/fleet-bus.js +34 -507
- package/dist/fleet/fleet-reconciler.js +9 -149
- package/dist/fleet/fleet-terminal-window.js +10 -178
- package/dist/fleet/subagent-tail-bus.js +3 -72
- package/dist/fleet-client.js +10 -70
- package/dist/fleet-lease.js +5 -79
- package/dist/git-api-kind.js +0 -3
- package/dist/governance-ask-marks.js +2 -78
- package/dist/hooks/branch-transcript.js +0 -74
- package/dist/hooks/cc-agent-hook-prompt.js +0 -29
- package/dist/hooks/cc-stop-prompt.js +1 -46
- package/dist/hooks/hook-llm.js +1 -53
- package/dist/hooks/hook-runner.js +20 -414
- package/dist/http/active-run-conflict.js +4 -129
- package/dist/http/cursor-fingerprint.d.ts +5 -0
- package/dist/http/cursor-fingerprint.js +5 -0
- package/dist/http/idempotency.js +0 -37
- package/dist/http/principal-gate.js +3 -40
- package/dist/http/route-ctx.js +0 -9
- package/dist/http/routes/a2a-serve.js +7 -319
- package/dist/http/routes/admin-config-refresh.js +0 -4
- package/dist/http/routes/admin-drain.js +0 -10
- package/dist/http/routes/adoption.js +1 -29
- package/dist/http/routes/agents-roster.js +1 -47
- package/dist/http/routes/approvals-assistant.js +32 -395
- package/dist/http/routes/attachments.js +4 -22
- package/dist/http/routes/capabilities.js +3 -420
- package/dist/http/routes/diagnostics.js +1 -81
- package/dist/http/routes/fleet.js +9 -185
- package/dist/http/routes/images.js +14 -239
- package/dist/http/routes/leader.js +0 -13
- package/dist/http/routes/memory-bundle.js +1 -61
- package/dist/http/routes/memory-policy.js +9 -99
- package/dist/http/routes/notify-wake.js +3 -37
- package/dist/http/routes/observability.js +2 -19
- package/dist/http/routes/retention-ops.js +2 -34
- package/dist/http/routes/rules.js +1 -93
- package/dist/http/routes/runs.js +64 -873
- package/dist/http/routes/session-sync.js +19 -258
- package/dist/http/routes/sessions-list.js +17 -43
- package/dist/http/routes/sessions.js +34 -226
- package/dist/http/routes/shared-memory.js +5 -36
- package/dist/http/routes/side-query.js +1 -87
- package/dist/http/routes/tasks.js +52 -722
- package/dist/http/routes/trace-usage.js +32 -195
- package/dist/http/routes/workflows.js +19 -195
- package/dist/http/run-meta.js +0 -6
- package/dist/http/send.js +0 -32
- package/dist/http/server.js +92 -1597
- package/dist/http/sse-lifecycle.js +2 -13
- package/dist/http/sse-log.js +3 -48
- package/dist/http/tar.js +5 -21
- package/dist/http/verify-rounds.js +0 -5
- package/dist/http/wire-gate.js +0 -9
- package/dist/http/workspace-content.js +0 -10
- package/dist/images/bake-validate.js +1 -70
- package/dist/images/manifest.js +1 -6
- package/dist/index.js +0 -21
- package/dist/key-resolver.js +2 -17
- package/dist/leader/diffout.js +1 -20
- package/dist/leader/diffup.js +0 -47
- package/dist/leader/endpoint.js +2 -57
- package/dist/leader/fanout.js +3 -45
- package/dist/leader/grader-env-factory.js +3 -72
- package/dist/leader/leader.js +5 -156
- package/dist/leader/merge.js +8 -115
- package/dist/leader/planner.js +3 -54
- package/dist/leader/repair-oracle.js +1 -60
- package/dist/leader/repair-wire.js +2 -79
- package/dist/leader/wire.js +8 -307
- package/dist/lsp/e2b-bridge.js +4 -64
- package/dist/lsp/e2b-manager.js +6 -94
- package/dist/lsp/lsp-frames.js +0 -12
- package/dist/lsp/manager.js +4 -96
- package/dist/lsp/ws-transport.js +5 -55
- package/dist/lsp-evict.js +1 -15
- package/dist/main.js +57 -765
- package/dist/memory-bundle-engine.js +0 -55
- package/dist/memory-export.js +0 -4
- package/dist/memory-posture.js +1 -15
- package/dist/memory-scope.js +11 -148
- package/dist/memory-sync-client.js +2 -44
- package/dist/memory-sync.js +1 -80
- package/dist/model-select.js +3 -80
- package/dist/observability/cost-quota.js +1 -17
- package/dist/observability/cost-taxonomy.js +0 -34
- package/dist/observability/fail-open.js +7 -86
- package/dist/observability/logger.js +0 -6
- package/dist/observability/metrics.js +0 -94
- package/dist/observability/otel-exporter.js +3 -13
- package/dist/observability/principal-context.js +0 -9
- package/dist/observability/prompt-manifest.js +1 -37
- package/dist/observability/rate-limit.js +0 -4
- package/dist/observability/secret-env-scrub.js +2 -56
- package/dist/observability/tool-trace.js +1 -70
- package/dist/orchestration/hardened-vm-runner.js +4 -118
- package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
- package/dist/orchestration/hardened-vm-worker.js +0 -27
- package/dist/orchestration/subagent-steer.js +1 -45
- package/dist/orchestration/workflow-agent-steer.js +1 -80
- package/dist/orchestration/workflow-completion-inbox.js +32 -285
- package/dist/orchestration/workflow-notify-journal.js +16 -259
- package/dist/org-memory-admission.js +3 -47
- package/dist/parent-watch.js +2 -48
- package/dist/parked-decide.js +1 -109
- package/dist/per-task-image.js +0 -57
- package/dist/plan-cache-probe.js +3 -27
- package/dist/plugins/adoption-log-sql.js +2 -119
- package/dist/plugins/approval-ask-store-memory.js +3 -38
- package/dist/plugins/approval-ask-store-sql.js +6 -188
- package/dist/plugins/approval-exemption-store.js +2 -28
- package/dist/plugins/background-agent-store-sql.js +4 -105
- package/dist/plugins/background-shell-support.js +14 -122
- package/dist/plugins/blob-backend.js +6 -169
- package/dist/plugins/breaker-state-sql.js +8 -46
- package/dist/plugins/caching-session-store.js +4 -106
- package/dist/plugins/checkpoint-store-sql.js +18 -505
- package/dist/plugins/e2b-orphan-reclaim.js +0 -45
- package/dist/plugins/file-outcome-sink.js +0 -9
- package/dist/plugins/file-resume-anchor-store.js +4 -43
- package/dist/plugins/file-run-store.js +26 -364
- package/dist/plugins/file-snapshot-store-sql.js +10 -181
- package/dist/plugins/fork-routing-session-store.js +8 -111
- package/dist/plugins/host-platform.js +2 -91
- package/dist/plugins/image-bake-store-sql.js +7 -250
- package/dist/plugins/image-index-sql.js +4 -123
- package/dist/plugins/k8s-bg-scripts.js +4 -88
- package/dist/plugins/k8s-exec-protocol.js +0 -41
- package/dist/plugins/leader-run-store-sql.js +0 -101
- package/dist/plugins/local-checkpoint-store.js +8 -128
- package/dist/plugins/local-session-store.js +34 -296
- package/dist/plugins/local-task-attachment-store.js +2 -16
- package/dist/plugins/mailbox-store-sql.js +8 -65
- package/dist/plugins/memory-embedder-fingerprint.js +5 -166
- package/dist/plugins/memory-embedder.js +4 -70
- package/dist/plugins/memory-engine-pg.js +6 -170
- package/dist/plugins/memory-engine-tidb.js +7 -155
- package/dist/plugins/memory-engine-vector-util.js +0 -10
- package/dist/plugins/memory-key-guards.js +0 -34
- package/dist/plugins/memory-origin-law.js +0 -187
- package/dist/plugins/memory-resume-anchor-store.js +0 -17
- package/dist/plugins/memory-run-store.js +12 -89
- package/dist/plugins/memory-session-policy-store.js +0 -17
- package/dist/plugins/memory-sync-store-pg.js +4 -49
- package/dist/plugins/memory-sync-store-tidb.js +3 -35
- package/dist/plugins/outcome-ledger-sql.js +3 -97
- package/dist/plugins/permission-rule-store-file.js +5 -133
- package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
- package/dist/plugins/permission-rule-store-sql.js +50 -307
- package/dist/plugins/pg-cost-quota.js +0 -7
- package/dist/plugins/pg-pool.js +0 -92
- package/dist/plugins/pg-rate-limiter.js +2 -13
- package/dist/plugins/pg-safe-json.js +4 -40
- package/dist/plugins/pg-session-storage.js +25 -189
- package/dist/plugins/posix-shell-fs.js +1 -31
- package/dist/plugins/remote-env-adb.js +12 -101
- package/dist/plugins/remote-env-e2b.js +40 -371
- package/dist/plugins/remote-env-file-error.js +0 -37
- package/dist/plugins/remote-env-host.js +63 -483
- package/dist/plugins/remote-env-k8s.js +32 -326
- package/dist/plugins/remote-env-local-docker.js +24 -157
- package/dist/plugins/remote-env-ssh.js +20 -128
- package/dist/plugins/remote-scratchpad.js +2 -32
- package/dist/plugins/remote-shell.js +1 -32
- package/dist/plugins/resume-anchor-store-sql.js +0 -11
- package/dist/plugins/retention-lane-store-sql.js +0 -108
- package/dist/plugins/retention-store-sql.js +4 -383
- package/dist/plugins/roster-store-sql.js +0 -55
- package/dist/plugins/run-store-sql.js +14 -251
- package/dist/plugins/s3-presign.js +2 -49
- package/dist/plugins/scheduler-support.js +3 -80
- package/dist/plugins/send-file-ledger.js +4 -53
- package/dist/plugins/send-user-file.js +4 -94
- package/dist/plugins/session-placement.js +1 -89
- package/dist/plugins/session-policy-store-sql.js +3 -81
- package/dist/plugins/session-store.js +0 -57
- package/dist/plugins/shared-memory-store-sql.js +4 -167
- package/dist/plugins/sql-driver.js +0 -17
- package/dist/plugins/sql-errors.js +0 -7
- package/dist/plugins/sql-escape.js +0 -8
- package/dist/plugins/sql-row-helpers.js +0 -25
- package/dist/plugins/store-backend.js +42 -222
- package/dist/plugins/store-contracts.js +2 -46
- package/dist/plugins/task-attachment-store.js +3 -49
- package/dist/plugins/task-list-store-sql.js +0 -79
- package/dist/plugins/tidb-cost-quota.js +1 -4
- package/dist/plugins/tidb-pool.js +1 -207
- package/dist/plugins/tidb-rate-limiter.js +3 -9
- package/dist/plugins/tidb-session-storage.js +4 -70
- package/dist/plugins/tidb-session-store.js +19 -341
- package/dist/plugins/tool-result-store-sql.js +4 -179
- package/dist/plugins/usage-window-store-sql.js +0 -8
- package/dist/plugins/web-search.js +10 -132
- package/dist/plugins/workflow-journal-store-sql.js +2 -58
- package/dist/plugins/workflow-run-store-sql.js +6 -91
- package/dist/plugins/worktree-isolation.js +6 -126
- package/dist/plugins/write-behind-counter.js +16 -75
- package/dist/principal-jwt.js +5 -60
- package/dist/project-memory.js +15 -146
- package/dist/prompts-domain-validate.js +1 -51
- package/dist/question.js +2 -111
- package/dist/resource-suspend.js +0 -18
- package/dist/router/route-orchestration.js +0 -77
- package/dist/rules-consent.d.ts +44 -5
- package/dist/rules-consent.js +75 -215
- package/dist/run-local.js +14 -381
- package/dist/runs.js +29 -617
- package/dist/runtime-caps-resolver.js +6 -132
- package/dist/runtime-governance.js +1 -232
- package/dist/sandbox-pkg-source.js +0 -37
- package/dist/sealed-key.js +3 -68
- package/dist/security.js +6 -291
- package/dist/session-leaf-bus.js +0 -32
- package/dist/session-sync-content.js +1 -69
- package/dist/session-sync-kernel.js +3 -60
- package/dist/session-sync.js +3 -66
- package/dist/session-titler.js +7 -41
- package/dist/session-watch.js +9 -73
- package/dist/shared-memory-scope-authorizer.js +0 -18
- package/dist/sighup-idle.js +1 -12
- package/dist/spec-fields.js +4 -143
- package/dist/store-live-probe.js +3 -49
- package/dist/task-a2a.js +1 -126
- package/dist/task-cwd.js +1 -103
- package/dist/task-mcp.js +1 -89
- package/dist/task-settings.js +10 -300
- package/dist/task-workflow.js +8 -75
- package/dist/tool-approval.d.ts +28 -1
- package/dist/tool-approval.js +70 -1430
- package/dist/trace/artifacts.js +5 -20
- package/dist/trace/engine-notice-wire.js +6 -138
- package/dist/trace/ledger-sink.js +7 -96
- package/dist/trace/project.js +4 -448
- package/dist/trace/redact.js +9 -77
- package/dist/turn-activity.js +1 -27
- package/dist/usage-analytics.js +4 -34
- package/dist/wall-clock-jump-guard.js +1 -68
- package/package.json +3 -3
- package/dist/capabilities/scenario-alias.d.ts +0 -27
- package/dist/capabilities/scenario-alias.js +0 -61
|
@@ -1,33 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Durable `CheckpointStore` (core 1.67 durable-checkpoint / design/45) — SINGLE-FILE DUAL-DIALECT
|
|
3
|
-
* (design/158 A12 定型半场). ONE implementation, TWO dialects; the historical `TiDBCheckpointStore` /
|
|
4
|
-
* `PgCheckpointStore` class names survive as thin ctor subclasses so every consumer (store-backend.ts,
|
|
5
|
-
* local-checkpoint-store.ts, the chaos/ownership/blackbox suites) is untouched.
|
|
6
|
-
*
|
|
7
|
-
* This is the durable backend that makes a policy `ask` survive cross-replica/crash/time as a suspended
|
|
8
|
-
* task an operator can `resume` from any instance. It implements core's `CheckpointStore` (put create-once
|
|
9
|
-
* / get / atomic CAS resolve / reopen / reap), modeled on the proven approval-store CAS, plus two concerns
|
|
10
|
-
* the core interface doesn't own but the service does (DURABLE-CHECKPOINT-PLAN §4.4a):
|
|
11
|
-
* - `checkpoint_ctx` (sessionId-keyed): the service-side scenario rebuild inputs for `resume`'s taskConfig —
|
|
12
|
-
* core's `put(token, cp)` can't carry it (it's service "profile", not the generic core checkpoint).
|
|
13
|
-
* - operator-queue helpers (`listPending` / `findPendingTokenBySession`) that surface NON-secret fields and
|
|
14
|
-
* map a session → its pending token INTERNALLY — the capability token never leaves the service (token-as-auth).
|
|
15
|
-
*
|
|
16
|
-
* ── Dialect deltas, kept EXPLICIT ────────────────────────────────────────────────────────────────────
|
|
17
|
-
* - `?` placeholders vs `$n` (and the OPTIONAL `AND rev = ?` / `AND rev = $5` OCC tail)
|
|
18
|
-
* - `ON DUPLICATE KEY UPDATE … VALUES(col)` vs `ON CONFLICT (session_id) DO UPDATE SET … EXCLUDED.col`
|
|
19
|
-
* - dup-key errno `1062` (ER_DUP_ENTRY) vs SQLSTATE `23505`
|
|
20
|
-
* - null-safe owner compare: `<=>` vs `IS NOT DISTINCT FROM` — AND the parameter arity that follows
|
|
21
|
-
* (`?` cannot be reused, so the TiDB deleteBySession binds sessionId TWICE; PG reuses `$1`)
|
|
22
|
-
* - affectedRows vs rowCount (via SqlDriver)
|
|
23
|
-
* - JSON binding: TiDB `JSON.stringify` verbatim vs PG `pgProtocolJsonStringify` + `$n::jsonb` casts.
|
|
24
|
-
* PG's envelope is LOSSLESS (codex R4-H1): tool_input is the operator's review surface and must agree
|
|
25
|
-
* with `checkpoint.pendingAction.args` at the NUL position — redact/truncate is the display contract,
|
|
26
|
-
* byte rewriting is not.
|
|
27
|
-
* - schema ownership: TiDB DDL in tidb-pool.ts, PG DDL centrally in pg-pool.ts (neither store creates tables).
|
|
28
|
-
*/
|
|
29
1
|
import { createHash } from "node:crypto";
|
|
30
|
-
import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
|
|
2
|
+
import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js";
|
|
31
3
|
import { CheckpointError, MAX_RULE_TEXT_CHARS, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, } from "@sema-agent/core";
|
|
32
4
|
import { redactDeep, redactSecrets } from "../trace/redact.js";
|
|
33
5
|
import { MAX_RULE_SUGGESTIONS, RuleSuggestionRawSchema } from "../approval-card.js";
|
|
@@ -36,77 +8,13 @@ import { recordFailOpen } from "../observability/fail-open.js";
|
|
|
36
8
|
import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
37
9
|
import { isDupKeyError } from "./sql-errors.js";
|
|
38
10
|
import { MANAGED_RETENTION } from "./retention-store-sql.js";
|
|
39
|
-
/** Cap the persisted/served pending-approval args so a pathological tool payload can't bloat the checkpoint row
|
|
40
|
-
* or the operator-queue response (the BFF renders this in an approval card; a few KB is plenty). */
|
|
41
11
|
const MAX_TOOL_INPUT_CHARS = 8192;
|
|
42
|
-
/**
|
|
43
|
-
* design/80 D-1 (§3 invariant #3 — crash-safe reaper backstop): an ABSOLUTE upper bound on a pending
|
|
44
|
-
* checkpoint's lifetime, stamped at put() into `terminal_at_ms` INDEPENDENT of the per-approval `deadline`. The
|
|
45
|
-
* SLA-timer (D-D) does the fine, per-gate-kind resolve-deny; THIS coarse backstop ensures even a pending row
|
|
46
|
-
* with a NULL `deadline` (no approval TTL configured) is eventually GC'd if the SLA service dies — closing a
|
|
47
|
-
* forever-leak of a never-resolved suspension (the current `reap`/`reapExpired` only catch non-NULL deadlines).
|
|
48
|
-
* Generous (default 30d) so it never pre-empts a legitimately long-lived human/irreversible_ask gate;
|
|
49
|
-
* env-overridable. Clamped ≥1min so a misconfig can't expire live suspensions instantly.
|
|
50
|
-
*/
|
|
51
|
-
// A-067 C1-2(F2a):解析必须钳有限——`"Infinity"` 经 `Number(env) || def` 取 Infinity、Math.max 恒
|
|
52
|
-
// Infinity ⇒ 本常量存在的唯一理由(SLA 服务死掉后 pending 行仍被 GC 的 crash-safe 绝对上限)整条失效。
|
|
53
|
-
// 非有限/非法值落默认(与 remote-shell numEnvOr 同判据方向;此处零告警通道,静默落默认承原形)。
|
|
54
12
|
export const TERMINAL_BACKSTOP_MS = Math.max(60_000, (Number.isFinite(Number(process.env.APPROVAL_TERMINAL_BACKSTOP_MS)) ? Number(process.env.APPROVAL_TERMINAL_BACKSTOP_MS) : 0) || 30 * 86_400_000);
|
|
55
|
-
|
|
56
|
-
* design/80 D-D (adversarial fix): the crash-safe `terminal_at_ms` backstop must fall STRICTLY AFTER any SLA
|
|
57
|
-
* `deadline`, never AT it. `terminal_at_ms = max(createdAt+backstop, deadline)` made the two coincide whenever an
|
|
58
|
-
* operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's terminal_at_ms-branch (which
|
|
59
|
-
* has NO gate_kind filter) then abort-EXPIRED a human/irreversible_ask gate in the SAME tick the deny-sweep
|
|
60
|
-
* wanted to gracefully DENY it, racing it away. Adding this grace to the deadline term guarantees the deny-sweep
|
|
61
|
-
* at least this window of clean ticks before the absolute backstop can fire. Far smaller than the backstop, so
|
|
62
|
-
* it never meaningfully delays the eventual crash-safe GC.
|
|
63
|
-
*/
|
|
64
|
-
export const TERMINAL_GRACE_MS = 3_600_000; // exported for the LOCAL twin's read-time terminal_at_ms derivation (anti-drift) // 1h — many reaper intervals of deny-sweep runway past the SLA deadline
|
|
65
|
-
/**
|
|
66
|
-
* The capability token IS the resume credential (token-as-auth) — anyone who reads it can impersonate a
|
|
67
|
-
* resume, so it must never reach the logs (which fan out to a log-aggregation pipeline). For the diagnostic
|
|
68
|
-
* resume-read-path log we emit a stable, non-reversible fingerprint instead, enough to correlate get↔resolve.
|
|
69
|
-
*/
|
|
13
|
+
export const TERMINAL_GRACE_MS = 3_600_000;
|
|
70
14
|
export function tokenFingerprint(token) {
|
|
71
15
|
return "sha256:" + createHash("sha256").update(token).digest("hex").slice(0, 12);
|
|
72
16
|
}
|
|
73
|
-
/** 窄读的**扫描窗**:最多看这么多条原料再判形(基数帽 {@link MAX_RULE_SUGGESTIONS} 扣在合形之后)。
|
|
74
|
-
* 存在的理由是热读路径的有界性,不是基数语义 —— 所以它比基数帽宽得多。 */
|
|
75
17
|
const MAX_RULE_SUGGESTION_SCAN = 32;
|
|
76
|
-
/**
|
|
77
|
-
* `pendingAction.ruleSuggestions` 的**铸行侧**投影(mint 面,与 {@link boundedToolInput} 同位同待遇)。
|
|
78
|
-
*
|
|
79
|
-
* 三件事,一件都不能少:
|
|
80
|
-
* 1. **结构窄读**(宪法 [2704] 边界必 schema / 禁裸 as-cast):blob 里的这一格是 `unknown`(旧 core /
|
|
81
|
-
* 坏行 / 未来加字段都可能)。形不合的**整条丢弃**(不是整只丢弃)——一条坏候选不该让另一条真候选消失。
|
|
82
|
-
* 判据只看 `rule`/`match`/`command` 三键合形(`RuleSuggestionRawSchema` 是**默认 strip 形** `.strip()`,
|
|
83
|
-
* 多余键**剥掉**、且不拒收 —— 重扫二轮更正:旧注写 `.loose()`,而 zod 4 的 loose = passthrough,
|
|
84
|
-
* 未知键会**留在** `parsed.data` 里,与「剥掉」相反)。上游 additive 加一键是 core 的常规做法,拿 strict 去窄读会让那一次加字段把整只
|
|
85
|
-
* 候选静默清零 —— 「有供给」当场变「无供给」,与本键的存在理由正相反(顶注第三段)。
|
|
86
|
-
* 2. **脱敏**:候选文本是从**命令原文**铸出来的(`Bash(curl -H "Authorization: Bearer …")` 这种 exact
|
|
87
|
-
* 形拼得出秘密),而 `GET /v1/approvals` 是**跨租户可见**的运维队列。落列前过 `redactSecrets` ——
|
|
88
|
-
* 与同行 `tool_input` 在 mint 就 `redactDeep` 是**同一条**纪律(#209 件4 把 `shadowedRule` 修在读面
|
|
89
|
-
* 是因为那条路的行早已落库、改铸点救不了存量;本键是**新**列,没有存量行要救,修在铸点更彻底:
|
|
90
|
-
* 秘密从此连**落库**都不落,不是落了库再在两条读面各洗一遍)。
|
|
91
|
-
* 3. **脱敏之后**才截长(codex 对抗复审 [medium],验真后修):`redactSecrets` 会**变长**,先按限长校验
|
|
92
|
-
* 原文再脱敏会产出超限文本,而 SQL 腿有**回读**(列 → 本函数再跑一遍)、LOCAL 腿没有 ⇒ 同一份素材
|
|
93
|
-
* 两个后端给出不同卡面。顺序反过来之后本函数对自己的输出**幂等**,两条腿同形。完整论证见
|
|
94
|
-
* `RuleSuggestionRawSchema` 顶注。
|
|
95
|
-
* 4. **限基数**:core 契约今天 ≤2,这里按 {@link MAX_RULE_SUGGESTIONS}(=4,server 侧容忍帽,两个数的
|
|
96
|
-
* 分工逐字见该常量顶注)截 —— 一条被改坏/未来放宽的上游不该把运维队列的一行撑爆。
|
|
97
|
-
* 🔴 **扣预算在合形之后**(合并码重扫):旧序先 `slice` 再判形,于是 ≥MAX 条坏候选能把唯一的真候选
|
|
98
|
-
* 挤出预算,读数是 `undefined` —— 与上面第 1 条自证的不变量(坏的丢、好的照投)正相反。扫描窗
|
|
99
|
-
* {@link MAX_RULE_SUGGESTION_SCAN} 仍是有界的:一条被改坏成上万条的行不该让每 3s 一拍的读路径去逐条判形。
|
|
100
|
-
*
|
|
101
|
-
* 🔴 `opts.countRedactions`:**两条腿都照常脱敏,只有回读腿不计数**(合并码重扫;codex 复审第一轮
|
|
102
|
-
* 指出「回读不脱敏」会把纵深防御一起关掉,验真后按这条收)。`redactSecrets` 对自己的输出是**值幂等、
|
|
103
|
-
* 计数不幂等**(URL-userinfo / JSON 秘密字段两条 pattern 会命中自己的输出并再计一次),而回读腿正是
|
|
104
|
-
* `/v1/approvals/stream` 每 3s 一拍的热路径 ⇒ 每条订阅每行每拍给 `redactions_applied_total` +1,把那个
|
|
105
|
-
* 用来识别「过度脱敏 / pattern 从不触发」的指标灌成不可读。关掉的是**计数**:重扫仍在,手改过的行 /
|
|
106
|
-
* 将来某条写路径漏了脱敏,读面照样兜住(运维队列跨租户可见,少脱一次的代价永远不能省)。
|
|
107
|
-
*
|
|
108
|
-
* 返回 `undefined` = **无供给**(键不落列):core 没给、给了空数组、或整只形不合。
|
|
109
|
-
*/
|
|
110
18
|
export function boundedRuleSuggestions(raw, opts = {}) {
|
|
111
19
|
if (!Array.isArray(raw))
|
|
112
20
|
return undefined;
|
|
@@ -114,11 +22,10 @@ export function boundedRuleSuggestions(raw, opts = {}) {
|
|
|
114
22
|
const out = [];
|
|
115
23
|
for (const item of raw.slice(0, MAX_RULE_SUGGESTION_SCAN)) {
|
|
116
24
|
if (out.length >= MAX_RULE_SUGGESTIONS)
|
|
117
|
-
break;
|
|
25
|
+
break;
|
|
118
26
|
const parsed = RuleSuggestionRawSchema.safeParse(item);
|
|
119
27
|
if (!parsed.success)
|
|
120
|
-
continue;
|
|
121
|
-
// 先脱敏、再截 —— 截完必然满足 `RuleSuggestionSchema` 的 `.max()`,于是回读再跑一遍是恒等操作。
|
|
28
|
+
continue;
|
|
122
29
|
out.push({
|
|
123
30
|
rule: redactSecrets(parsed.data.rule, { observe }).slice(0, MAX_RULE_TEXT_CHARS),
|
|
124
31
|
match: parsed.data.match,
|
|
@@ -127,19 +34,6 @@ export function boundedRuleSuggestions(raw, opts = {}) {
|
|
|
127
34
|
}
|
|
128
35
|
return out.length > 0 ? out : undefined;
|
|
129
36
|
}
|
|
130
|
-
/**
|
|
131
|
-
* `checkpoint.rule_suggestions` 这一格的**回读口**(合并码重扫)。
|
|
132
|
-
*
|
|
133
|
-
* 🔴 为什么不是直接 `parseJson`:本文件的 `parseJson` 是 **strict** flavor(畸形 JSON 文本直接抛),而
|
|
134
|
-
* `listPending` 是 `rows.map(...)` —— 一处抛就中止整只映射 ⇒ **一行**手改坏的 cell 打掉整个租户的运维
|
|
135
|
-
* 队列(`GET /v1/approvals` 500;`/v1/approvals/stream` 落进它的 catch,每 3s 一次静默重试,心跳照常而
|
|
136
|
-
* 队列永远空)。而 {@link boundedRuleSuggestions} 的顶注承诺的是「手改过的行在这里当场落成**无供给**」——
|
|
137
|
-
* 承诺只对「合法 JSON 但错形」成立,对「JSON 文本本身坏了」不成立。这里把两者补齐:坏 cell ⇒ 这一行
|
|
138
|
-
* 这一格缺席,其余行照常读出来。
|
|
139
|
-
*
|
|
140
|
-
* 🔴 **响亮**:F 类兜底(丢的是一格展示/分诊材料,不是判据),按仓内纪律走 `recordFailOpen` —— 登记 tag
|
|
141
|
-
* + 逐次计数 + 逐次探针 + 每进程一次 warn。静默 `catch` 在这条路上正是本仓要消灭的形。
|
|
142
|
-
*/
|
|
143
37
|
function readRuleSuggestionsCell(cell) {
|
|
144
38
|
let parsed;
|
|
145
39
|
try {
|
|
@@ -151,25 +45,6 @@ function readRuleSuggestionsCell(cell) {
|
|
|
151
45
|
}
|
|
152
46
|
return boundedRuleSuggestions(parsed, { countRedactions: false });
|
|
153
47
|
}
|
|
154
|
-
/**
|
|
155
|
-
* `checkpoint.risk_descriptor` 这一格的**回读口**(重扫二轮)——与 {@link readRuleSuggestionsCell} 同族同待遇。
|
|
156
|
-
*
|
|
157
|
-
* 🔴 病(红先复现):上一格补了容错,而**同一个 `rows.map`** 里这一格还走 strict `parseJson`。两个后端里
|
|
158
|
-
* 这两列是同一种存储形(`tidb-pool.ts` / `pg-pool.ts` 双方言都是 TEXT),坏法完全同族(运维手改 / 回滚
|
|
159
|
-
* 残留 / 列语义漂)⇒ 坏的若是这一格,`listPending` 照样整只 reject,上一格顶注与 FAIL-OPEN-CENSUS 宣称的
|
|
160
|
-
* 「一行坏 cell 不再打掉整只队列」在「手改过的行」这个真实场景上并不成立。
|
|
161
|
-
*
|
|
162
|
-
* 🔴 **失败方向自证**(能力语义一致性,#157 纪律):这一格在本读面上只有三个**展示/分诊**消费者 ——
|
|
163
|
-
* ① severity 分诊排序(缺席折 0,排在有 descriptor 的行之后)② `shadowedRule` 的读面脱敏 ③ 读面派生的
|
|
164
|
-
* `governanceForced` 徽章。三者**都不是执法判据**:门早已 park,队列只负责把它显示给人;真按
|
|
165
|
-
* `riskDescriptor.shellGateDoctrine` 判的 `active-run-conflict.ts` 读的是 checkpoint blob 的 `get()`,
|
|
166
|
-
* 不经本读面。而 `governanceForced` 的成文语义本就是「缺席 = 没有治理来源的**证据**」(恒不写 `false`),
|
|
167
|
-
* 缺席是保守读法。对照另一支:整只队列 500 时这三样连同**其余所有行**一起消失。⇒ 行级容忍严格优于整只
|
|
168
|
-
* 毒化,不是把保护型判据静默放行。
|
|
169
|
-
*
|
|
170
|
-
* tag **单列不并计**:两格的缺席后果不同(候选=「不再询问」那一格;本格=分诊排序与出身徽章),并计会让
|
|
171
|
-
* 「哪一格在坏」在遥测里读不出来。
|
|
172
|
-
*/
|
|
173
48
|
function readRiskDescriptorCell(cell) {
|
|
174
49
|
try {
|
|
175
50
|
return parseJson(cell);
|
|
@@ -179,33 +54,17 @@ function readRiskDescriptorCell(cell) {
|
|
|
179
54
|
return null;
|
|
180
55
|
}
|
|
181
56
|
}
|
|
182
|
-
/** Redact + size-bound a pending tool call's args for persistence/serving. JSON-roundtrips so the stored shape
|
|
183
|
-
* matches what the operator queue serves; oversized payloads collapse to a marker rather than bloating the row.
|
|
184
|
-
* Exported for the LOCAL twin (local-checkpoint-store.ts) so the operator-queue projection can't drift. */
|
|
185
57
|
export function boundedToolInput(args) {
|
|
186
58
|
if (args === undefined)
|
|
187
59
|
return null;
|
|
188
60
|
const redacted = redactDeep(args);
|
|
189
61
|
const json = JSON.stringify(redacted);
|
|
190
62
|
if (json === undefined)
|
|
191
|
-
return null;
|
|
63
|
+
return null;
|
|
192
64
|
if (json.length > MAX_TOOL_INPUT_CHARS)
|
|
193
65
|
return { truncated: true, bytes: json.length };
|
|
194
66
|
return redacted;
|
|
195
67
|
}
|
|
196
|
-
/**
|
|
197
|
-
* checkpoint blob → `pendingAction.toolCallId`。
|
|
198
|
-
*
|
|
199
|
-
* 🔴 三态、**不是**两态(真双库实跑抓到的缺陷,2026-08-06):「读不出」与「读出来了、但这条 park 本来就
|
|
200
|
-
* 没有 toolCallId」是两件事,压成同一个 `undefined` 会把每一条 `plan_review`/`task_done`/`resource_limit`
|
|
201
|
-
* 腿(core 的 `PendingAction` 联合里三个成员结构上就没有 toolCallId)都误报成坏行。前者应标 `unparseable`
|
|
202
|
-
* 交给收敛器当「不确定」,后者是**确定的不匹配**——干净地不是候选。
|
|
203
|
-
* - `{ readable: false }` —— JSON 坏 / 形状根本不是对象:真的读不出。
|
|
204
|
-
* - `{ readable: true, toolCallId: null }` —— 读出来了,这条 park 无工具动作:确定不匹配。
|
|
205
|
-
* - `{ readable: true, toolCallId: "…" }` —— 读出来了,拿去比。
|
|
206
|
-
*
|
|
207
|
-
* 逐层 `typeof` 收窄,不做裸 `as` 断言:blob 是持久层读回来的 `unknown`,是信任边界(宪法 [2704])。
|
|
208
|
-
*/
|
|
209
68
|
function pendingActionToolCallId(blob) {
|
|
210
69
|
let parsed;
|
|
211
70
|
try {
|
|
@@ -216,15 +75,12 @@ function pendingActionToolCallId(blob) {
|
|
|
216
75
|
}
|
|
217
76
|
if (typeof parsed !== "object" || parsed === null)
|
|
218
77
|
return { readable: false };
|
|
219
|
-
// 🔴 `sourceTaskId` 是 checkpoint 的**顶层**字段(不在 pendingAction 里),没有对应的列 ⇒ 只能从
|
|
220
|
-
// blob 读。它是和解三元组的第一维(#168 件1);形不合(有这个键但不是字符串)= 结构漂移,按本函数
|
|
221
|
-
// 一贯的收窄纪律判 `readable: false`,不静默折成缺席。
|
|
222
78
|
const rawSource = "sourceTaskId" in parsed ? parsed.sourceTaskId : null;
|
|
223
79
|
if (rawSource !== null && rawSource !== undefined && typeof rawSource !== "string")
|
|
224
80
|
return { readable: false };
|
|
225
81
|
const sourceTaskId = typeof rawSource === "string" ? rawSource : null;
|
|
226
82
|
if (!("pendingAction" in parsed))
|
|
227
|
-
return { readable: true, toolCallId: null, boundInputHash: null, kind: null, sourceTaskId };
|
|
83
|
+
return { readable: true, toolCallId: null, boundInputHash: null, kind: null, sourceTaskId };
|
|
228
84
|
const pa = parsed.pendingAction;
|
|
229
85
|
if (typeof pa !== "object" || pa === null)
|
|
230
86
|
return { readable: false };
|
|
@@ -232,17 +88,12 @@ function pendingActionToolCallId(blob) {
|
|
|
232
88
|
if (kind !== null && typeof kind !== "string")
|
|
233
89
|
return { readable: false };
|
|
234
90
|
if (!("toolCallId" in pa))
|
|
235
|
-
return { readable: true, toolCallId: null, boundInputHash: null, kind, sourceTaskId };
|
|
91
|
+
return { readable: true, toolCallId: null, boundInputHash: null, kind, sourceTaskId };
|
|
236
92
|
const id = pa.toolCallId;
|
|
237
93
|
if (typeof id !== "string")
|
|
238
94
|
return { readable: false };
|
|
239
|
-
// 🔴(属主兜底轮 F1)带 toolCallId 却挂着词表外的 kind = 结构漂移,不是「读得出」——这条 blob 声称
|
|
240
|
-
// 自己是一次工具审批 park(有 callId)又否认(kind 不是 tool_approval)。让它带着匹配的 callId 混进
|
|
241
|
-
// 健康候选,等于给「外部改库 / schema 偏斜」开一条错配 PARK 的路。词表判据窄取 "tool_approval":
|
|
242
|
-
// 其余合法 kind(plan_review 等)在 core 的类型里结构上**没有** toolCallId,走不到这里。
|
|
243
95
|
if (kind !== "tool_approval")
|
|
244
96
|
return { readable: false };
|
|
245
|
-
// hash 只做逐层收窄,不在这层与列比对(比对是调用处的列/blob 一致性门)。缺席 = null(旧行/无 hash 形)。
|
|
246
97
|
const bih = "boundInputHash" in pa ? pa.boundInputHash : null;
|
|
247
98
|
if (bih !== null && typeof bih !== "string")
|
|
248
99
|
return { readable: false };
|
|
@@ -258,8 +109,6 @@ function readActorAssertion(v) {
|
|
|
258
109
|
const issuer = "issuer" in v ? v.issuer : undefined;
|
|
259
110
|
return { id, hostAsserted, ...(typeof issuer === "string" ? { issuer } : {}) };
|
|
260
111
|
}
|
|
261
|
-
/** 队列化之前的**裸单座**形(`{text,trusted}`)—— 旧行原样躺在库里,core 的 `readPendingSteerQueue`
|
|
262
|
-
* 会把它折成 member 0,所以读出来照原样保留、不改写、不迁移。 */
|
|
263
112
|
function readLegacySeat(v) {
|
|
264
113
|
if (typeof v !== "object" || v === null)
|
|
265
114
|
return undefined;
|
|
@@ -269,8 +118,6 @@ function readLegacySeat(v) {
|
|
|
269
118
|
return undefined;
|
|
270
119
|
return { text, trusted };
|
|
271
120
|
}
|
|
272
|
-
/** 队列条目的逐层窄读(持久层读回的是 `unknown`,是信任边界 —— 禁裸 `as`)。四个必备键任一不合形 ⇒
|
|
273
|
-
* 整条不认(返回 undefined,由调用方判 lossy);`actor`/`priority` 可选,不合形即缺席。 */
|
|
274
121
|
function readQueueEntry(v) {
|
|
275
122
|
if (typeof v !== "object" || v === null)
|
|
276
123
|
return undefined;
|
|
@@ -295,14 +142,6 @@ function readQueueEntry(v) {
|
|
|
295
142
|
...(priority === "now" || priority === "next" || priority === "later" ? { priority } : {}),
|
|
296
143
|
};
|
|
297
144
|
}
|
|
298
|
-
/**
|
|
299
|
-
* 两个 steering 列 → `{pendingSteer?, pendingSteerQueue?}`(= core `appendPendingSteer`/`readPendingSteerQueue`
|
|
300
|
-
* 的入参形)。**两列各管一形**,不是一列两形:
|
|
301
|
-
* · `pending_steer`(旧列,本服务只读不写)—— 裸单座 `{text, trusted}`。来源有二:队列化之前的存量行,
|
|
302
|
-
* 以及**滚动升级窗里仍在跑的旧副本**写下的座。core 的 `readPendingSteerQueue` 把它折成 member 0。
|
|
303
|
-
* · `pending_steer_queue`(新列)—— `PendingSteerEntry[]`。
|
|
304
|
-
* 任一列有内容却读不出 ⇒ `lossy`(两个调用点对它的处置见 {@link SteerColumnRead})。
|
|
305
|
-
*/
|
|
306
145
|
export function readPendingSteerColumns(seatRaw, queueRaw) {
|
|
307
146
|
let lossy = false;
|
|
308
147
|
const parse = (raw) => {
|
|
@@ -343,97 +182,31 @@ export function readPendingSteerColumns(seatRaw, queueRaw) {
|
|
|
343
182
|
lossy,
|
|
344
183
|
};
|
|
345
184
|
}
|
|
346
|
-
/** 读-改-写的 CAS 重试上限。队列本身只有 `MAX_PENDING_STEER_ENTRIES`(= 3)个位子,再多就 fail-loud,
|
|
347
|
-
* 所以真实争用者数量天然很小;给足冗余后仍然全败 ⇒ 是异常,必须 fail-loud(见 setPendingSteer 尾注)。 */
|
|
348
185
|
const STEER_CAS_ATTEMPTS = 8;
|
|
349
|
-
/** Dual-dialect durable CheckpointStore. See the file header for the dialect-delta ledger. */
|
|
350
186
|
export class SqlCheckpointStore {
|
|
351
187
|
db;
|
|
352
188
|
logger;
|
|
353
|
-
/** #270 车1:托管留存声明。读法与「为什么 SQL 店答 managed 而三方法的实现体在 `retention-store-sql.ts`」
|
|
354
|
-
* 逐字见 {@link MANAGED_RETENTION}(本店的 `checkpoint`/`checkpoint_ctx` 两表由那只聚合店按期清)。 */
|
|
355
189
|
retention = MANAGED_RETENTION;
|
|
356
|
-
/**
|
|
357
|
-
* `CheckpointStore.durability` 声明(#167 欠账,#168 件5)—— 行落在 MySQL-protocol / PostgreSQL 的
|
|
358
|
-
* `checkpoint` 表里,进程重启、副本轮换、整机重建都不丢 ⇒ `"durable"`,如实。
|
|
359
|
-
*
|
|
360
|
-
* 🔴 为什么这一格空着是有代价的:core 的 `resolveDeclaredDurability` 把**缺席**折成 `"process-local"`
|
|
361
|
-
* (它不能替一个没表态的店猜),于是静态装配面对每一个真持久部署都读出 `process_local`,
|
|
362
|
-
* `GET /v1/diagnostics/wiring` 的 park 车道读数与启动自检的那条警告都因此不可信 —— 而 park 正是流内
|
|
363
|
-
* 审批协议的降级目的地,「重启后还赎不赎得回」是运维必须能一眼看见的事。声明是店自己的责任,不是
|
|
364
|
-
* 消费侧靠 backend.kind 猜出来的。
|
|
365
|
-
*/
|
|
366
190
|
durability = "durable";
|
|
367
|
-
/**
|
|
368
|
-
* `CheckpointStore.fidelity` 声明(core 5.17.0 [3052] 提货批 #172)——**如实按介质判**:本店把整个
|
|
369
|
-
* checkpoint 经 {@link SqlCheckpointStore.json} 编码进一个 JSON 列(TiDB 逐字文本 / PG lossless
|
|
370
|
-
* `::jsonb` 协议信封),读侧 `parseJson` 还原 ⇒ 能扛过 round-trip 的只有 JSON 值域,`"json"`。
|
|
371
|
-
*
|
|
372
|
-
* 🔴 为什么必须显式写、哪怕缺席也折向 json:core 的 `resolveDeclaredFidelity` 对缺席是 fail-closed
|
|
373
|
-
* (读 json),所以沉默不会立刻错——但沉默**表达不出**「我核对过我的介质就是这个宽度」。park 铸行
|
|
374
|
-
* 的 args / preview / 风险描述 / `boundInputHash` 全部从这一格算出的投影铸;哪天这四行编码里任何
|
|
375
|
-
* 一处改了介质(换存储格式、换列类型),声明在场才有东西可以红,沉默那格只会安静地按错宽度铸出
|
|
376
|
-
* 一份「审批人看到的 ≠ 盘上躺着的 ≠ resume 执行的」。同 `durability` 的 #168 件5 教训:表态是店
|
|
377
|
-
* 自己的责任,不是消费侧靠 backend.kind 猜。
|
|
378
|
-
*
|
|
379
|
-
* ⚠️ **已知残余(不是遮掩,是这两个词表达不了的那一格)**:PG 臂比 `"json"` 字面**略窄** ——
|
|
380
|
-
* {@link pgProtocolJsonStringify} 对含 NUL / lone surrogate 的串 fail-loud 拒绝(R4-H1 有意裁定:
|
|
381
|
-
* 复核面必须与真执行的 args 在 NUL 那一位上一致,「悄悄清洗再存」是不可接受的那一支),而 core 的
|
|
382
|
-
* json 宽度收下这些码位。`StoreFidelity` 的闭集只有 `"structured-clone" | "json"`,没有第三个词能说
|
|
383
|
-
* 「json 减去本介质存不下的码位」——声明 `"structured-clone"` 是大得多的谎,所以 `"json"` 仍是两者
|
|
384
|
-
* 里唯一诚实的选择。后果有界且 fail-closed:core 5.17.0 起 park 铸行失败会把 cause 带到 gate、追加
|
|
385
|
-
* 到 fallback 的 deny 上,这条 args 退回**同步门**由人判(不静默漏批、不挂死)。边界钉在
|
|
386
|
-
* `wiring-governance-operator.test.ts` 的 #172 组;已上报上游求一个能表达该宽度的词。
|
|
387
|
-
*/
|
|
388
191
|
fidelity = "json";
|
|
389
|
-
/**
|
|
390
|
-
* `CheckpointStore.redecision` 声明(core 5.22.0 F-012 L2/L3;[3342] clay 裁放行)——**如实按能力判**:
|
|
391
|
-
* 本店的 {@link SqlCheckpointStore.reopen} 是真 CAS 实现(`resolved`→`pending` 原子翻回 + `reopen_reason`
|
|
392
|
-
* 权威列,design/80 D-1 的两条重开腿都走它),声明 `reopen: true` 是读数不是抬举。core 的沙箱准入
|
|
393
|
-
* 模式(pre-flight 要求 `redecision.reopen === true`,declaration never duck-typing——方法在场不算数)
|
|
394
|
-
* 由这一格武装:Kata 腿(`capabilities.isolation` 真声明)+ durable park 部署下,`sandbox_local` ask
|
|
395
|
-
* 自动放行并以 `permission.sandbox_admitted` durable 事件披露(缺席披露=core 侧变异恰红)。
|
|
396
|
-
* `validatingLease` **不声明**:本店没有 durable validating-lease 纪律,不承诺没有的东西。
|
|
397
|
-
* 翻向钉:`wiring-governance-operator.test.ts`(修前恰红,与本声明同 commit)。
|
|
398
|
-
*/
|
|
399
192
|
redecision = { reopen: true };
|
|
400
193
|
constructor(db, logger) {
|
|
401
194
|
this.db = db;
|
|
402
195
|
this.logger = logger;
|
|
403
196
|
}
|
|
404
|
-
/** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
|
|
405
197
|
q(tidb, pg) {
|
|
406
198
|
return this.db.dialect === "tidb" ? tidb : pg;
|
|
407
199
|
}
|
|
408
|
-
/** JSON column binding. TiDB: plain `JSON.stringify`, stored verbatim. PG: `pgProtocolJsonStringify` —
|
|
409
|
-
* ordinary `JSON.stringify` that **refuses** (throws `PgUnstorableError`) when the value carries a code
|
|
410
|
-
* point PG cannot hold (NUL / lone surrogate); the `::jsonb` at the call site is just the bind cast, not
|
|
411
|
-
* an escaping layer.
|
|
412
|
-
*
|
|
413
|
-
* "LOSSLESS" in R4-H1 means exactly **"never silently lossy"**, not "encodes everything": the contrast is
|
|
414
|
-
* with `pgSanitizeText`, the lossy U+FFFD-scarring path used for CONTENT faces. An approval row is not a
|
|
415
|
-
* content face — the operator's review surface has to agree with the executed args AT the NUL position, so
|
|
416
|
-
* scrubbing the byte and storing the scrubbed row is the unacceptable arm; refusing loudly is the chosen one.
|
|
417
|
-
* That refusal is why {@link SqlCheckpointStore.fidelity} carries a documented residual (see it). */
|
|
418
200
|
json(value, label) {
|
|
419
201
|
return dialectProtocolJsonEncoder(this.db.dialect)(value, label);
|
|
420
202
|
}
|
|
421
|
-
/** 判据属主 = `sql-errors.ts`(A-032 P1-①;旧形 tidb 臂只认 errno ⇒ 只带 `code` 的 dup 会把
|
|
422
|
-
* `checkpoint.already_exists` 这条 typed 契约错误退化成裸驱动对象上抛)。 */
|
|
423
203
|
isDupKey(e) {
|
|
424
204
|
return isDupKeyError(this.db.dialect, e);
|
|
425
205
|
}
|
|
426
|
-
/** Create-once. core mints the token (`mintCheckpointToken`) and calls this during suspend. */
|
|
427
206
|
async put(token, cp) {
|
|
428
207
|
const pa = cp.pendingAction;
|
|
429
|
-
const version = checkpointVersionOf(cp);
|
|
430
|
-
// Persist the (redacted, bounded) tool args alongside so the operator queue can serve the approval-card
|
|
431
|
-
// payload without an N+1 trace.turns fetch. Stored as a stringified JSON column value.
|
|
208
|
+
const version = checkpointVersionOf(cp);
|
|
432
209
|
const toolInput = boundedToolInput(pa?.args);
|
|
433
|
-
// [3684]②:归因键之一——core 挂在 pendingAction 上的规则候选,**在 mint 这一刻**落成自己的列。
|
|
434
|
-
// 为什么是列而不是 listPending 里现拆 blob:`checkpoint` 列是整只 suspend 快照(会话状态全在里面),
|
|
435
|
-
// 运维队列每行拖一份那个 blob 是数量级错误的读放大;同行的 `tool_input`/`bound_input_hash`/`gate_kind`/
|
|
436
|
-
// `risk_descriptor` 四列都是同一条「铸行时反范式一格,读口零 blob」的先例。
|
|
437
210
|
const ruleSuggestions = boundedRuleSuggestions(pa?.ruleSuggestions);
|
|
438
211
|
try {
|
|
439
212
|
await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor, rule_suggestions) " +
|
|
@@ -449,16 +222,15 @@ export class SqlCheckpointStore {
|
|
|
449
222
|
this.json(cp, "checkpoint"),
|
|
450
223
|
cp.deadline ?? null,
|
|
451
224
|
cp.createdAt,
|
|
452
|
-
Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS),
|
|
453
|
-
cp.gate?.kind ?? null,
|
|
454
|
-
pa?.boundInputHash ?? null,
|
|
455
|
-
((g) => (g?.riskDescriptor ? this.json(g.riskDescriptor, "risk descriptor") : null))(cp.gate),
|
|
456
|
-
ruleSuggestions === undefined ? null : this.json(ruleSuggestions, "rule suggestions"),
|
|
225
|
+
Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS),
|
|
226
|
+
cp.gate?.kind ?? null,
|
|
227
|
+
pa?.boundInputHash ?? null,
|
|
228
|
+
((g) => (g?.riskDescriptor ? this.json(g.riskDescriptor, "risk descriptor") : null))(cp.gate),
|
|
229
|
+
ruleSuggestions === undefined ? null : this.json(ruleSuggestions, "rule suggestions"),
|
|
457
230
|
]);
|
|
458
231
|
}
|
|
459
232
|
catch (e) {
|
|
460
233
|
if (this.isDupKey(e)) {
|
|
461
|
-
// a reused token would clobber a live suspension — never a silent overwrite (checkpoint-store contract)
|
|
462
234
|
throw new CheckpointError("checkpoint.already_exists", `checkpoint token already exists`);
|
|
463
235
|
}
|
|
464
236
|
throw e;
|
|
@@ -469,41 +241,19 @@ export class SqlCheckpointStore {
|
|
|
469
241
|
const r = rows[0];
|
|
470
242
|
if (!r)
|
|
471
243
|
return null;
|
|
472
|
-
// Forward-compat guard: never hand a too-new checkpoint to a resume that can't
|
|
473
|
-
// parse it. Compare with `<= MAX` (never `=== CURRENT`) using core's exported constant so this
|
|
474
|
-
// stays aligned when core bumps the format. This durable-store get-path is the STRONGEST guard (protects a
|
|
475
|
-
// worker that doesn't check version itself); core's resume has a second pre-CAS guard.
|
|
476
244
|
const version = Number(r.version);
|
|
477
245
|
if (version > MAX_SUPPORTED_CHECKPOINT_VERSION) {
|
|
478
246
|
throw new Error(`checkpoint format version ${version} is newer than this build supports (max ${MAX_SUPPORTED_CHECKPOINT_VERSION}) — upgrade the service to resume it`);
|
|
479
247
|
}
|
|
480
248
|
const cp = parseJson(r.checkpoint);
|
|
481
|
-
// the COLUMN status is authoritative (resolve/reap update it, not the blob) — reflect it on the returned cp.
|
|
482
249
|
cp.status = r.status;
|
|
483
|
-
// design/80 D-1: surface the reopen-by-reason + OCC fields off the AUTHORITATIVE columns (the blob is the
|
|
484
|
-
// suspend-time snapshot; resolve/reopen mutate only the columns). Core's resume reads these to validate an
|
|
485
|
-
// env_failed re-resume against the persisted winner, and to require the `rev` it observed is still live (OCC).
|
|
486
|
-
// resolvedOutcome is DERIVED from the stored full outcome by core's `winnerFromOutcome`, so it is preserved
|
|
487
|
-
// across a reopen for free (reopen leaves `outcome` untouched). Do NOT read a key list into this line: the
|
|
488
|
-
// derived shape is core's and it GROWS (`answer` since 5.7.0, `reason` since 5.16.0 — the operator's deny
|
|
489
|
-
// note IS persisted; an older copy of this comment enumerated only {boundCallId,decision,updatedInput?} and
|
|
490
|
-
// read as if it weren't). We store the whole outcome blob and round-trip it, so additive keys ride for free
|
|
491
|
-
// — `ResolvedOutcome` in core's `checkpoint-store.d.ts` is the single owner of that list.
|
|
492
250
|
const outcomeRaw = parseJson(r.outcome);
|
|
493
251
|
if (outcomeRaw)
|
|
494
252
|
cp.resolvedOutcome = winnerFromOutcome(outcomeRaw);
|
|
495
253
|
cp.reopenReason = r.reopen_reason ?? undefined;
|
|
496
|
-
cp.rev = r.rev == null ? 0 : Number(r.rev);
|
|
497
|
-
// design/80 D-A: overlay the parked steer off the AUTHORITATIVE column (set by setPendingSteer AFTER the
|
|
498
|
-
// suspend-time blob was written) so core injects it on resume. The blob never carries it.
|
|
499
|
-
// core 5.14.0(#147):列现在承载**两个**字段 —— 队列本体 + 旧行折下来的裸单座。两者都要盖上去,
|
|
500
|
-
// core 的 `readPendingSteerQueue` 才能把「legacy 座 = member 0,其余按 seq」拼成完整投递序。
|
|
501
|
-
// 🔴 坏列在这里**只丢 steer、不抛**:resume 是这条挂起的 run 唯一的出路,为一条读不出的转向把整条
|
|
502
|
-
// run 变成不可 resume 是错误的失败方向(写侧相反,见 setPendingSteer)。
|
|
254
|
+
cp.rev = r.rev == null ? 0 : Number(r.rev);
|
|
503
255
|
const steer = readPendingSteerColumns(r.pending_steer, r.pending_steer_queue);
|
|
504
256
|
if (steer.lossy) {
|
|
505
|
-
// 「静默」到此为止:读不出的部分确实丢了(投递腿不能为一条坏列把整条 run 变成不可 resume),
|
|
506
|
-
// 但它必须**留痕** —— 丢的是操作员指令,而 #147 的全部意义就是这类丢失不许无声。
|
|
507
257
|
this.logger?.info?.("checkpoint_pending_steer_unreadable", { tokenFp: tokenFingerprint(token) });
|
|
508
258
|
}
|
|
509
259
|
if (cp.state) {
|
|
@@ -512,11 +262,8 @@ export class SqlCheckpointStore {
|
|
|
512
262
|
if (steer.state.pendingSteerQueue !== undefined)
|
|
513
263
|
cp.state.pendingSteerQueue = steer.state.pendingSteerQueue;
|
|
514
264
|
}
|
|
515
|
-
// Diagnostic: log what the REAL runner.resume store.get returns for the remote
|
|
516
|
-
// workspaceHandle (the isolated round-trip test can't prove the real-path get). undefined here ⇒ store/get
|
|
517
|
-
// drops it; present ⇒ core's 910→614 threading drops it. Cheap, on the resume read-path only.
|
|
518
265
|
this.logger?.info?.("checkpoint_get", {
|
|
519
|
-
tokenFp: tokenFingerprint(token),
|
|
266
|
+
tokenFp: tokenFingerprint(token),
|
|
520
267
|
status: cp.status,
|
|
521
268
|
version,
|
|
522
269
|
hasWorkspaceHandle: !!cp.state?.workspaceHandle,
|
|
@@ -524,25 +271,6 @@ export class SqlCheckpointStore {
|
|
|
524
271
|
});
|
|
525
272
|
return cp;
|
|
526
273
|
}
|
|
527
|
-
/**
|
|
528
|
-
* Atomic CAS: pending → resolved, scoped. Returns true for the single winner, false if already
|
|
529
|
-
* resolved/expired (AlreadyResolved → caller no-ops, never re-executes). `outcome` is persisted atomically
|
|
530
|
-
* with the flip (R20 #1 — a future v2 resumable-resume can recover a crash-after-CAS; v1 runner write-only).
|
|
531
|
-
*
|
|
532
|
-
* design/80 D-1 (atomicity): the winner-persist (the full `outcome` is stored; the winner
|
|
533
|
-
* {boundCallId,decision,updatedInput?} is derived from it on read) + the `rev` bump happen ATOMICALLY in the
|
|
534
|
-
* SAME UPDATE as the pending→resolved CAS — one statement, no get→write window. When the caller passes the
|
|
535
|
-
* `rev` it observed at get() (ResolveExpectation), the CAS additionally requires the LIVE rev to still equal
|
|
536
|
-
* it: ANY intervening resolve/reopen cycle bumps rev, so a cycle in the get→resolve window loses the CAS
|
|
537
|
-
* (returns false → core re-inspects → checkpoint.reopened_concurrently). Omitting `expect` keeps the legacy
|
|
538
|
-
* unconditional CAS (a first resume of a never-reopened checkpoint is unconstrained by rev).
|
|
539
|
-
* 🔴 reopen_reason = NULL on a winning resolve — parity with the core reference InMemoryCheckpointStore
|
|
540
|
-
* (MINOR-12: "a freshly-resolved row carries no stale reopen reason"). Without this, an env_failed reopen
|
|
541
|
-
* followed by a correct winner-replay resolve leaves reopen_reason='env_failed' stale on the now-resolved
|
|
542
|
-
* row; a later DUPLICATE resume of that token would hit core's pre-CAS env_failed winner-replay guard
|
|
543
|
-
* against the stale reason and throw checkpoint.reopen_revote (which the service classifies RETRIABLE →
|
|
544
|
-
* re-parks the run suspended) instead of the terminal checkpoint.already_resolved — stranding the run.
|
|
545
|
-
*/
|
|
546
274
|
async resolve(token, scope, outcome, expect) {
|
|
547
275
|
const params = [this.json(outcome, "checkpoint outcome"), Date.now(), token, scope];
|
|
548
276
|
if (expect)
|
|
@@ -552,147 +280,56 @@ export class SqlCheckpointStore {
|
|
|
552
280
|
(expect ? " AND rev = $5" : "")), params);
|
|
553
281
|
return res.affected === 1;
|
|
554
282
|
}
|
|
555
|
-
/**
|
|
556
|
-
* Inverse of `resolve` (core `CheckpointStore.reopen`): CAS `resolved → pending`. Returns the CAS winner
|
|
557
|
-
* (true = was resolved → now pending again). Compensates a post-CAS env-restore failure
|
|
558
|
-
* (`resume.env_failed`): `resumeStream` consumes the checkpoint BEFORE `resumeVM`, so a restore failure
|
|
559
|
-
* would otherwise strand the suspended work as `resolved` + unfindable (`findPendingTokenBySession` only
|
|
560
|
-
* matches `pending`). Reopening lets a retry re-resume the SAME work.
|
|
561
|
-
*
|
|
562
|
-
* design/80 D-1 (reopen-by-reason): the `reason` distinguishes the two triggers (both fire at core's
|
|
563
|
-
* runtask.ts) so the re-resume can validate per reason (§3 inv #1) — `env_failed` = the action is still
|
|
564
|
-
* valid, the re-resume MUST replay the persisted winner (core rejects a different decision with
|
|
565
|
-
* `checkpoint.reopen_revote`); `tool_unavailable` = the action could not run and may now be stale, so a
|
|
566
|
-
* FRESH decision is allowed (P-7). We RECORD the reason on the row and bump `rev` (so a concurrent resume's
|
|
567
|
-
* resolve CAS loses — atomicity). 🔴 We DELIBERATELY leave the persisted `outcome` UNTOUCHED (NOT stale junk
|
|
568
|
-
* as a pre-D-1 reopen treated it): `resolvedOutcome` derives from it on get(), and an `env_failed` re-resume
|
|
569
|
-
* is validated against it — clearing it would defeat the winner-replay invariant.
|
|
570
|
-
*/
|
|
571
283
|
async reopen(token, scope, reason) {
|
|
572
284
|
const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'pending', reopen_reason = ?, rev = rev + 1 WHERE token = ? AND scope = ? AND status = 'resolved'", "UPDATE checkpoint SET status = 'pending', reopen_reason = $1, rev = rev + 1 WHERE token = $2 AND scope = $3 AND status = 'resolved'"), [reason, token, scope]);
|
|
573
285
|
return res.affected === 1;
|
|
574
286
|
}
|
|
575
|
-
/**
|
|
576
|
-
* design/80 D-A (durable steering) —— **core 5.14.0(#147 BREAKING)起是「追加进一条有界有序队列」**,
|
|
577
|
-
* 不再是「盖掉单座」。两个人同时给一条挂起的 run 转向,旧语义会让第二条**静默销毁**第一条(一条没人
|
|
578
|
-
* 知道丢了的操作员指令);新语义是两条都在,resume 按 `seq` 全数投递。
|
|
579
|
-
*
|
|
580
|
-
* 语义与边界**全部铸在 core**,本 store 一个都不自铸(契约原文:`appendPendingSteer` is "the shared
|
|
581
|
-
* append every backend must build its next queue with: bounds + `seq` mint live there, not once per store"):
|
|
582
|
-
* · `validatePendingSteer` —— 每个 impl 内部必跑的 fail-closed 不变式(M2,接入审计 2026-07-08):内容
|
|
583
|
-
* 越狱/控制字符 `steering.invalid_content`;**未知字段现在是 REFUSE 而不是丢弃**(领先一个版本的
|
|
584
|
-
* producer 必须知道自己的新字段活不过这条挂起的腿);缺席的 `inputId` 在这里铸(uuidv7)。
|
|
585
|
-
* · `appendPendingSteer` —— 铸 `seq`、判 `inputId` 幂等(同 id 同载荷 = no-op 返回 true;同 id **不同**
|
|
586
|
-
* 载荷 = `steering.duplicate_input_id`,吞掉它就等于把队列要消灭的那种静默丢失原样请回来)、
|
|
587
|
-
* 执行两条上界(条数 + 48000 字节整信封),越界一律 `steering.queue_full` fail-loud,**绝不驱逐**
|
|
588
|
-
* 一条已被接受的指令。
|
|
589
|
-
*
|
|
590
|
-
* 🔴 从「一条无条件 UPDATE」变成「读-改-写」,中间那道窗必须自己有 CAS:两个并发 steer 读到同一份
|
|
591
|
-
* 队列、后写者整个盖掉前者 —— 就是 #147 要消灭的静默丢失换了个位置复发。守卫是 `pending_steer_rev`
|
|
592
|
-
* **专用整数列**(不是拿旧列值比:整数按构造唯一,而长 JSON 文本的相等性取决于排序规则/字段序/空白与
|
|
593
|
-
* 转义形,任一处判等松一点就是一次 CAS 假命中 = 悄悄丢一条 steer。#192 A11 更正:本注原先的理由写的是
|
|
594
|
-
* "本表无显式 COLLATE,*_ci 会把只差大小写的两份信封判等",那半已不成立——`checkpoint` 现在钉了
|
|
595
|
-
* `COLLATE utf8mb4_bin`/PG 侧 `COLLATE "C"`;结论不变,理由回到上面那条更根本的;
|
|
596
|
-
* 也不是复用 resolve 的 `rev`:bump 它会让并发 resume 的 `resolve(expect)` 白白落败)。
|
|
597
|
-
* CAS 输了就重读重算(队列只有 3 个位子,争用者天然极少);给足冗余仍全败 ⇒ **抛**,不返回 false ——
|
|
598
|
-
* `false` 在调用方的语义是「没有 pending 行可转向」(HTTP 409「已不再挂起」),把争用报成那个是撒谎。
|
|
599
|
-
*
|
|
600
|
-
* 返回:`true` = 落在一条活的 pending 行上(含幂等重放);`false` = 没有该 (token,scope) 的 pending 行。
|
|
601
|
-
*/
|
|
602
287
|
async setPendingSteer(token, scope, steer) {
|
|
603
|
-
const clean = validatePendingSteer(steer);
|
|
288
|
+
const clean = validatePendingSteer(steer);
|
|
604
289
|
for (let attempt = 0; attempt < STEER_CAS_ATTEMPTS; attempt++) {
|
|
605
290
|
const { rows } = await this.db.query(this.q("SELECT status, pending_steer, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = ? AND scope = ?", "SELECT status, pending_steer, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = $1 AND scope = $2"), [token, scope]);
|
|
606
291
|
const row = rows[0];
|
|
607
292
|
if (!row || row.status !== "pending")
|
|
608
|
-
return false;
|
|
293
|
+
return false;
|
|
609
294
|
const read = readPendingSteerColumns(row.pending_steer, row.pending_steer_queue);
|
|
610
295
|
if (read.lossy) {
|
|
611
|
-
// 🔴 写侧与 get() 的失败方向刻意相反:往一份读不全的队列上追加,写回时就会把读不出的那部分
|
|
612
|
-
// **永久抹掉** —— 那正是 #147 存在的理由(静默丢一条已 park 的操作员指令)。宁可这一次转向失败。
|
|
613
296
|
throw new Error(`the parked steering queue on checkpoint ${tokenFingerprint(token)} is unreadable — refusing to append onto it ` +
|
|
614
297
|
`(appending would silently drop the entries this build cannot parse)`);
|
|
615
298
|
}
|
|
616
299
|
const before = read.state.pendingSteerQueue?.length ?? 0;
|
|
617
|
-
const next = appendPendingSteer(read.state, clean);
|
|
618
|
-
// 幂等重放(同 inputId 同载荷):core 原样奉还既有队列 ⇒ 一个字都不用写,也不必烧一个 rev。
|
|
300
|
+
const next = appendPendingSteer(read.state, clean);
|
|
619
301
|
if (next.length === before)
|
|
620
302
|
return true;
|
|
621
303
|
const rev = Number(row.pending_steer_rev ?? 0);
|
|
622
|
-
// 🔴 只写队列列 —— `pending_steer`(旧单座列)本服务**从不写**:滚动升级窗里旧副本对那一列是无条件
|
|
623
|
-
// 覆盖写,两边写同一列 = 互相清零(codex 复审 F3)。分列之后旧副本的座与新队列共存,读侧合并。
|
|
624
304
|
const res = await this.db.query(this.q("UPDATE checkpoint SET pending_steer_queue = ?, pending_steer_rev = pending_steer_rev + 1 " +
|
|
625
305
|
"WHERE token = ? AND scope = ? AND status = 'pending' AND pending_steer_rev = ?", "UPDATE checkpoint SET pending_steer_queue = $1, pending_steer_rev = pending_steer_rev + 1 " +
|
|
626
306
|
"WHERE token = $2 AND scope = $3 AND status = 'pending' AND pending_steer_rev = $4"), [this.json(next, "pending steer queue"), token, scope, rev]);
|
|
627
307
|
if (res.affected > 0)
|
|
628
308
|
return true;
|
|
629
|
-
// CAS 输了:另一个 steer 抢先追加(或这行刚被 resolve/expire)⇒ 重读重算。下一轮的 SELECT 会
|
|
630
|
-
// 分辨这两种情形:行还 pending 就再追加一次,不 pending 就返回 false。
|
|
631
309
|
}
|
|
632
310
|
throw new Error(`setPendingSteer lost the pending_steer_rev CAS ${STEER_CAS_ATTEMPTS} times on checkpoint ${tokenFingerprint(token)} — ` +
|
|
633
311
|
`refusing to report it as "no pending checkpoint" (that would silently discard an operator instruction)`);
|
|
634
312
|
}
|
|
635
|
-
/**
|
|
636
|
-
* CAS-expire a SINGLE checkpoint by token (core 1.71 `CheckpointStore.expire`, for `TaskStream.destroy`'s
|
|
637
|
-
* suspended-worker reap — core [R33] {delta}). `pending → expired`, returns the CAS winner (true = we
|
|
638
|
-
* expired it; false = already resolved/expired). Same UPDATE as `reap`, keyed by **token** not deadline.
|
|
639
|
-
* It races the SAME `pending` row as `resolve`, so the two are mutually exclusive: destroy() must `expire`
|
|
640
|
-
* (fence) BEFORE destroying the env — if a concurrent resume already won `resolve`, expire returns false and
|
|
641
|
-
* destroy leaves the env for that resume; if expire wins, the resume's resolve fails → never resume-to-dead-env.
|
|
642
|
-
* Uses `expired` (not a `resolve`-deny) so a CANCELLED checkpoint never pollutes resolved-count / outcome.
|
|
643
|
-
*/
|
|
644
313
|
async expire(token, scope) {
|
|
645
314
|
const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? WHERE token = ? AND scope = ? AND status = 'pending'", "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 WHERE token = $2 AND scope = $3 AND status = 'pending'"), [Date.now(), token, scope]);
|
|
646
315
|
return res.affected === 1;
|
|
647
316
|
}
|
|
648
|
-
/** Interface reap: CAS-expire pending checkpoints in `scope` past `cutoff`. Returns count. */
|
|
649
317
|
async reap(scope, cutoff) {
|
|
650
318
|
const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? WHERE scope = ? AND status = 'pending' AND deadline IS NOT NULL AND deadline <= ?", "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 WHERE scope = $2 AND status = 'pending' AND deadline IS NOT NULL AND deadline <= $3"), [Date.now(), scope, cutoff]);
|
|
651
319
|
return res.affected;
|
|
652
320
|
}
|
|
653
|
-
/**
|
|
654
|
-
* GLOBAL sweep for the service's per-replica TTL reaper (expiry isn't tenant-
|
|
655
|
-
* sensitive — only `resolve` is scoped). Idempotent across replicas (DB serializes; no election). Returns count.
|
|
656
|
-
* Called with `cutoff = Date.now()` (deadline/terminal_at_ms are ABSOLUTE epoch-ms), so it expires any pending row
|
|
657
|
-
* whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `terminal_at_ms` crash-safe backstop has passed —
|
|
658
|
-
* the latter closes the forever-leak of a pending row with a NULL `deadline` (no approval TTL was configured).
|
|
659
|
-
*
|
|
660
|
-
* design/80 D-D: the deadline-branch EXPIRES (≈ abort) every kind EXCEPT a tool-approval human/irreversible_ask
|
|
661
|
-
* gate — those are resolve-DENIED gracefully by the SLA deny-sweep (listExpiredApprovalGates →
|
|
662
|
-
* resumeCheckpoint(deny)), so expiring them here too would race + lose the graceful denial. 🔴 A durable
|
|
663
|
-
* AskUserQuestion ALSO mints gate.kind='human' (no question-specific kind in core) — but DENYING a question is
|
|
664
|
-
* incoherent (the model gets a "denied" tool-result, not an answer), so it is carved BACK INTO the expire path
|
|
665
|
-
* (COALESCE(tool_name,'')='AskUserQuestion') to abort-expire on timeout instead. Legacy rows (gate_kind NULL)
|
|
666
|
-
* stay on the expire path. The terminal_at_ms-branch is the crash-safe backstop for ANY kind (incl. a human gate
|
|
667
|
-
* whose deny-resume keeps failing) — it always abort-expires past the absolute cap (which is now STRICTLY after
|
|
668
|
-
* the deadline, so it never races the deny-sweep at the deadline instant).
|
|
669
|
-
*/
|
|
670
321
|
async reapExpired(cutoff) {
|
|
671
322
|
const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? " +
|
|
672
323
|
`WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= ? AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at_ms IS NOT NULL AND terminal_at_ms <= ?))`, "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 " +
|
|
673
324
|
`WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= $2 AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at_ms IS NOT NULL AND terminal_at_ms <= $3))`), [Date.now(), cutoff, cutoff]);
|
|
674
325
|
return res.affected;
|
|
675
326
|
}
|
|
676
|
-
/**
|
|
677
|
-
* design/80 D-D (SLA-timer): the sessions of pending human/irreversible_ask checkpoints past their `deadline`
|
|
678
|
-
* (the per-approval SLA). The reaper resolve-DENIES each (re-resume with decision=deny → the model continues
|
|
679
|
-
* with the denial) — graceful, vs the abort that expire() gives resource_limit/needs_review. Excludes legacy
|
|
680
|
-
* rows (gate_kind NULL — they predate the split and fall through to reapExpired's expire path). LIMIT bounds
|
|
681
|
-
* the per-tick deny-resume work (each is a model run). Scope is read off the COMMITTED row (invariant #4).
|
|
682
|
-
* 🔴 EXCLUDES AskUserQuestion gates (which also mint gate.kind='human'): denying a question is incoherent —
|
|
683
|
-
* the model would get a "denied" tool-result instead of an answer — so a timed-out durable question falls to
|
|
684
|
-
* reapExpired's abort-expire path instead of this graceful-deny path.
|
|
685
|
-
*/
|
|
686
327
|
async listExpiredApprovalGates(cutoff, limit = 100) {
|
|
687
328
|
const { rows } = await this.db.query(this.q(`SELECT session_id, scope FROM checkpoint WHERE status = 'pending' AND gate_kind IN ${APPROVAL_GATE_KINDS_SQL_IN} ` +
|
|
688
329
|
"AND COALESCE(tool_name,'') <> 'AskUserQuestion' AND deadline IS NOT NULL AND deadline <= ? ORDER BY deadline ASC LIMIT ?", `SELECT session_id, scope FROM checkpoint WHERE status = 'pending' AND gate_kind IN ${APPROVAL_GATE_KINDS_SQL_IN} ` +
|
|
689
330
|
"AND COALESCE(tool_name,'') <> 'AskUserQuestion' AND deadline IS NOT NULL AND deadline <= $1 ORDER BY deadline ASC LIMIT $2"), [cutoff, limit]);
|
|
690
331
|
return rows.map((r) => ({ sessionId: String(r.session_id), scope: String(r.scope) }));
|
|
691
332
|
}
|
|
692
|
-
// ─────────────────────── operator approval queue (non-secret; token stays internal) ───────────────────────
|
|
693
|
-
/** Pending checkpoints for the operator queue — NON-secret fields only (never the token). `taskId` is
|
|
694
|
-
* joined from task_active (the JOIN KEY to the run/trace — a suspended run KEEPS its
|
|
695
|
-
* session claim, so the join is live for every pending row; null only in pathological windows). */
|
|
696
333
|
async listPending(scope) {
|
|
697
334
|
const base = "SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, c.tool_input, c.bound_input_hash, c.risk_descriptor, c.rule_suggestions, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id " +
|
|
698
335
|
"FROM checkpoint c LEFT JOIN task_active ta ON ta.session_id = c.session_id WHERE c.status='pending'";
|
|
@@ -703,60 +340,29 @@ export class SqlCheckpointStore {
|
|
|
703
340
|
const toolCallId = r.tool_call_id ?? null;
|
|
704
341
|
const boundInputHash = r.bound_input_hash ?? null;
|
|
705
342
|
const gateKind = r.gate_kind ?? null;
|
|
706
|
-
// [3684]②:回读也走**同一个** `boundedRuleSuggestions`(不是裸 parse 直投)—— 形的属主只有一个,
|
|
707
|
-
// 手改过的行/未来列语义漂了都在这里当场落成「无供给」而不是把垃圾投上运维队列。列已在 mint 面脱敏,
|
|
708
|
-
// 这一遍照常脱敏(纵深防御:手改过的行也不许把凭证投上跨租户队列),只是**不计数**
|
|
709
|
-
// (值幂等但计数不幂等,见该函数 `opts.countRedactions` 顶注)。
|
|
710
343
|
const ruleSuggestions = readRuleSuggestionsCell(r.rule_suggestions);
|
|
711
344
|
return {
|
|
712
345
|
sessionId: String(r.session_id),
|
|
713
346
|
scope: String(r.scope),
|
|
714
347
|
toolName: r.tool_name ?? null,
|
|
715
348
|
toolCallId,
|
|
716
|
-
// D-1: boundCallId === the pending tool call id; boundInputHash is the server-minted opaque hash. The
|
|
717
|
-
// portal echoes BOTH on /decide so the resume binds to the exact action shown (TOCTOU guard reachable).
|
|
718
|
-
// [1995]②: a park with no tool action (plan_review / resource_limit / task_done — both columns NULL)
|
|
719
|
-
// OMITS the keys entirely; the wire contract says absent, never `null` (LOCAL twin does the same).
|
|
720
349
|
...(toolCallId !== null ? { boundCallId: toolCallId } : {}),
|
|
721
350
|
...(boundInputHash !== null ? { boundInputHash } : {}),
|
|
722
|
-
// [1995]③ additive: the gate kind the row is parked on (NULL only on a pre-`gate_kind` legacy row) —
|
|
723
|
-
// lets a durable-recovery consumer route plan / tool / resource off this ONE queue.
|
|
724
351
|
...(gateKind !== null ? { gateKind } : {}),
|
|
725
352
|
taskId: r.task_id ?? null,
|
|
726
|
-
// Both drivers return the JSON column already parsed; null for pre-migration rows. PG 库内恒干净
|
|
727
|
-
// (拒绝式)——直读即审阅面=执行面。
|
|
728
353
|
input: r.tool_input ?? null,
|
|
729
354
|
createdAt: Number(r.created_at_ms),
|
|
730
355
|
deadline: r.deadline == null ? null : Number(r.deadline),
|
|
731
|
-
// 重扫二轮:与下一格同族的**容错回读**(strict 形会把一行手改坏的 cell 变成整只队列 500,
|
|
732
|
-
// 判据与失败方向逐字见 {@link readRiskDescriptorCell} 顶注)。
|
|
733
356
|
riskDescriptor: readRiskDescriptorCell(r.risk_descriptor),
|
|
734
|
-
// [3684]②:无供给 ⇒ **省键**(同 boundCallId/gateKind 的 [1995]② 纪律,绝不 null/[])。
|
|
735
357
|
...(ruleSuggestions !== undefined ? { ruleSuggestions } : {}),
|
|
736
358
|
};
|
|
737
359
|
});
|
|
738
|
-
// riskDescriptor inbox: triage-sort by severity DESC (highest-risk asks first), then oldest-first within a
|
|
739
|
-
// tier. A row with no descriptor (pre-migration / non-gate) sorts to severity 0 (after the descriptored ones).
|
|
740
360
|
return out.sort((a, b) => (b.riskDescriptor?.severity ?? 0) - (a.riskDescriptor?.severity ?? 0) || a.createdAt - b.createdAt);
|
|
741
361
|
}
|
|
742
|
-
/**
|
|
743
|
-
* design/80 #2 (scheduler seam, core 1.105): a scope's PENDING checkpoints as lightweight
|
|
744
|
-
* {@link CheckpointSummary} rows in ONE query — the cross-task triage view a supervisor scheduler needs
|
|
745
|
-
* (gate kind + risk severity + budget spent + deadline per suspended task), no N+1 `get`s. The projection is
|
|
746
|
-
* core's shared {@link summarizeCheckpoint} run over the persisted blob (the `checkpoint` column = the same full
|
|
747
|
-
* {@link Checkpoint} `get()` parses), so this stays byte-identical to core's InMemory/Pg/File impls. Order =
|
|
748
|
-
* created_at_ms ASC; callers (the inbox/scheduler) sort by severity. The COLUMN `status` is authoritative (the blob
|
|
749
|
-
* is the suspend-time snapshot), so it overrides the blob's status before the summary is derived.
|
|
750
|
-
*
|
|
751
|
-
* LIMIT bounds the fan-out (review w16yqkkxv): a triage view never needs more than a few — 500 is a generous
|
|
752
|
-
* ceiling that still protects memory/latency if a scope ever accumulates pathologically many pending gates.
|
|
753
|
-
*/
|
|
754
362
|
async listByScope(scope) {
|
|
755
363
|
const { rows } = await this.db.query(this.q("SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at_ms ASC LIMIT 500", "SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at_ms ASC LIMIT 500"), [scope]);
|
|
756
364
|
const out = [];
|
|
757
365
|
for (const r of rows) {
|
|
758
|
-
// A corrupt/missing blob skips THAT row rather than crashing the whole scheduler view (review w16yqkkxv):
|
|
759
|
-
// unlike get() (a single-token read where a null blob is a hard error), a bulk triage list must degrade.
|
|
760
366
|
const cp = parseJson(r.checkpoint);
|
|
761
367
|
if (!cp)
|
|
762
368
|
continue;
|
|
@@ -765,52 +371,18 @@ export class SqlCheckpointStore {
|
|
|
765
371
|
}
|
|
766
372
|
return out;
|
|
767
373
|
}
|
|
768
|
-
/**
|
|
769
|
-
* Map a session (the non-secret handle the operator decides by) → its single pending checkpoint token,
|
|
770
|
-
* INTERNALLY. A suspended session has exactly one pending checkpoint. `scope` is enforced so an operator
|
|
771
|
-
* can only resolve within its own tenant. Returns null if there is no pending checkpoint for that session/scope.
|
|
772
|
-
*/
|
|
773
374
|
async findPendingTokenBySession(sessionId, scope) {
|
|
774
375
|
const { rows } = scope
|
|
775
376
|
? await this.db.query(this.q("SELECT token FROM checkpoint WHERE session_id=? AND scope=? AND status='pending' LIMIT 1", "SELECT token FROM checkpoint WHERE session_id=$1 AND scope=$2 AND status='pending' LIMIT 1"), [sessionId, scope])
|
|
776
377
|
: await this.db.query(this.q("SELECT token FROM checkpoint WHERE session_id=? AND status='pending' LIMIT 1", "SELECT token FROM checkpoint WHERE session_id=$1 AND status='pending' LIMIT 1"), [sessionId]);
|
|
777
378
|
return rows[0] ? String(rows[0].token) : null;
|
|
778
379
|
}
|
|
779
|
-
/**
|
|
780
|
-
* 🔴 #151 车5 §9 C4 —— 流内审批对账收敛器判据 1 的**窄读口**(店面承载;收敛器本体不在本车)。
|
|
781
|
-
*
|
|
782
|
-
* 「这条 PARKING 的 ask 究竟 park 成了哪张 checkpoint?」的唯一读法。为什么不是「按 session 翻历史页」:
|
|
783
|
-
* 分页宽读会漏匹配,而漏匹配在收敛器那侧的后果是**假阴性 ⇒ 落一条不可逆的 DENIED**。所以这里改成
|
|
784
|
-
* 谓词精确查——`(scope, session_id, tool_call_id, created_at_ms ≥ sinceMs)` 这组条件下的行数天然极小,
|
|
785
|
-
* 一次全量返回,结构上没有分页假阴性。
|
|
786
|
-
*
|
|
787
|
-
* 三条口径,逐条都是判据:
|
|
788
|
-
* - **`scope` 必填**(不是 optional):对账是跨腿读,租户门是读口自己的责任,不靠调用方记得过滤
|
|
789
|
-
* (同 `listPendingBySession` 姿势;`resolve`/`reap` 的 scope enforced 同族)。
|
|
790
|
-
* - **不按 status 过滤**:已 `resolved`/`expired` 的行照样返回。后到的 resume 会把行翻成 resolved,
|
|
791
|
-
* 若只看 pending 就会把「park 成功过」这个既成事实读成「没 park 过」⇒ 又是一条假阴性 DENIED
|
|
792
|
-
* (对账三约束③)。park 成功是事实,gate 的活性由 `status` 表达,交给收敛器自己判。
|
|
793
|
-
* - **单行读不出 ⇒ 标记返回,不抛**:`unparseable` 的行由收敛器视同「不匹配」,一条坏行不许打断整段
|
|
794
|
-
* 扫描(§8 C-6)。什么算读不出:blob 的 `version` 超出本 build 支持(`get()` 那条前向兼容门在这里
|
|
795
|
-
* 不能 throw,否则一条超前行会让整个 session 的对账永久卡死)、或 blob JSON 坏。
|
|
796
|
-
*
|
|
797
|
-
* 匹配是两段的:`tool_call_id` **列**是 `put()` 从 `pendingAction.toolCallId` 盖下来的权威投影,SQL 谓词
|
|
798
|
-
* 先按它(或 NULL)收窄;列为 NULL 的行(无工具动作的 park,或列存在之前的旧行)靠解 blob 补判——解得出
|
|
799
|
-
* 且相等才算候选,解不出就标 `unparseable`。
|
|
800
|
-
* 🔴 **没有「列命中即零解析」的快路径**(原注写过,已作废,别照它优化):函数体对**每一行**无条件解
|
|
801
|
-
* blob,原因有二 ——(a) 前向兼容门与 blob 可读性门必须门在**所有**命中路径之前(codex F3 + 确认轮:
|
|
802
|
-
* 列长得对不代表 blob 读得出,放行一条读不出的行去 `bindBatch` 会把 ask 钉成 PARKED + 一张本进程读不出
|
|
803
|
-
* 的 resume 坐标,而 PARKED 不可回滚);(b) `#168` 之后 blob 顶层的 `sourceTaskId` 是和解三元组的第一
|
|
804
|
-
* 维,列命中行结构上也必须解 blob 才拿得到它。
|
|
805
|
-
*/
|
|
806
380
|
async findCheckpointCandidatesForAsk(scope, sessionId, toolCallId, sinceMs) {
|
|
807
381
|
const { rows } = await this.db.query(this.q("SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
|
|
808
382
|
"WHERE scope=? AND session_id=? AND created_at_ms>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at_ms ASC", "SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
|
|
809
383
|
"WHERE scope=$1 AND session_id=$2 AND created_at_ms>=$3 AND (tool_call_id=$4 OR tool_call_id IS NULL) ORDER BY created_at_ms ASC"), [scope, sessionId, sinceMs, toolCallId]);
|
|
810
384
|
const out = [];
|
|
811
385
|
for (const r of rows) {
|
|
812
|
-
// `sourceTaskId: null` 是**所有 unparseable 臂的共同底**(读不出的行不许带出一个可用于身份比对的
|
|
813
|
-
// 值);健康臂在下面用 blob 解出来的值覆盖它。
|
|
814
386
|
const base = {
|
|
815
387
|
token: String(r.token),
|
|
816
388
|
status: String(r.status),
|
|
@@ -818,67 +390,37 @@ export class SqlCheckpointStore {
|
|
|
818
390
|
boundInputHash: r.bound_input_hash == null ? null : String(r.bound_input_hash),
|
|
819
391
|
sourceTaskId: null,
|
|
820
392
|
};
|
|
821
|
-
// 🔴 前向兼容门必须在**任何**命中路径之前(codex 复审 F3,2026-08-06 真缺陷):版本超前的行是
|
|
822
|
-
// `get()` 明确拒读的行,而 `tool_call_id` 列命中与否跟能不能读懂 blob 毫无关系。若让列的快路径
|
|
823
|
-
// 绕过这道门,收敛器就会把它当健康候选去 `bindBatch` ⇒ ask 被钉成 **PARKED + 一张本进程读不出的
|
|
824
|
-
// checkpoint 的 resume 坐标**,而 PARKED 不可回滚。判据只能是「宁可不命中,绝不错配」(§8 D-2)。
|
|
825
|
-
// 门在这里**不抛**(见头注):标 unparseable 交给收敛器当「不确定」,单行不打断整段扫描。
|
|
826
393
|
if (Number(r.version) > MAX_SUPPORTED_CHECKPOINT_VERSION) {
|
|
827
394
|
out.push({ ...base, boundCallId: null, unparseable: true });
|
|
828
395
|
continue;
|
|
829
396
|
}
|
|
830
|
-
// 🔴 blob 可读性同样门在**所有**命中路径之前(codex 确认轮补抓):`tool_call_id` 列长得对,不代表
|
|
831
|
-
// 这张 checkpoint 读得出来——列是 put() 时的投影,blob 才是 resume 真正要吃的东西。让「列命中」
|
|
832
|
-
// 跳过可读性检查,等于给「blob 损坏 / schema 偏斜」留了一条与 F3 完全相同的不可逆后果:
|
|
833
|
-
// 收敛器把 ask 钉成 PARKED + 这张 checkpoint 的坐标,而后续 `get()` 必然失败。
|
|
834
|
-
// 读口的契约只有一条——**读不出就标记**,没有「有一列长得对就豁免」的例外。
|
|
835
397
|
const derived = pendingActionToolCallId(r.checkpoint);
|
|
836
398
|
if (!derived.readable) {
|
|
837
399
|
out.push({ ...base, boundCallId: null, unparseable: true });
|
|
838
400
|
continue;
|
|
839
401
|
}
|
|
840
402
|
const col = r.tool_call_id == null ? null : String(r.tool_call_id);
|
|
841
|
-
// 列与 blob **互相矛盾** ⇒ 判别不出谁对,标 unparseable(「宁可不命中,绝不错配」,§8 D-2)。
|
|
842
|
-
// 🔴 矛盾的定义是「列说得出、而 blob 不同意」——同时覆盖两形:blob 给了**别的** callId,以及
|
|
843
|
-
// blob 说这条 park **根本没有工具动作**(`toolCallId === null`)。后者一样是漂移(列声称有一次
|
|
844
|
-
// 工具调用、blob 否认),早先只判「两边都非空且不等」会把它漏成健康候选。
|
|
845
|
-
// 现网 put() 把列与 blob 同源同刻写下,矛盾只可能来自外部改库或 schema 偏斜——正是该保守的场合。
|
|
846
403
|
if (col !== null && col !== derived.toolCallId) {
|
|
847
404
|
out.push({ ...base, boundCallId: null, unparseable: true });
|
|
848
405
|
continue;
|
|
849
406
|
}
|
|
850
|
-
// 🔴 bound_input_hash 的列/blob 一致性门(属主兜底轮 F1)——与上面 toolCallId 的矛盾门同款:
|
|
851
|
-
// 返回给收敛器的 hash 是「identity ∧ hash 双等」硬谓词的**输入**,列被外部改成漂移值(乃至恰好
|
|
852
|
-
// 改成另一只 ask 的 hash)时,只信列 = 把双等谓词架空成单等。两边都在场且不等 ⇒ unparseable;
|
|
853
|
-
// 列 NULL 而 blob 有值 ⇒ 用 blob 值补齐(旧行回落,与 toolCallId 的「列 NULL 回落 blob」同规)。
|
|
854
407
|
if (base.boundInputHash !== null && derived.boundInputHash !== null && base.boundInputHash !== derived.boundInputHash) {
|
|
855
408
|
out.push({ ...base, boundCallId: null, unparseable: true });
|
|
856
409
|
continue;
|
|
857
410
|
}
|
|
858
411
|
const effectiveHash = base.boundInputHash ?? derived.boundInputHash;
|
|
859
|
-
// 列在场时以列为准(权威投影);列为 NULL 的旧行回落用 blob 解出来的值。
|
|
860
412
|
const effective = col ?? derived.toolCallId;
|
|
861
413
|
if (effective === toolCallId)
|
|
862
414
|
out.push({ ...base, boundInputHash: effectiveHash, sourceTaskId: derived.sourceTaskId, boundCallId: effective });
|
|
863
|
-
// 解得出、但不是这只 ask 的 callId(或这条 park 本来就没有工具动作)⇒ **确定**不是候选:
|
|
864
|
-
// 既不返回也不标坏行(标坏行会让收敛器把一条明确的「不匹配」当成「不确定」)。
|
|
865
415
|
}
|
|
866
416
|
return out;
|
|
867
417
|
}
|
|
868
|
-
/**
|
|
869
|
-
* The owner SCOPE of a session's pending checkpoint (the multi-tenant key === the owner principal in the
|
|
870
|
-
* BFF/non-operator flow, the same key listPending filters by). For the /decide owner-gate: a non-operator
|
|
871
|
-
* caller may only decide a checkpoint in their OWN scope. Returns `undefined` (no pending row), `null`
|
|
872
|
-
* (anonymous/dev session — open), or the owner scope string.
|
|
873
|
-
*/
|
|
874
418
|
async peekPendingScope(sessionId) {
|
|
875
419
|
const { rows } = await this.db.query(this.q("SELECT scope FROM checkpoint WHERE session_id=? AND status='pending' LIMIT 1", "SELECT scope FROM checkpoint WHERE session_id=$1 AND status='pending' LIMIT 1"), [sessionId]);
|
|
876
420
|
if (!rows[0])
|
|
877
421
|
return undefined;
|
|
878
422
|
return rows[0].scope ?? null;
|
|
879
423
|
}
|
|
880
|
-
// ─────────────────────── checkpoint_ctx (service rebuild inputs; sessionId-keyed) ───────────────────────
|
|
881
|
-
/** Upsert the resume rebuild inputs for a session (written at SUBMIT when durable mode is on). */
|
|
882
424
|
async putCtx(sessionId, ctx) {
|
|
883
425
|
await this.db.query(this.q("INSERT INTO checkpoint_ctx (session_id, ctx, updated_at_ms) VALUES (?,?,?) " +
|
|
884
426
|
"ON DUPLICATE KEY UPDATE ctx = VALUES(ctx), updated_at_ms = VALUES(updated_at_ms)", "INSERT INTO checkpoint_ctx (session_id, ctx, updated_at_ms) VALUES ($1,$2::jsonb,$3) " +
|
|
@@ -888,17 +430,6 @@ export class SqlCheckpointStore {
|
|
|
888
430
|
const { rows } = await this.db.query(this.q("SELECT ctx FROM checkpoint_ctx WHERE session_id=?", "SELECT ctx FROM checkpoint_ctx WHERE session_id=$1"), [sessionId]);
|
|
889
431
|
return rows[0] ? parseJson(rows[0].ctx) : null;
|
|
890
432
|
}
|
|
891
|
-
/**
|
|
892
|
-
* GC ctx rows whose lifecycle is over (bound to the checkpoint, don't leak):
|
|
893
|
-
* a session with NO pending checkpoint, NO active run, AND not touched since `cutoff`.
|
|
894
|
-
*
|
|
895
|
-
* The extra `task_active` guard closes a multi-gate window: between a resume's CAS (pending→resolved) and
|
|
896
|
-
* the task hitting its NEXT gate (writing a new pending checkpoint), the session has no pending checkpoint —
|
|
897
|
-
* yet `putCtx` only stamps `updated_at_ms` at submit, so the ctx could be older than `cutoff` and was being
|
|
898
|
-
* reaped, making the next resume 409 ("resume context missing"). `task_active` holds the session lock for
|
|
899
|
-
* the WHOLE submit→last-resume-complete span (running OR suspended), so excluding sessions with an active
|
|
900
|
-
* claim keeps the ctx exactly as long as a run still needs it, while still GC'ing it once the run is terminal.
|
|
901
|
-
*/
|
|
902
433
|
async reapCtx(cutoff) {
|
|
903
434
|
const res = await this.db.query(this.q("DELETE FROM checkpoint_ctx WHERE updated_at_ms < ? " +
|
|
904
435
|
"AND session_id NOT IN (SELECT session_id FROM checkpoint WHERE status='pending') " +
|
|
@@ -907,22 +438,6 @@ export class SqlCheckpointStore {
|
|
|
907
438
|
"AND session_id NOT IN (SELECT session_id FROM task_active)"), [cutoff]);
|
|
908
439
|
return res.affected;
|
|
909
440
|
}
|
|
910
|
-
/**
|
|
911
|
-
* E21 (§0.5 session delete) — purge ALL checkpoint state for one session: the durable checkpoint rows
|
|
912
|
-
* (suspended-task gates) AND their service-side resume-context rows (checkpoint_ctx). Keyed on the
|
|
913
|
-
* `session_id` column both tables carry. Idempotent (returns the count of `checkpoint` rows removed).
|
|
914
|
-
* Unconditional by design: a session being deleted has no resumable future, so any pending gate is moot.
|
|
915
|
-
*
|
|
916
|
-
* `owner` is a single-DB-fleet defense-in-depth guard (adversarial-review MEDIUM). Neither table carries an
|
|
917
|
-
* owner column, so the guard threads through `session_meta.owner` via an EXISTS sub-select (null-safe compare:
|
|
918
|
-
* `<=>` on TiDB, `IS NOT DISTINCT FROM` on PG). The purge coordinator runs this BEFORE deleting session_meta,
|
|
919
|
-
* so the meta row (with its authoritative owner) is still present here — a wrong-owner caller's EXISTS fails
|
|
920
|
-
* and purges nothing. `owner=null` (legacy/dev, never threaded by the route) keeps the historical
|
|
921
|
-
* unconditional behaviour via the null-safe match.
|
|
922
|
-
*
|
|
923
|
-
* 🔴 Placeholder ARITY differs, not just the syntax: `?` cannot be reused, so the TiDB arm binds `sessionId`
|
|
924
|
-
* TWICE (row key + guard sub-select) while PG reuses `$1`.
|
|
925
|
-
*/
|
|
926
441
|
async deleteBySession(sessionId, owner) {
|
|
927
442
|
const ownerGuard = this.q("EXISTS (SELECT 1 FROM session_meta sm WHERE sm.session_id = ? AND sm.owner <=> ?)", "EXISTS (SELECT 1 FROM session_meta sm WHERE sm.session_id = $1 AND sm.owner IS NOT DISTINCT FROM $2)");
|
|
928
443
|
const params = this.db.dialect === "tidb" ? [sessionId, sessionId, owner] : [sessionId, owner];
|
|
@@ -931,13 +446,11 @@ export class SqlCheckpointStore {
|
|
|
931
446
|
return res.affected;
|
|
932
447
|
}
|
|
933
448
|
}
|
|
934
|
-
/** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */
|
|
935
449
|
export class TiDBCheckpointStore extends SqlCheckpointStore {
|
|
936
450
|
constructor(pool, logger) {
|
|
937
451
|
super(mysqlDriver(pool), logger);
|
|
938
452
|
}
|
|
939
453
|
}
|
|
940
|
-
/** PostgreSQL binding — historical class name + ctor shape preserved. */
|
|
941
454
|
export class PgCheckpointStore extends SqlCheckpointStore {
|
|
942
455
|
constructor(pool, logger) {
|
|
943
456
|
super(pgDriver(pool), logger);
|