@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
@@ -37,15 +37,23 @@
37
37
  * execution lease before launch; the child renews that same-holder lease.
38
38
  */
39
39
  import type { Config } from '../config/schema'
40
+ import type { LanguageModel } from 'ai'
41
+ import { runOrchestratorTickStep } from './orchestrator-tick'
40
42
  import { DISPATCHER, agentActor, humanActor } from '../events/envelope'
43
+ import { isRemoteWorkspace } from '../events/workspace-remote'
41
44
  import type { AbEvent, EventWrite } from '../events/catalog'
42
- import type { EventPayload } from '../events/payloads'
45
+ import type { EventPayload, PinnedAsset } from '../events/payloads'
43
46
  import type { IdSource } from '../ids'
44
47
  import {
45
48
  autoMergeApplicationType,
46
49
  pendingAutoMerge,
47
50
  recordAutoMergeDeferralObservation,
48
51
  } from '../kernel/auto-merge'
52
+ import {
53
+ autoMergeDefaultTarget,
54
+ latestAutoMergeDefault,
55
+ type AutoMergeDefaultFact,
56
+ } from '../kernel/auto-merge-default'
49
57
  import { reduceDispatchSettings } from '../kernel/dispatch-settings'
50
58
  import { decideNext } from '../kernel/engine'
