@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,8 +1,11 @@
1
+ import { readDispatcherLiveness } from './dispatcher-liveness'
2
+ import { projectDispatcherLiveness } from '../kernel/dispatcher-liveness'
3
+ import type { RepositoryRecord } from '../store/types'
1
4
  /**
2
5
  * `ab watch` — the sessionless, read-only streaming half of the status family
3
6
  * (§8.2, §7.2). It follows the event logs of one, several, or all active
4
- * builds in the repository — optionally the repository journal — and emits one
5
- * record per matching event as it lands, each carrying the full durable
7
+ * builds in the repository, plus the repository journal by default with no
8
+ * slugs, and emits one record per matching event as it lands, carrying the durable
6
9
  * envelope, the build's reduced state after that event, and an opaque cursor
7
10
  * so a later `--since` invocation resumes exactly where this one stopped.
8
11
  *
@@ -102,8 +105,8 @@ export const BUILD_ATTENTION_EVENTS = [
102
105
  * Compile `--event` globs to anchored matchers: `*` matches any run of
103
106
  * characters, `?` exactly one, everything else verbatim (so the dots inside
104
107
  * event type names are escaped). Each glob must match at least one type in
105
- * the build catalog — or, when `--repository` is given, in the repository
106
- * catalog — otherwise a usage error naming the glob is thrown before any
108
+ * the build catalog, or the repository catalog with no slugs or explicit
109
+ * `--repository` — otherwise a usage error naming the glob is thrown before any
107
110
  * store read. Supplying any glob replaces the attention set entirely.
108
111
  *
109
112
  * The compiler itself lives in `events/globs.ts`, shared with the
@@ -133,6 +136,7 @@ interface CursorPayload {
133
136
  /** The repository identity (normalized origin or checkout path). */
134
137
  repo: string
135
138
  /** Per-stream watch position: build slug → seq, `#repo` → seq. */
139
+ lostRuns?: string[]
136
140
  streams: Record<string, number>
137
141
  }
138
142
 
@@ -169,7 +173,13 @@ export function decodeCursor(
169
173
  throw malformed()
170
174
  }
171
175
  if (typeof payload !== 'object' || payload === null) throw malformed()
172
- const { v, store, repo, streams } = payload as Record<string, unknown>
176
+ const { v, store, repo, streams, lostRuns } = payload as Record<string, unknown>
177
+ if (
178
+ lostRuns !== undefined &&
179
+ (!Array.isArray(lostRuns) ||
180
+ lostRuns.some((run) => typeof run !== 'string' || run.length === 0))
181
+ )
182
+ throw malformed()
173
183
  if (v !== 1 || typeof store !== 'string' || typeof repo !== 'string') throw malformed()
174
184
  if (typeof streams !== 'object' || streams === null || Array.isArray(streams)) throw malformed()
175
185
  for (const seq of Object.values(streams)) {
@@ -187,7 +197,13 @@ export function decodeCursor(
187
197
  `repository "${expected.repo}" — run from the repository the cursor came from`,
188
198
  )
189
199
  }
190
- return { v: 1, store, repo, streams: streams as Record<string, number> }
200
+ return {
201
+ v: 1,
202
+ store,
203
+ repo,
204
+ streams: streams as Record<string, number>,
205
+ ...(lostRuns === undefined ? {} : { lostRuns: lostRuns as string[] }),
206
+ }
191
207
  }
192
208
 
193
209
  // ── Durations and cadence ────────────────────────────────────────────────────
@@ -237,6 +253,25 @@ export interface WatchRecordState {
237
253
  outcome: BuildOutcome | null
238
254
  }
239
255
 
