@defrex/autobuild 0.10.1 → 0.12.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 (120) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +237 -23
  3. package/bin/ab-dispatch-kernel.ts +1 -1
  4. package/docs/architecture.md +66 -4
  5. package/docs/configuration.md +137 -19
  6. package/docs/hosted-dispatcher.md +17 -2
  7. package/docs/hosted-service.md +214 -0
  8. package/docs/mcp.md +8 -2
  9. package/docs/operator-api.md +107 -7
  10. package/docs/remote-store-protocol.md +189 -144
  11. package/docs/remote-ticket-protocol.md +111 -18
  12. package/docs/setup.md +173 -16
  13. package/docs/ticket-dependencies.md +1 -1
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/binary.ts +3 -3
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +261 -0
  19. package/packages/core/src/cli/dashboard/model.ts +8 -5
  20. package/packages/core/src/cli/dashboard/render.ts +37 -7
  21. package/packages/core/src/cli/dispatch-frontend.ts +16 -1
  22. package/packages/core/src/cli/dispatch-process.ts +25 -7
  23. package/packages/core/src/cli/dispatch.ts +247 -47
  24. package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
  25. package/packages/core/src/cli/help.ts +45 -13
  26. package/packages/core/src/cli/init-validation.ts +27 -2
  27. package/packages/core/src/cli/main.ts +41 -15
  28. package/packages/core/src/cli/mcp.ts +7 -1
  29. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  30. package/packages/core/src/cli/pr-summary.ts +2 -0
  31. package/packages/core/src/cli/project-credentials.ts +132 -0
  32. package/packages/core/src/cli/repo-state.ts +15 -2
  33. package/packages/core/src/cli/repository-status.ts +45 -5
  34. package/packages/core/src/cli/self-update.ts +10 -0
  35. package/packages/core/src/cli/store-opening.ts +22 -14
  36. package/packages/core/src/cli/terminals.ts +0 -10
  37. package/packages/core/src/cli/ticket.ts +8 -1
  38. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  39. package/packages/core/src/cli/wait.ts +84 -17
  40. package/packages/core/src/cli/watch.ts +350 -68
  41. package/packages/core/src/config/live.ts +4 -0
  42. package/packages/core/src/config/schema.ts +65 -11
  43. package/packages/core/src/connection.ts +86 -0
  44. package/packages/core/src/events/catalog.ts +10 -2
  45. package/packages/core/src/events/globs.ts +8 -3
  46. package/packages/core/src/events/payloads.ts +44 -0
  47. package/packages/core/src/events/repository.ts +64 -2
  48. package/packages/core/src/integration/harness.ts +2 -1
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +148 -83
  50. package/packages/core/src/kernel/dispatch-status.ts +9 -1
  51. package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
  52. package/packages/core/src/kernel/engine.ts +24 -0
  53. package/packages/core/src/kernel/generators/build-log.ts +10 -0
  54. package/packages/core/src/kernel/log-index.ts +17 -0
  55. package/packages/core/src/kernel/pr-summary.ts +89 -0
  56. package/packages/core/src/kernel/reducer-registry.ts +12 -0
  57. package/packages/core/src/kernel/reducer.ts +8 -0
  58. package/packages/core/src/kernel/ticket-source-health.ts +30 -0
  59. package/packages/core/src/ontology.ts +1 -0
  60. package/packages/core/src/operator/index.ts +7 -0
  61. package/packages/core/src/operator/query.ts +44 -19
  62. package/packages/core/src/operator/ticket-source-backend.ts +1 -0
  63. package/packages/core/src/operator/tickets.ts +6 -8
  64. package/packages/core/src/plugin-sdk/index.ts +1 -0
  65. package/packages/core/src/plugins/manifest.ts +5 -3
  66. package/packages/core/src/plugins/runtimes.ts +3 -1
  67. package/packages/core/src/ports/forge/fake.ts +13 -1
  68. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  69. package/packages/core/src/ports/forge/github.ts +29 -5
  70. package/packages/core/src/ports/forge/local-git.ts +12 -4
  71. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  72. package/packages/core/src/ports/runner/session-env.ts +1 -0
  73. package/packages/core/src/ports/tickets/contract.ts +25 -0
  74. package/packages/core/src/ports/tickets/create.ts +11 -3
  75. package/packages/core/src/ports/tickets/fake.ts +10 -8
  76. package/packages/core/src/ports/tickets/file.ts +1 -0
  77. package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
  78. package/packages/core/src/ports/tickets/linear.ts +241 -49
  79. package/packages/core/src/ports/tickets/remote-protocol.ts +3 -0
  80. package/packages/core/src/ports/tickets/remote.ts +1 -0
  81. package/packages/core/src/ports/types.ts +17 -0
  82. package/packages/core/src/ports/workspace/build-execution.ts +5 -1
  83. package/packages/core/src/ports/workspace/create.ts +11 -2
  84. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  85. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  86. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  87. package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
  88. package/packages/core/src/processes/build-execution-state.ts +1 -1
  89. package/packages/core/src/processes/build-runner.ts +87 -26
  90. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  91. package/packages/core/src/processes/dispatcher.ts +336 -83
  92. package/packages/core/src/processes/harvest-child.ts +1 -0
  93. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  94. package/packages/core/src/processes/harvest-runner.ts +142 -100
  95. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  96. package/packages/core/src/processes/publication-loss.ts +18 -1
  97. package/packages/core/src/processes/repo-view.ts +2 -1
  98. package/packages/core/src/store/adapter.ts +1 -0
  99. package/packages/core/src/store/contract.ts +109 -0
  100. package/packages/core/src/store/digest.ts +14 -2
  101. package/packages/core/src/store/local/store.ts +18 -5
  102. package/packages/core/src/store/remote/client.ts +1 -1
  103. package/packages/core/src/store/remote/compatibility.ts +90 -0
  104. package/packages/core/src/store/remote/credentials.ts +28 -0
  105. package/packages/core/src/store/remote/index.ts +14 -1
  106. package/packages/core/src/store/remote/protocol.ts +26 -0
  107. package/packages/core/src/store/remote/token.ts +74 -13
  108. package/packages/core/src/store/repo-state-events.ts +49 -59
  109. package/packages/core/src/store/types.ts +5 -1
  110. package/packages/core/src/testing/index.ts +7 -0
  111. package/skills/guide/SKILL.md +175 -26
  112. package/skills/guide/references/operator-api.md +135 -13
  113. package/skills/guide/references/plugin-authoring.md +10 -2
  114. package/skills/guide/references/remote-store-protocol.md +189 -144
  115. package/skills/guide/references/remote-ticket-protocol.md +111 -18
  116. package/skills/guide/references/setup.md +173 -16
  117. package/skills/guide/references/ticket-dependencies.md +1 -1
  118. package/skills/operate/SKILL.md +159 -34
  119. package/templates/hosted-service/package.json +13 -0
  120. package/templates/hosted-service/vercel.json +7 -0
