@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
package/dist/tool-approval.js
CHANGED
|
@@ -1,48 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* [816]/[820]② tool-approval bridge — the SERVICE side of core's `RunnerDeps.onAsk` seam (core `tool-policy.ts`
|
|
3
|
-
* `resolveAsk`, 1.290's sync-ask leg). The SIBLING of {@link import("./question.js").QuestionCoordinator}: onQuestion
|
|
4
|
-
* routes the agent's AskUserQuestion tool to a live human; THIS routes a policy `ask` DECISION (e.g. the
|
|
5
|
-
* `createFsWriteGatePolicy` fs-write gate, an ask-list rule, or a non-durable safety ask) to the live human as a
|
|
6
|
-
* CC-parity three-choice card (Yes / Yes-allow-all-session / No).
|
|
7
|
-
*
|
|
8
|
-
* Wire shape (mirrors question end to end): core adjudicates a tool call `ask` → `resolveAsk(req, onAsk)` calls this
|
|
9
|
-
* coordinator's `ask` → we mint a pending entry + emit a `tool_approval` SSE frame on the run's live stream → the
|
|
10
|
-
* shell renders the three-choice card → `POST /v1/tool-approvals/:id/respond` settles the parked promise →
|
|
11
|
-
* `resolveAsk` maps our boolean to allow/deny. RESPOND BODY selection rationale: the question precedent's body is
|
|
12
|
-
* core's own QuestionAnswer (a structured multi-answer object) because core FENCES its semantics; an approval has no
|
|
13
|
-
* core-side answer object — `OnAsk` resolves to a BARE boolean — so the wire body is a minimal closed enum
|
|
14
|
-
* `{decision: "allow" | "allow_session" | "deny"}` mapping 1:1 onto the CC card's three choices (no free text = no
|
|
15
|
-
* fence needed; "allow_session" carries the Yes-allow-all intent that a boolean cannot).
|
|
16
|
-
*
|
|
17
|
-
* Session state ([820]② "桥内会话态"): `allow_session` remembers a per-CAPABILITY grant keyed by
|
|
18
|
-
* `[owner, sessionId, category]` — every LATER ask in that (owner, session) whose tool maps to the SAME category
|
|
19
|
-
* auto-allows WITHOUT a card (CC 207: "Yes, allow all edits during this session"). The category collapses the
|
|
20
|
-
* fs-WRITE tool family (Write/Edit/NotebookEdit, canonical space) into ONE bucket `"fs-write"`; any OTHER tool is
|
|
21
|
-
* its OWN bucket `"tool:<canonical>"`. 🔴 A `allow_session` on a NON-fs-write ask (an irreversible-Bash safety ask,
|
|
22
|
-
* an ask-list rule on another tool) therefore unlocks ONLY that same tool for the session — it must NEVER unlock the
|
|
23
|
-
* fs-write family (the "approve a cheap ask once → all future Write/Edit auto-allowed" escalation, 三路复审 A 组 修2).
|
|
24
|
-
* The key is scoped to the VERIFIED owner too, so a session id shared across principals cannot inherit another's
|
|
25
|
-
* grant. This is the cheapest mid-task implementation; a mode SWITCH (default → acceptEdits) still goes through the
|
|
26
|
-
* shell's intent on the next task ([820] table).
|
|
27
|
-
*
|
|
28
|
-
* 🔒 FAIL-CLOSED everywhere (the inverse of the question lane — an unanswered QUESTION never blocks the run: since
|
|
29
|
-
* #166 the coordinator reports `unavailable` and CORE decides the landing, which depends on WHEN the human became
|
|
30
|
-
* unreachable — at policy time the durable gate says `ask` and the leg parks; once the tool is already running core is
|
|
31
|
-
* past `suspendAsk`, so it continues on `declined_unavailable`; an unanswered APPROVAL must NOT let the write
|
|
32
|
-
* proceed): TTL expiry ⇒ false; run abort / client
|
|
33
|
-
* disconnect ⇒ false. Since [879] G1 (core 1.295 OnAsk 三值化) the "no reachable human" class returns
|
|
34
|
-
* "unavailable" instead of false — no ALS context AND no bound-closure broker hit(1.258 起 bg/嵌套子代经
|
|
35
|
-
* spec.onAsk 的 bound 闭包 + streams broker 直达宿主活流,「无 ctx」只剩 durable-submit/headless resume
|
|
36
|
-
* 残余腿)and emit failure
|
|
37
|
-
* (card never delivered, and only when this catch is the first settler — a human verdict that raced in during the
|
|
38
|
-
* await keeps its boolean) ⇒ "unavailable" → core durable-parks (or fail-closed denies without a checkpoint). core's
|
|
39
|
-
* `resolveAsk` maps false to a deny WITH the ask message, so the model can re-route (deny + continue turn, [819]⑤).
|
|
40
|
-
* Owner gate on respond = the VERIFIED principal (gatedPrincipal at the HTTP layer), 404 without an existence oracle
|
|
41
|
-
* — same posture as question/elicit. The frame's model-authored surfaces (message + args) are secret-REDACTED and
|
|
42
|
-
* size-bounded before they are streamed (same redact-at-write contract as question; the args are shown to a human,
|
|
43
|
-
* never re-fed to a model). No throttle (unlike question): an approval breach-default would be a DENY (task failure),
|
|
44
|
-
* and asks are turn-blocking by construction — the TTL bounds the human-side exposure instead.
|
|
45
|
-
*/
|
|
46
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
47
2
|
import { uuidv7, MAX_RULE_TEXT_CHARS } from "@sema-agent/core";
|
|
48
3
|
import { redactDeep, redactSecrets } from "./trace/redact.js";
|
|
@@ -51,131 +6,64 @@ import { recordFailOpen } from "./observability/fail-open.js";
|
|
|
51
6
|
import { deriveAskId, deriveBatchId, MAX_DECISION_NOTE_CHARS } from "./approval-ask-machine.js";
|
|
52
7
|
import { ApprovalCardEnvelopeSchema, MAX_RULE_SUGGESTIONS, buildApprovalCard, buildApprovalCardEnvelope, buildApprovalRequestFrame, buildRevokeFrame, readProbeCause, readRuleEvidence, } from "./approval-card.js";
|
|
53
8
|
import { governanceAskMarksFor, runWithGovernanceAskScope } from "./governance-ask-marks.js";
|
|
54
|
-
/** #151 车2:本模块自有的日志出口——同 config-provider.ts/runtime-caps-resolver.ts 先例(协调器不走
|
|
55
|
-
* DI logger,构造签名是设计定稿钉死的三键 options bag,加第四个 logger 键属于重议已裁事项)。仅用于
|
|
56
|
-
* D5 的一次性 store-故障 warn。 */
|
|
57
9
|
const defaultLogger = createLogger();
|
|
58
|
-
/** A-002.1:审批 gate kind 闭集(core gateMatch 的 `human`/`irreversible_ask` ↔ outcome.gate "policy_ask")。
|
|
59
|
-
* 此前 7 份手写副本散在两只 checkpoint store 的数组/SQL 字面与 /decide 守卫——core 加审批味 kind 时
|
|
60
|
-
* 全部静默漂移。core 无导出词表(全大写导出面零命中,2026-08-09 亲验),属主落此;SQL IN 片段从
|
|
61
|
-
* 数组派生保证同源。门=test/approval-gate-kinds-single-owner.test.ts(副本回潮即红)。 */
|
|
62
10
|
export const APPROVAL_GATE_KINDS = ["human", "irreversible_ask"];
|
|
63
11
|
export function isApprovalGateKind(k) {
|
|
64
12
|
return k !== undefined && APPROVAL_GATE_KINDS.includes(k);
|
|
65
13
|
}
|
|
66
|
-
/** 两方言同形的 SQL IN 片段(值为闭集常量字面,无注入面)。 */
|
|
67
14
|
export const APPROVAL_GATE_KINDS_SQL_IN = `(${APPROVAL_GATE_KINDS.map((k) => `'${k}'`).join(",")})`;
|
|
68
|
-
/** Size bound on the redacted args payload in a `tool_approval` frame (parity with question's MAX_QUESTIONS_BYTES).
|
|
69
|
-
* Over the cap ⇒ the frame still goes out WITHOUT args (`argsOmitted: true`). The sibling lane makes the OPPOSITE
|
|
70
|
-
* call and that asymmetry is deliberate: an over-cap question is DROPPED and the ask reports `unavailable`
|
|
71
|
-
* (`question.ts` `MAX_QUESTIONS_BYTES`; since #166 that is an honest "nobody answered" and CORE picks the landing —
|
|
72
|
-
* here the tool is already running, so core is past `suspendAsk` and continues on `declined_unavailable` rather than
|
|
73
|
-
* parking). Dropping an approval would land it on
|
|
74
|
-
* this module's "no reachable human" class instead — `"unavailable"`, which durable-parks where a checkpoint store
|
|
75
|
-
* exists and fail-closed DENIES where it does not (see the module header). Both of those postpone or refuse the
|
|
76
|
-
* write; keeping the card on the wire without args is the only option that still buys an IMMEDIATE human decision —
|
|
77
|
-
* toolName+message suffice to decide, and the fail-safe decision on blind args is the human's No. */
|
|
78
15
|
const MAX_APPROVAL_ARGS_BYTES = 16384;
|
|
79
|
-
/** How long an unanswered approval waits before it DENIES (fail-closed — the human walked away; don't hold core). */
|
|
80
16
|
const DEFAULT_APPROVAL_TTL_MS = 5 * 60_000;
|
|
81
|
-
/** #151 车2(design/172 §3.3 D3):窗长三元公式里的安全余量默认值——`STREAM_ASK_WINDOW_MARGIN_MS` 缺省时
|
|
82
|
-
* 用这个(config.ts 的 numEnv fallback 字符串与本常量保持同值,便于对表)。 */
|
|
83
17
|
const DEFAULT_WINDOW_MARGIN_MS = 10_000;
|
|
84
|
-
/** Bound on the remembered allow-all grants (insertion-order evict — a long-lived worker must not grow unbounded). */
|
|
85
18
|
const MAX_ALLOW_SESSIONS = 4096;
|
|
86
|
-
/** #151 车3 刀 3b(设计稿 §0 X-2):写侧准入门两帽的**代码默认**——与 `config.ts` 的
|
|
87
|
-
* `STREAM_APPROVAL_ADMIT_MAX_PER_TASK` / `_PER_OWNER` fallback 同值(便于对表)。装配点恒显式传,
|
|
88
|
-
* 这两个常量只服务「手搓协调器」的测试与防御性缺省。 */
|
|
89
19
|
const DEFAULT_ADMIT_MAX_PER_TASK = 32;
|
|
90
20
|
const DEFAULT_ADMIT_MAX_PER_OWNER = 256;
|
|
91
|
-
/** #329:墓碑表的容量帽/条目 TTL **代码默认**。不接 env —— 它不是部署旋钮,而是一张内部有界表的
|
|
92
|
-
* 尺寸(判据可观测性由构造 opts 提供)。1h 的取值理由:比任何一条 run 的赎回窗都长(壳看到卡到人
|
|
93
|
-
* 回来按键的真实间隔),又短到不会把一台长命 worker 的内存钉在过期指路条上。 */
|
|
94
21
|
const DEFAULT_PARK_TOMBSTONE_MAX = 2000;
|
|
95
22
|
const DEFAULT_PARK_TOMBSTONE_TTL_MS = 60 * 60_000;
|
|
96
|
-
/** #329 G3:PARKING 窄窗(单 UPDATE 到 gate 铸完,毫秒级)的重试提示。客户端一次重试即落 PARKED 臂。 */
|
|
97
23
|
const PARKING_RETRY_AFTER_MS = 250;
|
|
98
|
-
/** #151 车5(X-1 状态感知分派):CAS 干净地输之后重读持久态的**有界**退避表(ms,一次退避一格)。
|
|
99
|
-
* 长度 = 重试次数上限(6 次,累计 ≈ 3.1s)。为什么必须有界:重读是为了「不挂死」,一个无界重试循环
|
|
100
|
-
* 只是把挂死从 promise 挪到 store —— 用尽仍读不出 ⇒ D5 fail-open 退回纯进程内语义。期间任何一步发现
|
|
101
|
-
* `done`(真赢家已在本进程落定)立即退出,退避 timer 一律 `unref`(绝不持住进程)。 */
|
|
102
24
|
const DURABLE_CONVERGE_BACKOFF_MS = [50, 100, 200, 400, 800, 1600];
|
|
103
|
-
/** 单次重读的**墙钟上限**(ms)。🔴 codex 交叉复审 C5(2026-08-06 真缺陷):只数重试次数是**不够**的 ——
|
|
104
|
-
* 次数只约束「已经返回或已经 reject 的调用」,一次**挂住不返回**的 `getAsk`(DB 连接黑洞、池饿死)会让
|
|
105
|
-
* 整个退避循环连一格都走不完,`onAsk` 的 promise 于是永久悬挂 —— 正是这段代码要消灭的那个形。每次读都
|
|
106
|
-
* 与这个 deadline 显式 race,超时算一次失败继续退避;全部用尽走 D5 fail-open。 */
|
|
107
25
|
const DURABLE_CONVERGE_READ_TIMEOUT_MS = 2_000;
|
|
108
|
-
/** #151 车5(codex round2 R2-5):**决定 onAsk 能否落定**的那些 store 调用(`ensureAsk` 及其复核读、
|
|
109
|
-
* 窗到期/取消/emit-全灭三臂的 CAS)的墙钟上限。
|
|
110
|
-
*
|
|
111
|
-
* 🔴 为什么必须有:R2-5 抓的是「重读有 deadline、但**它之前**的调用没有」——`ensureAsk` 在窗定时器与
|
|
112
|
-
* abort 监听器**装上之前**被 await,一次黑洞化的 `ensureAsk` 会让整只 ask 连一个兜底都没有地永久悬挂。
|
|
113
|
-
* 比 converge 的 2s 宽(这些是真正在做事的调用,不是轮询复核),但必须是**有限**的:超时后走的正是各自
|
|
114
|
-
* 既有的「store 抛错」路径(D5 fail-open / 防御性复核),语义不新增,只是把触发条件从「抛了」放宽到
|
|
115
|
-
* 「抛了**或**久到不可能再有意义」。 */
|
|
116
26
|
const DURABLE_CALL_TIMEOUT_MS = 5_000;
|
|
117
|
-
/** {@link ToolApprovalCoordinator.withStoreDeadline} 超时时抛的**具名**错误。
|
|
118
|
-
* 🔴 具名类而不是「按 message 文本判别」:错误文案是给人看的,拿它当控制流 = 上游改一句人话下游静默
|
|
119
|
-
* 失效(#90 工程规范 v3 门②,机械门在 test/engineering-code-gates.test.ts 盯着)。
|
|
120
|
-
* ⚠️ **判别用途已退役**(#280 R-13 A2,2026-08-18):窗到期 catch 臂此前用 `instanceof` 分「超时(不知道
|
|
121
|
-
* 做没做成)」与「店真报错」两条方向不同的臂;A2 把两支合判成 park 路由之后没有调用点再分它。类留着的
|
|
122
|
-
* 理由是**归因**(`noteStoreError` 的日志/计数里那个 name 区分得出「店挂了」与「店慢到没意义」)与
|
|
123
|
-
* `withStoreDeadline` 的迟到回调语义,不是控制流。 */
|
|
124
27
|
class ApprovalStoreDeadlineError extends Error {
|
|
125
28
|
constructor(label, timeoutMs) {
|
|
126
29
|
super(`approval store call ${label} timed out after ${timeoutMs}ms`);
|
|
127
30
|
this.name = "ApprovalStoreDeadlineError";
|
|
128
31
|
}
|
|
129
32
|
}
|
|
130
|
-
/** The fs-write tool family the session allow-all collapses into ONE capability bucket (canonical space — a
|
|
131
|
-
* 5.0.0 RB-476:折叠面退役,三名即 CC canonical(旧拼法在 core roster 层响亮 miss)。Mirrors core's
|
|
132
|
-
* PATH_WRITE_TOOLS + NotebookEdit (the same set `createFsWriteGatePolicy` gates); NOT exported by core's
|
|
133
|
-
* index, so the names are pinned here deliberately. */
|
|
134
33
|
const FS_WRITE_TOOLS = new Set(["Write", "Edit", "NotebookEdit"]);
|
|
135
|
-
/** The session allow-all CAPABILITY CATEGORY for a tool (修2): the fs-write family shares one bucket ("fs-write" —
|
|
136
|
-
* a yes-to-edits covers Write/Edit/NotebookEdit alike, CC 207 semantics); every other tool is its OWN bucket
|
|
137
|
-
* ("tool:<canonical>") so an allow_session on it never grants anything beyond that same tool — a low-risk ask must
|
|
138
|
-
* not become a session-wide write unlock. Canonical space in, category out. */
|
|
139
34
|
function sessionAllowCategory(toolName) {
|
|
140
|
-
// RAW(5.0.0):live 名恒 canonical;存量 grant 键当年也是 canonical 空间铸的,键空间不变。
|
|
141
35
|
return FS_WRITE_TOOLS.has(toolName) ? "fs-write" : `tool:${toolName}`;
|
|
142
36
|
}
|
|
143
|
-
/** The allow-all grant key (修2): owner + sessionId + capability category, JSON-tuple-encoded so no segment can
|
|
144
|
-
* ambiguate the boundary with the next (an owner or sessionId containing a separator can't forge another key).
|
|
145
|
-
* Owner rides VERBATIM (null = auth-off/anonymous, its own scope) so a session id shared across principals never
|
|
146
|
-
* leaks a grant across tenants. */
|
|
147
37
|
function sessionAllowKey(owner, sessionId, category) {
|
|
148
38
|
return JSON.stringify([owner, sessionId, category]);
|
|
149
39
|
}
|
|
150
|
-
|
|
40
|
+
export const PERSIST_RULE_TEXT_ERROR = `persistRule.rule must be a non-empty string of at most ${MAX_RULE_TEXT_CHARS} characters`;
|
|
41
|
+
export const PERSIST_RULE_EDITED_FLAG_ERROR = "persistRule.edited must be a boolean when present";
|
|
42
|
+
export const PERSIST_RULE_SCOPE_REFUSED = "this endpoint does not accept a rule scope — the server mints it from where the approval happened (send neither `scope` nor `persistRule.scope`)";
|
|
151
43
|
export function parseToolApprovalResponse(body) {
|
|
152
44
|
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
153
45
|
return { ok: false, error: "body must be an object" };
|
|
154
46
|
const d = body.decision;
|
|
155
47
|
if (d === "allow" || d === "allow_session" || d === "deny") {
|
|
156
|
-
// [1458] ctrl+g 编辑放行:allow 族可带 updatedInput(编辑后的 args 整体替换形,durable /decide 腿
|
|
157
|
-
// 同名位既有语义);deny 带=无意义,忽略不 400(宽收)。
|
|
158
48
|
const u = body.updatedInput;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
// deny 带 = 无意义(人拒了这次操作,谈不上持久化同意),忽略不 400(同 updatedInput 的宽收姿势)。
|
|
49
|
+
if (body.scope !== undefined)
|
|
50
|
+
return { ok: false, error: PERSIST_RULE_SCOPE_REFUSED };
|
|
162
51
|
const pr = body.persistRule;
|
|
163
52
|
let persistRule;
|
|
164
53
|
if (d !== "deny" && pr !== undefined) {
|
|
165
54
|
if (pr === null || typeof pr !== "object" || Array.isArray(pr))
|
|
166
55
|
return { ok: false, error: "persistRule must be an object" };
|
|
56
|
+
if (pr.scope !== undefined)
|
|
57
|
+
return { ok: false, error: PERSIST_RULE_SCOPE_REFUSED };
|
|
167
58
|
const rule = pr.rule;
|
|
168
59
|
if (typeof rule !== "string" || rule === "" || rule.length > MAX_RULE_TEXT_CHARS) {
|
|
169
|
-
return { ok: false, error:
|
|
60
|
+
return { ok: false, error: PERSIST_RULE_TEXT_ERROR };
|
|
170
61
|
}
|
|
171
|
-
|
|
62
|
+
const edited = pr.edited;
|
|
63
|
+
if (edited !== undefined && typeof edited !== "boolean")
|
|
64
|
+
return { ok: false, error: PERSIST_RULE_EDITED_FLAG_ERROR };
|
|
65
|
+
persistRule = { rule, edited: edited === true };
|
|
172
66
|
}
|
|
173
|
-
// #229(设计稿 233 稿B):回决**理由**。与 durable 腿(`http/routes/runs.ts` 的 `AskDecisionBodySchema.note`)
|
|
174
|
-
// **同词同源同上限**({@link MAX_DECISION_NOTE_CHARS}),落的也是同一列 `decision_note` —— 不造第三口径。
|
|
175
|
-
// 🔴 **任何 decision 都可带**(deny 也算),与 durable 腿的无条件记账形对齐:「为什么拒」正是审计面上
|
|
176
|
-
// 最值钱的那一条,做成 allow-only 等于把它扔掉。⚠️ `allow_session` 是 wire 上的三值之一,落到行上是
|
|
177
|
-
// `approve`(三值映二值)—— 它的 note 因此记在**那条 approve 行**上,不另开一行、也不丢。
|
|
178
|
-
// 坏形(非串 / 超上限)是**响亮 400**,不静默截断:一条被悄悄砍半的审计理由比没有理由更坏。
|
|
179
67
|
const rawNote = body.note;
|
|
180
68
|
let note;
|
|
181
69
|
if (rawNote !== undefined) {
|
|
@@ -194,23 +82,12 @@ export function parseToolApprovalResponse(body) {
|
|
|
194
82
|
}
|
|
195
83
|
return { ok: false, error: 'decision must be one of "allow" | "allow_session" | "deny"' };
|
|
196
84
|
}
|
|
197
|
-
/** [1550] RB-39② 兑现:判别式换 core 1.378 显式键 `fromSubagent`(委派子代 gate 发起的 ask 恒带,受信
|
|
198
|
-
* internals 事实——worker 不可伪造/不可抑制)。取代 [1546] MED-1 的「在场 ∧ ≠ 宿主 sessionId」权宜式
|
|
199
|
-
* (旧判别式靠 ctx.sessionId 参与推导,sessionless adhoc 场景无法区分;显式键不依赖 ctx,恒定准确)。 */
|
|
200
85
|
function isChildAsk(req) {
|
|
201
86
|
return req.fromSubagent === true;
|
|
202
87
|
}
|
|
203
|
-
/** redactDeep + size-bound the UNTRUSTED tool args before they are streamed. Over the cap / unserializable ⇒
|
|
204
|
-
* undefined (frame rides argsOmitted — see MAX_APPROVAL_ARGS_BYTES). */
|
|
205
88
|
function boundArgs(args) {
|
|
206
89
|
const redacted = redactDeep(args);
|
|
207
90
|
try {
|
|
208
|
-
// 🔴 #151 车3 刀 3b(设计稿 §6.5,codex 复审抓获的真缺陷):`.length` 数的是 **UTF-16 编码单元**,
|
|
209
|
-
// 而常量名写的是 `_BYTES`——含中文/emoji 的 args 实际 UTF-8 字节数可达该值的约 3 倍(CJK 每字符
|
|
210
|
-
// 3 字节)⇒ 帽名不副实,而设计 §3.1 把「全量 args 的**字节**帽与洗涤」明写成 server 的新增责任
|
|
211
|
-
// (引擎无此层)。改按真字节数判,常量语义与名字对齐。
|
|
212
|
-
// 同一判据**同时**服务 wire 帧与 `card_json` 落库:两侧共用本函数的这一份 `bounded` 结果
|
|
213
|
-
// (见 askBroadcast 里 frame/card 的构造),不各算各的。钉:E-3(纯 CJK 压线的变异钉)。
|
|
214
91
|
if (Buffer.byteLength(JSON.stringify(redacted), "utf8") > MAX_APPROVAL_ARGS_BYTES)
|
|
215
92
|
return { omitted: true };
|
|
216
93
|
}
|
|
@@ -219,20 +96,6 @@ function boundArgs(args) {
|
|
|
219
96
|
}
|
|
220
97
|
return { args: redacted, omitted: false };
|
|
221
98
|
}
|
|
222
|
-
/** #151 车3 刀 3b:`AskRequest.boundInputHash` 的**边界窄读**(车5 §9 C2 的对账 join 键)。
|
|
223
|
-
*
|
|
224
|
-
* ✅ **已点亮**(#164 翻真验证车,2026-08-07 亲验 core 5.15.0):`AskRequest.boundInputHash` 现在既在类型面
|
|
225
|
-
* (`core/tool-policy.d.ts:83`,`readonly boundInputHash?: string`)也在真码面(`core/tool-policy.js:610`:
|
|
226
|
-
* `onAsk({ ...req, boundInputHash: boundInputHashOf(presented.value), args: approverView.value })`)——
|
|
227
|
-
* 每一只经 `resolveAsk` 的 ask 都带值。窄读当初就是为这一刻写的:**同一行代码**不改而自然点亮,收敛器
|
|
228
|
-
* 判据 1 从「结构上恒不命中」变成会命中,`unmatchableNoHash` 从「响亮化的盲区」退回它该有的边缘含义。
|
|
229
|
-
* (旧注写的「树上 core 5.13.0 没有这个键 ⇒ 今天恒回 null」自 core 5.15.0 提货起即过期,勿据以判断。)
|
|
230
|
-
*
|
|
231
|
-
* 🔴 保留窄读而不改 import 的理由不变:`boundInputHashOf` 至今**不在 core 的公开导出面**上
|
|
232
|
-
* (package `exports` 只有 `.` / `./bench` / `./fixtures`),server 因此既算不出也不该算这个摘要 ——
|
|
233
|
-
* 它的职责恒是「原样透传落列」。非字符串/空串一律按缺席处置:一个形不对的 hash 比没有 hash 更危险
|
|
234
|
-
* (它会让「硬相等」这道门在一个垃圾值上偶然成立)。钉:test/stream-approval-on-e2e.test.ts 件4
|
|
235
|
-
* (正控 / 缺席 / 空串三臂 + 收敛器 `unmatchableNoHash` 归零的非零对照)。 */
|
|
236
99
|
function readBoundInputHash(req) {
|
|
237
100
|
if (req === null || typeof req !== "object")
|
|
238
101
|
return null;
|
|
@@ -252,20 +115,6 @@ export function resolveStreamApprovalGate(input) {
|
|
|
252
115
|
return { active: false, reason: "no_park_facility" };
|
|
253
116
|
return { active: true, askStore: input.backend.approvalAsk() };
|
|
254
117
|
}
|
|
255
|
-
/**
|
|
256
|
-
* #151 车3 刀 3b(codex 交叉复审 round2 R2-1,2026-08-06 真 finding)—— 呈卡帧的**双写投递口**。
|
|
257
|
-
*
|
|
258
|
-
* 🔴 为什么必须收成一个有属主的工厂:round1 之后「新帧送达」开始**参与**「卡到底有没有送到人手上」的
|
|
259
|
-
* 判定(见 `emitOne` 顶注)。而 sync 腿原来那个内联闭包把两个 sink 的失败都吞掉、然后**正常返回** ——
|
|
260
|
-
* 于是「账本写失败 ∧ socket 已死」这种**真的一路都没送到**的情形被上报成成功,`anySucceeded` 因此压住了
|
|
261
|
-
* park 路由,ask 会一直挂到窗到期而**没有任何人可能回答它**。诚实的形只有一个:**至少一个 sink 真的接下
|
|
262
|
-
* 了才算送达**,一个都没接下就抛 —— 调用点(`emitOne`)的 catch 会把它如实记成 `card: false`。
|
|
263
|
-
*
|
|
264
|
-
* 顺序 = **先账本后 live**(与 file_link 的先例相反,理由是本帧的用武之地恰好在 live 面已死的时候):
|
|
265
|
-
* detach 断连后 live 写被丢弃,而壳换道 events tail 必须还能看到这张未决卡(§4.3(c) 的案A)。
|
|
266
|
-
*
|
|
267
|
-
* 命名(CLAUDE.md 工厂命名律):`create*` —— 返回的是**捕获了两个 sink 的闭包**,不是纯数据。
|
|
268
|
-
*/
|
|
269
118
|
export function createApprovalCardEmitter(sinks) {
|
|
270
119
|
return async (frame) => {
|
|
271
120
|
let accepted = false;
|
|
@@ -275,7 +124,6 @@ export function createApprovalCardEmitter(sinks) {
|
|
|
275
124
|
accepted = true;
|
|
276
125
|
}
|
|
277
126
|
catch {
|
|
278
|
-
/* 账本瞬断不 fault 卡面 —— 但也**不算**送达(下面还有一路,两路都没接下才抛) */
|
|
279
127
|
}
|
|
280
128
|
}
|
|
281
129
|
if (sinks.writeLive) {
|
|
@@ -284,7 +132,6 @@ export function createApprovalCardEmitter(sinks) {
|
|
|
284
132
|
accepted = true;
|
|
285
133
|
}
|
|
286
134
|
catch {
|
|
287
|
-
/* 死流/已 destroy 的 socket —— 同上 */
|
|
288
135
|
}
|
|
289
136
|
}
|
|
290
137
|
if (!accepted)
|
|
@@ -303,135 +150,44 @@ export function resolveApprovalLeg(input) {
|
|
|
303
150
|
...(input.legWalltimeMs !== undefined ? { legDeadlineMonotonic: input.nowMonotonicMs + input.legWalltimeMs } : {}),
|
|
304
151
|
};
|
|
305
152
|
}
|
|
306
|
-
/** #151 车2(design/172 §3.3 D3,窗长三元,逐字):有效窗 = `min(ttlMs 配置, legRemainingMs − windowMarginMs)`。
|
|
307
|
-
* `legDeadlineMonotonic` 缺席(现行为绝大多数腿——装配点车3 才真传)⇒ 有效窗退化成 `ttlMs`,逐字不变
|
|
308
|
-
* (D1 零行为变化)。在场且算出的窗 ≤ 0(本 leg 剩余 walltime 撑不住安全余量)⇒ 折 0——调用方按 0ms
|
|
309
|
-
* 定时器处理,等同「不开窗,立即走窗到期同路」(§3.3 不变量:窗不得把一个可 park 的 ask 拖成
|
|
310
|
-
* abort-deny)。纯函数(禁 if 链风格的最小实现,`nowMonotonicMs` 显式入参便于测试注入,不裸读全局时钟)。 */
|
|
311
153
|
function effectiveAskWindowMs(ttlMs, windowMarginMs, legDeadlineMonotonic, nowMonotonicMs) {
|
|
312
154
|
if (legDeadlineMonotonic === undefined)
|
|
313
155
|
return ttlMs;
|
|
314
156
|
const legRemainingMs = legDeadlineMonotonic - nowMonotonicMs;
|
|
315
157
|
return Math.min(ttlMs, Math.max(0, legRemainingMs - windowMarginMs));
|
|
316
158
|
}
|
|
317
|
-
/** #151 车2(codex 交叉复审 round4 抓获,真 finding):`ensureAsk` 幂等 upsert 撞见一条**已经终结**(或
|
|
318
|
-
* 正在 PARKING/PARKED)的既有行时,把它翻译成一个直接可回放的 `AskOutcome`——不重新挂一条注定赢不了
|
|
319
|
-
* CAS 的本地待决。三态映射:`DECIDED` 按记录的决定回放(仅布尔臂,持久层不存 `updatedInput`,ctrl+g
|
|
320
|
-
* 编辑重放的字面丢失属已知、可接受的降级——`updatedInput` 全链条本就是纯 wire 层语义,未进车1 的行
|
|
321
|
-
* schema);`PARKING`/`PARKED` 统一按「已转投递面」回放 `"unavailable"`(park 路由,与 D2 窗到期赢的
|
|
322
|
-
* 语义一致);`DENIED`/`VOID` 回放 `false`(routing_failure 与人拒/取消的两分归因是车4 的活,本车只
|
|
323
|
-
* 保证不挂死、不用一个捏造的 true 掩盖真实终局)。`STREAM_PENDING` 不会走到这里(调用点已经判过)。 */
|
|
324
159
|
function replayTerminalAskRow(row) {
|
|
325
160
|
if (row.state === "DECIDED")
|
|
326
161
|
return row.decision === "approve";
|
|
327
162
|
if (row.state === "PARKING" || row.state === "PARKED")
|
|
328
163
|
return "unavailable";
|
|
329
|
-
return false;
|
|
164
|
+
return false;
|
|
330
165
|
}
|
|
331
|
-
/**
|
|
332
|
-
* 🔴 A-054.15(2026-08-19 合并重扫 confirmed;CLAUDE.md #157「P 类改不动的记 P-DEBT」)—— 一条**行派生**的
|
|
333
|
-
* `DECIDED=approve` 被交成裸 `true` 时的记债口。上面那条 JSDoc 早就写着「持久层不存 `updatedInput`,
|
|
334
|
-
* ctrl+g 编辑重放的字面丢失属已知、可接受的降级」——但那句只活在注释里,遥测里这条降级与「一切正常」
|
|
335
|
-
* 同形。它的方向是**错的一侧**:core 的 `d.updatedInput === undefined` 分支落回原始未改写实参执行 ⇒
|
|
336
|
-
* 人批的是改写后的命令、真跑的是原命令(执行面宽于人所批准),所以它不是可以静默的 F 类。
|
|
337
|
-
*
|
|
338
|
-
* **调用纪律**:只在真把 `true` 交出去的那一刻调(读到行不算——单赢者已落定时本腿并不 settle,记了就是
|
|
339
|
-
* 假阳性);deny 不调(没有编辑可丢)。四个同形站点一个不落,选择性登记比不登记更坏(遥测会说谎)。
|
|
340
|
-
* 终局收口 = 给 ask 行加 `updated_input` 列(店语义级,须本模块属主立件),届时本 tag 与本函数同批销。
|
|
341
|
-
*/
|
|
342
166
|
function recordRowDerivedApproveReplay(where) {
|
|
343
167
|
recordFailOpen("server.hitl.row-derived-approve-drops-updated-input", where);
|
|
344
168
|
}
|
|
345
|
-
/**
|
|
346
|
-
* Coordinates the live tool-approval HITL for the singleton runner. Process-local + same-replica (the pending map is
|
|
347
|
-
* in memory, like QuestionCoordinator): a respond that lands on another replica finds nothing → 404. Present (passed
|
|
348
|
-
* into `RunnerDeps.onAsk` + the respond route) ONLY when `TOOL_APPROVAL_ENABLED` — absent ⇒ core's `resolveAsk`
|
|
349
|
-
* keeps its headless auto-deny for every ask ([819]⑤ fail-closed default, zero behavior change).
|
|
350
|
-
*
|
|
351
|
-
* G1 interplay (core 1.290 sync-ask knob, prepare-task): with an onAsk present, a NON-safety ask with NO per-task
|
|
352
|
-
* `durableApproval` skips the durable park and resolves on this sync leg (zero checkpoint, CC-interactive latency).
|
|
353
|
-
* A durable-approval deployment (`DURABLE_APPROVAL=true` sets spec.durableApproval on every task) keeps parking every
|
|
354
|
-
* ask durably — this bridge then only serves legs where the park machinery is absent. Safety asks (irreversible/
|
|
355
|
-
* egress) always keep the durable park when a checkpointStore exists (core's condition, not ours).
|
|
356
|
-
*/
|
|
357
169
|
export class ToolApprovalCoordinator {
|
|
358
170
|
als = new AsyncLocalStorage();
|
|
359
171
|
pending = new Map();
|
|
360
|
-
/** #151 车2(codex 交叉复审 round3 抓获真 finding,round5 精化成 Set):次级索引,键 = 持久层 askId
|
|
361
|
-
* (与 {@link pending} 的 wire-面 uuidv7 `id` 是两条独立的身份轴,顶注同精神)——只在 askStore 在场且
|
|
362
|
-
* 这只 ask 真有 askId 时才登记。**同一 askId 下可能同时挂着不止一条本地条目**(round5 抓获:
|
|
363
|
-
* `ensureAsk` 是幂等 upsert,若 core 对同一 (sourceTaskId,runId,toolCallId,legKey) 真发起过两次并发调用——如
|
|
364
|
-
* 重试/failover 场景,round4 finding2 的顶注同源——两次 `askBroadcast` 各自的调用栈都会在行仍是
|
|
365
|
-
* STREAM_PENDING 时各自注册一条独立的本地 pending 条目,值形若是单值 Map 会被后到者覆盖前者的登记,
|
|
366
|
-
* 前者从此再也没有任何本地事件会驱动它 settle),故值形是 `Set`,不是单个 `PendingApproval`。
|
|
367
|
-
* 存在理由(两类真实成因共用同一套修复):①同一批(相同 sourceTaskId+leg)下若有多只**不同** ask 并发
|
|
368
|
-
* 在飞(不同 toolCallId,同一 batchId),`expireAsk` 赢家的同一次持久事务会把批内其余 STREAM_PENDING
|
|
369
|
-
* 兄弟原子撤成 VOID 并把它们的 askId 列在 `voidedSiblings` 里;②同一 askId 的**重复本地注册**(见上)。
|
|
370
|
-
* 两类情形下,那些"没赢"的本地条目各自独立的 timer/settle 闭包都对"这只 askId 其实已经有了终局"一无
|
|
371
|
-
* 所知:它们自己的窗到期/取消若稍后也去打 CAS,只会干净地输(D2 round2「输⇒什么都不做」),从此再没有
|
|
372
|
-
* 任何本地事件会驱动它们 settle——TTL 形同虚设,ask 会挂到进程重启。这个索引让赢家能反过来找到同一
|
|
373
|
-
* askId 下全部本地条目(自己 + 兄弟 + 重复注册),直接调用它们各自的 `settle`,而不是被动等一个永远
|
|
374
|
-
* 不会来的本地信号。 */
|
|
375
172
|
pendingByAskId = new Map();
|
|
376
|
-
/** Per-capability session grants — keys = sessionAllowKey(owner, sessionId, category) (修2; bounded). */
|
|
377
173
|
allowAllSessions = new Set();
|
|
378
|
-
/** [1546] HIGH-1(broker)→[1559]四 core 产品裁定「多活集合+广播+首决胜出」:per-(owner, host-session)
|
|
379
|
-
* 的**当前活跃连接集合**——boundAsk 闭包只携身份,emit 时广播给该 key 下**全部**活连接(宿主重连/
|
|
380
|
-
* attach 新 SSE 时 runWithContext 各自注册进同一集合,谁也不覆盖谁);查无活连接 = "unavailable"
|
|
381
|
-
* (G1 回路)。旧单指针形的缺口(file-backed local mode 无单活跃 run 409 守卫时,两个真正并发的
|
|
382
|
-
* runWithContext 会让后到者的指针覆盖先到者,先到连接的卡静默不可达——core 点名与「1.334 标已告知
|
|
383
|
-
* 须以投递确认为前提」同族)已随本集合改造解:后到者只是加入集合,不挤走先到者。首个 respond()
|
|
384
|
-
* 经 `pending` map 天然 CAS 胜出(第二个 respond() 撞 id 已被删 → 404),其余连接靠 completion 帧
|
|
385
|
-
* 收 dismiss 通知(见 askBroadcast)。 */
|
|
386
174
|
streams = new Map();
|
|
387
175
|
ttlMs;
|
|
388
|
-
/** #151 车2(design/172 §7 协调器半场,D1):可选持久层——缺席 ⇒ 每一条现行为逐字不变(in-memory
|
|
389
|
-
* promise 机械即契约);在场 ⇒ 四竞争者(回决/窗到期/取消/emit 全灭)的终局多一道持久 CAS 记账。
|
|
390
|
-
* additive 改造,不是替换——settle 闭包/pending map/TTL/broker 全保留,CAS 只决定「谁有权 settle
|
|
391
|
-
* 成什么终局」。 */
|
|
392
176
|
askStore;
|
|
393
|
-
/** #151 车2(design/172 §3.3 D3):窗长三元公式的安全余量,构造期定,详见 {@link effectiveAskWindowMs}。 */
|
|
394
177
|
windowMarginMs;
|
|
395
|
-
/** #151 车3 刀 3b(设计稿 §0 X-2 写侧准入门):per-task / per-owner 的未决 ask 上限。 */
|
|
396
178
|
admitMaxPerTask;
|
|
397
179
|
admitMaxPerOwner;
|
|
398
|
-
/** X-2 的两把**在飞计数**。键 = 出处 taskId / owner(`null` 折一个不可能与真 principal 相撞的哨兵)。
|
|
399
|
-
* 值在**准入那一刻**加、在 `settle`(或准入后的任何早退路径)减 —— 计的是「已经分配了资源的未决 ask」,
|
|
400
|
-
* 不是「注册进 pending map 的条目」:两者之间隔着 `ensureAsk` 的一次 await,只在注册时计数会让并发的
|
|
401
|
-
* 一群 ask 全部越过门。 */
|
|
402
180
|
admitByTask = new Map();
|
|
403
181
|
admitByOwner = new Map();
|
|
404
|
-
/** #151 车2(D5 一次性 warn 节流):同实例只报第一次,后续只计数(避免 store 抖动期间刷屏)。 */
|
|
405
182
|
storeErrorWarned = false;
|
|
406
183
|
storeErrorTally = 0;
|
|
407
|
-
/** [2942]/[2943] `governanceForced` 的**读侧**表(写侧 = runtime-governance 的两只观察器)。
|
|
408
|
-
* 缺席(生产形)⇒ 每条 run 腿由 {@link runWithContext} 现铸一张、经 ALS 与写侧共享;在场 ⇒ 测试注入的
|
|
409
|
-
* 固定表(此时不进 ALS 作用域,读写都走这一张)。作用域理由见 `governance-ask-marks.ts` 顶注。 */
|
|
410
184
|
governanceAskMarks;
|
|
411
|
-
/** #154 车二:持久化权限规则的**同意车道**。在场 ⇔ 规则店真装配(main.ts 与 core 的
|
|
412
|
-
* `RunnerDeps.permissionRuleStore` 同源于一个对象)⇒ ①ask 帧投 `ruleSuggestions`;②回决带
|
|
413
|
-
* `persistRule` 时兑付进店。缺席 ⇒ 两件都不做(诚实缺席,不发无处可兑的候选)。 */
|
|
414
185
|
ruleConsent;
|
|
415
|
-
/** #280 R-13 C(`UNATTENDED_APPROVAL_POLICY`,设计稿 §2):**无人可答**时这条部署要的终局。
|
|
416
|
-
* `park`(缺省)= 交 `"unavailable"` 走 core durable park;`deny` = 显式自声明「真无人值守、不积压
|
|
417
|
-
* park、要模型当场自走」⇒ 同样五臂改判 deny。**纯部署级**:不看任何客户端 posture / 表态
|
|
418
|
-
* (memory: operator-knob-must-be-unconditional —— #153 shellGate 让部署级旋钮的生死由客户端表态
|
|
419
|
-
* 决定,同病两犯过一次)。安全轴:`deny` 是收紧方向(不放行任何东西),fail-closed 铁律不受损。 */
|
|
420
186
|
unattendedPolicy;
|
|
421
|
-
/** #295(F-1,[4512] 修向 (b')):卡批「不再询问」落盘规则的 **project root 解析器**。铸卡素材时以
|
|
422
|
-
* ask 的 `sessionId` 咨询一次——「这次授权是在**哪里**点的」在授权发生时捕获,不在回决时再猜。
|
|
423
|
-
* 回 `undefined` = 该部署形上 run 的 workspace root 不可知(远程沙箱/多租户等)⇒ 不铸 scope,落
|
|
424
|
-
* core 的 global 缺省(= 现行为,恒不更宽;也绝不错铸一个坐标系不对的 root)。装配见 main.ts:
|
|
425
|
-
* registry cwd(host lane 显式注册的 launch dir)?? in-process 单用户形的 `process.cwd()`。 */
|
|
426
187
|
ruleScopeRootFor;
|
|
427
|
-
/** #329:park 路由墓碑表(键 = wire `approvalId`,插入序 = 逐出序)。语义与有界理由见
|
|
428
|
-
* {@link ParkTombstone} 顶注。 */
|
|
429
188
|
parkTombstones = new Map();
|
|
430
189
|
parkTombstoneMax;
|
|
431
190
|
parkTombstoneTtlMs;
|
|
432
|
-
/** #329:PARKED 臂的赎回席**取值口**(晚绑 —— 赎回腿要 checkpoint/bg 店与裸 Agent 工具,它们在协调器
|
|
433
|
-
* 构造之后才装配;同款 holder 先例 = main.ts 的 `getRunDenySweep`)。取到 undefined ⇒ 该臂逐字回落
|
|
434
|
-
* 修前 404,partial 部署安全。 */
|
|
435
191
|
parkedRedeem;
|
|
436
192
|
constructor(opts) {
|
|
437
193
|
this.ruleConsent = opts?.ruleConsent;
|
|
@@ -447,64 +203,15 @@ export class ToolApprovalCoordinator {
|
|
|
447
203
|
this.parkTombstoneMax = opts?.parkTombstoneMax ?? DEFAULT_PARK_TOMBSTONE_MAX;
|
|
448
204
|
this.parkTombstoneTtlMs = opts?.parkTombstoneTtlMs ?? DEFAULT_PARK_TOMBSTONE_TTL_MS;
|
|
449
205
|
}
|
|
450
|
-
/**
|
|
451
|
-
* #280 R-13 C —— 「**无人可答**」这一类终局的**唯一**成形口(park 路由 vs deny 政策)。
|
|
452
|
-
*
|
|
453
|
-
* 五臂(设计稿 §1 的表)全部读它,`unattendedPolicy` 因此是一处施加、五处消费:
|
|
454
|
-
* (a) 有店窗到期 `expireAsk` 赢 CAS / (b) 无店窗到期(D1)/ (c) 有店但 `expireAsk` 报错
|
|
455
|
-
* —— 三条走 `windowRouteUnavailable` 标志,在 {@link shapeOutcome}(askBroadcast 内)成形;
|
|
456
|
-
* (d) `windowZero`(装配层短路)与无 ALS 上下文的 headless 腿 —— 走本方法/{@link ask};
|
|
457
|
-
* (e) 断连 `forceParkNow` / emit 全灭 / 写侧准入超限 —— 走本方法。
|
|
458
|
-
*
|
|
459
|
-
* **持久回放的两个口也归它管**(codex R1-F1 验真后收口,红先钉在
|
|
460
|
-
* `test/unattended-approval-policy.test.ts`):`convergeFromDurableState` 与 `ensureAsk` 幂等重入,
|
|
461
|
-
* 只要回放出来的是 `"unavailable"`(行 PARKING/PARKED)就过这个口。理由:那三处与臂 (a) 是**同一件
|
|
462
|
-
* 事**(本 ask 的等待以「行进了投递面」收场,赢家可能是别的副本 / 上一次调用),不统一的话同一只 ask
|
|
463
|
-
* 的终局会取决于「谁赢了那把 CAS」「重试没重试」—— 比残余更坏的不可解释性。人的**真决议**(回放出
|
|
464
|
-
* `true`/`false`)当然不过这个口:那不是「无人可答」。
|
|
465
|
-
*
|
|
466
|
-
* 🔴 **不**归它管的一类(刻意,不是遗漏):`ensureAsk` **身份未定 / 坏行**两臂 —— 那里的 park 路由是对
|
|
467
|
-
* **身份不确定**的处置(与「有没有人」无关),且背后可能正躺着一条真行。于是 `deny` 部署上仍可能落
|
|
468
|
-
* 极少数 park(店抖动期),已知且有意的残余,成文在 config-types 的域注。
|
|
469
|
-
*
|
|
470
|
-
* ⚠️ **`deny` 部署上持久行仍会经 PARKING 记账**(codex R1-F1 的另一半,验真后**不采纳**其「给 deny
|
|
471
|
-
* 造一条原子 deny 终态转移」的建议):那是店语义变更(新转移 + 批内兄弟连坐语义 + 真双库套件),且与
|
|
472
|
-
* 已裁的设计稿(§2:(a) 在 deny 下只改交给 core 的终局)相左。**危害亲验为零**:收敛器判据①的
|
|
473
|
-
* `bindBatch` 只把 PARKING 行绑到**已经存在的 core checkpoint** 上,而 `deny` 部署里 core 从不 park
|
|
474
|
-
* ⇒ 结构上绑不上,该行按判据②/④/⑤ 自行收敛成 DENIED/VOID,不会凭空生出一张 park 或幻影 gate。
|
|
475
|
-
* 残余 = 那类行的归因会记成 `routing_failure`(其实是政策 deny),归因精化登记为候件。
|
|
476
|
-
*/
|
|
477
206
|
unattendedOutcome() {
|
|
478
207
|
return this.unattendedPolicy === "deny" ? false : "unavailable";
|
|
479
208
|
}
|
|
480
|
-
/** {@link unattendedOutcome} 的**公开**口:`windowZero` 的 immediate-unavailable 闭包长在装配层
|
|
481
|
-
* (`http/routes/tasks.ts` 的 sync 腿),它必须与协调器内五臂读**同一个**旋钮值,而不是各算一遍。 */
|
|
482
209
|
unattendedAskOutcome() {
|
|
483
210
|
return this.unattendedOutcome();
|
|
484
211
|
}
|
|
485
|
-
/**
|
|
486
|
-
* #151 车3 刀 3b —— design/172 §3.3 / 设计稿 §0 X-2 的**写侧准入门**。
|
|
487
|
-
*
|
|
488
|
-
* 位置(硬条款):在落 `pending`、建 timer、`ensureAsk` 落行、发帧**之前**。资源分配发生在**创建侧**
|
|
489
|
-
* (每只未决 ask 各带一条持久行 + 一只 timer + 一个 promise + 一份 SSE 载荷;`pending` map 本身无容量
|
|
490
|
-
* 上限,`MAX_ALLOW_SESSIONS` 只管 grant 集合),所以回决腿与恢复扫描都只能在**已经分配之后**动手 ——
|
|
491
|
-
* 门必须长在这里。
|
|
492
|
-
*
|
|
493
|
-
* 超限的处置是 `"unavailable"`(park 路由),**缺省 park 政策下永不 deny**:过载是**我方**的容量事实,不是人对这次
|
|
494
|
-
* 操作的判断;用 deny 表达过载会把一次「本可以 park 后由人补批」的操作变成任务失败(§3.3 硬条款)。
|
|
495
|
-
*
|
|
496
|
-
* 只在 `askStore` 在场(= 协议开着)时把关:开关关闭时本方法恒放行,现行为逐字不变(D1/A-1)。
|
|
497
|
-
*
|
|
498
|
-
* ⚠️ **已认领的偏离**(设计 X-2 字面要求「计数在持久层做」):v1 是**进程内**计数 —— 它对单副本完整
|
|
499
|
-
* 有效,多副本下每个副本各自把关(真实上限 = N × 帽)。理由=持久层计数要么在每只 ask 的热路径上多一次
|
|
500
|
-
* 往返(与「门必须在分配之前」叠加成两跳),要么引入一张新的计数表与它自己的收敛问题;而本门的目的是
|
|
501
|
-
* **防单腿失控**(一个疯狂 ask 的 run 打爆本副本的内存/连接),那正是进程内计数能完整覆盖的形。
|
|
502
|
-
* 多副本级的总量控制登记为后续件(汇报存疑单)。
|
|
503
|
-
*/
|
|
504
212
|
admit(taskId, owner) {
|
|
505
213
|
if (!this.askStore)
|
|
506
|
-
return () => undefined;
|
|
507
|
-
// JSON 元组编码(同 sessionAllowKey 先例):`null` 与字面量 "null" 的 principal 不可能算出同一把键。
|
|
214
|
+
return () => undefined;
|
|
508
215
|
const ownerKey = JSON.stringify(["owner", owner]);
|
|
509
216
|
const taskCount = this.admitByTask.get(taskId) ?? 0;
|
|
510
217
|
const ownerCount = this.admitByOwner.get(ownerKey) ?? 0;
|
|
@@ -515,7 +222,7 @@ export class ToolApprovalCoordinator {
|
|
|
515
222
|
let released = false;
|
|
516
223
|
return () => {
|
|
517
224
|
if (released)
|
|
518
|
-
return;
|
|
225
|
+
return;
|
|
519
226
|
released = true;
|
|
520
227
|
const t = (this.admitByTask.get(taskId) ?? 1) - 1;
|
|
521
228
|
if (t <= 0)
|
|
@@ -529,15 +236,6 @@ export class ToolApprovalCoordinator {
|
|
|
529
236
|
this.admitByOwner.set(ownerKey, o);
|
|
530
237
|
};
|
|
531
238
|
}
|
|
532
|
-
/**
|
|
533
|
-
* #154 车二:一只 ask 的**规则车道素材**,或 `undefined`(= 本 ask 不投候选、不接受 `persistRule`)。
|
|
534
|
-
*
|
|
535
|
-
* 三个合取项(缺一即 `undefined`,理由逐字见调用点上方注):①店在场 ②引擎铸了候选 ③命令原字节可读。
|
|
536
|
-
* `req.args` 是 `unknown` ⇒ 窄读,**禁裸 as-cast**(宪法 [2704]:一个形状漂了的 args 若被 cast,
|
|
537
|
-
* 会把 `undefined` 当命令送进 `prepareCardApproval`,那是放宽面上的静默垃圾)。
|
|
538
|
-
*/
|
|
539
|
-
/** 两份候选是否**逐条逐键**相等(顺序即展示序 ⇒ 顺序敏感)。两侧都缺席 = 相等;一侧缺席 = 不等。
|
|
540
|
-
* 用在幂等重入的「行 = 真源」对账上(见 `ensureAsk` 那段撤回臂)。 */
|
|
541
239
|
static ruleSuggestionsEqual(a, b) {
|
|
542
240
|
if (a === undefined || b === undefined)
|
|
543
241
|
return a === b;
|
|
@@ -548,36 +246,13 @@ export class ToolApprovalCoordinator {
|
|
|
548
246
|
buildRuleLaneMaterial(req, owner, governanceForced, sessionId) {
|
|
549
247
|
if (this.ruleConsent === undefined)
|
|
550
248
|
return undefined;
|
|
551
|
-
// 🔴 **治理档的 ask 不进规则车道**(#204 件7)。`governanceForced` 的语义(wire 契约逐字)是「这只
|
|
552
|
-
// ask 的门来自运维治理层,客户端表态掀不掉」;而一条持久规则会让引擎侧的规则命中臂短路掉后续
|
|
553
|
-
// **同命令**的 ask(core `hooks.js` 只排除 `decisionReason === "hook"` 与 `requiresRealApproval`),
|
|
554
|
-
// 于是一次「不再询问」= 静默拆掉 operator 的档位,且拆完之后遥测里连 ask 都不再出现。
|
|
555
|
-
// 与下面 `owner === null` 同族:这是**铸侧第一道门**,回决那侧 `persistRuleAfterDecision` 有同源的
|
|
556
|
-
// 第二道(拒因 `rule_governance_forced`)。两侧同源的理由见那一行。
|
|
557
249
|
if (governanceForced)
|
|
558
250
|
return undefined;
|
|
559
|
-
// 🔴 **归属人也是合取项**(codex 对抗复审 R1-F1,#203,验真后修)。这一条与回决那侧
|
|
560
|
-
// `persistRuleAfterDecision` 的第二道门(`owner === null ⇒ rule_lane_unavailable`)**同源** ——
|
|
561
|
-
// 一条规则是「**某个人**的持久同意」,没有人就没有桶可写。两侧不同源时的后果不是多问一次,而是
|
|
562
|
-
// 一张卡上渲出一格按下去恒被拒的「不再询问」= 本域顶注自己定义的 wire 谎言。
|
|
563
|
-
// 它在 #203 之前碰不到:local 车道没有规则店 ⇒ 上一行就返回了;本车给 local 接上 File 店之后,
|
|
564
|
-
// 「单机 + 无 principal」的部署第一次同时满足其余三项。
|
|
565
251
|
if (owner === null)
|
|
566
252
|
return undefined;
|
|
567
253
|
const rawSuggestions = req.ruleSuggestions;
|
|
568
254
|
if (!Array.isArray(rawSuggestions) || rawSuggestions.length === 0)
|
|
569
255
|
return undefined;
|
|
570
|
-
// 🔴 **基数帽在这里扣,而且只在这里扣**(重扫二轮红先修)。此前同步腿对这一格**零执法**:两族帧与
|
|
571
|
-
// `card_json` 都是逐字透传,真正按 {@link MAX_RULE_SUGGESTIONS} 截的只有耐久腿
|
|
572
|
-
// (`boundedRuleSuggestions`)。于是上游给 5 条时:live 帧发 5 条(契约文 §4a-bis 却把「server 执法帽 4」
|
|
573
|
-
// 写在**活卡帧**这条载体上,消费端按 ≤4 布局)、`card_json` 落 5 条,而一切读面都过
|
|
574
|
-
// `ApprovalCardSchema`(`ruleSuggestions.max(4)`)⇒ 判假 ⇒ 重放腿跳过 + `persisted-row-unusable` 臂返
|
|
575
|
-
// `"unavailable"` ⇒ **整只 ask 走 park**。那条「容忍余量」的设计意图在同步腿上完全没兑现,只是把悬崖
|
|
576
|
-
// 从 2 挪到了 4。
|
|
577
|
-
// 截在**素材铸点**而不是 `buildApprovalCard`:后者只截卡,会让 live 帧(5 条)与卡(4 条)分家,违背
|
|
578
|
-
// `approval-card.ts` 顶注自证的「wire 帧与 card_json 用同一份已洗素材,不是两处各算一遍的巧合」。
|
|
579
|
-
// 单一属主收在这里,于是**两族帧 + `card_json` + 回决口 `rule_not_offered` 的等式左边**拿的是同一份 ——
|
|
580
|
-
// 没渲出来的候选也就恒兑不动(反过来才是真扩权面)。保序取前 N:core 的 `exact` 形恒在 index 0。
|
|
581
256
|
const suggestions = rawSuggestions.length > MAX_RULE_SUGGESTIONS ? rawSuggestions.slice(0, MAX_RULE_SUGGESTIONS) : rawSuggestions;
|
|
582
257
|
const args = req.args;
|
|
583
258
|
if (args === null || typeof args !== "object" || Array.isArray(args))
|
|
@@ -586,8 +261,6 @@ export class ToolApprovalCoordinator {
|
|
|
586
261
|
if (typeof command !== "string" || command === "")
|
|
587
262
|
return undefined;
|
|
588
263
|
const boundInputHash = readBoundInputHash(req);
|
|
589
|
-
// #295(F-1):root 在**铸卡时**咨询——素材是「这次授权」的快照,scope 属于快照的一部分(回决可能
|
|
590
|
-
// 发生在断连重连/另一副本上,那时再解析等于让落盘 scope 取决于回决路径,而不是授权发生地)。
|
|
591
264
|
const scopeRoot = this.ruleScopeRootFor?.(sessionId);
|
|
592
265
|
return {
|
|
593
266
|
command,
|
|
@@ -597,11 +270,6 @@ export class ToolApprovalCoordinator {
|
|
|
597
270
|
...(scopeRoot !== undefined ? { scopeRoot } : {}),
|
|
598
271
|
};
|
|
599
272
|
}
|
|
600
|
-
/** #241([3731]/[3730] 双属主裁定):把某 wire run 的**流内未决 ask** 立即转 durable park——断连支专用。
|
|
601
|
-
* 匹配键=`ctxTaskId`(wire run id)。该字段的顶注说它「不是清扫判据」——那是因为清扫要判**连接**的
|
|
602
|
-
* 生死(同 id 可被两个 ctx 实例复用);本臂语义不同:调用方就是该 wire run 的承载流,断连=这个 id 的
|
|
603
|
-
* 流没了,值相等即目标集;极端复用形下多转的那条也只是从流内卡变 durable 卡,方向保守。
|
|
604
|
-
* 返回触发条数(0 = 无流内未决 ask,调用方照旧 abort)。幂等:已结算条目跳过,重复调用无害。 */
|
|
605
273
|
parkOpenAsksForTask(wireTaskId) {
|
|
606
274
|
let n = 0;
|
|
607
275
|
for (const e of this.pending.values()) {
|
|
@@ -612,14 +280,9 @@ export class ToolApprovalCoordinator {
|
|
|
612
280
|
}
|
|
613
281
|
return n;
|
|
614
282
|
}
|
|
615
|
-
/** 测试/可观测性钩子(X-2):某 (taskId) 维当前占用的准入名额数。 */
|
|
616
283
|
admittedCount(taskId) {
|
|
617
284
|
return this.admitByTask.get(taskId) ?? 0;
|
|
618
285
|
}
|
|
619
|
-
/** #151 车2(D5 store 故障姿势):任何 store 调用 throw ⇒ fail-open 到进程内机械照旧(store 是记账/
|
|
620
|
-
* 收敛层,不是投递面——裁决可用性不因它抖动而降级),一次性 `logger.warn`(同实例只报第一次,后续
|
|
621
|
-
* 只计数)。**区分**「store threw」(本方法专管)与「CAS 输」(如实拒绝,D2 必须服从,绝不算故障、
|
|
622
|
-
* 绝不走本方法)。 */
|
|
623
286
|
noteStoreError(err, where) {
|
|
624
287
|
this.storeErrorTally++;
|
|
625
288
|
if (!this.storeErrorWarned) {
|
|
@@ -630,23 +293,8 @@ export class ToolApprovalCoordinator {
|
|
|
630
293
|
});
|
|
631
294
|
}
|
|
632
295
|
}
|
|
633
|
-
/** #151 车5(R2-5):给一次 store 调用套墙钟上限。超时 = 以 `Error` 拒绝 ⇒ 调用点既有的 catch(D5
|
|
634
|
-
* fail-open / 防御性复核)原样接住,不需要为超时新增一条语义。定时器一律 `unref`(绝不持住进程),
|
|
635
|
-
* 竞速输的那一路由 `Promise.race` 自己的 rejection handler 接住(不会变成 unhandled rejection)。 */
|
|
636
296
|
withStoreDeadline(op, label, timeoutMs = DURABLE_CALL_TIMEOUT_MS, onLateSuccess) {
|
|
637
297
|
let timedOut = false;
|
|
638
|
-
// 🔴 codex 交叉复审 round3 R3-1(2026-08-06 真缺陷):deadline **只能取消等待,取消不了副作用** ——
|
|
639
|
-
// store 没有 abort 面,一条超时的 `expireAsk` 事务完全可能**稍后真的提交**(行进 PARKING、兄弟被原子
|
|
640
|
-
// 撤成 VOID)。旧形把那次结果整个丢掉,于是:兄弟的本地条目永远等不到 settle(挂死),撤卡帧一张不发
|
|
641
|
-
// (壳上留着已作废的卡)。判据不能是「我等到了吗」,得是「它到底做成了吗」。
|
|
642
|
-
// 修法:保留原 promise 的续接 —— 迟到的成功仍然把该做的收尾做完(`onLateSuccess`)。这不是「取消副作用」
|
|
643
|
-
// (那要 store 层给可中止事务,超本车范围),而是**不丢失**副作用;本地终局那一半由 `settle` 的幂等性
|
|
644
|
-
// 保证不被二次改写。回调自身抛错一律吞掉(收尾的收尾不许再变成故障源)。
|
|
645
|
-
// ⚠️ 迟到成功的观测器必须**旁挂**,不能挂进返回链(`op.then(...)` 会多插一个 microtask 跳,而本方法
|
|
646
|
-
// 包着的第一个调用点是 `ensureAsk` —— 它在开卡帧发射**之前**,多一跳就把「卡什么时候出现」整体推后
|
|
647
|
-
// 一格;存量用例用 `await Promise.resolve()` 计跳等这张卡,会静默拿到 undefined 的 approvalId)。
|
|
648
|
-
// 旁挂 = 观测得到、时序零影响(codex round4 的同款建议)。两个 handler 都给:`op` 的 rejection 因此
|
|
649
|
-
// 恒有处理者,不会变成 unhandled rejection。
|
|
650
298
|
if (onLateSuccess) {
|
|
651
299
|
void op.then((value) => {
|
|
652
300
|
if (!timedOut)
|
|
@@ -655,13 +303,9 @@ export class ToolApprovalCoordinator {
|
|
|
655
303
|
onLateSuccess(value);
|
|
656
304
|
}
|
|
657
305
|
catch {
|
|
658
|
-
/* 收尾的收尾不许成为故障源 */
|
|
659
306
|
}
|
|
660
307
|
}, () => undefined);
|
|
661
308
|
}
|
|
662
|
-
// R4-3:正常落定时**清掉**定时器 —— `unref` 只让它不持住进程,不释放定时器本身与它闭包里捕获的
|
|
663
|
-
// (coordinator / ctx / 回调)引用。审批流量持续时,这就是一堆本可立刻回收的定时器与堆。清定时器
|
|
664
|
-
// **不影响**迟到成功的续接:那一半挂在 `tracked` 上,与本定时器无关。
|
|
665
309
|
let timer;
|
|
666
310
|
const deadline = new Promise((_resolve, reject) => {
|
|
667
311
|
timer = setTimeout(() => {
|
|
@@ -671,8 +315,6 @@ export class ToolApprovalCoordinator {
|
|
|
671
315
|
timer.unref?.();
|
|
672
316
|
});
|
|
673
317
|
const raced = Promise.race([op, deadline]);
|
|
674
|
-
// 清定时器同样**旁挂**(`.finally()` 会给返回链再插一跳,理由同上面的观测器):调用方拿到的就是
|
|
675
|
-
// `raced` 本身,时序零影响;清理晚一个 microtask 发生,对定时器毫无影响。
|
|
676
318
|
const clear = () => {
|
|
677
319
|
if (timer !== undefined)
|
|
678
320
|
clearTimeout(timer);
|
|
@@ -680,116 +322,30 @@ export class ToolApprovalCoordinator {
|
|
|
680
322
|
void raced.then(clear, clear);
|
|
681
323
|
return raced;
|
|
682
324
|
}
|
|
683
|
-
/** 测试/可观测性钩子(D5):store 调用失败的累计次数(第一次触发 warn,其余只计数——本方法让「只计数」
|
|
684
|
-
* 那部分可断言)。 */
|
|
685
325
|
storeErrorCount() {
|
|
686
326
|
return this.storeErrorTally;
|
|
687
327
|
}
|
|
688
|
-
/** #151 车2(codex 交叉复审 round3 抓获、round5 精化,真 finding,{@link pendingByAskId} 顶注有完整
|
|
689
|
-
* 背景):某个 ask 赢下持久 CAS 时,同一 askId 下**全部**其余本地条目(批内被原子撤卡的兄弟、或同一
|
|
690
|
-
* askId 的重复本地注册——两类成因,{@link pendingByAskId} 顶注)都对"这只 askId 其实已经有了终局"一无
|
|
691
|
-
* 所知,任其自生自灭会让它们的 TTL 形同虚设(round2 之后,它们自己的窗到期/取消一旦发现 CAS 已经干净
|
|
692
|
-
* 地输,只会「什么都不做」,永远等不到本地事件驱动 settle)。这里直接反查这些 askId 各自的本地 pending
|
|
693
|
-
* 条目集合并逐个调用它们自己的 `settle` 闭包(复用它们自己的 timer 清理/abort 监听器摘除/pending
|
|
694
|
-
* 删除),把远程/其他调用栈发生的终局如实同步回本地——查无对应条目(不在这个副本、或从未真正注册过)
|
|
695
|
-
* 是正常情况,静默跳过。
|
|
696
|
-
*
|
|
697
|
-
* 🔴 **本口只收「行真的没了」那一类**(#280 codex R3-F2,验真后拆分;红先钉在
|
|
698
|
-
* `test/unattended-approval-policy.test.ts`):`expireAsk` 返回的 `voidedSiblings` 的行是 **VOID**,
|
|
699
|
-
* 裸 `(false,"expired")` 正是它们的真终局。而**调用方自己那个 askId** 的行是 **PARKING**(已转投递
|
|
700
|
-
* 面)—— 把它混进本名单会让「同 askId 的重复本地注册」拿到裸 deny,而赢家拿的是 park 路由:同一条
|
|
701
|
-
* 持久行、两种 live 终局。那一支改走 {@link settleSameAskIdParkRoute}。 */
|
|
702
328
|
settleVoidedSiblings(askIds) {
|
|
703
329
|
for (const askId of askIds) {
|
|
704
330
|
const entries = this.pendingByAskId.get(askId);
|
|
705
331
|
if (!entries)
|
|
706
332
|
continue;
|
|
707
333
|
for (const entry of [...entries])
|
|
708
|
-
entry.settle(false, "expired");
|
|
334
|
+
entry.settle(false, "expired");
|
|
709
335
|
}
|
|
710
336
|
}
|
|
711
|
-
/**
|
|
712
|
-
* #280 codex R3-F1 —— 「行上此刻有没有一个**真决议**」的一次性窄读(窗到期臂的店报错支专用)。
|
|
713
|
-
*
|
|
714
|
-
* 返回 `true`/`false` = 行是 `DECIDED` 且决议是 approve/deny(**人的**裁决,或车4 端点代人落的那一次);
|
|
715
|
-
* `undefined` = 其它一切(读失败、行不在、行是 STREAM_PENDING/PARKING/PARKED/DENIED/VOID)——调用方按
|
|
716
|
-
* 自己的臂继续。判别复用 {@link replayTerminalAskRow}(不造第二套映射),这里只把它的**布尔臂**取出来:
|
|
717
|
-
* `"unavailable"`(PARKING/PARKED)刻意**不**在本口消费,那属于路由面、归 `unattendedPolicy` 管。
|
|
718
|
-
*
|
|
719
|
-
* 一次读 + 墙钟 deadline(不重试):店已经在报错的语境里,重试只会把窗到期的收尾拖成秒级,而这一口要
|
|
720
|
-
* 回答的问题(「有没有人已经答过了」)读一次就够 —— 读不到就按「不知道」处置,与本臂原语义一致。
|
|
721
|
-
*
|
|
722
|
-
* ⚠️ **已登记的残余(codex R4-F1/F2,验真后不在本批修)**:本读与随后的本地 settle 之间没有持久 CAS,
|
|
723
|
-
* 所以「读到 STREAM_PENDING、另一副本**随后**才提交 DECIDED」这条窄窗仍会得到 park/deny 终局(行是
|
|
724
|
-
* approve 而本腿不执行);`emit` 全灭臂与取消臂的 store-未知态 catch 有**同形**缺口(两者都早于本件、
|
|
725
|
-
* 各自有既有判据钉着)。方向仍是 fail-closed(没有任何东西在未经人批时执行),补偿是 core 侧那张仍然
|
|
726
|
-
* pending 的 checkpoint —— 人再批一次就走。真正的收口 = 把窗/emit-全灭/取消三条 ambiguous-CAS catch
|
|
727
|
-
* 统一到一次**持久**恢复流程(带 CAS 与终态回读),那是店语义级改动,须由本模块属主立设计件,施工车
|
|
728
|
-
* 不私开(本批已试过整段复用 `convergeFromDurableState`,被自己的全量跑打回,理由见其顶注)。
|
|
729
|
-
*
|
|
730
|
-
* ⚠️ **第二条已登记的残余(A-054.15,2026-08-19 合并重扫 confirmed)**:本口读出的 `true` 交给 `settle`
|
|
731
|
-
* 时第三形参 `updatedInput` 恒缺席 —— 行上没有那一列,读不出「这次批准是否带过 ctrl+g 编辑」。上一条
|
|
732
|
-
* 残余的方向是 fail-closed(该跑的没跑),这一条**反过来**:core 的 `d.updatedInput === undefined` 分支
|
|
733
|
-
* 落回原始未改写实参执行 ⇒ 人批的是改写后的命令、真跑的是原命令 = 执行面**宽于**人所批准。因此它不能
|
|
734
|
-
* 只靠注释登记,已按 CLAUDE.md #157 记 `P-DEBT` 债({@link recordRowDerivedApproveReplay},四个同形站点
|
|
735
|
-
* 同一个 tag)。收口 = 给 ask 行加 `updated_input` 列(店语义级,须属主立件),届时债与 tag 同批销。
|
|
736
|
-
*/
|
|
737
|
-
/**
|
|
738
|
-
* 🔴 session grant 短路前的**持久出处复核**(A-054.20 的 codex R1-[high] 补丁;A-057.1 换判据)。
|
|
739
|
-
*
|
|
740
|
-
* 回 `true` = 「这只 ask 从来没有过持久出处」⇒ 一揽子放行可以短路;
|
|
741
|
-
* 回 `false` = 行在(任何状态),**或**读不出来(不知道 = 不放宽)⇒ 调用方必须往下走真出卡。
|
|
742
|
-
*
|
|
743
|
-
* 为什么只读不写:短路的全部价值就是「不铸卡、不落行、不占 pending」,做一次主键点读保住这个价值,
|
|
744
|
-
* 而 `ensureAsk` 会写行。为什么行不在就放行:确定性 askId 的行只可能由**这只 ask 自己**铸出来,
|
|
745
|
-
* 行不在 ⇒ 从来没有过一份能反驳本地判据的持久出处(这也是常见形:grant 命中的 ask 多半是头一次到)。
|
|
746
|
-
*
|
|
747
|
-
* ## 判据的单一原则:**短路不得产生与「没有 grant 时那条路」不同的终局**
|
|
748
|
-
*
|
|
749
|
-
* 没有 grant 时,同一条行在下面 `askBroadcast` 里都有确定去处:终局行(DECIDED/PARKED/DENIED/VOID)
|
|
750
|
-
* 走 `replayTerminalAskRow`(deny 回放 `false`、park 回放 `"unavailable"`);活着的 STREAM_PENDING 行走
|
|
751
|
-
* `ensureAsk` 的幂等命中(采信行上的卡与 deadline,等真决议)。短路一旦 return,这两条腿结构上都够不着
|
|
752
|
-
* —— 所以只要行在,短路就是在用一个**更宽**的答案顶替那条路。⇒ 判据 = `row === null`。
|
|
753
|
-
*
|
|
754
|
-
* 这条原则是分两次到位的,两次漏的都是「行 = 真源」的一部分:
|
|
755
|
-
* · **A-054.20**(codex R1-[high]):首版只查**本地** peek,行上标治理的那一半漏了 —— 本地标表是
|
|
756
|
-
* 进程内表,failover / 重启后幂等命中既有行时它恒空。
|
|
757
|
-
* · **A-057.1**(2026-08-19 三轴组复审,refuter 真运行复现三形):补丁把行读到手里,却只看
|
|
758
|
-
* `card.governanceForced` 一个位 —— 行处于 `DECIDED=deny`(人已明确拒过这只调用)时照样回 `true`,
|
|
759
|
-
* 一揽子放行把一次**已落盘的人类拒绝**在重入 / failover / deny-后到 三形里翻成放行(帧 0、行不变、
|
|
760
|
-
* 无 recordFailOpen ⇒ 遥测零痕迹)。
|
|
761
|
-
* · **codex 对抗复审**(A-057.1 同批,验真后采纳):只补终态判据仍漏「卡还在别处挂着」——
|
|
762
|
-
* STREAM_PENDING 行意味着已有一张卡在等人,凭 grant 抢跑会让行上的终局与真实执行长期矛盾
|
|
763
|
-
* (那张卡随后可被人/另一副本决成相反答案,或 TTL 到点被收敛成幻影 gate)。
|
|
764
|
-
* 三次都不是各自独立的 bug,是同一句话没说全,故最终判据收成一条而不是三个分支。
|
|
765
|
-
*
|
|
766
|
-
* 零额外 IO(行已经在手里),且 fail-closed **by construction**:将来 `AskState` 加词也不需要动这里。
|
|
767
|
-
*
|
|
768
|
-
* 🔴 **已登记的残余(A-057.60,PARTIAL,本批不修)**:这一步是一次**非原子的 check-then-act** ——
|
|
769
|
-
* 另一副本正在为同一确定性 askId 落行、而本次点读抢在其提交前完成时,本副本仍会短路。真解 = 把出处
|
|
770
|
-
* 判定与 grant 消费合并成**同一次**原子存储操作(带条件 upsert / CAS),属店语义级改动,须本模块属主
|
|
771
|
-
* 立件(与上面 R4-F1/A-054.15 两条同源残余同一处置)。「行不在 ⇒ 不短路」不是修法:grant 命中的 ask
|
|
772
|
-
* 多半头一次到,那等于把一揽子放行整只废掉。窗的上界由本判据定死:行**一提交**窗即关。
|
|
773
|
-
*/
|
|
774
337
|
async sessionGrantUncontestedByRow(origin, req) {
|
|
775
338
|
if (!this.askStore)
|
|
776
|
-
return true;
|
|
339
|
+
return true;
|
|
777
340
|
const sourceTaskId = req.sourceTaskId ?? origin.taskId;
|
|
778
341
|
const askId = deriveAskId(sourceTaskId, origin.taskId, req.toolCallId, origin.legKey, req.delegation?.parentToolCallId);
|
|
779
342
|
try {
|
|
780
343
|
const row = await this.withStoreDeadline(this.askStore.getAsk(askId), "getAsk(session-grant-provenance)", DURABLE_CONVERGE_READ_TIMEOUT_MS);
|
|
781
|
-
// 🔴 A-057.1 + codex 对抗复审(同函数,验真后采纳):**有行即不短路**。
|
|
782
|
-
// 判据不再按状态分支,理由见顶注:短路不得产生与「没有 grant 时那条路」不同的终局,而**任何**
|
|
783
|
-
// 状态的行在无 grant 时都有确定去处 —— 终局行走 `replayTerminalAskRow`(deny 回 false、park 回
|
|
784
|
-
// "unavailable"),活着的 STREAM_PENDING 行走 ensureAsk 的幂等命中(采信行上的卡、等真决议)。
|
|
785
|
-
// 早先那版按 `governanceForced` 一个位判,漏的是「人已落盘的 deny」;只补终态判据仍漏「卡还在
|
|
786
|
-
// 别处挂着」——凭 grant 抢跑会让行上的终局与真实执行长期矛盾(那张卡随后可被人决成相反答案,
|
|
787
|
-
// 或 TTL 到点被收敛成幻影 gate)。两处漏的是同一件事:**行是真源**。
|
|
788
344
|
return row === null;
|
|
789
345
|
}
|
|
790
346
|
catch (err) {
|
|
791
347
|
this.noteStoreError(err, "getAsk(session-grant-provenance)");
|
|
792
|
-
return false;
|
|
348
|
+
return false;
|
|
793
349
|
}
|
|
794
350
|
}
|
|
795
351
|
async readDecidedForRecovery(askId) {
|
|
@@ -807,19 +363,13 @@ export class ToolApprovalCoordinator {
|
|
|
807
363
|
return undefined;
|
|
808
364
|
}
|
|
809
365
|
}
|
|
810
|
-
/** #280 codex R3-F2:同一 askId 下**其余本地注册**(重复注册,见 {@link pendingByAskId} 顶注)按
|
|
811
|
-
* **park 路由**收尾 —— 行已 PARKING,它们看的是同一条行,终局必须与赢家同形(`unattendedPolicy`
|
|
812
|
-
* 由各自的闭包读同一个口,deny 部署上一起是 deny)。赢家自己此刻已 settle 过、已从集合里摘除,
|
|
813
|
-
* 故对它重复调用天然无操作。 */
|
|
814
366
|
settleSameAskIdParkRoute(askId) {
|
|
815
367
|
const entries = this.pendingByAskId.get(askId);
|
|
816
368
|
if (!entries)
|
|
817
369
|
return;
|
|
818
370
|
for (const entry of [...entries])
|
|
819
|
-
entry.settleParkRoute();
|
|
371
|
+
entry.settleParkRoute();
|
|
820
372
|
}
|
|
821
|
-
/** #329:落一条 park 墓碑(容量帽按**插入序**逐出最旧,与 `allowAllSessions` 同款有界纪律)。
|
|
822
|
-
* 调用点唯一 = `settle` 里的 park 路由支(见那处注);真终局绝不调它。 */
|
|
823
373
|
recordParkTombstone(approvalId, tomb) {
|
|
824
374
|
if (this.parkTombstones.size >= this.parkTombstoneMax) {
|
|
825
375
|
const oldest = this.parkTombstones.keys().next().value;
|
|
@@ -828,8 +378,6 @@ export class ToolApprovalCoordinator {
|
|
|
828
378
|
}
|
|
829
379
|
this.parkTombstones.set(approvalId, tomb);
|
|
830
380
|
}
|
|
831
|
-
/** #329:取墓碑(**惰性清扫**:过期条目读到即删,不另起定时器 —— 这张表只在迟到回决那一刻被读)。
|
|
832
|
-
* 属主门在调用方(与 live 腿同一句判据:`owner === null` 或逐字相等,否则按不存在处置)。 */
|
|
833
381
|
lookupParkTombstone(approvalId) {
|
|
834
382
|
const tomb = this.parkTombstones.get(approvalId);
|
|
835
383
|
if (tomb === undefined)
|
|
@@ -840,16 +388,6 @@ export class ToolApprovalCoordinator {
|
|
|
840
388
|
}
|
|
841
389
|
return tomb;
|
|
842
390
|
}
|
|
843
|
-
/**
|
|
844
|
-
* #151 车4 §12-E(F29/F30 裁定形):外部回决(车4 端点或本类 respond 腿)**赢下持久 CAS 之后**,把同
|
|
845
|
-
* askId 下全部本地悬挂条目按**真实决议**结算——端点已是唯一权威(CAS 已落),本地只是同步终局;查无
|
|
846
|
-
* 条目(跨副本/无流内窗)= 正常,返回 `{ settled: 0 }`,调用方据此诚实回显 `updatedInputForwarded`。
|
|
847
|
-
*
|
|
848
|
-
* 🔴 与 {@link settleVoidedSiblings} 是**两个语义,禁合并**(F29):那边是撤卡收尾,结算值恒
|
|
849
|
-
* `(false, "expired")`——批内兄弟被原子撤卡,它们的终局就是「没了」;这边是「同一只 askId 已经有了
|
|
850
|
-
* 真决议」,重复注册必须拿到**同一个**决议(approve 就是 approve)——合并会把外部批准的重复条目错
|
|
851
|
-
* 结算成拒绝。已被结算过的条目再调 settle 天然无操作(幂等),所以本口对「赢家自己已 settle」安全。
|
|
852
|
-
*/
|
|
853
391
|
notifyExternalDecision(askId, allowed, updatedInput) {
|
|
854
392
|
const entries = this.pendingByAskId.get(askId);
|
|
855
393
|
if (!entries)
|
|
@@ -861,37 +399,19 @@ export class ToolApprovalCoordinator {
|
|
|
861
399
|
}
|
|
862
400
|
return { settled };
|
|
863
401
|
}
|
|
864
|
-
/**
|
|
865
|
-
* #151 车6:把一张**批级撤卡帧**投给给定的一组连接(live only —— 见 `ApprovalRevokeFrame` 顶注:
|
|
866
|
-
* 撤卡帧不进 durable tail,丢帧的结构补偿是重连 preamble 的全量对账基准)。
|
|
867
|
-
*
|
|
868
|
-
* 空名单不发(零信息的帧只会让壳多一次无意义的对账)。每路独立 catch:一路 emit 失败(连接刚死、
|
|
869
|
-
* durable-append 目标抖动)**绝不回滚已经落定的持久 CAS** —— 帧是通知,行才是真源。
|
|
870
|
-
*/
|
|
871
402
|
emitRevokeTo(ctxs, frame) {
|
|
872
403
|
if (frame.askIds.length === 0)
|
|
873
404
|
return;
|
|
874
405
|
for (const c of ctxs) {
|
|
875
406
|
if (!c.emitRevoke)
|
|
876
|
-
continue;
|
|
407
|
+
continue;
|
|
877
408
|
try {
|
|
878
409
|
void Promise.resolve(c.emitRevoke(frame)).catch(() => undefined);
|
|
879
410
|
}
|
|
880
411
|
catch {
|
|
881
|
-
// 同步 throw 的 emit 目标(流已结束的那种)——吞掉:卡的作废已经是持久事实,通知投不出去
|
|
882
|
-
// 不改变它,更不回滚已经赢下的 CAS。
|
|
883
412
|
}
|
|
884
413
|
}
|
|
885
414
|
}
|
|
886
|
-
/**
|
|
887
|
-
* #151 车6 发射点③④:**进程外**收敛器(reaper 腿的 `approval-reconciler.ts`)产出的撤卡帧的投递口。
|
|
888
|
-
* 收敛器没有 ctx —— 它只有行上的 (owner, sessionId, taskId),经 broker 反查该身份下**当前**的活跃
|
|
889
|
-
* 连接集合。查无活连接 = 正常(壳不在线;重连 preamble 会把这张卡对账掉),返回 0。
|
|
890
|
-
*
|
|
891
|
-
* 两把 key 都试(session 形 + adhoc 形)并去重:行上的 `sessionId` 对 adhoc 腿折的是 taskId
|
|
892
|
-
* (车2 落行时的折叠),而 broker 的 adhoc 分桶键用的正是 taskId —— 两形各试一次比在这里猜哪种腿
|
|
893
|
-
* 更诚实,代价是一次 Map 查找。
|
|
894
|
-
*/
|
|
895
415
|
emitRevoke(frame, target) {
|
|
896
416
|
const targets = new Set();
|
|
897
417
|
for (const key of [this.streamKey(target.owner, target.sessionId, target.taskId), this.streamKey(target.owner, undefined, target.taskId)]) {
|
|
@@ -903,25 +423,10 @@ export class ToolApprovalCoordinator {
|
|
|
903
423
|
this.emitRevokeTo([...targets], frame);
|
|
904
424
|
return targets.size;
|
|
905
425
|
}
|
|
906
|
-
/** Broker key:宿主 session 优先(retained/续聊子代跨 SSE 找得到新流),sessionless adhoc 退 taskId。
|
|
907
|
-
* 并发审查加固:显式维度前缀(`"session"`/`"adhoc"`)分隔两个取值域,不再靠 `task:` 字符串前缀"祈祷"
|
|
908
|
-
* 不会跟一个真实 sessionId 字面重合——此前形式 `sessionId ?? "task:"+taskId` 里,若某会话的
|
|
909
|
-
* sessionId 恰好等于另一条 adhoc 流的 `"task:"+taskId`,两者会算出同一把 key(现网因 taskId 由
|
|
910
|
-
* 服务端 uuidv7 铸造、请求到达前不可预知而几乎不可达,但维度未加标签是编码层面的真实缺口)。 */
|
|
911
426
|
streamKey(owner, sessionId, taskId) {
|
|
912
427
|
return sessionId !== undefined ? JSON.stringify(["session", owner, sessionId]) : JSON.stringify(["adhoc", owner, taskId]);
|
|
913
428
|
}
|
|
914
|
-
/** Run `fn` with the per-run approval context ambient. On exit, notify every pending broadcast ask that this
|
|
915
|
-
* ctx is no longer a live target — an ask only dies once ALL its targets are gone (fail-closed backstop, the
|
|
916
|
-
* inverse of question's release-unanswered — but no longer a blunt "any one target exits ⇒ kill it" rule,
|
|
917
|
-
* see {@link PendingApproval.onTargetGone}).
|
|
918
|
-
* [1535] 注意([1539] core 点名的火):委派子代的 ask 经 {@link boundAsk} 落 pending 时不带
|
|
919
|
-
* `targetCtxs`/`onTargetGone`(只有 `originTaskId === undefined` 的宿主自身条目才带)——session-scoped
|
|
920
|
-
* bg 子代活过宿主 turn 时其未决卡不被这里触碰(TTL/abortSignal 仍兜底)。 */
|
|
921
429
|
runWithContext(ctx, fn) {
|
|
922
|
-
// broker 注册([1559]四 多活集合):本流加入该 (owner, session) 的活跃连接集合;退出时只摘除自己
|
|
923
|
-
// ——同 key 下真正并发的其余连接不受影响(这正是本车要修的「单指针后到覆盖先到」缺口:旧形
|
|
924
|
-
// `streams.set(key, ctx)` 会让后到者的注册整个替换掉先到者,先到连接从此收不到任何卡)。
|
|
925
430
|
const key = this.streamKey(ctx.owner, ctx.sessionId, ctx.taskId);
|
|
926
431
|
let set = this.streams.get(key);
|
|
927
432
|
if (!set) {
|
|
@@ -930,11 +435,6 @@ export class ToolApprovalCoordinator {
|
|
|
930
435
|
}
|
|
931
436
|
const liveSet = set;
|
|
932
437
|
liveSet.add(ctx);
|
|
933
|
-
// [2942]/[2943](codex round3/round5 两轮的修形):**写侧**的治理标作用域与审批 ctx 同拍进出,分格键
|
|
934
|
-
// 就是上面这把 broker `key` —— 读侧(askBroadcast)按**出处**显式算出同一把,于是委派/长命 bg 子代的
|
|
935
|
-
// ask 即便在另一条腿的上下文里发出也查得到自己的标(round5 的红先用例)。分格本身封的是 round3:
|
|
936
|
-
// `toolCallId` 是提供方给的、并非全局唯一,不分格会跨任务/跨租户串味(理由全文见 governance-ask-marks.ts)。
|
|
937
|
-
// 注入了固定表的实例(测试形)不再进作用域:读写都走那一张,行为与注入前逐字一致。
|
|
938
438
|
const enter = (body) => (this.governanceAskMarks ? body() : runWithGovernanceAskScope(key, body));
|
|
939
439
|
return enter(() => this.als.run(ctx, async () => {
|
|
940
440
|
try {
|
|
@@ -944,11 +444,6 @@ export class ToolApprovalCoordinator {
|
|
|
944
444
|
liveSet.delete(ctx);
|
|
945
445
|
if (liveSet.size === 0)
|
|
946
446
|
this.streams.delete(key);
|
|
947
|
-
// [1559]四独立复审①(HIGH)修:不再拿单个 `originCtx === ctx` 当清扫判据——多活集合下 `originCtx`
|
|
948
|
-
// 只是广播目标里任意一个成员,跟"这次 ask 到底是不是这条连接的执行栈在等它"没有必然关系,单独
|
|
949
|
-
// 靠它判死会误杀仍有其他活连接(甚至真正在等应答的那条)在场的未决卡。改为:只通知那些把本 ctx
|
|
950
|
-
// 列为广播目标之一的条目(`targetCtxs?.has(ctx)`——子代 ask 从不设 targetCtxs,天然免疫),
|
|
951
|
-
// 由 `askBroadcast` 内部的 liveConnections 倒计时决定是不是"全体目标都没了"才真正判死。
|
|
952
447
|
for (const [, p] of [...this.pending]) {
|
|
953
448
|
if (p.targetCtxs?.has(ctx))
|
|
954
449
|
p.onTargetGone?.(ctx);
|
|
@@ -956,118 +451,32 @@ export class ToolApprovalCoordinator {
|
|
|
956
451
|
}
|
|
957
452
|
}));
|
|
958
453
|
}
|
|
959
|
-
/** 测试/可观测性钩子:该 (owner, session/taskId) broker key 下当前活跃连接数——断言多连接注册/清扫
|
|
960
|
-
* 行为时用,免得伸手进私有内部状态。 */
|
|
961
454
|
liveConnectionCount(owner, sessionId, taskId) {
|
|
962
455
|
return this.streams.get(this.streamKey(owner, sessionId, taskId))?.size ?? 0;
|
|
963
456
|
}
|
|
964
|
-
/** `RunnerDeps.onAsk`. core's resolveAsk calls this for each policy `ask` on the sync leg. [879] G1 三值化
|
|
965
|
-
* (core 1.295):boolean = 人的决定;`"unavailable"` = 本 ask 到达时刻判无活人可同步送达 —— core 以
|
|
966
|
-
* approverUnavailable 回路把这只 ask 交回 suspendAsk 走 durable park(park 设施缺席的部署 core 自己
|
|
967
|
-
* fail-closed deny)。1.199 的「durable 部署不 wire onAsk」止血就此撤除:恒 wire,park 与 live 卡两全。
|
|
968
|
-
* Arrow property so it can be passed as `onAsk: coordinator.ask` with `this` bound. */
|
|
969
457
|
ask = async (req, signal) => {
|
|
970
458
|
const ctx = this.als.getStore();
|
|
971
|
-
// No run context (background/workflow/headless/durable-submit leg — [820]④) = nobody to render the card ⇒
|
|
972
|
-
// "unavailable"(交 durable park),不再把「无人可问」翻译成 deny(1.295 前无三值只能 fail-closed)。
|
|
973
|
-
// #280 R-13 C 臂(d):bg/resume 两腿的 `windowZero` 在装配层的表达就是**不包 ALS**
|
|
974
|
-
// (`resolveApprovalLeg` 顶注),于是它与 headless 腿一起落在本臂上 —— 政策取值走同一个口。
|
|
975
459
|
if (!ctx)
|
|
976
460
|
return this.unattendedOutcome();
|
|
977
|
-
// 出处 = 本 ctx 自身(ALS 腿的投递集合恒是它一个),显式传,不靠 askBroadcast 去猜。
|
|
978
461
|
return this.askBroadcast({ taskId: ctx.taskId, legKey: ctx.legKey ?? "", ...(ctx.legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic: ctx.legDeadlineMonotonic } : {}) }, [ctx], req, signal);
|
|
979
462
|
};
|
|
980
|
-
/** [1535] server 半场(审批链战役,[1539] 定谳断点①的修;[1546] HIGH-1 broker 形):宿主 sync
|
|
981
|
-
* streaming 腿在装配点以本闭包挂 `spec.onAsk`;core 继承链(parentConstraints 冻结
|
|
982
|
-
* `spec.onAsk ?? deps.onAsk`,1.294 起)把它逐 ancestor 冻给每个委派子代——子代(bg/嵌套孙代)的
|
|
983
|
-
* 权限 ask 不再依赖 ALS,直达宿主 live 流的审批卡。**闭包只携身份**(owner/taskId/sessionId),
|
|
984
|
-
* emit 时经 broker 取该 (owner, session) 的「当前」活跃流(宿主重连/续聊的新 SSE 自动接卡;
|
|
985
|
-
* internalsSnapshot 冻结的闭包因此永不携死流)。查无活流 = "unavailable"(G1 回路:durable 部署
|
|
986
|
-
* park;inherited plain-ask 的 park 回路 = core RB-39①,落地前该臂 = deny,与 1.257 前行为一致)。 */
|
|
987
|
-
/* [2942]/[2943] 与本闭包的关系(codex round4/round5 两轮追出来的形):本闭包**铸于**
|
|
988
|
-
* `runWithContext` 之前(装配点 routes/tasks.ts 就是这个顺序),而委派/长命 bg 子代经 core 继承链拿到它
|
|
989
|
-
* 之后,可能在**宿主腿早已退场**、甚至任何审批作用域之外的上下文里把 ask 发出来。治理来源标因此
|
|
990
|
-
* **不读环境**:`askBroadcast` 按 `identity`(经 `origin`/`primary`)显式算出与写侧同一把 broker key 去查
|
|
991
|
-
* ——「出处决定判定,投递到哪条连接不参与」,与 {@link AskOriginIdentity} 顶注同源。两条腿的钉见
|
|
992
|
-
* test/governance-forced-signal.test.ts(「长命 bg 子代形」与「脱钩定时器形」,改形前后者是红的)。 */
|
|
993
463
|
boundAsk = (identity) => {
|
|
994
464
|
return (req, signal) => {
|
|
995
465
|
const set = this.streams.get(this.streamKey(identity.owner, identity.sessionId, identity.taskId));
|
|
996
466
|
const live = set && set.size > 0 ? [...set] : [];
|
|
997
|
-
// #280 R-13 C 臂(e)的入口形:查无活流 = 「卡送不到任何人」的**到达时刻**版本(emit 全灭是它的
|
|
998
|
-
// 事后版本)—— 同一类事实,同一个政策口。
|
|
999
467
|
if (live.length === 0)
|
|
1000
468
|
return Promise.resolve(this.unattendedOutcome());
|
|
1001
|
-
// 🔴 codex 交叉复审(刀 3a,high)修:身份轴取**捕获的出处身份**,不取投递集合的第一条流。
|
|
1002
|
-
// `streamKey` 的 session 形不含 taskId ⇒ 同一集合里可以躺着不同 run 的 ctx,`aliveCtxs[0]` 只是
|
|
1003
|
-
// 「当下第一条活着的投递连接」。用它当 runId 会双向出错:同一只出处 ask 在集合组成变化后重入拿到
|
|
1004
|
-
// 不同 askId(幂等 upsert 失效 ⇒ 重入挂到 TTL),不同出处 run 经同一条流下发拿到同一 askId
|
|
1005
|
-
// (正是换轴要封死的复用面)。`legKey` 同理随出处走(装配点 3b 供真值,缺席折空串)。
|
|
1006
469
|
return this.askBroadcast({ taskId: identity.taskId, legKey: identity.legKey ?? "", ...(identity.legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic: identity.legDeadlineMonotonic } : {}) }, live, req, signal);
|
|
1007
470
|
};
|
|
1008
471
|
};
|
|
1009
|
-
/** 广播版裁决路径——`ask()`(ALS,恒单元素数组)与 `boundAsk`([1559]四多活集合,可能多元素)共用。
|
|
1010
|
-
* 全体 `ctxs` 保证同一 (owner, sessionId)(streamKey 分组不变式;单元素数组平凡成立)。
|
|
1011
|
-
*
|
|
1012
|
-
* [1559]三 emit-race 加固(回黑板 [1558]三1,core 确认方向):「等 emit 完成」与「等
|
|
1013
|
-
* TTL/abort/respond 三选一落定」显式 race——`emit` 的类型契约允许异步实现(durable-append 目标),
|
|
1014
|
-
* 若挂死,旧形 `await ctx.emit(...)` 会让整条 ask 乃至调用方 turn 一起卡死,TTL/abort 定时器虽已
|
|
1015
|
-
* 触发也无人读取其结果。核心不变式:race 由非人类结局(TTL/abort)先赢、且 emit 广播尚未有任何一路
|
|
1016
|
-
* 确认完成时,不可扣一个武断的「deny」终局(不知道卡是否曾送达任何人)——按 G1 回路交
|
|
1017
|
-
* "unavailable"。人类 respond() 落地的裁决永远照旧信任、不受本判据影响:它只可能在 approvalId 已经
|
|
1018
|
-
* 送达某个客户端之后发生(respond 需要客户端手上有这个 id),这是 outcome==="allowed"/"denied" 与
|
|
1019
|
-
* "expired"(TTL/abort/run-exit-cleanup/emit 广播全灭四类共用)的天然分野。 */
|
|
1020
472
|
async askBroadcast(origin, ctxs, req, signal) {
|
|
1021
|
-
// 🔴 **这一条不过政策口,是刻意的**(A-054.17 同批成文):`signal` 是 core 交给**这一只 ask** 的信号,
|
|
1022
|
-
// 它 aborted = 这次工具调用本身正在被拆掉,不是「没人可答」。core 侧同判据排在 onAsk 之前
|
|
1023
|
-
// (`resolveAsk` 先判 signal 再调 onAsk)与之后(拿到 `"unavailable"` 也照样覆盖成 deny)各有一道,
|
|
1024
|
-
// 所以这里回 park 路由既不可能被 core 采纳、也无 checkpoint 可落 —— 走政策口只会造出一条说谎的遥测。
|
|
1025
473
|
if (signal?.aborted)
|
|
1026
474
|
return false;
|
|
1027
|
-
// 连接级存活过滤:entry 时刻已死的投递目标不计入广播。
|
|
1028
|
-
// 🔴 A-054.17(2026-08-19 合并重扫 partial,存活断言):**全体目标 entry 时已死 ⇒ 走
|
|
1029
|
-
// {@link unattendedOutcome} 这一个口**,不再裸 `false`。它与 {@link boundAsk} 的「查无活流」
|
|
1030
|
-
// (`live.length === 0`,臂 (e) 的入口形)是同一件事实的两个时刻——那一条是 broker 集合已空,这一条是
|
|
1031
|
-
// 集合里躺着的连接自己已 abort;此前两者一个走政策口、一个裸 deny,于是同一句「卡送不到任何人」的
|
|
1032
|
-
// 终局取决于 core 收卷与子代 ask 到达谁先跑到。可达形:session-scoped bg / 委派子代经宿主 `boundAsk`
|
|
1033
|
-
// 发 ask(core 对这类子代刻意不挂宿主 abort 联动,子代自己的信号是活的 ⇒ 上面那条 signal 门不触发),
|
|
1034
|
-
// 落在「宿主 ctx 已 abort、`runWithContext` 的 finally 尚未摘除」的窗口里。红先钉 =
|
|
1035
|
-
// `test/unattended-approval-policy.test.ts` 的 A-054.17 格。
|
|
1036
475
|
const aliveCtxs = ctxs.filter((c) => !c.abortSignal?.aborted);
|
|
1037
476
|
if (aliveCtxs.length === 0)
|
|
1038
477
|
return this.unattendedOutcome();
|
|
1039
478
|
const primary = aliveCtxs[0];
|
|
1040
|
-
// [2942]/[2943]:治理来源标的**一次** peek(非消费式)。此前它只在下面的帧字面量里就地读 ——
|
|
1041
|
-
// #204 件7 起规则车道也要它(治理档的 ask 不进车道),两处读同一个值必须是**同一次** peek:
|
|
1042
|
-
// 分开读会让「帧说这是治理门 / 车道说不是」这种自相矛盾的卡在理论上存在。
|
|
1043
|
-
// 🔴 A-054.20 把这次 peek **提到 session-grant 短路之前**(此前它排在短路之后,结构上够不着)——
|
|
1044
|
-
// 消费点从两处变三处,但仍是**同一次** peek,上面那条不变式不变。
|
|
1045
479
|
const governanceForced = (this.governanceAskMarks ?? governanceAskMarksFor(this.streamKey(primary.owner, primary.sessionId, origin.taskId))).isMarked(req.toolCallId);
|
|
1046
|
-
// Session allow-all (CC "Yes, allow all edits during this session"): keyed [owner, sessionId, category] — the
|
|
1047
|
-
// asking tool's OWN capability category must have been granted in THIS owner's session (修2; module header).
|
|
1048
|
-
// 多活集合下 owner/sessionId 对全体 ctxs 恒同(streamKey 分组保证),primary 取值代表整个集合安全。
|
|
1049
|
-
// #287([4434]② P0):`requiresRealApproval` 的成文语义=「必须真人逐次批,一揽子放行不得覆盖」——
|
|
1050
|
-
// 这条短路正是它要禁的 blanket-allow,安全类 ask 无视既有 grant、恒往下走真出卡(core 侧持久规则
|
|
1051
|
-
// 半场已源头关死:ruleSuggestionsOf 对该位返 {};流内这半在此)。写侧配对臂见 finishRespond。
|
|
1052
|
-
// 🔴 A-054.20(2026-08-19 合并重扫 confirmed,真运行复现):**治理档的 ask 同样无视既有 grant**。
|
|
1053
|
-
// 本仓自己的教条早就成文在 `buildRuleLaneMaterial`(「一次『不再询问』= 静默拆掉 operator 的档位,
|
|
1054
|
-
// 且拆完之后遥测里连 ask 都不再出现」)与回决侧的 `rule_governance_forced` 第二道门;session grant
|
|
1055
|
-
// 是同形、只是更短命的一揽子放行,此前两侧都没门 —— 短路排在出卡/落行/发帧之前,于是同一 (owner,
|
|
1056
|
-
// session) 同能力桶的后续治理 ask 直接 `return true`,连一条 ask 痕迹都不留。wire 契约面
|
|
1057
|
-
// (docs/ASSISTANT-WIRE-CONTRACT.md 的 governanceForced 段)逐字承诺「客户端表态掀不掉」。
|
|
1058
|
-
// 🔴 判据 = 本地 peek **∪ 持久行上的出处**(codex 对抗复审 R1-[high],验真后修)。
|
|
1059
|
-
// 本注上一版写的是「本地 peek 说是治理门就够了,行说是的那一半由下面的车道门 fail-closed 兜住」——
|
|
1060
|
-
// **那句话是错的**,而且错在最坏的地方:短路一旦 return,下面那道车道门根本不会执行。本文件自己
|
|
1061
|
-
// 早就成文「本地表是**进程内**表,failover / 重启后幂等命中既有行时它**恒空**,而行上的卡里存着
|
|
1062
|
-
// 落库那一刻的判定」(见 ensureAsk 段的 F2 家族三条)。于是缺口是:一个副本上人点过一次同能力桶的
|
|
1063
|
-
// 普通 allow_session ⇒ 本进程有 grant;随后一只**行上已标治理**的 ask 在本副本重入(本地标表空)
|
|
1064
|
-
// ⇒ 旧形按本地 peek 判「非治理」直接 return true —— 运维治理门被掀掉,且不出卡、不落行、无痕迹。
|
|
1065
|
-
// 处置照本文件既定的**放宽动作 fail-closed 合成**纪律(与规则车道逐字同形:任一侧说治理门就不放宽):
|
|
1066
|
-
// · 无 `askStore` ⇒ 不存在能反驳本地 peek 的行,本地判据即全部真相,短路照旧(零行为变化);
|
|
1067
|
-
// · 有 `askStore` ⇒ 先做一次**确定性 askId 的点读**:行不在(常见形:这只 ask 头一次铸)⇒ 放行;
|
|
1068
|
-
// 行在且卡说治理 ⇒ 不短路(往下走真出卡);读失败/超时 ⇒ **不短路**(不知道 = 不放宽)。
|
|
1069
|
-
// 代价如实记:grant 命中时多一次主键点读;店抖动时人会多看到一张卡(方向是收紧,且 noteStoreError
|
|
1070
|
-
// 留痕)。这不违反 D5「裁决可用性不因店抖动而降级」——人照样答得了,只是不再被**静默**代答。
|
|
1071
480
|
if (!governanceForced &&
|
|
1072
481
|
req.requiresRealApproval !== true &&
|
|
1073
482
|
primary.sessionId &&
|
|
@@ -1075,8 +484,6 @@ export class ToolApprovalCoordinator {
|
|
|
1075
484
|
(await this.sessionGrantUncontestedByRow(origin, req))) {
|
|
1076
485
|
return true;
|
|
1077
486
|
}
|
|
1078
|
-
// #151 车3 刀 3b(设计稿 §0 X-2):**写侧准入门** —— 落 pending / 建 timer / 落行 / 发帧之前。
|
|
1079
|
-
// 超限 ⇒ park 路由(缺省 `park` 政策下即 `"unavailable"`,永不 deny)。协议未上场时恒放行(见 {@link admit})。
|
|
1080
487
|
const releaseAdmission = this.admit(origin.taskId, primary.owner);
|
|
1081
488
|
if (!releaseAdmission) {
|
|
1082
489
|
defaultLogger.warn("approval admission gate: too many pending asks — routing to park", {
|
|
@@ -1085,64 +492,23 @@ export class ToolApprovalCoordinator {
|
|
|
1085
492
|
admitMaxPerTask: this.admitMaxPerTask,
|
|
1086
493
|
admitMaxPerOwner: this.admitMaxPerOwner,
|
|
1087
494
|
});
|
|
1088
|
-
// #280 R-13 C 臂(e):`deny` 政策下同样改判 deny —— 「过载 ⇒ 永不 deny」那条硬条款的成立前提是
|
|
1089
|
-
// **有** park 可落(把一次可补批的操作变成任务失败才是它要禁的);声明了无人值守的部署里 park 本身
|
|
1090
|
-
// 就是死信,当场 deny 反而是它要的诚实终局。方向仍是收紧(不放行任何东西)。
|
|
1091
495
|
return this.unattendedOutcome();
|
|
1092
496
|
}
|
|
1093
|
-
// `id` = **本地** pending 条目的键(每次注册各一个,绝不共享 —— {@link pendingByAskId} 顶注记的
|
|
1094
|
-
// 「同 askId 重复本地注册」两类成因下,两条条目必须各占一格,否则后到者会覆盖先到者的登记)。
|
|
1095
497
|
const id = uuidv7();
|
|
1096
498
|
const child = isChildAsk(req);
|
|
1097
499
|
const bounded = boundArgs(req.args);
|
|
1098
|
-
// 🔴 **规则车道用的是本地 peek 与持久行的并集**(#204 件7,codex 对抗复审 R1-中,验真后修)。
|
|
1099
|
-
// 本地表是进程内的:failover / 重启后幂等命中一条既有行时它恒空,而行上的卡里存着落库那一刻的判定。
|
|
1100
|
-
// 下面 `ensureAsk` 的对账段按「行 = 真源」同步**帧上**那个键(那是出处属性,行说了算);但规则车道
|
|
1101
|
-
// 是一次**放宽**动作,它的合成必须 fail-closed —— 任一侧说「治理门」就不进车道。只同步帧键会留下
|
|
1102
|
-
// 一张「说是治理门却仍带 ruleSuggestions」的卡,且回决侧第二道门跟着没武装。
|
|
1103
500
|
let effectiveGovernanceForced = governanceForced;
|
|
1104
|
-
// #154 车二:规则候选的投影素材。**四个合取项**,缺一不投:
|
|
1105
|
-
// ① 规则店真装配(`this.ruleConsent` 在场)—— 店缺席仍投 = 无处可兑的「不再询问」= wire 谎言;
|
|
1106
|
-
// ② 这只 ask 不是治理档产的(#204 件7)—— 治理门上铸规则 = 静默拆掉 operator 档位;
|
|
1107
|
-
// ③ 引擎真铸了候选(`req.ruleSuggestions` 非空)—— 命令不可匹配(复合/重定向/替换)时 core 交空数组;
|
|
1108
|
-
// ④ 命令原字节读得出(回决时**引擎**要拿它重铸候选;读不出就没有可兑付的路,投候选等于骗人)。
|
|
1109
|
-
// 读 `req.args.command` 走窄读:`args` 是 `unknown`,禁裸 as-cast(宪法 [2704])。
|
|
1110
|
-
// 🔴 **core 5.27.0 起引擎自己也收窄了产源**(#231 提货,[3612]):mandated(`shellGate:"always"` /
|
|
1111
|
-
// 工具自带 egress / irreversibility `always|maybe` 标记;⚠️ **不含 org 规则**,亲读 `persistedRuleMandateOf`
|
|
1112
|
-
// 只收 {egress, irreversibility, shellGated} 三位)、`requiresRealApproval`、shadowed、hook 产、inherited-unresolved、ancestor-resolved、
|
|
1113
|
-
// anonymous(无 principal 且未声明 local-owner)六族 ask **一律不带** `ruleSuggestions`(亲读
|
|
1114
|
-
// `dist/core/runner/prepare-task.js` 的 `ruleSuggestionsOf`,不是读 CHANGELOG)。这与本仓那句
|
|
1115
|
-
// 「发一格按下去无处可兑的『不再询问』= wire 谎言」是**同一条判据**,只是这次由引擎在源头执行。
|
|
1116
|
-
// 上面四个合取项**一个都不删**:①③④ 是我方独有的前置(店装配 / 空数组不铸键 / 命令可读),
|
|
1117
|
-
// ② 的 `governanceForced` 是**我方**的治理标(部署侧 AUTONOMY/commandPolicy/SENSITIVE_WRITE_PATTERNS,
|
|
1118
|
-
// 引擎的 mandated 词表里没有它),六族里的另外四族只有引擎判得出(消音谓词 / hook 出处 / 继承未决 /
|
|
1119
|
-
// 祖先决议),所以两侧不是重复,是各管各的那一段。
|
|
1120
|
-
// 唯一**重叠**的是 anonymous 那一族:引擎的判据是 `spec.principal` 空且 `deps.localOwnerRules !== true`
|
|
1121
|
-
// (我方从不设后者,亲验 `grep -rn localOwnerRules src` 只有 adoption 计划表里的一行声明位),
|
|
1122
|
-
// 而我方的判据是 ask 的 `owner === null` —— 两者同源(`askOwner = gatedPrincipal(req) ?? null` 与
|
|
1123
|
-
// `spec.principal = auth?.principal` 取自同一次鉴权)⇒ **本批对单机匿名部署零行为变化**(那种卡在
|
|
1124
|
-
// #203 codex R1-F1 之后本来就不带候选)。⚠️ 记档:若将来要让单机形也能「不再询问」,正路是声明
|
|
1125
|
-
// `RunnerDeps.localOwnerRules`(core 会在 provider 缺 `forLocalOwner` 时**抛**,不静默),而不是把
|
|
1126
|
-
// 我方这道 owner 门放宽 —— 那会造出一张按下去无处可兑的卡。
|
|
1127
|
-
// 候选**基数**同批从 ≤1 变成 ≤2(reviewed 前缀候选,`exact` 恒 index 0)——本层逐字透传整只数组,
|
|
1128
|
-
// 不截长不重排;兑付口按**人报的文本**定位(`rules-consent.ts` 的 `findIndex`,不是 `[0]`)。
|
|
1129
|
-
// `let`:上面那条并集裁定可能在对账后把它撤回 undefined(行说这是治理门)。
|
|
1130
501
|
let ruleLaneMaterial = this.buildRuleLaneMaterial(req, primary.owner, governanceForced, primary.sessionId);
|
|
1131
502
|
const frame = {
|
|
1132
503
|
type: "tool_approval",
|
|
1133
504
|
approvalId: id,
|
|
1134
505
|
toolName: req.toolName,
|
|
1135
|
-
// [1939]:core 的 tool-call id 逐字透传(AskRequest.toolCallId 是必填);理由见字段顶注。
|
|
1136
506
|
...(typeof req.toolCallId === "string" && req.toolCallId !== "" ? { toolCallId: req.toolCallId } : {}),
|
|
1137
|
-
// 帧上 sourceTaskId/fromSubagent/sourceAgentName 只出**子代** ask(server 侧判别收口,现判别
|
|
1138
|
-
// 键 = req.fromSubagent[core RB-39② 显式受信键]——sourceTaskId 仍旁随传但不再是判别式,复审
|
|
1139
|
-
// F2 案:core 对宿主 ask 也恒填 sourceTaskId=宿主 sessionId,裸透传会让每张宿主卡被误判子代)。
|
|
1140
507
|
...(child
|
|
1141
508
|
? {
|
|
1142
509
|
sourceTaskId: req.sourceTaskId,
|
|
1143
510
|
fromSubagent: true,
|
|
1144
511
|
...(typeof req.sourceAgentName === "string" ? { sourceAgentName: redactSecrets(req.sourceAgentName) } : {}),
|
|
1145
|
-
// core 5.9.0 W1:出处链透传(agentName 同 sourceAgentName 待遇 redact;其余为 core-mint 标识非内容)。
|
|
1146
512
|
...(req.delegation
|
|
1147
513
|
? {
|
|
1148
514
|
delegation: {
|
|
@@ -1155,68 +521,26 @@ export class ToolApprovalCoordinator {
|
|
|
1155
521
|
}
|
|
1156
522
|
: {}),
|
|
1157
523
|
message: typeof req.message === "string" ? redactDeep(req.message) : "",
|
|
1158
|
-
// [2942]/[2943]:治理来源标(additive,只在为真时在场——缺席绝不编 false,见字段顶注)。
|
|
1159
|
-
// 值来自上方那一次**非消费式** peek(同一 toolCallId 的重播/failover 再入必须拿到同一份判定)。
|
|
1160
524
|
...(governanceForced ? { governanceForced: true } : {}),
|
|
1161
|
-
// #283:安全类出身位帧顶层过境(真才带;core 可选契约同构,类型顶注成文与卡内恒在形的分歧)。
|
|
1162
525
|
...(req.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
|
|
1163
|
-
// #144(core 5.25.0):被越级的持久规则原文。**逐字透传 core 的判定**(是不是"命中但清不掉"由引擎
|
|
1164
|
-
// 的消音谓词裁,server 不重判);内容族 ⇒ `redactSecrets` 后上帧,空串不铸键(见字段顶注)。
|
|
1165
|
-
// 🔴 **脱敏后当场截到卡的上限**(codex 对抗复审 R2-[medium],验真后修):`redactSecrets` 会**变长**
|
|
1166
|
-
// (一条 `password=…` 换成更长的遮蔽标记),于是一条 ≤512 的规则脱敏后可能超 512 ——而卡面
|
|
1167
|
-
// (`buildApprovalCard` 的 `clip`)会截、活卡帧不截 ⇒ **同一个 approvalId 的两族帧带着两份不同的文本**
|
|
1168
|
-
// (live 长的、`card_json`/呈卡/重放短的)。修法是「先脱敏再截,一次算定」,两面从此同源同值。
|
|
1169
526
|
...(typeof req.persistedRuleShadowed === "string" && req.persistedRuleShadowed !== ""
|
|
1170
527
|
? { persistedRuleShadowed: redactSecrets(req.persistedRuleShadowed).slice(0, MAX_RULE_TEXT_CHARS) }
|
|
1171
528
|
: {}),
|
|
1172
|
-
// #154 车二:候选逐字透传(条件见 `ruleLaneMaterial` 上方注)。
|
|
1173
529
|
...(ruleLaneMaterial !== undefined ? { ruleSuggestions: ruleLaneMaterial.suggestions } : {}),
|
|
1174
|
-
// #253 件 G1:结构化探针因由。窄读走 `approval-card.ts` 的 `readProbeCause` —— **卡也用它**,
|
|
1175
|
-
// 所以活卡帧与 `card_json` 是同一份值(而不是两处各挑一次键)。缺席/形不合 ⇒ 不铸键。
|
|
1176
530
|
...((c) => (c !== undefined ? { probeCause: c } : {}))(readProbeCause(req)),
|
|
1177
|
-
// #263 件 G2:规则出处证据。同款同源(`readRuleEvidence`,redact+截长函数内一次算定);
|
|
1178
|
-
// 缺席/形不合/恰一不变量被破 ⇒ 不铸键(语义见字段顶注)。
|
|
1179
531
|
...((c) => (c !== undefined ? { ruleEvidence: c } : {}))(readRuleEvidence(req)),
|
|
1180
532
|
...(bounded.omitted ? { argsOmitted: true } : { args: bounded.args }),
|
|
1181
533
|
};
|
|
1182
|
-
// #151 车2(design/172 §3.3 D3 窗长三元):有效窗——legDeadlineMonotonic 缺席时退化成 ttlMs(D1 零
|
|
1183
|
-
// 行为变化)。用它既定时器时长,也是 store 在场时 ensureAsk 的 expiresAtMs。
|
|
1184
|
-
// 🔴 车3 刀 3b 收口(3a 在此留的坐标):`legDeadlineMonotonic` 现取自 **`origin`**(出处腿),
|
|
1185
|
-
// 不再取投递连接 `primary` —— 理由见 {@link AskOriginIdentity.legDeadlineMonotonic}(session 形的
|
|
1186
|
-
// 投递集合可以躺着别的 run 的 ctx,读 primary 会拿到另一条腿的 deadline)。缺席仍退化成 ttlMs。
|
|
1187
534
|
const effectiveWindowMs = effectiveAskWindowMs(this.ttlMs, this.windowMarginMs, origin.legDeadlineMonotonic, performance.now());
|
|
1188
|
-
// 🔴 窗的**绝对** deadline 一次铸定(§3.1「永不赋新 deadline、永不重启窗」):落库的 `expires_at_ms`、
|
|
1189
|
-
// 呈卡帧的 `expiresAtMs`、以及重放腿回读的那一份**必须**是同一个数。此前它内联在 ensureAsk 的入参里,
|
|
1190
|
-
// 呈卡帧接上来之后若各算各的 `Date.now() + effectiveWindowMs`,两处就会差出一个 await 的时间。
|
|
1191
535
|
const expiresAtMs = Date.now() + effectiveWindowMs;
|
|
1192
|
-
// #151 车2(D4 askId/batchId 铸造 + register-before-emit):只在 askStore 在场时计算/落盘——askId 派生
|
|
1193
|
-
// 与 pending map 的 uuidv7 `id` 是两条独立的身份轴(顶注)。ensureAsk 在此处调用(register-before-emit
|
|
1194
|
-
// 之前),cardJson 存 frame 的已洗投影(boundArgs 之后的 frame 对象,D4)。
|
|
1195
536
|
let askId;
|
|
1196
537
|
let batchId;
|
|
1197
|
-
// #151 车3 刀 3b:design/172 §3.1 的**中性投影**。素材 = 上面那个已洗、已按**字节**帽处理过的
|
|
1198
|
-
// `frame`(结构上满足 `ApprovalCardSource`)—— 于是「wire 帧与 card_json 同一份判据」是结构事实,
|
|
1199
|
-
// 不是两处各算一遍的巧合(§6.5)。`risk` 三态由 `buildApprovalCard` 从 `req` 窄读(缺席=未标注)。
|
|
1200
|
-
// 只在协议上场(askStore 在场)时铸:关着时这几行连算都不算,现行为逐字不变(A-1)。
|
|
1201
538
|
const card = this.askStore ? buildApprovalCard(frame, req, req.requiresRealApproval === true) : undefined;
|
|
1202
|
-
/**
|
|
1203
|
-
* 发帧时真正用的**卡 / deadline / wire 身份** —— 默认是本地刚铸的这一份;`ensureAsk` 返回一条
|
|
1204
|
-
* **早已存在**的 STREAM_PENDING 行时改采信行(codex 交叉复审 F2,2026-08-06 真 finding)。
|
|
1205
|
-
*
|
|
1206
|
-
* 🔴 与上面的 `id` 是**两条轴,不可合并**:`id` 是本地 pending 的键(重复注册各占一格),
|
|
1207
|
-
* `wireApprovalId` 是这只 ask 在 **wire 上的唯一身份**(消费端按它去重、`respond` 按它寻的那把)。
|
|
1208
|
-
* 合并会二选一地出错:共用一把 ⇒ 重复注册互相覆盖本地登记(先到者从此无人驱动);各铸各的 ⇒
|
|
1209
|
-
* 同一个 askId 带着两个 approvalId 上 wire(壳把一张卡渲两次)、两个 deadline(破 §3.1
|
|
1210
|
-
* 「窗一次铸定、永不重算」)。分成两条轴,两件事各自成立。
|
|
1211
|
-
*/
|
|
1212
539
|
let wireApprovalId = id;
|
|
1213
540
|
let cardForFrame = card;
|
|
1214
541
|
let persistedExpiresAtMs = expiresAtMs;
|
|
1215
|
-
// R4-1 的三件套(见下方 ensureAsk 调用点注):迟到落盘的行 id / 本地是否已放弃 / 放弃时的收尾动作。
|
|
1216
542
|
let lateEnsuredAskId;
|
|
1217
543
|
let abandonedEnsure = false;
|
|
1218
|
-
/** 本次 `ensureAsk` 传给店里的 `createdAtMs`(纯时间戳;归属判别已改由店给的 `inserted` 位回答,
|
|
1219
|
-
* 见 `EnsureAskResult` 顶注与下方 onLateSuccess)。 */
|
|
1220
544
|
const attemptCreatedAtMs = Date.now();
|
|
1221
545
|
const voidAbandonedRow = (orphanAskId) => {
|
|
1222
546
|
const store = this.askStore;
|
|
@@ -1226,144 +550,60 @@ export class ToolApprovalCoordinator {
|
|
|
1226
550
|
.transitionAsk(orphanAskId, "STREAM_PENDING", "VOID", { updatedAtMs: Date.now() })
|
|
1227
551
|
.catch((err) => this.noteStoreError(err, "transitionAsk(abandon-late-ensure)"));
|
|
1228
552
|
};
|
|
1229
|
-
// `card` 与 `askStore` 一律同在同不在(上面同一个三元铸的)——把两者一起narrow,下面落行时就不必
|
|
1230
|
-
// 为「理论上可能缺席」的卡再写一条不可达的兜底分支。
|
|
1231
553
|
if (this.askStore && card) {
|
|
1232
554
|
const sourceTaskId = req.sourceTaskId ?? origin.taskId;
|
|
1233
|
-
// runId 轴 = 本 run 的 wire id,取自**出处**(`origin`)而不是投递连接:core 给
|
|
1234
|
-
// `AskRequest.sourceTaskId` 填的是 sessionId(prepare-task.js:2595),两者在同一会话的多次提交
|
|
1235
|
-
// 之间是「不变」与「必变」的关系——正是这条轴封死了跨 run 复用旧同意(§10 钉 C-1)。
|
|
1236
|
-
// 为什么不是 `primary.taskId`:见 boundAsk 里那条 codex 复审注(投递集合可含别的 run 的流)。
|
|
1237
555
|
const runId = origin.taskId;
|
|
1238
556
|
const legKey = origin.legKey;
|
|
1239
557
|
askId = deriveAskId(sourceTaskId, runId, req.toolCallId, legKey, req.delegation?.parentToolCallId);
|
|
1240
558
|
batchId = deriveBatchId(sourceTaskId, runId, legKey);
|
|
1241
559
|
try {
|
|
1242
|
-
// R2-5:`ensureAsk` 在窗定时器/abort 监听器装上**之前**被 await ⇒ 必须有界(见 DURABLE_CALL_TIMEOUT_MS)。
|
|
1243
|
-
// R4-1(codex round4):**迟到成功**要观测 —— 超时后若复核读也没看见行,下面会把 askId/batchId 清空
|
|
1244
|
-
// 降级成「store 缺席」;而那笔事务完全可能**稍后才提交**,留下一条**没有任何本地属主**的
|
|
1245
|
-
// STREAM_PENDING 行。它随后会被本车的孤儿腿代打 expire → PARKING → 甚至被对账收敛器 PARK 出一张
|
|
1246
|
-
// **幻影 gate**(工具其实早已按本地终局执行完了)。所以:一旦确认本地放弃了这条行,就把它收成
|
|
1247
|
-
// VOID(「这行从来没有过属主」)。best-effort:失败也无妨,收敛器仍是最后兜底。
|
|
1248
560
|
const existingOrCreated = await this.withStoreDeadline(this.askStore.ensureAsk({
|
|
1249
561
|
askId,
|
|
1250
|
-
// 行记在**出处** run 名下(listPendingByTask 与恢复扫描按它找人),不是投递流的 run。
|
|
1251
562
|
taskId: origin.taskId,
|
|
1252
563
|
sourceTaskId,
|
|
1253
|
-
// sessionId 是店内必填列;session-less(adhoc)ask 折 taskId——同 streamKey 的 "adhoc" 分桶
|
|
1254
|
-
// 精神一致(taskId 替身份充当会话锚),非 design/172 字面钉死,是本车工程判断(见汇报存疑单)。
|
|
1255
564
|
sessionId: primary.sessionId ?? origin.taskId,
|
|
1256
565
|
owner: primary.owner,
|
|
1257
566
|
batchId,
|
|
1258
567
|
toolCallId: req.toolCallId,
|
|
1259
568
|
legKey,
|
|
1260
569
|
parentToolCallId: req.delegation?.parentToolCallId ?? null,
|
|
1261
|
-
// 🔴 车3 刀 3b(设计稿 §6.2,车5 移交②):`card_json` 存的是**信封**(`{schemaVersion, approvalId,
|
|
1262
|
-
// card}`),不再是整帧。写侧(这里)与读侧(重放腿/车4/车5 的 `ApprovalCardEnvelopeSchema.safeParse`)
|
|
1263
|
-
// 因此是同一个符号,存的形与读的形不可能各自漂。**直接迁移、不做兼容读**:表与代码都还没出厂
|
|
1264
|
-
// (未打 tag),按 SCHEMA POLICY drop-and-recreate,不给一个从未出厂的形状留兼容层(§6.2 裁定 +
|
|
1265
|
-
// 本仓「不做临时方案」纪律)。
|
|
1266
570
|
cardJson: buildApprovalCardEnvelope(id, card),
|
|
1267
|
-
// 车5 §9 C2 的对账 join 键。**窄读**(顶注:core 5.15.0 起真有值,窄读的理由改为「摘要函数
|
|
1268
|
-
// 不在 core 公开导出面,server 只透传不重算」)——有则存、无则 null;缺席 ⇒ 收敛器判据 1
|
|
1269
|
-
// 结构上不命中(`unmatchableNoHash` 计数),这是**设计要的** fail-safe,不是降级(禁「能取到时才比」)。
|
|
1270
571
|
boundInputHash: readBoundInputHash(req),
|
|
1271
572
|
schemaVersion: 1,
|
|
1272
573
|
expiresAtMs,
|
|
1273
574
|
createdAtMs: attemptCreatedAtMs,
|
|
1274
575
|
}), "ensureAsk", DURABLE_CALL_TIMEOUT_MS, (late) => {
|
|
1275
|
-
// 🔴 codex 交叉复审 round2 R2-2(2026-08-06 真 finding)+ #168 件2 根治:`ensureAsk` 是**幂等
|
|
1276
|
-
// upsert**,它迟到返回的那一行可能根本不是本次插的 —— 而是一条**早已存在、且正被另一次调用
|
|
1277
|
-
// 持有**的行(askId 是确定性派生,两次并发调用天然指向同一行)。把任何 STREAM_PENDING 结果都
|
|
1278
|
-
// 当成「本次留下的孤儿」去收 VOID,会在本次超时放弃时把**别人正在等的那张活卡**作废掉:真属主
|
|
1279
|
-
// 的 decideAsk 随后干净地输,人的批准被吞成拒绝/延迟收敛。
|
|
1280
|
-
// 归属判据 = 店给的 `inserted` 位(引擎报的 affected 行数,`EnsureAskResult` 顶注)。R2-2 首修
|
|
1281
|
-
// 用的 `createdAtMs === attemptCreatedAtMs` 是**猜**,在同一毫秒的两次并发插入上会给假阳性
|
|
1282
|
-
// (那条残余随本件销账);`inserted` 是引擎对同一个问题的权威回答,严格窄于旧判据。
|
|
1283
|
-
// `state` 复核仍留:行是本次插的,不代表此刻还没人接手(别的副本可能已经在驱动它)。
|
|
1284
|
-
//
|
|
1285
|
-
// 🔴 **残余,登记而不假称已解**(codex 交叉复审 2026-08-07 [high] 二,验真;**先存**,本批只把它
|
|
1286
|
-
// 收窄没有扩大):`inserted` 回答的是「谁插的」,不是「此刻谁在驱动」。极窄的一支仍会伤人 ——
|
|
1287
|
-
// 本次插入**已提交**、提交后那次回读慢到超时、随后**每一次**复核读也全失败(店严重退化,才会走
|
|
1288
|
-
// `indeterminate` 臂),而这期间另一个副本幂等命中这条 STREAM_PENDING 行并开始真驱动它;此时
|
|
1289
|
-
// 迟到结果带着 `inserted: true` 回来,会把别人正在等的卡收成 VOID。
|
|
1290
|
-
// 旧的 `createdAtMs` 判据在同一支上同样会 VOID(那是本次自己写的时间戳)⇒ 本批不是引入方,是
|
|
1291
|
-
// 严格缩小了误伤集合(幂等命中的行从此一个字都不碰)。
|
|
1292
|
-
// 这条残余与 R4-1 的取舍是同一枚硬币:不收 = 留一条无属主的行被孤儿腿 park 出**幻影 gate**。
|
|
1293
|
-
// 真解 = 给行一个 attempt 级的属主租约(新列 + CAS 带租约),属协议改动,开题上报,不在本批。
|
|
1294
576
|
lateEnsuredAskId = late.inserted && late.row.state === "STREAM_PENDING" ? late.row.askId : undefined;
|
|
1295
577
|
if (abandonedEnsure && lateEnsuredAskId !== undefined)
|
|
1296
578
|
voidAbandonedRow(lateEnsuredAskId);
|
|
1297
579
|
});
|
|
1298
|
-
// 🔴 codex 交叉复审 round4 抓获(真 finding):`ensureAsk` 是幂等 upsert(approval-ask-machine.ts
|
|
1299
|
-
// 顶注:「同参数元组——含重试/failover/闭包再入——永远派生同一个 id」)——同一确定性 askId 的重入
|
|
1300
|
-
// 拿到的可能不是一条新鲜的 STREAM_PENDING 行,而是一条**早已终结**(或正在 PARKING/PARKED)的既有
|
|
1301
|
-
// 行。裸忽略返回值、继续往下注册一条全新的本地待决,会让这只 ask 陷入一个永远赢不了 CAS 的死局:
|
|
1302
|
-
// round2/round3 之后,三条竞争者对「干净地输」的响应统一是「什么都不做、信任真赢家」——但真赢家
|
|
1303
|
-
// 是**过去**某次早已返回的 askBroadcast 调用,进程里已经没有任何存活的闭包会再驱动它 settle,
|
|
1304
|
-
// TTL 到点时 expireAsk 对一条非 STREAM_PENDING 行同样只会干净地输,一样什么都不做——挂死到进程
|
|
1305
|
-
// 重启。修法:行不是 STREAM_PENDING ⇒ 不重新挂卡,直接回放既有终局(不落 pending、不 emit)。
|
|
1306
580
|
if (existingOrCreated.row.state !== "STREAM_PENDING") {
|
|
1307
|
-
releaseAdmission();
|
|
1308
|
-
// 🔴 #280 codex 对抗复审 R1-F1 [high](验真后**部分采纳**,红先复现):回放出来的
|
|
1309
|
-
// `"unavailable"`(行是 PARKING/PARKED)必须**同样过政策口**。不过的话,`deny` 部署上臂 (a)
|
|
1310
|
-
// 写下的 PARKING 行会让**同一只 ask** 的终局取决于「重试没重试」:首次 deny、重入 park。
|
|
1311
|
-
// 人的真决议(DECIDED ⇒ true/false)不经这个口 —— 那不是「无人可答」,回放它是幂等复述。
|
|
581
|
+
releaseAdmission();
|
|
1312
582
|
const replayed = replayTerminalAskRow(existingOrCreated.row);
|
|
1313
583
|
if (replayed === true)
|
|
1314
|
-
recordRowDerivedApproveReplay("ensure-ask-idempotent-replay");
|
|
584
|
+
recordRowDerivedApproveReplay("ensure-ask-idempotent-replay");
|
|
1315
585
|
return replayed === "unavailable" ? this.unattendedOutcome() : replayed;
|
|
1316
586
|
}
|
|
1317
|
-
// 🔴 F2 修:行 = 真源。刚插的行上这三样与本地一致(采信是恒等操作);**重入**拿到既有行时,
|
|
1318
|
-
// 采信才是唯一正确的做法 —— 帧、定时器、落库三处从此只有一个 deadline、一个 approvalId、一份卡。
|
|
1319
|
-
// 🔴 codex 交叉复审 round2 R2-3(2026-08-06 真 finding):**信封与 deadline 是一个校验单元**。
|
|
1320
|
-
// 旧形对 `safeParse` 失败「宽容降级」:留着本地新铸的 approvalId/card、却照样采信行上的
|
|
1321
|
-
// `expires_at_ms` —— 于是同一条持久行会以**编造的** wire 身份上 wire(重放腿反而因为形不合把它
|
|
1322
|
-
// 跳过),而车4 的回决端点按 askId 仍决得动它:人有可能照着一份**不是行上那份**的卡做授权。
|
|
1323
|
-
// 采信行上一个**无界/非有限**的 deadline 同样危险:它会把 pending 条目与准入名额钉在那里远超配置窗。
|
|
1324
|
-
// 裁:两样任一不合 ⇒ 不编造替身、不发帧,**fail-safe 走 park**(行本身交给对账收敛器处理)。
|
|
1325
587
|
const persistedEnvelope = ApprovalCardEnvelopeSchema.safeParse(existingOrCreated.row.cardJson);
|
|
1326
588
|
const sanePersistedDeadline = Number.isFinite(existingOrCreated.row.expiresAtMs) && existingOrCreated.row.expiresAtMs <= Date.now() + effectiveWindowMs + DURABLE_CALL_TIMEOUT_MS;
|
|
1327
589
|
if (!persistedEnvelope.success || !sanePersistedDeadline) {
|
|
1328
|
-
// #168 件2:`inserted` 让这条臂能分清两种成因 —— 幂等命中的**别人的**行不合形(存量/旧版/坏行),
|
|
1329
|
-
// 与「本次刚写下的行自己读不回来」(店在改写我们的字节,或本地铸形与 schema 不一致)。处置相同
|
|
1330
|
-
// (都不编造替身、都走 park),但后者是本进程自己的账,归因不许并成前者。
|
|
1331
590
|
this.noteStoreError(new Error(`persisted approval row is unusable (envelopeOk=${persistedEnvelope.success}, deadlineOk=${sanePersistedDeadline}, ` +
|
|
1332
591
|
`origin=${existingOrCreated.inserted ? "row-this-call-just-inserted" : "row-hit-idempotently"})`), "ensureAsk(persisted-row-unusable)");
|
|
1333
592
|
releaseAdmission();
|
|
1334
|
-
return "unavailable";
|
|
593
|
+
return "unavailable";
|
|
1335
594
|
}
|
|
1336
595
|
persistedExpiresAtMs = existingOrCreated.row.expiresAtMs;
|
|
1337
596
|
wireApprovalId = persistedEnvelope.data.approvalId;
|
|
1338
|
-
frame.approvalId = wireApprovalId;
|
|
597
|
+
frame.approvalId = wireApprovalId;
|
|
1339
598
|
cardForFrame = persistedEnvelope.data.card;
|
|
1340
|
-
// 🔴 [2942] codex 交叉复审 round1 [high](验真):`governanceForced` 也归入「行 = 真源」的 F2 家族。
|
|
1341
|
-
// 本地判据(`governanceAskMarks`)是**进程内**表:它不跨副本、不跨重启。幂等命中一条既有行时
|
|
1342
|
-
// (failover / 重启后重入),本地表恒空而行上的卡里存着它**落库那一刻**的判定 —— 两族帧若各读各的,
|
|
1343
|
-
// 同一个 `approvalId` 会带着**互相矛盾**的出处上 wire(旧帧无键、呈卡帧 true,反向亦然)。
|
|
1344
|
-
// 与 approvalId/deadline 同一条裁定:采信行。缺席也要**如实同步**(删键,不是留着本地的旧真值)。
|
|
1345
599
|
if (cardForFrame.governanceForced === true)
|
|
1346
600
|
frame.governanceForced = true;
|
|
1347
601
|
else
|
|
1348
602
|
delete frame.governanceForced;
|
|
1349
|
-
// 🔴 #209 件4:`persistedRuleShadowed` 同归「行 = 真源」的 F2 家族,姿势与上一行**逐字同形**。
|
|
1350
|
-
// 它比 `governanceForced` 更需要这一步:那个键的本地判据(进程内标记表)在重入时恒空,而本键的
|
|
1351
|
-
// 本地判据是 **core 交来的 `req`** —— 幂等命中一条既有行时,这一次的 `req` 是**新一腿**的 ask 请求,
|
|
1352
|
-
// 它带的规则命中判定可能与落库那一刻**不同**(规则在两腿之间被撤销 / 新加了一条命中的规则)。
|
|
1353
|
-
// 两族帧若各读各的,同一个 `approvalId` 会带着互相矛盾的规则解释上 wire(旧帧说"你的规则被越级"、
|
|
1354
|
-
// 呈卡帧说"没有规则",或反向)。采信行 —— 人看到的解释必须是**这张卡落库那一刻**的那一份。
|
|
1355
|
-
// 缺席也要**如实同步**(删键,不是留着本地这一腿的真值)。
|
|
1356
603
|
if (typeof cardForFrame.persistedRuleShadowed === "string")
|
|
1357
604
|
frame.persistedRuleShadowed = cardForFrame.persistedRuleShadowed;
|
|
1358
605
|
else
|
|
1359
606
|
delete frame.persistedRuleShadowed;
|
|
1360
|
-
// 🔴 #204 件7(codex 对抗复审 R1-中,验真后修):行说这是治理门 ⇒ **规则车道当场撤回**。
|
|
1361
|
-
// 上面那行只同步了帧上的出处键;素材、两族帧上的候选、以及回决侧第二道门的输入都还是按**本地**
|
|
1362
|
-
// 表算的,而本地表在 failover / 重启后恒空。不撤就正是本件要防的形:一张卡同时带着
|
|
1363
|
-
// `governanceForced:true` 与 `ruleSuggestions`,回决口还照收 —— operator 档位被一次点击拆掉。
|
|
1364
|
-
// 撤三处(缺一即漏):①旧帧的候选键;②呈卡帧读的**持久卡**上的同名键(它来自行,不是本地铸的);
|
|
1365
|
-
// ③素材本身(它是下面 `pendingEntry.ruleLane` 的唯一来源,也就是回决口的等式左边)。
|
|
1366
|
-
// 反向(行说不是治理门、本地表说是)**刻意不放开**:见上方并集裁定 —— 少渲一格是安全方向。
|
|
1367
607
|
if (cardForFrame.governanceForced === true && !effectiveGovernanceForced) {
|
|
1368
608
|
effectiveGovernanceForced = true;
|
|
1369
609
|
ruleLaneMaterial = undefined;
|
|
@@ -1373,19 +613,6 @@ export class ToolApprovalCoordinator {
|
|
|
1373
613
|
cardForFrame = rest;
|
|
1374
614
|
}
|
|
1375
615
|
}
|
|
1376
|
-
// 🔴 **候选是「行 = 真源」F2 家族的第三位**(codex 对抗复审 R1-[medium],验真后修)。上面两条把
|
|
1377
|
-
// `governanceForced` / `persistedRuleShadowed` 收进了这条纪律,候选却漏在外面:幂等命中一条既有行时
|
|
1378
|
-
// 呈卡帧读的是**行上的卡**,而旧帧的 `ruleSuggestions` 与 `ruleLaneMaterial`(= 回决口
|
|
1379
|
-
// `rule_not_offered` 那道等式的**左边**)仍来自**这一腿**的 `req`。两腿的候选可以真的不同(重试 /
|
|
1380
|
-
// 并发 / 混版跑 / 规则店在两腿之间变了),于是同一个 `approvalId` 上会出现:呈卡帧展示 A、旧帧展示 B、
|
|
1381
|
-
// 兑付口按 B 判 —— 人点了卡上看见的 A,拿回一句 `rule_not_offered`。
|
|
1382
|
-
//
|
|
1383
|
-
// 处置 = **不一致就整条撤回车道**,与上面那条治理撤回**逐字同形**(本文件既定的安全方向:少渲一格
|
|
1384
|
-
// 是安全的,渲一格按不动的才是 wire 谎言)。不选「采信行的候选」:兑付还要 `command`/`boundInputHash`
|
|
1385
|
-
// 等素材,而行上的卡不带它们,拼起来就是第三份来源;也不选「采信本地」:人看见的是行上那张卡。
|
|
1386
|
-
// ⚠️ 残余如实记:这里撤的是**本次投递**的两族帧与素材,行本身不动 ⇒ 纯重放腿(`buildReplayFrame`)
|
|
1387
|
-
// 仍会从行上投出候选。那条腿本来就没有自己的兑付通道(射程见 `ApprovalCardSchema.ruleSuggestions`
|
|
1388
|
-
// 顶注:只有同副本 live `approvalId` 兑得动),所以不构成「按得动却兑不动」。
|
|
1389
616
|
if (ruleLaneMaterial !== undefined && !ToolApprovalCoordinator.ruleSuggestionsEqual(cardForFrame.ruleSuggestions, ruleLaneMaterial.suggestions)) {
|
|
1390
617
|
this.noteStoreError(new Error("persisted approval row offers a different ruleSuggestions set than this leg — withdrawing the rule lane for this delivery"), "ensureAsk(rule-suggestions-drift)");
|
|
1391
618
|
ruleLaneMaterial = undefined;
|
|
@@ -1398,31 +625,12 @@ export class ToolApprovalCoordinator {
|
|
|
1398
625
|
}
|
|
1399
626
|
catch (err) {
|
|
1400
627
|
this.noteStoreError(err, "ensureAsk");
|
|
1401
|
-
// 🔴 codex 交叉复审 round1 抓获(真 finding):ensureAsk 失败若放任 askId/batchId 留在场,下游
|
|
1402
|
-
// decideAsk/expireAsk/transitionAsk 会对一个(疑似)不存在的行打 CAS——那不是「CAS 输」(某个真
|
|
1403
|
-
// 竞争者已经决定了)而是「压根没有行」,但 round2 的「只在自己赢时才 settle」修正后,三条竞争者
|
|
1404
|
-
// 现在对「干净地输」的响应统一是"什么都不做、信任真赢家"——若压根没有真赢家(行真的没落盘),
|
|
1405
|
-
// 这只 ask 会被三方一致「谦让」到永远,直接违反 D5「裁决可用性不因店抖动而降级」。
|
|
1406
|
-
// 🔴 codex 交叉复审 round2 追加抓获(真 finding,精化 round1 的修法):但反过来裸清空也不对——
|
|
1407
|
-
// `SqlApprovalAskStore.ensureAsk` 与 `decideAsk` 同款「提交后另起一次独立 getAsk 读」形态
|
|
1408
|
-
// (approval-ask-store-sql.ts:382-434),commit 可能已经真的成功、只是这次确认读断了——那种情形下
|
|
1409
|
-
// 行其实已经落盘,裸清空会把一条真实存在的 STREAM_PENDING 行永久架空(未来任何 CAS 都不会再碰
|
|
1410
|
-
// 它,只能靠车5 的恢复扫描兜底)。折中:失败后打一次防御性复核读——行确实存在(不论其当前状态)
|
|
1411
|
-
// 就保留 askId/batchId(后续 CAS 仍有机会打中真行);复核读本身也失败、或行确实不存在,才降级成
|
|
1412
|
-
// 「视同这只 ask 的 store 缺席」(D1 路径)。
|
|
1413
|
-
// 🔴 车3 刀 3b(设计稿 §2.2(b)-3,改车2 的 D5「继续呈卡」臂):复核读要分**两种失败**。
|
|
1414
|
-
// ①「读成功、行不在」= **确定**的事实 ⇒ 降级成「视同 store 缺席」,活卡腿逐字照旧(D1 路径)——
|
|
1415
|
-
// 这条腿早于本协议、不受「无持久身份不发新帧」约束,只是不发新帧、不落行。
|
|
1416
|
-
// ②「读本身也失败/超时」= **不确定** ⇒ 有界重试;耗尽仍不确定 ⇒ 返回 `"unavailable"` 走 park,
|
|
1417
|
-
// **不继续呈卡**。理由(§2.2(b)):在身份是否落盘都不知道的情况下继续收人决,与设计 §3.1
|
|
1418
|
-
// 「身份**先持久化后**发帧」直接相悖 —— 人批了、行却可能不存在(或存在而无人对得上),
|
|
1419
|
-
// 那是一次会凭空消失的批准。park 是这条路上唯一诚实的出路(人仍可经 durable gate 补批)。
|
|
1420
628
|
let confirmedRowExists = false;
|
|
1421
629
|
let indeterminate = true;
|
|
1422
630
|
for (let attempt = 0; attempt <= DURABLE_CONVERGE_BACKOFF_MS.length; attempt++) {
|
|
1423
631
|
try {
|
|
1424
632
|
confirmedRowExists = (await this.withStoreDeadline(this.askStore.getAsk(askId), "getAsk(ensureAsk-confirm)")) !== null;
|
|
1425
|
-
indeterminate = false;
|
|
633
|
+
indeterminate = false;
|
|
1426
634
|
break;
|
|
1427
635
|
}
|
|
1428
636
|
catch (readErr) {
|
|
@@ -1432,23 +640,15 @@ export class ToolApprovalCoordinator {
|
|
|
1432
640
|
}
|
|
1433
641
|
}
|
|
1434
642
|
if (indeterminate) {
|
|
1435
|
-
// 🔴 codex 交叉复审 F6(2026-08-06 真 finding):**必须**先登记「本地已放弃」再返回。
|
|
1436
|
-
// 这一臂是超时 —— 那笔 `ensureAsk` 完全可能**稍后才提交**,留下一条没有任何本地属主的
|
|
1437
|
-
// STREAM_PENDING 行(没有 pending 条目、没有定时器)。不登记的话 `onLateSuccess` 观测器看到
|
|
1438
|
-
// `abandonedEnsure === false` 就什么都不做,那条行会被孤儿腿代打 expire → PARKING → 被对账
|
|
1439
|
-
// 收敛器 PARK 出一张**幻影 gate**(工具其实早就按本地终局 park 掉了)。两个方向都覆盖:
|
|
1440
|
-
// 观测器已经先跑过 ⇒ 这里补收;还没跑 ⇒ 它稍后自己看到这个标志再收(与下面 D1 降级臂同形)。
|
|
1441
643
|
abandonedEnsure = true;
|
|
1442
644
|
if (lateEnsuredAskId !== undefined)
|
|
1443
645
|
voidAbandonedRow(lateEnsuredAskId);
|
|
1444
|
-
releaseAdmission();
|
|
1445
|
-
return "unavailable";
|
|
646
|
+
releaseAdmission();
|
|
647
|
+
return "unavailable";
|
|
1446
648
|
}
|
|
1447
649
|
if (!confirmedRowExists) {
|
|
1448
650
|
askId = undefined;
|
|
1449
651
|
batchId = undefined;
|
|
1450
|
-
// R4-1:登记「本地已放弃」。迟到的 ensureAsk 若已经落盘(观测器先跑),这里补收;若还没落盘,
|
|
1451
|
-
// 观测器稍后自己看到这个标志再收。两个方向都覆盖,不依赖谁先谁后。
|
|
1452
652
|
abandonedEnsure = true;
|
|
1453
653
|
if (lateEnsuredAskId !== undefined)
|
|
1454
654
|
voidAbandonedRow(lateEnsuredAskId);
|
|
@@ -1458,51 +658,16 @@ export class ToolApprovalCoordinator {
|
|
|
1458
658
|
let done = false;
|
|
1459
659
|
let emitFailed = false;
|
|
1460
660
|
let emitSettled = false;
|
|
1461
|
-
// #151 车2(D2 窗到期竞争者的返回值覆盖):askStore 在场且 expireAsk 赢 CAS ⇒ 强制返回 "unavailable"
|
|
1462
|
-
// (park 路由,G1 回路)——与旧形的 emit-race 覆盖判据(!emitSettled && lastOutcome==="expired")并存,
|
|
1463
|
-
// 互不排斥(见方法尾的返回值分派)。
|
|
1464
|
-
// ⚠️ #280 R-13 A1/A2 起,**askStore 缺席的 D1 窗到期臂与店报错臂也置位它**(park 路由是三条窗臂
|
|
1465
|
-
// 共用的终局形),所以「缺席时恒 false」那句话已作废;三臂的差别只剩宿主自报(见各臂注)。
|
|
1466
661
|
let windowRouteUnavailable = false;
|
|
1467
|
-
// A-058 F1/F3:`parked` 帧键的**唯一**事实源——真落了墓碑(⇔ 迟到 decide 腿对这把 approvalId 可达)。
|
|
1468
662
|
let parkTombstoned = false;
|
|
1469
663
|
let timer;
|
|
1470
664
|
let resolveAllowed;
|
|
1471
665
|
const allowedP = new Promise((resolve) => { resolveAllowed = resolve; });
|
|
1472
666
|
let lastOutcome = "expired";
|
|
1473
667
|
const abortListeners = [];
|
|
1474
|
-
// #151 车2(round5 精化):`pendingEntry`(下方构造,携带 `settle` 本身)构造之前,`settle` 闭包已经
|
|
1475
|
-
// 需要引用它自己在 {@link pendingByAskId} 那个 Set 里的身份,用来在结算时把自己摘出去——先立一个
|
|
1476
|
-
// 稍后才赋值的引用格(鸡生蛋:settle 是 pendingEntry 的一个字段,pendingEntry 建好之后才能把它自己
|
|
1477
|
-
// 塞进这个引用里)。
|
|
1478
668
|
let selfEntry;
|
|
1479
|
-
// core 5.23.0(#187/#114②):本次结算的**宿主自报来源**(见 {@link HostSettledBy})。
|
|
1480
|
-
// 🔴 记在 `settle` 的 **done 卫兵之内**,不是在调用点旁边立一个标志:窗到期的臂是「先算、后 settle」,
|
|
1481
|
-
// 而 `settle` 可能因为人已经答过了而整个是空操作 —— 标志式写法会在那种赛跑里给一个**人类的**终局盖上
|
|
1482
|
-
// 「窗到期」的章。只有真正落定这次终局的那一次调用才有资格留下自报词。
|
|
1483
669
|
let hostSettled;
|
|
1484
|
-
let denyReasonSettled;
|
|
1485
|
-
/**
|
|
1486
|
-
* 已落定的本地终局 → `AskOutcome` 的**唯一**成形口。本方法有两个出口(方法尾的正常路,与
|
|
1487
|
-
* `earlyAbortDuringEnsure` 的早退路),它们此前各写了一份等价分派 —— 结果就是本批的 `settledBy`
|
|
1488
|
-
* 只加到了一份上(codex 复审 round2 抓获)。合成一个函数,判据只写一次。
|
|
1489
|
-
* · `windowRouteUnavailable` ⇒ park 路由(持久终局是 PARKING/PARKED,压过一切);
|
|
1490
|
-
* · allow + 编辑 ⇒ 对象臂带 `updatedInput`;
|
|
1491
|
-
* · deny + 宿主自报 ⇒ 对象臂带 `settledBy`(见 {@link HostSettledBy};allow 侧恒不带 —— core 对
|
|
1492
|
-
* `{allow:true, settledBy:"timeout"}` 是响亮拒,而自报只在 deny 分支产生);
|
|
1493
|
-
* · deny + 人写理由 ⇒ 对象臂带 `reason`(#243,core 5.29.0 收口:模型经它看到拒因——围栏
|
|
1494
|
-
* delimitUntrusted 与 REVIEWER_NOTE_MAX_BODY 截断全在 core,server 只透传不预判;空串按 core
|
|
1495
|
-
* 契约的缺席读——truthiness——不铸键;allow 侧恒不带,core 对 allow 行的 reason 恒销毁不渲染,
|
|
1496
|
-
* 审计面由店的 decisionNote 承载)。理由只从 respond 腿流入(finishRespond),窗到期/取消/
|
|
1497
|
-
* 外部清算臂无人写理由,恒缺席。
|
|
1498
|
-
* · 其余 ⇒ 裸 boolean(falsy 纪律)。
|
|
1499
|
-
*
|
|
1500
|
-
* #280 R-13 C:`windowRouteUnavailable` 那一位在 `deny` 政策下**不返回 `"unavailable"`**,而是落到
|
|
1501
|
-
* 同一份 deny 成形上(自报/理由若在场照旧带上)—— 五臂一处施加的落点之一(见
|
|
1502
|
-
* {@link ToolApprovalCoordinator.unattendedOutcome})。⚠️ 那一支**不能**直接 fall through 到下面的
|
|
1503
|
-
* `settled.allowed` 分派:park 路由的语义是「持久终局压过一切」,而它可能与一个 `allowed:true` 的
|
|
1504
|
-
* 本地落定值并存(窗赢了 CAS 而人的 respond 稍后才到的那个交错)—— fall through 会把它渲成放行。
|
|
1505
|
-
*/
|
|
670
|
+
let denyReasonSettled;
|
|
1506
671
|
const shapeDeny = () => {
|
|
1507
672
|
if (hostSettled !== undefined || (denyReasonSettled !== undefined && denyReasonSettled !== "")) {
|
|
1508
673
|
return {
|
|
@@ -1532,23 +697,10 @@ export class ToolApprovalCoordinator {
|
|
|
1532
697
|
for (const [sig, fn] of abortListeners)
|
|
1533
698
|
sig.removeEventListener("abort", fn);
|
|
1534
699
|
this.pending.delete(id);
|
|
1535
|
-
// 🔴 #329:live 条目按 **park 路由**收尾 ⇒ 留墓碑(wire approvalId → 店内坐标),否则壳稍后按 Yes
|
|
1536
|
-
// 时两条身份轴接不上、只能得到一句 404(设计 §一 的病灶)。判据取 `windowRouteUnavailable` 而不是
|
|
1537
|
-
// 各臂各记一个标志:三条 park 臂(窗到期赢 CAS / 向持久真源收敛读出 PARKING|PARKED / 单赢者
|
|
1538
|
-
// `settleParkRouteIfUnsettled`)**都**是「先置位、后 settle」,所以这里是它们唯一的公共咽喉。
|
|
1539
|
-
// `deny` 政策下不留:那台部署显式声明了「不积压 park」,core 拿到的是 deny(无 checkpoint、无处可赎),
|
|
1540
|
-
// 行随后由收敛器收成 DENIED/VOID —— 留墓碑只会把一次 404 换成一次误导性的 202。
|
|
1541
700
|
if (windowRouteUnavailable && askId !== undefined && batchId !== undefined && this.unattendedOutcome() !== false) {
|
|
1542
701
|
this.recordParkTombstone(id, { askId, batchId, owner: primary.owner, parkedAtMs: Date.now() });
|
|
1543
|
-
// A-058 F1/F3(7.40 合并窗重扫,opus 证伪 CONFIRMED):闭合帧的 `parked` 判别位与墓碑必须是
|
|
1544
|
-
// **同一次判定**。首版读的是政策口(unattendedPolicy+askStore 在场)——三项全是协调器级事实,
|
|
1545
|
-
// 与「这条 ask 本身走没走 park 路由」无关,于是批内被连坐 VOID 的兄弟(settleVoidedSiblings
|
|
1546
|
-
// 裸 expired,无墓碑、迟到答恒 404)、取消/断连/D5 fail-open 等非 park 终局也被打上
|
|
1547
|
-
// `parked:true` —— 壳渲「已转后台候批」而答案无处兑现,正是 [4845] 病灶的新皮。
|
|
1548
702
|
parkTombstoned = true;
|
|
1549
703
|
}
|
|
1550
|
-
// #151 车2(round3 兄弟撤卡索引 + round5 精化成 Set,顶注):把自己从同 askId 的集合里摘除——集合
|
|
1551
|
-
// 可能还有其他条目(批内兄弟、或同 askId 的重复本地注册),它们的存亡与自己的结算无关。
|
|
1552
704
|
if (askId !== undefined) {
|
|
1553
705
|
const siblings = this.pendingByAskId.get(askId);
|
|
1554
706
|
if (siblings && selfEntry) {
|
|
@@ -1558,37 +710,11 @@ export class ToolApprovalCoordinator {
|
|
|
1558
710
|
}
|
|
1559
711
|
}
|
|
1560
712
|
lastOutcome = outcome;
|
|
1561
|
-
releaseAdmission();
|
|
713
|
+
releaseAdmission();
|
|
1562
714
|
if (selfEntry)
|
|
1563
|
-
selfEntry.settledOutcome = outcome;
|
|
715
|
+
selfEntry.settledOutcome = outcome;
|
|
1564
716
|
resolveAllowed({ allowed, ...(allowed && updatedInput !== undefined ? { updatedInput } : {}) });
|
|
1565
717
|
};
|
|
1566
|
-
// #151 车2(D2 窗到期竞争者):askStore 缺席 ⇒ settle(false,"expired") 逐字不变(D1)。在场 ⇒ settle
|
|
1567
|
-
// 前先赢一把 expireAsk CAS——赢 ⇒ settle 之余把终局强制改判 "unavailable"(windowRouteUnavailable);
|
|
1568
|
-
// 输 ⇒ 人已决(或即将由回决腿落定),这里什么都不做,交 done 标志兜底。⚠️ store CAS 是 async 而 settle
|
|
1569
|
-
// 是 sync,TTL 定时器回调因此变 async(void IIFE 包裹,内部 catch 全部错误按 D5 fail-open 处置)。
|
|
1570
|
-
/**
|
|
1571
|
-
* #151 车5(design/172 §4 状态感知分派表 = 车3 §14 X-1 裁定②的实施件;车5 稿 §8 D-1.2):
|
|
1572
|
-
* **CAS 干净地输之后,重读持久态并按真实终局收敛**——取代此前的「什么都不做、信任真赢家」。
|
|
1573
|
-
*
|
|
1574
|
-
* 🔴 为什么必须改:「信任真赢家」在**单进程**里成立(赢家是本进程另一条闭包,它会 settle 我),在
|
|
1575
|
-
* 跨副本/收敛器上线后**不成立**——赢家可能是另一个副本的回决端点,或本车的 reaper 收敛器。那条
|
|
1576
|
-
* 路径压根不认识本进程的 pending 条目,本地闭包于是永远等不到任何事件:HTTP 200 而持卡腿永挂
|
|
1577
|
-
* (X-1 契约明令排除的那个形)。收敛器上线前必须先落这一段,否则本车自己制造挂死面。
|
|
1578
|
-
*
|
|
1579
|
-
* 映射复用 {@link replayTerminalAskRow}(**同一套 settle 语义,不造第二套**):
|
|
1580
|
-
* `DECIDED` ⇒ 真决议(approve ⇒ allowed / deny ⇒ denied);
|
|
1581
|
-
* `PARKING|PARKED` ⇒ park 路由(`"unavailable"`,与窗到期赢家的终局同义,幂等);
|
|
1582
|
-
* `DENIED|VOID` ⇒ deny。
|
|
1583
|
-
* 行读不出(店抖动)⇒ **有界重试**(指数退避,期间随时被真赢家 settle 就退出),用尽仍失败 ⇒
|
|
1584
|
-
* D5 fail-open 退回纯进程内语义(`settle(false,"expired")`)——绝不无限等。
|
|
1585
|
-
*
|
|
1586
|
-
* ⚠️ #280 施工纪要(留一句免得下一个人重走):codex R3-F1 的第一版修法是让 `expireAsk` 报错臂整段复用
|
|
1587
|
-
* 本收敛,被**自己的全量跑**打回来了 —— 整套收敛会在几秒的退避里把终局让给并发的取消臂,而「行仍
|
|
1588
|
-
* STREAM_PENDING」恰是那条臂的**期望态**(我们这次 expire 就是没做成),预算等于白烧;两条既有判据
|
|
1589
|
-
* (codex-R2-2 / codex-R3-1)当场红。最终收窄成一次性窄读,见
|
|
1590
|
-
* {@link ToolApprovalCoordinator.readDecidedForRecovery}。
|
|
1591
|
-
*/
|
|
1592
718
|
const convergeFromDurableState = async (where) => {
|
|
1593
719
|
if (!this.askStore || askId === undefined)
|
|
1594
720
|
return;
|
|
@@ -1596,35 +722,26 @@ export class ToolApprovalCoordinator {
|
|
|
1596
722
|
const pinnedAskId = askId;
|
|
1597
723
|
for (let attempt = 0; attempt < DURABLE_CONVERGE_BACKOFF_MS.length; attempt++) {
|
|
1598
724
|
if (done)
|
|
1599
|
-
return;
|
|
725
|
+
return;
|
|
1600
726
|
try {
|
|
1601
|
-
// 每次读都带墙钟 deadline(见 DURABLE_CONVERGE_READ_TIMEOUT_MS 顶注):挂住的读不许吃掉整个
|
|
1602
|
-
// 退避预算。超时抛进下面的 catch,与「读失败」同一条路(退避 → 用尽 → fail-open)。
|
|
1603
727
|
const row = await this.withStoreDeadline(store.getAsk(pinnedAskId), `getAsk(${where})`, DURABLE_CONVERGE_READ_TIMEOUT_MS);
|
|
1604
728
|
if (done)
|
|
1605
729
|
return;
|
|
1606
730
|
if (row === null) {
|
|
1607
|
-
// 行不在(被 deleteByTask 清过 / 从未落盘)——没有可信终局可读,退回进程内语义
|
|
1608
|
-
// (调用方声明自己收尾时不落终局,见 opts 顶注)。
|
|
1609
731
|
settle(false, "expired");
|
|
1610
732
|
return;
|
|
1611
733
|
}
|
|
1612
734
|
if (row.state === "STREAM_PENDING") {
|
|
1613
|
-
// 竞争者 CAS 输、行却还是 STREAM_PENDING:只可能是刚才那一瞬别人先赢又被回滚一类的窄窗
|
|
1614
|
-
// (或 batchId 错配导致的 expireAsk 不赢)。退避后再读一次,不在这里替它编造终局。
|
|
1615
735
|
await new Promise((r) => setTimeout(r, DURABLE_CONVERGE_BACKOFF_MS[attempt]).unref?.());
|
|
1616
736
|
continue;
|
|
1617
737
|
}
|
|
1618
738
|
const outcome = replayTerminalAskRow(row);
|
|
1619
739
|
if (outcome === "unavailable") {
|
|
1620
|
-
// park 语义 ⇒ onAsk 交 `"unavailable"`(G1 回路)。#280 R-13 C:本臂与臂 (a) 是**同一件事**
|
|
1621
|
-
// (本 ask 的等待以「行进了投递面」收场,只是赢家可能在别的副本)⇒ `deny` 政策下随 (a) 一起
|
|
1622
|
-
// 改判 deny(自报缺席 —— 结束等待的不是本仓的窗)。理由全文见 unattendedOutcome 顶注。
|
|
1623
740
|
windowRouteUnavailable = true;
|
|
1624
741
|
settle(false, "expired");
|
|
1625
742
|
}
|
|
1626
743
|
else if (outcome === true) {
|
|
1627
|
-
recordRowDerivedApproveReplay(`converge:${where}`);
|
|
744
|
+
recordRowDerivedApproveReplay(`converge:${where}`);
|
|
1628
745
|
settle(true, "allowed");
|
|
1629
746
|
}
|
|
1630
747
|
else {
|
|
@@ -1638,68 +755,24 @@ export class ToolApprovalCoordinator {
|
|
|
1638
755
|
}
|
|
1639
756
|
}
|
|
1640
757
|
if (!done)
|
|
1641
|
-
settle(false, "expired");
|
|
758
|
+
settle(false, "expired");
|
|
1642
759
|
};
|
|
1643
|
-
/**
|
|
1644
|
-
* park 路由 + 结算的**单赢者**提交(🔴 #280 codex 对抗复审 R2-F1 [high],验真后修,红先复现器 =
|
|
1645
|
-
* `test/unattended-approval-policy.test.ts` 的 R2-F1 格)。
|
|
1646
|
-
*
|
|
1647
|
-
* 病灶:`windowRouteUnavailable = true` 与受 `done` 保护的 `settle` 是**两句**,而 `shapeOutcome` 把
|
|
1648
|
-
* 标志读在最前面。于是「回决先落定 → 迟到的窗臂再置标志」这条交错里(实测确定性可复现:窗先到期
|
|
1649
|
-
* 让 `expireAsk` 在飞,回决晚一拍赢下 CAS 并**回了 200**,店的报错续接恰好排在 settle 之后、
|
|
1650
|
-
* `askBroadcast` 尾部读标志之前),端点已经告诉人「已批准」、持久行也是 DECIDED(approve),
|
|
1651
|
-
* `onAsk` 却交出 `"unavailable"`(deny 政策下是 `false`)—— 人批了、工具不跑、run 反而再挂起。
|
|
1652
|
-
*
|
|
1653
|
-
* 修法 = 让改判路由这件事**继承 `settle` 的单赢者语义**:没赢下这次结算就一个字都不改。
|
|
1654
|
-
* ⚠️ 只给「不知道持久真源是什么」的两条臂用(D1 无店 / `expireAsk` 报错)。臂 (a)(`expireAsk` 真赢
|
|
1655
|
-
* CAS)与向持久真源收敛那两处**故意不走它**:那里的 PARKING/PARKED 是**读出来的持久事实**,按设计
|
|
1656
|
-
* (§D2「赢 ⇒ 无条件 park 路由」)压过一切本地落定值 —— 那条语义早于本件,不在本修射程内。
|
|
1657
|
-
*/
|
|
1658
760
|
const settleParkRouteIfUnsettled = (hostSettledBy) => {
|
|
1659
761
|
if (done)
|
|
1660
762
|
return;
|
|
1661
763
|
windowRouteUnavailable = true;
|
|
1662
764
|
settle(false, "expired", undefined, hostSettledBy);
|
|
1663
765
|
};
|
|
1664
|
-
/**
|
|
1665
|
-
* 🔴 `hostSelfReport` = 本次调用**结束这场等待的真实原因**能不能被本仓如实说出(A-054.13,2026-08-19
|
|
1666
|
-
* 合并重扫 confirmed)。缺省 `"timeout"` = 真的是本仓的窗走完了(定时器腿);断连强转的**有店支**
|
|
1667
|
-
* 整段委托本函数,它必须传 `undefined` —— 结束等待的是连接没了,`"timeout"` 会把「任务的流没了」谎成
|
|
1668
|
-
* 「没人答」(core 对该词铸的是「approver 的窗走完了没人答」)。此前有店支不传、于是继承了缺省词,
|
|
1669
|
-
* 与三处成文(本臂自注 / `PendingApproval.forceParkNow` 的契约句 / CHANGELOG 7.34.0 + 五臂表)逐字相反,
|
|
1670
|
-
* 而无店支一直是对的 —— 同一件事实的两条腿归因分家。自报只在 `deny` 政策下可见(park 路由把词吞掉)。
|
|
1671
|
-
*
|
|
1672
|
-
* ⚠️ 形参**必填、无缺省**(施工时先写成 `= "timeout"` 缺省形,当场被自己的红先钉打回:JS 的缺省参数
|
|
1673
|
-
* 对**显式传入的 `undefined`** 同样生效,`windowExpired(undefined)` 拿到的还是 `"timeout"`)。必填形
|
|
1674
|
-
* 顺带把「这条腿凭什么说得出这个词」变成每个调用点都要当场回答的问题。
|
|
1675
|
-
*/
|
|
1676
766
|
const windowExpired = (hostSelfReport) => {
|
|
1677
767
|
void (async () => {
|
|
1678
768
|
if (!this.askStore || askId === undefined || batchId === undefined) {
|
|
1679
|
-
// D1(无持久 ask 店)——终局**完全**由本仓这个窗决定,没有第二个写者。
|
|
1680
|
-
//
|
|
1681
|
-
// 🔴 #280 R-13 A1(设计稿 §2;[4297] 现场那条 error 的铸点):本臂**改判 park 路由**
|
|
1682
|
-
// (`windowRouteUnavailable` ⇒ `"unavailable"`),与 forceParkNow 的无店支同形。park 的承载是
|
|
1683
|
-
// **core checkpoint**(`spec.durableApproval`),不是 server 的 ask 行 —— 所以无 askStore 的
|
|
1684
|
-
// 部署同样受益;两者皆缺席时 core 以 `approverUnavailable` 文案 deny(fail-closed 保持,
|
|
1685
|
-
// 文字从「窗走完」变「无 durable 门可停靠」,对模型更可行动)。
|
|
1686
|
-
//
|
|
1687
|
-
// `"timeout"` 自报**留着但只在 `deny` 政策下才读得到**(shapeOutcome 的 park 支在它之前短路):
|
|
1688
|
-
// 5.23 立这个词是为了防「没人理」被记成「一个人拒绝了」—— park 路由下那种误记结构上不存在
|
|
1689
|
-
// (根本不是 deny),词的存在理由被 park 语义整体取代;而 `deny` 政策下结束这次等待的**确实**
|
|
1690
|
-
// 是本仓的窗,词仍然如实,必须留。
|
|
1691
|
-
// 单赢者提交(codex R2-F1):人已落定 ⇒ 一个字都不改(D1 的 respond 是同步收尾,这条交错真实可达)。
|
|
1692
769
|
settleParkRouteIfUnsettled(hostSelfReport);
|
|
1693
770
|
return;
|
|
1694
771
|
}
|
|
1695
772
|
try {
|
|
1696
773
|
const res = await this.withStoreDeadline(this.askStore.expireAsk(askId, batchId), "expireAsk(window)", DURABLE_CALL_TIMEOUT_MS, (late) => {
|
|
1697
|
-
// R3-1 迟到成功:本地终局已由下面的 catch 落定(幂等,不改写),但**兄弟的结算与撤卡帧**
|
|
1698
|
-
// 是这次事务真做出来的事实,必须补做 —— 否则兄弟闭包挂死、壳上留一张已作废的卡。
|
|
1699
774
|
if (!late.won)
|
|
1700
775
|
return;
|
|
1701
|
-
// #280 codex R3-F2:两类分开收 —— 同 askId 重复注册的行是 **PARKING**(park 路由),
|
|
1702
|
-
// `voidedSiblings` 的行是 **VOID**(裸 expired)。混在一个名单里会让前者拿到裸 deny。
|
|
1703
776
|
this.settleSameAskIdParkRoute(askId);
|
|
1704
777
|
this.settleVoidedSiblings(late.voidedSiblings);
|
|
1705
778
|
this.emitRevokeTo(aliveCtxs, buildRevokeFrame(batchId, late.voidedSiblings, "superseded_by_park", Date.now()));
|
|
@@ -1707,131 +780,62 @@ export class ToolApprovalCoordinator {
|
|
|
1707
780
|
if (res.won) {
|
|
1708
781
|
windowRouteUnavailable = true;
|
|
1709
782
|
settle(false, "expired");
|
|
1710
|
-
// round3:同批兄弟被远程撤卡,本地同步收尾;round5 追加自己的 askId——覆盖「同 askId 重复本地
|
|
1711
|
-
// 注册」那一支(顶注)。#280 codex R3-F2:那一支的行是 **PARKING** ⇒ 走 park 路由口,不能与
|
|
1712
|
-
// 行已 VOID 的兄弟混用同一个裸 expired 结算(否则同一条行两种 live 终局)。
|
|
1713
783
|
this.settleSameAskIdParkRoute(askId);
|
|
1714
784
|
this.settleVoidedSiblings(res.voidedSiblings);
|
|
1715
|
-
// #151 车6 发射点①:降级连坐的兄弟被原子撤卡 ⇒ 壳上那些卡必须消失(live only)。
|
|
1716
785
|
this.emitRevokeTo(aliveCtxs, buildRevokeFrame(batchId, res.voidedSiblings, "superseded_by_park", Date.now()));
|
|
1717
786
|
}
|
|
1718
787
|
else {
|
|
1719
|
-
// 干净地输 ⇒ 重读持久态收敛(X-1;见 convergeFromDurableState 顶注)。
|
|
1720
788
|
await convergeFromDurableState("windowExpired");
|
|
1721
789
|
}
|
|
1722
790
|
}
|
|
1723
791
|
catch (err) {
|
|
1724
792
|
this.noteStoreError(err, "expireAsk");
|
|
1725
|
-
// R3-1 原文只把**超时**支改判 park:超时 = 不知道那次 `expireAsk` 做没做成 —— 若它稍后提交,
|
|
1726
|
-
// 行就是 PARKING(已转投递面);此时把 onAsk 判成 deny 会与持久真源矛盾,而且违反 §3.3
|
|
1727
|
-
// 「窗不得把一个可 park 的 ask 拖成 abort-deny」。交 `"unavailable"`(park 路由)对两种结局都
|
|
1728
|
-
// 成立:真提交了 ⇒ 与行一致;没提交 ⇒ core 自己走 durable park,不劣于 deny。
|
|
1729
|
-
//
|
|
1730
|
-
// 🔴 #280 R-13 A2:**同一条理由逐字覆盖「店真报错」那一支** —— SQL 店的 `expireAsk` 同样是
|
|
1731
|
-
// 「事务提交 + 另一跳确认」的形,一次报错既可能是事务没做成,也可能是做成了而回报断了;两种
|
|
1732
|
-
// 结局下 park 路由都不劣于 deny。于是 deadline 与非 deadline 两支从此**同判**,分支消失。
|
|
1733
|
-
// fail-open(D5)不变:店抖动不挡窗到期的进程内机械照旧完成。`"timeout"` 自报的存留理由与 D1
|
|
1734
|
-
// 臂逐字同(park 政策下读不到,`deny` 政策下如实)。
|
|
1735
|
-
// 🔴 单赢者提交(codex R2-F1,红先复现):回决可能已经赢下 CAS 并**回了 200**,而这次报错的
|
|
1736
|
-
// 续接恰好排在它之后 —— 无条件置标志会把一次人类批准改判成 park/deny。没赢下结算就不改判。
|
|
1737
|
-
//
|
|
1738
|
-
// 🔴 **先看一眼行上有没有真决议**(codex R3-F1,三轮追问后改判采纳;红先钉 =
|
|
1739
|
-
// `test/unattended-approval-policy.test.ts` 的 R3-F1 两格):店报错 ≠ 没有真决议 —— **另一个
|
|
1740
|
-
// 副本**的回决腿可能刚把行判成 `DECIDED`,而本臂此前直接按政策收窗,等于把一次已经落盘的
|
|
1741
|
-
// 人类决议丢掉(工具不跑、run 还要人再批一次)。
|
|
1742
|
-
//
|
|
1743
|
-
// ⚠️ **只消费 `DECIDED`,而且只读一次**(收窄是刻意的,第一版写成整套
|
|
1744
|
-
// `convergeFromDurableState` 被自己的全量跑打回来了,两条既有判据当场红):
|
|
1745
|
-
// · 只读一次(一次带墙钟的 `getAsk`)⇒ 不把整段退避预算烧在「行仍 STREAM_PENDING」这个**期望
|
|
1746
|
-
// 态**上,也不会在那几秒里把终局让给并发的取消臂(codex-R2-2 钉的形当场翻)。
|
|
1747
|
-
// · 只认 `DECIDED` ⇒ `VOID`(批内兄弟被撤卡)/`PARKING`/读不出 三态的处置**逐字不动**,
|
|
1748
|
-
// 它们各自的判据早有钉(codex-R3-1 的迟到提交形、settleVoidedSiblings 的 F29 语义)。
|
|
1749
|
-
// 本修只补「人的真决议不许被丢」这**一条**缺口,不顺手改别人的语义。
|
|
1750
793
|
const decided = await this.readDecidedForRecovery(askId);
|
|
1751
794
|
if (decided !== undefined && !done) {
|
|
1752
795
|
if (decided)
|
|
1753
|
-
recordRowDerivedApproveReplay("expire-error-recovery");
|
|
796
|
+
recordRowDerivedApproveReplay("expire-error-recovery");
|
|
1754
797
|
settle(decided, decided ? "allowed" : "denied");
|
|
1755
798
|
return;
|
|
1756
799
|
}
|
|
1757
|
-
settleParkRouteIfUnsettled(hostSelfReport);
|
|
800
|
+
settleParkRouteIfUnsettled(hostSelfReport);
|
|
1758
801
|
}
|
|
1759
802
|
})();
|
|
1760
803
|
};
|
|
1761
|
-
// #241:断连强制转 park(接口注释见 PendingApproval.forceParkNow)。有店 ⇒ 走窗到期同路(expireAsk
|
|
1762
|
-
// CAS+兄弟撤卡,赢=unavailable/输=向持久真源收敛——人已决就尊重人);无店 ⇒ 进程内直接改判 park 路由。
|
|
1763
|
-
// ⚠️ #280 R-13 之后无店支**仍不**复用 windowExpired,理由换了一条:两臂的 park/deny 方向如今同判
|
|
1764
|
-
// (A1),但**宿主自报**不同——窗到期臂说得出 `"timeout"`(结束等待的是本仓的窗),断连臂说不出
|
|
1765
|
-
// (结束等待的是连接没了,那个词会把「任务的流没了」谎成「没人答」)。自报只在 `deny` 政策下可见。
|
|
1766
|
-
// 🔴 A-054.13:**有店支也归这条纪律管**。此前它裸调 `windowExpired()`,于是继承了那边的缺省
|
|
1767
|
-
// `"timeout"`——店报错 catch 臂一落词,断连臂就说出了自己明写「说不出」的那个词(三处成文同时被打脸,
|
|
1768
|
-
// 且只在 `deny` 部署上可观测,所以一直没人撞见)。现在显式传 `undefined`:两条腿的归因从此同形。
|
|
1769
804
|
const forceParkNow = () => {
|
|
1770
805
|
if (done)
|
|
1771
806
|
return;
|
|
1772
807
|
if (!this.askStore || askId === undefined || batchId === undefined) {
|
|
1773
|
-
settleParkRouteIfUnsettled();
|
|
808
|
+
settleParkRouteIfUnsettled();
|
|
1774
809
|
return;
|
|
1775
810
|
}
|
|
1776
811
|
windowExpired(undefined);
|
|
1777
812
|
};
|
|
1778
|
-
// F2 修:定时器量的是**行上那个绝对 deadline** 的剩余时长(重入拿到既有行时,剩余时长比一整个窗短
|
|
1779
|
-
// —— 旧形会给一条早就该到期的行重新挂一个满窗的表)。store 缺席时逐字沿用 `effectiveWindowMs`(D1)。
|
|
1780
|
-
// 🔴 包一层箭头而不是裸传函数引用:`windowExpired` 现在带必填形参(A-054.13),裸传会让 timer 的实参
|
|
1781
|
-
// 语义直接落到 `hostSelfReport` 上。定时器腿**结束等待的确实是本仓的窗** ⇒ 如实自报 `"timeout"`。
|
|
1782
813
|
timer = setTimeout(() => windowExpired("timeout"), this.askStore ? Math.max(0, persistedExpiresAtMs - Date.now()) : effectiveWindowMs);
|
|
1783
814
|
timer.unref?.();
|
|
1784
|
-
// #151 车2(D2 取消竞争者:task signal abort / 连接全灭同路):askStore 缺席 ⇒ settle(false,
|
|
1785
|
-
// "expired") 逐字不变(D1)。在场 ⇒ settle 前先打一把 transitionAsk(STREAM_PENDING→VOID)CAS。
|
|
1786
|
-
// 🔴 codex 交叉复审 round2 抓获(真 finding,与 D2 原文「赢输都照旧 settle」的字面不同——属主已认领
|
|
1787
|
-
// 的、以「验真优先于不重议」为准的偏离,理由如下):store 各方法的网络往返跳数并不对称——SQL 双方言
|
|
1788
|
-
// 实现里 `decideAsk` 在提交事务后**另起一次独立的 `getAsk` 读**才返回(`approval-ask-store-sql.ts`
|
|
1789
|
-
// :382-434/456-499),而 `transitionAsk` 直接把 UPDATE 的 affected-rows 当结果返回,少一整跳。若取消
|
|
1790
|
-
// 臂真照 D2 字面「赢输都 settle」,在人类回决(`respond`)已经**赢下持久 CAS**、但它自己较慢的第二跳
|
|
1791
|
-
// 还没落地时,取消臂会用一个在 CAS 层面已经**输了**的 transitionAsk 结果抢先把本地终局锁成 false 并
|
|
1792
|
-
// 删掉 pending 条目——回决那边随后真正拿到 `ok:true` 时,`respondWithCas` 的 entry 存活性复核(下方)
|
|
1793
|
-
// 只能如实 404:durable 行写着人已批准,活的终局却是拒绝,自相矛盾。改判:**只在自己真赢时才
|
|
1794
|
-
// settle**;干净地输(transitionAsk 返回 false,不是 throw)⇒ 什么都不做,信任真赢家走它自己的路径
|
|
1795
|
-
// 完成 settle(与 windowExpired 的「输 ⇒ 什么都不做」同精神,两条路径现在对称)。throw(店真的挂了,
|
|
1796
|
-
// 不是「CAS 输」而是「不知道」)仍走 D5 fail-open:退化回 D1 的纯进程内语义,无条件 settle。
|
|
1797
|
-
// #151 车2(codex round4 抓获,真 finding,精化):`runCancel` 改成**可 await 的**async 函数(不再是
|
|
1798
|
-
// void-返回的 fire-and-forget 包装)——事件监听器(下方 `onTaskAbort`/`onTargetGone`)仍然只是
|
|
1799
|
-
// `void runCancel()` 触发它、不等结果(它们是真正的"未来事件"回调,没有调用方在等);但下面的
|
|
1800
|
-
// 「ensureAsk 挂起期间错过的 abort」复核**需要真等它跑完**才能知道是否该在注册/emit 之前就短路收尾
|
|
1801
|
-
// (见复核处的顶注)——这是 round4 finding 1 的直接修复点,round3 的 fire-and-forget 形只触发了取消,
|
|
1802
|
-
// 却让函数继续往下注册/广播一张其实已经作废的卡。
|
|
1803
815
|
const runCancel = async () => {
|
|
1804
816
|
if (!this.askStore || askId === undefined || batchId === undefined) {
|
|
1805
|
-
settle(false, "expired");
|
|
817
|
+
settle(false, "expired");
|
|
1806
818
|
return;
|
|
1807
819
|
}
|
|
1808
820
|
try {
|
|
1809
821
|
const won = await this.withStoreDeadline(this.askStore.transitionAsk(askId, "STREAM_PENDING", "VOID", { updatedAtMs: Date.now() }), "transitionAsk(cancel)", DURABLE_CALL_TIMEOUT_MS, (lateWon) => {
|
|
1810
822
|
if (!lateWon)
|
|
1811
|
-
return;
|
|
823
|
+
return;
|
|
1812
824
|
this.settleVoidedSiblings([askId]);
|
|
1813
825
|
this.emitRevokeTo(aliveCtxs, buildRevokeFrame(batchId, [askId], "aborted", Date.now()));
|
|
1814
826
|
});
|
|
1815
827
|
if (won) {
|
|
1816
828
|
settle(false, "expired");
|
|
1817
|
-
// round5:transitionAsk 不携带 voidedSiblings(批级撤卡只是 expireAsk 的副作用),但「同 askId
|
|
1818
|
-
// 重复本地注册」那一支(pendingByAskId 顶注)与竞争者种类无关,取消赢下 CAS 时同样要收尾。
|
|
1819
829
|
this.settleVoidedSiblings([askId]);
|
|
1820
|
-
// #151 车6 发射点②:取消 ⇒ 本行已 VOID,壳上这张卡必须消失(reason="aborted")。
|
|
1821
|
-
// 🔴 名单是**这一行自己**,不是「本批全体」:`abortBatch`(整批 STREAM_PENDING→VOID)在本仓
|
|
1822
|
-
// 今天没有任何调用点(grep 实证,车2/车4 域),取消腿走的是单行 `transitionAsk`。兄弟各自的
|
|
1823
|
-
// 闭包会走它们自己的这条路径、各发各的撤卡帧;真正的整批名单形要等 `abortBatch` 接线后才成立
|
|
1824
|
-
// (登记在本车汇报的偏离单里,不在这里凭空造一个不存在的名单)。
|
|
1825
830
|
this.emitRevokeTo(aliveCtxs, buildRevokeFrame(batchId, [askId], "aborted", Date.now()));
|
|
1826
831
|
}
|
|
1827
832
|
else {
|
|
1828
|
-
// 干净地输 ⇒ 重读持久态收敛(X-1;与窗到期臂同一条路径,见 convergeFromDurableState 顶注)。
|
|
1829
833
|
await convergeFromDurableState("runCancel");
|
|
1830
834
|
}
|
|
1831
835
|
}
|
|
1832
836
|
catch (err) {
|
|
1833
837
|
this.noteStoreError(err, "transitionAsk(cancel)");
|
|
1834
|
-
settle(false, "expired");
|
|
838
|
+
settle(false, "expired");
|
|
1835
839
|
}
|
|
1836
840
|
};
|
|
1837
841
|
if (signal) {
|
|
@@ -1839,18 +843,6 @@ export class ToolApprovalCoordinator {
|
|
|
1839
843
|
signal.addEventListener("abort", onTaskAbort, { once: true });
|
|
1840
844
|
abortListeners.push([signal, onTaskAbort]);
|
|
1841
845
|
}
|
|
1842
|
-
// 目标存活倒计时(single source of truth):单连接沿用「这条连接死 = 无人可送达 = 判死」的今日语义
|
|
1843
|
-
// (数组退化成 1 时,一路"消失"即到零);多活集合改「全体目标都消失才判死」——any-of(哪一条先掉线
|
|
1844
|
-
// 就杀)会重现 core 点名的「单指针覆盖」同族 bug:一条连接消失不该杀死仍有其他活连接在场、可能仍会
|
|
1845
|
-
// 被响应的卡。**[1559]四独立复审①(HIGH)修**:「消失」现在统一有两条喂入路径——(a)该 ctx 自己的
|
|
1846
|
-
// abortSignal 触发(下方监听器),(b)该 ctx 的 `runWithContext` 作用域退出,不论是正常返回还是抛出
|
|
1847
|
-
// (`runWithContext` 的 finally 经 `targetCtxs`/`onTargetGone` 回调这里,见下方 pending.set)——此前
|
|
1848
|
-
// 两条路径完全独立、互不知情:(b)是靠 `originCtx === ctx` 单独判定,而 `originCtx` 在多活集合下只是
|
|
1849
|
-
// 目标数组里任意一个成员(通常是最先注册的那条),跟"是不是这条连接的执行栈真的在等这次 ask"没有
|
|
1850
|
-
// 必然关系——一条完全不相关、只是恰好也在同一 (owner,session) 广播集合里的连接,处理完它自己另一个
|
|
1851
|
-
// 无关的请求正常退出,就会单独触发把整条 ask 误杀,即便真正在等它的那条连接分毫未动、仍然存活。
|
|
1852
|
-
// `goneCtxs` 去重:同一个 ctx 理论上可能同时/先后触发 (a)(b) 两条路径(abort 通常伴随其作用域跟着
|
|
1853
|
-
// 退出),避免重复扣减。#151 车2:归零判死改走 runCancel(D2 取消竞争者,同 task-signal abort 同路)。
|
|
1854
846
|
let liveConnections = aliveCtxs.length;
|
|
1855
847
|
const goneCtxs = new Set();
|
|
1856
848
|
const onTargetGone = (c) => {
|
|
@@ -1868,41 +860,16 @@ export class ToolApprovalCoordinator {
|
|
|
1868
860
|
c.abortSignal.addEventListener("abort", onCtxAbort, { once: true });
|
|
1869
861
|
abortListeners.push([c.abortSignal, onCtxAbort]);
|
|
1870
862
|
}
|
|
1871
|
-
// 🔴 codex 交叉复审 round3 抓获、round4 精化(真 finding):上面两组监听器都装在 ensureAsk 的 await
|
|
1872
|
-
// 之后(askStore 在场时那是一个真实的挂起点)——`AbortSignal` 不会给「事件发生之后才装上」的监听器
|
|
1873
|
-
// 补放已经错过的那次 abort。若取消恰好发生在 ensureAsk 挂起期间(店慢),这次取消会被彻底吞掉,直到
|
|
1874
|
-
// 自己的 TTL 才收尾。监听器装完后立即补一次「是不是已经在等待期间触发过」的复核——**必须真等它跑完**
|
|
1875
|
-
// (round3 的 fire-and-forget 形只触发了取消,却让函数继续往下注册/广播一张其实已经作废的卡:respond()
|
|
1876
|
-
// 稍后如果撞上这条已经 settle 过的僵尸 entry,会经同步 `finishRespond` 分支应答成功并可能误记
|
|
1877
|
-
// `allowAllSessions`——`finishRespond` 对"是否已经 settle 过"没有重新核验,round1 那道存活性复核只
|
|
1878
|
-
// 长在 `respondWithCas` 的异步分支里)。等它跑完后若 `done` 已经为真,直接短路返回,绝不再往下注册/
|
|
1879
|
-
// emit——askStore 缺席时整段函数体到这里为止零 await,`earlyAbortDuringEnsure` 恒假,对 D1 零行为
|
|
1880
|
-
// 变化。
|
|
1881
863
|
const earlyAbortDuringEnsure = signal?.aborted === true || aliveCtxs.some((c) => c.abortSignal?.aborted === true);
|
|
1882
864
|
if (earlyAbortDuringEnsure) {
|
|
1883
865
|
await runCancel();
|
|
1884
|
-
// 🔴 #151 车5 自查(属主亲读 diff 抓获,2026-08-06):这里**不能**再写死 `return false`。
|
|
1885
|
-
// 旧形那句注释「取消恒 settle(false,"expired")」在 X-1 状态感知分派之前成立;现在 `runCancel` 的
|
|
1886
|
-
// CAS 输臂会重读持久态并按**真决议**结算 —— 一条在 ensureAsk 悬挂期间被别的副本 approve 掉的 ask,
|
|
1887
|
-
// `done` 为真而落定值是 `true`,写死 false 会把一次真实的人类批准悄悄吞成拒绝(且与 durable 行矛盾)。
|
|
1888
|
-
// 改成把**已经落定的那个值**如实交回去(与方法尾部的返回值分派同一套判据)。
|
|
1889
866
|
if (done) {
|
|
1890
|
-
// 🔴 [3372] codex 复审 round2(验真后修):这里**曾经**是尾部那套分派的手抄第二份,于是本批给
|
|
1891
|
-
// 窗到期加的 `settledBy` 自报只进了尾部、没进这里 —— 「窗先落定、取消臂随后才走完」的交错里,
|
|
1892
|
-
// 一个由**窗**决定的终局从这个出口以裸 `false` 溜出去,core 照旧记成人类拒绝。两个出口现在共用
|
|
1893
|
-
// {@link shapeOutcome} 这**一个**成形函数,结构上不可能再分家(红先钉:codex-R2-2)。
|
|
1894
867
|
return shapeOutcome(await allowedP);
|
|
1895
868
|
}
|
|
1896
869
|
}
|
|
1897
|
-
// Register BEFORE emitting so a (fast) respond can never miss the entry. 子代 ask 的生存期挂子代
|
|
1898
|
-
// (originTaskId 在场 = 不被宿主 finally 清扫烧;[1539] 注意事项,判别式 = isChildAsk 顶注)——同理,
|
|
1899
|
-
// 子代 ask 从不设 targetCtxs/onTargetGone(任何宿主 ctx 的 runWithContext 退出都不该碰它,只有它
|
|
1900
|
-
// 自己的 TTL/abortSignal 才作数;上面的 aliveCtxs abort 倒计时仍照常适用于它自己的连接级 abort)。
|
|
1901
870
|
const pendingEntry = {
|
|
1902
871
|
settle,
|
|
1903
872
|
forceParkNow,
|
|
1904
|
-
// #280 codex R3-F2:同 askId 重复注册的 park 路由收尾口(自报缺席 —— 结束这份注册等待的不是它
|
|
1905
|
-
// 自己的窗,是「这只 askId 已经有了投递面终局」这件事)。
|
|
1906
873
|
settleParkRoute: () => settleParkRouteIfUnsettled(),
|
|
1907
874
|
owner: primary.owner,
|
|
1908
875
|
ctxTaskId: primary.taskId,
|
|
@@ -1910,23 +877,15 @@ export class ToolApprovalCoordinator {
|
|
|
1910
877
|
...(child ? { originTaskId: req.sourceTaskId } : { targetCtxs: new Set(aliveCtxs), onTargetGone }),
|
|
1911
878
|
...(primary.sessionId ? { sessionId: primary.sessionId } : {}),
|
|
1912
879
|
toolName: req.toolName,
|
|
1913
|
-
// #315:live pending 列表的两只时间键——expiresAtMs 照抄 :1823 的单点铸定值(不重算)。
|
|
1914
880
|
registeredAtMs: Date.now(),
|
|
1915
881
|
expiresAtMs,
|
|
1916
|
-
// #154 车二:规则车道素材 —— 与帧上的 `ruleSuggestions` **同一个条件**(店在场 ∧ 非治理档 ∧ 引擎真
|
|
1917
|
-
// 铸了候选 ∧ 命令原字节读得出)。任一不成立 ⇒ 不登记,回决带 `persistRule` 时如实拒(不猜命令)。
|
|
1918
882
|
...(ruleLaneMaterial !== undefined ? { ruleLane: ruleLaneMaterial } : {}),
|
|
1919
|
-
// #204 件7:治理标随条目落定(回决侧第二道门的输入,理由见字段顶注)。读的是**并集后**的值
|
|
1920
|
-
// (`effectiveGovernanceForced`),不是本地 peek —— 幂等命中既有行那条路上,只有并集才是武装的。
|
|
1921
883
|
...(effectiveGovernanceForced ? { governanceForced: true } : {}),
|
|
1922
|
-
// #287:安全类出身位随条目落定(回决侧拒铸 grant 的输入;真才带,同 governanceForced 形)。
|
|
1923
884
|
...(req.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
|
|
1924
885
|
...(askId !== undefined && batchId !== undefined ? { askId, batchId } : {}),
|
|
1925
886
|
};
|
|
1926
|
-
selfEntry = pendingEntry;
|
|
887
|
+
selfEntry = pendingEntry;
|
|
1927
888
|
this.pending.set(id, pendingEntry);
|
|
1928
|
-
// #151 车2(round3 兄弟撤卡索引 + round5 精化成 Set,顶注):同一 askId 下可能已经有其他条目在场
|
|
1929
|
-
// (批内兄弟、或同 askId 的重复本地注册)——这里是 add 进集合,不是覆盖单值。
|
|
1930
889
|
if (askId !== undefined) {
|
|
1931
890
|
let siblings = this.pendingByAskId.get(askId);
|
|
1932
891
|
if (!siblings) {
|
|
@@ -1935,36 +894,15 @@ export class ToolApprovalCoordinator {
|
|
|
1935
894
|
}
|
|
1936
895
|
siblings.add(pendingEntry);
|
|
1937
896
|
}
|
|
1938
|
-
// 每路 emit 独立兜底(catch 而非任其抛出/reject 冒泡)——一路失败不拖垮整个广播的判定;`emitOne`
|
|
1939
|
-
// 因此**永不** reject(同时吸收同步 throw 与异步 rejection),`Promise.all` 也就永不 reject,
|
|
1940
|
-
// 下面的 `Promise.race` 才能安全地把它当"正常完成"的一端,不会把某一路的失败错误地冒泡成
|
|
1941
|
-
// askBroadcast 自身的未捕获异常。
|
|
1942
|
-
// #151 车3 刀 3b(设计稿 §4.1/§4.2):呈卡帧 —— **并行加帧,不替换**。
|
|
1943
|
-
// · 只有拿到**确认落盘**的 `askId` 才铸(§2.2(b) 硬条款一:无持久身份不发新帧)⇒ 开关关 / store 缺席 /
|
|
1944
|
-
// ensureAsk 降级三种情形下恒 `undefined`,wire 字节逐字不变(A-1)。
|
|
1945
|
-
// · `expiresAtMs` 用上面**一次铸定**的那个数(与落库同值),`expiresInMs` 由它现算。
|
|
1946
897
|
const cardFrame = askId !== undefined && cardForFrame !== undefined
|
|
1947
898
|
? buildApprovalRequestFrame({ askId, taskId: origin.taskId, approvalId: wireApprovalId, card: cardForFrame, expiresAtMs: persistedExpiresAtMs, nowMs: Date.now() })
|
|
1948
899
|
: undefined;
|
|
1949
|
-
// #288([4429]②):旧族帧补窗三键 —— 必须在这里(emit 前、ensureAsk 后)而不是 frame 字面量里:
|
|
1950
|
-
// `persistedExpiresAtMs` 在有店重入既有行时才被行值覆盖(F2 修),字面量铸造点还拿不到最终值;
|
|
1951
|
-
// 这里与 cardFrame 用**同一个数**,两族帧的到期读数从此同源(锚=此刻的服务器钟)。
|
|
1952
900
|
{
|
|
1953
901
|
const serverNowMs = Date.now();
|
|
1954
902
|
frame.expiresAtMs = persistedExpiresAtMs;
|
|
1955
903
|
frame.serverNowMs = serverNowMs;
|
|
1956
904
|
frame.expiresInMs = Math.max(0, persistedExpiresAtMs - serverNowMs);
|
|
1957
905
|
}
|
|
1958
|
-
/**
|
|
1959
|
-
* 一条连接的**两种**投递结果。分开记是 codex 交叉复审 F1(2026-08-06 真 finding)的修:
|
|
1960
|
-
* · `legacy` —— 旧 `tool_approval` 帧送达没有。它单独决定**闭合帧**发不发(没见过开卡帧的连接
|
|
1961
|
-
* 收到一个 `tool_approval_complete` 就是孤儿 close),也是存量壳能否 `respond` 的前提。
|
|
1962
|
-
* · `card` —— 新 `approval_request` 帧送达没有。它同样算「卡送到人手上了」:detach 车道正是
|
|
1963
|
-
* **旧帧必失败、新帧走 durable 账本必成功**的形,旧形只数旧帧 ⇒ `anySucceeded=false` ⇒
|
|
1964
|
-
* `expireAsk` 赢 ⇒ 行被改判 PARKING、`onAsk` 交 `"unavailable"` —— 而消费端手上明明已经拿到
|
|
1965
|
-
* 一张说自己未决的卡,且它带着可回决的 `askId`(车4 端点)。
|
|
1966
|
-
* 「emit 全灭」因此是**两条都没送到**,不是「旧帧没送到」。
|
|
1967
|
-
*/
|
|
1968
906
|
const emitOne = async (c) => {
|
|
1969
907
|
let delivered = true;
|
|
1970
908
|
try {
|
|
@@ -1973,16 +911,6 @@ export class ToolApprovalCoordinator {
|
|
|
1973
911
|
catch {
|
|
1974
912
|
delivered = false;
|
|
1975
913
|
}
|
|
1976
|
-
// 🔴 发射序钉死:旧帧在前、新帧在后(§4.2)—— 旧壳的字节流与今天逐字一致,新壳按 `approvalId` 去重
|
|
1977
|
-
// 后优先渲染新帧。**per-连接**串行(排在这条连接自己的旧帧之后),与下面 complete 帧的 per-ctx 排序
|
|
1978
|
-
// 同一条不变式。
|
|
1979
|
-
//
|
|
1980
|
-
// 两条判据都是有意的:
|
|
1981
|
-
// · 旧帧**失败也照样试新帧** —— 新帧的投递口在 detach 车道上是「live + durable 账本双写」
|
|
1982
|
-
// (routes/tasks.ts),而那条车道的 live 面**恰恰是死的**;若这里跟着旧帧一起早退,§4.3(c) 要修的
|
|
1983
|
-
// 「断连 ⇒ 卡永久不可见」正好一次都不会被修到。
|
|
1984
|
-
// · 新帧的成败**不参与** `delivered` —— 「卡有没有送达一个人」这条判断归旧帧(它是存量壳唯一认得的
|
|
1985
|
-
// 那一帧,也是 `respond` 的入口)。把新帧的失败算进去会误触 emit-全灭那条 `"unavailable"` 臂。
|
|
1986
914
|
let cardDelivered = false;
|
|
1987
915
|
if (cardFrame && c.emitCard) {
|
|
1988
916
|
try {
|
|
@@ -1990,88 +918,45 @@ export class ToolApprovalCoordinator {
|
|
|
1990
918
|
cardDelivered = true;
|
|
1991
919
|
}
|
|
1992
920
|
catch {
|
|
1993
|
-
/* 投不出去就是没送到;它不影响旧帧那半边的判定 */
|
|
1994
921
|
}
|
|
1995
922
|
}
|
|
1996
923
|
return { legacy: delivered, card: cardDelivered };
|
|
1997
924
|
};
|
|
1998
|
-
// 🔴 [1575] F2 修(cli 复查,红先行确认真红):旧形完成帧广播是"调用序"跟着 `await allowedP`
|
|
1999
|
-
// 走,不是"落盘序"——respond() 可能在某条连接自己的 OPEN emit 尚未落盘完成时就已经 settle(TTL/
|
|
2000
|
-
// abort/emit-race 三选一显式 race 就是为了让这成为可能),而 complete 帧当时是立即 fire-and-forget
|
|
2001
|
-
// 广播给全体 aliveCtxs,不等每条连接自己的 OPEN 是否已经落盘——异步/durable-append 型 emit 目标
|
|
2002
|
-
// (本改造自述要服务的场景)下,慢连接会看到 complete 先于 open 落盘,回放/转录面呈现「永不 dismiss
|
|
2003
|
-
// 的悬卡」或「丢失的 dismiss」。修复:**每条连接的 complete 帧显式排在该连接自己的 OPEN emit 落定
|
|
2004
|
-
// 之后**(用 per-ctx 的 openP 建 Map,不是笼统一个 aliveCtxs.map)——旧注释钉过的「Emit the OPEN
|
|
2005
|
-
// frame AWAITED (ordering ahead of the completion frame...)」不变式对多活广播的每一条连接分别成立,
|
|
2006
|
-
// 不因为广播整体不再阻塞返回就失效。
|
|
2007
925
|
const openByCtx = new Map(aliveCtxs.map((c) => [c, emitOne(c)]));
|
|
2008
926
|
const emitAllP = (async () => {
|
|
2009
927
|
const results = await Promise.all(openByCtx.values());
|
|
2010
|
-
// F1 修:**两种帧任一送达**都算「卡送到人手上了」(见 emitOne 顶注)。
|
|
2011
928
|
const anySucceeded = results.some((r) => r.legacy || r.card);
|
|
2012
|
-
// ⚠️ 竞态门(cross-review 抓获,原单连接形已有的不变式,广播形延续):respond/abort 可能在 emit
|
|
2013
|
-
// 广播完成前已经 settle(entry 先于 emit 注册)。emit 随后才判定全灭时,已有的人类 boolean 裁决/
|
|
2014
|
-
// abort 终局必须保持——只有本判定是第一个 settle 者(done 仍 false)才算「卡从未送达任何人」。
|
|
2015
929
|
if (!anySucceeded && !done) {
|
|
2016
|
-
// 🔴 codex 交叉复审两轮抓获(真 finding,红先复现 [tool-approval-machine.test.ts §3 4/8 emit-race
|
|
2017
|
-
// 用例];round2 精化):`emitFailed` 不能在 store 调用的 await 之前置位(round1 已修),但仅补一个
|
|
2018
|
-
// `!done` 复核仍不够严——`expireAsk` 与 `decideAsk` 的网络往返跳数不对称(`decideAsk` 提交后另起
|
|
2019
|
-
// 一次独立 `getAsk` 读,`expireAsk` 没有,approval-ask-store-sql.ts:382-434/456-499),respond() 可能
|
|
2020
|
-
// 已经**赢下**持久 CAS 但它自己较慢的第二跳尚未落地、本地 `done` 仍是 false——此时 `expireAsk`
|
|
2021
|
-
// 干净地**输**(`res.won===false`,不是 throw),若仍然只看 `!done` 就置位 `emitFailed`,会重演
|
|
2022
|
-
// round2 finding A 同款矛盾(durable 行写着已批准,活的终局却被 emit-全灭强改成
|
|
2023
|
-
// "unavailable")。改判:**只在 `res.won===true` 时才置位 settle**;干净地输 ⇒ 什么都不做,信任
|
|
2024
|
-
// 真赢家走它自己的路径完成 settle(与 windowExpired/runCancel 现在同一形状)。throw(店真的挂了)
|
|
2025
|
-
// 仍走 D5 fail-open,保留 `!done` 保险(避免明知已经有人赢了还去无条件覆盖)。
|
|
2026
930
|
if (this.askStore && askId !== undefined && batchId !== undefined) {
|
|
2027
931
|
try {
|
|
2028
932
|
const res = await this.withStoreDeadline(this.askStore.expireAsk(askId, batchId), "expireAsk(emit-failed)");
|
|
2029
933
|
if (res.won && !done) {
|
|
2030
934
|
emitFailed = true;
|
|
2031
935
|
settle(false, "expired");
|
|
2032
|
-
// round3:同批兄弟被远程撤卡,本地同步收尾;round5 追加自己的 askId(同精神,windowExpired 侧注)。
|
|
2033
|
-
// #280 codex R3-F2:同 askId 那一支的行是 PARKING ⇒ park 路由口;兄弟(VOID)裸 expired。
|
|
2034
936
|
this.settleSameAskIdParkRoute(askId);
|
|
2035
937
|
this.settleVoidedSiblings(res.voidedSiblings);
|
|
2036
938
|
}
|
|
2037
|
-
// 干净地输:什么都不做(见上方顶注)。
|
|
2038
939
|
}
|
|
2039
940
|
catch (err) {
|
|
2040
941
|
this.noteStoreError(err, "expireAsk(emit-failed)");
|
|
2041
942
|
if (!done) {
|
|
2042
943
|
emitFailed = true;
|
|
2043
|
-
settle(false, "expired");
|
|
944
|
+
settle(false, "expired");
|
|
2044
945
|
}
|
|
2045
946
|
}
|
|
2046
947
|
}
|
|
2047
948
|
else if (!done) {
|
|
2048
949
|
emitFailed = true;
|
|
2049
|
-
settle(false, "expired");
|
|
950
|
+
settle(false, "expired");
|
|
2050
951
|
}
|
|
2051
952
|
}
|
|
2052
953
|
emitSettled = true;
|
|
2053
954
|
})();
|
|
2054
|
-
// [1559]三:emit 完成 vs TTL/abort/respond 三选一落定——显式 race,不让挂死的 emit 独占执行权。
|
|
2055
955
|
await Promise.race([emitAllP, allowedP]);
|
|
2056
956
|
const settled = await allowedP;
|
|
2057
|
-
// Completion breadcrumb (card dismiss) — fire-and-forget to every target that actually got the OPEN frame,
|
|
2058
|
-
// so a slow/hung emit never delays returning to core AND every connection that saw the card learns it
|
|
2059
|
-
// resolved elsewhere([1559]四「其余连接收 resolved/dismissed 通知」的直接落点——单连接场景退化为
|
|
2060
|
-
// 原来的一次 emit)。**per-ctx 链在该 ctx 自己的 openP 之后且看它的布尔结果**([2557] 批3 F2 D1 修,
|
|
2061
|
-
// 2026-08-06):emitOne 失败是 resolve(false) 不是 reject,旧形 `.then(() => emit(complete))` 对
|
|
2062
|
-
// resolve 值全放行 ⇒ open 没送达的连接(瞬断型 emit 目标)也收 `tool_approval_complete` = 孤儿
|
|
2063
|
-
// close,注释与代码曾不符(红先=wire-pairing-approval D1,修后翻正)。`delivered` 门后:
|
|
2064
|
-
// `.then((delivered) => delivered ? c.emit(...) : undefined)` —— complete emit 若同步 throw,
|
|
2065
|
-
// `.then` 回调内抛出转成 rejection 由尾部 `.catch` 吸收(链形不变)。
|
|
2066
957
|
for (const c of aliveCtxs) {
|
|
2067
958
|
void openByCtx
|
|
2068
959
|
.get(c)
|
|
2069
|
-
// F1 修:闭合帧的门仍**只看旧帧**(`legacy`)—— 它按 `approvalId` 消卡,只有见过开卡帧的连接才有
|
|
2070
|
-
// 卡可消;拿新帧的成功去放行闭合帧会给只收到 `approval_request` 的连接发一个孤儿 close。
|
|
2071
|
-
// #329 小件:本条 ask 真按 park 路由收尾(墓碑已落 ⇒ 迟到 decide 腿对这把 approvalId 可达)时,
|
|
2072
|
-
// 闭合帧带 `parked:true`——把 outcome:"expired" 三义中 park 那一义显式化(壳渲「已转后台候批」
|
|
2073
|
-
// 而非「卡失效」)。判别位与墓碑读**同一次判定**(`parkTombstoned`,settle 咽喉里置位)——A-058
|
|
2074
|
-
// F1/F3:政策口判别(unattendedPolicy+askStore 在场)对 VOID 兄弟/取消/D5 fail-open 臂撒谎。
|
|
2075
960
|
.then((delivered) => delivered.legacy
|
|
2076
961
|
? c.emit({
|
|
2077
962
|
type: "tool_approval_complete",
|
|
@@ -2082,69 +967,35 @@ export class ToolApprovalCoordinator {
|
|
|
2082
967
|
: undefined)
|
|
2083
968
|
.catch(() => undefined);
|
|
2084
969
|
}
|
|
2085
|
-
// emit 全灭 = 卡从未送达任何人(≠人拒绝/TTL 走人)——同属「无活人可达」类 ⇒ "unavailable" 交 durable
|
|
2086
|
-
// park(#280 R-13 C 臂(e):`deny` 政策下同样改判 deny,取值走 unattendedOutcome 那**一个**口)。
|
|
2087
970
|
if (emitFailed)
|
|
2088
971
|
return this.unattendedOutcome();
|
|
2089
|
-
// #151 车2(D2/D3):窗到期赢了持久 CAS ⇒ 强制 park 路由,不看 emit 是否已经确认送达——D2 原话
|
|
2090
|
-
// 「赢 ⇒ settle + onAsk 返回 "unavailable"」是无条件的,与下面 emit-race 覆盖判据(供 store 缺席/
|
|
2091
|
-
// emit 尚未确认两种场景)并存、互不排斥。askStore 缺席时本判据在 #280 之前恒 false;A1 之后 D1 窗到期
|
|
2092
|
-
// 臂也会置位它(park 路由是三臂共用的终局),两条路因此汇到同一个成形口。
|
|
2093
|
-
// 🔴 经 {@link shapeOutcome} 而不是就地 `return "unavailable"`:政策与宿主自报的判据只写一份
|
|
2094
|
-
// (deny 政策下 (b)(c) 要带 `settledBy:"timeout"`,就地返回会把它丢掉)。
|
|
2095
972
|
if (windowRouteUnavailable)
|
|
2096
973
|
return shapeOutcome(settled);
|
|
2097
|
-
// emit 广播尚未有任何一路确认完成(既非成功也非已知失败)就被 TTL/abort 抢先判定 ⇒ 无法确认卡是否
|
|
2098
|
-
// 曾送达任何人,不可扣一个武断的「deny」终局——按 G1 回路交 "unavailable"(同 emit 失败同一处置,
|
|
2099
|
-
// core [1559]三 确认方向;`deny` 政策同臂(e)一并改判)。respond() 落地的人类裁决(outcome
|
|
2100
|
-
// allowed/denied)不受影响,原因见本方法顶注。
|
|
2101
974
|
if (!emitSettled && lastOutcome === "expired")
|
|
2102
975
|
return this.unattendedOutcome();
|
|
2103
|
-
// [1458] 编辑放行对象臂 + core 5.23.0(#187/#114②)宿主自报臂 —— 两条判据都在
|
|
2104
|
-
// {@link shapeOutcome} 里,与早退出口**共用同一份**(codex 复审 round2:此前这里与早退臂各写一份
|
|
2105
|
-
// 等价分派,本批的 `settledBy` 于是只加到了其中一份上)。
|
|
2106
|
-
// 到得了这里说明卡**确实送达过**(emit 全灭 / park 路由两条早就 return 掉了),所以自报的「窗到期」
|
|
2107
|
-
// 是这次等待的真实结局,而不是「没人可送」。
|
|
2108
976
|
return shapeOutcome(settled);
|
|
2109
977
|
}
|
|
2110
|
-
|
|
2111
|
-
* 404 (no existence oracle), body validated first (400 is existence-independent) — question/steer parity. The HTTP
|
|
2112
|
-
* layer owns auth (gatedPrincipal + REQUIRE_PRINCIPAL) before calling.
|
|
2113
|
-
*
|
|
2114
|
-
* #151 车2(D1/D2):return type 是 UNION,不是把整个方法标 `async`——askStore 缺席时这个方法逐字同步
|
|
2115
|
-
* 完成(D1 现行为不变;现存量测试对它的同步断言/`void` 调用零改动)。在场时才真的变成 Promise(D2 的
|
|
2116
|
-
* 「settle 前先赢一把 decideAsk CAS」离不开 await,同步函数做不到)。唯一生产调用点(`routes/runs.ts`)
|
|
2117
|
-
* 统一 `await`——`await` 对非 Promise 值是恒等操作,两条路径对它透明。 */
|
|
2118
|
-
respond(id, principal, body,
|
|
2119
|
-
/** #329:发起本次回决的真实 HTTP 请求。**只**沿 PARKED 臂透传给赎回席(那条腿要它做准入解析与
|
|
2120
|
-
* 舰队 scope,与 `/decide` 腿同姿势);live 命中路径一个字节都不看它。缺席 = 非 HTTP 调用点
|
|
2121
|
-
* (测试/内部),赎回席按「无请求」形处置。 */
|
|
2122
|
-
httpReq) {
|
|
978
|
+
respond(id, principal, body, httpReq) {
|
|
2123
979
|
const parsed = parseToolApprovalResponse(body);
|
|
2124
980
|
if (!parsed.ok)
|
|
2125
981
|
return { status: 400, body: { error: parsed.error, errorCode: "request.body_shape" } };
|
|
2126
982
|
const entry = this.pending.get(id);
|
|
2127
983
|
if (!entry || (entry.owner !== null && entry.owner !== principal)) {
|
|
2128
|
-
// #329:live 未命中(或属主门不过)⇒ **不再盲 404**,按持久行的状态分派(设计 §三)。
|
|
2129
|
-
// 属主门不过的那一支同样落这里:它在墓碑上会**再判一次同一句**,不过则与未知 id 同形同码
|
|
2130
|
-
// (无存在性谕示,与本方法头注的承诺一致)。
|
|
2131
984
|
return this.respondLate(id, principal, parsed, httpReq);
|
|
2132
985
|
}
|
|
2133
|
-
|
|
2134
|
-
// decideAsk CAS,赢了才 finishRespond;输(某个其他竞争者已经决定了这只 ask 的终局)⇒ 不 settle,照旧
|
|
2135
|
-
// 回现有 404 形(响应体的 409/410 分化是车4 的活,本车只保证「输者绝不覆盖赢者」)。
|
|
986
|
+
const ruleEligibility = { eligible: true };
|
|
2136
987
|
const settled = this.askStore && entry.askId !== undefined && entry.batchId !== undefined
|
|
2137
|
-
? this.respondWithCas(this.askStore, entry.askId, entry.batchId, id, entry, parsed)
|
|
988
|
+
? this.respondWithCas(this.askStore, entry.askId, entry.batchId, id, entry, parsed, ruleEligibility)
|
|
2138
989
|
: this.finishRespond(id, entry, parsed);
|
|
2139
|
-
// #154 车二:「不再询问」的兑付口。**在裁决落定之后**(顺序是硬的:规则是「这次同意的持久化」,
|
|
2140
|
-
// 一次没有生效的裁决不该留下一条永久规则),且只在 200 那一支 —— 404(CAS 输/条目已被别人结算)
|
|
2141
|
-
// 意味着这次回决不是有效终局,连带的规则确认也就不成立。
|
|
2142
990
|
if (parsed.persistRule === undefined)
|
|
2143
991
|
return settled;
|
|
2144
|
-
return this.persistRuleAfterDecision(settled, entry, parsed.persistRule, parsed.updatedInput !== undefined);
|
|
992
|
+
return this.persistRuleAfterDecision(settled, entry, parsed.persistRule, parsed.updatedInput !== undefined, ruleEligibility);
|
|
993
|
+
}
|
|
994
|
+
editedArmEcho(persistRule, persisted) {
|
|
995
|
+
if (!persistRule.edited || !persisted.ok)
|
|
996
|
+
return {};
|
|
997
|
+
return { persistedRule: persisted.rule };
|
|
2145
998
|
}
|
|
2146
|
-
/** #329:`tool_approval.not_pending` 的**唯一**成形口。文案逐字不变(存量壳/SDK 按它认这条 404),
|
|
2147
|
-
* 只 additive 加判别位;`cause` 缺席 = 这台部署没有行可判(D1 无店)或行形不可信(见各调用点)。 */
|
|
2148
999
|
notPending(cause) {
|
|
2149
1000
|
return {
|
|
2150
1001
|
status: 404,
|
|
@@ -2155,51 +1006,31 @@ export class ToolApprovalCoordinator {
|
|
|
2155
1006
|
},
|
|
2156
1007
|
};
|
|
2157
1008
|
}
|
|
2158
|
-
/**
|
|
2159
|
-
* #329 —— live 未命中之后的**状态感知分派**(设计 §三的那张表)。
|
|
2160
|
-
*
|
|
2161
|
-
* 门两道,都在读行之前:①持久店在场(协议上场才有行可读;缺席 ⇒ D1 体逐字不变);②本副本留过
|
|
2162
|
-
* park 墓碑且属主门过(墓碑是 wire id ↔ 店内 askId 的唯一桥,见 {@link ParkTombstone})。任一不过 ⇒
|
|
2163
|
-
* 与修前逐字同形的 404(店在场时带 `unknown_or_other_replica` 判别位 —— 那正是「换副本/换端点重试才
|
|
2164
|
-
* 有意义」的那一因)。
|
|
2165
|
-
*/
|
|
2166
1009
|
respondLate(id, principal, parsed, httpReq) {
|
|
2167
1010
|
const store = this.askStore;
|
|
2168
1011
|
if (!store)
|
|
2169
|
-
return this.notPending();
|
|
1012
|
+
return this.notPending();
|
|
2170
1013
|
const tomb = this.lookupParkTombstone(id);
|
|
2171
1014
|
if (tomb === undefined || (tomb.owner !== null && tomb.owner !== principal)) {
|
|
2172
1015
|
return this.notPending("unknown_or_other_replica");
|
|
2173
1016
|
}
|
|
2174
1017
|
return this.dispatchLateByRow(store, tomb, id, parsed, httpReq, principal);
|
|
2175
1018
|
}
|
|
2176
|
-
/** {@link respondLate} 的读行 + 六态穷举分派(闭集:新增 `AskState` 而不在此表态 = **编译错**)。 */
|
|
2177
1019
|
async dispatchLateByRow(store, tomb, id, parsed, httpReq, principal) {
|
|
2178
1020
|
let row;
|
|
2179
1021
|
try {
|
|
2180
1022
|
row = await this.withStoreDeadline(store.getAsk(tomb.askId), "getAsk(respond-late)", DURABLE_CONVERGE_READ_TIMEOUT_MS);
|
|
2181
1023
|
}
|
|
2182
1024
|
catch (err) {
|
|
2183
|
-
// D5 的 fail-open 只覆盖「不挡人的真实回决」那一面。这条腿反过来:读不出行 = **不知道**这只 ask
|
|
2184
|
-
// 落在哪一态,而三条受理臂(200/200/202)全都在对客户端做承诺。不知道 ⇒ 回修前那句 404,绝不把
|
|
2185
|
-
// 未知说成受理。
|
|
2186
1025
|
this.noteStoreError(err, "getAsk(respond-late)");
|
|
2187
1026
|
return this.notPending("unknown_or_other_replica");
|
|
2188
1027
|
}
|
|
2189
1028
|
if (row === null)
|
|
2190
|
-
return this.notPending("unknown_or_other_replica");
|
|
2191
|
-
// 🔴 属主门第二道:**行上当下的** owner(codex R1-[high] 一,验真后修 —— `/v1/adoption` 会把
|
|
2192
|
-
// `approval_ask.owner` 改写到新身份,而墓碑里那份是 park 那一刻的快照)。缓存值只是预筛,权威是这一行。
|
|
2193
|
-
// 判据与 durable 回决腿逐字同源(`row0.owner === null || principal === row0.owner`),但**不带** operator
|
|
2194
|
-
// 越权臂:这张卡是终端用户自己的(本方法头注「no operator bypass」),迟到不改变归属。
|
|
1029
|
+
return this.notPending("unknown_or_other_replica");
|
|
2195
1030
|
if (row.owner !== null && row.owner !== principal) {
|
|
2196
1031
|
defaultLogger.warn("tool approval late decision rejected: the ask row changed owner since it parked", { askId: row.askId });
|
|
2197
1032
|
return this.notPending("unknown_or_other_replica");
|
|
2198
1033
|
}
|
|
2199
|
-
// 已受理过一次赎回 ⇒ 回放本副本那张回执(见 {@link ParkTombstone.redeemed})。位置在**属主复核之后**
|
|
2200
|
-
// (codex R2-[medium] 二,验真后修:回执体里带着 run/session 坐标,归属改写之后前属主不该再拿到它;
|
|
2201
|
-
// 「他自己早就见过」不构成继续发的理由 —— 门的语义是「按当下的持久事实判」,不留例外臂)。行此刻仍是
|
|
2202
|
-
// PARKED(赎回不改 ask 行),所以这一支必须排在下面 switch 之前,否则重试会去动一张已被消费的 checkpoint。
|
|
2203
1034
|
if (tomb.redeemed !== undefined)
|
|
2204
1035
|
return this.replayRedeemReceipt(id, tomb.askId, tomb.redeemed, parsed);
|
|
2205
1036
|
switch (row.state) {
|
|
@@ -2208,9 +1039,6 @@ export class ToolApprovalCoordinator {
|
|
|
2208
1039
|
case "PARKED":
|
|
2209
1040
|
return this.redeemParkedAsk(id, tomb, row, parsed, httpReq, principal);
|
|
2210
1041
|
case "PARKING":
|
|
2211
|
-
// 极窄转移窗(`bindBatch` 的单事务:PARKING→PARKED + gate 落行,毫秒级)。**不预存决议** ——
|
|
2212
|
-
// 预存 = 在 gate CAS 之外开第二个写点,正是 #290 禁的双赢者形([4872] 锚②)。客户端一次重试
|
|
2213
|
-
// 就落 PARKED 臂;这里一个字节都不动行。
|
|
2214
1042
|
return {
|
|
2215
1043
|
status: 202,
|
|
2216
1044
|
body: {
|
|
@@ -2226,37 +1054,19 @@ export class ToolApprovalCoordinator {
|
|
|
2226
1054
|
case "VOID":
|
|
2227
1055
|
return this.notPending("voided");
|
|
2228
1056
|
case "STREAM_PENDING":
|
|
2229
|
-
// 墓碑在、行却仍活着:本副本这条注册按 park 路由收了尾而那次 `expireAsk` 其实没提交(店报错臂),
|
|
2230
|
-
// 或同 askId 的另一条注册还在别处等着。本副本已经没有闭包可以兑现这次决议 ⇒ 与「不在这台机器上」
|
|
2231
|
-
// 同码(壳换端点/重试才有意义),绝不在这里替别人打 CAS。
|
|
2232
1057
|
return this.notPending("unknown_or_other_replica");
|
|
2233
1058
|
default: {
|
|
2234
|
-
// 闭集穷举(#157 词表纪律):`AskState` 加员而不在上面表态,这一行**编译期**先红。
|
|
2235
1059
|
const unhandled = row.state;
|
|
2236
1060
|
defaultLogger.error("tool approval respond hit an unknown ask state", { askId: tomb.askId, state: unhandled });
|
|
2237
1061
|
return this.notPending();
|
|
2238
1062
|
}
|
|
2239
1063
|
}
|
|
2240
1064
|
}
|
|
2241
|
-
/** #329:迟到腿上的 `persistRule` 兑付面 —— 本地条目(规则车道素材的载体)早已随 park 路由散场,
|
|
2242
|
-
* 没有任何可对的候选表 ⇒ 如实拒(裁决本身照常受理,与 {@link persistRuleAfterDecision} 的
|
|
2243
|
-
* 「失败不翻转裁决」同判)。用既有词 `rule_lane_unavailable`,不为这一支新造词。 */
|
|
2244
1065
|
lateRuleRefusal(parsed) {
|
|
2245
1066
|
if (parsed.persistRule === undefined)
|
|
2246
1067
|
return {};
|
|
2247
1068
|
return { rulePersisted: false, ruleRefusal: "rule_lane_unavailable" };
|
|
2248
1069
|
}
|
|
2249
|
-
/**
|
|
2250
|
-
* #329 G2 —— DECIDED 行的**幂等回放**([4872] 锚③)。
|
|
2251
|
-
*
|
|
2252
|
-
* 同向迟到答与**反向**迟到答同判:都是 200 回放**首决**。反向不是 409、不是报错、更不改判 —— 那是
|
|
2253
|
-
* 一个 CAS 败者的诚实答:决议早已由首决落定,这次请求只是晚到,响应体把首决交回去,消费端自判向。
|
|
2254
|
-
*
|
|
2255
|
-
* 权威性四合取与 durable 回决腿的 `decidedRowIsAuthoritative` **同判据**(那处顶注是唯一真源):
|
|
2256
|
-
* 决议二值 ∧ 非 provisional ∧ 有 `decidedAtMs` ∧ 态确为 DECIDED。不合形 ⇒ 退回修前 404(绝不把一条
|
|
2257
|
-
* 坏行包装成「人的首决」交出去);此处比那条腿更保守地不铸 500,理由是本腿的客户端是壳上那张卡,
|
|
2258
|
-
* 它对 404 有成文处置(消卡),对 500 没有。
|
|
2259
|
-
*/
|
|
2260
1070
|
replayDecidedAsk(id, row, parsed) {
|
|
2261
1071
|
if ((row.decision !== "approve" && row.decision !== "deny") || row.provisional || row.decidedAtMs === null) {
|
|
2262
1072
|
defaultLogger.error("tool approval late replay saw a DECIDED row that is not a usable human decision", {
|
|
@@ -2267,12 +1077,7 @@ export class ToolApprovalCoordinator {
|
|
|
2267
1077
|
});
|
|
2268
1078
|
return this.notPending();
|
|
2269
1079
|
}
|
|
2270
|
-
// 见到一条权威 DECIDED 行 ⇒ **先把本副本活体窗同步到它**(durable 腿 `syncLiveFromDecidedRow` 的同一
|
|
2271
|
-
// 条纪律:响应分类不许决定本地是否结算)。同 askId 下的其余本地注册可能还挂着,`notifyExternalDecision`
|
|
2272
|
-
// 对已结算条目天然幂等。
|
|
2273
1080
|
const settled = this.notifyExternalDecision(row.askId, row.decision === "approve");
|
|
2274
|
-
// A-054.15:行派生的 approve —— 行上没有 `updated_input` 列,交出去的裸 `true` 会让 core 落回原始实参。
|
|
2275
|
-
// 与全仓同形站点计同一个 tag,且只在真结算了本地闭包时记(记了没发生的事 = 遥测撒谎)。
|
|
2276
1081
|
if (row.decision === "approve" && settled.settled > 0)
|
|
2277
1082
|
recordRowDerivedApproveReplay("respond-late-replay");
|
|
2278
1083
|
return {
|
|
@@ -2281,8 +1086,6 @@ export class ToolApprovalCoordinator {
|
|
|
2281
1086
|
approvalId: id,
|
|
2282
1087
|
askId: row.askId,
|
|
2283
1088
|
idempotent: true,
|
|
2284
|
-
// 回显的是**首决**,不是这次请求带来的那个词。行上是二值(`allow_session` 落行时已折成
|
|
2285
|
-
// `approve`)⇒ 回放恒是 `allow`/`deny` 两词,不假装分得出当初点的是哪一格。
|
|
2286
1089
|
decision: row.decision === "approve" ? "allow" : "deny",
|
|
2287
1090
|
decidedAtMs: row.decidedAtMs,
|
|
2288
1091
|
...(row.decisionNote !== null ? { decisionNote: row.decisionNote } : {}),
|
|
@@ -2291,36 +1094,16 @@ export class ToolApprovalCoordinator {
|
|
|
2291
1094
|
},
|
|
2292
1095
|
};
|
|
2293
1096
|
}
|
|
2294
|
-
/**
|
|
2295
|
-
* #329 G1 —— PARKED 行的迟到决议:**桥接既有赎回腿**([4872] 锚①「同腿新调用方,零新终局语义」)。
|
|
2296
|
-
*
|
|
2297
|
-
* 为什么桥接而不是在这里 revive-直续:PARKED 的语义是「候下一 run 重呈或 operator 决议」,而
|
|
2298
|
-
* operator 决议早有成文入口(`/decide` 那条链:checkpoint decide CAS → resume)。桥接 = 那条链多一个
|
|
2299
|
-
* 调用方,单赢者仍是它的 CAS;直续则要发明第三种赎回形,与 core 的 park/revive 契约重叠。
|
|
2300
|
-
*
|
|
2301
|
-
* 200 是**受理**语义(#316 先例):resume 的驱动是 core 内的 fire-and-forget,结果面走 run/events。
|
|
2302
|
-
* 非 2xx 原样透出(gate 被别人收走 ⇒ 409 等),绝不粉饰成受理。
|
|
2303
|
-
*/
|
|
2304
1097
|
async redeemParkedAsk(id, tomb, row, parsed, httpReq, principal) {
|
|
2305
1098
|
const redeem = this.parkedRedeem?.();
|
|
2306
|
-
// 席缺席 = 这台部署没接赎回腿(无 checkpoint/bg 店,或装配层显式不接)⇒ 逐字回落修前 404。
|
|
2307
1099
|
if (redeem === undefined)
|
|
2308
1100
|
return this.notPending();
|
|
2309
1101
|
if (row.gateToken === null) {
|
|
2310
|
-
// PARKED 恒带 gate 三坐标(`bindBatch` 一次事务同写)。缺 = 行受损,没有坐标可赎 ⇒ 诚实 404 + 一行
|
|
2311
|
-
// error(fail-closed:绝不拿一个空 token 去打赎回腿的 CAS)。
|
|
2312
1102
|
defaultLogger.error("tool approval parked row carries no gate token — nothing to redeem", { askId: row.askId });
|
|
2313
1103
|
return this.notPending();
|
|
2314
1104
|
}
|
|
2315
|
-
// 已受理 ⇒ 直接回放(与 {@link dispatchLateByRow} 那道同源;两处各守一个时点:那里守的是「上一次
|
|
2316
|
-
// 请求早已落定」,这里守的是「本次请求在读行期间被另一路落定」)。
|
|
2317
1105
|
if (tomb.redeemed !== undefined)
|
|
2318
1106
|
return this.replayRedeemReceipt(id, row.askId, tomb.redeemed, parsed);
|
|
2319
|
-
// 🔴 **单飞**(codex 对抗复审 R2-[medium] 三,验真后修):两路**重叠**的重试都会读到「无回执」并各自
|
|
2320
|
-
// 进赎回腿 —— 一路赢下 CAS,另一路拿 409/404。对「200 丢过一次、客户端并发重发」的真实客户端来说,
|
|
2321
|
-
// 那等于被告知失败而其实决议已被受理。所以按墓碑挂一枚在飞句柄:后到者不再打第二次赎回,而是**等**
|
|
2322
|
-
// 赢家并回放它的结果。非 2xx 时句柄随即清掉(下一次重试是真重试);2xx 落回执,由上面那道接。
|
|
2323
|
-
// ⚠️ 单飞不是第二个决定者:它只合并**同一把 approvalId 的重复请求**,任何终局仍由赎回腿的 CAS 定。
|
|
2324
1107
|
if (tomb.inflight !== undefined) {
|
|
2325
1108
|
const shared = await tomb.inflight;
|
|
2326
1109
|
if (shared.status < 200 || shared.status >= 300)
|
|
@@ -2345,10 +1128,6 @@ export class ToolApprovalCoordinator {
|
|
|
2345
1128
|
return { ...res, decision };
|
|
2346
1129
|
}
|
|
2347
1130
|
catch (err) {
|
|
2348
|
-
// 赎回腿自己抛 = 内部故障,不是「这只 ask 不在」。404 会让壳消掉一张其实仍可赎的卡,所以如实 500。
|
|
2349
|
-
// 码用**既有**的通用 `internal.error`(durable 回决腿的同类臂用的也是它),不为一条内部故障臂新造
|
|
2350
|
-
// wire 词表成员;归因走日志(部署方有,调用方没有)。**不 rethrow**:句柄要以 resolve 收场,
|
|
2351
|
-
// 等在它上面的那一路才不会拿到一个逃逸的 rejection。
|
|
2352
1131
|
defaultLogger.error("tool approval parked redemption threw", { askId: row.askId, error: err instanceof Error ? err.message : String(err) });
|
|
2353
1132
|
return { status: 500, body: { error: "the parked approval could not be redeemed right now — retry", errorCode: "internal.error" }, decision };
|
|
2354
1133
|
}
|
|
@@ -2359,12 +1138,10 @@ export class ToolApprovalCoordinator {
|
|
|
2359
1138
|
out = await flight;
|
|
2360
1139
|
}
|
|
2361
1140
|
finally {
|
|
2362
|
-
tomb.inflight = undefined;
|
|
1141
|
+
tomb.inflight = undefined;
|
|
2363
1142
|
}
|
|
2364
1143
|
if (out.status < 200 || out.status >= 300)
|
|
2365
1144
|
return { status: out.status, body: out.body };
|
|
2366
|
-
// 受理成功 ⇒ 留回执(**只在 2xx 之后**;失败绝不留,否则一次没生效的决议会被后来的重试当成已受理)。
|
|
2367
|
-
// 它接住的是「200 丢包后客户端重试」那一形,见 {@link ParkTombstone.redeemed}。
|
|
2368
1145
|
tomb.redeemed = { decision, body: out.body };
|
|
2369
1146
|
return {
|
|
2370
1147
|
status: out.status,
|
|
@@ -2373,16 +1150,11 @@ export class ToolApprovalCoordinator {
|
|
|
2373
1150
|
approvalId: id,
|
|
2374
1151
|
askId: row.askId,
|
|
2375
1152
|
accepted: "parked_redeemed",
|
|
2376
|
-
// 🔴 `allow_session` 在这条腿上**不铸** grant:墓碑刻意不携安全类/治理档两位(#287 / A-054.20 的
|
|
2377
|
-
// 写侧配对臂读的是本地条目上的它们),没有那两位就无从判断这只 ask 有没有资格换一条常驻放行 ——
|
|
2378
|
-
// fail-closed 不铸,并如实回显,壳据它不渲「本会话全放行」。
|
|
2379
1153
|
...(parsed.value === "allow_session" ? { rememberApplied: false } : {}),
|
|
2380
1154
|
...this.lateRuleRefusal(parsed),
|
|
2381
1155
|
},
|
|
2382
1156
|
};
|
|
2383
1157
|
}
|
|
2384
|
-
/** #329:回执/单飞胜者的**回放形** —— 与首次受理同键集,多一位 `idempotent`,`decision` 恒是**受理时**
|
|
2385
|
-
* 那一向(反向重试不改判,与 DECIDED 臂同一条纪律)。 */
|
|
2386
1158
|
replayRedeemReceipt(id, askId, receipt, parsed) {
|
|
2387
1159
|
return {
|
|
2388
1160
|
status: 200,
|
|
@@ -2398,124 +1170,74 @@ export class ToolApprovalCoordinator {
|
|
|
2398
1170
|
},
|
|
2399
1171
|
};
|
|
2400
1172
|
}
|
|
2401
|
-
|
|
2402
|
-
* #154 车二:回决携规则确认 ⇒ prepare→confirm→redeem(实现在 `rules-consent.ts`,本方法只做门与回显)。
|
|
2403
|
-
*
|
|
2404
|
-
* 三道门,每道都是**拒绝**而不是降级:
|
|
2405
|
-
* ① 裁决必须真落定(200)—— 见调用点注;
|
|
2406
|
-
* ② 必须有已验明的 owner —— 规则是**某个人**的持久同意,一条 `owner === null` 的 ask 没有归属人可写;
|
|
2407
|
-
* ③ 本 ask 必须登记过规则车道素材(店在场 ∧ 引擎铸过候选 ∧ 命令可读)。
|
|
2408
|
-
*
|
|
2409
|
-
* 失败**不翻转裁决**:人的「允许这次」已经生效并且已经交给引擎了,把整个回决改判成 4xx 会让一次真实的
|
|
2410
|
-
* 放行凭空消失。诚实的形是 200 + `rulePersisted:false`(壳据它告诉人「这次放行了,但『不再询问』没存上」)。
|
|
2411
|
-
*/
|
|
2412
|
-
async persistRuleAfterDecision(settled, entry, ruleText, inputWasEdited) {
|
|
1173
|
+
async persistRuleAfterDecision(settled, entry, persistRule, inputWasEdited, ruleEligibility) {
|
|
2413
1174
|
const result = await settled;
|
|
2414
1175
|
if (result.status !== 200)
|
|
2415
1176
|
return result;
|
|
2416
1177
|
const lane = this.ruleConsent;
|
|
2417
1178
|
const material = entry.ruleLane;
|
|
2418
1179
|
const owner = entry.owner;
|
|
2419
|
-
const
|
|
1180
|
+
const ruleText = persistRule.rule;
|
|
1181
|
+
const withFlag = (rulePersisted, ruleRefusal, echo) => ({
|
|
2420
1182
|
status: result.status,
|
|
2421
|
-
body: { ...result.body, rulePersisted, ...(ruleRefusal !== undefined ? { ruleRefusal } : {}) },
|
|
1183
|
+
body: { ...result.body, rulePersisted, ...(ruleRefusal !== undefined ? { ruleRefusal } : {}), ...(echo ?? {}) },
|
|
2422
1184
|
});
|
|
2423
|
-
// 🔴 **治理档第二道门**(#204 件7),排在 `rule_lane_unavailable` 那道**之前**:两道门都会命中一只
|
|
2424
|
-
// 治理 ask(铸侧不登记素材 ⇒ `material === undefined`),但回给壳的词必须是精确的那一个 ——
|
|
2425
|
-
// `rule_lane_unavailable` 意为「这台部署压根不供规则」,壳据它可以从此不渲这一格,而这里的真相是
|
|
2426
|
-
// 「车道好好的,只是**这一只** ask 归 operator 管」。与铸侧 `buildRuleLaneMaterial` 的同源门成对:
|
|
2427
|
-
// 少了这一道,一次回滚/旧壳(或将来某条不经铸侧登记的回决路径)就能把 operator 档位绕过去。
|
|
2428
1185
|
if (entry.governanceForced === true)
|
|
2429
1186
|
return withFlag(false, "rule_governance_forced");
|
|
2430
1187
|
if (lane === undefined || material === undefined || owner === null)
|
|
2431
1188
|
return withFlag(false, "rule_lane_unavailable");
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
// 于是「把一条危险命令改安全 + 勾上不再询问」会给原始那条危险命令装上一条常驻放行规则。
|
|
2435
|
-
// v1 处置 = **拒绝这次持久化**(裁决本身照常生效;人只是这一次没能顺手存下规则)。
|
|
2436
|
-
// 不选「从编辑后的输入重铸候选」:`updatedInput` 是 `unknown` 的整体替换形,它与这只 ask 上引擎
|
|
2437
|
-
// 铸的候选之间没有任何等式可对 —— 那条路要先给编辑面立形,是另一件事(登记为后续件)。
|
|
1189
|
+
if (!ruleEligibility.eligible)
|
|
1190
|
+
return withFlag(false, "rule_store_error");
|
|
2438
1191
|
if (inputWasEdited)
|
|
2439
1192
|
return withFlag(false, "rule_input_edited");
|
|
2440
|
-
|
|
2441
|
-
// 旧卡的残留,要么是有人在试着自铸规则,两种都不该落库。
|
|
2442
|
-
if (!material.suggestions.some((s) => s.rule === ruleText))
|
|
1193
|
+
if (!persistRule.edited && !material.suggestions.some((s) => s.rule === ruleText))
|
|
2443
1194
|
return withFlag(false, "rule_not_offered");
|
|
2444
1195
|
try {
|
|
2445
1196
|
const persisted = await lane.persistCardRule({
|
|
2446
1197
|
principal: owner,
|
|
2447
1198
|
toolName: entry.toolName,
|
|
2448
1199
|
command: material.command,
|
|
2449
|
-
ruleText,
|
|
1200
|
+
redemption: persistRule.edited ? { kind: "edited", text: ruleText } : { kind: "offered", ruleText },
|
|
2450
1201
|
...(material.toolCallId !== undefined ? { toolCallId: material.toolCallId } : {}),
|
|
2451
1202
|
...(material.boundInputHash !== undefined ? { boundInputHash: material.boundInputHash } : {}),
|
|
2452
|
-
// #295(F-1,[4512] (b')):授权发生地已知 ⇒ 落 project scope;未知 ⇒ 键缺席,core 落 global
|
|
2453
|
-
// (= 修前行为;绝不在坐标系不明时编一个 root)。root 语义与撤销面判别式串 `project:<root>` 同源。
|
|
2454
1203
|
...(material.scopeRoot !== undefined ? { scope: { kind: "project", root: material.scopeRoot } } : {}),
|
|
2455
1204
|
});
|
|
2456
1205
|
if (!persisted.ok) {
|
|
2457
|
-
defaultLogger.warn("permission rule was not persisted after an approval", { reason: persisted.reason, detail: persisted.detail });
|
|
1206
|
+
defaultLogger.warn("permission rule was not persisted after an approval", { reason: persisted.reason, ...(persisted.detail !== undefined ? { detail: redactSecrets(persisted.detail) } : {}) });
|
|
2458
1207
|
return withFlag(false, persisted.reason);
|
|
2459
1208
|
}
|
|
2460
|
-
return withFlag(true);
|
|
1209
|
+
return withFlag(true, undefined, this.editedArmEcho(persistRule, persisted));
|
|
2461
1210
|
}
|
|
2462
1211
|
catch (err) {
|
|
2463
|
-
// D5 同族姿势:店抖动不改判人的裁决,只如实报「没存上」。
|
|
2464
1212
|
this.noteStoreError(err, "persistCardRule");
|
|
2465
1213
|
return withFlag(false, "rule_store_error");
|
|
2466
1214
|
}
|
|
2467
1215
|
}
|
|
2468
|
-
|
|
2469
|
-
* 时仍是纯同步函数(D1)。`askStore`/`askId`/`batchId` 由调用方在已窄化的分支里传入(避免非空断言)。 */
|
|
2470
|
-
async respondWithCas(askStore, askId, batchId, id, entry, parsed) {
|
|
1216
|
+
async respondWithCas(askStore, askId, batchId, id, entry, parsed, ruleEligibility) {
|
|
2471
1217
|
let decidedWon = false;
|
|
2472
|
-
// 🔴 #229 + codex 对抗复审 round1 [high](验真后修):`noteRecorded` 的判据比 `decidedWon` **更严**,
|
|
2473
|
-
// 所以是**两个**变量而不是一个。`decidedWon` 回答「这条决议是不是终局」(单赢者 CAS 的语义,足够支撑
|
|
2474
|
-
// 200/404 的分派与 `notifyExternalDecision`);`noteLanded` 回答「**这一次请求**的那段文本在不在行上」。
|
|
2475
|
-
// 两者在**提交歧义**臂上会分岔(见下方 catch 里的理由),而回执上那句「你的理由记上了」只能由后者答。
|
|
2476
1218
|
let noteLanded = false;
|
|
2477
1219
|
try {
|
|
2478
|
-
const decided = await this.withStoreDeadline(
|
|
2479
|
-
// #229:`decisionNote` 是**店缝上既有**的键(`DecideAskInput.decisionNote`,SQL twin 与 memory twin
|
|
2480
|
-
// 都已实装)⇒ 零新 SQL、零新列,live 腿与 durable 腿写同一条 UPDATE 的同一列。
|
|
2481
|
-
askStore.decideAsk(askId, batchId, {
|
|
1220
|
+
const decided = await this.withStoreDeadline(askStore.decideAsk(askId, batchId, {
|
|
2482
1221
|
decision: parsed.value === "deny" ? "deny" : "approve",
|
|
2483
1222
|
...(parsed.note !== undefined ? { decisionNote: parsed.note } : {}),
|
|
2484
1223
|
}), "decideAsk", DURABLE_CALL_TIMEOUT_MS, (late) => {
|
|
2485
|
-
// R3-1 迟到成功:HTTP 响应早已发出(这次调用报的是超时),但**决议真落了盘** —— 同 askId 下
|
|
2486
|
-
// 还在悬挂的本地条目必须按真决议结清,否则它们只能等自己的窗/取消再绕一圈。
|
|
2487
1224
|
if (late.ok)
|
|
2488
1225
|
this.notifyExternalDecision(askId, parsed.value !== "deny", parsed.updatedInput);
|
|
2489
1226
|
});
|
|
2490
1227
|
decidedWon = decided.ok;
|
|
2491
|
-
// 干净赢下 CAS ⇒ note 与决议是**同一条 UPDATE** 写的,赢即落,不必再读一次。
|
|
2492
1228
|
if (decided.ok)
|
|
2493
1229
|
noteLanded = true;
|
|
2494
1230
|
if (!decided.ok) {
|
|
2495
|
-
|
|
2496
|
-
return this.notPending(); // 同一句 wire 文案的唯一成形口(#329 起收口;CAS 输者不带判别位——它不是「行的状态」问题)
|
|
1231
|
+
return this.notPending();
|
|
2497
1232
|
}
|
|
2498
1233
|
}
|
|
2499
1234
|
catch (err) {
|
|
2500
|
-
this.noteStoreError(err, "decideAsk");
|
|
2501
|
-
|
|
2502
|
-
// `getAsk` 读」的形状 —— 那一跳抛错时,事务**可能已经提交**(人的决定真落了盘),而这里的
|
|
2503
|
-
// `decidedWon` 却还是 false。这种「不知道」如果直接当成「没赢」,下面的守卫会对一个**已经生效**的
|
|
2504
|
-
// 决定回 404。补一次**有界**的复核读:行已是 `DECIDED` 且决议与本次请求逐字一致 ⇒ 就是我们赢的
|
|
2505
|
-
// (`decideAsk` 的 CAS 是单赢者,别人不可能写出同一条决议再让我们看见——除非是同一个请求的重试,
|
|
2506
|
-
// 那本就该回放成功)。复核读本身失败/超时 ⇒ 维持「不知道」,守卫照旧 404(不许把未知说成成功)。
|
|
1235
|
+
this.noteStoreError(err, "decideAsk");
|
|
1236
|
+
ruleEligibility.eligible = false;
|
|
2507
1237
|
try {
|
|
2508
1238
|
const row = await this.withStoreDeadline(askStore.getAsk(askId), "getAsk(decideAsk-confirm)", DURABLE_CONVERGE_READ_TIMEOUT_MS);
|
|
2509
1239
|
if (row?.state === "DECIDED" && row.decision === (parsed.value === "deny" ? "deny" : "approve")) {
|
|
2510
1240
|
decidedWon = true;
|
|
2511
|
-
// 🔴 codex 对抗复审 round1 [high](真 finding,红先复现):**note 的判据不能沿用决议的判据**。
|
|
2512
|
-
// 上一段那句「别人不可能写出同一条决议再让我们看见」对**决议**成立(单赢者 CAS),对 `note`
|
|
2513
|
-
// **不成立** —— 两路并发 respond 完全可以带**同决议、不同 note**:一路干净赢下 CAS(写进去的是
|
|
2514
|
-
// 它的 note),另一路撞上提交歧义,在这次确认读里看见那条 DECIDED 行。只比决议的话,输的那一路
|
|
2515
|
-
// 会把别人的胜利认成自己的,回一句「你的理由记上了」,而行上是**别人**的理由 —— 恰好是本布尔
|
|
2516
|
-
// 存在的意义被反过来用。所以这里逐字比**行上的 note**:相等才算落地(文本真的相同就不是谎,
|
|
2517
|
-
// 哪怕是别人写的);行上没有 note、或与本次提交不同 ⇒ 如实 `false`。
|
|
2518
|
-
// 决议侧的 `decidedWon` 保持原样(200/404 的分派与 `notifyExternalDecision` 的判据不动)。
|
|
2519
1241
|
noteLanded = parsed.note === undefined || row.decisionNote === parsed.note;
|
|
2520
1242
|
}
|
|
2521
1243
|
}
|
|
@@ -2523,76 +1245,26 @@ export class ToolApprovalCoordinator {
|
|
|
2523
1245
|
this.noteStoreError(confirmErr, "getAsk(decideAsk-confirm)");
|
|
2524
1246
|
}
|
|
2525
1247
|
}
|
|
2526
|
-
// 🔴 codex 交叉复审抓获(真 finding):`decideAsk` throw(店抖动、非 CAS 输)时上面完全没有 return——
|
|
2527
|
-
// 两个真正并发的 respond() 调用若都撞上这次抖动,会都揣着同一个(尚未被任何一方 settle 过的)`entry`
|
|
2528
|
-
// 走到这里;`finishRespond` 本身不重新核验 `entry` 是否还活着,会让**两个**调用都拿到 200 且都可能
|
|
2529
|
-
// 修改 `allowAllSessions`(先落地的赢家 settle 生效,晚到的那个理应 404,却会在 store 抖动窗口内被
|
|
2530
|
-
// 误当成「也成功了」——包括它自己的 `allow_session` 语义,即便真正的终局是另一路的 deny)。这里用
|
|
2531
|
-
// `this.pending` 本身当权威:是否还是**这一个** entry 引用(而非仅仅「id 还在」——万一同一 id 已被
|
|
2532
|
-
// 别的机制以别的对象重铸,值比较比键存在性更严——现网这只会是防御性写法,和 [1550] 的 originCtx
|
|
2533
|
-
// identity-compare 同精神)。已被任何竞争者(另一 respond / 取消 / 窗到期)结算过 ⇒ 如实 404,不
|
|
2534
|
-
// 覆盖、不重复应用 session 记忆。
|
|
2535
1248
|
const wantAllowed = parsed.value !== "deny";
|
|
2536
1249
|
if (this.pending.get(id) !== entry) {
|
|
2537
|
-
// 🔴 #151 车5(X-1 状态感知分派的连带收口,2026-08-06):判据从「本地条目还在不在」精化成
|
|
2538
|
-
// 「**这次回决是不是有效终局**」。两支分开:
|
|
2539
|
-
// (a) `decideAsk` **抛错**(`decidedWon=false`,不知道谁赢)⇒ 逐字保留 round1 的 404(否则两路
|
|
2540
|
-
// 并发 respond 在店抖动窗内会都自称成功、都改 `allowAllSessions`);
|
|
2541
|
-
// (b) CAS **真赢**了 ⇒ 再看本地那次抢先结算与本次决议**是否一致**:
|
|
2542
|
-
// · 一致(取消/窗到期臂经 `convergeFromDurableState` 重读到的正是这次刚写下的 `DECIDED`)
|
|
2543
|
-
// ⇒ 行是 approve、闭包也交了 approve,回 404「no pending approval」等于对壳撒谎,必须 200;
|
|
2544
|
-
// · 不一致(D5 fail-open 的无条件 `settle(false)` —— 店真挂了那一支)⇒ 引擎实际收到的是 deny,
|
|
2545
|
-
// 如实 404,绝不把一次没有生效的批准回显成成功(round6 钉住的正是这一支)。
|
|
2546
|
-
// 无论哪一支,只要 CAS 真赢就先把同 askId 下**其余**尚未闻讯的本地条目按真决议清算(F30;round6
|
|
2547
|
-
// 修的「因为提前 404 而跳过收尾」缺口在这里保住)。
|
|
2548
1250
|
if (decidedWon)
|
|
2549
1251
|
this.notifyExternalDecision(askId, wantAllowed, parsed.updatedInput);
|
|
2550
1252
|
const converged = decidedWon && entry.settledOutcome === (wantAllowed ? "allowed" : "denied");
|
|
2551
1253
|
if (!converged) {
|
|
2552
|
-
return this.notPending();
|
|
1254
|
+
return this.notPending();
|
|
2553
1255
|
}
|
|
2554
|
-
// 一致 ⇒ 走与正常路径**同一段**收尾:`finishRespond` 对已结算 entry 调 `settle` 天然无操作(幂等),
|
|
2555
|
-
// session 记忆照记(grant 谈的是**将来**的 ask,与这次投递是否由我完成无关)。
|
|
2556
|
-
// 但 `updatedInput` **没有**随那次结算送达闭包(R2-4)⇒ 显式声明未投递,回显里不许出现
|
|
2557
|
-
// `updatedInputForwarded`。
|
|
2558
|
-
// #229:`noteRecorded` 取的是**店的真实结果**(`noteLanded`,比 `decidedWon` 更严——见其声明处),
|
|
2559
|
-
// 与 `updatedInputForwarded` 的判据刻意分家:那一格问「闭包收到编辑没有」(本支恒否),
|
|
2560
|
-
// 这一格问「行上记下的是不是**这次**的理由」。两件事,不共用一个布尔。
|
|
2561
1256
|
return this.finishRespond(id, entry, parsed, { updatedInputDelivered: false, noteRecorded: noteLanded });
|
|
2562
1257
|
}
|
|
2563
1258
|
const result = this.finishRespond(id, entry, parsed, { noteRecorded: noteLanded });
|
|
2564
|
-
// round5(pendingByAskId 顶注):回决赢下 CAS 时同样要收尾「同 askId 重复本地注册」那一支——与
|
|
2565
|
-
// windowExpired/runCancel/emitAllP 三条竞争者的赢家路径同精神(那三处已经这么做)。此处 entry 已经
|
|
2566
|
-
// 经 `finishRespond` 自行 settle 并从 pendingByAskId 的 Set 里摘除自己,故这里天然只会清算真正
|
|
2567
|
-
// 「其余」的重复注册,不会覆盖自己刚落定的裁决。(F30 收口:按真决议,同上分支的理由。)
|
|
2568
1259
|
this.notifyExternalDecision(askId, wantAllowed, parsed.updatedInput);
|
|
2569
1260
|
return result;
|
|
2570
1261
|
}
|
|
2571
|
-
/** 回决收尾(D1 现行为逐字不变的那一半)——session allow-all 记账 + settle + 200 响应体。原 `respond()`
|
|
2572
|
-
* 方法体的逐字搬运(#151 车2 拆分,行为零改动)。 */
|
|
2573
1262
|
finishRespond(id, entry, parsed, opts) {
|
|
2574
|
-
// PAIR-REVIEW [1392]②:ack 带 rememberApplied 诚实回显——allow_session 且**真记了**(sessionId 在,
|
|
2575
|
-
// grant 落桥内店)= true;allow_session 但无 sessionId(adhoc 无会话,记不了)= false(壳不得渲染
|
|
2576
|
-
// 「本 session 全放行」);普通 allow/deny 不涉 remember = 字段省略。durable decide 腿的同名回显
|
|
2577
|
-
// (`routes/approvals-assistant.ts` 的 `rememberApplied`)语义对齐。
|
|
2578
|
-
// #287([4434]② P0 的写侧配对臂):安全类 ask(requiresRealApproval)的卡上点了 allow_session,
|
|
2579
|
-
// 只放行**这一次**调用、绝不铸 session grant——铸了就等于把「必须逐次真人批」的位翻译成「批一次
|
|
2580
|
-
// 解锁整族」。rememberApplied=false 诚实回显(壳不得渲染「本 session 全放行」)。读侧配对臂
|
|
2581
|
-
// (askBroadcast 短路跳过)保证即使别的普通 ask 铸过同族 grant,安全类 ask 也照样出卡。
|
|
2582
|
-
// 🔴 A-054.20 写侧配对臂(与 #287 的安全类臂同形、同理由):**治理档的卡上点 allow_session 不铸 grant**。
|
|
2583
|
-
// 铸了就等于把「运维治理层下的那道门」翻译成「批一次解锁整族」;`buildRuleLaneMaterial` 对持久规则
|
|
2584
|
-
// 早已是这个判据(治理 ask 不进车道),session grant 只是它更短命的一揽子形。读侧配对臂在
|
|
2585
|
-
// `askBroadcast`(治理 ask 无视既有 grant)—— **两臂各管一半**:读侧还额外覆盖「普通卡铸的 grant
|
|
2586
|
-
// 掀掉后续治理门」这条更宽的面,写侧管的是「治理卡自己别再去铸一个」。
|
|
2587
1263
|
let rememberApplied;
|
|
2588
1264
|
const grantable = entry.requiresRealApproval !== true && entry.governanceForced !== true;
|
|
2589
1265
|
if (parsed.value === "allow_session")
|
|
2590
1266
|
rememberApplied = Boolean(entry.sessionId) && grantable;
|
|
2591
1267
|
if (parsed.value === "allow_session" && entry.sessionId && grantable) {
|
|
2592
|
-
// Bounded remember (insertion-order evict). 修2 scope: the grant is keyed by THIS ask's own capability
|
|
2593
|
-
// category — a Write/Edit-family ask unlocks the fs-write family for the (owner, session); ANY other tool's
|
|
2594
|
-
// allow_session unlocks only that same tool. A cheap non-write ask can therefore never become a session-wide
|
|
2595
|
-
// write unlock (the pre-fix behavior: bare-sessionId key + remember-regardless-of-tool).
|
|
2596
1268
|
if (this.allowAllSessions.size >= MAX_ALLOW_SESSIONS) {
|
|
2597
1269
|
const oldest = this.allowAllSessions.values().next().value;
|
|
2598
1270
|
if (oldest !== undefined)
|
|
@@ -2601,32 +1273,8 @@ export class ToolApprovalCoordinator {
|
|
|
2601
1273
|
this.allowAllSessions.add(sessionAllowKey(entry.owner, entry.sessionId, sessionAllowCategory(entry.toolName)));
|
|
2602
1274
|
}
|
|
2603
1275
|
const allowed = parsed.value !== "deny";
|
|
2604
|
-
// #243:deny 的 note 同时是给模型的拒因(AskOutcome.reason,core 5.29.0 围栏+上限在 core);allow 的
|
|
2605
|
-
// note 只走店的审计列(core 对 allow 行 reason 恒销毁,不铸注定被丢的键)。第 4 参 hostSettledBy 恒缺席
|
|
2606
|
-
// ——respond 腿是人经 API 决,不是宿主自报臂。
|
|
2607
1276
|
entry.settle(allowed, allowed ? "allowed" : "denied", parsed.updatedInput, undefined, allowed ? undefined : parsed.note);
|
|
2608
|
-
// updatedInputForwarded=server 已透传(是否被引擎消费取决于 core OnAsk 对象臂是否在——诚实措辞,
|
|
2609
|
-
// 不称 applied;cli 版本门+core 单落地后全链生效)。
|
|
2610
|
-
// 🔴 codex 交叉复审 round2 R2-4(2026-08-06 真缺陷):这个回显必须以「**这次调用真的把 updatedInput
|
|
2611
|
-
// 交给了等待中的闭包**」为准,不能只看请求里带没带。已被别的臂(状态感知分派/另一路 respond)结算过的
|
|
2612
|
-
// entry,上面这行 `settle` 是幂等无操作 —— 引擎拿到的是**原始 args**,而编辑是安全相关的(ctrl+g 改
|
|
2613
|
-
// 命令行);此时还回 `updatedInputForwarded: true` 等于告诉壳「你的编辑生效了」,是最不该撒的那种谎。
|
|
2614
|
-
// 调用方在 stale-entry 分支传 `updatedInputDelivered: false`,回显里这个键就整个缺席(壳按未透传处理)。
|
|
2615
1277
|
const updatedInputDelivered = opts?.updatedInputDelivered ?? true;
|
|
2616
|
-
// #229(设计稿 233 稿B v2 §1):`noteRecorded` = 这次回决的**理由到底有没有落进持久行**。
|
|
2617
|
-
//
|
|
2618
|
-
// 🔴 形照 `rulePersisted`(always-emit 布尔,只在请求真带了 `note` 时在场),值照**店的真实结果**
|
|
2619
|
-
// (调用方传进来的 `decidedWon`,含 CAS 抛错后那次确认读的改判),不是「askStore 在不在」:
|
|
2620
|
-
// · 本方法被 `respond()` **直接**调到 = store 缺席,或 store 在场但 `ensureAsk` 失败已清掉坐标 ——
|
|
2621
|
-
// 两种都是「压根没打过那条 UPDATE」,缺省 `false` 正是这一支的真相;
|
|
2622
|
-
// · `respondWithCas` 的 D5 fail-open 支(店抖动、确认读也没读到赢)同样 `false` —— **不知道不许
|
|
2623
|
-
// 说成成功**(与 `updatedInputForwarded` 从不发 `false`、只在真投递时发 `true` 是同一条纪律的两面:
|
|
2624
|
-
// 那一格靠缺席表达否定,这一格是三态里的一态,必须显式说 `false`,否则壳分不出「这台不支持」);
|
|
2625
|
-
// · 提交歧义臂上**赢了决议但行上是别人的 note**(同决议不同 note 的并发)同样 `false` —— 判据是
|
|
2626
|
-
// 「行上那段文本是不是这次提交的」,不是「这条决议是不是终局」(codex round1 [high],见调用方
|
|
2627
|
-
// `respondWithCas` 里 `noteLanded` 的推导)。
|
|
2628
|
-
// 能力位 `capabilities.approvalDecisionNote` 与本格**必须同车**:老服务对未知键静默丢 + 200,少了
|
|
2629
|
-
// 能力位,「记上了」与「这台不认识 note」在 wire 上不可判别。
|
|
2630
1278
|
const noteRecorded = opts?.noteRecorded ?? false;
|
|
2631
1279
|
return {
|
|
2632
1280
|
status: 200,
|
|
@@ -2640,12 +1288,6 @@ export class ToolApprovalCoordinator {
|
|
|
2640
1288
|
},
|
|
2641
1289
|
};
|
|
2642
1290
|
}
|
|
2643
|
-
/** #315([4658] 案):live pending 的**列表读面**——`GET /v1/approvals` 响应第二顶层键 `livePending`
|
|
2644
|
-
* 的唯一数据源。纯读投影,零锁/零结算语义;行键集刻意窄(**input/args 不上列表**,不开第二个脱敏
|
|
2645
|
-
* 面;详情走流帧)。`scope` 与 durable 面同一表达式:`undefined` = operator 全量(含 owner null 的
|
|
2646
|
-
* 条目),串 = 只见 `owner === scope` 的条目(null-owner 行对 principal 不可见,fail-closed——
|
|
2647
|
-
* 宁少列不越权;`"__none__"` 未鉴权哨兵自然恒空)。行的 `approvalId` 就是 `respond()` 收的 id
|
|
2648
|
-
* (同源可达,A-058 判据);`expiresAtMs` 照抄登记时单点铸定的窗死线(#288 三键同一个数)。 */
|
|
2649
1291
|
listLivePending(scope) {
|
|
2650
1292
|
const rows = [];
|
|
2651
1293
|
for (const [id, e] of this.pending) {
|
|
@@ -2664,14 +1306,12 @@ export class ToolApprovalCoordinator {
|
|
|
2664
1306
|
}
|
|
2665
1307
|
return rows;
|
|
2666
1308
|
}
|
|
2667
|
-
/** Test/observability hooks. */
|
|
2668
1309
|
pendingCount() {
|
|
2669
1310
|
return this.pending.size;
|
|
2670
1311
|
}
|
|
2671
1312
|
sessionAllowedCount() {
|
|
2672
1313
|
return this.allowAllSessions.size;
|
|
2673
1314
|
}
|
|
2674
|
-
/** #329:当前在场的 park 墓碑数(有界性的可观测面 —— 判据据它证「帽是硬的、过期会被清」)。 */
|
|
2675
1315
|
parkTombstoneCount() {
|
|
2676
1316
|
return this.parkTombstones.size;
|
|
2677
1317
|
}
|