@defrex/autobuild 0.12.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 (151) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +138 -16
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +155 -73
  5. package/docs/configuration.md +41 -9
  6. package/docs/hosted-service.md +31 -5
  7. package/docs/operator-api.md +8 -1
  8. package/docs/remote-store-protocol.md +94 -2
  9. package/docs/remote-ticket-protocol.md +151 -9
  10. package/docs/setup.md +33 -7
  11. package/docs/ticket-migration.md +64 -0
  12. package/package.json +4 -3
  13. package/packages/core/src/cli/attention.ts +18 -164
  14. package/packages/core/src/cli/build-control.ts +23 -16
  15. package/packages/core/src/cli/bulk-control.ts +3 -2
  16. package/packages/core/src/cli/connect.ts +141 -38
  17. package/packages/core/src/cli/context.ts +23 -79
  18. package/packages/core/src/cli/dashboard/model.ts +36 -22
  19. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  20. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  21. package/packages/core/src/cli/dispatch-process.ts +14 -7
  22. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  23. package/packages/core/src/cli/dispatch.ts +304 -82
  24. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  25. package/packages/core/src/cli/harvest.ts +84 -42
  26. package/packages/core/src/cli/help.ts +14 -8
  27. package/packages/core/src/cli/historical-build-state.ts +27 -0
  28. package/packages/core/src/cli/installation.ts +67 -13
  29. package/packages/core/src/cli/main.ts +23 -5
  30. package/packages/core/src/cli/mcp.ts +2 -1
  31. package/packages/core/src/cli/pr-attachments.ts +10 -13
  32. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  33. package/packages/core/src/cli/pr-summary.ts +43 -13
  34. package/packages/core/src/cli/publication-settlement.ts +10 -20
  35. package/packages/core/src/cli/repository-status.ts +38 -2
  36. package/packages/core/src/cli/self-update.ts +5 -1
  37. package/packages/core/src/cli/status.ts +62 -32
  38. package/packages/core/src/cli/terminals.ts +25 -142
  39. package/packages/core/src/cli/ticket-assets.ts +7 -2
  40. package/packages/core/src/cli/ticket-editor.ts +82 -0
  41. package/packages/core/src/cli/ticket.ts +166 -36
  42. package/packages/core/src/cli/wait.ts +91 -84
  43. package/packages/core/src/cli/watch.ts +60 -97
  44. package/packages/core/src/config/pipeline-source.ts +4 -3
  45. package/packages/core/src/config/schema.ts +35 -3
  46. package/packages/core/src/connection.ts +3 -3
  47. package/packages/core/src/events/catalog.ts +3 -2
  48. package/packages/core/src/events/payloads.ts +6 -0
  49. package/packages/core/src/events/repository.ts +7 -3
  50. package/packages/core/src/integration/harness.ts +38 -4
  51. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
  52. package/packages/core/src/kernel/auto-merge.ts +19 -3
  53. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  54. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  55. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  56. package/packages/core/src/kernel/harvest.ts +25 -23
  57. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  58. package/packages/core/src/kernel/log-index-value.ts +38 -0
  59. package/packages/core/src/kernel/read-facts.ts +637 -0
  60. package/packages/core/src/kernel/reducer-registry.ts +139 -3
  61. package/packages/core/src/kernel/repository-attention.ts +179 -0
  62. package/packages/core/src/kernel/service-facts.ts +135 -0
  63. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  64. package/packages/core/src/ontology.ts +1 -0
  65. package/packages/core/src/operator/annotations.ts +12 -0
  66. package/packages/core/src/operator/control.ts +5 -6
  67. package/packages/core/src/operator/index.ts +7 -0
  68. package/packages/core/src/operator/protocol.ts +19 -2
  69. package/packages/core/src/operator/query.ts +37 -20
  70. package/packages/core/src/operator/read-view.ts +4 -13
  71. package/packages/core/src/operator/registry.ts +61 -8
  72. package/packages/core/src/operator/requests.ts +2 -14
  73. package/packages/core/src/operator/sandbox.ts +10 -9
  74. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  75. package/packages/core/src/operator/tickets.ts +132 -22
  76. package/packages/core/src/plugin-sdk/index.ts +19 -0
  77. package/packages/core/src/plugins/runtimes.ts +5 -1
  78. package/packages/core/src/ports/runner/session-env.ts +2 -0
  79. package/packages/core/src/ports/tickets/contract.ts +66 -1
  80. package/packages/core/src/ports/tickets/create.ts +48 -12
  81. package/packages/core/src/ports/tickets/fake.ts +30 -1
  82. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  83. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  84. package/packages/core/src/ports/tickets/file.ts +114 -120
  85. package/packages/core/src/ports/tickets/grants.ts +47 -0
  86. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  87. package/packages/core/src/ports/tickets/identity.ts +20 -0
  88. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  89. package/packages/core/src/ports/tickets/linear.ts +74 -9
  90. package/packages/core/src/ports/tickets/migration.ts +336 -0
  91. package/packages/core/src/ports/tickets/operations.ts +38 -7
  92. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  93. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  94. package/packages/core/src/ports/tickets/remote.ts +60 -3
  95. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  96. package/packages/core/src/ports/tickets/revision.ts +62 -0
  97. package/packages/core/src/ports/tickets/states.ts +61 -0
  98. package/packages/core/src/ports/types.ts +40 -3
  99. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  100. package/packages/core/src/ports/workspace/create.ts +5 -1
  101. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  102. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  103. package/packages/core/src/processes/build-child.ts +6 -6
  104. package/packages/core/src/processes/build-runner.ts +62 -70
  105. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  106. package/packages/core/src/processes/dispatcher.ts +223 -205
  107. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  108. package/packages/core/src/processes/harvest-child.ts +30 -4
  109. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  110. package/packages/core/src/processes/harvest-runner.ts +58 -74
  111. package/packages/core/src/processes/harvest.ts +72 -29
  112. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  113. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  114. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  115. package/packages/core/src/processes/publication-loss.ts +35 -21
  116. package/packages/core/src/processes/repo-view.ts +229 -1099
  117. package/packages/core/src/processes/tick-publication.ts +110 -0
  118. package/packages/core/src/store/adapter.ts +23 -0
  119. package/packages/core/src/store/build-scope.ts +17 -0
  120. package/packages/core/src/store/contract.ts +283 -1
  121. package/packages/core/src/store/local/schema.ts +1 -0
  122. package/packages/core/src/store/local/store.ts +180 -55
  123. package/packages/core/src/store/memory.ts +119 -29
  124. package/packages/core/src/store/phase-session.ts +17 -0
  125. package/packages/core/src/store/reduced-reads.ts +239 -0
  126. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  127. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  128. package/packages/core/src/store/reducer-values.ts +145 -0
  129. package/packages/core/src/store/remote/client.ts +41 -0
  130. package/packages/core/src/store/remote/compatibility.ts +2 -0
  131. package/packages/core/src/store/remote/index.ts +19 -0
  132. package/packages/core/src/store/remote/protocol.ts +16 -0
  133. package/packages/core/src/store/remote/version.ts +3 -1
  134. package/packages/core/src/store/repo-state-events.ts +1 -1
  135. package/packages/core/src/store/repo-tick.ts +72 -0
  136. package/packages/core/src/store/session-handle.ts +19 -0
  137. package/packages/core/src/store/snapshots.ts +10 -0
  138. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  139. package/packages/core/src/store/ticket-assets.ts +86 -8
  140. package/packages/core/src/store/types.ts +40 -3
  141. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  142. package/packages/core/src/testing/index.ts +8 -0
  143. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  144. package/skills/guide/SKILL.md +97 -12
  145. package/skills/guide/references/operator-api.md +8 -1
  146. package/skills/guide/references/remote-store-protocol.md +94 -2
  147. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  148. package/skills/guide/references/setup.md +33 -7
  149. package/skills/guide/references/ticket-migration.md +64 -0
  150. package/skills/tickets/SKILL.md +71 -0
  151. package/templates/hosted-service/package.json +2 -2
