@defrex/autobuild 0.11.0 → 0.13.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 (136) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +335 -31
  3. package/docs/architecture.md +125 -7
  4. package/docs/configuration.md +142 -21
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +240 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +97 -7
  9. package/docs/remote-store-protocol.md +199 -144
  10. package/docs/remote-ticket-protocol.md +249 -19
  11. package/docs/setup.md +199 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/docs/ticket-migration.md +64 -0
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/build-control.ts +12 -1
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +364 -0
  19. package/packages/core/src/cli/context.ts +7 -1
  20. package/packages/core/src/cli/dashboard/model.ts +4 -5
  21. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  22. package/packages/core/src/cli/dispatch.ts +336 -41
  23. package/packages/core/src/cli/harvest.ts +13 -3
  24. package/packages/core/src/cli/help.ts +51 -16
  25. package/packages/core/src/cli/init-validation.ts +27 -2
  26. package/packages/core/src/cli/installation.ts +67 -13
  27. package/packages/core/src/cli/main.ts +60 -16
  28. package/packages/core/src/cli/mcp.ts +9 -2
  29. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  30. package/packages/core/src/cli/pr-summary.ts +2 -0
  31. package/packages/core/src/cli/project-credentials.ts +132 -0
  32. package/packages/core/src/cli/repo-state.ts +15 -2
  33. package/packages/core/src/cli/self-update.ts +15 -1
  34. package/packages/core/src/cli/store-opening.ts +22 -14
  35. package/packages/core/src/cli/terminals.ts +0 -10
  36. package/packages/core/src/cli/ticket-assets.ts +7 -2
  37. package/packages/core/src/cli/ticket-editor.ts +82 -0
  38. package/packages/core/src/cli/ticket.ts +172 -35
  39. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  40. package/packages/core/src/cli/wait.ts +84 -17
  41. package/packages/core/src/cli/watch.ts +197 -60
  42. package/packages/core/src/config/live.ts +3 -0
  43. package/packages/core/src/config/schema.ts +90 -11
  44. package/packages/core/src/connection.ts +86 -0
  45. package/packages/core/src/events/catalog.ts +11 -2
  46. package/packages/core/src/events/payloads.ts +50 -0
  47. package/packages/core/src/events/repository.ts +13 -0
  48. package/packages/core/src/integration/harness.ts +37 -2
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
  50. package/packages/core/src/kernel/engine.ts +24 -0
  51. package/packages/core/src/kernel/generators/build-log.ts +11 -1
  52. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  53. package/packages/core/src/kernel/log-index.ts +17 -0
  54. package/packages/core/src/kernel/pr-summary.ts +89 -0
  55. package/packages/core/src/kernel/reducer-registry.ts +10 -0
  56. package/packages/core/src/kernel/reducer.ts +8 -0
  57. package/packages/core/src/ontology.ts +2 -0
  58. package/packages/core/src/operator/annotations.ts +12 -0
  59. package/packages/core/src/operator/index.ts +12 -0
  60. package/packages/core/src/operator/protocol.ts +19 -2
  61. package/packages/core/src/operator/query.ts +38 -16
  62. package/packages/core/src/operator/registry.ts +61 -8
  63. package/packages/core/src/operator/ticket-source-backend.ts +17 -20
  64. package/packages/core/src/operator/tickets.ts +136 -28
  65. package/packages/core/src/plugin-sdk/index.ts +7 -0
  66. package/packages/core/src/plugins/manifest.ts +5 -3
  67. package/packages/core/src/plugins/runtimes.ts +7 -1
  68. package/packages/core/src/ports/forge/fake.ts +13 -1
  69. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  70. package/packages/core/src/ports/forge/github.ts +29 -5
  71. package/packages/core/src/ports/forge/local-git.ts +12 -4
  72. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  73. package/packages/core/src/ports/runner/session-env.ts +3 -0
  74. package/packages/core/src/ports/tickets/contract.ts +66 -1
  75. package/packages/core/src/ports/tickets/create.ts +54 -10
  76. package/packages/core/src/ports/tickets/fake.ts +30 -1
  77. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  78. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  79. package/packages/core/src/ports/tickets/file.ts +114 -120
  80. package/packages/core/src/ports/tickets/grants.ts +47 -0
  81. package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
  82. package/packages/core/src/ports/tickets/identity.ts +20 -0
  83. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  84. package/packages/core/src/ports/tickets/linear.ts +74 -9
  85. package/packages/core/src/ports/tickets/migration.ts +336 -0
  86. package/packages/core/src/ports/tickets/operations.ts +38 -7
  87. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  88. package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
  89. package/packages/core/src/ports/tickets/remote.ts +61 -3
  90. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  91. package/packages/core/src/ports/tickets/revision.ts +62 -0
  92. package/packages/core/src/ports/tickets/states.ts +61 -0
  93. package/packages/core/src/ports/types.ts +40 -3
  94. package/packages/core/src/ports/workspace/build-execution.ts +6 -1
  95. package/packages/core/src/ports/workspace/create.ts +15 -2
  96. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  97. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  98. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  99. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  100. package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
  101. package/packages/core/src/processes/build-execution-state.ts +1 -1
  102. package/packages/core/src/processes/build-runner.ts +87 -26
  103. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  104. package/packages/core/src/processes/dispatcher.ts +168 -41
  105. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  106. package/packages/core/src/processes/harvest-child.ts +31 -4
  107. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  108. package/packages/core/src/processes/harvest-runner.ts +166 -104
  109. package/packages/core/src/processes/harvest.ts +34 -14
  110. package/packages/core/src/processes/phase-failure-budget.ts +2 -0
  111. package/packages/core/src/processes/publication-loss.ts +18 -1
  112. package/packages/core/src/processes/repo-view.ts +35 -0
  113. package/packages/core/src/store/contract.ts +32 -1
  114. package/packages/core/src/store/local/store.ts +1 -1
  115. package/packages/core/src/store/remote/client.ts +1 -1
  116. package/packages/core/src/store/remote/compatibility.ts +90 -0
  117. package/packages/core/src/store/remote/credentials.ts +28 -0
  118. package/packages/core/src/store/remote/index.ts +14 -1
  119. package/packages/core/src/store/remote/protocol.ts +25 -0
  120. package/packages/core/src/store/remote/token.ts +74 -13
  121. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  122. package/packages/core/src/store/ticket-assets.ts +86 -8
  123. package/packages/core/src/store/types.ts +1 -1
  124. package/packages/core/src/testing/index.ts +7 -0
  125. package/skills/guide/SKILL.md +232 -34
  126. package/skills/guide/references/operator-api.md +142 -13
  127. package/skills/guide/references/plugin-authoring.md +10 -2
  128. package/skills/guide/references/remote-store-protocol.md +199 -144
  129. package/skills/guide/references/remote-ticket-protocol.md +253 -18
  130. package/skills/guide/references/setup.md +199 -16
  131. package/skills/guide/references/ticket-dependencies.md +1 -1
  132. package/skills/guide/references/ticket-migration.md +64 -0
  133. package/skills/operate/SKILL.md +126 -36
  134. package/skills/tickets/SKILL.md +71 -0
  135. package/templates/hosted-service/package.json +13 -0
  136. package/templates/hosted-service/vercel.json +7 -0
