@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
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * The command shares `ab watch`'s machinery — target selection, ambient
11
11
  * scoping (via the extracted `assertReadScope`), the cursor codec, the record
12
- * projection, the poll cadence, and the read-failure policy — but ends on the
12
+ * projection, the cadence, and the read-failure policy — but ends on the
13
13
  * first satisfied condition instead of streaming. Conditions are derived from
14
14
  * the same reducer that produces `ab build status` (`status`, `prState`,
15
15
  * `phase`), so "blocked" means an open escalation exactly as it does
@@ -17,6 +17,17 @@
17
17
  * wait`, a state condition that already holds when the command starts returns
18
18
  * immediately with the build's latest event.
19
19
  *
20
+ * Cadence follows the store kind, exactly as `ab watch` does since AUT-334
21
+ * (verified and corrected for `ab wait` in AUT-368). Against a local store
22
+ * the loop is the sequential interval tick. Against an `http(s)` store every
23
+ * tracked build long-polls instead: one held `getEvents` request per stream
24
+ * is in flight (tasks run concurrently), `--interval` is the floor between
25
+ * request starts (elapsed request time counts toward the gap), and a hold's
26
+ * window is the remote default (25 s, whole seconds, under the store's
27
+ * ceiling) capped at the remaining `--timeout` budget. Every stop — a
28
+ * satisfied condition, an all-terminal named set, the timeout, an interrupt —
29
+ * cancels the in-flight holds promptly instead of waiting out a full window.
30
+ *
20
31
  * Read-only: it appends no event, claims or renews no lease, and creates no
21
32
  * build or repository record. Output carries no ANSI, ever, in either form.
22
33
  */
@@ -26,7 +37,13 @@ import type { BuildState, PrLifecycle } from '../kernel/reducer'
26
37
  import { reduceBuild } from '../kernel/reducer'
27
38
  import { isPhase, type BuildStatus, type Phase } from '../ontology'
28
39
  import type { Exec } from '../ports/workspace/git-worktree'
29
- import { buildInRepository, normalizeGitRemoteUrl } from './repo-state'
40
+ import {
41
+ createRemotePollRunner,
42
+ defaultDelay,
43
+ makeFailureStreak,
44
+ type RemotePollReadOpts,
45
+ } from './remote-poll'
46
+ import { buildInRepository, isRemoteStoreRef, normalizeGitRemoteUrl } from './repo-state'
30
47
  import type { BuildRecord } from '../store/types'
31
48
  import { withAmbientReadStore, type StoreOpener } from './store-opening'
32
49
  import {
@@ -245,23 +262,6 @@ interface WaitStream {
245
262
  status: BuildStatus
246
263
  }
247
264
 
248
- function defaultDelay(ms: number, signal?: AbortSignal): Promise<void> {
249
- return new Promise((resolve) => {
250
- if (signal?.aborted === true) {
251
- resolve()
252
- return
253
- }
254
- const finish = (): void => {
255
- clearTimeout(timer)
256
- signal?.removeEventListener('abort', onAbort)
257
- resolve()
258
- }
259
- const timer = setTimeout(finish, ms)
260
- const onAbort = (): void => finish()
261
- signal?.addEventListener('abort', onAbort, { once: true })
262
- })
263
- }
264
-
265
265
  /**
266
266
  * `ab wait` — block until a targeted build satisfies a condition, then print
267
267
  * one record and exit 0; exit 2 when every named build went terminal without
@@ -386,6 +386,12 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
386
386
  if (streams.has(slug)) return
387
387
  const record = await store.getBuild(slug)
388
388
  if (record === null) return
389
+ // AUT-545: this full-log read is deliberate, accepted growth — the
390
+ // retained prefix is consumed at full replay depth by every later
391
+ // reduction (approvals, findings, answered escalations, PR facts,
392
+ // observations), so bounding it would need reducer-partition store
393
+ // machinery out of AUT-545's scope. Required for `--since` replay and
394
+ // the registration state check over the full reduction.
389
395
  const events = await store.getEvents(slug)
390
396
  const stream = registerStream(slug, events, slugs.includes(slug))
391
397
  if (resumeSeq !== undefined) {
@@ -410,6 +416,9 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
410
416
  if (satisfied === null) checkRegistrationState(stream)
411
417
  }
412
418
 
419
+ /** Builds discovery read in full and found terminal; never re-read. */
420
+ const retired = new Set<string>()
421
+
413
422
  /**
414
423
  * No-slug membership: every nonterminal build of this repository joins
415
424
  * the wait, baselined at its current maximum so no history replays, and
@@ -419,18 +428,42 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
419
428
  * build to appear.
420
429
  */
421
430
  const discoverBuilds = async (): Promise<void> => {
431
+ // AUT-545: the terminal filter is the AUT-487 digest, not a full-log read —
432
+ // one flat batch read replaces a full getEvents per candidate build, per
433
+ // discovery pass; `terminal` is reduceBuild's exact terminal rule
434
+ // (store/digest.ts, pinned by contract). The per-build full read below
435
+ // remains deliberate for builds the digest cannot retire: its retained
436
+ // prefix is consumed at full replay depth by every later reduction
437
+ // (approvals, findings, answered escalations, PR facts, observations), so
438
+ // bounding it would need reducer-partition store machinery out of
439
+ // AUT-545's scope. The reduceBuild re-check stays: it is the
440
+ // legacy-record fallback (a record whose `repo` predates origin
441
+ // normalization is in `mine` via `repoOrigin` but absent from the digest
442
+ // map, which keys on `record.repo`) and it keeps this filter unable to
443
+ // diverge from reduceBuild.
444
+ const digests = await store.getRepoBuildDigests(repo)
422
445
  for (const record of (await store.listBuilds()).filter(mine)) {
423
- if (streams.has(record.slug)) continue
446
+ if (streams.has(record.slug) || retired.has(record.slug)) continue
447
+ const digest = digests.get(record.slug)
448
+ if (digest !== undefined && digest.terminal !== undefined) continue
424
449
  const events = await store.getEvents(record.slug)
425
450
  const status = reduceBuild(events).status
426
- if (!NONTERMINAL_STATUSES.includes(status)) continue
451
+ if (!NONTERMINAL_STATUSES.includes(status)) {
452
+ // Terminal is permanent (reduceBuild never clears it): a legacy
453
+ // record the digest cannot retire is read once, not every pass.
454
+ retired.add(record.slug)
455
+ continue
456
+ }
427
457
  const stream = registerStream(record.slug, events, false)
428
458
  if (satisfied === null) checkRegistrationState(stream)
429
459
  }
430
460
  }
431
461
 
432
- const pollBuild = async (stream: WaitStream): Promise<void> => {
433
- const fresh = await store.getEvents(stream.slug, stream.lastSeq)
462
+ const pollBuild = async (
463
+ stream: WaitStream,
464
+ readOpts?: RemotePollReadOpts,
465
+ ): Promise<void> => {
466
+ const fresh = await store.getEvents(stream.slug, stream.lastSeq, readOpts)
434
467
  for (const event of fresh) {
435
468
  stream.events.push(event)
436
469
  stream.lastSeq = event.seq
@@ -482,13 +515,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
482
515
  /** One poll cycle: discovery, then per-stream reads. A failed read is
483
516
  * reported once per failure streak, advances nothing, and is retried at
484
517
  * the next interval; a fully successful cycle re-arms the report. */
485
- let readFailureReported = false
486
- const reportReadFailure = (error: unknown): void => {
487
- if (readFailureReported) return
488
- readFailureReported = true
489
- const message = error instanceof Error ? error.message : String(error)
490
- opts.stderr(`ab wait: a store read failed (${message}); retrying at the next interval`)
491
- }
518
+ const tickStreak = makeFailureStreak('wait', opts.stderr)
492
519
 
493
520
  const tick = async (): Promise<void> => {
494
521
  let allReadsOk = true
@@ -497,7 +524,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
497
524
  await discoverBuilds()
498
525
  } catch (error) {
499
526
  allReadsOk = false
500
- reportReadFailure(error)
527
+ tickStreak.onFailure(error)
501
528
  }
502
529
  }
503
530
  for (const stream of [...streams.values()]) {
@@ -506,10 +533,10 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
506
533
  await pollBuild(stream)
507
534
  } catch (error) {
508
535
  allReadsOk = false
509
- reportReadFailure(error)
536
+ tickStreak.onFailure(error)
510
537
  }
511
538
  }
512
- if (allReadsOk) readFailureReported = false
539
+ if (allReadsOk) tickStreak.onSuccess()
513
540
  }
514
541
 
515
542
  const finishSatisfied = (): number =>
@@ -561,17 +588,106 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
561
588
  const timeoutText = opts.timeout ?? '30m'
562
589
  const deadline = timeoutMs === 0 ? Number.POSITIVE_INFINITY : now().getTime() + timeoutMs
563
590
  const aborted = (): boolean => opts.signal?.aborted === true
564
- while (!hasMatch()) {
565
- if (aborted() || now().getTime() >= deadline) break
566
- await sleep(interval, opts.signal)
567
- if (aborted()) break
568
- await tick()
569
- if (!hasMatch() && namedAllTerminal()) {
570
- const terminal = pickTerminalRecord()
571
- return finish(null, terminal.event, terminal.state)
591
+
592
+ if (!isRemoteStoreRef(storeRef)) {
593
+ // Local cadence: one sequential tick per interval.
594
+ while (!hasMatch()) {
595
+ if (aborted() || now().getTime() >= deadline) break
596
+ await sleep(interval, opts.signal)
597
+ if (aborted()) break
598
+ await tick()
599
+ if (!hasMatch() && namedAllTerminal()) {
600
+ const terminal = pickTerminalRecord()
601
+ return finish(null, terminal.event, terminal.state)
602
+ }
603
+ }
604
+ } else {
605
+ // Remote cadence (AUT-334, verified and corrected in AUT-368): one
606
+ // long-poll task per tracked stream, running concurrently — a quiet
607
+ // stream keeps one held `getEvents` request in flight, so an appended
608
+ // event is seen within about a second of its append instead of at the
609
+ // next interval tick. Gap-fill spaces each stream's request starts at
610
+ // least `interval` apart (elapsed request time counts toward the
611
+ // gap), so a server that answers immediately still sees today's
612
+ // request rate. Discovery keeps its interval cadence. The pre-launch
613
+ // guard mirrors the local loop's top-of-cycle checks: a satisfied
614
+ // match, an abort, an elapsed deadline, or an all-terminal named set
615
+ // ends the wait before any held request (an already-satisfied wait
616
+ // issues zero held reads).
617
+ // Every stop is prompt: a satisfied condition, an all-terminal named
618
+ // set, an external abort, or an elapsed --timeout cancels the
619
+ // in-flight held reads (via `requestStop`) and wakes the gap sleeps
620
+ // (via the runner's stop controller) instead of waiting out a full
621
+ // hold. The held read's own bound is additionally capped at the
622
+ // wait's remaining time budget, so a hold can never outlive the
623
+ // deadline.
624
+ const shouldStop =
625
+ hasMatch() || aborted() || now().getTime() >= deadline || namedAllTerminal()
626
+ const remoteRunner = createRemotePollRunner({
627
+ command: 'wait',
628
+ stderr: opts.stderr,
629
+ now,
630
+ sleep,
631
+ intervalMs: interval,
632
+ deadlineMs: deadline,
633
+ aborted,
634
+ shouldStop: () =>
635
+ hasMatch() || aborted() || now().getTime() >= deadline || namedAllTerminal(),
636
+ drain: 'quiesce',
637
+ })
638
+ const requestStop = (): void => remoteRunner.requestStop()
639
+
640
+ const launch = (stream: WaitStream): void => {
641
+ remoteRunner.launch(
642
+ stream.slug,
643
+ (readOpts) => pollBuild(stream, readOpts),
644
+ () => hasMatch() || namedAllTerminal(),
645
+ )
646
+ }
647
+
648
+ // An external abort (SIGINT) is a stop like any other: it cancels
649
+ // the in-flight held reads and wakes the gap sleeps.
650
+ opts.signal?.addEventListener('abort', requestStop, { once: true })
651
+
652
+ if (!shouldStop) {
653
+ for (const stream of streams.values()) launch(stream)
654
+
655
+ if (slugs.length === 0) {
656
+ // Discovery keeps its interval cadence, launching a new per-
657
+ // stream task for each newly discovered nonterminal build. A
658
+ // discovered build that already satisfies ends the wait. The
659
+ // launch loop stays unconditional, after the catch — exactly
660
+ // where the inline loop ran: streams registered before a
661
+ // mid-discovery throw still get tasks whose polls then run
662
+ // independently.
663
+ void remoteRunner.runDiscovery({
664
+ step: async (): Promise<void> => {
665
+ await discoverBuilds()
666
+ },
667
+ launchPending: (): void => {
668
+ for (const stream of streams.values()) launch(stream)
669
+ },
670
+ endCheck: () => hasMatch(),
671
+ })
672
+ }
572
673
  }
674
+
675
+ await remoteRunner.drain()
676
+ opts.signal?.removeEventListener('abort', requestStop)
573
677
  }
678
+
574
679
  if (hasMatch()) return finishSatisfied()
680
+ if (namedAllTerminal()) {
681
+ // Decision 5, loop form: every named build went terminal without
682
+ // satisfying anything. The local loop checks this after each tick;
683
+ // on the remote path the runner records only that a stop was
684
+ // requested — its `shouldStop`/`endCheck` callbacks re-derive the
685
+ // conditions live — so after the quiesce drain this decision re-reads
686
+ // the same live predicates (`hasMatch`, `namedAllTerminal`, `aborted`,
687
+ // the deadline) to pick the exit.
688
+ const terminal = pickTerminalRecord()
689
+ return finish(null, terminal.event, terminal.state)
690
+ }
575
691
  if (aborted()) {
576
692
  opts.stderr('ab wait: interrupted — no condition was satisfied before the signal')
577
693
  if (json) opts.stdout(JSON.stringify({ cursor: encodeCurrent() }))