@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.
Files changed (345) hide show
  1. package/USAGE.md +18 -0
  2. package/dist/adoption/plan.js +31 -192
  3. package/dist/adoption/quiesce.js +3 -86
  4. package/dist/adoption/runner.js +3 -140
  5. package/dist/adoption/sql.js +0 -74
  6. package/dist/adoption/wire.js +0 -80
  7. package/dist/approval-ask-machine.js +0 -75
  8. package/dist/approval-card.js +0 -323
  9. package/dist/approval-deny-reasons.js +0 -42
  10. package/dist/approval-hmac.js +0 -35
  11. package/dist/approval-reconciler.js +8 -166
  12. package/dist/approval.js +4 -67
  13. package/dist/audit.js +1 -44
  14. package/dist/auth-bridge.js +4 -47
  15. package/dist/auth-keys.js +0 -23
  16. package/dist/bake-runner/main.js +4 -65
  17. package/dist/bake-runner/protocol.js +3 -58
  18. package/dist/bake-runner/runner.js +5 -91
  19. package/dist/bench/l8/artifact.js +2 -61
  20. package/dist/bench/l8/escape.js +0 -25
  21. package/dist/bench/l8/index.js +0 -14
  22. package/dist/bench/l8/probes.js +0 -60
  23. package/dist/bench/l8/run-probes.js +2 -45
  24. package/dist/bench/s1/arms.js +14 -189
  25. package/dist/bench/s1/live-deps.js +11 -253
  26. package/dist/bench/s1/oracle.js +1 -36
  27. package/dist/bench/s1/repair-oracle-adapter.js +0 -30
  28. package/dist/bench/s1/reviewer.js +0 -34
  29. package/dist/bench/s1/row.js +0 -62
  30. package/dist/bench/s1/run-firm.js +4 -70
  31. package/dist/bench/s1/runner-ctx.js +0 -40
  32. package/dist/bench/s1/tasks.js +0 -115
  33. package/dist/boot/adoption.js +0 -20
  34. package/dist/boot/budget-tracing.js +3 -55
  35. package/dist/boot/config-center.js +43 -551
  36. package/dist/boot/coordinators.js +1 -69
  37. package/dist/boot/crash-last.js +0 -18
  38. package/dist/boot/deferred-sandbox-path-env.js +3 -119
  39. package/dist/boot/execution-env.js +5 -171
  40. package/dist/boot/governance-seams.js +0 -92
  41. package/dist/boot/leader.js +0 -69
  42. package/dist/boot/limit-sync.js +0 -2
  43. package/dist/boot/memory-boundary.js +3 -91
  44. package/dist/boot/org-memory.js +1 -25
  45. package/dist/boot/parked-revive-gate.js +0 -150
  46. package/dist/boot/permission-rules-audit.js +1 -56
  47. package/dist/boot/reapers.js +15 -273
  48. package/dist/boot/resolve-spec.js +9 -768
  49. package/dist/boot/retention-lane.js +4 -87
  50. package/dist/boot/runner-deps.js +3 -297
  51. package/dist/boot/runtime-caps.js +0 -40
  52. package/dist/boot/session-faces.js +2 -133
  53. package/dist/boot/shutdown.js +9 -83
  54. package/dist/boot/side-query-lane.js +2 -137
  55. package/dist/boot/stores.js +15 -215
  56. package/dist/boot/task-list-lane.js +0 -18
  57. package/dist/boot/webfetch-summarize-lane.js +0 -47
  58. package/dist/boot/workflow-orchestration.js +3 -99
  59. package/dist/boot-reclaim.js +0 -20
  60. package/dist/bounded-session-map.js +0 -19
  61. package/dist/brain.js +2 -139
  62. package/dist/budget.js +9 -244
  63. package/dist/capabilities/builtin-tools.js +0 -3
  64. package/dist/capabilities/center-plugins.js +6 -51
  65. package/dist/capabilities/center-prompts.js +6 -71
  66. package/dist/capabilities/code-review-council.js +7 -40
  67. package/dist/capabilities/collab-workflows.js +1 -44
  68. package/dist/capabilities/hands-lane.js +0 -65
  69. package/dist/capabilities/memory-notice.js +0 -70
  70. package/dist/capabilities/prompt.js +0 -14
  71. package/dist/capabilities/prompts/code-review.js +0 -14
  72. package/dist/capabilities/prompts/identity.js +0 -6
  73. package/dist/capabilities/prompts/team.js +0 -4
  74. package/dist/capabilities/repo-tools.js +0 -34
  75. package/dist/capabilities/sandbox-file-send.js +3 -92
  76. package/dist/capabilities/scenarios.d.ts +0 -1
  77. package/dist/capabilities/scenarios.js +18 -279
  78. package/dist/capabilities/select-environment-tool.js +0 -32
  79. package/dist/capabilities/send-user-file-tool.js +2 -66
  80. package/dist/capabilities/skills.d.ts +1 -2
  81. package/dist/capabilities/skills.js +3 -21
  82. package/dist/capabilities/team.d.ts +3 -11
  83. package/dist/capabilities/team.js +3 -51
  84. package/dist/capabilities/tool-defer.js +0 -3
  85. package/dist/config-center/apply-effective.js +15 -330
  86. package/dist/config-center/apply-ledger.js +5 -38
  87. package/dist/config-center/facade.js +0 -41
  88. package/dist/config-center/hot-keys-registry.js +0 -20
  89. package/dist/config-center/http-client.js +2 -125
  90. package/dist/config-center/mcp-revocation.js +2 -34
  91. package/dist/config-center/read-face.js +0 -59
  92. package/dist/config-center/restart-signal.js +1 -85
  93. package/dist/config-center/skills-mcp.d.ts +1 -1
  94. package/dist/config-center/skills-mcp.js +9 -76
  95. package/dist/config-center/stage-limits.js +8 -35
  96. package/dist/config-invariants.js +0 -16
  97. package/dist/config-lkg.js +0 -42
  98. package/dist/config-provider.js +3 -186
  99. package/dist/config-types.js +0 -5
  100. package/dist/config.js +107 -1145
  101. package/dist/degenerate-instrument.js +3 -67
  102. package/dist/deployment-governance.js +0 -124
  103. package/dist/digest-form.js +0 -12
  104. package/dist/elicitation.js +3 -86
  105. package/dist/env-facts.js +7 -75
  106. package/dist/fleet/fleet-bus.js +34 -507
  107. package/dist/fleet/fleet-reconciler.js +9 -149
  108. package/dist/fleet/fleet-terminal-window.js +10 -178
  109. package/dist/fleet/subagent-tail-bus.js +3 -72
  110. package/dist/fleet-client.js +10 -70
  111. package/dist/fleet-lease.js +5 -79
  112. package/dist/git-api-kind.js +0 -3
  113. package/dist/governance-ask-marks.js +2 -78
  114. package/dist/hooks/branch-transcript.js +0 -74
  115. package/dist/hooks/cc-agent-hook-prompt.js +0 -29
  116. package/dist/hooks/cc-stop-prompt.js +1 -46
  117. package/dist/hooks/hook-llm.js +1 -53
  118. package/dist/hooks/hook-runner.js +20 -414
  119. package/dist/http/active-run-conflict.js +4 -129
  120. package/dist/http/cursor-fingerprint.d.ts +5 -0
  121. package/dist/http/cursor-fingerprint.js +5 -0
  122. package/dist/http/idempotency.js +0 -37
  123. package/dist/http/principal-gate.js +3 -40
  124. package/dist/http/route-ctx.js +0 -9
  125. package/dist/http/routes/a2a-serve.js +7 -319
  126. package/dist/http/routes/admin-config-refresh.js +0 -4
  127. package/dist/http/routes/admin-drain.js +0 -10
  128. package/dist/http/routes/adoption.js +1 -29
  129. package/dist/http/routes/agents-roster.js +1 -47
  130. package/dist/http/routes/approvals-assistant.js +32 -395
  131. package/dist/http/routes/attachments.js +4 -22
  132. package/dist/http/routes/capabilities.js +3 -420
  133. package/dist/http/routes/diagnostics.js +1 -81
  134. package/dist/http/routes/fleet.js +9 -185
  135. package/dist/http/routes/images.js +14 -239
  136. package/dist/http/routes/leader.js +0 -13
  137. package/dist/http/routes/memory-bundle.js +1 -61
  138. package/dist/http/routes/memory-policy.js +9 -99
  139. package/dist/http/routes/notify-wake.js +3 -37
  140. package/dist/http/routes/observability.js +2 -19
  141. package/dist/http/routes/retention-ops.js +2 -34
  142. package/dist/http/routes/rules.js +1 -93
  143. package/dist/http/routes/runs.js +64 -873
  144. package/dist/http/routes/session-sync.js +19 -258
  145. package/dist/http/routes/sessions-list.js +17 -43
  146. package/dist/http/routes/sessions.js +34 -226
  147. package/dist/http/routes/shared-memory.js +5 -36
  148. package/dist/http/routes/side-query.js +1 -87
  149. package/dist/http/routes/tasks.js +52 -722
  150. package/dist/http/routes/trace-usage.js +32 -195
  151. package/dist/http/routes/workflows.js +19 -195
  152. package/dist/http/run-meta.js +0 -6
  153. package/dist/http/send.js +0 -32
  154. package/dist/http/server.js +92 -1597
  155. package/dist/http/sse-lifecycle.js +2 -13
  156. package/dist/http/sse-log.js +3 -48
  157. package/dist/http/tar.js +5 -21
  158. package/dist/http/verify-rounds.js +0 -5
  159. package/dist/http/wire-gate.js +0 -9
  160. package/dist/http/workspace-content.js +0 -10
  161. package/dist/images/bake-validate.js +1 -70
  162. package/dist/images/manifest.js +1 -6
  163. package/dist/index.js +0 -21
  164. package/dist/key-resolver.js +2 -17
  165. package/dist/leader/diffout.js +1 -20
  166. package/dist/leader/diffup.js +0 -47
  167. package/dist/leader/endpoint.js +2 -57
  168. package/dist/leader/fanout.js +3 -45
  169. package/dist/leader/grader-env-factory.js +3 -72
  170. package/dist/leader/leader.js +5 -156
  171. package/dist/leader/merge.js +8 -115
  172. package/dist/leader/planner.js +3 -54
  173. package/dist/leader/repair-oracle.js +1 -60
  174. package/dist/leader/repair-wire.js +2 -79
  175. package/dist/leader/wire.js +8 -307
  176. package/dist/lsp/e2b-bridge.js +4 -64
  177. package/dist/lsp/e2b-manager.js +6 -94
  178. package/dist/lsp/lsp-frames.js +0 -12
  179. package/dist/lsp/manager.js +4 -96
  180. package/dist/lsp/ws-transport.js +5 -55
  181. package/dist/lsp-evict.js +1 -15
  182. package/dist/main.js +57 -765
  183. package/dist/memory-bundle-engine.js +0 -55
  184. package/dist/memory-export.js +0 -4
  185. package/dist/memory-posture.js +1 -15
  186. package/dist/memory-scope.js +11 -148
  187. package/dist/memory-sync-client.js +2 -44
  188. package/dist/memory-sync.js +1 -80
  189. package/dist/model-select.js +3 -80
  190. package/dist/observability/cost-quota.js +1 -17
  191. package/dist/observability/cost-taxonomy.js +0 -34
  192. package/dist/observability/fail-open.js +7 -86
  193. package/dist/observability/logger.js +0 -6
  194. package/dist/observability/metrics.js +0 -94
  195. package/dist/observability/otel-exporter.js +3 -13
  196. package/dist/observability/principal-context.js +0 -9
  197. package/dist/observability/prompt-manifest.js +1 -37
  198. package/dist/observability/rate-limit.js +0 -4
  199. package/dist/observability/secret-env-scrub.js +2 -56
  200. package/dist/observability/tool-trace.js +1 -70
  201. package/dist/orchestration/hardened-vm-runner.js +4 -118
  202. package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
  203. package/dist/orchestration/hardened-vm-worker.js +0 -27
  204. package/dist/orchestration/subagent-steer.js +1 -45
  205. package/dist/orchestration/workflow-agent-steer.js +1 -80
  206. package/dist/orchestration/workflow-completion-inbox.js +32 -285
  207. package/dist/orchestration/workflow-notify-journal.js +16 -259
  208. package/dist/org-memory-admission.js +3 -47
  209. package/dist/parent-watch.js +2 -48
  210. package/dist/parked-decide.js +1 -109
  211. package/dist/per-task-image.js +0 -57
  212. package/dist/plan-cache-probe.js +3 -27
  213. package/dist/plugins/adoption-log-sql.js +2 -119
  214. package/dist/plugins/approval-ask-store-memory.js +3 -38
  215. package/dist/plugins/approval-ask-store-sql.js +6 -188
  216. package/dist/plugins/approval-exemption-store.js +2 -28
  217. package/dist/plugins/background-agent-store-sql.js +4 -105
  218. package/dist/plugins/background-shell-support.js +14 -122
  219. package/dist/plugins/blob-backend.js +6 -169
  220. package/dist/plugins/breaker-state-sql.js +8 -46
  221. package/dist/plugins/caching-session-store.js +4 -106
  222. package/dist/plugins/checkpoint-store-sql.js +18 -505
  223. package/dist/plugins/e2b-orphan-reclaim.js +0 -45
  224. package/dist/plugins/file-outcome-sink.js +0 -9
  225. package/dist/plugins/file-resume-anchor-store.js +4 -43
  226. package/dist/plugins/file-run-store.js +26 -364
  227. package/dist/plugins/file-snapshot-store-sql.js +10 -181
  228. package/dist/plugins/fork-routing-session-store.js +8 -111
  229. package/dist/plugins/host-platform.js +2 -91
  230. package/dist/plugins/image-bake-store-sql.js +7 -250
  231. package/dist/plugins/image-index-sql.js +4 -123
  232. package/dist/plugins/k8s-bg-scripts.js +4 -88
  233. package/dist/plugins/k8s-exec-protocol.js +0 -41
  234. package/dist/plugins/leader-run-store-sql.js +0 -101
  235. package/dist/plugins/local-checkpoint-store.js +8 -128
  236. package/dist/plugins/local-session-store.js +34 -296
  237. package/dist/plugins/local-task-attachment-store.js +2 -16
  238. package/dist/plugins/mailbox-store-sql.js +8 -65
  239. package/dist/plugins/memory-embedder-fingerprint.js +5 -166
  240. package/dist/plugins/memory-embedder.js +4 -70
  241. package/dist/plugins/memory-engine-pg.js +6 -170
  242. package/dist/plugins/memory-engine-tidb.js +7 -155
  243. package/dist/plugins/memory-engine-vector-util.js +0 -10
  244. package/dist/plugins/memory-key-guards.js +0 -34
  245. package/dist/plugins/memory-origin-law.js +0 -187
  246. package/dist/plugins/memory-resume-anchor-store.js +0 -17
  247. package/dist/plugins/memory-run-store.js +12 -89
  248. package/dist/plugins/memory-session-policy-store.js +0 -17
  249. package/dist/plugins/memory-sync-store-pg.js +4 -49
  250. package/dist/plugins/memory-sync-store-tidb.js +3 -35
  251. package/dist/plugins/outcome-ledger-sql.js +3 -97
  252. package/dist/plugins/permission-rule-store-file.js +5 -133
  253. package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
  254. package/dist/plugins/permission-rule-store-sql.js +50 -307
  255. package/dist/plugins/pg-cost-quota.js +0 -7
  256. package/dist/plugins/pg-pool.js +0 -92
  257. package/dist/plugins/pg-rate-limiter.js +2 -13
  258. package/dist/plugins/pg-safe-json.js +4 -40
  259. package/dist/plugins/pg-session-storage.js +25 -189
  260. package/dist/plugins/posix-shell-fs.js +1 -31
  261. package/dist/plugins/remote-env-adb.js +12 -101
  262. package/dist/plugins/remote-env-e2b.js +40 -371
  263. package/dist/plugins/remote-env-file-error.js +0 -37
  264. package/dist/plugins/remote-env-host.js +63 -483
  265. package/dist/plugins/remote-env-k8s.js +32 -326
  266. package/dist/plugins/remote-env-local-docker.js +24 -157
  267. package/dist/plugins/remote-env-ssh.js +20 -128
  268. package/dist/plugins/remote-scratchpad.js +2 -32
  269. package/dist/plugins/remote-shell.js +1 -32
  270. package/dist/plugins/resume-anchor-store-sql.js +0 -11
  271. package/dist/plugins/retention-lane-store-sql.js +0 -108
  272. package/dist/plugins/retention-store-sql.js +4 -383
  273. package/dist/plugins/roster-store-sql.js +0 -55
  274. package/dist/plugins/run-store-sql.js +14 -251
  275. package/dist/plugins/s3-presign.js +2 -49
  276. package/dist/plugins/scheduler-support.js +3 -80
  277. package/dist/plugins/send-file-ledger.js +4 -53
  278. package/dist/plugins/send-user-file.js +4 -94
  279. package/dist/plugins/session-placement.js +1 -89
  280. package/dist/plugins/session-policy-store-sql.js +3 -81
  281. package/dist/plugins/session-store.js +0 -57
  282. package/dist/plugins/shared-memory-store-sql.js +4 -167
  283. package/dist/plugins/sql-driver.js +0 -17
  284. package/dist/plugins/sql-errors.js +0 -7
  285. package/dist/plugins/sql-escape.js +0 -8
  286. package/dist/plugins/sql-row-helpers.js +0 -25
  287. package/dist/plugins/store-backend.js +42 -222
  288. package/dist/plugins/store-contracts.js +2 -46
  289. package/dist/plugins/task-attachment-store.js +3 -49
  290. package/dist/plugins/task-list-store-sql.js +0 -79
  291. package/dist/plugins/tidb-cost-quota.js +1 -4
  292. package/dist/plugins/tidb-pool.js +1 -207
  293. package/dist/plugins/tidb-rate-limiter.js +3 -9
  294. package/dist/plugins/tidb-session-storage.js +4 -70
  295. package/dist/plugins/tidb-session-store.js +19 -341
  296. package/dist/plugins/tool-result-store-sql.js +4 -179
  297. package/dist/plugins/usage-window-store-sql.js +0 -8
  298. package/dist/plugins/web-search.js +10 -132
  299. package/dist/plugins/workflow-journal-store-sql.js +2 -58
  300. package/dist/plugins/workflow-run-store-sql.js +6 -91
  301. package/dist/plugins/worktree-isolation.js +6 -126
  302. package/dist/plugins/write-behind-counter.js +16 -75
  303. package/dist/principal-jwt.js +5 -60
  304. package/dist/project-memory.js +15 -146
  305. package/dist/prompts-domain-validate.js +1 -51
  306. package/dist/question.js +2 -111
  307. package/dist/resource-suspend.js +0 -18
  308. package/dist/router/route-orchestration.js +0 -77
  309. package/dist/rules-consent.d.ts +44 -5
  310. package/dist/rules-consent.js +75 -215
  311. package/dist/run-local.js +14 -381
  312. package/dist/runs.js +29 -617
  313. package/dist/runtime-caps-resolver.js +6 -132
  314. package/dist/runtime-governance.js +1 -232
  315. package/dist/sandbox-pkg-source.js +0 -37
  316. package/dist/sealed-key.js +3 -68
  317. package/dist/security.js +6 -291
  318. package/dist/session-leaf-bus.js +0 -32
  319. package/dist/session-sync-content.js +1 -69
  320. package/dist/session-sync-kernel.js +3 -60
  321. package/dist/session-sync.js +3 -66
  322. package/dist/session-titler.js +7 -41
  323. package/dist/session-watch.js +9 -73
  324. package/dist/shared-memory-scope-authorizer.js +0 -18
  325. package/dist/sighup-idle.js +1 -12
  326. package/dist/spec-fields.js +4 -143
  327. package/dist/store-live-probe.js +3 -49
  328. package/dist/task-a2a.js +1 -126
  329. package/dist/task-cwd.js +1 -103
  330. package/dist/task-mcp.js +1 -89
  331. package/dist/task-settings.js +10 -300
  332. package/dist/task-workflow.js +8 -75
  333. package/dist/tool-approval.d.ts +28 -1
  334. package/dist/tool-approval.js +70 -1430
  335. package/dist/trace/artifacts.js +5 -20
  336. package/dist/trace/engine-notice-wire.js +6 -138
  337. package/dist/trace/ledger-sink.js +7 -96
  338. package/dist/trace/project.js +4 -448
  339. package/dist/trace/redact.js +9 -77
  340. package/dist/turn-activity.js +1 -27
  341. package/dist/usage-analytics.js +4 -34
  342. package/dist/wall-clock-jump-guard.js +1 -68
  343. package/package.json +3 -3
  344. package/dist/capabilities/scenario-alias.d.ts +0 -27
  345. package/dist/capabilities/scenario-alias.js +0 -61
