@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/routes/runs.js
CHANGED
|
@@ -24,68 +24,22 @@ import { clearTurnActivity, readTurnActivityMs } from "../../turn-activity.js";
|
|
|
24
24
|
import { headerStr, gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
|
|
25
25
|
import { ActorAssertionWire } from "@sema-agent/registry-core";
|
|
26
26
|
import { RUN_NOT_FOUND_MESSAGE, runNotFoundMessage } from "../route-ctx.js";
|
|
27
|
-
// server.ts 侧的 routeLabel / isBillableSubmitPath 仍要用下面这些正则,故本模块导出(方向恒为 server.ts → routes/*)。
|
|
28
27
|
export const RUN_ID_RE = /^\/v1\/runs\/([^/]+)(\/events)?$/;
|
|
29
28
|
export const RUN_CANCEL_RE = /^\/v1\/runs\/([^/]+)\/cancel$/;
|
|
30
29
|
export const RUN_STEER_RE = /^\/v1\/runs\/([^/]+)\/steer$/;
|
|
31
30
|
export const RUN_DETACH_RE = /^\/v1\/runs\/([^/]+)\/detach$/;
|
|
32
31
|
export const RUN_COMPACT_RE = /^\/v1\/runs\/([^/]+)\/compact$/;
|
|
33
|
-
// E23 (shell-host contract): the shell answers an inbound MCP elicitation here. Live-only + same-replica (the pending promise
|
|
34
|
-
// is in-memory) — a respond on the wrong replica / after answer/expiry → 404, like a steer on a non-live run.
|
|
35
|
-
/** PAIR-REVIEW F-10:runs 面 404 文案统一富形——a\* 形 id(background-agent registry 句柄,`a`+hex 短形)
|
|
36
|
-
* 是「结构性不在 run 店」的常客([1347]⑤ 记档裁量:不改路由,只给引导)。识别句法形即补引导;
|
|
37
|
-
* 非 a\* 形保通用富文案(cancel 腿 [888]① 同款)。 */
|
|
38
|
-
// #262:runNotFoundMessage 与 RUN_NOT_FOUND_MESSAGE 搬 route-ctx 叶子(server.ts 的 owner 门也要同串,
|
|
39
|
-
// 两文件共享必须走叶子防环)——unknown 与 not-yours 两臂自此单一文案源,oracle 封口理由见叶子头注。
|
|
40
32
|
export const ELICIT_RESPOND_RE = /^\/v1\/elicitations\/([^/]+)\/respond$/;
|
|
41
|
-
// §4④: the shell answers the agent's AskUserQuestion here. Same live-only + same-replica shape as elicit.
|
|
42
33
|
export const QUESTION_RESPOND_RE = /^\/v1\/questions\/([^/]+)\/respond$/;
|
|
43
34
|
export const TOOL_APPROVAL_RESPOND_RE = /^\/v1\/tool-approvals\/([^/]+)\/respond$/;
|
|
44
|
-
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
45
|
-
// #151 车4(design/172 §3.2;稿 = design/151-172-stream-approval/151-car4-design.md,§12 折叠节为准)
|
|
46
|
-
// —— 流内审批卡的 **durable 回决口**。HITL 族第四条,但与上面三条 respond 的辖域正相反:
|
|
47
|
-
// · 三条 respond = LIVE-ONLY + 同副本(解一个内存里的悬挂 promise;跨副本/窗后一律 404);
|
|
48
|
-
// · 本条 = 打 `approval_ask` 行的 **CAS**(任何副本都能受理),本副本活体窗只是「顺带同步终局」。
|
|
49
|
-
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
50
35
|
export const TASK_ASK_DECISION_RE = /^\/v1\/tasks\/([^/]+)\/asks\/([^/]+)\/decision$/;
|
|
51
|
-
/** 425 的轮询提示秒数(头与体成对,§12-C F37)。 */
|
|
52
36
|
const ASK_PARKING_RETRY_AFTER_SEC = 2;
|
|
53
|
-
/** `decision_actor` 的展示文本上限(171 ActorAssertion 的 `label` 帽,§2)。 */
|
|
54
37
|
const ASK_ACTOR_LABEL_MAX = 256;
|
|
55
|
-
/**
|
|
56
|
-
* 171 `ActorAssertion` 的 server 侧**读**面 schema —— **键集同源包版,严格度刻意宽读**。
|
|
57
|
-
*
|
|
58
|
-
* 属主收敛(#282 件1,DESIGN-282 §1):§12-D 早就裁了「schema 落 `@sema-agent/registry-core`」,车4 当时
|
|
59
|
-
* 因本仓 deps 钉在 `^0.16.0`(`ActorAssertionWire` 要 0.17.1 才有)只能手写一份对齐形,并把欠账写在注里。
|
|
60
|
-
* 本批把 deps 抬到 `^0.18.0` 并兑现:键集/取值域**不再有第二份拼写**,直接取包版 {@link ActorAssertionWire}。
|
|
61
|
-
*
|
|
62
|
-
* 🔴 但**不能裸替换**:包版是 `.strict()`(registry-core types.ts `ActorAssertionWire`),而本处是**读**面,
|
|
63
|
-
* 两者的严格度取向刻意相反 ——
|
|
64
|
-
* · 包版严:它同时被**写/铸**侧当规范用,未知键 = 写的人搞错了,当场拒是对的;
|
|
65
|
-
* · 本处宽:行是**别的进程、别的版本**写下的。未来某版给 actor 加一个附加字段,一 `.strict()` 就把
|
|
66
|
-
* **整份** actor 判为不认、连 `id`/`verified` 都一起丢 —— 而「坏形只丢 actor 不 500」正是本 schema
|
|
67
|
-
* 存在的意义(§12-D F12)。出处:本文件 codex 交叉复审 round2 的真 finding(红先验过);本批换装时
|
|
68
|
-
* 再次机械复验 —— 裸用包版 ⇒ D-10(`test/tool-approval-e2e-http.test.ts`)当场红。
|
|
69
|
-
* 故取 `.strip()`(zod 默认档):已知字段照常投影,附加字段静默丢弃(响应键集因此仍是封闭的,不转发陌生字节)。
|
|
70
|
-
* `verified`/`via` 缺席不是错误(缺席 = 写那行的版本没记)。
|
|
71
|
-
*
|
|
72
|
-
*/
|
|
73
38
|
const AskDecisionActorSchema = ActorAssertionWire.strip();
|
|
74
|
-
/**
|
|
75
|
-
* 回决 body(稿 §3 + §12-F)。`.strict()` = 未知键当场 400,不静默吞。
|
|
76
|
-
*
|
|
77
|
-
* 🔴 `actor` 只收 `label`:`id`/`verified` 恒由 server 铸(171 §5.1 —— **actor 永不参与权限判定**,
|
|
78
|
-
* 它是审计投影,不是凭证)。调用方给的 id 若被采信,任何持有本 ask 读权的调用方都能把回决记在别人名下。
|
|
79
|
-
* 🔴 `idempotencyKey` 的形与店门口的 `assertIdempotencyKeyShape` 同规(1–255 且无首尾空白)——
|
|
80
|
-
* 端点先拒 ⇒ 坏形键永远走不到店(店那道门是最后一道,不是唯一一道)。
|
|
81
|
-
*/
|
|
82
39
|
const AskDecisionBodySchema = z
|
|
83
40
|
.object({
|
|
84
41
|
decision: z.enum(["approve", "deny"]),
|
|
85
|
-
/** 仅 approve 臂有意义;deny 带 = 宽收后忽略(与三条 respond 同姿势)。 */
|
|
86
42
|
updatedInput: z.unknown().optional(),
|
|
87
|
-
/** 上限走**共享常量**(#229):live 腿的手写 parser 写同一列,两处各写一份 `2048` 字面量 =
|
|
88
|
-
* 同一列上两个口径,而分歧只会在「一条腿收下、另一条腿 400」的那天才暴露。 */
|
|
89
43
|
note: z.string().max(MAX_DECISION_NOTE_CHARS).optional(),
|
|
90
44
|
idempotencyKey: z
|
|
91
45
|
.string()
|
|
@@ -96,36 +50,16 @@ const AskDecisionBodySchema = z
|
|
|
96
50
|
actor: z.object({ label: z.string().max(ASK_ACTOR_LABEL_MAX) }).strict().optional(),
|
|
97
51
|
})
|
|
98
52
|
.strict();
|
|
99
|
-
/** 行上的 `decision_actor`(裸 JSON)投成回显形;形不认 ⇒ undefined(调用点省略该键,不 500)。
|
|
100
|
-
* 导出仅为契约门(`test/ask-decision-actor-contract.test.ts` 钉「键集同源包版 + 严格度刻意宽读」两条)
|
|
101
|
-
* —— 导出的是**行为**而不是 schema 对象:schema 的推断类型会牵出 registry-core 内嵌的 zod 路径
|
|
102
|
-
* (TS2742,不可移植),而这道门要钉的本来就是行为。非 wire 面。 */
|
|
103
53
|
export function projectDecisionActor(raw) {
|
|
104
54
|
const parsed = AskDecisionActorSchema.safeParse(raw);
|
|
105
55
|
return parsed.success ? parsed.data : undefined;
|
|
106
56
|
}
|
|
107
|
-
// C2 (core 1.219): steer a still-running Task SUB-AGENT of a run. `:target` = the child's
|
|
108
|
-
// parentToolCallId (spawn-time identity, unique) or its agentName (display name; 409 when ambiguous).
|
|
109
57
|
export const RUN_SUBAGENT_STEER_RE = /^\/v1\/runs\/([^/]+)\/subagents\/([^/]+)\/steer$/;
|
|
110
|
-
// design/122 (core 1.225): REVIVE a SETTLED sub-agent with a new prompt (CC dfe parity).
|
|
111
58
|
export const RUN_SUBAGENT_RESUME_RE = /^\/v1\/runs\/([^/]+)\/subagents\/([^/]+)\/resume$/;
|
|
112
|
-
// [1488]③(b): GET a BACKGROUND child's final report / current status (a… agent handle → core TaskRegistry,
|
|
113
|
-
// the TaskOutput-tool face over HTTP; background_agent ONLY — wa… workflow-agent rows are observation
|
|
114
|
-
// identities read via the workflow journal). Complements steer/resume (which cover SYNC children only).
|
|
115
59
|
export const RUN_SUBAGENT_OUTPUT_RE = /^\/v1\/runs\/([^/]+)\/subagents\/([^/]+)\/output$/;
|
|
116
60
|
export const RUN_SUBAGENT_STREAM_RE = /^\/v1\/runs\/([^/]+)\/subagents\/([^/]+)\/stream$/;
|
|
117
|
-
// [1499] CC TaskOutput/TaskStop 人侧对位 — the GENERIC task-handle verbs (b* bash stdout — cursor
|
|
118
|
-
// consumption depends on the handle's shape, see the handler; m* monitor batches, a* agent final report;
|
|
119
|
-
// `workflow` refused — journal face owns it). SESSION-enforced from birth (new face, zero consumers):
|
|
120
|
-
// a session-bound run requires the matching ?session=.
|
|
121
61
|
export const RUN_TASK_OUTPUT_RE = /^\/v1\/runs\/([^/]+)\/tasks\/([^/]+)\/output$/;
|
|
122
62
|
export const RUN_TASK_STOP_RE = /^\/v1\/runs\/([^/]+)\/tasks\/([^/]+)\/stop$/;
|
|
123
|
-
/** The task_run SSE — a thin wrapper over `streamSseLog` that PRESERVES the existing wire shape exactly
|
|
124
|
-
* (`data: {type, ...data}`, `event: failed` stale frame). Behavior-identical to the pre-refactor reader. */
|
|
125
|
-
/** [2373]C-5:subagent/task-handle 两读写面的**共享 authz 门**(principal→owner→session 三级,
|
|
126
|
-
* [1493] 模型)。此前两面逐字重写——[1493] 类修复要改两次的真风险。返回 run 行;`null`=已应答
|
|
127
|
-
* (401/404,honest 404 无存在性 oracle)。语义逐字保留:operator 双跳过(owner+session)、
|
|
128
|
-
* session-unbound run 仅 principal 门、fail-closed。 */
|
|
129
63
|
async function subagentReadGate(req, res, deps, runId) {
|
|
130
64
|
const principal = gatedPrincipal(req, deps.config);
|
|
131
65
|
if (deps.config.requirePrincipal && principal === undefined) {
|
|
@@ -135,55 +69,21 @@ async function subagentReadGate(req, res, deps, runId) {
|
|
|
135
69
|
const trusted = explicitOperatorOk(principal, deps.config.operatorPrincipals);
|
|
136
70
|
const run = await deps.runStore.getRun(runId);
|
|
137
71
|
if (!run || (!trusted && run.owner !== null && run.owner !== principal)) {
|
|
138
|
-
sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
|
|
72
|
+
sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
|
|
139
73
|
return null;
|
|
140
74
|
}
|
|
141
75
|
const callerSession = new URL(req.url ?? "", "http://x").searchParams.get("session");
|
|
142
76
|
if (!trusted && run.sessionId && callerSession !== run.sessionId) {
|
|
143
|
-
sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
|
|
77
|
+
sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
|
|
144
78
|
return null;
|
|
145
79
|
}
|
|
146
80
|
return { run, trusted };
|
|
147
81
|
}
|
|
148
|
-
/**
|
|
149
|
-
* #261 §3 —— a\* 句柄读腿的 **404 三分**(TaskOutput / tail / task-handle output+stop 四条腿共用)。
|
|
150
|
-
*
|
|
151
|
-
* ## 病灶(cli 原票的另一半)
|
|
152
|
-
* 这些腿的 404 此前把三件互不相干的事说成同一句话:(i) handle 从未存在(打错字);(ii) **引擎重启后
|
|
153
|
-
* registry 失忆** —— 句柄活在进程内 registry 里,宿主 run 一终局/进程一重启它就没了;(iii) **打错副本**
|
|
154
|
-
* —— run 正在别的 replica 上跑,句柄在**那边**的 registry 里。壳只能猜(cli 四刀里「断流判据修正」
|
|
155
|
-
* 就是在替这个缺口打补丁)。
|
|
156
|
-
*
|
|
157
|
-
* ## 判别材料从哪来(**零新增读**,这点是承重的)
|
|
158
|
-
* 全部来自 {@link subagentReadGate} 已经读到手、且**已证明属于调用方**的那条**宿主 run 行**:
|
|
159
|
-
* · `isTerminalRunStatus(run.status)` ⇒ 宿主 run 已终局 ⇒ 它的进程内 registry 子代必然已随之离场
|
|
160
|
-
* ⇒ `not_found.task_owner_settled`;
|
|
161
|
-
* · `run.status === "running"` 且 `run.instanceId` 指向**别的**副本 ⇒ 它的句柄都在那边的 registry 里
|
|
162
|
-
* ⇒ `not_found.task_owner_elsewhere`;
|
|
163
|
-
* · 其余(本副本在跑 / park 中 / `instanceId` 为 null 的前列行)⇒ **最弱主张**,码与文案逐字不变。
|
|
164
|
-
*
|
|
165
|
-
* 🔴 **两个新码陈述的是「属主」,不是「这个句柄」**(codex 对抗复审 R1-F5 [medium],验真后采纳)。
|
|
166
|
-
* 走到这里的 `not_found` 同样可能来自**拼错的句柄**、错的 kind、或别的 run 的句柄 —— 本函数手上
|
|
167
|
-
* **没有**任何目标级证据能证明这个句柄存在过。首版的码名(`task_retired`)与文案(「它存在过,已终局」)
|
|
168
|
-
* 因此是**越权断言**:一个自动客户端会据此停止查找,而真相可能只是它把 id 打错了。现在两个码逐字只说
|
|
169
|
-
* 属主 run 的**状态与位置**(那是调用方本就有权读到的东西,`GET /v1/runs/:id` 直答),句柄本身仍是
|
|
170
|
-
* 那句最弱主张 "no such background task handle here" —— 判别性给到了(壳能分清「registry 失忆」与
|
|
171
|
-
* 「打错副本」),存在性一个字都没多说。
|
|
172
|
-
*
|
|
173
|
-
* 🔴 **A-041 反枚举不变式原样保留**:三分**只对自己人开**。跨 principal 的调用方在上面那道门就已经
|
|
174
|
-
* 被 `not_found.run` 挡掉(与 unknown 臂同码同文,#262 oracle 封口),根本走不到这里 —— 所以这三分
|
|
175
|
-
* 泄露的全部信息是「**你自己**这条 run 的状态」,而那本来就是 `GET /v1/runs/:id` 直答的东西。
|
|
176
|
-
*
|
|
177
|
-
* 🔴 **拿不准的恒答最弱主张**(§3 逐字):本函数不为判别性去造任何新证据 —— 不查 registry、不查
|
|
178
|
-
* durable background_agent 行。`instanceId === null`(建列之前的行)是"不知道"而不是"在本机",故落最弱臂。
|
|
179
|
-
*/
|
|
180
82
|
function sendHandleMiss(res, run, thisInstanceId) {
|
|
181
83
|
if (isTerminalRunStatus(run.status)) {
|
|
182
84
|
sendError(res, 404, "not_found.task_owner_settled", "no such background task handle here. Routing hint about its OWNER (not about this handle): the run has already settled, and background handles live in the replica-local registry for the parent's lifetime, so none of its handles are readable any more. Read the run's own outcome via GET /v1/runs/:id");
|
|
183
85
|
return true;
|
|
184
86
|
}
|
|
185
|
-
// `instanceId` 缺席(null,或建列之前/替身行上的 undefined)= **不知道它在哪**,不是"在别处" ——
|
|
186
|
-
// 落最弱主张。同理 `thisInstanceId` 缺席时本副本连自己是谁都说不上,更不能断言别人。
|
|
187
87
|
if (run.status === "running" && typeof run.instanceId === "string" && run.instanceId.length > 0 && thisInstanceId !== undefined && run.instanceId !== thisInstanceId) {
|
|
188
88
|
sendError(res, 404, "not_found.task_owner_elsewhere", "no such background task handle here. Routing hint about its OWNER (not about this handle): the run is executing on another replica, and this one's registry holds none of its handles. Ask the replica that owns the run, or read the run's own outcome via GET /v1/runs/:id");
|
|
189
89
|
return true;
|
|
@@ -192,38 +92,13 @@ function sendHandleMiss(res, run, thisInstanceId) {
|
|
|
192
92
|
}
|
|
193
93
|
async function streamRunEvents(req, res, deps, runStore, taskId, staleMs) {
|
|
194
94
|
await streamSseLog(req, res, {
|
|
195
|
-
// #151 车3 §5.1:durable tail 腿的开流重放 —— 本 run 的未决审批卡按 taskId 读回并投成
|
|
196
|
-
// `approval_request` preamble 帧(时序=416 判定 + sseHeaders **之后**,见 sse-log.ts 的 hook 顶注)。
|
|
197
|
-
//
|
|
198
|
-
// 三条判据都在这几行里:
|
|
199
|
-
// · **零写**:只 `listPendingByTask`,不 CAS、不改 expires_at_ms(投影见 buildReplayFrame 顶注);
|
|
200
|
-
// · **坏行不炸开流**:形不合的 `card_json` 行跳过 + 一次 warn(不是每行一条,免得一张坏表刷屏);
|
|
201
|
-
// · **读面帽**:超 `replayMax` 只投最新 N + 一次 warn。
|
|
202
|
-
//
|
|
203
|
-
// 🔴 开关谓词是**必须**的(codex 复审第二轮 high,2026-08-06):原按「关的时候表里本就没行 ⇒
|
|
204
|
-
// 无需分支」落地,但这个前提在**回滚**下不成立 —— 开着协议的副本(或更新版本)已经落下的
|
|
205
|
-
// `STREAM_PENDING` 行在关掉开关后**仍在表里**,无谓词的重放腿会把它们照常投上 wire,
|
|
206
|
-
// 「开关关 = 逐字零变化」(§6.5/§7.2)当场破。谓词同时省掉了关闭态每次开流的一次 store 往返
|
|
207
|
-
// 与那份 store 故障暴露面。钉:场景9(零帧 **且** 零 store 读 —— 只钉零帧会放过「读了但没投」)。
|
|
208
95
|
preamble: async (signal) => {
|
|
209
|
-
// 🔴 A-010.7(验真后修):谓词是**上场门**,不是裸开关。
|
|
210
|
-
// 原式是 `if (!config.streamApproval.enabled) return []` + 一个 `backend?.approvalAsk()` 兜底,
|
|
211
|
-
// 那是本仓早就收编掉的「局部合取式」形(见本文件 askDecision 口的头注:车4 落地时的临时判据,
|
|
212
|
-
// 交接条件写死了「换成消费**那一个**谓词」)。两处不同源的后果在 `STREAM_APPROVAL` 默认翻 ON
|
|
213
|
-
// 之后才显形:上场门是**五**项合取(还要 toolApproval 在场、账本非易失 `kind !== "local"`、
|
|
214
|
-
// park 设施在场),而这里只看了两项 ⇒ 一个协议**没有上场**的 SQL 部署
|
|
215
|
-
// (典型:`TOOL_APPROVAL_ENABLED=false`,或没开 `DURABLE_APPROVAL` ⇒ 无 park 设施)
|
|
216
|
-
// **每一次开流都照打一次库** —— 一次纯白打的 `listPendingByTask`,外加那份 store 故障暴露面,
|
|
217
|
-
// 而它按契约永远只能投出零帧(协议没上场 ⇒ 没有任何一条 `STREAM_PENDING` 行是本协议铸的)。
|
|
218
|
-
// 换成单一谓词后,「能力面说 false」⟺「本腿零 store 往返」是结构成立的。
|
|
219
96
|
const gate = resolveStreamApprovalGate({
|
|
220
97
|
toolApprovalEnabled: Boolean(deps.toolApproval),
|
|
221
98
|
streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
|
|
222
99
|
backend: deps.backend,
|
|
223
100
|
parkFacility: deps.checkpointStore !== undefined,
|
|
224
101
|
});
|
|
225
|
-
// 协议未上场 ⇒ 零帧**且零 store 读**(场景9 的钉打的就是这两件事,不只是零帧)。
|
|
226
|
-
// env-only worker(无 durable backend)在 `no_backend` 那条臂上一并收掉:无 ask 账 ⇒ 无对账基准。
|
|
227
102
|
if (!gate.active)
|
|
228
103
|
return [];
|
|
229
104
|
const store = gate.askStore;
|
|
@@ -242,9 +117,6 @@ async function streamRunEvents(req, res, deps, runStore, taskId, staleMs) {
|
|
|
242
117
|
},
|
|
243
118
|
getEvents: (id, after) => runStore.getEvents(id, after),
|
|
244
119
|
retainedFrom: (id) => runStore.retainedFrom(id),
|
|
245
|
-
// design/158 B2:唯一的读边界归一 —— 存量 `brain_status` 行按公开联合的名字(`status`)下发。
|
|
246
|
-
// `event:` 与 `data.type` 用**同一个**归一后的名字(此前也是同一个原始名),否则按 event 名分派的
|
|
247
|
-
// 消费端与按 data.type 分派的消费端会看到两套名字。除这一个键外全部原样透传(见 normalizeRunEventType)。
|
|
248
120
|
formatEvent: (ev) => {
|
|
249
121
|
const type = normalizeRunEventType(ev.type);
|
|
250
122
|
return { id: ev.seq, event: type, data: { type, ...(ev.data ?? {}) } };
|
|
@@ -257,34 +129,23 @@ export async function handleRuns(req, res, url, ctx) {
|
|
|
257
129
|
await handleRunsBody(req, res, url, ctx, miss);
|
|
258
130
|
return !miss.fell;
|
|
259
131
|
}
|
|
260
|
-
/** 子代理 / 任务句柄 / HITL 应答段——`handle()` 里它在 SVC-5 workflow steer **之后**,故是独立的域入口。 */
|
|
261
132
|
export async function handleRunVerbs(req, res, url, ctx) {
|
|
262
133
|
const miss = { fell: false };
|
|
263
134
|
await handleRunVerbsBody(req, res, url, ctx, miss);
|
|
264
135
|
return !miss.fell;
|
|
265
136
|
}
|
|
266
|
-
/** 路由体 = 从 `server.ts` 的 `handle()` 里**整段剪切**过来的原文(唯一改动:统一去缩进两格)。裸 `return;`
|
|
267
|
-
* = 「本域已应答」;走到函数尾才是「没匹配上」。跨域的东西一律在首行解构出来。 */
|
|
268
137
|
async function handleRunsBody(req, res, url, ctx, miss) {
|
|
269
138
|
const { deps } = ctx;
|
|
270
139
|
const { idemCache, inflightRuns, preemptableRuns, cancelledViaVerb, steerableRuns, wakeParkMints } = ctx.registry;
|
|
271
140
|
const { readJson, rateLimited, quotaExceeded, leaseDenied, usageWindowDenied, runOwnerOk, runSessionAcceptOk } = ctx.helpers;
|
|
272
141
|
const { prepareSpec } = ctx.legs;
|
|
273
142
|
const source = ctx.req.source;
|
|
274
|
-
// Async run: create + return ids immediately, execute in the background.
|
|
275
143
|
if (req.method === "POST" && url === "/v1/runs") {
|
|
276
144
|
if (!deps.runStore) {
|
|
277
145
|
sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg|local)");
|
|
278
146
|
return;
|
|
279
147
|
}
|
|
280
|
-
// Idempotency-Key dedup (center blocker): a retried create returns the SAME taskId instead of starting a
|
|
281
|
-
// second background run (= second bill). Concurrent same-key requests share the one in-flight create. The
|
|
282
|
-
// replay is checked BEFORE the rate/quota gates so a retry doesn't get a fresh 429 (council).
|
|
283
148
|
const rawIdem = headerStr(req.headers["idempotency-key"]);
|
|
284
|
-
// Scope by identity only when there IS a key (BL-3) — avoids reading the principal header on every
|
|
285
|
-
// submit, and keeps the no-key path independent of principal config.
|
|
286
|
-
// F-fix: scope on the VERIFIED principal (gatedPrincipal) — on a direct door a spoofable header would let a
|
|
287
|
-
// caller poison/collide another principal's idempotency cache. (BFF/gated: identical to principalFrom.)
|
|
288
149
|
const idemKey = rawIdem ? scopedIdempotencyKey(rawIdem, source, gatedPrincipal(req, deps.config)) : undefined;
|
|
289
150
|
const cached = idemKey ? idemCache.peek(idemKey) : undefined;
|
|
290
151
|
if (cached) {
|
|
@@ -292,21 +153,14 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
292
153
|
sendJson(res, resp.status, resp.body);
|
|
293
154
|
return;
|
|
294
155
|
}
|
|
295
|
-
// 🔴 复审 C1:NO lease gate here — it must not sit ABOVE the durable-taskId idempotency replay
|
|
296
|
-
// below, or a retry of an already-created run (dispatch-gateway failover) would get 429 instead of its
|
|
297
|
-
// existing 202 receipt, breaking the exactly-once contract. The lease admits a genuinely-NEW run only
|
|
298
|
-
// (applied just before createRun). Rate/quota stay here (cheap pre-reject; a rate-limited replay is a
|
|
299
|
-
// transient the caller retries, unlike a persistent lease exhaustion).
|
|
300
156
|
if (rateLimited(req, res) || quotaExceeded(req, res))
|
|
301
157
|
return;
|
|
302
158
|
const prepared = await prepareSpec(req, res);
|
|
303
159
|
if (!prepared)
|
|
304
160
|
return;
|
|
305
|
-
// DESIGN-269 车2 件2:建行+驱动段整段搬进具名腿 {@link createDurableRun} —— A2A 的 `message/send`
|
|
306
|
-
// 复用**同一条**提交链(第二条提交路径=第二写者,禁)。本处逐字等价于搬走前:腿返回收据,路由发它。
|
|
307
161
|
const resp = await createDurableRun(req, res, ctx, prepared, idemKey);
|
|
308
162
|
if (!resp)
|
|
309
|
-
return;
|
|
163
|
+
return;
|
|
310
164
|
sendJson(res, resp.status, resp.body);
|
|
311
165
|
return;
|
|
312
166
|
}
|
|
@@ -317,18 +171,11 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
317
171
|
return;
|
|
318
172
|
}
|
|
319
173
|
const taskId = runMatch[1];
|
|
320
|
-
// Enforce the principal requirement BEFORE the lookup so the 401/404 ordering matches the approvals
|
|
321
|
-
// endpoint (no 404-vs-401 existence oracle for an unauthenticated caller under requirePrincipal).
|
|
322
|
-
// 🔴 F-fix: gatedPrincipal (VERIFIED), not the spoofable header. On a direct door the legit owner authenticates
|
|
323
|
-
// by the JWT (no x-agent-principal header), so a `principalFrom` presence check would 401 a VALID caller — and a
|
|
324
|
-
// spoofed header must not satisfy this gate either. Mirrors runOwnerOk's verified check below. (BFF/gated: same.)
|
|
325
174
|
if (deps.config.requirePrincipal && !gatedPrincipal(req, deps.config)) {
|
|
326
175
|
sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
|
|
327
176
|
return;
|
|
328
177
|
}
|
|
329
178
|
const run = await deps.runStore.getRun(taskId);
|
|
330
|
-
// NB: `runOwnerOk` is short-circuited away when `!run`, so the unknown-run case MUST send its own
|
|
331
|
-
// response — otherwise the request never completes (hung socket, invisible to access log/metrics).
|
|
332
179
|
if (!run) {
|
|
333
180
|
sendError(res, 404, "not_found.run", runNotFoundMessage(taskId));
|
|
334
181
|
return;
|
|
@@ -336,25 +183,15 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
336
183
|
if (!runOwnerOk(req, res, run.owner))
|
|
337
184
|
return;
|
|
338
185
|
if (!runSessionAcceptOk(req, res, run, runMatch[2] ? "run.events" : "run.poll"))
|
|
339
|
-
return;
|
|
186
|
+
return;
|
|
340
187
|
if (runMatch[2]) {
|
|
341
188
|
await streamRunEvents(req, res, deps, deps.runStore, taskId, deps.config.runStaleSec * 1000);
|
|
342
189
|
}
|
|
343
190
|
else {
|
|
344
191
|
const stale = run.status === "running" && Date.now() - new Date(run.updatedAt).getTime() > deps.config.runStaleSec * 1000;
|
|
345
|
-
// design/80 b (item 3): the supervisor cost surface = core's LLM costBreakdown + the service's infra axes.
|
|
346
|
-
// Only when infra pricing is configured (else the client reads stats.costBreakdown directly) AND the run is
|
|
347
|
-
// terminal with stats — reads the event log ONCE for the tool-call count + uses the run's wall-clock span as
|
|
348
|
-
// the sandbox-walltime proxy. Skipped for running/no-rate runs (no per-poll events read in the common case).
|
|
349
192
|
const infraRates = deps.config.infraCostRates;
|
|
350
193
|
const needCost = Boolean(run.result?.stats && !stale && infraRates && hasInfraPricing(infraRates));
|
|
351
|
-
// E12: surface post-completion prompt suggestions on the poll (the `await suggestions()` retrieval). They land
|
|
352
|
-
// as a `suggestions` event AFTER `done` (post-terminal, fire-and-forget) — so NO live events tail (which closes
|
|
353
|
-
// at the terminal status) can carry them; a poller reads them HERE once the pass settles. Only on a COMPLETED
|
|
354
|
-
// run (the sole status with suggestions), so a running poll never pays the events read.
|
|
355
194
|
const needSuggestions = !stale && run.status === "completed";
|
|
356
|
-
// Read the event log ONCE if either consumer needs it (skipped for running / no-rate to avoid a per-poll read).
|
|
357
|
-
// getEvents is a RunStore staple, but guard its presence so a degraded/partial store just omits these extras.
|
|
358
195
|
const events = (needCost || needSuggestions) && deps.runStore.getEvents ? await deps.runStore.getEvents(taskId, 0).catch(() => []) : undefined;
|
|
359
196
|
let supervisorCost;
|
|
360
197
|
if (needCost && events && run.result?.stats) {
|
|
@@ -365,18 +202,11 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
365
202
|
}
|
|
366
203
|
let suggestions;
|
|
367
204
|
if (needSuggestions && events) {
|
|
368
|
-
const sug = [...events].reverse().find((e) => e.type === "suggestions");
|
|
205
|
+
const sug = [...events].reverse().find((e) => e.type === "suggestions");
|
|
369
206
|
const arr = sug?.data?.suggestions;
|
|
370
|
-
// 🔴 **事件在场 ⇒ 已结算**,空数组必须原样透出(2026-07-26)。此前这里要求 `arr.length`,
|
|
371
|
-
// 于是「跑完了、确实一条都没有」与「还没跑完」都变成 `undefined` ⇒ **轮询方无从判断该不该停**。
|
|
372
|
-
// 与发射侧那半(`runs.ts` 此前 `if (suggestions.length)` 才 append)是同一个态被两处叠加抹平。
|
|
373
|
-
// ⚠️ 这属于本仓刚修过的**同一类**:消费方拿终态当停止条件,而数据在终态之后才到
|
|
374
|
-
// (`setTerminal` 在 suggestions 落之前)。判据统一成:事件在 = 已结算;事件不在 = 仍在跑。
|
|
375
207
|
if (Array.isArray(arr))
|
|
376
208
|
suggestions = arr.map((s) => String(s));
|
|
377
209
|
}
|
|
378
|
-
// #245 S1:poll 面的活性证据——语义/缺席纪律与 409 body 同键同源(active-run-conflict.ts 顶注)。
|
|
379
|
-
// 只在**真 running**(非 stale 折叠)时铸:stale 已被折成 failed,再带活性读数是自相矛盾的材料。
|
|
380
210
|
const lastActivityAt = run.status === "running" && !stale ? readTurnActivityMs(run.taskId) : undefined;
|
|
381
211
|
sendJson(res, 200, {
|
|
382
212
|
taskId: run.taskId,
|
|
@@ -385,11 +215,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
385
215
|
...(lastActivityAt !== undefined ? { msSinceLastActivity: Math.max(0, Date.now() - lastActivityAt) } : {}),
|
|
386
216
|
result: run.result ?? undefined,
|
|
387
217
|
supervisorCost,
|
|
388
|
-
suggestions,
|
|
389
|
-
// Surface the structured failure code at the top level so async-run clients (OA / team skill) can
|
|
390
|
-
// branch (auth → stop, budget.*/limit.* → quota, network·server → retry/failover) without parsing
|
|
391
|
-
// the whole result. It already lives inside the persisted result JSON — no run_store schema change.
|
|
392
|
-
// A `_stale` infra timeout is not a core error, so it carries no errorCode.
|
|
218
|
+
suggestions,
|
|
393
219
|
errorCode: stale ? undefined : (run.result?.errorCode ?? undefined),
|
|
394
220
|
error: stale ? "run stalled (instance lost?)" : run.error ?? undefined,
|
|
395
221
|
jobId: run.jobId ?? undefined,
|
|
@@ -398,19 +224,12 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
398
224
|
}
|
|
399
225
|
return;
|
|
400
226
|
}
|
|
401
|
-
// POST /v1/runs/:id/cancel — stop a durable async run. Durable cross-replica flag (the owning instance's
|
|
402
|
-
// heartbeat tick honors it) + a same-replica AbortController fast path. Terminal = "failed"+errorCode
|
|
403
|
-
// "cancelled". Idempotent on a terminal run. [868] a SUSPENDED run is cancelled here too: settle the
|
|
404
|
-
// pending checkpoint (CAS expire) then terminalize the row — see cancelSuspended below.
|
|
405
227
|
const cancelMatch = req.method === "POST" ? RUN_CANCEL_RE.exec(url) : null;
|
|
406
228
|
if (cancelMatch) {
|
|
407
229
|
if (!deps.runStore) {
|
|
408
230
|
sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg|local)");
|
|
409
231
|
return;
|
|
410
232
|
}
|
|
411
|
-
// Principal check BEFORE the lookup (parity with GET /v1/runs/:id): no 404-vs-401 existence oracle.
|
|
412
|
-
// 🔴 F-fix: gatedPrincipal (VERIFIED), not the spoofable header (parity with runOwnerOk below + the GET gate) —
|
|
413
|
-
// else a direct-door owner authenticating by JWT (no x-agent-principal header) is wrongly 401'd.
|
|
414
233
|
if (deps.config.requirePrincipal && !gatedPrincipal(req, deps.config)) {
|
|
415
234
|
sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
|
|
416
235
|
return;
|
|
@@ -418,27 +237,13 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
418
237
|
const taskId = cancelMatch[1];
|
|
419
238
|
const run = await deps.runStore.getRun(taskId);
|
|
420
239
|
if (!run) {
|
|
421
|
-
|
|
422
|
-
// 都注册快路)——404 只剩「id 属于别的进程/别的 store」一类(如另起的 server 进程配 in-memory store)。
|
|
423
|
-
sendError(res, 404, "not_found.run", runNotFoundMessage(taskId)); // #262:硬编码同串归 helper
|
|
240
|
+
sendError(res, 404, "not_found.run", runNotFoundMessage(taskId));
|
|
424
241
|
return;
|
|
425
242
|
}
|
|
426
243
|
if (!runOwnerOk(req, res, run.owner))
|
|
427
|
-
return;
|
|
244
|
+
return;
|
|
428
245
|
if (!runSessionAcceptOk(req, res, run, "run.cancel"))
|
|
429
|
-
return;
|
|
430
|
-
// [868] P0: cancel of a SUSPENDED run — the user's recovery handle for a locked session. Previously a flat
|
|
431
|
-
// 409 "deny the pending approval instead", which dead-ended when the deny itself failed or was swallowed
|
|
432
|
-
// (the incident: an AskUserQuestion durable park whose shell deny was lost kept the task_active claim with
|
|
433
|
-
// ZERO release paths on the file/memory lane — every later submit on the session 409'd forever). New
|
|
434
|
-
// semantics: settle the pending checkpoint FIRST via the CheckpointStore CAS `expire` (the reaper's
|
|
435
|
-
// "≈ deny" terminal — deliberately NOT resolve/resumeCheckpoint, which would drive a model continuation:
|
|
436
|
-
// cancel means the run DIES; a deny-RESUME feeds the refusal back to the model and burns tokens against
|
|
437
|
-
// the user's intent, and denying an AskUserQuestion is incoherent anyway — the reapExpired kind-split
|
|
438
|
-
// already encodes that), THEN terminalize the run row (setTerminal releases task_active = unlocks the
|
|
439
|
-
// session). Order is load-bearing: checkpoint-first means a late approve loses the CAS and is rejected
|
|
440
|
-
// honestly by /decide; terminal-first would open the window where an approve wins the CAS on a dead row
|
|
441
|
-
// and driveResume ghosts against markResuming (safe, but a lie in two ledgers).
|
|
246
|
+
return;
|
|
442
247
|
const rs = deps.runStore;
|
|
443
248
|
const cancelSuspended = async () => {
|
|
444
249
|
const cs = deps.checkpointStore;
|
|
@@ -447,45 +252,29 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
447
252
|
const token = await cs.findPendingTokenBySession(run.sessionId);
|
|
448
253
|
if (token) {
|
|
449
254
|
const cp = await cs.get(token);
|
|
450
|
-
// CAS pending→expired; `false` = a concurrent decide (or the reaper) settled the gate first.
|
|
451
|
-
// Winning it means NO model leg can run off this gate: an approve that already won markResuming
|
|
452
|
-
// (row=running) has its resumeStream reject on the now-expired checkpoint BEFORE any model call and
|
|
453
|
-
// settles the row failed itself — our unconditional setTerminal below double-writes the same
|
|
454
|
-
// terminal (harmless; claim releases once). 双腿并跑 is impossible on this path (codex H1 review).
|
|
455
255
|
const won = cp ? await cs.expire(token, cp.scope) : false;
|
|
456
256
|
if (!won) {
|
|
457
|
-
// CAS lost — answer by the run's ACTUAL state, never pretend the cancel landed.
|
|
458
257
|
const now = await rs.getRun(taskId);
|
|
459
258
|
const st = now?.status;
|
|
460
|
-
// 判据取词表属主(合并码重扫):park 是两个词,手抄形对 core 的词表增删两向都没有编译期钉。
|
|
461
259
|
if (st !== undefined && (isParkedRunStatus(st) || st === "running")) {
|
|
462
|
-
// 文案中性覆盖两因(复验镜头:输家可能是 decide 也可能是 reaper 的 expire——后者无人 resuming)
|
|
463
260
|
sendError(res, 409, "conflict.approval_settled", "pending approval was settled concurrently (decided or expired) — re-check the run and retry cancel if it is still active", { taskId, status: st });
|
|
464
261
|
}
|
|
465
262
|
else {
|
|
466
|
-
sendJson(res, 202, { taskId, status: st ?? "failed", note: "run already terminal — cancel is a no-op" });
|
|
263
|
+
sendJson(res, 202, { taskId, status: st ?? "failed", note: "run already terminal — cancel is a no-op" });
|
|
467
264
|
}
|
|
468
265
|
return;
|
|
469
266
|
}
|
|
470
267
|
}
|
|
471
268
|
else {
|
|
472
|
-
// NO pending token. Two very different worlds share this face (codex H1):
|
|
473
|
-
// (a) the lockup fingerprint — deny lost / checkpoint expired-or-reaped, row parked forever;
|
|
474
|
-
// (b) a concurrent approve ALREADY resolved the checkpoint and its resume leg is running — an
|
|
475
|
-
// unconditional setTerminal here would kill/orphan that leg AND release the claim (双腿并跑).
|
|
476
|
-
// Disambiguate with the SAME CAS the resume path uses: markResuming flips ONLY a suspended row.
|
|
477
|
-
// Winning it = no resume leg exists nor can start (approve's own markResuming will now lose) — the
|
|
478
|
-
// row is OURS to terminalize. Losing it = answer by the run's actual state, never pretend.
|
|
479
269
|
const claimed = await rs.markResuming(taskId);
|
|
480
270
|
if (!claimed) {
|
|
481
271
|
const now = await rs.getRun(taskId);
|
|
482
272
|
const st = now?.status;
|
|
483
|
-
// 同上:词表属主判据(手抄形绕开 isParkedRunStatus 的 `never` 执法点)。
|
|
484
273
|
if (st !== undefined && (st === "running" || isParkedRunStatus(st))) {
|
|
485
274
|
sendError(res, 409, "conflict.approval_settled", "pending approval was settled concurrently (decided or expired) — re-check the run and retry cancel if it is still active", { taskId, status: st });
|
|
486
275
|
}
|
|
487
276
|
else {
|
|
488
|
-
sendJson(res, 202, { taskId, status: st ?? "failed", note: "run already terminal — cancel is a no-op" });
|
|
277
|
+
sendJson(res, 202, { taskId, status: st ?? "failed", note: "run already terminal — cancel is a no-op" });
|
|
489
278
|
}
|
|
490
279
|
return;
|
|
491
280
|
}
|
|
@@ -493,23 +282,15 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
493
282
|
}
|
|
494
283
|
}
|
|
495
284
|
else {
|
|
496
|
-
// Minimal deployments without a checkpoint store: the run row is the only ledger — release it, say so.
|
|
497
285
|
note = "cancelled while suspended (no checkpoint store on this deployment — run row terminalized only)";
|
|
498
286
|
}
|
|
499
|
-
// Run-row terminal AFTER the checkpoint settle. Two ledgers, no silent tear: if this write fails after
|
|
500
|
-
// the checkpoint already expired, report exactly how far the cancel got — a retry takes the
|
|
501
|
-
// no-pending-token path above and completes the release (idempotent recovery). The row's error text
|
|
502
|
-
// = the SAME per-path truth as the response note (a stale-park release must not claim it settled a gate).
|
|
503
287
|
const err = note;
|
|
504
288
|
try {
|
|
505
289
|
const result = { taskId, sessionId: run.sessionId, status: "failed", errorCode: "cancelled", errorMessage: err, stats: { turns: 0, tokens: 0 } };
|
|
506
|
-
// [888] 低危残账修:撕裂窗自愈——checkpoint 已 settle 后 setTerminal 一次瞬时失败(store 抖动)曾直接
|
|
507
|
-
// 500,把释放推给 reapStale 的整个 stale 窗(no-token 路的重试读到 running 只会挂 requestCancel 旗,
|
|
508
|
-
// 无人消费)。这里就地有界重试(2 次退避)把瞬时抖动吞掉;仍然失败才 500 交 reapStale 兜底。
|
|
509
290
|
let lastErr;
|
|
510
291
|
for (let attempt = 0;; attempt++) {
|
|
511
292
|
try {
|
|
512
|
-
await rs.setTerminal(taskId, "failed", result, err);
|
|
293
|
+
await rs.setTerminal(taskId, "failed", result, err);
|
|
513
294
|
lastErr = undefined;
|
|
514
295
|
break;
|
|
515
296
|
}
|
|
@@ -524,18 +305,10 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
524
305
|
throw lastErr;
|
|
525
306
|
}
|
|
526
307
|
catch (e) {
|
|
527
|
-
// 文案与 note 同源(复验镜头:no-token/无 cs 路没有 settle 任何 gate,固定文案会失真);另注:
|
|
528
|
-
// no-token 路的重试会读到 running 走 requestCancel 旗,实际释放由 reapStale 兜底(有界延迟)。
|
|
529
308
|
sendError(res, 500, "internal.cancel_not_terminalized", `cancel progressed (${note}) but could not terminalize the run row (retried) — retry cancel; a stuck row is reaped after the stale window (${e instanceof Error ? e.message : String(e)})`, { taskId });
|
|
530
309
|
return;
|
|
531
310
|
}
|
|
532
|
-
// [1.207 codex M2] setTerminal 是 void 且 first-writer-wins 后 CAS 写打空不抛——退避窗内 reaper/
|
|
533
|
-
// 并发腿可能先终态化(如 approval.expired),我方写打空却仍答 "cancelled" 就是响应与账本不一致。
|
|
534
|
-
// 写后复读,照实际行应答(锁一样释放了;谁先写赢谁的 errorCode)。
|
|
535
311
|
const finalRow = await rs.getRun(taskId).catch(() => undefined);
|
|
536
|
-
// 终局判据取自词表属主 `store-contracts.ts` 的穷举 `isTerminalRunStatus`(原地手抄的三连 `===`
|
|
537
|
-
// 对 core **增**一个终局态是静默的:新态会被判成「还没终局」⇒ 这里改答 "cancelled",与账本不一致,
|
|
538
|
-
// 正是本段注释要防的那件事)。
|
|
539
312
|
if (finalRow && finalRow.errorCode !== "cancelled" && isTerminalRunStatus(finalRow.status)) {
|
|
540
313
|
sendJson(res, 202, { taskId, status: finalRow.status, errorCode: finalRow.errorCode ?? null, note: `session unlocked; the run was terminalized concurrently (${finalRow.errorCode ?? finalRow.status}) before this cancel's write — reporting the actual ledger state` });
|
|
541
314
|
return;
|
|
@@ -543,43 +316,25 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
543
316
|
sendJson(res, 202, { taskId, status: "failed", errorCode: "cancelled", note });
|
|
544
317
|
};
|
|
545
318
|
if (run.status === "running") {
|
|
546
|
-
|
|
547
|
-
// between the getRun above and here, the flag write affects 0 rows — re-read and answer by the run's
|
|
548
|
-
// ACTUAL state instead of lying "cancelling" about a run nothing will cancel.
|
|
549
|
-
let flagged = await deps.runStore.requestCancel(taskId, run.owner); // owner guard: single-DB defense-in-depth (HTTP gate already passed runOwnerOk)
|
|
550
|
-
// 抢跑输了(旗打空 0 行)⇒ 按 run 的**真实**状态重判。三向,而且 `running` 那一向必须**重挂旗**
|
|
551
|
-
// 而不是回答:
|
|
552
|
-
// · **park**(🔴 判据取词表属主 `store-contracts.ts` 的穷举 `isParkedRunStatus`,扫描P2):原地手抄的
|
|
553
|
-
// `=== "suspended"` 漏掉 `needs_review`(plan_review / dry-run 门的 park 落名),一条抢跑停到计划
|
|
554
|
-
// 评审上的 run 会掉进 no-op 臂被谎报终局,而行还活着、`task_active` claim 一根手指都没动 ——
|
|
555
|
-
// [868] 会话锁死指纹的同族第二例(直连分支早已认两个词,codex M1;这条孪生臂当时漏了)。
|
|
556
|
-
// · **running**(codex 复审 round1 [high] 二,验真):`requestCancel` 只对 running 行生效,而
|
|
557
|
-
// park→running 的**二次跃迁**真实存在——并发 approve 的 `markResuming` 会把行从 park 翻回
|
|
558
|
-
// running。旧码只处理「重读到 park」,重读到 running 掉进 no-op 臂:旗没挂上、腿没 abort,
|
|
559
|
-
// **被批准的活继续跑**,而调用方以为取消是空操作。行活过来正是该重挂旗的时刻,不是该撒谎的时刻。
|
|
560
|
-
// · 真终局 ⇒ 照旧幂等 no-op(带真实状态)。
|
|
561
|
-
// 有界(park↔running 反复跳时不许把请求钉在这):次数用尽仍 running ⇒ 如实 409 让调用方重试。
|
|
319
|
+
let flagged = await deps.runStore.requestCancel(taskId, run.owner);
|
|
562
320
|
const CANCEL_RECLASSIFY_ATTEMPTS = 3;
|
|
563
321
|
for (let attempt = 0; !flagged; attempt++) {
|
|
564
322
|
const now = await deps.runStore.getRun(taskId);
|
|
565
323
|
if (now !== undefined && isParkedRunStatus(now.status)) {
|
|
566
|
-
await cancelSuspended();
|
|
324
|
+
await cancelSuspended();
|
|
567
325
|
return;
|
|
568
326
|
}
|
|
569
327
|
if (now?.status === "running" && attempt < CANCEL_RECLASSIFY_ATTEMPTS) {
|
|
570
|
-
flagged = await deps.runStore.requestCancel(taskId, run.owner);
|
|
328
|
+
flagged = await deps.runStore.requestCancel(taskId, run.owner);
|
|
571
329
|
continue;
|
|
572
330
|
}
|
|
573
331
|
if (now?.status === "running") {
|
|
574
|
-
// 文案与 cancelSuspended 的 CAS 输臂同源(同一件事:并发决议赢了,它的恢复腿正在跑)。
|
|
575
332
|
sendError(res, 409, "conflict.approval_settled", "pending approval was settled concurrently (decided or expired) — re-check the run and retry cancel if it is still active", { taskId, status: now.status });
|
|
576
333
|
return;
|
|
577
334
|
}
|
|
578
335
|
sendJson(res, 202, { taskId, status: now?.status ?? "failed", note: "run already terminal — cancel is a no-op" });
|
|
579
336
|
return;
|
|
580
337
|
}
|
|
581
|
-
// fast path: cancel landed on the running instance → abort now. Label FIRST ([1.207 codex H2]) so the
|
|
582
|
-
// interactive legs' settle projection sees the verb origin (bg legs already write "cancelled" themselves).
|
|
583
338
|
if (inflightRuns.has(taskId)) {
|
|
584
339
|
cancelledViaVerb.add(taskId);
|
|
585
340
|
inflightRuns.get(taskId).abort();
|
|
@@ -587,61 +342,32 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
587
342
|
sendJson(res, 202, { taskId, status: "cancelling" });
|
|
588
343
|
}
|
|
589
344
|
else if (isParkedRunStatus(run.status)) {
|
|
590
|
-
// [868] settle the pending gate + terminalize + unlock (was a flat 409 dead-end). needs_review rides the
|
|
591
|
-
// SAME handle (codex M1): a plan-review park holds the claim identically and the old else branch lied
|
|
592
|
-
// "already terminal" about it (claim never released). 判据同上取属主(扫描P2:两处原地手抄的同一张
|
|
593
|
-
// park 词表已收敛到 `isParkedRunStatus`,再加词由 tsc 逼人表态)。
|
|
594
345
|
await cancelSuspended();
|
|
595
346
|
}
|
|
596
347
|
else {
|
|
597
|
-
sendJson(res, 202, { taskId, status: run.status, note: "run already terminal — cancel is a no-op" });
|
|
348
|
+
sendJson(res, 202, { taskId, status: run.status, note: "run already terminal — cancel is a no-op" });
|
|
598
349
|
}
|
|
599
350
|
return;
|
|
600
351
|
}
|
|
601
|
-
// POST /v1/runs/:id/steer — inject a mid-task steering message (CC-parity "运行中插话", design/47 + design/80
|
|
602
|
-
// D-A). Delivery is picked by the run's CURRENT state, surfaced via the status code (matching the spec):
|
|
603
|
-
// • in-flight on THIS replica (a live core TaskStream in `steerableRuns`) → `stream.steer()` queues the
|
|
604
|
-
// message into the harness, drained at the next turn boundary → 200 (applied).
|
|
605
|
-
// • durably SUSPENDED (any replica) → `setPendingSteer` parks it on the pending checkpoint, injected by core
|
|
606
|
-
// on the resume continuation → 202 (queued).
|
|
607
|
-
// • running on ANOTHER replica (not live here, status "running") → 409 steering.not_running. Cross-replica
|
|
608
|
-
// live-steer (a durable steer-signal + heartbeat poll, mirroring cancel_requested/preempt_requested) is a
|
|
609
|
-
// fast-follow — single-replica-per-worker deployment today, so the live + parked paths cover the headline
|
|
610
|
-
// use case; this branch is the honest seam, never a silent drop.
|
|
611
|
-
// • terminal (done/failed/cancelled/needs_review) → 409 steering.not_running.
|
|
612
|
-
// Content carrying a `</system-reminder>` break-out or control chars → 422 steering.invalid_content, validated
|
|
613
|
-
// ONCE up front with core's `validatePendingSteer` (the same validator setPendingSteer runs) so the 422 is
|
|
614
|
-
// consistent across both delivery paths — deliberately STRICTER than core's live path (which would sanitize an
|
|
615
|
-
// untrusted break-out at injection rather than reject it), trading that edge case for a uniform wire contract.
|
|
616
352
|
const steerMatch = req.method === "POST" ? RUN_STEER_RE.exec(url) : null;
|
|
617
353
|
if (steerMatch) {
|
|
618
354
|
if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
|
|
619
|
-
return;
|
|
355
|
+
return;
|
|
620
356
|
if (!deps.runStore) {
|
|
621
357
|
sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg|local)");
|
|
622
358
|
return;
|
|
623
359
|
}
|
|
624
|
-
// Per-tenant identity MUST come from gatedPrincipal (direct-door secure single point), NOT principalFrom:
|
|
625
|
-
// `trusted` carries OPERATOR authority (a trusted steer may ride a <system-reminder>), so deriving it from a
|
|
626
|
-
// spoofable header on a direct-door worker would be a privilege escalation (HANDOFF 铁律 + core §3 inv #1).
|
|
627
360
|
const principal = gatedPrincipal(req, deps.config);
|
|
628
361
|
if (deps.config.requirePrincipal && principal === undefined) {
|
|
629
362
|
sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
|
|
630
363
|
return;
|
|
631
364
|
}
|
|
632
365
|
const taskId = steerMatch[1];
|
|
633
|
-
// Parse + validate the REQUEST before the run lookup: a bad body is the caller's error regardless of run
|
|
634
|
-
// state, and validating once here (not per delivery path) keeps 400/422 identical whether the run is live or
|
|
635
|
-
// suspended. `text` required non-empty; `mode` (CC `--steering-mode`) is accepted + enum-checked but a v1
|
|
636
|
-
// no-op — core's public TaskStream.steer takes only { trusted } today (harness steeringMode is build-time),
|
|
637
|
-
// so per-call mode is forward-draft pending core wiring (raised to core).
|
|
638
366
|
let body;
|
|
639
367
|
try {
|
|
640
368
|
body = (await readJson(req));
|
|
641
369
|
}
|
|
642
370
|
catch (e) {
|
|
643
|
-
// TASKS-RUNS-5([2400] 审计):readJson 的 HttpError(413 超限)原样透传——裸 catch 折成
|
|
644
|
-
// 400 invalid_json 会让 9 MiB 合法 JSON 被报「JSON 非法」(compact 腿是既有对照组正确形)。
|
|
645
371
|
if (e instanceof HttpError) {
|
|
646
372
|
sendError(res, e.status, httpErrorCode(e.status, e.code), e.message);
|
|
647
373
|
return;
|
|
@@ -657,34 +383,19 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
657
383
|
sendError(res, 400, "request.field_invalid", "mode must be 'all' or 'one-at-a-time' when present");
|
|
658
384
|
return;
|
|
659
385
|
}
|
|
660
|
-
// E15 (shell-host contract): per-message queue priority. Accepted + enum-validated FAIL-LOUD, but ADVISORY
|
|
661
|
-
// today — core's steer is single-slot last-writer-wins with no per-message priority / addressable drop, so the
|
|
662
|
-
// faithful priority queue + DELETE /v1/runs/:id/queue/:messageId wait on a core seam (raised to core).
|
|
663
386
|
if (body.priority !== undefined && body.priority !== "now" && body.priority !== "next" && body.priority !== "later") {
|
|
664
387
|
sendError(res, 400, "request.field_invalid", "priority must be 'now', 'next', or 'later' when present");
|
|
665
388
|
return;
|
|
666
389
|
}
|
|
667
390
|
const text = body.text;
|
|
668
391
|
const priority = body.priority;
|
|
669
|
-
// 🔴 core 5.14.0(#147)之后**必须**把调用方的 Idempotency-Key 接到 core 的 `inputId` 上
|
|
670
|
-
// (codex 复审 2026-08-06 F4,验真后采纳)。队列化前,重发一条 steer 是 last-writer-wins ⇒ 无害;
|
|
671
|
-
// 队列化后,「202 丢在路上、客户端照常重试」会**再追加一条**同样的操作员指令,而队列只有
|
|
672
|
-
// MAX_PENDING_STEER_ENTRIES(=3)个位子 —— 三次重试就把它填满,之后合法转向一律 409 queue_full。
|
|
673
|
-
// 这条危害是本批的队列改动**引入**的,所以修它属于本批,不是顺手加功能。
|
|
674
|
-
// 形:与 `POST /v1/runs` 同一条 scoped 口径(source+verified principal+原 key),再 sha256 收成定长
|
|
675
|
-
// 不透明串 —— core 对 inputId 只限「非空 ≤128 且非保留词」,而 scoped 原串含 \x1f 分隔符且长度无界。
|
|
676
|
-
// 缺 header ⇒ 完全按旧行为(core 自铸 uuidv7),字节零变化。
|
|
677
392
|
const steerIdemRaw = headerStr(req.headers["idempotency-key"]);
|
|
678
393
|
const steerScoped = steerIdemRaw ? scopedIdempotencyKey(steerIdemRaw, source, principal) : undefined;
|
|
679
394
|
const steerInputId = steerScoped ? `idem-${createHash("sha256").update(steerScoped).digest("hex").slice(0, 32)}` : undefined;
|
|
680
|
-
// messageId:带 key 时**回同一个** id(重试拿到逐字相同的回执,与「这是同一条指令」一致);
|
|
681
|
-
// 不带 key 时保持历史的 uuidv7 现铸(壳的引用句柄,drop 动词候 core seam)。
|
|
682
395
|
const messageId = steerInputId ?? uuidv7();
|
|
683
|
-
// trusted = explicit operator (NOT bare isOperator — its empty-list "true-for-all" would let any caller send a
|
|
684
|
-
// system-authority steer). A normal owner's steer reaches the model as a plain user message (trusted:false).
|
|
685
396
|
const trusted = explicitOperatorOk(principal, deps.config.operatorPrincipals);
|
|
686
397
|
try {
|
|
687
|
-
validatePendingSteer({ text, trusted, ...(steerInputId !== undefined ? { inputId: steerInputId } : {}) });
|
|
398
|
+
validatePendingSteer({ text, trusted, ...(steerInputId !== undefined ? { inputId: steerInputId } : {}) });
|
|
688
399
|
}
|
|
689
400
|
catch (e) {
|
|
690
401
|
if (e instanceof CheckpointError && e.code === "steering.invalid_content") {
|
|
@@ -698,27 +409,14 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
698
409
|
sendError(res, 404, "not_found.run", runNotFoundMessage(taskId));
|
|
699
410
|
return;
|
|
700
411
|
}
|
|
701
|
-
// Owner-gate (parity with preempt): an EXPLICIT operator may steer any tenant's run (supervisor intervention);
|
|
702
|
-
// a normal caller only its own. 404 (not 403) for a non-owner — no existence oracle.
|
|
703
412
|
if (!trusted && run.owner !== null && run.owner !== principal) {
|
|
704
413
|
sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
|
|
705
414
|
return;
|
|
706
|
-
}
|
|
415
|
+
}
|
|
707
416
|
if (!runSessionAcceptOk(req, res, run, "run.steer"))
|
|
708
|
-
return;
|
|
709
|
-
// Durable-park helper: land the steer on this session's PENDING checkpoint. The checkpoint table — not the run
|
|
710
|
-
// row — is the AUTHORITY: core writes the pending checkpoint during a suspend (before the service flips the run
|
|
711
|
-
// row to "suspended"), and CASes on `status='pending'`, so this lands iff the run is genuinely parked. Read the
|
|
712
|
-
// checkpoint's ACTUAL scope ("_" for a no-principal run) then its token; a missing scope/token/CAS-loss means
|
|
713
|
-
// the checkpoint resolved/expired/never-existed → "no-checkpoint". "no-store" = no checkpoint store wired.
|
|
714
|
-
// core 5.14.0(#147)队列化后新出的第四态:park 的队列**满了**。core 的界是 fail-loud 的 ——
|
|
715
|
-
// 越界抛 `steering.queue_full` 而不是驱逐一条已被接受的指令(驱逐 = 把队列本来要消灭的静默丢失
|
|
716
|
-
// 原样请回来)。所以这条必须有自己的 wire 码,不能折进 `no-checkpoint`(那会谎称「已不再挂起」)。
|
|
717
|
-
// 另一个新码 `steering.duplicate_input_id` 自本批起**可达**:调用方带 `Idempotency-Key` 时它被接成
|
|
718
|
-
// core 的 `inputId`(见下),同 key 配不同正文即撞键 ⇒ 409。不带 key 的调用方永远碰不到它。
|
|
417
|
+
return;
|
|
719
418
|
let parkQueueFull;
|
|
720
419
|
let parkDuplicateKey;
|
|
721
|
-
/** 每个 durable park 调用点的同一份入参(带上幂等键才让 core 的 inputId 去重真正可达)。 */
|
|
722
420
|
const steerInput = () => ({ text, trusted, ...(steerInputId !== undefined ? { inputId: steerInputId } : {}) });
|
|
723
421
|
const tryPark = async () => {
|
|
724
422
|
const cs = deps.checkpointStore;
|
|
@@ -738,8 +436,6 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
738
436
|
parkQueueFull = e.message;
|
|
739
437
|
return "queue-full";
|
|
740
438
|
}
|
|
741
|
-
// 同一个 Idempotency-Key 配**不同**正文 —— core 拒绝(吞掉它就等于把队列要消灭的静默丢失请回来)。
|
|
742
|
-
// 与 `conflict.ask_idempotency` 同族语义:换一个 key,别复用。
|
|
743
439
|
if (e instanceof CheckpointError && e.code === "steering.duplicate_input_id") {
|
|
744
440
|
parkDuplicateKey = e.message;
|
|
745
441
|
return "duplicate-key";
|
|
@@ -749,20 +445,8 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
749
445
|
};
|
|
750
446
|
const sendQueueFull = () => sendError(res, 409, "steering.queue_full", parkQueueFull ?? "the parked steering queue on this checkpoint is full");
|
|
751
447
|
const sendDuplicateKey = () => sendError(res, 409, "steering.duplicate_input_id", parkDuplicateKey ?? "this Idempotency-Key is already parked with different steering content — reissue with a fresh key");
|
|
752
|
-
/**
|
|
753
|
-
* 202 park 回执。`status` 是**入参**而不是字面量(合并码重扫):park 是两个词,`403a841` 把 (b) 支的
|
|
754
|
-
* 判据拓成 `isParkedRunStatus` 之后 `needs_review` 行首次走进这条支,而回执还硬报 `"suspended"` ⇒ 同一
|
|
755
|
-
* 时刻 `GET /v1/runs/:id` 报 `needs_review`,按回执刷新本地行的消费端把「待评审」渲成「已挂起」。
|
|
756
|
-
* 姊妹位早就读真词(preempt 的 `extras.status`、本文件终局 cancel 与 wake-park 两处回执)。
|
|
757
|
-
*/
|
|
758
448
|
const sendParked = (status) => sendJson(res, 202, { taskId, status, delivery: "queued", messageId, ...(priority ? { priority } : {}), note: "steer parked on the checkpoint — injected when the run resumes" });
|
|
759
449
|
const sendNotRunning = (error) => sendError(res, 409, "steering.not_running", error);
|
|
760
|
-
// (a) Same-replica live path: the run is streaming here → inject now (drained at the next turn boundary).
|
|
761
|
-
// core 5.35.0(#257/#259 收口,[4022] G1):live 腿把同一个 `steerInputId` 接到 `TaskStream.steer` 的
|
|
762
|
-
// `inputId` 上 —— 此前只有 parked 腿去重,「202 丢在路上、客户端照常重试」打在 live 腿上就是二次注入
|
|
763
|
-
// (parked/live 两腿一条 ingress 键,自本版起 replay-safe 等强;live 判重窗口=流内存活期,契约成文)。
|
|
764
|
-
// 同 key 同载荷重放 = 幂等 no-op(照常 200,core 零二次注入零二次 human_input 帧);同 key 异载荷 =
|
|
765
|
-
// `steering.duplicate_input_id` → 409(与 parked 腿 sendDuplicateKey 同码同文案族)。
|
|
766
450
|
const live = steerableRuns.get(taskId);
|
|
767
451
|
if (live) {
|
|
768
452
|
try {
|
|
@@ -782,21 +466,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
782
466
|
return;
|
|
783
467
|
}
|
|
784
468
|
if (code !== "steering.not_running")
|
|
785
|
-
throw e;
|
|
786
|
-
// Raced: the stream finished between the map lookup and steer(). It may have durably SUSPENDED in the SAME
|
|
787
|
-
// tick (core already wrote the pending checkpoint) — fall through to the durable park rather than a spurious
|
|
788
|
-
// 409 (the live→suspended transition window). Only a genuinely-terminal run → not_running.
|
|
789
|
-
// 🔴 回执的 status 要报「投递时刻 server 看到的行状态」,而手里那份快照是**进 handler 时**读的
|
|
790
|
-
// (这条腿上多半还是 `running`,park 发生在它之后)。所以在这里重读一次 —— 且**必须在 park 之前**
|
|
791
|
-
// (codex 复审二/三轮合并处置):
|
|
792
|
-
// · 提交**后**再读 = 给一个已成功的写加提交后依赖。库降级时那次读可以永远挂着(`.catch` 只接
|
|
793
|
-
// 拒绝、接不住不返回),客户端拿不到 202 就重试,而不带 `Idempotency-Key` 的重试会**再追加一条**
|
|
794
|
-
// 同样的指令(队列只有 3 个位子)——把「回执更准」换成「指令重复入队」,方向反了。
|
|
795
|
-
// · 提交**前**读则没有这个代价:此刻还没有任何东西入队,读挂了 = 这次请求超时,与 park 之前任何
|
|
796
|
-
// 一步超时同形,重试语义不变。
|
|
797
|
-
// 读不到(理论不可达:行必在)⇒ 退回快照,绝不硬编码一个 park 词。残余如实说:行的翻面与 core
|
|
798
|
-
// 写 checkpoint 不在同一步,所以这一腿仍可能读到 `running` ——判别键是 `delivery`(SDK/openapi 逐字
|
|
799
|
-
// 「branch on `delivery`, not the status」),不是 status。
|
|
469
|
+
throw e;
|
|
800
470
|
const freshBeforePark = await deps.runStore.getRun(taskId).catch(() => undefined);
|
|
801
471
|
const raced = await tryPark();
|
|
802
472
|
if (raced === "parked") {
|
|
@@ -815,10 +485,6 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
815
485
|
return;
|
|
816
486
|
}
|
|
817
487
|
}
|
|
818
|
-
// (b) Durably PARKED → park the steer on the pending checkpoint (drained on resume). 判据取词表属主
|
|
819
|
-
// isParkedRunStatus(扫描P2 顺带件):只认 suspended 会把 needs_review park 漏到 (d) 终局支——消息
|
|
820
|
-
// 虽也 park 上同一张卡,但回执是 parked_for_wake+「run already ended,用 /wake 送」,指路错人
|
|
821
|
-
// (真相是本支的「injected when the run resumes」)。
|
|
822
488
|
if (isParkedRunStatus(run.status)) {
|
|
823
489
|
const outcome = await tryPark();
|
|
824
490
|
if (outcome === "no-store") {
|
|
@@ -837,35 +503,21 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
837
503
|
sendDuplicateKey();
|
|
838
504
|
return;
|
|
839
505
|
}
|
|
840
|
-
// 文案按 park **两个词**中性化(旧句只说 suspended,对一条 needs_review park 说的是别人的事)。
|
|
841
506
|
sendNotRunning("run is no longer parked on a pending decision (resolved or expired)");
|
|
842
507
|
return;
|
|
843
508
|
}
|
|
844
|
-
// (c) running-elsewhere (cross-replica, fast-follow seam) → not_running.
|
|
845
509
|
if (run.status === "running") {
|
|
846
510
|
sendNotRunning("run is active on another replica — cross-replica live-steer is not yet supported");
|
|
847
511
|
return;
|
|
848
512
|
}
|
|
849
|
-
// (d) design/144 wake 半场(core 1.283):TERMINAL run + steer = 不再一律 409——铸一个部署侧
|
|
850
|
-
// task_done 纯 park checkpoint(PendingAction {kind:"task_done"},deployment-minted per [770]①c),
|
|
851
|
-
// 把消息 park 上去,等 POST /v1/sessions/:id/wake({gate:"wake"})把它作为续跑首轮输入消费。
|
|
852
|
-
// 先 tryPark:session 若已有 pending checkpoint(needs_review park / 先前铸的 wake park)直接park上去,
|
|
853
|
-
// 绝不双铸(一 session 一 pending 不变式)。铸造前置=checkpoint store + session leaf(leafId 锚续跑);
|
|
854
|
-
// 缺任一 = 原 409 契约(env-only/无树面诚实拒绝)。
|
|
855
513
|
if (deps.checkpointStore && deps.sessionStorage?.getLeafId) {
|
|
856
|
-
// 复审 MED(陈旧 run 守卫):目标 run 终态但同 session 另有 ACTIVE run(claim 在)时,消息该去
|
|
857
|
-
// 活 run(live steer / suspended park),给这条旧终态 run 铸 park 只会造出基于活 leaf 的陈旧
|
|
858
|
-
// checkpoint + wake 撞 markResuming 409。诚实拒绝并指路。
|
|
859
514
|
const activeTaskId = await deps.runStore.getActiveTaskId?.(run.sessionId).catch(() => undefined);
|
|
860
515
|
if (activeTaskId !== undefined && activeTaskId !== null && activeTaskId !== taskId) {
|
|
861
516
|
sendNotRunning(`run is ${run.status} and its session has an ACTIVE run (${activeTaskId}) — steer that run instead`);
|
|
862
517
|
return;
|
|
863
518
|
}
|
|
864
|
-
// per-session 串行(复审 HIGH:并发双终态 steer 的 tryPark→put 窗口双铸两个 pending park)。
|
|
865
519
|
const prior = wakeParkMints.get(run.sessionId) ?? Promise.resolve();
|
|
866
520
|
const mintResult = { parked: false, queueFull: false, duplicateKey: false, noResumeCtx: false };
|
|
867
|
-
// wake park 腿的 queue_full:新铸的 task_done checkpoint 队列必空,所以只可能来自「锁内重查撞到
|
|
868
|
-
// 既有 pending」或「跨副本赢家」两条既有 park 上——两处都要如实报满,不许折成「不接受转向」。
|
|
869
521
|
const parkOnce = async (t, sc) => {
|
|
870
522
|
try {
|
|
871
523
|
if (await deps.checkpointStore.setPendingSteer(t, sc, steerInput()))
|
|
@@ -891,7 +543,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
891
543
|
if (reprobe === "parked") {
|
|
892
544
|
mintResult.parked = true;
|
|
893
545
|
return;
|
|
894
|
-
}
|
|
546
|
+
}
|
|
895
547
|
if (reprobe === "queue-full") {
|
|
896
548
|
mintResult.queueFull = true;
|
|
897
549
|
return;
|
|
@@ -903,26 +555,12 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
903
555
|
const leafId = await Promise.resolve(deps.sessionStorage.getLeafId(run.sessionId)).catch(() => undefined);
|
|
904
556
|
if (leafId === undefined || leafId === null)
|
|
905
557
|
return;
|
|
906
|
-
// 🔴 A-057.19 [CONFIRMED high](2026-08-19 三轴组复审,真机复现)—— **铸卡前先证明它赎得回**。
|
|
907
|
-
// 缺口:本支铸的 `task_done` park 只有一条出路 = `POST /v1/sessions/:id/wake`,而那条腿硬依赖
|
|
908
|
-
// `checkpoint_ctx`(server.ts 的 resumeWake:`getCtx` 为 null ⇒ 409
|
|
909
|
-
// `conflict.resume_context_unavailable`,无任何从 run 行重建的兜底);而 `reapCtx` 按**设计**在
|
|
910
|
-
// run 终态后把该行删掉(`run-store-sql.ts` 的 setTerminal 删 task_active ⇒ ctx 失去保护,
|
|
911
|
-
// reaper 每 tick 按 `REAP_RUN_STALE_SEC`(默认 120s)清理)。于是 steer-on-terminal 的真实用法
|
|
912
|
-
// (跑完隔几分钟补一句)几乎恒中:202「用 /wake 送」→ /wake 恒 409 → 消息永久搁浅在一张
|
|
913
|
-
// `GET /v1/approvals` 还照列、`/decide` 打它必 409(gate-kind 守卫)的卡上,直到 30 天
|
|
914
|
-
// terminal backstop 把它 expire 掉、消息静默蒸发。
|
|
915
|
-
// 处置照 #308 那条判例(「宁响亮拒不永久搁浅」):证不出 resume ctx 在 ⇒ **不铸**,当场诚实 409,
|
|
916
|
-
// 把真因(ctx 已随终态回收)与出路(重新提交任务)写进回体。读失败/超时同样算「证不出」——
|
|
917
|
-
// 方向 fail-closed:铸一张没人能消费的卡比拒绝一次 steer 伤得多(拒绝可重试,搁浅不可)。
|
|
918
|
-
// ⚠️ 射程只在**铸新卡**这一步:上面 reprobe 把消息 park 到**既有** pending 卡上的那条路不动
|
|
919
|
-
// (那张卡不是本腿造的,出路也未必是 /wake)。
|
|
920
558
|
const resumeCtx = await Promise.resolve(cs.getCtx(run.sessionId)).catch(() => null);
|
|
921
559
|
if (resumeCtx === null || resumeCtx === undefined) {
|
|
922
560
|
mintResult.noResumeCtx = true;
|
|
923
561
|
return;
|
|
924
562
|
}
|
|
925
|
-
const cpScope = encodeCheckpointScope(run.owner);
|
|
563
|
+
const cpScope = encodeCheckpointScope(run.owner);
|
|
926
564
|
const wakeToken = mintCheckpointToken();
|
|
927
565
|
await cs.put(wakeToken, {
|
|
928
566
|
token: wakeToken,
|
|
@@ -936,23 +574,12 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
936
574
|
createdAt: Date.now(),
|
|
937
575
|
sourceTaskId: taskId,
|
|
938
576
|
});
|
|
939
|
-
// 🔴 A-057.19 第二半(codex 对抗复审 high,验真后采纳):上面那次 `getCtx` 与这次 `put` 之间
|
|
940
|
-
// 仍有一个 TOCTOU 窗 —— 窗内 `reapCtx` 完全可能把 ctx 删掉(它的三守卫此刻全部成立:run 已终态
|
|
941
|
-
// ⇒ 无 task_active、卡还没铸 ⇒ 无 pending checkpoint、提交早于 cutoff),于是我们照样铸出那张
|
|
942
|
-
// 赎不回的卡。**put 本身就是关窗动作**:`reapCtx` 的谓词逐字含
|
|
943
|
-
// `session_id NOT IN (SELECT session_id FROM checkpoint WHERE status='pending')`
|
|
944
|
-
// (checkpoint-store-sql.ts 的 reapCtx / local-checkpoint-store.ts 双生同谓词),卡一落盘该 session
|
|
945
|
-
// 的 ctx 就被排除在回收之外。⇒ **put 之后再证一次**:还在 ⇒ 窗内没被删,从此也删不掉了;
|
|
946
|
-
// 不在 ⇒ 窗内正好被删,把刚铸的孤儿 expire 掉再诚实 409(绝不留一张赎不回的卡)。
|
|
947
|
-
// 读失败同样按「证不出」处置(与铸前那次同向 fail-closed)。
|
|
948
577
|
const ctxStillThere = await Promise.resolve(cs.getCtx(run.sessionId)).catch(() => null);
|
|
949
578
|
if (ctxStillThere === null || ctxStillThere === undefined) {
|
|
950
579
|
await cs.expire(wakeToken, cpScope).catch(() => undefined);
|
|
951
580
|
mintResult.noResumeCtx = true;
|
|
952
581
|
return;
|
|
953
582
|
}
|
|
954
|
-
// 跨副本纵深:put 后复核赢家——若另一副本抢先铸了别的 pending,expire 我方孤儿并把消息
|
|
955
|
-
// park 到赢家上(收敛到单 park;店无序时收敛到店的稳定扫描序,残窗如实记 fast-follow)。
|
|
956
583
|
const winner = await cs.findPendingTokenBySession(run.sessionId).catch(() => null);
|
|
957
584
|
if (winner !== null && winner !== wakeToken) {
|
|
958
585
|
await cs.expire(wakeToken, cpScope).catch(() => undefined);
|
|
@@ -967,7 +594,6 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
967
594
|
await job;
|
|
968
595
|
}
|
|
969
596
|
finally {
|
|
970
|
-
// 链尾自清:没有更晚的排队者(Map 仍指向本包装)才删,防长期泄漏;有排队者则由其链尾清。
|
|
971
597
|
if (wakeParkMints.get(run.sessionId) === wrapped)
|
|
972
598
|
wakeParkMints.delete(run.sessionId);
|
|
973
599
|
}
|
|
@@ -979,8 +605,6 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
979
605
|
sendDuplicateKey();
|
|
980
606
|
return;
|
|
981
607
|
}
|
|
982
|
-
// A-057.19:与 /wake 腿**同一个** errorCode —— 消费端对这条 session 的判断是同一件事(续跑材料没了),
|
|
983
|
-
// 两条腿给两个码只会让壳以为是两回事。
|
|
984
608
|
if (mintResult.noResumeCtx) {
|
|
985
609
|
sendError(res, 409, "conflict.resume_context_unavailable", "run already ended and this session's resume context is gone — a steer parked now could never be delivered by POST /v1/sessions/:id/wake; submit a new task instead");
|
|
986
610
|
return;
|
|
@@ -993,20 +617,15 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
993
617
|
sendNotRunning(`run is ${run.status} — not accepting steers`);
|
|
994
618
|
return;
|
|
995
619
|
}
|
|
996
|
-
// K-1c (shell-host §K): POST /v1/runs/:id/compact — MANUAL compaction (un-greys the shell's `/compact`). core
|
|
997
|
-
// 1.156 added `TaskStream.compact()`: it forces a compaction at the next SAFE turn boundary (never mid-turn) and
|
|
998
|
-
// emits `compacted{trigger:"manual"}` on the run's OWN stream (the result rides that stream, like steer). So this
|
|
999
|
-
// verb only applies to a run LIVE on THIS replica (`steerableRuns`); a suspended/terminal/other-replica run has no
|
|
1000
|
-
// live stream + there is NO durable "pending compact" in core (unlike steer's setPendingSteer) → 409.
|
|
1001
620
|
const compactMatch = req.method === "POST" ? RUN_COMPACT_RE.exec(url) : null;
|
|
1002
621
|
if (compactMatch) {
|
|
1003
622
|
if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
|
|
1004
|
-
return;
|
|
623
|
+
return;
|
|
1005
624
|
if (!deps.runStore) {
|
|
1006
625
|
sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg|local)");
|
|
1007
626
|
return;
|
|
1008
627
|
}
|
|
1009
|
-
const principal = gatedPrincipal(req, deps.config);
|
|
628
|
+
const principal = gatedPrincipal(req, deps.config);
|
|
1010
629
|
if (deps.config.requirePrincipal && principal === undefined) {
|
|
1011
630
|
sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
|
|
1012
631
|
return;
|
|
@@ -1016,23 +635,16 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
1016
635
|
if (!run) {
|
|
1017
636
|
sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
|
|
1018
637
|
return;
|
|
1019
|
-
}
|
|
1020
|
-
// Owner-gate (parity with steer/preempt): an explicit operator may compact any tenant's run; a normal caller
|
|
1021
|
-
// only its own. 404 (not 403) for a non-owner — no existence oracle.
|
|
638
|
+
}
|
|
1022
639
|
const operator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
|
|
1023
640
|
if (!operator && run.owner !== null && run.owner !== principal) {
|
|
1024
641
|
sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
|
|
1025
642
|
return;
|
|
1026
|
-
}
|
|
643
|
+
}
|
|
1027
644
|
if (!runSessionAcceptOk(req, res, run, "run.compact"))
|
|
1028
|
-
return;
|
|
1029
|
-
// core 1.293:compact(opts) 收 { instructions?, signal? }。instructions=壳 /compact 的定向摘要指令,
|
|
1030
|
-
// 此处 additive 透传(空 body/无字段=现状);signal(取消腿)不在本 verb——取消需要独立动词+
|
|
1031
|
-
// per-pending AbortController 台账,等壳侧 UX 契约定形再开(先到先记:[853] 知会件)。
|
|
1032
|
-
// 校验在 live 查找之前(steer 同纪律):400 形状不依赖运行态,live/非 live 一致。
|
|
645
|
+
return;
|
|
1033
646
|
let compactBody;
|
|
1034
647
|
try {
|
|
1035
|
-
// HttpError(413 request body too large)原样透传,不降级成语义错误的 400(交叉评审 L5)
|
|
1036
648
|
const parsed = await readJson(req);
|
|
1037
649
|
if (parsed === null || typeof parsed !== "object") {
|
|
1038
650
|
sendError(res, 400, "request.invalid_json", "body must be a JSON object when present");
|
|
@@ -1048,9 +660,6 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
1048
660
|
sendError(res, 400, "request.invalid_json", "invalid JSON body");
|
|
1049
661
|
return;
|
|
1050
662
|
}
|
|
1051
|
-
// 上限=core COMPACTION_INSTRUCTIONS_MAX_CHARS(2048 码点,auto-compaction.js:9;超长 core 会静默
|
|
1052
|
-
// 截断)——HTTP 面按同口径拒收,fail-loud 而非 202 后半截指令悄悄丢(交叉评审 M1)。码点计数与
|
|
1053
|
-
// core 同形([...s] 展开),防代理对 emoji 类双单元字符的错杀/漏杀。
|
|
1054
663
|
if (compactBody.instructions !== undefined && (typeof compactBody.instructions !== "string" || compactBody.instructions.length === 0 || [...compactBody.instructions].length > 2_048)) {
|
|
1055
664
|
sendError(res, 400, "request.field_invalid", "instructions must be a non-empty string of at most 2048 characters (code points) when present — the engine caps compaction instructions there");
|
|
1056
665
|
return;
|
|
@@ -1061,28 +670,10 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
1061
670
|
sendError(res, 409, "compact.not_running", run.status === "running" ? "run is active on another replica — manual compact is replica-local" : `run is ${run.status} — not accepting compaction`);
|
|
1062
671
|
return;
|
|
1063
672
|
}
|
|
1064
|
-
|
|
1065
|
-
// can be a whole in-flight turn away — so awaiting it would hold the HTTP connection (gateway-timeout risk + poor
|
|
1066
|
-
// UX). Ack "accepted" immediately; the compaction runs at the boundary and a `compacted{trigger:"manual"}` event
|
|
1067
|
-
// rides the run's OWN stream (which the client already consumes — this matches the shell's `compacting%` spinner
|
|
1068
|
-
// waiting on that event). A NO-OP compaction (nothing to summarize) emits NO event (so the shell must time out its
|
|
1069
|
-
// spinner, not block forever). Swallow the benign race where the stream finished between the lookup and the call
|
|
1070
|
-
// (steering.not_running) + any compact error — best-effort; failure surfaces as the absence of the event, the user
|
|
1071
|
-
// can retry. (core guarantees compact() never hangs — it resolves by task end at the latest.)
|
|
1072
|
-
void live.compact(compactInstructions !== undefined ? { instructions: compactInstructions } : undefined).then(
|
|
1073
|
-
// core 1.257 (MF-18 BREAKING): compact() now RESOLVES WITH a CompactOutcome instead of void
|
|
1074
|
-
// — "processed" alone was silent when the attempt failed or was mooted (the 202-then-silence fingerprint).
|
|
1075
|
-
// Consume the outcome: "compacted" is the only one that emits a `compacted{trigger:"manual"}` event on the
|
|
1076
|
-
// stream; the other five (failed/mooted/noop/blocked/disabled) each have a `compaction.*` trace counterpart
|
|
1077
|
-
// and produce NO event, so the shell's spinner must fall through. Log the discriminated outcome so a
|
|
1078
|
-
// "202 → no event" incident reads its reason straight from the log (not a service-wire-vs-core guess).
|
|
1079
|
-
(outcome) => {
|
|
673
|
+
void live.compact(compactInstructions !== undefined ? { instructions: compactInstructions } : undefined).then((outcome) => {
|
|
1080
674
|
const level = outcome === "failed" ? "warn" : "info";
|
|
1081
675
|
deps.logger?.[level]?.("manual_compact_outcome", { taskId, outcome, note: outcome === "compacted" ? "a compacted{trigger:'manual'} event rode the run stream" : `no event (see compaction.${outcome === "mooted" ? "mooted" : outcome} trace / task terminal events)` });
|
|
1082
676
|
}, (e) => {
|
|
1083
|
-
// The not_running rejection was fully silent — correct for the benign settle race it was
|
|
1084
|
-
// designed for, but a SYSTEMATIC not_running (e.g. a verb/handle mismatch on one lane) then has zero
|
|
1085
|
-
// observability. Keep it non-warn (it IS usually benign) but leave a fingerprint.
|
|
1086
677
|
if (e.code !== "steering.not_running") {
|
|
1087
678
|
deps.logger?.warn?.("manual_compact_failed", { taskId, err: e instanceof Error ? e.message : String(e) });
|
|
1088
679
|
}
|
|
@@ -1093,25 +684,15 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
1093
684
|
sendJson(res, 202, { taskId, status: "running", delivery: "accepted", note: "compaction will run at the next turn boundary; a compacted{trigger:'manual'} event rides the run stream if anything is summarized" });
|
|
1094
685
|
return;
|
|
1095
686
|
}
|
|
1096
|
-
// core 1.207 (design/116 detach, CC mid-flight ctrl+b): POST /v1/runs/:id/detach {toolCallId} —
|
|
1097
|
-
// move a RUNNING tool call to the background via `TaskStream.detach(toolCallId)`. Route (a) of the sync-leg
|
|
1098
|
-
// detach verb design ((b)'s durable-leg fallback
|
|
1099
|
-
// is blocked on TOC-local having no runStore). Core semantics: fire-and-forget + race-safe (a request landing
|
|
1100
|
-
// before the tool reads its signal still detaches; after it finished = no-op; unknown toolCallId = no-op) —
|
|
1101
|
-
// the detached Bash settles early with "moved to background; task_id=b*" + a `{type:"bash", detached:true}`
|
|
1102
|
-
// structured card, and the G2b completion notification/spool take over. Only a detach-capable env honors it
|
|
1103
|
-
// (`backgroundCapabilities.supportsDetach`; TOB envs ignore = fail-safe). Like compact, this verb is LIVE-only
|
|
1104
|
-
// (replica-local `steerableRuns` handle — the sync leg now registers too): a suspended run has no running tool
|
|
1105
|
-
// and there is no durable "pending detach" → 409.
|
|
1106
687
|
const detachMatch = req.method === "POST" ? RUN_DETACH_RE.exec(url) : null;
|
|
1107
688
|
if (detachMatch) {
|
|
1108
689
|
if (rateLimited(req, res))
|
|
1109
|
-
return;
|
|
690
|
+
return;
|
|
1110
691
|
if (!deps.runStore) {
|
|
1111
692
|
sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg|local)");
|
|
1112
693
|
return;
|
|
1113
694
|
}
|
|
1114
|
-
const principal = gatedPrincipal(req, deps.config);
|
|
695
|
+
const principal = gatedPrincipal(req, deps.config);
|
|
1115
696
|
if (deps.config.requirePrincipal && principal === undefined) {
|
|
1116
697
|
sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
|
|
1117
698
|
return;
|
|
@@ -1125,15 +706,11 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
1125
706
|
if (e instanceof HttpError) {
|
|
1126
707
|
sendError(res, e.status, httpErrorCode(e.status, e.code), e.message);
|
|
1127
708
|
return;
|
|
1128
|
-
}
|
|
709
|
+
}
|
|
1129
710
|
sendError(res, 400, "request.invalid_json", "invalid JSON body");
|
|
1130
711
|
return;
|
|
1131
712
|
}
|
|
1132
713
|
if (typeof body.toolCallId !== "string" || body.toolCallId.length === 0 || body.toolCallId.length > 256) {
|
|
1133
|
-
// 🔒 The length cap is load-bearing (review MEDIUM): core's ToolDetachHub.request() is create-then-abort —
|
|
1134
|
-
// an UNKNOWN id still allocates + retains a Map entry (keyed by the raw string) for the run's lifetime
|
|
1135
|
-
// (gc only fires from a real tool's completion), so unbounded ids = per-run memory growth on a shared
|
|
1136
|
-
// replica. Real core toolCallIds are short; 256 is generous.
|
|
1137
714
|
sendError(res, 400, "request.body_shape", "body must be { toolCallId: string (non-empty, ≤256 chars) }");
|
|
1138
715
|
return;
|
|
1139
716
|
}
|
|
@@ -1141,51 +718,28 @@ async function handleRunsBody(req, res, url, ctx, miss) {
|
|
|
1141
718
|
if (!run) {
|
|
1142
719
|
sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
|
|
1143
720
|
return;
|
|
1144
|
-
}
|
|
1145
|
-
// Owner-gate (parity with steer/compact): an explicit operator may detach on any tenant's run; a normal
|
|
1146
|
-
// caller only its own. 404 (not 403) for a non-owner — no existence oracle.
|
|
721
|
+
}
|
|
1147
722
|
const operator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
|
|
1148
723
|
if (!operator && run.owner !== null && run.owner !== principal) {
|
|
1149
724
|
sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
|
|
1150
725
|
return;
|
|
1151
|
-
}
|
|
726
|
+
}
|
|
1152
727
|
if (!runSessionAcceptOk(req, res, run, "run.detach"))
|
|
1153
|
-
return;
|
|
728
|
+
return;
|
|
1154
729
|
const live = steerableRuns.get(taskId);
|
|
1155
730
|
if (!live) {
|
|
1156
731
|
sendError(res, 409, "detach.not_running", run.status === "running" ? "run is active on another replica — detach is replica-local" : `run is ${run.status} — no running tool call to detach`);
|
|
1157
732
|
return;
|
|
1158
733
|
}
|
|
1159
|
-
// Fire-and-forget by CONTRACT (`detach(toolCallId): void`) — race-safety is core's; the outcome surfaces on
|
|
1160
|
-
// the run's OWN stream (the early-settled tool_end with the `detached:true` structured card, then the b* task
|
|
1161
|
-
// notification), which the client already consumes. 202 = "requested", honest about no confirmation here.
|
|
1162
734
|
live.detach(body.toolCallId);
|
|
1163
735
|
sendJson(res, 202, { taskId, toolCallId: body.toolCallId, delivery: "requested", note: "if the tool call is running in a detach-capable env it settles early with 'moved to background; task_id=b*'; otherwise the request is a no-op (fail-safe)" });
|
|
1164
736
|
return;
|
|
1165
737
|
}
|
|
1166
738
|
miss.fell = true;
|
|
1167
739
|
}
|
|
1168
|
-
/** 同上纪律(子代理 / 任务句柄 / HITL 应答段)。 */
|
|
1169
740
|
async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
1170
741
|
const { deps } = ctx;
|
|
1171
742
|
const { readJson, rateLimited, quotaExceeded, leaseDenied, safeDecode, runSessionAcceptOk } = ctx.helpers;
|
|
1172
|
-
// C2 (core 1.219) + design/122 (core 1.225): the Task-SUB-AGENT verbs of a run —
|
|
1173
|
-
// POST /v1/runs/:runId/subagents/:target/steer — inject fenced guidance into a STILL-RUNNING child;
|
|
1174
|
-
// POST /v1/runs/:runId/subagents/:target/resume — REVIVE a SETTLED child with a new prompt (CC dfe parity;
|
|
1175
|
-
// always async: the revived child runs in the background on its retained session, completion announced
|
|
1176
|
-
// via the deployment notify sink — requires the parent run to have set `retainSubagentSessions`).
|
|
1177
|
-
// Shared plumbing (one trust gate): addressed by the PARENT run's taskId + the child's parentToolCallId (or
|
|
1178
|
-
// agentName when unambiguous); replica-local handle registry (the legs' `onSubagentSpawn` sink; design/122 ③
|
|
1179
|
-
// handles now live until LEG END, not child settle — resume is only legal after settle); owner-gated via the
|
|
1180
|
-
// parent run row; content through `redactSteerIn`. 🔐 `handle.childSessionId` is a continuation capability
|
|
1181
|
-
// — never in any response. Sync delegations only (core r1-M3: bg children have no handle).
|
|
1182
|
-
// [1488]③(b): GET /v1/runs/:runId/subagents/:handle/output — the BACKGROUND child read face. The shell's
|
|
1183
|
-
// fleet viewer gets bg_notification summaries only; the child's FINAL assistant body lives in the core
|
|
1184
|
-
// TaskRegistry (what the TaskOutput tool reads). Auth = the runs-face read pattern (verified principal →
|
|
1185
|
-
// owner-gate via the parent run row → honest 404, no existence oracle); access into the registry is the
|
|
1186
|
-
// REGISTRATION tuple derived from the run row (owner=runId | sessionScoped via sessionId, scope=run
|
|
1187
|
-
// owner ?? "default") — NEVER caller-supplied. Replica-local (the registry is in-process, like steer);
|
|
1188
|
-
// non-blocking read: a still-running child returns its current status honestly, no long-poll (v1).
|
|
1189
743
|
const subOutputMatch = req.method === "GET" ? RUN_SUBAGENT_OUTPUT_RE.exec(url) : null;
|
|
1190
744
|
if (subOutputMatch) {
|
|
1191
745
|
if (!deps.runStore || !deps.subagentTaskOutput) {
|
|
@@ -1198,39 +752,20 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1198
752
|
sendError(res, 400, "request.path_malformed", "malformed subagent path (invalid percent-encoding)");
|
|
1199
753
|
return;
|
|
1200
754
|
}
|
|
1201
|
-
const gated = await subagentReadGate(req, res, deps, runId);
|
|
755
|
+
const gated = await subagentReadGate(req, res, deps, runId);
|
|
1202
756
|
if (!gated)
|
|
1203
757
|
return;
|
|
1204
758
|
const { run } = gated;
|
|
1205
|
-
// [1493] SESSION 边界的完整论证(threat model/为何 caller-asserted session 足够/为何不做 capability)
|
|
1206
|
-
// 已随共享门挪至 subagentReadGate 注;本面语义逐字不变。
|
|
1207
759
|
const out = await deps.subagentTaskOutput(target, { owner: runId, scope: run.owner ?? "default", ...(run.sessionId ? { sessionId: run.sessionId } : {}) });
|
|
1208
760
|
const details = out.details;
|
|
1209
|
-
// codex R1 (type gate): pollTask is the GENERIC registry face — same-tuple b*/m*/w* handles (bash
|
|
1210
|
-
// stdout, monitor batches, workflow projections) would ride this verb otherwise, a broader egress
|
|
1211
|
-
// than the documented agent-final-output contract. Only `background_agent` passes; everything else
|
|
1212
|
-
// gets the SAME indistinguishable 404 (unknown / not-yours / wrong-kind — no oracle). wa* workflow-
|
|
1213
|
-
// agent rows are OBSERVATION identities (never in the registry) — their read face is the workflow
|
|
1214
|
-
// journal (GET /v1/workflows/:id/journal), and they land in the not_found arm here by construction.
|
|
1215
761
|
if (details?.error === "not_found" || details?.type !== "background_agent") {
|
|
1216
|
-
// #261 §3:同 principal 前提下的 404 三分(材料全部来自已证属主的宿主 run 行,零新增读)。
|
|
1217
|
-
// #261 §3:同 principal 前提下的 404 三分(材料全部来自已证属主的宿主 run 行,零新增读)。
|
|
1218
762
|
if (!sendHandleMiss(res, run, deps.instanceId))
|
|
1219
763
|
sendError(res, 404, "not_found.subagent", `no background agent "${target}" under this run (unknown handle, not this run's child, or already reaped — bg children live in the replica-local registry for the parent's lifetime; wa… workflow-agent rows are read via the workflow journal, not this verb)`);
|
|
1220
764
|
return;
|
|
1221
765
|
}
|
|
1222
|
-
// Pass the registry's honest projection through: status/retrieval_status/partial flags verbatim,
|
|
1223
|
-
// `content` = the TaskOutput-tool text (the child's final assistant body once terminal). UNTRUSTED
|
|
1224
|
-
// model output, same posture as the runs face's `result`.
|
|
1225
766
|
sendJson(res, 200, { taskId: runId, target, content: out.content, output: out.details });
|
|
1226
767
|
return;
|
|
1227
768
|
}
|
|
1228
|
-
// S2([1520]{core} 提货,core 1.370 `bgAgentId`):GET /v1/runs/:id/subagents/:handle/stream —— per-agent
|
|
1229
|
-
// **live tail**(SSE)。「replay+tail」的 tail 半场:replay/终报=上面的 output 面(durable 臂 1.250),
|
|
1230
|
-
// 本面=从连接时刻起的 content 帧(text/reasoning delta、tool_start/end、task_progress——投影与 sync 主流
|
|
1231
|
-
// forward 分支同 builder 同纪律,subagent-tail-bus.ts 顶注)。帧只在宿主 run 所在副本产生 ⇒ live 帧
|
|
1232
|
-
// replica-local(meta 如实声明;他实例 running 的行=只 heartbeat,不虚构)。门=output 面逐字同形
|
|
1233
|
-
// (principal→owner→session fail-closed;a* 形判;探测经同 seam,未知/非本 run/型错=同 404 无 oracle)。
|
|
1234
769
|
const subStreamMatch = req.method === "GET" ? RUN_SUBAGENT_STREAM_RE.exec(url) : null;
|
|
1235
770
|
if (subStreamMatch) {
|
|
1236
771
|
if (!deps.runStore || !deps.subagentTaskOutput) {
|
|
@@ -1243,70 +778,38 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1243
778
|
sendError(res, 400, "request.path_malformed", "malformed subagent path (invalid percent-encoding)");
|
|
1244
779
|
return;
|
|
1245
780
|
}
|
|
1246
|
-
// [2373]C-5 共享门(principal→owner→session)——本腿此前逐字手写同三层([1493] 语义),与 output/stop
|
|
1247
|
-
// 两腿并列;收编到同一函数后一次修复处处生效。判定顺序以共享门为准(与已收编的两腿现行为一致:
|
|
1248
|
-
// 路径解码 400 先于 principal 401,门内三级次序不变)。
|
|
1249
781
|
const gatedTail = await subagentReadGate(req, res, deps, runId);
|
|
1250
782
|
if (!gatedTail)
|
|
1251
783
|
return;
|
|
1252
784
|
const { run } = gatedTail;
|
|
1253
|
-
// 🔴 cli[1524] HIGH-2:**subscribe 先行,probe 在后**——原序(probe await→meta→subscribe)在
|
|
1254
|
-
// probe 与 subscribe 之间留竞态窗:窗内子代 settle → task_settled 发布时无订阅者被丢(总线无
|
|
1255
|
-
// replay/sticky)→ 订阅者只收 heartbeat 永挂。先建订阅,窗内 settle 自然落进队列;probe 判 404
|
|
1256
|
-
// 时 it.return() 清订阅(未写任何帧,404 照常)。
|
|
1257
785
|
const it = defaultSubagentTailBus.subscribe(target);
|
|
1258
|
-
// 归属+型探测经 output 同 seam(含 1.250 durable 臂——跨实例行也探得到 status);not_found/型错=
|
|
1259
|
-
// 同一不可区分 404(无 oracle)。探测是纯读(a* 形在 seam 内先过形判门;background_agent poll 无副作用)。
|
|
1260
786
|
const probe = await deps.subagentTaskOutput(target, { owner: runId, scope: run.owner ?? "default", ...(run.sessionId ? { sessionId: run.sessionId } : {}) });
|
|
1261
787
|
const probeDetails = probe.details;
|
|
1262
788
|
if (probeDetails?.error === "not_found" || probeDetails?.type !== "background_agent") {
|
|
1263
789
|
void it.return?.();
|
|
1264
|
-
// #261 §3 三分(与 output 面同判据同码)
|
|
1265
790
|
if (!sendHandleMiss(res, run, deps.instanceId))
|
|
1266
791
|
sendError(res, 404, "not_found.subagent", `no background agent "${target}" under this run (unknown handle, not this run's child, or already reaped — bg children live in the replica-local registry for the parent's lifetime; wa… workflow-agent rows are read via the workflow journal, not this verb)`);
|
|
1267
792
|
return;
|
|
1268
793
|
}
|
|
1269
794
|
sseHeaders(res);
|
|
1270
|
-
// [2373]C-11(5.0.0 BREAKING,提货单点名):meta 帧的 run 实体键改 `taskId`——同 verb 家族的
|
|
1271
|
-
// output 面(:936/:1162)一直叫 taskId,meta 独叫 runId 是家族内第三键名;5.0.0 收一。
|
|
1272
|
-
// meta 声明:live 帧 replica-local + replay 指引;status=探测快照(终态=不再有帧,发 meta 即收尾,
|
|
1273
|
-
// cli 拿到「走 replay 面」的信号而不是空挂)。seq=stop-cycle 世代号(settle 铸;core 1.377 起
|
|
1274
|
-
// **全域投影**——进程内 live 臂投当前复活周期号 [[1533] ask 兑现,[1548]①],durable 终态 serve
|
|
1275
|
-
// 同键同义;老 core 只在 durable 臂在场,缺省容忍)。1=spawn 周期,≥2=复活周期(retained revive
|
|
1276
|
-
// 或 tier-3 均 bump——codex 快审 #11 纠,勿窄读成仅 tier-3)。
|
|
1277
|
-
// `type: "meta"` 与 `event: meta` 同名双发(tasks/trace-usage/approvals 三流同款):部分代理只转发
|
|
1278
|
-
// `data:` 行,按 data.type 分派的消费端否则认不出首帧。additive,老消费端忽略未知键。
|
|
1279
|
-
// [3911] `contentFrames` = 本面的**内容帧供给形**(宿主腿登记,SubagentTailBus.TailContentMode 顶注
|
|
1280
|
-
// 有全文理由):"on" = 内容帧(text/reasoning delta、tool_start/end)会来;"progress_only" = 宿主 run
|
|
1281
|
-
// 提交时没带 `forwardSubagentEvents` ⇒ core 的 forward 门只放 task_progress,本流**结构上**不会有内容
|
|
1282
|
-
// 帧(订阅方无法在查看态打开它,只能让宿主 run 带上那个键重开);"unknown" = 还不知道(子代还没产出
|
|
1283
|
-
// 第一帧,或宿主腿在别的副本)。
|
|
1284
|
-
// additive 键,老消费端忽略;它把此前「一切正常但永远空」的静默降级变成 wire 上可读的事实。
|
|
1285
|
-
// 🔴 按 **target 句柄**读,不按 URL 里的 runId(codex 复审 high):session-scoped 子代活得比派它的
|
|
1286
|
-
// run 久,订阅方常经**后起的另一条 run** 来看它——按 run 键答题在那一档确定性谎报。
|
|
1287
795
|
const contentFrames = defaultSubagentTailBus.handleContentMode(target) ?? "unknown";
|
|
1288
796
|
res.write(`event: meta\ndata: ${JSON.stringify({ type: "meta", version: 1, taskId: runId, target, status: probeDetails.status ?? "running", ...(typeof probeDetails.seq === "number" ? { seq: probeDetails.seq } : {}), live: "replica-local", contentFrames, replayFace: "GET /v1/runs/:id/subagents/:handle/output" })}\n\n`);
|
|
1289
|
-
// core 1.382([1561] 提货单③):"parked" 非活非终态(durably suspended pending approval)——跟
|
|
1290
|
-
// running/pending 同归"还没完",tail 该继续等而不是收尾指去 replay 面(那是给真终态用的)。子代获批
|
|
1291
|
-
// 恢复后会在**同一条**活流上继续产帧,提前收尾会让客户端误判"已经结束",错过恢复后的后续内容。
|
|
1292
797
|
if (probeDetails.status !== "running" && probeDetails.status !== "pending" && probeDetails.status !== "parked") {
|
|
1293
798
|
void it.return?.();
|
|
1294
|
-
res.end();
|
|
799
|
+
res.end();
|
|
1295
800
|
return;
|
|
1296
801
|
}
|
|
1297
802
|
let closed = false;
|
|
1298
803
|
const hb = setInterval(() => {
|
|
1299
804
|
if (!res.writableEnded)
|
|
1300
|
-
res.write(`event: heartbeat\ndata: {}\n\n`);
|
|
805
|
+
res.write(`event: heartbeat\ndata: {}\n\n`);
|
|
1301
806
|
}, 15_000);
|
|
1302
807
|
if (typeof hb.unref === "function")
|
|
1303
808
|
hb.unref();
|
|
1304
|
-
// design/245 件1:req+res 双挂 + 一次性闸(理由全文在 `sse-lifecycle.ts` 顶注)。本腿的代价最重——
|
|
1305
|
-
// 旗翻不过来时,parked 的 `next()` 会把**总线订阅**一直挂在那里,发布方继续为死对端缓冲。
|
|
1306
809
|
bindSseLifecycle(req, res, () => {
|
|
1307
810
|
closed = true;
|
|
1308
|
-
clearInterval(hb);
|
|
1309
|
-
void it.return?.();
|
|
811
|
+
clearInterval(hb);
|
|
812
|
+
void it.return?.();
|
|
1310
813
|
});
|
|
1311
814
|
try {
|
|
1312
815
|
for (;;) {
|
|
@@ -1315,7 +818,7 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1315
818
|
break;
|
|
1316
819
|
res.write(`event: forward\ndata: ${JSON.stringify(n.value)}\n\n`);
|
|
1317
820
|
if (n.value.type === "task_settled")
|
|
1318
|
-
break;
|
|
821
|
+
break;
|
|
1319
822
|
}
|
|
1320
823
|
}
|
|
1321
824
|
finally {
|
|
@@ -1326,16 +829,6 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1326
829
|
}
|
|
1327
830
|
return;
|
|
1328
831
|
}
|
|
1329
|
-
// [1499] CC TaskOutput/TaskStop 人侧对位 — the GENERIC task-handle verbs. The narrow subagents/:h/output
|
|
1330
|
-
// face above stays byte-stable (公告过的 background_agent ONLY contract); THIS family serves the full
|
|
1331
|
-
// registry kind set the TaskOutput/TaskStop tools serve (background_bash stdout — NB whether a read
|
|
1332
|
-
// consumes the output cursor depends on the handle's shape: a spooled handle re-reads in full, a
|
|
1333
|
-
// cursor-only handle returns new bytes per read; the projection's own flags are authoritative; monitor
|
|
1334
|
-
// batches; background_agent final report), with `workflow` refused at the seam (the journal face owns
|
|
1335
|
-
// workflow reads; a poll here would fire onServedTerminal and suppress the completion push). Addressing
|
|
1336
|
-
// is EXACT task_id only — the tools' agent-name / legacy-shellId resolution is deliberately not on the
|
|
1337
|
-
// wire (fail-closed 404). Gates mirror subagentOutput: verified principal → owner via the run row →
|
|
1338
|
-
// SESSION (enforced from birth — new face, zero consumers, no soft phase needed).
|
|
1339
832
|
const taskVerbMatch = req.method === "GET" ? RUN_TASK_OUTPUT_RE.exec(url) : req.method === "POST" ? RUN_TASK_STOP_RE.exec(url) : null;
|
|
1340
833
|
if (taskVerbMatch) {
|
|
1341
834
|
const stopVerb = req.method === "POST";
|
|
@@ -1344,29 +837,19 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1344
837
|
return;
|
|
1345
838
|
}
|
|
1346
839
|
if (stopVerb && rateLimited(req, res))
|
|
1347
|
-
return;
|
|
840
|
+
return;
|
|
1348
841
|
const runId = safeDecode(taskVerbMatch[1]);
|
|
1349
842
|
const target = safeDecode(taskVerbMatch[2]);
|
|
1350
843
|
if (runId === null || target === null) {
|
|
1351
844
|
sendError(res, 400, "request.path_malformed", "malformed task path (invalid percent-encoding)");
|
|
1352
845
|
return;
|
|
1353
846
|
}
|
|
1354
|
-
const gatedB = await subagentReadGate(req, res, deps, runId);
|
|
847
|
+
const gatedB = await subagentReadGate(req, res, deps, runId);
|
|
1355
848
|
if (!gatedB)
|
|
1356
849
|
return;
|
|
1357
850
|
const { run } = gatedB;
|
|
1358
851
|
const q = new URL(req.url ?? "", "http://x").searchParams;
|
|
1359
852
|
const access = { owner: runId, scope: run.owner ?? "default", ...(run.sessionId ? { sessionId: run.sessionId } : {}) };
|
|
1360
|
-
// ?filter= is REFUSED on BOTH verbs, not ignored (fail-loud 400; the face shipped unreleased, zero
|
|
1361
|
-
// consumers — and codex R2: a GET-only refusal would leave the stop verb silently accepting it). The
|
|
1362
|
-
// tools' filter is a caller-supplied RegExp run line-by-line on the replica's shared event loop — over
|
|
1363
|
-
// the wire that lets one request stall the whole replica for every tenant (pathological patterns on
|
|
1364
|
-
// long output lines take unbounded time in a backtracking engine). Client-side filtering of the fetched
|
|
1365
|
-
// output is the alternative, with an honest limit (codex R2): the wire serves the registry's CLIPPED
|
|
1366
|
-
// projection (core filters BEFORE clipping; a client can only filter after) — matches inside a clipped
|
|
1367
|
-
// middle are not recoverable over the wire, and a cursor-only bash leg consumes what it serves.
|
|
1368
|
-
// Full-fidelity filtered reads stay an in-engine tool capability; if a linear-time engine lands in
|
|
1369
|
-
// core, the wire filter can reopen deliberately.
|
|
1370
853
|
if (q.get("filter") !== null) {
|
|
1371
854
|
sendError(res, 400, "request.param_unsupported", "filter is not accepted on the wire — fetch the output and filter client-side (note: the wire serves the clipped projection)");
|
|
1372
855
|
return;
|
|
@@ -1376,42 +859,15 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1376
859
|
: await deps.taskHandleOutput(target, access);
|
|
1377
860
|
const details = out.details;
|
|
1378
861
|
if (details?.error === "not_found") {
|
|
1379
|
-
// #261 §3 三分(GET output 与 POST stop 共这一处)
|
|
1380
862
|
if (!sendHandleMiss(res, run, deps.instanceId))
|
|
1381
863
|
sendError(res, 404, "not_found.task_handle", `no background task "${target}" under this run (unknown handle, not this run's task, already reaped — handles live in the replica-local registry for the parent's lifetime — or a workflow handle: workflow rows read via GET /v1/workflows/:id/journal; stopping a workflow is not on this wire)`);
|
|
1382
864
|
return;
|
|
1383
865
|
}
|
|
1384
|
-
// [1499] codex R3: a stop whose kill did NOT land must not read as success. Core keeps the handle
|
|
1385
|
-
// honest (status stays "running", error = the env failure code) — surface that as a 409 so a
|
|
1386
|
-
// status-driven client never mistakes a failed stop for a dead process. The projection rides along
|
|
1387
|
-
// verbatim for diagnosis. NB the "user" attribution pre-mark stays pending on this arm (first-write-
|
|
1388
|
-
// wins, no public per-handle clear) — if the task later dies of another cause it reads stoppedBy:
|
|
1389
|
-
// "user"; the atomic fix is a `source` option on core's stopTask (asked on the board), same residue
|
|
1390
|
-
// shape as core's own internal "parent" pre-mark.
|
|
1391
|
-
// core 1.382([1561] 提货单③):parked 行的两个新 stop 结局——`parked_pending_approval`(stop 撞见
|
|
1392
|
-
// 一个仍在 parked 的行,没有活跃可 kill 的进程)与 `park_resume_won`(stop 与"审批已落地→恢复"发生
|
|
1393
|
-
// 竞态,恢复那侧赢了,行不再是 parked,但 status 不一定翻回 "running"——error 码本身才是权威判据,
|
|
1394
|
-
// 不能靠 status 反推)。二者都不该读成 200 成功("kill 没落地"同一条纪律的延伸),各给各的
|
|
1395
|
-
// errorCode(同 409 家族,additive,与既有 not_local/not_landed 同姿势)。
|
|
1396
|
-
// [1596]/[1600] core 1.397 三分:仲裁器**抛错**(店不可达)此前被误吞成 park_resume_won 的假话——
|
|
1397
|
-
// 「输给了并发 resume」与「根本问不到仲裁结果」是两种不同的行动建议(前者重查状态,后者原样重试
|
|
1398
|
-
// stop),新码 `park_arbiter_unreachable` 让二者不再共用一个 errorCode。
|
|
1399
|
-
// [4797](core 5.50)details.code 孪生对表:stop 族座位今天仍只铸 `error`(族扫未及,已上板点名),
|
|
1400
|
-
// 但 core 契约方向是「code wins when both present」且未承诺新失败座位必带 error ⇒ 判类读
|
|
1401
|
-
// code-优先-error-回落、在场判据双键取并,未来只铸 code 的失败座位不会漏判成 200 成功。
|
|
1402
866
|
const stopCode = typeof details?.code === "string" ? details.code : details?.error;
|
|
1403
867
|
const parkArbiterUnreachable = stopCode === "park_arbiter_unreachable";
|
|
1404
868
|
const parkResumeWon = stopCode === "park_resume_won";
|
|
1405
869
|
const stillParked = stopCode === "parked_pending_approval" || details?.status === "parked";
|
|
1406
|
-
// v3.1 批2(统检第二波 high):判别方向翻转——旧形是 (status,error) 的**失败白名单**(status==="running"
|
|
1407
|
-
// || 三个具名码),core 新增失败码且行状态不在 {running,parked} 时整个分支被跳过落 200,与上面注释自陈
|
|
1408
|
-
// 的不变量(kill 没落地绝不读成功)正相悖(B5 联合非穷尽的 default-放行形;not_local/park 族/arbiter
|
|
1409
|
-
// 三次历史新增全靠人追认)。新形:stop 动词的 error **在场即失败**(not_found 已在上方早退),具名臂
|
|
1410
|
-
// 保留各自 errorCode,未知码落 stop.not_landed 兜底 409——新失败码默认可见,不默认成功。
|
|
1411
870
|
if (stopVerb && (details?.error !== undefined || details?.code !== undefined)) {
|
|
1412
|
-
// 1.250:durable 回落臂(core stopTask agentStore 分支)对他实例 running 行应答 error="not_local"
|
|
1413
|
-
// (没有 kill 被尝试)——与「kill 尝试了没落地」(stop.not_landed)是不同的失败形,各给各的
|
|
1414
|
-
// errorCode(同 409 家族,additive)。
|
|
1415
871
|
const notLocal = stopCode === "not_local";
|
|
1416
872
|
sendError(res, 409, notLocal
|
|
1417
873
|
? "stop.not_local"
|
|
@@ -1432,14 +888,6 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1432
888
|
: `stop did not land (${stopCode}) — the process may still be running`, { taskId: runId, target, content: out.content, output: out.details });
|
|
1433
889
|
return;
|
|
1434
890
|
}
|
|
1435
|
-
// Registry projection passed through verbatim (status/retrieval_status/cursor flags); `content` is
|
|
1436
|
-
// UNTRUSTED tool/model output, same posture as subagentOutput.
|
|
1437
|
-
// G14([1833]/[1840]§五):顶层铸 `cursorSemantics` —— 此前「这次读是否消费了游标」只能从 content
|
|
1438
|
-
// 文本标记(`--- stdout (full, re-readable) ---` vs `--- new stdout ---`)反解,每个消费端都得抄
|
|
1439
|
-
// 字符串判别。判据(core task-registry dist 亲读):游标消费形(非 spool 的 pollBackground 路径)
|
|
1440
|
-
// 的内层 details.bytesDroppedBeforeCursor **恒在**(0 也发);spool bash / monitor / background_agent
|
|
1441
|
-
// 全是重读安全的 full 形。⚠️ 这仍是对 core 内形的一处嗅探——集中在 server 这一处替所有端挑担;
|
|
1442
|
-
// 已在黑板请 core 出一等 flag,到货换源、wire 键不变。error/not_ready 形不铸(没有输出体,别给假语义)。
|
|
1443
891
|
const g14 = (() => {
|
|
1444
892
|
const d = out.details;
|
|
1445
893
|
if (!d || d.error !== undefined || d.retrieval_status === "not_ready")
|
|
@@ -1448,7 +896,7 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1448
896
|
return { cursorSemantics: d.details && "bytesDroppedBeforeCursor" in d.details ? "cursor" : "full" };
|
|
1449
897
|
if (d.type === "monitor" || d.type === "background_agent")
|
|
1450
898
|
return { cursorSemantics: "full" };
|
|
1451
|
-
return {};
|
|
899
|
+
return {};
|
|
1452
900
|
})();
|
|
1453
901
|
sendJson(res, 200, { taskId: runId, target, content: out.content, output: out.details, ...g14 });
|
|
1454
902
|
return;
|
|
@@ -1457,13 +905,11 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1457
905
|
if (subVerbMatch) {
|
|
1458
906
|
const verb = url.endsWith("/resume") ? "resume" : "steer";
|
|
1459
907
|
if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
|
|
1460
|
-
return;
|
|
908
|
+
return;
|
|
1461
909
|
if (!deps.runStore) {
|
|
1462
910
|
sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg|local)");
|
|
1463
911
|
return;
|
|
1464
912
|
}
|
|
1465
|
-
// Identity from gatedPrincipal (direct-door-secure single point), NEVER the spoofable header — `trusted`
|
|
1466
|
-
// carries operator authority (parity with RUN steer / workflow-agent steer; HANDOFF 铁律 + core §3 inv #1).
|
|
1467
913
|
const principal = gatedPrincipal(req, deps.config);
|
|
1468
914
|
if (deps.config.requirePrincipal && principal === undefined) {
|
|
1469
915
|
sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
|
|
@@ -1483,7 +929,7 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1483
929
|
if (e instanceof HttpError) {
|
|
1484
930
|
sendError(res, e.status, httpErrorCode(e.status, e.code), e.message);
|
|
1485
931
|
return;
|
|
1486
|
-
}
|
|
932
|
+
}
|
|
1487
933
|
sendError(res, 400, "request.invalid_json", "invalid JSON body");
|
|
1488
934
|
return;
|
|
1489
935
|
}
|
|
@@ -1491,28 +937,19 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1491
937
|
sendError(res, 400, "request.body_shape", "body must be { content: string (non-empty) }");
|
|
1492
938
|
return;
|
|
1493
939
|
}
|
|
1494
|
-
// 入参上限(见 `STEER_IN_MAX_REQUEST_CHARS` 旁注):脱敏门是**同步**的,烧的是事件循环 ⇒
|
|
1495
|
-
// 一次超大 steer 卡住的是**整个副本**。**拒**而不是服务端先截 —— 先截会让引擎的
|
|
1496
|
-
// `[+N chars]` 披露低报,而"诚实标记不得低报"是本仓另一条钉守着的性质。
|
|
1497
940
|
if (body.content.length > STEER_IN_MAX_REQUEST_CHARS) {
|
|
1498
941
|
sendError(res, 413, "steer.content_too_large", `content exceeds ${STEER_IN_MAX_REQUEST_CHARS} characters (got ${body.content.length}); note only the first ${STEER_IN_MAX_CHARS} are delivered anyway`);
|
|
1499
942
|
return;
|
|
1500
943
|
}
|
|
1501
|
-
// trusted = explicit operator (NOT bare isOperator); a normal caller may drive only their OWN run's children.
|
|
1502
944
|
const trusted = explicitOperatorOk(principal, deps.config.operatorPrincipals);
|
|
1503
945
|
const run = await deps.runStore.getRun(runId);
|
|
1504
|
-
// Owner-gate via the parent run row (the child inherits the parent's tenancy — core threads the parent's
|
|
1505
|
-
// principal to the child, design/62). 404 (not 403) for a non-owner — no existence oracle.
|
|
1506
946
|
if (!run || (!trusted && run.owner !== null && run.owner !== principal)) {
|
|
1507
|
-
sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
|
|
947
|
+
sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
|
|
1508
948
|
return;
|
|
1509
949
|
}
|
|
1510
950
|
if (!runSessionAcceptOk(req, res, run, `subagent.${verb}`))
|
|
1511
|
-
return;
|
|
951
|
+
return;
|
|
1512
952
|
const send409 = (errorCode, error) => sendError(res, 409, errorCode, error);
|
|
1513
|
-
// STEER-IN REDACTION GATE — same trust gate as the workflow-agent route (one owner: redactSteerIn): the
|
|
1514
|
-
// human content is UNTRUSTED (host-leak/size/fence) BEFORE it reaches the child; core's handle.steer/
|
|
1515
|
-
// resume fence AGAIN inside their trusted marker framing (defense in depth).
|
|
1516
953
|
const redacted = redactSteerIn(body.content, target);
|
|
1517
954
|
const resolution = deps.subagentSteerRegistry?.resolve(runId, target);
|
|
1518
955
|
if (resolution && resolution.count > 1) {
|
|
@@ -1523,8 +960,6 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1523
960
|
if (handle) {
|
|
1524
961
|
try {
|
|
1525
962
|
if (verb === "resume") {
|
|
1526
|
-
// design/122: `handle.resume` is absent when the parent run did not retain child sessions — the
|
|
1527
|
-
// same condition core's own rejection names; report it with core's code so the shell renders one story.
|
|
1528
963
|
if (!handle.resume) {
|
|
1529
964
|
send409("resume.retain_off", "the parent run did not retain sub-agent sessions (set retainSubagentSessions on the run to enable revival)");
|
|
1530
965
|
return;
|
|
@@ -1534,14 +969,11 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1534
969
|
return;
|
|
1535
970
|
}
|
|
1536
971
|
const marker = await handle.steer(redacted);
|
|
1537
|
-
// Receipt wording aligned with CC ("Message queued for delivery…").
|
|
1538
972
|
sendJson(res, 200, { taskId: runId, target, status: "running", delivery: "applied", marker, note: `Message queued for delivery to ${handle.agentName ?? target} at its next tool round.` });
|
|
1539
973
|
return;
|
|
1540
974
|
}
|
|
1541
975
|
catch (e) {
|
|
1542
976
|
const code = e.code;
|
|
1543
|
-
// core's typed rejections → honest 409s with the core code verbatim (steer: not_running only;
|
|
1544
|
-
// resume: still_running / retain_off / evicted / cap / session_not_found — design/122 D2 contract).
|
|
1545
977
|
if (code === "steering.not_running") {
|
|
1546
978
|
send409(code, "sub-agent just finished — no longer accepting steers");
|
|
1547
979
|
return;
|
|
@@ -1554,32 +986,22 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1554
986
|
send409(code, e instanceof Error ? e.message : "resume rejected");
|
|
1555
987
|
return;
|
|
1556
988
|
}
|
|
1557
|
-
throw e;
|
|
989
|
+
throw e;
|
|
1558
990
|
}
|
|
1559
991
|
}
|
|
1560
|
-
// No handle here. Either the parent run is elsewhere/terminal, the child was never registered (bg
|
|
1561
|
-
// delegation — no handle by core's r1-M3 scope), or the owning leg already tore down. Honest 409.
|
|
1562
992
|
send409("steering.not_running", run.status === "running"
|
|
1563
993
|
? "no sub-agent matches on this replica (it runs backgrounded, or the run is on another replica)"
|
|
1564
994
|
: `run is ${run.status} — its sub-agents are no longer addressable`);
|
|
1565
995
|
return;
|
|
1566
996
|
}
|
|
1567
|
-
// E23 (shell-host contract): POST /v1/elicitations/:id/respond — the shell answers an inbound MCP elicitation. LIVE-ONLY +
|
|
1568
|
-
// same-replica (the parked promise is in-memory, like a live steer): the coordinator owner-gates (404 — no
|
|
1569
|
-
// existence oracle), validates the body (= core's McpElicitResponse), and resolves the promise so onElicit returns
|
|
1570
|
-
// to core, which replies to the MCP server on the still-open connection. A respond on the wrong replica / after
|
|
1571
|
-
// answer/TTL finds nothing → 404.
|
|
1572
997
|
const elicitMatch = req.method === "POST" ? ELICIT_RESPOND_RE.exec(url) : null;
|
|
1573
998
|
if (elicitMatch) {
|
|
1574
999
|
if (rateLimited(req, res))
|
|
1575
|
-
return;
|
|
1000
|
+
return;
|
|
1576
1001
|
if (!deps.elicitation) {
|
|
1577
1002
|
sendError(res, 501, "feature.elicitation_disabled", "inbound MCP elicitation is not enabled on this worker (MCP_ELICITATION_ENABLED)");
|
|
1578
1003
|
return;
|
|
1579
1004
|
}
|
|
1580
|
-
// Identity from gatedPrincipal (direct-door-secure single point), NEVER the spoofable header — the answer is
|
|
1581
|
-
// owner-gated against the run's owner (the END USER the elicitation was routed to). No operator bypass: an
|
|
1582
|
-
// elicitation form is the user's to fill, not a supervisor's (unlike steer).
|
|
1583
1005
|
const principal = gatedPrincipal(req, deps.config);
|
|
1584
1006
|
if (deps.config.requirePrincipal && principal === undefined) {
|
|
1585
1007
|
sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
|
|
@@ -1598,21 +1020,14 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1598
1020
|
sendJson(res, status, respBody);
|
|
1599
1021
|
return;
|
|
1600
1022
|
}
|
|
1601
|
-
// §4④: POST /v1/questions/:id/respond — the shell answers the agent's AskUserQuestion. LIVE-ONLY +
|
|
1602
|
-
// same-replica (parked promise in-memory), the sibling of the elicit respond: owner-gated (404, no existence
|
|
1603
|
-
// oracle), body validated (= core's QuestionAnswer), resolves the promise so onQuestion returns to core and the
|
|
1604
|
-
// model continues with "The user answered: …". The security ANSWER-fence (selected ⊆ options, note untrusted-fenced)
|
|
1605
|
-
// is core's AskUserQuestion tool, not here. A respond on the wrong replica / after answer/TTL finds nothing → 404.
|
|
1606
1023
|
const questionMatch = req.method === "POST" ? QUESTION_RESPOND_RE.exec(url) : null;
|
|
1607
1024
|
if (questionMatch) {
|
|
1608
1025
|
if (rateLimited(req, res))
|
|
1609
|
-
return;
|
|
1026
|
+
return;
|
|
1610
1027
|
if (!deps.question) {
|
|
1611
1028
|
sendError(res, 501, "feature.ask_question_disabled", "AskUserQuestion live HITL is not enabled on this worker (ASK_QUESTION_ENABLED)");
|
|
1612
1029
|
return;
|
|
1613
1030
|
}
|
|
1614
|
-
// Same owner-gate identity as elicit: the VERIFIED principal (gatedPrincipal), never the spoofable header. The
|
|
1615
|
-
// question is the END USER's to answer (routed to their stream) — no operator bypass.
|
|
1616
1031
|
const principal = gatedPrincipal(req, deps.config);
|
|
1617
1032
|
if (deps.config.requirePrincipal && principal === undefined) {
|
|
1618
1033
|
sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
|
|
@@ -1631,22 +1046,14 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1631
1046
|
sendJson(res, status, respBody);
|
|
1632
1047
|
return;
|
|
1633
1048
|
}
|
|
1634
|
-
// [816]/[820]②: POST /v1/tool-approvals/:id/respond — the shell answers a policy `ask` (the CC three-choice
|
|
1635
|
-
// card: {decision: "allow" | "allow_session" | "deny"}). LIVE-ONLY + same-replica (parked promise in-memory),
|
|
1636
|
-
// the sibling of the question respond: owner-gated (404, no existence oracle), body validated first (400 is
|
|
1637
|
-
// existence-independent), resolves the promise so core's resolveAsk returns allow/deny and the tool call
|
|
1638
|
-
// proceeds/denies. `allow_session` additionally arms the coordinator's per-session allow-all (fs-write family
|
|
1639
|
-
// only — rationale in tool-approval.ts). A respond on the wrong replica / after settle/TTL finds nothing → 404.
|
|
1640
1049
|
const approvalMatch = req.method === "POST" ? TOOL_APPROVAL_RESPOND_RE.exec(url) : null;
|
|
1641
1050
|
if (approvalMatch) {
|
|
1642
1051
|
if (rateLimited(req, res))
|
|
1643
|
-
return;
|
|
1052
|
+
return;
|
|
1644
1053
|
if (!deps.toolApproval) {
|
|
1645
1054
|
sendError(res, 501, "feature.tool_approval_disabled", "live tool-approval HITL is not enabled on this worker (TOOL_APPROVAL_ENABLED)");
|
|
1646
1055
|
return;
|
|
1647
1056
|
}
|
|
1648
|
-
// Same owner-gate identity as question/elicit: the VERIFIED principal (gatedPrincipal), never the spoofable
|
|
1649
|
-
// header. The approval is the END USER's to answer (routed to their stream) — no operator bypass.
|
|
1650
1057
|
const principal = gatedPrincipal(req, deps.config);
|
|
1651
1058
|
if (deps.config.requirePrincipal && principal === undefined) {
|
|
1652
1059
|
sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
|
|
@@ -1661,29 +1068,14 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1661
1068
|
sendError(res, 400, "request.invalid_json", "invalid JSON body");
|
|
1662
1069
|
return;
|
|
1663
1070
|
}
|
|
1664
|
-
// #151 车2:respond() 的返回型是 union(store 缺席时同步、在场时 Promise——src/tool-approval.ts 顶注);
|
|
1665
|
-
// await 对两者都是恒等/透传操作,这里统一 await 以兼容两条路径。
|
|
1666
|
-
// #329:末位透传**本次请求**。它只沿「行已 PARKED 的迟到决议」那条臂流到赎回席(那条腿要真请求做
|
|
1667
|
-
// 准入解析与舰队 scope,与 `/decide` 腿同姿势);live 命中路径一个字节都不看它。
|
|
1668
1071
|
const { status, body: respBody } = await deps.toolApproval.respond(id, principal, body, req);
|
|
1669
1072
|
sendJson(res, status, respBody);
|
|
1670
1073
|
return;
|
|
1671
1074
|
}
|
|
1672
|
-
// #151 车4:POST /v1/tasks/:taskId/asks/:askId/decision —— durable 回决(辖域对比见 TASK_ASK_DECISION_RE 顶注)。
|
|
1673
1075
|
const askDecisionMatch = req.method === "POST" ? TASK_ASK_DECISION_RE.exec(url) : null;
|
|
1674
1076
|
if (askDecisionMatch) {
|
|
1675
1077
|
if (rateLimited(req, res))
|
|
1676
|
-
return;
|
|
1677
|
-
// 协议关 / 无 durable backend ⇒ 根本没有可 CAS 的账本。501 fail-loud:静默 404 会把「本部署没接这个
|
|
1678
|
-
// 面」伪装成「你的 ask 不存在」,而这两件事的处置完全相反(改部署 vs 别重试)。
|
|
1679
|
-
//
|
|
1680
|
-
// 🔴 **上场判据 = 单一谓词**(车3 刀 3b 收口)。车4 落地时这里是一份局部的
|
|
1681
|
-
// `enabled ∧ backend ∧ kind !== "local"` 临时执行面,注释里写死了交接条件:「等能力面 `streamApproval`
|
|
1682
|
-
// 那一车,把 `kind === "local"` 换成消费**那一个**谓词并同步删掉本段 —— 两份判据长期并存必然漂」。
|
|
1683
|
-
// 兑现即此:`resolveStreamApprovalGate` 现在同时服务协调器注入(boot/coordinators.ts)、
|
|
1684
|
-
// `/v1/capabilities` 的 `streamApproval` 格与本口的 501 门。判据由三项扩到五项(多了 `toolApproval`
|
|
1685
|
-
// 在场与 **park 设施**在场,§8.4)——本口因此更严:能力面说 false 的部署,这里恒 501,不会出现
|
|
1686
|
-
// 「能力面诚实说没有、端点却收下一批注定无处安放的决议」。
|
|
1078
|
+
return;
|
|
1687
1079
|
const gate = resolveStreamApprovalGate({
|
|
1688
1080
|
toolApprovalEnabled: Boolean(deps.toolApproval),
|
|
1689
1081
|
streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
|
|
@@ -1695,8 +1087,6 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1695
1087
|
sendError(res, 501, "feature.approval_ask_disabled", "the durable stream-approval ask ledger is not enabled on this worker (needs STREAM_APPROVAL_ENABLED plus a DURABLE store backend — the local lane keeps asks in memory and would lose accepted decisions on restart)");
|
|
1696
1088
|
return;
|
|
1697
1089
|
}
|
|
1698
|
-
// §12-F 前置链(逐字照三条 respond 的先例):rateLimited → principal 门 → readJson → safeParse。
|
|
1699
|
-
// 身份恒取 gatedPrincipal(已验证的那份),**绝不**取可伪造的头 —— 它同时是属主门与 actor 铸造的唯一源。
|
|
1700
1090
|
const principal = gatedPrincipal(req, deps.config);
|
|
1701
1091
|
if (deps.config.requirePrincipal && principal === undefined) {
|
|
1702
1092
|
sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
|
|
@@ -1716,76 +1106,27 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1716
1106
|
return;
|
|
1717
1107
|
}
|
|
1718
1108
|
const body = parsedBody.data;
|
|
1719
|
-
// `updatedInput` 的**在场**判定读原始体:`z.unknown().optional()` 解出来的 undefined 分不清
|
|
1720
|
-
// 「没给这个键」与「给了 undefined」,而 forwarded 位的语义是前者。
|
|
1721
1109
|
const hasUpdatedInput = typeof rawBody === "object" && rawBody !== null && "updatedInput" in rawBody;
|
|
1722
1110
|
const taskId = askDecisionMatch[1];
|
|
1723
1111
|
const askId = askDecisionMatch[2];
|
|
1724
|
-
// 404 三处同文同码(行不存在 / taskId 不属 / 属主门不过)—— 不给存在性谕示是这条门的全部意义,
|
|
1725
|
-
// 三条臂只要文案分岔就等于把「它存在但不是你的」写在 wire 上(decide 面 parity)。
|
|
1726
1112
|
const askNotFound = () => sendError(res, 404, "not_found.ask", "no such approval ask under this task (never existed, belongs to another task, or is not yours)");
|
|
1727
1113
|
const row0 = await askStore.getAsk(askId);
|
|
1728
1114
|
if (!row0 || row0.taskId !== taskId) {
|
|
1729
1115
|
askNotFound();
|
|
1730
1116
|
return;
|
|
1731
1117
|
}
|
|
1732
|
-
// §2 v1 判据 = 行 owner 一致 ∨ 显式 operator。`owner === null`(auth-off 单用户)恒过。
|
|
1733
|
-
// 172 的 `task.approve` 独立动词候 center runtimeCaps 有位后再升级,v1 不 block(稿 §2)。
|
|
1734
1118
|
const ownerOk = row0.owner === null || (principal !== undefined && row0.owner === principal);
|
|
1735
1119
|
const operatorOk = explicitOperatorOk(principal, deps.config.operatorPrincipals);
|
|
1736
1120
|
if (!ownerOk && !operatorOk) {
|
|
1737
1121
|
askNotFound();
|
|
1738
1122
|
return;
|
|
1739
1123
|
}
|
|
1740
|
-
// CSRF:本仓无 cookie 面(身份恒来自头/凭证,浏览器不会自动附带)⇒ 不适用(§12-D F8 显式收案)。
|
|
1741
1124
|
const actor = {
|
|
1742
|
-
id: principal ?? "_",
|
|
1125
|
+
id: principal ?? "_",
|
|
1743
1126
|
...(body.actor ? { label: redactSecrets(body.actor.label).slice(0, ASK_ACTOR_LABEL_MAX) } : {}),
|
|
1744
|
-
verified: principal !== undefined,
|
|
1127
|
+
verified: principal !== undefined,
|
|
1745
1128
|
via: ownerOk ? "owner" : "operator",
|
|
1746
1129
|
};
|
|
1747
|
-
// 回放形与首决 200 形同键集,只差 `updatedInputForwarded`(它是本副本活体状态的函数,回放不承诺
|
|
1748
|
-
// 字节等同 —— §12-A 明写)。`actor` 从行上读出、safeParse 不过就省略(F12),不 500。
|
|
1749
|
-
/**
|
|
1750
|
-
* 🔴 见到一条 DECIDED 行 ⇒ **先把本副本活体窗同步到它**,再谈这次请求该拿 200 还是 409
|
|
1751
|
-
* (codex 交叉复审 round4 [high] + round5 [high],两轮真 finding,均红先验过)。
|
|
1752
|
-
*
|
|
1753
|
-
* round4 抓的是回放臂:提交歧义窗自己许诺的处置(带同一把键重试)会走 200 回放,而那只活体卡
|
|
1754
|
-
* 从没被结算过 —— run 一直等到窗到期,端点的头号承诺(「窗内回决 run 不停」)在它自己写下的恢复
|
|
1755
|
-
* 路径上不成立。round5 抓的是同一个洞的另一半:**无幂等键**的那次歧义之后,重试落的是 409 臂,
|
|
1756
|
-
* 那条臂当时同样不结算 —— 同一个 run 照样挂死;跨副本回决的本地输者也是这条臂。
|
|
1757
|
-
*
|
|
1758
|
-
* 收敛后的判据一句话:**响应分类不许决定本地是否结算**。行上有权威决议这件事,与「这次请求算
|
|
1759
|
-
* 重试还是算冲突」完全正交 —— 前者是事实,后者只是这次调用的礼节。
|
|
1760
|
-
*
|
|
1761
|
-
* 三条纪律:①用**行上的**决议,不是这次请求带的(权威是首决,B-2 已钉「异决重试回放首决」);
|
|
1762
|
-
* ②**不**转发这次请求的 `updatedInput`(它没有与幂等键一起持久绑定,转发等于让重试悄悄改写首决
|
|
1763
|
-
* 的载荷);③因此回放恒**省略** `updatedInputForwarded`。协调器缺席/查无条目都是正常(跨副本、
|
|
1764
|
-
* 窗已关),`notifyExternalDecision` 对已结算条目天然幂等(其顶注),故重复调用无害。
|
|
1765
|
-
*
|
|
1766
|
-
* 返回值 = 这行是否是一条**可当权威人决用**的 DECIDED 行。三个合取项(codex round5 [medium] +
|
|
1767
|
-
* round6 [high],两轮各补一半),缺任一项都**既不结算也不作答**,而是记不变量 + 500:
|
|
1768
|
-
* ① `decision ∈ {approve, deny}` —— 列可空且通用 `transitionAsk` 不强制 decision 载荷,DECIDED
|
|
1769
|
-
* 而无决议是可表示的坏行;把它当 200/409 交出去等于把持久层损坏包装成协议答案(契约是二值)。
|
|
1770
|
-
* ② `provisional === false` —— `AskRow.provisional` 的语义(§3.0 对账三约束②)就是「这条终态还可能
|
|
1771
|
-
* 被 `resolveProvisional` 版本化收敛掉」。**未定稿的终态不是终态**:照它 `notifyExternalDecision(true)`
|
|
1772
|
-
* 会在持久行还可能被补偿成 VOID 之前就放行一次不可逆工具调用 —— 结算是单向门,不可回收。
|
|
1773
|
-
* ③ `decidedAtMs !== null` —— 它是响应体与回放形的必备字段,也是「这条决议真的被 decideAsk 落过」
|
|
1774
|
-
* 的印记;为空说明这行不是走回决腿铸出来的。
|
|
1775
|
-
* 三者今天都没有在树上的写者会产生(人决恒走 `decideAsk`,它三项齐备),所以这是**fail-closed 的
|
|
1776
|
-
* 结构性守卫**,不是对某个已知 bug 的补丁 —— 判据是「可表示即须拒」,因为代价不对称:错拒 = 一次
|
|
1777
|
-
* 500,错认 = 一次不可逆放行。
|
|
1778
|
-
*/
|
|
1779
|
-
// 判据本身(纯守卫,不结算)—— **三个入口共用一份**:CAS 赢者、CAS 输者重读、幂等预查命中。
|
|
1780
|
-
// 🔴 三入口必须同守(codex round7 [high],真 finding,红先验过):此前只守了后两个,而 `decideAsk`
|
|
1781
|
-
// 的 CAS 谓词只看 `state='STREAM_PENDING'`、既不谓词也不清 `provisional` —— 一条 provisional 的
|
|
1782
|
-
// STREAM_PENDING 行赢下 CAS 就成了 provisional 的 DECIDED 行,赢者臂照样放行。守卫贴在「见到一条
|
|
1783
|
-
// DECIDED 行」这件事上,不贴在「怎么见到的」上,才没有第四个入口能绕过去。
|
|
1784
|
-
// 🔴 `state === "DECIDED"` 是第四个合取项(codex round10 [high]):赢者臂是唯一不先判 state 就调本
|
|
1785
|
-
// 谓词的入口,而 `decideAsk` 提交与回读之间那条行可能已被 `resolveProvisional` 版本化收敛走
|
|
1786
|
-
// (例如 provisional 的 DECIDED 被收敛成 VOID 却**留着** decision/decidedAtMs 元数据)——只验元数据
|
|
1787
|
-
// 会让那条已经不是 DECIDED 的行照样放行。谓词名叫「这条 DECIDED 行权威吗」,那就把 DECIDED 本身
|
|
1788
|
-
// 也验了,四个入口才真的同守。
|
|
1789
1130
|
const decidedRowIsAuthoritative = (row) => {
|
|
1790
1131
|
if (row.state !== "DECIDED" || row.decision !== "approve" && row.decision !== "deny" || row.provisional || row.decidedAtMs === null) {
|
|
1791
1132
|
deps.logger?.error?.("ask_decision_row_not_authoritative", {
|
|
@@ -1805,28 +1146,12 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1805
1146
|
if (!decidedRowIsAuthoritative(row))
|
|
1806
1147
|
return false;
|
|
1807
1148
|
const settled = deps.toolApproval?.notifyExternalDecision(row.askId, row.decision === "approve");
|
|
1808
|
-
// 🔴 A-054.15:**行派生**的 approve —— 第三形参 `updatedInput` 结构上取不到(行没有那一列),交出去的
|
|
1809
|
-
// 裸 `true` 会让 core 落回**原始未改写**实参执行。与 tool-approval.ts 的三个同形站点计同一个 tag
|
|
1810
|
-
// (选择性登记比不登记更坏);`settled > 0` 才算 —— 本地没有闭包被结算时什么都没执行,记了是假阳性。
|
|
1811
1149
|
if (row.decision === "approve" && (settled?.settled ?? 0) > 0) {
|
|
1812
1150
|
recordFailOpen("server.hitl.row-derived-approve-drops-updated-input", "runs:sync-live-from-decided-row");
|
|
1813
1151
|
}
|
|
1814
1152
|
return true;
|
|
1815
1153
|
};
|
|
1816
|
-
/**
|
|
1817
|
-
* #229(设计稿 233 稿B v2 §2):回决理由的**读面**投影 —— 三个回体共用这一份,行上有才发。
|
|
1818
|
-
*
|
|
1819
|
-
* 🔴 **裁定翻面,原样记账**:本 handler 原先明写「`note` 不回显(以请求者可见权限为界的最窄安全形)」。
|
|
1820
|
-
* 那条最窄形的实测代价是 `decisionNote` 全仓**只写不读** —— 一个零读面的「审计位」对任何按审计面
|
|
1821
|
-
* 接它的消费端都是当场落空。现行裁定:三回体一律 additive 投,**从不发 null / 空键**(缺席 = 这条
|
|
1822
|
-
* 决议没留理由,与 `updatedInputForwarded` 的「从不发 false」同族)。
|
|
1823
|
-
* 读权前提没有放宽:能走到这条口的调用方本来就有权决这只 ask(属主门在本 handler 上游),而 409 支
|
|
1824
|
-
* 回显的**首决**理由与它同支已经在回显的 `decision`/`decidedAtMs`/`actor` 是同一份首决投影 ——
|
|
1825
|
-
* 多这一格不新开任何一条越权读路径。
|
|
1826
|
-
*/
|
|
1827
1154
|
const decisionNoteEcho = (row) => (row.decisionNote === null ? {} : { decisionNote: row.decisionNote });
|
|
1828
|
-
/** 200 回放形:与首决 200 同键集,只差 `updatedInputForwarded`(§12-A 明写回放不承诺字节等同)。
|
|
1829
|
-
* 调用前提 = 已过 {@link syncLiveFromDecidedRow}(行合形且活体窗已同步)。 */
|
|
1830
1155
|
const replayDecided = (row) => {
|
|
1831
1156
|
const storedActor = projectDecisionActor(row.decisionActor);
|
|
1832
1157
|
sendJson(res, 200, {
|
|
@@ -1838,23 +1163,16 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1838
1163
|
...decisionNoteEcho(row),
|
|
1839
1164
|
});
|
|
1840
1165
|
};
|
|
1841
|
-
// 终局分派 —— **一律按传进来的这一行**投影,不重读(§12-A:`DecideResult.row` 已是新鲜行;
|
|
1842
|
-
// 补一次确认读只会引入第三个时点,让响应描述的行与 CAS 判定的行再次分家)。
|
|
1843
1166
|
const sendTerminal = (row) => {
|
|
1844
1167
|
switch (row.state) {
|
|
1845
1168
|
case "DECIDED": {
|
|
1846
|
-
// 结算**先于**响应分类(见 syncLiveFromDecidedRow 顶注:200 与 409 两臂对本地活体窗是同一件事)。
|
|
1847
1169
|
if (!syncLiveFromDecidedRow(row))
|
|
1848
|
-
return;
|
|
1849
|
-
// §12-A 顺序钉:**先**比幂等键。同 key 的两个并发请求都会 miss 上面的预查(那时谁都还没落键),
|
|
1850
|
-
// 输者重读时若不先比键,会把一次合法重试错报成 409。
|
|
1170
|
+
return;
|
|
1851
1171
|
if (body.idempotencyKey !== undefined && row.idempotencyKey === body.idempotencyKey) {
|
|
1852
1172
|
replayDecided(row);
|
|
1853
1173
|
return;
|
|
1854
1174
|
}
|
|
1855
1175
|
const first = projectDecisionActor(row.decisionActor);
|
|
1856
|
-
// 首决回显四件(#229 起含 `decisionNote`;翻面理由见 {@link decisionNoteEcho})——回显的恒是
|
|
1857
|
-
// **首决**那条理由,不是本次请求带来的那条(本次请求压根没落地)。
|
|
1858
1176
|
sendError(res, 409, "conflict.ask_decided", "this ask was already decided — the first decision stands and is echoed here", {
|
|
1859
1177
|
decision: row.decision,
|
|
1860
1178
|
decidedAtMs: row.decidedAtMs,
|
|
@@ -1864,8 +1182,6 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1864
1182
|
return;
|
|
1865
1183
|
}
|
|
1866
1184
|
case "PARKED":
|
|
1867
|
-
// §12-C:三件坐标,**不含 gateToken** —— checkpointToken 是从不外发的 resume 凭证
|
|
1868
|
-
// (approval-hmac 的既定不变量);壳按 sessionId 走现行 durable gate 流程。
|
|
1869
1185
|
sendError(res, 410, "gone.ask_parked", "the in-stream window closed and this ask was parked onto the durable approval gate — decide it through that gate for the session echoed here", {
|
|
1870
1186
|
sessionId: row.sessionId,
|
|
1871
1187
|
gateBoundCallId: row.gateBoundCallId,
|
|
@@ -1880,36 +1196,19 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1880
1196
|
});
|
|
1881
1197
|
return;
|
|
1882
1198
|
case "PARKING":
|
|
1883
|
-
// §12-C F44:PARKING 体**不含** gate 坐标(bindBatch 才写,此刻尚未存在)——只给轮询位置。
|
|
1884
1199
|
res.setHeader("retry-after", String(ASK_PARKING_RETRY_AFTER_SEC));
|
|
1885
1200
|
sendError(res, 425, "parking.ask_retry", "this ask is mid-park — retrying tells you where it finally landed; a retry does NOT queue this decision for acceptance", {
|
|
1886
1201
|
retryAfterSec: ASK_PARKING_RETRY_AFTER_SEC,
|
|
1887
1202
|
});
|
|
1888
1203
|
return;
|
|
1889
1204
|
default:
|
|
1890
|
-
// STREAM_PENDING 出现在这里 = 状态机转移表无回边却回去了(不变量破)。fail-loud 500 且
|
|
1891
|
-
// **禁重入 CAS**(§12-A F20):重入等于在一个已知不可信的状态上再赌一次。
|
|
1892
1205
|
deps.logger?.error?.("ask_decision_state_invariant", { taskId, askId, state: row.state });
|
|
1893
1206
|
sendError(res, 500, "internal.error", "ask decision hit an impossible row state (a CAS loser re-read a still-pending row) — no decision was recorded");
|
|
1894
1207
|
return;
|
|
1895
1208
|
}
|
|
1896
1209
|
};
|
|
1897
|
-
// 🔴 **观察到即验权 + 即同步**(codex 交叉复审 round8 [high],真 finding,红先验过)。
|
|
1898
|
-
//
|
|
1899
|
-
// 这是 round4/round5/round8 三轮同一族缺口的收口形。前两轮把结算分别补进了 200 回放臂和 409 冲突臂,
|
|
1900
|
-
// 但**跨 ask 撞键**那条臂在它们之前就 return 了:row0 明明已经是 DECIDED(这次请求已经看见了权威
|
|
1901
|
-
// 决议),却因为幂等键属于另一只 ask 而直接 409 走人 —— 本副本那只活体卡照样挂到窗到期。幂等预查
|
|
1902
|
-
// 本身抛错也同理(冒到外层 500,同样没同步)。
|
|
1903
|
-
//
|
|
1904
|
-
// 根治不是再补第三处调用,而是把这一步提到**观察到这行的那一刻**:凡这次请求手里有一条 DECIDED 行,
|
|
1905
|
-
// 先验它是不是权威决议、再把本地活体窗同步到它,**之后**才谈这次请求该拿 200 / 409 / 500。于是
|
|
1906
|
-
// 「响应分类不许决定本地是否结算」成为结构性成立,而不是靠每条臂各自记得调一次。
|
|
1907
|
-
// (下游 `sendTerminal` 的 DECIDED 臂仍会再调一次:`notifyExternalDecision` 对已结算条目幂等,
|
|
1908
|
-
// 重复调用是无操作 —— 宁可重复,不可遗漏。)
|
|
1909
1210
|
if (row0.state === "DECIDED" && !syncLiveFromDecidedRow(row0))
|
|
1910
1211
|
return;
|
|
1911
|
-
// ①幂等回放预查(§5-4 + §12-A):命中同 askId 且已 DECIDED ⇒ 直接回放,**不打第二次 CAS**;
|
|
1912
|
-
// 命中另一 askId ⇒ 409(跨 ask 回放会掩盖调用方的键管理缺陷,且 172「禁复用旧同意」排除该解,F23)。
|
|
1913
1212
|
if (body.idempotencyKey !== undefined) {
|
|
1914
1213
|
const hit = await askStore.getByIdempotencyKey(taskId, body.idempotencyKey);
|
|
1915
1214
|
if (hit && hit.askId !== askId) {
|
|
@@ -1920,7 +1219,7 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1920
1219
|
}
|
|
1921
1220
|
if (hit && hit.state === "DECIDED") {
|
|
1922
1221
|
if (!syncLiveFromDecidedRow(hit))
|
|
1923
|
-
return;
|
|
1222
|
+
return;
|
|
1924
1223
|
replayDecided(hit);
|
|
1925
1224
|
return;
|
|
1926
1225
|
}
|
|
@@ -1929,14 +1228,6 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1929
1228
|
sendTerminal(row0);
|
|
1930
1229
|
return;
|
|
1931
1230
|
}
|
|
1932
|
-
// 先读后分派是**优化**不是判权:CAS 仍是唯一权威,输者按它带回的新鲜行分派。
|
|
1933
|
-
//
|
|
1934
|
-
// 🔴 提交歧义窗(§12-A 的「行缺失 ⇒ 500 fail-loud + 日志注明」条款;codex 交叉复审 round2 抓获本
|
|
1935
|
-
// 实现漏了它):`SqlApprovalAskStore.decideAsk` 是「事务提交 → **另起**一次独立 getAsk 读」的形态
|
|
1936
|
-
// (approval-ask-store-sql.ts,commit() 之后那一行)。那次读若失败/读不到行,它抛 —— 而**决议此刻
|
|
1937
|
-
// 可能已经持久了**。裸让它冒到外层兜底 catch 会给调用方一个不带任何线索的 500:他既不知道该不该重
|
|
1938
|
-
// 试,不带幂等键重试还会撞 409(首决已在)。这里把它接住,fail-loud 但**把处置写进文案与日志**:
|
|
1939
|
-
// 带**同一个** idempotencyKey 重试 ⇒ 若决议真已落地,幂等回放路径会 200 把它原样交回。
|
|
1940
1231
|
let outcome;
|
|
1941
1232
|
try {
|
|
1942
1233
|
outcome = await askStore.decideAsk(askId, row0.batchId, {
|
|
@@ -1953,9 +1244,6 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1953
1244
|
hadIdempotencyKey: body.idempotencyKey !== undefined,
|
|
1954
1245
|
error: err instanceof Error ? err.message : String(err ?? ""),
|
|
1955
1246
|
});
|
|
1956
|
-
// 🔴 处置**按幂等键在场与否分岔**(codex round3 抓获,真 finding):无键的请求根本没有「同一把键」
|
|
1957
|
-
// 可重试,而一次盲重试撞上的是已决行(409),把「可能已批准」错读成「批准冲突」。无键臂如实说
|
|
1958
|
-
// 它做不到,并指出下次怎么做得到 —— 承诺一个不存在的处置比不给处置更坏(过期声明族)。
|
|
1959
1247
|
if (body.idempotencyKey !== undefined) {
|
|
1960
1248
|
sendError(res, 500, "internal.error", "the ask decision store failed around commit — the decision MAY already be persisted; retry with the same idempotencyKey to replay it instead of colliding with it");
|
|
1961
1249
|
}
|
|
@@ -1965,12 +1253,6 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1965
1253
|
return;
|
|
1966
1254
|
}
|
|
1967
1255
|
if (!outcome.ok) {
|
|
1968
|
-
// 🔴 同一条纪律施于**输者带回的行**(codex 交叉复审 round9 [high],真 finding,红先验过):
|
|
1969
|
-
// `idempotency_conflict` 臂原先直接 409、把 `outcome.row` 丢掉,而店契约允许该臂带回目标行,
|
|
1970
|
-
// SQL twin 更是在**回滚之后**才读它 —— 那一刻它完全可能已经是别人决出来的 DECIDED。于是又一条
|
|
1971
|
-
// 「已经看见权威决议却不结算」的臂(活体窗挂到窗到期),外加「非权威行被撞键 409 掩盖」。
|
|
1972
|
-
// 放在 reason 分派**之前** ⇒ 三个 reason 一视同仁,不靠每条臂各自记得。至此本 handler 能拿到
|
|
1973
|
-
// AskRow 的**全部四个来源**(row0 / 幂等预查命中 / CAS 赢者行 / CAS 输者行)都在守卫下游。
|
|
1974
1256
|
if (outcome.row?.state === "DECIDED" && !syncLiveFromDecidedRow(outcome.row))
|
|
1975
1257
|
return;
|
|
1976
1258
|
if (outcome.reason === "idempotency_conflict") {
|
|
@@ -1980,33 +1262,21 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
1980
1262
|
return;
|
|
1981
1263
|
}
|
|
1982
1264
|
if (outcome.row === null) {
|
|
1983
|
-
askNotFound();
|
|
1265
|
+
askNotFound();
|
|
1984
1266
|
return;
|
|
1985
1267
|
}
|
|
1986
1268
|
sendTerminal(outcome.row);
|
|
1987
1269
|
return;
|
|
1988
1270
|
}
|
|
1989
|
-
// 赢下 CAS **不等于**这行就能放行(codex round7 [high]):`decideAsk` 的谓词只看 `state`,不看也不清
|
|
1990
|
-
// `provisional` —— 赢者臂同样要过那一份权威守卫,否则一条 provisional 行会从这个入口绕过去(结算是
|
|
1991
|
-
// 单向门,而 `resolveProvisional` 事后还可能把它补偿成 VOID)。守卫在**结算之前**。
|
|
1992
1271
|
if (!decidedRowIsAuthoritative(outcome.row))
|
|
1993
1272
|
return;
|
|
1994
|
-
// 🔴 结算的**每一个入参都取自行**,不取自请求(codex round10 [high]):赢者臂原先用 `askId` 与
|
|
1995
|
-
// `body.decision` 结算 —— 那是本 handler 最后一处「按请求而非按行」的地方。提交与回读之间那条行
|
|
1996
|
-
// 可能已被版本化收敛改写,此时按请求结算 = 放行一个与持久真相相反的决议(结算不可回收)。
|
|
1997
1273
|
if (outcome.row.decision !== body.decision) {
|
|
1998
|
-
// 行与本次请求分岔:本地仍按**行**收敛(活体窗恒随持久真相走,哪怕代价是拒),HTTP 侧 fail-loud
|
|
1999
|
-
// —— 调用方提交的那个决议不是最终生效的那个,让他重读而不是以为自己批了。
|
|
2000
1274
|
deps.logger?.error?.("ask_decision_row_diverged", { taskId, askId, requested: body.decision, stored: outcome.row.decision });
|
|
2001
1275
|
deps.toolApproval?.notifyExternalDecision(outcome.row.askId, outcome.row.decision === "approve");
|
|
2002
1276
|
sendError(res, 500, "internal.error", "the ask row was rewritten between commit and read-back — the decision that stands is not the one this request submitted; re-read the ask before acting on it");
|
|
2003
1277
|
return;
|
|
2004
1278
|
}
|
|
2005
|
-
// 端点已是唯一权威。协调器缺席(TOOL_APPROVAL_ENABLED 关而 askStore 在场)⇒ 联动步
|
|
2006
|
-
// optional 跳过(§12-F F31):没有活体窗要同步,持久终局已经落定。
|
|
2007
1279
|
const settled = deps.toolApproval?.notifyExternalDecision(outcome.row.askId, outcome.row.decision === "approve", hasUpdatedInput ? body.updatedInput : undefined).settled ?? 0;
|
|
2008
|
-
// `updatedInputForwarded` 只在**真结算**时发 `true`,其余一律**省略**(从不发 false,照 finishRespond
|
|
2009
|
-
// 先例,§12-F)。它是「本副本活体状态」的函数,故幂等回放不承诺字节等同(§12-A)。
|
|
2010
1280
|
const forwarded = outcome.row.decision === "approve" && hasUpdatedInput && settled > 0;
|
|
2011
1281
|
sendJson(res, 200, {
|
|
2012
1282
|
askId: outcome.row.askId,
|
|
@@ -2015,52 +1285,22 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
|
|
|
2015
1285
|
decidedAtMs: outcome.row.decidedAtMs,
|
|
2016
1286
|
actor,
|
|
2017
1287
|
...(forwarded ? { updatedInputForwarded: true } : {}),
|
|
2018
|
-
// #229:同上,**按行**投(不按 `body.note`)——赢者行才是这次回决的真相,而 `decideAsk` 的提交与
|
|
2019
|
-
// 回读之间那条行仍可能被版本化收敛改写(同一条「每个入参都取自行」的纪律)。
|
|
2020
1288
|
...decisionNoteEcho(outcome.row),
|
|
2021
1289
|
});
|
|
2022
1290
|
return;
|
|
2023
1291
|
}
|
|
2024
1292
|
miss.fell = true;
|
|
2025
1293
|
}
|
|
2026
|
-
|
|
2027
|
-
* DESIGN-269 车2 件2 —— `POST /v1/runs` 的**建行 + 驱动**段,从路由体里原样搬出来的具名腿。
|
|
2028
|
-
*
|
|
2029
|
-
* 🔴 为什么提取:A2A 的 `message/send`(routes/a2a-serve.ts)必须走**同一条**提交链 —— 第二条提交
|
|
2030
|
-
* 路径就是第二个写者,而这条链上挂着 durable taskId 幂等重放、lease/治理窗 pre-admission、
|
|
2031
|
-
* `createRun` 的会话 claim、checkpoint ctx 落库、fleet 行发布、resume/rewind 锚点捕获、审批 ALS……
|
|
2032
|
-
* 手抄一份就是把这十来件事复制成两份会漂的实现(而漂开的那天没有任何东西会说话)。
|
|
2033
|
-
*
|
|
2034
|
-
* **纯搬运**:函数体逐字来自搬走前的路由体(唯一改动=统一去缩进两格 + 三个「已应答」的裸 `return;`
|
|
2035
|
-
* 写成 `return null;`,语义一字未变 —— 调用方拿到 `null` 就是「腿已经自己应答了」)。
|
|
2036
|
-
*
|
|
2037
|
-
* 契约:
|
|
2038
|
-
* · 返回 `null` = **已应答**(400 `body.taskId` 坏形 / 409 foreign owner / 202 durable 重放 /
|
|
2039
|
-
* lease·治理窗拒),调用方立即 return,不得再写响应体。
|
|
2040
|
-
* · 返回 `{status, body}` = 本次提交的收据(202 = 新行已建并在跑;409 = 会话占用冲突,**没有**建行)。
|
|
2041
|
-
* 调用方自己决定怎么发(HTTP 路由 `sendJson`;A2A 腿翻成协议对象或具名 RPC 错误)。
|
|
2042
|
-
* · 调用前必须先过 `prepareSpec`(本腿吃它的产物)与 `deps.runStore` 在场判据 —— 后者本腿内另有
|
|
2043
|
-
* 防御性复检(缺席 ⇒ 501),但两个调用方都在自己的入口先判过,那才是各自面上的正确错误形。
|
|
2044
|
-
*/
|
|
2045
|
-
export async function createDurableRun(req, res, ctx, prepared,
|
|
2046
|
-
/** in-memory 幂等键(`POST /v1/runs` = `Idempotency-Key` 头派生;A2A = `message.messageId` 派生)。
|
|
2047
|
-
* undefined = 本次提交不参与 in-memory 去重(durable `body.taskId` 重放是独立的第二道)。 */
|
|
2048
|
-
idemKey) {
|
|
1294
|
+
export async function createDurableRun(req, res, ctx, prepared, idemKey) {
|
|
2049
1295
|
const { deps } = ctx;
|
|
2050
1296
|
const { idemCache, inflightRuns, preemptableRuns, steerableRuns } = ctx.registry;
|
|
2051
1297
|
const { leaseDenied, usageWindowDenied } = ctx.helpers;
|
|
2052
1298
|
const source = ctx.req.source;
|
|
2053
1299
|
if (!deps.runStore) {
|
|
2054
|
-
// 防御性复检:两个调用方都在自己的入口先判过(各自面上的正确错误形不同),走到这里说明有人漏判。
|
|
2055
1300
|
sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg|local)");
|
|
2056
1301
|
return null;
|
|
2057
1302
|
}
|
|
2058
1303
|
const runStore = deps.runStore;
|
|
2059
|
-
// dispatch-gateway failover prerequisite: OPTIONAL caller-minted `body.taskId` idempotency. Unlike the
|
|
2060
|
-
// Idempotency-Key header (in-memory, per-instance), this replay reads the DURABLE run store, so a gateway that
|
|
2061
|
-
// re-routes a submit to ANOTHER instance after a network error cannot start a second run. uuidv7-validated
|
|
2062
|
-
// (same alphabet the server mints — a caller can't inject an arbitrary PK shape); owner-gated replay mirrors
|
|
2063
|
-
// the GET /v1/runs/:id gate (a foreign taskId reads as 404-shaped conflict, not another tenant's run ids).
|
|
2064
1304
|
const rawClientTaskId = prepared.body.taskId;
|
|
2065
1305
|
if (rawClientTaskId !== undefined && (typeof rawClientTaskId !== "string" || !isUuidV7(rawClientTaskId))) {
|
|
2066
1306
|
sendError(res, 400, "request.id_invalid", "body.taskId must be a uuidv7 string (caller-minted idempotency key)");
|
|
@@ -2072,50 +1312,31 @@ idemKey) {
|
|
|
2072
1312
|
if (existing) {
|
|
2073
1313
|
const verified = verifiedPrincipal(req, deps.config);
|
|
2074
1314
|
if (existing.owner !== null && existing.owner !== verified) {
|
|
2075
|
-
sendError(res, 409, "conflict.run_exists", "taskId already exists");
|
|
1315
|
+
sendError(res, 409, "conflict.run_exists", "taskId already exists");
|
|
2076
1316
|
return null;
|
|
2077
1317
|
}
|
|
2078
|
-
// 🔴 codex 轮 H2b 随修:这一臂原本自己 `sendJson(202)` 然后 return null(= 已应答)。搬成具名腿
|
|
2079
|
-
// 之后那个姿势有第二个调用方了 —— A2A 腿拿到 null 只能沉默,而**响应已经以裸 HTTP 202 发出去**,
|
|
2080
|
-
// 对一个 JSON-RPC 调用方就是一帧协议外的垃圾。改成**返回收据**:`/v1/runs` 那侧照旧
|
|
2081
|
-
// `sendJson(res, resp.status, resp.body)`,逐字节同形;A2A 那侧把同一份收据渲成协议对象。
|
|
2082
|
-
// 判词:腿只产收据,**协议渲染归调用方** —— 一条腿服务两种线协议时,这是唯一不撒谎的分工。
|
|
2083
1318
|
return { status: 202, body: { taskId: existing.taskId, sessionId: existing.sessionId, status: existing.status } };
|
|
2084
1319
|
}
|
|
2085
1320
|
}
|
|
2086
|
-
// 🔴 复审 C1:lease gate for a genuinely-NEW submission (past the idempotency replay above). Keyed on the
|
|
2087
|
-
// VERIFIED submitter = the principal billed for this run's spend (correct principal for the sync door).
|
|
2088
|
-
// design/166-T1:治理窗 pre-admission 同位(idempotency replay 之后——重放已建 run 必须拿回 202 收据,
|
|
2089
|
-
// 不得因窗满改判 429;异步 202 车道的唯一 429 出路,core 进场拒是竞态窗兜底)。
|
|
2090
1321
|
if ((await leaseDenied(req, res)) || (await usageWindowDenied(req, res)))
|
|
2091
1322
|
return null;
|
|
2092
1323
|
const resp = await idemCache.run(idemKey, async () => {
|
|
2093
1324
|
const sessionId = prepared.spec.sessionId ?? uuidv7();
|
|
2094
1325
|
const taskId = clientTaskId ?? uuidv7();
|
|
2095
1326
|
const created = await runStore.createRun(taskId, sessionId, prepared.auth?.principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
|
|
2096
|
-
// #245 S1(codex S1-F2):新 run 行落库即清同 taskId 的旧世代活性残留——session purge 后客户端合法
|
|
2097
|
-
// 复用自带 taskId 重提交时,旧记录会被读成「刚有活性」的假证据(map 无 generation 轴,清是唯一诚实解)。
|
|
2098
1327
|
if (created.ok)
|
|
2099
1328
|
clearTurnActivity(taskId);
|
|
2100
1329
|
if (created.ok)
|
|
2101
|
-
deps.sessionTitler?.maybeTitle(sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id);
|
|
1330
|
+
deps.sessionTitler?.maybeTitle(sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id);
|
|
2102
1331
|
if (!created.ok) {
|
|
2103
|
-
// race twin: two concurrent same-taskId submits (gateway retry racing the original) — the loser's
|
|
2104
|
-
// session claim collides with ITS OWN taskId → idempotent 202 replay, not a 409 (the run exists, once).
|
|
2105
1332
|
if (clientTaskId && created.activeTaskId === clientTaskId) {
|
|
2106
1333
|
return { status: 202, body: { taskId: clientTaskId, sessionId, status: "running" } };
|
|
2107
1334
|
}
|
|
2108
|
-
// [2255]① 真出路材料:activeTaskStatus + parked 时 pendingGate{kind,decidePath}(best-effort,失败退旧形)
|
|
2109
1335
|
return { status: 409, body: await buildActiveRunConflict({ runStore, checkpointStore: deps.checkpointStore, governance: deps.config, runStaleSec: deps.config.runStaleSec, turnActivity: readTurnActivityMs }, sessionId, created.activeTaskId) };
|
|
2110
1336
|
}
|
|
2111
|
-
// Durable F4: persist the resume rebuild inputs (sessionId-keyed) so an operator can resume from any
|
|
2112
|
-
// replica even after this worker is gone — core's checkpoint blob can't carry service scenario context.
|
|
2113
1337
|
if (deps.checkpointStore) {
|
|
2114
1338
|
await deps.checkpointStore.putCtx(sessionId, { body: prepared.body, memoryScope: prepared.auth?.memoryScope });
|
|
2115
1339
|
}
|
|
2116
|
-
// E18 resume-at: capture each completed turn's (message eventId → leaf entryId) anchor so the shell can later
|
|
2117
|
-
// rewind to that message. Closes over THIS run's sessionId/owner + the session store's getLeafId + the anchor
|
|
2118
|
-
// store. undefined ⇒ no anchor store / no getLeafId (the env-only deploy) → resume-at simply isn't captured.
|
|
2119
1340
|
const anchorOwner = prepared.auth?.principal ?? null;
|
|
2120
1341
|
const getLeafId = deps.sessionStorage?.getLeafId?.bind(deps.sessionStorage);
|
|
2121
1342
|
const captureTurnAnchor = deps.resumeAnchorStore && getLeafId
|
|
@@ -2125,44 +1346,16 @@ idemKey) {
|
|
|
2125
1346
|
await deps.resumeAnchorStore.put(sessionId, eventId, leaf, anchorOwner);
|
|
2126
1347
|
}
|
|
2127
1348
|
: undefined;
|
|
2128
|
-
// R8 (CC-parity rewind): the USER-message anchor — keyed by THIS run's taskId (the handle the shell holds
|
|
2129
|
-
// from the 202 response), value = the user-message `entryId` straight from `message_committed{role:"user"}`
|
|
2130
|
-
// (always a valid resumeAt target). No `getLeafId` (the entryId is on the event), so it works even on a
|
|
2131
|
-
// store that lacks getLeafId. This is the "rewind to the prompt" target = the code-restore parity path.
|
|
2132
1349
|
const captureUserMessageAnchor = deps.resumeAnchorStore
|
|
2133
1350
|
? async (entryId) => { await deps.resumeAnchorStore.put(sessionId, taskId, entryId, anchorOwner); }
|
|
2134
1351
|
: undefined;
|
|
2135
|
-
// MF-Fleet: a background (202) run is a durable run → it gets a live fleet row exactly like the sync leg.
|
|
2136
|
-
// runInBackground fires onStart/onEvent/onTerminal across ALL its legs (verify/cascade/plain-stream). No-op
|
|
2137
|
-
// when no fleetBus is wired (the publisher is a null-object).
|
|
2138
1352
|
const fleetPub = fleetRunPublisher(deps.fleetBus, {
|
|
2139
1353
|
runId: taskId,
|
|
2140
|
-
// 🔒 Scope the fleet row with the SAME identity GET /v1/fleet/stream FILTERS by — `gatedPrincipal`
|
|
2141
|
-
// (crypto-verified on a direct door), NOT `prepared.auth.principal` (= the spoofable header). On a direct
|
|
2142
|
-
// door the two diverge: a spoofed header tags the row under a victim's scope (cross-tenant leak) or under
|
|
2143
|
-
// "default" while the verified owner's stream filters it OUT (the row vanishes). TOC review #6.
|
|
2144
1354
|
scope: gatedPrincipal(req, deps.config) ?? "default",
|
|
2145
|
-
// core 1.154 nested-subagent tree: rootTaskId = the run's CANONICAL core taskId (`spec.taskId ?? sessionId`).
|
|
2146
|
-
// The background leg sets `spec.taskId = taskId` (runs.ts), so canonical == taskId == runId — a forwarded
|
|
2147
|
-
// subagent whose `parentTaskId` equals this nests under the run; deeper ones nest under their parent subagent.
|
|
2148
1355
|
rootTaskId: taskId,
|
|
2149
|
-
...fleetRunLabels(prepared.spec.objective),
|
|
1356
|
+
...fleetRunLabels(prepared.spec.objective),
|
|
2150
1357
|
});
|
|
2151
|
-
void runInBackground(prepared.runner,
|
|
2152
|
-
{ ...prepared.spec, sessionId }, runStore, taskId, deps.metrics, prepared.auth?.principal, prepared.verify, prepared.cascade ? cascadeConfig(deps.config.cascadeLadder, prepared.spec.limits?.maxCostUsd) : undefined, deps.instrumentDegenerate, deps.planCacheProbe, deps.config.traceThinking, inflightRuns, preemptableRuns, steerableRuns, deps.modelUsage, deps.elicitation, // E23: per-run elicitation context (onElicit routes inbound MCP elicitations to this run's stream)
|
|
2153
|
-
gatedPrincipal(req, deps.config) ?? null, // E23: the VERIFIED principal that may answer (same source the respond gate uses — never the spoofable header)
|
|
2154
|
-
captureTurnAnchor, // E18: per-turn (message eventId → leaf entryId) anchor capture
|
|
2155
|
-
fleetPub, // MF-Fleet: the run-scoped fleet-row publisher (onStart/onEvent/onTerminal across all legs)
|
|
2156
|
-
captureUserMessageAnchor, // R8: per-prompt (taskId → user-message entryId) anchor — CC "rewind to the prompt"
|
|
2157
|
-
deps.question, // §4④: per-run AskUserQuestion context (onQuestion routes the agent's asks to this run's stream)
|
|
2158
|
-
deps.workflowCompletionInbox, // P1 ①② follow-on: drain pending workflow completions into this leg's durable log at start
|
|
2159
|
-
deps.subagentSteerRegistry, // C2 (core 1.219): register sync-delegation steer handles under this run's taskId
|
|
2160
|
-
(m, x) => deps.logger?.info?.(m, x), // emit-target diagnostics for the bg leg's inbox drain
|
|
2161
|
-
deps.sendUserFile, // SendUserFile 切片2:file_link 帧走本腿 durable append
|
|
2162
|
-
deps.promptManifests, // [998]②: drained into a durable prompt_assembled event by this leg
|
|
2163
|
-
// #151 车3 刀 3b:bg 腿的审批 ALS(此前这条腿无 ctx ⇒ 每只 ask 恒 "unavailable" ⇒ 恒 park)。
|
|
2164
|
-
// 协议上场判据在**路由层**求值(单一谓词),执行腿只消费布尔——runs.ts 是纯执行面,不读 backend/config。
|
|
2165
|
-
deps.toolApproval
|
|
1358
|
+
void runInBackground(prepared.runner, { ...prepared.spec, sessionId }, runStore, taskId, deps.metrics, prepared.auth?.principal, prepared.verify, prepared.cascade ? cascadeConfig(deps.config.cascadeLadder, prepared.spec.limits?.maxCostUsd) : undefined, deps.instrumentDegenerate, deps.planCacheProbe, deps.config.traceThinking, inflightRuns, preemptableRuns, steerableRuns, deps.modelUsage, deps.elicitation, gatedPrincipal(req, deps.config) ?? null, captureTurnAnchor, fleetPub, captureUserMessageAnchor, deps.question, deps.workflowCompletionInbox, deps.subagentSteerRegistry, (m, x) => deps.logger?.info?.(m, x), deps.sendUserFile, deps.promptManifests, deps.toolApproval
|
|
2166
1359
|
? {
|
|
2167
1360
|
coordinator: deps.toolApproval,
|
|
2168
1361
|
streamApprovalOn: resolveStreamApprovalGate({
|
|
@@ -2171,14 +1364,12 @@ idemKey) {
|
|
|
2171
1364
|
backend: deps.backend,
|
|
2172
1365
|
parkFacility: deps.checkpointStore !== undefined,
|
|
2173
1366
|
}).active,
|
|
2174
|
-
// 两个窗旋钮随传:窗=0 的三源判定要在**本腿开始执行的时刻**做(那是 runInBackground 内部),
|
|
2175
|
-
// 路由层只负责把配置递过去 —— runs.ts 是纯执行面,不读 config。
|
|
2176
1367
|
windowMs: deps.config.streamApproval?.windowMs ?? 0,
|
|
2177
1368
|
windowMarginMs: deps.config.streamAskWindowMarginMs,
|
|
2178
1369
|
}
|
|
2179
1370
|
: undefined, deps.checkpointStore);
|
|
2180
1371
|
return { status: 202, body: { taskId, sessionId, status: "running" } };
|
|
2181
|
-
}, (r) => r.status === 202);
|
|
1372
|
+
}, (r) => r.status === 202);
|
|
2182
1373
|
return resp;
|
|
2183
1374
|
}
|
|
2184
1375
|
//# sourceMappingURL=runs.js.map
|