@defrex/autobuild 0.8.0 → 0.10.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 (182) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +323 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +166 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +175 -28
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +204 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +14 -0
  13. package/package.json +45 -23
  14. package/packages/core/src/cli/artifact.ts +130 -6
  15. package/packages/core/src/cli/binary.ts +9 -1
  16. package/packages/core/src/cli/build-control.ts +110 -13
  17. package/packages/core/src/cli/context.ts +46 -0
  18. package/packages/core/src/cli/dashboard/facts.ts +215 -0
  19. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  20. package/packages/core/src/cli/dashboard/model.ts +141 -153
  21. package/packages/core/src/cli/dashboard/poll.ts +76 -12
  22. package/packages/core/src/cli/dashboard/render.ts +34 -5
  23. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  24. package/packages/core/src/cli/dispatch-process.ts +1 -13
  25. package/packages/core/src/cli/dispatch.ts +392 -75
  26. package/packages/core/src/cli/harvest.ts +122 -8
  27. package/packages/core/src/cli/help.ts +62 -9
  28. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  29. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  30. package/packages/core/src/cli/init-validation.ts +266 -222
  31. package/packages/core/src/cli/installation.ts +50 -8
  32. package/packages/core/src/cli/main.ts +70 -11
  33. package/packages/core/src/cli/mcp.ts +23 -1
  34. package/packages/core/src/cli/plugin.ts +7 -4
  35. package/packages/core/src/cli/remote-poll.ts +277 -0
  36. package/packages/core/src/cli/repo-state.ts +22 -0
  37. package/packages/core/src/cli/repository-status.ts +39 -1
  38. package/packages/core/src/cli/self-update.ts +77 -2
  39. package/packages/core/src/cli/status.ts +150 -28
  40. package/packages/core/src/cli/terminals.ts +2 -1
  41. package/packages/core/src/cli/ticket-assets.ts +247 -0
  42. package/packages/core/src/cli/ticket.ts +453 -8
  43. package/packages/core/src/cli/wait.ts +157 -41
  44. package/packages/core/src/cli/watch.ts +138 -168
  45. package/packages/core/src/config/live.ts +23 -0
  46. package/packages/core/src/config/pipeline-source.ts +388 -0
  47. package/packages/core/src/config/schema.ts +251 -19
  48. package/packages/core/src/events/catalog.ts +1 -0
  49. package/packages/core/src/events/globs.ts +47 -0
  50. package/packages/core/src/events/payloads.ts +52 -2
  51. package/packages/core/src/events/repository.ts +52 -1
  52. package/packages/core/src/events/sessions.ts +69 -10
  53. package/packages/core/src/events/workspace-remote.ts +12 -0
  54. package/packages/core/src/integration/harness.ts +115 -2
  55. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  56. package/packages/core/src/kernel/auto-merge-default.ts +157 -0
  57. package/packages/core/src/kernel/auto-merge.ts +99 -22
  58. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  59. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  60. package/packages/core/src/kernel/engine.ts +9 -438
  61. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  62. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  63. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  64. package/packages/core/src/kernel/harvest.ts +62 -18
  65. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  66. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  67. package/packages/core/src/kernel/incremental.ts +53 -0
  68. package/packages/core/src/kernel/log-index.ts +620 -0
  69. package/packages/core/src/kernel/phases.ts +16 -2
  70. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  71. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  72. package/packages/core/src/kernel/reducer.ts +254 -73
  73. package/packages/core/src/operator/annotations.ts +6 -0
  74. package/packages/core/src/operator/control.ts +19 -5
  75. package/packages/core/src/operator/index.ts +71 -6
  76. package/packages/core/src/operator/query.ts +253 -24
  77. package/packages/core/src/operator/read-view.ts +98 -0
  78. package/packages/core/src/operator/registry.ts +55 -10
  79. package/packages/core/src/operator/requests.ts +9 -6
  80. package/packages/core/src/operator/sandbox-host.ts +140 -0
  81. package/packages/core/src/operator/sandbox.ts +247 -14
  82. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  83. package/packages/core/src/orchestrator/approvals.ts +115 -0
  84. package/packages/core/src/orchestrator/conversation.ts +167 -0
  85. package/packages/core/src/orchestrator/model.ts +86 -0
  86. package/packages/core/src/orchestrator/prompt.ts +61 -0
  87. package/packages/core/src/orchestrator/tools.ts +117 -0
  88. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  89. package/packages/core/src/plugin-sdk/index.ts +51 -1
  90. package/packages/core/src/plugins/load.ts +135 -7
  91. package/packages/core/src/plugins/manifest.ts +183 -10
  92. package/packages/core/src/plugins/registry.ts +73 -4
  93. package/packages/core/src/ports/forge/create.ts +1 -1
  94. package/packages/core/src/ports/forge/fake.ts +31 -0
  95. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  96. package/packages/core/src/ports/forge/github.ts +13 -2
  97. package/packages/core/src/ports/forge/local-git.ts +31 -1
  98. package/packages/core/src/ports/runner/claude.ts +2 -2
  99. package/packages/core/src/ports/runner/codex.ts +6 -2
  100. package/packages/core/src/ports/runner/fake.ts +7 -1
  101. package/packages/core/src/ports/runner/pi.ts +8 -6
  102. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  103. package/packages/core/src/ports/tickets/fake.ts +5 -1
  104. package/packages/core/src/ports/tickets/file.ts +169 -48
  105. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  106. package/packages/core/src/ports/tickets/linear.ts +33 -1
  107. package/packages/core/src/ports/tickets/operations.ts +1 -1
  108. package/packages/core/src/ports/tickets/remote.ts +8 -1
  109. package/packages/core/src/ports/types.ts +49 -3
  110. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  111. package/packages/core/src/ports/workspace/create.ts +74 -39
  112. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  113. package/packages/core/src/ports/workspace/fake.ts +169 -4
  114. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  115. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  116. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  117. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  118. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  119. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  120. package/packages/core/src/processes/build-child.ts +17 -2
  121. package/packages/core/src/processes/build-execution-state.ts +68 -15
  122. package/packages/core/src/processes/build-runner.ts +238 -93
  123. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  124. package/packages/core/src/processes/dispatcher.ts +501 -201
  125. package/packages/core/src/processes/execution-settlement.ts +72 -39
  126. package/packages/core/src/processes/harvest-child.ts +5 -0
  127. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  128. package/packages/core/src/processes/harvest-runner.ts +66 -19
  129. package/packages/core/src/processes/harvest.ts +196 -24
  130. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  131. package/packages/core/src/processes/orchestrator-tick.ts +360 -0
  132. package/packages/core/src/processes/publication-state.ts +164 -52
  133. package/packages/core/src/processes/repo-view.ts +1436 -0
  134. package/packages/core/src/processes/sandbox-state.ts +36 -2
  135. package/packages/core/src/processes/tick-state.ts +33 -0
  136. package/packages/core/src/store/adapter.ts +41 -0
  137. package/packages/core/src/store/build-scope.ts +90 -0
  138. package/packages/core/src/store/contract.ts +1205 -0
  139. package/packages/core/src/store/digest.ts +102 -0
  140. package/packages/core/src/store/local/schema.ts +46 -2
  141. package/packages/core/src/store/local/store.ts +445 -4
  142. package/packages/core/src/store/memory.ts +246 -0
  143. package/packages/core/src/store/new-build.ts +17 -0
  144. package/packages/core/src/store/phase-session.ts +94 -0
  145. package/packages/core/src/store/remote/client.ts +310 -2
  146. package/packages/core/src/store/remote/index.ts +14 -2
  147. package/packages/core/src/store/remote/protocol.ts +115 -12
  148. package/packages/core/src/store/remote/version.ts +6 -2
  149. package/packages/core/src/store/repo-state-events.ts +205 -0
  150. package/packages/core/src/store/retention.ts +7 -0
  151. package/packages/core/src/store/session-handle.ts +94 -0
  152. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
  153. package/packages/core/src/store/snapshots.ts +88 -0
  154. package/packages/core/src/store/streams/session-writer.ts +42 -2
  155. package/packages/core/src/store/streams/wait.ts +4 -5
  156. package/packages/core/src/store/ticket-assets.ts +458 -0
  157. package/packages/core/src/store/types.ts +160 -1
  158. package/packages/core/src/testing/index.ts +59 -0
  159. package/skills/code-review/SKILL.md +5 -1
  160. package/skills/guide/SKILL.md +152 -40
  161. package/skills/guide/references/operator-api.md +2 -2
  162. package/skills/guide/references/plugin-authoring.md +17 -0
  163. package/skills/guide/references/remote-store-protocol.md +204 -16
  164. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  165. package/skills/guide/references/setup.md +12 -9
  166. package/skills/implement/SKILL.md +3 -0
  167. package/skills/operate/SKILL.md +126 -0
  168. package/skills/plan/SKILL.md +4 -0
  169. package/skills/plan-review/SKILL.md +5 -1
  170. package/skills/spec/SKILL.md +46 -3
  171. package/skills/tickets/SKILL.md +57 -5
  172. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  173. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  174. package/packages/core/src/cli/testkit.ts +0 -194
  175. package/packages/core/src/markdown.ts +0 -67
  176. package/packages/core/src/operator/client.ts +0 -311
  177. package/packages/core/src/operator/server.ts +0 -616
  178. package/packages/core/src/operator/sessions.ts +0 -223
  179. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  180. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  181. package/packages/core/src/store/remote/server.ts +0 -923
  182. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -38,20 +38,23 @@
