@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
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
import { terminalGuardReducer } from './terminal-guard'
|
|
2
|
+
import { repositoryAttentionReducer } from './repository-attention'
|
|
3
|
+
import {
|
|
4
|
+
dispatcherRunFactsReducer,
|
|
5
|
+
buildStreamSeedReducer,
|
|
6
|
+
buildReadFactsReducer,
|
|
7
|
+
repositoryReadFactsReducer,
|
|
8
|
+
optionalValue,
|
|
9
|
+
} from './read-facts'
|
|
10
|
+
import { validateAccumulator, encodeReducerValue } from '../store/reducer-value-validation'
|
|
11
|
+
import {
|
|
12
|
+
effectiveConfigReducer,
|
|
13
|
+
buildDelegationReducer,
|
|
14
|
+
harvestDelegationReducer,
|
|
15
|
+
} from './service-facts'
|
|
16
|
+
import { phaseSchema } from '../ontology'
|
|
17
|
+
import { z } from 'zod'
|
|
18
|
+
import type { ValueCatalogEntry } from '../store/reducer-values'
|
|
19
|
+
import { dispatchFrontendReducer } from '../cli/dispatch-repository-feed'
|
|
1
20
|
import { executionNoProgressReducer } from '../processes/execution-no-progress'
|
|
2
21
|
import { summaryBoundaryReducer, summaryRequestsReducer } from './pr-summary'
|
|
3
22
|
import { dispatchFailureBudgetReducer } from '../processes/dispatch-failure-budget'
|
|
@@ -52,6 +71,7 @@ import {
|
|
|
52
71
|
import { buildReducer } from './reducer'
|
|
53
72
|
|
|
54
73
|
export interface RegisteredReducer {
|
|
74
|
+
valueRead: ValueCatalogEntry | { excluded: string }
|
|
55
75
|
// biome-ignore lint/suspicious/noExplicitAny: the registry is heterogeneous
|
|
56
76
|
reducer: IncrementalReducer<any, any, any>
|
|
57
77
|
/** The whole-array functions (or private methods) this reducer backs. */
|
|
@@ -60,7 +80,24 @@ export interface RegisteredReducer {
|
|
|
60
80
|
|
|
61
81
|
/** Parameterized reducers are registered with representative arguments; the
|
|
62
82
|
* accumulator shape does not depend on them. */
|
|
63
|
-
|
|
83
|
+
const REGISTERED: Record<string, Omit<RegisteredReducer, 'valueRead'>> = {
|
|
84
|
+
baseBranchValue: { reducer: optionalValue(baseBranchReducer), covers: [] },
|
|
85
|
+
publishedBranchHeadValue: { reducer: optionalValue(publishedBranchHeadReducer), covers: [] },
|
|
86
|
+
verifyDiffBaseValue: { reducer: optionalValue(verifyDiffBaseReducer), covers: [] },
|
|
87
|
+
recordedBaseShaValue: { reducer: optionalValue(recordedBaseShaReducer), covers: [] },
|
|
88
|
+
terminalGuard: {
|
|
89
|
+
reducer: terminalGuardReducer({ phase: 'plan', round: 1, session: 'session' }),
|
|
90
|
+
covers: [],
|
|
91
|
+
},
|
|
92
|
+
dispatcherRunFacts: { reducer: dispatcherRunFactsReducer('run-a'), covers: [] },
|
|
93
|
+
repositoryAttention: { reducer: repositoryAttentionReducer, covers: [] },
|
|
94
|
+
repositoryReadFacts: { reducer: repositoryReadFactsReducer, covers: [] },
|
|
95
|
+
buildStreamSeed: { reducer: buildStreamSeedReducer, covers: [] },
|
|
96
|
+
buildReadFacts: { reducer: buildReadFactsReducer, covers: [] },
|
|
97
|
+
effectiveConfig: { reducer: effectiveConfigReducer, covers: [] },
|
|
98
|
+
buildDelegation: { reducer: buildDelegationReducer, covers: [] },
|
|
99
|
+
harvestDelegation: { reducer: harvestDelegationReducer, covers: [] },
|
|
100
|
+
dispatchFrontend: { reducer: dispatchFrontendReducer, covers: [] },
|
|
64
101
|
executionNoProgress: { reducer: executionNoProgressReducer, covers: [] },
|
|
65
102
|
summaryBoundary: { reducer: summaryBoundaryReducer, covers: ['summaryBoundary'] },
|
|
66
103
|
summaryRequests: { reducer: summaryRequestsReducer, covers: ['pendingSummaryRequests'] },
|
|
@@ -122,9 +159,9 @@ export const REDUCERS: Record<string, RegisteredReducer> = {
|
|
|
122
159
|
phaseFailures: { reducer: phaseFailuresReducer('plan', 1), covers: ['phaseFailures'] },
|
|
123
160
|
openBuildWorkspace: {
|
|
124
161
|
reducer: openBuildWorkspaceReducer,
|
|
125
|
-
covers: ['openBuildWorkspace'
|
|
162
|
+
covers: ['openBuildWorkspace'],
|
|
126
163
|
},
|
|
127
|
-
baseBranch: { reducer: baseBranchReducer, covers: [
|
|
164
|
+
baseBranch: { reducer: baseBranchReducer, covers: [] },
|
|
128
165
|
provisionMarker: { reducer: provisionMarkerReducer, covers: ['openProvisionMarker'] },
|
|
129
166
|
recoveryCheckpoint: { reducer: recoveryCheckpointReducer, covers: ['recoveryCheckpoint'] },
|
|
130
167
|
currentDeferralObservation: {
|
|
@@ -143,9 +180,106 @@ export const REDUCERS: Record<string, RegisteredReducer> = {
|
|
|
143
180
|
pinnedAssets: { reducer: pinnedAssetsReducer, covers: ['findPinnedRevision'] },
|
|
144
181
|
}
|
|
145
182
|
|
|
183
|
+
/** Qualification concerns the existing finished contract, including absence. */
|
|
184
|
+
const valueExclusions: Record<string, string> = {
|
|
185
|
+
dispatchFrontend: 'returns a list of raw repository journal events',
|
|
186
|
+
journalView: 'returns a list of raw repository journal events',
|
|
187
|
+
repositoryStateEvents: 'returns a list of raw repository journal events',
|
|
188
|
+
session: 'consumes operator-session logs rather than build or repository events',
|
|
189
|
+
publicationState: 'finished value contains executable selector functions',
|
|
190
|
+
logIndex: 'finished value contains Maps and Sets',
|
|
191
|
+
hostedPrAttachments: 'finished value is a Map',
|
|
192
|
+
...Object.fromEntries(
|
|
193
|
+
[
|
|
194
|
+
'summaryBoundary',
|
|
195
|
+
'ticketSourceHealth',
|
|
196
|
+
'frozenPrImageHost',
|
|
197
|
+
'publishedBranchHead',
|
|
198
|
+
'verifyDiffBase',
|
|
199
|
+
'recordedBaseSha',
|
|
200
|
+
'baseBranch',
|
|
201
|
+
'provisionMarker',
|
|
202
|
+
'recoveryCheckpoint',
|
|
203
|
+
].map((name) => [name, 'finished contract can return top-level undefined, which is not JSON']),
|
|
204
|
+
),
|
|
205
|
+
}
|
|
206
|
+
const repositoryValues = new Set([
|
|
207
|
+
'repositoryReadFacts',
|
|
208
|
+
'dispatcherRunFacts',
|
|
209
|
+
'repositoryAttention',
|
|
210
|
+
'effectiveConfig',
|
|
211
|
+
'harvestDelegation',
|
|
212
|
+
'harvest',
|
|
213
|
+
'dispatchSettings',
|
|
214
|
+
'dispatchStatus',
|
|
215
|
+
'sandboxStates',
|
|
216
|
+
'openHarvestExecutions',
|
|
217
|
+
'openTick',
|
|
218
|
+
'autoMergeDefault',
|
|
219
|
+
])
|
|
220
|
+
const noArgs = z.object({}).strict()
|
|
221
|
+
const parameterized: Record<string, Pick<ValueCatalogEntry, 'args' | 'create'>> = {
|
|
222
|
+
terminalGuard: {
|
|
223
|
+
args: z
|
|
224
|
+
.object({ phase: phaseSchema, round: z.number().int(), session: z.string().min(1) })
|
|
225
|
+
.strict(),
|
|
226
|
+
create: (args) => terminalGuardReducer(args),
|
|
227
|
+
},
|
|
228
|
+
dispatchStatus: {
|
|
229
|
+
args: z.object({ run: z.string().min(1) }).strict(),
|
|
230
|
+
create: ({ run }) => dispatchStatusReducer(run),
|
|
231
|
+
},
|
|
232
|
+
dispatcherRunFacts: {
|
|
233
|
+
args: z.object({ run: z.string().min(1) }).strict(),
|
|
234
|
+
create: ({ run }) => dispatcherRunFactsReducer(run),
|
|
235
|
+
},
|
|
236
|
+
openTick: {
|
|
237
|
+
args: z.object({ run: z.string().min(1) }).strict(),
|
|
238
|
+
create: ({ run }) => openTickReducer(run),
|
|
239
|
+
},
|
|
240
|
+
dispatchFailureBudget: {
|
|
241
|
+
args: z.object({ infrastructureOwnsWorkspace: z.boolean() }).strict(),
|
|
242
|
+
create: ({ infrastructureOwnsWorkspace }) =>
|
|
243
|
+
dispatchFailureBudgetReducer(infrastructureOwnsWorkspace),
|
|
244
|
+
},
|
|
245
|
+
phaseFailures: {
|
|
246
|
+
args: z.object({ phase: phaseSchema, round: z.number().int().nonnegative() }).strict(),
|
|
247
|
+
create: ({ phase, round }) => phaseFailuresReducer(phase, round),
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
export const REDUCERS: Record<string, RegisteredReducer> = Object.fromEntries(
|
|
251
|
+
Object.entries(REGISTERED).map(([name, entry]) => [
|
|
252
|
+
name,
|
|
253
|
+
{
|
|
254
|
+
...entry,
|
|
255
|
+
valueRead: valueExclusions[name]
|
|
256
|
+
? { excluded: valueExclusions[name] }
|
|
257
|
+
: {
|
|
258
|
+
scope: repositoryValues.has(name) ? 'repo' : 'build',
|
|
259
|
+
args: parameterized[name]?.args ?? noArgs,
|
|
260
|
+
validate: (state: unknown) => validateAccumulator(name, state),
|
|
261
|
+
value: encodeReducerValue,
|
|
262
|
+
adapt: (events: Parameters<ValueCatalogEntry['adapt']>[0]) =>
|
|
263
|
+
name === 'buildDigest'
|
|
264
|
+
? events.map((event) => ({
|
|
265
|
+
type: event.type,
|
|
266
|
+
seq: event.seq,
|
|
267
|
+
ts: event.ts,
|
|
268
|
+
...('hostedSeq' in event.payload ? { hostedSeq: event.payload.hostedSeq } : {}),
|
|
269
|
+
...('instance' in event.payload ? { instance: event.payload.instance } : {}),
|
|
270
|
+
}))
|
|
271
|
+
: events,
|
|
272
|
+
create: parameterized[name]?.create ?? (() => entry.reducer),
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
]),
|
|
276
|
+
)
|
|
277
|
+
|
|
146
278
|
/** Event-array functions that are deliberately not reducers, each with the
|
|
147
279
|
* concrete reason. */
|
|
148
280
|
export const EXCLUDED: Record<string, string> = {
|
|
281
|
+
shouldPublishTick:
|
|
282
|
+
'Publication decision over a new report and actual start time plus the bounded recorded journal; no event-only reduction can decide whether an unrecorded tick has effects.',
|
|
149
283
|
projectDispatcherLiveness:
|
|
150
284
|
'combines mutable repository lease metadata and an explicit clock with correlated facts; cannot be restored from an event-only snapshot',
|
|
151
285
|
decideNext: 'a decision function over config; returns a Decision, not a state',
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { RepositoryEvent } from '../events/repository'
|
|
2
|
+
import { defineReducer } from './incremental'
|
|
3
|
+
type Degraded = Extract<RepositoryEvent, { type: 'dispatcher.ticket-source-degraded' }>
|
|
4
|
+
type Failed = Extract<RepositoryEvent, { type: 'dispatcher.tick-failed' }>
|
|
5
|
+
export type PersistenceObservation =
|
|
6
|
+
| {
|
|
7
|
+
type: 'dispatcher.ticket-source-persistent'
|
|
8
|
+
source: string
|
|
9
|
+
cause: string
|
|
10
|
+
message: string
|
|
11
|
+
resetAt?: string
|
|
12
|
+
episodeSeq: number
|
|
13
|
+
observedAt: string
|
|
14
|
+
dependencyLimited: boolean
|
|
15
|
+
}
|
|
16
|
+
| {
|
|
17
|
+
type: 'dispatcher.tick-failure-persistent'
|
|
18
|
+
run: string
|
|
19
|
+
firstFailure: string
|
|
20
|
+
error: string
|
|
21
|
+
episodeSeq: number
|
|
22
|
+
observedAt: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SourceAttention {
|
|
26
|
+
event: Degraded
|
|
27
|
+
deadline: number
|
|
28
|
+
dependencyLimited: boolean
|
|
29
|
+
requiresRetry: boolean
|
|
30
|
+
retryObserved: boolean
|
|
31
|
+
}
|
|
32
|
+
export interface RepositoryAttentionState {
|
|
33
|
+
sources: [string, SourceAttention][]
|
|
34
|
+
failure?: Failed
|
|
35
|
+
tickStarts: [string, number][]
|
|
36
|
+
}
|
|
37
|
+
/** Incremental journal projection. Episode starts survive run anchors and
|
|
38
|
+
* repeated evidence can strengthen, but never postpone, an existing deadline. */
|
|
39
|
+
export class RepositoryAttention {
|
|
40
|
+
private sources: Map<string, SourceAttention>
|
|
41
|
+
private failure: Failed | undefined
|
|
42
|
+
private tickStarts: Map<string, number>
|
|
43
|
+
constructor(state?: RepositoryAttentionState) {
|
|
44
|
+
this.sources = new Map(state?.sources ?? [])
|
|
45
|
+
this.failure = state?.failure
|
|
46
|
+
this.tickStarts = new Map(state?.tickStarts ?? [])
|
|
47
|
+
}
|
|
48
|
+
snapshot(): RepositoryAttentionState {
|
|
49
|
+
return structuredClone({
|
|
50
|
+
sources: [...this.sources],
|
|
51
|
+
failure: this.failure,
|
|
52
|
+
tickStarts: [...this.tickStarts],
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private sourceDefersTick(now: number): boolean {
|
|
57
|
+
return [...this.sources.values()].some(
|
|
58
|
+
(source) => now < source.deadline || (source.requiresRetry && !source.retryObserved),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Crossing resetAt alone does not prove persistence: the dispatcher must
|
|
63
|
+
* finish a tick that could actually retry after the provider cooldown. */
|
|
64
|
+
private observeRetry(run: string): void {
|
|
65
|
+
const startedAt = this.tickStarts.get(run)
|
|
66
|
+
this.tickStarts.delete(run)
|
|
67
|
+
if (startedAt === undefined) return
|
|
68
|
+
for (const source of this.sources.values()) {
|
|
69
|
+
if (startedAt >= source.deadline) source.retryObserved = true
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
fold(event: RepositoryEvent): void {
|
|
73
|
+
switch (event.type) {
|
|
74
|
+
case 'dispatcher.ticket-source-degraded': {
|
|
75
|
+
const existing = this.sources.get(event.payload.source)
|
|
76
|
+
const deadline =
|
|
77
|
+
'resetAt' in event.payload
|
|
78
|
+
? Date.parse(event.payload.resetAt)
|
|
79
|
+
: Date.parse(event.ts) + 15 * 60_000
|
|
80
|
+
if (existing) {
|
|
81
|
+
existing.deadline = Math.min(existing.deadline, deadline)
|
|
82
|
+
existing.requiresRetry ||= 'resetAt' in event.payload
|
|
83
|
+
} else
|
|
84
|
+
this.sources.set(event.payload.source, {
|
|
85
|
+
event,
|
|
86
|
+
deadline,
|
|
87
|
+
dependencyLimited: false,
|
|
88
|
+
requiresRetry: 'resetAt' in event.payload,
|
|
89
|
+
retryObserved: false,
|
|
90
|
+
})
|
|
91
|
+
// Cooldown-driven failures are not an independent dispatcher outage.
|
|
92
|
+
if (this.sourceDefersTick(Date.parse(event.ts))) this.failure = undefined
|
|
93
|
+
break
|
|
94
|
+
}
|
|
95
|
+
case 'dispatcher.ticket-source-dependency-limited': {
|
|
96
|
+
const source = this.sources.get(event.payload.source)
|
|
97
|
+
if (source) source.dependencyLimited = true
|
|
98
|
+
break
|
|
99
|
+
}
|
|
100
|
+
case 'dispatcher.ticket-source-recovered':
|
|
101
|
+
this.sources.delete(event.payload.source)
|
|
102
|
+
break
|
|
103
|
+
case 'dispatcher.run-started':
|
|
104
|
+
this.failure = undefined
|
|
105
|
+
this.tickStarts.clear()
|
|
106
|
+
break
|
|
107
|
+
case 'dispatcher.run-stopped':
|
|
108
|
+
this.tickStarts.delete(event.payload.run)
|
|
109
|
+
if (this.failure?.payload.run === event.payload.run) this.failure = undefined
|
|
110
|
+
break
|
|
111
|
+
case 'dispatcher.tick-started':
|
|
112
|
+
this.tickStarts.set(event.payload.run, Date.parse(event.payload.startedAt ?? event.ts))
|
|
113
|
+
break
|
|
114
|
+
case 'dispatcher.tick-completed':
|
|
115
|
+
this.observeRetry(event.payload.run)
|
|
116
|
+
if (this.failure?.payload.run === event.payload.run) this.failure = undefined
|
|
117
|
+
break
|
|
118
|
+
case 'dispatcher.tick-failed':
|
|
119
|
+
this.observeRetry(event.payload.run)
|
|
120
|
+
if (this.sourceDefersTick(Date.parse(event.ts))) this.failure = undefined
|
|
121
|
+
else if (this.failure?.payload.run !== event.payload.run) this.failure = event
|
|
122
|
+
break
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
pending(now: Date): { identity: string; observation: PersistenceObservation }[] {
|
|
126
|
+
const results: { identity: string; observation: PersistenceObservation }[] = []
|
|
127
|
+
for (const {
|
|
128
|
+
event,
|
|
129
|
+
deadline,
|
|
130
|
+
dependencyLimited,
|
|
131
|
+
requiresRetry,
|
|
132
|
+
retryObserved,
|
|
133
|
+
} of this.sources.values()) {
|
|
134
|
+
if (now.getTime() < deadline || (requiresRetry && !retryObserved)) continue
|
|
135
|
+
results.push({
|
|
136
|
+
identity: `source:${event.seq}`,
|
|
137
|
+
observation: {
|
|
138
|
+
type: 'dispatcher.ticket-source-persistent',
|
|
139
|
+
...event.payload,
|
|
140
|
+
episodeSeq: event.seq,
|
|
141
|
+
observedAt: now.toISOString(),
|
|
142
|
+
dependencyLimited,
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
const event = this.failure
|
|
147
|
+
if (
|
|
148
|
+
event &&
|
|
149
|
+
!this.sourceDefersTick(now.getTime()) &&
|
|
150
|
+
now.getTime() >= Date.parse(event.ts) + 5 * 60_000
|
|
151
|
+
)
|
|
152
|
+
results.push({
|
|
153
|
+
identity: `tick:${event.seq}`,
|
|
154
|
+
observation: {
|
|
155
|
+
type: 'dispatcher.tick-failure-persistent',
|
|
156
|
+
...event.payload,
|
|
157
|
+
firstFailure: event.ts,
|
|
158
|
+
episodeSeq: event.seq,
|
|
159
|
+
observedAt: now.toISOString(),
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
return results
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const repositoryAttentionReducer = defineReducer<
|
|
167
|
+
RepositoryAttentionState,
|
|
168
|
+
RepositoryEvent,
|
|
169
|
+
RepositoryAttentionState
|
|
170
|
+
>({
|
|
171
|
+
version: 1,
|
|
172
|
+
initial: () => ({ sources: [], tickStarts: [] }),
|
|
173
|
+
fold(acc, events) {
|
|
174
|
+
const attention = new RepositoryAttention(acc)
|
|
175
|
+
for (const event of events) attention.fold(event)
|
|
176
|
+
Object.assign(acc, attention.snapshot())
|
|
177
|
+
},
|
|
178
|
+
finish: (acc) => structuredClone(acc),
|
|
179
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** Small semantic projections used by stateless service requests. */
|
|
2
|
+
import type { AbEvent } from '../events/catalog'
|
|
3
|
+
import type { RepositoryEvent } from '../events/repository'
|
|
4
|
+
import type { ArtifactRef } from '../ontology'
|
|
5
|
+
import { defineReducer } from './incremental'
|
|
6
|
+
|
|
7
|
+
export const effectiveConfigReducer = defineReducer<
|
|
8
|
+
{ run?: string; reference?: ArtifactRef },
|
|
9
|
+
RepositoryEvent,
|
|
10
|
+
{ run?: string; reference?: ArtifactRef }
|
|
11
|
+
>({
|
|
12
|
+
version: 1,
|
|
13
|
+
initial: () => ({}),
|
|
14
|
+
fold(acc, events) {
|
|
15
|
+
for (const event of events) {
|
|
16
|
+
if (event.type === 'dispatcher.run-started') {
|
|
17
|
+
acc.run = event.payload.run
|
|
18
|
+
acc.reference = event.payload.effectiveConfig
|
|
19
|
+
} else if (
|
|
20
|
+
event.type === 'dispatcher.config-reloaded' &&
|
|
21
|
+
event.payload.run === acc.run &&
|
|
22
|
+
event.payload.effectiveConfig !== undefined
|
|
23
|
+
) {
|
|
24
|
+
acc.reference = event.payload.effectiveConfig
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
finish: (acc) => ({ ...acc }),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export function delegationFactKey(id: string): string {
|
|
32
|
+
return `id:${id}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface EndingFact {
|
|
36
|
+
seq: number
|
|
37
|
+
at: string
|
|
38
|
+
}
|
|
39
|
+
export interface SessionFacts {
|
|
40
|
+
starts: number[]
|
|
41
|
+
endings: EndingFact[]
|
|
42
|
+
}
|
|
43
|
+
export interface BuildDelegationFacts {
|
|
44
|
+
lastSeq: number
|
|
45
|
+
sessions: Record<string, SessionFacts>
|
|
46
|
+
terminals: EndingFact[]
|
|
47
|
+
}
|
|
48
|
+
export interface HarvestDelegationFacts {
|
|
49
|
+
lastSeq: number
|
|
50
|
+
runs: Record<string, true>
|
|
51
|
+
sessions: Record<string, Record<string, SessionFacts>>
|
|
52
|
+
authorizations: Record<string, Record<string, { seq: number; leaseHolder: string }>>
|
|
53
|
+
releases: Record<string, string[]>
|
|
54
|
+
endings: Record<string, EndingFact[]>
|
|
55
|
+
}
|
|
56
|
+
/** Only lifecycle facts are retained. Contiguous canonical logs make every seq
|
|
57
|
+
* from 1 through lastSeq a valid anchor without one entry per dispatcher tick. */
|
|
58
|
+
export const buildDelegationReducer = defineReducer<
|
|
59
|
+
BuildDelegationFacts,
|
|
60
|
+
AbEvent,
|
|
61
|
+
BuildDelegationFacts
|
|
62
|
+
>({
|
|
63
|
+
version: 1,
|
|
64
|
+
initial: () => ({ lastSeq: 0, sessions: {}, terminals: [] }),
|
|
65
|
+
fold(acc, events) {
|
|
66
|
+
for (const event of events) {
|
|
67
|
+
acc.lastSeq = event.seq
|
|
68
|
+
if (event.type === 'session.started' || event.type === 'session.ended') {
|
|
69
|
+
const key = delegationFactKey(event.payload.session)
|
|
70
|
+
acc.sessions[key] ??= { starts: [], endings: [] }
|
|
71
|
+
const session = acc.sessions[key]!
|
|
72
|
+
if (event.type === 'session.started') session.starts.push(event.seq)
|
|
73
|
+
else session.endings.push({ seq: event.seq, at: event.ts })
|
|
74
|
+
} else if (event.type === 'build.completed' || event.type === 'build.aborted')
|
|
75
|
+
acc.terminals.push({ seq: event.seq, at: event.ts })
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
finish: (acc) => structuredClone(acc),
|
|
79
|
+
})
|
|
80
|
+
export const harvestDelegationReducer = defineReducer<
|
|
81
|
+
HarvestDelegationFacts,
|
|
82
|
+
RepositoryEvent,
|
|
83
|
+
HarvestDelegationFacts
|
|
84
|
+
>({
|
|
85
|
+
version: 1,
|
|
86
|
+
initial: () => ({
|
|
87
|
+
lastSeq: 0,
|
|
88
|
+
runs: {},
|
|
89
|
+
sessions: {},
|
|
90
|
+
authorizations: {},
|
|
91
|
+
releases: {},
|
|
92
|
+
endings: {},
|
|
93
|
+
}),
|
|
94
|
+
fold(acc, events) {
|
|
95
|
+
for (const event of events) {
|
|
96
|
+
acc.lastSeq = event.seq
|
|
97
|
+
if (event.type === 'harvest.started') acc.runs[delegationFactKey(event.payload.run)] = true
|
|
98
|
+
if (event.type === 'harvest.execution.authorized') {
|
|
99
|
+
const key = delegationFactKey(event.payload.run)
|
|
100
|
+
acc.authorizations[key] ??= {}
|
|
101
|
+
const run = acc.authorizations[key]!
|
|
102
|
+
run[delegationFactKey(event.payload.execution)] ??= {
|
|
103
|
+
seq: event.seq,
|
|
104
|
+
leaseHolder: event.payload.leaseHolder,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (event.type === 'harvest.session.started' || event.type === 'harvest.session.ended') {
|
|
108
|
+
const runKey = delegationFactKey(event.payload.run)
|
|
109
|
+
acc.sessions[runKey] ??= {}
|
|
110
|
+
const run = acc.sessions[runKey]!
|
|
111
|
+
const sessionKey = delegationFactKey(event.payload.session)
|
|
112
|
+
run[sessionKey] ??= { starts: [], endings: [] }
|
|
113
|
+
const session = run[sessionKey]!
|
|
114
|
+
if (event.type === 'harvest.session.started') session.starts.push(event.seq)
|
|
115
|
+
else session.endings.push({ seq: event.seq, at: event.ts })
|
|
116
|
+
}
|
|
117
|
+
if (event.type === 'harvest.execution.released' || event.type === 'harvest.execution.ended') {
|
|
118
|
+
const key = delegationFactKey(event.payload.execution)
|
|
119
|
+
acc.releases[key] ??= []
|
|
120
|
+
acc.releases[key]!.push(event.ts)
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
event.type === 'harvest.completed' ||
|
|
124
|
+
event.type === 'harvest.escalated' ||
|
|
125
|
+
event.type === 'harvest.recovery-exhausted' ||
|
|
126
|
+
(event.type === 'harvest.failed' && !event.payload.willRetry)
|
|
127
|
+
) {
|
|
128
|
+
const key = delegationFactKey(event.payload.run)
|
|
129
|
+
acc.endings[key] ??= []
|
|
130
|
+
acc.endings[key]!.push({ seq: event.seq, at: event.ts })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
finish: (acc) => structuredClone(acc),
|
|
135
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { AbEvent } from '../events/catalog'
|
|
2
|
+
import type { Phase } from '../ontology'
|
|
3
|
+
import { isVerifyPhase, verifyStep } from '../ontology'
|
|
4
|
+
import { phaseSpecFor } from './phases'
|
|
5
|
+
import { defineReducer } from './incremental'
|
|
6
|
+
type CliEnv = { phase: Phase; round: number; session: string }
|
|
7
|
+
interface TerminalGuardAcc {
|
|
8
|
+
reconcileAttempt: number
|
|
9
|
+
sessionStartedSeq?: number
|
|
10
|
+
permanent?: { seq: number; diagnostic: string }
|
|
11
|
+
completion?: { seq: number; diagnostic: string }
|
|
12
|
+
}
|
|
13
|
+
export function terminalGuardReducer(env: CliEnv) {
|
|
14
|
+
const spec = phaseSpecFor(env.phase)
|
|
15
|
+
return defineReducer<TerminalGuardAcc, AbEvent, { diagnostic?: string }>({
|
|
16
|
+
version: 1,
|
|
17
|
+
initial: () => ({ reconcileAttempt: 0 }),
|
|
18
|
+
fold(acc, events) {
|
|
19
|
+
for (const event of events) {
|
|
20
|
+
if (event.type === 'spec.revised' && (isVerifyPhase(env.phase) || env.phase === 'finalize'))
|
|
21
|
+
delete acc.completion
|
|
22
|
+
if (event.type === 'reconcile.started') acc.reconcileAttempt = event.payload.attempt
|
|
23
|
+
if (event.type === 'session.started' && event.payload.session === env.session)
|
|
24
|
+
acc.sessionStartedSeq = event.seq
|
|
25
|
+
try {
|
|
26
|
+
checkEvent(event, env, acc.reconcileAttempt, acc.sessionStartedSeq)
|
|
27
|
+
} catch (error) {
|
|
28
|
+
const key = event.type === spec.terminalEvent ? 'completion' : 'permanent'
|
|
29
|
+
acc[key] ??= { seq: event.seq, diagnostic: (error as Error).message }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
finish(acc) {
|
|
34
|
+
const first = [acc.permanent, acc.completion]
|
|
35
|
+
.filter((item) => item !== undefined)
|
|
36
|
+
.sort((a, b) => a.seq - b.seq)[0]
|
|
37
|
+
return first ? { diagnostic: first.diagnostic } : {}
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
function checkEvent(
|
|
42
|
+
event: AbEvent,
|
|
43
|
+
env: CliEnv,
|
|
44
|
+
reconcileAttempt: number,
|
|
45
|
+
sessionStartedSeq?: number,
|
|
46
|
+
): void {
|
|
47
|
+
const spec = phaseSpecFor(env.phase)
|
|
48
|
+
const restartSeq = 0
|
|
49
|
+
if (event.type === 'session.ended' && event.payload.session === env.session) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`terminal rejected (D5): session "${env.session}" already ended ` +
|
|
52
|
+
`(session.ended at seq ${event.seq}) — a session that ended without ` +
|
|
53
|
+
'a terminal was failed and retried; only the live retry may complete ' +
|
|
54
|
+
'this phase round (§8.4).',
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
if (
|
|
58
|
+
sessionStartedSeq !== undefined &&
|
|
59
|
+
event.type === 'phase.failed' &&
|
|
60
|
+
event.payload.phase === env.phase &&
|
|
61
|
+
event.payload.round === env.round &&
|
|
62
|
+
event.seq > sessionStartedSeq
|
|
63
|
+
) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`terminal rejected (D5): ${env.phase}@${env.round} already failed after ` +
|
|
66
|
+
`this session started (phase.failed at seq ${event.seq}) — the retry ` +
|
|
67
|
+
'session owns this round now (§8.4).',
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
if (
|
|
71
|
+
event.type === 'escalation.raised' &&
|
|
72
|
+
event.actor.kind === 'agent' &&
|
|
73
|
+
event.actor.session === env.session
|
|
74
|
+
) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`second terminal call rejected (D5): this session already escalated — ` +
|
|
77
|
+
`escalation.raised "${event.payload.id}" at seq ${event.seq}. ` +
|
|
78
|
+
'Every phase ends with exactly one terminal command (§8.4).',
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
if (
|
|
82
|
+
event.type === 'publication.requested' &&
|
|
83
|
+
event.actor.kind === 'agent' &&
|
|
84
|
+
event.actor.session === env.session &&
|
|
85
|
+
((env.phase === 'implement' &&
|
|
86
|
+
event.payload.operation === 'implement' &&
|
|
87
|
+
event.payload.round === env.round) ||
|
|
88
|
+
(env.phase === 'reconcile' && event.payload.operation === 'reconcile') ||
|
|
89
|
+
(env.phase === 'finalize' && event.payload.operation === 'finalize'))
|
|
90
|
+
) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`second terminal call rejected (D5): ${event.type} for ` +
|
|
93
|
+
`${env.phase}@${env.round} already recorded at seq ${event.seq}. ` +
|
|
94
|
+
'Remote publication is pending; do not run ab done again (§8.4).',
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
if (event.type !== spec.terminalEvent) return
|
|
98
|
+
if (
|
|
99
|
+
(event.type === 'verify.completed' || event.type === 'finalize.completed') &&
|
|
100
|
+
event.seq <= restartSeq
|
|
101
|
+
) {
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (!terminalMatchesRound(event, env, reconcileAttempt)) return
|
|
105
|
+
throw new Error(
|
|
106
|
+
`second terminal call rejected (D5): ${event.type} for ` +
|
|
107
|
+
`${env.phase}@${env.round} already recorded at seq ${event.seq}. ` +
|
|
108
|
+
'Every phase ends with exactly one terminal command (§8.4).',
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
function terminalMatchesRound(event: AbEvent, env: CliEnv, reconcileAttempt: number): boolean {
|
|
112
|
+
switch (event.type) {
|
|
113
|
+
case 'plan.completed':
|
|
114
|
+
case 'plan-review.verdict':
|
|
115
|
+
case 'implement.completed':
|
|
116
|
+
case 'code-review.verdict':
|
|
117
|
+
return event.payload.round === env.round
|
|
118
|
+
case 'verify.completed':
|
|
119
|
+
return (
|
|
120
|
+
isVerifyPhase(env.phase) &&
|
|
121
|
+
event.payload.step === verifyStep(env.phase) &&
|
|
122
|
+
event.payload.attempt === env.round
|
|
123
|
+
)
|
|
124
|
+
case 'finalize.completed':
|
|
125
|
+
// finalize runs once per build (§5) — any prior completion counts.
|
|
126
|
+
return true
|
|
127
|
+
case 'reconcile.completed':
|
|
128
|
+
// No started event before it → conservative match (total over any log).
|
|
129
|
+
return reconcileAttempt === env.round || reconcileAttempt === 0
|
|
130
|
+
default:
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
+
import { readRepositoryFacts } from '../store/reduced-reads'
|
|
1
2
|
import { humanActor, type Via } from '../events/envelope'
|
|
2
3
|
import type { RepositoryEventEnvelope } from '../events/repository'
|
|
3
|
-
import { reduceDispatchSettings } from '../kernel/dispatch-settings'
|
|
4
|
-
import { reduceHarvest } from '../kernel/harvest'
|
|
5
4
|
import type { BuildStore } from '../store/types'
|
|
6
5
|
import { projectHarvest, type HarvestRunAction } from '../cli/dashboard/model'
|
|
7
6
|
|
|
@@ -65,7 +64,7 @@ export async function toggleRepositorySetting(opts: {
|
|
|
65
64
|
}): Promise<{ enabled: boolean; event: RepositoryEventEnvelope }> {
|
|
66
65
|
await opts.store.ensureRepo(opts.repo)
|
|
67
66
|
// Bounded read (AUT-489): the settings reducer consumes durable types only.
|
|
68
|
-
const settings =
|
|
67
|
+
const settings = (await readRepositoryFacts(opts.store, opts.repo)).value.settings
|
|
69
68
|
const enabled = opts.setting === 'intake' ? !settings.intake : !settings.defaultAutoMerge
|
|
70
69
|
return setRepositorySetting({ ...opts, enabled })
|
|
71
70
|
}
|
|
@@ -78,7 +77,7 @@ export async function toggleHarvestGate(opts: {
|
|
|
78
77
|
}): Promise<{ command: 'pause' | 'resume'; event: RepositoryEventEnvelope }> {
|
|
79
78
|
await opts.store.ensureRepo(opts.repo)
|
|
80
79
|
// Bounded read (AUT-489): the harvest reducer consumes durable types only.
|
|
81
|
-
const state =
|
|
80
|
+
const state = (await readRepositoryFacts(opts.store, opts.repo)).value.harvest
|
|
82
81
|
const pending = state.pendingCommands.at(-1)
|
|
83
82
|
const requestedPaused = pending === undefined ? state.paused : pending.command === 'pause'
|
|
84
83
|
const command = requestedPaused ? 'resume' : 'pause'
|
|
@@ -101,8 +100,8 @@ export async function controlHarvestRun(opts: {
|
|
|
101
100
|
await opts.store.ensureRepo(opts.repo)
|
|
102
101
|
// Bounded read (AUT-489): the harvest reducer and the dashboard harvest
|
|
103
102
|
// projection consume durable types only.
|
|
104
|
-
const events = await opts.store
|
|
105
|
-
const state =
|
|
103
|
+
const events = (await readRepositoryFacts(opts.store, opts.repo)).value
|
|
104
|
+
const state = events.harvest
|
|
106
105
|
const projected = projectHarvest(events)
|
|
107
106
|
if (projected === undefined || projected.run !== opts.run) {
|
|
108
107
|
throw new OperatorControlError(
|