@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
@@ -0,0 +1,215 @@
1
+ import { expect } from 'bun:test'
2
+ import { DispatchFrontend } from '../cli/dispatch-frontend'
3
+ import type { DispatchChildResult } from '../cli/dispatch-process'
4
+ import { createTerminalModeController } from '../cli/terminal-restore'
5
+ import { DISPATCHER, humanActor } from '../events/envelope'
6
+ import type { BuildStore } from '../store/types'
7
+
8
+ const tickCounters = {
9
+ merged: 0,
10
+ closed: 0,
11
+ conflicted: 0,
12
+ abandoned: 0,
13
+ discarded: 0,
14
+ janitorFailed: 0,
15
+ recovered: 0,
16
+ dispatchFailed: 0,
17
+ resumed: 0,
18
+ swept: 0,
19
+ dispatched: 0,
20
+ authored: 0,
21
+ bounced: 0,
22
+ claimRaces: 0,
23
+ invalidTickets: 0,
24
+ dependencyBlocked: 0,
25
+ harvestStarted: 0,
26
+ harvestResumed: 0,
27
+ harvestCompleted: 0,
28
+ harvestEscalated: 0,
29
+ harvestFailed: 0,
30
+ }
31
+
32
+ /** Run the actual frontend lifecycle with a deterministic child and manual
33
+ * refreshes. Instrument the backing boundary, including HTTP server reads. */
34
+ export async function dispatchReadBudget(store: BuildStore, backing: BuildStore): Promise<void> {
35
+ const repo = '/dashboard-read-budget'
36
+ await backing.ensureRepo(repo)
37
+ const metrics = {
38
+ rows: 0,
39
+ events: 0,
40
+ state: 0,
41
+ snapshots: 0,
42
+ digests: 0,
43
+ records: 0,
44
+ high: 0,
45
+ puts: 0,
46
+ }
47
+ const putSnapshot = backing.putReducerSnapshot.bind(backing)
48
+ backing.putReducerSnapshot = async (...args) => {
49
+ metrics.puts++
50
+ return putSnapshot(...args)
51
+ }
52
+ const getEvents = backing.getRepoEvents.bind(backing)
53
+ const getState = backing.getRepoStateEvents.bind(backing)
54
+ const getSnapshot = backing.getReducerSnapshot.bind(backing)
55
+ const getDigests = backing.getRepoBuildDigests.bind(backing)
56
+ const getRepo = backing.getRepo.bind(backing)
57
+ const getHigh = backing.getRepoHighWater.bind(backing)
58
+ backing.getRepoEvents = async (repo, since) => {
59
+ const events = await getEvents(repo, since)
60
+ metrics.events++
61
+ metrics.rows += events.length
62
+ return events
63
+ }
64
+ backing.getRepoStateEvents = async (repo) => {
65
+ metrics.state++
66
+ return getState(repo)
67
+ }
68
+ backing.getReducerSnapshot = async (...args) => {
69
+ metrics.snapshots++
70
+ return getSnapshot(...args)
71
+ }
72
+ backing.getRepoBuildDigests = async (repo) => {
73
+ metrics.digests++
74
+ return getDigests(repo)
75
+ }
76
+ backing.getRepo = async (repo) => {
77
+ metrics.records++
78
+ return getRepo(repo)
79
+ }
80
+ backing.getRepoHighWater = async (repo) => {
81
+ metrics.high++
82
+ return getHigh(repo)
83
+ }
84
+ let now = 0
85
+ let finish!: (result: DispatchChildResult) => void
86
+ let started!: () => void
87
+ let childRun = ''
88
+ const startup = new Promise<void>((resolve) => {
89
+ started = resolve
90
+ })
91
+ const frontend = new DispatchFrontend({
92
+ repo,
93
+ storeRef: 'test',
94
+ store,
95
+ env: {},
96
+ once: true,
97
+ clock: () => new Date(now),
98
+ input: { start: () => () => {} },
99
+ terminal: {
100
+ columns: 100,
101
+ rows: 24,
102
+ interactive: true,
103
+ write: () => {},
104
+ modes: createTerminalModeController(
105
+ () => {},
106
+ () => {},
107
+ ),
108
+ },
109
+ launchChild: ({ run }) => {
110
+ childRun = run
111
+ const completed = new Promise<DispatchChildResult>((resolve) => {
112
+ finish = resolve
113
+ })
114
+ void backing
115
+ .appendRepoWithArtifacts(
116
+ repo,
117
+ [
118
+ {
119
+ kind: 'config',
120
+ content: JSON.stringify({
121
+ capacity: 4,
122
+ roles: { default: { runtime: 'claude' } },
123
+ tickets: { source: 'file', readyState: 'ready' },
124
+ }),
125
+ },
126
+ ],
127
+ (artifacts) => ({
128
+ actor: DISPATCHER,
129
+ type: 'dispatcher.run-started',
130
+ payload: {
131
+ run,
132
+ pid: 1,
133
+ roleWarnings: [],
134
+ effectiveConfig: { kind: artifacts[0]!.kind, rev: artifacts[0]!.revision },
135
+ },
136
+ }),
137
+ )
138
+ .then(started)
139
+ return { completed, stop: async () => {} }
140
+ },
141
+ })
142
+ const running = frontend.run()
143
+ await startup
144
+ // Exercise exactly the controller operation used by timers and actions,
145
+ // without a wall-clock timer determining the batch boundaries.
146
+ const refresh = () => (frontend as unknown as { renderOnce(): Promise<void> }).renderOnce()
147
+ await refresh()
148
+ const initialSize = JSON.stringify(
149
+ (await getSnapshot({ kind: 'repo', repo }, 'dispatchFrontend', 2))?.state,
150
+ ).length
151
+ let recordReads: number | undefined
152
+ try {
153
+ for (let batch = 0; batch < 24; batch++) {
154
+ for (let i = 0; i < 128; i++) {
155
+ await backing.appendRepo(repo, {
156
+ actor: DISPATCHER,
157
+ type: 'dispatcher.tick-completed',
158
+ payload: {
159
+ run: childRun,
160
+ queued: 0,
161
+ counters: tickCounters,
162
+ janitorDiagnostics: [],
163
+ ticketDiagnostics: [],
164
+ creationDiagnostics: [],
165
+ dependencyDiagnostics: [],
166
+ },
167
+ })
168
+ }
169
+ const before = { ...metrics }
170
+ now += 6000
171
+ await refresh()
172
+ expect(metrics.puts - before.puts).toBe(1)
173
+ expect(metrics.rows - before.rows).toBe(128)
174
+ expect(metrics.events - before.events).toBe(1)
175
+ expect(metrics.state - before.state).toBe(0)
176
+ expect(metrics.snapshots - before.snapshots).toBe(0)
177
+ expect(metrics.digests - before.digests).toBe(1)
178
+ expect(metrics.high - before.high).toBe(0)
179
+ recordReads ??= metrics.records - before.records
180
+ expect(metrics.records - before.records).toBe(recordReads)
181
+ const size = JSON.stringify(
182
+ (await getSnapshot({ kind: 'repo', repo }, 'dispatchFrontend', 2))?.state,
183
+ ).length
184
+ // First advancement adds the child's identity; ticks add nothing.
185
+ expect(size).toBeLessThan(initialSize + 1000)
186
+ const rows = metrics.rows
187
+ await refresh()
188
+ expect(metrics.rows).toBe(rows)
189
+ }
190
+ // Advance the clock across rejected equal-cursor saves. A standby can
191
+ // remain quiet indefinitely; each 500 ms frame must not upload its cache.
192
+ const beforeQuiet = { ...metrics }
193
+ for (let poll = 0; poll < 40; poll++) {
194
+ now += 500
195
+ await refresh()
196
+ }
197
+ expect(metrics.puts - beforeQuiet.puts).toBe(4)
198
+ expect(metrics.rows).toBe(beforeQuiet.rows)
199
+ } finally {
200
+ finish({ outcome: 'normal', exitCode: 0 })
201
+ await running
202
+ }
203
+ await backing.appendRepo(repo, {
204
+ actor: humanActor('test'),
205
+ type: 'dispatcher.pause-set',
206
+ payload: { enabled: true },
207
+ })
208
+ // A new invocation restores persisted state without any bounded/full seed.
209
+ const { DispatchRepositoryFeed } = await import('../cli/dispatch-repository-feed')
210
+ const feed = new DispatchRepositoryFeed(store, repo, () => new Date(now))
211
+ const before = { ...metrics }
212
+ expect((await feed.refresh()).length).toBe(1)
213
+ expect(metrics.rows - before.rows).toBe(1)
214
+ expect(metrics.state - before.state).toBe(0)
215
+ }
@@ -64,3 +64,11 @@ export { resolveHarvestCliEnv } from '../cli/env'
64
64
  export { buildHarvestContext, submitHarvestProposals, submitHarvestVerdict } from '../cli/harvest'