@@ -1,8 +1,11 @@
1
+ import { readDispatcherLiveness } from './dispatcher-liveness'
2
+ import { projectDispatcherLiveness } from '../kernel/dispatcher-liveness'
3
+ import type { RepositoryRecord } from '../store/types'
1
4
  /**
2
5
  * `ab watch` — the sessionless, read-only streaming half of the status family
3
6
  * (§8.2, §7.2). It follows the event logs of one, several, or all active
4
- * builds in the repository — optionally the repository journal — and emits one
5
- * record per matching event as it lands, each carrying the full durable
7
+ * builds in the repository, plus the repository journal by default with no
8
+ * slugs, and emits one record per matching event as it lands, carrying the durable
6
9
  * envelope, the build's reduced state after that event, and an opaque cursor
7
10
  * so a later `--since` invocation resumes exactly where this one stopped.
8
11
  *
@@ -39,8 +42,15 @@
39
42
  */
40
43
  import type { AbEvent } from '../events/catalog'
41
44
  import type { EventType } from '../events/payloads'
42
- import type { RepositoryEvent, RepositoryEventType } from '../events/repository'
43
- import { REPOSITORY_ATTENTION_EVENTS } from '../events/repository'
45
+ import type { RepositoryEvent } from '../events/repository'
46
+ import {
47
+ mergeDecision,
48
+ mergeIdentity,
49
+ RepositoryAttention,
50
+ repositoryNeedsAttention,
51
+ type MergeDecision,
52
+ type PersistenceObservation,
53
+ } from './attention'
44
54
  import type { BuildState, PrLifecycle } from '../kernel/reducer'
