@defrex/autobuild 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +237 -23
  3. package/bin/ab-dispatch-kernel.ts +1 -1
  4. package/docs/architecture.md +66 -4
  5. package/docs/configuration.md +137 -19
  6. package/docs/hosted-dispatcher.md +17 -2
  7. package/docs/hosted-service.md +214 -0
  8. package/docs/mcp.md +8 -2
  9. package/docs/operator-api.md +107 -7
  10. package/docs/remote-store-protocol.md +189 -144
  11. package/docs/remote-ticket-protocol.md +111 -18
  12. package/docs/setup.md +173 -16
  13. package/docs/ticket-dependencies.md +1 -1
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/binary.ts +3 -3
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +261 -0
  19. package/packages/core/src/cli/dashboard/model.ts +8 -5
  20. package/packages/core/src/cli/dashboard/render.ts +37 -7
  21. package/packages/core/src/cli/dispatch-frontend.ts +16 -1
  22. package/packages/core/src/cli/dispatch-process.ts +25 -7
  23. package/packages/core/src/cli/dispatch.ts +247 -47
  24. package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
  25. package/packages/core/src/cli/help.ts +45 -13
  26. package/packages/core/src/cli/init-validation.ts +27 -2
  27. package/packages/core/src/cli/main.ts +41 -15
  28. package/packages/core/src/cli/mcp.ts +7 -1
  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/repository-status.ts +45 -5
  34. package/packages/core/src/cli/self-update.ts +10 -0
  35. package/packages/core/src/cli/store-opening.ts +22 -14
  36. package/packages/core/src/cli/terminals.ts +0 -10
  37. package/packages/core/src/cli/ticket.ts +8 -1
  38. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  39. package/packages/core/src/cli/wait.ts +84 -17
  40. package/packages/core/src/cli/watch.ts +350 -68
  41. package/packages/core/src/config/live.ts +4 -0
  42. package/packages/core/src/config/schema.ts +65 -11
  43. package/packages/core/src/connection.ts +86 -0
  44. package/packages/core/src/events/catalog.ts +10 -2
  45. package/packages/core/src/events/globs.ts +8 -3
  46. package/packages/core/src/events/payloads.ts +44 -0
  47. package/packages/core/src/events/repository.ts +64 -2
  48. package/packages/core/src/integration/harness.ts +2 -1
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +148 -83
  50. package/packages/core/src/kernel/dispatch-status.ts +9 -1
  51. package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
  52. package/packages/core/src/kernel/engine.ts +24 -0
  53. package/packages/core/src/kernel/generators/build-log.ts +10 -0
  54. package/packages/core/src/kernel/log-index.ts +17 -0
  55. package/packages/core/src/kernel/pr-summary.ts +89 -0
  56. package/packages/core/src/kernel/reducer-registry.ts +12 -0
  57. package/packages/core/src/kernel/reducer.ts +8 -0
  58. package/packages/core/src/kernel/ticket-source-health.ts +30 -0
  59. package/packages/core/src/ontology.ts +1 -0
  60. package/packages/core/src/operator/index.ts +7 -0
  61. package/packages/core/src/operator/query.ts +44 -19
  62. package/packages/core/src/operator/ticket-source-backend.ts +1 -0
  63. package/packages/core/src/operator/tickets.ts +6 -8
  64. package/packages/core/src/plugin-sdk/index.ts +1 -0
  65. package/packages/core/src/plugins/manifest.ts +5 -3
  66. package/packages/core/src/plugins/runtimes.ts +3 -1
  67. package/packages/core/src/ports/forge/fake.ts +13 -1
  68. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  69. package/packages/core/src/ports/forge/github.ts +29 -5
  70. package/packages/core/src/ports/forge/local-git.ts +12 -4
  71. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  72. package/packages/core/src/ports/runner/session-env.ts +1 -0
  73. package/packages/core/src/ports/tickets/contract.ts +25 -0
  74. package/packages/core/src/ports/tickets/create.ts +11 -3
  75. package/packages/core/src/ports/tickets/fake.ts +10 -8
  76. package/packages/core/src/ports/tickets/file.ts +1 -0
  77. package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
  78. package/packages/core/src/ports/tickets/linear.ts +241 -49
  79. package/packages/core/src/ports/tickets/remote-protocol.ts +3 -0
  80. package/packages/core/src/ports/tickets/remote.ts +1 -0
  81. package/packages/core/src/ports/types.ts +17 -0
  82. package/packages/core/src/ports/workspace/build-execution.ts +5 -1
  83. package/packages/core/src/ports/workspace/create.ts +11 -2
  84. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  85. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  86. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  87. package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
  88. package/packages/core/src/processes/build-execution-state.ts +1 -1
  89. package/packages/core/src/processes/build-runner.ts +87 -26
  90. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  91. package/packages/core/src/processes/dispatcher.ts +336 -83
  92. package/packages/core/src/processes/harvest-child.ts +1 -0
  93. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  94. package/packages/core/src/processes/harvest-runner.ts +142 -100
  95. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  96. package/packages/core/src/processes/publication-loss.ts +18 -1
  97. package/packages/core/src/processes/repo-view.ts +2 -1
  98. package/packages/core/src/store/adapter.ts +1 -0
  99. package/packages/core/src/store/contract.ts +109 -0
  100. package/packages/core/src/store/digest.ts +14 -2
  101. package/packages/core/src/store/local/store.ts +18 -5
  102. package/packages/core/src/store/remote/client.ts +1 -1
  103. package/packages/core/src/store/remote/compatibility.ts +90 -0
  104. package/packages/core/src/store/remote/credentials.ts +28 -0
  105. package/packages/core/src/store/remote/index.ts +14 -1
  106. package/packages/core/src/store/remote/protocol.ts +26 -0
  107. package/packages/core/src/store/remote/token.ts +74 -13
  108. package/packages/core/src/store/repo-state-events.ts +49 -59
  109. package/packages/core/src/store/types.ts +5 -1
  110. package/packages/core/src/testing/index.ts +7 -0
  111. package/skills/guide/SKILL.md +175 -26
  112. package/skills/guide/references/operator-api.md +135 -13
  113. package/skills/guide/references/plugin-authoring.md +10 -2
  114. package/skills/guide/references/remote-store-protocol.md +189 -144
  115. package/skills/guide/references/remote-ticket-protocol.md +111 -18
  116. package/skills/guide/references/setup.md +173 -16
  117. package/skills/guide/references/ticket-dependencies.md +1 -1
  118. package/skills/operate/SKILL.md +159 -34
  119. package/templates/hosted-service/package.json +13 -0
  120. package/templates/hosted-service/vercel.json +7 -0