38
38
  * Output carries no ANSI, ever, in either form (§16).
39
39
  */
40
40
  import type { AbEvent } from '../events/catalog'
41
- import { EVENT_TYPES, type EventType } from '../events/payloads'
42
- import {
43
- REPOSITORY_EVENT_TYPES,
44
- type RepositoryEvent,
45
- type RepositoryEventType,
46
- } from '../events/repository'
41
+ import type { EventType } from '../events/payloads'
42
+ import type { RepositoryEvent, RepositoryEventType } from '../events/repository'
43
+ import { REPOSITORY_ATTENTION_EVENTS } from '../events/repository'
47
44
  import type { BuildState, PrLifecycle } from '../kernel/reducer'
48
45
  import { reduceBuild } from '../kernel/reducer'
49
46
  import type { BuildOutcome, BuildStatus, Phase } from '../ontology'
50
47
  import type { Exec } from '../ports/workspace/git-worktree'
51
48
  import { PhaseSessionError } from '../store/phase-session'
52
- import { REMOTE_EVENT_WAIT_SECONDS } from '../store/remote/client'
53
49
  import type { BuildRecord } from '../store/types'
54
50
  import { resolveAmbientReadSession } from './env'
51
+ import {
52
+ createRemotePollRunner,
53
+ defaultDelay,
54
+ makeFailureStreak,
55
+ type RemotePollReadOpts,
56
+ type RemotePollRunner,
57
+ } from './remote-poll'
55
58
  import { buildInRepository, isRemoteStoreRef, normalizeGitRemoteUrl } from './repo-state'
