@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, readBuildSelector, readPhaseFailures } from '../store/reduced-reads'
1
2
  import { delegateCredential } from '../store/remote/credentials'
2
3
  import { refreshSummaryBoundary } from '../cli/pr-summary-publication'
3
4
  /**
@@ -50,12 +51,11 @@ import type { Config } from '../config/schema'
50
51
  import type { AbEvent, EventWrite } from '../events/catalog'
51
52
  import type { EventPayload } from '../events/payloads'
52
53
  import { KERNEL, agentActor, type Actor } from '../events/envelope'
53
- import { isRemoteWorkspace } from '../events/workspace-remote'
54
54
  import type { IdSource } from '../ids'
55
- import { decideNext, type Decision } from '../kernel/engine'
55
+ import { decideNextFromFacts, type Decision } from '../kernel/engine'
56
56
  import { defineReducer } from '../kernel/incremental'
57
57
  import { PHASE_SPECS } from '../kernel/phases'
58
- import { reduceBuild, type BuildState } from '../kernel/reducer'
58
+ import type { BuildState } from '../kernel/reducer'
59
59
  import { evaluateVerifyApplicability } from '../kernel/verify-applicability'
60
60
  import {
61
61
  verifyPhase,
@@ -89,7 +89,6 @@ import { phaseScratchRejection, scratchPathsTouchedInRange } from '../ports/work
89
89
  import type { Exec } from '../ports/workspace/git-worktree'
90
90
  import type { BuildScopedStore, Clock } from '../store/types'
91
91
  import { resetsPhaseFailureBudget, type PhaseFailureResetEscalation } from './phase-failure-budget'
92
- import { publicationPending } from './publication-state'
93
92
 
94
93
  export { publicationPending } from './publication-state'
95
94
 
@@ -639,9 +638,6 @@ export class BuildRunner {
639
638
  /** Consecutive durable setup failures since the latest successful attachment
640
639
  * or explicit human answer. The answer re-arms the budget but deliberately
641
640
  * does not erase the current diagnostic while recovery is still running. */
642
- private setupStreak(events: readonly AbEvent[]): number {
643
- return setupStreakReducer.reduce(events)
644
- }
645
641
 
