@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,7 @@
1
+ import { ensureExecutionProgress } from './execution-no-progress'
2
+ import { ticketIdentity } from '../ports/tickets/identity'
3
+ import { ticketLifecycle } from '../ports/tickets/lifecycle'
4
+ import { ensureDispatchBudget } from './dispatch-failure-budget'
1
5
  import {
2
6
  reduceTicketSourceHealth,
3
7
  type TicketSourceDegradation,
@@ -61,6 +65,8 @@ import {
61
65
  } from '../kernel/auto-merge-default'
62
66
  import { reduceDispatchSettings } from '../kernel/dispatch-settings'
63
67
  import { decideNextFromFacts } from '../kernel/engine'
68
+ import { composeBuildConfig } from '../config/live'
69
+ import { BUILD_EFFECTIVE_CONFIG_ARTIFACT, parseEffectiveBuildConfig } from './build-execution-state'
64
70
  import {
65
71
  DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
66
72
  decideHarvestControl,
@@ -82,7 +88,7 @@ import type {
82
88
  import { TicketSourceRateLimitError } from '../ports/types'
83
89
  import type { Exec } from '../ports/workspace/git-worktree'
84
90
  import type { ArtifactMeta, BuildDigest, BuildRecord, BuildStore, Clock } from '../store/types'
85
- import { samplePinnedAssets } from '../store/ticket-assets'
91
+ import { preparePinnedAssets } from '../store/ticket-assets'
86
92
  import { resolveRepoOrigin } from '../cli/repo-state'
87
93
  import { specConformance } from '../spec-standard'
88
94
  export { specConformance, type SpecConformance } from '../spec-standard'
@@ -147,16 +153,11 @@ export function readyCriteria(config: Config): {
147
153
  * readiness is: the file tracker's grooming area IS the `triage/` directory,
148
154
  * while a Linear team only has a "Triage" workflow state when the team's
149
155
  * triage feature is enabled — Backlog is the state every Linear team has.
150
- * The hosted source is the credential-bearing Linear proxy and shares that
151
- * fallback. Plugin sources use the neutral `Triage` fallback unless configured.
156
+ * Hosted projects default to lowercase triage; plugin sources use the neutral
157
+ * `Triage` fallback unless configured.
152
158
  */
153
159
  export function defaultTriageState(config: Config): string {
154
- return (
155
- config.tickets.triageState ??
156
- (config.tickets.source === 'linear' || config.tickets.source === 'hosted'
157
- ? 'Backlog'
158
- : 'Triage')
159
- )
160
+ return ticketLifecycle(config.tickets).triage
160
161
  }
161
162
 
162
163
  /**
@@ -546,6 +547,8 @@ export interface DispatcherDeps {
546
547
  retiredWorkspaces?: readonly WorkspaceProvider[]
547
548
  forge: Forge
548
549
  config: Config
550
+ /** Configuration-only publication for incompatible pins; never launches a runner. */
551
+ repairBuildConfig?: (slug: string) => Promise<void>
549
552
  /** Current process snapshot, captured once at the beginning of each tick.
550
553
  * Omission preserves the cron/static-config API. */
551
554
  getConfig?: () => Config
@@ -646,6 +649,38 @@ export function heldByRepositoryPause(state: BuildState, paused: boolean): boole
646
649
  }
647
650
 
648
651
  export class Dispatcher {
652
+ /** Read at each decision boundary: artifact revisions are independent of events. */
653
+ private async decideBuild(
654
+ slug: string,
655
+ state: Parameters<typeof decideNextFromFacts>[0],
656
+ log: Parameters<typeof decideNextFromFacts>[1],
657
+ report: TickReport,
658
+ ): Promise<ReturnType<typeof decideNextFromFacts> | undefined> {
659
+ let revision: number | undefined
660
+ try {
661
+ let artifact = await this.store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
662
+ if (artifact === null) return decideNextFromFacts(state, log, this.deps.config)
663
+ revision = artifact.meta.revision
664
+ let pinned: Config
665
+ try {
666
+ pinned = parseEffectiveBuildConfig(artifact)
667
+ } catch (error) {
668
+ if (this.deps.repairBuildConfig === undefined) throw error
669
+ await this.deps.repairBuildConfig(slug)
670
+ artifact = await this.store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
671
+ if (artifact === null) throw new Error('configuration repair deposited no pipeline')
672
+ revision = artifact.meta.revision
673
+ pinned = parseEffectiveBuildConfig(artifact)
674
+ }
675
+ return decideNextFromFacts(state, log, composeBuildConfig(pinned, this.deps.config))
676
+ } catch (error) {
677
+ report.blockedDiagnostics.push(
678
+ `build ${slug} pipeline${revision === undefined ? '' : ` revision ${revision}`} unavailable: pipeline decisions withheld; next tick retries. Restore readable build-branch configuration or use a compatible dispatcher version. ${error instanceof Error ? error.message : String(error)}`,
679
+ )
680
+ return undefined
681
+ }
682
+ }
683
+
649
684
  /** Memoized normalized origin of the served repository (one git call per
650
685
  * dispatcher), recorded on created builds so ambient reads can accept a
651
686
  * differently located checkout of the same repository by origin equality.
@@ -665,7 +700,15 @@ export class Dispatcher {
665
700
  return this.repoOrigin
666
701
  }
667
702
  private readonly leaseTtlMs: number
668
- private readonly doneState: string
703
+ private get doneState(): string {
704
+ return (
705
+ this.deps.opts?.doneState ??
706
+ (this.deps.config.tickets.source === 'hosted'
707
+ ? ticketLifecycle(this.deps.config.tickets).done
708
+ : undefined) ??
709
+ 'Done'
710
+ )
711
+ }
669
712
  private readonly slugNamingTimeoutMs: number
670
713
  private readonly maxHarvestRecoveryAttempts: number
671
714
  private readonly provisionStaleMs: number
@@ -695,7 +738,6 @@ export class Dispatcher {
695
738
  ? deps.store
696
739
  : new RepoViewStore(deps.store, { repo: deps.repo })
697
740
  this.leaseTtlMs = deps.opts?.leaseTtlMs ?? 0
698
- this.doneState = deps.opts?.doneState ?? 'Done'
699
741
  this.slugNamingTimeoutMs = deps.opts?.slugNamingTimeoutMs ?? DEFAULT_SLUG_NAMING_TIMEOUT_MS
700
742
  this.maxHarvestRecoveryAttempts =
701
743
  deps.opts?.maxHarvestRecoveryAttempts ?? DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS
@@ -980,6 +1022,23 @@ export class Dispatcher {
980
1022
  }
981
1023
  }
982
1024
 
1025
+ private async activeTicketIdentities(refs: TicketRef[], listed: Ticket[]): Promise<Set<string>> {
1026
+ const key = (ref: TicketRef) => `${ref.source}:${ref.id}`
1027
+ const ids = new Set(refs.map(key))
1028
+ if (this.deps.tickets.name !== 'hosted' || listed.length === 0) return ids
1029
+ const listedIds = new Set(listed.map((ticket) => key(ticket.ref)))
1030
+ const identity = ticketIdentity(this.deps.tickets)
1031
+ try {
1032
+ for (const ref of refs) {
1033
+ if (ref.source === 'hosted' && !listedIds.has(key(ref))) ids.add(await identity(ref))
1034
+ }
1035
+ } catch (error) {
1036
+ await this.sourceFailure(error, 'ready-scan')
1037
+ throw error
1038
+ }
1039
+ return ids
1040
+ }
1041
+
983
1042
  /** Observation-only ready listing for Store-backed frontends while intake is
984
1043
  * disabled. It shares the active-ticket exclusion with dispatch but performs
985
1044
  * no dependency fetch, claim, bounce, build, workspace, or launch side
@@ -991,20 +1050,21 @@ export class Dispatcher {
991
1050
  }> {
992
1051
  this.deps.config = this.deps.getConfig?.() ?? this.deps.config
993
1052
  await this.store.refresh()
994
- const activeTicketIds = new Set<string>()
995
- for (const { record, digest } of await this.repoBuildDigests()) {
996
- if (record.ticket !== undefined && digest.terminal === undefined) {
997
- activeTicketIds.add(record.ticket.id)
998
- }
999
- }
1000
1053
  const report = emptyTickReport()
1001
1054
  this.restoreScan(report)
1002
1055
  const checkpoint = await this.scanCheckpoint('observation')
1003
1056
  if (checkpoint.due) {
1004
1057
  await this.store.refreshJournal()
1005
1058
  const listing = await this.listReady()
1059
+ const refs: TicketRef[] = []
1060
+ for (const { record, digest } of await this.repoBuildDigests()) {
1061
+ if (record.ticket && digest.terminal === undefined) refs.push(record.ticket)
1062
+ }
1063
+ const activeTicketIds = await this.activeTicketIdentities(refs, listing.tickets)
1006
1064
  await this.store.refreshJournal()
1007
- report.queued = listing.tickets.filter((ticket) => !activeTicketIds.has(ticket.ref.id)).length
1065
+ report.queued = listing.tickets.filter(
1066
+ (ticket) => !activeTicketIds.has(`${ticket.ref.source}:${ticket.ref.id}`),
1067
+ ).length
1008
1068
  report.invalidTickets = listing.diagnostics.length
1009
1069
  report.ticketDiagnostics = [...listing.diagnostics]
1010
1070
  report.dependencyBlocked = 0
@@ -1165,9 +1225,24 @@ export class Dispatcher {
1165
1225
  }
1166
1226
  }
1167
1227
 
1168
- private async launch(slug: string, launched: Set<string>): Promise<LaunchRunnerResult> {
1228
+ private async launch(
1229
+ slug: string,
1230
+ launched: Set<string>,
1231
+ report: TickReport = emptyTickReport(),
1232
+ ): Promise<LaunchRunnerResult> {
1169
1233
  if (launched.has(slug)) return 'already-active'
1170
1234
  launched.add(slug)
1235
+ if (
1236
+ !(await ensureExecutionProgress(
1237
+ this.store,
1238
+ this.deps.ids,
1239
+ slug,
1240
+ this.deps.config,
1241
+ (state, log) => this.decideBuild(slug, state, log, report),
1242
+ ))
1243
+ ) {
1244
+ return 'already-active'
1245
+ }
1171
1246
  return this.deps.launchRunner(slug)
1172
1247
  }
1173
1248
 
@@ -1508,6 +1583,10 @@ export class Dispatcher {
1508
1583
  generation,
1509
1584
  seed,
1510
1585
  tail,
1586
+ }).catch(() => {
1587
+ // Detached work must never reject into the dispatcher process. Failure
1588
+ // recording can itself fail on a store outage; durable boundaries and
1589
+ // lease expiry let the next tick repair or retry the unfinished work.
1511
1590
  })
1512
1591
  return 'kicked'
1513
1592
  }
@@ -1542,6 +1621,7 @@ export class Dispatcher {
1542
1621
  const slug = entry.slug
1543
1622
  let leaseClaimed = false
1544
1623
  let heartbeat: ReturnType<typeof setInterval> | undefined
1624
+ let boundary: 'workspace' | 'read' | 'dispatch' | 'launch' = 'workspace'
1545
1625
  const stage: EventPayload<'dispatch.failed'>['stage'] = 'workspace'
1546
1626
  try {
1547
1627
  if (entry.controller.signal.aborted) {
@@ -1606,12 +1686,19 @@ export class Dispatcher {
1606
1686
  await store.releaseLease(slug, entry.instance)
1607
1687
  leaseClaimed = false
1608
1688
  if (entry.controller.signal.aborted) return
1689
+ boundary = 'read'
1609
1690
  const record = await store.getBuild(slug)
1610
1691
  if (record === null) return
1611
- await (input.tail === 'dispatch'
1612
- ? this.completeDispatchBoundaries(record, input.seed)
1613
- : this.deps.launchRunner(slug))
1692
+ const queued = reduceBuild(await store.getEvents(slug)).status === 'queued'
1693
+ boundary = input.tail === 'dispatch' || queued ? 'dispatch' : 'launch'
1694
+ if (boundary === 'dispatch') await this.completeDispatchBoundaries(record, input.seed)
1695
+ else await this.deps.launchRunner(slug)
1614
1696
  } catch (error) {
1697
+ // Dispatch boundaries own their failure facts and exhaustion repair.
1698
+ // A failed tail read/write leaves durable unfinished work for the next
1699
+ // tick; it is not another provider workspace failure. Replacement
1700
+ // runner launch errors retain the existing infrastructure-budget path.
1701
+ if (boundary === 'read' || boundary === 'dispatch') return
1615
1702
  const events = await store.getEvents(slug)
1616
1703
  if (workspaces.recovery !== undefined) {
1617
1704
  const open = openWorkspace(events)
@@ -2069,7 +2156,7 @@ export class Dispatcher {
2069
2156
  }
2070
2157
  // The dispatcher never runs agents (§15.7): re-attach a build-runner,
2071
2158
  // which executes the reconcile epilogue phase.
2072
- await this.launch(record.slug, launched)
2159
+ await this.launch(record.slug, launched, report)
2073
2160
  report.conflicted += 1
2074
2161
  return
2075
2162
  }
@@ -2103,12 +2190,12 @@ export class Dispatcher {
2103
2190
  const latest = await store.buildFacts(record.slug)
2104
2191
  const latestState = latest.state
2105
2192
  const latestIntent = pendingAutoMerge(latestState)
2106
- const decision = decideNextFromFacts(latest.state, latest.log, this.deps.config)
2193
+ const decision = await this.decideBuild(record.slug, latest.state, latest.log, report)
2107
2194
  if (
2108
2195
  latestState.pr?.number === pr.number &&
2109
2196
  latestIntent?.enabled === true &&
2110
2197
  latestIntent.commandSeq === autoMerge.commandSeq &&
2111
- decision.kind === 'wait' &&
2198
+ decision?.kind === 'wait' &&
2112
2199
  decision.reason === 'awaiting-pr'
2113
2200
  ) {
2114
2201
  await forge.squashMerge(workspacePath, pr.number, result.headSha)
@@ -2485,6 +2572,16 @@ export class Dispatcher {
2485
2572
  return sha
2486
2573
  }
2487
2574
 
2575
+ private dispatchBudget(slug: string): Promise<boolean> {
2576
+ return ensureDispatchBudget(
2577
+ this.store,
2578
+ this.deps.ids,
2579
+ slug,
2580
+ this.deps.config.policy.maxDispatchAttempts,
2581
+ this.deps.workspaces.recovery !== undefined,
2582
+ )
2583
+ }
2584
+
2488
2585
  // ── b. Interrupted dispatch recovery ───────────────────────────────────────
2489
2586
 
2490
2587
  /** The claim-time `build.created` payload. Assets are omitted when the ticket
@@ -2539,6 +2636,7 @@ export class Dispatcher {
2539
2636
  ): Promise<DispatchOutcome> {
2540
2637
  const store = this.store
2541
2638
  const { config } = this.deps
2639
+ if (!(await this.dispatchBudget(record.slug))) return 'parked'
2542
2640
  let stage: EventPayload<'dispatch.failed'>['stage'] = 'create'
2543
2641
  try {
2544
2642
  let events = await store.getEvents(record.slug)
@@ -2553,7 +2651,14 @@ export class Dispatcher {
2553
2651
  // can only sample the ticket's current assets (SPEC §6.3).
2554
2652
  const assets =
2555
2653
  seed?.assets ??
2556
- (await samplePinnedAssets(store, record.repo, record.ticket.id).catch(() => []))
2654
+ (await preparePinnedAssets(this.deps.tickets, store, record.repo, record.ticket.id).catch(
2655
+ (error) => {
2656
+ // Preserve legacy recovery only without native assets. Native preparation
2657
+ // must never publish an empty pin after losing the source bytes.
2658
+ if (this.deps.tickets.assets || this.deps.tickets.name === 'hosted') throw error
2659
+ return []
2660
+ },
2661
+ ))
2557
2662
  await store.appendIfCurrent(record.slug, 0, {
2558
2663
  actor: DISPATCHER,
2559
2664
  type: 'build.created',
@@ -2628,8 +2733,6 @@ export class Dispatcher {
2628
2733
  } satisfies EventWrite<'workspace.provisioned'>)
2629
2734
  events = await store.getEvents(record.slug)
2630
2735
  }
2631
-
2632
- return await this.completeDispatchBoundaries(record, seed, report, launched)
2633
2736
  } catch (error) {
2634
2737
  const events = await store.getEvents(record.slug)
2635
2738
  if (stage === 'workspace' && this.deps.workspaces.recovery !== undefined) {
@@ -2654,8 +2757,10 @@ export class Dispatcher {
2654
2757
  },
2655
2758
  })
2656
2759
  report.dispatchFailed += 1
2760
+ await this.dispatchBudget(record.slug)
2657
2761
  return 'failed'
2658
2762
  }
2763
+ return this.completeDispatchBoundaries(record, seed, report, launched)
2659
2764
  }
2660
2765
 
2661
2766
  /** The post-workspace dispatch boundaries — spec, comment, launch — shared
@@ -2676,10 +2781,13 @@ export class Dispatcher {
2676
2781
  ): Promise<Exclude<DispatchOutcome, 'deferred'>> {
2677
2782
  const store = this.store
2678
2783
 
2784
+ if (!(await this.dispatchBudget(record.slug))) return 'parked'
2679
2785
  let stage: EventPayload<'dispatch.failed'>['stage'] = 'spec'
2680
2786
  let authored = false
2681
2787
  try {
2682
2788
  const events = await store.getEvents(record.slug)
2789
+ const state = reduceBuild(events)
2790
+ if (state.status !== 'queued' || state.discardRequest !== undefined) return 'parked'
2683
2791
  if (
2684
2792
  !events.some((event) => event.type === 'spec.imported' || event.type === 'spec.authored')
2685
2793
  ) {
@@ -2762,7 +2870,7 @@ export class Dispatcher {
2762
2870
  return 'parked'
2763
2871
  }
2764
2872
  if (launched === undefined) await this.deps.launchRunner(record.slug)
2765
- else await this.launch(record.slug, launched)
2873
+ else await this.launch(record.slug, launched, report)
2766
2874
  if (authored && report !== undefined) report.authored += 1
2767
2875
  return 'completed'
2768
2876
  } catch (error) {
@@ -2779,6 +2887,7 @@ export class Dispatcher {
2779
2887
  },
2780
2888
  })
2781
2889
  if (report !== undefined) report.dispatchFailed += 1
2890
+ await this.dispatchBudget(record.slug)
2782
2891
  return 'failed'
2783
2892
  }
2784
2893
  }
@@ -2810,9 +2919,11 @@ export class Dispatcher {
2810
2919
  // Queued itself means runner attachment is still missing. Always enter
2811
2920
  // the fact-driven helper: it skips every completed boundary and retries
2812
2921
  // only the final launch when setup is already durable.
2813
- if ((await this.attemptDispatchCompletion(record, launched, report)) === 'completed') {
2814
- report.recovered += 1
2815
- }
2922
+ const outcome = await this.attemptDispatchCompletion(record, launched, report)
2923
+ if (outcome === 'completed') report.recovered += 1
2924
+ // A failed boundary owns this tick's attempt; startup and lease recovery
2925
+ // must not bypass the unfinished dispatch boundary later in the tick.
2926
+ if (outcome === 'failed') launched.add(record.slug)
2816
2927
  }
2817
2928
  }
2818
2929
 
@@ -2836,7 +2947,6 @@ export class Dispatcher {
2836
2947
  opts: TickOpts = {},
2837
2948
  ): Promise<void> {
2838
2949
  const store = this.store
2839
- const { config } = this.deps
2840
2950
  for (const record of await this.nonterminalBuilds()) {
2841
2951
  if (launched.has(record.slug)) continue
2842
2952
  // Budget gate: resume launches runners (transport-backed work).
@@ -2853,7 +2963,14 @@ export class Dispatcher {
2853
2963
  continue
2854
2964
  }
2855
2965
 
2856
- const decision = decideNextFromFacts(state, facts.log, config)
2966
+ if (
2967
+ state.status === 'queued' &&
2968
+ !state.pendingCommands.some((command) => command.command === 'abort') &&
2969
+ !(await this.dispatchBudget(record.slug))
2970
+ )
2971
+ continue
2972
+ const decision = await this.decideBuild(record.slug, state, facts.log, report)
2973
+ if (decision === undefined) continue
2857
2974
  if (decision.kind === 'wait') {
2858
2975
  if (decision.reason === 'blocked' && state.openEscalations.length > 0) {
2859
2976
  report.blockedDiagnostics.push(
@@ -2867,7 +2984,7 @@ export class Dispatcher {
2867
2984
  // Disposable remote environments must pass through the stale-lease
2868
2985
  // fencing sweep below; startup must never attach directly to an old VM.
2869
2986
  if (this.deps.workspaces.recovery !== undefined) continue
2870
- const result = await this.launch(record.slug, launched)
2987
+ const result = await this.launch(record.slug, launched, report)
2871
2988
  if (result === 'scheduled') report.resumed += 1
2872
2989
  }
2873
2990
  }
@@ -2923,7 +3040,14 @@ export class Dispatcher {
2923
3040
  ) {
2924
3041
  continue
2925
3042
  }
2926
- const decision = decideNextFromFacts(state, facts.log, this.deps.config)
3043
+ if (
3044
+ state.status === 'queued' &&
3045
+ !state.pendingCommands.some((command) => command.command === 'abort') &&
3046
+ !(await this.dispatchBudget(record.slug))
3047
+ )
3048
+ continue
3049
+ const decision = await this.decideBuild(record.slug, state, facts.log, report)
3050
+ if (decision === undefined) continue
2927
3051
  if (state.status === 'done' || state.status === 'aborted') continue
2928
3052
  // Raw history, read at most once and only by a branch that acts on it.
2929
3053
  let loaded: AbEvent[] | undefined
@@ -3024,7 +3148,7 @@ export class Dispatcher {
3024
3148
  // boundaries and the launch; this tick performs neither.
3025
3149
  continue
3026
3150
  }
3027
- const result = await this.launch(record.slug, launched)
3151
+ const result = await this.launch(record.slug, launched, report)
3028
3152
  if (result === 'scheduled') report.swept += 1
3029
3153
  }
3030
3154
  }
@@ -3052,11 +3176,11 @@ export class Dispatcher {
3052
3176
  // pending work are live, only waiting on a human. Capacity is per repo
3053
3177
  // (§16.1) — another repo's builds never consume this repo's slots.
3054
3178
  let active = 0
3055
- const activeTicketIds = new Set<string>()
3179
+ const activeRefs: TicketRef[] = []
3056
3180
  for (const { record, digest } of await this.repoBuildDigests()) {
3057
3181
  if (digest.terminal === undefined) {
3058
3182
  active += 1
3059
- if (record.ticket) activeTicketIds.add(record.ticket.id)
3183
+ if (record.ticket) activeRefs.push(record.ticket)
3060
3184
  }
3061
3185
  }
3062
3186
  let capacity = config.capacity - active
@@ -3068,6 +3192,7 @@ export class Dispatcher {
3068
3192
  await this.store.refreshJournal()
3069
3193
  const journalBeforeReady = this.store.recordedJournal()
3070
3194
  const listing = await this.listReady()
3195
+ const activeTicketIds = await this.activeTicketIdentities(activeRefs, listing.tickets)
3071
3196
  await this.store.refreshJournal()
3072
3197
  const journalAfterReady = this.store.recordedJournal()
3073
3198
  if (reduceTicketSourceHealth(journalAfterReady)?.cause === 'ready-scan-failed') {
@@ -3079,7 +3204,9 @@ export class Dispatcher {
3079
3204
  creation,
3080
3205
  ]),
3081
3206
  )
3082
- const ready = listing.tickets.filter((ticket) => !activeTicketIds.has(ticket.ref.id))
3207
+ const ready = listing.tickets.filter(
3208
+ (ticket) => !activeTicketIds.has(`${ticket.ref.source}:${ticket.ref.id}`),
3209
+ )
3083
3210
  report.invalidTickets += listing.diagnostics.length
3084
3211
  report.ticketDiagnostics.push(...listing.diagnostics)
3085
3212
  report.queued = ready.length
@@ -3176,7 +3303,7 @@ export class Dispatcher {
3176
3303
 
3177
3304
  // Freeze the ticket's assets at the claim (SPEC §6.3): sampled before the
3178
3305
  // spec-authoring interval so edits made during it are never pinned.
3179
- const assets = await samplePinnedAssets(store, this.deps.repo, ticket.ref.id)
3306
+ const assets = await preparePinnedAssets(tickets, store, this.deps.repo, ticket.ref.id)
3180
3307
 
3181
3308
  // Quality gate (§6.3): import a conforming ticket body as the spec;
3182
3309
  // author one for thin-but-groomed tickets; otherwise bounce.
@@ -0,0 +1,176 @@
1
+ import type { AbEvent } from '../events/catalog'
2
+ import { DISPATCHER } from '../events/envelope'
3
+ import type { EventType } from '../events/payloads'
4
+ import type { IdSource } from '../ids'
5
+ import type { Config } from '../config/schema'
6
+ import { type Decision, decideNextFromFacts } from '../kernel/engine'
7
+ import { defineReducer } from '../kernel/incremental'
8
+ import type { RepoViewStore } from './repo-view'
9
+
10
+ /** Every new event must explicitly choose whether it advances build work. */
11
+ export const executionProgress = {
12
+ 'build.created': 'progress',
13
+ 'build.completed': 'progress',
14
+ 'runner.attached': 'bookkeeping',
15
+ 'runner.setup-failed': 'failure',
16
+ 'workspace.provisioned': 'bookkeeping',
17
+ 'workspace.provision-started': 'bookkeeping',
18
+ 'workspace.released': 'bookkeeping',
19
+ 'execution.started': 'bookkeeping',
20
+ 'execution.ended': 'bookkeeping',
21
+ 'infrastructure.failed': 'failure',
22
+ 'infrastructure.cleanup-attempted': 'bookkeeping',
23
+ 'pr-summary.requested': 'progress',
24
+ 'pr-summary.attempted': 'progress',
25
+ 'pr-summary.result': 'progress',
26
+ 'pr-summary.settled': 'progress',
27
+ 'finalize.step-started': 'progress',
28
+ 'publication.requested': 'progress',
29
+ 'publication.lost': 'progress',
30
+ 'abort.remote-branch-deleted': 'progress',
31
+ 'abort.local-branch-deleted': 'progress',
32
+ 'abort.ticket-returned': 'progress',
33
+ 'dispatch.failed': 'failure',
34
+ 'dispatch.comment-posted': 'bookkeeping',
35
+ 'build.pause-requested': 'progress',
36
+ 'build.resume-requested': 'progress',
37
+ 'build.abort-requested': 'progress',
38
+ 'build.discard-requested': 'progress',
39
+ 'build.auto-merge-requested': 'progress',
40
+ 'build.auto-merge-cancelled': 'progress',
41
+ 'build.auto-merge-default-observed': 'bookkeeping',
42
+ 'build.paused': 'progress',
43
+ 'build.resumed': 'progress',
44
+ 'build.aborted': 'progress',
45
+ 'spec.imported': 'progress',
46
+ 'spec.authored': 'progress',
47
+ 'spec.revised': 'progress',
48
+ 'session.started': 'progress',
49
+ 'session.ended': 'progress',
50
+ 'plan.started': 'progress',
51
+ 'plan.completed': 'progress',
52
+ 'plan-review.started': 'progress',
53
+ 'plan-review.verdict': 'progress',
54
+ 'implement.started': 'progress',
55
+ 'implement.completed': 'progress',
56
+ 'code-review.started': 'progress',
57
+ 'code-review.verdict': 'progress',
58
+ 'verify.reconcile-policy-captured': 'progress',
59
+ 'verify.started': 'progress',
60
+ 'verify.completed': 'progress',
61
+ 'finalize.started': 'progress',
62
+ 'finalize.completed': 'progress',
63
+ 'finalize.step-completed': 'progress',
64
+ 'pr-attachment.designated': 'progress',
65
+ 'pr-attachment.hosted': 'progress',
66
+ 'pr-attachment.reclaimed': 'progress',
67
+ 'pr-attachment.reclaim-failed': 'progress',
68
+ 'pr.auto-merge-enabled': 'progress',
69
+ 'pr.auto-merge-disabled': 'progress',
70
+ 'pr.merged': 'progress',
71
+ 'pr.closed': 'progress',
72
+ 'pr.conflicted': 'progress',
73
+ 'reconcile.progress-checked': 'progress',
74
+ 'reconcile.started': 'progress',
75
+ 'reconcile.completed': 'progress',
76
+ 'observation.recorded': 'progress',
77
+ 'escalation.raised': 'progress',
78
+ 'escalation.answered': 'progress',
79
+ 'phase.failed': 'failure',
80
+ } satisfies Record<EventType, 'bookkeeping' | 'failure' | 'progress'>
81
+
82
+ interface Accumulator {
83
+ active: { instance: string; seq: number; eligible: boolean } | null
84
+ count: number
85
+ open: string[]
86
+ latest: { instance: string; seq: number } | null
87
+ }
88
+
89
+ export const executionNoProgressReducer = defineReducer({
90
+ version: 1,
91
+ initial: (): Accumulator => ({ active: null, count: 0, open: [], latest: null }),
92
+ fold(acc, events: readonly AbEvent[]) {
93
+ for (const event of events) {
94
+ if (event.type === 'execution.started') {
95
+ // An abandoned start breaks the consecutive completed-execution streak.
96
+ if (acc.active !== null) acc.count = 0
97
+ acc.active = { instance: event.payload.instance, seq: event.seq, eligible: true }
98
+ } else if (event.type === 'execution.ended') {
99
+ if (acc.active?.instance !== event.payload.instance) continue
100
+ const clean = event.payload.outcome === 'completed' && event.payload.exitCode === 0
101
+ acc.count = clean && acc.active.eligible ? acc.count + 1 : 0
102
+ acc.latest = { instance: event.payload.instance, seq: event.seq }
103
+ acc.active = null
104
+ } else if (
105
+ event.type === 'escalation.raised' &&
106
+ event.payload.source === 'policy' &&
107
+ event.payload.policyCause === 'execution-no-progress'
108
+ ) {
109
+ if (!acc.open.includes(event.payload.id)) acc.open.push(event.payload.id)
110
+ } else if (event.type === 'escalation.answered') {
111
+ if (!acc.open.includes(event.payload.id)) continue
112
+ acc.open = acc.open.filter((id) => id !== event.payload.id)
113
+ acc.count = 0
114
+ acc.active = null
115
+ } else if (executionProgress[event.type] !== 'bookkeeping') {
116
+ acc.count = 0
117
+ if (acc.active !== null) acc.active.eligible = false
118
+ }
119
+ }
120
+ },
121
+ finish: (acc) => ({ ...acc, open: [...acc.open] }),
122
+ })
123
+
124
+ /** Describe routing without copying commands or phase feedback into the question. */
125
+ function expectedAction(decision: Decision): string {
126
+ if (decision.kind === 'run-phase') return `${decision.kind} ${decision.phase}@${decision.round}`
127
+ if ('step' in decision)
128
+ return `${decision.kind} ${decision.step} (attempt ${'attempt' in decision ? decision.attempt : 1})`
129
+ if (decision.kind === 'raise-escalation') return `${decision.kind} ${decision.phase}`
130
+ return decision.kind
131
+ }
132
+
133
+ /** Repair the third-completion/raise crash gap without provisioning execution four. */
134
+ export async function ensureExecutionProgress(
135
+ store: Pick<RepoViewStore, 'buildFacts' | 'appendIfCurrent' | 'refreshBuild'>,
136
+ ids: IdSource,
137
+ slug: string,
138
+ config: Config,
139
+ decide?: (
140
+ state: Parameters<typeof decideNextFromFacts>[0],
141
+ log: Parameters<typeof decideNextFromFacts>[1],
142
+ ) => Promise<Decision | undefined>,
143
+ ): Promise<boolean> {
144
+ for (;;) {
145
+ await store.refreshBuild(slug)
146
+ const facts = await store.buildFacts(slug)
147
+ const decision = decide
148
+ ? await decide(facts.state, facts.log)
149
+ : decideNextFromFacts(facts.state, facts.log, config)
150
+ if (decision === undefined) return false
151
+ // Commands must still be acknowledged even while this guard is parked.
152
+ if (decision.kind === 'acknowledge') return true
153
+ const streak = facts.executionNoProgress
154
+ if (streak.open.length > 0) return false
155
+ // Callers own wait routing, including publication-recovery launches. A
156
+ // legitimate wait is not dispatcher/runner disagreement.
157
+ if (decision.kind === 'wait') return true
158
+ if (streak.count < 3) return true
159
+ const raised = await store.appendIfCurrent(slug, facts.state.lastSeq, {
160
+ actor: DISPATCHER,
161
+ type: 'escalation.raised',
162
+ payload: {
163
+ id: ids('esc'),
164
+ phase: 'setup',
165
+ source: 'policy',
166
+ policyCause: 'execution-no-progress',
167
+ question: `${streak.count} consecutive executions ended normally without build progress. Dispatcher expected ${expectedAction(decision)}. Inspect the dispatcher/runner disagreement, then answer with guidance or retry.`,
168
+ refs:
169
+ streak.latest === null
170
+ ? []
171
+ : [`execution:${streak.latest.instance}`, `seq:${streak.latest.seq}`],
172
+ },
173
+ })
174
+ if (raised !== null) return false
175
+ }
176
+ }