@defrex/autobuild 0.12.0 → 0.14.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 (151) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +138 -16
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +155 -73
  5. package/docs/configuration.md +41 -9
  6. package/docs/hosted-service.md +31 -5
  7. package/docs/operator-api.md +8 -1
  8. package/docs/remote-store-protocol.md +94 -2
  9. package/docs/remote-ticket-protocol.md +151 -9
  10. package/docs/setup.md +33 -7
  11. package/docs/ticket-migration.md +64 -0
  12. package/package.json +4 -3
  13. package/packages/core/src/cli/attention.ts +18 -164
  14. package/packages/core/src/cli/build-control.ts +23 -16
  15. package/packages/core/src/cli/bulk-control.ts +3 -2
  16. package/packages/core/src/cli/connect.ts +141 -38
  17. package/packages/core/src/cli/context.ts +23 -79
  18. package/packages/core/src/cli/dashboard/model.ts +36 -22
  19. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  20. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  21. package/packages/core/src/cli/dispatch-process.ts +14 -7
  22. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  23. package/packages/core/src/cli/dispatch.ts +304 -82
  24. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  25. package/packages/core/src/cli/harvest.ts +84 -42
  26. package/packages/core/src/cli/help.ts +14 -8
  27. package/packages/core/src/cli/historical-build-state.ts +27 -0
  28. package/packages/core/src/cli/installation.ts +67 -13
  29. package/packages/core/src/cli/main.ts +23 -5
  30. package/packages/core/src/cli/mcp.ts +2 -1
  31. package/packages/core/src/cli/pr-attachments.ts +10 -13
  32. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  33. package/packages/core/src/cli/pr-summary.ts +43 -13
  34. package/packages/core/src/cli/publication-settlement.ts +10 -20
  35. package/packages/core/src/cli/repository-status.ts +38 -2
  36. package/packages/core/src/cli/self-update.ts +5 -1
  37. package/packages/core/src/cli/status.ts +62 -32
  38. package/packages/core/src/cli/terminals.ts +25 -142
  39. package/packages/core/src/cli/ticket-assets.ts +7 -2
  40. package/packages/core/src/cli/ticket-editor.ts +82 -0
  41. package/packages/core/src/cli/ticket.ts +166 -36
  42. package/packages/core/src/cli/wait.ts +91 -84
  43. package/packages/core/src/cli/watch.ts +60 -97
  44. package/packages/core/src/config/pipeline-source.ts +4 -3
  45. package/packages/core/src/config/schema.ts +35 -3
  46. package/packages/core/src/connection.ts +3 -3
  47. package/packages/core/src/events/catalog.ts +3 -2
  48. package/packages/core/src/events/payloads.ts +6 -0
  49. package/packages/core/src/events/repository.ts +7 -3
  50. package/packages/core/src/integration/harness.ts +38 -4
  51. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
  52. package/packages/core/src/kernel/auto-merge.ts +19 -3
  53. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  54. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  55. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  56. package/packages/core/src/kernel/harvest.ts +25 -23
  57. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  58. package/packages/core/src/kernel/log-index-value.ts +38 -0
  59. package/packages/core/src/kernel/read-facts.ts +637 -0
  60. package/packages/core/src/kernel/reducer-registry.ts +139 -3
  61. package/packages/core/src/kernel/repository-attention.ts +179 -0
  62. package/packages/core/src/kernel/service-facts.ts +135 -0
  63. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  64. package/packages/core/src/ontology.ts +1 -0
  65. package/packages/core/src/operator/annotations.ts +12 -0
  66. package/packages/core/src/operator/control.ts +5 -6
  67. package/packages/core/src/operator/index.ts +7 -0
  68. package/packages/core/src/operator/protocol.ts +19 -2
  69. package/packages/core/src/operator/query.ts +37 -20
  70. package/packages/core/src/operator/read-view.ts +4 -13
  71. package/packages/core/src/operator/registry.ts +61 -8
  72. package/packages/core/src/operator/requests.ts +2 -14
  73. package/packages/core/src/operator/sandbox.ts +10 -9
  74. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  75. package/packages/core/src/operator/tickets.ts +132 -22
  76. package/packages/core/src/plugin-sdk/index.ts +19 -0
  77. package/packages/core/src/plugins/runtimes.ts +5 -1
  78. package/packages/core/src/ports/runner/session-env.ts +2 -0
  79. package/packages/core/src/ports/tickets/contract.ts +66 -1
  80. package/packages/core/src/ports/tickets/create.ts +48 -12
  81. package/packages/core/src/ports/tickets/fake.ts +30 -1
  82. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  83. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  84. package/packages/core/src/ports/tickets/file.ts +114 -120
  85. package/packages/core/src/ports/tickets/grants.ts +47 -0
  86. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  87. package/packages/core/src/ports/tickets/identity.ts +20 -0
  88. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  89. package/packages/core/src/ports/tickets/linear.ts +74 -9
  90. package/packages/core/src/ports/tickets/migration.ts +336 -0
  91. package/packages/core/src/ports/tickets/operations.ts +38 -7
  92. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  93. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  94. package/packages/core/src/ports/tickets/remote.ts +60 -3
  95. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  96. package/packages/core/src/ports/tickets/revision.ts +62 -0
  97. package/packages/core/src/ports/tickets/states.ts +61 -0
  98. package/packages/core/src/ports/types.ts +40 -3
  99. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  100. package/packages/core/src/ports/workspace/create.ts +5 -1
  101. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  102. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  103. package/packages/core/src/processes/build-child.ts +6 -6
  104. package/packages/core/src/processes/build-runner.ts +62 -70
  105. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  106. package/packages/core/src/processes/dispatcher.ts +223 -205
  107. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  108. package/packages/core/src/processes/harvest-child.ts +30 -4
  109. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  110. package/packages/core/src/processes/harvest-runner.ts +58 -74
  111. package/packages/core/src/processes/harvest.ts +72 -29
  112. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  113. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  114. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  115. package/packages/core/src/processes/publication-loss.ts +35 -21
  116. package/packages/core/src/processes/repo-view.ts +229 -1099
  117. package/packages/core/src/processes/tick-publication.ts +110 -0
  118. package/packages/core/src/store/adapter.ts +23 -0
  119. package/packages/core/src/store/build-scope.ts +17 -0
  120. package/packages/core/src/store/contract.ts +283 -1
  121. package/packages/core/src/store/local/schema.ts +1 -0
  122. package/packages/core/src/store/local/store.ts +180 -55
  123. package/packages/core/src/store/memory.ts +119 -29
  124. package/packages/core/src/store/phase-session.ts +17 -0
  125. package/packages/core/src/store/reduced-reads.ts +239 -0
  126. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  127. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  128. package/packages/core/src/store/reducer-values.ts +145 -0
  129. package/packages/core/src/store/remote/client.ts +41 -0
  130. package/packages/core/src/store/remote/compatibility.ts +2 -0
  131. package/packages/core/src/store/remote/index.ts +19 -0
  132. package/packages/core/src/store/remote/protocol.ts +16 -0
  133. package/packages/core/src/store/remote/version.ts +3 -1
  134. package/packages/core/src/store/repo-state-events.ts +1 -1
  135. package/packages/core/src/store/repo-tick.ts +72 -0
  136. package/packages/core/src/store/session-handle.ts +19 -0
  137. package/packages/core/src/store/snapshots.ts +10 -0
  138. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  139. package/packages/core/src/store/ticket-assets.ts +86 -8
  140. package/packages/core/src/store/types.ts +40 -3
  141. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  142. package/packages/core/src/testing/index.ts +8 -0
  143. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  144. package/skills/guide/SKILL.md +97 -12
  145. package/skills/guide/references/operator-api.md +8 -1
  146. package/skills/guide/references/remote-store-protocol.md +94 -2
  147. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  148. package/skills/guide/references/setup.md +33 -7
  149. package/skills/guide/references/ticket-migration.md +64 -0
  150. package/skills/tickets/SKILL.md +71 -0
  151. package/templates/hosted-service/package.json +2 -2
