@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,4 @@
|
|
|
1
|
+
import { readBuildFacts } from '../store/reduced-reads'
|
|
1
2
|
/**
|
|
2
3
|
* Durable operator controls for one build.
|
|
3
4
|
*
|
|
@@ -9,12 +10,7 @@
|
|
|
9
10
|
import type { AbEvent } from '../events/catalog'
|
|
10
11
|
import type { PinnedAsset } from '../events/payloads'
|
|
11
12
|
import { humanActor, KERNEL, type Via } from '../events/envelope'
|
|
12
|
-
import {
|
|
13
|
-
reduceBuild,
|
|
14
|
-
discardInFlight,
|
|
15
|
-
type BuildState,
|
|
16
|
-
type OpenEscalation,
|
|
17
|
-
} from '../kernel/reducer'
|
|
13
|
+
import { discardInFlight, type BuildState, type OpenEscalation } from '../kernel/reducer'
|
|
18
14
|
import type { ArtifactRef, BuildOutcome, BuildStatus, TicketRef } from '../ontology'
|
|
19
15
|
import type { Exec } from '../ports/workspace/git-worktree'
|
|
20
16
|
import { specConformance } from '../spec-standard'
|
|
@@ -276,7 +272,7 @@ async function appendConsentCommand(
|
|
|
276
272
|
via?: Via,
|
|
277
273
|
): Promise<BuildControlResult> {
|
|
278
274
|
while (true) {
|
|
279
|
-
const state =
|
|
275
|
+
const state = (await readBuildFacts(store, slug)).value.state
|
|
280
276
|
activeState(slug, state)
|
|
281
277
|
const command =
|
|
282
278
|
action === 'toggle-auto-merge'
|
|
@@ -484,8 +480,8 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
|
|
|
484
480
|
)
|
|
485
481
|
}
|
|
486
482
|
|
|
487
|
-
const events = await opts.store
|
|
488
|
-
const state =
|
|
483
|
+
const events = (await readBuildFacts(opts.store, opts.slug)).value
|
|
484
|
+
const state = events.state
|
|
489
485
|
const user = opts.user?.trim() || buildControlUser(opts.env ?? {})
|
|
490
486
|
|
|
491
487
|
if (opts.action.kind === 'discard') {
|
|
@@ -495,7 +491,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
|
|
|
495
491
|
`build "${opts.slug}" cannot be discarded (status: ${state.status}); discard requires queued`,
|
|
496
492
|
)
|
|
497
493
|
}
|
|
498
|
-
const existing = events.
|
|
494
|
+
const existing = events.latestDiscard
|
|
499
495
|
if (existing !== undefined) {
|
|
500
496
|
return { kind: 'command', slug: opts.slug, command: 'discard', event: existing }
|
|
501
497
|
}
|
|
@@ -504,7 +500,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
|
|
|
504
500
|
|
|
505
501
|
if (opts.action.kind === 'abort') {
|
|
506
502
|
if (state.status !== 'queued') activeState(opts.slug, state)
|
|
507
|
-
const existing = events.
|
|
503
|
+
const existing = events.latestAbort
|
|
508
504
|
if (existing !== undefined) {
|
|
509
505
|
return { kind: 'command', slug: opts.slug, command: 'abort', event: existing }
|
|
510
506
|
}
|
|
@@ -658,7 +654,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
|
|
|
658
654
|
|
|
659
655
|
// Re-read at the last durable moment. The PR janitor and abort
|
|
660
656
|
// acknowledgement may still write while a blocked build is parked.
|
|
661
|
-
const freshState =
|
|
657
|
+
const freshState = (await readBuildFacts(opts.store, opts.slug)).value.state
|
|
662
658
|
assertStillActive(opts.slug, freshState)
|
|
663
659
|
const decision = decideRevise(opts.slug, freshState, captured)
|
|
664
660
|
if (decision.kind === 'refuse') throwRefusal(decision)
|
|
@@ -701,7 +697,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
|
|
|
701
697
|
payload: {},
|
|
702
698
|
})
|
|
703
699
|
}
|
|
704
|
-
const after =
|
|
700
|
+
const after = (await readBuildFacts(opts.store, opts.slug)).value.state
|
|
705
701
|
const signal = terminalSignal(after)
|
|
706
702
|
return {
|
|
707
703
|
kind: 'answered',
|
|
@@ -729,7 +725,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
|
|
|
729
725
|
const initial = dismissPartition(state, captured)
|
|
730
726
|
if (initial.eligible.length === 0) throw noCitedFindings(opts.slug, initialOpen)
|
|
731
727
|
|
|
732
|
-
const freshState =
|
|
728
|
+
const freshState = (await readBuildFacts(opts.store, opts.slug)).value.state
|
|
733
729
|
assertStillActive(opts.slug, freshState)
|
|
734
730
|
if (hasPendingAbort(freshState)) throw abortPending(opts.slug)
|
|
735
731
|
const freshOpen = selectedOpen(freshState, captured)
|
|
@@ -757,7 +753,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
|
|
|
757
753
|
payload: {},
|
|
758
754
|
})
|
|
759
755
|
}
|
|
760
|
-
const after =
|
|
756
|
+
const after = (await readBuildFacts(opts.store, opts.slug)).value.state
|
|
761
757
|
const signal = terminalSignal(after)
|
|
762
758
|
return {
|
|
763
759
|
kind: 'answered',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readBuildFacts } from '../store/reduced-reads'
|
|
1
2
|
/**
|
|
2
3
|
* Repository-wide quiescence: one keypress that parks every running build and
|
|
3
4
|
* stops intake, and one that reverses it.
|
|
@@ -59,7 +60,7 @@
|
|
|
59
60
|
* what the operator sees, and a unit test pins that agreement.
|
|
60
61
|
*/
|
|
61
62
|
import { humanActor, type Actor, type Via } from '../events/envelope'
|
|
62
|
-
import {
|
|
63
|
+
import type { BuildState } from '../kernel/reducer'
|
|
63
64
|
import type { Exec } from '../ports/workspace/git-worktree'
|
|
64
65
|
import type { BuildRecord, BuildStore } from '../store/types'
|
|
65
66
|
import { BuildControlError, buildControlUser } from './build-control'
|
|
@@ -132,7 +133,7 @@ async function requestOne(
|
|
|
132
133
|
direction: BulkDirection,
|
|
133
134
|
): Promise<boolean> {
|
|
134
135
|
while (true) {
|
|
135
|
-
const state =
|
|
136
|
+
const state = (await readBuildFacts(store, slug)).value.state
|
|
136
137
|
if (!eligible(state, direction)) return false
|
|
137
138
|
// The two branches are written out rather than selecting the event type
|
|
138
139
|
// into a variable so `appendIfCurrent`'s `T extends EventType` infers a
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readBuildFacts, type CurrentBuildFacts } from '../store/reduced-reads'
|
|
1
2
|
/**
|
|
2
3
|
* The `ab context` materializer (SPEC §8.3): a phase-scoped hydration of the
|
|
3
4
|
* gitignored `.ab/` scratch dir. Everything derives from
|
|
@@ -16,14 +17,13 @@ import { mkdir, rm, writeFile } from 'node:fs/promises'
|
|
|
16
17
|
import { dirname, join } from 'node:path'
|
|
17
18
|
import { parseConfig } from '../config/load'
|
|
18
19
|
import type { VerifyStepConfig } from '../config/schema'
|
|
19
|
-
import type { AbEvent } from '../events/catalog'
|
|
20
20
|
import {
|
|
21
21
|
allowedTerminals,
|
|
22
22
|
phaseSpecFor,
|
|
23
23
|
type PhaseSpec,
|
|
24
24
|
type TerminalCommand,
|
|
25
25
|
} from '../kernel/phases'
|
|
26
|
-
import {
|
|
26
|
+
import type { BuildState } from '../kernel/reducer'
|
|
27
27
|
import {
|
|
28
28
|
isVerifyPhase,
|
|
29
29
|
verifyReportKind,
|
|
@@ -113,69 +113,12 @@ function requiredKinds(spec: PhaseSpec, phase: Phase): string[] {
|
|
|
113
113
|
|
|
114
114
|
/** All finding objects ever deposited, by id — verdict payloads carry them in
|
|
115
115
|
* full (§15.3), so feedback-by-id resolves without touching artifacts. */
|
|
116
|
-
function findingsById(events:
|
|
117
|
-
|
|
118
|
-
for (const event of events) {
|
|
119
|
-
if (event.type === 'plan-review.verdict' || event.type === 'code-review.verdict') {
|
|
120
|
-
for (const finding of event.payload.findings) byId.set(finding.id, finding)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return byId
|
|
116
|
+
function findingsById(events: CurrentBuildFacts): Map<string, Finding> {
|
|
117
|
+
return new Map(Object.values(events.findings).map((finding) => [finding.id, finding]))
|
|
124
118
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
* `implement.started`, `plan.started`, and agent `verify.started` may carry
|
|
129
|
-
* feedback — the carrier that gets answered guidance to a fresh session after
|
|
130
|
-
* a parked build re-attaches (§15.6-B). A `plan.started` without one (logs
|
|
131
|
-
* predating the field) falls
|
|
132
|
-
* back to deriving round-N feedback from the round-(N-1)
|
|
133
|
-
* `plan-review.verdict` — the plan loop's verdicts ARE its findings channel
|
|
134
|
-
* (§8.3: "prior plan rev + findings").
|
|
135
|
-
*/
|
|
136
|
-
function currentFeedback(events: AbEvent[], phase: Phase, round: number): Feedback | null {
|
|
137
|
-
if (isVerifyPhase(phase)) {
|
|
138
|
-
const step = verifyStep(phase)
|
|
139
|
-
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
140
|
-
const event = events[i]!
|
|
141
|
-
if (
|
|
142
|
-
event.type === 'verify.started' &&
|
|
143
|
-
event.payload.step === step &&
|
|
144
|
-
event.payload.attempt === round
|
|
145
|
-
) {
|
|
146
|
-
return event.payload.feedback ?? null
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return null
|
|
150
|
-
}
|
|
151
|
-
if (phase === 'implement') {
|
|
152
|
-
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
153
|
-
const event = events[i]!
|
|
154
|
-
if (event.type === 'implement.started' && event.payload.round === round) {
|
|
155
|
-
return event.payload.feedback ?? null
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return null
|
|
159
|
-
}
|
|
160
|
-
if (phase === 'plan') {
|
|
161
|
-
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
162
|
-
const event = events[i]!
|
|
163
|
-
if (event.type === 'plan.started' && event.payload.round === round) {
|
|
164
|
-
if (event.payload.feedback !== undefined) return event.payload.feedback
|
|
165
|
-
break // legacy start without the field — derive from the prior verdict
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
if (round <= 1) return null
|
|
169
|
-
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
170
|
-
const event = events[i]!
|
|
171
|
-
if (event.type === 'plan-review.verdict' && event.payload.round === round - 1) {
|
|
172
|
-
return event.payload.verdict === 'revise'
|
|
173
|
-
? { findings: event.payload.findings.map((finding) => finding.id) }
|
|
174
|
-
: null
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
return null
|
|
119
|
+
function currentFeedback(events: CurrentBuildFacts, phase: Phase, round: number): Feedback | null {
|
|
120
|
+
const explicit = events.feedback[JSON.stringify([phase, round])]
|
|
121
|
+
return explicit ?? (phase === 'plan' ? (events.planFeedback[String(round)] ?? null) : null)
|
|
179
122
|
}
|
|
180
123
|
|
|
181
124
|
/** Empty and recreate `.ab/`; callers restore any tracked legacy entries. */
|
|
@@ -184,7 +127,7 @@ async function wipeAbDir(abDir: string): Promise<void> {
|
|
|
184
127
|
await mkdir(abDir, { recursive: true })
|
|
185
128
|
}
|
|
186
129
|
|
|
187
|
-
function dismissedIds(state: BuildState, events:
|
|
130
|
+
function dismissedIds(state: BuildState, events: CurrentBuildFacts): string[] {
|
|
188
131
|
const known = findingsById(events)
|
|
189
132
|
const ids = new Set<string>()
|
|
190
133
|
for (const escalation of state.answeredEscalations) {
|
|
@@ -205,8 +148,8 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
|
|
|
205
148
|
const { build, phase, round } = env
|
|
206
149
|
const spec = phaseSpecFor(phase)
|
|
207
150
|
const inputs = spec.inputs
|
|
208
|
-
const events = await store
|
|
209
|
-
const state =
|
|
151
|
+
const events = (await readBuildFacts(store, build)).value
|
|
152
|
+
const state = events.state
|
|
210
153
|
|
|
211
154
|
// Preserve legacy tracked scratch exactly while replacing disposable files.
|
|
212
155
|
// Some adapter contract fixtures intentionally use a non-Git workspace;
|
|
@@ -271,7 +214,7 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
|
|
|
271
214
|
}
|
|
272
215
|
|
|
273
216
|
if (inputs.ticket === true) {
|
|
274
|
-
const created = events.
|
|
217
|
+
const created = events.created
|
|
275
218
|
if (created !== undefined && created.type === 'build.created') {
|
|
276
219
|
const original = created.payload.ticket
|
|
277
220
|
const current =
|
|
@@ -442,13 +385,8 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
|
|
|
442
385
|
// records the execution input in reconcile.started. A crashed same-attempt
|
|
443
386
|
// rerun can therefore have multiple starts; the newest matching AB_PHASE
|
|
444
387
|
// attempt is authoritative.
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if (event.type === 'reconcile.started' && event.payload.attempt === round) {
|
|
448
|
-
manifest.conflict = { baseSha: event.payload.baseSha }
|
|
449
|
-
break
|
|
450
|
-
}
|
|
451
|
-
}
|
|
388
|
+
const baseSha = events.reconcileBases[String(round)]
|
|
389
|
+
if (baseSha !== undefined) manifest.conflict = { baseSha }
|
|
452
390
|
if (manifest.conflict === undefined) {
|
|
453
391
|
throw new Error(
|
|
454
392
|
`reconcile@${round} context requires a matching reconcile.started event ` +
|
|
@@ -488,10 +426,10 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
|
|
|
488
426
|
// Legacy tracked scratch is repository debt, not content Autobuild rewrites.
|
|
489
427
|
// Record it once per build from the provisioned base so harvest can groom it.
|
|
490
428
|
if (gitWorkspace && deps.ids !== undefined) {
|
|
491
|
-
const provisioned = events.
|
|
429
|
+
const provisioned = events.firstWorkspace
|
|
492
430
|
const marker = 'phase-scratch:tracked-at-provisioned-base'
|
|
493
|
-
const alreadyRecorded = events.some(
|
|
494
|
-
|
|
431
|
+
const alreadyRecorded = events.observations.some((observation) =>
|
|
432
|
+
observation.refs?.includes(marker),
|
|
495
433
|
)
|
|
496
434
|
if (
|
|
497
435
|
provisioned !== undefined &&
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RepositoryReadFacts } from '../../store/reduced-reads'
|
|
1
2
|
/**
|
|
2
3
|
* The dashboard's pure projection: `BuildState` → what the operator sees.
|
|
3
4
|
*
|
|
@@ -920,7 +921,7 @@ function escalationAttentionDismissed(events: RepositoryEvent[], terminalSeq: nu
|
|
|
920
921
|
}
|
|
921
922
|
|
|
922
923
|
function selectDashboardHarvestRun(
|
|
923
|
-
events: RepositoryEvent[],
|
|
924
|
+
events: RepositoryEvent[] | RepositoryReadFacts,
|
|
924
925
|
state: HarvestState,
|
|
925
926
|
): HarvestRunState | undefined {
|
|
926
927
|
const attention = state.runs.find((run) => {
|
|
@@ -928,7 +929,12 @@ function selectDashboardHarvestRun(
|
|
|
928
929
|
return run.recoveryExhaustion?.attentionAcknowledgedSeq === undefined
|
|
929
930
|
}
|
|
930
931
|
if (run.status !== 'escalated') return false
|
|
931
|
-
return
|
|
932
|
+
return (
|
|
933
|
+
run.terminalSeq === undefined ||
|
|
934
|
+
!(Array.isArray(events)
|
|
935
|
+
? escalationAttentionDismissed(events, run.terminalSeq)
|
|
936
|
+
: events.dismissedThrough > run.terminalSeq)
|
|
937
|
+
)
|
|
932
938
|
})
|
|
933
939
|
return attention ?? openHarvestRun(state)
|
|
934
940
|
}
|
|
@@ -937,7 +943,7 @@ function selectDashboardHarvestRun(
|
|
|
937
943
|
* exhausted, or escalated attention outranks merely open work; completed and
|
|
938
944
|
* acknowledged terminal history never hides an older actionable run. */
|
|
939
945
|
function projectHarvestRun(
|
|
940
|
-
events: RepositoryEvent[],
|
|
946
|
+
events: RepositoryEvent[] | RepositoryReadFacts,
|
|
941
947
|
state: HarvestState,
|
|
942
948
|
): DashboardHarvest | undefined {
|
|
943
949
|
const run = selectDashboardHarvestRun(events, state)
|
|
@@ -968,25 +974,27 @@ function projectHarvestRun(
|
|
|
968
974
|
const approved = run.reviews.some((review) => review.verdict === 'approve')
|
|
969
975
|
const synthOutput = run.proposals.length > 0
|
|
970
976
|
const reviewOutput = run.reviews.length > 0
|
|
971
|
-
const
|
|
972
|
-
run.status
|
|
973
|
-
?
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
(
|
|
977
|
-
|
|
978
|
-
event
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
977
|
+
const failureStoppedAt =
|
|
978
|
+
run.status !== 'failed'
|
|
979
|
+
? undefined
|
|
980
|
+
: Array.isArray(events)
|
|
981
|
+
? [...events]
|
|
982
|
+
.reverse()
|
|
983
|
+
.find(
|
|
984
|
+
(event) =>
|
|
985
|
+
event.type === 'harvest.failed' &&
|
|
986
|
+
event.payload.run === run.run &&
|
|
987
|
+
!event.payload.willRetry,
|
|
988
|
+
)?.ts
|
|
989
|
+
: events.failedAt[JSON.stringify(run.run)]
|
|
982
990
|
// Both durable stop states freeze any open occurrence. A failure normally
|
|
983
991
|
// closes its step immediately afterward, but projecting the boundary itself
|
|
984
992
|
// must never make an errored row look like work is still accumulating time.
|
|
985
993
|
const frozenAt =
|
|
986
994
|
state.pausedAt !== undefined
|
|
987
995
|
? Date.parse(state.pausedAt)
|
|
988
|
-
:
|
|
989
|
-
? Date.parse(
|
|
996
|
+
: failureStoppedAt !== undefined
|
|
997
|
+
? Date.parse(failureStoppedAt)
|
|
990
998
|
: undefined
|
|
991
999
|
const steps: PipelineStep[] = [
|
|
992
1000
|
step('scan', hasOutcome('scan', 'completed'), current('scan'), {
|
|
@@ -1073,8 +1081,10 @@ interface RepositoryHarvestProjection {
|
|
|
1073
1081
|
/** The repository-journal half of a legacy dashboard frame (AUT-545): the
|
|
1074
1082
|
* harvest projection and the pause flag exactly as `buildDashboardFromProjected`
|
|
1075
1083
|
* derives them, exposed for tests that pin the bounded read's equivalence. */
|
|
1076
|
-
export function projectRepositoryHarvest(
|
|
1077
|
-
|
|
1084
|
+
export function projectRepositoryHarvest(
|
|
1085
|
+
events: RepositoryEvent[] | RepositoryReadFacts,
|
|
1086
|
+
): RepositoryHarvestProjection {
|
|
1087
|
+
const state = Array.isArray(events) ? reduceHarvest(events) : events.harvest
|
|
1078
1088
|
const harvest = projectHarvestRun(events, state)
|
|
1079
1089
|
return {
|
|
1080
1090
|
harvestPaused: state.paused,
|
|
@@ -1082,7 +1092,9 @@ export function projectRepositoryHarvest(events: RepositoryEvent[]): RepositoryH
|
|
|
1082
1092
|
}
|
|
1083
1093
|
}
|
|
1084
1094
|
|
|
1085
|
-
export function projectHarvest(
|
|
1095
|
+
export function projectHarvest(
|
|
1096
|
+
events: RepositoryEvent[] | RepositoryReadFacts,
|
|
1097
|
+
): DashboardHarvest | undefined {
|
|
1086
1098
|
return projectRepositoryHarvest(events).harvest
|
|
1087
1099
|
}
|
|
1088
1100
|
|
|
@@ -1112,11 +1124,13 @@ export interface DashboardHeader extends DashboardFrameHeader {
|
|
|
1112
1124
|
export function buildDashboardFromProjected(
|
|
1113
1125
|
projectedBuilds: readonly DashboardBuild[],
|
|
1114
1126
|
header: DashboardHeader,
|
|
1115
|
-
repositoryEvents: RepositoryEvent[] = [],
|
|
1127
|
+
repositoryEvents: RepositoryEvent[] | RepositoryReadFacts = [],
|
|
1116
1128
|
): DashboardModel {
|
|
1117
1129
|
const builds = [...projectedBuilds].sort((a, b) => a.slug.localeCompare(b.slug))
|
|
1118
1130
|
const harvestProjection = projectRepositoryHarvest(repositoryEvents)
|
|
1119
|
-
const settings =
|
|
1131
|
+
const settings = Array.isArray(repositoryEvents)
|
|
1132
|
+
? reduceDispatchSettings(repositoryEvents)
|
|
1133
|
+
: repositoryEvents.settings
|
|
1120
1134
|
return {
|
|
1121
1135
|
repo: header.repo,
|
|
1122
1136
|
queued: header.queued,
|
|
@@ -1143,7 +1157,7 @@ export function buildDashboard(
|
|
|
1143
1157
|
entries: { record: BuildRecord; state: BuildState; events: AbEvent[] }[],
|
|
1144
1158
|
config: Config,
|
|
1145
1159
|
header: DashboardFrameHeader,
|
|
1146
|
-
repositoryEvents: RepositoryEvent[] = [],
|
|
1160
|
+
repositoryEvents: RepositoryEvent[] | RepositoryReadFacts = [],
|
|
1147
1161
|
): DashboardModel {
|
|
1148
1162
|
const builds = entries
|
|
1149
1163
|
.map(({ record, state, events }) => projectBuild(record, state, config, events))
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { DispatchRepositoryFeed } from './dispatch-repository-feed'
|
|
1
2
|
import { StandbyIdentityResolver } from './dispatcher-liveness'
|
|
2
3
|
import { configSchema, type Config } from '../config/schema'
|
|
3
4
|
import { humanActor } from '../events/envelope'
|
|
4
5
|
import type { RepositoryEvent } from '../events/repository'
|
|
5
6
|
import { reduceDispatchStatus, type DispatchStatus } from '../kernel/dispatch-status'
|
|
6
7
|
import type { BuildState } from '../kernel/reducer'
|
|
7
|
-
import {
|
|
8
|
+
import { unclaimedObservationCount } from '../processes/harvest'
|
|
8
9
|
import {
|
|
9
10
|
controlHarvestRun,
|
|
10
11
|
OperatorControlError,
|
|
@@ -68,15 +69,6 @@ function isStaleAbortControlError(error: unknown): error is BuildControlError {
|
|
|
68
69
|
)
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
function isDashboardRepositoryEvent(event: RepositoryEvent): boolean {
|
|
72
|
-
return (
|
|
73
|
-
event.type.startsWith('harvest.') ||
|
|
74
|
-
event.type === 'dispatcher.intake-set' ||
|
|
75
|
-
event.type === 'dispatcher.pause-set' ||
|
|
76
|
-
event.type === 'dispatcher.auto-merge-default-set'
|
|
77
|
-
)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
72
|
interface ResumePrompt {
|
|
81
73
|
slug: string
|
|
82
74
|
escalationIds: string[]
|
|
@@ -121,11 +113,7 @@ export class DispatchFrontend {
|
|
|
121
113
|
private readonly region: LiveRegion
|
|
122
114
|
private readonly keyboard
|
|
123
115
|
private child: DispatchChildHandle | undefined
|
|
124
|
-
private
|
|
125
|
-
/** Set only after a successful initial fold; every later `events()` call
|
|
126
|
-
* takes the delta path. */
|
|
127
|
-
private seeded = false
|
|
128
|
-
private repositoryEvents: RepositoryEvent[] = []
|
|
116
|
+
private readonly repositoryFeed: DispatchRepositoryFeed
|
|
129
117
|
private dispatchStatus: DispatchStatus = reduceDispatchStatus([], this.runId)
|
|
130
118
|
private eventRefreshTail: Promise<void> = Promise.resolve()
|
|
131
119
|
private cache: DashboardBuildPollCache | undefined
|
|
@@ -155,8 +143,12 @@ export class DispatchFrontend {
|
|
|
155
143
|
private operatorStopRequested = false
|
|
156
144
|
|
|
157
145
|
constructor(private readonly opts: DispatchFrontendOptions) {
|
|
158
|
-
this.standbyResolver = new StandbyIdentityResolver(opts.store, opts.repo)
|
|
159
146
|
this.clock = opts.clock ?? systemClock
|
|
147
|
+
this.repositoryFeed = new DispatchRepositoryFeed(opts.store, opts.repo, this.clock)
|
|
148
|
+
this.standbyResolver = new StandbyIdentityResolver(opts.store, opts.repo, {
|
|
149
|
+
allowLookup: () => this.repositoryFeed.incompleteIdentities,
|
|
150
|
+
remember: (events, complete) => this.repositoryFeed.remember(events, complete),
|
|
151
|
+
})
|
|
160
152
|
this.keyboard = createKeyboardProtocol(
|
|
161
153
|
(chunk) => opts.terminal.write(chunk),
|
|
162
154
|
opts.terminal.modes,
|
|
@@ -170,47 +162,14 @@ export class DispatchFrontend {
|
|
|
170
162
|
* are retained for their existing replay reducers. */
|
|
171
163
|
private async events(): Promise<RepositoryEvent[]> {
|
|
172
164
|
const refresh = this.eventRefreshTail.then(async () => {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
// to pay is replaced by AUT-489's bounded read where that is provably
|
|
176
|
-
// equivalent. The bounded attempt is confined to the pre-launch
|
|
177
|
-
// window (`this.child === undefined`): before `launchChild` no
|
|
178
|
-
// run-scoped journal fact can carry `this.runId` (only the supervised
|
|
179
|
-
// child appends run-scoped facts with it), so every run-scoped event
|
|
180
|
-
// in the bounded subset is skipped by `reduceDispatchStatus` exactly
|
|
181
|
-
// as it would be in a full replay, and the dashboard filter keeps
|
|
182
|
-
// only durable types, which the subset carries in full. After launch
|
|
183
|
-
// a bounded subset cannot guarantee the child's `run-started` under a
|
|
184
|
-
// competing dispatcher's later anchor — dropping it would leave
|
|
185
|
-
// `effectiveConfig` unset and the first frame would never render —
|
|
186
|
-
// so a post-launch seed always takes the full read (the pre-AUT-534
|
|
187
|
-
// behavior). `once` mode's teardown fold runs after the child exits,
|
|
188
|
-
// so the same guard routes it to the full read.
|
|
189
|
-
const seed =
|
|
190
|
-
this.child === undefined
|
|
191
|
-
? await this.opts.store.getRepoStateEvents(this.opts.repo).catch(() =>
|
|
192
|
-
// Bounded-read failure falls back to the exact pre-AUT-534
|
|
193
|
-
// full read within this same seed attempt.
|
|
194
|
-
this.opts.store.getRepoEvents(this.opts.repo, 0),
|
|
195
|
-
)
|
|
196
|
-
: await this.opts.store.getRepoEvents(this.opts.repo, 0)
|
|
197
|
-
this.standbyResolver.ingest(seed)
|
|
198
|
-
this.repositorySeq = seed.at(-1)?.seq ?? 0
|
|
199
|
-
this.dispatchStatus = reduceDispatchStatus(seed, this.runId)
|
|
200
|
-
this.repositoryEvents.push(...seed.filter(isDashboardRepositoryEvent))
|
|
201
|
-
this.seeded = true
|
|
202
|
-
return
|
|
203
|
-
}
|
|
204
|
-
const delta = await this.opts.store.getRepoEvents(this.opts.repo, this.repositorySeq)
|
|
205
|
-
if (delta.length === 0) return
|
|
165
|
+
const delta = await this.repositoryFeed.refresh(false)
|
|
166
|
+
this.standbyResolver.ingest(this.repositoryFeed.identities)
|
|
206
167
|
this.standbyResolver.ingest(delta)
|
|
207
|
-
this.repositorySeq = delta.at(-1)!.seq
|
|
208
168
|
this.dispatchStatus = reduceDispatchStatus(delta, this.runId, this.dispatchStatus)
|
|
209
|
-
this.repositoryEvents.push(...delta.filter(isDashboardRepositoryEvent))
|
|
210
169
|
})
|
|
211
170
|
this.eventRefreshTail = refresh.catch(() => {})
|
|
212
171
|
await refresh
|
|
213
|
-
return this.
|
|
172
|
+
return this.repositoryFeed.events
|
|
214
173
|
}
|
|
215
174
|
|
|
216
175
|
private async report(message: string, level: 'info' | 'warning' = 'info'): Promise<void> {
|
|
@@ -333,14 +292,10 @@ export class DispatchFrontend {
|
|
|
333
292
|
}
|
|
334
293
|
const config = await this.loadConfig(ref.kind, ref.rev)
|
|
335
294
|
try {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
// traffic stays flat as finished builds accumulate. A journal record
|
|
341
|
-
// that does not yet exist reads as an empty journal (AUT-524); the
|
|
342
|
-
// sample writes nothing.
|
|
343
|
-
this.observationCount = await sampleUnclaimedObservationCount(this.opts.store, this.opts.repo)
|
|
295
|
+
this.observationCount = unclaimedObservationCount({
|
|
296
|
+
digests: await this.opts.store.getRepoBuildDigests(this.opts.repo),
|
|
297
|
+
harvestEvents: repositoryEvents,
|
|
298
|
+
})
|
|
344
299
|
} catch {
|
|
345
300
|
// Before the first successful sample there is no factual zero to place in
|
|
346
301
|
// a complete frame. Leave the prior frame untouched and retry on the next
|
|
@@ -401,6 +356,7 @@ export class DispatchFrontend {
|
|
|
401
356
|
) {
|
|
402
357
|
this.view = undefined
|
|
403
358
|
}
|
|
359
|
+
await this.repositoryFeed.save()
|
|
404
360
|
this.model = projected
|
|
405
361
|
this.syncControls()
|
|
406
362
|
this.paint()
|
|
@@ -1057,13 +1013,13 @@ export class DispatchFrontend {
|
|
|
1057
1013
|
})
|
|
1058
1014
|
}
|
|
1059
1015
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1016
|
+
try {
|
|
1017
|
+
const delta = await this.repositoryFeed.warm()
|
|
1018
|
+
this.standbyResolver.ingest(this.repositoryFeed.identities)
|
|
1019
|
+
this.dispatchStatus = reduceDispatchStatus(delta, this.runId, this.dispatchStatus)
|
|
1020
|
+
} catch {
|
|
1021
|
+
/* Display initialization must not gate the kernel. */
|
|
1022
|
+
}
|
|
1067
1023
|
|
|
1068
1024
|
const options: DispatchChildOptions = {
|
|
1069
1025
|
targetRepo: this.opts.checkout ?? this.opts.repo,
|
|
@@ -1104,6 +1060,7 @@ export class DispatchFrontend {
|
|
|
1104
1060
|
this.opts.signal?.removeEventListener('abort', stop)
|
|
1105
1061
|
await this.child.stop()
|
|
1106
1062
|
await this.finishPresentation()
|
|
1063
|
+
await this.repositoryFeed.save(true)
|
|
1107
1064
|
}
|
|
1108
1065
|
}
|
|
1109
1066
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { readDispatcherRunFacts, readOpenTick } from '../store/reduced-reads'
|
|
1
2
|
import { distributionPath } from '../distribution'
|
|
2
3
|
import { DISPATCHER } from '../events/envelope'
|
|
3
|
-
import { hasOpenTick } from '../processes/tick-state'
|
|
4
4
|
import type { BuildStore } from '../store/types'
|
|
5
5
|
|
|
6
6
|
export const DISPATCH_CHILD_OPTIONS_ENV = 'AB_DISPATCH_CHILD_OPTIONS'
|
|
@@ -168,10 +168,8 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
|
|
|
168
168
|
// chooses/awaits process termination, avoiding check-then-append races.
|
|
169
169
|
const completed = child.exited.then(async (exitCode): Promise<DispatchChildResult> => {
|
|
170
170
|
const processSignal = child.signalCode === null ? requestedSignal : String(child.signalCode)
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
(event) => event.type === 'dispatcher.run-stopped' && event.payload.run === deps.run,
|
|
174
|
-
)
|
|
171
|
+
const recorded = (await readDispatcherRunFacts(deps.store, deps.repo, deps.run, false)).value
|
|
172
|
+
.stop
|
|
175
173
|
if (recorded?.type === 'dispatcher.run-stopped') {
|
|
176
174
|
const signal = processSignal ?? recorded.payload.signal
|
|
177
175
|
return {
|
|
@@ -245,12 +243,21 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
|
|
|
245
243
|
// Ready with no durable build to recover. Let an open tick reach its
|
|
246
244
|
// normal boundary; all non-tick work is lease-backed/recoverable and a
|
|
247
245
|
// child stuck there is safe to terminate.
|
|
248
|
-
|
|
246
|
+
let open = true
|
|
247
|
+
try {
|
|
248
|
+
const record = await deps.store.getRepo(deps.repo)
|
|
249
|
+
open =
|
|
250
|
+
record?.tick?.run === deps.run
|
|
251
|
+
? record.tick.open !== undefined
|
|
252
|
+
: await readOpenTick(deps.store, deps.repo, deps.run, false)
|
|
253
|
+
} catch {
|
|
254
|
+
// Unreadable telemetry cannot authorize an unsafe kill.
|
|
255
|
+
}
|
|
249
256
|
// The child may have exited while the asynchronous journal read was in
|
|
250
257
|
// flight. Re-check at the force boundary so evidence never claims a
|
|
251
258
|
// SIGKILL that was not actually sent.
|
|
252
259
|
if (child.exitCode !== null) return
|
|
253
|
-
if (
|
|
260
|
+
if (open) {
|
|
254
261
|
await child.exited
|
|
255
262
|
return
|
|
256
263
|
}
|