@@ -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
+ }
@@ -197,7 +197,9 @@ The distinctions that change an administrator's answer:
197
197
  agent turn or command already running. Missing, unreadable, malformed, and
198
198
  routing-invalid candidates retain that snapshot with deduplicated operator
199
199
  notice, and restoring a valid file resumes reload. Scoped phase CLI processes
200
- still read the build worktree's branch-owned file.
200
+ still read the build worktree's branch-owned file. Dispatcher merge readiness,
201
+ resume, and execution recovery use the runner's pinned commands, verify, and
202
+ finalize configuration; builds without a pin retain the live fallback.
201
203
  - **Workspaces** are provisioned per build and remain the locally reachable
202
204
  execution context for that build.
203
205
  - **Ticket providers** (`linear`, `file`) sit behind one port; the dispatcher
@@ -215,6 +217,22 @@ The distinctions that change an administrator's answer:
215
217
  guidance and later dispatcher ticks retry. Without consent it remains open for
216
218
  ordinary `git diff` inspection; it has no review UI or image host.
217
219
 
220
+ For moving a project between sources, follow the [ticket migration procedure](references/ticket-migration.md).
221
+
222
+ ## Independent hosted tickets
223
+
224
+ Hosted tickets support local builds. Configure `[tickets] source = "hosted"`,
225
+ `teamKey`, and `url`, or run `ab connect --tickets URL` to register the project,
226
+ save its credential and publish ticket policy without changing the build store.
227
+ Without `tickets.url`, tickets use the selected remote build service; local builds
228
+ require an explicit ticket URL. No dispatcher is needed for ticket commands.
229
+ Hosted defaults are `triage`, `ready`, `doing`, `done`; create/proposal use triage.
230
+ Other sources still require readyState. Explicit state names retain their spelling.
231
+ Legacy clients without independent policy retain uploaded configuration defaults.
232
+ Harvest ticket grants are service-bound read/create authority; child phases are
233
+ read-only and cannot publish policy. See [setup](references/setup.md) and the
234
+ [ticket protocol](references/remote-ticket-protocol.md).
235
+
218
236
  ## `autobuild.toml` reference