45
55
  import { reduceBuild } from '../kernel/reducer'
46
56
  import type { BuildOutcome, BuildStatus, Phase } from '../ontology'
@@ -81,20 +91,7 @@ const TERMINAL_STATUSES: readonly BuildStatus[] = ['done', 'aborted']
81
91
  * a watch emits exactly these — deciding what to do about a blocker or a
82
92
  * merge stays with the caller's skill.
83
93
  */
84
- export const BUILD_ATTENTION_EVENTS = [
85
- 'escalation.raised',
86
- 'phase.failed',
87
- 'infrastructure.failed',
88
- 'runner.setup-failed',
89
- 'dispatch.failed',
90
- 'publication.lost',
91
- 'finalize.completed',
92
- 'pr.conflicted',
93
- 'pr.merged',
94
- 'pr.closed',
95
- 'build.completed',
96
- 'build.aborted',
97
- ] as const satisfies readonly EventType[]
94
+ export const BUILD_ATTENTION_EVENTS = ['escalation.raised'] as const satisfies readonly EventType[]
98
95
 
99
96
  // ── Glob compilation and matching ────────────────────────────────────────────
100
97
 
@@ -102,8 +99,8 @@ export const BUILD_ATTENTION_EVENTS = [
102
99
  * Compile `--event` globs to anchored matchers: `*` matches any run of
103
100
  * characters, `?` exactly one, everything else verbatim (so the dots inside
104
101
  * event type names are escaped). Each glob must match at least one type in
105
- * the build catalog — or, when `--repository` is given, in the repository
106
- * catalog — otherwise a usage error naming the glob is thrown before any
102
+ * the build catalog, or the repository catalog with no slugs or explicit
103
+ * `--repository` — otherwise a usage error naming the glob is thrown before any
107
104
  * store read. Supplying any glob replaces the attention set entirely.
108
105
  *
109
106
  * The compiler itself lives in `events/globs.ts`, shared with the
@@ -118,12 +115,6 @@ function matchesBuildFilter(type: EventType, filters: readonly RegExp[]): boolea
118
115
  : (BUILD_ATTENTION_EVENTS as readonly string[]).includes(type)
119
116
  }
120
117
 
121
- function matchesRepositoryFilter(type: RepositoryEventType, filters: readonly RegExp[]): boolean {
122
- return filters.length > 0
123
- ? filters.some((regex) => regex.test(type))
124
- : (REPOSITORY_ATTENTION_EVENTS as readonly string[]).includes(type)
125
- }
126
-
127
118
  // ── The cursor ───────────────────────────────────────────────────────────────
128
119
 
129
120
  interface CursorPayload {
@@ -133,9 +124,29 @@ interface CursorPayload {
133
124
  /** The repository identity (normalized origin or checkout path). */
134
125
  repo: string
135
126
  /** Per-stream watch position: build slug → seq, `#repo` → seq. */
127
+ deliveredAttention?: string[]
128
+ lostRuns?: string[]
136
129
  streams: Record<string, number>
137
130
  }
138
131
 
132
+ function validAttentionIdentity(id: unknown): boolean {
133
+ if (typeof id !== 'string') return false
134
+ if (/^(source|tick):[1-9][0-9]*$/.test(id)) return true
135
+ try {
136
+ const value: unknown = JSON.parse(id)
137
+ return (
138
+ Array.isArray(value) &&
139
+ value.length === 2 &&
140
+ typeof value[0] === 'string' &&
141
+ value[0].length > 0 &&
142
+ Number.isSafeInteger(value[1]) &&
143
+ value[1] > 0
144
+ )
145
+ } catch {
146
+ return false
147
+ }
148
+ }
149
+
139
150
  const CURSOR_PREFIX = 'v1.'
140
151
 
141
152
  /** Opaque to callers; the format is internal, not contract. */
@@ -169,7 +180,22 @@ export function decodeCursor(
169
180
  throw malformed()
170
181
  }
171
182
  if (typeof payload !== 'object' || payload === null) throw malformed()
172
- const { v, store, repo, streams } = payload as Record<string, unknown>
183
+ const { v, store, repo, streams, lostRuns, deliveredAttention } = payload as Record<
184
+ string,
185
+ unknown
186
+ >
187
+ if (
188
+ lostRuns !== undefined &&
189
+ (!Array.isArray(lostRuns) ||
190
+ lostRuns.some((run) => typeof run !== 'string' || run.length === 0))
191
+ )
192
+ throw malformed()
193
+ if (
194
+ deliveredAttention !== undefined &&
195
+ (!Array.isArray(deliveredAttention) ||
196
+ deliveredAttention.some((id) => !validAttentionIdentity(id)))
197
+ )
198
+ throw malformed()
173
199
  if (v !== 1 || typeof store !== 'string' || typeof repo !== 'string') throw malformed()
174
200
  if (typeof streams !== 'object' || streams === null || Array.isArray(streams)) throw malformed()
175
201
  for (const seq of Object.values(streams)) {
@@ -187,7 +213,16 @@ export function decodeCursor(
187
213
  `repository "${expected.repo}" — run from the repository the cursor came from`,
188
214
  )
189
215
  }
190
- return { v: 1, store, repo, streams: streams as Record<string, number> }
216
+ return {
217
+ v: 1,
218
+ store,
219
+ repo,
220
+ streams: streams as Record<string, number>,
221
+ ...(deliveredAttention === undefined
222
+ ? {}
223
+ : { deliveredAttention: deliveredAttention as string[] }),
224
+ ...(lostRuns === undefined ? {} : { lostRuns: lostRuns as string[] }),
225
+ }
191
226
  }