@@ -1,3 +1,4 @@
1
+ import { delegateCredential } from '../store/remote/credentials'
1
2
  /**
2
3
  * Crash-safe repository-scoped harvest workflow:
3
4
  * scan -> synthesize <-> review -> file. It reuses `converge` for adversarial
@@ -100,11 +101,13 @@ export interface HarvestRunnerDeps {
100
101
  * executes in (hosted harvest). Stamped into the run's harvest.started
101
102
  * payload so the journal records where it ran; absent on local runs. */
102
103
  environment?: { provider: string; environmentId: string; sessionId?: string }
104
+ preparedRun?: { run: string }
103
105
  sessionEnv?: Record<string, string>
104
106
  opts?: HarvestRunnerOpts
105
107
  }
106
108
 
107
109
  export type HarvestRunnerResult =
110
+ | { outcome: 'prepared'; run: string; launch: 'started' | 'resumed' }
108
111
  | { outcome: 'idle' }
109
112
  | { outcome: 'held' }
110
113
  | { outcome: 'parked'; run?: string }
@@ -217,9 +220,9 @@ export class HarvestRunner {
217
220
  )
218
221
  }
219
222
 
220
- async run(): Promise<HarvestRunnerResult> {
223
+ async run(preparationOnly = false): Promise<HarvestRunnerResult> {
221
224
  const { store, repo, instance } = this.deps
222
- await store.ensureRepo(repo)
225
+ if (!this.deps.preparedRun) await store.ensureRepo(repo)
223
226
  if (
224
227
  this.deps.leaseHolder === undefined &&
225
228
  !(await store.claimRepoLease(repo, instance, this.leaseTtlMs))
@@ -233,101 +236,116 @@ export class HarvestRunner {
233
236
  await this.ensureLease()
234
237
  // Bounded read (AUT-489): reduceHarvest and the verdict/failure filters
235
238
  // below consume durable types only, so the subset is replay-equivalent.
236
- let events = await store.getRepoStateEvents(repo)
237
- let state = reduceHarvest(events)
238
- run = actionableHarvestRun(state)
239
-
240
- // Settle durable operator control before scanning or resuming work. The
241
- // selected run comes from the full journal, so a shadowed failure or
242
- // exhaustion barrier is recovered/reported before any later open run.
243
- await this.controlBoundary(run?.run)
244
- events = await store.getRepoStateEvents(repo)
245
- state = reduceHarvest(events)
246
- run = openHarvestRun(state)
247
-
248
- if (!run) {
249
- await this.controlBoundary()
250
- // Flat-cost gate (AUT-521): the pressure trigger is evaluated from
251
- // the journal and one repo-scoped digest batch read — no per-build
252
- // history reads — so the idle evaluation no longer grows with
253
- // accumulated finished builds. Only a tripped gate pays for the full
254
- // unclaimed-observation scan, because the packet artifact needs the
255
- // observation payloads no bounded source carries.
256
- // Bounded read (AUT-489): the gate's journal input is consumed only
257
- // through reduceHarvest, so the subset is replay-equivalent here too.
258
- const [gateEvents, digests] = await Promise.all([
259
- store.getRepoStateEvents(repo),
260
- store.getRepoBuildDigests(repo),
261
- ])
262
- const pressure = evaluateHarvestPressureFromDigests({
263
- digests,
264
- harvestEvents: gateEvents,
265
- policy: this.currentConfig().policy,
266
- })
267
- if (pressure.trigger === undefined) {
268
- // Treat the read's completion as a boundary even when the gate did
269
- // not trip, so a request arriving during the read is acknowledged
270
- // before this runner returns idle.
239
+ if (this.deps.preparedRun) {
240
+ const events = await store.getRepoStateEvents(repo)
241
+ const authorization = events.find(
242
+ (event) =>
243
+ event.type === 'harvest.execution.authorized' &&
244
+ event.payload.execution === instance &&
245
+ event.payload.run === this.deps.preparedRun!.run,
246
+ )
247
+ if (authorization?.type !== 'harvest.execution.authorized')
248
+ throw new Error('prepared harvest execution unavailable')
249
+ run = reduceHarvest(events).runs.find((run) => run.run === this.deps.preparedRun!.run)
250
+ initial = authorization.payload.launch
251
+ } else {
252
+ let events = await store.getRepoStateEvents(repo)
253
+ let state = reduceHarvest(events)
254
+ run = actionableHarvestRun(state)
255
+
256
+ // Settle durable operator control before scanning or resuming work. The
257
+ // selected run comes from the full journal, so a shadowed failure or
258
+ // exhaustion barrier is recovered/reported before any later open run.
259
+ await this.controlBoundary(run?.run)
260
+ events = await store.getRepoStateEvents(repo)
261
+ state = reduceHarvest(events)
262
+ run = openHarvestRun(state)
263
+
264
+ if (!run) {
271
265
  await this.controlBoundary()
272
- return { outcome: 'idle' }
273
- }
274
- await this.controlBoundary()
275
- const scan = await scanUnclaimedObservations(store, repo)
276
- // The trigger came from an earlier read than this scan, and a claim
277
- // may have landed in the window. Re-confirm the gate against the
278
- // fresh scan (pure, free): an emptied window returns idle exactly as
279
- // if the gate had not tripped, so a packet is only ever built from a
280
- // scan that itself triggered.
281
- const confirmed = evaluateHarvestPressure(scan, this.currentConfig().policy)
282
- if (confirmed.trigger === undefined) {
266
+ // Flat-cost gate (AUT-521): the pressure trigger is evaluated from
267
+ // the journal and one repo-scoped digest batch read — no per-build
268
+ // history reads — so the idle evaluation no longer grows with
269
+ // accumulated finished builds. Only a tripped gate pays for the full
270
+ // unclaimed-observation scan, because the packet artifact needs the
271
+ // observation payloads no bounded source carries.
272
+ // Bounded read (AUT-489): the gate's journal input is consumed only
273
+ // through reduceHarvest, so the subset is replay-equivalent here too.
274
+ const [gateEvents, digests] = await Promise.all([
275
+ store.getRepoStateEvents(repo),
276
+ store.getRepoBuildDigests(repo),
277
+ ])
278
+ const pressure = evaluateHarvestPressureFromDigests({
279
+ digests,
280
+ harvestEvents: gateEvents,
281
+ policy: this.currentConfig().policy,
282
+ })
283
+ if (pressure.trigger === undefined) {
284
+ // Treat the read's completion as a boundary even when the gate did
285
+ // not trip, so a request arriving during the read is acknowledged
286
+ // before this runner returns idle.
287
+ await this.controlBoundary()
288
+ return { outcome: 'idle' }
289
+ }
283
290
  await this.controlBoundary()
284
- return { outcome: 'idle' }
291
+ const scan = await scanUnclaimedObservations(store, repo)
292
+ // The trigger came from an earlier read than this scan, and a claim
293
+ // may have landed in the window. Re-confirm the gate against the
294
+ // fresh scan (pure, free): an emptied window returns idle exactly as
295
+ // if the gate had not tripped, so a packet is only ever built from a
296
+ // scan that itself triggered.
297
+ const confirmed = evaluateHarvestPressure(scan, this.currentConfig().policy)
298
+ if (confirmed.trigger === undefined) {
299
+ await this.controlBoundary()
300
+ return { outcome: 'idle' }
301
+ }
302
+ const runId = this.deps.ids('harvest')
303
+ const packet = await makeHarvestScanPacket({
304
+ store,
305
+ tickets: this.deps.tickets,
306
+ repo,
307
+ run: runId,
308
+ observations: scan.observations,
309
+ state: scan.state,
310
+ })
311
+ // Scan preparation is not a claim. Re-read operator control immediately
312
+ // before atomically committing the immutable snapshot.
313
+ await this.controlBoundary()
314
+ initial = 'started'
315
+ await store.appendRepoWithArtifacts(
316
+ repo,
317
+ [
318
+ {
319
+ kind: HARVEST_SCAN_ARTIFACT,
320
+ content: `${JSON.stringify(packet, null, 2)}\n`,
321
+ metadata: { run: runId, observations: packet.observations.length },
322
+ },
323
+ ],
324
+ (deposited) => ({
325
+ actor: KERNEL,
326
+ type: 'harvest.started',
327
+ payload: {
328
+ run: runId,
329
+ observations: packet.observations.map((item) => item.occurrence),
330
+ scan: artifactRef(deposited[0]!),
331
+ trigger: confirmed.trigger,
332
+ ...(this.deps.environment !== undefined
333
+ ? { environment: this.deps.environment }
334
+ : {}),
335
+ },
336
+ }),
337
+ )
338
+ // Bounded read (AUT-489): reduceHarvest consumes durable types only.
339
+ events = await store.getRepoStateEvents(repo)
340
+ run = reduceHarvest(events).runs.find((candidate) => candidate.run === runId)
341
+ if (!run) throw new Error(`harvest.started did not reduce run "${runId}"`)
342
+ // A request racing the claim parks this exact run; its snapshot stays
343
+ // open and is resumed rather than scanned again.
344
+ await this.controlBoundary(run.run)
285
345
  }
286
- const runId = this.deps.ids('harvest')
287
- const packet = await makeHarvestScanPacket({
288
- store,
289
- tickets: this.deps.tickets,
290
- repo,
291
- run: runId,
292
- observations: scan.observations,
293
- state: scan.state,
294
- })
295
- // Scan preparation is not a claim. Re-read operator control immediately
296
- // before atomically committing the immutable snapshot.
297
- await this.controlBoundary()
298
- initial = 'started'
299
- await store.appendRepoWithArtifacts(
300
- repo,
301
- [
302
- {
303
- kind: HARVEST_SCAN_ARTIFACT,
304
- content: `${JSON.stringify(packet, null, 2)}\n`,
305
- metadata: { run: runId, observations: packet.observations.length },
306
- },
307
- ],
308
- (deposited) => ({
309
- actor: KERNEL,
310
- type: 'harvest.started',
311
- payload: {
312
- run: runId,
313
- observations: packet.observations.map((item) => item.occurrence),
314
- scan: artifactRef(deposited[0]!),
315
- trigger: confirmed.trigger,
316
- ...(this.deps.environment !== undefined
317
- ? { environment: this.deps.environment }
318
- : {}),
319
- },
320
- }),
321
- )
322
- // Bounded read (AUT-489): reduceHarvest consumes durable types only.
323
- events = await store.getRepoStateEvents(repo)
324
- run = reduceHarvest(events).runs.find((candidate) => candidate.run === runId)
325
- if (!run) throw new Error(`harvest.started did not reduce run "${runId}"`)
326
- // A request racing the claim parks this exact run; its snapshot stays
327
- // open and is resumed rather than scanned again.
328
- await this.controlBoundary(run.run)
329
346
  }
330
-
347
+ if (!run) throw new Error('prepared harvest run unavailable')
348
+ if (preparationOnly) return { outcome: 'prepared', run: run.run, launch: initial }
331
349
  try {
332
350
  await this.ensureScanStep(run)
333
351
  await this.controlBoundary(run.run)
@@ -691,7 +709,7 @@ export class HarvestRunner {
691
709
  live.handle,
692
710
  `Revise harvest proposals for round ${spec.round}: run ab harvest context, address .ab/findings.json, then submit.`,
693
711
  {
694
- env: this.sessionEnv(spec.run, spec.step, spec.round, session),
712
+ env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
695
713
  ...(stream !== undefined ? { stream } : {}),
696
714
  },
697
715
  )
@@ -702,7 +720,7 @@ export class HarvestRunner {
702
720
  workspacePath,
703
721
  ...(target.model !== undefined ? { model: target.model } : {}),
704
722
  ...(target.args !== undefined ? { args: target.args } : {}),
705
- env: this.sessionEnv(spec.run, spec.step, spec.round, session),
723
+ env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
706
724
  ...(stream !== undefined ? { stream } : {}),
707
725
  })
708
726
  handle = turn.session
@@ -941,7 +959,7 @@ export class HarvestRunner {
941
959
  live.handle,
942
960
  `Revise harvest proposals for round ${spec.round}: run ab harvest context, address .ab/findings.json, then submit.`,
943
961
  {
944
- env: this.sessionEnv(spec.run, spec.step, spec.round, session),
962
+ env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
945
963
  ...(stream !== undefined ? { stream } : {}),
946
964
  },
947
965
  )
@@ -952,7 +970,7 @@ export class HarvestRunner {
952
970
  workspacePath,
953
971
  ...(resolved.model !== undefined ? { model: resolved.model } : {}),
954
972
  ...(resolved.args !== undefined ? { args: resolved.args } : {}),
955
- env: this.sessionEnv(spec.run, spec.step, spec.round, session),
973
+ env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
956
974
  ...(stream !== undefined ? { stream } : {}),
957
975
  })
958
976
  handle = turn.session
@@ -1391,6 +1409,7 @@ export class HarvestRunner {
1391
1409
  * give-up before returning. Initial/within-step failures do not spend this
1392
1410
  * outer budget and therefore remain recoverable. */
1393
1411
  private async settleRecoveryExhaustion(run: string): Promise<void> {
1412
+ if (this.deps.preparedRun) return
1394
1413
  await this.ensureLease()
1395
1414
  // Bounded read (AUT-489): reduceHarvest consumes durable types only.
1396
1415
  const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
@@ -1459,6 +1478,7 @@ export class HarvestRunner {
1459
1478
  const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
1460
1479
  const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
1461
1480
  if (decision.kind === 'proceed') return
1481
+ if (this.deps.preparedRun) throw new HarvestParkedSignal(run)
1462
1482
  if (decision.kind === 'park') {
1463
1483
  throw new HarvestParkedSignal(actionableHarvestRun(state)?.run ?? run)
1464
1484
  }
@@ -1602,15 +1622,26 @@ export class HarvestRunner {
1602
1622
  }
1603
1623
  }
1604
1624
 
1605
- private sessionEnv(
1625
+ private async sessionEnv(
1606
1626
  run: string,
1607
1627
  step: 'synthesize' | 'review',
1608
1628
  round: number,
1609
1629
  session: string,
1610
- ): Record<string, string> {
1630
+ ): Promise<Record<string, string>> {
1631
+ const parent = this.deps.sessionEnv
1632
+ const token =
1633
+ parent?.AB_STORE && /^https?:/i.test(parent.AB_STORE)
1634
+ ? await delegateCredential(parent.AB_STORE, parent.AB_TOKEN, {
1635
+ kind: 'harvest-phase',
1636
+ repo: this.deps.repo,
1637
+ id: run,
1638
+ session,
1639
+ })
1640
+ : undefined
1611
1641
  return {
1612
1642
  AB_STORE: 'local',
1613
1643
  ...this.deps.sessionEnv,
1644
+ ...(token ? { AB_TOKEN: token } : {}),
1614
1645
  AB_REPO: this.deps.repo,
1615
1646
  AB_HARVEST: run,
1616
1647
  AB_PHASE: `${step}@${round}`,
@@ -1638,7 +1669,18 @@ export class HarvestRunner {
1638
1669
  const open = target.openSessionStream
1639
1670
  if (open === undefined) return undefined
1640
1671
  const sink = createSessionStreamSink({
1641
- store: this.deps.store,
1672
+ store: {
1673
+ ...this.deps.store,
1674
+ createStream: (scope, label) =>
1675
+ this.deps.store.createStream(
1676
+ scope,
1677
+ this.deps.preparedRun
1678
+ ? `harvest:${this.deps.preparedRun.run}:${this.deps.instance}:${label}`
1679
+ : label,
1680
+ ),
1681
+ appendStreamParts: (id, parts) => this.deps.store.appendStreamParts(id, parts),
1682
+ closeStream: (id, outcome) => this.deps.store.closeStream(id, outcome),
1683
+ },
1642
1684
  scope: { kind: 'repo', repo: this.deps.repo },
1643
1685
  onDiagnostic: this.onDiagnostic,
1644
1686
  })
@@ -11,6 +11,7 @@ export const POLICY_PHASE_FAILURE_RESET_SCOPES = {
11
11
  'review-round-limit': 'matching-round',
12
12
  'verify-failure-limit': 'all-phase-rounds',
13
13
  'reconcile-no-progress': 'none',
14
+ 'dispatch-failure-limit': 'none',
14
15
  'setup-failure-limit': 'none',
15
16
  'infrastructure-failure-limit': 'none',
16
17
  'phase-attempt-limit': 'matching-round',
@@ -1,3 +1,5 @@
1
+ import { summaryBoundary } from '../kernel/pr-summary'
2
+ import { isRemoteWorkspace } from '../events/workspace-remote'
1
3
  /**
2
4
  * Last-chance publication settlement at the workspace-release boundary
3
5
  * (AUT-328). A pending `publication.requested` whose workspace is destroyed
@@ -37,7 +39,22 @@ export async function settlePublicationBeforeRelease(
37
39
  reason: string,
38
40
  ): Promise<AbEvent[]> {
39
41
  const request = latestUncompletedPublicationRequest(events)
40
- if (request === undefined) return events
42
+ if (request === undefined) {
43
+ const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
44
+ if (
45
+ workspace?.type === 'workspace.provisioned' &&
46
+ isRemoteWorkspace(workspace.payload) &&
47
+ summaryBoundary(events) !== undefined
48
+ ) {
49
+ try {
50
+ await deps.settlePublication?.(slug)
51
+ } catch {
52
+ /* Summary is best effort. */
53
+ }
54
+ return deps.store.getEvents(slug)
55
+ }
56
+ return events
57
+ }
41
58
  try {
42
59
  await deps.settlePublication?.(slug)
43
60
  } catch {
@@ -32,7 +32,8 @@
32
32
  * What is held:
33
33
  *
34
34
  * - the bounded repository journal (`getRepoStateEvents`' subset), pruned on
35
- * arrival by the same keep rule — durable types plus the tail from the latest
35
+ * arrival by the same keep rule — durable facts, latest-only measurements,
36
+ * and the tail (excluding latest-only types) from the latest
36
37
  * `dispatcher.run-started` — which is exact for seq-ordered input;
37
38
  * - for *work* builds (nonterminal, aborted, reclaim-pending, execution-open),
38
39
  * the full event array. Settled terminal builds hold only the discovery
@@ -48,6 +48,7 @@ export {
48
48
  REPOSITORY_RUN_SCOPED_EVENT_TYPES,
49
49
  REPOSITORY_STATE_EVENT_TYPES,
50
50
  readRepoStateEventsWithAnchorRecheck,
51
+ REPOSITORY_LATEST_EVENT_TYPES,
51
52
  } from './repo-state-events'
52
53
  export {
53
54
  DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS,
@@ -607,21 +607,25 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
607
607
  })
608
608
  expect(digests.get('dg-done')).toEqual({
609
609
  slug: 'dg-done',
610
+ readinessChangeSeq: 2,
610
611
  terminal: 'done',
611
612
  observations: [{ seq: 3, ts: tsOf('dg-done', 3) }],
612
613
  })
613
614
  expect(digests.get('dg-cleaning')).toEqual({
614
615
  slug: 'dg-cleaning',
616
+ readinessChangeSeq: 2,
615
617
  terminal: 'aborted',
616
618
  observations: [{ seq: 3, ts: tsOf('dg-cleaning', 3) }],
617
619
  })
618
620
  expect(digests.get('dg-abandoned')).toEqual({
619
621
  slug: 'dg-abandoned',
622
+ readinessChangeSeq: 3,
620
623
  terminal: 'done',
621
624
  observations: [],
622
625
  })
623
626
  expect(digests.get('dg-overwrite')).toEqual({
624
627
  slug: 'dg-overwrite',
628
+ readinessChangeSeq: 4,
625
629
  terminal: 'done',
626
630
  observations: [],
627
631
  })
@@ -684,6 +688,7 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
684
688
  digest = (await store.getRepoBuildDigests(repo)).get('dg-paths')
685
689
  expect(digest).toEqual({
686
690
  slug: 'dg-paths',
691
+ readinessChangeSeq: 6,
687
692
  terminal: 'done',
688
693
  merged: secondMergeTs,
689
694
  observations: [
@@ -1176,6 +1181,48 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
1176
1181
  ]
1177
1182
  }
1178
1183
 
1184
+ test('ticket-source health remains durable across newer runs', async () => {
1185
+ await withStore(factory, undefined, async (store) => {
1186
+ const repo = 'acme/source-health'
1187
+ await store.ensureRepo(repo)
1188
+ const condition = {
1189
+ source: 'fake',
1190
+ cause: 'rate-limited' as const,
1191
+ operation: 'ready-scan' as const,
1192
+ message: 'quota exhausted',
1193
+ resetAt: '2026-10-14T12:00:00.000Z',
1194
+ }
1195
+ await store.appendRepo(repo, {
1196
+ actor: DISPATCHER,
1197
+ type: 'dispatcher.ticket-source-degraded',
1198
+ payload: condition,
1199
+ })
1200
+ await store.appendRepo(repo, {
1201
+ actor: DISPATCHER,
1202
+ type: 'dispatcher.ticket-source-dependency-limited',
1203
+ payload: { source: 'fake' },
1204
+ })
1205
+ for (const write of noopInvocation('new-run')) await store.appendRepo(repo, write)
1206
+ const bounded = await store.getRepoStateEvents(repo)
1207
+ expect(bounded[0]?.type).toBe('dispatcher.ticket-source-degraded')
1208
+ expect(bounded[1]?.type).toBe('dispatcher.ticket-source-dependency-limited')
1209
+ await store.appendRepo(repo, {
1210
+ actor: DISPATCHER,
1211
+ type: 'dispatcher.ticket-source-recovered',
1212
+ payload: { source: 'fake', cause: 'rate-limited', operation: 'dependency-check' },
1213
+ })
1214
+ expect(
1215
+ (await store.getRepoStateEvents(repo)).filter((event) =>
1216
+ event.type.startsWith('dispatcher.ticket-source-'),
1217
+ ),
1218
+ ).toEqual(
1219
+ (await store.getRepoEvents(repo)).filter((event) =>
1220
+ event.type.startsWith('dispatcher.ticket-source-'),
1221
+ ),
1222
+ )
1223
+ })
1224
+ })
1225
+
1179
1226
  function noopTickCounters() {
1180
1227
  return {
1181
1228
  merged: 0,
@@ -1202,6 +1249,68 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
1202
1249
  }
1203
1250
  }
1204
1251
 
1252
+ test('latest-only scans stay bounded across scans, runs and repositories', async () => {
1253
+ await withStore(factory, undefined, async (store) => {
1254
+ for (const repo of ['acme/scans', 'acme/foreign-scans']) await store.ensureRepo(repo)
1255
+ const appendScan = (repo: string, queued: number) =>
1256
+ store.appendRepo(repo, {
1257
+ actor: DISPATCHER,
1258
+ type: 'dispatcher.ticket-scan-completed',
1259
+ payload: {
1260
+ startedAt: '2026-10-14T00:00:00.000Z',
1261
+ mode: 'admission',
1262
+ readinessFingerprint: 'r',
1263
+ criteriaFingerprint: 'c',
1264
+ queued,
1265
+ invalidTickets: 0,
1266
+ ticketDiagnostics: [],
1267
+ dependencyBlocked: 0,
1268
+ dependencyDiagnostics: [],
1269
+ creationWithheld: 0,
1270
+ creationDiagnostics: [],
1271
+ },
1272
+ })
1273
+ await store.appendRepo('acme/scans', {
1274
+ actor: humanActor('op'),
1275
+ type: 'dispatcher.intake-set',
1276
+ payload: { enabled: true },
1277
+ })
1278
+ const check = async (expected: number, rows: number) => {
1279
+ const all = await store.getRepoEvents('acme/scans')
1280
+ const bounded = await store.getRepoStateEvents('acme/scans')
1281
+ expect(bounded).toEqual(projectRepositoryStateEvents(all))
1282
+ expect(bounded).toHaveLength(rows)
1283
+ const scans = bounded.filter((e) => e.type === 'dispatcher.ticket-scan-completed')
1284
+ expect(scans).toHaveLength(1)
1285
+ expect(scans[0]!.payload).toMatchObject({ queued: expected })
1286
+ expect(reduceDispatchSettings(bounded)).toEqual(reduceDispatchSettings(all))
1287
+ }
1288
+ for (let i = 0; i < 200; i++) await appendScan('acme/scans', i)
1289
+ await check(199, 2)
1290
+ for (let i = 200; i < 400; i++) {
1291
+ await appendScan('acme/scans', i)
1292
+ await appendScan('acme/foreign-scans', 999)
1293
+ }
1294
+ await check(399, 2)
1295
+ for (let run = 0; run < 20; run++) {
1296
+ for (const write of noopInvocation(`scan-run-${run}`))
1297
+ await store.appendRepo('acme/scans', write)
1298
+ await appendScan('acme/scans', run)
1299
+ }
1300
+ await check(19, 6)
1301
+ for (const write of noopInvocation('scan-final'))
1302
+ await store.appendRepo('acme/scans', write)
1303
+ await check(19, 6) // latest scan is older than the run anchor
1304
+ for (let i = 0; i < 100; i++) await appendScan('acme/scans', i)
1305
+ await check(99, 6)
1306
+ expect(
1307
+ (await store.getRepoEvents('acme/scans')).filter(
1308
+ (e) => e.type === 'dispatcher.ticket-scan-completed',
1309
+ ),
1310
+ ).toHaveLength(520)
1311
+ })
1312
+ })
1313
+
1205
1314
  test('unknown repo rejects like getRepoEvents', async () => {
1206
1315
  await withStore(factory, undefined, async (store) => {
1207
1316
  await expect(store.getRepoStateEvents('acme/never-seen')).rejects.toThrow(/unknown repo/)
@@ -45,6 +45,7 @@ export type DigestEvent = DigestEventRow
45
45
 
46
46
  /** The carried accumulator for `reduceBuildDigest`. */
47
47
  export interface DigestAcc {
48
+ readinessChangeSeq?: number
48
49
  terminal?: BuildDigest['terminal']
49
50
  merged?: string
50
51
  observations: { seq: number; ts: string }[]
@@ -57,7 +58,7 @@ export interface DigestAcc {
57
58
  }
58
59
 
59
60
  /** Bump when `DigestAcc`'s shape or fold semantics change. */
60
- export const BUILD_DIGEST_REDUCER_VERSION = 2
61
+ export const BUILD_DIGEST_REDUCER_VERSION = 3
61
62
 
62
63
  export const buildDigestReducer: IncrementalReducer<
63
64
  DigestAcc,
@@ -68,6 +69,8 @@ export const buildDigestReducer: IncrementalReducer<
68
69
  initial: (): DigestAcc => ({ observations: [], hosted: [], reclaimed: [], openInstance: null }),
69
70
  fold(acc, events) {
70
71
  for (const event of events) {
72
+ if (['pr.merged', 'build.completed', 'build.aborted'].includes(event.type))
73
+ acc.readinessChangeSeq = event.seq
71
74
  if (event.type === 'build.completed') acc.terminal = 'done'
72
75
  else if (event.type === 'build.aborted') acc.terminal = 'aborted'
73
76
  else if (event.type === 'pr.merged') acc.merged = event.ts
@@ -84,11 +87,20 @@ export const buildDigestReducer: IncrementalReducer<
84
87
  }
85
88
  }
86
89
  },
87
- finish: ({ terminal, merged, observations, hosted, reclaimed, openInstance }) => {
90
+ finish: ({
91
+ readinessChangeSeq,
92
+ terminal,
93
+ merged,
94
+ observations,
95
+ hosted,
96
+ reclaimed,
97
+ openInstance,
98
+ }) => {
88
99
  const reclaimedSet = new Set(reclaimed)
89
100
  const reclaimPending = hosted.some((seq) => !reclaimedSet.has(seq))
90
101
  return {
91
102
  observations: [...observations],
103
+ ...(readinessChangeSeq !== undefined ? { readinessChangeSeq } : {}),
92
104
  ...(merged !== undefined ? { merged } : {}),
93
105
  ...(terminal !== undefined ? { terminal } : {}),
94
106
  ...(reclaimPending ? { reclaimPending: true as const } : {}),
@@ -46,6 +46,7 @@ import { createSessionScopedStore } from '../session-handle'
46
46
  import { DIGEST_EVENT_TYPES, type DigestEventRow, reduceBuildDigest } from '../digest'
47
47
  import {
48
48
  REPOSITORY_STATE_EVENT_TYPES,
49
+ REPOSITORY_LATEST_EVENT_TYPES,
49
50
  readRepoStateEventsWithAnchorRecheck,
50
51
  } from '../repo-state-events'
51
52
  import {
@@ -1188,14 +1189,26 @@ export class SqliteBuildStore implements BuildStore {
1188
1189
  },
1189
1190
  async (anchor) => {
1190
1191
  const durable = inArray(repoEvents.type, [...REPOSITORY_STATE_EVENT_TYPES])
1192
+ const latest = REPOSITORY_LATEST_EVENT_TYPES.map((type) =>
1193
+ and(
1194
+ eq(repoEvents.type, type),
1195
+ sql`${repoEvents.seq} = (SELECT seq FROM repo_events WHERE repo = ${repo} AND type = ${type} ORDER BY seq DESC LIMIT 1)`,
1196
+ ),
1197
+ )
1198
+ const tail =
1199
+ anchor === undefined
1200
+ ? undefined
1201
+ : and(
1202
+ gte(repoEvents.seq, anchor),
1203
+ sql`${repoEvents.type} NOT IN (${sql.join(
1204
+ REPOSITORY_LATEST_EVENT_TYPES.map((type) => sql`${type}`),
1205
+ sql`, `,
1206
+ )})`,
1207
+ )
1191
1208
  const rows = this.db
1192
1209
  .select()
1193
1210
  .from(repoEvents)
1194
- .where(
1195
- anchor === undefined
1196
- ? and(eq(repoEvents.repo, repo), durable)
1197
- : and(eq(repoEvents.repo, repo), or(gte(repoEvents.seq, anchor), durable)),
1198
- )
1211
+ .where(and(eq(repoEvents.repo, repo), or(durable, tail, ...latest)))
1199
1212
  .orderBy(asc(repoEvents.seq))
1200
1213
  .all()
1201
1214
  return rows.map(
@@ -578,7 +578,7 @@ export class RemoteBuildStore implements BuildStore {
578
578
 
579
579
  async getRepoHighWater(repo: string): Promise<number> {
580
580
  // Additive route beside `state-events`; the store service and client
581
- // ship in version lockstep, so no protocol bump.
581
+ // retain this additive route throughout the supported client window.
582
582
  return this.requestJson('GET', `${this.repoPath(repo)}/high-water`, repoHighWaterSchema).then(
583
583
  (body) => (body as { seq: number }).seq,
584
584
  )