@defrex/autobuild 0.11.0 → 0.12.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 (96) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +222 -22
  3. package/docs/architecture.md +66 -4
  4. package/docs/configuration.md +105 -16
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +214 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +90 -7
  9. package/docs/remote-store-protocol.md +189 -144
  10. package/docs/remote-ticket-protocol.md +107 -19
  11. package/docs/setup.md +173 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/package.json +7 -6
  14. package/packages/core/src/cli/attention.ts +209 -0
  15. package/packages/core/src/cli/configured-store.ts +17 -0
  16. package/packages/core/src/cli/connect.ts +261 -0
  17. package/packages/core/src/cli/dashboard/model.ts +4 -5
  18. package/packages/core/src/cli/dispatch.ts +183 -41
  19. package/packages/core/src/cli/help.ts +39 -10
  20. package/packages/core/src/cli/init-validation.ts +27 -2
  21. package/packages/core/src/cli/main.ts +41 -15
  22. package/packages/core/src/cli/mcp.ts +7 -1
  23. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  24. package/packages/core/src/cli/pr-summary.ts +2 -0
  25. package/packages/core/src/cli/project-credentials.ts +132 -0
  26. package/packages/core/src/cli/repo-state.ts +15 -2
  27. package/packages/core/src/cli/self-update.ts +10 -0
  28. package/packages/core/src/cli/store-opening.ts +22 -14
  29. package/packages/core/src/cli/terminals.ts +0 -10
  30. package/packages/core/src/cli/ticket.ts +8 -1
  31. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  32. package/packages/core/src/cli/wait.ts +84 -17
  33. package/packages/core/src/cli/watch.ts +197 -60
  34. package/packages/core/src/config/live.ts +3 -0
  35. package/packages/core/src/config/schema.ts +56 -9
  36. package/packages/core/src/connection.ts +86 -0
  37. package/packages/core/src/events/catalog.ts +10 -2
  38. package/packages/core/src/events/payloads.ts +44 -0
  39. package/packages/core/src/events/repository.ts +13 -0
  40. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +130 -83
  41. package/packages/core/src/kernel/engine.ts +24 -0
  42. package/packages/core/src/kernel/generators/build-log.ts +10 -0
  43. package/packages/core/src/kernel/log-index.ts +17 -0
  44. package/packages/core/src/kernel/pr-summary.ts +89 -0
  45. package/packages/core/src/kernel/reducer-registry.ts +8 -0
  46. package/packages/core/src/kernel/reducer.ts +8 -0
  47. package/packages/core/src/ontology.ts +1 -0
  48. package/packages/core/src/operator/index.ts +7 -0
  49. package/packages/core/src/operator/query.ts +38 -16
  50. package/packages/core/src/operator/ticket-source-backend.ts +1 -0
  51. package/packages/core/src/operator/tickets.ts +6 -8
  52. package/packages/core/src/plugin-sdk/index.ts +1 -0
  53. package/packages/core/src/plugins/manifest.ts +5 -3
  54. package/packages/core/src/plugins/runtimes.ts +3 -1
  55. package/packages/core/src/ports/forge/fake.ts +13 -1
  56. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  57. package/packages/core/src/ports/forge/github.ts +29 -5
  58. package/packages/core/src/ports/forge/local-git.ts +12 -4
  59. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  60. package/packages/core/src/ports/runner/session-env.ts +1 -0
  61. package/packages/core/src/ports/tickets/create.ts +11 -3
  62. package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
  63. package/packages/core/src/ports/tickets/remote-protocol.ts +1 -0
  64. package/packages/core/src/ports/tickets/remote.ts +1 -0
  65. package/packages/core/src/ports/workspace/build-execution.ts +5 -1
  66. package/packages/core/src/ports/workspace/create.ts +11 -2
  67. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  68. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  69. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  70. package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
  71. package/packages/core/src/processes/build-execution-state.ts +1 -1
  72. package/packages/core/src/processes/build-runner.ts +87 -26
  73. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  74. package/packages/core/src/processes/dispatcher.ts +59 -10
  75. package/packages/core/src/processes/harvest-child.ts +1 -0
  76. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  77. package/packages/core/src/processes/harvest-runner.ts +142 -100
  78. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  79. package/packages/core/src/processes/publication-loss.ts +18 -1
  80. package/packages/core/src/store/remote/client.ts +1 -1
  81. package/packages/core/src/store/remote/compatibility.ts +90 -0
  82. package/packages/core/src/store/remote/credentials.ts +28 -0
  83. package/packages/core/src/store/remote/index.ts +14 -1
  84. package/packages/core/src/store/remote/protocol.ts +25 -0
  85. package/packages/core/src/store/remote/token.ts +74 -13
  86. package/packages/core/src/testing/index.ts +7 -0
  87. package/skills/guide/SKILL.md +144 -26
  88. package/skills/guide/references/operator-api.md +135 -13
  89. package/skills/guide/references/plugin-authoring.md +10 -2
  90. package/skills/guide/references/remote-store-protocol.md +189 -144
  91. package/skills/guide/references/remote-ticket-protocol.md +111 -18
  92. package/skills/guide/references/setup.md +173 -16
  93. package/skills/guide/references/ticket-dependencies.md +1 -1
  94. package/skills/operate/SKILL.md +126 -36
  95. package/templates/hosted-service/package.json +13 -0
  96. package/templates/hosted-service/vercel.json +7 -0