@@ -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); // #262:与 unknown 臂同串,oracle 封口
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); // #262:与 unknown 臂同串,oracle 封口
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; // 腿已应答(400 body.taskId 坏形 / 409 foreign owner / 202 durable 重放 / 门拒)
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; // [1499] accept-phase
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"); // last wins (a re-run could re-emit)
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, // E12: present once the post-completion pass settled (undefined while pending / if none / off)
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
- // [888]① 文案:这个 face 覆盖本部署 run store 里的全部 run(bg/durable + interactive sync 腿都建行、
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; // owner mismatch → 404 (no existence oracle for non-owners)
244
+ return;
428
245
  if (!runSessionAcceptOk(req, res, run, "run.cancel"))
429
- return; // [1499] accept-phase
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" }); // idempotent
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" }); // idempotent
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); // TaskStatus has no 'cancelled' member — failed + errorCode 'cancelled' is the codebase-wide cancel terminal (same shape as the resume-abort settle)
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
- // Durable flag FIRST (only flips a still-running row), abort after: if the run suspended/terminal'd
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(); // [868] lost race INTO a park — same recovery handle as the direct branch
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); // 行又活了 ⇒ 重挂 durable 旗(赢了就走下面的快路)
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" }); // idempotent
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; // mutating + hits TiDB / runs the model
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 } : {}) }); // throws CheckpointError(steering.invalid_content) on break-out / control chars / bad inputId
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
- } // #262 同串
415
+ }
707
416
  if (!runSessionAcceptOk(req, res, run, "run.steer"))