@@ -1,3 +1,12 @@
1
+ import type { BuildState } from '../kernel/reducer'
2
+ import {
3
+ readBuildFacts,
4
+ readRepositoryFacts,
5
+ readBuildSelector,
6
+ readDispatcherRunFacts,
7
+ } from '../store/reduced-reads'
8
+ import { shouldPublishTickFromFacts } from '../processes/tick-publication'
9
+ import { issueTicketGrant, ticketGrantRequest } from '../ports/tickets/grants'
1
10
  import { RemoteBuildStore } from '../store/remote/client'
2
11
  import { delegateCredential } from '../store/remote/credentials'
3
12
  import { isRemoteStoreRef } from './repo-state'
@@ -53,17 +62,14 @@ import { loadPlugins, pluginResolutionKind } from '../plugins/load'
53
62
  import type { PluginRegistry } from '../plugins/registry'
54
63
  import { materializePluginRuntimes } from '../plugins/runtimes'
55
64
  import { DISPATCHER, KERNEL, humanActor } from '../events/envelope'
56
- import type { AbEvent } from '../events/catalog'
57
65
  import type { RepositoryEventWrite } from '../events/repository'
58
66
  import { randomIds, randomUuids, type IdSource, type UuidSource } from '../ids'
59
- import { reduceDispatchSettings } from '../kernel/dispatch-settings'
67
+ import type { reduceDispatchSettings } from '../kernel/dispatch-settings'
60
68
  import {
61
69
  DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
62
70
  actionableHarvestRun,
63
71
  decideHarvestControl,
64
- reduceHarvest,
65
72
  } from '../kernel/harvest'
66
- import { reduceBuild } from '../kernel/reducer'
67
73
  import { dashboardBuildControl } from './dashboard/actions'