192
227
 
193
228
  // ── Durations and cadence ────────────────────────────────────────────────────
@@ -237,6 +272,55 @@ export interface WatchRecordState {
237
272
  outcome: BuildOutcome | null
238
273
  }
239
274
 
275
+ /** Lease-expiry evidence is an observation, never a fabricated journal event. */
276
+ export interface WatchObservationRecord {
277
+ repo: string
278
+ build: null
279
+ state: null
280
+ observation: {
281
+ type: 'dispatcher.lost'
282
+ run: string
283
+ observedAt: string
284
+ leaseExpiresAt: string
285
+ pid?: number | null
286
+ host?: string | null
287
+ startedAt?: string | null
288
+ heartbeatAt?: string | null
289
+ heartbeatAgeMs?: number | null
290
+ }
291
+ cursor: string
292
+ }
293
+
294
+ export interface AttentionObservationRecord {
295
+ repo?: string
296
+ build: string | null
297
+ state: WatchRecordState | null
298
+ observation: MergeDecision | PersistenceObservation
299
+ cursor: string
300
+ }
301
+
302
+ export function projectAttentionRecord(
303
+ observation: MergeDecision | PersistenceObservation,
304
+ state: BuildState | null,
305
+ cursor: string,
306
+ scope: { build: string } | { repo: string },
307
+ ): AttentionObservationRecord {
308
+ return {
309
+ ...scope,
310
+ build: 'build' in scope ? scope.build : null,
311
+ state: state ? projectState(state) : null,
312
+ observation,
313
+ cursor,
314
+ }
315
+ }
316
+
317
+ export function renderAttentionLine(
318
+ observation: MergeDecision | PersistenceObservation,
319
+ label: string,
320
+ ): string {
321
+ return `${observation.observedAt} ${singleLine(label)} ${observation.type} ${singleLine(JSON.stringify(observation))}`
322
+ }
323
+
240
324
  export interface WatchRecord {
241
325
  /** The build slug, or null for a repository-journal event. */
242
326
  build: string | null
@@ -340,6 +424,10 @@ export function summarizeEvent(event: AbEvent | RepositoryEvent, state: BuildSta
340
424
  return `harvest run ${event.payload.run} escalated: ${singleLine(event.payload.reason)}`
341
425
  case 'harvest.failed':
342
426
  return `harvest run ${event.payload.run} failed at ${event.payload.step} (attempt ${event.payload.attempt}): ${singleLine(event.payload.error)}`
427
+ case 'dispatcher.ticket-source-degraded':
428
+ return `ticket source ${event.payload.source} degraded: ${event.payload.cause} (${event.payload.operation}) — ${singleLine(event.payload.message)}${'resetAt' in event.payload ? `; reset ${event.payload.resetAt}` : ''}`
429
+ case 'dispatcher.ticket-source-recovered':
430
+ return `ticket source ${event.payload.source} recovered: ${event.payload.cause} (${event.payload.operation})`
343
431
  case 'dispatcher.tick-failed':
344
432
  return `dispatcher tick failed: ${singleLine(event.payload.error)}`
345
433
  case 'dispatcher.config-rejected':
@@ -451,6 +539,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
451
539
  const sleep = opts.delay ?? defaultDelay
452
540
  const slugs = [...(opts.slugs ?? [])]
453
541
  const repository = opts.repository === true
542
+ const followRepository = repository || slugs.length === 0
454
543
 
455
544
  const timeoutMs =
456
545
  opts.timeout !== undefined
@@ -462,10 +551,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
462
551
  : undefined
463
552
 
464
553
  // Ambient scope, before any store access.
465
- assertReadScope(slugs, { repository, env: opts.env })
554
+ assertReadScope(slugs, { repository: followRepository, env: opts.env })
466
555
 
467
556
  // Glob validation needs only the catalogs — before any store access.
468
- const filters = compileEventGlobs(opts.events ?? [], { repository, usage: WATCH_USAGE })
557
+ const filters = compileEventGlobs(opts.events ?? [], {
558
+ repository: followRepository,
559
+ additionalCatalog: followRepository ? ['dispatcher.lost'] : [],
560
+ usage: WATCH_USAGE,
561
+ })
469
562
 
470
563
  await withAmbientReadStore(
471
564
  {
@@ -497,6 +590,19 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
497
590
  record.repo === identity ||
498
591
  (record.repoOrigin !== undefined && normalizeGitRemoteUrl(record.repoOrigin) === identity)
499
592
 
593
+ const lostRuns = new Set(
594
+ opts.since ? (decodeCursor(opts.since, { store: storeRef, repo }).lostRuns ?? []) : [],
595
+ )
596
+ const resumedCursor = opts.since
597
+ ? decodeCursor(opts.since, { store: storeRef, repo })
598
+ : undefined
599
+ const deliveredAttention = new Set(resumedCursor?.deliveredAttention ?? [])
600
+ const repositoryAttention = new RepositoryAttention()
601
+ let attentionInitialized = false
602
+ let livenessEvents: RepositoryEvent[] = []
603
+ let livenessSeq = 0
604
+ let livenessInitialized = false
605
+ const observedOwners = new Map<string, RepositoryRecord>()
500
606
  const streams = new Map<string, Stream>()
501
607
  // Every stream's position — matching or not, delivered or baselined —
502
608
  // lives here, so a resume never rescans or redelivers a filtered event.
@@ -514,7 +620,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
514
620
  }
515
621
 
516
622
  const encodeCurrent = (): string =>
517
- encodeCursor({ v: 1, store: storeRef, repo, streams: { ...positions } })
623
+ encodeCursor({
624
+ v: 1,
625
+ store: storeRef,
626
+ repo,
627
+ streams: { ...positions },
628
+ lostRuns: [...lostRuns],
629
+ deliveredAttention: [...deliveredAttention],
630
+ })
518
631
 
519
632
  /** One record, flushed the moment it is projected. `cursor` is the watch
520
633
  * position AFTER this event. */
@@ -534,6 +647,111 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
534
647
  }
535
648
  }
536
649
 
650
+ const emitObservation = (
651
+ observation: MergeDecision | PersistenceObservation,
652
+ state: BuildState | null,
653
+ scope: { build: string } | { repo: string },
654
+ identity: string,
655
+ ): void => {
656
+ if (stop || deliveredAttention.has(identity)) return
657
+ deliveredAttention.add(identity)
658
+ const cursor = encodeCurrent()
659
+ opts.stdout(
660
+ opts.json
661
+ ? JSON.stringify(projectAttentionRecord(observation, state, cursor, scope))
662
+ : renderAttentionLine(observation, 'build' in scope ? scope.build : '(repository)'),
663
+ )
664
+ emitted += 1
665
+ if (opts.count !== undefined && emitted >= opts.count) {
666
+ countCursor = cursor
667
+ requestStop()
668
+ }
669
+ }
670
+ const checkPersistence = (): void => {
671
+ if (filters.length || !attentionInitialized || stop) return
672
+ for (const { identity, observation } of repositoryAttention.pending(now())) {
673
+ emitObservation(observation, null, { repo }, identity)
674
+ if (stop) return
675
+ }
676
+ }
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
+ }
686
+ }
687
+
688
+ const checkLiveness = async (): Promise<void> => {
689
+ 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
+ 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
700
+ // Sample metadata after the journal. Recheck both immediately before
701
+ // reporting: a failed read is infrastructure, never evidence of death.
702
+ const record = await store.getRepo(repo)
703
+ if (record?.lease) observedOwners.set(record.lease.holder, record)
704
+ for (const [id, owner] of observedOwners) {
705
+ if (
706
+ stop ||
707
+ lostRuns.has(id) ||
708
+ !owner.lease ||
709
+ Date.parse(owner.lease.expiresAt) > now().getTime()
710
+ )
711
+ continue
712
+ const delta = await store.getRepoEvents(repo, livenessSeq)
713
+ livenessEvents.push(...delta)
714
+ livenessSeq = delta.at(-1)?.seq ?? livenessSeq
715
+ const current = await store.getRepo(repo)
716
+ if (current?.lease?.holder === id) {
717
+ observedOwners.set(id, current)
718
+ if (Date.parse(current.lease.expiresAt) > now().getTime()) continue
719
+ }
720
+ const evidence = projectDispatcherLiveness(
721
+ current?.lease?.holder === id ? current : owner,
722
+ livenessEvents,
723
+ now(),
724
+ )
725
+ if (
726
+ livenessEvents.some((e) => e.type === 'dispatcher.run-stopped' && e.payload.run === id)
727
+ ) {
728
+ observedOwners.delete(id)
729
+ continue
730
+ }
731
+ if (!(filters.length ? filters.some((f) => f.test('dispatcher.lost')) : true)) continue
732
+ if (stop) return
733
+ lostRuns.add(id)
734
+ const cursor = encodeCurrent()
735
+ const observation = {
736
+ ...evidence.expiredHolder,
737
+ type: 'dispatcher.lost',
738
+ run: id,
739
+ observedAt: now().toISOString(),
740
+ leaseExpiresAt: (current?.lease?.holder === id ? current : owner).lease!.expiresAt,
741
+ }
742
+ opts.stdout(
743
+ opts.json
744
+ ? JSON.stringify({ repo, build: null, state: null, observation, cursor })
745
+ : `dispatcher.lost ${id} — lease expired ${observation.leaseExpiresAt}`,
746
+ )
747
+ emitted += 1
748
+ if (opts.count !== undefined && emitted >= opts.count) {
749
+ countCursor = cursor
750
+ requestStop()
751
+ }
752
+ }
753
+ }
754
+
537
755
  /** Record one event. `prefix` is the event prefix ENDING at `event` —
538
756
  * the record's state is the reduction after THIS event, never a
539
757
  * reduction over events that landed after it. The poll path passes the
@@ -541,17 +759,34 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
541
759
  * into a scratch prefix so a resumed record never leaks state from
542
760
  * events that landed after it. */
