@defrex/autobuild 0.13.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 (94) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +19 -1
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +96 -70
  5. package/docs/hosted-service.md +2 -2
  6. package/docs/operator-api.md +1 -1
  7. package/docs/remote-store-protocol.md +84 -2
  8. package/package.json +4 -3
  9. package/packages/core/src/cli/attention.ts +18 -164
  10. package/packages/core/src/cli/build-control.ts +11 -15
  11. package/packages/core/src/cli/bulk-control.ts +3 -2
  12. package/packages/core/src/cli/context.ts +16 -78
  13. package/packages/core/src/cli/dashboard/model.ts +36 -22
  14. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  15. package/packages/core/src/cli/dispatch-process.ts +14 -7
  16. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  17. package/packages/core/src/cli/dispatch.ts +148 -79
  18. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  19. package/packages/core/src/cli/harvest.ts +71 -39
  20. package/packages/core/src/cli/historical-build-state.ts +27 -0
  21. package/packages/core/src/cli/main.ts +3 -3
  22. package/packages/core/src/cli/pr-attachments.ts +10 -13
  23. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  24. package/packages/core/src/cli/pr-summary.ts +43 -13
  25. package/packages/core/src/cli/publication-settlement.ts +10 -20
  26. package/packages/core/src/cli/repository-status.ts +38 -2
  27. package/packages/core/src/cli/status.ts +62 -32
  28. package/packages/core/src/cli/terminals.ts +25 -142
  29. package/packages/core/src/cli/ticket.ts +2 -2
  30. package/packages/core/src/cli/wait.ts +91 -84
  31. package/packages/core/src/cli/watch.ts +60 -97
  32. package/packages/core/src/config/pipeline-source.ts +4 -3
  33. package/packages/core/src/events/repository.ts +7 -3
  34. package/packages/core/src/integration/harness.ts +1 -2
  35. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2429 -9
  36. package/packages/core/src/kernel/auto-merge.ts +19 -3
  37. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  38. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  39. package/packages/core/src/kernel/harvest.ts +25 -23
  40. package/packages/core/src/kernel/log-index-value.ts +38 -0
  41. package/packages/core/src/kernel/read-facts.ts +637 -0
  42. package/packages/core/src/kernel/reducer-registry.ts +137 -3
  43. package/packages/core/src/kernel/repository-attention.ts +179 -0
  44. package/packages/core/src/kernel/service-facts.ts +135 -0
  45. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  46. package/packages/core/src/operator/control.ts +5 -6
  47. package/packages/core/src/operator/index.ts +2 -0
  48. package/packages/core/src/operator/query.ts +37 -20
  49. package/packages/core/src/operator/read-view.ts +4 -13
  50. package/packages/core/src/operator/requests.ts +2 -14
  51. package/packages/core/src/operator/sandbox.ts +10 -9
  52. package/packages/core/src/operator/tickets.ts +2 -2
  53. package/packages/core/src/plugin-sdk/index.ts +13 -0
  54. package/packages/core/src/processes/build-child.ts +6 -6
  55. package/packages/core/src/processes/build-runner.ts +62 -70
  56. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  57. package/packages/core/src/processes/dispatcher.ts +113 -173
  58. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  59. package/packages/core/src/processes/harvest-runner.ts +34 -70
  60. package/packages/core/src/processes/harvest.ts +38 -15
  61. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  62. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  63. package/packages/core/src/processes/publication-loss.ts +35 -21
  64. package/packages/core/src/processes/repo-view.ts +229 -1134
  65. package/packages/core/src/processes/tick-publication.ts +110 -0
  66. package/packages/core/src/store/adapter.ts +23 -0
  67. package/packages/core/src/store/build-scope.ts +17 -0
  68. package/packages/core/src/store/contract.ts +251 -0
  69. package/packages/core/src/store/local/schema.ts +1 -0
  70. package/packages/core/src/store/local/store.ts +179 -54
  71. package/packages/core/src/store/memory.ts +119 -29
  72. package/packages/core/src/store/phase-session.ts +17 -0
  73. package/packages/core/src/store/reduced-reads.ts +239 -0
  74. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  75. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  76. package/packages/core/src/store/reducer-values.ts +145 -0
  77. package/packages/core/src/store/remote/client.ts +41 -0
  78. package/packages/core/src/store/remote/compatibility.ts +2 -0
  79. package/packages/core/src/store/remote/index.ts +19 -0
  80. package/packages/core/src/store/remote/protocol.ts +16 -0
  81. package/packages/core/src/store/remote/version.ts +3 -1
  82. package/packages/core/src/store/repo-state-events.ts +1 -1
  83. package/packages/core/src/store/repo-tick.ts +72 -0
  84. package/packages/core/src/store/session-handle.ts +19 -0
  85. package/packages/core/src/store/snapshots.ts +10 -0
  86. package/packages/core/src/store/ticket-assets.ts +4 -4
  87. package/packages/core/src/store/types.ts +39 -2
  88. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  89. package/packages/core/src/testing/index.ts +8 -0
  90. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  91. package/skills/guide/SKILL.md +6 -1
  92. package/skills/guide/references/operator-api.md +1 -1
  93. package/skills/guide/references/remote-store-protocol.md +84 -2
  94. package/templates/hosted-service/package.json +2 -2