219
237
 
220
238
  One declarative file at the repo root. **Declarative, not executable** —
@@ -737,6 +755,21 @@ exhaustion dispatch parks durably; bare `ab answer <slug>` and text guidance
737
755
  both grant a fresh budget, while abort terminates without retrying. Workspace
738
756
  failures owned by provider infrastructure recovery use only its existing budget.
739
757
 
758
+ The dispatcher also applies a fixed, non-configurable limit of three consecutive
759
+ executions that complete normally with exit code zero and no durable build
760
+ progress. Execution start/end, runner attachment and workspace lifecycle
761
+ bookkeeping do not count as progress. Pipeline, session, spec, publication/PR,
762
+ observation and acknowledged operator facts reset the streak; infrastructure,
763
+ budget and provider failures do not consume the allowance and retain their own
764
+ retry limits. Before a fourth actionable launch, the dispatcher raises one
765
+ setup-targeted `execution-no-progress` policy escalation naming the count and
766
+ the next action it expected the runner to take. Waiting, paused and terminal
767
+ builds are left parked, and pending operator commands remain executable.
768
+ Answering that escalation with guidance or a bare `ab answer <slug>` retry grants
769
+ three fresh executions. The guard is derived from durable facts, including on
770
+ supervisor restart, and uses the ordinary watch, dashboard and operator API
771
+ attention paths.
772
+
740
773
  For each repeat conflict, Autobuild compares the base merged by the latest
741
774
  completed reconcile with a fresh authoritative base snapshot. An advanced base
742
775
  proves the attempt lost a race and permits another reconcile regardless of the
@@ -788,13 +821,14 @@ state eligible.
788
821
  |---|---|---|---|
789
822
  | `source` | — | **required**, nonblank builtin or plugin registration name | Which provider backs all ticket operations. Unknown names report every available builtin and plugin source. |
790
823
  | `readyLabels` | — (source-aware) | optional; array of nonempty strings | A ticket must carry **every** listed label to be dispatchable. `[]` = **no label gate**. Absent uses the source default below. |
