@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
@@ -3,7 +3,7 @@ import { humanActor } from '../events/envelope'
3
3
  import type { RepositoryEvent } from '../events/repository'
4
4
  import { reduceDispatchStatus, type DispatchStatus } from '../kernel/dispatch-status'
5
5
  import type { BuildState } from '../kernel/reducer'
6
- import { scanUnclaimedObservations } from '../processes/harvest'
6
+ import { sampleUnclaimedObservationCount } from '../processes/harvest'
7
7
  import {
8
8
  controlHarvestRun,
9
9
  OperatorControlError,
@@ -120,6 +120,9 @@ export class DispatchFrontend {
120
120
  private readonly keyboard
121
121
  private child: DispatchChildHandle | undefined
122
122
  private repositorySeq = 0
123
+ /** Set only after a successful initial fold; every later `events()` call
124
+ * takes the delta path. */
125
+ private seeded = false
123
126
  private repositoryEvents: RepositoryEvent[] = []
124
127
  private dispatchStatus: DispatchStatus = reduceDispatchStatus([], this.runId)
125
128
  private eventRefreshTail: Promise<void> = Promise.resolve()
@@ -164,6 +167,36 @@ export class DispatchFrontend {
164
167
  * are retained for their existing replay reducers. */
165
168
  private async events(): Promise<RepositoryEvent[]> {
166
169
  const refresh = this.eventRefreshTail.then(async () => {
170
+ if (!this.seeded) {
171
+ // Initial fold (AUT-534): the whole-journal read the first poll used
172
+ // to pay is replaced by AUT-489's bounded read where that is provably
173
+ // equivalent. The bounded attempt is confined to the pre-launch
174
+ // window (`this.child === undefined`): before `launchChild` no
175
+ // run-scoped journal fact can carry `this.runId` (only the supervised
176
+ // child appends run-scoped facts with it), so every run-scoped event
177
+ // in the bounded subset is skipped by `reduceDispatchStatus` exactly
178
+ // as it would be in a full replay, and the dashboard filter keeps
179
+ // only durable types, which the subset carries in full. After launch
180
+ // a bounded subset cannot guarantee the child's `run-started` under a
181
+ // competing dispatcher's later anchor — dropping it would leave
182
+ // `effectiveConfig` unset and the first frame would never render —
183
+ // so a post-launch seed always takes the full read (the pre-AUT-534
184
+ // behavior). `once` mode's teardown fold runs after the child exits,
185
+ // so the same guard routes it to the full read.
186
+ const seed =
187
+ this.child === undefined
188
+ ? await this.opts.store.getRepoStateEvents(this.opts.repo).catch(() =>
189
+ // Bounded-read failure falls back to the exact pre-AUT-534
190
+ // full read within this same seed attempt.
191
+ this.opts.store.getRepoEvents(this.opts.repo, 0),
192
+ )
193
+ : await this.opts.store.getRepoEvents(this.opts.repo, 0)
194
+ this.repositorySeq = seed.at(-1)?.seq ?? 0
195
+ this.dispatchStatus = reduceDispatchStatus(seed, this.runId)
196
+ this.repositoryEvents.push(...seed.filter(isDashboardRepositoryEvent))
197
+ this.seeded = true
198
+ return
199
+ }
167
200
  const delta = await this.opts.store.getRepoEvents(this.opts.repo, this.repositorySeq)
168
201
  if (delta.length === 0) return
169
202
  this.repositorySeq = delta.at(-1)!.seq
@@ -295,8 +328,14 @@ export class DispatchFrontend {
295
328
  }
296
329
  const config = await this.loadConfig(ref.kind, ref.rev)
297
330
  try {
298
- const scan = await scanUnclaimedObservations(this.opts.store, this.opts.repo)
299
- this.observationCount = scan.observations.length
331
+ // Journal + digests (AUT-487): one journal read for the claimed set
332
+ // (this class's cached `repositoryEvents` is dashboard-filtered, so the
333
+ // claimed set needs the full journal, exactly as the old per-build scan
334
+ // read it) plus one repo-scoped digest read — the sample's store
335
+ // traffic stays flat as finished builds accumulate. A journal record
336
+ // that does not yet exist reads as an empty journal (AUT-524); the
337
+ // sample writes nothing.
338
+ this.observationCount = await sampleUnclaimedObservationCount(this.opts.store, this.opts.repo)
300
339
  } catch {
301
340
  // Before the first successful sample there is no factual zero to place in
302
341
  // a complete frame. Leave the prior frame untouched and retry on the next
@@ -677,6 +716,7 @@ export class DispatchFrontend {
677
716
  repo: this.opts.repo,
678
717
  user: buildControlUser(this.opts.env),
679
718
  setting: 'intake',
719
+ run: this.runId,
680
720
  })
681
721
  await this.report(`dispatcher intake ${event.enabled ? 'ON' : 'OFF'}`)
682
722
  await this.renderOnce()
@@ -688,6 +728,7 @@ export class DispatchFrontend {
688
728
  repo: this.opts.repo,
689
729
  user: buildControlUser(this.opts.env),
690
730
  setting: 'auto-merge-default',
731
+ run: this.runId,
691
732
  })
692
733
  await this.report(`dispatcher auto-merge default ${event.enabled ? 'ON' : 'OFF'}`)
693
734
  await this.renderOnce()
@@ -990,17 +1031,25 @@ export class DispatchFrontend {
990
1031
  await this.opts.store.appendRepo(this.opts.repo, {
991
1032
  actor,
992
1033
  type: 'dispatcher.intake-set',
993
- payload: { enabled: this.opts.intake },
1034
+ payload: { enabled: this.opts.intake, run: this.runId },
994
1035
  })
995
1036
  }
996
1037
  if (this.opts.defaultAutoMerge !== undefined) {
997
1038
  await this.opts.store.appendRepo(this.opts.repo, {
998
1039
  actor,
999
1040
  type: 'dispatcher.auto-merge-default-set',
1000
- payload: { enabled: this.opts.defaultAutoMerge },
1041
+ payload: { enabled: this.opts.defaultAutoMerge, run: this.runId },
1001
1042
  })
1002
1043
  }
1003
1044
 
1045
+ // Warm the initial fold before `launchChild` (AUT-534): the bounded seed
1046
+ // is only safe while `this.child === undefined`, so the one opportunity
1047
+ // to take it is here. A failed warm-up leaves the seed unset; the poll
1048
+ // loop's existing failure reporting covers the retry, which then takes
1049
+ // the full-read path post-launch. `once` mode gains no read — its single
1050
+ // fold runs from `finishPresentation()` after the child exits.
1051
+ if (!this.opts.once) await this.events().catch(() => {})
1052
+
1004
1053
  const options: DispatchChildOptions = {
1005
1054
  targetRepo: this.opts.checkout ?? this.opts.repo,
1006
1055
  storeRef: this.opts.storeRef,
@@ -28,13 +28,25 @@ import { tmpdir } from 'node:os'
28
28
  import { join, resolve } from 'node:path'
29
29
  import semver from 'semver'
30
30
  import { parseConfig } from '../config/load'
31
- import { DISPATCHER_CONFIG_ARTIFACT, LiveConfig, type ConfigSnapshot } from '../config/live'
31
+ import {
32
+ composeBuildConfig,
33
+ DISPATCHER_CONFIG_ARTIFACT,
34
+ LiveConfig,
35
+ type ConfigSnapshot,
36
+ } from '../config/live'
37
+ import {
38
+ isActionablePipelineFailure,
39
+ resolvePipelineSource,
40
+ type PipelineSourceFailure,
41
+ type PipelineSourceMeta,
42
+ } from '../config/pipeline-source'
32
43
  import { effectiveRuntimeReferences, roleKeyWarnings, SLUG_ROLE } from '../config/roles'
33
44
  import type { Config } from '../config/schema'
34
- import { loadPlugins } from '../plugins/load'
45
+ import { loadPlugins, pluginResolutionKind } from '../plugins/load'
35
46
  import type { PluginRegistry } from '../plugins/registry'
36
47
  import { materializePluginRuntimes } from '../plugins/runtimes'
37
- import { DISPATCHER, humanActor } from '../events/envelope'
48
+ import { DISPATCHER, KERNEL, humanActor } from '../events/envelope'
49
+ import type { AbEvent } from '../events/catalog'
38
50
  import type { RepositoryEventWrite } from '../events/repository'
39
51
  import { randomIds, randomUuids, type IdSource, type UuidSource } from '../ids'
40
52
  import { reduceDispatchSettings } from '../kernel/dispatch-settings'
@@ -100,15 +112,21 @@ import {
100
112
  type BuildExecutionHandle,
101
113
  } from '../ports/workspace/build-execution'
102
114
  import type { Exec } from '../ports/workspace/git-worktree'
103
- import { validateVercelGithubOrigin } from '../ports/workspace/vercel-sandbox'
104
115
  import {
105
116
  BUILD_EFFECTIVE_CONFIG_ARTIFACT,
106
117
  BUILD_RUNNER_DIAGNOSTIC_ARTIFACT,
107
118
  effectiveBuildConfigContent,
119
+ parseBuildConfigMetadata,
108
120
  parseDiagnostic,
121
+ parseEffectiveBuildConfig,
122
+ selectOpenWorkspace,
109
123
  } from '../processes/build-execution-state'
110
124
  import { HarvestRunner, type HarvestRunnerResult } from '../processes/harvest-runner'
111
- import { scanUnclaimedObservations, evaluateHarvestPressure } from '../processes/harvest'
125
+ import {
126
+ sampleUnclaimedObservationCount,
127
+ readRepoEventsIfRecorded,
128
+ evaluateHarvestPressureFromStore,
129
+ } from '../processes/harvest'
112
130
  import { classifyHarvestOutcome } from '../processes/harvest-execution-state'
113
131
  import type { HarvestExecution } from '../ports/workspace/harvest-execution'
114
132
  import {
@@ -154,8 +172,12 @@ const DEFAULT_INTERVAL_MS = 10_000
154
172
  const REPO_LEASE_TTL_MS = 60_000
155
173
  const REPO_LEASE_HEARTBEAT_MS = 20_000
156
174
  /** Repository artifact containing the schema-validated composed Config used by
157
- * one dispatch run. It is the frontend's only config source. */
158
- export const DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT = 'dispatcher-effective-config'
175
+ * one dispatch run. It is the frontend's only config source. Defined in
176
+ * `store/retention.ts` beside the other kind constants and re-exported here
177
+ * (the long-standing import path). */
178
+ export { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT } from '../store/retention'
179
+ // Local uses within this module.
180
+ import { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT as _effectiveConfigKind } from '../store/retention'
159
181
 
160
182
  /** JSON encoding in the config schema's declarative input shape. Parsed Config
161
183
  * has normalized `{steps, stepConfigs}` sections; flattening named step tables
@@ -196,8 +218,9 @@ function definedEnv(env: Record<string, string | undefined>): Record<string, str
196
218
  * the process-local display cache then polls dashboard-visible streams with
197
219
  * `getEvents(lastSeq)`, retaining abort cleanup until final completion and reusing unchanged
198
220
  * reductions/timing projections. Repository controls and Harvest are still
199
- * read fresh. The identical-frame check in `live.ts` makes an unchanged paint
200
- * cost zero terminal writes. */
221
+ * read fresh through the bounded journal read (AUT-489 subset; AUT-545). The
222
+ * identical-frame check in `live.ts` makes an unchanged paint cost zero
223
+ * terminal writes. */
201
224
  const DASHBOARD_POLL_MS = 500
202
225
 
203
226
  /** Dashboard repaint (not re-read) cadence in watch mode. A running step's
@@ -295,7 +318,13 @@ const DEFAULT_TIMERS: TimerScheduler = {
295
318
 
296
319
  export interface DispatchOpts {
297
320
  /** Repo the dispatcher serves (§12: one dispatcher per repo) — the cwd, or
298
- * in origin mode a private scratch root. Filesystem consumers only. */
321
+ * in origin mode a private scratch root. Filesystem consumers only.
322
+ * Callers without a checkout may pass a non-filesystem identity token
323
+ * instead (e.g. hosted-dispatcher's `'<hosted-dispatcher>'`): it is a
324
+ * caller-facing label, not a path, and origin-mode state resolution
325
+ * replaces it with the per-origin scratch root before any filesystem
326
+ * consumer — plugin loading, worktrees, exec cwd — runs (AUT-604). The
327
+ * token therefore never participates in cwd-relative disk resolution. */
299
328
  targetRepo: string
300
329
  /** Checkout-less origin mode (AUT-302): serve the repository at this origin
301
330
  * with no local checkout. CLI `--repository <origin>`, env `AB_REPOSITORY`.
@@ -433,23 +462,37 @@ async function defaultWire(
433
462
  // Origin mode: no checkout exists, so the provider's host git seams are
434
463
  // replaced by the injected origin and the forge's remote reads.
435
464
  const originMode = opts.repository !== undefined
436
- if (config.workspace.provider === 'vercel-sandbox') {
437
- if (config.forge !== 'github') {
438
- throw new Error('vercel-sandbox requires the builtin github forge')
439
- }
440
- if (!originMode) {
441
- const origin = await opts.exec(['git', 'remote', 'get-url', 'origin'], {
442
- cwd: opts.targetRepo,
443
- })
444
- if (origin.exitCode !== 0) throw new Error('vercel-sandbox requires a readable Git origin')
445
- validateVercelGithubOrigin(origin.stdout.trim())
446
- }
447
- if (githubTokenFromEnv(opts.env) === undefined) {
465
+ // Dispatch preflight is capability-driven (AUT-516): forge support, origin
466
+ // validation, and required environment all read the selected provider's
467
+ // registration declarations — builtin and plugin alike — instead of naming
468
+ // a provider.
469
+ const workspaceCaps = plugins.workspaceProviders.get(config.workspace.provider)?.capabilities
470
+ if (workspaceCaps?.supportedForges !== undefined) {
471
+ if (!workspaceCaps.supportedForges.includes(config.forge)) {
448
472
  throw new Error(
449
- 'vercel-sandbox publication requires GITHUB_TOKEN or GH_TOKEN in the dispatcher environment',
473
+ workspaceCaps.forgeDispatchMessage ??
474
+ `workspace provider "${config.workspace.provider}" does not support forge "${config.forge}"`,
450
475
  )
451
476
  }
452
477
  }
478
+ if (workspaceCaps?.validateOrigin !== undefined && !originMode) {
479
+ const origin = await opts.exec(['git', 'remote', 'get-url', 'origin'], {
480
+ cwd: opts.targetRepo,
481
+ })
482
+ if (origin.exitCode !== 0)
483
+ throw new Error(
484
+ workspaceCaps.originReadFailureMessage ??
485
+ `workspace provider "${config.workspace.provider}" requires a readable Git origin`,
486
+ )
487
+ workspaceCaps.validateOrigin(origin.stdout.trim())
488
+ }
489
+ for (const group of workspaceCaps?.requiredEnv ?? []) {
490
+ if (group.dispatchMessage === undefined) continue
491
+ const satisfied = group.alternatives.some((names) =>
492
+ names.every((name) => opts.env[name] !== undefined && opts.env[name] !== ''),
493
+ )
494
+ if (!satisfied) throw new Error(group.dispatchMessage)
495
+ }
453
496
  // The builtin GitHub forge resolves its credential lazily on its first
454
497
  // request (GITHUB_TOKEN, GH_TOKEN, then the gh CLI login) and names the
455
498
  // miss reason on any request that had to go out anonymously; nothing is
@@ -505,6 +548,7 @@ async function defaultWire(
505
548
  sandboxSetupCommand: config.commands.setup,
506
549
  sandboxRoot: resolve(join(opened.localStateRoot, 'orchestrator-sandboxes')),
507
550
  sandboxEnvironmentVariables: config.orchestrator.sandbox.environmentVariables,
551
+ orchestratorSandboxEnabled: config.orchestrator.enabled,
508
552
  ...providerSeams,
509
553
  ...(opened.token !== undefined ? { storeToken: opened.token } : {}),
510
554
  })
@@ -779,22 +823,198 @@ class DispatchLoop {
779
823
  return this.liveConfig.current()
780
824
  }
781
825
 
826
+ /** Resolve this build's pinned pipeline source (SPEC §16.1). Never throws:
827
+ * every failure — missing build record, absent forge capability, unreadable
828
+ * branch/base, malformed TOML, store read failure — degrades to `source:
829
+ * undefined` with the failed attempts classified, never failing the
830
+ * deposit. The failures feed the caller's fallback diagnostic. */
831
+ private async resolveBuildPipeline(slug: string): Promise<{
832
+ source: Awaited<ReturnType<typeof resolvePipelineSource>>
833
+ failures: PipelineSourceFailure[]
834
+ events: AbEvent[]
835
+ }> {
836
+ const failures: PipelineSourceFailure[] = []
837
+ try {
838
+ // The workspace event carries the branch the build was provisioned on;
839
+ // the record's `branch` (when present) is the same fact. Using the event
840
+ // keeps this read to the events the caller already needs and avoids an
841
+ // extra build-record lookup on the hot launch path.
842
+ const events = await this.wiring.store.getEvents(slug)
843
+ const workspace = selectOpenWorkspace(events)
844
+ const source = await resolvePipelineSource({
845
+ slug,
846
+ record: { ...(workspace !== null ? { branch: workspace.branch } : {}) },
847
+ events,
848
+ mode: this.opts.repository !== undefined ? 'origin' : 'checkout',
849
+ forge: this.wiring.forge,
850
+ checkout: this.opts.targetRepo,
851
+ exec: this.opts.exec,
852
+ ...(workspace !== null ? { workspacePath: workspace.path } : {}),
853
+ onFailure: (failure) => failures.push(failure),
854
+ })
855
+ return { source, failures, events }
856
+ } catch (error) {
857
+ failures.push({
858
+ kind: 'store',
859
+ detail: `build-event read failed: ${error instanceof Error ? error.message : String(error)}`,
860
+ })
861
+ return { source: undefined, failures, events: [] }
862
+ }
863
+ }
864
+
782
865
  /** Publish the exact composed snapshot into one build-owned namespace. The
783
- * child samples only this artifact and never receives config over IPC. */
866
+ * child samples only this artifact and never receives config over IPC.
867
+ *
868
+ * Build-owned sections (verify/finalize/commands/workspace) come from the
869
+ * build's own branch at its recorded pipeline-source commit; every other
870
+ * section comes from the live dispatcher snapshot. The source commit travels
871
+ * in the artifact metadata so status can report which autobuild.toml the
872
+ * build runs under (SPEC §16.1).
873
+ *
874
+ * A failed resolution never rewrites a pinned pipeline: when the build
875
+ * already has a deposit, its build-owned sections are reused verbatim (the
876
+ * reload path's rule), because depositing the live base-branch snapshot on a
877
+ * transient forge/store/git failure is exactly the AUT-366 poisoning. Only
878
+ * a build with no pinned artifact at all (legacy build, or a first deposit
879
+ * during an outage) takes the live snapshot. An actionable degradation — a
880
+ * missing forge capability, a build-branch source that resolved but cannot
881
+ * be read or parsed, a store failure — files a build-scoped observation
882
+ * naming the failed attempts; the documented fallbacks (an unpublished
883
+ * branch, an unreadable legacy base) stay quiet. */
784
884
  private async publishBuildConfig(slug: string, snapshot = this.currentConfig()): Promise<void> {
885
+ const { source, failures, events } = await this.resolveBuildPipeline(slug)
886
+ if (source !== undefined) {
887
+ await this.depositBuildConfig(
888
+ slug,
889
+ snapshot,
890
+ composeBuildConfig(source.config, snapshot.config),
891
+ source.meta,
892
+ )
893
+ return
894
+ }
895
+ let reused: PipelineSourceMeta | undefined
896
+ try {
897
+ const artifact = await this.wiring.store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
898
+ if (artifact !== null) {
899
+ const pinned = parseEffectiveBuildConfig(artifact)
900
+ reused = parseBuildConfigMetadata(artifact).pipelineSource
901
+ await this.depositBuildConfig(
902
+ slug,
903
+ snapshot,
904
+ composeBuildConfig(pinned, snapshot.config),
905
+ reused,
906
+ )
907
+ } else {
908
+ await this.depositBuildConfig(slug, snapshot, snapshot.config)
909
+ }
910
+ } catch {
911
+ // Even the artifact read failed; the live snapshot deposit is the only
912
+ // option left that keeps the launch alive.
913
+ await this.depositBuildConfig(slug, snapshot, snapshot.config)
914
+ }
915
+ await this.filePipelineFallbackObservation(slug, reused, failures, events)
916
+ }
917
+
918
+ /** File a build-scoped observation when a deposit hit an actionable
919
+ * pipeline-source failure (SPEC §16.1): the operator must know the deposit
920
+ * degraded to the last good pipeline or the live snapshot, and why. The
921
+ * documented fallbacks (unpublished branch, unreadable legacy base) are
922
+ * normal resolution order and stay quiet. A diagnostic, never a launch
923
+ * dependency. */
924
+ private async filePipelineFallbackObservation(
925
+ slug: string,
926
+ reused: PipelineSourceMeta | undefined,
927
+ failures: PipelineSourceFailure[],
928
+ events: readonly AbEvent[],
929
+ ): Promise<void> {
930
+ const actionable = failures.filter(isActionablePipelineFailure)
931
+ if (actionable.length === 0) return
932
+ const degradedTo =
933
+ reused !== undefined
934
+ ? 'kept the last pinned pipeline' +
935
+ (reused.commit !== undefined ? ` at ${reused.ref}@${reused.commit}` : ` (${reused.ref})`)
936
+ : 'deposited the live dispatcher snapshot (legacy fallback)'
937
+ const summary =
938
+ `pipeline-source resolution failed; ${degradedTo}. ` +
939
+ `Failed attempts: ${
940
+ actionable.map((failure) => `${failure.kind}: ${failure.detail}`).join('; ') ||
941
+ 'none recorded'
942
+ }`
943
+ // Deduplicated against the build's log: a parked build re-attaches every
944
+ // sweep, and an outage must not harvest one identical diagnostic per tick.
945
+ if (
946
+ events.some(
947
+ (event) => event.type === 'observation.recorded' && event.payload.summary === summary,
948
+ )
949
+ ) {
950
+ return
951
+ }
952
+ try {
953
+ await this.wiring.store.append(slug, {
954
+ // The catalog admits kernel/agent actors for observations (§8.2); the
955
+ // dispatcher's system-side diagnostic rides the kernel actor, like the
956
+ // finalize-failure and auto-merge-deferral observations.
957
+ actor: KERNEL,
958
+ type: 'observation.recorded',
959
+ payload: {
960
+ id: this.wiring.ids('obs'),
961
+ kind: 'followup',
962
+ summary,
963
+ files: ['autobuild.toml'],
964
+ },
965
+ })
966
+ } catch {
967
+ // The build's pipeline is already deposited; the observation is best-effort.
968
+ }
969
+ }
970
+
971
+ private async depositBuildConfig(
972
+ slug: string,
973
+ snapshot: ConfigSnapshot,
974
+ config: Config,
975
+ pipelineSource?: PipelineSourceMeta,
976
+ ): Promise<void> {
785
977
  await this.wiring.store.putArtifact(slug, {
786
978
  kind: BUILD_EFFECTIVE_CONFIG_ARTIFACT,
787
- content: effectiveBuildConfigContent(snapshot.config),
979
+ content: effectiveBuildConfigContent(config),
788
980
  metadata: {
789
981
  revision: snapshot.revision,
790
982
  run: this.runId,
983
+ ...(pipelineSource !== undefined ? { pipelineSource } : {}),
791
984
  },
792
985
  })
793
986
  }
794
987
 
988
+ /** Re-deposit deployment-owned sections to active builds on a reload. The
989
+ * build-owned sections are reused verbatim from the build's last deposit, so
990
+ * a base-branch reload can never retarget an in-flight build's pipeline; a
991
+ * build picks up pipeline changes only from its own branch at its next
992
+ * launch (SPEC §16.1). */
795
993
  private async publishActiveBuildConfigs(snapshot: ConfigSnapshot): Promise<void> {
796
994
  await Promise.all(
797
- [...this.activeBuildRuns.keys()].map((slug) => this.publishBuildConfig(slug, snapshot)),
995
+ [...this.activeBuildRuns.keys()].map(async (slug) => {
996
+ const artifact = await this.wiring.store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
997
+ if (artifact === null) {
998
+ await this.publishBuildConfig(slug, snapshot)
999
+ return
1000
+ }
1001
+ let existing: Config
1002
+ try {
1003
+ existing = parseEffectiveBuildConfig(artifact)
1004
+ } catch {
1005
+ // A corrupt or pre-pin artifact cannot pin a pipeline; fall back to
1006
+ // the launch-path resolution instead of failing the reload.
1007
+ await this.publishBuildConfig(slug, snapshot)
1008
+ return
1009
+ }
1010
+ const meta = parseBuildConfigMetadata(artifact)
1011
+ await this.depositBuildConfig(
1012
+ slug,
1013
+ snapshot,
1014
+ composeBuildConfig(existing, snapshot.config),
1015
+ meta.pipelineSource,
1016
+ )
1017
+ }),
798
1018
  )
799
1019
  }
800
1020
 
@@ -805,7 +1025,10 @@ class DispatchLoop {
805
1025
  private async refreshConfig(): Promise<void> {
806
1026
  if (this.opts.once === true) return
807
1027
  // Origin mode reloads from the forge: autobuild.toml at the CURRENT
808
- // effective base branch (hot — a rename converges within two ticks).
1028
+ // effective base branch (hot — a rename converges within two ticks). This
1029
+ // read feeds the dispatcher's own snapshot; a reload re-deposits only the
1030
+ // deployment-owned sections to active builds (build-owned pipeline
1031
+ // sections stay pinned to each build's own branch, SPEC §16.1).
809
1032
  // Restart-required changes keep startup-built adapters and file the
810
1033
  // existing restart-required observation (LiveConfig semantics).
811
1034
  const outcome =
@@ -861,7 +1084,8 @@ class DispatchLoop {
861
1084
  }
862
1085
 
863
1086
  private async readDispatchSettings(): Promise<ReturnType<typeof reduceDispatchSettings>> {
864
- const events = await this.wiring.store.getRepoEvents(this.repoIdentity)
1087
+ // Bounded read (AUT-489): the settings reducer consumes durable types only.
1088
+ const events = await this.wiring.store.getRepoStateEvents(this.repoIdentity)
865
1089
  return reduceDispatchSettings(events)
866
1090
  }
867
1091
 
@@ -884,12 +1108,17 @@ class DispatchLoop {
884
1108
  })
885
1109
 
886
1110
  // Unclaimed observations are display-only and sampled once per interactive
887
- // dispatcher tick. A failed scan must neither fail dispatch nor replace
888
- // the last complete measurement with a fabricated zero.
1111
+ // dispatcher tick from the journal and one repo-scoped digest read
1112
+ // (AUT-487) — flat in the finished-build count. A journal record that
1113
+ // does not yet exist reads as an empty journal (AUT-524); the sample
1114
+ // writes nothing. Any other failed sample must neither fail dispatch nor
1115
+ // replace the last complete measurement with a fabricated zero.
889
1116
  if (this.dashboard) {
890
1117
  try {
891
- const scan = await scanUnclaimedObservations(this.wiring.store, this.repoIdentity)
892
- this.observationCount = scan.observations.length
1118
+ this.observationCount = await sampleUnclaimedObservationCount(
1119
+ this.wiring.store,
1120
+ this.repoIdentity,
1121
+ )
893
1122
  } catch {
894
1123
  // Display-only sampling failures retain the last factual count and
895
1124
  // retry on the next tick; they are not dashboard failures.
@@ -1175,6 +1404,7 @@ class DispatchLoop {
1175
1404
  repo: this.repoIdentity,
1176
1405
  user: buildControlUser(this.opts.env),
1177
1406
  setting: 'intake',
1407
+ run: this.runId,
1178
1408
  })
1179
1409
  this.say(`dispatcher intake ${event.enabled ? 'ON' : 'OFF'}`)
1180
1410
  await this.renderOnce()
@@ -1439,6 +1669,7 @@ class DispatchLoop {
1439
1669
  repo: this.repoIdentity,
1440
1670
  user: buildControlUser(this.opts.env),
1441
1671
  setting: 'auto-merge-default',
1672
+ run: this.runId,
1442
1673
  })
1443
1674
  this.say(`dispatcher auto-merge default ${event.enabled ? 'ON' : 'OFF'}`)
1444
1675
  await this.renderOnce()
@@ -2056,7 +2287,9 @@ class DispatchLoop {
2056
2287
  // once provisioned, so a threshold crossing between gate and guest scan
2057
2288
  // is never suppressed by this check.
2058
2289
  const record = await store.getRepo(repo)
2059
- const events = record === null ? [] : await store.getRepoEvents(repo)
2290
+ // Bounded read (AUT-489): the harvest control decision reduces durable
2291
+ // types only.
2292
+ const events = record === null ? [] : await store.getRepoStateEvents(repo)
2060
2293
  const state = reduceHarvest(events)
2061
2294
  const control = decideHarvestControl(state, this.maxHarvestRecoveryAttempts)
2062
2295
  const resumePending =
@@ -2066,8 +2299,16 @@ class DispatchLoop {
2066
2299
  control.kind === 'request-recovery' ||
2067
2300
  control.kind === 'exhaust-recovery'
2068
2301
  if (!resumePending) {
2069
- const scan = await scanUnclaimedObservations(store, repo)
2070
- const pressure = evaluateHarvestPressure(scan, this.currentConfig().config.policy)
2302
+ // Flat-cost gate (AUT-521): the journal read above plus one
2303
+ // repo-scoped digest batch read — no per-build history reads, so the
2304
+ // per-evaluation cost no longer grows with accumulated finished
2305
+ // builds. The guest rescans authoritatively once provisioned.
2306
+ const pressure = await evaluateHarvestPressureFromStore({
2307
+ store,
2308
+ repo,
2309
+ harvestEvents: events,
2310
+ policy: this.currentConfig().config.policy,
2311
+ })
2071
2312
  if (pressure.trigger === undefined) return
2072
2313
  }
2073
2314
 
@@ -2165,7 +2406,8 @@ class DispatchLoop {
2165
2406
  startedSeq: number,
2166
2407
  ): Promise<HarvestRunnerResult> {
2167
2408
  const { store } = this.wiring
2168
- const events = await store.getRepoEvents(repo)
2409
+ // Bounded read (AUT-489): classifyHarvestOutcome reduces harvest facts only.
2410
+ const events = await store.getRepoStateEvents(repo)
2169
2411
  const record = await store.getRepo(repo)
2170
2412
  return classifyHarvestOutcome(events, {
2171
2413
  executionStartedSeq: startedSeq,
@@ -2839,9 +3081,14 @@ class DispatchLoop {
2839
3081
  configSnapshot.config,
2840
3082
  configSnapshot.revision,
2841
3083
  )
2842
- const repoRecord = await this.wiring.store.getRepo(this.repoIdentity)
2843
- const repositoryEvents =
2844
- repoRecord === null ? [] : await this.wiring.store.getRepoEvents(this.repoIdentity)
3084
+ // Bounded read (AUT-489, AUT-545): every repository consumer of this frame —
3085
+ // projectRepositoryHarvest (reduceHarvest plus the harvest-only raw scans in
3086
+ // projectHarvestRun/escalationAttentionDismissed) and reduceDispatchSettings —
3087
+ // reduces durable types only, so the AUT-489 subset is replay-equivalent with
3088
+ // no anchor dependence (anchor analysis recorded; the frontend seed needed one
3089
+ // only because reduceDispatchStatus folds here too — it does not here).
3090
+ // Missing journal record stays a read-only empty journal (AUT-524).
3091
+ const repositoryEvents = await readRepoEventsIfRecorded(this.wiring.store, this.repoIdentity)
2845
3092
 
2846
3093
  // Action-triggered and timer refreshes share the cache but may finish their
2847
3094
  // repository reads out of order. Never let an older build snapshot replace
@@ -3502,10 +3749,23 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3502
3749
  `origin-mode dispatch requires the builtin github forge, but the fetched autobuild.toml selects ${JSON.stringify(config.forge)}`,
3503
3750
  )
3504
3751
  }
3505
- if (config.plugins !== undefined && config.plugins.length > 0) {
3752
+ // Origin mode has no checkout, so repository-path plugin specifiers
3753
+ // (relative, absolute, file:) cannot resolve — reject them with the
3754
+ // existing remediation tone before any wiring. Bare package specifiers
3755
+ // are accepted: the shared loadPlugins below resolves them from the
3756
+ // dispatcher's own installation when the scratch root lacks them
3757
+ // (AUT-517), which is exactly "resolvable from the dispatcher's own
3758
+ // installation". The caller-supplied `targetRepo` is an identity token,
3759
+ // not a path, and never reaches plugin resolution: the candidate roots
3760
+ // are the scratch checkout and the installation, per the loadPlugins
3761
+ // call below (AUT-604).
3762
+ const repoPathPlugin = (config.plugins ?? []).find(
3763
+ (moduleSpecifier) => pluginResolutionKind(moduleSpecifier) === 'repo-path',
3764
+ )
3765
+ if (repoPathPlugin !== undefined) {
3506
3766
  throw new Error(
3507
- 'origin-mode dispatch cannot load configured plugins: plugin code is checkout-relative ' +
3508
- 'and there is no checkout. Remove [plugins] from the base branch autobuild.toml or run from a checkout',
3767
+ `origin-mode dispatch cannot load the repository-path plugin ${JSON.stringify(repoPathPlugin)}: ` +
3768
+ 'plugin code is checkout-relative and there is no checkout. Remove repository-path entries from [plugins] in the base branch autobuild.toml or run from a checkout',
3509
3769
  )
3510
3770
  }
3511
3771
  } else {
@@ -3524,8 +3784,22 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3524
3784
  }
3525
3785
  // Configured plugin code is trusted like configured shell commands, but it
3526
3786
  // must resolve, evaluate, validate, and register before production wiring
3527
- // opens a store, claims a ticket, or launches a runner.
3528
- const plugins = await loadPlugins(config.plugins, resolvedOpts.targetRepo)
3787
+ // opens a store, claims a ticket, or launches a runner. Skipped-module
3788
+ // notices (builtin duplicate-skip, guest tolerance) surface on dispatch's
3789
+ // own stderr channel.
3790
+ // AUT-604: origin mode's repository candidate root for plugin loading is
3791
+ // the per-origin scratch checkout, never the caller-supplied `targetRepo`
3792
+ // token (e.g. hosted-dispatcher's `'<hosted-dispatcher>'`) — that token is
3793
+ // a caller-facing identity label which `resolveOriginModeState` replaced
3794
+ // with `state.checkout` above, so it never participates in cwd-relative
3795
+ // disk resolution. `state.checkout` equals `resolvedOpts.targetRepo` in
3796
+ // both modes, so passing it as `packageRoot` is behavior-preserving; the
3797
+ // installation candidate (AUT-517) and the disk-first EROFS gate (AUT-587)
3798
+ // are unchanged.
3799
+ const plugins = await loadPlugins(config.plugins, resolvedOpts.targetRepo, {
3800
+ packageRoot: state.checkout,
3801
+ onNotice: (line) => resolvedOpts.stderr(line),
3802
+ })
3529
3803
  // Validate the selector against the complete catalog before either custom
3530
3804
  // wiring or production wiring can open state or perform side effects.
3531
3805
  resolveForgeRegistration(config.forge, plugins)
@@ -3580,7 +3854,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3580
3854
  metadata: { restartRequired: [...restartRequired], effectiveChanged },
3581
3855
  },
3582
3856
  {
3583
- kind: DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT,
3857
+ kind: _effectiveConfigKind,
3584
3858
  content: effectiveConfigContent(effectiveConfig),
3585
3859
  metadata: { run: runId, effectiveChanged },
3586
3860
  },
@@ -3636,7 +3910,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3636
3910
  state.repo,
3637
3911
  [
3638
3912
  {
3639
- kind: DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT,
3913
+ kind: _effectiveConfigKind,
3640
3914
  content: effectiveConfigContent(config),
3641
3915
  metadata: { run: runId, revision: 0 },
3642
3916
  },