@defrex/autobuild 0.12.0 → 0.14.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 (151) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +138 -16
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +155 -73
  5. package/docs/configuration.md +41 -9
  6. package/docs/hosted-service.md +31 -5
  7. package/docs/operator-api.md +8 -1
  8. package/docs/remote-store-protocol.md +94 -2
  9. package/docs/remote-ticket-protocol.md +151 -9
  10. package/docs/setup.md +33 -7
  11. package/docs/ticket-migration.md +64 -0
  12. package/package.json +4 -3
  13. package/packages/core/src/cli/attention.ts +18 -164
  14. package/packages/core/src/cli/build-control.ts +23 -16
  15. package/packages/core/src/cli/bulk-control.ts +3 -2
  16. package/packages/core/src/cli/connect.ts +141 -38
  17. package/packages/core/src/cli/context.ts +23 -79
  18. package/packages/core/src/cli/dashboard/model.ts +36 -22
  19. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  20. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  21. package/packages/core/src/cli/dispatch-process.ts +14 -7
  22. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  23. package/packages/core/src/cli/dispatch.ts +304 -82
  24. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  25. package/packages/core/src/cli/harvest.ts +84 -42
  26. package/packages/core/src/cli/help.ts +14 -8
  27. package/packages/core/src/cli/historical-build-state.ts +27 -0
  28. package/packages/core/src/cli/installation.ts +67 -13
  29. package/packages/core/src/cli/main.ts +23 -5
  30. package/packages/core/src/cli/mcp.ts +2 -1
  31. package/packages/core/src/cli/pr-attachments.ts +10 -13
  32. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  33. package/packages/core/src/cli/pr-summary.ts +43 -13
  34. package/packages/core/src/cli/publication-settlement.ts +10 -20
  35. package/packages/core/src/cli/repository-status.ts +38 -2
  36. package/packages/core/src/cli/self-update.ts +5 -1
  37. package/packages/core/src/cli/status.ts +62 -32
  38. package/packages/core/src/cli/terminals.ts +25 -142
  39. package/packages/core/src/cli/ticket-assets.ts +7 -2
  40. package/packages/core/src/cli/ticket-editor.ts +82 -0
  41. package/packages/core/src/cli/ticket.ts +166 -36
  42. package/packages/core/src/cli/wait.ts +91 -84
  43. package/packages/core/src/cli/watch.ts +60 -97
  44. package/packages/core/src/config/pipeline-source.ts +4 -3
  45. package/packages/core/src/config/schema.ts +35 -3
  46. package/packages/core/src/connection.ts +3 -3
  47. package/packages/core/src/events/catalog.ts +3 -2
  48. package/packages/core/src/events/payloads.ts +6 -0
  49. package/packages/core/src/events/repository.ts +7 -3
  50. package/packages/core/src/integration/harness.ts +38 -4
  51. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
  52. package/packages/core/src/kernel/auto-merge.ts +19 -3
  53. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  54. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  55. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  56. package/packages/core/src/kernel/harvest.ts +25 -23
  57. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  58. package/packages/core/src/kernel/log-index-value.ts +38 -0
  59. package/packages/core/src/kernel/read-facts.ts +637 -0
  60. package/packages/core/src/kernel/reducer-registry.ts +139 -3
  61. package/packages/core/src/kernel/repository-attention.ts +179 -0
  62. package/packages/core/src/kernel/service-facts.ts +135 -0
  63. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  64. package/packages/core/src/ontology.ts +1 -0
  65. package/packages/core/src/operator/annotations.ts +12 -0
  66. package/packages/core/src/operator/control.ts +5 -6
  67. package/packages/core/src/operator/index.ts +7 -0
  68. package/packages/core/src/operator/protocol.ts +19 -2
  69. package/packages/core/src/operator/query.ts +37 -20
  70. package/packages/core/src/operator/read-view.ts +4 -13
  71. package/packages/core/src/operator/registry.ts +61 -8
  72. package/packages/core/src/operator/requests.ts +2 -14
  73. package/packages/core/src/operator/sandbox.ts +10 -9
  74. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  75. package/packages/core/src/operator/tickets.ts +132 -22
  76. package/packages/core/src/plugin-sdk/index.ts +19 -0
  77. package/packages/core/src/plugins/runtimes.ts +5 -1
  78. package/packages/core/src/ports/runner/session-env.ts +2 -0
  79. package/packages/core/src/ports/tickets/contract.ts +66 -1
  80. package/packages/core/src/ports/tickets/create.ts +48 -12
  81. package/packages/core/src/ports/tickets/fake.ts +30 -1
  82. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  83. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  84. package/packages/core/src/ports/tickets/file.ts +114 -120
  85. package/packages/core/src/ports/tickets/grants.ts +47 -0
  86. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  87. package/packages/core/src/ports/tickets/identity.ts +20 -0
  88. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  89. package/packages/core/src/ports/tickets/linear.ts +74 -9
  90. package/packages/core/src/ports/tickets/migration.ts +336 -0
  91. package/packages/core/src/ports/tickets/operations.ts +38 -7
  92. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  93. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  94. package/packages/core/src/ports/tickets/remote.ts +60 -3
  95. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  96. package/packages/core/src/ports/tickets/revision.ts +62 -0
  97. package/packages/core/src/ports/tickets/states.ts +61 -0
  98. package/packages/core/src/ports/types.ts +40 -3
  99. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  100. package/packages/core/src/ports/workspace/create.ts +5 -1
  101. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  102. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  103. package/packages/core/src/processes/build-child.ts +6 -6
  104. package/packages/core/src/processes/build-runner.ts +62 -70
  105. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  106. package/packages/core/src/processes/dispatcher.ts +223 -205
  107. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  108. package/packages/core/src/processes/harvest-child.ts +30 -4
  109. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  110. package/packages/core/src/processes/harvest-runner.ts +58 -74
  111. package/packages/core/src/processes/harvest.ts +72 -29
  112. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  113. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  114. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  115. package/packages/core/src/processes/publication-loss.ts +35 -21
  116. package/packages/core/src/processes/repo-view.ts +229 -1099
  117. package/packages/core/src/processes/tick-publication.ts +110 -0
  118. package/packages/core/src/store/adapter.ts +23 -0
  119. package/packages/core/src/store/build-scope.ts +17 -0
  120. package/packages/core/src/store/contract.ts +283 -1
  121. package/packages/core/src/store/local/schema.ts +1 -0
  122. package/packages/core/src/store/local/store.ts +180 -55
  123. package/packages/core/src/store/memory.ts +119 -29
  124. package/packages/core/src/store/phase-session.ts +17 -0
  125. package/packages/core/src/store/reduced-reads.ts +239 -0
  126. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  127. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  128. package/packages/core/src/store/reducer-values.ts +145 -0
  129. package/packages/core/src/store/remote/client.ts +41 -0
  130. package/packages/core/src/store/remote/compatibility.ts +2 -0
  131. package/packages/core/src/store/remote/index.ts +19 -0
  132. package/packages/core/src/store/remote/protocol.ts +16 -0
  133. package/packages/core/src/store/remote/version.ts +3 -1
  134. package/packages/core/src/store/repo-state-events.ts +1 -1
  135. package/packages/core/src/store/repo-tick.ts +72 -0
  136. package/packages/core/src/store/session-handle.ts +19 -0
  137. package/packages/core/src/store/snapshots.ts +10 -0
  138. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  139. package/packages/core/src/store/ticket-assets.ts +86 -8
  140. package/packages/core/src/store/types.ts +40 -3
  141. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  142. package/packages/core/src/testing/index.ts +8 -0
  143. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  144. package/skills/guide/SKILL.md +97 -12
  145. package/skills/guide/references/operator-api.md +8 -1
  146. package/skills/guide/references/remote-store-protocol.md +94 -2
  147. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  148. package/skills/guide/references/setup.md +33 -7
  149. package/skills/guide/references/ticket-migration.md +64 -0
  150. package/skills/tickets/SKILL.md +71 -0
  151. package/templates/hosted-service/package.json +2 -2
@@ -1,8 +1,10 @@
1
+ import { readBuildDigests } from '../store/reduced-reads'
2
+ import { readBuildFacts, type CurrentBuildFacts } from '../store/reduced-reads'
3
+ import { ensureExecutionProgress } from './execution-no-progress'
4
+ import { ticketIdentity } from '../ports/tickets/identity'
5
+ import { ticketLifecycle } from '../ports/tickets/lifecycle'
1
6
  import { ensureDispatchBudget } from './dispatch-failure-budget'
