@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
@@ -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 {
@@ -122,6 +140,7 @@ import {
122
140
  type LaunchRunnerResult,
123
141
  type TickReport,
124
142
  } from '../processes/dispatcher'
143
+ import { RepoViewStore } from '../processes/repo-view'
125
144
  import {
126
145
  BuildControlError,
127
146
  buildControlUser,
@@ -154,8 +173,12 @@ const DEFAULT_INTERVAL_MS = 10_000
154
173
  const REPO_LEASE_TTL_MS = 60_000
155
174
  const REPO_LEASE_HEARTBEAT_MS = 20_000
156
175
  /** 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'
176
+ * one dispatch run. It is the frontend's only config source. Defined in
177
+ * `store/retention.ts` beside the other kind constants and re-exported here
178
+ * (the long-standing import path). */
179
+ export { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT } from '../store/retention'
180
+ // Local uses within this module.
181
+ import { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT as _effectiveConfigKind } from '../store/retention'
159
182
 
160
183
  /** JSON encoding in the config schema's declarative input shape. Parsed Config
161
184
  * has normalized `{steps, stepConfigs}` sections; flattening named step tables
@@ -196,8 +219,9 @@ function definedEnv(env: Record<string, string | undefined>): Record<string, str
196
219
  * the process-local display cache then polls dashboard-visible streams with
197
220
  * `getEvents(lastSeq)`, retaining abort cleanup until final completion and reusing unchanged
198
221
  * 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. */
222
+ * read fresh through the bounded journal read (AUT-489 subset; AUT-545). The
223
+ * identical-frame check in `live.ts` makes an unchanged paint cost zero
224
+ * terminal writes. */
201
225
  const DASHBOARD_POLL_MS = 500
202
226
 
203
227
  /** Dashboard repaint (not re-read) cadence in watch mode. A running step's
@@ -295,7 +319,13 @@ const DEFAULT_TIMERS: TimerScheduler = {
295
319
 
296
320
  export interface DispatchOpts {
297
321
  /** Repo the dispatcher serves (§12: one dispatcher per repo) — the cwd, or
298
- * in origin mode a private scratch root. Filesystem consumers only. */
322
+ * in origin mode a private scratch root. Filesystem consumers only.
323
+ * Callers without a checkout may pass a non-filesystem identity token
324
+ * instead (e.g. hosted-dispatcher's `'<hosted-dispatcher>'`): it is a
325
+ * caller-facing label, not a path, and origin-mode state resolution
326
+ * replaces it with the per-origin scratch root before any filesystem
327
+ * consumer — plugin loading, worktrees, exec cwd — runs (AUT-604). The
328
+ * token therefore never participates in cwd-relative disk resolution. */
299
329
  targetRepo: string
300
330
  /** Checkout-less origin mode (AUT-302): serve the repository at this origin
301
331
  * with no local checkout. CLI `--repository <origin>`, env `AB_REPOSITORY`.
@@ -433,23 +463,37 @@ async function defaultWire(
433
463
  // Origin mode: no checkout exists, so the provider's host git seams are
434
464
  // replaced by the injected origin and the forge's remote reads.
435
465
  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) {
466
+ // Dispatch preflight is capability-driven (AUT-516): forge support, origin
467
+ // validation, and required environment all read the selected provider's
468
+ // registration declarations — builtin and plugin alike — instead of naming
469
+ // a provider.
470
+ const workspaceCaps = plugins.workspaceProviders.get(config.workspace.provider)?.capabilities
471
+ if (workspaceCaps?.supportedForges !== undefined) {
472
+ if (!workspaceCaps.supportedForges.includes(config.forge)) {
448
473
  throw new Error(
449
- 'vercel-sandbox publication requires GITHUB_TOKEN or GH_TOKEN in the dispatcher environment',
474
+ workspaceCaps.forgeDispatchMessage ??
475
+ `workspace provider "${config.workspace.provider}" does not support forge "${config.forge}"`,
450
476
  )
451
477
  }
452
478
  }
479
+ if (workspaceCaps?.validateOrigin !== undefined && !originMode) {
480
+ const origin = await opts.exec(['git', 'remote', 'get-url', 'origin'], {
481
+ cwd: opts.targetRepo,
482
+ })
483
+ if (origin.exitCode !== 0)
484
+ throw new Error(
485
+ workspaceCaps.originReadFailureMessage ??
486
+ `workspace provider "${config.workspace.provider}" requires a readable Git origin`,
487
+ )
488
+ workspaceCaps.validateOrigin(origin.stdout.trim())
489
+ }
490
+ for (const group of workspaceCaps?.requiredEnv ?? []) {
491
+ if (group.dispatchMessage === undefined) continue
492
+ const satisfied = group.alternatives.some((names) =>
493
+ names.every((name) => opts.env[name] !== undefined && opts.env[name] !== ''),
494
+ )
495
+ if (!satisfied) throw new Error(group.dispatchMessage)
496
+ }
453
497
  // The builtin GitHub forge resolves its credential lazily on its first
454
498
  // request (GITHUB_TOKEN, GH_TOKEN, then the gh CLI login) and names the
455
499
  // miss reason on any request that had to go out anonymously; nothing is
@@ -505,6 +549,7 @@ async function defaultWire(
505
549
  sandboxSetupCommand: config.commands.setup,
506
550
  sandboxRoot: resolve(join(opened.localStateRoot, 'orchestrator-sandboxes')),
507
551
  sandboxEnvironmentVariables: config.orchestrator.sandbox.environmentVariables,
552
+ orchestratorSandboxEnabled: config.orchestrator.enabled,
508
553
  ...providerSeams,
509
554
  ...(opened.token !== undefined ? { storeToken: opened.token } : {}),
510
555
  })
@@ -553,6 +598,8 @@ interface ActiveBuildExecution {
553
598
  * running, so the completion chain must append no `execution.ended`, release
554
599
  * no lease, settle no publication, and record no wait failure. */
555
600
  detaching?: boolean
601
+ /** A wait failure being recorded; teardown lets it finish rather than drop it. */
602
+ recording?: Promise<void>
556
603
  }
557
604
 
558
605
  /** The dispatch loop owns deterministic decisions and supervises one
@@ -560,6 +607,10 @@ interface ActiveBuildExecution {
560
607
  * Store; child completion is liveness evidence used for reaping/single-flight. */
561
608
  class DispatchLoop {
562
609
  private readonly dispatcher: Dispatcher
610
+ /** The incremental repository view (AUT-647) the dispatcher and this
611
+ * frontend's tick-path journal reads and writes share. Resident for a
612
+ * watch loop; `--once` builds it cold for its single tick. */
613
+ private readonly repoView: RepoViewStore
563
614
  private readonly host = hostname()
564
615
  private readonly maxHarvestRecoveryAttempts = DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS
565
616
  /** In-flight build and harvest runs (fire-and-forget) — awaited before a
@@ -705,6 +756,8 @@ class DispatchLoop {
705
756
  private readonly liveConfig: LiveConfig,
706
757
  private readonly wiring: DispatchWiring,
707
758
  private readonly opts: DispatchOpts,
759
+ /** The view `abDispatch` already used for its lifecycle appends. */
760
+ view?: RepoViewStore,
708
761
  ) {
709
762
  const config = liveConfig.current().config
710
763
  // Minted exactly once per invocation: `wiring.ids` is `randomIds()` and a
@@ -741,8 +794,16 @@ class DispatchLoop {
741
794
  return result.text
742
795
  }
743
796
 
797
+ // `abDispatch` hands over a view it already used for the lifecycle appends,
798
+ // so those rows are folded, not re-read, and one flush covers them all.
799
+ this.repoView =
800
+ view ??
801
+ new RepoViewStore(wiring.store, {
802
+ repo: this.repoIdentity,
803
+ resident: opts.once !== true,
804
+ })
744
805
  this.dispatcher = new Dispatcher({
745
- store: wiring.store,
806
+ store: this.repoView,
746
807
  tickets: wiring.tickets,
747
808
  workspaces: wiring.workspaces,
748
809
  ...(wiring.retiredWorkspaces === undefined
@@ -779,33 +840,212 @@ class DispatchLoop {
779
840
  return this.liveConfig.current()
780
841
  }
781
842
 
843
+ /** Resolve this build's pinned pipeline source (SPEC §16.1). Never throws:
844
+ * every failure — missing build record, absent forge capability, unreadable
845
+ * branch/base, malformed TOML, store read failure — degrades to `source:
846
+ * undefined` with the failed attempts classified, never failing the
847
+ * deposit. The failures feed the caller's fallback diagnostic. */
848
+ private async resolveBuildPipeline(slug: string): Promise<{
849
+ source: Awaited<ReturnType<typeof resolvePipelineSource>>
850
+ failures: PipelineSourceFailure[]
851
+ events: AbEvent[]
852
+ }> {
853
+ const failures: PipelineSourceFailure[] = []
854
+ try {
855
+ // The workspace event carries the branch the build was provisioned on;
856
+ // the record's `branch` (when present) is the same fact. Using the event
857
+ // keeps this read to the events the caller already needs and avoids an
858
+ // extra build-record lookup on the hot launch path.
859
+ const events = await this.repoView.getEvents(slug)
860
+ const workspace = selectOpenWorkspace(events)
861
+ const source = await resolvePipelineSource({
862
+ slug,
863
+ record: { ...(workspace !== null ? { branch: workspace.branch } : {}) },
864
+ events,
865
+ mode: this.opts.repository !== undefined ? 'origin' : 'checkout',
866
+ forge: this.wiring.forge,
867
+ checkout: this.opts.targetRepo,
868
+ exec: this.opts.exec,
869
+ ...(workspace !== null ? { workspacePath: workspace.path } : {}),
870
+ onFailure: (failure) => failures.push(failure),
871
+ })
872
+ return { source, failures, events }
873
+ } catch (error) {
874
+ failures.push({
875
+ kind: 'store',
876
+ detail: `build-event read failed: ${error instanceof Error ? error.message : String(error)}`,
877
+ })
878
+ return { source: undefined, failures, events: [] }
879
+ }
880
+ }
881
+
782
882
  /** Publish the exact composed snapshot into one build-owned namespace. The
783
- * child samples only this artifact and never receives config over IPC. */
883
+ * child samples only this artifact and never receives config over IPC.
884
+ *
885
+ * Build-owned sections (verify/finalize/commands/workspace) come from the
886
+ * build's own branch at its recorded pipeline-source commit; every other
887
+ * section comes from the live dispatcher snapshot. The source commit travels
888
+ * in the artifact metadata so status can report which autobuild.toml the
889
+ * build runs under (SPEC §16.1).
890
+ *
891
+ * A failed resolution never rewrites a pinned pipeline: when the build
892
+ * already has a deposit, its build-owned sections are reused verbatim (the
893
+ * reload path's rule), because depositing the live base-branch snapshot on a
894
+ * transient forge/store/git failure is exactly the AUT-366 poisoning. Only
895
+ * a build with no pinned artifact at all (legacy build, or a first deposit
896
+ * during an outage) takes the live snapshot. An actionable degradation — a
897
+ * missing forge capability, a build-branch source that resolved but cannot
898
+ * be read or parsed, a store failure — files a build-scoped observation
899
+ * naming the failed attempts; the documented fallbacks (an unpublished
900
+ * branch, an unreadable legacy base) stay quiet. */
784
901
  private async publishBuildConfig(slug: string, snapshot = this.currentConfig()): Promise<void> {
902
+ const { source, failures, events } = await this.resolveBuildPipeline(slug)
903
+ if (source !== undefined) {
904
+ await this.depositBuildConfig(
905
+ slug,
906
+ snapshot,
907
+ composeBuildConfig(source.config, snapshot.config),
908
+ source.meta,
909
+ )
910
+ return
911
+ }
912
+ let reused: PipelineSourceMeta | undefined
913
+ try {
914
+ const artifact = await this.wiring.store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
915
+ if (artifact !== null) {
916
+ const pinned = parseEffectiveBuildConfig(artifact)
917
+ reused = parseBuildConfigMetadata(artifact).pipelineSource
918
+ await this.depositBuildConfig(
919
+ slug,
920
+ snapshot,
921
+ composeBuildConfig(pinned, snapshot.config),
922
+ reused,
923
+ )
924
+ } else {
925
+ await this.depositBuildConfig(slug, snapshot, snapshot.config)
926
+ }
927
+ } catch {
928
+ // Even the artifact read failed; the live snapshot deposit is the only
929
+ // option left that keeps the launch alive.
930
+ await this.depositBuildConfig(slug, snapshot, snapshot.config)
931
+ }
932
+ await this.filePipelineFallbackObservation(slug, reused, failures, events)
933
+ }
934
+
935
+ /** File a build-scoped observation when a deposit hit an actionable
936
+ * pipeline-source failure (SPEC §16.1): the operator must know the deposit
937
+ * degraded to the last good pipeline or the live snapshot, and why. The
938
+ * documented fallbacks (unpublished branch, unreadable legacy base) are
939
+ * normal resolution order and stay quiet. A diagnostic, never a launch
940
+ * dependency. */
941
+ private async filePipelineFallbackObservation(
942
+ slug: string,
943
+ reused: PipelineSourceMeta | undefined,
944
+ failures: PipelineSourceFailure[],
945
+ events: readonly AbEvent[],
946
+ ): Promise<void> {
947
+ const actionable = failures.filter(isActionablePipelineFailure)
948
+ if (actionable.length === 0) return
949
+ const degradedTo =
950
+ reused !== undefined
951
+ ? 'kept the last pinned pipeline' +
952
+ (reused.commit !== undefined ? ` at ${reused.ref}@${reused.commit}` : ` (${reused.ref})`)
953
+ : 'deposited the live dispatcher snapshot (legacy fallback)'
954
+ const summary =
955
+ `pipeline-source resolution failed; ${degradedTo}. ` +
956
+ `Failed attempts: ${
957
+ actionable.map((failure) => `${failure.kind}: ${failure.detail}`).join('; ') ||
958
+ 'none recorded'
959
+ }`
960
+ // Deduplicated against the build's log: a parked build re-attaches every
961
+ // sweep, and an outage must not harvest one identical diagnostic per tick.
962
+ if (
963
+ events.some(
964
+ (event) => event.type === 'observation.recorded' && event.payload.summary === summary,
965
+ )
966
+ ) {
967
+ return
968
+ }
969
+ try {
970
+ await this.wiring.store.append(slug, {
971
+ // The catalog admits kernel/agent actors for observations (§8.2); the
972
+ // dispatcher's system-side diagnostic rides the kernel actor, like the
973
+ // finalize-failure and auto-merge-deferral observations.
974
+ actor: KERNEL,
975
+ type: 'observation.recorded',
976
+ payload: {
977
+ id: this.wiring.ids('obs'),
978
+ kind: 'followup',
979
+ summary,
980
+ files: ['autobuild.toml'],
981
+ },
982
+ })
983
+ } catch {
984
+ // The build's pipeline is already deposited; the observation is best-effort.
985
+ }
986
+ }
987
+
988
+ private async depositBuildConfig(
989
+ slug: string,
990
+ snapshot: ConfigSnapshot,
991
+ config: Config,
992
+ pipelineSource?: PipelineSourceMeta,
993
+ ): Promise<void> {
785
994
  await this.wiring.store.putArtifact(slug, {
786
995
  kind: BUILD_EFFECTIVE_CONFIG_ARTIFACT,
787
- content: effectiveBuildConfigContent(snapshot.config),
996
+ content: effectiveBuildConfigContent(config),
788
997
  metadata: {
789
998
  revision: snapshot.revision,
790
999
  run: this.runId,
1000
+ ...(pipelineSource !== undefined ? { pipelineSource } : {}),
791
1001
  },
792
1002
  })
793
1003
  }
794
1004
 
1005
+ /** Re-deposit deployment-owned sections to active builds on a reload. The
1006
+ * build-owned sections are reused verbatim from the build's last deposit, so
1007
+ * a base-branch reload can never retarget an in-flight build's pipeline; a
1008
+ * build picks up pipeline changes only from its own branch at its next
1009
+ * launch (SPEC §16.1). */
795
1010
  private async publishActiveBuildConfigs(snapshot: ConfigSnapshot): Promise<void> {
796
1011
  await Promise.all(
797
- [...this.activeBuildRuns.keys()].map((slug) => this.publishBuildConfig(slug, snapshot)),
1012
+ [...this.activeBuildRuns.keys()].map(async (slug) => {
1013
+ const artifact = await this.wiring.store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
1014
+ if (artifact === null) {
1015
+ await this.publishBuildConfig(slug, snapshot)
1016
+ return
1017
+ }
1018
+ let existing: Config
1019
+ try {
1020
+ existing = parseEffectiveBuildConfig(artifact)
1021
+ } catch {
1022
+ // A corrupt or pre-pin artifact cannot pin a pipeline; fall back to
1023
+ // the launch-path resolution instead of failing the reload.
1024
+ await this.publishBuildConfig(slug, snapshot)
1025
+ return
1026
+ }
1027
+ const meta = parseBuildConfigMetadata(artifact)
1028
+ await this.depositBuildConfig(
1029
+ slug,
1030
+ snapshot,
1031
+ composeBuildConfig(existing, snapshot.config),
1032
+ meta.pipelineSource,
1033
+ )
1034
+ }),
798
1035
  )
799
1036
  }
800
1037
 
801
1038
  private async appendStatus(event: RepositoryEventWrite): Promise<void> {
802
- await this.wiring.store.appendRepo(this.repoIdentity, event)
1039
+ await this.repoView.appendRepo(this.repoIdentity, event)
803
1040
  }
804
1041
 
805
1042
  private async refreshConfig(): Promise<void> {
806
1043
  if (this.opts.once === true) return
807
1044
  // Origin mode reloads from the forge: autobuild.toml at the CURRENT
808
- // effective base branch (hot — a rename converges within two ticks).
1045
+ // effective base branch (hot — a rename converges within two ticks). This
1046
+ // read feeds the dispatcher's own snapshot; a reload re-deposits only the
1047
+ // deployment-owned sections to active builds (build-owned pipeline
1048
+ // sections stay pinned to each build's own branch, SPEC §16.1).
809
1049
  // Restart-required changes keep startup-built adapters and file the
810
1050
  // existing restart-required observation (LiveConfig semantics).
811
1051
  const outcome =
@@ -861,8 +1101,10 @@ class DispatchLoop {
861
1101
  }
862
1102
 
863
1103
  private async readDispatchSettings(): Promise<ReturnType<typeof reduceDispatchSettings>> {
864
- const events = await this.wiring.store.getRepoEvents(this.repoIdentity)
865
- return reduceDispatchSettings(events)
1104
+ // The view's bounded subset (AUT-489): the settings reducer consumes
1105
+ // durable types only. `refresh()` ran first in the tick, so this is the
1106
+ // delta-current journal, never a store read.
1107
+ return reduceDispatchSettings(this.repoView.recordedJournal())
866
1108
  }
867
1109
 
868
1110
  private dispatcherTick(resumeCurrent: boolean): Promise<Awaited<ReturnType<Dispatcher['tick']>>> {
@@ -884,12 +1126,17 @@ class DispatchLoop {
884
1126
  })
885
1127
 
886
1128
  // 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.
1129
+ // dispatcher tick from the journal and one repo-scoped digest read
1130
+ // (AUT-487) — flat in the finished-build count. A journal record that
1131
+ // does not yet exist reads as an empty journal (AUT-524); the sample
1132
+ // writes nothing. Any other failed sample must neither fail dispatch nor
1133
+ // replace the last complete measurement with a fabricated zero.
889
1134
  if (this.dashboard) {
890
1135
  try {
891
- const scan = await scanUnclaimedObservations(this.wiring.store, this.repoIdentity)
892
- this.observationCount = scan.observations.length
1136
+ this.observationCount = await sampleUnclaimedObservationCount(
1137
+ this.wiring.store,
1138
+ this.repoIdentity,
1139
+ )
893
1140
  } catch {
894
1141
  // Display-only sampling failures retain the last factual count and
895
1142
  // retry on the next tick; they are not dashboard failures.
@@ -898,6 +1145,7 @@ class DispatchLoop {
898
1145
 
899
1146
  // Sample inside the serialized tick, not at process startup. Every
900
1147
  // dispatcher therefore gates claims from the latest repository facts.
1148
+ await this.repoView.refresh()
901
1149
  const settings = await this.readDispatchSettings()
902
1150
  const readyObservation =
903
1151
  settings.intake || (!this.dashboard && this.opts.kernelRunId === undefined)
@@ -905,6 +1153,7 @@ class DispatchLoop {
905
1153
  : await this.dispatcher.observeReady()
906
1154
  const report = await this.dispatcher.tick({
907
1155
  resumeCurrent,
1156
+ viewRefreshed: true,
908
1157
  acceptNewWork: settings.intake,
909
1158
  defaultAutoMerge: settings.defaultAutoMerge,
910
1159
  autoMergeUser: buildControlUser(this.opts.env),
@@ -1175,6 +1424,7 @@ class DispatchLoop {
1175
1424
  repo: this.repoIdentity,
1176
1425
  user: buildControlUser(this.opts.env),
1177
1426
  setting: 'intake',
1427
+ run: this.runId,
1178
1428
  })
1179
1429
  this.say(`dispatcher intake ${event.enabled ? 'ON' : 'OFF'}`)
1180
1430
  await this.renderOnce()
@@ -1439,6 +1689,7 @@ class DispatchLoop {
1439
1689
  repo: this.repoIdentity,
1440
1690
  user: buildControlUser(this.opts.env),
1441
1691
  setting: 'auto-merge-default',
1692
+ run: this.runId,
1442
1693
  })
1443
1694
  this.say(`dispatcher auto-merge default ${event.enabled ? 'ON' : 'OFF'}`)
1444
1695
  await this.renderOnce()
@@ -2055,8 +2306,11 @@ class DispatchLoop {
2055
2306
  // the pure Store-read pressure check; the guest rescans authoritatively
2056
2307
  // once provisioned, so a threshold crossing between gate and guest scan
2057
2308
  // is never suppressed by this check.
2058
- const record = await store.getRepo(repo)
2059
- const events = record === null ? [] : await store.getRepoEvents(repo)
2309
+ // The view's bounded subset (AUT-489): the harvest control decision
2310
+ // reduces durable types only. Take the journal delta first — guests and
2311
+ // other dispatchers write harvest facts between ticks.
2312
+ await this.repoView.refreshJournal()
2313
+ const events = this.repoView.recordedJournal()
2060
2314
  const state = reduceHarvest(events)
2061
2315
  const control = decideHarvestControl(state, this.maxHarvestRecoveryAttempts)
2062
2316
  const resumePending =
@@ -2066,8 +2320,16 @@ class DispatchLoop {
2066
2320
  control.kind === 'request-recovery' ||
2067
2321
  control.kind === 'exhaust-recovery'
2068
2322
  if (!resumePending) {
2069
- const scan = await scanUnclaimedObservations(store, repo)
2070
- const pressure = evaluateHarvestPressure(scan, this.currentConfig().config.policy)
2323
+ // Flat-cost gate (AUT-521): the journal read above plus one
2324
+ // repo-scoped digest batch read — no per-build history reads, so the
2325
+ // per-evaluation cost no longer grows with accumulated finished
2326
+ // builds. The guest rescans authoritatively once provisioned.
2327
+ const pressure = await evaluateHarvestPressureFromStore({
2328
+ store,
2329
+ repo,
2330
+ harvestEvents: events,
2331
+ policy: this.currentConfig().config.policy,
2332
+ })
2071
2333
  if (pressure.trigger === undefined) return
2072
2334
  }
2073
2335
 
@@ -2090,7 +2352,7 @@ class DispatchLoop {
2090
2352
  )
2091
2353
  }
2092
2354
  this.hostedHarvest = { execution, handle, detached }
2093
- const started = await store.appendRepo(repo, {
2355
+ const started = await this.repoView.appendRepo(repo, {
2094
2356
  actor: DISPATCHER,
2095
2357
  type: 'harvest.execution.started',
2096
2358
  payload: {
@@ -2165,7 +2427,10 @@ class DispatchLoop {
2165
2427
  startedSeq: number,
2166
2428
  ): Promise<HarvestRunnerResult> {
2167
2429
  const { store } = this.wiring
2168
- const events = await store.getRepoEvents(repo)
2430
+ // Bounded subset (AUT-489): classifyHarvestOutcome reduces harvest facts
2431
+ // only. A guest wrote the facts through its own handle, so read the delta.
2432
+ await this.repoView.refreshJournal()
2433
+ const events = this.repoView.recordedJournal()
2169
2434
  const record = await store.getRepo(repo)
2170
2435
  return classifyHarvestOutcome(events, {
2171
2436
  executionStartedSeq: startedSeq,
@@ -2191,7 +2456,7 @@ class DispatchLoop {
2191
2456
  branch: '',
2192
2457
  }
2193
2458
  const reap = await this.reapHostedWorkspace(workspaceHandle)
2194
- await this.wiring.store.appendRepo(repo, {
2459
+ await this.repoView.appendRepo(repo, {
2195
2460
  actor: DISPATCHER,
2196
2461
  type: 'harvest.execution.released',
2197
2462
  payload: {
@@ -2348,9 +2613,11 @@ class DispatchLoop {
2348
2613
  }
2349
2614
 
2350
2615
  private async settlePendingPublication(slug: string): Promise<void> {
2616
+ // The guest appended through its own handle; settle against fresh state.
2617
+ await this.repoView.refreshBuild(slug)
2351
2618
  await settleWorkspacePublication(
2352
2619
  {
2353
- store: this.wiring.store,
2620
+ store: this.repoView,
2354
2621
  storeRef: this.wiring.storeRef,
2355
2622
  publication: this.wiring.workspaces.publication,
2356
2623
  forge: this.wiring.forge,
@@ -2373,11 +2640,13 @@ class DispatchLoop {
2373
2640
  cleanupPending: boolean
2374
2641
  identity?: BuildExecutionHandle['identity']
2375
2642
  }): Promise<void> {
2643
+ // The failed executor may have appended through its own handle.
2644
+ await this.repoView.refreshBuild(input.slug)
2376
2645
  await appendInfrastructureFailure(
2377
- { store: this.wiring.store, ids: this.wiring.ids },
2646
+ { store: this.repoView, ids: this.wiring.ids },
2378
2647
  {
2379
2648
  slug: input.slug,
2380
- events: await this.wiring.store.getEvents(input.slug),
2649
+ events: await this.repoView.getEvents(input.slug),
2381
2650
  maxAttempts: this.currentConfig().config.policy.maxInfrastructureAttempts,
2382
2651
  provider: input.identity?.provider ?? this.wiring.workspaces.name,
2383
2652
  workspaceRef: input.workspaceRef,
@@ -2408,11 +2677,7 @@ class DispatchLoop {
2408
2677
 
2409
2678
  try {
2410
2679
  await this.publishBuildConfig(slug)
2411
- leaseClaimed = await this.wiring.store.claimLease(
2412
- slug,
2413
- instance,
2414
- BUILD_EXECUTION_LEASE_TTL_MS,
2415
- )
2680
+ leaseClaimed = await this.repoView.claimLease(slug, instance, BUILD_EXECUTION_LEASE_TTL_MS)
2416
2681
  if (!leaseClaimed) {
2417
2682
  this.activeBuildRuns.delete(slug)
2418
2683
  await this.appendStatus({
@@ -2423,7 +2688,7 @@ class DispatchLoop {
2423
2688
  this.failureNotice(`build ${slug} already held by another runner — skipped`)
2424
2689
  return 'already-active'
2425
2690
  }
2426
- const launchEvents = await this.wiring.store.getEvents(slug)
2691
+ const launchEvents = await this.repoView.getEvents(slug)
2427
2692
  let workspaceRef: string | undefined
2428
2693
  let workspaceProvider: string | undefined
2429
2694
  for (const event of launchEvents) {
@@ -2467,7 +2732,7 @@ class DispatchLoop {
2467
2732
  provider: owning.name,
2468
2733
  workspaceRef,
2469
2734
  }
2470
- await this.wiring.store.append(slug, {
2735
+ await this.repoView.append(slug, {
2471
2736
  actor: DISPATCHER,
2472
2737
  type: 'execution.started',
2473
2738
  payload: { ...identity, instance },
@@ -2482,7 +2747,7 @@ class DispatchLoop {
2482
2747
  // supervisor settles the execution from the Store.
2483
2748
  if (active.detaching) return
2484
2749
  try {
2485
- await this.wiring.store.append(slug, {
2750
+ await this.repoView.append(slug, {
2486
2751
  actor: DISPATCHER,
2487
2752
  type: 'execution.ended',
2488
2753
  payload: {
@@ -2504,7 +2769,8 @@ class DispatchLoop {
2504
2769
  return
2505
2770
  }
2506
2771
 
2507
- const state = reduceBuild(await this.wiring.store.getEvents(slug))
2772
+ await this.repoView.refreshBuild(slug)
2773
+ const state = reduceBuild(await this.repoView.getEvents(slug))
2508
2774
  if (diagnostic === null && exit.exitCode === 0) {
2509
2775
  await this.appendStatus({
2510
2776
  actor: DISPATCHER,
@@ -2530,7 +2796,7 @@ class DispatchLoop {
2530
2796
  })
2531
2797
  this.warn(`build ${slug} runner failed: ${detail}`)
2532
2798
  } finally {
2533
- await this.wiring.store.releaseLease(slug, instance)
2799
+ await this.repoView.releaseLease(slug, instance)
2534
2800
  await this.settlePendingPublication(slug)
2535
2801
  }
2536
2802
  },
@@ -2541,7 +2807,7 @@ class DispatchLoop {
2541
2807
  // A rejected executor completion cannot prove the remote VM was
2542
2808
  // stopped. Keep the lease until expiry: recovery fences and reaps
2543
2809
  // the exact recorded identity before authorizing a replacement.
2544
- await this.recordInfrastructureFailure({
2810
+ active.recording = this.recordInfrastructureFailure({
2545
2811
  slug,
2546
2812
  instance,
2547
2813
  workspaceRef,
@@ -2550,6 +2816,7 @@ class DispatchLoop {
2550
2816
  cleanupPending: true,
2551
2817
  identity,
2552
2818
  })
2819
+ await active.recording
2553
2820
  },
2554
2821
  )
2555
2822
  .finally(() => {
@@ -2568,7 +2835,9 @@ class DispatchLoop {
2568
2835
  this.activeBuildRuns.delete(slug)
2569
2836
  }
2570
2837
  if (leaseClaimed) {
2571
- const events = await this.wiring.store.getEvents(slug)
2838
+ // The runner appended through its own handle: take the delta.
2839
+ await this.repoView.refreshBuild(slug)
2840
+ const events = await this.repoView.getEvents(slug)
2572
2841
  let workspaceRef = ''
2573
2842
  for (const event of events) {
2574
2843
  if (event.type === 'workspace.provisioned') workspaceRef = event.payload.ref
@@ -2587,7 +2856,7 @@ class DispatchLoop {
2587
2856
  // Local start failures have no possible remote holder. Remote failures
2588
2857
  // retain the lease until its TTL fences an ambiguous start.
2589
2858
  if (this.wiring.workspaces.recovery === undefined) {
2590
- await this.wiring.store.releaseLease(slug, instance)
2859
+ await this.repoView.releaseLease(slug, instance)
2591
2860
  }
2592
2861
  }
2593
2862
  await this.appendStatus({
@@ -2683,6 +2952,9 @@ class DispatchLoop {
2683
2952
  } catch {
2684
2953
  // Detach is best-effort: an unresolvable wait still exits.
2685
2954
  }
2955
+ // A wait failure already being recorded is durable work, not
2956
+ // supervision: let it land before the process exits.
2957
+ await entry.recording?.catch(() => {})
2686
2958
  this.activeBuildRuns.delete(slug)
2687
2959
  if (entry.settled !== undefined) {
2688
2960
  this.inFlight.delete(entry.settled)
@@ -2839,9 +3111,14 @@ class DispatchLoop {
2839
3111
  configSnapshot.config,
2840
3112
  configSnapshot.revision,
2841
3113
  )
2842
- const repoRecord = await this.wiring.store.getRepo(this.repoIdentity)
2843
- const repositoryEvents =
2844
- repoRecord === null ? [] : await this.wiring.store.getRepoEvents(this.repoIdentity)
3114
+ // Bounded read (AUT-489, AUT-545): every repository consumer of this frame —
3115
+ // projectRepositoryHarvest (reduceHarvest plus the harvest-only raw scans in
3116
+ // projectHarvestRun/escalationAttentionDismissed) and reduceDispatchSettings —
3117
+ // reduces durable types only, so the AUT-489 subset is replay-equivalent with
3118
+ // no anchor dependence (anchor analysis recorded; the frontend seed needed one
3119
+ // only because reduceDispatchStatus folds here too — it does not here).
3120
+ // Missing journal record stays a read-only empty journal (AUT-524).
3121
+ const repositoryEvents = await readRepoEventsIfRecorded(this.wiring.store, this.repoIdentity)
2845
3122
 
2846
3123
  // Action-triggered and timer refreshes share the cache but may finish their
2847
3124
  // repository reads out of order. Never let an older build snapshot replace
@@ -3162,7 +3439,7 @@ class DispatchLoop {
3162
3439
  const holder = record?.lease?.holder
3163
3440
  if (holder === undefined || this.yieldedTo === holder) return
3164
3441
  this.yieldedTo = holder
3165
- await this.wiring.store.appendRepo(this.repoIdentity, {
3442
+ await this.repoView.appendRepo(this.repoIdentity, {
3166
3443
  actor: DISPATCHER,
3167
3444
  type: 'dispatcher.tick-yielded',
3168
3445
  payload: {
@@ -3502,10 +3779,23 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3502
3779
  `origin-mode dispatch requires the builtin github forge, but the fetched autobuild.toml selects ${JSON.stringify(config.forge)}`,
3503
3780
  )
3504
3781
  }
3505
- if (config.plugins !== undefined && config.plugins.length > 0) {
3782
+ // Origin mode has no checkout, so repository-path plugin specifiers
3783
+ // (relative, absolute, file:) cannot resolve — reject them with the
3784
+ // existing remediation tone before any wiring. Bare package specifiers
3785
+ // are accepted: the shared loadPlugins below resolves them from the
3786
+ // dispatcher's own installation when the scratch root lacks them
3787
+ // (AUT-517), which is exactly "resolvable from the dispatcher's own
3788
+ // installation". The caller-supplied `targetRepo` is an identity token,
3789
+ // not a path, and never reaches plugin resolution: the candidate roots
3790
+ // are the scratch checkout and the installation, per the loadPlugins
3791
+ // call below (AUT-604).
3792
+ const repoPathPlugin = (config.plugins ?? []).find(
3793
+ (moduleSpecifier) => pluginResolutionKind(moduleSpecifier) === 'repo-path',
3794
+ )
3795
+ if (repoPathPlugin !== undefined) {
3506
3796
  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',
3797
+ `origin-mode dispatch cannot load the repository-path plugin ${JSON.stringify(repoPathPlugin)}: ` +
3798
+ '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
3799
  )
3510
3800
  }
3511
3801
  } else {
@@ -3524,8 +3814,22 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3524
3814
  }
3525
3815
  // Configured plugin code is trusted like configured shell commands, but it
3526
3816
  // 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)
3817
+ // opens a store, claims a ticket, or launches a runner. Skipped-module
3818
+ // notices (builtin duplicate-skip, guest tolerance) surface on dispatch's
3819
+ // own stderr channel.
3820
+ // AUT-604: origin mode's repository candidate root for plugin loading is
3821
+ // the per-origin scratch checkout, never the caller-supplied `targetRepo`
3822
+ // token (e.g. hosted-dispatcher's `'<hosted-dispatcher>'`) — that token is
3823
+ // a caller-facing identity label which `resolveOriginModeState` replaced
3824
+ // with `state.checkout` above, so it never participates in cwd-relative
3825
+ // disk resolution. `state.checkout` equals `resolvedOpts.targetRepo` in
3826
+ // both modes, so passing it as `packageRoot` is behavior-preserving; the
3827
+ // installation candidate (AUT-517) and the disk-first EROFS gate (AUT-587)
3828
+ // are unchanged.
3829
+ const plugins = await loadPlugins(config.plugins, resolvedOpts.targetRepo, {
3830
+ packageRoot: state.checkout,
3831
+ onNotice: (line) => resolvedOpts.stderr(line),
3832
+ })
3529
3833
  // Validate the selector against the complete catalog before either custom
3530
3834
  // wiring or production wiring can open state or perform side effects.
3531
3835
  resolveForgeRegistration(config.forge, plugins)
@@ -3580,7 +3884,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3580
3884
  metadata: { restartRequired: [...restartRequired], effectiveChanged },
3581
3885
  },
3582
3886
  {
3583
- kind: DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT,
3887
+ kind: _effectiveConfigKind,
3584
3888
  content: effectiveConfigContent(effectiveConfig),
3585
3889
  metadata: { run: runId, effectiveChanged },
3586
3890
  },
@@ -3616,27 +3920,36 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3616
3920
  // another dispatcher cannot clobber the latest operator choice with a value
3617
3921
  // it inferred at startup. Fresh-repository fallbacks live in the reducer.
3618
3922
  await wiring.store.ensureRepo(state.repo)
3923
+ // Every own lifecycle append goes through one view, restored from snapshots
3924
+ // before the first of them, so a quiet invocation reads none of its own
3925
+ // rows back and the final flush covers `run-stopped` too.
3926
+ const view = new RepoViewStore(wiring.store, {
3927
+ // The loop's store-keyed identity (an explicit `repo` pins it).
3928
+ repo: resolvedOpts.repo ?? resolvedOpts.targetRepo,
3929
+ resident: resolvedOpts.once !== true,
3930
+ })
3931
+ await view.startJournal()
3619
3932
  const actor = humanActor(buildControlUser(resolvedOpts.env))
3620
3933
  if (resolvedOpts.intake !== undefined) {
3621
- await wiring.store.appendRepo(state.repo, {
3934
+ await view.appendRepo(state.repo, {
3622
3935
  actor,
3623
3936
  type: 'dispatcher.intake-set',
3624
3937
  payload: { enabled: resolvedOpts.intake, run: runId },
3625
3938
  })
3626
3939
  }
3627
3940
  if (resolvedOpts.defaultAutoMerge !== undefined) {
3628
- await wiring.store.appendRepo(state.repo, {
3941
+ await view.appendRepo(state.repo, {
3629
3942
  actor,
3630
3943
  type: 'dispatcher.auto-merge-default-set',
3631
3944
  payload: { enabled: resolvedOpts.defaultAutoMerge, run: runId },
3632
3945
  })
3633
3946
  }
3634
3947
 
3635
- await wiring.store.appendRepoWithArtifacts(
3948
+ await view.appendRepoWithArtifacts(
3636
3949
  state.repo,
3637
3950
  [
3638
3951
  {
3639
- kind: DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT,
3952
+ kind: _effectiveConfigKind,
3640
3953
  content: effectiveConfigContent(config),
3641
3954
  metadata: { run: runId, revision: 0 },
3642
3955
  },
@@ -3657,10 +3970,10 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3657
3970
  },
3658
3971
  )
3659
3972
 
3660
- const loop = new DispatchLoop(liveConfig, wiring, resolvedOpts)
3973
+ const loop = new DispatchLoop(liveConfig, wiring, resolvedOpts, view)
3661
3974
  try {
3662
3975
  await loop.run()
3663
- await wiring.store.appendRepo(state.repo, {
3976
+ await view.appendRepo(state.repo, {
3664
3977
  actor: DISPATCHER,
3665
3978
  type: 'dispatcher.run-stopped',
3666
3979
  payload: {
@@ -3672,7 +3985,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3672
3985
  })
3673
3986
  } catch (error) {
3674
3987
  try {
3675
- await wiring.store.appendRepo(state.repo, {
3988
+ await view.appendRepo(state.repo, {
3676
3989
  actor: DISPATCHER,
3677
3990
  type: 'dispatcher.run-stopped',
3678
3991
  payload: {
@@ -3687,6 +4000,10 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3687
4000
  }
3688
4001
  throw error
3689
4002
  } finally {
4003
+ // Every append this invocation made, `run-stopped` included, is in the
4004
+ // view; leave the reduced state behind so the next cold process reads
4005
+ // only what is newer than it.
4006
+ await view.persistSnapshots({ force: true })
3690
4007
  if (resolvedOpts.wire === undefined) await wiring.store.close()
3691
4008
  }
3692
4009
  }