@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
@@ -45,6 +45,7 @@ export type DigestEvent = DigestEventRow
45
45
 
46
46
  /** The carried accumulator for `reduceBuildDigest`. */
47
47
  export interface DigestAcc {
48
+ readinessChangeSeq?: number
48
49
  terminal?: BuildDigest['terminal']
49
50
  merged?: string
50
51
  observations: { seq: number; ts: string }[]
@@ -57,7 +58,7 @@ export interface DigestAcc {
57
58
  }
58
59
 
59
60
  /** Bump when `DigestAcc`'s shape or fold semantics change. */
60
- export const BUILD_DIGEST_REDUCER_VERSION = 2
61
+ export const BUILD_DIGEST_REDUCER_VERSION = 3
61
62
 
62
63
  export const buildDigestReducer: IncrementalReducer<
63
64
  DigestAcc,
@@ -68,6 +69,8 @@ export const buildDigestReducer: IncrementalReducer<
68
69
  initial: (): DigestAcc => ({ observations: [], hosted: [], reclaimed: [], openInstance: null }),
69
70
  fold(acc, events) {
70
71
  for (const event of events) {
72
+ if (['pr.merged', 'build.completed', 'build.aborted'].includes(event.type))
73
+ acc.readinessChangeSeq = event.seq
71
74
  if (event.type === 'build.completed') acc.terminal = 'done'
72
75
  else if (event.type === 'build.aborted') acc.terminal = 'aborted'
73
76
  else if (event.type === 'pr.merged') acc.merged = event.ts
@@ -84,11 +87,20 @@ export const buildDigestReducer: IncrementalReducer<
84
87
  }
85
88
  }
86
89
  },
