@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,3 +1,8 @@
1
+ import { readBuildDigests } from '../store/reduced-reads'
2
+ import { readBuildStreamSeed } from '../store/reduced-reads'
3
+ import { buildStreamSeedReducer, type BuildStreamSeed } from '../kernel/read-facts'
4
+ import { historicalBuildState } from './historical-build-state'
5
+ import { currentDeferralObservationReducer, type DeferralLedger } from '../kernel/auto-merge'
1
6
  /**
2
7
  * `ab wait` — the blocking sibling of `ab watch` (§8.2, §7.2). It follows the
3
8
  * event logs of one, several, or all active builds in the repository and
@@ -31,11 +36,11 @@
31
36
  * Read-only: it appends no event, claims or renews no lease, and creates no
32
37
  * build or repository record. Output carries no ANSI, ever, in either form.
33
38
  */
34
- import { buildNeedsAttention, mergeDecision, mergeIdentity } from './attention'
39
+ import { mergeDecisionFromFacts, mergeIdentity } from './attention'
35
40
  import type { AbEvent } from '../events/catalog'
36
41
  import type { EventType } from '../events/payloads'
37
42
  import type { BuildState, PrLifecycle } from '../kernel/reducer'
38
- import { reduceBuild } from '../kernel/reducer'
43
+ import { buildReducer, reduceBuild } from '../kernel/reducer'
39
44
  import { isPhase, type BuildStatus, type Phase } from '../ontology'
40
45
  import type { Exec } from '../ports/workspace/git-worktree'