51
59
  import {
@@ -56,7 +64,8 @@ import {
56
64
  } from '../kernel/harvest'
57
65
  import { pendingPrAttachmentReclaims } from '../kernel/pr-attachments'
58
66
  import { reduceBuild, type BuildState } from '../kernel/reducer'
59
- import type { ArtifactRef } from '../ontology'
67
+ import { createOperatorSandboxService, type OperatorSandboxService } from '../operator/sandbox'
68
+ import type { ArtifactRef, TicketRef } from '../ontology'
60
69
  import { BUILD_EXECUTION_LEASE_TTL_MS } from '../ports/workspace/build-execution'
61
70
  import type {
62
71
  DependencyState,
@@ -67,12 +76,14 @@ import type {
67
76
  } from '../ports/types'
68
77
  import type { Exec } from '../ports/workspace/git-worktree'
69
78
  import type { ArtifactMeta, BuildRecord, BuildStore, Clock } from '../store/types'
79
+ import { samplePinnedAssets } from '../store/ticket-assets'
70
80
  import { resolveRepoOrigin } from '../cli/repo-state'
71
81
  import { specConformance } from '../spec-standard'
72
82
  export { specConformance, type SpecConformance } from '../spec-standard'
73
83
  import { recordInfrastructureFailure as appendInfrastructureFailure } from './infrastructure-failure-budget'
74
84
  import { lastExecutionOutcome, openExecution, settleExecution } from './execution-settlement'
75
85
  import { openHarvestExecutions } from './harvest-execution-state'
86
+ import { readRepoEventsIfRecorded } from './harvest'
76
87
  import type { RepositoryEvent } from '../events/repository'
77
88
  import { sandboxStates } from './sandbox-state'
78
89
  import { abandonedPublicationPending, publicationPending } from './publication-state'
@@ -349,6 +360,11 @@ export interface TickReport {
349
360
  sandboxIdleStops: number
350
361
  /** Contained idle-settlement failures this tick; retried next tick. */
351
362
  sandboxSettleFailures: number
363
+ /** Contained sandbox-backend construction failures this tick (AUT-584):
364
+ * deterministic and config-shaped, so it re-reports every tick until the
365
+ * config is repaired. The affected tick runs with sandbox tools filtered
366
+ * exactly as when no backend exists. */
367
+ orchestratorSandboxFailures: number
352
368
  }
353
369
 
354
370
  export function emptyTickReport(): TickReport {
@@ -385,6 +401,7 @@ export function emptyTickReport(): TickReport {
385
401
  harvestFailed: 0,
386
402
  sandboxIdleStops: 0,
387
403
  sandboxSettleFailures: 0,
404
+ orchestratorSandboxFailures: 0,
388
405
  }
389
406
  }
390
407
 
@@ -534,6 +551,11 @@ export interface DispatcherDeps {
534
551
  * supplies cancellation and treats every absence/failure/invalid result as a
535
552
  * local deterministic fallback, so naming can never prevent build creation. */
536
553
  nameSlug?: (spec: string, signal: AbortSignal) => Promise<string | null>
554
+ /** The orchestrator turn runner's language model, injected for tests. When
555
+ * absent the configured gateway model is resolved in process (AUT-342);
556
+ * the step itself only ever constructs in origin mode with the orchestrator
557
+ * enabled, so a local dispatcher never resolves a model. */
558
+ orchestratorModel?: LanguageModel
537
559
  /** Settle pending publication for `slug` after durable proof that the
538
560
  * guest execution ended. Supplied by the DispatchLoop; the settlement stage
539
561
  * invokes it only on a recorded completion. */
@@ -672,11 +694,23 @@ export class Dispatcher {
672
694
  /** Builds already launched this tick — a janitor/startup re-attach must
673
695
  * not be doubled by the sweep, nor a fresh dispatch by anything. */
674
696
  const launched = new Set<string>()
675
- // Sampled once, before any stage that could attach a runner: a pause that
676
- // lands mid-tick takes effect on the next tick, matching intake (which the
677
- // CLI likewise samples before calling `tick`). Neither control pretends to
678
- // a serialization the store does not offer.
679
- const paused = await this.repositoryPaused()
697
+ // One journal read for both repository-wide controls: a pause that lands
698
+ // mid-tick takes effect on the next tick, matching intake (which the CLI
699
+ // likewise samples before calling `tick`); the auto-merge default fact is
700
+ // the durable driver of this tick's fan-out below. Neither control
701
+ // pretends to a serialization the store does not offer.
702
+ const repoEvents = await readRepoEventsIfRecorded(this.deps.store, this.deps.repo)
703
+ const paused = reduceDispatchSettings(repoEvents).paused
704
+ const autoMergeDefault = latestAutoMergeDefault(repoEvents)
705
+ // The claim-time seed cites the newest fact whose enabled state matches
706
+ // the launch sample, not blindly the newest fact: the CLI samples
707
+ // `defaultAutoMerge` from its own earlier read, so an interleaved toggle
708
+ // can make this tick's read newer-and-opposite. Citing the matching fact
709
+ // keeps the newer opposite fact strictly ahead of the seed's provenance,
710
+ // so the fan-out reconciles the build on the next tick instead of the
711
+ // stale sample out-voting the durable default forever (f_b851c0e8).
712
+ const autoMergeSeedFact =
713
+ opts.defaultAutoMerge === true ? latestAutoMergeDefault(repoEvents, true) : undefined
680
714
  // Stage 0 — SETTLEMENT: observe foreign executions from durable facts plus
681
715
  // provider liveness and settle their completion, lease, and publication.
682
716
  // Runs before the janitor so its executionLeaseLive checks see freshly
@@ -691,6 +725,13 @@ export class Dispatcher {
691
725
  if (this.outOfBudget(opts)) return report
692
726
  await this.settleOrchestratorSandboxes(report, opts)
693
727
  if (this.outOfBudget(opts)) return report
728
+ // The default fan-out runs before the janitor so one tick both records the
729
+ // consent and lets the janitor act on it (the janitor reduces a fresh read
730
+ // inside `checkPr`). Independent of intake and `acceptNewWork` — the
731
+ // durable fact drives it, so a second dispatcher polling the same
732
+ // repository converges the same state.
733
+ await this.applyAutoMergeDefault(autoMergeDefault)
734
+ if (this.outOfBudget(opts)) return report
694
735
  await this.janitor(report, launched, opts)
695
736
  if (this.outOfBudget(opts)) return report
696
737
  await this.recoverDispatches(report, launched, paused, opts)
@@ -700,7 +741,7 @@ export class Dispatcher {
700
741
  await this.leaseSweep(report, launched, paused, opts)
701
742
  if (this.outOfBudget(opts)) return report
702
743
  if (opts.acceptNewWork !== false) {
703
- await this.dispatch(report, launched, autoMergeUser, paused, opts)
744
+ await this.dispatch(report, launched, autoMergeUser, paused, opts, autoMergeSeedFact)
704
745
  }
705
746
  // Fire-and-forget by contract: long synthesize/review sessions must not
706
747
  // stop janitor, lease sweep, ticket dispatch, or signal handling on later
@@ -708,6 +749,60 @@ export class Dispatcher {
708
749
  // launch. Pending control commands still launch the runner so the kernel
709
750
  // can settle them under the repository lease.
710
751
  await this.triggerHarvest()
752
+ // The orchestrator step (AUT-342) — LAST, after every build stage, so it
753
+ // can only consume slack: resumes, crash reaping, and wake turns run
754
+ // against min(configured, remaining tick budget) with a 30 s floor, and
755
+ // a busy repository simply defers its sessions to later ticks (durable
756
+ // state makes that safe). Two independent gates keep it inert where it
757
+ // does not belong: the step is constructed ONLY in origin mode (a local
758
+ // `ab dispatch` never constructs it — enabled = true in a local
759
+ // repository's autobuild.toml lists no sessions, builds no registry, and
760
+ // calls no model), and in origin mode only when the effective config
761
+ // enables the orchestrator.
762
+ if (this.deps.repoOrigin !== undefined && this.deps.config.orchestrator.enabled) {
763
+ if (!this.outOfBudget(opts)) {
764
+ const remainingSeconds =
765
+ opts.deadlineAt === undefined
766
+ ? undefined
767
+ : Math.max(0, Math.floor((opts.deadlineAt - this.deps.clock().getTime()) / 1000))
768
+ // The origin-mode tick's sandbox backend (AUT-584), built from the
769
+ // dispatcher's own deps — the same wired provider and forge the
770
+ // settlement stage already consumes. Construction is cheap and
771
+ // network-free (one idempotent ensureRepo), and building per tick
772
+ // picks up hot config reloads the way the rest of the tick does.
773
+ // A construction failure is contained: the step runs with sandbox
774
+ // tools filtered exactly as today, and the standing counter names
775
+ // the condition (the same discipline as sandboxSettleFailures).
776
+ let sandbox: OperatorSandboxService | undefined
777
+ try {
778
+ sandbox = await createOperatorSandboxService({
779
+ store: this.deps.store,
780
+ repo: this.deps.repo,
781
+ provider: this.deps.workspaces,
782
+ sandbox: this.deps.config.orchestrator.sandbox,
783
+ baseBranch: this.deps.config.baseBranch,
784
+ forge: this.deps.forge,
785
+ clock: this.deps.clock,
786
+ })
787
+ } catch {
788
+ report.orchestratorSandboxFailures += 1
789
+ sandbox = undefined
790
+ }
791
+ await runOrchestratorTickStep({
792
+ store: this.deps.store,
793
+ repo: this.deps.repo,
794
+ config: this.deps.config,
795
+ clock: this.deps.clock,
796
+ ids: this.deps.ids,
797
+ tickets: this.deps.tickets,
798
+ ...(sandbox !== undefined ? { sandbox } : {}),
799
+ ...(this.deps.orchestratorModel !== undefined
800
+ ? { model: this.deps.orchestratorModel }
801
+ : {}),
802
+ ...(remainingSeconds !== undefined ? { remainingBudgetSeconds: remainingSeconds } : {}),
803
+ })
804
+ }
805
+ }
711
806
  return report
712
807
  }
713
808
 
@@ -745,13 +840,51 @@ export class Dispatcher {
745
840
  }
746
841
  }
747
842
 
748
- private async repositoryEvents() {
749
- if ((await this.deps.store.getRepo(this.deps.repo)) === null) return []
750
- return this.deps.store.getRepoEvents(this.deps.repo)
751
- }
752
-
753
- private async repositoryPaused(): Promise<boolean> {
754
- return reduceDispatchSettings(await this.repositoryEvents()).paused
843
+ /** Apply the newest durable `dispatcher.auto-merge-default-set` fact to every
844
+ * current non-terminal build of this repository: on — request consent; off —
845
+ * cancel it. Attribution is the toggling human's own actor, exactly as if
846
+ * they had pressed `m` on each row; each command carries the fact's seq so
847
+ * the fan-out is idempotent (a settled build fails `autoMergeDefaultTarget`)
848
+ * and a per-build override stands until the default is toggled again. A fact
849
+ * whose value already matches the build's state writes a provenance-only
850
+ * `build.auto-merge-default-observed` marker instead of a duplicate command,
851
+ * so the matching fact is recorded as answered (f_28b3fba1).
852
+ *
853
+ * Per build, a compare-and-set loop (the `requestOne` shape from
854
+ * `bulk-control.ts`): the read/reduce/evaluate lives inside the loop, so a
855
+ * competing write loses at most one attempt and the next re-reads fresh
856
+ * state. No TickReport counter: the evidence is the build events, and the
857
+ * report shape stays compatible. */
858
+ private async applyAutoMergeDefault(fact: AutoMergeDefaultFact | undefined): Promise<void> {
859
+ if (fact === undefined) return
860
+ const { store } = this.deps
861
+ for (const record of (await store.listBuilds())
862
+ .filter((candidate) => candidate.repo === this.deps.repo)
863
+ .sort((a, b) => a.slug.localeCompare(b.slug))) {
864
+ while (true) {
865
+ const events = await store.getEvents(record.slug)
866
+ const state = reduceBuild(events)
867
+ const target = autoMergeDefaultTarget(state, fact)
868
+ if (target === undefined) break
869
+ if (target === 'observed') {
870
+ const observed = await store.appendIfCurrent(record.slug, state.lastSeq, {
871
+ actor: DISPATCHER,
872
+ type: 'build.auto-merge-default-observed',
873
+ payload: { defaultSeq: fact.seq },
874
+ })
875
+ if (observed !== null) break
876
+ continue
877
+ }
878
+ const type =
879
+ target === 'request' ? 'build.auto-merge-requested' : 'build.auto-merge-cancelled'
880
+ const appended = await store.appendIfCurrent(record.slug, state.lastSeq, {
881
+ actor: fact.actor,
882
+ type,
883
+ payload: { defaultSeq: fact.seq },
884
+ })
885
+ if (appended !== null) break
886
+ }
887
+ }
755
888
  }
756
889
 
757
890
  private async triggerHarvest(): Promise<void> {
@@ -762,7 +895,8 @@ export class Dispatcher {
762
895
  start()
763
896
  return
764
897
  }
765
- const state = reduceHarvest(await this.deps.store.getRepoEvents(this.deps.repo))
898
+ // Bounded read (AUT-489): the harvest reducer consumes durable types only.
899
+ const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
766
900
  if (decideHarvestControl(state, this.maxHarvestRecoveryAttempts).kind !== 'park') {
767
901
  start()
768
902
  }
@@ -837,9 +971,12 @@ export class Dispatcher {
837
971
  const active = this.deps.activeHarvestExecutions?.() ?? new Set<string>()
838
972
  let events: RepositoryEvent[]
839
973
  try {
840
- events = await this.repositoryEvents()
974
+ events = await readRepoEventsIfRecorded(this.deps.store, this.deps.repo)
841
975
  } catch {
842
- // A repository with no journal yet has nothing to settle.
976
+ // The read itself failed (a transient store error — a missing record
977
+ // answers `[]` inside the helper, so it never reaches this arm):
978
+ // skipping this settlement stage for this tick while the rest of the
979
+ // tick proceeds, and the next tick retries what remains.
843
980
  return
844
981
  }
845
982
  for (const execution of openHarvestExecutions(events)) {
@@ -933,9 +1070,12 @@ export class Dispatcher {
933
1070
  const providerName = this.deps.workspaces.name
934
1071
  let events: RepositoryEvent[]
935
1072
  try {
936
- events = await this.repositoryEvents()
1073
+ events = await readRepoEventsIfRecorded(this.deps.store, this.deps.repo)
937
1074
  } catch {
938
- // A repository with no journal yet has nothing to settle.
1075
+ // The read itself failed (a transient store error — a missing record
1076
+ // answers `[]` inside the helper, so it never reaches this arm):
1077
+ // skipping this settlement stage for this tick while the rest of the
1078
+ // tick proceeds, and the next tick retries what remains.
939
1079
  return
940
1080
  }
941
1081
  const idleMs = config.orchestrator.sandbox.idleMinutes * 60_000
@@ -1065,6 +1205,8 @@ export class Dispatcher {
1065
1205
  body: string
1066
1206
  authoredSession?: string
1067
1207
  autoMergeUser?: string
1208
+ autoMergeDefaultSeq?: number
1209
+ assets?: PinnedAsset[]
1068
1210
  },
1069
1211
  ): Promise<'kicked' | 'in-flight' | 'foreign-live'> {
1070
1212
  const slug = record.slug
@@ -1134,6 +1276,8 @@ export class Dispatcher {
1134
1276
  body: string
1135
1277
  authoredSession?: string
1136
1278
  autoMergeUser?: string
1279
+ autoMergeDefaultSeq?: number
1280
+ assets?: PinnedAsset[]
1137
1281
  }
1138
1282
  | undefined
1139
1283
  tail: 'dispatch' | 'launch'
@@ -1192,6 +1336,7 @@ export class Dispatcher {
1192
1336
  ref: handle.ref,
1193
1337
  path: handle.path,
1194
1338
  ...(handle.localPath !== undefined ? { localPath: handle.localPath } : {}),
1339
+ remote: workspaces.publication !== undefined,
1195
1340
  branch: handle.branch,
1196
1341
  base: handle.base,
1197
1342
  },
@@ -1379,7 +1524,7 @@ export class Dispatcher {
1379
1524
 
1380
1525
  private forgeWorkspacePath(events: AbEvent[]): string {
1381
1526
  const open = openWorkspace(events)
1382
- if (open?.provider === 'vercel-sandbox') return this.deps.repo
1527
+ if (open !== null && isRemoteWorkspace(open)) return this.deps.repo
1383
1528
  // Local forges run git with cwd = workspacePath: it must be the physical
1384
1529
  // checkout, never the store identity (an origin URL since the identity
1385
1530
  // change; a path only for origin-less fixtures).
@@ -1407,13 +1552,12 @@ export class Dispatcher {
1407
1552
  // provisioned facts (not the open one, which is null when the build never
1408
1553
  // provisioned or already released — only the former must skip the
1409
1554
  // local-branch git step; a released local workspace may still have left a
1410
- // branch in the checkout). Remote workspaces (vercel-sandbox) never create
1411
- // a local branch in the main checkout, and origin mode has no checkout at
1412
- // all: the local-branch git step is skipped, and the saga's later steps
1413
- // tolerate its absence.
1555
+ // branch in the checkout). Remote workspaces never create a local branch
1556
+ // in the main checkout, and origin mode has no checkout at all: the
1557
+ // local-branch git step is skipped, and the saga's later steps tolerate
1558
+ // its absence.
1414
1559
  const hadLocalWorkspace = events.some(
1415
- (event) =>
1416
- event.type === 'workspace.provisioned' && event.payload.provider !== 'vercel-sandbox',
1560
+ (event) => event.type === 'workspace.provisioned' && !isRemoteWorkspace(event.payload),
1417
1561
  )
1418
1562
  const has = (type: AbEvent['type']): boolean => events.some((event) => event.type === type)
1419
1563
  const append = async <T extends EventWrite['type']>(write: EventWrite<T>): Promise<void> => {
@@ -2046,7 +2190,15 @@ export class Dispatcher {
2046
2190
  private async baseSha(baseBranch: string): Promise<string> {
2047
2191
  const remoteBranchSha = this.deps.forge.remoteBranchSha
2048
2192
  if (remoteBranchSha !== undefined) {
2049
- return await remoteBranchSha.call(this.deps.forge, baseBranch)
2193
+ try {
2194
+ return await remoteBranchSha.call(this.deps.forge, baseBranch)
2195
+ } catch {
2196
+ // A forge that advertises the capability but cannot resolve this branch
2197
+ // (a checkout-mode adapter, an unseeded test fake, or a transient
2198
+ // remote failure) falls through to the physical checkout's git read.
2199
+ // In origin mode there is no checkout, so the git read's failure is the
2200
+ // actionable error and the tick still fails closed.
2201
+ }
2050
2202
  }
2051
2203
  const args = [
2052
2204
  'git',
@@ -2068,6 +2220,29 @@ export class Dispatcher {
2068
2220
 
2069
2221
  // ── b. Interrupted dispatch recovery ───────────────────────────────────────
2070
2222
 
2223
+ /** The claim-time `build.created` payload. Assets are omitted when the ticket
2224
+ * had none, so a no-asset build's log is exactly what it was before. */
2225
+ private buildCreatedPayload(
2226
+ ticket: TicketRef,
2227
+ repo: string,
2228
+ seed:
2229
+ | { autoMergeUser?: string; autoMergeDefaultSeq?: number; assets?: PinnedAsset[] }
2230
+ | undefined,
2231
+ ): EventPayload<'build.created'> {
2232
+ const { config } = this.deps
2233
+ return {
2234
+ ticket,
2235
+ repo,
2236
+ baseBranch: config.baseBranch,
2237
+ ...(seed?.autoMergeUser !== undefined ? { autoMergeRequestedBy: seed.autoMergeUser } : {}),
2238
+ ...(seed?.autoMergeDefaultSeq !== undefined
2239
+ ? { autoMergeDefaultSeq: seed.autoMergeDefaultSeq }
2240
+ : {}),
2241
+ ...(config.pr !== undefined ? { pr: config.pr } : {}),
2242
+ ...(seed?.assets !== undefined && seed.assets.length > 0 ? { assets: seed.assets } : {}),
2243
+ }
2244
+ }
2245
+
2071
2246
  /** Every boundary before runner attachment is derived from durable facts.
2072
2247
  * Missing facts are the todo list; successful provider calls are followed
2073
2248
  * immediately by their facts, so an ordinary later tick can continue. */
@@ -2091,6 +2266,8 @@ export class Dispatcher {
2091
2266
  body: string
2092
2267
  authoredSession?: string
2093
2268
  autoMergeUser?: string
2269
+ autoMergeDefaultSeq?: number
2270
+ assets?: PinnedAsset[]
2094
2271
  },
2095
2272
  ): Promise<DispatchOutcome> {
2096
2273
  const { store, config } = this.deps
@@ -2103,18 +2280,16 @@ export class Dispatcher {
2103
2280
  'build record lacks the immutable ticket facts needed to reconstruct build.created',
2104
2281
  )
2105
2282
  }
2283
+ // Legacy path: a record an older dispatcher left with an empty stream.
2284
+ // New claims write `build.created` atomically with the record, so this
2285
+ // can only sample the ticket's current assets (SPEC §6.3).
2286
+ const assets =
2287
+ seed?.assets ??
2288
+ (await samplePinnedAssets(store, record.repo, record.ticket.id).catch(() => []))
2106
2289
  await store.appendIfCurrent(record.slug, 0, {
2107
2290
  actor: DISPATCHER,
2108
2291
  type: 'build.created',
2109
- payload: {
2110
- ticket: record.ticket,
2111
- repo: record.repo,
2112
- baseBranch: config.baseBranch,
2113
- ...(seed?.autoMergeUser !== undefined
2114
- ? { autoMergeRequestedBy: seed.autoMergeUser }
2115
- : {}),
2116
- ...(config.pr !== undefined ? { pr: config.pr } : {}),
2117
- },
2292
+ payload: this.buildCreatedPayload(record.ticket, record.repo, { ...seed, assets }),
2118
2293
  })
2119
2294
  events = await store.getEvents(record.slug)
2120
2295
  if (!events.some((event) => event.type === 'build.created')) {
@@ -2124,6 +2299,10 @@ export class Dispatcher {
2124
2299
 
2125
2300
  const created = events.find((event) => event.type === 'build.created')
2126
2301
  const autoMergeUser = seed?.autoMergeUser ?? created?.payload.autoMergeRequestedBy
2302
+ // The claim's sampled default fact, for the request's fan-out provenance:
2303
+ // the seed's own value first (this dispatch created the build), else the
2304
+ // durable `build.created` fact a recovery pass resumes from.
2305
+ const autoMergeDefaultSeq = seed?.autoMergeDefaultSeq ?? created?.payload.autoMergeDefaultSeq
2127
2306
  if (
2128
2307
  autoMergeUser !== undefined &&
2129
2308
  !events.some((event) => event.type === 'build.auto-merge-requested')
@@ -2131,7 +2310,9 @@ export class Dispatcher {
2131
2310
  await store.append(record.slug, {
2132
2311
  actor: humanActor(autoMergeUser),
2133
2312
  type: 'build.auto-merge-requested',
2134
- payload: {},
2313
+ payload: {
2314
+ ...(autoMergeDefaultSeq !== undefined ? { defaultSeq: autoMergeDefaultSeq } : {}),
2315
+ },
2135
2316
  })
2136
2317
  events = await store.getEvents(record.slug)
2137
2318
  }
@@ -2172,6 +2353,7 @@ export class Dispatcher {
2172
2353
  ref: handle.ref,
2173
2354
  path: handle.path,
2174
2355
  ...(handle.localPath !== undefined ? { localPath: handle.localPath } : {}),
2356
+ remote: this.deps.workspaces.publication !== undefined,
2175
2357
  branch: handle.branch,
2176
2358
  base: handle.base,
2177
2359
  },
@@ -2218,6 +2400,8 @@ export class Dispatcher {
2218
2400
  body: string
2219
2401
  authoredSession?: string
2220
2402
  autoMergeUser?: string
2403
+ autoMergeDefaultSeq?: number
2404
+ assets?: PinnedAsset[]
2221
2405
  },
2222
2406
  report?: TickReport,
2223
2407
  launched?: Set<string>,
@@ -2578,6 +2762,7 @@ export class Dispatcher {
2578
2762
  autoMergeUser: string | undefined,
2579
2763
  paused: boolean,
2580
2764
  opts: TickOpts = {},
2765
+ autoMergeSeedFact: AutoMergeDefaultFact | undefined = undefined,
2581
2766
  ): Promise<void> {
2582
2767
  const { store, tickets, config } = this.deps
2583
2768
  // Blocked and paused builds still occupy a slot: their workspaces and
@@ -2599,9 +2784,9 @@ export class Dispatcher {
2599
2784
  // standing queue-depth report and must stay honest exactly when the
2600
2785
  // dispatcher is saturated. Sources that keep a claimed ticket in the
2601
2786
  // ready state are deduped against the active builds embodying them.
2602
- const journalBeforeReady = await this.repositoryEvents()
2787
+ const journalBeforeReady = await readRepoEventsIfRecorded(this.deps.store, this.deps.repo)
2603
2788
  const listing = await tickets.listReady(readyCriteria(config))
2604
- const journalAfterReady = await this.repositoryEvents()
2789
+ const journalAfterReady = await readRepoEventsIfRecorded(this.deps.store, this.deps.repo)
2605
2790
  const creationHolds = new Map(
2606
2791
  harvestCreationsDuringReadyScan(journalBeforeReady, journalAfterReady).map((creation) => [
2607
2792
  creation.creationKey.toLowerCase(),
@@ -2691,6 +2876,10 @@ export class Dispatcher {
2691
2876
  continue
2692
2877
  }
2693
2878
 
2879
+ // Freeze the ticket's assets at the claim (SPEC §6.3): sampled before the
2880
+ // spec-authoring interval so edits made during it are never pinned.
2881
+ const assets = await samplePinnedAssets(store, this.deps.repo, ticket.ref.id)
2882
+
2694
2883
  // Quality gate (§6.3): import a conforming ticket body as the spec;
2695
2884
  // author one for thin-but-groomed tickets; otherwise bounce.
2696
2885
  let body = ticket.body
@@ -2717,12 +2906,29 @@ export class Dispatcher {
2717
2906
  const slug = await this.uniqueSlug(baseSlug)
2718
2907
  const branch = `ab/${slug}`
2719
2908
  const repoOrigin = await this.resolveRepoOriginOnce()
2909
+ const createdSeed = {
2910
+ ...(autoMergeUser !== undefined ? { autoMergeUser } : {}),
2911
+ // Claim-time provenance: the newest durable default fact that agrees
2912
+ // with the launch sample, recorded on `build.created` so the fan-out's
2913
+ // seq comparison sees the claim as already answering that fact — and
2914
+ // so a newer opposite fact (an interleaved toggle) stays strictly
2915
+ // ahead of it and reconciles this build on the next tick (f_b851c0e8).
2916
+ ...(autoMergeSeedFact !== undefined ? { autoMergeDefaultSeq: autoMergeSeedFact.seq } : {}),
2917
+ assets,
2918
+ }
2919
+ // `build.created` rides with the record so the pinned assets and the
2920
+ // record exist together or not at all.
2720
2921
  const record = await store.createBuild({
2721
2922
  slug,
2722
2923
  repo: this.deps.repo,
2723
2924
  ticket: ticket.ref,
2724
2925
  branch,
2725
2926
  ...(repoOrigin !== undefined ? { repoOrigin } : {}),
2927
+ created: {
2928
+ actor: DISPATCHER,
2929
+ type: 'build.created',
2930
+ payload: this.buildCreatedPayload(ticket.ref, this.deps.repo, createdSeed),
2931
+ },
2726
2932
  })
2727
2933
  report.queued -= 1
2728
2934
  capacity -= 1
@@ -2730,7 +2936,7 @@ export class Dispatcher {
2730
2936
  ticket,
2731
2937
  body,
2732
2938
  ...(authoredSession !== undefined ? { authoredSession } : {}),
2733
- ...(autoMergeUser !== undefined ? { autoMergeUser } : {}),
2939
+ ...createdSeed,
2734
2940
  })
2735
2941
  // Accepted dispatches: durable boundaries recorded. A deferred outcome
2736
2942
  // means the remote provisioning continuation owns the rest.
@@ -61,8 +61,13 @@ export async function runHarvestChild(
61
61
  // Full build-child composition parity, in the same order: plugins load
62
62
  // first or materializePluginRuntimes silently no-ops and leaves any
63
63
  // plugin-provided runtime unrouted in the guest.
64
+ // Guest tolerance (AUT-517): a harvest runner launch is always
65
+ // environment-supervised — the process is structurally a guest and never
66
+ // constructs a workspace provider — so a configured provider plugin the
67
+ // guest cannot resolve is skipped with a notice instead of failing.
64
68
  const plugins = await loadPlugins(config.plugins, workspacePath, {
65
69
  packageRoot: input.repo,
70
+ ...(input.supervision.kind === 'environment' ? { guest: true } : {}),
66
71
  })
67
72
  const { runtimes: builtins } = createProductionRuntimes()
68
73
  const runtimes = await materializePluginRuntimes(builtins, plugins, {