543
761
  const processBuildEvent = (stream: BuildStream, event: AbEvent, prefix: AbEvent[]): void => {
762
+ if (stop) return
544
763
  stream.lastSeq = event.seq
545
764
  positions[stream.slug] = event.seq
546
- if (!matchesBuildFilter(event.type, filters)) return
547
- emit(event, reduceBuild(prefix))
765
+ const state = reduceBuild(prefix)
766
+ if (matchesBuildFilter(event.type, filters)) emit(event, state)
767
+ if (!filters.length && !stop) {
768
+ const observation = mergeDecision(prefix, now(), state)
769
+ if (observation)
770
+ emitObservation(
771
+ observation,
772
+ state,
773
+ { build: stream.slug },
774
+ mergeIdentity(stream.slug, observation.pr.number),
775
+ )
776
+ }
548
777
  }
549
778
 
550
779
  const processRepositoryEvent = (stream: RepositoryStream, event: RepositoryEvent): void => {
780
+ if (stop) return
551
781
  stream.lastSeq = event.seq
552
782
  positions[REPO_STREAM_KEY] = event.seq
553
- if (!matchesRepositoryFilter(event.type, filters)) return
554
- emit(event, null)
783
+ repositoryAttention.fold(event)
784
+ if (
785
+ filters.length
786
+ ? filters.some((regex) => regex.test(event.type))
787
+ : repositoryNeedsAttention(event)
788
+ )
789
+ emit(event, null)
555
790
  }