65
65
  export { abConnect, abDisconnect } from '../cli/connect'
66
66
  export { ProjectCredentials } from '../cli/project-credentials'
67
+
68
+ export { dispatchReadBudget } from './dispatch-read-budget'
69
+
70
+ export {
71
+ reducedReadBudget,
72
+ countHistoricalRows,
73
+ type ReducedReadCosts,
74
+ } from './reduced-read-budget'
@@ -0,0 +1,462 @@
1
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { abDispatch } from '../cli/dispatch'
5
+ import { randomUuids } from '../ids'
6
+ import { ScriptedAgentRunner, defaultTurnResult } from '../ports/runner/fake'
7
+ import { Dispatcher } from '../processes/dispatcher'
8
+ import { FakeTicketSource } from '../ports/tickets/fake'
9
+ import { FakeWorkspaceProvider } from '../ports/workspace/fake'
10
+ import { FakeForge } from '../ports/forge/fake'
11
+ import { sequentialIds } from '../ids'
12
+ import { parseConfig } from '../config/load'
13
+ import {
14
+ getOperatorDashboard,
15
+ listOperatorBuilds,
16
+ getOperatorBuild,
17
+ getRepositoryStatus,
18
+ } from '../operator/query'
19
+ import { buildRegistry } from '../operator/registry'
20
+ import { expect } from 'bun:test'
21
+ import type { BuildStore } from '../store/types'
22
+ import { KERNEL, DISPATCHER, humanActor } from '../events/envelope'
23
+ import {
24
+ readBuildFacts,
25
+ readBuildStreamSeed,
26
+ readRepositoryFacts,
27
+ readRepositoryAttention,
28
+ readBuildDigests,
29
+ readDispatcherRunFacts,
30
+ readOpenTick,
31
+ } from '../store/reduced-reads'
32
+ import { RepoViewStore } from '../processes/repo-view'
33
+ import { abWatch, decodeCursor } from '../cli/watch'
34
+ import { abWait } from '../cli/wait'
35
+ import type { Exec } from '../ports/workspace/git-worktree'
36
+
37
+ export interface ReducedReadCosts {
38
+ events: number
39
+ metadata: number
40
+ bytes: number
41
+ historicalEvents: number
42
+ history?: { repo: string; through: number }
43
+ }
44
+ const repo = '/reduced-read-budget'
45
+ const slug = 'reduced-budget'
46
+ const exec: Exec = async (args) =>
47
+ args[1] === 'remote'
48
+ ? { stdout: '', stderr: 'no origin', exitCode: 2 }
49
+ : { stdout: `${repo}/.git\n${repo}/.git\n${repo}\n`, stderr: '', exitCode: 0 }
50
+
51
+ /** Shared actual-consumer scenario. Costs must instrument SQL statement results,
52
+ * including private reducer storage, rather than just public getEvents calls. */
53
+ export async function reducedReadBudget(opts: {
54
+ store: BuildStore
55
+ costs: ReducedReadCosts
56
+ fresh: () => Promise<BuildStore>
57
+ storeRef?: string
58
+ }) {
59
+ const { store, costs } = opts
60
+ await store.ensureRepo(repo)
61
+ const config = parseConfig(
62
+ '[tickets]\nsource="file"\nreadyState="ready"\n[verify]\nsteps=[]\n[finalize]\nsteps=[]',
63
+ )
64
+ const { verify: _verify, finalize: _finalize, ...root } = config
65
+ const configArtifact = await store.putRepoArtifact(repo, {
66
+ kind: 'dispatcher-effective-config',
67
+ content: JSON.stringify({ ...root, verify: { steps: [] }, finalize: { steps: [] } }),
68
+ })
69
+ await store.appendRepo(repo, {
70
+ actor: DISPATCHER,
71
+ type: 'dispatcher.run-started',
72
+ payload: {
73
+ run: 'budget-run',
74
+ pid: 1,
75
+ roleWarnings: [],
76
+ effectiveConfig: { kind: configArtifact.kind, rev: configArtifact.revision },
77
+ },
78
+ })
79
+ await store.createBuild({ slug, repo })
80
+ await store.append(slug, {
81
+ actor: KERNEL,
82
+ type: 'runner.attached',
83
+ payload: { instance: 'budget', host: 'host' },
84
+ })
85
+ await store.append(slug, {
86
+ actor: KERNEL,
87
+ type: 'finalize.completed',
88
+ payload: { pr: { number: 7, url: 'https://example.test/7', headSha: 'abc' } },
89
+ })
90
+ await store.append(slug, {
91
+ actor: humanActor('human'),
92
+ type: 'build.auto-merge-requested',
93
+ payload: {},
94
+ })
95
+ const reset = () => {
96
+ costs.events = 0
97
+ costs.metadata = 0
98
+ costs.bytes = 0
99
+ }
100
+ let repositoryBytes: number | undefined
101
+ for (const length of [32, 256]) {
102
+ for (let i = 0; i < length; i++) {
103
+ await store.append(slug, {
104
+ actor: humanActor('human'),
105
+ type: 'build.auto-merge-requested',
106
+ payload: {},
107
+ })
108
+ await store.appendRepo(repo, {
109
+ actor: humanActor('human'),
110
+ type: 'dispatcher.intake-set',
111
+ payload: { enabled: true },
112
+ })
113
+ const run = `old-${length}-${String(i).padStart(4, '0')}`
114
+ await store.appendRepo(repo, {
115
+ actor: DISPATCHER,
116
+ type: 'dispatcher.run-started',
117
+ payload: {
118
+ run,
119
+ pid: 1,
120
+ roleWarnings: [],
121
+ effectiveConfig: { kind: configArtifact.kind, rev: configArtifact.revision },
122
+ },
123
+ })
124
+ await store.appendRepo(repo, {
125
+ actor: DISPATCHER,
126
+ type: 'dispatcher.run-stopped',
127
+ payload: { run, outcome: 'normal' },
128
+ })
129
+ }
130
+ await store.appendRepo(repo, {
131
+ actor: DISPATCHER,
132
+ type: 'dispatcher.run-started',
133
+ payload: {
134
+ run: 'budget-run',
135
+ pid: 1,
136
+ roleWarnings: [],
137
+ effectiveConfig: { kind: configArtifact.kind, rev: configArtifact.revision },
138
+ },
139
+ })
140
+ const factsBytes = Buffer.byteLength(
141
+ JSON.stringify((await readRepositoryFacts(store, repo)).value),
142
+ )
143
+ if (repositoryBytes !== undefined) expect(factsBytes).toBeLessThan(repositoryBytes + 512)
144
+ repositoryBytes = factsBytes
145
+ // Warm persisted values independently of any consumer instance.
146
+ await readBuildStreamSeed(store, slug)
147
+ await readBuildFacts(store, slug)
148
+ await readRepositoryFacts(store, repo)
149
+ await readBuildDigests(store, repo)
150
+ await readRepositoryAttention(store, repo)
151
+ const handle = await opts.fresh()
152
+ const cold = new Proxy(handle, {
153
+ get(target, key) {
154
+ if (key === 'close') return async () => {}
155
+ const value = Reflect.get(target, key, target)
156
+ return typeof value === 'function' ? value.bind(target) : value
157
+ },
158
+ })
159
+ await coldDispatchOnceBudget(store, cold, length, costs)
160
+ reset()
161
+ const view = new RepoViewStore(cold, { repo })
162
+ await view.refresh()
163
+ expect((await view.buildFacts(slug)).state.pr?.number).toBe(7)
164
+ expect((await view.buildState(slug)).status).toBe('running')
165
+ expect(costs.events).toBe(0)
166
+ expect(costs.metadata).toBeLessThan(100)
167
+
168
+ const dispatcher = () =>
169
+ new Dispatcher({
170
+ store: cold,
171
+ repo,
172
+ config,
173
+ tickets: new FakeTicketSource([]),
174
+ workspaces: new FakeWorkspaceProvider({ root: '/budget-workspaces', mode: 'logical' }),
175
+ forge: new FakeForge(),
176
+ ids: sequentialIds(),
177
+ clock: () => new Date(1000),
178
+ exec,
179
+ launchRunner: async () => 'scheduled',
180
+ })
181
+ await dispatcher().tick({ acceptNewWork: false })
182
+ await getOperatorDashboard({ store: cold, repo, clock: () => new Date(1000) })
183
+ reset()
184
+ await dispatcher().tick({ acceptNewWork: false })
185
+ await getOperatorDashboard({ store: cold, repo, clock: () => new Date(1000) })
186
+ await listOperatorBuilds({ store: cold, repo, scope: 'all', now: new Date(1000) })
187
+ await getOperatorBuild({ store: cold, repo, slug, now: new Date(1000) })
188
+ await getRepositoryStatus(cold, repo, new Date(1000))
189
+ await buildRegistry({ store: cold, clock: () => new Date(1000) }).call('builds.get', {
190
+ repo,
191
+ slug,
192
+ })
193
+ expect(costs.events).toBe(0)
194
+ expect(costs.metadata).toBeLessThan(400)
195
+ expect(costs.bytes).toBeLessThan(1_000_000)
196
+
197
+ // A fresh watch must suppress historical PR eligibility despite armed
198
+ // consent at registration, including its first event-bearing poll.
199
+ reset()
200
+ let ticks = 0
201
+ let time = 0
202
+ const out: string[] = []
203
+ await abWatch({
204
+ targetRepo: repo,
205
+ env: {},
206
+ exec,
207
+ storeRef: opts.storeRef ?? '/budget',
208
+ openStore: () => cold,
209
+ slugs: [slug],
210
+ json: true,
211
+ timeout: '2',
212
+ now: () => new Date(time),
213
+ stdout: (line) => out.push(line),
214
+ stderr: () => {},
215
+ delay: async () => {
216
+ time += 1000
217
+ if (++ticks === 1)
218
+ await store.append(slug, {
219
+ actor: humanActor('human'),
220
+ type: 'build.auto-merge-cancelled',
221
+ payload: {},
222
+ })
223
+ },
224
+ })
225
+ expect(out).toHaveLength(1)
226
+ const cursor = JSON.parse(out[0]!).cursor
227
+ expect(
228
+ decodeCursor(cursor, { store: opts.storeRef ?? '/budget', repo }).deliveredAttention,
229
+ ).toEqual([JSON.stringify([slug, 7])])
230
+ expect(costs.events).toBeLessThanOrEqual(4)
231
+ expect(costs.metadata).toBeLessThan(100)
232
+
233
+ await store.append(slug, {
234
+ actor: humanActor('human'),
235
+ type: 'build.auto-merge-requested',
236
+ payload: {},
237
+ })
238
+ await readBuildStreamSeed(store, slug)
239
+ reset()
240
+ let discoveryTick = 0
241
+ time = 0
242
+ const discovered = new Proxy(cold, {
243
+ get(target, key) {
244
+ if (key === 'listBuilds')
245
+ return async () => (discoveryTick === 0 ? [] : target.listBuilds())
246
+ const value = Reflect.get(target, key, target)
247
+ return typeof value === 'function' ? value.bind(target) : value
248
+ },
249
+ })
250
+ const discoveryOut: string[] = []
251
+ await abWatch({
252
+ targetRepo: repo,
253
+ env: {},
254
+ exec,
255
+ storeRef: opts.storeRef ?? '/budget',
256
+ openStore: () => discovered,
257
+ json: true,
258
+ timeout: '3',
259
+ now: () => new Date(time),
260
+ stdout: (line) => discoveryOut.push(line),
261
+ stderr: () => {},
262
+ delay: async () => {
263
+ time += 1000
264
+ if (++discoveryTick === 2)
265
+ await store.append(slug, {
266
+ actor: humanActor('human'),
267
+ type: 'build.auto-merge-cancelled',
268
+ payload: {},
269
+ })
270
+ },
271
+ })
272
+ expect(discoveryOut).toHaveLength(1)
273
+ expect(costs.events).toBeLessThanOrEqual(6)
274
+ expect(costs.metadata).toBeLessThan(180)
275
+
276
+ // Wait applies its current-attention policy instead of watch suppression.
277
+ await readBuildStreamSeed(store, slug)
278
+ reset()
279
+ const waitOut: string[] = []
280
+ const result = await abWait({
281
+ targetRepo: repo,
282
+ env: {},
283
+ exec,
284
+ storeRef: opts.storeRef ?? '/budget',
285
+ openStore: () => cold,
286
+ slugs: [slug],
287
+ forValues: ['attention'],
288
+ json: true,
289
+ now: () => new Date(time),
290
+ stdout: (line) => waitOut.push(line),
291
+ stderr: () => {},
292
+ })
293
+ expect(result).toBe(0)
294
+ expect(JSON.parse(waitOut[0]!).observation.pr.number).toBe(7)
295
+ expect(costs.events).toBe(0)
296
+ expect(costs.metadata).toBeLessThan(40)
297
+ await store.append(slug, {
298
+ actor: humanActor('human'),
299
+ type: 'build.auto-merge-requested',
300
+ payload: {},
301
+ })
302
+ await readBuildStreamSeed(store, slug)
303
+ reset()
304
+ let waitTick = 0
305
+ time = 0
306
+ const polled: string[] = []
307
+ const waited = await abWait({
308
+ targetRepo: repo,
309
+ env: {},
310
+ exec,
311
+ storeRef: opts.storeRef ?? '/budget',
312
+ openStore: () => cold,
313
+ slugs: [slug],
314
+ forValues: ['attention'],
315
+ json: true,
316
+ timeout: '4',
317
+ now: () => new Date(time),
318
+ stdout: (line) => polled.push(line),
319
+ stderr: () => {},
320
+ delay: async () => {
321
+ time += 1000
322
+ // The first pass stays idle; the next delivers the first event batch.
323
+ if (++waitTick === 1) {
324
+ expect(costs.events).toBe(0)
325
+ expect(costs.metadata).toBeLessThan(40)
326
+ }
327
+ if (waitTick === 2) {
328
+ expect(costs.events).toBe(0)
329
+ await store.append(slug, {
330
+ actor: humanActor('human'),
331
+ type: 'build.auto-merge-cancelled',
332
+ payload: {},
333
+ })
334
+ }
335
+ },
336
+ })
337
+ expect(waited).toBe(0)
338
+ expect(waitTick).toBe(2)
339
+ expect(JSON.parse(polled[0]!).observation.pr.number).toBe(7)
340
+ expect(costs.events).toBeLessThanOrEqual(2)
341
+ expect(costs.metadata).toBeLessThan(50)
342
+ }
343
+ }
344
+
345
+ /** Exercise the real outer DispatchLoop with a new invocation identity. Only
346
+ * shared values are warmed: no parameterized run value may hide a cold replay. */
347
+ async function coldDispatchOnceBudget(
348
+ store: BuildStore,
349
+ cold: BuildStore,
350
+ length: number,
351
+ costs: ReducedReadCosts,
352
+ ) {
353
+ const onceRepo = 'https://github.com/budget/cold-once'
354
+ await store.ensureRepo(onceRepo)
355
+ for (let i = 0; i < length; i++)
356
+ await store.appendRepo(onceRepo, {
357
+ actor: humanActor('human'),
358
+ type: 'dispatcher.intake-set',
359
+ payload: { enabled: false },
360
+ })
361
+ const boundary = (await readRepositoryFacts(store, onceRepo)).cursor
362
+ const directory = await mkdtemp(join(tmpdir(), 'ab-cold-once-'))
363
+ try {
364
+ await writeFile(
365
+ join(directory, 'autobuild.toml'),
366
+ `[roles.default]
367
+ runtime = "claude"
368
+ [tickets]
369
+ source = "file"
370
+ readyState = "ready"
371
+ [verify]
372
+ steps = []
373
+ [finalize]
374
+ steps = []
375
+ `,
376
+ )
377
+ const checked = new Proxy(cold, {
378
+ get(target, key) {
379
+ if (key === 'getReducerValue')
380
+ return (...args: Parameters<BuildStore['getReducerValue']>) => {
381
+ expect(['dispatcherRunFacts', 'openTick']).not.toContain(args[1].reducer)
382
+ return target.getReducerValue(...args)
383
+ }
384
+ const value = Reflect.get(target, key, target)
385
+ return typeof value === 'function' ? value.bind(target) : value
386
+ },
387
+ })
388
+ costs.events = 0
389
+ costs.metadata = 0
390
+ costs.bytes = 0
391
+ costs.historicalEvents = 0
392
+ costs.history = { repo: onceRepo, through: boundary }
393
+ const run = `never-read-once-${length}`
394
+ await abDispatch({
395
+ targetRepo: directory,
396
+ repo: onceRepo,
397
+ env: {},
398
+ exec: async (args) =>
399
+ args[1] === 'remote'
400
+ ? { stdout: `${onceRepo}.git\n`, stderr: '', exitCode: 0 }
401
+ : {
402
+ stdout: `${directory}/.git\n${directory}/.git\n${directory}\n`,
403
+ stderr: '',
404
+ exitCode: 0,
405
+ },
406
+ stdout: () => {},
407
+ stderr: () => {},
408
+ once: true,
409
+ plain: true,
410
+ kernelRunId: run,
411
+ storeRef: '/budget',
412
+ wire: () => ({
413
+ store: checked,
414
+ storeRef: '/budget',
415
+ tickets: new FakeTicketSource([]),
416
+ forge: new FakeForge(),
417
+ workspaces: new FakeWorkspaceProvider({ root: '/once-workspaces', mode: 'logical' }),
418
+ buildExecution: {
419
+ start: () => {
420
+ throw new Error('no builds in once budget')
421
+ },
422
+ },
423
+ runtimes: {
424
+ claude: {
425
+ runner: new ScriptedAgentRunner({ script: () => defaultTurnResult() }),
426
+ servesModels: [],
427
+ },
428
+ },
429
+ ids: sequentialIds(),
430
+ uuids: randomUuids(),
431
+ clock: () => new Date(1000),
432
+ }),
433
+ })
434
+ const finished = (await readRepositoryFacts(checked, onceRepo)).value.recentRuns.find(
435
+ ([id]) => id === run,
436
+ )?.[1]
437
+ expect(finished?.completed?.type).toBe('dispatcher.tick-completed')
438
+ expect(finished?.stop?.type).toBe('dispatcher.run-stopped')
439
+ // The same fresh invocation may fail before writing its start fact.
440
+ expect(
441
+ (await readDispatcherRunFacts(checked, onceRepo, `never-started-${length}`, false)).value,
442
+ ).toEqual({})
443
+ expect(await readOpenTick(checked, onceRepo, `never-started-${length}`, false)).toBe(false)
444
+ expect(costs.historicalEvents).toBe(0)
445
+ expect(costs.events).toBeLessThanOrEqual(32)
446
+ expect(costs.metadata).toBeLessThan(300)
447
+ expect(costs.bytes).toBeLessThan(1_000_000)
448
+ } finally {
449
+ delete costs.history
450
+ await rm(directory, { recursive: true, force: true })
451
+ }
452
+ }
453
+
454
+ /** SQL result instrumentation handles both raw object rows and Drizzle values. */
455
+ export function countHistoricalRows(costs: ReducedReadCosts, rows: unknown): number {
456
+ if (!costs.history || !Array.isArray(rows)) return 0
457
+ return rows.filter((row) => {
458
+ const repo = Array.isArray(row) ? row[0] : row?.repo
459
+ const seq = Number(Array.isArray(row) ? row[1] : row?.seq)
460
+ return repo === costs.history!.repo && seq <= costs.history!.through
461
+ }).length
462
+ }
@@ -1823,7 +1823,12 @@ cursor from a different repository or store is rejected.
1823
1823
  Dispatcher liveness is also visible through `ab repository status --json`:
1824
1824
  `dispatcher.holder` identifies the unexpired repository lease owner, independently
1825
1825
  of `latestRun`. Heartbeat age measures attributable renewals; tick age measures
1826
- durable progress. Missing historical evidence is null. The liveness query uses
1826
+ actual tick completion. Successful unchanged ticks leave no journal pair; bounded,
1827
+ lease-fenced repository telemetry still records each actual completion and its
1828
+ outcome. Last-tick age refreshes independently of the event cursor and never uses
1829
+ heartbeat as completion evidence. Open telemetry protects unsafe work during
1830
+ shutdown; historical journals retain their boundary fallback. Missing historical
1831
+ evidence is null. The liveness query uses
1827
1832
  bounded repository facts first, with a full-history fallback when a later
1828
1833
  invocation hides the holder start or the latest stop. Other state reads retain
1829
1834
  their bounded semantics.