87
- finish: ({ terminal, merged, observations, hosted, reclaimed, openInstance }) => {
90
+ finish: ({
91
+ readinessChangeSeq,
92
+ terminal,
93
+ merged,
94
+ observations,
95
+ hosted,
96
+ reclaimed,
97
+ openInstance,
98
+ }) => {
88
99
  const reclaimedSet = new Set(reclaimed)
89
100
  const reclaimPending = hosted.some((seq) => !reclaimedSet.has(seq))
90
101
  return {
91
102
  observations: [...observations],
103
+ ...(readinessChangeSeq !== undefined ? { readinessChangeSeq } : {}),
92
104
  ...(merged !== undefined ? { merged } : {}),
93
105
  ...(terminal !== undefined ? { terminal } : {}),
94
106
  ...(reclaimPending ? { reclaimPending: true as const } : {}),
@@ -46,6 +46,7 @@ import { createSessionScopedStore } from '../session-handle'
46
46
  import { DIGEST_EVENT_TYPES, type DigestEventRow, reduceBuildDigest } from '../digest'
47
47
  import {
48
48
  REPOSITORY_STATE_EVENT_TYPES,
49
+ REPOSITORY_LATEST_EVENT_TYPES,
49
50
  readRepoStateEventsWithAnchorRecheck,
50
51
  } from '../repo-state-events'
51
52
  import {
@@ -1188,14 +1189,26 @@ export class SqliteBuildStore implements BuildStore {
1188
1189
  },
1189
1190
  async (anchor) => {
1190
1191
  const durable = inArray(repoEvents.type, [...REPOSITORY_STATE_EVENT_TYPES])
1192
+ const latest = REPOSITORY_LATEST_EVENT_TYPES.map((type) =>
1193
+ and(
1194
+ eq(repoEvents.type, type),
1195
+ sql`${repoEvents.seq} = (SELECT seq FROM repo_events WHERE repo = ${repo} AND type = ${type} ORDER BY seq DESC LIMIT 1)`,
1196
+ ),
1197
+ )
1198
+ const tail =
1199
+ anchor === undefined
1200
+ ? undefined
1201
+ : and(
1202
+ gte(repoEvents.seq, anchor),
1203
+ sql`${repoEvents.type} NOT IN (${sql.join(
1204
+ REPOSITORY_LATEST_EVENT_TYPES.map((type) => sql`${type}`),
1205
+ sql`, `,
1206
+ )})`,
1207
+ )
1191
1208
  const rows = this.db
1192
1209
  .select()
1193
1210
  .from(repoEvents)
1194
- .where(
1195
- anchor === undefined
1196
- ? and(eq(repoEvents.repo, repo), durable)
1197
- : and(eq(repoEvents.repo, repo), or(gte(repoEvents.seq, anchor), durable)),
1198
- )
1211
+ .where(and(eq(repoEvents.repo, repo), or(durable, tail, ...latest)))
1199
1212
  .orderBy(asc(repoEvents.seq))
1200
1213
  .all()
1201
1214
  return rows.map(
@@ -285,6 +285,7 @@ export const buildDigestWireSchema = z.object({
285
285
  }),
286
286
  ),
287
287
  merged: z.string().min(1).optional(),
288
+ readinessChangeSeq: z.number().int().positive().optional(),
288
289
  reclaimPending: z.literal(true).optional(),
289
290
  executionOpen: z.literal(true).optional(),
290
291
  })
@@ -1,54 +1,12 @@
1
- /**
2
- * The bounded repository-journal read (AUT-489): a pure subset derivation
3
- * that stateless full-state readers can fetch instead of the whole journal
4
- * without changing what their reducers report.
5
- *
6
- * The hosted dispatcher appends ~4 journal events per invocation (run-started,
7
- * tick-started, tick-completed/tick-yielded, run-stopped) whether or not it
8
- * did anything, so the journal grows forever with wall-clock time while the
9
- * durable signal stays small. Every repository-state consumer reduces the
10
- * journal through one of four pure reducers whose event-type dependencies
11
- * partition the catalog:
12
- *
13
- * - `reduceDispatchSettings` — only the three dispatcher setting types
14
- * (latest-wins by seq).
15
- * - `reduceDispatchStatus(events, latestRun)` — only run-correlated
16
- * dispatcher facts, and it skips every event whose `payload.run` is not the
17
- * latest run. A run's facts are appended under its own run id, which is
18
- * minted at `dispatcher.run-started`, so all facts the reducer keeps live at
19
- * or after the latest run-started.
20
- * - `reduceHarvest` (and everything built on it) — only `harvest.*` events
21
- * plus the three setting types, which it explicitly ignores.
22
- * - `sandboxStates` — only `orchestrator.sandbox.*` events.
23
- *
24
- * So the bounded subset is: **every event of a durable (slow-growing) type —
25
- * all `harvest.*`, all `orchestrator.sandbox.*`, and the three dispatcher
26
- * setting types — plus, when the journal contains at least one
27
- * `dispatcher.run-started`, the tail from that latest run-started to the end
28
- * of the journal.** For a hosted repository with N no-op invocations this
29
- * subset's size is a constant (one invocation's ~4 facts plus the durable
30
- * signal), independent of N.
31
- *
32
- * INVARIANT FOR AUTHORS OF NEW REPOSITORY EVENT TYPES: a new type must either
33
- * (a) join `REPOSITORY_STATE_EVENT_TYPES` — it is durable, slow-growing state
34
- * some reducer depends on at full replay depth — or (b) join
35
- * `REPOSITORY_RUN_SCOPED_EVENT_TYPES` — it is run-correlated and its only
36
- * consumers reduce it for the latest run, so the tail from the latest
37
- * `dispatcher.run-started` covers it. `tests/store/repo-state-events.test.ts`
38
- * asserts the two sets exactly partition `REPOSITORY_EVENT_TYPES`; adding a
39
- * type to neither fails the build until the author makes the call. A type in
40
- * neither set would be silently dropped from bounded reads.
41
- *
42
- * Reducing this subset with the state readers yields exactly what a full
43
- * replay yields: `reduceDispatchSettings`, `reduceHarvest`, `sandboxStates`
44
- * see identical event lists, and `reduceDispatchStatus` sees every event it
45
- * keeps (the rest are skipped by the `payload.run !== latestRun` guard). The
46
- * journal itself stays append-only; no event is deleted, rewritten, or
47
- * re-ordered, and a replay from the start is untouched.
1
+ /** Bounded journal state has three disjoint, exhaustive categories:
2
+ * durable facts retain all history; run-scoped facts retain the latest run
3
+ * tail; latest-only facts retain exactly the greatest sequence per type,
4
+ * including before any run anchor. The full journal remains append-only.
48
5
  */
49
6
  import type { RepositoryEvent, RepositoryEventType } from '../events/repository'
50
7
  import { defineReducer, type IncrementalReducer } from '../kernel/incremental'
51
8
  import {
9
+ ticketSourceHealthEventPayloadSchemas,
52
10
  dispatcherSettingEventPayloadSchemas,
53
11
  dispatcherStatusEventPayloadSchemas,
54
12
  harvestEventPayloadSchemas,
@@ -64,6 +22,7 @@ const RUN_STARTED = 'dispatcher.run-started'
64
22
  * setting types are enumerated because they share one schema group with the
65
23
  * run-correlated `dispatcher.config-reloaded`. */
66
24
  export const REPOSITORY_STATE_EVENT_TYPES: readonly RepositoryEventType[] = [
25
+ ...Object.keys(ticketSourceHealthEventPayloadSchemas),
67
26
  ...Object.keys(harvestEventPayloadSchemas),
68
27
  ...Object.keys(orchestratorSandboxEventPayloadSchemas),
69
28
  ...Object.keys(dispatcherSettingEventPayloadSchemas).filter(
@@ -71,11 +30,19 @@ export const REPOSITORY_STATE_EVENT_TYPES: readonly RepositoryEventType[] = [
71
30
  ),
72
31
  ] as RepositoryEventType[]
73
32
 
33
+ /** Latest-only measurements survive run turnover without retaining scan history. */
34
+ export const REPOSITORY_LATEST_EVENT_TYPES: readonly RepositoryEventType[] = [
35
+ 'dispatcher.ticket-scan-completed',
36
+ ]
37
+ const LATEST_TYPES = new Set<string>(REPOSITORY_LATEST_EVENT_TYPES)
38
+
74
39
  /** Run-correlated repository event types: facts whose only state consumers
75
40
  * reduce them for the latest dispatcher run, so the tail from the latest
76
41
  * `dispatcher.run-started` contains everything those consumers keep. */
77
42
  export const REPOSITORY_RUN_SCOPED_EVENT_TYPES: readonly RepositoryEventType[] = [
78
- ...Object.keys(dispatcherStatusEventPayloadSchemas),
43
+ ...Object.keys(dispatcherStatusEventPayloadSchemas).filter(
44
+ (type) => !REPOSITORY_LATEST_EVENT_TYPES.includes(type as RepositoryEventType),
45
+ ),
79
46
  'dispatcher.config-reloaded',
80
47
  ] as RepositoryEventType[]
81
48
 
@@ -122,8 +89,8 @@ export async function readRepoStateEventsWithAnchorRecheck(
122
89
  }
123
90
 
124
91
  /** Carried state for the bounded read: the latest run-started seq (the last
125
- * in array order, as the whole-array pass overwrites it) and every event seen,
126
- * in array order. Nothing is pruned: a later run-started may carry a lower seq
92
+ * in array order, as the whole-array pass overwrites it) and every ordinary event seen,
93
+ * in array order, plus one latest-only fact per type. Ordinary history is retained: a later run-started may carry a lower seq
127
94
  * than an earlier one and restore events below the earlier anchor, so only
128
95
  * `finish` can apply the filter exactly. Pruning below a monotone anchor is
129
96
  * valid only for seq-ordered input and belongs with the cached-state
@@ -134,10 +101,11 @@ export interface RepositoryStateEventsAcc {
134
101
  }
135
102
 
136
103
  /** Bump when `RepositoryStateEventsAcc` or its fold changes. */
137
- export const REPOSITORY_STATE_EVENTS_REDUCER_VERSION = 1
104
+ export const REPOSITORY_STATE_EVENTS_REDUCER_VERSION = 4
138
105
 
139
106
  const keeps = (event: RepositoryEvent, anchor: number | undefined): boolean =>
140
- STATE_TYPES.has(event.type) || (anchor !== undefined && event.seq >= anchor)
107
+ STATE_TYPES.has(event.type) ||
108
+ (!LATEST_TYPES.has(event.type) && anchor !== undefined && event.seq >= anchor)
141
109
 
142
110
  export const repositoryStateEventsReducer: IncrementalReducer<
143
111
  RepositoryStateEventsAcc,
@@ -149,10 +117,24 @@ export const repositoryStateEventsReducer: IncrementalReducer<
149
117
  fold(acc, events) {
150
118
  for (const event of events) {
151
119
  if (event.type === RUN_STARTED) acc.anchor = event.seq
120
+ if (LATEST_TYPES.has(event.type)) {
121
+ const held = acc.retained.find((held) => held.type === event.type)
122
+ if (held !== undefined && held.seq >= event.seq) continue
123
+ acc.retained = acc.retained.filter((held) => held.type !== event.type)
124
+ }
152
125
  acc.retained.push(event)
153
126
  }
154
127
  },
155
- finish: (acc) => acc.retained.filter((event) => keeps(event, acc.anchor)),
128
+ finish: (acc) => {
129
+ const latest = new Map<string, RepositoryEvent>()
130
+ for (const event of acc.retained) {
131
+ if (LATEST_TYPES.has(event.type) && event.seq > (latest.get(event.type)?.seq ?? -1))
132
+ latest.set(event.type, event)
133
+ }
134
+ return [...acc.retained.filter((event) => keeps(event, acc.anchor)), ...latest.values()].sort(
135
+ (a, b) => a.seq - b.seq,
136
+ )
137
+ },
156
138
  })
157
139
 
158
140
  /** Carried state for the pruned journal view: the latest run-started seq and
@@ -167,16 +149,23 @@ export interface JournalViewAcc {
167
149
  }
168
150
 
169
151
  /** Bump when `JournalViewAcc` or its fold changes. */
170
- export const JOURNAL_VIEW_REDUCER_VERSION = 1
152
+ export const JOURNAL_VIEW_REDUCER_VERSION = 4
171
153
 
172
154
  /** Fold seq-ordered events into a journal view in place. */
173
155
  export function foldJournalView(acc: JournalViewAcc, events: readonly RepositoryEvent[]): void {
174
156
  for (const event of events) {
175
157
  if (event.type === RUN_STARTED) {
176
158
  acc.anchor = event.seq
177
- acc.retained = acc.retained.filter((held) => STATE_TYPES.has(held.type))
159
+ acc.retained = acc.retained.filter(
160
+ (held) => STATE_TYPES.has(held.type) || LATEST_TYPES.has(held.type),
161
+ )
178
162
  }
179
- if (STATE_TYPES.has(event.type) || acc.anchor !== undefined) acc.retained.push(event)
163
+ if (LATEST_TYPES.has(event.type)) {
164
+ const held = acc.retained.find((held) => held.type === event.type)
165
+ if (held !== undefined && held.seq >= event.seq) continue
166
+ acc.retained = acc.retained.filter((held) => held.type !== event.type)
167
+ acc.retained.push(event)
168
+ } else if (STATE_TYPES.has(event.type) || acc.anchor !== undefined) acc.retained.push(event)
180
169
  }
181
170
  }
182
171
 
@@ -193,9 +182,10 @@ export const journalViewReducer: IncrementalReducer<
193
182
 
194
183
  /** The normative subset derivation every adapter implements and the contract
195
184
  * tests use as the oracle: durable types across the whole journal, plus —
196
- * only when the journal has one — every event from the latest
197
- * `dispatcher.run-started` onward, ordered by seq. A journal with no
198
- * run-started has **no tail at all** (durable types only); the anchor is
185
+ * only when the journal has one — every non-latest-only event from the latest
186
+ * `dispatcher.run-started` onward, plus the maximum-sequence fact of each
187
+ * latest-only type across the whole journal, ordered by seq. A journal with no
188
+ * run-started has **no tail at all** (durable and latest-only facts only); the anchor is
199
189
  * never defaulted to 0, which would make a `seq >= anchor` clause select the
200
190
  * whole journal and defeat the bound. */
201
191
  export function projectRepositoryStateEvents(
@@ -105,6 +105,8 @@ export interface ArtifactMeta {
105
105
  * the event log on each call, so the log stays the sole authority and the
106
106
  * digest can always be discarded and rebuilt. */
107
107
  export interface BuildDigest {
108
+ /** Latest merge, completion or abort fact sequence. */
109
+ readinessChangeSeq?: number
108
110
  slug: string
109
111
  terminal?: 'done' | 'aborted'
110
112
  /** Ascending-seq `{seq, ts}` pairs for every `observation.recorded` in the
@@ -368,7 +370,9 @@ export interface BuildStore {
368
370
  ): Promise<RepositoryEvent[]>
369
371
  /** Bounded repository-journal read for stateless full-state readers
370
372
  * (AUT-489): every durable fact (REPOSITORY_STATE_EVENT_TYPES) plus — when
371
- * the journal has one — the tail from the latest `dispatcher.run-started`.
373
+ * the journal has one — the tail from the latest `dispatcher.run-started`,
374
+ * excluding latest-only types; plus exactly the maximum-sequence fact for
375
+ * each REPOSITORY_LATEST_EVENT_TYPES type across the whole journal.
372
376
  * Reducing this subset with the state readers yields exactly what a full
373
377
  * replay yields (`projectRepositoryStateEvents` is the normative oracle).
374
378
  * Unknown repo rejects like `getRepoEvents`. */
@@ -234,6 +234,19 @@ Every other known table is closed to unknown keys. The removed `[dashboardFrames
234
234
  `[dispatcher]`, `[harvest]`, and `[outer]` tables have no aliases or migration
235
235
  shims; they fail as ordinary unknown top-level keys.
236
236
 
237
+ Ticket-source ready scans run immediately when no completed measurement exists,
238
+ then every
239
+ `ticketScanIntervalSeconds` (default 60 seconds, a positive integer). Actual
240
+ tick spacing is the floor when the configured interval is shorter. A
241
+ same-repository `pr.merged`, `build.completed`, or `build.aborted` fact forces
242
+ another scan on the next tick; changed ready criteria also force a scan.
243
+ Housekeeping keeps its ordinary tick cadence. Between scans, queue depth and
244
+ standing diagnostics repeat the last completed measurement, including across
245
+ process restarts. Tickets groomed directly in the tracker are picked up within
246
+ one scan interval plus one tick when capacity permits. Observation while intake
247
+ is off shares the cadence; an observation measurement cannot suppress a fresh
248
+ admission scan when intake is enabled.
249
+
237
250
  ### Root scalars
238
251
 
239
252
  Root scalars must appear before the first TOML table; TOML otherwise nests them
@@ -242,6 +255,7 @@ under the preceding table.
242
255
  | Field | Default | Allowed / constraints | Effect |
243
256
  |---|---|---|---|
244
257
  | `baseBranch` | `"main"` | nonempty string | The branch builds branch from and target with their PR; what `reconcile` merges into the build branch. |
258
+ | `ticketScanIntervalSeconds` | `60` | positive integer seconds | Hot-reloaded ready scan cadence; tick spacing is the floor, and same-repository merge/completion/abort forces an early scan. |
245
259
  | `capacity` | `1` | positive integer | Maximum concurrent builds for this repository. |
246
260
  | `forge` | `"github"` | nonblank string | Selects shipped `github` or `local-git`, or a plugin-registered Forge adapter. `github` authenticates with `GITHUB_TOKEN`, then `GH_TOKEN`, then the gh CLI's stored login. |
247
261
  | `plugins` | `[]` | array of unique nonblank module specifiers | Trusted Bun plugin modules loaded before dispatch, `ab ticket`, and scoped phase wiring. |
@@ -1650,6 +1664,23 @@ matching event after that cursor is delivered exactly once, in per-stream
1650
1664
  sequence order — including events that landed while no watch was running. A
1651
1665
  cursor from a different repository or store is rejected.
1652
1666
 
1667
+
1668
+ Dispatcher liveness is also visible through `ab repository status --json`:
1669
+ `dispatcher.holder` identifies the unexpired repository lease owner, independently
1670
+ of `latestRun`. Heartbeat age measures attributable renewals; tick age measures
1671
+ durable progress. Missing historical evidence is null. The liveness query uses
1672
+ bounded repository facts first, with a full-history fallback when a later
1673
+ invocation hides the holder start or the latest stop. Other state reads retain
1674
+ their bounded semantics.
1675
+
1676
+ Repository-following `ab watch` additionally emits a read-only `dispatcher.lost`
1677
+ observation when an observed lease expires without a matching stop. JSON carries
1678
+ `observation` instead of `event`, with run, observedAt, leaseExpiresAt and available
1679
+ identity, plus the usual repo/build/state/cursor fields. It invents no durable
1680
+ sequence or actor. Retain the cursor to avoid repeat reports across invocations.
1681
+ No lease alone is not evidence of death. SIGINT, SIGHUP and SIGTERM drain dispatch,
1682
+ record the first signal in the stop fact, and release its lease before exit.
1683
+
1653
1684
  The watch ends with exit 0 when `--timeout` elapses (30 minutes by default;
1654
1685
  `0` unbounded), after `--count <n>` records, or when named builds have all
1655
1686
  gone terminal; with no slugs named, an empty set of active builds does not end
@@ -35,7 +35,7 @@ records. Every record carries a `cursor`; a bounded watch ends with a trailing
35
35
  `{"cursor": …}` line. Resume with `--since <cursor>` and every event after it
36
36
  is delivered exactly once, including events that landed while nothing was
37
37
  watching. Name slugs to watch only those builds; with none, every active build
38
- (including ones created later) is watched. `--event <glob>` replaces the
38
+ (including ones created later) and repository attention are watched. Named watches need `--repository` to also receive repository attention. `--event <glob>` replaces the
39
39
  default filter, `--timeout <dur>` bounds it (default 30 minutes, `0` is
40
40
  unbounded), `--count <n>` ends it after n records.
41
41
 
@@ -53,6 +53,39 @@ Run long watches and waits in whatever background or monitor facility your
53
53
  harness provides, so the session stays responsive and you are woken when they
54
54
  emit. Never block a foreground command for their whole duration.
55
55
 
56
+ Ticket-source degradation records mean dispatch intake cannot evaluate the source;
57
+ existing builds may still advance. Inspect `ab repository status --json` for the
58
+ active cause, operation, message, and expected reset. Reset times are advisory;
59
+ recovery means reads actually succeeded. Allow the existing cooldown and retry
60
+ behavior, investigate persistent ready-scan failures or a condition still active
61
+ after its reset estimate, and keep watching through recovery. Empty builds do
62
+ not establish idle intake; repeatedly restarting dispatch does not clear it.
63
+
64
+ Repository records have null build and state. Handle them with repository
65
+ status rather than build commands, keep their cursor, and re-arm. A legacy
66
+ cursor without a repository position baselines that stream at attachment and
67
+ cannot recover its earlier interval: reconcile current repository status.
68
+ `ab wait --for attention` remains build-only and does not replace a repository
69
+ attention watch.
70
+
71
+
72
+ Check dispatcher liveness with `ab repository status --json` before starting another
73
+ `ab dispatch`, and after a watch timeout or `dispatcher.lost` observation. Do not
74
+ use process lists to decide whether a dispatcher owns this repository. The
75
+ `dispatcher` projection distinguishes `held` (an unexpired lease), `lost`
76
+ (expired ownership without a recorded stop), `stopped`, and `absent`. Its holder
77
+ identity resolves a yield message to run, pid, host, start time, and heartbeat
78
+ age. Historical identity and a new holder's heartbeat may be unknown until its
79
+ first attributable renewal. Source degradation or an old tick alone does not
80
+ establish loss: a healthy lease can accompany a slow tick.
81
+
82
+ An unscoped watch, or a named watch with `--repository`, reports loss as a
83
+ read-only observation: `{repo, build:null, state:null, observation:{type:
84
+ "dispatcher.lost", run, observedAt, leaseExpiresAt, ...identity}, cursor}`.
85
+ It has no durable event envelope. Keep its cursor just as for event records;
86
+ resuming it suppresses repeated loss reports for that run. Inspect repository
87
+ status before deciding how to recover; watch never restarts a dispatcher.
88
+
56
89
  ### Keep watching until the work is done
57
90
 
58
91
  Checking once is not shepherding. When you take responsibility for builds:
@@ -60,7 +93,7 @@ Checking once is not shepherding. When you take responsibility for builds:
60
93
  1. Record the set you are shepherding (`ab builds --all --json`, or the slugs
61
94
  you were given).
62
95
  2. Arm an event-only watch and keep its cursor:
63
- `ab watch <slugs…> --json --since <cursor>` (omit `--since` the first
96
+ `ab watch --json --since <cursor>` (or named slugs with `--repository`) (omit `--since` the first
64
97
  time). Events are delivered exactly once, so a build that already finished
65
98
  is never replayed. Do not loop on `ab wait --for terminal|attention|…`:
66
99
  state conditions are satisfied at once by any build already in that state,
@@ -72,12 +105,14 @@ Checking once is not shepherding. When you take responsibility for builds:
72
105
  current position and does not emit what already happened, so a build that
73
106
  merged, or an escalation already open, before registration would otherwise
74
107
  never reach you. Run `ab build status <slug> --json` for every slug in the
75
- set. Reading status after arming leaves no gap — anything earlier shows in
108
+ set, and `ab repository status --json` for source health, initially and after
109
+ every timeout or legacy-cursor attachment. Reading status after arming leaves no gap — anything earlier shows in
76
110
  the status, anything later arrives as an event. Treat a terminal status
77
111
  (merged, closed, aborted) as that build's outcome, and an open escalation
78
112
  or failed phase as attention to handle now.
79
113
  4. When a watch returns records, react: read the record, inspect the build,
80
- answer or escalate, then re-arm from the last cursor. Keep your own
114
+ answer or escalate; for a repository record inspect repository status and act
115
+ on its condition without assuming a build slug. Then re-arm from the last cursor. Keep your own
81
116
  outcome table: mark a build done when you see `pr.merged`, `pr.closed`, or
82
117
  `build.aborted`, or when status shows it. A timeout (exit 3, or a watch
83
118
  that ends with only a `{"cursor": …}` line) is not completion — reconcile