56
59
  import { withAmbientReadStore, type StoreOpener } from './store-opening'
57
60
 
@@ -93,15 +96,6 @@ export const BUILD_ATTENTION_EVENTS = [
93
96
  'build.aborted',
94
97
  ] as const satisfies readonly EventType[]
95
98
 
96
- /** The repository-journal attention set, followed only under `--repository`. */
97
- export const REPOSITORY_ATTENTION_EVENTS = [
98
- 'harvest.escalated',
99
- 'harvest.failed',
100
- 'dispatcher.tick-failed',
101
- 'dispatcher.config-rejected',
102
- 'dispatcher.harvest-runner-failed',
103
- ] as const satisfies readonly RepositoryEventType[]
104
-
105
99
  // ── Glob compilation and matching ────────────────────────────────────────────
106
100
 
107
101
  /**
@@ -111,36 +105,12 @@ export const REPOSITORY_ATTENTION_EVENTS = [
111
105
  * the build catalog — or, when `--repository` is given, in the repository
112
106
  * catalog — otherwise a usage error naming the glob is thrown before any
113
107
  * store read. Supplying any glob replaces the attention set entirely.
108
+ *
109
+ * The compiler itself lives in `events/globs.ts`, shared with the
110
+ * `[orchestrator].wake` configuration validation (AUT-342).
114
111
  */
