@defrex/autobuild 0.8.0 → 0.10.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 (182) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +323 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +166 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +175 -28
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +204 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +14 -0
  13. package/package.json +45 -23
  14. package/packages/core/src/cli/artifact.ts +130 -6
  15. package/packages/core/src/cli/binary.ts +9 -1
  16. package/packages/core/src/cli/build-control.ts +110 -13
  17. package/packages/core/src/cli/context.ts +46 -0
  18. package/packages/core/src/cli/dashboard/facts.ts +215 -0
  19. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  20. package/packages/core/src/cli/dashboard/model.ts +141 -153
  21. package/packages/core/src/cli/dashboard/poll.ts +76 -12
  22. package/packages/core/src/cli/dashboard/render.ts +34 -5
  23. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  24. package/packages/core/src/cli/dispatch-process.ts +1 -13
  25. package/packages/core/src/cli/dispatch.ts +392 -75
  26. package/packages/core/src/cli/harvest.ts +122 -8
  27. package/packages/core/src/cli/help.ts +62 -9
  28. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  29. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  30. package/packages/core/src/cli/init-validation.ts +266 -222
  31. package/packages/core/src/cli/installation.ts +50 -8
  32. package/packages/core/src/cli/main.ts +70 -11
  33. package/packages/core/src/cli/mcp.ts +23 -1
  34. package/packages/core/src/cli/plugin.ts +7 -4
  35. package/packages/core/src/cli/remote-poll.ts +277 -0
  36. package/packages/core/src/cli/repo-state.ts +22 -0
  37. package/packages/core/src/cli/repository-status.ts +39 -1
  38. package/packages/core/src/cli/self-update.ts +77 -2
  39. package/packages/core/src/cli/status.ts +150 -28
  40. package/packages/core/src/cli/terminals.ts +2 -1
  41. package/packages/core/src/cli/ticket-assets.ts +247 -0
  42. package/packages/core/src/cli/ticket.ts +453 -8
  43. package/packages/core/src/cli/wait.ts +157 -41
  44. package/packages/core/src/cli/watch.ts +138 -168
  45. package/packages/core/src/config/live.ts +23 -0
  46. package/packages/core/src/config/pipeline-source.ts +388 -0
  47. package/packages/core/src/config/schema.ts +251 -19
  48. package/packages/core/src/events/catalog.ts +1 -0
  49. package/packages/core/src/events/globs.ts +47 -0
  50. package/packages/core/src/events/payloads.ts +52 -2
  51. package/packages/core/src/events/repository.ts +52 -1
  52. package/packages/core/src/events/sessions.ts +69 -10
  53. package/packages/core/src/events/workspace-remote.ts +12 -0
  54. package/packages/core/src/integration/harness.ts +115 -2
  55. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  56. package/packages/core/src/kernel/auto-merge-default.ts +157 -0
  57. package/packages/core/src/kernel/auto-merge.ts +99 -22
  58. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  59. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  60. package/packages/core/src/kernel/engine.ts +9 -438
  61. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  62. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  63. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  64. package/packages/core/src/kernel/harvest.ts +62 -18
  65. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  66. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  67. package/packages/core/src/kernel/incremental.ts +53 -0
  68. package/packages/core/src/kernel/log-index.ts +620 -0
  69. package/packages/core/src/kernel/phases.ts +16 -2
  70. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  71. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  72. package/packages/core/src/kernel/reducer.ts +254 -73
  73. package/packages/core/src/operator/annotations.ts +6 -0
  74. package/packages/core/src/operator/control.ts +19 -5
  75. package/packages/core/src/operator/index.ts +71 -6
  76. package/packages/core/src/operator/query.ts +253 -24
  77. package/packages/core/src/operator/read-view.ts +98 -0
  78. package/packages/core/src/operator/registry.ts +55 -10
  79. package/packages/core/src/operator/requests.ts +9 -6
  80. package/packages/core/src/operator/sandbox-host.ts +140 -0
  81. package/packages/core/src/operator/sandbox.ts +247 -14
  82. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  83. package/packages/core/src/orchestrator/approvals.ts +115 -0
  84. package/packages/core/src/orchestrator/conversation.ts +167 -0
  85. package/packages/core/src/orchestrator/model.ts +86 -0
  86. package/packages/core/src/orchestrator/prompt.ts +61 -0
  87. package/packages/core/src/orchestrator/tools.ts +117 -0
  88. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  89. package/packages/core/src/plugin-sdk/index.ts +51 -1
  90. package/packages/core/src/plugins/load.ts +135 -7
  91. package/packages/core/src/plugins/manifest.ts +183 -10
  92. package/packages/core/src/plugins/registry.ts +73 -4
  93. package/packages/core/src/ports/forge/create.ts +1 -1
  94. package/packages/core/src/ports/forge/fake.ts +31 -0
  95. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  96. package/packages/core/src/ports/forge/github.ts +13 -2
  97. package/packages/core/src/ports/forge/local-git.ts +31 -1
  98. package/packages/core/src/ports/runner/claude.ts +2 -2
  99. package/packages/core/src/ports/runner/codex.ts +6 -2
  100. package/packages/core/src/ports/runner/fake.ts +7 -1
  101. package/packages/core/src/ports/runner/pi.ts +8 -6
  102. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  103. package/packages/core/src/ports/tickets/fake.ts +5 -1
  104. package/packages/core/src/ports/tickets/file.ts +169 -48
  105. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  106. package/packages/core/src/ports/tickets/linear.ts +33 -1
  107. package/packages/core/src/ports/tickets/operations.ts +1 -1
  108. package/packages/core/src/ports/tickets/remote.ts +8 -1
  109. package/packages/core/src/ports/types.ts +49 -3
  110. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  111. package/packages/core/src/ports/workspace/create.ts +74 -39
  112. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  113. package/packages/core/src/ports/workspace/fake.ts +169 -4
  114. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  115. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  116. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  117. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  118. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  119. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  120. package/packages/core/src/processes/build-child.ts +17 -2
  121. package/packages/core/src/processes/build-execution-state.ts +68 -15
  122. package/packages/core/src/processes/build-runner.ts +238 -93
  123. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  124. package/packages/core/src/processes/dispatcher.ts +501 -201
  125. package/packages/core/src/processes/execution-settlement.ts +72 -39
  126. package/packages/core/src/processes/harvest-child.ts +5 -0
  127. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  128. package/packages/core/src/processes/harvest-runner.ts +66 -19
  129. package/packages/core/src/processes/harvest.ts +196 -24
  130. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  131. package/packages/core/src/processes/orchestrator-tick.ts +360 -0
  132. package/packages/core/src/processes/publication-state.ts +164 -52
  133. package/packages/core/src/processes/repo-view.ts +1436 -0
  134. package/packages/core/src/processes/sandbox-state.ts +36 -2
  135. package/packages/core/src/processes/tick-state.ts +33 -0
  136. package/packages/core/src/store/adapter.ts +41 -0
  137. package/packages/core/src/store/build-scope.ts +90 -0
  138. package/packages/core/src/store/contract.ts +1205 -0
  139. package/packages/core/src/store/digest.ts +102 -0
  140. package/packages/core/src/store/local/schema.ts +46 -2
  141. package/packages/core/src/store/local/store.ts +445 -4
  142. package/packages/core/src/store/memory.ts +246 -0
  143. package/packages/core/src/store/new-build.ts +17 -0
  144. package/packages/core/src/store/phase-session.ts +94 -0
  145. package/packages/core/src/store/remote/client.ts +310 -2
  146. package/packages/core/src/store/remote/index.ts +14 -2
  147. package/packages/core/src/store/remote/protocol.ts +115 -12
  148. package/packages/core/src/store/remote/version.ts +6 -2
  149. package/packages/core/src/store/repo-state-events.ts +205 -0
  150. package/packages/core/src/store/retention.ts +7 -0
  151. package/packages/core/src/store/session-handle.ts +94 -0
  152. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
  153. package/packages/core/src/store/snapshots.ts +88 -0
  154. package/packages/core/src/store/streams/session-writer.ts +42 -2
  155. package/packages/core/src/store/streams/wait.ts +4 -5
  156. package/packages/core/src/store/ticket-assets.ts +458 -0
  157. package/packages/core/src/store/types.ts +160 -1
  158. package/packages/core/src/testing/index.ts +59 -0
  159. package/skills/code-review/SKILL.md +5 -1
  160. package/skills/guide/SKILL.md +152 -40
  161. package/skills/guide/references/operator-api.md +2 -2
  162. package/skills/guide/references/plugin-authoring.md +17 -0
  163. package/skills/guide/references/remote-store-protocol.md +204 -16
  164. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  165. package/skills/guide/references/setup.md +12 -9
  166. package/skills/implement/SKILL.md +3 -0
  167. package/skills/operate/SKILL.md +126 -0
  168. package/skills/plan/SKILL.md +4 -0
  169. package/skills/plan-review/SKILL.md +5 -1
  170. package/skills/spec/SKILL.md +46 -3
  171. package/skills/tickets/SKILL.md +57 -5
  172. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  173. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  174. package/packages/core/src/cli/testkit.ts +0 -194
  175. package/packages/core/src/markdown.ts +0 -67
  176. package/packages/core/src/operator/client.ts +0 -311
  177. package/packages/core/src/operator/server.ts +0 -616
  178. package/packages/core/src/operator/sessions.ts +0 -223
  179. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  180. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  181. package/packages/core/src/store/remote/server.ts +0 -923
  182. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -37,17 +37,25 @@
37
37
  * execution lease before launch; the child renews that same-holder lease.
38
38
  */
39
39
  import type { Config } from '../config/schema'
40
+ import type { LanguageModel } from 'ai'
41
+ import { runOrchestratorTickStep } from './orchestrator-tick'
40
42
  import { DISPATCHER, agentActor, humanActor } from '../events/envelope'
43
+ import { isRemoteWorkspace } from '../events/workspace-remote'
41
44
  import type { AbEvent, EventWrite } from '../events/catalog'
42
- import type { EventPayload } from '../events/payloads'
45
+ import type { EventPayload, PinnedAsset } from '../events/payloads'
43
46
  import type { IdSource } from '../ids'
44
47
  import {
45
48
  autoMergeApplicationType,
46
49
  pendingAutoMerge,
47
50
  recordAutoMergeDeferralObservation,
48
51
  } from '../kernel/auto-merge'
52
+ import {
53
+ autoMergeDefaultTarget,
54
+ latestAutoMergeDefault,
55
+ type AutoMergeDefaultFact,
56
+ } from '../kernel/auto-merge-default'
49
57
  import { reduceDispatchSettings } from '../kernel/dispatch-settings'
