@defrex/autobuild 0.13.0 → 0.14.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 (94) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +19 -1
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +96 -70
  5. package/docs/hosted-service.md +2 -2
  6. package/docs/operator-api.md +1 -1
  7. package/docs/remote-store-protocol.md +84 -2
  8. package/package.json +4 -3
  9. package/packages/core/src/cli/attention.ts +18 -164
  10. package/packages/core/src/cli/build-control.ts +11 -15
  11. package/packages/core/src/cli/bulk-control.ts +3 -2
  12. package/packages/core/src/cli/context.ts +16 -78
  13. package/packages/core/src/cli/dashboard/model.ts +36 -22
  14. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  15. package/packages/core/src/cli/dispatch-process.ts +14 -7
  16. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  17. package/packages/core/src/cli/dispatch.ts +148 -79
  18. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  19. package/packages/core/src/cli/harvest.ts +71 -39
  20. package/packages/core/src/cli/historical-build-state.ts +27 -0
  21. package/packages/core/src/cli/main.ts +3 -3
  22. package/packages/core/src/cli/pr-attachments.ts +10 -13
  23. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  24. package/packages/core/src/cli/pr-summary.ts +43 -13
  25. package/packages/core/src/cli/publication-settlement.ts +10 -20
  26. package/packages/core/src/cli/repository-status.ts +38 -2
  27. package/packages/core/src/cli/status.ts +62 -32
  28. package/packages/core/src/cli/terminals.ts +25 -142
  29. package/packages/core/src/cli/ticket.ts +2 -2
  30. package/packages/core/src/cli/wait.ts +91 -84
  31. package/packages/core/src/cli/watch.ts +60 -97
  32. package/packages/core/src/config/pipeline-source.ts +4 -3
  33. package/packages/core/src/events/repository.ts +7 -3
  34. package/packages/core/src/integration/harness.ts +1 -2
  35. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2429 -9
  36. package/packages/core/src/kernel/auto-merge.ts +19 -3
  37. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  38. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  39. package/packages/core/src/kernel/harvest.ts +25 -23
  40. package/packages/core/src/kernel/log-index-value.ts +38 -0
  41. package/packages/core/src/kernel/read-facts.ts +637 -0
  42. package/packages/core/src/kernel/reducer-registry.ts +137 -3
  43. package/packages/core/src/kernel/repository-attention.ts +179 -0
  44. package/packages/core/src/kernel/service-facts.ts +135 -0
  45. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  46. package/packages/core/src/operator/control.ts +5 -6
  47. package/packages/core/src/operator/index.ts +2 -0
  48. package/packages/core/src/operator/query.ts +37 -20
  49. package/packages/core/src/operator/read-view.ts +4 -13
  50. package/packages/core/src/operator/requests.ts +2 -14
  51. package/packages/core/src/operator/sandbox.ts +10 -9
  52. package/packages/core/src/operator/tickets.ts +2 -2
  53. package/packages/core/src/plugin-sdk/index.ts +13 -0
  54. package/packages/core/src/processes/build-child.ts +6 -6
  55. package/packages/core/src/processes/build-runner.ts +62 -70
  56. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  57. package/packages/core/src/processes/dispatcher.ts +113 -173
  58. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  59. package/packages/core/src/processes/harvest-runner.ts +34 -70
  60. package/packages/core/src/processes/harvest.ts +38 -15
  61. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  62. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  63. package/packages/core/src/processes/publication-loss.ts +35 -21
  64. package/packages/core/src/processes/repo-view.ts +229 -1134
  65. package/packages/core/src/processes/tick-publication.ts +110 -0
  66. package/packages/core/src/store/adapter.ts +23 -0
  67. package/packages/core/src/store/build-scope.ts +17 -0
  68. package/packages/core/src/store/contract.ts +251 -0
  69. package/packages/core/src/store/local/schema.ts +1 -0
  70. package/packages/core/src/store/local/store.ts +179 -54
  71. package/packages/core/src/store/memory.ts +119 -29
  72. package/packages/core/src/store/phase-session.ts +17 -0
  73. package/packages/core/src/store/reduced-reads.ts +239 -0
  74. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  75. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  76. package/packages/core/src/store/reducer-values.ts +145 -0
  77. package/packages/core/src/store/remote/client.ts +41 -0
  78. package/packages/core/src/store/remote/compatibility.ts +2 -0
  79. package/packages/core/src/store/remote/index.ts +19 -0
  80. package/packages/core/src/store/remote/protocol.ts +16 -0
  81. package/packages/core/src/store/remote/version.ts +3 -1
  82. package/packages/core/src/store/repo-state-events.ts +1 -1
  83. package/packages/core/src/store/repo-tick.ts +72 -0
  84. package/packages/core/src/store/session-handle.ts +19 -0
  85. package/packages/core/src/store/snapshots.ts +10 -0
  86. package/packages/core/src/store/ticket-assets.ts +4 -4
  87. package/packages/core/src/store/types.ts +39 -2
  88. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  89. package/packages/core/src/testing/index.ts +8 -0
  90. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  91. package/skills/guide/SKILL.md +6 -1
  92. package/skills/guide/references/operator-api.md +1 -1
  93. package/skills/guide/references/remote-store-protocol.md +84 -2
  94. package/templates/hosted-service/package.json +2 -2