@@ -1,3 +1,4 @@
1
+ import { ticketGrantRequest } from '../ports/tickets/grants'
1
2
  /**
2
3
  * Crash-safe repository-scoped harvest workflow: scan -> synthesize <-> review
3
4
  * -> file. It reuses `converge` for adversarial review semantics while every
@@ -56,6 +57,20 @@ export async function runHarvestChild(
56
57
  workspacePath: string = process.cwd(),
57
58
  ): Promise<void> {
58
59
  const fullStore: BuildStore = openStore(input.storeRef, env.AB_TOKEN)
60
+ const ticketAuthority =
61
+ env.AB_TICKET_URL && env.AB_TICKET_TOKEN
62
+ ? { url: env.AB_TICKET_URL, token: env.AB_TICKET_TOKEN }
63
+ : undefined
64
+ const renewTickets = ticketAuthority
65
+ ? setInterval(() => {
66
+ void ticketGrantRequest(ticketAuthority.url, ticketAuthority.token, {
67
+ action: 'renew',
68
+ repo: input.repo,
69
+ }).catch(() => {
70
+ // Expiry denies subsequent ticket operations; never regain project authority.
71
+ })
72
+ }, 60_000)
73
+ : undefined
59
74
  try {
60
75
  const config = await loadConfig(join(workspacePath, 'autobuild.toml'))
61
76
  // Full build-child composition parity, in the same order: plugins load
@@ -80,7 +95,7 @@ export async function runHarvestChild(
80
95
  const { targetRepo, localStateRoot } = guestTicketSourceArgs(input, workspacePath)
81
96
  const tickets = await createTicketSource(
82
97
  config.tickets,
83
- env,
98
+ { ...env, AB_HARVEST: input.preparedRun?.run ?? input.instance },
84
99
  targetRepo,
85
100
  localStateRoot,
86
101
  plugins,
@@ -97,11 +112,14 @@ export async function runHarvestChild(
97
112
  uuids: randomUuids(),
98
113
  clock: systemClock,
99
114
  instance: input.instance,
115
+ ...(input.preparedRun ? { preparedRun: input.preparedRun } : {}),
100
116
  // Adopted holder: the guest skips its own claim, heartbeats the owning
101
117
  // dispatch loop's repository lease, and never releases it.
102
118
  ...(input.leaseHolder !== undefined ? { leaseHolder: input.leaseHolder } : {}),
103
119
  ...(input.environment !== undefined ? { environment: input.environment } : {}),
104
120
  sessionEnv: {
121
+ ...(env.AB_TICKET_URL ? { AB_TICKET_URL: env.AB_TICKET_URL } : {}),
122
+ ...(env.AB_TICKET_TOKEN ? { AB_TICKET_TOKEN: env.AB_TICKET_TOKEN } : {}),
105
123
  AB_STORE: input.storeRef,
106
124
  ...(env.AB_TOKEN !== undefined && env.AB_TOKEN !== '' ? { AB_TOKEN: env.AB_TOKEN } : {}),
107
125
  },
@@ -111,10 +129,19 @@ export async function runHarvestChild(
111
129
  })
112
130
  await runner.run()
113
131
  } finally {
132
+ if (renewTickets) clearInterval(renewTickets)
114
133
  try {
115
- await fullStore.close()
116
- } catch {
117
- // Process teardown cannot change the already-durable runner outcome.
134
+ if (ticketAuthority)
135
+ await ticketGrantRequest(ticketAuthority.url, ticketAuthority.token, {
136
+ action: 'revoke',
137
+ repo: input.repo,
138
+ })
139
+ } finally {
140
+ try {
141
+ await fullStore.close()
142
+ } catch {
143
+ // Process teardown cannot change the already-durable runner outcome.
144
+ }
118
145
  }
119
146
  }
120
147
  }
@@ -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,6 @@
1
+ import { ticketIdentity } from '../ports/tickets/identity'
2
+ import { issueTicketGrant } from '../ports/tickets/grants'
3
+ import { delegateCredential } from '../store/remote/credentials'
1
4
  /**
2
5
  * Crash-safe repository-scoped harvest workflow:
3
6
  * scan -> synthesize <-> review -> file. It reuses `converge` for adversarial
@@ -100,11 +103,13 @@ export interface HarvestRunnerDeps {
100
103
  * executes in (hosted harvest). Stamped into the run's harvest.started
101
104
  * payload so the journal records where it ran; absent on local runs. */
