@defrex/autobuild 0.8.0 → 0.9.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 (147) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +313 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +57 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +151 -25
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +169 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +10 -0
  13. package/package.json +43 -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/model.ts +57 -7
  19. package/packages/core/src/cli/dashboard/poll.ts +69 -10
  20. package/packages/core/src/cli/dashboard/render.ts +34 -5
  21. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  22. package/packages/core/src/cli/dispatch.ts +320 -46
  23. package/packages/core/src/cli/harvest.ts +122 -8
  24. package/packages/core/src/cli/help.ts +47 -8
  25. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  26. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  27. package/packages/core/src/cli/init-validation.ts +248 -222
  28. package/packages/core/src/cli/main.ts +70 -11
  29. package/packages/core/src/cli/mcp.ts +23 -1
  30. package/packages/core/src/cli/plugin.ts +7 -4
  31. package/packages/core/src/cli/remote-poll.ts +277 -0
  32. package/packages/core/src/cli/repo-state.ts +22 -0
  33. package/packages/core/src/cli/repository-status.ts +39 -1
  34. package/packages/core/src/cli/status.ts +149 -27
  35. package/packages/core/src/cli/terminals.ts +2 -1
  36. package/packages/core/src/cli/ticket-assets.ts +247 -0
  37. package/packages/core/src/cli/ticket.ts +307 -7
  38. package/packages/core/src/cli/wait.ts +147 -39
  39. package/packages/core/src/cli/watch.ts +128 -166
  40. package/packages/core/src/config/live.ts +22 -0
  41. package/packages/core/src/config/pipeline-source.ts +376 -0
  42. package/packages/core/src/config/schema.ts +230 -19
  43. package/packages/core/src/events/catalog.ts +1 -0
  44. package/packages/core/src/events/globs.ts +47 -0
  45. package/packages/core/src/events/payloads.ts +52 -2
  46. package/packages/core/src/events/repository.ts +52 -1
  47. package/packages/core/src/events/sessions.ts +69 -10
  48. package/packages/core/src/events/workspace-remote.ts +12 -0
  49. package/packages/core/src/integration/harness.ts +2 -1
  50. package/packages/core/src/kernel/auto-merge-default.ts +123 -0
  51. package/packages/core/src/kernel/phases.ts +16 -2
  52. package/packages/core/src/kernel/reducer.ts +40 -1
  53. package/packages/core/src/operator/annotations.ts +6 -0
  54. package/packages/core/src/operator/control.ts +19 -5
  55. package/packages/core/src/operator/index.ts +71 -6
  56. package/packages/core/src/operator/query.ts +208 -19
  57. package/packages/core/src/operator/registry.ts +55 -10
  58. package/packages/core/src/operator/requests.ts +9 -6
  59. package/packages/core/src/operator/sandbox-host.ts +140 -0
  60. package/packages/core/src/operator/sandbox.ts +247 -14
  61. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  62. package/packages/core/src/orchestrator/approvals.ts +115 -0
  63. package/packages/core/src/orchestrator/conversation.ts +167 -0
  64. package/packages/core/src/orchestrator/model.ts +86 -0
  65. package/packages/core/src/orchestrator/prompt.ts +61 -0
  66. package/packages/core/src/orchestrator/tools.ts +117 -0
  67. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  68. package/packages/core/src/plugin-sdk/index.ts +51 -1
  69. package/packages/core/src/plugins/load.ts +135 -7
  70. package/packages/core/src/plugins/manifest.ts +183 -10
  71. package/packages/core/src/plugins/registry.ts +73 -4
  72. package/packages/core/src/ports/forge/create.ts +1 -1
  73. package/packages/core/src/ports/forge/fake.ts +31 -0
  74. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  75. package/packages/core/src/ports/forge/github.ts +13 -2
  76. package/packages/core/src/ports/forge/local-git.ts +31 -1
  77. package/packages/core/src/ports/runner/claude.ts +2 -2
  78. package/packages/core/src/ports/runner/codex.ts +6 -2
  79. package/packages/core/src/ports/runner/fake.ts +7 -1
  80. package/packages/core/src/ports/runner/pi.ts +8 -6
  81. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  82. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  83. package/packages/core/src/ports/types.ts +17 -2
  84. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  85. package/packages/core/src/ports/workspace/create.ts +74 -39
  86. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  87. package/packages/core/src/ports/workspace/fake.ts +169 -4
  88. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  89. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  90. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  91. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  92. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  93. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  94. package/packages/core/src/processes/build-child.ts +17 -2
  95. package/packages/core/src/processes/build-execution-state.ts +37 -0
  96. package/packages/core/src/processes/build-runner.ts +54 -3
  97. package/packages/core/src/processes/dispatcher.ts +247 -41
  98. package/packages/core/src/processes/harvest-child.ts +5 -0
  99. package/packages/core/src/processes/harvest-runner.ts +66 -19
  100. package/packages/core/src/processes/harvest.ts +196 -24
  101. package/packages/core/src/processes/orchestrator-tick.ts +319 -0
  102. package/packages/core/src/processes/sandbox-state.ts +10 -0
  103. package/packages/core/src/store/adapter.ts +32 -0
  104. package/packages/core/src/store/build-scope.ts +64 -0
  105. package/packages/core/src/store/contract.ts +868 -0
  106. package/packages/core/src/store/digest.ts +49 -0
  107. package/packages/core/src/store/local/schema.ts +29 -2
  108. package/packages/core/src/store/local/store.ts +338 -4
  109. package/packages/core/src/store/memory.ts +179 -0
  110. package/packages/core/src/store/new-build.ts +17 -0
  111. package/packages/core/src/store/phase-session.ts +71 -0
  112. package/packages/core/src/store/remote/client.ts +260 -1
  113. package/packages/core/src/store/remote/index.ts +12 -2
  114. package/packages/core/src/store/remote/protocol.ts +100 -12
  115. package/packages/core/src/store/remote/version.ts +6 -2
  116. package/packages/core/src/store/repo-state-events.ts +140 -0
  117. package/packages/core/src/store/retention.ts +7 -0
  118. package/packages/core/src/store/session-handle.ts +64 -0
  119. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
  120. package/packages/core/src/store/streams/session-writer.ts +42 -2
  121. package/packages/core/src/store/streams/wait.ts +4 -5
  122. package/packages/core/src/store/ticket-assets.ts +425 -0
  123. package/packages/core/src/store/types.ts +122 -1
  124. package/packages/core/src/testing/index.ts +59 -0
  125. package/skills/code-review/SKILL.md +5 -1
  126. package/skills/guide/SKILL.md +126 -33
  127. package/skills/guide/references/operator-api.md +2 -2
  128. package/skills/guide/references/plugin-authoring.md +17 -0
  129. package/skills/guide/references/remote-store-protocol.md +169 -16
  130. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  131. package/skills/guide/references/setup.md +12 -9
  132. package/skills/implement/SKILL.md +3 -0
  133. package/skills/operate/SKILL.md +126 -0
  134. package/skills/plan/SKILL.md +4 -0
  135. package/skills/plan-review/SKILL.md +5 -1
  136. package/skills/spec/SKILL.md +46 -3
  137. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  138. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  139. package/packages/core/src/cli/testkit.ts +0 -194
  140. package/packages/core/src/markdown.ts +0 -67
  141. package/packages/core/src/operator/client.ts +0 -311
  142. package/packages/core/src/operator/server.ts +0 -616
  143. package/packages/core/src/operator/sessions.ts +0 -223
  144. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  145. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  146. package/packages/core/src/store/remote/server.ts +0 -923
  147. 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',