@@ -1,3 +1,4 @@
1
+ import { readBuildFacts } from '../store/reduced-reads'
1
2
  /**
2
3
  * Durable operator controls for one build.
3
4
  *
@@ -9,12 +10,7 @@
9
10
  import type { AbEvent } from '../events/catalog'
10
11
  import type { PinnedAsset } from '../events/payloads'
11
12
  import { humanActor, KERNEL, type Via } from '../events/envelope'
12
- import {
13
- reduceBuild,
14
- discardInFlight,
15
- type BuildState,
16
- type OpenEscalation,
17
- } from '../kernel/reducer'
13
+ import { discardInFlight, type BuildState, type OpenEscalation } from '../kernel/reducer'
18
14
  import type { ArtifactRef, BuildOutcome, BuildStatus, TicketRef } from '../ontology'
19
15
  import type { Exec } from '../ports/workspace/git-worktree'
20
16
  import { specConformance } from '../spec-standard'
@@ -276,7 +272,7 @@ async function appendConsentCommand(
276
272
  via?: Via,
277
273
  ): Promise<BuildControlResult> {
278
274
  while (true) {
279
- const state = reduceBuild(await store.getEvents(slug))
275
+ const state = (await readBuildFacts(store, slug)).value.state
280
276
  activeState(slug, state)
281
277
  const command =
282
278
  action === 'toggle-auto-merge'
@@ -484,8 +480,8 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
484
480
  )
485
481
  }
486
482
 
487
- const events = await opts.store.getEvents(opts.slug)
488
- const state = reduceBuild(events)
483
+ const events = (await readBuildFacts(opts.store, opts.slug)).value
484
+ const state = events.state
489
485
  const user = opts.user?.trim() || buildControlUser(opts.env ?? {})
490
486
 
491
487
  if (opts.action.kind === 'discard') {
@@ -495,7 +491,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
495
491
  `build "${opts.slug}" cannot be discarded (status: ${state.status}); discard requires queued`,
496
492
  )
497
493
  }
498
- const existing = events.findLast((event) => event.type === 'build.discard-requested')
494
+ const existing = events.latestDiscard
499
495
  if (existing !== undefined) {
500
496
  return { kind: 'command', slug: opts.slug, command: 'discard', event: existing }
501
497
  }
@@ -504,7 +500,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
504
500
 
505
501
  if (opts.action.kind === 'abort') {
506
502
  if (state.status !== 'queued') activeState(opts.slug, state)
507
- const existing = events.findLast((event) => event.type === 'build.abort-requested')
503
+ const existing = events.latestAbort
508
504
  if (existing !== undefined) {
509
505
  return { kind: 'command', slug: opts.slug, command: 'abort', event: existing }
510
506
  }
@@ -658,7 +654,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
658
654
 
659
655
  // Re-read at the last durable moment. The PR janitor and abort
660
656
  // acknowledgement may still write while a blocked build is parked.
661
- const freshState = reduceBuild(await opts.store.getEvents(opts.slug))
657
+ const freshState = (await readBuildFacts(opts.store, opts.slug)).value.state
662
658
  assertStillActive(opts.slug, freshState)
663
659
  const decision = decideRevise(opts.slug, freshState, captured)
664
660
  if (decision.kind === 'refuse') throwRefusal(decision)
@@ -701,7 +697,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
701
697
  payload: {},
702
698
  })
703
699
  }
704
- const after = reduceBuild(await opts.store.getEvents(opts.slug))
700
+ const after = (await readBuildFacts(opts.store, opts.slug)).value.state
705
701
  const signal = terminalSignal(after)
706
702
  return {
707
703
  kind: 'answered',
@@ -729,7 +725,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
729
725
  const initial = dismissPartition(state, captured)
730
726
  if (initial.eligible.length === 0) throw noCitedFindings(opts.slug, initialOpen)
731
727
 
732
- const freshState = reduceBuild(await opts.store.getEvents(opts.slug))
728
+ const freshState = (await readBuildFacts(opts.store, opts.slug)).value.state
733
729
  assertStillActive(opts.slug, freshState)
734
730
  if (hasPendingAbort(freshState)) throw abortPending(opts.slug)
735
731
  const freshOpen = selectedOpen(freshState, captured)
@@ -757,7 +753,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
757
753
  payload: {},
758
754
  })
759
755
  }
760
- const after = reduceBuild(await opts.store.getEvents(opts.slug))
756
+ const after = (await readBuildFacts(opts.store, opts.slug)).value.state
761
757
  const signal = terminalSignal(after)
762
758
  return {
763
759
  kind: 'answered',
@@ -1,3 +1,4 @@
1
+ import { readBuildFacts } from '../store/reduced-reads'
1
2
  /**
2
3
  * Repository-wide quiescence: one keypress that parks every running build and
3
4
  * stops intake, and one that reverses it.
@@ -59,7 +60,7 @@
59
60
  * what the operator sees, and a unit test pins that agreement.
60
61
  */
