@defrex/autobuild 0.12.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 (88) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +119 -15
  3. package/docs/architecture.md +59 -3
  4. package/docs/configuration.md +41 -9
  5. package/docs/hosted-service.md +31 -5
  6. package/docs/operator-api.md +7 -0
  7. package/docs/remote-store-protocol.md +10 -0
  8. package/docs/remote-ticket-protocol.md +151 -9
  9. package/docs/setup.md +33 -7
  10. package/docs/ticket-migration.md +64 -0
  11. package/package.json +3 -3
  12. package/packages/core/src/cli/build-control.ts +12 -1
  13. package/packages/core/src/cli/connect.ts +141 -38
  14. package/packages/core/src/cli/context.ts +7 -1
  15. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  16. package/packages/core/src/cli/dispatch.ts +156 -3
  17. package/packages/core/src/cli/harvest.ts +13 -3
  18. package/packages/core/src/cli/help.ts +14 -8
  19. package/packages/core/src/cli/installation.ts +67 -13
  20. package/packages/core/src/cli/main.ts +20 -2
  21. package/packages/core/src/cli/mcp.ts +2 -1
  22. package/packages/core/src/cli/self-update.ts +5 -1
  23. package/packages/core/src/cli/ticket-assets.ts +7 -2
  24. package/packages/core/src/cli/ticket-editor.ts +82 -0
  25. package/packages/core/src/cli/ticket.ts +164 -34
  26. package/packages/core/src/config/schema.ts +35 -3
  27. package/packages/core/src/connection.ts +3 -3
  28. package/packages/core/src/events/catalog.ts +3 -2
  29. package/packages/core/src/events/payloads.ts +6 -0
  30. package/packages/core/src/integration/harness.ts +37 -2
  31. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +15 -0
  32. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  33. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  34. package/packages/core/src/kernel/reducer-registry.ts +2 -0
  35. package/packages/core/src/ontology.ts +1 -0
  36. package/packages/core/src/operator/annotations.ts +12 -0
  37. package/packages/core/src/operator/index.ts +5 -0
  38. package/packages/core/src/operator/protocol.ts +19 -2
  39. package/packages/core/src/operator/registry.ts +61 -8
  40. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  41. package/packages/core/src/operator/tickets.ts +130 -20
  42. package/packages/core/src/plugin-sdk/index.ts +6 -0
  43. package/packages/core/src/plugins/runtimes.ts +5 -1
  44. package/packages/core/src/ports/runner/session-env.ts +2 -0
  45. package/packages/core/src/ports/tickets/contract.ts +66 -1
  46. package/packages/core/src/ports/tickets/create.ts +48 -12
  47. package/packages/core/src/ports/tickets/fake.ts +30 -1
  48. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  49. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  50. package/packages/core/src/ports/tickets/file.ts +114 -120
  51. package/packages/core/src/ports/tickets/grants.ts +47 -0
  52. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  53. package/packages/core/src/ports/tickets/identity.ts +20 -0
  54. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  55. package/packages/core/src/ports/tickets/linear.ts +74 -9
  56. package/packages/core/src/ports/tickets/migration.ts +336 -0
  57. package/packages/core/src/ports/tickets/operations.ts +38 -7
  58. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  59. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  60. package/packages/core/src/ports/tickets/remote.ts +60 -3
  61. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  62. package/packages/core/src/ports/tickets/revision.ts +62 -0
  63. package/packages/core/src/ports/tickets/states.ts +61 -0
  64. package/packages/core/src/ports/types.ts +40 -3
  65. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  66. package/packages/core/src/ports/workspace/create.ts +5 -1
  67. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  68. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  69. package/packages/core/src/processes/dispatcher.ts +110 -32
  70. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  71. package/packages/core/src/processes/harvest-child.ts +30 -4
  72. package/packages/core/src/processes/harvest-runner.ts +24 -4
  73. package/packages/core/src/processes/harvest.ts +34 -14
  74. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  75. package/packages/core/src/processes/repo-view.ts +35 -0
  76. package/packages/core/src/store/contract.ts +32 -1
  77. package/packages/core/src/store/local/store.ts +1 -1
  78. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  79. package/packages/core/src/store/ticket-assets.ts +86 -8
  80. package/packages/core/src/store/types.ts +1 -1
  81. package/skills/guide/SKILL.md +91 -11
  82. package/skills/guide/references/operator-api.md +7 -0
  83. package/skills/guide/references/remote-store-protocol.md +10 -0
  84. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  85. package/skills/guide/references/setup.md +33 -7
  86. package/skills/guide/references/ticket-migration.md +64 -0
  87. package/skills/tickets/SKILL.md +71 -0
  88. package/templates/hosted-service/package.json +2 -2