791
- | `readyState` | — | **required**, non-blank string | The one workflow state a ticket must sit in to be dispatchable. Linear matches exactly and case-sensitively; file matches it case-insensitively to a state directory (`ready` → `ready/`). There is no any-state mode. |
824
+ | `url` | selected build service | HTTPS URL (loopback HTTP allowed), no userinfo/query/fragment; hosted only | Ticket service independent of builds; required for local builds. |
825
+ | `readyState` | hosted: `ready` | non-blank string; required for other sources | The one workflow state a ticket must sit in to be dispatchable. Linear matches exactly and case-sensitively; file matches it case-insensitively to a state directory (`ready` → `ready/`). There is no any-state mode. |
792
826
  | `readyAssignee` | — | optional; `"me"` or an email; `linear` only | Also require the ticket to be assigned to that Linear user (`me` = owner of `LINEAR_API_KEY`); unassigned tickets are never ready. |
793
- | `teamKey` | — | required by `linear` and `hosted`, forbidden by `file`, allowed for plugins | The Linear team key (e.g. `"ENG"`) or an existing plugin configuration field. |
827
+ | `teamKey` | — | required by `linear` and `hosted`, forbidden by `file`, allowed for plugins | The Linear team key (e.g. `"ENG"`), the captured prefix for new hosted ticket ids (`ENG-1`), or a plugin configuration field. Hosted numbers are project-local and continue across prefix changes; legacy ids remain aliases and readable inputs are case-insensitive. |
794
828
  | `claimedState` | — | optional nonempty string; forbidden by `file`, allowed for plugins | Workflow state `claim()` moves a ticket to when a build starts. |
795
- | `doneState` | hosted: `"Done"` | optional nonblank string | Project completion state and merged-ticket transition; hosted tickets only. |
829
+ | `doneState` | hosted: `"done"` | optional nonblank string | Project completion state and merged-ticket transition; hosted tickets only. |
796
830
  | `createState` | — | optional, nonempty string | Default state for new tickets when a create does not name one. Absent = the provider's default (Linear: the team's default, e.g. Backlog; file: Triage). |
797
- | `triageState` | — | optional, nonempty string | State the dispatcher hands tickets back to for human triage — spec-gate bounces, aborted builds, closed-unmerged PRs. Absent = Linear/hosted: Backlog; file/plugin: Triage. Must name a state the tracker actually has — a Linear team only has "Triage" when its triage feature is enabled. |
831
+ | `triageState` | — | optional, nonempty string | State the dispatcher hands tickets back to for human triage — spec-gate bounces, aborted builds, closed-unmerged PRs. Absent = Linear: Backlog; hosted: triage; file/plugin: Triage. Must name a state the tracker actually has — a Linear team only has "Triage" when its triage feature is enabled. |
798
832
  | `proposalState` | — | optional, nonempty string | State observation harvest files approved proposals into. Absent = the resolved `triageState`, which keeps the human grooming gate. Setting it to `readyState` waives that gate: harvested proposals dispatch without being read. Keep it distinct from `triageState` — see below. |
799
833
  | `dir` | file: `.autobuild/tickets`; plugin: — | optional nonempty string; forbidden by `linear`, allowed for plugins | Root holding file state directories, or an existing plugin configuration field. |
800
834
 
@@ -1214,16 +1248,19 @@ transitions, so an invalid state fails with the
1214
1248
  source's known states. A missing id fails nonzero with an error naming both the
1215
1249
  id and configured source.
1216
1250
 
1217
- Human-readable output is the default and must not be parsed for ids. Every
1218
- subcommand accepts `--json`, which emits one bare JSON value and no prose: a
1251
+ Human-readable output is the default and must not be parsed for ids. The CRUD
1252
+ subcommands accept `--json`, which emits one bare JSON value and no prose: a
1219
1253
  `Ticket[]` for `list`, and the complete resulting `Ticket` for `create`,
1220
1254
  `update`, `block`, `unblock`, `show`, or `move`.
1221
1255
 
1222
1256
  ### Ticket assets
1223
1257
 