556
791
 
557
792
  /**
@@ -581,7 +816,16 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
581
816
  }
582
817
  streams.set(slug, stream)
583
818
  positions[slug] = stream.lastSeq
584
- if (resumeSeq === undefined) return true
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
+ )
585
829
  stream.lastSeq = resumeSeq
586
830
  // Replay the backlog into a scratch prefix: each record's state is
587
831
  // the reduction of the prefix ENDING at that event, never the
@@ -647,6 +891,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
647
891
  status,
648
892
  }
649
893
  streams.set(record.slug, stream)
894
+ baselineMerge(record.slug, events)
650
895
  positions[record.slug] = stream.lastSeq
651
896
  }
652
897
  }
@@ -657,30 +902,22 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
657
902
  streams.set(REPO_STREAM_KEY, stream)
658
903
  positions[REPO_STREAM_KEY] = stream.lastSeq
659
904
  try {
660
- // A fresh store has no repository row; do not create one.
661
- if (resumeSeq === undefined && (await store.getRepo(repo)) !== null) {
662
- // Bounded baseline (AUT-534): the baseline needs only the
663
- // journal's end position, and AUT-489's bounded read answers that
664
- // whenever the journal contains a `dispatcher.run-started` — the
665
- // tail from the latest anchor runs to the journal end, so the
666
- // subset's max seq equals the journal's max seq.
667
- const subset = await store.getRepoStateEvents(repo)
668
- let last = subset.at(-1)?.seq ?? 0
669
- if (!subset.some((event) => event.type === 'dispatcher.run-started')) {
670
- // Anchor-less journal: the subset may have dropped run-scoped
671
- // facts appended before any anchor, so one bounded delta read
672
- // recovers the true end; its events are baselined silently,
673
- // exactly as the full read baselines them. The skip condition
674
- // is exact: a run-started is its own anchor or lies in a later
675
- // anchor's tail, so "no run-started in the subset" means "no
676
- // run-started in the journal" — the only case where the subset
677
- // can trail.
678
- const tail = await store.getRepoEvents(repo, last)
679
- last = tail.at(-1)?.seq ?? last
905
+ 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)
680
911
  }
681
- stream.lastSeq = last
682
- positions[REPO_STREAM_KEY] = last
683
- }
912
+ attentionInitialized = true
913
+ stream.lastSeq = baseline
914
+ positions[REPO_STREAM_KEY] = baseline
915
+ for (const event of history) {
916
+ if (event.seq <= baseline || stop) continue
917
+ processRepositoryEvent(stream, event)
918
+ }
919
+ if (!stop) checkPersistence()
920
+ } else attentionInitialized = true
684
921
  return true
685
922
  } catch {
686
923
  return false
@@ -693,6 +930,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
693
930
  ): Promise<boolean> => {
694
931
  const fresh = await store.getEvents(stream.slug, stream.lastSeq, readOpts)
695
932
  for (const event of fresh) {
933
+ if (stop) break
696
934
  stream.events.push(event)
697
935
  processBuildEvent(stream, event, stream.events)
698
936
  if (stop) break
@@ -703,17 +941,43 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
703
941
  return true
704
942
  }
705
943
 
944
+ let repositoryQueue = Promise.resolve()
945
+ let repositoryInitialization: Promise<void> | undefined
706
946
  const pollRepository = async (
707
947
  stream: RepositoryStream,
708
948
  readOpts?: RemotePollReadOpts,
709
949
  ): Promise<boolean> => {
710
950
  // A repository row may be created mid-watch by the first dispatch.
711
951
  if ((await store.getRepo(repo)) === null) return true
712
- const fresh = await store.getRepoEvents(repo, stream.lastSeq, readOpts)
713
- for (const event of fresh) {
714
- processRepositoryEvent(stream, event)
715
- if (stop) break
952
+ if (!attentionInitialized) {
953
+ 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
+ }
959
+ stream.lastSeq = baseline
960
+ positions[REPO_STREAM_KEY] = baseline
961
+ attentionInitialized = true
962
+ })()
963
+ try {
964
+ await repositoryInitialization
965
+ } catch (error) {
966
+ repositoryInitialization = undefined
967
+ throw error
968
+ }
716
969
  }
970
+ const fresh = await store.getRepoEvents(repo, stream.lastSeq, readOpts)
971
+ const apply = repositoryQueue.then(() => {
972
+ for (const event of fresh) {
973
+ if (stop) break
974
+ if (event.seq <= stream.lastSeq) continue
975
+ processRepositoryEvent(stream, event)
976
+ }
977
+ if (!stop) checkPersistence()
978
+ })
979
+ repositoryQueue = apply.catch(() => {})
980
+ await apply
717
981
  return true
718
982
  }
719
983
 
@@ -752,7 +1016,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
752
1016
  tickStreak.onFailure(error)
753
1017
  }
754
1018
  }
755
- if (allReadsOk) tickStreak.onSuccess()
1019
+ if (allReadsOk) {
1020
+ try {
1021
+ await checkLiveness()
1022
+ tickStreak.onSuccess()
1023
+ } catch (error) {
1024
+ tickStreak.onFailure(error)
1025
+ }
1026
+ }
756
1027
  }
757
1028
 
758
1029
  // ── Initial scan ──
@@ -778,13 +1049,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
778
1049
  await trackBuild(key, sinceStreams[key])
779
1050
  if (stop) break
780
1051
  }
781
- if (repository) {
1052
+ if (followRepository) {
782
1053
  if (!(await trackRepository())) {
783
1054
  makeFailureStreak('watch', opts.stderr).onFailure(
784
1055
  new Error(`could not read the repository journal for "${repo}"`),
785
1056
  )
786
1057
  }
787
- } else if (slugs.length === 0) {
1058
+ }
1059
+ if (!repository && slugs.length === 0) {
788
1060
  // Membership discovery also baselines the default watch; a failed
789
1061
  // discovery here is fatal — nothing has started yet.
790
1062
  try {
@@ -795,6 +1067,13 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
795
1067
  }
796
1068
  }
797
1069
 
1070
+ if (followRepository && !stop) {
1071
+ try {
1072
+ await checkLiveness()
1073
+ } catch (error) {
1074
+ tickStreak.onFailure(error)
1075
+ }
1076
+ }
798
1077
  // ── The poll loop ──
799
1078
  const deadline = timeoutMs === 0 ? Number.POSITIVE_INFINITY : now().getTime() + timeoutMs
800
1079
  const aborted = (): boolean => opts.signal?.aborted === true
@@ -875,10 +1154,13 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
875
1154
 
876
1155
  for (const stream of streams.values()) if (!shouldStop) launch(stream)
877
1156
 
878
- if (slugs.length === 0 && !shouldStop) {
1157
+ if ((slugs.length === 0 || followRepository) && !shouldStop) {
879
1158
  void remoteRunner.runDiscovery({
880
1159
  step: async (): Promise<void> => {
881
- await discoverBuilds()
1160
+ if (slugs.length === 0) await discoverBuilds()
1161
+ const repositoryStream = streams.get(REPO_STREAM_KEY)
1162
+ if (repositoryStream?.kind === 'repo' && !stop) await pollRepository(repositoryStream)
1163
+ await checkLiveness()
882
1164
  },
883
1165
  // Unconditional, after the catch — exactly where the inline
884
1166
  // launch loop ran: streams registered before a mid-discovery
@@ -12,8 +12,10 @@ export const DISPATCHER_CONFIG_ARTIFACT = 'dispatcher-config'
12
12
  * behavior.
13
13
  */
14
14
  export const CONFIG_RELOAD_CLASSIFICATION = {
15
+ store: 'restart',
15
16
  baseBranch: 'hot',
16
17
  capacity: 'hot',
18
+ ticketScanIntervalSeconds: 'hot',
17
19
  forge: 'restart',
18
20
  plugins: 'restart',
19
21
  pr: 'hot',
@@ -48,6 +50,7 @@ export const CONFIG_RELOAD_CLASSIFICATION = {
48
50
  }
49
51
 
50
52
  export const RESTART_REQUIRED_CONFIG_PATHS = [
53
+ 'store',
51
54
  'forge',
52
55
  'plugins',
53
56
  'workspace.provider',
@@ -117,6 +120,7 @@ export function composeBuildConfig(pipeline: Config, deployment: Config): Config
117
120
  export function composeReloadedConfig(startup: Config, candidate: Config): Config {
118
121
  return {
119
122
  ...candidate,
123
+ store: startup.store,
120
124
  forge: startup.forge,
121
125
  plugins: startup.plugins,
122
126
  workspace: startup.workspace,