@@ -646,8 +609,24 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
646
609
  * the throw stay tracked, so the retry neither duplicates nor skips.
647
610
  */
648
611
  const discoverBuilds = async (): Promise<void> => {
612
+ // AUT-545: the terminal filter is the AUT-487 digest, not a full-log read —
613
+ // one flat batch read replaces a full getEvents per candidate build, per
614
+ // discovery pass; `terminal` is reduceBuild's exact terminal rule
615
+ // (store/digest's reduceBuildDigest, pinned by contract). The per-build
616
+ // full read below remains deliberate for builds the digest cannot retire:
617
+ // its retained prefix is consumed at full replay depth by every later
618
+ // reduction (approvals, findings, answered escalations, PR facts,
619
+ // observations), so bounding it would need reducer-partition store
620
+ // machinery out of AUT-545's scope. The reduceBuild re-check stays: it is
621
+ // the legacy-record fallback (a record whose `repo` predates origin
622
+ // normalization is in `mine` via `repoOrigin` but absent from the digest
623
+ // map, which keys on `record.repo`) and it keeps this filter unable to
624
+ // diverge from reduceBuild. Mirrors cli/wait.ts discoverBuilds.
625
+ const digests = await store.getRepoBuildDigests(repo)
649
626
  for (const record of (await store.listBuilds()).filter(mine)) {
650
627
  if (streams.has(record.slug)) continue
628
+ const digest = digests.get(record.slug)
629
+ if (digest !== undefined && digest.terminal !== undefined) continue
651
630
  const events = await store.getEvents(record.slug)
652
631
  const status = reduceBuild(events).status
653
632
  if (!NONTERMINAL_STATUSES.includes(status)) continue
@@ -672,9 +651,27 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
672
651
  try {
673
652
  // A fresh store has no repository row; do not create one.
674
653
  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
654
+ // Bounded baseline (AUT-534): the baseline needs only the
655
+ // journal's end position, and AUT-489's bounded read answers that
656
+ // whenever the journal contains a `dispatcher.run-started` — the
657
+ // tail from the latest anchor runs to the journal end, so the
658
+ // subset's max seq equals the journal's max seq.
659
+ const subset = await store.getRepoStateEvents(repo)
660
+ let last = subset.at(-1)?.seq ?? 0
661
+ if (!subset.some((event) => event.type === 'dispatcher.run-started')) {
662
+ // Anchor-less journal: the subset may have dropped run-scoped
663
+ // facts appended before any anchor, so one bounded delta read
664
+ // recovers the true end; its events are baselined silently,
665
+ // exactly as the full read baselines them. The skip condition
666
+ // is exact: a run-started is its own anchor or lies in a later
667
+ // anchor's tail, so "no run-started in the subset" means "no
668
+ // run-started in the journal" — the only case where the subset
669
+ // can trail.
670
+ const tail = await store.getRepoEvents(repo, last)
671
+ last = tail.at(-1)?.seq ?? last
672
+ }
673
+ stream.lastSeq = last
674
+ positions[REPO_STREAM_KEY] = last
678
675
  }
679
676
  return true
680
677
  } catch {
@@ -684,7 +681,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
684
681
 
685
682
  const pollBuild = async (
686
683
  stream: BuildStream,
687
- readOpts?: { waitSeconds?: number; signal?: AbortSignal },
684
+ readOpts?: RemotePollReadOpts,
688
685
  ): Promise<boolean> => {
689
686
  const fresh = await store.getEvents(stream.slug, stream.lastSeq, readOpts)
690
687
  for (const event of fresh) {
@@ -700,7 +697,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
700
697
 
701
698
  const pollRepository = async (
702
699
  stream: RepositoryStream,
703
- readOpts?: { waitSeconds?: number; signal?: AbortSignal },
700
+ readOpts?: RemotePollReadOpts,
704
701
  ): Promise<boolean> => {
705
702
  // A repository row may be created mid-watch by the first dispatch.
706
703
  if ((await store.getRepo(repo)) === null) return true
@@ -722,34 +719,10 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
722
719
  return true
723
720
  }
724
721
 
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
722
  // One streak for the whole local loop — the same lifetime the shared
750
723
  // reported flag had before per-source streaks: a persistent read
751
724
  // failure is reported once, and a fully-successful cycle re-arms it.
752
- const tickStreak = makeFailureStreak()
725
+ const tickStreak = makeFailureStreak('watch', opts.stderr)
753
726
 
754
727
  const tick = async (): Promise<void> => {
755
728
  let allReadsOk = true
@@ -799,7 +772,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
799
772
  }
800
773
  if (repository) {
801
774
  if (!(await trackRepository())) {
802
- makeFailureStreak().onFailure(
775
+ makeFailureStreak('watch', opts.stderr).onFailure(
803
776
  new Error(`could not read the repository journal for "${repo}"`),
804
777
  )
805
778
  }
@@ -840,57 +813,51 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
840
813
  // Every stop is prompt: an external abort, a tripped --count, an
841
814
  // all-terminal named set, or an elapsed --timeout cancels the
842
815
  // 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.
816
+ // (via the runner's stop controller) instead of waiting out a full
817
+ // hold. The held read's own bound is additionally capped at the
818
+ // watch's remaining time budget, so a hold can never outlive the
819
+ // deadline.
846
820
  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
- }
821
+ const remoteRunner = createRemotePollRunner({
822
+ command: 'watch',
823
+ stderr: opts.stderr,
824
+ now,
825
+ sleep,
826
+ intervalMs: interval,
827
+ deadlineMs: deadline,
828
+ aborted,
829
+ shouldStop: () => aborted() || now().getTime() >= deadline || namedAllTerminal(),
830
+ // A task's terminal endCheck stops the runner from the inside; the
831
+ // command's `stop` flag must trip with it — pollBuild and
832
+ // pollRepository gate their batch loops on that flag
833
+ // (`if (stop) break`), so a sibling task whose held read has
834
+ // already resolved stops delivering its batch instead of draining
835
+ // it, exactly as the pre-extraction command-scoped `requestStop`
836
+ // (which set `stop` on every path) made it do (f_42263f38).
837
+ onStop: (): void => {
838
+ stop = true
839
+ },
840
+ // Quiesce, not the runner's one-shot snapshot: a stream the
841
+ // discovery task registers while drain is awaiting must have its
842
+ // first held read awaited before the final cursor is encoded, or a
843
+ // mid-discovery registration's emission lands after the cursor and
844
+ // the next run replays it (the gap widened from a scheduling race
845
+ // to a deterministic miss when discovery's digest read added one
846
+ // await before the per-build reads — AUT-545). The snapshot
847
+ // semantics the runner documents remain covered by its own tests.
848
+ drain: 'quiesce',
849
+ })
850
+ runner = remoteRunner
885
851
 
886
852
  const launch = (stream: Stream): void => {
887
853
  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)),
854
+ remoteRunner.launch(
855
+ key,
856
+ (readOpts) =>
857
+ stream.kind === 'build'
858
+ ? pollBuild(stream, readOpts)
859
+ : pollRepository(stream, readOpts),
860
+ () => namedAllTerminal(),
894
861
  )
895
862
  }
896
863
 
@@ -901,25 +868,20 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
901
868
  for (const stream of streams.values()) if (!shouldStop) launch(stream)
902
869
 
903
870
  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
- )
871
+ void remoteRunner.runDiscovery({
872
+ step: async (): Promise<void> => {
873
+ await discoverBuilds()
874
+ },
875
+ // Unconditional, after the catch — exactly where the inline
876
+ // launch loop ran: streams registered before a mid-discovery
877
+ // throw still get tasks whose polls then run independently.
878
+ launchPending: (): void => {
879
+ for (const stream of streams.values()) launch(stream)
880
+ },
881
+ })
920
882
  }
921
883
 
922
- await Promise.all(tasks)
884
+ await remoteRunner.drain()
923
885
  opts.signal?.removeEventListener('abort', requestStop)
924
886
  }