102
105
  environment?: { provider: string; environmentId: string; sessionId?: string }
106
+ preparedRun?: { run: string }
103
107
  sessionEnv?: Record<string, string>
104
108
  opts?: HarvestRunnerOpts
105
109
  }
106
110
 
107
111
  export type HarvestRunnerResult =
112
+ | { outcome: 'prepared'; run: string; launch: 'started' | 'resumed' }
108
113
  | { outcome: 'idle' }
109
114
  | { outcome: 'held' }
110
115
  | { outcome: 'parked'; run?: string }
@@ -217,9 +222,9 @@ export class HarvestRunner {
217
222
  )
218
223
  }
219
224
 
220
- async run(): Promise<HarvestRunnerResult> {
225
+ async run(preparationOnly = false): Promise<HarvestRunnerResult> {
221
226
  const { store, repo, instance } = this.deps
222
- await store.ensureRepo(repo)
227
+ if (!this.deps.preparedRun) await store.ensureRepo(repo)
223
228
  if (
224
229
  this.deps.leaseHolder === undefined &&
225
230
  !(await store.claimRepoLease(repo, instance, this.leaseTtlMs))
@@ -233,101 +238,116 @@ export class HarvestRunner {
233
238
  await this.ensureLease()
234
239
  // Bounded read (AUT-489): reduceHarvest and the verdict/failure filters
235
240
  // 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.
241
+ if (this.deps.preparedRun) {
242
+ const events = await store.getRepoStateEvents(repo)
243
+ const authorization = events.find(
244
+ (event) =>
245
+ event.type === 'harvest.execution.authorized' &&
246
+ event.payload.execution === instance &&
247
+ event.payload.run === this.deps.preparedRun!.run,
248
+ )
249
+ if (authorization?.type !== 'harvest.execution.authorized')
250
+ throw new Error('prepared harvest execution unavailable')
251
+ run = reduceHarvest(events).runs.find((run) => run.run === this.deps.preparedRun!.run)
252
+ initial = authorization.payload.launch
253
+ } else {
254
+ let events = await store.getRepoStateEvents(repo)
255
+ let state = reduceHarvest(events)
256
+ run = actionableHarvestRun(state)
257
+
258
+ // Settle durable operator control before scanning or resuming work. The
259
+ // selected run comes from the full journal, so a shadowed failure or
260
+ // exhaustion barrier is recovered/reported before any later open run.
261
+ await this.controlBoundary(run?.run)
262
+ events = await store.getRepoStateEvents(repo)
263
+ state = reduceHarvest(events)
264
+ run = openHarvestRun(state)
265
+
266
+ if (!run) {
271
267
  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) {
268
+ // Flat-cost gate (AUT-521): the pressure trigger is evaluated from
269
+ // the journal and one repo-scoped digest batch read — no per-build
270
+ // history reads — so the idle evaluation no longer grows with
271
+ // accumulated finished builds. Only a tripped gate pays for the full
272
+ // unclaimed-observation scan, because the packet artifact needs the
273
+ // observation payloads no bounded source carries.
274
+ // Bounded read (AUT-489): the gate's journal input is consumed only
275
+ // through reduceHarvest, so the subset is replay-equivalent here too.
276
+ const [gateEvents, digests] = await Promise.all([
277
+ store.getRepoStateEvents(repo),
278
+ store.getRepoBuildDigests(repo),
279
+ ])
280
+ const pressure = evaluateHarvestPressureFromDigests({
281
+ digests,
282
+ harvestEvents: gateEvents,
283
+ policy: this.currentConfig().policy,
284
+ })
285
+ if (pressure.trigger === undefined) {
286
+ // Treat the read's completion as a boundary even when the gate did
287
+ // not trip, so a request arriving during the read is acknowledged
288
+ // before this runner returns idle.
289
+ await this.controlBoundary()
290
+ return { outcome: 'idle' }
291
+ }
292
+ await this.controlBoundary()
293
+ const scan = await scanUnclaimedObservations(store, repo)
294
+ // The trigger came from an earlier read than this scan, and a claim
295
+ // may have landed in the window. Re-confirm the gate against the
296
+ // fresh scan (pure, free): an emptied window returns idle exactly as
297
+ // if the gate had not tripped, so a packet is only ever built from a
298
+ // scan that itself triggered.
299
+ const confirmed = evaluateHarvestPressure(scan, this.currentConfig().policy)
300
+ if (confirmed.trigger === undefined) {
301
+ await this.controlBoundary()
302
+ return { outcome: 'idle' }
303
+ }
304
+ const runId = this.deps.ids('harvest')
305
+ const packet = await makeHarvestScanPacket({
306
+ store,
307
+ tickets: this.deps.tickets,
308
+ repo,
309
+ run: runId,
310
+ observations: scan.observations,
311
+ state: scan.state,
312
+ })
313
+ // Scan preparation is not a claim. Re-read operator control immediately
314
+ // before atomically committing the immutable snapshot.
283
315
  await this.controlBoundary()
284
- return { outcome: 'idle' }
316
+ initial = 'started'
317
+ await store.appendRepoWithArtifacts(
318
+ repo,
319
+ [
320
+ {
321
+ kind: HARVEST_SCAN_ARTIFACT,
322
+ content: `${JSON.stringify(packet, null, 2)}\n`,
323
+ metadata: { run: runId, observations: packet.observations.length },
324
+ },
325
+ ],
326
+ (deposited) => ({
327
+ actor: KERNEL,
328
+ type: 'harvest.started',
329
+ payload: {
330
+ run: runId,
331
+ observations: packet.observations.map((item) => item.occurrence),
332
+ scan: artifactRef(deposited[0]!),
333
+ trigger: confirmed.trigger,
334
+ ...(this.deps.environment !== undefined
335
+ ? { environment: this.deps.environment }
336
+ : {}),
337
+ },
338
+ }),
339
+ )
340
+ // Bounded read (AUT-489): reduceHarvest consumes durable types only.
341
+ events = await store.getRepoStateEvents(repo)
342
+ run = reduceHarvest(events).runs.find((candidate) => candidate.run === runId)
343
+ if (!run) throw new Error(`harvest.started did not reduce run "${runId}"`)
344
+ // A request racing the claim parks this exact run; its snapshot stays
345
+ // open and is resumed rather than scanned again.
346
+ await this.controlBoundary(run.run)
285
347
  }
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
348
  }
330
-
349
+ if (!run) throw new Error('prepared harvest run unavailable')
350
+ if (preparationOnly) return { outcome: 'prepared', run: run.run, launch: initial }
331
351
  try {
332
352
  await this.ensureScanStep(run)
333
353
  await this.controlBoundary(run.run)
@@ -691,7 +711,7 @@ export class HarvestRunner {
691
711
  live.handle,
692
712
  `Revise harvest proposals for round ${spec.round}: run ab harvest context, address .ab/findings.json, then submit.`,
693
713
  {
694
- env: this.sessionEnv(spec.run, spec.step, spec.round, session),
714
+ env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
695
715
  ...(stream !== undefined ? { stream } : {}),
696
716
  },
697
717
  )
@@ -702,7 +722,7 @@ export class HarvestRunner {
702
722
  workspacePath,
703
723
  ...(target.model !== undefined ? { model: target.model } : {}),
704
724
  ...(target.args !== undefined ? { args: target.args } : {}),
705
- env: this.sessionEnv(spec.run, spec.step, spec.round, session),
725
+ env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
706
726
  ...(stream !== undefined ? { stream } : {}),
707
727
  })
708
728
  handle = turn.session
@@ -941,7 +961,7 @@ export class HarvestRunner {
941
961
  live.handle,
942
962
  `Revise harvest proposals for round ${spec.round}: run ab harvest context, address .ab/findings.json, then submit.`,
943
963
  {
944
- env: this.sessionEnv(spec.run, spec.step, spec.round, session),
964
+ env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
945
965
  ...(stream !== undefined ? { stream } : {}),
946
966
  },
947
967
  )
@@ -952,7 +972,7 @@ export class HarvestRunner {
952
972
  workspacePath,
953
973
  ...(resolved.model !== undefined ? { model: resolved.model } : {}),
954
974
  ...(resolved.args !== undefined ? { args: resolved.args } : {}),
955
- env: this.sessionEnv(spec.run, spec.step, spec.round, session),
975
+ env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
956
976
  ...(stream !== undefined ? { stream } : {}),
957
977
  })
958
978
  handle = turn.session
@@ -1158,9 +1178,16 @@ export class HarvestRunner {
1158
1178
  )
1159
1179
  const packet = await loadScanPacket(store, repo, current.scan)
1160
1180
  const observations = packet.observations
1161
- const knownLedger = new Map(
1162
- packet.ledger.map((entry) => [`${entry.ticket.source}:${entry.ticket.id}`, entry]),
1163
- )
1181
+ const identity = ticketIdentity(tickets)
1182
+ const knownLedger = new Map<string, (typeof packet.ledger)[number]>()
1183
+ for (const entry of packet.ledger) {
1184
+ const current =
1185
+ entry.ticket.source === tickets.name ? await tickets.get(entry.ticket.id) : null
1186
+ knownLedger.set(await identity(entry.ticket), {
1187
+ ...entry,
1188
+ ticket: current?.ref ?? entry.ticket,
1189
+ })
1190
+ }
1164
1191
  const alreadyFiled = new Map(current.filed.map((entry) => [entry.proposalKey, entry]))
1165
1192
  const reservations = new Map(current.reservations.map((entry) => [entry.proposalKey, entry.id]))
1166
1193
  const dispositions: HarvestDisposition[] = []
@@ -1240,7 +1267,7 @@ export class HarvestRunner {
1240
1267
  ...(filed.blockers !== undefined ? { blockers: filed.blockers } : {}),
1241
1268
  })
1242
1269
  } else if (proposal.action === 'join') {
1243
- const known = knownLedger.get(`${proposal.ticket.source}:${proposal.ticket.id}`)
1270
+ const known = knownLedger.get(await identity(proposal.ticket))
1244
1271
  if (!known) {
1245
1272
  throw new Error(
1246
1273
  `approved join target ${proposal.ticket.source}:${proposal.ticket.id} is not in the ledger context`,
@@ -1391,6 +1418,7 @@ export class HarvestRunner {
1391
1418
  * give-up before returning. Initial/within-step failures do not spend this
1392
1419
  * outer budget and therefore remain recoverable. */
1393
1420
  private async settleRecoveryExhaustion(run: string): Promise<void> {
1421
+ if (this.deps.preparedRun) return
1394
1422
  await this.ensureLease()
1395
1423
  // Bounded read (AUT-489): reduceHarvest consumes durable types only.
1396
1424
  const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
@@ -1459,6 +1487,7 @@ export class HarvestRunner {
1459
1487
  const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
1460
1488
  const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
1461
1489
  if (decision.kind === 'proceed') return
1490
+ if (this.deps.preparedRun) throw new HarvestParkedSignal(run)
1462
1491
  if (decision.kind === 'park') {
1463
1492
  throw new HarvestParkedSignal(actionableHarvestRun(state)?.run ?? run)
1464
1493
  }
@@ -1602,15 +1631,37 @@ export class HarvestRunner {
1602
1631
  }
1603
1632
  }
1604
1633
 
1605
- private sessionEnv(
1634
+ private async sessionEnv(
1606
1635
  run: string,
1607
1636
  step: 'synthesize' | 'review',
1608
1637
  round: number,
1609
1638
  session: string,
1610
- ): Record<string, string> {
1639
+ ): Promise<Record<string, string>> {
1640
+ const parent = this.deps.sessionEnv
1641
+ const token =
1642
+ parent?.AB_STORE && /^https?:/i.test(parent.AB_STORE)
1643
+ ? await delegateCredential(parent.AB_STORE, parent.AB_TOKEN, {
1644
+ kind: 'harvest-phase',
1645
+ repo: this.deps.repo,
1646
+ id: run,
1647
+ session,
1648
+ })
1649
+ : undefined
1650
+ const ticketGrant =
1651
+ parent?.AB_TICKET_URL && parent.AB_TICKET_TOKEN
1652
+ ? await issueTicketGrant(
1653
+ parent.AB_TICKET_URL,
1654
+ parent.AB_TICKET_TOKEN,
1655
+ this.deps.repo,
1656
+ this.deps.instance,
1657
+ 'phase',
1658
+ )
1659
+ : undefined
1611
1660
  return {
1612
1661
  AB_STORE: 'local',
1613
1662
  ...this.deps.sessionEnv,
1663
+ ...(ticketGrant ? { AB_TICKET_TOKEN: ticketGrant.token } : {}),
1664
+ ...(token ? { AB_TOKEN: token } : {}),
1614
1665
  AB_REPO: this.deps.repo,
1615
1666
  AB_HARVEST: run,
1616
1667
  AB_PHASE: `${step}@${round}`,
@@ -1638,7 +1689,18 @@ export class HarvestRunner {
1638
1689
  const open = target.openSessionStream
1639
1690
  if (open === undefined) return undefined
1640
1691
  const sink = createSessionStreamSink({
1641
- store: this.deps.store,
1692
+ store: {
1693
+ ...this.deps.store,
1694
+ createStream: (scope, label) =>
1695
+ this.deps.store.createStream(
1696
+ scope,
1697
+ this.deps.preparedRun
1698
+ ? `harvest:${this.deps.preparedRun.run}:${this.deps.instance}:${label}`
1699
+ : label,
1700
+ ),
1701
+ appendStreamParts: (id, parts) => this.deps.store.appendStreamParts(id, parts),
1702
+ closeStream: (id, outcome) => this.deps.store.closeStream(id, outcome),
1703
+ },
1642
1704
  scope: { kind: 'repo', repo: this.deps.repo },
1643
1705
  onDiagnostic: this.onDiagnostic,
1644
1706
  })
@@ -327,12 +327,17 @@ export async function reconcileHarvestLedger(
327
327
  )
328
328
  if (entries.length === 0) return []
329
329
 
330
- const ids = entries.map((entry) => entry.ticket.id)
330
+ const ids = entries
331
+ .filter((entry) => entry.ticket.source === tickets.name)
332
+ .map((entry) => entry.ticket.id)
331
333
  const states = await tickets.dependencyStates(ids)
334
+ const byId = new Map(states.map((state) => [state.id, state]))
332
335
  const out: HarvestLedgerTicket[] = []
333
336
  for (let index = 0; index < entries.length; index += 1) {
334
337
  const entry = entries[index]!
335
- const dependency = states[index] ?? {
338
+ const dependency = (entry.ticket.source === tickets.name
339
+ ? byId.get(entry.ticket.id)
340
+ : undefined) ?? {
336
341
  id: entry.ticket.id,
337
342
  exists: false,
338
343
  resolved: false,
@@ -341,7 +346,7 @@ export async function reconcileHarvestLedger(
341
346
  const current = dependency.exists ? await tickets.get(entry.ticket.id) : null
342
347
  out.push({
343
348
  proposalKey: entry.proposalKey,
344
- ticket: structuredClone(entry.ticket),
349
+ ticket: structuredClone(current?.ref ?? entry.ticket),
345
350
  exists: dependency.exists,
346
351
  resolved: dependency.resolved,
347
352
  ...(current?.title !== undefined ? { title: current.title } : {}),
@@ -371,15 +376,25 @@ export async function reconcileOriginatingTickets(
371
376
  const states = matchingIds.length > 0 ? await tickets.dependencyStates(matchingIds) : []
372
377
  const byId = new Map(states.map((state) => [state.id, state]))
373
378
 
374
- return refs.map((ticket) => {
375
- const sourceMatches = ticket.source === tickets.name
376
- const state = sourceMatches ? byId.get(ticket.id) : undefined
377
- return {
378
- ticket,
379
- sourceMatches,
380
- exists: state?.exists ?? false,
381
- resolved: state?.resolved ?? false,
382
- }
379
+ const identity = new Set<string>()
380
+ const projected = await Promise.all(
381
+ refs.map(async (ticket) => {
382
+ const sourceMatches = ticket.source === tickets.name
383
+ const state = sourceMatches ? byId.get(ticket.id) : undefined
384
+ const current = sourceMatches && state?.exists ? await tickets.get(ticket.id) : null
385
+ return {
386
+ ticket: current?.ref ?? ticket,
387
+ sourceMatches,
388
+ exists: state?.exists ?? false,
389
+ resolved: state?.resolved ?? false,
390
+ }
391
+ }),
392
+ )
393
+ return projected.filter((entry) => {
394
+ const key = `${entry.ticket.source}:${entry.ticket.id}`
395
+ if (identity.has(key)) return false
396
+ identity.add(key)
397
+ return true
383
398
  })
384
399
  }
385
400
 
@@ -536,9 +551,14 @@ export async function resolveHarvestCreateBlockers(
536
551
  const state = byId.get(id)
537
552
  return state?.exists === true && !state.resolved
538
553
  })
554
+ const canonical = new Map<string, string>()
555
+ for (const id of candidates) {
556
+ if (byId.get(id)?.exists) canonical.set(id, (await tickets.get(id))?.ref.id ?? id)
557
+ }
558
+ const project = (ids: string[]) => [...new Set(ids.map((id) => canonical.get(id) ?? id))]
539
559
  return {
540
- blockedBy: [...new Set([...declared, ...derived])],
541
- provenance: { declared, derived },
560
+ blockedBy: project([...declared, ...derived]),
561
+ provenance: { declared: project(declared), derived: project(derived) },
542
562
  }
543
563
  }
544
564
 
@@ -11,6 +11,8 @@ 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',
15
+ 'execution-no-progress': 'none',
14
16
  'setup-failure-limit': 'none',
15
17
  'infrastructure-failure-limit': 'none',
16
18
  '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 {
@@ -1,3 +1,4 @@
1
+ import { executionNoProgressReducer } from './execution-no-progress'
1
2
  /**
2
3
  * The dispatcher's in-process repository view (AUT-647): a `BuildStore`
3
4
  * decorator that builds the state a tick needs once, at the first refresh, and
@@ -154,6 +155,7 @@ const EXTRA_REDUCERS = {
154
155
  finish: buildDigestReducer.finish,
155
156
  },
156
157
  logIndex: logIndexReducer,
158
+ executionNoProgress: executionNoProgressReducer,
157
159
  openExecution: openExecutionReducer,
158
160
  openBuildWorkspace: openBuildWorkspaceReducer,
159
161
  publicationState: publicationStateReducer,
@@ -186,6 +188,7 @@ const WORK_BUNDLE_REDUCER = { version: WORK_BUNDLE_VERSION }
186
188
  /** The facts a quiet-path stage needs about one build, without its event array. */
187
189
  export interface BuildFacts {
188
190
  state: BuildState
191
+ executionNoProgress: ReturnType<typeof executionNoProgressReducer.finish>
189
192
  log: LogIndex
190
193
  open: OpenExecution | null
191
194
  /** The open workspace (the latest provisioned, not yet released). */
@@ -279,6 +282,37 @@ function retainedShape(retained: unknown[], cursor: number): boolean {
279
282
  /** Structural checks on the small accumulators a later fold dereferences. */
280
283
  function extraAccsShape(extra: ExtraAccs): boolean {
281
284
  const field = (acc: unknown, key: string): unknown => (isObject(acc) ? acc[key] : undefined)
285
+ const emptyCount = field(extra.executionNoProgress, 'count')
286
+ const emptyOpen = field(extra.executionNoProgress, 'open')
287
+ if (
288
+ typeof emptyCount !== 'number' ||
289
+ !Number.isSafeInteger(emptyCount) ||
290
+ emptyCount < 0 ||
291
+ !Array.isArray(emptyOpen) ||
292
+ !emptyOpen.every((id) => typeof id === 'string')
293
+ )
294
+ return false
295
+ const emptyLatest = field(extra.executionNoProgress, 'latest')
296
+ if (
297
+ emptyLatest !== null &&
298
+ !(
299
+ isObject(emptyLatest) &&
300
+ typeof emptyLatest.instance === 'string' &&
301
+ typeof emptyLatest.seq === 'number'
302
+ )
303
+ )
304
+ return false
305
+ const active = field(extra.executionNoProgress, 'active')
306
+ if (
307
+ active !== null &&
308
+ !(
309
+ isObject(active) &&
310
+ typeof active.instance === 'string' &&
311
+ typeof active.seq === 'number' &&
312
+ typeof active.eligible === 'boolean'
313
+ )
314
+ )
315
+ return false
282
316
  const open = field(extra.openExecution, 'open')
283
317
  if (open !== null && !(isObject(open) && typeof open.instance === 'string')) return false
284
318
  const workspace = field(extra.openBuildWorkspace, 'open')
@@ -914,6 +948,7 @@ export class RepoViewStore implements BuildStore {
914
948
  const extra = log.extra!
915
949
  return {
916
950
  state: buildReducer.finish(log.acc!),
951
+ executionNoProgress: executionNoProgressReducer.finish(extra.executionNoProgress as never),
917
952
  log: logIndexReducer.finish(extra.logIndex as never),
918
953
  open: openExecutionReducer.finish(extra.openExecution as never),
919
954
  workspace: openBuildWorkspaceReducer.finish(extra.openBuildWorkspace as never),