@sema-agent/server 1.322.0 → 2.0.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/dist/approval-hmac.d.ts +40 -0
- package/dist/approval-hmac.js +67 -0
- package/dist/approval.d.ts +31 -0
- package/dist/approval.js +72 -4
- package/dist/audit.d.ts +26 -0
- package/dist/audit.js +40 -1
- package/dist/auth-bridge.d.ts +10 -0
- package/dist/auth-bridge.js +47 -4
- package/dist/auth-keys.d.ts +19 -0
- package/dist/auth-keys.js +13 -0
- package/dist/bake-runner/main.d.ts +5 -0
- package/dist/bake-runner/main.js +43 -4
- package/dist/bake-runner/protocol.d.ts +57 -0
- package/dist/bake-runner/protocol.js +58 -3
- package/dist/bake-runner/runner.d.ts +54 -0
- package/dist/bake-runner/runner.js +87 -5
- package/dist/bench/l8/artifact.d.ts +63 -0
- package/dist/bench/l8/artifact.js +61 -2
- package/dist/bench/l8/escape.d.ts +36 -0
- package/dist/bench/l8/escape.js +25 -0
- package/dist/bench/l8/index.d.ts +14 -0
- package/dist/bench/l8/index.js +14 -0
- package/dist/bench/l8/probes.d.ts +74 -0
- package/dist/bench/l8/probes.js +60 -0
- package/dist/bench/l8/run-probes.d.ts +43 -0
- package/dist/bench/l8/run-probes.js +45 -2
- package/dist/bench/s1/arms.d.ts +126 -0
- package/dist/bench/s1/arms.js +188 -13
- package/dist/bench/s1/live-deps.d.ts +66 -0
- package/dist/bench/s1/live-deps.js +249 -11
- package/dist/bench/s1/oracle.d.ts +49 -0
- package/dist/bench/s1/oracle.js +36 -1
- package/dist/bench/s1/repair-oracle-adapter.d.ts +33 -0
- package/dist/bench/s1/repair-oracle-adapter.js +30 -0
- package/dist/bench/s1/reviewer.d.ts +38 -0
- package/dist/bench/s1/reviewer.js +34 -0
- package/dist/bench/s1/row.d.ts +77 -0
- package/dist/bench/s1/row.js +62 -0
- package/dist/bench/s1/run-firm.d.ts +47 -0
- package/dist/bench/s1/run-firm.js +70 -4
- package/dist/bench/s1/runner-ctx.d.ts +71 -0
- package/dist/bench/s1/runner-ctx.js +36 -0
- package/dist/bench/s1/tasks.d.ts +108 -0
- package/dist/bench/s1/tasks.js +115 -0
- package/dist/boot-reclaim.d.ts +19 -0
- package/dist/boot-reclaim.js +20 -0
- package/dist/brain.d.ts +25 -0
- package/dist/brain.js +61 -1
- package/dist/budget.d.ts +66 -0
- package/dist/budget.js +128 -4
- package/dist/capabilities/builtin-tools.d.ts +3 -0
- package/dist/capabilities/builtin-tools.js +3 -0
- package/dist/capabilities/center-plugins.d.ts +17 -1
- package/dist/capabilities/center-plugins.js +45 -4
- package/dist/capabilities/center-prompts.d.ts +46 -0
- package/dist/capabilities/center-prompts.js +57 -3
- package/dist/capabilities/code-review-council.d.ts +28 -0
- package/dist/capabilities/code-review-council.js +40 -7
- package/dist/capabilities/collab-wire.d.ts +2 -0
- package/dist/capabilities/collab-workflows.d.ts +7 -0
- package/dist/capabilities/collab-workflows.js +44 -1
- package/dist/capabilities/oa-tools.d.ts +6 -0
- package/dist/capabilities/oa-tools.js +10 -0
- package/dist/capabilities/prompt.d.ts +14 -0
- package/dist/capabilities/prompt.js +14 -0
- package/dist/capabilities/prompts/code-review.d.ts +14 -0
- package/dist/capabilities/prompts/code-review.js +14 -0
- package/dist/capabilities/prompts/identity.d.ts +6 -0
- package/dist/capabilities/prompts/identity.js +6 -0
- package/dist/capabilities/prompts/team.d.ts +4 -0
- package/dist/capabilities/prompts/team.js +4 -0
- package/dist/capabilities/repo-tools.d.ts +11 -0
- package/dist/capabilities/repo-tools.js +8 -0
- package/dist/capabilities/sandbox-file-send.d.ts +95 -0
- package/dist/capabilities/sandbox-file-send.js +91 -3
- package/dist/capabilities/scenarios.d.ts +130 -0
- package/dist/capabilities/scenarios.js +180 -7
- package/dist/capabilities/select-environment-tool.d.ts +7 -0
- package/dist/capabilities/select-environment-tool.js +30 -0
- package/dist/capabilities/send-user-file-tool.d.ts +33 -0
- package/dist/capabilities/send-user-file-tool.js +65 -1
- package/dist/capabilities/skills.d.ts +29 -0
- package/dist/capabilities/skills.js +13 -2
- package/dist/capabilities/team.d.ts +22 -0
- package/dist/capabilities/team.js +22 -2
- package/dist/capabilities/tool-defer.d.ts +26 -0
- package/dist/capabilities/tool-defer.js +3 -0
- package/dist/config-center/apply-effective.d.ts +65 -0
- package/dist/config-center/apply-effective.js +204 -12
- package/dist/config-center/http-client.d.ts +34 -0
- package/dist/config-center/http-client.js +68 -0
- package/dist/config-center/restart-signal.d.ts +22 -0
- package/dist/config-center/restart-signal.js +39 -0
- package/dist/config-center/skills-mcp.d.ts +20 -1
- package/dist/config-center/skills-mcp.js +42 -7
- package/dist/config-center/types.d.ts +92 -1
- package/dist/config-lkg.d.ts +12 -0
- package/dist/config-lkg.js +41 -0
- package/dist/config-provider.d.ts +78 -0
- package/dist/config-provider.js +165 -2
- package/dist/config-types.d.ts +546 -0
- package/dist/config.d.ts +64 -0
- package/dist/config.js +455 -60
- package/dist/degenerate-instrument.d.ts +38 -0
- package/dist/degenerate-instrument.js +55 -3
- package/dist/elicitation.d.ts +39 -0
- package/dist/elicitation.js +87 -3
- package/dist/env-facts.d.ts +61 -0
- package/dist/env-facts.js +73 -7
- package/dist/fleet/fleet-bus.d.ts +185 -0
- package/dist/fleet/fleet-bus.js +333 -27
- package/dist/fleet/subagent-tail-bus.d.ts +11 -0
- package/dist/fleet/subagent-tail-bus.js +33 -2
- package/dist/fleet-client.d.ts +38 -0
- package/dist/fleet-client.js +44 -9
- package/dist/fleet-lease.d.ts +46 -0
- package/dist/fleet-lease.js +73 -5
- package/dist/hooks/branch-transcript.d.ts +8 -0
- package/dist/hooks/branch-transcript.js +33 -0
- package/dist/hooks/cc-stop-prompt.d.ts +42 -0
- package/dist/hooks/cc-stop-prompt.js +43 -1
- package/dist/hooks/hook-llm.d.ts +15 -0
- package/dist/hooks/hook-llm.js +47 -0
- package/dist/hooks/hook-runner.d.ts +89 -0
- package/dist/hooks/hook-runner.js +327 -18
- package/dist/http/idempotency.d.ts +31 -0
- package/dist/http/idempotency.js +34 -0
- package/dist/http/principal-gate.d.ts +17 -0
- package/dist/http/principal-gate.js +35 -3
- package/dist/http/route-ctx.d.ts +36 -0
- package/dist/http/routes/approvals-assistant.d.ts +25 -0
- package/dist/http/routes/approvals-assistant.js +253 -23
- package/dist/http/routes/attachments.js +22 -4
- package/dist/http/routes/capabilities.d.ts +7 -0
- package/dist/http/routes/capabilities.js +202 -1
- package/dist/http/routes/fleet.d.ts +6 -0
- package/dist/http/routes/fleet.js +94 -5
- package/dist/http/routes/images.d.ts +17 -0
- package/dist/http/routes/images.js +222 -14
- package/dist/http/routes/leader.d.ts +7 -0
- package/dist/http/routes/leader.js +10 -0
- package/dist/http/routes/memory-policy.d.ts +6 -0
- package/dist/http/routes/memory-policy.js +38 -6
- package/dist/http/routes/notify-wake.d.ts +10 -0
- package/dist/http/routes/notify-wake.js +19 -3
- package/dist/http/routes/observability.d.ts +6 -0
- package/dist/http/routes/observability.js +19 -2
- package/dist/http/routes/runs.d.ts +15 -0
- package/dist/http/routes/runs.js +464 -41
- package/dist/http/routes/session-sync.d.ts +15 -0
- package/dist/http/routes/session-sync.js +229 -19
- package/dist/http/routes/sessions-list.d.ts +6 -0
- package/dist/http/routes/sessions-list.js +35 -4
- package/dist/http/routes/sessions.d.ts +14 -0
- package/dist/http/routes/sessions.js +176 -31
- package/dist/http/routes/side-query.d.ts +8 -0
- package/dist/http/routes/side-query.js +26 -1
- package/dist/http/routes/tasks.d.ts +18 -0
- package/dist/http/routes/tasks.js +501 -38
- package/dist/http/routes/trace-usage.d.ts +10 -0
- package/dist/http/routes/trace-usage.js +66 -14
- package/dist/http/routes/workflows.d.ts +12 -0
- package/dist/http/routes/workflows.js +180 -19
- package/dist/http/run-meta.d.ts +11 -0
- package/dist/http/run-meta.js +6 -0
- package/dist/http/send.d.ts +19 -0
- package/dist/http/send.js +11 -0
- package/dist/http/server.d.ts +255 -0
- package/dist/http/server.js +921 -75
- package/dist/http/sse-log.d.ts +15 -0
- package/dist/http/sse-log.js +16 -0
- package/dist/http/tar.d.ts +9 -0
- package/dist/http/tar.js +21 -5
- package/dist/http/wire-gate.d.ts +9 -0
- package/dist/http/wire-gate.js +9 -0
- package/dist/http/wire-types.d.ts +187 -0
- package/dist/http/workspace-content.d.ts +8 -0
- package/dist/http/workspace-content.js +10 -0
- package/dist/images/bake-validate.d.ts +63 -0
- package/dist/images/bake-validate.js +71 -1
- package/dist/images/manifest.d.ts +19 -0
- package/dist/images/manifest.js +6 -1
- package/dist/index.d.ts +1 -5
- package/dist/index.js +10 -5
- package/dist/key-resolver.d.ts +23 -0
- package/dist/key-resolver.js +27 -2
- package/dist/leader/diffout.d.ts +23 -0
- package/dist/leader/diffout.js +8 -1
- package/dist/leader/diffup.d.ts +32 -0
- package/dist/leader/diffup.js +43 -0
- package/dist/leader/endpoint.d.ts +20 -0
- package/dist/leader/endpoint.js +25 -2
- package/dist/leader/fanout.d.ts +60 -0
- package/dist/leader/fanout.js +29 -3
- package/dist/leader/grader-env-factory.d.ts +74 -0
- package/dist/leader/grader-env-factory.js +62 -3
- package/dist/leader/leader.d.ts +97 -0
- package/dist/leader/leader.js +151 -4
- package/dist/leader/merge.d.ts +94 -0
- package/dist/leader/merge.js +112 -9
- package/dist/leader/planner.d.ts +52 -0
- package/dist/leader/planner.js +47 -3
- package/dist/leader/repair-oracle.d.ts +61 -0
- package/dist/leader/repair-oracle.js +59 -1
- package/dist/leader/repair-wire.d.ts +78 -0
- package/dist/leader/repair-wire.js +79 -2
- package/dist/leader/wire.d.ts +79 -0
- package/dist/leader/wire.js +255 -8
- package/dist/lsp/e2b-bridge.d.ts +16 -0
- package/dist/lsp/e2b-bridge.js +32 -4
- package/dist/lsp/e2b-manager.d.ts +10 -0
- package/dist/lsp/e2b-manager.js +18 -2
- package/dist/lsp/lsp-frames.d.ts +12 -0
- package/dist/lsp/lsp-frames.js +12 -0
- package/dist/lsp/manager.d.ts +12 -0
- package/dist/lsp/manager.js +35 -2
- package/dist/lsp/types.d.ts +10 -0
- package/dist/lsp/ws-transport.d.ts +12 -0
- package/dist/lsp/ws-transport.js +53 -5
- package/dist/lsp-evict.d.ts +14 -0
- package/dist/lsp-evict.js +15 -1
- package/dist/main.js +2132 -96
- package/dist/memory-export.d.ts +1 -0
- package/dist/memory-export.js +4 -0
- package/dist/memory-scope.d.ts +49 -0
- package/dist/memory-scope.js +87 -4
- package/dist/memory-sync-client.d.ts +24 -0
- package/dist/memory-sync-client.js +41 -2
- package/dist/memory-sync.d.ts +23 -0
- package/dist/memory-sync.js +70 -1
- package/dist/model-select.d.ts +39 -0
- package/dist/model-select.js +42 -1
- package/dist/observability/cost-quota.d.ts +20 -0
- package/dist/observability/cost-quota.js +4 -0
- package/dist/observability/cost-taxonomy.d.ts +45 -0
- package/dist/observability/cost-taxonomy.js +34 -0
- package/dist/observability/logger.d.ts +1 -0
- package/dist/observability/logger.js +6 -0
- package/dist/observability/metrics.d.ts +13 -0
- package/dist/observability/metrics.js +63 -0
- package/dist/observability/otel-exporter.d.ts +9 -0
- package/dist/observability/otel-exporter.js +11 -3
- package/dist/observability/principal-context.d.ts +9 -0
- package/dist/observability/principal-context.js +9 -0
- package/dist/observability/prompt-manifest.d.ts +37 -0
- package/dist/observability/prompt-manifest.js +35 -1
- package/dist/observability/rate-limit.d.ts +10 -0
- package/dist/observability/rate-limit.js +1 -0
- package/dist/observability/tool-trace.d.ts +24 -0
- package/dist/observability/tool-trace.js +41 -1
- package/dist/orchestration/hardened-vm-runner.d.ts +107 -0
- package/dist/orchestration/hardened-vm-runner.js +119 -4
- package/dist/orchestration/hardened-vm-worker-runner.d.ts +14 -0
- package/dist/orchestration/hardened-vm-worker-runner.js +26 -1
- package/dist/orchestration/hardened-vm-worker.js +27 -0
- package/dist/orchestration/subagent-steer.d.ts +44 -0
- package/dist/orchestration/subagent-steer.js +45 -1
- package/dist/orchestration/workflow-agent-steer.d.ts +54 -0
- package/dist/orchestration/workflow-agent-steer.js +80 -1
- package/dist/orchestration/workflow-completion-inbox.d.ts +189 -2
- package/dist/orchestration/workflow-completion-inbox.js +283 -32
- package/dist/orchestration/workflow-notify-journal.d.ts +132 -1
- package/dist/orchestration/workflow-notify-journal.js +187 -13
- package/dist/parked-decide.d.ts +61 -0
- package/dist/parked-decide.js +58 -1
- package/dist/per-task-image.d.ts +54 -0
- package/dist/per-task-image.js +41 -0
- package/dist/plan-cache-probe.d.ts +35 -0
- package/dist/plan-cache-probe.js +18 -3
- package/dist/plugins/approval-exemption-store.d.ts +7 -0
- package/dist/plugins/approval-exemption-store.js +28 -2
- package/dist/plugins/approval-store-sql.d.ts +64 -0
- package/dist/plugins/approval-store-sql.js +32 -0
- package/dist/plugins/background-agent-store-sql.d.ts +55 -0
- package/dist/plugins/background-agent-store-sql.js +91 -4
- package/dist/plugins/background-shell-support.d.ts +98 -0
- package/dist/plugins/background-shell-support.js +117 -13
- package/dist/plugins/blob-backend.d.ts +104 -0
- package/dist/plugins/blob-backend.js +163 -6
- package/dist/plugins/breaker-state-sql.d.ts +65 -1
- package/dist/plugins/breaker-state-sql.js +46 -8
- package/dist/plugins/caching-session-store.d.ts +42 -0
- package/dist/plugins/caching-session-store.js +63 -3
- package/dist/plugins/checkpoint-store-sql.d.ts +179 -0
- package/dist/plugins/checkpoint-store-sql.js +239 -11
- package/dist/plugins/file-outcome-sink.d.ts +4 -0
- package/dist/plugins/file-outcome-sink.js +9 -0
- package/dist/plugins/file-resume-anchor-store.d.ts +20 -0
- package/dist/plugins/file-resume-anchor-store.js +43 -4
- package/dist/plugins/file-run-store.d.ts +90 -0
- package/dist/plugins/file-run-store.js +145 -13
- package/dist/plugins/file-snapshot-store-sql.d.ts +133 -1
- package/dist/plugins/file-snapshot-store-sql.js +178 -10
- package/dist/plugins/file-workflow-journal-store.d.ts +10 -0
- package/dist/plugins/file-workflow-journal-store.js +10 -0
- package/dist/plugins/fork-routing-session-store.d.ts +47 -0
- package/dist/plugins/fork-routing-session-store.js +51 -3
- package/dist/plugins/host-platform.d.ts +69 -0
- package/dist/plugins/host-platform.js +81 -2
- package/dist/plugins/image-bake-store-sql.d.ts +136 -1
- package/dist/plugins/image-bake-store-sql.js +241 -7
- package/dist/plugins/image-index-sql.d.ts +46 -0
- package/dist/plugins/image-index-sql.js +113 -4
- package/dist/plugins/k8s-bg-scripts.d.ts +34 -0
- package/dist/plugins/k8s-bg-scripts.js +88 -4
- package/dist/plugins/k8s-exec-protocol.d.ts +33 -0
- package/dist/plugins/k8s-exec-protocol.js +41 -0
- package/dist/plugins/local-checkpoint-store.d.ts +40 -0
- package/dist/plugins/local-checkpoint-store.js +86 -7
- package/dist/plugins/local-session-store.d.ts +157 -0
- package/dist/plugins/local-session-store.js +289 -34
- package/dist/plugins/local-task-attachment-store.js +14 -2
- package/dist/plugins/mailbox-store-sql.d.ts +38 -0
- package/dist/plugins/mailbox-store-sql.js +39 -6
- package/dist/plugins/memory-engine-pg.d.ts +30 -0
- package/dist/plugins/memory-engine-pg.js +122 -4
- package/dist/plugins/memory-engine-tidb.d.ts +26 -0
- package/dist/plugins/memory-engine-tidb.js +115 -5
- package/dist/plugins/memory-engine-vector-util.d.ts +5 -0
- package/dist/plugins/memory-engine-vector-util.js +5 -0
- package/dist/plugins/memory-resume-anchor-store.d.ts +8 -0
- package/dist/plugins/memory-resume-anchor-store.js +17 -0
- package/dist/plugins/memory-run-store.d.ts +63 -0
- package/dist/plugins/memory-run-store.js +73 -8
- package/dist/plugins/memory-session-policy-store.d.ts +17 -0
- package/dist/plugins/memory-session-policy-store.js +17 -0
- package/dist/plugins/memory-sync-store-pg.d.ts +42 -0
- package/dist/plugins/memory-sync-store-pg.js +45 -2
- package/dist/plugins/memory-sync-store-tidb.d.ts +9 -0
- package/dist/plugins/memory-sync-store-tidb.js +31 -1
- package/dist/plugins/outcome-ledger-sql.d.ts +63 -0
- package/dist/plugins/outcome-ledger-sql.js +97 -3
- package/dist/plugins/pg-approval-store.d.ts +7 -0
- package/dist/plugins/pg-approval-store.js +7 -0
- package/dist/plugins/pg-breaker-state.d.ts +6 -0
- package/dist/plugins/pg-breaker-state.js +6 -0
- package/dist/plugins/pg-checkpoint-store.d.ts +8 -0
- package/dist/plugins/pg-checkpoint-store.js +8 -0
- package/dist/plugins/pg-cost-quota.d.ts +17 -0
- package/dist/plugins/pg-cost-quota.js +6 -0
- package/dist/plugins/pg-file-snapshot-store.d.ts +6 -0
- package/dist/plugins/pg-file-snapshot-store.js +6 -0
- package/dist/plugins/pg-image-bake.d.ts +9 -0
- package/dist/plugins/pg-image-bake.js +9 -0
- package/dist/plugins/pg-image-index.d.ts +9 -0
- package/dist/plugins/pg-image-index.js +9 -0
- package/dist/plugins/pg-outcome-ledger.d.ts +9 -0
- package/dist/plugins/pg-outcome-ledger.js +9 -0
- package/dist/plugins/pg-pool.d.ts +50 -0
- package/dist/plugins/pg-pool.js +78 -0
- package/dist/plugins/pg-query.d.ts +11 -0
- package/dist/plugins/pg-rate-limiter.d.ts +23 -0
- package/dist/plugins/pg-rate-limiter.js +12 -2
- package/dist/plugins/pg-resume-anchor-store.d.ts +5 -0
- package/dist/plugins/pg-resume-anchor-store.js +5 -0
- package/dist/plugins/pg-run-store.d.ts +7 -0
- package/dist/plugins/pg-run-store.js +7 -0
- package/dist/plugins/pg-safe-json.d.ts +13 -0
- package/dist/plugins/pg-safe-json.js +40 -4
- package/dist/plugins/pg-session-policy-store.d.ts +5 -0
- package/dist/plugins/pg-session-policy-store.js +5 -0
- package/dist/plugins/pg-session-storage.d.ts +72 -0
- package/dist/plugins/pg-session-storage.js +152 -20
- package/dist/plugins/pg-session-store.d.ts +10 -0
- package/dist/plugins/pg-session-store.js +10 -0
- package/dist/plugins/pg-tool-result-store.d.ts +7 -0
- package/dist/plugins/pg-tool-result-store.js +7 -0
- package/dist/plugins/pg-workflow-journal-store.d.ts +7 -0
- package/dist/plugins/pg-workflow-journal-store.js +7 -0
- package/dist/plugins/pg-workflow-run-store.d.ts +7 -0
- package/dist/plugins/pg-workflow-run-store.js +7 -0
- package/dist/plugins/posix-shell-fs.d.ts +3 -0
- package/dist/plugins/posix-shell-fs.js +31 -1
- package/dist/plugins/remote-env-adb.d.ts +25 -0
- package/dist/plugins/remote-env-adb.js +95 -12
- package/dist/plugins/remote-env-e2b.d.ts +146 -0
- package/dist/plugins/remote-env-e2b.js +371 -40
- package/dist/plugins/remote-env-file-error.d.ts +34 -0
- package/dist/plugins/remote-env-file-error.js +37 -0
- package/dist/plugins/remote-env-host.d.ts +151 -0
- package/dist/plugins/remote-env-host.js +468 -70
- package/dist/plugins/remote-env-k8s.d.ts +135 -0
- package/dist/plugins/remote-env-k8s.js +318 -32
- package/dist/plugins/remote-env-local-docker.d.ts +65 -0
- package/dist/plugins/remote-env-local-docker.js +149 -24
- package/dist/plugins/remote-env-ssh.d.ts +35 -0
- package/dist/plugins/remote-env-ssh.js +117 -20
- package/dist/plugins/remote-scratchpad.d.ts +38 -0
- package/dist/plugins/remote-scratchpad.js +32 -2
- package/dist/plugins/remote-shell.d.ts +22 -0
- package/dist/plugins/remote-shell.js +27 -1
- package/dist/plugins/resume-anchor-store-sql.d.ts +28 -0
- package/dist/plugins/resume-anchor-store-sql.js +11 -0
- package/dist/plugins/roster-store-sql.d.ts +27 -0
- package/dist/plugins/roster-store-sql.js +54 -0
- package/dist/plugins/run-store-sql.d.ts +194 -0
- package/dist/plugins/run-store-sql.js +210 -9
- package/dist/plugins/s3-presign.d.ts +20 -0
- package/dist/plugins/s3-presign.js +49 -2
- package/dist/plugins/scheduler-support.d.ts +12 -0
- package/dist/plugins/scheduler-support.js +75 -3
- package/dist/plugins/send-file-ledger.d.ts +15 -0
- package/dist/plugins/send-file-ledger.js +53 -4
- package/dist/plugins/send-user-file.d.ts +77 -0
- package/dist/plugins/send-user-file.js +92 -4
- package/dist/plugins/session-policy-store-sql.d.ts +15 -0
- package/dist/plugins/session-policy-store-sql.js +75 -3
- package/dist/plugins/session-store.d.ts +45 -0
- package/dist/plugins/session-store.js +51 -0
- package/dist/plugins/sql-driver.d.ts +77 -0
- package/dist/plugins/sql-driver.js +4 -0
- package/dist/plugins/sql-escape.d.ts +8 -0
- package/dist/plugins/sql-escape.js +8 -0
- package/dist/plugins/sql-row-helpers.d.ts +25 -0
- package/dist/plugins/sql-row-helpers.js +25 -0
- package/dist/plugins/store-backend.d.ts +103 -0
- package/dist/plugins/store-backend.js +120 -27
- package/dist/plugins/store-contracts.d.ts +62 -0
- package/dist/plugins/store-contracts.js +3 -0
- package/dist/plugins/task-attachment-store.d.ts +60 -0
- package/dist/plugins/task-attachment-store.js +49 -3
- package/dist/plugins/task-list-store-sql.d.ts +25 -0
- package/dist/plugins/task-list-store-sql.js +21 -0
- package/dist/plugins/tidb-approval-store.d.ts +6 -0
- package/dist/plugins/tidb-approval-store.js +6 -0
- package/dist/plugins/tidb-breaker-state.d.ts +5 -0
- package/dist/plugins/tidb-breaker-state.js +5 -0
- package/dist/plugins/tidb-checkpoint-store.d.ts +7 -0
- package/dist/plugins/tidb-checkpoint-store.js +7 -0
- package/dist/plugins/tidb-cost-quota.d.ts +6 -0
- package/dist/plugins/tidb-cost-quota.js +1 -0
- package/dist/plugins/tidb-file-snapshot-store.d.ts +6 -0
- package/dist/plugins/tidb-file-snapshot-store.js +6 -0
- package/dist/plugins/tidb-image-bake.d.ts +8 -0
- package/dist/plugins/tidb-image-bake.js +8 -0
- package/dist/plugins/tidb-image-index.d.ts +8 -0
- package/dist/plugins/tidb-image-index.js +8 -0
- package/dist/plugins/tidb-outcome-ledger.d.ts +10 -0
- package/dist/plugins/tidb-outcome-ledger.js +10 -0
- package/dist/plugins/tidb-pool.d.ts +56 -0
- package/dist/plugins/tidb-pool.js +181 -1
- package/dist/plugins/tidb-rate-limiter.d.ts +11 -0
- package/dist/plugins/tidb-rate-limiter.js +5 -2
- package/dist/plugins/tidb-resume-anchor-store.d.ts +5 -0
- package/dist/plugins/tidb-resume-anchor-store.js +5 -0
- package/dist/plugins/tidb-run-store.d.ts +7 -0
- package/dist/plugins/tidb-run-store.js +7 -0
- package/dist/plugins/tidb-session-policy-store.d.ts +5 -0
- package/dist/plugins/tidb-session-policy-store.js +5 -0
- package/dist/plugins/tidb-session-storage.d.ts +43 -0
- package/dist/plugins/tidb-session-storage.js +62 -3
- package/dist/plugins/tidb-session-store.d.ts +142 -0
- package/dist/plugins/tidb-session-store.js +264 -16
- package/dist/plugins/tidb-tool-result-store.d.ts +5 -0
- package/dist/plugins/tidb-tool-result-store.js +7 -0
- package/dist/plugins/tidb-workflow-journal-store.d.ts +7 -0
- package/dist/plugins/tidb-workflow-journal-store.js +7 -0
- package/dist/plugins/tidb-workflow-run-store.d.ts +8 -0
- package/dist/plugins/tidb-workflow-run-store.js +8 -0
- package/dist/plugins/tool-result-store-sql.d.ts +58 -0
- package/dist/plugins/tool-result-store-sql.js +38 -2
- package/dist/plugins/web-search.d.ts +35 -0
- package/dist/plugins/web-search.js +45 -9
- package/dist/plugins/workflow-journal-limits.d.ts +10 -0
- package/dist/plugins/workflow-journal-limits.js +10 -0
- package/dist/plugins/workflow-journal-store-sql.d.ts +78 -0
- package/dist/plugins/workflow-journal-store-sql.js +59 -3
- package/dist/plugins/workflow-run-store-sql.d.ts +84 -0
- package/dist/plugins/workflow-run-store-sql.js +84 -6
- package/dist/plugins/worktree-isolation.d.ts +45 -0
- package/dist/plugins/worktree-isolation.js +105 -6
- package/dist/plugins/write-behind-counter.d.ts +56 -1
- package/dist/plugins/write-behind-counter.js +53 -14
- package/dist/principal-jwt.d.ts +24 -0
- package/dist/principal-jwt.js +60 -5
- package/dist/project-memory.d.ts +22 -0
- package/dist/project-memory.js +144 -15
- package/dist/prompts-domain-validate.d.ts +28 -0
- package/dist/prompts-domain-validate.js +42 -1
- package/dist/question.d.ts +33 -0
- package/dist/question.js +69 -2
- package/dist/resource-suspend.d.ts +21 -0
- package/dist/resource-suspend.js +16 -0
- package/dist/router/route-orchestration.d.ts +78 -0
- package/dist/router/route-orchestration.js +77 -0
- package/dist/run-local.d.ts +32 -0
- package/dist/run-local.js +172 -8
- package/dist/runs.d.ts +189 -4
- package/dist/runs.js +465 -24
- package/dist/runtime-caps-resolver.d.ts +106 -0
- package/dist/runtime-caps-resolver.js +89 -4
- package/dist/runtime-governance.d.ts +84 -0
- package/dist/runtime-governance.js +127 -1
- package/dist/sandbox-pkg-source.d.ts +23 -0
- package/dist/sandbox-pkg-source.js +37 -0
- package/dist/sealed-key.d.ts +54 -0
- package/dist/sealed-key.js +68 -3
- package/dist/security.d.ts +108 -0
- package/dist/security.js +145 -6
- package/dist/sema-registry.d.ts +21 -0
- package/dist/sema-registry.js +21 -0
- package/dist/session-leaf-bus.d.ts +31 -0
- package/dist/session-leaf-bus.js +32 -0
- package/dist/session-sync-content.d.ts +46 -0
- package/dist/session-sync-content.js +47 -1
- package/dist/session-sync-kernel.d.ts +89 -0
- package/dist/session-sync-kernel.js +48 -3
- package/dist/session-sync.d.ts +125 -0
- package/dist/session-sync.js +181 -8
- package/dist/session-titler.d.ts +26 -0
- package/dist/session-titler.js +41 -7
- package/dist/session-watch.d.ts +53 -1
- package/dist/session-watch.js +73 -9
- package/dist/sighup-idle.d.ts +30 -0
- package/dist/sighup-idle.js +12 -1
- package/dist/spec-fields.d.ts +91 -3
- package/dist/spec-fields.js +116 -4
- package/dist/task-cwd.d.ts +50 -0
- package/dist/task-cwd.js +68 -1
- package/dist/task-mcp.d.ts +47 -0
- package/dist/task-mcp.js +29 -0
- package/dist/task-settings.d.ts +175 -0
- package/dist/task-settings.js +226 -11
- package/dist/task-workflow.d.ts +67 -0
- package/dist/task-workflow.js +75 -8
- package/dist/tool-approval.d.ts +91 -0
- package/dist/tool-approval.js +215 -0
- package/dist/trace/artifacts.d.ts +9 -0
- package/dist/trace/artifacts.js +30 -5
- package/dist/trace/core-keyset-guard.d.ts +18 -0
- package/dist/trace/ledger-sink.d.ts +43 -0
- package/dist/trace/ledger-sink.js +64 -5
- package/dist/trace/project.d.ts +133 -0
- package/dist/trace/project.js +189 -2
- package/dist/trace/redact.d.ts +19 -0
- package/dist/trace/redact.js +66 -9
- package/dist/usage-analytics.d.ts +26 -0
- package/dist/usage-analytics.js +26 -3
- package/package.json +1 -1
package/dist/http/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import { once } from "node:events";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { uuidv7, isThinkingLevel, expandTiers, resumeWithVerification, CheckpointError, HAND_TOOL_EFFECTS, canonicalToolName, defaultTaskRegistry, validatePendingSteer, subscribeWorkflow } from "@sema-agent/core";
|
|
4
|
+
import { uuidv7, isThinkingLevel, expandTiers, resumeWithVerification, CheckpointError, HAND_TOOL_EFFECTS, canonicalToolName, defaultTaskRegistry, validatePendingSteer, subscribeWorkflow } from "@sema-agent/core"; // canonicalToolName = core single-source (1.162; replaced the transitional service mirror)
|
|
5
5
|
import { decideParkedAgent, findParkedAgentForCheckpoint } from "../parked-decide.js";
|
|
6
6
|
import { matchCatalogModel } from "../model-select.js";
|
|
7
7
|
import {} from "../sema-registry.js";
|
|
@@ -46,19 +46,30 @@ import { cascadeConfig } from "./run-meta.js";
|
|
|
46
46
|
export { cascadeConfig };
|
|
47
47
|
import { handleImages, createImagesLocal, coarseStatusForState, errorCodeForExit, BAKE_ID_RE, BAKE_EVENTS_RE, BAKE_CANCEL_RE, BAKE_CLAIM_RE, BAKE_INGEST_RE } from "./routes/images.js";
|
|
48
48
|
export { coarseStatusForState, errorCodeForExit };
|
|
49
|
+
// design/158 A9:装配器缝——发送器/身份门下沉到 http/ 叶子模块,routes/* 与 server.ts 共用同一实现
|
|
50
|
+
// (routes/* 绝不可值 import server.ts:那条边会闭合运行时环,见 test/module-cycle-gate.test.ts)。
|
|
49
51
|
import { sendJson, sendError, httpErrorCode, msg } from "./send.js";
|
|
50
52
|
import { authorized, systemFor, gatedPrincipal, explicitOperatorOk, isOperator, explicitOperator } from "./principal-gate.js";
|
|
51
53
|
export { explicitOperatorOk, isOperator, explicitOperator };
|
|
54
|
+
/** 分组入参 → 平铺视图(createHttpServer 的第一件事)。一组都没给 ⇒ 原样返回(存量调用零开销、零形变)。 */
|
|
52
55
|
export function flattenServiceDeps(deps) {
|
|
53
56
|
const { stores, coordinators, seams, observability, governance, deployment, knobs, ...flat } = deps;
|
|
54
57
|
if (!(stores || coordinators || seams || observability || governance || deployment || knobs))
|
|
55
58
|
return deps;
|
|
56
59
|
return { ...stores, ...coordinators, ...seams, ...observability, ...governance, ...deployment, ...knobs, ...flat };
|
|
57
60
|
}
|
|
61
|
+
/** Personalization caps: server-pinned hard limits — a UI may be stricter, never looser. */
|
|
58
62
|
export const MAX_USER_SKILLS = 10;
|
|
63
|
+
/** = core `SKILL_CONTENT_MAX_CHARS`(1MB 加载门,core 1.293 起 invoke 全文零截断、超 1MB 整拒;常量在
|
|
64
|
+
* dist/core/runner/synthetic-tools.js:84,未从 core 包根导出,故钉本地孪生+本出处注记)。修7 曾对齐旧
|
|
65
|
+
* 20k 截断门([845] 飞轮考据:20k 是压缩保留区口径,invoke 时刻用错),core 1.293 撤门后本门跟随回收
|
|
66
|
+
* ([853]① 收口)——HTTP 面与引擎加载门同值,超限 400 fail-loud 而非引擎侧整拒后难诊断。 */
|
|
59
67
|
export const MAX_SKILL_CONTENT_CHARS = 1_048_576;
|
|
60
68
|
export const MAX_SYSTEM_PROMPT_CHARS = 16_384;
|
|
69
|
+
/** Cap on a request's `outputSchema` (JSON-serialized) — an uncapped schema bloats the prompt + the per-turn
|
|
70
|
+
* validation cost. 32KB comfortably holds a rich nested schema while bounding the abuse surface. */
|
|
61
71
|
export const MAX_OUTPUT_SCHEMA_CHARS = 32_768;
|
|
72
|
+
/** Validate `body.skills` (caps + shape). Returns an error string (→ 400) or null when acceptable. */
|
|
62
73
|
export function validateUserSkills(skills) {
|
|
63
74
|
if (skills === undefined)
|
|
64
75
|
return null;
|
|
@@ -78,17 +89,46 @@ export function validateUserSkills(skills) {
|
|
|
78
89
|
}
|
|
79
90
|
return null;
|
|
80
91
|
}
|
|
92
|
+
/** Verification rounds requested by a caller, clamped to a safe [1,5] (default 2). */
|
|
81
93
|
export function clampVerifyRounds(v) {
|
|
82
94
|
const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : 2;
|
|
83
95
|
return Math.min(5, Math.max(1, n));
|
|
84
96
|
}
|
|
85
97
|
const MAX_BODY = 8 * 1024 * 1024;
|
|
98
|
+
/** Caps on `body.images`: inline base64 `data` is otherwise passed RAW to the
|
|
99
|
+
* spec (URL images are SSRF+size-gated by core, inline aren't). The whole JSON body is already ≤ MAX_BODY, but with NO
|
|
100
|
+
* COUNT cap a request could carry thousands of tiny images (a decode/processing DoS), and there was no explicit
|
|
101
|
+
* per-image error. The two-axis gate belongs in the server (shell carries content, server gates it). */
|
|
86
102
|
const MAX_IMAGES_PER_REQUEST = 20;
|
|
87
|
-
const MAX_IMAGE_BASE64_BYTES = 6 * 1024 * 1024;
|
|
103
|
+
const MAX_IMAGE_BASE64_BYTES = 6 * 1024 * 1024; // a single inline image's base64 ≤ 6 MiB (< MAX_BODY, so REACHABLE — one image can't hog the whole 8 MiB budget); ~4.5 MiB decoded, generous for a screenshot/photo
|
|
104
|
+
// Task Trace API (S1): GET /v1/tasks (list) | /v1/tasks/:id/turns | /v1/tasks/:id/stream. The `:id` group +
|
|
105
|
+
// sub-route are both-or-neither, so a GET /v1/tasks/stream (no :id) does NOT match — the POST sync stream is unshadowed.
|
|
106
|
+
// Hand (executionEnv) tool names core mounts at the runner when an executionEnvFactory is configured — the
|
|
107
|
+
// authoritative set is core `HAND_TOOL_EFFECTS` (root-exported since 1.70). These are NEVER in spec.tools, so
|
|
108
|
+
// the durable-resume satisfiability pre-check must union them when remote exec is on.
|
|
88
109
|
const HAND_TOOL_NAMES = Object.keys(HAND_TOOL_EFFECTS);
|
|
110
|
+
/** [1245] codex-3/4 — the REOPEN-class resume failures: core's runtask reopened the checkpoint (store.reopen,
|
|
111
|
+
* dist-read) BEFORE returning this failed result, so the park is STILL PENDING and decidable. Everything
|
|
112
|
+
* downstream must treat these as a re-park, never terminal: the durable event log writes `suspended` (not
|
|
113
|
+
* `done`), the run row re-parks (keeps task_active), and /decide surfaces retriable:true. A reopen that
|
|
114
|
+
* itself failed surfaces as `checkpoint.reopen_failed` — deliberately NOT matched here (terminal). */
|
|
89
115
|
function isCheckpointReopenedFailure(r) {
|
|
90
116
|
return r.status === "failed" && (r.errorCode === "resume.env_failed" || r.errorCode === "resume.tool_unavailable");
|
|
91
117
|
}
|
|
118
|
+
/** The TRUSTED end-user principal for an owner-gated / per-tenant request. On a direct-door worker the
|
|
119
|
+
* trusted-header path is OFF (x-agent-principal is client-spoofable), so identity comes from the crypto-VERIFIED
|
|
120
|
+
* principal JWT (`x-approval-principal-token`, principal-only proof — no cnf.bnd action binding); otherwise the
|
|
121
|
+
* BFF-minted trusted header. undefined when absent/invalid (fail-closed → callers map to 401 / scope "__none__").
|
|
122
|
+
* 🔴 Use this for ANY endpoint that reads or gates PER-TENANT data — NOT `principalFrom` directly, which trusts
|
|
123
|
+
* the spoofable header on a direct door (/v1/usage + the trace owner-gate
|
|
124
|
+
* had drifted to a bare principalFrom, unlike the leader/images/approvals identity paths). One source of truth
|
|
125
|
+
* so a future per-tenant endpoint can't re-introduce the bypass. The action-BOUND decide/answer paths keep their
|
|
126
|
+
* own verifyDirectDoorProof (cnf.bnd) — this is the identity-only twin. */
|
|
127
|
+
// F-fix: a thin delegate to the single source of the verified identity (security.ts `verifiedPrincipal`). Kept as a
|
|
128
|
+
// named alias so the existing call sites read clearly; the JWT logic now lives in one place that createAuthorizer
|
|
129
|
+
// shares, so spec.principal (governance + cost) and these observability/owner sites can never drift apart again.
|
|
130
|
+
/** design/158 A9 装配表(顺序 = 拆分前 `handle()` 里各域出现的先后,逐行同序;真正的 `return` 在 handle 内,
|
|
131
|
+
* 这里只做**签名一致性**的编译期钉:任何域入口漂了形,是编译红,不是运行时静默 404)。 */
|
|
92
132
|
const ROUTE_DOMAINS = [
|
|
93
133
|
handleTraceUsage,
|
|
94
134
|
handleWorkflows,
|
|
@@ -111,17 +151,73 @@ const ROUTE_DOMAINS = [
|
|
|
111
151
|
handleNotifyWake,
|
|
112
152
|
];
|
|
113
153
|
void ROUTE_DOMAINS;
|
|
154
|
+
/**
|
|
155
|
+
* Managed-Agents-style HTTP/SSE service surface.
|
|
156
|
+
* GET /health
|
|
157
|
+
* POST /v1/tasks → run to completion, TaskResult JSON (synchronous)
|
|
158
|
+
* POST /v1/tasks/stream → SSE of TaskEvent, live tokens (synchronous, same instance)
|
|
159
|
+
* POST /v1/runs → async: 202 {taskId, sessionId, status}, runs in background (S1)
|
|
160
|
+
* GET /v1/runs/:id → run status + result (poll)
|
|
161
|
+
* GET /v1/runs/:id/events → SSE replay from Last-Event-ID, then tail to terminal (S2)
|
|
162
|
+
* POST /v1/sessions/:id/wake→ 501 (resume is implicit: re-issue a task with the same sessionId)
|
|
163
|
+
*/
|
|
114
164
|
export function createHttpServer(rawDeps) {
|
|
165
|
+
// design/158 A8:分组装配 → 平铺视图。**分组是装配面的形,不是消费面的形**——下面 ~700 处 `deps.x`
|
|
166
|
+
// 消费点原样保留(平铺入参零形变、零开销:没给任何组时 flatten 原样返回同一个对象)。
|
|
115
167
|
const deps = flattenServiceDeps(rawDeps);
|
|
168
|
+
// Idempotency-Key dedup for task submission (center review-routing blocker: exactly-once billing). One
|
|
169
|
+
// cache per server instance, shared by the sync /v1/tasks and async /v1/runs create paths.
|
|
116
170
|
const idemCache = new IdempotencyCache();
|
|
171
|
+
// In-flight durable runs on THIS instance → their cancel controller. POST /v1/runs/:id/cancel landing here
|
|
172
|
+
// aborts immediately (fast path); a cancel landing on another replica uses the durable flag + heartbeat poll.
|
|
117
173
|
const inflightRuns = new Map();
|
|
174
|
+
// [1.207 codex H2] taskIds whose abort came from the CANCEL VERB (fast path or flag poll) — the interactive
|
|
175
|
+
// legs consult this at settle time to label an abort-shaped failed result `errorCode:"cancelled"` (core's
|
|
176
|
+
// external-signal abort yields failed WITHOUT an errorCode; first-writer-wins would then pin the unlabeled
|
|
177
|
+
// form). A genuine completion that raced the abort is NOT relabeled (only status==="failed" gets the code).
|
|
178
|
+
// Entries are dropped in each leg's finally (same lifecycle as its inflightRuns registration).
|
|
118
179
|
const cancelledViaVerb = new Set();
|
|
180
|
+
// design/80 seam #2 (preemption): in-flight PREEMPTIBLE runs on THIS instance → their preempt controller (the
|
|
181
|
+
// graceful-yield twin of inflightRuns' kill). POST /v1/assistant/tasks/:id/preempt landing here aborts it
|
|
182
|
+
// immediately (fast path → core durably suspends at the next clean turn boundary); a preempt landing on another
|
|
183
|
+
// replica uses the durable `preempt_requested` flag + heartbeat poll. Only plain-stream + resumed legs register.
|
|
119
184
|
const preemptableRuns = new Map();
|
|
185
|
+
// design/47 + design/80 D-A (steering): in-flight STEERABLE runs on THIS instance → their live core TaskStream
|
|
186
|
+
// handle. POST /v1/runs/:id/steer landing here injects a mid-task message via the stream's `steer()` (applied at
|
|
187
|
+
// the next turn-boundary drain → 200). A run that is NOT here is either durably suspended (→ park the steer on its
|
|
188
|
+
// checkpoint via setPendingSteer → 202) or streaming on another replica (cross-replica live-steer is a fast-follow,
|
|
189
|
+
// single-replica today → 409). Only the plain-stream + resumed legs hold a live stream, so only they register
|
|
190
|
+
// (the verify/cascade legs return a result, not a stream — nothing to steer, parity with preemptableRuns).
|
|
120
191
|
const steerableRuns = new Map();
|
|
192
|
+
// design/144 wake 半场:终态 steer 铸 park 的 per-session 串行链(复审 HIGH:tryPark→put 非原子,
|
|
193
|
+
// 并发双 steer 双铸两个 pending park)。单副本部署(本仓 steer 跨副本 seam 同款现实)进程锁即闭环;
|
|
194
|
+
// 跨副本残窗由 put 后赢家复核收敛兜底(见 (d) 分支),与 cross-replica live-steer 同列 fast-follow。
|
|
121
195
|
const wakeParkMints = new Map();
|
|
196
|
+
// design/158 A9:两条「裸 number 登记簿」装盒——`let` 拆不出闭包,域模块要自增/自减必须共享同一个对象。
|
|
197
|
+
// 语义与计数时机逐字不变(uncountedBillableInflight = B-1: sync /v1/tasks + leader bg runs,无 map 身份,
|
|
198
|
+
// drain 靠它看见;admittedInflight = 修1 的 admission 期计数,见下方注释)。
|
|
122
199
|
const counters = { uncountedBillableInflight: 0, admittedInflight: 0 };
|
|
200
|
+
// 修1(三路复审 B3 HIGH,迟登记窗):ADMISSION-time in-flight counter. Every non-probe request is counted
|
|
201
|
+
// SYNCHRONOUSLY in handle()'s first statements (before ANY await — auth/prepareSpec/lease/claim all happen
|
|
202
|
+
// later) and released once, on whichever of finish/close fires first. Closes the window where a request that
|
|
203
|
+
// just entered the handler was invisible to inflight() and a SIGHUP idle check (or SIGTERM drain read)
|
|
204
|
+
// misjudged the replica idle → hardShutdown mid-admission. Deliberately OVERLAPS the leg registries below
|
|
205
|
+
// (a registered leg counts twice) — every consumer only tests inflight()===0, so overcount is conservative,
|
|
206
|
+
// never a false-idle.
|
|
207
|
+
// drain: expose this replica's in-flight leg count as a LIVE getter on the shared drain state. Union of
|
|
208
|
+
// taskIds (a resumed leg registers in BOTH maps) — bg/resume durable legs (inflightRuns) + live streams
|
|
209
|
+
// (steerableRuns, sync leg included since 1.78). main.ts polls this after SIGTERM to know when drain is done.
|
|
210
|
+
// 对抗复查 B-1(CRITICAL):the SYNC /v1/tasks await and the leader background run register in NEITHER map —
|
|
211
|
+
// a SIGTERM during either read inflight=0 and hard-shut mid-model-work. Count them explicitly (a plain
|
|
212
|
+
// counter: they have no steer/resume identity to key a map on).
|
|
123
213
|
if (deps.drainState)
|
|
124
214
|
deps.drainState.inflight = () => new Set([...inflightRuns.keys(), ...steerableRuns.keys()]).size + counters.uncountedBillableInflight + counters.admittedInflight + (deps.leaderEndpoint?.inflight?.() ?? 0);
|
|
215
|
+
// B3 复审(codex HIGH):admission 计数之外再补一条粗粒度活性时钟:任何非探针请求(/health、/metrics*
|
|
216
|
+
// 除外——探针/抓取是常态噪音)都推进时间戳;SIGHUP 空闲窗要求「inflight 归零 + 活性静默」双满足才关停。
|
|
217
|
+
// 顺带把 idle-but-live 的 peer(还在 list/浏览的壳)也保住。
|
|
218
|
+
// ⚠️ 时基契约(三路复审修3):performance.now() 单调域,与 sighup-idle.ts 的 `now` seam 同域——挂钟
|
|
219
|
+
// (Date.now)跳变(NTP 回拨/夏令时)会把空闲窗判定拉爆。对外报告的 epoch 时间戳(请求日志/drainState.since)
|
|
220
|
+
// 不受影响,仍走 Date.now。
|
|
125
221
|
let lastBillableActivityAt = performance.now();
|
|
126
222
|
const noteActivity = (url) => {
|
|
127
223
|
if (url === "/health" || url.startsWith("/metrics"))
|
|
@@ -130,13 +226,22 @@ export function createHttpServer(rawDeps) {
|
|
|
130
226
|
};
|
|
131
227
|
if (deps.drainState)
|
|
132
228
|
deps.drainState.lastActivityAt = () => lastBillableActivityAt;
|
|
229
|
+
// asyncRewake wake 管道(hooks 阶段二挂账兑现):hook-runner 的 ctx.wake 经 main.ts 的 hookWakeBus holder
|
|
230
|
+
// 到这里——session → 本副本活流(steerableRuns 是 taskId 键,反查经 run 行;活流数=本副本并发数,量小;
|
|
231
|
+
// wake 是稀有事件[async hook exit 2],逐行 get 可接受)。找到即 steer(操作者 hook 的 stderr,trusted
|
|
232
|
+
// 与 explicitOperator 同源语义;下个 turn 边界注入)。无活流 → 挂起腿 park fallback(与 HTTP steer 的
|
|
233
|
+
// tryPark 同一原语:pending checkpoint 是权威,CAS on status='pending';落上=resume 时 core 注入)。
|
|
234
|
+
// park 也不上 → false(别副本 live/已结束——调用方记账)。
|
|
133
235
|
if (deps.hookWakeBus) {
|
|
134
236
|
deps.hookWakeBus.deliver = async (sessionId, text) => {
|
|
237
|
+
// hook stderr 不是操作者亲笔——是 hook 脚本运行时输出(可能回显下载物/工具输出),
|
|
238
|
+
// 【内容】不升 trusted(与 HTTP steer 的非-explicitOperator 姿势一致),且先过同一 validatePendingSteer
|
|
239
|
+
// 校验(break-out/控制字符即拒,不静默换流)。围栏 provenance 在 main.ts 闭包侧(注入前缀标明来源)。
|
|
135
240
|
try {
|
|
136
241
|
validatePendingSteer({ text, trusted: false });
|
|
137
242
|
}
|
|
138
243
|
catch {
|
|
139
|
-
return false;
|
|
244
|
+
return false; // steering.invalid_content:内容不合法,不投递(调用方按 dropped 记账)
|
|
140
245
|
}
|
|
141
246
|
for (const [tid, stream] of steerableRuns) {
|
|
142
247
|
try {
|
|
@@ -147,9 +252,12 @@ export function createHttpServer(rawDeps) {
|
|
|
147
252
|
return true;
|
|
148
253
|
}
|
|
149
254
|
catch {
|
|
150
|
-
continue;
|
|
255
|
+
continue; // not_running race / 行读失败 → 试下一个;全空 → 挂起腿 park
|
|
151
256
|
}
|
|
152
257
|
}
|
|
258
|
+
// 挂起腿:任务 durably suspended 时 async hook 才收场(如慢 lint 收尾)——
|
|
259
|
+
// wake park 到 session 的 pending checkpoint(镜像 POST /v1/runs/:id/steer 的 tryPark:scope/token
|
|
260
|
+
// 缺失或 CAS 失手=没有真挂起的 checkpoint → false)。resume 时 core 把它作为 pending steer 注入。
|
|
153
261
|
try {
|
|
154
262
|
const cs = deps.checkpointStore;
|
|
155
263
|
if (!cs)
|
|
@@ -163,12 +271,14 @@ export function createHttpServer(rawDeps) {
|
|
|
163
271
|
return await cs.setPendingSteer(token, scope, { text, trusted: false });
|
|
164
272
|
}
|
|
165
273
|
catch {
|
|
166
|
-
return false;
|
|
274
|
+
return false; // park 面任何失败=诚实 false(调用方按 dropped 记账),绝不 throw 回 hook 收尾路径
|
|
167
275
|
}
|
|
168
276
|
};
|
|
169
277
|
}
|
|
170
278
|
const server = http.createServer((req, res) => {
|
|
171
279
|
void handle(req, res).catch((err) => {
|
|
280
|
+
// A typed HttpError (e.g. a malformed JSON body → 400) carries its own status — a client mistake is a
|
|
281
|
+
// 4xx, not a 500. Everything else is a genuine internal error: log the cause, never echo it (info disclosure).
|
|
172
282
|
if (err instanceof HttpError) {
|
|
173
283
|
if (!res.headersSent)
|
|
174
284
|
sendError(res, err.status, httpErrorCode(err.status, err.code), err.message, { ...(err.code ? { code: err.code } : {}), ...(err.extra ?? {}) });
|
|
@@ -183,22 +293,38 @@ export function createHttpServer(rawDeps) {
|
|
|
183
293
|
res.end();
|
|
184
294
|
});
|
|
185
295
|
});
|
|
296
|
+
// design/158 A9 装配缝:服务级 ctx 建**一次**(deps 平铺视图 / 跨域运行期登记簿 / 无状态助手 / 单域状态)。
|
|
297
|
+
// 每请求只再加一格 `req`(见 handle 内)。助手全是 `function` 声明,提升后此处引用安全。
|
|
186
298
|
const routeCtxBase = {
|
|
187
299
|
deps,
|
|
188
300
|
registry: { idemCache, inflightRuns, preemptableRuns, cancelledViaVerb, steerableRuns, wakeParkMints, counters },
|
|
189
301
|
helpers: { readJson, readRawBody, rateLimited, quotaExceeded, leaseDenied, safeDecode, isFleetWide, runOwnerOk, sessionOwnerScope, sessionOwnerScopeForWrite, runSessionAcceptOk },
|
|
190
302
|
local: { images: createImagesLocal(deps), sessions: createSessionsLocal(), sessionSync: createSessionSyncLocal() },
|
|
303
|
+
// A9 尾批:提交/续跑「腿」只装引用(实现仍在本闭包里,逐字未动)——tasks/runs 共用 prepareSpec,
|
|
304
|
+
// approvals/assistant/notify-wake 共用 resume 家族;走这一格才不会造出 routes/* 互相值 import 的环。
|
|
191
305
|
legs: { prepareSpec, finalizeTaskResult, resumeCheckpoint, driveResumeIntoRunLog, resumePreempted, resumeWake, resumePlanReview },
|
|
192
306
|
};
|
|
193
307
|
async function handle(req, res) {
|
|
194
308
|
const startedAt = Date.now();
|
|
195
309
|
const method = req.method ?? "GET";
|
|
196
310
|
const url = (req.url ?? "/").split("?")[0];
|
|
197
|
-
noteActivity(url);
|
|
311
|
+
noteActivity(url); // B3: admission-time activity stamp (before any await — no gap for the idle watch to miss)
|
|
312
|
+
// 修1(三路复审 B3 HIGH):admission-time inflight registration — SYNCHRONOUS, before the first await, so a
|
|
313
|
+
// request still in its pre-registration phase (auth/prepareSpec/lease/claim) is already visible to
|
|
314
|
+
// drainState.inflight(). Released exactly once in `record` below (finish/close latch — the same lifecycle
|
|
315
|
+
// that covers aborted SSE streams). Probes (/health, /metrics*) and CORS preflight stay uncounted (constant
|
|
316
|
+
// sampling noise must not hold the SIGHUP idle window open).
|
|
198
317
|
const admitted = method !== "OPTIONS" && url !== "/health" && !url.startsWith("/metrics");
|
|
199
318
|
if (admitted)
|
|
200
319
|
counters.admittedInflight++;
|
|
320
|
+
// Record on whichever of finish/close fires FIRST (a `logged` latch prevents double-count): a client that
|
|
321
|
+
// aborts (esp. an SSE stream) fires `close` WITHOUT `finish`, so a finish-only handler never counts it —
|
|
322
|
+
// exactly the abnormal traffic most worth observing. An abort mid-response is logged as 499 (client closed).
|
|
201
323
|
let logged = false;
|
|
324
|
+
// [854]⑥ 纯观测:tasks/stream 断连(499)日志此前无 task 上下文,排查「谁的流断了」只能对时间戳。
|
|
325
|
+
// 流路由把 X-Task-Id(earlyDurableTid)stash 到这两个 handle 作用域变量;record 只在 499 行带上——
|
|
326
|
+
// 不动计费/inflight(admittedInflight 的 latch 语义原样)。detached 标记见 [854]①b(断连宽限 opt-in)。
|
|
327
|
+
// design/158 A9:每请求局部量收进 reqState(域模块经 ctx.req 读写同一个盒子),ctx = 服务级四格 + 本格。
|
|
202
328
|
const reqState = { streamTaskId: undefined, streamDetached: false, source: null };
|
|
203
329
|
const ctx = { deps: routeCtxBase.deps, registry: routeCtxBase.registry, helpers: routeCtxBase.helpers, local: routeCtxBase.local, legs: routeCtxBase.legs, req: reqState };
|
|
204
330
|
const record = (viaClose) => {
|
|
@@ -206,7 +332,7 @@ export function createHttpServer(rawDeps) {
|
|
|
206
332
|
return;
|
|
207
333
|
logged = true;
|
|
208
334
|
if (admitted)
|
|
209
|
-
counters.admittedInflight--;
|
|
335
|
+
counters.admittedInflight--; // 修1: paired release (latched — one decrement per admitted request)
|
|
210
336
|
const route = routeLabel(method, url);
|
|
211
337
|
const seconds = (Date.now() - startedAt) / 1000;
|
|
212
338
|
const aborted = viaClose && !res.writableEnded;
|
|
@@ -225,27 +351,54 @@ export function createHttpServer(rawDeps) {
|
|
|
225
351
|
return;
|
|
226
352
|
}
|
|
227
353
|
if (req.method === "GET" && url === "/health") {
|
|
354
|
+
// Sema-registry restart signal: additive, present ONLY when a restart-to-apply slice changed
|
|
355
|
+
// since boot — so a normal worker's /health shape is unchanged. `restartRequired` is the boolean the
|
|
356
|
+
// orchestrator polls; `restart` carries which slices + the version + when it first became required.
|
|
228
357
|
const restart = deps.restartState?.();
|
|
358
|
+
// TOC integration (#1 /health contract — INTEGRATION-DESIGN): identity fields so the shell's Lifecycle
|
|
359
|
+
// Manager can verify it connected to the RIGHT engine (instance + data dir + config fingerprint + pid/port), not
|
|
360
|
+
// a stale/wrong one. configHash = a STABLE fingerprint of the restart-relevant config shape (NO secrets — only
|
|
361
|
+
// model id / backends / data root / provider / port), so the shell detects a config drift without reading values.
|
|
229
362
|
const configHash = createHash("sha256")
|
|
230
363
|
.update(JSON.stringify({ model: deps.config.model.id, sessionBackend: deps.config.sessionBackend, dbBackend: deps.config.dbBackend, dataRoot: deps.config.localDataRoot, configProvider: deps.config.configProvider, remoteExec: deps.config.remoteExec?.provider, port: deps.config.port }))
|
|
231
364
|
.digest("hex")
|
|
232
365
|
.slice(0, 16);
|
|
233
366
|
sendJson(res, 200, {
|
|
234
367
|
status: "ok",
|
|
368
|
+
// build self-description — "did the restart actually pick up new code" judgement for the
|
|
369
|
+
// shell/orchestrator (same source as GET /v1/capabilities.version; absent only if capabilities wiring is).
|
|
235
370
|
...(typeof deps.capabilities?.version === "string" ? { version: deps.capabilities.version } : {}),
|
|
371
|
+
// additive draining flag — k8s readiness / shell Lifecycle Manager摘流 signal. Absent when not draining.
|
|
236
372
|
...(deps.drainState?.draining ? { draining: true, ...(deps.drainState.since ? { drainingSince: deps.drainState.since } : {}) } : {}),
|
|
373
|
+
// S5: additive degradation flag — present ONLY when the boot auto-probe fell back to in-memory, so a
|
|
374
|
+
// healthy worker's /health shape is unchanged. durable:false = "this replica is NOT persisting although
|
|
375
|
+
// a DB was configured" (the orchestrator's restart/alarm signal; an in-memory-by-choice worker omits it).
|
|
237
376
|
...(deps.storeDegraded ? { durable: false, storeDegraded: true } : {}),
|
|
377
|
+
// boot ready 门(b):additive — absent = ready(正常形状不变);false = roster 未落地,
|
|
378
|
+
// 计费提交在 503(orchestrator/壳读此旗决定摘流/重试;编排器侧「pull 成功才 ready」增强不互斥)。
|
|
238
379
|
...(deps.modelReady && !deps.modelReady() ? { ready: false, readyReason: "model roster pending (first effective-config pull)" } : {}),
|
|
239
380
|
model: deps.config.model.id,
|
|
240
|
-
sessionBackend: deps.sessionStoreLabel ?? deps.config.sessionBackend,
|
|
381
|
+
sessionBackend: deps.sessionStoreLabel ?? deps.config.sessionBackend, // effective store, not the coerced enum
|
|
241
382
|
sessions: deps.runner.sessions.size,
|
|
242
383
|
...(deps.instanceId ? { instanceId: deps.instanceId } : {}),
|
|
384
|
+
// 🔴 `dataRoot`/`pid` 是**公开承诺字段**(2026-07-28 起,USAGE「嵌入形契约」段):桌面宿主的
|
|
385
|
+
// 引擎身份验证(「这个 /health 是不是我起的那个进程」)整个押在这两个键上——pid 必须等于所
|
|
386
|
+
// spawn 子进程、dataRoot 必须等于所配数据根,缺任一=判外来。localDataRoot 的解析恒回退
|
|
387
|
+
// `~/.ai-agent`(config.ts,与 DB_BACKEND 无关)⇒ 此条件 spread 实为恒真;改动这两个键的
|
|
388
|
+
// 在场性/语义前先看 test/health-identity-contract.test.ts。
|
|
243
389
|
...(deps.config.localDataRoot ? { dataRoot: deps.config.localDataRoot } : {}),
|
|
244
390
|
configHash,
|
|
245
391
|
pid: process.pid,
|
|
246
392
|
port: deps.config.port,
|
|
393
|
+
// D-G: a POSITIVE go-live signal that this worker is a crypto-bound direct-door worker (trusted-header
|
|
394
|
+
// path disabled) — additive, only present when active, so a normal worker's /health shape is unchanged.
|
|
395
|
+
// Surfaced for the DG-M2 go-live runbook (was previously inferrable only behaviorally via a 401).
|
|
247
396
|
...(deps.config.directDoorActive ? { directDoorActive: true } : {}),
|
|
248
397
|
...(restart ? { restartRequired: true, restart: { reasons: restart.reasons, version: restart.version, since: restart.since } } : {}),
|
|
398
|
+
// codex R18 (models-tiers deferral): additive DEGRADED state — the model plane is deferred but no
|
|
399
|
+
// next-boot handoff exists (LKG not restart-surviving), so no restart is forced and the plane stays
|
|
400
|
+
// unapplied. Present ⇒ the operator must either mount restart-surviving LKG storage (+ set
|
|
401
|
+
// CONFIG_LKG_DURABLE) or restart manually after ensuring the candidate will be re-pulled at boot.
|
|
249
402
|
...(() => {
|
|
250
403
|
const stuck = deps.planeDeferredState?.();
|
|
251
404
|
return stuck ? { modelPlaneDeferred: { version: stuck.version, since: stuck.since, noHandoff: true, ...(stuck.blocked ? { blockedReasons: stuck.blocked } : {}) } } : {};
|
|
@@ -253,6 +406,9 @@ export function createHttpServer(rawDeps) {
|
|
|
253
406
|
});
|
|
254
407
|
return;
|
|
255
408
|
}
|
|
409
|
+
// Metrics (read-only): authorized by EITHER the full authToken OR a read-only metricsToken — so a
|
|
410
|
+
// control plane (sema-registry) can pull metrics fleet-wide with one token, never holding each
|
|
411
|
+
// worker's full authToken. Handled BEFORE the global gate so metricsToken-only callers aren't 401'd.
|
|
256
412
|
if (req.method === "GET" && (url === "/metrics" || url === "/metrics/summary" || url === "/metrics/plan-cache")) {
|
|
257
413
|
if (!deps.metrics) {
|
|
258
414
|
sendError(res, 404, "feature.metrics_disabled", "metrics disabled");
|
|
@@ -269,6 +425,7 @@ export function createHttpServer(rawDeps) {
|
|
|
269
425
|
sendJson(res, 200, { model: deps.config.model.id, ...deps.metrics.summarize() });
|
|
270
426
|
return;
|
|
271
427
|
}
|
|
428
|
+
// Per-scope objective-recurrence detail (core design/42 handoff). Same token gate as /metrics.
|
|
272
429
|
if (url === "/metrics/plan-cache") {
|
|
273
430
|
sendJson(res, 200, { scopes: deps.planCacheProbe?.dump() ?? {} });
|
|
274
431
|
return;
|
|
@@ -277,7 +434,14 @@ export function createHttpServer(rawDeps) {
|
|
|
277
434
|
res.end(deps.metrics.render());
|
|
278
435
|
return;
|
|
279
436
|
}
|
|
437
|
+
// 鉴权桥预认证:在任何 principal-gated 路由(trace/fleet/workflows/authorizer)读取身份之【前】,
|
|
438
|
+
// 若 Authorization bearer 形似 registry-JWT 且 verifier 在位,验它——通过则把 sub 挂进 request-local
|
|
439
|
+
// (setSsoPrincipal),verifiedPrincipal 单一真源随后优先读它(directDoor 与 BFF 两部署同效),并同时写入
|
|
440
|
+
// principalHeader(小写键)供 principalFrom 直读处使用。验证不通过=不设身份,后续 systemFor 因 bearer 不匹配
|
|
441
|
+
// 任何静态 token 而 401(恒 generic,R9:错误码只进日志)。非 JWT-形 bearer(静态 token)= looksLikeJwt
|
|
442
|
+
// 跳过,零开销,既有调用方字节不变。
|
|
280
443
|
if (deps.registryJwtVerifier) {
|
|
444
|
+
// 只认 Bearer scheme(异源复审 INFO:`Basic a.b.c` 之前会白跑一次 verify + 一条 rejected 记账噪声)。
|
|
281
445
|
const authz = req.headers.authorization ?? "";
|
|
282
446
|
const rawBearer = /^Bearer\s/i.test(authz) ? authz.replace(/^Bearer\s+/i, "") : "";
|
|
283
447
|
if (rawBearer && looksLikeJwt(rawBearer)) {
|
|
@@ -285,23 +449,35 @@ export function createHttpServer(rawDeps) {
|
|
|
285
449
|
if (v.ok) {
|
|
286
450
|
setSsoPrincipal(req, v.identity.principal);
|
|
287
451
|
req.headers[deps.config.principalHeader.toLowerCase()] = v.identity.principal;
|
|
452
|
+
// scope claim 也挂 request-local(多租户活动租户;registry-core 0.3.0+ VerifiedIdentity.scope)。
|
|
453
|
+
// 用途=审计归因 + 未来 scope 级 quota/过滤的读取地基。同时记一条 info 审计锚(每认证请求一条):
|
|
454
|
+
// 此前 principal 入了 owner 但 scope 全程不落任何日志/审计(grep 实证零命中 scope)——补齐。
|
|
288
455
|
if (v.identity.scope)
|
|
289
456
|
setSsoScope(req, v.identity.scope);
|
|
290
457
|
deps.logger?.info?.("auth_bridge_principal_accepted", { principal: v.identity.principal, ...(v.identity.scope ? { scope: v.identity.scope } : {}) });
|
|
291
458
|
}
|
|
292
459
|
}
|
|
293
460
|
}
|
|
461
|
+
// design/158 A9 域模块:Task Trace 只读面 + 用量统计面(routes/trace-usage.ts)。
|
|
462
|
+
// ⚠️ 位置即契约:必须留在下面的全局 service-credential 门**之前**(traceToken-only 调用方否则先被 401)。
|
|
294
463
|
if (await handleTraceUsage(req, res, url, ctx))
|
|
295
464
|
return;
|
|
465
|
+
// design/158 A9 域模块:S8 自编排 workflow 只读三路(routes/workflows.ts)。
|
|
466
|
+
// 位置同 trace/fleet:全局 service-credential 门之前,块内 scope 门=这些读的唯一租户边界。
|
|
296
467
|
if (await handleWorkflows(req, res, url, ctx))
|
|
297
468
|
return;
|
|
469
|
+
// design/158 A9 域模块:MF-Fleet 实时舰队流(routes/fleet.ts)。服务凭据门之前,按 principal 自门。
|
|
298
470
|
if (await handleFleet(req, res, url, ctx))
|
|
299
471
|
return;
|
|
472
|
+
// Service-credential gate + SOURCE derivation: which system's token authenticated this request.
|
|
473
|
+
// `source` is set ONLY here, from the credential — a caller cannot claim another system's identity.
|
|
300
474
|
let source = null;
|
|
301
475
|
const anyServiceAuth = Boolean(deps.config.authToken) || Object.keys(deps.config.authTokens ?? {}).length > 0;
|
|
302
476
|
if (anyServiceAuth) {
|
|
303
477
|
const sys = systemFor(req, deps.config);
|
|
304
478
|
if (sys === undefined) {
|
|
479
|
+
// 鉴权桥:静态 service-token 都不匹配 → 若入口预认证已把一个验过的 registry-JWT principal
|
|
480
|
+
// 挂进 request-local(ssoVerifiedPrincipal),即用户直连(source="sso");否则无凭据 401。
|
|
305
481
|
if (ssoVerifiedPrincipal(req)) {
|
|
306
482
|
source = "sso";
|
|
307
483
|
}
|
|
@@ -314,7 +490,16 @@ export function createHttpServer(rawDeps) {
|
|
|
314
490
|
source = sys;
|
|
315
491
|
}
|
|
316
492
|
}
|
|
493
|
+
// design/158 A9:`source` 的**唯一**赋值段就在上面;域模块经 ctx.req.source 读到同一个值(单向镜像,
|
|
494
|
+
// 此行之后 handle() 不再改它)。
|
|
317
495
|
reqState.source = source;
|
|
496
|
+
// Mandatory service token on submission endpoints (center: a worker MUST validate a service-to-service
|
|
497
|
+
// token on task creation — not "if auth is configured" — else anything in-cluster can submit & bill
|
|
498
|
+
// directly, bypassing sema-registry's auth/audit). Fail-closed: the POST task/run/leader endpoints refuse
|
|
499
|
+
// when no authToken is set, unless explicitly opted out for local dev (ALLOW_UNAUTHED_WRITES=true).
|
|
500
|
+
// The bake door (POST /v1/images/bakes*) is build-host-RCE-capable and authed by the Bearer-token-no-cookie
|
|
501
|
+
// model (§P2.4b) — it MUST also refuse when no service token is configured (else a forged principal header
|
|
502
|
+
// alone could reach an operator-gated RCE door).
|
|
318
503
|
if (req.method === "POST" &&
|
|
319
504
|
!anyServiceAuth &&
|
|
320
505
|
!deps.config.allowUnauthedWrites &&
|
|
@@ -322,16 +507,31 @@ export function createHttpServer(rawDeps) {
|
|
|
322
507
|
sendError(res, 503, "auth.service_token_required", "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting task submissions");
|
|
323
508
|
return;
|
|
324
509
|
}
|
|
510
|
+
// DRAINING — this instance is shutting down gracefully (SIGTERM received, in-flight turns being
|
|
511
|
+
// waited on). Refuse NEW model-running work (the same billable submit set: task/stream/runs/leader + the
|
|
512
|
+
// resume-flavored assistant verbs + approvals responds — each starts a model turn) with 503 + Retry-After so
|
|
513
|
+
// the shell/orchestrator retries against the replacement. Everything else stays open: reads (events/health/
|
|
514
|
+
// trace), cancel, steer/respond on in-flight runs — those help the drained turns finish, they don't start new ones.
|
|
325
515
|
if (deps.drainState?.draining && req.method === "POST" && isBillableSubmitPath(url)) {
|
|
326
516
|
res.setHeader("retry-after", "15");
|
|
517
|
+
// errorCode 补齐(2026-07-28 核查:cli 的 pre-stream drain 重试只能靠 `error` 文案字面判型——
|
|
518
|
+
// SDK 的 code 臂恒 undefined 是死码;server 改文案=对端重试静默失效)。`error:"draining"` 字面
|
|
519
|
+
// 自此视为**冻结的 wire 契约**(旧消费端判的就是它),errorCode 是机器码正门。
|
|
327
520
|
sendError(res, 503, "draining", "draining", { message: "this instance is draining for shutdown/upgrade — retry against the replacement instance" });
|
|
328
521
|
return;
|
|
329
522
|
}
|
|
523
|
+
// boot ready 门(b)(E3 workers.model 废除的 fail-closed 半场):registry 部署且无显式 env 模型时,
|
|
524
|
+
// 首次 effective pull 落 roster 之前 worker 只有占位模型——计费提交打占位=垃圾任务,503 让编排器/壳重试
|
|
525
|
+
// 到 roster 落地(通常 <秒)。镜像 draining 姿势(同 billable 集合;读面/cancel/steer 全开);/health 带
|
|
526
|
+
// ready:false 加性旗。env 模型在(操作员显式声明)或非 registry 部署=恒 ready,现有部署零影响。
|
|
330
527
|
if (deps.modelReady && !deps.modelReady() && req.method === "POST" && isBillableSubmitPath(url)) {
|
|
331
528
|
res.setHeader("retry-after", "5");
|
|
332
529
|
sendError(res, 503, "state.model_roster_pending", "model_roster_pending", { message: "this worker has no model yet (waiting for the first effective-config pull to land the roster) — retry shortly" });
|
|
333
530
|
return;
|
|
334
531
|
}
|
|
532
|
+
// E6 (review): the operator session-policy PUT is a mutating operator-gated write — apply the SAME fail-closed
|
|
533
|
+
// no-service-token guard as the POST submit paths (an un-authed worker must not accept policy writes from anything
|
|
534
|
+
// in-cluster; without it a forged principal header alone could tighten/DoS a session's tools).
|
|
335
535
|
if (req.method === "PUT" &&
|
|
336
536
|
!anyServiceAuth &&
|
|
337
537
|
!deps.config.allowUnauthedWrites &&
|
|
@@ -339,62 +539,99 @@ export function createHttpServer(rawDeps) {
|
|
|
339
539
|
sendError(res, 503, "auth.service_token_required", "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting session-policy writes");
|
|
340
540
|
return;
|
|
341
541
|
}
|
|
542
|
+
// design/158 A9:能力/场景/模型目录域(routes/capabilities.ts)。位置=全局 service-credential 门之后、
|
|
543
|
+
// 任务提交面之前,与拆分前逐行同序。
|
|
342
544
|
if (await handleCapabilities(req, res, url, ctx))
|
|
343
545
|
return;
|
|
546
|
+
// design/158 A9 域模块:sideQuery 一次性问答面(routes/side-query.ts)。
|
|
344
547
|
if (await handleSideQuery(req, res, url, ctx))
|
|
345
548
|
return;
|
|
549
|
+
// design/158 A9 域模块:同步提交面 POST /v1/tasks · /v1/tasks/stream(routes/tasks.ts;B3 三腿具名化在内)。
|
|
346
550
|
if (await handleTasks(req, res, url, ctx))
|
|
347
551
|
return;
|
|
552
|
+
// design/158 A9 域模块:异步 run 面 + run 级动词(routes/runs.ts)。与 tasks 域共用 ctx.legs.prepareSpec。
|
|
348
553
|
if (await handleRuns(req, res, url, ctx))
|
|
349
554
|
return;
|
|
555
|
+
// design/158 A9 域模块:SVC-5 workflow agent steer(routes/workflows.ts,与上面的只读三路同域)。
|
|
350
556
|
if (await handleWorkflowAgentSteer(req, res, url, ctx))
|
|
351
557
|
return;
|
|
558
|
+
// design/158 A9 域模块:子代理 / 任务句柄 / 三条 HITL 应答口(routes/runs.ts,与上面的 run 面同域)。
|
|
352
559
|
if (await handleRunVerbs(req, res, url, ctx))
|
|
353
560
|
return;
|
|
561
|
+
// design/158 A9 域模块:v2 leader 端点(routes/leader.ts)。
|
|
354
562
|
if (await handleLeader(req, res, url, ctx))
|
|
355
563
|
return;
|
|
564
|
+
// design/158 A9:sandbox-image-pool 控制面(routes/images.ts;bakes 段在 images 段之前的顺序由域模块内部保住)。
|
|
356
565
|
if (await handleImages(req, res, url, ctx))
|
|
357
566
|
return;
|
|
567
|
+
// design/158 A9 域模块:人在环审批面(durable F4 + legacy)与 assistant-scheduler 三动词
|
|
568
|
+
// (routes/approvals-assistant.ts)。续跑一律经 ctx.legs 的 resume 家族。
|
|
358
569
|
if (await handleApprovalsAssistant(req, res, url, ctx))
|
|
359
570
|
return;
|
|
571
|
+
// design/158 A9:观测只读面(routes/observability.ts):/v1/outcomes + /v1/usage。
|
|
360
572
|
if (await handleObservability(req, res, url, ctx))
|
|
361
573
|
return;
|
|
574
|
+
// design/158 A9:memory 导出/同步 + /v1/policy 只读面(routes/memory-policy.ts)。
|
|
362
575
|
if (await handleMemoryPolicy(req, res, url, ctx))
|
|
363
576
|
return;
|
|
577
|
+
// design/158 A9:会话/发件链接列表面(routes/sessions-list.ts)。
|
|
364
578
|
if (await handleSessionsList(req, res, url, ctx))
|
|
365
579
|
return;
|
|
580
|
+
// design/158 A9:单会话面(routes/sessions.ts:init/settings/mcp/head/events/audit/fork/delete/policy/workspace)。
|
|
366
581
|
if (await handleSessions(req, res, url, ctx))
|
|
367
582
|
return;
|
|
583
|
+
// design/158 A9:2c session-sync 面(routes/session-sync.ts)。
|
|
368
584
|
if (await handleSessionSync(req, res, url, ctx))
|
|
369
585
|
return;
|
|
586
|
+
// design/158 A9:D-1 通用附件面(routes/attachments.ts)。
|
|
370
587
|
if (await handleAttachments(req, res, url, ctx))
|
|
371
588
|
return;
|
|
589
|
+
// design/158 A9 域模块:会话外事件注入 / 唤醒(routes/notify-wake.ts)。
|
|
372
590
|
if (await handleNotifyWake(req, res, url, ctx))
|
|
373
591
|
return;
|
|
374
592
|
sendError(res, 404, "not_found.route", "not found");
|
|
375
593
|
}
|
|
594
|
+
/** Read + validate the body, authorize, and build the spec. Returns null if it already responded. */
|
|
376
595
|
async function prepareSpec(req, res) {
|
|
377
596
|
const body = (await readJson(req));
|
|
378
597
|
if (!body || typeof body.objective !== "string") {
|
|
379
598
|
sendError(res, 400, "request.field_invalid", "missing 'objective' string");
|
|
380
599
|
return null;
|
|
381
600
|
}
|
|
601
|
+
// [920] 空毒帧第一道门:空/纯空白 objective 会成为持久历史里的空 user 帧——严格网关(anthropic/kimi)
|
|
602
|
+
// 对空 content 400,且一帧毒全 session(同角色 merge 后残留空 block,存量救不了)。core 半场(装配
|
|
603
|
+
// 自愈+intake 拒)在修;HTTP 面 fail-loud 是宪法「诚实优先」形:空任务无合法用例,静默吞掉才是坑。
|
|
382
604
|
if (body.objective.trim().length === 0) {
|
|
383
605
|
sendError(res, 400, "request.field_invalid", "objective must not be empty or whitespace-only (an empty user message poisons the session history on strict providers)");
|
|
384
606
|
return null;
|
|
385
607
|
}
|
|
608
|
+
// jobId (work-view correlation): optional, but if present must be a non-empty string ≤64 chars (the column
|
|
609
|
+
// width) — reject a malformed one up front rather than silently truncating/dropping it at the INSERT.
|
|
386
610
|
if (body.jobId !== undefined && (typeof body.jobId !== "string" || body.jobId.length === 0 || body.jobId.length > 64)) {
|
|
387
611
|
sendError(res, 400, "request.field_invalid", "jobId must be a non-empty string of at most 64 characters");
|
|
388
612
|
return null;
|
|
389
613
|
}
|
|
614
|
+
// [#15] sessionId: a client MAY supply an ARBITRARY id (deliberate asymmetry vs fork/delete — see security.ts
|
|
615
|
+
// `isUuidV7` doc: run-local `--session`, dev anonymous-share ids; the wire contract types sessionId as an opaque
|
|
616
|
+
// string), but it must fit the `session_id VARCHAR(64)` column. Reject an over-long one up front rather than
|
|
617
|
+
// 500/truncating at the INSERT (the authorizer's register() runs later in this function, after this block via the
|
|
618
|
+
// deps.authorize() call — so the cap fires before any INSERT). LENGTH-ONLY — NOT a uuidv7 shape
|
|
619
|
+
// check (that would break the permissive submit contract); submit stays lenient, fork/delete keep the uuidv7 gate.
|
|
390
620
|
if (typeof body.sessionId === "string" && body.sessionId.length > 64) {
|
|
391
621
|
sendError(res, 400, "request.field_invalid", "sessionId must be at most 64 characters");
|
|
392
622
|
return null;
|
|
393
623
|
}
|
|
624
|
+
// Personalization caps: once users can edit these, an uncapped prompt/skill body is a
|
|
625
|
+
// cost hole — reject up front, server-pinned (a UI may be stricter, never looser).
|
|
394
626
|
if (typeof body.systemPrompt === "string" && body.systemPrompt.length > MAX_SYSTEM_PROMPT_CHARS) {
|
|
395
627
|
sendError(res, 400, "request.field_invalid", `systemPrompt must be at most ${MAX_SYSTEM_PROMPT_CHARS} characters`);
|
|
396
628
|
return null;
|
|
397
629
|
}
|
|
630
|
+
// appendSystemPrompt ([1478] R2 + codex R7 F2): fail-loud SHAPE gate on fresh submits — a defined value must
|
|
631
|
+
// be a non-empty string within the systemPrompt cap. A non-string/empty value would be silently normalized to
|
|
632
|
+
// "absent" by resolveSpec's defensive accept (a client serialization bug then runs the task WITHOUT its
|
|
633
|
+
// product-knowledge block — the silent-drop class again); the cap matches systemPrompt (same prompt surface,
|
|
634
|
+
// same per-turn cost hole). Stored resume bodies stay on the defensive drop+warn (acceptAppendSystemPrompt).
|
|
398
635
|
if (body.appendSystemPrompt !== undefined) {
|
|
399
636
|
if (typeof body.appendSystemPrompt !== "string" || body.appendSystemPrompt.length === 0) {
|
|
400
637
|
sendError(res, 400, "request.field_invalid", "appendSystemPrompt must be a non-empty string");
|
|
@@ -405,10 +642,23 @@ export function createHttpServer(rawDeps) {
|
|
|
405
642
|
return null;
|
|
406
643
|
}
|
|
407
644
|
}
|
|
645
|
+
// codex F3/R3/R4 (capability honesty): the append-less-pack combos (rider / settings.outputStyle meeting an
|
|
646
|
+
// already-assembled systemPrompt or a center assembled-identity pack) are adjudicated INSIDE resolveSpec —
|
|
647
|
+
// it alone derives the effective scenario (principal ruling + defaultScenario) and holds the center-pack
|
|
648
|
+
// snapshot the provider pick uses, so an HTTP-side probe could disagree with the actual assembly (R4:
|
|
649
|
+
// scenario mismatch / centerPrompts refresh TOCTOU). This route passes `leg:"fresh"` below, which makes
|
|
650
|
+
// resolveSpec 400 fail-loud (HttpError, surfaced by the catch around prepareSpec's caller); resume families
|
|
651
|
+
// omit it and get the drop+warn mirror instead.
|
|
652
|
+
// [#40 / TOC cwd seam] body.cwd must be an ABSOLUTE host path — fail-loud (a relative cwd would silently resolve
|
|
653
|
+
// against the SERVICE process cwd, not the user's project — TOC review #12). Whether it is HONORED is separately
|
|
654
|
+
// gated to the single-user host lane (resolveSpec); validating shape here is independent of that gate.
|
|
408
655
|
if (body.cwd !== undefined && !isValidCwd(body.cwd)) {
|
|
409
656
|
sendError(res, 400, "request.field_invalid", "cwd must be a non-empty absolute host path");
|
|
410
657
|
return null;
|
|
411
658
|
}
|
|
659
|
+
// design/119 (CC --add-dir) body.additionalDirectories — fail-loud shape on submit (mirrors cwd; each entry is a
|
|
660
|
+
// host path with the SAME absolute/no-`..` rule). Whether it is HONORED is separately gated to the host lane
|
|
661
|
+
// (resolveSpec). An empty array is fine (no-op); a non-array or an entry failing the cwd shape is a client error.
|
|
412
662
|
const addDirsRaw = body.additionalDirectories;
|
|
413
663
|
if (addDirsRaw !== undefined) {
|
|
414
664
|
if (!Array.isArray(addDirsRaw)) {
|
|
@@ -425,6 +675,9 @@ export function createHttpServer(rawDeps) {
|
|
|
425
675
|
return null;
|
|
426
676
|
}
|
|
427
677
|
}
|
|
678
|
+
// body.settings (client SemaSettings stamp) — fail-loud shape/size caps on submit, matching the sibling
|
|
679
|
+
// fields' posture (parseTaskSettings is also defensive on every path incl. resume; this 400s a bad submit early
|
|
680
|
+
// rather than silently coercing — adversarial review). The semantic projection (tighten-only) happens in resolveSpec.
|
|
428
681
|
const settingsRaw = body.settings;
|
|
429
682
|
if (settingsRaw !== undefined && settingsRaw !== null) {
|
|
430
683
|
if (typeof settingsRaw !== "object" || Array.isArray(settingsRaw)) {
|
|
@@ -436,6 +689,8 @@ export function createHttpServer(rawDeps) {
|
|
|
436
689
|
sendError(res, 400, "request.field_invalid", `settings.outputStyle must be at most ${MAX_SETTINGS_OUTPUT_STYLE_CHARS} characters`);
|
|
437
690
|
return null;
|
|
438
691
|
}
|
|
692
|
+
// codex R14: BOTH append carriers fold into ONE spec field (applyTaskSettings: `append\n\nstyle`) — two
|
|
693
|
+
// individually-valid values can exceed the documented cap combined. One aggregate gate, same bound.
|
|
439
694
|
if (typeof st.outputStyle === "string" &&
|
|
440
695
|
st.outputStyle.length > 0 &&
|
|
441
696
|
typeof body.appendSystemPrompt === "string" &&
|
|
@@ -453,6 +708,8 @@ export function createHttpServer(rawDeps) {
|
|
|
453
708
|
}
|
|
454
709
|
}
|
|
455
710
|
}
|
|
711
|
+
// settings.env (R-survey) — fail-loud on submit, parity with outputStyle/permissions (adversarial-review LOW). The
|
|
712
|
+
// defensive parse `cleanEnvMap` also caps these on every path (resume re-runs WITHOUT this gate), this just 400s a fresh submit.
|
|
456
713
|
const envRaw = st.env;
|
|
457
714
|
if (envRaw !== undefined && envRaw !== null) {
|
|
458
715
|
if (typeof envRaw !== "object" || Array.isArray(envRaw)) {
|
|
@@ -471,10 +728,16 @@ export function createHttpServer(rawDeps) {
|
|
|
471
728
|
}
|
|
472
729
|
}
|
|
473
730
|
}
|
|
731
|
+
// L2 ultracode (design/111): fail-loud on a non-boolean (parity with the other settings caps; parseTaskSettings
|
|
732
|
+
// silently ignores non-true on the resume paths the HTTP gate doesn't cover).
|
|
474
733
|
if (st.ultracode !== undefined && typeof st.ultracode !== "boolean") {
|
|
475
734
|
sendError(res, 400, "request.field_invalid", "settings.ultracode must be a boolean");
|
|
476
735
|
return null;
|
|
477
736
|
}
|
|
737
|
+
// settings.hooks(hook-runner 阶段一)— fail-loud on submit(131/132/133 邻居姿势):一个 malformed
|
|
738
|
+
// hooks 配置被静默丢弃 = 用户以为在生效的 deny 钩子其实没跑(安全预期破坏),必须 400。契约校验 =
|
|
739
|
+
// registry-core 0.1.51 HooksConfig + 服务侧上限(parseHooksConfig);resume 路径由 parseTaskSettings 防御
|
|
740
|
+
// (malformed → deferred 报告+warn)。是否 HONOR 另由单用户闸在 resolveSpec 决定(多租户=忽略非 400)。
|
|
478
741
|
if (st.hooks !== undefined && st.hooks !== null) {
|
|
479
742
|
const hooksParsed = parseHooksConfig(st.hooks);
|
|
480
743
|
if (!hooksParsed.config) {
|
|
@@ -488,6 +751,9 @@ export function createHttpServer(rawDeps) {
|
|
|
488
751
|
sendError(res, 400, "request.field_invalid", skillsErr);
|
|
489
752
|
return null;
|
|
490
753
|
}
|
|
754
|
+
// body.images (CC parity): cap COUNT + per-image inline base64 size → 413 (the inline
|
|
755
|
+
// `data` is otherwise passed raw to the spec; URL images are core-gated). MAX_BODY already bounds total bytes; this
|
|
756
|
+
// adds the missing count cap (anti decode-DoS) + an explicit per-image 413. Fail-loud parity with the settings caps.
|
|
491
757
|
if (Array.isArray(body.images)) {
|
|
492
758
|
if (body.images.length > MAX_IMAGES_PER_REQUEST) {
|
|
493
759
|
sendError(res, 413, "request.payload_too_large", `at most ${MAX_IMAGES_PER_REQUEST} images per request`);
|
|
@@ -501,6 +767,10 @@ export function createHttpServer(rawDeps) {
|
|
|
501
767
|
}
|
|
502
768
|
}
|
|
503
769
|
}
|
|
770
|
+
// design/112 C1: validate the client context shape + cap each field. The body is untrusted and timeZone/userEmail
|
|
771
|
+
// flow into core's env block — an object with optional string timeZone/userEmail; reject non-strings / over-long
|
|
772
|
+
// values (anti-DoS + anti env-block-injection bloat). core additionally validates timeZone semantically (invalid
|
|
773
|
+
// IANA zone → UTC, never mislabeled). locale was removed in core 1.186 (dead field) so it is not accepted here.
|
|
504
774
|
if (body.clientContext !== undefined) {
|
|
505
775
|
const cc = body.clientContext;
|
|
506
776
|
if (typeof cc !== "object" || cc === null || Array.isArray(cc)) {
|
|
@@ -516,6 +786,9 @@ export function createHttpServer(rawDeps) {
|
|
|
516
786
|
return null;
|
|
517
787
|
}
|
|
518
788
|
}
|
|
789
|
+
// §7 P0.5 sandbox image selection: shape-validate here (a non-empty profile string; capabilitiesNeeded a
|
|
790
|
+
// string[]). The per-principal visibility re-admit + profile→digest resolution happen in resolveSpec (it
|
|
791
|
+
// holds the imageIndex + the trusted principal); a 400/404 there surfaces via the HttpError catch below.
|
|
519
792
|
if (body.sandboxImageProfile !== undefined && (typeof body.sandboxImageProfile !== "string" || body.sandboxImageProfile.length === 0 || body.sandboxImageProfile.length > 128)) {
|
|
520
793
|
sendError(res, 400, "request.field_invalid", "sandboxImageProfile must be a non-empty string of at most 128 characters");
|
|
521
794
|
return null;
|
|
@@ -524,14 +797,23 @@ export function createHttpServer(rawDeps) {
|
|
|
524
797
|
sendError(res, 400, "request.field_invalid", "capabilitiesNeeded must be an array of strings");
|
|
525
798
|
return null;
|
|
526
799
|
}
|
|
800
|
+
// capabilitiesNeeded is a constraint ON the selected image — meaningless without a profile to select (it would
|
|
801
|
+
// otherwise be silently ignored while the task runs the unchecked worker-global default). Adversarial-review LOW.
|
|
527
802
|
if (Array.isArray(body.capabilitiesNeeded) && body.capabilitiesNeeded.length > 0 && (typeof body.sandboxImageProfile !== "string" || body.sandboxImageProfile.length === 0)) {
|
|
528
803
|
sendError(res, 400, "request.field_conflict", "capabilitiesNeeded requires sandboxImageProfile (it constrains the selected image)");
|
|
529
804
|
return null;
|
|
530
805
|
}
|
|
806
|
+
// §7 P0.5 + the multi-run orchestration gates don't compose in v1: the per-task image binds by sessionId, but
|
|
807
|
+
// `cascade` strips sessionId per rung and `verify` runs its verifier on a separate session — both would
|
|
808
|
+
// silently fall back to the worker-global default image. Reject the combo FAIL-LOUD rather than run the wrong
|
|
809
|
+
// image (adversarial-review round-2 MEDIUM). Lift when core propagates the image binding to those sub-runs.
|
|
531
810
|
if (typeof body.sandboxImageProfile === "string" && body.sandboxImageProfile.length > 0 && (body.cascade === true || body.verify === true)) {
|
|
532
811
|
sendError(res, 400, "request.field_conflict", "sandboxImageProfile is not supported together with cascade/verify in v1 (the per-task image would not bind to the cascade rungs / verifier sub-run)");
|
|
533
812
|
return null;
|
|
534
813
|
}
|
|
814
|
+
// Structured output (CC --json-schema): a plain JSON-schema OBJECT, size-capped (an uncapped schema is a
|
|
815
|
+
// prompt-bloat / cost hole, same posture as systemPrompt/skills). Deep schema validity is core's job — a
|
|
816
|
+
// malformed schema fails the task with a typed core error, not a 400 here. Threaded into spec by resolveSpec.
|
|
535
817
|
if (body.outputSchema !== undefined) {
|
|
536
818
|
if (typeof body.outputSchema !== "object" || body.outputSchema === null || Array.isArray(body.outputSchema)) {
|
|
537
819
|
sendError(res, 400, "request.field_invalid", "outputSchema must be a JSON Schema object");
|
|
@@ -542,10 +824,19 @@ export function createHttpServer(rawDeps) {
|
|
|
542
824
|
return null;
|
|
543
825
|
}
|
|
544
826
|
}
|
|
827
|
+
// E7 (shell-host contract): reasoning-effort selection (the `/effort` picker), mapped to core's ThinkingLevel in
|
|
828
|
+
// resolveSpec. Validate FAIL-LOUD — a provided-but-unknown level is a 400, never silently dropped (the body.model
|
|
829
|
+
// silent-drop bug class). The accepted set is core's tier scale; the picker's default set is advertised on
|
|
830
|
+
// /v1/models (supportedEffortLevels). Absent ⇒ core uses the resolved role's default thinking.
|
|
545
831
|
if (body.reasoningEffort !== undefined && !isThinkingLevel(body.reasoningEffort)) {
|
|
546
832
|
sendError(res, 400, "request.field_invalid", "reasoningEffort must be one of: off, minimal, low, medium, high, xhigh, max");
|
|
547
833
|
return null;
|
|
548
834
|
}
|
|
835
|
+
// E18 (shell-host contract): resume-at handle shape. A non-empty string ≤64 (the uuidv7 eventId / entry_id column
|
|
836
|
+
// width). resolveSpec resolves it to a SessionTreeEntry.id (eventId→entryId anchor map) — an unknown handle 4xx's
|
|
837
|
+
// there. Reject combos core forbids (resumeAt + a fresh verify/cascade leg replaces/strips the session per
|
|
838
|
+
// rung/sub-run, so resume-at is meaningless there — fail-loud rather than silently branch a session that's about
|
|
839
|
+
// to be replaced), mirroring the sandboxImageProfile+cascade/verify reject.
|
|
549
840
|
if (body.resumeAt !== undefined) {
|
|
550
841
|
if (typeof body.resumeAt !== "string" || body.resumeAt.length === 0 || body.resumeAt.length > 64) {
|
|
551
842
|
sendError(res, 400, "request.field_invalid", "resumeAt must be a non-empty message eventId string (≤64 chars)");
|
|
@@ -556,6 +847,8 @@ export function createHttpServer(rawDeps) {
|
|
|
556
847
|
return null;
|
|
557
848
|
}
|
|
558
849
|
}
|
|
850
|
+
// [833] rewind exclusive mode: two enum values only, and only riding a resumeAt (alone it qualifies nothing — a
|
|
851
|
+
// silent no-op is the body.model silent-drop bug class, so fail loud). Absent ⇒ core default "at" (zero regression).
|
|
559
852
|
if (body.resumeAtMode !== undefined) {
|
|
560
853
|
if (body.resumeAtMode !== "at" && body.resumeAtMode !== "before") {
|
|
561
854
|
sendError(res, 400, "request.field_invalid", 'resumeAtMode must be "at" or "before"');
|
|
@@ -566,6 +859,9 @@ export function createHttpServer(rawDeps) {
|
|
|
566
859
|
return null;
|
|
567
860
|
}
|
|
568
861
|
}
|
|
862
|
+
// E12 (shell-host contract): prompt-suggestions opt-in. `true`/`false` or an object `{count?, role?}`. Validate
|
|
863
|
+
// FAIL-LOUD (a malformed value is a 400, not a silent off — the body.model silent-drop bug class); resolveSpec then
|
|
864
|
+
// normalizes (and re-normalizes defensively on resume, which skips this HTTP validation).
|
|
569
865
|
if (body.suggestNextPrompts !== undefined && typeof body.suggestNextPrompts !== "boolean") {
|
|
570
866
|
const s = body.suggestNextPrompts;
|
|
571
867
|
if (typeof s !== "object" || s === null || Array.isArray(s)) {
|
|
@@ -582,22 +878,33 @@ export function createHttpServer(rawDeps) {
|
|
|
582
878
|
return null;
|
|
583
879
|
}
|
|
584
880
|
}
|
|
881
|
+
// E19 (shell-host contract): rewind-files opt-in must be a boolean (fail-loud, not silently dropped).
|
|
585
882
|
if (body.rewindFiles !== undefined && typeof body.rewindFiles !== "boolean") {
|
|
586
883
|
sendError(res, 400, "request.field_invalid", "rewindFiles must be a boolean");
|
|
587
884
|
return null;
|
|
588
885
|
}
|
|
886
|
+
// MF-30 memory PAUSE (option B): the per-request write toggle must be a boolean (fail-loud — a malformed value
|
|
887
|
+
// silently writing to memory when the user asked to pause is exactly the silent-drop bug class).
|
|
589
888
|
if (body.memoryWrite !== undefined && typeof body.memoryWrite !== "boolean") {
|
|
590
889
|
sendError(res, 400, "request.field_invalid", "memoryWrite must be a boolean (false = pause memory writes for this run)");
|
|
591
890
|
return null;
|
|
592
891
|
}
|
|
892
|
+
// design/114 Phase3: requireExistingSession opt-in must be a boolean (fail-loud — a truthy non-boolean silently
|
|
893
|
+
// NOT triggering the fail-loud is the very "silent fresh run" this flag exists to prevent).
|
|
593
894
|
if (body.requireExistingSession !== undefined && typeof body.requireExistingSession !== "boolean") {
|
|
594
895
|
sendError(res, 400, "request.field_invalid", "requireExistingSession must be a boolean");
|
|
595
896
|
return null;
|
|
596
897
|
}
|
|
898
|
+
// §4 (Fork): enableFork opt-in must be a boolean (fail-loud, not silently dropped — mirrors selfOrchestration).
|
|
597
899
|
if (body.enableFork !== undefined && typeof body.enableFork !== "boolean") {
|
|
598
900
|
sendError(res, 400, "request.field_invalid", "enableFork must be a boolean");
|
|
599
901
|
return null;
|
|
600
902
|
}
|
|
903
|
+
// design/131/132/133 (1.127.0): fresh-submit shape validation for the three new opt-ins — a
|
|
904
|
+
// malformed value must 400 here, not silently normalize to "off" (the body.model silent-drop bug class).
|
|
905
|
+
// resolveSpec's normalizers stay defensive for the RESUME path (which re-enters without HTTP validation).
|
|
906
|
+
// NOTE: `resilience.bypassBreaker=true` from a NON-operator is a shape-valid permission downgrade (dropped
|
|
907
|
+
// by the normalizer, not a 400) — the field's authz, not its type.
|
|
601
908
|
if (body.resilience !== undefined) {
|
|
602
909
|
const r = body.resilience;
|
|
603
910
|
if (typeof r !== "object" || r === null || Array.isArray(r)) {
|
|
@@ -616,6 +923,11 @@ export function createHttpServer(rawDeps) {
|
|
|
616
923
|
sendError(res, 400, "request.field_invalid", "finalVerification must be a boolean");
|
|
617
924
|
return null;
|
|
618
925
|
}
|
|
926
|
+
// [854]④: fresh-submit shape validation for per-request 配速 —— 已知三键必须是正整数,否则 400 fail-loud
|
|
927
|
+
// (body.model silent-drop 同类教训:静默 normalize 成「没配速」正是本件要防的假成功)。0 也拒绝:core 侧
|
|
928
|
+
// `timeoutSec > 0` 才生效(0=不设墙,会绕过 TASK_TIMEOUT_MAX_SEC 封顶),maxTurns/maxOutputTokens 的 0 无意义。
|
|
929
|
+
// 枚举外键与 resilience/attachments 同口径:容忍(只校验已知键)。resolveSpec 的 normalizeLimits 对 RESUME
|
|
930
|
+
// 重放路径保持 defensive(按键 DROP,不 throw)。
|
|
619
931
|
if (body.limits !== undefined) {
|
|
620
932
|
const l = body.limits;
|
|
621
933
|
if (typeof l !== "object" || l === null || Array.isArray(l)) {
|
|
@@ -629,6 +941,9 @@ export function createHttpServer(rawDeps) {
|
|
|
629
941
|
return null;
|
|
630
942
|
}
|
|
631
943
|
}
|
|
944
|
+
// 快审 F2(1.254):deadline 族三 opt-out 在场必须 literal false——"false"/0 等畸形值此前 200 后被
|
|
945
|
+
// normalizeLimits 静默丢=调用方以为关了其实恒开(B1 同类故障)。fresh fail-loud;resume 重放不过
|
|
946
|
+
// 此门,normalizeLimits 保持 defensive。
|
|
632
947
|
for (const k of ["deadlineNudge", "callCapByDeadline", "gracefulFinalize"]) {
|
|
633
948
|
const v = body.limits[k];
|
|
634
949
|
if (v !== undefined && v !== false) {
|
|
@@ -637,6 +952,11 @@ export function createHttpServer(rawDeps) {
|
|
|
637
952
|
}
|
|
638
953
|
}
|
|
639
954
|
}
|
|
955
|
+
// [876] per-task subagents(core 1.295 TaskSpec.agents):fresh-submit 白名单门 fail-loud。与 resilience/
|
|
956
|
+
// limits 的「容忍未知键」取舍相反 —— agents 是能力定义,静默丢字段=定义被悄悄削(丢一个 denyTools/
|
|
957
|
+
// isolation 就是放宽),400 点名未知键更安全。permissionMode 有意不纳(core 拍,独立批)——出现按未知键拒。
|
|
958
|
+
// 形状细则(name 必填且数组内唯一/各字段浅形状/≤32 条)单源在 spec-fields.ts validateTaskAgents;RESUME
|
|
959
|
+
// 重放不过这道门 → resolveSpec 侧 taskAgentsFromBody defensive(非法项按条 DROP+warn,不砖 resume)。
|
|
640
960
|
if (body.agents !== undefined) {
|
|
641
961
|
const agentsErr = validateTaskAgents(body.agents);
|
|
642
962
|
if (agentsErr) {
|
|
@@ -644,14 +964,20 @@ export function createHttpServer(rawDeps) {
|
|
|
644
964
|
return null;
|
|
645
965
|
}
|
|
646
966
|
}
|
|
967
|
+
// [922]①/[915]② interactiveTools(core 1.296 三态旋钮):boolean fail-loud 同邻居姿势。per-run 工具面
|
|
968
|
+
// 选择(AskUserQuestion/present_plan 挂载判据覆写),无跨租户面 ⇒ 不设租户门;undefined=core 自动判据。
|
|
647
969
|
if (body.interactiveTools !== undefined && typeof body.interactiveTools !== "boolean") {
|
|
648
970
|
sendError(res, 400, "request.field_invalid", "interactiveTools must be a boolean");
|
|
649
971
|
return null;
|
|
650
972
|
}
|
|
973
|
+
// [854]② retainBackgroundProcesses:boolean fail-loud(邻居姿势 —— 非 boolean 静默变「不驻留」正是
|
|
974
|
+
// silent-drop 病灶);单用户闸/多租忽略在 resolveSpec(retainBackgroundProcessesFromBody)。
|
|
651
975
|
if (body.retainBackgroundProcesses !== undefined && typeof body.retainBackgroundProcesses !== "boolean") {
|
|
652
976
|
sendError(res, 400, "request.field_invalid", "retainBackgroundProcesses must be a boolean");
|
|
653
977
|
return null;
|
|
654
978
|
}
|
|
979
|
+
// [1052]② excludeTools/deferTools:数组-of-非空-string fail-loud(邻居姿势——坏形静默丢=工具面照旧,
|
|
980
|
+
// 调用方以为收窄了却没收=silent-drop 病灶同族)。per-run 收窄无跨租户面 ⇒ 无租户门(interactiveTools 同)。
|
|
655
981
|
for (const key of ["excludeTools", "deferTools"]) {
|
|
656
982
|
const v = body[key];
|
|
657
983
|
if (v !== undefined && (!Array.isArray(v) || v.some((n) => typeof n !== "string" || n.length === 0))) {
|
|
@@ -659,6 +985,7 @@ export function createHttpServer(rawDeps) {
|
|
|
659
985
|
return null;
|
|
660
986
|
}
|
|
661
987
|
}
|
|
988
|
+
// [1144]/[1146] promptProfile:枚举 fail-loud(坏值静默丢=调用方以为切了形没切,silent-drop 病灶同族)。
|
|
662
989
|
{
|
|
663
990
|
const v = body.promptProfile;
|
|
664
991
|
if (v !== undefined && v !== "simple" && v !== "classic") {
|
|
@@ -666,18 +993,30 @@ export function createHttpServer(rawDeps) {
|
|
|
666
993
|
return null;
|
|
667
994
|
}
|
|
668
995
|
}
|
|
996
|
+
// [865]② fresh-submit 未知模型 400 fail-loud(clay 生产实锤:未知 body.model 静默落 default = 用户不知情
|
|
997
|
+
// 换模型)。🔴 门目录必须与 resolveSpec 同源 = expandTiers 增广视图(codex H1:裸 config.models 会把合法
|
|
998
|
+
// 档位词 "pro"/CC alias "sonnet" 当未知 400 误杀——SDK 契约明确允许它们;增广后 matchCatalogModel 对
|
|
999
|
+
// name/tier 词/alias/id 四形一体裁决)。restart 窗口注记与 main.ts wireCatalog 同款:tiers 热刷新 vs
|
|
1000
|
+
// Runner 构造期快照,门短暂超前=fail-loud 而非静默降级,诚实。"default" 是合法目录键(hasOwn 命中)。
|
|
1001
|
+
// RESUME 重放持久化 body 不过这道门(模型事后被移出目录时砖死 resume 比降级糟)——resolveSpec 侧对未知
|
|
1002
|
+
// ref warn 降级,双层与 [854] limits 同姿势。settings.model 保持 lenient(文档化,picker 路径 =
|
|
1003
|
+
// body.model 才是本病灶面)。
|
|
669
1004
|
if (body.model !== undefined) {
|
|
670
1005
|
if (typeof body.model !== "string" || body.model.length === 0) {
|
|
671
1006
|
sendError(res, 400, "request.field_invalid", "model must be a non-empty string (a configured catalog name, tier word, or model id)");
|
|
672
1007
|
return null;
|
|
673
1008
|
}
|
|
674
|
-
const bare = deps.config.models ?? {};
|
|
1009
|
+
const bare = deps.config.models ?? {}; // 防御:极简 stub deps 可无 models 域(真 loadConfig 恒有)
|
|
675
1010
|
const catalog = expandTiers(bare, deps.config.tiers ?? {}) ?? bare;
|
|
676
1011
|
if (matchCatalogModel(body.model, catalog) === undefined) {
|
|
677
1012
|
sendError(res, 400, "request.unknown_reference", `unknown model "${body.model.slice(0, 120)}" — not in the configured catalog (name, tier word, or id)`, { available: Object.keys(catalog).filter((n) => n !== "default") });
|
|
678
1013
|
return null;
|
|
679
1014
|
}
|
|
680
1015
|
}
|
|
1016
|
+
// [1479]① compactionModel — the SAME fail-loud posture + gate catalog as body.model (an unknown cheap-gear pick
|
|
1017
|
+
// silently burning the MAIN model's price is exactly the silent-drop bug class this train exists to kill).
|
|
1018
|
+
// RESUME replays skip this gate (resolveSpec drops+warns there — core's resolveModel throws on an unknown ref,
|
|
1019
|
+
// which would brick the resume).
|
|
681
1020
|
if (body.compactionModel !== undefined) {
|
|
682
1021
|
if (typeof body.compactionModel !== "string" || body.compactionModel.length === 0) {
|
|
683
1022
|
sendError(res, 400, "request.field_invalid", "compactionModel must be a non-empty string (a configured catalog name, tier word, or model id)");
|
|
@@ -720,6 +1059,8 @@ export function createHttpServer(rawDeps) {
|
|
|
720
1059
|
}
|
|
721
1060
|
}
|
|
722
1061
|
}
|
|
1062
|
+
// 历史复审轴A #3(1.254):verify 环补 cost 顶——cascade 腿一直映 costCeilingMicroUsd,verify 腿漏了
|
|
1063
|
+
// (每轮独立 runTask,总花费此前仅受 maxRounds 约束);同源 body.maxCostUsd,同换算。
|
|
723
1064
|
const verify = body.verify === true
|
|
724
1065
|
? {
|
|
725
1066
|
maxRounds: clampVerifyRounds(body.verifyRounds),
|
|
@@ -731,6 +1072,9 @@ export function createHttpServer(rawDeps) {
|
|
|
731
1072
|
sendError(res, 400, "request.field_conflict", "verify and cascade are mutually exclusive (verify = adversarial gate; cascade = cheap→strong ladder)");
|
|
732
1073
|
return null;
|
|
733
1074
|
}
|
|
1075
|
+
// E12: suggestNextPrompts is meaningless on the verify/cascade wrappers — they return a TaskResult, not
|
|
1076
|
+
// a stream with core's post-completion suggestions() pass, so the suggestion event would never be produced. Reject
|
|
1077
|
+
// the combo fail-loud rather than silently accept-and-drop it (mirrors the resumeAt + verify/cascade reject above).
|
|
734
1078
|
if (body.suggestNextPrompts && (verify || cascade)) {
|
|
735
1079
|
sendError(res, 400, "request.field_conflict", "suggestNextPrompts is not supported with verify or cascade (those return a result, not a streamed run)");
|
|
736
1080
|
return null;
|
|
@@ -745,18 +1089,62 @@ export function createHttpServer(rawDeps) {
|
|
|
745
1089
|
}
|
|
746
1090
|
catch (err) {
|
|
747
1091
|
if (err instanceof HttpError) {
|
|
1092
|
+
// Typed rejection: echo the stable `code` + any structured extras (e.g. the scenario
|
|
1093
|
+
// allowlist) beside the human message — machine code on the wire, prose stays with the shell/web.
|
|
748
1094
|
sendError(res, err.status, httpErrorCode(err.status, err.code), err.message, { ...(err.code ? { code: err.code } : {}), ...(err.extra ?? {}) });
|
|
749
1095
|
return null;
|
|
750
1096
|
}
|
|
751
1097
|
throw err;
|
|
752
1098
|
}
|
|
753
1099
|
}
|
|
1100
|
+
// design/158 A9:`DecideBinding` 已上提到 `http/wire-types.ts` 叶子(approvals 域模块与 resume 腿同名一形),
|
|
1101
|
+
// 本处仅留指路;形状逐字未变。
|
|
1102
|
+
/**
|
|
1103
|
+
* The VERIFIED fleet scope for a resume publish (TOC review #6 parity with the create paths). Prefer the
|
|
1104
|
+
* crypto-verified `gatedPrincipal(req)` — the SAME identity GET /v1/fleet/stream filters by — on a real HTTP
|
|
1105
|
+
* door; fall back to the checkpoint's own scope (`auth.principal`) only when there is NO request (the internal
|
|
1106
|
+
* D-D SLA deny-sweep, which has no caller to verify). NEVER the spoofable `auth.principal` on a live door:
|
|
1107
|
+
* a spoofed header would re-publish the resumed row under a victim's scope or under "default" (vanishing from
|
|
1108
|
+
* its real owner's stream). Mirrors the sync/create sites' `gatedPrincipal(req, deps.config) ?? "default"`.
|
|
1109
|
+
*
|
|
1110
|
+
* ✅ FIDELITY EDGE — CLOSED by the F-fix (2026-07-01, direct-door principal spoofing). On the REQ-LESS leg (the
|
|
1111
|
+
* internal D-D SLA deny-sweep, `req===undefined`) we fall back to `auth.principal` = the checkpoint's stored scope.
|
|
1112
|
+
* Since the F-fix, createAuthorizer derives `auth.principal` (hence spec.principal, hence the checkpoint scope core
|
|
1113
|
+
* persists at suspend) from `verifiedPrincipal` — the VERIFIED JWT sub on a direct door, the SAME identity the
|
|
1114
|
+
* create path's `gatedPrincipal` tags the row with. So the earlier caveat — a misconfigured direct door where the
|
|
1115
|
+
* raw `x-agent-principal` ≠ the verified sub could surface a swept row under the wrong tenant's /v1/fleet/stream —
|
|
1116
|
+
* is GONE: the checkpoint scope is now always the verified identity, identical to what the create path tagged. The
|
|
1117
|
+
* "TRUE fix" once deferred here (persist the verified principal on the checkpoint at create) is exactly what the
|
|
1118
|
+
* F-fix did upstream at createAuthorizer; the req-less fallback and the create-path scope now agree on every posture.
|
|
1119
|
+
*/
|
|
754
1120
|
const resumeFleetScope = (req, auth) => req ? (gatedPrincipal(req, deps.config) ?? "default") : (auth.principal ?? "default");
|
|
755
|
-
|
|
1121
|
+
/**
|
|
1122
|
+
* Durable F4 resume (design/45): map a session → its pending checkpoint token INTERNALLY,
|
|
1123
|
+
* rebuild the taskConfig via the same `resolveSpec` path (from the sessionId-keyed checkpoint_ctx), then
|
|
1124
|
+
* `runner.resume`. The capability token never leaves the service. Updates the parked run row to its new state.
|
|
1125
|
+
*/
|
|
1126
|
+
async function resumeCheckpoint(sessionId, decision, reason, req, // absent on the internal D-D SLA deny-sweep (resolveSpec rebuilds auth from the checkpoint, not req)
|
|
1127
|
+
answer, binding, onResumeCommitted) {
|
|
756
1128
|
const cs = deps.checkpointStore;
|
|
1129
|
+
// No scope filter here BY DESIGN (council): an operator may view/decide ANY tenant's pending approval.
|
|
1130
|
+
// This load-before-CAS only READS; the definitive multi-tenant guard is the
|
|
1131
|
+
// `AND scope = ?` inside the atomic resolve CAS — a wrong-scope decision can never EXECUTE.
|
|
757
1132
|
const token = await cs.findPendingTokenBySession(sessionId);
|
|
1133
|
+
// D-1 approval_stale (opt-in, only when the operator echoed the checkpointToken they saw): if it no longer
|
|
1134
|
+
// matches the current pending — resolved, superseded by a re-mint, or expired — they are deciding a STALE
|
|
1135
|
+
// view. Reject distinctly from the markResuming session-CAS 409 below: the SDK keys ApprovalStaleError on
|
|
1136
|
+
// errorCode:"approval_stale" while the CAS 409 stays bare (→ ConflictError), so the two 409s never collide.
|
|
758
1137
|
if (binding?.checkpointToken && binding.checkpointToken !== token) {
|
|
759
1138
|
const seen = await cs.get(binding.checkpointToken).catch(() => null);
|
|
1139
|
+
// 🔴 `terminal` 只在该 token **确实属于本会话**时披露(2026-07-26 修)。
|
|
1140
|
+
// 此前是 `seen ? "resolved" : "expired_abort"` —— 而这次 `cs.get` 用的是**调用方给的** token 且
|
|
1141
|
+
// 存储层**没有 scope 谓词**(`WHERE token = ?`,连 status 都不过滤),这一步还跑在下面那个
|
|
1142
|
+
// `if (!token) → 404` **之前**。于是任何持凭据者都能拿一串 token 来问「它在库里吗」:
|
|
1143
|
+
// 命中 ⇒ `terminal:"resolved"`,不命中 ⇒ `terminal:"expired_abort"` —— **一位跨租户存在性 oracle**,
|
|
1144
|
+
// 与本文件自述的「no existence oracle」相反。token 本身不可猜(uuid 级),所以这不是枚举面;
|
|
1145
|
+
// 真实伤害是**泄漏出去的 token 可被免费确认有效性**(从日志/截图/工单捡到一串,不必也不能用它 resume,
|
|
1146
|
+
// 却能确认它对应一条真实 checkpoint 行、以及它是否已被解决)。
|
|
1147
|
+
// 归属不上就**省略这个键**(SDK 侧 `terminal?:` 本来可选)——不撒谎、也不泄露;真实原因照旧进日志。
|
|
760
1148
|
const attributable = seen !== null && seen.sessionId === sessionId;
|
|
761
1149
|
if (!attributable && seen !== null) {
|
|
762
1150
|
deps.logger?.warn?.("approval_stale_token_foreign", { sessionId, tokenSession: seen.sessionId ?? null });
|
|
@@ -771,21 +1159,34 @@ export function createHttpServer(rawDeps) {
|
|
|
771
1159
|
};
|
|
772
1160
|
}
|
|
773
1161
|
if (!token) {
|
|
1162
|
+
// 🔴 文案与 owner 门那条统一(2026-07-26 修)。此前两句可区分:owner 门拒 ⇒ "approval not found";
|
|
1163
|
+
// 本会话**没有** pending ⇒ 这条更详细的。而 `peekPendingScope` 在没有 pending 时返回 `null`、`null` 被当作
|
|
1164
|
+
// "开放"放过门 ⇒ 两句文案精确区分「这儿有**别人的** pending」与「这儿没有 pending」。
|
|
1165
|
+
// 后果:任何持凭据的租户可逐个 sessionId 判定「此刻有一条别人的待决审批」,拼出一张跨租户的
|
|
1166
|
+
// 「谁正卡在审批上」活动图(时序/规模情报)—— 而本文件自述承诺的正是不给这个。
|
|
1167
|
+
// 统一成同一句;**诊断不丢**,只是挪进日志(部署方有日志权限,外部调用方没有)。
|
|
774
1168
|
deps.logger?.info?.("approval_decide_no_pending", { sessionId });
|
|
775
1169
|
return { status: 404, body: { error: "approval not found", errorCode: "not_found.approval" } };
|
|
776
1170
|
}
|
|
777
|
-
|
|
1171
|
+
// get (checkpoint table) + getCtx (checkpoint_ctx table) are independent — load concurrently (council).
|
|
1172
|
+
const cpPromise = cs.get(token); // throws if the checkpoint format is newer than this build (version guard)
|
|
778
1173
|
const ctxPromise = cs.getCtx(sessionId);
|
|
779
1174
|
let cp;
|
|
780
1175
|
try {
|
|
781
1176
|
cp = await cpPromise;
|
|
782
1177
|
}
|
|
783
1178
|
catch (e) {
|
|
784
|
-
void ctxPromise.catch(() => { });
|
|
1179
|
+
void ctxPromise.catch(() => { }); // don't leak an unhandled rejection from the parallel load
|
|
785
1180
|
return { status: 409, body: { error: e instanceof Error ? e.message : String(e), errorCode: "conflict.checkpoint_unreadable" } };
|
|
786
1181
|
}
|
|
787
1182
|
if (!cp)
|
|
788
1183
|
return { status: 404, body: { error: "checkpoint not found", errorCode: "not_found.checkpoint" } };
|
|
1184
|
+
// design/153 件3d parked 赎回腿([1574]/[1584]/[1588] core 裁定;src/parked-decide.ts):该 pending
|
|
1185
|
+
// checkpoint 若属于一个 parked 后台子代,decide 必须走 claim→裸 execute(reviveClaim) 链——legacy 腿的
|
|
1186
|
+
// resumeStream 会绕开 bg registry(无 consumeParkedFlip、行永 parked、生命周期分叉),故 parked-first。
|
|
1187
|
+
// 判别 miss(undefined)= 任务级 suspend,原样落到下方 legacy 腿(零回归)。⚠️ 门在 `req !== undefined`:
|
|
1188
|
+
// 内部 D-D SLA deny-sweep(req 缺席)对 parked 子代 cp 保持现状(getCtx null → 409 跳过本 tick)——
|
|
1189
|
+
// 过期 parked 的收割属 reaper 的 expire+reconcileParkedAgents 车道,sweep 不做重活赎回。
|
|
789
1190
|
if (req !== undefined && deps.backgroundAgentStore !== undefined && deps.parkedReviveTool !== undefined) {
|
|
790
1191
|
const parked = await decideParkedAgent({
|
|
791
1192
|
agentStore: deps.backgroundAgentStore,
|
|
@@ -799,14 +1200,20 @@ export function createHttpServer(rawDeps) {
|
|
|
799
1200
|
token, scope: cp.scope, sessionId: cp.sessionId, pendingAction: cp.pendingAction,
|
|
800
1201
|
decision, ...(reason !== undefined ? { reason } : {}),
|
|
801
1202
|
...(binding !== undefined ? { binding: { ...(binding.boundCallId !== undefined ? { boundCallId: binding.boundCallId } : {}), ...(binding.boundInputHash !== undefined ? { boundInputHash: binding.boundInputHash } : {}), ...(binding.updatedInput !== undefined ? { updatedInput: binding.updatedInput } : {}) } } : {}),
|
|
1203
|
+
// remember 的 grant 闭包(onResumeCommitted 的唯一现役来源)在 parked 腿以行的 root/host
|
|
1204
|
+
// 会话为锚透传([1591] 候裁② server 修——落子代会话则探针键永不相交,见 grantOnCommit 注);
|
|
1205
|
+
// answer 显式 400(复审 F6)。
|
|
802
1206
|
...(onResumeCommitted ? { grantRemember: (rootSessionId) => onResumeCommitted(rootSessionId) } : {}),
|
|
803
1207
|
...(answer !== undefined ? { answerPresent: true } : {}),
|
|
804
1208
|
});
|
|
805
1209
|
if (parked !== undefined) {
|
|
806
|
-
void ctxPromise.catch(() => { });
|
|
1210
|
+
void ctxPromise.catch(() => { }); // 并行 ctx 读不再被消费,吞掉可能的 rejection
|
|
807
1211
|
return parked;
|
|
808
1212
|
}
|
|
809
1213
|
}
|
|
1214
|
+
// Guard the ctx await too (council): if cpPromise resolved but ctxPromise REJECTS (a TiDB blip on the
|
|
1215
|
+
// checkpoint_ctx read), an unguarded await would escape to the generic 500 — inconsistent with the 409 the
|
|
1216
|
+
// cpPromise branch returns for the same class of transient load failure.
|
|
810
1217
|
let ctx;
|
|
811
1218
|
try {
|
|
812
1219
|
ctx = await ctxPromise;
|
|
@@ -816,7 +1223,18 @@ export function createHttpServer(rawDeps) {
|
|
|
816
1223
|
}
|
|
817
1224
|
if (!ctx)
|
|
818
1225
|
return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
|
|
1226
|
+
// Rebuild taskConfig via resolveSpec with auth reconstructed from the checkpoint (sessionId + scope).
|
|
819
1227
|
const auth = { sessionId: cp.sessionId, principal: cp.scope === "_" ? undefined : cp.scope, memoryScope: ctx.memoryScope };
|
|
1228
|
+
// E18: STRIP resumeAt before re-resolving on a durable resume — the leaf is already set by the checkpoint, and
|
|
1229
|
+
// resumeAt + a durable resume is rejected by core (resume_at.conflicts_resume); also avoids re-resolving a stale
|
|
1230
|
+
// anchor (which could 404 if the session was purged). A resume-at run that suspended stored its body with resumeAt.
|
|
1231
|
+
// 对抗评审 2026-07-11(MED):resolveSpec re-runs the ADMISSION gates (scenario/execution ruling — resume does
|
|
1232
|
+
// NOT freeze governance, the established precedent posture; a deny is ALSO a model run, so it is gated too). But an
|
|
1233
|
+
// escaping HttpError here was (a) attributed to the OPERATOR's /decide request as a bare 400/403 — undiagnosable
|
|
1234
|
+
// from that contract — and (b) swallowed by the D-D SLA deny-sweep's catch, silently failing EVERY tick. Fold it
|
|
1235
|
+
// into a typed 409 result instead: the operator sees "this parked task is blocked by a policy change" (retry
|
|
1236
|
+
// after the policy is restored, or let terminal_at abort it); the sweep skips the row this tick (retried next
|
|
1237
|
+
// tick, terminal_at backstop — its documented per-call-failure semantics).
|
|
820
1238
|
let spec;
|
|
821
1239
|
try {
|
|
822
1240
|
spec = await deps.resolveSpec({ ...ctx.body, resumeAt: undefined }, req, auth);
|
|
@@ -830,14 +1248,33 @@ export function createHttpServer(rawDeps) {
|
|
|
830
1248
|
}
|
|
831
1249
|
throw e;
|
|
832
1250
|
}
|
|
1251
|
+
// Typed pre-check: the pending tool must still exist in the rebuilt config; a scenario
|
|
1252
|
+
// redeploy that removed it means the approval can no longer be applied — fail clearly, never a 500/silent.
|
|
1253
|
+
// The "hand" tools are NOT in spec.tools — core mounts them at the runner from the executionEnvFactory
|
|
1254
|
+
// (core `HAND_TOOL_EFFECTS`, not re-exported from the package root → mirrored here). When this deployment
|
|
1255
|
+
// has remote exec configured the rebuilt task WILL expose them, so union them in; else gating a hand tool
|
|
1256
|
+
// (the common code-agent case) would always 422 on resume. Keep in sync with core's HAND_TOOL_EFFECTS.
|
|
1257
|
+
// Q6 (core 1.161): canonicalize a PRE-Q6 persisted name (`bash`/`write_file`/…) to its CC-aligned new name
|
|
1258
|
+
// BEFORE matching the rebuilt (post-Q6) tool set — else an in-flight checkpoint suspended before the upgrade
|
|
1259
|
+
// would 422 here before core's own resume shim runs. New names map to themselves (idempotent).
|
|
833
1260
|
const pendingTool = cp.pendingAction.toolName;
|
|
834
1261
|
const pendingToolCanon = pendingTool ? canonicalToolName(pendingTool) : undefined;
|
|
835
1262
|
const availableTools = new Set((spec.tools ?? []).map((t) => t.name));
|
|
836
1263
|
if (deps.config.remoteExec)
|
|
837
1264
|
for (const name of HAND_TOOL_NAMES)
|
|
838
1265
|
availableTools.add(name);
|
|
1266
|
+
// AskUserQuestion (durable ask, TC-5.4) is ALSO not in spec.tools — core mounts it from `onQuestion`, which
|
|
1267
|
+
// the durable path always sets (QUESTION_AWAITS_RESUME on suspend, the answer closure on resume). Without
|
|
1268
|
+
// this it 422s on resume exactly like a hand tool would. Gated on checkpointStore = durable mode is active.
|
|
839
1269
|
if (deps.checkpointStore)
|
|
840
1270
|
availableTools.add("AskUserQuestion");
|
|
1271
|
+
// Workflow (codex round-2 on [1245]/[1248]②): `run_workflow` is ALSO mounted at the runner, never in
|
|
1272
|
+
// spec.tools — core mounts it when `spec.selfOrchestration === true` and the deployment wires the workflow
|
|
1273
|
+
// deps (isSelfOrchestrationActive, dist-read). Without this, approving a durably-parked workflow ask
|
|
1274
|
+
// (the new mode-derived leg) 422s here and the suspend can only ever be denied. Same over-approximation
|
|
1275
|
+
// posture as the hand-tools union above (a per-principal allowWorkflows=false revoked between suspend and
|
|
1276
|
+
// approve slips this pre-check; core's own resume path then fails loudly — the pre-check is a UX guard,
|
|
1277
|
+
// not the authority).
|
|
841
1278
|
if (spec.selfOrchestration === true && deps.config.selfOrchestrationEnabled) {
|
|
842
1279
|
availableTools.add("Workflow");
|
|
843
1280
|
}
|
|
@@ -847,6 +1284,14 @@ export function createHttpServer(rawDeps) {
|
|
|
847
1284
|
body: { error: `pending action no longer satisfiable: tool "${pendingToolCanon}" is not in the current task config (scenario changed since suspend)`, errorCode: "conflict.pending_action_unsatisfiable" },
|
|
848
1285
|
};
|
|
849
1286
|
}
|
|
1287
|
+
// Durable-ask request/state coherence (TC-5.4), all pre-CAS so a mismatched request never consumes the
|
|
1288
|
+
// checkpoint:
|
|
1289
|
+
// - approving a pending AskUserQuestion REQUIRES body.answer. Without it the resume re-mounts the
|
|
1290
|
+
// QUESTION_AWAITS_RESUME placeholder, the pending call executes against it and throws QuestionConfigError —
|
|
1291
|
+
// the operator's question is unrecoverably LOST (checkpoint consumed, no answer ever delivered). Fail the
|
|
1292
|
+
// request instead; the operator retries with the answer.
|
|
1293
|
+
// - an answer is only meaningful on an approve of a question gate; on a deny or a non-question gate it
|
|
1294
|
+
// signals the operator is deciding a different pending action than the one parked — reject, don't guess.
|
|
850
1295
|
if (decision === "approve" && pendingTool === "AskUserQuestion" && !answer) {
|
|
851
1296
|
return { status: 400, body: { error: "pending action is AskUserQuestion — approve must carry body.answer ({ answers: [{ header, selected: string[], note? }] })", errorCode: "request.field_conflict" } };
|
|
852
1297
|
}
|
|
@@ -854,8 +1299,18 @@ export function createHttpServer(rawDeps) {
|
|
|
854
1299
|
return { status: 400, body: { error: `body.answer is only valid when approving a pending AskUserQuestion (decision: "${decision}", pending: "${pendingTool ?? "unknown"}")`, errorCode: "request.field_conflict" } };
|
|
855
1300
|
}
|
|
856
1301
|
const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
|
|
1302
|
+
// Durable ask (TC-5.4, core 1.95): the resume re-mounts AskUserQuestion with the operator's answer
|
|
1303
|
+
// closure — the pending call executes against it and the model continues with "The user answered: …".
|
|
1304
|
+
// This OVERRIDES the suspend-side QUESTION_AWAITS_RESUME placeholder resolveSpec just rebuilt. Absent
|
|
1305
|
+
// answer ⇒ config unchanged (an F4 tool approval resumes exactly as before).
|
|
857
1306
|
if (answer)
|
|
858
1307
|
taskConfig.onQuestion = async () => answer;
|
|
1308
|
+
// D-1 decision-action binding (core 1.101, design/80 §2): echo the boundCallId + boundInputHash the operator
|
|
1309
|
+
// saw back into the outcome — core verifies opaque STRING equality against the persisted suspend-mint values
|
|
1310
|
+
// (fail-closed, PRE-CAS), so "saw action A, resolve B" is rejected. boundInputHash is NEVER recomputed here
|
|
1311
|
+
// (the engine minted it once via canonical-json; a second runtime re-serializing would false-mismatch and
|
|
1312
|
+
// fail-closed reject a legit approval). Legacy callers that omit the fields fall back to the current persisted
|
|
1313
|
+
// values (no human binding — pre-D-1 behavior). resource_limit pendings carry no action → empty (core gate_mismatch).
|
|
859
1314
|
const pa = cp.pendingAction;
|
|
860
1315
|
const persistedCallId = pa.kind === "tool_approval" ? pa.toolCallId : "";
|
|
861
1316
|
const persistedHash = pa.kind === "tool_approval" ? (pa.boundInputHash ?? "") : "";
|
|
@@ -867,16 +1322,46 @@ export function createHttpServer(rawDeps) {
|
|
|
867
1322
|
...(decision === "approve" && binding?.updatedInput !== undefined ? { updatedInput: binding.updatedInput } : {}),
|
|
868
1323
|
...(reason ? { reason } : {}),
|
|
869
1324
|
};
|
|
1325
|
+
// Developer-mode verify (1.44) survives suspend/resume (core `resumeWithVerification`, design/51 P1-b): a
|
|
1326
|
+
// task submitted with verify:true that hit an F4 gate must STILL be gated by the adversarial verifier on
|
|
1327
|
+
// completion — a plain resumeStream would silently drop the gate. The flag rides in the persisted ctx.body
|
|
1328
|
+
// (same source the spec was rebuilt from). Like first-run verify it's multi-attempt (not a live stream), so
|
|
1329
|
+
// we await the result + write a single terminal event rather than streaming post-resume deltas.
|
|
870
1330
|
const verifyRounds = ctx.body.verify === true
|
|
871
1331
|
? {
|
|
872
1332
|
maxRounds: clampVerifyRounds(ctx.body.verifyRounds),
|
|
1333
|
+
// 快审 F1:resume 腿同样带 cost 顶(persisted body 的 maxCostUsd 同源;cascade 腿同换算)
|
|
873
1334
|
...(typeof ctx.body.maxCostUsd === "number" && ctx.body.maxCostUsd > 0 ? { costCeilingMicroUsd: Math.round(ctx.body.maxCostUsd * 1e6) } : {}),
|
|
874
1335
|
}
|
|
875
1336
|
: undefined;
|
|
1337
|
+
// taskId up-front: the resumed leg streams its post-resume events into the SAME durable run log, CONTINUING
|
|
1338
|
+
// the seq past the suspend (core 1.70 `resumeStream`) — so GET /v1/runs/:id/events covers post-resume too,
|
|
1339
|
+
// observability parity with runInBackground (closed by core 1.70).
|
|
1340
|
+
// ── Drive the resume into the durable run log via the SHARED machinery (driveResumeIntoRunLog): markResuming
|
|
1341
|
+
// CAS, the cancel + preempt controllers (same-replica fast path + cross-replica flag polls), the resume
|
|
1342
|
+
// heartbeat, the resumeStream / resumeWithVerification loop, CheckpointError classification, re-suspend vs
|
|
1343
|
+
// terminal drive. This HITL /decide path supplies a `policy_ask` outcome; the scheduler preempt path supplies
|
|
1344
|
+
// a `resource_limit`/continue one — sharing this prevents the two resume entries from drifting.
|
|
876
1345
|
return driveResumeIntoRunLog({ token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds, ...(onResumeCommitted ? { onResumeCommitted } : {}) });
|
|
877
1346
|
}
|
|
1347
|
+
/**
|
|
1348
|
+
* The resume-drive machinery SHARED by `resumeCheckpoint` (operator /decide, `policy_ask`) and `resumePreempted`
|
|
1349
|
+
* (scheduler resume, `resource_limit`/continue). Given the already-located `token`, the rebuilt
|
|
1350
|
+
* `taskConfig`/`resumeObjective`, the gate-specific `outcome`, and optional `verifyRounds`, it: markResuming-
|
|
1351
|
+
* CASes the parked row to `running` (idempotent across replicas), registers the leg in BOTH `inflightRuns`
|
|
1352
|
+
* (cancel) and `preemptableRuns` (re-preempt) for the same-replica fast path, runs a heartbeat that polls the
|
|
1353
|
+
* durable cancel + preempt flags (cross-replica), resumes via resumeStream / resumeWithVerification threading a
|
|
1354
|
+
* FRESH cancel signal AND a FRESH preemptSignal (so a resumed leg is itself cancel-able + RE-preempt-able —
|
|
1355
|
+
* never the consumed suspend-time signal), classifies a CheckpointError, handles a re-suspend
|
|
1356
|
+
* (setSuspended, keep the lock) vs a terminal (setTerminal), and returns the HTTP {status, body}.
|
|
1357
|
+
*/
|
|
878
1358
|
async function driveResumeIntoRunLog(args) {
|
|
879
1359
|
const { token, sessionId, principal, fleetScope, taskConfig, resumeObjective, outcome, verifyRounds } = args;
|
|
1360
|
+
// 🔴 复审 C2:lease admission for EVERY resume family (/decide, /answer, /plan_review, preempt-resume)
|
|
1361
|
+
// funnels through here — keyed on `principal` = the CHECKPOINT OWNER, the SAME identity the resumed model spend
|
|
1362
|
+
// is billed to via `withPrincipal(principal)` below. The route guards deliberately do NOT lease-gate (they'd
|
|
1363
|
+
// key on the request principal = a possibly-cross-tenant operator). A live lease admits locally; center-judged
|
|
1364
|
+
// exhaustion of the OWNER's budget returns 429 (mirroring leaseDenied's shape); an unreachable center fails open.
|
|
880
1365
|
if (deps.fleetLease && principal) {
|
|
881
1366
|
const adm = await deps.fleetLease.admit(principal);
|
|
882
1367
|
if (!adm.ok) {
|
|
@@ -885,22 +1370,48 @@ export function createHttpServer(rawDeps) {
|
|
|
885
1370
|
}
|
|
886
1371
|
}
|
|
887
1372
|
const taskId = await deps.runStore?.getActiveTaskId(sessionId);
|
|
1373
|
+
// MF-Fleet (TOC review #7): a HITL resume (/decide, /answer, /plan_review, /preempt-resume, /wake) drives the
|
|
1374
|
+
// SAME durable run row that suspended — so it must re-publish to the fleet bus under the SAME runId, or the run
|
|
1375
|
+
// VANISHES from GET /v1/fleet/stream the moment it resumes (the original publisher's row was last touched at
|
|
1376
|
+
// suspend; nothing keeps it live or settles it after). Reuse `taskId` (the existing suspended run id) so the row
|
|
1377
|
+
// RE-APPEARS as the same fleet row; scope = `principal` (the checkpoint/run-owner scope persisted at suspend —
|
|
1378
|
+
// the original submitter, NOT spoofable; matches the run row's owner). rootTaskId = sessionId: the resume's
|
|
1379
|
+
// rebuilt taskConfig carries NO spec.taskId (resolveSpec omits it), so core's canonical taskId == sessionId,
|
|
1380
|
+
// exactly like the sync leg. No-op when no fleetBus / no taskId.
|
|
1381
|
+
// 🔒 scope = `fleetScope` (the VERIFIED `gatedPrincipal(req)` the calling route computed) — the SAME identity
|
|
1382
|
+
// GET /v1/fleet/stream filters by, mirroring the create-path #6 fix. NOT `principal` (= the spoofable
|
|
1383
|
+
// checkpoint/auth header from `principalFrom`): a spoofed header would re-publish the resumed row under a
|
|
1384
|
+
// victim's scope (cross-tenant leak) or under "default" while the real owner's stream filters it OUT (the row
|
|
1385
|
+
// vanishes on resume). Tenant-isolation parity with the stream filter on the direct resume door (TOC review #6).
|
|
1386
|
+
// onForwardEvent parity: core 1.210 added the RunInternals 4th arg to `resumeStream` — the
|
|
1387
|
+
// resume leg now feeds the forward sink like the sync/bg legs (fleet child rows at any depth + the durable
|
|
1388
|
+
// task_progress append; see the resumeStream call in driveResume).
|
|
888
1389
|
const fleetPub = taskId && deps.fleetBus
|
|
889
1390
|
? fleetRunPublisher(deps.fleetBus, { runId: taskId, scope: fleetScope, rootTaskId: sessionId, ...fleetRunLabels(resumeObjective) })
|
|
890
1391
|
: undefined;
|
|
1392
|
+
// C (TOC adversarial review): settle the resumed fleet row EXACTLY once. A plain Error thrown out of the
|
|
1393
|
+
// resume (not CheckpointError/cancel) hits the catch's `throw e` BEFORE the final onTerminal below → the
|
|
1394
|
+
// re-published row leaks as a stale "running". Route every onTerminal through this guard so the finally can
|
|
1395
|
+
// settle a row no branch reached, without double-settling one that did.
|
|
891
1396
|
let fleetSettled = false;
|
|
892
1397
|
const settleFleet = (status, residuals) => {
|
|
893
1398
|
if (fleetSettled)
|
|
894
1399
|
return;
|
|
895
1400
|
fleetSettled = true;
|
|
896
|
-
fleetPub?.onTerminal(status, residuals);
|
|
1401
|
+
fleetPub?.onTerminal(status, residuals); // [1839]①/(a):真终态帧带残局键(publisher 侧对 parked 再挡一层)
|
|
897
1402
|
};
|
|
898
1403
|
let result;
|
|
1404
|
+
// [1245] codex-5: the resume.* code ALONE does not prove the reopen landed — installed core (1.330) only
|
|
1405
|
+
// rewrites to checkpoint.reopen_failed when store.reopen returned FALSE; a THROWING reopen (plain DB error)
|
|
1406
|
+
// keeps the resume.* code with the checkpoint still resolved. Confirm against the store before re-parking:
|
|
1407
|
+
// this token pending again ⇒ real re-park; not pending ⇒ fall through to the terminal/done leg; probe error
|
|
1408
|
+
// ⇒ re-park anyway (the safer side under uncertainty: a wrongly-suspended row is reaper-bounded
|
|
1409
|
+
// (reapSuspended), a wrongly-terminal row orphans a live park until TTL — the round-3 bug shape).
|
|
899
1410
|
let reopenConfirmedPark = false;
|
|
900
1411
|
const confirmCheckpointReopened = async () => {
|
|
901
1412
|
const store = deps.checkpointStore;
|
|
902
1413
|
if (!store)
|
|
903
|
-
return true;
|
|
1414
|
+
return true; // durable resume required the store; absence here = cannot confirm → the safer side
|
|
904
1415
|
try {
|
|
905
1416
|
return (await store.findPendingTokenBySession(sessionId)) === token;
|
|
906
1417
|
}
|
|
@@ -908,22 +1419,52 @@ export function createHttpServer(rawDeps) {
|
|
|
908
1419
|
return true;
|
|
909
1420
|
}
|
|
910
1421
|
};
|
|
1422
|
+
// Protect the in-flight resume from the reaper: flip the parked row to `running` + heartbeat for the
|
|
1423
|
+
// duration of the resume. The run row otherwise stays `suspended`/updated_at=suspend-time the whole time
|
|
1424
|
+
// (resume neither rewrites the row nor heartbeats), so `reapSuspended` would mark a live resume `failed`
|
|
1425
|
+
// and release its session lock once it crossed the approval TTL. Cleared in the finally below.
|
|
911
1426
|
let resumeHeartbeat;
|
|
912
|
-
let resumeManifestLease = 0;
|
|
1427
|
+
let resumeManifestLease = 0; // [998]②: this leg's tracker lease (assigned at register, checked by the finally's clear)
|
|
1428
|
+
// 1.78 (review LOW, pre-existing): THIS leg's live TaskStream — held so the finally can identity-guard its
|
|
1429
|
+
// steerableRuns deregistration (an unconditional delete could clobber a handle a FASTER sibling leg just
|
|
1430
|
+
// registered under the same taskId across a suspend→fast-resume).
|
|
913
1431
|
let resumeStreamRef;
|
|
914
|
-
let resumeLegLive = false;
|
|
1432
|
+
let resumeLegLive = false; // true only while the resumed leg is live (set at resumeStream, cleared in the finally)
|
|
1433
|
+
// design/122 ③: this leg's subagent-handle unregister thunks — run at leg end (finally), not on settle.
|
|
915
1434
|
const subagentHandleEvictions = [];
|
|
916
1435
|
let claimedRow = false;
|
|
1436
|
+
// Cancel parity with runInBackground: the resumed leg is registered in `inflightRuns` (same-replica fast
|
|
1437
|
+
// path) and its heartbeat polls the durable cancel flag (cross-replica) — without this a resumed run was
|
|
1438
|
+
// un-cancellable (cancel 202'd + flagged the row, but nothing honored it). markResuming reset the flag,
|
|
1439
|
+
// so only a cancel issued DURING this leg aborts it.
|
|
917
1440
|
const cancelCtrl = new AbortController();
|
|
1441
|
+
// stoppedBy — resume leg NOW marked (core 1.256 delivered the mark-by-owner seam): on this
|
|
1442
|
+
// leg core's canonical taskId == sessionId (the rebuilt taskConfig carries no spec.taskId), so a
|
|
1443
|
+
// task-scoped child's owner KEY VALUE coincides with a session-scoped child's — the old `list()` path
|
|
1444
|
+
// couldn't tell them apart (no sessionScoped flag exposed) and took an honest degrade. The seam filters
|
|
1445
|
+
// by the EXPLICIT flag (`skipSessionScoped:true` inside the helper), so only task-scoped children take
|
|
1446
|
+
// the "user" marker; session-scoped ones a cancelled parent deliberately does NOT stop stay unmarked
|
|
1447
|
+
// (CC Backgrounded semantics). Owner = sessionId (this leg's canonical key), NOT the run-row taskId.
|
|
918
1448
|
markChildrenStoppedByUserOnAbort(cancelCtrl.signal, sessionId, principal);
|
|
1449
|
+
// design/80 seam #2: a resumed leg is itself preempt-able. Register a FRESH preempt controller (markResuming
|
|
1450
|
+
// also reset preempt_requested), thread a LIVE preemptSignal into the resume taskConfig below, and poll the
|
|
1451
|
+
// durable preempt flag in the heartbeat — so the scheduler can preempt the RESUMED work once it makes
|
|
1452
|
+
// progress. 🔴 Never re-supply the consumed suspend-time signal (core strips a pre-aborted one).
|
|
919
1453
|
const preemptCtrl = new AbortController();
|
|
920
1454
|
if (taskId && deps.runStore) {
|
|
1455
|
+
// The CAS fails (false) only if the row is no longer `suspended` — the reaper already expired this
|
|
1456
|
+
// approval (and released the session lock), the run is terminal, or a concurrent resume already claimed
|
|
1457
|
+
// it. Bail rather than run a resume whose run row is gone / a sibling owns; the checkpoint reaper expires
|
|
1458
|
+
// the token in the same tick so resumeStream would reject anyway. Closes the window the heartbeat can't.
|
|
921
1459
|
claimedRow = await deps.runStore.markResuming(taskId);
|
|
922
1460
|
if (!claimedRow)
|
|
923
1461
|
return { status: 409, body: { error: "run is not in a resumable (suspended) state (already resumed, decided, or expired)", errorCode: "conflict.not_resumable" } };
|
|
1462
|
+
// MF-Fleet (#7): the row is now `running` again → re-publish it LIVE to the fleet (re-appears as the same row
|
|
1463
|
+
// that was sitting "waiting" while parked). After onStart only — never if markResuming lost the CAS (a sibling
|
|
1464
|
+
// owns the row). onTerminal at every settle below keeps the row's lifecycle in lock-step with the run-store row.
|
|
924
1465
|
fleetPub?.onStart();
|
|
925
1466
|
const rs = deps.runStore;
|
|
926
|
-
const owner = principal ?? null;
|
|
1467
|
+
const owner = principal ?? null; // the resumed run's owner (the original submitter) — single-DB null-safe guard
|
|
927
1468
|
inflightRuns.set(taskId, cancelCtrl);
|
|
928
1469
|
preemptableRuns.set(taskId, preemptCtrl);
|
|
929
1470
|
resumeHeartbeat = setInterval(() => {
|
|
@@ -938,75 +1479,137 @@ export function createHttpServer(rawDeps) {
|
|
|
938
1479
|
}
|
|
939
1480
|
}, HEARTBEAT_MS);
|
|
940
1481
|
resumeHeartbeat.unref?.();
|
|
941
|
-
deps.modelUsage?.register(taskId);
|
|
942
|
-
resumeManifestLease = deps.promptManifests?.register(taskId) ?? 0;
|
|
1482
|
+
deps.modelUsage?.register(taskId); // E8 leak fence: track the resumed leg's brain.calls (sub-tasks excluded); cleared in the finally
|
|
1483
|
+
resumeManifestLease = deps.promptManifests?.register(taskId) ?? 0; // [998]②: the resumed prepare re-fires prompt.assembled — this leg drains its own manifest; lease guards the finally's clear (B-3 window)
|
|
943
1484
|
}
|
|
1485
|
+
// codex M2 hook point: the CAS (when a run row exists) is decided above — a lost race already returned 409 and
|
|
1486
|
+
// never reaches here. Awaited BEFORE the model drive so the resumed leg's first ask observes the side-effects.
|
|
944
1487
|
if (args.onResumeCommitted)
|
|
945
1488
|
await args.onResumeCommitted();
|
|
946
1489
|
try {
|
|
1490
|
+
// Wrap in withPrincipal so the resumed leg's LLM spend is attributed to the tenant's CostQuota — parity
|
|
1491
|
+
// with the sync/async paths (council: otherwise maxPrincipalCostUsd is silently bypassed on resume).
|
|
947
1492
|
result = await withPrincipal(principal, async () => {
|
|
1493
|
+
// P1 ①② follow-on: drain the async-workflow completion inbox on the RESUME leg — hoisted
|
|
1494
|
+
// ABOVE the verify/stream fork so BOTH resume flavors get it (parity with runs.ts, whose drain runs
|
|
1495
|
+
// "before ANY branch"; review round 2 caught the verify-leg gap). A workflow that finished while this run
|
|
1496
|
+
// sat suspended surfaces as a durable `workflow_complete` event the tailing client replays (at-least-once
|
|
1497
|
+
// + shell runId-dedup). Owner gate = the original submitter (the checkpoint scope) — the identity the
|
|
1498
|
+
// entries were stamped with at enqueue. 🔒 ONLY when a durable log exists (`deps.runStore && taskId`):
|
|
1499
|
+
// without one there is nowhere to persist the frame, and `drain` REMOVES entries — draining there would
|
|
1500
|
+
// EAT the completions. One-shot appender off maxSeq; each branch below re-reads maxSeq, so seq stays safe.
|
|
1501
|
+
// option A: keys this leg already delivered (turn-open drain, loop case OR observer) — hoisted ABOVE the
|
|
1502
|
+
// drain so the drained frames' keys register here too (core [784]④b: the same idle-arrival is also parked
|
|
1503
|
+
// core-side; its pend-drain replay rides the resumed stream as a task_notification event — the loop's key
|
|
1504
|
+
// check below folds that second durable append). codex F1: NotifiedKeys — the observer arm appends
|
|
1505
|
+
// fire-and-forget, so claims carry the write's outcome promise (bg-leg twin's rationale).
|
|
948
1506
|
const resumeNotifiedKeys = new NotifiedKeys();
|
|
949
1507
|
if (deps.runStore && taskId) {
|
|
950
1508
|
const rs0 = deps.runStore;
|
|
951
1509
|
let drainSeq = (await rs0.maxSeq(taskId)) + 1;
|
|
952
1510
|
await emitPendingWorkflowCompletions(deps.workflowCompletionInbox, sessionId, principal ?? null, (frame) => {
|
|
953
1511
|
const { type, ...rest } = frame;
|
|
1512
|
+
// codex F1 + 修2(三路复审 notify-1):the claim must carry the WRITE OUTCOME, not a pre-resolved
|
|
1513
|
+
// true — a transient append failure with a `Promise.resolve(true)` claim would fake-suppress the
|
|
1514
|
+
// stream/core-side replay of the SAME key while the durable row never landed (the un-acked inbox
|
|
1515
|
+
// entry re-delivers next leg, but this leg's replay was eaten). Mirror of the bg-leg/runs.ts twin.
|
|
1516
|
+
// codex F2: task_type rides the key (external 帧独立键域 — bg twin's note).
|
|
954
1517
|
const f = frame;
|
|
955
1518
|
const write = rs0.appendEvent(taskId, drainSeq++, type, rest);
|
|
956
1519
|
if (f.type === "task_notification" && f.task_id)
|
|
957
1520
|
resumeNotifiedKeys.set(taskNotificationStreamKey({ task_type: f.task_type, task_id: f.task_id, status: String(f.status), seq: f.seq }), write.then(() => true, () => false));
|
|
958
1521
|
return write;
|
|
959
|
-
},
|
|
1522
|
+
},
|
|
1523
|
+
// emit-target diagnostics — this drain appends to the resumed run's durable event log
|
|
1524
|
+
// (a tailing GET /v1/runs/:id/events client replays it; nothing is written to a live socket here).
|
|
1525
|
+
{ route: "resume-open", connection: "durable-append", log: (m, x) => deps.logger?.info?.(m, x) });
|
|
960
1526
|
}
|
|
1527
|
+
// Verify task → resume AND re-gate (core resumeWithVerification): resume the impl, and if it COMPLETES
|
|
1528
|
+
// run the identical adversarial verifier + fix loop; if it re-suspends it comes back failed-with-token
|
|
1529
|
+
// (handled like any re-suspend below). 🔴 Freshness (BUG5, design/53 §2.B): we pass NO `evidence` diff,
|
|
1530
|
+
// so the verifier judges the POST-resume working tree directly — there is no stale pre-suspend diff to
|
|
1531
|
+
// grade, which closes the "approve then plant a backdoor" window by construction.
|
|
961
1532
|
if (verifyRounds !== undefined) {
|
|
962
|
-
|
|
1533
|
+
// resumeWithVerification runs the SAME pre-CAS guard + atomic CAS as resume() internally, so a lost
|
|
1534
|
+
// CAS rejects with CheckpointError before any work — handled by the catch below, same as the stream path.
|
|
1535
|
+
const vr = await resumeWithVerification(deps.runner, token, outcome, { ...taskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, resumeObjective, verifyRounds); // 快审 F1:整对象直传
|
|
963
1536
|
let safe = stripCheckpointToken(vr);
|
|
1537
|
+
// 🔴 A re-suspend on a LATER gate is NOT terminal: core maps it to status:"failed" +
|
|
1538
|
+
// verification.unverifiedReason:"suspended" with the checkpoint token still live. Treat it exactly like
|
|
1539
|
+
// the stream path's suspended branch — write a "suspended" event and return status:"suspended" so the
|
|
1540
|
+
// outer block calls setSuspended (keeps the session lock + resumable row). Driving it terminal would
|
|
1541
|
+
// release the lock and orphan the second checkpoint (next /decide → 409, task abandoned).
|
|
964
1542
|
const reSuspended = vr.verification?.unverifiedReason === "suspended";
|
|
1543
|
+
// [1245] codex-4: reopen-class twin of the stream path (store-confirmed, codex-5) — the checkpoint
|
|
1544
|
+
// is pending again, so the durable log must record a park, never `done` (the outer row drive
|
|
1545
|
+
// re-parks via checkpointReopened; the response keeps status:"failed"+retriable, unlike reSuspended
|
|
1546
|
+
// which surfaces "suspended").
|
|
965
1547
|
const vrReopened = isCheckpointReopenedFailure(vr) && (await confirmCheckpointReopened());
|
|
966
1548
|
if (vrReopened)
|
|
967
1549
|
reopenConfirmedPark = true;
|
|
968
1550
|
if (taskId && deps.runStore) {
|
|
969
1551
|
const rs2 = deps.runStore;
|
|
1552
|
+
// codex-6/7 (vrReopened leg): EVERYTHING here is observability — best-effort once the reopen is
|
|
1553
|
+
// confirmed (maxSeq + manifest + usage + suspended event): a thrown append must not rethrow past
|
|
1554
|
+
// the authoritative row re-park below (stream twin). reSuspended-only keeps its fail-loud shape.
|
|
970
1555
|
try {
|
|
971
1556
|
let seq = (await rs2.maxSeq(taskId)) + 1;
|
|
972
1557
|
const ap = (type, data) => rs2.appendEvent(taskId, seq++, type, data);
|
|
973
|
-
await appendPromptManifest(ap, deps.promptManifests, taskId);
|
|
1558
|
+
await appendPromptManifest(ap, deps.promptManifests, taskId); // [998]②: the verified-resume leg has no stream — persist the re-fired manifest before its terminal/suspend event
|
|
974
1559
|
if (reSuspended || vrReopened) {
|
|
975
|
-
await appendModelUsageDelta(ap, deps.modelUsage, taskId);
|
|
1560
|
+
await appendModelUsageDelta(ap, deps.modelUsage, taskId); // E8: persist pre-re-suspend per-model usage
|
|
976
1561
|
await ap("suspended", { gate: vr.checkpointGate ?? null, ...(vrReopened ? { reopened: vr.errorCode ?? null } : {}) });
|
|
977
1562
|
}
|
|
978
1563
|
else {
|
|
1564
|
+
// E8: this verified-resume leg is the run's final done → flush the last delta + SUM model_usage into stats.modelUsage
|
|
979
1565
|
safe = await attachModelUsage(safe, { append: ap, getEvents: (id, a) => rs2.getEvents(id, a), modelUsage: deps.modelUsage, taskId });
|
|
980
|
-
await ap("done", { result: safe });
|
|
1566
|
+
await ap("done", { result: safe }); // verify is not a live stream → one terminal event
|
|
981
1567
|
}
|
|
982
1568
|
}
|
|
983
1569
|
catch (e) {
|
|
984
1570
|
if (!vrReopened)
|
|
985
|
-
throw e;
|
|
1571
|
+
throw e; // best-effort ONLY on the confirmed-reopen leg — see the boundary note above
|
|
986
1572
|
}
|
|
987
1573
|
}
|
|
988
1574
|
if (reSuspended)
|
|
989
1575
|
return { ...safe, status: "suspended" };
|
|
1576
|
+
// Count the gate verdict only when the verifier actually ran (a re-suspend never reached it).
|
|
990
1577
|
if (vr.verification?.verdict)
|
|
991
1578
|
deps.metrics?.inc("verifications_total", { verdict: vr.verification.verdict });
|
|
992
1579
|
return safe;
|
|
993
1580
|
}
|
|
1581
|
+
// resumeStream runs the SAME pre-CAS guard + atomic CAS as resume(), then returns a live stream; a
|
|
1582
|
+
// lost CAS rejects (CheckpointError) BEFORE any stream → never re-runs a resolved action.
|
|
1583
|
+
// E23: core's resumeStream is EAGER (runtask.js:1710 — once it resolves the resumed run is already
|
|
1584
|
+
// executing), so the per-run elicitation context MUST exist BEFORE the stream is created, or an inbound MCP
|
|
1585
|
+
// elicitation early in the resumed leg finds no ALS store and fail-closed declines. Hoist the durable-append
|
|
1586
|
+
// machinery, then create + drain the stream INSIDE runWithContext (emit = append → events tail). The owner is
|
|
1587
|
+
// the ORIGINAL submitter (`principal` = the checkpoint scope), NOT the operator who /decided — they answer.
|
|
994
1588
|
const rs = deps.runStore;
|
|
995
1589
|
let seq = rs && taskId ? await rs.maxSeq(taskId) : 0;
|
|
996
1590
|
let text = "";
|
|
997
1591
|
let reasoning = "";
|
|
998
1592
|
const persistThinking = deps.config.traceThinking;
|
|
999
1593
|
const append = (type, data) => (rs && taskId ? rs.appendEvent(taskId, ++seq, type, data) : Promise.resolve());
|
|
1594
|
+
// (resumeNotifiedKeys — this leg's delivered-notification keys — is hoisted above the inbox drain, see top)
|
|
1595
|
+
// E18: anchor POST-resume turns too (a resumed conversation's new messages are rewindable). Same capture as
|
|
1596
|
+
// runInBackground via the SHARED TurnAnchorCapture (only a settled assistant-text turn is anchored — see its
|
|
1597
|
+
// doc). owner = the original submitter (the checkpoint scope), not the operator who /decided. undefined
|
|
1598
|
+
// captureTurnAnchor ⇒ no anchor store / no getLeafId.
|
|
1000
1599
|
const anchorOwner = principal ?? null;
|
|
1001
1600
|
const getLeafId = deps.sessionStorage?.getLeafId?.bind(deps.sessionStorage);
|
|
1002
1601
|
const captureTurnAnchor = deps.resumeAnchorStore && getLeafId
|
|
1003
1602
|
? async (eventId) => { const leaf = await getLeafId(sessionId); if (leaf)
|
|
1004
1603
|
await deps.resumeAnchorStore.put(sessionId, eventId, leaf, anchorOwner); }
|
|
1005
1604
|
: undefined;
|
|
1605
|
+
// R8 (CC-parity rewind): USER-message anchor keyed by taskId (parity with the first-run path). A resume leg
|
|
1606
|
+
// rarely commits a new user message, but wiring it is harmless (only `message_committed{role:"user"}` fires it).
|
|
1006
1607
|
const captureUserMessageAnchor = deps.resumeAnchorStore && taskId
|
|
1007
1608
|
? async (entryId) => { await deps.resumeAnchorStore.put(sessionId, taskId, entryId, anchorOwner); }
|
|
1008
1609
|
: undefined;
|
|
1009
1610
|
const anchor = new TurnAnchorCapture(captureTurnAnchor, () => deps.metrics?.inc("resume_anchor_capture_failed"), captureUserMessageAnchor);
|
|
1611
|
+
// Parity with runInBackground (src/runs.ts): the resume leg must capture thinking + per-turn usage too,
|
|
1612
|
+
// else a suspend→resume loses them on post-resume turns. Reasoning flushed before content, redacted.
|
|
1010
1613
|
const flush = async () => {
|
|
1011
1614
|
if (reasoning) {
|
|
1012
1615
|
await append("reasoning", { text: redactSecrets(reasoning) });
|
|
@@ -1015,13 +1618,21 @@ export function createHttpServer(rawDeps) {
|
|
|
1015
1618
|
if (text) {
|
|
1016
1619
|
await append("text", { text, ...(anchor.firstTextEventId ? { eventId: anchor.firstTextEventId } : {}) });
|
|
1017
1620
|
text = "";
|
|
1018
|
-
}
|
|
1621
|
+
} // E18: per-message resume handle
|
|
1019
1622
|
};
|
|
1020
1623
|
const driveResume = async () => {
|
|
1624
|
+
// (the workflow-completion inbox drain for this leg is hoisted ABOVE the verify/stream fork — see top)
|
|
1625
|
+
// core 1.210: the resume leg now has the RunInternals seam — fan a subagent's task_progress
|
|
1626
|
+
// tick (child-isolated stream; the forward sink is its ONLY exit) out to the fleet AND the durable log
|
|
1627
|
+
// (shared whitelist+redact builder + E2 identity), parity with the sync/bg legs. Fire-and-forget append
|
|
1628
|
+
// (`++seq` is a sync increment — sink-vs-loop appends get unique seqs); only when a durable log exists.
|
|
1629
|
+
// false once this leg settles — notifications after that take the durable-inbox path.
|
|
1021
1630
|
resumeLegLive = true;
|
|
1022
1631
|
const stream = await deps.runner.resumeStream(token, outcome, { ...taskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, {
|
|
1023
1632
|
onForwardEvent: (e) => {
|
|
1024
1633
|
fleetPub?.onForwardEvent(e);
|
|
1634
|
+
// S2 live tail(复审 #1:forward sink 有三条腿——resume 腿上 spawn 的 bg 子代同样带
|
|
1635
|
+
// bgAgentId,漏挂=同副本开流只见 heartbeat)。publish 在 rs/taskId 门之前(tail 独立于账本)。
|
|
1025
1636
|
{
|
|
1026
1637
|
const bg = e.bgAgentId;
|
|
1027
1638
|
if (bg !== undefined && defaultSubagentTailBus.hasSubscribers(bg)) {
|
|
@@ -1037,31 +1648,47 @@ export function createHttpServer(rawDeps) {
|
|
|
1037
1648
|
void append("task_progress", taskProgressEventData(e)).catch(() => undefined);
|
|
1038
1649
|
}
|
|
1039
1650
|
else if (t === "tool_start") {
|
|
1651
|
+
// C1 (core 1.219): a delegated child's forwarded tool lifecycle — durable via the SAME shared
|
|
1652
|
+
// whitelist+redact builders as the top stream (parity with the bg leg; deltas not persisted per-chunk).
|
|
1040
1653
|
void append("tool_start", toolStartEventData(e)).catch(() => undefined);
|
|
1041
1654
|
}
|
|
1042
1655
|
else if (t === "tool_end") {
|
|
1043
1656
|
void append("tool_end", toolEndEventData(e)).catch(() => undefined);
|
|
1044
1657
|
}
|
|
1045
1658
|
},
|
|
1659
|
+
// background-completion observer — flip the child's fleet row +
|
|
1660
|
+
// park an idle-arrival in the durable inbox (workflow-type skipped). Rationale in runs.ts (bg twin).
|
|
1661
|
+
// core 1.283 `seq` (per-stop-cycle counter) rides into the dedup key + parked entry — sync twin's note.
|
|
1662
|
+
// 白名单排查(2026-07-24,「彻查 as {} 连环坑」):手写窄类型换 core 真实类型,理由同 runs.ts twin。
|
|
1046
1663
|
onTaskNotification: (n) => {
|
|
1047
1664
|
if (n.task_type === "workflow")
|
|
1048
1665
|
return;
|
|
1666
|
+
// S2 复审 #3(sync 腿同注):中途终态信号。
|
|
1049
1667
|
if (defaultSubagentTailBus.hasSubscribers(n.task_id)) {
|
|
1050
1668
|
defaultSubagentTailBus.publish(n.task_id, { type: "task_settled", taskId: n.task_id, status: n.status, ...(typeof n.seq === "number" ? { seq: n.seq } : {}), ...(n.summary ? { summary: redactSecrets(n.summary) } : {}) });
|
|
1051
1669
|
}
|
|
1670
|
+
// id-domain alias: flip by payload.sessionId (= the tick's uuid domain), fallback task_id.
|
|
1052
1671
|
const hadRow = fleetPub?.onChildTerminal(n.sessionId ?? n.task_id, n.status, n.task_id, n.toolUseId) ?? false;
|
|
1053
1672
|
const parked = !resumeLegLive && Boolean(deps.workflowCompletionInbox && sessionId);
|
|
1673
|
+
// diagnosability (rationale in runs.ts twin).
|
|
1054
1674
|
deps.logger?.info?.("task_notification_observed", { route: "resume", taskId: n.task_id, taskType: n.task_type, status: n.status, hadFleetRow: hadRow, legLive: resumeLegLive, parkedDurable: parked });
|
|
1055
1675
|
if (parked) {
|
|
1676
|
+
// 对抗评审 2026-07-11(MED):log the enqueue failure (false park✓ fingerprint otherwise; sync twin's note).
|
|
1056
1677
|
void deps.workflowCompletionInbox.enqueue(taskNotificationInboxEntry(sessionId, principal ?? null, n, Date.now(), taskId)).catch((err) => deps.logger?.warn?.("park_enqueue_failed", { route: "resume", taskId: n.task_id, err: err instanceof Error ? err.message : String(err) }));
|
|
1057
1678
|
}
|
|
1058
1679
|
else if (resumeLegLive) {
|
|
1680
|
+
// option A: teardown-time reap notification → durable display frame NOW (bg-leg twin's note).
|
|
1681
|
+
// core [784]④a: seq-folded shared key — the seq-less shape reverse-swallowed cycle 2's frame.
|
|
1682
|
+
// codex F1 (HIGH): fire-and-forget append — claim the key with its REAL outcome so a failed
|
|
1683
|
+
// write doesn't eat the stream replay of the same key (bg twin's note).
|
|
1059
1684
|
const key = taskNotificationStreamKey(n);
|
|
1060
1685
|
if (resumeNotifiedKeys.get(key) === undefined) {
|
|
1061
1686
|
resumeNotifiedKeys.set(key, append("task_notification", taskNotificationEventData({ notification: n })).then(() => true, () => false));
|
|
1062
1687
|
}
|
|
1063
1688
|
}
|
|
1064
1689
|
},
|
|
1690
|
+
// C2 (core 1.219) + design/122 (core 1.225 ③): the resumed leg re-registers sync-delegation steer
|
|
1691
|
+
// handles under the SAME taskId. NOT evicted on settle (resume legality) — evicted at leg end.
|
|
1065
1692
|
...(deps.subagentSteerRegistry && taskId
|
|
1066
1693
|
? {
|
|
1067
1694
|
onSubagentSpawn: (handle) => {
|
|
@@ -1072,17 +1699,17 @@ export function createHttpServer(rawDeps) {
|
|
|
1072
1699
|
});
|
|
1073
1700
|
resumeStreamRef = stream;
|
|
1074
1701
|
if (taskId)
|
|
1075
|
-
steerableRuns.set(taskId, stream);
|
|
1702
|
+
steerableRuns.set(taskId, stream); // the resumed leg is in-flight again → live-steerable (parity with the first-run plain-stream path; taskId-guarded like the finally)
|
|
1076
1703
|
if (!taskId || !rs)
|
|
1077
|
-
return await stream.result();
|
|
1704
|
+
return await stream.result(); // no durable log to persist into → just drain
|
|
1078
1705
|
for await (const ev of stream) {
|
|
1079
|
-
fleetPub?.onEvent(ev);
|
|
1080
|
-
await appendPromptManifest(append, deps.promptManifests, taskId);
|
|
1706
|
+
fleetPub?.onEvent(ev); // MF-Fleet (#7): turn_end → tokens, tool_start → live activity, in-stream task_progress → child row (parity with the sync leg's onEvent)
|
|
1707
|
+
await appendPromptManifest(append, deps.promptManifests, taskId); // [998]②: the resumed prepare's manifest lands ahead of its first event (runs.ts twin)
|
|
1081
1708
|
switch (ev.type) {
|
|
1082
1709
|
case "text_delta":
|
|
1083
1710
|
text += ev.delta;
|
|
1084
1711
|
anchor.onText(ev.eventId);
|
|
1085
|
-
break;
|
|
1712
|
+
break; // E18: latch first-text eventId = message handle
|
|
1086
1713
|
case "reasoning_delta":
|
|
1087
1714
|
if (persistThinking)
|
|
1088
1715
|
reasoning += ev.delta;
|
|
@@ -1091,7 +1718,7 @@ export function createHttpServer(rawDeps) {
|
|
|
1091
1718
|
anchor.onTool();
|
|
1092
1719
|
await flush();
|
|
1093
1720
|
await append("tool_start", toolStartEventData(ev));
|
|
1094
|
-
break;
|
|
1721
|
+
break; // E18: tool ran → not anchorable
|
|
1095
1722
|
case "tool_end":
|
|
1096
1723
|
await flush();
|
|
1097
1724
|
await append("tool_end", toolEndEventData(ev));
|
|
@@ -1102,57 +1729,65 @@ export function createHttpServer(rawDeps) {
|
|
|
1102
1729
|
await flush();
|
|
1103
1730
|
await append("status", brainStatusEventData(st));
|
|
1104
1731
|
break;
|
|
1105
|
-
}
|
|
1732
|
+
} // S4 P0-a: durable brain-liveness observation (rationale in runs.ts twin — flush first so a mid-text reconnect doesn't invert the timeline); design/158 B2: row type aligned to the live frame name ("status", was "brain_status" — ledger-sink.ts twin's note has the full rationale + compat contract); 白名单排查 2026-07-24: 手写字面量换 Parameters<> 防未来漂移
|
|
1106
1733
|
case "context_usage":
|
|
1107
1734
|
await flush();
|
|
1108
1735
|
await append("context_usage", contextUsageEventData(ev));
|
|
1109
|
-
break;
|
|
1736
|
+
break; /* core 1.414 一等分支;两条口径见 builder 旁注 */
|
|
1110
1737
|
case "compacted":
|
|
1111
1738
|
await flush();
|
|
1112
1739
|
await append("compacted", compactedEventData(ev));
|
|
1113
|
-
break;
|
|
1740
|
+
break; // MF-18 trigger + core 1.219 attachedFiles/preserved_segment (shared whitelist builder)
|
|
1114
1741
|
case "diagnostics":
|
|
1115
1742
|
await flush();
|
|
1116
1743
|
await append("diagnostics", diagnosticsEventData(ev));
|
|
1117
|
-
break;
|
|
1744
|
+
break; // core 1.220 design/121: NEW LSP diagnostics (shared whitelist builder, §E1 redact)
|
|
1118
1745
|
case "steering_injected":
|
|
1119
1746
|
await flush();
|
|
1120
1747
|
await append("steering_injected", steeringInjectedEventData(ev));
|
|
1121
|
-
break;
|
|
1748
|
+
break; // [1550]/[1552]: shared whitelist builder, §E1 redact — resume twin of the sync + ledger-sink legs
|
|
1122
1749
|
case "workspace_changed":
|
|
1123
1750
|
await flush();
|
|
1124
1751
|
await append("workspace_changed", workspaceChangedEventData(ev));
|
|
1125
|
-
break;
|
|
1752
|
+
break; // [1559]二: cwd move (bash cd/EnterWorktree) — shared whitelist builder, §E1 redact — resume twin of the sync + ledger-sink legs
|
|
1126
1753
|
case "message_committed":
|
|
1127
1754
|
await anchor.onMessageCommitted(ev.role, ev.entryId);
|
|
1128
|
-
break;
|
|
1755
|
+
break; // R8: USER-message rewind anchor (CC "rewind to the prompt")
|
|
1129
1756
|
case "task_progress":
|
|
1130
1757
|
await append("task_progress", taskProgressEventData(ev));
|
|
1131
|
-
break;
|
|
1758
|
+
break; // shared whitelist+redact builder (+E2 identity, 1.78) — parity with the runs.ts + sync legs
|
|
1132
1759
|
case "task_notification": {
|
|
1133
1760
|
await flush();
|
|
1761
|
+
// core [784]④a+④b: seq-folded shared key + skip keys this leg already delivered (turn-open inbox
|
|
1762
|
+
// drain or observer arm) — core's pend-drain replays a double-parked idle arrival as a stream event
|
|
1763
|
+
// on the resumed leg; appending it again doubles the frame in the durable events tail (sync twin).
|
|
1764
|
+
// codex F1: await the claiming write's outcome — a failed observer claim must not eat this
|
|
1765
|
+
// replay (write anyway + promote the key with THIS write's outcome; bg twin's note).
|
|
1134
1766
|
const tnr = ev.notification;
|
|
1135
1767
|
const tnrKey = tnr?.task_id ? taskNotificationStreamKey({ task_type: tnr.task_type, task_id: tnr.task_id, status: String(tnr.status), seq: tnr.seq }) : undefined;
|
|
1136
1768
|
const tnrPrior = tnrKey !== undefined ? resumeNotifiedKeys.get(tnrKey) : undefined;
|
|
1137
1769
|
if (tnrPrior === undefined || !(await tnrPrior)) {
|
|
1138
|
-
const write = append("task_notification", taskNotificationEventData(ev));
|
|
1770
|
+
const write = append("task_notification", taskNotificationEventData(ev)); // core 1.202 design/115 P2: display projection (shared whitelist+redact builder — parity with the runs.ts + sync legs)
|
|
1139
1771
|
if (tnrKey !== undefined)
|
|
1140
|
-
resumeNotifiedKeys.set(tnrKey, write.then(() => true, () => false));
|
|
1772
|
+
resumeNotifiedKeys.set(tnrKey, write.then(() => true, () => false)); // stream delivered this key
|
|
1141
1773
|
await write;
|
|
1142
1774
|
}
|
|
1143
1775
|
break;
|
|
1144
1776
|
}
|
|
1145
1777
|
case "turn_end": {
|
|
1146
1778
|
await flush();
|
|
1147
|
-
await append("turn_end", turnEndEventData(ev));
|
|
1779
|
+
await append("turn_end", turnEndEventData(ev)); /* 白名单 builder:此前手写 {usage},剥掉了 stopReason/usageMissing */
|
|
1148
1780
|
await appendModelUsageDelta(append, deps.modelUsage, taskId);
|
|
1149
1781
|
await anchor.onTurnEnd();
|
|
1150
1782
|
break;
|
|
1151
|
-
}
|
|
1783
|
+
} // E18: anchor a settled assistant-text turn only
|
|
1784
|
+
// "done" handled below via result() + the run-row drive (terminal event written once)
|
|
1152
1785
|
}
|
|
1153
1786
|
}
|
|
1154
1787
|
await flush();
|
|
1155
1788
|
const r = await stream.result();
|
|
1789
|
+
// Close the durable stream with the matching terminal event (mirrors runInBackground). Never the
|
|
1790
|
+
// capability token — only the non-secret gate on a re-suspend.
|
|
1156
1791
|
if (r.status === "suspended") {
|
|
1157
1792
|
await append("suspended", { gate: r.checkpointGate ?? null });
|
|
1158
1793
|
await appendModelUsageDelta(append, deps.modelUsage, taskId);
|
|
@@ -1162,19 +1797,34 @@ export function createHttpServer(rawDeps) {
|
|
|
1162
1797
|
await append("needs_review", { gate: r.checkpointGate ?? null });
|
|
1163
1798
|
await appendModelUsageDelta(append, deps.modelUsage, taskId);
|
|
1164
1799
|
return r;
|
|
1165
|
-
}
|
|
1800
|
+
} // D-B: a resumed plan_review re-gated to another review pause
|
|
1801
|
+
// [1245] codex-4: a CONFIRMED reopen-class failure (core reopened the checkpoint — store-verified,
|
|
1802
|
+
// codex-5) is NOT terminal: logging `done` here would make the append-only log say terminal while
|
|
1803
|
+
// the park stays decidable (trace projection + events tailers treat `done` as final and would miss
|
|
1804
|
+
// the retry). Log the park, keep E8's terminal usage-SUM reserved for genuinely terminal results
|
|
1805
|
+
// (`checkpoint.reopen_failed` and an unconfirmed reopen stay on the done leg).
|
|
1166
1806
|
if (isCheckpointReopenedFailure(r) && (await confirmCheckpointReopened())) {
|
|
1167
1807
|
reopenConfirmedPark = true;
|
|
1808
|
+
// codex-6: best-effort — the ROW re-park below is the authority once the reopen is confirmed. A
|
|
1809
|
+
// thrown append here would otherwise rethrow past the row drive and strand a `running` row (no
|
|
1810
|
+
// heartbeat) over a pending park until reapStale; a missing suspended event is merely a log gap
|
|
1811
|
+
// (nothing terminal was written — tailers keep waiting), strictly recoverable.
|
|
1168
1812
|
try {
|
|
1169
1813
|
await append("suspended", { gate: null, reopened: r.errorCode ?? null });
|
|
1170
1814
|
await appendModelUsageDelta(append, deps.modelUsage, taskId);
|
|
1171
1815
|
}
|
|
1172
1816
|
catch {
|
|
1817
|
+
/* row drive is authoritative */
|
|
1173
1818
|
}
|
|
1174
1819
|
return r;
|
|
1175
1820
|
}
|
|
1176
|
-
|
|
1821
|
+
// E8: terminal — this resumed leg is the run's final done, so flush the last delta + SUM model_usage across
|
|
1822
|
+
// ALL legs (pre-suspend + post-resume) into stats.modelUsage. setTerminal (below, ~2619) stores this result.
|
|
1823
|
+
const safe = await attachModelUsage(stripCheckpointToken(r), { append, getEvents: (id, a) => rs.getEvents(id, a), modelUsage: deps.modelUsage, taskId }); // structural fence (strip no-op today)
|
|
1177
1824
|
await append("done", { result: safe });
|
|
1825
|
+
// E12 (shell-host contract): a resumed leg that COMPLETES can also emit post-completion suggestions (parity with the
|
|
1826
|
+
// first-run path + the completed-only gate). suspended/needs_review returned above; a failed leg is
|
|
1827
|
+
// skipped here. Redact + persist as a `suggestions` event (UNTRUSTED UI-only, never re-fed to a model).
|
|
1178
1828
|
if (taskConfig.suggestNextPrompts && safe.status === "completed") {
|
|
1179
1829
|
const suggestions = await stream.suggestions().catch(() => []);
|
|
1180
1830
|
if (suggestions.length)
|
|
@@ -1189,21 +1839,32 @@ export function createHttpServer(rawDeps) {
|
|
|
1189
1839
|
}
|
|
1190
1840
|
catch (e) {
|
|
1191
1841
|
if (e instanceof CheckpointError) {
|
|
1842
|
+
// We flipped the row to `running` (claimedRow). Two CheckpointError classes diverge here (D-1, core 1.101):
|
|
1843
|
+
// • TERMINAL (already_resolved / not_found / gate_mismatch / unsupported_version): the checkpoint is
|
|
1844
|
+
// consumed or gone — drive the row terminal now (releasing task_active) instead of leaving a zombie
|
|
1845
|
+
// `running` row for reapStale to mislabel and hold the session lock for ~runStaleSec. (Original case.)
|
|
1846
|
+
// • RETRIABLE PRE-CAS (invalid_outcome = binding mismatch / reopen_revote = env_failed must replay the
|
|
1847
|
+
// persisted winner / reopened_concurrently = a concurrent resolve-reopen advanced the rev): the
|
|
1848
|
+
// checkpoint STAYS pending and the operator re-fetches + re-/decides. Driving the row terminal would
|
|
1849
|
+
// release task_active and ORPHAN the still-pending checkpoint (next /decide → 404). Re-park it
|
|
1850
|
+
// `suspended` so the corrected retry can claim it again.
|
|
1192
1851
|
const retriable = e.code === "checkpoint.invalid_outcome" ||
|
|
1193
1852
|
e.code === "checkpoint.reopen_revote" ||
|
|
1194
1853
|
e.code === "checkpoint.reopened_concurrently" ||
|
|
1195
|
-
e.code === "resume.parent_constraint_missing" ||
|
|
1196
|
-
e.code === "resume.parent_constraint_mismatch" ||
|
|
1197
|
-
e.code.startsWith("wake.");
|
|
1854
|
+
e.code === "resume.parent_constraint_missing" || // 历史复审轴A #4(1.254):core 新增两码同为
|
|
1855
|
+
e.code === "resume.parent_constraint_mismatch" || // PRE-CAS(checkpoint 保持 pending)——按本块 doctrine 归 retriable;当前 /decide 面只 resume 顶层 checkpoint 应不可达,前瞻补齐防嵌套 resume 面开放时踩中
|
|
1856
|
+
e.code.startsWith("wake."); // design/144:wake 拒绝(gate_pending/nothing_to_deliver)全是 PRE-CAS,checkpoint 保持 pending——驱 terminal 会孤儿化仍在等的 park
|
|
1198
1857
|
if (claimedRow && taskId && deps.runStore) {
|
|
1858
|
+
// Re-park on the SAME gate family the outcome targeted: a plan_review retry must re-park `needs_review`
|
|
1859
|
+
// (keep the row's review status + the lock), not `suspended` — else GET /v1/assistant/tasks mislabels it.
|
|
1199
1860
|
if (retriable) {
|
|
1200
1861
|
await (outcome.gate === "plan_review" ? deps.runStore.setNeedsReview(taskId) : deps.runStore.setSuspended(taskId)).catch(() => undefined);
|
|
1201
1862
|
settleFleet(outcome.gate === "plan_review" ? "needs_review" : "suspended");
|
|
1202
|
-
}
|
|
1863
|
+
} // MF-Fleet (#7): re-park keeps the row
|
|
1203
1864
|
else {
|
|
1204
1865
|
await deps.runStore.setTerminal(taskId, "failed", null, e.message).catch(() => undefined);
|
|
1205
1866
|
settleFleet("failed");
|
|
1206
|
-
}
|
|
1867
|
+
} // MF-Fleet (#7): terminal removes the row
|
|
1207
1868
|
}
|
|
1208
1869
|
const status = e.code === "checkpoint.not_found" ? 404 : 409;
|
|
1209
1870
|
return {
|
|
@@ -1211,6 +1872,12 @@ export function createHttpServer(rawDeps) {
|
|
|
1211
1872
|
body: {
|
|
1212
1873
|
error: e.message,
|
|
1213
1874
|
code: e.code,
|
|
1875
|
+
// invalid_outcome means DIFFERENT things per gate: for policy_ask it IS the D-1 action-binding
|
|
1876
|
+
// mismatch; for plan_review/dry_run_review it is a content-sanitize reject (editedPlan/reason carried a
|
|
1877
|
+
// forbidden tag) — give those a gate-appropriate code so the UI routes to the right recovery, not the
|
|
1878
|
+
// action-binding flow (adversarial-review medium finding).
|
|
1879
|
+
// 归一(clay 大改版窗 2026-07-28):errorCode **恒在**——改铸臂保持优先,其余=原码镜像
|
|
1880
|
+
// (「code 单轨」历史形从此退役;SDK 的 ?? b.code 兜底只为老 server 保留)。
|
|
1214
1881
|
errorCode: e.code === "checkpoint.invalid_outcome"
|
|
1215
1882
|
? (outcome.gate === "policy_ask" ? "approval_binding_mismatch" : "resume_outcome_invalid")
|
|
1216
1883
|
: e.code,
|
|
@@ -1218,55 +1885,93 @@ export function createHttpServer(rawDeps) {
|
|
|
1218
1885
|
},
|
|
1219
1886
|
};
|
|
1220
1887
|
}
|
|
1888
|
+
// Cancelled mid-resume (the abort threw out of the resumed stream): settle the row to the cancelled
|
|
1889
|
+
// terminal (parity with runInBackground) instead of bubbling a 500 that leaves a zombie `running` row
|
|
1890
|
+
// for the reaper. The operator's /decide gets the run's final state, not an opaque error. (A preempt mid-
|
|
1891
|
+
// resume is NOT an abort — it durably suspends and surfaces above as status:"suspended", not here.)
|
|
1221
1892
|
if (cancelCtrl.signal.aborted) {
|
|
1222
1893
|
if (taskId && deps.runStore) {
|
|
1223
1894
|
const c = { taskId, sessionId, status: "failed", errorCode: "cancelled", errorMessage: "cancelled by user", stats: { turns: 0, tokens: 0 } };
|
|
1224
1895
|
await deps.runStore.setTerminal(taskId, "failed", c, c.errorMessage ?? null).catch(() => undefined);
|
|
1225
1896
|
}
|
|
1226
|
-
settleFleet("failed");
|
|
1897
|
+
settleFleet("failed"); // MF-Fleet (#7): a cancel mid-resume settles the row terminal → removed from the fleet
|
|
1227
1898
|
return { status: 200, body: { taskId, sessionId, status: "failed", errorCode: "cancelled" } };
|
|
1228
1899
|
}
|
|
1900
|
+
// C: a plain Error (not CheckpointError, not a cancel) is about to propagate before the post-catch
|
|
1901
|
+
// `settleFleet(result.status)` can run → settle the re-published row to "failed" first so it doesn't leak
|
|
1902
|
+
// as a stale "running" (no-op if a CheckpointError/cancel branch above already settled it).
|
|
1229
1903
|
settleFleet("failed");
|
|
1230
1904
|
throw e;
|
|
1231
1905
|
}
|
|
1232
1906
|
finally {
|
|
1233
1907
|
if (taskId) {
|
|
1908
|
+
// [1.207 codex H3] identity-guarded like steerableRuns below — a retriable-CheckpointError re-park
|
|
1909
|
+
// (setSuspended) opens a window where a fast sibling resume markResuming's + registers its OWN
|
|
1910
|
+
// controllers under the same taskId before this finally runs; an unconditional delete killed the
|
|
1911
|
+
// fresh handles (cancel/preempt would 409 "another replica" until re-registration).
|
|
1234
1912
|
if (inflightRuns.get(taskId) === cancelCtrl)
|
|
1235
1913
|
inflightRuns.delete(taskId);
|
|
1236
1914
|
if (preemptableRuns.get(taskId) === preemptCtrl)
|
|
1237
1915
|
preemptableRuns.delete(taskId);
|
|
1916
|
+
// Identity-guarded (1.78 review LOW): only delete the handle THIS leg registered (and only if it is
|
|
1917
|
+
// still ours) — a faster sibling resume may have re-registered the same taskId already; never registered
|
|
1918
|
+
// ⇒ nothing of ours to delete.
|
|
1238
1919
|
if (resumeStreamRef !== undefined && steerableRuns.get(taskId) === resumeStreamRef)
|
|
1239
1920
|
steerableRuns.delete(taskId);
|
|
1240
1921
|
for (const evict of subagentHandleEvictions)
|
|
1241
|
-
evict();
|
|
1242
|
-
deps.modelUsage?.clear(taskId);
|
|
1243
|
-
deps.promptManifests?.clear(taskId, resumeManifestLease);
|
|
1922
|
+
evict(); // design/122 ③: leg-end handle eviction (identity-guarded)
|
|
1923
|
+
deps.modelUsage?.clear(taskId); // E8: parity with runInBackground's finally — unregister + drop any un-drained accumulation (no leak / no cross-resume contamination)
|
|
1924
|
+
deps.promptManifests?.clear(taskId, resumeManifestLease); // [998]②: lease-guarded (a fast sibling's fresh registration survives our teardown)
|
|
1244
1925
|
}
|
|
1245
1926
|
if (resumeHeartbeat)
|
|
1246
1927
|
clearInterval(resumeHeartbeat);
|
|
1247
|
-
resumeLegLive = false;
|
|
1928
|
+
resumeLegLive = false; // notifications from here on take the durable-inbox path
|
|
1248
1929
|
}
|
|
1930
|
+
// A session-CAS conflict can surface as a failed result on resume too — evict the stale warm-cache entry so
|
|
1931
|
+
// the next task on this session re-wakes from TiDB instead of re-conflicting (parity with runInBackground /
|
|
1932
|
+
// sync POST / cascade; the only terminal-result path that was missing this — council).
|
|
1249
1933
|
evictIfConflict(deps.runner, sessionId, result);
|
|
1250
|
-
|
|
1934
|
+
// [1245] codex-3: the two REOPEN-class resume failures (`resume.env_failed` / `resume.tool_unavailable` —
|
|
1935
|
+
// core dist-read: runtask reopens the checkpoint via store.reopen BEFORE returning this failed result; a
|
|
1936
|
+
// reopen that itself fails surfaces as `checkpoint.reopen_failed`, NOT these codes) leave the checkpoint
|
|
1937
|
+
// PENDING. Driving the row terminal here would release task_active and orphan the still-decidable park
|
|
1938
|
+
// (the exact class the CheckpointError `retriable` branch above re-parks) — e.g. Workflow entitlement
|
|
1939
|
+
// revoked between suspend and approve. Re-park `suspended` so the operator's retry can claim it again.
|
|
1940
|
+
const checkpointReopened = reopenConfirmedPark; // codex-5: store-confirmed inside the resume paths — never inferred from the code alone
|
|
1941
|
+
// Drive the parked run row to its new state (terminal, or re-suspended on a further gate). Strip the token
|
|
1942
|
+
// from the persisted result — structural fence (no-op today; GET /v1/runs/:id reads this row back).
|
|
1251
1943
|
if (taskId && deps.runStore) {
|
|
1252
1944
|
if (result.status === "suspended" || checkpointReopened)
|
|
1253
1945
|
await deps.runStore.setSuspended(taskId);
|
|
1254
1946
|
else if (result.status === "needs_review")
|
|
1255
|
-
await deps.runStore.setNeedsReview(taskId);
|
|
1947
|
+
await deps.runStore.setNeedsReview(taskId); // D-B: a resumed leg re-gated to a review pause — re-park, keep the lock
|
|
1256
1948
|
else
|
|
1257
1949
|
await deps.runStore.setTerminal(taskId, result.status, stripCheckpointToken(result), result.errorMessage ?? null);
|
|
1258
1950
|
}
|
|
1259
|
-
|
|
1951
|
+
// MF-Fleet (#7): settle the fleet row in lock-step with the run row — suspended/needs_review KEEP the row (it stays
|
|
1952
|
+
// "waiting"/"awaiting approval"); a terminal status REMOVES it (onTerminal does this mapping). Mirrors the sync leg.
|
|
1953
|
+
settleFleet(checkpointReopened ? "suspended" : result.status, fleetRunResiduals(result)); // [1839]①/(a):终态残局键(parked 时 publisher 不上帧)
|
|
1954
|
+
// Surface the failure code/message to the deciding operator (never the token): a resumed leg that fails
|
|
1955
|
+
// otherwise returns an opaque {status:"failed"} — undiagnosable without a run row (leader sub-workers).
|
|
1260
1956
|
return {
|
|
1261
1957
|
status: 200,
|
|
1262
1958
|
body: {
|
|
1263
1959
|
taskId, sessionId, status: result.status,
|
|
1264
1960
|
...(result.errorCode ? { errorCode: result.errorCode } : {}),
|
|
1265
1961
|
...(result.errorMessage ? { errorMessage: redactSecrets(result.errorMessage) } : {}),
|
|
1266
|
-
...(checkpointReopened ? { retriable: true } : {}),
|
|
1962
|
+
...(checkpointReopened ? { retriable: true } : {}), // the park is still pending — re-fetch + re-decide
|
|
1267
1963
|
},
|
|
1268
1964
|
};
|
|
1269
1965
|
}
|
|
1966
|
+
/**
|
|
1967
|
+
* design/80 seam #2 (scheduler resume): resume a task that durably SUSPENDED on a `resource_limit` gate — a
|
|
1968
|
+
* scheduler PREEMPT (reason "preempt") or a budget/turns/walltime slice boundary (design/74). Distinct from the
|
|
1969
|
+
* operator `/decide` path (`resumeCheckpoint`, `policy_ask`): there is NO human decision, NO binding, NO answer
|
|
1970
|
+
* — just `{gate:"resource_limit", decision:"continue"}` (core computes the next slice's allowance from the
|
|
1971
|
+
* ledger). 🔴 GATE-KIND GUARD: it REFUSES to resume a `policy_ask`/human gate via this path (that would bypass
|
|
1972
|
+
* the human approval) — a non-resource gate returns 409 and must go through `/decide`. Owner-scoped by the
|
|
1973
|
+
* caller; `scope` is the checkpoint's own (multi-tenant). Returns the HTTP {status, body}.
|
|
1974
|
+
*/
|
|
1270
1975
|
async function resumePreempted(sessionId, req) {
|
|
1271
1976
|
const cs = deps.checkpointStore;
|
|
1272
1977
|
const token = await cs.findPendingTokenBySession(sessionId);
|
|
@@ -1274,13 +1979,18 @@ export function createHttpServer(rawDeps) {
|
|
|
1274
1979
|
return { status: 404, body: { error: "no resumable suspension for this task (already running, resumed, or expired)", errorCode: "not_found.suspension" } };
|
|
1275
1980
|
let cp;
|
|
1276
1981
|
try {
|
|
1277
|
-
cp = await cs.get(token);
|
|
1982
|
+
cp = await cs.get(token); // throws if the checkpoint format is newer than this build (version guard)
|
|
1278
1983
|
}
|
|
1279
1984
|
catch (e) {
|
|
1280
1985
|
return { status: 409, body: { error: e instanceof Error ? e.message : String(e), errorCode: "conflict.checkpoint_unreadable" } };
|
|
1281
1986
|
}
|
|
1282
1987
|
if (!cp)
|
|
1283
1988
|
return { status: 404, body: { error: "checkpoint not found", errorCode: "not_found.checkpoint" } };
|
|
1989
|
+
// 🔴 GATE-KIND GUARD (security): this "continue" path must ONLY advance a resource_limit suspend. A
|
|
1990
|
+
// policy_ask / human / irreversible_ask gate requires a HUMAN decision via POST /v1/approvals/:id/decide —
|
|
1991
|
+
// resuming it here with decision:"continue" would BYPASS the approval. Reject it distinctly (core's resume
|
|
1992
|
+
// gate-match is the fail-closed backstop, but we reject pre-CAS for a clear error + to never consume the
|
|
1993
|
+
// checkpoint). dry_run_review / plan_review likewise are not "continue"-resumable here.
|
|
1284
1994
|
const gateKind = cp.gate?.kind;
|
|
1285
1995
|
if (gateKind !== "resource_limit") {
|
|
1286
1996
|
return { status: 409, body: { error: `task is suspended on a '${gateKind ?? "unknown"}' gate, not a resumable resource/preempt suspension — an approval gate must be decided via POST /v1/approvals/:id/decide`, errorCode: "gate_not_resumable" } };
|
|
@@ -1288,27 +1998,38 @@ export function createHttpServer(rawDeps) {
|
|
|
1288
1998
|
const ctx = await cs.getCtx(sessionId).catch(() => null);
|
|
1289
1999
|
if (!ctx)
|
|
1290
2000
|
return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
|
|
2001
|
+
// Rebuild taskConfig via resolveSpec with auth reconstructed from the checkpoint scope (mirrors resumeCheckpoint).
|
|
1291
2002
|
const auth = { sessionId: cp.sessionId, principal: cp.scope === "_" ? undefined : cp.scope, memoryScope: ctx.memoryScope };
|
|
2003
|
+
// E18: STRIP resumeAt before re-resolving on a durable resume — the leaf is already set by the checkpoint, and
|
|
2004
|
+
// resumeAt + a durable resume is rejected by core (resume_at.conflicts_resume); also avoids re-resolving a stale
|
|
2005
|
+
// anchor (which could 404 if the session was purged). A resume-at run that suspended stored its body with resumeAt.
|
|
1292
2006
|
const spec = await deps.resolveSpec({ ...ctx.body, resumeAt: undefined }, req, auth);
|
|
1293
2007
|
const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
|
|
2008
|
+
// A resource-suspended task that was submitted with verify:true is re-gated on completion (parity with the
|
|
2009
|
+
// HITL path — the flag rides the persisted ctx.body).
|
|
1294
2010
|
const verifyRounds = ctx.body.verify === true
|
|
1295
2011
|
? {
|
|
1296
2012
|
maxRounds: clampVerifyRounds(ctx.body.verifyRounds),
|
|
2013
|
+
// 快审 F1:resume 腿同样带 cost 顶(persisted body 的 maxCostUsd 同源;cascade 腿同换算)
|
|
1297
2014
|
...(typeof ctx.body.maxCostUsd === "number" && ctx.body.maxCostUsd > 0 ? { costCeilingMicroUsd: Math.round(ctx.body.maxCostUsd * 1e6) } : {}),
|
|
1298
2015
|
}
|
|
1299
2016
|
: undefined;
|
|
1300
2017
|
return driveResumeIntoRunLog({ token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome: { gate: "resource_limit", decision: "continue" }, verifyRounds });
|
|
1301
2018
|
}
|
|
2019
|
+
/** design/144 §3:wake 一个 task_done 纯 park——非门决策,解除 park 消费消息续跑。owner 门:非 operator
|
|
2020
|
+
* 只能 wake 自己 scope 的 park(404 无 oracle);core 端 gate_pending/nothing_to_deliver 双拒兜底。 */
|
|
1302
2021
|
async function resumeWake(sessionId, message, caller, req) {
|
|
1303
2022
|
const cs = deps.checkpointStore;
|
|
1304
2023
|
const token = await cs.findPendingTokenBySession(sessionId);
|
|
1305
2024
|
if (!token)
|
|
1306
2025
|
return { status: 404, body: { error: "no parked checkpoint for this session (nothing to wake)", errorCode: "not_found.parked_checkpoint" } };
|
|
2026
|
+
// owner 门先于 get(复审 finding:get 对损坏/新版行会抛,若 gate 在后,非 owner 能用 409 错误详情
|
|
2027
|
+
// 区分「存在但坏」vs「不存在」= 存在性 oracle)。scope 走 peekPendingScope(轻读,不碰行体)。
|
|
1307
2028
|
const operator = explicitOperatorOk(caller, deps.config.operatorPrincipals);
|
|
1308
2029
|
if (!operator) {
|
|
1309
2030
|
const scope = await cs.peekPendingScope(sessionId).catch(() => undefined);
|
|
1310
2031
|
if (scope !== null && scope !== undefined && scope !== "_" && scope !== caller) {
|
|
1311
|
-
return { status: 404, body: { error: "no parked checkpoint for this session (nothing to wake)", errorCode: "not_found.parked_checkpoint" } };
|
|
2032
|
+
return { status: 404, body: { error: "no parked checkpoint for this session (nothing to wake)", errorCode: "not_found.parked_checkpoint" } }; // 无 oracle
|
|
1312
2033
|
}
|
|
1313
2034
|
}
|
|
1314
2035
|
let cp;
|
|
@@ -1321,13 +2042,14 @@ export function createHttpServer(rawDeps) {
|
|
|
1321
2042
|
if (!cp)
|
|
1322
2043
|
return { status: 404, body: { error: "checkpoint not found", errorCode: "not_found.checkpoint" } };
|
|
1323
2044
|
if (!operator && cp.scope !== "_" && cp.scope !== caller) {
|
|
1324
|
-
return { status: 404, body: { error: "no parked checkpoint for this session (nothing to wake)", errorCode: "not_found.parked_checkpoint" } };
|
|
2045
|
+
return { status: 404, body: { error: "no parked checkpoint for this session (nothing to wake)", errorCode: "not_found.parked_checkpoint" } }; // 纵深(peek 缺席面兜底)
|
|
1325
2046
|
}
|
|
2047
|
+
// 预检(core 兜底同码):非纯 park = 有未决门,wake 永不绕审批。
|
|
1326
2048
|
const gateKind = cp.gate?.kind;
|
|
1327
2049
|
if (gateKind !== "task_done") {
|
|
1328
2050
|
return { status: 409, body: { error: `cannot wake: this checkpoint awaits a pending gate decision (gate "${gateKind ?? "unknown"}") — resolve it through its own decide entry`, errorCode: "wake.gate_pending" } };
|
|
1329
2051
|
}
|
|
1330
|
-
const trusted = operator;
|
|
2052
|
+
const trusted = operator; // steer 同款:operator 消息可携 <system-reminder> 权威
|
|
1331
2053
|
if (message !== undefined) {
|
|
1332
2054
|
try {
|
|
1333
2055
|
validatePendingSteer({ text: message, trusted });
|
|
@@ -1353,9 +2075,18 @@ export function createHttpServer(rawDeps) {
|
|
|
1353
2075
|
taskConfig,
|
|
1354
2076
|
resumeObjective,
|
|
1355
2077
|
outcome: { gate: "wake", ...(message !== undefined ? { message: { text: message, trusted } } : {}) },
|
|
1356
|
-
verifyRounds: undefined,
|
|
2078
|
+
verifyRounds: undefined, // wake 续跑不重开 verify 门(原任务已终局;新工作要 verify 走新 run)
|
|
1357
2079
|
});
|
|
1358
2080
|
}
|
|
2081
|
+
/**
|
|
2082
|
+
* design/80 D-B: resolve a PRE-ACTION `plan_review` gate (3-state approve | edit | reject) and drive the resumed
|
|
2083
|
+
* leg into the durable run log via the SHARED machinery. DISTINCT from `resumeCheckpoint` (a `policy_ask` tool
|
|
2084
|
+
* approval, allow/deny + binding) and `resumePreempted` (`resource_limit` continue). The `edit` decision carries
|
|
2085
|
+
* the operator's REVISED plan (core threads `editedPlan` into the resumed run). 🔴 GATE-KIND GUARD (security):
|
|
2086
|
+
* refuses any non-`plan_review` gate — an approve/edit/reject must NEVER resolve a tool-approval / resource_limit
|
|
2087
|
+
* suspend (that would bypass the correct /decide allow-deny + action binding). Rejects PRE-CAS so a wrong-gate
|
|
2088
|
+
* call never consumes the checkpoint (core's resume gate-match is the fail-closed backstop).
|
|
2089
|
+
*/
|
|
1359
2090
|
async function resumePlanReview(sessionId, decision, editedPlan, reason, req) {
|
|
1360
2091
|
const cs = deps.checkpointStore;
|
|
1361
2092
|
const token = await cs.findPendingTokenBySession(sessionId);
|
|
@@ -1363,13 +2094,16 @@ export function createHttpServer(rawDeps) {
|
|
|
1363
2094
|
return { status: 404, body: { error: "no pending plan_review for this session (already decided or expired)", errorCode: "not_found.plan_review" } };
|
|
1364
2095
|
let cp;
|
|
1365
2096
|
try {
|
|
1366
|
-
cp = await cs.get(token);
|
|
2097
|
+
cp = await cs.get(token); // throws if the checkpoint format is newer than this build (version guard)
|
|
1367
2098
|
}
|
|
1368
2099
|
catch (e) {
|
|
1369
2100
|
return { status: 409, body: { error: e instanceof Error ? e.message : String(e), errorCode: "conflict.checkpoint_unreadable" } };
|
|
1370
2101
|
}
|
|
1371
2102
|
if (!cp)
|
|
1372
2103
|
return { status: 404, body: { error: "checkpoint not found", errorCode: "not_found.checkpoint" } };
|
|
2104
|
+
// 🔴 GATE-KIND GUARD: a plan_review decision must ONLY resolve a plan_review gate. A human/irreversible_ask
|
|
2105
|
+
// (tool approval) or resource_limit/needs_review gate routed here would let approve/edit/reject bypass the
|
|
2106
|
+
// correct wire (a tool approval needs /decide's allow/deny + action binding; resource_limit needs /resume).
|
|
1373
2107
|
const gateKind = cp.gate?.kind;
|
|
1374
2108
|
if (gateKind !== "plan_review") {
|
|
1375
2109
|
return {
|
|
@@ -1383,12 +2117,19 @@ export function createHttpServer(rawDeps) {
|
|
|
1383
2117
|
const ctx = await cs.getCtx(sessionId).catch(() => null);
|
|
1384
2118
|
if (!ctx)
|
|
1385
2119
|
return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
|
|
2120
|
+
// Rebuild taskConfig via resolveSpec with auth reconstructed from the checkpoint scope (mirrors resumeCheckpoint).
|
|
1386
2121
|
const auth = { sessionId: cp.sessionId, principal: cp.scope === "_" ? undefined : cp.scope, memoryScope: ctx.memoryScope };
|
|
2122
|
+
// E18: STRIP resumeAt before re-resolving on a durable resume — the leaf is already set by the checkpoint, and
|
|
2123
|
+
// resumeAt + a durable resume is rejected by core (resume_at.conflicts_resume); also avoids re-resolving a stale
|
|
2124
|
+
// anchor (which could 404 if the session was purged). A resume-at run that suspended stored its body with resumeAt.
|
|
1387
2125
|
const spec = await deps.resolveSpec({ ...ctx.body, resumeAt: undefined }, req, auth);
|
|
1388
2126
|
const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
|
|
2127
|
+
// A plan_review-suspended task submitted with verify:true is re-gated on completion (parity with the other
|
|
2128
|
+
// resume paths — the flag rides the persisted ctx.body).
|
|
1389
2129
|
const verifyRounds = ctx.body.verify === true
|
|
1390
2130
|
? {
|
|
1391
2131
|
maxRounds: clampVerifyRounds(ctx.body.verifyRounds),
|
|
2132
|
+
// 快审 F1:resume 腿同样带 cost 顶(persisted body 的 maxCostUsd 同源;cascade 腿同换算)
|
|
1392
2133
|
...(typeof ctx.body.maxCostUsd === "number" && ctx.body.maxCostUsd > 0 ? { costCeilingMicroUsd: Math.round(ctx.body.maxCostUsd * 1e6) } : {}),
|
|
1393
2134
|
}
|
|
1394
2135
|
: undefined;
|
|
@@ -1406,26 +2147,39 @@ export function createHttpServer(rawDeps) {
|
|
|
1406
2147
|
deps.metrics?.inc("task_tokens_total", {}, result.stats.tokens);
|
|
1407
2148
|
if (result.stats.cacheHitRate !== undefined)
|
|
1408
2149
|
deps.metrics?.observe("task_cache_hit_rate", result.stats.cacheHitRate);
|
|
2150
|
+
// ⑤ budget/limit cutoffs (1.37): count failures by their dotted code (budget.precall/exceeded,
|
|
2151
|
+
// limit.timeout/max_turns) so an operator can alert on tenants hitting the cost/token ceiling.
|
|
1409
2152
|
const code = result.errorCode;
|
|
1410
2153
|
if (code && (code.startsWith("budget.") || code.startsWith("limit."))) {
|
|
1411
2154
|
deps.metrics?.inc("budget_exceeded_total", { code });
|
|
1412
2155
|
}
|
|
2156
|
+
// Developer-mode verification gate (1.44): count the final verdict when this task was verified.
|
|
1413
2157
|
const verdict = result.verification?.verdict;
|
|
1414
2158
|
if (verdict)
|
|
1415
2159
|
deps.metrics?.inc("verifications_total", { verdict });
|
|
2160
|
+
// Cascade (1.45): count the outcome (passed / exhausted) when this task ran the ladder.
|
|
1416
2161
|
const cascadeOutcome = result.cascadeOutcome;
|
|
1417
2162
|
if (cascadeOutcome)
|
|
1418
2163
|
deps.metrics?.inc("cascade_total", { outcome: cascadeOutcome });
|
|
2164
|
+
// Degenerate-repetition salvage classifier (1.59): a/b/unknown — see degenerate-instrument.ts.
|
|
1419
2165
|
deps.instrumentDegenerate?.(result);
|
|
1420
2166
|
}
|
|
2167
|
+
/** The terminal-result side-effect trio (metrics, plan-cache recurrence probe, warm-cache eviction) in ONE
|
|
2168
|
+
* place so a terminal path can't silently omit one (council DESIGN-6 — the omissions caused tracking gaps). */
|
|
1421
2169
|
function finalizeTaskResult(result, principal, objective, sessionId) {
|
|
1422
2170
|
recordTaskResult(result);
|
|
1423
2171
|
deps.planCacheProbe?.record(principal, objective, result.status === "completed");
|
|
1424
2172
|
evictIfConflict(deps.runner, sessionId, result);
|
|
1425
2173
|
}
|
|
2174
|
+
/** Per-principal cumulative cost quota: 429 + retry-after when the principal is over for the window.
|
|
2175
|
+
* No principal → not gated (the rate limiter + REQUIRE_PRINCIPAL still apply). */
|
|
1426
2176
|
function quotaExceeded(req, res) {
|
|
1427
2177
|
if (!deps.costQuota)
|
|
1428
2178
|
return false;
|
|
2179
|
+
// 🔴 F-fix: the VERIFIED principal (gatedPrincipal), never the spoofable header. Spend ACCRUES to the verified
|
|
2180
|
+
// principal (budget.ts `costQuota.add(currentPrincipal())` = auth.principal), so checking a header-keyed bucket
|
|
2181
|
+
// while the verified bucket fills would let a direct-door caller dodge its quota by rotating x-agent-principal.
|
|
2182
|
+
// The sibling rateLimited keys on the socket on a direct door for the same reason — this was its overlooked twin.
|
|
1429
2183
|
const principal = gatedPrincipal(req, deps.config);
|
|
1430
2184
|
if (!principal)
|
|
1431
2185
|
return false;
|
|
@@ -1437,6 +2191,12 @@ export function createHttpServer(rawDeps) {
|
|
|
1437
2191
|
sendError(res, 429, "limit.cost_quota_exceeded", "cost quota exceeded", { usedMicroUsd: d.usedMicroUsd, limitMicroUsd: d.limitMicroUsd, retryAfterSec: d.retryAfterSec });
|
|
1438
2192
|
return true;
|
|
1439
2193
|
}
|
|
2194
|
+
/** lease admission(D4 AP)— the quotaExceeded sibling for lease-enforced principals: first
|
|
2195
|
+
* touch negotiates with center (single-flight; 409 not-lease-enforced → negative-cached pass-through to
|
|
2196
|
+
* the local CostQuota path), a live lease admits locally, center-judged exhaustion denies 429+Retry-After
|
|
2197
|
+
* (same shape as the cost-quota deny), and an unreachable center FAILS OPEN (AP posture — reconciliation
|
|
2198
|
+
* is the backstop). Keys on the VERIFIED principal only (gatedPrincipal — the quotaExceeded F-fix twin);
|
|
2199
|
+
* no principal (single-user turnkey) → no lease surface. */
|
|
1440
2200
|
async function leaseDenied(req, res) {
|
|
1441
2201
|
if (!deps.fleetLease)
|
|
1442
2202
|
return false;
|
|
@@ -1448,12 +2208,18 @@ export function createHttpServer(rawDeps) {
|
|
|
1448
2208
|
return false;
|
|
1449
2209
|
deps.metrics?.inc("fleet_lease_rejected_total");
|
|
1450
2210
|
res.setHeader("retry-after", String(adm.retryAfterSec));
|
|
2211
|
+
// E4 定稿:429 带 typed code + center 结构化字段 verbatim 透传(windowType/remaining/resetAt/
|
|
2212
|
+
// isEstimate/pool)——人话文案归壳/web(契约如此),error 串保留旧消费者兼容。
|
|
1451
2213
|
sendError(res, 429, "quota_exhausted", "budget exhausted (quota lease)", { code: "quota_exhausted", retryAfterSec: adm.retryAfterSec, ...(adm.detail ?? {}) });
|
|
1452
2214
|
return true;
|
|
1453
2215
|
}
|
|
2216
|
+
/** Apply the per-principal rate limit; responds 429 and returns true when the caller is over. */
|
|
1454
2217
|
function rateLimited(req, res) {
|
|
1455
2218
|
if (!deps.rateLimiter)
|
|
1456
2219
|
return false;
|
|
2220
|
+
// 🔴 D-G (DeepSeek council Bug#2): on a direct-mode worker x-agent-principal is UNTRUSTED, so keying the limiter
|
|
2221
|
+
// on it lets a client rotate the header to evade its own bucket OR set a victim's principal to poison theirs
|
|
2222
|
+
// (DoS). Key on the socket address instead (the verified principal isn't known until post-body JWT verify).
|
|
1457
2223
|
const key = (deps.config.directDoorActive ? undefined : principalFrom(req, deps.config)) ?? req.socket.remoteAddress ?? "anon";
|
|
1458
2224
|
const d = deps.rateLimiter.check(key);
|
|
1459
2225
|
if (d.allowed)
|
|
@@ -1463,6 +2229,14 @@ export function createHttpServer(rawDeps) {
|
|
|
1463
2229
|
sendError(res, 429, "limit.rate_exceeded", "rate limit exceeded", { retryAfterSec: d.retryAfterSec });
|
|
1464
2230
|
return true;
|
|
1465
2231
|
}
|
|
2232
|
+
/**
|
|
2233
|
+
* §0.5 session-scoped owner scope — the SAME model as GET /v1/sessions, reused by the fork/delete routes so
|
|
2234
|
+
* all three agree on "who may touch this session". A system/traceToken caller is FLEET-WIDE (ops, any session);
|
|
2235
|
+
* a principal caller is PINNED to `gateOwner` (its own). Identity is `gatedPrincipal` (direct-door safe), never
|
|
2236
|
+
* the spoofable header. Caller checks: `!fleetWide && gateOwner===null` ⇒ 401; otherwise compare the session's
|
|
2237
|
+
* stored owner to `gateOwner` (principal path) or allow (fleetWide).
|
|
2238
|
+
*/
|
|
2239
|
+
/** Decode a URL path segment, returning null on a malformed %-sequence (a client error → 400, never a 500). */
|
|
1466
2240
|
function safeDecode(seg) {
|
|
1467
2241
|
try {
|
|
1468
2242
|
return decodeURIComponent(seg);
|
|
@@ -1471,6 +2245,11 @@ export function createHttpServer(rawDeps) {
|
|
|
1471
2245
|
return null;
|
|
1472
2246
|
}
|
|
1473
2247
|
}
|
|
2248
|
+
// 🔒 Single source for the fleet-wide (all-tenant) gate. dev-open (no service/trace token ⇒ fleet-wide) is honored
|
|
2249
|
+
// ONLY on a single-user deployment. On MULTI-TENANT (requirePrincipal=true), the ABSENCE of a token must NOT open the
|
|
2250
|
+
// fleet — that would leak every tenant's sessions/runs/fleet rows to ANY caller (HIGH tenant-isolation gap, systematic
|
|
2251
|
+
// audit 2026-06-30). Multi-tenant without a token ⇒ NOT fleet-wide ⇒ owner-gated by principal (and main.ts boot-guards
|
|
2252
|
+
// it to fail FAST). A system authToken or the read-only traceToken still grant fleet-wide on any deployment.
|
|
1474
2253
|
function isFleetWide(req) {
|
|
1475
2254
|
const { authToken: at, traceToken: tt } = deps.config;
|
|
1476
2255
|
const anyAt = Boolean(at) || Object.keys(deps.config.authTokens ?? {}).length > 0;
|
|
@@ -1481,6 +2260,11 @@ export function createHttpServer(rawDeps) {
|
|
|
1481
2260
|
const gateOwner = fleetWide ? null : (gatedPrincipal(req, deps.config) || null);
|
|
1482
2261
|
return { fleetWide, gateOwner };
|
|
1483
2262
|
}
|
|
2263
|
+
/** 🔴 WRITE-grade fleet-wide (2026-07-13): the READ-ONLY traceToken must never authorize a
|
|
2264
|
+
* cross-tenant WRITE (delete/fork/sync import) — isFleetWide deliberately grants it fleet-wide for the trace/
|
|
2265
|
+
* list READ surfaces, but consuming that same bit on destructive routes turned a read credential into write
|
|
2266
|
+
* authority. Write routes use THIS gate: dev-open (single-user, no tokens) and the SYSTEM authToken keep their
|
|
2267
|
+
* authority; a traceToken-only caller falls through to the principal owner gate (or 401). */
|
|
1484
2268
|
function sessionOwnerScopeForWrite(req) {
|
|
1485
2269
|
const { authToken: at, traceToken: tt } = deps.config;
|
|
1486
2270
|
const anyAt = Boolean(at) || Object.keys(deps.config.authTokens ?? {}).length > 0;
|
|
@@ -1488,18 +2272,36 @@ export function createHttpServer(rawDeps) {
|
|
|
1488
2272
|
const gateOwner = fleetWide ? null : (gatedPrincipal(req, deps.config) || null);
|
|
1489
2273
|
return { fleetWide, gateOwner };
|
|
1490
2274
|
}
|
|
2275
|
+
/** Reject when the caller's principal does not own the run (null owner = open, dev). */
|
|
1491
2276
|
function runOwnerOk(req, res, owner) {
|
|
2277
|
+
// 🔴 F-fix: the VERIFIED principal (gatedPrincipal), never the spoofable header — `owner` was persisted from the
|
|
2278
|
+
// verified principal at create, so comparing it to a spoofable header would let a direct-door caller read/cancel a
|
|
2279
|
+
// victim's run (and its event stream) by sending x-agent-principal:<victim> with NO JWT. Same model as
|
|
2280
|
+
// sessionOwnerScope. (BFF/gated: gatedPrincipal === principalFrom, byte-identical.)
|
|
1492
2281
|
const principal = gatedPrincipal(req, deps.config);
|
|
1493
2282
|
if (deps.config.requirePrincipal && !principal) {
|
|
1494
2283
|
sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
|
|
1495
2284
|
return false;
|
|
1496
2285
|
}
|
|
1497
2286
|
if (owner !== null && principal !== owner) {
|
|
1498
|
-
sendError(res, 404, "not_found.run", "run not found");
|
|
2287
|
+
sendError(res, 404, "not_found.run", "run not found"); // don't reveal existence to non-owners
|
|
1499
2288
|
return false;
|
|
1500
2289
|
}
|
|
1501
2290
|
return true;
|
|
1502
2291
|
}
|
|
2292
|
+
/** [1499] G — session-scoping ACCEPT phase on the run faces ([1498]⑥ soft rollout, 第一拍). principal answers
|
|
2293
|
+
* "whose data" (authz axis); session answers "which conversation" (content axis, [1493]). These faces deliver
|
|
2294
|
+
* conversation content (poll/events) or act on a conversation (cancel/steer/compact/detach, subagent
|
|
2295
|
+
* steer/resume) but were principal-gated only. Enforcement (absent ⇒ 404, subagentOutput-style) needs every
|
|
2296
|
+
* caller migrated first, so THIS phase 404s ONLY a PRESENT-but-MISMATCHED `?session=`: an honest caller
|
|
2297
|
+
* asserting its own session never mismatches on its own run — a mismatch IS the accidental cross-session
|
|
2298
|
+
* touch. Absent ⇒ allowed + warn-once per face (migration observability for the enforcement train). NB the
|
|
2299
|
+
* operator branch below is only REACHABLE where the preceding owner gate admits the caller: on faces whose
|
|
2300
|
+
* owner gate carries its own trusted arm (steer/compact/detach/subagent verbs) it is fully live; on the
|
|
2301
|
+
* runOwnerOk faces (poll/events/cancel — no operator arm, [1498]⑥ ledger) a cross-owner operator 404s at
|
|
2302
|
+
* the owner gate first, so here it covers only owner-null runs or an operator who IS the owner. Widening
|
|
2303
|
+
* those owner gates is a deliberate [1498]⑥ decision, not this phase's. A session-unbound run has nothing
|
|
2304
|
+
* to assert against. */
|
|
1503
2305
|
const sessionAssertAbsentWarned = new Set();
|
|
1504
2306
|
function runSessionAcceptOk(req, res, run, face, notFoundError = "run not found") {
|
|
1505
2307
|
if (!run.sessionId)
|
|
@@ -1516,7 +2318,7 @@ export function createHttpServer(rawDeps) {
|
|
|
1516
2318
|
return true;
|
|
1517
2319
|
if (explicitOperatorOk(gatedPrincipal(req, deps.config), deps.config.operatorPrincipals))
|
|
1518
2320
|
return true;
|
|
1519
|
-
sendError(res, 404, "not_found.run", notFoundError);
|
|
2321
|
+
sendError(res, 404, "not_found.run", notFoundError); // byte-identical to the face's own owner-gate wording (no distinguishable arm)
|
|
1520
2322
|
return false;
|
|
1521
2323
|
}
|
|
1522
2324
|
async function readJson(req) {
|
|
@@ -1534,9 +2336,13 @@ export function createHttpServer(rawDeps) {
|
|
|
1534
2336
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
1535
2337
|
}
|
|
1536
2338
|
catch {
|
|
2339
|
+
// F1 (center K3S report): a malformed body is a client error → 400, not an uncaught 500.
|
|
1537
2340
|
throw new HttpError(400, "invalid JSON body");
|
|
1538
2341
|
}
|
|
1539
2342
|
}
|
|
2343
|
+
/** 2c session-sync: read a RAW request body (binary blob bytes) with a running per-request size cap — the bytes are
|
|
2344
|
+
* NEVER buffered unboundedly (the §5 "reject, not OOM" guard). Throws HttpError(413) the instant `max` is exceeded
|
|
2345
|
+
* (mid-stream, before the whole body is in memory). Returns the concatenated Buffer (≤ max). */
|
|
1540
2346
|
async function readRawBody(req, max) {
|
|
1541
2347
|
const chunks = [];
|
|
1542
2348
|
let size = 0;
|
|
@@ -1548,11 +2354,25 @@ export function createHttpServer(rawDeps) {
|
|
|
1548
2354
|
}
|
|
1549
2355
|
return Buffer.concat(chunks);
|
|
1550
2356
|
}
|
|
2357
|
+
/**
|
|
2358
|
+
* design/80 D-D (SLA-timer deny-sweep): resolve-DENY every pending human/irreversible_ask approval past its
|
|
2359
|
+
* deadline (the per-approval SLA) — re-resume each with decision=deny so the model CONTINUES with the denial
|
|
2360
|
+
* (graceful; vs the abort the reaper's expire() gives resource_limit/needs_review). Reuses resumeCheckpoint,
|
|
2361
|
+
* so the markResuming CAS makes it idempotent across replicas (one replica wins each resume) and the parked
|
|
2362
|
+
* run row is driven correctly. A per-call failure (e.g. a redeployed scenario) is swallowed → the row stays
|
|
2363
|
+
* pending and is retried next tick, with the terminal_at backstop as the eventual abort if the deny never
|
|
2364
|
+
* succeeds. Bounded per tick by the store query's LIMIT. Wired into main.ts's reaper.
|
|
2365
|
+
*/
|
|
1551
2366
|
async function denyExpiredApprovals(now) {
|
|
1552
2367
|
const cs = deps.checkpointStore;
|
|
1553
2368
|
if (!cs)
|
|
1554
2369
|
return;
|
|
1555
2370
|
const expired = await cs.listExpiredApprovalGates(now).catch(() => []);
|
|
2371
|
+
// [1591] 候裁③ 静默臂:parked 后台子代的过期 cp 不走 sweep 的重活赎回(deadline→terminal_at 窗内
|
|
2372
|
+
// 每行每 tick 一条 warn 的噪声源)——其收割属 reaper 的 expire+reconcileParkedAgents 车道。判别在
|
|
2373
|
+
// resume 尝试之前做(省掉注定 409 的整条 ctx/resolveSpec 重建尝试;代价=每过期行两次店读,过期集
|
|
2374
|
+
// 本就被店查询 LIMIT 界住);命中聚合为单条 info 留痕。判别自身故障 ⇒ 按非 parked 处理(保留 warn,
|
|
2375
|
+
// fail-loud 方向)。
|
|
1556
2376
|
const isParkedOwned = async (sessionId) => {
|
|
1557
2377
|
if (deps.backgroundAgentStore === undefined)
|
|
1558
2378
|
return false;
|
|
@@ -1571,6 +2391,9 @@ export function createHttpServer(rawDeps) {
|
|
|
1571
2391
|
};
|
|
1572
2392
|
let parkedSkipped = 0;
|
|
1573
2393
|
for (const { sessionId } of expired) {
|
|
2394
|
+
// 对抗评审 2026-07-11: log the swallow — a per-tick failure retried forever (e.g. a policy change now folded
|
|
2395
|
+
// into a 409 by resumeCheckpoint, or a redeployed scenario) was fully silent; the row sat pending to its
|
|
2396
|
+
// terminal_at with zero operator-visible signal. Behavior unchanged (skip + retry next tick), now diagnosable.
|
|
1574
2397
|
if (await isParkedOwned(sessionId)) {
|
|
1575
2398
|
parkedSkipped += 1;
|
|
1576
2399
|
continue;
|
|
@@ -1581,19 +2404,36 @@ export function createHttpServer(rawDeps) {
|
|
|
1581
2404
|
if (parkedSkipped > 0)
|
|
1582
2405
|
deps.logger?.info?.("deny_sweep_parked_skipped", { count: parkedSkipped });
|
|
1583
2406
|
}
|
|
2407
|
+
// Augment the http.Server with the D-D deny-sweep handle (back-compat: all 12 callers keep using the return
|
|
2408
|
+
// value AS an http.Server — listen/close/etc. — while main.ts's reaper reads server.denyExpiredApprovals).
|
|
1584
2409
|
return Object.assign(server, { denyExpiredApprovals });
|
|
1585
2410
|
}
|
|
2411
|
+
/** SSE: replay durable events after Last-Event-ID, then tail until the run is terminal (or stale). */
|
|
2412
|
+
/**
|
|
2413
|
+
* The differences a concrete log (task_run | image_bake) feeds the ONE resumable SSE reader (P2.8). Everything
|
|
2414
|
+
* the reader does — Last-Event-ID/`?from=` resume, the 416 retention boundary, the per-poll concurrent
|
|
2415
|
+
* status+events read, the terminal re-fetch (the terminal event lands in the gap before the status flips), the
|
|
2416
|
+
* stale fallback, the 15-min cap, the 15s idle heartbeat — is provider-agnostic and lives in `streamSseLog`.
|
|
2417
|
+
* 🔴 The task_run provider MUST keep the existing wire bytes EXACTLY (center's relay + 730+ tests depend on it).
|
|
2418
|
+
*/
|
|
2419
|
+
/** POST endpoints that trigger BILLABLE work — the fail-closed auth guard must cover ALL of them (council: the
|
|
2420
|
+
* guard's inline list had drifted from the handlers and missed `/v1/approvals/:id/decide`, which resumes a run
|
|
2421
|
+
* via resumeCheckpoint/store.decide → paid tokens). Keep this in sync when adding a billable POST route. */
|
|
1586
2422
|
function isBillableSubmitPath(url) {
|
|
1587
|
-
return (url === "/v1/side-query" ||
|
|
2423
|
+
return (url === "/v1/side-query" || // [1469] one-shot brain call — runs the model, so it rides every billable-submit gate
|
|
1588
2424
|
url === "/v1/tasks" ||
|
|
1589
2425
|
url === "/v1/tasks/stream" ||
|
|
1590
2426
|
url === "/v1/runs" ||
|
|
1591
2427
|
url === "/v1/leader" ||
|
|
1592
|
-
ASSISTANT_RESUME_RE.test(url) ||
|
|
1593
|
-
ASSISTANT_PLAN_REVIEW_RE.test(url) ||
|
|
1594
|
-
RUN_SUBAGENT_RESUME_RE.test(url) ||
|
|
2428
|
+
ASSISTANT_RESUME_RE.test(url) || // resource_limit/preempt resume runs the model → BILLABLE (preempt is not)
|
|
2429
|
+
ASSISTANT_PLAN_REVIEW_RE.test(url) || // plan_review approve/edit resumes + runs the model → BILLABLE
|
|
2430
|
+
RUN_SUBAGENT_RESUME_RE.test(url) || // 对抗复查 B-2(HIGH):subagent revive (handle.resume) STARTS model work → BILLABLE
|
|
1595
2431
|
url.startsWith("/v1/approvals"));
|
|
1596
2432
|
}
|
|
2433
|
+
// ⚠️ SSE 帧纪律(2026-07-12):center BFF 中继凭「data 无 type 字段」识别心跳帧并吞掉
|
|
2434
|
+
// (AgentEvent 是 closed oneof 全带 type,故可控)。**未来新增任何 SSE data 帧必须带 type 字段**,否则会被
|
|
2435
|
+
// 中继当心跳吞掉、永远到不了前端。heartbeat 帧(`event: heartbeat` + `data: {}`)是唯一豁免。
|
|
2436
|
+
/** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`). */
|
|
1597
2437
|
function routeLabel(_method, url) {
|
|
1598
2438
|
if (RUN_STEER_RE.test(url))
|
|
1599
2439
|
return "/v1/runs/:id/steer";
|
|
@@ -1618,7 +2458,7 @@ function routeLabel(_method, url) {
|
|
|
1618
2458
|
if (BAKE_ID_RE.test(url))
|
|
1619
2459
|
return "/v1/images/bakes/:id";
|
|
1620
2460
|
if (url === "/v1/approvals/stream")
|
|
1621
|
-
return "/v1/approvals/stream";
|
|
2461
|
+
return "/v1/approvals/stream"; // native-push SSE — distinct from a /:id decide
|
|
1622
2462
|
if (/^\/v1\/approvals\/[^/]+$/.test(url))
|
|
1623
2463
|
return "/v1/approvals/:id";
|
|
1624
2464
|
if (/^\/v1\/sessions\/[^/]+\/wake$/.test(url))
|
|
@@ -1640,9 +2480,13 @@ function routeLabel(_method, url) {
|
|
|
1640
2480
|
return url;
|
|
1641
2481
|
return "other";
|
|
1642
2482
|
}
|
|
2483
|
+
/** CORS v2: allowlist semantics. A SINGLE configured origin keeps the v1 posture
|
|
2484
|
+
* (echoed unconditionally — non-browser callers see it too, byte-compat); MULTIPLE origins match the
|
|
2485
|
+
* request's Origin header (echo the matched one, `Vary: Origin` so a shared cache never serves origin-A's
|
|
2486
|
+
* grant to origin-B; no match ⇒ no allow-origin header ⇒ the browser refuses). Empty list = CORS off. */
|
|
1643
2487
|
function cors(res, req, origins, principalHeader) {
|
|
1644
2488
|
if (!origins || origins.length === 0)
|
|
1645
|
-
return;
|
|
2489
|
+
return; // defensive undefined: a hand-built test config may omit the field
|
|
1646
2490
|
let allow;
|
|
1647
2491
|
if (origins.length === 1) {
|
|
1648
2492
|
allow = origins[0];
|
|
@@ -1656,6 +2500,8 @@ function cors(res, req, origins, principalHeader) {
|
|
|
1656
2500
|
return;
|
|
1657
2501
|
res.setHeader("access-control-allow-origin", allow);
|
|
1658
2502
|
res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
|
|
2503
|
+
// Include the principal header so a browser front-end that sends it isn't rejected by the OPTIONS preflight
|
|
2504
|
+
// (which would otherwise drop identity → an owner=null session under requirePrincipal=false).
|
|
1659
2505
|
res.setHeader("access-control-allow-headers", `content-type, authorization, last-event-id, ${principalHeader}`);
|
|
1660
2506
|
}
|
|
1661
2507
|
//# sourceMappingURL=server.js.map
|