925
887
 
@@ -90,6 +90,28 @@ export function restartRequiredChanges(
90
90
  return changed
91
91
  }
92
92
 
93
+ /** Config sections that define the pipeline a build runs under (SPEC §16.1):
94
+ * the verify universe, finalize steps, deterministic commands, and the
95
+ * workspace provisioning definition. Build-owned sections are composed from
96
+ * the build's own branch at a recorded pipeline-source commit and never
97
+ * replaced by a later base-branch change. */
98
+ export const BUILD_OWNED_CONFIG_PATHS = ['verify', 'finalize', 'commands', 'workspace'] as const
99
+
100
+ /** Compose one build's effective config: build-owned tables come from the
101
+ * pipeline source (the build's own branch), everything else from the live
102
+ * dispatcher snapshot (deployment). Dispatcher-owned sections (baseBranch,
103
+ * capacity, forge, plugins, pr, tickets, orchestrator) always travel with the
104
+ * deployment side — the runner never interprets them. */
105
+ export function composeBuildConfig(pipeline: Config, deployment: Config): Config {
106
+ return {
107
+ ...deployment,
108
+ verify: pipeline.verify,
109
+ finalize: pipeline.finalize,
110
+ commands: pipeline.commands,
111
+ workspace: pipeline.workspace,
112
+ }
113
+ }
114
+
93
115
  /** Overlay all hot fields while retaining every startup-built adapter field. */
94
116
  export function composeReloadedConfig(startup: Config, candidate: Config): Config {
95
117
  return {