@defrex/autobuild 0.11.0 → 0.12.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 +3 -1
- package/SPEC.md +222 -22
- package/docs/architecture.md +66 -4
- package/docs/configuration.md +105 -16
- package/docs/hosted-dispatcher.md +17 -2
- package/docs/hosted-service.md +214 -0
- package/docs/mcp.md +8 -2
- package/docs/operator-api.md +90 -7
- package/docs/remote-store-protocol.md +189 -144
- package/docs/remote-ticket-protocol.md +107 -19
- package/docs/setup.md +173 -16
- package/docs/ticket-dependencies.md +1 -1
- package/package.json +7 -6
- package/packages/core/src/cli/attention.ts +209 -0
- package/packages/core/src/cli/configured-store.ts +17 -0
- package/packages/core/src/cli/connect.ts +261 -0
- package/packages/core/src/cli/dashboard/model.ts +4 -5
- package/packages/core/src/cli/dispatch.ts +183 -41
- package/packages/core/src/cli/help.ts +39 -10
- package/packages/core/src/cli/init-validation.ts +27 -2
- package/packages/core/src/cli/main.ts +41 -15
- package/packages/core/src/cli/mcp.ts +7 -1
- package/packages/core/src/cli/pr-summary-publication.ts +220 -0
- package/packages/core/src/cli/pr-summary.ts +2 -0
- package/packages/core/src/cli/project-credentials.ts +132 -0
- package/packages/core/src/cli/repo-state.ts +15 -2
- package/packages/core/src/cli/self-update.ts +10 -0
- package/packages/core/src/cli/store-opening.ts +22 -14
- package/packages/core/src/cli/terminals.ts +0 -10
- package/packages/core/src/cli/ticket.ts +8 -1
- package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
- package/packages/core/src/cli/wait.ts +84 -17
- package/packages/core/src/cli/watch.ts +197 -60
- package/packages/core/src/config/live.ts +3 -0
- package/packages/core/src/config/schema.ts +56 -9
- package/packages/core/src/connection.ts +86 -0
- package/packages/core/src/events/catalog.ts +10 -2
- package/packages/core/src/events/payloads.ts +44 -0
- package/packages/core/src/events/repository.ts +13 -0
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +130 -83
- package/packages/core/src/kernel/engine.ts +24 -0
- package/packages/core/src/kernel/generators/build-log.ts +10 -0
- package/packages/core/src/kernel/log-index.ts +17 -0
- package/packages/core/src/kernel/pr-summary.ts +89 -0
- package/packages/core/src/kernel/reducer-registry.ts +8 -0
- package/packages/core/src/kernel/reducer.ts +8 -0
- package/packages/core/src/ontology.ts +1 -0
- package/packages/core/src/operator/index.ts +7 -0
- package/packages/core/src/operator/query.ts +38 -16
- package/packages/core/src/operator/ticket-source-backend.ts +1 -0
- package/packages/core/src/operator/tickets.ts +6 -8
- package/packages/core/src/plugin-sdk/index.ts +1 -0
- package/packages/core/src/plugins/manifest.ts +5 -3
- package/packages/core/src/plugins/runtimes.ts +3 -1
- package/packages/core/src/ports/forge/fake.ts +13 -1
- package/packages/core/src/ports/forge/github-transport.ts +36 -1
- package/packages/core/src/ports/forge/github.ts +29 -5
- package/packages/core/src/ports/forge/local-git.ts +12 -4
- package/packages/core/src/ports/forge/summary-marker.ts +8 -0
- package/packages/core/src/ports/runner/session-env.ts +1 -0
- package/packages/core/src/ports/tickets/create.ts +11 -3
- package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
- package/packages/core/src/ports/tickets/remote-protocol.ts +1 -0
- package/packages/core/src/ports/tickets/remote.ts +1 -0
- package/packages/core/src/ports/workspace/build-execution.ts +5 -1
- package/packages/core/src/ports/workspace/create.ts +11 -2
- package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
- package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
- package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
- package/packages/core/src/processes/build-execution-state.ts +1 -1
- package/packages/core/src/processes/build-runner.ts +87 -26
- package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
- package/packages/core/src/processes/dispatcher.ts +59 -10
- package/packages/core/src/processes/harvest-child.ts +1 -0
- package/packages/core/src/processes/harvest-execution-state.ts +19 -2
- package/packages/core/src/processes/harvest-runner.ts +142 -100
- package/packages/core/src/processes/phase-failure-budget.ts +1 -0
- package/packages/core/src/processes/publication-loss.ts +18 -1
- package/packages/core/src/store/remote/client.ts +1 -1
- package/packages/core/src/store/remote/compatibility.ts +90 -0
- package/packages/core/src/store/remote/credentials.ts +28 -0
- package/packages/core/src/store/remote/index.ts +14 -1
- package/packages/core/src/store/remote/protocol.ts +25 -0
- package/packages/core/src/store/remote/token.ts +74 -13
- package/packages/core/src/testing/index.ts +7 -0
- package/skills/guide/SKILL.md +144 -26
- package/skills/guide/references/operator-api.md +135 -13
- package/skills/guide/references/plugin-authoring.md +10 -2
- package/skills/guide/references/remote-store-protocol.md +189 -144
- package/skills/guide/references/remote-ticket-protocol.md +111 -18
- package/skills/guide/references/setup.md +173 -16
- package/skills/guide/references/ticket-dependencies.md +1 -1
- package/skills/operate/SKILL.md +126 -36
- package/templates/hosted-service/package.json +13 -0
- package/templates/hosted-service/vercel.json +7 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ensureDispatchBudget } from './dispatch-failure-budget'
|
|
1
2
|
import {
|
|
2
3
|
reduceTicketSourceHealth,
|
|
3
4
|
type TicketSourceDegradation,
|
|
@@ -665,7 +666,15 @@ export class Dispatcher {
|
|
|
665
666
|
return this.repoOrigin
|
|
666
667
|
}
|
|
667
668
|
private readonly leaseTtlMs: number
|
|
668
|
-
private
|
|
669
|
+
private get doneState(): string {
|
|
670
|
+
return (
|
|
671
|
+
this.deps.opts?.doneState ??
|
|
672
|
+
(this.deps.config.tickets.source === 'hosted'
|
|
673
|
+
? this.deps.config.tickets.doneState
|
|
674
|
+
: undefined) ??
|
|
675
|
+
'Done'
|
|
676
|
+
)
|
|
677
|
+
}
|
|
669
678
|
private readonly slugNamingTimeoutMs: number
|
|
670
679
|
private readonly maxHarvestRecoveryAttempts: number
|
|
671
680
|
private readonly provisionStaleMs: number
|
|
@@ -695,7 +704,6 @@ export class Dispatcher {
|
|
|
695
704
|
? deps.store
|
|
696
705
|
: new RepoViewStore(deps.store, { repo: deps.repo })
|
|
697
706
|
this.leaseTtlMs = deps.opts?.leaseTtlMs ?? 0
|
|
698
|
-
this.doneState = deps.opts?.doneState ?? 'Done'
|
|
699
707
|
this.slugNamingTimeoutMs = deps.opts?.slugNamingTimeoutMs ?? DEFAULT_SLUG_NAMING_TIMEOUT_MS
|
|
700
708
|
this.maxHarvestRecoveryAttempts =
|
|
701
709
|
deps.opts?.maxHarvestRecoveryAttempts ?? DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS
|
|
@@ -1508,6 +1516,10 @@ export class Dispatcher {
|
|
|
1508
1516
|
generation,
|
|
1509
1517
|
seed,
|
|
1510
1518
|
tail,
|
|
1519
|
+
}).catch(() => {
|
|
1520
|
+
// Detached work must never reject into the dispatcher process. Failure
|
|
1521
|
+
// recording can itself fail on a store outage; durable boundaries and
|
|
1522
|
+
// lease expiry let the next tick repair or retry the unfinished work.
|
|
1511
1523
|
})
|
|
1512
1524
|
return 'kicked'
|
|
1513
1525
|
}
|
|
@@ -1542,6 +1554,7 @@ export class Dispatcher {
|
|
|
1542
1554
|
const slug = entry.slug
|
|
1543
1555
|
let leaseClaimed = false
|
|
1544
1556
|
let heartbeat: ReturnType<typeof setInterval> | undefined
|
|
1557
|
+
let boundary: 'workspace' | 'read' | 'dispatch' | 'launch' = 'workspace'
|
|
1545
1558
|
const stage: EventPayload<'dispatch.failed'>['stage'] = 'workspace'
|
|
1546
1559
|
try {
|
|
1547
1560
|
if (entry.controller.signal.aborted) {
|
|
@@ -1606,12 +1619,19 @@ export class Dispatcher {
|
|
|
1606
1619
|
await store.releaseLease(slug, entry.instance)
|
|
1607
1620
|
leaseClaimed = false
|
|
1608
1621
|
if (entry.controller.signal.aborted) return
|
|
1622
|
+
boundary = 'read'
|
|
1609
1623
|
const record = await store.getBuild(slug)
|
|
1610
1624
|
if (record === null) return
|
|
1611
|
-
await (
|
|
1612
|
-
|
|
1613
|
-
|
|
1625
|
+
const queued = reduceBuild(await store.getEvents(slug)).status === 'queued'
|
|
1626
|
+
boundary = input.tail === 'dispatch' || queued ? 'dispatch' : 'launch'
|
|
1627
|
+
if (boundary === 'dispatch') await this.completeDispatchBoundaries(record, input.seed)
|
|
1628
|
+
else await this.deps.launchRunner(slug)
|
|
1614
1629
|
} catch (error) {
|
|
1630
|
+
// Dispatch boundaries own their failure facts and exhaustion repair.
|
|
1631
|
+
// A failed tail read/write leaves durable unfinished work for the next
|
|
1632
|
+
// tick; it is not another provider workspace failure. Replacement
|
|
1633
|
+
// runner launch errors retain the existing infrastructure-budget path.
|
|
1634
|
+
if (boundary === 'read' || boundary === 'dispatch') return
|
|
1615
1635
|
const events = await store.getEvents(slug)
|
|
1616
1636
|
if (workspaces.recovery !== undefined) {
|
|
1617
1637
|
const open = openWorkspace(events)
|
|
@@ -2485,6 +2505,16 @@ export class Dispatcher {
|
|
|
2485
2505
|
return sha
|
|
2486
2506
|
}
|
|
2487
2507
|
|
|
2508
|
+
private dispatchBudget(slug: string): Promise<boolean> {
|
|
2509
|
+
return ensureDispatchBudget(
|
|
2510
|
+
this.store,
|
|
2511
|
+
this.deps.ids,
|
|
2512
|
+
slug,
|
|
2513
|
+
this.deps.config.policy.maxDispatchAttempts,
|
|
2514
|
+
this.deps.workspaces.recovery !== undefined,
|
|
2515
|
+
)
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2488
2518
|
// ── b. Interrupted dispatch recovery ───────────────────────────────────────
|
|
2489
2519
|
|
|
2490
2520
|
/** The claim-time `build.created` payload. Assets are omitted when the ticket
|
|
@@ -2539,6 +2569,7 @@ export class Dispatcher {
|
|
|
2539
2569
|
): Promise<DispatchOutcome> {
|
|
2540
2570
|
const store = this.store
|
|
2541
2571
|
const { config } = this.deps
|
|
2572
|
+
if (!(await this.dispatchBudget(record.slug))) return 'parked'
|
|
2542
2573
|
let stage: EventPayload<'dispatch.failed'>['stage'] = 'create'
|
|
2543
2574
|
try {
|
|
2544
2575
|
let events = await store.getEvents(record.slug)
|
|
@@ -2628,8 +2659,6 @@ export class Dispatcher {
|
|
|
2628
2659
|
} satisfies EventWrite<'workspace.provisioned'>)
|
|
2629
2660
|
events = await store.getEvents(record.slug)
|
|
2630
2661
|
}
|
|
2631
|
-
|
|
2632
|
-
return await this.completeDispatchBoundaries(record, seed, report, launched)
|
|
2633
2662
|
} catch (error) {
|
|
2634
2663
|
const events = await store.getEvents(record.slug)
|
|
2635
2664
|
if (stage === 'workspace' && this.deps.workspaces.recovery !== undefined) {
|
|
@@ -2654,8 +2683,10 @@ export class Dispatcher {
|
|
|
2654
2683
|
},
|
|
2655
2684
|
})
|
|
2656
2685
|
report.dispatchFailed += 1
|
|
2686
|
+
await this.dispatchBudget(record.slug)
|
|
2657
2687
|
return 'failed'
|
|
2658
2688
|
}
|
|
2689
|
+
return this.completeDispatchBoundaries(record, seed, report, launched)
|
|
2659
2690
|
}
|
|
2660
2691
|
|
|
2661
2692
|
/** The post-workspace dispatch boundaries — spec, comment, launch — shared
|
|
@@ -2676,10 +2707,13 @@ export class Dispatcher {
|
|
|
2676
2707
|
): Promise<Exclude<DispatchOutcome, 'deferred'>> {
|
|
2677
2708
|
const store = this.store
|
|
2678
2709
|
|
|
2710
|
+
if (!(await this.dispatchBudget(record.slug))) return 'parked'
|
|
2679
2711
|
let stage: EventPayload<'dispatch.failed'>['stage'] = 'spec'
|
|
2680
2712
|
let authored = false
|
|
2681
2713
|
try {
|
|
2682
2714
|
const events = await store.getEvents(record.slug)
|
|
2715
|
+
const state = reduceBuild(events)
|
|
2716
|
+
if (state.status !== 'queued' || state.discardRequest !== undefined) return 'parked'
|
|
2683
2717
|
if (
|
|
2684
2718
|
!events.some((event) => event.type === 'spec.imported' || event.type === 'spec.authored')
|
|
2685
2719
|
) {
|
|
@@ -2779,6 +2813,7 @@ export class Dispatcher {
|
|
|
2779
2813
|
},
|
|
2780
2814
|
})
|
|
2781
2815
|
if (report !== undefined) report.dispatchFailed += 1
|
|
2816
|
+
await this.dispatchBudget(record.slug)
|
|
2782
2817
|
return 'failed'
|
|
2783
2818
|
}
|
|
2784
2819
|
}
|
|
@@ -2810,9 +2845,11 @@ export class Dispatcher {
|
|
|
2810
2845
|
// Queued itself means runner attachment is still missing. Always enter
|
|
2811
2846
|
// the fact-driven helper: it skips every completed boundary and retries
|
|
2812
2847
|
// only the final launch when setup is already durable.
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2848
|
+
const outcome = await this.attemptDispatchCompletion(record, launched, report)
|
|
2849
|
+
if (outcome === 'completed') report.recovered += 1
|
|
2850
|
+
// A failed boundary owns this tick's attempt; startup and lease recovery
|
|
2851
|
+
// must not bypass the unfinished dispatch boundary later in the tick.
|
|
2852
|
+
if (outcome === 'failed') launched.add(record.slug)
|
|
2816
2853
|
}
|
|
2817
2854
|
}
|
|
2818
2855
|
|
|
@@ -2853,6 +2890,12 @@ export class Dispatcher {
|
|
|
2853
2890
|
continue
|
|
2854
2891
|
}
|
|
2855
2892
|
|
|
2893
|
+
if (
|
|
2894
|
+
state.status === 'queued' &&
|
|
2895
|
+
!state.pendingCommands.some((command) => command.command === 'abort') &&
|
|
2896
|
+
!(await this.dispatchBudget(record.slug))
|
|
2897
|
+
)
|
|
2898
|
+
continue
|
|
2856
2899
|
const decision = decideNextFromFacts(state, facts.log, config)
|
|
2857
2900
|
if (decision.kind === 'wait') {
|
|
2858
2901
|
if (decision.reason === 'blocked' && state.openEscalations.length > 0) {
|
|
@@ -2923,6 +2966,12 @@ export class Dispatcher {
|
|
|
2923
2966
|
) {
|
|
2924
2967
|
continue
|
|
2925
2968
|
}
|
|
2969
|
+
if (
|
|
2970
|
+
state.status === 'queued' &&
|
|
2971
|
+
!state.pendingCommands.some((command) => command.command === 'abort') &&
|
|
2972
|
+
!(await this.dispatchBudget(record.slug))
|
|
2973
|
+
)
|
|
2974
|
+
continue
|
|
2926
2975
|
const decision = decideNextFromFacts(state, facts.log, this.deps.config)
|
|
2927
2976
|
if (state.status === 'done' || state.status === 'aborted') continue
|
|
2928
2977
|
// Raw history, read at most once and only by a branch that acts on it.
|
|
@@ -97,6 +97,7 @@ export async function runHarvestChild(
|
|
|
97
97
|
uuids: randomUuids(),
|
|
98
98
|
clock: systemClock,
|
|
99
99
|
instance: input.instance,
|
|
100
|
+
...(input.preparedRun ? { preparedRun: input.preparedRun } : {}),
|
|
100
101
|
// Adopted holder: the guest skips its own claim, heartbeats the owning
|
|
101
102
|
// dispatch loop's repository lease, and never releases it.
|
|
102
103
|
...(input.leaseHolder !== undefined ? { leaseHolder: input.leaseHolder } : {}),
|
|
@@ -92,9 +92,26 @@ export function classifyHarvestOutcome(
|
|
|
92
92
|
input: HarvestExecutionClassificationInput,
|
|
93
93
|
): HarvestRunnerResult {
|
|
94
94
|
const state = reduceHarvest(events)
|
|
95
|
-
const
|
|
95
|
+
const started = events.find(
|
|
96
|
+
(event) =>
|
|
97
|
+
event.type === 'harvest.execution.started' && event.seq === input.executionStartedSeq,
|
|
98
|
+
)
|
|
99
|
+
const authorization =
|
|
100
|
+
started?.type === 'harvest.execution.started'
|
|
101
|
+
? events.find(
|
|
102
|
+
(event) =>
|
|
103
|
+
event.type === 'harvest.execution.authorized' &&
|
|
104
|
+
event.payload.execution === started.payload.execution,
|
|
105
|
+
)
|
|
106
|
+
: undefined
|
|
107
|
+
const selected =
|
|
108
|
+
authorization?.type === 'harvest.execution.authorized' ? authorization.payload : undefined
|
|
109
|
+
const latestRun = selected
|
|
110
|
+
? state.runs.find((run) => run.run === selected.run)
|
|
111
|
+
: state.runs.at(-1)
|
|
96
112
|
if (latestRun !== undefined) {
|
|
97
|
-
const launch =
|
|
113
|
+
const launch =
|
|
114
|
+
selected?.launch ?? (latestRun.startedSeq > input.executionStartedSeq ? 'started' : 'resumed')
|
|
98
115
|
// Recency gate: a terminal outcome counts only if its fact — the run's
|
|
99
116
|
// terminal fact, or for an unresolved failure the failure fact itself
|
|
100
117
|
// (`failureSeq`), which also covers exhaustion via `terminalSeq` —
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { delegateCredential } from '../store/remote/credentials'
|
|
1
2
|
/**
|
|
2
3
|
* Crash-safe repository-scoped harvest workflow:
|
|
3
4
|
* scan -> synthesize <-> review -> file. It reuses `converge` for adversarial
|
|
@@ -100,11 +101,13 @@ export interface HarvestRunnerDeps {
|
|
|
100
101
|
* executes in (hosted harvest). Stamped into the run's harvest.started
|
|
101
102
|
* payload so the journal records where it ran; absent on local runs. */
|
|
102
103
|
environment?: { provider: string; environmentId: string; sessionId?: string }
|
|
104
|
+
preparedRun?: { run: string }
|
|
103
105
|
sessionEnv?: Record<string, string>
|
|
104
106
|
opts?: HarvestRunnerOpts
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
export type HarvestRunnerResult =
|
|
110
|
+
| { outcome: 'prepared'; run: string; launch: 'started' | 'resumed' }
|
|
108
111
|
| { outcome: 'idle' }
|
|
109
112
|
| { outcome: 'held' }
|
|
110
113
|
| { outcome: 'parked'; run?: string }
|
|
@@ -217,9 +220,9 @@ export class HarvestRunner {
|
|
|
217
220
|
)
|
|
218
221
|
}
|
|
219
222
|
|
|
220
|
-
async run(): Promise<HarvestRunnerResult> {
|
|
223
|
+
async run(preparationOnly = false): Promise<HarvestRunnerResult> {
|
|
221
224
|
const { store, repo, instance } = this.deps
|
|
222
|
-
await store.ensureRepo(repo)
|
|
225
|
+
if (!this.deps.preparedRun) await store.ensureRepo(repo)
|
|
223
226
|
if (
|
|
224
227
|
this.deps.leaseHolder === undefined &&
|
|
225
228
|
!(await store.claimRepoLease(repo, instance, this.leaseTtlMs))
|
|
@@ -233,101 +236,116 @@ export class HarvestRunner {
|
|
|
233
236
|
await this.ensureLease()
|
|
234
237
|
// Bounded read (AUT-489): reduceHarvest and the verdict/failure filters
|
|
235
238
|
// below consume durable types only, so the subset is replay-equivalent.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
await
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const pressure = evaluateHarvestPressureFromDigests({
|
|
263
|
-
digests,
|
|
264
|
-
harvestEvents: gateEvents,
|
|
265
|
-
policy: this.currentConfig().policy,
|
|
266
|
-
})
|
|
267
|
-
if (pressure.trigger === undefined) {
|
|
268
|
-
// Treat the read's completion as a boundary even when the gate did
|
|
269
|
-
// not trip, so a request arriving during the read is acknowledged
|
|
270
|
-
// before this runner returns idle.
|
|
239
|
+
if (this.deps.preparedRun) {
|
|
240
|
+
const events = await store.getRepoStateEvents(repo)
|
|
241
|
+
const authorization = events.find(
|
|
242
|
+
(event) =>
|
|
243
|
+
event.type === 'harvest.execution.authorized' &&
|
|
244
|
+
event.payload.execution === instance &&
|
|
245
|
+
event.payload.run === this.deps.preparedRun!.run,
|
|
246
|
+
)
|
|
247
|
+
if (authorization?.type !== 'harvest.execution.authorized')
|
|
248
|
+
throw new Error('prepared harvest execution unavailable')
|
|
249
|
+
run = reduceHarvest(events).runs.find((run) => run.run === this.deps.preparedRun!.run)
|
|
250
|
+
initial = authorization.payload.launch
|
|
251
|
+
} else {
|
|
252
|
+
let events = await store.getRepoStateEvents(repo)
|
|
253
|
+
let state = reduceHarvest(events)
|
|
254
|
+
run = actionableHarvestRun(state)
|
|
255
|
+
|
|
256
|
+
// Settle durable operator control before scanning or resuming work. The
|
|
257
|
+
// selected run comes from the full journal, so a shadowed failure or
|
|
258
|
+
// exhaustion barrier is recovered/reported before any later open run.
|
|
259
|
+
await this.controlBoundary(run?.run)
|
|
260
|
+
events = await store.getRepoStateEvents(repo)
|
|
261
|
+
state = reduceHarvest(events)
|
|
262
|
+
run = openHarvestRun(state)
|
|
263
|
+
|
|
264
|
+
if (!run) {
|
|
271
265
|
await this.controlBoundary()
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
266
|
+
// Flat-cost gate (AUT-521): the pressure trigger is evaluated from
|
|
267
|
+
// the journal and one repo-scoped digest batch read — no per-build
|
|
268
|
+
// history reads — so the idle evaluation no longer grows with
|
|
269
|
+
// accumulated finished builds. Only a tripped gate pays for the full
|
|
270
|
+
// unclaimed-observation scan, because the packet artifact needs the
|
|
271
|
+
// observation payloads no bounded source carries.
|
|
272
|
+
// Bounded read (AUT-489): the gate's journal input is consumed only
|
|
273
|
+
// through reduceHarvest, so the subset is replay-equivalent here too.
|
|
274
|
+
const [gateEvents, digests] = await Promise.all([
|
|
275
|
+
store.getRepoStateEvents(repo),
|
|
276
|
+
store.getRepoBuildDigests(repo),
|
|
277
|
+
])
|
|
278
|
+
const pressure = evaluateHarvestPressureFromDigests({
|
|
279
|
+
digests,
|
|
280
|
+
harvestEvents: gateEvents,
|
|
281
|
+
policy: this.currentConfig().policy,
|
|
282
|
+
})
|
|
283
|
+
if (pressure.trigger === undefined) {
|
|
284
|
+
// Treat the read's completion as a boundary even when the gate did
|
|
285
|
+
// not trip, so a request arriving during the read is acknowledged
|
|
286
|
+
// before this runner returns idle.
|
|
287
|
+
await this.controlBoundary()
|
|
288
|
+
return { outcome: 'idle' }
|
|
289
|
+
}
|
|
283
290
|
await this.controlBoundary()
|
|
284
|
-
|
|
291
|
+
const scan = await scanUnclaimedObservations(store, repo)
|
|
292
|
+
// The trigger came from an earlier read than this scan, and a claim
|
|
293
|
+
// may have landed in the window. Re-confirm the gate against the
|
|
294
|
+
// fresh scan (pure, free): an emptied window returns idle exactly as
|
|
295
|
+
// if the gate had not tripped, so a packet is only ever built from a
|
|
296
|
+
// scan that itself triggered.
|
|
297
|
+
const confirmed = evaluateHarvestPressure(scan, this.currentConfig().policy)
|
|
298
|
+
if (confirmed.trigger === undefined) {
|
|
299
|
+
await this.controlBoundary()
|
|
300
|
+
return { outcome: 'idle' }
|
|
301
|
+
}
|
|
302
|
+
const runId = this.deps.ids('harvest')
|
|
303
|
+
const packet = await makeHarvestScanPacket({
|
|
304
|
+
store,
|
|
305
|
+
tickets: this.deps.tickets,
|
|
306
|
+
repo,
|
|
307
|
+
run: runId,
|
|
308
|
+
observations: scan.observations,
|
|
309
|
+
state: scan.state,
|
|
310
|
+
})
|
|
311
|
+
// Scan preparation is not a claim. Re-read operator control immediately
|
|
312
|
+
// before atomically committing the immutable snapshot.
|
|
313
|
+
await this.controlBoundary()
|
|
314
|
+
initial = 'started'
|
|
315
|
+
await store.appendRepoWithArtifacts(
|
|
316
|
+
repo,
|
|
317
|
+
[
|
|
318
|
+
{
|
|
319
|
+
kind: HARVEST_SCAN_ARTIFACT,
|
|
320
|
+
content: `${JSON.stringify(packet, null, 2)}\n`,
|
|
321
|
+
metadata: { run: runId, observations: packet.observations.length },
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
(deposited) => ({
|
|
325
|
+
actor: KERNEL,
|
|
326
|
+
type: 'harvest.started',
|
|
327
|
+
payload: {
|
|
328
|
+
run: runId,
|
|
329
|
+
observations: packet.observations.map((item) => item.occurrence),
|
|
330
|
+
scan: artifactRef(deposited[0]!),
|
|
331
|
+
trigger: confirmed.trigger,
|
|
332
|
+
...(this.deps.environment !== undefined
|
|
333
|
+
? { environment: this.deps.environment }
|
|
334
|
+
: {}),
|
|
335
|
+
},
|
|
336
|
+
}),
|
|
337
|
+
)
|
|
338
|
+
// Bounded read (AUT-489): reduceHarvest consumes durable types only.
|
|
339
|
+
events = await store.getRepoStateEvents(repo)
|
|
340
|
+
run = reduceHarvest(events).runs.find((candidate) => candidate.run === runId)
|
|
341
|
+
if (!run) throw new Error(`harvest.started did not reduce run "${runId}"`)
|
|
342
|
+
// A request racing the claim parks this exact run; its snapshot stays
|
|
343
|
+
// open and is resumed rather than scanned again.
|
|
344
|
+
await this.controlBoundary(run.run)
|
|
285
345
|
}
|
|
286
|
-
const runId = this.deps.ids('harvest')
|
|
287
|
-
const packet = await makeHarvestScanPacket({
|
|
288
|
-
store,
|
|
289
|
-
tickets: this.deps.tickets,
|
|
290
|
-
repo,
|
|
291
|
-
run: runId,
|
|
292
|
-
observations: scan.observations,
|
|
293
|
-
state: scan.state,
|
|
294
|
-
})
|
|
295
|
-
// Scan preparation is not a claim. Re-read operator control immediately
|
|
296
|
-
// before atomically committing the immutable snapshot.
|
|
297
|
-
await this.controlBoundary()
|
|
298
|
-
initial = 'started'
|
|
299
|
-
await store.appendRepoWithArtifacts(
|
|
300
|
-
repo,
|
|
301
|
-
[
|
|
302
|
-
{
|
|
303
|
-
kind: HARVEST_SCAN_ARTIFACT,
|
|
304
|
-
content: `${JSON.stringify(packet, null, 2)}\n`,
|
|
305
|
-
metadata: { run: runId, observations: packet.observations.length },
|
|
306
|
-
},
|
|
307
|
-
],
|
|
308
|
-
(deposited) => ({
|
|
309
|
-
actor: KERNEL,
|
|
310
|
-
type: 'harvest.started',
|
|
311
|
-
payload: {
|
|
312
|
-
run: runId,
|
|
313
|
-
observations: packet.observations.map((item) => item.occurrence),
|
|
314
|
-
scan: artifactRef(deposited[0]!),
|
|
315
|
-
trigger: confirmed.trigger,
|
|
316
|
-
...(this.deps.environment !== undefined
|
|
317
|
-
? { environment: this.deps.environment }
|
|
318
|
-
: {}),
|
|
319
|
-
},
|
|
320
|
-
}),
|
|
321
|
-
)
|
|
322
|
-
// Bounded read (AUT-489): reduceHarvest consumes durable types only.
|
|
323
|
-
events = await store.getRepoStateEvents(repo)
|
|
324
|
-
run = reduceHarvest(events).runs.find((candidate) => candidate.run === runId)
|
|
325
|
-
if (!run) throw new Error(`harvest.started did not reduce run "${runId}"`)
|
|
326
|
-
// A request racing the claim parks this exact run; its snapshot stays
|
|
327
|
-
// open and is resumed rather than scanned again.
|
|
328
|
-
await this.controlBoundary(run.run)
|
|
329
346
|
}
|
|
330
|
-
|
|
347
|
+
if (!run) throw new Error('prepared harvest run unavailable')
|
|
348
|
+
if (preparationOnly) return { outcome: 'prepared', run: run.run, launch: initial }
|
|
331
349
|
try {
|
|
332
350
|
await this.ensureScanStep(run)
|
|
333
351
|
await this.controlBoundary(run.run)
|
|
@@ -691,7 +709,7 @@ export class HarvestRunner {
|
|
|
691
709
|
live.handle,
|
|
692
710
|
`Revise harvest proposals for round ${spec.round}: run ab harvest context, address .ab/findings.json, then submit.`,
|
|
693
711
|
{
|
|
694
|
-
env: this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
712
|
+
env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
695
713
|
...(stream !== undefined ? { stream } : {}),
|
|
696
714
|
},
|
|
697
715
|
)
|
|
@@ -702,7 +720,7 @@ export class HarvestRunner {
|
|
|
702
720
|
workspacePath,
|
|
703
721
|
...(target.model !== undefined ? { model: target.model } : {}),
|
|
704
722
|
...(target.args !== undefined ? { args: target.args } : {}),
|
|
705
|
-
env: this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
723
|
+
env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
706
724
|
...(stream !== undefined ? { stream } : {}),
|
|
707
725
|
})
|
|
708
726
|
handle = turn.session
|
|
@@ -941,7 +959,7 @@ export class HarvestRunner {
|
|
|
941
959
|
live.handle,
|
|
942
960
|
`Revise harvest proposals for round ${spec.round}: run ab harvest context, address .ab/findings.json, then submit.`,
|
|
943
961
|
{
|
|
944
|
-
env: this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
962
|
+
env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
945
963
|
...(stream !== undefined ? { stream } : {}),
|
|
946
964
|
},
|
|
947
965
|
)
|
|
@@ -952,7 +970,7 @@ export class HarvestRunner {
|
|
|
952
970
|
workspacePath,
|
|
953
971
|
...(resolved.model !== undefined ? { model: resolved.model } : {}),
|
|
954
972
|
...(resolved.args !== undefined ? { args: resolved.args } : {}),
|
|
955
|
-
env: this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
973
|
+
env: await this.sessionEnv(spec.run, spec.step, spec.round, session),
|
|
956
974
|
...(stream !== undefined ? { stream } : {}),
|
|
957
975
|
})
|
|
958
976
|
handle = turn.session
|
|
@@ -1391,6 +1409,7 @@ export class HarvestRunner {
|
|
|
1391
1409
|
* give-up before returning. Initial/within-step failures do not spend this
|
|
1392
1410
|
* outer budget and therefore remain recoverable. */
|
|
1393
1411
|
private async settleRecoveryExhaustion(run: string): Promise<void> {
|
|
1412
|
+
if (this.deps.preparedRun) return
|
|
1394
1413
|
await this.ensureLease()
|
|
1395
1414
|
// Bounded read (AUT-489): reduceHarvest consumes durable types only.
|
|
1396
1415
|
const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
|
|
@@ -1459,6 +1478,7 @@ export class HarvestRunner {
|
|
|
1459
1478
|
const state = reduceHarvest(await this.deps.store.getRepoStateEvents(this.deps.repo))
|
|
1460
1479
|
const decision = decideHarvestControl(state, this.maxRecoveryAttempts)
|
|
1461
1480
|
if (decision.kind === 'proceed') return
|
|
1481
|
+
if (this.deps.preparedRun) throw new HarvestParkedSignal(run)
|
|
1462
1482
|
if (decision.kind === 'park') {
|
|
1463
1483
|
throw new HarvestParkedSignal(actionableHarvestRun(state)?.run ?? run)
|
|
1464
1484
|
}
|
|
@@ -1602,15 +1622,26 @@ export class HarvestRunner {
|
|
|
1602
1622
|
}
|
|
1603
1623
|
}
|
|
1604
1624
|
|
|
1605
|
-
private sessionEnv(
|
|
1625
|
+
private async sessionEnv(
|
|
1606
1626
|
run: string,
|
|
1607
1627
|
step: 'synthesize' | 'review',
|
|
1608
1628
|
round: number,
|
|
1609
1629
|
session: string,
|
|
1610
|
-
): Record<string, string
|
|
1630
|
+
): Promise<Record<string, string>> {
|
|
1631
|
+
const parent = this.deps.sessionEnv
|
|
1632
|
+
const token =
|
|
1633
|
+
parent?.AB_STORE && /^https?:/i.test(parent.AB_STORE)
|
|
1634
|
+
? await delegateCredential(parent.AB_STORE, parent.AB_TOKEN, {
|
|
1635
|
+
kind: 'harvest-phase',
|
|
1636
|
+
repo: this.deps.repo,
|
|
1637
|
+
id: run,
|
|
1638
|
+
session,
|
|
1639
|
+
})
|
|
1640
|
+
: undefined
|
|
1611
1641
|
return {
|
|
1612
1642
|
AB_STORE: 'local',
|
|
1613
1643
|
...this.deps.sessionEnv,
|
|
1644
|
+
...(token ? { AB_TOKEN: token } : {}),
|
|
1614
1645
|
AB_REPO: this.deps.repo,
|
|
1615
1646
|
AB_HARVEST: run,
|
|
1616
1647
|
AB_PHASE: `${step}@${round}`,
|
|
@@ -1638,7 +1669,18 @@ export class HarvestRunner {
|
|
|
1638
1669
|
const open = target.openSessionStream
|
|
1639
1670
|
if (open === undefined) return undefined
|
|
1640
1671
|
const sink = createSessionStreamSink({
|
|
1641
|
-
store:
|
|
1672
|
+
store: {
|
|
1673
|
+
...this.deps.store,
|
|
1674
|
+
createStream: (scope, label) =>
|
|
1675
|
+
this.deps.store.createStream(
|
|
1676
|
+
scope,
|
|
1677
|
+
this.deps.preparedRun
|
|
1678
|
+
? `harvest:${this.deps.preparedRun.run}:${this.deps.instance}:${label}`
|
|
1679
|
+
: label,
|
|
1680
|
+
),
|
|
1681
|
+
appendStreamParts: (id, parts) => this.deps.store.appendStreamParts(id, parts),
|
|
1682
|
+
closeStream: (id, outcome) => this.deps.store.closeStream(id, outcome),
|
|
1683
|
+
},
|
|
1642
1684
|
scope: { kind: 'repo', repo: this.deps.repo },
|
|
1643
1685
|
onDiagnostic: this.onDiagnostic,
|
|
1644
1686
|
})
|
|
@@ -11,6 +11,7 @@ export const POLICY_PHASE_FAILURE_RESET_SCOPES = {
|
|
|
11
11
|
'review-round-limit': 'matching-round',
|
|
12
12
|
'verify-failure-limit': 'all-phase-rounds',
|
|
13
13
|
'reconcile-no-progress': 'none',
|
|
14
|
+
'dispatch-failure-limit': 'none',
|
|
14
15
|
'setup-failure-limit': 'none',
|
|
15
16
|
'infrastructure-failure-limit': 'none',
|
|
16
17
|
'phase-attempt-limit': 'matching-round',
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { summaryBoundary } from '../kernel/pr-summary'
|
|
2
|
+
import { isRemoteWorkspace } from '../events/workspace-remote'
|
|
1
3
|
/**
|
|
2
4
|
* Last-chance publication settlement at the workspace-release boundary
|
|
3
5
|
* (AUT-328). A pending `publication.requested` whose workspace is destroyed
|
|
@@ -37,7 +39,22 @@ export async function settlePublicationBeforeRelease(
|
|
|
37
39
|
reason: string,
|
|
38
40
|
): Promise<AbEvent[]> {
|
|
39
41
|
const request = latestUncompletedPublicationRequest(events)
|
|
40
|
-
if (request === undefined)
|
|
42
|
+
if (request === undefined) {
|
|
43
|
+
const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
|
|
44
|
+
if (
|
|
45
|
+
workspace?.type === 'workspace.provisioned' &&
|
|
46
|
+
isRemoteWorkspace(workspace.payload) &&
|
|
47
|
+
summaryBoundary(events) !== undefined
|
|
48
|
+
) {
|
|
49
|
+
try {
|
|
50
|
+
await deps.settlePublication?.(slug)
|
|
51
|
+
} catch {
|
|
52
|
+
/* Summary is best effort. */
|
|
53
|
+
}
|
|
54
|
+
return deps.store.getEvents(slug)
|
|
55
|
+
}
|
|
56
|
+
return events
|
|
57
|
+
}
|
|
41
58
|
try {
|
|
42
59
|
await deps.settlePublication?.(slug)
|
|
43
60
|
} catch {
|
|
@@ -578,7 +578,7 @@ export class RemoteBuildStore implements BuildStore {
|
|
|
578
578
|
|
|
579
579
|
async getRepoHighWater(repo: string): Promise<number> {
|
|
580
580
|
// Additive route beside `state-events`; the store service and client
|
|
581
|
-
//
|
|
581
|
+
// retain this additive route throughout the supported client window.
|
|
582
582
|
return this.requestJson('GET', `${this.repoPath(repo)}/high-water`, repoHighWaterSchema).then(
|
|
583
583
|
(body) => (body as { seq: number }).seq,
|
|
584
584
|
)
|