@sema-agent/server 7.43.0 → 7.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/USAGE.md +18 -0
- package/dist/adoption/plan.js +31 -192
- package/dist/adoption/quiesce.js +3 -86
- package/dist/adoption/runner.js +3 -140
- package/dist/adoption/sql.js +0 -74
- package/dist/adoption/wire.js +0 -80
- package/dist/approval-ask-machine.js +0 -75
- package/dist/approval-card.js +0 -323
- package/dist/approval-deny-reasons.js +0 -42
- package/dist/approval-hmac.js +0 -35
- package/dist/approval-reconciler.js +8 -166
- package/dist/approval.js +4 -67
- package/dist/audit.js +1 -44
- package/dist/auth-bridge.js +4 -47
- package/dist/auth-keys.js +0 -23
- package/dist/bake-runner/main.js +4 -65
- package/dist/bake-runner/protocol.js +3 -58
- package/dist/bake-runner/runner.js +5 -91
- package/dist/bench/l8/artifact.js +2 -61
- package/dist/bench/l8/escape.js +0 -25
- package/dist/bench/l8/index.js +0 -14
- package/dist/bench/l8/probes.js +0 -60
- package/dist/bench/l8/run-probes.js +2 -45
- package/dist/bench/s1/arms.js +14 -189
- package/dist/bench/s1/live-deps.js +11 -253
- package/dist/bench/s1/oracle.js +1 -36
- package/dist/bench/s1/repair-oracle-adapter.js +0 -30
- package/dist/bench/s1/reviewer.js +0 -34
- package/dist/bench/s1/row.js +0 -62
- package/dist/bench/s1/run-firm.js +4 -70
- package/dist/bench/s1/runner-ctx.js +0 -40
- package/dist/bench/s1/tasks.js +0 -115
- package/dist/boot/adoption.js +0 -20
- package/dist/boot/budget-tracing.js +3 -55
- package/dist/boot/config-center.js +43 -551
- package/dist/boot/coordinators.js +1 -69
- package/dist/boot/crash-last.js +0 -18
- package/dist/boot/deferred-sandbox-path-env.js +3 -119
- package/dist/boot/execution-env.js +5 -171
- package/dist/boot/governance-seams.js +0 -92
- package/dist/boot/leader.js +0 -69
- package/dist/boot/limit-sync.js +0 -2
- package/dist/boot/memory-boundary.js +3 -91
- package/dist/boot/org-memory.js +1 -25
- package/dist/boot/parked-revive-gate.js +0 -150
- package/dist/boot/permission-rules-audit.js +1 -56
- package/dist/boot/reapers.js +15 -273
- package/dist/boot/resolve-spec.js +9 -768
- package/dist/boot/retention-lane.js +4 -87
- package/dist/boot/runner-deps.js +3 -297
- package/dist/boot/runtime-caps.js +0 -40
- package/dist/boot/session-faces.js +2 -133
- package/dist/boot/shutdown.js +9 -83
- package/dist/boot/side-query-lane.js +2 -137
- package/dist/boot/stores.js +15 -215
- package/dist/boot/task-list-lane.js +0 -18
- package/dist/boot/webfetch-summarize-lane.js +0 -47
- package/dist/boot/workflow-orchestration.js +3 -99
- package/dist/boot-reclaim.js +0 -20
- package/dist/bounded-session-map.js +0 -19
- package/dist/brain.js +2 -139
- package/dist/budget.js +9 -244
- package/dist/capabilities/builtin-tools.js +0 -3
- package/dist/capabilities/center-plugins.js +6 -51
- package/dist/capabilities/center-prompts.js +6 -71
- package/dist/capabilities/code-review-council.js +7 -40
- package/dist/capabilities/collab-workflows.js +1 -44
- package/dist/capabilities/hands-lane.js +0 -65
- package/dist/capabilities/memory-notice.js +0 -70
- package/dist/capabilities/prompt.js +0 -14
- package/dist/capabilities/prompts/code-review.js +0 -14
- package/dist/capabilities/prompts/identity.js +0 -6
- package/dist/capabilities/prompts/team.js +0 -4
- package/dist/capabilities/repo-tools.js +0 -34
- package/dist/capabilities/sandbox-file-send.js +3 -92
- package/dist/capabilities/scenarios.d.ts +0 -1
- package/dist/capabilities/scenarios.js +18 -279
- package/dist/capabilities/select-environment-tool.js +0 -32
- package/dist/capabilities/send-user-file-tool.js +2 -66
- package/dist/capabilities/skills.d.ts +1 -2
- package/dist/capabilities/skills.js +3 -21
- package/dist/capabilities/team.d.ts +3 -11
- package/dist/capabilities/team.js +3 -51
- package/dist/capabilities/tool-defer.js +0 -3
- package/dist/config-center/apply-effective.js +15 -330
- package/dist/config-center/apply-ledger.js +5 -38
- package/dist/config-center/facade.js +0 -41
- package/dist/config-center/hot-keys-registry.js +0 -20
- package/dist/config-center/http-client.js +2 -125
- package/dist/config-center/mcp-revocation.js +2 -34
- package/dist/config-center/read-face.js +0 -59
- package/dist/config-center/restart-signal.js +1 -85
- package/dist/config-center/skills-mcp.d.ts +1 -1
- package/dist/config-center/skills-mcp.js +9 -76
- package/dist/config-center/stage-limits.js +8 -35
- package/dist/config-invariants.js +0 -16
- package/dist/config-lkg.js +0 -42
- package/dist/config-provider.js +3 -186
- package/dist/config-types.js +0 -5
- package/dist/config.js +107 -1145
- package/dist/degenerate-instrument.js +3 -67
- package/dist/deployment-governance.js +0 -124
- package/dist/digest-form.js +0 -12
- package/dist/elicitation.js +3 -86
- package/dist/env-facts.js +7 -75
- package/dist/fleet/fleet-bus.js +34 -507
- package/dist/fleet/fleet-reconciler.js +9 -149
- package/dist/fleet/fleet-terminal-window.js +10 -178
- package/dist/fleet/subagent-tail-bus.js +3 -72
- package/dist/fleet-client.js +10 -70
- package/dist/fleet-lease.js +5 -79
- package/dist/git-api-kind.js +0 -3
- package/dist/governance-ask-marks.js +2 -78
- package/dist/hooks/branch-transcript.js +0 -74
- package/dist/hooks/cc-agent-hook-prompt.js +0 -29
- package/dist/hooks/cc-stop-prompt.js +1 -46
- package/dist/hooks/hook-llm.js +1 -53
- package/dist/hooks/hook-runner.js +20 -414
- package/dist/http/active-run-conflict.js +4 -129
- package/dist/http/cursor-fingerprint.d.ts +5 -0
- package/dist/http/cursor-fingerprint.js +5 -0
- package/dist/http/idempotency.js +0 -37
- package/dist/http/principal-gate.js +3 -40
- package/dist/http/route-ctx.js +0 -9
- package/dist/http/routes/a2a-serve.js +7 -319
- package/dist/http/routes/admin-config-refresh.js +0 -4
- package/dist/http/routes/admin-drain.js +0 -10
- package/dist/http/routes/adoption.js +1 -29
- package/dist/http/routes/agents-roster.js +1 -47
- package/dist/http/routes/approvals-assistant.js +32 -395
- package/dist/http/routes/attachments.js +4 -22
- package/dist/http/routes/capabilities.js +3 -420
- package/dist/http/routes/diagnostics.js +1 -81
- package/dist/http/routes/fleet.js +9 -185
- package/dist/http/routes/images.js +14 -239
- package/dist/http/routes/leader.js +0 -13
- package/dist/http/routes/memory-bundle.js +1 -61
- package/dist/http/routes/memory-policy.js +9 -99
- package/dist/http/routes/notify-wake.js +3 -37
- package/dist/http/routes/observability.js +2 -19
- package/dist/http/routes/retention-ops.js +2 -34
- package/dist/http/routes/rules.js +1 -93
- package/dist/http/routes/runs.js +64 -873
- package/dist/http/routes/session-sync.js +19 -258
- package/dist/http/routes/sessions-list.js +17 -43
- package/dist/http/routes/sessions.js +34 -226
- package/dist/http/routes/shared-memory.js +5 -36
- package/dist/http/routes/side-query.js +1 -87
- package/dist/http/routes/tasks.js +52 -722
- package/dist/http/routes/trace-usage.js +32 -195
- package/dist/http/routes/workflows.js +19 -195
- package/dist/http/run-meta.js +0 -6
- package/dist/http/send.js +0 -32
- package/dist/http/server.js +92 -1597
- package/dist/http/sse-lifecycle.js +2 -13
- package/dist/http/sse-log.js +3 -48
- package/dist/http/tar.js +5 -21
- package/dist/http/verify-rounds.js +0 -5
- package/dist/http/wire-gate.js +0 -9
- package/dist/http/workspace-content.js +0 -10
- package/dist/images/bake-validate.js +1 -70
- package/dist/images/manifest.js +1 -6
- package/dist/index.js +0 -21
- package/dist/key-resolver.js +2 -17
- package/dist/leader/diffout.js +1 -20
- package/dist/leader/diffup.js +0 -47
- package/dist/leader/endpoint.js +2 -57
- package/dist/leader/fanout.js +3 -45
- package/dist/leader/grader-env-factory.js +3 -72
- package/dist/leader/leader.js +5 -156
- package/dist/leader/merge.js +8 -115
- package/dist/leader/planner.js +3 -54
- package/dist/leader/repair-oracle.js +1 -60
- package/dist/leader/repair-wire.js +2 -79
- package/dist/leader/wire.js +8 -307
- package/dist/lsp/e2b-bridge.js +4 -64
- package/dist/lsp/e2b-manager.js +6 -94
- package/dist/lsp/lsp-frames.js +0 -12
- package/dist/lsp/manager.js +4 -96
- package/dist/lsp/ws-transport.js +5 -55
- package/dist/lsp-evict.js +1 -15
- package/dist/main.js +57 -765
- package/dist/memory-bundle-engine.js +0 -55
- package/dist/memory-export.js +0 -4
- package/dist/memory-posture.js +1 -15
- package/dist/memory-scope.js +11 -148
- package/dist/memory-sync-client.js +2 -44
- package/dist/memory-sync.js +1 -80
- package/dist/model-select.js +3 -80
- package/dist/observability/cost-quota.js +1 -17
- package/dist/observability/cost-taxonomy.js +0 -34
- package/dist/observability/fail-open.js +7 -86
- package/dist/observability/logger.js +0 -6
- package/dist/observability/metrics.js +0 -94
- package/dist/observability/otel-exporter.js +3 -13
- package/dist/observability/principal-context.js +0 -9
- package/dist/observability/prompt-manifest.js +1 -37
- package/dist/observability/rate-limit.js +0 -4
- package/dist/observability/secret-env-scrub.js +2 -56
- package/dist/observability/tool-trace.js +1 -70
- package/dist/orchestration/hardened-vm-runner.js +4 -118
- package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
- package/dist/orchestration/hardened-vm-worker.js +0 -27
- package/dist/orchestration/subagent-steer.js +1 -45
- package/dist/orchestration/workflow-agent-steer.js +1 -80
- package/dist/orchestration/workflow-completion-inbox.js +32 -285
- package/dist/orchestration/workflow-notify-journal.js +16 -259
- package/dist/org-memory-admission.js +3 -47
- package/dist/parent-watch.js +2 -48
- package/dist/parked-decide.js +1 -109
- package/dist/per-task-image.js +0 -57
- package/dist/plan-cache-probe.js +3 -27
- package/dist/plugins/adoption-log-sql.js +2 -119
- package/dist/plugins/approval-ask-store-memory.js +3 -38
- package/dist/plugins/approval-ask-store-sql.js +6 -188
- package/dist/plugins/approval-exemption-store.js +2 -28
- package/dist/plugins/background-agent-store-sql.js +4 -105
- package/dist/plugins/background-shell-support.js +14 -122
- package/dist/plugins/blob-backend.js +6 -169
- package/dist/plugins/breaker-state-sql.js +8 -46
- package/dist/plugins/caching-session-store.js +4 -106
- package/dist/plugins/checkpoint-store-sql.js +18 -505
- package/dist/plugins/e2b-orphan-reclaim.js +0 -45
- package/dist/plugins/file-outcome-sink.js +0 -9
- package/dist/plugins/file-resume-anchor-store.js +4 -43
- package/dist/plugins/file-run-store.js +26 -364
- package/dist/plugins/file-snapshot-store-sql.js +10 -181
- package/dist/plugins/fork-routing-session-store.js +8 -111
- package/dist/plugins/host-platform.js +2 -91
- package/dist/plugins/image-bake-store-sql.js +7 -250
- package/dist/plugins/image-index-sql.js +4 -123
- package/dist/plugins/k8s-bg-scripts.js +4 -88
- package/dist/plugins/k8s-exec-protocol.js +0 -41
- package/dist/plugins/leader-run-store-sql.js +0 -101
- package/dist/plugins/local-checkpoint-store.js +8 -128
- package/dist/plugins/local-session-store.js +34 -296
- package/dist/plugins/local-task-attachment-store.js +2 -16
- package/dist/plugins/mailbox-store-sql.js +8 -65
- package/dist/plugins/memory-embedder-fingerprint.js +5 -166
- package/dist/plugins/memory-embedder.js +4 -70
- package/dist/plugins/memory-engine-pg.js +6 -170
- package/dist/plugins/memory-engine-tidb.js +7 -155
- package/dist/plugins/memory-engine-vector-util.js +0 -10
- package/dist/plugins/memory-key-guards.js +0 -34
- package/dist/plugins/memory-origin-law.js +0 -187
- package/dist/plugins/memory-resume-anchor-store.js +0 -17
- package/dist/plugins/memory-run-store.js +12 -89
- package/dist/plugins/memory-session-policy-store.js +0 -17
- package/dist/plugins/memory-sync-store-pg.js +4 -49
- package/dist/plugins/memory-sync-store-tidb.js +3 -35
- package/dist/plugins/outcome-ledger-sql.js +3 -97
- package/dist/plugins/permission-rule-store-file.js +5 -133
- package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
- package/dist/plugins/permission-rule-store-sql.js +50 -307
- package/dist/plugins/pg-cost-quota.js +0 -7
- package/dist/plugins/pg-pool.js +0 -92
- package/dist/plugins/pg-rate-limiter.js +2 -13
- package/dist/plugins/pg-safe-json.js +4 -40
- package/dist/plugins/pg-session-storage.js +25 -189
- package/dist/plugins/posix-shell-fs.js +1 -31
- package/dist/plugins/remote-env-adb.js +12 -101
- package/dist/plugins/remote-env-e2b.js +40 -371
- package/dist/plugins/remote-env-file-error.js +0 -37
- package/dist/plugins/remote-env-host.js +63 -483
- package/dist/plugins/remote-env-k8s.js +32 -326
- package/dist/plugins/remote-env-local-docker.js +24 -157
- package/dist/plugins/remote-env-ssh.js +20 -128
- package/dist/plugins/remote-scratchpad.js +2 -32
- package/dist/plugins/remote-shell.js +1 -32
- package/dist/plugins/resume-anchor-store-sql.js +0 -11
- package/dist/plugins/retention-lane-store-sql.js +0 -108
- package/dist/plugins/retention-store-sql.js +4 -383
- package/dist/plugins/roster-store-sql.js +0 -55
- package/dist/plugins/run-store-sql.js +14 -251
- package/dist/plugins/s3-presign.js +2 -49
- package/dist/plugins/scheduler-support.js +3 -80
- package/dist/plugins/send-file-ledger.js +4 -53
- package/dist/plugins/send-user-file.js +4 -94
- package/dist/plugins/session-placement.js +1 -89
- package/dist/plugins/session-policy-store-sql.js +3 -81
- package/dist/plugins/session-store.js +0 -57
- package/dist/plugins/shared-memory-store-sql.js +4 -167
- package/dist/plugins/sql-driver.js +0 -17
- package/dist/plugins/sql-errors.js +0 -7
- package/dist/plugins/sql-escape.js +0 -8
- package/dist/plugins/sql-row-helpers.js +0 -25
- package/dist/plugins/store-backend.js +42 -222
- package/dist/plugins/store-contracts.js +2 -46
- package/dist/plugins/task-attachment-store.js +3 -49
- package/dist/plugins/task-list-store-sql.js +0 -79
- package/dist/plugins/tidb-cost-quota.js +1 -4
- package/dist/plugins/tidb-pool.js +1 -207
- package/dist/plugins/tidb-rate-limiter.js +3 -9
- package/dist/plugins/tidb-session-storage.js +4 -70
- package/dist/plugins/tidb-session-store.js +19 -341
- package/dist/plugins/tool-result-store-sql.js +4 -179
- package/dist/plugins/usage-window-store-sql.js +0 -8
- package/dist/plugins/web-search.js +10 -132
- package/dist/plugins/workflow-journal-store-sql.js +2 -58
- package/dist/plugins/workflow-run-store-sql.js +6 -91
- package/dist/plugins/worktree-isolation.js +6 -126
- package/dist/plugins/write-behind-counter.js +16 -75
- package/dist/principal-jwt.js +5 -60
- package/dist/project-memory.js +15 -146
- package/dist/prompts-domain-validate.js +1 -51
- package/dist/question.js +2 -111
- package/dist/resource-suspend.js +0 -18
- package/dist/router/route-orchestration.js +0 -77
- package/dist/rules-consent.d.ts +44 -5
- package/dist/rules-consent.js +75 -215
- package/dist/run-local.js +14 -381
- package/dist/runs.js +29 -617
- package/dist/runtime-caps-resolver.js +6 -132
- package/dist/runtime-governance.js +1 -232
- package/dist/sandbox-pkg-source.js +0 -37
- package/dist/sealed-key.js +3 -68
- package/dist/security.js +6 -291
- package/dist/session-leaf-bus.js +0 -32
- package/dist/session-sync-content.js +1 -69
- package/dist/session-sync-kernel.js +3 -60
- package/dist/session-sync.js +3 -66
- package/dist/session-titler.js +7 -41
- package/dist/session-watch.js +9 -73
- package/dist/shared-memory-scope-authorizer.js +0 -18
- package/dist/sighup-idle.js +1 -12
- package/dist/spec-fields.js +4 -143
- package/dist/store-live-probe.js +3 -49
- package/dist/task-a2a.js +1 -126
- package/dist/task-cwd.js +1 -103
- package/dist/task-mcp.js +1 -89
- package/dist/task-settings.js +10 -300
- package/dist/task-workflow.js +8 -75
- package/dist/tool-approval.d.ts +28 -1
- package/dist/tool-approval.js +70 -1430
- package/dist/trace/artifacts.js +5 -20
- package/dist/trace/engine-notice-wire.js +6 -138
- package/dist/trace/ledger-sink.js +7 -96
- package/dist/trace/project.js +4 -448
- package/dist/trace/redact.js +9 -77
- package/dist/turn-activity.js +1 -27
- package/dist/usage-analytics.js +4 -34
- package/dist/wall-clock-jump-guard.js +1 -68
- package/package.json +3 -3
- package/dist/capabilities/scenario-alias.d.ts +0 -27
- package/dist/capabilities/scenario-alias.js +0 -61
package/dist/http/server.js
CHANGED
|
@@ -14,7 +14,7 @@ import { normalizeApproachNotice, validateTaskAgents } from "../spec-fields.js";
|
|
|
14
14
|
import { isValidCwd, MAX_ADDITIONAL_DIRS } from "../task-cwd.js";
|
|
15
15
|
import { runInBackground, evictIfConflict, stripCheckpointToken, parkToolCallId, TurnAnchorCapture, HEARTBEAT_MS, markChildrenStoppedByUserOnAbort } from "../runs.js";
|
|
16
16
|
import { readTurnActivityMs, recordTurnActivity } from "../turn-activity.js";
|
|
17
|
-
import { publicStoreProbeError } from "../store-live-probe.js";
|
|
17
|
+
import { publicStoreProbeError } from "../store-live-probe.js";
|
|
18
18
|
import { looksLikeJwt } from "@sema-agent/registry-core/api/auth-bridge";
|
|
19
19
|
import {} from "../orchestration/workflow-agent-steer.js";
|
|
20
20
|
import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "../orchestration/workflow-completion-inbox.js";
|
|
@@ -27,15 +27,12 @@ import {} from "../observability/rate-limit.js";
|
|
|
27
27
|
import { withPrincipal } from "../observability/principal-context.js";
|
|
28
28
|
import { turnEndEventData, contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, humanInputEventData, appendModelUsageDelta, appendPromptManifest, attachModelUsage } from "../trace/project.js";
|
|
29
29
|
import { redactSecrets } from "../trace/redact.js";
|
|
30
|
-
import { createLedgerSink, createSerialLedgerAppend } from "../trace/ledger-sink.js";
|
|
31
|
-
import { registerEngineNoticeLeg } from "../trace/engine-notice-wire.js";
|
|
32
|
-
import { recordFailOpen } from "../observability/fail-open.js";
|
|
30
|
+
import { createLedgerSink, createSerialLedgerAppend } from "../trace/ledger-sink.js";
|
|
31
|
+
import { registerEngineNoticeLeg } from "../trace/engine-notice-wire.js";
|
|
32
|
+
import { recordFailOpen } from "../observability/fail-open.js";
|
|
33
33
|
import { IdempotencyCache, scopedIdempotencyKey } from "./idempotency.js";
|
|
34
34
|
export { scopedIdempotencyKey };
|
|
35
35
|
import { streamSseLog } from "./sse-log.js";
|
|
36
|
-
// design/97 R9: the shape's neutral leaf (route-ctx.ts type-imports VerifyRoundsSpec from here, not from this file).
|
|
37
|
-
// `clampVerifyRounds` used to be re-exported from here for the tests; that re-export was deleted (8566348) and the
|
|
38
|
-
// import sat on as a dead name — tests import it from `verify-rounds.js` directly, which is the single owner.
|
|
39
36
|
import { verifyRoundsFromBody } from "./verify-rounds.js";
|
|
40
37
|
import { RUN_NOT_FOUND_MESSAGE } from "./route-ctx.js";
|
|
41
38
|
import { handleCapabilities } from "./routes/capabilities.js";
|
|
@@ -44,7 +41,7 @@ import { handleDiagnostics } from "./routes/diagnostics.js";
|
|
|
44
41
|
import { handleAdoption } from "./routes/adoption.js";
|
|
45
42
|
import { handleRetention } from "./routes/retention-ops.js";
|
|
46
43
|
import { handleAdminDrain } from "./routes/admin-drain.js";
|
|
47
|
-
import { handleAdminConfigRefresh } from "./routes/admin-config-refresh.js";
|
|
44
|
+
import { handleAdminConfigRefresh } from "./routes/admin-config-refresh.js";
|
|
48
45
|
import { handleMemoryPolicy } from "./routes/memory-policy.js";
|
|
49
46
|
import { handleMemoryBundle } from "./routes/memory-bundle.js";
|
|
50
47
|
import { handleSharedMemory } from "./routes/shared-memory.js";
|
|
@@ -69,33 +66,20 @@ import { cascadeConfig } from "./run-meta.js";
|
|
|
69
66
|
export { cascadeConfig };
|
|
70
67
|
import { handleImages, createImagesLocal, coarseStatusForState, errorCodeForExit } from "./routes/images.js";
|
|
71
68
|
export { coarseStatusForState, errorCodeForExit };
|
|
72
|
-
// design/158 A9:装配器缝——发送器/身份门下沉到 http/ 叶子模块,routes/* 与 server.ts 共用同一实现
|
|
73
|
-
// (routes/* 绝不可值 import server.ts:那条边会闭合运行时环,见 test/module-cycle-gate.test.ts)。
|
|
74
69
|
import { sendJson, sendError, httpErrorCode, msg } from "./send.js";
|
|
75
70
|
import { authorized, systemFor, gatedPrincipal, explicitOperatorOk, isOperator } from "./principal-gate.js";
|
|
76
71
|
import { buildActiveRunConflict, resumeEntryForGate } from "./active-run-conflict.js";
|
|
77
72
|
export { explicitOperatorOk, isOperator };
|
|
78
|
-
/** 分组入参 → 平铺视图(createHttpServer 的第一件事)。一组都没给 ⇒ 原样返回(存量调用零开销、零形变)。 */
|
|
79
73
|
export function flattenServiceDeps(deps) {
|
|
80
74
|
const { stores, coordinators, seams, observability, governance, deployment, knobs, ...flat } = deps;
|
|
81
75
|
if (!(stores || coordinators || seams || observability || governance || deployment || knobs))
|
|
82
76
|
return deps;
|
|
83
77
|
return { ...stores, ...coordinators, ...seams, ...observability, ...governance, ...deployment, ...knobs, ...flat };
|
|
84
78
|
}
|
|
85
|
-
// design/158 A4: TaskRequestBody lives in ./wire-types.ts (a type-only leaf) — import it from there
|
|
86
|
-
// ([2354] 兼容面全清:此处的兼容 re-export 已删;index.ts 公共面直接从 wire-types 转出)。
|
|
87
|
-
/** Personalization caps: server-pinned hard limits — a UI may be stricter, never looser. */
|
|
88
79
|
export const MAX_USER_SKILLS = 10;
|
|
89
|
-
/** = core `SKILL_CONTENT_MAX_CHARS`(1MB 加载门,core 1.293 起 invoke 全文零截断、超 1MB 整拒;常量在
|
|
90
|
-
* dist/core/runner/synthetic-tools.js:84,未从 core 包根导出,故钉本地孪生+本出处注记)。修7 曾对齐旧
|
|
91
|
-
* 20k 截断门([845] 飞轮考据:20k 是压缩保留区口径,invoke 时刻用错),core 1.293 撤门后本门跟随回收
|
|
92
|
-
* ([853]① 收口)——HTTP 面与引擎加载门同值,超限 400 fail-loud 而非引擎侧整拒后难诊断。 */
|
|
93
80
|
export const MAX_SKILL_CONTENT_CHARS = 1_048_576;
|
|
94
81
|
export const MAX_SYSTEM_PROMPT_CHARS = 16_384;
|
|
95
|
-
/** Cap on a request's `outputSchema` (JSON-serialized) — an uncapped schema bloats the prompt + the per-turn
|
|
96
|
-
* validation cost. 32KB comfortably holds a rich nested schema while bounding the abuse surface. */
|
|
97
82
|
export const MAX_OUTPUT_SCHEMA_CHARS = 32_768;
|
|
98
|
-
/** Validate `body.skills` (caps + shape). Returns an error string (→ 400) or null when acceptable. */
|
|
99
83
|
export function validateUserSkills(skills) {
|
|
100
84
|
if (skills === undefined)
|
|
101
85
|
return null;
|
|
@@ -116,89 +100,26 @@ export function validateUserSkills(skills) {
|
|
|
116
100
|
return null;
|
|
117
101
|
}
|
|
118
102
|
const MAX_BODY = 8 * 1024 * 1024;
|
|
119
|
-
/** Caps on `body.images`: inline base64 `data` is otherwise passed RAW to the
|
|
120
|
-
* spec (URL images are SSRF+size-gated by core, inline aren't). The whole JSON body is already ≤ MAX_BODY, but with NO
|
|
121
|
-
* COUNT cap a request could carry thousands of tiny images (a decode/processing DoS), and there was no explicit
|
|
122
|
-
* per-image error. The two-axis gate belongs in the server (shell carries content, server gates it). */
|
|
123
103
|
const MAX_IMAGES_PER_REQUEST = 20;
|
|
124
|
-
const MAX_IMAGE_BASE64_BYTES = 6 * 1024 * 1024;
|
|
125
|
-
// Task Trace API (S1): GET /v1/tasks (list) | /v1/tasks/:id/turns | /v1/tasks/:id/stream. The `:id` group +
|
|
126
|
-
// sub-route are both-or-neither, so a GET /v1/tasks/stream (no :id) does NOT match — the POST sync stream is unshadowed.
|
|
127
|
-
// Hand (executionEnv) tool names core mounts at the runner when an executionEnvFactory is configured. These are
|
|
128
|
-
// NEVER in spec.tools, so the durable-resume satisfiability pre-check must union them when remote exec is on.
|
|
129
|
-
// #244([3751]): the authoritative set is the FULL band `HANDS_BAND_TOOL_NAMES` (capabilities/hands-lane.ts —
|
|
130
|
-
// effects table + the mounted complement Monitor/EnterWorktree/ExitWorktree/SendMessage/AgentTranscript, retired
|
|
131
|
-
// names filtered at the single owner). The old effects-only derivation left the complement out, so approving a
|
|
132
|
-
// parked complement-tool ask (the disconnect force-park population, e.g. Monitor on the cli host lane) was a
|
|
133
|
-
// permanent 422 conflict.pending_action_unsatisfiable — the re-presented card's answer could never propagate.
|
|
104
|
+
const MAX_IMAGE_BASE64_BYTES = 6 * 1024 * 1024;
|
|
134
105
|
const HAND_TOOL_NAMES = HANDS_BAND_TOOL_NAMES;
|
|
135
|
-
/** #233 / A-002.8 —— 未知模型 400 里 `available` 列表的**名单过滤**:不给用户广告他点不了的模型。
|
|
136
|
-
* 名单缺席/空 ⇒ 与本件之前逐字同集(全目录去 `default` 别名);名单在场 ⇒ 只留名单准的那些。
|
|
137
|
-
* 判定单源 `isModelAllowlisted`(与三个执法点同一只),所以"列出来的"与"点了不被拒的"恒为同一批。 */
|
|
138
106
|
function mentionableNames(config, catalog) {
|
|
139
107
|
const allowlist = config.atModelAllowlist ?? [];
|
|
140
108
|
return Object.keys(catalog).filter((n) => n !== "default" && isModelAllowlisted(n, catalog, allowlist));
|
|
141
109
|
}
|
|
142
|
-
/** [1245] codex-3/4 — the REOPEN-class resume failures: core's runtask reopened the checkpoint (store.reopen,
|
|
143
|
-
* dist-read) BEFORE returning this failed result, so the park is STILL PENDING and decidable. Everything
|
|
144
|
-
* downstream must treat these as a re-park, never terminal: the durable event log writes `suspended` (not
|
|
145
|
-
* `done`), the run row re-parks (keeps task_active), and /decide surfaces retriable:true. A reopen that
|
|
146
|
-
* itself failed surfaces as `checkpoint.reopen_failed` — deliberately NOT matched here (terminal). */
|
|
147
110
|
function isCheckpointReopenedFailure(r) {
|
|
148
111
|
return r.status === "failed" && (r.errorCode === "resume.env_failed" || r.errorCode === "resume.tool_unavailable");
|
|
149
112
|
}
|
|
150
|
-
/**
|
|
151
|
-
* 一条 verify/cascade 腿的结果**是不是嵌套 durable park**,以及 park 落在哪个词上(扫描P2 + codex 复审
|
|
152
|
-
* round1 [high] 一,2026-08-12 亲读 core 验真)。`undefined` = 不是 park(照常走终局)。
|
|
153
|
-
*
|
|
154
|
-
* 🔴 判据是 **errorCode**,不是 `verification.unverifiedReason` —— 后者**不覆盖修复轮**:
|
|
155
|
-
* · `verify.js:201/208`(runWithVerification / resumeWithVerification 的**立即** park)铸的 outcome 带
|
|
156
|
-
* `unverifiedReason: status === "needs_review" ? "needs_review" : "suspended"`;
|
|
157
|
-
* · 但 `verify.js:191`(verifier 判 FAIL 后的**修复轮** `runTask` 又停在门上)返回的是
|
|
158
|
-
* `{ ...mapNestedSuspend(current), verification: outcome }`,那个 `outcome` 是**上一轮验证器的
|
|
159
|
-
* FAIL 判决** —— `verdict:"FAIL"` 的臂根本不填 `unverifiedReason`(`verify.js:166` 只在
|
|
160
|
-
* no_verdict / PARTIAL 两种情况填)。于是「按 reason 判 park」对修复轮恒为假,而结果里
|
|
161
|
-
* checkpointToken 还活着 ⇒ 写 `done` + setTerminal ⇒ 释放 task_active 锁 + 孤儿化那张卡。
|
|
162
|
-
*
|
|
163
|
-
* `mapNestedSuspend`(core `agents/suspend-guard.js`)是**每一条**嵌套 park 的必经点,它无条件盖这两个
|
|
164
|
-
* 码之一,并且码本身就带 park 的词 —— 所以它同时是「是不是 park」和「是哪个 park」的单一真源。
|
|
165
|
-
* 两个字面量在 core 的 index 里没有导出,只能照字面锚(与 `runs.ts` / `resource-suspend.ts` 顶注同姿势)。
|
|
166
|
-
*
|
|
167
|
-
* ⚠️ 边界:`checkpoint.reopen_failed` 与 `resume.*` 两类 reopen 失败**不在**这里(它们不是 gate 再停,
|
|
168
|
-
* 由 {@link isCheckpointReopenedFailure} + 店内复核那条腿处置);`verifier_paused` 也不在(那是验证器
|
|
169
|
-
* 自己的会话与凭据,本 run 的活已经做完)。
|
|
170
|
-
*/
|
|
171
113
|
function nestedParkStatusOf(r) {
|
|
172
114
|
switch (r.errorCode) {
|
|
173
|
-
case "unexpected.needs_review":
|
|
115
|
+
case "unexpected.needs_review":
|
|
174
116
|
return "needs_review";
|
|
175
|
-
case "unexpected.suspended":
|
|
117
|
+
case "unexpected.suspended":
|
|
176
118
|
return "suspended";
|
|
177
119
|
default:
|
|
178
120
|
return undefined;
|
|
179
121
|
}
|
|
180
122
|
}
|
|
181
|
-
/** The TRUSTED end-user principal for an owner-gated / per-tenant request. On a direct-door worker the
|
|
182
|
-
* trusted-header path is OFF (x-agent-principal is client-spoofable), so identity comes from the crypto-VERIFIED
|
|
183
|
-
* principal JWT (`x-approval-principal-token`, principal-only proof — no cnf.bnd action binding); otherwise the
|
|
184
|
-
* BFF-minted trusted header. undefined when absent/invalid (fail-closed → callers map to 401 / scope "__none__").
|
|
185
|
-
* 🔴 Use this for ANY endpoint that reads or gates PER-TENANT data — NOT `principalFrom` directly, which trusts
|
|
186
|
-
* the spoofable header on a direct door (/v1/usage + the trace owner-gate
|
|
187
|
-
* had drifted to a bare principalFrom, unlike the leader/images/approvals identity paths). One source of truth
|
|
188
|
-
* so a future per-tenant endpoint can't re-introduce the bypass. The action-BOUND decide/answer paths keep their
|
|
189
|
-
* own verifyDirectDoorProof (cnf.bnd) — this is the identity-only twin. */
|
|
190
|
-
// F-fix: a thin delegate to the single source of the verified identity (security.ts `verifiedPrincipal`). Kept as a
|
|
191
|
-
// named alias so the existing call sites read clearly; the JWT logic now lives in one place that createAuthorizer
|
|
192
|
-
// shares, so spec.principal (governance + cost) and these observability/owner sites can never drift apart again.
|
|
193
|
-
/** design/158 A9 装配表(顺序 = 拆分前 `handle()` 里各域出现的先后,逐行同序;真正的 `return` 在 handle 内,
|
|
194
|
-
* 这里只做**签名一致性**的编译期钉:任何域入口漂了形,是编译红,不是运行时静默 404)。
|
|
195
|
-
*
|
|
196
|
-
* 🔴 A-010.24(验真后修):上面那句「逐行同序」此前是**一句自述,没有任何东西执法**,而它已经不成立 ——
|
|
197
|
-
* `handleDiagnostics` 从来没进过这张表,车二着陆后 `handleRules` / `handleSharedMemory` 的先后也反了。
|
|
198
|
-
* 类型钉不住这两类漂:少一个成员、调换两个成员,`RouteHandler[]` 全都收下。而这张表是评审时唯一会被
|
|
199
|
-
* 当成「域全集 + 域次序」来读的清单(新域该插哪儿、某域在谁之后才可达),它给错答案是要害。
|
|
200
|
-
* 执法面现在真的有了:`test/route-shape-roster.test.ts` 的**层D**从 `handle()` 的真源码抽出域调用序,
|
|
201
|
-
* 与本表**逐项**对拍 —— 漏员或乱序都指名道姓地红。改 handle() 的次序时,这张表要跟着改。 */
|
|
202
123
|
const ROUTE_DOMAINS = [
|
|
203
124
|
handleTraceUsage,
|
|
204
125
|
handleWorkflows,
|
|
@@ -232,81 +153,17 @@ const ROUTE_DOMAINS = [
|
|
|
232
153
|
handleNotifyWake,
|
|
233
154
|
];
|
|
234
155
|
void ROUTE_DOMAINS;
|
|
235
|
-
/**
|
|
236
|
-
* Managed-Agents-style HTTP/SSE service surface.
|
|
237
|
-
* GET /health
|
|
238
|
-
* POST /v1/tasks → run to completion, TaskResult JSON (synchronous)
|
|
239
|
-
* POST /v1/tasks/stream → SSE of TaskEvent, live tokens (synchronous, same instance)
|
|
240
|
-
* POST /v1/runs → async: 202 {taskId, sessionId, status}, runs in background (S1)
|
|
241
|
-
* GET /v1/runs/:id → run status + result (poll)
|
|
242
|
-
* GET /v1/runs/:id/events → SSE replay from Last-Event-ID, then tail to terminal (S2)
|
|
243
|
-
* POST /v1/sessions/:id/notify → inject an out-of-session event (design/144 §2): live stream ⇒ core
|
|
244
|
-
* `TaskStream.notify()`; idle ⇒ parked in the session inbox, drained
|
|
245
|
-
* as a `task_notification` on the next stream open
|
|
246
|
-
* POST /v1/sessions/:id/wake → wake a `task_done` pure park with a message and resume it
|
|
247
|
-
* (design/144 §3). NOT a gate decision — a pending gate is refused
|
|
248
|
-
* (`wake.gate_pending`). Goes through `resumeWake`, which reuses the
|
|
249
|
-
* resume family's lease/CAS/run-log leg, so it BURNS MODEL TOKENS and
|
|
250
|
-
* is subject to the billable-submit gates (`SESSION_WAKE_RE`).
|
|
251
|
-
* (both live in `http/routes/notify-wake.ts` — design/158 A9 domain split)
|
|
252
|
-
*/
|
|
253
156
|
export function createHttpServer(rawDeps) {
|
|
254
|
-
// design/158 A8:分组装配 → 平铺视图。**分组是装配面的形,不是消费面的形**——下面 ~700 处 `deps.x`
|
|
255
|
-
// 消费点原样保留(平铺入参零形变、零开销:没给任何组时 flatten 原样返回同一个对象)。
|
|
256
157
|
const deps = flattenServiceDeps(rawDeps);
|
|
257
|
-
// Idempotency-Key dedup for task submission (center review-routing blocker: exactly-once billing). One
|
|
258
|
-
// cache per server instance, shared by the sync /v1/tasks and async /v1/runs create paths.
|
|
259
158
|
const idemCache = new IdempotencyCache();
|
|
260
|
-
// In-flight durable runs on THIS instance → their cancel controller. POST /v1/runs/:id/cancel landing here
|
|
261
|
-
// aborts immediately (fast path); a cancel landing on another replica uses the durable flag + heartbeat poll.
|
|
262
159
|
const inflightRuns = new Map();
|
|
263
|
-
// [1.207 codex H2] taskIds whose abort came from the CANCEL VERB (fast path or flag poll) — the interactive
|
|
264
|
-
// legs consult this at settle time to label an abort-shaped failed result `errorCode:"cancelled"` (core's
|
|
265
|
-
// external-signal abort yields failed WITHOUT an errorCode; first-writer-wins would then pin the unlabeled
|
|
266
|
-
// form). A genuine completion that raced the abort is NOT relabeled (only status==="failed" gets the code).
|
|
267
|
-
// Entries are dropped in each leg's finally (same lifecycle as its inflightRuns registration).
|
|
268
160
|
const cancelledViaVerb = new Set();
|
|
269
|
-
// design/80 seam #2 (preemption): in-flight PREEMPTIBLE runs on THIS instance → their preempt controller (the
|
|
270
|
-
// graceful-yield twin of inflightRuns' kill). POST /v1/assistant/tasks/:id/preempt landing here aborts it
|
|
271
|
-
// immediately (fast path → core durably suspends at the next clean turn boundary); a preempt landing on another
|
|
272
|
-
// replica uses the durable `preempt_requested` flag + heartbeat poll. Only plain-stream + resumed legs register.
|
|
273
161
|
const preemptableRuns = new Map();
|
|
274
|
-
// design/47 + design/80 D-A (steering): in-flight STEERABLE runs on THIS instance → their live core TaskStream
|
|
275
|
-
// handle. POST /v1/runs/:id/steer landing here injects a mid-task message via the stream's `steer()` (applied at
|
|
276
|
-
// the next turn-boundary drain → 200). A run that is NOT here is either durably suspended (→ park the steer on its
|
|
277
|
-
// checkpoint via setPendingSteer → 202) or streaming on another replica (cross-replica live-steer is a fast-follow,
|
|
278
|
-
// single-replica today → 409). Only the plain-stream + resumed legs hold a live stream, so only they register
|
|
279
|
-
// (the verify/cascade legs return a result, not a stream — nothing to steer, parity with preemptableRuns).
|
|
280
162
|
const steerableRuns = new Map();
|
|
281
|
-
// design/144 wake 半场:终态 steer 铸 park 的 per-session 串行链(复审 HIGH:tryPark→put 非原子,
|
|
282
|
-
// 并发双 steer 双铸两个 pending park)。单副本部署(本仓 steer 跨副本 seam 同款现实)进程锁即闭环;
|
|
283
|
-
// 跨副本残窗由 put 后赢家复核收敛兜底(见 (d) 分支),与 cross-replica live-steer 同列 fast-follow。
|
|
284
163
|
const wakeParkMints = new Map();
|
|
285
|
-
// design/158 A9:两条「裸 number 登记簿」装盒——`let` 拆不出闭包,域模块要自增/自减必须共享同一个对象。
|
|
286
|
-
// 语义与计数时机逐字不变(uncountedBillableInflight = B-1: sync /v1/tasks + leader bg runs,无 map 身份,
|
|
287
|
-
// drain 靠它看见;admittedInflight = 修1 的 admission 期计数,见下方注释)。
|
|
288
164
|
const counters = { uncountedBillableInflight: 0, admittedInflight: 0 };
|
|
289
|
-
// 修1(三路复审 B3 HIGH,迟登记窗):ADMISSION-time in-flight counter. Every non-probe request is counted
|
|
290
|
-
// SYNCHRONOUSLY in handle()'s first statements (before ANY await — auth/prepareSpec/lease/claim all happen
|
|
291
|
-
// later) and released once, on whichever of finish/close fires first. Closes the window where a request that
|
|
292
|
-
// just entered the handler was invisible to inflight() and a SIGHUP idle check (or SIGTERM drain read)
|
|
293
|
-
// misjudged the replica idle → hardShutdown mid-admission. Deliberately OVERLAPS the leg registries below
|
|
294
|
-
// (a registered leg counts twice) — every consumer only tests inflight()===0, so overcount is conservative,
|
|
295
|
-
// never a false-idle.
|
|
296
|
-
// drain: expose this replica's in-flight leg count as a LIVE getter on the shared drain state. Union of
|
|
297
|
-
// taskIds (a resumed leg registers in BOTH maps) — bg/resume durable legs (inflightRuns) + live streams
|
|
298
|
-
// (steerableRuns, sync leg included since 1.78). main.ts polls this after SIGTERM to know when drain is done.
|
|
299
|
-
// 对抗复查 B-1(CRITICAL):the SYNC /v1/tasks await and the leader background run register in NEITHER map —
|
|
300
|
-
// a SIGTERM during either read inflight=0 and hard-shut mid-model-work. Count them explicitly (a plain
|
|
301
|
-
// counter: they have no steer/resume identity to key a map on).
|
|
302
165
|
if (deps.drainState)
|
|
303
166
|
deps.drainState.inflight = () => new Set([...inflightRuns.keys(), ...steerableRuns.keys()]).size + counters.uncountedBillableInflight + counters.admittedInflight + (deps.leaderEndpoint?.inflight?.() ?? 0);
|
|
304
|
-
// B3 复审(codex HIGH):admission 计数之外再补一条粗粒度活性时钟:任何非探针请求(/health、/metrics*
|
|
305
|
-
// 除外——探针/抓取是常态噪音)都推进时间戳;SIGHUP 空闲窗要求「inflight 归零 + 活性静默」双满足才关停。
|
|
306
|
-
// 顺带把 idle-but-live 的 peer(还在 list/浏览的壳)也保住。
|
|
307
|
-
// ⚠️ 时基契约(三路复审修3):performance.now() 单调域,与 sighup-idle.ts 的 `now` seam 同域——挂钟
|
|
308
|
-
// (Date.now)跳变(NTP 回拨/夏令时)会把空闲窗判定拉爆。对外报告的 epoch 时间戳(请求日志/drainState.since)
|
|
309
|
-
// 不受影响,仍走 Date.now。
|
|
310
167
|
let lastBillableActivityAt = performance.now();
|
|
311
168
|
const noteActivity = (url) => {
|
|
312
169
|
if (url === "/health" || url.startsWith("/metrics"))
|
|
@@ -315,22 +172,13 @@ export function createHttpServer(rawDeps) {
|
|
|
315
172
|
};
|
|
316
173
|
if (deps.drainState)
|
|
317
174
|
deps.drainState.lastActivityAt = () => lastBillableActivityAt;
|
|
318
|
-
// asyncRewake wake 管道(hooks 阶段二挂账兑现):hook-runner 的 ctx.wake 经 main.ts 的 hookWakeBus holder
|
|
319
|
-
// 到这里——session → 本副本活流(steerableRuns 是 taskId 键,反查经 run 行;活流数=本副本并发数,量小;
|
|
320
|
-
// wake 是稀有事件[async hook exit 2],逐行 get 可接受)。找到即 steer(操作者 hook 的 stderr,trusted
|
|
321
|
-
// 与 explicitOperator 同源语义;下个 turn 边界注入)。无活流 → 挂起腿 park fallback(与 HTTP steer 的
|
|
322
|
-
// tryPark 同一原语:pending checkpoint 是权威,CAS on status='pending';落上=resume 时 core 注入)。
|
|
323
|
-
// park 也不上 → false(别副本 live/已结束——调用方记账)。
|
|
324
175
|
if (deps.hookWakeBus) {
|
|
325
176
|
deps.hookWakeBus.deliver = async (sessionId, text) => {
|
|
326
|
-
// hook stderr 不是操作者亲笔——是 hook 脚本运行时输出(可能回显下载物/工具输出),
|
|
327
|
-
// 【内容】不升 trusted(与 HTTP steer 的非-explicitOperator 姿势一致),且先过同一 validatePendingSteer
|
|
328
|
-
// 校验(break-out/控制字符即拒,不静默换流)。围栏 provenance 在 main.ts 闭包侧(注入前缀标明来源)。
|
|
329
177
|
try {
|
|
330
178
|
validatePendingSteer({ text, trusted: false });
|
|
331
179
|
}
|
|
332
180
|
catch {
|
|
333
|
-
return false;
|
|
181
|
+
return false;
|
|
334
182
|
}
|
|
335
183
|
for (const [tid, stream] of steerableRuns) {
|
|
336
184
|
try {
|
|
@@ -341,12 +189,9 @@ export function createHttpServer(rawDeps) {
|
|
|
341
189
|
return true;
|
|
342
190
|
}
|
|
343
191
|
catch {
|
|
344
|
-
continue;
|
|
192
|
+
continue;
|
|
345
193
|
}
|
|
346
194
|
}
|
|
347
|
-
// 挂起腿:任务 durably suspended 时 async hook 才收场(如慢 lint 收尾)——
|
|
348
|
-
// wake park 到 session 的 pending checkpoint(镜像 POST /v1/runs/:id/steer 的 tryPark:scope/token
|
|
349
|
-
// 缺失或 CAS 失手=没有真挂起的 checkpoint → false)。resume 时 core 把它作为 pending steer 注入。
|
|
350
195
|
try {
|
|
351
196
|
const cs = deps.checkpointStore;
|
|
352
197
|
if (!cs)
|
|
@@ -360,17 +205,12 @@ export function createHttpServer(rawDeps) {
|
|
|
360
205
|
return await cs.setPendingSteer(token, scope, { text, trusted: false });
|
|
361
206
|
}
|
|
362
207
|
catch {
|
|
363
|
-
// park 面任何失败=诚实 false(调用方按 dropped 记账),绝不 throw 回 hook 收尾路径。
|
|
364
|
-
// core 5.14.0(#147)新增的 `steering.queue_full` 也落这里:队列满 ⇒ 这条 hook 输出确实没投出去,
|
|
365
|
-
// `false` 就是它的真实结局。**不给它 wire 码**——本通道是进程内 hook 收尾,没有 HTTP 应答面。
|
|
366
208
|
return false;
|
|
367
209
|
}
|
|
368
210
|
};
|
|
369
211
|
}
|
|
370
212
|
const server = http.createServer((req, res) => {
|
|
371
213
|
void handle(req, res).catch((err) => {
|
|
372
|
-
// A typed HttpError (e.g. a malformed JSON body → 400) carries its own status — a client mistake is a
|
|
373
|
-
// 4xx, not a 500. Everything else is a genuine internal error: log the cause, never echo it (info disclosure).
|
|
374
214
|
if (err instanceof HttpError) {
|
|
375
215
|
if (!res.headersSent)
|
|
376
216
|
sendError(res, err.status, httpErrorCode(err.status, err.code), err.message, { ...(err.extra ?? {}) });
|
|
@@ -385,38 +225,22 @@ export function createHttpServer(rawDeps) {
|
|
|
385
225
|
res.end();
|
|
386
226
|
});
|
|
387
227
|
});
|
|
388
|
-
// design/158 A9 装配缝:服务级 ctx 建**一次**(deps 平铺视图 / 跨域运行期登记簿 / 无状态助手 / 单域状态)。
|
|
389
|
-
// 每请求只再加一格 `req`(见 handle 内)。助手全是 `function` 声明,提升后此处引用安全。
|
|
390
228
|
const routeCtxBase = {
|
|
391
229
|
deps,
|
|
392
230
|
registry: { idemCache, inflightRuns, preemptableRuns, cancelledViaVerb, steerableRuns, wakeParkMints, counters },
|
|
393
231
|
helpers: { readJson, readRawBody, rateLimited, quotaExceeded, leaseDenied, usageWindowDenied, safeDecode, isFleetWide, runOwnerOk, sessionOwnerScope, sessionOwnerScopeForWrite, runSessionAcceptOk },
|
|
394
232
|
local: { images: createImagesLocal(deps), sessions: createSessionsLocal(), sessionSync: createSessionSyncLocal() },
|
|
395
|
-
// A9 尾批:提交/续跑「腿」只装引用(实现仍在本闭包里,逐字未动)——tasks/runs 共用 prepareSpec,
|
|
396
|
-
// approvals/assistant/notify-wake 共用 resume 家族;走这一格才不会造出 routes/* 互相值 import 的环。
|
|
397
233
|
legs: { prepareSpec, finalizeTaskResult, resumeCheckpoint, driveResumeIntoRunLog, resumePreempted, resumeWake, resumePlanReview },
|
|
398
234
|
};
|
|
399
235
|
async function handle(req, res) {
|
|
400
236
|
const startedAt = Date.now();
|
|
401
237
|
const method = req.method ?? "GET";
|
|
402
238
|
const url = (req.url ?? "/").split("?")[0];
|
|
403
|
-
noteActivity(url);
|
|
404
|
-
// 修1(三路复审 B3 HIGH):admission-time inflight registration — SYNCHRONOUS, before the first await, so a
|
|
405
|
-
// request still in its pre-registration phase (auth/prepareSpec/lease/claim) is already visible to
|
|
406
|
-
// drainState.inflight(). Released exactly once in `record` below (finish/close latch — the same lifecycle
|
|
407
|
-
// that covers aborted SSE streams). Probes (/health, /metrics*) and CORS preflight stay uncounted (constant
|
|
408
|
-
// sampling noise must not hold the SIGHUP idle window open).
|
|
239
|
+
noteActivity(url);
|
|
409
240
|
const admitted = method !== "OPTIONS" && url !== "/health" && !url.startsWith("/metrics");
|
|
410
241
|
if (admitted)
|
|
411
242
|
counters.admittedInflight++;
|
|
412
|
-
// Record on whichever of finish/close fires FIRST (a `logged` latch prevents double-count): a client that
|
|
413
|
-
// aborts (esp. an SSE stream) fires `close` WITHOUT `finish`, so a finish-only handler never counts it —
|
|
414
|
-
// exactly the abnormal traffic most worth observing. An abort mid-response is logged as 499 (client closed).
|
|
415
243
|
let logged = false;
|
|
416
|
-
// [854]⑥ 纯观测:tasks/stream 断连(499)日志此前无 task 上下文,排查「谁的流断了」只能对时间戳。
|
|
417
|
-
// 流路由把 X-Task-Id(earlyDurableTid)stash 到这两个 handle 作用域变量;record 只在 499 行带上——
|
|
418
|
-
// 不动计费/inflight(admittedInflight 的 latch 语义原样)。detached 标记见 [854]①b(断连宽限 opt-in)。
|
|
419
|
-
// design/158 A9:每请求局部量收进 reqState(域模块经 ctx.req 读写同一个盒子),ctx = 服务级四格 + 本格。
|
|
420
244
|
const reqState = { streamTaskId: undefined, streamDetached: false, source: null };
|
|
421
245
|
const ctx = { deps: routeCtxBase.deps, registry: routeCtxBase.registry, helpers: routeCtxBase.helpers, local: routeCtxBase.local, legs: routeCtxBase.legs, req: reqState };
|
|
422
246
|
const record = (viaClose) => {
|
|
@@ -424,7 +248,7 @@ export function createHttpServer(rawDeps) {
|
|
|
424
248
|
return;
|
|
425
249
|
logged = true;
|
|
426
250
|
if (admitted)
|
|
427
|
-
counters.admittedInflight--;
|
|
251
|
+
counters.admittedInflight--;
|
|
428
252
|
const route = routeLabel(method, url);
|
|
429
253
|
const seconds = (Date.now() - startedAt) / 1000;
|
|
430
254
|
const aborted = viaClose && !res.writableEnded;
|
|
@@ -443,69 +267,32 @@ export function createHttpServer(rawDeps) {
|
|
|
443
267
|
return;
|
|
444
268
|
}
|
|
445
269
|
if (req.method === "GET" && url === "/health") {
|
|
446
|
-
// Sema-registry restart signal: additive, present ONLY when a restart-to-apply slice changed
|
|
447
|
-
// since boot — so a normal worker's /health shape is unchanged. `restartRequired` is the boolean the
|
|
448
|
-
// orchestrator polls; `restart` carries which slices + the version + when it first became required.
|
|
449
270
|
const restart = deps.restartState?.();
|
|
450
|
-
// TOC integration (#1 /health contract — INTEGRATION-DESIGN): identity fields so the shell's Lifecycle
|
|
451
|
-
// Manager can verify it connected to the RIGHT engine (instance + data dir + config fingerprint + pid/port), not
|
|
452
|
-
// a stale/wrong one. configHash = a STABLE fingerprint of the restart-relevant config shape (NO secrets — only
|
|
453
|
-
// model id / backends / data root / provider / port), so the shell detects a config drift without reading values.
|
|
454
271
|
const configHash = createHash("sha256")
|
|
455
272
|
.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 }))
|
|
456
273
|
.digest("hex")
|
|
457
274
|
.slice(0, 16);
|
|
458
275
|
sendJson(res, 200, {
|
|
459
276
|
status: "ok",
|
|
460
|
-
// build self-description — "did the restart actually pick up new code" judgement for the
|
|
461
|
-
// shell/orchestrator (same source as GET /v1/capabilities.version; absent only if capabilities wiring is).
|
|
462
277
|
...(typeof deps.capabilities?.version === "string" ? { version: deps.capabilities.version } : {}),
|
|
463
|
-
// additive draining flag — k8s readiness / shell Lifecycle Manager摘流 signal. Absent when not draining.
|
|
464
|
-
// #291:drainReason = POST /v1/admin/drain 声明的停机因由(additive,未声明=键缺席)。
|
|
465
278
|
...(deps.drainState?.draining ? { draining: true, ...(deps.drainState.since ? { drainingSince: deps.drainState.since } : {}), ...(deps.drainState.reason ? { drainReason: deps.drainState.reason } : {}) } : {}),
|
|
466
|
-
// #320②([4728] 五点令):in-flight 腿数**恒报**(不只 draining 时)——排空观测「还剩几条腿」
|
|
467
|
-
// 与日常容量观察同一个键;additive,getter 未接线(drainState 缺席部署)= 键缺席形状不变。
|
|
468
|
-
// 值取 createServer 自赋的同一只 getter(durable bg/resume inflightRuns ∪ live 流 steerableRuns
|
|
469
|
-
// ∪ admission 高水位,见 drainState.inflight 契约注)——不另立第二判据。
|
|
470
279
|
...(deps.drainState?.inflight ? { inflight: deps.drainState.inflight() } : {}),
|
|
471
|
-
// S5: additive degradation flag — present ONLY when the boot auto-probe fell back to in-memory, so a
|
|
472
|
-
// healthy worker's /health shape is unchanged. durable:false = "this replica is NOT persisting although
|
|
473
|
-
// a DB was configured" (the orchestrator's restart/alarm signal; an in-memory-by-choice worker omits it).
|
|
474
280
|
...(deps.storeDegraded ? { durable: false, storeDegraded: true } : {}),
|
|
475
|
-
// boot ready 门(b):additive — absent = ready(正常形状不变);false = roster 未落地,
|
|
476
|
-
// 计费提交在 503(orchestrator/壳读此旗决定摘流/重试;编排器侧「pull 成功才 ready」增强不互斥)。
|
|
477
281
|
...(deps.modelReady && !deps.modelReady() ? { ready: false, readyReason: "model roster pending (first effective-config pull)" } : {}),
|
|
478
282
|
model: deps.config.model.id,
|
|
479
|
-
sessionBackend: deps.sessionStoreLabel ?? deps.config.sessionBackend,
|
|
283
|
+
sessionBackend: deps.sessionStoreLabel ?? deps.config.sessionBackend,
|
|
480
284
|
sessions: deps.runner.sessions.size,
|
|
481
285
|
...(deps.instanceId ? { instanceId: deps.instanceId } : {}),
|
|
482
|
-
// 🔴 `dataRoot`/`pid` 是**公开承诺字段**(2026-07-28 起,USAGE「嵌入形契约」段):桌面宿主的
|
|
483
|
-
// 引擎身份验证(「这个 /health 是不是我起的那个进程」)整个押在这两个键上——pid 必须等于所
|
|
484
|
-
// spawn 子进程、dataRoot 必须等于所配数据根,缺任一=判外来。localDataRoot 的解析恒回退
|
|
485
|
-
// `~/.ai-agent`(config.ts,与 DB_BACKEND 无关)⇒ 此条件 spread 实为恒真;改动这两个键的
|
|
486
|
-
// 在场性/语义前先看 test/health-identity-contract.test.ts。
|
|
487
286
|
...(deps.config.localDataRoot ? { dataRoot: deps.config.localDataRoot } : {}),
|
|
488
287
|
configHash,
|
|
489
288
|
pid: process.pid,
|
|
490
289
|
port: deps.config.port,
|
|
491
|
-
// D-G: a POSITIVE go-live signal that this worker is a crypto-bound direct-door worker (trusted-header
|
|
492
|
-
// path disabled) — additive, only present when active, so a normal worker's /health shape is unchanged.
|
|
493
|
-
// Surfaced for the DG-M2 go-live runbook (was previously inferrable only behaviorally via a 401).
|
|
494
290
|
...(deps.config.directDoorActive ? { directDoorActive: true } : {}),
|
|
495
291
|
...(restart ? { restartRequired: true, restart: { reasons: restart.reasons, version: restart.version, since: restart.since } } : {}),
|
|
496
|
-
// codex R18 (models-tiers deferral): additive DEGRADED state — the model plane is deferred but no
|
|
497
|
-
// next-boot handoff exists (LKG not restart-surviving), so no restart is forced and the plane stays
|
|
498
|
-
// unapplied. Present ⇒ the operator must either mount restart-surviving LKG storage (+ set
|
|
499
|
-
// CONFIG_LKG_DURABLE) or restart manually after ensuring the candidate will be re-pulled at boot.
|
|
500
292
|
...(() => {
|
|
501
293
|
const stuck = deps.planeDeferredState?.();
|
|
502
294
|
return stuck ? { modelPlaneDeferred: { version: stuck.version, since: stuck.since, noHandoff: true, ...(stuck.blocked ? { blockedReasons: stuck.blocked } : {}) } } : {};
|
|
503
295
|
})(),
|
|
504
|
-
// #322 世代账三键(additive;未接线 ⇒ 键全缺席,/health 形状逐字不变)。
|
|
505
|
-
// 语义:target = 本副本**最后**见到的源版本(身份标);applied = 最后一次真的 commit 的版本;
|
|
506
|
-
// staleMs = 两者**持续**不等的时长(收敛即缺席)。#325 起 additive 补两 ordinal 键(收货序数,
|
|
507
|
-
// 进程内单调)——local 腿 version 是内容哈希无序承诺,「见过多少代/停在哪一代」的推进判断
|
|
508
|
-
// 用 ordinal 键;跨副本「同不同代」的对账仍用 version 身份标。
|
|
509
296
|
...(() => {
|
|
510
297
|
const gen = deps.configApplyState?.();
|
|
511
298
|
if (!gen)
|
|
@@ -518,25 +305,18 @@ export function createHttpServer(rawDeps) {
|
|
|
518
305
|
...(gen.configApplyStaleMs !== undefined ? { configApplyStaleMs: gen.configApplyStaleMs } : {}),
|
|
519
306
|
};
|
|
520
307
|
})(),
|
|
521
|
-
// B1 活体 store 探测(座注见 storeLiveState 声明;钉 test/health-store-live.test.ts)
|
|
522
308
|
...(() => {
|
|
523
309
|
const probe = deps.storeLiveState?.();
|
|
524
310
|
if (!probe)
|
|
525
311
|
return {};
|
|
526
312
|
return {
|
|
527
313
|
...(probe.live ? {} : { storeLive: false }),
|
|
528
|
-
// #305:error 过闭集词表 —— 这一整个响应体在鉴权门**之前**发出,而座席给的是驱动原始
|
|
529
|
-
// message(DSN/主机/账号常在其中)。消毒钉在**这个 wire 装配点**而不是探针环里:座席是
|
|
530
|
-
// 可注入接缝,只有边界上的这一道对**任何**生产者成立(fail-closed 方向)。原文留日志轴。
|
|
531
314
|
storeProbe: { live: probe.live, ageMs: probe.ageMs, ...(probe.error !== undefined ? { error: publicStoreProbeError(probe.error) } : {}) },
|
|
532
315
|
};
|
|
533
316
|
})(),
|
|
534
317
|
});
|
|
535
318
|
return;
|
|
536
319
|
}
|
|
537
|
-
// Metrics (read-only): authorized by EITHER the full authToken OR a read-only metricsToken — so a
|
|
538
|
-
// control plane (config-center) can pull metrics fleet-wide with one token, never holding each
|
|
539
|
-
// worker's full authToken. Handled BEFORE the global gate so metricsToken-only callers aren't 401'd.
|
|
540
320
|
if (req.method === "GET" && (url === "/metrics" || url === "/metrics/summary" || url === "/metrics/plan-cache")) {
|
|
541
321
|
if (!deps.metrics) {
|
|
542
322
|
sendError(res, 404, "feature.metrics_disabled", "metrics disabled");
|
|
@@ -544,10 +324,6 @@ export function createHttpServer(rawDeps) {
|
|
|
544
324
|
}
|
|
545
325
|
const { authToken: t, metricsToken: mt } = deps.config;
|
|
546
326
|
const anyT = Boolean(t) || Object.keys(deps.config.authTokens ?? {}).length > 0;
|
|
547
|
-
// 🔴 第一臂的 `!requirePrincipal` 是 2026-07-31 缝合审补的:同族单源 `isFleetWide` 早就带着它
|
|
548
|
-
// (连同那段「多租户下 token 的缺席不得开放全网」的审计注释),这一处停在了旧形。要紧在于
|
|
549
|
-
// `/metrics/plan-cache` 的 dump **按 principal 分键** ⇒ 它是租户名册而不是聚合数;而「多租 + 无
|
|
550
|
-
// service token + 无 metrics token」并不是 boot 会拒的形(main.ts 只 warn)。dev-open 只在单用户形成立。
|
|
551
327
|
const devOpen = !anyT && !mt && !deps.config.requirePrincipal;
|
|
552
328
|
const ok = devOpen || (anyT && systemFor(req, deps.config) !== undefined) || (!!mt && authorized(req, mt));
|
|
553
329
|
if (!ok) {
|
|
@@ -558,7 +334,6 @@ export function createHttpServer(rawDeps) {
|
|
|
558
334
|
sendJson(res, 200, { model: deps.config.model.id, ...deps.metrics.summarize() });
|
|
559
335
|
return;
|
|
560
336
|
}
|
|
561
|
-
// Per-scope objective-recurrence detail (core design/42 handoff). Same token gate as /metrics.
|
|
562
337
|
if (url === "/metrics/plan-cache") {
|
|
563
338
|
sendJson(res, 200, { scopes: deps.planCacheProbe?.dump() ?? {} });
|
|
564
339
|
return;
|
|
@@ -567,14 +342,7 @@ export function createHttpServer(rawDeps) {
|
|
|
567
342
|
res.end(deps.metrics.render());
|
|
568
343
|
return;
|
|
569
344
|
}
|
|
570
|
-
// 鉴权桥预认证:在任何 principal-gated 路由(trace/fleet/workflows/authorizer)读取身份之【前】,
|
|
571
|
-
// 若 Authorization bearer 形似 registry-JWT 且 verifier 在位,验它——通过则把 sub 挂进 request-local
|
|
572
|
-
// (setSsoPrincipal),verifiedPrincipal 单一真源随后优先读它(directDoor 与 BFF 两部署同效),并同时写入
|
|
573
|
-
// principalHeader(小写键)供 principalFrom 直读处使用。验证不通过=不设身份,后续 systemFor 因 bearer 不匹配
|
|
574
|
-
// 任何静态 token 而 401(恒 generic,R9:错误码只进日志)。非 JWT-形 bearer(静态 token)= looksLikeJwt
|
|
575
|
-
// 跳过,零开销,既有调用方字节不变。
|
|
576
345
|
if (deps.registryJwtVerifier) {
|
|
577
|
-
// 只认 Bearer scheme(异源复审 INFO:`Basic a.b.c` 之前会白跑一次 verify + 一条 rejected 记账噪声)。
|
|
578
346
|
const authz = req.headers.authorization ?? "";
|
|
579
347
|
const rawBearer = /^Bearer\s/i.test(authz) ? authz.replace(/^Bearer\s+/i, "") : "";
|
|
580
348
|
if (rawBearer && looksLikeJwt(rawBearer)) {
|
|
@@ -582,41 +350,25 @@ export function createHttpServer(rawDeps) {
|
|
|
582
350
|
if (v.ok) {
|
|
583
351
|
setSsoPrincipal(req, v.identity.principal);
|
|
584
352
|
req.headers[deps.config.principalHeader.toLowerCase()] = v.identity.principal;
|
|
585
|
-
// scope claim 也挂 request-local(多租户活动租户;registry-core 0.3.0+ VerifiedIdentity.scope)。
|
|
586
|
-
// 用途=审计归因 + 未来 scope 级 quota/过滤的读取地基。同时记一条 info 审计锚(每认证请求一条):
|
|
587
|
-
// 此前 principal 入了 owner 但 scope 全程不落任何日志/审计(grep 实证零命中 scope)——补齐。
|
|
588
353
|
if (v.identity.scope)
|
|
589
354
|
setSsoScope(req, v.identity.scope);
|
|
590
355
|
deps.logger?.info?.("auth_bridge_principal_accepted", { principal: v.identity.principal, ...(v.identity.scope ? { scope: v.identity.scope } : {}) });
|
|
591
356
|
}
|
|
592
357
|
}
|
|
593
358
|
}
|
|
594
|
-
// design/158 A9 域模块:Task Trace 只读面 + 用量统计面(routes/trace-usage.ts)。
|
|
595
|
-
// ⚠️ 位置即契约:必须留在下面的全局 service-credential 门**之前**(traceToken-only 调用方否则先被 401)。
|
|
596
359
|
if (await handleTraceUsage(req, res, url, ctx))
|
|
597
360
|
return;
|
|
598
|
-
// design/158 A9 域模块:S8 自编排 workflow 只读三路(routes/workflows.ts)。
|
|
599
|
-
// 位置同 trace/fleet:全局 service-credential 门之前,块内 scope 门=这些读的唯一租户边界。
|
|
600
361
|
if (await handleWorkflows(req, res, url, ctx))
|
|
601
362
|
return;
|
|
602
|
-
// design/158 A9 域模块:MF-Fleet 实时舰队流(routes/fleet.ts)。服务凭据门之前,按 principal 自门。
|
|
603
363
|
if (await handleFleet(req, res, url, ctx))
|
|
604
364
|
return;
|
|
605
|
-
// DESIGN-269 车2 件1 域模块:A2A agent card(routes/a2a-serve.ts)。
|
|
606
|
-
// ⚠️ 位置即契约:必须留在下面的全局 service-credential 门**之前** —— 卡是 R-11 裁定的「公网匿名
|
|
607
|
-
// 可发现」面(挂在门后=要凭据才能发现,A2A 协商第一步就断)。它**零存量披露**(纯 config 投影,
|
|
608
|
-
// 不读任何 task/run/session),所以匿名可读不构成披露;同域的 JSON-RPC 调用面在门**内**(下方)。
|
|
609
365
|
if (await handleA2aServe(req, res, url, ctx))
|
|
610
366
|
return;
|
|
611
|
-
// Service-credential gate + SOURCE derivation: which system's token authenticated this request.
|
|
612
|
-
// `source` is set ONLY here, from the credential — a caller cannot claim another system's identity.
|
|
613
367
|
let source = null;
|
|
614
|
-
const anyServiceAuth = hasServiceAuth(deps.config);
|
|
368
|
+
const anyServiceAuth = hasServiceAuth(deps.config);
|
|
615
369
|
if (anyServiceAuth) {
|
|
616
370
|
const sys = systemFor(req, deps.config);
|
|
617
371
|
if (sys === undefined) {
|
|
618
|
-
// 鉴权桥:静态 service-token 都不匹配 → 若入口预认证已把一个验过的 registry-JWT principal
|
|
619
|
-
// 挂进 request-local(ssoVerifiedPrincipal),即用户直连(source="sso");否则无凭据 401。
|
|
620
372
|
if (ssoVerifiedPrincipal(req)) {
|
|
621
373
|
source = "sso";
|
|
622
374
|
}
|
|
@@ -629,22 +381,7 @@ export function createHttpServer(rawDeps) {
|
|
|
629
381
|
source = sys;
|
|
630
382
|
}
|
|
631
383
|
}
|
|
632
|
-
// design/158 A9:`source` 的**唯一**赋值段就在上面;域模块经 ctx.req.source 读到同一个值(单向镜像,
|
|
633
|
-
// 此行之后 handle() 不再改它)。
|
|
634
384
|
reqState.source = source;
|
|
635
|
-
// Mandatory service token on submission endpoints (center: a worker MUST validate a service-to-service
|
|
636
|
-
// token on task creation — not "if auth is configured" — else anything in-cluster can submit & bill
|
|
637
|
-
// directly, bypassing config-center's auth/audit). Fail-closed: the POST task/run/leader endpoints refuse
|
|
638
|
-
// when no authToken is set, unless explicitly opted out for local dev (ALLOW_UNAUTHED_WRITES=true).
|
|
639
|
-
// The bake door (POST /v1/images/bakes*) is build-host-RCE-capable and authed by the Bearer-token-no-cookie
|
|
640
|
-
// model (§P2.4b) — it MUST also refuse when no service token is configured (else a forged principal header
|
|
641
|
-
// alone could reach an operator-gated RCE door).
|
|
642
|
-
// A-010.23 (#209 件5) adds the same sibling arm for the REWRITE doors (`isCredentialGatedRewrite`:
|
|
643
|
-
// adoption + rules/cc-import + rules revoke) — same sentence, same threat, different verbs: one rekeys every
|
|
644
|
-
// row a principal owns, one mints that principal's DURABLE allow rules, one deletes them. They stay OUT of
|
|
645
|
-
// `isBillableSubmitPath` on purpose (billable=false; see that predicate's twin comment), and that family is
|
|
646
|
-
// METHOD-AWARE (its revoke verb is DELETE) so it is evaluated OUTSIDE the POST conjunct — same shape as
|
|
647
|
-
// `isDestructiveSessionWrite` below.
|
|
648
385
|
if (!anyServiceAuth &&
|
|
649
386
|
!deps.config.allowUnauthedWrites &&
|
|
650
387
|
((req.method === "POST" && (isBillableSubmitPath(url) || url.startsWith("/v1/images/bakes"))) ||
|
|
@@ -652,190 +389,99 @@ export function createHttpServer(rawDeps) {
|
|
|
652
389
|
sendError(res, 503, "auth.service_token_required", "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting task submissions");
|
|
653
390
|
return;
|
|
654
391
|
}
|
|
655
|
-
// DRAINING — this instance is shutting down gracefully (SIGTERM received, in-flight turns being
|
|
656
|
-
// waited on). Refuse NEW model-running work (the same billable submit set: task/stream/runs/leader + the
|
|
657
|
-
// resume-flavored assistant verbs + approvals responds — each starts a model turn) with 503 + Retry-After so
|
|
658
|
-
// the shell/orchestrator retries against the replacement. Everything else stays open: reads (events/health/
|
|
659
|
-
// trace), cancel, steer/respond on in-flight runs — those help the drained turns finish, they don't start new ones.
|
|
660
392
|
if (deps.drainState?.draining && req.method === "POST" && isBillableSubmitPath(url) && !isMethodDispatchedSubmitPath(url)) {
|
|
661
393
|
res.setHeader("retry-after", "15");
|
|
662
|
-
// errorCode 补齐(2026-07-28 核查:cli 的 pre-stream drain 重试只能靠 `error` 文案字面判型——
|
|
663
|
-
// SDK 的 code 臂恒 undefined 是死码;server 改文案=对端重试静默失效)。`error:"draining"` 字面
|
|
664
|
-
// 自此视为**冻结的 wire 契约**(旧消费端判的就是它),errorCode 是机器码正门。
|
|
665
|
-
// #291:`reason` = 声明的停机因由(additive 键;`message` 字面冻结不动——SDK/壳按 error:"draining" 判)。
|
|
666
394
|
sendError(res, 503, "draining", "draining", { message: "this instance is draining for shutdown/upgrade — retry against the replacement instance", ...(deps.drainState.reason ? { reason: deps.drainState.reason } : {}) });
|
|
667
395
|
return;
|
|
668
396
|
}
|
|
669
|
-
// boot ready 门(b)(E3 workers.model 废除的 fail-closed 半场):registry 部署且无显式 env 模型时,
|
|
670
|
-
// 首次 effective pull 落 roster 之前 worker 只有占位模型——计费提交打占位=垃圾任务,503 让编排器/壳重试
|
|
671
|
-
// 到 roster 落地(通常 <秒)。镜像 draining 姿势(同 billable 集合;读面/cancel/steer 全开);/health 带
|
|
672
|
-
// ready:false 加性旗。env 模型在(操作员显式声明)或非 registry 部署=恒 ready,现有部署零影响。
|
|
673
397
|
if (deps.modelReady && !deps.modelReady() && req.method === "POST" && isBillableSubmitPath(url) && !isMethodDispatchedSubmitPath(url)) {
|
|
674
398
|
res.setHeader("retry-after", "5");
|
|
675
399
|
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" });
|
|
676
400
|
return;
|
|
677
401
|
}
|
|
678
|
-
// E6 fail-closed:对**既有会话**的破坏性写,在「无 service token + 未开 ALLOW_UNAUTHED_WRITES」
|
|
679
|
-
// 这一形下一律拒。此形下 `sessionOwnerScopeForWrite` 判 fleet-wide ⇒ 集群内任意调用方可对任意
|
|
680
|
-
// session 动手;policy 腿担心的是「forged principal 能 tighten/DoS 一个会话的工具」。
|
|
681
|
-
//
|
|
682
|
-
// 🔴 2026-08-01 复审:这里原本是**逐条路由白名单**(PUT …/policy 与 POST …/sync/import 两块),
|
|
683
|
-
// 而 `DELETE /v1/sessions/:id`(删整条会话)**不在其中** —— 同一威胁形、爆炸半径更大却没门。
|
|
684
|
-
// 改成族判定(`isDestructiveSessionWrite`),新增破坏性会话路由自动落进门内。
|
|
685
|
-
// import 腿仍拦在 **Phase A**(建 staging 之前),因为族判据匹配的就是那个 URL,不是 Phase B。
|
|
686
402
|
if (isDestructiveSessionWrite(req.method ?? "", url) && !anyServiceAuth && !deps.config.allowUnauthedWrites) {
|
|
687
403
|
sendError(res, 503, "auth.service_token_required", "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting destructive session writes (policy / sync-import / fork / delete)");
|
|
688
404
|
return;
|
|
689
405
|
}
|
|
690
|
-
// design/158 A9:能力/场景/模型目录域(routes/capabilities.ts)。位置=全局 service-credential 门之后、
|
|
691
|
-
// 任务提交面之前,与拆分前逐行同序。
|
|
692
406
|
if (await handleCapabilities(req, res, url, ctx))
|
|
693
407
|
return;
|
|
694
|
-
// design/158 A9 域模块:sideQuery 一次性问答面(routes/side-query.ts)。
|
|
695
408
|
if (await handleSideQuery(req, res, url, ctx))
|
|
696
409
|
return;
|
|
697
|
-
// design/158 A9 域模块:同步提交面 POST /v1/tasks · /v1/tasks/stream(routes/tasks.ts;B3 三腿具名化在内)。
|
|
698
410
|
if (await handleTasks(req, res, url, ctx))
|
|
699
411
|
return;
|
|
700
|
-
// design/158 A9 域模块:异步 run 面 + run 级动词(routes/runs.ts)。与 tasks 域共用 ctx.legs.prepareSpec。
|
|
701
412
|
if (await handleRuns(req, res, url, ctx))
|
|
702
413
|
return;
|
|
703
|
-
// DESIGN-269 车2 件2 域模块:A2A JSON-RPC 端点 POST /v1/a2a(routes/a2a-serve.ts)。
|
|
704
|
-
// ⚠️ 位置即契约:服务凭据门**之后**(R-11「持凭据才可调」),且在 drain / model-roster-pending 两道
|
|
705
|
-
// 503 之后 —— `/v1/a2a` 在 isBillableSubmitPath 里(message/send 会烧模型)。排在 handleRuns 之后是
|
|
706
|
-
// 因为它**复用** runs 域导出的提交腿(createDurableRun):次序上先让原生 run 面各就各位,再挂它的
|
|
707
|
-
// 协议门面,评审读这张链时的因果方向才与实现一致。
|
|
708
414
|
if (await handleA2a(req, res, url, ctx))
|
|
709
415
|
return;
|
|
710
|
-
// design/158 A9 域模块:SVC-5 workflow agent steer(routes/workflows.ts,与上面的只读三路同域)。
|
|
711
416
|
if (await handleWorkflowAgentSteer(req, res, url, ctx))
|
|
712
417
|
return;
|
|
713
|
-
// design/158 A9 域模块:子代理 / 任务句柄 / 三条 HITL 应答口(routes/runs.ts,与上面的 run 面同域)。
|
|
714
418
|
if (await handleRunVerbs(req, res, url, ctx))
|
|
715
419
|
return;
|
|
716
|
-
// design/158 A9 域模块:v2 leader 端点(routes/leader.ts)。
|
|
717
420
|
if (await handleLeader(req, res, url, ctx))
|
|
718
421
|
return;
|
|
719
|
-
// design/158 A9:sandbox-image-pool 控制面(routes/images.ts;bakes 段在 images 段之前的顺序由域模块内部保住)。
|
|
720
422
|
if (await handleImages(req, res, url, ctx))
|
|
721
423
|
return;
|
|
722
|
-
// design/158 A9 域模块:人在环审批面(durable F4 + legacy)与 assistant-scheduler 三动词
|
|
723
|
-
// (routes/approvals-assistant.ts)。续跑一律经 ctx.legs 的 resume 家族。
|
|
724
424
|
if (await handleApprovalsAssistant(req, res, url, ctx))
|
|
725
425
|
return;
|
|
726
|
-
// design/158 A9:观测只读面(routes/observability.ts):/v1/outcomes + /v1/usage。
|
|
727
426
|
if (await handleObservability(req, res, url, ctx))
|
|
728
427
|
return;
|
|
729
|
-
// #154 件三:装配自证只读面(routes/diagnostics.ts):GET /v1/diagnostics/wiring(operator-only)。
|
|
730
428
|
if (await handleDiagnostics(req, res, url, ctx))
|
|
731
429
|
return;
|
|
732
|
-
// design/183 §4.3:收编面(routes/adoption.ts,operator lane;POST /v1/adoption + GET /v1/adoption/:id)。
|
|
733
430
|
if (await handleAdoption(req, res, url, ctx))
|
|
734
431
|
return;
|
|
735
|
-
// #270 车2:托管留存的 operator 治理面(routes/retention-ops.ts,operator lane;hold PUT/DELETE +
|
|
736
|
-
// 审计读)。位置跟着 adoption —— 同族(部署级 operator 动作、同一条 `explicitOperatorOk` 门、
|
|
737
|
-
// 同样 billable=false 且刻意不吃 drain / model-roster-pending 两道 503)。
|
|
738
432
|
if (await handleRetention(req, res, url, ctx))
|
|
739
433
|
return;
|
|
740
|
-
// #291(C17):停机因由声明口(routes/admin-drain.ts,operator lane;POST /v1/admin/drain)。
|
|
741
|
-
// 同族(operator 门、billable=false、不吃 drain 503——它正是 drain 前要调的口),位置跟着 retention。
|
|
742
434
|
if (await handleAdminDrain(req, res, url, ctx))
|
|
743
435
|
return;
|
|
744
|
-
// #322 批1 追加件:配置手动刷新口(routes/admin-config-refresh.ts,operator lane;POST
|
|
745
|
-
// /v1/admin/config/refresh)。与 drain 同族同门同 billable 姿势,位置紧跟它。
|
|
746
436
|
if (await handleAdminConfigRefresh(req, res, url, ctx))
|
|
747
437
|
return;
|
|
748
|
-
// design/158 A9:memory 导出/同步 + /v1/policy 只读面(routes/memory-policy.ts)。
|
|
749
438
|
if (await handleMemoryPolicy(req, res, url, ctx))
|
|
750
439
|
return;
|
|
751
|
-
// #264 v2-c:治理携出 bundle 的 operator 面(routes/memory-bundle.ts;POST 两口)。
|
|
752
|
-
// 🔴 位置在 memory-policy **之后**是有意的:`/v1/memory/export` 这条路径上住着两个不同的面 ——
|
|
753
|
-
// GET = 142-S5 的属主自助条目读(owner 门,零存在性 oracle),POST = 本件的治理包(operator-only)。
|
|
754
|
-
// 两者按**方法**互斥,谁先谁后都不改判;把既有的 GET 面留在前面,是让新面在任何情况下都不可能
|
|
755
|
-
// 遮住它(本模块只认 POST,一个字都不碰 GET 的匹配式)。族属上它与 adoption / retention-ops 同类:
|
|
756
|
-
// 部署级 operator 动作、同一条 explicitOperatorOk 门、同样 billable=false。
|
|
757
440
|
if (await handleMemoryBundle(req, res, url, ctx))
|
|
758
441
|
return;
|
|
759
|
-
// #154 车二:CC settings 导入两口(lane=principal,billable=false)。
|
|
760
442
|
if (await handleRules(req, res, url, ctx))
|
|
761
443
|
return;
|
|
762
|
-
// design/177 共享记忆库只读面(routes/shared-memory.ts)。🔴 域头的合取式**就是**挂载条件:供给面
|
|
763
|
-
// 与 org 折叠面双在场才有这个域,缺一个则请求落到本函数尾的全局 404(诚实缺席,不是 501)。
|
|
764
|
-
// `capabilities.sharedMemory` 读同一个合取式 ——「说 yes ⟺ 面真能用」因此是结构性的,不靠人盯。
|
|
765
444
|
if (deps.sharedMemoryStore && deps.orgMemoryDirectory && (await handleSharedMemory(req, res, url, ctx)))
|
|
766
445
|
return;
|
|
767
|
-
// design/158 A9:会话/发件链接列表面(routes/sessions-list.ts)。
|
|
768
446
|
if (await handleSessionsList(req, res, url, ctx))
|
|
769
447
|
return;
|
|
770
|
-
// #A1([4147] 件3):后台 agent 名册读面(routes/agents-roster.ts)。位置跟着 sessions-list —— 同一族
|
|
771
|
-
// (租户只读列举面、同一只 `sessionOwnerScope` 门、同样在全局服务凭据门之后)。
|
|
772
448
|
if (await handleAgentRoster(req, res, url, ctx))
|
|
773
449
|
return;
|
|
774
|
-
// design/158 A9:单会话面(routes/sessions.ts:init/settings/mcp/head/events/audit/fork/delete/policy/workspace)。
|
|
775
450
|
if (await handleSessions(req, res, url, ctx))
|
|
776
451
|
return;
|
|
777
|
-
// design/158 A9:2c session-sync 面(routes/session-sync.ts)。
|
|
778
452
|
if (await handleSessionSync(req, res, url, ctx))
|
|
779
453
|
return;
|
|
780
|
-
// design/158 A9:D-1 通用附件面(routes/attachments.ts)。
|
|
781
454
|
if (await handleAttachments(req, res, url, ctx))
|
|
782
455
|
return;
|
|
783
|
-
// design/158 A9 域模块:会话外事件注入 / 唤醒(routes/notify-wake.ts)。
|
|
784
456
|
if (await handleNotifyWake(req, res, url, ctx))
|
|
785
457
|
return;
|
|
786
458
|
sendError(res, 404, "not_found.route", "not found");
|
|
787
459
|
}
|
|
788
|
-
/** #196:本任务跑哪只 Runner —— 由装配点按场景 hands 判别位裁定(main.ts 的 `runnerFor` 闭包)。
|
|
789
|
-
* seam 缺席 = 单 Runner 部署形(#196 之前的行为逐字不变),不是降级臂。 */
|
|
790
460
|
function runnerFor(spec) {
|
|
791
461
|
return deps.runnerFor?.(spec) ?? deps.runner;
|
|
792
462
|
}
|
|
793
|
-
/** Read + validate the body, authorize, and build the spec. Returns null if it already responded.
|
|
794
|
-
*
|
|
795
|
-
* `bodyIn` (DESIGN-269 车2):调用方**已经**把体解析出来了(A2A 的 JSON-RPC 腿 —— 那条请求的正文是
|
|
796
|
-
* 一次 `message/send`,腿把它翻成 `TaskRequestBody` 再进这条链)。缺席 ⇒ 照旧 `readJson(req)`。
|
|
797
|
-
* 分歧只在**入口**:往下的每一条校验/授权/resolveSpec 都是同一段代码,这正是本参数的全部意义
|
|
798
|
-
* (给 A2A 抄一份校验 = 抄一份会漂的 400 语义)。 */
|
|
799
463
|
async function prepareSpec(req, res, bodyIn, onTypedFailure) {
|
|
800
464
|
const body = bodyIn ?? (await readJson(req));
|
|
801
465
|
if (!body || typeof body.objective !== "string") {
|
|
802
466
|
sendError(res, 400, "request.field_invalid", "missing 'objective' string");
|
|
803
467
|
return null;
|
|
804
468
|
}
|
|
805
|
-
// [920] 空毒帧第一道门:空/纯空白 objective 会成为持久历史里的空 user 帧——严格网关(anthropic/kimi)
|
|
806
|
-
// 对空 content 400,且一帧毒全 session(同角色 merge 后残留空 block,存量救不了)。core 半场(装配
|
|
807
|
-
// 自愈+intake 拒)在修;HTTP 面 fail-loud 是宪法「诚实优先」形:空任务无合法用例,静默吞掉才是坑。
|
|
808
469
|
if (body.objective.trim().length === 0) {
|
|
809
470
|
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)");
|
|
810
471
|
return null;
|
|
811
472
|
}
|
|
812
|
-
// jobId (work-view correlation): optional, but if present must be a non-empty string ≤64 chars (the column
|
|
813
|
-
// width) — reject a malformed one up front rather than silently truncating/dropping it at the INSERT.
|
|
814
473
|
if (body.jobId !== undefined && (typeof body.jobId !== "string" || body.jobId.length === 0 || body.jobId.length > 64)) {
|
|
815
474
|
sendError(res, 400, "request.field_invalid", "jobId must be a non-empty string of at most 64 characters");
|
|
816
475
|
return null;
|
|
817
476
|
}
|
|
818
|
-
// [#15] sessionId: a client MAY supply an ARBITRARY id (deliberate asymmetry vs fork/delete — see security.ts
|
|
819
|
-
// `isUuidV7` doc: run-local `--session`, dev anonymous-share ids; the wire contract types sessionId as an opaque
|
|
820
|
-
// string), but it must fit the `session_id VARCHAR(64)` column. Reject an over-long one up front rather than
|
|
821
|
-
// 500/truncating at the INSERT (the authorizer's register() runs later in this function, after this block via the
|
|
822
|
-
// deps.authorize() call — so the cap fires before any INSERT). LENGTH-ONLY — NOT a uuidv7 shape
|
|
823
|
-
// check (that would break the permissive submit contract); submit stays lenient, fork/delete keep the uuidv7 gate.
|
|
824
477
|
if (typeof body.sessionId === "string" && body.sessionId.length > 64) {
|
|
825
478
|
sendError(res, 400, "request.field_invalid", "sessionId must be at most 64 characters");
|
|
826
479
|
return null;
|
|
827
480
|
}
|
|
828
|
-
// Personalization caps: once users can edit these, an uncapped prompt/skill body is a
|
|
829
|
-
// cost hole — reject up front, server-pinned (a UI may be stricter, never looser).
|
|
830
481
|
if (typeof body.systemPrompt === "string" && body.systemPrompt.length > MAX_SYSTEM_PROMPT_CHARS) {
|
|
831
482
|
sendError(res, 400, "request.field_invalid", `systemPrompt must be at most ${MAX_SYSTEM_PROMPT_CHARS} characters`);
|
|
832
483
|
return null;
|
|
833
484
|
}
|
|
834
|
-
// appendSystemPrompt ([1478] R2 + codex R7 F2): fail-loud SHAPE gate on fresh submits — a defined value must
|
|
835
|
-
// be a non-empty string within the systemPrompt cap. A non-string/empty value would be silently normalized to
|
|
836
|
-
// "absent" by resolveSpec's defensive accept (a client serialization bug then runs the task WITHOUT its
|
|
837
|
-
// product-knowledge block — the silent-drop class again); the cap matches systemPrompt (same prompt surface,
|
|
838
|
-
// same per-turn cost hole). Stored resume bodies stay on the defensive drop+warn (acceptAppendSystemPrompt).
|
|
839
485
|
if (body.appendSystemPrompt !== undefined) {
|
|
840
486
|
if (typeof body.appendSystemPrompt !== "string" || body.appendSystemPrompt.length === 0) {
|
|
841
487
|
sendError(res, 400, "request.field_invalid", "appendSystemPrompt must be a non-empty string");
|
|
@@ -846,23 +492,10 @@ export function createHttpServer(rawDeps) {
|
|
|
846
492
|
return null;
|
|
847
493
|
}
|
|
848
494
|
}
|
|
849
|
-
// codex F3/R3/R4 (capability honesty): the append-less-pack combos (rider / settings.outputStyle meeting an
|
|
850
|
-
// already-assembled systemPrompt or a center assembled-identity pack) are adjudicated INSIDE resolveSpec —
|
|
851
|
-
// it alone derives the effective scenario (principal ruling + defaultScenario) and holds the center-pack
|
|
852
|
-
// snapshot the provider pick uses, so an HTTP-side probe could disagree with the actual assembly (R4:
|
|
853
|
-
// scenario mismatch / centerPrompts refresh TOCTOU). This route passes `leg:"fresh"` below, which makes
|
|
854
|
-
// resolveSpec 400 fail-loud (HttpError, surfaced by the catch around prepareSpec's caller); resume families
|
|
855
|
-
// omit it and get the drop+warn mirror instead.
|
|
856
|
-
// [#40 / TOC cwd seam] body.cwd must be an ABSOLUTE host path — fail-loud (a relative cwd would silently resolve
|
|
857
|
-
// against the SERVICE process cwd, not the user's project — TOC review #12). Whether it is HONORED is separately
|
|
858
|
-
// gated to the single-user host lane (resolveSpec); validating shape here is independent of that gate.
|
|
859
495
|
if (body.cwd !== undefined && !isValidCwd(body.cwd)) {
|
|
860
496
|
sendError(res, 400, "request.field_invalid", "cwd must be a non-empty absolute host path");
|
|
861
497
|
return null;
|
|
862
498
|
}
|
|
863
|
-
// design/119 (CC --add-dir) body.additionalDirectories — fail-loud shape on submit (mirrors cwd; each entry is a
|
|
864
|
-
// host path with the SAME absolute/no-`..` rule). Whether it is HONORED is separately gated to the host lane
|
|
865
|
-
// (resolveSpec). An empty array is fine (no-op); a non-array or an entry failing the cwd shape is a client error.
|
|
866
499
|
const addDirsRaw = body.additionalDirectories;
|
|
867
500
|
if (addDirsRaw !== undefined) {
|
|
868
501
|
if (!Array.isArray(addDirsRaw)) {
|
|
@@ -879,8 +512,6 @@ export function createHttpServer(rawDeps) {
|
|
|
879
512
|
return null;
|
|
880
513
|
}
|
|
881
514
|
}
|
|
882
|
-
// core 5.11.0:additionalReadDirectories(只读围栏)—— 与 additionalDirectories 同形 fail-loud
|
|
883
|
-
// (绝对路径数组、无 `..`、同条数上限);语义差异(只进 READ 面)在 core,形校验在此对齐。
|
|
884
515
|
const addReadDirsRaw = body.additionalReadDirectories;
|
|
885
516
|
if (addReadDirsRaw !== undefined) {
|
|
886
517
|
if (!Array.isArray(addReadDirsRaw)) {
|
|
@@ -897,9 +528,6 @@ export function createHttpServer(rawDeps) {
|
|
|
897
528
|
return null;
|
|
898
529
|
}
|
|
899
530
|
}
|
|
900
|
-
// body.settings (client SemaSettings stamp) — fail-loud shape/size caps on submit, matching the sibling
|
|
901
|
-
// fields' posture (parseTaskSettings is also defensive on every path incl. resume; this 400s a bad submit early
|
|
902
|
-
// rather than silently coercing — adversarial review). The semantic projection (tighten-only) happens in resolveSpec.
|
|
903
531
|
const settingsRaw = body.settings;
|
|
904
532
|
if (settingsRaw !== undefined && settingsRaw !== null) {
|
|
905
533
|
if (typeof settingsRaw !== "object" || Array.isArray(settingsRaw)) {
|
|
@@ -911,8 +539,6 @@ export function createHttpServer(rawDeps) {
|
|
|
911
539
|
sendError(res, 400, "request.field_invalid", `settings.outputStyle must be at most ${MAX_SETTINGS_OUTPUT_STYLE_CHARS} characters`);
|
|
912
540
|
return null;
|
|
913
541
|
}
|
|
914
|
-
// codex R14: BOTH append carriers fold into ONE spec field (applyTaskSettings: `append\n\nstyle`) — two
|
|
915
|
-
// individually-valid values can exceed the documented cap combined. One aggregate gate, same bound.
|
|
916
542
|
if (typeof st.outputStyle === "string" &&
|
|
917
543
|
st.outputStyle.length > 0 &&
|
|
918
544
|
typeof body.appendSystemPrompt === "string" &&
|
|
@@ -921,9 +547,6 @@ export function createHttpServer(rawDeps) {
|
|
|
921
547
|
return null;
|
|
922
548
|
}
|
|
923
549
|
const perms = st.permissions;
|
|
924
|
-
// F1(#157 复审车B 留裁,裁修 2026-08-05):出现但形错(数组/标量)⇒ 400,与 settings 自身门(上方
|
|
925
|
-
// :1097)同姿势——此前数组形被本门与 #157-③ 枚举门的 `!Array.isArray` 双双跳过、parse 层再静默
|
|
926
|
-
// drop = 保护型配置(deny 规则)端到端无效且无声。null 保持「容器缺席」不咬(既有钉)。
|
|
927
550
|
if (perms !== undefined && perms !== null && (typeof perms !== "object" || Array.isArray(perms))) {
|
|
928
551
|
sendError(res, 400, "request.field_invalid", "settings.permissions must be an object");
|
|
929
552
|
return null;
|
|
@@ -937,8 +560,6 @@ export function createHttpServer(rawDeps) {
|
|
|
937
560
|
}
|
|
938
561
|
}
|
|
939
562
|
}
|
|
940
|
-
// settings.env (R-survey) — fail-loud on submit, parity with outputStyle/permissions (adversarial-review LOW). The
|
|
941
|
-
// defensive parse `cleanEnvMap` also caps these on every path (resume re-runs WITHOUT this gate), this just 400s a fresh submit.
|
|
942
563
|
const envRaw = st.env;
|
|
943
564
|
if (envRaw !== undefined && envRaw !== null) {
|
|
944
565
|
if (typeof envRaw !== "object" || Array.isArray(envRaw)) {
|
|
@@ -957,16 +578,10 @@ export function createHttpServer(rawDeps) {
|
|
|
957
578
|
}
|
|
958
579
|
}
|
|
959
580
|
}
|
|
960
|
-
// L2 ultracode (design/111): fail-loud on a non-boolean (parity with the other settings caps; parseTaskSettings
|
|
961
|
-
// silently ignores non-true on the resume paths the HTTP gate doesn't cover).
|
|
962
581
|
if (st.ultracode !== undefined && typeof st.ultracode !== "boolean") {
|
|
963
582
|
sendError(res, 400, "request.field_invalid", "settings.ultracode must be a boolean");
|
|
964
583
|
return null;
|
|
965
584
|
}
|
|
966
|
-
// settings.hooks(hook-runner 阶段一)— fail-loud on submit(131/132/133 邻居姿势):一个 malformed
|
|
967
|
-
// hooks 配置被静默丢弃 = 用户以为在生效的 deny 钩子其实没跑(安全预期破坏),必须 400。契约校验 =
|
|
968
|
-
// registry-core 0.1.51 HooksConfig + 服务侧上限(parseHooksConfig);resume 路径由 parseTaskSettings 防御
|
|
969
|
-
// (malformed → deferred 报告+warn)。是否 HONOR 另由单用户闸在 resolveSpec 决定(多租户=忽略非 400)。
|
|
970
585
|
if (st.hooks !== undefined && st.hooks !== null) {
|
|
971
586
|
const hooksParsed = parseHooksConfig(st.hooks);
|
|
972
587
|
if (!hooksParsed.config) {
|
|
@@ -980,9 +595,6 @@ export function createHttpServer(rawDeps) {
|
|
|
980
595
|
sendError(res, 400, "request.field_invalid", skillsErr);
|
|
981
596
|
return null;
|
|
982
597
|
}
|
|
983
|
-
// body.images (CC parity): cap COUNT + per-image inline base64 size → 413 (the inline
|
|
984
|
-
// `data` is otherwise passed raw to the spec; URL images are core-gated). MAX_BODY already bounds total bytes; this
|
|
985
|
-
// adds the missing count cap (anti decode-DoS) + an explicit per-image 413. Fail-loud parity with the settings caps.
|
|
986
598
|
if (Array.isArray(body.images)) {
|
|
987
599
|
if (body.images.length > MAX_IMAGES_PER_REQUEST) {
|
|
988
600
|
sendError(res, 413, "request.payload_too_large", `at most ${MAX_IMAGES_PER_REQUEST} images per request`);
|
|
@@ -996,10 +608,6 @@ export function createHttpServer(rawDeps) {
|
|
|
996
608
|
}
|
|
997
609
|
}
|
|
998
610
|
}
|
|
999
|
-
// design/112 C1: validate the client context shape + cap each field. The body is untrusted and timeZone/userEmail
|
|
1000
|
-
// flow into core's env block — an object with optional string timeZone/userEmail; reject non-strings / over-long
|
|
1001
|
-
// values (anti-DoS + anti env-block-injection bloat). core additionally validates timeZone semantically (invalid
|
|
1002
|
-
// IANA zone → UTC, never mislabeled). locale was removed in core 1.186 (dead field) so it is not accepted here.
|
|
1003
611
|
if (body.clientContext !== undefined) {
|
|
1004
612
|
const cc = body.clientContext;
|
|
1005
613
|
if (typeof cc !== "object" || cc === null || Array.isArray(cc)) {
|
|
@@ -1015,9 +623,6 @@ export function createHttpServer(rawDeps) {
|
|
|
1015
623
|
return null;
|
|
1016
624
|
}
|
|
1017
625
|
}
|
|
1018
|
-
// §7 P0.5 sandbox image selection: shape-validate here (a non-empty profile string; capabilitiesNeeded a
|
|
1019
|
-
// string[]). The per-principal visibility re-admit + profile→digest resolution happen in resolveSpec (it
|
|
1020
|
-
// holds the imageIndex + the trusted principal); a 400/404 there surfaces via the HttpError catch below.
|
|
1021
626
|
if (body.sandboxImageProfile !== undefined && (typeof body.sandboxImageProfile !== "string" || body.sandboxImageProfile.length === 0 || body.sandboxImageProfile.length > 128)) {
|
|
1022
627
|
sendError(res, 400, "request.field_invalid", "sandboxImageProfile must be a non-empty string of at most 128 characters");
|
|
1023
628
|
return null;
|
|
@@ -1026,23 +631,14 @@ export function createHttpServer(rawDeps) {
|
|
|
1026
631
|
sendError(res, 400, "request.field_invalid", "capabilitiesNeeded must be an array of strings");
|
|
1027
632
|
return null;
|
|
1028
633
|
}
|
|
1029
|
-
// capabilitiesNeeded is a constraint ON the selected image — meaningless without a profile to select (it would
|
|
1030
|
-
// otherwise be silently ignored while the task runs the unchecked worker-global default). Adversarial-review LOW.
|
|
1031
634
|
if (Array.isArray(body.capabilitiesNeeded) && body.capabilitiesNeeded.length > 0 && (typeof body.sandboxImageProfile !== "string" || body.sandboxImageProfile.length === 0)) {
|
|
1032
635
|
sendError(res, 400, "request.field_conflict", "capabilitiesNeeded requires sandboxImageProfile (it constrains the selected image)");
|
|
1033
636
|
return null;
|
|
1034
637
|
}
|
|
1035
|
-
// §7 P0.5 + the multi-run orchestration gates don't compose in v1: the per-task image binds by sessionId, but
|
|
1036
|
-
// `cascade` strips sessionId per rung and `verify` runs its verifier on a separate session — both would
|
|
1037
|
-
// silently fall back to the worker-global default image. Reject the combo FAIL-LOUD rather than run the wrong
|
|
1038
|
-
// image (adversarial-review round-2 MEDIUM). Lift when core propagates the image binding to those sub-runs.
|
|
1039
638
|
if (typeof body.sandboxImageProfile === "string" && body.sandboxImageProfile.length > 0 && (body.cascade === true || body.verify === true)) {
|
|
1040
639
|
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)");
|
|
1041
640
|
return null;
|
|
1042
641
|
}
|
|
1043
|
-
// Structured output (CC --json-schema): a plain JSON-schema OBJECT, size-capped (an uncapped schema is a
|
|
1044
|
-
// prompt-bloat / cost hole, same posture as systemPrompt/skills). Deep schema validity is core's job — a
|
|
1045
|
-
// malformed schema fails the task with a typed core error, not a 400 here. Threaded into spec by resolveSpec.
|
|
1046
642
|
if (body.outputSchema !== undefined) {
|
|
1047
643
|
if (typeof body.outputSchema !== "object" || body.outputSchema === null || Array.isArray(body.outputSchema)) {
|
|
1048
644
|
sendError(res, 400, "request.field_invalid", "outputSchema must be a JSON Schema object");
|
|
@@ -1053,19 +649,10 @@ export function createHttpServer(rawDeps) {
|
|
|
1053
649
|
return null;
|
|
1054
650
|
}
|
|
1055
651
|
}
|
|
1056
|
-
// E7 (shell-host contract): reasoning-effort selection (the `/effort` picker), mapped to core's ThinkingLevel in
|
|
1057
|
-
// resolveSpec. Validate FAIL-LOUD — a provided-but-unknown level is a 400, never silently dropped (the body.model
|
|
1058
|
-
// silent-drop bug class). The accepted set is core's tier scale; the picker's default set is advertised on
|
|
1059
|
-
// /v1/models (supportedEffortLevels). Absent ⇒ core uses the resolved role's default thinking.
|
|
1060
652
|
if (body.reasoningEffort !== undefined && !isThinkingLevel(body.reasoningEffort)) {
|
|
1061
653
|
sendError(res, 400, "request.field_invalid", "reasoningEffort must be one of: off, minimal, low, medium, high, xhigh, max");
|
|
1062
654
|
return null;
|
|
1063
655
|
}
|
|
1064
|
-
// E18 (shell-host contract): resume-at handle shape. A non-empty string ≤64 (the uuidv7 eventId / entry_id column
|
|
1065
|
-
// width). resolveSpec resolves it to a SessionTreeEntry.id (eventId→entryId anchor map) — an unknown handle 4xx's
|
|
1066
|
-
// there. Reject combos core forbids (resumeAt + a fresh verify/cascade leg replaces/strips the session per
|
|
1067
|
-
// rung/sub-run, so resume-at is meaningless there — fail-loud rather than silently branch a session that's about
|
|
1068
|
-
// to be replaced), mirroring the sandboxImageProfile+cascade/verify reject.
|
|
1069
656
|
if (body.resumeAt !== undefined) {
|
|
1070
657
|
if (typeof body.resumeAt !== "string" || body.resumeAt.length === 0 || body.resumeAt.length > 64) {
|
|
1071
658
|
sendError(res, 400, "request.field_invalid", "resumeAt must be a non-empty message eventId string (≤64 chars)");
|
|
@@ -1076,8 +663,6 @@ export function createHttpServer(rawDeps) {
|
|
|
1076
663
|
return null;
|
|
1077
664
|
}
|
|
1078
665
|
}
|
|
1079
|
-
// [833] rewind exclusive mode: two enum values only, and only riding a resumeAt (alone it qualifies nothing — a
|
|
1080
|
-
// silent no-op is the body.model silent-drop bug class, so fail loud). Absent ⇒ core default "at" (zero regression).
|
|
1081
666
|
if (body.resumeAtMode !== undefined) {
|
|
1082
667
|
if (body.resumeAtMode !== "at" && body.resumeAtMode !== "before") {
|
|
1083
668
|
sendError(res, 400, "request.field_invalid", 'resumeAtMode must be "at" or "before"');
|
|
@@ -1088,9 +673,6 @@ export function createHttpServer(rawDeps) {
|
|
|
1088
673
|
return null;
|
|
1089
674
|
}
|
|
1090
675
|
}
|
|
1091
|
-
// E12 (shell-host contract): prompt-suggestions opt-in. `true`/`false` or an object `{count?, role?}`. Validate
|
|
1092
|
-
// FAIL-LOUD (a malformed value is a 400, not a silent off — the body.model silent-drop bug class); resolveSpec then
|
|
1093
|
-
// normalizes (and re-normalizes defensively on resume, which skips this HTTP validation).
|
|
1094
676
|
if (body.suggestNextPrompts !== undefined && typeof body.suggestNextPrompts !== "boolean") {
|
|
1095
677
|
const s = body.suggestNextPrompts;
|
|
1096
678
|
if (typeof s !== "object" || s === null || Array.isArray(s)) {
|
|
@@ -1107,33 +689,22 @@ export function createHttpServer(rawDeps) {
|
|
|
1107
689
|
return null;
|
|
1108
690
|
}
|
|
1109
691
|
}
|
|
1110
|
-
// E19 (shell-host contract): rewind-files opt-in must be a boolean (fail-loud, not silently dropped).
|
|
1111
692
|
if (body.rewindFiles !== undefined && typeof body.rewindFiles !== "boolean") {
|
|
1112
693
|
sendError(res, 400, "request.field_invalid", "rewindFiles must be a boolean");
|
|
1113
694
|
return null;
|
|
1114
695
|
}
|
|
1115
|
-
// MF-30 memory PAUSE (option B): the per-request write toggle must be a boolean (fail-loud — a malformed value
|
|
1116
|
-
// silently writing to memory when the user asked to pause is exactly the silent-drop bug class).
|
|
1117
696
|
if (body.memoryWrite !== undefined && typeof body.memoryWrite !== "boolean") {
|
|
1118
697
|
sendError(res, 400, "request.field_invalid", "memoryWrite must be a boolean (false = pause memory writes for this run)");
|
|
1119
698
|
return null;
|
|
1120
699
|
}
|
|
1121
|
-
// design/114 Phase3: requireExistingSession opt-in must be a boolean (fail-loud — a truthy non-boolean silently
|
|
1122
|
-
// NOT triggering the fail-loud is the very "silent fresh run" this flag exists to prevent).
|
|
1123
700
|
if (body.requireExistingSession !== undefined && typeof body.requireExistingSession !== "boolean") {
|
|
1124
701
|
sendError(res, 400, "request.field_invalid", "requireExistingSession must be a boolean");
|
|
1125
702
|
return null;
|
|
1126
703
|
}
|
|
1127
|
-
// §4 (Fork): enableFork opt-in must be a boolean (fail-loud, not silently dropped — mirrors selfOrchestration).
|
|
1128
704
|
if (body.enableFork !== undefined && typeof body.enableFork !== "boolean") {
|
|
1129
705
|
sendError(res, 400, "request.field_invalid", "enableFork must be a boolean");
|
|
1130
706
|
return null;
|
|
1131
707
|
}
|
|
1132
|
-
// design/131/132/133 (1.127.0): fresh-submit shape validation for the three new opt-ins — a
|
|
1133
|
-
// malformed value must 400 here, not silently normalize to "off" (the body.model silent-drop bug class).
|
|
1134
|
-
// resolveSpec's normalizers stay defensive for the RESUME path (which re-enters without HTTP validation).
|
|
1135
|
-
// NOTE: `resilience.bypassBreaker=true` from a NON-operator is a shape-valid permission downgrade (dropped
|
|
1136
|
-
// by the normalizer, not a 400) — the field's authz, not its type.
|
|
1137
708
|
if (body.resilience !== undefined) {
|
|
1138
709
|
const r = body.resilience;
|
|
1139
710
|
if (typeof r !== "object" || r === null || Array.isArray(r)) {
|
|
@@ -1152,28 +723,16 @@ export function createHttpServer(rawDeps) {
|
|
|
1152
723
|
sendError(res, 400, "request.field_invalid", "finalVerification must be a boolean");
|
|
1153
724
|
return null;
|
|
1154
725
|
}
|
|
1155
|
-
// [854]④: fresh-submit shape validation for per-request 配速 —— 已知三键必须是正整数,否则 400 fail-loud
|
|
1156
|
-
// (body.model silent-drop 同类教训:静默 normalize 成「没配速」正是本件要防的假成功)。0 也拒绝:core 侧
|
|
1157
|
-
// `timeoutSec > 0` 才生效(0=不设墙,会绕过 TASK_TIMEOUT_MAX_SEC 封顶),maxTurns/maxOutputTokens 的 0 无意义。
|
|
1158
|
-
// 枚举外键与 resilience/attachments 同口径:容忍(只校验已知键)——但退役键与预算族错位键**点名 400**
|
|
1159
|
-
// (它们是「caller 有明确意图、本层却不会采纳」的已知键形,容忍=静默吞意图)。resolveSpec 的
|
|
1160
|
-
// normalizeLimits 对 RESUME 重放路径保持 defensive(按键 DROP,不 throw)。
|
|
1161
726
|
if (body.limits !== undefined) {
|
|
1162
727
|
const l = body.limits;
|
|
1163
728
|
if (typeof l !== "object" || l === null || Array.isArray(l)) {
|
|
1164
729
|
sendError(res, 400, "request.field_invalid", "limits must be an object { maxWalltimeMs?, maxOutputTokens?, maxTurns? } (positive integers)");
|
|
1165
730
|
return null;
|
|
1166
731
|
}
|
|
1167
|
-
// core 5.8.0 时限重构:旧键响亮拒并点名替代(core 同姿势 config.limit_unknown_key;「勿自行兼容」是
|
|
1168
|
-
// core 的公开指令)。timeoutSec(秒)→ maxWalltimeMs(毫秒,注意单位);deadline 族三 opt-out 随
|
|
1169
|
-
// 机制整族退役(nudge/call-cap/graceful-finalize 已从引擎移除),无替代键。fresh fail-loud;resume
|
|
1170
|
-
// 重放不过此门,resolveTaskLimits 对旧键 defensive DROP + 具名留痕(task_limits_legacy_key_dropped:
|
|
1171
|
-
// 存量 suspended 体降级为默认配速——单用户无 env 墙时=整任务无墙,多租=延到租户墙,非「方向安全」)。
|
|
1172
732
|
if (l.timeoutSec !== undefined) {
|
|
1173
733
|
sendError(res, 400, "request.field_invalid", "limits.timeoutSec was retired in core 5.8.0 — use limits.maxWalltimeMs (milliseconds; opt-in wall clock)");
|
|
1174
734
|
return null;
|
|
1175
735
|
}
|
|
1176
|
-
// 166-T3(靶4b):deadlineNudge 的替代=approachNotice(core RETIRED 表同源);另两键随机制退役无替代。
|
|
1177
736
|
if (l.deadlineNudge !== undefined) {
|
|
1178
737
|
sendError(res, 400, "request.field_invalid", "limits.deadlineNudge was retired in core 5.8.0 — use limits.approachNotice (default on at 80%/95%; false turns it off, {at: [r1, r2]} tunes thresholds)");
|
|
1179
738
|
return null;
|
|
@@ -1184,11 +743,6 @@ export function createHttpServer(rawDeps) {
|
|
|
1184
743
|
return null;
|
|
1185
744
|
}
|
|
1186
745
|
}
|
|
1187
|
-
// 预算族错位键(core 5.8.0「预算入 limits」的单向缺口):caller 按 core 键形写 limits.maxCostUsd/
|
|
1188
|
-
// maxTokens/degrade/budgetStreamCancel 时,本服务的采纳面在 body **顶层**(resolve-spec.ts
|
|
1189
|
-
// cappedCeiling)/运营方 env(MODEL_DEGRADE_*)/core 默认(budgetStreamCancel=maxCostUsd 在场即
|
|
1190
|
-
// true)——不点名拒则被静默吞掉,方向「更松」(caller 以为封了 0.5 刀,实际无成本上限)。
|
|
1191
|
-
// 退役键同姿势 fail-loud;resume 重放不过此门,normalizeLimits 对这四键维持 defensive DROP。
|
|
1192
746
|
for (const k of ["maxCostUsd", "maxTokens"]) {
|
|
1193
747
|
if (l[k] !== undefined) {
|
|
1194
748
|
sendError(res, 400, "request.field_invalid", `limits.${k} is not read from limits here — send it at the request body top level (${k}); it is capped by the operator ceiling ${k === "maxCostUsd" ? "MAX_TASK_COST_USD" : "MAX_TASK_TOKENS"}`);
|
|
@@ -1210,7 +764,6 @@ export function createHttpServer(rawDeps) {
|
|
|
1210
764
|
return null;
|
|
1211
765
|
}
|
|
1212
766
|
}
|
|
1213
|
-
// 166-T3:approachNotice fresh-submit fail-loud(判据单源 normalizeApproachNotice,阈值规则对齐 core a<=b)。
|
|
1214
767
|
{
|
|
1215
768
|
const v = l.approachNotice;
|
|
1216
769
|
if (v !== undefined && normalizeApproachNotice(v) === undefined) {
|
|
@@ -1219,11 +772,6 @@ export function createHttpServer(rawDeps) {
|
|
|
1219
772
|
}
|
|
1220
773
|
}
|
|
1221
774
|
}
|
|
1222
|
-
// [876] per-task subagents(core 1.295 TaskSpec.agents):fresh-submit 白名单门 fail-loud。与 resilience/
|
|
1223
|
-
// limits 的「容忍未知键」取舍相反 —— agents 是能力定义,静默丢字段=定义被悄悄削(丢一个 denyTools/
|
|
1224
|
-
// isolation 就是放宽),400 点名未知键更安全。permissionMode 有意不纳(core 拍,独立批)——出现按未知键拒。
|
|
1225
|
-
// 形状细则(name 必填且数组内唯一/各字段浅形状/≤32 条)单源在 spec-fields.ts validateTaskAgents;RESUME
|
|
1226
|
-
// 重放不过这道门 → resolveSpec 侧 taskAgentsFromBody defensive(非法项按条 DROP+warn,不砖 resume)。
|
|
1227
775
|
if (body.agents !== undefined) {
|
|
1228
776
|
const agentsErr = validateTaskAgents(body.agents);
|
|
1229
777
|
if (agentsErr) {
|
|
@@ -1231,27 +779,18 @@ export function createHttpServer(rawDeps) {
|
|
|
1231
779
|
return null;
|
|
1232
780
|
}
|
|
1233
781
|
}
|
|
1234
|
-
// [922]①/[915]② interactiveTools(core 1.296 三态旋钮):boolean fail-loud 同邻居姿势。per-run 工具面
|
|
1235
|
-
// 选择(AskUserQuestion/present_plan 挂载判据覆写),无跨租户面 ⇒ 不设租户门;undefined=core 自动判据。
|
|
1236
782
|
if (body.interactiveTools !== undefined && typeof body.interactiveTools !== "boolean") {
|
|
1237
783
|
sendError(res, 400, "request.field_invalid", "interactiveTools must be a boolean");
|
|
1238
784
|
return null;
|
|
1239
785
|
}
|
|
1240
|
-
// [1909]⑧ oneShot(core 5.23.0):boolean fail-loud,与它的姊妹旋钮 interactiveTools 逐字同姿势 ——
|
|
1241
|
-
// 两者说的是同一件事的两半(这次提交是不是 `-p` 一次性形)。非 boolean 静默丢会让调用方以为已经声明
|
|
1242
|
-
// 了一次性、模型却照旧被告知「结束回合等通知」= 后台结果丢失,正是本键存在的那个失败形。
|
|
1243
786
|
if (body.oneShot !== undefined && typeof body.oneShot !== "boolean") {
|
|
1244
787
|
sendError(res, 400, "request.field_invalid", "oneShot must be a boolean");
|
|
1245
788
|
return null;
|
|
1246
789
|
}
|
|
1247
|
-
// [854]② retainBackgroundProcesses:boolean fail-loud(邻居姿势 —— 非 boolean 静默变「不驻留」正是
|
|
1248
|
-
// silent-drop 病灶);单用户闸/多租忽略在 resolveSpec(retainBackgroundProcessesFromBody)。
|
|
1249
790
|
if (body.retainBackgroundProcesses !== undefined && typeof body.retainBackgroundProcesses !== "boolean") {
|
|
1250
791
|
sendError(res, 400, "request.field_invalid", "retainBackgroundProcesses must be a boolean");
|
|
1251
792
|
return null;
|
|
1252
793
|
}
|
|
1253
|
-
// [1052]② excludeTools/deferTools:数组-of-非空-string fail-loud(邻居姿势——坏形静默丢=工具面照旧,
|
|
1254
|
-
// 调用方以为收窄了却没收=silent-drop 病灶同族)。per-run 收窄无跨租户面 ⇒ 无租户门(interactiveTools 同)。
|
|
1255
794
|
for (const key of ["excludeTools", "deferTools"]) {
|
|
1256
795
|
const v = body[key];
|
|
1257
796
|
if (v !== undefined && (!Array.isArray(v) || v.some((n) => typeof n !== "string" || n.length === 0))) {
|
|
@@ -1259,7 +798,6 @@ export function createHttpServer(rawDeps) {
|
|
|
1259
798
|
return null;
|
|
1260
799
|
}
|
|
1261
800
|
}
|
|
1262
|
-
// [1144]/[1146] promptProfile:枚举 fail-loud(坏值静默丢=调用方以为切了形没切,silent-drop 病灶同族)。
|
|
1263
801
|
{
|
|
1264
802
|
const v = body.promptProfile;
|
|
1265
803
|
if (v !== undefined && v !== "simple" && v !== "classic") {
|
|
@@ -1267,8 +805,6 @@ export function createHttpServer(rawDeps) {
|
|
|
1267
805
|
return null;
|
|
1268
806
|
}
|
|
1269
807
|
}
|
|
1270
|
-
// [2856]② toolMaterializeStrategy:枚举 fail-loud(邻居 promptProfile 同款,同一条理由——静默折缺省
|
|
1271
|
-
// 会让调用方以为切了策略却没切,而这个键的整个存在理由就是 #181 那条「缺省下无界循环」)。
|
|
1272
808
|
{
|
|
1273
809
|
const v = body.toolMaterializeStrategy;
|
|
1274
810
|
if (v !== undefined && v !== "static" && v !== "swap") {
|
|
@@ -1276,11 +812,6 @@ export function createHttpServer(rawDeps) {
|
|
|
1276
812
|
return null;
|
|
1277
813
|
}
|
|
1278
814
|
}
|
|
1279
|
-
// #157-② permissionMode:枚举 fail-loud(邻居 promptProfile 同款)。此前开放 string + coercePermissionMode
|
|
1280
|
-
// 未知词静默折 "default"(最爱问档)——方向对(收紧)但静默:拼错(大小写 "Plan")的调用方以为进了 plan
|
|
1281
|
-
// 模式,实拿恒询问,silent-drop 病灶同族([2759] cli settings.json 同源病)。RESUME 重放持久化 body 不过
|
|
1282
|
-
// 此门(coercePermissionMode 宽容层护存量,双层与 [854]/outputStyle 同姿势);bundle 的
|
|
1283
|
-
// settings.permissions.defaultMode drop 臂本批不动(候 [2759] 跨仓对表统一裁,task-settings.ts:216 注)。
|
|
1284
815
|
{
|
|
1285
816
|
const v = body.permissionMode;
|
|
1286
817
|
if (v !== undefined && v !== "default" && v !== "acceptEdits" && v !== "plan" && v !== "bypassPermissions" && v !== "auto") {
|
|
@@ -1288,10 +819,6 @@ export function createHttpServer(rawDeps) {
|
|
|
1288
819
|
return null;
|
|
1289
820
|
}
|
|
1290
821
|
}
|
|
1291
|
-
// #157-③([2766] cli 对表裁决):bundle 的 settings.permissions.defaultMode 与顶层同形 400 —— 经壳的
|
|
1292
|
-
// 流量上 wire 前已过闭集校验(壳侧对人手编辑面走「降级+响亮」b979f74),server 收到未知词只可能来自
|
|
1293
|
-
// 旧版壳或第三方直连=程序化入口,严格拒合理。parseTaskSettings 的 drop 臂保留护 RESUME 存量
|
|
1294
|
-
// (task-settings.ts:216 双层,同 permissionMode/outputStyle 姿势)。
|
|
1295
822
|
{
|
|
1296
823
|
const s = body.settings;
|
|
1297
824
|
if (s !== null && typeof s === "object" && !Array.isArray(s)) {
|
|
@@ -1305,30 +832,18 @@ export function createHttpServer(rawDeps) {
|
|
|
1305
832
|
}
|
|
1306
833
|
}
|
|
1307
834
|
}
|
|
1308
|
-
// [865]② fresh-submit 未知模型 400 fail-loud(clay 生产实锤:未知 body.model 静默落 default = 用户不知情
|
|
1309
|
-
// 换模型)。🔴 门目录必须与 resolveSpec 同源 = expandTiers 增广视图(codex H1:裸 config.models 会把合法
|
|
1310
|
-
// 档位词 "pro"/CC alias "sonnet" 当未知 400 误杀——SDK 契约明确允许它们;增广后 matchCatalogModel 对
|
|
1311
|
-
// name/tier 词/alias/id 四形一体裁决)。restart 窗口注记与 main.ts wireCatalog 同款:tiers 热刷新 vs
|
|
1312
|
-
// Runner 构造期快照,门短暂超前=fail-loud 而非静默降级,诚实。"default" 是合法目录键(hasOwn 命中)。
|
|
1313
|
-
// RESUME 重放持久化 body 不过这道门(模型事后被移出目录时砖死 resume 比降级糟)——resolveSpec 侧对未知
|
|
1314
|
-
// ref warn 降级,双层与 [854] limits 同姿势。settings.model 保持 lenient(文档化,picker 路径 =
|
|
1315
|
-
// body.model 才是本病灶面)。
|
|
1316
835
|
if (body.model !== undefined) {
|
|
1317
836
|
if (typeof body.model !== "string" || body.model.length === 0) {
|
|
1318
837
|
sendError(res, 400, "request.field_invalid", "model must be a non-empty string (a configured catalog name, tier word, or model id)");
|
|
1319
838
|
return null;
|
|
1320
839
|
}
|
|
1321
|
-
const bare = deps.config.models ?? {};
|
|
840
|
+
const bare = deps.config.models ?? {};
|
|
1322
841
|
const catalog = expandTiers(bare, deps.config.tiers ?? {}) ?? bare;
|
|
1323
842
|
if (matchCatalogModel(body.model, catalog) === undefined) {
|
|
1324
843
|
sendError(res, 400, "request.unknown_reference", `unknown model "${body.model.slice(0, 120)}" — not in the configured catalog (name, tier word, or id)`, { available: mentionableNames(deps.config, catalog) });
|
|
1325
844
|
return null;
|
|
1326
845
|
}
|
|
1327
846
|
}
|
|
1328
|
-
// [1479]① compactionModel — the SAME fail-loud posture + gate catalog as body.model (an unknown cheap-gear pick
|
|
1329
|
-
// silently burning the MAIN model's price is exactly the silent-drop bug class this train exists to kill).
|
|
1330
|
-
// RESUME replays skip this gate (resolveSpec drops+warns there — core's resolveModel throws on an unknown ref,
|
|
1331
|
-
// which would brick the resume).
|
|
1332
847
|
if (body.compactionModel !== undefined) {
|
|
1333
848
|
if (typeof body.compactionModel !== "string" || body.compactionModel.length === 0) {
|
|
1334
849
|
sendError(res, 400, "request.field_invalid", "compactionModel must be a non-empty string (a configured catalog name, tier word, or model id)");
|
|
@@ -1371,17 +886,12 @@ export function createHttpServer(rawDeps) {
|
|
|
1371
886
|
}
|
|
1372
887
|
}
|
|
1373
888
|
}
|
|
1374
|
-
// 历史复审轴A #3(1.254):verify 环补 cost 顶——cascade 腿一直映 costCeilingMicroUsd,verify 腿漏了
|
|
1375
|
-
// (每轮独立 runTask,总花费此前仅受 maxRounds 约束);同源 body.maxCostUsd,同换算。
|
|
1376
889
|
const verify = verifyRoundsFromBody(body);
|
|
1377
890
|
const cascade = body.cascade === true;
|
|
1378
891
|
if (cascade && verify) {
|
|
1379
892
|
sendError(res, 400, "request.field_conflict", "verify and cascade are mutually exclusive (verify = adversarial gate; cascade = cheap→strong ladder)");
|
|
1380
893
|
return null;
|
|
1381
894
|
}
|
|
1382
|
-
// E12: suggestNextPrompts is meaningless on the verify/cascade wrappers — they return a TaskResult, not
|
|
1383
|
-
// a stream with core's post-completion suggestions() pass, so the suggestion event would never be produced. Reject
|
|
1384
|
-
// the combo fail-loud rather than silently accept-and-drop it (mirrors the resumeAt + verify/cascade reject above).
|
|
1385
895
|
if (body.suggestNextPrompts && (verify || cascade)) {
|
|
1386
896
|
sendError(res, 400, "request.field_conflict", "suggestNextPrompts is not supported with verify or cascade (those return a result, not a streamed run)");
|
|
1387
897
|
return null;
|
|
@@ -1393,88 +903,26 @@ export function createHttpServer(rawDeps) {
|
|
|
1393
903
|
try {
|
|
1394
904
|
const auth = deps.authorize ? await deps.authorize({ req, body }) : undefined;
|
|
1395
905
|
const spec = await deps.resolveSpec(body, req, auth, { leg: "fresh" });
|
|
1396
|
-
// #196:执行 Runner 与 spec 同一次解析产出 —— 两个消费域(tasks 同步/流式、runs 异步)拿的都是这一只,
|
|
1397
|
-
// 不各自再问一遍(再问 = 两处判别,正是漂移的成因)。
|
|
1398
906
|
return { spec, runner: runnerFor(spec), auth, verify, cascade, jobId: body.jobId, body };
|
|
1399
907
|
}
|
|
1400
908
|
catch (err) {
|
|
1401
909
|
if (err instanceof HttpError) {
|
|
1402
|
-
// 🔴 codex 轮2 R2-4(验真后修):`onTypedFailure` 在场 ⇒ **不写响应**,把 typed 失败交回调用方。
|
|
1403
|
-
// 为什么必须有这个口:本仓的 typed 拒绝会把结构化 `extra` 一起回显(`scenario_unknown` 的
|
|
1404
|
-
// extra 是**本部署的全部场景名**)。那对壳/SDK 是指路材料,对一个 **A2A 外部 peer** 却是内部
|
|
1405
|
-
// 词表泄漏 —— 而 DESIGN-269 车2 的整条 skill 声明制,存在的理由就是不把场景名给公网。
|
|
1406
|
-
// 加钩子而不是重构响应形:三个既有调用方一字不传 ⇒ 行为逐字节不变(仍是这一行 sendError)。
|
|
1407
910
|
if (onTypedFailure) {
|
|
1408
911
|
onTypedFailure(err);
|
|
1409
912
|
return null;
|
|
1410
913
|
}
|
|
1411
|
-
// Typed rejection: echo the stable `code` + any structured extras (e.g. the scenario
|
|
1412
|
-
// allowlist) beside the human message — machine code on the wire, prose stays with the shell/web.
|
|
1413
914
|
sendError(res, err.status, httpErrorCode(err.status, err.code), err.message, { ...(err.extra ?? {}) });
|
|
1414
915
|
return null;
|
|
1415
916
|
}
|
|
1416
917
|
throw err;
|
|
1417
918
|
}
|
|
1418
919
|
}
|
|
1419
|
-
// design/158 A9:`DecideBinding` 已上提到 `http/wire-types.ts` 叶子(approvals 域模块与 resume 腿同名一形),
|
|
1420
|
-
// 本处仅留指路;形状逐字未变。
|
|
1421
|
-
/**
|
|
1422
|
-
* The VERIFIED fleet scope for a resume publish (TOC review #6 parity with the create paths). Prefer the
|
|
1423
|
-
* crypto-verified `gatedPrincipal(req)` — the SAME identity GET /v1/fleet/stream filters by — on a real HTTP
|
|
1424
|
-
* door; fall back to the checkpoint's own scope (`auth.principal`) only when there is NO request (the internal
|
|
1425
|
-
* D-D SLA deny-sweep, which has no caller to verify). NEVER the spoofable `auth.principal` on a live door:
|
|
1426
|
-
* a spoofed header would re-publish the resumed row under a victim's scope or under "default" (vanishing from
|
|
1427
|
-
* its real owner's stream). Mirrors the sync/create sites' `gatedPrincipal(req, deps.config) ?? "default"`.
|
|
1428
|
-
*
|
|
1429
|
-
* ✅ FIDELITY EDGE — CLOSED by the F-fix (2026-07-01, direct-door principal spoofing). On the REQ-LESS leg (the
|
|
1430
|
-
* internal D-D SLA deny-sweep, `req===undefined`) we fall back to `auth.principal` = the checkpoint's stored scope.
|
|
1431
|
-
* Since the F-fix, createAuthorizer derives `auth.principal` (hence spec.principal, hence the checkpoint scope core
|
|
1432
|
-
* persists at suspend) from `verifiedPrincipal` — the VERIFIED JWT sub on a direct door, the SAME identity the
|
|
1433
|
-
* create path's `gatedPrincipal` tags the row with. So the earlier caveat — a misconfigured direct door where the
|
|
1434
|
-
* raw `x-agent-principal` ≠ the verified sub could surface a swept row under the wrong tenant's /v1/fleet/stream —
|
|
1435
|
-
* is GONE: the checkpoint scope is now always the verified identity, identical to what the create path tagged. The
|
|
1436
|
-
* "TRUE fix" once deferred here (persist the verified principal on the checkpoint at create) is exactly what the
|
|
1437
|
-
* F-fix did upstream at createAuthorizer; the req-less fallback and the create-path scope now agree on every posture.
|
|
1438
|
-
*/
|
|
1439
920
|
const resumeFleetScope = (req, auth) => req ? (gatedPrincipal(req, deps.config) ?? "default") : (auth.principal ?? "default");
|
|
1440
|
-
|
|
1441
|
-
* Durable F4 resume (design/45): map a session → its pending checkpoint token INTERNALLY,
|
|
1442
|
-
* rebuild the taskConfig via the same `resolveSpec` path (from the sessionId-keyed checkpoint_ctx), then
|
|
1443
|
-
* `runner.resume`. The capability token never leaves the service. Updates the parked run row to its new state.
|
|
1444
|
-
*/
|
|
1445
|
-
async function resumeCheckpoint(sessionId, decision, reason,
|
|
1446
|
-
// core `ApprovalSettledBy`(5.23.0):**这次结算的出处**,由调用方命名。REQUIRED —— core 的字段
|
|
1447
|
-
// 文档把义务钉在结算点上(「Fill it at every settlement site」),并说明集中推导正是「窗到期被报成
|
|
1448
|
-
// 另一个人拒绝」的成因。本函数有两个调用方且函数内**无从分辨**(sweep 与人为 decide 在这里唯一
|
|
1449
|
-
// 的差别是 `req` 缺席,那是「有没有 HTTP 请求」不是「谁结算的」),所以只能穿参。
|
|
1450
|
-
// ⚠️ core 的相容规则:`decision:"approve"` 只接受 `"human"` 或缺席 —— 「没人答所以它跑了」不是
|
|
1451
|
-
// 任何事实的记录。今天唯一传非 `"human"` 的调用方(SLA sweep)恒 deny,故不冲突;将来若有一个
|
|
1452
|
-
// 会 approve 的非人调用方,它该传的是**缺席**而不是一个自造的词。
|
|
1453
|
-
settledBy, req, // absent on the internal D-D SLA deny-sweep (resolveSpec rebuilds auth from the checkpoint, not req)
|
|
1454
|
-
answer, binding, onResumeCommitted, // codex M2: fires post-markResuming-CAS, pre-model-drive (decide leg's exemption grant); MUST NOT throw the resume down — callers swallow internally. overrideSessionId = parked 腿的 root-会话 grant 锚([1591] 候裁②),legacy 腿不传。
|
|
1455
|
-
// #316:200 受理语义(HTTP `/decide` 传 true;内部 D-D SLA deny-sweep 不传 —— 没有在等响应的人)。
|
|
1456
|
-
// 语义与判据的唯一属主 = `DriveResumeArgs.acceptEarly` 顶注。
|
|
1457
|
-
acceptEarly) {
|
|
921
|
+
async function resumeCheckpoint(sessionId, decision, reason, settledBy, req, answer, binding, onResumeCommitted, acceptEarly) {
|
|
1458
922
|
const cs = deps.checkpointStore;
|
|
1459
|
-
// No scope filter here BY DESIGN (council): an operator may view/decide ANY tenant's pending approval.
|
|
1460
|
-
// This load-before-CAS only READS; the definitive multi-tenant guard is the
|
|
1461
|
-
// `AND scope = ?` inside the atomic resolve CAS — a wrong-scope decision can never EXECUTE.
|
|
1462
923
|
const token = await cs.findPendingTokenBySession(sessionId);
|
|
1463
|
-
// D-1 approval_stale (opt-in, only when the operator echoed the checkpointToken they saw): if it no longer
|
|
1464
|
-
// matches the current pending — resolved, superseded by a re-mint, or expired — they are deciding a STALE
|
|
1465
|
-
// view. Reject distinctly from the markResuming session-CAS 409 below: the SDK keys ApprovalStaleError on
|
|
1466
|
-
// errorCode:"approval_stale" while the CAS 409 stays bare (→ ConflictError), so the two 409s never collide.
|
|
1467
924
|
if (binding?.checkpointToken && binding.checkpointToken !== token) {
|
|
1468
925
|
const seen = await cs.get(binding.checkpointToken).catch(() => null);
|
|
1469
|
-
// 🔴 `terminal` 只在该 token **确实属于本会话**时披露(2026-07-26 修)。
|
|
1470
|
-
// 此前是 `seen ? "resolved" : "expired_abort"` —— 而这次 `cs.get` 用的是**调用方给的** token 且
|
|
1471
|
-
// 存储层**没有 scope 谓词**(`WHERE token = ?`,连 status 都不过滤),这一步还跑在下面那个
|
|
1472
|
-
// `if (!token) → 404` **之前**。于是任何持凭据者都能拿一串 token 来问「它在库里吗」:
|
|
1473
|
-
// 命中 ⇒ `terminal:"resolved"`,不命中 ⇒ `terminal:"expired_abort"` —— **一位跨租户存在性 oracle**,
|
|
1474
|
-
// 与本文件自述的「no existence oracle」相反。token 本身不可猜(uuid 级),所以这不是枚举面;
|
|
1475
|
-
// 真实伤害是**泄漏出去的 token 可被免费确认有效性**(从日志/截图/工单捡到一串,不必也不能用它 resume,
|
|
1476
|
-
// 却能确认它对应一条真实 checkpoint 行、以及它是否已被解决)。
|
|
1477
|
-
// 归属不上就**省略这个键**(SDK 侧 `terminal?:` 本来可选)——不撒谎、也不泄露;真实原因照旧进日志。
|
|
1478
926
|
const attributable = seen !== null && seen.sessionId === sessionId;
|
|
1479
927
|
if (!attributable && seen !== null) {
|
|
1480
928
|
deps.logger?.warn?.("approval_stale_token_foreign", { sessionId, tokenSession: seen.sessionId ?? null });
|
|
@@ -1489,47 +937,23 @@ export function createHttpServer(rawDeps) {
|
|
|
1489
937
|
};
|
|
1490
938
|
}
|
|
1491
939
|
if (!token) {
|
|
1492
|
-
// 🔴 文案与 owner 门那条统一(2026-07-26 修)。此前两句可区分:owner 门拒 ⇒ "approval not found";
|
|
1493
|
-
// 本会话**没有** pending ⇒ 这条更详细的。而 `peekPendingScope` 在没有 pending 时返回 `null`、`null` 被当作
|
|
1494
|
-
// "开放"放过门 ⇒ 两句文案精确区分「这儿有**别人的** pending」与「这儿没有 pending」。
|
|
1495
|
-
// 后果:任何持凭据的租户可逐个 sessionId 判定「此刻有一条别人的待决审批」,拼出一张跨租户的
|
|
1496
|
-
// 「谁正卡在审批上」活动图(时序/规模情报)—— 而本文件自述承诺的正是不给这个。
|
|
1497
|
-
// 统一成同一句;**诊断不丢**,只是挪进日志(部署方有日志权限,外部调用方没有)。
|
|
1498
940
|
deps.logger?.info?.("approval_decide_no_pending", { sessionId });
|
|
1499
941
|
return { status: 404, body: { error: "approval not found", errorCode: "not_found.approval" } };
|
|
1500
942
|
}
|
|
1501
|
-
|
|
1502
|
-
const cpPromise = cs.get(token); // throws if the checkpoint format is newer than this build (version guard)
|
|
943
|
+
const cpPromise = cs.get(token);
|
|
1503
944
|
const ctxPromise = cs.getCtx(sessionId);
|
|
1504
945
|
let cp;
|
|
1505
946
|
try {
|
|
1506
947
|
cp = await cpPromise;
|
|
1507
948
|
}
|
|
1508
949
|
catch (e) {
|
|
1509
|
-
void ctxPromise.catch(() => { });
|
|
950
|
+
void ctxPromise.catch(() => { });
|
|
1510
951
|
return { status: 409, body: { error: e instanceof Error ? e.message : String(e), errorCode: "conflict.checkpoint_unreadable" } };
|
|
1511
952
|
}
|
|
1512
953
|
if (!cp)
|
|
1513
954
|
return { status: 404, body: { error: "checkpoint not found", errorCode: "not_found.checkpoint" } };
|
|
1514
|
-
// 🔴 GATE-KIND GUARD ([1995]①,`gate_not_resumable` / `gate_not_plan_review` 的**反方向镜像**):这条腿
|
|
1515
|
-
// 只解 TOOL 审批门(core gateMatch:`human` / `irreversible_ask` ↔ outcome.gate "policy_ask")。此前它无
|
|
1516
|
-
// 条件铸 outcome{gate:"policy_ask"} 交给 core —— 一个 plan_review / resource_limit / task_done park 被误
|
|
1517
|
-
// 路由到 /decide(而 /v1/approvals 把它们与工具审批**混在同一个队列**里、toolName 同为 null)就会深入到
|
|
1518
|
-
// core 的 `checkpoint.gate_mismatch`。那是 core 的 **pre-CAS** 拒绝(checkpoint 仍 pending),但 server 的
|
|
1519
|
-
// CheckpointError 分类表把 gate_mismatch 归为 TERMINAL ⇒ 行被 setTerminal 成 failed + 释放 task_active,
|
|
1520
|
-
// 于是「行说这活死了、park 却还活着」:claim 泄漏(同 session 可并发起第二个 run)、事后用正确的腿救能
|
|
1521
|
-
// 200 真跑完却拿不到 taskId、run 行永停 failed(账本与现实分叉)。这里 pre-CAS 纯拒绝:不动 checkpoint、
|
|
1522
|
-
// 不动 run 行、不烧模型腿,与另外两条腿的守卫同姿势(core 的 gate-match 仍是 fail-closed 兜底)。
|
|
1523
|
-
// ⚠️ 门放在 parked 赎回腿**之前**:赎回腿同样只铸 policy_ask outcome,错门 cp 在那条链上只会更深地炸。
|
|
1524
|
-
// D-D SLA deny-sweep 不受影响:它的取行查询本就只选 gate_kind ∈ APPROVAL_GATE_KINDS(tool-approval.ts 单一属主)。
|
|
1525
955
|
const decideGateKind = cp.gate?.kind;
|
|
1526
|
-
if (!isApprovalGateKind(decideGateKind)) {
|
|
1527
|
-
// [2400] HITL-6(clay 裁 2026-08-03,不做兼容):补机读指路——与 [2255]① 的 409 材料同形
|
|
1528
|
-
// (pendingGate{kind, decidePath},resumeEntryForGate 单源表),客户端凭 decidePath 直达对的
|
|
1529
|
-
// resume 入口,不再解析人话文案。taskId 源=账本活行(getActiveTaskId,与 active-run-conflict.ts
|
|
1530
|
-
// 的 409 材料同源)——cp.sourceTaskId 是 core 契约里 issuing worker 的 **session id**(ECHO-ONLY
|
|
1531
|
-
// triage,checkpoint-store.d.ts),拿它当 run taskId 铸路径=打出去必 404([3683]-1 同病族,
|
|
1532
|
-
// 扫描 P1 修)。账本无活行 ⇒ 只带 kind,诚实不铸假路径。
|
|
956
|
+
if (!isApprovalGateKind(decideGateKind)) {
|
|
1533
957
|
const activeForPath = decideGateKind
|
|
1534
958
|
? await deps.runStore?.getActiveTaskId(sessionId).catch(() => undefined)
|
|
1535
959
|
: undefined;
|
|
@@ -1545,10 +969,6 @@ export function createHttpServer(rawDeps) {
|
|
|
1545
969
|
},
|
|
1546
970
|
};
|
|
1547
971
|
}
|
|
1548
|
-
// [2400] HITL-12(clay 裁 2026-08-03:不兼容老客户端):HTTP 腿强制决策-动作绑定——无 checkpointToken
|
|
1549
|
-
// 且无 boundCallId 的 decide 拒收(此前静默回落 current-pending:并发窗口下 A 想批的门刚被 B 的新门顶掉
|
|
1550
|
-
// 就裁错对象)。内部 D-D SLA deny-sweep(req 缺席)豁免:系统腿 deny 过期行,无并发歧义。位置刻意在
|
|
1551
|
-
// gate-kind 守卫**之后**:错门型先得 409+pendingGate 指路(HITL-6),而不是被本门的 400 遮蔽。
|
|
1552
972
|
if (req !== undefined && !binding?.checkpointToken && !binding?.boundCallId) {
|
|
1553
973
|
return {
|
|
1554
974
|
status: 400,
|
|
@@ -1559,12 +979,6 @@ export function createHttpServer(rawDeps) {
|
|
|
1559
979
|
},
|
|
1560
980
|
};
|
|
1561
981
|
}
|
|
1562
|
-
// design/153 件3d parked 赎回腿([1574]/[1584]/[1588] core 裁定;src/parked-decide.ts):该 pending
|
|
1563
|
-
// checkpoint 若属于一个 parked 后台子代,decide 必须走 claim→裸 execute(reviveClaim) 链——legacy 腿的
|
|
1564
|
-
// resumeStream 会绕开 bg registry(无 consumeParkedFlip、行永 parked、生命周期分叉),故 parked-first。
|
|
1565
|
-
// 判别 miss(undefined)= 任务级 suspend,原样落到下方 legacy 腿(零回归)。⚠️ 门在 `req !== undefined`:
|
|
1566
|
-
// 内部 D-D SLA deny-sweep(req 缺席)对 parked 子代 cp 保持现状(getCtx null → 409 跳过本 tick)——
|
|
1567
|
-
// 过期 parked 的收割属 reaper 的 expire+reconcileParkedAgents 车道,sweep 不做重活赎回。
|
|
1568
982
|
if (req !== undefined && deps.backgroundAgentStore !== undefined && deps.parkedReviveTool !== undefined) {
|
|
1569
983
|
const parked = await decideParkedAgent({
|
|
1570
984
|
agentStore: deps.backgroundAgentStore,
|
|
@@ -1576,24 +990,16 @@ export function createHttpServer(rawDeps) {
|
|
|
1576
990
|
...(deps.logger ? { warn: (event, fields) => deps.logger?.warn?.(event, fields) } : {}),
|
|
1577
991
|
}, {
|
|
1578
992
|
token, scope: cp.scope, sessionId: cp.sessionId, pendingAction: cp.pendingAction,
|
|
1579
|
-
// #204 件6③:赎回腿是**另一个**结算点,出处同样穿参(它自己不猜)。今天走到这里的恒是人为
|
|
1580
|
-
// /decide —— 上面那道门写着 `req !== undefined`,而 sweep 恰是 req 缺席的那条腿。
|
|
1581
993
|
decision, settledBy, ...(reason !== undefined ? { reason } : {}),
|
|
1582
994
|
...(binding !== undefined ? { binding: { ...(binding.boundCallId !== undefined ? { boundCallId: binding.boundCallId } : {}), ...(binding.boundInputHash !== undefined ? { boundInputHash: binding.boundInputHash } : {}), ...(binding.updatedInput !== undefined ? { updatedInput: binding.updatedInput } : {}) } } : {}),
|
|
1583
|
-
// remember 的 grant 闭包(onResumeCommitted 的唯一现役来源)在 parked 腿以行的 root/host
|
|
1584
|
-
// 会话为锚透传([1591] 候裁② server 修——落子代会话则探针键永不相交,见 grantOnCommit 注);
|
|
1585
|
-
// answer 原样透传(RB-459,core 5.7.0 起 revive 侧有挂点;形状校验已在路由层 isQuestionAnswer)。
|
|
1586
995
|
...(onResumeCommitted ? { grantRemember: (rootSessionId) => onResumeCommitted(rootSessionId) } : {}),
|
|
1587
996
|
...(answer !== undefined ? { answer } : {}),
|
|
1588
997
|
});
|
|
1589
998
|
if (parked !== undefined) {
|
|
1590
|
-
void ctxPromise.catch(() => { });
|
|
999
|
+
void ctxPromise.catch(() => { });
|
|
1591
1000
|
return parked;
|
|
1592
1001
|
}
|
|
1593
1002
|
}
|
|
1594
|
-
// Guard the ctx await too (council): if cpPromise resolved but ctxPromise REJECTS (a TiDB blip on the
|
|
1595
|
-
// checkpoint_ctx read), an unguarded await would escape to the generic 500 — inconsistent with the 409 the
|
|
1596
|
-
// cpPromise branch returns for the same class of transient load failure.
|
|
1597
1003
|
let ctx;
|
|
1598
1004
|
try {
|
|
1599
1005
|
ctx = await ctxPromise;
|
|
@@ -1603,18 +1009,7 @@ export function createHttpServer(rawDeps) {
|
|
|
1603
1009
|
}
|
|
1604
1010
|
if (!ctx)
|
|
1605
1011
|
return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
|
|
1606
|
-
// Rebuild taskConfig via resolveSpec with auth reconstructed from the checkpoint (sessionId + scope).
|
|
1607
1012
|
const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
|
|
1608
|
-
// E18: STRIP resumeAt before re-resolving on a durable resume — the leaf is already set by the checkpoint, and
|
|
1609
|
-
// resumeAt + a durable resume is rejected by core (resume_at.conflicts_resume); also avoids re-resolving a stale
|
|
1610
|
-
// anchor (which could 404 if the session was purged). A resume-at run that suspended stored its body with resumeAt.
|
|
1611
|
-
// 对抗评审 2026-07-11(MED):resolveSpec re-runs the ADMISSION gates (scenario/execution ruling — resume does
|
|
1612
|
-
// NOT freeze governance, the established precedent posture; a deny is ALSO a model run, so it is gated too). But an
|
|
1613
|
-
// escaping HttpError here was (a) attributed to the OPERATOR's /decide request as a bare 400/403 — undiagnosable
|
|
1614
|
-
// from that contract — and (b) swallowed by the D-D SLA deny-sweep's catch, silently failing EVERY tick. Fold it
|
|
1615
|
-
// into a typed 409 result instead: the operator sees "this parked task is blocked by a policy change" (retry
|
|
1616
|
-
// after the policy is restored, or let terminal_at_ms abort it); the sweep skips the row this tick (retried next
|
|
1617
|
-
// tick, terminal_at_ms backstop — its documented per-call-failure semantics).
|
|
1618
1013
|
let spec;
|
|
1619
1014
|
try {
|
|
1620
1015
|
spec = await deps.resolveSpec({ ...ctx.body, resumeAt: undefined }, req, auth);
|
|
@@ -1628,41 +1023,14 @@ export function createHttpServer(rawDeps) {
|
|
|
1628
1023
|
}
|
|
1629
1024
|
throw e;
|
|
1630
1025
|
}
|
|
1631
|
-
// Typed pre-check: the pending tool must still exist in the rebuilt config; a scenario
|
|
1632
|
-
// redeploy that removed it means the approval can no longer be applied — fail clearly, never a 500/silent.
|
|
1633
|
-
// The "hand" tools are NOT in spec.tools — core mounts them at the runner from the executionEnvFactory
|
|
1634
|
-
// (`HAND_TOOL_NAMES` above = hands-lane.ts `HANDS_BAND_TOOL_NAMES`, the single owner; nothing is mirrored here).
|
|
1635
|
-
// When this deployment has remote exec configured the rebuilt task WILL expose them, so union them in; else
|
|
1636
|
-
// gating a hand tool (the common code-agent case) would always 422 on resume.
|
|
1637
|
-
// #196 旁注:本 union 只看部署有没有 remoteExec,不看本任务的 hands lane —— 对 hands=none 的任务这是
|
|
1638
|
-
// **过近似**(那些工具根本没 mount,也就不可能有它们的 park 行),只影响 422 的 UX 宽严,不放大任何权限。
|
|
1639
|
-
// #244:union 必须是 band **全**名单——effects 表外的挂载补集(Monitor/EnterWorktree/…)当年漏了,
|
|
1640
|
-
// 断连 force-park 人群第一个踩中(approve 恒 422,重呈卡回答永不传导;test legB1-durable 实测指纹)。
|
|
1641
|
-
// [2380] RB-476(5.0.0):Q6 折叠预检退役——满足性匹配按 RAW 名。pre-floor 旧名 checkpoint
|
|
1642
|
-
// (`bash`/`run_workflow` 等)在此 422 诚实拒,与 core 自己的 resume.tool_unavailable 同方向
|
|
1643
|
-
// ([2364] 令:耐久面响亮牺牲 + P-7 重开,不静默兜)。现役名恒自映射。
|
|
1644
1026
|
const pendingTool = cp.pendingAction.toolName;
|
|
1645
1027
|
const pendingToolCanon = pendingTool;
|
|
1646
1028
|
const availableTools = new Set((spec.tools ?? []).map((t) => t.name));
|
|
1647
1029
|
if (deps.config.remoteExec)
|
|
1648
1030
|
for (const name of HAND_TOOL_NAMES)
|
|
1649
1031
|
availableTools.add(name);
|
|
1650
|
-
// AskUserQuestion (durable ask, TC-5.4) is ALSO not in spec.tools — core mounts it from `onQuestion`
|
|
1651
|
-
// (#152: the QUESTION_AWAITS_RESUME sentinel when no live coordinator is wired, else RunnerDeps.onQuestion's
|
|
1652
|
-
// coordinator serves the seat). Without this it 422s on resume exactly like a hand tool would. Gated on
|
|
1653
|
-
// checkpointStore = durable mode is active.
|
|
1654
1032
|
if (deps.checkpointStore)
|
|
1655
1033
|
availableTools.add("AskUserQuestion");
|
|
1656
|
-
// Workflow (codex round-2 on [1245]/[1248]②): `run_workflow` is ALSO mounted at the runner, never in
|
|
1657
|
-
// spec.tools — core mounts it when `spec.selfOrchestration === true` and the deployment wires the workflow
|
|
1658
|
-
// deps (isSelfOrchestrationActive, dist-read). Without this, approving a durably-parked workflow ask
|
|
1659
|
-
// (the new mode-derived leg) 422s here and the suspend can only ever be denied. Same over-approximation
|
|
1660
|
-
// posture as the hand-tools union above (a per-principal allowWorkflows=false revoked between suspend and
|
|
1661
|
-
// approve slips this pre-check; core's own resume path then fails loudly — the pre-check is a UX guard,
|
|
1662
|
-
// not the authority).
|
|
1663
|
-
// A-033.1(#256):名字从 core 的 `RUN_WORKFLOW_TOOL_NAME` 取,不再手抄 —— 手抄的名字在 core 改名后
|
|
1664
|
-
// 与实挂名分家,这一格会把**每一次**已批准的 workflow 赎回打成 422(pendingTool 是实挂名,集合里
|
|
1665
|
-
// 却是旧名),而 task-settings 那三处同族手抄的后果更重(ask/deny 门整条静默不触发)。
|
|
1666
1034
|
if (spec.selfOrchestration === true && deps.config.selfOrchestrationEnabled) {
|
|
1667
1035
|
availableTools.add(RUN_WORKFLOW_TOOL_NAME);
|
|
1668
1036
|
}
|
|
@@ -1672,25 +1040,6 @@ export function createHttpServer(rawDeps) {
|
|
|
1672
1040
|
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" },
|
|
1673
1041
|
};
|
|
1674
1042
|
}
|
|
1675
|
-
// Durable-ask request/state coherence (TC-5.4), all pre-CAS so a mismatched request never consumes the
|
|
1676
|
-
// checkpoint:
|
|
1677
|
-
// - approving a pending AskUserQuestion REQUIRES body.answer. Without it the redeemed call executes against
|
|
1678
|
-
// whatever question face the rebuilt spec has, and the operator's question is unrecoverably LOST (checkpoint
|
|
1679
|
-
// consumed, no answer ever delivered). Fail the request instead; the operator retries with the answer.
|
|
1680
|
-
// 🔴 #152: this check is now the ONLY one on this route. core has its own pre-CAS refusal for
|
|
1681
|
-
// "approve a content-ask with no answer" (runtask.js: `taskConfig.onQuestion ?? deps.onQuestion` is
|
|
1682
|
-
// `undefined` or QUESTION_AWAITS_RESUME), but on an ASK_QUESTION_ENABLED deployment the spec no longer
|
|
1683
|
-
// stamps the sentinel and `deps.onQuestion` is the coordinator, so core's condition is never met and it
|
|
1684
|
-
// does NOT refuse.
|
|
1685
|
-
// ⚠️ The ORIGINAL rationale — "the redeemed leg would take the coordinator's empty 'no human' default" —
|
|
1686
|
-
// is DISPROVED and must not be repeated: since #166 QuestionCoordinator never synthesizes an empty answer
|
|
1687
|
-
// set (every no-human arm returns `{kind:"unavailable"}`), and core 5.16 answers a redeemed AskUserQuestion
|
|
1688
|
-
// with an isError ("will not silently self-answer") rather than a fabricated verdict. The guard STAYS on a
|
|
1689
|
-
// cheaper rationale: the checkpoint is consumed by the CAS BEFORE that revive can report unavailable, so
|
|
1690
|
-
// the operator loses the parked question for a revive that could only fail — refusing pre-CAS costs nothing
|
|
1691
|
-
// and gives a precise error. Do not delete it (parked twin: src/parked-decide.ts, same rationale).
|
|
1692
|
-
// - an answer is only meaningful on an approve of a question gate; on a deny or a non-question gate it
|
|
1693
|
-
// signals the operator is deciding a different pending action than the one parked — reject, don't guess.
|
|
1694
1043
|
if (decision === "approve" && pendingTool === "AskUserQuestion" && !answer) {
|
|
1695
1044
|
return { status: 400, body: { error: "pending action is AskUserQuestion — approve must carry body.answer ({ answers: [{ header, selected: string[], note? }] })", errorCode: "request.field_conflict" } };
|
|
1696
1045
|
}
|
|
@@ -1698,17 +1047,6 @@ export function createHttpServer(rawDeps) {
|
|
|
1698
1047
|
return { status: 400, body: { error: `body.answer is only valid when approving a pending AskUserQuestion (decision: "${decision}", pending: "${pendingTool ?? "unknown"}")`, errorCode: "request.field_conflict" } };
|
|
1699
1048
|
}
|
|
1700
1049
|
const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
|
|
1701
|
-
// Durable ask (TC-5.4 → RB-459, core 5.7.0): the operator's answer rides the ResumeOutcome — core's
|
|
1702
|
-
// answerFaceForRedeemedCall replays it to the REDEEMED question call only, so a NEW question on the
|
|
1703
|
-
// resumed leg gets an honest error instead of a silently replayed stale answer (which the pre-5.7.0
|
|
1704
|
-
// `onQuestion = async () => answer` closure form did). Absent answer ⇒ outcome unchanged (an F4 tool
|
|
1705
|
-
// approval resumes exactly as before). The onQuestion seat is left alone.
|
|
1706
|
-
// D-1 decision-action binding (core 1.101, design/80 §2): echo the boundCallId + boundInputHash the operator
|
|
1707
|
-
// saw back into the outcome — core verifies opaque STRING equality against the persisted suspend-mint values
|
|
1708
|
-
// (fail-closed, PRE-CAS), so "saw action A, resolve B" is rejected. boundInputHash is NEVER recomputed here
|
|
1709
|
-
// (the engine minted it once via canonical-json; a second runtime re-serializing would false-mismatch and
|
|
1710
|
-
// fail-closed reject a legit approval). Legacy callers that omit the fields fall back to the current persisted
|
|
1711
|
-
// values (no human binding — pre-D-1 behavior). resource_limit pendings carry no action → empty (core gate_mismatch).
|
|
1712
1050
|
const pa = cp.pendingAction;
|
|
1713
1051
|
const persistedCallId = pa.kind === "tool_approval" ? pa.toolCallId : "";
|
|
1714
1052
|
const persistedHash = pa.kind === "tool_approval" ? (pa.boundInputHash ?? "") : "";
|
|
@@ -1720,74 +1058,24 @@ export function createHttpServer(rawDeps) {
|
|
|
1720
1058
|
...(decision === "approve" && binding?.updatedInput !== undefined ? { updatedInput: binding.updatedInput } : {}),
|
|
1721
1059
|
...(reason ? { reason } : {}),
|
|
1722
1060
|
...(answer !== undefined ? { answer } : {}),
|
|
1723
|
-
// #204 件6:结算出处逐字上 outcome(见参数顶注)。core 只接受它自己那三个词,server 不派生、不缺省。
|
|
1724
1061
|
settledBy,
|
|
1725
1062
|
};
|
|
1726
|
-
// Developer-mode verify (1.44) survives suspend/resume (core `resumeWithVerification`, design/51 P1-b): a
|
|
1727
|
-
// task submitted with verify:true that hit an F4 gate must STILL be gated by the adversarial verifier on
|
|
1728
|
-
// completion — a plain resumeStream would silently drop the gate. The flag rides in the persisted ctx.body
|
|
1729
|
-
// (same source the spec was rebuilt from). Like first-run verify it's multi-attempt (not a live stream), so
|
|
1730
|
-
// we await the result + write a single terminal event rather than streaming post-resume deltas.
|
|
1731
1063
|
const verifyRounds = verifyRoundsFromBody(ctx.body);
|
|
1732
|
-
// taskId up-front: the resumed leg streams its post-resume events into the SAME durable run log, CONTINUING
|
|
1733
|
-
// the seq past the suspend (core 1.70 `resumeStream`) — so GET /v1/runs/:id/events covers post-resume too,
|
|
1734
|
-
// observability parity with runInBackground (closed by core 1.70).
|
|
1735
|
-
// ── Drive the resume into the durable run log via the SHARED machinery (driveResumeIntoRunLog): markResuming
|
|
1736
|
-
// CAS, the cancel + preempt controllers (same-replica fast path + cross-replica flag polls), the resume
|
|
1737
|
-
// heartbeat, the resumeStream / resumeWithVerification loop, CheckpointError classification, re-suspend vs
|
|
1738
|
-
// terminal drive. This HITL /decide path supplies a `policy_ask` outcome; the scheduler preempt path supplies
|
|
1739
|
-
// a `resource_limit`/continue one — sharing this prevents the two resume entries from drifting.
|
|
1740
1064
|
return driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds, ...(onResumeCommitted ? { onResumeCommitted } : {}), ...(acceptEarly === true ? { acceptEarly: true } : {}) });
|
|
1741
1065
|
}
|
|
1742
|
-
/**
|
|
1743
|
-
* The resume-drive machinery SHARED by `resumeCheckpoint` (operator /decide, `policy_ask`) and `resumePreempted`
|
|
1744
|
-
* (scheduler resume, `resource_limit`/continue). Given the already-located `token`, the rebuilt
|
|
1745
|
-
* `taskConfig`/`resumeObjective`, the gate-specific `outcome`, and optional `verifyRounds`, it: markResuming-
|
|
1746
|
-
* CASes the parked row to `running` (idempotent across replicas), registers the leg in BOTH `inflightRuns`
|
|
1747
|
-
* (cancel) and `preemptableRuns` (re-preempt) for the same-replica fast path, runs a heartbeat that polls the
|
|
1748
|
-
* durable cancel + preempt flags (cross-replica), resumes via resumeStream / resumeWithVerification threading a
|
|
1749
|
-
* FRESH cancel signal AND a FRESH preemptSignal (so a resumed leg is itself cancel-able + RE-preempt-able —
|
|
1750
|
-
* never the consumed suspend-time signal), classifies a CheckpointError, handles a re-suspend
|
|
1751
|
-
* (setSuspended, keep the lock) vs a terminal (setTerminal), and returns the HTTP {status, body}.
|
|
1752
|
-
*
|
|
1753
|
-
* #316:**200 受理形的分派器**。`args.acceptEarly !== true` ⇒ 逐字旧行为(整条腿 await 完再回)。
|
|
1754
|
-
* 传 `true` ⇒ 在受理点(`driveResumeLeg` 里 `resumeStream` 解析之后)先把受理体交回,模型腿继续在
|
|
1755
|
-
* 同一条 promise 链上跑完。判据(受理点为什么在那里、哪三类判定必须留在同步侧)见
|
|
1756
|
-
* {@link DriveResumeArgs.acceptEarly} 的顶注 —— 那是这条语义的唯一属主,别在这里第二次成文。
|
|
1757
|
-
*
|
|
1758
|
-
* 🔴 **受理之后的失败绝不静默**:HTTP 已经走了,行/账本是权威可见面(腿内部各分支照旧驱行终局),
|
|
1759
|
-
* 但那条链的**结局**在这里必须留痕 —— 否则「受理了然后呢」在遥测里没有任何线索。
|
|
1760
|
-
*
|
|
1761
|
-
* 🔴 **如实登记的一条能见度差额**(codex 对抗复审 R1-[high],验真后按真形收窄):受理之后**行驱动写**
|
|
1762
|
-
* (`setTerminal`/`setSuspended`,腿尾那段)如果自己炸了,调用方拿不到任何东西 —— 修前那一形是 500。
|
|
1763
|
-
* ⚠️ 但**持久状态的分歧是既存的、两形逐字相同**:同步形下 500 并不会修好任何东西(账本可能已落
|
|
1764
|
-
* `done` 而行仍 `running` 且攥着 session claim,靠 reapStale 收;LOCAL/file 车道无周期腿 ⇒ 上界 ∞,
|
|
1765
|
-
* [4664]② 洞 (d))。⇒ 本批的真差额 = **调用方能见度**,不是新增的数据分歧;补偿 = 下面这条 error 级
|
|
1766
|
-
* 留痕 + `resume_post_accept_failed_total` 计数(受理前的 4xx **不**计入,见 `accepted` 标志)。
|
|
1767
|
-
* 真正的根治(受理前durable 记一条可续的续跑意图 + 行驱动重试对账)是**另一批**的设计件,已上报。
|
|
1768
|
-
*/
|
|
1769
1066
|
async function driveResumeIntoRunLog(args) {
|
|
1770
1067
|
if (args.acceptEarly !== true)
|
|
1771
1068
|
return await driveResumeLeg(args);
|
|
1772
1069
|
let settleAccepted;
|
|
1773
|
-
// 🔴 codex 对抗复审 R1-[medium](验真后修):遥测必须能分辨「受理前的正常拒绝」与「受理后的脱手
|
|
1774
|
-
// 失败」。处理器挂在 race **之前**(它必须无条件挂:否则受理前就 reject 的腿会变成进程级
|
|
1775
|
-
// unhandledRejection),所以判别位不能靠「处理器有没有跑」——用受理回调**同步**置起的这个标志。
|
|
1776
|
-
// 没有它:每一次 429 admission 拒 / 409 CAS 输 / pre-CAS 绑定不符都会被记成「受理后失败」,把这条
|
|
1777
|
-
// 新信号淹掉(常态冲突远多于真脱手失败)。
|
|
1778
1070
|
let wasAccepted = false;
|
|
1779
1071
|
const accepted = new Promise((resolve) => { settleAccepted = resolve; });
|
|
1780
1072
|
const leg = driveResumeLeg(args, (receipt) => { wasAccepted = true; settleAccepted?.(receipt); });
|
|
1781
1073
|
void leg.then((out) => {
|
|
1782
|
-
// 受理前的 4xx = 正常的同步拒绝(它就是调用方拿到的那个响应),不是本信号的事。
|
|
1783
1074
|
if (wasAccepted && out.status >= 400) {
|
|
1784
1075
|
deps.logger?.warn?.("resume_leg_failed_after_accept", { sessionId: args.sessionId, status: out.status, body: out.body });
|
|
1785
1076
|
deps.metrics?.inc("resume_post_accept_failed_total");
|
|
1786
1077
|
}
|
|
1787
1078
|
}, (err) => {
|
|
1788
|
-
// 腿内部对「普通异常」已经驱行终局(claimedRow 守卫那支),这里补的是**可观察性**那一半。
|
|
1789
|
-
// 受理**前**抛的(race 会把它原样传给调用方 ⇒ 500)同样不计入本信号——那条路的可观察性归
|
|
1790
|
-
// 路由层的错误面,重复计数只会让「脱手失败」这个读数失去判别力。
|
|
1791
1079
|
if (!wasAccepted)
|
|
1792
1080
|
return;
|
|
1793
1081
|
deps.logger?.error?.("resume_leg_threw_after_accept", { sessionId: args.sessionId, err: err instanceof Error ? err.message : String(err) });
|
|
@@ -1795,33 +1083,15 @@ export function createHttpServer(rawDeps) {
|
|
|
1795
1083
|
});
|
|
1796
1084
|
return await Promise.race([accepted, leg]);
|
|
1797
1085
|
}
|
|
1798
|
-
/**
|
|
1799
|
-
* {@link driveResumeIntoRunLog} 的**腿本体**(实现逐字未动,只多了一个受理回调)。
|
|
1800
|
-
*
|
|
1801
|
-
* `onAccepted` = #316 的受理点回调:在 `resumeStream` 解析之后、流迭代之前触发**一次**(有 run 行时
|
|
1802
|
-
* 才触发 —— 没有行就没有可 poll 的终态面,那时提前受理等于把结果扔了)。缺席 ⇒ 全同步旧形。
|
|
1803
|
-
*/
|
|
1804
1086
|
async function driveResumeLeg(args, onAccepted) {
|
|
1805
|
-
// #196:`runner` = 本次续跑的执行 Runner(场景 hands 判别位裁定,与首跑同源;见 DriveResumeArgs 注)。
|
|
1806
1087
|
const { runner: legRunner, token, sessionId, principal, fleetScope, taskConfig, resumeObjective, outcome, verifyRounds } = args;
|
|
1807
|
-
// ── #151 车3 刀 3b:resume 腿的 `legKey` 真值(设计稿 §3.2′)──────────────────────────────────
|
|
1808
|
-
// 一次 park→resume 的 checkpoint token **就是**这条腿的天然身份:同一 token 重投 = 同一条腿(幂等,
|
|
1809
|
-
// askId 不变);新 park ⇒ 新 token ⇒ 新腿 ⇒ 新 askId ⇒ **重新征询**(§3.3「resume 只重放未完成兄弟」)。
|
|
1810
|
-
// 🔴 存**摘要**不存原文:token 是能力凭据(本仓有 `stripCheckpointToken` 专门把它从可重放账本/结果里
|
|
1811
|
-
// 剥掉),而 `askId` 是 wire 可见值 —— 摘要单向,原始 token 不进任何行、不进任何帧。
|
|
1812
|
-
// 入口算一次(这里就是唯一持有 `args.token` 的地方),下面随 ctx 走。
|
|
1813
1088
|
const legKey = createHash("sha256").update(String(token)).digest("hex");
|
|
1814
|
-
// §7.3:本 leg 的 walltime deadline —— 取值点 = 本腿开始执行之前(`resumeStream` 调用在下方)。
|
|
1815
|
-
// 与 sync/bg 两腿同一条判据:只在协议上场时供值,误差方向偏短(安全侧),理由见 routes/tasks.ts 同处注。
|
|
1816
1089
|
const streamApprovalOn = resolveStreamApprovalGate({
|
|
1817
1090
|
toolApprovalEnabled: Boolean(deps.toolApproval),
|
|
1818
1091
|
streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
|
|
1819
1092
|
backend: deps.backend,
|
|
1820
1093
|
parkFacility: deps.checkpointStore !== undefined,
|
|
1821
1094
|
}).active;
|
|
1822
|
-
// 🔴 codex 交叉复审 F4(2026-08-06 真 finding)的修:本腿此前**无条件**包 approval ALS(只把新协议的
|
|
1823
|
-
// 口与轴挂在 `streamApprovalOn` 后面)⇒ ①协议关时本腿凭空多了一条活卡投递路(此前恒 park,A-1 破)、
|
|
1824
|
-
// ②窗=0 在本腿上不生效。裁定走**三腿共用**的 `resolveApprovalLeg`,一份判据三处消费。
|
|
1825
1095
|
const approvalLeg = resolveApprovalLeg({
|
|
1826
1096
|
streamApprovalOn,
|
|
1827
1097
|
windowMs: deps.config.streamApproval?.windowMs ?? 0,
|
|
@@ -1830,11 +1100,6 @@ export function createHttpServer(rawDeps) {
|
|
|
1830
1100
|
nowMonotonicMs: performance.now(),
|
|
1831
1101
|
});
|
|
1832
1102
|
const legDeadlineMonotonic = approvalLeg.legDeadlineMonotonic;
|
|
1833
|
-
// 🔴 复审 C2:lease admission for EVERY resume family (/decide, /answer, /plan_review, preempt-resume)
|
|
1834
|
-
// funnels through here — keyed on `principal` = the CHECKPOINT OWNER, the SAME identity the resumed model spend
|
|
1835
|
-
// is billed to via `withPrincipal(principal)` below. The route guards deliberately do NOT lease-gate (they'd
|
|
1836
|
-
// key on the request principal = a possibly-cross-tenant operator). A live lease admits locally; center-judged
|
|
1837
|
-
// exhaustion of the OWNER's budget returns 429 (mirroring leaseDenied's shape); an unreachable center fails open.
|
|
1838
1103
|
if (deps.fleetLease && principal) {
|
|
1839
1104
|
const adm = await deps.fleetLease.admit(principal);
|
|
1840
1105
|
if (!adm.ok) {
|
|
@@ -1843,20 +1108,7 @@ export function createHttpServer(rawDeps) {
|
|
|
1843
1108
|
}
|
|
1844
1109
|
}
|
|
1845
1110
|
let taskId = await deps.runStore?.getActiveTaskId(sessionId);
|
|
1846
|
-
// [3829]/[3830] wake 腿(mintFreshRun 声明位):task_done 纯 park 的终局 finalize 已删 task_active
|
|
1847
|
-
// claim——本腿在此恒拿不到 taskId,曾经整腿无行裸跑(不可取消/无计费归因/无 durable append/fleet
|
|
1848
|
-
// 不可见),而 core `spec.taskId ?? sessionId` fallback 还会把 lastRunId 铸成 sessionId(续听恒 404,
|
|
1849
|
-
// cli 7.18 提货围栏首撞)。wake 续跑=同 session 新一轮 turn(原任务已终局),正确形=铸新 canonical
|
|
1850
|
-
// taskId + createRun 全生命周期行;createRun 的 task_active 唯一键 claim 同时补上 double-text 互斥
|
|
1851
|
-
// (getActiveTaskId 读后另一提交抢建行的竞态窗 ⇒ 与 sync 腿同形 409 真出路)。未声明该位的腿
|
|
1852
|
-
// (decide/answer/plan_review)语义不变:claim 缺席=行被 reap 的竞态,checkpoint token 同拍过期兜底。
|
|
1853
1111
|
let freshRunClaimed = false;
|
|
1854
|
-
// 🔴 codex R1-F1(2026-08-14):mintFreshRun 腿对「claim 有值」**只有 409 一条出路**,绝不落入下方的
|
|
1855
|
-
// markResuming 复用臂。本腿的 park 是终局后纯 park——它自己的行早已终局并释放 claim,此刻 claim 里
|
|
1856
|
-
// 的必是**别的 run**(典型:task_done park 之后同 session 又提交了新任务且其已 park 成 suspended,
|
|
1857
|
-
// claim 保留)。旧路径会用**本 park 的旧 token**对**对方的行**赢下 markResuming CAS 并驱动续跑——
|
|
1858
|
-
// 错误终局化对方的行、孤儿化对方仍 pending 的卡(checkpoint 表无 per-session pending 唯一约束,
|
|
1859
|
-
// findPendingTokenBySession 又是无序 LIMIT 1,两条 pending 并存时旧 task_done park 完全可能被选中)。
|
|
1860
1112
|
if (args.mintFreshRun && taskId && deps.runStore) {
|
|
1861
1113
|
return { status: 409, body: await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore, governance: deps.config, runStaleSec: deps.config.runStaleSec, turnActivity: readTurnActivityMs }, sessionId, taskId) };
|
|
1862
1114
|
}
|
|
@@ -1872,54 +1124,23 @@ export function createHttpServer(rawDeps) {
|
|
|
1872
1124
|
taskId = freshId;
|
|
1873
1125
|
freshRunClaimed = true;
|
|
1874
1126
|
}
|
|
1875
|
-
// MF-Fleet (TOC review #7): a HITL resume (/decide, /answer, /plan_review, /preempt-resume, /wake) drives the
|
|
1876
|
-
// SAME durable run row that suspended — so it must re-publish to the fleet bus under the SAME runId, or the run
|
|
1877
|
-
// VANISHES from GET /v1/fleet/stream the moment it resumes (the original publisher's row was last touched at
|
|
1878
|
-
// suspend; nothing keeps it live or settles it after). Reuse `taskId` (the existing suspended run id) so the row
|
|
1879
|
-
// RE-APPEARS as the same fleet row; scope = `principal` (the checkpoint/run-owner scope persisted at suspend —
|
|
1880
|
-
// the original submitter, NOT spoofable; matches the run row's owner). rootTaskId = taskId(codex F3,2026-08-13):
|
|
1881
|
-
// resume 的 taskConfig 自此携带 spec.taskId=run 行 id(下方注入点),core canonical 随之——[3806] 修完 sync
|
|
1882
|
-
// 两腿后本腿曾是最后一条 sessionId fallback 腿(resume 后 noteTaskRun 再铸 lastRunId 污染)。No-op when no
|
|
1883
|
-
// fleetBus / no taskId.
|
|
1884
|
-
// 🔒 scope = `fleetScope` (the VERIFIED `gatedPrincipal(req)` the calling route computed) — the SAME identity
|
|
1885
|
-
// GET /v1/fleet/stream filters by, mirroring the create-path #6 fix. NOT `principal` (= the spoofable
|
|
1886
|
-
// checkpoint/auth header from `principalFrom`): a spoofed header would re-publish the resumed row under a
|
|
1887
|
-
// victim's scope (cross-tenant leak) or under "default" while the real owner's stream filters it OUT (the row
|
|
1888
|
-
// vanishes on resume). Tenant-isolation parity with the stream filter on the direct resume door (TOC review #6).
|
|
1889
|
-
// onForwardEvent parity: core 1.210 added the RunInternals 4th arg to `resumeStream` — the
|
|
1890
|
-
// resume leg now feeds the forward sink like the sync/bg legs (fleet child rows at any depth + the durable
|
|
1891
|
-
// task_progress append; see the resumeStream call in driveResume).
|
|
1892
1127
|
const fleetPub = taskId && deps.fleetBus
|
|
1893
1128
|
? fleetRunPublisher(deps.fleetBus, { runId: taskId, scope: fleetScope, rootTaskId: taskId, ...fleetRunLabels(resumeObjective) })
|
|
1894
1129
|
: undefined;
|
|
1895
|
-
// codex F3(2026-08-13):resume 重建的 taskConfig 必须携带 run 行 tid——缺席时 core `spec.taskId ??
|
|
1896
|
-
// sessionId` fallback 喂 noteTaskRun,resume 一次 lastRunId 就再被污染成 sessionId([3806] sync 两腿
|
|
1897
|
-
// 修复的 resume 腿孪生)。canonical taskId 迁移的三个同源消费点(fleet rootTaskId / stoppedBy owner /
|
|
1898
|
-
// model-usage 归因键)本批同改,勿单点回退。
|
|
1899
1130
|
const resumeTaskConfig = taskId ? { ...taskConfig, taskId } : taskConfig;
|
|
1900
|
-
// C (TOC adversarial review): settle the resumed fleet row EXACTLY once. A plain Error thrown out of the
|
|
1901
|
-
// resume (not CheckpointError/cancel) hits the catch's `throw e` BEFORE the final onTerminal below → the
|
|
1902
|
-
// re-published row leaks as a stale "running". Route every onTerminal through this guard so the finally can
|
|
1903
|
-
// settle a row no branch reached, without double-settling one that did.
|
|
1904
1131
|
let fleetSettled = false;
|
|
1905
1132
|
const settleFleet = (status, residuals) => {
|
|
1906
1133
|
if (fleetSettled)
|
|
1907
1134
|
return;
|
|
1908
1135
|
fleetSettled = true;
|
|
1909
|
-
fleetPub?.onTerminal(status, residuals);
|
|
1136
|
+
fleetPub?.onTerminal(status, residuals);
|
|
1910
1137
|
};
|
|
1911
1138
|
let result;
|
|
1912
|
-
// [1245] codex-5: the resume.* code ALONE does not prove the reopen landed — installed core (1.330) only
|
|
1913
|
-
// rewrites to checkpoint.reopen_failed when store.reopen returned FALSE; a THROWING reopen (plain DB error)
|
|
1914
|
-
// keeps the resume.* code with the checkpoint still resolved. Confirm against the store before re-parking:
|
|
1915
|
-
// this token pending again ⇒ real re-park; not pending ⇒ fall through to the terminal/done leg; probe error
|
|
1916
|
-
// ⇒ re-park anyway (the safer side under uncertainty: a wrongly-suspended row is reaper-bounded
|
|
1917
|
-
// (reapSuspended), a wrongly-terminal row orphans a live park until TTL — the round-3 bug shape).
|
|
1918
1139
|
let reopenConfirmedPark = false;
|
|
1919
1140
|
const confirmCheckpointReopened = async () => {
|
|
1920
1141
|
const store = deps.checkpointStore;
|
|
1921
1142
|
if (!store)
|
|
1922
|
-
return true;
|
|
1143
|
+
return true;
|
|
1923
1144
|
try {
|
|
1924
1145
|
return (await store.findPendingTokenBySession(sessionId)) === token;
|
|
1925
1146
|
}
|
|
@@ -1927,59 +1148,22 @@ export function createHttpServer(rawDeps) {
|
|
|
1927
1148
|
return true;
|
|
1928
1149
|
}
|
|
1929
1150
|
};
|
|
1930
|
-
// Protect the in-flight resume from the reaper: flip the parked row to `running` + heartbeat for the
|
|
1931
|
-
// duration of the resume. The run row otherwise stays `suspended`/updated_at=suspend-time the whole time
|
|
1932
|
-
// (resume neither rewrites the row nor heartbeats), so `reapSuspended` would mark a live resume `failed`
|
|
1933
|
-
// and release its session lock once it crossed the approval TTL. Cleared in the finally below.
|
|
1934
1151
|
let resumeHeartbeat;
|
|
1935
|
-
let resumeManifestLease = 0;
|
|
1936
|
-
// 1.78 (review LOW, pre-existing): THIS leg's live TaskStream — held so the finally can identity-guard its
|
|
1937
|
-
// steerableRuns deregistration (an unconditional delete could clobber a handle a FASTER sibling leg just
|
|
1938
|
-
// registered under the same taskId across a suspend→fast-resume).
|
|
1152
|
+
let resumeManifestLease = 0;
|
|
1939
1153
|
let resumeStreamRef;
|
|
1940
|
-
let resumeLegLive = false;
|
|
1941
|
-
// design/122 ③: this leg's subagent-handle unregister thunks — run at leg end (finally), not on settle.
|
|
1154
|
+
let resumeLegLive = false;
|
|
1942
1155
|
const subagentHandleEvictions = [];
|
|
1943
1156
|
let claimedRow = false;
|
|
1944
|
-
// Cancel parity with runInBackground: the resumed leg is registered in `inflightRuns` (same-replica fast
|
|
1945
|
-
// path) and its heartbeat polls the durable cancel flag (cross-replica) — without this a resumed run was
|
|
1946
|
-
// un-cancellable (cancel 202'd + flagged the row, but nothing honored it). markResuming reset the flag,
|
|
1947
|
-
// so only a cancel issued DURING this leg aborts it.
|
|
1948
1157
|
const cancelCtrl = new AbortController();
|
|
1949
|
-
// 🔴 stoppedBy 归因的注册点在**下面**(markResuming 认领成功之后),不在这里 —— #168 件3,见那里的注。
|
|
1950
|
-
// design/80 seam #2: a resumed leg is itself preempt-able. Register a FRESH preempt controller (markResuming
|
|
1951
|
-
// also reset preempt_requested), thread a LIVE preemptSignal into the resume taskConfig below, and poll the
|
|
1952
|
-
// durable preempt flag in the heartbeat — so the scheduler can preempt the RESUMED work once it makes
|
|
1953
|
-
// progress. 🔴 Never re-supply the consumed suspend-time signal (core strips a pre-aborted one).
|
|
1954
1158
|
const preemptCtrl = new AbortController();
|
|
1955
1159
|
if (taskId && deps.runStore) {
|
|
1956
|
-
// The CAS fails (false) only if the row is no longer `suspended` — the reaper already expired this
|
|
1957
|
-
// approval (and released the session lock), the run is terminal, or a concurrent resume already claimed
|
|
1958
|
-
// it. Bail rather than run a resume whose run row is gone / a sibling owns; the checkpoint reaper expires
|
|
1959
|
-
// the token in the same tick so resumeStream would reject anyway. Closes the window the heartbeat can't.
|
|
1960
|
-
// [3829]:fresh 行由上面的 createRun 事务铸出即 `running` + claim 在手——markResuming 的 CAS 只认
|
|
1961
|
-
// suspended/needs_review,对它恒败;跳过=行本来就是本腿自己的,资格等价于 CAS 赢。
|
|
1962
1160
|
claimedRow = freshRunClaimed ? true : await deps.runStore.markResuming(taskId);
|
|
1963
1161
|
if (!claimedRow)
|
|
1964
1162
|
return { status: 409, body: { error: "run is not in a resumable (suspended) state (already resumed, decided, or expired)", errorCode: "conflict.not_resumable" } };
|
|
1965
|
-
|
|
1966
|
-
// 起本腿 canonical taskId == run 行 taskId(resumeTaskConfig 注入 spec.taskId),task-scoped 子代的 owner
|
|
1967
|
-
// 键值随之与 session-scoped 的分开——但 seam 仍按 EXPLICIT flag 过滤(`skipSessionScoped:true` inside
|
|
1968
|
-
// the helper),only task-scoped children take the "user" marker; session-scoped ones a cancelled parent
|
|
1969
|
-
// deliberately does NOT stop stay unmarked (CC Backgrounded semantics). Owner = taskId(this leg's
|
|
1970
|
-
// canonical key since the F3 injection), NOT sessionId.
|
|
1971
|
-
//
|
|
1972
|
-
// 🔴 位置(#168 件3,与 /v1/tasks/stream 同族):归因规则只有**抢到这条 run 的腿**才有资格挂 ——
|
|
1973
|
-
// `markResuming` 输掉的那一支(别的副本已经在 resume,或行已被 reap)在上面就 409 走人,从此一次
|
|
1974
|
-
// 都不注册。本腿此前把它挂在 CAS 之前,只是因为 `cancelCtrl` 的唯一 abort 源(inflightRuns / 心跳
|
|
1975
|
-
// 轮询)也都在 CAS 之后注册才没出事 —— 那是巧合不是保证。
|
|
1976
|
-
markChildrenStoppedByUserOnAbort(cancelCtrl.signal, taskId, principal); // owner=canonical taskId(codex F3:resumeTaskConfig 带 spec.taskId=run 行 id,core 子代 owner 键随之;此臂在 if(taskId) 块内恒真)
|
|
1977
|
-
// MF-Fleet (#7): the row is now `running` again → re-publish it LIVE to the fleet (re-appears as the same row
|
|
1978
|
-
// that was sitting "waiting" while parked). After onStart only — never if markResuming lost the CAS (a sibling
|
|
1979
|
-
// owns the row). onTerminal at every settle below keeps the row's lifecycle in lock-step with the run-store row.
|
|
1163
|
+
markChildrenStoppedByUserOnAbort(cancelCtrl.signal, taskId, principal);
|
|
1980
1164
|
fleetPub?.onStart();
|
|
1981
1165
|
const rs = deps.runStore;
|
|
1982
|
-
const owner = principal ?? null;
|
|
1166
|
+
const owner = principal ?? null;
|
|
1983
1167
|
inflightRuns.set(taskId, cancelCtrl);
|
|
1984
1168
|
preemptableRuns.set(taskId, preemptCtrl);
|
|
1985
1169
|
resumeHeartbeat = setInterval(() => {
|
|
@@ -1994,98 +1178,39 @@ export function createHttpServer(rawDeps) {
|
|
|
1994
1178
|
}
|
|
1995
1179
|
}, HEARTBEAT_MS);
|
|
1996
1180
|
resumeHeartbeat.unref?.();
|
|
1997
|
-
deps.modelUsage?.register(taskId);
|
|
1998
|
-
resumeManifestLease = deps.promptManifests?.register(taskId) ?? 0;
|
|
1181
|
+
deps.modelUsage?.register(taskId);
|
|
1182
|
+
resumeManifestLease = deps.promptManifests?.register(taskId) ?? 0;
|
|
1999
1183
|
}
|
|
2000
|
-
// codex M2 hook point: the CAS (when a run row exists) is decided above — a lost race already returned 409 and
|
|
2001
|
-
// never reaches here. Awaited BEFORE the model drive so the resumed leg's first ask observes the side-effects.
|
|
2002
1184
|
if (args.onResumeCommitted)
|
|
2003
1185
|
await args.onResumeCommitted();
|
|
2004
|
-
// #310 `engine_notice` wire 腿(三腿之一:**resume**)。声明在 try 之外、赋值在 `append` 铸出之后
|
|
2005
|
-
// (账本写口在 withPrincipal 闭包里才成形),注销在下方同一个 finally —— 任何退出路径都过它。
|
|
2006
1186
|
let unregisterEngineNotice;
|
|
2007
1187
|
try {
|
|
2008
|
-
// Wrap in withPrincipal so the resumed leg's LLM spend is attributed to the tenant's CostQuota — parity
|
|
2009
|
-
// with the sync/async paths (council: otherwise maxPrincipalCostUsd is silently bypassed on resume).
|
|
2010
1188
|
result = await withPrincipal(principal, async () => {
|
|
2011
|
-
// P1 ①② follow-on: drain the async-workflow completion inbox on the RESUME leg — hoisted
|
|
2012
|
-
// ABOVE the verify/stream fork so BOTH resume flavors get it (parity with runs.ts, whose drain runs
|
|
2013
|
-
// "before ANY branch"; review round 2 caught the verify-leg gap). A workflow that finished while this run
|
|
2014
|
-
// sat suspended surfaces as a durable `workflow_complete` event the tailing client replays (at-least-once
|
|
2015
|
-
// + shell runId-dedup). Owner gate = the original submitter (the checkpoint scope) — the identity the
|
|
2016
|
-
// entries were stamped with at enqueue. 🔒 ONLY when a durable log exists (`deps.runStore && taskId`):
|
|
2017
|
-
// without one there is nowhere to persist the frame, and `drain` REMOVES entries — draining there would
|
|
2018
|
-
// EAT the completions. One-shot appender off maxSeq; each branch below re-reads maxSeq, so seq stays safe.
|
|
2019
|
-
// option A: keys this leg already delivered (turn-open drain, loop case OR observer) — hoisted ABOVE the
|
|
2020
|
-
// drain so the drained frames' keys register here too (core [784]④b: the same idle-arrival is also parked
|
|
2021
|
-
// core-side; its pend-drain replay rides the resumed stream as a task_notification event — the loop's key
|
|
2022
|
-
// check below folds that second durable append). codex F1: NotifiedKeys — the observer arm appends
|
|
2023
|
-
// fire-and-forget, so claims carry the write's outcome promise (bg-leg twin's rationale).
|
|
2024
1189
|
const resumeNotifiedKeys = new NotifiedKeys();
|
|
2025
1190
|
if (deps.runStore && taskId) {
|
|
2026
1191
|
const rs0 = deps.runStore;
|
|
2027
1192
|
let drainSeq = (await rs0.maxSeq(taskId)) + 1;
|
|
2028
1193
|
await emitPendingWorkflowCompletions(deps.workflowCompletionInbox, sessionId, principal ?? null, (frame) => {
|
|
2029
|
-
// 🔴 本仓唯一的**动态**账本写口:`type` 逐字来自 core 帧,不是本仓字面量,所以闭集
|
|
2030
|
-
// `LedgerEventType` 在这里只能是**声明**而非证明(A-032 P1-③ 射程上限,ledger-events.ts 顶注
|
|
2031
|
-
// 有同款自陈)。今天这条腿产出的是 `task_notification`;core 换帧名时**本处不会红**,对齐由
|
|
2032
|
-
// core 帧集的消费门(`trace/core-keyset-guard.ts` 一族)负责。
|
|
2033
1194
|
const { type, ...rest } = frame;
|
|
2034
|
-
// codex F1 + 修2(三路复审 notify-1):the claim must carry the WRITE OUTCOME, not a pre-resolved
|
|
2035
|
-
// true — a transient append failure with a `Promise.resolve(true)` claim would fake-suppress the
|
|
2036
|
-
// stream/core-side replay of the SAME key while the durable row never landed (the un-acked inbox
|
|
2037
|
-
// entry re-delivers next leg, but this leg's replay was eaten). Mirror of the bg-leg/runs.ts twin.
|
|
2038
|
-
// codex F2: task_type rides the key (external 帧独立键域 — bg twin's note).
|
|
2039
1195
|
const f = frame;
|
|
2040
1196
|
const write = rs0.appendEvent(taskId, drainSeq++, type, rest);
|
|
2041
1197
|
if (f.type === "task_notification" && f.task_id)
|
|
2042
1198
|
resumeNotifiedKeys.set(taskNotificationStreamKey({ task_type: f.task_type, task_id: f.task_id, status: String(f.status), seq: f.seq }), write.then(() => true, () => false));
|
|
2043
1199
|
return write;
|
|
2044
|
-
},
|
|
2045
|
-
// emit-target diagnostics — this drain appends to the resumed run's durable event log
|
|
2046
|
-
// (a tailing GET /v1/runs/:id/events client replays it; nothing is written to a live socket here).
|
|
2047
|
-
{ route: "resume-open", connection: "durable-append", log: (m, x) => deps.logger?.info?.(m, x) });
|
|
1200
|
+
}, { route: "resume-open", connection: "durable-append", log: (m, x) => deps.logger?.info?.(m, x) });
|
|
2048
1201
|
}
|
|
2049
|
-
// Verify task → resume AND re-gate (core resumeWithVerification): resume the impl, and if it COMPLETES
|
|
2050
|
-
// run the identical adversarial verifier + fix loop; if it re-suspends it comes back failed-with-token
|
|
2051
|
-
// (handled like any re-suspend below). 🔴 Freshness (BUG5, design/53 §2.B): we pass NO `evidence` diff,
|
|
2052
|
-
// so the verifier judges the POST-resume working tree directly — there is no stale pre-suspend diff to
|
|
2053
|
-
// grade, which closes the "approve then plant a backdoor" window by construction.
|
|
2054
1202
|
if (verifyRounds !== undefined) {
|
|
2055
|
-
// ── #253(core 5.33.0 #246 提货):**这条腿的供给座席**。5.32 及以前 `resumeWithVerification`
|
|
2056
|
-
// 的签名里没有 internals 那一位 ⇒ 带 verify 的 resume 对子代进度 / 活性 / bg 终态通知全盲
|
|
2057
|
-
// (旁边 resumeStream 臂自 core 1.210 起就有);5.33.0 开了尾可选 `internals`,逐字转发给
|
|
2058
|
-
// **恢复实现腿 + 验证器腿 + 每一轮修复腿**。这里把 resumeStream 臂**同一套**供给链接上去。
|
|
2059
|
-
// ⚠️ `isolation` 一位永不传(core 在这道门上双相位响亮拒 `config.verify_internals_invalid`)。
|
|
2060
1203
|
const rsV = deps.runStore;
|
|
2061
|
-
// 🔴 **本臂的账本写口 = 共享 `LedgerSink`**(codex 对抗复审 R1 [high],验真后修)。为什么是它
|
|
2062
|
-
// 而不是在这里手写三行 append:
|
|
2063
|
-
// ① **提交序 == 序号序**:sink 的单写串行链在**链步内**分配序号(它的 [1.211 codex H2] 注)。
|
|
2064
|
-
// 此前这条臂的 forward 写是 fire-and-forget、终局/manifest 是 awaited,各自独立发起 ⇒
|
|
2065
|
-
// 池化 SQL 后端上**高序号可以先提交**,游标读方(`afterSeq`)推进过去之后,迟到的低序号
|
|
2066
|
-
// 行**永久读不到**;
|
|
2067
|
-
// ② 序号**一次铸定**:此前在腿跑完之后才 `maxSeq()+1` 现读,与运行期的 forward 写撞号 ——
|
|
2068
|
-
// 改成腿开跑前读一次当**基址**,sink 的 1..N 加上去(零 forward 帧时与旧写法逐字等价);
|
|
2069
|
-
// ③ forward 帧的**投影**(task_progress / tool_start / tool_end 三型 + 白名单 builder + §E1
|
|
2070
|
-
// 脱敏)也归它,本臂不再抄一份 —— 三腿同源正是那个模块存在的理由。
|
|
2071
|
-
// ⚠️ **不设「终局即封口」的准入闸**(codex R1/R2 都提了「终局后不该再落行」,**验真后驳回**):
|
|
2072
|
-
// 本仓已有属主裁定 —— `runs-coverage` 的 [1840]§三定性钉(mock-anthropic 确定性复现器取证):
|
|
2073
|
-
// `run_in_background` **收养形**子代的整个生命周期在宿主腿终态**之后**,live 流按设计 terminal
|
|
2074
|
-
// break,帧**照落账**、消费端 done 后重拉即全见;「leg teardown 撕掉 sink」对那一族子代就是
|
|
2075
|
-
// **真丢帧**(施工期实测:在 bg 腿加同款围栏,那条钉当场红)。⇒ 本臂与 bg 腿同一条语义:
|
|
2076
|
-
// 晚到照落、序号单调。
|
|
2077
1204
|
const vSeqBase = rsV && taskId ? await rsV.maxSeq(taskId) : 0;
|
|
2078
1205
|
const verifySink = createLedgerSink({
|
|
2079
1206
|
appendEvent: (seq, type, data) => (rsV && taskId ? rsV.appendEvent(taskId, vSeqBase + seq, type, data) : Promise.resolve()),
|
|
2080
1207
|
persistThinking: deps.config.traceThinking,
|
|
2081
|
-
notifiedKeys: resumeNotifiedKeys,
|
|
1208
|
+
notifiedKeys: resumeNotifiedKeys,
|
|
2082
1209
|
...(taskId ? { onActivity: () => recordTurnActivity(taskId) } : {}),
|
|
2083
1210
|
});
|
|
2084
1211
|
const vAppend = (type, data) => verifySink.append(type, data);
|
|
2085
1212
|
const verifyTailContentMode = resumeTaskConfig.forwardSubagentEvents === true ? "on" : "progress_only";
|
|
2086
1213
|
const verifyInternals = {
|
|
2087
|
-
// 活性打点:这条臂没有事件循环(stream 臂的 `recordTurnActivity` 在 for-await 里),
|
|
2088
|
-
// 不接 ⇒ 一条长跑的 verify-resume 腿在活性面上从 suspend 那刻起就冻住。
|
|
2089
1214
|
...(taskId ? { onActivity: () => recordTurnActivity(taskId) } : {}),
|
|
2090
1215
|
onForwardEvent: (e) => {
|
|
2091
1216
|
fleetPub?.onForwardEvent(e);
|
|
@@ -2100,32 +1225,15 @@ export function createHttpServer(rawDeps) {
|
|
|
2100
1225
|
}
|
|
2101
1226
|
}
|
|
2102
1227
|
}
|
|
2103
|
-
// 三型投影 + 白名单 builder + 单写串行链全在共享 sink 里(见上面的顶注);写失败留痕由
|
|
2104
|
-
// sink 自己的 catch 兜(它对失败步不断链)。
|
|
2105
1228
|
if (rsV && taskId)
|
|
2106
1229
|
verifySink.onForwardEvent(e);
|
|
2107
1230
|
},
|
|
2108
|
-
// 🔴 **#255(core 5.34.0 #253 / 判据帖 [3967] G1):status 第五席**。这条臂没有事件循环 ——
|
|
2109
|
-
// 旁边 resumeStream 臂的顶层 status 走它自己那条 for-await 的 `case "status"`(driveResume
|
|
2110
|
-
// switch),本臂在 5.33 时代整段重试期是**黑的**(座位那时还不存在)。core 5.34.0 的
|
|
2111
|
-
// `statusEmit` 双写(queue 照旧 + 座位喂**同一个** frozen 帧)把这条腿补齐,这里接的是
|
|
2112
|
-
// **同一条**投影链:`brain_retry_total` 计数 + 共享 `verifySink` 的白名单 builder/单写串行链。
|
|
2113
|
-
// ⚠️ 帧 frozen ⇒ 这条链上不许写帧字段;同步回调不能 await ⇒ fire-and-forget。下面那个
|
|
2114
|
-
// `.catch` 买的是**留痕**不是进程安全:`appendStatus` 返回的链步在 `LedgerSink.append` 内部
|
|
2115
|
-
// 已被挂上处理器(「失败不断链」)⇒ 没有逃逸的 rejection(实测:摘掉它 §4⑥b 仍绿);
|
|
2116
|
-
// 判别力由 §4⑥b 的「诊断行在场」断言看守(与 `src/runs.ts` 的 `statusSeat` 同款注)。
|
|
2117
|
-
// ⚠️ 与 `src/runs.ts` 的 `statusSeat` 同一条纪律:**只**挂在「只回结果」的这条臂上,
|
|
2118
|
-
// 旁边 resumeStream 臂那只 internals 不带它(带了就是同一帧两行账本 + 计数翻倍)。
|
|
2119
|
-
// 🔴 走 sink 的 **`appendStatus` 同步入链门**,不是 `onEvent` —— 后者先 `await flush()`
|
|
2120
|
-
// 才入链,同一拍后到的 `onForwardEvent`(同步入链)会抢到更小的序号,账本时间线倒挂
|
|
2121
|
-
// (codex 对抗复审 R2 [medium],红先复现后修;本臂与 `src/runs.ts` 的 `statusSeat` 同病同修)。
|
|
2122
1231
|
onStatusEvent: (e) => {
|
|
2123
1232
|
deps.metrics?.inc("brain_retry_total", { phase: String(e.phase) });
|
|
2124
1233
|
if (rsV && taskId) {
|
|
2125
1234
|
void verifySink.appendStatus(e).catch((err) => deps.logger?.warn?.("status_event_append_failed", { route: "resume-verify", taskId, err: err instanceof Error ? err.message : String(err) }));
|
|
2126
1235
|
}
|
|
2127
1236
|
},
|
|
2128
|
-
// bg 子代终态:与 resumeStream 臂逐字同款(理由全在那份孪生注里)。
|
|
2129
1237
|
onTaskNotification: (n) => {
|
|
2130
1238
|
if (n.task_type === "workflow")
|
|
2131
1239
|
return;
|
|
@@ -2146,7 +1254,6 @@ export function createHttpServer(rawDeps) {
|
|
|
2146
1254
|
}
|
|
2147
1255
|
}
|
|
2148
1256
|
},
|
|
2149
|
-
// 子代 steer 句柄登记:与 resumeStream 臂同款(腿末尾统一撤,见 finally)。
|
|
2150
1257
|
...(deps.subagentSteerRegistry && taskId
|
|
2151
1258
|
? {
|
|
2152
1259
|
onSubagentSpawn: (handle) => {
|
|
@@ -2155,122 +1262,64 @@ export function createHttpServer(rawDeps) {
|
|
|
2155
1262
|
}
|
|
2156
1263
|
: {}),
|
|
2157
1264
|
};
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
resumeLegLive = true; // 与 stream 臂同义:腿在跑 ⇒ bg 终态走 live 落账而非 durable park(finally 清)
|
|
2161
|
-
const vr = await resumeWithVerification(legRunner, token, outcome, { ...resumeTaskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, resumeObjective, verifyRounds, verifyInternals); // 快审 F1:整对象直传;codex F3:带 taskId 的形
|
|
1265
|
+
resumeLegLive = true;
|
|
1266
|
+
const vr = await resumeWithVerification(legRunner, token, outcome, { ...resumeTaskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, resumeObjective, verifyRounds, verifyInternals);
|
|
2162
1267
|
let safe = stripCheckpointToken(vr);
|
|
2163
|
-
// 🔴 A re-suspend on a LATER gate is NOT terminal: core maps it to status:"failed" +
|
|
2164
|
-
// verification.unverifiedReason with the checkpoint token still live. Treat it exactly like
|
|
2165
|
-
// the stream path's park branches — write the matching park event and return the park status so the
|
|
2166
|
-
// outer block calls setSuspended/setNeedsReview (keeps the session lock + resumable row). Driving it
|
|
2167
|
-
// terminal would release the lock and orphan the second checkpoint (next /decide → 409, task abandoned).
|
|
2168
|
-
//
|
|
2169
|
-
// 🔴 park 是**两个词**(扫描P2,status/kind 同名词混淆病族),而且判据必须取 **errorCode** 而不是
|
|
2170
|
-
// `verification.unverifiedReason` —— 完整理由(含修复轮那条 reason 恒缺席的腿)见
|
|
2171
|
-
// {@link nestedParkStatusOf} 的顶注,它是这条判据的唯一属主。只认 `unverifiedReason === "suspended"`
|
|
2172
|
-
// 会漏掉两类真 park:plan_review / dry_run 的 `needs_review` 再停,以及 verifier 判 FAIL 后修复轮
|
|
2173
|
-
// 的**任一**再停 —— 两类都会被写成 `done` + setTerminal:锁被释放、活卡成孤儿。
|
|
2174
|
-
// ⚠️ `verifier_paused` 不进这个集合:那是**验证器自己**的会话/凭据(见 core `VerificationOutcome
|
|
2175
|
-
// .verifierPause`),本 run 的活已经做完,与「本 run 再停在自己的门上」是两件事。
|
|
2176
1268
|
const reParkStatus = nestedParkStatusOf(vr);
|
|
2177
1269
|
const reSuspended = reParkStatus !== undefined;
|
|
2178
|
-
// [1245] codex-4: reopen-class twin of the stream path (store-confirmed, codex-5) — the checkpoint
|
|
2179
|
-
// is pending again, so the durable log must record a park, never `done` (the outer row drive
|
|
2180
|
-
// re-parks via checkpointReopened; the response keeps status:"failed"+retriable, unlike reSuspended
|
|
2181
|
-
// which surfaces "suspended").
|
|
2182
1270
|
const vrReopened = isCheckpointReopenedFailure(vr) && (await confirmCheckpointReopened());
|
|
2183
1271
|
if (vrReopened)
|
|
2184
1272
|
reopenConfirmedPark = true;
|
|
2185
1273
|
if (taskId && deps.runStore) {
|
|
2186
1274
|
const rs2 = deps.runStore;
|
|
2187
|
-
// codex-6/7 (vrReopened leg): EVERYTHING here is observability — best-effort once the reopen is
|
|
2188
|
-
// confirmed (maxSeq + manifest + usage + suspended event): a thrown append must not rethrow past
|
|
2189
|
-
// the authoritative row re-park below (stream twin). reSuspended-only keeps its fail-loud shape.
|
|
2190
1275
|
try {
|
|
2191
|
-
// #253:序号来自腿开跑前铸的**同一个**分配器(`vseq`/`vChain`),不再在这里二次 `maxSeq()`
|
|
2192
|
-
// ——腿运行期间 forward sink 可能已经写过行,二次现读会与那些 fire-and-forget 写撞号。
|
|
2193
1276
|
const ap = vAppend;
|
|
2194
|
-
await appendPromptManifest(ap, deps.promptManifests, taskId);
|
|
1277
|
+
await appendPromptManifest(ap, deps.promptManifests, taskId);
|
|
2195
1278
|
if (reSuspended || vrReopened) {
|
|
2196
|
-
await appendModelUsageDelta(ap, deps.modelUsage, taskId);
|
|
2197
|
-
// reopen 腿恒落 `suspended`(它不是 gate 再停,是同一张卡被重开);reSuspended 腿按真 park 词落名。
|
|
2198
|
-
// [4913]:再停事件带待批 toolCallId(源=新 checkpoint 的 pendingAction;tool-less/读失败=键缺席)。
|
|
1279
|
+
await appendModelUsageDelta(ap, deps.modelUsage, taskId);
|
|
2199
1280
|
const vrParkCallId = reSuspended ? await parkToolCallId(vr, deps.checkpointStore) : undefined;
|
|
2200
1281
|
await ap(reSuspended ? reParkStatus : "suspended", { gate: vr.checkpointGate ?? null, ...(vrParkCallId !== undefined ? { toolCallId: vrParkCallId } : {}), ...(vrReopened ? { reopened: vr.errorCode ?? null } : {}) });
|
|
2201
1282
|
}
|
|
2202
1283
|
else {
|
|
2203
|
-
// E8: this verified-resume leg is the run's final done → flush the last delta + SUM model_usage into stats.modelUsage
|
|
2204
1284
|
safe = await attachModelUsage(safe, { append: ap, getEvents: (id, a) => rs2.getEvents(id, a), modelUsage: deps.modelUsage, taskId });
|
|
2205
|
-
await ap("done", { result: safe });
|
|
1285
|
+
await ap("done", { result: safe });
|
|
2206
1286
|
}
|
|
2207
1287
|
}
|
|
2208
1288
|
catch (e) {
|
|
2209
1289
|
if (!vrReopened)
|
|
2210
|
-
throw e;
|
|
1290
|
+
throw e;
|
|
2211
1291
|
}
|
|
2212
1292
|
}
|
|
2213
1293
|
if (reSuspended)
|
|
2214
1294
|
return { ...safe, status: reParkStatus };
|
|
2215
|
-
// Count the gate verdict only when the verifier actually ran (a re-suspend never reached it).
|
|
2216
1295
|
if (vr.verification?.verdict)
|
|
2217
1296
|
deps.metrics?.inc("verifications_total", { verdict: vr.verification.verdict });
|
|
2218
1297
|
return safe;
|
|
2219
1298
|
}
|
|
2220
|
-
// resumeStream runs the SAME pre-CAS guard + atomic CAS as resume(), then returns a live stream; a
|
|
2221
|
-
// lost CAS rejects (CheckpointError) BEFORE any stream → never re-runs a resolved action.
|
|
2222
|
-
// E23: core's resumeStream is EAGER (runtask.js:1710 — once it resolves the resumed run is already
|
|
2223
|
-
// executing), so the per-run elicitation context MUST exist BEFORE the stream is created, or an inbound MCP
|
|
2224
|
-
// elicitation early in the resumed leg finds no ALS store and fail-closed declines. Hoist the durable-append
|
|
2225
|
-
// machinery, then create + drain the stream INSIDE runWithContext (emit = append → events tail). The owner is
|
|
2226
|
-
// the ORIGINAL submitter (`principal` = the checkpoint scope), NOT the operator who /decided — they answer.
|
|
2227
1299
|
const rs = deps.runStore;
|
|
2228
1300
|
const startSeq = rs && taskId ? await rs.maxSeq(taskId) : 0;
|
|
2229
1301
|
let text = "";
|
|
2230
1302
|
let reasoning = "";
|
|
2231
1303
|
const persistThinking = deps.config.traceThinking;
|
|
2232
|
-
// 🔴 **单写串行链**(#310 codex 对抗复审 R1-[high],验真后修)。此前本腿是裸写口
|
|
2233
|
-
// (`rs.appendEvent(taskId, ++seq, …)`,同步分配 seq、立刻发起 insert)—— 主循环 await 自己的写
|
|
2234
|
-
// 所以主循环内部有序,但本腿有**两族 fire-and-forget 写者**:子代 forward 帧(下方 `warnAppend`
|
|
2235
|
-
// 那三处,既有)与 #310 的通告口(新增)。它们的 N 号 insert 在池化 SQL 后端上可能晚于后发的
|
|
2236
|
-
// N+1 号提交,而 events tail 读 `getEvents(id, afterSeq)`:读到 N+1 就把游标推过 N,那一行
|
|
2237
|
-
// **此后永远不会被投递**(重连也不会,游标只前进)。bg / sync 两腿早就有这条链(`LedgerSink`
|
|
2238
|
-
// 顶注 [1.211 codex H2] 逐字记着同一个病),本腿是漏掉的第三条 —— 判据抽成共享件,三腿同源。
|
|
2239
1304
|
const serialAppend = createSerialLedgerAppend((s, type, data) => rs.appendEvent(taskId, s, type, data), startSeq);
|
|
2240
1305
|
const append = (type, data) => (rs && taskId ? serialAppend(type, data) : Promise.resolve());
|
|
2241
|
-
// #310:本腿同样**没有 live SSE**(续跑的消费方走 `GET /v1/runs/:id/events` 的 durable tail),
|
|
2242
|
-
// 故只挂 durable 口 —— 与本腿的 elicit/question/approval 三族帧姿态逐字相同。无账本(无 rs/taskId)
|
|
2243
|
-
// 时 `append` 本就是 resolve() 空写,注册它不产生任何行(与本腿其它帧同一条退化路径)。
|
|
2244
1306
|
unregisterEngineNotice = registerEngineNoticeLeg({
|
|
2245
1307
|
sessionId,
|
|
2246
|
-
// 写失败留痕不裸吞(codex 对抗复审 R1-[medium]):本腿账本同样是通告的唯一用户可见终点,
|
|
2247
|
-
// 与下方子代 forward 写口的 `warnAppend` 同一条纪律(C2/C5 批2)。
|
|
2248
1308
|
durable: (row) => void append("engine_notice", row).catch((err) => {
|
|
2249
1309
|
recordFailOpen("server.engine-notice.durable-append-failed", `leg=resume code=${row.code} task=${taskId ?? ""}`);
|
|
2250
|
-
// 字段名是 `noticeCode` 不是 `code`:`error-code-key-gate` 按词法扫 http/ 全树的 `code:` 键
|
|
2251
|
-
// (3.0.0 起 wire 错误体只认 `errorCode`),一条日志行也不给它留歧义面。
|
|
2252
1310
|
deps.logger?.warn?.("engine_notice_append_failed", { taskId, noticeCode: row.code, sessionId: row.sessionId, err: err instanceof Error ? err.message : String(err) });
|
|
2253
1311
|
}),
|
|
2254
1312
|
});
|
|
2255
|
-
// (resumeNotifiedKeys — this leg's delivered-notification keys — is hoisted above the inbox drain, see top)
|
|
2256
|
-
// E18: anchor POST-resume turns too (a resumed conversation's new messages are rewindable). Same capture as
|
|
2257
|
-
// runInBackground via the SHARED TurnAnchorCapture (only a settled assistant-text turn is anchored — see its
|
|
2258
|
-
// doc). owner = the original submitter (the checkpoint scope), not the operator who /decided. undefined
|
|
2259
|
-
// captureTurnAnchor ⇒ no anchor store / no getLeafId.
|
|
2260
1313
|
const anchorOwner = principal ?? null;
|
|
2261
1314
|
const getLeafId = deps.sessionStorage?.getLeafId?.bind(deps.sessionStorage);
|
|
2262
1315
|
const captureTurnAnchor = deps.resumeAnchorStore && getLeafId
|
|
2263
1316
|
? async (eventId) => { const leaf = await getLeafId(sessionId); if (leaf)
|
|
2264
1317
|
await deps.resumeAnchorStore.put(sessionId, eventId, leaf, anchorOwner); }
|
|
2265
1318
|
: undefined;
|
|
2266
|
-
// R8 (CC-parity rewind): USER-message anchor keyed by taskId (parity with the first-run path). A resume leg
|
|
2267
|
-
// rarely commits a new user message, but wiring it is harmless (only `message_committed{role:"user"}` fires it).
|
|
2268
1319
|
const captureUserMessageAnchor = deps.resumeAnchorStore && taskId
|
|
2269
1320
|
? async (entryId) => { await deps.resumeAnchorStore.put(sessionId, taskId, entryId, anchorOwner); }
|
|
2270
1321
|
: undefined;
|
|
2271
1322
|
const anchor = new TurnAnchorCapture(captureTurnAnchor, () => deps.metrics?.inc("resume_anchor_capture_failed"), captureUserMessageAnchor);
|
|
2272
|
-
// Parity with runInBackground (src/runs.ts): the resume leg must capture thinking + per-turn usage too,
|
|
2273
|
-
// else a suspend→resume loses them on post-resume turns. Reasoning flushed before content, redacted.
|
|
2274
1323
|
const flush = async () => {
|
|
2275
1324
|
if (reasoning) {
|
|
2276
1325
|
await append("reasoning", { text: redactSecrets(reasoning) });
|
|
@@ -2279,31 +1328,17 @@ export function createHttpServer(rawDeps) {
|
|
|
2279
1328
|
if (text) {
|
|
2280
1329
|
await append("text", { text, ...(anchor.firstTextEventId ? { eventId: anchor.firstTextEventId } : {}) });
|
|
2281
1330
|
text = "";
|
|
2282
|
-
}
|
|
1331
|
+
}
|
|
2283
1332
|
};
|
|
2284
1333
|
const driveResume = async () => {
|
|
2285
|
-
// (the workflow-completion inbox drain for this leg is hoisted ABOVE the verify/stream fork — see top)
|
|
2286
|
-
// core 1.210: the resume leg now has the RunInternals seam — fan a subagent's task_progress
|
|
2287
|
-
// tick (child-isolated stream; the forward sink is its ONLY exit) out to the fleet AND the durable log
|
|
2288
|
-
// (shared whitelist+redact builder + E2 identity), parity with the sync/bg legs. Fire-and-forget append
|
|
2289
|
-
// (`++seq` is a sync increment — sink-vs-loop appends get unique seqs); only when a durable log exists.
|
|
2290
|
-
// false once this leg settles — notifications after that take the durable-inbox path.
|
|
2291
1334
|
resumeLegLive = true;
|
|
2292
|
-
// [3911] 定谳(bg/sync 两腿 twin):per-agent tail 的**内容帧供给**由本腿 spec 的
|
|
2293
|
-
// forwardSubagentEvents 决定(core prepare-task 的 forward 门)。逐帧按**句柄**登记
|
|
2294
|
-
// (键为什么是句柄:SubagentTailBus.modeByHandle 顶注)。⚠️ 旧注这里写的「verify 腿不传
|
|
2295
|
-
// internals ⇒ 无帧 ⇒ meta 出 unknown」自 #253(core 5.33.0 #246)起**已过期**:verify 臂
|
|
2296
|
-
// 现在也传 internals(见上面那段),两条 resume 臂的供给形因此同形。
|
|
2297
1335
|
const tailContentMode = resumeTaskConfig.forwardSubagentEvents === true ? "on" : "progress_only";
|
|
2298
1336
|
const stream = await legRunner.resumeStream(token, outcome, { ...resumeTaskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, {
|
|
2299
1337
|
onForwardEvent: (e) => {
|
|
2300
1338
|
fleetPub?.onForwardEvent(e);
|
|
2301
|
-
// S2 live tail(复审 #1:forward sink 有三条腿——resume 腿上 spawn 的 bg 子代同样带
|
|
2302
|
-
// bgAgentId,漏挂=同副本开流只见 heartbeat)。publish 在 rs/taskId 门之前(tail 独立于账本)。
|
|
2303
1339
|
{
|
|
2304
1340
|
const bg = e.bgAgentId;
|
|
2305
1341
|
if (bg !== undefined) {
|
|
2306
|
-
// 登记在 hasSubscribers 快门**之前**:没人在听时也要学,否则晚开的 tail 读不到供给形。
|
|
2307
1342
|
defaultSubagentTailBus.noteHandleContentMode(bg, tailContentMode);
|
|
2308
1343
|
if (defaultSubagentTailBus.hasSubscribers(bg)) {
|
|
2309
1344
|
const f = projectTailFrame(e);
|
|
@@ -2315,60 +1350,37 @@ export function createHttpServer(rawDeps) {
|
|
|
2315
1350
|
if (!rs || !taskId)
|
|
2316
1351
|
return;
|
|
2317
1352
|
const t = e.type;
|
|
2318
|
-
// C2/C5(v3.1 批2):写失败留痕——同函数 onTaskNotification 的 park-enqueue 早按 2026-07-11
|
|
2319
|
-
// 对抗评审补了 warn(false park 指纹),这三处同构 fire-and-forget 此前裸吞:子代 tool 生命周期
|
|
2320
|
-
// 事件从 durable log 永久消失且零信号。结构上仍不能 await(同步回调),留痕不改调用形。
|
|
2321
1353
|
const warnAppend = (kind) => (err) => deps.logger?.warn?.("forward_event_append_failed", { type: kind, taskId, err: err instanceof Error ? err.message : String(err) });
|
|
2322
1354
|
if (t === "task_progress") {
|
|
2323
1355
|
void append("task_progress", taskProgressEventData(e)).catch(warnAppend(t));
|
|
2324
1356
|
}
|
|
2325
1357
|
else if (t === "tool_start") {
|
|
2326
|
-
// C1 (core 1.219): a delegated child's forwarded tool lifecycle — durable via the SAME shared
|
|
2327
|
-
// whitelist+redact builders as the top stream (parity with the bg leg; deltas not persisted per-chunk).
|
|
2328
1358
|
void append("tool_start", toolStartEventData(e)).catch(warnAppend(t));
|
|
2329
1359
|
}
|
|
2330
1360
|
else if (t === "tool_end") {
|
|
2331
1361
|
void append("tool_end", toolEndEventData(e)).catch(warnAppend(t));
|
|
2332
1362
|
}
|
|
2333
1363
|
},
|
|
2334
|
-
// background-completion observer — flip the child's fleet row +
|
|
2335
|
-
// park an idle-arrival in the durable inbox (workflow-type skipped). Rationale in runs.ts (bg twin).
|
|
2336
|
-
// core 1.283 `seq` (per-stop-cycle counter) rides into the dedup key + parked entry — sync twin's note.
|
|
2337
|
-
// 白名单排查(2026-07-24,「彻查 as {} 连环坑」):手写窄类型换 core 真实类型,理由同 runs.ts twin。
|
|
2338
1364
|
onTaskNotification: (n) => {
|
|
2339
1365
|
if (n.task_type === "workflow")
|
|
2340
1366
|
return;
|
|
2341
|
-
// S2 复审 #3(sync 腿同注):中途终态信号。
|
|
2342
|
-
// [3911] 终态即撤供给形登记:同一 a* 句柄可进入下一个**复活周期**,而新周期的宿主腿模式
|
|
2343
|
-
// 可能相反——不撤会把上一周期的答案报给新周期的首帧前窗口(理由全文见 forgetHandleContentMode)。
|
|
2344
1367
|
defaultSubagentTailBus.forgetHandleContentMode(n.task_id);
|
|
2345
1368
|
if (defaultSubagentTailBus.hasSubscribers(n.task_id)) {
|
|
2346
1369
|
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) } : {}) });
|
|
2347
1370
|
}
|
|
2348
|
-
// id-domain alias: flip by payload.sessionId (= the tick's uuid domain), fallback task_id.
|
|
2349
|
-
// [2687-cli] 幽灵行案:只对 agent 族终态打(isFleetAgentTerminalNotification 单源判别,
|
|
2350
|
-
// 病灶链见其 doc 注);bash/monitor 的通知帧/park 面照走。孪生:runs.ts bg 腿、routes/tasks.ts sync 腿。
|
|
2351
1371
|
const hadRow = isFleetAgentTerminalNotification(n) ? (fleetPub?.onChildTerminal(n.sessionId ?? n.task_id, n.status, n.task_id, n.toolUseId) ?? false) : false;
|
|
2352
1372
|
const parked = !resumeLegLive && Boolean(deps.workflowCompletionInbox && sessionId);
|
|
2353
|
-
// diagnosability (rationale in runs.ts twin).
|
|
2354
1373
|
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 });
|
|
2355
1374
|
if (parked) {
|
|
2356
|
-
// 对抗评审 2026-07-11(MED):log the enqueue failure (false park✓ fingerprint otherwise; sync twin's note).
|
|
2357
1375
|
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) }));
|
|
2358
1376
|
}
|
|
2359
1377
|
else if (resumeLegLive) {
|
|
2360
|
-
// option A: teardown-time reap notification → durable display frame NOW (bg-leg twin's note).
|
|
2361
|
-
// core [784]④a: seq-folded shared key — the seq-less shape reverse-swallowed cycle 2's frame.
|
|
2362
|
-
// codex F1 (HIGH): fire-and-forget append — claim the key with its REAL outcome so a failed
|
|
2363
|
-
// write doesn't eat the stream replay of the same key (bg twin's note).
|
|
2364
1378
|
const key = taskNotificationStreamKey(n);
|
|
2365
1379
|
if (resumeNotifiedKeys.get(key) === undefined) {
|
|
2366
1380
|
resumeNotifiedKeys.set(key, append("task_notification", taskNotificationEventData({ notification: n })).then(() => true, () => false));
|
|
2367
1381
|
}
|
|
2368
1382
|
}
|
|
2369
1383
|
},
|
|
2370
|
-
// C2 (core 1.219) + design/122 (core 1.225 ③): the resumed leg re-registers sync-delegation steer
|
|
2371
|
-
// handles under the SAME taskId. NOT evicted on settle (resume legality) — evicted at leg end.
|
|
2372
1384
|
...(deps.subagentSteerRegistry && taskId
|
|
2373
1385
|
? {
|
|
2374
1386
|
onSubagentSpawn: (handle) => {
|
|
@@ -2379,29 +1391,19 @@ export function createHttpServer(rawDeps) {
|
|
|
2379
1391
|
});
|
|
2380
1392
|
resumeStreamRef = stream;
|
|
2381
1393
|
if (taskId)
|
|
2382
|
-
steerableRuns.set(taskId, stream);
|
|
1394
|
+
steerableRuns.set(taskId, stream);
|
|
2383
1395
|
if (!taskId || !rs)
|
|
2384
|
-
return await stream.result();
|
|
2385
|
-
// ── #316 **受理点**([4660] Inkglow 60s 雪崩首环的修)──────────────────────────────────
|
|
2386
|
-
// 到这一行为止,所有「会变成拒绝」的判定都已经落定:lease admission(429)、markResuming CAS
|
|
2387
|
-
// (409)、以及 `resumeStream` 内部 core 的 pre-CAS 守卫 + 原子 CAS(绑定不符 409 / 卡不在 404
|
|
2388
|
-
// ——它们在 `await legRunner.resumeStream(...)` 处就 reject 了,根本到不了这里)。剩下的只有
|
|
2389
|
-
// **模型往返**,时长无上限。⇒ 声明了受理语义的调用方在此拿走 `200 {status:"resuming"}`,本腿
|
|
2390
|
-
// 继续在同一条 promise 链上跑到停点(账本写口逐字还是上面那条 `createSerialLedgerAppend`
|
|
2391
|
-
// 单写串行链 —— 本改动**不新增任何写口**,也就不引入 fire-and-forget 的低 seq 竞态)。
|
|
2392
|
-
// 🔴 `taskId && rs` 之后才受理(上一行的早退已经把无行形滤走):没有 durable 行就没有 poll/SSE
|
|
2393
|
-
// 可跟的终态面,那种部署形上提前受理等于把结果扔掉 —— 它维持同步形(受理回调不触发 ⇒ 分派器
|
|
2394
|
-
// 的 race 自然落到腿本身的返回值上)。
|
|
1396
|
+
return await stream.result();
|
|
2395
1397
|
onAccepted?.({ status: 200, body: { taskId, sessionId, status: "resuming", bindingEnforced: true } });
|
|
2396
1398
|
for await (const ev of stream) {
|
|
2397
|
-
recordTurnActivity(taskId);
|
|
2398
|
-
fleetPub?.onEvent(ev);
|
|
2399
|
-
await appendPromptManifest(append, deps.promptManifests, taskId);
|
|
1399
|
+
recordTurnActivity(taskId);
|
|
1400
|
+
fleetPub?.onEvent(ev);
|
|
1401
|
+
await appendPromptManifest(append, deps.promptManifests, taskId);
|
|
2400
1402
|
switch (ev.type) {
|
|
2401
1403
|
case "text_delta":
|
|
2402
1404
|
text += ev.delta;
|
|
2403
1405
|
anchor.onText(ev.eventId);
|
|
2404
|
-
break;
|
|
1406
|
+
break;
|
|
2405
1407
|
case "reasoning_delta":
|
|
2406
1408
|
if (persistThinking)
|
|
2407
1409
|
reasoning += ev.delta;
|
|
@@ -2410,7 +1412,7 @@ export function createHttpServer(rawDeps) {
|
|
|
2410
1412
|
anchor.onTool();
|
|
2411
1413
|
await flush();
|
|
2412
1414
|
await append("tool_start", toolStartEventData(ev));
|
|
2413
|
-
break;
|
|
1415
|
+
break;
|
|
2414
1416
|
case "tool_end":
|
|
2415
1417
|
await flush();
|
|
2416
1418
|
await append("tool_end", toolEndEventData(ev));
|
|
@@ -2421,78 +1423,69 @@ export function createHttpServer(rawDeps) {
|
|
|
2421
1423
|
await flush();
|
|
2422
1424
|
await append("status", brainStatusEventData(st));
|
|
2423
1425
|
break;
|
|
2424
|
-
}
|
|
1426
|
+
}
|
|
2425
1427
|
case "context_usage":
|
|
2426
1428
|
await flush();
|
|
2427
1429
|
await append("context_usage", contextUsageEventData(ev));
|
|
2428
|
-
break;
|
|
1430
|
+
break;
|
|
2429
1431
|
case "compacted":
|
|
2430
1432
|
await flush();
|
|
2431
1433
|
await append("compacted", compactedEventData(ev));
|
|
2432
|
-
break;
|
|
1434
|
+
break;
|
|
2433
1435
|
case "diagnostics":
|
|
2434
1436
|
await flush();
|
|
2435
1437
|
await append("diagnostics", diagnosticsEventData(ev));
|
|
2436
|
-
break;
|
|
1438
|
+
break;
|
|
2437
1439
|
case "steering_injected":
|
|
2438
1440
|
await flush();
|
|
2439
1441
|
await append("steering_injected", steeringInjectedEventData(ev));
|
|
2440
|
-
break;
|
|
1442
|
+
break;
|
|
2441
1443
|
case "compaction_outcome":
|
|
2442
1444
|
await flush();
|
|
2443
1445
|
await append("compaction_outcome", compactionOutcomeEventData(ev));
|
|
2444
|
-
break;
|
|
1446
|
+
break;
|
|
2445
1447
|
case "workspace_changed":
|
|
2446
1448
|
await flush();
|
|
2447
1449
|
await append("workspace_changed", workspaceChangedEventData(ev));
|
|
2448
|
-
break;
|
|
1450
|
+
break;
|
|
2449
1451
|
case "wiring_manifest":
|
|
2450
1452
|
await flush();
|
|
2451
1453
|
await append("wiring_manifest", wiringManifestEventData(ev));
|
|
2452
|
-
break;
|
|
1454
|
+
break;
|
|
2453
1455
|
case "human_input":
|
|
2454
1456
|
await flush();
|
|
2455
1457
|
await append("human_input", humanInputEventData(ev));
|
|
2456
|
-
break;
|
|
1458
|
+
break;
|
|
2457
1459
|
case "message_committed":
|
|
2458
1460
|
await anchor.onMessageCommitted(ev.role, ev.entryId);
|
|
2459
|
-
break;
|
|
1461
|
+
break;
|
|
2460
1462
|
case "task_progress":
|
|
2461
1463
|
await append("task_progress", taskProgressEventData(ev));
|
|
2462
|
-
break;
|
|
1464
|
+
break;
|
|
2463
1465
|
case "task_notification": {
|
|
2464
1466
|
await flush();
|
|
2465
|
-
// core [784]④a+④b: seq-folded shared key + skip keys this leg already delivered (turn-open inbox
|
|
2466
|
-
// drain or observer arm) — core's pend-drain replays a double-parked idle arrival as a stream event
|
|
2467
|
-
// on the resumed leg; appending it again doubles the frame in the durable events tail (sync twin).
|
|
2468
|
-
// codex F1: await the claiming write's outcome — a failed observer claim must not eat this
|
|
2469
|
-
// replay (write anyway + promote the key with THIS write's outcome; bg twin's note).
|
|
2470
1467
|
const tnr = ev.notification;
|
|
2471
1468
|
const tnrKey = tnr?.task_id ? taskNotificationStreamKey({ task_type: tnr.task_type, task_id: tnr.task_id, status: String(tnr.status), seq: tnr.seq }) : undefined;
|
|
2472
1469
|
const tnrPrior = tnrKey !== undefined ? resumeNotifiedKeys.get(tnrKey) : undefined;
|
|
2473
1470
|
if (tnrPrior === undefined || !(await tnrPrior)) {
|
|
2474
|
-
const write = append("task_notification", taskNotificationEventData(ev));
|
|
1471
|
+
const write = append("task_notification", taskNotificationEventData(ev));
|
|
2475
1472
|
if (tnrKey !== undefined)
|
|
2476
|
-
resumeNotifiedKeys.set(tnrKey, write.then(() => true, () => false));
|
|
1473
|
+
resumeNotifiedKeys.set(tnrKey, write.then(() => true, () => false));
|
|
2477
1474
|
await write;
|
|
2478
1475
|
}
|
|
2479
1476
|
break;
|
|
2480
1477
|
}
|
|
2481
1478
|
case "turn_end": {
|
|
2482
1479
|
await flush();
|
|
2483
|
-
await append("turn_end", turnEndEventData(ev));
|
|
1480
|
+
await append("turn_end", turnEndEventData(ev));
|
|
2484
1481
|
await appendModelUsageDelta(append, deps.modelUsage, taskId);
|
|
2485
1482
|
await anchor.onTurnEnd();
|
|
2486
1483
|
break;
|
|
2487
|
-
}
|
|
2488
|
-
// "done" handled below via result() + the run-row drive (terminal event written once)
|
|
1484
|
+
}
|
|
2489
1485
|
}
|
|
2490
1486
|
}
|
|
2491
1487
|
await flush();
|
|
2492
1488
|
const r = await stream.result();
|
|
2493
|
-
// Close the durable stream with the matching terminal event (mirrors runInBackground). Never the
|
|
2494
|
-
// capability token — only the non-secret gate on a re-suspend.
|
|
2495
|
-
// [4913]:再停事件带待批 toolCallId(源=新 checkpoint 的 pendingAction;tool-less/读失败=键缺席)。
|
|
2496
1489
|
if (r.status === "suspended") {
|
|
2497
1490
|
const parkCallId = await parkToolCallId(r, deps.checkpointStore);
|
|
2498
1491
|
await append("suspended", { gate: r.checkpointGate ?? null, ...(parkCallId !== undefined ? { toolCallId: parkCallId } : {}) });
|
|
@@ -2503,34 +1496,19 @@ export function createHttpServer(rawDeps) {
|
|
|
2503
1496
|
await append("needs_review", { gate: r.checkpointGate ?? null });
|
|
2504
1497
|
await appendModelUsageDelta(append, deps.modelUsage, taskId);
|
|
2505
1498
|
return r;
|
|
2506
|
-
}
|
|
2507
|
-
// [1245] codex-4: a CONFIRMED reopen-class failure (core reopened the checkpoint — store-verified,
|
|
2508
|
-
// codex-5) is NOT terminal: logging `done` here would make the append-only log say terminal while
|
|
2509
|
-
// the park stays decidable (trace projection + events tailers treat `done` as final and would miss
|
|
2510
|
-
// the retry). Log the park, keep E8's terminal usage-SUM reserved for genuinely terminal results
|
|
2511
|
-
// (`checkpoint.reopen_failed` and an unconfirmed reopen stay on the done leg).
|
|
1499
|
+
}
|
|
2512
1500
|
if (isCheckpointReopenedFailure(r) && (await confirmCheckpointReopened())) {
|
|
2513
1501
|
reopenConfirmedPark = true;
|
|
2514
|
-
// codex-6: best-effort — the ROW re-park below is the authority once the reopen is confirmed. A
|
|
2515
|
-
// thrown append here would otherwise rethrow past the row drive and strand a `running` row (no
|
|
2516
|
-
// heartbeat) over a pending park until reapStale; a missing suspended event is merely a log gap
|
|
2517
|
-
// (nothing terminal was written — tailers keep waiting), strictly recoverable.
|
|
2518
1502
|
try {
|
|
2519
1503
|
await append("suspended", { gate: null, reopened: r.errorCode ?? null });
|
|
2520
1504
|
await appendModelUsageDelta(append, deps.modelUsage, taskId);
|
|
2521
1505
|
}
|
|
2522
1506
|
catch {
|
|
2523
|
-
/* row drive is authoritative */
|
|
2524
1507
|
}
|
|
2525
1508
|
return r;
|
|
2526
1509
|
}
|
|
2527
|
-
|
|
2528
|
-
// ALL legs (pre-suspend + post-resume) into stats.modelUsage. setTerminal (below, ~2619) stores this result.
|
|
2529
|
-
const safe = await attachModelUsage(stripCheckpointToken(r), { append, getEvents: (id, a) => rs.getEvents(id, a), modelUsage: deps.modelUsage, taskId }); // structural fence (strip no-op today)
|
|
1510
|
+
const safe = await attachModelUsage(stripCheckpointToken(r), { append, getEvents: (id, a) => rs.getEvents(id, a), modelUsage: deps.modelUsage, taskId });
|
|
2530
1511
|
await append("done", { result: safe });
|
|
2531
|
-
// E12 (shell-host contract): a resumed leg that COMPLETES can also emit post-completion suggestions (parity with the
|
|
2532
|
-
// first-run path + the completed-only gate). suspended/needs_review returned above; a failed leg is
|
|
2533
|
-
// skipped here. Redact + persist as a `suggestions` event (UNTRUSTED UI-only, never re-fed to a model).
|
|
2534
1512
|
if (taskConfig.suggestNextPrompts && safe.status === "completed") {
|
|
2535
1513
|
const suggestions = await stream.suggestions().catch(() => []);
|
|
2536
1514
|
if (suggestions.length)
|
|
@@ -2538,17 +1516,10 @@ export function createHttpServer(rawDeps) {
|
|
|
2538
1516
|
}
|
|
2539
1517
|
return safe;
|
|
2540
1518
|
};
|
|
2541
|
-
// #151 车3 刀 3b(design/172 §4.3(b)):resume 腿的**审批 ALS** —— 此前这条腿只有 elicitation ctx,
|
|
2542
|
-
// 审批 ask 恒 `"unavailable"` ⇒ 恒 park,一张卡都不产。投递面 = 本腿的 durable events tail
|
|
2543
|
-
// (与 bg 腿同形:`append(type, rest)`,`emitCard` 同口;撤卡帧 live-only 故不接)。
|
|
2544
|
-
// `legKey` = 上面算好的 token 摘要 —— 这是 resume 腿与 sync/bg 首腿的唯一区别,也正是「新 park ⇒
|
|
2545
|
-
// 新腿 ⇒ 重新征询」这条安全语义的承载轴。
|
|
2546
1519
|
const approvalEmit = (frame) => {
|
|
2547
1520
|
const { type, ...rest } = frame;
|
|
2548
1521
|
return append(type, rest);
|
|
2549
1522
|
};
|
|
2550
|
-
// 包 ALS ⟺ 协议在本腿上场且窗不为 0(F4 修;`windowZero` 在没有 per-task `spec.onAsk` 装配点的
|
|
2551
|
-
// 腿上,「不包 ALS」就是 immediate-unavailable 闭包的等价形,见 resolveApprovalLeg 顶注)。
|
|
2552
1523
|
const withApproval = () => deps.toolApproval && taskId && approvalLeg.active && !approvalLeg.windowZero
|
|
2553
1524
|
? deps.toolApproval.runWithContext({
|
|
2554
1525
|
taskId,
|
|
@@ -2556,7 +1527,6 @@ export function createHttpServer(rawDeps) {
|
|
|
2556
1527
|
emit: approvalEmit,
|
|
2557
1528
|
abortSignal: cancelCtrl.signal,
|
|
2558
1529
|
sessionId,
|
|
2559
|
-
// 同 bg 腿:唯一 sink 是本腿的 durable tail,append 失败如实算未送达(R2-1)。
|
|
2560
1530
|
emitCard: createApprovalCardEmitter({ appendDurable: (f) => approvalEmit(f) }),
|
|
2561
1531
|
legKey,
|
|
2562
1532
|
...(legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic } : {}),
|
|
@@ -2569,166 +1539,68 @@ export function createHttpServer(rawDeps) {
|
|
|
2569
1539
|
}
|
|
2570
1540
|
catch (e) {
|
|
2571
1541
|
if (e instanceof CheckpointError) {
|
|
2572
|
-
// ── A-010.2:`checkpoint.resume_aborted` = 取消终局(既非 terminal-failed,也**不是** retriable)──
|
|
2573
|
-
// core 只在 `taskConfig.signal.aborted === true` 时铸这个码(四个铸造点:pre-CAS 入口 / 编辑复裁
|
|
2574
|
-
// 竞速两处 / consumed 后 / reopen 成功后),而本腿递进去的 `signal` 只有 `cancelCtrl.signal` ——
|
|
2575
|
-
// 其唯一 abort 源是取消(inflightRuns 句柄 + 心跳轮询的持久取消旗)。所以收到该码 ⟺ 这条 run
|
|
2576
|
-
// 被取消了,四形的正确落点因此**同一个**:按取消结算(下面那道 pending/consumed 的判别式,在
|
|
2577
|
-
// 「该做什么」这个问题上是无差别的,故不做)。
|
|
2578
|
-
// · 归下面的 terminal 支(修前现状)= 200 的取消出路(本 catch 更下面的 `cancelCtrl.signal.aborted`
|
|
2579
|
-
// 分支)被本 CheckpointError 分支**遮蔽**:运维拿到 409 + core 裸报文,行虽落 failed 但
|
|
2580
|
-
// `errorCode` 不是 `cancelled` 且 `setTerminal` 连 TaskResult 都不写(null),pre-CAS 三形的
|
|
2581
|
-
// 卡还留 pending = 行终局 + 卡 pending 的孤儿对。
|
|
2582
|
-
// · 归 retriable(把该码加进下面的闭集)= 把一条**用户已取消**的 run 重新 park 成 `suspended`;
|
|
2583
|
-
// 下一次 /decide 的 markResuming 会重置取消旗并把活真跑起来 —— 取消被静默打败,执行车道回归。
|
|
2584
|
-
// ⛔ 修法刻意是「只给该码开一条臂」而不是把 `cancelCtrl.signal.aborted` 分支上提到本分支之前:
|
|
2585
|
-
// 同在 abort 期铸的 `checkpoint.reopen_failed` 两形(store 拒 reopen = 审批已终局消耗;reopen
|
|
2586
|
-
// 在飞失败 = 卡的状态从这里无法证明)带的信息比 "cancelled" 多,上提会把它们折没。
|
|
2587
1542
|
if (e.code === "checkpoint.resume_aborted") {
|
|
2588
|
-
// 序与 /v1/runs/:id/cancel 的 cancelSuspended 逐字同向:**先**按 cancel 语义结算卡,**再**驱行
|
|
2589
|
-
// 终局(行终局会释放 task_active;反序会开一个「卡还 pending 而行已死」的窗)。cancel 的卡结算
|
|
2590
|
-
// 语义是 expire(≈ deny 的 reaper 终局),不是 resolve —— 取消意味着 run 死掉,deny-RESUME 会
|
|
2591
|
-
// 把拒绝喂回模型烧 token。consumed 那一形(core 3894)在这里天然是 no-op:CAS 只翻 pending。
|
|
2592
|
-
// ⚠️ 与 `/v1/runs/:id/cancel` 的 `cancelSuspended` **刻意分道**的一点(codex 对抗复审 R2-高2
|
|
2593
|
-
// 的处置):那只在 CAS 输掉时会改答 409,因为它站在腿**外面**——输掉意味着「可能有一条 decide
|
|
2594
|
-
// 腿正在跑」,它没资格驱行终局。本臂站在腿**里面**:markResuming 已经赢过、这条 run 归我们,
|
|
2595
|
-
// 而本码的语义是「它被取消了」。此刻 CAS 输掉只可能是卡已离开 pending(自己那次 resume 消费掉
|
|
2596
|
-
// 的 consumed 形,或 reaper 先收了)——两种都意味着**没有活审批留在外面**,行照驱终局才是对的:
|
|
2597
|
-
// 不驱才会把会话锁一直攥着(这正是 [868] 那条事故)。故不改结果面,但**必须留痕**。
|
|
2598
1543
|
const cs2 = deps.checkpointStore;
|
|
2599
1544
|
if (cs2) {
|
|
2600
1545
|
try {
|
|
2601
1546
|
const live = await cs2.get(token);
|
|
2602
1547
|
if (live?.status === "pending" && !(await cs2.expire(token, live.scope))) {
|
|
2603
|
-
// CAS 输了:卡在我们读它与写它之间被别人(自己的 resume / reaper)settle 了。行为面无变化,
|
|
2604
|
-
// 但「取消时这张卡到底是谁收的」不留痕就永远查不出来。
|
|
2605
1548
|
deps.logger?.warn?.("resume_cancel_checkpoint_settle_lost", { taskId, sessionId });
|
|
2606
1549
|
}
|
|
2607
1550
|
}
|
|
2608
1551
|
catch (cancelSettleErr) {
|
|
2609
|
-
// 卡结算是尽力而为:行终局才是释放锁的权威写,残留的 pending 卡另有 reaper 车道兜底。
|
|
2610
|
-
// 但**必须留痕** —— 静默吞掉的话「取消之后卡为什么还在」在遥测里没有任何线索。
|
|
2611
1552
|
deps.logger?.warn?.("resume_cancel_checkpoint_settle_failed", {
|
|
2612
1553
|
taskId, sessionId, err: cancelSettleErr instanceof Error ? cancelSettleErr.message : String(cancelSettleErr),
|
|
2613
1554
|
});
|
|
2614
1555
|
}
|
|
2615
1556
|
}
|
|
2616
|
-
// `claimedRow &&` 与下面那道分支**同一个**守卫,不是多余的:驱行终局是有资格才做的写(只有
|
|
2617
|
-
// 抢到这条 run 的腿才有资格),而「本码只可能在 markResuming 之后铸」这件事是当前控制流的
|
|
2618
|
-
// 巧合、不是结构保证。守卫写出来,这条臂就不依赖那个巧合。
|
|
2619
1557
|
if (claimedRow && taskId && deps.runStore) {
|
|
2620
1558
|
const c = { taskId, sessionId, status: "failed", errorCode: "cancelled", errorMessage: e.message, stats: { turns: 0, tokens: 0 } };
|
|
2621
1559
|
await deps.runStore.setTerminal(taskId, "failed", c, c.errorMessage ?? null).catch(() => undefined);
|
|
2622
1560
|
}
|
|
2623
|
-
settleFleet("failed");
|
|
1561
|
+
settleFleet("failed");
|
|
2624
1562
|
return { status: 200, body: { taskId, sessionId, status: "failed", errorCode: "cancelled" } };
|
|
2625
1563
|
}
|
|
2626
|
-
// We flipped the row to `running` (claimedRow). Two CheckpointError classes diverge here (D-1, core 1.101):
|
|
2627
|
-
// • TERMINAL (already_resolved / not_found / gate_mismatch): the checkpoint is
|
|
2628
|
-
// consumed or gone — drive the row terminal now (releasing task_active) instead of leaving a zombie
|
|
2629
|
-
// `running` row for reapStale to mislabel and hold the session lock for ~runStaleSec. (Original case.)
|
|
2630
|
-
// ⚠️ `unsupported_version` USED to sit in this bullet unconditionally (its checkpoint does NOT go
|
|
2631
|
-
// away — that was the registered orphan debt). As of #209 / core 5.25.0 it SPLITS on
|
|
2632
|
-
// `detail.reason`: a recognised worker-swap word joins the retriable class below; an absent /
|
|
2633
|
-
// unrecognised word stays TERMINAL. Read the block under the closed set before touching it.
|
|
2634
|
-
// • RETRIABLE PRE-CAS (invalid_outcome = binding mismatch / reopen_revote = env_failed must replay the
|
|
2635
|
-
// persisted winner / reopened_concurrently = a concurrent resolve-reopen advanced the rev): the
|
|
2636
|
-
// checkpoint STAYS pending and the operator re-fetches + re-/decides. Driving the row terminal would
|
|
2637
|
-
// release task_active and ORPHAN the still-pending checkpoint (next /decide → 404). Re-park it
|
|
2638
|
-
// `suspended` so the corrected retry can claim it again.
|
|
2639
1564
|
const retriable = e.code === "checkpoint.invalid_outcome" ||
|
|
2640
1565
|
e.code === "checkpoint.reopen_revote" ||
|
|
2641
1566
|
e.code === "checkpoint.reopened_concurrently" ||
|
|
2642
|
-
e.code === "resume.parent_constraint_missing" ||
|
|
2643
|
-
e.code === "resume.parent_constraint_mismatch" ||
|
|
2644
|
-
// core 5.22.0(BREAKING「编辑复裁前移 pre-CAS」)闭集加员:审批人**改写**入参时,继承祖先链
|
|
2645
|
-
// 的冻结投影先静态复裁 —— 判 deny ⇒ constraint_rejected;链上有 opaque 层/声明 live remainder
|
|
2646
|
-
// ⇒ constraint_unprojectable。两者与上面两码同族:PRE-CAS、checkpoint 留 pending 且**同 token
|
|
2647
|
-
// 仍可再决**(core 明写 fresh-redecision 语义:照原样批 / deny / 改别的都还点得动)。归 terminal
|
|
2648
|
-
// 会释放 task_active 并孤儿化那条仍 pending 的卡 —— 一张还能点的卡当场变死件。
|
|
1567
|
+
e.code === "resume.parent_constraint_missing" ||
|
|
1568
|
+
e.code === "resume.parent_constraint_mismatch" ||
|
|
2649
1569
|
e.code === "resume.constraint_rejected" ||
|
|
2650
1570
|
e.code === "resume.constraint_unprojectable" ||
|
|
2651
|
-
e.code.startsWith("wake.") ||
|
|
2652
|
-
// 🟢 **`checkpoint.unsupported_version` 的一臂精确 retriable**(#209 件2,core 5.25.0 提货批
|
|
2653
|
-
// [3443];取代 #205 件4② 那条「维持 TERMINAL + 孤儿代价在案」的登记账)。
|
|
2654
|
-
//
|
|
2655
|
-
// 旧账的形状(留着,因为它解释了为什么这条臂长这样):该码在 core 上有**三个** PRE-CAS 铸点
|
|
2656
|
-
// (版本超上限 / org 治理行落到无 `permissionRuleOrg` 的 worker / 带 remote `workspaceHandle` 的行
|
|
2657
|
-
// 落到无 `executionEnvFactory` 的 Runner),三形卡一律留 pending 但**可恢复性不同**,而 core 当时
|
|
2658
|
-
// 同码同 `detail`(都不带)⇒ 下游只剩「按报文文案猜」或「按 checkpoint 形状反推」两条路,两版修法
|
|
2659
|
-
// 各被一轮 codex 对抗复审验伪(blanket-retriable 让无手臂永挂 task_active;按 `gate.realApproval`
|
|
2660
|
-
// 分流不健全 —— core 把该位放 gate、`workspaceHandle` 放 state,同一条行可以两样都带)。按宪法
|
|
2661
|
-
// 「源头修复,禁下游旁路」全回退,并把判别式列成上游请托([3434] 请托 1)。
|
|
2662
|
-
//
|
|
2663
|
-
// 请托已兑现:core 5.25.0 起三个铸点各带 `detail.reason`(`runtask.js:3706/3721/3733` 亲读),
|
|
2664
|
-
// d.ts 逐字说三者都是「retryable on a newer / factory-wired / org-wired worker」。⇒ 本臂**读词表**
|
|
2665
|
-
// (`WORKER_SWAP_REDEEMABLE`,闭集、新词 tsc 红),不做任何形状推断:
|
|
2666
|
-
// · 认得的三个词 ⇒ retriable:行重新 park suspended,那条仍 pending 的卡不再被孤儿化
|
|
2667
|
-
// (旧账里「行 failed 而卡活着、下一次 /decide → 404、只能等 `terminal_at_ms`」的代价至此销掉)。
|
|
2668
|
-
// · 词缺席 / 认不得 ⇒ **维持 TERMINAL**(旧行为逐字保留)。这不是保守的装饰:旧引擎的裸抛与
|
|
2669
|
-
// 将来某个我们还没读过的新臂都落在这一支,而把它们默认成 retriable 会把一条**真的没救**的行
|
|
2670
|
-
// 永久 park 在 suspended 上攥着 task_active —— 那正是 codex R1-[high] 验伪掉的那一版。
|
|
2671
|
-
// 🔴 形③(`env_factory_missing`)按 core 的词判 retriable,而不是按 #205 那条「选 Runner 由
|
|
2672
|
-
// `handsLanes.laneOf(spec)` 按场景判,重投永远同样失败」的推断:那条推断说的是**同一份部署配置下**
|
|
2673
|
-
// 重投无用,而 core 的词说的是「接上 `executionEnvFactory` 的 worker 兑得掉」—— 那是一次运维动作,
|
|
2674
|
-
// 与 `governed_unwired` 要求接 `permissionRuleOrg` 完全同族。两者都不是「换个副本再点一次」。
|
|
2675
|
-
// 期间行停在 suspended 攥着会话锁,与它 park 时的状态**逐字相同**(这条腿只是没能把它推进),
|
|
2676
|
-
// 而 TERMINAL 那一支要用「释放锁」换「卡变死件」,方向更坏。
|
|
2677
|
-
// 行为面钉:`test/durable-resume-http.test.ts` 的「#209 件2」三正格 + 三反极格。
|
|
1571
|
+
e.code.startsWith("wake.") ||
|
|
2678
1572
|
(e.code === "checkpoint.unsupported_version" && checkpointRowRedeemableElsewhere(e.detail?.reason));
|
|
2679
1573
|
if (claimedRow && taskId && deps.runStore) {
|
|
2680
|
-
// Re-park on the SAME gate family the outcome targeted: a plan_review retry must re-park `needs_review`
|
|
2681
|
-
// (keep the row's review status + the lock), not `suspended` — else GET /v1/assistant/tasks mislabels it.
|
|
2682
1574
|
if (retriable) {
|
|
2683
1575
|
await (outcome.gate === "plan_review" ? deps.runStore.setNeedsReview(taskId) : deps.runStore.setSuspended(taskId)).catch(() => undefined);
|
|
2684
1576
|
settleFleet(outcome.gate === "plan_review" ? "needs_review" : "suspended");
|
|
2685
|
-
}
|
|
1577
|
+
}
|
|
2686
1578
|
else {
|
|
2687
1579
|
await deps.runStore.setTerminal(taskId, "failed", null, e.message).catch(() => undefined);
|
|
2688
1580
|
settleFleet("failed");
|
|
2689
|
-
}
|
|
1581
|
+
}
|
|
2690
1582
|
}
|
|
2691
1583
|
const status = e.code === "checkpoint.not_found" ? 404 : 409;
|
|
2692
1584
|
return {
|
|
2693
1585
|
status,
|
|
2694
1586
|
body: {
|
|
2695
1587
|
error: e.message,
|
|
2696
|
-
// invalid_outcome means DIFFERENT things per gate: for policy_ask it IS the D-1 action-binding
|
|
2697
|
-
// mismatch; for plan_review/dry_run_review it is a content-sanitize reject (editedPlan/reason carried a
|
|
2698
|
-
// forbidden tag) — give those a gate-appropriate code so the UI routes to the right recovery, not the
|
|
2699
|
-
// action-binding flow (adversarial-review medium finding).
|
|
2700
|
-
// 归一(clay 大改版窗 2026-07-28):errorCode **恒在**——改铸臂保持优先,其余=原码镜像
|
|
2701
|
-
// (「code 单轨」历史形从此退役;SDK 的 ?? b.code 兜底只为老 server 保留)。
|
|
2702
1588
|
errorCode: e.code === "checkpoint.invalid_outcome"
|
|
2703
1589
|
? (outcome.gate === "policy_ask" ? "approval_binding_mismatch" : "resume_outcome_invalid")
|
|
2704
1590
|
: e.code,
|
|
2705
|
-
// [2422] HITL-2 裁 (a),core 5.3.0:invalid_outcome 同码两义(动作被换 vs 输入被换)——
|
|
2706
|
-
// detail.field 是唯一机读判别式,透传给 UI 路由恢复动作(boundCallId=重取 pending 列表,
|
|
2707
|
-
// boundInputHash=重审同动作的新输入)。缺席=core 未铸(非绑定类 invalid_outcome),不造键。
|
|
2708
1591
|
...(e.detail?.field ? { field: e.detail.field } : {}),
|
|
2709
1592
|
...(retriable ? { retriable: true } : {}),
|
|
2710
1593
|
},
|
|
2711
1594
|
};
|
|
2712
1595
|
}
|
|
2713
|
-
// Cancelled mid-resume (the abort threw out of the resumed stream): settle the row to the cancelled
|
|
2714
|
-
// terminal (parity with runInBackground) instead of bubbling a 500 that leaves a zombie `running` row
|
|
2715
|
-
// for the reaper. The operator's /decide gets the run's final state, not an opaque error. (A preempt mid-
|
|
2716
|
-
// resume is NOT an abort — it durably suspends and surfaces above as status:"suspended", not here.)
|
|
2717
1596
|
if (cancelCtrl.signal.aborted) {
|
|
2718
1597
|
if (taskId && deps.runStore) {
|
|
2719
1598
|
const c = { taskId, sessionId, status: "failed", errorCode: "cancelled", errorMessage: "cancelled by user", stats: { turns: 0, tokens: 0 } };
|
|
2720
1599
|
await deps.runStore.setTerminal(taskId, "failed", c, c.errorMessage ?? null).catch(() => undefined);
|
|
2721
1600
|
}
|
|
2722
|
-
settleFleet("failed");
|
|
1601
|
+
settleFleet("failed");
|
|
2723
1602
|
return { status: 200, body: { taskId, sessionId, status: "failed", errorCode: "cancelled" } };
|
|
2724
1603
|
}
|
|
2725
|
-
// C: a plain Error (not CheckpointError, not a cancel) is about to propagate before the post-catch
|
|
2726
|
-
// `settleFleet(result.status)` can run → settle the re-published row to "failed" first so it doesn't leak
|
|
2727
|
-
// as a stale "running" (no-op if a CheckpointError/cancel branch above already settled it).
|
|
2728
|
-
// 🔴 codex R1-F2(2026-08-14):行也要落终局,不只 fleet 帧——本腿在 markResuming/createRun 赢下行后
|
|
2729
|
-
// 普通异常一路 throw 出去,行曾一直留 running+claim 在手直到 reapStale(期间 session 被锁死,最终被
|
|
2730
|
-
// 误记 instance-lost)。cancel 分支(上方)早为同一理由落行,本臂是漏掉的孪生。claimedRow 守卫=只有
|
|
2731
|
-
// 抢到行的腿才有资格驱终局(与 CheckpointError 臂同一条纪律)。
|
|
2732
1604
|
if (claimedRow && taskId && deps.runStore) {
|
|
2733
1605
|
await deps.runStore.setTerminal(taskId, "failed", null, e instanceof Error ? e.message : String(e)).catch(() => undefined);
|
|
2734
1606
|
}
|
|
@@ -2736,78 +1608,45 @@ export function createHttpServer(rawDeps) {
|
|
|
2736
1608
|
throw e;
|
|
2737
1609
|
}
|
|
2738
1610
|
finally {
|
|
2739
|
-
unregisterEngineNotice?.();
|
|
1611
|
+
unregisterEngineNotice?.();
|
|
2740
1612
|
if (taskId) {
|
|
2741
|
-
// [1.207 codex H3] identity-guarded like steerableRuns below — a retriable-CheckpointError re-park
|
|
2742
|
-
// (setSuspended) opens a window where a fast sibling resume markResuming's + registers its OWN
|
|
2743
|
-
// controllers under the same taskId before this finally runs; an unconditional delete killed the
|
|
2744
|
-
// fresh handles (cancel/preempt would 409 "another replica" until re-registration).
|
|
2745
1613
|
if (inflightRuns.get(taskId) === cancelCtrl)
|
|
2746
1614
|
inflightRuns.delete(taskId);
|
|
2747
1615
|
if (preemptableRuns.get(taskId) === preemptCtrl)
|
|
2748
1616
|
preemptableRuns.delete(taskId);
|
|
2749
|
-
// Identity-guarded (1.78 review LOW): only delete the handle THIS leg registered (and only if it is
|
|
2750
|
-
// still ours) — a faster sibling resume may have re-registered the same taskId already; never registered
|
|
2751
|
-
// ⇒ nothing of ours to delete.
|
|
2752
1617
|
if (resumeStreamRef !== undefined && steerableRuns.get(taskId) === resumeStreamRef)
|
|
2753
1618
|
steerableRuns.delete(taskId);
|
|
2754
1619
|
for (const evict of subagentHandleEvictions)
|
|
2755
|
-
evict();
|
|
2756
|
-
deps.modelUsage?.clear(taskId);
|
|
2757
|
-
deps.promptManifests?.clear(taskId, resumeManifestLease);
|
|
1620
|
+
evict();
|
|
1621
|
+
deps.modelUsage?.clear(taskId);
|
|
1622
|
+
deps.promptManifests?.clear(taskId, resumeManifestLease);
|
|
2758
1623
|
}
|
|
2759
1624
|
if (resumeHeartbeat)
|
|
2760
1625
|
clearInterval(resumeHeartbeat);
|
|
2761
|
-
resumeLegLive = false;
|
|
1626
|
+
resumeLegLive = false;
|
|
2762
1627
|
}
|
|
2763
|
-
// A session-CAS conflict can surface as a failed result on resume too — evict the stale warm-cache entry so
|
|
2764
|
-
// the next task on this session re-wakes from TiDB instead of re-conflicting (parity with runInBackground /
|
|
2765
|
-
// sync POST / cascade; the only terminal-result path that was missing this — council).
|
|
2766
1628
|
evictIfConflict(deps.runner, sessionId, result);
|
|
2767
|
-
|
|
2768
|
-
// core dist-read: runtask reopens the checkpoint via store.reopen BEFORE returning this failed result; a
|
|
2769
|
-
// reopen that itself fails surfaces as `checkpoint.reopen_failed`, NOT these codes) leave the checkpoint
|
|
2770
|
-
// PENDING. Driving the row terminal here would release task_active and orphan the still-decidable park
|
|
2771
|
-
// (the exact class the CheckpointError `retriable` branch above re-parks) — e.g. Workflow entitlement
|
|
2772
|
-
// revoked between suspend and approve. Re-park `suspended` so the operator's retry can claim it again.
|
|
2773
|
-
const checkpointReopened = reopenConfirmedPark; // codex-5: store-confirmed inside the resume paths — never inferred from the code alone
|
|
2774
|
-
// Drive the parked run row to its new state (terminal, or re-suspended on a further gate). Strip the token
|
|
2775
|
-
// from the persisted result — structural fence (no-op today; GET /v1/runs/:id reads this row back).
|
|
1629
|
+
const checkpointReopened = reopenConfirmedPark;
|
|
2776
1630
|
if (taskId && deps.runStore) {
|
|
2777
1631
|
if (result.status === "suspended" || checkpointReopened)
|
|
2778
1632
|
await deps.runStore.setSuspended(taskId);
|
|
2779
1633
|
else if (result.status === "needs_review")
|
|
2780
|
-
await deps.runStore.setNeedsReview(taskId);
|
|
1634
|
+
await deps.runStore.setNeedsReview(taskId);
|
|
2781
1635
|
else
|
|
2782
1636
|
await deps.runStore.setTerminal(taskId, result.status, stripCheckpointToken(result), result.errorMessage ?? null);
|
|
2783
1637
|
}
|
|
2784
|
-
|
|
2785
|
-
// "waiting"/"awaiting approval"); a terminal status REMOVES it (onTerminal does this mapping). Mirrors the sync leg.
|
|
2786
|
-
settleFleet(checkpointReopened ? "suspended" : result.status, fleetRunResiduals(result)); // [1839]①/(a):终态残局键(parked 时 publisher 不上帧)
|
|
2787
|
-
// Surface the failure code/message to the deciding operator (never the token): a resumed leg that fails
|
|
2788
|
-
// otherwise returns an opaque {status:"failed"} — undiagnosable without a run row (leader sub-workers).
|
|
1638
|
+
settleFleet(checkpointReopened ? "suspended" : result.status, fleetRunResiduals(result));
|
|
2789
1639
|
return {
|
|
2790
1640
|
status: 200,
|
|
2791
1641
|
body: {
|
|
2792
1642
|
taskId, sessionId, status: result.status,
|
|
2793
|
-
// [2400] HITL-12:世代探测位——本 server 强制决策-动作绑定(无绑定 decide 已在上方 400 拒),
|
|
2794
|
-
// 客户端凭它机器确认「我裁的就是我看到的那个动作」,不再有静默回落窗口。
|
|
2795
1643
|
bindingEnforced: true,
|
|
2796
1644
|
...(result.errorCode ? { errorCode: result.errorCode } : {}),
|
|
2797
1645
|
...(result.errorMessage ? { errorMessage: redactSecrets(result.errorMessage) } : {}),
|
|
2798
|
-
...(checkpointReopened ? { retriable: true } : {}),
|
|
1646
|
+
...(checkpointReopened ? { retriable: true } : {}),
|
|
2799
1647
|
},
|
|
2800
1648
|
};
|
|
2801
1649
|
}
|
|
2802
|
-
/**
|
|
2803
|
-
* design/80 seam #2 (scheduler resume): resume a task that durably SUSPENDED on a `resource_limit` gate — a
|
|
2804
|
-
* scheduler PREEMPT (reason "preempt") or a budget/turns/walltime slice boundary (design/74). Distinct from the
|
|
2805
|
-
* operator `/decide` path (`resumeCheckpoint`, `policy_ask`): there is NO human decision, NO binding, NO answer
|
|
2806
|
-
* — just `{gate:"resource_limit", decision:"continue"}` (core computes the next slice's allowance from the
|
|
2807
|
-
* ledger). 🔴 GATE-KIND GUARD: it REFUSES to resume a `policy_ask`/human gate via this path (that would bypass
|
|
2808
|
-
* the human approval) — a non-resource gate returns 409 and must go through `/decide`. Owner-scoped by the
|
|
2809
|
-
* caller; `scope` is the checkpoint's own (multi-tenant). Returns the HTTP {status, body}.
|
|
2810
|
-
*/
|
|
2811
1650
|
async function resumePreempted(sessionId, req) {
|
|
2812
1651
|
const cs = deps.checkpointStore;
|
|
2813
1652
|
const token = await cs.findPendingTokenBySession(sessionId);
|
|
@@ -2815,18 +1654,13 @@ export function createHttpServer(rawDeps) {
|
|
|
2815
1654
|
return { status: 404, body: { error: "no resumable suspension for this task (already running, resumed, or expired)", errorCode: "not_found.suspension" } };
|
|
2816
1655
|
let cp;
|
|
2817
1656
|
try {
|
|
2818
|
-
cp = await cs.get(token);
|
|
1657
|
+
cp = await cs.get(token);
|
|
2819
1658
|
}
|
|
2820
1659
|
catch (e) {
|
|
2821
1660
|
return { status: 409, body: { error: e instanceof Error ? e.message : String(e), errorCode: "conflict.checkpoint_unreadable" } };
|
|
2822
1661
|
}
|
|
2823
1662
|
if (!cp)
|
|
2824
1663
|
return { status: 404, body: { error: "checkpoint not found", errorCode: "not_found.checkpoint" } };
|
|
2825
|
-
// 🔴 GATE-KIND GUARD (security): this "continue" path must ONLY advance a resource_limit suspend. A
|
|
2826
|
-
// policy_ask / human / irreversible_ask gate requires a HUMAN decision via POST /v1/approvals/:id/decide —
|
|
2827
|
-
// resuming it here with decision:"continue" would BYPASS the approval. Reject it distinctly (core's resume
|
|
2828
|
-
// gate-match is the fail-closed backstop, but we reject pre-CAS for a clear error + to never consume the
|
|
2829
|
-
// checkpoint). dry_run_review / plan_review likewise are not "continue"-resumable here.
|
|
2830
1664
|
const gateKind = cp.gate?.kind;
|
|
2831
1665
|
if (gateKind !== "resource_limit") {
|
|
2832
1666
|
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" } };
|
|
@@ -2834,33 +1668,23 @@ export function createHttpServer(rawDeps) {
|
|
|
2834
1668
|
const ctx = await cs.getCtx(sessionId).catch(() => null);
|
|
2835
1669
|
if (!ctx)
|
|
2836
1670
|
return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
|
|
2837
|
-
// Rebuild taskConfig via resolveSpec with auth reconstructed from the checkpoint scope (mirrors resumeCheckpoint).
|
|
2838
1671
|
const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
|
|
2839
|
-
// E18: STRIP resumeAt before re-resolving on a durable resume — the leaf is already set by the checkpoint, and
|
|
2840
|
-
// resumeAt + a durable resume is rejected by core (resume_at.conflicts_resume); also avoids re-resolving a stale
|
|
2841
|
-
// anchor (which could 404 if the session was purged). A resume-at run that suspended stored its body with resumeAt.
|
|
2842
1672
|
const spec = await deps.resolveSpec({ ...ctx.body, resumeAt: undefined }, req, auth);
|
|
2843
1673
|
const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
|
|
2844
|
-
// A resource-suspended task that was submitted with verify:true is re-gated on completion (parity with the
|
|
2845
|
-
// HITL path — the flag rides the persisted ctx.body).
|
|
2846
1674
|
const verifyRounds = verifyRoundsFromBody(ctx.body);
|
|
2847
1675
|
return driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome: { gate: "resource_limit", decision: "continue" }, verifyRounds });
|
|
2848
1676
|
}
|
|
2849
|
-
/** design/144 §3:wake 一个 task_done 纯 park——非门决策,解除 park 消费消息续跑。owner 门:非 operator
|
|
2850
|
-
* 只能 wake 自己 scope 的 park(404 无 oracle);core 端 gate_pending/nothing_to_deliver 双拒兜底。 */
|
|
2851
1677
|
async function resumeWake(sessionId, message, caller, req) {
|
|
2852
1678
|
const cs = deps.checkpointStore;
|
|
2853
1679
|
const token = await cs.findPendingTokenBySession(sessionId);
|
|
2854
1680
|
if (!token)
|
|
2855
1681
|
return { status: 404, body: { error: "no parked checkpoint for this session (nothing to wake)", errorCode: "not_found.parked_checkpoint" } };
|
|
2856
|
-
// owner 门先于 get(复审 finding:get 对损坏/新版行会抛,若 gate 在后,非 owner 能用 409 错误详情
|
|
2857
|
-
// 区分「存在但坏」vs「不存在」= 存在性 oracle)。scope 走 peekPendingScope(轻读,不碰行体)。
|
|
2858
1682
|
const operator = explicitOperatorOk(caller, deps.config.operatorPrincipals);
|
|
2859
1683
|
if (!operator) {
|
|
2860
1684
|
const scope = await cs.peekPendingScope(sessionId).catch(() => undefined);
|
|
2861
1685
|
const decodedScope = scope != null ? decodeCheckpointScope(scope) : undefined;
|
|
2862
1686
|
if (decodedScope !== undefined && decodedScope !== caller) {
|
|
2863
|
-
return { status: 404, body: { error: "no parked checkpoint for this session (nothing to wake)", errorCode: "not_found.parked_checkpoint" } };
|
|
1687
|
+
return { status: 404, body: { error: "no parked checkpoint for this session (nothing to wake)", errorCode: "not_found.parked_checkpoint" } };
|
|
2864
1688
|
}
|
|
2865
1689
|
}
|
|
2866
1690
|
let cp;
|
|
@@ -2872,18 +1696,14 @@ export function createHttpServer(rawDeps) {
|
|
|
2872
1696
|
}
|
|
2873
1697
|
if (!cp)
|
|
2874
1698
|
return { status: 404, body: { error: "checkpoint not found", errorCode: "not_found.checkpoint" } };
|
|
2875
|
-
// 纵深(peek 缺席面兜底)。比较留在 scope 空间(常量比较,不 decode):行体可能来自不做形状校验的
|
|
2876
|
-
// JSON 读取,scope 越契约为 nullish 时 decode 形会把「畸形行」折成「公开」= 放行;旧码对该支是拒绝
|
|
2877
|
-
// 方向,常量形逐字节保持它(codex 码审 finding,#97 α 车)。
|
|
2878
1699
|
if (!operator && cp.scope !== CHECKPOINT_PUBLIC_SCOPE && cp.scope !== caller) {
|
|
2879
1700
|
return { status: 404, body: { error: "no parked checkpoint for this session (nothing to wake)", errorCode: "not_found.parked_checkpoint" } };
|
|
2880
1701
|
}
|
|
2881
|
-
// 预检(core 兜底同码):非纯 park = 有未决门,wake 永不绕审批。
|
|
2882
1702
|
const gateKind = cp.gate?.kind;
|
|
2883
1703
|
if (gateKind !== "task_done") {
|
|
2884
1704
|
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" } };
|
|
2885
1705
|
}
|
|
2886
|
-
const trusted = operator;
|
|
1706
|
+
const trusted = operator;
|
|
2887
1707
|
if (message !== undefined) {
|
|
2888
1708
|
try {
|
|
2889
1709
|
validatePendingSteer({ text: message, trusted });
|
|
@@ -2910,29 +1730,12 @@ export function createHttpServer(rawDeps) {
|
|
|
2910
1730
|
taskConfig,
|
|
2911
1731
|
resumeObjective,
|
|
2912
1732
|
outcome: { gate: "wake", ...(message !== undefined ? { message: { text: message, trusted } } : {}) },
|
|
2913
|
-
verifyRounds: undefined,
|
|
2914
|
-
mintFreshRun: { source: "wake" },
|
|
1733
|
+
verifyRounds: undefined,
|
|
1734
|
+
mintFreshRun: { source: "wake" },
|
|
2915
1735
|
});
|
|
2916
1736
|
}
|
|
2917
|
-
|
|
2918
|
-
* design/80 D-B: resolve a PRE-ACTION `plan_review` gate (3-state approve | edit | reject) and drive the resumed
|
|
2919
|
-
* leg into the durable run log via the SHARED machinery. DISTINCT from `resumeCheckpoint` (a `policy_ask` tool
|
|
2920
|
-
* approval, allow/deny + binding) and `resumePreempted` (`resource_limit` continue). The `edit` decision carries
|
|
2921
|
-
* the operator's REVISED plan (core threads `editedPlan` into the resumed run). 🔴 GATE-KIND GUARD (security):
|
|
2922
|
-
* refuses any non-`plan_review` gate — an approve/edit/reject must NEVER resolve a tool-approval / resource_limit
|
|
2923
|
-
* suspend (that would bypass the correct /decide allow-deny + action binding). Rejects PRE-CAS so a wrong-gate
|
|
2924
|
-
* call never consumes the checkpoint (core's resume gate-match is the fail-closed backstop).
|
|
2925
|
-
*/
|
|
2926
|
-
async function resumePlanReview(sessionId, decision, editedPlan, reason, req,
|
|
2927
|
-
/** [4277]/[4288] 件5:判别日志的两个附加位。调用方(plan_review 端点)手上有 taskId 与 gatedPrincipal,
|
|
2928
|
-
* 本函数只有 sessionId —— 穿进来是为了让 `decide_404` 的**四个 reason 共用同一套字段**(联调 grep 一条
|
|
2929
|
-
* msg 就拿到同一个 id 键与同一个 principalPresent 口径,不必按 reason 分两种形)。
|
|
2930
|
-
* 缺席 ⇒ id 退回 sessionId 并如实换键名(不假装那是 taskId)。 */
|
|
2931
|
-
log) {
|
|
1737
|
+
async function resumePlanReview(sessionId, decision, editedPlan, reason, req, log) {
|
|
2932
1738
|
const cs = deps.checkpointStore;
|
|
2933
|
-
// [4268]/[4274]/[4277]/[4288] 件5:404 成因判别位——**只进服务端日志,不上 wire**(响应体一字不改,
|
|
2934
|
-
// 反枚举不破)。词表四值全在本函数与 approvals-assistant.ts 两处铸,合起来覆盖 plan_review 一条决裁
|
|
2935
|
-
// 路径的全部 404 出口。`principalPresent` 只记**有无**:身份串一个都不许进日志(日志读者面比 wire 宽)。
|
|
2936
1739
|
const log404 = (r) => deps.logger?.info?.("decide_404", {
|
|
2937
1740
|
reason: r,
|
|
2938
1741
|
...(log?.taskId !== undefined ? { taskId: log.taskId } : { sessionId }),
|
|
@@ -2945,7 +1748,7 @@ export function createHttpServer(rawDeps) {
|
|
|
2945
1748
|
}
|
|
2946
1749
|
let cp;
|
|
2947
1750
|
try {
|
|
2948
|
-
cp = await cs.get(token);
|
|
1751
|
+
cp = await cs.get(token);
|
|
2949
1752
|
}
|
|
2950
1753
|
catch (e) {
|
|
2951
1754
|
return { status: 409, body: { error: e instanceof Error ? e.message : String(e), errorCode: "conflict.checkpoint_unreadable" } };
|
|
@@ -2954,9 +1757,6 @@ export function createHttpServer(rawDeps) {
|
|
|
2954
1757
|
log404("checkpoint-missing");
|
|
2955
1758
|
return { status: 404, body: { error: "checkpoint not found", errorCode: "not_found.checkpoint" } };
|
|
2956
1759
|
}
|
|
2957
|
-
// 🔴 GATE-KIND GUARD: a plan_review decision must ONLY resolve a plan_review gate. A human/irreversible_ask
|
|
2958
|
-
// (tool approval) or resource_limit/needs_review gate routed here would let approve/edit/reject bypass the
|
|
2959
|
-
// correct wire (a tool approval needs /decide's allow/deny + action binding; resource_limit needs /resume).
|
|
2960
1760
|
const gateKind = cp.gate?.kind;
|
|
2961
1761
|
if (gateKind !== "plan_review") {
|
|
2962
1762
|
return {
|
|
@@ -2970,15 +1770,9 @@ export function createHttpServer(rawDeps) {
|
|
|
2970
1770
|
const ctx = await cs.getCtx(sessionId).catch(() => null);
|
|
2971
1771
|
if (!ctx)
|
|
2972
1772
|
return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
|
|
2973
|
-
// Rebuild taskConfig via resolveSpec with auth reconstructed from the checkpoint scope (mirrors resumeCheckpoint).
|
|
2974
1773
|
const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
|
|
2975
|
-
// E18: STRIP resumeAt before re-resolving on a durable resume — the leaf is already set by the checkpoint, and
|
|
2976
|
-
// resumeAt + a durable resume is rejected by core (resume_at.conflicts_resume); also avoids re-resolving a stale
|
|
2977
|
-
// anchor (which could 404 if the session was purged). A resume-at run that suspended stored its body with resumeAt.
|
|
2978
1774
|
const spec = await deps.resolveSpec({ ...ctx.body, resumeAt: undefined }, req, auth);
|
|
2979
1775
|
const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
|
|
2980
|
-
// A plan_review-suspended task submitted with verify:true is re-gated on completion (parity with the other
|
|
2981
|
-
// resume paths — the flag rides the persisted ctx.body).
|
|
2982
1776
|
const verifyRounds = verifyRoundsFromBody(ctx.body);
|
|
2983
1777
|
const outcome = {
|
|
2984
1778
|
gate: "plan_review",
|
|
@@ -2994,40 +1788,26 @@ export function createHttpServer(rawDeps) {
|
|
|
2994
1788
|
deps.metrics?.inc("task_tokens_total", {}, result.stats.tokens);
|
|
2995
1789
|
if (result.stats.cacheHitRate !== undefined)
|
|
2996
1790
|
deps.metrics?.observe("task_cache_hit_rate", result.stats.cacheHitRate);
|
|
2997
|
-
// ⑤ budget/limit cutoffs:count failures by their dotted code — core 5.8.0 起统一 `limits.` 前缀
|
|
2998
|
-
// (limits.max_{tokens,cost,turns,walltime}_exceeded;旧 budget.*/limit.* 双前缀同拍退役),
|
|
2999
|
-
// operator 按此对租户撞顶告警。metric 名保留 budget_exceeded_total(时序连续性;code label 自述新码)。
|
|
3000
1791
|
const code = result.errorCode;
|
|
3001
1792
|
if (code && code.startsWith("limits.")) {
|
|
3002
1793
|
deps.metrics?.inc("budget_exceeded_total", { code });
|
|
3003
1794
|
}
|
|
3004
|
-
// Developer-mode verification gate (1.44): count the final verdict when this task was verified.
|
|
3005
1795
|
const verdict = result.verification?.verdict;
|
|
3006
1796
|
if (verdict)
|
|
3007
1797
|
deps.metrics?.inc("verifications_total", { verdict });
|
|
3008
|
-
// Cascade (1.45): count the outcome (passed / exhausted) when this task ran the ladder.
|
|
3009
1798
|
const cascadeOutcome = result.cascadeOutcome;
|
|
3010
1799
|
if (cascadeOutcome)
|
|
3011
1800
|
deps.metrics?.inc("cascade_total", { outcome: cascadeOutcome });
|
|
3012
|
-
// Degenerate-repetition salvage classifier (1.59): a/b/unknown — see degenerate-instrument.ts.
|
|
3013
1801
|
deps.instrumentDegenerate?.(result);
|
|
3014
1802
|
}
|
|
3015
|
-
/** The terminal-result side-effect trio (metrics, plan-cache recurrence probe, warm-cache eviction) in ONE
|
|
3016
|
-
* place so a terminal path can't silently omit one (council DESIGN-6 — the omissions caused tracking gaps). */
|
|
3017
1803
|
function finalizeTaskResult(result, principal, objective, sessionId) {
|
|
3018
1804
|
recordTaskResult(result);
|
|
3019
1805
|
deps.planCacheProbe?.record(principal, objective, result.status === "completed");
|
|
3020
1806
|
evictIfConflict(deps.runner, sessionId, result);
|
|
3021
1807
|
}
|
|
3022
|
-
/** Per-principal cumulative cost quota: 429 + retry-after when the principal is over for the window.
|
|
3023
|
-
* No principal → not gated (the rate limiter + REQUIRE_PRINCIPAL still apply). */
|
|
3024
1808
|
function quotaExceeded(req, res) {
|
|
3025
1809
|
if (!deps.costQuota)
|
|
3026
1810
|
return false;
|
|
3027
|
-
// 🔴 F-fix: the VERIFIED principal (gatedPrincipal), never the spoofable header. Spend ACCRUES to the verified
|
|
3028
|
-
// principal (budget.ts `costQuota.add(currentPrincipal())` = auth.principal), so checking a header-keyed bucket
|
|
3029
|
-
// while the verified bucket fills would let a direct-door caller dodge its quota by rotating x-agent-principal.
|
|
3030
|
-
// The sibling rateLimited keys on the socket on a direct door for the same reason — this was its overlooked twin.
|
|
3031
1811
|
const principal = gatedPrincipal(req, deps.config);
|
|
3032
1812
|
if (!principal)
|
|
3033
1813
|
return false;
|
|
@@ -3039,12 +1819,6 @@ export function createHttpServer(rawDeps) {
|
|
|
3039
1819
|
sendError(res, 429, "limit.cost_quota_exceeded", "cost quota exceeded", { usedMicroUsd: d.usedMicroUsd, limitMicroUsd: d.limitMicroUsd, retryAfterSec: d.retryAfterSec });
|
|
3040
1820
|
return true;
|
|
3041
1821
|
}
|
|
3042
|
-
/** lease admission(D4 AP)— the quotaExceeded sibling for lease-enforced principals: first
|
|
3043
|
-
* touch negotiates with center (single-flight; 409 not-lease-enforced → negative-cached pass-through to
|
|
3044
|
-
* the local CostQuota path), a live lease admits locally, center-judged exhaustion denies 429+Retry-After
|
|
3045
|
-
* (same shape as the cost-quota deny), and an unreachable center FAILS OPEN (AP posture — reconciliation
|
|
3046
|
-
* is the backstop). Keys on the VERIFIED principal only (gatedPrincipal — the quotaExceeded F-fix twin);
|
|
3047
|
-
* no principal (single-user turnkey) → no lease surface. */
|
|
3048
1822
|
async function leaseDenied(req, res) {
|
|
3049
1823
|
if (!deps.fleetLease)
|
|
3050
1824
|
return false;
|
|
@@ -3056,17 +1830,9 @@ export function createHttpServer(rawDeps) {
|
|
|
3056
1830
|
return false;
|
|
3057
1831
|
deps.metrics?.inc("fleet_lease_rejected_total");
|
|
3058
1832
|
res.setHeader("retry-after", String(adm.retryAfterSec));
|
|
3059
|
-
// E4 定稿:429 带 typed code + center 结构化字段 verbatim 透传(windowType/remaining/resetAt/
|
|
3060
|
-
// isEstimate/pool)——人话文案归壳/web(契约如此),error 串保留旧消费者兼容。
|
|
3061
1833
|
sendError(res, 429, "quota_exhausted", "budget exhausted (quota lease)", { retryAfterSec: adm.retryAfterSec, ...(adm.detail ?? {}) });
|
|
3062
1834
|
return true;
|
|
3063
1835
|
}
|
|
3064
|
-
/** design/166-T1 — 治理窗 pre-admission(quotaExceeded/leaseDenied 的兄弟门):key 公式与 core 单源
|
|
3065
|
-
* (`principal || GLOBAL_USAGE_KEY`,GLOBAL_USAGE_KEY 引 core 公共导出防漂移;principal=verified,
|
|
3066
|
-
* spoofable header 不进键)。窗耗尽 → 429 + retryAfterSec(族形一致,秒上取整)+ Retry-After 头。
|
|
3067
|
-
* 读窗失败(DB 瞬断/坏 record)→ warn + fail-open:enforcement 真源在 core 进场检查(同店同键,
|
|
3068
|
-
* 会响亮拒),此门只是异步车道的 429 快速座——门故障不该把提交面整个拖死。admission-pass 与
|
|
3069
|
-
* core entry-拒之间存在竞态窗:该形落终态 run 记录(errorCode usage.window_exhausted),契约文档在案。 */
|
|
3070
1836
|
async function usageWindowDenied(req, res) {
|
|
3071
1837
|
const store = deps.usageWindowStore;
|
|
3072
1838
|
const windows = deps.config.usageWindows;
|
|
@@ -3090,13 +1856,9 @@ export function createHttpServer(rawDeps) {
|
|
|
3090
1856
|
sendError(res, 429, "usage.window_exhausted", "usage window exhausted (deployment token governance)", { retryAfterSec });
|
|
3091
1857
|
return true;
|
|
3092
1858
|
}
|
|
3093
|
-
/** Apply the per-principal rate limit; responds 429 and returns true when the caller is over. */
|
|
3094
1859
|
function rateLimited(req, res) {
|
|
3095
1860
|
if (!deps.rateLimiter)
|
|
3096
1861
|
return false;
|
|
3097
|
-
// 🔴 D-G (DeepSeek council Bug#2): on a direct-mode worker x-agent-principal is UNTRUSTED, so keying the limiter
|
|
3098
|
-
// on it lets a client rotate the header to evade its own bucket OR set a victim's principal to poison theirs
|
|
3099
|
-
// (DoS). Key on the socket address instead (the verified principal isn't known until post-body JWT verify).
|
|
3100
1862
|
const key = (deps.config.directDoorActive ? undefined : principalFrom(req, deps.config)) ?? req.socket.remoteAddress ?? "anon";
|
|
3101
1863
|
const d = deps.rateLimiter.check(key);
|
|
3102
1864
|
if (d.allowed)
|
|
@@ -3106,14 +1868,6 @@ export function createHttpServer(rawDeps) {
|
|
|
3106
1868
|
sendError(res, 429, "limit.rate_exceeded", "rate limit exceeded", { retryAfterSec: d.retryAfterSec });
|
|
3107
1869
|
return true;
|
|
3108
1870
|
}
|
|
3109
|
-
/**
|
|
3110
|
-
* §0.5 session-scoped owner scope — the SAME model as GET /v1/sessions, reused by the fork/delete routes so
|
|
3111
|
-
* all three agree on "who may touch this session". A system/traceToken caller is FLEET-WIDE (ops, any session);
|
|
3112
|
-
* a principal caller is PINNED to `gateOwner` (its own). Identity is `gatedPrincipal` (direct-door safe), never
|
|
3113
|
-
* the spoofable header. Caller checks: `!fleetWide && gateOwner===null` ⇒ 401; otherwise compare the session's
|
|
3114
|
-
* stored owner to `gateOwner` (principal path) or allow (fleetWide).
|
|
3115
|
-
*/
|
|
3116
|
-
/** Decode a URL path segment, returning null on a malformed %-sequence (a client error → 400, never a 500). */
|
|
3117
1871
|
function safeDecode(seg) {
|
|
3118
1872
|
try {
|
|
3119
1873
|
return decodeURIComponent(seg);
|
|
@@ -3122,11 +1876,6 @@ export function createHttpServer(rawDeps) {
|
|
|
3122
1876
|
return null;
|
|
3123
1877
|
}
|
|
3124
1878
|
}
|
|
3125
|
-
// 🔒 Single source for the fleet-wide (all-tenant) gate. dev-open (no service/trace token ⇒ fleet-wide) is honored
|
|
3126
|
-
// ONLY on a single-user deployment. On MULTI-TENANT (requirePrincipal=true), the ABSENCE of a token must NOT open the
|
|
3127
|
-
// fleet — that would leak every tenant's sessions/runs/fleet rows to ANY caller (HIGH tenant-isolation gap, systematic
|
|
3128
|
-
// audit 2026-06-30). Multi-tenant without a token ⇒ NOT fleet-wide ⇒ owner-gated by principal (and main.ts boot-guards
|
|
3129
|
-
// it to fail FAST). A system authToken or the read-only traceToken still grant fleet-wide on any deployment.
|
|
3130
1879
|
function isFleetWide(req) {
|
|
3131
1880
|
const { authToken: at, traceToken: tt } = deps.config;
|
|
3132
1881
|
const anyAt = Boolean(at) || Object.keys(deps.config.authTokens ?? {}).length > 0;
|
|
@@ -3137,11 +1886,6 @@ export function createHttpServer(rawDeps) {
|
|
|
3137
1886
|
const gateOwner = fleetWide ? null : (gatedPrincipal(req, deps.config) || null);
|
|
3138
1887
|
return { fleetWide, gateOwner };
|
|
3139
1888
|
}
|
|
3140
|
-
/** 🔴 WRITE-grade fleet-wide (2026-07-13): the READ-ONLY traceToken must never authorize a
|
|
3141
|
-
* cross-tenant WRITE (delete/fork/sync import) — isFleetWide deliberately grants it fleet-wide for the trace/
|
|
3142
|
-
* list READ surfaces, but consuming that same bit on destructive routes turned a read credential into write
|
|
3143
|
-
* authority. Write routes use THIS gate: dev-open (single-user, no tokens) and the SYSTEM authToken keep their
|
|
3144
|
-
* authority; a traceToken-only caller falls through to the principal owner gate (or 401). */
|
|
3145
1889
|
function sessionOwnerScopeForWrite(req) {
|
|
3146
1890
|
const { authToken: at, traceToken: tt } = deps.config;
|
|
3147
1891
|
const anyAt = Boolean(at) || Object.keys(deps.config.authTokens ?? {}).length > 0;
|
|
@@ -3149,41 +1893,19 @@ export function createHttpServer(rawDeps) {
|
|
|
3149
1893
|
const gateOwner = fleetWide ? null : (gatedPrincipal(req, deps.config) || null);
|
|
3150
1894
|
return { fleetWide, gateOwner };
|
|
3151
1895
|
}
|
|
3152
|
-
/** Reject when the caller's principal does not own the run (null owner = open, dev). */
|
|
3153
1896
|
function runOwnerOk(req, res, owner) {
|
|
3154
|
-
// 🔴 F-fix: the VERIFIED principal (gatedPrincipal), never the spoofable header — `owner` was persisted from the
|
|
3155
|
-
// verified principal at create, so comparing it to a spoofable header would let a direct-door caller read/cancel a
|
|
3156
|
-
// victim's run (and its event stream) by sending x-agent-principal:<victim> with NO JWT. Same model as
|
|
3157
|
-
// sessionOwnerScope. (BFF/gated: gatedPrincipal === principalFrom, byte-identical.)
|
|
3158
1897
|
const principal = gatedPrincipal(req, deps.config);
|
|
3159
1898
|
if (deps.config.requirePrincipal && !principal) {
|
|
3160
1899
|
sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
|
|
3161
1900
|
return false;
|
|
3162
1901
|
}
|
|
3163
|
-
// [3273] C8:窄互认的读面半场——[3262] 只修了提交/续发面(createAuthorizer),head/audit/events
|
|
3164
|
-
// 三读面走本函数,7.8.0 未同窗 ⇒ cli 停发哨兵头后存量会话「能续跑但看不见」,且 404 与「压根
|
|
3165
|
-
// 不存在」黑盒同形(test [3273] 实测)。同 security.ts 三条件窄臂,语义/墓碑注同源(存量随会话
|
|
3166
|
-
// 生命周期消亡后可整删);带具名 principal 的请求对哨兵行仍走下方 404 无 oracle 形。
|
|
3167
1902
|
const legacyShellOwner = !deps.config.requirePrincipal && owner === "anon:shell-live" && (principal === undefined || principal === null);
|
|
3168
1903
|
if (!legacyShellOwner && owner !== null && principal !== owner) {
|
|
3169
|
-
sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
|
|
1904
|
+
sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
|
|
3170
1905
|
return false;
|
|
3171
1906
|
}
|
|
3172
1907
|
return true;
|
|
3173
1908
|
}
|
|
3174
|
-
/** [1499] G — session-scoping ACCEPT phase on the run faces ([1498]⑥ soft rollout, 第一拍). principal answers
|
|
3175
|
-
* "whose data" (authz axis); session answers "which conversation" (content axis, [1493]). These faces deliver
|
|
3176
|
-
* conversation content (poll/events) or act on a conversation (cancel/steer/compact/detach, subagent
|
|
3177
|
-
* steer/resume) but were principal-gated only. Enforcement (absent ⇒ 404, subagentOutput-style) needs every
|
|
3178
|
-
* caller migrated first, so THIS phase 404s ONLY a PRESENT-but-MISMATCHED `?session=`: an honest caller
|
|
3179
|
-
* asserting its own session never mismatches on its own run — a mismatch IS the accidental cross-session
|
|
3180
|
-
* touch. Absent ⇒ allowed + warn-once per face (migration observability for the enforcement train). NB the
|
|
3181
|
-
* operator branch below is only REACHABLE where the preceding owner gate admits the caller: on faces whose
|
|
3182
|
-
* owner gate carries its own trusted arm (steer/compact/detach/subagent verbs) it is fully live; on the
|
|
3183
|
-
* runOwnerOk faces (poll/events/cancel — no operator arm, [1498]⑥ ledger) a cross-owner operator 404s at
|
|
3184
|
-
* the owner gate first, so here it covers only owner-null runs or an operator who IS the owner. Widening
|
|
3185
|
-
* those owner gates is a deliberate [1498]⑥ decision, not this phase's. A session-unbound run has nothing
|
|
3186
|
-
* to assert against. */
|
|
3187
1909
|
const sessionAssertAbsentWarned = new Set();
|
|
3188
1910
|
function runSessionAcceptOk(req, res, run, face, notFoundError = RUN_NOT_FOUND_MESSAGE) {
|
|
3189
1911
|
if (!run.sessionId)
|
|
@@ -3200,7 +1922,7 @@ export function createHttpServer(rawDeps) {
|
|
|
3200
1922
|
return true;
|
|
3201
1923
|
if (explicitOperatorOk(gatedPrincipal(req, deps.config), deps.config.operatorPrincipals))
|
|
3202
1924
|
return true;
|
|
3203
|
-
sendError(res, 404, "not_found.run", notFoundError);
|
|
1925
|
+
sendError(res, 404, "not_found.run", notFoundError);
|
|
3204
1926
|
return false;
|
|
3205
1927
|
}
|
|
3206
1928
|
async function readJson(req) {
|
|
@@ -3218,13 +1940,9 @@ export function createHttpServer(rawDeps) {
|
|
|
3218
1940
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
3219
1941
|
}
|
|
3220
1942
|
catch {
|
|
3221
|
-
// F1 (center K3S report): a malformed body is a client error → 400, not an uncaught 500.
|
|
3222
1943
|
throw new HttpError(400, "invalid JSON body");
|
|
3223
1944
|
}
|
|
3224
1945
|
}
|
|
3225
|
-
/** 2c session-sync: read a RAW request body (binary blob bytes) with a running per-request size cap — the bytes are
|
|
3226
|
-
* NEVER buffered unboundedly (the §5 "reject, not OOM" guard). Throws HttpError(413) the instant `max` is exceeded
|
|
3227
|
-
* (mid-stream, before the whole body is in memory). Returns the concatenated Buffer (≤ max). */
|
|
3228
1946
|
async function readRawBody(req, max) {
|
|
3229
1947
|
const chunks = [];
|
|
3230
1948
|
let size = 0;
|
|
@@ -3236,15 +1954,6 @@ export function createHttpServer(rawDeps) {
|
|
|
3236
1954
|
}
|
|
3237
1955
|
return Buffer.concat(chunks);
|
|
3238
1956
|
}
|
|
3239
|
-
/**
|
|
3240
|
-
* design/80 D-D (SLA-timer deny-sweep): resolve-DENY every pending human/irreversible_ask approval past its
|
|
3241
|
-
* deadline (the per-approval SLA) — re-resume each with decision=deny so the model CONTINUES with the denial
|
|
3242
|
-
* (graceful; vs the abort the reaper's expire() gives resource_limit/needs_review). Reuses resumeCheckpoint,
|
|
3243
|
-
* so the markResuming CAS makes it idempotent across replicas (one replica wins each resume) and the parked
|
|
3244
|
-
* run row is driven correctly. A per-call failure (e.g. a redeployed scenario) is swallowed → the row stays
|
|
3245
|
-
* pending and is retried next tick, with the terminal_at_ms backstop as the eventual abort if the deny never
|
|
3246
|
-
* succeeds. Bounded per tick by the store query's LIMIT. Wired into main.ts's reaper.
|
|
3247
|
-
*/
|
|
3248
1957
|
const denySweepClockGuard = createWallClockJumpGuard({
|
|
3249
1958
|
slaMs: deps.config.approvalTimeoutSec > 0 ? deps.config.approvalTimeoutSec * 1000 : undefined,
|
|
3250
1959
|
onJump: ({ jumpMs, graceMs }) => deps.logger?.warn?.("deny_sweep_wall_clock_jump", {
|
|
@@ -3255,8 +1964,6 @@ export function createHttpServer(rawDeps) {
|
|
|
3255
1964
|
: "the wall clock stepped BACKWARD relative to the monotonic clock; the deny-sweep needs no grace for this direction (a receding deadline only DELAYS a deny) but the step itself is an operational fact worth recording.",
|
|
3256
1965
|
}),
|
|
3257
1966
|
onSuppressed: ({ remainingMs }) => deps.logger?.info?.("deny_sweep_clock_grace", { remainingMs }),
|
|
3258
|
-
// codex R2 [high] 五:连续跳变撞上累计上限 ⇒ 守卫**响亮**恢复判定。这一行是运维必须看见的:
|
|
3259
|
-
// 它说的是「本机墙钟在持续抖,我不再为它让路了,接下来的 deny 判定基于一个不稳的钟」。
|
|
3260
1967
|
onGraceCapped: ({ totalGraceMs }) => deps.logger?.warn?.("deny_sweep_clock_grace_capped", {
|
|
3261
1968
|
totalGraceMs,
|
|
3262
1969
|
note: "the wall clock kept stepping forward; the approval SLA deny-sweep has stood down for the maximum cumulative grace and now RESUMES on a demonstrably unstable clock — a fail-closed leg that never runs is worse than one running on a bad clock. Fix NTP on this host.",
|
|
@@ -3266,18 +1973,9 @@ export function createHttpServer(rawDeps) {
|
|
|
3266
1973
|
const cs = deps.checkpointStore;
|
|
3267
1974
|
if (!cs)
|
|
3268
1975
|
return;
|
|
3269
|
-
// [4232]①/[4235]:**判定**钟的单调佐证。`now` 是墙钟(生产由 reaper 传 `Date.now()`),而
|
|
3270
|
-
// `checkpoint.deadline` 是 core 按铸卡当时的墙钟算的绝对时刻 —— 墙钟凭空前跳 ⇒ 判定钟凭空多走同样的量,
|
|
3271
|
-
// 人还没走开窗就没了(真机实测:SLA 45s,真实等 14s 即被自动拒)。守卫按单调钟宽限整拍让开;
|
|
3272
|
-
// **记账钟一律不动**(下面 resumeCheckpoint 的审计/时间戳仍是真墙钟,core 的 totalWaitMs 账面同样不碰)。
|
|
3273
1976
|
if (!denySweepClockGuard.admit(now))
|
|
3274
1977
|
return;
|
|
3275
1978
|
const expired = await cs.listExpiredApprovalGates(now).catch(() => []);
|
|
3276
|
-
// [1591] 候裁③ 静默臂:parked 后台子代的过期 cp 不走 sweep 的重活赎回(deadline→terminal_at_ms 窗内
|
|
3277
|
-
// 每行每 tick 一条 warn 的噪声源)——其收割属 reaper 的 expire+reconcileParkedAgents 车道。判别在
|
|
3278
|
-
// resume 尝试之前做(省掉注定 409 的整条 ctx/resolveSpec 重建尝试;代价=每过期行两次店读,过期集
|
|
3279
|
-
// 本就被店查询 LIMIT 界住);命中聚合为单条 info 留痕。判别自身故障 ⇒ 按非 parked 处理(保留 warn,
|
|
3280
|
-
// fail-loud 方向)。
|
|
3281
1979
|
const isParkedOwned = async (sessionId) => {
|
|
3282
1980
|
if (deps.backgroundAgentStore === undefined)
|
|
3283
1981
|
return false;
|
|
@@ -3296,55 +1994,21 @@ export function createHttpServer(rawDeps) {
|
|
|
3296
1994
|
};
|
|
3297
1995
|
let parkedSkipped = 0;
|
|
3298
1996
|
for (const { sessionId } of expired) {
|
|
3299
|
-
// 对抗评审 2026-07-11: log the swallow — a per-tick failure retried forever (e.g. a policy change now folded
|
|
3300
|
-
// into a 409 by resumeCheckpoint, or a redeployed scenario) was fully silent; the row sat pending to its
|
|
3301
|
-
// terminal_at_ms with zero operator-visible signal. Behavior unchanged (skip + retry next tick), now diagnosable.
|
|
3302
1997
|
if (await isParkedOwned(sessionId)) {
|
|
3303
1998
|
parkedSkipped += 1;
|
|
3304
1999
|
continue;
|
|
3305
2000
|
}
|
|
3306
|
-
// #204 件6②:出处 = `"timeout"`(窗到期,**没有人**答)。此前这里与人为 /decide 共用一个不带
|
|
3307
|
-
// 出处的构造,于是下游把一次自动拒渲染成「有人拒绝了你」。
|
|
3308
|
-
// 🕐 **#316 同族登记(本批刻意不改)**:末位 `acceptEarly` 不传 —— 本腿没有在等响应的 HTTP 客户端
|
|
3309
|
-
// (它是内部 SLA 车道),受理化对它零收益;而且这条 for 循环**靠 await 串行**来给每 tick 的重活
|
|
3310
|
-
// 限流,改成受理即返会让一个 tick 把整批过期卡的模型腿同时点着(下一个问题,不是本批的修)。
|
|
3311
2001
|
await resumeCheckpoint(sessionId, "deny", "approval SLA expired — auto-denied", "timeout").then((r) => { if (r.status >= 400)
|
|
3312
2002
|
deps.logger?.warn?.("deny_sweep_resume_blocked", { sessionId, status: r.status, body: r.body }); }, (e) => deps.logger?.warn?.("deny_sweep_resume_failed", { sessionId, err: e instanceof Error ? e.message : String(e) }));
|
|
3313
2003
|
}
|
|
3314
2004
|
if (parkedSkipped > 0)
|
|
3315
2005
|
deps.logger?.info?.("deny_sweep_parked_skipped", { count: parkedSkipped });
|
|
3316
2006
|
}
|
|
3317
|
-
/**
|
|
3318
|
-
* #329 —— live 回决腿(`POST /v1/tool-approvals/:id/respond`)对一条**已 park** 的 ask 的赎回席。
|
|
3319
|
-
*
|
|
3320
|
-
* 语义 = 「壳本来要人手做的那件事,server 替它做完」:PARKED 行上的 gate 三坐标就是
|
|
3321
|
-
* `/v1/approvals/:sessionId/decide` 里操作员回显的那三件,于是本席把它们原样喂回**同一条**
|
|
3322
|
-
* `resumeCheckpoint` 腿(parked 子代赎回 / 任务级 resume 由那条腿自己 parked-first 分流)。
|
|
3323
|
-
* [4872] 锚①「同腿新调用方,零新终局语义」:这里没有第二个终局写点,单赢者仍是那条腿的 CAS
|
|
3324
|
-
* (`markResuming` / `claimParkedAgent`),本席只是又一个调用方。
|
|
3325
|
-
*
|
|
3326
|
-
* 三件刻意的姿势:
|
|
3327
|
-
* ① **binding 逐字回显、绝不重算** —— `checkpointToken` 给的是行上的 `gateToken`(= park 那一刻从
|
|
3328
|
-
* checkpoint 抄回的那一把):它同时是 D-1 的 stale 判据(gate 已被重铸/已解决 ⇒ 那条腿自己回
|
|
3329
|
-
* `approval_stale` 409),所以「决议投给了一张过期的卡」在这条链上是被**拒**的,不是被猜的;
|
|
3330
|
-
* ② **sessionId 取 checkpoint 自己的那一个**,不是 ask 行的 —— 委派子代的 park 落在子代会话上,
|
|
3331
|
-
* 而 ask 行的 `sessionId` 是**投递上下文**(宿主流)的会话,拿后者去 resume 会打到别人的门上;
|
|
3332
|
-
* ③ **acceptEarly=true**(#316):有人正卡在 respond 的 HTTP 上等答复,受理语义是这条腿既有的形。
|
|
3333
|
-
*/
|
|
3334
2007
|
async function redeemParkedAsk(req) {
|
|
3335
2008
|
const cs = deps.checkpointStore;
|
|
3336
2009
|
if (!cs) {
|
|
3337
|
-
// 结构上到不了(协调器的 askStore 与 park 设施同在同不在);诚实拒,绝不假装受理。
|
|
3338
2010
|
return { status: 404, body: { error: "approval not found", errorCode: "not_found.approval" } };
|
|
3339
2011
|
}
|
|
3340
|
-
// 🔴 **计费提交三道门**(codex 对抗复审 R1-[high] 二,验真后修)。`POST /v1/tool-approvals/:id/respond`
|
|
3341
|
-
// 刻意**不在** `isBillableSubmitPath` 里,而且必须留在外面:活卡回决是「让在飞的那一轮跑完」,排空期
|
|
3342
|
-
// 恰恰要放行(那道门的自注写着 read/cancel/steer/respond 全开)。但**本席不是活卡回决** —— 它开一条新的
|
|
3343
|
-
// resume 腿(烧模型),与 `/v1/approvals/:sessionId/decide` 同一件事;那条 url 在名单里、吃这三道门。
|
|
3344
|
-
// 于是门贴在**这条臂**上,而不是整条路由上:文案与 errorCode 逐字取 `handle()` 的同三处(同一件事在
|
|
3345
|
-
// wire 上只该有一种说法),真值也取同三个 dep(不另立判据)。⚠️ 唯一的形差如实记:那三处是
|
|
3346
|
-
// `sendError` + `retry-after` 头,本席只产生**响应体**(路由用 `sendJson` 送),所以没有那个头 ——
|
|
3347
|
-
// 体里的码/文案足以让消费端分型,退避节奏由它自己的通用 503 处置管。
|
|
3348
2012
|
if (!hasServiceAuth(deps.config) && !deps.config.allowUnauthedWrites) {
|
|
3349
2013
|
return { status: 503, body: { error: "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting task submissions", errorCode: "auth.service_token_required" } };
|
|
3350
2014
|
}
|
|
@@ -3369,12 +2033,6 @@ export function createHttpServer(rawDeps) {
|
|
|
3369
2033
|
},
|
|
3370
2034
|
};
|
|
3371
2035
|
}
|
|
3372
|
-
// 🔴 A-058 F0(7.40 合并窗重扫,high,opus 证伪 CONFIRMED):第四道 = **成本配额门**。`/decide` 对
|
|
3373
|
-
// 同一动作吃 `quotaExceeded`(approvals-assistant 路由挂);respond 路由刻意只有 rateLimited(活卡
|
|
3374
|
-
// 回决=resolve 在飞 promise,确实无新计费,路由注释如实),但本席开的是一条新的烧模型 resume 腿——
|
|
3375
|
-
// 修前超窗租户可以「攒一张会 park 的卡、park 后再赎」绕开成本上限。判据与文案逐字取 `quotaExceeded`
|
|
3376
|
-
// 的同三件(gatedPrincipal 验证身份 / costQuota.check / 429 同码同文案);无 principal = not gated
|
|
3377
|
-
// 是那道门自己的成文语义(单用户 turnkey 形),此处同判不另立。
|
|
3378
2036
|
if (deps.costQuota && req.httpReq) {
|
|
3379
2037
|
const quotaPrincipal = gatedPrincipal(req.httpReq, deps.config);
|
|
3380
2038
|
if (quotaPrincipal !== undefined) {
|
|
@@ -3396,14 +2054,8 @@ export function createHttpServer(rawDeps) {
|
|
|
3396
2054
|
return { status: 409, body: { error: e instanceof Error ? e.message : String(e), errorCode: "conflict.checkpoint_unreadable" } };
|
|
3397
2055
|
}
|
|
3398
2056
|
if (!cp) {
|
|
3399
|
-
// 行说 PARKED,checkpoint 却没了(过期清算 / 已被消费)——answer 无处兑现。用与「本会话没有 pending」
|
|
3400
|
-
// 同一句同一码(不给存在性谕示,与 decide 腿的 404 口径一致)。
|
|
3401
2057
|
return { status: 404, body: { error: "approval not found", errorCode: "not_found.approval" } };
|
|
3402
2058
|
}
|
|
3403
|
-
// 🔴 属主门第三道:**checkpoint 当下的 scope**(codex R1-[high] 一的另一半)。判据逐字取 `/decide` 路由
|
|
3404
|
-
// 的非 operator 支(`"_"`/null = 匿名单用户形,开放;否则必须与已验证调用方逐字相等)—— 本席绝不比那条
|
|
3405
|
-
// 腿宽。收编把行与 cp 的身份一起改写之后,持旧 approvalId 的前属主到这里被挡下(协调器那边还有一道
|
|
3406
|
-
// 行属主门,两道各判各的持久事实)。**不加 operator 越权臂**:活卡是终端用户自己的。
|
|
3407
2059
|
const cpPrincipal = decodeCheckpointScope(cp.scope);
|
|
3408
2060
|
if (cpPrincipal !== undefined && cpPrincipal !== req.principal) {
|
|
3409
2061
|
deps.logger?.warn?.("parked_late_decision_scope_mismatch", { askId: req.askId });
|
|
@@ -3415,22 +2067,10 @@ export function createHttpServer(rawDeps) {
|
|
|
3415
2067
|
...(req.gateBoundInputHash !== null ? { boundInputHash: req.gateBoundInputHash } : {}),
|
|
3416
2068
|
...(req.updatedInput !== undefined ? { updatedInput: req.updatedInput } : {}),
|
|
3417
2069
|
};
|
|
3418
|
-
// #204 件6③:出处 = `"human"` —— 走到这里的恒是壳上那张卡被**人**按下的那一次 respond
|
|
3419
|
-
// (窗到期/取消/SLA sweep 都不经本席)。
|
|
3420
2070
|
const out = await resumeCheckpoint(cp.sessionId, req.decision, req.note, "human", req.httpReq, undefined, binding, undefined, true);
|
|
3421
2071
|
return { status: out.status, body: out.body };
|
|
3422
2072
|
}
|
|
3423
|
-
/**
|
|
3424
|
-
* #261 §1(a):**副本本地活体谓词** —— 这条 taskId 现在是不是本副本手上的活腿。
|
|
3425
|
-
*
|
|
3426
|
-
* 交出的是一个 `(taskId) => boolean` **探针**,不是 Map:登记面的属主仍是本模块(它们是 cancel /
|
|
3427
|
-
* steer / preempt 的权威表),对账腿只问一个是非题。搬 Map 会造出第二份活性判据,而两份判据必漂移。
|
|
3428
|
-
* 并集口径与 `drainState.inflight` 逐字同源(`inflightRuns` = 在飞 durable 腿、`steerableRuns` =
|
|
3429
|
-
* 持活 core 流的腿;一条 resume 腿同时在两表里),所以「本副本认为它活着」在两条消费线上是同一句话。
|
|
3430
|
-
*/
|
|
3431
2073
|
const isRunLiveOnThisReplica = (taskId) => inflightRuns.has(taskId) || steerableRuns.has(taskId);
|
|
3432
|
-
// Augment the http.Server with the D-D deny-sweep handle (back-compat: all 12 callers keep using the return
|
|
3433
|
-
// value AS an http.Server — listen/close/etc. — while main.ts's reaper reads server.denyExpiredApprovals).
|
|
3434
2074
|
return Object.assign(server, { denyExpiredApprovals, isRunLiveOnThisReplica, redeemParkedAsk });
|
|
3435
2075
|
}
|
|
3436
2076
|
const WORKER_SWAP_REDEEMABLE = {
|
|
@@ -3439,114 +2079,37 @@ const WORKER_SWAP_REDEEMABLE = {
|
|
|
3439
2079
|
governed_unwired: true,
|
|
3440
2080
|
real_approval_damaged: false,
|
|
3441
2081
|
real_approval_forged: false,
|
|
3442
|
-
// core 5.30.0(#246 提货):v9 read-face 段的行完整性两词——d.ts 逐字与 real_approval 两词同列
|
|
3443
|
-
// 「terminal for the row's current bytes」,换 worker 不可兑,归属同前(invalid_outcome 臂,行留 pending)。
|
|
3444
2082
|
read_face_damaged: false,
|
|
3445
2083
|
read_face_forged: false,
|
|
3446
2084
|
constraint_chain_missing: false,
|
|
3447
2085
|
};
|
|
3448
|
-
/** 判别符缺席(旧引擎裸抛)或词表不认得 ⇒ **false**,即维持修前的 TERMINAL 归属。fail-closed 的方向在
|
|
3449
|
-
* 这里是「不宽容默认」:一个我们还没读过的新臂不该因为一个 `?? true` 被悄悄改判成「重试吧」。 */
|
|
3450
2086
|
function checkpointRowRedeemableElsewhere(reason) {
|
|
3451
2087
|
return reason !== undefined && WORKER_SWAP_REDEEMABLE[reason] === true;
|
|
3452
2088
|
}
|
|
3453
|
-
/** POST endpoints that trigger BILLABLE work — the fail-closed auth guard must cover ALL of them (council: the
|
|
3454
|
-
* guard's inline list had drifted from the handlers and missed `/v1/approvals/:id/decide`, which resumes a run
|
|
3455
|
-
* via resumeCheckpoint/store.decide → paid tokens). Keep this in sync when adding a billable POST route. */
|
|
3456
|
-
// [#104/#87-A3] exported for the declaration gate (test/billable-route-declaration.test.ts):每条路由
|
|
3457
|
-
// 标签必须显式申明 billable,与本谓词逐样本对账——「Keep this in sync」从注释请求变成机器断言。
|
|
3458
|
-
/**
|
|
3459
|
-
* 「这条 url **是**计费提交面,但它到底提不提交由**请求体里的方法**决定」—— 目前只有 A2A 的
|
|
3460
|
-
* JSON-RPC 端点(单 URL 承载 `message/send` 写 + `tasks/get` 读,因为 agent card 只能公告一个 `url`)。
|
|
3461
|
-
*
|
|
3462
|
-
* 用途:两道**可用性** 503(drain / model-roster-pending)按 url 一刀切会把 `tasks/get` 这条纯读一起
|
|
3463
|
-
* 关掉,而单副本部署恰好在重启/依赖降级窗口里最需要取回已完成的结果(codex 轮2 R2-3,验真后修)。
|
|
3464
|
-
* 本谓词让 `handle()` 对这条 url **跳过**那两道,改由域模块在 `message/send` 分支里施加同样的两道
|
|
3465
|
-
* (`routes/a2a-serve.ts` 的 `submitUnavailable`,文案逐字同源)。
|
|
3466
|
-
*
|
|
3467
|
-
* 🔴 **只豁免那两道,不豁免安全轴**:`isBillableSubmitPath` 本身**不变**(它仍答 true),所以
|
|
3468
|
-
* ① 无 service token 的部署上那道 fail-closed 503 照旧罩着这条 url —— 它判的是「这台机能不能收写」,
|
|
3469
|
-
* 与方法无关,而且门必须在解析请求体**之前**;
|
|
3470
|
-
* ② billable 申明门钉的仍是那只纯谓词(表里 `/v1/a2a` 依旧 billable=true)。
|
|
3471
|
-
* 新成员进本表 = 承诺「该域在自己的方法分支里补齐那两道」,漏补就是一条静默旁路;所以本表只该有
|
|
3472
|
-
* 「单 URL 多方法」这一种成员,加一条要连同它的域内补门一起过评审。
|
|
3473
|
-
*/
|
|
3474
2089
|
export function isMethodDispatchedSubmitPath(url) {
|
|
3475
2090
|
return url === "/v1/a2a";
|
|
3476
2091
|
}
|
|
3477
|
-
/** 「这台部署配了任何一把 service 凭证吗」—— fail-closed 写门族的真值源。**单一属主**:`handle()` 的
|
|
3478
|
-
* 凭证门与 #329 的 parked 赎回席读同一句(两处手抄迟早只改一处,而这条判据决定的是「无凭证部署上写口
|
|
3479
|
-
* 开不开」)。 */
|
|
3480
2092
|
function hasServiceAuth(config) {
|
|
3481
2093
|
return Boolean(config.authToken) || Object.keys(config.authTokens ?? {}).length > 0;
|
|
3482
2094
|
}
|
|
3483
2095
|
export function isBillableSubmitPath(url) {
|
|
3484
|
-
return (url === "/v1/side-query" ||
|
|
2096
|
+
return (url === "/v1/side-query" ||
|
|
3485
2097
|
url === "/v1/tasks" ||
|
|
3486
2098
|
url === "/v1/tasks/stream" ||
|
|
3487
2099
|
url === "/v1/runs" ||
|
|
3488
2100
|
url === "/v1/leader" ||
|
|
3489
|
-
// DESIGN-269 车2:A2A JSON-RPC 端点。`message/send` 走的是与 /v1/runs **同一条**提交腿 ⇒ 它烧模型
|
|
3490
|
-
// ⇒ 三道 503 门(drain / model-roster-pending / 无 service-token)必须对它生效,否则一台正在下线的
|
|
3491
|
-
// 副本会照收外部 peer 的新任务(wake 当年漏在名单外犯的就是这一条)。
|
|
3492
|
-
// ⚠️ 端点是**单 URL** 的(A2A 卡只公告一个 url),所以同一道门也会在排空期挡下 `tasks/get` 的读。
|
|
3493
|
-
// 方向取「宁紧」:开新模型工作不可回滚,而读面有现成补偿(行是 durable 的,换一台副本读同一条)。
|
|
3494
2101
|
url === "/v1/a2a" ||
|
|
3495
|
-
ASSISTANT_RESUME_RE.test(url) ||
|
|
3496
|
-
ASSISTANT_PLAN_REVIEW_RE.test(url) ||
|
|
3497
|
-
RUN_SUBAGENT_RESUME_RE.test(url) ||
|
|
3498
|
-
// 缝合审 2026-07-31:wake 走的是 assistant resume 那条同一个 `driveResumeIntoRunLog` 驱动腿
|
|
3499
|
-
// (`routes/notify-wake.ts` 头注:「wake 会**烧模型**(续跑)」),resume 在名单里而它一直不在 ⇒
|
|
3500
|
-
// 同一条模型腿从 wake 进来时 drain / model-roster-pending / 无 service-token 三道门全不生效。
|
|
3501
|
-
// 正则从 notify-wake 单源导入,免得又漂开。钉在 test/drain.test.ts。
|
|
2102
|
+
ASSISTANT_RESUME_RE.test(url) ||
|
|
2103
|
+
ASSISTANT_PLAN_REVIEW_RE.test(url) ||
|
|
2104
|
+
RUN_SUBAGENT_RESUME_RE.test(url) ||
|
|
3502
2105
|
SESSION_WAKE_RE.test(url) ||
|
|
3503
2106
|
url.startsWith("/v1/approvals"));
|
|
3504
2107
|
}
|
|
3505
|
-
/**
|
|
3506
|
-
* A-010.23(#209 件5)—— **改写门**:不烧模型、但在**没有任何 service credential** 的部署形下必须与
|
|
3507
|
-
* billable / bake 两族一样 fail-closed 的写门。谓词与 `isBillableSubmitPath` **刻意分开**:这几扇门
|
|
3508
|
-
* `billable=false`(零模型工作),塞进那张名单会让它们连带吃 drain 与 model-roster-pending 两道 503 ——
|
|
3509
|
-
* 而收编与规则读写在排空期/roster 未落时做完全无害。
|
|
3510
|
-
*
|
|
3511
|
-
* 判据(两个合取项,缺一不进本表)= 「授权的唯一输入是 principal 头」∧「动作是**持久改写**」:
|
|
3512
|
-
* · `POST /v1/adoption` —— operator-only 的一次性部署级动作,把一个 principal 名下**每一行**重写到另一个
|
|
3513
|
-
* 身份;授权判据 `explicitOperatorOk(gatedPrincipal(...))`。
|
|
3514
|
-
* · `POST /v1/rules/cc-import/*` —— principal lane 的**持久 allow 规则**铸造口;一条持久 allow 规则对后续
|
|
3515
|
-
* 同命令的 classify 档 ask 常驻消音(core #144 之后仍然如此,只是不再消 mandated 的那些)。
|
|
3516
|
-
* · `DELETE /v1/rules` —— 同一个规则店的**撤销**口。方向相反(收紧)但同样是持久改写,而且它多一条
|
|
3517
|
-
* operator 越权域(`?principal=` / body 的 `principal` 让 operator 收回**任一**租户的规则)⇒ 伪造一个
|
|
3518
|
-
* 列在 `OPERATOR_PRINCIPALS` 里的头,就能把每一位租户的规则一次清空。
|
|
3519
|
-
* 无 service token 的部署上 `verifiedPrincipal` 走的是「BFF/gated:头已由上游验过」那一支,而这一形恰恰
|
|
3520
|
-
* 是**没有**那个上游 —— 于是伪造一个头就能收编别人、替任意租户种下常驻放行、或替他们全删。bake 门当年
|
|
3521
|
-
* 补的就是同一句话,`isDestructiveSessionWrite` 从逐条路由白名单改成族判定也是同一句话(那次的措辞逐字是
|
|
3522
|
-
* 「a forged principal header alone could **tighten/DoS** a session's tools」——撤销口落的正是 DoS 那一半)。
|
|
3523
|
-
*
|
|
3524
|
-
* 🔴 **方法感知**(codex 对抗复审 R2-[high],验真后修):撤销走的是 `DELETE`,而本门最初只挂在 `POST` 的
|
|
3525
|
-
* 合取里 —— 于是一个自称「持久改写」的谓词把爆炸半径最大的那条动词漏在门外,正是 `isDestructiveSessionWrite`
|
|
3526
|
-
* 当年被抓到的同一形。签名因此收方法,与那只谓词逐字同形。
|
|
3527
|
-
* 🔴 读面**原则上**不进本表:`GET /v1/adoption/:id` 与 `GET /v1/rules` 各有自己的**属主门**兜底,本门只拦写。
|
|
3528
|
-
*
|
|
3529
|
-
* 🔴 **第二个合取项的加宽**(codex 对抗复审 [high],#277 提货批,验真后修)——判据现在是
|
|
3530
|
-
* 「持久改写 **或** 授权唯一输入是 principal 头的**跨租户治理读**」。加宽是为了收进 #264 v2-c 的两口:
|
|
3531
|
-
* · `POST /v1/memory/import` —— 直接落在**原**判据上,而且是本仓最深的那一种持久改写:它把别处铸的
|
|
3532
|
-
* 条目 / 挑战 / 污染标记 / 托管链落进本部署的记忆平面。伪造一个在册 operator 头 ⇒ 一份**攻击者自制的
|
|
3533
|
-
* 治理史**成为本店的账;而记忆是模型**每次挂载都读**的面,比种一条常驻 allow 规则更深(那条只影响
|
|
3534
|
-
* ask 会不会问,这条影响模型相信什么)。
|
|
3535
|
-
* · `POST /v1/memory/export` —— 是**读**,按原措辞本不该进。但被点名豁免的那两条读各有属主门兜底,
|
|
3536
|
-
* 而本条**没有**:它的授权判据就是 `explicitOperatorOk`,过了就是**全库**——一次调用取走每个 scope 的
|
|
3537
|
-
* 条目正文 + 托管链 + 跨会话污染标记 + lineage。把一次全租户记忆外泄留在门外、只为守住「本门只拦写」
|
|
3538
|
-
* 这句措辞,是把措辞看得比它要保护的东西更重。加宽后的判据仍然是**闭**的(不是「写门以外都进」):
|
|
3539
|
-
* 它要求「授权的唯一输入是那个头」∧「一次调用的爆炸半径跨租户」,`GET /v1/rules` 那种属主门读不满足。
|
|
3540
|
-
* 行为面钉:`test/rewrite-door-service-token-gate.test.ts`(六扇门各一正一反 + 逃生口格 + 读面负控)。
|
|
3541
|
-
*/
|
|
3542
2108
|
export function isCredentialGatedRewrite(method, url) {
|
|
3543
2109
|
const m = method.toUpperCase();
|
|
3544
2110
|
if (m === "POST") {
|
|
3545
2111
|
return (url === "/v1/adoption" ||
|
|
3546
2112
|
url.startsWith("/v1/rules/cc-import/") ||
|
|
3547
|
-
// #264 v2-c 的两口(codex 对抗复审 [high],验真后修)。判据见上方头注**加宽**的那一段:
|
|
3548
|
-
// import 是本仓最深的持久改写(外来治理史成为本店的账,而记忆是模型每次挂载都读的面);
|
|
3549
|
-
// export 虽是读,但它是**没有属主门兜底**的跨租户治理外泄——两者的授权唯一输入都是那个头。
|
|
3550
2113
|
url === "/v1/memory/import" ||
|
|
3551
2114
|
url === "/v1/memory/export");
|
|
3552
2115
|
}
|
|
@@ -3554,13 +2117,6 @@ export function isCredentialGatedRewrite(method, url) {
|
|
|
3554
2117
|
return url === "/v1/rules";
|
|
3555
2118
|
return false;
|
|
3556
2119
|
}
|
|
3557
|
-
// ⚠️ SSE 帧纪律(2026-07-12):center BFF 中继凭「data 无 type 字段」识别心跳帧并吞掉
|
|
3558
|
-
// (AgentEvent 是 closed oneof 全带 type,故可控)。**未来新增任何 SSE data 帧必须带 type 字段**,否则会被
|
|
3559
|
-
// 中继当心跳吞掉、永远到不了前端。heartbeat 帧(`event: heartbeat` + `data: {}`)是唯一豁免。
|
|
3560
|
-
/** 路由标签表(表驱动;[2061] 批1 层A 重写)。顺序=具体先于泛化。
|
|
3561
|
-
* 🔴 本表由 `test/route-shape-roster.test.ts` 棘轮看守:门机器枚举源码全部路由模式并断言每条都映射到
|
|
3562
|
-
* 非 "other"——新路由不补此表该门当场红(census 抓获本表曾漂掉 56 条模式:session-sync/workspace/
|
|
3563
|
-
* workflows/subagents/attachments/images/leader 等整族全折 "other",metrics 不可分桶)。 */
|
|
3564
2120
|
const ROUTE_LABEL_PATTERNS = [
|
|
3565
2121
|
[/^\/v1\/runs\/[^/]+\/steer$/, "/v1/runs/:id/steer"],
|
|
3566
2122
|
[/^\/v1\/runs\/[^/]+\/detach$/, "/v1/runs/:id/detach"],
|
|
@@ -3593,23 +2149,16 @@ const ROUTE_LABEL_PATTERNS = [
|
|
|
3593
2149
|
[/^\/v1\/workflows\/[^/]+\/agents\/[^/]+\/steer$/, "/v1/workflows/:id/agents/:aid/steer"],
|
|
3594
2150
|
[/^\/v1\/workflows\/[^/]+\/(stream|journal)$/, "/v1/workflows/:id/$sub"],
|
|
3595
2151
|
[/^\/v1\/workflows\/[^/]+$/, "/v1/workflows/:id"],
|
|
3596
|
-
// #151 车4:durable 回决口。必须排在 `/v1/tasks/:id/$sub` **之前**?—— 不必(那条只认三个字面动词),
|
|
3597
|
-
// 但仍按「具体先于泛化」的表序纪律放在它前面,免得日后 $sub 放宽成 `[^/]+` 时静默吞掉本条。
|
|
3598
2152
|
[/^\/v1\/tasks\/[^/]+\/asks\/[^/]+\/decision$/, "/v1/tasks/:id/asks/:askId/decision"],
|
|
3599
|
-
// [3321] tool-results 读面:同样排在 `/v1/tasks/:id/$sub` 之前(具体先于泛化的表序纪律;$sub 那条
|
|
3600
|
-
// 只认三个字面动词,今日不冲突,但它日放宽成 `[^/]+` 时本条不会被静默吞掉)。
|
|
3601
2153
|
[/^\/v1\/tasks\/[^/]+\/tool-results\/[^/]+$/, "/v1/tasks/:id/tool-results/:ref"],
|
|
3602
2154
|
[/^\/v1\/tasks\/[^/]+\/(turns|stream|artifacts)$/, "/v1/tasks/:id/$sub"],
|
|
3603
2155
|
[/^\/v1\/leader\/[^/]+$/, "/v1/leader/:id"],
|
|
3604
2156
|
[/^\/v1\/attachments\/[^/]+$/, "/v1/attachments/:id"],
|
|
3605
2157
|
[/^\/v1\/capabilities\/scenarios\/[^/]+$/, "/v1/capabilities/scenarios/:name"],
|
|
3606
2158
|
[/^\/v1\/adoption\/[^/]+$/, "/v1/adoption/:id"],
|
|
3607
|
-
// #270 车2:留存 legal-hold 的放置/解除。`*` 不是 `+` —— 空段 = 无主桶(单用户部署里它是唯一的域;
|
|
3608
|
-
// 理由逐字见 routes/retention-ops.ts 的「域的两个边界」)。
|
|
3609
2159
|
[/^\/v1\/ops\/retention\/holds\/[^/]*$/, "/v1/ops/retention/holds/:domain"],
|
|
3610
2160
|
[/^\/v1\/memory\/sync\/[^/]+$/, "/v1/memory/sync/:scope"],
|
|
3611
2161
|
[/^\/v1\/usage\/(summary|series|breakdown)$/, "/v1/usage/$sub"],
|
|
3612
|
-
// design/177 共享记忆库只读面。两条各自成桶(列举是窗口读、正文是单文档读,延迟分布不同族)。
|
|
3613
2162
|
[/^\/v1\/shared-memory\/stores\/[^/]+\/documents$/, "/v1/shared-memory/stores/:store/documents"],
|
|
3614
2163
|
[/^\/v1\/shared-memory\/stores\/[^/]+\/document$/, "/v1/shared-memory/stores/:store/document"],
|
|
3615
2164
|
];
|
|
@@ -3620,55 +2169,20 @@ const ROUTE_LABEL_LITERALS = new Set([
|
|
|
3620
2169
|
"/v1/assistant/inbox", "/v1/assistant/tasks", "/v1/usage", "/v1/policy", "/v1/capabilities",
|
|
3621
2170
|
"/v1/images", "/v1/images/bakes", "/v1/images/bakes/claim", "/v1/images/select", "/v1/images/register",
|
|
3622
2171
|
"/v1/workflows", "/v1/attachments", "/v1/leader", "/v1/outcomes", "/v1/side-query",
|
|
3623
|
-
// 2026-07-31 缝合审:它一直不在表里,而它是**每个壳一条的长连接** —— 那种 duration 落进 `other`
|
|
3624
|
-
// 桶会把该桶的 P99 拉爆,运营看不出是谁。批1 建表时漏掉它不是遗忘:fleet 域用 `pathname === …` 判路,
|
|
3625
|
-
// 名册门的枚举器当时只认 `url === …`,整条路由对门隐形(枚举器已一并补上)。
|
|
3626
2172
|
"/v1/fleet/stream",
|
|
3627
|
-
// ⚠️ `/v1/memory/export` 是**两个面**共用的标签(GET=142-S5 属主自助条目读,POST=#264 治理包)——
|
|
3628
|
-
// 标签粒度粗于面的边界,但两面同 billable(false)、同为低频运维/壳读,合桶不会掩盖任何一方的信号。
|
|
3629
2173
|
"/v1/memory/export", "/v1/sendfile-links",
|
|
3630
|
-
// #264 v2-c:治理包的导入口(operator-only,POST)。低频,但它是**治理写面** —— 它的 4xx/5xx 分布
|
|
3631
|
-
// 是「这批迁移到底成没成」的第一手机器信号,落进 `other` 桶等于迁移现场看不见它。同族纪律:写
|
|
3632
|
-
// **字面量**不引常量(名册门与 billable 申明门都扫源码文本)。
|
|
3633
2174
|
"/v1/memory/import",
|
|
3634
|
-
// design/177:共享记忆库清单面(具名两条走上面的模式表)。
|
|
3635
2175
|
"/v1/shared-memory/stores",
|
|
3636
|
-
// #154:装配自证读面(operator-only)。低频但**每次都在排障现场被打**,落进 `other` 桶等于排障时看不见。
|
|
3637
2176
|
"/v1/diagnostics/wiring",
|
|
3638
|
-
// design/183:收编发起口(operator-only,一次性部署级动作)。低频、但它的 duration 与失败率是
|
|
3639
|
-
// 迁移现场唯一的机器信号,落进 `other` 桶就看不见了。
|
|
3640
2177
|
"/v1/adoption",
|
|
3641
|
-
// #291:停机因由声明口(operator-only,drain 前一次性动作)。同 adoption 判:低频但只在停机现场被打。
|
|
3642
2178
|
"/v1/admin/drain",
|
|
3643
|
-
// #322 批1 追加件:配置手动刷新口(operator-only)。同判:低频,但它的 duration 与失败率正是「这次
|
|
3644
|
-
// 配置到底拉没拉下来」的第一手信号,落进 other 桶就看不见了。同族纪律:写**字面量**不引常量。
|
|
3645
2179
|
"/v1/admin/config/refresh",
|
|
3646
|
-
// #154 车二:CC settings 导入两口。字面量(无 id 段)⇒ 进 LITERALS 表而不是模式表。
|
|
3647
|
-
// 🔴 写**字面量**而不是引 `RULES_CC_IMPORT_*_PATH` 常量:名册门与 billable 申明门都是**扫源码文本**
|
|
3648
|
-
// 的(它们正则抓本表里的双引号词),引常量会让两条新路由对两道门隐形 —— 那不是「过了门」,是绕过门。
|
|
3649
|
-
// ⚠️ 本段注释里也不许出现方括号后紧跟右圆括号的字符对:billable 门的表捕获正则是**非贪婪**的,
|
|
3650
|
-
// 那两个字符会让它在这里提前收尾,后面的字面量整段落在门的视野外(写这条注时真的踩到过)。
|
|
3651
2180
|
"/v1/rules/cc-import/prepare", "/v1/rules/cc-import/redeem",
|
|
3652
|
-
// #203 §2:撤销面两口(GET 列举 / DELETE 撤销)共用一个字面路径。同样写字面量而不是引 `RULES_PATH`
|
|
3653
|
-
// 常量,理由与上一条逐字相同(名册门与 billable 申明门都扫源码文本)。
|
|
3654
2181
|
"/v1/rules",
|
|
3655
|
-
// DESIGN-269 车2:server-as-peer 两口。卡是**公网匿名**面(唯一一条不吃服务凭据的 2xx 路由),
|
|
3656
|
-
// 它的 QPS 与 duration 是判断「有没有人在扫我们」的第一手材料;RPC 口是外部 peer 的提交面。
|
|
3657
|
-
// 两条都落进 `other` 桶 = 对外暴露面在 metrics 上不可分。同上,写**字面量**不引常量(两道门扫源码文本)。
|
|
3658
2182
|
"/.well-known/agent-card.json", "/v1/a2a",
|
|
3659
|
-
// #A1([4147] 件3):后台 agent 名册读面。壳每次开面板打一次,是**每用户高频**的租户读 ——
|
|
3660
|
-
// 落进 `other` 桶会把它的 duration 和 4xx 混进一堆低频运维口里。同上,写字面量不引常量。
|
|
3661
2183
|
"/v1/agents/roster",
|
|
3662
|
-
// #270 车2:留存审计读面(operator-only)。低频,但它是「留存到底删没删、按哪档删的」的唯一读口
|
|
3663
|
-
// —— 落进 `other` 桶等于合规排障现场看不见它。同上,写**字面量**不引常量(名册门与 billable
|
|
3664
|
-
// 申明门都扫源码文本)。
|
|
3665
2184
|
"/v1/ops/retention/audit",
|
|
3666
2185
|
]);
|
|
3667
|
-
/** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`).
|
|
3668
|
-
* [#104] 字面量**先于**模式:此前模式先查,五条精确路由被形状桶吞掉(`/v1/approvals/stream`
|
|
3669
|
-
* 落 ":id"、`/v1/images/{bakes,select,register}` 落 ":profile"、`/v1/images/bakes/claim` 落
|
|
3670
|
-
* "bakes/:id")——长连接 stream 的 duration 混进 :id 桶正是 fleet/stream 案的同族错桶病。
|
|
3671
|
-
* 精确匹配恒比形状匹配更对,序修零反例(billable-route-declaration 门逐样本钉)。 */
|
|
3672
2186
|
export function routeLabel(_method, url) {
|
|
3673
2187
|
if (ROUTE_LABEL_LITERALS.has(url))
|
|
3674
2188
|
return url;
|
|
@@ -3677,13 +2191,9 @@ export function routeLabel(_method, url) {
|
|
|
3677
2191
|
return label;
|
|
3678
2192
|
return "other";
|
|
3679
2193
|
}
|
|
3680
|
-
/** CORS v2: allowlist semantics. A SINGLE configured origin keeps the v1 posture
|
|
3681
|
-
* (echoed unconditionally — non-browser callers see it too, byte-compat); MULTIPLE origins match the
|
|
3682
|
-
* request's Origin header (echo the matched one, `Vary: Origin` so a shared cache never serves origin-A's
|
|
3683
|
-
* grant to origin-B; no match ⇒ no allow-origin header ⇒ the browser refuses). Empty list = CORS off. */
|
|
3684
2194
|
function cors(res, req, origins, principalHeader) {
|
|
3685
2195
|
if (!origins || origins.length === 0)
|
|
3686
|
-
return;
|
|
2196
|
+
return;
|
|
3687
2197
|
let allow;
|
|
3688
2198
|
if (origins.length === 1) {
|
|
3689
2199
|
allow = origins[0];
|
|
@@ -3696,23 +2206,8 @@ function cors(res, req, origins, principalHeader) {
|
|
|
3696
2206
|
if (allow === undefined)
|
|
3697
2207
|
return;
|
|
3698
2208
|
res.setHeader("access-control-allow-origin", allow);
|
|
3699
|
-
// 🔴 这三行自 2026-07-14 公开快照起逐字未动,而这期间进来的路由与请求头它一个都不认
|
|
3700
|
-
// (2026-08-01 缝合审)。浏览器前端撞的不是"请求失败"而是**请求根本发不出去** —— OPTIONS 预检就被拒。
|
|
3701
|
-
// 维护纪律:**新增 PUT/DELETE 路由、或新读一个自定义请求头时,这里必须同步**;
|
|
3702
|
-
// `test/cors-v2.test.ts` 的三条钉逐个断言方法与头名,漏一个当场红。
|
|
3703
2209
|
res.setHeader("access-control-allow-methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
3704
|
-
|
|
3705
|
-
// (which would otherwise drop identity → an owner=null session under requirePrincipal=false).
|
|
3706
|
-
// 后五项都是**真被 handler 读**的自定义头:`idempotency-key`(tasks/runs/images)、
|
|
3707
|
-
// `x-detach-on-disconnect`(tasks 断连不中止)、direct-door 决策证明三件套(approvals /decide 的
|
|
3708
|
-
// crypto 绑定腿 —— 缺了它,direct-door worker 上的 web 审批面在浏览器里根本用不了)。
|
|
3709
|
-
res.setHeader("access-control-allow-headers",
|
|
3710
|
-
// direct-door 三件套用 security.ts 的具名导出插值(与同句的 `${principalHeader}` 同姿势)——手抄的
|
|
3711
|
-
// 头名漏改在这一行是**静默**的:预检挡下 ⇒ 请求到不了 handler,任何一处都不会报错。
|
|
3712
|
-
`content-type, authorization, last-event-id, ${principalHeader}, idempotency-key, x-detach-on-disconnect, ${PRINCIPAL_TOKEN_HEADER}, ${APPROVAL_MAC_HEADER}, ${APPROVAL_MAC_KID_HEADER}, if-none-match`);
|
|
3713
|
-
// 🔴 expose-headers 此前**整个缺席** ⇒ fetch 类客户端读不到 `X-Task-Id`(routes/tasks.ts 经 sseHeaders
|
|
3714
|
-
// 下发的 durable rewind handle)。tasks.ts 的 G15 meta 帧注释把这归因为「EventSource 与部分代理读不到
|
|
3715
|
-
// 响应头」—— 对 EventSource 成立,但对 fetch 客户端真因是这里缺 expose,meta 帧只是绕过。
|
|
2210
|
+
res.setHeader("access-control-allow-headers", `content-type, authorization, last-event-id, ${principalHeader}, idempotency-key, x-detach-on-disconnect, ${PRINCIPAL_TOKEN_HEADER}, ${APPROVAL_MAC_HEADER}, ${APPROVAL_MAC_KID_HEADER}, if-none-match`);
|
|
3716
2211
|
res.setHeader("access-control-expose-headers", "x-task-id, retry-after");
|
|
3717
2212
|
}
|
|
3718
2213
|
//# sourceMappingURL=server.js.map
|