@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
package/dist/runs.js CHANGED
@@ -3,52 +3,14 @@ import { withPrincipal } from "./observability/principal-context.js";
3
3
  import { redactSecrets } from "./trace/redact.js";
4
4
  import { taskNotificationEventData, appendModelUsageDelta, appendPromptManifest, attachModelUsage } from "./trace/project.js";
5
5
  import { createLedgerSink } from "./trace/ledger-sink.js";
6
- import { registerEngineNoticeLeg } from "./trace/engine-notice-wire.js"; // #310:通告 wire 腿(bg 半场)
6
+ import { registerEngineNoticeLeg } from "./trace/engine-notice-wire.js";
7
7
  import { recordTurnActivity } from "./turn-activity.js";
8
8
  import { createApprovalCardEmitter, resolveApprovalLeg } from "./tool-approval.js";
9
9
  import { fleetRunResiduals, isFleetAgentTerminalNotification } from "./fleet/fleet-bus.js";
10
10
  import { defaultSubagentTailBus, projectTailFrame } from "./fleet/subagent-tail-bus.js";
11
11
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "./orchestration/workflow-completion-inbox.js";
12
- import { recordFailOpen } from "./observability/fail-open.js"; // #310:通告 durable 写失败的留痕口
13
- import { LIVENESS_HEARTBEAT_MS } from "./config-invariants.js"; // #322:心跳周期的单源 = 它服务的那条不变量
14
- /**
15
- * stoppedBy (core 1.252): a service cancel is a USER stop — mark the run's still-running background
16
- * children BEFORE core's teardown reaps them (bare abort = attribution falls back to "system"; core's own
17
- * reap marks "parent", but first-marker-wins means our earlier "user" is the one that lands). Register this
18
- * FIRST on the cancel signal so it runs ahead of any later-registered core listener (listener-order
19
- * guarantee); one registration covers every abort route on that signal (same-replica fast path + the
20
- * cross-replica flag poll). Advisory only — the cancel itself must never fail on attribution.
21
- *
22
- * Access = TASK-scoped children ONLY: owner = the parent taskId (on a RESUME leg: the sessionId — core's
23
- * canonical taskId there), scope = `principal ?? "default"` (core registers children under
24
- * `spec.principal ?? "default"`, so an anonymous run's children live in scope "default" and an access
25
- * WITHOUT scope matched nothing). Session-scoped children are DELIBERATELY not marked: a cancelled parent
26
- * does NOT take them down (CC Backgrounded semantics — core's teardown reaps with skipSessionScoped), so
27
- * pre-marking them "user" would misattribute a stop that isn't happening.
28
- *
29
- * core 1.256: `markStopSourceForOwner` replaces the old `list()+markStopSource`
30
- * per-row loop, closing BOTH documented holes — ① the EXPLICIT `sessionScoped` flag
31
- * (`skipSessionScoped:true`) makes the resume leg safe (its owner key == sessionId COINCIDES with
32
- * session-scoped children's; the old code couldn't tell them apart and took an honest degrade instead),
33
- * ② a direct handle-map walk (no `list()` 500-row display cap under-marking >500-children runs). Covers
34
- * core's full markable set (background_agent / background_bash / monitor); running-only +
35
- * first-marker-wins guards live inside the seam.
36
- */
37
- /** [1488]③(b) + codex R2: the bg agent-handle OUTPUT read, kind-gated BEFORE any poll. `pollTask` is the
38
- * generic registry face and polling is NOT side-effect-free for other kinds — a terminal workflow poll
39
- * fires `onServedTerminal` (acknowledges the completion as served → suppresses its notification) and a
40
- * bash poll advances the output cursor. `getAccessibleTask` is a pure lookup: resolve + access-check +
41
- * type-check FIRST; only a `background_agent` row is ever polled (non-blocking). Everything else returns
42
- * the registry's own not_found shape, indistinguishable from an unknown handle (the route 404s it).
43
- *
44
- * 1.250 durable arm (core 1.364, [1511] follow-up delivered): a FULL in-process miss (`!row`) falls
45
- * through to `pollTask(…, { agentStore })` — core's durable arm only fires for `a*`-shaped handles
46
- * (DURABLE_AGENT_HANDLE_RE) behind `canAccessAgentRecord`, and with NO in-process handle the live
47
- * workflow/bash branches are unreachable, so the kind-gate's reason (onServedTerminal / cursor advance)
48
- * cannot trigger. Terminal rows serve the durable snapshot cross-instance/post-restart; a foreign-writer
49
- * running row answers the honest "outcome unknown here"; anything else is core's own not_found (byte-same
50
- * route 404 as today). A row that IS in-process but of the wrong type stays a hard 404 — the process
51
- * knows the truth, falling back would sidestep the kind gate. */
12
+ import { recordFailOpen } from "./observability/fail-open.js";
13
+ import { LIVENESS_HEARTBEAT_MS } from "./config-invariants.js";
52
14
  export async function backgroundAgentOutput(registry, handle, access, agentStore) {
53
15
  const row = registry.getAccessibleTask(handle, access);
54
16
  if (!row || row.type !== "background_agent") {
@@ -58,56 +20,20 @@ export async function backgroundAgentOutput(registry, handle, access, agentStore
58
20
  }
59
21
  return registry.pollTask(handle, access);
60
22
  }
61
- /** [1499] CC TaskOutput 人侧对位 — the GENERIC task-handle read face (b* bash stdout — NB whether a read
62
- * consumes the output cursor depends on the handle's shape: spooled handles re-read in full [GET-idempotent],
63
- * cursor-only handles return new bytes per read; the projection's flags are authoritative; m* monitor
64
- * batches; a* agent final report). No filter option: caller-supplied regexes are refused at the route (a
65
- * backtracking engine on the shared event loop must not run wire-supplied patterns; client-side filtering
66
- * sees only the clipped projection — full-fidelity filtered reads stay an in-engine tool capability).
67
- * Addressing is exact task_id only (the tools' name/legacy-id resolution stays off the wire). Kind-gate
68
- * FIRST on the pure getAccessibleTask (codex-R2 shape — pollTask is not side-effect-free): `workflow` is
69
- * refused — its HTTP read face is the workflow journal ([1491] contract), and a pollTask here would fire
70
- * onServedTerminal (ack the completion as served) and suppress the workflow completion push. Refusals
71
- * return the registry's own not_found shape, indistinguishable from an unknown handle (the route 404s it). */
72
23
  const TASK_HANDLE_KINDS = new Set(["background_bash", "monitor", "background_agent"]);
73
- /** 1.250 复审 F1(HIGH):durable 回落的**形判门**——`getAccessibleTask` 是精确键(不 trim、不查
74
- * 别名),而 core `pollTask/stopTask` 是通用解析面(先 trim;[2380] RB-479-A②:`legacyToTaskId`
75
- * shellId 索引已随 5.0.0 整族删,b* task_id 是唯一 registry 地址——本门防的「legacy shellId 穿缝」
76
- * 一臂如今在 core 层就响亮 not_found;stopTask 仍有 byName 臂)。裸 `!row` 回落会让垫充空白句柄
77
- * (" "+wfId)/agent 名字从回落缝穿进 live 分支——kind 门要防的 onServedTerminal/游标消费/按名 stop
78
- * 全部复活(复审实测复现)。
79
- * 只有 **a\* 精确形**才回落:`DURABLE_AGENT_HANDLE_RE` = core 公共导出(core 1.369,[1518] ask 即答
80
- * ——两边判词同源,镜像私拷已删)。残余(documented):恰为 a\* 形的 agent **名字**
81
- * 在无 durable 行时仍会进 core stopTask 的 byName 臂——同 access 元组内、名字域与句柄域同形的病态窄面,
82
- * 接受。 */
83
24
  const DURABLE_AGENT_HANDLE_SHAPE = DURABLE_AGENT_HANDLE_RE;
84
25
  export async function taskHandleOutput(registry, handle, access, agentStore) {
85
26
  const row = registry.getAccessibleTask(handle, access);
86
27
  if (!row || !TASK_HANDLE_KINDS.has(row.type)) {
87
- // 1.250 durable arm — full in-process miss + a* 精确形才回落(F1 形判门,DURABLE_AGENT_HANDLE_SHAPE
88
- // 顶注;b*/m* 无 durable 面照旧 not_found;进程内命中但型错 = 真相已知,不回落)。
89
28
  if (!row && agentStore && DURABLE_AGENT_HANDLE_SHAPE.test(handle))
90
29
  return registry.pollTask(handle, access, { agentStore });
91
30
  return { content: "", details: { task_id: handle, retrieval_status: "not_ready", error: "not_found" } };
92
31
  }
93
32
  return registry.pollTask(handle, access);
94
33
  }
95
- /** [1499] CC TaskStop 人侧对位 — stop a background task handle (bash kill / monitor stop / agent abort).
96
- * Same kind gate as the read face (a workflow stops via its own cancel face, never this verb). stopTask is
97
- * idempotent on a terminal handle (the registry answers honestly); the stop is attributed "user" via the
98
- * registry's existing markStopSource machinery (an HTTP stop is the human's hand — parity with the shell's
99
- * TaskStop attribution). The pre-mark is first-write-wins and MUST come first (core's own stop path marks
100
- * "parent" internally — marking after would lose the attribution race on success, the common path); the
101
- * trade (codex R3) is that on a kill-that-didn't-land the pending "user" mark is not clearable through the
102
- * public registry face — the route surfaces that arm as 409 stop.not_landed, and the atomic fix (a `source`
103
- * option on stopTask) is a core ask. */
104
34
  export async function taskHandleStop(registry, handle, access, agentStore) {
105
35
  const row = registry.getAccessibleTask(handle, access);
106
36
  if (!row || !TASK_HANDLE_KINDS.has(row.type)) {
107
- // 1.250 durable arm — core stopTask's agentStore branch answers honestly (terminal row: "already
108
- // <status> — nothing to stop"; foreign-writer running row: not_local, no kill is attempted). NO
109
- // markStopSource here: there is no in-process kill to attribute, and the durable answer never reads
110
- // as a landed stop. F1 形判门同 output 面(stopTask 还有 byName 臂——非 a* 形一律不进通用解析面)。
111
37
  if (!row && agentStore && DURABLE_AGENT_HANDLE_SHAPE.test(handle))
112
38
  return registry.stopTask(handle, access, { agentStore });
113
39
  return { content: "", details: { task_id: handle, retrieval_status: "not_ready", error: "not_found" } };
@@ -115,21 +41,12 @@ export async function taskHandleStop(registry, handle, access, agentStore) {
115
41
  registry.markStopSource(handle, "user");
116
42
  return registry.stopTask(handle, access);
117
43
  }
118
- /**
119
- * Attribute this signal's abort as a USER stop for the owner's task-scoped children.
120
- *
121
- * 🔴 已经 abort 的信号在这里**立刻**记账,不是静默无操作(#168 件3 的配套)。调用点在 #168 件3 之后
122
- * 一律挪到了「run 认领成功之后」—— 认领是一次 await,断连完全可能落在它之前。`addEventListener` 对一个
123
- * 已 fire 的信号永不回调,那样一次真实的人为断连会被吞成「无归因」(子代 settle 时记成 system)。
124
- * 语义上这两种时序是同一件事:本请求赢下了这条 session,而这条 session 的连接是被人断掉的。
125
- */
126
44
  export function markChildrenStoppedByUserOnAbort(signal, taskId, principal) {
127
45
  const mark = () => {
128
46
  try {
129
47
  defaultTaskRegistry.markStopSourceForOwner({ owner: taskId, scope: principal ?? "default" }, "user", { skipSessionScoped: true });
130
48
  }
131
49
  catch {
132
- /* attribution is advisory — the cancel itself must never fail on it */
133
50
  }
134
51
  };
135
52
  if (signal.aborted) {
@@ -138,11 +55,6 @@ export function markChildrenStoppedByUserOnAbort(signal, taskId, principal) {
138
55
  }
139
56
  signal.addEventListener("abort", mark, { once: true });
140
57
  }
141
- /**
142
- * A session CAS conflict is swallowed by core into a failed `TaskResult` (it is not thrown from
143
- * `runTaskStream`), so detect it from the result. Prefer the structured `errorCode` (core ≥1.8); fall
144
- * back to the conflict wording only for older results without one.
145
- */
146
58
  export function isSessionConflictResult(result) {
147
59
  if (result.status !== "failed")
148
60
  return false;
@@ -150,30 +62,6 @@ export function isSessionConflictResult(result) {
150
62
  return result.errorCode === "conflict";
151
63
  return /stale write|concurrent write|conflict/i.test(result.errorMessage ?? "");
152
64
  }
153
- /**
154
- * E18 resume-at CALLER error → an HTTP 4xx. A bad/stale resume-at target (the resolved entryId is gone or is not a
155
- * settled message boundary) surfaces as a FAILED TaskResult carrying a `resume_at.*` errorCode (core swallows the
156
- * prepare-throw into the result, exactly like a session conflict — runtask catch → status:"failed"+errorCode). The
157
- * sync path otherwise 200s a failed result; map these to 4xx so a caller mistake reads as one. `not_found` (the
158
- * entry is genuinely absent) → 404; everything else (not_a_message / conflicts_resume / no_session) → 422.
159
- *
160
- * design/114 Phase3: `requireExistingSession` on a genuinely-missing/purged session ALSO surfaces as a failed result
161
- * (core prepare-task maps the store's `not_found` → `resume.session_not_found`); map it to 404 (the session is gone),
162
- * the same "caller mistake reads as a 4xx, not a silent 200 fresh run" discipline.
163
- *
164
- * [833] rewind exclusive mode (core 1.292 resumeAtMode:"before"): its two edge rejections ride the same `resume_at.*`
165
- * prefix (before_target_not_user / before_root_unsupported → 422 via the existing branch, errorCode passed through
166
- * UNCHANGED for the shell). `rewind_snapshot.unresolvable` (rewindFiles + "before": no snapshot at/above the branch
167
- * point) is core's third rejection with a DIFFERENT prefix — same caller-mistake shape (prepare-throw, nothing
168
- * billed), map it to 422 explicitly so it doesn't fall through to a 200-with-failed body.
169
- *
170
- * design/170 件A(#148 件4): core 5.13.0's org-memory admission rides the SAME prepare-throw pipe
171
- * (GOVERNANCE_CODES). `memory.admission_denied` is TERMINAL — the principal has no grant on that tenant
172
- * plane → 403 (a retry cannot change the verdict). `memory.admission_required` is TRANSIENT fail-closed —
173
- * the directory is unreachable / no resolver is wired → 503 (retry later; the sync leg forwards the
174
- * result's `retryAfterMs` as body `retryAfterSec`, same family shape as usage.window_exhausted). Exact
175
- * codes only, no memory.* family grab — an unknown sibling stays a 200-with-failed-body until cataloged.
176
- */
177
65
  export function resumeAtHttpStatus(result) {
178
66
  if (result.status !== "failed")
179
67
  return undefined;
@@ -189,71 +77,31 @@ export function resumeAtHttpStatus(result) {
189
77
  return undefined;
190
78
  return result.errorCode === "resume_at.not_found" ? 404 : 422;
191
79
  }
192
- /**
193
- * E18 resume-at — per-turn anchor capture, shared by runInBackground AND the resume-leg driveResume so the (single,
194
- * tricky) capture rule can't drift between the two stream-drain loops.
195
- *
196
- * A "turn" in core = ONE assistant message + its tool batch. resume-at is valid ONLY at a SETTLED rewindable boundary
197
- * — core's prepare-task accepts "user messages or finished assistant-text turns" and REJECTS an assistant message that
198
- * ends mid-tool-call (resume_at.not_a_message). So we capture an anchor ONLY for a turn that produced text AND ran NO
199
- * tools: then the turn's leaf at turn_end IS that assistant-text message (the rewindable target). For a tool-using
200
- * turn neither candidate works — at turn_end the leaf is the turn's LAST tool-result (core appends tool results before
201
- * emitting turn_end; a tool-result is stored as a `message` so core would SILENTLY branch AFTER the tools ran), and
202
- * the assistant message itself ends mid-tool-call so core would reject it. The shell still gets a per-message eventId
203
- * handle on every text event (E2 identity); a rewind to a non-captured (tool-using) message simply 404s — honest,
204
- * never a silent wrong-point branch. (The adversarial-review HIGH: capturing at turn_end unconditionally mis-anchored
205
- * every tool-using turn — the common agentic shape — to the tool-result leaf.)
206
- *
207
- * Best-effort: a capture failure must NEVER fail the run (it only makes that one turn non-rewindable). `onCaptureFail`
208
- * surfaces the degrade (a metric) so a SYSTEMATIC failure — e.g. an un-migrated DB missing `resume_anchor` — is
209
- * observable instead of a silent feature outage.
210
- */
211
80
  export class TurnAnchorCapture {
212
81
  capture;
213
82
  onCaptureFail;
214
83
  captureUser;
215
- /** The turn's FIRST text_delta eventId = the message's stable resume handle (also stamped on the durable text event). */
216
84
  firstTextEventId;
217
85
  turnHadTool = false;
218
- constructor(capture, onCaptureFail,
219
- /** R8 (CC-parity rewind): capture a USER-message anchor straight from the
220
- * `message_committed{role:"user"}` event — its `entryId` is the persisted user-message `SessionTreeEntry.id`,
221
- * ALWAYS a valid `resumeAt` target (core accepts a user message; it rejects a tool-result / mid-tool-call
222
- * assistant). This is the CC rewind target ("rewind to the prompt" = the code-restore parity path — a code
223
- * change happens in a tool turn WITHIN a prompt's run, so rewinding to the prompt + rewindFiles undoes it).
224
- * Keyed by the run's taskId (the handle the shell already holds from the POST response — no eventId→entryId
225
- * side map / no `getLeafId` inference needed). The assistant-turn capture above stays as an additive bonus. */
226
- captureUser) {
86
+ constructor(capture, onCaptureFail, captureUser) {
227
87
  this.capture = capture;
228
88
  this.onCaptureFail = onCaptureFail;
229
89
  this.captureUser = captureUser;
230
90
  }
231
- /** A `message_committed` arrived — capture a USER-message rewind anchor (CC parity). Best-effort (a failure only
232
- * makes that one prompt non-rewindable). Only `role==="user"`; assistant/toolResult committed entries are handled
233
- * by the turn-text path (assistant) or deliberately not anchored (toolResult — `resumeAt` rejects them). */
234
91
  async onMessageCommitted(role, entryId) {
235
92
  if (role === "user" && entryId && this.captureUser) {
236
93
  await this.captureUser(entryId).catch(() => this.onCaptureFail?.());
237
94
  }
238
95
  }
239
- /** A text_delta arrived — latch the first DEFINED eventId as the message handle (eventId is optional on the stream). */
240
96
  onText(eventId) {
241
97
  if (this.firstTextEventId === undefined) {
242
- // First text of THIS turn. Any tool seen before now belonged to a PRIOR turn: on the resume leg, core streams
243
- // the suspended turn's resolution tool_start/tool_end (the approved tool) with NO intervening turn_end before the
244
- // continuation turn (runtask.js applyResumeDecision). Clearing turnHadTool here keeps a pure-text continuation
245
- // turn anchorable. SAFE for normal turns: within a turn the assistant streams its text BEFORE its own tool calls,
246
- // so a real same-turn tool always arrives AFTER this first text and re-sets the flag (guarded by `=== undefined`,
247
- // so a later text part after a tool — [text, toolCall, text] — does NOT wrongly clear it).
248
98
  this.turnHadTool = false;
249
99
  }
250
100
  this.firstTextEventId ??= eventId;
251
101
  }
252
- /** A tool ran this turn — mark it non-rewindable (its leaf will be a tool-result, not a settled assistant-text message). */
253
102
  onTool() {
254
103
  this.turnHadTool = true;
255
104
  }
256
- /** turn_end — capture the anchor iff this was a settled assistant-TEXT turn, then reset for the next turn. */
257
105
  async onTurnEnd() {
258
106
  if (this.firstTextEventId && !this.turnHadTool && this.capture) {
259
107
  await this.capture(this.firstTextEventId).catch(() => this.onCaptureFail?.());
@@ -262,36 +110,13 @@ export class TurnAnchorCapture {
262
110
  this.turnHadTool = false;
263
111
  }
264
112
  }
265
- /**
266
- * Strip the durable-resume capability token from a result before it is persisted to the replayable event
267
- * log / run row or returned to the submitter. The token IS the resume credential (token-as-auth, search
268
- * [18] Q1b) and must never leave the service. Safe on any result (no-op when absent) — notably core maps a
269
- * NESTED suspend under verify/cascade to status:"failed" + errorCode "unexpected.suspended"(inner status
270
- * "needs_review" ⇒ "unexpected.needs_review";core `agents/suspend-guard.js` `mapNestedSuspend`,由 verify.js
271
- * 191/201/208 与 cascade.js:125 调用)while still
272
- * spreading `checkpointToken`, so that "failed" path (unlike the stream path's explicit suspend handling)
273
- * would otherwise leak it into task_event / task_run.result / the sync 200 response. `checkpointGate` is
274
- * non-secret and intentionally kept.
275
- */
276
113
  export function stripCheckpointToken(result) {
277
- // Narrow to a plain object (reject null / arrays) and an OWN `checkpointToken` key, so a non-plain or
278
- // array input is returned unchanged rather than reshaped.
279
114
  if (result !== null && typeof result === "object" && !Array.isArray(result) && Object.hasOwn(result, "checkpointToken")) {
280
115
  const { checkpointToken: _drop, ...rest } = result;
281
116
  return rest;
282
117
  }
283
118
  return result;
284
119
  }
285
- /**
286
- * [4913](cli 判别子 v3 请托)—— park 结果的**待批工具调用身份**:suspended/needs_review 结果在
287
- * strip 之前手握 checkpointToken,用它 `cs.get(token)` 换 `pendingAction`,`tool_approval` 臂直读
288
- * `toolCallId`(与 tool_approval 帧 ≥1.307 同键同义)。核心 `TaskResult` 上没有这个键(CheckpointGate
289
- * 只带 toolName),checkpoint 本体是 park 的事实源——同源判据(A-058 教训)。
290
- *
291
- * 缺席形照 core `CheckpointSummary` 的 [1995]② OMITTED 契约:tool-less park(resource_limit /
292
- * plan_review / task_done)回 `undefined` ⇒ 调用方**不铸键**,绝不编 null。读失败=F 类兜底
293
- * (展示/关联键,缺席=该键到货前的现状),`recordFailOpen` 留痕,绝不挡 done/suspended 终局写链。
294
- */
295
120
  export async function parkToolCallId(result, cs) {
296
121
  if (!cs || result === null || typeof result !== "object")
297
122
  return undefined;
@@ -307,112 +132,24 @@ export async function parkToolCallId(result, cs) {
307
132
  return undefined;
308
133
  }
309
134
  }
310
- /** [4913] 整对象形:park 结果顶层 additive 上 `toolCallId` 键(与 `checkpointId` 同位,#285 先例;
311
- * 不塞进 `checkpointGate`——那是 core 的类型,server 不改其形)。非 park / 无 token / tool-less /
312
- * 读失败 ⇒ **同一引用**原样返回(键缺席)。graft 不负责 strip——组合序恒为 graft → strip。 */
313
135
  export async function graftParkToolCallId(result, cs) {
314
136
  if (result !== null && typeof result === "object" && Object.hasOwn(result, "toolCallId"))
315
- return result; // 已带(未来 core 直供形)不覆写
137
+ return result;
316
138
  const id = await parkToolCallId(result, cs);
317
139
  return id === undefined ? result : { ...result, toolCallId: id };
318
140
  }
319
- /** Evict a stale warm-cache entry after a cross-instance write conflict (broken-affinity backstop). */
320
141
  export function evictIfConflict(runner, sessionId, result) {
321
142
  if (sessionId && isSessionConflictResult(result)) {
322
143
  runner.sessions.evict?.(sessionId);
323
144
  }
324
145
  }
325
- /**
326
- * Drive a task to completion in the background, persisting its events to the durable run log (S2).
327
- *
328
- * Text deltas are coalesced into one `text` event per turn (the durable stream is turn-grained for
329
- * text); tool/lifecycle events are recorded individually. Per-token live streaming stays on the
330
- * synchronous `/v1/tasks/stream` endpoint. Never throws — failures are recorded on the run.
331
- *
332
- * Cancellation (POST /v1/runs/:id/cancel): the run registers an AbortController in `inflight` (same-replica
333
- * fast path — the cancel handler aborts it directly) and its heartbeat tick polls the durable
334
- * `cancel_requested` flag (cross-replica, ≤HEARTBEAT_MS lag). Either way the run settles to
335
- * status "failed" + errorCode "cancelled" (metric label "cancelled"); the entry is removed in `finally`.
336
- *
337
- * Preemption (POST /v1/assistant/tasks/:id/preempt, design/80 seam #2): a SECOND, INDEPENDENT controller
338
- * (`preemptCtrl`, registered in `preemptable`) carries the scheduler's graceful "yield this task" — when raised,
339
- * core durably SUSPENDS the task at the next clean turn boundary (gate resource_limit, reason "preempt") so it is
340
- * RESUMABLE, the opposite of cancel's kill. It is wired ONLY on the plain-stream path: the verify/cascade
341
- * branches finalize with `setTerminal` unconditionally (no `setSuspended`), so a preempt-suspend there would write
342
- * a non-terminal "suspended" as terminal and release the session lock — and preempt's contract is graceful, not a
343
- * failure. Cross-replica via the `preempt_requested` flag (same heartbeat tick). Eligibility (resourceSuspend +
344
- * durable store + durable tool-results + remote env) is core's call: an ineligible task's preemptSignal no-ops.
345
- *
346
- * Steering (POST /v1/runs/:id/steer, design/47): the live core `TaskStream` is registered in `steerable` so the
347
- * HTTP route can inject a mid-task message via `stream.steer()` (drained at the next turn boundary). Same-replica
348
- * only — a suspended run is steered durably via the checkpoint's `setPendingSteer` instead, and only the
349
- * plain-stream path holds a steerable stream (the verify/cascade branches return a result, not a stream).
350
- */
351
- export async function runInBackground(runner, spec, runStore, taskId, metrics, principal, verify, // 快审 F1:number 参升整对象——cost 顶随传
352
- cascade, instrumentDegenerate, planCacheProbe, persistThinking = false, inflight, preemptable, steerable, modelUsage, elicitation,
353
- /** E23: the VERIFIED principal (gatedPrincipal) that may answer this run's elicitations — matches the respond
354
- * owner-gate's identity source (NOT the cost-attribution `principal`, which on a direct door is the spoofable
355
- * header → can be null while a JWT principal exists). Falls back to the run owner when not supplied. */
356
- elicitOwner,
357
- /** E18 resume-at: capture the turn's (firstTextEventId → leaf entryId) anchor at each turn_end. Built by the caller
358
- * closing over {sessionId, owner, sessionStore.getLeafId, resumeAnchorStore}; undefined ⇒ no anchor store wired. */
359
- captureTurnAnchor,
360
- /** MF-Fleet (shell-host contract): the run-scoped fleet-row publisher. `onStart` adds the live row; `onEvent` accrues
361
- * tokens (turn_end) + subagent child rows (task_progress); `onTerminal` flips/removes it. Fired for ALL legs
362
- * (verify/cascade/plain-stream) so a background run TRANSITIONS its `GET /v1/fleet/stream` row exactly like the
363
- * sync leg. No-op when no fleet bus is wired (the publisher is a null-object). */
364
- fleetPublisher,
365
- /** R8 (CC-parity rewind): capture a USER-message anchor (keyed by this run's taskId) from `message_committed{role:"user"}`
366
- * — the CC "rewind to the prompt" target. undefined ⇒ no anchor store wired. */
367
- captureUserMessageAnchor,
368
- /** §4④ : AskUserQuestion LIVE coordinator — establishes a per-run onQuestion ALS context (like `elicitation`)
369
- * so an AskUserQuestion tool call routes to this run's stream + owner-gated respond. undefined ⇒ headless default. */
370
- question,
371
- /** P1 ①② follow-on (core): the async-workflow completion inbox, drained at the START of this background
372
- * leg (parity with the sync stream-open drain) into durable `workflow_complete` events a tailing client replays.
373
- * Owner gate = `elicitOwner ?? owner` (the same verified-principal-with-run-owner-fallback the elicit/question
374
- * contexts use). undefined ⇒ no push half wired (the WorkflowStatus poll floor stands alone). */
375
- workflowCompletionInbox,
376
- /** C2 (core 1.219): the replica-local Task-subagent steer-handle registry. When present, this leg
377
- * opts into core's `onSubagentSpawn` sink — each SYNC delegation's steer handle registers under THIS run's
378
- * taskId (auto-evicted on child settle) so POST /v1/runs/:id/subagents/:target/steer can route into it. */
379
- subagentSteer,
380
- /** ask①: emit-target diagnostic sink for the completion-inbox drain below (route/connection/
381
- * deliveryLagMs per delivered `workflow_complete`). runs.ts has no logger of its own — the caller closes over
382
- * deps.logger. undefined ⇒ silent (behavior unchanged). */
383
- completionDiagLog,
384
- /** SendUserFile(切片2):per-run file_link 帧发射 ALS(question/elicitation 同款)。emit=durable append。 */
385
- sendUserFile,
386
- /** [998]② the shared prompt-manifest accumulator (tracer records at prepare; this leg drains the pending
387
- * record into a durable `prompt_assembled` event — turns/stream read it). undefined ⇒ not wired. */
388
- promptManifests,
389
- /**
390
- * #151 车3 刀 3b:**bg 腿的流内审批 ctx**(design/172 §4.3(b))。今天为止这条腿连 approval ALS 都没有
391
- * ——`deps.onAsk` 拿不到 ctx ⇒ 恒 `"unavailable"` ⇒ durable park,一张卡都不产。接上之后,bg run 的
392
- * 权限 ask 走本腿的 **durable events tail**(`GET /v1/runs/:id/events`,唯一带 SSE `id:` 的面)。
393
- *
394
- * `streamApprovalOn` = 协议上场判据(`resolveStreamApprovalGate`,由路由层求值后传进来 —— 本函数是纯
395
- * 执行腿,不该自己读 backend/config)。为假 ⇒ 只接既有四键 ctx(呈卡口/腿轴都不接),本腿字节逐字不变。
396
- */
397
- approval,
398
- /** [4913]:park 投影腿的 checkpoint 读口(`deps.checkpointStore`)。在场时,durable park 的
399
- * `suspended` 事件带 `toolCallId`(graft 见 {@link parkToolCallId});缺席=旧字节逐字不变。 */
400
- parkCheckpointReader) {
146
+ export async function runInBackground(runner, spec, runStore, taskId, metrics, principal, verify, cascade, instrumentDegenerate, planCacheProbe, persistThinking = false, inflight, preemptable, steerable, modelUsage, elicitation, elicitOwner, captureTurnAnchor, fleetPublisher, captureUserMessageAnchor, question, workflowCompletionInbox, subagentSteer, completionDiagLog, sendUserFile, promptManifests, approval, parkCheckpointReader) {
401
147
  const startedAt = Date.now();
402
148
  metrics?.addGauge("runs_active", 1);
403
- fleetPublisher?.onStart(); // MF-Fleet: the row goes live (running) the moment the background run starts
404
- // Cancel (POST /v1/runs/:id/cancel): the controller aborts the run two ways — (a) same-replica fast path,
405
- // the cancel handler looks this up in `inflight` and aborts now; (b) cross-replica, the heartbeat tick below
406
- // polls the durable `cancel_requested` flag and aborts. Either way an aborted run finalizes to
407
- // "failed" + errorCode "cancelled" (we do NOT extend TaskStatus — errorCode distinguishes user-cancel).
149
+ fleetPublisher?.onStart();
408
150
  const cancelCtrl = new AbortController();
409
151
  inflight?.set(taskId, cancelCtrl);
410
- // stoppedBy (core 1.252): a service cancel is a USER stop — see markChildrenStoppedByUserOnAbort.
411
152
  markChildrenStoppedByUserOnAbort(cancelCtrl.signal, taskId, principal);
412
- // design/80 seam #2 (preemption): only the plain-stream path can durably suspend-and-resume on a preempt (the
413
- // verify/cascade branches setTerminal unconditionally). Register the preempt controller — and poll the durable
414
- // flag — ONLY for that path, so the endpoint that looks a task up in `preemptable` can honestly tell a
415
- // verify/cascade task apart (not preemptible) from a stream task.
416
153
  const preemptEligible = verify === undefined && cascade === undefined;
417
154
  const preemptCtrl = new AbortController();
418
155
  if (preemptEligible)
@@ -426,25 +163,11 @@ parkCheckpointReader) {
426
163
  metrics?.inc("task_tokens_total", {}, result.stats.tokens);
427
164
  if (result?.stats.cacheHitRate !== undefined)
428
165
  metrics?.observe("task_cache_hit_rate", result.stats.cacheHitRate);
429
- // Degenerate-output a/b instrument (1.59) — same as the sync path; no-op unless output.degenerate.
430
166
  if (result)
431
167
  instrumentDegenerate?.(result);
432
- // Plan-cache recurrence probe (design/42): objective recurrence per scope. status "completed" feeds
433
- // the history ring (mirrors the sync path); other terminal states still count toward the denominator.
434
168
  planCacheProbe?.record(principal, spec.objective, status === "completed");
435
169
  };
436
- // Liveness heartbeat (independent of events): keeps a long-but-alive run — e.g. one blocked in a
437
- // multi-minute subagent that streams no parent events — from being mistaken for a dead instance.
438
- // The owning principal is this run's owner — thread it as the single-DB-fleet null-safe owner guard on every
439
- // run-store mutation/poll below (the run row was created with owner = principal ?? null).
440
170
  const owner = principal ?? null;
441
- // #151 车3 刀 3b(§7.3 + §8.3):本腿的审批装配裁定走**三腿共用**的那一份判据。
442
- // 🔴 codex 交叉复审 F3(2026-08-06 真 finding)的修:此前本腿**无条件**包 ALS,只把新协议的口与轴挂在
443
- // `streamApprovalOn` 后面 —— 于是 ①开关关时本腿的行为变了(此前它根本没有 approval ctx,每只 ask 恒
444
- // `"unavailable"` ⇒ 恒 park;包上之后变成活卡并等满窗,A-1 当场破),②窗=0 在本腿上不生效(照样落行、
445
- // 注册资源、发帧,再等一个 0ms 的表)。现在两件都由下面这一个裁定统一管。
446
- // 取值点 = **本腿开始执行的时刻** = `runInBackground` 进入(路由层 `void runInBackground(...)` 之后
447
- // 立刻),不是请求到达时刻(§3.3:按任务创建至今算会系统性算错)。
448
171
  const approvalLeg = resolveApprovalLeg({
449
172
  streamApprovalOn: approval?.streamApprovalOn === true,
450
173
  windowMs: approval?.windowMs ?? 0,
@@ -454,208 +177,73 @@ parkCheckpointReader) {
454
177
  });
455
178
  const legDeadlineMonotonic = approvalLeg.legDeadlineMonotonic;
456
179
  const heartbeat = setInterval(() => {
457
- // 鲁棒性批5 A5(2026-08-05):与下面 cancel/preempt 两条兄弟同族——此前裸吞错,store 持续故障期间本 run
458
- // 的心跳每拍静默落空、运维零信号,直到某个副本的 reapStale 把它误判死亡(心跳失败正是那个误判的前兆)。
459
180
  void runStore.heartbeat(taskId, owner).catch(() => { metrics?.inc("run_signal_poll_errors_total", { kind: "heartbeat" }); });
460
- // Cross-replica cancel: the cancel may have landed on another instance, which only set the durable flag.
461
- // Poll it here so the OWNING instance aborts its in-flight run (bounded by HEARTBEAT_MS).
462
- // 鲁棒性批3 A5(2026-08-04,§M 感知链路):poll 失败本身有界(下一拍重试),但此前**零披露**——
463
- // store 持续故障期间取消/抢占每拍静默落空、run 继续烧预算,运维零信号。修=计数指标(warn 会逐拍
464
- // 刷屏,counter 是轮询披露的对形);abort 判定逻辑零变化。
465
181
  if (!cancelCtrl.signal.aborted) {
466
182
  void runStore.isCancelRequested(taskId, owner).then((c) => { if (c)
467
183
  cancelCtrl.abort(); }).catch(() => { metrics?.inc("run_signal_poll_errors_total", { kind: "cancel" }); });
468
184
  }
469
- // Cross-replica preempt (design/80 seam #2): a preempt may have landed on another instance (durable flag
470
- // only). Poll it so the OWNING instance raises its preemptSignal → core suspends at the next clean turn
471
- // boundary (resumable). Only the stream path wires the signal, so only poll there.
472
185
  if (preemptEligible && !preemptCtrl.signal.aborted) {
473
186
  void runStore.isPreemptRequested(taskId, owner).then((p) => { if (p)
474
187
  preemptCtrl.abort(); }).catch(() => { metrics?.inc("run_signal_poll_errors_total", { kind: "preempt" }); });
475
188
  }
476
189
  }, HEARTBEAT_MS);
477
190
  heartbeat.unref?.();
478
- // E18 resume-at: per-turn anchor capture (the turn's first text_delta eventId = the stable per-message handle, which
479
- // is also stamped on the durable `text` event so the shell sees ONE resume handle per message). The shared
480
- // TurnAnchorCapture only anchors a SETTLED assistant-text turn (text + no tools) — see its doc; the metric surfaces
481
- // a systematic capture failure (un-migrated DB) instead of a silent feature outage.
482
191
  const anchor = new TurnAnchorCapture(captureTurnAnchor, () => metrics?.inc("resume_anchor_capture_failed"), captureUserMessageAnchor);
483
- // Once a terminal outcome's event is DURABLY appended, the run has reached that state — record it so a later
484
- // failure (e.g. setTerminal throws after the `done` event is logged) re-attempts the SAME terminal write
485
- // rather than clobbering a completed run with `failed` (which would log both `done` and `failed` and report a
486
- // succeeded task as failed).
487
192
  let reached;
488
- // 1.78 (review LOW): the plain-stream leg's live TaskStream — held so the finally can identity-guard the
489
- // `steerable` deregistration (see the finally).
490
193
  let steerableStreamRef;
491
- // false once this leg settles — a background subagent/bash completion arriving AFTER that has no live
492
- // harness for core's in-process injection (the idle gap), so the observer below parks it durably instead.
493
194
  let legLive = true;
494
- // option A: taskNotificationStreamKey (域:task_id:status[#seq], core [784]④a + codex F2 域二分) keys
495
- // already carried by THIS leg (start-of-leg inbox drain, event-stream branch OR observer) — each arm skips
496
- // keys another arm already delivered, so a notification isn't double-appended to the durable log (the
497
- // turn-TAIL reap notification, which never reaches the stream, is exactly the one the observer catches;
498
- // core's pend-drain replay of a double-parked idle arrival is exactly the one the drain-registration
499
- // catches). The shell's dedup stays the outer belt. codex F1: value = the claiming write's outcome promise
500
- // (the observer arm appends fire-and-forget; a failed claim must NOT eat the stream replay — see NotifiedKeys).
501
195
  const notifiedKeys = new NotifiedKeys();
502
- // design/122 ③: THIS leg's subagent-handle unregister thunks (identity-guarded by the registry) — run at
503
- // leg end (finally), NOT on child settle, so a settled child stays resumable while the parent run lives.
504
196
  const subagentHandleEvictions = [];
505
- // design/80 D-B: PARK a `needs_review` result (a dry-run diff review OR a pre-action `plan_review` pause; core
506
- // assemble-result slot 8.6) like a suspend — KEEP task_active + flip the row non-terminal so the resume
507
- // (getActiveTaskId → markResuming) can claim it. Without this every finalize site setTerminal'd it (deleting the
508
- // session lock), and the plan_review resume then got taskId=undefined → drove the model UNPROTECTED on an
509
- // unlocked session with the row stuck `needs_review` (the adversarial-review HIGH finding). Mirrors the suspended
510
- // branch (append only the non-secret gate, never the capability token).
511
197
  const parkNeedsReview = async (res) => {
512
198
  await sink.appendParked("needs_review", res);
513
- await flushModelUsage(); // E8: persist pre-park usage before the review pause (resume leg accumulates fresh)
199
+ await flushModelUsage();
514
200
  reached = { kind: "needs_review" };
515
201
  await runStore.setNeedsReview(taskId);
516
202
  metrics?.addGauge("runs_active", -1);
517
203
  metrics?.inc("tasks_total", { status: "needs_review" });
518
204
  };
519
- // [900]①/[906]③: the event→ledger projection (text/reasoning coalescing + redaction + seq + the type/data
520
- // whitelist shapes) lives in the SHARED LedgerSink — the sync /v1/tasks/stream detach lane consumes the same
521
- // single switch, so the two ledgers can't drift. This leg keeps its non-ledger side-legs (anchor/metrics/
522
- // model-usage/park-terminal/fleet) in place and drives the sink; `append`/`flush` below stay the leg-local
523
- // names for every out-of-band row (elicit/question frames, model_usage deltas, suggestions, failed) so they
524
- // share the run's single seq counter. `notifiedKeys` is the same per-leg instance the observer/drain arms use.
525
205
  const sink = createLedgerSink({ appendEvent: (seq, type, data) => runStore.appendEvent(taskId, seq, type, data), persistThinking, notifiedKeys, onActivity: () => recordTurnActivity(taskId) });
526
206
  const append = (type, data) => sink.append(type, data);
527
207
  const flush = () => sink.flush();
528
- // E8 (shell-host contract): drain the per-task model-usage accumulator into an append-only `model_usage` DELTA event, so
529
- // the durable log is the resume-safe source of `TaskStats.modelUsage`. Called at every turn boundary AND before a
530
- // park (suspend/needs_review) — a run that suspends has its pre-suspend usage persisted before the resume leg
531
- // continues accumulating; the append-only deltas SUM across all legs (no overwrite).
532
208
  const flushModelUsage = () => appendModelUsageDelta(append, modelUsage, taskId);
533
- // At a TERMINAL done: flush the final delta + SUM all `model_usage` events into result.stats.modelUsage (shared
534
- // with the resume leg's terminal so a suspended→resumed run's total is summed across both legs).
535
209
  const withModelUsage = (result) => attachModelUsage(result, { append, getEvents: (id, after) => runStore.getEvents(id, after), modelUsage, taskId });
536
- modelUsage?.register(taskId); // E8 leak fence: only this top-level run's brain.calls are tracked (sub-tasks excluded)
537
- // [998]②: same fence — sub-task manifests are never registered, never leak. The returned LEASE guards the
538
- // finally's clear (B-3 window: a fast sibling resume re-registers this taskId before our finally runs —
539
- // an unconditional clear would delete the NEW leg's registration/pending manifest).
210
+ modelUsage?.register(taskId);
540
211
  const manifestLease = promptManifests?.register(taskId) ?? 0;
541
- // [998]②: no-op when nothing is pending (one Map.get) — fired per stream event so the manifest lands at the
542
- // top of the log right after core's prepare records it, and before the terminal on the no-stream legs.
543
212
  const flushPromptManifest = () => appendPromptManifest(append, promptManifests, taskId);
544
- // E23 (shell-host contract): an inbound MCP elicitation fired deep inside core's callTool surfaces via onElicit, which has
545
- // no taskId — so establish a per-run elicitation context (ALS) carrying this run's emit (durable append → delivered
546
- // on the events tail), owner (respond owner-gate) + cancel signal (a run abort cancels a parked elicitation). The
547
- // elicitation append interleaves safely: onElicit is awaited SYNCHRONOUSLY inside callTool, so the loop is parked
548
- // (not appending) while it runs. No-op when elicitation is off. `type` is the SSE event name; the rest is the data.
549
213
  const elicitEmit = (frame) => {
550
214
  const { type, ...rest } = frame;
551
215
  return append(type, rest);
552
216
  };
553
- // §4④: same durable-append emit for AskUserQuestion frames (delivered on the events tail; the shell renders `question`
554
- // + dismisses on `question_complete`). onQuestion is awaited SYNCHRONOUSLY inside core's callTool, so the loop parks
555
- // (not appending) while it runs — the open frame's append is ordered ahead of the answer/terminal appends.
556
217
  const questionEmit = (frame) => {
557
218
  const { type, ...rest } = frame;
558
219
  return append(type, rest);
559
220
  };
560
- // #151 车3 刀 3b(design/172 §4.3(b)):同款 durable-append emit,服务**审批**帧 —— 既有
561
- // `tool_approval`/`tool_approval_complete` 走它,新协议的 `approval_request` 也走它(同一条投递面、
562
- // 两种帧,§4.3(a) 的「不新增出口」在这条腿上的落实)。`type` 是 SSE 事件名,其余是 data。
563
- // 撤卡帧(`approval_revoke`)**不接**:它是 live-only 语义(不进账本 —— reaper/收敛器那类调用点没有
564
- // seq 可分配),而本腿的唯一投递面就是账本;丢帧的结构补偿是重连 preamble 的全量对账基准。
565
221
  const approvalEmit = (frame) => {
566
222
  const { type, ...rest } = frame;
567
223
  return append(type, rest);
568
224
  };
569
- // #310 `engine_notice` wire 腿(设计稿 = 黑板 [4630],三腿之一:**bg**)。本腿**没有 live SSE**
570
- // (消费方走 `GET /v1/runs/:id/events` 的 durable tail),所以只挂 durable 口 —— 这与本腿的 HITL 帧
571
- // (elicit/question/approval 三族)姿态逐字相同:唯一投递面就是账本,tail 负责送到人眼前。
572
- // fire-and-forget:`onNotice` 是 core 的同步回调,不能拿一次账本写去阻塞引擎;seq 在 `append` 的链步内
573
- // 同步分配 ⇒ 与主循环的 await 写不会乱序(`onForwardEvent` 同款契约)。
574
- // 🔴 写失败**留痕不裸吞**(codex 对抗复审 R1-[medium],验真后修):本腿账本是通告的**唯一**用户可见
575
- // 终点,一次 store 抖动就让它对用户永久消失;`route` 的同步 try 观察不到异步 reject。同文件 resume 腿
576
- // 的 `warnAppend` 是同一条纪律的先例(C2/C5 批2)。
577
225
  const unregisterEngineNotice = registerEngineNoticeLeg({
578
226
  sessionId: spec.sessionId,
579
- // (本函数没有 logger 席 —— 留痕走 `recordFailOpen` 的三件套:逐次计数 + 逐次探针行[带 detail] +
580
- // 一次性 warn。这正是 #157 为「结构上拿不到 logger 的兜底臂」准备的口,不为它加一个参数。)
581
227
  durable: (row) => void append("engine_notice", row).catch(() => recordFailOpen("server.engine-notice.durable-append-failed", `leg=bg code=${row.code} task=${taskId}`)),
582
228
  });
583
229
  try {
584
- // P1 ①② follow-on (core): drain this session's async-workflow completion inbox at the START of the
585
- // background leg (before ANY branch — verify/cascade/plain-stream all get it; parity with the sync leg's
586
- // stream-open drain) into durable `workflow_complete` events the tailing client (GET /v1/runs/:id/events)
587
- // replays as <task-notification>s. Best-effort inside the helper (it never throws); inside the try so the
588
- // heartbeat/gauge finally-cleanup would cover even a defensive miss. Owner gate mirrors the elicit/question owner.
589
230
  await emitPendingWorkflowCompletions(workflowCompletionInbox, spec.sessionId, elicitOwner ?? owner, (frame) => {
590
231
  const { type, ...rest } = frame;
591
- // core [784]④b: register the drained frame's key on THIS leg — the same idle-arrival was ALSO parked
592
- // core-side (pendingSessionNotifications); its pend-drain replay rides this run's stream as a
593
- // task_notification event, and the loop's key check folds that second durable append (sync twin's note).
594
- // 三路复审修2(MED,推翻旧 codex F1 注释的 resolved(true) 登记):emitPendingWorkflowCompletions
595
- // 虽 await 这次 append,但它 CATCH 失败(留 row 不 ack)——键却已被 Promise.resolve(true) 认领;
596
- // core 若把同一 pending 经 pend-drain 重放到本腿 stream 臂,stream 臂见「真成功」claim 抑制重写,
597
- // 通知就延到下一次开腿(可能无限延)。登记真实 append 结果:失败 resolve false,stream 臂
598
- // await 到 false 照写(咬合 NotifiedKeys 的 F1 语义);row 未 ack 的下腿重投仍是兜底。
599
- // codex F2: task_type rides into the key(drained frames restore it from the entry extras)——
600
- // external 帧独立键域,不与内部任务帧互吞.
601
232
  const f = frame;
602
233
  const write = append(type, rest);
603
234
  if (f.type === "task_notification" && f.task_id)
604
235
  notifiedKeys.set(taskNotificationStreamKey({ task_type: f.task_type, task_id: f.task_id, status: String(f.status), seq: f.seq }), write.then(() => true, () => false));
605
236
  return write;
606
- },
607
- // ask①: emit-target diagnostics — this drain appends to THIS background run's durable event log.
608
- completionDiagLog ? { route: "bg-run-start", connection: "durable-append", log: completionDiagLog } : undefined);
609
- // Thread the owning principal so the cost tracer attributes this run's spend (incl. council/team
610
- // sub-tasks) to the principal's cumulative quota — mirrors the synchronous path.
237
+ }, completionDiagLog ? { route: "bg-run-start", connection: "durable-append", log: completionDiagLog } : undefined);
611
238
  const runBody = async () => {
612
- // core 1.154 nested-subagent live tree: opt into the forward sink — a subagent's task_progress lives in the
613
- // child run's ISOLATED stream (core's only emitter is parentToolCallId-gated and pushes onto the CHILD queue;
614
- // the top stream NEVER carries it), so this sink is the ONLY path a tick reaches the top-run consumers. 1.78
615
- // (review HIGH): fan it out to the DURABLE event log too (via the shared whitelist+redact builder, fire-and-
616
- // forget — `++seq` is a sync increment so sink-vs-loop appends get unique seqs), not just the fleet bus —
617
- // previously no fleet bus wired ⇒ ticks dropped entirely, and the stream-switch task_progress case below was
618
- // forward-compat then — [2373]B-4:floor ^4.0.0 下它是热路径(腐注方向已翻转,勿按旧注当死支)。core swallow-guards a throwing sink.
619
- // [3911] 定谳:per-agent tail 的**内容帧供给**由本腿 spec 的 forwardSubagentEvents 决定(core
620
- // prepare-task 的 forward 门);缺席 ⇒ 那条流恒只有 task_progress。逐帧按**句柄**登记本腿的供给形,
621
- // 让 tail 的 meta 帧如实声明(键为什么必须是句柄:SubagentTailBus.modeByHandle 顶注)。
622
- //
623
- // 🔴 **#253(core 5.33.0 #246 提货)起,这只 internals 在 verify/cascade 早返分支之前铸**:三条腿
624
- // (verify 形 / cascade 形 / 普通流形)用**同一只**对象。5.32 及以前 core 的 `runWithVerification` /
625
- // `runCascade` 签名里根本没有这一位,那两条腿因此对子代进度、活性打点、bg 终态通知**全盲**;5.33.0
626
- // 开了尾可选 `internals` 座位并逐字转发给它驱动的每一条腿(实现腿/验证器腿/修复腿/每一级 rung),
627
- // 于是这里只需要把既有供给链**接上去**,不新铸第二条链(黑盒判据见
628
- // `test/issue190-status-wire-disclosure.test.ts` §4)。
629
- // ⚠️ **`isolation` 一位永不传**:core 对 `internals.isolation` 在 verify/cascade 门上双相位响亮拒
630
- // (`config.verify_internals_invalid` —— 这道门一次跑好几条腿,而 isolation 是 per-run 铸工作区),
631
- // 本仓从不铸这一位,座位对象里也不许出现它。
632
239
  const tailContentMode = spec.forwardSubagentEvents === true ? "on" : "progress_only";
633
240
  const fwdInternals = {
634
- // #253:活性打点 —— 「只回结果」形没有事件循环,`recordTurnActivity` 此前只挂在 LedgerSink 的
635
- // append 链上(那条链在 verify/cascade 腿上整段任务只响一次:终局 done)。core 的 onActivity 在
636
- // 每次工具 start/end 同步回调,于是一条长跑的 verify 腿不再被 `GET /v1/runs/:id` 的
637
- // `msSinceLastActivity` / 409 冲突体读成僵死。普通流形不经它(那条腿的打点在 sink 里,同一个函数,
638
- // 重复打点无害但也无必要——本键对三腿一律在场,少一条判别分支)。
639
241
  onActivity: () => recordTurnActivity(taskId),
640
242
  onForwardEvent: (e) => {
641
243
  fleetPublisher?.onForwardEvent(e);
642
- // C1 (core 1.219): a delegated child's tool lifecycle + task_progress ticks persist through the SHARED
643
- // sink (same whitelist+redact builders as the top stream — §E1, forwarded events are UNTRUSTED RAW;
644
- // fire-and-forget with sync seq allocation). Rationale + the deliberate per-chunk-delta omission live
645
- // on LedgerSink.onForwardEvent.
646
- //
647
- // 🔴 **别在这里加「腿结束就不落账」的围栏**(#253 施工期实测:加了当场打红 `runs-coverage` 的
648
- // [1840]§三定性钉)。那条钉是**取证结论**,不是习惯:`run_in_background` 收养形子代的**整个**
649
- // 生命周期都在父 run 终态之后,live events 流按设计 terminal break —— 帧照落账,消费端 done 后
650
- // 重拉即全见。撕掉 sink = 对那一族子代**真丢帧**(cli [1839] 轮②「子代内层零转发」的病因分析里
651
- // 写死了这一条)。codex 对抗复审 R2 [medium] 提的正是「终局后不该再落行」,**验真后驳回**:
652
- // 本仓已有属主裁定 + 复现器 + 防回归钉,不能因为一次静态复审把它反过来。
653
244
  sink.onForwardEvent(e);
654
- // S2 live tail(core 1.370 bgAgentId,[1520]{core}):bg 子代帧按句柄分发给 per-agent 订阅者
655
- // (sync 腿 twin 在 http/server.ts)。hasSubscribers 快门=没人听不投影;投影一次 N 订阅者共享。
656
245
  const bg = e.bgAgentId;
657
246
  if (bg !== undefined) {
658
- // 供给形登记在 hasSubscribers 快门**之前**:没人在听时也要学,否则晚开的 tail 读不到。
659
247
  defaultSubagentTailBus.noteHandleContentMode(bg, tailContentMode);
660
248
  if (defaultSubagentTailBus.hasSubscribers(bg)) {
661
249
  const f = projectTailFrame(e);
@@ -664,66 +252,26 @@ parkCheckpointReader) {
664
252
  }
665
253
  }
666
254
  },
667
- // (durable half is the deployment's job): observe EVERY background
668
- // completion notification. (1) flip the child's fleet row (task_progress never emits a terminal tick —
669
- // without this a settled bg agent showed "running" until parent settle); (2) when the LEG already ended
670
- // (idle — core's steer/followUp had nowhere to go), park it in the durable inbox; the next stream-open
671
- // drain emits it as the same `task_notification` frame family. Workflow-type notifications are SKIPPED —
672
- // they have their own durable chain (notify gate + inbox + serve fences); double-enqueueing would re-open
673
- // the double-push. While the leg is LIVE, core injects in-process + the stream carries the display frame —
674
- // no durable copy (a boundary-race notification that misses both is covered by the TaskOutput poll floor).
675
- // core 1.283 `seq` (per-stop-cycle counter) rides into the dedup key + parked entry — core [784]④a.
676
- // 白名单排查(clay 令,2026-07-24,「彻查 as {} 连环坑」):此前手写窄类型(仅 10/19 键),3 处
677
- // (此处+http/server.ts 两处)复制粘贴、随 core payload 增field 逐渐过时——n 本身按引用直转
678
- // taskNotificationEventData({notification:n}),运行时不丢字段(该函数自己的白名单是完整的),
679
- // 但窄类型会让未来在这些回调体内新增读字段的人被 TS 误导「这个字段到不了这里」。改用 core
680
- // 真实导出类型,杜绝副本漂移。
681
255
  onTaskNotification: (n) => {
682
256
  if (n.task_type === "workflow")
683
257
  return;
684
- // S2 复审 #3(sync 腿同注):中途终态信号。
685
- // [3911] 终态即撤供给形登记:同一 a* 句柄可进入下一个**复活周期**,而新周期的宿主腿模式
686
- // 可能相反——不撤会把上一周期的答案报给新周期的首帧前窗口(理由全文见 forgetHandleContentMode)。
687
258
  defaultSubagentTailBus.forgetHandleContentMode(n.task_id);
688
259
  if (defaultSubagentTailBus.hasSubscribers(n.task_id)) {
689
260
  defaultSubagentTailBus.publish(n.task_id, { type: "task_settled", taskId: n.task_id, status: n.status, ...(typeof n.seq === "number" ? { seq: n.seq } : {}), ...(n.summary ? { summary: redactSecrets(n.summary) } : {}) });
690
261
  }
691
- // id-domain alias (core): fleet child rows key by the TICK's taskId = the child's sessionId
692
- // (uuid domain); the notification's task_id is the a* domain — payload.sessionId IS the tick-domain
693
- // alias, so flip by it (fallback task_id for pre-1.238 payloads).
694
- // [2687-cli] 幽灵行案:onChildTerminal 只对 **agent 族终态**调用(isFleetAgentTerminalNotification
695
- // 单源判别,理由与病灶链见其 doc 注)。通知帧/park 面照走,只掐 fleet 铸行。三消费点孪生同形
696
- // (http/server.ts resume 腿、routes/tasks.ts sync 腿)。
697
262
  const hadRow = isFleetAgentTerminalNotification(n) ? (fleetPublisher?.onChildTerminal(n.sessionId ?? n.task_id, n.status, n.task_id, n.toolUseId) ?? false) : false;
698
263
  const parked = !legLive && Boolean(workflowCompletionInbox && spec.sessionId);
699
- // diagnosability: one line per observed bg completion. hadFleetRow=false = the terminal frame was
700
- // FLIP-THROUGH synthesized (the row was gone — parent settle removed it, or a bg BASH never ticked).
701
264
  completionDiagLog?.("task_notification_observed", { route: "bg-run-start", taskId: n.task_id, taskType: n.task_type, status: n.status, hadFleetRow: hadRow, legLive, parkedDurable: parked });
702
265
  if (parked) {
703
- // 对抗评审 2026-07-11(MED):log the enqueue failure — the diag line above already printed
704
- // parkedDurable:true, so a swallow made that a false park✓ fingerprint. Never faults the callback.
705
266
  void workflowCompletionInbox.enqueue(taskNotificationInboxEntry(spec.sessionId, elicitOwner ?? owner, n, Date.now(), taskId)).catch((err) => completionDiagLog?.("park_enqueue_failed", { route: "bg-run-start", taskId: n.task_id, err: err instanceof Error ? err.message : String(err) }));
706
267
  }
707
268
  else if (legLive) {
708
- // option A (the turn-TAIL killed receipt must be IMMEDIATE): a teardown-time reap
709
- // notification fires while the leg is still live but past the stream's delivery window — core's
710
- // in-process steer is swallowed and the event never reaches the log. Append the display frame HERE,
711
- // skipping keys the stream already delivered (mid-turn dedup); the tailing client renders the receipt
712
- // this turn, not one turn late. core [784]④a: seq-folded shared key — the seq-less
713
- // `${task_id}:${status}` shape reverse-swallowed a second stop-cycle's same-status frame here.
714
- // codex F1 (HIGH): this append is fire-and-forget — claim the key with its REAL outcome, not
715
- // unconditionally. A failed append (SQL blip) resolves the claim false, and the stream arm's
716
- // await-then-write below re-delivers the same key instead of being swallowed by a dead claim.
717
269
  const key = taskNotificationStreamKey(n);
718
270
  if (notifiedKeys.get(key) === undefined) {
719
271
  notifiedKeys.set(key, append("task_notification", taskNotificationEventData({ notification: n })).then(() => true, () => false));
720
272
  }
721
273
  }
722
274
  },
723
- // C2 (core 1.219) + design/122 (core 1.225 ③): register each SYNC child's steer handle under THIS
724
- // run's taskId. NOT evicted on settle — `resume` is only legal AFTER the child settled (a settle-evict
725
- // would make revival structurally unreachable). Evicted at LEG END via the collected identity-guarded
726
- // thunks (finally below); a resume re-emits a FRESH handle under the same id (registry newest-wins).
727
275
  ...(subagentSteer
728
276
  ? {
729
277
  onSubagentSpawn: (handle) => {
@@ -732,48 +280,18 @@ parkCheckpointReader) {
732
280
  }
733
281
  : {}),
734
282
  };
735
- // 🔴 **#255(core 5.34.0 #253 / 判据帖 [3967] G1):status 第五席** —— 与上面四席**不同**,它只在
736
- // 「只回结果」的两条腿上展开,**绝不**挂到 `runTaskStream` 那一位。理由是 core 的 `statusEmit` 是
737
- // **双写**(`queue.push` 照旧 + 座位在场即喂**同一个** frozen 帧对象):普通流形的那条 queue 就是本
738
- // 函数自己 `for await` 的流 —— 下面 loop 里 `sink.onEvent(ev)` 已落账、`metrics.inc` 已计数 ⇒ 两处
739
- // 都写 = **同一帧两行账本 + 计数翻倍**。组合腿(verify/cascade)没有这条流(引擎把内部腿的 queue
740
- // 自己排干丢弃),座位是它**唯一**的载体,这正是 5.34.0 开这一席的理由。
741
- // (反向钉:`test/issue190-status-wire-disclosure.test.ts` §4⑤ —— 夹具照 core 的真姿势 dual-write,
742
- // 普通流形上账本必须恰五行不是十行。)
743
- //
744
- // ⚠️ **帧是 frozen 的**:core 在双写前 `Object.freeze(frame)`,所以这条链上任何一环都不许写帧字段
745
- // (改一下再往下传会当场 TypeError)。`brainStatusEventData` 是纯读投影,不改源帧。
746
- // ⚠️ **同步回调不能 await**:失败留痕不断链(孪生姿势 = `onForwardEvent` 的 fire-and-forget 写)。
747
- // ⚠️ 下面那个 `.catch` **买的不是进程安全**(别照抄别处的「逃逸 rejection 杀进程」说法):
748
- // `sink.appendStatus` 返回的是**链步**promise,而 `LedgerSink.append` 内部已经给每一步挂了处理器
749
- // (`chain = step.then(ok, err)`,「失败不断链」)⇒ 调用方 `void` 掉也不会有逃逸的 rejection
750
- // (实测:摘掉这个 `.catch`,issue190 §4⑥/⑥b 仍绿)。它买的是**留痕** —— 披露行被静默丢掉时至少
751
- // 有一行诊断可查;判别力由 §4⑥ 的「诊断行在场」断言看守。
752
283
  const statusSeat = {
753
284
  onStatusEvent: (e) => {
754
- // S4 P0-a 计数腿:与普通流形 loop 的 `case "status"`、sync 腿、resume 腿同键同标签。
755
285
  metrics?.inc("brain_retry_total", { phase: String(e.phase) });
756
- // 账本腿:共享 LedgerSink 的 **`appendStatus` 同步入链门**(与 `onEvent` 的 `case "status"` 共用
757
- // 同一个白名单 builder ⇒ 行的 type/data 与其余三腿逐字同形;判据 = issue190 §2/§3/§4 共用一份
758
- // EXPECTED_ROW_DATA)。🔴 **别改回 `sink.onEvent(e)`**:那条路先 `await flush()` 才入链,同一拍
759
- // 后到的 `onForwardEvent`(同步入链)会抢到更小的序号 ⇒ 账本时间线倒挂(codex 对抗复审 R2
760
- // [medium],红先复现后修;反向钉 = issue190 §4⑤b)。
761
286
  void sink.appendStatus(e).catch((err) => completionDiagLog?.("status_event_append_failed", { route: "bg-run-composite", taskId, err: err instanceof Error ? err.message : String(err) }));
762
287
  },
763
288
  };
764
289
  if (verify !== undefined) {
765
- // Developer-mode verification gate (1.44): multi-round (impl → adversarial verify → fix loop), not
766
- // a stream — await the result and persist it terminal. The durable event log gets just the `done`
767
- // (the verifier subtask runs on its own session). `result.verification` carries the verdict.
768
- // #253(core 5.33.0 #246):第 4 位 = 供给座席,core 逐字转发给实现腿 + 验证器腿 + 每一轮修复腿。
769
- // #255(core 5.34.0):`statusSeat` 只在这两条「只回结果」腿上展开(理由见其顶注)。
770
290
  const result = await runWithVerification(runner, { ...spec, taskId, signal: cancelCtrl.signal }, verify, { ...fwdInternals, ...statusSeat });
771
291
  await flush();
772
292
  evictIfConflict(runner, spec.sessionId, result);
773
- // A nested suspend under the verify gate is mapped by core to status:"failed" carrying the capability
774
- // token strip it before it touches the replayable log / run row (search [18] Q1b).
775
- await flushPromptManifest(); // [998]②: the verify leg has no stream — persist the manifest before the terminal
776
- const safe = await withModelUsage(stripCheckpointToken(result)); // E8: attach stats.modelUsage at terminal
293
+ await flushPromptManifest();
294
+ const safe = await withModelUsage(stripCheckpointToken(result));
777
295
  await append("done", { result: safe });
778
296
  reached = { kind: "done", result: safe };
779
297
  await runStore.setTerminal(taskId, safe.status, safe, safe.errorMessage ?? null);
@@ -781,74 +299,42 @@ parkCheckpointReader) {
781
299
  return;
782
300
  }
783
301
  if (cascade !== undefined) {
784
- // Cascade (1.45): cheap→strong ladder; multi-attempt cold re-runs, not a stream. Persist terminal.
785
- // #253(core 5.33.0 #246):第 4 位 = 供给座席,core 逐字转发给**每一级 rung**(顺序跑,一只 sink
786
- // 按梯序拼接收到各级的帧 —— core 的 ATTRIBUTION 注写明 ToolActivity 自身不带 rung 身份)。
787
302
  const result = await runCascade(runner, { ...spec, taskId, signal: cancelCtrl.signal }, cascade, { ...fwdInternals, ...statusSeat });
788
303
  await flush();
789
304
  evictIfConflict(runner, spec.sessionId, result);
790
- await flushPromptManifest(); // [998]②: same no-stream posture as the verify leg above
791
- const safe = await withModelUsage(stripCheckpointToken(result)); // see the verify branch; E8 modelUsage at terminal
305
+ await flushPromptManifest();
306
+ const safe = await withModelUsage(stripCheckpointToken(result));
792
307
  await append("done", { result: safe });
793
308
  reached = { kind: "done", result: safe };
794
309
  await runStore.setTerminal(taskId, safe.status, safe, safe.errorMessage ?? null);
795
310
  recordDone(safe, safe.status);
796
311
  return;
797
312
  }
798
- // design/47 (mid-task steering): hold the live stream handle so POST /v1/runs/:id/steer can inject a steer via
799
- // stream.steer() (drained at the next turn boundary). Only this plain-stream path is steerable — the verify/
800
- // cascade branches above return a result, not a stream (parity with the preemptable registration). Deregistered
801
- // in `finally`. Registering it AFTER the verify/cascade early-returns means those legs never enter the map.
802
- // (供给座席 `fwdInternals` 已在本函数顶部铸好并被三条腿共用 —— 见那里的顶注。)
803
313
  const stream = runner.runTaskStream({ ...spec, taskId, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, undefined, fwdInternals);
804
- steerableStreamRef = stream; // held for the finally's identity-guarded delete (1.78 review LOW)
314
+ steerableStreamRef = stream;
805
315
  steerable?.set(taskId, stream);
806
316
  for await (const ev of stream) {
807
- // MF-Fleet: accrue this run's fleet-row tokens (turn_end usage). Fired before the switch so every event
808
- // reaches it. No-op when no bus is wired. (Subagent task_progress arrives via the FORWARD sink above, not
809
- // this top stream — the case below is forward-compat only; a stale comment here claimed otherwise.)
810
317
  fleetPublisher?.onEvent(ev);
811
- // [998]② release-review round 2: drain the pending prompt manifest at the TOP of every iteration —
812
- // BEFORE the done/suspend branches below. A done-first stream (post-prepare failure/abort, or an
813
- // immediate suspend) would otherwise skip the flush entirely: the manifest recorded at prepare would
814
- // be cleared by the finally (or, worse, survive a lease-guarded no-op clear into a fast sibling
815
- // resume's queue and land AFTER the suspension event). Here it always precedes its own terminal.
816
318
  await flushPromptManifest();
817
- // Non-ledger side-legs stay HERE (E18 anchor capture / S4 metrics) — the event→ledger projection itself
818
- // is the shared sink below (single switch; the sync detach lane consumes the same one, so the persisted
819
- // type/data vocabulary has ONE source). Ordering parity: anchor.onTool precedes the sink's tool_start
820
- // flush (as before); the metric fires before the brain_status row lands (metric ≠ ledger, no row moves).
821
319
  switch (ev.type) {
822
320
  case "text_delta":
823
- anchor.onText(ev.eventId); // E18: latch the turn's first-text eventId = the message's resume handle
321
+ anchor.onText(ev.eventId);
824
322
  break;
825
323
  case "tool_start":
826
- anchor.onTool(); // E18: a tool ran this turn → not a settled assistant-text boundary (don't anchor it)
324
+ anchor.onTool();
827
325
  break;
828
326
  case "status":
829
- // S4 (SILENT-FALLBACK P0-a): count the brain-layer liveness observation (the sink persists the row —
830
- // previously: sync SSE only, async runs completely dark).
831
327
  metrics?.inc("brain_retry_total", { phase: String(ev.phase) });
832
328
  break;
833
329
  case "message_committed":
834
- // R8 (CC-parity rewind): a persisted session entry was committed. Capture a USER-message rewind anchor
835
- // (keyed by taskId) straight from its `entryId` — the CC "rewind to the prompt" target. assistant entries
836
- // are anchored by the turn-text path; tool-results are deliberately not anchored (resumeAt rejects them).
837
- // NOT forwarded to the durable trace / shell stream (core口径: replay walks the persisted session tree —
838
- // the sink deliberately ignores this type).
839
330
  await anchor.onMessageCommitted(ev.role, ev.entryId);
840
331
  break;
841
332
  }
842
333
  if (ev.type === "done") {
843
334
  await flush();
844
335
  if (ev.result.status === "suspended") {
845
- // Durable F4 pause (design/45): PARK the run (non-terminal), NOT a `done`. Never persist the
846
- // capability `checkpointToken` to the replayable event log / run row (search [18] Q1b) — only the
847
- // non-secret gate (sink.appendParked). The submitter polling GET /v1/runs/:id sees status:"suspended";
848
- // an operator resumes via /v1/approvals (token looked up internally). The worker is freed (runs_active -1).
849
- // [4913]:graft 先于投影——事件里带待批 toolCallId(tool-less park 键缺席;读失败=F 类留痕,不挡 park)。
850
336
  await sink.appendParked("suspended", await graftParkToolCallId(ev.result, parkCheckpointReader));
851
- await flushModelUsage(); // E8: persist pre-suspend usage before parking (resume leg accumulates fresh)
337
+ await flushModelUsage();
852
338
  reached = { kind: "suspended" };
853
339
  await runStore.setSuspended(taskId);
854
340
  metrics?.addGauge("runs_active", -1);
@@ -856,18 +342,12 @@ parkCheckpointReader) {
856
342
  continue;
857
343
  }
858
344
  if (ev.result.status === "needs_review") {
859
- // design/80 D-B: a review pause (plan_review / dry_run_review) — PARK it like a suspend (keep the lock)
860
- // so the /v1/assistant/tasks/:id/plan_review resume can claim the row. NEVER persist the token.
861
345
  await parkNeedsReview(ev.result);
862
346
  continue;
863
347
  }
864
- // A session CAS conflict arrives here as a failed result (core swallows it) — evict the
865
- // now-stale warm-cache entry so the next turn re-wakes from TiDB instead of re-conflicting.
866
348
  evictIfConflict(runner, spec.sessionId, ev.result);
867
- // Structural token fence (no-op today; this plain-stream path's only token-bearing result is the
868
- // suspended branch above, handled separately) — symmetric with the verify/cascade + resume paths.
869
349
  {
870
- const safe = await withModelUsage(stripCheckpointToken(ev.result)); // E8: attach stats.modelUsage at terminal
350
+ const safe = await withModelUsage(stripCheckpointToken(ev.result));
871
351
  await sink.appendDone(safe);
872
352
  reached = { kind: "done", result: safe };
873
353
  await runStore.setTerminal(taskId, safe.status, safe, safe.errorMessage ?? null);
@@ -875,39 +355,17 @@ parkCheckpointReader) {
875
355
  }
876
356
  continue;
877
357
  }
878
- // The shared event→ledger projection (text/reasoning coalescing, whitelist+redact builders, flush-before
879
- // discipline, task_notification dedup against this leg's `notifiedKeys`) — see LedgerSink.onEvent.
880
- // (The prompt-manifest drain runs at the loop TOP — ahead of done/suspend too, see above.)
881
358
  await sink.onEvent(ev);
882
359
  if (ev.type === "turn_end") {
883
- await flushModelUsage(); // E8: persist this turn's per-model usage delta (resume-safe)
884
- // E18 resume-at: anchor (this turn's message eventId → its leaf SessionTreeEntry.id), but ONLY for a settled
885
- // assistant-TEXT turn (text + no tools) — for a tool-using turn the leaf here is the last tool-result, NOT
886
- // the message the eventId names (see TurnAnchorCapture). Best-effort + reset for the next turn.
360
+ await flushModelUsage();
887
361
  await anchor.onTurnEnd();
888
362
  }
889
363
  }
890
- // E12 (shell-host contract): after a COMPLETED run, pull core's opt-in post-completion prompt suggestions (a fire-and-forget
891
- // pass in core; suggestions() never rejects + resolves to [] when off / not completed / empty). UNTRUSTED model
892
- // text for the shell UI ONLY → redact + persist as a `suggestions` event on the durable ledger; NEVER re-fed to a
893
- // model. ⚠️ 读法(TASKS-RUNS-1 注漂移修,[2400] 审计):它落账在 setTerminal **之后**,而 live tail
894
- // (sse-log)在终态时有意**不投递**这一帧(见 sse-log.ts 注)——消费契约是 `await result()` 后从
895
- // `GET /v1/runs/:id` 的 RunRecord.suggestions 读,**不是**从 events tail 等它。 Gated on a COMPLETED terminal — a suspended/parked/failed leg has
896
- // none (core also returns [] for a non-completed task; the explicit status gate avoids the needless call + pins the
897
- // completed-only contract rather than depending on core's guard).
898
364
  if (spec.suggestNextPrompts && reached?.kind === "done" && reached.result.status === "completed") {
899
365
  const suggestions = await stream.suggestions().catch(() => []);
900
- // 🔴 **空结果也要落事件**(2026-07-26):这个事件是「已结算」的唯一信号。此前 `if (suggestions.length)`
901
- // 才 append ⇒ 「跑完了、确实一条都没有」在轮询面上与「还没跑完」完全同形(都是 `suggestions: undefined`)
902
- // ⇒ 轮询方无从判断该不该停。而 `setTerminal`(上方)在本段**之前**执行,所以确实存在一个真实的
903
- // pending 窗口 —— 这正是本仓刚在后台 shell `poll()` 上修过的**同一类**:消费方拿终态当停止条件,
904
- // 而数据在终态之后才到。⇒ 事件在 = 已结算(哪怕是空);事件不在 = 仍在跑。
905
366
  await append("suggestions", { suggestions: suggestions.map((s) => redactSecrets(s)) });
906
367
  }
907
368
  };
908
- // E23 + §4④: nest the elicitation AND AskUserQuestion per-run contexts (each its own ALS) around the run body — a
909
- // background run's inbound elicitation AND the agent's AskUserQuestion both route to this run's durable events tail
910
- // + owner-gated respond. Same id/owner/signal; the nesting order is immaterial (disjoint ALS).
911
369
  await withPrincipal(principal, () => {
912
370
  const withElicit = elicitation
913
371
  ? () => elicitation.runWithContext({ taskId, owner: elicitOwner ?? owner, emit: elicitEmit, abortSignal: cancelCtrl.signal }, runBody)
@@ -915,14 +373,6 @@ parkCheckpointReader) {
915
373
  const withQuestion = () => question
916
374
  ? question.runWithContext({ taskId, owner: elicitOwner ?? owner, emit: questionEmit, abortSignal: cancelCtrl.signal }, withElicit)
917
375
  : withElicit();
918
- // #151 车3 刀 3b:审批 ALS —— 本腿**首次**拿到卡面(此前 `deps.onAsk` 在这条腿上恒无 ctx ⇒
919
- // `"unavailable"` ⇒ 恒 park)。嵌套序同样无关(各自独立 ALS)。
920
- // 🔴 F3 修:包不包 ALS 由 `approvalLeg` 决定,不再是「协调器在场就包」。
921
- // · `active === false`(协议没上场)⇒ 不包 ⇒ `deps.onAsk` 找不到 ctx ⇒ `"unavailable"` ⇒ core
922
- // durable park —— 这正是本腿在协议之前的逐字行为(A-1)。
923
- // · `windowZero`(运维关窗 / 贴 deadline)⇒ 同样不包。本腿没有 per-task `spec.onAsk` 装配点,
924
- // 「不包 ALS」就是 sync 腿那个 immediate-unavailable 闭包在这条腿上的**等价形**(§8.2 已亲证:
925
- // `approverUnavailable === true` 与「onAsk 缺席」在 park 结局上逐字等价)。
926
376
  const withApproval = () => approval && approvalLeg.active && !approvalLeg.windowZero
927
377
  ? approval.coordinator.runWithContext({
928
378
  taskId,
@@ -930,22 +380,15 @@ parkCheckpointReader) {
930
380
  emit: approvalEmit,
931
381
  abortSignal: cancelCtrl.signal,
932
382
  ...(spec.sessionId ? { sessionId: spec.sessionId } : {}),
933
- // 同一条 durable tail(§4.3(b))。经共用的投递口工厂:账本 append 失败 ⇒ 如实算「这一路没
934
- // 接下」(本腿只有这一个 sink,于是=整条没送到),协调器据此走 park 而不是挂到窗到期(R2-1)。
935
383
  emitCard: createApprovalCardEmitter({ appendDurable: (f) => approvalEmit(f) }),
936
- // bg run 的这条腿恒是**首腿**(park→resume 走的是 server.ts 的 resume 腿,那条自己带
937
- // token 摘要);空串是首腿的真值,不是「未知」。
938
384
  legKey: "",
939
385
  ...(legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic } : {}),
940
386
  }, withQuestion)
941
387
  : withQuestion();
942
- // SendUserFile 帧与 question 帧同投递面(durable events tail,web 渲染文件卡);嵌套序无关(ALS 互不相交)。
943
388
  return sendUserFile
944
389
  ? sendUserFile.runWithContext({ taskId, emit: (frame) => { const { type, ...rest } = frame; return append(type, rest); } }, withApproval)
945
390
  : withApproval();
946
391
  });
947
- // Cancelled mid-stream and the generator ended WITHOUT a terminal event (abort can end the stream cleanly
948
- // rather than throwing) — finalize cancelled here so the run isn't left "running" for the reaper to catch.
949
392
  if (!reached && cancelCtrl.signal.aborted) {
950
393
  await flush();
951
394
  const c = cancelledResult();
@@ -958,41 +401,29 @@ parkCheckpointReader) {
958
401
  catch (err) {
959
402
  const message = err instanceof Error ? err.message : String(err);
960
403
  try {
961
- await flush(); // persist any buffered turn text before recording the failure (don't drop it)
404
+ await flush();
962
405
  if (reached?.kind === "done") {
963
- // The task already produced a terminal result (its `done` event is durably logged); only the run-row
964
- // write failed. Re-attempt THAT write — never write `failed` over a completed run.
965
406
  await runStore.setTerminal(taskId, reached.result.status, reached.result, reached.result.errorMessage ?? null);
966
407
  }
967
408
  else if (reached?.kind === "suspended") {
968
- await runStore.setSuspended(taskId); // parked, not failed — re-attempt the suspend row write
409
+ await runStore.setSuspended(taskId);
969
410
  }
970
411
  else if (reached?.kind === "needs_review") {
971
- await runStore.setNeedsReview(taskId); // D-B: parked on a review gate — re-attempt the review-park row write
412
+ await runStore.setNeedsReview(taskId);
972
413
  }
973
414
  else if (cancelCtrl.signal.aborted) {
974
- // Cancelled: the abort threw out of the stream/verify/cascade. Record a cancelled terminal (queryable
975
- // errorCode) instead of a generic failure, so the portal can show "cancelled by user" not an error.
976
415
  const c = cancelledResult();
977
416
  await append("failed", { errorMessage: c.errorMessage ?? "cancelled by user", errorCode: "cancelled" });
978
417
  await runStore.setTerminal(taskId, "failed", c, c.errorMessage ?? null);
979
418
  }
980
419
  else {
981
- // BL-12: redact the raw exception message before it lands in the durable event log / run row — a tool
982
- // or transport error can carry a secret (a credentialed URL, a token) and the trace surface is served
983
- // to TRACE_TOKEN holders. redactSecrets is best-effort + idempotent (the read side redacts again, BL-13).
984
420
  const safeMsg = redactSecrets(message);
985
421
  await append("failed", { errorMessage: safeMsg });
986
422
  await runStore.setTerminal(taskId, "failed", null, safeMsg);
987
423
  }
988
424
  }
989
425
  catch {
990
- /* best-effort: the stale reaper will catch a run we couldn't finalize */
991
426
  }
992
- // recordDone runs exactly once: the success paths call it inline (and return/break before the throw); we
993
- // reach here only if the terminal-row write threw AFTER the event was logged, or on a genuine failure.
994
- // A cancelled terminal keeps its distinct metric label even on this retry path (council B2: the result's
995
- // STATUS is "failed" — labeling by status would count a user cancel as a failure).
996
427
  if (reached?.kind === "done")
997
428
  recordDone(reached.result, reached.result.errorCode === "cancelled" ? "cancelled" : reached.result.status);
998
429
  else if (reached?.kind === "suspended") {
@@ -1000,23 +431,16 @@ parkCheckpointReader) {
1000
431
  metrics?.inc("tasks_total", { status: "suspended" });
1001
432
  }
1002
433
  else if (cancelCtrl.signal.aborted) {
1003
- recordDone(null, "cancelled"); // distinct metric label; the run row is "failed"+errorCode:"cancelled"
434
+ recordDone(null, "cancelled");
1004
435
  }
1005
436
  else {
1006
437
  recordDone(null, "failed");
1007
438
  }
1008
439
  }
1009
440
  finally {
1010
- legLive = false; // notifications from here on take the durable-inbox path
1011
- unregisterEngineNotice(); // #310:腿结束即注销 wire 口(下一条同会话腿自带自己的口)
1012
- // MF-Fleet: flip/remove the fleet row at the SINGLE finalization point — runs once for EVERY exit path
1013
- // (success / cancel / error / suspend / needs_review). A truly-terminal status leaves the fleet; a PARKED
1014
- // status (suspended→waiting / needs_review→awaiting approval) STAYS (the run is still active). A cancelled
1015
- // run carries status:"failed"+errorCode:"cancelled" — map it to "killed" so the row reads as a kill, not a
1016
- // failure. `reached` undefined ⇒ errored/aborted before any terminal event (catch recorded failed/cancelled).
441
+ legLive = false;
442
+ unregisterEngineNotice();
1017
443
  if (fleetPublisher) {
1018
- // [1839]①/(a) 案:真终态帧带残局键(usage=stats 映射 / cancel⇒stoppedBy:"user" / transcriptId=sessionId);
1019
- // parked(suspended/needs_review)不带(publisher 侧也再挡一层)。
1020
444
  if (reached?.kind === "done")
1021
445
  fleetPublisher.onTerminal(reached.result.errorCode === "cancelled" ? "cancelled" : reached.result.status, fleetRunResiduals(reached.result));
1022
446
  else if (reached?.kind === "suspended")
@@ -1025,34 +449,22 @@ parkCheckpointReader) {
1025
449
  fleetPublisher.onTerminal("needs_review");
1026
450
  else
1027
451
  fleetPublisher.onTerminal(cancelCtrl.signal.aborted ? "cancelled" : "failed", {
1028
- // errored/aborted before any terminal event — no TaskResult.stats to map; the transcript still exists.
1029
452
  ...(cancelCtrl.signal.aborted ? { stoppedBy: "user" } : {}),
1030
453
  ...(spec.sessionId ? { transcriptId: spec.sessionId } : {}),
1031
454
  });
1032
455
  }
1033
- // 对抗复查 B-3(HIGH):identity-guard these two like `steerable` below — the run row flips suspended
1034
- // BEFORE this finally runs, so a fast resume can re-register the SAME taskId (a fresh cancelCtrl /
1035
- // preemptCtrl) in the window; an unconditional delete then evicted the NEW leg's entry, and drain
1036
- // under-counted the resuming leg (its cancel/preempt handles also went unreachable).
1037
456
  if (inflight?.get(taskId) === cancelCtrl)
1038
457
  inflight.delete(taskId);
1039
458
  if (preemptable?.get(taskId) === preemptCtrl)
1040
459
  preemptable.delete(taskId);
1041
- // Identity-guarded (1.78 review LOW, parity with the sync/resume legs): only delete the handle THIS leg
1042
- // registered and only if it is still ours — a fast sibling leg re-registering the same taskId must not lose
1043
- // its fresh handle to our teardown; never registered ⇒ nothing of ours to delete.
1044
460
  if (steerableStreamRef !== undefined && steerable?.get(taskId) === steerableStreamRef)
1045
461
  steerable.delete(taskId);
1046
462
  for (const evict of subagentHandleEvictions)
1047
- evict(); // design/122 ③: leg-end handle eviction (identity-guarded — a fast sibling leg's fresh handles survive)
1048
- modelUsage?.clear(taskId); // E8: drop any un-drained accumulation (abandoned/errored leg) — no memory leak
1049
- promptManifests?.clear(taskId, manifestLease); // [998]②: lease-guarded — a stale leg never tears down the new leg's slot
463
+ evict();
464
+ modelUsage?.clear(taskId);
465
+ promptManifests?.clear(taskId, manifestLease);
1050
466
  clearInterval(heartbeat);
1051
467
  }
1052
468
  }
1053
- /** How often a running instance refreshes its run's updated_at (liveness, independent of events).
1054
- * Must stay strictly below `runStaleSec` (asserted at startup AND on every hot config candidate) or the
1055
- * reaper would race live runs. #322:数值的单源是 `config-invariants.ts`(那条不变量的属主),两个消费面
1056
- * ——心跳环与 run-stale 判据——因此不可能各读各的。 */
1057
469
  export const HEARTBEAT_MS = LIVENESS_HEARTBEAT_MS;
1058
470
  //# sourceMappingURL=runs.js.map