@defrex/autobuild 0.8.0 → 0.10.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 +323 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +166 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +175 -28
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +204 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +14 -0
- package/package.json +45 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/facts.ts +215 -0
- package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
- package/packages/core/src/cli/dashboard/model.ts +141 -153
- package/packages/core/src/cli/dashboard/poll.ts +76 -12
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch-process.ts +1 -13
- package/packages/core/src/cli/dispatch.ts +392 -75
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +62 -9
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +266 -222
- package/packages/core/src/cli/installation.ts +50 -8
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/self-update.ts +77 -2
- package/packages/core/src/cli/status.ts +150 -28
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +453 -8
- package/packages/core/src/cli/wait.ts +157 -41
- package/packages/core/src/cli/watch.ts +138 -168
- package/packages/core/src/config/live.ts +23 -0
- package/packages/core/src/config/pipeline-source.ts +388 -0
- package/packages/core/src/config/schema.ts +251 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +115 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
- package/packages/core/src/kernel/auto-merge-default.ts +157 -0
- package/packages/core/src/kernel/auto-merge.ts +99 -22
- package/packages/core/src/kernel/dispatch-settings.ts +42 -17
- package/packages/core/src/kernel/dispatch-status.ts +23 -0
- package/packages/core/src/kernel/engine.ts +9 -438
- package/packages/core/src/kernel/generators/build-log.ts +259 -0
- package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
- package/packages/core/src/kernel/generators/session-log.ts +72 -0
- package/packages/core/src/kernel/harvest.ts +62 -18
- package/packages/core/src/kernel/incremental-contract.ts +75 -0
- package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
- package/packages/core/src/kernel/incremental.ts +53 -0
- package/packages/core/src/kernel/log-index.ts +620 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/pr-attachments.ts +164 -46
- package/packages/core/src/kernel/reducer-registry.ts +164 -0
- package/packages/core/src/kernel/reducer.ts +254 -73
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +253 -24
- package/packages/core/src/operator/read-view.ts +98 -0
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/fake.ts +5 -1
- package/packages/core/src/ports/tickets/file.ts +169 -48
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/tickets/linear.ts +33 -1
- package/packages/core/src/ports/tickets/operations.ts +1 -1
- package/packages/core/src/ports/tickets/remote.ts +8 -1
- package/packages/core/src/ports/types.ts +49 -3
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +68 -15
- package/packages/core/src/processes/build-runner.ts +238 -93
- package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
- package/packages/core/src/processes/dispatcher.ts +501 -201
- package/packages/core/src/processes/execution-settlement.ts +72 -39
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-execution-state.ts +41 -29
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
- package/packages/core/src/processes/orchestrator-tick.ts +360 -0
- package/packages/core/src/processes/publication-state.ts +164 -52
- package/packages/core/src/processes/repo-view.ts +1436 -0
- package/packages/core/src/processes/sandbox-state.ts +36 -2
- package/packages/core/src/processes/tick-state.ts +33 -0
- package/packages/core/src/store/adapter.ts +41 -0
- package/packages/core/src/store/build-scope.ts +90 -0
- package/packages/core/src/store/contract.ts +1205 -0
- package/packages/core/src/store/digest.ts +102 -0
- package/packages/core/src/store/local/schema.ts +46 -2
- package/packages/core/src/store/local/store.ts +445 -4
- package/packages/core/src/store/memory.ts +246 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +94 -0
- package/packages/core/src/store/remote/client.ts +310 -2
- package/packages/core/src/store/remote/index.ts +14 -2
- package/packages/core/src/store/remote/protocol.ts +115 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +205 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +94 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
- package/packages/core/src/store/snapshots.ts +88 -0
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +458 -0
- package/packages/core/src/store/types.ts +160 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +152 -40
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +204 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/skills/tickets/SKILL.md +57 -5
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- package/packages/core/src/testing/store-failures.ts +0 -32
|
@@ -37,17 +37,25 @@
|
|
|
37
37
|
* execution lease before launch; the child renews that same-holder lease.
|
|
38
38
|
*/
|
|
39
39
|
import type { Config } from '../config/schema'
|
|
40
|
+
import type { LanguageModel } from 'ai'
|
|
41
|
+
import { runOrchestratorTickStep } from './orchestrator-tick'
|
|
40
42
|
import { DISPATCHER, agentActor, humanActor } from '../events/envelope'
|
|
43
|
+
import { isRemoteWorkspace } from '../events/workspace-remote'
|
|
41
44
|
import type { AbEvent, EventWrite } from '../events/catalog'
|
|
42
|
-
import type { EventPayload } from '../events/payloads'
|
|
45
|
+
import type { EventPayload, PinnedAsset } from '../events/payloads'
|
|
43
46
|
import type { IdSource } from '../ids'
|
|
44
47
|
import {
|
|
45
48
|
autoMergeApplicationType,
|
|
46
49
|
pendingAutoMerge,
|
|
47
50
|
recordAutoMergeDeferralObservation,
|
|
48
51
|
} from '../kernel/auto-merge'
|
|
52
|
+
import {
|
|
53
|
+
autoMergeDefaultTarget,
|
|
54
|
+
latestAutoMergeDefault,
|
|
55
|
+
type AutoMergeDefaultFact,
|
|
56
|
+
} from '../kernel/auto-merge-default'
|
|
49
57
|
import { reduceDispatchSettings } from '../kernel/dispatch-settings'
|
|
50
|
-
import {
|
|
58
|
+
import { decideNextFromFacts } from '../kernel/engine'
|
|
51
59
|
import {
|
|
52
60
|
DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
|
|
53
61
|
decideHarvestControl,
|
|
@@ -56,7 +64,8 @@ import {
|
|
|
56
64
|
} from '../kernel/harvest'
|
|
57
65
|
import { pendingPrAttachmentReclaims } from '../kernel/pr-attachments'
|
|
58
66
|
import { reduceBuild, type BuildState } from '../kernel/reducer'
|
|
59
|
-
import type
|
|
67
|
+
import { createOperatorSandboxService, type OperatorSandboxService } from '../operator/sandbox'
|
|
68
|
+
import type { ArtifactRef, TicketRef } from '../ontology'
|
|
60
69
|
import { BUILD_EXECUTION_LEASE_TTL_MS } from '../ports/workspace/build-execution'
|
|
61
70
|
import type {
|
|
62
71
|
DependencyState,
|
|
@@ -66,16 +75,25 @@ import type {
|
|
|
66
75
|
WorkspaceProvider,
|
|
67
76
|
} from '../ports/types'
|
|
68
77
|
import type { Exec } from '../ports/workspace/git-worktree'
|
|
69
|
-
import type { ArtifactMeta, BuildRecord, BuildStore, Clock } from '../store/types'
|
|
78
|
+
import type { ArtifactMeta, BuildDigest, BuildRecord, BuildStore, Clock } from '../store/types'
|
|
79
|
+
import { samplePinnedAssets } from '../store/ticket-assets'
|
|
70
80
|
import { resolveRepoOrigin } from '../cli/repo-state'
|
|
71
81
|
import { specConformance } from '../spec-standard'
|
|
72
82
|
export { specConformance, type SpecConformance } from '../spec-standard'
|
|
73
83
|
import { recordInfrastructureFailure as appendInfrastructureFailure } from './infrastructure-failure-budget'
|
|
74
|
-
import {
|
|
84
|
+
import { settleOpenExecution } from './execution-settlement'
|
|
75
85
|
import { openHarvestExecutions } from './harvest-execution-state'
|
|
86
|
+
import { isWorkDigest, RepoViewStore } from './repo-view'
|
|
87
|
+
import {
|
|
88
|
+
baseBranchReducer,
|
|
89
|
+
openBuildWorkspace,
|
|
90
|
+
provisionMarkerReducer,
|
|
91
|
+
recoveryCheckpointReducer,
|
|
92
|
+
type OpenBuildWorkspace,
|
|
93
|
+
type ProvisionMarker,
|
|
94
|
+
} from './dispatcher-selectors'
|
|
76
95
|
import type { RepositoryEvent } from '../events/repository'
|
|
77
96
|
import { sandboxStates } from './sandbox-state'
|
|
78
|
-
import { abandonedPublicationPending, publicationPending } from './publication-state'
|
|
79
97
|
import { settlePublicationBeforeRelease } from './publication-loss'
|
|
80
98
|
|
|
81
99
|
// ── Readiness resolution (SPEC §3.3) ─────────────────────────────────────────
|
|
@@ -97,11 +115,22 @@ import { settlePublicationBeforeRelease } from './publication-loss'
|
|
|
97
115
|
* is otherwise the directory, so it defaults to no label gate. Plugin sources
|
|
98
116
|
* likewise receive no host-imposed label convention. An explicit
|
|
99
117
|
* `readyLabels` wins for every source.
|
|
118
|
+
*
|
|
119
|
+
* `[tickets].readyAssignee` (Linear only) adds a third conjunctive gate: the
|
|
120
|
+
* ticket must be assigned to that user.
|
|
100
121
|
*/
|
|
101
|
-
export function readyCriteria(config: Config): {
|
|
102
|
-
|
|
122
|
+
export function readyCriteria(config: Config): {
|
|
123
|
+
labels: string[]
|
|
124
|
+
state: string
|
|
125
|
+
assignee?: string
|
|
126
|
+
} {
|
|
127
|
+
const { readyLabels, readyState, readyAssignee } = config.tickets
|
|
103
128
|
if (config.tickets.source === 'linear') {
|
|
104
|
-
return {
|
|
129
|
+
return {
|
|
130
|
+
labels: readyLabels ?? ['autobuild'],
|
|
131
|
+
state: readyState,
|
|
132
|
+
...(readyAssignee !== undefined ? { assignee: readyAssignee } : {}),
|
|
133
|
+
}
|
|
105
134
|
}
|
|
106
135
|
return { labels: readyLabels ?? [], state: readyState }
|
|
107
136
|
}
|
|
@@ -349,6 +378,11 @@ export interface TickReport {
|
|
|
349
378
|
sandboxIdleStops: number
|
|
350
379
|
/** Contained idle-settlement failures this tick; retried next tick. */
|
|
351
380
|
sandboxSettleFailures: number
|
|
381
|
+
/** Contained sandbox-backend construction failures this tick (AUT-584):
|
|
382
|
+
* deterministic and config-shaped, so it re-reports every tick until the
|
|
383
|
+
* config is repaired. The affected tick runs with sandbox tools filtered
|
|
384
|
+
* exactly as when no backend exists. */
|
|
385
|
+
orchestratorSandboxFailures: number
|
|
352
386
|
}
|
|
353
387
|
|
|
354
388
|
export function emptyTickReport(): TickReport {
|
|
@@ -385,6 +419,7 @@ export function emptyTickReport(): TickReport {
|
|
|
385
419
|
harvestFailed: 0,
|
|
386
420
|
sandboxIdleStops: 0,
|
|
387
421
|
sandboxSettleFailures: 0,
|
|
422
|
+
orchestratorSandboxFailures: 0,
|
|
388
423
|
}
|
|
389
424
|
}
|
|
390
425
|
|
|
@@ -452,6 +487,10 @@ export interface TickOpts {
|
|
|
452
487
|
* escalations remain human gates and are only reported as parked.
|
|
453
488
|
*/
|
|
454
489
|
resumeCurrent?: boolean
|
|
490
|
+
/** The caller refreshed the repository view for this tick already (the CLI
|
|
491
|
+
* does, to sample the repository-wide controls from the same state). The
|
|
492
|
+
* tick then skips its own refresh so one tick performs one discovery read. */
|
|
493
|
+
viewRefreshed?: boolean
|
|
455
494
|
/**
|
|
456
495
|
* Repository-derived intake gate supplied by the CLI for this tick. False
|
|
457
496
|
* skips only the ready-ticket list/claim/dispatch stage; janitor, startup
|
|
@@ -534,6 +573,11 @@ export interface DispatcherDeps {
|
|
|
534
573
|
* supplies cancellation and treats every absence/failure/invalid result as a
|
|
535
574
|
* local deterministic fallback, so naming can never prevent build creation. */
|
|
536
575
|
nameSlug?: (spec: string, signal: AbortSignal) => Promise<string | null>
|
|
576
|
+
/** The orchestrator turn runner's language model, injected for tests. When
|
|
577
|
+
* absent the configured gateway model is resolved in process (AUT-342);
|
|
578
|
+
* the step itself only ever constructs in origin mode with the orchestrator
|
|
579
|
+
* enabled, so a local dispatcher never resolves a model. */
|
|
580
|
+
orchestratorModel?: LanguageModel
|
|
537
581
|
/** Settle pending publication for `slug` after durable proof that the
|
|
538
582
|
* guest execution ended. Supplied by the DispatchLoop; the settlement stage
|
|
539
583
|
* invokes it only on a recorded completion. */
|
|
@@ -553,30 +597,14 @@ export interface DispatcherDeps {
|
|
|
553
597
|
/** Latest `workspace.provisioned` not followed by a `workspace.released` —
|
|
554
598
|
* the reducer deliberately ignores workspace events (liveness is the
|
|
555
599
|
* dispatcher's concern), so the janitor scans the raw log. */
|
|
556
|
-
function openWorkspace(
|
|
557
|
-
events
|
|
558
|
-
): { provider: string; ref: string; path?: string; localPath?: string; branch: string } | null {
|
|
559
|
-
let open: {
|
|
560
|
-
provider: string
|
|
561
|
-
ref: string
|
|
562
|
-
path?: string
|
|
563
|
-
localPath?: string
|
|
564
|
-
branch: string
|
|
565
|
-
} | null = null
|
|
566
|
-
for (const event of events) {
|
|
567
|
-
if (event.type === 'workspace.provisioned') open = event.payload
|
|
568
|
-
else if (event.type === 'workspace.released') open = null
|
|
569
|
-
}
|
|
570
|
-
return open
|
|
600
|
+
function openWorkspace(events: AbEvent[]): OpenBuildWorkspace | null {
|
|
601
|
+
return openBuildWorkspace(events)
|
|
571
602
|
}
|
|
572
603
|
|
|
573
604
|
/** The build's base branch, from its own `build.created` fact (§15.3);
|
|
574
605
|
* falls back to the repo config for logs missing one. */
|
|
575
606
|
function baseBranchOf(events: AbEvent[], config: Config): string {
|
|
576
|
-
|
|
577
|
-
if (event.type === 'build.created') return event.payload.baseBranch
|
|
578
|
-
}
|
|
579
|
-
return config.baseBranch
|
|
607
|
+
return baseBranchReducer.reduce(events) ?? config.baseBranch
|
|
580
608
|
}
|
|
581
609
|
|
|
582
610
|
function artifactRefOf(deposited: ArtifactMeta[]): ArtifactRef {
|
|
@@ -634,7 +662,27 @@ export class Dispatcher {
|
|
|
634
662
|
* slug here defers to the continuation; it never blocks on it. */
|
|
635
663
|
private readonly continuations = new Map<string, ProvisionContinuation>()
|
|
636
664
|
|
|
665
|
+
/** The incremental repository view every stage reads and writes through
|
|
666
|
+
* (AUT-647): `deps.store` itself when the caller already wrapped it. */
|
|
667
|
+
private readonly store: RepoViewStore
|
|
668
|
+
|
|
669
|
+
/** `deps.settlePublication`, followed by a delta read of the build: the
|
|
670
|
+
* guest-side settlement appends through its own store handle, which the view
|
|
671
|
+
* only learns of by reading. */
|
|
672
|
+
private get settlePublication(): ((slug: string) => Promise<void>) | undefined {
|
|
673
|
+
const settle = this.deps.settlePublication
|
|
674
|
+
if (settle === undefined) return undefined
|
|
675
|
+
return async (slug) => {
|
|
676
|
+
await settle(slug)
|
|
677
|
+
await this.store.refreshBuild(slug)
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
637
681
|
constructor(private readonly deps: DispatcherDeps) {
|
|
682
|
+
this.store =
|
|
683
|
+
deps.store instanceof RepoViewStore
|
|
684
|
+
? deps.store
|
|
685
|
+
: new RepoViewStore(deps.store, { repo: deps.repo })
|
|
638
686
|
this.leaseTtlMs = deps.opts?.leaseTtlMs ?? 0
|
|
639
687
|
this.doneState = deps.opts?.doneState ?? 'Done'
|
|
640
688
|
this.slugNamingTimeoutMs = deps.opts?.slugNamingTimeoutMs ?? DEFAULT_SLUG_NAMING_TIMEOUT_MS
|
|
@@ -658,9 +706,21 @@ export class Dispatcher {
|
|
|
658
706
|
}
|
|
659
707
|
|
|
660
708
|
async tick(opts: TickOpts = {}): Promise<TickReport> {
|
|
709
|
+
try {
|
|
710
|
+
return await this.runTick(opts)
|
|
711
|
+
} finally {
|
|
712
|
+
// Leave this tick's reduced state behind for the next process (a cache;
|
|
713
|
+
// a failure to write it is never a tick failure). Throttled in a
|
|
714
|
+
// resident dispatcher, always run by a cold single-tick process.
|
|
715
|
+
await this.store.persistSnapshots()
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private async runTick(opts: TickOpts): Promise<TickReport> {
|
|
661
720
|
// One immutable config for this complete decision pass. A reload racing the
|
|
662
721
|
// tick is observed by the next tick, never half-way through this one.
|
|
663
722
|
this.deps.config = this.deps.getConfig?.() ?? this.deps.config
|
|
723
|
+
if (opts.viewRefreshed !== true) await this.store.refresh()
|
|
664
724
|
let autoMergeUser: string | undefined
|
|
665
725
|
if (opts.defaultAutoMerge === true) {
|
|
666
726
|
autoMergeUser = opts.autoMergeUser?.trim()
|
|
@@ -672,11 +732,23 @@ export class Dispatcher {
|
|
|
672
732
|
/** Builds already launched this tick — a janitor/startup re-attach must
|
|
673
733
|
* not be doubled by the sweep, nor a fresh dispatch by anything. */
|
|
674
734
|
const launched = new Set<string>()
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
|
|
735
|
+
// One journal read for both repository-wide controls: a pause that lands
|
|
736
|
+
// mid-tick takes effect on the next tick, matching intake (which the CLI
|
|
737
|
+
// likewise samples before calling `tick`); the auto-merge default fact is
|
|
738
|
+
// the durable driver of this tick's fan-out below. Neither control
|
|
739
|
+
// pretends to a serialization the store does not offer.
|
|
740
|
+
const repoEvents = this.store.recordedJournal()
|
|
741
|
+
const paused = reduceDispatchSettings(repoEvents).paused
|
|
742
|
+
const autoMergeDefault = latestAutoMergeDefault(repoEvents)
|
|
743
|
+
// The claim-time seed cites the newest fact whose enabled state matches
|
|
744
|
+
// the launch sample, not blindly the newest fact: the CLI samples
|
|
745
|
+
// `defaultAutoMerge` from its own earlier read, so an interleaved toggle
|
|
746
|
+
// can make this tick's read newer-and-opposite. Citing the matching fact
|
|
747
|
+
// keeps the newer opposite fact strictly ahead of the seed's provenance,
|
|
748
|
+
// so the fan-out reconciles the build on the next tick instead of the
|
|
749
|
+
// stale sample out-voting the durable default forever (f_b851c0e8).
|
|
750
|
+
const autoMergeSeedFact =
|
|
751
|
+
opts.defaultAutoMerge === true ? latestAutoMergeDefault(repoEvents, true) : undefined
|
|
680
752
|
// Stage 0 — SETTLEMENT: observe foreign executions from durable facts plus
|
|
681
753
|
// provider liveness and settle their completion, lease, and publication.
|
|
682
754
|
// Runs before the janitor so its executionLeaseLive checks see freshly
|
|
@@ -691,6 +763,13 @@ export class Dispatcher {
|
|
|
691
763
|
if (this.outOfBudget(opts)) return report
|
|
692
764
|
await this.settleOrchestratorSandboxes(report, opts)
|
|
693
765
|
if (this.outOfBudget(opts)) return report
|
|
766
|
+
// The default fan-out runs before the janitor so one tick both records the
|
|
767
|
+
// consent and lets the janitor act on it (the janitor reduces a fresh read
|
|
768
|
+
// inside `checkPr`). Independent of intake and `acceptNewWork` — the
|
|
769
|
+
// durable fact drives it, so a second dispatcher polling the same
|
|
770
|
+
// repository converges the same state.
|
|
771
|
+
await this.applyAutoMergeDefault(autoMergeDefault)
|
|
772
|
+
if (this.outOfBudget(opts)) return report
|
|
694
773
|
await this.janitor(report, launched, opts)
|
|
695
774
|
if (this.outOfBudget(opts)) return report
|
|
696
775
|
await this.recoverDispatches(report, launched, paused, opts)
|
|
@@ -700,7 +779,7 @@ export class Dispatcher {
|
|
|
700
779
|
await this.leaseSweep(report, launched, paused, opts)
|
|
701
780
|
if (this.outOfBudget(opts)) return report
|
|
702
781
|
if (opts.acceptNewWork !== false) {
|
|
703
|
-
await this.dispatch(report, launched, autoMergeUser, paused, opts)
|
|
782
|
+
await this.dispatch(report, launched, autoMergeUser, paused, opts, autoMergeSeedFact)
|
|
704
783
|
}
|
|
705
784
|
// Fire-and-forget by contract: long synthesize/review sessions must not
|
|
706
785
|
// stop janitor, lease sweep, ticket dispatch, or signal handling on later
|
|
@@ -708,6 +787,61 @@ export class Dispatcher {
|
|
|
708
787
|
// launch. Pending control commands still launch the runner so the kernel
|
|
709
788
|
// can settle them under the repository lease.
|
|
710
789
|
await this.triggerHarvest()
|
|
790
|
+
// The orchestrator step (AUT-342) — LAST, after every build stage, so it
|
|
791
|
+
// can only consume slack: resumes, crash reaping, and wake turns run
|
|
792
|
+
// against min(configured, remaining tick budget) with a 30 s floor, and
|
|
793
|
+
// a busy repository simply defers its sessions to later ticks (durable
|
|
794
|
+
// state makes that safe). Two independent gates keep it inert where it
|
|
795
|
+
// does not belong: the step is constructed ONLY in origin mode (a local
|
|
796
|
+
// `ab dispatch` never constructs it — enabled = true in a local
|
|
797
|
+
// repository's autobuild.toml lists no sessions, builds no registry, and
|
|
798
|
+
// calls no model), and in origin mode only when the effective config
|
|
799
|
+
// enables the orchestrator.
|
|
800
|
+
if (this.deps.repoOrigin !== undefined && this.deps.config.orchestrator.enabled) {
|
|
801
|
+
if (!this.outOfBudget(opts)) {
|
|
802
|
+
const remainingSeconds =
|
|
803
|
+
opts.deadlineAt === undefined
|
|
804
|
+
? undefined
|
|
805
|
+
: Math.max(0, Math.floor((opts.deadlineAt - this.deps.clock().getTime()) / 1000))
|
|
806
|
+
// The origin-mode tick's sandbox backend (AUT-584), built from the
|
|
807
|
+
// dispatcher's own deps — the same wired provider and forge the
|
|
808
|
+
// settlement stage already consumes. Construction is cheap and
|
|
809
|
+
// network-free (one idempotent ensureRepo), and building per tick
|
|
810
|
+
// picks up hot config reloads the way the rest of the tick does.
|
|
811
|
+
// A construction failure is contained: the step runs with sandbox
|
|
812
|
+
// tools filtered exactly as today, and the standing counter names
|
|
813
|
+
// the condition (the same discipline as sandboxSettleFailures).
|
|
814
|
+
let sandbox: OperatorSandboxService | undefined
|
|
815
|
+
try {
|
|
816
|
+
sandbox = await createOperatorSandboxService({
|
|
817
|
+
store: this.store,
|
|
818
|
+
repo: this.deps.repo,
|
|
819
|
+
provider: this.deps.workspaces,
|
|
820
|
+
sandbox: this.deps.config.orchestrator.sandbox,
|
|
821
|
+
baseBranch: this.deps.config.baseBranch,
|
|
822
|
+
forge: this.deps.forge,
|
|
823
|
+
clock: this.deps.clock,
|
|
824
|
+
})
|
|
825
|
+
} catch {
|
|
826
|
+
report.orchestratorSandboxFailures += 1
|
|
827
|
+
sandbox = undefined
|
|
828
|
+
}
|
|
829
|
+
await runOrchestratorTickStep({
|
|
830
|
+
store: this.store,
|
|
831
|
+
view: this.store,
|
|
832
|
+
repo: this.deps.repo,
|
|
833
|
+
config: this.deps.config,
|
|
834
|
+
clock: this.deps.clock,
|
|
835
|
+
ids: this.deps.ids,
|
|
836
|
+
tickets: this.deps.tickets,
|
|
837
|
+
...(sandbox !== undefined ? { sandbox } : {}),
|
|
838
|
+
...(this.deps.orchestratorModel !== undefined
|
|
839
|
+
? { model: this.deps.orchestratorModel }
|
|
840
|
+
: {}),
|
|
841
|
+
...(remainingSeconds !== undefined ? { remainingBudgetSeconds: remainingSeconds } : {}),
|
|
842
|
+
})
|
|
843
|
+
}
|
|
844
|
+
}
|
|
711
845
|
return report
|
|
712
846
|
}
|
|
713
847
|
|
|
@@ -719,6 +853,46 @@ export class Dispatcher {
|
|
|
719
853
|
return this.deps.opts?.triageState ?? defaultTriageState(this.deps.config)
|
|
720
854
|
}
|
|
721
855
|
|
|
856
|
+
/** This repository's builds paired with their digests, from one record
|
|
857
|
+
* listing and one digest read — the tick loops' per-build event reads start
|
|
858
|
+
* from here so a settled terminal build costs none. Records are read before
|
|
859
|
+
* digests (as `operator/query.ts` does) so a concurrent create cannot fail
|
|
860
|
+
* the completeness check. */
|
|
861
|
+
private async repoBuildDigests(): Promise<{ record: BuildRecord; digest: BuildDigest }[]> {
|
|
862
|
+
const store = this.store
|
|
863
|
+
const { repo } = this.deps
|
|
864
|
+
const records = (await store.listBuilds()).filter((record) => record.repo === repo)
|
|
865
|
+
const digests = await store.getRepoBuildDigests(repo)
|
|
866
|
+
return records.map((record) => {
|
|
867
|
+
const digest = digests.get(record.slug)
|
|
868
|
+
if (digest === undefined) {
|
|
869
|
+
throw new Error(
|
|
870
|
+
`build digest missing for ${record.slug}: the digest read broke completeness`,
|
|
871
|
+
)
|
|
872
|
+
}
|
|
873
|
+
return { record, digest }
|
|
874
|
+
})
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Builds with no terminal fact: the only ones whose tick duties change
|
|
878
|
+
* their state (decide, resume, lease sweep, capacity). */
|
|
879
|
+
private async nonterminalBuilds(): Promise<BuildRecord[]> {
|
|
880
|
+
return (await this.repoBuildDigests())
|
|
881
|
+
.filter(({ digest }) => digest.terminal === undefined)
|
|
882
|
+
.map(({ record }) => record)
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** Builds that still have work: nonterminal ones, plus terminal builds whose
|
|
886
|
+
* post-terminal duty is outstanding — an `aborted` build awaiting its
|
|
887
|
+
* cleanup's `build.completed`, a `done` build with an unreclaimed release
|
|
888
|
+
* asset, or any build with an execution the provider has not been seen to
|
|
889
|
+
* end. A terminal, settled build costs no event reads. */
|
|
890
|
+
private async workBuilds(): Promise<BuildRecord[]> {
|
|
891
|
+
return (await this.repoBuildDigests())
|
|
892
|
+
.filter(({ digest }) => isWorkDigest(digest))
|
|
893
|
+
.map(({ record }) => record)
|
|
894
|
+
}
|
|
895
|
+
|
|
722
896
|
/** Observation-only ready listing for Store-backed frontends while intake is
|
|
723
897
|
* disabled. It shares the active-ticket exclusion with dispatch but performs
|
|
724
898
|
* no dependency fetch, claim, bounce, build, workspace, or launch side
|
|
@@ -729,11 +903,10 @@ export class Dispatcher {
|
|
|
729
903
|
ticketDiagnostics: string[]
|
|
730
904
|
}> {
|
|
731
905
|
this.deps.config = this.deps.getConfig?.() ?? this.deps.config
|
|
906
|
+
await this.store.refresh()
|
|
732
907
|
const activeTicketIds = new Set<string>()
|
|
733
|
-
for (const record of await this.
|
|
734
|
-
if (record.
|
|
735
|
-
const state = reduceBuild(await this.deps.store.getEvents(record.slug))
|
|
736
|
-
if (state.status !== 'done' && state.status !== 'aborted') {
|
|
908
|
+
for (const { record, digest } of await this.repoBuildDigests()) {
|
|
909
|
+
if (record.ticket !== undefined && digest.terminal === undefined) {
|
|
737
910
|
activeTicketIds.add(record.ticket.id)
|
|
738
911
|
}
|
|
739
912
|
}
|
|
@@ -745,24 +918,66 @@ export class Dispatcher {
|
|
|
745
918
|
}
|
|
746
919
|
}
|
|
747
920
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
921
|
+
/** Apply the newest durable `dispatcher.auto-merge-default-set` fact to every
|
|
922
|
+
* current non-terminal build of this repository: on — request consent; off —
|
|
923
|
+
* cancel it. Attribution is the toggling human's own actor, exactly as if
|
|
924
|
+
* they had pressed `m` on each row; each command carries the fact's seq so
|
|
925
|
+
* the fan-out is idempotent (a settled build fails `autoMergeDefaultTarget`)
|
|
926
|
+
* and a per-build override stands until the default is toggled again. A fact
|
|
927
|
+
* whose value already matches the build's state writes a provenance-only
|
|
928
|
+
* `build.auto-merge-default-observed` marker instead of a duplicate command,
|
|
929
|
+
* so the matching fact is recorded as answered (f_28b3fba1).
|
|
930
|
+
*
|
|
931
|
+
* Per build, a compare-and-set loop (the `requestOne` shape from
|
|
932
|
+
* `bulk-control.ts`): the read/reduce/evaluate lives inside the loop, so a
|
|
933
|
+
* competing write loses at most one attempt and the next re-reads fresh
|
|
934
|
+
* state. No TickReport counter: the evidence is the build events, and the
|
|
935
|
+
* report shape stays compatible. */
|
|
936
|
+
private async applyAutoMergeDefault(fact: AutoMergeDefaultFact | undefined): Promise<void> {
|
|
937
|
+
if (fact === undefined) return
|
|
938
|
+
const store = this.store
|
|
939
|
+
|
|
940
|
+
// Terminal builds are never eligible (`autoMergeDefaultEligible`).
|
|
941
|
+
for (const record of (await this.nonterminalBuilds()).sort((a, b) =>
|
|
942
|
+
a.slug.localeCompare(b.slug),
|
|
943
|
+
)) {
|
|
944
|
+
while (true) {
|
|
945
|
+
const { state } = await store.buildFacts(record.slug)
|
|
946
|
+
const target = autoMergeDefaultTarget(state, fact)
|
|
947
|
+
if (target === undefined) break
|
|
948
|
+
if (target === 'observed') {
|
|
949
|
+
const observed = await store.appendIfCurrent(record.slug, state.lastSeq, {
|
|
950
|
+
actor: DISPATCHER,
|
|
951
|
+
type: 'build.auto-merge-default-observed',
|
|
952
|
+
payload: { defaultSeq: fact.seq },
|
|
953
|
+
})
|
|
954
|
+
if (observed !== null) break
|
|
955
|
+
continue
|
|
956
|
+
}
|
|
957
|
+
const type =
|
|
958
|
+
target === 'request' ? 'build.auto-merge-requested' : 'build.auto-merge-cancelled'
|
|
959
|
+
const appended = await store.appendIfCurrent(record.slug, state.lastSeq, {
|
|
960
|
+
actor: fact.actor,
|
|
961
|
+
type,
|
|
962
|
+
payload: { defaultSeq: fact.seq },
|
|
963
|
+
})
|
|
964
|
+
if (appended !== null) break
|
|
965
|
+
}
|
|
966
|
+
}
|
|
755
967
|
}
|
|
756
968
|
|
|
757
969
|
private async triggerHarvest(): Promise<void> {
|
|
758
970
|
const start = this.deps.startHarvest
|
|
759
971
|
if (start === undefined) return
|
|
760
|
-
|
|
761
|
-
|
|
972
|
+
// The journal moves under foreign harvest writers while the stages run, so
|
|
973
|
+
// take the delta before deciding.
|
|
974
|
+
await this.store.refreshJournal()
|
|
975
|
+
if (!this.store.journalRecorded()) {
|
|
762
976
|
start()
|
|
763
977
|
return
|
|
764
978
|
}
|
|
765
|
-
|
|
979
|
+
// Bounded subset (AUT-489): the harvest reducer consumes durable types only.
|
|
980
|
+
const state = reduceHarvest(this.store.recordedJournal())
|
|
766
981
|
if (decideHarvestControl(state, this.maxHarvestRecoveryAttempts).kind !== 'park') {
|
|
767
982
|
start()
|
|
768
983
|
}
|
|
@@ -791,28 +1006,26 @@ export class Dispatcher {
|
|
|
791
1006
|
* the stage still settles. */
|
|
792
1007
|
private async settleForeignExecutions(report: TickReport, opts: TickOpts = {}): Promise<void> {
|
|
793
1008
|
const active = this.deps.activeExecutions?.() ?? new Set<string>()
|
|
794
|
-
for (const record of await this.
|
|
795
|
-
if (record.repo !== this.deps.repo) continue
|
|
1009
|
+
for (const record of await this.workBuilds()) {
|
|
796
1010
|
if (active.has(record.slug)) continue
|
|
797
1011
|
// Budget gate: once spent, stop settling further foreign executions.
|
|
798
1012
|
if (this.outOfBudget(opts)) break
|
|
799
1013
|
try {
|
|
800
|
-
const
|
|
801
|
-
const open = openExecution(events)
|
|
1014
|
+
const { open } = await this.store.buildFacts(record.slug)
|
|
802
1015
|
if (open === null) continue
|
|
803
1016
|
const owner = this.executionOwner(open.provider)
|
|
804
1017
|
const execution = owner?.buildExecution
|
|
805
1018
|
if (execution?.observe === undefined || open.commandId === undefined) continue
|
|
806
|
-
const settlement = await
|
|
1019
|
+
const settlement = await settleOpenExecution(
|
|
807
1020
|
{
|
|
808
|
-
store: this.
|
|
1021
|
+
store: this.store,
|
|
809
1022
|
execution,
|
|
810
|
-
...(this.
|
|
811
|
-
? { settlePublication: this.
|
|
1023
|
+
...(this.settlePublication !== undefined
|
|
1024
|
+
? { settlePublication: this.settlePublication }
|
|
812
1025
|
: {}),
|
|
813
1026
|
},
|
|
814
1027
|
record.slug,
|
|
815
|
-
|
|
1028
|
+
open,
|
|
816
1029
|
)
|
|
817
1030
|
if (settlement === 'settled') report.settled += 1
|
|
818
1031
|
} catch {
|
|
@@ -837,9 +1050,16 @@ export class Dispatcher {
|
|
|
837
1050
|
const active = this.deps.activeHarvestExecutions?.() ?? new Set<string>()
|
|
838
1051
|
let events: RepositoryEvent[]
|
|
839
1052
|
try {
|
|
840
|
-
|
|
1053
|
+
// The earlier stages awaited providers; take the journal delta so a
|
|
1054
|
+
// foreign append (operator activity, harvest facts) made meanwhile is
|
|
1055
|
+
// seen before any destructive decision below.
|
|
1056
|
+
await this.store.refreshJournal()
|
|
1057
|
+
events = this.store.recordedJournal()
|
|
841
1058
|
} catch {
|
|
842
|
-
//
|
|
1059
|
+
// The read itself failed (a transient store error — a missing record
|
|
1060
|
+
// answers `[]` inside the helper, so it never reaches this arm):
|
|
1061
|
+
// skipping this settlement stage for this tick while the rest of the
|
|
1062
|
+
// tick proceeds, and the next tick retries what remains.
|
|
843
1063
|
return
|
|
844
1064
|
}
|
|
845
1065
|
for (const execution of openHarvestExecutions(events)) {
|
|
@@ -890,7 +1110,7 @@ export class Dispatcher {
|
|
|
890
1110
|
branch: '',
|
|
891
1111
|
})
|
|
892
1112
|
}
|
|
893
|
-
await this.
|
|
1113
|
+
await this.store.appendRepo(this.deps.repo, {
|
|
894
1114
|
actor: DISPATCHER,
|
|
895
1115
|
type: 'harvest.execution.released',
|
|
896
1116
|
payload: {
|
|
@@ -933,9 +1153,16 @@ export class Dispatcher {
|
|
|
933
1153
|
const providerName = this.deps.workspaces.name
|
|
934
1154
|
let events: RepositoryEvent[]
|
|
935
1155
|
try {
|
|
936
|
-
|
|
1156
|
+
// The earlier stages awaited providers; take the journal delta so a
|
|
1157
|
+
// foreign append (operator activity, harvest facts) made meanwhile is
|
|
1158
|
+
// seen before any destructive decision below.
|
|
1159
|
+
await this.store.refreshJournal()
|
|
1160
|
+
events = this.store.recordedJournal()
|
|
937
1161
|
} catch {
|
|
938
|
-
//
|
|
1162
|
+
// The read itself failed (a transient store error — a missing record
|
|
1163
|
+
// answers `[]` inside the helper, so it never reaches this arm):
|
|
1164
|
+
// skipping this settlement stage for this tick while the rest of the
|
|
1165
|
+
// tick proceeds, and the next tick retries what remains.
|
|
939
1166
|
return
|
|
940
1167
|
}
|
|
941
1168
|
const idleMs = config.orchestrator.sandbox.idleMinutes * 60_000
|
|
@@ -957,7 +1184,7 @@ export class Dispatcher {
|
|
|
957
1184
|
// never a stop: nothing was stopped through a provider this
|
|
958
1185
|
// dispatcher no longer owns. If the provider returns, the next
|
|
959
1186
|
// operator ensure() re-provisions and re-brackets the environment.
|
|
960
|
-
await this.
|
|
1187
|
+
await this.store.appendRepo(this.deps.repo, {
|
|
961
1188
|
actor: DISPATCHER,
|
|
962
1189
|
type: 'orchestrator.sandbox.released',
|
|
963
1190
|
payload: {
|
|
@@ -978,7 +1205,7 @@ export class Dispatcher {
|
|
|
978
1205
|
})
|
|
979
1206
|
if (outcome.outcome === 'unsupported') continue
|
|
980
1207
|
if (outcome.outcome === 'stopped') {
|
|
981
|
-
await this.
|
|
1208
|
+
await this.store.appendRepo(this.deps.repo, {
|
|
982
1209
|
actor: DISPATCHER,
|
|
983
1210
|
type: 'orchestrator.sandbox.stopped',
|
|
984
1211
|
payload: {
|
|
@@ -993,7 +1220,7 @@ export class Dispatcher {
|
|
|
993
1220
|
// dead host): close the orphan's trail with an unconfirmed-purge
|
|
994
1221
|
// release fact, dispatcher-authored exactly as
|
|
995
1222
|
// `harvest.execution.released` is.
|
|
996
|
-
await this.
|
|
1223
|
+
await this.store.appendRepo(this.deps.repo, {
|
|
997
1224
|
actor: DISPATCHER,
|
|
998
1225
|
type: 'orchestrator.sandbox.released',
|
|
999
1226
|
payload: {
|
|
@@ -1021,23 +1248,8 @@ export class Dispatcher {
|
|
|
1021
1248
|
|
|
1022
1249
|
/** The latest `workspace.provision-started` not yet followed by a
|
|
1023
1250
|
* provisioned/released fact — an open provisioning marker. */
|
|
1024
|
-
private openProvisionMarker(
|
|
1025
|
-
events
|
|
1026
|
-
): { provider: string; branch: string; generation: number; ts: string; seq: number } | undefined {
|
|
1027
|
-
let marker:
|
|
1028
|
-
| { provider: string; branch: string; generation: number; ts: string; seq: number }
|
|
1029
|
-
| undefined
|
|
1030
|
-
for (const event of events) {
|
|
1031
|
-
if (event.type === 'workspace.provision-started') {
|
|
1032
|
-
marker = { ...event.payload, ts: event.ts, seq: event.seq }
|
|
1033
|
-
} else if (
|
|
1034
|
-
marker !== undefined &&
|
|
1035
|
-
(event.type === 'workspace.provisioned' || event.type === 'workspace.released')
|
|
1036
|
-
) {
|
|
1037
|
-
marker = undefined
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
return marker
|
|
1251
|
+
private openProvisionMarker(events: AbEvent[]): ProvisionMarker | undefined {
|
|
1252
|
+
return provisionMarkerReducer.reduce(events)
|
|
1041
1253
|
}
|
|
1042
1254
|
|
|
1043
1255
|
/** Liveness of the lease behind an open provision marker: `live` means a
|
|
@@ -1046,7 +1258,7 @@ export class Dispatcher {
|
|
|
1046
1258
|
* may have crashed between marker and claim — adopted only after the
|
|
1047
1259
|
* `provisionStaleMs` backstop. */
|
|
1048
1260
|
private async provisionMarkerLiveness(slug: string): Promise<'live' | 'expired' | 'absent'> {
|
|
1049
|
-
const record = await this.
|
|
1261
|
+
const record = await this.store.getBuild(slug)
|
|
1050
1262
|
if (record?.lease === undefined) return 'absent'
|
|
1051
1263
|
return new Date(record.lease.expiresAt).getTime() > this.deps.clock().getTime()
|
|
1052
1264
|
? 'live'
|
|
@@ -1065,6 +1277,8 @@ export class Dispatcher {
|
|
|
1065
1277
|
body: string
|
|
1066
1278
|
authoredSession?: string
|
|
1067
1279
|
autoMergeUser?: string
|
|
1280
|
+
autoMergeDefaultSeq?: number
|
|
1281
|
+
assets?: PinnedAsset[]
|
|
1068
1282
|
},
|
|
1069
1283
|
): Promise<'kicked' | 'in-flight' | 'foreign-live'> {
|
|
1070
1284
|
const slug = record.slug
|
|
@@ -1093,7 +1307,7 @@ export class Dispatcher {
|
|
|
1093
1307
|
}
|
|
1094
1308
|
const branch = record.branch ?? `ab/${slug}`
|
|
1095
1309
|
const generation = events.filter((event) => event.type === 'workspace.provisioned').length
|
|
1096
|
-
await this.
|
|
1310
|
+
await this.store.append(slug, {
|
|
1097
1311
|
actor: DISPATCHER,
|
|
1098
1312
|
type: 'workspace.provision-started',
|
|
1099
1313
|
payload: { provider: this.deps.workspaces.name, branch, generation },
|
|
@@ -1134,12 +1348,15 @@ export class Dispatcher {
|
|
|
1134
1348
|
body: string
|
|
1135
1349
|
authoredSession?: string
|
|
1136
1350
|
autoMergeUser?: string
|
|
1351
|
+
autoMergeDefaultSeq?: number
|
|
1352
|
+
assets?: PinnedAsset[]
|
|
1137
1353
|
}
|
|
1138
1354
|
| undefined
|
|
1139
1355
|
tail: 'dispatch' | 'launch'
|
|
1140
1356
|
},
|
|
1141
1357
|
): Promise<void> {
|
|
1142
|
-
const
|
|
1358
|
+
const store = this.store
|
|
1359
|
+
const { workspaces, config } = this.deps
|
|
1143
1360
|
const slug = entry.slug
|
|
1144
1361
|
let leaseClaimed = false
|
|
1145
1362
|
let heartbeat: ReturnType<typeof setInterval> | undefined
|
|
@@ -1192,6 +1409,7 @@ export class Dispatcher {
|
|
|
1192
1409
|
ref: handle.ref,
|
|
1193
1410
|
path: handle.path,
|
|
1194
1411
|
...(handle.localPath !== undefined ? { localPath: handle.localPath } : {}),
|
|
1412
|
+
remote: workspaces.publication !== undefined,
|
|
1195
1413
|
branch: handle.branch,
|
|
1196
1414
|
base: handle.base,
|
|
1197
1415
|
},
|
|
@@ -1277,10 +1495,10 @@ export class Dispatcher {
|
|
|
1277
1495
|
for (const entry of entries) entry.controller.abort()
|
|
1278
1496
|
for (const entry of entries) {
|
|
1279
1497
|
try {
|
|
1280
|
-
const events = await this.
|
|
1498
|
+
const events = await this.store.getEvents(entry.slug)
|
|
1281
1499
|
if (this.openProvisionMarker(events) !== undefined) {
|
|
1282
1500
|
const attempt = events.filter((event) => event.type === 'dispatch.failed').length + 1
|
|
1283
|
-
await this.
|
|
1501
|
+
await this.store.append(entry.slug, {
|
|
1284
1502
|
actor: DISPATCHER,
|
|
1285
1503
|
type: 'dispatch.failed',
|
|
1286
1504
|
payload: {
|
|
@@ -1295,7 +1513,7 @@ export class Dispatcher {
|
|
|
1295
1513
|
// The durable marker plus the released lease still gate adoption.
|
|
1296
1514
|
}
|
|
1297
1515
|
try {
|
|
1298
|
-
await this.
|
|
1516
|
+
await this.store.releaseLease(entry.slug, entry.instance)
|
|
1299
1517
|
} catch {
|
|
1300
1518
|
// Expiry fences an ambiguous release.
|
|
1301
1519
|
}
|
|
@@ -1311,12 +1529,11 @@ export class Dispatcher {
|
|
|
1311
1529
|
): Promise<void> {
|
|
1312
1530
|
// Listing is repository-wide work: if it fails there is no individual
|
|
1313
1531
|
// build to attribute, so the caller's existing top-level boundary owns it.
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
if (record.repo !== this.deps.repo) continue
|
|
1532
|
+
// Only this repo's builds that still have work (§12: another repo's
|
|
1533
|
+
// builds are another dispatcher's duty; settled terminal builds cost no
|
|
1534
|
+
// event reads).
|
|
1535
|
+
const entries = (await this.repoBuildDigests()).filter(({ digest }) => isWorkDigest(digest))
|
|
1536
|
+
for (const { record, digest } of entries) {
|
|
1320
1537
|
// Budget gate: the janitor's per-build path issues forge/store transport
|
|
1321
1538
|
// calls (PR probes, closePr, deleteBranch); once the budget is spent,
|
|
1322
1539
|
// stop starting new ones. The remaining builds stay janitor due — the
|
|
@@ -1326,14 +1543,22 @@ export class Dispatcher {
|
|
|
1326
1543
|
// Once a record is known, contain its complete janitor path: loading
|
|
1327
1544
|
// and reducing facts, cleanup, forge/ticket calls, and store writes.
|
|
1328
1545
|
// Existing partial facts leave the same work due on the next tick.
|
|
1329
|
-
const
|
|
1330
|
-
const state = reduceBuild(events)
|
|
1546
|
+
const { state, workspace } = await this.store.buildFacts(record.slug)
|
|
1331
1547
|
const executionLeaseLive = this.hasLiveExecutionLease(record)
|
|
1548
|
+
// Raw history, read at most once and only by a branch that acts on it:
|
|
1549
|
+
// the facts above answer every quiet-path question without it.
|
|
1550
|
+
let loaded: AbEvent[] | undefined
|
|
1551
|
+
const history = async (): Promise<AbEvent[]> => {
|
|
1552
|
+
loaded ??= await this.store.getEvents(record.slug)
|
|
1553
|
+
return loaded
|
|
1554
|
+
}
|
|
1332
1555
|
// Pipeline/ticket/workspace state is settled, but release-asset cleanup
|
|
1333
1556
|
// has its own crash window after build.completed. Revisit only pending
|
|
1334
1557
|
// hosted handles; this never relaunches a runner or consumes capacity.
|
|
1335
1558
|
if (state.status === 'done') {
|
|
1336
|
-
|
|
1559
|
+
if (digest.reclaimPending === true) {
|
|
1560
|
+
await this.reclaimPrAttachments(record.slug, await history())
|
|
1561
|
+
}
|
|
1337
1562
|
continue
|
|
1338
1563
|
}
|
|
1339
1564
|
// Abort is destructive human judgment and therefore outranks a queued
|
|
@@ -1343,13 +1568,15 @@ export class Dispatcher {
|
|
|
1343
1568
|
state.status === 'queued' &&
|
|
1344
1569
|
state.pendingCommands.some((command) => command.command === 'abort')
|
|
1345
1570
|
) {
|
|
1346
|
-
const acknowledged = await this.
|
|
1571
|
+
const acknowledged = await this.store.append(record.slug, {
|
|
1347
1572
|
actor: DISPATCHER,
|
|
1348
1573
|
type: 'build.aborted',
|
|
1349
1574
|
payload: {},
|
|
1350
1575
|
} satisfies EventWrite<'build.aborted'>)
|
|
1351
|
-
|
|
1352
|
-
|
|
1576
|
+
void acknowledged
|
|
1577
|
+
// The view folded the acknowledgement it just appended.
|
|
1578
|
+
loaded = undefined
|
|
1579
|
+
await this.cleanupAborted(record, await history(), report)
|
|
1353
1580
|
continue
|
|
1354
1581
|
}
|
|
1355
1582
|
// Discard is a queued-only recovery control. A request that raced with
|
|
@@ -1360,15 +1587,23 @@ export class Dispatcher {
|
|
|
1360
1587
|
state.status === 'queued' &&
|
|
1361
1588
|
state.discardRequest !== undefined
|
|
1362
1589
|
) {
|
|
1363
|
-
await this.cleanupDiscarded(record,
|
|
1590
|
+
await this.cleanupDiscarded(record, await history(), report)
|
|
1364
1591
|
continue
|
|
1365
1592
|
}
|
|
1366
1593
|
if (!executionLeaseLive && state.status === 'aborted') {
|
|
1367
|
-
await this.cleanupAborted(record,
|
|
1594
|
+
await this.cleanupAborted(record, await history(), report)
|
|
1368
1595
|
continue
|
|
1369
1596
|
}
|
|
1370
|
-
if (state.pr)
|
|
1371
|
-
await this.checkPr(
|
|
1597
|
+
if (state.pr) {
|
|
1598
|
+
await this.checkPr(
|
|
1599
|
+
record,
|
|
1600
|
+
{ history, workspace },
|
|
1601
|
+
state,
|
|
1602
|
+
report,
|
|
1603
|
+
launched,
|
|
1604
|
+
executionLeaseLive,
|
|
1605
|
+
)
|
|
1606
|
+
}
|
|
1372
1607
|
} catch (error) {
|
|
1373
1608
|
const message = error instanceof Error ? error.message : String(error)
|
|
1374
1609
|
report.janitorFailed += 1
|
|
@@ -1378,8 +1613,11 @@ export class Dispatcher {
|
|
|
1378
1613
|
}
|
|
1379
1614
|
|
|
1380
1615
|
private forgeWorkspacePath(events: AbEvent[]): string {
|
|
1381
|
-
|
|
1382
|
-
|
|
1616
|
+
return this.forgeWorkspacePathOf(openWorkspace(events))
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
private forgeWorkspacePathOf(open: OpenBuildWorkspace | null): string {
|
|
1620
|
+
if (open !== null && isRemoteWorkspace(open)) return this.deps.repo
|
|
1383
1621
|
// Local forges run git with cwd = workspacePath: it must be the physical
|
|
1384
1622
|
// checkout, never the store identity (an origin URL since the identity
|
|
1385
1623
|
// change; a path only for origin-less fixtures).
|
|
@@ -1401,19 +1639,19 @@ export class Dispatcher {
|
|
|
1401
1639
|
events: AbEvent[],
|
|
1402
1640
|
report: TickReport,
|
|
1403
1641
|
): Promise<void> {
|
|
1404
|
-
const
|
|
1642
|
+
const store = this.store
|
|
1643
|
+
const { tickets, forge } = this.deps
|
|
1405
1644
|
const branch = record.branch
|
|
1406
1645
|
// Whether this build ever owned a local checkout workspace, judged on the
|
|
1407
1646
|
// provisioned facts (not the open one, which is null when the build never
|
|
1408
1647
|
// provisioned or already released — only the former must skip the
|
|
1409
1648
|
// local-branch git step; a released local workspace may still have left a
|
|
1410
|
-
// branch in the checkout). Remote workspaces
|
|
1411
|
-
//
|
|
1412
|
-
//
|
|
1413
|
-
//
|
|
1649
|
+
// branch in the checkout). Remote workspaces never create a local branch
|
|
1650
|
+
// in the main checkout, and origin mode has no checkout at all: the
|
|
1651
|
+
// local-branch git step is skipped, and the saga's later steps tolerate
|
|
1652
|
+
// its absence.
|
|
1414
1653
|
const hadLocalWorkspace = events.some(
|
|
1415
|
-
(event) =>
|
|
1416
|
-
event.type === 'workspace.provisioned' && event.payload.provider !== 'vercel-sandbox',
|
|
1654
|
+
(event) => event.type === 'workspace.provisioned' && !isRemoteWorkspace(event.payload),
|
|
1417
1655
|
)
|
|
1418
1656
|
const has = (type: AbEvent['type']): boolean => events.some((event) => event.type === type)
|
|
1419
1657
|
const append = async <T extends EventWrite['type']>(write: EventWrite<T>): Promise<void> => {
|
|
@@ -1565,7 +1803,8 @@ export class Dispatcher {
|
|
|
1565
1803
|
events: AbEvent[],
|
|
1566
1804
|
report: TickReport,
|
|
1567
1805
|
): Promise<void> {
|
|
1568
|
-
const
|
|
1806
|
+
const store = this.store
|
|
1807
|
+
const { tickets } = this.deps
|
|
1569
1808
|
await this.releaseWorkspace(record.slug, events, 'discard')
|
|
1570
1809
|
if (record.lease !== undefined) {
|
|
1571
1810
|
await store.releaseLease(record.slug, record.lease.holder)
|
|
@@ -1589,18 +1828,21 @@ export class Dispatcher {
|
|
|
1589
1828
|
* against the reduced `prState`), release/complete, project to the ticket. */
|
|
1590
1829
|
private async checkPr(
|
|
1591
1830
|
record: BuildRecord,
|
|
1592
|
-
|
|
1831
|
+
// `workspace` is the open workspace fact; `history` loads the raw log, which
|
|
1832
|
+
// only the branches that act on a PR transition need.
|
|
1833
|
+
source: { history: () => Promise<AbEvent[]>; workspace: OpenBuildWorkspace | null },
|
|
1593
1834
|
state: BuildState,
|
|
1594
1835
|
report: TickReport,
|
|
1595
1836
|
launched: Set<string>,
|
|
1596
1837
|
executionLeaseLive: boolean,
|
|
1597
1838
|
): Promise<void> {
|
|
1598
|
-
const
|
|
1839
|
+
const store = this.store
|
|
1840
|
+
const { tickets, forge } = this.deps
|
|
1599
1841
|
const pr = state.pr
|
|
1600
1842
|
if (!pr) return
|
|
1601
1843
|
// Forge calls run from the workspace when it still exists (it does until
|
|
1602
1844
|
// the build completes); fall back to the repo itself for odd logs.
|
|
1603
|
-
const workspacePath = this.
|
|
1845
|
+
const workspacePath = this.forgeWorkspacePathOf(source.workspace)
|
|
1604
1846
|
const prState = await forge.getPrState(workspacePath, pr.number)
|
|
1605
1847
|
const autoMerge = prState.state === 'open' ? pendingAutoMerge(state) : undefined
|
|
1606
1848
|
|
|
@@ -1626,6 +1868,7 @@ export class Dispatcher {
|
|
|
1626
1868
|
// Dedupe: a reduced prState of 'conflicted' means reconcile is already
|
|
1627
1869
|
// pending (a `reconcile.completed` returns it to 'open').
|
|
1628
1870
|
if (state.prState === 'conflicted') return
|
|
1871
|
+
const events = await source.history()
|
|
1629
1872
|
const baseSha = await this.baseSha(baseBranchOf(events, this.deps.config))
|
|
1630
1873
|
const conflicted = await store.append(record.slug, {
|
|
1631
1874
|
actor: DISPATCHER,
|
|
@@ -1674,10 +1917,11 @@ export class Dispatcher {
|
|
|
1674
1917
|
// Re-read at the last possible point. A cancellation, replacement
|
|
1675
1918
|
// command, newly due pipeline work, or application fact suppresses
|
|
1676
1919
|
// this attempt; the next tick reclassifies from fresh forge state.
|
|
1677
|
-
|
|
1678
|
-
const
|
|
1920
|
+
await store.refreshBuild(record.slug)
|
|
1921
|
+
const latest = await store.buildFacts(record.slug)
|
|
1922
|
+
const latestState = latest.state
|
|
1679
1923
|
const latestIntent = pendingAutoMerge(latestState)
|
|
1680
|
-
const decision =
|
|
1924
|
+
const decision = decideNextFromFacts(latest.state, latest.log, this.deps.config)
|
|
1681
1925
|
if (
|
|
1682
1926
|
latestState.pr?.number === pr.number &&
|
|
1683
1927
|
latestIntent?.enabled === true &&
|
|
@@ -1693,6 +1937,7 @@ export class Dispatcher {
|
|
|
1693
1937
|
switch (prState.state) {
|
|
1694
1938
|
case 'merged': {
|
|
1695
1939
|
if (executionLeaseLive) return
|
|
1940
|
+
const events = await source.history()
|
|
1696
1941
|
// Emit the fact once (a crash between steps re-runs this block; the
|
|
1697
1942
|
// reduced prState dedupes the event, the log dedupes the release).
|
|
1698
1943
|
if (state.prState !== 'merged') {
|
|
@@ -1718,6 +1963,7 @@ export class Dispatcher {
|
|
|
1718
1963
|
}
|
|
1719
1964
|
case 'closed': {
|
|
1720
1965
|
if (executionLeaseLive) return
|
|
1966
|
+
const events = await source.history()
|
|
1721
1967
|
// Closed without merge is a human decision — back to Triage (§15.7).
|
|
1722
1968
|
if (state.prState !== 'closed') {
|
|
1723
1969
|
await store.append(record.slug, {
|
|
@@ -1774,14 +2020,14 @@ export class Dispatcher {
|
|
|
1774
2020
|
workspacePath: this.deps.checkout ?? this.deps.repo,
|
|
1775
2021
|
asset: hosted.payload.asset,
|
|
1776
2022
|
})
|
|
1777
|
-
await this.
|
|
2023
|
+
await this.store.append(slug, {
|
|
1778
2024
|
actor: DISPATCHER,
|
|
1779
2025
|
type: 'pr-attachment.reclaimed',
|
|
1780
2026
|
payload: { hostedSeq: hosted.seq },
|
|
1781
2027
|
})
|
|
1782
2028
|
} catch (error) {
|
|
1783
2029
|
try {
|
|
1784
|
-
await this.
|
|
2030
|
+
await this.store.append(slug, {
|
|
1785
2031
|
actor: DISPATCHER,
|
|
1786
2032
|
type: 'pr-attachment.reclaim-failed',
|
|
1787
2033
|
payload: {
|
|
@@ -1801,24 +2047,7 @@ export class Dispatcher {
|
|
|
1801
2047
|
}
|
|
1802
2048
|
|
|
1803
2049
|
private recoveryCheckpoint(events: AbEvent[]): string | undefined {
|
|
1804
|
-
|
|
1805
|
-
let original: string | undefined
|
|
1806
|
-
for (const event of events) {
|
|
1807
|
-
if (event.type === 'workspace.provisioned' && original === undefined) {
|
|
1808
|
-
original = event.payload.base.sha
|
|
1809
|
-
} else if (event.type === 'implement.completed') {
|
|
1810
|
-
settled = event.payload.commits.head
|
|
1811
|
-
} else if (event.type === 'reconcile.completed') {
|
|
1812
|
-
settled = event.payload.mergeCommit
|
|
1813
|
-
} else if (
|
|
1814
|
-
event.type === 'finalize.step-completed' &&
|
|
1815
|
-
event.payload.ok &&
|
|
1816
|
-
event.payload.headSha !== undefined
|
|
1817
|
-
) {
|
|
1818
|
-
settled = event.payload.headSha
|
|
1819
|
-
}
|
|
1820
|
-
}
|
|
1821
|
-
return settled ?? original
|
|
2050
|
+
return recoveryCheckpointReducer.reduce(events)
|
|
1822
2051
|
}
|
|
1823
2052
|
|
|
1824
2053
|
private async recordInfrastructureFailure(
|
|
@@ -1834,7 +2063,7 @@ export class Dispatcher {
|
|
|
1834
2063
|
): Promise<void> {
|
|
1835
2064
|
const execution = [...events].reverse().find((event) => event.type === 'execution.started')
|
|
1836
2065
|
const appended = await appendInfrastructureFailure(
|
|
1837
|
-
{ store: this.
|
|
2066
|
+
{ store: this.store, ids: this.deps.ids },
|
|
1838
2067
|
{
|
|
1839
2068
|
slug,
|
|
1840
2069
|
events,
|
|
@@ -1884,7 +2113,7 @@ export class Dispatcher {
|
|
|
1884
2113
|
// failure propagates — a stale workspace with an unrecorded pending
|
|
1885
2114
|
// request is never reaped (the next tick retries).
|
|
1886
2115
|
events = await settlePublicationBeforeRelease(
|
|
1887
|
-
{ store: this.
|
|
2116
|
+
{ store: this.store, settlePublication: this.settlePublication },
|
|
1888
2117
|
slug,
|
|
1889
2118
|
events,
|
|
1890
2119
|
reason,
|
|
@@ -1903,7 +2132,7 @@ export class Dispatcher {
|
|
|
1903
2132
|
...(open.localPath !== undefined ? { localPath: open.localPath } : {}),
|
|
1904
2133
|
branch: open.branch,
|
|
1905
2134
|
})
|
|
1906
|
-
const cleaned = await this.
|
|
2135
|
+
const cleaned = await this.store.append(slug, {
|
|
1907
2136
|
actor: DISPATCHER,
|
|
1908
2137
|
type: 'infrastructure.cleanup-attempted',
|
|
1909
2138
|
payload: {
|
|
@@ -1916,7 +2145,7 @@ export class Dispatcher {
|
|
|
1916
2145
|
},
|
|
1917
2146
|
})
|
|
1918
2147
|
events.push(cleaned)
|
|
1919
|
-
const released = await this.
|
|
2148
|
+
const released = await this.store.append(slug, {
|
|
1920
2149
|
actor: DISPATCHER,
|
|
1921
2150
|
type: 'workspace.released',
|
|
1922
2151
|
payload: { ref: open.ref, reason },
|
|
@@ -1925,7 +2154,7 @@ export class Dispatcher {
|
|
|
1925
2154
|
return true
|
|
1926
2155
|
} catch (error) {
|
|
1927
2156
|
const message = error instanceof Error ? error.message : String(error)
|
|
1928
|
-
const failed = await this.
|
|
2157
|
+
const failed = await this.store.append(slug, {
|
|
1929
2158
|
actor: DISPATCHER,
|
|
1930
2159
|
type: 'infrastructure.cleanup-attempted',
|
|
1931
2160
|
payload: {
|
|
@@ -1973,7 +2202,7 @@ export class Dispatcher {
|
|
|
1973
2202
|
// workspace still exists. The guard's append failure propagates — the
|
|
1974
2203
|
// release never proceeds past an unrecorded pending request.
|
|
1975
2204
|
events = await settlePublicationBeforeRelease(
|
|
1976
|
-
{ store: this.
|
|
2205
|
+
{ store: this.store, settlePublication: this.settlePublication },
|
|
1977
2206
|
slug,
|
|
1978
2207
|
events,
|
|
1979
2208
|
reason,
|
|
@@ -1988,7 +2217,7 @@ export class Dispatcher {
|
|
|
1988
2217
|
).length + 1
|
|
1989
2218
|
try {
|
|
1990
2219
|
const outcome = await recovery.reap(handle)
|
|
1991
|
-
const cleaned = await this.
|
|
2220
|
+
const cleaned = await this.store.append(slug, {
|
|
1992
2221
|
actor: DISPATCHER,
|
|
1993
2222
|
type: 'infrastructure.cleanup-attempted',
|
|
1994
2223
|
payload: {
|
|
@@ -2003,7 +2232,7 @@ export class Dispatcher {
|
|
|
2003
2232
|
events.push(cleaned)
|
|
2004
2233
|
} catch (error) {
|
|
2005
2234
|
const message = error instanceof Error ? error.message : String(error)
|
|
2006
|
-
const failed = await this.
|
|
2235
|
+
const failed = await this.store.append(slug, {
|
|
2007
2236
|
actor: DISPATCHER,
|
|
2008
2237
|
type: 'infrastructure.cleanup-attempted',
|
|
2009
2238
|
payload: {
|
|
@@ -2028,7 +2257,7 @@ export class Dispatcher {
|
|
|
2028
2257
|
} else {
|
|
2029
2258
|
await owner.release(handle)
|
|
2030
2259
|
}
|
|
2031
|
-
return this.
|
|
2260
|
+
return this.store.append(slug, {
|
|
2032
2261
|
actor: DISPATCHER,
|
|
2033
2262
|
type: 'workspace.released',
|
|
2034
2263
|
payload: { ref: open.ref, reason },
|
|
@@ -2046,7 +2275,15 @@ export class Dispatcher {
|
|
|
2046
2275
|
private async baseSha(baseBranch: string): Promise<string> {
|
|
2047
2276
|
const remoteBranchSha = this.deps.forge.remoteBranchSha
|
|
2048
2277
|
if (remoteBranchSha !== undefined) {
|
|
2049
|
-
|
|
2278
|
+
try {
|
|
2279
|
+
return await remoteBranchSha.call(this.deps.forge, baseBranch)
|
|
2280
|
+
} catch {
|
|
2281
|
+
// A forge that advertises the capability but cannot resolve this branch
|
|
2282
|
+
// (a checkout-mode adapter, an unseeded test fake, or a transient
|
|
2283
|
+
// remote failure) falls through to the physical checkout's git read.
|
|
2284
|
+
// In origin mode there is no checkout, so the git read's failure is the
|
|
2285
|
+
// actionable error and the tick still fails closed.
|
|
2286
|
+
}
|
|
2050
2287
|
}
|
|
2051
2288
|
const args = [
|
|
2052
2289
|
'git',
|
|
@@ -2068,6 +2305,29 @@ export class Dispatcher {
|
|
|
2068
2305
|
|
|
2069
2306
|
// ── b. Interrupted dispatch recovery ───────────────────────────────────────
|
|
2070
2307
|
|
|
2308
|
+
/** The claim-time `build.created` payload. Assets are omitted when the ticket
|
|
2309
|
+
* had none, so a no-asset build's log is exactly what it was before. */
|
|
2310
|
+
private buildCreatedPayload(
|
|
2311
|
+
ticket: TicketRef,
|
|
2312
|
+
repo: string,
|
|
2313
|
+
seed:
|
|
2314
|
+
| { autoMergeUser?: string; autoMergeDefaultSeq?: number; assets?: PinnedAsset[] }
|
|
2315
|
+
| undefined,
|
|
2316
|
+
): EventPayload<'build.created'> {
|
|
2317
|
+
const { config } = this.deps
|
|
2318
|
+
return {
|
|
2319
|
+
ticket,
|
|
2320
|
+
repo,
|
|
2321
|
+
baseBranch: config.baseBranch,
|
|
2322
|
+
...(seed?.autoMergeUser !== undefined ? { autoMergeRequestedBy: seed.autoMergeUser } : {}),
|
|
2323
|
+
...(seed?.autoMergeDefaultSeq !== undefined
|
|
2324
|
+
? { autoMergeDefaultSeq: seed.autoMergeDefaultSeq }
|
|
2325
|
+
: {}),
|
|
2326
|
+
...(config.pr !== undefined ? { pr: config.pr } : {}),
|
|
2327
|
+
...(seed?.assets !== undefined && seed.assets.length > 0 ? { assets: seed.assets } : {}),
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2071
2331
|
/** Every boundary before runner attachment is derived from durable facts.
|
|
2072
2332
|
* Missing facts are the todo list; successful provider calls are followed
|
|
2073
2333
|
* immediately by their facts, so an ordinary later tick can continue. */
|
|
@@ -2091,9 +2351,12 @@ export class Dispatcher {
|
|
|
2091
2351
|
body: string
|
|
2092
2352
|
authoredSession?: string
|
|
2093
2353
|
autoMergeUser?: string
|
|
2354
|
+
autoMergeDefaultSeq?: number
|
|
2355
|
+
assets?: PinnedAsset[]
|
|
2094
2356
|
},
|
|
2095
2357
|
): Promise<DispatchOutcome> {
|
|
2096
|
-
const
|
|
2358
|
+
const store = this.store
|
|
2359
|
+
const { config } = this.deps
|
|
2097
2360
|
let stage: EventPayload<'dispatch.failed'>['stage'] = 'create'
|
|
2098
2361
|
try {
|
|
2099
2362
|
let events = await store.getEvents(record.slug)
|
|
@@ -2103,18 +2366,16 @@ export class Dispatcher {
|
|
|
2103
2366
|
'build record lacks the immutable ticket facts needed to reconstruct build.created',
|
|
2104
2367
|
)
|
|
2105
2368
|
}
|
|
2369
|
+
// Legacy path: a record an older dispatcher left with an empty stream.
|
|
2370
|
+
// New claims write `build.created` atomically with the record, so this
|
|
2371
|
+
// can only sample the ticket's current assets (SPEC §6.3).
|
|
2372
|
+
const assets =
|
|
2373
|
+
seed?.assets ??
|
|
2374
|
+
(await samplePinnedAssets(store, record.repo, record.ticket.id).catch(() => []))
|
|
2106
2375
|
await store.appendIfCurrent(record.slug, 0, {
|
|
2107
2376
|
actor: DISPATCHER,
|
|
2108
2377
|
type: 'build.created',
|
|
2109
|
-
payload: {
|
|
2110
|
-
ticket: record.ticket,
|
|
2111
|
-
repo: record.repo,
|
|
2112
|
-
baseBranch: config.baseBranch,
|
|
2113
|
-
...(seed?.autoMergeUser !== undefined
|
|
2114
|
-
? { autoMergeRequestedBy: seed.autoMergeUser }
|
|
2115
|
-
: {}),
|
|
2116
|
-
...(config.pr !== undefined ? { pr: config.pr } : {}),
|
|
2117
|
-
},
|
|
2378
|
+
payload: this.buildCreatedPayload(record.ticket, record.repo, { ...seed, assets }),
|
|
2118
2379
|
})
|
|
2119
2380
|
events = await store.getEvents(record.slug)
|
|
2120
2381
|
if (!events.some((event) => event.type === 'build.created')) {
|
|
@@ -2124,6 +2385,10 @@ export class Dispatcher {
|
|
|
2124
2385
|
|
|
2125
2386
|
const created = events.find((event) => event.type === 'build.created')
|
|
2126
2387
|
const autoMergeUser = seed?.autoMergeUser ?? created?.payload.autoMergeRequestedBy
|
|
2388
|
+
// The claim's sampled default fact, for the request's fan-out provenance:
|
|
2389
|
+
// the seed's own value first (this dispatch created the build), else the
|
|
2390
|
+
// durable `build.created` fact a recovery pass resumes from.
|
|
2391
|
+
const autoMergeDefaultSeq = seed?.autoMergeDefaultSeq ?? created?.payload.autoMergeDefaultSeq
|
|
2127
2392
|
if (
|
|
2128
2393
|
autoMergeUser !== undefined &&
|
|
2129
2394
|
!events.some((event) => event.type === 'build.auto-merge-requested')
|
|
@@ -2131,7 +2396,9 @@ export class Dispatcher {
|
|
|
2131
2396
|
await store.append(record.slug, {
|
|
2132
2397
|
actor: humanActor(autoMergeUser),
|
|
2133
2398
|
type: 'build.auto-merge-requested',
|
|
2134
|
-
payload: {
|
|
2399
|
+
payload: {
|
|
2400
|
+
...(autoMergeDefaultSeq !== undefined ? { defaultSeq: autoMergeDefaultSeq } : {}),
|
|
2401
|
+
},
|
|
2135
2402
|
})
|
|
2136
2403
|
events = await store.getEvents(record.slug)
|
|
2137
2404
|
}
|
|
@@ -2172,6 +2439,7 @@ export class Dispatcher {
|
|
|
2172
2439
|
ref: handle.ref,
|
|
2173
2440
|
path: handle.path,
|
|
2174
2441
|
...(handle.localPath !== undefined ? { localPath: handle.localPath } : {}),
|
|
2442
|
+
remote: this.deps.workspaces.publication !== undefined,
|
|
2175
2443
|
branch: handle.branch,
|
|
2176
2444
|
base: handle.base,
|
|
2177
2445
|
},
|
|
@@ -2218,11 +2486,14 @@ export class Dispatcher {
|
|
|
2218
2486
|
body: string
|
|
2219
2487
|
authoredSession?: string
|
|
2220
2488
|
autoMergeUser?: string
|
|
2489
|
+
autoMergeDefaultSeq?: number
|
|
2490
|
+
assets?: PinnedAsset[]
|
|
2221
2491
|
},
|
|
2222
2492
|
report?: TickReport,
|
|
2223
2493
|
launched?: Set<string>,
|
|
2224
2494
|
): Promise<Exclude<DispatchOutcome, 'deferred'>> {
|
|
2225
|
-
const
|
|
2495
|
+
const store = this.store
|
|
2496
|
+
|
|
2226
2497
|
let stage: EventPayload<'dispatch.failed'>['stage'] = 'spec'
|
|
2227
2498
|
let authored = false
|
|
2228
2499
|
try {
|
|
@@ -2303,6 +2574,7 @@ export class Dispatcher {
|
|
|
2303
2574
|
// Discard validation and recovery selection happened before several
|
|
2304
2575
|
// provider awaits. Re-read immediately before launch so a request made
|
|
2305
2576
|
// during those boundaries parks cleanly for the next janitor pass.
|
|
2577
|
+
await store.refreshBuild(record.slug)
|
|
2306
2578
|
const launchState = reduceBuild(await store.getEvents(record.slug))
|
|
2307
2579
|
if (launchState.status !== 'queued' || launchState.discardRequest !== undefined) {
|
|
2308
2580
|
return 'parked'
|
|
@@ -2335,9 +2607,8 @@ export class Dispatcher {
|
|
|
2335
2607
|
paused: boolean,
|
|
2336
2608
|
opts: TickOpts = {},
|
|
2337
2609
|
): Promise<void> {
|
|
2338
|
-
for (const record of await this.
|
|
2610
|
+
for (const record of await this.nonterminalBuilds()) {
|
|
2339
2611
|
if (
|
|
2340
|
-
record.repo !== this.deps.repo ||
|
|
2341
2612
|
record.ticket === undefined ||
|
|
2342
2613
|
launched.has(record.slug) ||
|
|
2343
2614
|
this.continuations.has(record.slug)
|
|
@@ -2346,8 +2617,7 @@ export class Dispatcher {
|
|
|
2346
2617
|
}
|
|
2347
2618
|
// Budget gate: recovery issues store transport calls per build.
|
|
2348
2619
|
if (this.outOfBudget(opts)) break
|
|
2349
|
-
const
|
|
2350
|
-
const state = reduceBuild(events)
|
|
2620
|
+
const { state } = await this.store.buildFacts(record.slug)
|
|
2351
2621
|
if (
|
|
2352
2622
|
state.status !== 'queued' ||
|
|
2353
2623
|
state.discardRequest !== undefined ||
|
|
@@ -2383,14 +2653,15 @@ export class Dispatcher {
|
|
|
2383
2653
|
paused: boolean,
|
|
2384
2654
|
opts: TickOpts = {},
|
|
2385
2655
|
): Promise<void> {
|
|
2386
|
-
const
|
|
2387
|
-
|
|
2388
|
-
|
|
2656
|
+
const store = this.store
|
|
2657
|
+
const { config } = this.deps
|
|
2658
|
+
for (const record of await this.nonterminalBuilds()) {
|
|
2659
|
+
if (launched.has(record.slug)) continue
|
|
2389
2660
|
// Budget gate: resume launches runners (transport-backed work).
|
|
2390
2661
|
if (this.outOfBudget(opts)) break
|
|
2391
2662
|
|
|
2392
|
-
const
|
|
2393
|
-
const state =
|
|
2663
|
+
const facts = await store.buildFacts(record.slug)
|
|
2664
|
+
const state = facts.state
|
|
2394
2665
|
if (
|
|
2395
2666
|
state.status === 'done' ||
|
|
2396
2667
|
state.status === 'aborted' ||
|
|
@@ -2400,7 +2671,7 @@ export class Dispatcher {
|
|
|
2400
2671
|
continue
|
|
2401
2672
|
}
|
|
2402
2673
|
|
|
2403
|
-
const decision =
|
|
2674
|
+
const decision = decideNextFromFacts(state, facts.log, config)
|
|
2404
2675
|
if (decision.kind === 'wait') {
|
|
2405
2676
|
if (decision.reason === 'blocked' && state.openEscalations.length > 0) {
|
|
2406
2677
|
report.blockedDiagnostics.push(
|
|
@@ -2451,8 +2722,9 @@ export class Dispatcher {
|
|
|
2451
2722
|
opts: TickOpts = {},
|
|
2452
2723
|
): Promise<void> {
|
|
2453
2724
|
const now = this.deps.clock().getTime()
|
|
2454
|
-
|
|
2455
|
-
|
|
2725
|
+
// Terminal builds are filtered on the digest before the lease and TTL
|
|
2726
|
+
// checks, so a zero TTL cannot fall through to a log read.
|
|
2727
|
+
for (const record of await this.nonterminalBuilds()) {
|
|
2456
2728
|
if (launched.has(record.slug)) continue
|
|
2457
2729
|
// Budget gate: the sweep re-attaches runners per expired build.
|
|
2458
2730
|
if (this.outOfBudget(opts)) break
|
|
@@ -2461,16 +2733,22 @@ export class Dispatcher {
|
|
|
2461
2733
|
} else if (now - new Date(record.updatedAt).getTime() < this.leaseTtlMs) {
|
|
2462
2734
|
continue // absent lease within the first-claim grace (see DispatcherOpts)
|
|
2463
2735
|
}
|
|
2464
|
-
const
|
|
2465
|
-
const state =
|
|
2736
|
+
const facts = await this.store.buildFacts(record.slug)
|
|
2737
|
+
const state = facts.state
|
|
2466
2738
|
if (
|
|
2467
2739
|
(state.status === 'queued' && state.discardRequest !== undefined) ||
|
|
2468
2740
|
heldByRepositoryPause(state, paused)
|
|
2469
2741
|
) {
|
|
2470
2742
|
continue
|
|
2471
2743
|
}
|
|
2472
|
-
const decision =
|
|
2744
|
+
const decision = decideNextFromFacts(state, facts.log, this.deps.config)
|
|
2473
2745
|
if (state.status === 'done' || state.status === 'aborted') continue
|
|
2746
|
+
// Raw history, read at most once and only by a branch that acts on it.
|
|
2747
|
+
let loaded: AbEvent[] | undefined
|
|
2748
|
+
const history = async (): Promise<AbEvent[]> => {
|
|
2749
|
+
loaded ??= await this.store.getEvents(record.slug)
|
|
2750
|
+
return loaded
|
|
2751
|
+
}
|
|
2474
2752
|
// Liveness gate (durable supervision): a recorded execution the provider
|
|
2475
2753
|
// observes as RUNNING is left completely alone — no settle, no reap, no
|
|
2476
2754
|
// replacement, no re-attach. This is the change that stops a supervisor
|
|
@@ -2478,13 +2756,12 @@ export class Dispatcher {
|
|
|
2478
2756
|
// proved end settles first (completion facts, lease release, publication)
|
|
2479
2757
|
// and then falls through to the normal decision; a provider-proved loss
|
|
2480
2758
|
// falls through to the existing stale path below.
|
|
2481
|
-
const foreignExecution =
|
|
2759
|
+
const foreignExecution = facts.open
|
|
2482
2760
|
// A provider- or log-proved end in the same generation re-attaches in
|
|
2483
2761
|
// place: the guest finished its work, the workspace is still the
|
|
2484
2762
|
// build's, and replacing the environment would discard a healthy
|
|
2485
2763
|
// generation. Only an unproven stale lease or a proved loss reaps.
|
|
2486
|
-
|
|
2487
|
-
let endedInPlace = lastExecution === 'completed'
|
|
2764
|
+
let endedInPlace = facts.lastExecution === 'completed'
|
|
2488
2765
|
if (
|
|
2489
2766
|
foreignExecution !== null &&
|
|
2490
2767
|
foreignExecution.commandId !== undefined &&
|
|
@@ -2493,16 +2770,16 @@ export class Dispatcher {
|
|
|
2493
2770
|
const owner = this.executionOwner(foreignExecution.provider)
|
|
2494
2771
|
const execution = owner?.buildExecution
|
|
2495
2772
|
if (execution?.observe !== undefined) {
|
|
2496
|
-
const settlement = await
|
|
2773
|
+
const settlement = await settleOpenExecution(
|
|
2497
2774
|
{
|
|
2498
|
-
store: this.
|
|
2775
|
+
store: this.store,
|
|
2499
2776
|
execution,
|
|
2500
|
-
...(this.
|
|
2501
|
-
? { settlePublication: this.
|
|
2777
|
+
...(this.settlePublication !== undefined
|
|
2778
|
+
? { settlePublication: this.settlePublication }
|
|
2502
2779
|
: {}),
|
|
2503
2780
|
},
|
|
2504
2781
|
record.slug,
|
|
2505
|
-
|
|
2782
|
+
foreignExecution,
|
|
2506
2783
|
)
|
|
2507
2784
|
if (settlement === 'running') continue
|
|
2508
2785
|
if (settlement === 'settled') {
|
|
@@ -2514,8 +2791,8 @@ export class Dispatcher {
|
|
|
2514
2791
|
const publicationRecoveryDue =
|
|
2515
2792
|
state.status !== 'paused' &&
|
|
2516
2793
|
state.status !== 'blocked' &&
|
|
2517
|
-
(
|
|
2518
|
-
const open =
|
|
2794
|
+
(facts.publication.pending() || facts.publication.abandonedPending())
|
|
2795
|
+
const open = facts.workspace
|
|
2519
2796
|
// A provider- or log-proved END in the same generation re-attaches in
|
|
2520
2797
|
// place: the guest finished its work, the workspace is still the
|
|
2521
2798
|
// build's, and replacing the environment would discard a healthy
|
|
@@ -2528,13 +2805,13 @@ export class Dispatcher {
|
|
|
2528
2805
|
: state.status === 'blocked'
|
|
2529
2806
|
? ('blocked' as const)
|
|
2530
2807
|
: ('replacement' as const)
|
|
2531
|
-
const reaped = await this.reapStaleWorkspace(record.slug,
|
|
2808
|
+
const reaped = await this.reapStaleWorkspace(record.slug, await history(), reason)
|
|
2532
2809
|
if (!reaped) continue
|
|
2533
2810
|
if (!parked) {
|
|
2534
2811
|
// Recovery-capable provider: reap inline (bounded), then kick the
|
|
2535
2812
|
// same background continuation — it provisions and launches without
|
|
2536
2813
|
// blocking this tick. Local providers never enter this branch.
|
|
2537
|
-
const kicked = await this.ensureProvisionContinuation(record,
|
|
2814
|
+
const kicked = await this.ensureProvisionContinuation(record, await history(), 'launch')
|
|
2538
2815
|
if (kicked === 'kicked') {
|
|
2539
2816
|
report.provisioning += 1
|
|
2540
2817
|
report.swept += 1
|
|
@@ -2551,11 +2828,11 @@ export class Dispatcher {
|
|
|
2551
2828
|
// already continued earlier.
|
|
2552
2829
|
if (decision.kind === 'wait' && !publicationRecoveryDue) continue
|
|
2553
2830
|
if (
|
|
2554
|
-
|
|
2831
|
+
facts.workspace === null &&
|
|
2555
2832
|
this.deps.workspaces.recovery !== undefined &&
|
|
2556
|
-
|
|
2833
|
+
(await history()).some((event) => event.type === 'workspace.provisioned')
|
|
2557
2834
|
) {
|
|
2558
|
-
const kicked = await this.ensureProvisionContinuation(record,
|
|
2835
|
+
const kicked = await this.ensureProvisionContinuation(record, await history(), 'launch')
|
|
2559
2836
|
if (kicked === 'kicked') {
|
|
2560
2837
|
report.provisioning += 1
|
|
2561
2838
|
report.swept += 1
|
|
@@ -2578,17 +2855,17 @@ export class Dispatcher {
|
|
|
2578
2855
|
autoMergeUser: string | undefined,
|
|
2579
2856
|
paused: boolean,
|
|
2580
2857
|
opts: TickOpts = {},
|
|
2858
|
+
autoMergeSeedFact: AutoMergeDefaultFact | undefined = undefined,
|
|
2581
2859
|
): Promise<void> {
|
|
2582
|
-
const
|
|
2860
|
+
const store = this.store
|
|
2861
|
+
const { tickets, config } = this.deps
|
|
2583
2862
|
// Blocked and paused builds still occupy a slot: their workspaces and
|
|
2584
2863
|
// pending work are live, only waiting on a human. Capacity is per repo
|
|
2585
2864
|
// (§16.1) — another repo's builds never consume this repo's slots.
|
|
2586
2865
|
let active = 0
|
|
2587
2866
|
const activeTicketIds = new Set<string>()
|
|
2588
|
-
for (const record of await
|
|
2589
|
-
if (
|
|
2590
|
-
const state = reduceBuild(await store.getEvents(record.slug))
|
|
2591
|
-
if (state.status !== 'done' && state.status !== 'aborted') {
|
|
2867
|
+
for (const { record, digest } of await this.repoBuildDigests()) {
|
|
2868
|
+
if (digest.terminal === undefined) {
|
|
2592
2869
|
active += 1
|
|
2593
2870
|
if (record.ticket) activeTicketIds.add(record.ticket.id)
|
|
2594
2871
|
}
|
|
@@ -2599,9 +2876,11 @@ export class Dispatcher {
|
|
|
2599
2876
|
// standing queue-depth report and must stay honest exactly when the
|
|
2600
2877
|
// dispatcher is saturated. Sources that keep a claimed ticket in the
|
|
2601
2878
|
// ready state are deduped against the active builds embodying them.
|
|
2602
|
-
|
|
2879
|
+
await this.store.refreshJournal()
|
|
2880
|
+
const journalBeforeReady = this.store.recordedJournal()
|
|
2603
2881
|
const listing = await tickets.listReady(readyCriteria(config))
|
|
2604
|
-
|
|
2882
|
+
await this.store.refreshJournal()
|
|
2883
|
+
const journalAfterReady = this.store.recordedJournal()
|
|
2605
2884
|
const creationHolds = new Map(
|
|
2606
2885
|
harvestCreationsDuringReadyScan(journalBeforeReady, journalAfterReady).map((creation) => [
|
|
2607
2886
|
creation.creationKey.toLowerCase(),
|
|
@@ -2691,6 +2970,10 @@ export class Dispatcher {
|
|
|
2691
2970
|
continue
|
|
2692
2971
|
}
|
|
2693
2972
|
|
|
2973
|
+
// Freeze the ticket's assets at the claim (SPEC §6.3): sampled before the
|
|
2974
|
+
// spec-authoring interval so edits made during it are never pinned.
|
|
2975
|
+
const assets = await samplePinnedAssets(store, this.deps.repo, ticket.ref.id)
|
|
2976
|
+
|
|
2694
2977
|
// Quality gate (§6.3): import a conforming ticket body as the spec;
|
|
2695
2978
|
// author one for thin-but-groomed tickets; otherwise bounce.
|
|
2696
2979
|
let body = ticket.body
|
|
@@ -2717,12 +3000,29 @@ export class Dispatcher {
|
|
|
2717
3000
|
const slug = await this.uniqueSlug(baseSlug)
|
|
2718
3001
|
const branch = `ab/${slug}`
|
|
2719
3002
|
const repoOrigin = await this.resolveRepoOriginOnce()
|
|
3003
|
+
const createdSeed = {
|
|
3004
|
+
...(autoMergeUser !== undefined ? { autoMergeUser } : {}),
|
|
3005
|
+
// Claim-time provenance: the newest durable default fact that agrees
|
|
3006
|
+
// with the launch sample, recorded on `build.created` so the fan-out's
|
|
3007
|
+
// seq comparison sees the claim as already answering that fact — and
|
|
3008
|
+
// so a newer opposite fact (an interleaved toggle) stays strictly
|
|
3009
|
+
// ahead of it and reconciles this build on the next tick (f_b851c0e8).
|
|
3010
|
+
...(autoMergeSeedFact !== undefined ? { autoMergeDefaultSeq: autoMergeSeedFact.seq } : {}),
|
|
3011
|
+
assets,
|
|
3012
|
+
}
|
|
3013
|
+
// `build.created` rides with the record so the pinned assets and the
|
|
3014
|
+
// record exist together or not at all.
|
|
2720
3015
|
const record = await store.createBuild({
|
|
2721
3016
|
slug,
|
|
2722
3017
|
repo: this.deps.repo,
|
|
2723
3018
|
ticket: ticket.ref,
|
|
2724
3019
|
branch,
|
|
2725
3020
|
...(repoOrigin !== undefined ? { repoOrigin } : {}),
|
|
3021
|
+
created: {
|
|
3022
|
+
actor: DISPATCHER,
|
|
3023
|
+
type: 'build.created',
|
|
3024
|
+
payload: this.buildCreatedPayload(ticket.ref, this.deps.repo, createdSeed),
|
|
3025
|
+
},
|
|
2726
3026
|
})
|
|
2727
3027
|
report.queued -= 1
|
|
2728
3028
|
capacity -= 1
|
|
@@ -2730,7 +3030,7 @@ export class Dispatcher {
|
|
|
2730
3030
|
ticket,
|
|
2731
3031
|
body,
|
|
2732
3032
|
...(authoredSession !== undefined ? { authoredSession } : {}),
|
|
2733
|
-
...
|
|
3033
|
+
...createdSeed,
|
|
2734
3034
|
})
|
|
2735
3035
|
// Accepted dispatches: durable boundaries recorded. A deferred outcome
|
|
2736
3036
|
// means the remote provisioning continuation owns the rest.
|
|
@@ -2817,7 +3117,7 @@ export class Dispatcher {
|
|
|
2817
3117
|
/** Valid bounded base, deduped store-wide with -2/-3/… suffixes. */
|
|
2818
3118
|
private async uniqueSlug(base: string): Promise<string> {
|
|
2819
3119
|
let slug = base
|
|
2820
|
-
for (let n = 2; (await this.
|
|
3120
|
+
for (let n = 2; (await this.store.getBuild(slug)) !== null; n += 1) {
|
|
2821
3121
|
slug = `${base}-${n}`
|
|
2822
3122
|
}
|
|
2823
3123
|
return slug
|