115
- export function compileEventGlobs(
116
- globs: readonly string[],
117
- opts: { repository: boolean; usage: string },
118
- ): RegExp[] {
119
- return globs.map((glob) => {
120
- const source =
121
- '^' +
122
- [...glob]
123
- .map((char) => {
124
- if (char === '*') return '.*'
125
- if (char === '?') return '.'
126
- return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
127
- })
128
- .join('') +
129
- '$'
130
- const regex = new RegExp(source)
131
- const inBuildCatalog = EVENT_TYPES.some((type) => regex.test(type))
132
- const inRepositoryCatalog =
133
- opts.repository && REPOSITORY_EVENT_TYPES.some((type) => regex.test(type))
134
- if (!inBuildCatalog && !inRepositoryCatalog) {
135
- throw new Error(
136
- `--event "${glob}" matches no known event type${
137
- opts.repository ? ' in the build or repository catalogs' : ' in the build event catalog'
138
- } — ${opts.usage}`,
139
- )
140
- }
141
- return regex
142
- })
143
- }
112
+ import { compileEventGlobs } from '../events/globs'
113
+ export { compileEventGlobs } from '../events/globs'
144
114
 
145
115
  function matchesBuildFilter(type: EventType, filters: readonly RegExp[]): boolean {
146
116
  return filters.length > 0
@@ -470,23 +440,6 @@ interface RepositoryStream {
470
440
 
471
441
  type Stream = BuildStream | RepositoryStream
472
442
 
473
- function defaultDelay(ms: number, signal?: AbortSignal): Promise<void> {
474
- return new Promise((resolve) => {
475
- if (signal?.aborted === true) {
476
- resolve()
477
- return
478
- }
479
- const finish = (): void => {
480
- clearTimeout(timer)
481
- signal?.removeEventListener('abort', onAbort)
482
- resolve()
483
- }
484
- const timer = setTimeout(finish, ms)
485
- const onAbort = (): void => finish()
486
- signal?.addEventListener('abort', onAbort, { once: true })
487
- })
488
- }
489
-
490
443
  /**
491
444
  * `ab watch` — stream matching build and repository events until a timeout, a
492
445
  * record count, or (only when slugs were named) terminal status ends the
@@ -531,6 +484,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
531
484
  const sinceStreams =
532
485
  opts.since !== undefined ? decodeCursor(opts.since, { store: storeRef, repo }).streams : {}
533
486
 
487
+ // Verified (AUT-574): this is the full normalized-identity rule of
488
+ // AUT-567 — the same `mine` predicate as status.ts and wait.ts, with
489
+ // buildInRepository's semantics. `identity` is resolveRepoState's
490
+ // `origin ?? resolved checkout`, already normalized (re-normalizing is
491
+ // a no-op); the first arm is the whole test for new records, whose
492
+ // `record.repo` IS the normalized origin, and the second arm forgives
493
+ // legacy path-keyed records via their normalized `repoOrigin`. A
494
+ // foreign origin, or an origin-less legacy record, is excluded.
534
495
  const identity = normalizeGitRemoteUrl(repo)
535
496
  const mine = (record: BuildRecord): boolean =>
536
497
  record.repo === identity ||
@@ -543,17 +504,13 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
543
504
  let emitted = 0
544
505
  let countCursor: string | undefined
545
506
  let stop = false
546
- // Held reads in flight right now, each with its own controller. When
547
- // the watch stops for any reason, every in-flight read is cancelled —
548
- // otherwise a stop during a quiet window would wait out a full wait
549
- // window (up to the remote hold) before the watch could exit.
550
- const inFlightReads = new Set<AbortController>()
551
- // Wakes stop-aware gap sleeps the moment the watch stops.
552
- const stopController = new AbortController()
507
+ // On the remote path this is the shared long-poll runner (the held
508
+ // reads, their cancellation, and the gap sleeps it owns); on the local
509
+ // path it stays undefined and a stop trips the `stop` flag alone.
510
+ let runner: RemotePollRunner | undefined
553
511
  const requestStop = (): void => {
554
512
  stop = true
555
- stopController.abort()
556
- for (const controller of inFlightReads) controller.abort()
513
+ runner?.requestStop()
557
514
  }
558
515
 
559
516
  const encodeCurrent = (): string =>
@@ -607,6 +564,12 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
607
564
  if (streams.has(slug)) return true
608
565
  const record = await store.getBuild(slug)
609
566
  if (record === null) return false
567
+ // AUT-545: this full-log read is deliberate, accepted growth — the
568
+ // retained prefix is consumed at full replay depth by every later
569
+ // reduction (approvals, findings, answered escalations, PR facts,
570
+ // observations), so bounding it would need reducer-partition store
571
+ // machinery out of AUT-545's scope. Required for `--since` replay and
572
+ // the baselined stream prefix. Mirrors cli/wait.ts trackBuild.
610
573
  const events = await store.getEvents(slug)
611
574
  const stream: BuildStream = {
612
575
  kind: 'build',
@@ -634,6 +597,9 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
634
597
  return true
635
598
  }
636
599
 
600
+ /** Builds discovery read in full and found terminal; never re-read. */
601
+ const retired = new Set<string>()
602
+
637
603
  /**
638
604
  * No-slug membership: every nonterminal build of this repository joins
639
605
  * the watch, baselined at its current maximum so no history replays. A
@@ -646,11 +612,32 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
646
612
  * the throw stay tracked, so the retry neither duplicates nor skips.
647
613
  */
648
614
  const discoverBuilds = async (): Promise<void> => {
615
+ // AUT-545: the terminal filter is the AUT-487 digest, not a full-log read —
616
+ // one flat batch read replaces a full getEvents per candidate build, per
617
+ // discovery pass; `terminal` is reduceBuild's exact terminal rule
618
+ // (store/digest's reduceBuildDigest, pinned by contract). The per-build
619
+ // full read below remains deliberate for builds the digest cannot retire:
620
+ // its retained prefix is consumed at full replay depth by every later
621
+ // reduction (approvals, findings, answered escalations, PR facts,
622
+ // observations), so bounding it would need reducer-partition store
623
+ // machinery out of AUT-545's scope. The reduceBuild re-check stays: it is
624
+ // the legacy-record fallback (a record whose `repo` predates origin
625
+ // normalization is in `mine` via `repoOrigin` but absent from the digest
626
+ // map, which keys on `record.repo`) and it keeps this filter unable to
627
+ // diverge from reduceBuild. Mirrors cli/wait.ts discoverBuilds.
628
+ const digests = await store.getRepoBuildDigests(repo)
649
629
  for (const record of (await store.listBuilds()).filter(mine)) {
650
- if (streams.has(record.slug)) continue
630
+ if (streams.has(record.slug) || retired.has(record.slug)) continue
631
+ const digest = digests.get(record.slug)
632
+ if (digest !== undefined && digest.terminal !== undefined) continue
651
633
  const events = await store.getEvents(record.slug)
652
634
  const status = reduceBuild(events).status
653
- if (!NONTERMINAL_STATUSES.includes(status)) continue
635
+ if (!NONTERMINAL_STATUSES.includes(status)) {
636
+ // Terminal is permanent (reduceBuild never clears it): a legacy
637
+ // record the digest cannot retire is read once, not every pass.
638
+ retired.add(record.slug)
639
+ continue
640
+ }
654
641
  const stream: BuildStream = {
655
642
  kind: 'build',
656
643
  slug: record.slug,
@@ -672,9 +659,27 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
672
659
  try {
673
660
  // A fresh store has no repository row; do not create one.
674
661
  if (resumeSeq === undefined && (await store.getRepo(repo)) !== null) {
675
- const events = await store.getRepoEvents(repo)
676
- stream.lastSeq = events.at(-1)?.seq ?? 0
677
- positions[REPO_STREAM_KEY] = stream.lastSeq
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
680
+ }
681
+ stream.lastSeq = last
682
+ positions[REPO_STREAM_KEY] = last
678
683
  }
679
684
  return true
680
685
  } catch {
@@ -684,7 +689,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
684
689
 
685
690
  const pollBuild = async (
686
691
  stream: BuildStream,
687
- readOpts?: { waitSeconds?: number; signal?: AbortSignal },
692
+ readOpts?: RemotePollReadOpts,
688
693
  ): Promise<boolean> => {
689
694
  const fresh = await store.getEvents(stream.slug, stream.lastSeq, readOpts)
690
695
  for (const event of fresh) {
@@ -700,7 +705,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
700
705
 
701
706
  const pollRepository = async (
702
707
  stream: RepositoryStream,
703
- readOpts?: { waitSeconds?: number; signal?: AbortSignal },
708
+ readOpts?: RemotePollReadOpts,
704
709
  ): Promise<boolean> => {
705
710
  // A repository row may be created mid-watch by the first dispatch.
706
711
  if ((await store.getRepo(repo)) === null) return true
@@ -722,34 +727,10 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
722
727
  return true
723
728
  }
724
729
 
725
- /** A failed read is reported once per failure streak, advances nothing,
726
- * and is retried at the next interval (local) or next request (remote);
727
- * a success on the same source re-arms the report. Each read source —
728
- * per stream task, plus discovery — carries its own streak, so a
729
- * persistent failure on one stream interleaved with successes elsewhere
730
- * is still reported only once. */
731
- const makeFailureStreak = (): {
732
- onFailure: (error: unknown) => void
733
- onSuccess: () => void
734
- } => {
735
- let reported = false
736
- return {
737
- onFailure: (error: unknown): void => {
738
- if (reported) return
739
- reported = true
740
- const message = error instanceof Error ? error.message : String(error)
741
- opts.stderr(`ab watch: a store read failed (${message}); retrying at the next interval`)
742
- },
743
- onSuccess: (): void => {
744
- reported = false
745
- },
746
- }
747
- }
748
-
749
730
  // One streak for the whole local loop — the same lifetime the shared
750
731
  // reported flag had before per-source streaks: a persistent read
751
732
  // failure is reported once, and a fully-successful cycle re-arms it.
752
- const tickStreak = makeFailureStreak()
733
+ const tickStreak = makeFailureStreak('watch', opts.stderr)
753
734
 
754
735
  const tick = async (): Promise<void> => {
755
736
  let allReadsOk = true
@@ -799,7 +780,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
799
780
  }
800
781
  if (repository) {
801
782
  if (!(await trackRepository())) {
802
- makeFailureStreak().onFailure(
783
+ makeFailureStreak('watch', opts.stderr).onFailure(
803
784
  new Error(`could not read the repository journal for "${repo}"`),
804
785
  )
805
786
  }
@@ -840,57 +821,51 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
840
821
  // Every stop is prompt: an external abort, a tripped --count, an
841
822
  // all-terminal named set, or an elapsed --timeout cancels the
842
823
  // in-flight held reads (via `requestStop`) and wakes the gap sleeps
843
- // (via `stopController`) instead of waiting out a full hold. The
844
- // held read's own bound is additionally capped at the watch's
845
- // remaining time budget, so a hold can never outlive the deadline.
824
+ // (via the runner's stop controller) instead of waiting out a full
825
+ // hold. The held read's own bound is additionally capped at the
826
+ // watch's remaining time budget, so a hold can never outlive the
827
+ // deadline.
846
828
  const shouldStop = stop || aborted() || now().getTime() >= deadline || namedAllTerminal()
847
- const tasks: Promise<void>[] = []
848
- const launched = new Set<string>()
849
-
850
- /** The wait bound for one held read: the remote default, capped at
851
- * the watch's remaining time budget. */
852
- const readWaitSeconds = (atMs: number): number => {
853
- if (timeoutMs === 0) return REMOTE_EVENT_WAIT_SECONDS
854
- const remaining = Math.floor((deadline - atMs) / 1000)
855
- return Math.max(0, Math.min(REMOTE_EVENT_WAIT_SECONDS, remaining))
856
- }
857
-
858
- const runStreamTask = async (
859
- poll: (readOpts: { waitSeconds?: number; signal?: AbortSignal }) => Promise<unknown>,
860
- ): Promise<void> => {
861
- const streak = makeFailureStreak()
862
- while (!stop && !aborted() && now().getTime() < deadline) {
863
- const started = now().getTime()
864
- const controller = new AbortController()
865
- inFlightReads.add(controller)
866
- try {
867
- await poll({ waitSeconds: readWaitSeconds(started), signal: controller.signal })
868
- streak.onSuccess()
869
- if (namedAllTerminal()) {
870
- requestStop()
871
- return
872
- }
873
- } catch (error) {
874
- // A cancelled held read is the watch stopping, not a store
875
- // failure — never report it.
876
- if (!stop && !aborted() && !controller.signal.aborted) streak.onFailure(error)
877
- } finally {
878
- inFlightReads.delete(controller)
879
- }
880
- if (stop || aborted()) return
881
- const elapsed = now().getTime() - started
882
- if (elapsed < interval) await sleep(interval - elapsed, stopController.signal)
883
- }
884
- }
829
+ const remoteRunner = createRemotePollRunner({
830
+ command: 'watch',
831
+ stderr: opts.stderr,
832
+ now,
833
+ sleep,
834
+ intervalMs: interval,
835
+ deadlineMs: deadline,
836
+ aborted,
837
+ shouldStop: () => aborted() || now().getTime() >= deadline || namedAllTerminal(),
838
+ // A task's terminal endCheck stops the runner from the inside; the
839
+ // command's `stop` flag must trip with it — pollBuild and
840
+ // pollRepository gate their batch loops on that flag
841
+ // (`if (stop) break`), so a sibling task whose held read has
842
+ // already resolved stops delivering its batch instead of draining
843
+ // it, exactly as the pre-extraction command-scoped `requestStop`
844
+ // (which set `stop` on every path) made it do (f_42263f38).
845
+ onStop: (): void => {
846
+ stop = true
847
+ },
848
+ // Quiesce, not the runner's one-shot snapshot: a stream the
849
+ // discovery task registers while drain is awaiting must have its
850
+ // first held read awaited before the final cursor is encoded, or a
851
+ // mid-discovery registration's emission lands after the cursor and
852
+ // the next run replays it (the gap widened from a scheduling race
853
+ // to a deterministic miss when discovery's digest read added one
854
+ // await before the per-build reads — AUT-545). The snapshot
855
+ // semantics the runner documents remain covered by its own tests.
856
+ drain: 'quiesce',
857
+ })
858
+ runner = remoteRunner
885
859
 
886
860
  const launch = (stream: Stream): void => {
887
861
  const key = stream.kind === 'build' ? stream.slug : REPO_STREAM_KEY
888
- if (launched.has(key)) return
889
- launched.add(key)
890
- tasks.push(
891
- stream.kind === 'build'
892
- ? runStreamTask((readOpts) => pollBuild(stream, readOpts))
893
- : runStreamTask((readOpts) => pollRepository(stream, readOpts)),
862
+ remoteRunner.launch(
863
+ key,
864
+ (readOpts) =>
865
+ stream.kind === 'build'
866
+ ? pollBuild(stream, readOpts)
867
+ : pollRepository(stream, readOpts),
868
+ () => namedAllTerminal(),
894
869
  )
895
870
  }
896
871
 
@@ -901,25 +876,20 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
901
876
  for (const stream of streams.values()) if (!shouldStop) launch(stream)
902
877
 
903
878
  if (slugs.length === 0 && !shouldStop) {
904
- tasks.push(
905
- (async (): Promise<void> => {
906
- const streak = makeFailureStreak()
907
- while (!stop && !aborted() && now().getTime() < deadline) {
908
- try {
909
- await discoverBuilds()
910
- streak.onSuccess()
911
- } catch (error) {
912
- streak.onFailure(error)
913
- }
914
- for (const stream of streams.values()) launch(stream)
915
- if (stop || aborted()) return
916
- await sleep(interval, stopController.signal)
917
- }
918
- })(),
919
- )
879
+ void remoteRunner.runDiscovery({
880
+ step: async (): Promise<void> => {
881
+ await discoverBuilds()
882
+ },
883
+ // Unconditional, after the catch — exactly where the inline
884
+ // launch loop ran: streams registered before a mid-discovery
885
+ // throw still get tasks whose polls then run independently.
886
+ launchPending: (): void => {
887
+ for (const stream of streams.values()) launch(stream)
888
+ },
889
+ })
920
890
  }
921
891
 
922
- await Promise.all(tasks)
892
+ await remoteRunner.drain()
923
893
  opts.signal?.removeEventListener('abort', requestStop)
924
894
  }
925
895
 
@@ -31,6 +31,7 @@ export const CONFIG_RELOAD_CLASSIFICATION = {
31
31
  source: 'restart',
32
32
  readyLabels: 'hot',
33
33
  readyState: 'hot',
34
+ readyAssignee: 'hot',
34
35
  teamKey: 'restart',
35
36
  claimedState: 'restart',
36
37
  createState: 'restart',
@@ -90,6 +91,28 @@ export function restartRequiredChanges(
90
91
  return changed
91
92
  }
92
93
 
94
+ /** Config sections that define the pipeline a build runs under (SPEC §16.1):
95
+ * the verify universe, finalize steps, deterministic commands, and the
96
+ * workspace provisioning definition. Build-owned sections are composed from
97
+ * the build's own branch at a recorded pipeline-source commit and never
98
+ * replaced by a later base-branch change. */
99
+ export const BUILD_OWNED_CONFIG_PATHS = ['verify', 'finalize', 'commands', 'workspace'] as const
100
+
101
+ /** Compose one build's effective config: build-owned tables come from the
102
+ * pipeline source (the build's own branch), everything else from the live
103
+ * dispatcher snapshot (deployment). Dispatcher-owned sections (baseBranch,
104
+ * capacity, forge, plugins, pr, tickets, orchestrator) always travel with the
105
+ * deployment side — the runner never interprets them. */
106
+ export function composeBuildConfig(pipeline: Config, deployment: Config): Config {
107
+ return {
108
+ ...deployment,
109
+ verify: pipeline.verify,
110
+ finalize: pipeline.finalize,
111
+ commands: pipeline.commands,
112
+ workspace: pipeline.workspace,
113
+ }
114
+ }
115
+
93
116
  /** Overlay all hot fields while retaining every startup-built adapter field. */
94
117
  export function composeReloadedConfig(startup: Config, candidate: Config): Config {
95
118
  return {