61
62
  import { humanActor, type Actor, type Via } from '../events/envelope'
62
- import { reduceBuild, type BuildState } from '../kernel/reducer'
63
+ import type { BuildState } from '../kernel/reducer'
63
64
  import type { Exec } from '../ports/workspace/git-worktree'
64
65
  import type { BuildRecord, BuildStore } from '../store/types'
65
66
  import { BuildControlError, buildControlUser } from './build-control'
@@ -132,7 +133,7 @@ async function requestOne(
132
133
  direction: BulkDirection,
133
134
  ): Promise<boolean> {
134
135
  while (true) {
135
- const state = reduceBuild(await store.getEvents(slug))
136
+ const state = (await readBuildFacts(store, slug)).value.state
136
137
  if (!eligible(state, direction)) return false
137
138
  // The two branches are written out rather than selecting the event type
138
139
  // into a variable so `appendIfCurrent`'s `T extends EventType` infers a
@@ -1,3 +1,4 @@
1
+ import { readBuildFacts, type CurrentBuildFacts } from '../store/reduced-reads'
1
2
  /**
2
3
  * The `ab context` materializer (SPEC §8.3): a phase-scoped hydration of the
3
4
  * gitignored `.ab/` scratch dir. Everything derives from
@@ -16,14 +17,13 @@ import { mkdir, rm, writeFile } from 'node:fs/promises'
16
17
  import { dirname, join } from 'node:path'
17
18
  import { parseConfig } from '../config/load'
18
19
  import type { VerifyStepConfig } from '../config/schema'
19
- import type { AbEvent } from '../events/catalog'
20
20
  import {
21
21
  allowedTerminals,
22
22
  phaseSpecFor,
23
23
  type PhaseSpec,
24
24
  type TerminalCommand,
25
25
  } from '../kernel/phases'
26
- import { reduceBuild, type BuildState } from '../kernel/reducer'
26
+ import type { BuildState } from '../kernel/reducer'
27
27
  import {
28
28
  isVerifyPhase,
29
29
  verifyReportKind,
@@ -113,69 +113,12 @@ function requiredKinds(spec: PhaseSpec, phase: Phase): string[] {
113
113
 
114
114
  /** All finding objects ever deposited, by id — verdict payloads carry them in
115
115
  * full (§15.3), so feedback-by-id resolves without touching artifacts. */
116
- function findingsById(events: AbEvent[]): Map<string, Finding> {
117
- const byId = new Map<string, Finding>()
118
- for (const event of events) {
119
- if (event.type === 'plan-review.verdict' || event.type === 'code-review.verdict') {
120
- for (const finding of event.payload.findings) byId.set(finding.id, finding)
121
- }
122
- }
123
- return byId
116
+ function findingsById(events: CurrentBuildFacts): Map<string, Finding> {
117
+ return new Map(Object.values(events.findings).map((finding) => [finding.id, finding]))
124
118
  }
125
-
126
- /**
127
- * The feedback carried by this occurrence's `*.started` event (§15.3).
128
- * `implement.started`, `plan.started`, and agent `verify.started` may carry
129
- * feedback — the carrier that gets answered guidance to a fresh session after
130
- * a parked build re-attaches (§15.6-B). A `plan.started` without one (logs
131
- * predating the field) falls
132
- * back to deriving round-N feedback from the round-(N-1)
133
- * `plan-review.verdict` — the plan loop's verdicts ARE its findings channel
134
- * (§8.3: "prior plan rev + findings").
135
- */
136
- function currentFeedback(events: AbEvent[], phase: Phase, round: number): Feedback | null {
137
- if (isVerifyPhase(phase)) {
138
- const step = verifyStep(phase)
139
- for (let i = events.length - 1; i >= 0; i -= 1) {
140
- const event = events[i]!
141
- if (
142
- event.type === 'verify.started' &&
143
- event.payload.step === step &&
144
- event.payload.attempt === round
145
- ) {
146
- return event.payload.feedback ?? null
147
- }
148
- }
149
- return null
150
- }
151
- if (phase === 'implement') {
152
- for (let i = events.length - 1; i >= 0; i -= 1) {
153
- const event = events[i]!
154
- if (event.type === 'implement.started' && event.payload.round === round) {
155
- return event.payload.feedback ?? null
156
- }
157
- }
158
- return null
159
- }
160
- if (phase === 'plan') {
161
- for (let i = events.length - 1; i >= 0; i -= 1) {
162
- const event = events[i]!
163
- if (event.type === 'plan.started' && event.payload.round === round) {
164
- if (event.payload.feedback !== undefined) return event.payload.feedback
165
- break // legacy start without the field — derive from the prior verdict
166
- }
167
- }
168
- if (round <= 1) return null
169
- for (let i = events.length - 1; i >= 0; i -= 1) {
170
- const event = events[i]!
171
- if (event.type === 'plan-review.verdict' && event.payload.round === round - 1) {
172
- return event.payload.verdict === 'revise'
173
- ? { findings: event.payload.findings.map((finding) => finding.id) }
174
- : null
175
- }
176
- }
177
- }
178
- return null
119
+ function currentFeedback(events: CurrentBuildFacts, phase: Phase, round: number): Feedback | null {
120
+ const explicit = events.feedback[JSON.stringify([phase, round])]
121
+ return explicit ?? (phase === 'plan' ? (events.planFeedback[String(round)] ?? null) : null)
179
122
  }
180
123
 
181
124
  /** Empty and recreate `.ab/`; callers restore any tracked legacy entries. */
@@ -184,7 +127,7 @@ async function wipeAbDir(abDir: string): Promise<void> {
184
127
  await mkdir(abDir, { recursive: true })
185
128
  }
186
129
 
187
- function dismissedIds(state: BuildState, events: AbEvent[]): string[] {
130
+ function dismissedIds(state: BuildState, events: CurrentBuildFacts): string[] {
188
131
  const known = findingsById(events)
189
132
  const ids = new Set<string>()
190
133
  for (const escalation of state.answeredEscalations) {
@@ -205,8 +148,8 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
205
148
  const { build, phase, round } = env
206
149
  const spec = phaseSpecFor(phase)
207
150
  const inputs = spec.inputs
208
- const events = await store.getEvents(build)
209
- const state = reduceBuild(events)
151
+ const events = (await readBuildFacts(store, build)).value
152
+ const state = events.state
210
153
 
211
154
  // Preserve legacy tracked scratch exactly while replacing disposable files.
212
155
  // Some adapter contract fixtures intentionally use a non-Git workspace;
@@ -271,7 +214,7 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
271
214
  }
272
215
 
273
216
  if (inputs.ticket === true) {
274
- const created = events.find((event) => event.type === 'build.created')
217
+ const created = events.created
275
218
  if (created !== undefined && created.type === 'build.created') {
276
219
  const original = created.payload.ticket
277
220
  const current =
@@ -442,13 +385,8 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
442
385
  // records the execution input in reconcile.started. A crashed same-attempt
443
386
  // rerun can therefore have multiple starts; the newest matching AB_PHASE
444
387
  // attempt is authoritative.
445
- for (let i = events.length - 1; i >= 0; i -= 1) {
446
- const event = events[i]!
447
- if (event.type === 'reconcile.started' && event.payload.attempt === round) {
448
- manifest.conflict = { baseSha: event.payload.baseSha }
449
- break
450
- }
451
- }
388
+ const baseSha = events.reconcileBases[String(round)]
389
+ if (baseSha !== undefined) manifest.conflict = { baseSha }
452
390
  if (manifest.conflict === undefined) {
453
391
  throw new Error(
454
392
  `reconcile@${round} context requires a matching reconcile.started event ` +
@@ -488,10 +426,10 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
488
426
  // Legacy tracked scratch is repository debt, not content Autobuild rewrites.
489
427
  // Record it once per build from the provisioned base so harvest can groom it.
490
428
  if (gitWorkspace && deps.ids !== undefined) {
491
- const provisioned = events.find((event) => event.type === 'workspace.provisioned')
429
+ const provisioned = events.firstWorkspace
492
430
  const marker = 'phase-scratch:tracked-at-provisioned-base'
493
- const alreadyRecorded = events.some(
494
- (event) => event.type === 'observation.recorded' && event.payload.refs?.includes(marker),
431
+ const alreadyRecorded = events.observations.some((observation) =>
432
+ observation.refs?.includes(marker),
495
433
  )
496
434
  if (
497
435
  provisioned !== undefined &&
@@ -1,3 +1,4 @@
1
+ import type { RepositoryReadFacts } from '../../store/reduced-reads'
1
2
  /**
2
3
  * The dashboard's pure projection: `BuildState` → what the operator sees.
3
4
  *
@@ -920,7 +921,7 @@ function escalationAttentionDismissed(events: RepositoryEvent[], terminalSeq: nu
920
921
  }
921
922
 
922
923
  function selectDashboardHarvestRun(
923
- events: RepositoryEvent[],
924
+ events: RepositoryEvent[] | RepositoryReadFacts,
924
925
  state: HarvestState,
925
926
  ): HarvestRunState | undefined {
926
927
  const attention = state.runs.find((run) => {
@@ -928,7 +929,12 @@ function selectDashboardHarvestRun(
928
929
  return run.recoveryExhaustion?.attentionAcknowledgedSeq === undefined
929
930
  }
930
931
  if (run.status !== 'escalated') return false
931
- return run.terminalSeq === undefined || !escalationAttentionDismissed(events, run.terminalSeq)
932
+ return (
933
+ run.terminalSeq === undefined ||
934
+ !(Array.isArray(events)
935
+ ? escalationAttentionDismissed(events, run.terminalSeq)
936
+ : events.dismissedThrough > run.terminalSeq)
937
+ )
932
938
  })
933
939
  return attention ?? openHarvestRun(state)
934
940
  }
@@ -937,7 +943,7 @@ function selectDashboardHarvestRun(
937
943
  * exhausted, or escalated attention outranks merely open work; completed and
938
944
  * acknowledged terminal history never hides an older actionable run. */
939
945
  function projectHarvestRun(
940
- events: RepositoryEvent[],
946
+ events: RepositoryEvent[] | RepositoryReadFacts,
941
947
  state: HarvestState,
942
948
  ): DashboardHarvest | undefined {
943
949
  const run = selectDashboardHarvestRun(events, state)
@@ -968,25 +974,27 @@ function projectHarvestRun(
968
974
  const approved = run.reviews.some((review) => review.verdict === 'approve')
969
975
  const synthOutput = run.proposals.length > 0
970
976
  const reviewOutput = run.reviews.length > 0
971
- const failedEvent =
972
- run.status === 'failed'
973
- ? [...events]
974
- .reverse()
975
- .find(
976
- (event) =>
977
- event.type === 'harvest.failed' &&
978
- event.payload.run === run.run &&
979
- event.payload.willRetry === false,
980
- )
981
- : undefined
977
+ const failureStoppedAt =
978
+ run.status !== 'failed'
979
+ ? undefined
980
+ : Array.isArray(events)
981
+ ? [...events]
982
+ .reverse()
983
+ .find(
984
+ (event) =>
985
+ event.type === 'harvest.failed' &&
986
+ event.payload.run === run.run &&
987
+ !event.payload.willRetry,
988
+ )?.ts
989
+ : events.failedAt[JSON.stringify(run.run)]
982
990
  // Both durable stop states freeze any open occurrence. A failure normally
983
991
  // closes its step immediately afterward, but projecting the boundary itself
984
992
  // must never make an errored row look like work is still accumulating time.
985
993
  const frozenAt =
986
994
  state.pausedAt !== undefined
987
995
  ? Date.parse(state.pausedAt)
988
- : failedEvent !== undefined
989
- ? Date.parse(failedEvent.ts)
996
+ : failureStoppedAt !== undefined
997
+ ? Date.parse(failureStoppedAt)
990
998
  : undefined
991
999
  const steps: PipelineStep[] = [
992
1000
  step('scan', hasOutcome('scan', 'completed'), current('scan'), {
@@ -1073,8 +1081,10 @@ interface RepositoryHarvestProjection {
1073
1081
  /** The repository-journal half of a legacy dashboard frame (AUT-545): the
1074
1082
  * harvest projection and the pause flag exactly as `buildDashboardFromProjected`
1075
1083
  * derives them, exposed for tests that pin the bounded read's equivalence. */
1076
- export function projectRepositoryHarvest(events: RepositoryEvent[]): RepositoryHarvestProjection {
1077
- const state = reduceHarvest(events)
1084
+ export function projectRepositoryHarvest(
1085
+ events: RepositoryEvent[] | RepositoryReadFacts,
1086
+ ): RepositoryHarvestProjection {
1087
+ const state = Array.isArray(events) ? reduceHarvest(events) : events.harvest
1078
1088
  const harvest = projectHarvestRun(events, state)
1079
1089
  return {
1080
1090
  harvestPaused: state.paused,
@@ -1082,7 +1092,9 @@ export function projectRepositoryHarvest(events: RepositoryEvent[]): RepositoryH
1082
1092
  }
1083
1093
  }
1084
1094
 
1085
- export function projectHarvest(events: RepositoryEvent[]): DashboardHarvest | undefined {
1095
+ export function projectHarvest(
1096
+ events: RepositoryEvent[] | RepositoryReadFacts,
1097
+ ): DashboardHarvest | undefined {
1086
1098
  return projectRepositoryHarvest(events).harvest
1087
1099
  }
1088
1100
 
@@ -1112,11 +1124,13 @@ export interface DashboardHeader extends DashboardFrameHeader {
1112
1124
  export function buildDashboardFromProjected(
1113
1125
  projectedBuilds: readonly DashboardBuild[],
1114
1126
  header: DashboardHeader,
1115
- repositoryEvents: RepositoryEvent[] = [],
1127
+ repositoryEvents: RepositoryEvent[] | RepositoryReadFacts = [],
1116
1128
  ): DashboardModel {
1117
1129
  const builds = [...projectedBuilds].sort((a, b) => a.slug.localeCompare(b.slug))
1118
1130
  const harvestProjection = projectRepositoryHarvest(repositoryEvents)
1119
- const settings = reduceDispatchSettings(repositoryEvents)
1131
+ const settings = Array.isArray(repositoryEvents)
1132
+ ? reduceDispatchSettings(repositoryEvents)
1133
+ : repositoryEvents.settings
1120
1134
  return {
1121
1135
  repo: header.repo,
1122
1136
  queued: header.queued,
@@ -1143,7 +1157,7 @@ export function buildDashboard(
1143
1157
  entries: { record: BuildRecord; state: BuildState; events: AbEvent[] }[],
1144
1158
  config: Config,
1145
1159
  header: DashboardFrameHeader,
1146
- repositoryEvents: RepositoryEvent[] = [],
1160
+ repositoryEvents: RepositoryEvent[] | RepositoryReadFacts = [],
1147
1161
  ): DashboardModel {
1148
1162
  const builds = entries
1149
1163
  .map(({ record, state, events }) => projectBuild(record, state, config, events))
@@ -1,10 +1,11 @@
1
+ import { DispatchRepositoryFeed } from './dispatch-repository-feed'
1
2
  import { StandbyIdentityResolver } from './dispatcher-liveness'
2
3
  import { configSchema, type Config } from '../config/schema'
3
4
  import { humanActor } from '../events/envelope'
4
5
  import type { RepositoryEvent } from '../events/repository'
5
6
  import { reduceDispatchStatus, type DispatchStatus } from '../kernel/dispatch-status'
6
7
  import type { BuildState } from '../kernel/reducer'
7
- import { sampleUnclaimedObservationCount } from '../processes/harvest'
8
+ import { unclaimedObservationCount } from '../processes/harvest'
8
9
  import {
9
10
  controlHarvestRun,
10
11
  OperatorControlError,
@@ -68,15 +69,6 @@ function isStaleAbortControlError(error: unknown): error is BuildControlError {
68
69
  )
69
70
  }
70
71
 
71
- function isDashboardRepositoryEvent(event: RepositoryEvent): boolean {
72
- return (
73
- event.type.startsWith('harvest.') ||
74
- event.type === 'dispatcher.intake-set' ||
75
- event.type === 'dispatcher.pause-set' ||
76
- event.type === 'dispatcher.auto-merge-default-set'
77
- )
78
- }
79
-
80
72
  interface ResumePrompt {
81
73
  slug: string
82
74
  escalationIds: string[]
@@ -121,11 +113,7 @@ export class DispatchFrontend {
121
113
  private readonly region: LiveRegion
122
114
  private readonly keyboard
123
115
  private child: DispatchChildHandle | undefined
124
- private repositorySeq = 0
125
- /** Set only after a successful initial fold; every later `events()` call
126
- * takes the delta path. */
127
- private seeded = false
128
- private repositoryEvents: RepositoryEvent[] = []
116
+ private readonly repositoryFeed: DispatchRepositoryFeed
129
117
  private dispatchStatus: DispatchStatus = reduceDispatchStatus([], this.runId)
130
118
  private eventRefreshTail: Promise<void> = Promise.resolve()
131
119
  private cache: DashboardBuildPollCache | undefined
@@ -155,8 +143,12 @@ export class DispatchFrontend {
155
143
  private operatorStopRequested = false
156
144
 
157
145
  constructor(private readonly opts: DispatchFrontendOptions) {
158
- this.standbyResolver = new StandbyIdentityResolver(opts.store, opts.repo)
159
146
  this.clock = opts.clock ?? systemClock
147
+ this.repositoryFeed = new DispatchRepositoryFeed(opts.store, opts.repo, this.clock)
148
+ this.standbyResolver = new StandbyIdentityResolver(opts.store, opts.repo, {
149
+ allowLookup: () => this.repositoryFeed.incompleteIdentities,
150
+ remember: (events, complete) => this.repositoryFeed.remember(events, complete),
151
+ })
160
152
  this.keyboard = createKeyboardProtocol(
161
153
  (chunk) => opts.terminal.write(chunk),
162
154
  opts.terminal.modes,
@@ -170,47 +162,14 @@ export class DispatchFrontend {
170
162
  * are retained for their existing replay reducers. */
171
163
  private async events(): Promise<RepositoryEvent[]> {
172
164
  const refresh = this.eventRefreshTail.then(async () => {
173
- if (!this.seeded) {
174
- // Initial fold (AUT-534): the whole-journal read the first poll used
175
- // to pay is replaced by AUT-489's bounded read where that is provably
176
- // equivalent. The bounded attempt is confined to the pre-launch
177
- // window (`this.child === undefined`): before `launchChild` no
178
- // run-scoped journal fact can carry `this.runId` (only the supervised
179
- // child appends run-scoped facts with it), so every run-scoped event
180
- // in the bounded subset is skipped by `reduceDispatchStatus` exactly
181
- // as it would be in a full replay, and the dashboard filter keeps
182
- // only durable types, which the subset carries in full. After launch
183
- // a bounded subset cannot guarantee the child's `run-started` under a
184
- // competing dispatcher's later anchor — dropping it would leave
185
- // `effectiveConfig` unset and the first frame would never render —
186
- // so a post-launch seed always takes the full read (the pre-AUT-534
187
- // behavior). `once` mode's teardown fold runs after the child exits,
188
- // so the same guard routes it to the full read.
189
- const seed =
190
- this.child === undefined
191
- ? await this.opts.store.getRepoStateEvents(this.opts.repo).catch(() =>
192
- // Bounded-read failure falls back to the exact pre-AUT-534
193
- // full read within this same seed attempt.
194
- this.opts.store.getRepoEvents(this.opts.repo, 0),
195
- )
196
- : await this.opts.store.getRepoEvents(this.opts.repo, 0)
197
- this.standbyResolver.ingest(seed)
198
- this.repositorySeq = seed.at(-1)?.seq ?? 0
199
- this.dispatchStatus = reduceDispatchStatus(seed, this.runId)
200
- this.repositoryEvents.push(...seed.filter(isDashboardRepositoryEvent))
201
- this.seeded = true
202
- return
203
- }
204
- const delta = await this.opts.store.getRepoEvents(this.opts.repo, this.repositorySeq)
205
- if (delta.length === 0) return
165
+ const delta = await this.repositoryFeed.refresh(false)
166
+ this.standbyResolver.ingest(this.repositoryFeed.identities)
206
167
  this.standbyResolver.ingest(delta)
207
- this.repositorySeq = delta.at(-1)!.seq
208
168
  this.dispatchStatus = reduceDispatchStatus(delta, this.runId, this.dispatchStatus)
209
- this.repositoryEvents.push(...delta.filter(isDashboardRepositoryEvent))
210
169
  })
211
170
  this.eventRefreshTail = refresh.catch(() => {})
212
171
  await refresh
213
- return this.repositoryEvents
172
+ return this.repositoryFeed.events
214
173
  }
215
174
 
216
175
  private async report(message: string, level: 'info' | 'warning' = 'info'): Promise<void> {
@@ -333,14 +292,10 @@ export class DispatchFrontend {
333
292
  }
334
293
  const config = await this.loadConfig(ref.kind, ref.rev)
335
294
  try {
336
- // Journal + digests (AUT-487): one journal read for the claimed set
337
- // (this class's cached `repositoryEvents` is dashboard-filtered, so the
338
- // claimed set needs the full journal, exactly as the old per-build scan
339
- // read it) plus one repo-scoped digest read — the sample's store
340
- // traffic stays flat as finished builds accumulate. A journal record
341
- // that does not yet exist reads as an empty journal (AUT-524); the
342
- // sample writes nothing.
343
- this.observationCount = await sampleUnclaimedObservationCount(this.opts.store, this.opts.repo)
295
+ this.observationCount = unclaimedObservationCount({
296
+ digests: await this.opts.store.getRepoBuildDigests(this.opts.repo),
297
+ harvestEvents: repositoryEvents,
298
+ })
344
299
  } catch {
345
300
  // Before the first successful sample there is no factual zero to place in
346
301
  // a complete frame. Leave the prior frame untouched and retry on the next
@@ -401,6 +356,7 @@ export class DispatchFrontend {
401
356
  ) {
402
357
  this.view = undefined
403
358
  }
359
+ await this.repositoryFeed.save()
404
360
  this.model = projected
405
361
  this.syncControls()
406
362
  this.paint()
@@ -1057,13 +1013,13 @@ export class DispatchFrontend {
1057
1013
  })
1058
1014
  }
1059
1015
 
1060
- // Warm the initial fold before `launchChild` (AUT-534): the bounded seed
1061
- // is only safe while `this.child === undefined`, so the one opportunity
1062
- // to take it is here. A failed warm-up leaves the seed unset; the poll
1063
- // loop's existing failure reporting covers the retry, which then takes
1064
- // the full-read path post-launch. `once` mode gains no read — its single
1065
- // fold runs from `finishPresentation()` after the child exits.
1066
- if (!this.opts.once) await this.events().catch(() => {})
1016
+ try {
1017
+ const delta = await this.repositoryFeed.warm()
1018
+ this.standbyResolver.ingest(this.repositoryFeed.identities)
1019
+ this.dispatchStatus = reduceDispatchStatus(delta, this.runId, this.dispatchStatus)
1020
+ } catch {
1021
+ /* Display initialization must not gate the kernel. */
1022
+ }
1067
1023
 
1068
1024
  const options: DispatchChildOptions = {
1069
1025
  targetRepo: this.opts.checkout ?? this.opts.repo,
@@ -1104,6 +1060,7 @@ export class DispatchFrontend {
1104
1060
  this.opts.signal?.removeEventListener('abort', stop)
1105
1061
  await this.child.stop()
1106
1062
  await this.finishPresentation()
1063
+ await this.repositoryFeed.save(true)
1107
1064
  }
1108
1065
  }
1109
1066
  }
@@ -1,6 +1,6 @@
1
+ import { readDispatcherRunFacts, readOpenTick } from '../store/reduced-reads'
1
2
  import { distributionPath } from '../distribution'
2
3
  import { DISPATCHER } from '../events/envelope'
3
- import { hasOpenTick } from '../processes/tick-state'
4
4
  import type { BuildStore } from '../store/types'
5
5
 
6
6
  export const DISPATCH_CHILD_OPTIONS_ENV = 'AB_DISPATCH_CHILD_OPTIONS'
@@ -168,10 +168,8 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
168
168
  // chooses/awaits process termination, avoiding check-then-append races.
169
169
  const completed = child.exited.then(async (exitCode): Promise<DispatchChildResult> => {
170
170
  const processSignal = child.signalCode === null ? requestedSignal : String(child.signalCode)
171
- const events = await deps.store.getRepoEvents(deps.repo)
172
- const recorded = events.findLast(
173
- (event) => event.type === 'dispatcher.run-stopped' && event.payload.run === deps.run,
174
- )
171
+ const recorded = (await readDispatcherRunFacts(deps.store, deps.repo, deps.run, false)).value
172
+ .stop
175
173
  if (recorded?.type === 'dispatcher.run-stopped') {
176
174
  const signal = processSignal ?? recorded.payload.signal
177
175
  return {
@@ -245,12 +243,21 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
245
243
  // Ready with no durable build to recover. Let an open tick reach its
246
244
  // normal boundary; all non-tick work is lease-backed/recoverable and a
247
245
  // child stuck there is safe to terminate.
248
- const events = await deps.store.getRepoEvents(deps.repo)
246
+ let open = true
247
+ try {
248
+ const record = await deps.store.getRepo(deps.repo)
249
+ open =
250
+ record?.tick?.run === deps.run
251
+ ? record.tick.open !== undefined
252
+ : await readOpenTick(deps.store, deps.repo, deps.run, false)
253
+ } catch {
254
+ // Unreadable telemetry cannot authorize an unsafe kill.
255
+ }
249
256
  // The child may have exited while the asynchronous journal read was in
250
257
  // flight. Re-check at the force boundary so evidence never claims a
251
258
  // SIGKILL that was not actually sent.
252
259
  if (child.exitCode !== null) return
253
- if (hasOpenTick(events, deps.run)) {
260
+ if (open) {
254
261
  await child.exited
255
262
  return
256
263
  }