@defrex/autobuild 0.10.1 → 0.11.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 (45) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +15 -1
  3. package/bin/ab-dispatch-kernel.ts +1 -1
  4. package/docs/configuration.md +32 -3
  5. package/docs/operator-api.md +17 -0
  6. package/docs/remote-ticket-protocol.md +5 -0
  7. package/package.json +3 -3
  8. package/packages/core/src/cli/binary.ts +3 -3
  9. package/packages/core/src/cli/dashboard/model.ts +4 -0
  10. package/packages/core/src/cli/dashboard/render.ts +37 -7
  11. package/packages/core/src/cli/dispatch-frontend.ts +16 -1
  12. package/packages/core/src/cli/dispatch-process.ts +25 -7
  13. package/packages/core/src/cli/dispatch.ts +64 -6
  14. package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
  15. package/packages/core/src/cli/help.ts +8 -5
  16. package/packages/core/src/cli/repository-status.ts +45 -5
  17. package/packages/core/src/cli/watch.ts +159 -14
  18. package/packages/core/src/config/live.ts +1 -0
  19. package/packages/core/src/config/schema.ts +9 -2
  20. package/packages/core/src/events/globs.ts +8 -3
  21. package/packages/core/src/events/repository.ts +51 -2
  22. package/packages/core/src/integration/harness.ts +2 -1
  23. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +18 -0
  24. package/packages/core/src/kernel/dispatch-status.ts +9 -1
  25. package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
  26. package/packages/core/src/kernel/reducer-registry.ts +4 -0
  27. package/packages/core/src/kernel/ticket-source-health.ts +30 -0
  28. package/packages/core/src/operator/query.ts +6 -3
  29. package/packages/core/src/ports/tickets/contract.ts +25 -0
  30. package/packages/core/src/ports/tickets/fake.ts +10 -8
  31. package/packages/core/src/ports/tickets/file.ts +1 -0
  32. package/packages/core/src/ports/tickets/linear.ts +241 -49
  33. package/packages/core/src/ports/tickets/remote-protocol.ts +2 -0
  34. package/packages/core/src/ports/types.ts +17 -0
  35. package/packages/core/src/processes/dispatcher.ts +277 -73
  36. package/packages/core/src/processes/repo-view.ts +2 -1
  37. package/packages/core/src/store/adapter.ts +1 -0
  38. package/packages/core/src/store/contract.ts +109 -0
  39. package/packages/core/src/store/digest.ts +14 -2
  40. package/packages/core/src/store/local/store.ts +18 -5
  41. package/packages/core/src/store/remote/protocol.ts +1 -0
  42. package/packages/core/src/store/repo-state-events.ts +49 -59
  43. package/packages/core/src/store/types.ts +5 -1
  44. package/skills/guide/SKILL.md +31 -0
  45. package/skills/operate/SKILL.md +39 -4
@@ -1,3 +1,7 @@
1
+ import {
2
+ reduceTicketSourceHealth,
3
+ type TicketSourceDegradation,
4
+ } from '../kernel/ticket-source-health'
1
5
  /**
2
6
  * The dispatcher process (SPEC §3.3, §12): watches the TicketSource for
3
7
  * Ready tickets, claims, provisions a workspace, and launches build-runners
@@ -36,6 +40,7 @@
36
40
  * validation and fallback remain here. The supervising kernel reserves each
37
41
  * execution lease before launch; the child renews that same-holder lease.
38
42
  */
43
+ import { createHash } from 'node:crypto'
39
44
  import type { Config } from '../config/schema'
40
45
  import type { LanguageModel } from 'ai'
41
46
  import { runOrchestratorTickStep } from './orchestrator-tick'
@@ -74,6 +79,7 @@ import type {
74
79
  TicketSource,
75
80
  WorkspaceProvider,
76
81
  } from '../ports/types'