1224
- A ticket asset is a file or folder attached to a ticket and stored in the
1225
- BuildStore, never in the ticket source: a design export, reference screenshots,
1226
- a sample payload. Each has a kind and a name.
1258
+ A ticket asset is a file or folder attached to a ticket: a design export,
1259
+ reference screenshots, or a sample payload. Hosted tickets store assets in their
1260
+ ticket service, independently of the build store; file tickets use native portable
1261
+ assets composed with legacy BuildStore attachments. Sources without native assets
1262
+ use the BuildStore. Existing service assets need no migration. Old local hosted-ticket
1263
+ assets are not moved and are not a fallback. Each has a kind and a name.
1227
1264
 
1228
1265
  - `ab ticket attach <id> <kind> <path> [--name <name>] [--json]` stores `<path>`
1229
1266
  (default name: its basename). Attaching the same kind and name again makes a
@@ -1235,7 +1272,10 @@ a sample payload. Each has a kind and a name.
1235
1272
  - `ab ticket asset get <id> <kind> <name> <dest> [--rev <n>]` downloads the
1236
1273
  exact bytes of the latest (or given) revision; `ab ticket asset rm <id> <kind>
1237
1274
  <name>` drops it from the current assets.
1238
- - **Frozen at claim.** A claim pins the current revisions into the build. A
1275
+ - **Frozen at claim.** A claim pins the current revisions into the build.
1276
+ Hosted revisions are copied into immutable snapshots in the build store,
1277
+ preserving revision numbers and allowing reads without the ticket service.
1278
+ Old pins without snapshots keep reading their original ticket rows. A
1239
1279
  later attach or remove leaves a running build alone; a human answer with
1240
1280
  `--revise-spec-from-ticket` pins again. `ab build asset get <slug> <kind>
1241
1281
  <name> <dest> [--rev <n>]` downloads what the build froze, also after it ends.
@@ -1783,7 +1823,12 @@ cursor from a different repository or store is rejected.
1783
1823
  Dispatcher liveness is also visible through `ab repository status --json`:
1784
1824
  `dispatcher.holder` identifies the unexpired repository lease owner, independently
1785
1825
  of `latestRun`. Heartbeat age measures attributable renewals; tick age measures
1786
- 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
1787
1832
  bounded repository facts first, with a full-history fallback when a later
1788
1833
  invocation hides the holder start or the latest stop. Other state reads retain
1789
1834
  their bounded semantics.
@@ -2038,3 +2083,43 @@ build phase, which is the criterion for membership (§16.3): `ab-spec` and
2038
2083
  `ab-tickets` are human/agent-facing pre-build surfaces, `ab-guide` is
2039
2084
  read-only reference including the setup handoff guidance, and `ab-operate` is
2040
2085
  the manual for the orchestrator role, which any agent may take on.
2086
+
2087
+ ## Revision-checked ticket writes
2088
+
2089
+ Every ticket read includes an opaque `revision`. Pass it as `expectedRevision`
2090
+ (on MCP/operator requests) or `--expected-revision <opaque>` on `ab ticket update`,
2091
+ `move`, `block` and `unblock`. A stale write fails without changing anything and
2092
+ reports the current revision. Omission preserves unguarded behavior. Blocker
2093
+ checks cover the entire batch. Dispatcher moves remain unguarded and change the
2094
+ revision. Sources without guarded writes, including Linear, refuse supplied
2095
+ revisions rather than ignoring them.
2096
+
2097
+ `ab ticket edit <id>` opens `$VISUAL`, falling back to `$EDITOR`, using TOML
2098
+ frontmatter and a Markdown body. Only title, body and labels are editable;
2099
+ id, blockers, creation key and reference comments are read-only. It saves only
2100
+ changed fields with the opening revision. Unchanged or formatting-only edits
2101
+ write nothing. On conflict, invalid input or editor failure, the error names an
2102
+ absolute file path containing the edited text. Read the current ticket and
2103
+ reapply the intended changes before retrying; conflicts are never merged.
2104
+
2105
+ File and Linear revisions fingerprint title, body, canonical label/blocker sets
2106
+ and state; reverting identical content restores its fingerprint. File checks
2107
+ re-read immediately before writing, with the existing no-lock race window.
2108
+ Direct file edits and state-directory moves change revisions. File revisions also
2109
+ cover structured comments and portable attachments. Legacy file comments change
2110
+ the body; structured comments preserve it. Separate hosted comments do not change
2111
+ ticket revisions. Hosted revisions
2112
+ are monotonic decimal bigint strings, and checks run under the same row lock
2113
+ and transaction as all requested changes. Hosted reads return a coherent
2114
+ snapshot of fields, blockers and revision.
2115
+
2116
+ Read before writing, and copy the opaque revision verbatim:
2117
+
2118
+ ```sh
2119
+ ab ticket show AUT-8 --json
2120
+ ab ticket update AUT-8 --title 'Revised title' --expected-revision '<revision from show>'
2121
+ ab ticket edit AUT-8
2122
+ ```
2123
+
2124
+ Use `expectedRevision` on MCP `tickets.update`, `tickets.move`, `tickets.block`
2125
+ and `tickets.unblock` with the revision from `tickets.get` or `tickets.list`.
@@ -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.
@@ -87,6 +87,7 @@ turn runner (a later ticket) is the only producer of turn facts.
87
87
  | `GET …/harvest/status` | Harvest gate, runs, steps, recovery, and attention projection. |