256
+ /** Lease-expiry evidence is an observation, never a fabricated journal event. */
257
+ export interface WatchObservationRecord {
258
+ repo: string
259
+ build: null
260
+ state: null
261
+ observation: {
262
+ type: 'dispatcher.lost'
263
+ run: string
264
+ observedAt: string
265
+ leaseExpiresAt: string
266
+ pid?: number | null
267
+ host?: string | null
268
+ startedAt?: string | null
269
+ heartbeatAt?: string | null
270
+ heartbeatAgeMs?: number | null
271
+ }
272
+ cursor: string
273
+ }
274
+
240
275
  export interface WatchRecord {
241
276
  /** The build slug, or null for a repository-journal event. */
242
277
  build: string | null
@@ -340,6 +375,10 @@ export function summarizeEvent(event: AbEvent | RepositoryEvent, state: BuildSta
340
375
  return `harvest run ${event.payload.run} escalated: ${singleLine(event.payload.reason)}`
341
376
  case 'harvest.failed':
342
377
  return `harvest run ${event.payload.run} failed at ${event.payload.step} (attempt ${event.payload.attempt}): ${singleLine(event.payload.error)}`
378
+ case 'dispatcher.ticket-source-degraded':
379
+ return `ticket source ${event.payload.source} degraded: ${event.payload.cause} (${event.payload.operation}) — ${singleLine(event.payload.message)}${'resetAt' in event.payload ? `; reset ${event.payload.resetAt}` : ''}`
380
+ case 'dispatcher.ticket-source-recovered':
381
+ return `ticket source ${event.payload.source} recovered: ${event.payload.cause} (${event.payload.operation})`
343
382
  case 'dispatcher.tick-failed':
344
383
  return `dispatcher tick failed: ${singleLine(event.payload.error)}`
345
384
  case 'dispatcher.config-rejected':
@@ -451,6 +490,7 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
451
490
  const sleep = opts.delay ?? defaultDelay
452
491
  const slugs = [...(opts.slugs ?? [])]
453
492
  const repository = opts.repository === true
493
+ const followRepository = repository || slugs.length === 0
454
494
 
455
495
  const timeoutMs =
456
496
  opts.timeout !== undefined
@@ -462,10 +502,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
462
502
  : undefined
463
503
 
464
504
  // Ambient scope, before any store access.
465
- assertReadScope(slugs, { repository, env: opts.env })
505
+ assertReadScope(slugs, { repository: followRepository, env: opts.env })
466
506
 
467
507
  // Glob validation needs only the catalogs — before any store access.
468
- const filters = compileEventGlobs(opts.events ?? [], { repository, usage: WATCH_USAGE })
508
+ const filters = compileEventGlobs(opts.events ?? [], {
509
+ repository: followRepository,
510
+ additionalCatalog: followRepository ? ['dispatcher.lost'] : [],
511
+ usage: WATCH_USAGE,
512
+ })
469
513
 
470
514
  await withAmbientReadStore(
471
515
  {
@@ -497,6 +541,13 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
497
541
  record.repo === identity ||
498
542
  (record.repoOrigin !== undefined && normalizeGitRemoteUrl(record.repoOrigin) === identity)
499
543
 
544
+ const lostRuns = new Set(
545
+ opts.since ? (decodeCursor(opts.since, { store: storeRef, repo }).lostRuns ?? []) : [],
546
+ )
547
+ let livenessEvents: RepositoryEvent[] = []
548
+ let livenessSeq = 0
549
+ let livenessInitialized = false
550
+ const observedOwners = new Map<string, RepositoryRecord>()
500
551
  const streams = new Map<string, Stream>()
501
552
  // Every stream's position — matching or not, delivered or baselined —
502
553
  // lives here, so a resume never rescans or redelivers a filtered event.
@@ -514,7 +565,13 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
514
565
  }
515
566
 
516
567
  const encodeCurrent = (): string =>
517
- encodeCursor({ v: 1, store: storeRef, repo, streams: { ...positions } })
568
+ encodeCursor({
569
+ v: 1,
570
+ store: storeRef,
571
+ repo,
572
+ streams: { ...positions },
573
+ lostRuns: [...lostRuns],
574
+ })
518
575
 
519
576
  /** One record, flushed the moment it is projected. `cursor` is the watch
520
577
  * position AFTER this event. */
@@ -534,6 +591,73 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
534
591
  }
535
592
  }
536
593
 
594
+ const checkLiveness = async (): Promise<void> => {
595
+ if (!followRepository || stop) return
596
+ if (!livenessInitialized) {
597
+ const input = await readDispatcherLiveness(store, repo, now())
598
+ livenessEvents = input.events
599
+ livenessSeq = input.events.at(-1)?.seq ?? 0
600
+ livenessInitialized = true
601
+ }
602
+ if ((await store.getRepo(repo)) === null) return
603
+ const fresh = await store.getRepoEvents(repo, livenessSeq)
604
+ livenessEvents.push(...fresh)
605
+ livenessSeq = fresh.at(-1)?.seq ?? livenessSeq
606
+ // Sample metadata after the journal. Recheck both immediately before
607
+ // reporting: a failed read is infrastructure, never evidence of death.
608
+ const record = await store.getRepo(repo)
609
+ if (record?.lease) observedOwners.set(record.lease.holder, record)
610
+ for (const [id, owner] of observedOwners) {
611
+ if (
612
+ stop ||
613
+ lostRuns.has(id) ||
614
+ !owner.lease ||
615
+ Date.parse(owner.lease.expiresAt) > now().getTime()
616
+ )
617
+ continue
618
+ const delta = await store.getRepoEvents(repo, livenessSeq)
619
+ livenessEvents.push(...delta)
620
+ livenessSeq = delta.at(-1)?.seq ?? livenessSeq
621
+ const current = await store.getRepo(repo)
622
+ if (current?.lease?.holder === id) {
623
+ observedOwners.set(id, current)
624
+ if (Date.parse(current.lease.expiresAt) > now().getTime()) continue
625
+ }
626
+ const evidence = projectDispatcherLiveness(
627
+ current?.lease?.holder === id ? current : owner,
628
+ livenessEvents,
629
+ now(),
630
+ )
631
+ if (
632
+ livenessEvents.some((e) => e.type === 'dispatcher.run-stopped' && e.payload.run === id)
633
+ ) {
634
+ observedOwners.delete(id)
635
+ continue
636
+ }
637
+ if (!(filters.length ? filters.some((f) => f.test('dispatcher.lost')) : true)) continue
638
+ if (stop) return
639
+ lostRuns.add(id)
640
+ const cursor = encodeCurrent()
641
+ const observation = {
642
+ ...evidence.expiredHolder,
643
+ type: 'dispatcher.lost',
644
+ run: id,
645
+ observedAt: now().toISOString(),
646
+ leaseExpiresAt: (current?.lease?.holder === id ? current : owner).lease!.expiresAt,
647
+ }
648
+ opts.stdout(
649
+ opts.json
650
+ ? JSON.stringify({ repo, build: null, state: null, observation, cursor })
651
+ : `dispatcher.lost ${id} — lease expired ${observation.leaseExpiresAt}`,
652
+ )
653
+ emitted += 1
654
+ if (opts.count !== undefined && emitted >= opts.count) {
655
+ countCursor = cursor
656
+ requestStop()
657
+ }
658
+ }
659
+ }
660
+
537
661
  /** Record one event. `prefix` is the event prefix ENDING at `event` —
538
662
  * the record's state is the reduction after THIS event, never a
539
663
  * reduction over events that landed after it. The poll path passes the
@@ -678,6 +802,11 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
678
802
  const tail = await store.getRepoEvents(repo, last)
679
803
  last = tail.at(-1)?.seq ?? last
680
804
  }
805
+ const input = await readDispatcherLiveness(store, repo, now(), subset)
806
+ livenessEvents = input.events
807
+ livenessSeq = Math.max(last, input.events.at(-1)?.seq ?? 0)
808
+ livenessInitialized = true
809
+ if (input.record?.lease) observedOwners.set(input.record.lease.holder, input.record)
681
810
  stream.lastSeq = last
682
811
  positions[REPO_STREAM_KEY] = last
683
812
  }
@@ -752,7 +881,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
752
881
  tickStreak.onFailure(error)
753
882
  }
754
883
  }
755
- if (allReadsOk) tickStreak.onSuccess()
884
+ if (allReadsOk) {
885
+ try {
886
+ await checkLiveness()
887
+ tickStreak.onSuccess()
888
+ } catch (error) {
889
+ tickStreak.onFailure(error)
890
+ }
891
+ }
756
892
  }
757
893
 
758
894
  // ── Initial scan ──
@@ -778,13 +914,14 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
778
914
  await trackBuild(key, sinceStreams[key])
779
915
  if (stop) break
780
916
  }
781
- if (repository) {
917
+ if (followRepository) {
782
918
  if (!(await trackRepository())) {
783
919
  makeFailureStreak('watch', opts.stderr).onFailure(
784
920
  new Error(`could not read the repository journal for "${repo}"`),
785
921
  )
786
922
  }
787
- } else if (slugs.length === 0) {
923
+ }
924
+ if (!repository && slugs.length === 0) {
788
925
  // Membership discovery also baselines the default watch; a failed
789
926
  // discovery here is fatal — nothing has started yet.
790
927
  try {
@@ -795,6 +932,13 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
795
932
  }
796
933
  }
797
934
 
935
+ if (followRepository && !stop) {
936
+ try {
937
+ await checkLiveness()
938
+ } catch (error) {
939
+ tickStreak.onFailure(error)
940
+ }
941
+ }
798
942
  // ── The poll loop ──
799
943
  const deadline = timeoutMs === 0 ? Number.POSITIVE_INFINITY : now().getTime() + timeoutMs
800
944
  const aborted = (): boolean => opts.signal?.aborted === true
@@ -875,10 +1019,11 @@ export async function abWatch(opts: AbWatchOpts): Promise<void> {
875
1019
 
876
1020
  for (const stream of streams.values()) if (!shouldStop) launch(stream)
877
1021
 
878
- if (slugs.length === 0 && !shouldStop) {
1022
+ if ((slugs.length === 0 || followRepository) && !shouldStop) {
879
1023
  void remoteRunner.runDiscovery({
880
1024
  step: async (): Promise<void> => {
881
- await discoverBuilds()
1025
+ if (slugs.length === 0) await discoverBuilds()
1026
+ await checkLiveness()
882
1027
  },
883
1028
  // Unconditional, after the catch — exactly where the inline
884
1029
  // launch loop ran: streams registered before a mid-discovery
@@ -14,6 +14,7 @@ export const DISPATCHER_CONFIG_ARTIFACT = 'dispatcher-config'
14
14
  export const CONFIG_RELOAD_CLASSIFICATION = {
15
15
  baseBranch: 'hot',
16
16
  capacity: 'hot',
17
+ ticketScanIntervalSeconds: 'hot',
17
18
  forge: 'restart',
18
19
  plugins: 'restart',
19
20
  pr: 'hot',
@@ -644,7 +644,7 @@ const ORCHESTRATOR_APPROVAL_ENTRY = /^[a-z][a-z0-9_.]*(:[a-z0-9_-]+)?$/
644
644
  /** The attention set a new session's wake settings inherit when the
645
645
  * repository does not override `wake`: the build events an operator or agent
646
646
  * must wake up for (the same set `ab watch` filters on by default) plus the
647
- * repository-journal attention events (the same set `ab watch --repository`
647
+ * repository-journal attention events (the same set `ab watch`
648
648
  * follows), spelled as literal globs. Both halves of the attention set can
649
649
  * wake a session. */
650
650
  export const defaultOrchestratorWakeGlobs: readonly string[] = [
@@ -730,6 +730,7 @@ const configRootSchema = z.strictObject({
730
730
  baseBranch: z.string().min(1).default('main'),
731
731
  /** Concurrent builds for this repository (§16.1). */
732
732
  capacity: z.number().int().positive().default(1),
733
+ ticketScanIntervalSeconds: z.number().int().positive().default(60),
733
734
  /** Forge adapter selected from builtins and configured plugins. */
734
735
  forge: z
735
736
  .string()
@@ -769,7 +770,13 @@ const configRootSchema = z.strictObject({
769
770
  })
770
771
 
771
772
  /** Root metadata keeps strict error prose and documentation coverage honest. */
772
- export const TOP_LEVEL_SCALARS = ['baseBranch', 'capacity', 'forge', 'plugins'] as const
773
+ export const TOP_LEVEL_SCALARS = [
774
+ 'baseBranch',
775
+ 'capacity',
776
+ 'ticketScanIntervalSeconds',
777
+ 'forge',
778
+ 'plugins',
779
+ ] as const
773
780
  export const TOP_LEVEL_TABLES = Object.keys(configRootSchema.shape).filter(
774
781
  (key) => !(TOP_LEVEL_SCALARS as readonly string[]).includes(key),
775
782
  )
@@ -10,8 +10,9 @@ import { EVENT_TYPES } from './payloads'
10
10
  import { REPOSITORY_EVENT_TYPES } from './repository'
11
11
 
12
12
  export interface CompileEventGlobsOptions {
13
- /** Also accept matches in the repository-journal catalog (the `--repository`
14
- * behavior of `ab watch`/`ab wait`). */
13
+ /** Also accept matches in the repository-journal catalog (the no-slug or
14
+ * explicit `--repository` behavior of `ab watch`; wait remains build-only). */
15
+ additionalCatalog?: readonly string[]
15
16
  repository?: boolean
16
17
  usage: string
17
18
  }
@@ -35,7 +36,11 @@ export function compileEventGlobs(
35
36
  const inBuildCatalog = EVENT_TYPES.some((type) => regex.test(type))
36
37
  const inRepositoryCatalog =
37
38
  opts.repository === true && REPOSITORY_EVENT_TYPES.some((type) => regex.test(type))
38
- if (!inBuildCatalog && !inRepositoryCatalog) {
39
+ if (
40
+ !inBuildCatalog &&
41
+ !inRepositoryCatalog &&
42
+ !opts.additionalCatalog?.some((type) => regex.test(type))
43
+ ) {
39
44
  throw new Error(
40
45
  `--event "${glob}" matches no known event type${
41
46
  opts.repository ? ' in the build or repository catalogs' : ' in the build event catalog'
@@ -64,6 +64,7 @@ const tickCountersSchema = z.strictObject({
64
64
  /** Added in plugin/API-era 1.4; optional so historical tick facts replay. */
65
65
  creationWithheld: z.number().int().nonnegative().optional(),
66
66
  dependencyBlocked: z.number().int().nonnegative(),
67
+ dependencyCheckFailed: z.number().int().nonnegative().optional(),
67
68
  /** Foreign executions settled from durable facts plus provider liveness.
68
69
  * Added with durable supervision; optional so historical tick facts replay. */
69
70
  settled: z.number().int().nonnegative().optional(),
@@ -353,14 +354,30 @@ export const orchestratorSandboxEventPayloadSchemas = {
353
354
  } as const
354
355
 
355
356
  export const dispatcherStatusEventPayloadSchemas = {
357
+ /** Completed ticket-source measurement, independent of dispatcher runs. */
358
+ 'dispatcher.ticket-scan-completed': z.strictObject({
359
+ startedAt: z.iso.datetime(),
360
+ readinessFingerprint: z.string().min(1),
361
+ criteriaFingerprint: z.string().min(1),
362
+ mode: z.enum(['admission', 'observation']),
363
+ queued: z.number().int().nonnegative(),
364
+ invalidTickets: z.number().int().nonnegative(),
365
+ ticketDiagnostics: boundedDiagnostics,
366
+ dependencyBlocked: z.number().int().nonnegative(),
367
+ dependencyDiagnostics: boundedDiagnostics,
368
+ creationWithheld: z.number().int().nonnegative(),
369
+ creationDiagnostics: boundedDiagnostics,
370
+ }),
356
371
  /** A terminal frontend follows exactly one run. The referenced artifact is
357
372
  * the composed Config the kernel is actually using, never an on-disk guess. */
358
373
  'dispatcher.run-started': z.strictObject({
359
374
  run: dispatchRun,
360
375
  pid: z.number().int().positive(),
376
+ host: z.string().trim().min(1).optional(),
361
377
  effectiveConfig: artifactRefSchema,
362
378
  roleWarnings: boundedDiagnostics,
363
379
  }),
380
+ 'dispatcher.stop-requested': z.strictObject({ run: dispatchRun, signal: z.string().min(1) }),
364
381
  'dispatcher.run-stopped': z.strictObject({
365
382
  run: dispatchRun,
366
383
  outcome: z.enum(['normal', 'abnormal', 'forced']),
@@ -449,7 +466,32 @@ export const dispatcherSettingEventPayloadSchemas = {
449
466
  'dispatcher.auto-merge-default-set': dispatcherSetting,
450
467
  } as const
451
468
 
469
+ const ticketSourceCondition = {
470
+ source: z.string().min(1),
471
+ operation: z.enum(['ready-scan', 'dependency-check']),
472
+ message: z.string().min(1),
473
+ }
474
+ export const ticketSourceHealthEventPayloadSchemas = {
475
+ /** An existing listing-limit episode also encountered a limited dependency
476
+ * read. Strengthens recovery evidence without closing or reopening it. */
477
+ 'dispatcher.ticket-source-dependency-limited': z.strictObject({ source: z.string().min(1) }),
478
+ 'dispatcher.ticket-source-degraded': z.discriminatedUnion('cause', [
479
+ z.strictObject({
480
+ ...ticketSourceCondition,
481
+ cause: z.literal('rate-limited'),
482
+ resetAt: z.iso.datetime(),
483
+ }),
484
+ z.strictObject({ ...ticketSourceCondition, cause: z.literal('ready-scan-failed') }),
485
+ ]),
486
+ 'dispatcher.ticket-source-recovered': z.strictObject({
487
+ source: z.string().min(1),
488
+ cause: z.enum(['rate-limited', 'ready-scan-failed']),
489
+ operation: z.enum(['ready-scan', 'dependency-check']),
490
+ }),
491
+ } as const
492
+
452
493
  export const repositoryEventPayloadSchemas = {
494
+ ...ticketSourceHealthEventPayloadSchemas,
453
495
  ...harvestEventPayloadSchemas,
454
496
  ...orchestratorSandboxEventPayloadSchemas,
455
497
  ...dispatcherStatusEventPayloadSchemas,
@@ -461,11 +503,13 @@ export const REPOSITORY_EVENT_TYPES = Object.keys(
461
503
  repositoryEventPayloadSchemas,
462
504
  ) as RepositoryEventType[]
463
505
 
464
- /** The repository-journal attention set, followed only under `--repository`
465
- * by `ab watch` and scanned by the orchestrator wake pass. */
506
+ /** Repository attention followed by `ab watch` with no slugs or explicit
507
+ * `--repository`, and scanned by the orchestrator wake pass. */
466
508
  export const REPOSITORY_ATTENTION_EVENTS = [
467
509
  'harvest.escalated',
468
510
  'harvest.failed',
511
+ 'dispatcher.ticket-source-degraded',
512
+ 'dispatcher.ticket-source-recovered',
469
513
  'dispatcher.tick-failed',
470
514
  'dispatcher.config-rejected',
471
515
  'dispatcher.harvest-runner-failed',
@@ -539,12 +583,17 @@ const allowedActorKinds: Record<RepositoryEventType, readonly ActorKind[]> = {
539
583
  'orchestrator.sandbox.published': ['human'],
540
584
  'orchestrator.sandbox.publish-failed': ['human'],
541
585
  'dispatcher.run-started': ['dispatcher'],
586
+ 'dispatcher.stop-requested': ['dispatcher'],
542
587
  'dispatcher.run-stopped': ['dispatcher'],
543
588
  'dispatcher.tick-yielded': ['dispatcher'],
544
589
  'dispatcher.config-rejected': ['dispatcher'],
545
590
  'dispatcher.config-publication-failed': ['dispatcher'],
546
591
  'dispatcher.tick-started': ['dispatcher'],
547
592
  'dispatcher.tick-completed': ['dispatcher'],
593
+ 'dispatcher.ticket-scan-completed': ['dispatcher'],
594
+ 'dispatcher.ticket-source-dependency-limited': ['dispatcher'],
595
+ 'dispatcher.ticket-source-degraded': ['dispatcher'],
596
+ 'dispatcher.ticket-source-recovered': ['dispatcher'],
548
597
  'dispatcher.tick-failed': ['dispatcher'],
549
598
  'dispatcher.runner-settled': ['dispatcher'],
550
599
  'dispatcher.harvest-runner-failed': ['dispatcher'],
@@ -124,6 +124,7 @@ export function readyTicket(id: string, over: Partial<Omit<Ticket, 'ref'>> = {})
124
124
  title,
125
125
  body: over.body ?? CONFORMING_BODY,
126
126
  state: over.state ?? 'Ready',
127
+ resolved: false,
127
128
  labels: over.labels ?? ['autobuild'],
128
129
  }
129
130
  }
@@ -369,7 +370,7 @@ export async function assertSnapshotEquivalence(store: BuildStore, repo: string)
369
370
  dashboard: await attempt(() =>
370
371
  getOperatorDashboard({ store: target, repo, clock: () => at }),
371
372
  ),
372
- status: await attempt(() => getRepositoryStatus(target, repo)),
373
+ status: await attempt(() => getRepositoryStatus(target, repo, at)),
373
374
  harvest: await attempt(() => getHarvestStatus(target, repo)),
374
375
  lists: await Promise.all(
375
376
  (['active', 'queued', 'all'] as const).map((scope) =>
@@ -236,6 +236,7 @@ exports[`reducer registry accumulator shapes 1`] = `
236
236
  "$.lastEvent.payload.report.rev: number",
237
237
  "$.lastEvent.payload.report: object",
238
238
  "$.lastEvent.payload.request: number",
239
+ "$.lastEvent.payload.resetAt: string",
239
240
  "$.lastEvent.payload.retryable: boolean",
240
241
  "$.lastEvent.payload.role: string",
241
242
  "$.lastEvent.payload.roleWarnings: array",
@@ -414,6 +415,7 @@ exports[`reducer registry accumulator shapes 1`] = `
414
415
  "$.observations[].ts: string",
415
416
  "$.observations[]: object",
416
417
  "$.openInstance: null",
418
+ "$.readinessChangeSeq: number",
417
419
  "$.reclaimed: array",
418
420
  "$.terminal: string",
419
421
  "$: object",
@@ -436,6 +438,7 @@ exports[`reducer registry accumulator shapes 1`] = `
436
438
  "$.entries[].event: object",
437
439
  "$.entries[].headSha: string",
438
440
  "$.entries[].seq: number",
441
+ "$.entries[].seq: undefined",
439
442
  "$.entries[].t: string",
440
443
  "$.entries[]: object",
441
444
  "$: object",
@@ -611,6 +614,7 @@ exports[`reducer registry accumulator shapes 1`] = `
611
614
  "$.run: string",
612
615
  "$.warningNotice: string",
613
616
  "$.warningNotice: undefined",
617
+ "$.yieldedHolder: undefined",
614
618
  "$: object",
615
619
  ],
616
620
  "frozenPrImageHost": [
@@ -837,6 +841,7 @@ exports[`reducer registry accumulator shapes 1`] = `
837
841
  "$.retained[].actor.user: string",
838
842
  "$.retained[].actor: object",
839
843
  "$.retained[].payload.attempt: number",
844
+ "$.retained[].payload.cause: string",
840
845
  "$.retained[].payload.counters.claimed: number",
841
846
  "$.retained[].payload.counters.closed: number",
842
847
  "$.retained[].payload.counters.conflicted: number",
@@ -872,6 +877,7 @@ exports[`reducer registry accumulator shapes 1`] = `
872
877
  "$.retained[].payload.observations[].build: string",
873
878
  "$.retained[].payload.observations[].seq: number",
874
879
  "$.retained[].payload.observations[]: object",
880
+ "$.retained[].payload.operation: string",
875
881
  "$.retained[].payload.operator: string",
876
882
  "$.retained[].payload.outcome: string",
877
883
  "$.retained[].payload.pid: number",
@@ -881,6 +887,7 @@ exports[`reducer registry accumulator shapes 1`] = `
881
887
  "$.retained[].payload.report.kind: string",
882
888
  "$.retained[].payload.report.rev: number",
883
889
  "$.retained[].payload.report: object",
890
+ "$.retained[].payload.resetAt: string",
884
891
  "$.retained[].payload.roleWarnings: array",
885
892
  "$.retained[].payload.roleWarnings[]: string",
886
893
  "$.retained[].payload.run: string",
@@ -1103,6 +1110,7 @@ exports[`reducer registry accumulator shapes 1`] = `
1103
1110
  "$.ledger[].request: number",
1104
1111
  "$.ledger[].round: number",
1105
1112
  "$.ledger[].seq: number",
1113
+ "$.ledger[].seq: undefined",
1106
1114
  "$.ledger[].step: string",
1107
1115
  "$.ledger[].t: string",
1108
1116
  "$.ledger[]: object",
@@ -1245,6 +1253,7 @@ exports[`reducer registry accumulator shapes 1`] = `
1245
1253
  "$.retained[].payload.report.rev: number",
1246
1254
  "$.retained[].payload.report: object",
1247
1255
  "$.retained[].payload.request: number",
1256
+ "$.retained[].payload.resetAt: string",
1248
1257
  "$.retained[].payload.resolution: string",
1249
1258
  "$.retained[].payload.retryable: boolean",
1250
1259
  "$.retained[].payload.role: string",
@@ -1359,6 +1368,15 @@ exports[`reducer registry accumulator shapes 1`] = `
1359
1368
  "$.failures[]: number",
1360
1369
  "$: object",
1361
1370
  ],
1371
+ "ticketSourceHealth": [
1372
+ "$.active.cause: string",
1373
+ "$.active.message: string",
1374
+ "$.active.operation: string",
1375
+ "$.active.resetAt: string",
1376
+ "$.active.source: string",
1377
+ "$.active: object",
1378
+ "$: object",
1379
+ ],
1362
1380
  "verifyDiffBase": [
1363
1381
  "$.pendingReconcileBase: string",
1364
1382
  "$.promoted: string",
@@ -12,6 +12,7 @@ export interface DispatchStatus {
12
12
  queued?: number
13
13
  /** Standing count from newer tick facts; absent for historical events. */
14
14
  creationWithheld?: number
15
+ yieldedHolder?: string
15
16
  availableUpgrade?: string
16
17
  roleWarnings: string[]
17
18
  diagnostics: string[]
@@ -58,6 +59,7 @@ export function reduceDispatchStatus(
58
59
  state.lastSeq = event.seq
59
60
  switch (event.type) {
60
61
  case 'dispatcher.run-started':
62
+ state.yieldedHolder = undefined
61
63
  state.health = 'running'
62
64
  state.effectiveConfig = event.payload.effectiveConfig
63
65
  state.roleWarnings = [...event.payload.roleWarnings]
@@ -88,6 +90,12 @@ export function reduceDispatchStatus(
88
90
  )
89
91
  state.warningNotice = state.notice
90
92
  break
93
+ case 'dispatcher.tick-yielded':
94
+ state.yieldedHolder = event.payload.holder
95
+ break
96
+ case 'dispatcher.tick-started':
97
+ state.yieldedHolder = undefined
98
+ break
91
99
  case 'dispatcher.tick-completed':
92
100
  state.queued = event.payload.queued
93
101
  state.creationWithheld = event.payload.counters.creationWithheld
@@ -143,7 +151,7 @@ export function reduceDispatchStatus(
143
151
 
144
152
  /** Bump when `DispatchStatus`'s shape or fold semantics change (it is its own
145
153
  * accumulator: `lastSeq` makes the fold skip already-seen events). */
146
- export const DISPATCH_STATUS_REDUCER_VERSION = 1
154
+ export const DISPATCH_STATUS_REDUCER_VERSION = 2
147
155
 
148
156
  /** The incremental form of `reduceDispatchStatus` for one run. The run is a
149
157
  * parameter, so this is a factory. */