82
+ import { TicketSourceRateLimitError } from '../ports/types'
77
83
  import type { Exec } from '../ports/workspace/git-worktree'
78
84
  import type { ArtifactMeta, BuildDigest, BuildRecord, BuildStore, Clock } from '../store/types'
79
85
  import { samplePinnedAssets } from '../store/ticket-assets'
@@ -201,7 +207,9 @@ function findCycle(
201
207
  nodes: Map<string, DependencyState>,
202
208
  explored: Set<string>,
203
209
  ): string[] | null {
204
- for (const next of nodes.get(from)?.blockedBy ?? []) {
210
+ const node = nodes.get(from)
211
+ if (!node?.exists || node.resolved) return null
212
+ for (const next of node.blockedBy) {
205
213
  const seen = path.indexOf(next)
206
214
  if (seen !== -1) return [...path.slice(seen), next]
207
215
  if (explored.has(next)) continue
@@ -301,7 +309,7 @@ export function validateSlugCandidate(candidate: string | null | undefined): str
301
309
  /** Counts per action, for observability and tests. An idempotent re-run of
302
310
  * `tick()` over unchanged state reports all zeroes — except invalid-record,
303
311
  * in-flight-creation, and dependency fields, which are standing queue reports rather than records of
304
- * action. They repeat on every ready scan by design, because that is the only
312
+ * action. They repeat between scans and refresh on completed scans, because that is the only
305
313
  * place the source problems are visible without provider inspection. */
306
314
  export interface TickReport {
307
315
  /** Janitor (§15.7): builds completed as merged. */
@@ -354,13 +362,15 @@ export interface TickReport {
354
362
  /** Standing queue depth: ready tickets still waiting after this tick —
355
363
  * excludes tickets already embodied by an active build, and ones that left
356
364
  * the queue this tick (dispatched, bounced, or claimed elsewhere). Like the
357
- * invalid/dependency fields it re-reports on every ready scan. */
365
+ * invalid/dependency fields it repeats between completed scans. */
358
366
  queued: number
359
367
  /** Ready tickets withheld while Autobuild's own external creation is still
360
368
  * between durable reservation and settled blocker recording. */
361
369
  creationWithheld: number
362
370
  /** One line per creation hold, with Harvest run/proposal provenance. */
363
371
  creationDiagnostics: string[]
372
+ /** Ready tickets whose dependency reads failed rather than yielding a verdict. */
373
+ dependencyCheckFailed: number
364
374
  /** Dependency gate (§13): ready tickets held back by unresolved blockers. */
365
375
  dependencyBlocked: number
366
376
  /** One line per held ticket naming its unresolved blockers — the operator's
@@ -410,6 +420,7 @@ export function emptyTickReport(): TickReport {
410
420
  queued: 0,
411
421
  creationWithheld: 0,
412
422
  creationDiagnostics: [],
423
+ dependencyCheckFailed: 0,
413
424
  dependencyBlocked: 0,
414
425
  dependencyDiagnostics: [],
415
426
  harvestStarted: 0,
@@ -729,6 +740,7 @@ export class Dispatcher {
729
740
  }
730
741
  }
731
742
  const report = emptyTickReport()
743
+ this.restoreScan(report)
732
744
  /** Builds already launched this tick — a janitor/startup re-attach must
733
745
  * not be doubled by the sweep, nor a fresh dispatch by anything. */
734
746
  const launched = new Set<string>()
@@ -893,6 +905,81 @@ export class Dispatcher {
893
905
  .map(({ record }) => record)
894
906
  }
895
907
 
908
+ private async sourceCondition(
909
+ next?: TicketSourceDegradation,
910
+ successfulOperation?: 'ready-scan' | 'dependency-check',
911
+ ): Promise<void> {
912
+ await this.store.refreshJournal()
913
+ const current = reduceTicketSourceHealth(this.store.recordedJournal())
914
+ if (next !== undefined && current?.cause === next.cause && current.source === next.source) {
915
+ if (
916
+ next.cause === 'rate-limited' &&
917
+ next.operation === 'dependency-check' &&
918
+ current.operation !== 'dependency-check'
919
+ ) {
920
+ await this.store.appendRepo(this.deps.repo, {
921
+ actor: DISPATCHER,
922
+ type: 'dispatcher.ticket-source-dependency-limited',
923
+ payload: { source: current.source },
924
+ })
925
+ }
926
+ return
927
+ }
928
+ if (
929
+ next === undefined &&
930
+ current?.operation === 'dependency-check' &&
931
+ successfulOperation !== 'dependency-check'
932
+ )
933
+ return
934
+ if (current !== undefined) {
935
+ await this.store.appendRepo(this.deps.repo, {
936
+ actor: DISPATCHER,
937
+ type: 'dispatcher.ticket-source-recovered',
938
+ payload: { source: current.source, cause: current.cause, operation: current.operation },
939
+ })
940
+ }
941
+ if (next !== undefined) {
942
+ await this.store.ensureRepo(this.deps.repo)
943
+ await this.store.appendRepo(this.deps.repo, {
944
+ actor: DISPATCHER,
945
+ type: 'dispatcher.ticket-source-degraded',
946
+ payload: next,
947
+ })
948
+ }
949
+ }
950
+
951
+ private async sourceFailure(
952
+ error: unknown,
953
+ operation: 'ready-scan' | 'dependency-check',
954
+ ): Promise<void> {
955
+ const message = error instanceof Error ? error.message : String(error)
956
+ await this.sourceCondition(
957
+ error instanceof TicketSourceRateLimitError
958
+ ? {
959
+ source: error.source,
960
+ cause: 'rate-limited',
961
+ operation,
962
+ message,
963
+ resetAt: new Date(error.resetAt).toISOString(),
964
+ }
965
+ : {
966
+ source: this.deps.tickets.name,
967
+ cause: 'ready-scan-failed',
968
+ operation,
969
+ message: message || 'Ready scan failed',
970
+ },
971
+ )
972
+ }
973
+
974
+ private async listReady() {
975
+ try {
976
+ return await this.deps.tickets.listReady(readyCriteria(this.deps.config))
977
+ } catch (error) {
978
+ await this.sourceFailure(error, 'ready-scan')
979
+ throw error
980
+ }
981
+ }
982
+
896
983
  /** Observation-only ready listing for Store-backed frontends while intake is
897
984
  * disabled. It shares the active-ticket exclusion with dispatch but performs
898
985
  * no dependency fetch, claim, bounce, build, workspace, or launch side
@@ -910,14 +997,109 @@ export class Dispatcher {
910
997
  activeTicketIds.add(record.ticket.id)
911
998
  }
912
999
  }
913
- const listing = await this.deps.tickets.listReady(readyCriteria(this.deps.config))
1000
+ const report = emptyTickReport()
1001
+ this.restoreScan(report)
1002
+ const checkpoint = await this.scanCheckpoint('observation')
1003
+ if (checkpoint.due) {
1004
+ await this.store.refreshJournal()
1005
+ const listing = await this.listReady()
1006
+ await this.store.refreshJournal()
1007
+ report.queued = listing.tickets.filter((ticket) => !activeTicketIds.has(ticket.ref.id)).length
1008
+ report.invalidTickets = listing.diagnostics.length
1009
+ report.ticketDiagnostics = [...listing.diagnostics]
1010
+ report.dependencyBlocked = 0
1011
+ report.dependencyDiagnostics = []
1012
+ report.creationWithheld = 0
1013
+ report.creationDiagnostics = []
1014
+ await this.sourceCondition(undefined, 'ready-scan')
1015
+ await this.completeScan(checkpoint, report)
1016
+ }
914
1017
  return {
915
- queued: listing.tickets.filter((ticket) => !activeTicketIds.has(ticket.ref.id)).length,
916
- invalidTickets: listing.diagnostics.length,
917
- ticketDiagnostics: [...listing.diagnostics],
1018
+ queued: report.queued,
1019
+ invalidTickets: report.invalidTickets,
1020
+ ticketDiagnostics: report.ticketDiagnostics,
918
1021
  }
919
1022
  }
920
1023
 
1024
+ private latestScan() {
1025
+ return this.store
1026
+ .recordedJournal()
1027
+ .findLast(
1028
+ (event): event is Extract<RepositoryEvent, { type: 'dispatcher.ticket-scan-completed' }> =>
1029
+ event.type === 'dispatcher.ticket-scan-completed',
1030
+ )?.payload
1031
+ }
1032
+
1033
+ private restoreScan(report: TickReport): void {
1034
+ const previous = this.latestScan()
1035
+ if (previous === undefined) return
1036
+ for (const key of [
1037
+ 'queued',
1038
+ 'invalidTickets',
1039
+ 'dependencyBlocked',
1040
+ 'creationWithheld',
1041
+ ] as const)
1042
+ report[key] = previous[key]
1043
+ for (const key of [
1044
+ 'ticketDiagnostics',
1045
+ 'dependencyDiagnostics',
1046
+ 'creationDiagnostics',
1047
+ ] as const)
1048
+ report[key] = [...previous[key]]
1049
+ }
1050
+
1051
+ private async scanCheckpoint(mode: 'admission' | 'observation') {
1052
+ const digest = (await this.repoBuildDigests())
1053
+ .filter(({ digest }) => digest.readinessChangeSeq !== undefined)
1054
+ .map(({ record, digest }) => [record.slug, digest.readinessChangeSeq] as const)
1055
+ .sort(([a], [b]) => a.localeCompare(b))
1056
+ const hash = (value: unknown) =>
1057
+ createHash('sha256').update(JSON.stringify(value)).digest('hex')
1058
+ const readinessFingerprint = hash(digest)
1059
+ const criteriaFingerprint = hash(readyCriteria(this.deps.config))
1060
+ const startedAt = this.deps.clock().toISOString()
1061
+ const previous = this.latestScan()
1062
+ const elapsed =
1063
+ previous === undefined ? Infinity : Date.parse(startedAt) - Date.parse(previous.startedAt)
1064
+ return {
1065
+ startedAt,
1066
+ mode,
1067
+ readinessFingerprint,
1068
+ criteriaFingerprint,
1069
+ due:
1070
+ previous === undefined ||
1071
+ elapsed < 0 ||
1072
+ elapsed >= this.deps.config.ticketScanIntervalSeconds * 1000 ||
1073
+ previous.readinessFingerprint !== readinessFingerprint ||
1074
+ previous.criteriaFingerprint !== criteriaFingerprint ||
1075
+ (mode === 'admission' && previous.mode === 'observation'),
1076
+ }
1077
+ }
1078
+
1079
+ private async completeScan(
1080
+ checkpoint: Awaited<ReturnType<Dispatcher['scanCheckpoint']>>,
1081
+ report: TickReport,
1082
+ ): Promise<void> {
1083
+ await this.store.ensureRepo(this.deps.repo)
1084
+ await this.store.appendRepo(this.deps.repo, {
1085
+ actor: DISPATCHER,
1086
+ type: 'dispatcher.ticket-scan-completed',
1087
+ payload: {
1088
+ startedAt: checkpoint.startedAt,
1089
+ mode: checkpoint.mode,
1090
+ readinessFingerprint: checkpoint.readinessFingerprint,
1091
+ criteriaFingerprint: checkpoint.criteriaFingerprint,
1092
+ queued: report.queued,
1093
+ invalidTickets: report.invalidTickets,
1094
+ ticketDiagnostics: report.ticketDiagnostics.slice(0, 1000),
1095
+ dependencyBlocked: report.dependencyBlocked,
1096
+ dependencyDiagnostics: report.dependencyDiagnostics.slice(0, 1000),
1097
+ creationWithheld: report.creationWithheld,
1098
+ creationDiagnostics: report.creationDiagnostics.slice(0, 1000),
1099
+ },
1100
+ })
1101
+ }
1102
+
921
1103
  /** Apply the newest durable `dispatcher.auto-merge-default-set` fact to every
922
1104
  * current non-terminal build of this repository: on — request consent; off —
923
1105
  * cancel it. Attribution is the toggling human's own actor, exactly as if
@@ -2859,6 +3041,13 @@ export class Dispatcher {
2859
3041
  ): Promise<void> {
2860
3042
  const store = this.store
2861
3043
  const { tickets, config } = this.deps
3044
+ const checkpoint = await this.scanCheckpoint('admission')
3045
+ if (!checkpoint.due) return
3046
+ // Clear only standing measurement fields; action counters belong to this tick.
3047
+ report.queued = report.invalidTickets = report.dependencyBlocked = report.creationWithheld = 0
3048
+ report.ticketDiagnostics = []
3049
+ report.dependencyDiagnostics = []
3050
+ report.creationDiagnostics = []
2862
3051
  // Blocked and paused builds still occupy a slot: their workspaces and
2863
3052
  // pending work are live, only waiting on a human. Capacity is per repo
2864
3053
  // (§16.1) — another repo's builds never consume this repo's slots.
@@ -2878,9 +3067,12 @@ export class Dispatcher {
2878
3067
  // ready state are deduped against the active builds embodying them.
2879
3068
  await this.store.refreshJournal()
2880
3069
  const journalBeforeReady = this.store.recordedJournal()
2881
- const listing = await tickets.listReady(readyCriteria(config))
3070
+ const listing = await this.listReady()
2882
3071
  await this.store.refreshJournal()
2883
3072
  const journalAfterReady = this.store.recordedJournal()
3073
+ if (reduceTicketSourceHealth(journalAfterReady)?.cause === 'ready-scan-failed') {
3074
+ await this.sourceCondition(undefined, 'ready-scan')
3075
+ }
2884
3076
  const creationHolds = new Map(
2885
3077
  harvestCreationsDuringReadyScan(journalBeforeReady, journalAfterReady).map((creation) => [
2886
3078
  creation.creationKey.toLowerCase(),
@@ -2912,55 +3104,67 @@ export class Dispatcher {
2912
3104
  // still runs either way, so the standing queue-depth report stays honest —
2913
3105
  // this only declines to claim a ticket into a build that would immediately
2914
3106
  // sit held.
2915
- if (capacity <= 0 || paused) return
2916
- // One dependency-node cache per tick: blockers are commonly shared across
2917
- // the ready set, and a blocker's resolution must be re-read every tick
2918
- // (never cached across them) so a completion lands on the next pass.
2919
- const nodes = new Map<string, DependencyState>()
3107
+ if (capacity <= 0 || paused) {
3108
+ await this.sourceCondition(undefined, 'ready-scan')
3109
+ if (!this.outOfBudget(opts)) await this.completeScan(checkpoint, report)
3110
+ return
3111
+ }
3112
+ // One dependency-node cache per scan: blockers are commonly shared across
3113
+ // the ready set. Never reuse ticket or dependency objects between scans.
3114
+ const nodes = new Map<string, DependencyState>(
3115
+ listing.tickets.map((ticket) => [
3116
+ ticket.ref.id,
3117
+ {
3118
+ id: ticket.ref.id,
3119
+ exists: true,
3120
+ resolved: ticket.resolved,
3121
+ blockedBy: [...(ticket.blockedBy ?? [])],
3122
+ },
3123
+ ]),
3124
+ )
2920
3125
 
3126
+ // Finish read-only evaluation before any intake claim: a later source
3127
+ // failure must leave the entire ready set unclaimed. Publish dependency
3128
+ // diagnostics only after the complete preflight succeeds.
3129
+ let scanSuccessful = true
3130
+ const verdicts = new Map<string, DependencyVerdict>()
3131
+ const failed = new Set<string>()
2921
3132
  for (const ticket of ready) {
2922
- if (capacity <= 0) break
2923
- // Budget gate before the dependency fetch / claim / createBuild: the
2924
- // first ready read above stays (it is the standing queue-depth report);
2925
- // once the budget is spent no additional transport call is started, so
2926
- // the remaining tickets are left un-attempted for the next invocation.
2927
- if (this.outOfBudget(opts)) break
2928
-
2929
- // Autobuild's own in-flight create gate precedes even dependency reads:
2930
- // the ready projection may have been captured before blocker recording.
2931
- // It spends no capacity and leaves queue depth unchanged.
3133
+ if (this.outOfBudget(opts)) return
2932
3134
  if (withheld.has(ticket.ref.id)) continue
2933
-
2934
- // The dependency gate runs FIRST — before the claim, before the spec
2935
- // gate. A blocked ticket must not be claimed, must not be bounced, must
2936
- // create no build or workspace, and must not spend capacity; it is
2937
- // simply not this tick's work. Note this sits *above* [tickets]'s
2938
- // readyLabels / readyState gate: those criteria produced `ready`, and the
2939
- // gate subtracts from it — labels and state can make a ticket a
2940
- // candidate but can never override an unresolved blocker.
2941
- if ((ticket.blockedBy ?? []).length > 0) {
2942
- let verdict: DependencyVerdict
2943
- try {
2944
- await this.loadDependencyGraph(ticket, nodes)
2945
- verdict = analyzeDependencies(ticket.ref.id, ticket.blockedBy ?? [], nodes)
2946
- } catch (error) {
2947
- // A broken graph is this ticket's problem, not the tick's: skip it
2948
- // and let unrelated eligible tickets dispatch normally.
2949
- verdict = {
2950
- unresolved: [ticket.ref.id],
2951
- diagnostics: [
2952
- `ticket ${ticket.ref.id}: dependency check failed — ${
2953
- error instanceof Error ? error.message : String(error)
2954
- }`,
2955
- ],
2956
- }
2957
- }
2958
- if (verdict.unresolved.length > 0) {
2959
- report.dependencyBlocked += 1
2960
- report.dependencyDiagnostics.push(...verdict.diagnostics)
2961
- continue
3135
+ if (ticket.blockersResolved === true || (ticket.blockedBy ?? []).length === 0) continue
3136
+ let verdict: DependencyVerdict
3137
+ try {
3138
+ if (!(await this.loadDependencyGraph(ticket, nodes, opts))) return
3139
+ verdict = analyzeDependencies(ticket.ref.id, ticket.blockedBy ?? [], nodes)
3140
+ } catch (error) {
3141
+ if (error instanceof TicketSourceRateLimitError) {
3142
+ await this.sourceFailure(error, 'dependency-check')
3143
+ throw error
2962
3144
  }
3145
+ scanSuccessful = false
3146
+ failed.add(ticket.ref.id)
3147
+ report.dependencyCheckFailed += 1
3148
+ report.dependencyDiagnostics.push(
3149
+ `ticket ${ticket.ref.id}: dependency check failed — ${error instanceof Error ? error.message : String(error)}`,
3150
+ )
3151
+ continue
2963
3152
  }
3153
+ verdicts.set(ticket.ref.id, verdict)
3154
+ }
3155
+ if (this.outOfBudget(opts)) return
3156
+ if (failed.size === 0) await this.sourceCondition(undefined, 'dependency-check')
3157
+ for (const verdict of verdicts.values()) {
3158
+ if (verdict.unresolved.length > 0) {
3159
+ report.dependencyBlocked += 1
3160
+ report.dependencyDiagnostics.push(...verdict.diagnostics)
3161
+ }
3162
+ }
3163
+ for (const ticket of ready) {
3164
+ if (capacity <= 0 || this.outOfBudget(opts)) break
3165
+ if (withheld.has(ticket.ref.id)) continue
3166
+ if (failed.has(ticket.ref.id)) continue
3167
+ if ((verdicts.get(ticket.ref.id)?.unresolved.length ?? 0) > 0) continue
2964
3168
 
2965
3169
  // Claim-before-launch (§12): losing the claim means another dispatcher
2966
3170
  // (or an earlier tick) owns this ticket.
@@ -3036,36 +3240,36 @@ export class Dispatcher {
3036
3240
  // means the remote provisioning continuation owns the rest.
3037
3241
  if (outcome === 'completed' || outcome === 'deferred') report.dispatched += 1
3038
3242
  }
3243
+ if (scanSuccessful && !this.outOfBudget(opts)) await this.completeScan(checkpoint, report)
3039
3244
  }
3040
3245
 
3041
3246
  /**
3042
- * Close `nodes` over everything reachable from `ticket`'s blockers, so the
3043
- * analyzer can both gate and name a cycle. Breadth-first, one port call per
3044
- * level, skipping ids already cached — the common case (a blocker or two,
3045
- * none of them blocked themselves) is a single call.
3046
- *
3047
- * INVARIANT: `nodes` contains only states actually returned by the source.
3048
- * The cache is shared across the tick's tickets, so a node this ticket
3049
- * invents is a node the *next* ticket will trust as a fact about its own
3050
- * blocker. Nothing is seeded here for that reason (f_8bc9ee0c); the
3051
- * analyzer takes the ticket's own blockers as an argument instead, and
3052
- * `findCycle` closes a cycle via its path rather than the map.
3053
- *
3054
- * A provider-side cycle terminates the walk: an id already in `nodes` is
3055
- * never re-queued, so every reachable node is fetched exactly once.
3247
+ * Walk unresolved branches in provider order. Listing facts and dependency
3248
+ * reads share a tick-local cache; traversal visits are local to this root.
3056
3249
  */
3057
3250
  private async loadDependencyGraph(
3058
3251
  ticket: Ticket,
3059
3252
  nodes: Map<string, DependencyState>,
3060
- ): Promise<void> {
3061
- let frontier = [...new Set(ticket.blockedBy ?? [])].filter((id) => !nodes.has(id))
3253
+ opts: TickOpts = {},
3254
+ ): Promise<boolean> {
3255
+ const visited = new Set<string>()
3256
+ let frontier = [...new Set(ticket.blockedBy ?? [])]
3062
3257
  while (frontier.length > 0) {
3063
- const states = await this.deps.tickets.dependencyStates(frontier)
3064
- for (const state of states) nodes.set(state.id, state)
3065
- frontier = [...new Set(states.flatMap((state) => state.blockedBy))].filter(
3066
- (id) => !nodes.has(id),
3067
- )
3258
+ if (this.outOfBudget(opts)) return false
3259
+ const uncached = frontier.filter((id) => !nodes.has(id))
3260
+ if (uncached.length > 0) {
3261
+ const states = await this.deps.tickets.dependencyStates(uncached)
3262
+ for (const state of states) nodes.set(state.id, state)
3263
+ }
3264
+ const next: string[] = []
3265
+ for (const id of frontier) {
3266
+ visited.add(id)
3267
+ const node = nodes.get(id)
3268
+ if (node?.exists && !node.resolved) next.push(...node.blockedBy)
3269
+ }
3270
+ frontier = [...new Set(next)].filter((id) => !visited.has(id))
3068
3271
  }
3272
+ return true
3069
3273
  }
3070
3274
 
3071
3275
  /** Bounce (§6.3): back to Triage with a comment citing the standard and
@@ -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/)