@@ -1,3 +1,4 @@
1
+ import { ensureDispatchBudget } from './dispatch-failure-budget'
1
2
  import {
2
3
  reduceTicketSourceHealth,
3
4
  type TicketSourceDegradation,
@@ -665,7 +666,15 @@ export class Dispatcher {
665
666
  return this.repoOrigin
666
667
  }
667
668
  private readonly leaseTtlMs: number
668
- private readonly doneState: string
669
+ private get doneState(): string {
670
+ return (
671
+ this.deps.opts?.doneState ??
672
+ (this.deps.config.tickets.source === 'hosted'
673
+ ? this.deps.config.tickets.doneState
674
+ : undefined) ??
675
+ 'Done'
676
+ )
677
+ }
669
678
  private readonly slugNamingTimeoutMs: number
670
679
  private readonly maxHarvestRecoveryAttempts: number
671
680
  private readonly provisionStaleMs: number
@@ -695,7 +704,6 @@ export class Dispatcher {
695
704
  ? deps.store
696
705
  : new RepoViewStore(deps.store, { repo: deps.repo })
697
706
  this.leaseTtlMs = deps.opts?.leaseTtlMs ?? 0
698
- this.doneState = deps.opts?.doneState ?? 'Done'
699
707
  this.slugNamingTimeoutMs = deps.opts?.slugNamingTimeoutMs ?? DEFAULT_SLUG_NAMING_TIMEOUT_MS
700
708
  this.maxHarvestRecoveryAttempts =
701
709
  deps.opts?.maxHarvestRecoveryAttempts ?? DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS
@@ -1508,6 +1516,10 @@ export class Dispatcher {
1508
1516
  generation,
1509
1517
  seed,
1510
1518
  tail,
1519
+ }).catch(() => {
1520
+ // Detached work must never reject into the dispatcher process. Failure
1521
+ // recording can itself fail on a store outage; durable boundaries and
1522
+ // lease expiry let the next tick repair or retry the unfinished work.
1511
1523
  })
1512
1524
  return 'kicked'
1513
1525
  }