68
74
  import {
69
75
  buildDashboardFromProjected,
@@ -126,12 +132,10 @@ import {
126
132
  parseBuildConfigMetadata,
127
133
  parseDiagnostic,
128
134
  parseEffectiveBuildConfig,
129
- selectOpenWorkspace,
130
135
  } from '../processes/build-execution-state'
131
136
  import { HarvestRunner, type HarvestRunnerResult } from '../processes/harvest-runner'
132
137
  import {
133
138
  sampleUnclaimedObservationCount,
134
- readRepoEventsIfRecorded,
135
139
  evaluateHarvestPressureFromStore,
136
140
  } from '../processes/harvest'
137
141
  import { classifyHarvestOutcome } from '../processes/harvest-execution-state'
@@ -163,7 +167,13 @@ import {
163
167
  } from './repo-state'
164
168
  import { openStoreForRepoState } from './store-opening'
165
169
  import { DispatchFrontend } from './dispatch-frontend'
166
- import { systemClock, type BuildStore, type Clock } from '../store/types'
170
+ import {
171
+ systemClock,
172
+ type BuildStore,
173
+ type Clock,
174
+ type RepoTickCompletion,
175
+ type RepoTickFinish,
176
+ } from '../store/types'
167
177
  import { availableRelease } from './self-update'
168
178
  import {
169
179
  startUpgradeNotice,
@@ -205,7 +215,8 @@ export function slugNamingPrompt(spec: string): string {
205
215
  function definedEnv(env: Record<string, string | undefined>): Record<string, string> {
206
216
  const defined: Record<string, string> = {}
207
217
  for (const [key, value] of Object.entries(env)) {
208
- if (value !== undefined && key !== 'AB_TOKEN') defined[key] = value
218
+ if (value !== undefined && !['AB_TOKEN', 'AB_TICKET_TOKEN', 'AB_TICKET_URL'].includes(key))
219
+ defined[key] = value
209
220
  }
210
221
  return defined
211
222
  }
@@ -646,6 +657,7 @@ class DispatchLoop {
646
657
  * manufacturing a zero queue measurement or losing diagnostics. */
647
658
  private lastTickStatus = {
648
659
  queued: 0,
660
+ blockedDiagnostics: [] as string[],
649
661
  janitorDiagnostics: [] as string[],
650
662
  ticketDiagnostics: [] as string[],
651
663
  creationDiagnostics: [] as string[],
@@ -811,6 +823,7 @@ class DispatchLoop {
811
823
  forge: wiring.forge,
812
824
  config,
813
825
  getConfig: () => this.liveConfig.current().config,
826
+ repairBuildConfig: (slug) => this.publishBuildConfig(slug),
814
827
  repo: this.repoIdentity,
815
828
  // Origin mode: the forge answers remote base-branch questions; no
816
829
  // checkout git is probed for the served repository's origin.
@@ -847,7 +860,7 @@ class DispatchLoop {
847
860
  private async resolveBuildPipeline(slug: string): Promise<{
848
861
  source: Awaited<ReturnType<typeof resolvePipelineSource>>
849
862
  failures: PipelineSourceFailure[]
850
- events: AbEvent[]
863
+ observations: BuildState['observations']
851
864
  }> {
852
865
  const failures: PipelineSourceFailure[] = []
853
866
  try {
@@ -855,12 +868,12 @@ class DispatchLoop {
855
868
  // the record's `branch` (when present) is the same fact. Using the event
856
869
  // keeps this read to the events the caller already needs and avoids an
857
870
  // extra build-record lookup on the hot launch path.
858
- const events = await this.repoView.getEvents(slug)
859
- const workspace = selectOpenWorkspace(events)
871
+ const facts = (await readBuildFacts(this.repoView, slug)).value
872
+ const workspace = facts.workspace
860
873
  const source = await resolvePipelineSource({
861
874
  slug,
862
875
  record: { ...(workspace !== null ? { branch: workspace.branch } : {}) },
863
- events,
876
+ recordedBase: await readBuildSelector(this.repoView, slug, 'recordedBaseSha'),
864
877
  mode: this.opts.repository !== undefined ? 'origin' : 'checkout',
865
878
  forge: this.wiring.forge,
866
879
  checkout: this.opts.targetRepo,
@@ -868,13 +881,13 @@ class DispatchLoop {
868
881
  ...(workspace !== null ? { workspacePath: workspace.path } : {}),
869
882
  onFailure: (failure) => failures.push(failure),
870
883
  })
871
- return { source, failures, events }
884
+ return { source, failures, observations: facts.observations }
872
885
  } catch (error) {
873
886
  failures.push({
874
887
  kind: 'store',
875
888
  detail: `build-event read failed: ${error instanceof Error ? error.message : String(error)}`,
876
889
  })
877
- return { source: undefined, failures, events: [] }
890
+ return { source: undefined, failures, observations: [] }
878
891
  }
879
892
  }
880
893
 
@@ -898,7 +911,7 @@ class DispatchLoop {
898
911
  * naming the failed attempts; the documented fallbacks (an unpublished
899
912
  * branch, an unreadable legacy base) stay quiet. */
900
913
  private async publishBuildConfig(slug: string, snapshot = this.currentConfig()): Promise<void> {
901
- const { source, failures, events } = await this.resolveBuildPipeline(slug)
914
+ const { source, failures, observations } = await this.resolveBuildPipeline(slug)
902
915
  if (source !== undefined) {
903
916
  await this.depositBuildConfig(
904
917
  slug,
@@ -928,7 +941,7 @@ class DispatchLoop {
928
941
  // option left that keeps the launch alive.
929
942
  await this.depositBuildConfig(slug, snapshot, snapshot.config)
930
943
  }
931
- await this.filePipelineFallbackObservation(slug, reused, failures, events)
944
+ await this.filePipelineFallbackObservation(slug, reused, failures, observations)
932
945
  }
933
946
 
934
947
  /** File a build-scoped observation when a deposit hit an actionable
@@ -941,7 +954,7 @@ class DispatchLoop {
941
954
  slug: string,
942
955
  reused: PipelineSourceMeta | undefined,
943
956
  failures: PipelineSourceFailure[],
944
- events: readonly AbEvent[],
957
+ observations: BuildState['observations'],
945
958
  ): Promise<void> {
946
959
  const actionable = failures.filter(isActionablePipelineFailure)
947
960
  if (actionable.length === 0) return
@@ -958,11 +971,7 @@ class DispatchLoop {
958
971
  }`
959
972
  // Deduplicated against the build's log: a parked build re-attaches every
960
973
  // sweep, and an outage must not harvest one identical diagnostic per tick.
961
- if (
962
- events.some(
963
- (event) => event.type === 'observation.recorded' && event.payload.summary === summary,
964
- )
965
- ) {
974
+ if (observations.some((observation) => observation.summary === summary)) {
966
975
  return
967
976
  }
968
977
  try {
@@ -1103,9 +1112,11 @@ class DispatchLoop {
1103
1112
  // The view's bounded subset (AUT-489): the settings reducer consumes
1104
1113
  // durable types only. `refresh()` ran first in the tick, so this is the
1105
1114
  // delta-current journal, never a store read.
1106
- return reduceDispatchSettings(this.repoView.recordedJournal())
1115
+ return this.repoView.repositoryFacts().settings
1107
1116
  }
1108
1117
 
1118
+ private tickStartedAt: string | undefined
1119
+
1109
1120
  private dispatcherTick(resumeCurrent: boolean): Promise<Awaited<ReturnType<Dispatcher['tick']>>> {
1110
1121
  return this.serialize(async () => {
1111
1122
  // Refresh before every watch decision. The owner publishes atomically;
@@ -1118,11 +1129,12 @@ class DispatchLoop {
1118
1129
  await this.recordTickYielded()
1119
1130
  return emptyTickReport()
1120
1131
  }
1121
- await this.appendStatus({
1122
- actor: DISPATCHER,
1123
- type: 'dispatcher.tick-started',
1124
- payload: { run: this.runId },
1125
- })
1132
+ const startedAt = await this.repoView.beginRepoTick(this.repoIdentity, this.runId)
1133
+ if (startedAt === null) {
1134
+ this.superseded = true
1135
+ return emptyTickReport()
1136
+ }
1137
+ this.tickStartedAt = startedAt
1126
1138
 
1127
1139
  // Unclaimed observations are display-only and sampled once per interactive
1128
1140
  // dispatcher tick from the journal and one repo-scoped digest read
@@ -1171,6 +1183,7 @@ class DispatchLoop {
1171
1183
  this.queuedCount = publishedReport.queued
1172
1184
  this.lastTickStatus = {
1173
1185
  queued: publishedReport.queued,
1186
+ blockedDiagnostics: [...publishedReport.blockedDiagnostics],
1174
1187
  janitorDiagnostics: [...publishedReport.janitorDiagnostics],
1175
1188
  ticketDiagnostics: [...publishedReport.ticketDiagnostics],
1176
1189
  creationDiagnostics: [...publishedReport.creationDiagnostics],
@@ -1607,8 +1620,8 @@ class DispatchLoop {
1607
1620
  const { store } = this.wiring
1608
1621
  const repo = this.repoIdentity
1609
1622
  await store.ensureRepo(repo)
1610
- const events = await store.getRepoEvents(repo)
1611
- const state = reduceHarvest(events)
1623
+ const events = (await readRepositoryFacts(store, repo)).value
1624
+ const state = events.harvest
1612
1625
  const projected = projectHarvest(events)
1613
1626
  if (expectedRun === undefined || projected === undefined || projected.run !== expectedRun) {
1614
1627
  this.say('harvest run action ignored: selected run is no longer active')
@@ -2248,6 +2261,110 @@ class DispatchLoop {
2248
2261
 
2249
2262
  let tracked: Promise<void>
2250
2263
  tracked = (async () => {
2264
+ const ticketUrl =
2265
+ harvestDeps.config.tickets.source === 'hosted'
2266
+ ? (harvestDeps.config.tickets.url ?? (/^https?:/i.test(storeRef) ? storeRef : undefined))
2267
+ : undefined
2268
+ if (ticketUrl && ticketUrl.replace(/\/+$/, '') !== storeRef.replace(/\/+$/, '')) {
2269
+ const ticketToken = projectToken(ticketUrl, this.repoIdentity, {
2270
+ ...this.opts.env,
2271
+ AB_TOKEN: undefined,
2272
+ })
2273
+ if (!ticketToken)
2274
+ throw new Error('Independent hosted harvest requires a ticket-service project credential')
2275
+ const issued = await issueTicketGrant(
2276
+ ticketUrl,
2277
+ ticketToken,
2278
+ this.repoIdentity,
2279
+ harvestDeps.instance,
2280
+ 'runner',
2281
+ )
2282
+ let remoteRunnerStore: RemoteBuildStore | undefined
2283
+ let preparedRun: { run: string } | undefined
2284
+ let buildScoped: string | undefined
2285
+ let renew: ReturnType<typeof setInterval> | undefined
2286
+ try {
2287
+ if (/^https?:/i.test(storeRef)) {
2288
+ const prepared = await new HarvestRunner(harvestDeps).run(true)
2289
+ if (prepared.outcome !== 'prepared') {
2290
+ return prepared
2291
+ }
2292
+ preparedRun = { run: prepared.run }
2293
+ await this.repoView.appendRepo(this.repoIdentity, {
2294
+ actor: DISPATCHER,
2295
+ type: 'harvest.execution.authorized',
2296
+ payload: {
2297
+ execution: harvestDeps.instance,
2298
+ run: prepared.run,
2299
+ launch: prepared.launch,
2300
+ leaseHolder: this.repoLeaseHolder ?? harvestDeps.instance,
2301
+ },
2302
+ })
2303
+ buildScoped = await (this.wiring.credentialIssuer ?? delegateCredential)(
2304
+ storeRef,
2305
+ token,
2306
+ {
2307
+ kind: 'harvest-runner',
2308
+ execution: harvestDeps.instance,
2309
+ repo: this.repoIdentity,
2310
+ id: prepared.run,
2311
+ leaseHolder: this.repoLeaseHolder ?? harvestDeps.instance,
2312
+ },
2313
+ )
2314
+ remoteRunnerStore = new RemoteBuildStore({ url: storeRef, token: buildScoped })
2315
+ }
2316
+ const sessionEnv = {
2317
+ ...harvestDeps.sessionEnv,
2318
+ ...(buildScoped ? { AB_TOKEN: buildScoped } : {}),
2319
+ AB_TICKET_URL: ticketUrl,
2320
+ AB_TICKET_TOKEN: issued.token,
2321
+ }
2322
+ let renewalFailure: unknown
2323
+ renew = setInterval(() => {
2324
+ void ticketGrantRequest(ticketUrl, issued.token, {
2325
+ action: 'renew',
2326
+ repo: this.repoIdentity,
2327
+ id: issued.grant.id,
2328
+ }).catch((error) => {
2329
+ renewalFailure = error
2330
+ })
2331
+ }, 60_000)
2332
+ const runnerTickets = await createTicketSource(
2333
+ harvestDeps.config.tickets,
2334
+ { ...this.opts.env, ...sessionEnv, AB_TOKEN: undefined },
2335
+ this.opts.targetRepo,
2336
+ undefined,
2337
+ this.wiring.plugins,
2338
+ )
2339
+ const result = await new HarvestRunner({
2340
+ ...harvestDeps,
2341
+ ...(remoteRunnerStore ? { store: remoteRunnerStore } : {}),
2342
+ ...(preparedRun ? { preparedRun } : {}),
2343
+ tickets: runnerTickets,
2344
+ sessionEnv,
2345
+ }).run()
2346
+ if (renewalFailure) throw renewalFailure
2347
+ return result
2348
+ } finally {
2349
+ if (renew) clearInterval(renew)
2350
+ try {
2351
+ if (remoteRunnerStore && preparedRun) {
2352
+ await remoteRunnerStore.appendRepo(this.repoIdentity, {
2353
+ actor: DISPATCHER,
2354
+ type: 'harvest.execution.ended',
2355
+ payload: { execution: harvestDeps.instance, run: preparedRun.run },
2356
+ })
2357
+ await remoteRunnerStore.close()
2358
+ }
2359
+ } finally {
2360
+ await ticketGrantRequest(ticketUrl, ticketToken, {
2361
+ action: 'revoke',
2362
+ repo: this.repoIdentity,
2363
+ id: issued.grant.id,
2364
+ })
2365
+ }
2366
+ }
2367
+ }
2251
2368
  if (!/^https?:/i.test(storeRef)) return new HarvestRunner(harvestDeps).run()
2252
2369
  const prepared = await new HarvestRunner(harvestDeps).run(true)
2253
2370
  if (prepared.outcome !== 'prepared') return prepared
@@ -2274,7 +2391,12 @@ class DispatchLoop {
2274
2391
  harvestDeps.config.tickets.source === 'hosted'
2275
2392
  ? await createTicketSource(
2276
2393
  harvestDeps.config.tickets,
2277
- { ...this.opts.env, AB_STORE: storeRef, AB_TOKEN: scoped },
2394
+ {
2395
+ ...this.opts.env,
2396
+ AB_STORE: storeRef,
2397
+ AB_TOKEN: scoped,
2398
+ AB_HARVEST: prepared.run,
2399
+ },
2278
2400
  this.opts.targetRepo,
2279
2401
  resolveRepoStatePaths({
2280
2402
  repo: this.repoIdentity,
@@ -2369,6 +2491,8 @@ class DispatchLoop {
2369
2491
  const repo = this.repoIdentity
2370
2492
  let handle: BuildExecutionHandle | undefined
2371
2493
  let startedSeq: number | undefined
2494
+ let ticketAuthority: { url: string; token: string; id: string } | undefined
2495
+ let renewTickets: ReturnType<typeof setInterval> | undefined
2372
2496
  // Shared with `stopHostedHarvest` through the `hostedHarvest` entry: a
2373
2497
  // detach must stay observable to the completion chain even after the
2374
2498
  // entry is cleared, or the chain would classify and reap a live guest.
@@ -2388,8 +2512,8 @@ class DispatchLoop {
2388
2512
  // reduces durable types only. Take the journal delta first — guests and
2389
2513
  // other dispatchers write harvest facts between ticks.
2390
2514
  await this.repoView.refreshJournal()
2391
- const events = this.repoView.recordedJournal()
2392
- const state = reduceHarvest(events)
2515
+ const events = this.repoView.repositoryFacts().harvest
2516
+ const state = events
2393
2517
  const control = decideHarvestControl(state, this.maxHarvestRecoveryAttempts)
2394
2518
  const resumePending =
2395
2519
  actionableHarvestRun(state) !== undefined ||
@@ -2438,6 +2562,36 @@ class DispatchLoop {
2438
2562
  ),
2439
2563
  }
2440
2564
  : undefined
2565
+ const ticketConfig = this.currentConfig().config.tickets
2566
+ if (
2567
+ ticketConfig.source === 'hosted' &&
2568
+ ticketConfig.url &&
2569
+ ticketConfig.url.replace(/\/+$/, '') !== storeRef.replace(/\/+$/, '')
2570
+ ) {
2571
+ const ticketToken = projectToken(ticketConfig.url, repo, {
2572
+ ...this.opts.env,
2573
+ AB_TOKEN: undefined,
2574
+ })
2575
+ if (!ticketToken || !launchAuth)
2576
+ throw new Error('Independent harvest requires scoped build and ticket credentials')
2577
+ const issued = await issueTicketGrant(
2578
+ ticketConfig.url,
2579
+ ticketToken,
2580
+ repo,
2581
+ execution,
2582
+ 'runner',
2583
+ )
2584
+ ticketAuthority = { url: ticketConfig.url, token: issued.token, id: issued.grant.id }
2585
+ renewTickets = setInterval(() => {
2586
+ void ticketGrantRequest(ticketConfig.url!, issued.token, {
2587
+ action: 'renew',
2588
+ repo,
2589
+ id: issued.grant.id,
2590
+ }).catch(() => {
2591
+ // Expiry fails subsequent reads closed; the guest cannot regain project authority.
2592
+ })
2593
+ }, 60_000)
2594
+ }
2441
2595
  handle = await harvestExecution.start(
2442
2596
  {
2443
2597
  preparedRun: { run: prepared.run },
@@ -2447,7 +2601,9 @@ class DispatchLoop {
2447
2601
  baseBranch: this.currentConfig().config.baseBranch,
2448
2602
  ...(this.repoLeaseHolder !== undefined ? { leaseHolder: this.repoLeaseHolder } : {}),
2449
2603
  },
2450
- launchAuth,
2604
+ launchAuth && ticketAuthority
2605
+ ? { ...launchAuth, tickets: { url: ticketAuthority.url, token: ticketAuthority.token } }
2606
+ : launchAuth,
2451
2607
  )
2452
2608
  const identity = handle.identity
2453
2609
  if (
@@ -2525,6 +2681,13 @@ class DispatchLoop {
2525
2681
  )
2526
2682
  }
2527
2683
  } finally {
2684
+ if (renewTickets) clearInterval(renewTickets)
2685
+ if (ticketAuthority && !detached.value)
2686
+ await ticketGrantRequest(ticketAuthority.url, ticketAuthority.token, {
2687
+ action: 'revoke',
2688
+ repo,
2689
+ id: ticketAuthority.id,
2690
+ })
2528
2691
  this.hostedHarvest = undefined
2529
2692
  }
2530
2693
  }
@@ -2539,7 +2702,7 @@ class DispatchLoop {
2539
2702
  // Bounded subset (AUT-489): classifyHarvestOutcome reduces harvest facts
2540
2703
  // only. A guest wrote the facts through its own handle, so read the delta.
2541
2704
  await this.repoView.refreshJournal()
2542
- const events = this.repoView.recordedJournal()
2705
+ const events = this.repoView.repositoryFacts()
2543
2706
  const record = await store.getRepo(repo)
2544
2707
  return classifyHarvestOutcome(events, {
2545
2708
  executionStartedSeq: startedSeq,
@@ -2678,15 +2841,38 @@ class DispatchLoop {
2678
2841
  dependencyDiagnostics: _dependencyDiagnostics,
2679
2842
  ...counters
2680
2843
  } = merged
2681
- await this.appendStatus({
2844
+ const completed = {
2682
2845
  actor: DISPATCHER,
2683
- type: 'dispatcher.tick-completed',
2684
- payload: {
2685
- run: this.runId,
2686
- ...this.lastTickStatus,
2687
- counters,
2688
- },
2689
- })
2846
+ type: 'dispatcher.tick-completed' as const,
2847
+ payload: { run: this.runId, ...this.lastTickStatus, counters },
2848
+ }
2849
+ if (settlementOnly) {
2850
+ await this.appendStatus(completed)
2851
+ } else {
2852
+ const startedAt = this.tickStartedAt!
2853
+ const facts = this.repoView.repositoryFacts()
2854
+ const recent = facts.recentRuns.find(([run]) => run === this.runId)?.[1]
2855
+ const runFacts = recent?.start
2856
+ ? recent
2857
+ : (await readDispatcherRunFacts(this.repoView, this.repoIdentity, this.runId)).value
2858
+ const publish = shouldPublishTickFromFacts(merged, facts, runFacts, startedAt)
2859
+ const events: RepositoryEventWrite[] = publish
2860
+ ? [
2861
+ {
2862
+ actor: DISPATCHER,
2863
+ type: 'dispatcher.tick-started',
2864
+ payload: { run: this.runId, startedAt },
2865
+ },
2866
+ completed,
2867
+ ]
2868
+ : []
2869
+ const result = await this.finishTick({ outcome: 'completed', counters }, events)
2870
+ if (result === null) {
2871
+ this.superseded = true
2872
+ throw new Error('repository tick lease lost')
2873
+ }
2874
+ this.tickStartedAt = undefined
2875
+ }
2690
2876
 
2691
2877
  this.pendingHarvest.harvestStarted -= snapshot.harvestStarted
2692
2878
  this.pendingHarvest.harvestResumed -= snapshot.harvestResumed
@@ -2696,10 +2882,72 @@ class DispatchLoop {
2696
2882
  return merged
2697
2883
  }
2698
2884
 
2885
+ /** A remote finish can commit before its response is lost. Confirm that
2886
+ * exact tick through durable telemetry before acknowledging its counters;
2887
+ * never try to close an already committed boundary as a second failure. */
2888
+ private async finishTick(
2889
+ completion: RepoTickCompletion,
2890
+ events: RepositoryEventWrite[],
2891
+ ): Promise<RepoTickFinish | null> {
2892
+ try {
2893
+ return await this.repoView.finishRepoTick(this.repoIdentity, this.runId, completion, events)
2894
+ } catch (error) {
2895
+ const observation = (await this.wiring.store.getRepo(this.repoIdentity).catch(() => null))
2896
+ ?.tick
2897
+ const finished = observation?.finished
2898
+ if (
2899
+ observation?.run !== this.runId ||
2900
+ observation.open ||
2901
+ finished?.run !== this.runId ||
2902
+ finished.startedAt !== this.tickStartedAt ||
2903
+ finished.outcome !== completion.outcome ||
2904
+ (finished.outcome === 'failed' &&
2905
+ completion.outcome === 'failed' &&
2906
+ finished.error !== completion.error) ||
2907
+ (finished.outcome === 'completed' &&
2908
+ completion.outcome === 'completed' &&
2909
+ Object.entries(completion.counters).some(
2910
+ ([key, value]) => finished.counters[key as keyof typeof finished.counters] !== value,
2911
+ ))
2912
+ )
2913
+ throw error
2914
+ // The failed response bypassed the view's own-write fold. Refresh its
2915
+ // cursor from the journal, including any committed boundary pair.
2916
+ await this.repoView.refresh()
2917
+ return { observation, events: [] }
2918
+ }
2919
+ }
2920
+
2921
+ private async publishTickFailure(error: unknown): Promise<void> {
2922
+ const failed = {
2923
+ actor: DISPATCHER,
2924
+ type: 'dispatcher.tick-failed' as const,
2925
+ payload: { run: this.runId, error: error instanceof Error ? error.message : String(error) },
2926
+ }
2927
+ if (this.tickStartedAt === undefined) {
2928
+ await this.appendStatus(failed)
2929
+ return
2930
+ }
2931
+ const result = await this.finishTick({ outcome: 'failed', error: failed.payload.error }, [
2932
+ {
2933
+ actor: DISPATCHER,
2934
+ type: 'dispatcher.tick-started',
2935
+ payload: { run: this.runId, startedAt: this.tickStartedAt },
2936
+ },
2937
+ failed,
2938
+ ])
2939
+ if (result === null) {
2940
+ this.superseded = true
2941
+ return
2942
+ }
2943
+ this.tickStartedAt = undefined
2944
+ }
2945
+
2699
2946
  /** Settlement-only completions are serialized after operational ticks. Loop
2700
2947
  * so a result arriving during an awaited status append gets its own report. */
2701
2948
  private publishSettlementReports(): Promise<TickReport[]> {
2702
2949
  return this.serialize(async () => {
2950
+ if (this.superseded) return []
2703
2951
  const reports: TickReport[] = []
2704
2952
  while (this.hasPendingHarvestResults()) {
2705
2953
  const report = await this.publishTickReport(emptyTickReport(), true)
@@ -2765,7 +3013,7 @@ class DispatchLoop {
2765
3013
  { store: this.repoView, ids: this.wiring.ids },
2766
3014
  {
2767
3015
  slug: input.slug,
2768
- events: await this.repoView.getEvents(input.slug),
3016
+ events: (await readBuildFacts(this.repoView, input.slug)).value,
2769
3017
  maxAttempts: this.currentConfig().config.policy.maxInfrastructureAttempts,
2770
3018
  provider: input.identity?.provider ?? this.wiring.workspaces.name,
2771
3019
  workspaceRef: input.workspaceRef,
@@ -2807,18 +3055,9 @@ class DispatchLoop {
2807
3055
  this.failureNotice(`build ${slug} already held by another runner — skipped`)
2808
3056
  return 'already-active'
2809
3057
  }
2810
- const launchEvents = await this.repoView.getEvents(slug)
2811
- let workspaceRef: string | undefined
2812
- let workspaceProvider: string | undefined
2813
- for (const event of launchEvents) {
2814
- if (event.type === 'workspace.provisioned') {
2815
- workspaceRef = event.payload.ref
2816
- workspaceProvider = event.payload.provider
2817
- } else if (event.type === 'workspace.released') {
2818
- workspaceRef = undefined
2819
- workspaceProvider = undefined
2820
- }
2821
- }
3058
+ const workspace = (await readBuildFacts(this.repoView, slug)).value.workspace
3059
+ const workspaceRef = workspace?.ref
3060
+ const workspaceProvider = workspace?.provider
2822
3061
  if (workspaceRef === undefined) throw new Error(`build ${slug} has no open workspace`)
2823
3062
  // Execute through the runtime that owns the workspace: a build
2824
3063
  // provisioned before a provider switch still runs where it lives.
@@ -2901,7 +3140,7 @@ class DispatchLoop {
2901
3140
  }
2902
3141
 
2903
3142
  await this.repoView.refreshBuild(slug)
2904
- const state = reduceBuild(await this.repoView.getEvents(slug))
3143
+ const state = (await readBuildFacts(this.repoView, slug)).value.state
2905
3144
  if (diagnostic === null && exit.exitCode === 0) {
2906
3145
  await this.appendStatus({
2907
3146
  actor: DISPATCHER,
@@ -2968,12 +3207,7 @@ class DispatchLoop {
2968
3207
  if (leaseClaimed) {
2969
3208
  // The runner appended through its own handle: take the delta.
2970
3209
  await this.repoView.refreshBuild(slug)
2971
- const events = await this.repoView.getEvents(slug)
2972
- let workspaceRef = ''
2973
- for (const event of events) {
2974
- if (event.type === 'workspace.provisioned') workspaceRef = event.payload.ref
2975
- else if (event.type === 'workspace.released') workspaceRef = ''
2976
- }
3210
+ const workspaceRef = (await readBuildFacts(this.repoView, slug)).value.workspace?.ref ?? ''
2977
3211
  if (workspaceRef !== '') {
2978
3212
  await this.recordInfrastructureFailure({
2979
3213
  slug,
@@ -3249,7 +3483,7 @@ class DispatchLoop {
3249
3483
  // no anchor dependence (anchor analysis recorded; the frontend seed needed one
3250
3484
  // only because reduceDispatchStatus folds here too — it does not here).
3251
3485
  // Missing journal record stays a read-only empty journal (AUT-524).
3252
- const repositoryEvents = await readRepoEventsIfRecorded(this.wiring.store, this.repoIdentity)
3486
+ const repositoryEvents = (await readRepositoryFacts(this.wiring.store, this.repoIdentity)).value
3253
3487
 
3254
3488
  // Action-triggered and timer refreshes share the cache but may finish their
3255
3489
  // repository reads out of order. Never let an older build snapshot replace
@@ -3649,14 +3883,7 @@ class DispatchLoop {
3649
3883
  // invocation.
3650
3884
  if (this.pastDeadline()) return
3651
3885
  const initial = await this.dispatcherTick(true).catch(async (error: unknown) => {
3652
- await this.appendStatus({
3653
- actor: DISPATCHER,
3654
- type: 'dispatcher.tick-failed',
3655
- payload: {
3656
- run: this.runId,
3657
- error: error instanceof Error ? error.message : String(error),
3658
- },
3659
- })
3886
+ await this.publishTickFailure(error)
3660
3887
  throw error
3661
3888
  })
3662
3889
  const initialPrinted = this.printReport(initial, false)
@@ -3703,11 +3930,7 @@ class DispatchLoop {
3703
3930
  startup = false
3704
3931
  } catch (error) {
3705
3932
  const message = error instanceof Error ? error.message : String(error)
3706
- await this.appendStatus({
3707
- actor: DISPATCHER,
3708
- type: 'dispatcher.tick-failed',
3709
- payload: { run: this.runId, error: message },
3710
- })
3933
+ await this.publishTickFailure(error)
3711
3934
  this.warn(`tick failed: ${message}`)
3712
3935
  }
3713
3936
  if (this.stopped || this.superseded) break
@@ -4161,11 +4384,10 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
4161
4384
  const loop = new DispatchLoop(liveConfig, wiring, resolvedOpts, view)
4162
4385
  try {
4163
4386
  await loop.run()
4164
- const stopRequest = (
4387
+ const stopRequest =
4165
4388
  resolvedOpts.kernelRunId && resolvedOpts.signal?.aborted
4166
- ? await wiring.store.getRepoEvents(state.repo)
4167
- : []
4168
- ).find((event) => event.type === 'dispatcher.stop-requested' && event.payload.run === runId)
4389
+ ? (await readDispatcherRunFacts(wiring.store, state.repo, runId)).value.stopRequest
4390
+ : undefined
4169
4391
  const stoppedSignal =
4170
4392
  stopRequest?.type === 'dispatcher.stop-requested'
4171
4393
  ? stopRequest.payload.signal
@@ -4203,7 +4425,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
4203
4425
  // Every append this invocation made, `run-stopped` included, is in the
4204
4426
  // view; leave the reduced state behind so the next cold process reads
4205
4427
  // only what is newer than it.
4206
- await view.persistSnapshots({ force: true })
4428
+
4207
4429
  if (resolvedOpts.wire === undefined) await wiring.store.close()
4208
4430
  }
4209
4431
  }