@defrex/autobuild 0.10.0 → 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 (47) hide show
  1. package/README.md +97 -113
  2. package/SPEC.md +22 -5
  3. package/bin/ab-dispatch-kernel.ts +1 -1
  4. package/docs/configuration.md +34 -7
  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/init.ts +3 -2
  17. package/packages/core/src/cli/repository-status.ts +45 -5
  18. package/packages/core/src/cli/watch.ts +159 -14
  19. package/packages/core/src/config/live.ts +1 -0
  20. package/packages/core/src/config/schema.ts +9 -2
  21. package/packages/core/src/events/globs.ts +8 -3
  22. package/packages/core/src/events/repository.ts +51 -2
  23. package/packages/core/src/integration/harness.ts +2 -1
  24. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +18 -0
  25. package/packages/core/src/kernel/dispatch-status.ts +9 -1
  26. package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
  27. package/packages/core/src/kernel/reducer-registry.ts +4 -0
  28. package/packages/core/src/kernel/ticket-source-health.ts +30 -0
  29. package/packages/core/src/operator/query.ts +6 -3
  30. package/packages/core/src/orchestrator/prompt.ts +3 -1
  31. package/packages/core/src/ports/tickets/contract.ts +25 -0
  32. package/packages/core/src/ports/tickets/fake.ts +10 -8
  33. package/packages/core/src/ports/tickets/file.ts +1 -0
  34. package/packages/core/src/ports/tickets/linear.ts +241 -49
  35. package/packages/core/src/ports/tickets/remote-protocol.ts +2 -0
  36. package/packages/core/src/ports/types.ts +17 -0
  37. package/packages/core/src/processes/dispatcher.ts +277 -73
  38. package/packages/core/src/processes/repo-view.ts +2 -1
  39. package/packages/core/src/store/adapter.ts +1 -0
  40. package/packages/core/src/store/contract.ts +109 -0
  41. package/packages/core/src/store/digest.ts +14 -2
  42. package/packages/core/src/store/local/store.ts +18 -5
  43. package/packages/core/src/store/remote/protocol.ts +1 -0
  44. package/packages/core/src/store/repo-state-events.ts +49 -59
  45. package/packages/core/src/store/types.ts +5 -1
  46. package/skills/guide/SKILL.md +43 -14
  47. package/skills/operate/SKILL.md +180 -18
@@ -722,6 +722,21 @@ interface DetailGeometry extends DetailBodyLayout {
722
722
  limit: number
723
723
  }
724
724
 
725
+ function standbyLine(model: DashboardModel, color: boolean, width: number): string[] {
726
+ return model.standby === undefined
727
+ ? []
728
+ : [
729
+ truncate(
730
+ paint(
731
+ ` Dispatcher on standby: repository held by ${displayText(model.standby)}`,
732
+ 'yellow',
733
+ color,
734
+ ),
735
+ width,
736
+ ),
737
+ ]
738
+ }
739
+
725
740
  function upgradeNoticeLine(
726
741
  model: DashboardModel,
727
742
  color: boolean,
@@ -749,7 +764,8 @@ function detailGeometry(
749
764
  const build = model.builds.find((candidate) => candidate.slug === view.slug)
750
765
  if (build === undefined) return undefined
751
766
  const layout = detailBody(build, view, { color: false, width, height, now: 0 })
752
- const topLength = 2 + (model.availableUpgrade === undefined ? 0 : 1)
767
+ const topLength =
768
+ 2 + (model.standby === undefined ? 0 : 1) + (model.availableUpgrade === undefined ? 0 : 1)
753
769
  const controls =
754
770
  model.resumeInput !== undefined || model.abortConfirmation !== undefined
755
771
  ? dashboardControls(model, false, width, controlsCapacity(height, topLength))
@@ -808,6 +824,7 @@ function renderDetail(model: DashboardModel, opts: RenderOpts): string[] {
808
824
  ` status ${paint(build.status.toUpperCase(), STATUS_COLOR[build.status], color)}${paused}`,
809
825
  width,
810
826
  ),
827
+ ...standbyLine(model, color, width),
811
828
  ...(notice === undefined ? [] : [notice]),
812
829
  ]
813
830
  const { body } = detailBody(build, view, opts)
@@ -862,11 +879,12 @@ export function transcriptScrollLimit(
862
879
  terminalWidth: number,
863
880
  height: number,
864
881
  hasUpgradeNotice = false,
882
+ hasStandby = false,
865
883
  ): number {
866
884
  const contentLines = transcriptContent(presentation, dashboardContentWidth(terminalWidth)).length
867
- // Transcript chrome is two header rows, the optional upgrade row, two
885
+ // Transcript chrome is two header rows, optional standby/upgrade rows, two
868
886
  // separators, and controls. Keep this in lockstep with renderTranscript.
869
- const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0))
887
+ const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0) - (hasStandby ? 1 : 0))
870
888
  return Math.max(0, contentLines - capacity)
