@defrex/autobuild 0.8.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 (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,2614 @@
1
+ /**
2
+ * The build-runner process (SPEC §3.3): one per build, owns one pipeline
3
+ * execution end to end. The kernel's job reduced to a loop (§8): read the
4
+ * event log, ask `decideNext` for the next move, execute it — start sessions,
5
+ * run check commands, append the events execution produces — and ask again.
6
+ * All judgment lives in the skills behind the AgentRunner; everything here is
7
+ * deterministic plumbing (constitution §2.1). That split makes the runner
8
+ * crash-safe by construction: killed mid-phase, a re-attached runner re-reads
9
+ * the log and `decideNext` re-decides the same started-but-unterminated phase
10
+ * from its start (§15.6-C) — resumability is not a feature (§2.2).
11
+ *
12
+ * Ownership: `attach` claims or renews the build's execution lease (§7.4) so a
13
+ * second sandbox can never execute the same build. The local supervising kernel
14
+ * reserves that same holder before launch. The runner may then acknowledge a pending pause or
15
+ * abort without preparing the workspace; workspace-backed work starts a
16
+ * heartbeat interval before setup (liveness is mutable columns, never events
17
+ * — §15.2.6). `run` exits when the build
18
+ * parks (§11: a parked build's runner exits; cron re-attaches when
19
+ * actionable). The runner releases nothing on exit: a local execution supervisor
20
+ * releases ownership after full tree teardown, while abrupt loss falls back to
21
+ * expiry and the dispatcher's sweep re-attaches (§15.6-C).
22
+ *
23
+ * Documented pragmatisms (where SPEC forces are in tension, the choice and
24
+ * its reason live here):
25
+ *
26
+ * - Producer session memory (§10) vs per-run transcripts (§7.1). The producer
27
+ * must *continue* its session across review rounds, but `AgentRunner.end`
28
+ * destroys continuability in every adapter. A SUCCESSFUL producer run
29
+ * therefore keeps its runner session live and closes its per-round
30
+ * session bracket with the turn's own transcript (final text + usage,
31
+ * straight off the AgentRunner interface — still "through the interface",
32
+ * §9, never scraped). The handle is truly `end()`ed when the phase later
33
+ * fails (a rambling session is never continued — fresh start next attempt,
34
+ * D5) or when `run` exits; the cumulative turn log is discarded then,
35
+ * because every round already deposited its own transcript.
36
+ * - Finalize checks are deterministic kernel work with no synthetic session;
37
+ * finalize agents retain their session actor. Both paths are deliberately
38
+ * failure-tolerant and drain through the same completion/observation writer.
39
+ * - The D5 retry guard also covers agent-verify steps (keyed `verify:<step>`,
40
+ * round = attempt): a no-terminal or provider-failed verify session would
41
+ * otherwise re-run forever, since its `verify.completed` never lands.
42
+ * - A throwing Exec in `run-check` propagates: deterministic checks carry no
43
+ * session to fail (§8.2), and a crashed runner's dangling `verify.started`
44
+ * simply re-runs the check on re-attach (§15.6-C) — the same crash model
45
+ * as every phase.
46
+ */
47
+ import type { Config } from '../config/schema'
48
+ import type { AbEvent, EventWrite } from '../events/catalog'
49
+ import type { EventPayload } from '../events/payloads'
50
+ import { KERNEL, agentActor, type Actor } from '../events/envelope'
51
+ import type { IdSource } from '../ids'
52
+ import { decideNext, type Decision } from '../kernel/engine'
53
+ import { PHASE_SPECS } from '../kernel/phases'
54
+ import { reduceBuild, type BuildState } from '../kernel/reducer'
55
+ import { evaluateVerifyApplicability } from '../kernel/verify-applicability'
56
+ import {
57
+ verifyPhase,
58
+ verifyReportKind,
59
+ type CorePhase,
60
+ type Feedback,
61
+ type Phase,
62
+ } from '../ontology'
63
+ import {
64
+ createRuntimeResolver,
65
+ type ResolvedRole,
66
+ type ResolvedRuntime,
67
+ type RuntimeResolver,
68
+ } from '../ports/runner/routing'
69
+ import { isAlternateEligible, mayRetryPhase } from '../ports/runner/provider-error'
70
+ import { sessionPart } from '../ports/runner/stream-parts'
71
+ import type { SessionStreamInfo } from '../ports/runner/runtime'
72
+ import type { RuntimeRegistry } from '../ports/runner/runtime'
73
+ import { createSessionStreamSink } from '../store/streams/session-writer'
74
+ import type { StreamOutcome } from '../store/streams/types'
75
+ import type {
76
+ AgentRunner,
77
+ AgentSessionHandle,
78
+ AgentTurnFailure,
79
+ AgentTurnResult,
80
+ Forge,
81
+ SessionStreamSink,
82
+ } from '../ports/types'
83
+ import { BUILD_EXECUTION_LEASE_TTL_MS } from '../ports/workspace/build-execution'
84
+ import { phaseScratchRejection, scratchPathsTouchedInRange } from '../ports/workspace/phase-scratch'
85
+ import type { Exec } from '../ports/workspace/git-worktree'
86
+ import type { BuildScopedStore, Clock } from '../store/types'
87
+ import { resetsPhaseFailureBudget, type PhaseFailureResetEscalation } from './phase-failure-budget'
88
+ import { publicationPending } from './publication-state'
89
+
90
+ export { publicationPending } from './publication-state'
91
+
92
+ // ── Seams ────────────────────────────────────────────────────────────────────
93
+
94
+ /** Another sandbox owns the build (§7.4): its lease is live, so this runner
95
+ * must not execute. The dispatcher retries once the lease expires. */
96
+ export class LeaseHeldError extends Error {
97
+ constructor(
98
+ readonly slug: string,
99
+ readonly instance: string,
100
+ ) {
101
+ super(
102
+ `build "${slug}" is leased to another runner — instance "${instance}" ` +
103
+ 'cannot attach while the holder is live (§7.4)',
104
+ )
105
+ this.name = 'LeaseHeldError'
106
+ }
107
+ }
108
+
109
+ /** Recognizable launch failure whose durable detail is already in the build
110
+ * log. Interactive dispatch therefore renders it on the build row rather than
111
+ * duplicating it in process-global warning chrome. */
112
+ export class SetupFailureError extends Error {
113
+ constructor(
114
+ readonly slug: string,
115
+ readonly failure: EventPayload<'runner.setup-failed'>,
116
+ readonly exhausted: boolean,
117
+ ) {
118
+ const status =
119
+ failure.exitStatus === null ? 'exit status unavailable' : `exited ${failure.exitStatus}`
120
+ super(
121
+ `[commands].setup ${JSON.stringify(failure.command)} ${status} ` +
122
+ `(attempt ${failure.attempt}): ${failure.output || '(no output)'}`,
123
+ )
124
+ this.name = 'SetupFailureError'
125
+ }
126
+ }
127
+
128
+ export interface BuildRunnerOpts {
129
+ /** D5 retry policy: phase.failed count per phase+round before a policy
130
+ * escalation replaces the next run (never loop forever). Default 2. */
131
+ maxPhaseAttempts?: number
132
+ /** Lease keep-alive interval (§15.2.6). Default 15s. */
133
+ heartbeatMs?: number
134
+ /** Lease TTL passed to claimLease (§7.4). Default 60s. */
135
+ leaseTtlMs?: number
136
+ /** Test seam for phase-session deadlines. Production uses an unref'ed timer. */
137
+ scheduleSessionBudget?: (expire: () => void, delayMs: number) => () => void
138
+ /** Session streams (SPEC §9): open one stream per session bracket for
139
+ * runtimes that declare the streaming capability. Default true. A failing
140
+ * stream path never fails a turn; disabling reproduces the event log
141
+ * without streams. */
142
+ streamSessions?: boolean
143
+ /** Where stream diagnostics land. Default: a stderr write. */
144
+ onDiagnostic?: (message: string) => void
145
+ }
146
+
147
+ export interface BuildRunnerDeps {
148
+ store: BuildScopedStore
149
+ config: Config
150
+ /** Current dispatcher snapshot. It is sampled once for setup and once for
151
+ * each engine/action boundary; omission preserves static runner callers. */
152
+ getConfig?: () => Config | Promise<Config>
153
+ /** Runtime registry: name → adapter + compatibility data (§9). The resolver
154
+ * applies `config.roles`, including its reserved `default` entry, to it. */
155
+ runtimes: RuntimeRegistry
156
+ workspacePath: string
157
+ branch: string
158
+ slug: string
159
+ /** Shell seam for deterministic checks and finalize Git guards (§8.2, D7). */
160
+ exec: Exec
161
+ /** Remote publication stays behind the kernel-owned Forge port (D7). */
162
+ forge: Forge
163
+ ids: IdSource
164
+ clock: Clock
165
+ /** Lease holder identity (§7.4) and `runner.attached` payload (§15.3). */
166
+ instance: string
167
+ host: string
168
+ /** Extra AB_* the launcher wants in every session env (e.g. AB_TOKEN,
169
+ * AB_STORE for a remote store) — D8. May override AB_STORE; never the
170
+ * per-session identity keys (AB_BUILD/AB_PHASE/AB_SESSION). */
171
+ sessionEnv?: Record<string, string>
172
+ opts?: BuildRunnerOpts
173
+ }
174
+
175
+ // ── Internal shapes ──────────────────────────────────────────────────────────
176
+
177
+ type RunPhaseDecision = Extract<Decision, { kind: 'run-phase' }>
178
+ type RunCheckDecision = Extract<Decision, { kind: 'run-check' }>
179
+ type RunAgentVerifyDecision = Extract<Decision, { kind: 'run-agent-verify' }>
180
+ type EvaluateVerifyDecision = Extract<Decision, { kind: 'evaluate-verify' }>
181
+ type SkipVerifyDecision = Extract<Decision, { kind: 'skip-verify' }>
182
+ type RunFinalizeStepDecision = Extract<Decision, { kind: 'run-finalize-step' }>
183
+ type CheckReconcileProgressDecision = Extract<Decision, { kind: 'check-reconcile-progress' }>
184
+ type RaiseEscalationDecision = Extract<Decision, { kind: 'raise-escalation' }>
185
+
186
+ /** Lease acquisition can finish either workspace-backed attachment or one
187
+ * engine-selected stop acknowledgement that deliberately needs no workspace. */
188
+ export type AttachmentResult =
189
+ | { kind: 'workspace-ready' }
190
+ | { kind: 'control-acknowledged'; command: 'pause' | 'abort' }
191
+
192
+ /** §10 memory: a producer's live runner session, kept across review rounds. */
193
+ interface ProducerSession {
194
+ handle: AgentSessionHandle
195
+ runner: AgentRunner
196
+ route: string
197
+ }
198
+
199
+ type ProviderAttempt = NonNullable<EventPayload<'phase.failed'>['providerAttempts']>[number]
200
+
201
+ type TurnSettlement<T> =
202
+ | { kind: 'value'; value: T }
203
+ | { kind: 'error'; error: unknown }
204
+ | { kind: 'expired' }
205
+
206
+ interface BudgetedTurn<T> {
207
+ settlement: TurnSettlement<T>
208
+ /** The first kernel cancellation cause. A provider result cannot overwrite it. */
209
+ abortCause?: 'operator' | 'budget'
210
+ /** Eventually settles even when the kernel has stopped waiting. */
211
+ pending: Promise<Exclude<TurnSettlement<T>, { kind: 'expired' }>>
212
+ }
213
+
214
+ /** One bracketed agent run (§15.3 sessions). */
215
+ interface SessionSpec {
216
+ /** Event/AB_PHASE identity: core phase or `verify:<step>`. */
217
+ phase: Phase
218
+ /** Loop round; verify attempt (AB_PHASE uses attempt as round); 1 for
219
+ * finalize. */
220
+ round: number
221
+ role: string
222
+ /** Deprecated compatibility role keys, consulted in order only when `role`
223
+ * is undeclared (§9 routing). Today: an agent verify step's configured skill
224
+ * name, the key verify used to route by before step names unified the rule. */
225
+ roleAliases?: readonly string[]
226
+ skill: string
227
+ abPhase: string
228
+ /** Set for producer core phases — §10 session memory applies. */
229
+ producerPhase?: CorePhase
230
+ /** Rendered into the continue message; the CLI context (`ab context`) is
231
+ * the real carrier — the message just names the .ab/ inputs. */
232
+ feedback?: Feedback
233
+ /** phase.failed count for this phase+round at decision time (D5). */
234
+ priorFailures: number
235
+ /** Phase-specific terminal recognizer; the session-scoped `escalation.
236
+ * raised` terminal (§8.4: escalate is always available) is added here. */
237
+ isTerminal: (event: AbEvent) => boolean
238
+ }
239
+
240
+ /** Longest tail of check output preserved in a verify report (§8.2). */
241
+ const REPORT_TAIL_CHARS = 10_000
242
+
243
+ /**
244
+ * One session bracket's live stream (SPEC §9): the sink the build-runner
245
+ * opened through the resolved target's streaming capability, the store id it
246
+ * returned, and the identity already emitted as the stream's first part.
247
+ * Undefined ⇒ the runtime declared no capability or the opener declined —
248
+ * the bracket then behaves exactly as before streams existed.
249
+ */
250
+ interface SessionBracketStream {
251
+ sink: SessionStreamSink
252
+ streamId: string
253
+ info: SessionStreamInfo
254
+ }
255
+ /** Git object ids are 40 hex characters for SHA-1 repositories and 64 for
256
+ * SHA-256 repositories. `rev-parse <ref>^{commit}` supplies the type check. */
257
+ const GIT_OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
258
+
259
+ function errorMessage(error: unknown): string {
260
+ return error instanceof Error ? error.message : String(error)
261
+ }
262
+
263
+ function sessionBudgetError(seconds: number): string {
264
+ return `phase session budget expired after ${seconds} seconds`
265
+ }
266
+
267
+ function defaultSessionBudgetScheduler(expire: () => void, delayMs: number): () => void {
268
+ // Node/Bun timers clamp larger delays. Chunk against an absolute deadline so
269
+ // a valid large positive config value can never become an immediate expiry.
270
+ const maxDelayMs = 2_147_483_647
271
+ const deadline = Date.now() + delayMs
272
+ let timer: ReturnType<typeof setTimeout> | undefined
273
+ const arm = (): void => {
274
+ const remaining = deadline - Date.now()
275
+ if (remaining <= 0) {
276
+ expire()
277
+ return
278
+ }
279
+ timer = setTimeout(arm, Math.min(remaining, maxDelayMs))
280
+ timer.unref?.()
281
+ }
282
+ arm()
283
+ return () => {
284
+ if (timer !== undefined) clearTimeout(timer)
285
+ }
286
+ }
287
+
288
+ function providerAttemptSummary(attempts: readonly ProviderAttempt[] | undefined): string {
289
+ if (attempts === undefined) return ''
290
+ return (
291
+ '; configured provider attempts: ' +
292
+ attempts
293
+ .map(
294
+ (attempt) =>
295
+ `#${attempt.index} ${attempt.runner}` +
296
+ `${attempt.model !== undefined ? `/${attempt.model}` : ''}: ${attempt.error}`,
297
+ )
298
+ .join(' | ')
299
+ )
300
+ }
301
+
302
+ /** Latest event-checkpointed branch head that kernel plumbing published. */
303
+ export function selectPublishedBranchHead(events: readonly AbEvent[]): string {
304
+ let head: string | undefined
305
+ for (const event of events) {
306
+ if (event.type === 'implement.completed') {
307
+ head = event.payload.commits.head
308
+ } else if (event.type === 'reconcile.completed') {
309
+ head = event.payload.mergeCommit
310
+ } else if (
311
+ event.type === 'finalize.step-completed' &&
312
+ event.payload.ok &&
313
+ event.payload.headSha !== undefined
314
+ ) {
315
+ head = event.payload.headSha
316
+ }
317
+ }
318
+ if (head === undefined) {
319
+ throw new Error(
320
+ 'finalize publication requires a prior implement.completed, reconcile.completed, ' +
321
+ 'or successful finalize publication head, but this build has none',
322
+ )
323
+ }
324
+ return head
325
+ }
326
+
327
+ /**
328
+ * Select the durable tree against which a conditional verifier evaluates the
329
+ * branch's current HEAD. The initial branch-cut SHA is authoritative until a
330
+ * successful reconcile; each completed reconcile promotes the latest start's
331
+ * refreshed base SHA. A dangling start is intentionally ignored.
332
+ */
333
+ export function selectVerifyDiffBase(events: readonly AbEvent[]): string {
334
+ const provisioned = events.find((event) => event.type === 'workspace.provisioned')
335
+ if (provisioned === undefined || provisioned.type !== 'workspace.provisioned') {
336
+ throw new Error(
337
+ 'conditional verify requires a workspace.provisioned base SHA, but this build has none',
338
+ )
339
+ }
340
+
341
+ let baseSha = provisioned.payload.base.sha
342
+ let pendingReconcileBase: string | undefined
343
+ for (const event of events) {
344
+ if (event.type === 'reconcile.started') {
345
+ pendingReconcileBase = event.payload.baseSha
346
+ } else if (event.type === 'reconcile.completed') {
347
+ if (pendingReconcileBase !== undefined) baseSha = pendingReconcileBase
348
+ pendingReconcileBase = undefined
349
+ }
350
+ }
351
+ return baseSha
352
+ }
353
+
354
+ /** Parse `git diff --name-only -z`; malformed output fails closed. */
355
+ export function parseNulChangedPaths(output: string): string[] {
356
+ if (output.length === 0) return []
357
+ if (!output.endsWith('\0')) {
358
+ throw new Error('git diff --name-only -z returned non-NUL-terminated output')
359
+ }
360
+ const paths = output.slice(0, -1).split('\0')
361
+ if (paths.some((path) => path.length === 0)) {
362
+ throw new Error('git diff --name-only -z returned an empty path entry')
363
+ }
364
+ return paths
365
+ }
366
+
367
+ /** Terminal events of loop phases carry `round`; finalize.completed and
368
+ * reconcile.completed do not (§15.3). Payloads are schema-validated at
369
+ * append, so the loose read is safe. */
370
+ function roundMatches(event: AbEvent, round: number): boolean {
371
+ const r = (event.payload as { round?: unknown }).round
372
+ return typeof r !== 'number' || r === round
373
+ }
374
+
375
+ /**
376
+ * The producer's round-N+1 prompt (§10): assembled deterministically from
377
+ * the feedback, naming the .ab/ inputs `ab context` materializes (§8.3) —
378
+ * the message is a pointer, the CLI context is the real carrier.
379
+ */
380
+ function continueMessage(spec: SessionSpec): string {
381
+ const refresh = 'run `ab context` to refresh .ab/, then finish with your terminal command'
382
+ const { feedback } = spec
383
+ if (feedback === undefined) {
384
+ return `Continue ${spec.phase} (round ${spec.round}): ${refresh}.`
385
+ }
386
+ if ('findings' in feedback) {
387
+ return (
388
+ `Revise ${spec.phase} (round ${spec.round}): address findings ` +
389
+ `${feedback.findings.join(', ')} — materialized in .ab/findings.json; ${refresh}.`
390
+ )
391
+ }
392
+ if ('verify' in feedback) {
393
+ return (
394
+ `verify:${feedback.verify.step} failed — its report ` +
395
+ `(${feedback.verify.report.kind}@${feedback.verify.report.rev}) is in ` +
396
+ `.ab/verify/; fix in ${spec.phase} round ${spec.round}, ${refresh}.`
397
+ )
398
+ }
399
+ return (
400
+ `Human guidance on escalation ${feedback.guidance.escalation} (authoritative, ` +
401
+ `§15.6-B): ${feedback.guidance.answer} — inputs in .ab/; ${refresh}.`
402
+ )
403
+ }
404
+
405
+ // ── The runner ───────────────────────────────────────────────────────────────
406
+
407
+ function isPublicationBoundary(event: AbEvent, spec: SessionSpec, session: string): boolean {
408
+ if (
409
+ event.type !== 'publication.requested' ||
410
+ event.actor.kind !== 'agent' ||
411
+ event.actor.session !== session
412
+ )
413
+ return false
414
+ if (spec.phase === 'implement')
415
+ return event.payload.operation === 'implement' && event.payload.round === spec.round
416
+ if (spec.phase === 'reconcile') return event.payload.operation === 'reconcile'
417
+ if (spec.phase === 'finalize') return event.payload.operation === 'finalize'
418
+ return false
419
+ }
420
+
421
+ export class BuildRunner {
422
+ private readonly maxPhaseAttempts: number
423
+ private readonly heartbeatMs: number
424
+ private readonly leaseTtlMs: number
425
+ private readonly streamSessions: boolean
426
+ private readonly onDiagnostic: (message: string) => void
427
+ private attached = false
428
+ private heartbeatTimer: ReturnType<typeof setInterval> | undefined
429
+ /** Set when a heartbeat reports the lease lapsed (§7.4): the store never
430
+ * renews an expired lease, so the holder must re-claim before doing any
431
+ * more work — or stop, because the sweep may already have launched a
432
+ * replacement runner (§15.6-C). */
433
+ private leaseLost = false
434
+ /** §10 producer session memory — in-memory by design: a new sandbox resumes
435
+ * with fresh sessions rehydrated from the store (§7.4, §9). */
436
+ private readonly producerSessions = new Map<CorePhase, ProducerSession>()
437
+ /** Captured only at setup/step boundaries. Async work below a boundary never
438
+ * consults the mutable owner again. */
439
+ private boundaryConfig: Config
440
+ private boundaryResolver: RuntimeResolver
441
+
442
+ constructor(private readonly deps: BuildRunnerDeps) {
443
+ this.maxPhaseAttempts = deps.opts?.maxPhaseAttempts ?? 2
444
+ this.heartbeatMs = deps.opts?.heartbeatMs ?? 15_000
445
+ this.leaseTtlMs = deps.opts?.leaseTtlMs ?? BUILD_EXECUTION_LEASE_TTL_MS
446
+ this.streamSessions = deps.opts?.streamSessions ?? true
447
+ this.onDiagnostic = deps.opts?.onDiagnostic ?? ((message) => console.error(message))
448
+ this.boundaryConfig = deps.config
449
+ this.boundaryResolver = createRuntimeResolver(
450
+ deps.runtimes,
451
+ deps.config.roles,
452
+ deps.config.policy.sessionBudgetSeconds,
453
+ )
454
+ }
455
+
456
+ private async captureConfig(): Promise<Config> {
457
+ const config = (await this.deps.getConfig?.()) ?? this.deps.config
458
+ this.boundaryConfig = config
459
+ this.boundaryResolver = createRuntimeResolver(
460
+ this.deps.runtimes,
461
+ config.roles,
462
+ config.policy.sessionBudgetSeconds,
463
+ )
464
+ return config
465
+ }
466
+
467
+ /** Consecutive durable setup failures since the latest successful attachment
468
+ * or explicit human answer. The answer re-arms the budget but deliberately
469
+ * does not erase the current diagnostic while recovery is still running. */
470
+ private setupStreak(events: readonly AbEvent[]): number {
471
+ const setupEscalations = new Set<string>()
472
+ let boundary = 0
473
+ for (const event of events) {
474
+ if (event.type === 'runner.attached') boundary = event.seq
475
+ else if (
476
+ event.type === 'escalation.raised' &&
477
+ event.payload.phase === 'setup' &&
478
+ event.payload.source === 'policy'
479
+ ) {
480
+ setupEscalations.add(event.payload.id)
481
+ } else if (event.type === 'escalation.answered' && setupEscalations.has(event.payload.id)) {
482
+ boundary = event.seq
483
+ }
484
+ }
485
+ return events.filter((event) => event.type === 'runner.setup-failed' && event.seq > boundary)
486
+ .length
487
+ }
488
+
489
+ private setupQuestion(failure: EventPayload<'runner.setup-failed'>): string {
490
+ const status =
491
+ failure.exitStatus === null ? 'exit status unavailable' : `exit status ${failure.exitStatus}`
492
+ return (
493
+ `maxSetupAttempts (${this.boundaryConfig.policy.maxSetupAttempts}) exhausted: ` +
494
+ `[commands].setup ${JSON.stringify(failure.command)} failed with ${status}. ` +
495
+ `Output: ${failure.output || '(no output)'}`
496
+ )
497
+ }
498
+
499
+ private async raiseSetupEscalation(failure: EventPayload<'runner.setup-failed'>): Promise<void> {
500
+ await this.deps.store.append(this.deps.slug, {
501
+ actor: KERNEL,
502
+ type: 'escalation.raised',
503
+ payload: {
504
+ id: this.deps.ids('esc'),
505
+ phase: 'setup',
506
+ source: 'policy',
507
+ policyCause: 'setup-failure-limit',
508
+ question: this.setupQuestion(failure),
509
+ },
510
+ } satisfies EventWrite<'escalation.raised'>)
511
+ }
512
+
513
+ /** Close every session open in the captured pre-attachment prefix, then
514
+ * announce takeover against that same boundary. If a write is interrupted,
515
+ * no attachment lands; the next runner reduces the newer prefix and closes
516
+ * only sessions that remain open. */
517
+ private async reclaimSessionsAndAttach(events: readonly AbEvent[]): Promise<void> {
518
+ const { store, slug, instance, host } = this.deps
519
+ const resumedFromSeq = events.at(-1)?.seq ?? 0
520
+ for (const session of reduceBuild([...events]).sessions.open) {
521
+ await store.append(slug, {
522
+ actor: KERNEL,
523
+ type: 'session.ended',
524
+ payload: {
525
+ session: session.session,
526
+ outcome: 'reclaimed',
527
+ reclaimedBy: { instance, resumedFromSeq },
528
+ },
529
+ } satisfies EventWrite<'session.ended'>)
530
+ }
531
+ await store.append(slug, {
532
+ actor: KERNEL,
533
+ type: 'runner.attached',
534
+ payload: {
535
+ instance,
536
+ host,
537
+ ...(resumedFromSeq > 0 ? { resumedFromSeq } : {}),
538
+ },
539
+ } satisfies EventWrite<'runner.attached'>)
540
+ }
541
+
542
+ /**
543
+ * Claim the lease, honor engine-selected stop controls, then run pre-phase
544
+ * setup for workspace-backed work. The first workspace attempt retains the
545
+ * historical successful-path protocol by announcing attachment before setup.
546
+ * Once a setup failure is current, retries delay `runner.attached` until
547
+ * setup succeeds; that fact then proves recovery and clears the projection.
548
+ */
549
+ async attach(): Promise<AttachmentResult> {
550
+ const config = await this.captureConfig()
551
+ const { store, slug, instance } = this.deps
552
+ const claimed = await store.claimLease(slug, instance, this.leaseTtlMs)
553
+ if (!claimed) throw new LeaseHeldError(slug, instance)
554
+
555
+ let events = await store.getEvents(slug)
556
+ const decision = decideNext(events, config)
557
+ if (
558
+ decision.kind === 'acknowledge' &&
559
+ (decision.command === 'pause' || decision.command === 'abort')
560
+ ) {
561
+ await this.acknowledge(decision.command)
562
+ return { kind: 'control-acknowledged', command: decision.command }
563
+ }
564
+
565
+ const state = reduceBuild(events)
566
+ const currentFailure = state.setupFailure
567
+ const openSetupEscalation = state.openEscalations.some(
568
+ (escalation) => escalation.phase === 'setup' && escalation.source === 'policy',
569
+ )
570
+ const streak = this.setupStreak(events)
571
+
572
+ // Crash-safe exhaustion: the final failure and escalation are separate
573
+ // appends. If only the failure landed, raise the blocker before another
574
+ // command execution can buy an extra attempt.
575
+ if (currentFailure !== undefined && streak >= config.policy.maxSetupAttempts) {
576
+ if (!openSetupEscalation) await this.raiseSetupEscalation(currentFailure)
577
+ throw new SetupFailureError(slug, currentFailure, true)
578
+ }
579
+ if (currentFailure !== undefined && openSetupEscalation) {
580
+ throw new SetupFailureError(slug, currentFailure, true)
581
+ }
582
+
583
+ const recovering = currentFailure !== undefined
584
+ if (!recovering) await this.reclaimSessionsAndAttach(events)
585
+
586
+ // First beat immediately (liveness visible without waiting an interval),
587
+ // then keep-alive. Setup runs under this heartbeat so a slow install cannot
588
+ // outlive the claimed lease.
589
+ await store.heartbeat(slug, instance)
590
+ this.heartbeatTimer = setInterval(() => {
591
+ store.heartbeat(slug, instance).then(
592
+ (alive) => {
593
+ if (!alive) this.leaseLost = true
594
+ },
595
+ () => {
596
+ // Store unreachable — the next beat retries; the lease TTL is the
597
+ // real liveness contract (§15.2.6).
598
+ },
599
+ )
600
+ }, this.heartbeatMs)
601
+ this.heartbeatTimer.unref?.()
602
+
603
+ const setup = config.commands.setup
604
+ if (setup !== undefined) {
605
+ let failure: EventPayload<'runner.setup-failed'> | undefined
606
+ try {
607
+ const result = await this.deps.exec(['sh', '-c', setup], {
608
+ cwd: this.deps.workspacePath,
609
+ })
610
+ if (result.exitCode !== 0) {
611
+ failure = {
612
+ command: setup,
613
+ attempt: streak + 1,
614
+ exitStatus: result.exitCode,
615
+ output: [result.stderr, result.stdout].filter((text) => text !== '').join('\n'),
616
+ }
617
+ }
618
+ } catch (error) {
619
+ failure = {
620
+ command: setup,
621
+ attempt: streak + 1,
622
+ exitStatus: null,
623
+ output: errorMessage(error),
624
+ }
625
+ }
626
+
627
+ if (failure !== undefined) {
628
+ try {
629
+ await store.append(slug, {
630
+ actor: KERNEL,
631
+ type: 'runner.setup-failed',
632
+ payload: failure,
633
+ } satisfies EventWrite<'runner.setup-failed'>)
634
+ const exhausted = failure.attempt >= config.policy.maxSetupAttempts
635
+ if (exhausted) await this.raiseSetupEscalation(failure)
636
+ throw new SetupFailureError(slug, failure, exhausted)
637
+ } finally {
638
+ this.stopHeartbeat()
639
+ this.attached = false
640
+ }
641
+ }
642
+ }
643
+
644
+ if (recovering) {
645
+ try {
646
+ events = await store.getEvents(slug)
647
+ await this.reclaimSessionsAndAttach(events)
648
+ } catch (error) {
649
+ this.stopHeartbeat()
650
+ this.attached = false
651
+ throw error
652
+ }
653
+ }
654
+ this.attached = true
655
+ return { kind: 'workspace-ready' }
656
+ }
657
+
658
+ /**
659
+ * One read→decide→execute cycle (§8): the unit-test surface. Returns the
660
+ * decision it executed; `wait` decisions execute nothing.
661
+ */
662
+ async step(): Promise<Decision> {
663
+ const config = await this.captureConfig()
664
+ const { store, slug } = this.deps
665
+ const events = await store.getEvents(slug)
666
+ const decision = decideNext(events, config)
667
+ switch (decision.kind) {
668
+ case 'wait':
669
+ return decision
670
+ case 'acknowledge':
671
+ await this.acknowledge(decision.command)
672
+ return decision
673
+ case 'raise-escalation':
674
+ await this.raiseEscalation(decision)
675
+ return decision
676
+ case 'run-phase':
677
+ await this.runPhase(decision, events)
678
+ return decision
679
+ case 'run-check':
680
+ await this.runCheck(decision)
681
+ return decision
682
+ case 'run-agent-verify':
683
+ await this.runAgentVerify(decision, events)
684
+ return decision
685
+ case 'evaluate-verify':
686
+ await this.evaluateVerify(decision, events)
687
+ return decision
688
+ case 'skip-verify':
689
+ await this.skipVerify(decision)
690
+ return decision
691
+ case 'run-finalize-step':
692
+ await this.runFinalizeStep(decision, events)
693
+ return decision
694
+ case 'check-reconcile-progress':
695
+ await this.checkReconcileProgress(decision, events)
696
+ return decision
697
+ }
698
+ }
699
+
700
+ /**
701
+ * The pipeline loop (§8): attach if needed, step until the build parks
702
+ * (`wait/*` — §11), return the final reduced state. Releases nothing on
703
+ * exit (leases expire; the dispatcher re-attaches) but stops the heartbeat,
704
+ * closes any live producer sessions (their per-round transcripts are
705
+ * already deposited — see module doc).
706
+ */
707
+ async run(): Promise<BuildState> {
708
+ if (!this.attached) {
709
+ const attachment = await this.attach()
710
+ if (attachment.kind === 'control-acknowledged') {
711
+ return reduceBuild(await this.deps.store.getEvents(this.deps.slug))
712
+ }
713
+ }
714
+ try {
715
+ if (publicationPending(await this.deps.store.getEvents(this.deps.slug))) {
716
+ return reduceBuild(await this.deps.store.getEvents(this.deps.slug))
717
+ }
718
+ for (;;) {
719
+ await this.ensureLease()
720
+ const decision = await this.step()
721
+ if (decision.kind === 'wait') break
722
+ if (publicationPending(await this.deps.store.getEvents(this.deps.slug))) break
723
+ }
724
+ return reduceBuild(await this.deps.store.getEvents(this.deps.slug))
725
+ } finally {
726
+ this.stopHeartbeat()
727
+ await this.closeProducerSessions()
728
+ }
729
+ }
730
+
731
+ /**
732
+ * §7.4 exclusivity, holder side: a lapsed lease means the sweep may already
733
+ * have launched a replacement, so this runner must re-claim before doing
734
+ * any more work. claimLease succeeds when the lease is expired or unheld
735
+ * (nobody took over — carry on) and fails when a live replacement holds it
736
+ * (stop: LeaseHeldError, mirroring attach). Checked at every loop boundary;
737
+ * work already in flight when the lapse happened cannot be interrupted, but
738
+ * nothing further executes without the lease.
739
+ */
740
+ private async ensureLease(): Promise<void> {
741
+ if (!this.leaseLost) return
742
+ const { store, slug, instance } = this.deps
743
+ const reclaimed = await store.claimLease(slug, instance, this.leaseTtlMs)
744
+ if (!reclaimed) throw new LeaseHeldError(slug, instance)
745
+ this.leaseLost = false
746
+ }
747
+
748
+ // ── Decision execution ─────────────────────────────────────────────────────
749
+
750
+ /** D2: humans append `*-requested`; the kernel acknowledges with facts. */
751
+ private async acknowledge(command: 'pause' | 'resume' | 'abort'): Promise<void> {
752
+ const { store, slug } = this.deps
753
+ if (command === 'pause') {
754
+ await store.append(slug, { actor: KERNEL, type: 'build.paused', payload: {} })
755
+ } else if (command === 'resume') {
756
+ await store.append(slug, { actor: KERNEL, type: 'build.resumed', payload: {} })
757
+ } else {
758
+ await store.append(slug, { actor: KERNEL, type: 'build.aborted', payload: {} })
759
+ // The execution supervisor retains ownership until the complete process
760
+ // tree is gone; janitor cleanup is gated by that live lease.
761
+ }
762
+ }
763
+
764
+ /** Stall/policy thresholds live in the engine (§15.4); the runner stamps
765
+ * the id (§15.4: ids are kernel-assigned) and appends the fact. */
766
+ private async raiseEscalation(decision: RaiseEscalationDecision): Promise<void> {
767
+ const { store, slug, ids } = this.deps
768
+ await store.append(slug, {
769
+ actor: KERNEL,
770
+ type: 'escalation.raised',
771
+ payload: {
772
+ id: ids('esc'),
773
+ phase: decision.phase,
774
+ ...(decision.round !== undefined ? { round: decision.round } : {}),
775
+ source: decision.source,
776
+ ...(decision.source === 'policy' ? { policyCause: decision.policyCause } : {}),
777
+ question: decision.question,
778
+ ...(decision.refs !== undefined ? { refs: decision.refs } : {}),
779
+ },
780
+ } satisfies EventWrite<'escalation.raised'>)
781
+ }
782
+
783
+ /**
784
+ * Resolve and persist the authoritative base for one repeat conflict before
785
+ * the engine spends reconcile policy budget. This deliberately does not
786
+ * reuse the snapshot as the merge target: actual phase startup refreshes
787
+ * again, preserving the execution-boundary freshness guarantee.
788
+ */
789
+ private async checkReconcileProgress(
790
+ decision: CheckReconcileProgressDecision,
791
+ events: AbEvent[],
792
+ ): Promise<void> {
793
+ const { nextAttempt } = decision
794
+ const failures = this.phaseFailures(events, 'reconcile', nextAttempt)
795
+ if (failures.count >= this.maxPhaseAttempts) {
796
+ await this.raisePolicyExhausted('reconcile', nextAttempt, failures)
797
+ return
798
+ }
799
+ if (failures.lastWillRetry === false) {
800
+ await this.raisePolicyNonRetryable('reconcile', nextAttempt, failures)
801
+ return
802
+ }
803
+
804
+ let baseSha: string
805
+ try {
806
+ baseSha = await this.refreshReconcileBase(events)
807
+ } catch (error) {
808
+ await this.failPhase(
809
+ 'reconcile',
810
+ nextAttempt,
811
+ failures.count,
812
+ `failed to check reconcile progress: ${errorMessage(error)}`,
813
+ )
814
+ return
815
+ }
816
+
817
+ await this.deps.store.append(this.deps.slug, {
818
+ actor: KERNEL,
819
+ type: 'reconcile.progress-checked',
820
+ payload: {
821
+ conflictSeq: decision.conflictSeq,
822
+ attempt: decision.completedAttempt,
823
+ baseSha,
824
+ },
825
+ } satisfies EventWrite<'reconcile.progress-checked'>)
826
+ }
827
+
828
+ /** A core-phase run: retry guard (D5), started event (§15.3), session. */
829
+ private async runPhase(decision: RunPhaseDecision, events: AbEvent[]): Promise<void> {
830
+ const { store, slug } = this.deps
831
+ const { phase, round } = decision
832
+
833
+ // D5 RETRY GUARD first: at the cap, escalate instead of running — a
834
+ // failing phase must never loop forever.
835
+ const failures = this.phaseFailures(events, phase, round)
836
+ if (failures.count >= this.maxPhaseAttempts) {
837
+ await this.raisePolicyExhausted(phase, round, failures)
838
+ return
839
+ }
840
+ if (failures.lastWillRetry === false) {
841
+ await this.raisePolicyNonRetryable(phase, round, failures)
842
+ return
843
+ }
844
+
845
+ // A conflict's recorded baseSha is an observation-time snapshot. Resolve
846
+ // the build's frozen base branch again at the actual execution boundary,
847
+ // after the retry guard but before reconcile.started/the agent session.
848
+ // A crash re-enters this path at the same attempt and refreshes again.
849
+ let reconcileBaseSha: string | undefined
850
+ if (phase === 'reconcile') {
851
+ try {
852
+ reconcileBaseSha = await this.refreshReconcileBase(events)
853
+ } catch (error) {
854
+ await this.failPhase(
855
+ phase,
856
+ round,
857
+ failures.count,
858
+ `failed to refresh reconcile base: ${errorMessage(error)}`,
859
+ )
860
+ return
861
+ }
862
+ }
863
+
864
+ await store.append(slug, this.startedWrite(decision, reconcileBaseSha))
865
+
866
+ const spec = PHASE_SPECS[phase]
867
+ await this.executeSession({
868
+ phase,
869
+ round,
870
+ role: phase, // role name = core phase name (§9 routing)
871
+ skill: spec.skill,
872
+ abPhase: `${phase}@${round}`, // reconcile: decision.round IS the attempt
873
+ ...(spec.kind === 'producer' ? { producerPhase: phase } : {}),
874
+ ...(decision.feedback !== undefined ? { feedback: decision.feedback } : {}),
875
+ priorFailures: failures.count,
876
+ isTerminal: (event) => event.type === spec.terminalEvent && roundMatches(event, round),
877
+ })
878
+ }
879
+
880
+ /**
881
+ * Resolve the current remote tip of this build's immutable base branch.
882
+ *
883
+ * The fetch target is build-scoped and internal: concurrent worktrees share
884
+ * one Git common directory, so FETCH_HEAD or a shared remote-tracking ref
885
+ * would let another build overwrite the object this attempt resolves. The
886
+ * explicit ref also makes the fetched remote-only commit available locally.
887
+ */
888
+ private async refreshReconcileBase(events: AbEvent[]): Promise<string> {
889
+ const created = events.find((event) => event.type === 'build.created')
890
+ if (created === undefined || created.type !== 'build.created') {
891
+ throw new Error(
892
+ 'this build has no build.created event — cannot resolve its frozen baseBranch (§15.3)',
893
+ )
894
+ }
895
+
896
+ const sourceRef = `refs/heads/${created.payload.baseBranch}`
897
+ const targetRef = `refs/autobuild/reconcile/${this.deps.slug}/base`
898
+ if (this.deps.forge.snapshotBase !== undefined) {
899
+ const sha = await this.deps.forge.snapshotBase({
900
+ workspacePath: this.deps.workspacePath,
901
+ base: created.payload.baseBranch,
902
+ destinationRef: targetRef,
903
+ })
904
+ if (!GIT_OBJECT_ID.test(sha)) {
905
+ throw new Error(
906
+ `${this.deps.forge.name} base snapshot did not return a commit SHA: ${JSON.stringify(sha)}`,
907
+ )
908
+ }
909
+ return sha
910
+ }
911
+
912
+ const fetchArgs = [
913
+ 'git',
914
+ 'fetch',
915
+ '--no-tags',
916
+ '--no-write-fetch-head',
917
+ 'origin',
918
+ `+${sourceRef}:${targetRef}`,
919
+ ]
920
+ const fetched = await this.deps.exec(fetchArgs, { cwd: this.deps.workspacePath })
921
+ if (fetched.exitCode !== 0) {
922
+ throw new Error(
923
+ `${fetchArgs.join(' ')} exited ${fetched.exitCode}: ` +
924
+ `${fetched.stderr.trim() || fetched.stdout.trim() || '(no output)'}`,
925
+ )
926
+ }
927
+
928
+ const resolveArgs = ['git', 'rev-parse', '--verify', `${targetRef}^{commit}`]
929
+ const resolved = await this.deps.exec(resolveArgs, { cwd: this.deps.workspacePath })
930
+ const sha = resolved.stdout.trim()
931
+ if (resolved.exitCode !== 0 || !GIT_OBJECT_ID.test(sha)) {
932
+ throw new Error(
933
+ `${resolveArgs.join(' ')} did not resolve a commit SHA: ` +
934
+ `${resolved.stderr.trim() || sha || '(no output)'}`,
935
+ )
936
+ }
937
+ return sha
938
+ }
939
+
940
+ /**
941
+ * Resolve a conditional verifier against the live branch diff. Exclusion is
942
+ * a canonical kernel-authored skip and launches no command or session.
943
+ * Inclusion delegates to the existing verifier path unchanged.
944
+ */
945
+ private async evaluateVerify(decision: EvaluateVerifyDecision, events: AbEvent[]): Promise<void> {
946
+ const { exec, workspacePath } = this.deps
947
+ const baseSha = selectVerifyDiffBase(events)
948
+ const args = ['git', 'diff', '--no-renames', '--name-only', '-z', baseSha, 'HEAD', '--']
949
+ const result = await exec(args, { cwd: workspacePath })
950
+ if (result.exitCode !== 0) {
951
+ throw new Error(
952
+ `${args.join(' ')} exited ${result.exitCode}: ` +
953
+ `${result.stderr.trim() || result.stdout.trim() || '(no output)'}`,
954
+ )
955
+ }
956
+ const changedPaths = parseNulChangedPaths(result.stdout)
957
+ const applicability = evaluateVerifyApplicability(
958
+ { kind: 'paths', step: decision.step, paths: decision.paths },
959
+ changedPaths,
960
+ )
961
+ if (!applicability.applies) {
962
+ await this.skipVerify({
963
+ kind: 'skip-verify',
964
+ step: decision.step,
965
+ attempt: decision.attempt,
966
+ reason: applicability.reason,
967
+ })
968
+ return
969
+ }
970
+
971
+ if (decision.action.kind === 'run-check') {
972
+ await this.runCheck(decision.action)
973
+ } else {
974
+ await this.runAgentVerify(decision.action, events)
975
+ }
976
+ }
977
+
978
+ /** Shared writer for every deterministic exclusion mechanism. A skip is a
979
+ * queryable two-fact phase occurrence and launches no command or session. */
980
+ private async skipVerify(decision: SkipVerifyDecision): Promise<void> {
981
+ const { store, slug } = this.deps
982
+ await store.append(slug, {
983
+ actor: KERNEL,
984
+ type: 'verify.started',
985
+ payload: { step: decision.step, attempt: decision.attempt },
986
+ } satisfies EventWrite<'verify.started'>)
987
+ await store.append(slug, {
988
+ actor: KERNEL,
989
+ type: 'verify.completed',
990
+ payload: {
991
+ step: decision.step,
992
+ attempt: decision.attempt,
993
+ outcome: 'skipped',
994
+ reason: decision.reason,
995
+ },
996
+ } satisfies EventWrite<'verify.completed'>)
997
+ }
998
+
999
+ /** Deterministic check (§8.2): NO session, NO CLI — the kernel runs the
1000
+ * command directly; a failure deposits the output tail as the report. */
1001
+ private async runCheck(decision: RunCheckDecision): Promise<void> {
1002
+ const { store, slug, exec, workspacePath } = this.deps
1003
+ const { step, command, attempt } = decision
1004
+ await store.append(slug, {
1005
+ actor: KERNEL,
1006
+ type: 'verify.started',
1007
+ payload: { step, attempt },
1008
+ } satisfies EventWrite<'verify.started'>)
1009
+
1010
+ const result = await exec(['sh', '-c', command], { cwd: workspacePath })
1011
+ if (result.exitCode === 0) {
1012
+ await store.append(slug, {
1013
+ actor: KERNEL,
1014
+ type: 'verify.completed',
1015
+ payload: { step, attempt, outcome: 'pass' },
1016
+ } satisfies EventWrite<'verify.completed'>)
1017
+ return
1018
+ }
1019
+
1020
+ const output = [result.stdout, result.stderr].filter((s) => s !== '').join('\n')
1021
+ const content = output.length > REPORT_TAIL_CHARS ? output.slice(-REPORT_TAIL_CHARS) : output
1022
+ // Atomic deposit (D6): report artifact + verify.completed in one bundle;
1023
+ // the report routes back to implement via the engine (§15.6-A).
1024
+ await store.appendWithArtifacts(
1025
+ slug,
1026
+ [
1027
+ {
1028
+ kind: verifyReportKind(step),
1029
+ content: content || '(no output)',
1030
+ metadata: { step, attempt, command, exitCode: result.exitCode },
1031
+ },
1032
+ ],
1033
+ (deposited) => {
1034
+ const meta = deposited[0]
1035
+ if (!meta) throw new Error('verify report deposit returned no metadata')
1036
+ return {
1037
+ actor: KERNEL,
1038
+ type: 'verify.completed',
1039
+ payload: {
1040
+ step,
1041
+ attempt,
1042
+ outcome: 'fail',
1043
+ report: { kind: meta.kind, rev: meta.revision },
1044
+ },
1045
+ } satisfies EventWrite<'verify.completed'>
1046
+ },
1047
+ )
1048
+ }
1049
+
1050
+ /** Agent-verify step (§5): a session with a pass/fail/skip verdict. */
1051
+ private async runAgentVerify(decision: RunAgentVerifyDecision, events: AbEvent[]): Promise<void> {
1052
+ const { store, slug } = this.deps
1053
+ const { step, skill, attempt, feedback } = decision
1054
+ const phase = verifyPhase(step)
1055
+
1056
+ // D5 guard, extended to agent-verify (see module doc): keyed by
1057
+ // verify:<step> with the verify attempt as the round.
1058
+ const failures = this.phaseFailures(events, phase, attempt)
1059
+ if (failures.count >= this.maxPhaseAttempts) {
1060
+ await this.raisePolicyExhausted(phase, attempt, failures)
1061
+ return
1062
+ }
1063
+ if (failures.lastWillRetry === false) {
1064
+ await this.raisePolicyNonRetryable(phase, attempt, failures)
1065
+ return
1066
+ }
1067
+
1068
+ await store.append(slug, {
1069
+ actor: KERNEL,
1070
+ type: 'verify.started',
1071
+ payload: { step, attempt, ...(feedback !== undefined ? { feedback } : {}) },
1072
+ } satisfies EventWrite<'verify.started'>)
1073
+
1074
+ await this.executeSession({
1075
+ phase,
1076
+ round: attempt,
1077
+ role: step, // role name = the verify step's logical name (§9 routing)
1078
+ // The deprecated skill-name key, still honoured for existing configs.
1079
+ roleAliases: [skill],
1080
+ skill,
1081
+ abPhase: `verify:${step}@${attempt}`,
1082
+ priorFailures: failures.count,
1083
+ isTerminal: (event) =>
1084
+ event.type === 'verify.completed' &&
1085
+ event.payload.step === step &&
1086
+ event.payload.attempt === attempt,
1087
+ })
1088
+ }
1089
+
1090
+ /**
1091
+ * Failure-tolerant post-step (§5). Checks run directly and agents run their
1092
+ * exact configured skill, but either kind may commit selected repository
1093
+ * content locally. Only this kernel boundary may publish it (D7): a clean
1094
+ * descendant HEAD is regular-pushed and checkpointed before completion.
1095
+ * Step, Git, and Forge failures become the ordinary failed outcome plus an
1096
+ * observation and never route a green build backward.
1097
+ */
1098
+ private async runFinalizeStep(
1099
+ decision: RunFinalizeStepDecision,
1100
+ events: AbEvent[],
1101
+ ): Promise<void> {
1102
+ const { step, action } = decision
1103
+ let actor: Actor = KERNEL
1104
+ let durableHead: string | undefined
1105
+ let failureNote: string | undefined
1106
+
1107
+ try {
1108
+ await this.requireFinalizeWorktreeClean('before the post-step starts')
1109
+ durableHead = selectPublishedBranchHead(events)
1110
+ } catch (error) {
1111
+ failureNote = `finalize publication preflight failed: ${errorMessage(error)}`
1112
+ }
1113
+
1114
+ if (failureNote === undefined) {
1115
+ if (action.kind === 'check') {
1116
+ failureNote = await this.runFinalizeCheck(action.command)
1117
+ } else {
1118
+ const outcome = await this.runFinalizeAgent(step, action.skill)
1119
+ if (outcome.cancelled) return
1120
+ actor = outcome.actor
1121
+ failureNote = outcome.failureNote
1122
+ }
1123
+ }
1124
+
1125
+ let pushedHead: string | undefined
1126
+ if (failureNote === undefined && durableHead !== undefined) {
1127
+ try {
1128
+ await this.requireFinalizeWorktreeClean('after the post-step finishes')
1129
+ const head = await this.resolveFinalizeHead()
1130
+ if (head !== durableHead) {
1131
+ await this.requirePublishedHeadAncestor(durableHead, head)
1132
+ const scratchPaths = await scratchPathsTouchedInRange(
1133
+ this.deps.exec,
1134
+ this.deps.workspacePath,
1135
+ durableHead,
1136
+ head,
1137
+ )
1138
+ if (scratchPaths.length > 0) throw phaseScratchRejection(scratchPaths)
1139
+ const remote = events.some(
1140
+ (event) =>
1141
+ event.type === 'workspace.provisioned' && event.payload.provider === 'vercel-sandbox',
1142
+ )
1143
+ if (remote) {
1144
+ await this.deps.store.append(this.deps.slug, {
1145
+ actor,
1146
+ type: 'publication.requested',
1147
+ payload: {
1148
+ operation: 'finalize-step',
1149
+ branch: this.deps.branch,
1150
+ sha: head,
1151
+ step,
1152
+ },
1153
+ })
1154
+ return
1155
+ }
1156
+ await this.deps.forge.pushBranch(this.deps.workspacePath, this.deps.branch)
1157
+ pushedHead = head
1158
+ }
1159
+ } catch (error) {
1160
+ failureNote = `finalize publication failed: ${errorMessage(error)}`
1161
+ }
1162
+ }
1163
+
1164
+ await this.recordFinalizeOutcome(step, actor, failureNote, pushedHead)
1165
+ }
1166
+
1167
+ /** A finalize check is direct kernel plumbing: no agent session is created. */
1168
+ private async runFinalizeCheck(command: string): Promise<string | undefined> {
1169
+ try {
1170
+ const result = await this.deps.exec(['sh', '-c', command], {
1171
+ cwd: this.deps.workspacePath,
1172
+ })
1173
+ return result.exitCode === 0 ? undefined : `command exited ${result.exitCode}`
1174
+ } catch (error) {
1175
+ return `command execution failed: ${errorMessage(error)}`
1176
+ }
1177
+ }
1178
+
1179
+ /** A finalize agent uses the configured skill verbatim and the logical step
1180
+ * name for role routing. Provider/launch failures remain post-step failures,
1181
+ * not build failures. */
1182
+ private async runFinalizeAgent(
1183
+ step: string,
1184
+ skill: string,
1185
+ ): Promise<{ actor: Actor; failureNote: string | undefined; cancelled: boolean }> {
1186
+ const resolved = this.boundaryResolver.resolve(step)
1187
+ if (resolved.alternates.length === 0) return this.runFinalizeAgentSingle(step, skill)
1188
+
1189
+ const { store, slug, ids, workspacePath } = this.deps
1190
+ const targets: readonly ResolvedRuntime[] = [resolved, ...resolved.alternates]
1191
+ let substitution: ProviderAttempt | undefined
1192
+ for (let index = 0; index < targets.length; index += 1) {
1193
+ const target = targets[index]!
1194
+ const session = ids('s')
1195
+ const finalizeBracket = {
1196
+ phase: 'finalize' as const,
1197
+ round: 1,
1198
+ role: step,
1199
+ runnerName: target.runtime,
1200
+ }
1201
+ const streamBracket = await this.openSessionStream(target, {
1202
+ session,
1203
+ role: step,
1204
+ runner: target.runtime,
1205
+ ...(target.model !== undefined ? { model: target.model } : {}),
1206
+ phase: 'finalize',
1207
+ round: 1,
1208
+ })
1209
+ const stream = streamBracket?.sink
1210
+ const started = await store.append(slug, {
1211
+ actor: KERNEL,
1212
+ type: 'session.started',
1213
+ payload: {
1214
+ session,
1215
+ role: step,
1216
+ runner: target.runtime,
1217
+ ...(target.model !== undefined ? { model: target.model } : {}),
1218
+ args: [...target.args],
1219
+ phase: 'finalize',
1220
+ round: 1,
1221
+ ...(streamBracket !== undefined ? { stream: streamBracket.streamId } : {}),
1222
+ ...(substitution !== undefined
1223
+ ? { substitution: { failed: substitution, selectedIndex: index } }
1224
+ : {}),
1225
+ },
1226
+ } satisfies EventWrite<'session.started'>)
1227
+ let handle: AgentSessionHandle | undefined
1228
+ let result: AgentTurnResult | undefined
1229
+ let turnError: unknown
1230
+ const turn = await this.runBudgetedTurn(
1231
+ started.seq,
1232
+ resolved.sessionBudgetSeconds,
1233
+ (signal) =>
1234
+ target.runner.start({
1235
+ skill,
1236
+ invocation: slug,
1237
+ buildSlug: slug,
1238
+ workspacePath,
1239
+ ...(target.model !== undefined ? { model: target.model } : {}),
1240
+ ...(target.args !== undefined ? { args: target.args } : {}),
1241
+ env: this.sessionEnvFor('finalize@1', session),
1242
+ signal,
1243
+ ...(stream !== undefined ? { stream } : {}),
1244
+ }),
1245
+ )
1246
+ if (turn.settlement.kind === 'value') {
1247
+ handle = turn.settlement.value.session
1248
+ result = turn.settlement.value.result
1249
+ } else if (turn.settlement.kind === 'error') {
1250
+ turnError = turn.settlement.error
1251
+ } else {
1252
+ this.releaseLateStart(turn.pending, target.runner)
1253
+ }
1254
+ if (turn.abortCause === 'budget') {
1255
+ if (handle !== undefined) {
1256
+ this.releaseExpiredSession(
1257
+ target.runner,
1258
+ handle,
1259
+ session,
1260
+ finalizeBracket,
1261
+ target.model,
1262
+ streamBracket,
1263
+ )
1264
+ } else {
1265
+ this.closeBracketStream(streamBracket, 'aborted')
1266
+ }
1267
+ return {
1268
+ actor: agentActor(step, session),
1269
+ failureNote: sessionBudgetError(resolved.sessionBudgetSeconds),
1270
+ cancelled: false,
1271
+ }
1272
+ }
1273
+ if (handle !== undefined) {
1274
+ try {
1275
+ const transcript = await target.runner.end(handle)
1276
+ const outcome: StreamOutcome =
1277
+ turnError === undefined && result?.kind !== 'failed' ? 'completed' : 'aborted'
1278
+ await this.depositTranscriptAndEnd(
1279
+ session,
1280
+ finalizeBracket,
1281
+ transcript.content,
1282
+ transcript.metadata.usage,
1283
+ transcript.metadata.model ?? target.model,
1284
+ streamBracket !== undefined ? { stream: streamBracket, outcome } : undefined,
1285
+ )
1286
+ } catch {
1287
+ // The failure-tolerant post-step still records its outcome below.
1288
+ }
1289
+ }
1290
+ if (turn.abortCause === 'operator') {
1291
+ this.closeBracketStream(streamBracket, 'aborted')
1292
+ return { actor: agentActor(step, session), failureNote: undefined, cancelled: true }
1293
+ }
1294
+ if (turnError === undefined && result?.kind !== 'failed') {
1295
+ this.closeBracketStream(streamBracket, 'completed')
1296
+ return { actor: agentActor(step, session), failureNote: undefined, cancelled: false }
1297
+ }
1298
+ this.closeBracketStream(streamBracket, 'aborted')
1299
+ const failure: AgentTurnFailure =
1300
+ turnError !== undefined
1301
+ ? { message: errorMessage(turnError), permanent: false, cause: 'availability' }
1302
+ : result?.kind === 'failed'
1303
+ ? result.failure
1304
+ : {
1305
+ message: 'agent session returned no result',
1306
+ permanent: false,
1307
+ cause: 'availability',
1308
+ }
1309
+ const attempt: ProviderAttempt = {
1310
+ index,
1311
+ session,
1312
+ runner: target.runtime,
1313
+ ...(target.model !== undefined ? { model: target.model } : {}),
1314
+ error: failure.message,
1315
+ ...(failure.cause !== undefined ? { cause: failure.cause } : {}),
1316
+ }
1317
+ if (index + 1 < targets.length && isAlternateEligible(failure)) {
1318
+ substitution = attempt
1319
+ continue
1320
+ }
1321
+ return {
1322
+ actor: agentActor(step, session),
1323
+ failureNote:
1324
+ `${turnError !== undefined ? 'agent session failed' : 'agent turn failed'}: ` +
1325
+ failure.message,
1326
+ cancelled: false,
1327
+ }
1328
+ }
1329
+ throw new Error('finalize alternate chain had no execution target')
1330
+ }
1331
+
1332
+ private async runFinalizeAgentSingle(
1333
+ step: string,
1334
+ skill: string,
1335
+ ): Promise<{ actor: Actor; failureNote: string | undefined; cancelled: boolean }> {
1336
+ const { store, slug, ids, workspacePath } = this.deps
1337
+ const session = ids('s')
1338
+ const resolved = this.boundaryResolver.resolve(step)
1339
+ const { runner, runtime: runnerName, model, args, sessionBudgetSeconds } = resolved
1340
+ const finalizeBracket = {
1341
+ phase: 'finalize' as const,
1342
+ round: 1,
1343
+ role: step,
1344
+ runnerName,
1345
+ }
1346
+ const streamBracket = await this.openSessionStream(resolved, {
1347
+ session,
1348
+ role: step,
1349
+ runner: runnerName,
1350
+ ...(model !== undefined ? { model } : {}),
1351
+ phase: 'finalize',
1352
+ round: 1,
1353
+ })
1354
+ const stream = streamBracket?.sink
1355
+
1356
+ const started = await store.append(slug, {
1357
+ actor: KERNEL,
1358
+ type: 'session.started',
1359
+ payload: {
1360
+ session,
1361
+ role: step,
1362
+ runner: runnerName,
1363
+ ...(model !== undefined ? { model } : {}),
1364
+ args: [...args],
1365
+ phase: 'finalize',
1366
+ round: 1,
1367
+ ...(streamBracket !== undefined ? { stream: streamBracket.streamId } : {}),
1368
+ },
1369
+ } satisfies EventWrite<'session.started'>)
1370
+
1371
+ let handle: AgentSessionHandle | undefined
1372
+ let failureNote: string | undefined
1373
+ const turn = await this.runBudgetedTurn(started.seq, sessionBudgetSeconds, (signal) =>
1374
+ runner.start({
1375
+ skill,
1376
+ invocation: slug,
1377
+ buildSlug: slug,
1378
+ workspacePath,
1379
+ ...(model !== undefined ? { model } : {}),
1380
+ ...(args !== undefined ? { args } : {}),
1381
+ env: this.sessionEnvFor('finalize@1', session),
1382
+ signal,
1383
+ ...(stream !== undefined ? { stream } : {}),
1384
+ }),
1385
+ )
1386
+ if (turn.settlement.kind === 'value') {
1387
+ handle = turn.settlement.value.session
1388
+ if (turn.settlement.value.result.kind === 'failed') {
1389
+ failureNote = `agent turn failed: ${turn.settlement.value.result.failure.message}`
1390
+ }
1391
+ } else if (turn.settlement.kind === 'error') {
1392
+ failureNote = `agent session failed: ${errorMessage(turn.settlement.error)}`
1393
+ } else {
1394
+ this.releaseLateStart(turn.pending, runner)
1395
+ }
1396
+ if (turn.abortCause === 'budget') {
1397
+ if (handle !== undefined) {
1398
+ this.releaseExpiredSession(runner, handle, session, finalizeBracket, model, streamBracket)
1399
+ } else {
1400
+ this.closeBracketStream(streamBracket, 'aborted')
1401
+ }
1402
+ return {
1403
+ actor: agentActor(step, session),
1404
+ failureNote: sessionBudgetError(sessionBudgetSeconds),
1405
+ cancelled: false,
1406
+ }
1407
+ }
1408
+
1409
+ if (handle !== undefined) {
1410
+ try {
1411
+ const transcript = await runner.end(handle)
1412
+ await this.depositTranscriptAndEnd(
1413
+ session,
1414
+ finalizeBracket,
1415
+ transcript.content,
1416
+ transcript.metadata.usage,
1417
+ transcript.metadata.model ?? model,
1418
+ streamBracket !== undefined
1419
+ ? {
1420
+ stream: streamBracket,
1421
+ outcome: failureNote === undefined ? 'completed' : 'aborted',
1422
+ }
1423
+ : undefined,
1424
+ )
1425
+ } catch {
1426
+ // Best-effort: the step outcome below is still recorded.
1427
+ }
1428
+ }
1429
+ this.closeBracketStream(
1430
+ streamBracket,
1431
+ failureNote === undefined && turn.abortCause !== 'operator' ? 'completed' : 'aborted',
1432
+ )
1433
+
1434
+ return {
1435
+ actor: agentActor(step, session),
1436
+ failureNote,
1437
+ cancelled: turn.abortCause === 'operator',
1438
+ }
1439
+ }
1440
+
1441
+ /** Both finalize kinds append the same facts and always let the engine
1442
+ * advance. Durable-write failures still propagate; only step/publication
1443
+ * work is failure-tolerant. */
1444
+ private async recordFinalizeOutcome(
1445
+ step: string,
1446
+ actor: Actor,
1447
+ failureNote: string | undefined,
1448
+ pushedHead: string | undefined,
1449
+ ): Promise<void> {
1450
+ const { store, slug, ids } = this.deps
1451
+ if (failureNote === undefined) {
1452
+ await store.append(slug, {
1453
+ actor,
1454
+ type: 'finalize.step-completed',
1455
+ payload: {
1456
+ step,
1457
+ ok: true,
1458
+ ...(pushedHead !== undefined ? { headSha: pushedHead } : {}),
1459
+ },
1460
+ } satisfies EventWrite<'finalize.step-completed'>)
1461
+ return
1462
+ }
1463
+
1464
+ await store.append(slug, {
1465
+ actor,
1466
+ type: 'finalize.step-completed',
1467
+ payload: { step, ok: false, note: failureNote },
1468
+ } satisfies EventWrite<'finalize.step-completed'>)
1469
+ // §5: a failed post-step files an observation; it never kills a green
1470
+ // build. Store-write failures intentionally propagate: no durable fact is
1471
+ // claimed when its append did not land.
1472
+ await store.append(slug, {
1473
+ actor,
1474
+ type: 'observation.recorded',
1475
+ payload: {
1476
+ id: ids('o'),
1477
+ kind: 'followup',
1478
+ summary: `finalize step "${step}" failed — needs manual follow-up: ${failureNote}`,
1479
+ },
1480
+ } satisfies EventWrite<'observation.recorded'>)
1481
+ }
1482
+
1483
+ private async requireFinalizeWorktreeClean(when: string): Promise<void> {
1484
+ const args = ['git', 'status', '--porcelain']
1485
+ const result = await this.deps.exec(args, { cwd: this.deps.workspacePath })
1486
+ if (result.exitCode !== 0) {
1487
+ throw new Error(
1488
+ `${args.join(' ')} exited ${result.exitCode}: ` +
1489
+ `${result.stderr.trim() || result.stdout.trim() || '(no output)'}`,
1490
+ )
1491
+ }
1492
+ if (result.stdout.length > 0) {
1493
+ throw new Error(
1494
+ `workspace must be clean ${when}; commit intended files and remove ` +
1495
+ `temporary files before finishing:\n${result.stdout.trimEnd()}`,
1496
+ )
1497
+ }
1498
+ }
1499
+
1500
+ private async resolveFinalizeHead(): Promise<string> {
1501
+ const args = ['git', 'rev-parse', 'HEAD']
1502
+ const result = await this.deps.exec(args, { cwd: this.deps.workspacePath })
1503
+ const head = result.stdout.trim()
1504
+ if (result.exitCode !== 0 || head.length === 0 || /\s/.test(head)) {
1505
+ throw new Error(
1506
+ `${args.join(' ')} did not resolve one commit: ` +
1507
+ `${result.stderr.trim() || head || '(no output)'}`,
1508
+ )
1509
+ }
1510
+ return head
1511
+ }
1512
+
1513
+ private async requirePublishedHeadAncestor(
1514
+ publishedHead: string,
1515
+ currentHead: string,
1516
+ ): Promise<void> {
1517
+ const args = ['git', 'merge-base', '--is-ancestor', publishedHead, currentHead]
1518
+ const result = await this.deps.exec(args, { cwd: this.deps.workspacePath })
1519
+ if (result.exitCode === 0) return
1520
+ if (result.exitCode === 1) {
1521
+ throw new Error(
1522
+ `finalize step rewrote branch history: published head ${publishedHead} ` +
1523
+ `is not an ancestor of current HEAD ${currentHead}`,
1524
+ )
1525
+ }
1526
+ throw new Error(
1527
+ `${args.join(' ')} exited ${result.exitCode}: ` +
1528
+ `${result.stderr.trim() || result.stdout.trim() || '(no output)'}`,
1529
+ )
1530
+ }
1531
+
1532
+ // ── The session bracket (§15.3) ────────────────────────────────────────────
1533
+
1534
+ /** Run one adapter turn under the logical role's wall-clock budget. The
1535
+ * expiry promise is resolved one microtask after the deadline so a conforming
1536
+ * adapter can return its endable handle first. An earlier operator abort owns
1537
+ * classification, but does not disarm this backstop for an adapter that
1538
+ * ignores cancellation. */
1539
+ private async runBudgetedTurn<T>(
1540
+ preSeq: number,
1541
+ budgetSeconds: number,
1542
+ run: (signal: AbortSignal) => Promise<T>,
1543
+ ): Promise<BudgetedTurn<T>> {
1544
+ const { store, slug } = this.deps
1545
+ const controller = new AbortController()
1546
+ let abortCause: 'operator' | 'budget' | undefined
1547
+ let resolveExpiry!: (settlement: { kind: 'expired' }) => void
1548
+ const expiry = new Promise<{ kind: 'expired' }>((resolve) => {
1549
+ resolveExpiry = resolve
1550
+ })
1551
+ const pending = Promise.resolve()
1552
+ .then(() => run(controller.signal))
1553
+ .then(
1554
+ (value): { kind: 'value'; value: T } => ({ kind: 'value', value }),
1555
+ (error): { kind: 'error'; error: unknown } => ({ kind: 'error', error }),
1556
+ )
1557
+ const unsubscribe = store.subscribe(slug, { fromSeq: preSeq, pollMs: 25 }, (event) => {
1558
+ if (event.type === 'build.abort-requested' && !controller.signal.aborted) {
1559
+ abortCause = 'operator'
1560
+ controller.abort(new Error(`build ${slug} aborted by operator`))
1561
+ }
1562
+ })
1563
+ const schedule = this.deps.opts?.scheduleSessionBudget ?? defaultSessionBudgetScheduler
1564
+ const cancelTimer = schedule(() => {
1565
+ if (!controller.signal.aborted) {
1566
+ abortCause = 'budget'
1567
+ controller.abort(new Error(sessionBudgetError(budgetSeconds)))
1568
+ }
1569
+ queueMicrotask(() => resolveExpiry({ kind: 'expired' }))
1570
+ }, budgetSeconds * 1000)
1571
+
1572
+ try {
1573
+ const settlement = await Promise.race([pending, expiry])
1574
+ return {
1575
+ settlement,
1576
+ ...(abortCause !== undefined ? { abortCause } : {}),
1577
+ pending,
1578
+ }
1579
+ } finally {
1580
+ cancelTimer()
1581
+ unsubscribe()
1582
+ }
1583
+ }
1584
+
1585
+ /** Start resource cleanup without allowing a stuck `end()` or transcript
1586
+ * deposit to extend an already-expired phase. Conforming adapters still end
1587
+ * immediately; every cleanup/deposit failure remains best-effort. The
1588
+ * bracket's stream closes `aborted` (budget cancellation) before the
1589
+ * deposit, preserving the close-before-`session.ended` ordering. */
1590
+ private releaseExpiredSession(
1591
+ runner: AgentRunner,
1592
+ handle: AgentSessionHandle,
1593
+ session: string,
1594
+ bracket: { phase: Phase; round: number; role: string; runnerName: string },
1595
+ model?: string,
1596
+ stream?: SessionBracketStream,
1597
+ ): void {
1598
+ void (async () => {
1599
+ try {
1600
+ const transcript = await runner.end(handle)
1601
+ await this.depositTranscriptAndEnd(
1602
+ session,
1603
+ bracket,
1604
+ transcript.content,
1605
+ transcript.metadata.usage,
1606
+ transcript.metadata.model ?? model,
1607
+ stream !== undefined ? { stream, outcome: 'aborted' } : undefined,
1608
+ )
1609
+ } catch {
1610
+ // The expiry failure must remain bounded even when cleanup is not.
1611
+ } finally {
1612
+ // Belt and braces: idempotent close covers a failed deposit path.
1613
+ if (stream !== undefined) void stream.sink.close('aborted')
1614
+ }
1615
+ })()
1616
+ }
1617
+
1618
+ /** A start call can ignore cancellation and return a handle after the kernel
1619
+ * has advanced. End that late handle in the background so it never becomes
1620
+ * reusable continuation state. The stale durable bracket is intentionally
1621
+ * left for the existing recovery model; complete descendant reaping remains
1622
+ * the execution supervisor's exit boundary. */
1623
+ private releaseLateStart(
1624
+ pending: Promise<Exclude<TurnSettlement<{ session: AgentSessionHandle }>, { kind: 'expired' }>>,
1625
+ runner: AgentRunner,
1626
+ ): void {
1627
+ void pending.then(async (settlement) => {
1628
+ if (settlement.kind !== 'value') return
1629
+ try {
1630
+ await runner.end(settlement.value.session)
1631
+ } catch {
1632
+ // Best-effort resource release after a non-conforming late return.
1633
+ }
1634
+ })
1635
+ }
1636
+
1637
+ /**
1638
+ * One bracketed agent run: `session.started` → turn → terminal check →
1639
+ * crash-gap repair → `session.ended` (with transcript) or `phase.failed`.
1640
+ * Producer phases with a live handle continue it (§10); reviewers always
1641
+ * start fresh (fresh skeptic).
1642
+ */
1643
+ private async executeSession(spec: SessionSpec): Promise<void> {
1644
+ const resolved = this.boundaryResolver.resolve(spec.role, ...(spec.roleAliases ?? []))
1645
+ // Preserve the historical single-target path byte-for-byte when no chain
1646
+ // is configured. Besides compatibility, this keeps its crash-gap and
1647
+ // producer-continuation behavior independently well exercised.
1648
+ if (resolved.alternates.length === 0) return this.executeSingleSession(spec)
1649
+ return this.executeSessionChain(spec, resolved, resolved.alternates)
1650
+ }
1651
+
1652
+ /** Execute all configured targets inside one phase attempt. Every target has
1653
+ * its own durable session bracket; only exhaustion writes phase.failed. */
1654
+ private async executeSessionChain(
1655
+ spec: SessionSpec,
1656
+ primary: ResolvedRole,
1657
+ alternates: readonly ResolvedRuntime[],
1658
+ ): Promise<void> {
1659
+ const { store, slug, ids, workspacePath } = this.deps
1660
+ const targets = [primary, ...alternates]
1661
+ const attempts: ProviderAttempt[] = []
1662
+ let substitution: ProviderAttempt | undefined
1663
+ const primaryRoute = JSON.stringify({
1664
+ runtime: primary.runtime,
1665
+ model: primary.model,
1666
+ args: primary.args,
1667
+ })
1668
+ let primaryLive =
1669
+ spec.producerPhase !== undefined ? this.producerSessions.get(spec.producerPhase) : undefined
1670
+ if (
1671
+ primaryLive !== undefined &&
1672
+ (primaryLive.runner !== primary.runner || primaryLive.route !== primaryRoute)
1673
+ ) {
1674
+ try {
1675
+ await primaryLive.runner.end(primaryLive.handle)
1676
+ } catch {
1677
+ // Best-effort cleanup after the previous round reached its boundary.
1678
+ }
1679
+ if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
1680
+ primaryLive = undefined
1681
+ }
1682
+
1683
+ for (let index = 0; index < targets.length; index += 1) {
1684
+ const target = targets[index]!
1685
+ const session = ids('s')
1686
+ // SPEC §9: each chain target gets its own durable bracket and its own
1687
+ // stream, opened before the bracket's first turn.
1688
+ const streamBracket = await this.openSessionStream(target, {
1689
+ session,
1690
+ role: spec.role,
1691
+ runner: target.runtime,
1692
+ ...(target.model !== undefined ? { model: target.model } : {}),
1693
+ phase: spec.phase,
1694
+ round: spec.round,
1695
+ })
1696
+ const stream = streamBracket?.sink
1697
+ const startedEnvelope = await store.append(slug, {
1698
+ actor: KERNEL,
1699
+ type: 'session.started',
1700
+ payload: {
1701
+ session,
1702
+ role: spec.role,
1703
+ runner: target.runtime,
1704
+ ...(target.model !== undefined ? { model: target.model } : {}),
1705
+ args: [...target.args],
1706
+ phase: spec.phase,
1707
+ round: spec.round,
1708
+ ...(streamBracket !== undefined ? { stream: streamBracket.streamId } : {}),
1709
+ ...(substitution !== undefined
1710
+ ? { substitution: { failed: substitution, selectedIndex: index } }
1711
+ : {}),
1712
+ },
1713
+ } satisfies EventWrite<'session.started'>)
1714
+ const preSeq = startedEnvelope.seq
1715
+ const live = index === 0 ? primaryLive : undefined
1716
+
1717
+ let handle: AgentSessionHandle | undefined = live?.handle
1718
+ let result: AgentTurnResult | undefined
1719
+ let turnError: unknown
1720
+ const turn = await this.runBudgetedTurn(preSeq, primary.sessionBudgetSeconds, (signal) =>
1721
+ live !== undefined
1722
+ ? live.runner
1723
+ .continue(live.handle, continueMessage(spec), {
1724
+ env: this.sessionEnvFor(spec.abPhase, session),
1725
+ signal,
1726
+ ...(stream !== undefined ? { stream } : {}),
1727
+ })
1728
+ .then((continued) => ({ session: live.handle, result: continued }))
1729
+ : target.runner.start({
1730
+ skill: spec.skill,
1731
+ invocation: slug,
1732
+ buildSlug: slug,
1733
+ workspacePath,
1734
+ ...(target.model !== undefined ? { model: target.model } : {}),
1735
+ ...(target.args !== undefined ? { args: target.args } : {}),
1736
+ env: this.sessionEnvFor(spec.abPhase, session),
1737
+ signal,
1738
+ ...(stream !== undefined ? { stream } : {}),
1739
+ }),
1740
+ )
1741
+ if (turn.settlement.kind === 'value') {
1742
+ handle = turn.settlement.value.session
1743
+ result = turn.settlement.value.result
1744
+ } else if (turn.settlement.kind === 'error') {
1745
+ turnError = turn.settlement.error
1746
+ } else if (live === undefined) {
1747
+ this.releaseLateStart(turn.pending, target.runner)
1748
+ }
1749
+
1750
+ const since = await store.getEvents(slug, preSeq)
1751
+ const terminal = since.some(
1752
+ (event) =>
1753
+ spec.isTerminal(event) ||
1754
+ isPublicationBoundary(event, spec, session) ||
1755
+ (event.type === 'escalation.raised' &&
1756
+ event.actor.kind === 'agent' &&
1757
+ event.actor.session === session),
1758
+ )
1759
+ if (spec.phase === 'plan-review' || spec.phase === 'code-review') {
1760
+ await this.repairEscalateGap(since, spec.phase, spec.round)
1761
+ }
1762
+
1763
+ const bracket = {
1764
+ phase: spec.phase,
1765
+ round: spec.round,
1766
+ role: spec.role,
1767
+ runnerName: target.runtime,
1768
+ }
1769
+ const owner = live?.runner ?? target.runner
1770
+
1771
+ if (turn.abortCause === 'operator') {
1772
+ if (handle !== undefined) {
1773
+ try {
1774
+ const transcript = await owner.end(handle)
1775
+ await this.depositTranscriptAndEnd(
1776
+ session,
1777
+ bracket,
1778
+ transcript.content,
1779
+ transcript.metadata.usage,
1780
+ transcript.metadata.model ?? target.model,
1781
+ streamBracket !== undefined
1782
+ ? { stream: streamBracket, outcome: 'aborted' }
1783
+ : undefined,
1784
+ )
1785
+ } catch {
1786
+ // Cancellation remains a control boundary, never phase failure.
1787
+ }
1788
+ }
1789
+ this.closeBracketStream(streamBracket, 'aborted')
1790
+ if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
1791
+ return
1792
+ }
1793
+
1794
+ // A typed terminal deposited at the deadline remains authoritative even
1795
+ // when the adapter has not yet returned its handle/result.
1796
+ if (terminal && turn.abortCause === 'budget') {
1797
+ if (handle !== undefined) {
1798
+ this.releaseExpiredSession(owner, handle, session, bracket, target.model, streamBracket)
1799
+ } else {
1800
+ this.closeBracketStream(streamBracket, 'aborted')
1801
+ }
1802
+ if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
1803
+ return
1804
+ }
1805
+
1806
+ if (terminal && handle !== undefined && result !== undefined) {
1807
+ if (spec.producerPhase !== undefined && index === 0) {
1808
+ this.producerSessions.set(spec.producerPhase, {
1809
+ handle,
1810
+ runner: owner,
1811
+ route: primaryRoute,
1812
+ })
1813
+ const content = JSON.stringify(
1814
+ {
1815
+ session,
1816
+ phase: spec.phase,
1817
+ round: spec.round,
1818
+ note: 'producer session kept live for §10 continuation; per-round turn transcript',
1819
+ turn: { text: result.text, usage: result.usage },
1820
+ },
1821
+ null,
1822
+ 2,
1823
+ )
1824
+ await this.depositTranscriptAndEnd(
1825
+ session,
1826
+ bracket,
1827
+ content,
1828
+ result.usage,
1829
+ target.model,
1830
+ streamBracket !== undefined
1831
+ ? { stream: streamBracket, outcome: 'completed' }
1832
+ : undefined,
1833
+ )
1834
+ } else {
1835
+ // An alternate producer must not become sticky across the review
1836
+ // boundary. End it like any fresh session; the next round begins on
1837
+ // the primary and rehydrates context from durable artifacts.
1838
+ const transcript = await owner.end(handle)
1839
+ await this.depositTranscriptAndEnd(
1840
+ session,
1841
+ bracket,
1842
+ transcript.content,
1843
+ transcript.metadata.usage,
1844
+ transcript.metadata.model ?? target.model,
1845
+ streamBracket !== undefined
1846
+ ? { stream: streamBracket, outcome: 'completed' }
1847
+ : undefined,
1848
+ )
1849
+ if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
1850
+ }
1851
+ return
1852
+ }
1853
+
1854
+ if (terminal) {
1855
+ if (handle !== undefined) {
1856
+ try {
1857
+ const transcript = await owner.end(handle)
1858
+ await this.depositTranscriptAndEnd(
1859
+ session,
1860
+ bracket,
1861
+ transcript.content,
1862
+ transcript.metadata.usage,
1863
+ transcript.metadata.model ?? target.model,
1864
+ streamBracket !== undefined
1865
+ ? { stream: streamBracket, outcome: 'completed' }
1866
+ : undefined,
1867
+ )
1868
+ } catch {
1869
+ // The durable terminal remains authoritative over adapter failure
1870
+ // and best-effort transcript cleanup.
1871
+ }
1872
+ }
1873
+ this.closeBracketStream(streamBracket, 'completed')
1874
+ if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
1875
+ return
1876
+ }
1877
+
1878
+ if (turn.abortCause === 'budget') {
1879
+ if (handle !== undefined) {
1880
+ this.releaseExpiredSession(owner, handle, session, bracket, target.model, streamBracket)
1881
+ } else {
1882
+ this.closeBracketStream(streamBracket, 'aborted')
1883
+ }
1884
+ if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
1885
+ await this.failPhase(
1886
+ spec.phase,
1887
+ spec.round,
1888
+ spec.priorFailures,
1889
+ sessionBudgetError(primary.sessionBudgetSeconds),
1890
+ true,
1891
+ )
1892
+ return
1893
+ }
1894
+
1895
+ if (handle !== undefined) {
1896
+ try {
1897
+ const transcript = await owner.end(handle)
1898
+ await this.depositTranscriptAndEnd(
1899
+ session,
1900
+ bracket,
1901
+ transcript.content,
1902
+ transcript.metadata.usage,
1903
+ transcript.metadata.model ?? target.model,
1904
+ streamBracket !== undefined ? { stream: streamBracket, outcome: 'aborted' } : undefined,
1905
+ )
1906
+ } catch {
1907
+ // A dead provider may leave no recoverable transcript.
1908
+ }
1909
+ }
1910
+ this.closeBracketStream(streamBracket, 'aborted')
1911
+ if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
1912
+
1913
+ const failure: AgentTurnFailure =
1914
+ turnError !== undefined
1915
+ ? { message: errorMessage(turnError), permanent: false, cause: 'availability' }
1916
+ : result?.kind === 'failed'
1917
+ ? result.failure
1918
+ : { message: 'no-terminal', permanent: false }
1919
+ const attempt: ProviderAttempt = {
1920
+ index,
1921
+ session,
1922
+ runner: target.runtime,
1923
+ ...(target.model !== undefined ? { model: target.model } : {}),
1924
+ error: failure.message,
1925
+ ...(failure.cause !== undefined ? { cause: failure.cause } : {}),
1926
+ }
1927
+ attempts.push(attempt)
1928
+
1929
+ const eligibleForAlternate =
1930
+ turnError !== undefined ||
1931
+ (result?.kind === 'failed' && isAlternateEligible(result.failure))
1932
+ if (index + 1 < targets.length && eligibleForAlternate) {
1933
+ substitution = attempt
1934
+ continue
1935
+ }
1936
+
1937
+ await this.failPhase(
1938
+ spec.phase,
1939
+ spec.round,
1940
+ spec.priorFailures,
1941
+ failure.message,
1942
+ mayRetryPhase(failure),
1943
+ attempts,
1944
+ )
1945
+ return
1946
+ }
1947
+ }
1948
+
1949
+ /** Historical single-target bracket, retained unchanged for repositories
1950
+ * that declare no alternates. */
1951
+ private async executeSingleSession(spec: SessionSpec): Promise<void> {
1952
+ const { store, slug, ids, workspacePath } = this.deps
1953
+ const session = ids('s')
1954
+ // `session.started.role` records the LOGICAL name the pipeline dispatched
1955
+ // in every case, including when a deprecated alias answered the lookup —
1956
+ // the runtime/model on the same payload say what was resolved.
1957
+ const resolved = this.boundaryResolver.resolve(spec.role, ...(spec.roleAliases ?? []))
1958
+ const { runner, runtime: runnerName, model, args, sessionBudgetSeconds } = resolved
1959
+ const route = JSON.stringify({ runtime: runnerName, model, args })
1960
+ // SPEC §9: the stream opens before the bracket's first turn — its id is
1961
+ // already known when `session.started` appends.
1962
+ const streamBracket = await this.openSessionStream(resolved, {
1963
+ session,
1964
+ role: spec.role,
1965
+ runner: runnerName,
1966
+ ...(model !== undefined ? { model } : {}),
1967
+ phase: spec.phase,
1968
+ round: spec.round,
1969
+ })
1970
+ let live =
1971
+ spec.producerPhase !== undefined ? this.producerSessions.get(spec.producerPhase) : undefined
1972
+ if (live !== undefined && (live.runner !== runner || live.route !== route)) {
1973
+ // A completed old round may keep provider continuation state, but a new
1974
+ // route must begin a new provider session. The old round itself was not
1975
+ // interrupted; it reached this boundary before being closed.
1976
+ try {
1977
+ await live.runner.end(live.handle)
1978
+ } catch {
1979
+ // Best-effort provider cleanup; route adoption must still proceed.
1980
+ }
1981
+ if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
1982
+ live = undefined
1983
+ }
1984
+
1985
+ const startedEnvelope = await store.append(slug, {
1986
+ actor: KERNEL,
1987
+ type: 'session.started',
1988
+ payload: {
1989
+ session,
1990
+ role: spec.role,
1991
+ runner: runnerName,
1992
+ ...(model !== undefined ? { model } : {}),
1993
+ args: [...args],
1994
+ phase: spec.phase,
1995
+ round: spec.round,
1996
+ ...(streamBracket !== undefined ? { stream: streamBracket.streamId } : {}),
1997
+ },
1998
+ } satisfies EventWrite<'session.started'>)
1999
+ const preSeq = startedEnvelope.seq
2000
+ const stream = streamBracket?.sink
2001
+
2002
+ let handle: AgentSessionHandle | undefined = live?.handle
2003
+ let result: AgentTurnResult | undefined
2004
+ let turnError: unknown
2005
+ const turn = await this.runBudgetedTurn(preSeq, sessionBudgetSeconds, (signal) => {
2006
+ if (live !== undefined) {
2007
+ // §10: the producer continues its session; the feedback message
2008
+ // points at the .ab/ inputs — `ab context` is the real carrier. The
2009
+ // turn re-issues ambient auth (D8) for the NEW bracket — AB_PHASE at
2010
+ // this round, AB_SESSION = this bracket's id — so the CLI's terminal
2011
+ // lands on the continued round instead of being rejected as round
2012
+ // 1's second terminal (§8.4 D5).
2013
+ return live.runner
2014
+ .continue(live.handle, continueMessage(spec), {
2015
+ env: this.sessionEnvFor(spec.abPhase, session),
2016
+ signal,
2017
+ ...(stream !== undefined ? { stream } : {}),
2018
+ })
2019
+ .then((continued) => ({ session: live.handle, result: continued }))
2020
+ }
2021
+ return runner.start({
2022
+ skill: spec.skill,
2023
+ invocation: slug,
2024
+ buildSlug: slug,
2025
+ workspacePath,
2026
+ ...(model !== undefined ? { model } : {}),
2027
+ ...(args !== undefined ? { args } : {}),
2028
+ env: this.sessionEnvFor(spec.abPhase, session),
2029
+ signal,
2030
+ ...(stream !== undefined ? { stream } : {}),
2031
+ })
2032
+ })
2033
+ if (turn.settlement.kind === 'value') {
2034
+ handle = turn.settlement.value.session
2035
+ result = turn.settlement.value.result
2036
+ } else if (turn.settlement.kind === 'error') {
2037
+ turnError = turn.settlement.error
2038
+ } else if (live === undefined) {
2039
+ this.releaseLateStart(turn.pending, runner)
2040
+ }
2041
+
2042
+ // The turn is over: what did the CLI leave in the log? Terminal = the
2043
+ // phase's terminal event for this round, or an `ab escalate` from this
2044
+ // session (§8.4: escalate is always an allowed terminal).
2045
+ const since = await store.getEvents(slug, preSeq)
2046
+ const terminal = since.some(
2047
+ (event) =>
2048
+ spec.isTerminal(event) ||
2049
+ isPublicationBoundary(event, spec, session) ||
2050
+ (event.type === 'escalation.raised' &&
2051
+ event.actor.kind === 'agent' &&
2052
+ event.actor.session === session),
2053
+ )
2054
+
2055
+ // CRASH-GAP REPAIR (§8.5 makes verdict+escalation near-atomic; this keeps
2056
+ // the log routable when the CLI died between the two).
2057
+ if (spec.phase === 'plan-review' || spec.phase === 'code-review') {
2058
+ await this.repairEscalateGap(since, spec.phase, spec.round)
2059
+ }
2060
+
2061
+ const bracket = {
2062
+ phase: spec.phase,
2063
+ round: spec.round,
2064
+ role: spec.role,
2065
+ runnerName,
2066
+ }
2067
+
2068
+ // Operator cancellation is not an infrastructure failure. Close whatever
2069
+ // handle the adapter created, retain its transcript, and return to the
2070
+ // engine loop; the pending durable command is acknowledged there.
2071
+ if (turn.abortCause === 'operator') {
2072
+ if (handle !== undefined) {
2073
+ const owner = live?.runner ?? runner
2074
+ try {
2075
+ const transcript = await owner.end(handle)
2076
+ await this.depositTranscriptAndEnd(
2077
+ session,
2078
+ bracket,
2079
+ transcript.content,
2080
+ transcript.metadata.usage,
2081
+ transcript.metadata.model ?? model,
2082
+ streamBracket !== undefined ? { stream: streamBracket, outcome: 'aborted' } : undefined,
2083
+ )
2084
+ } catch {
2085
+ // A provider that died before yielding an endable handle leaves the
2086
+ // open session for stale-runner recovery, but never a phase failure.
2087
+ }
2088
+ }
2089
+ this.closeBracketStream(streamBracket, 'aborted')
2090
+ if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
2091
+ return
2092
+ }
2093
+
2094
+ // The typed terminal is the phase authority even when it lands on the
2095
+ // same boundary tick as the kernel deadline.
2096
+ if (terminal && turn.abortCause === 'budget') {
2097
+ if (handle !== undefined) {
2098
+ const owner = live?.runner ?? runner
2099
+ this.releaseExpiredSession(owner, handle, session, bracket, model, streamBracket)
2100
+ } else {
2101
+ this.closeBracketStream(streamBracket, 'aborted')
2102
+ }
2103
+ if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
2104
+ return
2105
+ }
2106
+
2107
+ if (terminal && handle !== undefined && result !== undefined) {
2108
+ if (spec.producerPhase !== undefined) {
2109
+ // §10 memory: keep the handle live for the next round; the bracket
2110
+ // closes with THIS turn's transcript (see module doc — ending the
2111
+ // runner session would destroy the continuation every adapter needs).
2112
+ this.producerSessions.set(spec.producerPhase, {
2113
+ handle,
2114
+ runner: live?.runner ?? runner,
2115
+ route,
2116
+ })
2117
+ const content = JSON.stringify(
2118
+ {
2119
+ session,
2120
+ phase: spec.phase,
2121
+ round: spec.round,
2122
+ note: 'producer session kept live for §10 continuation; ' + 'per-round turn transcript',
2123
+ turn: { text: result.text, usage: result.usage },
2124
+ },
2125
+ null,
2126
+ 2,
2127
+ )
2128
+ await this.depositTranscriptAndEnd(
2129
+ session,
2130
+ bracket,
2131
+ content,
2132
+ result.usage,
2133
+ model,
2134
+ streamBracket !== undefined ? { stream: streamBracket, outcome: 'completed' } : undefined,
2135
+ )
2136
+ } else {
2137
+ // Reviewer / agent-verify: single-run session, real transcript.
2138
+ const transcript = await runner.end(handle)
2139
+ await this.depositTranscriptAndEnd(
2140
+ session,
2141
+ bracket,
2142
+ transcript.content,
2143
+ transcript.metadata.usage,
2144
+ transcript.metadata.model ?? model,
2145
+ streamBracket !== undefined ? { stream: streamBracket, outcome: 'completed' } : undefined,
2146
+ )
2147
+ }
2148
+ return
2149
+ }
2150
+
2151
+ if (terminal) {
2152
+ if (handle !== undefined) {
2153
+ const owner = live?.runner ?? runner
2154
+ try {
2155
+ const transcript = await owner.end(handle)
2156
+ await this.depositTranscriptAndEnd(
2157
+ session,
2158
+ bracket,
2159
+ transcript.content,
2160
+ transcript.metadata.usage,
2161
+ transcript.metadata.model ?? model,
2162
+ streamBracket !== undefined
2163
+ ? { stream: streamBracket, outcome: 'completed' }
2164
+ : undefined,
2165
+ )
2166
+ } catch {
2167
+ // The durable terminal remains authoritative over adapter failure
2168
+ // and best-effort transcript cleanup.
2169
+ }
2170
+ }
2171
+ this.closeBracketStream(streamBracket, 'completed')
2172
+ if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
2173
+ return
2174
+ }
2175
+
2176
+ if (turn.abortCause === 'budget') {
2177
+ if (handle !== undefined) {
2178
+ const owner = live?.runner ?? runner
2179
+ this.releaseExpiredSession(owner, handle, session, bracket, model, streamBracket)
2180
+ } else {
2181
+ this.closeBracketStream(streamBracket, 'aborted')
2182
+ }
2183
+ if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
2184
+ await this.failPhase(
2185
+ spec.phase,
2186
+ spec.round,
2187
+ spec.priorFailures,
2188
+ sessionBudgetError(sessionBudgetSeconds),
2189
+ true,
2190
+ )
2191
+ return
2192
+ }
2193
+
2194
+ // No terminal, a structured failed turn, or a throw: an infra failure,
2195
+ // not a verdict (§8.4). End whatever session exists — the transcript still
2196
+ // joins the corpus —
2197
+ // then record the failure and drop the producer handle: a rambling
2198
+ // session is not continued; the next attempt starts fresh (D5).
2199
+ if (handle !== undefined) {
2200
+ const owner = live?.runner ?? runner
2201
+ try {
2202
+ const transcript = await owner.end(handle)
2203
+ await this.depositTranscriptAndEnd(
2204
+ session,
2205
+ bracket,
2206
+ transcript.content,
2207
+ transcript.metadata.usage,
2208
+ transcript.metadata.model ?? model,
2209
+ streamBracket !== undefined ? { stream: streamBracket, outcome: 'aborted' } : undefined,
2210
+ )
2211
+ } catch {
2212
+ // Transcript deposition can still fail. A later recovering runner
2213
+ // explicitly reclaims this open session before announcing attachment.
2214
+ }
2215
+ }
2216
+ this.closeBracketStream(streamBracket, 'aborted')
2217
+ if (spec.producerPhase !== undefined) {
2218
+ this.producerSessions.delete(spec.producerPhase)
2219
+ }
2220
+ const failure =
2221
+ turnError !== undefined
2222
+ ? { message: errorMessage(turnError), mayRetry: true }
2223
+ : result?.kind === 'failed'
2224
+ ? {
2225
+ message: result.failure.message,
2226
+ mayRetry: !result.failure.permanent,
2227
+ }
2228
+ : { message: 'no-terminal', mayRetry: true }
2229
+ await this.failPhase(
2230
+ spec.phase,
2231
+ spec.round,
2232
+ spec.priorFailures,
2233
+ failure.message,
2234
+ failure.mayRetry,
2235
+ )
2236
+ }
2237
+
2238
+ /** Transcript artifact + `session.ended` in one atomic bundle (D6). The
2239
+ * metadata IS the analysis corpus (§7.1): phase, round, role, runner,
2240
+ * model, session, usage. When the bracket carried a stream, it closes
2241
+ * here — before the `session.ended` append lands — so a reader never sees
2242
+ * a closed session with an open stream. */
2243
+ private async depositTranscriptAndEnd(
2244
+ session: string,
2245
+ bracket: { phase: Phase; round: number; role: string; runnerName: string },
2246
+ content: string,
2247
+ usage: { inputTokens: number; outputTokens: number; turns: number },
2248
+ model?: string,
2249
+ close?: { stream: SessionBracketStream; outcome: StreamOutcome },
2250
+ ): Promise<void> {
2251
+ const { store, slug } = this.deps
2252
+ if (close !== undefined) {
2253
+ // Close must land no later than `session.ended`; this makes the
2254
+ // ordering structural rather than call-site discipline.
2255
+ await close.stream.sink.close(close.outcome)
2256
+ }
2257
+ await store.appendWithArtifacts(
2258
+ slug,
2259
+ [
2260
+ {
2261
+ kind: 'transcript',
2262
+ content,
2263
+ metadata: {
2264
+ phase: bracket.phase,
2265
+ round: bracket.round,
2266
+ role: bracket.role,
2267
+ runner: bracket.runnerName,
2268
+ ...(model !== undefined ? { model } : {}),
2269
+ session,
2270
+ usage,
2271
+ },
2272
+ },
2273
+ ],
2274
+ (deposited) => {
2275
+ const meta = deposited[0]
2276
+ if (!meta) throw new Error('transcript deposit returned no metadata')
2277
+ return {
2278
+ actor: KERNEL,
2279
+ type: 'session.ended',
2280
+ payload: {
2281
+ session,
2282
+ transcript: { kind: meta.kind, rev: meta.revision },
2283
+ usage,
2284
+ },
2285
+ } satisfies EventWrite<'session.ended'>
2286
+ },
2287
+ )
2288
+ }
2289
+
2290
+ /** A review verdict 'escalate' whose `escalation.raised` never landed →
2291
+ * append it (kernel actor, source 'agent', question from the reason). */
2292
+ private async repairEscalateGap(
2293
+ since: AbEvent[],
2294
+ phase: 'plan-review' | 'code-review',
2295
+ round: number,
2296
+ ): Promise<void> {
2297
+ let verdict: { seq: number; reason?: string } | undefined
2298
+ for (const event of since) {
2299
+ if (event.type !== 'plan-review.verdict' && event.type !== 'code-review.verdict') {
2300
+ continue
2301
+ }
2302
+ const expected = phase === 'plan-review' ? 'plan-review.verdict' : 'code-review.verdict'
2303
+ if (event.type !== expected) continue
2304
+ if (event.payload.round !== round || event.payload.verdict !== 'escalate') continue
2305
+ verdict = {
2306
+ seq: event.seq,
2307
+ ...(event.payload.reason !== undefined ? { reason: event.payload.reason } : {}),
2308
+ }
2309
+ }
2310
+ if (verdict === undefined) return
2311
+ const raisedSeq = verdict.seq
2312
+ if (since.some((e) => e.type === 'escalation.raised' && e.seq > raisedSeq)) return
2313
+ await this.deps.store.append(this.deps.slug, {
2314
+ actor: KERNEL,
2315
+ type: 'escalation.raised',
2316
+ payload: {
2317
+ id: this.deps.ids('esc'),
2318
+ phase,
2319
+ round,
2320
+ source: 'agent',
2321
+ question: verdict.reason ?? `${phase} escalated without a recorded reason`,
2322
+ },
2323
+ } satisfies EventWrite<'escalation.raised'>)
2324
+ }
2325
+
2326
+ // ── Session streams (SPEC §9) ────────────────────────────────────────────
2327
+
2328
+ /**
2329
+ * Open one stream for this session bracket through the resolved target's
2330
+ * streaming capability: create the store-backed sink, call the capability,
2331
+ * and — once it returns an id — emit the bracket's `data-ab-session` part.
2332
+ * Returns undefined (and reports once) when streaming is disabled, the
2333
+ * runtime declared no capability, or the opener declined/failed; the
2334
+ * bracket then proceeds exactly as without streams.
2335
+ */
2336
+ private async openSessionStream(
2337
+ target: ResolvedRuntime,
2338
+ info: SessionStreamInfo,
2339
+ ): Promise<SessionBracketStream | undefined> {
2340
+ if (!this.streamSessions) return undefined
2341
+ const open = target.openSessionStream
2342
+ if (open === undefined) return undefined
2343
+ const sink = createSessionStreamSink({
2344
+ store: this.deps.store,
2345
+ scope: { kind: 'build', build: this.deps.slug },
2346
+ onDiagnostic: this.onDiagnostic,
2347
+ })
2348
+ try {
2349
+ const streamId = await open(sink, info)
2350
+ if (streamId === undefined) return undefined
2351
+ sink.append([sessionPart(info)])
2352
+ return { sink, streamId, info }
2353
+ } catch (error) {
2354
+ this.onDiagnostic(
2355
+ `session stream for "${info.session}" failed to open: ${errorMessage(error)}`,
2356
+ )
2357
+ return undefined
2358
+ }
2359
+ }
2360
+
2361
+ /** Best-effort belt-and-braces close; idempotency makes repeated calls
2362
+ * harmless and lets the deposit path own the close-then-append ordering. */
2363
+ private closeBracketStream(
2364
+ stream: SessionBracketStream | undefined,
2365
+ outcome: StreamOutcome,
2366
+ ): void {
2367
+ if (stream === undefined) return
2368
+ void stream.sink.close(outcome)
2369
+ }
2370
+
2371
+ /** D8 ambient auth: sessionEnv may override AB_STORE (remote stores) and
2372
+ * add AB_TOKEN, but never the per-session identity keys. */
2373
+ private sessionEnvFor(abPhase: string, session: string): Record<string, string> {
2374
+ return {
2375
+ AB_STORE: 'local',
2376
+ ...this.deps.sessionEnv,
2377
+ AB_BUILD: this.deps.slug,
2378
+ AB_PHASE: abPhase,
2379
+ AB_SESSION: session,
2380
+ }
2381
+ }
2382
+
2383
+ /**
2384
+ * phase.failed tally for one phase+round — D5's retry-guard input.
2385
+ *
2386
+ * An answered escalation re-arms this budget only when its semantic class
2387
+ * acknowledges phase-runner failures (§15.6-B). Runner-failure causes reset
2388
+ * their matching round, the phase-level verify cause resets all rounds, and
2389
+ * unrelated policy causes reset nothing. Cause-less historical and
2390
+ * non-policy raises retain the legacy round-shaped behavior. Failures after
2391
+ * an applicable answer count again, so a still-failing phase re-escalates on
2392
+ * new evidence instead of deadlocking before its recovery session starts.
2393
+ */
2394
+ private phaseFailures(
2395
+ events: AbEvent[],
2396
+ phase: Phase,
2397
+ round: number,
2398
+ ): {
2399
+ count: number
2400
+ lastError?: string
2401
+ lastWillRetry?: boolean
2402
+ lastProviderAttempts?: readonly ProviderAttempt[]
2403
+ } {
2404
+ /** id → the raise's reset semantics — answers are matched by id (§15.3). */
2405
+ const raised = new Map<string, PhaseFailureResetEscalation>()
2406
+ let count = 0
2407
+ let lastError: string | undefined
2408
+ let lastWillRetry: boolean | undefined
2409
+ let lastProviderAttempts: readonly ProviderAttempt[] | undefined
2410
+ for (const event of events) {
2411
+ switch (event.type) {
2412
+ case 'escalation.raised':
2413
+ raised.set(event.payload.id, {
2414
+ phase: event.payload.phase,
2415
+ source: event.payload.source,
2416
+ ...(event.payload.policyCause !== undefined
2417
+ ? { policyCause: event.payload.policyCause }
2418
+ : {}),
2419
+ ...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
2420
+ })
2421
+ break
2422
+ case 'escalation.answered': {
2423
+ const raise = raised.get(event.payload.id)
2424
+ if (raise !== undefined && resetsPhaseFailureBudget(raise, phase, round)) {
2425
+ count = 0
2426
+ lastError = undefined
2427
+ lastWillRetry = undefined
2428
+ lastProviderAttempts = undefined
2429
+ }
2430
+ break
2431
+ }
2432
+ case 'phase.failed':
2433
+ if (event.payload.phase === phase && event.payload.round === round) {
2434
+ count += 1
2435
+ lastError = event.payload.error
2436
+ lastWillRetry = event.payload.willRetry
2437
+ lastProviderAttempts = event.payload.providerAttempts
2438
+ }
2439
+ break
2440
+ default:
2441
+ break
2442
+ }
2443
+ }
2444
+ return {
2445
+ count,
2446
+ ...(lastError !== undefined ? { lastError } : {}),
2447
+ ...(lastWillRetry !== undefined ? { lastWillRetry } : {}),
2448
+ ...(lastProviderAttempts !== undefined ? { lastProviderAttempts } : {}),
2449
+ }
2450
+ }
2451
+
2452
+ private async failPhase(
2453
+ phase: Phase,
2454
+ round: number,
2455
+ priorFailures: number,
2456
+ error: string,
2457
+ mayRetry = true,
2458
+ providerAttempts?: readonly ProviderAttempt[],
2459
+ ): Promise<void> {
2460
+ const attempt = priorFailures + 1
2461
+ await this.deps.store.append(this.deps.slug, {
2462
+ actor: KERNEL,
2463
+ type: 'phase.failed',
2464
+ payload: {
2465
+ phase,
2466
+ round,
2467
+ attempt,
2468
+ error,
2469
+ willRetry: mayRetry && attempt < this.maxPhaseAttempts,
2470
+ ...(providerAttempts !== undefined ? { providerAttempts: [...providerAttempts] } : {}),
2471
+ },
2472
+ } satisfies EventWrite<'phase.failed'>)
2473
+ }
2474
+
2475
+ /** A provider/runner-declared permanent failure parks immediately. The fact
2476
+ * comes from the latest durable `phase.failed {willRetry:false}`, so a crash
2477
+ * between that write and this escalation resumes safely without attempt 2. */
2478
+ private async raisePolicyNonRetryable(
2479
+ phase: Phase,
2480
+ round: number,
2481
+ failures: {
2482
+ count: number
2483
+ lastError?: string
2484
+ lastProviderAttempts?: readonly ProviderAttempt[]
2485
+ },
2486
+ ): Promise<void> {
2487
+ await this.deps.store.append(this.deps.slug, {
2488
+ actor: KERNEL,
2489
+ type: 'escalation.raised',
2490
+ payload: {
2491
+ id: this.deps.ids('esc'),
2492
+ phase,
2493
+ round,
2494
+ source: 'policy',
2495
+ policyCause: 'non-retryable-phase-failure',
2496
+ question:
2497
+ `${phase} round ${round} stopped after a non-retryable ` +
2498
+ `provider/runner failure on attempt ${failures.count}; last error: ` +
2499
+ `${failures.lastError ?? 'unknown'}` +
2500
+ providerAttemptSummary(failures.lastProviderAttempts),
2501
+ },
2502
+ } satisfies EventWrite<'escalation.raised'>)
2503
+ }
2504
+
2505
+ /** D5: the retry cap escalates (source 'policy') instead of running,
2506
+ * naming the phase, round, and last error. */
2507
+ private async raisePolicyExhausted(
2508
+ phase: Phase,
2509
+ round: number,
2510
+ failures: {
2511
+ count: number
2512
+ lastError?: string
2513
+ lastProviderAttempts?: readonly ProviderAttempt[]
2514
+ },
2515
+ ): Promise<void> {
2516
+ await this.deps.store.append(this.deps.slug, {
2517
+ actor: KERNEL,
2518
+ type: 'escalation.raised',
2519
+ payload: {
2520
+ id: this.deps.ids('esc'),
2521
+ phase,
2522
+ round,
2523
+ source: 'policy',
2524
+ policyCause: 'phase-attempt-limit',
2525
+ question:
2526
+ `${phase} round ${round} failed ${failures.count} times ` +
2527
+ `(maxPhaseAttempts ${this.maxPhaseAttempts}); last error: ` +
2528
+ `${failures.lastError ?? 'unknown'}` +
2529
+ providerAttemptSummary(failures.lastProviderAttempts),
2530
+ },
2531
+ } satisfies EventWrite<'escalation.raised'>)
2532
+ }
2533
+
2534
+ /** The phase's started event, payload per the catalog (§15.3). */
2535
+ private startedWrite(decision: RunPhaseDecision, reconcileBaseSha?: string): EventWrite {
2536
+ const { phase, round } = decision
2537
+ switch (phase) {
2538
+ case 'plan':
2539
+ // Feedback rides the started payload exactly like implement (§15.3
2540
+ // symmetric by design): a parked plan loop re-attaches with a FRESH
2541
+ // session, so `ab context` — not the continue message — is what
2542
+ // delivers an answered guidance escalation to the producer (§15.6-B).
2543
+ return {
2544
+ actor: KERNEL,
2545
+ type: 'plan.started',
2546
+ payload: {
2547
+ round,
2548
+ ...(decision.feedback !== undefined ? { feedback: decision.feedback } : {}),
2549
+ },
2550
+ } satisfies EventWrite<'plan.started'>
2551
+ case 'plan-review':
2552
+ return {
2553
+ actor: KERNEL,
2554
+ type: 'plan-review.started',
2555
+ payload: { round },
2556
+ } satisfies EventWrite<'plan-review.started'>
2557
+ case 'implement':
2558
+ return {
2559
+ actor: KERNEL,
2560
+ type: 'implement.started',
2561
+ payload: {
2562
+ round,
2563
+ ...(decision.feedback !== undefined ? { feedback: decision.feedback } : {}),
2564
+ },
2565
+ } satisfies EventWrite<'implement.started'>
2566
+ case 'code-review':
2567
+ return {
2568
+ actor: KERNEL,
2569
+ type: 'code-review.started',
2570
+ payload: { round },
2571
+ } satisfies EventWrite<'code-review.started'>
2572
+ case 'finalize':
2573
+ return {
2574
+ actor: KERNEL,
2575
+ type: 'finalize.started',
2576
+ payload: {},
2577
+ } satisfies EventWrite<'finalize.started'>
2578
+ case 'reconcile': {
2579
+ const reconcile = decision.reconcile
2580
+ if (reconcile === undefined || reconcileBaseSha === undefined) {
2581
+ // The engine supplies the attempt; runPhase resolves the current
2582
+ // base before calling this helper. Keep the runner total anyway.
2583
+ throw new Error('run-phase reconcile start requires an attempt and refreshed base SHA')
2584
+ }
2585
+ return {
2586
+ actor: KERNEL,
2587
+ type: 'reconcile.started',
2588
+ payload: { attempt: reconcile.attempt, baseSha: reconcileBaseSha },
2589
+ } satisfies EventWrite<'reconcile.started'>
2590
+ }
2591
+ }
2592
+ }
2593
+
2594
+ private stopHeartbeat(): void {
2595
+ if (this.heartbeatTimer !== undefined) {
2596
+ clearInterval(this.heartbeatTimer)
2597
+ this.heartbeatTimer = undefined
2598
+ }
2599
+ }
2600
+
2601
+ /** End live producer sessions best-effort on exit: every round already
2602
+ * deposited its transcript, so the cumulative turn log is discarded; no
2603
+ * events are appended (the brackets are already closed). */
2604
+ private async closeProducerSessions(): Promise<void> {
2605
+ for (const entry of this.producerSessions.values()) {
2606
+ try {
2607
+ await entry.runner.end(entry.handle)
2608
+ } catch {
2609
+ // Already dead — nothing to release.
2610
+ }
2611
+ }
2612
+ this.producerSessions.clear()
2613
+ }
2614
+ }