41
46
  import {
@@ -156,8 +161,25 @@ export function parseConditions(
156
161
  return { conditions, globs }
157
162
  }
158
163
 
164
+ function attentionFrom(
165
+ input: AbEvent[] | { state: BuildState; deferral: DeferralLedger },
166
+ state?: BuildState,
167
+ ) {
168
+ return Array.isArray(input)
169
+ ? mergeDecisionFromFacts(
170
+ state ?? reduceBuild(input),
171
+ currentDeferralObservationReducer.reduce(input),
172
+ new Date(0),
173
+ )
174
+ : mergeDecisionFromFacts(input.state, input.deferral, new Date(0))
175
+ }
176
+
159
177
  /** Does one condition body hold over a reduction? Attention includes current escalation and merge state. */
160
- function stateSatisfies(cond: ConditionBody, state: BuildState, events: AbEvent[] = []): boolean {
178
+ function stateSatisfies(
179
+ cond: ConditionBody,
180
+ state: BuildState,
181
+ events: AbEvent[] | { state: BuildState; deferral: DeferralLedger } = [],
182
+ ): boolean {
161
183
  switch (cond.kind) {
162
184
  case 'status':
163
185
  return state.status === cond.status
@@ -168,7 +190,7 @@ function stateSatisfies(cond: ConditionBody, state: BuildState, events: AbEvent[
168
190
  case 'phase':
169
191
  return (state.phase ?? null) === cond.phase
170
192
  case 'attention':
171
- return buildNeedsAttention(events, state)
193
+ return (state.openEscalations?.length ?? 0) > 0 || attentionFrom(events, state) !== null
172
194
  }
173
195
  }
174
196
 
@@ -180,7 +202,7 @@ function stateSatisfies(cond: ConditionBody, state: BuildState, events: AbEvent[
180
202
  export function matchState(
181
203
  conditions: readonly WaitCondition[],
182
204
  state: BuildState,
183
- events: AbEvent[] = [],
205
+ events: AbEvent[] | { state: BuildState; deferral: DeferralLedger } = [],
184
206
  ): string | null {
185
207
  for (const { text, cond } of conditions) {
186
208
  if (stateSatisfies(cond, state, events)) return text
@@ -195,13 +217,12 @@ export function matchEvent(
195
217
  event: Pick<AbEvent, 'type'>,
196
218
  conditions: readonly WaitCondition[],
197
219
  globs: readonly EventGlob[],
198
- events: AbEvent[] = [],
220
+ events: AbEvent[] | { state: BuildState; deferral: DeferralLedger } = [],
199
221
  ): string | null {
200
222
  for (const { text, cond } of conditions) {
201
223
  if (
202
224
  cond.kind === 'attention' &&
203
- (isAttentionEvent(event.type) ||
204
- (events.length > 0 && mergeDecision(events, new Date(0)) !== null))
225
+ (isAttentionEvent(event.type) || attentionFrom(events) !== null)
205
226
  )
206
227
  return text
207
228
  }
@@ -222,14 +243,13 @@ function matchFirst(
222
243
  state: BuildState,
223
244
  conditions: readonly WaitCondition[],
224
245
  globs: readonly EventGlob[],
225
- events: AbEvent[] = [],
246
+ events: AbEvent[] | { state: BuildState; deferral: DeferralLedger } = [],
226
247
  ): string | null {
227
248
  for (const { text, cond } of conditions) {
228
249
  if (stateSatisfies(cond, state, events)) return text
229
250
  if (
230
251
  cond.kind === 'attention' &&
231
- (isAttentionEvent(event.type) ||
232
- (events.length > 0 && mergeDecision(events, new Date(0)) !== null))
252
+ (isAttentionEvent(event.type) || attentionFrom(events) !== null)
233
253
  )
234
254
  return text
235
255
  }
@@ -276,7 +296,7 @@ interface WaitStream {
276
296
  slug: string
277
297
  named: boolean
278
298
  /** The full prefix, appended in seq order — what `reduceBuild` runs over. */
279
- events: AbEvent[]
299
+ seed: BuildStreamSeed
280
300
  lastSeq: number
281
301
  status: BuildStatus
282
302
  }
@@ -357,11 +377,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
357
377
  const finish = (condition: string | null, event: AbEvent, state: BuildState): number => {
358
378
  const observation =
359
379
  condition === 'attention' && event.type !== 'escalation.raised'
360
- ? mergeDecision(
361
- streams.get(event.build)?.events.filter((e) => e.seq <= event.seq) ?? [],
362
- now(),
363
- state,
364
- )
380
+ ? mergeDecisionFromFacts(state, streams.get(event.build)!.seed.deferral, now())
365
381
  : null
366
382
  if (observation) deliveredAttention.add(mergeIdentity(event.build, observation.pr.number))
367
383
  const record = {
@@ -394,22 +410,30 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
394
410
  if (satisfied === null) satisfied = { event, state, condition }
395
411
  }
396
412
 
397
- const eventConditions = (event: AbEvent, prefix: AbEvent[]): readonly WaitCondition[] => {
413
+ const eventConditions = (event: AbEvent, seed: BuildStreamSeed): readonly WaitCondition[] => {
398
414
  if (event.type === 'escalation.raised') return conditions
399
- const observation = mergeDecision(prefix, now())
415
+ const observation = mergeDecisionFromFacts(
416
+ buildReducer.finish(seed.build),
417
+ seed.deferral,
418
+ now(),
419
+ )
400
420
  return observation &&
401
421
  !deliveredAttention.has(mergeIdentity(event.build, observation.pr.number))
402
422
  ? conditions
403
423
  : conditions.filter(({ cond }) => cond.kind !== 'attention')
404
424
  }
405
425
 
406
- const registerStream = (slug: string, events: AbEvent[], named: boolean): WaitStream => {
407
- const state = reduceBuild(events)
426
+ const registerStream = (
427
+ slug: string,
428
+ current: { value: BuildStreamSeed; cursor: number },
429
+ named: boolean,
430
+ ): WaitStream => {
431
+ const state = buildReducer.finish(current.value.build)
408
432
  const stream: WaitStream = {
409
433
  slug,
410
434
  named,
411
- events,
412
- lastSeq: events.at(-1)?.seq ?? 0,
435
+ seed: current.value,
436
+ lastSeq: current.cursor,
413
437
  status: state.status,
414
438
  }
415
439
  streams.set(slug, stream)
@@ -421,10 +445,10 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
421
445
  * already satisfying when it becomes targeted ends the wait with its
422
446
  * latest event. Not cursor-relative — `--since` does not suppress it. */
423
447
  const checkRegistrationState = (stream: WaitStream): void => {
424
- const state = reduceBuild(stream.events)
425
- const condition = matchState(conditions, state, stream.events)
426
- if (condition !== null && stream.events.length > 0) {
427
- match(stream.events.at(-1)!, state, condition)
448
+ const state = buildReducer.finish(stream.seed.build)
449
+ const condition = matchState(conditions, state, { state, deferral: stream.seed.deferral })
450
+ if (condition !== null && state.lastEvent !== undefined) {
451
+ match(state.lastEvent!, state, condition)
428
452
  }
429
453
  }
430
454
  /**
@@ -438,42 +462,43 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
438
462
  if (streams.has(slug)) return
439
463
  const record = await store.getBuild(slug)
440
464
  if (record === null) return
441
- // AUT-545: this full-log read is deliberate, accepted growth — the
442
- // retained prefix is consumed at full replay depth by every later
443
- // reduction (approvals, findings, answered escalations, PR facts,
444
- // observations), so bounding it would need reducer-partition store
445
- // machinery out of AUT-545's scope. Required for `--since` replay and
446
- // the registration state check over the full reduction.
447
- const events = await store.getEvents(slug)
448
- const stream = registerStream(slug, events, slugs.includes(slug))
465
+ const current = await readBuildStreamSeed(store, slug)
466
+ const stream = registerStream(slug, current, slugs.includes(slug))
449
467
  if (resumeSeq !== undefined) {
468
+ stream.seed = await historicalBuildState(store, slug, resumeSeq, current)
450
469
  stream.lastSeq = resumeSeq
451
470
  positions[slug] = resumeSeq
452
- // Replay the backlog into a scratch prefix: each evaluated state is
453
- // the reduction of the prefix ENDING at that event, never the
454
- // reduction of the full loaded prefix.
455
- const replay: AbEvent[] = events.filter((event) => event.seq <= resumeSeq)
456
471
  if (resumedCursor?.deliveredAttention === undefined) {
457
- const historical: AbEvent[] = []
458
- for (const event of replay) {
459
- historical.push(event)
460
- const observation = mergeDecision(historical, now())
461
- if (observation) deliveredAttention.add(mergeIdentity(slug, observation.pr.number))
462
- }
472
+ for (const pr of stream.seed.mergeAttentionPrs)
473
+ deliveredAttention.add(mergeIdentity(slug, pr))
463
474
  }
464
- for (const event of events) {
465
- if (event.seq <= resumeSeq) continue
466
- replay.push(event)
475
+ for (const event of await store.getEvents(slug, resumeSeq)) {
476
+ stream.seed = buildStreamSeedReducer.advance(stream.seed, [event])
467
477
  stream.lastSeq = event.seq
468
478
  positions[slug] = event.seq
469
- const condition = matchEvent(event, eventConditions(event, replay), globs, replay)
479
+ const state = buildReducer.finish(stream.seed.build)
480
+ const condition = matchEvent(event, eventConditions(event, stream.seed), globs, {
481
+ state,
482
+ deferral: stream.seed.deferral,
483
+ })
470
484
  if (condition !== null) {
471
- match(event, reduceBuild(replay), condition)
485
+ match(event, state, condition)
472
486
  break
473
487
  }
474
488
  }
475
489
  }
476
- if (satisfied === null) checkRegistrationState(stream)
490
+ if (satisfied === null) {
491
+ // Registration checks current state independently from historical replay.
492
+ const latest = buildReducer.finish(current.value.build)
493
+ const condition = matchState(conditions, latest, {
494
+ state: latest,
495
+ deferral: current.value.deferral,
496
+ })
497
+ if (condition !== null && latest.lastEvent) {
498
+ stream.seed = current.value
499
+ match(latest.lastEvent, latest, condition)
500
+ }
501
+ }
477
502
  }
478
503
 
479
504
  /** Builds discovery read in full and found terminal; never re-read. */
@@ -488,33 +513,20 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
488
513
  * build to appear.
489
514
  */
490
515
  const discoverBuilds = async (): Promise<void> => {
491
- // AUT-545: the terminal filter is the AUT-487 digest, not a full-log read —
492
- // one flat batch read replaces a full getEvents per candidate build, per
493
- // discovery pass; `terminal` is reduceBuild's exact terminal rule
494
- // (store/digest.ts, pinned by contract). The per-build full read below
495
- // remains deliberate for builds the digest cannot retire: its retained
496
- // prefix is consumed at full replay depth by every later reduction
497
- // (approvals, findings, answered escalations, PR facts, observations), so
498
- // bounding it would need reducer-partition store machinery out of
499
- // AUT-545's scope. The reduceBuild re-check stays: it is the
500
- // legacy-record fallback (a record whose `repo` predates origin
501
- // normalization is in `mine` via `repoOrigin` but absent from the digest
502
- // map, which keys on `record.repo`) and it keeps this filter unable to
503
- // diverge from reduceBuild.
504
- const digests = await store.getRepoBuildDigests(repo)
516
+ const digests = await readBuildDigests(store, repo)
505
517
  for (const record of (await store.listBuilds()).filter(mine)) {
506
518
  if (streams.has(record.slug) || retired.has(record.slug)) continue
507
519
  const digest = digests.get(record.slug)
508
520
  if (digest !== undefined && digest.terminal !== undefined) continue
509
- const events = await store.getEvents(record.slug)
510
- const status = reduceBuild(events).status
521
+ const current = await readBuildStreamSeed(store, record.slug)
522
+ const status = buildReducer.finish(current.value.build).status
511
523
  if (!NONTERMINAL_STATUSES.includes(status)) {
512
524
  // Terminal is permanent (reduceBuild never clears it): a legacy
513
525
  // record the digest cannot retire is read once, not every pass.
514
526
  retired.add(record.slug)
515
527
  continue
516
528
  }
517
- const stream = registerStream(record.slug, events, false)
529
+ const stream = registerStream(record.slug, current, false)
518
530
  if (satisfied === null) checkRegistrationState(stream)
519
531
  }
520
532
  }
@@ -525,18 +537,15 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
525
537
  ): Promise<void> => {
526
538
  const fresh = await store.getEvents(stream.slug, stream.lastSeq, readOpts)
527
539
  for (const event of fresh) {
528
- stream.events.push(event)
540
+ stream.seed = buildStreamSeedReducer.advance(stream.seed, [event])
529
541
  stream.lastSeq = event.seq
530
542
  positions[stream.slug] = event.seq
531
- const state = reduceBuild(stream.events)
543
+ const state = buildReducer.finish(stream.seed.build)
532
544
  stream.status = state.status
533
- const condition = matchFirst(
534
- event,
545
+ const condition = matchFirst(event, state, eventConditions(event, stream.seed), globs, {
535
546
  state,
536
- eventConditions(event, stream.events),
537
- globs,
538
- stream.events,
539
- )
547
+ deferral: stream.seed.deferral,
548
+ })
540
549
  if (condition !== null) {
541
550
  match(event, state, condition)
542
551
  return
@@ -556,21 +565,19 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
556
565
  /** The exit-2 record: among the named builds' terminal events, the one
557
566
  * with the latest `ts` (ties broken by slug order) — the event that made
558
567
  * the final named build terminal. */
559
- const pickTerminalRecord = (): { event: AbEvent; state: BuildState } => {
568
+ const pickTerminalRecord = async (): Promise<{ event: AbEvent; state: BuildState }> => {
560
569
  let best: { event: AbEvent; state: BuildState; slug: string } | null = null
561
570
  for (const slug of slugs) {
562
571
  const stream = streams.get(slug)
563
572
  if (stream === undefined) continue
564
- const terminalEvent = [...stream.events]
565
- .reverse()
566
- .find((event) => event.type === 'build.completed' || event.type === 'build.aborted')
573
+ const terminalEvent = stream.seed.terminalEvent
567
574
  if (terminalEvent === undefined) continue
568
575
  if (
569
576
  best === null ||
570
577
  terminalEvent.ts > best.event.ts ||
571
578
  (terminalEvent.ts === best.event.ts && slug < best.slug)
572
579
  ) {
573
- best = { event: terminalEvent, state: reduceBuild(stream.events), slug }
580
+ best = { event: terminalEvent, state: buildReducer.finish(stream.seed.build), slug }
574
581
  }
575
582
  }
576
583
  // A named build is terminal only through its terminal event, so
@@ -647,7 +654,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
647
654
  } else if (namedAllTerminal()) {
648
655
  // Decision 5, start form: every named build is already terminal and
649
656
  // no condition held — show how the build ended.
650
- const terminal = pickTerminalRecord()
657
+ const terminal = await pickTerminalRecord()
651
658
  return finish(null, terminal.event, terminal.state)
652
659
  }
653
660
 
@@ -664,7 +671,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
664
671
  if (aborted()) break
665
672
  await tick()
666
673
  if (!hasMatch() && namedAllTerminal()) {
667
- const terminal = pickTerminalRecord()
674
+ const terminal = await pickTerminalRecord()
668
675
  return finish(null, terminal.event, terminal.state)
669
676
  }
670
677
  }
@@ -752,7 +759,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
752
759
  // conditions live — so after the quiesce drain this decision re-reads
753
760
  // the same live predicates (`hasMatch`, `namedAllTerminal`, `aborted`,
754
761
  // the deadline) to pick the exit.
755
- const terminal = pickTerminalRecord()
762
+ const terminal = await pickTerminalRecord()
756
763
  return finish(null, terminal.event, terminal.state)
757
764
  }
758
765
  if (aborted()) {
@@ -1,3 +1,7 @@
1
+ import { readBuildDigests } from '../store/reduced-reads'
2
+ import { readBuildStreamSeed, readRepositoryAttention } from '../store/reduced-reads'
3
+ import { buildStreamSeedReducer, type BuildStreamSeed } from '../kernel/read-facts'
4
+ import { historicalBuildState, historicalRepositoryAttention } from './historical-build-state'
1
5
  import { readDispatcherLiveness } from './dispatcher-liveness'
2
6
  import { projectDispatcherLiveness } from '../kernel/dispatcher-liveness'
3
7
  import type { RepositoryRecord } from '../store/types'
@@ -44,7 +48,7 @@ import type { AbEvent } from '../events/catalog'
44
48
  import type { EventType } from '../events/payloads'
45
49
  import type { RepositoryEvent } from '../events/repository'
46
50
  import {
47
- mergeDecision,
51
+ mergeDecisionFromFacts,
48
52
  mergeIdentity,
49
53
  RepositoryAttention,
50
54
  repositoryNeedsAttention,
@@ -52,7 +56,7 @@ import {
52
56
  type PersistenceObservation,
53
57
  } from './attention'
54
58
  import type { BuildState, PrLifecycle } from '../kernel/reducer'
55
- import { reduceBuild } from '../kernel/reducer'
59
+ import { buildReducer } from '../kernel/reducer'
56
60
  import type { BuildOutcome, BuildStatus, Phase } from '../ontology'
57
61
  import type { Exec } from '../ports/workspace/git-worktree'
58
62
  import { PhaseSessionError } from '../store/phase-session'
@@ -516,7 +520,7 @@ interface BuildStream {
516
520
  slug: string
517
521
  named: boolean
518
522
  /** The full prefix, appended in seq order — what `reduceBuild` runs over. */
519
- events: AbEvent[]
523
+ seed: BuildStreamSeed
520
524
  lastSeq: number
521
525
  status: BuildStatus
522
526
  }
@@ -597,11 +601,9 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
597
601
  ? decodeCursor(opts.since, { store: storeRef, repo })
598
602
  : undefined
599
603
  const deliveredAttention = new Set(resumedCursor?.deliveredAttention ?? [])
600
- const repositoryAttention = new RepositoryAttention()
604
+ let repositoryAttention = new RepositoryAttention()
601
605
  let attentionInitialized = false
602
606
  let livenessEvents: RepositoryEvent[] = []
603
- let livenessSeq = 0
604
- let livenessInitialized = false
605
607
  const observedOwners = new Map<string, RepositoryRecord>()
606
608
  const streams = new Map<string, Stream>()
607
609
  // Every stream's position — matching or not, delivered or baselined —
@@ -674,29 +676,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
674
676
  if (stop) return
675
677
  }
676
678
  }
677
- const baselineMerge = (slug: string, events: AbEvent[]): void => {
678
- // Old cursors already consumed finalize attention. Infer every eligible
679
- // historical prefix, even if consent was subsequently armed again.
680
- const prefix: AbEvent[] = []
681
- for (const event of events) {
682
- prefix.push(event)
683
- const observation = mergeDecision(prefix, now())
684
- if (observation) deliveredAttention.add(mergeIdentity(slug, observation.pr.number))
685
- }
679
+ const baselineMerge = (slug: string, seed: BuildStreamSeed): void => {
680
+ for (const pr of seed.mergeAttentionPrs) deliveredAttention.add(mergeIdentity(slug, pr))
686
681
  }
687
682
 
688
683
  const checkLiveness = async (): Promise<void> => {
689
684
  if (!followRepository || stop) return
690
- if (!livenessInitialized) {
691
- const input = await readDispatcherLiveness(store, repo, now())
692
- livenessEvents = input.events
693
- livenessSeq = input.events.at(-1)?.seq ?? 0
694
- livenessInitialized = true
695
- }
696
685
  if ((await store.getRepo(repo)) === null) return
697
- const fresh = await store.getRepoEvents(repo, livenessSeq)
698
- livenessEvents.push(...fresh)
699
- livenessSeq = fresh.at(-1)?.seq ?? livenessSeq
686
+ livenessEvents = (await readDispatcherLiveness(store, repo, now())).events
700
687
  // Sample metadata after the journal. Recheck both immediately before
701
688
  // reporting: a failed read is infrastructure, never evidence of death.
702
689
  const record = await store.getRepo(repo)
@@ -709,9 +696,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
709
696
  Date.parse(owner.lease.expiresAt) > now().getTime()
710
697
  )
711
698
  continue
712
- const delta = await store.getRepoEvents(repo, livenessSeq)
713
- livenessEvents.push(...delta)
714
- livenessSeq = delta.at(-1)?.seq ?? livenessSeq
699
+ livenessEvents = (await readDispatcherLiveness(store, repo, now())).events
715
700
  const current = await store.getRepo(repo)
716
701
  if (current?.lease?.holder === id) {
717
702
  observedOwners.set(id, current)
@@ -758,14 +743,16 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
758
743
  * stream's just-extended array; the resume path replays the backlog
759
744
  * into a scratch prefix so a resumed record never leaks state from
760
745
  * events that landed after it. */
761
- const processBuildEvent = (stream: BuildStream, event: AbEvent, prefix: AbEvent[]): void => {
746
+ const processBuildEvent = (stream: BuildStream, event: AbEvent): void => {
762
747
  if (stop) return
763
748
  stream.lastSeq = event.seq
764
749
  positions[stream.slug] = event.seq
765
- const state = reduceBuild(prefix)
750
+ stream.seed = buildStreamSeedReducer.advance(stream.seed, [event])
751
+ const state = buildReducer.finish(stream.seed.build)
752
+ stream.status = state.status
766
753
  if (matchesBuildFilter(event.type, filters)) emit(event, state)
767
754
  if (!filters.length && !stop) {
768
- const observation = mergeDecision(prefix, now(), state)
755
+ const observation = mergeDecisionFromFacts(state, stream.seed.deferral, now())
769
756
  if (observation)
770
757
  emitObservation(
771
758
  observation,
@@ -799,44 +786,28 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
799
786
  if (streams.has(slug)) return true
800
787
  const record = await store.getBuild(slug)
801
788
  if (record === null) return false
802
- // AUT-545: this full-log read is deliberate, accepted growth — the
803
- // retained prefix is consumed at full replay depth by every later
804
- // reduction (approvals, findings, answered escalations, PR facts,
805
- // observations), so bounding it would need reducer-partition store
806
- // machinery out of AUT-545's scope. Required for `--since` replay and
807
- // the baselined stream prefix. Mirrors cli/wait.ts trackBuild.
808
- const events = await store.getEvents(slug)
789
+ const current = await readBuildStreamSeed(store, slug)
790
+ const seed =
791
+ resumeSeq === undefined
792
+ ? current.value
793
+ : await historicalBuildState(store, slug, resumeSeq, current)
809
794
  const stream: BuildStream = {
810
795
  kind: 'build',
811
796
  slug,
812
797
  named: slugs.includes(slug),
813
- events,
814
- lastSeq: events.at(-1)?.seq ?? 0,
815
- status: reduceBuild(events).status,
798
+ seed,
799
+ lastSeq: resumeSeq ?? current.cursor,
800
+ status: buildReducer.finish(current.value.build).status,
816
801
  }
817
802
  streams.set(slug, stream)
818
803
  positions[slug] = stream.lastSeq
819
- if (resumeSeq === undefined) {
820
- baselineMerge(slug, events)
821
- return true
822
- }
823
- positions[slug] = resumeSeq
824
- if (resumedCursor?.deliveredAttention === undefined)
825
- baselineMerge(
826
- slug,
827
- events.filter((event) => event.seq <= resumeSeq),
828
- )
829
- stream.lastSeq = resumeSeq
830
- // Replay the backlog into a scratch prefix: each record's state is
831
- // the reduction of the prefix ENDING at that event, never the
832
- // reduction of the full loaded prefix, which would leak state from
833
- // events that landed after the one being recorded.
834
- const replay: AbEvent[] = events.filter((event) => event.seq <= resumeSeq)
835
- for (const event of events) {
836
- if (event.seq <= resumeSeq) continue
837
- replay.push(event)
838
- processBuildEvent(stream, event, replay)
839
- if (stop) break
804
+ if (resumeSeq === undefined || resumedCursor?.deliveredAttention === undefined)
805
+ baselineMerge(slug, seed)
806
+ if (resumeSeq !== undefined) {
807
+ for (const event of await store.getEvents(slug, resumeSeq)) {
808
+ processBuildEvent(stream, event)
809
+ if (stop) break
810
+ }
840
811
  }
841
812
  return true
842
813
  }
@@ -856,26 +827,13 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
856
827
  * the throw stay tracked, so the retry neither duplicates nor skips.
857
828
  */
858
829
  const discoverBuilds = async (): Promise<void> => {
859
- // AUT-545: the terminal filter is the AUT-487 digest, not a full-log read —
860
- // one flat batch read replaces a full getEvents per candidate build, per
861
- // discovery pass; `terminal` is reduceBuild's exact terminal rule
862
- // (store/digest's reduceBuildDigest, pinned by contract). The per-build
863
- // full read below remains deliberate for builds the digest cannot retire:
864
- // its retained prefix is consumed at full replay depth by every later
865
- // reduction (approvals, findings, answered escalations, PR facts,
866
- // observations), so bounding it would need reducer-partition store
867
- // machinery out of AUT-545's scope. The reduceBuild re-check stays: it is
868
- // the legacy-record fallback (a record whose `repo` predates origin
869
- // normalization is in `mine` via `repoOrigin` but absent from the digest
870
- // map, which keys on `record.repo`) and it keeps this filter unable to
871
- // diverge from reduceBuild. Mirrors cli/wait.ts discoverBuilds.
872
- const digests = await store.getRepoBuildDigests(repo)
830
+ const digests = await readBuildDigests(store, repo)
873
831
  for (const record of (await store.listBuilds()).filter(mine)) {
874
832
  if (streams.has(record.slug) || retired.has(record.slug)) continue
875
833
  const digest = digests.get(record.slug)
876
834
  if (digest !== undefined && digest.terminal !== undefined) continue
877
- const events = await store.getEvents(record.slug)
878
- const status = reduceBuild(events).status
835
+ const current = await readBuildStreamSeed(store, record.slug)
836
+ const status = buildReducer.finish(current.value.build).status
879
837
  if (!NONTERMINAL_STATUSES.includes(status)) {
880
838
  // Terminal is permanent (reduceBuild never clears it): a legacy
881
839
  // record the digest cannot retire is read once, not every pass.
@@ -886,12 +844,12 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
886
844
  kind: 'build',
887
845
  slug: record.slug,
888
846
  named: false,
889
- events,
890
- lastSeq: events.at(-1)?.seq ?? 0,
847
+ seed: current.value,
848
+ lastSeq: current.cursor,
891
849
  status,
892
850
  }
893
851
  streams.set(record.slug, stream)
894
- baselineMerge(record.slug, events)
852
+ baselineMerge(record.slug, current.value)
895
853
  positions[record.slug] = stream.lastSeq
896
854
  }
897
855
  }
@@ -903,18 +861,21 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
903
861
  positions[REPO_STREAM_KEY] = stream.lastSeq
904
862
  try {
905
863
  if ((await store.getRepo(repo)) !== null) {
906
- // Full bootstrap is intentional: degradation can predate run anchors.
907
- const history = await store.getRepoEvents(repo)
908
- const baseline = resumeSeq ?? history.at(-1)?.seq ?? 0
909
- for (const event of history) {
910
- if (event.seq <= baseline) repositoryAttention.fold(event)
911
- }
864
+ const current = await readRepositoryAttention(store, repo)
865
+ const baseline = resumeSeq ?? current.cursor
866
+ const seed =
867
+ resumeSeq === undefined
868
+ ? current.value
869
+ : await historicalRepositoryAttention(store, repo, resumeSeq, current)
870
+ repositoryAttention = new RepositoryAttention(seed)
912
871
  attentionInitialized = true
913
872
  stream.lastSeq = baseline
914
873
  positions[REPO_STREAM_KEY] = baseline
915
- for (const event of history) {
916
- if (event.seq <= baseline || stop) continue
917
- processRepositoryEvent(stream, event)
874
+ if (resumeSeq !== undefined) {
875
+ for (const event of await store.getRepoEvents(repo, baseline)) {
876
+ if (stop) break
877
+ processRepositoryEvent(stream, event)
878
+ }
918
879
  }
919
880
  if (!stop) checkPersistence()
920
881
  } else attentionInitialized = true
@@ -931,12 +892,11 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
931
892
  const fresh = await store.getEvents(stream.slug, stream.lastSeq, readOpts)
932
893
  for (const event of fresh) {
933
894
  if (stop) break
934
- stream.events.push(event)
935
- processBuildEvent(stream, event, stream.events)
895
+ processBuildEvent(stream, event)
936
896
  if (stop) break
937
897
  }
938
898
  if (fresh.length > 0 && !stop) {
939
- stream.status = reduceBuild(stream.events).status
899
+ stream.status = buildReducer.finish(stream.seed.build).status
940
900
  }
941
901
  return true
942
902
  }
@@ -951,11 +911,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
951
911
  if ((await store.getRepo(repo)) === null) return true
952
912
  if (!attentionInitialized) {
953
913
  repositoryInitialization ??= (async () => {
954
- const history = await store.getRepoEvents(repo)
955
- const baseline = sinceStreams[REPO_STREAM_KEY] ?? history.at(-1)?.seq ?? 0
956
- for (const event of history) {
957
- if (event.seq <= baseline) repositoryAttention.fold(event)
958
- }
914
+ const current = await readRepositoryAttention(store, repo)
915
+ const resumeSeq = sinceStreams[REPO_STREAM_KEY]
916
+ const baseline = resumeSeq ?? current.cursor
917
+ const seed =
918
+ resumeSeq === undefined
919
+ ? current.value
920
+ : await historicalRepositoryAttention(store, repo, resumeSeq, current)
921
+ repositoryAttention = new RepositoryAttention(seed)
959
922
  stream.lastSeq = baseline
960
923
  positions[REPO_STREAM_KEY] = baseline
961
924
  attentionInitialized = true
@@ -55,7 +55,8 @@ export interface PipelineSource {
55
55
  export interface PipelineSourceInput {
56
56
  slug: string
57
57
  record: Pick<BuildRecord, 'branch'>
58
- events: readonly AbEvent[]
58
+ events?: readonly AbEvent[]
59
+ recordedBase?: string
59
60
  /** 'origin' reads through the forge; 'checkout' reads through git exec in
60
61
  * the dispatcher's checkout (build branches are refs of the shared repo). */
61
62
  mode: 'origin' | 'checkout'
@@ -250,7 +251,7 @@ export async function resolvePipelineSource(
250
251
  }
251
252
  }
252
253
  }
253
- const baseSha = recordedBaseSha(events)
254
+ const baseSha = input.recordedBase ?? recordedBaseSha(events ?? [])
254
255
  if (baseSha === undefined) {
255
256
  onFailure({ kind: 'no-base', detail: 'no recorded base commit (legacy build)' })
256
257
  return undefined
@@ -368,7 +369,7 @@ export async function resolvePipelineSource(
368
369
  return parsed
369
370
  }
370
371
  }
371
- const baseSha = recordedBaseSha(events)
372
+ const baseSha = input.recordedBase ?? recordedBaseSha(events ?? [])
372
373
  if (baseSha === undefined) {
373
374
  onFailure({ kind: 'no-base', detail: 'no recorded base commit (legacy build)' })
374
375
  return undefined