@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,823 @@
1
+ /**
2
+ * Pure reduction of the repository-scoped harvest journal. As with builds,
3
+ * snapshots are never authoritative: dashboard state, resumption boundaries,
4
+ * claims, and the dedup ledger are all re-derived from append-only facts.
5
+ */
6
+ import type { RepositoryEvent } from '../events/repository'
7
+ import type { Actor } from '../events/envelope'
8
+ import type { ArtifactRef, Finding, TicketRef } from '../ontology'
9
+ import {
10
+ occurrenceKey,
11
+ type HarvestBlockerProvenance,
12
+ type HarvestDisposition,
13
+ type HarvestPendingProposal,
14
+ type HarvestStep,
15
+ type HarvestTrigger,
16
+ type OccurrenceKey,
17
+ } from '../harvest/schema'
18
+
19
+ export interface HarvestStepOccurrence {
20
+ step: HarvestStep
21
+ round?: number
22
+ startedSeq: number
23
+ startedAt: string
24
+ completedSeq?: number
25
+ completedAt?: string
26
+ outcome?: 'completed' | 'approve' | 'revise' | 'escalate' | 'failed'
27
+ artifact?: ArtifactRef
28
+ detail?: string
29
+ }
30
+
31
+ export interface HarvestReviewRound {
32
+ round: number
33
+ verdict: 'approve' | 'revise' | 'escalate'
34
+ findings: Finding[]
35
+ artifact: ArtifactRef
36
+ reason?: string
37
+ seq: number
38
+ }
39
+
40
+ export interface HarvestProposalReservation {
41
+ proposalKey: string
42
+ id: string
43
+ seq: number
44
+ }
45
+
46
+ export interface HarvestRecoveryRequest {
47
+ attempt: number
48
+ limit: number
49
+ seq: number
50
+ requestedAt: string
51
+ acknowledgedSeq?: number
52
+ acknowledgedAt?: string
53
+ }
54
+
55
+ export interface HarvestRecoveryExhaustion {
56
+ step: HarvestStep
57
+ round?: number
58
+ error: string
59
+ attempts: number
60
+ limit: number
61
+ releasedObservations: OccurrenceKey[]
62
+ committedDispositions: HarvestDisposition[]
63
+ pendingProposals: HarvestPendingProposal[]
64
+ seq: number
65
+ at: string
66
+ attentionAcknowledgedSeq?: number
67
+ attentionAcknowledgedAt?: string
68
+ }
69
+
70
+ export interface HarvestRunState {
71
+ run: string
72
+ status: 'running' | 'completed' | 'escalated' | 'failed'
73
+ startedSeq: number
74
+ startedAt: string
75
+ observations: OccurrenceKey[]
76
+ scan: ArtifactRef
77
+ /** Absent only on runs started by historical pre-drift journals. */
78
+ trigger?: HarvestTrigger
79
+ steps: HarvestStepOccurrence[]
80
+ proposals: Array<{ round: number; artifact: ArtifactRef; seq: number }>
81
+ reviews: HarvestReviewRound[]
82
+ reservations: HarvestProposalReservation[]
83
+ filed: Array<{
84
+ proposalKey: string
85
+ ticket: TicketRef
86
+ blockers?: HarvestBlockerProvenance
87
+ seq: number
88
+ }>
89
+ dispositions: HarvestDisposition[]
90
+ report?: ArtifactRef
91
+ escalation?: {
92
+ source: 'agent' | 'stall' | 'policy'
93
+ reason: string
94
+ round?: number
95
+ }
96
+ failure?: {
97
+ step: HarvestStep
98
+ round?: number
99
+ attempt: number
100
+ error: string
101
+ willRetry: boolean
102
+ }
103
+ /** Repo seq of the failure fact that set `failure`; deleted with it, so it
104
+ * marks the recency of the currently unresolved failure. */
105
+ failureSeq?: number
106
+ /** Durable outer recoveries, independent of within-step attempt facts. */
107
+ recoveryRequests: HarvestRecoveryRequest[]
108
+ /** Present after the automatic recovery budget is atomically exhausted. */
109
+ recoveryExhaustion?: HarvestRecoveryExhaustion
110
+ terminalSeq?: number
111
+ terminalAt?: string
112
+ }
113
+
114
+ export interface HarvestLedgerEntry extends HarvestDisposition {
115
+ run: string
116
+ seq: number
117
+ }
118
+
119
+ /** An operator request not yet acknowledged by the harvest kernel. Requests
120
+ * are ordered repository facts; pause and resume countermand older opposing
121
+ * intent exactly as build commands do. */
122
+ export interface HarvestPendingCommand {
123
+ command: 'pause' | 'resume'
124
+ seq: number
125
+ actor: Actor
126
+ }
127
+
128
+ export interface HarvestState {
129
+ lastSeq: number
130
+ runs: HarvestRunState[]
131
+ latest?: HarvestRunState
132
+ /** Repository-wide gate, independent of any one run. It changes only on the
133
+ * kernel acknowledgement, never on the human request. */
134
+ paused: boolean
135
+ /** Boundary at which the current pause was acknowledged. Dashboard timing
136
+ * freezes here; absent whenever the gate is open. */
137
+ pausedSeq?: number
138
+ pausedAt?: string
139
+ /** Unacknowledged human commands in repository sequence order. */
140
+ pendingCommands: HarvestPendingCommand[]
141
+ /** Occurrences still owned by started runs; exhaustion may selectively
142
+ * release pending members while retaining committed ones. */
143
+ claimed: OccurrenceKey[]
144
+ /** Completed and recovery-exhausted committed disposition facts. */
145
+ ledger: HarvestLedgerEntry[]
146
+ }
147
+
148
+ export const DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS = 2
149
+
150
+ export type HarvestControlDecision =
151
+ | { kind: 'acknowledge'; command: 'pause' | 'resume' }
152
+ | { kind: 'request-recovery'; run: string; attempt: number; limit: number }
153
+ | { kind: 'exhaust-recovery'; run: string; attempts: number; limit: number }
154
+ | { kind: 'park' }
155
+ | { kind: 'proceed' }
156
+
157
+ function cloneRef(ref: ArtifactRef): ArtifactRef {
158
+ return { kind: ref.kind, rev: ref.rev }
159
+ }
160
+
161
+ function isOrdinaryParkedRun(run: HarvestRunState): boolean {
162
+ return run.status === 'failed' && run.recoveryExhaustion === undefined
163
+ }
164
+
165
+ function hasUnresolvedExhaustion(run: HarvestRunState): boolean {
166
+ return (
167
+ run.status === 'failed' &&
168
+ run.recoveryExhaustion !== undefined &&
169
+ run.recoveryExhaustion.attentionAcknowledgedSeq === undefined
170
+ )
171
+ }
172
+
173
+ function requireRun(
174
+ runs: Map<string, HarvestRunState>,
175
+ run: string,
176
+ event: RepositoryEvent,
177
+ ): HarvestRunState {
178
+ const state = runs.get(run)
179
+ if (!state) {
180
+ throw new Error(
181
+ `${event.type} at repo seq ${event.seq} references unknown harvest run "${run}"`,
182
+ )
183
+ }
184
+ return state
185
+ }
186
+
187
+ /** Total for valid journals; malformed cross-event references throw loudly so
188
+ * storage corruption cannot masquerade as an idle harvester. */
189
+ export function reduceHarvest(events: readonly RepositoryEvent[]): HarvestState {
190
+ const runs = new Map<string, HarvestRunState>()
191
+ const order: HarvestRunState[] = []
192
+ const claimed = new Map<string, { occurrence: OccurrenceKey; run: string }>()
193
+ const ledger: HarvestLedgerEntry[] = []
194
+ const pending: Record<HarvestPendingCommand['command'], HarvestPendingCommand[]> = {
195
+ pause: [],
196
+ resume: [],
197
+ }
198
+ let paused = false
199
+ let pausedSeq: number | undefined
200
+ let pausedAt: string | undefined
201
+ let lastSeq = 0
202
+
203
+ for (const event of events) {
204
+ lastSeq = Math.max(lastSeq, event.seq)
205
+ switch (event.type) {
206
+ case 'harvest.pause-requested':
207
+ pending.resume = []
208
+ pending.pause.push({
209
+ command: 'pause',
210
+ seq: event.seq,
211
+ actor: event.actor,
212
+ })
213
+ break
214
+ case 'harvest.resume-requested':
215
+ pending.pause = []
216
+ pending.resume.push({
217
+ command: 'resume',
218
+ seq: event.seq,
219
+ actor: event.actor,
220
+ })
221
+ break
222
+ case 'harvest.paused':
223
+ paused = true
224
+ pausedSeq = event.seq
225
+ pausedAt = event.ts
226
+ pending.pause = []
227
+ break
228
+ case 'harvest.resumed': {
229
+ const hadHumanResume = pending.resume.length > 0
230
+ paused = false
231
+ pausedSeq = undefined
232
+ pausedAt = undefined
233
+ pending.resume = []
234
+ for (const run of order) {
235
+ const exhaustion = run.recoveryExhaustion
236
+ if (exhaustion !== undefined) {
237
+ // Give-up is terminal. A human acknowledgement removes every
238
+ // outstanding repository attention barrier without resurrecting
239
+ // a run or changing its selectively retained claims.
240
+ if (hadHumanResume && exhaustion.attentionAcknowledgedSeq === undefined) {
241
+ exhaustion.attentionAcknowledgedSeq = event.seq
242
+ exhaustion.attentionAcknowledgedAt = event.ts
243
+ }
244
+ continue
245
+ }
246
+ if (!isOrdinaryParkedRun(run)) continue
247
+
248
+ const automatic = [...run.recoveryRequests]
249
+ .reverse()
250
+ .find((request) => request.acknowledgedSeq === undefined)
251
+ if (automatic !== undefined) {
252
+ automatic.acknowledgedSeq = event.seq
253
+ automatic.acknowledgedAt = event.ts
254
+ }
255
+ // One human acknowledgement is repository-wide and reopens every
256
+ // ordinary parked run. Without human intent, the unscoped resumed
257
+ // fact correlates only to runs with durable automatic requests.
258
+ if (hadHumanResume || automatic !== undefined) {
259
+ run.status = 'running'
260
+ delete run.failure
261
+ delete run.failureSeq
262
+ delete run.terminalSeq
263
+ delete run.terminalAt
264
+ }
265
+ }
266
+ break
267
+ }
268
+ case 'harvest.recovery-requested': {
269
+ const run = requireRun(runs, event.payload.run, event)
270
+ if (run.status !== 'failed' || run.recoveryExhaustion !== undefined) {
271
+ throw new Error(
272
+ `harvest.recovery-requested at repo seq ${event.seq} requires an ` +
273
+ `ordinary failed run; "${run.run}" is ${run.status}`,
274
+ )
275
+ }
276
+ if (run.recoveryRequests.some((request) => request.acknowledgedSeq === undefined)) {
277
+ throw new Error(
278
+ `harvest run "${run.run}" already has an unacknowledged automatic recovery request`,
279
+ )
280
+ }
281
+ const expectedAttempt = run.recoveryRequests.length + 1
282
+ if (event.payload.attempt !== expectedAttempt) {
283
+ throw new Error(
284
+ `harvest run "${run.run}" recovery attempt must be ${expectedAttempt}; ` +
285
+ `repo seq ${event.seq} recorded ${event.payload.attempt}`,
286
+ )
287
+ }
288
+ const appliedLimit = run.recoveryRequests[0]?.limit
289
+ if (appliedLimit !== undefined && event.payload.limit !== appliedLimit) {
290
+ throw new Error(
291
+ `harvest run "${run.run}" recovery limit was already applied as ` +
292
+ `${appliedLimit}; repo seq ${event.seq} cannot change it to ${event.payload.limit}`,
293
+ )
294
+ }
295
+ if (event.payload.attempt > event.payload.limit) {
296
+ throw new Error(
297
+ `harvest run "${run.run}" recovery attempt ${event.payload.attempt} ` +
298
+ `exceeds its applied limit ${event.payload.limit}`,
299
+ )
300
+ }
301
+ run.recoveryRequests.push({
302
+ attempt: event.payload.attempt,
303
+ limit: event.payload.limit,
304
+ seq: event.seq,
305
+ requestedAt: event.ts,
306
+ })
307
+ break
308
+ }
309
+ case 'harvest.recovery-exhausted': {
310
+ const run = requireRun(runs, event.payload.run, event)
311
+ if (run.recoveryExhaustion !== undefined) {
312
+ throw new Error(
313
+ `duplicate harvest.recovery-exhausted for run "${run.run}" at repo seq ${event.seq}`,
314
+ )
315
+ }
316
+ if (run.status !== 'failed' || run.failure === undefined) {
317
+ throw new Error(
318
+ `harvest.recovery-exhausted at repo seq ${event.seq} requires a failed run`,
319
+ )
320
+ }
321
+ if (
322
+ run.failure.step !== event.payload.step ||
323
+ run.failure.round !== event.payload.round ||
324
+ run.failure.error !== event.payload.error
325
+ ) {
326
+ throw new Error(
327
+ `harvest.recovery-exhausted at repo seq ${event.seq} does not match ` +
328
+ `the stopped boundary for run "${run.run}"`,
329
+ )
330
+ }
331
+ if (
332
+ event.payload.attempts !== run.recoveryRequests.length ||
333
+ event.payload.attempts !== event.payload.limit ||
334
+ run.recoveryRequests.some((request) => request.limit !== event.payload.limit)
335
+ ) {
336
+ throw new Error(
337
+ `harvest run "${run.run}" exhaustion must record its exact recovery ` +
338
+ `count and limit; got ${event.payload.attempts}/${event.payload.limit} ` +
339
+ `after ${run.recoveryRequests.length} requests`,
340
+ )
341
+ }
342
+ if (run.recoveryRequests.some((request) => request.acknowledgedSeq === undefined)) {
343
+ throw new Error(
344
+ `harvest run "${run.run}" cannot exhaust with an unacknowledged recovery request`,
345
+ )
346
+ }
347
+
348
+ const expected = new Set(run.observations.map(occurrenceKey))
349
+ const partition = new Set<string>()
350
+ const released = new Set<string>()
351
+ for (const disposition of event.payload.committedDispositions) {
352
+ const key = occurrenceKey(disposition.occurrence)
353
+ if (!expected.has(key) || partition.has(key)) {
354
+ throw new Error(
355
+ `harvest run "${run.run}" exhaustion has an invalid or duplicate committed occurrence ${key}`,
356
+ )
357
+ }
358
+ partition.add(key)
359
+ }
360
+ for (const occurrence of event.payload.releasedObservations) {
361
+ const key = occurrenceKey(occurrence)
362
+ if (!expected.has(key) || partition.has(key)) {
363
+ throw new Error(
364
+ `harvest run "${run.run}" exhaustion has an invalid or duplicate released occurrence ${key}`,
365
+ )
366
+ }
367
+ partition.add(key)
368
+ released.add(key)
369
+ }
370
+ if (partition.size !== expected.size) {
371
+ const missing = [...expected].filter((key) => !partition.has(key))
372
+ throw new Error(
373
+ `harvest run "${run.run}" exhaustion does not partition its snapshot; ` +
374
+ `missing ${missing.join(', ')}`,
375
+ )
376
+ }
377
+ const pendingKeys = new Set<string>()
378
+ const pendingOccurrences = new Set<string>()
379
+ for (const proposal of event.payload.pendingProposals) {
380
+ if (pendingKeys.has(proposal.proposalKey)) {
381
+ throw new Error(
382
+ `harvest run "${run.run}" exhaustion repeats pending proposal key "${proposal.proposalKey}"`,
383
+ )
384
+ }
385
+ pendingKeys.add(proposal.proposalKey)
386
+ for (const occurrence of proposal.observations) {
387
+ const key = occurrenceKey(occurrence)
388
+ if (!released.has(key) || pendingOccurrences.has(key)) {
389
+ throw new Error(
390
+ `harvest run "${run.run}" pending proposal "${proposal.proposalKey}" ` +
391
+ `does not uniquely describe released occurrence ${key}`,
392
+ )
393
+ }
394
+ pendingOccurrences.add(key)
395
+ }
396
+ }
397
+
398
+ for (const key of released) {
399
+ const owner = claimed.get(key)
400
+ if (owner?.run !== run.run) {
401
+ throw new Error(
402
+ `harvest run "${run.run}" cannot release ${key}; its claim owner is ` +
403
+ `${owner?.run ?? 'missing'}`,
404
+ )
405
+ }
406
+ claimed.delete(key)
407
+ }
408
+ run.dispositions = structuredClone(event.payload.committedDispositions)
409
+ for (const disposition of event.payload.committedDispositions) {
410
+ ledger.push({
411
+ ...structuredClone(disposition),
412
+ run: run.run,
413
+ seq: event.seq,
414
+ })
415
+ }
416
+ run.recoveryExhaustion = {
417
+ step: event.payload.step,
418
+ ...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
419
+ error: event.payload.error,
420
+ attempts: event.payload.attempts,
421
+ limit: event.payload.limit,
422
+ releasedObservations: structuredClone(event.payload.releasedObservations),
423
+ committedDispositions: structuredClone(event.payload.committedDispositions),
424
+ pendingProposals: structuredClone(event.payload.pendingProposals),
425
+ seq: event.seq,
426
+ at: event.ts,
427
+ }
428
+ run.terminalSeq = event.seq
429
+ run.terminalAt = event.ts
430
+ break
431
+ }
432
+ case 'harvest.started': {
433
+ if (runs.has(event.payload.run)) {
434
+ throw new Error(
435
+ `duplicate harvest.started for run "${event.payload.run}" at repo seq ${event.seq}`,
436
+ )
437
+ }
438
+ const run: HarvestRunState = {
439
+ run: event.payload.run,
440
+ status: 'running',
441
+ startedSeq: event.seq,
442
+ startedAt: event.ts,
443
+ observations: event.payload.observations.map((key) => ({ ...key })),
444
+ scan: cloneRef(event.payload.scan),
445
+ ...(event.payload.trigger !== undefined ? { trigger: event.payload.trigger } : {}),
446
+ steps: [],
447
+ proposals: [],
448
+ reviews: [],
449
+ reservations: [],
450
+ filed: [],
451
+ dispositions: [],
452
+ recoveryRequests: [],
453
+ }
454
+ runs.set(run.run, run)
455
+ order.push(run)
456
+ for (const key of run.observations) {
457
+ const id = occurrenceKey(key)
458
+ const existing = claimed.get(id)
459
+ if (existing !== undefined) {
460
+ throw new Error(
461
+ `harvest run "${run.run}" at repo seq ${event.seq} cannot claim ` +
462
+ `${id}; it is already claimed by "${existing.run}"`,
463
+ )
464
+ }
465
+ claimed.set(id, { occurrence: { ...key }, run: run.run })
466
+ }
467
+ break
468
+ }
469
+ case 'harvest.step.started': {
470
+ const run = requireRun(runs, event.payload.run, event)
471
+ run.steps.push({
472
+ step: event.payload.step,
473
+ ...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
474
+ startedSeq: event.seq,
475
+ startedAt: event.ts,
476
+ })
477
+ break
478
+ }
479
+ case 'harvest.step.completed': {
480
+ const run = requireRun(runs, event.payload.run, event)
481
+ const occurrence = [...run.steps]
482
+ .reverse()
483
+ .find(
484
+ (step) =>
485
+ step.step === event.payload.step &&
486
+ step.round === event.payload.round &&
487
+ step.completedSeq === undefined,
488
+ )
489
+ const target =
490
+ occurrence ??
491
+ ({
492
+ step: event.payload.step,
493
+ ...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
494
+ startedSeq: event.seq,
495
+ startedAt: event.ts,
496
+ } satisfies HarvestStepOccurrence)
497
+ if (occurrence === undefined) run.steps.push(target)
498
+ target.completedSeq = event.seq
499
+ target.completedAt = event.ts
500
+ target.outcome = event.payload.outcome
501
+ if (event.payload.artifact !== undefined) {
502
+ target.artifact = cloneRef(event.payload.artifact)
503
+ }
504
+ if (event.payload.detail !== undefined) target.detail = event.payload.detail
505
+ break
506
+ }
507
+ case 'harvest.proposals.submitted': {
508
+ const run = requireRun(runs, event.payload.run, event)
509
+ run.proposals.push({
510
+ round: event.payload.round,
511
+ artifact: cloneRef(event.payload.artifact),
512
+ seq: event.seq,
513
+ })
514
+ break
515
+ }
516
+ case 'harvest.review.verdict': {
517
+ const run = requireRun(runs, event.payload.run, event)
518
+ run.reviews.push({
519
+ round: event.payload.round,
520
+ verdict: event.payload.verdict,
521
+ findings: structuredClone(event.payload.findings),
522
+ artifact: cloneRef(event.payload.artifact),
523
+ ...(event.payload.reason !== undefined ? { reason: event.payload.reason } : {}),
524
+ seq: event.seq,
525
+ })
526
+ break
527
+ }
528
+ case 'harvest.proposal.id-reserved': {
529
+ const run = requireRun(runs, event.payload.run, event)
530
+ const normalizedId = event.payload.id.toLowerCase()
531
+ const existingKey = run.reservations.find(
532
+ (entry) => entry.proposalKey === event.payload.proposalKey,
533
+ )
534
+ if (existingKey !== undefined) {
535
+ if (existingKey.id.toLowerCase() !== normalizedId) {
536
+ throw new Error(
537
+ `harvest proposal key "${event.payload.proposalKey}" was already ` +
538
+ `reserved as "${existingKey.id}" at repo seq ${existingKey.seq}; ` +
539
+ `repo seq ${event.seq} cannot replace it with "${event.payload.id}"`,
540
+ )
541
+ }
542
+ // Exact replay is one logical reservation.
543
+ break
544
+ }
545
+ const existingId = run.reservations.find((entry) => entry.id.toLowerCase() === normalizedId)
546
+ if (existingId !== undefined) {
547
+ throw new Error(
548
+ `harvest reserved id "${event.payload.id}" already belongs to proposal ` +
549
+ `key "${existingId.proposalKey}" at repo seq ${existingId.seq}; ` +
550
+ `repo seq ${event.seq} cannot reuse it for "${event.payload.proposalKey}"`,
551
+ )
552
+ }
553
+ run.reservations.push({
554
+ proposalKey: event.payload.proposalKey,
555
+ id: event.payload.id,
556
+ seq: event.seq,
557
+ })
558
+ break
559
+ }
560
+ case 'harvest.proposal.filed': {
561
+ const run = requireRun(runs, event.payload.run, event)
562
+ // Idempotent projection: a retry may adopt the external ticket and see
563
+ // the already-journaled fact. Keep one entry per stable proposal key.
564
+ const existing = run.filed.find((entry) => entry.proposalKey === event.payload.proposalKey)
565
+ if (!existing) {
566
+ run.filed.push({
567
+ proposalKey: event.payload.proposalKey,
568
+ ticket: structuredClone(event.payload.ticket),
569
+ ...(event.payload.blockers !== undefined
570
+ ? { blockers: structuredClone(event.payload.blockers) }
571
+ : {}),
572
+ seq: event.seq,
573
+ })
574
+ }
575
+ break
576
+ }
577
+ case 'harvest.completed': {
578
+ const run = requireRun(runs, event.payload.run, event)
579
+ if (run.status !== 'running') break
580
+ run.status = 'completed'
581
+ run.dispositions = structuredClone(event.payload.dispositions)
582
+ run.report = cloneRef(event.payload.report)
583
+ run.terminalSeq = event.seq
584
+ run.terminalAt = event.ts
585
+ for (const disposition of run.dispositions) {
586
+ ledger.push({
587
+ ...structuredClone(disposition),
588
+ run: run.run,
589
+ seq: event.seq,
590
+ })
591
+ }
592
+ break
593
+ }
594
+ case 'harvest.escalated': {
595
+ const run = requireRun(runs, event.payload.run, event)
596
+ if (run.status !== 'running') break
597
+ run.status = 'escalated'
598
+ run.escalation = {
599
+ source: event.payload.source,
600
+ reason: event.payload.reason,
601
+ ...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
602
+ }
603
+ run.terminalSeq = event.seq
604
+ run.terminalAt = event.ts
605
+ break
606
+ }
607
+ case 'harvest.failed': {
608
+ const run = requireRun(runs, event.payload.run, event)
609
+ // Completion and deliberate escalation are irrevocable outcomes. A
610
+ // late infrastructure fact remains in the journal but cannot replace
611
+ // either with a recoverable error projection.
612
+ if (run.status !== 'running') break
613
+ run.failure = {
614
+ step: event.payload.step,
615
+ ...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
616
+ attempt: event.payload.attempt,
617
+ error: event.payload.error,
618
+ willRetry: event.payload.willRetry,
619
+ }
620
+ run.failureSeq = event.seq
621
+ if (!event.payload.willRetry) {
622
+ // Failed is a parked infrastructure stop, not a completed outcome.
623
+ // The claim and every workflow artifact remain owned by this run
624
+ // until an explicit harvest.resumed fact reopens it.
625
+ run.status = 'failed'
626
+ }
627
+ break
628
+ }
629
+ case 'harvest.session.started':
630
+ case 'harvest.session.ended':
631
+ // Session facts remain individually queryable but add no transition.
632
+ break
633
+ case 'dispatcher.intake-set':
634
+ case 'dispatcher.pause-set':
635
+ case 'dispatcher.auto-merge-default-set':
636
+ // Dispatcher controls share the repository journal but are projected
637
+ // independently; they never alter harvest workflow state.
638
+ break
639
+ }
640
+ }
641
+
642
+ const state: HarvestState = {
643
+ lastSeq,
644
+ runs: order,
645
+ paused,
646
+ ...(pausedSeq !== undefined ? { pausedSeq } : {}),
647
+ ...(pausedAt !== undefined ? { pausedAt } : {}),
648
+ pendingCommands: [...pending.pause, ...pending.resume].sort(
649
+ (left, right) => left.seq - right.seq,
650
+ ),
651
+ claimed: [...claimed.values()].map(({ occurrence }) => occurrence),
652
+ ledger,
653
+ }
654
+ const latest = order.at(-1)
655
+ if (latest !== undefined) state.latest = latest
656
+ return state
657
+ }
658
+
659
+ export function claimedOccurrenceKeys(state: HarvestState): Set<string> {
660
+ return new Set(state.claimed.map(occurrenceKey))
661
+ }
662
+
663
+ /** Durable correlation for a ticket whose external create may still be between
664
+ * source creation and blocker recording. Only running workflows contribute;
665
+ * every terminal/parked status releases unmatched reservations. */
666
+ export interface InFlightHarvestCreation {
667
+ run: string
668
+ proposalKey: string
669
+ creationKey: string
670
+ reservationSeq: number
671
+ }
672
+
673
+ export function inFlightHarvestCreations(state: HarvestState): InFlightHarvestCreation[] {
674
+ const creations: InFlightHarvestCreation[] = []
675
+ for (const run of state.runs) {
676
+ if (run.status !== 'running') continue
677
+ const filed = new Set(run.filed.map((entry) => entry.proposalKey))
678
+ for (const reservation of run.reservations) {
679
+ if (filed.has(reservation.proposalKey)) continue
680
+ creations.push({
681
+ run: run.run,
682
+ proposalKey: reservation.proposalKey,
683
+ creationKey: reservation.id,
684
+ reservationSeq: reservation.seq,
685
+ })
686
+ }
687
+ }
688
+ return creations
689
+ }
690
+
691
+ /** Bracket a ready-source listing with repository snapshots. Creations active
692
+ * before the listing remain withheld for this scan even if they settle during
693
+ * it. A reservation first written during the listing is also withheld once,
694
+ * including when its filing/terminal fact lands before the second snapshot:
695
+ * the ready projection may have captured the source between those writes. */
696
+ export function harvestCreationsDuringReadyScan(
697
+ beforeEvents: readonly RepositoryEvent[],
698
+ afterEvents: readonly RepositoryEvent[],
699
+ ): InFlightHarvestCreation[] {
700
+ const before = reduceHarvest(beforeEvents)
701
+ // Validate the complete second snapshot before using its event suffix.
702
+ reduceHarvest(afterEvents)
703
+ const creations = new Map<string, InFlightHarvestCreation>()
704
+ const keyOf = (run: string, proposalKey: string): string => `${run}\0${proposalKey}`
705
+ const known = new Set<string>()
706
+ for (const run of before.runs) {
707
+ for (const reservation of run.reservations) {
708
+ known.add(keyOf(run.run, reservation.proposalKey))
709
+ }
710
+ }
711
+ for (const creation of inFlightHarvestCreations(before)) {
712
+ creations.set(keyOf(creation.run, creation.proposalKey), creation)
713
+ }
714
+ for (const event of afterEvents) {
715
+ if (event.seq <= before.lastSeq || event.type !== 'harvest.proposal.id-reserved') continue
716
+ const key = keyOf(event.payload.run, event.payload.proposalKey)
717
+ if (known.has(key)) continue
718
+ known.add(key)
719
+ creations.set(key, {
720
+ run: event.payload.run,
721
+ proposalKey: event.payload.proposalKey,
722
+ creationKey: event.payload.id,
723
+ reservationSeq: event.seq,
724
+ })
725
+ }
726
+ return [...creations.values()]
727
+ }
728
+
729
+ /** Runs are always returned in durable start order. `latest` remains useful for
730
+ * history, but recovery and execution must never use it as an authority. */
731
+ export function parkedHarvestRuns(state: HarvestState): HarvestRunState[] {
732
+ return state.runs.filter(isOrdinaryParkedRun)
733
+ }
734
+
735
+ export function unresolvedHarvestAttentionRuns(state: HarvestState): HarvestRunState[] {
736
+ return state.runs.filter(hasUnresolvedExhaustion)
737
+ }
738
+
739
+ export function openHarvestRuns(state: HarvestState): HarvestRunState[] {
740
+ return state.runs.filter((run) => run.status === 'running')
741
+ }
742
+
743
+ /** The concrete run a control boundary should report if it parks. Exhaustion
744
+ * barriers are repository-global, then ordinary parked work takes priority,
745
+ * then the oldest open workflow is the run execution should continue. */
746
+ export function actionableHarvestRun(state: HarvestState): HarvestRunState | undefined {
747
+ return (
748
+ unresolvedHarvestAttentionRuns(state)[0] ??
749
+ parkedHarvestRuns(state)[0] ??
750
+ openHarvestRuns(state)[0]
751
+ )
752
+ }
753
+
754
+ /** Pure repository-control routing shared by dispatcher and harvest runner.
755
+ * Human commands settle first. Recorded exhaustion is a global barrier; then
756
+ * every durable automatic request is acknowledged before the oldest ordinary
757
+ * parked run receives its next run-local attempt. Only a journal with no
758
+ * unresolved failure may proceed to open work or a new scan. */
759
+ export function decideHarvestControl(
760
+ state: HarvestState,
761
+ maxRecoveryAttempts = DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
762
+ ): HarvestControlDecision {
763
+ if (!Number.isInteger(maxRecoveryAttempts) || maxRecoveryAttempts <= 0) {
764
+ throw new Error('max harvest recovery attempts must be a positive integer')
765
+ }
766
+ const wantsResume = state.pendingCommands.some((command) => command.command === 'resume')
767
+ if (state.paused) {
768
+ if (wantsResume) return { kind: 'acknowledge', command: 'resume' }
769
+ return { kind: 'park' }
770
+ }
771
+ if (state.pendingCommands.some((command) => command.command === 'pause')) {
772
+ return { kind: 'acknowledge', command: 'pause' }
773
+ }
774
+ // A stale/racing human resume is still a command and must be consumed. It
775
+ // reopens all ordinary failures and acknowledges all exhaustion barriers.
776
+ if (wantsResume) return { kind: 'acknowledge', command: 'resume' }
777
+
778
+ if (unresolvedHarvestAttentionRuns(state).length > 0) {
779
+ return { kind: 'park' }
780
+ }
781
+
782
+ const parked = parkedHarvestRuns(state)
783
+ if (
784
+ parked.some((run) =>
785
+ run.recoveryRequests.some((request) => request.acknowledgedSeq === undefined),
786
+ )
787
+ ) {
788
+ return { kind: 'acknowledge', command: 'resume' }
789
+ }
790
+
791
+ const run = parked[0]
792
+ if (run === undefined) return { kind: 'proceed' }
793
+
794
+ const attempts = run.recoveryRequests.length
795
+ // Once the first request records the applied policy, replacements honor
796
+ // that durable limit even if a later process ships a different default.
797
+ const appliedLimit = run.recoveryRequests[0]?.limit ?? maxRecoveryAttempts
798
+ if (attempts < appliedLimit) {
799
+ return {
800
+ kind: 'request-recovery',
801
+ run: run.run,
802
+ attempt: attempts + 1,
803
+ limit: appliedLimit,
804
+ }
805
+ }
806
+ return {
807
+ kind: 'exhaust-recovery',
808
+ run: run.run,
809
+ attempts,
810
+ limit: appliedLimit,
811
+ }
812
+ }
813
+
814
+ export function openHarvestRun(state: HarvestState): HarvestRunState | undefined {
815
+ return openHarvestRuns(state)[0]
816
+ }
817
+
818
+ export function proposalArtifactForRound(
819
+ run: HarvestRunState,
820
+ round: number,
821
+ ): ArtifactRef | undefined {
822
+ return [...run.proposals].reverse().find((proposal) => proposal.round === round)?.artifact
823
+ }