@@ -1,3 +1,6 @@
1
+ import { ensureExecutionProgress } from './execution-no-progress'
2
+ import { ticketIdentity } from '../ports/tickets/identity'
3
+ import { ticketLifecycle } from '../ports/tickets/lifecycle'
1
4
  import { ensureDispatchBudget } from './dispatch-failure-budget'
2
5
  import {
3
6
  reduceTicketSourceHealth,
@@ -62,6 +65,8 @@ import {
62
65
  } from '../kernel/auto-merge-default'
63
66
  import { reduceDispatchSettings } from '../kernel/dispatch-settings'
64
67
  import { decideNextFromFacts } from '../kernel/engine'
68
+ import { composeBuildConfig } from '../config/live'
69
+ import { BUILD_EFFECTIVE_CONFIG_ARTIFACT, parseEffectiveBuildConfig } from './build-execution-state'
65
70
  import {
66
71
  DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
67
72
  decideHarvestControl,
@@ -83,7 +88,7 @@ import type {
83
88
  import { TicketSourceRateLimitError } from '../ports/types'
84
89
  import type { Exec } from '../ports/workspace/git-worktree'
85
90
  import type { ArtifactMeta, BuildDigest, BuildRecord, BuildStore, Clock } from '../store/types'
86
- import { samplePinnedAssets } from '../store/ticket-assets'
91
+ import { preparePinnedAssets } from '../store/ticket-assets'
87
92
  import { resolveRepoOrigin } from '../cli/repo-state'
88
93
  import { specConformance } from '../spec-standard'
89
94
  export { specConformance, type SpecConformance } from '../spec-standard'
@@ -148,16 +153,11 @@ export function readyCriteria(config: Config): {
148
153
  * readiness is: the file tracker's grooming area IS the `triage/` directory,
149
154
  * while a Linear team only has a "Triage" workflow state when the team's
150
155
  * triage feature is enabled — Backlog is the state every Linear team has.
151
- * The hosted source is the credential-bearing Linear proxy and shares that
152
- * 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.
153
158
  */
154
159
  export function defaultTriageState(config: Config): string {
155
- return (
156
- config.tickets.triageState ??
157
- (config.tickets.source === 'linear' || config.tickets.source === 'hosted'
158
- ? 'Backlog'
159
- : 'Triage')
160
- )
160
+ return ticketLifecycle(config.tickets).triage
161
161
  }
162
162
 
163
163
  /**
@@ -547,6 +547,8 @@ export interface DispatcherDeps {
547
547
  retiredWorkspaces?: readonly WorkspaceProvider[]
548
548
  forge: Forge
549
549
  config: Config
550
+ /** Configuration-only publication for incompatible pins; never launches a runner. */
551
+ repairBuildConfig?: (slug: string) => Promise<void>
550
552
  /** Current process snapshot, captured once at the beginning of each tick.
551
553
  * Omission preserves the cron/static-config API. */
552
554
  getConfig?: () => Config
@@ -647,6 +649,38 @@ export function heldByRepositoryPause(state: BuildState, paused: boolean): boole
647
649
  }
648
650
 
649
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
+
650
684
  /** Memoized normalized origin of the served repository (one git call per
651
685
  * dispatcher), recorded on created builds so ambient reads can accept a
652
686
  * differently located checkout of the same repository by origin equality.
@@ -670,7 +704,7 @@ export class Dispatcher {
670
704
  return (
671
705
  this.deps.opts?.doneState ??
672
706
  (this.deps.config.tickets.source === 'hosted'
673
- ? this.deps.config.tickets.doneState
707
+ ? ticketLifecycle(this.deps.config.tickets).done
674
708
  : undefined) ??
675
709
  'Done'
676
710
  )
@@ -988,6 +1022,23 @@ export class Dispatcher {
988
1022
  }
989
1023
  }
990
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
+
991
1042
  /** Observation-only ready listing for Store-backed frontends while intake is
992
1043
  * disabled. It shares the active-ticket exclusion with dispatch but performs
993
1044
  * no dependency fetch, claim, bounce, build, workspace, or launch side
@@ -999,20 +1050,21 @@ export class Dispatcher {
999
1050
  }> {
1000
1051
  this.deps.config = this.deps.getConfig?.() ?? this.deps.config
1001
1052
  await this.store.refresh()
1002
- const activeTicketIds = new Set<string>()
1003
- for (const { record, digest } of await this.repoBuildDigests()) {
1004
- if (record.ticket !== undefined && digest.terminal === undefined) {
1005
- activeTicketIds.add(record.ticket.id)
1006
- }
1007
- }
1008
1053
  const report = emptyTickReport()
1009
1054
  this.restoreScan(report)
1010
1055
  const checkpoint = await this.scanCheckpoint('observation')
1011
1056
  if (checkpoint.due) {
1012
1057
  await this.store.refreshJournal()
1013
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)
1014
1064
  await this.store.refreshJournal()
1015
- 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
1016
1068
  report.invalidTickets = listing.diagnostics.length
1017
1069
  report.ticketDiagnostics = [...listing.diagnostics]
1018
1070
  report.dependencyBlocked = 0
@@ -1173,9 +1225,24 @@ export class Dispatcher {
1173
1225
  }
1174
1226
  }
1175
1227
 
1176
- 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> {
1177
1233
  if (launched.has(slug)) return 'already-active'
1178
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
+ }
1179
1246
  return this.deps.launchRunner(slug)
1180
1247
  }
1181
1248
 
@@ -2089,7 +2156,7 @@ export class Dispatcher {
2089
2156
  }
2090
2157
  // The dispatcher never runs agents (§15.7): re-attach a build-runner,
2091
2158
  // which executes the reconcile epilogue phase.
2092
- await this.launch(record.slug, launched)
2159
+ await this.launch(record.slug, launched, report)
2093
2160
  report.conflicted += 1
2094
2161
  return
2095
2162
  }
@@ -2123,12 +2190,12 @@ export class Dispatcher {
2123
2190
  const latest = await store.buildFacts(record.slug)
2124
2191
  const latestState = latest.state
2125
2192
  const latestIntent = pendingAutoMerge(latestState)
2126
- const decision = decideNextFromFacts(latest.state, latest.log, this.deps.config)
2193
+ const decision = await this.decideBuild(record.slug, latest.state, latest.log, report)
2127
2194
  if (
2128
2195
  latestState.pr?.number === pr.number &&
2129
2196
  latestIntent?.enabled === true &&
2130
2197
  latestIntent.commandSeq === autoMerge.commandSeq &&
2131
- decision.kind === 'wait' &&
2198
+ decision?.kind === 'wait' &&
2132
2199
  decision.reason === 'awaiting-pr'
2133
2200
  ) {
2134
2201
  await forge.squashMerge(workspacePath, pr.number, result.headSha)
@@ -2584,7 +2651,14 @@ export class Dispatcher {
2584
2651
  // can only sample the ticket's current assets (SPEC §6.3).
2585
2652
  const assets =
2586
2653
  seed?.assets ??
2587
- (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
+ ))
2588
2662
  await store.appendIfCurrent(record.slug, 0, {
2589
2663
  actor: DISPATCHER,
2590
2664
  type: 'build.created',
@@ -2796,7 +2870,7 @@ export class Dispatcher {
2796
2870
  return 'parked'
2797
2871
  }
2798
2872
  if (launched === undefined) await this.deps.launchRunner(record.slug)
2799
- else await this.launch(record.slug, launched)
2873
+ else await this.launch(record.slug, launched, report)
2800
2874
  if (authored && report !== undefined) report.authored += 1
2801
2875
  return 'completed'
2802
2876
  } catch (error) {
@@ -2873,7 +2947,6 @@ export class Dispatcher {
2873
2947
  opts: TickOpts = {},
2874
2948
  ): Promise<void> {
2875
2949
  const store = this.store
2876
- const { config } = this.deps
2877
2950
  for (const record of await this.nonterminalBuilds()) {
2878
2951
  if (launched.has(record.slug)) continue
2879
2952
  // Budget gate: resume launches runners (transport-backed work).
@@ -2896,7 +2969,8 @@ export class Dispatcher {
2896
2969
  !(await this.dispatchBudget(record.slug))
2897
2970
  )
2898
2971
  continue
2899
- const decision = decideNextFromFacts(state, facts.log, config)
2972
+ const decision = await this.decideBuild(record.slug, state, facts.log, report)
2973
+ if (decision === undefined) continue
2900
2974
  if (decision.kind === 'wait') {
2901
2975
  if (decision.reason === 'blocked' && state.openEscalations.length > 0) {
2902
2976
  report.blockedDiagnostics.push(
@@ -2910,7 +2984,7 @@ export class Dispatcher {
2910
2984
  // Disposable remote environments must pass through the stale-lease
2911
2985
  // fencing sweep below; startup must never attach directly to an old VM.
2912
2986
  if (this.deps.workspaces.recovery !== undefined) continue
2913
- const result = await this.launch(record.slug, launched)
2987
+ const result = await this.launch(record.slug, launched, report)
2914
2988
  if (result === 'scheduled') report.resumed += 1
2915
2989
  }
2916
2990
  }
@@ -2972,7 +3046,8 @@ export class Dispatcher {
2972
3046
  !(await this.dispatchBudget(record.slug))
2973
3047
  )
2974
3048
  continue
2975
- const decision = decideNextFromFacts(state, facts.log, this.deps.config)
3049
+ const decision = await this.decideBuild(record.slug, state, facts.log, report)
3050
+ if (decision === undefined) continue
2976
3051
  if (state.status === 'done' || state.status === 'aborted') continue
2977
3052
  // Raw history, read at most once and only by a branch that acts on it.
2978
3053
  let loaded: AbEvent[] | undefined
@@ -3073,7 +3148,7 @@ export class Dispatcher {
3073
3148
  // boundaries and the launch; this tick performs neither.
3074
3149
  continue
3075
3150
  }
3076
- const result = await this.launch(record.slug, launched)
3151
+ const result = await this.launch(record.slug, launched, report)
3077
3152
  if (result === 'scheduled') report.swept += 1
3078
3153
  }
3079
3154
  }
@@ -3101,11 +3176,11 @@ export class Dispatcher {
3101
3176
  // pending work are live, only waiting on a human. Capacity is per repo
3102
3177
  // (§16.1) — another repo's builds never consume this repo's slots.
3103
3178
  let active = 0
3104
- const activeTicketIds = new Set<string>()
3179
+ const activeRefs: TicketRef[] = []
3105
3180
  for (const { record, digest } of await this.repoBuildDigests()) {
3106
3181
  if (digest.terminal === undefined) {
3107
3182
  active += 1
3108
- if (record.ticket) activeTicketIds.add(record.ticket.id)
3183
+ if (record.ticket) activeRefs.push(record.ticket)
3109
3184
  }
3110
3185
  }
3111
3186
  let capacity = config.capacity - active
@@ -3117,6 +3192,7 @@ export class Dispatcher {
3117
3192
  await this.store.refreshJournal()
3118
3193
  const journalBeforeReady = this.store.recordedJournal()
3119
3194
  const listing = await this.listReady()
3195
+ const activeTicketIds = await this.activeTicketIdentities(activeRefs, listing.tickets)
3120
3196
  await this.store.refreshJournal()
3121
3197
  const journalAfterReady = this.store.recordedJournal()
3122
3198
  if (reduceTicketSourceHealth(journalAfterReady)?.cause === 'ready-scan-failed') {
@@ -3128,7 +3204,9 @@ export class Dispatcher {
3128
3204
  creation,
3129
3205
  ]),
3130
3206
  )
3131
- 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
+ )
3132
3210
  report.invalidTickets += listing.diagnostics.length
3133
3211
  report.ticketDiagnostics.push(...listing.diagnostics)
3134
3212
  report.queued = ready.length
@@ -3225,7 +3303,7 @@ export class Dispatcher {
3225
3303
 
3226
3304
  // Freeze the ticket's assets at the claim (SPEC §6.3): sampled before the
3227
3305
  // spec-authoring interval so edits made during it are never pinned.
3228
- const assets = await samplePinnedAssets(store, this.deps.repo, ticket.ref.id)
3306
+ const assets = await preparePinnedAssets(tickets, store, this.deps.repo, ticket.ref.id)
3229
3307
 
3230
3308
  // Quality gate (§6.3): import a conforming ticket body as the spec;
3231
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
+ }
@@ -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,
@@ -103,6 +118,8 @@ export async function runHarvestChild(
103
118
  ...(input.leaseHolder !== undefined ? { leaseHolder: input.leaseHolder } : {}),
104
119
  ...(input.environment !== undefined ? { environment: input.environment } : {}),
105
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 } : {}),
106
123
  AB_STORE: input.storeRef,
107
124
  ...(env.AB_TOKEN !== undefined && env.AB_TOKEN !== '' ? { AB_TOKEN: env.AB_TOKEN } : {}),
108
125
  },
@@ -112,10 +129,19 @@ export async function runHarvestChild(
112
129
  })
113
130
  await runner.run()
114
131
  } finally {
132
+ if (renewTickets) clearInterval(renewTickets)
115
133
  try {
116
- await fullStore.close()
117
- } catch {
118
- // 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
+ }
119
145
  }
120
146
  }
121
147
  }
@@ -1,3 +1,5 @@
1
+ import { ticketIdentity } from '../ports/tickets/identity'
2
+ import { issueTicketGrant } from '../ports/tickets/grants'
1
3
  import { delegateCredential } from '../store/remote/credentials'
2
4
  /**
3
5
  * Crash-safe repository-scoped harvest workflow:
@@ -1176,9 +1178,16 @@ export class HarvestRunner {
1176
1178
  )
1177
1179
  const packet = await loadScanPacket(store, repo, current.scan)
1178
1180
  const observations = packet.observations
1179
- const knownLedger = new Map(
1180
- packet.ledger.map((entry) => [`${entry.ticket.source}:${entry.ticket.id}`, entry]),
1181
- )
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
+ }
1182
1191
  const alreadyFiled = new Map(current.filed.map((entry) => [entry.proposalKey, entry]))
1183
1192
  const reservations = new Map(current.reservations.map((entry) => [entry.proposalKey, entry.id]))
1184
1193
  const dispositions: HarvestDisposition[] = []
@@ -1258,7 +1267,7 @@ export class HarvestRunner {
1258
1267
  ...(filed.blockers !== undefined ? { blockers: filed.blockers } : {}),
1259
1268
  })
1260
1269
  } else if (proposal.action === 'join') {
1261
- const known = knownLedger.get(`${proposal.ticket.source}:${proposal.ticket.id}`)
1270
+ const known = knownLedger.get(await identity(proposal.ticket))
1262
1271
  if (!known) {
1263
1272
  throw new Error(
1264
1273
  `approved join target ${proposal.ticket.source}:${proposal.ticket.id} is not in the ledger context`,
@@ -1638,9 +1647,20 @@ export class HarvestRunner {
1638
1647
  session,
1639
1648
  })
1640
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
1641
1660
  return {
1642
1661
  AB_STORE: 'local',
1643
1662
  ...this.deps.sessionEnv,
1663
+ ...(ticketGrant ? { AB_TICKET_TOKEN: ticketGrant.token } : {}),
1644
1664
  ...(token ? { AB_TOKEN: token } : {}),
1645
1665
  AB_REPO: this.deps.repo,
1646
1666
  AB_HARVEST: run,