88
88
  | `GET …/builds/{slug}/artifacts/{kind}?rev=N` | Raw bytes (`application/octet-stream`) plus content-disposition and `X-Autobuild-Artifact-*` metadata headers. Omit `rev` for latest. |
89
89
  | `GET …/tickets?state=S&label=L` | `{states,tickets,diagnostics,criteria,triageState,readyState}`. The two lifecycle names come from effective repository configuration (including provider fallback). With no `state`, `criteria.state` is `triageState`; repeated `label` parameters narrow that state with AND filters. An explicit `state` selects that exact backend workflow state. |
90
+ | `GET …/tickets/states` | Full state metadata `{name,tickets,about?}[]`, including empty states (`tickets.states` in MCP). |
90
91
  | `GET …/tickets/{id}` | `{ticket,blockers,build}`. Blockers include native `exists`/`resolved` status; `build` prefers an active matching repository build, otherwise the most recently updated one. |
91
92
 
92
93
  For artifact reads, an absent `rev` selects the latest revision. A supplied value
@@ -119,6 +120,7 @@ JSON bodies are strict: unknown fields are rejected.
119
120
  | `POST …/settings/auto-merge-default/toggle` | Empty body. |
120
121
  | `POST …/bulk-control` | `{"action":"pause"\|"resume"}`. The hold fact, intake fact, then eligible build events are written in that order. |
121
122
  | `POST …/harvest/control` | `{"action":"toggle-gate"}` or `{"action":"run","run":"…"}`. A run action is bound to that concrete projected run. |
123
+ | `POST …/tickets/states` | `{name,about?}` → `{state,created}`; requires attributed operator identity (`tickets.state.create` in MCP). Hosted duplicate names are refused case-insensitively. |
122
124
  | `POST …/tickets` | `{"title":"…","body":"…","labels"?:[],"state"?:"…","blockedBy"?:[]}`; omitted state preserves the backend default. |
123
125
  | `PATCH …/tickets/{id}` | Any nonempty subset of `title`, `body`, and complete-replacement `labels`. Body bytes are not normalized. |
124
126
  | `POST …/tickets/{id}/move` | `{"state":"backend state name"}`. |
@@ -284,3 +286,8 @@ even with `--since`; live and backlog matches ignore routine outcomes and
284
286
  self-healing failures. A merge match uses the watch observation with
285
287
  `condition: "attention"`. Explicit `--event` globs remain event-only.
286
288
  Watch and wait preserve each other's cursor notification metadata.
289
+
290
+ Hosted ticket tools resolve independent project ticket policy before legacy uploaded
291
+ configuration. Queue, detail, state discovery and mutations work before any build
292
+ or dispatcher run; detail reports a null matching build. Ticket policy publication
293
+ never creates synthetic build or repository history.