@defrex/autobuild 0.12.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 +138 -16
- package/bin/ab-dispatch-kernel.ts +4 -6
- package/docs/architecture.md +155 -73
- package/docs/configuration.md +41 -9
- package/docs/hosted-service.md +31 -5
- package/docs/operator-api.md +8 -1
- package/docs/remote-store-protocol.md +94 -2
- package/docs/remote-ticket-protocol.md +151 -9
- package/docs/setup.md +33 -7
- package/docs/ticket-migration.md +64 -0
- package/package.json +4 -3
- package/packages/core/src/cli/attention.ts +18 -164
- package/packages/core/src/cli/build-control.ts +23 -16
- package/packages/core/src/cli/bulk-control.ts +3 -2
- package/packages/core/src/cli/connect.ts +141 -38
- package/packages/core/src/cli/context.ts +23 -79
- package/packages/core/src/cli/dashboard/model.ts +36 -22
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- 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 +304 -82
- package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
- package/packages/core/src/cli/harvest.ts +84 -42
- package/packages/core/src/cli/help.ts +14 -8
- package/packages/core/src/cli/historical-build-state.ts +27 -0
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +23 -5
- package/packages/core/src/cli/mcp.ts +2 -1
- 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/self-update.ts +5 -1
- package/packages/core/src/cli/status.ts +62 -32
- package/packages/core/src/cli/terminals.ts +25 -142
- package/packages/core/src/cli/ticket-assets.ts +7 -2
- package/packages/core/src/cli/ticket-editor.ts +82 -0
- package/packages/core/src/cli/ticket.ts +166 -36
- 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/config/schema.ts +35 -3
- package/packages/core/src/connection.ts +3 -3
- package/packages/core/src/events/catalog.ts +3 -2
- package/packages/core/src/events/payloads.ts +6 -0
- package/packages/core/src/events/repository.ts +7 -3
- package/packages/core/src/integration/harness.ts +38 -4
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -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/build-log.ts +1 -1
- 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/incremental-fixtures.ts +13 -2
- 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 +139 -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/ontology.ts +1 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/control.ts +5 -6
- package/packages/core/src/operator/index.ts +7 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/query.ts +37 -20
- package/packages/core/src/operator/read-view.ts +4 -13
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/requests.ts +2 -14
- package/packages/core/src/operator/sandbox.ts +10 -9
- package/packages/core/src/operator/ticket-source-backend.ts +17 -21
- package/packages/core/src/operator/tickets.ts +132 -22
- package/packages/core/src/plugin-sdk/index.ts +19 -0
- package/packages/core/src/plugins/runtimes.ts +5 -1
- package/packages/core/src/ports/runner/session-env.ts +2 -0
- package/packages/core/src/ports/tickets/contract.ts +66 -1
- package/packages/core/src/ports/tickets/create.ts +48 -12
- package/packages/core/src/ports/tickets/fake.ts +30 -1
- package/packages/core/src/ports/tickets/file-assets.ts +159 -0
- package/packages/core/src/ports/tickets/file-codec.ts +90 -0
- package/packages/core/src/ports/tickets/file.ts +114 -120
- package/packages/core/src/ports/tickets/grants.ts +47 -0
- package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
- package/packages/core/src/ports/tickets/identity.ts +20 -0
- package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
- package/packages/core/src/ports/tickets/linear.ts +74 -9
- package/packages/core/src/ports/tickets/migration.ts +336 -0
- package/packages/core/src/ports/tickets/operations.ts +38 -7
- package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
- package/packages/core/src/ports/tickets/remote.ts +60 -3
- package/packages/core/src/ports/tickets/requested-id.ts +46 -0
- package/packages/core/src/ports/tickets/revision.ts +62 -0
- package/packages/core/src/ports/tickets/states.ts +61 -0
- package/packages/core/src/ports/types.ts +40 -3
- package/packages/core/src/ports/workspace/build-execution.ts +1 -0
- package/packages/core/src/ports/workspace/create.ts +5 -1
- package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
- 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 +223 -205
- package/packages/core/src/processes/execution-no-progress.ts +176 -0
- package/packages/core/src/processes/harvest-child.ts +30 -4
- package/packages/core/src/processes/harvest-execution-state.ts +15 -9
- package/packages/core/src/processes/harvest-runner.ts +58 -74
- package/packages/core/src/processes/harvest.ts +72 -29
- 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/phase-failure-budget.ts +1 -0
- package/packages/core/src/processes/publication-loss.ts +35 -21
- package/packages/core/src/processes/repo-view.ts +229 -1099
- 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 +283 -1
- package/packages/core/src/store/local/schema.ts +1 -0
- package/packages/core/src/store/local/store.ts +180 -55
- 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-asset-snapshots.ts +192 -0
- package/packages/core/src/store/ticket-assets.ts +86 -8
- package/packages/core/src/store/types.ts +40 -3
- 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 +97 -12
- package/skills/guide/references/operator-api.md +8 -1
- package/skills/guide/references/remote-store-protocol.md +94 -2
- package/skills/guide/references/remote-ticket-protocol.md +151 -9
- package/skills/guide/references/setup.md +33 -7
- package/skills/guide/references/ticket-migration.md +64 -0
- package/skills/tickets/SKILL.md +71 -0
- package/templates/hosted-service/package.json +2 -2
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { readBuildDigests } from '../store/reduced-reads'
|
|
2
|
+
import { readBuildFacts, type CurrentBuildFacts } from '../store/reduced-reads'
|
|
3
|
+
import { ensureExecutionProgress } from './execution-no-progress'
|
|
4
|
+
import { ticketIdentity } from '../ports/tickets/identity'
|
|
5
|
+
import { ticketLifecycle } from '../ports/tickets/lifecycle'
|
|
1
6
|
import { ensureDispatchBudget } from './dispatch-failure-budget'
|
|
2
|
-
import {
|
|
3
|
-
reduceTicketSourceHealth,
|
|
4
|
-
type TicketSourceDegradation,
|
|
5
|
-
} from '../kernel/ticket-source-health'
|
|
7
|
+
import type { TicketSourceDegradation } from '../kernel/ticket-source-health'
|
|
6
8
|
/**
|
|
7
9
|
* The dispatcher process (SPEC §3.3, §12): watches the TicketSource for
|
|
8
10
|
* Ready tickets, claims, provisions a workspace, and launches build-runners
|
|
@@ -57,19 +59,18 @@ import {
|
|
|
57
59
|
} from '../kernel/auto-merge'
|
|
58
60
|
import {
|
|
59
61
|
autoMergeDefaultTarget,
|
|
60
|
-
|
|
62
|
+
pickAutoMergeDefault,
|
|
61
63
|
type AutoMergeDefaultFact,
|
|
62
64
|
} from '../kernel/auto-merge-default'
|
|
63
|
-
import { reduceDispatchSettings } from '../kernel/dispatch-settings'
|
|
64
65
|
import { decideNextFromFacts } from '../kernel/engine'
|
|
66
|
+
import { composeBuildConfig } from '../config/live'
|
|
67
|
+
import { BUILD_EFFECTIVE_CONFIG_ARTIFACT, parseEffectiveBuildConfig } from './build-execution-state'
|
|
65
68
|
import {
|
|
66
69
|
DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
|
|
67
70
|
decideHarvestControl,
|
|
68
|
-
|
|
69
|
-
reduceHarvest,
|
|
71
|
+
harvestCreationsFromStates,
|
|
70
72
|
} from '../kernel/harvest'
|
|
71
|
-
import {
|
|
72
|
-
import { reduceBuild, type BuildState } from '../kernel/reducer'
|
|
73
|
+
import type { BuildState } from '../kernel/reducer'
|
|
73
74
|
import { createOperatorSandboxService, type OperatorSandboxService } from '../operator/sandbox'
|
|
74
75
|
import type { ArtifactRef, TicketRef } from '../ontology'
|
|
75
76
|
import { BUILD_EXECUTION_LEASE_TTL_MS } from '../ports/workspace/build-execution'
|
|
@@ -83,25 +84,15 @@ import type {
|
|
|
83
84
|
import { TicketSourceRateLimitError } from '../ports/types'
|
|
84
85
|
import type { Exec } from '../ports/workspace/git-worktree'
|
|
85
86
|
import type { ArtifactMeta, BuildDigest, BuildRecord, BuildStore, Clock } from '../store/types'
|
|
86
|
-
import {
|
|
87
|
+
import { preparePinnedAssets } from '../store/ticket-assets'
|
|
87
88
|
import { resolveRepoOrigin } from '../cli/repo-state'
|
|
88
89
|
import { specConformance } from '../spec-standard'
|
|
89
90
|
export { specConformance, type SpecConformance } from '../spec-standard'
|
|
90
91
|
import { recordInfrastructureFailure as appendInfrastructureFailure } from './infrastructure-failure-budget'
|
|
91
92
|
import { settleOpenExecution } from './execution-settlement'
|
|
92
|
-
import { openHarvestExecutions } from './harvest-execution-state'
|
|
93
93
|
import { isWorkDigest, RepoViewStore } from './repo-view'
|
|
94
|
-
import {
|
|
95
|
-
|
|
96
|
-
openBuildWorkspace,
|
|
97
|
-
provisionMarkerReducer,
|
|
98
|
-
recoveryCheckpointReducer,
|
|
99
|
-
type OpenBuildWorkspace,
|
|
100
|
-
type ProvisionMarker,
|
|
101
|
-
} from './dispatcher-selectors'
|
|
102
|
-
import type { RepositoryEvent } from '../events/repository'
|
|
103
|
-
import { sandboxStates } from './sandbox-state'
|
|
104
|
-
import { settlePublicationBeforeRelease } from './publication-loss'
|
|
94
|
+
import type { OpenBuildWorkspace, ProvisionMarker } from './dispatcher-selectors'
|
|
95
|
+
import { settlePublicationFactsBeforeRelease } from './publication-loss'
|
|
105
96
|
|
|
106
97
|
// ── Readiness resolution (SPEC §3.3) ─────────────────────────────────────────
|
|
107
98
|
|
|
@@ -148,16 +139,11 @@ export function readyCriteria(config: Config): {
|
|
|
148
139
|
* readiness is: the file tracker's grooming area IS the `triage/` directory,
|
|
149
140
|
* while a Linear team only has a "Triage" workflow state when the team's
|
|
150
141
|
* triage feature is enabled — Backlog is the state every Linear team has.
|
|
151
|
-
*
|
|
152
|
-
*
|
|
142
|
+
* Hosted projects default to lowercase triage; plugin sources use the neutral
|
|
143
|
+
* `Triage` fallback unless configured.
|
|
153
144
|
*/
|
|
154
145
|
export function defaultTriageState(config: Config): string {
|
|
155
|
-
return (
|
|
156
|
-
config.tickets.triageState ??
|
|
157
|
-
(config.tickets.source === 'linear' || config.tickets.source === 'hosted'
|
|
158
|
-
? 'Backlog'
|
|
159
|
-
: 'Triage')
|
|
160
|
-
)
|
|
146
|
+
return ticketLifecycle(config.tickets).triage
|
|
161
147
|
}
|
|
162
148
|
|
|
163
149
|
/**
|
|
@@ -547,6 +533,8 @@ export interface DispatcherDeps {
|
|
|
547
533
|
retiredWorkspaces?: readonly WorkspaceProvider[]
|
|
548
534
|
forge: Forge
|
|
549
535
|
config: Config
|
|
536
|
+
/** Configuration-only publication for incompatible pins; never launches a runner. */
|
|
537
|
+
repairBuildConfig?: (slug: string) => Promise<void>
|
|
550
538
|
/** Current process snapshot, captured once at the beginning of each tick.
|
|
551
539
|
* Omission preserves the cron/static-config API. */
|
|
552
540
|
getConfig?: () => Config
|
|
@@ -609,14 +597,14 @@ export interface DispatcherDeps {
|
|
|
609
597
|
/** Latest `workspace.provisioned` not followed by a `workspace.released` —
|
|
610
598
|
* the reducer deliberately ignores workspace events (liveness is the
|
|
611
599
|
* dispatcher's concern), so the janitor scans the raw log. */
|
|
612
|
-
function openWorkspace(events:
|
|
613
|
-
return
|
|
600
|
+
function openWorkspace(events: CurrentBuildFacts): OpenBuildWorkspace | null {
|
|
601
|
+
return events.workspace
|
|
614
602
|
}
|
|
615
603
|
|
|
616
604
|
/** The build's base branch, from its own `build.created` fact (§15.3);
|
|
617
605
|
* falls back to the repo config for logs missing one. */
|
|
618
|
-
function baseBranchOf(events:
|
|
619
|
-
return
|
|
606
|
+
function baseBranchOf(events: CurrentBuildFacts, config: Config): string {
|
|
607
|
+
return events.baseBranch ?? config.baseBranch
|
|
620
608
|
}
|
|
621
609
|
|
|
622
610
|
function artifactRefOf(deposited: ArtifactMeta[]): ArtifactRef {
|
|
@@ -647,6 +635,38 @@ export function heldByRepositoryPause(state: BuildState, paused: boolean): boole
|
|
|
647
635
|
}
|
|
648
636
|
|
|
649
637
|
export class Dispatcher {
|
|
638
|
+
/** Read at each decision boundary: artifact revisions are independent of events. */
|
|
639
|
+
private async decideBuild(
|
|
640
|
+
slug: string,
|
|
641
|
+
state: Parameters<typeof decideNextFromFacts>[0],
|
|
642
|
+
log: Parameters<typeof decideNextFromFacts>[1],
|
|
643
|
+
report: TickReport,
|
|
644
|
+
): Promise<ReturnType<typeof decideNextFromFacts> | undefined> {
|
|
645
|
+
let revision: number | undefined
|
|
646
|
+
try {
|
|
647
|
+
let artifact = await this.store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
|
|
648
|
+
if (artifact === null) return decideNextFromFacts(state, log, this.deps.config)
|
|
649
|
+
revision = artifact.meta.revision
|
|
650
|
+
let pinned: Config
|
|
651
|
+
try {
|
|
652
|
+
pinned = parseEffectiveBuildConfig(artifact)
|
|
653
|
+
} catch (error) {
|
|
654
|
+
if (this.deps.repairBuildConfig === undefined) throw error
|
|
655
|
+
await this.deps.repairBuildConfig(slug)
|
|
656
|
+
artifact = await this.store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
|
|
657
|
+
if (artifact === null) throw new Error('configuration repair deposited no pipeline')
|
|
658
|
+
revision = artifact.meta.revision
|
|
659
|
+
pinned = parseEffectiveBuildConfig(artifact)
|
|
660
|
+
}
|
|
661
|
+
return decideNextFromFacts(state, log, composeBuildConfig(pinned, this.deps.config))
|
|
662
|
+
} catch (error) {
|
|
663
|
+
report.blockedDiagnostics.push(
|
|
664
|
+
`build ${slug} pipeline${revision === undefined ? '' : ` revision ${revision}`} unavailable: pipeline decisions withheld; next tick retries. Restore readable build-branch configuration or use a compatible dispatcher version. ${error instanceof Error ? error.message : String(error)}`,
|
|
665
|
+
)
|
|
666
|
+
return undefined
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
650
670
|
/** Memoized normalized origin of the served repository (one git call per
|
|
651
671
|
* dispatcher), recorded on created builds so ambient reads can accept a
|
|
652
672
|
* differently located checkout of the same repository by origin equality.
|
|
@@ -670,7 +690,7 @@ export class Dispatcher {
|
|
|
670
690
|
return (
|
|
671
691
|
this.deps.opts?.doneState ??
|
|
672
692
|
(this.deps.config.tickets.source === 'hosted'
|
|
673
|
-
? this.deps.config.tickets.
|
|
693
|
+
? ticketLifecycle(this.deps.config.tickets).done
|
|
674
694
|
: undefined) ??
|
|
675
695
|
'Done'
|
|
676
696
|
)
|
|
@@ -725,14 +745,7 @@ export class Dispatcher {
|
|
|
725
745
|
}
|
|
726
746
|
|
|
727
747
|
async tick(opts: TickOpts = {}): Promise<TickReport> {
|
|
728
|
-
|
|
729
|
-
return await this.runTick(opts)
|
|
730
|
-
} finally {
|
|
731
|
-
// Leave this tick's reduced state behind for the next process (a cache;
|
|
732
|
-
// a failure to write it is never a tick failure). Throttled in a
|
|
733
|
-
// resident dispatcher, always run by a cold single-tick process.
|
|
734
|
-
await this.store.persistSnapshots()
|
|
735
|
-
}
|
|
748
|
+
return this.runTick(opts)
|
|
736
749
|
}
|
|
737
750
|
|
|
738
751
|
private async runTick(opts: TickOpts): Promise<TickReport> {
|
|
@@ -757,9 +770,9 @@ export class Dispatcher {
|
|
|
757
770
|
// likewise samples before calling `tick`); the auto-merge default fact is
|
|
758
771
|
// the durable driver of this tick's fan-out below. Neither control
|
|
759
772
|
// pretends to a serialization the store does not offer.
|
|
760
|
-
const
|
|
761
|
-
const paused =
|
|
762
|
-
const autoMergeDefault =
|
|
773
|
+
const repoFacts = this.store.repositoryFacts()
|
|
774
|
+
const paused = repoFacts.settings.paused
|
|
775
|
+
const autoMergeDefault = pickAutoMergeDefault(repoFacts.autoMerge)
|
|
763
776
|
// The claim-time seed cites the newest fact whose enabled state matches
|
|
764
777
|
// the launch sample, not blindly the newest fact: the CLI samples
|
|
765
778
|
// `defaultAutoMerge` from its own earlier read, so an interleaved toggle
|
|
@@ -768,7 +781,7 @@ export class Dispatcher {
|
|
|
768
781
|
// so the fan-out reconciles the build on the next tick instead of the
|
|
769
782
|
// stale sample out-voting the durable default forever (f_b851c0e8).
|
|
770
783
|
const autoMergeSeedFact =
|
|
771
|
-
opts.defaultAutoMerge === true ?
|
|
784
|
+
opts.defaultAutoMerge === true ? pickAutoMergeDefault(repoFacts.autoMerge, true) : undefined
|
|
772
785
|
// Stage 0 — SETTLEMENT: observe foreign executions from durable facts plus
|
|
773
786
|
// provider liveness and settle their completion, lease, and publication.
|
|
774
787
|
// Runs before the janitor so its executionLeaseLive checks see freshly
|
|
@@ -882,7 +895,7 @@ export class Dispatcher {
|
|
|
882
895
|
const store = this.store
|
|
883
896
|
const { repo } = this.deps
|
|
884
897
|
const records = (await store.listBuilds()).filter((record) => record.repo === repo)
|
|
885
|
-
const digests = await store
|
|
898
|
+
const digests = await readBuildDigests(store, repo)
|
|
886
899
|
return records.map((record) => {
|
|
887
900
|
const digest = digests.get(record.slug)
|
|
888
901
|
if (digest === undefined) {
|
|
@@ -918,7 +931,7 @@ export class Dispatcher {
|
|
|
918
931
|
successfulOperation?: 'ready-scan' | 'dependency-check',
|
|
919
932
|
): Promise<void> {
|
|
920
933
|
await this.store.refreshJournal()
|
|
921
|
-
const current =
|
|
934
|
+
const current = this.store.repositoryFacts().health
|
|
922
935
|
if (next !== undefined && current?.cause === next.cause && current.source === next.source) {
|
|
923
936
|
if (
|
|
924
937
|
next.cause === 'rate-limited' &&
|
|
@@ -988,6 +1001,23 @@ export class Dispatcher {
|
|
|
988
1001
|
}
|
|
989
1002
|
}
|
|
990
1003
|
|
|
1004
|
+
private async activeTicketIdentities(refs: TicketRef[], listed: Ticket[]): Promise<Set<string>> {
|
|
1005
|
+
const key = (ref: TicketRef) => `${ref.source}:${ref.id}`
|
|
1006
|
+
const ids = new Set(refs.map(key))
|
|
1007
|
+
if (this.deps.tickets.name !== 'hosted' || listed.length === 0) return ids
|
|
1008
|
+
const listedIds = new Set(listed.map((ticket) => key(ticket.ref)))
|
|
1009
|
+
const identity = ticketIdentity(this.deps.tickets)
|
|
1010
|
+
try {
|
|
1011
|
+
for (const ref of refs) {
|
|
1012
|
+
if (ref.source === 'hosted' && !listedIds.has(key(ref))) ids.add(await identity(ref))
|
|
1013
|
+
}
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
await this.sourceFailure(error, 'ready-scan')
|
|
1016
|
+
throw error
|
|
1017
|
+
}
|
|
1018
|
+
return ids
|
|
1019
|
+
}
|
|
1020
|
+
|
|
991
1021
|
/** Observation-only ready listing for Store-backed frontends while intake is
|
|
992
1022
|
* disabled. It shares the active-ticket exclusion with dispatch but performs
|
|
993
1023
|
* no dependency fetch, claim, bounce, build, workspace, or launch side
|
|
@@ -999,20 +1029,21 @@ export class Dispatcher {
|
|
|
999
1029
|
}> {
|
|
1000
1030
|
this.deps.config = this.deps.getConfig?.() ?? this.deps.config
|
|
1001
1031
|
await this.store.refresh()
|
|
1002
|
-
const activeTicketIds = new Set<string>()
|
|
1003
|
-
for (const { record, digest } of await this.repoBuildDigests()) {
|
|
1004
|
-
if (record.ticket !== undefined && digest.terminal === undefined) {
|
|
1005
|
-
activeTicketIds.add(record.ticket.id)
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
1032
|
const report = emptyTickReport()
|
|
1009
1033
|
this.restoreScan(report)
|
|
1010
1034
|
const checkpoint = await this.scanCheckpoint('observation')
|
|
1011
1035
|
if (checkpoint.due) {
|
|
1012
1036
|
await this.store.refreshJournal()
|
|
1013
1037
|
const listing = await this.listReady()
|
|
1038
|
+
const refs: TicketRef[] = []
|
|
1039
|
+
for (const { record, digest } of await this.repoBuildDigests()) {
|
|
1040
|
+
if (record.ticket && digest.terminal === undefined) refs.push(record.ticket)
|
|
1041
|
+
}
|
|
1042
|
+
const activeTicketIds = await this.activeTicketIdentities(refs, listing.tickets)
|
|
1014
1043
|
await this.store.refreshJournal()
|
|
1015
|
-
report.queued = listing.tickets.filter(
|
|
1044
|
+
report.queued = listing.tickets.filter(
|
|
1045
|
+
(ticket) => !activeTicketIds.has(`${ticket.ref.source}:${ticket.ref.id}`),
|
|
1046
|
+
).length
|
|
1016
1047
|
report.invalidTickets = listing.diagnostics.length
|
|
1017
1048
|
report.ticketDiagnostics = [...listing.diagnostics]
|
|
1018
1049
|
report.dependencyBlocked = 0
|
|
@@ -1030,12 +1061,7 @@ export class Dispatcher {
|
|
|
1030
1061
|
}
|
|
1031
1062
|
|
|
1032
1063
|
private latestScan() {
|
|
1033
|
-
return this.store
|
|
1034
|
-
.recordedJournal()
|
|
1035
|
-
.findLast(
|
|
1036
|
-
(event): event is Extract<RepositoryEvent, { type: 'dispatcher.ticket-scan-completed' }> =>
|
|
1037
|
-
event.type === 'dispatcher.ticket-scan-completed',
|
|
1038
|
-
)?.payload
|
|
1064
|
+
return this.store.repositoryFacts().scan
|
|
1039
1065
|
}
|
|
1040
1066
|
|
|
1041
1067
|
private restoreScan(report: TickReport): void {
|
|
@@ -1167,15 +1193,30 @@ export class Dispatcher {
|
|
|
1167
1193
|
return
|
|
1168
1194
|
}
|
|
1169
1195
|
// Bounded subset (AUT-489): the harvest reducer consumes durable types only.
|
|
1170
|
-
const state =
|
|
1196
|
+
const state = this.store.repositoryFacts().harvest
|
|
1171
1197
|
if (decideHarvestControl(state, this.maxHarvestRecoveryAttempts).kind !== 'park') {
|
|
1172
1198
|
start()
|
|
1173
1199
|
}
|
|
1174
1200
|
}
|
|
1175
1201
|
|
|
1176
|
-
private async launch(
|
|
1202
|
+
private async launch(
|
|
1203
|
+
slug: string,
|
|
1204
|
+
launched: Set<string>,
|
|
1205
|
+
report: TickReport = emptyTickReport(),
|
|
1206
|
+
): Promise<LaunchRunnerResult> {
|
|
1177
1207
|
if (launched.has(slug)) return 'already-active'
|
|
1178
1208
|
launched.add(slug)
|
|
1209
|
+
if (
|
|
1210
|
+
!(await ensureExecutionProgress(
|
|
1211
|
+
this.store,
|
|
1212
|
+
this.deps.ids,
|
|
1213
|
+
slug,
|
|
1214
|
+
this.deps.config,
|
|
1215
|
+
(state, log) => this.decideBuild(slug, state, log, report),
|
|
1216
|
+
))
|
|
1217
|
+
) {
|
|
1218
|
+
return 'already-active'
|
|
1219
|
+
}
|
|
1179
1220
|
return this.deps.launchRunner(slug)
|
|
1180
1221
|
}
|
|
1181
1222
|
|
|
@@ -1238,13 +1279,13 @@ export class Dispatcher {
|
|
|
1238
1279
|
* repository lease. */
|
|
1239
1280
|
private async settleHarvestExecutions(opts: TickOpts = {}): Promise<void> {
|
|
1240
1281
|
const active = this.deps.activeHarvestExecutions?.() ?? new Set<string>()
|
|
1241
|
-
let
|
|
1282
|
+
let facts: ReturnType<RepoViewStore['repositoryFacts']>
|
|
1242
1283
|
try {
|
|
1243
1284
|
// The earlier stages awaited providers; take the journal delta so a
|
|
1244
1285
|
// foreign append (operator activity, harvest facts) made meanwhile is
|
|
1245
1286
|
// seen before any destructive decision below.
|
|
1246
1287
|
await this.store.refreshJournal()
|
|
1247
|
-
|
|
1288
|
+
facts = this.store.repositoryFacts()
|
|
1248
1289
|
} catch {
|
|
1249
1290
|
// The read itself failed (a transient store error — a missing record
|
|
1250
1291
|
// answers `[]` inside the helper, so it never reaches this arm):
|
|
@@ -1252,7 +1293,7 @@ export class Dispatcher {
|
|
|
1252
1293
|
// tick proceeds, and the next tick retries what remains.
|
|
1253
1294
|
return
|
|
1254
1295
|
}
|
|
1255
|
-
for (const execution of
|
|
1296
|
+
for (const execution of facts.openHarvest) {
|
|
1256
1297
|
if (active.has(execution.execution)) continue
|
|
1257
1298
|
// Budget gate: once spent, stop settling further harvest executions;
|
|
1258
1299
|
// the next tick retries what remains.
|
|
@@ -1341,13 +1382,13 @@ export class Dispatcher {
|
|
|
1341
1382
|
const capability = this.deps.workspaces.orchestratorSandbox
|
|
1342
1383
|
if (capability === undefined) return
|
|
1343
1384
|
const providerName = this.deps.workspaces.name
|
|
1344
|
-
let
|
|
1385
|
+
let facts: ReturnType<RepoViewStore['repositoryFacts']>
|
|
1345
1386
|
try {
|
|
1346
1387
|
// The earlier stages awaited providers; take the journal delta so a
|
|
1347
1388
|
// foreign append (operator activity, harvest facts) made meanwhile is
|
|
1348
1389
|
// seen before any destructive decision below.
|
|
1349
1390
|
await this.store.refreshJournal()
|
|
1350
|
-
|
|
1391
|
+
facts = this.store.repositoryFacts()
|
|
1351
1392
|
} catch {
|
|
1352
1393
|
// The read itself failed (a transient store error — a missing record
|
|
1353
1394
|
// answers `[]` inside the helper, so it never reaches this arm):
|
|
@@ -1356,7 +1397,7 @@ export class Dispatcher {
|
|
|
1356
1397
|
return
|
|
1357
1398
|
}
|
|
1358
1399
|
const idleMs = config.orchestrator.sandbox.idleMinutes * 60_000
|
|
1359
|
-
for (const environment of
|
|
1400
|
+
for (const environment of facts.sandboxes) {
|
|
1360
1401
|
if (environment.state !== 'live') continue
|
|
1361
1402
|
// Budget gate: once spent, stop settling further environments; the
|
|
1362
1403
|
// next tick retries what remains.
|
|
@@ -1438,8 +1479,8 @@ export class Dispatcher {
|
|
|
1438
1479
|
|
|
1439
1480
|
/** The latest `workspace.provision-started` not yet followed by a
|
|
1440
1481
|
* provisioned/released fact — an open provisioning marker. */
|
|
1441
|
-
private openProvisionMarker(events:
|
|
1442
|
-
return
|
|
1482
|
+
private openProvisionMarker(events: CurrentBuildFacts): ProvisionMarker | undefined {
|
|
1483
|
+
return events.provisionMarker
|
|
1443
1484
|
}
|
|
1444
1485
|
|
|
1445
1486
|
/** Liveness of the lease behind an open provision marker: `live` means a
|
|
@@ -1460,7 +1501,7 @@ export class Dispatcher {
|
|
|
1460
1501
|
* report progress without awaiting any of it. */
|
|
1461
1502
|
private async ensureProvisionContinuation(
|
|
1462
1503
|
record: BuildRecord,
|
|
1463
|
-
events:
|
|
1504
|
+
events: CurrentBuildFacts,
|
|
1464
1505
|
tail: 'dispatch' | 'launch',
|
|
1465
1506
|
seed?: {
|
|
1466
1507
|
ticket: Ticket
|
|
@@ -1476,12 +1517,7 @@ export class Dispatcher {
|
|
|
1476
1517
|
const marker = this.openProvisionMarker(events)
|
|
1477
1518
|
if (marker !== undefined) {
|
|
1478
1519
|
const liveness = await this.provisionMarkerLiveness(slug)
|
|
1479
|
-
const failedAttempt = events.
|
|
1480
|
-
(event) =>
|
|
1481
|
-
event.type === 'dispatch.failed' &&
|
|
1482
|
-
event.payload.stage === 'workspace' &&
|
|
1483
|
-
event.seq > marker.seq,
|
|
1484
|
-
)
|
|
1520
|
+
const failedAttempt = events.latestWorkspaceFailureSeq > marker.seq
|
|
1485
1521
|
if (liveness === 'live') return 'foreign-live'
|
|
1486
1522
|
if (
|
|
1487
1523
|
liveness === 'absent' &&
|
|
@@ -1496,7 +1532,7 @@ export class Dispatcher {
|
|
|
1496
1532
|
// Expired lease, failed attempt, or an old lease-less marker: adopt.
|
|
1497
1533
|
}
|
|
1498
1534
|
const branch = record.branch ?? `ab/${slug}`
|
|
1499
|
-
const generation = events.
|
|
1535
|
+
const generation = events.workspaceGenerations
|
|
1500
1536
|
await this.store.append(slug, {
|
|
1501
1537
|
actor: DISPATCHER,
|
|
1502
1538
|
type: 'workspace.provision-started',
|
|
@@ -1576,10 +1612,8 @@ export class Dispatcher {
|
|
|
1576
1612
|
// so the next supervisor adopts the marker immediately.
|
|
1577
1613
|
return
|
|
1578
1614
|
}
|
|
1579
|
-
const events = await store
|
|
1580
|
-
const priorGenerations = events.
|
|
1581
|
-
(event) => event.type === 'workspace.provisioned',
|
|
1582
|
-
).length
|
|
1615
|
+
const events = (await readBuildFacts(store, slug)).value
|
|
1616
|
+
const priorGenerations = events.workspaceGenerations
|
|
1583
1617
|
const handle = await workspaces.provision({
|
|
1584
1618
|
// Local providers provision FROM the physical checkout; remote
|
|
1585
1619
|
// providers ignore the value (their host seams are injected).
|
|
@@ -1622,7 +1656,7 @@ export class Dispatcher {
|
|
|
1622
1656
|
boundary = 'read'
|
|
1623
1657
|
const record = await store.getBuild(slug)
|
|
1624
1658
|
if (record === null) return
|
|
1625
|
-
const queued =
|
|
1659
|
+
const queued = (await readBuildFacts(store, slug)).value.state.status === 'queued'
|
|
1626
1660
|
boundary = input.tail === 'dispatch' || queued ? 'dispatch' : 'launch'
|
|
1627
1661
|
if (boundary === 'dispatch') await this.completeDispatchBoundaries(record, input.seed)
|
|
1628
1662
|
else await this.deps.launchRunner(slug)
|
|
@@ -1632,10 +1666,10 @@ export class Dispatcher {
|
|
|
1632
1666
|
// tick; it is not another provider workspace failure. Replacement
|
|
1633
1667
|
// runner launch errors retain the existing infrastructure-budget path.
|
|
1634
1668
|
if (boundary === 'read' || boundary === 'dispatch') return
|
|
1635
|
-
const events = await store
|
|
1669
|
+
const events = (await readBuildFacts(store, slug)).value
|
|
1636
1670
|
if (workspaces.recovery !== undefined) {
|
|
1637
1671
|
const open = openWorkspace(events)
|
|
1638
|
-
const latestRef =
|
|
1672
|
+
const latestRef = events.latestRelease
|
|
1639
1673
|
await this.recordInfrastructureFailure(slug, events, {
|
|
1640
1674
|
provider: workspaces.name,
|
|
1641
1675
|
workspaceRef:
|
|
@@ -1648,7 +1682,7 @@ export class Dispatcher {
|
|
|
1648
1682
|
cleanupPending: true,
|
|
1649
1683
|
})
|
|
1650
1684
|
}
|
|
1651
|
-
const attempt = events.
|
|
1685
|
+
const attempt = events.dispatchFailureCount + 1
|
|
1652
1686
|
const message = (error instanceof Error ? error.message : String(error)).trim()
|
|
1653
1687
|
await store.append(slug, {
|
|
1654
1688
|
actor: DISPATCHER,
|
|
@@ -1697,9 +1731,9 @@ export class Dispatcher {
|
|
|
1697
1731
|
for (const entry of entries) entry.controller.abort()
|
|
1698
1732
|
for (const entry of entries) {
|
|
1699
1733
|
try {
|
|
1700
|
-
const events = await this.store
|
|
1734
|
+
const events = (await readBuildFacts(this.store, entry.slug)).value
|
|
1701
1735
|
if (this.openProvisionMarker(events) !== undefined) {
|
|
1702
|
-
const attempt = events.
|
|
1736
|
+
const attempt = events.dispatchFailureCount + 1
|
|
1703
1737
|
await this.store.append(entry.slug, {
|
|
1704
1738
|
actor: DISPATCHER,
|
|
1705
1739
|
type: 'dispatch.failed',
|
|
@@ -1749,9 +1783,9 @@ export class Dispatcher {
|
|
|
1749
1783
|
const executionLeaseLive = this.hasLiveExecutionLease(record)
|
|
1750
1784
|
// Raw history, read at most once and only by a branch that acts on it:
|
|
1751
1785
|
// the facts above answer every quiet-path question without it.
|
|
1752
|
-
let loaded:
|
|
1753
|
-
const history = async (): Promise<
|
|
1754
|
-
loaded ??= await this.store
|
|
1786
|
+
let loaded: CurrentBuildFacts | undefined
|
|
1787
|
+
const history = async (): Promise<CurrentBuildFacts> => {
|
|
1788
|
+
loaded ??= (await readBuildFacts(this.store, record.slug)).value
|
|
1755
1789
|
return loaded
|
|
1756
1790
|
}
|
|
1757
1791
|
// Pipeline/ticket/workspace state is settled, but release-asset cleanup
|
|
@@ -1814,7 +1848,7 @@ export class Dispatcher {
|
|
|
1814
1848
|
}
|
|
1815
1849
|
}
|
|
1816
1850
|
|
|
1817
|
-
private forgeWorkspacePath(events:
|
|
1851
|
+
private forgeWorkspacePath(events: CurrentBuildFacts): string {
|
|
1818
1852
|
return this.forgeWorkspacePathOf(openWorkspace(events))
|
|
1819
1853
|
}
|
|
1820
1854
|
|
|
@@ -1838,7 +1872,7 @@ export class Dispatcher {
|
|
|
1838
1872
|
* repeating on later ticks. `build.completed` is deliberately last. */
|
|
1839
1873
|
private async cleanupAborted(
|
|
1840
1874
|
record: BuildRecord,
|
|
1841
|
-
events:
|
|
1875
|
+
events: CurrentBuildFacts,
|
|
1842
1876
|
report: TickReport,
|
|
1843
1877
|
): Promise<void> {
|
|
1844
1878
|
const store = this.store
|
|
@@ -1852,13 +1886,11 @@ export class Dispatcher {
|
|
|
1852
1886
|
// in the main checkout, and origin mode has no checkout at all: the
|
|
1853
1887
|
// local-branch git step is skipped, and the saga's later steps tolerate
|
|
1854
1888
|
// its absence.
|
|
1855
|
-
const hadLocalWorkspace = events.
|
|
1856
|
-
|
|
1857
|
-
)
|
|
1858
|
-
const has = (type: AbEvent['type']): boolean => events.some((event) => event.type === type)
|
|
1889
|
+
const hadLocalWorkspace = events.localWorkspace
|
|
1890
|
+
const has = (type: AbEvent['type']): boolean => events.seenTypes.includes(type)
|
|
1859
1891
|
const append = async <T extends EventWrite['type']>(write: EventWrite<T>): Promise<void> => {
|
|
1860
|
-
const
|
|
1861
|
-
|
|
1892
|
+
const _event = await store.append(record.slug, write)
|
|
1893
|
+
Object.assign(events, (await readBuildFacts(store, record.slug)).value)
|
|
1862
1894
|
}
|
|
1863
1895
|
const fail = (operation: string, identity: string, error: unknown): never => {
|
|
1864
1896
|
const detail = error instanceof Error ? error.message : String(error)
|
|
@@ -1875,7 +1907,7 @@ export class Dispatcher {
|
|
|
1875
1907
|
}
|
|
1876
1908
|
}
|
|
1877
1909
|
|
|
1878
|
-
const reduced =
|
|
1910
|
+
const reduced = events.state
|
|
1879
1911
|
if (reduced.pr !== undefined && !has('pr.closed') && !has('pr.merged')) {
|
|
1880
1912
|
const closePr = forge.closePr
|
|
1881
1913
|
if (closePr === undefined) {
|
|
@@ -1905,7 +1937,8 @@ export class Dispatcher {
|
|
|
1905
1937
|
|
|
1906
1938
|
try {
|
|
1907
1939
|
const released = await this.releaseWorkspace(record.slug, events, 'abort')
|
|
1908
|
-
if (released !== undefined)
|
|
1940
|
+
if (released !== undefined)
|
|
1941
|
+
Object.assign(events, (await readBuildFacts(store, record.slug)).value)
|
|
1909
1942
|
} catch (error) {
|
|
1910
1943
|
fail('workspace release', `build ${record.slug}`, error)
|
|
1911
1944
|
}
|
|
@@ -2002,7 +2035,7 @@ export class Dispatcher {
|
|
|
2002
2035
|
* every partial cleanup is retried after a crash. */
|
|
2003
2036
|
private async cleanupDiscarded(
|
|
2004
2037
|
record: BuildRecord,
|
|
2005
|
-
events:
|
|
2038
|
+
events: CurrentBuildFacts,
|
|
2006
2039
|
report: TickReport,
|
|
2007
2040
|
): Promise<void> {
|
|
2008
2041
|
const store = this.store
|
|
@@ -2032,7 +2065,7 @@ export class Dispatcher {
|
|
|
2032
2065
|
record: BuildRecord,
|
|
2033
2066
|
// `workspace` is the open workspace fact; `history` loads the raw log, which
|
|
2034
2067
|
// only the branches that act on a PR transition need.
|
|
2035
|
-
source: { history: () => Promise<
|
|
2068
|
+
source: { history: () => Promise<CurrentBuildFacts>; workspace: OpenBuildWorkspace | null },
|
|
2036
2069
|
state: BuildState,
|
|
2037
2070
|
report: TickReport,
|
|
2038
2071
|
launched: Set<string>,
|
|
@@ -2072,12 +2105,12 @@ export class Dispatcher {
|
|
|
2072
2105
|
if (state.prState === 'conflicted') return
|
|
2073
2106
|
const events = await source.history()
|
|
2074
2107
|
const baseSha = await this.baseSha(baseBranchOf(events, this.deps.config))
|
|
2075
|
-
const
|
|
2108
|
+
const _conflicted = await store.append(record.slug, {
|
|
2076
2109
|
actor: DISPATCHER,
|
|
2077
2110
|
type: 'pr.conflicted',
|
|
2078
2111
|
payload: { baseSha },
|
|
2079
2112
|
} satisfies EventWrite<'pr.conflicted'>)
|
|
2080
|
-
events.
|
|
2113
|
+
Object.assign(events, (await readBuildFacts(this.store, record.slug)).value)
|
|
2081
2114
|
if (openWorkspace(events) === null && this.deps.workspaces.recovery !== undefined) {
|
|
2082
2115
|
// The reconcile workspace is provisioned by the tracked background
|
|
2083
2116
|
// continuation, whose tail re-attaches the build-runner; this tick
|
|
@@ -2089,7 +2122,7 @@ export class Dispatcher {
|
|
|
2089
2122
|
}
|
|
2090
2123
|
// The dispatcher never runs agents (§15.7): re-attach a build-runner,
|
|
2091
2124
|
// which executes the reconcile epilogue phase.
|
|
2092
|
-
await this.launch(record.slug, launched)
|
|
2125
|
+
await this.launch(record.slug, launched, report)
|
|
2093
2126
|
report.conflicted += 1
|
|
2094
2127
|
return
|
|
2095
2128
|
}
|
|
@@ -2123,12 +2156,12 @@ export class Dispatcher {
|
|
|
2123
2156
|
const latest = await store.buildFacts(record.slug)
|
|
2124
2157
|
const latestState = latest.state
|
|
2125
2158
|
const latestIntent = pendingAutoMerge(latestState)
|
|
2126
|
-
const decision =
|
|
2159
|
+
const decision = await this.decideBuild(record.slug, latest.state, latest.log, report)
|
|
2127
2160
|
if (
|
|
2128
2161
|
latestState.pr?.number === pr.number &&
|
|
2129
2162
|
latestIntent?.enabled === true &&
|
|
2130
2163
|
latestIntent.commandSeq === autoMerge.commandSeq &&
|
|
2131
|
-
decision
|
|
2164
|
+
decision?.kind === 'wait' &&
|
|
2132
2165
|
decision.reason === 'awaiting-pr'
|
|
2133
2166
|
) {
|
|
2134
2167
|
await forge.squashMerge(workspacePath, pr.number, result.headSha)
|
|
@@ -2203,12 +2236,9 @@ export class Dispatcher {
|
|
|
2203
2236
|
* is deliberately post-terminal and best-effort: a provider, timeout, or
|
|
2204
2237
|
* store failure cannot roll back build.completed/ticket/workspace work.
|
|
2205
2238
|
* Pending handles remain derivable and are retried on every later tick. */
|
|
2206
|
-
private async reclaimPrAttachments(slug: string, events:
|
|
2207
|
-
for (const hosted of
|
|
2208
|
-
const priorAttempts = events.
|
|
2209
|
-
(event) =>
|
|
2210
|
-
event.type === 'pr-attachment.reclaim-failed' && event.payload.hostedSeq === hosted.seq,
|
|
2211
|
-
).length
|
|
2239
|
+
private async reclaimPrAttachments(slug: string, events: CurrentBuildFacts): Promise<void> {
|
|
2240
|
+
for (const hosted of events.pendingAttachments) {
|
|
2241
|
+
const priorAttempts = events.reclaimCounts[String(hosted.seq)] ?? 0
|
|
2212
2242
|
try {
|
|
2213
2243
|
const capability = this.deps.forge.prAttachments
|
|
2214
2244
|
if (capability === undefined) {
|
|
@@ -2248,13 +2278,13 @@ export class Dispatcher {
|
|
|
2248
2278
|
}
|
|
2249
2279
|
}
|
|
2250
2280
|
|
|
2251
|
-
private recoveryCheckpoint(events:
|
|
2252
|
-
return
|
|
2281
|
+
private recoveryCheckpoint(events: CurrentBuildFacts): string | undefined {
|
|
2282
|
+
return events.recoveryCheckpoint
|
|
2253
2283
|
}
|
|
2254
2284
|
|
|
2255
2285
|
private async recordInfrastructureFailure(
|
|
2256
2286
|
slug: string,
|
|
2257
|
-
events:
|
|
2287
|
+
events: CurrentBuildFacts,
|
|
2258
2288
|
input: {
|
|
2259
2289
|
provider: string
|
|
2260
2290
|
workspaceRef: string
|
|
@@ -2263,8 +2293,8 @@ export class Dispatcher {
|
|
|
2263
2293
|
cleanupPending: boolean
|
|
2264
2294
|
},
|
|
2265
2295
|
): Promise<void> {
|
|
2266
|
-
const execution =
|
|
2267
|
-
const
|
|
2296
|
+
const execution = events.state.executions.at(-1)
|
|
2297
|
+
const _appended = await appendInfrastructureFailure(
|
|
2268
2298
|
{ store: this.store, ids: this.deps.ids },
|
|
2269
2299
|
{
|
|
2270
2300
|
slug,
|
|
@@ -2272,22 +2302,19 @@ export class Dispatcher {
|
|
|
2272
2302
|
maxAttempts: this.deps.config.policy.maxInfrastructureAttempts,
|
|
2273
2303
|
provider: input.provider,
|
|
2274
2304
|
workspaceRef: input.workspaceRef,
|
|
2275
|
-
instance:
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
: 'dispatcher-recovery',
|
|
2279
|
-
...(execution?.type === 'execution.started' && execution.payload.environmentId !== undefined
|
|
2280
|
-
? { environmentId: execution.payload.environmentId }
|
|
2305
|
+
instance: execution !== undefined ? execution.instance : 'dispatcher-recovery',
|
|
2306
|
+
...(execution !== undefined && execution.environmentId !== undefined
|
|
2307
|
+
? { environmentId: execution.environmentId }
|
|
2281
2308
|
: {}),
|
|
2282
|
-
...(execution
|
|
2283
|
-
? { sessionId: execution.
|
|
2309
|
+
...(execution !== undefined && execution.sessionId !== undefined
|
|
2310
|
+
? { sessionId: execution.sessionId }
|
|
2284
2311
|
: {}),
|
|
2285
2312
|
operation: input.operation,
|
|
2286
2313
|
error: input.error,
|
|
2287
2314
|
cleanupPending: input.cleanupPending,
|
|
2288
2315
|
},
|
|
2289
2316
|
)
|
|
2290
|
-
events.
|
|
2317
|
+
Object.assign(events, (await readBuildFacts(this.store, slug)).value)
|
|
2291
2318
|
}
|
|
2292
2319
|
|
|
2293
2320
|
/** The provider that owns a recorded workspace: the one named on its
|
|
@@ -2303,7 +2330,7 @@ export class Dispatcher {
|
|
|
2303
2330
|
* only after exact-name cleanup is durably acknowledged. */
|
|
2304
2331
|
private async reapStaleWorkspace(
|
|
2305
2332
|
slug: string,
|
|
2306
|
-
events:
|
|
2333
|
+
events: CurrentBuildFacts,
|
|
2307
2334
|
reason: 'pause' | 'blocked' | 'replacement',
|
|
2308
2335
|
): Promise<boolean> {
|
|
2309
2336
|
const open = openWorkspace(events)
|
|
@@ -2314,18 +2341,13 @@ export class Dispatcher {
|
|
|
2314
2341
|
// publication's fate BEFORE the destructive reap. The guard's append
|
|
2315
2342
|
// failure propagates — a stale workspace with an unrecorded pending
|
|
2316
2343
|
// request is never reaped (the next tick retries).
|
|
2317
|
-
events = await
|
|
2344
|
+
events = await settlePublicationFactsBeforeRelease(
|
|
2318
2345
|
{ store: this.store, settlePublication: this.settlePublication },
|
|
2319
2346
|
slug,
|
|
2320
2347
|
events,
|
|
2321
2348
|
reason,
|
|
2322
2349
|
)
|
|
2323
|
-
const attempt =
|
|
2324
|
-
events.filter(
|
|
2325
|
-
(event) =>
|
|
2326
|
-
event.type === 'infrastructure.cleanup-attempted' &&
|
|
2327
|
-
event.payload.workspaceRef === open.ref,
|
|
2328
|
-
).length + 1
|
|
2350
|
+
const attempt = (events.cleanupCounts[JSON.stringify(open.ref)] ?? 0) + 1
|
|
2329
2351
|
try {
|
|
2330
2352
|
const outcome = await recovery.reap({
|
|
2331
2353
|
provider: open.provider,
|
|
@@ -2334,7 +2356,7 @@ export class Dispatcher {
|
|
|
2334
2356
|
...(open.localPath !== undefined ? { localPath: open.localPath } : {}),
|
|
2335
2357
|
branch: open.branch,
|
|
2336
2358
|
})
|
|
2337
|
-
const
|
|
2359
|
+
const _cleaned = await this.store.append(slug, {
|
|
2338
2360
|
actor: DISPATCHER,
|
|
2339
2361
|
type: 'infrastructure.cleanup-attempted',
|
|
2340
2362
|
payload: {
|
|
@@ -2346,17 +2368,17 @@ export class Dispatcher {
|
|
|
2346
2368
|
snapshots: outcome.snapshots,
|
|
2347
2369
|
},
|
|
2348
2370
|
})
|
|
2349
|
-
events.
|
|
2350
|
-
const
|
|
2371
|
+
Object.assign(events, (await readBuildFacts(this.store, slug)).value)
|
|
2372
|
+
const _released = await this.store.append(slug, {
|
|
2351
2373
|
actor: DISPATCHER,
|
|
2352
2374
|
type: 'workspace.released',
|
|
2353
2375
|
payload: { ref: open.ref, reason },
|
|
2354
2376
|
})
|
|
2355
|
-
events.
|
|
2377
|
+
Object.assign(events, (await readBuildFacts(this.store, slug)).value)
|
|
2356
2378
|
return true
|
|
2357
2379
|
} catch (error) {
|
|
2358
2380
|
const message = error instanceof Error ? error.message : String(error)
|
|
2359
|
-
const
|
|
2381
|
+
const _failed = await this.store.append(slug, {
|
|
2360
2382
|
actor: DISPATCHER,
|
|
2361
2383
|
type: 'infrastructure.cleanup-attempted',
|
|
2362
2384
|
payload: {
|
|
@@ -2368,7 +2390,7 @@ export class Dispatcher {
|
|
|
2368
2390
|
error: message || 'cleanup failed without an error message',
|
|
2369
2391
|
},
|
|
2370
2392
|
})
|
|
2371
|
-
events.
|
|
2393
|
+
Object.assign(events, (await readBuildFacts(this.store, slug)).value)
|
|
2372
2394
|
await this.recordInfrastructureFailure(slug, events, {
|
|
2373
2395
|
provider: open.provider,
|
|
2374
2396
|
workspaceRef: open.ref,
|
|
@@ -2384,7 +2406,7 @@ export class Dispatcher {
|
|
|
2384
2406
|
* append `workspace.released`. Log-deduped, so re-runs are no-ops. */
|
|
2385
2407
|
private async releaseWorkspace(
|
|
2386
2408
|
slug: string,
|
|
2387
|
-
events:
|
|
2409
|
+
events: CurrentBuildFacts,
|
|
2388
2410
|
reason: 'completion' | 'abort' | 'pause' | 'blocked' | 'replacement' | 'discard' = 'completion',
|
|
2389
2411
|
): Promise<AbEvent | undefined> {
|
|
2390
2412
|
const open = openWorkspace(events)
|
|
@@ -2403,7 +2425,7 @@ export class Dispatcher {
|
|
|
2403
2425
|
// pending publication BEFORE any provider cleanup call, while the
|
|
2404
2426
|
// workspace still exists. The guard's append failure propagates — the
|
|
2405
2427
|
// release never proceeds past an unrecorded pending request.
|
|
2406
|
-
events = await
|
|
2428
|
+
events = await settlePublicationFactsBeforeRelease(
|
|
2407
2429
|
{ store: this.store, settlePublication: this.settlePublication },
|
|
2408
2430
|
slug,
|
|
2409
2431
|
events,
|
|
@@ -2411,15 +2433,10 @@ export class Dispatcher {
|
|
|
2411
2433
|
)
|
|
2412
2434
|
const recovery = owner.recovery
|
|
2413
2435
|
if (recovery !== undefined) {
|
|
2414
|
-
const attempt =
|
|
2415
|
-
events.filter(
|
|
2416
|
-
(event) =>
|
|
2417
|
-
event.type === 'infrastructure.cleanup-attempted' &&
|
|
2418
|
-
event.payload.workspaceRef === open.ref,
|
|
2419
|
-
).length + 1
|
|
2436
|
+
const attempt = (events.cleanupCounts[JSON.stringify(open.ref)] ?? 0) + 1
|
|
2420
2437
|
try {
|
|
2421
2438
|
const outcome = await recovery.reap(handle)
|
|
2422
|
-
const
|
|
2439
|
+
const _cleaned = await this.store.append(slug, {
|
|
2423
2440
|
actor: DISPATCHER,
|
|
2424
2441
|
type: 'infrastructure.cleanup-attempted',
|
|
2425
2442
|
payload: {
|
|
@@ -2431,10 +2448,10 @@ export class Dispatcher {
|
|
|
2431
2448
|
snapshots: outcome.snapshots,
|
|
2432
2449
|
},
|
|
2433
2450
|
})
|
|
2434
|
-
events.
|
|
2451
|
+
Object.assign(events, (await readBuildFacts(this.store, slug)).value)
|
|
2435
2452
|
} catch (error) {
|
|
2436
2453
|
const message = error instanceof Error ? error.message : String(error)
|
|
2437
|
-
const
|
|
2454
|
+
const _failed = await this.store.append(slug, {
|
|
2438
2455
|
actor: DISPATCHER,
|
|
2439
2456
|
type: 'infrastructure.cleanup-attempted',
|
|
2440
2457
|
payload: {
|
|
@@ -2446,7 +2463,7 @@ export class Dispatcher {
|
|
|
2446
2463
|
error: message || 'cleanup failed without an error message',
|
|
2447
2464
|
},
|
|
2448
2465
|
})
|
|
2449
|
-
events.
|
|
2466
|
+
Object.assign(events, (await readBuildFacts(this.store, slug)).value)
|
|
2450
2467
|
await this.recordInfrastructureFailure(slug, events, {
|
|
2451
2468
|
provider: open.provider,
|
|
2452
2469
|
workspaceRef: open.ref,
|
|
@@ -2572,9 +2589,9 @@ export class Dispatcher {
|
|
|
2572
2589
|
if (!(await this.dispatchBudget(record.slug))) return 'parked'
|
|
2573
2590
|
let stage: EventPayload<'dispatch.failed'>['stage'] = 'create'
|
|
2574
2591
|
try {
|
|
2575
|
-
let events = await store
|
|
2576
|
-
if (
|
|
2577
|
-
if (events.
|
|
2592
|
+
let events = (await readBuildFacts(store, record.slug)).value
|
|
2593
|
+
if (events.created === undefined) {
|
|
2594
|
+
if (events.state.lastSeq !== 0 || record.ticket === undefined) {
|
|
2578
2595
|
throw new Error(
|
|
2579
2596
|
'build record lacks the immutable ticket facts needed to reconstruct build.created',
|
|
2580
2597
|
)
|
|
@@ -2584,28 +2601,32 @@ export class Dispatcher {
|
|
|
2584
2601
|
// can only sample the ticket's current assets (SPEC §6.3).
|
|
2585
2602
|
const assets =
|
|
2586
2603
|
seed?.assets ??
|
|
2587
|
-
(await
|
|
2604
|
+
(await preparePinnedAssets(this.deps.tickets, store, record.repo, record.ticket.id).catch(
|
|
2605
|
+
(error) => {
|
|
2606
|
+
// Preserve legacy recovery only without native assets. Native preparation
|
|
2607
|
+
// must never publish an empty pin after losing the source bytes.
|
|
2608
|
+
if (this.deps.tickets.assets || this.deps.tickets.name === 'hosted') throw error
|
|
2609
|
+
return []
|
|
2610
|
+
},
|
|
2611
|
+
))
|
|
2588
2612
|
await store.appendIfCurrent(record.slug, 0, {
|
|
2589
2613
|
actor: DISPATCHER,
|
|
2590
2614
|
type: 'build.created',
|
|
2591
2615
|
payload: this.buildCreatedPayload(record.ticket, record.repo, { ...seed, assets }),
|
|
2592
2616
|
})
|
|
2593
|
-
events = await store
|
|
2594
|
-
if (
|
|
2617
|
+
events = (await readBuildFacts(store, record.slug)).value
|
|
2618
|
+
if (events.created === undefined) {
|
|
2595
2619
|
throw new Error('build.created reconstruction lost its conditional append race')
|
|
2596
2620
|
}
|
|
2597
2621
|
}
|
|
2598
2622
|
|
|
2599
|
-
const created = events.
|
|
2623
|
+
const created = events.created
|
|
2600
2624
|
const autoMergeUser = seed?.autoMergeUser ?? created?.payload.autoMergeRequestedBy
|
|
2601
2625
|
// The claim's sampled default fact, for the request's fan-out provenance:
|
|
2602
2626
|
// the seed's own value first (this dispatch created the build), else the
|
|
2603
2627
|
// durable `build.created` fact a recovery pass resumes from.
|
|
2604
2628
|
const autoMergeDefaultSeq = seed?.autoMergeDefaultSeq ?? created?.payload.autoMergeDefaultSeq
|
|
2605
|
-
if (
|
|
2606
|
-
autoMergeUser !== undefined &&
|
|
2607
|
-
!events.some((event) => event.type === 'build.auto-merge-requested')
|
|
2608
|
-
) {
|
|
2629
|
+
if (autoMergeUser !== undefined && !events.autoMergeEverRequested) {
|
|
2609
2630
|
await store.append(record.slug, {
|
|
2610
2631
|
actor: humanActor(autoMergeUser),
|
|
2611
2632
|
type: 'build.auto-merge-requested',
|
|
@@ -2613,7 +2634,7 @@ export class Dispatcher {
|
|
|
2613
2634
|
...(autoMergeDefaultSeq !== undefined ? { defaultSeq: autoMergeDefaultSeq } : {}),
|
|
2614
2635
|
},
|
|
2615
2636
|
})
|
|
2616
|
-
events = await store
|
|
2637
|
+
events = (await readBuildFacts(store, record.slug)).value
|
|
2617
2638
|
}
|
|
2618
2639
|
|
|
2619
2640
|
stage = 'workspace'
|
|
@@ -2628,9 +2649,7 @@ export class Dispatcher {
|
|
|
2628
2649
|
launched.add(record.slug)
|
|
2629
2650
|
return 'deferred'
|
|
2630
2651
|
}
|
|
2631
|
-
const priorGenerations = events.
|
|
2632
|
-
(event) => event.type === 'workspace.provisioned',
|
|
2633
|
-
).length
|
|
2652
|
+
const priorGenerations = events.workspaceGenerations
|
|
2634
2653
|
const handle = await this.deps.workspaces.provision({
|
|
2635
2654
|
// Local providers provision FROM the physical checkout; remote
|
|
2636
2655
|
// providers ignore the value (their host seams are injected).
|
|
@@ -2657,10 +2676,10 @@ export class Dispatcher {
|
|
|
2657
2676
|
base: handle.base,
|
|
2658
2677
|
},
|
|
2659
2678
|
} satisfies EventWrite<'workspace.provisioned'>)
|
|
2660
|
-
events = await store
|
|
2679
|
+
events = (await readBuildFacts(store, record.slug)).value
|
|
2661
2680
|
}
|
|
2662
2681
|
} catch (error) {
|
|
2663
|
-
const events = await store
|
|
2682
|
+
const events = (await readBuildFacts(store, record.slug)).value
|
|
2664
2683
|
if (stage === 'workspace' && this.deps.workspaces.recovery !== undefined) {
|
|
2665
2684
|
const open = openWorkspace(events)
|
|
2666
2685
|
await this.recordInfrastructureFailure(record.slug, events, {
|
|
@@ -2671,7 +2690,7 @@ export class Dispatcher {
|
|
|
2671
2690
|
cleanupPending: true,
|
|
2672
2691
|
})
|
|
2673
2692
|
}
|
|
2674
|
-
const attempt = events.
|
|
2693
|
+
const attempt = events.dispatchFailureCount + 1
|
|
2675
2694
|
const message = (error instanceof Error ? error.message : String(error)).trim()
|
|
2676
2695
|
await store.append(record.slug, {
|
|
2677
2696
|
actor: DISPATCHER,
|
|
@@ -2711,12 +2730,10 @@ export class Dispatcher {
|
|
|
2711
2730
|
let stage: EventPayload<'dispatch.failed'>['stage'] = 'spec'
|
|
2712
2731
|
let authored = false
|
|
2713
2732
|
try {
|
|
2714
|
-
const events = await store
|
|
2715
|
-
const state =
|
|
2733
|
+
const events = (await readBuildFacts(store, record.slug)).value
|
|
2734
|
+
const state = events.state
|
|
2716
2735
|
if (state.status !== 'queued' || state.discardRequest !== undefined) return 'parked'
|
|
2717
|
-
if (
|
|
2718
|
-
!events.some((event) => event.type === 'spec.imported' || event.type === 'spec.authored')
|
|
2719
|
-
) {
|
|
2736
|
+
if (!events.specRecorded) {
|
|
2720
2737
|
let ticket: Ticket
|
|
2721
2738
|
if (seed?.ticket !== undefined) {
|
|
2722
2739
|
ticket = seed.ticket
|
|
@@ -2774,10 +2791,7 @@ export class Dispatcher {
|
|
|
2774
2791
|
}
|
|
2775
2792
|
|
|
2776
2793
|
stage = 'comment'
|
|
2777
|
-
if (
|
|
2778
|
-
record.ticket !== undefined &&
|
|
2779
|
-
!events.some((event) => event.type === 'dispatch.comment-posted')
|
|
2780
|
-
) {
|
|
2794
|
+
if (record.ticket !== undefined && !events.commentPosted) {
|
|
2781
2795
|
await this.deps.tickets.comment(record.ticket.id, `build ${record.slug} dispatched`)
|
|
2782
2796
|
await store.append(record.slug, {
|
|
2783
2797
|
actor: DISPATCHER,
|
|
@@ -2791,17 +2805,17 @@ export class Dispatcher {
|
|
|
2791
2805
|
// provider awaits. Re-read immediately before launch so a request made
|
|
2792
2806
|
// during those boundaries parks cleanly for the next janitor pass.
|
|
2793
2807
|
await store.refreshBuild(record.slug)
|
|
2794
|
-
const launchState =
|
|
2808
|
+
const launchState = (await readBuildFacts(store, record.slug)).value.state
|
|
2795
2809
|
if (launchState.status !== 'queued' || launchState.discardRequest !== undefined) {
|
|
2796
2810
|
return 'parked'
|
|
2797
2811
|
}
|
|
2798
2812
|
if (launched === undefined) await this.deps.launchRunner(record.slug)
|
|
2799
|
-
else await this.launch(record.slug, launched)
|
|
2813
|
+
else await this.launch(record.slug, launched, report)
|
|
2800
2814
|
if (authored && report !== undefined) report.authored += 1
|
|
2801
2815
|
return 'completed'
|
|
2802
2816
|
} catch (error) {
|
|
2803
|
-
const events = await store
|
|
2804
|
-
const attempt = events.
|
|
2817
|
+
const events = (await readBuildFacts(store, record.slug)).value
|
|
2818
|
+
const attempt = events.dispatchFailureCount + 1
|
|
2805
2819
|
const message = (error instanceof Error ? error.message : String(error)).trim()
|
|
2806
2820
|
await store.append(record.slug, {
|
|
2807
2821
|
actor: DISPATCHER,
|
|
@@ -2873,7 +2887,6 @@ export class Dispatcher {
|
|
|
2873
2887
|
opts: TickOpts = {},
|
|
2874
2888
|
): Promise<void> {
|
|
2875
2889
|
const store = this.store
|
|
2876
|
-
const { config } = this.deps
|
|
2877
2890
|
for (const record of await this.nonterminalBuilds()) {
|
|
2878
2891
|
if (launched.has(record.slug)) continue
|
|
2879
2892
|
// Budget gate: resume launches runners (transport-backed work).
|
|
@@ -2896,7 +2909,8 @@ export class Dispatcher {
|
|
|
2896
2909
|
!(await this.dispatchBudget(record.slug))
|
|
2897
2910
|
)
|
|
2898
2911
|
continue
|
|
2899
|
-
const decision =
|
|
2912
|
+
const decision = await this.decideBuild(record.slug, state, facts.log, report)
|
|
2913
|
+
if (decision === undefined) continue
|
|
2900
2914
|
if (decision.kind === 'wait') {
|
|
2901
2915
|
if (decision.reason === 'blocked' && state.openEscalations.length > 0) {
|
|
2902
2916
|
report.blockedDiagnostics.push(
|
|
@@ -2910,7 +2924,7 @@ export class Dispatcher {
|
|
|
2910
2924
|
// Disposable remote environments must pass through the stale-lease
|
|
2911
2925
|
// fencing sweep below; startup must never attach directly to an old VM.
|
|
2912
2926
|
if (this.deps.workspaces.recovery !== undefined) continue
|
|
2913
|
-
const result = await this.launch(record.slug, launched)
|
|
2927
|
+
const result = await this.launch(record.slug, launched, report)
|
|
2914
2928
|
if (result === 'scheduled') report.resumed += 1
|
|
2915
2929
|
}
|
|
2916
2930
|
}
|
|
@@ -2972,12 +2986,13 @@ export class Dispatcher {
|
|
|
2972
2986
|
!(await this.dispatchBudget(record.slug))
|
|
2973
2987
|
)
|
|
2974
2988
|
continue
|
|
2975
|
-
const decision =
|
|
2989
|
+
const decision = await this.decideBuild(record.slug, state, facts.log, report)
|
|
2990
|
+
if (decision === undefined) continue
|
|
2976
2991
|
if (state.status === 'done' || state.status === 'aborted') continue
|
|
2977
2992
|
// Raw history, read at most once and only by a branch that acts on it.
|
|
2978
|
-
let loaded:
|
|
2979
|
-
const history = async (): Promise<
|
|
2980
|
-
loaded ??= await this.store
|
|
2993
|
+
let loaded: CurrentBuildFacts | undefined
|
|
2994
|
+
const history = async (): Promise<CurrentBuildFacts> => {
|
|
2995
|
+
loaded ??= (await readBuildFacts(this.store, record.slug)).value
|
|
2981
2996
|
return loaded
|
|
2982
2997
|
}
|
|
2983
2998
|
// Liveness gate (durable supervision): a recorded execution the provider
|
|
@@ -3061,7 +3076,7 @@ export class Dispatcher {
|
|
|
3061
3076
|
if (
|
|
3062
3077
|
facts.workspace === null &&
|
|
3063
3078
|
this.deps.workspaces.recovery !== undefined &&
|
|
3064
|
-
(await history()).
|
|
3079
|
+
(await history()).workspaceGenerations > 0
|
|
3065
3080
|
) {
|
|
3066
3081
|
const kicked = await this.ensureProvisionContinuation(record, await history(), 'launch')
|
|
3067
3082
|
if (kicked === 'kicked') {
|
|
@@ -3073,7 +3088,7 @@ export class Dispatcher {
|
|
|
3073
3088
|
// boundaries and the launch; this tick performs neither.
|
|
3074
3089
|
continue
|
|
3075
3090
|
}
|
|
3076
|
-
const result = await this.launch(record.slug, launched)
|
|
3091
|
+
const result = await this.launch(record.slug, launched, report)
|
|
3077
3092
|
if (result === 'scheduled') report.swept += 1
|
|
3078
3093
|
}
|
|
3079
3094
|
}
|
|
@@ -3101,11 +3116,11 @@ export class Dispatcher {
|
|
|
3101
3116
|
// pending work are live, only waiting on a human. Capacity is per repo
|
|
3102
3117
|
// (§16.1) — another repo's builds never consume this repo's slots.
|
|
3103
3118
|
let active = 0
|
|
3104
|
-
const
|
|
3119
|
+
const activeRefs: TicketRef[] = []
|
|
3105
3120
|
for (const { record, digest } of await this.repoBuildDigests()) {
|
|
3106
3121
|
if (digest.terminal === undefined) {
|
|
3107
3122
|
active += 1
|
|
3108
|
-
if (record.ticket)
|
|
3123
|
+
if (record.ticket) activeRefs.push(record.ticket)
|
|
3109
3124
|
}
|
|
3110
3125
|
}
|
|
3111
3126
|
let capacity = config.capacity - active
|
|
@@ -3115,20 +3130,23 @@ export class Dispatcher {
|
|
|
3115
3130
|
// dispatcher is saturated. Sources that keep a claimed ticket in the
|
|
3116
3131
|
// ready state are deduped against the active builds embodying them.
|
|
3117
3132
|
await this.store.refreshJournal()
|
|
3118
|
-
const journalBeforeReady = this.store.
|
|
3133
|
+
const journalBeforeReady = this.store.repositoryFacts().harvest
|
|
3119
3134
|
const listing = await this.listReady()
|
|
3135
|
+
const activeTicketIds = await this.activeTicketIdentities(activeRefs, listing.tickets)
|
|
3120
3136
|
await this.store.refreshJournal()
|
|
3121
|
-
const journalAfterReady = this.store.
|
|
3122
|
-
if (
|
|
3137
|
+
const journalAfterReady = this.store.repositoryFacts()
|
|
3138
|
+
if (journalAfterReady.health?.cause === 'ready-scan-failed') {
|
|
3123
3139
|
await this.sourceCondition(undefined, 'ready-scan')
|
|
3124
3140
|
}
|
|
3125
3141
|
const creationHolds = new Map(
|
|
3126
|
-
|
|
3142
|
+
harvestCreationsFromStates(journalBeforeReady, journalAfterReady.harvest).map((creation) => [
|
|
3127
3143
|
creation.creationKey.toLowerCase(),
|
|
3128
3144
|
creation,
|
|
3129
3145
|
]),
|
|
3130
3146
|
)
|
|
3131
|
-
const ready = listing.tickets.filter(
|
|
3147
|
+
const ready = listing.tickets.filter(
|
|
3148
|
+
(ticket) => !activeTicketIds.has(`${ticket.ref.source}:${ticket.ref.id}`),
|
|
3149
|
+
)
|
|
3132
3150
|
report.invalidTickets += listing.diagnostics.length
|
|
3133
3151
|
report.ticketDiagnostics.push(...listing.diagnostics)
|
|
3134
3152
|
report.queued = ready.length
|
|
@@ -3225,7 +3243,7 @@ export class Dispatcher {
|
|
|
3225
3243
|
|
|
3226
3244
|
// Freeze the ticket's assets at the claim (SPEC §6.3): sampled before the
|
|
3227
3245
|
// spec-authoring interval so edits made during it are never pinned.
|
|
3228
|
-
const assets = await
|
|
3246
|
+
const assets = await preparePinnedAssets(tickets, store, this.deps.repo, ticket.ref.id)
|
|
3229
3247
|
|
|
3230
3248
|
// Quality gate (§6.3): import a conforming ticket body as the spec;
|
|
3231
3249
|
// author one for thin-but-groomed tickets; otherwise bounce.
|