@defrex/autobuild 0.8.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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,3692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ab dispatch` — the operator's entry into the outer loop (SPEC §3.3, §12).
|
|
3
|
+
* Runs OUTSIDE build sessions like init/upgrade/ticket (§16.3): it takes a
|
|
4
|
+
* repo, not a build, loads its autobuild.toml, wires the real ports, and runs
|
|
5
|
+
* the dispatcher's `tick()` — janitor → startup resume → lease sweep →
|
|
6
|
+
* dispatch — either once (`--once`) or on a watch loop until interrupted.
|
|
7
|
+
* Startup resume runs once per invocation and attempts every current build;
|
|
8
|
+
* later watch ticks preserve deliberate policy parks.
|
|
9
|
+
*
|
|
10
|
+
* It is the SAME `ab` binary agents use (§8): install is `ab` + `ab init`, and
|
|
11
|
+
* everyone — agents and operators — attaches to this one surface. The heavy
|
|
12
|
+
* adapters are constructed here behind an injectable `wire` seam, exactly as
|
|
13
|
+
* `ab ticket create` constructs its TicketSource via a factory, so the whole
|
|
14
|
+
* loop is testable over fakes.
|
|
15
|
+
*
|
|
16
|
+
* Concurrency is config, not code (§16.1): top-level `capacity` caps the
|
|
17
|
+
* concurrent builds for this repo. `launchRunner` starts each build-runner
|
|
18
|
+
* IN-PROCESS but does not block the dispatcher on it (fire-and-forget,
|
|
19
|
+
* tracked) — so with capacity N up to N builds run at once, while the
|
|
20
|
+
* dispatcher's own active-count gate (§12) keeps it from over-launching. A
|
|
21
|
+
* runner drives its build to a park point (§11) and returns; the watch loop's
|
|
22
|
+
* next tick advances the post-PR epilogue (§15.7).
|
|
23
|
+
*/
|
|
24
|
+
import { hostname } from 'node:os'
|
|
25
|
+
import { mkdir } from 'node:fs/promises'
|
|
26
|
+
import { createHash } from 'node:crypto'
|
|
27
|
+
import { tmpdir } from 'node:os'
|
|
28
|
+
import { join, resolve } from 'node:path'
|
|
29
|
+
import semver from 'semver'
|
|
30
|
+
import { parseConfig } from '../config/load'
|
|
31
|
+
import { DISPATCHER_CONFIG_ARTIFACT, LiveConfig, type ConfigSnapshot } from '../config/live'
|
|
32
|
+
import { effectiveRuntimeReferences, roleKeyWarnings, SLUG_ROLE } from '../config/roles'
|
|
33
|
+
import type { Config } from '../config/schema'
|
|
34
|
+
import { loadPlugins } from '../plugins/load'
|
|
35
|
+
import type { PluginRegistry } from '../plugins/registry'
|
|
36
|
+
import { materializePluginRuntimes } from '../plugins/runtimes'
|
|
37
|
+
import { DISPATCHER, humanActor } from '../events/envelope'
|
|
38
|
+
import type { RepositoryEventWrite } from '../events/repository'
|
|
39
|
+
import { randomIds, randomUuids, type IdSource, type UuidSource } from '../ids'
|
|
40
|
+
import { reduceDispatchSettings } from '../kernel/dispatch-settings'
|
|
41
|
+
import {
|
|
42
|
+
DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
|
|
43
|
+
actionableHarvestRun,
|
|
44
|
+
decideHarvestControl,
|
|
45
|
+
reduceHarvest,
|
|
46
|
+
} from '../kernel/harvest'
|
|
47
|
+
import { reduceBuild } from '../kernel/reducer'
|
|
48
|
+
import { dashboardBuildControl } from './dashboard/actions'
|
|
49
|
+
import {
|
|
50
|
+
buildDashboardFromProjected,
|
|
51
|
+
projectHarvest,
|
|
52
|
+
type DashboardBuild,
|
|
53
|
+
type DashboardModel,
|
|
54
|
+
type DashboardSelection,
|
|
55
|
+
type DashboardView,
|
|
56
|
+
} from './dashboard/model'
|
|
57
|
+
import { DashboardBuildPollCache } from './dashboard/poll'
|
|
58
|
+
import {
|
|
59
|
+
dashboardContentWidth,
|
|
60
|
+
detailScrollLimit,
|
|
61
|
+
moveDetailScroll,
|
|
62
|
+
moveTranscriptScroll,
|
|
63
|
+
renderDashboard,
|
|
64
|
+
revealDetailFocus,
|
|
65
|
+
type DashboardRendererResolver,
|
|
66
|
+
} from './dashboard/render'
|
|
67
|
+
import { parseTranscript } from './dashboard/transcript'
|
|
68
|
+
import { applySessionFeedUpdate, SessionStreamFeed } from './dashboard/session-feed'
|
|
69
|
+
import { moveSessionScroll, sessionScrollLimit } from './dashboard/render'
|
|
70
|
+
import { deleteBefore, insertText, moveCursor, type ComposerMotion } from './dashboard/composer'
|
|
71
|
+
import { dashboardSelections, moveSelection, reconcileSelection } from './dashboard/selection'
|
|
72
|
+
import { LiveRegion, paintableRows } from './dashboard/live'
|
|
73
|
+
import { createKeyboardProtocol, type KeyboardProtocol } from './keyboard'
|
|
74
|
+
import { recordInfrastructureFailure as appendInfrastructureFailure } from '../processes/infrastructure-failure-budget'
|
|
75
|
+
import { settlePendingPublication as settleWorkspacePublication } from './publication-settlement'
|
|
76
|
+
import type { TerminalInput, TerminalInputEvent, TerminalOut } from './terminal'
|
|
77
|
+
import { createForge, resolveForgeRegistration } from '../ports/forge/create'
|
|
78
|
+
import {
|
|
79
|
+
GitHubApiError,
|
|
80
|
+
githubTokenFromEnv,
|
|
81
|
+
type GitHubRequest,
|
|
82
|
+
} from '../ports/forge/github-transport'
|
|
83
|
+
import { GitHubForge } from '../ports/forge/github'
|
|
84
|
+
import { createProductionRuntimes } from '../ports/runner/production'
|
|
85
|
+
import type { RuntimeRegistry } from '../ports/runner/runtime'
|
|
86
|
+
import { createTicketSource } from '../ports/tickets/create'
|
|
87
|
+
import type {
|
|
88
|
+
Forge,
|
|
89
|
+
TicketSource,
|
|
90
|
+
WorkspaceHandle,
|
|
91
|
+
WorkspaceProvider,
|
|
92
|
+
WorkspaceReapOutcome,
|
|
93
|
+
} from '../ports/types'
|
|
94
|
+
import { createWorkspaceRuntime, type WorkspaceRuntime } from '../ports/workspace/create'
|
|
95
|
+
import { GitWorktreeProvider } from '../ports/workspace/git-worktree'
|
|
96
|
+
import { LocalBuildExecution } from '../ports/workspace/local-build-execution'
|
|
97
|
+
import {
|
|
98
|
+
BUILD_EXECUTION_LEASE_TTL_MS,
|
|
99
|
+
type BuildExecution,
|
|
100
|
+
type BuildExecutionHandle,
|
|
101
|
+
} from '../ports/workspace/build-execution'
|
|
102
|
+
import type { Exec } from '../ports/workspace/git-worktree'
|
|
103
|
+
import { validateVercelGithubOrigin } from '../ports/workspace/vercel-sandbox'
|
|
104
|
+
import {
|
|
105
|
+
BUILD_EFFECTIVE_CONFIG_ARTIFACT,
|
|
106
|
+
BUILD_RUNNER_DIAGNOSTIC_ARTIFACT,
|
|
107
|
+
effectiveBuildConfigContent,
|
|
108
|
+
parseDiagnostic,
|
|
109
|
+
} from '../processes/build-execution-state'
|
|
110
|
+
import { HarvestRunner, type HarvestRunnerResult } from '../processes/harvest-runner'
|
|
111
|
+
import { scanUnclaimedObservations, evaluateHarvestPressure } from '../processes/harvest'
|
|
112
|
+
import { classifyHarvestOutcome } from '../processes/harvest-execution-state'
|
|
113
|
+
import type { HarvestExecution } from '../ports/workspace/harvest-execution'
|
|
114
|
+
import {
|
|
115
|
+
controlHarvestRun as applyHarvestRunControl,
|
|
116
|
+
toggleHarvestGate as applyHarvestGateToggle,
|
|
117
|
+
toggleRepositorySetting,
|
|
118
|
+
} from '../operator/control'
|
|
119
|
+
import {
|
|
120
|
+
Dispatcher,
|
|
121
|
+
emptyTickReport,
|
|
122
|
+
type LaunchRunnerResult,
|
|
123
|
+
type TickReport,
|
|
124
|
+
} from '../processes/dispatcher'
|
|
125
|
+
import {
|
|
126
|
+
BuildControlError,
|
|
127
|
+
buildControlUser,
|
|
128
|
+
controlBuild,
|
|
129
|
+
type BuildControlResult,
|
|
130
|
+
} from './build-control'
|
|
131
|
+
import { bulkControlReport, bulkControlRepository, type BulkDirection } from './bulk-control'
|
|
132
|
+
import {
|
|
133
|
+
normalizeGitRemoteUrl,
|
|
134
|
+
resolveRepoState,
|
|
135
|
+
resolveRepoStatePaths,
|
|
136
|
+
type RepoStatePaths,
|
|
137
|
+
} from './repo-state'
|
|
138
|
+
import { openStoreForRepoState } from './store-opening'
|
|
139
|
+
import { DispatchFrontend } from './dispatch-frontend'
|
|
140
|
+
import { systemClock, type BuildStore, type Clock } from '../store/types'
|
|
141
|
+
import { availableRelease } from './self-update'
|
|
142
|
+
import {
|
|
143
|
+
startUpgradeNotice,
|
|
144
|
+
type AvailableReleaseProbe,
|
|
145
|
+
type UpgradeNoticeScheduler,
|
|
146
|
+
} from './upgrade-notice'
|
|
147
|
+
|
|
148
|
+
/** Watch-loop default cadence between ticks (§3.3 re-run safety makes this a
|
|
149
|
+
* pure knob — a shorter interval only polls the forge more often). */
|
|
150
|
+
const DEFAULT_INTERVAL_MS = 10_000
|
|
151
|
+
/** Repository supervisor lease (§12 serialization): TTL and heartbeat cadence.
|
|
152
|
+
* The heartbeat renews at a third of the TTL, so one lost beat still leaves a
|
|
153
|
+
* full interval of margin before a peer can take over. */
|
|
154
|
+
const REPO_LEASE_TTL_MS = 60_000
|
|
155
|
+
const REPO_LEASE_HEARTBEAT_MS = 20_000
|
|
156
|
+
/** Repository artifact containing the schema-validated composed Config used by
|
|
157
|
+
* one dispatch run. It is the frontend's only config source. */
|
|
158
|
+
export const DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT = 'dispatcher-effective-config'
|
|
159
|
+
|
|
160
|
+
/** JSON encoding in the config schema's declarative input shape. Parsed Config
|
|
161
|
+
* has normalized `{steps, stepConfigs}` sections; flattening named step tables
|
|
162
|
+
* lets the frontend validate the artifact with the same strict configSchema. */
|
|
163
|
+
function effectiveConfigContent(config: Config): string {
|
|
164
|
+
const { verify, finalize, ...root } = config
|
|
165
|
+
return JSON.stringify({
|
|
166
|
+
...root,
|
|
167
|
+
verify: { steps: verify.steps, ...verify.stepConfigs },
|
|
168
|
+
finalize: { steps: finalize.steps, ...finalize.stepConfigs },
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The pre-build naming prompt. Its output is only a proposal: dispatcher.ts
|
|
173
|
+
* owns strict validation, timeout/failure fallback, and store-wide uniqueness. */
|
|
174
|
+
export function slugNamingPrompt(spec: string): string {
|
|
175
|
+
return [
|
|
176
|
+
'Choose a short identifier for this software build.',
|
|
177
|
+
'Return exactly one lowercase ASCII kebab-case identifier containing one to three meaningful words.',
|
|
178
|
+
'Choose distinguishing subject/action words from the substance of the entire spec, not generic title lead-ins such as add, update, or please.',
|
|
179
|
+
'Return no quotes, Markdown, explanation, or numeric collision suffix; collision handling is done separately.',
|
|
180
|
+
'',
|
|
181
|
+
'<build-spec>',
|
|
182
|
+
spec,
|
|
183
|
+
'</build-spec>',
|
|
184
|
+
].join('\n')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function definedEnv(env: Record<string, string | undefined>): Record<string, string> {
|
|
188
|
+
const defined: Record<string, string> = {}
|
|
189
|
+
for (const [key, value] of Object.entries(env)) {
|
|
190
|
+
if (value !== undefined) defined[key] = value
|
|
191
|
+
}
|
|
192
|
+
return defined
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Dashboard store-read cadence. `listBuilds` remains the discovery read;
|
|
196
|
+
* the process-local display cache then polls dashboard-visible streams with
|
|
197
|
+
* `getEvents(lastSeq)`, retaining abort cleanup until final completion and reusing unchanged
|
|
198
|
+
* reductions/timing projections. Repository controls and Harvest are still
|
|
199
|
+
* read fresh. The identical-frame check in `live.ts` makes an unchanged paint
|
|
200
|
+
* cost zero terminal writes. */
|
|
201
|
+
const DASHBOARD_POLL_MS = 500
|
|
202
|
+
|
|
203
|
+
/** Dashboard repaint (not re-read) cadence in watch mode. A running step's
|
|
204
|
+
* elapsed must advance ~1×/s even if the store poll is raised for a slow remote
|
|
205
|
+
* store, so paint is decoupled from the store read and driven from this cheaper
|
|
206
|
+
* timer. A knob: the identical-frame check in `live.ts` collapses a repaint to
|
|
207
|
+
* zero writes until a displayed second actually changes, so a sub-second cadence
|
|
208
|
+
* costs nothing. `--once` runs no tick timer — it renders one snapshot per
|
|
209
|
+
* state (AC 8). */
|
|
210
|
+
const DASHBOARD_TICK_MS = 250
|
|
211
|
+
|
|
212
|
+
type DashboardAction =
|
|
213
|
+
| 'up'
|
|
214
|
+
| 'down'
|
|
215
|
+
| 'left'
|
|
216
|
+
| 'right'
|
|
217
|
+
| 'enter'
|
|
218
|
+
| 'auto-merge'
|
|
219
|
+
| 'intake'
|
|
220
|
+
| 'pause'
|
|
221
|
+
| 'resume'
|
|
222
|
+
| 'bulk-pause'
|
|
223
|
+
| 'bulk-resume'
|
|
224
|
+
| 'discard'
|
|
225
|
+
| 'abort-confirm'
|
|
226
|
+
| 'harvest-gate'
|
|
227
|
+
| { kind: 'harvest-run'; run: string | undefined }
|
|
228
|
+
|
|
229
|
+
interface ResumePrompt {
|
|
230
|
+
slug: string
|
|
231
|
+
/** Snapshot at prompt-open time. Submission revalidates each id. */
|
|
232
|
+
escalationIds: string[]
|
|
233
|
+
value: string
|
|
234
|
+
/** Caret as a UTF-16 grapheme-boundary offset into `value`. */
|
|
235
|
+
cursor: number
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The real adapters the loop drives — resolved by `wire` (default: the
|
|
239
|
+
* production ports; tests inject fakes). */
|
|
240
|
+
export interface DispatchWiring {
|
|
241
|
+
store: BuildStore
|
|
242
|
+
tickets: TicketSource
|
|
243
|
+
forge: Forge
|
|
244
|
+
workspaces: WorkspaceProvider
|
|
245
|
+
/** Provider and executor pairs for workspaces recorded before a
|
|
246
|
+
* `[workspace].provider` switch. A build is reaped, released, and executed
|
|
247
|
+
* only through the runtime named on its provisioned fact. */
|
|
248
|
+
retiredWorkspaces?: readonly WorkspaceRuntime[]
|
|
249
|
+
/** Workspace-adjacent build executor. Production always supplies the local
|
|
250
|
+
* subprocess implementation; tests may inject an in-process double. */
|
|
251
|
+
buildExecution: BuildExecution
|
|
252
|
+
/** Runtime registry (§9): name → adapter + compatibility data. The resolver
|
|
253
|
+
* applies `[roles]`, whose `default` entry is required. */
|
|
254
|
+
runtimes: RuntimeRegistry
|
|
255
|
+
/** The store reference sessions resolve as `AB_STORE` (D8) — MUST name the
|
|
256
|
+
* same store as `store`, so an agent's `ab` commands write where the
|
|
257
|
+
* dispatcher reads. */
|
|
258
|
+
storeRef: string
|
|
259
|
+
/** Scoped token for a remote store (D8, `AB_TOKEN`); passed to sessions. */
|
|
260
|
+
token?: string
|
|
261
|
+
ids: IdSource
|
|
262
|
+
uuids: UuidSource
|
|
263
|
+
clock: Clock
|
|
264
|
+
/** Validated startup catalog used by selected plugin adapters. Runtime
|
|
265
|
+
* factories are materialized into `runtimes` before role resolution. */
|
|
266
|
+
plugins?: PluginRegistry
|
|
267
|
+
/** Keeps workspace-owned runtime preflights aligned with accepted hot role reloads. */
|
|
268
|
+
updateRuntimeReferences?: (config: Config) => void
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export type DispatchNonStoreWiring = Omit<DispatchWiring, 'store' | 'storeRef' | 'token'>
|
|
272
|
+
|
|
273
|
+
/** Timer seam for the invocation bound (hosted cron): the drain races the
|
|
274
|
+
* in-flight build set against a `deadlineAt` expiry timer. Production uses
|
|
275
|
+
* `DEFAULT_TIMERS`; tests inject a fake whose pending handles are inspectable
|
|
276
|
+
* and fireable, so both race outcomes are deterministic — no real-time sleeps.
|
|
277
|
+
* Handles are opaque (`unknown`) so the interface stays runtime-agnostic. */
|
|
278
|
+
export interface TimerScheduler {
|
|
279
|
+
setTimeout(handler: () => void, ms: number): unknown
|
|
280
|
+
clearTimeout(handle: unknown): void
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Production scheduler: real global timers, preserving the existing
|
|
284
|
+
* `unref()` courtesy so a pending expiry never holds the process open. */
|
|
285
|
+
const DEFAULT_TIMERS: TimerScheduler = {
|
|
286
|
+
setTimeout(handler, ms) {
|
|
287
|
+
const timer = setTimeout(handler, ms)
|
|
288
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
289
|
+
return timer
|
|
290
|
+
},
|
|
291
|
+
clearTimeout(handle) {
|
|
292
|
+
clearTimeout(handle as ReturnType<typeof setTimeout>)
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export interface DispatchOpts {
|
|
297
|
+
/** Repo the dispatcher serves (§12: one dispatcher per repo) — the cwd, or
|
|
298
|
+
* in origin mode a private scratch root. Filesystem consumers only. */
|
|
299
|
+
targetRepo: string
|
|
300
|
+
/** Checkout-less origin mode (AUT-302): serve the repository at this origin
|
|
301
|
+
* with no local checkout. CLI `--repository <origin>`, env `AB_REPOSITORY`.
|
|
302
|
+
* Requires an HTTPS AB_STORE + AB_TOKEN, GitHub credentials, and the
|
|
303
|
+
* builtin github forge (from the fetched config). */
|
|
304
|
+
repository?: string
|
|
305
|
+
/** Resolved repository identity — the normalized origin, or the checkout
|
|
306
|
+
* path when the checkout has no origin. Derived from repo state by
|
|
307
|
+
* `abDispatch`; an explicitly supplied value WINS, letting an embedding
|
|
308
|
+
* that already resolved the identity (and keyed its store by it) pin the
|
|
309
|
+
* dispatcher to the same key. Every Store-keyed key and record write uses
|
|
310
|
+
* it. */
|
|
311
|
+
repo?: string
|
|
312
|
+
/** Test seam for origin mode: the GitHub transport the startup config
|
|
313
|
+
* fetch (and the default forge) use instead of real fetch. */
|
|
314
|
+
originConfigTransport?: GitHubRequest
|
|
315
|
+
/** Process environment: adapter secrets (LINEAR_API_KEY) and AB_TOKEN. */
|
|
316
|
+
env: Record<string, string | undefined>
|
|
317
|
+
exec: Exec
|
|
318
|
+
stdout: (line: string) => void
|
|
319
|
+
stderr: (line: string) => void
|
|
320
|
+
/** Single pass then drain in-flight runners and exit; default is a loop. */
|
|
321
|
+
once?: boolean
|
|
322
|
+
/** Watch-loop cadence in ms (§3.3); default DEFAULT_INTERVAL_MS. */
|
|
323
|
+
intervalMs?: number
|
|
324
|
+
/** Explicit durable repository intake override. Omission reuses stored state,
|
|
325
|
+
* falling back to true only when the repository has no setting fact. */
|
|
326
|
+
intake?: boolean
|
|
327
|
+
/** Explicit durable repository claim-time auto-merge override. Omission
|
|
328
|
+
* reuses stored state, falling back to false on a fresh repository. */
|
|
329
|
+
defaultAutoMerge?: boolean
|
|
330
|
+
/** Explicit `--store` override; otherwise AB_STORE, then repo-local state. */
|
|
331
|
+
storeRef?: string
|
|
332
|
+
/** Watch-loop stop signal — the binary aborts it on SIGINT (§15.6-C: an
|
|
333
|
+
* interrupted runner's lease expires and a future dispatch re-attaches). */
|
|
334
|
+
signal?: AbortSignal
|
|
335
|
+
/** Injectable for tests — defaults to the production adapters. */
|
|
336
|
+
wire?: (
|
|
337
|
+
config: Config,
|
|
338
|
+
opts: DispatchOpts,
|
|
339
|
+
state: RepoStatePaths,
|
|
340
|
+
plugins: PluginRegistry,
|
|
341
|
+
) => Promise<DispatchWiring> | DispatchWiring
|
|
342
|
+
/** Integration seam that substitutes only non-store ports. Store identity,
|
|
343
|
+
* reference, and token always come from production environment selection. */
|
|
344
|
+
nonStoreWire?: (
|
|
345
|
+
config: Config,
|
|
346
|
+
opts: DispatchOpts,
|
|
347
|
+
state: RepoStatePaths,
|
|
348
|
+
plugins: PluginRegistry,
|
|
349
|
+
) => Promise<DispatchNonStoreWiring> | DispatchNonStoreWiring
|
|
350
|
+
/** Injectable sleep (watch loop); default a real timer. Tests use `once`. */
|
|
351
|
+
sleep?: (ms: number) => Promise<void>
|
|
352
|
+
/** Force line-oriented output with no terminal control sequences (`--plain`),
|
|
353
|
+
* whatever the terminal says. */
|
|
354
|
+
plain?: boolean
|
|
355
|
+
/**
|
|
356
|
+
* The interactive output seam. ABSENT ⇒ non-interactive ⇒ plain — which is
|
|
357
|
+
* exactly today's behavior, so the dashboard can never be the reason a
|
|
358
|
+
* scripted or piped `ab dispatch` starts emitting escapes. The shared binary
|
|
359
|
+
* wiring constructs the real one over `process.stdout`.
|
|
360
|
+
*/
|
|
361
|
+
terminal?: TerminalOut
|
|
362
|
+
/** Injectable normalized keyboard/text source; the binary wraps stdin. */
|
|
363
|
+
input?: TerminalInput
|
|
364
|
+
/** Optional repo-dev presentation seam. The resolver is called for every
|
|
365
|
+
* paint; production omits it and remains bound to `renderDashboard`. */
|
|
366
|
+
resolveDashboardRenderer?: DashboardRendererResolver
|
|
367
|
+
/** Private child-kernel correlation. Presence suppresses terminal ownership
|
|
368
|
+
* and enables durable dispatcher status publication. */
|
|
369
|
+
kernelRunId?: string
|
|
370
|
+
/** Durable journaling identity of this dispatcher invocation. Every
|
|
371
|
+
* dispatcher-initiated journal write and the repository lease carry this id,
|
|
372
|
+
* so "who holds this repository?" and "who is writing this journal?" are the
|
|
373
|
+
* same string. Kernel children pass their kernelRunId; plain invocations
|
|
374
|
+
* leave it unset — abDispatch synthesizes `<host>-dispatch-<id>` once per
|
|
375
|
+
* invocation and the loop falls back to the same shape if unset. This is
|
|
376
|
+
* purely a journaling identity: behavioral kernelRunId gates (interactive
|
|
377
|
+
* frontend routing, terminal-ownership suppression) keep reading
|
|
378
|
+
* kernelRunId. */
|
|
379
|
+
runId?: string
|
|
380
|
+
/** Private child mode: preserve kernel behavior while routing no legacy
|
|
381
|
+
* line output into the terminal-owning parent. */
|
|
382
|
+
silent?: boolean
|
|
383
|
+
/** Invocation bound (epoch ms, measured against `wiring.clock`). `--once`
|
|
384
|
+
* only: an already-passed deadline skips the tick and the drain entirely
|
|
385
|
+
* (teardown still runs), and a deadline reached mid-drain stops awaiting
|
|
386
|
+
* `local-parent` executions, which teardown then stops and reaps — so one
|
|
387
|
+
* invocation always returns and unfinished work resumes next invocation.
|
|
388
|
+
* Watch mode is unaffected; absent ⇒ unbounded (today's exact behavior). */
|
|
389
|
+
deadlineAt?: number
|
|
390
|
+
/** Timer seam for the `deadlineAt` drain race. Absent ⇒ `DEFAULT_TIMERS`
|
|
391
|
+
* (real, unref'd timers); tests inject a manual scheduler to drive both
|
|
392
|
+
* race outcomes deterministically and to assert no expiry handle outlives
|
|
393
|
+
* the invocation. */
|
|
394
|
+
timers?: TimerScheduler
|
|
395
|
+
/** Interactive-watch-only release courtesy seams. The supervised kernel
|
|
396
|
+
* publishes results durably; direct plain/noninteractive and one-pass
|
|
397
|
+
* dispatch never consult them. */
|
|
398
|
+
availableReleaseProbe?: AvailableReleaseProbe
|
|
399
|
+
upgradeNoticeScheduler?: UpgradeNoticeScheduler
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** setTimeout that also resolves the moment ANY stop signal aborts, so OS
|
|
403
|
+
* SIGINT and raw-mode Ctrl-C share the same watch-loop boundary. */
|
|
404
|
+
function interruptibleSleep(
|
|
405
|
+
ms: number,
|
|
406
|
+
signals: readonly (AbortSignal | undefined)[],
|
|
407
|
+
): Promise<void> {
|
|
408
|
+
return new Promise<void>((resolveSleep) => {
|
|
409
|
+
const live = signals.filter((signal): signal is AbortSignal => signal !== undefined)
|
|
410
|
+
if (live.some((signal) => signal.aborted)) return resolveSleep()
|
|
411
|
+
let settled = false
|
|
412
|
+
const finish = (): void => {
|
|
413
|
+
if (settled) return
|
|
414
|
+
settled = true
|
|
415
|
+
clearTimeout(timer)
|
|
416
|
+
for (const signal of live) signal.removeEventListener('abort', finish)
|
|
417
|
+
resolveSleep()
|
|
418
|
+
}
|
|
419
|
+
const timer = setTimeout(finish, ms)
|
|
420
|
+
for (const signal of live) signal.addEventListener('abort', finish, { once: true })
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Production wiring: the local (or remote) store, configured adapters,
|
|
425
|
+
* git worktrees, and shipped runtimes. Forge construction deliberately happens
|
|
426
|
+
* before store opening so a plugin factory failure cannot precede a claim. */
|
|
427
|
+
async function defaultWire(
|
|
428
|
+
config: Config,
|
|
429
|
+
opts: DispatchOpts,
|
|
430
|
+
state: RepoStatePaths,
|
|
431
|
+
plugins: PluginRegistry,
|
|
432
|
+
): Promise<DispatchWiring> {
|
|
433
|
+
// Origin mode: no checkout exists, so the provider's host git seams are
|
|
434
|
+
// replaced by the injected origin and the forge's remote reads.
|
|
435
|
+
const originMode = opts.repository !== undefined
|
|
436
|
+
if (config.workspace.provider === 'vercel-sandbox') {
|
|
437
|
+
if (config.forge !== 'github') {
|
|
438
|
+
throw new Error('vercel-sandbox requires the builtin github forge')
|
|
439
|
+
}
|
|
440
|
+
if (!originMode) {
|
|
441
|
+
const origin = await opts.exec(['git', 'remote', 'get-url', 'origin'], {
|
|
442
|
+
cwd: opts.targetRepo,
|
|
443
|
+
})
|
|
444
|
+
if (origin.exitCode !== 0) throw new Error('vercel-sandbox requires a readable Git origin')
|
|
445
|
+
validateVercelGithubOrigin(origin.stdout.trim())
|
|
446
|
+
}
|
|
447
|
+
if (githubTokenFromEnv(opts.env) === undefined) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
'vercel-sandbox publication requires GITHUB_TOKEN or GH_TOKEN in the dispatcher environment',
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// The builtin GitHub forge resolves its credential lazily on its first
|
|
454
|
+
// request (GITHUB_TOKEN, GH_TOKEN, then the gh CLI login) and names the
|
|
455
|
+
// miss reason on any request that had to go out anonymously; nothing is
|
|
456
|
+
// probed here, so a dispatcher that never publishes spawns nothing.
|
|
457
|
+
const forge = await createForge({
|
|
458
|
+
name: config.forge,
|
|
459
|
+
registry: plugins,
|
|
460
|
+
env: opts.env,
|
|
461
|
+
repoRoot: state.checkout,
|
|
462
|
+
...(opts.repository !== undefined ? { repository: opts.repository } : {}),
|
|
463
|
+
})
|
|
464
|
+
// Checkout-less provider seams: the sandbox provider derives its origin and
|
|
465
|
+
// remote branch heads from these instead of host `git` — every host-exec
|
|
466
|
+
// call site in the provider is bypassed in origin mode.
|
|
467
|
+
const providerSeams = originMode
|
|
468
|
+
? {
|
|
469
|
+
origin: async (): Promise<string> => opts.repository!,
|
|
470
|
+
remoteBranchHead: async (branch: string): Promise<string | undefined> => {
|
|
471
|
+
const remoteBranchSha = forge.remoteBranchSha
|
|
472
|
+
if (remoteBranchSha === undefined) return undefined
|
|
473
|
+
try {
|
|
474
|
+
return await remoteBranchSha.call(forge, branch)
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (error instanceof GitHubApiError && error.status === 404) return undefined
|
|
477
|
+
throw error
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
}
|
|
481
|
+
: {}
|
|
482
|
+
const opened = openStoreForRepoState(state, { env: opts.env })
|
|
483
|
+
|
|
484
|
+
const tickets = await createTicketSource(
|
|
485
|
+
config.tickets,
|
|
486
|
+
opts.env,
|
|
487
|
+
state.repo,
|
|
488
|
+
opened.localStateRoot,
|
|
489
|
+
plugins,
|
|
490
|
+
)
|
|
491
|
+
const { runtimes } = createProductionRuntimes()
|
|
492
|
+
let runtimeReferences = effectiveRuntimeReferences(config)
|
|
493
|
+
// A local override relocates the whole tree. Remote stores still need local
|
|
494
|
+
// scratch beneath the repository default. Plugin factories receive only
|
|
495
|
+
// their explicit config plus repository/environment context.
|
|
496
|
+
const workspaceRuntime = await createWorkspaceRuntime(config.workspace, {
|
|
497
|
+
registry: plugins,
|
|
498
|
+
worktreeRoot: opened.worktreeRoot,
|
|
499
|
+
// Plugin factories always get the filesystem checkout (the physical path
|
|
500
|
+
// or the origin-mode scratch root) — never the store identity.
|
|
501
|
+
repoRoot: state.checkout,
|
|
502
|
+
env: opts.env,
|
|
503
|
+
storeRef: opened.storeRef,
|
|
504
|
+
runtimeReferences: () => runtimeReferences,
|
|
505
|
+
sandboxSetupCommand: config.commands.setup,
|
|
506
|
+
sandboxRoot: resolve(join(opened.localStateRoot, 'orchestrator-sandboxes')),
|
|
507
|
+
sandboxEnvironmentVariables: config.orchestrator.sandbox.environmentVariables,
|
|
508
|
+
...providerSeams,
|
|
509
|
+
...(opened.token !== undefined ? { storeToken: opened.token } : {}),
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
// Builds provisioned before this repository switched providers still hold
|
|
513
|
+
// local worktrees; the builtin can release them when they finish.
|
|
514
|
+
const retiredWorkspaces: WorkspaceRuntime[] =
|
|
515
|
+
config.workspace.provider === 'git-worktree'
|
|
516
|
+
? []
|
|
517
|
+
: [
|
|
518
|
+
{
|
|
519
|
+
provider: new GitWorktreeProvider({ root: resolve(opened.worktreeRoot) }),
|
|
520
|
+
execution: new LocalBuildExecution({ env: opts.env }),
|
|
521
|
+
},
|
|
522
|
+
]
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
store: opened.store,
|
|
526
|
+
tickets,
|
|
527
|
+
forge,
|
|
528
|
+
workspaces: workspaceRuntime.provider,
|
|
529
|
+
retiredWorkspaces,
|
|
530
|
+
buildExecution: workspaceRuntime.execution,
|
|
531
|
+
// Shipped registrations are shared with other non-phase judgment paths.
|
|
532
|
+
// Model ids stay in config; production.ts owns adapter compatibility data.
|
|
533
|
+
runtimes,
|
|
534
|
+
storeRef: opened.storeRef,
|
|
535
|
+
...(opened.token !== undefined ? { token: opened.token } : {}),
|
|
536
|
+
ids: randomIds(),
|
|
537
|
+
uuids: randomUuids(),
|
|
538
|
+
clock: systemClock,
|
|
539
|
+
plugins,
|
|
540
|
+
updateRuntimeReferences: (effectiveConfig) => {
|
|
541
|
+
runtimeReferences = effectiveRuntimeReferences(effectiveConfig)
|
|
542
|
+
},
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
interface ActiveBuildExecution {
|
|
547
|
+
reservation: symbol
|
|
548
|
+
instance: string
|
|
549
|
+
handle?: BuildExecutionHandle
|
|
550
|
+
settled?: Promise<void>
|
|
551
|
+
stopping: boolean
|
|
552
|
+
/** Set at teardown of an environment-supervised execution: the guest keeps
|
|
553
|
+
* running, so the completion chain must append no `execution.ended`, release
|
|
554
|
+
* no lease, settle no publication, and record no wait failure. */
|
|
555
|
+
detaching?: boolean
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** The dispatch loop owns deterministic decisions and supervises one
|
|
559
|
+
* workspace-adjacent process per build. Build outcomes flow only through the
|
|
560
|
+
* Store; child completion is liveness evidence used for reaping/single-flight. */
|
|
561
|
+
class DispatchLoop {
|
|
562
|
+
private readonly dispatcher: Dispatcher
|
|
563
|
+
private readonly host = hostname()
|
|
564
|
+
private readonly maxHarvestRecoveryAttempts = DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS
|
|
565
|
+
/** In-flight build and harvest runs (fire-and-forget) — awaited before a
|
|
566
|
+
* `--once` exit so every visible workflow reaches a durable boundary.
|
|
567
|
+
* Environment-supervised executions are tagged and deliberately NOT drained:
|
|
568
|
+
* their guests keep running and the next invocation settles them. */
|
|
569
|
+
private readonly inFlight = new Set<Promise<void>>()
|
|
570
|
+
private readonly inFlightKinds = new Map<Promise<void>, 'local-parent' | 'environment'>()
|
|
571
|
+
/** One active execution per slug in this kernel. Reservations are acquired
|
|
572
|
+
* before awaits; the durable lease remains the cross-kernel exclusion gate. */
|
|
573
|
+
private readonly activeBuildRuns = new Map<string, ActiveBuildExecution>()
|
|
574
|
+
private acceptingBuildLaunches = true
|
|
575
|
+
/** Process-local fast path; the repository lease is the cross-process gate. */
|
|
576
|
+
private harvestInFlight: Promise<void> | undefined
|
|
577
|
+
/** Live hosted-harvest execution: the environment-supervised handle whose
|
|
578
|
+
* completion chain classifies, releases, and journals the close fact.
|
|
579
|
+
* Teardown detaches it — the guest keeps running and the next invocation
|
|
580
|
+
* settles the execution from the Store plus provider liveness. The detach
|
|
581
|
+
* flag is a shared cell also closed over by the completion chain, so the
|
|
582
|
+
* chain still observes the detach after this field is cleared. */
|
|
583
|
+
private hostedHarvest:
|
|
584
|
+
| { execution: string; handle: BuildExecutionHandle; detached: { value: boolean } }
|
|
585
|
+
| undefined
|
|
586
|
+
/** Execution ids THIS process supervises; the dispatcher's harvest
|
|
587
|
+
* settlement stage skips them (their completion chain owns the facts). */
|
|
588
|
+
private readonly hostedHarvestExecutions = new Set<string>()
|
|
589
|
+
/** Outcomes settle outside Dispatcher.tick(), then merge into the next
|
|
590
|
+
* report publication (or a settlement-only publication during teardown). */
|
|
591
|
+
private pendingHarvest = {
|
|
592
|
+
harvestStarted: 0,
|
|
593
|
+
harvestResumed: 0,
|
|
594
|
+
harvestCompleted: 0,
|
|
595
|
+
harvestEscalated: 0,
|
|
596
|
+
harvestFailed: 0,
|
|
597
|
+
}
|
|
598
|
+
/** Settlement-only publications carry the latest standing status instead of
|
|
599
|
+
* manufacturing a zero queue measurement or losing diagnostics. */
|
|
600
|
+
private lastTickStatus = {
|
|
601
|
+
queued: 0,
|
|
602
|
+
janitorDiagnostics: [] as string[],
|
|
603
|
+
ticketDiagnostics: [] as string[],
|
|
604
|
+
creationDiagnostics: [] as string[],
|
|
605
|
+
dependencyDiagnostics: [] as string[],
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Interactive dashboard on. `opts.terminal?.interactive === true` — an
|
|
609
|
+
* absent terminal yields `undefined === true` ⇒ false ⇒ today's exact
|
|
610
|
+
* behavior, which is what keeps every existing dispatch test passing
|
|
611
|
+
* untouched and makes plain the default rather than a mode.
|
|
612
|
+
*/
|
|
613
|
+
private readonly dashboard: boolean
|
|
614
|
+
private readonly keyboard: KeyboardProtocol | undefined
|
|
615
|
+
private readonly region: LiveRegion | undefined
|
|
616
|
+
/** One display-only incremental build cache for this dispatch process. */
|
|
617
|
+
private readonly dashboardBuilds: DashboardBuildPollCache
|
|
618
|
+
/** Guard against overlapping timer polls, exactly as pollingSubscribe does. */
|
|
619
|
+
private rendering = false
|
|
620
|
+
/** The full, warning-handled timer poll. Teardown joins this exact promise
|
|
621
|
+
* before selecting the final frame. */
|
|
622
|
+
private renderInFlight: Promise<void> | undefined
|
|
623
|
+
/** Cleared at the rendering stop boundary so an already-queued timer callback
|
|
624
|
+
* cannot open a new store read after teardown begins. */
|
|
625
|
+
private acceptingRenderPolls = false
|
|
626
|
+
private timer: ReturnType<typeof setInterval> | undefined
|
|
627
|
+
/** Watch-mode paint timer (AC 8): repaints the CACHED model against a fresh
|
|
628
|
+
* clock so running elapsed ticks between store reads. Absent in `--once`. */
|
|
629
|
+
private tickTimer: ReturnType<typeof setInterval> | undefined
|
|
630
|
+
/** The last projected model, repainted by `paint()` against a moving clock.
|
|
631
|
+
* Read from the store by `renderOnce`; timing is now-independent so the same
|
|
632
|
+
* model ticks without a re-read. */
|
|
633
|
+
private model: DashboardModel | undefined
|
|
634
|
+
/** Ephemeral per-process presentation controls. Dispatcher settings are
|
|
635
|
+
* projected from the repository journal and never cached here. */
|
|
636
|
+
private selection: DashboardSelection | undefined = { kind: 'global' }
|
|
637
|
+
/** Read-only nested UI state. Omission is the top-level list. */
|
|
638
|
+
private view: DashboardView | undefined
|
|
639
|
+
/** The session view's live-read cycle, created with the view it serves and
|
|
640
|
+
* discarded on Escape. All read semantics live in the shared feed module. */
|
|
641
|
+
private sessionFeed: SessionStreamFeed | undefined
|
|
642
|
+
private warningLine: string | undefined
|
|
643
|
+
/** Process-local, persistent release notice. It never shares the replaceable
|
|
644
|
+
* warning slot and is re-applied to every store projection. */
|
|
645
|
+
private availableUpgrade: string | undefined
|
|
646
|
+
private stopUpgradeNotice: (() => void) | undefined
|
|
647
|
+
/** Startup, configuration-level notices — constant for the life of the
|
|
648
|
+
* process. Rendered ABOVE the transient warning line and never overwritten by
|
|
649
|
+
* it: `setWarning` replaces the transient slot outright, so sharing it would
|
|
650
|
+
* let the first tick's janitor notice erase a startup diagnostic for good. */
|
|
651
|
+
private configWarnings: readonly string[] = []
|
|
652
|
+
/** Last intake-enabled tick's standing queue depth, for the dashboard header. */
|
|
653
|
+
private queuedCount = 0
|
|
654
|
+
/** Last successfully measured unclaimed observation count. Sampling failures
|
|
655
|
+
* retain this factual value rather than inventing a zero. */
|
|
656
|
+
private observationCount = 0
|
|
657
|
+
/** A slug/id-bound blocked-resume field. The model receives only slug/value;
|
|
658
|
+
* captured escalation ids stay controller-private. */
|
|
659
|
+
private resumePrompt: ResumePrompt | undefined
|
|
660
|
+
private resumeSubmitting = false
|
|
661
|
+
/** First `a` captures identity only; Enter performs the shared control. */
|
|
662
|
+
private abortConfirmation: { slug: string } | undefined
|
|
663
|
+
/** One queue defines order between ticks and mutating keys. */
|
|
664
|
+
private operationTail: Promise<void> = Promise.resolve()
|
|
665
|
+
private acceptingKeys = false
|
|
666
|
+
private cleanupInput: (() => void) | undefined
|
|
667
|
+
/** Raw Ctrl-C does not raise SIGINT; this wakes the same watch loop. */
|
|
668
|
+
private readonly inputStop = new AbortController()
|
|
669
|
+
/** Repository supervisor lease (§12 serialization): this invocation's holder
|
|
670
|
+
* id while it holds the repo lease, else undefined. A losing invocation
|
|
671
|
+
* records `dispatcher.tick-yielded` and performs no claims, launches, or
|
|
672
|
+
* publications. */
|
|
673
|
+
private repoLeaseHolder: string | undefined
|
|
674
|
+
private repoLeaseHeartbeat: ReturnType<typeof setInterval> | undefined
|
|
675
|
+
/** Set when the store reports this supervisor's repo lease was taken over:
|
|
676
|
+
* stop accepting launches, detach/reap, signal continuations, and exit watch
|
|
677
|
+
* mode cleanly. */
|
|
678
|
+
private superseded = false
|
|
679
|
+
/** Public teardown fact for the durable `dispatcher.run-stopped` reason. */
|
|
680
|
+
get supersededByPeer(): boolean {
|
|
681
|
+
return this.superseded
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Invocation bound (hosted cron): true once `deadlineAt` has passed. An
|
|
685
|
+
* absent deadline is never past, so every existing caller is unchanged. */
|
|
686
|
+
private pastDeadline(): boolean {
|
|
687
|
+
return (
|
|
688
|
+
this.opts.deadlineAt !== undefined && this.wiring.clock().getTime() >= this.opts.deadlineAt
|
|
689
|
+
)
|
|
690
|
+
}
|
|
691
|
+
/** The holder id this invocation already recorded a `tick-yielded` for. */
|
|
692
|
+
private yieldedTo: string | undefined
|
|
693
|
+
/** The invocation's durable journaling identity: every repository-lease
|
|
694
|
+
* claim and every `dispatcher.*` journal payload carries exactly this
|
|
695
|
+
* string. Computed once in the constructor (see above). */
|
|
696
|
+
private readonly runId: string
|
|
697
|
+
/** Repository identity (§12): the normalized origin, or the checkout path
|
|
698
|
+
* when there is no origin. Every Store-keyed key uses this; filesystem
|
|
699
|
+
* consumers keep using `opts.targetRepo` (the checkout or scratch root). */
|
|
700
|
+
private get repoIdentity(): string {
|
|
701
|
+
return this.opts.repo ?? this.opts.targetRepo
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
constructor(
|
|
705
|
+
private readonly liveConfig: LiveConfig,
|
|
706
|
+
private readonly wiring: DispatchWiring,
|
|
707
|
+
private readonly opts: DispatchOpts,
|
|
708
|
+
) {
|
|
709
|
+
const config = liveConfig.current().config
|
|
710
|
+
// Minted exactly once per invocation: `wiring.ids` is `randomIds()` and a
|
|
711
|
+
// fresh id per access would split the journal run from the lease holder.
|
|
712
|
+
// abDispatch always passes `runId`, so the fallback only covers direct
|
|
713
|
+
// DispatchLoop constructions (tests).
|
|
714
|
+
this.runId = opts.runId ?? opts.kernelRunId ?? `${this.host}-dispatch-${wiring.ids('inst')}`
|
|
715
|
+
this.dashboard = opts.terminal?.interactive === true && opts.plain !== true
|
|
716
|
+
this.keyboard =
|
|
717
|
+
this.dashboard && opts.terminal !== undefined && opts.input !== undefined
|
|
718
|
+
? createKeyboardProtocol((chunk) => opts.terminal!.write(chunk), opts.terminal.modes)
|
|
719
|
+
: undefined
|
|
720
|
+
this.region =
|
|
721
|
+
this.dashboard && opts.terminal !== undefined
|
|
722
|
+
? new LiveRegion(opts.terminal, this.keyboard)
|
|
723
|
+
: undefined
|
|
724
|
+
this.dashboardBuilds = new DashboardBuildPollCache(wiring.store, this.repoIdentity, config)
|
|
725
|
+
|
|
726
|
+
// `slug` is an internal pre-build role on the same runtime/model resolver. A
|
|
727
|
+
// runtime without the optional capability is normal: omit the seam and let
|
|
728
|
+
// the dispatcher take its deterministic title fallback.
|
|
729
|
+
const nameSlug = async (spec: string, signal: AbortSignal): Promise<string | null> => {
|
|
730
|
+
const resolvedSlug = this.liveConfig.current().resolver.resolve(SLUG_ROLE)
|
|
731
|
+
const oneShot = wiring.runtimes[resolvedSlug.runtime]?.oneShot
|
|
732
|
+
if (oneShot === undefined) return null
|
|
733
|
+
const result = await oneShot.complete({
|
|
734
|
+
prompt: slugNamingPrompt(spec),
|
|
735
|
+
cwd: opts.targetRepo,
|
|
736
|
+
env: definedEnv(opts.env),
|
|
737
|
+
signal,
|
|
738
|
+
...(resolvedSlug.model !== undefined ? { model: resolvedSlug.model } : {}),
|
|
739
|
+
args: resolvedSlug.args,
|
|
740
|
+
})
|
|
741
|
+
return result.text
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
this.dispatcher = new Dispatcher({
|
|
745
|
+
store: wiring.store,
|
|
746
|
+
tickets: wiring.tickets,
|
|
747
|
+
workspaces: wiring.workspaces,
|
|
748
|
+
...(wiring.retiredWorkspaces === undefined
|
|
749
|
+
? {}
|
|
750
|
+
: { retiredWorkspaces: wiring.retiredWorkspaces.map((runtime) => runtime.provider) }),
|
|
751
|
+
forge: wiring.forge,
|
|
752
|
+
config,
|
|
753
|
+
getConfig: () => this.liveConfig.current().config,
|
|
754
|
+
repo: this.repoIdentity,
|
|
755
|
+
// Origin mode: the forge answers remote base-branch questions; no
|
|
756
|
+
// checkout git is probed for the served repository's origin.
|
|
757
|
+
...(opts.repository !== undefined
|
|
758
|
+
? { repoOrigin: normalizeGitRemoteUrl(opts.repository) }
|
|
759
|
+
: {}),
|
|
760
|
+
checkout: opts.targetRepo,
|
|
761
|
+
exec: opts.exec,
|
|
762
|
+
launchRunner: (slug) => this.launchRunner(slug),
|
|
763
|
+
startHarvest: () => this.launchHarvest(),
|
|
764
|
+
nameSlug,
|
|
765
|
+
ids: wiring.ids,
|
|
766
|
+
clock: wiring.clock,
|
|
767
|
+
activeHarvestExecutions: () => this.hostedHarvestExecutions,
|
|
768
|
+
// Durable supervision: settle publication from the log-backed guard and
|
|
769
|
+
// skip foreign-execution settlement for builds this process supervises.
|
|
770
|
+
settlePublication: (slug) => this.settlePendingPublication(slug),
|
|
771
|
+
activeExecutions: () => new Set(this.activeBuildRuns.keys()),
|
|
772
|
+
opts: {
|
|
773
|
+
maxHarvestRecoveryAttempts: this.maxHarvestRecoveryAttempts,
|
|
774
|
+
},
|
|
775
|
+
})
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
private currentConfig(): ConfigSnapshot {
|
|
779
|
+
return this.liveConfig.current()
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Publish the exact composed snapshot into one build-owned namespace. The
|
|
783
|
+
* child samples only this artifact and never receives config over IPC. */
|
|
784
|
+
private async publishBuildConfig(slug: string, snapshot = this.currentConfig()): Promise<void> {
|
|
785
|
+
await this.wiring.store.putArtifact(slug, {
|
|
786
|
+
kind: BUILD_EFFECTIVE_CONFIG_ARTIFACT,
|
|
787
|
+
content: effectiveBuildConfigContent(snapshot.config),
|
|
788
|
+
metadata: {
|
|
789
|
+
revision: snapshot.revision,
|
|
790
|
+
run: this.runId,
|
|
791
|
+
},
|
|
792
|
+
})
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private async publishActiveBuildConfigs(snapshot: ConfigSnapshot): Promise<void> {
|
|
796
|
+
await Promise.all(
|
|
797
|
+
[...this.activeBuildRuns.keys()].map((slug) => this.publishBuildConfig(slug, snapshot)),
|
|
798
|
+
)
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private async appendStatus(event: RepositoryEventWrite): Promise<void> {
|
|
802
|
+
await this.wiring.store.appendRepo(this.repoIdentity, event)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private async refreshConfig(): Promise<void> {
|
|
806
|
+
if (this.opts.once === true) return
|
|
807
|
+
// Origin mode reloads from the forge: autobuild.toml at the CURRENT
|
|
808
|
+
// effective base branch (hot — a rename converges within two ticks).
|
|
809
|
+
// Restart-required changes keep startup-built adapters and file the
|
|
810
|
+
// existing restart-required observation (LiveConfig semantics).
|
|
811
|
+
const outcome =
|
|
812
|
+
this.opts.repository !== undefined && typeof this.wiring.forge.readFile === 'function'
|
|
813
|
+
? await this.liveConfig.refreshFrom(async () => {
|
|
814
|
+
const baseBranch = this.liveConfig.current().config.baseBranch
|
|
815
|
+
return await this.wiring.forge.readFile!(
|
|
816
|
+
'autobuild.toml',
|
|
817
|
+
baseBranch !== undefined && baseBranch !== '' ? baseBranch : undefined,
|
|
818
|
+
)
|
|
819
|
+
})
|
|
820
|
+
: await this.liveConfig.refreshFromDisk()
|
|
821
|
+
if (outcome.kind === 'unchanged') return
|
|
822
|
+
if (outcome.kind === 'rejected') {
|
|
823
|
+
if (outcome.notify) {
|
|
824
|
+
await this.appendStatus({
|
|
825
|
+
actor: DISPATCHER,
|
|
826
|
+
type: 'dispatcher.config-rejected',
|
|
827
|
+
payload: { run: this.runId, error: outcome.error },
|
|
828
|
+
})
|
|
829
|
+
this.warn(`config reload rejected: ${outcome.error}`)
|
|
830
|
+
}
|
|
831
|
+
return
|
|
832
|
+
}
|
|
833
|
+
if (outcome.kind === 'publication-failed') {
|
|
834
|
+
await this.appendStatus({
|
|
835
|
+
actor: DISPATCHER,
|
|
836
|
+
type: 'dispatcher.config-publication-failed',
|
|
837
|
+
payload: { run: this.runId, error: outcome.error },
|
|
838
|
+
})
|
|
839
|
+
this.warn(`config reload not applied because its durable trace failed: ${outcome.error}`)
|
|
840
|
+
return
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
await this.publishActiveBuildConfigs(outcome.snapshot)
|
|
844
|
+
this.announce(`autobuild.toml reloaded (revision ${outcome.snapshot.revision})`)
|
|
845
|
+
if (outcome.restartRequired.length > 0) {
|
|
846
|
+
this.warn(
|
|
847
|
+
`autobuild.toml reload requires dispatch restart for: ${outcome.restartRequired.join(', ')}`,
|
|
848
|
+
)
|
|
849
|
+
}
|
|
850
|
+
this.reportRoleDiagnostics()
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Append one operation after every previously observed tick/key action. */
|
|
854
|
+
private serialize<T>(operation: () => Promise<T> | T): Promise<T> {
|
|
855
|
+
const result = this.operationTail.then(operation)
|
|
856
|
+
this.operationTail = result.then(
|
|
857
|
+
() => undefined,
|
|
858
|
+
() => undefined,
|
|
859
|
+
)
|
|
860
|
+
return result
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
private async readDispatchSettings(): Promise<ReturnType<typeof reduceDispatchSettings>> {
|
|
864
|
+
const events = await this.wiring.store.getRepoEvents(this.repoIdentity)
|
|
865
|
+
return reduceDispatchSettings(events)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
private dispatcherTick(resumeCurrent: boolean): Promise<Awaited<ReturnType<Dispatcher['tick']>>> {
|
|
869
|
+
return this.serialize(async () => {
|
|
870
|
+
// Refresh before every watch decision. The owner publishes atomically;
|
|
871
|
+
// everything below captures the resulting one snapshot for this tick.
|
|
872
|
+
await this.refreshConfig()
|
|
873
|
+
// Every tick first tries the repository supervisor lease. Without it,
|
|
874
|
+
// this invocation records the yield and performs no claims, launches,
|
|
875
|
+
// or publications — including no tick-completed status publication.
|
|
876
|
+
if (!(await this.ensureRepoLease(this.repoLeaseHolder ?? this.runId))) {
|
|
877
|
+
await this.recordTickYielded()
|
|
878
|
+
return emptyTickReport()
|
|
879
|
+
}
|
|
880
|
+
await this.appendStatus({
|
|
881
|
+
actor: DISPATCHER,
|
|
882
|
+
type: 'dispatcher.tick-started',
|
|
883
|
+
payload: { run: this.runId },
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
// Unclaimed observations are display-only and sampled once per interactive
|
|
887
|
+
// dispatcher tick. A failed scan must neither fail dispatch nor replace
|
|
888
|
+
// the last complete measurement with a fabricated zero.
|
|
889
|
+
if (this.dashboard) {
|
|
890
|
+
try {
|
|
891
|
+
const scan = await scanUnclaimedObservations(this.wiring.store, this.repoIdentity)
|
|
892
|
+
this.observationCount = scan.observations.length
|
|
893
|
+
} catch {
|
|
894
|
+
// Display-only sampling failures retain the last factual count and
|
|
895
|
+
// retry on the next tick; they are not dashboard failures.
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Sample inside the serialized tick, not at process startup. Every
|
|
900
|
+
// dispatcher therefore gates claims from the latest repository facts.
|
|
901
|
+
const settings = await this.readDispatchSettings()
|
|
902
|
+
const readyObservation =
|
|
903
|
+
settings.intake || (!this.dashboard && this.opts.kernelRunId === undefined)
|
|
904
|
+
? undefined
|
|
905
|
+
: await this.dispatcher.observeReady()
|
|
906
|
+
const report = await this.dispatcher.tick({
|
|
907
|
+
resumeCurrent,
|
|
908
|
+
acceptNewWork: settings.intake,
|
|
909
|
+
defaultAutoMerge: settings.defaultAutoMerge,
|
|
910
|
+
autoMergeUser: buildControlUser(this.opts.env),
|
|
911
|
+
// Invocation bound forwarded into the tick: per-item loops inside the
|
|
912
|
+
// dispatcher stop starting new forge/store transport calls once the
|
|
913
|
+
// remaining budget cannot cover them (bounded overrun, one in-flight
|
|
914
|
+
// transport call). Watch mode has no deadline ⇒ absent ⇒ unbounded.
|
|
915
|
+
...(this.opts.deadlineAt !== undefined ? { deadlineAt: this.opts.deadlineAt } : {}),
|
|
916
|
+
})
|
|
917
|
+
const publishedReport: TickReport = {
|
|
918
|
+
...report,
|
|
919
|
+
queued: readyObservation?.queued ?? report.queued,
|
|
920
|
+
invalidTickets: readyObservation?.invalidTickets ?? report.invalidTickets,
|
|
921
|
+
ticketDiagnostics: readyObservation?.ticketDiagnostics ?? report.ticketDiagnostics,
|
|
922
|
+
}
|
|
923
|
+
this.queuedCount = publishedReport.queued
|
|
924
|
+
this.lastTickStatus = {
|
|
925
|
+
queued: publishedReport.queued,
|
|
926
|
+
janitorDiagnostics: [...publishedReport.janitorDiagnostics],
|
|
927
|
+
ticketDiagnostics: [...publishedReport.ticketDiagnostics],
|
|
928
|
+
creationDiagnostics: [...publishedReport.creationDiagnostics],
|
|
929
|
+
dependencyDiagnostics: [...publishedReport.dependencyDiagnostics],
|
|
930
|
+
}
|
|
931
|
+
return (await this.publishTickReport(publishedReport))!
|
|
932
|
+
})
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** Overlay process-local presentation controls and normalize the exact model
|
|
936
|
+
* the renderer will receive. Detail bounds depend on modal control height, so
|
|
937
|
+
* composition must precede clamping rather than measuring the durable base. */
|
|
938
|
+
private syncModelControls(): void {
|
|
939
|
+
if (this.model === undefined) return
|
|
940
|
+
const {
|
|
941
|
+
selection: _oldSelection,
|
|
942
|
+
warningLines: _oldWarningLines,
|
|
943
|
+
availableUpgrade: _oldAvailableUpgrade,
|
|
944
|
+
resumeInput: _oldResumeInput,
|
|
945
|
+
abortConfirmation: _oldAbortConfirmation,
|
|
946
|
+
view: _oldView,
|
|
947
|
+
...base
|
|
948
|
+
} = this.model
|
|
949
|
+
// Composed at PROJECTION time, on every projection, so the sticky config
|
|
950
|
+
// diagnostic reaches the first painted frame and every frame after it with
|
|
951
|
+
// no dependence on emission order relative to `startRendering()`.
|
|
952
|
+
const warningLines = [
|
|
953
|
+
...this.configWarnings,
|
|
954
|
+
...(this.warningLine !== undefined ? [this.warningLine] : []),
|
|
955
|
+
]
|
|
956
|
+
let effective: DashboardModel = {
|
|
957
|
+
...base,
|
|
958
|
+
...(this.availableUpgrade !== undefined ? { availableUpgrade: this.availableUpgrade } : {}),
|
|
959
|
+
...(warningLines.length > 0 ? { warningLines } : {}),
|
|
960
|
+
...(this.selection !== undefined ? { selection: this.selection } : {}),
|
|
961
|
+
...(this.resumePrompt !== undefined
|
|
962
|
+
? {
|
|
963
|
+
resumeInput: {
|
|
964
|
+
slug: this.resumePrompt.slug,
|
|
965
|
+
value: this.resumePrompt.value,
|
|
966
|
+
cursor: this.resumePrompt.cursor,
|
|
967
|
+
},
|
|
968
|
+
}
|
|
969
|
+
: {}),
|
|
970
|
+
...(this.abortConfirmation !== undefined
|
|
971
|
+
? { abortConfirmation: this.abortConfirmation }
|
|
972
|
+
: {}),
|
|
973
|
+
...(this.view !== undefined ? { view: this.view } : {}),
|
|
974
|
+
}
|
|
975
|
+
if (this.view?.kind === 'detail') {
|
|
976
|
+
const terminal = this.opts.terminal
|
|
977
|
+
const normalized = {
|
|
978
|
+
...this.view,
|
|
979
|
+
scroll:
|
|
980
|
+
terminal === undefined
|
|
981
|
+
? 0
|
|
982
|
+
: Math.max(
|
|
983
|
+
0,
|
|
984
|
+
Math.min(
|
|
985
|
+
this.view.scroll,
|
|
986
|
+
detailScrollLimit(
|
|
987
|
+
effective,
|
|
988
|
+
dashboardContentWidth(terminal.columns),
|
|
989
|
+
paintableRows(terminal.rows),
|
|
990
|
+
),
|
|
991
|
+
),
|
|
992
|
+
),
|
|
993
|
+
}
|
|
994
|
+
this.view = normalized
|
|
995
|
+
effective = { ...effective, view: normalized }
|
|
996
|
+
}
|
|
997
|
+
this.model = effective
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Session-view Up/Down: follow-and-pause. Scrolling up off the bottom
|
|
1001
|
+
* pauses tail-following; landing back on the last content row resumes it.
|
|
1002
|
+
* While following, the render pins the offset to the limit, so the effective
|
|
1003
|
+
* current position before a move is that limit. */
|
|
1004
|
+
private moveSessionView(delta: number): void {
|
|
1005
|
+
const view = this.view
|
|
1006
|
+
if (view?.kind !== 'session') return
|
|
1007
|
+
const terminal = this.opts.terminal
|
|
1008
|
+
const upgrade = this.availableUpgrade !== undefined
|
|
1009
|
+
const limit =
|
|
1010
|
+
terminal === undefined
|
|
1011
|
+
? 0
|
|
1012
|
+
: sessionScrollLimit(view, terminal.columns, paintableRows(terminal.rows), upgrade)
|
|
1013
|
+
const current = view.follow ? limit : view.scroll
|
|
1014
|
+
const scroll =
|
|
1015
|
+
terminal === undefined
|
|
1016
|
+
? 0
|
|
1017
|
+
: moveSessionScroll(
|
|
1018
|
+
view,
|
|
1019
|
+
terminal.columns,
|
|
1020
|
+
paintableRows(terminal.rows),
|
|
1021
|
+
current,
|
|
1022
|
+
delta,
|
|
1023
|
+
upgrade,
|
|
1024
|
+
)
|
|
1025
|
+
let follow = view.follow
|
|
1026
|
+
if (delta < 0 && follow && scroll < limit) follow = false
|
|
1027
|
+
if (delta > 0 && !follow && scroll >= limit) follow = true
|
|
1028
|
+
this.view = { ...view, scroll, follow }
|
|
1029
|
+
this.syncModelControls()
|
|
1030
|
+
this.paint()
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/** Clamp a session view's offset against the current viewport and pin it to
|
|
1034
|
+
* the tail while following. Called at feed-apply time, where new content may
|
|
1035
|
+
* have grown (or shrunk) the scrollable range. */
|
|
1036
|
+
private clampSessionView(view: DashboardView): DashboardView {
|
|
1037
|
+
if (view.kind !== 'session') return view
|
|
1038
|
+
const terminal = this.opts.terminal
|
|
1039
|
+
if (terminal === undefined) return { ...view, scroll: 0 }
|
|
1040
|
+
const limit = sessionScrollLimit(
|
|
1041
|
+
view,
|
|
1042
|
+
terminal.columns,
|
|
1043
|
+
paintableRows(terminal.rows),
|
|
1044
|
+
this.availableUpgrade !== undefined,
|
|
1045
|
+
)
|
|
1046
|
+
return { ...view, scroll: view.follow ? limit : Math.min(view.scroll, limit) }
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** Fire-and-forget live-read cycle for an open session view (or one whose
|
|
1050
|
+
* last read failed). The apply goes through the serialize queue under the
|
|
1051
|
+
* feed's identity+cursor fence, mirroring the transcript read's pattern. */
|
|
1052
|
+
private pollSessionFeed(): void {
|
|
1053
|
+
const view = this.view
|
|
1054
|
+
if (view?.kind !== 'session') return
|
|
1055
|
+
if (view.status !== 'open' && view.error === undefined) {
|
|
1056
|
+
// A closed view is final once its content is in hand; an unloaded one
|
|
1057
|
+
// (a closed session just opened, empty chunk log so far) still needs
|
|
1058
|
+
// its read — which is where the artifact fallback happens too.
|
|
1059
|
+
if (view.source.kind !== 'parts' || view.source.lastSeq !== 0) return
|
|
1060
|
+
}
|
|
1061
|
+
const feed = this.sessionFeed
|
|
1062
|
+
if (feed === undefined) return
|
|
1063
|
+
void feed
|
|
1064
|
+
.poll(view)
|
|
1065
|
+
.then((update) => {
|
|
1066
|
+
if (update === undefined) return
|
|
1067
|
+
return this.serialize(() => {
|
|
1068
|
+
// The fence rejects anything but the exact session view (and
|
|
1069
|
+
// cursor) this update was polled from.
|
|
1070
|
+
const next = applySessionFeedUpdate(
|
|
1071
|
+
this.view?.kind === 'session' ? this.view : undefined,
|
|
1072
|
+
update,
|
|
1073
|
+
)
|
|
1074
|
+
if (next === undefined) return
|
|
1075
|
+
this.view = this.clampSessionView(next)
|
|
1076
|
+
this.syncModelControls()
|
|
1077
|
+
this.paint()
|
|
1078
|
+
})
|
|
1079
|
+
})
|
|
1080
|
+
.catch(() => {
|
|
1081
|
+
// The feed contains its own failures; the next poll retries.
|
|
1082
|
+
})
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
private moveSelection(delta: number): void {
|
|
1086
|
+
if (this.view?.kind === 'session') {
|
|
1087
|
+
this.moveSessionView(delta)
|
|
1088
|
+
return
|
|
1089
|
+
}
|
|
1090
|
+
if (this.view?.kind === 'transcript') {
|
|
1091
|
+
const terminal = this.opts.terminal
|
|
1092
|
+
this.view = {
|
|
1093
|
+
...this.view,
|
|
1094
|
+
scroll:
|
|
1095
|
+
terminal === undefined
|
|
1096
|
+
? 0
|
|
1097
|
+
: moveTranscriptScroll(
|
|
1098
|
+
this.view.transcript,
|
|
1099
|
+
terminal.columns,
|
|
1100
|
+
paintableRows(terminal.rows),
|
|
1101
|
+
this.view.scroll,
|
|
1102
|
+
delta,
|
|
1103
|
+
this.availableUpgrade !== undefined,
|
|
1104
|
+
),
|
|
1105
|
+
}
|
|
1106
|
+
this.syncModelControls()
|
|
1107
|
+
this.paint()
|
|
1108
|
+
return
|
|
1109
|
+
}
|
|
1110
|
+
if (this.view?.kind === 'detail') {
|
|
1111
|
+
const terminal = this.opts.terminal
|
|
1112
|
+
this.view = {
|
|
1113
|
+
...this.view,
|
|
1114
|
+
scroll:
|
|
1115
|
+
terminal === undefined || this.model === undefined
|
|
1116
|
+
? 0
|
|
1117
|
+
: moveDetailScroll(
|
|
1118
|
+
this.model,
|
|
1119
|
+
dashboardContentWidth(terminal.columns),
|
|
1120
|
+
paintableRows(terminal.rows),
|
|
1121
|
+
this.view.scroll,
|
|
1122
|
+
delta,
|
|
1123
|
+
),
|
|
1124
|
+
}
|
|
1125
|
+
this.syncModelControls()
|
|
1126
|
+
this.paint()
|
|
1127
|
+
return
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Input starts before the first asynchronous store projection. The global
|
|
1131
|
+
// row exists independently of that projection, so startup navigation must
|
|
1132
|
+
// clamp on it rather than letting the generic empty-list helper clear it.
|
|
1133
|
+
const rows =
|
|
1134
|
+
this.model === undefined ? [{ kind: 'global' } as const] : dashboardSelections(this.model)
|
|
1135
|
+
this.selection = moveSelection(rows, this.selection, delta)
|
|
1136
|
+
this.syncModelControls()
|
|
1137
|
+
this.paint()
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
private selectedBuildSlug(
|
|
1141
|
+
action: 'auto-merge' | 'pause' | 'resume' | 'discard',
|
|
1142
|
+
): string | undefined {
|
|
1143
|
+
if (this.view !== undefined) return this.view.slug
|
|
1144
|
+
const selection = this.selection
|
|
1145
|
+
if (selection === undefined) {
|
|
1146
|
+
this.warn('dashboard action ignored: no active row is selected')
|
|
1147
|
+
return undefined
|
|
1148
|
+
}
|
|
1149
|
+
if (selection.kind !== 'build') {
|
|
1150
|
+
const subject = selection.kind === 'harvest' ? 'Harvest' : 'Dispatcher'
|
|
1151
|
+
this.say(
|
|
1152
|
+
action === 'auto-merge'
|
|
1153
|
+
? `${subject} auto-merge unavailable: select a build`
|
|
1154
|
+
: action === 'discard'
|
|
1155
|
+
? `${subject} discard unavailable: select a queued build`
|
|
1156
|
+
: `${subject} ${action} unavailable: select a build`,
|
|
1157
|
+
)
|
|
1158
|
+
return undefined
|
|
1159
|
+
}
|
|
1160
|
+
return selection.slug
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
private async ignoreControlError(surface: 'action' | 'resume', error: unknown): Promise<boolean> {
|
|
1164
|
+
if (!(error instanceof BuildControlError)) return false
|
|
1165
|
+
this.warn(`dashboard ${surface} ignored: ${error.message}`)
|
|
1166
|
+
await this.renderOnce()
|
|
1167
|
+
return true
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
private async toggleIntake(): Promise<void> {
|
|
1171
|
+
if (this.selection?.kind !== 'global') return
|
|
1172
|
+
|
|
1173
|
+
const event = await toggleRepositorySetting({
|
|
1174
|
+
store: this.wiring.store,
|
|
1175
|
+
repo: this.repoIdentity,
|
|
1176
|
+
user: buildControlUser(this.opts.env),
|
|
1177
|
+
setting: 'intake',
|
|
1178
|
+
})
|
|
1179
|
+
this.say(`dispatcher intake ${event.enabled ? 'ON' : 'OFF'}`)
|
|
1180
|
+
await this.renderOnce()
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** Repository-wide quiescence from the always-present global row: park every
|
|
1184
|
+
* pausable build and stop intake, or reverse both. Unlike the per-build keys
|
|
1185
|
+
* this never toggles — it is an absolute request in one direction, so a build
|
|
1186
|
+
* already pausing keeps its single pending pause. A store failure propagates
|
|
1187
|
+
* to `queueAction`'s catch, which reports it on the same notice row. */
|
|
1188
|
+
private async bulkControl(direction: BulkDirection): Promise<void> {
|
|
1189
|
+
// The key routing already guards; keeping it with the action means the
|
|
1190
|
+
// invariant travels with the write rather than only with the keypress.
|
|
1191
|
+
if (this.view !== undefined || this.selection?.kind !== 'global') return
|
|
1192
|
+
|
|
1193
|
+
const summary = await bulkControlRepository({
|
|
1194
|
+
store: this.wiring.store,
|
|
1195
|
+
repo: this.repoIdentity,
|
|
1196
|
+
env: this.opts.env,
|
|
1197
|
+
direction,
|
|
1198
|
+
})
|
|
1199
|
+
this.announce(bulkControlReport(summary))
|
|
1200
|
+
await this.renderOnce()
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
private selectedDashboardBuild(): DashboardBuild | undefined {
|
|
1204
|
+
const slug =
|
|
1205
|
+
this.view?.kind === 'detail'
|
|
1206
|
+
? this.view.slug
|
|
1207
|
+
: this.view === undefined && this.selection?.kind === 'build'
|
|
1208
|
+
? this.selection.slug
|
|
1209
|
+
: undefined
|
|
1210
|
+
return this.model?.builds.find((candidate) => candidate.slug === slug)
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
private async dashboardPause(): Promise<void> {
|
|
1214
|
+
const slug = this.selectedBuildSlug('pause')
|
|
1215
|
+
if (slug === undefined) return
|
|
1216
|
+
|
|
1217
|
+
let result: BuildControlResult
|
|
1218
|
+
try {
|
|
1219
|
+
result = await controlBuild({
|
|
1220
|
+
store: this.wiring.store,
|
|
1221
|
+
repo: this.repoIdentity,
|
|
1222
|
+
slug,
|
|
1223
|
+
env: this.opts.env,
|
|
1224
|
+
action: { kind: 'dashboard-pause' },
|
|
1225
|
+
})
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
if (await this.ignoreControlError('action', error)) return
|
|
1228
|
+
throw error
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
if (result.kind !== 'command' || (result.command !== 'pause' && result.command !== 'resume')) {
|
|
1232
|
+
throw new Error('build-control returned an invalid dashboard pause result')
|
|
1233
|
+
}
|
|
1234
|
+
this.say(
|
|
1235
|
+
result.command === 'resume'
|
|
1236
|
+
? `build ${slug}: pending pause cancelled`
|
|
1237
|
+
: `build ${slug}: pause requested`,
|
|
1238
|
+
)
|
|
1239
|
+
await this.renderOnce()
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
private async dashboardResume(): Promise<void> {
|
|
1243
|
+
const slug = this.selectedBuildSlug('resume')
|
|
1244
|
+
if (slug === undefined) return
|
|
1245
|
+
|
|
1246
|
+
let result: BuildControlResult
|
|
1247
|
+
try {
|
|
1248
|
+
result = await controlBuild({
|
|
1249
|
+
store: this.wiring.store,
|
|
1250
|
+
repo: this.repoIdentity,
|
|
1251
|
+
slug,
|
|
1252
|
+
env: this.opts.env,
|
|
1253
|
+
action: { kind: 'dashboard-resume' },
|
|
1254
|
+
})
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
if (await this.ignoreControlError('action', error)) return
|
|
1257
|
+
throw error
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (result.kind === 'answer-required') {
|
|
1261
|
+
this.resumePrompt = {
|
|
1262
|
+
slug,
|
|
1263
|
+
escalationIds: result.escalationIds,
|
|
1264
|
+
value: '',
|
|
1265
|
+
cursor: 0,
|
|
1266
|
+
}
|
|
1267
|
+
this.syncModelControls()
|
|
1268
|
+
this.paint()
|
|
1269
|
+
return
|
|
1270
|
+
}
|
|
1271
|
+
if (result.kind !== 'command' || result.command !== 'resume') {
|
|
1272
|
+
throw new Error('build-control returned an invalid dashboard resume result')
|
|
1273
|
+
}
|
|
1274
|
+
this.say(`build ${slug}: resume requested`)
|
|
1275
|
+
await this.renderOnce()
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
private async abortConfirmed(): Promise<void> {
|
|
1279
|
+
const confirmation = this.abortConfirmation
|
|
1280
|
+
if (confirmation === undefined) return
|
|
1281
|
+
this.abortConfirmation = undefined
|
|
1282
|
+
this.syncModelControls()
|
|
1283
|
+
try {
|
|
1284
|
+
const result = await controlBuild({
|
|
1285
|
+
store: this.wiring.store,
|
|
1286
|
+
repo: this.repoIdentity,
|
|
1287
|
+
slug: confirmation.slug,
|
|
1288
|
+
env: this.opts.env,
|
|
1289
|
+
action: { kind: 'abort' },
|
|
1290
|
+
})
|
|
1291
|
+
if (result.kind !== 'command' || result.command !== 'abort') {
|
|
1292
|
+
throw new Error('build-control returned an invalid abort result')
|
|
1293
|
+
}
|
|
1294
|
+
this.say(`build ${confirmation.slug}: abort requested`)
|
|
1295
|
+
await this.renderOnce()
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
if (await this.ignoreControlError('action', error)) return
|
|
1298
|
+
throw error
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
private async discardSelected(): Promise<void> {
|
|
1303
|
+
const slug = this.selectedBuildSlug('discard')
|
|
1304
|
+
if (slug === undefined) return
|
|
1305
|
+
try {
|
|
1306
|
+
const result = await controlBuild({
|
|
1307
|
+
store: this.wiring.store,
|
|
1308
|
+
repo: this.repoIdentity,
|
|
1309
|
+
slug,
|
|
1310
|
+
env: this.opts.env,
|
|
1311
|
+
action: { kind: 'discard' },
|
|
1312
|
+
})
|
|
1313
|
+
if (result.kind !== 'command' || result.command !== 'discard') {
|
|
1314
|
+
throw new Error('build-control returned an invalid discard result')
|
|
1315
|
+
}
|
|
1316
|
+
this.say(`build ${slug}: discard requested`)
|
|
1317
|
+
await this.renderOnce()
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
if (await this.ignoreControlError('action', error)) return
|
|
1320
|
+
throw error
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/** Toggle the durable repository gate from the always-present header. The
|
|
1325
|
+
* latest pending command is the effective requested target, while rendering
|
|
1326
|
+
* remains acknowledged-only. */
|
|
1327
|
+
private async toggleHarvestGate(): Promise<void> {
|
|
1328
|
+
if (this.selection?.kind !== 'global') {
|
|
1329
|
+
const subject = this.selection?.kind === 'harvest' ? 'Harvest' : 'Build'
|
|
1330
|
+
this.say(`${subject} harvest gate unavailable: select Dispatcher`)
|
|
1331
|
+
return
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
const result = await applyHarvestGateToggle({
|
|
1335
|
+
store: this.wiring.store,
|
|
1336
|
+
repo: this.repoIdentity,
|
|
1337
|
+
user: buildControlUser(this.opts.env),
|
|
1338
|
+
})
|
|
1339
|
+
this.say(`harvest gate: ${result.command} requested`)
|
|
1340
|
+
await this.renderOnce()
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** `p` on Harvest acts only on the concrete run captured at keypress time.
|
|
1344
|
+
* It never toggles the repository gate and never retargets a replacement run
|
|
1345
|
+
* that appeared while the action waited in the serialized queue. */
|
|
1346
|
+
private async controlHarvestRun(expectedRun: string | undefined): Promise<void> {
|
|
1347
|
+
const { store } = this.wiring
|
|
1348
|
+
const repo = this.repoIdentity
|
|
1349
|
+
await store.ensureRepo(repo)
|
|
1350
|
+
const events = await store.getRepoEvents(repo)
|
|
1351
|
+
const state = reduceHarvest(events)
|
|
1352
|
+
const projected = projectHarvest(events)
|
|
1353
|
+
if (expectedRun === undefined || projected === undefined || projected.run !== expectedRun) {
|
|
1354
|
+
this.say('harvest run action ignored: selected run is no longer active')
|
|
1355
|
+
await this.renderOnce()
|
|
1356
|
+
return
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if (state.paused) {
|
|
1360
|
+
this.say('harvest run action unavailable while harvest is OFF; select Dispatcher and press h')
|
|
1361
|
+
await this.renderOnce()
|
|
1362
|
+
return
|
|
1363
|
+
}
|
|
1364
|
+
if (state.pendingCommands.some((command) => command.command === 'resume')) {
|
|
1365
|
+
this.say('harvest run: resume acknowledgement pending')
|
|
1366
|
+
await this.renderOnce()
|
|
1367
|
+
return
|
|
1368
|
+
}
|
|
1369
|
+
if (projected.action === undefined) {
|
|
1370
|
+
this.say('harvest run has no available action')
|
|
1371
|
+
await this.renderOnce()
|
|
1372
|
+
return
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
await applyHarvestRunControl({
|
|
1376
|
+
store,
|
|
1377
|
+
repo,
|
|
1378
|
+
user: buildControlUser(this.opts.env),
|
|
1379
|
+
run: projected.run,
|
|
1380
|
+
})
|
|
1381
|
+
const selectedRun = state.runs.find((run) => run.run === projected.run)
|
|
1382
|
+
this.say(
|
|
1383
|
+
projected.action === 'resume'
|
|
1384
|
+
? 'harvest: error resume requested'
|
|
1385
|
+
: selectedRun?.recoveryExhaustion !== undefined
|
|
1386
|
+
? 'harvest: exhausted recovery attention acknowledgement requested'
|
|
1387
|
+
: 'harvest: escalation acknowledgement requested',
|
|
1388
|
+
)
|
|
1389
|
+
await this.renderOnce()
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
private clearResumePrompt(slug: string): void {
|
|
1393
|
+
if (this.resumePrompt?.slug !== slug) return
|
|
1394
|
+
this.resumePrompt = undefined
|
|
1395
|
+
this.syncModelControls()
|
|
1396
|
+
this.paint()
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/** Submit the prompt through the shared build-control service. Empty input
|
|
1400
|
+
* is a retry; nonempty input is authoritative guidance. */
|
|
1401
|
+
private async submitResume(prompt: ResumePrompt): Promise<void> {
|
|
1402
|
+
let result: BuildControlResult
|
|
1403
|
+
try {
|
|
1404
|
+
result = await controlBuild({
|
|
1405
|
+
store: this.wiring.store,
|
|
1406
|
+
repo: this.repoIdentity,
|
|
1407
|
+
slug: prompt.slug,
|
|
1408
|
+
env: this.opts.env,
|
|
1409
|
+
action: {
|
|
1410
|
+
kind: 'answer',
|
|
1411
|
+
text: prompt.value,
|
|
1412
|
+
escalationIds: prompt.escalationIds,
|
|
1413
|
+
},
|
|
1414
|
+
})
|
|
1415
|
+
} catch (error) {
|
|
1416
|
+
if (error instanceof BuildControlError) {
|
|
1417
|
+
this.clearResumePrompt(prompt.slug)
|
|
1418
|
+
}
|
|
1419
|
+
if (await this.ignoreControlError('resume', error)) return
|
|
1420
|
+
throw error
|
|
1421
|
+
}
|
|
1422
|
+
if (result.kind !== 'answered') {
|
|
1423
|
+
throw new Error('build-control returned an invalid answer result')
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
this.clearResumePrompt(prompt.slug)
|
|
1427
|
+
this.say(
|
|
1428
|
+
`build ${prompt.slug}: blocked resume requested${
|
|
1429
|
+
result.resolution === 'guidance' ? ' with guidance' : ' without feedback'
|
|
1430
|
+
}`,
|
|
1431
|
+
)
|
|
1432
|
+
await this.renderOnce()
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
private async toggleAutoMerge(): Promise<void> {
|
|
1436
|
+
if (this.view === undefined && this.selection?.kind === 'global') {
|
|
1437
|
+
const event = await toggleRepositorySetting({
|
|
1438
|
+
store: this.wiring.store,
|
|
1439
|
+
repo: this.repoIdentity,
|
|
1440
|
+
user: buildControlUser(this.opts.env),
|
|
1441
|
+
setting: 'auto-merge-default',
|
|
1442
|
+
})
|
|
1443
|
+
this.say(`dispatcher auto-merge default ${event.enabled ? 'ON' : 'OFF'}`)
|
|
1444
|
+
await this.renderOnce()
|
|
1445
|
+
return
|
|
1446
|
+
}
|
|
1447
|
+
const slug = this.selectedBuildSlug('auto-merge')
|
|
1448
|
+
if (slug === undefined) return
|
|
1449
|
+
|
|
1450
|
+
let result: BuildControlResult
|
|
1451
|
+
try {
|
|
1452
|
+
result = await controlBuild({
|
|
1453
|
+
store: this.wiring.store,
|
|
1454
|
+
repo: this.repoIdentity,
|
|
1455
|
+
slug,
|
|
1456
|
+
env: this.opts.env,
|
|
1457
|
+
action: { kind: 'toggle-auto-merge' },
|
|
1458
|
+
})
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
if (await this.ignoreControlError('action', error)) return
|
|
1461
|
+
throw error
|
|
1462
|
+
}
|
|
1463
|
+
if (
|
|
1464
|
+
result.kind !== 'command' ||
|
|
1465
|
+
(result.command !== 'auto-merge-on' && result.command !== 'auto-merge-off')
|
|
1466
|
+
) {
|
|
1467
|
+
throw new Error('build-control returned an invalid auto-merge toggle result')
|
|
1468
|
+
}
|
|
1469
|
+
this.say(
|
|
1470
|
+
`build ${slug}: auto-merge ${
|
|
1471
|
+
result.command === 'auto-merge-off' ? 'cancelled' : 'requested'
|
|
1472
|
+
}`,
|
|
1473
|
+
)
|
|
1474
|
+
await this.renderOnce()
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
private moveDetailSession(delta: number): void {
|
|
1478
|
+
if (this.view?.kind !== 'detail') return
|
|
1479
|
+
const build = this.model?.builds.find((candidate) => candidate.slug === this.view!.slug)
|
|
1480
|
+
const sessions = build?.sessions ?? []
|
|
1481
|
+
if (sessions.length === 0) return
|
|
1482
|
+
const current = sessions.findIndex((session) => session.id === this.view!.sessionId)
|
|
1483
|
+
const index = Math.max(0, Math.min(sessions.length - 1, (current < 0 ? 0 : current) + delta))
|
|
1484
|
+
const next = { ...this.view, sessionId: sessions[index]!.id }
|
|
1485
|
+
const terminal = this.opts.terminal
|
|
1486
|
+
const nextModel = this.model === undefined ? undefined : { ...this.model, view: next }
|
|
1487
|
+
this.view = {
|
|
1488
|
+
...next,
|
|
1489
|
+
scroll:
|
|
1490
|
+
terminal === undefined || nextModel === undefined
|
|
1491
|
+
? 0
|
|
1492
|
+
: revealDetailFocus(
|
|
1493
|
+
nextModel,
|
|
1494
|
+
dashboardContentWidth(terminal.columns),
|
|
1495
|
+
paintableRows(terminal.rows),
|
|
1496
|
+
'session',
|
|
1497
|
+
next.scroll,
|
|
1498
|
+
),
|
|
1499
|
+
}
|
|
1500
|
+
this.syncModelControls()
|
|
1501
|
+
this.paint()
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
private detailMessage(
|
|
1505
|
+
view: Extract<DashboardView, { kind: 'detail' }>,
|
|
1506
|
+
message: string,
|
|
1507
|
+
messageWhileSessionOpen?: string,
|
|
1508
|
+
): Extract<DashboardView, { kind: 'detail' }> {
|
|
1509
|
+
const { message: _priorMessage, messageWhileSessionOpen: _priorFence, ...stable } = view
|
|
1510
|
+
const next = {
|
|
1511
|
+
...stable,
|
|
1512
|
+
message,
|
|
1513
|
+
...(messageWhileSessionOpen !== undefined ? { messageWhileSessionOpen } : {}),
|
|
1514
|
+
}
|
|
1515
|
+
const terminal = this.opts.terminal
|
|
1516
|
+
const nextModel = this.model === undefined ? undefined : { ...this.model, view: next }
|
|
1517
|
+
return {
|
|
1518
|
+
...next,
|
|
1519
|
+
scroll:
|
|
1520
|
+
terminal === undefined || nextModel === undefined
|
|
1521
|
+
? 0
|
|
1522
|
+
: revealDetailFocus(
|
|
1523
|
+
nextModel,
|
|
1524
|
+
dashboardContentWidth(terminal.columns),
|
|
1525
|
+
paintableRows(terminal.rows),
|
|
1526
|
+
'message',
|
|
1527
|
+
next.scroll,
|
|
1528
|
+
),
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
private async openSelected(): Promise<void> {
|
|
1533
|
+
if (this.view === undefined) {
|
|
1534
|
+
if (this.selection?.kind !== 'build') return
|
|
1535
|
+
const selectedSlug = this.selection.slug
|
|
1536
|
+
const build = this.model?.builds.find((candidate) => candidate.slug === selectedSlug)
|
|
1537
|
+
if (build === undefined) return
|
|
1538
|
+
this.view = {
|
|
1539
|
+
kind: 'detail',
|
|
1540
|
+
slug: build.slug,
|
|
1541
|
+
scroll: 0,
|
|
1542
|
+
...(build.sessions?.[0] !== undefined ? { sessionId: build.sessions[0].id } : {}),
|
|
1543
|
+
}
|
|
1544
|
+
this.syncModelControls()
|
|
1545
|
+
this.paint()
|
|
1546
|
+
return
|
|
1547
|
+
}
|
|
1548
|
+
if (this.view.kind === 'transcript' || this.view.kind === 'session') return
|
|
1549
|
+
|
|
1550
|
+
const captured = this.view
|
|
1551
|
+
const build = this.model?.builds.find((candidate) => candidate.slug === captured.slug)
|
|
1552
|
+
const session = build?.sessions?.find((candidate) => candidate.id === captured.sessionId)
|
|
1553
|
+
if (session === undefined) {
|
|
1554
|
+
this.view = this.detailMessage(captured, 'No session is selected.')
|
|
1555
|
+
this.syncModelControls()
|
|
1556
|
+
this.paint()
|
|
1557
|
+
return
|
|
1558
|
+
}
|
|
1559
|
+
// The stream branch outranks every transcript outcome: a session with a
|
|
1560
|
+
// stream — open or closed — opens the read-only live view. Sessions
|
|
1561
|
+
// without a stream fall through to the existing branches unchanged.
|
|
1562
|
+
if (session.stream !== undefined) {
|
|
1563
|
+
this.view = {
|
|
1564
|
+
kind: 'session',
|
|
1565
|
+
slug: captured.slug,
|
|
1566
|
+
sessionId: session.id,
|
|
1567
|
+
stream: session.stream,
|
|
1568
|
+
status: session.streamStatus,
|
|
1569
|
+
source: { kind: 'parts', parts: [], lastSeq: 0 },
|
|
1570
|
+
follow: true,
|
|
1571
|
+
scroll: 0,
|
|
1572
|
+
}
|
|
1573
|
+
this.sessionFeed = new SessionStreamFeed(this.wiring.store, captured.slug)
|
|
1574
|
+
this.syncModelControls()
|
|
1575
|
+
this.paint()
|
|
1576
|
+
// Kick the first read immediately rather than waiting one interval.
|
|
1577
|
+
this.pollSessionFeed()
|
|
1578
|
+
return
|
|
1579
|
+
}
|
|
1580
|
+
if (session.status === 'open') {
|
|
1581
|
+
this.view = this.detailMessage(
|
|
1582
|
+
captured,
|
|
1583
|
+
'Transcript unavailable while this session is still open.',
|
|
1584
|
+
session.id,
|
|
1585
|
+
)
|
|
1586
|
+
this.syncModelControls()
|
|
1587
|
+
this.paint()
|
|
1588
|
+
return
|
|
1589
|
+
}
|
|
1590
|
+
if (session.status === 'reclaimed') {
|
|
1591
|
+
this.view = this.detailMessage(
|
|
1592
|
+
captured,
|
|
1593
|
+
'This session was reclaimed by a recovering runner; transcript unavailable.',
|
|
1594
|
+
)
|
|
1595
|
+
this.syncModelControls()
|
|
1596
|
+
this.paint()
|
|
1597
|
+
return
|
|
1598
|
+
}
|
|
1599
|
+
if (session.transcript === undefined) {
|
|
1600
|
+
this.view = this.detailMessage(captured, 'This session ended without a transcript deposit.')
|
|
1601
|
+
this.syncModelControls()
|
|
1602
|
+
this.paint()
|
|
1603
|
+
return
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
const ref = session.transcript
|
|
1607
|
+
try {
|
|
1608
|
+
const artifact = await this.wiring.store.getArtifact(captured.slug, ref.kind, ref.rev)
|
|
1609
|
+
// Reads race polling, terminalization, and Escape. Apply only to the exact
|
|
1610
|
+
// detail/session that initiated the pinned read.
|
|
1611
|
+
if (
|
|
1612
|
+
this.view?.kind !== 'detail' ||
|
|
1613
|
+
this.view.slug !== captured.slug ||
|
|
1614
|
+
this.view.sessionId !== session.id
|
|
1615
|
+
) {
|
|
1616
|
+
return
|
|
1617
|
+
}
|
|
1618
|
+
if (artifact === null) {
|
|
1619
|
+
this.view = this.detailMessage(
|
|
1620
|
+
captured,
|
|
1621
|
+
`Transcript ${ref.kind}@${ref.rev} is not retrievable.`,
|
|
1622
|
+
)
|
|
1623
|
+
} else {
|
|
1624
|
+
this.view = {
|
|
1625
|
+
kind: 'transcript',
|
|
1626
|
+
slug: captured.slug,
|
|
1627
|
+
sessionId: session.id,
|
|
1628
|
+
transcript: parseTranscript(new TextDecoder().decode(artifact.content)),
|
|
1629
|
+
scroll: 0,
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
} catch (error) {
|
|
1633
|
+
if (
|
|
1634
|
+
this.view?.kind === 'detail' &&
|
|
1635
|
+
this.view.slug === captured.slug &&
|
|
1636
|
+
this.view.sessionId === session.id
|
|
1637
|
+
) {
|
|
1638
|
+
this.view = this.detailMessage(
|
|
1639
|
+
captured,
|
|
1640
|
+
`Transcript read failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1641
|
+
)
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
this.syncModelControls()
|
|
1645
|
+
this.paint()
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
private leaveView(): void {
|
|
1649
|
+
if (this.view?.kind === 'session') {
|
|
1650
|
+
this.sessionFeed = undefined
|
|
1651
|
+
this.view = {
|
|
1652
|
+
kind: 'detail',
|
|
1653
|
+
slug: this.view.slug,
|
|
1654
|
+
sessionId: this.view.sessionId,
|
|
1655
|
+
scroll: 0,
|
|
1656
|
+
}
|
|
1657
|
+
} else if (this.view?.kind === 'transcript') {
|
|
1658
|
+
this.view = {
|
|
1659
|
+
kind: 'detail',
|
|
1660
|
+
slug: this.view.slug,
|
|
1661
|
+
sessionId: this.view.sessionId,
|
|
1662
|
+
scroll: 0,
|
|
1663
|
+
}
|
|
1664
|
+
} else if (this.view?.kind === 'detail') {
|
|
1665
|
+
this.view = undefined
|
|
1666
|
+
} else {
|
|
1667
|
+
return
|
|
1668
|
+
}
|
|
1669
|
+
this.syncModelControls()
|
|
1670
|
+
this.paint()
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
private async handleAction(action: DashboardAction): Promise<void> {
|
|
1674
|
+
if (typeof action !== 'string') {
|
|
1675
|
+
await this.controlHarvestRun(action.run)
|
|
1676
|
+
return
|
|
1677
|
+
}
|
|
1678
|
+
switch (action) {
|
|
1679
|
+
case 'up':
|
|
1680
|
+
this.moveSelection(-1)
|
|
1681
|
+
return
|
|
1682
|
+
case 'down':
|
|
1683
|
+
this.moveSelection(1)
|
|
1684
|
+
return
|
|
1685
|
+
case 'left':
|
|
1686
|
+
this.moveDetailSession(-1)
|
|
1687
|
+
return
|
|
1688
|
+
case 'right':
|
|
1689
|
+
this.moveDetailSession(1)
|
|
1690
|
+
return
|
|
1691
|
+
case 'enter':
|
|
1692
|
+
await this.openSelected()
|
|
1693
|
+
return
|
|
1694
|
+
case 'intake':
|
|
1695
|
+
await this.toggleIntake()
|
|
1696
|
+
return
|
|
1697
|
+
case 'pause':
|
|
1698
|
+
await this.dashboardPause()
|
|
1699
|
+
return
|
|
1700
|
+
case 'resume':
|
|
1701
|
+
await this.dashboardResume()
|
|
1702
|
+
return
|
|
1703
|
+
case 'bulk-pause':
|
|
1704
|
+
await this.bulkControl('pause')
|
|
1705
|
+
return
|
|
1706
|
+
case 'bulk-resume':
|
|
1707
|
+
await this.bulkControl('resume')
|
|
1708
|
+
return
|
|
1709
|
+
case 'auto-merge':
|
|
1710
|
+
await this.toggleAutoMerge()
|
|
1711
|
+
return
|
|
1712
|
+
case 'discard':
|
|
1713
|
+
await this.discardSelected()
|
|
1714
|
+
return
|
|
1715
|
+
case 'abort-confirm':
|
|
1716
|
+
await this.abortConfirmed()
|
|
1717
|
+
return
|
|
1718
|
+
case 'harvest-gate':
|
|
1719
|
+
await this.toggleHarvestGate()
|
|
1720
|
+
return
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
private queueAction(action: DashboardAction): void {
|
|
1725
|
+
void this.serialize(() => this.handleAction(action)).catch((error: unknown) => {
|
|
1726
|
+
const name = typeof action === 'string' ? action : action.kind
|
|
1727
|
+
this.warn(
|
|
1728
|
+
`dashboard ${name} action failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1729
|
+
)
|
|
1730
|
+
})
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/** Modal edits are synchronous and never enter the dispatcher operation
|
|
1734
|
+
* queue. Only Enter serializes a store write, so polling/ticks continue while
|
|
1735
|
+
* an operator thinks and types. */
|
|
1736
|
+
private handleResumeInput(input: TerminalInputEvent): void {
|
|
1737
|
+
const prompt = this.resumePrompt
|
|
1738
|
+
if (prompt === undefined || this.resumeSubmitting) return
|
|
1739
|
+
const edit = (next: { value: string; cursor: number }): void => {
|
|
1740
|
+
this.resumePrompt = { ...prompt, ...next }
|
|
1741
|
+
this.syncModelControls()
|
|
1742
|
+
this.paint()
|
|
1743
|
+
}
|
|
1744
|
+
const move = (motion: ComposerMotion): void => {
|
|
1745
|
+
this.resumePrompt = { ...prompt, cursor: moveCursor(prompt.value, prompt.cursor, motion) }
|
|
1746
|
+
this.syncModelControls()
|
|
1747
|
+
this.paint()
|
|
1748
|
+
}
|
|
1749
|
+
switch (input.type) {
|
|
1750
|
+
// A paste is one insertion, not a burst of keystrokes: no part of it can
|
|
1751
|
+
// be interpreted as submit, and none of it is dropped.
|
|
1752
|
+
case 'text':
|
|
1753
|
+
case 'paste':
|
|
1754
|
+
edit(insertText(prompt.value, prompt.cursor, input.text))
|
|
1755
|
+
return
|
|
1756
|
+
case 'newline':
|
|
1757
|
+
edit(insertText(prompt.value, prompt.cursor, '\n'))
|
|
1758
|
+
return
|
|
1759
|
+
case 'backspace':
|
|
1760
|
+
edit(deleteBefore(prompt.value, prompt.cursor))
|
|
1761
|
+
return
|
|
1762
|
+
case 'left':
|
|
1763
|
+
case 'right':
|
|
1764
|
+
case 'up':
|
|
1765
|
+
case 'down':
|
|
1766
|
+
case 'home':
|
|
1767
|
+
case 'end':
|
|
1768
|
+
// Up/Down move the CARET while the prompt is open; the dashboard's row
|
|
1769
|
+
// selection deliberately does not follow.
|
|
1770
|
+
move(input.type)
|
|
1771
|
+
return
|
|
1772
|
+
case 'escape':
|
|
1773
|
+
this.clearResumePrompt(prompt.slug)
|
|
1774
|
+
return
|
|
1775
|
+
case 'enter':
|
|
1776
|
+
this.resumeSubmitting = true
|
|
1777
|
+
void this.serialize(() => this.submitResume(prompt))
|
|
1778
|
+
.catch((error: unknown) => {
|
|
1779
|
+
this.warn(
|
|
1780
|
+
`dashboard resume action failed: ${
|
|
1781
|
+
error instanceof Error ? error.message : String(error)
|
|
1782
|
+
}`,
|
|
1783
|
+
)
|
|
1784
|
+
})
|
|
1785
|
+
.finally(() => {
|
|
1786
|
+
this.resumeSubmitting = false
|
|
1787
|
+
this.syncModelControls()
|
|
1788
|
+
this.paint()
|
|
1789
|
+
})
|
|
1790
|
+
return
|
|
1791
|
+
case 'interrupt':
|
|
1792
|
+
return
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
private onInput(input: TerminalInputEvent): void {
|
|
1797
|
+
if (!this.acceptingKeys) return
|
|
1798
|
+
if (input.type === 'interrupt') {
|
|
1799
|
+
this.acceptingKeys = false
|
|
1800
|
+
this.inputStop.abort()
|
|
1801
|
+
return
|
|
1802
|
+
}
|
|
1803
|
+
if (this.resumePrompt !== undefined) {
|
|
1804
|
+
this.handleResumeInput(input)
|
|
1805
|
+
return
|
|
1806
|
+
}
|
|
1807
|
+
// The session view is strictly read-only: before the abort-confirmation
|
|
1808
|
+
// and enterLike branches can be reached, every key except Up/Down (scroll)
|
|
1809
|
+
// and Escape (back) returns without queueing any action. No key handled in
|
|
1810
|
+
// the session view can append an event or open the abort confirmation.
|
|
1811
|
+
if (this.view?.kind === 'session') {
|
|
1812
|
+
if (input.type === 'up' || input.type === 'down') {
|
|
1813
|
+
this.moveSelection(input.type === 'up' ? -1 : 1)
|
|
1814
|
+
} else if (input.type === 'escape') {
|
|
1815
|
+
this.leaveView()
|
|
1816
|
+
}
|
|
1817
|
+
return
|
|
1818
|
+
}
|
|
1819
|
+
// Outside the resume prompt `newline` is handled identically to `enter`.
|
|
1820
|
+
// That is the structural mitigation for splitting CR from LF: only the
|
|
1821
|
+
// composer can tell the two apart, so a terminal that reports Return as LF
|
|
1822
|
+
// cannot make Enter stop working anywhere else.
|
|
1823
|
+
const enterLike = input.type === 'enter' || input.type === 'newline'
|
|
1824
|
+
if (this.abortConfirmation !== undefined) {
|
|
1825
|
+
if (enterLike) this.queueAction('abort-confirm')
|
|
1826
|
+
else if (input.type === 'escape') {
|
|
1827
|
+
this.abortConfirmation = undefined
|
|
1828
|
+
this.syncModelControls()
|
|
1829
|
+
this.paint()
|
|
1830
|
+
}
|
|
1831
|
+
return
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
if (
|
|
1835
|
+
input.type === 'up' ||
|
|
1836
|
+
input.type === 'down' ||
|
|
1837
|
+
input.type === 'left' ||
|
|
1838
|
+
input.type === 'right'
|
|
1839
|
+
) {
|
|
1840
|
+
this.queueAction(input.type)
|
|
1841
|
+
return
|
|
1842
|
+
}
|
|
1843
|
+
if (enterLike) {
|
|
1844
|
+
this.queueAction('enter')
|
|
1845
|
+
return
|
|
1846
|
+
}
|
|
1847
|
+
if (input.type === 'escape') {
|
|
1848
|
+
this.leaveView()
|
|
1849
|
+
return
|
|
1850
|
+
}
|
|
1851
|
+
// A stray paste outside the prompt is a no-op, not a burst of command keys;
|
|
1852
|
+
// directional motions were routed above.
|
|
1853
|
+
if (input.type !== 'text') return
|
|
1854
|
+
switch (input.text.toLowerCase()) {
|
|
1855
|
+
case 'm':
|
|
1856
|
+
// Defense in depth: the session-view gate above already returns for a
|
|
1857
|
+
// session view, and the transcript guard below predates it.
|
|
1858
|
+
if (this.view?.kind !== 'transcript') {
|
|
1859
|
+
this.queueAction('auto-merge')
|
|
1860
|
+
}
|
|
1861
|
+
return
|
|
1862
|
+
case 'i':
|
|
1863
|
+
if (this.view === undefined && this.selection?.kind === 'global') this.queueAction('intake')
|
|
1864
|
+
return
|
|
1865
|
+
case 'p':
|
|
1866
|
+
if (this.view === undefined && this.selection?.kind === 'global') {
|
|
1867
|
+
this.queueAction('bulk-pause')
|
|
1868
|
+
return
|
|
1869
|
+
}
|
|
1870
|
+
if (this.view === undefined && this.selection?.kind === 'harvest') {
|
|
1871
|
+
this.queueAction({ kind: 'harvest-run', run: this.model?.harvest?.run })
|
|
1872
|
+
return
|
|
1873
|
+
}
|
|
1874
|
+
if (dashboardBuildControl(this.selectedDashboardBuild()?.status ?? 'queued')?.key === 'p') {
|
|
1875
|
+
this.queueAction('pause')
|
|
1876
|
+
}
|
|
1877
|
+
return
|
|
1878
|
+
case 'r':
|
|
1879
|
+
if (this.view === undefined && this.selection?.kind === 'global') {
|
|
1880
|
+
this.queueAction('bulk-resume')
|
|
1881
|
+
return
|
|
1882
|
+
}
|
|
1883
|
+
if (dashboardBuildControl(this.selectedDashboardBuild()?.status ?? 'queued')?.key === 'r') {
|
|
1884
|
+
this.queueAction('resume')
|
|
1885
|
+
}
|
|
1886
|
+
return
|
|
1887
|
+
case 'd': {
|
|
1888
|
+
const slug =
|
|
1889
|
+
this.view?.slug ?? (this.selection?.kind === 'build' ? this.selection.slug : undefined)
|
|
1890
|
+
const build = this.model?.builds.find((candidate) => candidate.slug === slug)
|
|
1891
|
+
if (build?.status === 'queued') this.queueAction('discard')
|
|
1892
|
+
return
|
|
1893
|
+
}
|
|
1894
|
+
case 'a': {
|
|
1895
|
+
// Same defense as `m`: the session-view gate above already made this
|
|
1896
|
+
// unreachable from a session view, so the abort confirmation can never
|
|
1897
|
+
// open from one.
|
|
1898
|
+
if (this.view?.kind === 'transcript') return
|
|
1899
|
+
const slug =
|
|
1900
|
+
this.view?.slug ?? (this.selection?.kind === 'build' ? this.selection.slug : undefined)
|
|
1901
|
+
const build = this.model?.builds.find((candidate) => candidate.slug === slug)
|
|
1902
|
+
if (build === undefined || build.status === 'aborting' || build.status === 'cleaning') {
|
|
1903
|
+
return
|
|
1904
|
+
}
|
|
1905
|
+
this.abortConfirmation = { slug: build.slug }
|
|
1906
|
+
this.syncModelControls()
|
|
1907
|
+
this.paint()
|
|
1908
|
+
return
|
|
1909
|
+
}
|
|
1910
|
+
case 'h':
|
|
1911
|
+
if (this.view === undefined && this.selection?.kind === 'global')
|
|
1912
|
+
this.queueAction('harvest-gate')
|
|
1913
|
+
return
|
|
1914
|
+
default:
|
|
1915
|
+
return
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
private startInput(): void {
|
|
1920
|
+
if (!this.dashboard || this.opts.input === undefined) return
|
|
1921
|
+
this.acceptingKeys = true
|
|
1922
|
+
this.cleanupInput = this.opts.input.start((input) => this.onInput(input), {
|
|
1923
|
+
onListening: () => this.keyboard?.query(),
|
|
1924
|
+
onKeyboardFlags: (flags) => this.keyboard?.reported(flags),
|
|
1925
|
+
onDeviceAttributes: () => this.keyboard?.deviceAttributes(),
|
|
1926
|
+
})
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
private stopInput(): void {
|
|
1930
|
+
this.acceptingKeys = false
|
|
1931
|
+
const cleanup = this.cleanupInput
|
|
1932
|
+
this.cleanupInput = undefined
|
|
1933
|
+
cleanup?.()
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/** Start one repository workflow without blocking the dispatcher tick.
|
|
1937
|
+
* Process-local tracking prevents redundant contenders and lets `--once`
|
|
1938
|
+
* drain it; the repository lease excludes other dispatch processes.
|
|
1939
|
+
* With a provider `harvestExecution` capability the same workflow is
|
|
1940
|
+
* launched in a disposable guest environment instead: still fire-and-forget
|
|
1941
|
+
* within the tick and single-flight, and still drained by `--once` (the
|
|
1942
|
+
* existing drain semantics), so one invocation reaches a durable boundary.
|
|
1943
|
+
* Watch teardown detaches the local wait — the guest keeps running and a
|
|
1944
|
+
* later invocation settles the execution from the Store plus provider
|
|
1945
|
+
* liveness. */
|
|
1946
|
+
private launchHarvest(): void {
|
|
1947
|
+
// Do not even start a second local contender while one is active. A second
|
|
1948
|
+
// dispatch process is independently excluded by the repository lease.
|
|
1949
|
+
if (this.harvestInFlight !== undefined) return
|
|
1950
|
+
|
|
1951
|
+
const hosted = this.wiring.workspaces.harvestExecution
|
|
1952
|
+
if (hosted !== undefined) {
|
|
1953
|
+
const execution = `${this.host}-harvest-${this.wiring.ids('inst')}`
|
|
1954
|
+
this.hostedHarvestExecutions.add(execution)
|
|
1955
|
+
let tracked: Promise<void>
|
|
1956
|
+
tracked = this.launchHostedHarvest(hosted, execution).finally(() => {
|
|
1957
|
+
this.hostedHarvestExecutions.delete(execution)
|
|
1958
|
+
this.inFlight.delete(tracked)
|
|
1959
|
+
if (this.harvestInFlight === tracked) this.harvestInFlight = undefined
|
|
1960
|
+
})
|
|
1961
|
+
this.harvestInFlight = tracked
|
|
1962
|
+
this.inFlight.add(tracked)
|
|
1963
|
+
return
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
const { store, tickets, runtimes, ids, uuids, clock, storeRef, token } = this.wiring
|
|
1967
|
+
const runner = new HarvestRunner({
|
|
1968
|
+
store,
|
|
1969
|
+
tickets,
|
|
1970
|
+
config: this.currentConfig().config,
|
|
1971
|
+
getConfig: () => this.currentConfig().config,
|
|
1972
|
+
runtimes,
|
|
1973
|
+
repo: this.repoIdentity,
|
|
1974
|
+
workspacePath: this.opts.targetRepo,
|
|
1975
|
+
ids,
|
|
1976
|
+
uuids,
|
|
1977
|
+
clock,
|
|
1978
|
+
instance: `${this.host}-harvest-${ids('inst')}`,
|
|
1979
|
+
...(this.repoLeaseHolder !== undefined ? { leaseHolder: this.repoLeaseHolder } : {}),
|
|
1980
|
+
sessionEnv: {
|
|
1981
|
+
AB_STORE: storeRef,
|
|
1982
|
+
...(token !== undefined ? { AB_TOKEN: token } : {}),
|
|
1983
|
+
},
|
|
1984
|
+
opts: {
|
|
1985
|
+
maxRecoveryAttempts: this.maxHarvestRecoveryAttempts,
|
|
1986
|
+
},
|
|
1987
|
+
})
|
|
1988
|
+
|
|
1989
|
+
let tracked: Promise<void>
|
|
1990
|
+
tracked = runner
|
|
1991
|
+
.run()
|
|
1992
|
+
.then((result) => {
|
|
1993
|
+
this.recordHarvestResult(result)
|
|
1994
|
+
if (
|
|
1995
|
+
!this.stopped &&
|
|
1996
|
+
(result.outcome === 'completed' ||
|
|
1997
|
+
result.outcome === 'escalated' ||
|
|
1998
|
+
result.outcome === 'failed')
|
|
1999
|
+
) {
|
|
2000
|
+
const line = `harvest ${result.run} ${result.outcome}`
|
|
2001
|
+
if (result.outcome === 'failed') this.failureNotice(line)
|
|
2002
|
+
else this.say(line)
|
|
2003
|
+
}
|
|
2004
|
+
})
|
|
2005
|
+
.catch(async (error: unknown) => {
|
|
2006
|
+
this.pendingHarvest.harvestFailed += 1
|
|
2007
|
+
await this.appendStatus({
|
|
2008
|
+
actor: DISPATCHER,
|
|
2009
|
+
type: 'dispatcher.harvest-runner-failed',
|
|
2010
|
+
payload: {
|
|
2011
|
+
run: this.runId,
|
|
2012
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2013
|
+
},
|
|
2014
|
+
})
|
|
2015
|
+
if (!this.stopped) {
|
|
2016
|
+
this.warn(
|
|
2017
|
+
`harvest runner failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2018
|
+
)
|
|
2019
|
+
}
|
|
2020
|
+
})
|
|
2021
|
+
.finally(() => {
|
|
2022
|
+
this.inFlight.delete(tracked)
|
|
2023
|
+
if (this.harvestInFlight === tracked) this.harvestInFlight = undefined
|
|
2024
|
+
})
|
|
2025
|
+
this.harvestInFlight = tracked
|
|
2026
|
+
this.inFlight.add(tracked)
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
/** Hosted harvest lifecycle: threshold gate → provision + launch in the
|
|
2030
|
+
* guest → durable execution identity → supervise → classify from the
|
|
2031
|
+
* journal → release the environment → durable close. Fire-and-forget by
|
|
2032
|
+
* contract: provisioning takes minutes and must never stall a tick; the
|
|
2033
|
+
* single-flight guard covers the whole span. Every failure is contained
|
|
2034
|
+
* and retried on a later threshold tick. */
|
|
2035
|
+
private async launchHostedHarvest(
|
|
2036
|
+
harvestExecution: HarvestExecution,
|
|
2037
|
+
execution: string,
|
|
2038
|
+
): Promise<void> {
|
|
2039
|
+
const { store, storeRef } = this.wiring
|
|
2040
|
+
const repo = this.repoIdentity
|
|
2041
|
+
let handle: BuildExecutionHandle | undefined
|
|
2042
|
+
let startedSeq: number | undefined
|
|
2043
|
+
// Shared with `stopHostedHarvest` through the `hostedHarvest` entry: a
|
|
2044
|
+
// detach must stay observable to the completion chain even after the
|
|
2045
|
+
// entry is cleared, or the chain would classify and reap a live guest.
|
|
2046
|
+
const detached = { value: false }
|
|
2047
|
+
try {
|
|
2048
|
+
// Threshold gate before any provisioning — with resume precedence. The
|
|
2049
|
+
// runner's real check runs in the guest after a full provisioning
|
|
2050
|
+
// chain, and its order is resume-first, pressure-second: it settles
|
|
2051
|
+
// control and resumes an actionable/open run before any pressure
|
|
2052
|
+
// check. The host gate replicates that order, or an interrupted run
|
|
2053
|
+
// whose unclaimed pressure reads below threshold would never
|
|
2054
|
+
// re-provision (durable recovery). Only the no-open-run case pays for
|
|
2055
|
+
// the pure Store-read pressure check; the guest rescans authoritatively
|
|
2056
|
+
// once provisioned, so a threshold crossing between gate and guest scan
|
|
2057
|
+
// is never suppressed by this check.
|
|
2058
|
+
const record = await store.getRepo(repo)
|
|
2059
|
+
const events = record === null ? [] : await store.getRepoEvents(repo)
|
|
2060
|
+
const state = reduceHarvest(events)
|
|
2061
|
+
const control = decideHarvestControl(state, this.maxHarvestRecoveryAttempts)
|
|
2062
|
+
const resumePending =
|
|
2063
|
+
actionableHarvestRun(state) !== undefined ||
|
|
2064
|
+
state.pendingCommands.length > 0 ||
|
|
2065
|
+
control.kind === 'acknowledge' ||
|
|
2066
|
+
control.kind === 'request-recovery' ||
|
|
2067
|
+
control.kind === 'exhaust-recovery'
|
|
2068
|
+
if (!resumePending) {
|
|
2069
|
+
const scan = await scanUnclaimedObservations(store, repo)
|
|
2070
|
+
const pressure = evaluateHarvestPressure(scan, this.currentConfig().config.policy)
|
|
2071
|
+
if (pressure.trigger === undefined) return
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
handle = await harvestExecution.start({
|
|
2075
|
+
storeRef,
|
|
2076
|
+
repo,
|
|
2077
|
+
instance: execution,
|
|
2078
|
+
baseBranch: this.currentConfig().config.baseBranch,
|
|
2079
|
+
...(this.repoLeaseHolder !== undefined ? { leaseHolder: this.repoLeaseHolder } : {}),
|
|
2080
|
+
})
|
|
2081
|
+
const identity = handle.identity
|
|
2082
|
+
if (
|
|
2083
|
+
identity === undefined ||
|
|
2084
|
+
identity.provider === undefined ||
|
|
2085
|
+
identity.environmentId === undefined ||
|
|
2086
|
+
identity.commandId === undefined
|
|
2087
|
+
) {
|
|
2088
|
+
throw new Error(
|
|
2089
|
+
'harvest execution launch returned no durable identity; refusing to supervise an unobservable execution',
|
|
2090
|
+
)
|
|
2091
|
+
}
|
|
2092
|
+
this.hostedHarvest = { execution, handle, detached }
|
|
2093
|
+
const started = await store.appendRepo(repo, {
|
|
2094
|
+
actor: DISPATCHER,
|
|
2095
|
+
type: 'harvest.execution.started',
|
|
2096
|
+
payload: {
|
|
2097
|
+
execution,
|
|
2098
|
+
provider: identity.provider,
|
|
2099
|
+
environmentId: identity.environmentId,
|
|
2100
|
+
...(identity.sessionId !== undefined ? { sessionId: identity.sessionId } : {}),
|
|
2101
|
+
commandId: identity.commandId,
|
|
2102
|
+
},
|
|
2103
|
+
})
|
|
2104
|
+
startedSeq = started.seq
|
|
2105
|
+
|
|
2106
|
+
await handle.completion
|
|
2107
|
+
if (detached.value) {
|
|
2108
|
+
// Teardown detached the local wait; the guest keeps running and the
|
|
2109
|
+
// execution stays open for durable settlement by a later invocation.
|
|
2110
|
+
return
|
|
2111
|
+
}
|
|
2112
|
+
// The guest exit code carries no pipeline outcome: classify from the
|
|
2113
|
+
// repository journal, feed the same counters a local run reports, then
|
|
2114
|
+
// release the environment and journal the close with the snapshot
|
|
2115
|
+
// purge outcome. Releasing on every guest exit also covers parked runs
|
|
2116
|
+
// — a paused run resumes later in a fresh environment.
|
|
2117
|
+
const result = await this.classifyHostedHarvestOutcome(repo, startedSeq!)
|
|
2118
|
+
this.recordHarvestResult(result)
|
|
2119
|
+
if (
|
|
2120
|
+
!this.stopped &&
|
|
2121
|
+
(result.outcome === 'completed' ||
|
|
2122
|
+
result.outcome === 'escalated' ||
|
|
2123
|
+
result.outcome === 'failed')
|
|
2124
|
+
) {
|
|
2125
|
+
const line = `harvest ${result.run} ${result.outcome}`
|
|
2126
|
+
if (result.outcome === 'failed') this.failureNotice(line)
|
|
2127
|
+
else this.say(line)
|
|
2128
|
+
}
|
|
2129
|
+
await this.releaseHostedHarvest(repo, execution, handle)
|
|
2130
|
+
} catch (error) {
|
|
2131
|
+
// Launch or wait failure: best-effort release attempt, the existing
|
|
2132
|
+
// durable failure event + warn path, retried on a later threshold tick.
|
|
2133
|
+
// An unmarked leftover from a mid-provision death is deleted by the
|
|
2134
|
+
// next run's get-or-create; a marked-but-unlaunched one is reused.
|
|
2135
|
+
if (handle !== undefined) {
|
|
2136
|
+
try {
|
|
2137
|
+
await this.releaseHostedHarvest(repo, execution, handle)
|
|
2138
|
+
} catch {
|
|
2139
|
+
// Left open: durable settlement re-proves and reaps it later.
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
this.pendingHarvest.harvestFailed += 1
|
|
2143
|
+
await this.appendStatus({
|
|
2144
|
+
actor: DISPATCHER,
|
|
2145
|
+
type: 'dispatcher.harvest-runner-failed',
|
|
2146
|
+
payload: {
|
|
2147
|
+
run: this.runId,
|
|
2148
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2149
|
+
},
|
|
2150
|
+
})
|
|
2151
|
+
if (!this.stopped) {
|
|
2152
|
+
this.warn(
|
|
2153
|
+
`harvest runner failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2154
|
+
)
|
|
2155
|
+
}
|
|
2156
|
+
} finally {
|
|
2157
|
+
this.hostedHarvest = undefined
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
/** Classify the outcome of one finished hosted execution from the
|
|
2162
|
+
* repository journal. */
|
|
2163
|
+
private async classifyHostedHarvestOutcome(
|
|
2164
|
+
repo: string,
|
|
2165
|
+
startedSeq: number,
|
|
2166
|
+
): Promise<HarvestRunnerResult> {
|
|
2167
|
+
const { store } = this.wiring
|
|
2168
|
+
const events = await store.getRepoEvents(repo)
|
|
2169
|
+
const record = await store.getRepo(repo)
|
|
2170
|
+
return classifyHarvestOutcome(events, {
|
|
2171
|
+
executionStartedSeq: startedSeq,
|
|
2172
|
+
adoptedHolder: this.repoLeaseHolder,
|
|
2173
|
+
leaseHolder: record?.lease?.holder,
|
|
2174
|
+
maxRecoveryAttempts: this.maxHarvestRecoveryAttempts,
|
|
2175
|
+
})
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
/** Release the disposable environment through the owning provider's reap
|
|
2179
|
+
* path and journal the close fact with the snapshot purge outcome. */
|
|
2180
|
+
private async releaseHostedHarvest(
|
|
2181
|
+
repo: string,
|
|
2182
|
+
execution: string,
|
|
2183
|
+
handle: BuildExecutionHandle,
|
|
2184
|
+
): Promise<void> {
|
|
2185
|
+
const identity = handle.identity
|
|
2186
|
+
if (identity === undefined || identity.environmentId === undefined) return
|
|
2187
|
+
const workspaceHandle: WorkspaceHandle = {
|
|
2188
|
+
provider: identity.provider,
|
|
2189
|
+
ref: identity.environmentId,
|
|
2190
|
+
path: identity.environmentId,
|
|
2191
|
+
branch: '',
|
|
2192
|
+
}
|
|
2193
|
+
const reap = await this.reapHostedWorkspace(workspaceHandle)
|
|
2194
|
+
await this.wiring.store.appendRepo(repo, {
|
|
2195
|
+
actor: DISPATCHER,
|
|
2196
|
+
type: 'harvest.execution.released',
|
|
2197
|
+
payload: {
|
|
2198
|
+
execution,
|
|
2199
|
+
environmentId: identity.environmentId,
|
|
2200
|
+
snapshots: {
|
|
2201
|
+
outcome: reap.snapshots.outcome === 'unknown' ? 'unknown' : 'confirmed',
|
|
2202
|
+
...(reap.snapshots.deleted !== undefined ? { deleted: reap.snapshots.deleted } : {}),
|
|
2203
|
+
...(reap.snapshots.error !== undefined ? { error: reap.snapshots.error } : {}),
|
|
2204
|
+
},
|
|
2205
|
+
},
|
|
2206
|
+
})
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
/** Reap through the provider's recovery capability when it reports the
|
|
2210
|
+
* snapshot purge outcome; fall back to plain release otherwise (the close
|
|
2211
|
+
* fact then carries an unconfirmed purge). */
|
|
2212
|
+
private async reapHostedWorkspace(handle: WorkspaceHandle): Promise<WorkspaceReapOutcome> {
|
|
2213
|
+
const provider = this.wiring.workspaces
|
|
2214
|
+
if (provider.name !== handle.provider) {
|
|
2215
|
+
const retired = this.wiring.retiredWorkspaces?.find(
|
|
2216
|
+
(candidate) => candidate.provider.name === handle.provider,
|
|
2217
|
+
)?.provider
|
|
2218
|
+
if (retired === undefined) throw new Error(`no provider owns harvest workspace ${handle.ref}`)
|
|
2219
|
+
if (retired.recovery !== undefined) return retired.recovery.reap(handle)
|
|
2220
|
+
await retired.release(handle)
|
|
2221
|
+
} else if (provider.recovery !== undefined) {
|
|
2222
|
+
return provider.recovery.reap(handle)
|
|
2223
|
+
} else {
|
|
2224
|
+
await provider.release(handle)
|
|
2225
|
+
}
|
|
2226
|
+
return {
|
|
2227
|
+
outcome: 'confirmed',
|
|
2228
|
+
snapshots: { outcome: 'unknown', error: 'provider did not report the snapshot purge' },
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
/** Teardown: detach the local wait only. The guest keeps running and
|
|
2233
|
+
* heartbeating its adopted repository lease; the execution stays open and
|
|
2234
|
+
* a later invocation settles it from the Store plus provider liveness. */
|
|
2235
|
+
private async stopHostedHarvest(): Promise<void> {
|
|
2236
|
+
const entry = this.hostedHarvest
|
|
2237
|
+
if (entry === undefined) return
|
|
2238
|
+
entry.detached.value = true
|
|
2239
|
+
this.hostedHarvest = undefined
|
|
2240
|
+
try {
|
|
2241
|
+
await entry.handle.detach()
|
|
2242
|
+
} catch {
|
|
2243
|
+
// Detach is best-effort: an unresolvable wait still exits.
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
private recordHarvestResult(result: HarvestRunnerResult): void {
|
|
2248
|
+
if ('launch' in result) {
|
|
2249
|
+
if (result.launch === 'started') this.pendingHarvest.harvestStarted += 1
|
|
2250
|
+
else this.pendingHarvest.harvestResumed += 1
|
|
2251
|
+
}
|
|
2252
|
+
if (result.outcome === 'completed') this.pendingHarvest.harvestCompleted += 1
|
|
2253
|
+
else if (result.outcome === 'escalated') {
|
|
2254
|
+
this.pendingHarvest.harvestEscalated += 1
|
|
2255
|
+
} else if (result.outcome === 'failed') {
|
|
2256
|
+
this.pendingHarvest.harvestFailed += 1
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
private hasPendingHarvestResults(): boolean {
|
|
2261
|
+
return (
|
|
2262
|
+
this.pendingHarvest.harvestStarted > 0 ||
|
|
2263
|
+
this.pendingHarvest.harvestResumed > 0 ||
|
|
2264
|
+
this.pendingHarvest.harvestCompleted > 0 ||
|
|
2265
|
+
this.pendingHarvest.harvestEscalated > 0 ||
|
|
2266
|
+
this.pendingHarvest.harvestFailed > 0
|
|
2267
|
+
)
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
/** Publish one report from one Harvest snapshot. Acknowledgement subtracts
|
|
2271
|
+
* only that snapshot after the durable append, preserving outcomes that
|
|
2272
|
+
* settle while the Store write is in flight and retaining all counters when
|
|
2273
|
+
* publication fails. */
|
|
2274
|
+
private async publishTickReport(
|
|
2275
|
+
report: TickReport,
|
|
2276
|
+
settlementOnly = false,
|
|
2277
|
+
): Promise<TickReport | undefined> {
|
|
2278
|
+
const snapshot = { ...this.pendingHarvest }
|
|
2279
|
+
if (
|
|
2280
|
+
settlementOnly &&
|
|
2281
|
+
snapshot.harvestStarted === 0 &&
|
|
2282
|
+
snapshot.harvestResumed === 0 &&
|
|
2283
|
+
snapshot.harvestCompleted === 0 &&
|
|
2284
|
+
snapshot.harvestEscalated === 0 &&
|
|
2285
|
+
snapshot.harvestFailed === 0
|
|
2286
|
+
) {
|
|
2287
|
+
return undefined
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
const merged: TickReport = {
|
|
2291
|
+
...report,
|
|
2292
|
+
harvestStarted: report.harvestStarted + snapshot.harvestStarted,
|
|
2293
|
+
harvestResumed: report.harvestResumed + snapshot.harvestResumed,
|
|
2294
|
+
harvestCompleted: report.harvestCompleted + snapshot.harvestCompleted,
|
|
2295
|
+
harvestEscalated: report.harvestEscalated + snapshot.harvestEscalated,
|
|
2296
|
+
harvestFailed: report.harvestFailed + snapshot.harvestFailed,
|
|
2297
|
+
}
|
|
2298
|
+
const {
|
|
2299
|
+
queued: _queued,
|
|
2300
|
+
janitorDiagnostics: _janitorDiagnostics,
|
|
2301
|
+
blockedDiagnostics: _blockedDiagnostics,
|
|
2302
|
+
ticketDiagnostics: _ticketDiagnostics,
|
|
2303
|
+
creationDiagnostics: _creationDiagnostics,
|
|
2304
|
+
dependencyDiagnostics: _dependencyDiagnostics,
|
|
2305
|
+
...counters
|
|
2306
|
+
} = merged
|
|
2307
|
+
await this.appendStatus({
|
|
2308
|
+
actor: DISPATCHER,
|
|
2309
|
+
type: 'dispatcher.tick-completed',
|
|
2310
|
+
payload: {
|
|
2311
|
+
run: this.runId,
|
|
2312
|
+
...this.lastTickStatus,
|
|
2313
|
+
counters,
|
|
2314
|
+
},
|
|
2315
|
+
})
|
|
2316
|
+
|
|
2317
|
+
this.pendingHarvest.harvestStarted -= snapshot.harvestStarted
|
|
2318
|
+
this.pendingHarvest.harvestResumed -= snapshot.harvestResumed
|
|
2319
|
+
this.pendingHarvest.harvestCompleted -= snapshot.harvestCompleted
|
|
2320
|
+
this.pendingHarvest.harvestEscalated -= snapshot.harvestEscalated
|
|
2321
|
+
this.pendingHarvest.harvestFailed -= snapshot.harvestFailed
|
|
2322
|
+
return merged
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
/** Settlement-only completions are serialized after operational ticks. Loop
|
|
2326
|
+
* so a result arriving during an awaited status append gets its own report. */
|
|
2327
|
+
private publishSettlementReports(): Promise<TickReport[]> {
|
|
2328
|
+
return this.serialize(async () => {
|
|
2329
|
+
const reports: TickReport[] = []
|
|
2330
|
+
while (this.hasPendingHarvestResults()) {
|
|
2331
|
+
const report = await this.publishTickReport(emptyTickReport(), true)
|
|
2332
|
+
if (report !== undefined) reports.push(report)
|
|
2333
|
+
}
|
|
2334
|
+
return reports
|
|
2335
|
+
})
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
private async matchingRunnerDiagnostic(slug: string, instance: string) {
|
|
2339
|
+
const metas = await this.wiring.store.listArtifacts(slug, BUILD_RUNNER_DIAGNOSTIC_ARTIFACT)
|
|
2340
|
+
for (const meta of metas.toReversed()) {
|
|
2341
|
+
if (meta.metadata.instance !== instance) continue
|
|
2342
|
+
const artifact = await this.wiring.store.getArtifact(slug, meta.kind, meta.revision)
|
|
2343
|
+
if (artifact === null) continue
|
|
2344
|
+
const diagnostic = parseDiagnostic(artifact)
|
|
2345
|
+
if (diagnostic?.instance === instance) return diagnostic
|
|
2346
|
+
}
|
|
2347
|
+
return null
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
private async settlePendingPublication(slug: string): Promise<void> {
|
|
2351
|
+
await settleWorkspacePublication(
|
|
2352
|
+
{
|
|
2353
|
+
store: this.wiring.store,
|
|
2354
|
+
storeRef: this.wiring.storeRef,
|
|
2355
|
+
publication: this.wiring.workspaces.publication,
|
|
2356
|
+
forge: this.wiring.forge,
|
|
2357
|
+
workspacePath: this.opts.targetRepo,
|
|
2358
|
+
exec: this.opts.exec,
|
|
2359
|
+
ids: this.wiring.ids,
|
|
2360
|
+
runId: this.runId,
|
|
2361
|
+
clock: this.wiring.clock,
|
|
2362
|
+
},
|
|
2363
|
+
slug,
|
|
2364
|
+
)
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
private async recordInfrastructureFailure(input: {
|
|
2368
|
+
slug: string
|
|
2369
|
+
instance: string
|
|
2370
|
+
workspaceRef: string
|
|
2371
|
+
operation: 'provision' | 'start' | 'wait' | 'stop' | 'delete' | 'reconcile'
|
|
2372
|
+
error: unknown
|
|
2373
|
+
cleanupPending: boolean
|
|
2374
|
+
identity?: BuildExecutionHandle['identity']
|
|
2375
|
+
}): Promise<void> {
|
|
2376
|
+
await appendInfrastructureFailure(
|
|
2377
|
+
{ store: this.wiring.store, ids: this.wiring.ids },
|
|
2378
|
+
{
|
|
2379
|
+
slug: input.slug,
|
|
2380
|
+
events: await this.wiring.store.getEvents(input.slug),
|
|
2381
|
+
maxAttempts: this.currentConfig().config.policy.maxInfrastructureAttempts,
|
|
2382
|
+
provider: input.identity?.provider ?? this.wiring.workspaces.name,
|
|
2383
|
+
workspaceRef: input.workspaceRef,
|
|
2384
|
+
instance: input.instance,
|
|
2385
|
+
...(input.identity?.environmentId !== undefined
|
|
2386
|
+
? { environmentId: input.identity.environmentId }
|
|
2387
|
+
: {}),
|
|
2388
|
+
...(input.identity?.sessionId !== undefined ? { sessionId: input.identity.sessionId } : {}),
|
|
2389
|
+
operation: input.operation,
|
|
2390
|
+
error: input.error,
|
|
2391
|
+
cleanupPending: input.cleanupPending,
|
|
2392
|
+
},
|
|
2393
|
+
)
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
/** Start one workspace-adjacent executor without handing it workspace,
|
|
2397
|
+
* config, or outcome channels. Capacity and local single-flight remain
|
|
2398
|
+
* kernel decisions; the kernel reserves the durable execution lease before
|
|
2399
|
+
* launch and retains it until the executor has reaped its complete tree. */
|
|
2400
|
+
private async launchRunner(slug: string): Promise<LaunchRunnerResult> {
|
|
2401
|
+
if (!this.acceptingBuildLaunches || this.activeBuildRuns.has(slug)) return 'already-active'
|
|
2402
|
+
|
|
2403
|
+
const reservation = Symbol(slug)
|
|
2404
|
+
const instance = `${this.host}-${slug}-${this.wiring.ids('inst')}`
|
|
2405
|
+
const active: ActiveBuildExecution = { reservation, instance, stopping: false }
|
|
2406
|
+
this.activeBuildRuns.set(slug, active)
|
|
2407
|
+
let leaseClaimed = false
|
|
2408
|
+
|
|
2409
|
+
try {
|
|
2410
|
+
await this.publishBuildConfig(slug)
|
|
2411
|
+
leaseClaimed = await this.wiring.store.claimLease(
|
|
2412
|
+
slug,
|
|
2413
|
+
instance,
|
|
2414
|
+
BUILD_EXECUTION_LEASE_TTL_MS,
|
|
2415
|
+
)
|
|
2416
|
+
if (!leaseClaimed) {
|
|
2417
|
+
this.activeBuildRuns.delete(slug)
|
|
2418
|
+
await this.appendStatus({
|
|
2419
|
+
actor: DISPATCHER,
|
|
2420
|
+
type: 'dispatcher.runner-settled',
|
|
2421
|
+
payload: { run: this.runId, slug, outcome: 'lease-held' },
|
|
2422
|
+
})
|
|
2423
|
+
this.failureNotice(`build ${slug} already held by another runner — skipped`)
|
|
2424
|
+
return 'already-active'
|
|
2425
|
+
}
|
|
2426
|
+
const launchEvents = await this.wiring.store.getEvents(slug)
|
|
2427
|
+
let workspaceRef: string | undefined
|
|
2428
|
+
let workspaceProvider: string | undefined
|
|
2429
|
+
for (const event of launchEvents) {
|
|
2430
|
+
if (event.type === 'workspace.provisioned') {
|
|
2431
|
+
workspaceRef = event.payload.ref
|
|
2432
|
+
workspaceProvider = event.payload.provider
|
|
2433
|
+
} else if (event.type === 'workspace.released') {
|
|
2434
|
+
workspaceRef = undefined
|
|
2435
|
+
workspaceProvider = undefined
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
if (workspaceRef === undefined) throw new Error(`build ${slug} has no open workspace`)
|
|
2439
|
+
// Execute through the runtime that owns the workspace: a build
|
|
2440
|
+
// provisioned before a provider switch still runs where it lives.
|
|
2441
|
+
const owning =
|
|
2442
|
+
workspaceProvider === undefined || workspaceProvider === this.wiring.workspaces.name
|
|
2443
|
+
? { name: this.wiring.workspaces.name, execution: this.wiring.buildExecution }
|
|
2444
|
+
: (() => {
|
|
2445
|
+
const retired = this.wiring.retiredWorkspaces?.find(
|
|
2446
|
+
(runtime) => runtime.provider.name === workspaceProvider,
|
|
2447
|
+
)
|
|
2448
|
+
if (retired === undefined) {
|
|
2449
|
+
throw new Error(
|
|
2450
|
+
`build ${slug} workspace ${workspaceRef} belongs to provider "${workspaceProvider}", which is no longer configured`,
|
|
2451
|
+
)
|
|
2452
|
+
}
|
|
2453
|
+
return { name: retired.provider.name, execution: retired.execution }
|
|
2454
|
+
})()
|
|
2455
|
+
// A prior publication acknowledgement may have been lost after its push
|
|
2456
|
+
// reached the durable branch. Reconcile that fact before a replacement
|
|
2457
|
+
// runner can rerun the phase and create a non-fast-forward successor.
|
|
2458
|
+
await this.settlePendingPublication(slug)
|
|
2459
|
+
const handle = await owning.execution.start({
|
|
2460
|
+
slug,
|
|
2461
|
+
storeRef: this.wiring.storeRef,
|
|
2462
|
+
instance,
|
|
2463
|
+
workspaceRef,
|
|
2464
|
+
})
|
|
2465
|
+
active.handle = handle
|
|
2466
|
+
const identity = handle.identity ?? {
|
|
2467
|
+
provider: owning.name,
|
|
2468
|
+
workspaceRef,
|
|
2469
|
+
}
|
|
2470
|
+
await this.wiring.store.append(slug, {
|
|
2471
|
+
actor: DISPATCHER,
|
|
2472
|
+
type: 'execution.started',
|
|
2473
|
+
payload: { ...identity, instance },
|
|
2474
|
+
})
|
|
2475
|
+
|
|
2476
|
+
let tracked: Promise<void>
|
|
2477
|
+
tracked = handle.completion
|
|
2478
|
+
.then(
|
|
2479
|
+
async (exit) => {
|
|
2480
|
+
// A detached execution's guest keeps running: no `execution.ended`,
|
|
2481
|
+
// no lease release, no publication settlement — the next
|
|
2482
|
+
// supervisor settles the execution from the Store.
|
|
2483
|
+
if (active.detaching) return
|
|
2484
|
+
try {
|
|
2485
|
+
await this.wiring.store.append(slug, {
|
|
2486
|
+
actor: DISPATCHER,
|
|
2487
|
+
type: 'execution.ended',
|
|
2488
|
+
payload: {
|
|
2489
|
+
instance,
|
|
2490
|
+
workspaceRef,
|
|
2491
|
+
outcome: active.stopping ? 'stopped' : 'completed',
|
|
2492
|
+
exitCode: exit.exitCode,
|
|
2493
|
+
},
|
|
2494
|
+
})
|
|
2495
|
+
if (active.stopping) return
|
|
2496
|
+
const diagnostic = await this.matchingRunnerDiagnostic(slug, instance)
|
|
2497
|
+
if (diagnostic?.outcome === 'lease-held') {
|
|
2498
|
+
await this.appendStatus({
|
|
2499
|
+
actor: DISPATCHER,
|
|
2500
|
+
type: 'dispatcher.runner-settled',
|
|
2501
|
+
payload: { run: this.runId, slug, outcome: 'lease-held' },
|
|
2502
|
+
})
|
|
2503
|
+
this.failureNotice(`build ${slug} already held by another runner — skipped`)
|
|
2504
|
+
return
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
const state = reduceBuild(await this.wiring.store.getEvents(slug))
|
|
2508
|
+
if (diagnostic === null && exit.exitCode === 0) {
|
|
2509
|
+
await this.appendStatus({
|
|
2510
|
+
actor: DISPATCHER,
|
|
2511
|
+
type: 'dispatcher.runner-settled',
|
|
2512
|
+
payload: {
|
|
2513
|
+
run: this.runId,
|
|
2514
|
+
slug,
|
|
2515
|
+
outcome: 'parked',
|
|
2516
|
+
status: state.status,
|
|
2517
|
+
},
|
|
2518
|
+
})
|
|
2519
|
+
this.failureNotice(`build ${slug} parked (${state.status})`)
|
|
2520
|
+
return
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
const detail =
|
|
2524
|
+
diagnostic?.error ??
|
|
2525
|
+
`child exited ${exit.exitCode ?? 'without status'}${exit.signal ? ` (${exit.signal})` : ''}`
|
|
2526
|
+
await this.appendStatus({
|
|
2527
|
+
actor: DISPATCHER,
|
|
2528
|
+
type: 'dispatcher.runner-settled',
|
|
2529
|
+
payload: { run: this.runId, slug, outcome: 'failed', error: detail },
|
|
2530
|
+
})
|
|
2531
|
+
this.warn(`build ${slug} runner failed: ${detail}`)
|
|
2532
|
+
} finally {
|
|
2533
|
+
await this.wiring.store.releaseLease(slug, instance)
|
|
2534
|
+
await this.settlePendingPublication(slug)
|
|
2535
|
+
}
|
|
2536
|
+
},
|
|
2537
|
+
async (error) => {
|
|
2538
|
+
// A detached wait is teardown of local supervision, not a guest
|
|
2539
|
+
// failure; the next supervisor re-observes the execution.
|
|
2540
|
+
if (active.detaching) return
|
|
2541
|
+
// A rejected executor completion cannot prove the remote VM was
|
|
2542
|
+
// stopped. Keep the lease until expiry: recovery fences and reaps
|
|
2543
|
+
// the exact recorded identity before authorizing a replacement.
|
|
2544
|
+
await this.recordInfrastructureFailure({
|
|
2545
|
+
slug,
|
|
2546
|
+
instance,
|
|
2547
|
+
workspaceRef,
|
|
2548
|
+
operation: 'wait',
|
|
2549
|
+
error,
|
|
2550
|
+
cleanupPending: true,
|
|
2551
|
+
identity,
|
|
2552
|
+
})
|
|
2553
|
+
},
|
|
2554
|
+
)
|
|
2555
|
+
.finally(() => {
|
|
2556
|
+
this.inFlightKinds.delete(tracked)
|
|
2557
|
+
this.inFlight.delete(tracked)
|
|
2558
|
+
if (this.activeBuildRuns.get(slug)?.reservation === reservation) {
|
|
2559
|
+
this.activeBuildRuns.delete(slug)
|
|
2560
|
+
}
|
|
2561
|
+
})
|
|
2562
|
+
active.settled = tracked
|
|
2563
|
+
this.inFlight.add(tracked)
|
|
2564
|
+
this.inFlightKinds.set(tracked, handle.supervision)
|
|
2565
|
+
return 'scheduled'
|
|
2566
|
+
} catch (error) {
|
|
2567
|
+
if (this.activeBuildRuns.get(slug)?.reservation === reservation) {
|
|
2568
|
+
this.activeBuildRuns.delete(slug)
|
|
2569
|
+
}
|
|
2570
|
+
if (leaseClaimed) {
|
|
2571
|
+
const events = await this.wiring.store.getEvents(slug)
|
|
2572
|
+
let workspaceRef = ''
|
|
2573
|
+
for (const event of events) {
|
|
2574
|
+
if (event.type === 'workspace.provisioned') workspaceRef = event.payload.ref
|
|
2575
|
+
else if (event.type === 'workspace.released') workspaceRef = ''
|
|
2576
|
+
}
|
|
2577
|
+
if (workspaceRef !== '') {
|
|
2578
|
+
await this.recordInfrastructureFailure({
|
|
2579
|
+
slug,
|
|
2580
|
+
instance,
|
|
2581
|
+
workspaceRef,
|
|
2582
|
+
operation: 'start',
|
|
2583
|
+
error,
|
|
2584
|
+
cleanupPending: this.wiring.workspaces.recovery !== undefined,
|
|
2585
|
+
})
|
|
2586
|
+
}
|
|
2587
|
+
// Local start failures have no possible remote holder. Remote failures
|
|
2588
|
+
// retain the lease until its TTL fences an ambiguous start.
|
|
2589
|
+
if (this.wiring.workspaces.recovery === undefined) {
|
|
2590
|
+
await this.wiring.store.releaseLease(slug, instance)
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
await this.appendStatus({
|
|
2594
|
+
actor: DISPATCHER,
|
|
2595
|
+
type: 'dispatcher.runner-settled',
|
|
2596
|
+
payload: {
|
|
2597
|
+
run: this.runId,
|
|
2598
|
+
slug,
|
|
2599
|
+
outcome: 'launch-failed',
|
|
2600
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2601
|
+
},
|
|
2602
|
+
})
|
|
2603
|
+
throw error
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
private get stopped(): boolean {
|
|
2608
|
+
return this.opts.signal?.aborted === true || this.inputStop.signal.aborted
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
/** `--once` awaits everything in `inFlight` that is not tagged
|
|
2612
|
+
* 'environment': `local-parent` build executions, locally run harvest, and
|
|
2613
|
+
* hosted harvest. Hosted harvest runs in a disposable guest but carries no
|
|
2614
|
+
* 'environment' tag (launchHarvest never sets inFlightKinds), so `--once`
|
|
2615
|
+
* awaits it and one invocation reaches a durable boundary — per the
|
|
2616
|
+
* launchHarvest doc comment. The provisioning drain is awaited too (a
|
|
2617
|
+
* sandbox is created, bootstrapped, and launched by the host, so leaving
|
|
2618
|
+
* early would abandon it at teardown and the next invocation would start
|
|
2619
|
+
* over). Only build executions launched with environment supervision are
|
|
2620
|
+
* excluded (tagged at the build-launch site): they keep running in their
|
|
2621
|
+
* guest, and a later invocation settles them from the Store alone. An
|
|
2622
|
+
* invocation deadline stops the await loop; the remaining `local-parent`
|
|
2623
|
+
* executions are then stopped and reaped by the `stopBuildExecutions()`
|
|
2624
|
+
* teardown, exactly as a deliberately stopped watch run, and an unfinished
|
|
2625
|
+
* provisioning is recorded as abandoned for the next supervisor to adopt. */
|
|
2626
|
+
private async drainInFlight(): Promise<void> {
|
|
2627
|
+
for (;;) {
|
|
2628
|
+
const pending: Promise<unknown>[] = [...this.inFlight].filter(
|
|
2629
|
+
(promise) => this.inFlightKinds.get(promise) !== 'environment',
|
|
2630
|
+
)
|
|
2631
|
+
if (this.dispatcher.provisioningInFlight()) {
|
|
2632
|
+
pending.push(this.dispatcher.drainProvisioning())
|
|
2633
|
+
}
|
|
2634
|
+
if (pending.length === 0) return
|
|
2635
|
+
const deadline = this.opts.deadlineAt
|
|
2636
|
+
if (deadline === undefined) {
|
|
2637
|
+
await Promise.all(pending)
|
|
2638
|
+
continue
|
|
2639
|
+
}
|
|
2640
|
+
if (this.pastDeadline()) return
|
|
2641
|
+
const remainingMs = Math.max(0, deadline - this.wiring.clock().getTime())
|
|
2642
|
+
const timers = this.opts.timers ?? DEFAULT_TIMERS
|
|
2643
|
+
let expired = false
|
|
2644
|
+
// The expiry handle is captured and cleared on BOTH race outcomes: when
|
|
2645
|
+
// the build set wins, `clearTimeout` runs here too, so no pending expiry
|
|
2646
|
+
// handle outlives the invocation (obs_e7fb42f0). When the timer wins, it
|
|
2647
|
+
// has already fired and the clear is a no-op.
|
|
2648
|
+
let expiryHandle: unknown
|
|
2649
|
+
await Promise.race([
|
|
2650
|
+
Promise.all(pending),
|
|
2651
|
+
new Promise<void>((resolveRace) => {
|
|
2652
|
+
expiryHandle = timers.setTimeout(() => {
|
|
2653
|
+
expired = true
|
|
2654
|
+
resolveRace()
|
|
2655
|
+
}, remainingMs)
|
|
2656
|
+
}),
|
|
2657
|
+
])
|
|
2658
|
+
timers.clearTimeout(expiryHandle)
|
|
2659
|
+
if (expired) return
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
/** Teardown distinguishes supervision kinds. A local-parent execution is
|
|
2664
|
+
* stopped and reaped exactly as before. An environment-supervised execution
|
|
2665
|
+
* is DETACHED: the guest keeps running and heartbeating its lease; the next
|
|
2666
|
+
* invocation settles the execution from the Store plus provider liveness
|
|
2667
|
+
* (AC: a restarted dispatcher never replaces a healthy environment). */
|
|
2668
|
+
private async stopBuildExecutions(): Promise<void> {
|
|
2669
|
+
this.acceptingBuildLaunches = false
|
|
2670
|
+
const active = [...this.activeBuildRuns.entries()]
|
|
2671
|
+
for (const [, entry] of active) {
|
|
2672
|
+
if (entry.handle?.supervision === 'environment') entry.detaching = true
|
|
2673
|
+
else entry.stopping = true
|
|
2674
|
+
}
|
|
2675
|
+
await Promise.all(
|
|
2676
|
+
active.map(async ([slug, entry]) => {
|
|
2677
|
+
if (entry.handle?.supervision === 'environment') {
|
|
2678
|
+
// Abort the local wait only — no command kill, no environment stop,
|
|
2679
|
+
// no lease release, no durable end. Drop the tracking so the
|
|
2680
|
+
// process can exit while the guest outlives it.
|
|
2681
|
+
try {
|
|
2682
|
+
await entry.handle.detach()
|
|
2683
|
+
} catch {
|
|
2684
|
+
// Detach is best-effort: an unresolvable wait still exits.
|
|
2685
|
+
}
|
|
2686
|
+
this.activeBuildRuns.delete(slug)
|
|
2687
|
+
if (entry.settled !== undefined) {
|
|
2688
|
+
this.inFlight.delete(entry.settled)
|
|
2689
|
+
this.inFlightKinds.delete(entry.settled)
|
|
2690
|
+
}
|
|
2691
|
+
return
|
|
2692
|
+
}
|
|
2693
|
+
const result = await entry.handle?.stop()
|
|
2694
|
+
if (result?.outcome === 'unknown') {
|
|
2695
|
+
await this.recordInfrastructureFailure({
|
|
2696
|
+
slug,
|
|
2697
|
+
instance: entry.instance,
|
|
2698
|
+
workspaceRef: entry.handle?.identity?.workspaceRef ?? slug,
|
|
2699
|
+
operation: 'stop',
|
|
2700
|
+
error: result.error,
|
|
2701
|
+
cleanupPending: true,
|
|
2702
|
+
identity: entry.handle?.identity,
|
|
2703
|
+
})
|
|
2704
|
+
// Do not wait forever for command.wait after an ambiguous stop. The
|
|
2705
|
+
// durable lease intentionally remains until TTL expiry and fencing.
|
|
2706
|
+
return
|
|
2707
|
+
}
|
|
2708
|
+
await entry.settled
|
|
2709
|
+
}),
|
|
2710
|
+
)
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
// ── Message routing ───────────────────────────────────────────────────────
|
|
2714
|
+
//
|
|
2715
|
+
// The interactive frame is the dashboard's ONLY output surface. Routine
|
|
2716
|
+
// notices are intentionally silent there; only warnings/errors become a
|
|
2717
|
+
// process-local warning row. A concurrent store poll overlays the latest
|
|
2718
|
+
// warning again before painting, so an older projection cannot erase it.
|
|
2719
|
+
// Plain/non-interactive mode keeps the existing line sinks exactly.
|
|
2720
|
+
|
|
2721
|
+
private setWarning(line: string): void {
|
|
2722
|
+
this.warningLine = line
|
|
2723
|
+
this.syncModelControls()
|
|
2724
|
+
this.paint()
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
private say(line: string): void {
|
|
2728
|
+
if (!this.dashboard && this.opts.silent !== true) this.opts.stdout(line)
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
/** Routine acknowledgement: line-oriented stdout, dashboard-silent. */
|
|
2732
|
+
private announce(line: string): void {
|
|
2733
|
+
if (!this.dashboard) this.opts.stdout(line)
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
private warn(line: string): void {
|
|
2737
|
+
if (this.dashboard) this.setWarning(line)
|
|
2738
|
+
else if (this.opts.silent !== true) this.opts.stderr(line)
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
/** Failure-severity dashboard notice whose historical plain sink is stdout. */
|
|
2742
|
+
private failureNotice(line: string): void {
|
|
2743
|
+
if (this.dashboard) this.setWarning(line)
|
|
2744
|
+
else if (this.opts.silent !== true) this.opts.stdout(line)
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
/** Routine diagnostic whose historical line-oriented sink is stderr. */
|
|
2748
|
+
private routineDiagnostic(line: string): void {
|
|
2749
|
+
if (!this.dashboard && this.opts.silent !== true) this.opts.stderr(line)
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
/**
|
|
2753
|
+
* Janitor, ticket, and dependency diagnostics are independent notices
|
|
2754
|
+
* (line-oriented in plain mode; on a TTY only warning-severity diagnostics
|
|
2755
|
+
* are rendered). Contained janitor failures and invalid ticket records use
|
|
2756
|
+
* the warning seam so scripted JSON/stdout consumers stay clean; dependency
|
|
2757
|
+
* holds retain their ordinary notice seam. In line-oriented mode this is the
|
|
2758
|
+
* operator's attributed view of why a build or ready ticket is sitting still,
|
|
2759
|
+
* with no provider, filesystem, or database inspection. The counts map
|
|
2760
|
+
* guards on `typeof count === 'number'`
|
|
2761
|
+
* because a non-numeric TickReport field would otherwise be dropped here
|
|
2762
|
+
* silently (`count > 0` is false for an array, with no type error).
|
|
2763
|
+
*
|
|
2764
|
+
* `say()` is the plain stdout identity and intentionally silent on the
|
|
2765
|
+
* dashboard, so line-oriented behavior is unchanged while routine
|
|
2766
|
+
* interactive chatter disappears.
|
|
2767
|
+
*/
|
|
2768
|
+
/**
|
|
2769
|
+
* A startup, configuration-level notice (§9 role-key consumability) — never a
|
|
2770
|
+
* tick outcome, never blocking. Both surfaces get the SAME strings in full:
|
|
2771
|
+
* stderr writes each one, the dashboard wraps them into its warning region.
|
|
2772
|
+
* No surface gets a digest, a cap, or a truncated tail.
|
|
2773
|
+
*/
|
|
2774
|
+
private reportRoleDiagnostics(): void {
|
|
2775
|
+
const lines = roleKeyWarnings(this.currentConfig().config)
|
|
2776
|
+
if (this.dashboard) {
|
|
2777
|
+
this.configWarnings = lines
|
|
2778
|
+
this.syncModelControls()
|
|
2779
|
+
this.paint()
|
|
2780
|
+
} else {
|
|
2781
|
+
for (const line of lines) this.opts.stderr(line)
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
private printReport(report: Awaited<ReturnType<Dispatcher['tick']>>, printIdle = true): boolean {
|
|
2786
|
+
// `queued` is a standing depth, not a tick action — the header owns it;
|
|
2787
|
+
// repeating it in the notice would make every saturated tick look busy.
|
|
2788
|
+
const {
|
|
2789
|
+
janitorDiagnostics,
|
|
2790
|
+
blockedDiagnostics,
|
|
2791
|
+
ticketDiagnostics,
|
|
2792
|
+
creationDiagnostics,
|
|
2793
|
+
dependencyDiagnostics,
|
|
2794
|
+
queued: _queued,
|
|
2795
|
+
...counts
|
|
2796
|
+
} = report
|
|
2797
|
+
for (const line of janitorDiagnostics) this.routineDiagnostic(line)
|
|
2798
|
+
for (const line of blockedDiagnostics) this.say(line)
|
|
2799
|
+
for (const line of ticketDiagnostics) this.routineDiagnostic(line)
|
|
2800
|
+
for (const line of creationDiagnostics) this.say(line)
|
|
2801
|
+
for (const line of dependencyDiagnostics) this.say(line)
|
|
2802
|
+
const parts = Object.entries(counts)
|
|
2803
|
+
.filter(([, count]) => typeof count === 'number' && count > 0)
|
|
2804
|
+
.map(([name, count]) => `${name}=${count}`)
|
|
2805
|
+
if (parts.length > 0) {
|
|
2806
|
+
this.say(`tick: ${parts.join(' ')}`)
|
|
2807
|
+
return true
|
|
2808
|
+
}
|
|
2809
|
+
const reportedDiagnostic =
|
|
2810
|
+
janitorDiagnostics.length > 0 ||
|
|
2811
|
+
blockedDiagnostics.length > 0 ||
|
|
2812
|
+
ticketDiagnostics.length > 0 ||
|
|
2813
|
+
creationDiagnostics.length > 0 ||
|
|
2814
|
+
dependencyDiagnostics.length > 0
|
|
2815
|
+
if (reportedDiagnostic) return true
|
|
2816
|
+
// A tick that did something is worth a plain line. Interactive mode
|
|
2817
|
+
// suppresses both action counts and the every-10s idle noise.
|
|
2818
|
+
if (!this.dashboard && printIdle) {
|
|
2819
|
+
this.opts.stdout('tick: idle')
|
|
2820
|
+
return true
|
|
2821
|
+
}
|
|
2822
|
+
return false
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
// ── The live region ───────────────────────────────────────────────────────
|
|
2826
|
+
|
|
2827
|
+
/**
|
|
2828
|
+
* The store READ half of a frame: discover builds, incrementally refresh
|
|
2829
|
+
* dashboard-visible streams, combine their cached projections with a fresh
|
|
2830
|
+
* repository-journal projection, then paint. Read-only — it appends nothing
|
|
2831
|
+
* and decides nothing. Paint is split out so the watch-mode tick timer can
|
|
2832
|
+
* repaint cached timing against a moving clock without re-reading.
|
|
2833
|
+
*/
|
|
2834
|
+
private async renderOnce(): Promise<void> {
|
|
2835
|
+
const { terminal } = this.opts
|
|
2836
|
+
if (this.region === undefined || terminal === undefined) return
|
|
2837
|
+
const configSnapshot = this.currentConfig()
|
|
2838
|
+
const buildSnapshot = await this.dashboardBuilds.refresh(
|
|
2839
|
+
configSnapshot.config,
|
|
2840
|
+
configSnapshot.revision,
|
|
2841
|
+
)
|
|
2842
|
+
const repoRecord = await this.wiring.store.getRepo(this.repoIdentity)
|
|
2843
|
+
const repositoryEvents =
|
|
2844
|
+
repoRecord === null ? [] : await this.wiring.store.getRepoEvents(this.repoIdentity)
|
|
2845
|
+
|
|
2846
|
+
// Action-triggered and timer refreshes share the cache but may finish their
|
|
2847
|
+
// repository reads out of order. Never let an older build snapshot replace
|
|
2848
|
+
// one that committed later.
|
|
2849
|
+
if (!this.dashboardBuilds.isCurrent(buildSnapshot)) return
|
|
2850
|
+
|
|
2851
|
+
// Polling continues while the operator types. Keep the prompt bound to the
|
|
2852
|
+
// captured build/escalations, shrink it around externally answered ids, and
|
|
2853
|
+
// clear it rather than ever retargeting feedback to a newly selected row.
|
|
2854
|
+
if (this.resumePrompt !== undefined) {
|
|
2855
|
+
const prompt = this.resumePrompt
|
|
2856
|
+
const state = buildSnapshot.states.get(prompt.slug)
|
|
2857
|
+
const active = state !== undefined && ['running', 'paused', 'blocked'].includes(state.status)
|
|
2858
|
+
const openIds = new Set(state?.openEscalations.map((item) => item.id) ?? [])
|
|
2859
|
+
const remaining = prompt.escalationIds.filter((id) => openIds.has(id))
|
|
2860
|
+
if (!active || remaining.length === 0) {
|
|
2861
|
+
this.resumePrompt = undefined
|
|
2862
|
+
} else if (remaining.length !== prompt.escalationIds.length) {
|
|
2863
|
+
this.resumePrompt = { ...prompt, escalationIds: remaining }
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
if (this.abortConfirmation !== undefined) {
|
|
2867
|
+
const state = buildSnapshot.states.get(this.abortConfirmation.slug)
|
|
2868
|
+
if (
|
|
2869
|
+
state === undefined ||
|
|
2870
|
+
!['queued', 'running', 'paused', 'blocked'].includes(state.status) ||
|
|
2871
|
+
state.pendingCommands.some((command) => command.command === 'abort')
|
|
2872
|
+
) {
|
|
2873
|
+
// Another process may have durably requested the abort while this
|
|
2874
|
+
// process-local prompt was open. Never leave a stale confirmation up.
|
|
2875
|
+
this.abortConfirmation = undefined
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
const previousRows = this.model === undefined ? [] : dashboardSelections(this.model)
|
|
2880
|
+
const projected = buildDashboardFromProjected(
|
|
2881
|
+
buildSnapshot.builds,
|
|
2882
|
+
{
|
|
2883
|
+
repo: this.repoIdentity,
|
|
2884
|
+
queued: this.queuedCount,
|
|
2885
|
+
activeCount: [...buildSnapshot.states.values()].filter(
|
|
2886
|
+
(state) => state.status !== 'done' && state.status !== 'aborted',
|
|
2887
|
+
).length,
|
|
2888
|
+
capacity: configSnapshot.config.capacity,
|
|
2889
|
+
observationCount: this.observationCount,
|
|
2890
|
+
observationLimit: configSnapshot.config.policy.harvestThreshold,
|
|
2891
|
+
},
|
|
2892
|
+
repositoryEvents,
|
|
2893
|
+
)
|
|
2894
|
+
const nextRows = dashboardSelections(projected)
|
|
2895
|
+
this.selection = reconcileSelection(previousRows, nextRows, this.selection)
|
|
2896
|
+
|
|
2897
|
+
if (this.view !== undefined) {
|
|
2898
|
+
const build = projected.builds.find((candidate) => candidate.slug === this.view!.slug)
|
|
2899
|
+
if (build === undefined) {
|
|
2900
|
+
// Detail follows dashboard visibility: acknowledged abort cleanup stays
|
|
2901
|
+
// open, and final completion returns the operator to the list.
|
|
2902
|
+
this.view = undefined
|
|
2903
|
+
} else if (this.view.kind === 'detail') {
|
|
2904
|
+
const detail = this.view
|
|
2905
|
+
const sessions = build.sessions ?? []
|
|
2906
|
+
const selected = sessions.some((session) => session.id === detail.sessionId)
|
|
2907
|
+
? detail.sessionId
|
|
2908
|
+
: sessions[0]?.id
|
|
2909
|
+
const messageStillValid =
|
|
2910
|
+
detail.messageWhileSessionOpen === undefined ||
|
|
2911
|
+
sessions.some(
|
|
2912
|
+
(session) => session.id === detail.messageWhileSessionOpen && session.status === 'open',
|
|
2913
|
+
)
|
|
2914
|
+
const {
|
|
2915
|
+
message: priorMessage,
|
|
2916
|
+
messageWhileSessionOpen: priorMessageFence,
|
|
2917
|
+
sessionId: _priorSession,
|
|
2918
|
+
...stableDetail
|
|
2919
|
+
} = detail
|
|
2920
|
+
this.view = {
|
|
2921
|
+
...stableDetail,
|
|
2922
|
+
...(selected !== undefined ? { sessionId: selected } : {}),
|
|
2923
|
+
...(messageStillValid && priorMessage !== undefined
|
|
2924
|
+
? {
|
|
2925
|
+
message: priorMessage,
|
|
2926
|
+
...(priorMessageFence !== undefined
|
|
2927
|
+
? { messageWhileSessionOpen: priorMessageFence }
|
|
2928
|
+
: {}),
|
|
2929
|
+
}
|
|
2930
|
+
: {}),
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
this.model = projected
|
|
2936
|
+
this.syncModelControls()
|
|
2937
|
+
this.paint()
|
|
2938
|
+
// The session view's live-read cycle rides the poll tick: one immediate
|
|
2939
|
+
// read per interval, applied through the serialize queue under the fence.
|
|
2940
|
+
this.pollSessionFeed()
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
/**
|
|
2944
|
+
* The PAINT half: render the cached model against the CURRENT clock and
|
|
2945
|
+
* repaint. `now` is what makes a running step's elapsed advance (AC 8); the
|
|
2946
|
+
* identical-frame check in `LiveRegion.update` collapses a repaint whose
|
|
2947
|
+
* displayed second is unchanged to zero writes. No store I/O.
|
|
2948
|
+
*/
|
|
2949
|
+
private paint(): void {
|
|
2950
|
+
const { terminal } = this.opts
|
|
2951
|
+
if (this.region === undefined || terminal === undefined || this.model === undefined) return
|
|
2952
|
+
const renderer = this.opts.resolveDashboardRenderer?.() ?? renderDashboard
|
|
2953
|
+
this.region.update(
|
|
2954
|
+
renderer(this.model, {
|
|
2955
|
+
color: true,
|
|
2956
|
+
width: terminal.columns,
|
|
2957
|
+
// NOT `terminal.rows` — the region's trailing newline needs a row of
|
|
2958
|
+
// its own, so a frame of exactly `rows` scrolls its own header off.
|
|
2959
|
+
// See `paintableRows`.
|
|
2960
|
+
height: paintableRows(terminal.rows),
|
|
2961
|
+
now: this.wiring.clock().getTime(),
|
|
2962
|
+
}),
|
|
2963
|
+
)
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
private startUpgradeChecks(): void {
|
|
2967
|
+
if (
|
|
2968
|
+
(!this.dashboard && this.opts.kernelRunId === undefined) ||
|
|
2969
|
+
this.opts.once === true ||
|
|
2970
|
+
this.stopUpgradeNotice !== undefined
|
|
2971
|
+
) {
|
|
2972
|
+
return
|
|
2973
|
+
}
|
|
2974
|
+
const probe =
|
|
2975
|
+
this.opts.availableReleaseProbe ?? ((signal: AbortSignal) => availableRelease({ signal }))
|
|
2976
|
+
try {
|
|
2977
|
+
this.stopUpgradeNotice = startUpgradeNotice({
|
|
2978
|
+
probe,
|
|
2979
|
+
...(this.opts.upgradeNoticeScheduler !== undefined
|
|
2980
|
+
? { scheduler: this.opts.upgradeNoticeScheduler }
|
|
2981
|
+
: {}),
|
|
2982
|
+
onAvailable: (version) => {
|
|
2983
|
+
if (this.availableUpgrade !== undefined && !semver.gt(version, this.availableUpgrade)) {
|
|
2984
|
+
return
|
|
2985
|
+
}
|
|
2986
|
+
this.availableUpgrade = version
|
|
2987
|
+
void this.appendStatus({
|
|
2988
|
+
actor: DISPATCHER,
|
|
2989
|
+
type: 'dispatcher.upgrade-available',
|
|
2990
|
+
payload: { run: this.runId, version },
|
|
2991
|
+
}).catch(() => {
|
|
2992
|
+
// Release discovery and publication are a silent courtesy.
|
|
2993
|
+
})
|
|
2994
|
+
this.syncModelControls()
|
|
2995
|
+
this.paint()
|
|
2996
|
+
},
|
|
2997
|
+
})
|
|
2998
|
+
} catch {
|
|
2999
|
+
// Timer/probe setup is the same silent courtesy as the check itself.
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
private startRendering(): void {
|
|
3004
|
+
if (!this.dashboard || this.timer !== undefined) return
|
|
3005
|
+
this.acceptingRenderPolls = true
|
|
3006
|
+
const tick = (): void => {
|
|
3007
|
+
if (!this.acceptingRenderPolls || this.rendering) return // fenced, no overlap
|
|
3008
|
+
this.rendering = true
|
|
3009
|
+
let handled!: Promise<void>
|
|
3010
|
+
handled = this.renderOnce()
|
|
3011
|
+
.catch((error: unknown) => {
|
|
3012
|
+
// A transient store error must never kill dispatch — the dashboard
|
|
3013
|
+
// is a view, and a view that throws is a bug in the view.
|
|
3014
|
+
this.warn(
|
|
3015
|
+
`dashboard render failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
3016
|
+
)
|
|
3017
|
+
})
|
|
3018
|
+
.finally(() => {
|
|
3019
|
+
// Identity matters if lifecycle code ever stops and restarts polling:
|
|
3020
|
+
// an older completion must not clear a replacement poll's guard.
|
|
3021
|
+
if (this.renderInFlight !== handled) return
|
|
3022
|
+
this.renderInFlight = undefined
|
|
3023
|
+
this.rendering = false
|
|
3024
|
+
})
|
|
3025
|
+
this.renderInFlight = handled
|
|
3026
|
+
}
|
|
3027
|
+
this.timer = setInterval(tick, DASHBOARD_POLL_MS)
|
|
3028
|
+
// Never hold the process open for a redraw.
|
|
3029
|
+
this.timer.unref?.()
|
|
3030
|
+
// Watch mode only: a second, cheap timer repaints the cached model so a
|
|
3031
|
+
// running step's elapsed advances ~1×/s decoupled from the store poll
|
|
3032
|
+
// (AC 8). `--once` renders a single snapshot per state, so it gets no ticker.
|
|
3033
|
+
if (this.opts.once !== true) {
|
|
3034
|
+
this.tickTimer = setInterval(() => this.paint(), DASHBOARD_TICK_MS)
|
|
3035
|
+
this.tickTimer.unref?.()
|
|
3036
|
+
}
|
|
3037
|
+
tick()
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
private async stopRendering(): Promise<void> {
|
|
3041
|
+
// Clear both timer sources before yielding. The boolean also fences a timer
|
|
3042
|
+
// callback that was already queued when clearInterval ran.
|
|
3043
|
+
if (this.timer !== undefined) clearInterval(this.timer)
|
|
3044
|
+
this.timer = undefined
|
|
3045
|
+
if (this.tickTimer !== undefined) clearInterval(this.tickTimer)
|
|
3046
|
+
this.tickTimer = undefined
|
|
3047
|
+
this.acceptingRenderPolls = false
|
|
3048
|
+
|
|
3049
|
+
// The poll owns model/controller mutation as well as terminal painting, so
|
|
3050
|
+
// join it rather than merely suppressing a late LiveRegion.update(). Its
|
|
3051
|
+
// normal rejection is warning-handled above; the catch keeps teardown safe
|
|
3052
|
+
// even if reporting that warning itself throws.
|
|
3053
|
+
const inFlight = this.renderInFlight
|
|
3054
|
+
if (inFlight !== undefined) {
|
|
3055
|
+
try {
|
|
3056
|
+
await inFlight
|
|
3057
|
+
} catch {
|
|
3058
|
+
// Dashboard rendering remains best-effort during teardown.
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
/** Stop polling, paint the truth one last time, release the region. Every
|
|
3064
|
+
* exit path runs this — including SIGINT — or the operator's shell is left
|
|
3065
|
+
* without a cursor. */
|
|
3066
|
+
private async finishRendering(): Promise<void> {
|
|
3067
|
+
// Stop network/process discovery synchronously before any final dashboard
|
|
3068
|
+
// read. A probe that ignores cancellation is never joined. The private
|
|
3069
|
+
// kernel owns this courtesy even though it owns no terminal rendering.
|
|
3070
|
+
this.stopUpgradeNotice?.()
|
|
3071
|
+
this.stopUpgradeNotice = undefined
|
|
3072
|
+
if (!this.dashboard) return
|
|
3073
|
+
// No new keys or polls may begin once teardown starts. Already queued
|
|
3074
|
+
// actions finish before the final truth is painted and raw mode/cursor are
|
|
3075
|
+
// considered released.
|
|
3076
|
+
try {
|
|
3077
|
+
try {
|
|
3078
|
+
this.stopInput()
|
|
3079
|
+
} catch (error) {
|
|
3080
|
+
// Cursor restoration must not be skipped just because stdin restoration
|
|
3081
|
+
// itself failed. Keep the failure visible in the final warning row when
|
|
3082
|
+
// the presentation seam is still usable.
|
|
3083
|
+
try {
|
|
3084
|
+
this.warn(
|
|
3085
|
+
`dashboard input cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
3086
|
+
)
|
|
3087
|
+
} catch {
|
|
3088
|
+
// Rendering that warning is best-effort too.
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
await this.stopRendering()
|
|
3092
|
+
await this.operationTail
|
|
3093
|
+
try {
|
|
3094
|
+
await this.renderOnce()
|
|
3095
|
+
} catch {
|
|
3096
|
+
// Best-effort: a failed final frame must not mask the run's outcome.
|
|
3097
|
+
}
|
|
3098
|
+
} finally {
|
|
3099
|
+
// Cursor/normal-screen restoration is unconditional once dashboard
|
|
3100
|
+
// teardown begins, including poll and final-read failures.
|
|
3101
|
+
this.region?.finish()
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
// ── Repository supervisor lease (§12 serialization) ────────────────────────
|
|
3106
|
+
|
|
3107
|
+
/** Claim (or renew) the repository lease for this invocation and start the
|
|
3108
|
+
* heartbeat timer. False means another supervisor holds it: the caller
|
|
3109
|
+
* yields — no claims, launches, or publications. */
|
|
3110
|
+
private async ensureRepoLease(holder: string): Promise<boolean> {
|
|
3111
|
+
if (this.superseded) return false
|
|
3112
|
+
const claimed = await this.wiring.store
|
|
3113
|
+
.claimRepoLease(this.repoIdentity, holder, REPO_LEASE_TTL_MS)
|
|
3114
|
+
.catch(() => false)
|
|
3115
|
+
if (!claimed) {
|
|
3116
|
+
if (this.repoLeaseHolder !== undefined) {
|
|
3117
|
+
// We held the lease and the store says it is gone: a peer took over.
|
|
3118
|
+
this.superseded = true
|
|
3119
|
+
}
|
|
3120
|
+
return false
|
|
3121
|
+
}
|
|
3122
|
+
if (this.repoLeaseHolder === undefined) {
|
|
3123
|
+
this.repoLeaseHolder = holder
|
|
3124
|
+
this.startRepoLeaseHeartbeat(holder)
|
|
3125
|
+
}
|
|
3126
|
+
return true
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
private startRepoLeaseHeartbeat(holder: string): void {
|
|
3130
|
+
if (this.repoLeaseHeartbeat !== undefined) return
|
|
3131
|
+
this.repoLeaseHeartbeat = setInterval(() => {
|
|
3132
|
+
this.wiring.store.heartbeatRepo(this.repoIdentity, holder).then(
|
|
3133
|
+
(alive) => {
|
|
3134
|
+
if (alive) return
|
|
3135
|
+
// A peer holds the repository now. Exit watch mode cleanly after
|
|
3136
|
+
// the same teardown every stop takes (detach remote / reap local,
|
|
3137
|
+
// signal continuations, release the lease).
|
|
3138
|
+
this.superseded = true
|
|
3139
|
+
this.inputStop.abort()
|
|
3140
|
+
},
|
|
3141
|
+
() => {
|
|
3142
|
+
// Store unreachable: retry on the next beat; a later false result
|
|
3143
|
+
// proves takeover.
|
|
3144
|
+
},
|
|
3145
|
+
)
|
|
3146
|
+
}, REPO_LEASE_HEARTBEAT_MS)
|
|
3147
|
+
this.repoLeaseHeartbeat.unref?.()
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
private stopRepoLeaseHeartbeat(): void {
|
|
3151
|
+
if (this.repoLeaseHeartbeat !== undefined) clearInterval(this.repoLeaseHeartbeat)
|
|
3152
|
+
this.repoLeaseHeartbeat = undefined
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
/** Durable record that this invocation found the repository held and
|
|
3156
|
+
* performed no claims, launches, or publications. The holder is resolved
|
|
3157
|
+
* from the repository record. Recorded once per yield period, not once per
|
|
3158
|
+
* retrying tick. */
|
|
3159
|
+
private async recordTickYielded(): Promise<void> {
|
|
3160
|
+
try {
|
|
3161
|
+
const record = await this.wiring.store.getRepo(this.repoIdentity).catch(() => null)
|
|
3162
|
+
const holder = record?.lease?.holder
|
|
3163
|
+
if (holder === undefined || this.yieldedTo === holder) return
|
|
3164
|
+
this.yieldedTo = holder
|
|
3165
|
+
await this.wiring.store.appendRepo(this.repoIdentity, {
|
|
3166
|
+
actor: DISPATCHER,
|
|
3167
|
+
type: 'dispatcher.tick-yielded',
|
|
3168
|
+
payload: {
|
|
3169
|
+
run: this.runId,
|
|
3170
|
+
holder,
|
|
3171
|
+
},
|
|
3172
|
+
})
|
|
3173
|
+
} catch {
|
|
3174
|
+
// Presentation-only durable evidence; the yield behavior is unchanged.
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
private async releaseRepoLease(): Promise<void> {
|
|
3179
|
+
this.stopRepoLeaseHeartbeat()
|
|
3180
|
+
const holder = this.repoLeaseHolder
|
|
3181
|
+
this.repoLeaseHolder = undefined
|
|
3182
|
+
if (holder === undefined) return
|
|
3183
|
+
try {
|
|
3184
|
+
await this.wiring.store.releaseRepoLease(this.repoIdentity, holder)
|
|
3185
|
+
} catch {
|
|
3186
|
+
// Expiry fences an ambiguous release.
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
async run(): Promise<void> {
|
|
3191
|
+
// Before the `--once` branch, so both modes report it exactly once.
|
|
3192
|
+
this.reportRoleDiagnostics()
|
|
3193
|
+
// Repository supervisor lease (§12): two invocations for one repository
|
|
3194
|
+
// never both act. A losing invocation records the yield and performs no
|
|
3195
|
+
// claims, launches, or publications. The holder is the invocation's
|
|
3196
|
+
// journaling run id — the same string every journal payload carries.
|
|
3197
|
+
const holder = this.runId
|
|
3198
|
+
if (!(await this.ensureRepoLease(holder))) {
|
|
3199
|
+
await this.recordTickYielded()
|
|
3200
|
+
const peer = (await this.wiring.store.getRepo(this.repoIdentity).catch(() => null))?.lease
|
|
3201
|
+
?.holder
|
|
3202
|
+
const message = `tick yielded: repository held by ${peer ?? 'another invocation'}`
|
|
3203
|
+
this.warn(message)
|
|
3204
|
+
if (this.opts.once === true) return
|
|
3205
|
+
// Watch mode stays alive and retries the claim at every tick.
|
|
3206
|
+
}
|
|
3207
|
+
const capacity = this.currentConfig().config.capacity
|
|
3208
|
+
if (this.opts.once) {
|
|
3209
|
+
if (!this.dashboard) {
|
|
3210
|
+
this.say(`ab dispatch — one pass over ${this.opts.targetRepo} (capacity ${capacity})`)
|
|
3211
|
+
}
|
|
3212
|
+
// Render BEFORE the tick and until the drain finishes, so the operator
|
|
3213
|
+
// watches the initial pass's builds change state while they run. The
|
|
3214
|
+
// render loop only reads: `--once` still calls tick() exactly ONCE, so
|
|
3215
|
+
// it never claims a ticket that becomes Ready mid-drain.
|
|
3216
|
+
try {
|
|
3217
|
+
this.startInput()
|
|
3218
|
+
this.startRendering()
|
|
3219
|
+
// Invocation bound: an already-passed deadline skips the tick and the
|
|
3220
|
+
// drain entirely — teardown in `finally` still runs, so the repository
|
|
3221
|
+
// lease and any provisioning state are released for the next
|
|
3222
|
+
// invocation.
|
|
3223
|
+
if (this.pastDeadline()) return
|
|
3224
|
+
const initial = await this.dispatcherTick(true)
|
|
3225
|
+
const initialPrinted = this.printReport(initial, false)
|
|
3226
|
+
await this.drainInFlight()
|
|
3227
|
+
const settledReports = await this.publishSettlementReports()
|
|
3228
|
+
let settledPrinted = false
|
|
3229
|
+
for (const report of settledReports) {
|
|
3230
|
+
settledPrinted = this.printReport(report, false) || settledPrinted
|
|
3231
|
+
}
|
|
3232
|
+
if (!initialPrinted && !settledPrinted) {
|
|
3233
|
+
this.printReport(emptyTickReport())
|
|
3234
|
+
}
|
|
3235
|
+
} finally {
|
|
3236
|
+
await this.stopBuildExecutions()
|
|
3237
|
+
await this.stopHostedHarvest()
|
|
3238
|
+
// Signal provisioning continuations and release their leases before
|
|
3239
|
+
// the repository lease, so the next supervisor adopts immediately.
|
|
3240
|
+
await this.dispatcher.stopProvisioning()
|
|
3241
|
+
await this.finishRendering()
|
|
3242
|
+
await this.releaseRepoLease()
|
|
3243
|
+
}
|
|
3244
|
+
return
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
const intervalMs = this.opts.intervalMs ?? DEFAULT_INTERVAL_MS
|
|
3248
|
+
const sleep =
|
|
3249
|
+
this.opts.sleep ??
|
|
3250
|
+
((ms: number) => interruptibleSleep(ms, [this.opts.signal, this.inputStop.signal]))
|
|
3251
|
+
if (!this.dashboard) {
|
|
3252
|
+
this.say(
|
|
3253
|
+
`ab dispatch — watching ${this.opts.targetRepo} (capacity ${capacity}, ` +
|
|
3254
|
+
`every ${Math.round(intervalMs / 1000)}s) — Ctrl-C to stop`,
|
|
3255
|
+
)
|
|
3256
|
+
}
|
|
3257
|
+
try {
|
|
3258
|
+
this.startInput()
|
|
3259
|
+
this.startRendering()
|
|
3260
|
+
this.startUpgradeChecks()
|
|
3261
|
+
let startup = true
|
|
3262
|
+
while (!this.stopped && !this.superseded) {
|
|
3263
|
+
try {
|
|
3264
|
+
const report = await this.dispatcherTick(startup)
|
|
3265
|
+
this.printReport(report, this.harvestInFlight === undefined)
|
|
3266
|
+
startup = false
|
|
3267
|
+
} catch (error) {
|
|
3268
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
3269
|
+
await this.appendStatus({
|
|
3270
|
+
actor: DISPATCHER,
|
|
3271
|
+
type: 'dispatcher.tick-failed',
|
|
3272
|
+
payload: { run: this.runId, error: message },
|
|
3273
|
+
})
|
|
3274
|
+
this.warn(`tick failed: ${message}`)
|
|
3275
|
+
}
|
|
3276
|
+
if (this.stopped || this.superseded) break
|
|
3277
|
+
await sleep(intervalMs)
|
|
3278
|
+
}
|
|
3279
|
+
if (this.superseded) {
|
|
3280
|
+
this.warn('dispatcher superseded by another invocation — detaching')
|
|
3281
|
+
}
|
|
3282
|
+
} finally {
|
|
3283
|
+
await this.stopBuildExecutions()
|
|
3284
|
+
await this.stopHostedHarvest()
|
|
3285
|
+
// Signal provisioning continuations and release their leases before
|
|
3286
|
+
// the repository lease, so the next supervisor adopts immediately.
|
|
3287
|
+
await this.dispatcher.stopProvisioning()
|
|
3288
|
+
// Stop input and join teardown work first so every result that becomes
|
|
3289
|
+
// reportable at that boundary is included. Active Harvest work is still
|
|
3290
|
+
// deliberately not awaited in watch mode.
|
|
3291
|
+
await this.finishRendering()
|
|
3292
|
+
for (const report of await this.publishSettlementReports()) {
|
|
3293
|
+
this.printReport(report, false)
|
|
3294
|
+
}
|
|
3295
|
+
await this.releaseRepoLease()
|
|
3296
|
+
}
|
|
3297
|
+
// The finished interactive frame stays on screen; never append a late line
|
|
3298
|
+
// beneath it. Plain mode retains its historical shutdown line.
|
|
3299
|
+
if (!this.dashboard) this.say('ab dispatch stopped')
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
/** Origin-mode startup: fetch autobuild.toml from the repository through the
|
|
3304
|
+
* forge — the default branch first (baseBranch is itself config), then the
|
|
3305
|
+
* configured base branch when one is set. The final parse is the startup
|
|
3306
|
+
* Config; both reads share one memoized GitHubForge. */
|
|
3307
|
+
async function fetchOriginModeConfig(
|
|
3308
|
+
opts: DispatchOpts,
|
|
3309
|
+
transport?: GitHubRequest,
|
|
3310
|
+
): Promise<{
|
|
3311
|
+
content: string
|
|
3312
|
+
config: Config
|
|
3313
|
+
}> {
|
|
3314
|
+
const repository = normalizeGitRemoteUrl(opts.repository!)
|
|
3315
|
+
const label = `${repository}/autobuild.toml`
|
|
3316
|
+
const forge = new GitHubForge({
|
|
3317
|
+
env: opts.env,
|
|
3318
|
+
repository,
|
|
3319
|
+
exec: opts.exec,
|
|
3320
|
+
...(transport !== undefined ? { transport } : {}),
|
|
3321
|
+
})
|
|
3322
|
+
const readFile = forge.readFile
|
|
3323
|
+
if (readFile === undefined) {
|
|
3324
|
+
throw new Error('the github forge does not implement readFile — this is a wiring bug')
|
|
3325
|
+
}
|
|
3326
|
+
const content = await readFile.call(forge, 'autobuild.toml').catch((error: unknown) => {
|
|
3327
|
+
if (error instanceof GitHubApiError && error.status === 404) {
|
|
3328
|
+
throw new Error(
|
|
3329
|
+
`${label}: not found on the repository's default branch — origin-mode dispatch ` +
|
|
3330
|
+
'reads autobuild.toml from the forge (SPEC §8.2, §16.1)',
|
|
3331
|
+
)
|
|
3332
|
+
}
|
|
3333
|
+
throw error
|
|
3334
|
+
})
|
|
3335
|
+
const parsed = parseConfig(content, label)
|
|
3336
|
+
if (parsed.baseBranch === undefined || parsed.baseBranch === '') {
|
|
3337
|
+
return { content, config: parsed }
|
|
3338
|
+
}
|
|
3339
|
+
const branchContent = await readFile
|
|
3340
|
+
.call(forge, 'autobuild.toml', parsed.baseBranch)
|
|
3341
|
+
.catch((error: unknown) => {
|
|
3342
|
+
if (error instanceof GitHubApiError && error.status === 404) {
|
|
3343
|
+
throw new Error(
|
|
3344
|
+
`${label}: not found at configured baseBranch ${JSON.stringify(parsed.baseBranch)} — ` +
|
|
3345
|
+
'commit autobuild.toml to the base branch before dispatching in origin mode',
|
|
3346
|
+
)
|
|
3347
|
+
}
|
|
3348
|
+
throw error
|
|
3349
|
+
})
|
|
3350
|
+
const branchLabel = `${label}@${parsed.baseBranch}`
|
|
3351
|
+
return { content: branchContent, config: parseConfig(branchContent, branchLabel) }
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
/** Loopback hosts where a plain-http store origin is accepted: it names the
|
|
3355
|
+
* operator's own machine (local development, integration harnesses), never a
|
|
3356
|
+
* network hop that would carry the bearer token in the clear. */
|
|
3357
|
+
const LOOPBACK_STORE_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]'])
|
|
3358
|
+
|
|
3359
|
+
function acceptableOriginModeStore(storeRef: string): boolean {
|
|
3360
|
+
if (/^https:\/\//i.test(storeRef)) return true
|
|
3361
|
+
if (!/^http:\/\//i.test(storeRef)) return false
|
|
3362
|
+
try {
|
|
3363
|
+
return LOOPBACK_STORE_HOSTS.has(new URL(storeRef).hostname)
|
|
3364
|
+
} catch {
|
|
3365
|
+
return false
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
/** Origin-mode repo state: the identity is the normalized origin; local
|
|
3370
|
+
* scratch (state root, worktree root) lives under a per-origin temp directory
|
|
3371
|
+
* and the store MUST be remote HTTPS — except a loopback http origin, which
|
|
3372
|
+
* names the operator's own machine (local development, integration harnesses).
|
|
3373
|
+
* Requirements are validated here so a misconfigured origin-mode launch fails
|
|
3374
|
+
* before any side effect. */
|
|
3375
|
+
async function resolveOriginModeState(opts: DispatchOpts): Promise<RepoStatePaths> {
|
|
3376
|
+
const identity = normalizeGitRemoteUrl(opts.repository!)
|
|
3377
|
+
const selectedStore = opts.storeRef ?? opts.env.AB_STORE
|
|
3378
|
+
if (selectedStore === undefined || !acceptableOriginModeStore(selectedStore)) {
|
|
3379
|
+
throw new Error(
|
|
3380
|
+
'origin-mode dispatch requires an HTTPS BuildStore — set AB_STORE (or --store) to the hosted Store URL',
|
|
3381
|
+
)
|
|
3382
|
+
}
|
|
3383
|
+
if (opts.env.AB_TOKEN === undefined || opts.env.AB_TOKEN === '') {
|
|
3384
|
+
throw new Error('origin-mode dispatch requires AB_TOKEN for the remote Store')
|
|
3385
|
+
}
|
|
3386
|
+
// Checkout-less hosts (serverless functions, sandboxes) have no gh CLI and
|
|
3387
|
+
// no keyring, and the only workspace provider origin mode can run —
|
|
3388
|
+
// vercel-sandbox — injects this same credential for publication, so origin
|
|
3389
|
+
// mode requires an exported token: the gh fallback is a checkout-mode
|
|
3390
|
+
// convenience only.
|
|
3391
|
+
if (githubTokenFromEnv(opts.env) === undefined) {
|
|
3392
|
+
throw new Error('origin-mode dispatch requires GITHUB_TOKEN or GH_TOKEN for the GitHub API')
|
|
3393
|
+
}
|
|
3394
|
+
const scratch = join(
|
|
3395
|
+
tmpdir(),
|
|
3396
|
+
'autobuild',
|
|
3397
|
+
createHash('sha256').update(identity).digest('hex').slice(0, 16),
|
|
3398
|
+
)
|
|
3399
|
+
await mkdir(scratch, { recursive: true })
|
|
3400
|
+
return resolveRepoStatePaths({
|
|
3401
|
+
repo: identity,
|
|
3402
|
+
checkout: scratch,
|
|
3403
|
+
...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
|
|
3404
|
+
...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
|
|
3405
|
+
})
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
/**
|
|
3409
|
+
* Entry point (§8.2). Loads the repo's config — whose required [tickets]
|
|
3410
|
+
* table selects the TicketSource and names its ready state. A file source with
|
|
3411
|
+
* no `dir` still defaults to `.autobuild/tickets` (§13). Then wires the ports
|
|
3412
|
+
* and runs until one pass finishes (`--once`) or `opts.signal` aborts (SIGINT).
|
|
3413
|
+
*
|
|
3414
|
+
* Origin mode (AUT-302): `opts.repository` (CLI `--repository`, env
|
|
3415
|
+
* `AB_REPOSITORY`) serves a repository with NO local checkout. The startup
|
|
3416
|
+
* config is fetched from the forge (default branch, then the configured
|
|
3417
|
+
* `baseBranch`), the store must be remote HTTPS, and the identity — not a
|
|
3418
|
+
* path — is the served repository.
|
|
3419
|
+
*/
|
|
3420
|
+
export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
3421
|
+
if (opts.wire !== undefined && opts.nonStoreWire !== undefined) {
|
|
3422
|
+
throw new Error('dispatch wire and nonStoreWire are mutually exclusive')
|
|
3423
|
+
}
|
|
3424
|
+
const state =
|
|
3425
|
+
opts.repository !== undefined
|
|
3426
|
+
? await resolveOriginModeState(opts)
|
|
3427
|
+
: await resolveRepoState({
|
|
3428
|
+
targetRepo: opts.targetRepo,
|
|
3429
|
+
exec: opts.exec,
|
|
3430
|
+
...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
|
|
3431
|
+
...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
|
|
3432
|
+
})
|
|
3433
|
+
// Normalize once, then use these exact values for config/tickets/repository
|
|
3434
|
+
// identity, store wiring, worktrees, and every session's AB_STORE.
|
|
3435
|
+
// `targetRepo` stays the FILESYSTEM checkout (or origin-mode scratch root);
|
|
3436
|
+
// `repo` is the store-keyed identity.
|
|
3437
|
+
const resolvedOpts: DispatchOpts = {
|
|
3438
|
+
...opts,
|
|
3439
|
+
targetRepo: state.checkout,
|
|
3440
|
+
storeRef: state.storeRef,
|
|
3441
|
+
repo: opts.repo ?? state.repo,
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
// Interactive production dispatch is two programs. Resolve/open only the
|
|
3445
|
+
// Store in the terminal owner; config, plugins, adapters, runners, and ticket
|
|
3446
|
+
// I/O are constructed exclusively by the supervised private child. Injected
|
|
3447
|
+
// wiring remains the direct test/embedding seam, and every plain/non-TTY
|
|
3448
|
+
// invocation stays on the byte-compatible in-process kernel path below.
|
|
3449
|
+
if (
|
|
3450
|
+
resolvedOpts.kernelRunId === undefined &&
|
|
3451
|
+
resolvedOpts.wire === undefined &&
|
|
3452
|
+
resolvedOpts.nonStoreWire === undefined &&
|
|
3453
|
+
resolvedOpts.plain !== true &&
|
|
3454
|
+
resolvedOpts.terminal?.interactive === true &&
|
|
3455
|
+
resolvedOpts.input !== undefined
|
|
3456
|
+
) {
|
|
3457
|
+
if (opts.repository !== undefined) {
|
|
3458
|
+
throw new Error(
|
|
3459
|
+
'origin-mode dispatch (--repository) is a serverless workhorse and cannot run the ' +
|
|
3460
|
+
'interactive dashboard: pass --plain or run it without a TTY',
|
|
3461
|
+
)
|
|
3462
|
+
}
|
|
3463
|
+
const opened = openStoreForRepoState(state, { env: resolvedOpts.env })
|
|
3464
|
+
try {
|
|
3465
|
+
const frontend = new DispatchFrontend({
|
|
3466
|
+
repo: state.repo,
|
|
3467
|
+
checkout: state.checkout,
|
|
3468
|
+
storeRef: opened.storeRef,
|
|
3469
|
+
store: opened.store,
|
|
3470
|
+
env: resolvedOpts.env,
|
|
3471
|
+
terminal: resolvedOpts.terminal,
|
|
3472
|
+
input: resolvedOpts.input,
|
|
3473
|
+
once: resolvedOpts.once === true,
|
|
3474
|
+
...(resolvedOpts.intervalMs !== undefined ? { intervalMs: resolvedOpts.intervalMs } : {}),
|
|
3475
|
+
...(resolvedOpts.intake !== undefined ? { intake: resolvedOpts.intake } : {}),
|
|
3476
|
+
...(resolvedOpts.defaultAutoMerge !== undefined
|
|
3477
|
+
? { defaultAutoMerge: resolvedOpts.defaultAutoMerge }
|
|
3478
|
+
: {}),
|
|
3479
|
+
...(resolvedOpts.signal !== undefined ? { signal: resolvedOpts.signal } : {}),
|
|
3480
|
+
...(resolvedOpts.resolveDashboardRenderer !== undefined
|
|
3481
|
+
? { resolveDashboardRenderer: resolvedOpts.resolveDashboardRenderer }
|
|
3482
|
+
: {}),
|
|
3483
|
+
})
|
|
3484
|
+
await frontend.run()
|
|
3485
|
+
} finally {
|
|
3486
|
+
await opened.store.close()
|
|
3487
|
+
}
|
|
3488
|
+
return
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
const configPath = join(resolvedOpts.targetRepo, 'autobuild.toml')
|
|
3492
|
+
let configContent: string
|
|
3493
|
+
let config: Config
|
|
3494
|
+
if (opts.repository !== undefined) {
|
|
3495
|
+
// Origin mode startup config: the base branch's autobuild.toml, fetched
|
|
3496
|
+
// through the forge (default branch first — baseBranch itself is config).
|
|
3497
|
+
const fetched = await fetchOriginModeConfig(opts, opts.originConfigTransport)
|
|
3498
|
+
configContent = fetched.content
|
|
3499
|
+
config = fetched.config
|
|
3500
|
+
if (config.forge !== 'github') {
|
|
3501
|
+
throw new Error(
|
|
3502
|
+
`origin-mode dispatch requires the builtin github forge, but the fetched autobuild.toml selects ${JSON.stringify(config.forge)}`,
|
|
3503
|
+
)
|
|
3504
|
+
}
|
|
3505
|
+
if (config.plugins !== undefined && config.plugins.length > 0) {
|
|
3506
|
+
throw new Error(
|
|
3507
|
+
'origin-mode dispatch cannot load configured plugins: plugin code is checkout-relative ' +
|
|
3508
|
+
'and there is no checkout. Remove [plugins] from the base branch autobuild.toml or run from a checkout',
|
|
3509
|
+
)
|
|
3510
|
+
}
|
|
3511
|
+
} else {
|
|
3512
|
+
try {
|
|
3513
|
+
configContent = await Bun.file(configPath).text()
|
|
3514
|
+
config = parseConfig(configContent, configPath)
|
|
3515
|
+
} catch (error) {
|
|
3516
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
3517
|
+
throw new Error(
|
|
3518
|
+
`${configPath}: not found — 'ab dispatch' reads autobuild.toml from ` +
|
|
3519
|
+
'the resolved Git main checkout (SPEC §8.2, §16.1)',
|
|
3520
|
+
)
|
|
3521
|
+
}
|
|
3522
|
+
throw error
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
// Configured plugin code is trusted like configured shell commands, but it
|
|
3526
|
+
// must resolve, evaluate, validate, and register before production wiring
|
|
3527
|
+
// opens a store, claims a ticket, or launches a runner.
|
|
3528
|
+
const plugins = await loadPlugins(config.plugins, resolvedOpts.targetRepo)
|
|
3529
|
+
// Validate the selector against the complete catalog before either custom
|
|
3530
|
+
// wiring or production wiring can open state or perform side effects.
|
|
3531
|
+
resolveForgeRegistration(config.forge, plugins)
|
|
3532
|
+
let wired: DispatchWiring
|
|
3533
|
+
if (resolvedOpts.nonStoreWire !== undefined) {
|
|
3534
|
+
const opened = openStoreForRepoState(state, { env: resolvedOpts.env })
|
|
3535
|
+
const ports = await resolvedOpts.nonStoreWire(config, resolvedOpts, state, plugins)
|
|
3536
|
+
wired = {
|
|
3537
|
+
...ports,
|
|
3538
|
+
store: opened.store,
|
|
3539
|
+
storeRef: opened.storeRef,
|
|
3540
|
+
...(opened.token !== undefined ? { token: opened.token } : {}),
|
|
3541
|
+
}
|
|
3542
|
+
} else {
|
|
3543
|
+
const wire = resolvedOpts.wire ?? defaultWire
|
|
3544
|
+
wired = await wire(config, resolvedOpts, state, plugins)
|
|
3545
|
+
}
|
|
3546
|
+
const runtimes = await materializePluginRuntimes(wired.runtimes, plugins, {
|
|
3547
|
+
// Filesystem root for plugin runtime code — never the store identity.
|
|
3548
|
+
repoRoot: state.checkout,
|
|
3549
|
+
env: resolvedOpts.env,
|
|
3550
|
+
})
|
|
3551
|
+
const wiring: DispatchWiring = {
|
|
3552
|
+
...wired,
|
|
3553
|
+
runtimes,
|
|
3554
|
+
plugins: wired.plugins ?? plugins,
|
|
3555
|
+
}
|
|
3556
|
+
// The invocation's durable journaling identity — the single synthesis point
|
|
3557
|
+
// for this invocation. Kernel children keep their kernelRunId; every plain
|
|
3558
|
+
// invocation mints `<host>-dispatch-<id>` exactly the format the
|
|
3559
|
+
// repository-supervisor lease holder already uses. Minted once: `wiring.ids`
|
|
3560
|
+
// is `randomIds()`, so a getter re-evaluating it would re-mint per access.
|
|
3561
|
+
// Everything below (startup deposits, the loop's journal payloads, both
|
|
3562
|
+
// lease sites) carries this one value.
|
|
3563
|
+
const runId = resolvedOpts.kernelRunId ?? `${hostname()}-dispatch-${wiring.ids('inst')}`
|
|
3564
|
+
resolvedOpts.runId = runId
|
|
3565
|
+
// Construction eagerly validates every startup role before repository
|
|
3566
|
+
// settings or runner work can mutate durable state. The publisher is used
|
|
3567
|
+
// only by later watch refreshes.
|
|
3568
|
+
const liveConfig = new LiveConfig(
|
|
3569
|
+
configPath,
|
|
3570
|
+
config,
|
|
3571
|
+
configContent,
|
|
3572
|
+
wiring.runtimes,
|
|
3573
|
+
async ({ content, effectiveConfig, restartRequired, effectiveChanged }) => {
|
|
3574
|
+
await wiring.store.appendRepoWithArtifacts(
|
|
3575
|
+
state.repo,
|
|
3576
|
+
[
|
|
3577
|
+
{
|
|
3578
|
+
kind: DISPATCHER_CONFIG_ARTIFACT,
|
|
3579
|
+
content,
|
|
3580
|
+
metadata: { restartRequired: [...restartRequired], effectiveChanged },
|
|
3581
|
+
},
|
|
3582
|
+
{
|
|
3583
|
+
kind: DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT,
|
|
3584
|
+
content: effectiveConfigContent(effectiveConfig),
|
|
3585
|
+
metadata: { run: runId, effectiveChanged },
|
|
3586
|
+
},
|
|
3587
|
+
],
|
|
3588
|
+
(deposited) => {
|
|
3589
|
+
const artifact = deposited[0]
|
|
3590
|
+
const effectiveArtifact = deposited[1]
|
|
3591
|
+
if (artifact === undefined || effectiveArtifact === undefined) {
|
|
3592
|
+
throw new Error('config reload deposit returned incomplete artifacts')
|
|
3593
|
+
}
|
|
3594
|
+
return {
|
|
3595
|
+
actor: DISPATCHER,
|
|
3596
|
+
type: 'dispatcher.config-reloaded',
|
|
3597
|
+
payload: {
|
|
3598
|
+
artifact: { kind: artifact.kind, rev: artifact.revision },
|
|
3599
|
+
restartRequired: [...restartRequired],
|
|
3600
|
+
effectiveChanged,
|
|
3601
|
+
run: runId,
|
|
3602
|
+
effectiveConfig: {
|
|
3603
|
+
kind: effectiveArtifact.kind,
|
|
3604
|
+
rev: effectiveArtifact.revision,
|
|
3605
|
+
},
|
|
3606
|
+
roleWarnings: roleKeyWarnings(effectiveConfig),
|
|
3607
|
+
},
|
|
3608
|
+
}
|
|
3609
|
+
},
|
|
3610
|
+
)
|
|
3611
|
+
wiring.updateRuntimeReferences?.(effectiveConfig)
|
|
3612
|
+
},
|
|
3613
|
+
)
|
|
3614
|
+
|
|
3615
|
+
// Launch flags are durable repository setters. Omission writes nothing, so
|
|
3616
|
+
// another dispatcher cannot clobber the latest operator choice with a value
|
|
3617
|
+
// it inferred at startup. Fresh-repository fallbacks live in the reducer.
|
|
3618
|
+
await wiring.store.ensureRepo(state.repo)
|
|
3619
|
+
const actor = humanActor(buildControlUser(resolvedOpts.env))
|
|
3620
|
+
if (resolvedOpts.intake !== undefined) {
|
|
3621
|
+
await wiring.store.appendRepo(state.repo, {
|
|
3622
|
+
actor,
|
|
3623
|
+
type: 'dispatcher.intake-set',
|
|
3624
|
+
payload: { enabled: resolvedOpts.intake, run: runId },
|
|
3625
|
+
})
|
|
3626
|
+
}
|
|
3627
|
+
if (resolvedOpts.defaultAutoMerge !== undefined) {
|
|
3628
|
+
await wiring.store.appendRepo(state.repo, {
|
|
3629
|
+
actor,
|
|
3630
|
+
type: 'dispatcher.auto-merge-default-set',
|
|
3631
|
+
payload: { enabled: resolvedOpts.defaultAutoMerge, run: runId },
|
|
3632
|
+
})
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
await wiring.store.appendRepoWithArtifacts(
|
|
3636
|
+
state.repo,
|
|
3637
|
+
[
|
|
3638
|
+
{
|
|
3639
|
+
kind: DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT,
|
|
3640
|
+
content: effectiveConfigContent(config),
|
|
3641
|
+
metadata: { run: runId, revision: 0 },
|
|
3642
|
+
},
|
|
3643
|
+
],
|
|
3644
|
+
(deposited) => {
|
|
3645
|
+
const artifact = deposited[0]
|
|
3646
|
+
if (artifact === undefined) throw new Error('startup config deposit returned no artifact')
|
|
3647
|
+
return {
|
|
3648
|
+
actor: DISPATCHER,
|
|
3649
|
+
type: 'dispatcher.run-started',
|
|
3650
|
+
payload: {
|
|
3651
|
+
run: runId,
|
|
3652
|
+
pid: process.pid,
|
|
3653
|
+
effectiveConfig: { kind: artifact.kind, rev: artifact.revision },
|
|
3654
|
+
roleWarnings: roleKeyWarnings(config),
|
|
3655
|
+
},
|
|
3656
|
+
}
|
|
3657
|
+
},
|
|
3658
|
+
)
|
|
3659
|
+
|
|
3660
|
+
const loop = new DispatchLoop(liveConfig, wiring, resolvedOpts)
|
|
3661
|
+
try {
|
|
3662
|
+
await loop.run()
|
|
3663
|
+
await wiring.store.appendRepo(state.repo, {
|
|
3664
|
+
actor: DISPATCHER,
|
|
3665
|
+
type: 'dispatcher.run-stopped',
|
|
3666
|
+
payload: {
|
|
3667
|
+
run: runId,
|
|
3668
|
+
outcome: 'normal',
|
|
3669
|
+
exitCode: 0,
|
|
3670
|
+
...(loop.supersededByPeer ? { reason: 'superseded' as const } : {}),
|
|
3671
|
+
},
|
|
3672
|
+
})
|
|
3673
|
+
} catch (error) {
|
|
3674
|
+
try {
|
|
3675
|
+
await wiring.store.appendRepo(state.repo, {
|
|
3676
|
+
actor: DISPATCHER,
|
|
3677
|
+
type: 'dispatcher.run-stopped',
|
|
3678
|
+
payload: {
|
|
3679
|
+
run: runId,
|
|
3680
|
+
outcome: 'abnormal',
|
|
3681
|
+
exitCode: 1,
|
|
3682
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3683
|
+
},
|
|
3684
|
+
})
|
|
3685
|
+
} catch {
|
|
3686
|
+
// Preserve the kernel failure; the supervising frontend records exit.
|
|
3687
|
+
}
|
|
3688
|
+
throw error
|
|
3689
|
+
} finally {
|
|
3690
|
+
if (resolvedOpts.wire === undefined) await wiring.store.close()
|
|
3691
|
+
}
|
|
3692
|
+
}
|