package/README.md CHANGED
@@ -24,7 +24,7 @@ needs an authenticated [`gh` CLI](https://cli.github.com) or an exported
24
24
  <!-- release-install:start -->
25
25
 
26
26
  ```sh
27
- bun add -g @defrex/autobuild@0.13.0
27
+ bun add -g @defrex/autobuild@0.14.0
28
28
  ```
29
29
 
30
30
  <!-- release-install:end -->
package/SPEC.md CHANGED
@@ -457,7 +457,7 @@ builds id/slug, ticket ref, repo, branch, status (derived), created/update
457
457
  lease + heartbeat (mutable liveness columns — §15.2.6, never events)
458
458
  events build_id, seq, timestamp, actor, type, payload (JSON) — append-only
459
459
  artifacts build_id, kind, revision, blobRef, metadata
460
- repo_streams repo, created/updated, lease + heartbeat
460
+ repo_streams repo, created/updated, lease + heartbeat, bounded tick observation
461
461
  repo_events repo, seq, timestamp, actor, type, payload (JSON) — append-only
462
462
  repo_artifacts repo, kind, revision, blobRef, metadata
463
463
  ticket_assets repo, ticket_id, kind, name, revision, layout, manifest (JSON), size,
@@ -1926,6 +1926,24 @@ head (or an explicit commit) to a deterministic operator branch and a pull
1926
1926
  request against the base branch and records published/publish-failed journal
1927
1927
  facts.
1928
1928
 
1929
+ Successful dispatcher ticks with no effects, counters, diagnostics, queue change,
1930
+ or attention transition append no tick boundary events. The first queue baseline,
1931
+ failed ticks, meaningful ticks, diagnostic clearing, failure recovery, and the
1932
+ first retry after a source cooldown remain recorded. Recorded starts may arrive
1933
+ with completion; optional `startedAt` preserves the actual start time, falling
1934
+ back to envelope time for historical journals.
1935
+
1936
+ Repository tick telemetry is bounded mutable operational metadata, like leases.
1937
+ `beginRepoTick` persists the open boundary before unsafe work; `finishRepoTick`
1938
+ atomically replaces it with actual completion time and outcome and appends any
1939
+ validated tick boundaries. Both are fenced by the unexpired repository lease,
1940
+ and neither renews it. Quiet completions advance telemetry without advancing the
1941
+ journal. Readers refresh telemetry independently of event cursors, correlate it
1942
+ by run, and merge with historical completions by actual time; heartbeat never
1943
+ stands in for tick completion. Telemetry survives lease release. Safe shutdown
1944
+ uses the matching run's open observation, historical journal boundaries when
1945
+ absent, and refuses forced termination when the observation cannot be read.
1946
+
1929
1947
  The operator's job across many concurrent builds: see status at a glance,
1930
1948
  act on a selected build, find blocked builds, answer escalations, and inspect
1931
1949
  any build's trail. Each dashboard build row spends its header on identity and
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { readDispatcherRunFacts } from '../packages/core/src/store/reduced-reads'
2
3
  /** Private, shipped child for the interactive `ab dispatch` kernel. It owns no
3
4
  * terminal handles; the public command launches it with immutable options. */
4
5
  import { abDispatch } from '../packages/core/src/cli/dispatch'
@@ -61,12 +62,9 @@ try {
61
62
  const opened = openStoreForRepoState(state, { env: process.env })
62
63
  try {
63
64
  await opened.store.ensureRepo(state.repo)
64
- const events = await opened.store.getRepoEvents(state.repo)
65
- if (
66
- !events.some(
67
- (event) => event.type === 'dispatcher.run-stopped' && event.payload.run === options.run,
68
- )
69
- ) {
65
+ const facts = (await readDispatcherRunFacts(opened.store, state.repo, options.run, false))
66
+ .value
67
+ if (!facts.stop) {
70
68
  await opened.store.appendRepo(state.repo, {
71
69
  actor: DISPATCHER,
72
70
  type: 'dispatcher.run-stopped',
@@ -145,9 +145,9 @@ by a later advance. Each reducer publishes a `version`; bump it whenever the
145
145
  accumulator shape or the fold semantics change, because a cached accumulator is
146
146
  only valid for the version that wrote it. `kernel/reducer-registry.ts` lists
147
147
  every reducer with its version, and its snapshot test fails when an accumulator
148
- shape changes without a bump. Most callers still read whole logs (the terminal
149
- dashboard poll advances a carried build accumulator by per-build deltas; the
150
- dispatcher view below restores from stored accumulators).
148
+ shape changes without a bump. Current-state consumers use the typed persisted
149
+ read helpers in `store/reduced-reads.ts`; whole-array reducers remain pure
150
+ reference projections for tests and explicit historical output.
151
151
 
152
152
  **Reducer snapshots.** The `BuildStore` port can keep, per scope (one build log
153
153
  or the repository journal) and registered reducer, that reducer's accumulator as
@@ -173,72 +173,86 @@ reads every snapshot as missing and replays (SQLSTATE `42P01` is never an
173
173
  error), and an older client never asks. Run `bun run postgres:migrate` once to
174
174
  gain the table; until then the dispatcher and every other reader replay in full.
175
175
 
176
- **Dispatcher repository view.** A dispatcher process builds its view of the
177
- repository once and refreshes it by deltas
178
- (`packages/core/src/processes/repo-view.ts`). `RepoViewStore` decorates the
179
- `BuildStore` port: the first `refresh()` reads the journal's high-water mark
180
- (`getRepoHighWater`), the bounded journal subset, the build listing and digests,
181
- and the full log of every build that still has work (the same predicate the tick
182
- loops use); every later `refresh()` reads only events newer than its cursors —
183
- one journal delta, one delta per held build, plus the listing and digest reads
184
- that discover new builds. Every tick stage reads and writes through the view, so
185
- an event a stage appends is folded in at once and later stages in the same tick
186
- see it with no re-read; a foreign append interleaved with ours leaves a seq gap
187
- that marks the log dirty, a lost compare-and-set does the same, and any delta
188
- that is not contiguous from its cursor discards the log and re-reads it in full,
189
- so no event is skipped. Every state transition of one source (a build, the
190
- journal) runs on that source's single promise chain — snapshot and delta reads,
191
- window opening and widening, the fold after an own append, a lost
192
- compare-and-set's invalidation — and readers queue on it too, so overlapping
193
- readers join the repair in flight instead of observing half-applied state.
194
- Decisions that deliberately re-read at the last moment
195
- (`refreshBuild`) take an explicit delta. Cursor-bearing readers such as the
196
- orchestrator wake pass get per-source windows that hold only events above the
197
- lowest session cursor. A `--once` invocation builds the view cold and reads each
198
- log once; a long-running dispatcher pays that replay at its first tick and not
199
- again.
200
-
201
- The view restores from reducer snapshots on cold start. The journal comes from
202
- the `journalView` reducer (the journal pruned on arrival: durable types plus the
203
- tail from the latest `dispatcher.run-started`); each work build comes from one
204
- `work` snapshot row — the `build`, `logIndex`, `openExecution`,
205
- `openBuildWorkspace`, `publicationState`, `lastExecutionOutcome`, `buildDigest`,
206
- `dashboardFacts` and `currentDeferralObservation` accumulators at a single
207
- cursor. One row is one atomic write on every adapter, so a reader in another
208
- process sees the old bundle or the new, never a mix; the bundle version is
209
- derived from the component reducer versions, so a stale bundle misses and the
210
- build is replayed once. The view
211
- reads only the events newer than that cursor and holds, for a snapshot-backed
212
- build, just that delta plus the reduced state of everything before it, so a tick
213
- with no new events reads zero event rows (apart from the discovery listing and
214
- digests) and one with M new events reads exactly M. Tick stages that run for every
215
- build each tick take `buildFacts(slug)` (state, log index, open execution, open
216
- workspace, publication view) instead of the event array, and read raw history
217
- only on the branches that act on it. `persistSnapshots()` writes the advanced
218
- state back at the end of each tick and on shutdown — best effort, throttled in a
219
- resident dispatcher, always run by a cold single-tick process — and never writes
220
- a cursor that was not read from the log.
221
-
222
- **Operator reads.** The web dashboard, the repository and Harvest status
223
- routes, the operator build listings, and the `ab watch`/`ab wait` discovery
224
- passes derive from that same view instead of replaying the log
225
- (`packages/core/src/operator/read-view.ts`). A dashboard row renders from the
226
- `BuildState` plus the `dashboardFacts` reducer
227
- (`packages/core/src/cli/dashboard/facts.ts`: queued-row dispatch text, session
228
- brackets, phase intervals, pause time, and the per-round loop-output seqs) and
229
- the auto-merge deferral ledger; `projectBuild(…, events)` is the same projection
230
- over the whole-array reduction, so the snapshot and replay paths cannot differ.
231
- Within one serving process, passes are single-flight per repository: requests
232
- arriving while a pass runs join the one trailing pass, which restores from the
233
- snapshot the previous pass persisted. A poll at idle therefore reads no event
234
- rows beyond snapshot lookups and build discovery, and a poll after M new events
235
- reads those M rows once, however many tabs or watchers the process serves.
236
- Serving processes do not coordinate: with P of them, each may read the same M
237
- rows once, so duplicate reads are bounded by P and never by history, tabs, or
238
- watchers. A settled build listed under `--all` is restored from the snapshot the
239
- dispatcher left at settlement, or read once and then persisted. The first
240
- operator poll after upgrading replays each work build once, because the stored
241
- bundle layout changed.
176
+ **Named reducer values.** `BuildStore.getReducerValue(scope, {reducer, args?})`
177
+ returns `{version, cursor, value}` for each eligible entry in the executable
178
+ reducer registry. Eligible reducers consume build or repository events and finish
179
+ as plain JSON; raw journal-event lists, operator-session reducers, collections,
180
+ selector functions, and top-level optional results are explicitly excluded with
181
+ reasons. Factory arguments are validated and participate in a bounded digest
182
+ cache key. The persisted envelope also records the canonical scope, arguments,
183
+ name and version, and nested accumulator shapes are validated before restore.
184
+ The `value.*` namespace is reserved: public snapshot reads and writes reject it,
185
+ and the engine persists through private adapter hooks. Versioned cache keys
186
+ exclude checkpoints produced while the old namespace was publicly writable.
187
+ `tools/reducer-value-shapes.ts` regenerates those structural validators from the
188
+ declared accumulator types.
189
+
190
+ Memory, SQLite and Postgres use the same algorithm: capture the authoritative
191
+ high-water, load persisted state on every call, fold the indexed contiguous delta
192
+ through that boundary, persist, and return an independent JSON value. There is
193
+ no process-local accumulator cache. A foreign checkpoint newer than the initial
194
+ boundary causes a fresh high-water check. Normal writes are monotone; corrupt,
195
+ wrong-version or ahead-of-log rows are repaired only by comparing the exact row
196
+ observed, atomically with the log boundary. A losing writer still returns its
197
+ correct prefix. Missing or unreadable cache state replays; log failures propagate.
198
+
199
+ The remote adapter makes one value request; only the finished answer crosses the
200
+ wire. The service uses named effective-configuration references for hosted ticket
201
+ operations (including policy publication) and semantic build/harvest lifecycle
202
+ facts for delegated authentication and issuance. Lifecycle snapshots keep session,
203
+ execution and terminal evidence, including historical endings, without dispatcher
204
+ tick tails. Anchor membership follows the contiguous log prefix. The legacy
205
+ state-events route remains a deprecated compatibility endpoint. Public generic
206
+ snapshot routes remain compatible, but current consumer reads do not restore the
207
+ former dispatcher work bundles.
208
+
209
+ **Dispatcher repository view.** `RepoViewStore`
210
+ (`packages/core/src/processes/repo-view.ts`) coordinates repository metadata and
211
+ source promise chains. Its build and journal facts come from persisted reduced
212
+ values. Reads after an append or lost conditional append consult that boundary
213
+ again, so no process-local memo suppresses a required delta. The adapter owns
214
+ cache persistence and recovery; the view neither writes work bundles nor hydrates
215
+ whole logs for decisions. Discovery uses persisted build digests, including for
216
+ settled builds. Raw methods forward actual cursor event output.
217
+
218
+ JSON-safe companion projections preserve collection and optional-value contracts.
219
+ The build facts projection returns one prefix for engine routing; its log-index
220
+ rounds and finalize steps reconstruct Maps and Sets explicitly, while publication
221
+ selectors reconstruct from a JSON ledger. Repository facts retain configuration,
222
+ control, Harvest, and dispatcher run correlations. Lease metadata and clocks stay
223
+ outside event reducers: liveness combines durable run facts with the current
224
+ repository record and observation time, including an older lease holder hidden
225
+ by a newer run. The shared repository value keeps a bounded recent-run window, including the
226
+ latest started and stopped runs. Fresh invocations use those shared facts for
227
+ first-tick publication, liveness and supervision without creating a cold
228
+ parameterized cache. Older holder correlations can use `dispatcherRunFacts` for
229
+ the requested historical run. A never-started fresh invocation does not request
230
+ historical correlation. Stopped dispatcher history is not copied into every
231
+ current read.
232
+
233
+ **Operator and CLI reads.** Build listings and details, repository and Harvest
234
+ status, dispatcher decisions, operator API queries, MCP delegation and web
235
+ dashboard refreshes use the same typed persisted read helpers. Shared projection
236
+ functions render those facts; whole-array overloads remain reference oracles.
237
+ Operator passes are single-flight per repository, with requests arriving during a
238
+ pass queued for one trailing pass. Fresh readers use persisted cache rows too;
239
+ steady-state event-row cost depends on newly appended events at fixed build/run
240
+ cardinality, not on log length. Reduced collections may legitimately grow in JSON
241
+ size. Cache loss or corruption can replay history once to recover the same answer.
242
+
243
+ Watch and wait register from a compound persisted `buildStreamSeed` at cursor C.
244
+ Its build accumulator, deferral ledger and insertion-ordered historical PR
245
+ eligibility describe that same prefix. Ordinary polling, including the first
246
+ batch on an old build, reads only events after the cursor and advances that seed
247
+ event by event, preserving state at event N in each emitted record. Watch uses
248
+ historical eligibility for baseline suppression; wait retains its separate
249
+ current-attention registration policy. Only an explicit `--since` older than C
250
+ reconstructs a historical prefix once in `cli/historical-build-state.ts`.
251
+ Actual event listings, transcripts and cursor streams remain raw reads. The
252
+ interactive terminal frontend retains its independently established incremental
253
+ feed. `tools/persisted-reduced-reads.test.ts` scans production TypeScript calls
254
+ and maintains precise, reason-bearing exceptions for these boundaries and pure
255
+ reference projections; stale exceptions fail the check.
242
256
 
243
257
  **Session Store authority.** `packages/core/src/cli/binary.ts` validates a complete build or
244
258
  Harvest ambient tuple before opening its phase Store through
@@ -387,7 +401,19 @@ SIGTERM restore terminal modes immediately and begin one supervised drain;
387
401
  persistent frontend and child handlers keep repeated signals graceful until the
388
402
  kernel child is reaped, after which a non-SIGINT terminating signal is replayed
389
403
  in the frontend. A parent-liveness watch remains the detached-kernel fallback.
390
- Timeout escalation is held while a durable `tick-started` fact remains open,
404
+ The repository record carries constant-size, lease-holder-fenced tick telemetry:
405
+ `beginRepoTick` opens the unsafe boundary, and `finishRepoTick` atomically closes
406
+ it and appends selected tick facts. Successful unchanged ticks append nothing;
407
+ queue transitions, counters, diagnostics, attention resets/retries, and failures
408
+ retain their journal evidence. Buffered starts preserve actual `startedAt` for
409
+ attention cooldowns; old starts use their envelope time. Liveness reads refresh
410
+ the repository record even with an unchanged event cursor, merging matching-run
411
+ completion samples with journal ticks. Heartbeat renewal cannot invent progress.
412
+ The memory, SQLite, Postgres and remote adapters share this contract, including
413
+ no mutation after lease expiry and no partial publication on validation failure.
414
+
415
+ Timeout escalation is held while the matching repository tick observation is open
416
+ (with historical `tick-started` journal fallback when no observation exists),
391
417
  because force-killing the ticket claim before build creation would not be
392
418
  recoverable. After a build child claims a takeover lease, it reduces one
393
419
  captured event prefix and appends transcriptless reclaimed `session.ended`
@@ -17,8 +17,8 @@ package's `deployment/` directory and in `templates/hosted-service/`).
17
17
  "build": "ab-hosted-service-build"
18
18
  },
19
19
  "dependencies": {
20
- "@defrex/autobuild-hosted-store-service": "0.13.0",
21
- "@defrex/autobuild": "0.13.0",
20
+ "@defrex/autobuild-hosted-store-service": "0.14.0",
21
+ "@defrex/autobuild": "0.14.0",
22
22
  "next": "16.1.6",
23
23
  "react": "19.2.4",
24
24
  "react-dom": "19.2.4"
@@ -1,7 +1,7 @@
1
1
  # Operator API v1
2
2
 
3
3
  The hosted service exposes the polling-oriented operator surface at
4
- `/operator/v1/repos/{repo}`. It is versioned with remote store protocol **3**.
4
+ `/operator/v1/repos/{repo}`. It is versioned with remote store protocol **4**.
5
5
  Every request must send `X-Autobuild-Version`,
6
6
  `X-Autobuild-Protocol-Version: 3`, and `Authorization: Bearer …`. Identity is
7
7
  checked before authentication or resource lookup.
@@ -37,7 +37,7 @@ headers, or available backend.
37
37
  `[store] url` (in that precedence order, before the local default); the shipped client appends the routes below.
38
38
  - Every `/builds`, `/repos`, and `/sessions` request sends
39
39
  `X-Autobuild-Version` with the exact client package version and
40
- `X-Autobuild-Protocol-Version` with the remote protocol version (`3` in this
40
+ `X-Autobuild-Protocol-Version` with the remote protocol version (`4` in this
41
41
  distribution). Before authentication,
42
42
  body parsing, or resource lookup, the server validates the release-line window and known protocol identity.
43
43
  Unsupported or missing values receive `409 {"kind":"conflict","error":"…"}`;
@@ -355,6 +355,74 @@ part of this protocol. Retention is enforced server-side inside the store at
355
355
  deposit time (bounded pruning of dispatcher run/config artifact revisions),
356
356
  not through any endpoint.
357
357
 
358
+ ### Named reducer values
359
+
360
+ `POST /builds/{slug}/reducer-values` and
361
+ `POST /repos/{repo}/reducer-values` accept at most 4 KiB of JSON:
362
+
363
+ ```json
364
+ {"reducer":"dispatchStatus","args":{"run":"run-a"}}
365
+ ```
366
+
367
+ The response is `200 {"version":2,"cursor":123,"value":{...}}`. Only the
368
+ finished JSON answer is returned, with no accumulator or raw-log bundle. `cursor`
369
+ is the authoritative prefix included in the answer, not a guarantee to include
370
+ appends concurrent with the request. Every call checks durable progress and
371
+ persisted state; parallel instances may safely advance the same checkpoint.
372
+ Snapshots are disposable caches: deletion, corruption, wrong versions and
373
+ unreadable cache storage cause replay. Log read failures remain errors.
374
+
375
+ The executable catalog is `kernel/reducer-registry.ts`. All requests reject
376
+ unknown names, excluded reducers, wrong scopes and invalid/extra arguments with
377
+ `400 validation`. A missing resource is `404`, project and delegated authority
378
+ checks are the same as other reads, and unsupported methods remain unavailable.
379
+ The operation is read-only authority even though its HTTP verb is POST and its
380
+ internal cache may advance. It does not accept caller-supplied state. This is an
381
+ additive protocol route: historical snapshot and event handlers and the supported
382
+ client version window are unchanged.
383
+
384
+ | Scope | Reducers with no arguments |
385
+ | --- | --- |
386
+ | Build | `executionNoProgress`, `summaryRequests`, `build`, `buildDigest`, `currentPrAttachments`, `pendingPrAttachmentReclaims`, `openExecution`, `lastExecutionOutcome`, `infrastructureFailureReset`, `openWorkspace`, `setupStreak`, `openBuildWorkspace`, `currentDeferralObservation`, `dashboardFacts`, `pinnedAssets`, `buildDelegation` |
387
+ | Repository | `harvest`, `dispatchSettings`, `sandboxStates`, `openHarvestExecutions`, `autoMergeDefault`, `effectiveConfig`, `harvestDelegation` |
388
+
389
+ | Reducer | Scope | Required arguments |
390
+ | --- | --- | --- |
391
+ | `dispatchStatus`, `openTick` | Repository | `run`: nonempty string |
392
+ | `dispatchFailureBudget` | Build | `infrastructureOwnsWorkspace`: boolean |
393
+ | `phaseFailures` | Build | `phase`: valid phase name; `round`: nonnegative integer |
394
+
395
+ Arguments are canonicalized into the cache identity; variants never reuse one
396
+ another's accumulator. `effectiveConfig` returns an object with optional `run`
397
+ and `reference: {kind, rev}`. Absence is distinct from an unreadable referenced
398
+ artifact. Delegation values contain semantic lifecycle facts indexed by prefixed
399
+ identifiers, not authorization decisions; request time and grace expiry are
400
+ always evaluated against those facts on the service.
401
+
402
+ Each remaining registered reducer is explicitly excluded:
403
+
404
+ | Reducers | Reason |
405
+ | --- | --- |
406
+ | `journalView`, `repositoryStateEvents`, `dispatchFrontend` | Raw repository event lists |
407
+ | `session` | Operator-session log scope |
408
+ | `publicationState` | Selector functions in finished value |
409
+ | `logIndex` | Maps and Sets in finished value |
410
+ | `hostedPrAttachments` | Map as finished value |
411
+ | `summaryBoundary`, `ticketSourceHealth`, `frozenPrImageHost`, `publishedBranchHead`, `verifyDiffBase`, `recordedBaseSha`, `baseBranch`, `provisionMarker`, `recoveryCheckpoint` | Existing finished contract can return top-level `undefined`, which is not JSON |
412
+
413
+ `GET /repos/{repo}/state-events` is **deprecated**. Its response remains unchanged
414
+ for supported clients, including the current dispatcher run's event tail. It is
415
+ the compatibility exception to flat event-row reads; new callers should request
416
+ named values. The current product no longer loads or writes raw-tail snapshots.
417
+
418
+ The `value.*` snapshot namespace is reserved for the adapter's private value
419
+ checkpoint hooks. Public snapshot GET and PUT requests for this prefix return
420
+ `400 validation`, including requests made with delegated or administrator
421
+ credentials. Adapter snapshot APIs reject the same names. Ordinary snapshot
422
+ names retain their existing behavior. The value cache key format is versioned;
423
+ checkpoints from the former publicly writable format are ignored and rebuilt
424
+ from the log after upgrading.
425
+
358
426
  ### Reducer snapshots
359
427
 
360
428
  A reducer snapshot is one registered reducer's serialized state as of a log
@@ -1136,7 +1204,7 @@ Four shipped behaviors do not add `BuildStore` routes:
1136
1204
  store lifecycle; there is no close endpoint.
1137
1205
  - `GET /health` is outside the `BuildStore` interface. It is always open and
1138
1206
  returns exactly `200 {"ok":true,"autobuildVersion":"<package>",
1139
- "protocolVersion":"3", "supportedClientRange":{"minVersion":"0.9.0",
1207
+ "protocolVersion":"4", "supportedClientRange":{"minVersion":"0.9.0",
1140
1208
  "maxVersionExclusive":"0.12.0"}}`. It reports HTTP-process availability and identity,
1141
1209
  not a deeper backing-store transaction or migration check.
1142
1210
 
@@ -1213,3 +1281,17 @@ require a project credential and operate only on its own identity; admin and
1213
1281
  internal operator credentials cannot impersonate self-service. All protocol
1214
1282
  responses are no-store/no-referrer. Connection commands are unavailable in
1215
1283
  build/harvest sessions.
1284
+
1285
+ ### Repository tick telemetry (protocol 4)
1286
+
1287
+ `POST /repos/:repo/tick/begin` accepts `{holder}` and returns the actual start
1288
+ timestamp, or null when the unexpired repository lease does not match.
1289
+ `POST /repos/:repo/tick/finish` accepts `{holder, completion, events}`. Completion
1290
+ is `{outcome:"completed", counters}` or `{outcome:"failed", error}`; events is
1291
+ empty for a quiet success or a matching dispatcher start/terminal pair for that
1292
+ holder. The validated batch and finished telemetry commit atomically. The reply
1293
+ is `{observation, events}` with assigned envelopes, or null on fencing failure.
1294
+ These operations never renew the lease. Repository records optionally expose
1295
+ `tick: {run, open?: {startedAt}, finished?: {run, startedAt, completedAt, ...completion}}`.
1296
+ They retain the previous completion while a new tick is open and after lease
1297
+ release. Build and session credentials cannot mutate repository ticks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defrex/autobuild",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Agent-driven software delivery from groomed ticket to merged PR",
5
5
  "repository": {
6
6
  "type": "git",
@@ -114,6 +114,7 @@
114
114
  "docs"
115
115
  ],
116
116
  "scripts": {
117
+ "ab": "bun bin/ab.ts",
117
118
  "dev:cli": "bun --hot --no-clear-screen bin/ab-dev.ts",
118
119
  "capture:dashboard": "bun tools/dashboard-capture.ts",
119
120
  "capture:web-dashboard": "bun packages/web-dashboard-capture/src/web-dashboard-capture.tsx",
@@ -143,8 +144,8 @@
143
144
  },
144
145
  "devDependencies": {
145
146
  "@biomejs/biome": "2.5.5",
146
- "@defrex/autobuild-postgres-store": "0.12.0",
147
- "@defrex/autobuild-vercel-sandbox": "0.12.0",
147
+ "@defrex/autobuild-postgres-store": "0.14.0",
148
+ "@defrex/autobuild-vercel-sandbox": "0.14.0",
148
149
  "@resvg/resvg-js": "2.6.2",
149
150
  "@types/bun": "^1.3.14",
150
151
  "@types/node": "^24.0.0",
@@ -1,6 +1,10 @@
1
1
  import type { AbEvent } from '../events/catalog'
2
2
  import type { RepositoryEvent } from '../events/repository'
3
- import { currentAutoMergeDeferral } from '../kernel/auto-merge'
3
+ import {
4
+ currentDeferralObservationReducer,
5
+ mergeAttentionFromFacts,
6
+ type DeferralLedger,
7
+ } from '../kernel/auto-merge'
4
8
  import { reduceBuild, type BuildState } from '../kernel/reducer'
5
9
 
6
10
  export interface MergeDecision {
@@ -17,22 +21,18 @@ export function mergeDecision(
17
21
  now: Date,
18
22
  state = reduceBuild(events),
19
23
  ): MergeDecision | null {
20
- if (
21
- !state.finalizeCompletedSeq ||
22
- !state.pr ||
23
- state.prState !== 'open' ||
24
- state.status === 'done' ||
25
- state.status === 'aborted'
26
- )
27
- return null
28
- const deferral = currentAutoMergeDeferral(events, state)
29
- if (state.autoMerge.requested && deferral === undefined) return null
30
- return {
31
- type: 'build.merge-decision',
32
- pr: state.pr,
33
- reason: deferral ?? 'Auto-merge is not armed',
34
- observedAt: now.toISOString(),
35
- }
24
+ return mergeDecisionFromFacts(state, currentDeferralObservationReducer.reduce(events), now)
25
+ }
26
+
27
+ export function mergeDecisionFromFacts(
28
+ state: BuildState,
29
+ ledger: DeferralLedger,
30
+ now: Date,
31
+ ): MergeDecision | null {
32
+ const decision = mergeAttentionFromFacts(state, ledger)
33
+ return decision
34
+ ? { type: 'build.merge-decision', ...decision, observedAt: now.toISOString() }
35
+ : null
36
36
  }
37
37
 
38
38
  export function buildNeedsAttention(events: AbEvent[], state: BuildState): boolean {
@@ -60,150 +60,4 @@ export function repositoryNeedsAttention(event: RepositoryEvent): boolean {
60
60
  }
61
61
  }
62
62
 
63
- type Degraded = Extract<RepositoryEvent, { type: 'dispatcher.ticket-source-degraded' }>
64
- type Failed = Extract<RepositoryEvent, { type: 'dispatcher.tick-failed' }>
65
- export type PersistenceObservation =
66
- | {
67
- type: 'dispatcher.ticket-source-persistent'
68
- source: string
69
- cause: string
70
- message: string
71
- resetAt?: string
72
- episodeSeq: number
73
- observedAt: string
74
- dependencyLimited: boolean
75
- }
76
- | {
77
- type: 'dispatcher.tick-failure-persistent'
78
- run: string
79
- firstFailure: string
80
- error: string
81
- episodeSeq: number
82
- observedAt: string
83
- }
84
-
85
- /** Incremental journal projection. Episode starts survive run anchors and
86
- * repeated evidence can strengthen, but never postpone, an existing deadline. */
87
- export class RepositoryAttention {
88
- private sources = new Map<
89
- string,
90
- {
91
- event: Degraded
92
- deadline: number
93
- dependencyLimited: boolean
94
- requiresRetry: boolean
95
- retryObserved: boolean
96
- }
97
- >()
98
- private failure: Failed | undefined
99
- private tickStarts = new Map<string, number>()
100
-
101
- private sourceDefersTick(now: number): boolean {
102
- return [...this.sources.values()].some(
103
- (source) => now < source.deadline || (source.requiresRetry && !source.retryObserved),
104
- )
105
- }
106
-
107
- /** Crossing resetAt alone does not prove persistence: the dispatcher must
108
- * finish a tick that could actually retry after the provider cooldown. */
109
- private observeRetry(run: string): void {
110
- const startedAt = this.tickStarts.get(run)
111
- this.tickStarts.delete(run)
112
- if (startedAt === undefined) return
113
- for (const source of this.sources.values()) {
114
- if (startedAt >= source.deadline) source.retryObserved = true
115
- }
116
- }
117
- fold(event: RepositoryEvent): void {
118
- switch (event.type) {
119
- case 'dispatcher.ticket-source-degraded': {
120
- const existing = this.sources.get(event.payload.source)
121
- const deadline =
122
- 'resetAt' in event.payload
123
- ? Date.parse(event.payload.resetAt)
124
- : Date.parse(event.ts) + 15 * 60_000
125
- if (existing) {
126
- existing.deadline = Math.min(existing.deadline, deadline)
127
- existing.requiresRetry ||= 'resetAt' in event.payload
128
- } else
129
- this.sources.set(event.payload.source, {
130
- event,
131
- deadline,
132
- dependencyLimited: false,
133
- requiresRetry: 'resetAt' in event.payload,
134
- retryObserved: false,
135
- })
136
- // Cooldown-driven failures are not an independent dispatcher outage.
137
- if (this.sourceDefersTick(Date.parse(event.ts))) this.failure = undefined
138
- break
139
- }
140
- case 'dispatcher.ticket-source-dependency-limited': {
141
- const source = this.sources.get(event.payload.source)
142
- if (source) source.dependencyLimited = true
143
- break
144
- }
145
- case 'dispatcher.ticket-source-recovered':
146
- this.sources.delete(event.payload.source)
147
- break
148
- case 'dispatcher.run-started':
149
- this.failure = undefined
150
- this.tickStarts.clear()
151
- break
152
- case 'dispatcher.run-stopped':
153
- this.tickStarts.delete(event.payload.run)
154
- if (this.failure?.payload.run === event.payload.run) this.failure = undefined
155
- break
156
- case 'dispatcher.tick-started':
157
- this.tickStarts.set(event.payload.run, Date.parse(event.ts))
158
- break
159
- case 'dispatcher.tick-completed':
160
- this.observeRetry(event.payload.run)
161
- if (this.failure?.payload.run === event.payload.run) this.failure = undefined
162
- break
163
- case 'dispatcher.tick-failed':
164
- this.observeRetry(event.payload.run)
165
- if (this.sourceDefersTick(Date.parse(event.ts))) this.failure = undefined
166
- else if (this.failure?.payload.run !== event.payload.run) this.failure = event
167
- break
168
- }
169
- }
170
- pending(now: Date): { identity: string; observation: PersistenceObservation }[] {
171
- const results: { identity: string; observation: PersistenceObservation }[] = []
172
- for (const {
173
- event,
174
- deadline,
175
- dependencyLimited,
176
- requiresRetry,
177
- retryObserved,
178
- } of this.sources.values()) {
179
- if (now.getTime() < deadline || (requiresRetry && !retryObserved)) continue
180
- results.push({
181
- identity: `source:${event.seq}`,
182
- observation: {
183
- type: 'dispatcher.ticket-source-persistent',
184
- ...event.payload,
185
- episodeSeq: event.seq,
186
- observedAt: now.toISOString(),
187
- dependencyLimited,
188
- },
189
- })
190
- }
191
- const event = this.failure
192
- if (
193
- event &&
194
- !this.sourceDefersTick(now.getTime()) &&
195
- now.getTime() >= Date.parse(event.ts) + 5 * 60_000
196
- )
197
- results.push({
198
- identity: `tick:${event.seq}`,
199
- observation: {
200
- type: 'dispatcher.tick-failure-persistent',
201
- ...event.payload,
202
- firstFailure: event.ts,
203
- episodeSeq: event.seq,
204
- observedAt: now.toISOString(),
205
- },
206
- })
207
- return results
208
- }
209
- }
63
+ export { RepositoryAttention, type PersistenceObservation } from '../kernel/repository-attention'