@@ -1542,6 +1554,7 @@ export class Dispatcher {
1542
1554
  const slug = entry.slug
1543
1555
  let leaseClaimed = false
1544
1556
  let heartbeat: ReturnType<typeof setInterval> | undefined
1557
+ let boundary: 'workspace' | 'read' | 'dispatch' | 'launch' = 'workspace'
1545
1558
  const stage: EventPayload<'dispatch.failed'>['stage'] = 'workspace'
1546
1559
  try {
1547
1560
  if (entry.controller.signal.aborted) {
@@ -1606,12 +1619,19 @@ export class Dispatcher {
1606
1619
  await store.releaseLease(slug, entry.instance)
1607
1620
  leaseClaimed = false
1608
1621
  if (entry.controller.signal.aborted) return
1622
+ boundary = 'read'
1609
1623
  const record = await store.getBuild(slug)
1610
1624
  if (record === null) return
1611
- await (input.tail === 'dispatch'
1612
- ? this.completeDispatchBoundaries(record, input.seed)
1613
- : this.deps.launchRunner(slug))
1625
+ const queued = reduceBuild(await store.getEvents(slug)).status === 'queued'
1626
+ boundary = input.tail === 'dispatch' || queued ? 'dispatch' : 'launch'
1627
+ if (boundary === 'dispatch') await this.completeDispatchBoundaries(record, input.seed)
1628
+ else await this.deps.launchRunner(slug)
1614
1629
  } catch (error) {
1630
+ // Dispatch boundaries own their failure facts and exhaustion repair.
1631
+ // A failed tail read/write leaves durable unfinished work for the next
1632
+ // tick; it is not another provider workspace failure. Replacement
1633
+ // runner launch errors retain the existing infrastructure-budget path.
1634
+ if (boundary === 'read' || boundary === 'dispatch') return
1615
1635
  const events = await store.getEvents(slug)
1616
1636
  if (workspaces.recovery !== undefined) {
1617
1637
  const open = openWorkspace(events)
@@ -2485,6 +2505,16 @@ export class Dispatcher {
2485
2505
  return sha
2486
2506
  }
2487
2507
 
2508
+ private dispatchBudget(slug: string): Promise<boolean> {
2509
+ return ensureDispatchBudget(
2510
+ this.store,
2511
+ this.deps.ids,
2512
+ slug,
2513
+ this.deps.config.policy.maxDispatchAttempts,
2514
+ this.deps.workspaces.recovery !== undefined,
2515
+ )
2516
+ }
2517
+
2488
2518
  // ── b. Interrupted dispatch recovery ───────────────────────────────────────
2489
2519
 
2490
2520
  /** The claim-time `build.created` payload. Assets are omitted when the ticket
@@ -2539,6 +2569,7 @@ export class Dispatcher {
2539
2569
  ): Promise<DispatchOutcome> {
2540
2570
  const store = this.store
2541
2571
  const { config } = this.deps
2572
+ if (!(await this.dispatchBudget(record.slug))) return 'parked'
2542
2573
  let stage: EventPayload<'dispatch.failed'>['stage'] = 'create'
2543
2574
  try {
2544
2575
  let events = await store.getEvents(record.slug)
@@ -2628,8 +2659,6 @@ export class Dispatcher {
2628
2659
  } satisfies EventWrite<'workspace.provisioned'>)
2629
2660
  events = await store.getEvents(record.slug)
2630
2661
  }
2631
-
2632
- return await this.completeDispatchBoundaries(record, seed, report, launched)
2633
2662
  } catch (error) {
2634
2663
  const events = await store.getEvents(record.slug)
2635
2664
  if (stage === 'workspace' && this.deps.workspaces.recovery !== undefined) {
@@ -2654,8 +2683,10 @@ export class Dispatcher {
2654
2683
  },
2655
2684
  })
2656
2685
  report.dispatchFailed += 1
2686
+ await this.dispatchBudget(record.slug)
2657
2687
  return 'failed'
2658
2688
  }
2689
+ return this.completeDispatchBoundaries(record, seed, report, launched)
2659
2690
  }
2660
2691
 
2661
2692
  /** The post-workspace dispatch boundaries — spec, comment, launch — shared
@@ -2676,10 +2707,13 @@ export class Dispatcher {
2676
2707
  ): Promise<Exclude<DispatchOutcome, 'deferred'>> {
2677
2708
  const store = this.store
2678
2709
 
2710
+ if (!(await this.dispatchBudget(record.slug))) return 'parked'
2679
2711
  let stage: EventPayload<'dispatch.failed'>['stage'] = 'spec'
2680
2712
  let authored = false
2681
2713
  try {
2682
2714
  const events = await store.getEvents(record.slug)
2715
+ const state = reduceBuild(events)
2716
+ if (state.status !== 'queued' || state.discardRequest !== undefined) return 'parked'
2683
2717
  if (
2684
2718
  !events.some((event) => event.type === 'spec.imported' || event.type === 'spec.authored')
2685
2719
  ) {
@@ -2779,6 +2813,7 @@ export class Dispatcher {
2779
2813
  },
2780
2814
  })
2781
2815
  if (report !== undefined) report.dispatchFailed += 1
2816
+ await this.dispatchBudget(record.slug)
2782
2817
  return 'failed'
2783
2818
  }
2784
2819
  }
@@ -2810,9 +2845,11 @@ export class Dispatcher {
2810
2845
  // Queued itself means runner attachment is still missing. Always enter
2811
2846
  // the fact-driven helper: it skips every completed boundary and retries
2812
2847
  // only the final launch when setup is already durable.
2813
- if ((await this.attemptDispatchCompletion(record, launched, report)) === 'completed') {
2814
- report.recovered += 1
2815
- }
2848
+ const outcome = await this.attemptDispatchCompletion(record, launched, report)
2849
+ if (outcome === 'completed') report.recovered += 1
2850
+ // A failed boundary owns this tick's attempt; startup and lease recovery
2851
+ // must not bypass the unfinished dispatch boundary later in the tick.
2852
+ if (outcome === 'failed') launched.add(record.slug)
2816
2853
  }
2817
2854
  }
2818
2855
 
@@ -2853,6 +2890,12 @@ export class Dispatcher {
2853
2890
  continue
2854
2891
  }
2855
2892
 
2893
+ if (
2894
+ state.status === 'queued' &&
2895
+ !state.pendingCommands.some((command) => command.command === 'abort') &&
2896
+ !(await this.dispatchBudget(record.slug))
2897
+ )
2898
+ continue
2856
2899
  const decision = decideNextFromFacts(state, facts.log, config)
2857
2900
  if (decision.kind === 'wait') {
2858
2901
  if (decision.reason === 'blocked' && state.openEscalations.length > 0) {
@@ -2923,6 +2966,12 @@ export class Dispatcher {
2923
2966
  ) {
2924
2967
  continue
2925
2968
  }
2969
+ if (
2970
+ state.status === 'queued' &&
2971
+ !state.pendingCommands.some((command) => command.command === 'abort') &&
2972
+ !(await this.dispatchBudget(record.slug))
2973
+ )
2974
+ continue
2926
2975
  const decision = decideNextFromFacts(state, facts.log, this.deps.config)
2927
2976
  if (state.status === 'done' || state.status === 'aborted') continue
2928
2977
  // Raw history, read at most once and only by a branch that acts on it.
@@ -97,6 +97,7 @@ export async function runHarvestChild(
97
97
  uuids: randomUuids(),
98
98
  clock: systemClock,
99
99
  instance: input.instance,
100
+ ...(input.preparedRun ? { preparedRun: input.preparedRun } : {}),
100
101
  // Adopted holder: the guest skips its own claim, heartbeats the owning
101
102
  // dispatch loop's repository lease, and never releases it.
102
103
  ...(input.leaseHolder !== undefined ? { leaseHolder: input.leaseHolder } : {}),
@@ -92,9 +92,26 @@ export function classifyHarvestOutcome(
92
92
  input: HarvestExecutionClassificationInput,
93
93
  ): HarvestRunnerResult {
94
94
  const state = reduceHarvest(events)
95
- const latestRun = state.runs.at(-1)
95
+ const started = events.find(
96
+ (event) =>
97
+ event.type === 'harvest.execution.started' && event.seq === input.executionStartedSeq,
98
+ )
99
+ const authorization =
100
+ started?.type === 'harvest.execution.started'
101
+ ? events.find(
102
+ (event) =>
103
+ event.type === 'harvest.execution.authorized' &&
104
+ event.payload.execution === started.payload.execution,
105
+ )
106
+ : undefined
107
+ const selected =
108
+ authorization?.type === 'harvest.execution.authorized' ? authorization.payload : undefined
109
+ const latestRun = selected
110
+ ? state.runs.find((run) => run.run === selected.run)
111
+ : state.runs.at(-1)
96
112
  if (latestRun !== undefined) {
97
- const launch = latestRun.startedSeq > input.executionStartedSeq ? 'started' : 'resumed'
113
+ const launch =
114
+ selected?.launch ?? (latestRun.startedSeq > input.executionStartedSeq ? 'started' : 'resumed')
98
115
  // Recency gate: a terminal outcome counts only if its fact — the run's
99
116
  // terminal fact, or for an unresolved failure the failure fact itself
100
117
  // (`failureSeq`), which also covers exhaustion via `terminalSeq` —
@@ -1,3 +1,4 @@
1
+ import { delegateCredential } from '../store/remote/credentials'
1
2
  /**
2
3
  * Crash-safe repository-scoped harvest workflow:
3
4
  * scan -> synthesize <-> review -> file. It reuses `converge` for adversarial
@@ -100,11 +101,13 @@ export interface HarvestRunnerDeps {
100
101
  * executes in (hosted harvest). Stamped into the run's harvest.started
101
102
  * payload so the journal records where it ran; absent on local runs. */
102
103
  environment?: { provider: string; environmentId: string; sessionId?: string }
104
+ preparedRun?: { run: string }
103
105
  sessionEnv?: Record<string, string>
104
106
  opts?: HarvestRunnerOpts
105
107
  }
106
108
 
107
109
  export type HarvestRunnerResult =
110
+ | { outcome: 'prepared'; run: string; launch: 'started' | 'resumed' }
108
111
  | { outcome: 'idle' }
109
112
  | { outcome: 'held' }
110
113
  | { outcome: 'parked'; run?: string }
@@ -217,9 +220,9 @@ export class HarvestRunner {
217
220
  )
218
221
  }
219
222
 
220
- async run(): Promise<HarvestRunnerResult> {
223
+ async run(preparationOnly = false): Promise<HarvestRunnerResult> {
221
224
  const { store, repo, instance } = this.deps
222
- await store.ensureRepo(repo)
225
+ if (!this.deps.preparedRun) await store.ensureRepo(repo)
223
226
  if (
224
227
  this.deps.leaseHolder === undefined &&
225
228
  !(await store.claimRepoLease(repo, instance, this.leaseTtlMs))
@@ -233,101 +236,116 @@ export class HarvestRunner {
233
236
  await this.ensureLease()
234
237
  // Bounded read (AUT-489): reduceHarvest and the verdict/failure filters
235
238
  // below consume durable types only, so the subset is replay-equivalent.
236
- let events = await store.getRepoStateEvents(repo)
237
- let state = reduceHarvest(events)
238
- run = actionableHarvestRun(state)
239
-
240
- // Settle durable operator control before scanning or resuming work. The
241
- // selected run comes from the full journal, so a shadowed failure or
242
- // exhaustion barrier is recovered/reported before any later open run.
243
- await this.controlBoundary(run?.run)
244
- events = await store.getRepoStateEvents(repo)
245
- state = reduceHarvest(events)
246
- run = openHarvestRun(state)
247
-
248
- if (!run) {
249
- await this.controlBoundary()
250
- // Flat-cost gate (AUT-521): the pressure trigger is evaluated from
251
- // the journal and one repo-scoped digest batch read — no per-build
252
- // history reads — so the idle evaluation no longer grows with
253
- // accumulated finished builds. Only a tripped gate pays for the full
254
- // unclaimed-observation scan, because the packet artifact needs the
255
- // observation payloads no bounded source carries.
256
- // Bounded read (AUT-489): the gate's journal input is consumed only
257
- // through reduceHarvest, so the subset is replay-equivalent here too.
258
- const [gateEvents, digests] = await Promise.all([
259
- store.getRepoStateEvents(repo),
260
- store.getRepoBuildDigests(repo),
261
- ])
262
- const pressure = evaluateHarvestPressureFromDigests({
263
- digests,
264
- harvestEvents: gateEvents,
265
- policy: this.currentConfig().policy,
266
- })
267
- if (pressure.trigger === undefined) {
268
- // Treat the read's completion as a boundary even when the gate did
269
- // not trip, so a request arriving during the read is acknowledged
270
- // before this runner returns idle.
239
+ if (this.deps.preparedRun) {
240
+ const events = await store.getRepoStateEvents(repo)
241
+ const authorization = events.find(
242
+ (event) =>
243
+ event.type === 'harvest.execution.authorized' &&
244
+ event.payload.execution === instance &&
245
+ event.payload.run === this.deps.preparedRun!.run,
246
+ )
247
+ if (authorization?.type !== 'harvest.execution.authorized')
248
+ throw new Error('prepared harvest execution unavailable')
249
+ run = reduceHarvest(events).runs.find((run) => run.run === this.deps.preparedRun!.run)
250
+ initial = authorization.payload.launch
251
+ } else {
252
+ let events = await store.getRepoStateEvents(repo)
253
+ let state = reduceHarvest(events)
254
+ run = actionableHarvestRun(state)
255
+
256
+ // Settle durable operator control before scanning or resuming work. The
257
+ // selected run comes from the full journal, so a shadowed failure or
258
+ // exhaustion barrier is recovered/reported before any later open run.
259
+ await this.controlBoundary(run?.run)
260
+ events = await store.getRepoStateEvents(repo)
261
+ state = reduceHarvest(events)
262
+ run = openHarvestRun(state)
263
+
264
+ if (!run) {
271
265
  await this.controlBoundary()
272
- return { outcome: 'idle' }
273
- }
274
- await this.controlBoundary()
275
- const scan = await scanUnclaimedObservations(store, repo)
276
- // The trigger came from an earlier read than this scan, and a claim
277
- // may have landed in the window. Re-confirm the gate against the
278
- // fresh scan (pure, free): an emptied window returns idle exactly as
279
- // if the gate had not tripped, so a packet is only ever built from a
280
- // scan that itself triggered.
281
- const confirmed = evaluateHarvestPressure(scan, this.currentConfig().policy)
282
- if (confirmed.trigger === undefined) {
266
+ // Flat-cost gate (AUT-521): the pressure trigger is evaluated from
267
+ // the journal and one repo-scoped digest batch read — no per-build
268
+ // history reads — so the idle evaluation no longer grows with
269
+ // accumulated finished builds. Only a tripped gate pays for the full
270
+ // unclaimed-observation scan, because the packet artifact needs the
271
+ // observation payloads no bounded source carries.
272
+ // Bounded read (AUT-489): the gate's journal input is consumed only
273
+ // through reduceHarvest, so the subset is replay-equivalent here too.
274
+ const [gateEvents, digests] = await Promise.all([
275
+ store.getRepoStateEvents(repo),
276
+ store.getRepoBuildDigests(repo),
277
+ ])
278
+ const pressure = evaluateHarvestPressureFromDigests({
279
+ digests,
280
+ harvestEvents: gateEvents,
281
+ policy: this.currentConfig().policy,
282
+ })
283
+ if (pressure.trigger === undefined) {
284
+ // Treat the read's completion as a boundary even when the gate did
285
+ // not trip, so a request arriving during the read is acknowledged
286
+ // before this runner returns idle.
287
+ await this.controlBoundary()
288
+ return { outcome: 'idle' }
289
+ }
283
290
  await this.controlBoundary()
284
- return { outcome: 'idle' }
291
+ const scan = await scanUnclaimedObservations(store, repo)
292
+ // The trigger came from an earlier read than this scan, and a claim
293
+ // may have landed in the window. Re-confirm the gate against the
294
+ // fresh scan (pure, free): an emptied window returns idle exactly as
295
+ // if the gate had not tripped, so a packet is only ever built from a
296
+ // scan that itself triggered.
297
+ const confirmed = evaluateHarvestPressure(scan, this.currentConfig().policy)
298
+ if (confirmed.trigger === undefined) {
299
+ await this.controlBoundary()
300
+ return { outcome: 'idle' }
301
+ }
302
+ const runId = this.deps.ids('harvest')
303
+ const packet = await makeHarvestScanPacket({
304
+ store,
305
+ tickets: this.deps.tickets,
306
+ repo,
307
+ run: runId,
308
+ observations: scan.observations,
309
+ state: scan.state,
310
+ })
311
+ // Scan preparation is not a claim. Re-read operator control immediately
312
+ // before atomically committing the immutable snapshot.
313
+ await this.controlBoundary()
314
+ initial = 'started'
315
+ await store.appendRepoWithArtifacts(
316
+ repo,
317
+ [
318
+ {
319
+ kind: HARVEST_SCAN_ARTIFACT,
320
+ content: `${JSON.stringify(packet, null, 2)}\n`,
321
+ metadata: { run: runId, observations: packet.observations.length },
322
+ },
323
+ ],
324
+ (deposited) => ({
325
+ actor: KERNEL,
326
+ type: 'harvest.started',
327
+ payload: {
328
+ run: runId,
329
+ observations: packet.observations.map((item) => item.occurrence),
330
+ scan: artifactRef(deposited[0]!),
331
+ trigger: confirmed.trigger,
332
+ ...(this.deps.environment !== undefined
333
+ ? { environment: this.deps.environment }
334
+ : {}),
335
+ },
336
+ }),
337
+ )
338
+ // Bounded read (AUT-489): reduceHarvest consumes durable types only.
339
+ events = await store.getRepoStateEvents(repo)
340
+ run = reduceHarvest(events).runs.find((candidate) => candidate.run === runId)
341
+ if (!run) throw new Error(`harvest.started did not reduce run "${runId}"`)
342
+ // A request racing the claim parks this exact run; its snapshot stays
343
+ // open and is resumed rather than scanned again.
344
+ await this.controlBoundary(run.run)
285
345
  }
286
- const runId = this.deps.ids('harvest')
287
- const packet = await makeHarvestScanPacket({
288
- store,
289
- tickets: this.deps.tickets,
290
- repo,
291
- run: runId,
292
- observations: scan.observations,
293
- state: scan.state,
294
- })
295
- // Scan preparation is not a claim. Re-read operator control immediately
296
- // before atomically committing the immutable snapshot.
297
- await this.controlBoundary()
298
- initial = 'started'
299
- await store.appendRepoWithArtifacts(
300
- repo,
301
- [
302
- {
303
- kind: HARVEST_SCAN_ARTIFACT,
304
- content: `${JSON.stringify(packet, null, 2)}\n`,
305
- metadata: { run: runId, observations: packet.observations.length },
306
- },
307
- ],
308
- (deposited) => ({
309
- actor: KERNEL,
310
- type: 'harvest.started',
311
- payload: {
312
- run: runId,
313
- observations: packet.observations.map((item) => item.occurrence),
314
- scan: artifactRef(deposited[0]!),
315
- trigger: confirmed.trigger,
316
- ...(this.deps.environment !== undefined
317
- ? { environment: this.deps.environment }
318
- : {}),
319
- },
320
- }),
321
- )
322
- // Bounded read (AUT-489): reduceHarvest consumes durable types only.
323
- events = await store.getRepoStateEvents(repo)
324
- run = reduceHarvest(events).runs.find((candidate) => candidate.run === runId)
325
- if (!run) throw new Error(`harvest.started did not reduce run "${runId}"`)
326
- // A request racing the claim parks this exact run; its snapshot stays
327
- // open and is resumed rather than scanned again.
328
- await this.controlBoundary(run.run)
329
346
  }
330
-
347
+ if (!run) throw new Error('prepared harvest run unavailable')
348
+ if (preparationOnly) return { outcome: 'prepared', run: run.run, launch: initial }
331
349
  try {
332
350
  await this.ensureScanStep(run)
333
351
  await this.controlBoundary(run.run)
@@ -691,7 +709,7 @@ export class HarvestRunner {
691
709
  live.handle,
692
710
  `Revise harvest proposals for round ${spec.round}: run ab harvest context, address .ab/findings.json, then submit.`,
693
711
  {
694
- env: this.sessionEnv(spec.run, spec.step, spec.round, session),
712
+ env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
695
713
  ...(stream !== undefined ? { stream } : {}),
696
714
  },
697
715
  )
@@ -702,7 +720,7 @@ export class HarvestRunner {
702
720
  workspacePath,
703
721
  ...(target.model !== undefined ? { model: target.model } : {}),
704
722
  ...(target.args !== undefined ? { args: target.args } : {}),
705
- env: this.sessionEnv(spec.run, spec.step, spec.round, session),
723
+ env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
706
724
  ...(stream !== undefined ? { stream } : {}),
707
725
  })
708
726
  handle = turn.session
@@ -941,7 +959,7 @@ export class HarvestRunner {
941
959
  live.handle,
942
960
  `Revise harvest proposals for round ${spec.round}: run ab harvest context, address .ab/findings.json, then submit.`,
943
961
  {
944
- env: this.sessionEnv(spec.run, spec.step, spec.round, session),
962
+ env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
945
963
  ...(stream !== undefined ? { stream } : {}),
946
964
  },
947
965
  )
@@ -952,7 +970,7 @@ export class HarvestRunner {
952
970
  workspacePath,
953
971
  ...(resolved.model !== undefined ? { model: resolved.model } : {}),
954
972
  ...(resolved.args !== undefined ? { args: resolved.args } : {}),
955
- env: this.sessionEnv(spec.run, spec.step, spec.round, session),
973
+ env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
956
974
  ...(stream !== undefined ? { stream } : {}),
957
975
  })
958
976
  handle = turn.session
@@ -1391,6 +1409,7 @@ export class HarvestRunner {
1391
1409
  * give-up before returning. Initial/within-step failures do not spend this
1392
1410
  * outer budget and therefore remain recoverable. */
1393
1411
  private async settleRecoveryExhaustion(run: string): Promise<void> {
1412
+ if (this.deps.preparedRun) return
1394
1413
  await this.ensureLease()
1395
1414
  // Bounded read (AUT-489): reduceHarvest consumes durable types only.
1396
1415
  const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
@@ -1459,6 +1478,7 @@ export class HarvestRunner {
1459
1478
  const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
1460
1479
  const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
1461
1480
  if (decision.kind === 'proceed') return
1481
+ if (this.deps.preparedRun) throw new HarvestParkedSignal(run)
1462
1482
  if (decision.kind === 'park') {
1463
1483
  throw new HarvestParkedSignal(actionableHarvestRun(state)?.run ?? run)
1464
1484
  }
@@ -1602,15 +1622,26 @@ export class HarvestRunner {
1602
1622
  }
1603
1623
  }
1604
1624
 
1605
- private sessionEnv(
1625
+ private async sessionEnv(
1606
1626
  run: string,
1607
1627
  step: 'synthesize' | 'review',
1608
1628
  round: number,
1609
1629
  session: string,
1610
- ): Record<string, string> {
1630
+ ): Promise<Record<string, string>> {
1631
+ const parent = this.deps.sessionEnv
1632
+ const token =
1633
+ parent?.AB_STORE && /^https?:/i.test(parent.AB_STORE)
1634
+ ? await delegateCredential(parent.AB_STORE, parent.AB_TOKEN, {
1635
+ kind: 'harvest-phase',
1636
+ repo: this.deps.repo,
1637
+ id: run,
1638
+ session,
1639
+ })
1640
+ : undefined
1611
1641
  return {
1612
1642
  AB_STORE: 'local',
1613
1643
  ...this.deps.sessionEnv,
1644
+ ...(token ? { AB_TOKEN: token } : {}),
1614
1645
  AB_REPO: this.deps.repo,
1615
1646
  AB_HARVEST: run,
1616
1647
  AB_PHASE: `${step}@${round}`,
@@ -1638,7 +1669,18 @@ export class HarvestRunner {
1638
1669
  const open = target.openSessionStream
1639
1670
  if (open === undefined) return undefined
1640
1671
  const sink = createSessionStreamSink({
1641
- store: this.deps.store,
1672
+ store: {
1673
+ ...this.deps.store,
1674
+ createStream: (scope, label) =>
1675
+ this.deps.store.createStream(
1676
+ scope,
1677
+ this.deps.preparedRun
1678
+ ? `harvest:${this.deps.preparedRun.run}:${this.deps.instance}:${label}`
1679
+ : label,
1680
+ ),
1681
+ appendStreamParts: (id, parts) => this.deps.store.appendStreamParts(id, parts),
1682
+ closeStream: (id, outcome) => this.deps.store.closeStream(id, outcome),
1683
+ },
1642
1684
  scope: { kind: 'repo', repo: this.deps.repo },
1643
1685
  onDiagnostic: this.onDiagnostic,
1644
1686
  })
@@ -11,6 +11,7 @@ export const POLICY_PHASE_FAILURE_RESET_SCOPES = {
11
11
  'review-round-limit': 'matching-round',
12
12
  'verify-failure-limit': 'all-phase-rounds',
13
13
  'reconcile-no-progress': 'none',
14
+ 'dispatch-failure-limit': 'none',
14
15
  'setup-failure-limit': 'none',
15
16
  'infrastructure-failure-limit': 'none',
16
17
  'phase-attempt-limit': 'matching-round',
@@ -1,3 +1,5 @@
1
+ import { summaryBoundary } from '../kernel/pr-summary'
2
+ import { isRemoteWorkspace } from '../events/workspace-remote'
1
3
  /**
2
4
  * Last-chance publication settlement at the workspace-release boundary
3
5
  * (AUT-328). A pending `publication.requested` whose workspace is destroyed
@@ -37,7 +39,22 @@ export async function settlePublicationBeforeRelease(
37
39
  reason: string,
38
40
  ): Promise<AbEvent[]> {
39
41
  const request = latestUncompletedPublicationRequest(events)
40
- if (request === undefined) return events
42
+ if (request === undefined) {
43
+ const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
44
+ if (
45
+ workspace?.type === 'workspace.provisioned' &&
46
+ isRemoteWorkspace(workspace.payload) &&
47
+ summaryBoundary(events) !== undefined
48
+ ) {
49
+ try {
50
+ await deps.settlePublication?.(slug)
51
+ } catch {
52
+ /* Summary is best effort. */
53
+ }
54
+ return deps.store.getEvents(slug)
55
+ }
56
+ return events
57
+ }
41
58
  try {
42
59
  await deps.settlePublication?.(slug)
43
60
  } catch {
@@ -578,7 +578,7 @@ export class RemoteBuildStore implements BuildStore {
578
578
 
579
579
  async getRepoHighWater(repo: string): Promise<number> {
580
580
  // Additive route beside `state-events`; the store service and client
581
- // ship in version lockstep, so no protocol bump.
581
+ // retain this additive route throughout the supported client window.
582
582
  return this.requestJson('GET', `${this.repoPath(repo)}/high-water`, repoHighWaterSchema).then(
583
583
  (body) => (body as { seq: number }).seq,
584
584
  )