@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,3692 @@
1
+ /**
2
+ * `ab dispatch` — the operator's entry into the outer loop (SPEC §3.3, §12).
3
+ * Runs OUTSIDE build sessions like init/upgrade/ticket (§16.3): it takes a
4
+ * repo, not a build, loads its autobuild.toml, wires the real ports, and runs
5
+ * the dispatcher's `tick()` — janitor → startup resume → lease sweep →
6
+ * dispatch — either once (`--once`) or on a watch loop until interrupted.
7
+ * Startup resume runs once per invocation and attempts every current build;
8
+ * later watch ticks preserve deliberate policy parks.
9
+ *
10
+ * It is the SAME `ab` binary agents use (§8): install is `ab` + `ab init`, and
11
+ * everyone — agents and operators — attaches to this one surface. The heavy
12
+ * adapters are constructed here behind an injectable `wire` seam, exactly as
13
+ * `ab ticket create` constructs its TicketSource via a factory, so the whole
14
+ * loop is testable over fakes.
15
+ *
16
+ * Concurrency is config, not code (§16.1): top-level `capacity` caps the
17
+ * concurrent builds for this repo. `launchRunner` starts each build-runner
18
+ * IN-PROCESS but does not block the dispatcher on it (fire-and-forget,
19
+ * tracked) — so with capacity N up to N builds run at once, while the
20
+ * dispatcher's own active-count gate (§12) keeps it from over-launching. A
21
+ * runner drives its build to a park point (§11) and returns; the watch loop's
22
+ * next tick advances the post-PR epilogue (§15.7).
23
+ */
24
+ import { hostname } from 'node:os'
25
+ import { mkdir } from 'node:fs/promises'
26
+ import { createHash } from 'node:crypto'
27
+ import { tmpdir } from 'node:os'
28
+ import { join, resolve } from 'node:path'
29
+ import semver from 'semver'
30
+ import { parseConfig } from '../config/load'
31
+ import { DISPATCHER_CONFIG_ARTIFACT, LiveConfig, type ConfigSnapshot } from '../config/live'
32
+ import { effectiveRuntimeReferences, roleKeyWarnings, SLUG_ROLE } from '../config/roles'
33
+ import type { Config } from '../config/schema'
34
+ import { loadPlugins } from '../plugins/load'
35
+ import type { PluginRegistry } from '../plugins/registry'
36
+ import { materializePluginRuntimes } from '../plugins/runtimes'
37
+ import { DISPATCHER, humanActor } from '../events/envelope'
38
+ import type { RepositoryEventWrite } from '../events/repository'
39
+ import { randomIds, randomUuids, type IdSource, type UuidSource } from '../ids'
40
+ import { reduceDispatchSettings } from '../kernel/dispatch-settings'
41
+ import {
42
+ DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
43
+ actionableHarvestRun,
44
+ decideHarvestControl,
45
+ reduceHarvest,
46
+ } from '../kernel/harvest'
47
+ import { reduceBuild } from '../kernel/reducer'
48
+ import { dashboardBuildControl } from './dashboard/actions'
49
+ import {
50
+ buildDashboardFromProjected,
51
+ projectHarvest,
52
+ type DashboardBuild,
53
+ type DashboardModel,
54
+ type DashboardSelection,
55
+ type DashboardView,
56
+ } from './dashboard/model'
57
+ import { DashboardBuildPollCache } from './dashboard/poll'
58
+ import {
59
+ dashboardContentWidth,
60
+ detailScrollLimit,
61
+ moveDetailScroll,
62
+ moveTranscriptScroll,
63
+ renderDashboard,
64
+ revealDetailFocus,
65
+ type DashboardRendererResolver,
66
+ } from './dashboard/render'
67
+ import { parseTranscript } from './dashboard/transcript'
68
+ import { applySessionFeedUpdate, SessionStreamFeed } from './dashboard/session-feed'
69
+ import { moveSessionScroll, sessionScrollLimit } from './dashboard/render'
70
+ import { deleteBefore, insertText, moveCursor, type ComposerMotion } from './dashboard/composer'
71
+ import { dashboardSelections, moveSelection, reconcileSelection } from './dashboard/selection'
72
+ import { LiveRegion, paintableRows } from './dashboard/live'
73
+ import { createKeyboardProtocol, type KeyboardProtocol } from './keyboard'
74
+ import { recordInfrastructureFailure as appendInfrastructureFailure } from '../processes/infrastructure-failure-budget'
75
+ import { settlePendingPublication as settleWorkspacePublication } from './publication-settlement'
76
+ import type { TerminalInput, TerminalInputEvent, TerminalOut } from './terminal'
77
+ import { createForge, resolveForgeRegistration } from '../ports/forge/create'
78
+ import {
79
+ GitHubApiError,
80
+ githubTokenFromEnv,
81
+ type GitHubRequest,
82
+ } from '../ports/forge/github-transport'
83
+ import { GitHubForge } from '../ports/forge/github'
84
+ import { createProductionRuntimes } from '../ports/runner/production'
85
+ import type { RuntimeRegistry } from '../ports/runner/runtime'
86
+ import { createTicketSource } from '../ports/tickets/create'
87
+ import type {
88
+ Forge,
89
+ TicketSource,
90
+ WorkspaceHandle,
91
+ WorkspaceProvider,
92
+ WorkspaceReapOutcome,
93
+ } from '../ports/types'
94
+ import { createWorkspaceRuntime, type WorkspaceRuntime } from '../ports/workspace/create'
95
+ import { GitWorktreeProvider } from '../ports/workspace/git-worktree'
96
+ import { LocalBuildExecution } from '../ports/workspace/local-build-execution'
97
+ import {
98
+ BUILD_EXECUTION_LEASE_TTL_MS,
99
+ type BuildExecution,
100
+ type BuildExecutionHandle,
101
+ } from '../ports/workspace/build-execution'
102
+ import type { Exec } from '../ports/workspace/git-worktree'
103
+ import { validateVercelGithubOrigin } from '../ports/workspace/vercel-sandbox'
104
+ import {
105
+ BUILD_EFFECTIVE_CONFIG_ARTIFACT,
106
+ BUILD_RUNNER_DIAGNOSTIC_ARTIFACT,
107
+ effectiveBuildConfigContent,
108
+ parseDiagnostic,
109
+ } from '../processes/build-execution-state'
110
+ import { HarvestRunner, type HarvestRunnerResult } from '../processes/harvest-runner'
111
+ import { scanUnclaimedObservations, evaluateHarvestPressure } from '../processes/harvest'
112
+ import { classifyHarvestOutcome } from '../processes/harvest-execution-state'
113
+ import type { HarvestExecution } from '../ports/workspace/harvest-execution'
114
+ import {
115
+ controlHarvestRun as applyHarvestRunControl,
116
+ toggleHarvestGate as applyHarvestGateToggle,
117
+ toggleRepositorySetting,
118
+ } from '../operator/control'
119
+ import {
120
+ Dispatcher,
121
+ emptyTickReport,
122
+ type LaunchRunnerResult,
123
+ type TickReport,
124
+ } from '../processes/dispatcher'
125
+ import {
126
+ BuildControlError,
127
+ buildControlUser,
128
+ controlBuild,
129
+ type BuildControlResult,
130
+ } from './build-control'
131
+ import { bulkControlReport, bulkControlRepository, type BulkDirection } from './bulk-control'
132
+ import {
133
+ normalizeGitRemoteUrl,
134
+ resolveRepoState,
135
+ resolveRepoStatePaths,
136
+ type RepoStatePaths,
137
+ } from './repo-state'
138
+ import { openStoreForRepoState } from './store-opening'
139
+ import { DispatchFrontend } from './dispatch-frontend'
140
+ import { systemClock, type BuildStore, type Clock } from '../store/types'
141
+ import { availableRelease } from './self-update'
142
+ import {
143
+ startUpgradeNotice,
144
+ type AvailableReleaseProbe,
145
+ type UpgradeNoticeScheduler,
146
+ } from './upgrade-notice'
147
+
148
+ /** Watch-loop default cadence between ticks (§3.3 re-run safety makes this a
149
+ * pure knob — a shorter interval only polls the forge more often). */
150
+ const DEFAULT_INTERVAL_MS = 10_000
151
+ /** Repository supervisor lease (§12 serialization): TTL and heartbeat cadence.
152
+ * The heartbeat renews at a third of the TTL, so one lost beat still leaves a
153
+ * full interval of margin before a peer can take over. */
154
+ const REPO_LEASE_TTL_MS = 60_000
155
+ const REPO_LEASE_HEARTBEAT_MS = 20_000
156
+ /** Repository artifact containing the schema-validated composed Config used by
157
+ * one dispatch run. It is the frontend's only config source. */
158
+ export const DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT = 'dispatcher-effective-config'
159
+
160
+ /** JSON encoding in the config schema's declarative input shape. Parsed Config
161
+ * has normalized `{steps, stepConfigs}` sections; flattening named step tables
162
+ * lets the frontend validate the artifact with the same strict configSchema. */
163
+ function effectiveConfigContent(config: Config): string {
164
+ const { verify, finalize, ...root } = config
165
+ return JSON.stringify({
166
+ ...root,
167
+ verify: { steps: verify.steps, ...verify.stepConfigs },
168
+ finalize: { steps: finalize.steps, ...finalize.stepConfigs },
169
+ })
170
+ }
171
+
172
+ /** The pre-build naming prompt. Its output is only a proposal: dispatcher.ts
173
+ * owns strict validation, timeout/failure fallback, and store-wide uniqueness. */
174
+ export function slugNamingPrompt(spec: string): string {
175
+ return [
176
+ 'Choose a short identifier for this software build.',
177
+ 'Return exactly one lowercase ASCII kebab-case identifier containing one to three meaningful words.',
178
+ 'Choose distinguishing subject/action words from the substance of the entire spec, not generic title lead-ins such as add, update, or please.',
179
+ 'Return no quotes, Markdown, explanation, or numeric collision suffix; collision handling is done separately.',
180
+ '',
181
+ '<build-spec>',
182
+ spec,
183
+ '</build-spec>',
184
+ ].join('\n')
185
+ }
186
+
187
+ function definedEnv(env: Record<string, string | undefined>): Record<string, string> {
188
+ const defined: Record<string, string> = {}
189
+ for (const [key, value] of Object.entries(env)) {
190
+ if (value !== undefined) defined[key] = value
191
+ }
192
+ return defined
193
+ }
194
+
195
+ /** Dashboard store-read cadence. `listBuilds` remains the discovery read;
196
+ * the process-local display cache then polls dashboard-visible streams with
197
+ * `getEvents(lastSeq)`, retaining abort cleanup until final completion and reusing unchanged
198
+ * reductions/timing projections. Repository controls and Harvest are still
199
+ * read fresh. The identical-frame check in `live.ts` makes an unchanged paint
200
+ * cost zero terminal writes. */
201
+ const DASHBOARD_POLL_MS = 500
202
+
203
+ /** Dashboard repaint (not re-read) cadence in watch mode. A running step's
204
+ * elapsed must advance ~1×/s even if the store poll is raised for a slow remote
205
+ * store, so paint is decoupled from the store read and driven from this cheaper
206
+ * timer. A knob: the identical-frame check in `live.ts` collapses a repaint to
207
+ * zero writes until a displayed second actually changes, so a sub-second cadence
208
+ * costs nothing. `--once` runs no tick timer — it renders one snapshot per
209
+ * state (AC 8). */
210
+ const DASHBOARD_TICK_MS = 250
211
+
212
+ type DashboardAction =
213
+ | 'up'
214
+ | 'down'
215
+ | 'left'
216
+ | 'right'
217
+ | 'enter'
218
+ | 'auto-merge'
219
+ | 'intake'
220
+ | 'pause'
221
+ | 'resume'
222
+ | 'bulk-pause'
223
+ | 'bulk-resume'
224
+ | 'discard'
225
+ | 'abort-confirm'
226
+ | 'harvest-gate'
227
+ | { kind: 'harvest-run'; run: string | undefined }
228
+
229
+ interface ResumePrompt {
230
+ slug: string
231
+ /** Snapshot at prompt-open time. Submission revalidates each id. */
232
+ escalationIds: string[]
233
+ value: string
234
+ /** Caret as a UTF-16 grapheme-boundary offset into `value`. */
235
+ cursor: number
236
+ }
237
+
238
+ /** The real adapters the loop drives — resolved by `wire` (default: the
239
+ * production ports; tests inject fakes). */
240
+ export interface DispatchWiring {
241
+ store: BuildStore
242
+ tickets: TicketSource
243
+ forge: Forge
244
+ workspaces: WorkspaceProvider
245
+ /** Provider and executor pairs for workspaces recorded before a
246
+ * `[workspace].provider` switch. A build is reaped, released, and executed
247
+ * only through the runtime named on its provisioned fact. */
248
+ retiredWorkspaces?: readonly WorkspaceRuntime[]
249
+ /** Workspace-adjacent build executor. Production always supplies the local
250
+ * subprocess implementation; tests may inject an in-process double. */
251
+ buildExecution: BuildExecution
252
+ /** Runtime registry (§9): name → adapter + compatibility data. The resolver
253
+ * applies `[roles]`, whose `default` entry is required. */
254
+ runtimes: RuntimeRegistry
255
+ /** The store reference sessions resolve as `AB_STORE` (D8) — MUST name the
256
+ * same store as `store`, so an agent's `ab` commands write where the
257
+ * dispatcher reads. */
258
+ storeRef: string
259
+ /** Scoped token for a remote store (D8, `AB_TOKEN`); passed to sessions. */
260
+ token?: string
261
+ ids: IdSource
262
+ uuids: UuidSource
263
+ clock: Clock
264
+ /** Validated startup catalog used by selected plugin adapters. Runtime
265
+ * factories are materialized into `runtimes` before role resolution. */
266
+ plugins?: PluginRegistry
267
+ /** Keeps workspace-owned runtime preflights aligned with accepted hot role reloads. */
268
+ updateRuntimeReferences?: (config: Config) => void
269
+ }
270
+
271
+ export type DispatchNonStoreWiring = Omit<DispatchWiring, 'store' | 'storeRef' | 'token'>
272
+
273
+ /** Timer seam for the invocation bound (hosted cron): the drain races the
274
+ * in-flight build set against a `deadlineAt` expiry timer. Production uses
275
+ * `DEFAULT_TIMERS`; tests inject a fake whose pending handles are inspectable
276
+ * and fireable, so both race outcomes are deterministic — no real-time sleeps.
277
+ * Handles are opaque (`unknown`) so the interface stays runtime-agnostic. */
278
+ export interface TimerScheduler {
279
+ setTimeout(handler: () => void, ms: number): unknown
280
+ clearTimeout(handle: unknown): void
281
+ }
282
+
283
+ /** Production scheduler: real global timers, preserving the existing
284
+ * `unref()` courtesy so a pending expiry never holds the process open. */
285
+ const DEFAULT_TIMERS: TimerScheduler = {
286
+ setTimeout(handler, ms) {
287
+ const timer = setTimeout(handler, ms)
288
+ ;(timer as { unref?: () => void }).unref?.()
289
+ return timer
290
+ },
291
+ clearTimeout(handle) {
292
+ clearTimeout(handle as ReturnType<typeof setTimeout>)
293
+ },
294
+ }
295
+
296
+ export interface DispatchOpts {
297
+ /** Repo the dispatcher serves (§12: one dispatcher per repo) — the cwd, or
298
+ * in origin mode a private scratch root. Filesystem consumers only. */
299
+ targetRepo: string
300
+ /** Checkout-less origin mode (AUT-302): serve the repository at this origin
301
+ * with no local checkout. CLI `--repository <origin>`, env `AB_REPOSITORY`.
302
+ * Requires an HTTPS AB_STORE + AB_TOKEN, GitHub credentials, and the
303
+ * builtin github forge (from the fetched config). */
304
+ repository?: string
305
+ /** Resolved repository identity — the normalized origin, or the checkout
306
+ * path when the checkout has no origin. Derived from repo state by
307
+ * `abDispatch`; an explicitly supplied value WINS, letting an embedding
308
+ * that already resolved the identity (and keyed its store by it) pin the
309
+ * dispatcher to the same key. Every Store-keyed key and record write uses
310
+ * it. */
311
+ repo?: string
312
+ /** Test seam for origin mode: the GitHub transport the startup config
313
+ * fetch (and the default forge) use instead of real fetch. */
314
+ originConfigTransport?: GitHubRequest
315
+ /** Process environment: adapter secrets (LINEAR_API_KEY) and AB_TOKEN. */
316
+ env: Record<string, string | undefined>
317
+ exec: Exec
318
+ stdout: (line: string) => void
319
+ stderr: (line: string) => void
320
+ /** Single pass then drain in-flight runners and exit; default is a loop. */
321
+ once?: boolean
322
+ /** Watch-loop cadence in ms (§3.3); default DEFAULT_INTERVAL_MS. */
323
+ intervalMs?: number
324
+ /** Explicit durable repository intake override. Omission reuses stored state,
325
+ * falling back to true only when the repository has no setting fact. */
326
+ intake?: boolean
327
+ /** Explicit durable repository claim-time auto-merge override. Omission
328
+ * reuses stored state, falling back to false on a fresh repository. */
329
+ defaultAutoMerge?: boolean
330
+ /** Explicit `--store` override; otherwise AB_STORE, then repo-local state. */
331
+ storeRef?: string
332
+ /** Watch-loop stop signal — the binary aborts it on SIGINT (§15.6-C: an
333
+ * interrupted runner's lease expires and a future dispatch re-attaches). */
334
+ signal?: AbortSignal
335
+ /** Injectable for tests — defaults to the production adapters. */
336
+ wire?: (
337
+ config: Config,
338
+ opts: DispatchOpts,
339
+ state: RepoStatePaths,
340
+ plugins: PluginRegistry,
341
+ ) => Promise<DispatchWiring> | DispatchWiring
342
+ /** Integration seam that substitutes only non-store ports. Store identity,
343
+ * reference, and token always come from production environment selection. */
344
+ nonStoreWire?: (
345
+ config: Config,
346
+ opts: DispatchOpts,
347
+ state: RepoStatePaths,
348
+ plugins: PluginRegistry,
349
+ ) => Promise<DispatchNonStoreWiring> | DispatchNonStoreWiring
350
+ /** Injectable sleep (watch loop); default a real timer. Tests use `once`. */
351
+ sleep?: (ms: number) => Promise<void>
352
+ /** Force line-oriented output with no terminal control sequences (`--plain`),
353
+ * whatever the terminal says. */
354
+ plain?: boolean
355
+ /**
356
+ * The interactive output seam. ABSENT ⇒ non-interactive ⇒ plain — which is
357
+ * exactly today's behavior, so the dashboard can never be the reason a
358
+ * scripted or piped `ab dispatch` starts emitting escapes. The shared binary
359
+ * wiring constructs the real one over `process.stdout`.
360
+ */
361
+ terminal?: TerminalOut
362
+ /** Injectable normalized keyboard/text source; the binary wraps stdin. */
363
+ input?: TerminalInput
364
+ /** Optional repo-dev presentation seam. The resolver is called for every
365
+ * paint; production omits it and remains bound to `renderDashboard`. */
366
+ resolveDashboardRenderer?: DashboardRendererResolver
367
+ /** Private child-kernel correlation. Presence suppresses terminal ownership
368
+ * and enables durable dispatcher status publication. */
369
+ kernelRunId?: string
370
+ /** Durable journaling identity of this dispatcher invocation. Every
371
+ * dispatcher-initiated journal write and the repository lease carry this id,
372
+ * so "who holds this repository?" and "who is writing this journal?" are the
373
+ * same string. Kernel children pass their kernelRunId; plain invocations
374
+ * leave it unset — abDispatch synthesizes `<host>-dispatch-<id>` once per
375
+ * invocation and the loop falls back to the same shape if unset. This is
376
+ * purely a journaling identity: behavioral kernelRunId gates (interactive
377
+ * frontend routing, terminal-ownership suppression) keep reading
378
+ * kernelRunId. */
379
+ runId?: string
380
+ /** Private child mode: preserve kernel behavior while routing no legacy
381
+ * line output into the terminal-owning parent. */
382
+ silent?: boolean
383
+ /** Invocation bound (epoch ms, measured against `wiring.clock`). `--once`
384
+ * only: an already-passed deadline skips the tick and the drain entirely
385
+ * (teardown still runs), and a deadline reached mid-drain stops awaiting
386
+ * `local-parent` executions, which teardown then stops and reaps — so one
387
+ * invocation always returns and unfinished work resumes next invocation.
388
+ * Watch mode is unaffected; absent ⇒ unbounded (today's exact behavior). */
389
+ deadlineAt?: number
390
+ /** Timer seam for the `deadlineAt` drain race. Absent ⇒ `DEFAULT_TIMERS`
391
+ * (real, unref'd timers); tests inject a manual scheduler to drive both
392
+ * race outcomes deterministically and to assert no expiry handle outlives
393
+ * the invocation. */
394
+ timers?: TimerScheduler
395
+ /** Interactive-watch-only release courtesy seams. The supervised kernel
396
+ * publishes results durably; direct plain/noninteractive and one-pass
397
+ * dispatch never consult them. */
398
+ availableReleaseProbe?: AvailableReleaseProbe
399
+ upgradeNoticeScheduler?: UpgradeNoticeScheduler
400
+ }
401
+
402
+ /** setTimeout that also resolves the moment ANY stop signal aborts, so OS
403
+ * SIGINT and raw-mode Ctrl-C share the same watch-loop boundary. */
404
+ function interruptibleSleep(
405
+ ms: number,
406
+ signals: readonly (AbortSignal | undefined)[],
407
+ ): Promise<void> {
408
+ return new Promise<void>((resolveSleep) => {
409
+ const live = signals.filter((signal): signal is AbortSignal => signal !== undefined)
410
+ if (live.some((signal) => signal.aborted)) return resolveSleep()
411
+ let settled = false
412
+ const finish = (): void => {
413
+ if (settled) return
414
+ settled = true
415
+ clearTimeout(timer)
416
+ for (const signal of live) signal.removeEventListener('abort', finish)
417
+ resolveSleep()
418
+ }
419
+ const timer = setTimeout(finish, ms)
420
+ for (const signal of live) signal.addEventListener('abort', finish, { once: true })
421
+ })
422
+ }
423
+
424
+ /** Production wiring: the local (or remote) store, configured adapters,
425
+ * git worktrees, and shipped runtimes. Forge construction deliberately happens
426
+ * before store opening so a plugin factory failure cannot precede a claim. */
427
+ async function defaultWire(
428
+ config: Config,
429
+ opts: DispatchOpts,
430
+ state: RepoStatePaths,
431
+ plugins: PluginRegistry,
432
+ ): Promise<DispatchWiring> {
433
+ // Origin mode: no checkout exists, so the provider's host git seams are
434
+ // replaced by the injected origin and the forge's remote reads.
435
+ const originMode = opts.repository !== undefined
436
+ if (config.workspace.provider === 'vercel-sandbox') {
437
+ if (config.forge !== 'github') {
438
+ throw new Error('vercel-sandbox requires the builtin github forge')
439
+ }
440
+ if (!originMode) {
441
+ const origin = await opts.exec(['git', 'remote', 'get-url', 'origin'], {
442
+ cwd: opts.targetRepo,
443
+ })
444
+ if (origin.exitCode !== 0) throw new Error('vercel-sandbox requires a readable Git origin')
445
+ validateVercelGithubOrigin(origin.stdout.trim())
446
+ }
447
+ if (githubTokenFromEnv(opts.env) === undefined) {
448
+ throw new Error(
449
+ 'vercel-sandbox publication requires GITHUB_TOKEN or GH_TOKEN in the dispatcher environment',
450
+ )
451
+ }
452
+ }
453
+ // The builtin GitHub forge resolves its credential lazily on its first
454
+ // request (GITHUB_TOKEN, GH_TOKEN, then the gh CLI login) and names the
455
+ // miss reason on any request that had to go out anonymously; nothing is
456
+ // probed here, so a dispatcher that never publishes spawns nothing.
457
+ const forge = await createForge({
458
+ name: config.forge,
459
+ registry: plugins,
460
+ env: opts.env,
461
+ repoRoot: state.checkout,
462
+ ...(opts.repository !== undefined ? { repository: opts.repository } : {}),
463
+ })
464
+ // Checkout-less provider seams: the sandbox provider derives its origin and
465
+ // remote branch heads from these instead of host `git` — every host-exec
466
+ // call site in the provider is bypassed in origin mode.
467
+ const providerSeams = originMode
468
+ ? {
469
+ origin: async (): Promise<string> => opts.repository!,
470
+ remoteBranchHead: async (branch: string): Promise<string | undefined> => {
471
+ const remoteBranchSha = forge.remoteBranchSha
472
+ if (remoteBranchSha === undefined) return undefined
473
+ try {
474
+ return await remoteBranchSha.call(forge, branch)
475
+ } catch (error) {
476
+ if (error instanceof GitHubApiError && error.status === 404) return undefined
477
+ throw error
478
+ }
479
+ },
480
+ }
481
+ : {}
482
+ const opened = openStoreForRepoState(state, { env: opts.env })
483
+
484
+ const tickets = await createTicketSource(
485
+ config.tickets,
486
+ opts.env,
487
+ state.repo,
488
+ opened.localStateRoot,
489
+ plugins,
490
+ )
491
+ const { runtimes } = createProductionRuntimes()
492
+ let runtimeReferences = effectiveRuntimeReferences(config)
493
+ // A local override relocates the whole tree. Remote stores still need local
494
+ // scratch beneath the repository default. Plugin factories receive only
495
+ // their explicit config plus repository/environment context.
496
+ const workspaceRuntime = await createWorkspaceRuntime(config.workspace, {
497
+ registry: plugins,
498
+ worktreeRoot: opened.worktreeRoot,
499
+ // Plugin factories always get the filesystem checkout (the physical path
500
+ // or the origin-mode scratch root) — never the store identity.
501
+ repoRoot: state.checkout,
502
+ env: opts.env,
503
+ storeRef: opened.storeRef,
504
+ runtimeReferences: () => runtimeReferences,
505
+ sandboxSetupCommand: config.commands.setup,
506
+ sandboxRoot: resolve(join(opened.localStateRoot, 'orchestrator-sandboxes')),
507
+ sandboxEnvironmentVariables: config.orchestrator.sandbox.environmentVariables,
508
+ ...providerSeams,
509
+ ...(opened.token !== undefined ? { storeToken: opened.token } : {}),
510
+ })
511
+
512
+ // Builds provisioned before this repository switched providers still hold
513
+ // local worktrees; the builtin can release them when they finish.
514
+ const retiredWorkspaces: WorkspaceRuntime[] =
515
+ config.workspace.provider === 'git-worktree'
516
+ ? []
517
+ : [
518
+ {
519
+ provider: new GitWorktreeProvider({ root: resolve(opened.worktreeRoot) }),
520
+ execution: new LocalBuildExecution({ env: opts.env }),
521
+ },
522
+ ]
523
+
524
+ return {
525
+ store: opened.store,
526
+ tickets,
527
+ forge,
528
+ workspaces: workspaceRuntime.provider,
529
+ retiredWorkspaces,
530
+ buildExecution: workspaceRuntime.execution,
531
+ // Shipped registrations are shared with other non-phase judgment paths.
532
+ // Model ids stay in config; production.ts owns adapter compatibility data.
533
+ runtimes,
534
+ storeRef: opened.storeRef,
535
+ ...(opened.token !== undefined ? { token: opened.token } : {}),
536
+ ids: randomIds(),
537
+ uuids: randomUuids(),
538
+ clock: systemClock,
539
+ plugins,
540
+ updateRuntimeReferences: (effectiveConfig) => {
541
+ runtimeReferences = effectiveRuntimeReferences(effectiveConfig)
542
+ },
543
+ }
544
+ }
545
+
546
+ interface ActiveBuildExecution {
547
+ reservation: symbol
548
+ instance: string
549
+ handle?: BuildExecutionHandle
550
+ settled?: Promise<void>
551
+ stopping: boolean
552
+ /** Set at teardown of an environment-supervised execution: the guest keeps
553
+ * running, so the completion chain must append no `execution.ended`, release
554
+ * no lease, settle no publication, and record no wait failure. */
555
+ detaching?: boolean
556
+ }
557
+
558
+ /** The dispatch loop owns deterministic decisions and supervises one
559
+ * workspace-adjacent process per build. Build outcomes flow only through the
560
+ * Store; child completion is liveness evidence used for reaping/single-flight. */
561
+ class DispatchLoop {
562
+ private readonly dispatcher: Dispatcher
563
+ private readonly host = hostname()
564
+ private readonly maxHarvestRecoveryAttempts = DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS
565
+ /** In-flight build and harvest runs (fire-and-forget) — awaited before a
566
+ * `--once` exit so every visible workflow reaches a durable boundary.
567
+ * Environment-supervised executions are tagged and deliberately NOT drained:
568
+ * their guests keep running and the next invocation settles them. */
569
+ private readonly inFlight = new Set<Promise<void>>()
570
+ private readonly inFlightKinds = new Map<Promise<void>, 'local-parent' | 'environment'>()
571
+ /** One active execution per slug in this kernel. Reservations are acquired
572
+ * before awaits; the durable lease remains the cross-kernel exclusion gate. */
573
+ private readonly activeBuildRuns = new Map<string, ActiveBuildExecution>()
574
+ private acceptingBuildLaunches = true
575
+ /** Process-local fast path; the repository lease is the cross-process gate. */
576
+ private harvestInFlight: Promise<void> | undefined
577
+ /** Live hosted-harvest execution: the environment-supervised handle whose
578
+ * completion chain classifies, releases, and journals the close fact.
579
+ * Teardown detaches it — the guest keeps running and the next invocation
580
+ * settles the execution from the Store plus provider liveness. The detach
581
+ * flag is a shared cell also closed over by the completion chain, so the
582
+ * chain still observes the detach after this field is cleared. */
583
+ private hostedHarvest:
584
+ | { execution: string; handle: BuildExecutionHandle; detached: { value: boolean } }
585
+ | undefined
586
+ /** Execution ids THIS process supervises; the dispatcher's harvest
587
+ * settlement stage skips them (their completion chain owns the facts). */
588
+ private readonly hostedHarvestExecutions = new Set<string>()
589
+ /** Outcomes settle outside Dispatcher.tick(), then merge into the next
590
+ * report publication (or a settlement-only publication during teardown). */
591
+ private pendingHarvest = {
592
+ harvestStarted: 0,
593
+ harvestResumed: 0,
594
+ harvestCompleted: 0,
595
+ harvestEscalated: 0,
596
+ harvestFailed: 0,
597
+ }
598
+ /** Settlement-only publications carry the latest standing status instead of
599
+ * manufacturing a zero queue measurement or losing diagnostics. */
600
+ private lastTickStatus = {
601
+ queued: 0,
602
+ janitorDiagnostics: [] as string[],
603
+ ticketDiagnostics: [] as string[],
604
+ creationDiagnostics: [] as string[],
605
+ dependencyDiagnostics: [] as string[],
606
+ }
607
+ /**
608
+ * Interactive dashboard on. `opts.terminal?.interactive === true` — an
609
+ * absent terminal yields `undefined === true` ⇒ false ⇒ today's exact
610
+ * behavior, which is what keeps every existing dispatch test passing
611
+ * untouched and makes plain the default rather than a mode.
612
+ */
613
+ private readonly dashboard: boolean
614
+ private readonly keyboard: KeyboardProtocol | undefined
615
+ private readonly region: LiveRegion | undefined
616
+ /** One display-only incremental build cache for this dispatch process. */
617
+ private readonly dashboardBuilds: DashboardBuildPollCache
618
+ /** Guard against overlapping timer polls, exactly as pollingSubscribe does. */
619
+ private rendering = false
620
+ /** The full, warning-handled timer poll. Teardown joins this exact promise
621
+ * before selecting the final frame. */
622
+ private renderInFlight: Promise<void> | undefined
623
+ /** Cleared at the rendering stop boundary so an already-queued timer callback
624
+ * cannot open a new store read after teardown begins. */
625
+ private acceptingRenderPolls = false
626
+ private timer: ReturnType<typeof setInterval> | undefined
627
+ /** Watch-mode paint timer (AC 8): repaints the CACHED model against a fresh
628
+ * clock so running elapsed ticks between store reads. Absent in `--once`. */
629
+ private tickTimer: ReturnType<typeof setInterval> | undefined
630
+ /** The last projected model, repainted by `paint()` against a moving clock.
631
+ * Read from the store by `renderOnce`; timing is now-independent so the same
632
+ * model ticks without a re-read. */
633
+ private model: DashboardModel | undefined
634
+ /** Ephemeral per-process presentation controls. Dispatcher settings are
635
+ * projected from the repository journal and never cached here. */
636
+ private selection: DashboardSelection | undefined = { kind: 'global' }
637
+ /** Read-only nested UI state. Omission is the top-level list. */
638
+ private view: DashboardView | undefined
639
+ /** The session view's live-read cycle, created with the view it serves and
640
+ * discarded on Escape. All read semantics live in the shared feed module. */
641
+ private sessionFeed: SessionStreamFeed | undefined
642
+ private warningLine: string | undefined
643
+ /** Process-local, persistent release notice. It never shares the replaceable
644
+ * warning slot and is re-applied to every store projection. */
645
+ private availableUpgrade: string | undefined
646
+ private stopUpgradeNotice: (() => void) | undefined
647
+ /** Startup, configuration-level notices — constant for the life of the
648
+ * process. Rendered ABOVE the transient warning line and never overwritten by
649
+ * it: `setWarning` replaces the transient slot outright, so sharing it would
650
+ * let the first tick's janitor notice erase a startup diagnostic for good. */
651
+ private configWarnings: readonly string[] = []
652
+ /** Last intake-enabled tick's standing queue depth, for the dashboard header. */
653
+ private queuedCount = 0
654
+ /** Last successfully measured unclaimed observation count. Sampling failures
655
+ * retain this factual value rather than inventing a zero. */
656
+ private observationCount = 0
657
+ /** A slug/id-bound blocked-resume field. The model receives only slug/value;
658
+ * captured escalation ids stay controller-private. */
659
+ private resumePrompt: ResumePrompt | undefined
660
+ private resumeSubmitting = false
661
+ /** First `a` captures identity only; Enter performs the shared control. */
662
+ private abortConfirmation: { slug: string } | undefined
663
+ /** One queue defines order between ticks and mutating keys. */
664
+ private operationTail: Promise<void> = Promise.resolve()
665
+ private acceptingKeys = false
666
+ private cleanupInput: (() => void) | undefined
667
+ /** Raw Ctrl-C does not raise SIGINT; this wakes the same watch loop. */
668
+ private readonly inputStop = new AbortController()
669
+ /** Repository supervisor lease (§12 serialization): this invocation's holder
670
+ * id while it holds the repo lease, else undefined. A losing invocation
671
+ * records `dispatcher.tick-yielded` and performs no claims, launches, or
672
+ * publications. */
673
+ private repoLeaseHolder: string | undefined
674
+ private repoLeaseHeartbeat: ReturnType<typeof setInterval> | undefined
675
+ /** Set when the store reports this supervisor's repo lease was taken over:
676
+ * stop accepting launches, detach/reap, signal continuations, and exit watch
677
+ * mode cleanly. */
678
+ private superseded = false
679
+ /** Public teardown fact for the durable `dispatcher.run-stopped` reason. */
680
+ get supersededByPeer(): boolean {
681
+ return this.superseded
682
+ }
683
+
684
+ /** Invocation bound (hosted cron): true once `deadlineAt` has passed. An
685
+ * absent deadline is never past, so every existing caller is unchanged. */
686
+ private pastDeadline(): boolean {
687
+ return (
688
+ this.opts.deadlineAt !== undefined && this.wiring.clock().getTime() >= this.opts.deadlineAt
689
+ )
690
+ }
691
+ /** The holder id this invocation already recorded a `tick-yielded` for. */
692
+ private yieldedTo: string | undefined
693
+ /** The invocation's durable journaling identity: every repository-lease
694
+ * claim and every `dispatcher.*` journal payload carries exactly this
695
+ * string. Computed once in the constructor (see above). */
696
+ private readonly runId: string
697
+ /** Repository identity (§12): the normalized origin, or the checkout path
698
+ * when there is no origin. Every Store-keyed key uses this; filesystem
699
+ * consumers keep using `opts.targetRepo` (the checkout or scratch root). */
700
+ private get repoIdentity(): string {
701
+ return this.opts.repo ?? this.opts.targetRepo
702
+ }
703
+
704
+ constructor(
705
+ private readonly liveConfig: LiveConfig,
706
+ private readonly wiring: DispatchWiring,
707
+ private readonly opts: DispatchOpts,
708
+ ) {
709
+ const config = liveConfig.current().config
710
+ // Minted exactly once per invocation: `wiring.ids` is `randomIds()` and a
711
+ // fresh id per access would split the journal run from the lease holder.
712
+ // abDispatch always passes `runId`, so the fallback only covers direct
713
+ // DispatchLoop constructions (tests).
714
+ this.runId = opts.runId ?? opts.kernelRunId ?? `${this.host}-dispatch-${wiring.ids('inst')}`
715
+ this.dashboard = opts.terminal?.interactive === true && opts.plain !== true
716
+ this.keyboard =
717
+ this.dashboard && opts.terminal !== undefined && opts.input !== undefined
718
+ ? createKeyboardProtocol((chunk) => opts.terminal!.write(chunk), opts.terminal.modes)
719
+ : undefined
720
+ this.region =
721
+ this.dashboard && opts.terminal !== undefined
722
+ ? new LiveRegion(opts.terminal, this.keyboard)
723
+ : undefined
724
+ this.dashboardBuilds = new DashboardBuildPollCache(wiring.store, this.repoIdentity, config)
725
+
726
+ // `slug` is an internal pre-build role on the same runtime/model resolver. A
727
+ // runtime without the optional capability is normal: omit the seam and let
728
+ // the dispatcher take its deterministic title fallback.
729
+ const nameSlug = async (spec: string, signal: AbortSignal): Promise<string | null> => {
730
+ const resolvedSlug = this.liveConfig.current().resolver.resolve(SLUG_ROLE)
731
+ const oneShot = wiring.runtimes[resolvedSlug.runtime]?.oneShot
732
+ if (oneShot === undefined) return null
733
+ const result = await oneShot.complete({
734
+ prompt: slugNamingPrompt(spec),
735
+ cwd: opts.targetRepo,
736
+ env: definedEnv(opts.env),
737
+ signal,
738
+ ...(resolvedSlug.model !== undefined ? { model: resolvedSlug.model } : {}),
739
+ args: resolvedSlug.args,
740
+ })
741
+ return result.text
742
+ }
743
+
744
+ this.dispatcher = new Dispatcher({
745
+ store: wiring.store,
746
+ tickets: wiring.tickets,
747
+ workspaces: wiring.workspaces,
748
+ ...(wiring.retiredWorkspaces === undefined
749
+ ? {}
750
+ : { retiredWorkspaces: wiring.retiredWorkspaces.map((runtime) => runtime.provider) }),
751
+ forge: wiring.forge,
752
+ config,
753
+ getConfig: () => this.liveConfig.current().config,
754
+ repo: this.repoIdentity,
755
+ // Origin mode: the forge answers remote base-branch questions; no
756
+ // checkout git is probed for the served repository's origin.
757
+ ...(opts.repository !== undefined
758
+ ? { repoOrigin: normalizeGitRemoteUrl(opts.repository) }
759
+ : {}),
760
+ checkout: opts.targetRepo,
761
+ exec: opts.exec,
762
+ launchRunner: (slug) => this.launchRunner(slug),
763
+ startHarvest: () => this.launchHarvest(),
764
+ nameSlug,
765
+ ids: wiring.ids,
766
+ clock: wiring.clock,
767
+ activeHarvestExecutions: () => this.hostedHarvestExecutions,
768
+ // Durable supervision: settle publication from the log-backed guard and
769
+ // skip foreign-execution settlement for builds this process supervises.
770
+ settlePublication: (slug) => this.settlePendingPublication(slug),
771
+ activeExecutions: () => new Set(this.activeBuildRuns.keys()),
772
+ opts: {
773
+ maxHarvestRecoveryAttempts: this.maxHarvestRecoveryAttempts,
774
+ },
775
+ })
776
+ }
777
+
778
+ private currentConfig(): ConfigSnapshot {
779
+ return this.liveConfig.current()
780
+ }
781
+
782
+ /** Publish the exact composed snapshot into one build-owned namespace. The
783
+ * child samples only this artifact and never receives config over IPC. */
784
+ private async publishBuildConfig(slug: string, snapshot = this.currentConfig()): Promise<void> {
785
+ await this.wiring.store.putArtifact(slug, {
786
+ kind: BUILD_EFFECTIVE_CONFIG_ARTIFACT,
787
+ content: effectiveBuildConfigContent(snapshot.config),
788
+ metadata: {
789
+ revision: snapshot.revision,
790
+ run: this.runId,
791
+ },
792
+ })
793
+ }
794
+
795
+ private async publishActiveBuildConfigs(snapshot: ConfigSnapshot): Promise<void> {
796
+ await Promise.all(
797
+ [...this.activeBuildRuns.keys()].map((slug) => this.publishBuildConfig(slug, snapshot)),
798
+ )
799
+ }
800
+
801
+ private async appendStatus(event: RepositoryEventWrite): Promise<void> {
802
+ await this.wiring.store.appendRepo(this.repoIdentity, event)
803
+ }
804
+
805
+ private async refreshConfig(): Promise<void> {
806
+ if (this.opts.once === true) return
807
+ // Origin mode reloads from the forge: autobuild.toml at the CURRENT
808
+ // effective base branch (hot — a rename converges within two ticks).
809
+ // Restart-required changes keep startup-built adapters and file the
810
+ // existing restart-required observation (LiveConfig semantics).
811
+ const outcome =
812
+ this.opts.repository !== undefined && typeof this.wiring.forge.readFile === 'function'
813
+ ? await this.liveConfig.refreshFrom(async () => {
814
+ const baseBranch = this.liveConfig.current().config.baseBranch
815
+ return await this.wiring.forge.readFile!(
816
+ 'autobuild.toml',
817
+ baseBranch !== undefined && baseBranch !== '' ? baseBranch : undefined,
818
+ )
819
+ })
820
+ : await this.liveConfig.refreshFromDisk()
821
+ if (outcome.kind === 'unchanged') return
822
+ if (outcome.kind === 'rejected') {
823
+ if (outcome.notify) {
824
+ await this.appendStatus({
825
+ actor: DISPATCHER,
826
+ type: 'dispatcher.config-rejected',
827
+ payload: { run: this.runId, error: outcome.error },
828
+ })
829
+ this.warn(`config reload rejected: ${outcome.error}`)
830
+ }
831
+ return
832
+ }
833
+ if (outcome.kind === 'publication-failed') {
834
+ await this.appendStatus({
835
+ actor: DISPATCHER,
836
+ type: 'dispatcher.config-publication-failed',
837
+ payload: { run: this.runId, error: outcome.error },
838
+ })
839
+ this.warn(`config reload not applied because its durable trace failed: ${outcome.error}`)
840
+ return
841
+ }
842
+
843
+ await this.publishActiveBuildConfigs(outcome.snapshot)
844
+ this.announce(`autobuild.toml reloaded (revision ${outcome.snapshot.revision})`)
845
+ if (outcome.restartRequired.length > 0) {
846
+ this.warn(
847
+ `autobuild.toml reload requires dispatch restart for: ${outcome.restartRequired.join(', ')}`,
848
+ )
849
+ }
850
+ this.reportRoleDiagnostics()
851
+ }
852
+
853
+ /** Append one operation after every previously observed tick/key action. */
854
+ private serialize<T>(operation: () => Promise<T> | T): Promise<T> {
855
+ const result = this.operationTail.then(operation)
856
+ this.operationTail = result.then(
857
+ () => undefined,
858
+ () => undefined,
859
+ )
860
+ return result
861
+ }
862
+
863
+ private async readDispatchSettings(): Promise<ReturnType<typeof reduceDispatchSettings>> {
864
+ const events = await this.wiring.store.getRepoEvents(this.repoIdentity)
865
+ return reduceDispatchSettings(events)
866
+ }
867
+
868
+ private dispatcherTick(resumeCurrent: boolean): Promise<Awaited<ReturnType<Dispatcher['tick']>>> {
869
+ return this.serialize(async () => {
870
+ // Refresh before every watch decision. The owner publishes atomically;
871
+ // everything below captures the resulting one snapshot for this tick.
872
+ await this.refreshConfig()
873
+ // Every tick first tries the repository supervisor lease. Without it,
874
+ // this invocation records the yield and performs no claims, launches,
875
+ // or publications — including no tick-completed status publication.
876
+ if (!(await this.ensureRepoLease(this.repoLeaseHolder ?? this.runId))) {
877
+ await this.recordTickYielded()
878
+ return emptyTickReport()
879
+ }
880
+ await this.appendStatus({
881
+ actor: DISPATCHER,
882
+ type: 'dispatcher.tick-started',
883
+ payload: { run: this.runId },
884
+ })
885
+
886
+ // Unclaimed observations are display-only and sampled once per interactive
887
+ // dispatcher tick. A failed scan must neither fail dispatch nor replace
888
+ // the last complete measurement with a fabricated zero.
889
+ if (this.dashboard) {
890
+ try {
891
+ const scan = await scanUnclaimedObservations(this.wiring.store, this.repoIdentity)
892
+ this.observationCount = scan.observations.length
893
+ } catch {
894
+ // Display-only sampling failures retain the last factual count and
895
+ // retry on the next tick; they are not dashboard failures.
896
+ }
897
+ }
898
+
899
+ // Sample inside the serialized tick, not at process startup. Every
900
+ // dispatcher therefore gates claims from the latest repository facts.
901
+ const settings = await this.readDispatchSettings()
902
+ const readyObservation =
903
+ settings.intake || (!this.dashboard && this.opts.kernelRunId === undefined)
904
+ ? undefined
905
+ : await this.dispatcher.observeReady()
906
+ const report = await this.dispatcher.tick({
907
+ resumeCurrent,
908
+ acceptNewWork: settings.intake,
909
+ defaultAutoMerge: settings.defaultAutoMerge,
910
+ autoMergeUser: buildControlUser(this.opts.env),
911
+ // Invocation bound forwarded into the tick: per-item loops inside the
912
+ // dispatcher stop starting new forge/store transport calls once the
913
+ // remaining budget cannot cover them (bounded overrun, one in-flight
914
+ // transport call). Watch mode has no deadline ⇒ absent ⇒ unbounded.
915
+ ...(this.opts.deadlineAt !== undefined ? { deadlineAt: this.opts.deadlineAt } : {}),
916
+ })
917
+ const publishedReport: TickReport = {
918
+ ...report,
919
+ queued: readyObservation?.queued ?? report.queued,
920
+ invalidTickets: readyObservation?.invalidTickets ?? report.invalidTickets,
921
+ ticketDiagnostics: readyObservation?.ticketDiagnostics ?? report.ticketDiagnostics,
922
+ }
923
+ this.queuedCount = publishedReport.queued
924
+ this.lastTickStatus = {
925
+ queued: publishedReport.queued,
926
+ janitorDiagnostics: [...publishedReport.janitorDiagnostics],
927
+ ticketDiagnostics: [...publishedReport.ticketDiagnostics],
928
+ creationDiagnostics: [...publishedReport.creationDiagnostics],
929
+ dependencyDiagnostics: [...publishedReport.dependencyDiagnostics],
930
+ }
931
+ return (await this.publishTickReport(publishedReport))!
932
+ })
933
+ }
934
+
935
+ /** Overlay process-local presentation controls and normalize the exact model
936
+ * the renderer will receive. Detail bounds depend on modal control height, so
937
+ * composition must precede clamping rather than measuring the durable base. */
938
+ private syncModelControls(): void {
939
+ if (this.model === undefined) return
940
+ const {
941
+ selection: _oldSelection,
942
+ warningLines: _oldWarningLines,
943
+ availableUpgrade: _oldAvailableUpgrade,
944
+ resumeInput: _oldResumeInput,
945
+ abortConfirmation: _oldAbortConfirmation,
946
+ view: _oldView,
947
+ ...base
948
+ } = this.model
949
+ // Composed at PROJECTION time, on every projection, so the sticky config
950
+ // diagnostic reaches the first painted frame and every frame after it with
951
+ // no dependence on emission order relative to `startRendering()`.
952
+ const warningLines = [
953
+ ...this.configWarnings,
954
+ ...(this.warningLine !== undefined ? [this.warningLine] : []),
955
+ ]
956
+ let effective: DashboardModel = {
957
+ ...base,
958
+ ...(this.availableUpgrade !== undefined ? { availableUpgrade: this.availableUpgrade } : {}),
959
+ ...(warningLines.length > 0 ? { warningLines } : {}),
960
+ ...(this.selection !== undefined ? { selection: this.selection } : {}),
961
+ ...(this.resumePrompt !== undefined
962
+ ? {
963
+ resumeInput: {
964
+ slug: this.resumePrompt.slug,
965
+ value: this.resumePrompt.value,
966
+ cursor: this.resumePrompt.cursor,
967
+ },
968
+ }
969
+ : {}),
970
+ ...(this.abortConfirmation !== undefined
971
+ ? { abortConfirmation: this.abortConfirmation }
972
+ : {}),
973
+ ...(this.view !== undefined ? { view: this.view } : {}),
974
+ }
975
+ if (this.view?.kind === 'detail') {
976
+ const terminal = this.opts.terminal
977
+ const normalized = {
978
+ ...this.view,
979
+ scroll:
980
+ terminal === undefined
981
+ ? 0
982
+ : Math.max(
983
+ 0,
984
+ Math.min(
985
+ this.view.scroll,
986
+ detailScrollLimit(
987
+ effective,
988
+ dashboardContentWidth(terminal.columns),
989
+ paintableRows(terminal.rows),
990
+ ),
991
+ ),
992
+ ),
993
+ }
994
+ this.view = normalized
995
+ effective = { ...effective, view: normalized }
996
+ }
997
+ this.model = effective
998
+ }
999
+
1000
+ /** Session-view Up/Down: follow-and-pause. Scrolling up off the bottom
1001
+ * pauses tail-following; landing back on the last content row resumes it.
1002
+ * While following, the render pins the offset to the limit, so the effective
1003
+ * current position before a move is that limit. */
1004
+ private moveSessionView(delta: number): void {
1005
+ const view = this.view
1006
+ if (view?.kind !== 'session') return
1007
+ const terminal = this.opts.terminal
1008
+ const upgrade = this.availableUpgrade !== undefined
1009
+ const limit =
1010
+ terminal === undefined
1011
+ ? 0
1012
+ : sessionScrollLimit(view, terminal.columns, paintableRows(terminal.rows), upgrade)
1013
+ const current = view.follow ? limit : view.scroll
1014
+ const scroll =
1015
+ terminal === undefined
1016
+ ? 0
1017
+ : moveSessionScroll(
1018
+ view,
1019
+ terminal.columns,
1020
+ paintableRows(terminal.rows),
1021
+ current,
1022
+ delta,
1023
+ upgrade,
1024
+ )
1025
+ let follow = view.follow
1026
+ if (delta < 0 && follow && scroll < limit) follow = false
1027
+ if (delta > 0 && !follow && scroll >= limit) follow = true
1028
+ this.view = { ...view, scroll, follow }
1029
+ this.syncModelControls()
1030
+ this.paint()
1031
+ }
1032
+
1033
+ /** Clamp a session view's offset against the current viewport and pin it to
1034
+ * the tail while following. Called at feed-apply time, where new content may
1035
+ * have grown (or shrunk) the scrollable range. */
1036
+ private clampSessionView(view: DashboardView): DashboardView {
1037
+ if (view.kind !== 'session') return view
1038
+ const terminal = this.opts.terminal
1039
+ if (terminal === undefined) return { ...view, scroll: 0 }
1040
+ const limit = sessionScrollLimit(
1041
+ view,
1042
+ terminal.columns,
1043
+ paintableRows(terminal.rows),
1044
+ this.availableUpgrade !== undefined,
1045
+ )
1046
+ return { ...view, scroll: view.follow ? limit : Math.min(view.scroll, limit) }
1047
+ }
1048
+
1049
+ /** Fire-and-forget live-read cycle for an open session view (or one whose
1050
+ * last read failed). The apply goes through the serialize queue under the
1051
+ * feed's identity+cursor fence, mirroring the transcript read's pattern. */
1052
+ private pollSessionFeed(): void {
1053
+ const view = this.view
1054
+ if (view?.kind !== 'session') return
1055
+ if (view.status !== 'open' && view.error === undefined) {
1056
+ // A closed view is final once its content is in hand; an unloaded one
1057
+ // (a closed session just opened, empty chunk log so far) still needs
1058
+ // its read — which is where the artifact fallback happens too.
1059
+ if (view.source.kind !== 'parts' || view.source.lastSeq !== 0) return
1060
+ }
1061
+ const feed = this.sessionFeed
1062
+ if (feed === undefined) return
1063
+ void feed
1064
+ .poll(view)
1065
+ .then((update) => {
1066
+ if (update === undefined) return
1067
+ return this.serialize(() => {
1068
+ // The fence rejects anything but the exact session view (and
1069
+ // cursor) this update was polled from.
1070
+ const next = applySessionFeedUpdate(
1071
+ this.view?.kind === 'session' ? this.view : undefined,
1072
+ update,
1073
+ )
1074
+ if (next === undefined) return
1075
+ this.view = this.clampSessionView(next)
1076
+ this.syncModelControls()
1077
+ this.paint()
1078
+ })
1079
+ })
1080
+ .catch(() => {
1081
+ // The feed contains its own failures; the next poll retries.
1082
+ })
1083
+ }
1084
+
1085
+ private moveSelection(delta: number): void {
1086
+ if (this.view?.kind === 'session') {
1087
+ this.moveSessionView(delta)
1088
+ return
1089
+ }
1090
+ if (this.view?.kind === 'transcript') {
1091
+ const terminal = this.opts.terminal
1092
+ this.view = {
1093
+ ...this.view,
1094
+ scroll:
1095
+ terminal === undefined
1096
+ ? 0
1097
+ : moveTranscriptScroll(
1098
+ this.view.transcript,
1099
+ terminal.columns,
1100
+ paintableRows(terminal.rows),
1101
+ this.view.scroll,
1102
+ delta,
1103
+ this.availableUpgrade !== undefined,
1104
+ ),
1105
+ }
1106
+ this.syncModelControls()
1107
+ this.paint()
1108
+ return
1109
+ }
1110
+ if (this.view?.kind === 'detail') {
1111
+ const terminal = this.opts.terminal
1112
+ this.view = {
1113
+ ...this.view,
1114
+ scroll:
1115
+ terminal === undefined || this.model === undefined
1116
+ ? 0
1117
+ : moveDetailScroll(
1118
+ this.model,
1119
+ dashboardContentWidth(terminal.columns),
1120
+ paintableRows(terminal.rows),
1121
+ this.view.scroll,
1122
+ delta,
1123
+ ),
1124
+ }
1125
+ this.syncModelControls()
1126
+ this.paint()
1127
+ return
1128
+ }
1129
+
1130
+ // Input starts before the first asynchronous store projection. The global
1131
+ // row exists independently of that projection, so startup navigation must
1132
+ // clamp on it rather than letting the generic empty-list helper clear it.
1133
+ const rows =
1134
+ this.model === undefined ? [{ kind: 'global' } as const] : dashboardSelections(this.model)
1135
+ this.selection = moveSelection(rows, this.selection, delta)
1136
+ this.syncModelControls()
1137
+ this.paint()
1138
+ }
1139
+
1140
+ private selectedBuildSlug(
1141
+ action: 'auto-merge' | 'pause' | 'resume' | 'discard',
1142
+ ): string | undefined {
1143
+ if (this.view !== undefined) return this.view.slug
1144
+ const selection = this.selection
1145
+ if (selection === undefined) {
1146
+ this.warn('dashboard action ignored: no active row is selected')
1147
+ return undefined
1148
+ }
1149
+ if (selection.kind !== 'build') {
1150
+ const subject = selection.kind === 'harvest' ? 'Harvest' : 'Dispatcher'
1151
+ this.say(
1152
+ action === 'auto-merge'
1153
+ ? `${subject} auto-merge unavailable: select a build`
1154
+ : action === 'discard'
1155
+ ? `${subject} discard unavailable: select a queued build`
1156
+ : `${subject} ${action} unavailable: select a build`,
1157
+ )
1158
+ return undefined
1159
+ }
1160
+ return selection.slug
1161
+ }
1162
+
1163
+ private async ignoreControlError(surface: 'action' | 'resume', error: unknown): Promise<boolean> {
1164
+ if (!(error instanceof BuildControlError)) return false
1165
+ this.warn(`dashboard ${surface} ignored: ${error.message}`)
1166
+ await this.renderOnce()
1167
+ return true
1168
+ }
1169
+
1170
+ private async toggleIntake(): Promise<void> {
1171
+ if (this.selection?.kind !== 'global') return
1172
+
1173
+ const event = await toggleRepositorySetting({
1174
+ store: this.wiring.store,
1175
+ repo: this.repoIdentity,
1176
+ user: buildControlUser(this.opts.env),
1177
+ setting: 'intake',
1178
+ })
1179
+ this.say(`dispatcher intake ${event.enabled ? 'ON' : 'OFF'}`)
1180
+ await this.renderOnce()
1181
+ }
1182
+
1183
+ /** Repository-wide quiescence from the always-present global row: park every
1184
+ * pausable build and stop intake, or reverse both. Unlike the per-build keys
1185
+ * this never toggles — it is an absolute request in one direction, so a build
1186
+ * already pausing keeps its single pending pause. A store failure propagates
1187
+ * to `queueAction`'s catch, which reports it on the same notice row. */
1188
+ private async bulkControl(direction: BulkDirection): Promise<void> {
1189
+ // The key routing already guards; keeping it with the action means the
1190
+ // invariant travels with the write rather than only with the keypress.
1191
+ if (this.view !== undefined || this.selection?.kind !== 'global') return
1192
+
1193
+ const summary = await bulkControlRepository({
1194
+ store: this.wiring.store,
1195
+ repo: this.repoIdentity,
1196
+ env: this.opts.env,
1197
+ direction,
1198
+ })
1199
+ this.announce(bulkControlReport(summary))
1200
+ await this.renderOnce()
1201
+ }
1202
+
1203
+ private selectedDashboardBuild(): DashboardBuild | undefined {
1204
+ const slug =
1205
+ this.view?.kind === 'detail'
1206
+ ? this.view.slug
1207
+ : this.view === undefined && this.selection?.kind === 'build'
1208
+ ? this.selection.slug
1209
+ : undefined
1210
+ return this.model?.builds.find((candidate) => candidate.slug === slug)
1211
+ }
1212
+
1213
+ private async dashboardPause(): Promise<void> {
1214
+ const slug = this.selectedBuildSlug('pause')
1215
+ if (slug === undefined) return
1216
+
1217
+ let result: BuildControlResult
1218
+ try {
1219
+ result = await controlBuild({
1220
+ store: this.wiring.store,
1221
+ repo: this.repoIdentity,
1222
+ slug,
1223
+ env: this.opts.env,
1224
+ action: { kind: 'dashboard-pause' },
1225
+ })
1226
+ } catch (error) {
1227
+ if (await this.ignoreControlError('action', error)) return
1228
+ throw error
1229
+ }
1230
+
1231
+ if (result.kind !== 'command' || (result.command !== 'pause' && result.command !== 'resume')) {
1232
+ throw new Error('build-control returned an invalid dashboard pause result')
1233
+ }
1234
+ this.say(
1235
+ result.command === 'resume'
1236
+ ? `build ${slug}: pending pause cancelled`
1237
+ : `build ${slug}: pause requested`,
1238
+ )
1239
+ await this.renderOnce()
1240
+ }
1241
+
1242
+ private async dashboardResume(): Promise<void> {
1243
+ const slug = this.selectedBuildSlug('resume')
1244
+ if (slug === undefined) return
1245
+
1246
+ let result: BuildControlResult
1247
+ try {
1248
+ result = await controlBuild({
1249
+ store: this.wiring.store,
1250
+ repo: this.repoIdentity,
1251
+ slug,
1252
+ env: this.opts.env,
1253
+ action: { kind: 'dashboard-resume' },
1254
+ })
1255
+ } catch (error) {
1256
+ if (await this.ignoreControlError('action', error)) return
1257
+ throw error
1258
+ }
1259
+
1260
+ if (result.kind === 'answer-required') {
1261
+ this.resumePrompt = {
1262
+ slug,
1263
+ escalationIds: result.escalationIds,
1264
+ value: '',
1265
+ cursor: 0,
1266
+ }
1267
+ this.syncModelControls()
1268
+ this.paint()
1269
+ return
1270
+ }
1271
+ if (result.kind !== 'command' || result.command !== 'resume') {
1272
+ throw new Error('build-control returned an invalid dashboard resume result')
1273
+ }
1274
+ this.say(`build ${slug}: resume requested`)
1275
+ await this.renderOnce()
1276
+ }
1277
+
1278
+ private async abortConfirmed(): Promise<void> {
1279
+ const confirmation = this.abortConfirmation
1280
+ if (confirmation === undefined) return
1281
+ this.abortConfirmation = undefined
1282
+ this.syncModelControls()
1283
+ try {
1284
+ const result = await controlBuild({
1285
+ store: this.wiring.store,
1286
+ repo: this.repoIdentity,
1287
+ slug: confirmation.slug,
1288
+ env: this.opts.env,
1289
+ action: { kind: 'abort' },
1290
+ })
1291
+ if (result.kind !== 'command' || result.command !== 'abort') {
1292
+ throw new Error('build-control returned an invalid abort result')
1293
+ }
1294
+ this.say(`build ${confirmation.slug}: abort requested`)
1295
+ await this.renderOnce()
1296
+ } catch (error) {
1297
+ if (await this.ignoreControlError('action', error)) return
1298
+ throw error
1299
+ }
1300
+ }
1301
+
1302
+ private async discardSelected(): Promise<void> {
1303
+ const slug = this.selectedBuildSlug('discard')
1304
+ if (slug === undefined) return
1305
+ try {
1306
+ const result = await controlBuild({
1307
+ store: this.wiring.store,
1308
+ repo: this.repoIdentity,
1309
+ slug,
1310
+ env: this.opts.env,
1311
+ action: { kind: 'discard' },
1312
+ })
1313
+ if (result.kind !== 'command' || result.command !== 'discard') {
1314
+ throw new Error('build-control returned an invalid discard result')
1315
+ }
1316
+ this.say(`build ${slug}: discard requested`)
1317
+ await this.renderOnce()
1318
+ } catch (error) {
1319
+ if (await this.ignoreControlError('action', error)) return
1320
+ throw error
1321
+ }
1322
+ }
1323
+
1324
+ /** Toggle the durable repository gate from the always-present header. The
1325
+ * latest pending command is the effective requested target, while rendering
1326
+ * remains acknowledged-only. */
1327
+ private async toggleHarvestGate(): Promise<void> {
1328
+ if (this.selection?.kind !== 'global') {
1329
+ const subject = this.selection?.kind === 'harvest' ? 'Harvest' : 'Build'
1330
+ this.say(`${subject} harvest gate unavailable: select Dispatcher`)
1331
+ return
1332
+ }
1333
+
1334
+ const result = await applyHarvestGateToggle({
1335
+ store: this.wiring.store,
1336
+ repo: this.repoIdentity,
1337
+ user: buildControlUser(this.opts.env),
1338
+ })
1339
+ this.say(`harvest gate: ${result.command} requested`)
1340
+ await this.renderOnce()
1341
+ }
1342
+
1343
+ /** `p` on Harvest acts only on the concrete run captured at keypress time.
1344
+ * It never toggles the repository gate and never retargets a replacement run
1345
+ * that appeared while the action waited in the serialized queue. */
1346
+ private async controlHarvestRun(expectedRun: string | undefined): Promise<void> {
1347
+ const { store } = this.wiring
1348
+ const repo = this.repoIdentity
1349
+ await store.ensureRepo(repo)
1350
+ const events = await store.getRepoEvents(repo)
1351
+ const state = reduceHarvest(events)
1352
+ const projected = projectHarvest(events)
1353
+ if (expectedRun === undefined || projected === undefined || projected.run !== expectedRun) {
1354
+ this.say('harvest run action ignored: selected run is no longer active')
1355
+ await this.renderOnce()
1356
+ return
1357
+ }
1358
+
1359
+ if (state.paused) {
1360
+ this.say('harvest run action unavailable while harvest is OFF; select Dispatcher and press h')
1361
+ await this.renderOnce()
1362
+ return
1363
+ }
1364
+ if (state.pendingCommands.some((command) => command.command === 'resume')) {
1365
+ this.say('harvest run: resume acknowledgement pending')
1366
+ await this.renderOnce()
1367
+ return
1368
+ }
1369
+ if (projected.action === undefined) {
1370
+ this.say('harvest run has no available action')
1371
+ await this.renderOnce()
1372
+ return
1373
+ }
1374
+
1375
+ await applyHarvestRunControl({
1376
+ store,
1377
+ repo,
1378
+ user: buildControlUser(this.opts.env),
1379
+ run: projected.run,
1380
+ })
1381
+ const selectedRun = state.runs.find((run) => run.run === projected.run)
1382
+ this.say(
1383
+ projected.action === 'resume'
1384
+ ? 'harvest: error resume requested'
1385
+ : selectedRun?.recoveryExhaustion !== undefined
1386
+ ? 'harvest: exhausted recovery attention acknowledgement requested'
1387
+ : 'harvest: escalation acknowledgement requested',
1388
+ )
1389
+ await this.renderOnce()
1390
+ }
1391
+
1392
+ private clearResumePrompt(slug: string): void {
1393
+ if (this.resumePrompt?.slug !== slug) return
1394
+ this.resumePrompt = undefined
1395
+ this.syncModelControls()
1396
+ this.paint()
1397
+ }
1398
+
1399
+ /** Submit the prompt through the shared build-control service. Empty input
1400
+ * is a retry; nonempty input is authoritative guidance. */
1401
+ private async submitResume(prompt: ResumePrompt): Promise<void> {
1402
+ let result: BuildControlResult
1403
+ try {
1404
+ result = await controlBuild({
1405
+ store: this.wiring.store,
1406
+ repo: this.repoIdentity,
1407
+ slug: prompt.slug,
1408
+ env: this.opts.env,
1409
+ action: {
1410
+ kind: 'answer',
1411
+ text: prompt.value,
1412
+ escalationIds: prompt.escalationIds,
1413
+ },
1414
+ })
1415
+ } catch (error) {
1416
+ if (error instanceof BuildControlError) {
1417
+ this.clearResumePrompt(prompt.slug)
1418
+ }
1419
+ if (await this.ignoreControlError('resume', error)) return
1420
+ throw error
1421
+ }
1422
+ if (result.kind !== 'answered') {
1423
+ throw new Error('build-control returned an invalid answer result')
1424
+ }
1425
+
1426
+ this.clearResumePrompt(prompt.slug)
1427
+ this.say(
1428
+ `build ${prompt.slug}: blocked resume requested${
1429
+ result.resolution === 'guidance' ? ' with guidance' : ' without feedback'
1430
+ }`,
1431
+ )
1432
+ await this.renderOnce()
1433
+ }
1434
+
1435
+ private async toggleAutoMerge(): Promise<void> {
1436
+ if (this.view === undefined && this.selection?.kind === 'global') {
1437
+ const event = await toggleRepositorySetting({
1438
+ store: this.wiring.store,
1439
+ repo: this.repoIdentity,
1440
+ user: buildControlUser(this.opts.env),
1441
+ setting: 'auto-merge-default',
1442
+ })
1443
+ this.say(`dispatcher auto-merge default ${event.enabled ? 'ON' : 'OFF'}`)
1444
+ await this.renderOnce()
1445
+ return
1446
+ }
1447
+ const slug = this.selectedBuildSlug('auto-merge')
1448
+ if (slug === undefined) return
1449
+
1450
+ let result: BuildControlResult
1451
+ try {
1452
+ result = await controlBuild({
1453
+ store: this.wiring.store,
1454
+ repo: this.repoIdentity,
1455
+ slug,
1456
+ env: this.opts.env,
1457
+ action: { kind: 'toggle-auto-merge' },
1458
+ })
1459
+ } catch (error) {
1460
+ if (await this.ignoreControlError('action', error)) return
1461
+ throw error
1462
+ }
1463
+ if (
1464
+ result.kind !== 'command' ||
1465
+ (result.command !== 'auto-merge-on' && result.command !== 'auto-merge-off')
1466
+ ) {
1467
+ throw new Error('build-control returned an invalid auto-merge toggle result')
1468
+ }
1469
+ this.say(
1470
+ `build ${slug}: auto-merge ${
1471
+ result.command === 'auto-merge-off' ? 'cancelled' : 'requested'
1472
+ }`,
1473
+ )
1474
+ await this.renderOnce()
1475
+ }
1476
+
1477
+ private moveDetailSession(delta: number): void {
1478
+ if (this.view?.kind !== 'detail') return
1479
+ const build = this.model?.builds.find((candidate) => candidate.slug === this.view!.slug)
1480
+ const sessions = build?.sessions ?? []
1481
+ if (sessions.length === 0) return
1482
+ const current = sessions.findIndex((session) => session.id === this.view!.sessionId)
1483
+ const index = Math.max(0, Math.min(sessions.length - 1, (current < 0 ? 0 : current) + delta))
1484
+ const next = { ...this.view, sessionId: sessions[index]!.id }
1485
+ const terminal = this.opts.terminal
1486
+ const nextModel = this.model === undefined ? undefined : { ...this.model, view: next }
1487
+ this.view = {
1488
+ ...next,
1489
+ scroll:
1490
+ terminal === undefined || nextModel === undefined
1491
+ ? 0
1492
+ : revealDetailFocus(
1493
+ nextModel,
1494
+ dashboardContentWidth(terminal.columns),
1495
+ paintableRows(terminal.rows),
1496
+ 'session',
1497
+ next.scroll,
1498
+ ),
1499
+ }
1500
+ this.syncModelControls()
1501
+ this.paint()
1502
+ }
1503
+
1504
+ private detailMessage(
1505
+ view: Extract<DashboardView, { kind: 'detail' }>,
1506
+ message: string,
1507
+ messageWhileSessionOpen?: string,
1508
+ ): Extract<DashboardView, { kind: 'detail' }> {
1509
+ const { message: _priorMessage, messageWhileSessionOpen: _priorFence, ...stable } = view
1510
+ const next = {
1511
+ ...stable,
1512
+ message,
1513
+ ...(messageWhileSessionOpen !== undefined ? { messageWhileSessionOpen } : {}),
1514
+ }
1515
+ const terminal = this.opts.terminal
1516
+ const nextModel = this.model === undefined ? undefined : { ...this.model, view: next }
1517
+ return {
1518
+ ...next,
1519
+ scroll:
1520
+ terminal === undefined || nextModel === undefined
1521
+ ? 0
1522
+ : revealDetailFocus(
1523
+ nextModel,
1524
+ dashboardContentWidth(terminal.columns),
1525
+ paintableRows(terminal.rows),
1526
+ 'message',
1527
+ next.scroll,
1528
+ ),
1529
+ }
1530
+ }
1531
+
1532
+ private async openSelected(): Promise<void> {
1533
+ if (this.view === undefined) {
1534
+ if (this.selection?.kind !== 'build') return
1535
+ const selectedSlug = this.selection.slug
1536
+ const build = this.model?.builds.find((candidate) => candidate.slug === selectedSlug)
1537
+ if (build === undefined) return
1538
+ this.view = {
1539
+ kind: 'detail',
1540
+ slug: build.slug,
1541
+ scroll: 0,
1542
+ ...(build.sessions?.[0] !== undefined ? { sessionId: build.sessions[0].id } : {}),
1543
+ }
1544
+ this.syncModelControls()
1545
+ this.paint()
1546
+ return
1547
+ }
1548
+ if (this.view.kind === 'transcript' || this.view.kind === 'session') return
1549
+
1550
+ const captured = this.view
1551
+ const build = this.model?.builds.find((candidate) => candidate.slug === captured.slug)
1552
+ const session = build?.sessions?.find((candidate) => candidate.id === captured.sessionId)
1553
+ if (session === undefined) {
1554
+ this.view = this.detailMessage(captured, 'No session is selected.')
1555
+ this.syncModelControls()
1556
+ this.paint()
1557
+ return
1558
+ }
1559
+ // The stream branch outranks every transcript outcome: a session with a
1560
+ // stream — open or closed — opens the read-only live view. Sessions
1561
+ // without a stream fall through to the existing branches unchanged.
1562
+ if (session.stream !== undefined) {
1563
+ this.view = {
1564
+ kind: 'session',
1565
+ slug: captured.slug,
1566
+ sessionId: session.id,
1567
+ stream: session.stream,
1568
+ status: session.streamStatus,
1569
+ source: { kind: 'parts', parts: [], lastSeq: 0 },
1570
+ follow: true,
1571
+ scroll: 0,
1572
+ }
1573
+ this.sessionFeed = new SessionStreamFeed(this.wiring.store, captured.slug)
1574
+ this.syncModelControls()
1575
+ this.paint()
1576
+ // Kick the first read immediately rather than waiting one interval.
1577
+ this.pollSessionFeed()
1578
+ return
1579
+ }
1580
+ if (session.status === 'open') {
1581
+ this.view = this.detailMessage(
1582
+ captured,
1583
+ 'Transcript unavailable while this session is still open.',
1584
+ session.id,
1585
+ )
1586
+ this.syncModelControls()
1587
+ this.paint()
1588
+ return
1589
+ }
1590
+ if (session.status === 'reclaimed') {
1591
+ this.view = this.detailMessage(
1592
+ captured,
1593
+ 'This session was reclaimed by a recovering runner; transcript unavailable.',
1594
+ )
1595
+ this.syncModelControls()
1596
+ this.paint()
1597
+ return
1598
+ }
1599
+ if (session.transcript === undefined) {
1600
+ this.view = this.detailMessage(captured, 'This session ended without a transcript deposit.')
1601
+ this.syncModelControls()
1602
+ this.paint()
1603
+ return
1604
+ }
1605
+
1606
+ const ref = session.transcript
1607
+ try {
1608
+ const artifact = await this.wiring.store.getArtifact(captured.slug, ref.kind, ref.rev)
1609
+ // Reads race polling, terminalization, and Escape. Apply only to the exact
1610
+ // detail/session that initiated the pinned read.
1611
+ if (
1612
+ this.view?.kind !== 'detail' ||
1613
+ this.view.slug !== captured.slug ||
1614
+ this.view.sessionId !== session.id
1615
+ ) {
1616
+ return
1617
+ }
1618
+ if (artifact === null) {
1619
+ this.view = this.detailMessage(
1620
+ captured,
1621
+ `Transcript ${ref.kind}@${ref.rev} is not retrievable.`,
1622
+ )
1623
+ } else {
1624
+ this.view = {
1625
+ kind: 'transcript',
1626
+ slug: captured.slug,
1627
+ sessionId: session.id,
1628
+ transcript: parseTranscript(new TextDecoder().decode(artifact.content)),
1629
+ scroll: 0,
1630
+ }
1631
+ }
1632
+ } catch (error) {
1633
+ if (
1634
+ this.view?.kind === 'detail' &&
1635
+ this.view.slug === captured.slug &&
1636
+ this.view.sessionId === session.id
1637
+ ) {
1638
+ this.view = this.detailMessage(
1639
+ captured,
1640
+ `Transcript read failed: ${error instanceof Error ? error.message : String(error)}`,
1641
+ )
1642
+ }
1643
+ }
1644
+ this.syncModelControls()
1645
+ this.paint()
1646
+ }
1647
+
1648
+ private leaveView(): void {
1649
+ if (this.view?.kind === 'session') {
1650
+ this.sessionFeed = undefined
1651
+ this.view = {
1652
+ kind: 'detail',
1653
+ slug: this.view.slug,
1654
+ sessionId: this.view.sessionId,
1655
+ scroll: 0,
1656
+ }
1657
+ } else if (this.view?.kind === 'transcript') {
1658
+ this.view = {
1659
+ kind: 'detail',
1660
+ slug: this.view.slug,
1661
+ sessionId: this.view.sessionId,
1662
+ scroll: 0,
1663
+ }
1664
+ } else if (this.view?.kind === 'detail') {
1665
+ this.view = undefined
1666
+ } else {
1667
+ return
1668
+ }
1669
+ this.syncModelControls()
1670
+ this.paint()
1671
+ }
1672
+
1673
+ private async handleAction(action: DashboardAction): Promise<void> {
1674
+ if (typeof action !== 'string') {
1675
+ await this.controlHarvestRun(action.run)
1676
+ return
1677
+ }
1678
+ switch (action) {
1679
+ case 'up':
1680
+ this.moveSelection(-1)
1681
+ return
1682
+ case 'down':
1683
+ this.moveSelection(1)
1684
+ return
1685
+ case 'left':
1686
+ this.moveDetailSession(-1)
1687
+ return
1688
+ case 'right':
1689
+ this.moveDetailSession(1)
1690
+ return
1691
+ case 'enter':
1692
+ await this.openSelected()
1693
+ return
1694
+ case 'intake':
1695
+ await this.toggleIntake()
1696
+ return
1697
+ case 'pause':
1698
+ await this.dashboardPause()
1699
+ return
1700
+ case 'resume':
1701
+ await this.dashboardResume()
1702
+ return
1703
+ case 'bulk-pause':
1704
+ await this.bulkControl('pause')
1705
+ return
1706
+ case 'bulk-resume':
1707
+ await this.bulkControl('resume')
1708
+ return
1709
+ case 'auto-merge':
1710
+ await this.toggleAutoMerge()
1711
+ return
1712
+ case 'discard':
1713
+ await this.discardSelected()
1714
+ return
1715
+ case 'abort-confirm':
1716
+ await this.abortConfirmed()
1717
+ return
1718
+ case 'harvest-gate':
1719
+ await this.toggleHarvestGate()
1720
+ return
1721
+ }
1722
+ }
1723
+
1724
+ private queueAction(action: DashboardAction): void {
1725
+ void this.serialize(() => this.handleAction(action)).catch((error: unknown) => {
1726
+ const name = typeof action === 'string' ? action : action.kind
1727
+ this.warn(
1728
+ `dashboard ${name} action failed: ${error instanceof Error ? error.message : String(error)}`,
1729
+ )
1730
+ })
1731
+ }
1732
+
1733
+ /** Modal edits are synchronous and never enter the dispatcher operation
1734
+ * queue. Only Enter serializes a store write, so polling/ticks continue while
1735
+ * an operator thinks and types. */
1736
+ private handleResumeInput(input: TerminalInputEvent): void {
1737
+ const prompt = this.resumePrompt
1738
+ if (prompt === undefined || this.resumeSubmitting) return
1739
+ const edit = (next: { value: string; cursor: number }): void => {
1740
+ this.resumePrompt = { ...prompt, ...next }
1741
+ this.syncModelControls()
1742
+ this.paint()
1743
+ }
1744
+ const move = (motion: ComposerMotion): void => {
1745
+ this.resumePrompt = { ...prompt, cursor: moveCursor(prompt.value, prompt.cursor, motion) }
1746
+ this.syncModelControls()
1747
+ this.paint()
1748
+ }
1749
+ switch (input.type) {
1750
+ // A paste is one insertion, not a burst of keystrokes: no part of it can
1751
+ // be interpreted as submit, and none of it is dropped.
1752
+ case 'text':
1753
+ case 'paste':
1754
+ edit(insertText(prompt.value, prompt.cursor, input.text))
1755
+ return
1756
+ case 'newline':
1757
+ edit(insertText(prompt.value, prompt.cursor, '\n'))
1758
+ return
1759
+ case 'backspace':
1760
+ edit(deleteBefore(prompt.value, prompt.cursor))
1761
+ return
1762
+ case 'left':
1763
+ case 'right':
1764
+ case 'up':
1765
+ case 'down':
1766
+ case 'home':
1767
+ case 'end':
1768
+ // Up/Down move the CARET while the prompt is open; the dashboard's row
1769
+ // selection deliberately does not follow.
1770
+ move(input.type)
1771
+ return
1772
+ case 'escape':
1773
+ this.clearResumePrompt(prompt.slug)
1774
+ return
1775
+ case 'enter':
1776
+ this.resumeSubmitting = true
1777
+ void this.serialize(() => this.submitResume(prompt))
1778
+ .catch((error: unknown) => {
1779
+ this.warn(
1780
+ `dashboard resume action failed: ${
1781
+ error instanceof Error ? error.message : String(error)
1782
+ }`,
1783
+ )
1784
+ })
1785
+ .finally(() => {
1786
+ this.resumeSubmitting = false
1787
+ this.syncModelControls()
1788
+ this.paint()
1789
+ })
1790
+ return
1791
+ case 'interrupt':
1792
+ return
1793
+ }
1794
+ }
1795
+
1796
+ private onInput(input: TerminalInputEvent): void {
1797
+ if (!this.acceptingKeys) return
1798
+ if (input.type === 'interrupt') {
1799
+ this.acceptingKeys = false
1800
+ this.inputStop.abort()
1801
+ return
1802
+ }
1803
+ if (this.resumePrompt !== undefined) {
1804
+ this.handleResumeInput(input)
1805
+ return
1806
+ }
1807
+ // The session view is strictly read-only: before the abort-confirmation
1808
+ // and enterLike branches can be reached, every key except Up/Down (scroll)
1809
+ // and Escape (back) returns without queueing any action. No key handled in
1810
+ // the session view can append an event or open the abort confirmation.
1811
+ if (this.view?.kind === 'session') {
1812
+ if (input.type === 'up' || input.type === 'down') {
1813
+ this.moveSelection(input.type === 'up' ? -1 : 1)
1814
+ } else if (input.type === 'escape') {
1815
+ this.leaveView()
1816
+ }
1817
+ return
1818
+ }
1819
+ // Outside the resume prompt `newline` is handled identically to `enter`.
1820
+ // That is the structural mitigation for splitting CR from LF: only the
1821
+ // composer can tell the two apart, so a terminal that reports Return as LF
1822
+ // cannot make Enter stop working anywhere else.
1823
+ const enterLike = input.type === 'enter' || input.type === 'newline'
1824
+ if (this.abortConfirmation !== undefined) {
1825
+ if (enterLike) this.queueAction('abort-confirm')
1826
+ else if (input.type === 'escape') {
1827
+ this.abortConfirmation = undefined
1828
+ this.syncModelControls()
1829
+ this.paint()
1830
+ }
1831
+ return
1832
+ }
1833
+
1834
+ if (
1835
+ input.type === 'up' ||
1836
+ input.type === 'down' ||
1837
+ input.type === 'left' ||
1838
+ input.type === 'right'
1839
+ ) {
1840
+ this.queueAction(input.type)
1841
+ return
1842
+ }
1843
+ if (enterLike) {
1844
+ this.queueAction('enter')
1845
+ return
1846
+ }
1847
+ if (input.type === 'escape') {
1848
+ this.leaveView()
1849
+ return
1850
+ }
1851
+ // A stray paste outside the prompt is a no-op, not a burst of command keys;
1852
+ // directional motions were routed above.
1853
+ if (input.type !== 'text') return
1854
+ switch (input.text.toLowerCase()) {
1855
+ case 'm':
1856
+ // Defense in depth: the session-view gate above already returns for a
1857
+ // session view, and the transcript guard below predates it.
1858
+ if (this.view?.kind !== 'transcript') {
1859
+ this.queueAction('auto-merge')
1860
+ }
1861
+ return
1862
+ case 'i':
1863
+ if (this.view === undefined && this.selection?.kind === 'global') this.queueAction('intake')
1864
+ return
1865
+ case 'p':
1866
+ if (this.view === undefined && this.selection?.kind === 'global') {
1867
+ this.queueAction('bulk-pause')
1868
+ return
1869
+ }
1870
+ if (this.view === undefined && this.selection?.kind === 'harvest') {
1871
+ this.queueAction({ kind: 'harvest-run', run: this.model?.harvest?.run })
1872
+ return
1873
+ }
1874
+ if (dashboardBuildControl(this.selectedDashboardBuild()?.status ?? 'queued')?.key === 'p') {
1875
+ this.queueAction('pause')
1876
+ }
1877
+ return
1878
+ case 'r':
1879
+ if (this.view === undefined && this.selection?.kind === 'global') {
1880
+ this.queueAction('bulk-resume')
1881
+ return
1882
+ }
1883
+ if (dashboardBuildControl(this.selectedDashboardBuild()?.status ?? 'queued')?.key === 'r') {
1884
+ this.queueAction('resume')
1885
+ }
1886
+ return
1887
+ case 'd': {
1888
+ const slug =
1889
+ this.view?.slug ?? (this.selection?.kind === 'build' ? this.selection.slug : undefined)
1890
+ const build = this.model?.builds.find((candidate) => candidate.slug === slug)
1891
+ if (build?.status === 'queued') this.queueAction('discard')
1892
+ return
1893
+ }
1894
+ case 'a': {
1895
+ // Same defense as `m`: the session-view gate above already made this
1896
+ // unreachable from a session view, so the abort confirmation can never
1897
+ // open from one.
1898
+ if (this.view?.kind === 'transcript') return
1899
+ const slug =
1900
+ this.view?.slug ?? (this.selection?.kind === 'build' ? this.selection.slug : undefined)
1901
+ const build = this.model?.builds.find((candidate) => candidate.slug === slug)
1902
+ if (build === undefined || build.status === 'aborting' || build.status === 'cleaning') {
1903
+ return
1904
+ }
1905
+ this.abortConfirmation = { slug: build.slug }
1906
+ this.syncModelControls()
1907
+ this.paint()
1908
+ return
1909
+ }
1910
+ case 'h':
1911
+ if (this.view === undefined && this.selection?.kind === 'global')
1912
+ this.queueAction('harvest-gate')
1913
+ return
1914
+ default:
1915
+ return
1916
+ }
1917
+ }
1918
+
1919
+ private startInput(): void {
1920
+ if (!this.dashboard || this.opts.input === undefined) return
1921
+ this.acceptingKeys = true
1922
+ this.cleanupInput = this.opts.input.start((input) => this.onInput(input), {
1923
+ onListening: () => this.keyboard?.query(),
1924
+ onKeyboardFlags: (flags) => this.keyboard?.reported(flags),
1925
+ onDeviceAttributes: () => this.keyboard?.deviceAttributes(),
1926
+ })
1927
+ }
1928
+
1929
+ private stopInput(): void {
1930
+ this.acceptingKeys = false
1931
+ const cleanup = this.cleanupInput
1932
+ this.cleanupInput = undefined
1933
+ cleanup?.()
1934
+ }
1935
+
1936
+ /** Start one repository workflow without blocking the dispatcher tick.
1937
+ * Process-local tracking prevents redundant contenders and lets `--once`
1938
+ * drain it; the repository lease excludes other dispatch processes.
1939
+ * With a provider `harvestExecution` capability the same workflow is
1940
+ * launched in a disposable guest environment instead: still fire-and-forget
1941
+ * within the tick and single-flight, and still drained by `--once` (the
1942
+ * existing drain semantics), so one invocation reaches a durable boundary.
1943
+ * Watch teardown detaches the local wait — the guest keeps running and a
1944
+ * later invocation settles the execution from the Store plus provider
1945
+ * liveness. */
1946
+ private launchHarvest(): void {
1947
+ // Do not even start a second local contender while one is active. A second
1948
+ // dispatch process is independently excluded by the repository lease.
1949
+ if (this.harvestInFlight !== undefined) return
1950
+
1951
+ const hosted = this.wiring.workspaces.harvestExecution
1952
+ if (hosted !== undefined) {
1953
+ const execution = `${this.host}-harvest-${this.wiring.ids('inst')}`
1954
+ this.hostedHarvestExecutions.add(execution)
1955
+ let tracked: Promise<void>
1956
+ tracked = this.launchHostedHarvest(hosted, execution).finally(() => {
1957
+ this.hostedHarvestExecutions.delete(execution)
1958
+ this.inFlight.delete(tracked)
1959
+ if (this.harvestInFlight === tracked) this.harvestInFlight = undefined
1960
+ })
1961
+ this.harvestInFlight = tracked
1962
+ this.inFlight.add(tracked)
1963
+ return
1964
+ }
1965
+
1966
+ const { store, tickets, runtimes, ids, uuids, clock, storeRef, token } = this.wiring
1967
+ const runner = new HarvestRunner({
1968
+ store,
1969
+ tickets,
1970
+ config: this.currentConfig().config,
1971
+ getConfig: () => this.currentConfig().config,
1972
+ runtimes,
1973
+ repo: this.repoIdentity,
1974
+ workspacePath: this.opts.targetRepo,
1975
+ ids,
1976
+ uuids,
1977
+ clock,
1978
+ instance: `${this.host}-harvest-${ids('inst')}`,
1979
+ ...(this.repoLeaseHolder !== undefined ? { leaseHolder: this.repoLeaseHolder } : {}),
1980
+ sessionEnv: {
1981
+ AB_STORE: storeRef,
1982
+ ...(token !== undefined ? { AB_TOKEN: token } : {}),
1983
+ },
1984
+ opts: {
1985
+ maxRecoveryAttempts: this.maxHarvestRecoveryAttempts,
1986
+ },
1987
+ })
1988
+
1989
+ let tracked: Promise<void>
1990
+ tracked = runner
1991
+ .run()
1992
+ .then((result) => {
1993
+ this.recordHarvestResult(result)
1994
+ if (
1995
+ !this.stopped &&
1996
+ (result.outcome === 'completed' ||
1997
+ result.outcome === 'escalated' ||
1998
+ result.outcome === 'failed')
1999
+ ) {
2000
+ const line = `harvest ${result.run} ${result.outcome}`
2001
+ if (result.outcome === 'failed') this.failureNotice(line)
2002
+ else this.say(line)
2003
+ }
2004
+ })
2005
+ .catch(async (error: unknown) => {
2006
+ this.pendingHarvest.harvestFailed += 1
2007
+ await this.appendStatus({
2008
+ actor: DISPATCHER,
2009
+ type: 'dispatcher.harvest-runner-failed',
2010
+ payload: {
2011
+ run: this.runId,
2012
+ error: error instanceof Error ? error.message : String(error),
2013
+ },
2014
+ })
2015
+ if (!this.stopped) {
2016
+ this.warn(
2017
+ `harvest runner failed: ${error instanceof Error ? error.message : String(error)}`,
2018
+ )
2019
+ }
2020
+ })
2021
+ .finally(() => {
2022
+ this.inFlight.delete(tracked)
2023
+ if (this.harvestInFlight === tracked) this.harvestInFlight = undefined
2024
+ })
2025
+ this.harvestInFlight = tracked
2026
+ this.inFlight.add(tracked)
2027
+ }
2028
+
2029
+ /** Hosted harvest lifecycle: threshold gate → provision + launch in the
2030
+ * guest → durable execution identity → supervise → classify from the
2031
+ * journal → release the environment → durable close. Fire-and-forget by
2032
+ * contract: provisioning takes minutes and must never stall a tick; the
2033
+ * single-flight guard covers the whole span. Every failure is contained
2034
+ * and retried on a later threshold tick. */
2035
+ private async launchHostedHarvest(
2036
+ harvestExecution: HarvestExecution,
2037
+ execution: string,
2038
+ ): Promise<void> {
2039
+ const { store, storeRef } = this.wiring
2040
+ const repo = this.repoIdentity
2041
+ let handle: BuildExecutionHandle | undefined
2042
+ let startedSeq: number | undefined
2043
+ // Shared with `stopHostedHarvest` through the `hostedHarvest` entry: a
2044
+ // detach must stay observable to the completion chain even after the
2045
+ // entry is cleared, or the chain would classify and reap a live guest.
2046
+ const detached = { value: false }
2047
+ try {
2048
+ // Threshold gate before any provisioning — with resume precedence. The
2049
+ // runner's real check runs in the guest after a full provisioning
2050
+ // chain, and its order is resume-first, pressure-second: it settles
2051
+ // control and resumes an actionable/open run before any pressure
2052
+ // check. The host gate replicates that order, or an interrupted run
2053
+ // whose unclaimed pressure reads below threshold would never
2054
+ // re-provision (durable recovery). Only the no-open-run case pays for
2055
+ // the pure Store-read pressure check; the guest rescans authoritatively
2056
+ // once provisioned, so a threshold crossing between gate and guest scan
2057
+ // is never suppressed by this check.
2058
+ const record = await store.getRepo(repo)
2059
+ const events = record === null ? [] : await store.getRepoEvents(repo)
2060
+ const state = reduceHarvest(events)
2061
+ const control = decideHarvestControl(state, this.maxHarvestRecoveryAttempts)
2062
+ const resumePending =
2063
+ actionableHarvestRun(state) !== undefined ||
2064
+ state.pendingCommands.length > 0 ||
2065
+ control.kind === 'acknowledge' ||
2066
+ control.kind === 'request-recovery' ||
2067
+ control.kind === 'exhaust-recovery'
2068
+ if (!resumePending) {
2069
+ const scan = await scanUnclaimedObservations(store, repo)
2070
+ const pressure = evaluateHarvestPressure(scan, this.currentConfig().config.policy)
2071
+ if (pressure.trigger === undefined) return
2072
+ }
2073
+
2074
+ handle = await harvestExecution.start({
2075
+ storeRef,
2076
+ repo,
2077
+ instance: execution,
2078
+ baseBranch: this.currentConfig().config.baseBranch,
2079
+ ...(this.repoLeaseHolder !== undefined ? { leaseHolder: this.repoLeaseHolder } : {}),
2080
+ })
2081
+ const identity = handle.identity
2082
+ if (
2083
+ identity === undefined ||
2084
+ identity.provider === undefined ||
2085
+ identity.environmentId === undefined ||
2086
+ identity.commandId === undefined
2087
+ ) {
2088
+ throw new Error(
2089
+ 'harvest execution launch returned no durable identity; refusing to supervise an unobservable execution',
2090
+ )
2091
+ }
2092
+ this.hostedHarvest = { execution, handle, detached }
2093
+ const started = await store.appendRepo(repo, {
2094
+ actor: DISPATCHER,
2095
+ type: 'harvest.execution.started',
2096
+ payload: {
2097
+ execution,
2098
+ provider: identity.provider,
2099
+ environmentId: identity.environmentId,
2100
+ ...(identity.sessionId !== undefined ? { sessionId: identity.sessionId } : {}),
2101
+ commandId: identity.commandId,
2102
+ },
2103
+ })
2104
+ startedSeq = started.seq
2105
+
2106
+ await handle.completion
2107
+ if (detached.value) {
2108
+ // Teardown detached the local wait; the guest keeps running and the
2109
+ // execution stays open for durable settlement by a later invocation.
2110
+ return
2111
+ }
2112
+ // The guest exit code carries no pipeline outcome: classify from the
2113
+ // repository journal, feed the same counters a local run reports, then
2114
+ // release the environment and journal the close with the snapshot
2115
+ // purge outcome. Releasing on every guest exit also covers parked runs
2116
+ // — a paused run resumes later in a fresh environment.
2117
+ const result = await this.classifyHostedHarvestOutcome(repo, startedSeq!)
2118
+ this.recordHarvestResult(result)
2119
+ if (
2120
+ !this.stopped &&
2121
+ (result.outcome === 'completed' ||
2122
+ result.outcome === 'escalated' ||
2123
+ result.outcome === 'failed')
2124
+ ) {
2125
+ const line = `harvest ${result.run} ${result.outcome}`
2126
+ if (result.outcome === 'failed') this.failureNotice(line)
2127
+ else this.say(line)
2128
+ }
2129
+ await this.releaseHostedHarvest(repo, execution, handle)
2130
+ } catch (error) {
2131
+ // Launch or wait failure: best-effort release attempt, the existing
2132
+ // durable failure event + warn path, retried on a later threshold tick.
2133
+ // An unmarked leftover from a mid-provision death is deleted by the
2134
+ // next run's get-or-create; a marked-but-unlaunched one is reused.
2135
+ if (handle !== undefined) {
2136
+ try {
2137
+ await this.releaseHostedHarvest(repo, execution, handle)
2138
+ } catch {
2139
+ // Left open: durable settlement re-proves and reaps it later.
2140
+ }
2141
+ }
2142
+ this.pendingHarvest.harvestFailed += 1
2143
+ await this.appendStatus({
2144
+ actor: DISPATCHER,
2145
+ type: 'dispatcher.harvest-runner-failed',
2146
+ payload: {
2147
+ run: this.runId,
2148
+ error: error instanceof Error ? error.message : String(error),
2149
+ },
2150
+ })
2151
+ if (!this.stopped) {
2152
+ this.warn(
2153
+ `harvest runner failed: ${error instanceof Error ? error.message : String(error)}`,
2154
+ )
2155
+ }
2156
+ } finally {
2157
+ this.hostedHarvest = undefined
2158
+ }
2159
+ }
2160
+
2161
+ /** Classify the outcome of one finished hosted execution from the
2162
+ * repository journal. */
2163
+ private async classifyHostedHarvestOutcome(
2164
+ repo: string,
2165
+ startedSeq: number,
2166
+ ): Promise<HarvestRunnerResult> {
2167
+ const { store } = this.wiring
2168
+ const events = await store.getRepoEvents(repo)
2169
+ const record = await store.getRepo(repo)
2170
+ return classifyHarvestOutcome(events, {
2171
+ executionStartedSeq: startedSeq,
2172
+ adoptedHolder: this.repoLeaseHolder,
2173
+ leaseHolder: record?.lease?.holder,
2174
+ maxRecoveryAttempts: this.maxHarvestRecoveryAttempts,
2175
+ })
2176
+ }
2177
+
2178
+ /** Release the disposable environment through the owning provider's reap
2179
+ * path and journal the close fact with the snapshot purge outcome. */
2180
+ private async releaseHostedHarvest(
2181
+ repo: string,
2182
+ execution: string,
2183
+ handle: BuildExecutionHandle,
2184
+ ): Promise<void> {
2185
+ const identity = handle.identity
2186
+ if (identity === undefined || identity.environmentId === undefined) return
2187
+ const workspaceHandle: WorkspaceHandle = {
2188
+ provider: identity.provider,
2189
+ ref: identity.environmentId,
2190
+ path: identity.environmentId,
2191
+ branch: '',
2192
+ }
2193
+ const reap = await this.reapHostedWorkspace(workspaceHandle)
2194
+ await this.wiring.store.appendRepo(repo, {
2195
+ actor: DISPATCHER,
2196
+ type: 'harvest.execution.released',
2197
+ payload: {
2198
+ execution,
2199
+ environmentId: identity.environmentId,
2200
+ snapshots: {
2201
+ outcome: reap.snapshots.outcome === 'unknown' ? 'unknown' : 'confirmed',
2202
+ ...(reap.snapshots.deleted !== undefined ? { deleted: reap.snapshots.deleted } : {}),
2203
+ ...(reap.snapshots.error !== undefined ? { error: reap.snapshots.error } : {}),
2204
+ },
2205
+ },
2206
+ })
2207
+ }
2208
+
2209
+ /** Reap through the provider's recovery capability when it reports the
2210
+ * snapshot purge outcome; fall back to plain release otherwise (the close
2211
+ * fact then carries an unconfirmed purge). */
2212
+ private async reapHostedWorkspace(handle: WorkspaceHandle): Promise<WorkspaceReapOutcome> {
2213
+ const provider = this.wiring.workspaces
2214
+ if (provider.name !== handle.provider) {
2215
+ const retired = this.wiring.retiredWorkspaces?.find(
2216
+ (candidate) => candidate.provider.name === handle.provider,
2217
+ )?.provider
2218
+ if (retired === undefined) throw new Error(`no provider owns harvest workspace ${handle.ref}`)
2219
+ if (retired.recovery !== undefined) return retired.recovery.reap(handle)
2220
+ await retired.release(handle)
2221
+ } else if (provider.recovery !== undefined) {
2222
+ return provider.recovery.reap(handle)
2223
+ } else {
2224
+ await provider.release(handle)
2225
+ }
2226
+ return {
2227
+ outcome: 'confirmed',
2228
+ snapshots: { outcome: 'unknown', error: 'provider did not report the snapshot purge' },
2229
+ }
2230
+ }
2231
+
2232
+ /** Teardown: detach the local wait only. The guest keeps running and
2233
+ * heartbeating its adopted repository lease; the execution stays open and
2234
+ * a later invocation settles it from the Store plus provider liveness. */
2235
+ private async stopHostedHarvest(): Promise<void> {
2236
+ const entry = this.hostedHarvest
2237
+ if (entry === undefined) return
2238
+ entry.detached.value = true
2239
+ this.hostedHarvest = undefined
2240
+ try {
2241
+ await entry.handle.detach()
2242
+ } catch {
2243
+ // Detach is best-effort: an unresolvable wait still exits.
2244
+ }
2245
+ }
2246
+
2247
+ private recordHarvestResult(result: HarvestRunnerResult): void {
2248
+ if ('launch' in result) {
2249
+ if (result.launch === 'started') this.pendingHarvest.harvestStarted += 1
2250
+ else this.pendingHarvest.harvestResumed += 1
2251
+ }
2252
+ if (result.outcome === 'completed') this.pendingHarvest.harvestCompleted += 1
2253
+ else if (result.outcome === 'escalated') {
2254
+ this.pendingHarvest.harvestEscalated += 1
2255
+ } else if (result.outcome === 'failed') {
2256
+ this.pendingHarvest.harvestFailed += 1
2257
+ }
2258
+ }
2259
+
2260
+ private hasPendingHarvestResults(): boolean {
2261
+ return (
2262
+ this.pendingHarvest.harvestStarted > 0 ||
2263
+ this.pendingHarvest.harvestResumed > 0 ||
2264
+ this.pendingHarvest.harvestCompleted > 0 ||
2265
+ this.pendingHarvest.harvestEscalated > 0 ||
2266
+ this.pendingHarvest.harvestFailed > 0
2267
+ )
2268
+ }
2269
+
2270
+ /** Publish one report from one Harvest snapshot. Acknowledgement subtracts
2271
+ * only that snapshot after the durable append, preserving outcomes that
2272
+ * settle while the Store write is in flight and retaining all counters when
2273
+ * publication fails. */
2274
+ private async publishTickReport(
2275
+ report: TickReport,
2276
+ settlementOnly = false,
2277
+ ): Promise<TickReport | undefined> {
2278
+ const snapshot = { ...this.pendingHarvest }
2279
+ if (
2280
+ settlementOnly &&
2281
+ snapshot.harvestStarted === 0 &&
2282
+ snapshot.harvestResumed === 0 &&
2283
+ snapshot.harvestCompleted === 0 &&
2284
+ snapshot.harvestEscalated === 0 &&
2285
+ snapshot.harvestFailed === 0
2286
+ ) {
2287
+ return undefined
2288
+ }
2289
+
2290
+ const merged: TickReport = {
2291
+ ...report,
2292
+ harvestStarted: report.harvestStarted + snapshot.harvestStarted,
2293
+ harvestResumed: report.harvestResumed + snapshot.harvestResumed,
2294
+ harvestCompleted: report.harvestCompleted + snapshot.harvestCompleted,
2295
+ harvestEscalated: report.harvestEscalated + snapshot.harvestEscalated,
2296
+ harvestFailed: report.harvestFailed + snapshot.harvestFailed,
2297
+ }
2298
+ const {
2299
+ queued: _queued,
2300
+ janitorDiagnostics: _janitorDiagnostics,
2301
+ blockedDiagnostics: _blockedDiagnostics,
2302
+ ticketDiagnostics: _ticketDiagnostics,
2303
+ creationDiagnostics: _creationDiagnostics,
2304
+ dependencyDiagnostics: _dependencyDiagnostics,
2305
+ ...counters
2306
+ } = merged
2307
+ await this.appendStatus({
2308
+ actor: DISPATCHER,
2309
+ type: 'dispatcher.tick-completed',
2310
+ payload: {
2311
+ run: this.runId,
2312
+ ...this.lastTickStatus,
2313
+ counters,
2314
+ },
2315
+ })
2316
+
2317
+ this.pendingHarvest.harvestStarted -= snapshot.harvestStarted
2318
+ this.pendingHarvest.harvestResumed -= snapshot.harvestResumed
2319
+ this.pendingHarvest.harvestCompleted -= snapshot.harvestCompleted
2320
+ this.pendingHarvest.harvestEscalated -= snapshot.harvestEscalated
2321
+ this.pendingHarvest.harvestFailed -= snapshot.harvestFailed
2322
+ return merged
2323
+ }
2324
+
2325
+ /** Settlement-only completions are serialized after operational ticks. Loop
2326
+ * so a result arriving during an awaited status append gets its own report. */
2327
+ private publishSettlementReports(): Promise<TickReport[]> {
2328
+ return this.serialize(async () => {
2329
+ const reports: TickReport[] = []
2330
+ while (this.hasPendingHarvestResults()) {
2331
+ const report = await this.publishTickReport(emptyTickReport(), true)
2332
+ if (report !== undefined) reports.push(report)
2333
+ }
2334
+ return reports
2335
+ })
2336
+ }
2337
+
2338
+ private async matchingRunnerDiagnostic(slug: string, instance: string) {
2339
+ const metas = await this.wiring.store.listArtifacts(slug, BUILD_RUNNER_DIAGNOSTIC_ARTIFACT)
2340
+ for (const meta of metas.toReversed()) {
2341
+ if (meta.metadata.instance !== instance) continue
2342
+ const artifact = await this.wiring.store.getArtifact(slug, meta.kind, meta.revision)
2343
+ if (artifact === null) continue
2344
+ const diagnostic = parseDiagnostic(artifact)
2345
+ if (diagnostic?.instance === instance) return diagnostic
2346
+ }
2347
+ return null
2348
+ }
2349
+
2350
+ private async settlePendingPublication(slug: string): Promise<void> {
2351
+ await settleWorkspacePublication(
2352
+ {
2353
+ store: this.wiring.store,
2354
+ storeRef: this.wiring.storeRef,
2355
+ publication: this.wiring.workspaces.publication,
2356
+ forge: this.wiring.forge,
2357
+ workspacePath: this.opts.targetRepo,
2358
+ exec: this.opts.exec,
2359
+ ids: this.wiring.ids,
2360
+ runId: this.runId,
2361
+ clock: this.wiring.clock,
2362
+ },
2363
+ slug,
2364
+ )
2365
+ }
2366
+
2367
+ private async recordInfrastructureFailure(input: {
2368
+ slug: string
2369
+ instance: string
2370
+ workspaceRef: string
2371
+ operation: 'provision' | 'start' | 'wait' | 'stop' | 'delete' | 'reconcile'
2372
+ error: unknown
2373
+ cleanupPending: boolean
2374
+ identity?: BuildExecutionHandle['identity']
2375
+ }): Promise<void> {
2376
+ await appendInfrastructureFailure(
2377
+ { store: this.wiring.store, ids: this.wiring.ids },
2378
+ {
2379
+ slug: input.slug,
2380
+ events: await this.wiring.store.getEvents(input.slug),
2381
+ maxAttempts: this.currentConfig().config.policy.maxInfrastructureAttempts,
2382
+ provider: input.identity?.provider ?? this.wiring.workspaces.name,
2383
+ workspaceRef: input.workspaceRef,
2384
+ instance: input.instance,
2385
+ ...(input.identity?.environmentId !== undefined
2386
+ ? { environmentId: input.identity.environmentId }
2387
+ : {}),
2388
+ ...(input.identity?.sessionId !== undefined ? { sessionId: input.identity.sessionId } : {}),
2389
+ operation: input.operation,
2390
+ error: input.error,
2391
+ cleanupPending: input.cleanupPending,
2392
+ },
2393
+ )
2394
+ }
2395
+
2396
+ /** Start one workspace-adjacent executor without handing it workspace,
2397
+ * config, or outcome channels. Capacity and local single-flight remain
2398
+ * kernel decisions; the kernel reserves the durable execution lease before
2399
+ * launch and retains it until the executor has reaped its complete tree. */
2400
+ private async launchRunner(slug: string): Promise<LaunchRunnerResult> {
2401
+ if (!this.acceptingBuildLaunches || this.activeBuildRuns.has(slug)) return 'already-active'
2402
+
2403
+ const reservation = Symbol(slug)
2404
+ const instance = `${this.host}-${slug}-${this.wiring.ids('inst')}`
2405
+ const active: ActiveBuildExecution = { reservation, instance, stopping: false }
2406
+ this.activeBuildRuns.set(slug, active)
2407
+ let leaseClaimed = false
2408
+
2409
+ try {
2410
+ await this.publishBuildConfig(slug)
2411
+ leaseClaimed = await this.wiring.store.claimLease(
2412
+ slug,
2413
+ instance,
2414
+ BUILD_EXECUTION_LEASE_TTL_MS,
2415
+ )
2416
+ if (!leaseClaimed) {
2417
+ this.activeBuildRuns.delete(slug)
2418
+ await this.appendStatus({
2419
+ actor: DISPATCHER,
2420
+ type: 'dispatcher.runner-settled',
2421
+ payload: { run: this.runId, slug, outcome: 'lease-held' },
2422
+ })
2423
+ this.failureNotice(`build ${slug} already held by another runner — skipped`)
2424
+ return 'already-active'
2425
+ }
2426
+ const launchEvents = await this.wiring.store.getEvents(slug)
2427
+ let workspaceRef: string | undefined
2428
+ let workspaceProvider: string | undefined
2429
+ for (const event of launchEvents) {
2430
+ if (event.type === 'workspace.provisioned') {
2431
+ workspaceRef = event.payload.ref
2432
+ workspaceProvider = event.payload.provider
2433
+ } else if (event.type === 'workspace.released') {
2434
+ workspaceRef = undefined
2435
+ workspaceProvider = undefined
2436
+ }
2437
+ }
2438
+ if (workspaceRef === undefined) throw new Error(`build ${slug} has no open workspace`)
2439
+ // Execute through the runtime that owns the workspace: a build
2440
+ // provisioned before a provider switch still runs where it lives.
2441
+ const owning =
2442
+ workspaceProvider === undefined || workspaceProvider === this.wiring.workspaces.name
2443
+ ? { name: this.wiring.workspaces.name, execution: this.wiring.buildExecution }
2444
+ : (() => {
2445
+ const retired = this.wiring.retiredWorkspaces?.find(
2446
+ (runtime) => runtime.provider.name === workspaceProvider,
2447
+ )
2448
+ if (retired === undefined) {
2449
+ throw new Error(
2450
+ `build ${slug} workspace ${workspaceRef} belongs to provider "${workspaceProvider}", which is no longer configured`,
2451
+ )
2452
+ }
2453
+ return { name: retired.provider.name, execution: retired.execution }
2454
+ })()
2455
+ // A prior publication acknowledgement may have been lost after its push
2456
+ // reached the durable branch. Reconcile that fact before a replacement
2457
+ // runner can rerun the phase and create a non-fast-forward successor.
2458
+ await this.settlePendingPublication(slug)
2459
+ const handle = await owning.execution.start({
2460
+ slug,
2461
+ storeRef: this.wiring.storeRef,
2462
+ instance,
2463
+ workspaceRef,
2464
+ })
2465
+ active.handle = handle
2466
+ const identity = handle.identity ?? {
2467
+ provider: owning.name,
2468
+ workspaceRef,
2469
+ }
2470
+ await this.wiring.store.append(slug, {
2471
+ actor: DISPATCHER,
2472
+ type: 'execution.started',
2473
+ payload: { ...identity, instance },
2474
+ })
2475
+
2476
+ let tracked: Promise<void>
2477
+ tracked = handle.completion
2478
+ .then(
2479
+ async (exit) => {
2480
+ // A detached execution's guest keeps running: no `execution.ended`,
2481
+ // no lease release, no publication settlement — the next
2482
+ // supervisor settles the execution from the Store.
2483
+ if (active.detaching) return
2484
+ try {
2485
+ await this.wiring.store.append(slug, {
2486
+ actor: DISPATCHER,
2487
+ type: 'execution.ended',
2488
+ payload: {
2489
+ instance,
2490
+ workspaceRef,
2491
+ outcome: active.stopping ? 'stopped' : 'completed',
2492
+ exitCode: exit.exitCode,
2493
+ },
2494
+ })
2495
+ if (active.stopping) return
2496
+ const diagnostic = await this.matchingRunnerDiagnostic(slug, instance)
2497
+ if (diagnostic?.outcome === 'lease-held') {
2498
+ await this.appendStatus({
2499
+ actor: DISPATCHER,
2500
+ type: 'dispatcher.runner-settled',
2501
+ payload: { run: this.runId, slug, outcome: 'lease-held' },
2502
+ })
2503
+ this.failureNotice(`build ${slug} already held by another runner — skipped`)
2504
+ return
2505
+ }
2506
+
2507
+ const state = reduceBuild(await this.wiring.store.getEvents(slug))
2508
+ if (diagnostic === null && exit.exitCode === 0) {
2509
+ await this.appendStatus({
2510
+ actor: DISPATCHER,
2511
+ type: 'dispatcher.runner-settled',
2512
+ payload: {
2513
+ run: this.runId,
2514
+ slug,
2515
+ outcome: 'parked',
2516
+ status: state.status,
2517
+ },
2518
+ })
2519
+ this.failureNotice(`build ${slug} parked (${state.status})`)
2520
+ return
2521
+ }
2522
+
2523
+ const detail =
2524
+ diagnostic?.error ??
2525
+ `child exited ${exit.exitCode ?? 'without status'}${exit.signal ? ` (${exit.signal})` : ''}`
2526
+ await this.appendStatus({
2527
+ actor: DISPATCHER,
2528
+ type: 'dispatcher.runner-settled',
2529
+ payload: { run: this.runId, slug, outcome: 'failed', error: detail },
2530
+ })
2531
+ this.warn(`build ${slug} runner failed: ${detail}`)
2532
+ } finally {
2533
+ await this.wiring.store.releaseLease(slug, instance)
2534
+ await this.settlePendingPublication(slug)
2535
+ }
2536
+ },
2537
+ async (error) => {
2538
+ // A detached wait is teardown of local supervision, not a guest
2539
+ // failure; the next supervisor re-observes the execution.
2540
+ if (active.detaching) return
2541
+ // A rejected executor completion cannot prove the remote VM was
2542
+ // stopped. Keep the lease until expiry: recovery fences and reaps
2543
+ // the exact recorded identity before authorizing a replacement.
2544
+ await this.recordInfrastructureFailure({
2545
+ slug,
2546
+ instance,
2547
+ workspaceRef,
2548
+ operation: 'wait',
2549
+ error,
2550
+ cleanupPending: true,
2551
+ identity,
2552
+ })
2553
+ },
2554
+ )
2555
+ .finally(() => {
2556
+ this.inFlightKinds.delete(tracked)
2557
+ this.inFlight.delete(tracked)
2558
+ if (this.activeBuildRuns.get(slug)?.reservation === reservation) {
2559
+ this.activeBuildRuns.delete(slug)
2560
+ }
2561
+ })
2562
+ active.settled = tracked
2563
+ this.inFlight.add(tracked)
2564
+ this.inFlightKinds.set(tracked, handle.supervision)
2565
+ return 'scheduled'
2566
+ } catch (error) {
2567
+ if (this.activeBuildRuns.get(slug)?.reservation === reservation) {
2568
+ this.activeBuildRuns.delete(slug)
2569
+ }
2570
+ if (leaseClaimed) {
2571
+ const events = await this.wiring.store.getEvents(slug)
2572
+ let workspaceRef = ''
2573
+ for (const event of events) {
2574
+ if (event.type === 'workspace.provisioned') workspaceRef = event.payload.ref
2575
+ else if (event.type === 'workspace.released') workspaceRef = ''
2576
+ }
2577
+ if (workspaceRef !== '') {
2578
+ await this.recordInfrastructureFailure({
2579
+ slug,
2580
+ instance,
2581
+ workspaceRef,
2582
+ operation: 'start',
2583
+ error,
2584
+ cleanupPending: this.wiring.workspaces.recovery !== undefined,
2585
+ })
2586
+ }
2587
+ // Local start failures have no possible remote holder. Remote failures
2588
+ // retain the lease until its TTL fences an ambiguous start.
2589
+ if (this.wiring.workspaces.recovery === undefined) {
2590
+ await this.wiring.store.releaseLease(slug, instance)
2591
+ }
2592
+ }
2593
+ await this.appendStatus({
2594
+ actor: DISPATCHER,
2595
+ type: 'dispatcher.runner-settled',
2596
+ payload: {
2597
+ run: this.runId,
2598
+ slug,
2599
+ outcome: 'launch-failed',
2600
+ error: error instanceof Error ? error.message : String(error),
2601
+ },
2602
+ })
2603
+ throw error
2604
+ }
2605
+ }
2606
+
2607
+ private get stopped(): boolean {
2608
+ return this.opts.signal?.aborted === true || this.inputStop.signal.aborted
2609
+ }
2610
+
2611
+ /** `--once` awaits everything in `inFlight` that is not tagged
2612
+ * 'environment': `local-parent` build executions, locally run harvest, and
2613
+ * hosted harvest. Hosted harvest runs in a disposable guest but carries no
2614
+ * 'environment' tag (launchHarvest never sets inFlightKinds), so `--once`
2615
+ * awaits it and one invocation reaches a durable boundary — per the
2616
+ * launchHarvest doc comment. The provisioning drain is awaited too (a
2617
+ * sandbox is created, bootstrapped, and launched by the host, so leaving
2618
+ * early would abandon it at teardown and the next invocation would start
2619
+ * over). Only build executions launched with environment supervision are
2620
+ * excluded (tagged at the build-launch site): they keep running in their
2621
+ * guest, and a later invocation settles them from the Store alone. An
2622
+ * invocation deadline stops the await loop; the remaining `local-parent`
2623
+ * executions are then stopped and reaped by the `stopBuildExecutions()`
2624
+ * teardown, exactly as a deliberately stopped watch run, and an unfinished
2625
+ * provisioning is recorded as abandoned for the next supervisor to adopt. */
2626
+ private async drainInFlight(): Promise<void> {
2627
+ for (;;) {
2628
+ const pending: Promise<unknown>[] = [...this.inFlight].filter(
2629
+ (promise) => this.inFlightKinds.get(promise) !== 'environment',
2630
+ )
2631
+ if (this.dispatcher.provisioningInFlight()) {
2632
+ pending.push(this.dispatcher.drainProvisioning())
2633
+ }
2634
+ if (pending.length === 0) return
2635
+ const deadline = this.opts.deadlineAt
2636
+ if (deadline === undefined) {
2637
+ await Promise.all(pending)
2638
+ continue
2639
+ }
2640
+ if (this.pastDeadline()) return
2641
+ const remainingMs = Math.max(0, deadline - this.wiring.clock().getTime())
2642
+ const timers = this.opts.timers ?? DEFAULT_TIMERS
2643
+ let expired = false
2644
+ // The expiry handle is captured and cleared on BOTH race outcomes: when
2645
+ // the build set wins, `clearTimeout` runs here too, so no pending expiry
2646
+ // handle outlives the invocation (obs_e7fb42f0). When the timer wins, it
2647
+ // has already fired and the clear is a no-op.
2648
+ let expiryHandle: unknown
2649
+ await Promise.race([
2650
+ Promise.all(pending),
2651
+ new Promise<void>((resolveRace) => {
2652
+ expiryHandle = timers.setTimeout(() => {
2653
+ expired = true
2654
+ resolveRace()
2655
+ }, remainingMs)
2656
+ }),
2657
+ ])
2658
+ timers.clearTimeout(expiryHandle)
2659
+ if (expired) return
2660
+ }
2661
+ }
2662
+
2663
+ /** Teardown distinguishes supervision kinds. A local-parent execution is
2664
+ * stopped and reaped exactly as before. An environment-supervised execution
2665
+ * is DETACHED: the guest keeps running and heartbeating its lease; the next
2666
+ * invocation settles the execution from the Store plus provider liveness
2667
+ * (AC: a restarted dispatcher never replaces a healthy environment). */
2668
+ private async stopBuildExecutions(): Promise<void> {
2669
+ this.acceptingBuildLaunches = false
2670
+ const active = [...this.activeBuildRuns.entries()]
2671
+ for (const [, entry] of active) {
2672
+ if (entry.handle?.supervision === 'environment') entry.detaching = true
2673
+ else entry.stopping = true
2674
+ }
2675
+ await Promise.all(
2676
+ active.map(async ([slug, entry]) => {
2677
+ if (entry.handle?.supervision === 'environment') {
2678
+ // Abort the local wait only — no command kill, no environment stop,
2679
+ // no lease release, no durable end. Drop the tracking so the
2680
+ // process can exit while the guest outlives it.
2681
+ try {
2682
+ await entry.handle.detach()
2683
+ } catch {
2684
+ // Detach is best-effort: an unresolvable wait still exits.
2685
+ }
2686
+ this.activeBuildRuns.delete(slug)
2687
+ if (entry.settled !== undefined) {
2688
+ this.inFlight.delete(entry.settled)
2689
+ this.inFlightKinds.delete(entry.settled)
2690
+ }
2691
+ return
2692
+ }
2693
+ const result = await entry.handle?.stop()
2694
+ if (result?.outcome === 'unknown') {
2695
+ await this.recordInfrastructureFailure({
2696
+ slug,
2697
+ instance: entry.instance,
2698
+ workspaceRef: entry.handle?.identity?.workspaceRef ?? slug,
2699
+ operation: 'stop',
2700
+ error: result.error,
2701
+ cleanupPending: true,
2702
+ identity: entry.handle?.identity,
2703
+ })
2704
+ // Do not wait forever for command.wait after an ambiguous stop. The
2705
+ // durable lease intentionally remains until TTL expiry and fencing.
2706
+ return
2707
+ }
2708
+ await entry.settled
2709
+ }),
2710
+ )
2711
+ }
2712
+
2713
+ // ── Message routing ───────────────────────────────────────────────────────
2714
+ //
2715
+ // The interactive frame is the dashboard's ONLY output surface. Routine
2716
+ // notices are intentionally silent there; only warnings/errors become a
2717
+ // process-local warning row. A concurrent store poll overlays the latest
2718
+ // warning again before painting, so an older projection cannot erase it.
2719
+ // Plain/non-interactive mode keeps the existing line sinks exactly.
2720
+
2721
+ private setWarning(line: string): void {
2722
+ this.warningLine = line
2723
+ this.syncModelControls()
2724
+ this.paint()
2725
+ }
2726
+
2727
+ private say(line: string): void {
2728
+ if (!this.dashboard && this.opts.silent !== true) this.opts.stdout(line)
2729
+ }
2730
+
2731
+ /** Routine acknowledgement: line-oriented stdout, dashboard-silent. */
2732
+ private announce(line: string): void {
2733
+ if (!this.dashboard) this.opts.stdout(line)
2734
+ }
2735
+
2736
+ private warn(line: string): void {
2737
+ if (this.dashboard) this.setWarning(line)
2738
+ else if (this.opts.silent !== true) this.opts.stderr(line)
2739
+ }
2740
+
2741
+ /** Failure-severity dashboard notice whose historical plain sink is stdout. */
2742
+ private failureNotice(line: string): void {
2743
+ if (this.dashboard) this.setWarning(line)
2744
+ else if (this.opts.silent !== true) this.opts.stdout(line)
2745
+ }
2746
+
2747
+ /** Routine diagnostic whose historical line-oriented sink is stderr. */
2748
+ private routineDiagnostic(line: string): void {
2749
+ if (!this.dashboard && this.opts.silent !== true) this.opts.stderr(line)
2750
+ }
2751
+
2752
+ /**
2753
+ * Janitor, ticket, and dependency diagnostics are independent notices
2754
+ * (line-oriented in plain mode; on a TTY only warning-severity diagnostics
2755
+ * are rendered). Contained janitor failures and invalid ticket records use
2756
+ * the warning seam so scripted JSON/stdout consumers stay clean; dependency
2757
+ * holds retain their ordinary notice seam. In line-oriented mode this is the
2758
+ * operator's attributed view of why a build or ready ticket is sitting still,
2759
+ * with no provider, filesystem, or database inspection. The counts map
2760
+ * guards on `typeof count === 'number'`
2761
+ * because a non-numeric TickReport field would otherwise be dropped here
2762
+ * silently (`count > 0` is false for an array, with no type error).
2763
+ *
2764
+ * `say()` is the plain stdout identity and intentionally silent on the
2765
+ * dashboard, so line-oriented behavior is unchanged while routine
2766
+ * interactive chatter disappears.
2767
+ */
2768
+ /**
2769
+ * A startup, configuration-level notice (§9 role-key consumability) — never a
2770
+ * tick outcome, never blocking. Both surfaces get the SAME strings in full:
2771
+ * stderr writes each one, the dashboard wraps them into its warning region.
2772
+ * No surface gets a digest, a cap, or a truncated tail.
2773
+ */
2774
+ private reportRoleDiagnostics(): void {
2775
+ const lines = roleKeyWarnings(this.currentConfig().config)
2776
+ if (this.dashboard) {
2777
+ this.configWarnings = lines
2778
+ this.syncModelControls()
2779
+ this.paint()
2780
+ } else {
2781
+ for (const line of lines) this.opts.stderr(line)
2782
+ }
2783
+ }
2784
+
2785
+ private printReport(report: Awaited<ReturnType<Dispatcher['tick']>>, printIdle = true): boolean {
2786
+ // `queued` is a standing depth, not a tick action — the header owns it;
2787
+ // repeating it in the notice would make every saturated tick look busy.
2788
+ const {
2789
+ janitorDiagnostics,
2790
+ blockedDiagnostics,
2791
+ ticketDiagnostics,
2792
+ creationDiagnostics,
2793
+ dependencyDiagnostics,
2794
+ queued: _queued,
2795
+ ...counts
2796
+ } = report
2797
+ for (const line of janitorDiagnostics) this.routineDiagnostic(line)
2798
+ for (const line of blockedDiagnostics) this.say(line)
2799
+ for (const line of ticketDiagnostics) this.routineDiagnostic(line)
2800
+ for (const line of creationDiagnostics) this.say(line)
2801
+ for (const line of dependencyDiagnostics) this.say(line)
2802
+ const parts = Object.entries(counts)
2803
+ .filter(([, count]) => typeof count === 'number' && count > 0)
2804
+ .map(([name, count]) => `${name}=${count}`)
2805
+ if (parts.length > 0) {
2806
+ this.say(`tick: ${parts.join(' ')}`)
2807
+ return true
2808
+ }
2809
+ const reportedDiagnostic =
2810
+ janitorDiagnostics.length > 0 ||
2811
+ blockedDiagnostics.length > 0 ||
2812
+ ticketDiagnostics.length > 0 ||
2813
+ creationDiagnostics.length > 0 ||
2814
+ dependencyDiagnostics.length > 0
2815
+ if (reportedDiagnostic) return true
2816
+ // A tick that did something is worth a plain line. Interactive mode
2817
+ // suppresses both action counts and the every-10s idle noise.
2818
+ if (!this.dashboard && printIdle) {
2819
+ this.opts.stdout('tick: idle')
2820
+ return true
2821
+ }
2822
+ return false
2823
+ }
2824
+
2825
+ // ── The live region ───────────────────────────────────────────────────────
2826
+
2827
+ /**
2828
+ * The store READ half of a frame: discover builds, incrementally refresh
2829
+ * dashboard-visible streams, combine their cached projections with a fresh
2830
+ * repository-journal projection, then paint. Read-only — it appends nothing
2831
+ * and decides nothing. Paint is split out so the watch-mode tick timer can
2832
+ * repaint cached timing against a moving clock without re-reading.
2833
+ */
2834
+ private async renderOnce(): Promise<void> {
2835
+ const { terminal } = this.opts
2836
+ if (this.region === undefined || terminal === undefined) return
2837
+ const configSnapshot = this.currentConfig()
2838
+ const buildSnapshot = await this.dashboardBuilds.refresh(
2839
+ configSnapshot.config,
2840
+ configSnapshot.revision,
2841
+ )
2842
+ const repoRecord = await this.wiring.store.getRepo(this.repoIdentity)
2843
+ const repositoryEvents =
2844
+ repoRecord === null ? [] : await this.wiring.store.getRepoEvents(this.repoIdentity)
2845
+
2846
+ // Action-triggered and timer refreshes share the cache but may finish their
2847
+ // repository reads out of order. Never let an older build snapshot replace
2848
+ // one that committed later.
2849
+ if (!this.dashboardBuilds.isCurrent(buildSnapshot)) return
2850
+
2851
+ // Polling continues while the operator types. Keep the prompt bound to the
2852
+ // captured build/escalations, shrink it around externally answered ids, and
2853
+ // clear it rather than ever retargeting feedback to a newly selected row.
2854
+ if (this.resumePrompt !== undefined) {
2855
+ const prompt = this.resumePrompt
2856
+ const state = buildSnapshot.states.get(prompt.slug)
2857
+ const active = state !== undefined && ['running', 'paused', 'blocked'].includes(state.status)
2858
+ const openIds = new Set(state?.openEscalations.map((item) => item.id) ?? [])
2859
+ const remaining = prompt.escalationIds.filter((id) => openIds.has(id))
2860
+ if (!active || remaining.length === 0) {
2861
+ this.resumePrompt = undefined
2862
+ } else if (remaining.length !== prompt.escalationIds.length) {
2863
+ this.resumePrompt = { ...prompt, escalationIds: remaining }
2864
+ }
2865
+ }
2866
+ if (this.abortConfirmation !== undefined) {
2867
+ const state = buildSnapshot.states.get(this.abortConfirmation.slug)
2868
+ if (
2869
+ state === undefined ||
2870
+ !['queued', 'running', 'paused', 'blocked'].includes(state.status) ||
2871
+ state.pendingCommands.some((command) => command.command === 'abort')
2872
+ ) {
2873
+ // Another process may have durably requested the abort while this
2874
+ // process-local prompt was open. Never leave a stale confirmation up.
2875
+ this.abortConfirmation = undefined
2876
+ }
2877
+ }
2878
+
2879
+ const previousRows = this.model === undefined ? [] : dashboardSelections(this.model)
2880
+ const projected = buildDashboardFromProjected(
2881
+ buildSnapshot.builds,
2882
+ {
2883
+ repo: this.repoIdentity,
2884
+ queued: this.queuedCount,
2885
+ activeCount: [...buildSnapshot.states.values()].filter(
2886
+ (state) => state.status !== 'done' && state.status !== 'aborted',
2887
+ ).length,
2888
+ capacity: configSnapshot.config.capacity,
2889
+ observationCount: this.observationCount,
2890
+ observationLimit: configSnapshot.config.policy.harvestThreshold,
2891
+ },
2892
+ repositoryEvents,
2893
+ )
2894
+ const nextRows = dashboardSelections(projected)
2895
+ this.selection = reconcileSelection(previousRows, nextRows, this.selection)
2896
+
2897
+ if (this.view !== undefined) {
2898
+ const build = projected.builds.find((candidate) => candidate.slug === this.view!.slug)
2899
+ if (build === undefined) {
2900
+ // Detail follows dashboard visibility: acknowledged abort cleanup stays
2901
+ // open, and final completion returns the operator to the list.
2902
+ this.view = undefined
2903
+ } else if (this.view.kind === 'detail') {
2904
+ const detail = this.view
2905
+ const sessions = build.sessions ?? []
2906
+ const selected = sessions.some((session) => session.id === detail.sessionId)
2907
+ ? detail.sessionId
2908
+ : sessions[0]?.id
2909
+ const messageStillValid =
2910
+ detail.messageWhileSessionOpen === undefined ||
2911
+ sessions.some(
2912
+ (session) => session.id === detail.messageWhileSessionOpen && session.status === 'open',
2913
+ )
2914
+ const {
2915
+ message: priorMessage,
2916
+ messageWhileSessionOpen: priorMessageFence,
2917
+ sessionId: _priorSession,
2918
+ ...stableDetail
2919
+ } = detail
2920
+ this.view = {
2921
+ ...stableDetail,
2922
+ ...(selected !== undefined ? { sessionId: selected } : {}),
2923
+ ...(messageStillValid && priorMessage !== undefined
2924
+ ? {
2925
+ message: priorMessage,
2926
+ ...(priorMessageFence !== undefined
2927
+ ? { messageWhileSessionOpen: priorMessageFence }
2928
+ : {}),
2929
+ }
2930
+ : {}),
2931
+ }
2932
+ }
2933
+ }
2934
+
2935
+ this.model = projected
2936
+ this.syncModelControls()
2937
+ this.paint()
2938
+ // The session view's live-read cycle rides the poll tick: one immediate
2939
+ // read per interval, applied through the serialize queue under the fence.
2940
+ this.pollSessionFeed()
2941
+ }
2942
+
2943
+ /**
2944
+ * The PAINT half: render the cached model against the CURRENT clock and
2945
+ * repaint. `now` is what makes a running step's elapsed advance (AC 8); the
2946
+ * identical-frame check in `LiveRegion.update` collapses a repaint whose
2947
+ * displayed second is unchanged to zero writes. No store I/O.
2948
+ */
2949
+ private paint(): void {
2950
+ const { terminal } = this.opts
2951
+ if (this.region === undefined || terminal === undefined || this.model === undefined) return
2952
+ const renderer = this.opts.resolveDashboardRenderer?.() ?? renderDashboard
2953
+ this.region.update(
2954
+ renderer(this.model, {
2955
+ color: true,
2956
+ width: terminal.columns,
2957
+ // NOT `terminal.rows` — the region's trailing newline needs a row of
2958
+ // its own, so a frame of exactly `rows` scrolls its own header off.
2959
+ // See `paintableRows`.
2960
+ height: paintableRows(terminal.rows),
2961
+ now: this.wiring.clock().getTime(),
2962
+ }),
2963
+ )
2964
+ }
2965
+
2966
+ private startUpgradeChecks(): void {
2967
+ if (
2968
+ (!this.dashboard && this.opts.kernelRunId === undefined) ||
2969
+ this.opts.once === true ||
2970
+ this.stopUpgradeNotice !== undefined
2971
+ ) {
2972
+ return
2973
+ }
2974
+ const probe =
2975
+ this.opts.availableReleaseProbe ?? ((signal: AbortSignal) => availableRelease({ signal }))
2976
+ try {
2977
+ this.stopUpgradeNotice = startUpgradeNotice({
2978
+ probe,
2979
+ ...(this.opts.upgradeNoticeScheduler !== undefined
2980
+ ? { scheduler: this.opts.upgradeNoticeScheduler }
2981
+ : {}),
2982
+ onAvailable: (version) => {
2983
+ if (this.availableUpgrade !== undefined && !semver.gt(version, this.availableUpgrade)) {
2984
+ return
2985
+ }
2986
+ this.availableUpgrade = version
2987
+ void this.appendStatus({
2988
+ actor: DISPATCHER,
2989
+ type: 'dispatcher.upgrade-available',
2990
+ payload: { run: this.runId, version },
2991
+ }).catch(() => {
2992
+ // Release discovery and publication are a silent courtesy.
2993
+ })
2994
+ this.syncModelControls()
2995
+ this.paint()
2996
+ },
2997
+ })
2998
+ } catch {
2999
+ // Timer/probe setup is the same silent courtesy as the check itself.
3000
+ }
3001
+ }
3002
+
3003
+ private startRendering(): void {
3004
+ if (!this.dashboard || this.timer !== undefined) return
3005
+ this.acceptingRenderPolls = true
3006
+ const tick = (): void => {
3007
+ if (!this.acceptingRenderPolls || this.rendering) return // fenced, no overlap
3008
+ this.rendering = true
3009
+ let handled!: Promise<void>
3010
+ handled = this.renderOnce()
3011
+ .catch((error: unknown) => {
3012
+ // A transient store error must never kill dispatch — the dashboard
3013
+ // is a view, and a view that throws is a bug in the view.
3014
+ this.warn(
3015
+ `dashboard render failed: ${error instanceof Error ? error.message : String(error)}`,
3016
+ )
3017
+ })
3018
+ .finally(() => {
3019
+ // Identity matters if lifecycle code ever stops and restarts polling:
3020
+ // an older completion must not clear a replacement poll's guard.
3021
+ if (this.renderInFlight !== handled) return
3022
+ this.renderInFlight = undefined
3023
+ this.rendering = false
3024
+ })
3025
+ this.renderInFlight = handled
3026
+ }
3027
+ this.timer = setInterval(tick, DASHBOARD_POLL_MS)
3028
+ // Never hold the process open for a redraw.
3029
+ this.timer.unref?.()
3030
+ // Watch mode only: a second, cheap timer repaints the cached model so a
3031
+ // running step's elapsed advances ~1×/s decoupled from the store poll
3032
+ // (AC 8). `--once` renders a single snapshot per state, so it gets no ticker.
3033
+ if (this.opts.once !== true) {
3034
+ this.tickTimer = setInterval(() => this.paint(), DASHBOARD_TICK_MS)
3035
+ this.tickTimer.unref?.()
3036
+ }
3037
+ tick()
3038
+ }
3039
+
3040
+ private async stopRendering(): Promise<void> {
3041
+ // Clear both timer sources before yielding. The boolean also fences a timer
3042
+ // callback that was already queued when clearInterval ran.
3043
+ if (this.timer !== undefined) clearInterval(this.timer)
3044
+ this.timer = undefined
3045
+ if (this.tickTimer !== undefined) clearInterval(this.tickTimer)
3046
+ this.tickTimer = undefined
3047
+ this.acceptingRenderPolls = false
3048
+
3049
+ // The poll owns model/controller mutation as well as terminal painting, so
3050
+ // join it rather than merely suppressing a late LiveRegion.update(). Its
3051
+ // normal rejection is warning-handled above; the catch keeps teardown safe
3052
+ // even if reporting that warning itself throws.
3053
+ const inFlight = this.renderInFlight
3054
+ if (inFlight !== undefined) {
3055
+ try {
3056
+ await inFlight
3057
+ } catch {
3058
+ // Dashboard rendering remains best-effort during teardown.
3059
+ }
3060
+ }
3061
+ }
3062
+
3063
+ /** Stop polling, paint the truth one last time, release the region. Every
3064
+ * exit path runs this — including SIGINT — or the operator's shell is left
3065
+ * without a cursor. */
3066
+ private async finishRendering(): Promise<void> {
3067
+ // Stop network/process discovery synchronously before any final dashboard
3068
+ // read. A probe that ignores cancellation is never joined. The private
3069
+ // kernel owns this courtesy even though it owns no terminal rendering.
3070
+ this.stopUpgradeNotice?.()
3071
+ this.stopUpgradeNotice = undefined
3072
+ if (!this.dashboard) return
3073
+ // No new keys or polls may begin once teardown starts. Already queued
3074
+ // actions finish before the final truth is painted and raw mode/cursor are
3075
+ // considered released.
3076
+ try {
3077
+ try {
3078
+ this.stopInput()
3079
+ } catch (error) {
3080
+ // Cursor restoration must not be skipped just because stdin restoration
3081
+ // itself failed. Keep the failure visible in the final warning row when
3082
+ // the presentation seam is still usable.
3083
+ try {
3084
+ this.warn(
3085
+ `dashboard input cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
3086
+ )
3087
+ } catch {
3088
+ // Rendering that warning is best-effort too.
3089
+ }
3090
+ }
3091
+ await this.stopRendering()
3092
+ await this.operationTail
3093
+ try {
3094
+ await this.renderOnce()
3095
+ } catch {
3096
+ // Best-effort: a failed final frame must not mask the run's outcome.
3097
+ }
3098
+ } finally {
3099
+ // Cursor/normal-screen restoration is unconditional once dashboard
3100
+ // teardown begins, including poll and final-read failures.
3101
+ this.region?.finish()
3102
+ }
3103
+ }
3104
+
3105
+ // ── Repository supervisor lease (§12 serialization) ────────────────────────
3106
+
3107
+ /** Claim (or renew) the repository lease for this invocation and start the
3108
+ * heartbeat timer. False means another supervisor holds it: the caller
3109
+ * yields — no claims, launches, or publications. */
3110
+ private async ensureRepoLease(holder: string): Promise<boolean> {
3111
+ if (this.superseded) return false
3112
+ const claimed = await this.wiring.store
3113
+ .claimRepoLease(this.repoIdentity, holder, REPO_LEASE_TTL_MS)
3114
+ .catch(() => false)
3115
+ if (!claimed) {
3116
+ if (this.repoLeaseHolder !== undefined) {
3117
+ // We held the lease and the store says it is gone: a peer took over.
3118
+ this.superseded = true
3119
+ }
3120
+ return false
3121
+ }
3122
+ if (this.repoLeaseHolder === undefined) {
3123
+ this.repoLeaseHolder = holder
3124
+ this.startRepoLeaseHeartbeat(holder)
3125
+ }
3126
+ return true
3127
+ }
3128
+
3129
+ private startRepoLeaseHeartbeat(holder: string): void {
3130
+ if (this.repoLeaseHeartbeat !== undefined) return
3131
+ this.repoLeaseHeartbeat = setInterval(() => {
3132
+ this.wiring.store.heartbeatRepo(this.repoIdentity, holder).then(
3133
+ (alive) => {
3134
+ if (alive) return
3135
+ // A peer holds the repository now. Exit watch mode cleanly after
3136
+ // the same teardown every stop takes (detach remote / reap local,
3137
+ // signal continuations, release the lease).
3138
+ this.superseded = true
3139
+ this.inputStop.abort()
3140
+ },
3141
+ () => {
3142
+ // Store unreachable: retry on the next beat; a later false result
3143
+ // proves takeover.
3144
+ },
3145
+ )
3146
+ }, REPO_LEASE_HEARTBEAT_MS)
3147
+ this.repoLeaseHeartbeat.unref?.()
3148
+ }
3149
+
3150
+ private stopRepoLeaseHeartbeat(): void {
3151
+ if (this.repoLeaseHeartbeat !== undefined) clearInterval(this.repoLeaseHeartbeat)
3152
+ this.repoLeaseHeartbeat = undefined
3153
+ }
3154
+
3155
+ /** Durable record that this invocation found the repository held and
3156
+ * performed no claims, launches, or publications. The holder is resolved
3157
+ * from the repository record. Recorded once per yield period, not once per
3158
+ * retrying tick. */
3159
+ private async recordTickYielded(): Promise<void> {
3160
+ try {
3161
+ const record = await this.wiring.store.getRepo(this.repoIdentity).catch(() => null)
3162
+ const holder = record?.lease?.holder
3163
+ if (holder === undefined || this.yieldedTo === holder) return
3164
+ this.yieldedTo = holder
3165
+ await this.wiring.store.appendRepo(this.repoIdentity, {
3166
+ actor: DISPATCHER,
3167
+ type: 'dispatcher.tick-yielded',
3168
+ payload: {
3169
+ run: this.runId,
3170
+ holder,
3171
+ },
3172
+ })
3173
+ } catch {
3174
+ // Presentation-only durable evidence; the yield behavior is unchanged.
3175
+ }
3176
+ }
3177
+
3178
+ private async releaseRepoLease(): Promise<void> {
3179
+ this.stopRepoLeaseHeartbeat()
3180
+ const holder = this.repoLeaseHolder
3181
+ this.repoLeaseHolder = undefined
3182
+ if (holder === undefined) return
3183
+ try {
3184
+ await this.wiring.store.releaseRepoLease(this.repoIdentity, holder)
3185
+ } catch {
3186
+ // Expiry fences an ambiguous release.
3187
+ }
3188
+ }
3189
+
3190
+ async run(): Promise<void> {
3191
+ // Before the `--once` branch, so both modes report it exactly once.
3192
+ this.reportRoleDiagnostics()
3193
+ // Repository supervisor lease (§12): two invocations for one repository
3194
+ // never both act. A losing invocation records the yield and performs no
3195
+ // claims, launches, or publications. The holder is the invocation's
3196
+ // journaling run id — the same string every journal payload carries.
3197
+ const holder = this.runId
3198
+ if (!(await this.ensureRepoLease(holder))) {
3199
+ await this.recordTickYielded()
3200
+ const peer = (await this.wiring.store.getRepo(this.repoIdentity).catch(() => null))?.lease
3201
+ ?.holder
3202
+ const message = `tick yielded: repository held by ${peer ?? 'another invocation'}`
3203
+ this.warn(message)
3204
+ if (this.opts.once === true) return
3205
+ // Watch mode stays alive and retries the claim at every tick.
3206
+ }
3207
+ const capacity = this.currentConfig().config.capacity
3208
+ if (this.opts.once) {
3209
+ if (!this.dashboard) {
3210
+ this.say(`ab dispatch — one pass over ${this.opts.targetRepo} (capacity ${capacity})`)
3211
+ }
3212
+ // Render BEFORE the tick and until the drain finishes, so the operator
3213
+ // watches the initial pass's builds change state while they run. The
3214
+ // render loop only reads: `--once` still calls tick() exactly ONCE, so
3215
+ // it never claims a ticket that becomes Ready mid-drain.
3216
+ try {
3217
+ this.startInput()
3218
+ this.startRendering()
3219
+ // Invocation bound: an already-passed deadline skips the tick and the
3220
+ // drain entirely — teardown in `finally` still runs, so the repository
3221
+ // lease and any provisioning state are released for the next
3222
+ // invocation.
3223
+ if (this.pastDeadline()) return
3224
+ const initial = await this.dispatcherTick(true)
3225
+ const initialPrinted = this.printReport(initial, false)
3226
+ await this.drainInFlight()
3227
+ const settledReports = await this.publishSettlementReports()
3228
+ let settledPrinted = false
3229
+ for (const report of settledReports) {
3230
+ settledPrinted = this.printReport(report, false) || settledPrinted
3231
+ }
3232
+ if (!initialPrinted && !settledPrinted) {
3233
+ this.printReport(emptyTickReport())
3234
+ }
3235
+ } finally {
3236
+ await this.stopBuildExecutions()
3237
+ await this.stopHostedHarvest()
3238
+ // Signal provisioning continuations and release their leases before
3239
+ // the repository lease, so the next supervisor adopts immediately.
3240
+ await this.dispatcher.stopProvisioning()
3241
+ await this.finishRendering()
3242
+ await this.releaseRepoLease()
3243
+ }
3244
+ return
3245
+ }
3246
+
3247
+ const intervalMs = this.opts.intervalMs ?? DEFAULT_INTERVAL_MS
3248
+ const sleep =
3249
+ this.opts.sleep ??
3250
+ ((ms: number) => interruptibleSleep(ms, [this.opts.signal, this.inputStop.signal]))
3251
+ if (!this.dashboard) {
3252
+ this.say(
3253
+ `ab dispatch — watching ${this.opts.targetRepo} (capacity ${capacity}, ` +
3254
+ `every ${Math.round(intervalMs / 1000)}s) — Ctrl-C to stop`,
3255
+ )
3256
+ }
3257
+ try {
3258
+ this.startInput()
3259
+ this.startRendering()
3260
+ this.startUpgradeChecks()
3261
+ let startup = true
3262
+ while (!this.stopped && !this.superseded) {
3263
+ try {
3264
+ const report = await this.dispatcherTick(startup)
3265
+ this.printReport(report, this.harvestInFlight === undefined)
3266
+ startup = false
3267
+ } catch (error) {
3268
+ const message = error instanceof Error ? error.message : String(error)
3269
+ await this.appendStatus({
3270
+ actor: DISPATCHER,
3271
+ type: 'dispatcher.tick-failed',
3272
+ payload: { run: this.runId, error: message },
3273
+ })
3274
+ this.warn(`tick failed: ${message}`)
3275
+ }
3276
+ if (this.stopped || this.superseded) break
3277
+ await sleep(intervalMs)
3278
+ }
3279
+ if (this.superseded) {
3280
+ this.warn('dispatcher superseded by another invocation — detaching')
3281
+ }
3282
+ } finally {
3283
+ await this.stopBuildExecutions()
3284
+ await this.stopHostedHarvest()
3285
+ // Signal provisioning continuations and release their leases before
3286
+ // the repository lease, so the next supervisor adopts immediately.
3287
+ await this.dispatcher.stopProvisioning()
3288
+ // Stop input and join teardown work first so every result that becomes
3289
+ // reportable at that boundary is included. Active Harvest work is still
3290
+ // deliberately not awaited in watch mode.
3291
+ await this.finishRendering()
3292
+ for (const report of await this.publishSettlementReports()) {
3293
+ this.printReport(report, false)
3294
+ }
3295
+ await this.releaseRepoLease()
3296
+ }
3297
+ // The finished interactive frame stays on screen; never append a late line
3298
+ // beneath it. Plain mode retains its historical shutdown line.
3299
+ if (!this.dashboard) this.say('ab dispatch stopped')
3300
+ }
3301
+ }
3302
+
3303
+ /** Origin-mode startup: fetch autobuild.toml from the repository through the
3304
+ * forge — the default branch first (baseBranch is itself config), then the
3305
+ * configured base branch when one is set. The final parse is the startup
3306
+ * Config; both reads share one memoized GitHubForge. */
3307
+ async function fetchOriginModeConfig(
3308
+ opts: DispatchOpts,
3309
+ transport?: GitHubRequest,
3310
+ ): Promise<{
3311
+ content: string
3312
+ config: Config
3313
+ }> {
3314
+ const repository = normalizeGitRemoteUrl(opts.repository!)
3315
+ const label = `${repository}/autobuild.toml`
3316
+ const forge = new GitHubForge({
3317
+ env: opts.env,
3318
+ repository,
3319
+ exec: opts.exec,
3320
+ ...(transport !== undefined ? { transport } : {}),
3321
+ })
3322
+ const readFile = forge.readFile
3323
+ if (readFile === undefined) {
3324
+ throw new Error('the github forge does not implement readFile — this is a wiring bug')
3325
+ }
3326
+ const content = await readFile.call(forge, 'autobuild.toml').catch((error: unknown) => {
3327
+ if (error instanceof GitHubApiError && error.status === 404) {
3328
+ throw new Error(
3329
+ `${label}: not found on the repository's default branch — origin-mode dispatch ` +
3330
+ 'reads autobuild.toml from the forge (SPEC §8.2, §16.1)',
3331
+ )
3332
+ }
3333
+ throw error
3334
+ })
3335
+ const parsed = parseConfig(content, label)
3336
+ if (parsed.baseBranch === undefined || parsed.baseBranch === '') {
3337
+ return { content, config: parsed }
3338
+ }
3339
+ const branchContent = await readFile
3340
+ .call(forge, 'autobuild.toml', parsed.baseBranch)
3341
+ .catch((error: unknown) => {
3342
+ if (error instanceof GitHubApiError && error.status === 404) {
3343
+ throw new Error(
3344
+ `${label}: not found at configured baseBranch ${JSON.stringify(parsed.baseBranch)} — ` +
3345
+ 'commit autobuild.toml to the base branch before dispatching in origin mode',
3346
+ )
3347
+ }
3348
+ throw error
3349
+ })
3350
+ const branchLabel = `${label}@${parsed.baseBranch}`
3351
+ return { content: branchContent, config: parseConfig(branchContent, branchLabel) }
3352
+ }
3353
+
3354
+ /** Loopback hosts where a plain-http store origin is accepted: it names the
3355
+ * operator's own machine (local development, integration harnesses), never a
3356
+ * network hop that would carry the bearer token in the clear. */
3357
+ const LOOPBACK_STORE_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]'])
3358
+
3359
+ function acceptableOriginModeStore(storeRef: string): boolean {
3360
+ if (/^https:\/\//i.test(storeRef)) return true
3361
+ if (!/^http:\/\//i.test(storeRef)) return false
3362
+ try {
3363
+ return LOOPBACK_STORE_HOSTS.has(new URL(storeRef).hostname)
3364
+ } catch {
3365
+ return false
3366
+ }
3367
+ }
3368
+
3369
+ /** Origin-mode repo state: the identity is the normalized origin; local
3370
+ * scratch (state root, worktree root) lives under a per-origin temp directory
3371
+ * and the store MUST be remote HTTPS — except a loopback http origin, which
3372
+ * names the operator's own machine (local development, integration harnesses).
3373
+ * Requirements are validated here so a misconfigured origin-mode launch fails
3374
+ * before any side effect. */
3375
+ async function resolveOriginModeState(opts: DispatchOpts): Promise<RepoStatePaths> {
3376
+ const identity = normalizeGitRemoteUrl(opts.repository!)
3377
+ const selectedStore = opts.storeRef ?? opts.env.AB_STORE
3378
+ if (selectedStore === undefined || !acceptableOriginModeStore(selectedStore)) {
3379
+ throw new Error(
3380
+ 'origin-mode dispatch requires an HTTPS BuildStore — set AB_STORE (or --store) to the hosted Store URL',
3381
+ )
3382
+ }
3383
+ if (opts.env.AB_TOKEN === undefined || opts.env.AB_TOKEN === '') {
3384
+ throw new Error('origin-mode dispatch requires AB_TOKEN for the remote Store')
3385
+ }
3386
+ // Checkout-less hosts (serverless functions, sandboxes) have no gh CLI and
3387
+ // no keyring, and the only workspace provider origin mode can run —
3388
+ // vercel-sandbox — injects this same credential for publication, so origin
3389
+ // mode requires an exported token: the gh fallback is a checkout-mode
3390
+ // convenience only.
3391
+ if (githubTokenFromEnv(opts.env) === undefined) {
3392
+ throw new Error('origin-mode dispatch requires GITHUB_TOKEN or GH_TOKEN for the GitHub API')
3393
+ }
3394
+ const scratch = join(
3395
+ tmpdir(),
3396
+ 'autobuild',
3397
+ createHash('sha256').update(identity).digest('hex').slice(0, 16),
3398
+ )
3399
+ await mkdir(scratch, { recursive: true })
3400
+ return resolveRepoStatePaths({
3401
+ repo: identity,
3402
+ checkout: scratch,
3403
+ ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
3404
+ ...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
3405
+ })
3406
+ }
3407
+
3408
+ /**
3409
+ * Entry point (§8.2). Loads the repo's config — whose required [tickets]
3410
+ * table selects the TicketSource and names its ready state. A file source with
3411
+ * no `dir` still defaults to `.autobuild/tickets` (§13). Then wires the ports
3412
+ * and runs until one pass finishes (`--once`) or `opts.signal` aborts (SIGINT).
3413
+ *
3414
+ * Origin mode (AUT-302): `opts.repository` (CLI `--repository`, env
3415
+ * `AB_REPOSITORY`) serves a repository with NO local checkout. The startup
3416
+ * config is fetched from the forge (default branch, then the configured
3417
+ * `baseBranch`), the store must be remote HTTPS, and the identity — not a
3418
+ * path — is the served repository.
3419
+ */
3420
+ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3421
+ if (opts.wire !== undefined && opts.nonStoreWire !== undefined) {
3422
+ throw new Error('dispatch wire and nonStoreWire are mutually exclusive')
3423
+ }
3424
+ const state =
3425
+ opts.repository !== undefined
3426
+ ? await resolveOriginModeState(opts)
3427
+ : await resolveRepoState({
3428
+ targetRepo: opts.targetRepo,
3429
+ exec: opts.exec,
3430
+ ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
3431
+ ...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
3432
+ })
3433
+ // Normalize once, then use these exact values for config/tickets/repository
3434
+ // identity, store wiring, worktrees, and every session's AB_STORE.
3435
+ // `targetRepo` stays the FILESYSTEM checkout (or origin-mode scratch root);
3436
+ // `repo` is the store-keyed identity.
3437
+ const resolvedOpts: DispatchOpts = {
3438
+ ...opts,
3439
+ targetRepo: state.checkout,
3440
+ storeRef: state.storeRef,
3441
+ repo: opts.repo ?? state.repo,
3442
+ }
3443
+
3444
+ // Interactive production dispatch is two programs. Resolve/open only the
3445
+ // Store in the terminal owner; config, plugins, adapters, runners, and ticket
3446
+ // I/O are constructed exclusively by the supervised private child. Injected
3447
+ // wiring remains the direct test/embedding seam, and every plain/non-TTY
3448
+ // invocation stays on the byte-compatible in-process kernel path below.
3449
+ if (
3450
+ resolvedOpts.kernelRunId === undefined &&
3451
+ resolvedOpts.wire === undefined &&
3452
+ resolvedOpts.nonStoreWire === undefined &&
3453
+ resolvedOpts.plain !== true &&
3454
+ resolvedOpts.terminal?.interactive === true &&
3455
+ resolvedOpts.input !== undefined
3456
+ ) {
3457
+ if (opts.repository !== undefined) {
3458
+ throw new Error(
3459
+ 'origin-mode dispatch (--repository) is a serverless workhorse and cannot run the ' +
3460
+ 'interactive dashboard: pass --plain or run it without a TTY',
3461
+ )
3462
+ }
3463
+ const opened = openStoreForRepoState(state, { env: resolvedOpts.env })
3464
+ try {
3465
+ const frontend = new DispatchFrontend({
3466
+ repo: state.repo,
3467
+ checkout: state.checkout,
3468
+ storeRef: opened.storeRef,
3469
+ store: opened.store,
3470
+ env: resolvedOpts.env,
3471
+ terminal: resolvedOpts.terminal,
3472
+ input: resolvedOpts.input,
3473
+ once: resolvedOpts.once === true,
3474
+ ...(resolvedOpts.intervalMs !== undefined ? { intervalMs: resolvedOpts.intervalMs } : {}),
3475
+ ...(resolvedOpts.intake !== undefined ? { intake: resolvedOpts.intake } : {}),
3476
+ ...(resolvedOpts.defaultAutoMerge !== undefined
3477
+ ? { defaultAutoMerge: resolvedOpts.defaultAutoMerge }
3478
+ : {}),
3479
+ ...(resolvedOpts.signal !== undefined ? { signal: resolvedOpts.signal } : {}),
3480
+ ...(resolvedOpts.resolveDashboardRenderer !== undefined
3481
+ ? { resolveDashboardRenderer: resolvedOpts.resolveDashboardRenderer }
3482
+ : {}),
3483
+ })
3484
+ await frontend.run()
3485
+ } finally {
3486
+ await opened.store.close()
3487
+ }
3488
+ return
3489
+ }
3490
+
3491
+ const configPath = join(resolvedOpts.targetRepo, 'autobuild.toml')
3492
+ let configContent: string
3493
+ let config: Config
3494
+ if (opts.repository !== undefined) {
3495
+ // Origin mode startup config: the base branch's autobuild.toml, fetched
3496
+ // through the forge (default branch first — baseBranch itself is config).
3497
+ const fetched = await fetchOriginModeConfig(opts, opts.originConfigTransport)
3498
+ configContent = fetched.content
3499
+ config = fetched.config
3500
+ if (config.forge !== 'github') {
3501
+ throw new Error(
3502
+ `origin-mode dispatch requires the builtin github forge, but the fetched autobuild.toml selects ${JSON.stringify(config.forge)}`,
3503
+ )
3504
+ }
3505
+ if (config.plugins !== undefined && config.plugins.length > 0) {
3506
+ throw new Error(
3507
+ 'origin-mode dispatch cannot load configured plugins: plugin code is checkout-relative ' +
3508
+ 'and there is no checkout. Remove [plugins] from the base branch autobuild.toml or run from a checkout',
3509
+ )
3510
+ }
3511
+ } else {
3512
+ try {
3513
+ configContent = await Bun.file(configPath).text()
3514
+ config = parseConfig(configContent, configPath)
3515
+ } catch (error) {
3516
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
3517
+ throw new Error(
3518
+ `${configPath}: not found — 'ab dispatch' reads autobuild.toml from ` +
3519
+ 'the resolved Git main checkout (SPEC §8.2, §16.1)',
3520
+ )
3521
+ }
3522
+ throw error
3523
+ }
3524
+ }
3525
+ // Configured plugin code is trusted like configured shell commands, but it
3526
+ // must resolve, evaluate, validate, and register before production wiring
3527
+ // opens a store, claims a ticket, or launches a runner.
3528
+ const plugins = await loadPlugins(config.plugins, resolvedOpts.targetRepo)
3529
+ // Validate the selector against the complete catalog before either custom
3530
+ // wiring or production wiring can open state or perform side effects.
3531
+ resolveForgeRegistration(config.forge, plugins)
3532
+ let wired: DispatchWiring
3533
+ if (resolvedOpts.nonStoreWire !== undefined) {
3534
+ const opened = openStoreForRepoState(state, { env: resolvedOpts.env })
3535
+ const ports = await resolvedOpts.nonStoreWire(config, resolvedOpts, state, plugins)
3536
+ wired = {
3537
+ ...ports,
3538
+ store: opened.store,
3539
+ storeRef: opened.storeRef,
3540
+ ...(opened.token !== undefined ? { token: opened.token } : {}),
3541
+ }
3542
+ } else {
3543
+ const wire = resolvedOpts.wire ?? defaultWire
3544
+ wired = await wire(config, resolvedOpts, state, plugins)
3545
+ }
3546
+ const runtimes = await materializePluginRuntimes(wired.runtimes, plugins, {
3547
+ // Filesystem root for plugin runtime code — never the store identity.
3548
+ repoRoot: state.checkout,
3549
+ env: resolvedOpts.env,
3550
+ })
3551
+ const wiring: DispatchWiring = {
3552
+ ...wired,
3553
+ runtimes,
3554
+ plugins: wired.plugins ?? plugins,
3555
+ }
3556
+ // The invocation's durable journaling identity — the single synthesis point
3557
+ // for this invocation. Kernel children keep their kernelRunId; every plain
3558
+ // invocation mints `<host>-dispatch-<id>` exactly the format the
3559
+ // repository-supervisor lease holder already uses. Minted once: `wiring.ids`
3560
+ // is `randomIds()`, so a getter re-evaluating it would re-mint per access.
3561
+ // Everything below (startup deposits, the loop's journal payloads, both
3562
+ // lease sites) carries this one value.
3563
+ const runId = resolvedOpts.kernelRunId ?? `${hostname()}-dispatch-${wiring.ids('inst')}`
3564
+ resolvedOpts.runId = runId
3565
+ // Construction eagerly validates every startup role before repository
3566
+ // settings or runner work can mutate durable state. The publisher is used
3567
+ // only by later watch refreshes.
3568
+ const liveConfig = new LiveConfig(
3569
+ configPath,
3570
+ config,
3571
+ configContent,
3572
+ wiring.runtimes,
3573
+ async ({ content, effectiveConfig, restartRequired, effectiveChanged }) => {
3574
+ await wiring.store.appendRepoWithArtifacts(
3575
+ state.repo,
3576
+ [
3577
+ {
3578
+ kind: DISPATCHER_CONFIG_ARTIFACT,
3579
+ content,
3580
+ metadata: { restartRequired: [...restartRequired], effectiveChanged },
3581
+ },
3582
+ {
3583
+ kind: DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT,
3584
+ content: effectiveConfigContent(effectiveConfig),
3585
+ metadata: { run: runId, effectiveChanged },
3586
+ },
3587
+ ],
3588
+ (deposited) => {
3589
+ const artifact = deposited[0]
3590
+ const effectiveArtifact = deposited[1]
3591
+ if (artifact === undefined || effectiveArtifact === undefined) {
3592
+ throw new Error('config reload deposit returned incomplete artifacts')
3593
+ }
3594
+ return {
3595
+ actor: DISPATCHER,
3596
+ type: 'dispatcher.config-reloaded',
3597
+ payload: {
3598
+ artifact: { kind: artifact.kind, rev: artifact.revision },
3599
+ restartRequired: [...restartRequired],
3600
+ effectiveChanged,
3601
+ run: runId,
3602
+ effectiveConfig: {
3603
+ kind: effectiveArtifact.kind,
3604
+ rev: effectiveArtifact.revision,
3605
+ },
3606
+ roleWarnings: roleKeyWarnings(effectiveConfig),
3607
+ },
3608
+ }
3609
+ },
3610
+ )
3611
+ wiring.updateRuntimeReferences?.(effectiveConfig)
3612
+ },
3613
+ )
3614
+
3615
+ // Launch flags are durable repository setters. Omission writes nothing, so
3616
+ // another dispatcher cannot clobber the latest operator choice with a value
3617
+ // it inferred at startup. Fresh-repository fallbacks live in the reducer.
3618
+ await wiring.store.ensureRepo(state.repo)
3619
+ const actor = humanActor(buildControlUser(resolvedOpts.env))
3620
+ if (resolvedOpts.intake !== undefined) {
3621
+ await wiring.store.appendRepo(state.repo, {
3622
+ actor,
3623
+ type: 'dispatcher.intake-set',
3624
+ payload: { enabled: resolvedOpts.intake, run: runId },
3625
+ })
3626
+ }
3627
+ if (resolvedOpts.defaultAutoMerge !== undefined) {
3628
+ await wiring.store.appendRepo(state.repo, {
3629
+ actor,
3630
+ type: 'dispatcher.auto-merge-default-set',
3631
+ payload: { enabled: resolvedOpts.defaultAutoMerge, run: runId },
3632
+ })
3633
+ }
3634
+
3635
+ await wiring.store.appendRepoWithArtifacts(
3636
+ state.repo,
3637
+ [
3638
+ {
3639
+ kind: DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT,
3640
+ content: effectiveConfigContent(config),
3641
+ metadata: { run: runId, revision: 0 },
3642
+ },
3643
+ ],
3644
+ (deposited) => {
3645
+ const artifact = deposited[0]
3646
+ if (artifact === undefined) throw new Error('startup config deposit returned no artifact')
3647
+ return {
3648
+ actor: DISPATCHER,
3649
+ type: 'dispatcher.run-started',
3650
+ payload: {
3651
+ run: runId,
3652
+ pid: process.pid,
3653
+ effectiveConfig: { kind: artifact.kind, rev: artifact.revision },
3654
+ roleWarnings: roleKeyWarnings(config),
3655
+ },
3656
+ }
3657
+ },
3658
+ )
3659
+
3660
+ const loop = new DispatchLoop(liveConfig, wiring, resolvedOpts)
3661
+ try {
3662
+ await loop.run()
3663
+ await wiring.store.appendRepo(state.repo, {
3664
+ actor: DISPATCHER,
3665
+ type: 'dispatcher.run-stopped',
3666
+ payload: {
3667
+ run: runId,
3668
+ outcome: 'normal',
3669
+ exitCode: 0,
3670
+ ...(loop.supersededByPeer ? { reason: 'superseded' as const } : {}),
3671
+ },
3672
+ })
3673
+ } catch (error) {
3674
+ try {
3675
+ await wiring.store.appendRepo(state.repo, {
3676
+ actor: DISPATCHER,
3677
+ type: 'dispatcher.run-stopped',
3678
+ payload: {
3679
+ run: runId,
3680
+ outcome: 'abnormal',
3681
+ exitCode: 1,
3682
+ error: error instanceof Error ? error.message : String(error),
3683
+ },
3684
+ })
3685
+ } catch {
3686
+ // Preserve the kernel failure; the supervising frontend records exit.
3687
+ }
3688
+ throw error
3689
+ } finally {
3690
+ if (resolvedOpts.wire === undefined) await wiring.store.close()
3691
+ }
3692
+ }