708
- return; // [1499] accept-phase
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; // unexpected → outer handler 500
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
- } // 锁内重查:前一位刚铸完 → 直接 park 上去
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); // 匿名提交哨兵,与 main.ts putCtx 的 principal ?? "_" 同域
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; // mutating + runs the model (compaction summarizes)
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); // direct-door-secure identity, never the spoofable header
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
- } // #262 同串
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
- } // #262 同串
643
+ }
1027
644
  if (!runSessionAcceptOk(req, res, run, "run.compact"))
1028
- return; // [1499] accept-phase
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
- // FIRE-AND-FORGET 202 (review): `compact()` resolves only once PROCESSED at the next SAFE turn boundary — which
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; // mutating, but runs no model (no quota gate — parity with cancel, not steer)
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); // direct-door-secure identity, never the spoofable header
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
- } // TASKS-RUNS-5 同族
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
- } // #262 同串
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
- } // #262 同串
726
+ }
1152
727
  if (!runSessionAcceptOk(req, res, run, "run.detach"))
1153
- return; // [1499] accept-phase
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); // [2373]C-5 共享门(principal→owner→session)
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(); // 终态:tail 无帧可等——replay 面才是读处(meta 已指路)
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`); // workflow stream 同款真帧(BFF 丢注释帧案)
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); // 与 finally 同一手:`it.return()` 若没能当场解开 parked next(订阅方不配合),定时器不该比连接活得久
1309
- void it.return?.(); // parked next() 不观察 closed 旗——显式 return 解除 park + 清订阅(subscribeWorkflow 同案)
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; // 复审 #3:终态帧即完整故事结束——收流,余下在 replay 面
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; // mutating, runs no model (parity with detach — rateLimited only, no quota/lease gate)
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); // [2373]C-5 共享门([1493] 模型,论证见门注)
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 {}; // 未知 kind:不猜
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; // mutating + drives a model agent
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
- } // TASKS-RUNS-5 同族
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); // #262 同串
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; // [1499] accept-phase (child write verbs, same class)
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; // unexpected → outer handler 500
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; // mutating, but only resolves an in-flight promise (no new billable work → no quota gate)
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; // resolves an in-flight promise (no new billable work → no quota gate)
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; // resolves an in-flight promise (no new billable work → no quota gate)
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; // 打的是一行 CAS,不新开模型工作 ⇒ 不过配额门(与三条 respond 同判)
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 ?? "_", // auth-off 哨兵,与 durable scope 的 "_" 同字
1125
+ id: principal ?? "_",
1743
1126
  ...(body.actor ? { label: redactSecrets(body.actor.label).slice(0, ASK_ACTOR_LABEL_MAX) } : {}),
1744
- verified: principal !== undefined, // auth-off 下无「验证」可言,如实 false
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; // 坏行 ⇒ 已答 500
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; // 同上:先同步活体窗,坏行 ⇒ 500
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(); // 并发删(deleteByTask)——行没了,如实 404
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"); // foreign owner: conflict, no replay (no cross-tenant oracle)
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); // fire-and-forget;model 跟 turn([1992]②)
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), // BC-1: name = short objective preview (description = live-activity, set by the publisher onEvent tool_start)
1356
+ ...fleetRunLabels(prepared.spec.objective),
2150
1357
  });
2151
- void runInBackground(prepared.runner, // #196:场景 hands 判别位选出的 Runner(hands=none 无手孪生)
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); // don't cache a transient 409 (session-active, billed nothing) — let a retry re-run (council)
1372
+ }, (r) => r.status === 202);
2182
1373
  return resp;
2183
1374
  }
2184
1375
  //# sourceMappingURL=runs.js.map