50
- import { decideNext } from '../kernel/engine'
58
+ import { decideNextFromFacts } from '../kernel/engine'
51
59
  import {
52
60
  DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
53
61
  decideHarvestControl,
@@ -56,7 +64,8 @@ import {
56
64
  } from '../kernel/harvest'
57
65
  import { pendingPrAttachmentReclaims } from '../kernel/pr-attachments'
58
66
  import { reduceBuild, type BuildState } from '../kernel/reducer'
59
- import type { ArtifactRef } from '../ontology'
67
+ import { createOperatorSandboxService, type OperatorSandboxService } from '../operator/sandbox'
68
+ import type { ArtifactRef, TicketRef } from '../ontology'
60
69
  import { BUILD_EXECUTION_LEASE_TTL_MS } from '../ports/workspace/build-execution'
61
70
  import type {
62
71
  DependencyState,
@@ -66,16 +75,25 @@ import type {
66
75
  WorkspaceProvider,
67
76
  } from '../ports/types'
68
77
  import type { Exec } from '../ports/workspace/git-worktree'
69
- import type { ArtifactMeta, BuildRecord, BuildStore, Clock } from '../store/types'
78
+ import type { ArtifactMeta, BuildDigest, BuildRecord, BuildStore, Clock } from '../store/types'
79
+ import { samplePinnedAssets } from '../store/ticket-assets'
70
80
  import { resolveRepoOrigin } from '../cli/repo-state'
71
81
  import { specConformance } from '../spec-standard'
72
82
  export { specConformance, type SpecConformance } from '../spec-standard'
73
83
  import { recordInfrastructureFailure as appendInfrastructureFailure } from './infrastructure-failure-budget'
74
- import { lastExecutionOutcome, openExecution, settleExecution } from './execution-settlement'
84
+ import { settleOpenExecution } from './execution-settlement'
75
85
  import { openHarvestExecutions } from './harvest-execution-state'
86
+ import { isWorkDigest, RepoViewStore } from './repo-view'
87
+ import {
88
+ baseBranchReducer,
89
+ openBuildWorkspace,
90
+ provisionMarkerReducer,
91
+ recoveryCheckpointReducer,
92
+ type OpenBuildWorkspace,
93
+ type ProvisionMarker,
94
+ } from './dispatcher-selectors'
76
95
  import type { RepositoryEvent } from '../events/repository'
77
96
  import { sandboxStates } from './sandbox-state'
78
- import { abandonedPublicationPending, publicationPending } from './publication-state'
79
97
  import { settlePublicationBeforeRelease } from './publication-loss'
80
98
 
81
99
  // ── Readiness resolution (SPEC §3.3) ─────────────────────────────────────────
@@ -97,11 +115,22 @@ import { settlePublicationBeforeRelease } from './publication-loss'
97
115
  * is otherwise the directory, so it defaults to no label gate. Plugin sources
98
116
  * likewise receive no host-imposed label convention. An explicit
99
117
  * `readyLabels` wins for every source.
118
+ *
119
+ * `[tickets].readyAssignee` (Linear only) adds a third conjunctive gate: the
120
+ * ticket must be assigned to that user.
100
121
  */
101
- export function readyCriteria(config: Config): { labels: string[]; state: string } {
102
- const { readyLabels, readyState } = config.tickets
122
+ export function readyCriteria(config: Config): {
123
+ labels: string[]
124
+ state: string
125
+ assignee?: string
126
+ } {
127
+ const { readyLabels, readyState, readyAssignee } = config.tickets
103
128
  if (config.tickets.source === 'linear') {
104
- return { labels: readyLabels ?? ['autobuild'], state: readyState }
129
+ return {
130
+ labels: readyLabels ?? ['autobuild'],
131
+ state: readyState,
132
+ ...(readyAssignee !== undefined ? { assignee: readyAssignee } : {}),
133
+ }
105
134
  }
106
135
  return { labels: readyLabels ?? [], state: readyState }
107
136
  }
@@ -349,6 +378,11 @@ export interface TickReport {
349
378
  sandboxIdleStops: number
350
379
  /** Contained idle-settlement failures this tick; retried next tick. */
351
380
  sandboxSettleFailures: number
381
+ /** Contained sandbox-backend construction failures this tick (AUT-584):
382
+ * deterministic and config-shaped, so it re-reports every tick until the
383
+ * config is repaired. The affected tick runs with sandbox tools filtered
384
+ * exactly as when no backend exists. */
385
+ orchestratorSandboxFailures: number
352
386
  }
353
387
 
354
388
  export function emptyTickReport(): TickReport {
@@ -385,6 +419,7 @@ export function emptyTickReport(): TickReport {
385
419
  harvestFailed: 0,
386
420
  sandboxIdleStops: 0,
387
421
  sandboxSettleFailures: 0,
422
+ orchestratorSandboxFailures: 0,
388
423
  }
389
424
  }
390
425
 
@@ -452,6 +487,10 @@ export interface TickOpts {
452
487
  * escalations remain human gates and are only reported as parked.
453
488
  */
454
489
  resumeCurrent?: boolean
490
+ /** The caller refreshed the repository view for this tick already (the CLI
491
+ * does, to sample the repository-wide controls from the same state). The
492
+ * tick then skips its own refresh so one tick performs one discovery read. */
493
+ viewRefreshed?: boolean
455
494
  /**
456
495
  * Repository-derived intake gate supplied by the CLI for this tick. False
457
496
  * skips only the ready-ticket list/claim/dispatch stage; janitor, startup
@@ -534,6 +573,11 @@ export interface DispatcherDeps {
534
573
  * supplies cancellation and treats every absence/failure/invalid result as a
535
574
  * local deterministic fallback, so naming can never prevent build creation. */
536
575
  nameSlug?: (spec: string, signal: AbortSignal) => Promise<string | null>
576
+ /** The orchestrator turn runner's language model, injected for tests. When
577
+ * absent the configured gateway model is resolved in process (AUT-342);
578
+ * the step itself only ever constructs in origin mode with the orchestrator
579
+ * enabled, so a local dispatcher never resolves a model. */
580
+ orchestratorModel?: LanguageModel
537
581
  /** Settle pending publication for `slug` after durable proof that the
538
582
  * guest execution ended. Supplied by the DispatchLoop; the settlement stage
539
583
  * invokes it only on a recorded completion. */
@@ -553,30 +597,14 @@ export interface DispatcherDeps {
553
597
  /** Latest `workspace.provisioned` not followed by a `workspace.released` —
554
598
  * the reducer deliberately ignores workspace events (liveness is the
555
599
  * dispatcher's concern), so the janitor scans the raw log. */
556
- function openWorkspace(
557
- events: AbEvent[],
558
- ): { provider: string; ref: string; path?: string; localPath?: string; branch: string } | null {
559
- let open: {
560
- provider: string
561
- ref: string
562
- path?: string
563
- localPath?: string
564
- branch: string
565
- } | null = null
566
- for (const event of events) {
567
- if (event.type === 'workspace.provisioned') open = event.payload
568
- else if (event.type === 'workspace.released') open = null
569
- }
570
- return open
600
+ function openWorkspace(events: AbEvent[]): OpenBuildWorkspace | null {
601
+ return openBuildWorkspace(events)
571
602
  }
572
603
 
573
604
  /** The build's base branch, from its own `build.created` fact (§15.3);
574
605
  * falls back to the repo config for logs missing one. */
575
606
  function baseBranchOf(events: AbEvent[], config: Config): string {
576
- for (const event of events) {
577
- if (event.type === 'build.created') return event.payload.baseBranch
578
- }
579
- return config.baseBranch
607
+ return baseBranchReducer.reduce(events) ?? config.baseBranch
580
608
  }
581
609
 
582
610
  function artifactRefOf(deposited: ArtifactMeta[]): ArtifactRef {
@@ -634,7 +662,27 @@ export class Dispatcher {
634
662
  * slug here defers to the continuation; it never blocks on it. */
635
663
  private readonly continuations = new Map<string, ProvisionContinuation>()
636
664
 
665
+ /** The incremental repository view every stage reads and writes through
666
+ * (AUT-647): `deps.store` itself when the caller already wrapped it. */
667
+ private readonly store: RepoViewStore
668
+
669
+ /** `deps.settlePublication`, followed by a delta read of the build: the
670
+ * guest-side settlement appends through its own store handle, which the view
671
+ * only learns of by reading. */
672
+ private get settlePublication(): ((slug: string) => Promise<void>) | undefined {
673
+ const settle = this.deps.settlePublication
674
+ if (settle === undefined) return undefined
675
+ return async (slug) => {
676
+ await settle(slug)
677
+ await this.store.refreshBuild(slug)
678
+ }
679
+ }
680
+
637
681
  constructor(private readonly deps: DispatcherDeps) {
682
+ this.store =
683
+ deps.store instanceof RepoViewStore
684
+ ? deps.store
685
+ : new RepoViewStore(deps.store, { repo: deps.repo })
638
686
  this.leaseTtlMs = deps.opts?.leaseTtlMs ?? 0
639
687
  this.doneState = deps.opts?.doneState ?? 'Done'
640
688
  this.slugNamingTimeoutMs = deps.opts?.slugNamingTimeoutMs ?? DEFAULT_SLUG_NAMING_TIMEOUT_MS
@@ -658,9 +706,21 @@ export class Dispatcher {
658
706
  }
659
707
 
660
708
  async tick(opts: TickOpts = {}): Promise<TickReport> {
709
+ try {
710
+ return await this.runTick(opts)
711
+ } finally {
712
+ // Leave this tick's reduced state behind for the next process (a cache;
713
+ // a failure to write it is never a tick failure). Throttled in a
714
+ // resident dispatcher, always run by a cold single-tick process.
715
+ await this.store.persistSnapshots()
716
+ }
717
+ }
718
+
719
+ private async runTick(opts: TickOpts): Promise<TickReport> {
661
720
  // One immutable config for this complete decision pass. A reload racing the
662
721
  // tick is observed by the next tick, never half-way through this one.
663
722
  this.deps.config = this.deps.getConfig?.() ?? this.deps.config
723
+ if (opts.viewRefreshed !== true) await this.store.refresh()
664
724
  let autoMergeUser: string | undefined
665
725
  if (opts.defaultAutoMerge === true) {
666
726
  autoMergeUser = opts.autoMergeUser?.trim()
@@ -672,11 +732,23 @@ export class Dispatcher {
672
732
  /** Builds already launched this tick — a janitor/startup re-attach must
673
733
  * not be doubled by the sweep, nor a fresh dispatch by anything. */
674
734
  const launched = new Set<string>()
675
- // Sampled once, before any stage that could attach a runner: a pause that
676
- // lands mid-tick takes effect on the next tick, matching intake (which the
677
- // CLI likewise samples before calling `tick`). Neither control pretends to
678
- // a serialization the store does not offer.
679
- const paused = await this.repositoryPaused()
735
+ // One journal read for both repository-wide controls: a pause that lands
736
+ // mid-tick takes effect on the next tick, matching intake (which the CLI
737
+ // likewise samples before calling `tick`); the auto-merge default fact is
738
+ // the durable driver of this tick's fan-out below. Neither control
739
+ // pretends to a serialization the store does not offer.
740
+ const repoEvents = this.store.recordedJournal()
741
+ const paused = reduceDispatchSettings(repoEvents).paused
742
+ const autoMergeDefault = latestAutoMergeDefault(repoEvents)
743
+ // The claim-time seed cites the newest fact whose enabled state matches
744
+ // the launch sample, not blindly the newest fact: the CLI samples
745
+ // `defaultAutoMerge` from its own earlier read, so an interleaved toggle
746
+ // can make this tick's read newer-and-opposite. Citing the matching fact
747
+ // keeps the newer opposite fact strictly ahead of the seed's provenance,
748
+ // so the fan-out reconciles the build on the next tick instead of the
749
+ // stale sample out-voting the durable default forever (f_b851c0e8).
750
+ const autoMergeSeedFact =
751
+ opts.defaultAutoMerge === true ? latestAutoMergeDefault(repoEvents, true) : undefined
680
752
  // Stage 0 — SETTLEMENT: observe foreign executions from durable facts plus
681
753
  // provider liveness and settle their completion, lease, and publication.
682
754
  // Runs before the janitor so its executionLeaseLive checks see freshly
@@ -691,6 +763,13 @@ export class Dispatcher {
691
763
  if (this.outOfBudget(opts)) return report
692
764
  await this.settleOrchestratorSandboxes(report, opts)
693
765
  if (this.outOfBudget(opts)) return report
766
+ // The default fan-out runs before the janitor so one tick both records the
767
+ // consent and lets the janitor act on it (the janitor reduces a fresh read
768
+ // inside `checkPr`). Independent of intake and `acceptNewWork` — the
769
+ // durable fact drives it, so a second dispatcher polling the same
770
+ // repository converges the same state.
771
+ await this.applyAutoMergeDefault(autoMergeDefault)
772
+ if (this.outOfBudget(opts)) return report
694
773
  await this.janitor(report, launched, opts)
695
774
  if (this.outOfBudget(opts)) return report
696
775
  await this.recoverDispatches(report, launched, paused, opts)
@@ -700,7 +779,7 @@ export class Dispatcher {
700
779
  await this.leaseSweep(report, launched, paused, opts)
701
780
  if (this.outOfBudget(opts)) return report
702
781
  if (opts.acceptNewWork !== false) {
703
- await this.dispatch(report, launched, autoMergeUser, paused, opts)
782
+ await this.dispatch(report, launched, autoMergeUser, paused, opts, autoMergeSeedFact)
704
783
  }
705
784
  // Fire-and-forget by contract: long synthesize/review sessions must not
706
785
  // stop janitor, lease sweep, ticket dispatch, or signal handling on later
@@ -708,6 +787,61 @@ export class Dispatcher {
708
787
  // launch. Pending control commands still launch the runner so the kernel
709
788
  // can settle them under the repository lease.
710
789
  await this.triggerHarvest()
790
+ // The orchestrator step (AUT-342) — LAST, after every build stage, so it
791
+ // can only consume slack: resumes, crash reaping, and wake turns run
792
+ // against min(configured, remaining tick budget) with a 30 s floor, and
793
+ // a busy repository simply defers its sessions to later ticks (durable
794
+ // state makes that safe). Two independent gates keep it inert where it
795
+ // does not belong: the step is constructed ONLY in origin mode (a local
796
+ // `ab dispatch` never constructs it — enabled = true in a local
797
+ // repository's autobuild.toml lists no sessions, builds no registry, and
798
+ // calls no model), and in origin mode only when the effective config
799
+ // enables the orchestrator.
800
+ if (this.deps.repoOrigin !== undefined && this.deps.config.orchestrator.enabled) {
801
+ if (!this.outOfBudget(opts)) {
802
+ const remainingSeconds =
803
+ opts.deadlineAt === undefined
804
+ ? undefined
805
+ : Math.max(0, Math.floor((opts.deadlineAt - this.deps.clock().getTime()) / 1000))
806
+ // The origin-mode tick's sandbox backend (AUT-584), built from the
807
+ // dispatcher's own deps — the same wired provider and forge the
808
+ // settlement stage already consumes. Construction is cheap and
809
+ // network-free (one idempotent ensureRepo), and building per tick
810
+ // picks up hot config reloads the way the rest of the tick does.
811
+ // A construction failure is contained: the step runs with sandbox
812
+ // tools filtered exactly as today, and the standing counter names
813
+ // the condition (the same discipline as sandboxSettleFailures).
814
+ let sandbox: OperatorSandboxService | undefined
815
+ try {
816
+ sandbox = await createOperatorSandboxService({
817
+ store: this.store,
818
+ repo: this.deps.repo,
819
+ provider: this.deps.workspaces,
820
+ sandbox: this.deps.config.orchestrator.sandbox,
821
+ baseBranch: this.deps.config.baseBranch,
822
+ forge: this.deps.forge,
823
+ clock: this.deps.clock,
824
+ })
825
+ } catch {
826
+ report.orchestratorSandboxFailures += 1
827
+ sandbox = undefined
828
+ }
829
+ await runOrchestratorTickStep({
830
+ store: this.store,
831
+ view: this.store,
832
+ repo: this.deps.repo,
833
+ config: this.deps.config,
834
+ clock: this.deps.clock,
835
+ ids: this.deps.ids,
836
+ tickets: this.deps.tickets,
837
+ ...(sandbox !== undefined ? { sandbox } : {}),
838
+ ...(this.deps.orchestratorModel !== undefined
839
+ ? { model: this.deps.orchestratorModel }
840
+ : {}),
841
+ ...(remainingSeconds !== undefined ? { remainingBudgetSeconds: remainingSeconds } : {}),
842
+ })
843
+ }
844
+ }
711
845
  return report
712
846
  }
713
847
 
@@ -719,6 +853,46 @@ export class Dispatcher {
719
853
  return this.deps.opts?.triageState ?? defaultTriageState(this.deps.config)
720
854
  }
721
855
 
856
+ /** This repository's builds paired with their digests, from one record
857
+ * listing and one digest read — the tick loops' per-build event reads start
858
+ * from here so a settled terminal build costs none. Records are read before
859
+ * digests (as `operator/query.ts` does) so a concurrent create cannot fail
860
+ * the completeness check. */
861
+ private async repoBuildDigests(): Promise<{ record: BuildRecord; digest: BuildDigest }[]> {
862
+ const store = this.store
863
+ const { repo } = this.deps
864
+ const records = (await store.listBuilds()).filter((record) => record.repo === repo)
865
+ const digests = await store.getRepoBuildDigests(repo)
866
+ return records.map((record) => {
867
+ const digest = digests.get(record.slug)
868
+ if (digest === undefined) {
869
+ throw new Error(
870
+ `build digest missing for ${record.slug}: the digest read broke completeness`,
871
+ )
872
+ }
873
+ return { record, digest }
874
+ })
875
+ }
876
+
877
+ /** Builds with no terminal fact: the only ones whose tick duties change
878
+ * their state (decide, resume, lease sweep, capacity). */
879
+ private async nonterminalBuilds(): Promise<BuildRecord[]> {
880
+ return (await this.repoBuildDigests())
881
+ .filter(({ digest }) => digest.terminal === undefined)
882
+ .map(({ record }) => record)
883
+ }
884
+
885
+ /** Builds that still have work: nonterminal ones, plus terminal builds whose
886
+ * post-terminal duty is outstanding — an `aborted` build awaiting its
887
+ * cleanup's `build.completed`, a `done` build with an unreclaimed release
888
+ * asset, or any build with an execution the provider has not been seen to
889
+ * end. A terminal, settled build costs no event reads. */
890
+ private async workBuilds(): Promise<BuildRecord[]> {
891
+ return (await this.repoBuildDigests())
892
+ .filter(({ digest }) => isWorkDigest(digest))
893
+ .map(({ record }) => record)
894
+ }
895
+
722
896
  /** Observation-only ready listing for Store-backed frontends while intake is
723
897
  * disabled. It shares the active-ticket exclusion with dispatch but performs
724
898
  * no dependency fetch, claim, bounce, build, workspace, or launch side
@@ -729,11 +903,10 @@ export class Dispatcher {
729
903
  ticketDiagnostics: string[]
730
904
  }> {
731
905
  this.deps.config = this.deps.getConfig?.() ?? this.deps.config
906
+ await this.store.refresh()
732
907
  const activeTicketIds = new Set<string>()
733
- for (const record of await this.deps.store.listBuilds()) {
734
- if (record.repo !== this.deps.repo || record.ticket === undefined) continue
735
- const state = reduceBuild(await this.deps.store.getEvents(record.slug))
736
- if (state.status !== 'done' && state.status !== 'aborted') {
908
+ for (const { record, digest } of await this.repoBuildDigests()) {
909
+ if (record.ticket !== undefined && digest.terminal === undefined) {
737
910
  activeTicketIds.add(record.ticket.id)
738
911
  }
739
912
  }
@@ -745,24 +918,66 @@ export class Dispatcher {
745
918
  }
746
919
  }
747
920
 
748
- private async repositoryEvents() {
749
- if ((await this.deps.store.getRepo(this.deps.repo)) === null) return []
750
- return this.deps.store.getRepoEvents(this.deps.repo)
751
- }
752
-
753
- private async repositoryPaused(): Promise<boolean> {
754
- return reduceDispatchSettings(await this.repositoryEvents()).paused
921
+ /** Apply the newest durable `dispatcher.auto-merge-default-set` fact to every
922
+ * current non-terminal build of this repository: on — request consent; off —
923
+ * cancel it. Attribution is the toggling human's own actor, exactly as if
924
+ * they had pressed `m` on each row; each command carries the fact's seq so
925
+ * the fan-out is idempotent (a settled build fails `autoMergeDefaultTarget`)
926
+ * and a per-build override stands until the default is toggled again. A fact
927
+ * whose value already matches the build's state writes a provenance-only
928
+ * `build.auto-merge-default-observed` marker instead of a duplicate command,
929
+ * so the matching fact is recorded as answered (f_28b3fba1).
930
+ *
931
+ * Per build, a compare-and-set loop (the `requestOne` shape from
932
+ * `bulk-control.ts`): the read/reduce/evaluate lives inside the loop, so a
933
+ * competing write loses at most one attempt and the next re-reads fresh
934
+ * state. No TickReport counter: the evidence is the build events, and the
935
+ * report shape stays compatible. */
936
+ private async applyAutoMergeDefault(fact: AutoMergeDefaultFact | undefined): Promise<void> {
937
+ if (fact === undefined) return
938
+ const store = this.store
939
+
940
+ // Terminal builds are never eligible (`autoMergeDefaultEligible`).
941
+ for (const record of (await this.nonterminalBuilds()).sort((a, b) =>
942
+ a.slug.localeCompare(b.slug),
943
+ )) {
944
+ while (true) {
945
+ const { state } = await store.buildFacts(record.slug)
946
+ const target = autoMergeDefaultTarget(state, fact)
947
+ if (target === undefined) break
948
+ if (target === 'observed') {
949
+ const observed = await store.appendIfCurrent(record.slug, state.lastSeq, {
950
+ actor: DISPATCHER,
951
+ type: 'build.auto-merge-default-observed',
952
+ payload: { defaultSeq: fact.seq },
953
+ })
954
+ if (observed !== null) break
955
+ continue
956
+ }
957
+ const type =
958
+ target === 'request' ? 'build.auto-merge-requested' : 'build.auto-merge-cancelled'
959
+ const appended = await store.appendIfCurrent(record.slug, state.lastSeq, {
960
+ actor: fact.actor,
961
+ type,
962
+ payload: { defaultSeq: fact.seq },
963
+ })
964
+ if (appended !== null) break
965
+ }
966
+ }
755
967
  }
756
968
 
757
969
  private async triggerHarvest(): Promise<void> {
758
970
  const start = this.deps.startHarvest
759
971
  if (start === undefined) return
760
- const record = await this.deps.store.getRepo(this.deps.repo)
761
- if (record === null) {
972
+ // The journal moves under foreign harvest writers while the stages run, so
973
+ // take the delta before deciding.
974
+ await this.store.refreshJournal()
975
+ if (!this.store.journalRecorded()) {
762
976
  start()
763
977
  return
764
978
  }
765
- const state = reduceHarvest(await this.deps.store.getRepoEvents(this.deps.repo))
979
+ // Bounded subset (AUT-489): the harvest reducer consumes durable types only.
980
+ const state = reduceHarvest(this.store.recordedJournal())
766
981
  if (decideHarvestControl(state, this.maxHarvestRecoveryAttempts).kind !== 'park') {
767
982
  start()
768
983
  }
@@ -791,28 +1006,26 @@ export class Dispatcher {
791
1006
  * the stage still settles. */
792
1007
  private async settleForeignExecutions(report: TickReport, opts: TickOpts = {}): Promise<void> {
793
1008
  const active = this.deps.activeExecutions?.() ?? new Set<string>()
794
- for (const record of await this.deps.store.listBuilds()) {
795
- if (record.repo !== this.deps.repo) continue
1009
+ for (const record of await this.workBuilds()) {
796
1010
  if (active.has(record.slug)) continue
797
1011
  // Budget gate: once spent, stop settling further foreign executions.
798
1012
  if (this.outOfBudget(opts)) break
799
1013
  try {
800
- const events = await this.deps.store.getEvents(record.slug)
801
- const open = openExecution(events)
1014
+ const { open } = await this.store.buildFacts(record.slug)
802
1015
  if (open === null) continue
803
1016
  const owner = this.executionOwner(open.provider)
804
1017
  const execution = owner?.buildExecution
805
1018
  if (execution?.observe === undefined || open.commandId === undefined) continue
806
- const settlement = await settleExecution(
1019
+ const settlement = await settleOpenExecution(
807
1020
  {
808
- store: this.deps.store,
1021
+ store: this.store,
809
1022
  execution,
810
- ...(this.deps.settlePublication !== undefined
811
- ? { settlePublication: this.deps.settlePublication }
1023
+ ...(this.settlePublication !== undefined
1024
+ ? { settlePublication: this.settlePublication }
812
1025
  : {}),
813
1026
  },
814
1027
  record.slug,
815
- events,
1028
+ open,
816
1029
  )
817
1030
  if (settlement === 'settled') report.settled += 1
818
1031
  } catch {
@@ -837,9 +1050,16 @@ export class Dispatcher {
837
1050
  const active = this.deps.activeHarvestExecutions?.() ?? new Set<string>()
838
1051
  let events: RepositoryEvent[]
839
1052
  try {
840
- events = await this.repositoryEvents()
1053
+ // The earlier stages awaited providers; take the journal delta so a
1054
+ // foreign append (operator activity, harvest facts) made meanwhile is
1055
+ // seen before any destructive decision below.
1056
+ await this.store.refreshJournal()
1057
+ events = this.store.recordedJournal()
841
1058
  } catch {
842
- // A repository with no journal yet has nothing to settle.
1059
+ // The read itself failed (a transient store error — a missing record
1060
+ // answers `[]` inside the helper, so it never reaches this arm):
1061
+ // skipping this settlement stage for this tick while the rest of the
1062
+ // tick proceeds, and the next tick retries what remains.
843
1063
  return
844
1064
  }
845
1065
  for (const execution of openHarvestExecutions(events)) {
@@ -890,7 +1110,7 @@ export class Dispatcher {
890
1110
  branch: '',
891
1111
  })
892
1112
  }
893
- await this.deps.store.appendRepo(this.deps.repo, {
1113
+ await this.store.appendRepo(this.deps.repo, {
894
1114
  actor: DISPATCHER,
895
1115
  type: 'harvest.execution.released',
896
1116
  payload: {
@@ -933,9 +1153,16 @@ export class Dispatcher {
933
1153
  const providerName = this.deps.workspaces.name
934
1154
  let events: RepositoryEvent[]
935
1155
  try {
936
- events = await this.repositoryEvents()
1156
+ // The earlier stages awaited providers; take the journal delta so a
1157
+ // foreign append (operator activity, harvest facts) made meanwhile is
1158
+ // seen before any destructive decision below.
1159
+ await this.store.refreshJournal()
1160
+ events = this.store.recordedJournal()
937
1161
  } catch {
938
- // A repository with no journal yet has nothing to settle.
1162
+ // The read itself failed (a transient store error — a missing record
1163
+ // answers `[]` inside the helper, so it never reaches this arm):
1164
+ // skipping this settlement stage for this tick while the rest of the
1165
+ // tick proceeds, and the next tick retries what remains.
939
1166
  return
940
1167
  }
941
1168
  const idleMs = config.orchestrator.sandbox.idleMinutes * 60_000
@@ -957,7 +1184,7 @@ export class Dispatcher {
957
1184
  // never a stop: nothing was stopped through a provider this
958
1185
  // dispatcher no longer owns. If the provider returns, the next
959
1186
  // operator ensure() re-provisions and re-brackets the environment.
960
- await this.deps.store.appendRepo(this.deps.repo, {
1187
+ await this.store.appendRepo(this.deps.repo, {
961
1188
  actor: DISPATCHER,
962
1189
  type: 'orchestrator.sandbox.released',
963
1190
  payload: {
@@ -978,7 +1205,7 @@ export class Dispatcher {
978
1205
  })
979
1206
  if (outcome.outcome === 'unsupported') continue
980
1207
  if (outcome.outcome === 'stopped') {
981
- await this.deps.store.appendRepo(this.deps.repo, {
1208
+ await this.store.appendRepo(this.deps.repo, {
982
1209
  actor: DISPATCHER,
983
1210
  type: 'orchestrator.sandbox.stopped',
984
1211
  payload: {
@@ -993,7 +1220,7 @@ export class Dispatcher {
993
1220
  // dead host): close the orphan's trail with an unconfirmed-purge
994
1221
  // release fact, dispatcher-authored exactly as
995
1222
  // `harvest.execution.released` is.
996
- await this.deps.store.appendRepo(this.deps.repo, {
1223
+ await this.store.appendRepo(this.deps.repo, {
997
1224
  actor: DISPATCHER,
998
1225
  type: 'orchestrator.sandbox.released',
999
1226
  payload: {
@@ -1021,23 +1248,8 @@ export class Dispatcher {
1021
1248
 
1022
1249
  /** The latest `workspace.provision-started` not yet followed by a
1023
1250
  * provisioned/released fact — an open provisioning marker. */
1024
- private openProvisionMarker(
1025
- events: AbEvent[],
1026
- ): { provider: string; branch: string; generation: number; ts: string; seq: number } | undefined {
1027
- let marker:
1028
- | { provider: string; branch: string; generation: number; ts: string; seq: number }
1029
- | undefined
1030
- for (const event of events) {
1031
- if (event.type === 'workspace.provision-started') {
1032
- marker = { ...event.payload, ts: event.ts, seq: event.seq }
1033
- } else if (
1034
- marker !== undefined &&
1035
- (event.type === 'workspace.provisioned' || event.type === 'workspace.released')
1036
- ) {
1037
- marker = undefined
1038
- }
1039
- }
1040
- return marker
1251
+ private openProvisionMarker(events: AbEvent[]): ProvisionMarker | undefined {
1252
+ return provisionMarkerReducer.reduce(events)
1041
1253
  }
1042
1254
 
1043
1255
  /** Liveness of the lease behind an open provision marker: `live` means a
@@ -1046,7 +1258,7 @@ export class Dispatcher {
1046
1258
  * may have crashed between marker and claim — adopted only after the
1047
1259
  * `provisionStaleMs` backstop. */
1048
1260
  private async provisionMarkerLiveness(slug: string): Promise<'live' | 'expired' | 'absent'> {
1049
- const record = await this.deps.store.getBuild(slug)
1261
+ const record = await this.store.getBuild(slug)
1050
1262
  if (record?.lease === undefined) return 'absent'
1051
1263
  return new Date(record.lease.expiresAt).getTime() > this.deps.clock().getTime()
1052
1264
  ? 'live'
@@ -1065,6 +1277,8 @@ export class Dispatcher {
1065
1277
  body: string
1066
1278
  authoredSession?: string
1067
1279
  autoMergeUser?: string
1280
+ autoMergeDefaultSeq?: number
1281
+ assets?: PinnedAsset[]
1068
1282
  },
1069
1283
  ): Promise<'kicked' | 'in-flight' | 'foreign-live'> {
1070
1284
  const slug = record.slug
@@ -1093,7 +1307,7 @@ export class Dispatcher {
1093
1307
  }
1094
1308
  const branch = record.branch ?? `ab/${slug}`
1095
1309
  const generation = events.filter((event) => event.type === 'workspace.provisioned').length
1096
- await this.deps.store.append(slug, {
1310
+ await this.store.append(slug, {
1097
1311
  actor: DISPATCHER,
1098
1312
  type: 'workspace.provision-started',
1099
1313
  payload: { provider: this.deps.workspaces.name, branch, generation },
@@ -1134,12 +1348,15 @@ export class Dispatcher {
1134
1348
  body: string
1135
1349
  authoredSession?: string
1136
1350
  autoMergeUser?: string
1351
+ autoMergeDefaultSeq?: number
1352
+ assets?: PinnedAsset[]
1137
1353
  }
1138
1354
  | undefined
1139
1355
  tail: 'dispatch' | 'launch'
1140
1356
  },
1141
1357
  ): Promise<void> {
1142
- const { store, workspaces, config } = this.deps
1358
+ const store = this.store
1359
+ const { workspaces, config } = this.deps
1143
1360
  const slug = entry.slug
1144
1361
  let leaseClaimed = false
1145
1362
  let heartbeat: ReturnType<typeof setInterval> | undefined
@@ -1192,6 +1409,7 @@ export class Dispatcher {
1192
1409
  ref: handle.ref,
1193
1410
  path: handle.path,
1194
1411
  ...(handle.localPath !== undefined ? { localPath: handle.localPath } : {}),
1412
+ remote: workspaces.publication !== undefined,
1195
1413
  branch: handle.branch,
1196
1414
  base: handle.base,
1197
1415
  },
@@ -1277,10 +1495,10 @@ export class Dispatcher {
1277
1495
  for (const entry of entries) entry.controller.abort()
1278
1496
  for (const entry of entries) {
1279
1497
  try {
1280
- const events = await this.deps.store.getEvents(entry.slug)
1498
+ const events = await this.store.getEvents(entry.slug)
1281
1499
  if (this.openProvisionMarker(events) !== undefined) {
1282
1500
  const attempt = events.filter((event) => event.type === 'dispatch.failed').length + 1
1283
- await this.deps.store.append(entry.slug, {
1501
+ await this.store.append(entry.slug, {
1284
1502
  actor: DISPATCHER,
1285
1503
  type: 'dispatch.failed',
1286
1504
  payload: {
@@ -1295,7 +1513,7 @@ export class Dispatcher {
1295
1513
  // The durable marker plus the released lease still gate adoption.
1296
1514
  }
1297
1515
  try {
1298
- await this.deps.store.releaseLease(entry.slug, entry.instance)
1516
+ await this.store.releaseLease(entry.slug, entry.instance)
1299
1517
  } catch {
1300
1518
  // Expiry fences an ambiguous release.
1301
1519
  }
@@ -1311,12 +1529,11 @@ export class Dispatcher {
1311
1529
  ): Promise<void> {
1312
1530
  // Listing is repository-wide work: if it fails there is no individual
1313
1531
  // build to attribute, so the caller's existing top-level boundary owns it.
1314
- const records = await this.deps.store.listBuilds()
1315
- for (const record of records) {
1316
- // One dispatcher per repo (§12), but the store is shared by design
1317
- // (§7.2) — another repo's builds are another dispatcher's duty. Acting
1318
- // on them would poll foreign PRs and break single-writer discipline.
1319
- if (record.repo !== this.deps.repo) continue
1532
+ // Only this repo's builds that still have work (§12: another repo's
1533
+ // builds are another dispatcher's duty; settled terminal builds cost no
1534
+ // event reads).
1535
+ const entries = (await this.repoBuildDigests()).filter(({ digest }) => isWorkDigest(digest))
1536
+ for (const { record, digest } of entries) {
1320
1537
  // Budget gate: the janitor's per-build path issues forge/store transport
1321
1538
  // calls (PR probes, closePr, deleteBranch); once the budget is spent,
1322
1539
  // stop starting new ones. The remaining builds stay janitor due — the
@@ -1326,14 +1543,22 @@ export class Dispatcher {
1326
1543
  // Once a record is known, contain its complete janitor path: loading
1327
1544
  // and reducing facts, cleanup, forge/ticket calls, and store writes.
1328
1545
  // Existing partial facts leave the same work due on the next tick.
1329
- const events = await this.deps.store.getEvents(record.slug)
1330
- const state = reduceBuild(events)
1546
+ const { state, workspace } = await this.store.buildFacts(record.slug)
1331
1547
  const executionLeaseLive = this.hasLiveExecutionLease(record)
1548
+ // Raw history, read at most once and only by a branch that acts on it:
1549
+ // the facts above answer every quiet-path question without it.
1550
+ let loaded: AbEvent[] | undefined
1551
+ const history = async (): Promise<AbEvent[]> => {
1552
+ loaded ??= await this.store.getEvents(record.slug)
1553
+ return loaded
1554
+ }
1332
1555
  // Pipeline/ticket/workspace state is settled, but release-asset cleanup
1333
1556
  // has its own crash window after build.completed. Revisit only pending
1334
1557
  // hosted handles; this never relaunches a runner or consumes capacity.
1335
1558
  if (state.status === 'done') {
1336
- await this.reclaimPrAttachments(record.slug, events)
1559
+ if (digest.reclaimPending === true) {
1560
+ await this.reclaimPrAttachments(record.slug, await history())
1561
+ }
1337
1562
  continue
1338
1563
  }
1339
1564
  // Abort is destructive human judgment and therefore outranks a queued
@@ -1343,13 +1568,15 @@ export class Dispatcher {
1343
1568
  state.status === 'queued' &&
1344
1569
  state.pendingCommands.some((command) => command.command === 'abort')
1345
1570
  ) {
1346
- const acknowledged = await this.deps.store.append(record.slug, {
1571
+ const acknowledged = await this.store.append(record.slug, {
1347
1572
  actor: DISPATCHER,
1348
1573
  type: 'build.aborted',
1349
1574
  payload: {},
1350
1575
  } satisfies EventWrite<'build.aborted'>)
1351
- events.push(acknowledged)
1352
- await this.cleanupAborted(record, events, report)
1576
+ void acknowledged
1577
+ // The view folded the acknowledgement it just appended.
1578
+ loaded = undefined
1579
+ await this.cleanupAborted(record, await history(), report)
1353
1580
  continue
1354
1581
  }
1355
1582
  // Discard is a queued-only recovery control. A request that raced with
@@ -1360,15 +1587,23 @@ export class Dispatcher {
1360
1587
  state.status === 'queued' &&
1361
1588
  state.discardRequest !== undefined
1362
1589
  ) {
1363
- await this.cleanupDiscarded(record, events, report)
1590
+ await this.cleanupDiscarded(record, await history(), report)
1364
1591
  continue
1365
1592
  }
1366
1593
  if (!executionLeaseLive && state.status === 'aborted') {
1367
- await this.cleanupAborted(record, events, report)
1594
+ await this.cleanupAborted(record, await history(), report)
1368
1595
  continue
1369
1596
  }
1370
- if (state.pr)
1371
- await this.checkPr(record, events, state, report, launched, executionLeaseLive)
1597
+ if (state.pr) {
1598
+ await this.checkPr(
1599
+ record,
1600
+ { history, workspace },
1601
+ state,
1602
+ report,
1603
+ launched,
1604
+ executionLeaseLive,
1605
+ )
1606
+ }
1372
1607
  } catch (error) {
1373
1608
  const message = error instanceof Error ? error.message : String(error)
1374
1609
  report.janitorFailed += 1
@@ -1378,8 +1613,11 @@ export class Dispatcher {
1378
1613
  }
1379
1614
 
1380
1615
  private forgeWorkspacePath(events: AbEvent[]): string {
1381
- const open = openWorkspace(events)
1382
- if (open?.provider === 'vercel-sandbox') return this.deps.repo
1616
+ return this.forgeWorkspacePathOf(openWorkspace(events))
1617
+ }
1618
+
1619
+ private forgeWorkspacePathOf(open: OpenBuildWorkspace | null): string {
1620
+ if (open !== null && isRemoteWorkspace(open)) return this.deps.repo
1383
1621
  // Local forges run git with cwd = workspacePath: it must be the physical
1384
1622
  // checkout, never the store identity (an origin URL since the identity
1385
1623
  // change; a path only for origin-less fixtures).
@@ -1401,19 +1639,19 @@ export class Dispatcher {
1401
1639
  events: AbEvent[],
1402
1640
  report: TickReport,
1403
1641
  ): Promise<void> {
1404
- const { store, tickets, forge } = this.deps
1642
+ const store = this.store
1643
+ const { tickets, forge } = this.deps
1405
1644
  const branch = record.branch
1406
1645
  // Whether this build ever owned a local checkout workspace, judged on the
1407
1646
  // provisioned facts (not the open one, which is null when the build never
1408
1647
  // provisioned or already released — only the former must skip the
1409
1648
  // local-branch git step; a released local workspace may still have left a
1410
- // branch in the checkout). Remote workspaces (vercel-sandbox) never create
1411
- // a local branch in the main checkout, and origin mode has no checkout at
1412
- // all: the local-branch git step is skipped, and the saga's later steps
1413
- // tolerate its absence.
1649
+ // branch in the checkout). Remote workspaces never create a local branch
1650
+ // in the main checkout, and origin mode has no checkout at all: the
1651
+ // local-branch git step is skipped, and the saga's later steps tolerate
1652
+ // its absence.
1414
1653
  const hadLocalWorkspace = events.some(
1415
- (event) =>
1416
- event.type === 'workspace.provisioned' && event.payload.provider !== 'vercel-sandbox',
1654
+ (event) => event.type === 'workspace.provisioned' && !isRemoteWorkspace(event.payload),
1417
1655
  )
1418
1656
  const has = (type: AbEvent['type']): boolean => events.some((event) => event.type === type)
1419
1657
  const append = async <T extends EventWrite['type']>(write: EventWrite<T>): Promise<void> => {
@@ -1565,7 +1803,8 @@ export class Dispatcher {
1565
1803
  events: AbEvent[],
1566
1804
  report: TickReport,
1567
1805
  ): Promise<void> {
1568
- const { store, tickets } = this.deps
1806
+ const store = this.store
1807
+ const { tickets } = this.deps
1569
1808
  await this.releaseWorkspace(record.slug, events, 'discard')
1570
1809
  if (record.lease !== undefined) {
1571
1810
  await store.releaseLease(record.slug, record.lease.holder)
@@ -1589,18 +1828,21 @@ export class Dispatcher {
1589
1828
  * against the reduced `prState`), release/complete, project to the ticket. */
1590
1829
  private async checkPr(
1591
1830
  record: BuildRecord,
1592
- events: AbEvent[],
1831
+ // `workspace` is the open workspace fact; `history` loads the raw log, which
1832
+ // only the branches that act on a PR transition need.
1833
+ source: { history: () => Promise<AbEvent[]>; workspace: OpenBuildWorkspace | null },
1593
1834
  state: BuildState,
1594
1835
  report: TickReport,
1595
1836
  launched: Set<string>,
1596
1837
  executionLeaseLive: boolean,
1597
1838
  ): Promise<void> {
1598
- const { store, tickets, forge } = this.deps
1839
+ const store = this.store
1840
+ const { tickets, forge } = this.deps
1599
1841
  const pr = state.pr
1600
1842
  if (!pr) return
1601
1843
  // Forge calls run from the workspace when it still exists (it does until
1602
1844
  // the build completes); fall back to the repo itself for odd logs.
1603
- const workspacePath = this.forgeWorkspacePath(events)
1845
+ const workspacePath = this.forgeWorkspacePathOf(source.workspace)
1604
1846
  const prState = await forge.getPrState(workspacePath, pr.number)
1605
1847
  const autoMerge = prState.state === 'open' ? pendingAutoMerge(state) : undefined
1606
1848
 
@@ -1626,6 +1868,7 @@ export class Dispatcher {
1626
1868
  // Dedupe: a reduced prState of 'conflicted' means reconcile is already
1627
1869
  // pending (a `reconcile.completed` returns it to 'open').
1628
1870
  if (state.prState === 'conflicted') return
1871
+ const events = await source.history()
1629
1872
  const baseSha = await this.baseSha(baseBranchOf(events, this.deps.config))
1630
1873
  const conflicted = await store.append(record.slug, {
1631
1874
  actor: DISPATCHER,
@@ -1674,10 +1917,11 @@ export class Dispatcher {
1674
1917
  // Re-read at the last possible point. A cancellation, replacement
1675
1918
  // command, newly due pipeline work, or application fact suppresses
1676
1919
  // this attempt; the next tick reclassifies from fresh forge state.
1677
- const latestEvents = await store.getEvents(record.slug)
1678
- const latestState = reduceBuild(latestEvents)
1920
+ await store.refreshBuild(record.slug)
1921
+ const latest = await store.buildFacts(record.slug)
1922
+ const latestState = latest.state
1679
1923
  const latestIntent = pendingAutoMerge(latestState)
1680
- const decision = decideNext(latestEvents, this.deps.config)
1924
+ const decision = decideNextFromFacts(latest.state, latest.log, this.deps.config)
1681
1925
  if (
1682
1926
  latestState.pr?.number === pr.number &&
1683
1927
  latestIntent?.enabled === true &&
@@ -1693,6 +1937,7 @@ export class Dispatcher {
1693
1937
  switch (prState.state) {
1694
1938
  case 'merged': {
1695
1939
  if (executionLeaseLive) return
1940
+ const events = await source.history()
1696
1941
  // Emit the fact once (a crash between steps re-runs this block; the
1697
1942
  // reduced prState dedupes the event, the log dedupes the release).
1698
1943
  if (state.prState !== 'merged') {
@@ -1718,6 +1963,7 @@ export class Dispatcher {
1718
1963
  }
1719
1964
  case 'closed': {
1720
1965
  if (executionLeaseLive) return
1966
+ const events = await source.history()
1721
1967
  // Closed without merge is a human decision — back to Triage (§15.7).
1722
1968
  if (state.prState !== 'closed') {
1723
1969
  await store.append(record.slug, {
@@ -1774,14 +2020,14 @@ export class Dispatcher {
1774
2020
  workspacePath: this.deps.checkout ?? this.deps.repo,
1775
2021
  asset: hosted.payload.asset,
1776
2022
  })
1777
- await this.deps.store.append(slug, {
2023
+ await this.store.append(slug, {
1778
2024
  actor: DISPATCHER,
1779
2025
  type: 'pr-attachment.reclaimed',
1780
2026
  payload: { hostedSeq: hosted.seq },
1781
2027
  })
1782
2028
  } catch (error) {
1783
2029
  try {
1784
- await this.deps.store.append(slug, {
2030
+ await this.store.append(slug, {
1785
2031
  actor: DISPATCHER,
1786
2032
  type: 'pr-attachment.reclaim-failed',
1787
2033
  payload: {
@@ -1801,24 +2047,7 @@ export class Dispatcher {
1801
2047
  }
1802
2048
 
1803
2049
  private recoveryCheckpoint(events: AbEvent[]): string | undefined {
1804
- let settled: string | undefined
1805
- let original: string | undefined
1806
- for (const event of events) {
1807
- if (event.type === 'workspace.provisioned' && original === undefined) {
1808
- original = event.payload.base.sha
1809
- } else if (event.type === 'implement.completed') {
1810
- settled = event.payload.commits.head
1811
- } else if (event.type === 'reconcile.completed') {
1812
- settled = event.payload.mergeCommit
1813
- } else if (
1814
- event.type === 'finalize.step-completed' &&
1815
- event.payload.ok &&
1816
- event.payload.headSha !== undefined
1817
- ) {
1818
- settled = event.payload.headSha
1819
- }
1820
- }
1821
- return settled ?? original
2050
+ return recoveryCheckpointReducer.reduce(events)
1822
2051
  }
1823
2052
 
1824
2053
  private async recordInfrastructureFailure(
@@ -1834,7 +2063,7 @@ export class Dispatcher {
1834
2063
  ): Promise<void> {
1835
2064
  const execution = [...events].reverse().find((event) => event.type === 'execution.started')
1836
2065
  const appended = await appendInfrastructureFailure(
1837
- { store: this.deps.store, ids: this.deps.ids },
2066
+ { store: this.store, ids: this.deps.ids },
1838
2067
  {
1839
2068
  slug,
1840
2069
  events,
@@ -1884,7 +2113,7 @@ export class Dispatcher {
1884
2113
  // failure propagates — a stale workspace with an unrecorded pending
1885
2114
  // request is never reaped (the next tick retries).
1886
2115
  events = await settlePublicationBeforeRelease(
1887
- { store: this.deps.store, settlePublication: this.deps.settlePublication },
2116
+ { store: this.store, settlePublication: this.settlePublication },
1888
2117
  slug,
1889
2118
  events,
1890
2119
  reason,
@@ -1903,7 +2132,7 @@ export class Dispatcher {
1903
2132
  ...(open.localPath !== undefined ? { localPath: open.localPath } : {}),
1904
2133
  branch: open.branch,
1905
2134
  })
1906
- const cleaned = await this.deps.store.append(slug, {
2135
+ const cleaned = await this.store.append(slug, {
1907
2136
  actor: DISPATCHER,
1908
2137
  type: 'infrastructure.cleanup-attempted',
1909
2138
  payload: {
@@ -1916,7 +2145,7 @@ export class Dispatcher {
1916
2145
  },
1917
2146
  })
1918
2147
  events.push(cleaned)
1919
- const released = await this.deps.store.append(slug, {
2148
+ const released = await this.store.append(slug, {
1920
2149
  actor: DISPATCHER,
1921
2150
  type: 'workspace.released',
1922
2151
  payload: { ref: open.ref, reason },
@@ -1925,7 +2154,7 @@ export class Dispatcher {
1925
2154
  return true
1926
2155
  } catch (error) {
1927
2156
  const message = error instanceof Error ? error.message : String(error)
1928
- const failed = await this.deps.store.append(slug, {
2157
+ const failed = await this.store.append(slug, {
1929
2158
  actor: DISPATCHER,
1930
2159
  type: 'infrastructure.cleanup-attempted',
1931
2160
  payload: {
@@ -1973,7 +2202,7 @@ export class Dispatcher {
1973
2202
  // workspace still exists. The guard's append failure propagates — the
1974
2203
  // release never proceeds past an unrecorded pending request.
1975
2204
  events = await settlePublicationBeforeRelease(
1976
- { store: this.deps.store, settlePublication: this.deps.settlePublication },
2205
+ { store: this.store, settlePublication: this.settlePublication },
1977
2206
  slug,
1978
2207
  events,
1979
2208
  reason,
@@ -1988,7 +2217,7 @@ export class Dispatcher {
1988
2217
  ).length + 1
1989
2218
  try {
1990
2219
  const outcome = await recovery.reap(handle)
1991
- const cleaned = await this.deps.store.append(slug, {
2220
+ const cleaned = await this.store.append(slug, {
1992
2221
  actor: DISPATCHER,
1993
2222
  type: 'infrastructure.cleanup-attempted',
1994
2223
  payload: {
@@ -2003,7 +2232,7 @@ export class Dispatcher {
2003
2232
  events.push(cleaned)
2004
2233
  } catch (error) {
2005
2234
  const message = error instanceof Error ? error.message : String(error)
2006
- const failed = await this.deps.store.append(slug, {
2235
+ const failed = await this.store.append(slug, {
2007
2236
  actor: DISPATCHER,
2008
2237
  type: 'infrastructure.cleanup-attempted',
2009
2238
  payload: {
@@ -2028,7 +2257,7 @@ export class Dispatcher {
2028
2257
  } else {
2029
2258
  await owner.release(handle)
2030
2259
  }
2031
- return this.deps.store.append(slug, {
2260
+ return this.store.append(slug, {
2032
2261
  actor: DISPATCHER,
2033
2262
  type: 'workspace.released',
2034
2263
  payload: { ref: open.ref, reason },
@@ -2046,7 +2275,15 @@ export class Dispatcher {
2046
2275
  private async baseSha(baseBranch: string): Promise<string> {
2047
2276
  const remoteBranchSha = this.deps.forge.remoteBranchSha
2048
2277
  if (remoteBranchSha !== undefined) {
2049
- return await remoteBranchSha.call(this.deps.forge, baseBranch)
2278
+ try {
2279
+ return await remoteBranchSha.call(this.deps.forge, baseBranch)
2280
+ } catch {
2281
+ // A forge that advertises the capability but cannot resolve this branch
2282
+ // (a checkout-mode adapter, an unseeded test fake, or a transient
2283
+ // remote failure) falls through to the physical checkout's git read.
2284
+ // In origin mode there is no checkout, so the git read's failure is the
2285
+ // actionable error and the tick still fails closed.
2286
+ }
2050
2287
  }
2051
2288
  const args = [
2052
2289
  'git',
@@ -2068,6 +2305,29 @@ export class Dispatcher {
2068
2305
 
2069
2306
  // ── b. Interrupted dispatch recovery ───────────────────────────────────────
2070
2307
 
2308
+ /** The claim-time `build.created` payload. Assets are omitted when the ticket
2309
+ * had none, so a no-asset build's log is exactly what it was before. */
2310
+ private buildCreatedPayload(
2311
+ ticket: TicketRef,
2312
+ repo: string,
2313
+ seed:
2314
+ | { autoMergeUser?: string; autoMergeDefaultSeq?: number; assets?: PinnedAsset[] }
2315
+ | undefined,
2316
+ ): EventPayload<'build.created'> {
2317
+ const { config } = this.deps
2318
+ return {
2319
+ ticket,
2320
+ repo,
2321
+ baseBranch: config.baseBranch,
2322
+ ...(seed?.autoMergeUser !== undefined ? { autoMergeRequestedBy: seed.autoMergeUser } : {}),
2323
+ ...(seed?.autoMergeDefaultSeq !== undefined
2324
+ ? { autoMergeDefaultSeq: seed.autoMergeDefaultSeq }
2325
+ : {}),
2326
+ ...(config.pr !== undefined ? { pr: config.pr } : {}),
2327
+ ...(seed?.assets !== undefined && seed.assets.length > 0 ? { assets: seed.assets } : {}),
2328
+ }
2329
+ }
2330
+
2071
2331
  /** Every boundary before runner attachment is derived from durable facts.
2072
2332
  * Missing facts are the todo list; successful provider calls are followed
2073
2333
  * immediately by their facts, so an ordinary later tick can continue. */
@@ -2091,9 +2351,12 @@ export class Dispatcher {
2091
2351
  body: string
2092
2352
  authoredSession?: string
2093
2353
  autoMergeUser?: string
2354
+ autoMergeDefaultSeq?: number
2355
+ assets?: PinnedAsset[]
2094
2356
  },
2095
2357
  ): Promise<DispatchOutcome> {
2096
- const { store, config } = this.deps
2358
+ const store = this.store
2359
+ const { config } = this.deps
2097
2360
  let stage: EventPayload<'dispatch.failed'>['stage'] = 'create'
2098
2361
  try {
2099
2362
  let events = await store.getEvents(record.slug)
@@ -2103,18 +2366,16 @@ export class Dispatcher {
2103
2366
  'build record lacks the immutable ticket facts needed to reconstruct build.created',
2104
2367
  )
2105
2368
  }
2369
+ // Legacy path: a record an older dispatcher left with an empty stream.
2370
+ // New claims write `build.created` atomically with the record, so this
2371
+ // can only sample the ticket's current assets (SPEC §6.3).
2372
+ const assets =
2373
+ seed?.assets ??
2374
+ (await samplePinnedAssets(store, record.repo, record.ticket.id).catch(() => []))
2106
2375
  await store.appendIfCurrent(record.slug, 0, {
2107
2376
  actor: DISPATCHER,
2108
2377
  type: 'build.created',
2109
- payload: {
2110
- ticket: record.ticket,
2111
- repo: record.repo,
2112
- baseBranch: config.baseBranch,
2113
- ...(seed?.autoMergeUser !== undefined
2114
- ? { autoMergeRequestedBy: seed.autoMergeUser }
2115
- : {}),
2116
- ...(config.pr !== undefined ? { pr: config.pr } : {}),
2117
- },
2378
+ payload: this.buildCreatedPayload(record.ticket, record.repo, { ...seed, assets }),
2118
2379
  })
2119
2380
  events = await store.getEvents(record.slug)
2120
2381
  if (!events.some((event) => event.type === 'build.created')) {
@@ -2124,6 +2385,10 @@ export class Dispatcher {
2124
2385
 
2125
2386
  const created = events.find((event) => event.type === 'build.created')
2126
2387
  const autoMergeUser = seed?.autoMergeUser ?? created?.payload.autoMergeRequestedBy
2388
+ // The claim's sampled default fact, for the request's fan-out provenance:
2389
+ // the seed's own value first (this dispatch created the build), else the
2390
+ // durable `build.created` fact a recovery pass resumes from.
2391
+ const autoMergeDefaultSeq = seed?.autoMergeDefaultSeq ?? created?.payload.autoMergeDefaultSeq
2127
2392
  if (
2128
2393
  autoMergeUser !== undefined &&
2129
2394
  !events.some((event) => event.type === 'build.auto-merge-requested')
@@ -2131,7 +2396,9 @@ export class Dispatcher {
2131
2396
  await store.append(record.slug, {
2132
2397
  actor: humanActor(autoMergeUser),
2133
2398
  type: 'build.auto-merge-requested',
2134
- payload: {},
2399
+ payload: {
2400
+ ...(autoMergeDefaultSeq !== undefined ? { defaultSeq: autoMergeDefaultSeq } : {}),
2401
+ },
2135
2402
  })
2136
2403
  events = await store.getEvents(record.slug)
2137
2404
  }
@@ -2172,6 +2439,7 @@ export class Dispatcher {
2172
2439
  ref: handle.ref,
2173
2440
  path: handle.path,
2174
2441
  ...(handle.localPath !== undefined ? { localPath: handle.localPath } : {}),
2442
+ remote: this.deps.workspaces.publication !== undefined,
2175
2443
  branch: handle.branch,
2176
2444
  base: handle.base,
2177
2445
  },
@@ -2218,11 +2486,14 @@ export class Dispatcher {
2218
2486
  body: string
2219
2487
  authoredSession?: string
2220
2488
  autoMergeUser?: string
2489
+ autoMergeDefaultSeq?: number
2490
+ assets?: PinnedAsset[]
2221
2491
  },
2222
2492
  report?: TickReport,
2223
2493
  launched?: Set<string>,
2224
2494
  ): Promise<Exclude<DispatchOutcome, 'deferred'>> {
2225
- const { store } = this.deps
2495
+ const store = this.store
2496
+
2226
2497
  let stage: EventPayload<'dispatch.failed'>['stage'] = 'spec'
2227
2498
  let authored = false
2228
2499
  try {
@@ -2303,6 +2574,7 @@ export class Dispatcher {
2303
2574
  // Discard validation and recovery selection happened before several
2304
2575
  // provider awaits. Re-read immediately before launch so a request made
2305
2576
  // during those boundaries parks cleanly for the next janitor pass.
2577
+ await store.refreshBuild(record.slug)
2306
2578
  const launchState = reduceBuild(await store.getEvents(record.slug))
2307
2579
  if (launchState.status !== 'queued' || launchState.discardRequest !== undefined) {
2308
2580
  return 'parked'
@@ -2335,9 +2607,8 @@ export class Dispatcher {
2335
2607
  paused: boolean,
2336
2608
  opts: TickOpts = {},
2337
2609
  ): Promise<void> {
2338
- for (const record of await this.deps.store.listBuilds()) {
2610
+ for (const record of await this.nonterminalBuilds()) {
2339
2611
  if (
2340
- record.repo !== this.deps.repo ||
2341
2612
  record.ticket === undefined ||
2342
2613
  launched.has(record.slug) ||
2343
2614
  this.continuations.has(record.slug)
@@ -2346,8 +2617,7 @@ export class Dispatcher {
2346
2617
  }
2347
2618
  // Budget gate: recovery issues store transport calls per build.
2348
2619
  if (this.outOfBudget(opts)) break
2349
- const events = await this.deps.store.getEvents(record.slug)
2350
- const state = reduceBuild(events)
2620
+ const { state } = await this.store.buildFacts(record.slug)
2351
2621
  if (
2352
2622
  state.status !== 'queued' ||
2353
2623
  state.discardRequest !== undefined ||
@@ -2383,14 +2653,15 @@ export class Dispatcher {
2383
2653
  paused: boolean,
2384
2654
  opts: TickOpts = {},
2385
2655
  ): Promise<void> {
2386
- const { store, config } = this.deps
2387
- for (const record of await store.listBuilds()) {
2388
- if (record.repo !== this.deps.repo || launched.has(record.slug)) continue
2656
+ const store = this.store
2657
+ const { config } = this.deps
2658
+ for (const record of await this.nonterminalBuilds()) {
2659
+ if (launched.has(record.slug)) continue
2389
2660
  // Budget gate: resume launches runners (transport-backed work).
2390
2661
  if (this.outOfBudget(opts)) break
2391
2662
 
2392
- const events = await store.getEvents(record.slug)
2393
- const state = reduceBuild(events)
2663
+ const facts = await store.buildFacts(record.slug)
2664
+ const state = facts.state
2394
2665
  if (
2395
2666
  state.status === 'done' ||
2396
2667
  state.status === 'aborted' ||
@@ -2400,7 +2671,7 @@ export class Dispatcher {
2400
2671
  continue
2401
2672
  }
2402
2673
 
2403
- const decision = decideNext(events, config)
2674
+ const decision = decideNextFromFacts(state, facts.log, config)
2404
2675
  if (decision.kind === 'wait') {
2405
2676
  if (decision.reason === 'blocked' && state.openEscalations.length > 0) {
2406
2677
  report.blockedDiagnostics.push(
@@ -2451,8 +2722,9 @@ export class Dispatcher {
2451
2722
  opts: TickOpts = {},
2452
2723
  ): Promise<void> {
2453
2724
  const now = this.deps.clock().getTime()
2454
- for (const record of await this.deps.store.listBuilds()) {
2455
- if (record.repo !== this.deps.repo) continue // §12: not this dispatcher's build
2725
+ // Terminal builds are filtered on the digest before the lease and TTL
2726
+ // checks, so a zero TTL cannot fall through to a log read.
2727
+ for (const record of await this.nonterminalBuilds()) {
2456
2728
  if (launched.has(record.slug)) continue
2457
2729
  // Budget gate: the sweep re-attaches runners per expired build.
2458
2730
  if (this.outOfBudget(opts)) break
@@ -2461,16 +2733,22 @@ export class Dispatcher {
2461
2733
  } else if (now - new Date(record.updatedAt).getTime() < this.leaseTtlMs) {
2462
2734
  continue // absent lease within the first-claim grace (see DispatcherOpts)
2463
2735
  }
2464
- const events = await this.deps.store.getEvents(record.slug)
2465
- const state = reduceBuild(events)
2736
+ const facts = await this.store.buildFacts(record.slug)
2737
+ const state = facts.state
2466
2738
  if (
2467
2739
  (state.status === 'queued' && state.discardRequest !== undefined) ||
2468
2740
  heldByRepositoryPause(state, paused)
2469
2741
  ) {
2470
2742
  continue
2471
2743
  }
2472
- const decision = decideNext(events, this.deps.config)
2744
+ const decision = decideNextFromFacts(state, facts.log, this.deps.config)
2473
2745
  if (state.status === 'done' || state.status === 'aborted') continue
2746
+ // Raw history, read at most once and only by a branch that acts on it.
2747
+ let loaded: AbEvent[] | undefined
2748
+ const history = async (): Promise<AbEvent[]> => {
2749
+ loaded ??= await this.store.getEvents(record.slug)
2750
+ return loaded
2751
+ }
2474
2752
  // Liveness gate (durable supervision): a recorded execution the provider
2475
2753
  // observes as RUNNING is left completely alone — no settle, no reap, no
2476
2754
  // replacement, no re-attach. This is the change that stops a supervisor
@@ -2478,13 +2756,12 @@ export class Dispatcher {
2478
2756
  // proved end settles first (completion facts, lease release, publication)
2479
2757
  // and then falls through to the normal decision; a provider-proved loss
2480
2758
  // falls through to the existing stale path below.
2481
- const foreignExecution = openExecution(events)
2759
+ const foreignExecution = facts.open
2482
2760
  // A provider- or log-proved end in the same generation re-attaches in
2483
2761
  // place: the guest finished its work, the workspace is still the
2484
2762
  // build's, and replacing the environment would discard a healthy
2485
2763
  // generation. Only an unproven stale lease or a proved loss reaps.
2486
- const lastExecution = lastExecutionOutcome(events)
2487
- let endedInPlace = lastExecution === 'completed'
2764
+ let endedInPlace = facts.lastExecution === 'completed'
2488
2765
  if (
2489
2766
  foreignExecution !== null &&
2490
2767
  foreignExecution.commandId !== undefined &&
@@ -2493,16 +2770,16 @@ export class Dispatcher {
2493
2770
  const owner = this.executionOwner(foreignExecution.provider)
2494
2771
  const execution = owner?.buildExecution
2495
2772
  if (execution?.observe !== undefined) {
2496
- const settlement = await settleExecution(
2773
+ const settlement = await settleOpenExecution(
2497
2774
  {
2498
- store: this.deps.store,
2775
+ store: this.store,
2499
2776
  execution,
2500
- ...(this.deps.settlePublication !== undefined
2501
- ? { settlePublication: this.deps.settlePublication }
2777
+ ...(this.settlePublication !== undefined
2778
+ ? { settlePublication: this.settlePublication }
2502
2779
  : {}),
2503
2780
  },
2504
2781
  record.slug,
2505
- events,
2782
+ foreignExecution,
2506
2783
  )
2507
2784
  if (settlement === 'running') continue
2508
2785
  if (settlement === 'settled') {
@@ -2514,8 +2791,8 @@ export class Dispatcher {
2514
2791
  const publicationRecoveryDue =
2515
2792
  state.status !== 'paused' &&
2516
2793
  state.status !== 'blocked' &&
2517
- (publicationPending(events) || abandonedPublicationPending(events))
2518
- const open = openWorkspace(events)
2794
+ (facts.publication.pending() || facts.publication.abandonedPending())
2795
+ const open = facts.workspace
2519
2796
  // A provider- or log-proved END in the same generation re-attaches in
2520
2797
  // place: the guest finished its work, the workspace is still the
2521
2798
  // build's, and replacing the environment would discard a healthy
@@ -2528,13 +2805,13 @@ export class Dispatcher {
2528
2805
  : state.status === 'blocked'
2529
2806
  ? ('blocked' as const)
2530
2807
  : ('replacement' as const)
2531
- const reaped = await this.reapStaleWorkspace(record.slug, events, reason)
2808
+ const reaped = await this.reapStaleWorkspace(record.slug, await history(), reason)
2532
2809
  if (!reaped) continue
2533
2810
  if (!parked) {
2534
2811
  // Recovery-capable provider: reap inline (bounded), then kick the
2535
2812
  // same background continuation — it provisions and launches without
2536
2813
  // blocking this tick. Local providers never enter this branch.
2537
- const kicked = await this.ensureProvisionContinuation(record, events, 'launch')
2814
+ const kicked = await this.ensureProvisionContinuation(record, await history(), 'launch')
2538
2815
  if (kicked === 'kicked') {
2539
2816
  report.provisioning += 1
2540
2817
  report.swept += 1
@@ -2551,11 +2828,11 @@ export class Dispatcher {
2551
2828
  // already continued earlier.
2552
2829
  if (decision.kind === 'wait' && !publicationRecoveryDue) continue
2553
2830
  if (
2554
- openWorkspace(events) === null &&
2831
+ facts.workspace === null &&
2555
2832
  this.deps.workspaces.recovery !== undefined &&
2556
- events.some((event) => event.type === 'workspace.provisioned')
2833
+ (await history()).some((event) => event.type === 'workspace.provisioned')
2557
2834
  ) {
2558
- const kicked = await this.ensureProvisionContinuation(record, events, 'launch')
2835
+ const kicked = await this.ensureProvisionContinuation(record, await history(), 'launch')
2559
2836
  if (kicked === 'kicked') {
2560
2837
  report.provisioning += 1
2561
2838
  report.swept += 1
@@ -2578,17 +2855,17 @@ export class Dispatcher {
2578
2855
  autoMergeUser: string | undefined,
2579
2856
  paused: boolean,
2580
2857
  opts: TickOpts = {},
2858
+ autoMergeSeedFact: AutoMergeDefaultFact | undefined = undefined,
2581
2859
  ): Promise<void> {
2582
- const { store, tickets, config } = this.deps
2860
+ const store = this.store
2861
+ const { tickets, config } = this.deps
2583
2862
  // Blocked and paused builds still occupy a slot: their workspaces and
2584
2863
  // pending work are live, only waiting on a human. Capacity is per repo
2585
2864
  // (§16.1) — another repo's builds never consume this repo's slots.
2586
2865
  let active = 0
2587
2866
  const activeTicketIds = new Set<string>()
2588
- for (const record of await store.listBuilds()) {
2589
- if (record.repo !== this.deps.repo) continue
2590
- const state = reduceBuild(await store.getEvents(record.slug))
2591
- if (state.status !== 'done' && state.status !== 'aborted') {
2867
+ for (const { record, digest } of await this.repoBuildDigests()) {
2868
+ if (digest.terminal === undefined) {
2592
2869
  active += 1
2593
2870
  if (record.ticket) activeTicketIds.add(record.ticket.id)
2594
2871
  }
@@ -2599,9 +2876,11 @@ export class Dispatcher {
2599
2876
  // standing queue-depth report and must stay honest exactly when the
2600
2877
  // dispatcher is saturated. Sources that keep a claimed ticket in the
2601
2878
  // ready state are deduped against the active builds embodying them.
2602
- const journalBeforeReady = await this.repositoryEvents()
2879
+ await this.store.refreshJournal()
2880
+ const journalBeforeReady = this.store.recordedJournal()
2603
2881
  const listing = await tickets.listReady(readyCriteria(config))
2604
- const journalAfterReady = await this.repositoryEvents()
2882
+ await this.store.refreshJournal()
2883
+ const journalAfterReady = this.store.recordedJournal()
2605
2884
  const creationHolds = new Map(
2606
2885
  harvestCreationsDuringReadyScan(journalBeforeReady, journalAfterReady).map((creation) => [
2607
2886
  creation.creationKey.toLowerCase(),
@@ -2691,6 +2970,10 @@ export class Dispatcher {
2691
2970
  continue
2692
2971
  }
2693
2972
 
2973
+ // Freeze the ticket's assets at the claim (SPEC §6.3): sampled before the
2974
+ // spec-authoring interval so edits made during it are never pinned.
2975
+ const assets = await samplePinnedAssets(store, this.deps.repo, ticket.ref.id)
2976
+
2694
2977
  // Quality gate (§6.3): import a conforming ticket body as the spec;
2695
2978
  // author one for thin-but-groomed tickets; otherwise bounce.
2696
2979
  let body = ticket.body
@@ -2717,12 +3000,29 @@ export class Dispatcher {
2717
3000
  const slug = await this.uniqueSlug(baseSlug)
2718
3001
  const branch = `ab/${slug}`
2719
3002
  const repoOrigin = await this.resolveRepoOriginOnce()
3003
+ const createdSeed = {
3004
+ ...(autoMergeUser !== undefined ? { autoMergeUser } : {}),
3005
+ // Claim-time provenance: the newest durable default fact that agrees
3006
+ // with the launch sample, recorded on `build.created` so the fan-out's
3007
+ // seq comparison sees the claim as already answering that fact — and
3008
+ // so a newer opposite fact (an interleaved toggle) stays strictly
3009
+ // ahead of it and reconciles this build on the next tick (f_b851c0e8).
3010
+ ...(autoMergeSeedFact !== undefined ? { autoMergeDefaultSeq: autoMergeSeedFact.seq } : {}),
3011
+ assets,
3012
+ }
3013
+ // `build.created` rides with the record so the pinned assets and the
3014
+ // record exist together or not at all.
2720
3015
  const record = await store.createBuild({
2721
3016
  slug,
2722
3017
  repo: this.deps.repo,
2723
3018
  ticket: ticket.ref,
2724
3019
  branch,
2725
3020
  ...(repoOrigin !== undefined ? { repoOrigin } : {}),
3021
+ created: {
3022
+ actor: DISPATCHER,
3023
+ type: 'build.created',
3024
+ payload: this.buildCreatedPayload(ticket.ref, this.deps.repo, createdSeed),
3025
+ },
2726
3026
  })
2727
3027
  report.queued -= 1
2728
3028
  capacity -= 1
@@ -2730,7 +3030,7 @@ export class Dispatcher {
2730
3030
  ticket,
2731
3031
  body,
2732
3032
  ...(authoredSession !== undefined ? { authoredSession } : {}),
2733
- ...(autoMergeUser !== undefined ? { autoMergeUser } : {}),
3033
+ ...createdSeed,
2734
3034
  })
2735
3035
  // Accepted dispatches: durable boundaries recorded. A deferred outcome
2736
3036
  // means the remote provisioning continuation owns the rest.
@@ -2817,7 +3117,7 @@ export class Dispatcher {
2817
3117
  /** Valid bounded base, deduped store-wide with -2/-3/… suffixes. */
2818
3118
  private async uniqueSlug(base: string): Promise<string> {
2819
3119
  let slug = base
2820
- for (let n = 2; (await this.deps.store.getBuild(slug)) !== null; n += 1) {
3120
+ for (let n = 2; (await this.store.getBuild(slug)) !== null; n += 1) {
2821
3121
  slug = `${base}-${n}`
2822
3122
  }
2823
3123
  return slug