871
889
  }
872
890
 
@@ -877,8 +895,15 @@ export function moveTranscriptScroll(
877
895
  current: number,
878
896
  delta: number,
879
897
  hasUpgradeNotice = false,
898
+ hasStandby = false,
880
899
  ): number {
881
- const limit = transcriptScrollLimit(presentation, terminalWidth, height, hasUpgradeNotice)
900
+ const limit = transcriptScrollLimit(
901
+ presentation,
902
+ terminalWidth,
903
+ height,
904
+ hasUpgradeNotice,
905
+ hasStandby,
906
+ )
882
907
  // Clamp current first because a resize, or state from an older controller,
883
908
  // may leave it beyond the freshly wrapped viewport's end.
884
909
  return Math.max(0, Math.min(limit, Math.min(current, limit) + delta))
@@ -909,6 +934,7 @@ function renderTranscript(model: DashboardModel, opts: RenderOpts): string[] {
909
934
  const top = [
910
935
  truncate(`${paint('Transcript', 'bold', color)} ${displayText(view.slug)}`, width),
911
936
  truncate(` session ${displayText(view.sessionId)}`, width),
937
+ ...standbyLine(model, color, width),
912
938
  ...(notice === undefined ? [] : [notice]),
913
939
  ]
914
940
  const content = transcriptContent(view.transcript, width).map((line) => truncate(line, width))
@@ -948,11 +974,12 @@ export function sessionScrollLimit(
948
974
  terminalWidth: number,
949
975
  height: number,
950
976
  hasUpgradeNotice = false,
977
+ hasStandby = false,
951
978
  ): number {
952
979
  const contentLines = sessionContentLines(view, dashboardContentWidth(terminalWidth)).length
953
- // Session chrome is two header rows, the optional upgrade row, two
980
+ // Session chrome is two header rows, optional standby/upgrade rows, two
954
981
  // separators, and controls — the transcript's exact arithmetic.
955
- const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0))
982
+ const capacity = Math.max(0, height - 5 - (hasUpgradeNotice ? 1 : 0) - (hasStandby ? 1 : 0))
956
983
  return Math.max(0, contentLines - capacity)
957
984
  }
958
985
 