2
- import {
3
- reduceTicketSourceHealth,
4
- type TicketSourceDegradation,
5
- } from '../kernel/ticket-source-health'
7
+ import type { TicketSourceDegradation } from '../kernel/ticket-source-health'
6
8
  /**
7
9
  * The dispatcher process (SPEC §3.3, §12): watches the TicketSource for
8
10
  * Ready tickets, claims, provisions a workspace, and launches build-runners
@@ -57,19 +59,18 @@ import {
57
59
  } from '../kernel/auto-merge'
58
60
  import {
59
61
  autoMergeDefaultTarget,
60
- latestAutoMergeDefault,
62
+ pickAutoMergeDefault,
61
63
  type AutoMergeDefaultFact,
62
64
  } from '../kernel/auto-merge-default'
63
- import { reduceDispatchSettings } from '../kernel/dispatch-settings'
64
65
  import { decideNextFromFacts } from '../kernel/engine'
66
+ import { composeBuildConfig } from '../config/live'
67
+ import { BUILD_EFFECTIVE_CONFIG_ARTIFACT, parseEffectiveBuildConfig } from './build-execution-state'
65
68
  import {
66
69
  DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
67
70
  decideHarvestControl,
68
- harvestCreationsDuringReadyScan,
69
- reduceHarvest,
71
+ harvestCreationsFromStates,
70
72
  } from '../kernel/harvest'
71
- import { pendingPrAttachmentReclaims } from '../kernel/pr-attachments'
72
- import { reduceBuild, type BuildState } from '../kernel/reducer'
73
+ import type { BuildState } from '../kernel/reducer'
73
74
  import { createOperatorSandboxService, type OperatorSandboxService } from '../operator/sandbox'
74
75
  import type { ArtifactRef, TicketRef } from '../ontology'
75
76
  import { BUILD_EXECUTION_LEASE_TTL_MS } from '../ports/workspace/build-execution'
@@ -83,25 +84,15 @@ import type {
83
84
  import { TicketSourceRateLimitError } from '../ports/types'
84
85
  import type { Exec } from '../ports/workspace/git-worktree'
85
86
  import type { ArtifactMeta, BuildDigest, BuildRecord, BuildStore, Clock } from '../store/types'
86
- import { samplePinnedAssets } from '../store/ticket-assets'
87
+ import { preparePinnedAssets } from '../store/ticket-assets'
87
88
  import { resolveRepoOrigin } from '../cli/repo-state'
88
89
  import { specConformance } from '../spec-standard'
89
90
  export { specConformance, type SpecConformance } from '../spec-standard'
90
91
  import { recordInfrastructureFailure as appendInfrastructureFailure } from './infrastructure-failure-budget'
91
92
  import { settleOpenExecution } from './execution-settlement'
92
- import { openHarvestExecutions } from './harvest-execution-state'
93
93
  import { isWorkDigest, RepoViewStore } from './repo-view'
94
- import {
95
- baseBranchReducer,
96
- openBuildWorkspace,
97
- provisionMarkerReducer,
98
- recoveryCheckpointReducer,
99
- type OpenBuildWorkspace,
100
- type ProvisionMarker,
101
- } from './dispatcher-selectors'
102
- import type { RepositoryEvent } from '../events/repository'
103
- import { sandboxStates } from './sandbox-state'
104
- import { settlePublicationBeforeRelease } from './publication-loss'
94
+ import type { OpenBuildWorkspace, ProvisionMarker } from './dispatcher-selectors'
95
+ import { settlePublicationFactsBeforeRelease } from './publication-loss'
105
96
 
106
97
  // ── Readiness resolution (SPEC §3.3) ─────────────────────────────────────────
107
98
 
@@ -148,16 +139,11 @@ export function readyCriteria(config: Config): {
148
139
  * readiness is: the file tracker's grooming area IS the `triage/` directory,
149
140
  * while a Linear team only has a "Triage" workflow state when the team's
150
141
  * triage feature is enabled — Backlog is the state every Linear team has.
151
- * The hosted source is the credential-bearing Linear proxy and shares that
152
- * fallback. Plugin sources use the neutral `Triage` fallback unless configured.
142
+ * Hosted projects default to lowercase triage; plugin sources use the neutral
143
+ * `Triage` fallback unless configured.
153
144
  */
154
145
  export function defaultTriageState(config: Config): string {
155
- return (
156
- config.tickets.triageState ??
157
- (config.tickets.source === 'linear' || config.tickets.source === 'hosted'
158
- ? 'Backlog'
159
- : 'Triage')
160
- )
146
+ return ticketLifecycle(config.tickets).triage
161
147
  }
162
148
 
163
149
  /**
@@ -547,6 +533,8 @@ export interface DispatcherDeps {
547
533
  retiredWorkspaces?: readonly WorkspaceProvider[]
548
534
  forge: Forge
549
535
  config: Config
536
+ /** Configuration-only publication for incompatible pins; never launches a runner. */
537
+ repairBuildConfig?: (slug: string) => Promise<void>
550
538
  /** Current process snapshot, captured once at the beginning of each tick.
551
539
  * Omission preserves the cron/static-config API. */
552
540
  getConfig?: () => Config