646
642
  private setupQuestion(failure: EventPayload<'runner.setup-failed'>): string {
647
643
  const status =
@@ -671,10 +667,10 @@ export class BuildRunner {
671
667
  * announce takeover against that same boundary. If a write is interrupted,
672
668
  * no attachment lands; the next runner reduces the newer prefix and closes
673
669
  * only sessions that remain open. */
674
- private async reclaimSessionsAndAttach(events: readonly AbEvent[]): Promise<void> {
670
+ private async reclaimSessionsAndAttach(state: BuildState): Promise<void> {
675
671
  const { store, slug, instance, host } = this.deps
676
- const resumedFromSeq = events.at(-1)?.seq ?? 0
677
- for (const session of reduceBuild([...events]).sessions.open) {
672
+ const resumedFromSeq = state.lastSeq
673
+ for (const session of state.sessions.open) {
678
674
  await store.append(slug, {
679
675
  actor: KERNEL,
680
676
  type: 'session.ended',
@@ -709,8 +705,8 @@ export class BuildRunner {
709
705
  const claimed = await store.claimLease(slug, instance, this.leaseTtlMs)
710
706
  if (!claimed) throw new LeaseHeldError(slug, instance)
711
707
 
712
- let events = await store.getEvents(slug)
713
- const decision = decideNext(events, config)
708
+ let facts = (await readBuildFacts(store, slug)).value
709
+ const decision = decideNextFromFacts(facts.state, facts.log, config)
714
710
  if (
715
711
  decision.kind === 'acknowledge' &&
716
712
  (decision.command === 'pause' || decision.command === 'abort')
@@ -719,12 +715,12 @@ export class BuildRunner {
719
715
  return { kind: 'control-acknowledged', command: decision.command }
720
716
  }
721
717
 
722
- const state = reduceBuild(events)
718
+ const state = facts.state
723
719
  const currentFailure = state.setupFailure
724
720
  const openSetupEscalation = state.openEscalations.some(
725
721
  (escalation) => escalation.phase === 'setup' && escalation.source === 'policy',
726
722
  )
727
- const streak = this.setupStreak(events)
723
+ const streak = await readBuildSelector(store, slug, 'setupStreak')
728
724
 
729
725
  // Crash-safe exhaustion: the final failure and escalation are separate
730
726
  // appends. If only the failure landed, raise the blocker before another
@@ -738,7 +734,7 @@ export class BuildRunner {
738
734
  }
739
735
 
740
736
  const recovering = currentFailure !== undefined
741
- if (!recovering) await this.reclaimSessionsAndAttach(events)
737
+ if (!recovering) await this.reclaimSessionsAndAttach(facts.state)
742
738
 
743
739
  // First beat immediately (liveness visible without waiting an interval),
744
740
  // then keep-alive. Setup runs under this heartbeat so a slow install cannot
@@ -800,8 +796,8 @@ export class BuildRunner {
800
796
 
801
797
  if (recovering) {
802
798
  try {
803
- events = await store.getEvents(slug)
804
- await this.reclaimSessionsAndAttach(events)
799
+ facts = (await readBuildFacts(store, slug)).value
800
+ await this.reclaimSessionsAndAttach(facts.state)
805
801
  } catch (error) {
806
802
  this.stopHeartbeat()
807
803
  this.attached = false
@@ -819,8 +815,8 @@ export class BuildRunner {
819
815
  async step(): Promise<Decision> {
820
816
  const config = await this.captureConfig()
821
817
  const { store, slug } = this.deps
822
- const events = await store.getEvents(slug)
823
- const decision = decideNext(events, config)
818
+ const facts = (await readBuildFacts(store, slug)).value
819
+ const decision = decideNextFromFacts(facts.state, facts.log, config)
824
820
  switch (decision.kind) {
825
821
  case 'capture-reconcile-policy':
826
822
  await store.append(slug, {
@@ -851,25 +847,25 @@ export class BuildRunner {
851
847
  await this.raiseEscalation(decision)
852
848
  return decision
853
849
  case 'run-phase':
854
- await this.runPhase(decision, events)
850
+ await this.runPhase(decision)
855
851
  return decision
856
852
  case 'run-check':
857
853
  await this.runCheck(decision)
858
854
  return decision
859
855
  case 'run-agent-verify':
860
- await this.runAgentVerify(decision, events)
856
+ await this.runAgentVerify(decision)
861
857
  return decision
862
858
  case 'evaluate-verify':
863
- await this.evaluateVerify(decision, events)
859
+ await this.evaluateVerify(decision)
864
860
  return decision
865
861
  case 'skip-verify':
866
862
  await this.skipVerify(decision)
867
863
  return decision
868
864
  case 'run-finalize-step':
869
- await this.runFinalizeStep(decision, events)
865
+ await this.runFinalizeStep(decision)
870
866
  return decision
871
867
  case 'check-reconcile-progress':
872
- await this.checkReconcileProgress(decision, events)
868
+ await this.checkReconcileProgress(decision)
873
869
  return decision
874
870
  }
875
871
  }
@@ -885,23 +881,24 @@ export class BuildRunner {
885
881
  if (!this.attached) {
886
882
  const attachment = await this.attach()
887
883
  if (attachment.kind === 'control-acknowledged') {
888
- return reduceBuild(await this.deps.store.getEvents(this.deps.slug))
884
+ return (await readBuildFacts(this.deps.store, this.deps.slug)).value.state
889
885
  }
890
886
  }
891
887
  try {
892
888
  if (await this.refreshSummary())
893
- return reduceBuild(await this.deps.store.getEvents(this.deps.slug))
894
- if (publicationPending(await this.deps.store.getEvents(this.deps.slug))) {
895
- return reduceBuild(await this.deps.store.getEvents(this.deps.slug))
889
+ return (await readBuildFacts(this.deps.store, this.deps.slug)).value.state
890
+ if ((await readBuildFacts(this.deps.store, this.deps.slug)).value.publication.pending()) {
891
+ return (await readBuildFacts(this.deps.store, this.deps.slug)).value.state
896
892
  }
897
893
  for (;;) {
898
894
  await this.ensureLease()
899
895
  const decision = await this.step()
900
896
  if (await this.refreshSummary()) break
901
897
  if (decision.kind === 'wait') break
902
- if (publicationPending(await this.deps.store.getEvents(this.deps.slug))) break
898
+ if ((await readBuildFacts(this.deps.store, this.deps.slug)).value.publication.pending())
899
+ break
903
900
  }
904
- return reduceBuild(await this.deps.store.getEvents(this.deps.slug))
901
+ return (await readBuildFacts(this.deps.store, this.deps.slug)).value.state
905
902
  } finally {
906
903
  this.stopHeartbeat()
907
904
  await this.closeProducerSessions()
@@ -982,12 +979,14 @@ export class BuildRunner {
982
979
  * reuse the snapshot as the merge target: actual phase startup refreshes
983
980
  * again, preserving the execution-boundary freshness guarantee.
984
981
  */
985
- private async checkReconcileProgress(
986
- decision: CheckReconcileProgressDecision,
987
- events: AbEvent[],
988
- ): Promise<void> {
982
+ private async checkReconcileProgress(decision: CheckReconcileProgressDecision): Promise<void> {
989
983
  const { nextAttempt } = decision
990
- const failures = this.phaseFailures(events, 'reconcile', nextAttempt)
984
+ const failures = await readPhaseFailures(
985
+ this.deps.store,
986
+ this.deps.slug,
987
+ 'reconcile',
988
+ nextAttempt,
989
+ )
991
990
  if (failures.count >= this.maxPhaseAttempts) {
992
991
  await this.raisePolicyExhausted('reconcile', nextAttempt, failures)
993
992
  return
@@ -999,7 +998,7 @@ export class BuildRunner {
999
998
 
1000
999
  let baseSha: string
1001
1000
  try {
1002
- baseSha = await this.refreshReconcileBase(events)
1001
+ baseSha = await this.refreshReconcileBase()
1003
1002
  } catch (error) {
1004
1003
  await this.failPhase(
1005
1004
  'reconcile',
@@ -1022,13 +1021,13 @@ export class BuildRunner {
1022
1021
  }
1023
1022
 
1024
1023
  /** A core-phase run: retry guard (D5), started event (§15.3), session. */
1025
- private async runPhase(decision: RunPhaseDecision, events: AbEvent[]): Promise<void> {
1024
+ private async runPhase(decision: RunPhaseDecision): Promise<void> {
1026
1025
  const { store, slug } = this.deps
1027
1026
  const { phase, round } = decision
1028
1027
 
1029
1028
  // D5 RETRY GUARD first: at the cap, escalate instead of running — a
1030
1029
  // failing phase must never loop forever.
1031
- const failures = this.phaseFailures(events, phase, round)
1030
+ const failures = await readPhaseFailures(this.deps.store, this.deps.slug, phase, round)
1032
1031
  if (failures.count >= this.maxPhaseAttempts) {
1033
1032
  await this.raisePolicyExhausted(phase, round, failures)
1034
1033
  return
@@ -1045,7 +1044,7 @@ export class BuildRunner {
1045
1044
  let reconcileBaseSha: string | undefined
1046
1045
  if (phase === 'reconcile') {
1047
1046
  try {
1048
- reconcileBaseSha = await this.refreshReconcileBase(events)
1047
+ reconcileBaseSha = await this.refreshReconcileBase()
1049
1048
  } catch (error) {
1050
1049
  await this.failPhase(
1051
1050
  phase,
@@ -1081,20 +1080,20 @@ export class BuildRunner {
1081
1080
  * would let another build overwrite the object this attempt resolves. The
1082
1081
  * explicit ref also makes the fetched remote-only commit available locally.
1083
1082
  */
1084
- private async refreshReconcileBase(events: AbEvent[]): Promise<string> {
1085
- const created = events.find((event) => event.type === 'build.created')
1086
- if (created === undefined || created.type !== 'build.created') {
1083
+ private async refreshReconcileBase(): Promise<string> {
1084
+ const baseBranch = await readBuildSelector(this.deps.store, this.deps.slug, 'baseBranch')
1085
+ if (baseBranch === undefined) {
1087
1086
  throw new Error(
1088
1087
  'this build has no build.created event — cannot resolve its frozen baseBranch (§15.3)',
1089
1088
  )
1090
1089
  }
1091
1090
 
1092
- const sourceRef = `refs/heads/${created.payload.baseBranch}`
1091
+ const sourceRef = `refs/heads/${baseBranch}`
1093
1092
  const targetRef = `refs/autobuild/reconcile/${this.deps.slug}/base`
1094
1093
  if (this.deps.forge.snapshotBase !== undefined) {
1095
1094
  const sha = await this.deps.forge.snapshotBase({
1096
1095
  workspacePath: this.deps.workspacePath,
1097
- base: created.payload.baseBranch,
1096
+ base: baseBranch,
1098
1097
  destinationRef: targetRef,
1099
1098
  })
1100
1099
  if (!GIT_OBJECT_ID.test(sha)) {
@@ -1138,9 +1137,13 @@ export class BuildRunner {
1138
1137
  * a canonical kernel-authored skip and launches no command or session.
1139
1138
  * Inclusion delegates to the existing verifier path unchanged.
1140
1139
  */
1141
- private async evaluateVerify(decision: EvaluateVerifyDecision, events: AbEvent[]): Promise<void> {
1140
+ private async evaluateVerify(decision: EvaluateVerifyDecision): Promise<void> {
1142
1141
  const { exec, workspacePath } = this.deps
1143
- const baseSha = selectVerifyDiffBase(events)
1142
+ const baseSha = await readBuildSelector(this.deps.store, this.deps.slug, 'verifyDiffBase')
1143
+ if (baseSha === undefined)
1144
+ throw new Error(
1145
+ 'conditional verify requires a workspace.provisioned base SHA, but this build has none',
1146
+ )
1144
1147
  const args = ['git', 'diff', '--no-renames', '--name-only', '-z', baseSha, 'HEAD', '--']
1145
1148
  const result = await exec(args, { cwd: workspacePath })
1146
1149
  if (result.exitCode !== 0) {
@@ -1154,7 +1157,7 @@ export class BuildRunner {
1154
1157
  { kind: 'paths', step: decision.step, paths: decision.paths },
1155
1158
  changedPaths,
1156
1159
  )
1157
- if (!applicability.applies) {
1160
+ if (applicability.applies === false) {
1158
1161
  await this.skipVerify({
1159
1162
  kind: 'skip-verify',
1160
1163
  step: decision.step,
@@ -1167,7 +1170,7 @@ export class BuildRunner {
1167
1170
  if (decision.action.kind === 'run-check') {
1168
1171
  await this.runCheck(decision.action)
1169
1172
  } else {
1170
- await this.runAgentVerify(decision.action, events)
1173
+ await this.runAgentVerify(decision.action)
1171
1174
  }
1172
1175
  }
1173
1176
 
@@ -1245,14 +1248,14 @@ export class BuildRunner {
1245
1248
  }
1246
1249
 
1247
1250
  /** Agent-verify step (§5): a session with a pass/fail/skip verdict. */
1248
- private async runAgentVerify(decision: RunAgentVerifyDecision, events: AbEvent[]): Promise<void> {
1251
+ private async runAgentVerify(decision: RunAgentVerifyDecision): Promise<void> {
1249
1252
  const { store, slug } = this.deps
1250
1253
  const { step, skill, attempt, feedback } = decision
1251
1254
  const phase = verifyPhase(step)
1252
1255
 
1253
1256
  // D5 guard, extended to agent-verify (see module doc): keyed by
1254
1257
  // verify:<step> with the verify attempt as the round.
1255
- const failures = this.phaseFailures(events, phase, attempt)
1258
+ const failures = await readPhaseFailures(this.deps.store, this.deps.slug, phase, attempt)
1256
1259
  if (failures.count >= this.maxPhaseAttempts) {
1257
1260
  await this.raisePolicyExhausted(phase, attempt, failures)
1258
1261
  return
@@ -1292,10 +1295,7 @@ export class BuildRunner {
1292
1295
  * Step, Git, and Forge failures become the ordinary failed outcome plus an
1293
1296
  * observation and never route a green build backward.
1294
1297
  */
1295
- private async runFinalizeStep(
1296
- decision: RunFinalizeStepDecision,
1297
- events: AbEvent[],
1298
- ): Promise<void> {
1298
+ private async runFinalizeStep(decision: RunFinalizeStepDecision): Promise<void> {
1299
1299
  const { step, action } = decision
1300
1300
  await this.deps.store.append(this.deps.slug, {
1301
1301
  actor: KERNEL,
@@ -1308,7 +1308,12 @@ export class BuildRunner {
1308
1308
 
1309
1309
  try {
1310
1310
  await this.requireFinalizeWorktreeClean('before the post-step starts')
1311
- durableHead = selectPublishedBranchHead(events)
1311
+ durableHead = await readBuildSelector(this.deps.store, this.deps.slug, 'publishedBranchHead')
1312
+ if (durableHead === undefined)
1313
+ throw new Error(
1314
+ 'finalize publication requires a prior implement.completed, reconcile.completed, ' +
1315
+ 'or successful finalize publication head, but this build has none',
1316
+ )
1312
1317
  } catch (error) {
1313
1318
  failureNote = `finalize publication preflight failed: ${errorMessage(error)}`
1314
1319
  }
@@ -1338,9 +1343,8 @@ export class BuildRunner {
1338
1343
  head,
1339
1344
  )
1340
1345
  if (scratchPaths.length > 0) throw phaseScratchRejection(scratchPaths)
1341
- const remote = events.some(
1342
- (event) => event.type === 'workspace.provisioned' && isRemoteWorkspace(event.payload),
1343
- )
1346
+ const remote = (await readBuildFacts(this.deps.store, this.deps.slug)).value
1347
+ .remoteWorkspace
1344
1348
  if (remote) {
1345
1349
  await this.deps.store.append(this.deps.slug, {
1346
1350
  actor,
@@ -2642,18 +2646,6 @@ export class BuildRunner {
2642
2646
  * an applicable answer count again, so a still-failing phase re-escalates on
2643
2647
  * new evidence instead of deadlocking before its recovery session starts.
2644
2648
  */
2645
- private phaseFailures(
2646
- events: AbEvent[],
2647
- phase: Phase,
2648
- round: number,
2649
- ): {
2650
- count: number
2651
- lastError?: string
2652
- lastWillRetry?: boolean
2653
- lastProviderAttempts?: readonly ProviderAttempt[]
2654
- } {
2655
- return phaseFailuresReducer(phase, round).reduce(events)
2656
- }
2657
2649
 
2658
2650
  private async failPhase(
2659
2651
  phase: Phase,
@@ -1,3 +1,4 @@
1
+ import { readDispatchBudget } from '../store/reduced-reads'
1
2
  import type { AbEvent } from '../events/catalog'
2
3
  import { DISPATCHER } from '../events/envelope'
3
4
  import type { IdSource } from '../ids'
@@ -54,19 +55,19 @@ export function dispatchFailureBudget(
54
55
 
55
56
  /** Repair the failure/raise crash gap before allowing another provider call. */
56
57
  export async function ensureDispatchBudget(
57
- store: Pick<BuildStore, 'getEvents' | 'appendIfCurrent'>,
58
+ store: Pick<BuildStore, 'getReducerValue' | 'appendIfCurrent'>,
58
59
  ids: IdSource,
59
60
  slug: string,
60
61
  maxAttempts: number,
61
62
  infrastructureOwnsWorkspace: boolean,
62
63
  ): Promise<boolean> {
63
64
  for (;;) {
64
- const events = await store.getEvents(slug)
65
- const budget = dispatchFailureBudget(events, infrastructureOwnsWorkspace)
65
+ const current = await readDispatchBudget(store, slug, infrastructureOwnsWorkspace)
66
+ const budget = current.value
66
67
  if (budget.parked) return false
67
68
  if (budget.count < maxAttempts || budget.latest === undefined) return true
68
69
  const failure = budget.latest.payload
69
- const raised = await store.appendIfCurrent(slug, events.at(-1)?.seq ?? 0, {
70
+ const raised = await store.appendIfCurrent(slug, current.cursor, {
70
71
  actor: DISPATCHER,
71
72
  type: 'escalation.raised',
72
73
  payload: {