@defrex/autobuild 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +19 -1
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +96 -70
  5. package/docs/hosted-service.md +2 -2
  6. package/docs/operator-api.md +1 -1
  7. package/docs/remote-store-protocol.md +84 -2
  8. package/package.json +4 -3
  9. package/packages/core/src/cli/attention.ts +18 -164
  10. package/packages/core/src/cli/build-control.ts +11 -15
  11. package/packages/core/src/cli/bulk-control.ts +3 -2
  12. package/packages/core/src/cli/context.ts +16 -78
  13. package/packages/core/src/cli/dashboard/model.ts +36 -22
  14. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  15. package/packages/core/src/cli/dispatch-process.ts +14 -7
  16. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  17. package/packages/core/src/cli/dispatch.ts +148 -79
  18. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  19. package/packages/core/src/cli/harvest.ts +71 -39
  20. package/packages/core/src/cli/historical-build-state.ts +27 -0
  21. package/packages/core/src/cli/main.ts +3 -3
  22. package/packages/core/src/cli/pr-attachments.ts +10 -13
  23. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  24. package/packages/core/src/cli/pr-summary.ts +43 -13
  25. package/packages/core/src/cli/publication-settlement.ts +10 -20
  26. package/packages/core/src/cli/repository-status.ts +38 -2
  27. package/packages/core/src/cli/status.ts +62 -32
  28. package/packages/core/src/cli/terminals.ts +25 -142
  29. package/packages/core/src/cli/ticket.ts +2 -2
  30. package/packages/core/src/cli/wait.ts +91 -84
  31. package/packages/core/src/cli/watch.ts +60 -97
  32. package/packages/core/src/config/pipeline-source.ts +4 -3
  33. package/packages/core/src/events/repository.ts +7 -3
  34. package/packages/core/src/integration/harness.ts +1 -2
  35. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2429 -9
  36. package/packages/core/src/kernel/auto-merge.ts +19 -3
  37. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  38. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  39. package/packages/core/src/kernel/harvest.ts +25 -23
  40. package/packages/core/src/kernel/log-index-value.ts +38 -0
  41. package/packages/core/src/kernel/read-facts.ts +637 -0
  42. package/packages/core/src/kernel/reducer-registry.ts +137 -3
  43. package/packages/core/src/kernel/repository-attention.ts +179 -0
  44. package/packages/core/src/kernel/service-facts.ts +135 -0
  45. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  46. package/packages/core/src/operator/control.ts +5 -6
  47. package/packages/core/src/operator/index.ts +2 -0
  48. package/packages/core/src/operator/query.ts +37 -20
  49. package/packages/core/src/operator/read-view.ts +4 -13
  50. package/packages/core/src/operator/requests.ts +2 -14
  51. package/packages/core/src/operator/sandbox.ts +10 -9
  52. package/packages/core/src/operator/tickets.ts +2 -2
  53. package/packages/core/src/plugin-sdk/index.ts +13 -0
  54. package/packages/core/src/processes/build-child.ts +6 -6
  55. package/packages/core/src/processes/build-runner.ts +62 -70
  56. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  57. package/packages/core/src/processes/dispatcher.ts +113 -173
  58. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  59. package/packages/core/src/processes/harvest-runner.ts +34 -70
  60. package/packages/core/src/processes/harvest.ts +38 -15
  61. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  62. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  63. package/packages/core/src/processes/publication-loss.ts +35 -21
  64. package/packages/core/src/processes/repo-view.ts +229 -1134
  65. package/packages/core/src/processes/tick-publication.ts +110 -0
  66. package/packages/core/src/store/adapter.ts +23 -0
  67. package/packages/core/src/store/build-scope.ts +17 -0
  68. package/packages/core/src/store/contract.ts +251 -0
  69. package/packages/core/src/store/local/schema.ts +1 -0
  70. package/packages/core/src/store/local/store.ts +179 -54
  71. package/packages/core/src/store/memory.ts +119 -29
  72. package/packages/core/src/store/phase-session.ts +17 -0
  73. package/packages/core/src/store/reduced-reads.ts +239 -0
  74. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  75. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  76. package/packages/core/src/store/reducer-values.ts +145 -0
  77. package/packages/core/src/store/remote/client.ts +41 -0
  78. package/packages/core/src/store/remote/compatibility.ts +2 -0
  79. package/packages/core/src/store/remote/index.ts +19 -0
  80. package/packages/core/src/store/remote/protocol.ts +16 -0
  81. package/packages/core/src/store/remote/version.ts +3 -1
  82. package/packages/core/src/store/repo-state-events.ts +1 -1
  83. package/packages/core/src/store/repo-tick.ts +72 -0
  84. package/packages/core/src/store/session-handle.ts +19 -0
  85. package/packages/core/src/store/snapshots.ts +10 -0
  86. package/packages/core/src/store/ticket-assets.ts +4 -4
  87. package/packages/core/src/store/types.ts +39 -2
  88. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  89. package/packages/core/src/testing/index.ts +8 -0
  90. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  91. package/skills/guide/SKILL.md +6 -1
  92. package/skills/guide/references/operator-api.md +1 -1
  93. package/skills/guide/references/remote-store-protocol.md +84 -2
  94. package/templates/hosted-service/package.json +2 -2
@@ -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
- export const REDUCERS: Record<string, RegisteredReducer> = {
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', 'openWorkspace', 'forgeWorkspacePath'],
162
+ covers: ['openBuildWorkspace'],
126
163
  },
127
- baseBranch: { reducer: baseBranchReducer, covers: ['baseBranchOf'] },
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 = reduceDispatchSettings(await opts.store.getRepoStateEvents(opts.repo))
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 = reduceHarvest(await opts.store.getRepoStateEvents(opts.repo))
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.getRepoStateEvents(opts.repo)
105
- const state = reduceHarvest(events)
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(
@@ -137,3 +137,5 @@ export {
137
137
  } from './query'
138
138
 
139
139
  export { ticketStateCreateRequestSchema } from './protocol'
140
+
141
+ export { readBuildFacts } from '../store/reduced-reads'