@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.
- package/README.md +1 -1
- package/SPEC.md +19 -1
- package/bin/ab-dispatch-kernel.ts +4 -6
- package/docs/architecture.md +96 -70
- package/docs/hosted-service.md +2 -2
- package/docs/operator-api.md +1 -1
- package/docs/remote-store-protocol.md +84 -2
- package/package.json +4 -3
- package/packages/core/src/cli/attention.ts +18 -164
- package/packages/core/src/cli/build-control.ts +11 -15
- package/packages/core/src/cli/bulk-control.ts +3 -2
- package/packages/core/src/cli/context.ts +16 -78
- package/packages/core/src/cli/dashboard/model.ts +36 -22
- package/packages/core/src/cli/dispatch-frontend.ts +24 -67
- package/packages/core/src/cli/dispatch-process.ts +14 -7
- package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
- package/packages/core/src/cli/dispatch.ts +148 -79
- package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
- package/packages/core/src/cli/harvest.ts +71 -39
- package/packages/core/src/cli/historical-build-state.ts +27 -0
- package/packages/core/src/cli/main.ts +3 -3
- package/packages/core/src/cli/pr-attachments.ts +10 -13
- package/packages/core/src/cli/pr-summary-publication.ts +27 -60
- package/packages/core/src/cli/pr-summary.ts +43 -13
- package/packages/core/src/cli/publication-settlement.ts +10 -20
- package/packages/core/src/cli/repository-status.ts +38 -2
- package/packages/core/src/cli/status.ts +62 -32
- package/packages/core/src/cli/terminals.ts +25 -142
- package/packages/core/src/cli/ticket.ts +2 -2
- package/packages/core/src/cli/wait.ts +91 -84
- package/packages/core/src/cli/watch.ts +60 -97
- package/packages/core/src/config/pipeline-source.ts +4 -3
- package/packages/core/src/events/repository.ts +7 -3
- package/packages/core/src/integration/harness.ts +1 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2429 -9
- package/packages/core/src/kernel/auto-merge.ts +19 -3
- package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
- package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
- package/packages/core/src/kernel/harvest.ts +25 -23
- package/packages/core/src/kernel/log-index-value.ts +38 -0
- package/packages/core/src/kernel/read-facts.ts +637 -0
- package/packages/core/src/kernel/reducer-registry.ts +137 -3
- package/packages/core/src/kernel/repository-attention.ts +179 -0
- package/packages/core/src/kernel/service-facts.ts +135 -0
- package/packages/core/src/kernel/terminal-guard.ts +133 -0
- package/packages/core/src/operator/control.ts +5 -6
- package/packages/core/src/operator/index.ts +2 -0
- package/packages/core/src/operator/query.ts +37 -20
- package/packages/core/src/operator/read-view.ts +4 -13
- package/packages/core/src/operator/requests.ts +2 -14
- package/packages/core/src/operator/sandbox.ts +10 -9
- package/packages/core/src/operator/tickets.ts +2 -2
- package/packages/core/src/plugin-sdk/index.ts +13 -0
- package/packages/core/src/processes/build-child.ts +6 -6
- package/packages/core/src/processes/build-runner.ts +62 -70
- package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
- package/packages/core/src/processes/dispatcher.ts +113 -173
- package/packages/core/src/processes/harvest-execution-state.ts +15 -9
- package/packages/core/src/processes/harvest-runner.ts +34 -70
- package/packages/core/src/processes/harvest.ts +38 -15
- package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
- package/packages/core/src/processes/orchestrator-tick.ts +2 -2
- package/packages/core/src/processes/publication-loss.ts +35 -21
- package/packages/core/src/processes/repo-view.ts +229 -1134
- package/packages/core/src/processes/tick-publication.ts +110 -0
- package/packages/core/src/store/adapter.ts +23 -0
- package/packages/core/src/store/build-scope.ts +17 -0
- package/packages/core/src/store/contract.ts +251 -0
- package/packages/core/src/store/local/schema.ts +1 -0
- package/packages/core/src/store/local/store.ts +179 -54
- package/packages/core/src/store/memory.ts +119 -29
- package/packages/core/src/store/phase-session.ts +17 -0
- package/packages/core/src/store/reduced-reads.ts +239 -0
- package/packages/core/src/store/reducer-value-shapes.json +14056 -0
- package/packages/core/src/store/reducer-value-validation.ts +96 -0
- package/packages/core/src/store/reducer-values.ts +145 -0
- package/packages/core/src/store/remote/client.ts +41 -0
- package/packages/core/src/store/remote/compatibility.ts +2 -0
- package/packages/core/src/store/remote/index.ts +19 -0
- package/packages/core/src/store/remote/protocol.ts +16 -0
- package/packages/core/src/store/remote/version.ts +3 -1
- package/packages/core/src/store/repo-state-events.ts +1 -1
- package/packages/core/src/store/repo-tick.ts +72 -0
- package/packages/core/src/store/session-handle.ts +19 -0
- package/packages/core/src/store/snapshots.ts +10 -0
- package/packages/core/src/store/ticket-assets.ts +4 -4
- package/packages/core/src/store/types.ts +39 -2
- package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
- package/packages/core/src/testing/index.ts +8 -0
- package/packages/core/src/testing/reduced-read-budget.ts +462 -0
- package/skills/guide/SKILL.md +6 -1
- package/skills/guide/references/operator-api.md +1 -1
- package/skills/guide/references/remote-store-protocol.md +84 -2
- 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
|
+
}
|
package/skills/guide/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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.
|