@@ -963,8 +990,9 @@ export function moveSessionScroll(
963
990
  current: number,
964
991
  delta: number,
965
992
  hasUpgradeNotice = false,
993
+ hasStandby = false,
966
994
  ): number {
967
- const limit = sessionScrollLimit(view, terminalWidth, height, hasUpgradeNotice)
995
+ const limit = sessionScrollLimit(view, terminalWidth, height, hasUpgradeNotice, hasStandby)
968
996
  // Clamp current first: a resize, a feed apply, or state from an older
969
997
  // controller may leave it beyond the freshly wrapped viewport's end.
970
998
  return Math.max(0, Math.min(limit, Math.min(current, limit) + delta))
@@ -983,6 +1011,7 @@ function renderSessionView(model: DashboardModel, opts: RenderOpts): string[] {
983
1011
  }`,
984
1012
  width,
985
1013
  ),
1014
+ ...standbyLine(model, color, width),
986
1015
  ...(notice === undefined ? [] : [notice]),
987
1016
  ]
988
1017
  const content = sessionContentLines(view, width).map((line) => truncate(line, width))
@@ -1417,6 +1446,7 @@ function renderDashboardContent(model: DashboardModel, opts: RenderOpts): string
1417
1446
  const top = [
1418
1447
  summary,
1419
1448
  toggles,
1449
+ ...standbyLine(model, color, width),
1420
1450
  ...(upgradeNotice === undefined ? [] : [upgradeNotice]),
1421
1451
  ...warnings,
1422
1452
  ]
@@ -1,3 +1,4 @@
1
+ import { StandbyIdentityResolver } from './dispatcher-liveness'
1
2
  import { configSchema, type Config } from '../config/schema'
2
3
  import { humanActor } from '../events/envelope'
3
4
  import type { RepositoryEvent } from '../events/repository'
@@ -114,6 +115,7 @@ export interface DispatchFrontendOptions {
114
115
  /** Terminal controller with a deliberately narrow wiring surface: terminal,
115
116
  * BuildStore, immutable repository identity, clock, and child supervision. */
116
117
  export class DispatchFrontend {
118
+ private readonly standbyResolver: StandbyIdentityResolver
117
119
  private readonly runId = crypto.randomUUID()
118
120
  private readonly clock: Clock
119
121
  private readonly region: LiveRegion
@@ -153,6 +155,7 @@ export class DispatchFrontend {
153
155
  private operatorStopRequested = false
154
156
 
155
157
  constructor(private readonly opts: DispatchFrontendOptions) {
158
+ this.standbyResolver = new StandbyIdentityResolver(opts.store, opts.repo)
156
159
  this.clock = opts.clock ?? systemClock
157
160
  this.keyboard = createKeyboardProtocol(
158
161
  (chunk) => opts.terminal.write(chunk),
@@ -191,6 +194,7 @@ export class DispatchFrontend {
191
194
  this.opts.store.getRepoEvents(this.opts.repo, 0),
192
195
  )
193
196
  : await this.opts.store.getRepoEvents(this.opts.repo, 0)
197
+ this.standbyResolver.ingest(seed)
194
198
  this.repositorySeq = seed.at(-1)?.seq ?? 0
195
199
  this.dispatchStatus = reduceDispatchStatus(seed, this.runId)
196
200
  this.repositoryEvents.push(...seed.filter(isDashboardRepositoryEvent))
@@ -199,6 +203,7 @@ export class DispatchFrontend {
199
203
  }
200
204
  const delta = await this.opts.store.getRepoEvents(this.opts.repo, this.repositorySeq)
201
205
  if (delta.length === 0) return
206
+ this.standbyResolver.ingest(delta)
202
207
  this.repositorySeq = delta.at(-1)!.seq
203
208
  this.dispatchStatus = reduceDispatchStatus(delta, this.runId, this.dispatchStatus)
204
209
  this.repositoryEvents.push(...delta.filter(isDashboardRepositoryEvent))
@@ -367,10 +372,14 @@ export class DispatchFrontend {
367
372
  ...status.roleWarnings,
368
373
  ...(status.warningNotice !== undefined ? [status.warningNotice] : []),
369
374
  ]
375
+ const standby = this.opts.once
376
+ ? undefined
377
+ : await this.standbyResolver.refresh(this.runId, status.yieldedHolder, this.clock())
370
378
  const projected = buildDashboardFromProjected(
371
379
  snapshot.builds,
372
380
  {
373
381
  repo: this.opts.repo,
382
+ ...(standby !== undefined ? { standby } : {}),
374
383
  queued: status.queued ?? 0,
375
384
  activeCount: [...snapshot.states.values()].filter(
376
385
  (state) => state.status !== 'done' && state.status !== 'aborted',
@@ -409,6 +418,7 @@ export class DispatchFrontend {
409
418
  this.opts.terminal.columns,
410
419
  paintableRows(this.opts.terminal.rows),
411
420
  upgrade,
421
+ this.model?.standby !== undefined,
412
422
  )
413
423
  const current = view.follow ? limit : view.scroll
414
424
  const scroll = moveSessionScroll(
@@ -418,6 +428,7 @@ export class DispatchFrontend {
418
428
  current,
419
429
  delta,
420
430
  upgrade,
431
+ this.model?.standby !== undefined,
421
432
  )
422
433
  let follow = view.follow
423
434
  if (delta < 0 && follow && scroll < limit) follow = false
@@ -437,6 +448,7 @@ export class DispatchFrontend {
437
448
  this.view.scroll,
438
449
  delta,
439
450
  this.model?.availableUpgrade !== undefined,
451
+ this.model?.standby !== undefined,
440
452
  ),
441
453
  }
442
454
  } else if (this.view?.kind === 'detail' && this.model !== undefined) {
@@ -598,6 +610,7 @@ export class DispatchFrontend {
598
610
  terminal.columns,
599
611
  paintableRows(terminal.rows),
600
612
  this.model?.availableUpgrade !== undefined,
613
+ this.model?.standby !== undefined,
601
614
  )
602
615
  this.view = {
603
616
  ...next,
@@ -977,7 +990,9 @@ export class DispatchFrontend {
977
990
  private requestOperatorStop(): void {
978
991
  this.operatorStopRequested = true
979
992
  this.restorePresentationForStop()
980
- void this.child?.stop()
993
+ void this.child?.stop(
994
+ typeof this.opts.signal?.reason === 'string' ? this.opts.signal.reason : undefined,
995
+ )
981
996
  }
982
997
 
983
998
  /** Stop terminal ownership synchronously before awaiting a kernel that may
@@ -25,7 +25,7 @@ export interface DispatchChildResult {
25
25
 
26
26
  export interface DispatchChildHandle {
27
27
  completed: Promise<DispatchChildResult>
28
- stop(): Promise<void>
28
+ stop(signal?: string): Promise<void>
29
29
  }
30
30
 
31
31
  export interface DispatchSubprocess {
@@ -57,16 +57,20 @@ export interface DispatchKernelSignalBoundary {
57
57
  * one-shot handlers would expose the child to the default disposition on a
58
58
  * repeated Ctrl-C while it is still crossing an unsafe claim boundary. */
59
59
  export function installDispatchKernelSignalHandlers(
60
- onStop: () => void,
60
+ onStop: (signal: DispatchKernelSignal) => void,
61
61
  boundary: DispatchKernelSignalBoundary = process,
62
62
  ): DispatchProcessHook {
63
- for (const signal of DISPATCH_KERNEL_SIGNALS) boundary.on(signal, onStop)
63
+ const listeners = DISPATCH_KERNEL_SIGNALS.map((signal) => ({
64
+ signal,
65
+ listener: () => onStop(signal),
66
+ }))
67
+ for (const { signal, listener } of listeners) boundary.on(signal, listener)
64
68
  let closed = false
65
69
  return {
66
70
  close(): void {
67
71
  if (closed) return
68
72
  closed = true
69
- for (const signal of DISPATCH_KERNEL_SIGNALS) boundary.removeListener(signal, onStop)
73
+ for (const { signal, listener } of listeners) boundary.removeListener(signal, listener)
70
74
  },
71
75
  }
72
76
  }
@@ -156,13 +160,14 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
156
160
  env[DISPATCH_CHILD_OPTIONS_ENV] = JSON.stringify(deps.options)
157
161
  const child = (deps.spawn ?? defaultSpawn)({ entrypoint, cwd: deps.cwd ?? deps.repo, env })
158
162
 
163
+ let requestedSignal: string | undefined
159
164
  let stopping = false
160
165
  let forced = false
161
166
  let stopPromise: Promise<void> | undefined
162
167
  // This is the sole supervisor writer of a missing stop fact. `stop()` only
163
168
  // chooses/awaits process termination, avoiding check-then-append races.
164
169
  const completed = child.exited.then(async (exitCode): Promise<DispatchChildResult> => {
165
- const processSignal = child.signalCode === null ? undefined : String(child.signalCode)
170
+ const processSignal = child.signalCode === null ? requestedSignal : String(child.signalCode)
166
171
  const events = await deps.store.getRepoEvents(deps.repo)
167
172
  const recorded = events.findLast(
168
173
  (event) => event.type === 'dispatcher.run-stopped' && event.payload.run === deps.run,
@@ -179,7 +184,7 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
179
184
 
180
185
  const outcome: DispatchChildOutcome = forced
181
186
  ? 'forced'
182
- : stopping && exitCode === 0 && processSignal === undefined
187
+ : stopping && exitCode === 0 && child.signalCode === null
183
188
  ? 'normal'
184
189
  : 'abnormal'
185
190
  const error =
@@ -209,11 +214,24 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
209
214
 
210
215
  return {
211
216
  completed,
212
- stop(): Promise<void> {
217
+ stop(signal?: string): Promise<void> {
213
218
  if (stopPromise !== undefined) return stopPromise
214
219
  stopPromise = (async () => {
215
220
  if (child.exitCode !== null) return
216
221
  stopping = true
222
+ requestedSignal = signal
223
+ if (signal !== undefined) {
224
+ try {
225
+ await deps.store.appendRepo(deps.repo, {
226
+ actor: DISPATCHER,
227
+ type: 'dispatcher.stop-requested',
228
+ payload: { run: deps.run, signal },
229
+ })
230
+ } catch {
231
+ // Signal delivery must survive a transient Store failure. The
232
+ // kernel can still record its own abort reason after draining.
233
+ }
234
+ }
217
235
  child.kill('SIGINT')
218
236
  const timeout = deps.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS
219
237
  const stopped = await Promise.race([
@@ -1,3 +1,5 @@
1
+ import { readDispatcherLiveness, StandbyIdentityResolver } from './dispatcher-liveness'
2
+ import { formatDispatcherIdentity } from '../kernel/dispatcher-liveness'
1
3
  /**
2
4
  * `ab dispatch` — the operator's entry into the outer loop (SPEC §3.3, §12).
3
5
  * Runs OUTSIDE build sessions like init/upgrade/ticket (§16.3): it takes a
@@ -691,6 +693,9 @@ class DispatchLoop {
691
693
  * discarded on Escape. All read semantics live in the shared feed module. */
692
694
  private sessionFeed: SessionStreamFeed | undefined
693
695
  private warningLine: string | undefined
696
+ /** Invocation-local ownership; independent of every notice slot. */
697
+ private standby: string | undefined
698
+ private standbyResolver: StandbyIdentityResolver | undefined
694
699
  /** Process-local, persistent release notice. It never shares the replaceable
695
700
  * warning slot and is re-applied to every store projection. */
696
701
  private availableUpgrade: string | undefined
@@ -1190,6 +1195,7 @@ class DispatchLoop {
1190
1195
  selection: _oldSelection,
1191
1196
  warningLines: _oldWarningLines,
1192
1197
  availableUpgrade: _oldAvailableUpgrade,
1198
+ standby: _oldStandby,
1193
1199
  resumeInput: _oldResumeInput,
1194
1200
  abortConfirmation: _oldAbortConfirmation,
1195
1201
  view: _oldView,
@@ -1204,6 +1210,7 @@ class DispatchLoop {
1204
1210
  ]
1205
1211
  let effective: DashboardModel = {
1206
1212
  ...base,
1213
+ ...(this.standby !== undefined ? { standby: this.standby } : {}),
1207
1214
  ...(this.availableUpgrade !== undefined ? { availableUpgrade: this.availableUpgrade } : {}),
1208
1215
  ...(warningLines.length > 0 ? { warningLines } : {}),
1209
1216
  ...(this.selection !== undefined ? { selection: this.selection } : {}),
@@ -1258,7 +1265,13 @@ class DispatchLoop {
1258
1265
  const limit =
1259
1266
  terminal === undefined
1260
1267
  ? 0
1261
- : sessionScrollLimit(view, terminal.columns, paintableRows(terminal.rows), upgrade)
1268
+ : sessionScrollLimit(
1269
+ view,
1270
+ terminal.columns,
1271
+ paintableRows(terminal.rows),
1272
+ upgrade,
1273
+ this.standby !== undefined,
1274
+ )
1262
1275
  const current = view.follow ? limit : view.scroll
1263
1276
  const scroll =
1264
1277
  terminal === undefined
@@ -1270,6 +1283,7 @@ class DispatchLoop {
1270
1283
  current,
1271
1284
  delta,
1272
1285
  upgrade,
1286
+ this.standby !== undefined,
1273
1287
  )
1274
1288
  let follow = view.follow
1275
1289
  if (delta < 0 && follow && scroll < limit) follow = false
@@ -1291,6 +1305,7 @@ class DispatchLoop {
1291
1305
  terminal.columns,
1292
1306
  paintableRows(terminal.rows),
1293
1307
  this.availableUpgrade !== undefined,
1308
+ this.standby !== undefined,
1294
1309
  )
1295
1310
  return { ...view, scroll: view.follow ? limit : Math.min(view.scroll, limit) }
1296
1311
  }
@@ -1350,6 +1365,7 @@ class DispatchLoop {
1350
1365
  this.view.scroll,
1351
1366
  delta,
1352
1367
  this.availableUpgrade !== undefined,
1368
+ this.standby !== undefined,
1353
1369
  ),
1354
1370
  }
1355
1371
  this.syncModelControls()
@@ -3389,6 +3405,18 @@ class DispatchLoop {
3389
3405
  const claimed = await this.wiring.store
3390
3406
  .claimRepoLease(this.repoIdentity, holder, REPO_LEASE_TTL_MS)
3391
3407
  .catch(() => false)
3408
+ if (this.dashboard && !this.opts.once) {
3409
+ this.standbyResolver ??= new StandbyIdentityResolver(this.wiring.store, this.repoIdentity)
3410
+ this.standby = claimed
3411
+ ? undefined
3412
+ : await this.standbyResolver.refresh(
3413
+ this.runId,
3414
+ this.yieldedTo ?? 'another invocation',
3415
+ this.wiring.clock(),
3416
+ )
3417
+ this.syncModelControls()
3418
+ this.paint()
3419
+ }
3392
3420
  if (!claimed) {
3393
3421
  if (this.repoLeaseHolder !== undefined) {
3394
3422
  // We held the lease and the store says it is gone: a peer took over.
@@ -3474,10 +3502,17 @@ class DispatchLoop {
3474
3502
  const holder = this.runId
3475
3503
  if (!(await this.ensureRepoLease(holder))) {
3476
3504
  await this.recordTickYielded()
3477
- const peer = (await this.wiring.store.getRepo(this.repoIdentity).catch(() => null))?.lease
3478
- ?.holder
3479
- const message = `tick yielded: repository held by ${peer ?? 'another invocation'}`
3480
- this.warn(message)
3505
+ if ((!this.dashboard && this.opts.kernelRunId === undefined) || this.opts.once) {
3506
+ const peer = (await this.wiring.store.getRepo(this.repoIdentity).catch(() => null))?.lease
3507
+ ?.holder
3508
+ const liveness = await readDispatcherLiveness(
3509
+ this.wiring.store,
3510
+ this.repoIdentity,
3511
+ this.wiring.clock(),
3512
+ ).catch(() => null)
3513
+ const message = `tick yielded: repository held by ${liveness?.dispatcher.holder ? formatDispatcherIdentity(liveness.dispatcher.holder) : (peer ?? 'another invocation')}`
3514
+ this.warn(message)
3515
+ }
3481
3516
  if (this.opts.once === true) return
3482
3517
  // Watch mode stays alive and retries the claim at every tick.
3483
3518
  }
@@ -3498,7 +3533,17 @@ class DispatchLoop {
3498
3533
  // lease and any provisioning state are released for the next
3499
3534
  // invocation.
3500
3535
  if (this.pastDeadline()) return
3501
- const initial = await this.dispatcherTick(true)
3536
+ const initial = await this.dispatcherTick(true).catch(async (error: unknown) => {
3537
+ await this.appendStatus({
3538
+ actor: DISPATCHER,
3539
+ type: 'dispatcher.tick-failed',
3540
+ payload: {
3541
+ run: this.runId,
3542
+ error: error instanceof Error ? error.message : String(error),
3543
+ },
3544
+ })
3545
+ throw error
3546
+ })
3502
3547
  const initialPrinted = this.printReport(initial, false)
3503
3548
  await this.drainInFlight()
3504
3549
  const settledReports = await this.publishSettlementReports()
@@ -3963,6 +4008,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3963
4008
  payload: {
3964
4009
  run: runId,
3965
4010
  pid: process.pid,
4011
+ host: hostname(),
3966
4012
  effectiveConfig: { kind: artifact.kind, rev: artifact.revision },
3967
4013
  roleWarnings: roleKeyWarnings(config),
3968
4014
  },
@@ -3973,6 +4019,17 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3973
4019
  const loop = new DispatchLoop(liveConfig, wiring, resolvedOpts, view)
3974
4020
  try {
3975
4021
  await loop.run()
4022
+ const stopRequest = (
4023
+ resolvedOpts.kernelRunId && resolvedOpts.signal?.aborted
4024
+ ? await wiring.store.getRepoEvents(state.repo)
4025
+ : []
4026
+ ).find((event) => event.type === 'dispatcher.stop-requested' && event.payload.run === runId)
4027
+ const stoppedSignal =
4028
+ stopRequest?.type === 'dispatcher.stop-requested'
4029
+ ? stopRequest.payload.signal
4030
+ : typeof resolvedOpts.signal?.reason === 'string'
4031
+ ? resolvedOpts.signal.reason
4032
+ : undefined
3976
4033
  await view.appendRepo(state.repo, {
3977
4034
  actor: DISPATCHER,
3978
4035
  type: 'dispatcher.run-stopped',
@@ -3980,6 +4037,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
3980
4037
  run: runId,
3981
4038
  outcome: 'normal',
3982
4039
  exitCode: 0,
4040
+ ...(stoppedSignal === undefined ? {} : { signal: stoppedSignal }),
3983
4041
  ...(loop.supersededByPeer ? { reason: 'superseded' as const } : {}),
3984
4042
  },
3985
4043
  })
@@ -0,0 +1,91 @@
1
+ import type { RepositoryEvent } from '../events/repository'
2
+ import type { BuildStore, RepositoryRecord } from '../store/types'
3
+ import { formatDispatcherIdentity, projectDispatcherLiveness } from '../kernel/dispatcher-liveness'
4
+
5
+ /** Bounded first; a later yielding invocation can hide the owner's identity or
6
+ * an earlier stop. Only this correlation query falls back to full history. */
7
+ export async function readDispatcherLiveness(
8
+ store: BuildStore,
9
+ repo: string,
10
+ now = new Date(),
11
+ subset?: RepositoryEvent[],
12
+ ) {
13
+ const record = await store.getRepo(repo)
14
+ let events = record === null ? [] : (subset ?? (await store.getRepoStateEvents(repo)))
15
+ const bounded = projectDispatcherLiveness(record, events, now)
16
+ if (
17
+ record &&
18
+ ((record.lease &&
19
+ !events.some(
20
+ (e) => e.type === 'dispatcher.run-started' && e.payload.run === record.lease!.holder,
21
+ )) ||
22
+ (bounded.state !== 'held' &&
23
+ events.some((e) => e.type === 'dispatcher.run-started') &&
24
+ !events.some((e) => e.type === 'dispatcher.run-stopped')))
25
+ ) {
26
+ events = await store.getRepoEvents(repo)
27
+ }
28
+ return { record, events, dispatcher: projectDispatcherLiveness(record, events, now) }
29
+ }
30
+
31
+ /** Invocation-local identity cache. Only start facts survive refreshes; ages
32
+ * always come from the current lease record and clock. Failed correlations are
33
+ * negatively cached so a standby cannot repeatedly replay a large journal. */
34
+ export class StandbyIdentityResolver {
35
+ private readonly starts = new Map<string, RepositoryEvent>()
36
+ private readonly attempted = new Set<string>()
37
+ private readonly pending = new Map<string, Promise<void>>()
38
+
39
+ constructor(
40
+ private readonly store: BuildStore,
41
+ private readonly repo: string,
42
+ ) {}
43
+
44
+ ingest(events: readonly RepositoryEvent[]): void {
45
+ for (const event of events) {
46
+ if (event.type === 'dispatcher.run-started') this.starts.set(event.payload.run, event)
47
+ }
48
+ }
49
+
50
+ private async correlate(holder: string): Promise<void> {
51
+ const pending = this.pending.get(holder)
52
+ if (pending !== undefined) return pending
53
+ if (this.starts.has(holder) || this.attempted.has(holder)) return
54
+ this.attempted.add(holder)
55
+ const lookup = (async () => {
56
+ try {
57
+ this.ingest(await this.store.getRepoStateEvents(this.repo))
58
+ if (!this.starts.has(holder)) this.ingest(await this.store.getRepoEvents(this.repo, 0))
59
+ } catch {
60
+ // Identity is best effort; the sampled holder id remains sufficient.
61
+ }
62
+ })()
63
+ this.pending.set(holder, lookup)
64
+ try {
65
+ await lookup
66
+ } finally {
67
+ this.pending.delete(holder)
68
+ }
69
+ }
70
+
71
+ async refresh(
72
+ run: string,
73
+ yieldedHolder: string | undefined,
74
+ now: Date,
75
+ ): Promise<string | undefined> {
76
+ let record: RepositoryRecord | null
77
+ try {
78
+ record = await this.store.getRepo(this.repo)
79
+ } catch {
80
+ return yieldedHolder
81
+ }
82
+ const holder = record?.lease?.holder
83
+ if (holder === run) return undefined
84
+ if (holder === undefined) return yieldedHolder
85
+ await this.correlate(holder)
86
+ if (!this.starts.has(holder)) return holder
87
+ const projection = projectDispatcherLiveness(record, [...this.starts.values()], now)
88
+ const identity = projection.holder ?? projection.expiredHolder
89
+ return identity?.run === holder ? formatDispatcherIdentity(identity) : holder
90
+ }
91
+ }
@@ -282,7 +282,7 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
282
282
  ' ab watch [<slug>...] [--repository] [--event <glob>]... [--since <cursor>] [--timeout <duration>] [--interval <duration>] [--count <n>] [--json] [--store <ref>]',
283
283
  '',
284
284
  'Follow the event logs of one, several, or all active builds in this repository —',
285
- 'optionally the repository journal — and emit one record per matching event as it',
285
+ 'the repository journal by default with no slugs — and emit one record per matching event as it',
286
286
  'lands. With no slug, every nonterminal build (queued, running, paused, blocked) is',
287
287
  'watched, and a build created while the watch runs joins it without a restart.',
288
288
  'Naming one or more slugs watches exactly those builds; an unknown slug is an error',
@@ -291,16 +291,18 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
291
291
  '',
292
292
  'Options:',
293
293
  ' --repository Also follow the repository journal (dispatcher and harvest',
294
- ' events). Without it, repository events are never emitted.',
294
+ ' events) for named watches. No-slug watches include it by default.',
295
295
  ' --event <glob> Repeatable glob over event type names: * matches any run of',
296
296
  ' characters, ? one character (escalation.*, pr.merged, *). Every',
297
297
  ' glob must match at least one type in the build catalog — or,',
298
- ' with --repository, the repository catalog — or the command fails',
298
+ ' with no slugs or --repository, the repository catalog — or the command fails',
299
299
  ' before any store read. Any --event replaces the default filter.',
300
300
  ' --since <cursor> Resume from a cursor emitted by an earlier record; every matching',
301
301
  ' event after that cursor is delivered exactly once, including',
302
302
  ' events that landed while no watch was running. A cursor from a',
303
- ' different repository or store is rejected.',
303
+ ' different repository or store is rejected. A legacy cursor without',
304
+ ' a repository position baselines that stream now; reconcile earlier',
305
+ ' source conditions with ab repository status.',
304
306
  ' --timeout <dur> End the watch after this duration (30, 45s, 5m, 1h). Default',
305
307
  ' 30 minutes; 0 means unbounded.',
306
308
  ' --interval <dur> Poll cadence. Default: 5s for an http(s) store, 1s for a local store.',
@@ -313,7 +315,8 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
313
315
  'infrastructure.failed, runner.setup-failed, dispatch.failed, publication.lost,',
314
316
  'finalize.completed, pr.conflicted, pr.merged, pr.closed, build.completed,',
315
317
  'build.aborted. For the repository journal: harvest.escalated, harvest.failed,',
316
- 'dispatcher.tick-failed, dispatcher.config-rejected, dispatcher.harvest-runner-failed.',
318
+ 'dispatcher.tick-failed, dispatcher.config-rejected, dispatcher.harvest-runner-failed,',
319
+ 'dispatcher.ticket-source-degraded, dispatcher.ticket-source-recovered.',
317
320
  '',
318
321
  'Record shape (--json): build (slug, or null for a repository event), repo (repository',
319
322
  'id, present only for repository events), event (the complete durable envelope: build,',
@@ -161,11 +161,12 @@ export async function ensureLocalStateIgnored(targetRepo: string): Promise<boole
161
161
  * pattern-matching a description. Membership here is reserved for skills that
162
162
  * drive NO phase — `spec` is the conversation that writes a spec before a build
163
163
  * exists, `tickets` is how "move ticket X to ready" becomes an action, and
164
- * `guide` is read-only reference material about the system. For these, model
164
+ * `guide` is read-only reference material about the system, and `operate` is the
165
+ * orchestrator role's manual (a role any agent takes on, not a pipeline phase). For these, model
165
166
  * invocation is precisely the point. Keep this set small; widening it needs the
166
167
  * §16.3 criterion, not convenience.
167
168
  */
168
- export const MODEL_INVOCABLE_SKILLS = new Set(['spec', 'tickets', 'guide'])
169
+ export const MODEL_INVOCABLE_SKILLS = new Set(['spec', 'tickets', 'guide', 'operate'])
169
170
 
170
171
  /**
171
172
  * Rewrite a canonical skill's YAML frontmatter for installation (§16.3):