@@ -609,14 +597,14 @@ export interface DispatcherDeps {
609
597
  /** Latest `workspace.provisioned` not followed by a `workspace.released` —
610
598
  * the reducer deliberately ignores workspace events (liveness is the
611
599
  * dispatcher's concern), so the janitor scans the raw log. */
612
- function openWorkspace(events: AbEvent[]): OpenBuildWorkspace | null {
613
- return openBuildWorkspace(events)
600
+ function openWorkspace(events: CurrentBuildFacts): OpenBuildWorkspace | null {
601
+ return events.workspace
614
602
  }
615
603
 
616
604
  /** The build's base branch, from its own `build.created` fact (§15.3);
617
605
  * falls back to the repo config for logs missing one. */
618
- function baseBranchOf(events: AbEvent[], config: Config): string {
619
- return baseBranchReducer.reduce(events) ?? config.baseBranch
606
+ function baseBranchOf(events: CurrentBuildFacts, config: Config): string {
607
+ return events.baseBranch ?? config.baseBranch
620
608
  }
621
609
 
622
610
  function artifactRefOf(deposited: ArtifactMeta[]): ArtifactRef {
@@ -647,6 +635,38 @@ export function heldByRepositoryPause(state: BuildState, paused: boolean): boole
647
635
  }
648
636
 
649
637
  export class Dispatcher {
638
+ /** Read at each decision boundary: artifact revisions are independent of events. */
639
+ private async decideBuild(
640
+ slug: string,
641
+ state: Parameters<typeof decideNextFromFacts>[0],
642
+ log: Parameters<typeof decideNextFromFacts>[1],
643
+ report: TickReport,
644
+ ): Promise<ReturnType<typeof decideNextFromFacts> | undefined> {
645
+ let revision: number | undefined
646
+ try {
647
+ let artifact = await this.store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
648
+ if (artifact === null) return decideNextFromFacts(state, log, this.deps.config)
649
+ revision = artifact.meta.revision
650
+ let pinned: Config
651
+ try {
652
+ pinned = parseEffectiveBuildConfig(artifact)
653
+ } catch (error) {
654
+ if (this.deps.repairBuildConfig === undefined) throw error
655
+ await this.deps.repairBuildConfig(slug)
656
+ artifact = await this.store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
657
+ if (artifact === null) throw new Error('configuration repair deposited no pipeline')
658
+ revision = artifact.meta.revision
659
+ pinned = parseEffectiveBuildConfig(artifact)
660
+ }
661
+ return decideNextFromFacts(state, log, composeBuildConfig(pinned, this.deps.config))
662
+ } catch (error) {
663
+ report.blockedDiagnostics.push(
664
+ `build ${slug} pipeline${revision === undefined ? '' : ` revision ${revision}`} unavailable: pipeline decisions withheld; next tick retries. Restore readable build-branch configuration or use a compatible dispatcher version. ${error instanceof Error ? error.message : String(error)}`,
665
+ )
666
+ return undefined
667
+ }
668
+ }
669
+
650
670
  /** Memoized normalized origin of the served repository (one git call per
651
671
  * dispatcher), recorded on created builds so ambient reads can accept a
652
672
  * differently located checkout of the same repository by origin equality.
@@ -670,7 +690,7 @@ export class Dispatcher {
670
690
  return (
671
691
  this.deps.opts?.doneState ??
672
692
  (this.deps.config.tickets.source === 'hosted'
673
- ? this.deps.config.tickets.doneState
693
+ ? ticketLifecycle(this.deps.config.tickets).done
674
694
  : undefined) ??
675
695
  'Done'
676
696
  )
@@ -725,14 +745,7 @@ export class Dispatcher {
725
745
  }
726
746
 
727
747
  async tick(opts: TickOpts = {}): Promise<TickReport> {
728
- try {
729
- return await this.runTick(opts)
730
- } finally {
731
- // Leave this tick's reduced state behind for the next process (a cache;
732
- // a failure to write it is never a tick failure). Throttled in a
733
- // resident dispatcher, always run by a cold single-tick process.
734
- await this.store.persistSnapshots()
735
- }
748
+ return this.runTick(opts)
736
749
  }
737
750
 
738
751
  private async runTick(opts: TickOpts): Promise<TickReport> {
@@ -757,9 +770,9 @@ export class Dispatcher {
757
770
  // likewise samples before calling `tick`); the auto-merge default fact is
758
771
  // the durable driver of this tick's fan-out below. Neither control
759
772
  // pretends to a serialization the store does not offer.
760
- const repoEvents = this.store.recordedJournal()
761
- const paused = reduceDispatchSettings(repoEvents).paused
762
- const autoMergeDefault = latestAutoMergeDefault(repoEvents)
773
+ const repoFacts = this.store.repositoryFacts()
774
+ const paused = repoFacts.settings.paused
775
+ const autoMergeDefault = pickAutoMergeDefault(repoFacts.autoMerge)
763
776
  // The claim-time seed cites the newest fact whose enabled state matches
764
777
  // the launch sample, not blindly the newest fact: the CLI samples
765
778
  // `defaultAutoMerge` from its own earlier read, so an interleaved toggle
@@ -768,7 +781,7 @@ export class Dispatcher {
768
781
  // so the fan-out reconciles the build on the next tick instead of the
769
782
  // stale sample out-voting the durable default forever (f_b851c0e8).
770
783
  const autoMergeSeedFact =
771
- opts.defaultAutoMerge === true ? latestAutoMergeDefault(repoEvents, true) : undefined
784
+ opts.defaultAutoMerge === true ? pickAutoMergeDefault(repoFacts.autoMerge, true) : undefined
772
785
  // Stage 0 — SETTLEMENT: observe foreign executions from durable facts plus
773
786
  // provider liveness and settle their completion, lease, and publication.
774
787
  // Runs before the janitor so its executionLeaseLive checks see freshly
@@ -882,7 +895,7 @@ export class Dispatcher {
882
895
  const store = this.store
883
896
  const { repo } = this.deps
884
897
  const records = (await store.listBuilds()).filter((record) => record.repo === repo)
885
- const digests = await store.getRepoBuildDigests(repo)
898
+ const digests = await readBuildDigests(store, repo)
886
899
  return records.map((record) => {
887
900
  const digest = digests.get(record.slug)
888
901
  if (digest === undefined) {
@@ -918,7 +931,7 @@ export class Dispatcher {
918
931
  successfulOperation?: 'ready-scan' | 'dependency-check',
919
932
  ): Promise<void> {
920
933
  await this.store.refreshJournal()
921
- const current = reduceTicketSourceHealth(this.store.recordedJournal())
934
+ const current = this.store.repositoryFacts().health
922
935
  if (next !== undefined && current?.cause === next.cause && current.source === next.source) {
923
936
  if (
924
937
  next.cause === 'rate-limited' &&
@@ -988,6 +1001,23 @@ export class Dispatcher {
988
1001
  }
989
1002
  }
990
1003
 
1004
+ private async activeTicketIdentities(refs: TicketRef[], listed: Ticket[]): Promise<Set<string>> {
1005
+ const key = (ref: TicketRef) => `${ref.source}:${ref.id}`
1006
+ const ids = new Set(refs.map(key))
1007
+ if (this.deps.tickets.name !== 'hosted' || listed.length === 0) return ids
1008
+ const listedIds = new Set(listed.map((ticket) => key(ticket.ref)))
1009
+ const identity = ticketIdentity(this.deps.tickets)
1010
+ try {
1011
+ for (const ref of refs) {
1012
+ if (ref.source === 'hosted' && !listedIds.has(key(ref))) ids.add(await identity(ref))
1013
+ }
1014
+ } catch (error) {
1015
+ await this.sourceFailure(error, 'ready-scan')
1016
+ throw error
1017
+ }
1018
+ return ids
1019
+ }
1020
+
991
1021
  /** Observation-only ready listing for Store-backed frontends while intake is
992
1022
  * disabled. It shares the active-ticket exclusion with dispatch but performs
993
1023
  * no dependency fetch, claim, bounce, build, workspace, or launch side
@@ -999,20 +1029,21 @@ export class Dispatcher {
999
1029
  }> {
1000
1030
  this.deps.config = this.deps.getConfig?.() ?? this.deps.config
1001
1031
  await this.store.refresh()
1002
- const activeTicketIds = new Set<string>()
1003
- for (const { record, digest } of await this.repoBuildDigests()) {
1004
- if (record.ticket !== undefined && digest.terminal === undefined) {
1005
- activeTicketIds.add(record.ticket.id)
1006
- }
1007
- }
1008
1032
  const report = emptyTickReport()
1009
1033
  this.restoreScan(report)
1010
1034
  const checkpoint = await this.scanCheckpoint('observation')
1011
1035
  if (checkpoint.due) {
1012
1036
  await this.store.refreshJournal()
1013
1037
  const listing = await this.listReady()
1038
+ const refs: TicketRef[] = []
1039
+ for (const { record, digest } of await this.repoBuildDigests()) {
1040
+ if (record.ticket && digest.terminal === undefined) refs.push(record.ticket)
1041
+ }
1042
+ const activeTicketIds = await this.activeTicketIdentities(refs, listing.tickets)
1014
1043
  await this.store.refreshJournal()
1015
- report.queued = listing.tickets.filter((ticket) => !activeTicketIds.has(ticket.ref.id)).length
1044
+ report.queued = listing.tickets.filter(
1045
+ (ticket) => !activeTicketIds.has(`${ticket.ref.source}:${ticket.ref.id}`),
1046
+ ).length
1016
1047
  report.invalidTickets = listing.diagnostics.length
1017
1048
  report.ticketDiagnostics = [...listing.diagnostics]
1018
1049
  report.dependencyBlocked = 0
@@ -1030,12 +1061,7 @@ export class Dispatcher {
1030
1061
  }
1031
1062
 
1032
1063
  private latestScan() {
1033
- return this.store
1034
- .recordedJournal()
1035
- .findLast(
1036
- (event): event is Extract<RepositoryEvent, { type: 'dispatcher.ticket-scan-completed' }> =>
1037
- event.type === 'dispatcher.ticket-scan-completed',
1038
- )?.payload
1064
+ return this.store.repositoryFacts().scan
1039
1065
  }
1040
1066
 
1041
1067
  private restoreScan(report: TickReport): void {
@@ -1167,15 +1193,30 @@ export class Dispatcher {
1167
1193
  return
1168
1194
  }
1169
1195
  // Bounded subset (AUT-489): the harvest reducer consumes durable types only.
1170
- const state = reduceHarvest(this.store.recordedJournal())
1196
+ const state = this.store.repositoryFacts().harvest
1171
1197
  if (decideHarvestControl(state, this.maxHarvestRecoveryAttempts).kind !== 'park') {
1172
1198
  start()
1173
1199
  }
1174
1200
  }
1175
1201
 
1176
- private async launch(slug: string, launched: Set<string>): Promise<LaunchRunnerResult> {
1202
+ private async launch(
1203
+ slug: string,
1204
+ launched: Set<string>,
1205
+ report: TickReport = emptyTickReport(),
1206
+ ): Promise<LaunchRunnerResult> {
1177
1207
  if (launched.has(slug)) return 'already-active'
1178
1208
  launched.add(slug)
1209
+ if (
1210
+ !(await ensureExecutionProgress(
1211
+ this.store,
1212
+ this.deps.ids,
1213
+ slug,
1214
+ this.deps.config,
1215
+ (state, log) => this.decideBuild(slug, state, log, report),
1216
+ ))
1217
+ ) {
1218
+ return 'already-active'
1219
+ }
1179
1220
  return this.deps.launchRunner(slug)
1180
1221
  }
1181
1222
 
@@ -1238,13 +1279,13 @@ export class Dispatcher {
1238
1279
  * repository lease. */
1239
1280
  private async settleHarvestExecutions(opts: TickOpts = {}): Promise<void> {
1240
1281
  const active = this.deps.activeHarvestExecutions?.() ?? new Set<string>()
1241
- let events: RepositoryEvent[]
1282
+ let facts: ReturnType<RepoViewStore['repositoryFacts']>
1242
1283
  try {
1243
1284
  // The earlier stages awaited providers; take the journal delta so a
1244
1285
  // foreign append (operator activity, harvest facts) made meanwhile is
1245
1286
  // seen before any destructive decision below.
1246
1287
  await this.store.refreshJournal()
1247
- events = this.store.recordedJournal()
1288
+ facts = this.store.repositoryFacts()
1248
1289
  } catch {
1249
1290
  // The read itself failed (a transient store error — a missing record
1250
1291
  // answers `[]` inside the helper, so it never reaches this arm):
@@ -1252,7 +1293,7 @@ export class Dispatcher {
1252
1293
  // tick proceeds, and the next tick retries what remains.
1253
1294
  return
1254
1295
  }
1255
- for (const execution of openHarvestExecutions(events)) {
1296
+ for (const execution of facts.openHarvest) {
1256
1297
  if (active.has(execution.execution)) continue
1257
1298
  // Budget gate: once spent, stop settling further harvest executions;
1258
1299
  // the next tick retries what remains.
@@ -1341,13 +1382,13 @@ export class Dispatcher {
1341
1382
  const capability = this.deps.workspaces.orchestratorSandbox
1342
1383
  if (capability === undefined) return
1343
1384
  const providerName = this.deps.workspaces.name
1344
- let events: RepositoryEvent[]
1385
+ let facts: ReturnType<RepoViewStore['repositoryFacts']>
1345
1386
  try {
1346
1387
  // The earlier stages awaited providers; take the journal delta so a
1347
1388
  // foreign append (operator activity, harvest facts) made meanwhile is
1348
1389
  // seen before any destructive decision below.
1349
1390
  await this.store.refreshJournal()
1350
- events = this.store.recordedJournal()
1391
+ facts = this.store.repositoryFacts()
1351
1392
  } catch {
1352
1393
  // The read itself failed (a transient store error — a missing record
1353
1394
  // answers `[]` inside the helper, so it never reaches this arm):
@@ -1356,7 +1397,7 @@ export class Dispatcher {
1356
1397
  return
1357
1398
  }
1358
1399
  const idleMs = config.orchestrator.sandbox.idleMinutes * 60_000
1359
- for (const environment of sandboxStates(events)) {
1400
+ for (const environment of facts.sandboxes) {
1360
1401
  if (environment.state !== 'live') continue
1361
1402
  // Budget gate: once spent, stop settling further environments; the
1362
1403
  // next tick retries what remains.
@@ -1438,8 +1479,8 @@ export class Dispatcher {
1438
1479
 
1439
1480
  /** The latest `workspace.provision-started` not yet followed by a
1440
1481
  * provisioned/released fact — an open provisioning marker. */
1441
- private openProvisionMarker(events: AbEvent[]): ProvisionMarker | undefined {
1442
- return provisionMarkerReducer.reduce(events)
1482
+ private openProvisionMarker(events: CurrentBuildFacts): ProvisionMarker | undefined {
1483
+ return events.provisionMarker
1443
1484
  }
1444
1485
 
1445
1486
  /** Liveness of the lease behind an open provision marker: `live` means a
@@ -1460,7 +1501,7 @@ export class Dispatcher {
1460
1501
  * report progress without awaiting any of it. */
1461
1502
  private async ensureProvisionContinuation(
1462
1503
  record: BuildRecord,
1463
- events: AbEvent[],
1504
+ events: CurrentBuildFacts,
1464
1505
  tail: 'dispatch' | 'launch',
1465
1506
  seed?: {
1466
1507
  ticket: Ticket
@@ -1476,12 +1517,7 @@ export class Dispatcher {
1476
1517
  const marker = this.openProvisionMarker(events)
1477
1518
  if (marker !== undefined) {
1478
1519
  const liveness = await this.provisionMarkerLiveness(slug)
1479
- const failedAttempt = events.some(
1480
- (event) =>
1481
- event.type === 'dispatch.failed' &&
1482
- event.payload.stage === 'workspace' &&
1483
- event.seq > marker.seq,
1484
- )
1520
+ const failedAttempt = events.latestWorkspaceFailureSeq > marker.seq
1485
1521
  if (liveness === 'live') return 'foreign-live'
1486
1522
  if (
1487
1523
  liveness === 'absent' &&
@@ -1496,7 +1532,7 @@ export class Dispatcher {
1496
1532
  // Expired lease, failed attempt, or an old lease-less marker: adopt.
1497
1533
  }
1498
1534
  const branch = record.branch ?? `ab/${slug}`
1499
- const generation = events.filter((event) => event.type === 'workspace.provisioned').length
1535
+ const generation = events.workspaceGenerations
1500
1536
  await this.store.append(slug, {
1501
1537
  actor: DISPATCHER,
1502
1538
  type: 'workspace.provision-started',
@@ -1576,10 +1612,8 @@ export class Dispatcher {
1576
1612
  // so the next supervisor adopts the marker immediately.
1577
1613
  return
1578
1614
  }
1579
- const events = await store.getEvents(slug)
1580
- const priorGenerations = events.filter(
1581
- (event) => event.type === 'workspace.provisioned',
1582
- ).length
1615
+ const events = (await readBuildFacts(store, slug)).value
1616
+ const priorGenerations = events.workspaceGenerations
1583
1617
  const handle = await workspaces.provision({
1584
1618
  // Local providers provision FROM the physical checkout; remote
1585
1619
  // providers ignore the value (their host seams are injected).
@@ -1622,7 +1656,7 @@ export class Dispatcher {
1622
1656
  boundary = 'read'
1623
1657
  const record = await store.getBuild(slug)
1624
1658
  if (record === null) return
1625
- const queued = reduceBuild(await store.getEvents(slug)).status === 'queued'
1659
+ const queued = (await readBuildFacts(store, slug)).value.state.status === 'queued'
1626
1660
  boundary = input.tail === 'dispatch' || queued ? 'dispatch' : 'launch'
1627
1661
  if (boundary === 'dispatch') await this.completeDispatchBoundaries(record, input.seed)
1628
1662
  else await this.deps.launchRunner(slug)
@@ -1632,10 +1666,10 @@ export class Dispatcher {
1632
1666
  // tick; it is not another provider workspace failure. Replacement
1633
1667
  // runner launch errors retain the existing infrastructure-budget path.
1634
1668
  if (boundary === 'read' || boundary === 'dispatch') return
1635
- const events = await store.getEvents(slug)
1669
+ const events = (await readBuildFacts(store, slug)).value
1636
1670
  if (workspaces.recovery !== undefined) {
1637
1671
  const open = openWorkspace(events)
1638
- const latestRef = [...events].reverse().find((event) => event.type === 'workspace.released')
1672
+ const latestRef = events.latestRelease
1639
1673
  await this.recordInfrastructureFailure(slug, events, {
1640
1674
  provider: workspaces.name,
1641
1675
  workspaceRef:
@@ -1648,7 +1682,7 @@ export class Dispatcher {
1648
1682
  cleanupPending: true,
1649
1683
  })
1650
1684
  }
1651
- const attempt = events.filter((event) => event.type === 'dispatch.failed').length + 1
1685
+ const attempt = events.dispatchFailureCount + 1
1652
1686
  const message = (error instanceof Error ? error.message : String(error)).trim()
1653
1687
  await store.append(slug, {
1654
1688
  actor: DISPATCHER,
@@ -1697,9 +1731,9 @@ export class Dispatcher {
1697
1731
  for (const entry of entries) entry.controller.abort()
1698
1732
  for (const entry of entries) {
1699
1733
  try {
1700
- const events = await this.store.getEvents(entry.slug)
1734
+ const events = (await readBuildFacts(this.store, entry.slug)).value
1701
1735
  if (this.openProvisionMarker(events) !== undefined) {
1702
- const attempt = events.filter((event) => event.type === 'dispatch.failed').length + 1
1736
+ const attempt = events.dispatchFailureCount + 1
1703
1737
  await this.store.append(entry.slug, {
1704
1738
  actor: DISPATCHER,
1705
1739
  type: 'dispatch.failed',
@@ -1749,9 +1783,9 @@ export class Dispatcher {
1749
1783
  const executionLeaseLive = this.hasLiveExecutionLease(record)
1750
1784
  // Raw history, read at most once and only by a branch that acts on it:
1751
1785
  // the facts above answer every quiet-path question without it.
1752
- let loaded: AbEvent[] | undefined
1753
- const history = async (): Promise<AbEvent[]> => {
1754
- loaded ??= await this.store.getEvents(record.slug)
1786
+ let loaded: CurrentBuildFacts | undefined
1787
+ const history = async (): Promise<CurrentBuildFacts> => {
1788
+ loaded ??= (await readBuildFacts(this.store, record.slug)).value
1755
1789
  return loaded
1756
1790
  }
1757
1791
  // Pipeline/ticket/workspace state is settled, but release-asset cleanup
@@ -1814,7 +1848,7 @@ export class Dispatcher {
1814
1848
  }
1815
1849
  }
1816
1850
 
1817
- private forgeWorkspacePath(events: AbEvent[]): string {
1851
+ private forgeWorkspacePath(events: CurrentBuildFacts): string {
1818
1852
  return this.forgeWorkspacePathOf(openWorkspace(events))
1819
1853
  }
1820
1854
 
@@ -1838,7 +1872,7 @@ export class Dispatcher {
1838
1872
  * repeating on later ticks. `build.completed` is deliberately last. */
1839
1873
  private async cleanupAborted(
1840
1874
  record: BuildRecord,
1841
- events: AbEvent[],
1875
+ events: CurrentBuildFacts,
1842
1876
  report: TickReport,
1843
1877
  ): Promise<void> {
1844
1878
  const store = this.store
@@ -1852,13 +1886,11 @@ export class Dispatcher {
1852
1886
  // in the main checkout, and origin mode has no checkout at all: the
1853
1887
  // local-branch git step is skipped, and the saga's later steps tolerate
1854
1888
  // its absence.
1855
- const hadLocalWorkspace = events.some(
1856
- (event) => event.type === 'workspace.provisioned' && !isRemoteWorkspace(event.payload),
1857
- )
1858
- const has = (type: AbEvent['type']): boolean => events.some((event) => event.type === type)
1889
+ const hadLocalWorkspace = events.localWorkspace
1890
+ const has = (type: AbEvent['type']): boolean => events.seenTypes.includes(type)
1859
1891
  const append = async <T extends EventWrite['type']>(write: EventWrite<T>): Promise<void> => {
1860
- const event = await store.append(record.slug, write)
1861
- events.push(event as AbEvent)
1892
+ const _event = await store.append(record.slug, write)
1893
+ Object.assign(events, (await readBuildFacts(store, record.slug)).value)
1862
1894
  }
1863
1895
  const fail = (operation: string, identity: string, error: unknown): never => {
1864
1896
  const detail = error instanceof Error ? error.message : String(error)
@@ -1875,7 +1907,7 @@ export class Dispatcher {
1875
1907
  }
1876
1908
  }
1877
1909
 
1878
- const reduced = reduceBuild(events)
1910
+ const reduced = events.state
1879
1911
  if (reduced.pr !== undefined && !has('pr.closed') && !has('pr.merged')) {
1880
1912
  const closePr = forge.closePr
1881
1913
  if (closePr === undefined) {
@@ -1905,7 +1937,8 @@ export class Dispatcher {
1905
1937
 
1906
1938
  try {
1907
1939
  const released = await this.releaseWorkspace(record.slug, events, 'abort')
1908
- if (released !== undefined) events.push(released)
1940
+ if (released !== undefined)
1941
+ Object.assign(events, (await readBuildFacts(store, record.slug)).value)
1909
1942
  } catch (error) {
1910
1943
  fail('workspace release', `build ${record.slug}`, error)
1911
1944
  }
@@ -2002,7 +2035,7 @@ export class Dispatcher {
2002
2035
  * every partial cleanup is retried after a crash. */
2003
2036
  private async cleanupDiscarded(
2004
2037
  record: BuildRecord,
2005
- events: AbEvent[],
2038
+ events: CurrentBuildFacts,
2006
2039
  report: TickReport,
2007
2040
  ): Promise<void> {
2008
2041
  const store = this.store
@@ -2032,7 +2065,7 @@ export class Dispatcher {
2032
2065
  record: BuildRecord,
2033
2066
  // `workspace` is the open workspace fact; `history` loads the raw log, which
2034
2067
  // only the branches that act on a PR transition need.
2035
- source: { history: () => Promise<AbEvent[]>; workspace: OpenBuildWorkspace | null },
2068
+ source: { history: () => Promise<CurrentBuildFacts>; workspace: OpenBuildWorkspace | null },
2036
2069
  state: BuildState,
2037
2070
  report: TickReport,
2038
2071
  launched: Set<string>,
@@ -2072,12 +2105,12 @@ export class Dispatcher {
2072
2105
  if (state.prState === 'conflicted') return
2073
2106
  const events = await source.history()
2074
2107
  const baseSha = await this.baseSha(baseBranchOf(events, this.deps.config))
2075
- const conflicted = await store.append(record.slug, {
2108
+ const _conflicted = await store.append(record.slug, {
2076
2109
  actor: DISPATCHER,
2077
2110
  type: 'pr.conflicted',
2078
2111
  payload: { baseSha },
2079
2112
  } satisfies EventWrite<'pr.conflicted'>)
2080
- events.push(conflicted)
2113
+ Object.assign(events, (await readBuildFacts(this.store, record.slug)).value)
2081
2114
  if (openWorkspace(events) === null && this.deps.workspaces.recovery !== undefined) {
2082
2115
  // The reconcile workspace is provisioned by the tracked background
2083
2116
  // continuation, whose tail re-attaches the build-runner; this tick
@@ -2089,7 +2122,7 @@ export class Dispatcher {
2089
2122
  }
2090
2123
  // The dispatcher never runs agents (§15.7): re-attach a build-runner,
2091
2124
  // which executes the reconcile epilogue phase.
2092
- await this.launch(record.slug, launched)
2125
+ await this.launch(record.slug, launched, report)
2093
2126
  report.conflicted += 1
2094
2127
  return
2095
2128
  }
@@ -2123,12 +2156,12 @@ export class Dispatcher {
2123
2156
  const latest = await store.buildFacts(record.slug)
2124
2157
  const latestState = latest.state
2125
2158
  const latestIntent = pendingAutoMerge(latestState)
2126
- const decision = decideNextFromFacts(latest.state, latest.log, this.deps.config)
2159
+ const decision = await this.decideBuild(record.slug, latest.state, latest.log, report)
2127
2160
  if (
2128
2161
  latestState.pr?.number === pr.number &&
2129
2162
  latestIntent?.enabled === true &&
2130
2163
  latestIntent.commandSeq === autoMerge.commandSeq &&
2131
- decision.kind === 'wait' &&
2164
+ decision?.kind === 'wait' &&
2132
2165
  decision.reason === 'awaiting-pr'
2133
2166
  ) {
2134
2167
  await forge.squashMerge(workspacePath, pr.number, result.headSha)
@@ -2203,12 +2236,9 @@ export class Dispatcher {
2203
2236
  * is deliberately post-terminal and best-effort: a provider, timeout, or
2204
2237
  * store failure cannot roll back build.completed/ticket/workspace work.
2205
2238
  * Pending handles remain derivable and are retried on every later tick. */
2206
- private async reclaimPrAttachments(slug: string, events: AbEvent[]): Promise<void> {
2207
- for (const hosted of pendingPrAttachmentReclaims(events)) {
2208
- const priorAttempts = events.filter(
2209
- (event) =>
2210
- event.type === 'pr-attachment.reclaim-failed' && event.payload.hostedSeq === hosted.seq,
2211
- ).length
2239
+ private async reclaimPrAttachments(slug: string, events: CurrentBuildFacts): Promise<void> {
2240
+ for (const hosted of events.pendingAttachments) {
2241
+ const priorAttempts = events.reclaimCounts[String(hosted.seq)] ?? 0
2212
2242
  try {
2213
2243
  const capability = this.deps.forge.prAttachments
2214
2244
  if (capability === undefined) {
@@ -2248,13 +2278,13 @@ export class Dispatcher {
2248
2278
  }
2249
2279
  }
2250
2280
 
2251
- private recoveryCheckpoint(events: AbEvent[]): string | undefined {
2252
- return recoveryCheckpointReducer.reduce(events)
2281
+ private recoveryCheckpoint(events: CurrentBuildFacts): string | undefined {
2282
+ return events.recoveryCheckpoint
2253
2283
  }
2254
2284
 
2255
2285
  private async recordInfrastructureFailure(
2256
2286
  slug: string,
2257
- events: AbEvent[],
2287
+ events: CurrentBuildFacts,
2258
2288
  input: {
2259
2289
  provider: string
2260
2290
  workspaceRef: string
@@ -2263,8 +2293,8 @@ export class Dispatcher {
2263
2293
  cleanupPending: boolean
2264
2294
  },
2265
2295
  ): Promise<void> {
2266
- const execution = [...events].reverse().find((event) => event.type === 'execution.started')
2267
- const appended = await appendInfrastructureFailure(
2296
+ const execution = events.state.executions.at(-1)
2297
+ const _appended = await appendInfrastructureFailure(
2268
2298
  { store: this.store, ids: this.deps.ids },
2269
2299
  {
2270
2300
  slug,
@@ -2272,22 +2302,19 @@ export class Dispatcher {
2272
2302
  maxAttempts: this.deps.config.policy.maxInfrastructureAttempts,
2273
2303
  provider: input.provider,
2274
2304
  workspaceRef: input.workspaceRef,
2275
- instance:
2276
- execution?.type === 'execution.started'
2277
- ? execution.payload.instance
2278
- : 'dispatcher-recovery',
2279
- ...(execution?.type === 'execution.started' && execution.payload.environmentId !== undefined
2280
- ? { environmentId: execution.payload.environmentId }
2305
+ instance: execution !== undefined ? execution.instance : 'dispatcher-recovery',
2306
+ ...(execution !== undefined && execution.environmentId !== undefined
2307
+ ? { environmentId: execution.environmentId }
2281
2308
  : {}),
2282
- ...(execution?.type === 'execution.started' && execution.payload.sessionId !== undefined
2283
- ? { sessionId: execution.payload.sessionId }
2309
+ ...(execution !== undefined && execution.sessionId !== undefined
2310
+ ? { sessionId: execution.sessionId }
2284
2311
  : {}),
2285
2312
  operation: input.operation,
2286
2313
  error: input.error,
2287
2314
  cleanupPending: input.cleanupPending,
2288
2315
  },
2289
2316
  )
2290
- events.push(...appended)
2317
+ Object.assign(events, (await readBuildFacts(this.store, slug)).value)
2291
2318
  }
2292
2319
 
2293
2320
  /** The provider that owns a recorded workspace: the one named on its
@@ -2303,7 +2330,7 @@ export class Dispatcher {
2303
2330
  * only after exact-name cleanup is durably acknowledged. */
2304
2331
  private async reapStaleWorkspace(
2305
2332
  slug: string,
2306
- events: AbEvent[],
2333
+ events: CurrentBuildFacts,
2307
2334
  reason: 'pause' | 'blocked' | 'replacement',
2308
2335
  ): Promise<boolean> {
2309
2336
  const open = openWorkspace(events)
@@ -2314,18 +2341,13 @@ export class Dispatcher {
2314
2341
  // publication's fate BEFORE the destructive reap. The guard's append
2315
2342
  // failure propagates — a stale workspace with an unrecorded pending
2316
2343
  // request is never reaped (the next tick retries).
2317
- events = await settlePublicationBeforeRelease(
2344
+ events = await settlePublicationFactsBeforeRelease(
2318
2345
  { store: this.store, settlePublication: this.settlePublication },
2319
2346
  slug,
2320
2347
  events,
2321
2348
  reason,
2322
2349
  )
2323
- const attempt =
2324
- events.filter(
2325
- (event) =>
2326
- event.type === 'infrastructure.cleanup-attempted' &&
2327
- event.payload.workspaceRef === open.ref,
2328
- ).length + 1
2350
+ const attempt = (events.cleanupCounts[JSON.stringify(open.ref)] ?? 0) + 1
2329
2351
  try {
2330
2352
  const outcome = await recovery.reap({
2331
2353
  provider: open.provider,
@@ -2334,7 +2356,7 @@ export class Dispatcher {
2334
2356
  ...(open.localPath !== undefined ? { localPath: open.localPath } : {}),
2335
2357
  branch: open.branch,
2336
2358
  })
2337
- const cleaned = await this.store.append(slug, {
2359
+ const _cleaned = await this.store.append(slug, {
2338
2360
  actor: DISPATCHER,
2339
2361
  type: 'infrastructure.cleanup-attempted',
2340
2362
  payload: {
@@ -2346,17 +2368,17 @@ export class Dispatcher {
2346
2368
  snapshots: outcome.snapshots,
2347
2369
  },
2348
2370
  })
2349
- events.push(cleaned)
2350
- const released = await this.store.append(slug, {
2371
+ Object.assign(events, (await readBuildFacts(this.store, slug)).value)
2372
+ const _released = await this.store.append(slug, {
2351
2373
  actor: DISPATCHER,
2352
2374
  type: 'workspace.released',
2353
2375
  payload: { ref: open.ref, reason },
2354
2376
  })
2355
- events.push(released)
2377
+ Object.assign(events, (await readBuildFacts(this.store, slug)).value)
2356
2378
  return true
2357
2379
  } catch (error) {
2358
2380
  const message = error instanceof Error ? error.message : String(error)
2359
- const failed = await this.store.append(slug, {
2381
+ const _failed = await this.store.append(slug, {
2360
2382
  actor: DISPATCHER,
2361
2383
  type: 'infrastructure.cleanup-attempted',
2362
2384
  payload: {
@@ -2368,7 +2390,7 @@ export class Dispatcher {
2368
2390
  error: message || 'cleanup failed without an error message',
2369
2391
  },
2370
2392
  })
2371
- events.push(failed)
2393
+ Object.assign(events, (await readBuildFacts(this.store, slug)).value)
2372
2394
  await this.recordInfrastructureFailure(slug, events, {
2373
2395
  provider: open.provider,
2374
2396
  workspaceRef: open.ref,
@@ -2384,7 +2406,7 @@ export class Dispatcher {
2384
2406
  * append `workspace.released`. Log-deduped, so re-runs are no-ops. */
2385
2407
  private async releaseWorkspace(
2386
2408
  slug: string,
2387
- events: AbEvent[],
2409
+ events: CurrentBuildFacts,
2388
2410
  reason: 'completion' | 'abort' | 'pause' | 'blocked' | 'replacement' | 'discard' = 'completion',
2389
2411
  ): Promise<AbEvent | undefined> {
2390
2412
  const open = openWorkspace(events)
@@ -2403,7 +2425,7 @@ export class Dispatcher {
2403
2425
  // pending publication BEFORE any provider cleanup call, while the
2404
2426
  // workspace still exists. The guard's append failure propagates — the
2405
2427
  // release never proceeds past an unrecorded pending request.
2406
- events = await settlePublicationBeforeRelease(
2428
+ events = await settlePublicationFactsBeforeRelease(
2407
2429
  { store: this.store, settlePublication: this.settlePublication },
2408
2430
  slug,
2409
2431
  events,
@@ -2411,15 +2433,10 @@ export class Dispatcher {
2411
2433
  )
2412
2434
  const recovery = owner.recovery
2413
2435
  if (recovery !== undefined) {
2414
- const attempt =
2415
- events.filter(
2416
- (event) =>
2417
- event.type === 'infrastructure.cleanup-attempted' &&
2418
- event.payload.workspaceRef === open.ref,
2419
- ).length + 1
2436
+ const attempt = (events.cleanupCounts[JSON.stringify(open.ref)] ?? 0) + 1
2420
2437
  try {
2421
2438
  const outcome = await recovery.reap(handle)
2422
- const cleaned = await this.store.append(slug, {
2439
+ const _cleaned = await this.store.append(slug, {
2423
2440
  actor: DISPATCHER,
2424
2441
  type: 'infrastructure.cleanup-attempted',
2425
2442
  payload: {
@@ -2431,10 +2448,10 @@ export class Dispatcher {
2431
2448
  snapshots: outcome.snapshots,
2432
2449
  },
2433
2450
  })
2434
- events.push(cleaned)
2451
+ Object.assign(events, (await readBuildFacts(this.store, slug)).value)
2435
2452
  } catch (error) {
2436
2453
  const message = error instanceof Error ? error.message : String(error)
2437
- const failed = await this.store.append(slug, {
2454
+ const _failed = await this.store.append(slug, {
2438
2455
  actor: DISPATCHER,
2439
2456
  type: 'infrastructure.cleanup-attempted',
2440
2457
  payload: {
@@ -2446,7 +2463,7 @@ export class Dispatcher {
2446
2463
  error: message || 'cleanup failed without an error message',
2447
2464
  },
2448
2465
  })
2449
- events.push(failed)
2466
+ Object.assign(events, (await readBuildFacts(this.store, slug)).value)
2450
2467
  await this.recordInfrastructureFailure(slug, events, {
2451
2468
  provider: open.provider,
2452
2469
  workspaceRef: open.ref,
@@ -2572,9 +2589,9 @@ export class Dispatcher {
2572
2589
  if (!(await this.dispatchBudget(record.slug))) return 'parked'
2573
2590
  let stage: EventPayload<'dispatch.failed'>['stage'] = 'create'
2574
2591
  try {
2575
- let events = await store.getEvents(record.slug)
2576
- if (!events.some((event) => event.type === 'build.created')) {
2577
- if (events.length !== 0 || record.ticket === undefined) {
2592
+ let events = (await readBuildFacts(store, record.slug)).value
2593
+ if (events.created === undefined) {
2594
+ if (events.state.lastSeq !== 0 || record.ticket === undefined) {
2578
2595
  throw new Error(
2579
2596
  'build record lacks the immutable ticket facts needed to reconstruct build.created',
2580
2597
  )
@@ -2584,28 +2601,32 @@ export class Dispatcher {
2584
2601
  // can only sample the ticket's current assets (SPEC §6.3).
2585
2602
  const assets =
2586
2603
  seed?.assets ??
2587
- (await samplePinnedAssets(store, record.repo, record.ticket.id).catch(() => []))
2604
+ (await preparePinnedAssets(this.deps.tickets, store, record.repo, record.ticket.id).catch(
2605
+ (error) => {
2606
+ // Preserve legacy recovery only without native assets. Native preparation
2607
+ // must never publish an empty pin after losing the source bytes.
2608
+ if (this.deps.tickets.assets || this.deps.tickets.name === 'hosted') throw error
2609
+ return []
2610
+ },
2611
+ ))
2588
2612
  await store.appendIfCurrent(record.slug, 0, {
2589
2613
  actor: DISPATCHER,
2590
2614
  type: 'build.created',
2591
2615
  payload: this.buildCreatedPayload(record.ticket, record.repo, { ...seed, assets }),
2592
2616
  })
2593
- events = await store.getEvents(record.slug)
2594
- if (!events.some((event) => event.type === 'build.created')) {
2617
+ events = (await readBuildFacts(store, record.slug)).value
2618
+ if (events.created === undefined) {
2595
2619
  throw new Error('build.created reconstruction lost its conditional append race')
2596
2620
  }
2597
2621
  }
2598
2622
 
2599
- const created = events.find((event) => event.type === 'build.created')
2623
+ const created = events.created
2600
2624
  const autoMergeUser = seed?.autoMergeUser ?? created?.payload.autoMergeRequestedBy
2601
2625
  // The claim's sampled default fact, for the request's fan-out provenance:
2602
2626
  // the seed's own value first (this dispatch created the build), else the
2603
2627
  // durable `build.created` fact a recovery pass resumes from.
2604
2628
  const autoMergeDefaultSeq = seed?.autoMergeDefaultSeq ?? created?.payload.autoMergeDefaultSeq
2605
- if (
2606
- autoMergeUser !== undefined &&
2607
- !events.some((event) => event.type === 'build.auto-merge-requested')
2608
- ) {
2629
+ if (autoMergeUser !== undefined && !events.autoMergeEverRequested) {
2609
2630
  await store.append(record.slug, {
2610
2631
  actor: humanActor(autoMergeUser),
2611
2632
  type: 'build.auto-merge-requested',
@@ -2613,7 +2634,7 @@ export class Dispatcher {
2613
2634
  ...(autoMergeDefaultSeq !== undefined ? { defaultSeq: autoMergeDefaultSeq } : {}),
2614
2635
  },
2615
2636
  })
2616
- events = await store.getEvents(record.slug)
2637
+ events = (await readBuildFacts(store, record.slug)).value
2617
2638
  }
2618
2639
 
2619
2640
  stage = 'workspace'
@@ -2628,9 +2649,7 @@ export class Dispatcher {
2628
2649
  launched.add(record.slug)
2629
2650
  return 'deferred'
2630
2651
  }
2631
- const priorGenerations = events.filter(
2632
- (event) => event.type === 'workspace.provisioned',
2633
- ).length
2652
+ const priorGenerations = events.workspaceGenerations
2634
2653
  const handle = await this.deps.workspaces.provision({
2635
2654
  // Local providers provision FROM the physical checkout; remote
2636
2655
  // providers ignore the value (their host seams are injected).
@@ -2657,10 +2676,10 @@ export class Dispatcher {
2657
2676
  base: handle.base,
2658
2677
  },
2659
2678
  } satisfies EventWrite<'workspace.provisioned'>)
2660
- events = await store.getEvents(record.slug)
2679
+ events = (await readBuildFacts(store, record.slug)).value
2661
2680
  }
2662
2681
  } catch (error) {
2663
- const events = await store.getEvents(record.slug)
2682
+ const events = (await readBuildFacts(store, record.slug)).value
2664
2683
  if (stage === 'workspace' && this.deps.workspaces.recovery !== undefined) {
2665
2684
  const open = openWorkspace(events)
2666
2685
  await this.recordInfrastructureFailure(record.slug, events, {
@@ -2671,7 +2690,7 @@ export class Dispatcher {
2671
2690
  cleanupPending: true,
2672
2691
  })
2673
2692
  }
2674
- const attempt = events.filter((event) => event.type === 'dispatch.failed').length + 1
2693
+ const attempt = events.dispatchFailureCount + 1
2675
2694
  const message = (error instanceof Error ? error.message : String(error)).trim()
2676
2695
  await store.append(record.slug, {
2677
2696
  actor: DISPATCHER,
@@ -2711,12 +2730,10 @@ export class Dispatcher {
2711
2730
  let stage: EventPayload<'dispatch.failed'>['stage'] = 'spec'
2712
2731
  let authored = false
2713
2732
  try {
2714
- const events = await store.getEvents(record.slug)
2715
- const state = reduceBuild(events)
2733
+ const events = (await readBuildFacts(store, record.slug)).value
2734
+ const state = events.state
2716
2735
  if (state.status !== 'queued' || state.discardRequest !== undefined) return 'parked'
2717
- if (
2718
- !events.some((event) => event.type === 'spec.imported' || event.type === 'spec.authored')
2719
- ) {
2736
+ if (!events.specRecorded) {
2720
2737
  let ticket: Ticket
2721
2738
  if (seed?.ticket !== undefined) {
2722
2739
  ticket = seed.ticket
@@ -2774,10 +2791,7 @@ export class Dispatcher {
2774
2791
  }
2775
2792
 
2776
2793
  stage = 'comment'
2777
- if (
2778
- record.ticket !== undefined &&
2779
- !events.some((event) => event.type === 'dispatch.comment-posted')
2780
- ) {
2794
+ if (record.ticket !== undefined && !events.commentPosted) {
2781
2795
  await this.deps.tickets.comment(record.ticket.id, `build ${record.slug} dispatched`)
2782
2796
  await store.append(record.slug, {
2783
2797
  actor: DISPATCHER,
@@ -2791,17 +2805,17 @@ export class Dispatcher {
2791
2805
  // provider awaits. Re-read immediately before launch so a request made
2792
2806
  // during those boundaries parks cleanly for the next janitor pass.
2793
2807
  await store.refreshBuild(record.slug)
2794
- const launchState = reduceBuild(await store.getEvents(record.slug))
2808
+ const launchState = (await readBuildFacts(store, record.slug)).value.state
2795
2809
  if (launchState.status !== 'queued' || launchState.discardRequest !== undefined) {
2796
2810
  return 'parked'
2797
2811
  }
2798
2812
  if (launched === undefined) await this.deps.launchRunner(record.slug)
2799
- else await this.launch(record.slug, launched)
2813
+ else await this.launch(record.slug, launched, report)
2800
2814
  if (authored && report !== undefined) report.authored += 1
2801
2815
  return 'completed'
2802
2816
  } catch (error) {
2803
- const events = await store.getEvents(record.slug)
2804
- const attempt = events.filter((event) => event.type === 'dispatch.failed').length + 1
2817
+ const events = (await readBuildFacts(store, record.slug)).value
2818
+ const attempt = events.dispatchFailureCount + 1
2805
2819
  const message = (error instanceof Error ? error.message : String(error)).trim()
2806
2820
  await store.append(record.slug, {
2807
2821
  actor: DISPATCHER,
@@ -2873,7 +2887,6 @@ export class Dispatcher {
2873
2887
  opts: TickOpts = {},
2874
2888
  ): Promise<void> {
2875
2889
  const store = this.store
2876
- const { config } = this.deps
2877
2890
  for (const record of await this.nonterminalBuilds()) {
2878
2891
  if (launched.has(record.slug)) continue
2879
2892
  // Budget gate: resume launches runners (transport-backed work).
@@ -2896,7 +2909,8 @@ export class Dispatcher {
2896
2909
  !(await this.dispatchBudget(record.slug))
2897
2910
  )
2898
2911
  continue
2899
- const decision = decideNextFromFacts(state, facts.log, config)
2912
+ const decision = await this.decideBuild(record.slug, state, facts.log, report)
2913
+ if (decision === undefined) continue
2900
2914
  if (decision.kind === 'wait') {
2901
2915
  if (decision.reason === 'blocked' && state.openEscalations.length > 0) {
2902
2916
  report.blockedDiagnostics.push(
@@ -2910,7 +2924,7 @@ export class Dispatcher {
2910
2924
  // Disposable remote environments must pass through the stale-lease
2911
2925
  // fencing sweep below; startup must never attach directly to an old VM.
2912
2926
  if (this.deps.workspaces.recovery !== undefined) continue
2913
- const result = await this.launch(record.slug, launched)
2927
+ const result = await this.launch(record.slug, launched, report)
2914
2928
  if (result === 'scheduled') report.resumed += 1
2915
2929
  }
2916
2930
  }
@@ -2972,12 +2986,13 @@ export class Dispatcher {
2972
2986
  !(await this.dispatchBudget(record.slug))
2973
2987
  )
2974
2988
  continue
2975
- const decision = decideNextFromFacts(state, facts.log, this.deps.config)
2989
+ const decision = await this.decideBuild(record.slug, state, facts.log, report)
2990
+ if (decision === undefined) continue
2976
2991
  if (state.status === 'done' || state.status === 'aborted') continue
2977
2992
  // Raw history, read at most once and only by a branch that acts on it.
2978
- let loaded: AbEvent[] | undefined
2979
- const history = async (): Promise<AbEvent[]> => {
2980
- loaded ??= await this.store.getEvents(record.slug)
2993
+ let loaded: CurrentBuildFacts | undefined
2994
+ const history = async (): Promise<CurrentBuildFacts> => {
2995
+ loaded ??= (await readBuildFacts(this.store, record.slug)).value
2981
2996
  return loaded
2982
2997
  }
2983
2998
  // Liveness gate (durable supervision): a recorded execution the provider
@@ -3061,7 +3076,7 @@ export class Dispatcher {
3061
3076
  if (
3062
3077
  facts.workspace === null &&
3063
3078
  this.deps.workspaces.recovery !== undefined &&
3064
- (await history()).some((event) => event.type === 'workspace.provisioned')
3079
+ (await history()).workspaceGenerations > 0
3065
3080
  ) {
3066
3081
  const kicked = await this.ensureProvisionContinuation(record, await history(), 'launch')
3067
3082
  if (kicked === 'kicked') {
@@ -3073,7 +3088,7 @@ export class Dispatcher {
3073
3088
  // boundaries and the launch; this tick performs neither.
3074
3089
  continue
3075
3090
  }
3076
- const result = await this.launch(record.slug, launched)
3091
+ const result = await this.launch(record.slug, launched, report)
3077
3092
  if (result === 'scheduled') report.swept += 1
3078
3093
  }
3079
3094
  }
@@ -3101,11 +3116,11 @@ export class Dispatcher {
3101
3116
  // pending work are live, only waiting on a human. Capacity is per repo
3102
3117
  // (§16.1) — another repo's builds never consume this repo's slots.
3103
3118
  let active = 0
3104
- const activeTicketIds = new Set<string>()
3119
+ const activeRefs: TicketRef[] = []
3105
3120
  for (const { record, digest } of await this.repoBuildDigests()) {
3106
3121
  if (digest.terminal === undefined) {
3107
3122
  active += 1
3108
- if (record.ticket) activeTicketIds.add(record.ticket.id)
3123
+ if (record.ticket) activeRefs.push(record.ticket)
3109
3124
  }
3110
3125
  }
3111
3126
  let capacity = config.capacity - active
@@ -3115,20 +3130,23 @@ export class Dispatcher {
3115
3130
  // dispatcher is saturated. Sources that keep a claimed ticket in the
3116
3131
  // ready state are deduped against the active builds embodying them.
3117
3132
  await this.store.refreshJournal()
3118
- const journalBeforeReady = this.store.recordedJournal()
3133
+ const journalBeforeReady = this.store.repositoryFacts().harvest
3119
3134
  const listing = await this.listReady()
3135
+ const activeTicketIds = await this.activeTicketIdentities(activeRefs, listing.tickets)
3120
3136
  await this.store.refreshJournal()
3121
- const journalAfterReady = this.store.recordedJournal()
3122
- if (reduceTicketSourceHealth(journalAfterReady)?.cause === 'ready-scan-failed') {
3137
+ const journalAfterReady = this.store.repositoryFacts()
3138
+ if (journalAfterReady.health?.cause === 'ready-scan-failed') {
3123
3139
  await this.sourceCondition(undefined, 'ready-scan')
3124
3140
  }
3125
3141
  const creationHolds = new Map(
3126
- harvestCreationsDuringReadyScan(journalBeforeReady, journalAfterReady).map((creation) => [
3142
+ harvestCreationsFromStates(journalBeforeReady, journalAfterReady.harvest).map((creation) => [
3127
3143
  creation.creationKey.toLowerCase(),
3128
3144
  creation,
3129
3145
  ]),
3130
3146
  )
3131
- const ready = listing.tickets.filter((ticket) => !activeTicketIds.has(ticket.ref.id))
3147
+ const ready = listing.tickets.filter(
3148
+ (ticket) => !activeTicketIds.has(`${ticket.ref.source}:${ticket.ref.id}`),
3149
+ )
3132
3150
  report.invalidTickets += listing.diagnostics.length
3133
3151
  report.ticketDiagnostics.push(...listing.diagnostics)
3134
3152
  report.queued = ready.length
@@ -3225,7 +3243,7 @@ export class Dispatcher {
3225
3243
 
3226
3244
  // Freeze the ticket's assets at the claim (SPEC §6.3): sampled before the
3227
3245
  // spec-authoring interval so edits made during it are never pinned.
3228
- const assets = await samplePinnedAssets(store, this.deps.repo, ticket.ref.id)
3246
+ const assets = await preparePinnedAssets(tickets, store, this.deps.repo, ticket.ref.id)
3229
3247
 
3230
3248
  // Quality gate (§6.3): import a conforming ticket body as the spec;
3231
3249
  // author one for thin-but-groomed tickets; otherwise bounce.