@bridge_gpt/mcp-server 0.2.69 → 0.2.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -292
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +4 -4
- package/build/artifact-diff.js +110 -0
- package/build/bin-entrypoint.js +36 -0
- package/build/bridge-permissions.js +23 -4
- package/build/claude-review-workflow-drift.js +3 -3
- package/build/codex-bridge-profile.js +1 -1
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +598 -19
- package/build/conductor/bring-up-facts.js +9 -9
- package/build/conductor/doctor.js +466 -18
- package/build/conductor/epic-child-continuation.js +54 -3
- package/build/conductor/epic-implementer-cli.js +132 -34
- package/build/conductor/epic-runtime.js +2 -2
- package/build/conductor/epic-state.js +1 -1
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +4 -2
- package/build/conductor/launch-need-probes.js +337 -0
- package/build/conductor/local-merge.js +241 -56
- package/build/conductor/merge-credential.js +172 -0
- package/build/conductor/merge-execution-eligibility.js +1 -1
- package/build/conductor/merged-child-check.js +95 -0
- package/build/conductor/plan-trim.js +30 -0
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +11 -6
- package/build/conductor/readiness-prerequisites.js +41 -0
- package/build/conductor/readiness-sections.js +345 -55
- package/build/conductor/readiness.js +99 -87
- package/build/conductor/recovery-cli.js +606 -21
- package/build/conductor/recovery-operations.js +306 -7
- package/build/conductor/repair-contract.js +47 -2
- package/build/conductor/repository-workflow-remedy.js +8 -6
- package/build/conductor/stored-merge-credential.js +76 -0
- package/build/conductor/stored-merge-token-probe.js +28 -0
- package/build/conductor/temporary-plan.js +50 -0
- package/build/conductor/wait-run-cli.js +759 -0
- package/build/conductor/worker-database-doctor-inspector.js +49 -0
- package/build/conductor-bin.js +2 -2
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
- package/build/conductor-removed-stub-bin.js +2 -5
- package/build/credential-store.js +356 -13
- package/build/docs.generated.js +26 -13
- package/build/doctor.js +94 -32
- package/build/drive-epic-help.js +212 -0
- package/build/drive-epic.js +1840 -373
- package/build/epic-ci-workflow-drift.js +77 -0
- package/build/epic-ci-workflow-install.js +183 -0
- package/build/epic-ci-workflow.js +157 -0
- package/build/epic-contract-context.js +326 -0
- package/build/epic-implementer-bin.js +57 -41
- package/build/epic-implementer-claude-hook-bin.js +2 -2
- package/build/escalation-reachability.js +134 -0
- package/build/executor/cleanup-report.js +99 -0
- package/build/executor/cli.js +12 -1
- package/build/executor/completion-contract.js +66 -11
- package/build/executor/completion-supervision.js +99 -0
- package/build/executor/delivery-outcome.js +179 -0
- package/build/executor/deps.js +3 -0
- package/build/executor/env.js +71 -0
- package/build/executor/failure-reason-codes.js +126 -0
- package/build/executor/heartbeat.js +38 -0
- package/build/executor/http-client.js +72 -0
- package/build/executor/integration-job.js +298 -5
- package/build/executor/job-errors.js +36 -6
- package/build/executor/job-runner.js +720 -36
- package/build/executor/likely-tests.js +36 -2
- package/build/executor/merge-job.js +145 -2
- package/build/executor/observation.js +73 -4
- package/build/executor/permissions.js +193 -97
- package/build/executor/preflight.js +14 -3
- package/build/executor/process-containment.js +28 -2
- package/build/executor/prompt-spec.js +50 -5
- package/build/executor/rebase-finalization.js +38 -3
- package/build/executor/reconcile-overlap-job.js +35 -7
- package/build/executor/required-work-ledger.js +236 -81
- package/build/executor/resume-pre-spawn.js +38 -1
- package/build/executor/runner.js +91 -3
- package/build/executor/test-clock.js +8 -0
- package/build/executor/types.js +37 -2
- package/build/executor/worker-cli-launcher.js +259 -0
- package/build/executor/worker-command.js +41 -4
- package/build/executor/worker-database-target.js +91 -0
- package/build/executor/worker-database.js +908 -0
- package/build/executor/worker-finalization.js +106 -0
- package/build/executor/worker-guard-hook.js +203 -0
- package/build/executor/worker-resources.js +47 -0
- package/build/executor/worker-verification-guidance.js +15 -2
- package/build/executor/worktree.js +303 -15
- package/build/executor-launch.js +11 -1
- package/build/generated-assets-doctor.js +9 -1
- package/build/generated-assets-health.js +13 -4
- package/build/generated-assets-legacy.js +39 -0
- package/build/generated-assets-paths.js +12 -1
- package/build/generated-assets-repair.js +4 -1
- package/build/generated-assets-test-support.js +12 -0
- package/build/github-remote.js +33 -0
- package/build/github-sign-in.js +62 -0
- package/build/index.js +1067 -536
- package/build/init.js +39 -1
- package/build/install-bridge-conductor-gate.js +78 -0
- package/build/install-bridge-conductor-setup.js +413 -0
- package/build/install-bridge-conductor.js +1226 -267
- package/build/install-bridge.js +376 -60
- package/build/mcp-install-state.js +8 -2
- package/build/merge-pull-request.js +6 -0
- package/build/merge-workflow-install.js +763 -345
- package/build/phase-result-artifacts.js +75 -20
- package/build/pipeline-orchestrator.js +247 -4
- package/build/pipelines.generated.js +35 -35
- package/build/plan-discovery.js +15 -2
- package/build/plan-execution-ledger.js +306 -17
- package/build/plan-phase-routing.js +48 -13
- package/build/plane/cli.js +105 -51
- package/build/plane/defaults.js +3 -0
- package/build/plane/plane-freshness.js +294 -0
- package/build/plane/schema-compatibility.js +137 -0
- package/build/plane/shutdown-deps.js +38 -0
- package/build/plane/shutdown.js +83 -8
- package/build/plane/terminal-run-cleanup.js +303 -0
- package/build/printed-remedy-commands.js +26 -4
- package/build/readiness-check.js +6 -2
- package/build/readiness-prerequisites.generated.js +532 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +400 -30
- package/build/shared-resources.generated.js +5 -0
- package/build/start-tickets.js +28 -15
- package/build/test-command-detection.js +247 -0
- package/build/test-command-validation.js +73 -0
- package/build/testing-config-client.js +150 -0
- package/build/ticket-delivery-artifacts.js +258 -0
- package/build/ticket-delivery-enrollment.js +306 -0
- package/build/ticket-delivery.js +205 -0
- package/build/ticket-review-summary.js +14 -0
- package/build/upgrade-cli.js +13 -0
- package/build/v2-readiness-requirements.js +552 -0
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/build/worktree-core.js +22 -7
- package/docs/BRIDGE-DIRECTORY-README.md +48 -0
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
- package/docs/EPIC-IMPLEMENTER.md +104 -63
- package/docs/epic-implementer/getting-started.md +131 -0
- package/docs/epic-implementer/self-hosting.md +133 -0
- package/package.json +3 -3
- package/pipelines/idea-to-ticket.json +1 -1
- package/pipelines/implement-ticket.json +8 -8
- package/pipelines/review-ticket.json +2 -2
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
package/build/drive-epic.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { ticketReviewSummary } from "./ticket-review-summary.js";
|
|
2
|
+
import { parseConductorReadinessResponse } from "./conductor/bridge-api-client.js";
|
|
3
|
+
import { READINESS_PRE_CREATE_CODES, READINESS_PREREQUISITE_BY_ID, READINESS_LEGACY_IDS } from "./readiness-prerequisites.generated.js";
|
|
1
4
|
/**
|
|
2
5
|
* `drive-epic` — the one universal conductor entry point (BAPI-887).
|
|
3
6
|
*
|
|
@@ -53,32 +56,49 @@
|
|
|
53
56
|
* ratcheted down, and a tool would be useless in a bare-chat session anyway,
|
|
54
57
|
* where the model has no tool call to make.
|
|
55
58
|
*/
|
|
56
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
59
|
+
import { access as fsAccess, readdir, readFile, stat as fsStat } from "node:fs/promises";
|
|
60
|
+
import { constants as fsConstants } from "node:fs";
|
|
57
61
|
import path, { dirname, join } from "node:path";
|
|
58
|
-
import {
|
|
62
|
+
import { ConductorBridgeApiError, resolveEpicLaunchBridgeApiAccess, fetchConductorReadiness, fetchRepositoryUsageAdvisory, REPOSITORY_USAGE_ADVISORY_UNAVAILABLE, formatSchemaIncompatibleRefusal, isSchemaIncompatibleError, SCHEMA_INCOMPATIBLE_REPAIR_ROUTE, fetchEpicRunState, isProviderRepositoryIdentity, safeDiagnosticMessage, classifyChildMergeStatus as classifyChildMergeStatusClient, recordSkippedChildren as recordSkippedChildrenClient, } from "./conductor/bridge-api-client.js";
|
|
63
|
+
import { CHILD_MERGE_STATUS_BATCH_SIZE, buildAlreadyDoneEntries, classifyPlanChildren, selectSkips, } from "./conductor/merged-child-check.js";
|
|
64
|
+
import { trimDonePlanNodes } from "./conductor/plan-trim.js";
|
|
65
|
+
import { withPlanPath } from "./conductor/temporary-plan.js";
|
|
66
|
+
import { ESCALATION_REACHABILITY_CHECK_ID, buildEscalationReachabilityCheck, } from "./escalation-reachability.js";
|
|
59
67
|
import { EPIC_LAUNCH_PROFILE_NAME_PATTERN, epicLaunchPolicyDigest, pinEpicLaunchPolicy, renderEpicLaunchCommand, resolveEpicLaunchSelection, shellQuote, } from "./epic-launch-profile.js";
|
|
60
68
|
import { validateHttpBaseUrl, describeBaseUrlRejection } from "./base-url.js";
|
|
61
69
|
import { BRING_UP_REFUSAL_MESSAGES, BRING_UP_RUNBOOK, firstBringUpRefusal, } from "./conductor/bring-up-facts.js";
|
|
62
|
-
import {
|
|
63
|
-
import {
|
|
70
|
+
import { V2_READINESS_REQUIREMENTS, buildLaunchReadinessChecks, firstLaunchEvidenceBlocker, formatLaunchReadinessSummary, projectReadinessRowBlocking, unattendedPostureCheck, } from "./v2-readiness-requirements.js";
|
|
71
|
+
import { classifyLocalGhPreflight, probeLocalMergeCapability, } from "./conductor/local-merge.js";
|
|
72
|
+
import { nodeGithubMergeCredentialReadDeps, resolveMergeCredentialAtAction, } from "./conductor/stored-merge-credential.js";
|
|
73
|
+
import { resolveWorktreeSettingsExclude, } from "./executor/permissions.js";
|
|
74
|
+
import { collectWorkerDatabaseDoctorSection, WORKER_DATABASE_DOCTOR_TIMEOUT_MS, } from "./conductor/doctor.js";
|
|
75
|
+
import { buildEpicBranchBehindBaseLaunchRow, buildHostPowerLaunchRow, buildSelfHostedRunnerLaunchRow, buildUsageResetAfterRunLaunchRow, buildWorkerDbRoleLaunchRow, hostPowerFactFromReport, workerDatabaseReadinessChecks, } from "./conductor/readiness-sections.js";
|
|
76
|
+
import { createWorkflowSource, inspectEpicBranchBehindBase, inspectRequiredSelfHostedRunners, } from "./conductor/launch-need-probes.js";
|
|
77
|
+
import { createWorkerDatabaseDoctorInspector } from "./conductor/worker-database-doctor-inspector.js";
|
|
78
|
+
import { policyDeclaresNotifyWebhook, policyNotifyDestinationsConflict, notifyDestinationsConflictMessage, effectivePolicyFacts, readSetupEpicPolicyFile, readCanonicalParseTruth as readSetupCanonicalParseTruth, runSetupEpicWorkflow, } from "./setup-epic.js";
|
|
64
79
|
import { EPIC_KEY_PATTERN } from "./drive-epic-key.js";
|
|
65
80
|
import { flushProcessStdout, writeJsonDocument } from "./conductor/json-output.js";
|
|
81
|
+
import { BRIDGE_LAUNCHER_ENV } from "./bridge-permissions.js";
|
|
82
|
+
import { checkPlaneBuildFreshness, PLANE_BUILD_FIX_REMEDIATION } from "./plane/build-freshness.js";
|
|
66
83
|
import { discoverEpicPlan, } from "./plan-discovery.js";
|
|
67
84
|
import { formatPlaneDiagnostic, launchPlaneControlPlane, requestPlaneScopedLanes, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
|
|
68
85
|
import { PLANE_PORT_PROBE_TIMEOUT_MS, describePlanePortOccupant, } from "./plane/preflight.js";
|
|
69
|
-
import { createPlaneFsDeps, probeAutomationHealth, probeTcpPort, resolveHostname, } from "./plane/defaults.js";
|
|
86
|
+
import { createPlaneExecFile, createPlaneFsDeps, probeAutomationHealth, probeTcpPort, resolveHostname, } from "./plane/defaults.js";
|
|
70
87
|
import { readPlaneManifest } from "./plane/manifest.js";
|
|
88
|
+
import { cleanupTerminalRunPlane, createTerminalRunCleanupDeps, terminalRunCleanupStderrLine, } from "./plane/terminal-run-cleanup.js";
|
|
89
|
+
import { checkPlaneFreshness, inspectHostSleep, PLANE_DATABASE_HEAD_ID, PLANE_DATABASE_HEAD_REMEDY, PLANE_FRESHNESS_UNVERIFIED_REMEDY, PLANE_HOST_SLEEP_ID, PLANE_SLEEP_REMEDY, formatBoundPlaneStaleRemedy, PLANE_SNAPSHOT_FRESHNESS_ID, PLANE_SNAPSHOT_STALE_REMEDY, } from "./plane/plane-freshness.js";
|
|
71
90
|
import { baseUrlNamesPlaneEndpoint, planeEndpointForTarget } from "./plane/endpoint.js";
|
|
72
91
|
import { resolvePlaneServerEndpoint, } from "./plane/member-roster.js";
|
|
73
92
|
import { resolveMemberInstanceId } from "./plane/supervisor.js";
|
|
74
|
-
import { PLANE_SERVER_PORT_ENV_VAR, } from "./plane/types.js";
|
|
75
93
|
import { validateBranchName } from "./base-ref.js";
|
|
76
94
|
import { createDefaultRunExecutorLaunchDeps, ensureRunExecutor as ensureRunExecutorDefault, prepareRunExecutor as prepareRunExecutorDefault, } from "./executor-launch.js";
|
|
77
95
|
import { isTerminalEpicRunStatus } from "./epic-run-status.js";
|
|
78
96
|
// BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
|
|
79
97
|
// the printed invocation interpolates it rather than repeating it.
|
|
80
98
|
import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
|
|
81
|
-
import {
|
|
99
|
+
import { getDriveEpicAdvancedUsage, getDriveEpicUsage } from "./drive-epic-help.js";
|
|
100
|
+
export { getDriveEpicUsage };
|
|
101
|
+
import { CONSUMER_GUIDE_POINTER, PRINTED_CHECK_NAME_INSTRUCTION, PRINTED_CONDUCTOR_INSTALL_COMMAND, PRINTED_DOCTOR_COMMAND, } from "./printed-remedy-commands.js";
|
|
82
102
|
/** The conductor paths. Derived types below come from this constant. */
|
|
83
103
|
export const DRIVE_EPIC_CONDUCTORS = ["v2"];
|
|
84
104
|
/**
|
|
@@ -89,135 +109,12 @@ export const DRIVE_EPIC_CONDUCTORS = ["v2"];
|
|
|
89
109
|
* one named constant rather than branching scattered through the routing logic.
|
|
90
110
|
*/
|
|
91
111
|
export const DRIVE_EPIC_PREFERRED_CONDUCTOR = "v2";
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
* never re-derived locally — two implementations of one classification drift,
|
|
99
|
-
* and a CLI that disagreed with the server about whether v2 can run would be
|
|
100
|
-
* worse than no check at all.
|
|
101
|
-
*/
|
|
102
|
-
export const V2_READINESS_REQUIREMENTS = [
|
|
103
|
-
{
|
|
104
|
-
id: "supervisor_setup",
|
|
105
|
-
describe: "supervisor setup stored for this repository",
|
|
106
|
-
satisfied: (r) => r.supervisor.setup_present,
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
id: "supervisor_config",
|
|
110
|
-
describe: "supervisor configuration stored for this repository",
|
|
111
|
-
satisfied: (r) => r.supervisor.config_present,
|
|
112
|
-
},
|
|
113
|
-
{
|
|
114
|
-
id: "github_credentials",
|
|
115
|
-
describe: "GitHub App credentials that resolve completely",
|
|
116
|
-
satisfied: (r) => r.github.credentials_complete,
|
|
117
|
-
},
|
|
118
|
-
{
|
|
119
|
-
id: "reconciler_live",
|
|
120
|
-
describe: "a reconciler that is ticking and not stale",
|
|
121
|
-
// BAPI-1279 — launch liveness is a DEPLOYMENT fact. The `bring_up` block
|
|
122
|
-
// reads the reconciler's process heartbeat deployment-wide, so a repository
|
|
123
|
-
// that has never run an epic still reads live when the worker is healthy.
|
|
124
|
-
// The repository-scoped `reconciler` section ("is this repository's work
|
|
125
|
-
// moving?", from `epic_runs.last_tick_at`) is only the fallback for a server
|
|
126
|
-
// too old to report `bring_up`.
|
|
127
|
-
satisfied: (r) => r.bring_up
|
|
128
|
-
? r.bring_up.heartbeat_readable && r.bring_up.worker_present &&
|
|
129
|
-
r.bring_up.single_transition_authority
|
|
130
|
-
: r.reconciler.liveness_readable && !r.reconciler.stale,
|
|
131
|
-
},
|
|
132
|
-
{
|
|
133
|
-
id: "executor_live",
|
|
134
|
-
describe: "an executor provisioned and reporting ready",
|
|
135
|
-
// BAPI-1279 — diagnostic only. drive-epic starts (or, for a durable profile,
|
|
136
|
-
// verifies) the run's executor itself once the run exists.
|
|
137
|
-
advisoryOnly: true,
|
|
138
|
-
satisfied: (r) => r.executor.liveness_readable && r.executor.ready === true,
|
|
139
|
-
},
|
|
140
|
-
// BAPI-1102 — the three unattended prerequisites, in the order `setup-epic`
|
|
141
|
-
// refuses them, so readiness and the refusal name the same first blocker.
|
|
142
|
-
//
|
|
143
|
-
// Each reads the SERVER's `unattended` block and re-derives nothing. A server
|
|
144
|
-
// that does not report the block (older than BAPI-1102) leaves every one of
|
|
145
|
-
// them UNSATISFIED: "could not be read" is not "holds", and these authorize an
|
|
146
|
-
// unattended merge.
|
|
147
|
-
//
|
|
148
|
-
// The FIRST of them therefore carries the older-server remedy too, because it
|
|
149
|
-
// is the one an unreported block always blocks on, and telling that operator
|
|
150
|
-
// to set a consent flag would send them to fix something that may already be
|
|
151
|
-
// set — the server simply never said (BAPI-1102 review).
|
|
152
|
-
{
|
|
153
|
-
id: "unattended_consent",
|
|
154
|
-
describe: "repository consent to unattended Epic Implementer runs",
|
|
155
|
-
unattendedOnly: true,
|
|
156
|
-
// No fixed `remedy`: this requirement's action always depends on WHY.
|
|
157
|
-
remedyFor: (r) => r.unattended === null || r.unattended === undefined
|
|
158
|
-
? "This Bridge API deploy does not report a repository's unattended " +
|
|
159
|
-
"prerequisites at all, so none of them could be confirmed. Upgrade the " +
|
|
160
|
-
"deploy, or pass --attended."
|
|
161
|
-
: "Unattended runs are off for this repository. Turn on 'Allow unattended " +
|
|
162
|
-
"Epic Implementer runs' on the repository's Setup page in the Bridge " +
|
|
163
|
-
"admin panel, or pass --attended. If it is already on, contact Bridge support.",
|
|
164
|
-
satisfied: (r) => r.unattended?.conductor_allowed === true,
|
|
165
|
-
},
|
|
166
|
-
{
|
|
167
|
-
id: "unattended_notify_default",
|
|
168
|
-
describe: "an escalation channel (a verified notify webhook or the account email)",
|
|
169
|
-
unattendedOnly: true,
|
|
170
|
-
remedy: "Supply a --policy-file declaring notify.webhook_url, or add an email address " +
|
|
171
|
-
"to the account so escalation alerts can be emailed, or pass --attended. " +
|
|
172
|
-
"notify.local_sink does not satisfy this.",
|
|
173
|
-
// BAPI-1274 — the account's email address is an escalation channel when no
|
|
174
|
-
// webhook is declared; the server reports its presence, never the address.
|
|
175
|
-
satisfied: (r) => r.unattended?.notify_webhook_default_declared === true ||
|
|
176
|
-
r.unattended?.escalation_email_available === true,
|
|
177
|
-
},
|
|
178
|
-
{
|
|
179
|
-
id: "repository_readiness",
|
|
180
|
-
describe: "confirmed review and epic-implementer-ci workflows on the default branch",
|
|
181
|
-
unattendedOnly: true,
|
|
182
|
-
// BAPI-1359 (Miss 38): the remedy names the SPECIFIC missing item, from the
|
|
183
|
-
// same shared formatter the readiness row and the setup-epic refusal use. The
|
|
184
|
-
// fixed install-command sentence that stood here could never clear a missing
|
|
185
|
-
// migration-guard job — no install step creates one — so it is gone from this
|
|
186
|
-
// refusal.
|
|
187
|
-
//
|
|
188
|
-
// BAPI-1390: the server decides which files are required (the review file
|
|
189
|
-
// only for a `verdict_protocol` run; the CI lane file only for a repository
|
|
190
|
-
// with no STORED gate profile and no `no_ci_gate`), and `satisfied` reads that
|
|
191
|
-
// decision as given. The remedy waives the CI file the same way, so a stored
|
|
192
|
-
// profile is never told to add a file it does not need. Gate-profile WARNINGS
|
|
193
|
-
// (epic-branch rows) are not requirements and are never read here.
|
|
194
|
-
remedyFor: (r) => repositoryWorkflowRemedy(gateProfileWaivesCiWorkflow(r) ? { ...r, conductor_ci_workflow: null } : r),
|
|
195
|
-
satisfied: (r) => r.unattended?.repository_readiness_confirmed === true,
|
|
196
|
-
},
|
|
197
|
-
// BAPI-1135 — merge-execution eligibility is deliberately NOT a requirement here.
|
|
198
|
-
//
|
|
199
|
-
// It was one, and it made `drive-epic` refuse EVERY repository the moment it
|
|
200
|
-
// shipped. The requirement could only be keyed on repository facts, because
|
|
201
|
-
// this layer keeps the composed run policy opaque to itself — and no
|
|
202
|
-
// repository carries the merge workflow until epic child 4 lands it. So both
|
|
203
|
-
// states resolved the same way: an upgraded server reported
|
|
204
|
-
// `merge_workflow_present: false` (`not_eligible`), an older one reported no
|
|
205
|
-
// block at all (`could_not_verify`), and either made `selectConductor` return
|
|
206
|
-
// `not_ready` and `classifyV2Readiness` return `operator_blocked` — for
|
|
207
|
-
// `--attended`, `--into-base` and `local`-mode runs alike, with a remedy
|
|
208
|
-
// naming a flag that does not exist yet.
|
|
209
|
-
//
|
|
210
|
-
// That is exactly the failure `selectConductor`'s own docstring warns about,
|
|
211
|
-
// and it contradicts the compatibility guarantee the rest of this slice keeps:
|
|
212
|
-
// nothing changes for a run that does not declare `github_actions`.
|
|
213
|
-
//
|
|
214
|
-
// Eligibility is still enforced, at the three layers that can actually see the
|
|
215
|
-
// declared mode: `setup-epic`'s pre-create refusal (which reads the composed
|
|
216
|
-
// policy), the approval route's server-side gate, and — authoritatively — the
|
|
217
|
-
// before-merge preflight. Readiness still REPORTS the facts as diagnostic
|
|
218
|
-
// rows, which inform without blocking. Reinstating a requirement here belongs
|
|
219
|
-
// with child 4, once a repository can actually satisfy it.
|
|
220
|
-
];
|
|
112
|
+
// BAPI-1452: `V2_READINESS_REQUIREMENTS` moved to `./v2-readiness-requirements.ts`
|
|
113
|
+
// with an explicit governed row-ID list on every requirement, so readiness rows
|
|
114
|
+
// derive `blocking` from requirement applicability alone. It is re-exported
|
|
115
|
+
// here for existing consumers; classification and conductor selection stay in
|
|
116
|
+
// this module.
|
|
117
|
+
export { V2_READINESS_REQUIREMENTS };
|
|
221
118
|
/**
|
|
222
119
|
* The readiness facts a `plane up` can actually repair (BAPI-1054).
|
|
223
120
|
*
|
|
@@ -237,7 +134,9 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
237
134
|
* running plane behind and change nothing — which is the precise failure the
|
|
238
135
|
* classification exists to prevent.
|
|
239
136
|
*/
|
|
240
|
-
export const V2_RUNTIME_READINESS_IDS = ["
|
|
137
|
+
export const V2_RUNTIME_READINESS_IDS = ["server.worker-present"];
|
|
138
|
+
/** BAPI-1452 — the credential capability row id, for naming its refusal. */
|
|
139
|
+
const MERGE_CREDENTIAL_ROW = "conductor.merge-credential-capability";
|
|
241
140
|
/**
|
|
242
141
|
* Classify the SERVER's readiness report into the composition's three cases.
|
|
243
142
|
*
|
|
@@ -250,13 +149,22 @@ export const V2_RUNTIME_READINESS_IDS = ["reconciler_live"];
|
|
|
250
149
|
* three competing actions has to work out which to do first, and the order here
|
|
251
150
|
* already encodes that (setup, then config, then credentials).
|
|
252
151
|
*/
|
|
253
|
-
export function classifyV2Readiness(readiness, options = {}
|
|
152
|
+
export function classifyV2Readiness(readiness, options = {},
|
|
153
|
+
/**
|
|
154
|
+
* BAPI-1452 — the value-free launch evidence (credential probe verdict and
|
|
155
|
+
* worktree-preparation result) collected locally before run creation. A
|
|
156
|
+
* separate input rather than a new option, so the server report and the
|
|
157
|
+
* local evidence stay visibly distinct. Absent: no evidence was collected,
|
|
158
|
+
* and the classification is exactly the pre-BAPI-1452 one. The unattended
|
|
159
|
+
* posture is never read from here — only from the server report.
|
|
160
|
+
*/
|
|
161
|
+
launchEvidence) {
|
|
254
162
|
for (const diagnostic of readiness.diagnostics ?? []) {
|
|
255
163
|
if (diagnostic.severity === null || diagnostic.applicability === null) {
|
|
256
164
|
return {
|
|
257
165
|
kind: "operator_blocked",
|
|
258
166
|
id: "diagnostic-metadata-unknown",
|
|
259
|
-
describe: "
|
|
167
|
+
describe: "Additional readiness diagnostics",
|
|
260
168
|
remedy: "Upgrade or repair the readiness producer, then retry the selected target.",
|
|
261
169
|
};
|
|
262
170
|
}
|
|
@@ -264,7 +172,7 @@ export function classifyV2Readiness(readiness, options = {}) {
|
|
|
264
172
|
return {
|
|
265
173
|
kind: "operator_blocked",
|
|
266
174
|
id: "mandatory-diagnostic-unverified",
|
|
267
|
-
describe:
|
|
175
|
+
describe: "Additional readiness diagnostics",
|
|
268
176
|
remedy: "Resolve the mandatory diagnostic at the selected Bridge API target and retry.",
|
|
269
177
|
};
|
|
270
178
|
}
|
|
@@ -287,7 +195,7 @@ export function classifyV2Readiness(readiness, options = {}) {
|
|
|
287
195
|
const deferred = options.approvedStoredPolicy ||
|
|
288
196
|
options.effectivePolicy?.escalationChannel === "configured webhook" ||
|
|
289
197
|
(options.effectivePolicy === undefined && options.hasPolicyFile)
|
|
290
|
-
? V2_READINESS_REQUIREMENTS.filter((req) => req.id !== "
|
|
198
|
+
? V2_READINESS_REQUIREMENTS.filter((req) => req.id !== "server.unattended-notify-default")
|
|
291
199
|
: V2_READINESS_REQUIREMENTS;
|
|
292
200
|
// `--into-base` is the second opt-out, and it is structural rather than
|
|
293
201
|
// declared: it selects the branch-silent composition, whose policy carries no
|
|
@@ -303,6 +211,30 @@ export function classifyV2Readiness(readiness, options = {}) {
|
|
|
303
211
|
? gating.filter((req) => req.unattendedOnly !== true)
|
|
304
212
|
: gating;
|
|
305
213
|
const unmet = applicable.filter((req) => !req.satisfied(readiness));
|
|
214
|
+
// BAPI-1452 — launch evidence. Only a PROVEN gap (missing or invalid
|
|
215
|
+
// credential, proven no access, proven worktree impossibility) refuses;
|
|
216
|
+
// unverified evidence is a warning and never a pass. Server-fact blockers
|
|
217
|
+
// keep priority, so a disabled posture is still named first.
|
|
218
|
+
const launchChecks = launchEvidence !== undefined && !optedOut
|
|
219
|
+
? buildLaunchReadinessChecks(readiness, {
|
|
220
|
+
attended: options.attended,
|
|
221
|
+
intoBase: options.intoBase,
|
|
222
|
+
launchEvidence,
|
|
223
|
+
})
|
|
224
|
+
: [];
|
|
225
|
+
const launchBlocker = firstLaunchEvidenceBlocker(launchChecks);
|
|
226
|
+
const serverBlocker = unmet.find((req) => !(V2_RUNTIME_READINESS_IDS.includes(req.id) || V2_RUNTIME_READINESS_IDS.some(id => READINESS_LEGACY_IDS[id] === req.id)));
|
|
227
|
+
if (launchBlocker !== null && serverBlocker === undefined) {
|
|
228
|
+
return {
|
|
229
|
+
kind: "operator_blocked",
|
|
230
|
+
id: launchBlocker.id.slice(launchBlocker.id.indexOf(".") + 1),
|
|
231
|
+
describe: launchBlocker.id === MERGE_CREDENTIAL_ROW
|
|
232
|
+
? "a merge credential that has push and can read pull requests"
|
|
233
|
+
: "an executor clone whose shared git exclude can be prepared",
|
|
234
|
+
remedy: `${launchBlocker.detail ?? launchBlocker.label}. ${launchBlocker.remediation}`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const launchWarnings = launchChecks.filter((check) => check.status === "warn");
|
|
306
238
|
if (unmet.length === 0) {
|
|
307
239
|
// Older deployments have no bring-up block; their existing reconciler
|
|
308
240
|
// liveness predicate remains the available evidence. For deployments that
|
|
@@ -323,13 +255,16 @@ export function classifyV2Readiness(readiness, options = {}) {
|
|
|
323
255
|
remedy: "Resolve the missing or duplicate reconciler process before launch.",
|
|
324
256
|
};
|
|
325
257
|
}
|
|
326
|
-
return { kind: "green" };
|
|
258
|
+
return launchWarnings.length > 0 ? { kind: "green", warnings: launchWarnings } : { kind: "green" };
|
|
259
|
+
}
|
|
260
|
+
if (readiness.is_production && (serverBlocker?.id === 'server.worker-present' || unmet.some(req => req.id === 'server.worker-present'))) {
|
|
261
|
+
return { kind: 'operator_blocked', id: 'service-availability', describe: 'Service availability', remedy: 'Retry readiness before launching.' };
|
|
327
262
|
}
|
|
328
|
-
const blocker =
|
|
263
|
+
const blocker = serverBlocker;
|
|
329
264
|
if (blocker) {
|
|
330
265
|
return {
|
|
331
266
|
kind: "operator_blocked",
|
|
332
|
-
id: blocker.id,
|
|
267
|
+
id: { "server.indexed-workflows": READINESS_PRE_CREATE_CODES.INDEXED_WORKFLOWS_NOT_MET, "server.unattended-notify-default": READINESS_PRE_CREATE_CODES.ESCALATION_CHANNEL_NOT_MET, "server.test-command": READINESS_PRE_CREATE_CODES.TEST_COMMAND_NOT_MET }[blocker.id] ?? blocker.id,
|
|
333
268
|
describe: blocker.describe,
|
|
334
269
|
...(() => {
|
|
335
270
|
const remedy = blocker.remedyFor?.(readiness) ?? blocker.remedy;
|
|
@@ -339,157 +274,14 @@ export function classifyV2Readiness(readiness, options = {}) {
|
|
|
339
274
|
}
|
|
340
275
|
return { kind: "runtime_only", unmetIds: unmet.map((req) => req.id) };
|
|
341
276
|
}
|
|
342
|
-
export function getDriveEpicUsage() {
|
|
343
|
-
return [
|
|
344
|
-
"Usage: mcp-server drive-epic [options] <EPIC>",
|
|
345
|
-
"",
|
|
346
|
-
"Drives one epic with the conductor this project can actually run. Reads",
|
|
347
|
-
"conductor readiness from Bridge API and routes to exactly one path — it",
|
|
348
|
-
"never asks you to choose.",
|
|
349
|
-
"",
|
|
350
|
-
"Arguments:",
|
|
351
|
-
" <EPIC> Jira epic key, matches [A-Z]+-[0-9]+ (e.g. BAPI-885)",
|
|
352
|
-
"",
|
|
353
|
-
"Branch strategy (v2): a plan with two or more nodes defaults to a dedicated",
|
|
354
|
-
"epic/<KEY> branch — child PRs target it, one draft integration PR targets the",
|
|
355
|
-
"repository base branch. Both flags below only override that default; the",
|
|
356
|
-
"strategy itself is decided by the bootstrap this command delegates to, never",
|
|
357
|
-
"here.",
|
|
358
|
-
"",
|
|
359
|
-
"Posture (v2, BAPI-1102): a run that selects an epic branch is created",
|
|
360
|
-
"UNATTENDED by default — it authorizes automatic merge into the epic branch, and",
|
|
361
|
-
"the server stamps its CI gate at first approval. The INTEGRATION PR into the",
|
|
362
|
-
"repository base branch stays human-gated. Pass --attended to opt out. An",
|
|
363
|
-
"unattended run has three repository prerequisites (consent, which is ON by",
|
|
364
|
-
"default and switched on the Bridge admin panel's Setup page; an escalation",
|
|
365
|
-
"channel, either a verified notify webhook or the account's email address; and",
|
|
366
|
-
"confirmed review and CI workflows on the default branch) that readiness lists",
|
|
367
|
-
"with their state and that are refused BY NAME before any run is created.",
|
|
368
|
-
"",
|
|
369
|
-
"Every run gets an executor (BAPI-1279): with --plan-file, once the run is",
|
|
370
|
-
"created drive-epic starts a RUN-SCOPED EXECUTOR for it in the background, from",
|
|
371
|
-
"this checkout's Git top level. It claims only that run's jobs, logs to",
|
|
372
|
-
".bridge/executors/<run>.log, and exits on its own when the run is done or",
|
|
373
|
-
"abandoned. Re-running for the same live run starts no second one. A durable",
|
|
374
|
-
"launch profile starts none: it requires a fresh --repo-wide executor instead.",
|
|
375
|
-
"Checks that need no run happen FIRST, so a refusal before the run is created",
|
|
376
|
-
"creates nothing; an executor that fails to start after it leaves the run intact",
|
|
377
|
-
"and prints the log path and the exact command to start it by hand.",
|
|
378
|
-
"",
|
|
379
|
-
"Reconciler liveness is read from the deployment's heartbeat. Against a target",
|
|
380
|
-
"that is not this machine's plane (production, say), drive-epic never touches a",
|
|
381
|
-
"local plane: a target with no live reconciler is refused by name.",
|
|
382
|
-
"",
|
|
383
|
-
"One command (BAPI-1054, reordered by BAPI-1102): with --plan-file, drive-epic",
|
|
384
|
-
"starts the conductor runtime for you against the local plane endpoint, when",
|
|
385
|
-
"readiness is missing ONLY the reconciler. It brings up the CONTROL PLANE first",
|
|
386
|
-
"(server and reconciler), creates and approves the run, then starts executor lanes",
|
|
387
|
-
"SCOPED to that run, and starts the dead-man observer last — one invocation, no",
|
|
388
|
-
"manual `plane up` first. A report missing supervisor setup,",
|
|
389
|
-
"supervisor configuration, GitHub App credentials, or an unattended prerequisite",
|
|
390
|
-
"is NOT started: those are operator-owned, and drive-epic exits with that one",
|
|
391
|
-
"named reason instead.",
|
|
392
|
-
"",
|
|
393
|
-
"The plane port (BAPI-1153): with --plan-file and BAPI_BASE_URL naming the",
|
|
394
|
-
`local plane endpoint (127.0.0.1, port ${PLANE_SERVER_PORT_ENV_VAR} or the default),`,
|
|
395
|
-
"drive-epic handles the port one of three ways, and never asks you to choose:",
|
|
396
|
-
" - cold start: nothing listens there — it brings the plane up, then creates",
|
|
397
|
-
" the run;",
|
|
398
|
-
" - attach: this repository's own healthy plane is there — it creates the run",
|
|
399
|
-
" against that plane and starts NO second server or reconciler;",
|
|
400
|
-
" - refusal: anything else holds the port (another listener, a manifest for a",
|
|
401
|
-
" different repository, an unreadable manifest, a plane that cannot take a",
|
|
402
|
-
" run) — one named reason, and nothing is started.",
|
|
403
|
-
"One epic per plane — for its LANES: a plane's lanes serve the one run it is",
|
|
404
|
-
"bound to. A second run attaches to the same plane's reconciler and gets its own",
|
|
405
|
-
"run-scoped executor instead. A plane whose bound run cannot be read is refused",
|
|
406
|
-
"as plane-already-bound. Concurrent planes on one database are not supported.",
|
|
407
|
-
"Executor lanes and run-scoped executors are always scoped to the one run with",
|
|
408
|
-
"--epic-run-id, on every path.",
|
|
409
|
-
"",
|
|
410
|
-
"Finding the plan (BAPI-1285): without --plan-file, drive-epic searches",
|
|
411
|
-
"{docs_dir}/epic-plans/*/epic-plan.meta.json for a file naming this epic",
|
|
412
|
-
"({\"epic_key\": \"<KEY>\"}), where docs_dir is BAPI_DOCS_DIR or docs/tmp",
|
|
413
|
-
"resolved against the Git top level. Exactly one match with a sibling",
|
|
414
|
-
"epic-plan.dag.json is used as if you had passed --plan-file, and its path is",
|
|
415
|
-
"printed before anything is created. It never guesses: no match, two or more",
|
|
416
|
-
"matches, or an unusable meta file stops with the paths it found and creates",
|
|
417
|
-
"nothing. It never matches by folder name, child ticket key, or manifest. A",
|
|
418
|
-
"supplied --plan-file always wins, and discovery does not run. The meta file is",
|
|
419
|
-
"written by `emit-epic-implementer-bundle finalize`; a plan finalized before",
|
|
420
|
-
"that has none, so pass its DAG with --plan-file.",
|
|
421
|
-
"",
|
|
422
|
-
"A live run with no plan: when the epic already has a live run and no plan was",
|
|
423
|
-
"supplied or found, drive-epic does not create anything. It makes sure that run",
|
|
424
|
-
"has an executor (reusing a live one, never starting a second) and reports it. A",
|
|
425
|
-
"durable launch profile starts none and relies on its host's confirmed repo-wide",
|
|
426
|
-
"executor; a plane already bound to the run needs none. An epic with several active runs, or",
|
|
427
|
-
"a run that cannot be read, is refused by name.",
|
|
428
|
-
"",
|
|
429
|
-
"Preconditions are validated, never repaired: the current directory must be a Git",
|
|
430
|
-
"work tree inside the target repository, because the run's executor starts there.",
|
|
431
|
-
"No worktree is created for you and no replacement port is chosen for you — each",
|
|
432
|
-
"failure names the fix. Fix it and rerun.",
|
|
433
|
-
"",
|
|
434
|
-
"Options:",
|
|
435
|
-
" --plan-file <path> Plan DAG sidecar. Optional: without it, drive-epic finds",
|
|
436
|
-
" the plan by epic key (see above). A supplied path always",
|
|
437
|
-
" wins, and when the v2 path is selected drive-epic runs",
|
|
438
|
-
" the bootstrap directly with it.",
|
|
439
|
-
" --repo <name> Repo name (default: BAPI_REPO_NAME or .bridge/config)",
|
|
440
|
-
" --feature-branch <name> Override the derived epic/<KEY> name. Forwarded",
|
|
441
|
-
" verbatim, never interpreted here.",
|
|
442
|
-
" --into-base OPT OUT of the epic-branch default: children target the",
|
|
443
|
-
" repository base branch (the pre-BAPI-1009 behavior).",
|
|
444
|
-
" Contradictory with --feature-branch; passing both is a",
|
|
445
|
-
" parse error.",
|
|
446
|
-
" --attended OPT OUT of the unattended default. Forwarded verbatim to",
|
|
447
|
-
" the bootstrap, which owns what it composes; drive-epic",
|
|
448
|
-
" reads it only to skip the unattended prerequisites.",
|
|
449
|
-
"",
|
|
450
|
-
"Policy (forwarded verbatim; drive-epic interprets none of it):",
|
|
451
|
-
" --policy-file <path> JSON file holding the COMPLETE run policy.",
|
|
452
|
-
" --review-policy <src> PER-RUN review policy source.",
|
|
453
|
-
" --replace-policy Authorize replacing a LIVE run's stored policy.",
|
|
454
|
-
"",
|
|
455
|
-
"Launch target (BAPI-1260) — decided BEFORE any authenticated request:",
|
|
456
|
-
" --launch-profile <name> Select the host-local launch profile guided setup",
|
|
457
|
-
" approved (`install conductor --launch-profile <name>`):",
|
|
458
|
-
" target, repository, mode, runtime, and policy and",
|
|
459
|
-
" credential references. Useful for a local or other",
|
|
460
|
-
" non-production server and for durable hosts.",
|
|
461
|
-
" --base-url <url> Explicit Bridge API target. With a profile it must",
|
|
462
|
-
" agree with it — an override never rebinds a profile.",
|
|
463
|
-
"BAPI_EPIC_LAUNCH_PROFILE selects a host-local launch profile for epic",
|
|
464
|
-
"launches. --launch-profile takes precedence. For an environment-selected",
|
|
465
|
-
"profile, BAPI_BASE_URL remains the general CLI endpoint and does not",
|
|
466
|
-
"conflict with the epic launch target; --base-url still must agree.",
|
|
467
|
-
"Without either, BAPI_BASE_URL is used. With none of the three, drive-epic",
|
|
468
|
-
`targets production (${CONDUCTOR_DEFAULT_BASE_URL}) and uses the`,
|
|
469
|
-
"stored bapi:<repo> key when one is available. A stored key approved for a",
|
|
470
|
-
"different endpoint is never sent; an unapproved key is sent only to",
|
|
471
|
-
"production.",
|
|
472
|
-
"",
|
|
473
|
-
"Preview (BAPI-1285):",
|
|
474
|
-
" --dry-run Read-only launch preview. Resolves the target, reads",
|
|
475
|
-
" readiness and the epic's live run, discovers the plan,",
|
|
476
|
-
" and runs the bootstrap's own dry-run validation. It",
|
|
477
|
-
" reports what a real run WOULD do (create, attach,",
|
|
478
|
-
" refuse, or stop) and never creates a run, cuts a",
|
|
479
|
-
" branch, starts a plane, requests a lane, or starts or",
|
|
480
|
-
" signals an executor. Exit 0 for create or attach, 1 for",
|
|
481
|
-
" refuse or stop.",
|
|
482
|
-
" --json With --dry-run only: print exactly one JSON document on",
|
|
483
|
-
" stdout and nothing else. Alone, it is a parse error.",
|
|
484
|
-
"",
|
|
485
|
-
" -h, --help Show this help",
|
|
486
|
-
].join("\n");
|
|
487
|
-
}
|
|
488
277
|
/**
|
|
489
278
|
* Parse argv. Rejects malformed input BEFORE any side effect — no readiness
|
|
490
279
|
* read, no credential resolution, no delegation.
|
|
491
280
|
*/
|
|
492
281
|
export function parseDriveEpicArgs(argv) {
|
|
282
|
+
if (argv.includes("--help-advanced")) {
|
|
283
|
+
return { status: "help", usage: getDriveEpicAdvancedUsage() };
|
|
284
|
+
}
|
|
493
285
|
if (argv.includes("-h") || argv.includes("--help")) {
|
|
494
286
|
return { status: "help", usage: getDriveEpicUsage() };
|
|
495
287
|
}
|
|
@@ -528,8 +320,46 @@ export function parseDriveEpicArgs(argv) {
|
|
|
528
320
|
// BAPI-1285 preview flags. Valueless; `--json` is meaningless without `--dry-run`.
|
|
529
321
|
let dryRun = false;
|
|
530
322
|
let json = false;
|
|
323
|
+
let all = false;
|
|
324
|
+
let allowStalePlane = false;
|
|
325
|
+
// BAPI-1496 — verification hand-off pass-throughs. Captured, never interpreted:
|
|
326
|
+
// setup-epic owns the vocabulary, the pairing rules, and the policy keys.
|
|
327
|
+
let verificationHandoff;
|
|
328
|
+
const verificationHandoffExceptions = [];
|
|
329
|
+
// BAPI-1510 — drive-only already-merged child controls. Never reach setup argv.
|
|
330
|
+
const keepChildren = [];
|
|
331
|
+
const skipChildren = [];
|
|
332
|
+
let noMergedCheck = false;
|
|
531
333
|
for (let i = 0; i < argv.length; i++) {
|
|
532
334
|
const arg = argv[i];
|
|
335
|
+
if (arg === "--all") {
|
|
336
|
+
all = true;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (arg === "--no-merged-check") {
|
|
340
|
+
noMergedCheck = true;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (arg === "--keep-child" || arg === "--skip-child") {
|
|
344
|
+
const value = argv[i + 1];
|
|
345
|
+
if (value === undefined || value.startsWith("-")) {
|
|
346
|
+
return { status: "error", message: `${arg} requires a ticket key.` };
|
|
347
|
+
}
|
|
348
|
+
if (!EPIC_KEY_PATTERN.test(value)) {
|
|
349
|
+
return { status: "error", message: `${arg} requires a ticket key like PROJ-123, got '${value}'.` };
|
|
350
|
+
}
|
|
351
|
+
(arg === "--keep-child" ? keepChildren : skipChildren).push(value);
|
|
352
|
+
i++;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const childEq = arg.match(/^(--keep-child|--skip-child)=(.*)$/);
|
|
356
|
+
if (childEq) {
|
|
357
|
+
if (!EPIC_KEY_PATTERN.test(childEq[2])) {
|
|
358
|
+
return { status: "error", message: `${childEq[1]} requires a ticket key like PROJ-123, got '${childEq[2]}'.` };
|
|
359
|
+
}
|
|
360
|
+
(childEq[1] === "--keep-child" ? keepChildren : skipChildren).push(childEq[2]);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
533
363
|
if (arg === "--dry-run") {
|
|
534
364
|
dryRun = true;
|
|
535
365
|
continue;
|
|
@@ -542,6 +372,11 @@ export function parseDriveEpicArgs(argv) {
|
|
|
542
372
|
intoBase = true;
|
|
543
373
|
continue;
|
|
544
374
|
}
|
|
375
|
+
// BAPI-1476 — valueless, invocation-only, and never forwarded to setup-epic.
|
|
376
|
+
if (arg === "--allow-stale-plane") {
|
|
377
|
+
allowStalePlane = true;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
545
380
|
// BAPI-1102 — valueless, forwarded verbatim, and interpreted here for
|
|
546
381
|
// exactly one thing: making the unattended readiness prerequisites
|
|
547
382
|
// inapplicable. drive-epic reads no policy.
|
|
@@ -553,6 +388,29 @@ export function parseDriveEpicArgs(argv) {
|
|
|
553
388
|
replacePolicy = true;
|
|
554
389
|
continue;
|
|
555
390
|
}
|
|
391
|
+
if (arg === "--verification-handoff" || arg === "--verification-handoff-exception") {
|
|
392
|
+
const value = argv[i + 1];
|
|
393
|
+
if (value === undefined || value.startsWith("-")) {
|
|
394
|
+
return { status: "error", message: `${arg} requires a value.` };
|
|
395
|
+
}
|
|
396
|
+
if (arg === "--verification-handoff")
|
|
397
|
+
verificationHandoff = value;
|
|
398
|
+
else
|
|
399
|
+
verificationHandoffExceptions.push(value);
|
|
400
|
+
i++;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const handoffEq = arg.match(/^(--verification-handoff|--verification-handoff-exception)=(.*)$/);
|
|
404
|
+
if (handoffEq) {
|
|
405
|
+
if (handoffEq[2].trim().length === 0) {
|
|
406
|
+
return { status: "error", message: `${handoffEq[1]} requires a value.` };
|
|
407
|
+
}
|
|
408
|
+
if (handoffEq[1] === "--verification-handoff")
|
|
409
|
+
verificationHandoff = handoffEq[2];
|
|
410
|
+
else
|
|
411
|
+
verificationHandoffExceptions.push(handoffEq[2]);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
556
414
|
if (arg === "--plan-file" ||
|
|
557
415
|
arg === "--repo" ||
|
|
558
416
|
arg === "--feature-branch" ||
|
|
@@ -628,6 +486,22 @@ export function parseDriveEpicArgs(argv) {
|
|
|
628
486
|
"form of the read-only launch preview, and a real launch has no JSON output.",
|
|
629
487
|
};
|
|
630
488
|
}
|
|
489
|
+
// BAPI-1510 — contradictory child intent is refused before any file read or launch.
|
|
490
|
+
const overlap = keepChildren.filter((key) => skipChildren.includes(key));
|
|
491
|
+
if (overlap.length > 0) {
|
|
492
|
+
return {
|
|
493
|
+
status: "error",
|
|
494
|
+
message: `${overlap[0]} is named by both --keep-child and --skip-child. ` +
|
|
495
|
+
"Keep it (run it) or skip it, not both.",
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
if (noMergedCheck && skipChildren.length > 0) {
|
|
499
|
+
return {
|
|
500
|
+
status: "error",
|
|
501
|
+
message: "--no-merged-check turns off the already-merged check, but --skip-child needs " +
|
|
502
|
+
"that check to confirm the child is merged. Remove one of them.",
|
|
503
|
+
};
|
|
504
|
+
}
|
|
631
505
|
// BAPI-1009: refused HERE — before access resolution, before the readiness
|
|
632
506
|
// request, and before any delegation — with the same corrective wording
|
|
633
507
|
// setup-epic uses, so the two entry points explain one mistake one way.
|
|
@@ -670,7 +544,7 @@ export function parseDriveEpicArgs(argv) {
|
|
|
670
544
|
}
|
|
671
545
|
const epicKey = positionals[0];
|
|
672
546
|
if (!EPIC_KEY_PATTERN.test(epicKey)) {
|
|
673
|
-
return { status: "error", message: `Malformed epic key '${epicKey}'. Expected e.g.
|
|
547
|
+
return { status: "error", message: `Malformed epic key '${epicKey}'. Expected e.g. PROJ-885.` };
|
|
674
548
|
}
|
|
675
549
|
const options = {
|
|
676
550
|
epicKey,
|
|
@@ -685,8 +559,15 @@ export function parseDriveEpicArgs(argv) {
|
|
|
685
559
|
...(replacePolicy ? { replacePolicy: true } : {}),
|
|
686
560
|
...(launchProfile ? { launchProfile } : {}),
|
|
687
561
|
...(baseUrl ? { baseUrl } : {}),
|
|
562
|
+
...(all ? { all: true } : {}),
|
|
688
563
|
...(dryRun ? { dryRun: true } : {}),
|
|
689
564
|
...(json ? { json: true } : {}),
|
|
565
|
+
...(allowStalePlane ? { allowStalePlane: true } : {}),
|
|
566
|
+
...(verificationHandoff !== undefined ? { verificationHandoff } : {}),
|
|
567
|
+
...(verificationHandoffExceptions.length > 0 ? { verificationHandoffExceptions } : {}),
|
|
568
|
+
...(keepChildren.length > 0 ? { keepChildren: [...new Set(keepChildren)] } : {}),
|
|
569
|
+
...(skipChildren.length > 0 ? { skipChildren: [...new Set(skipChildren)] } : {}),
|
|
570
|
+
...(noMergedCheck ? { noMergedCheck: true } : {}),
|
|
690
571
|
};
|
|
691
572
|
return { status: "ok", options };
|
|
692
573
|
}
|
|
@@ -718,7 +599,7 @@ export function selectConductor(readiness) {
|
|
|
718
599
|
return {
|
|
719
600
|
kind: "selected",
|
|
720
601
|
conductor: DRIVE_EPIC_PREFERRED_CONDUCTOR,
|
|
721
|
-
reason: "
|
|
602
|
+
reason: "Epic Implementer readiness is green",
|
|
722
603
|
};
|
|
723
604
|
}
|
|
724
605
|
return {
|
|
@@ -739,7 +620,7 @@ export function selectConductor(readiness) {
|
|
|
739
620
|
* no quoting to get wrong.
|
|
740
621
|
*/
|
|
741
622
|
export function buildSetupEpicArgv(options) {
|
|
742
|
-
const { epicKey, planFile, repo, featureBranch, intoBase, attended, policyFile, reviewPolicy, replacePolicy, launchProfile, baseUrl, } = options;
|
|
623
|
+
const { epicKey, planFile, repo, featureBranch, intoBase, attended, policyFile, reviewPolicy, replacePolicy, launchProfile, baseUrl, verificationHandoff, verificationHandoffExceptions, } = options;
|
|
743
624
|
return [
|
|
744
625
|
"--epic-key",
|
|
745
626
|
epicKey,
|
|
@@ -760,17 +641,26 @@ export function buildSetupEpicArgv(options) {
|
|
|
760
641
|
// the adopted BAPI_BASE_URL instead.
|
|
761
642
|
...(launchProfile ? ["--launch-profile", launchProfile] : []),
|
|
762
643
|
...(baseUrl ? ["--base-url", baseUrl] : []),
|
|
644
|
+
// BAPI-1496 — the owner's verification hand-off choice, verbatim and only
|
|
645
|
+
// when supplied, so every pre-existing argv stays byte-identical.
|
|
646
|
+
...(verificationHandoff !== undefined ? ["--verification-handoff", verificationHandoff] : []),
|
|
647
|
+
...(verificationHandoffExceptions ?? []).flatMap((entry) => ["--verification-handoff-exception", entry]),
|
|
763
648
|
];
|
|
764
649
|
}
|
|
765
650
|
/**
|
|
766
651
|
* Render the no-plan stop: what discovery found, how to make a plan, and how to
|
|
767
652
|
* re-run with one.
|
|
768
653
|
*
|
|
769
|
-
*
|
|
770
|
-
* AND a live run. This runs only after plan discovery came back empty or
|
|
654
|
+
* Normal precondition: the caller has already ruled out delegation, a not-ready
|
|
655
|
+
* result, AND a live run. This runs only after plan discovery came back empty or
|
|
771
656
|
* ambiguous (BAPI-1285), so it never claims "nothing derives a plan from an epic
|
|
772
657
|
* key" — something does, and it names exactly what it found.
|
|
773
658
|
*
|
|
659
|
+
* The `plane_not_running` presentation is the exception to that precondition:
|
|
660
|
+
* the local plane refused connections, so readiness was never read and whether a
|
|
661
|
+
* run for this epic is already live is UNCHECKED. It says so, and tells the
|
|
662
|
+
* operator how to attach.
|
|
663
|
+
*
|
|
774
664
|
* It offers two ways to re-run and only one of them is a runnable `drive-epic`
|
|
775
665
|
* command: `/drive-epic-run` is a session slash command, not a second conductor.
|
|
776
666
|
*/
|
|
@@ -787,7 +677,20 @@ launch = { profile: null },
|
|
|
787
677
|
* BAPI-1285 — what discovery found. Absent reads as "no metadata named this
|
|
788
678
|
* epic", with the searched directory left as its placeholder.
|
|
789
679
|
*/
|
|
790
|
-
discovery) {
|
|
680
|
+
discovery, presentation = "default") {
|
|
681
|
+
// BAPI-1496 — an owner's explicit verification hand-off choice survives the
|
|
682
|
+
// re-run command verbatim. Nothing is derived: no flag, no flag added.
|
|
683
|
+
const handoffSuffix = [
|
|
684
|
+
...(launch.options?.verificationHandoff !== undefined
|
|
685
|
+
? ["--verification-handoff", launch.options.verificationHandoff]
|
|
686
|
+
: []),
|
|
687
|
+
...(launch.options?.verificationHandoffExceptions ?? []).flatMap((entry) => [
|
|
688
|
+
"--verification-handoff-exception",
|
|
689
|
+
entry,
|
|
690
|
+
]),
|
|
691
|
+
]
|
|
692
|
+
.map((part) => ` ${shellQuote(part)}`)
|
|
693
|
+
.join("");
|
|
791
694
|
const invocation = launch.profile !== null
|
|
792
695
|
? renderEpicLaunchCommand(launch.profile, {
|
|
793
696
|
epicKey,
|
|
@@ -796,8 +699,8 @@ discovery) {
|
|
|
796
699
|
...(launch.options?.attended ? { attended: true } : {}),
|
|
797
700
|
...(launch.options?.intoBase ? { intoBase: true } : {}),
|
|
798
701
|
...(launch.options?.featureBranch ? { featureBranch: launch.options.featureBranch } : {}),
|
|
799
|
-
}).replace(`${shellQuote("<path>")}`, "<path>")
|
|
800
|
-
: `npx -y ${MCP_PACKAGE_NAME} drive-epic ${epicKey} --plan-file <path
|
|
702
|
+
}).replace(`${shellQuote("<path>")}`, "<path>") + handoffSuffix
|
|
703
|
+
: `npx -y ${MCP_PACKAGE_NAME} drive-epic ${epicKey} --plan-file <path>${handoffSuffix}`;
|
|
801
704
|
const plansDir = discovery?.epic_plans_dir ?? "{docs_dir}/epic-plans";
|
|
802
705
|
const outcome = discovery?.status === "plan_ambiguous"
|
|
803
706
|
? [
|
|
@@ -809,7 +712,7 @@ discovery) {
|
|
|
809
712
|
const skipped = discovery !== undefined && discovery.skipped.length > 0
|
|
810
713
|
? [
|
|
811
714
|
"",
|
|
812
|
-
"These metadata files could not be used, so they were not counted:",
|
|
715
|
+
"These plan metadata files were missing or could not be used, so they were not counted:",
|
|
813
716
|
...discovery.skipped.map((entry) => ` - ${entry.path} (${entry.reason})`),
|
|
814
717
|
]
|
|
815
718
|
: [];
|
|
@@ -817,7 +720,9 @@ discovery) {
|
|
|
817
720
|
conductor: "v2",
|
|
818
721
|
invocation,
|
|
819
722
|
lines: [
|
|
820
|
-
|
|
723
|
+
presentation === "plane_not_running"
|
|
724
|
+
? `${epicKey}: readiness could not be checked because the local plane is not running and no usable plan could be selected.`
|
|
725
|
+
: `${epicKey}: ${selection.reason}, but no plan was supplied and none could be selected.`,
|
|
821
726
|
"",
|
|
822
727
|
...outcome,
|
|
823
728
|
...skipped,
|
|
@@ -832,19 +737,42 @@ discovery) {
|
|
|
832
737
|
"",
|
|
833
738
|
` 1. To make one, run /plan-epic ${epicKey} in an interactive session. It writes the`,
|
|
834
739
|
" sidecar to {docs_dir}/epic-plans/{epic_slug}/epic-plan.dag.json.",
|
|
835
|
-
"
|
|
836
|
-
"
|
|
837
|
-
"
|
|
838
|
-
" plan is found next time.",
|
|
740
|
+
" For an existing /plan-epic sidecar that has the required bundle inputs and",
|
|
741
|
+
" whose tickets already exist, run emit-epic-implementer-bundle finalize",
|
|
742
|
+
" instead — it resolves the placeholder keys, attaches touched_files, and",
|
|
743
|
+
" writes epic-plan.meta.json so the plan is found next time.",
|
|
744
|
+
" A folder produced by /define-tickets holds a DAG but no metadata file. Use it",
|
|
745
|
+
" directly with --plan-file <that folder's epic-plan.dag.json>.",
|
|
839
746
|
"",
|
|
840
747
|
` 2. Re-run with the plan, in a session: /drive-epic-run ${epicKey} <plan path>`,
|
|
841
748
|
` or from a shell: ${invocation}`,
|
|
842
749
|
"",
|
|
843
750
|
`That shell command bootstraps the run for you; ${SETUP_EPIC_SUBCOMMAND} is not`,
|
|
844
751
|
"something you invoke directly.",
|
|
752
|
+
...(presentation === "plane_not_running"
|
|
753
|
+
? ["", PLANE_DOWN_LIVE_RUN_CAVEAT]
|
|
754
|
+
: []),
|
|
845
755
|
],
|
|
846
756
|
};
|
|
847
757
|
}
|
|
758
|
+
/** Appended to the plane-not-running handoff: live-run existence was not checked. */
|
|
759
|
+
const PLANE_DOWN_LIVE_RUN_CAVEAT = "If a run for this epic is already live, bring its plane up and preview again to attach.";
|
|
760
|
+
/** Placeholder selection for the plane-down stop: readiness was never read. */
|
|
761
|
+
const PLANE_DOWN_SELECTION = {
|
|
762
|
+
kind: "selected",
|
|
763
|
+
conductor: DRIVE_EPIC_PREFERRED_CONDUCTOR,
|
|
764
|
+
reason: "readiness was not checked",
|
|
765
|
+
};
|
|
766
|
+
/**
|
|
767
|
+
* The one text both the preview and a real launch print for the guarded
|
|
768
|
+
* plane-down no-plan stop, so the preview is a verbatim part of the real output.
|
|
769
|
+
*/
|
|
770
|
+
function planeDownNoPlanStopText(epicKey, launch, discovery) {
|
|
771
|
+
const text = renderConductorHandoff(PLANE_DOWN_SELECTION, epicKey, launch, discovery, "plane_not_running")
|
|
772
|
+
.lines.join("\n");
|
|
773
|
+
assertSingleConductorInvocation(text);
|
|
774
|
+
return text;
|
|
775
|
+
}
|
|
848
776
|
/**
|
|
849
777
|
* Tokens that identify a conductor path in user-facing output. Used by the
|
|
850
778
|
* output guard and by its test — one list, so a new spelling cannot be added to
|
|
@@ -864,7 +792,7 @@ const SETUP_EPIC_SUBCOMMAND = CONDUCTOR_INVOCATION_TOKENS[0];
|
|
|
864
792
|
export function assertSingleConductorInvocation(text) {
|
|
865
793
|
const named = CONDUCTOR_INVOCATION_TOKENS.filter((token) => text.includes(token));
|
|
866
794
|
if (named.length > 1) {
|
|
867
|
-
throw new Error(`drive-epic emitted more than one
|
|
795
|
+
throw new Error(`drive-epic emitted more than one launch path (${named.join(", ")}). ` +
|
|
868
796
|
"Exactly one path may ever be presented.");
|
|
869
797
|
}
|
|
870
798
|
// BAPI-1260 — the entry point itself, too. A rendered handoff now carries a
|
|
@@ -1120,6 +1048,9 @@ function createDefaultPlanDiscoveryFs() {
|
|
|
1120
1048
|
export async function readLiveEpicRunByKey(access, epicKey, fetchImpl = globalThis.fetch) {
|
|
1121
1049
|
try {
|
|
1122
1050
|
const state = await fetchEpicRunState(access, epicKey, fetchImpl);
|
|
1051
|
+
// The same response's engine-reported channel facts; read here so attach,
|
|
1052
|
+
// preview and setup never re-derive a channel from the redacted policy.
|
|
1053
|
+
const notificationSink = state?.notification_sink;
|
|
1123
1054
|
const run = state
|
|
1124
1055
|
?.epic_run;
|
|
1125
1056
|
if (typeof run?.epic_run_id !== "string" ||
|
|
@@ -1131,8 +1062,9 @@ export async function readLiveEpicRunByKey(access, epicKey, fetchImpl = globalTh
|
|
|
1131
1062
|
kind: "live",
|
|
1132
1063
|
epicRunId: run.epic_run_id,
|
|
1133
1064
|
status: run.status,
|
|
1065
|
+
ticketReview: ticketReviewSummary(run.ticket_review),
|
|
1134
1066
|
...(run.policy_json !== undefined
|
|
1135
|
-
? { effectivePolicy: effectivePolicyFacts(run.policy_json, "stored") }
|
|
1067
|
+
? { effectivePolicy: effectivePolicyFacts(run.policy_json, "stored", notificationSink) }
|
|
1136
1068
|
: {}),
|
|
1137
1069
|
};
|
|
1138
1070
|
}
|
|
@@ -1191,6 +1123,25 @@ export function createDefaultDriveEpicDeps() {
|
|
|
1191
1123
|
cwd: () => process.cwd(),
|
|
1192
1124
|
resolvePlaneEndpoint: () => resolvePlaneServerEndpoint(process.env),
|
|
1193
1125
|
probePlanePort: (host, port) => probeTcpPort(host, port, PLANE_PORT_PROBE_TIMEOUT_MS),
|
|
1126
|
+
checkExecutorBuildFreshness: (repoRoot) => checkPlaneBuildFreshness(repoRoot, { fs: createPlaneFsDeps() }),
|
|
1127
|
+
collectPlaneFreshness: ({ repoRoot, ownsManifest, expectedPlaneId }) => checkPlaneFreshness(repoRoot, {
|
|
1128
|
+
execFile: createPlaneExecFile(),
|
|
1129
|
+
fileExists: (filePath) => fsStat(filePath).then((entry) => entry.isFile(), () => false),
|
|
1130
|
+
platform: process.platform,
|
|
1131
|
+
readPlaneManifest: (root) => readPlaneManifest(root, createPlaneFsDeps()),
|
|
1132
|
+
}, repoRoot, { ownsManifest, ...(expectedPlaneId !== undefined ? { expectedPlaneId } : {}) }),
|
|
1133
|
+
inspectHostSleep: (repoRoot) => inspectHostSleep(repoRoot, { execFile: createPlaneExecFile(), platform: process.platform }),
|
|
1134
|
+
inspectRequiredRunners: ({ repoRoot, providerRepository, gateProfile }) => inspectRequiredSelfHostedRunners({
|
|
1135
|
+
repoRoot,
|
|
1136
|
+
providerRepository,
|
|
1137
|
+
gateProfile,
|
|
1138
|
+
execFile: createPlaneExecFile(),
|
|
1139
|
+
workflows: createWorkflowSource(),
|
|
1140
|
+
}),
|
|
1141
|
+
inspectEpicBranchBehind: ({ repoRoot, branch, baseBranch }) => inspectEpicBranchBehindBase({ repoRoot, branch, baseBranch, execFile: createPlaneExecFile() }),
|
|
1142
|
+
fetchUsageAdvisory: (access) => fetchRepositoryUsageAdvisory(access),
|
|
1143
|
+
inspectWorkerDatabase: createWorkerDatabaseDoctorInspector({ env: process.env, cwd: process.cwd() }),
|
|
1144
|
+
cleanupTerminalRunPlane: (input) => cleanupTerminalRunPlane(input, createTerminalRunCleanupDeps(process.env)),
|
|
1194
1145
|
readRunState: async (access, epicRunId) => {
|
|
1195
1146
|
const state = await fetchEpicRunState(access, epicRunId, globalThis.fetch);
|
|
1196
1147
|
const run = state.epic_run;
|
|
@@ -1242,8 +1193,28 @@ export function createDefaultDriveEpicDeps() {
|
|
|
1242
1193
|
// preview must not call.
|
|
1243
1194
|
inspectRunExecutorPrecondition: async (cwd) => {
|
|
1244
1195
|
const result = await prepareRunExecutorDefault(cwd, createDefaultRunExecutorLaunchDeps());
|
|
1245
|
-
|
|
1196
|
+
// BAPI-1452: the Git top level rides along so the dry run can inspect the
|
|
1197
|
+
// same clone a real launch would, without ever preparing it.
|
|
1198
|
+
return result.ok
|
|
1199
|
+
? { ok: true, repoRoot: result.prepared.repoRoot }
|
|
1200
|
+
: { ok: false, message: result.message };
|
|
1246
1201
|
},
|
|
1202
|
+
probeLaunchMergeCredential: (args) => probeLocalMergeCapability(args),
|
|
1203
|
+
// BAPI-1461: the local executor this launch starts runs in the person's own
|
|
1204
|
+
// session, so its credential may come from the repository's stored entry.
|
|
1205
|
+
resolveLaunchMergeCredential: ({ kind, repository }) => resolveMergeCredentialAtAction(process.env, kind, {
|
|
1206
|
+
allowStoredEntry: true,
|
|
1207
|
+
providerRepository: repository,
|
|
1208
|
+
storeDeps: nodeGithubMergeCredentialReadDeps(process.env),
|
|
1209
|
+
}),
|
|
1210
|
+
launchReadinessLog: (message) => console.error(message),
|
|
1211
|
+
// Read-only filesystem boundary: `readFile`, `stat`, and a W_OK `access`
|
|
1212
|
+
// check. Nothing here can create or write the exclude.
|
|
1213
|
+
inspectWorktreeExclude: (repoRoot) => resolveWorktreeSettingsExclude(repoRoot, {
|
|
1214
|
+
readFile: (filePath) => readFile(filePath, "utf8"),
|
|
1215
|
+
stat: async (filePath) => ({ mode: (await fsStat(filePath)).mode }),
|
|
1216
|
+
accessWritable: (target) => fsAccess(target, fsConstants.W_OK),
|
|
1217
|
+
}),
|
|
1247
1218
|
prepareRunExecutor: (cwd) => prepareRunExecutorDefault(cwd, createDefaultRunExecutorLaunchDeps()),
|
|
1248
1219
|
ensureRunExecutor: (args) => ensureRunExecutorDefault(args, createDefaultRunExecutorLaunchDeps()),
|
|
1249
1220
|
// stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
|
|
@@ -1292,7 +1263,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1292
1263
|
// child processes' BAPI_BASE_URL.
|
|
1293
1264
|
const accessResult = await deps.resolveAccess(repo, undefined, launch);
|
|
1294
1265
|
if (!accessResult.ok) {
|
|
1295
|
-
return escalate(deps, epicKey, `
|
|
1266
|
+
return escalate(deps, epicKey, `Epic Implementer readiness could not be read: ${accessResult.error}`);
|
|
1296
1267
|
}
|
|
1297
1268
|
const launchProfile = accessResult.profile ?? null;
|
|
1298
1269
|
deps.adoptLaunchTarget?.(accessResult.access.baseUrl);
|
|
@@ -1310,6 +1281,8 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1310
1281
|
access: accessResult.access,
|
|
1311
1282
|
profile: launchProfile,
|
|
1312
1283
|
targetSource: accessResult.targetSource ?? "default",
|
|
1284
|
+
freshness: createPlaneFreshnessCache(),
|
|
1285
|
+
launchCredential: {},
|
|
1313
1286
|
};
|
|
1314
1287
|
// BAPI-1285 — with no `--plan-file`, look for the plan BEFORE readiness. A
|
|
1315
1288
|
// discovered plan must count as a supplied one for the BAPI-1153 cold-start
|
|
@@ -1320,11 +1293,16 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1320
1293
|
// escalates exactly as before, and the live-run read stays after readiness.
|
|
1321
1294
|
let discovered = null;
|
|
1322
1295
|
let docsRootUnresolved = false;
|
|
1296
|
+
// BAPI-1532 — whether the effective plan was FOUND rather than supplied. A
|
|
1297
|
+
// discovered plan is a convenience, not approved input: it may still be used to
|
|
1298
|
+
// create a run, but a live run is attached to first and the plan stays unused.
|
|
1299
|
+
let planWasDiscovered = false;
|
|
1323
1300
|
if (planFile === undefined) {
|
|
1324
1301
|
const docs = await deps.resolveDocsDir();
|
|
1325
1302
|
if (docs.ok) {
|
|
1326
1303
|
discovered = await discoverEpicPlan(epicKey, docs.docsDir, deps.discoveryFs);
|
|
1327
1304
|
if (discovered.status === "found") {
|
|
1305
|
+
planWasDiscovered = true;
|
|
1328
1306
|
ctx = { ...ctx, options: { ...ctx.options, planFile: discovered.path } };
|
|
1329
1307
|
}
|
|
1330
1308
|
}
|
|
@@ -1348,6 +1326,10 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1348
1326
|
readiness = await deps.readReadiness(accessResult.access);
|
|
1349
1327
|
}
|
|
1350
1328
|
catch (err) {
|
|
1329
|
+
// BAPI-1455 — a proven-incompatible local schema is a terminal refusal,
|
|
1330
|
+
// not an unknown: the server read the catalog and named what is missing.
|
|
1331
|
+
if (isSchemaIncompatibleError(err))
|
|
1332
|
+
return refuse(deps, formatSchemaIncompatibleRefusal(epicKey, err));
|
|
1351
1333
|
// BAPI-1153 — the cold-start case. With a plan file, a readiness read that
|
|
1352
1334
|
// failed at the TRANSPORT against the plane's own endpoint, on a port that
|
|
1353
1335
|
// actively refuses connections, is not "unknown": it is "no server yet".
|
|
@@ -1376,9 +1358,33 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1376
1358
|
}
|
|
1377
1359
|
}
|
|
1378
1360
|
}
|
|
1379
|
-
|
|
1361
|
+
// The narrow no-plan stop: a local, non-durable plane that refuses
|
|
1362
|
+
// connections, with no usable plan supplied or discovered. Readiness was
|
|
1363
|
+
// never read, so this is the existing stop outcome — not a new refusal.
|
|
1364
|
+
// Nothing is started or created.
|
|
1365
|
+
if (ctx.options.planFile === undefined &&
|
|
1366
|
+
planeTarget &&
|
|
1367
|
+
err instanceof ConductorBridgeApiError &&
|
|
1368
|
+
err.kind === "network" &&
|
|
1369
|
+
launchProfile?.executionMode !== "durable" &&
|
|
1370
|
+
discovered !== null &&
|
|
1371
|
+
(discovered.status === "plan_not_found" || discovered.status === "plan_ambiguous")) {
|
|
1372
|
+
const endpoint = planeEndpointForBaseUrl(deps, accessResult.access.baseUrl);
|
|
1373
|
+
if (endpoint !== null) {
|
|
1374
|
+
const probe = await deps.probePlanePort(endpoint.host, endpoint.port).catch(() => ({ kind: "error" }));
|
|
1375
|
+
if (probe.kind === "refused") {
|
|
1376
|
+
deps.errorLog(planeDownNoPlanStopText(epicKey, { profile: launchProfile, options: parsed.options }, discovered));
|
|
1377
|
+
return 1;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
return escalate(deps, epicKey, `Epic Implementer readiness could not be read: ${safeDiagnosticMessage(err, "readiness request failed")}`);
|
|
1380
1382
|
}
|
|
1381
|
-
|
|
1383
|
+
// BAPI-1532 — an EXPLICIT plan selects its setup now. A discovered plan does
|
|
1384
|
+
// not: plan selection, approval, and the create-time escalation-channel checks
|
|
1385
|
+
// run only on the create path, after the live-run read below, so attaching to a
|
|
1386
|
+
// live run never depends on them.
|
|
1387
|
+
if (ctx.options.planFile !== undefined && !planWasDiscovered) {
|
|
1382
1388
|
const prepared = await prepareSetupSelection(ctx);
|
|
1383
1389
|
if (!prepared.ok) {
|
|
1384
1390
|
return refuse(deps, [
|
|
@@ -1417,8 +1423,24 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1417
1423
|
if (live.kind === "unknown")
|
|
1418
1424
|
return refuse(deps, liveRunUnknownRefusal(epicKey, live.reason).lines);
|
|
1419
1425
|
if (discovered.status === "found") {
|
|
1420
|
-
|
|
1421
|
-
|
|
1426
|
+
if (live.kind === "live") {
|
|
1427
|
+
// BAPI-1532 — attach FIRST. The discovered plan is dropped from the
|
|
1428
|
+
// effective options, so nothing below can treat it as approved input,
|
|
1429
|
+
// and the local file is left untouched.
|
|
1430
|
+
const { planFile: _unusedDiscoveredPlan, ...attachOptions } = ctx.options;
|
|
1431
|
+
return await attachToLiveRun({ ...ctx, options: attachOptions }, readiness, live, discovered.path);
|
|
1432
|
+
}
|
|
1433
|
+
// The create path: setup selection runs now. The plan was folded into
|
|
1434
|
+
// `ctx.options` before readiness; from here it is indistinguishable from
|
|
1435
|
+
// a supplied `--plan-file`.
|
|
1436
|
+
const prepared = await prepareSetupSelection(ctx);
|
|
1437
|
+
if (!prepared.ok) {
|
|
1438
|
+
return refuse(deps, [
|
|
1439
|
+
`${epicKey}: REFUSED (${prepared.reasonId}) — the selected run or policy could not be verified.`,
|
|
1440
|
+
"Nothing was created or started.",
|
|
1441
|
+
]);
|
|
1442
|
+
}
|
|
1443
|
+
ctx = { ...ctx, handoff: prepared.handoff };
|
|
1422
1444
|
await announceFoundPlan();
|
|
1423
1445
|
}
|
|
1424
1446
|
else if (live.kind === "live") {
|
|
@@ -1461,10 +1483,33 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1461
1483
|
// Otherwise — no plane here, or one owned by a sibling worktree — the
|
|
1462
1484
|
// run gets a run-scoped executor. The preflight runs only when a
|
|
1463
1485
|
// manifest says there is a plane in this checkout to attach to.
|
|
1486
|
+
// BAPI-1496: a plane bound to a finished run is torn down BEFORE it can
|
|
1487
|
+
// be attached to. Once it is gone, nothing serves this endpoint, so the
|
|
1488
|
+
// launch cold-starts a fresh plane rather than creating against the
|
|
1489
|
+
// stopped API or trusting the readiness read from it.
|
|
1490
|
+
const cleaned = await cleanUpTerminalRunPlane(ctx, deps.cwd());
|
|
1491
|
+
if (cleaned?.kind === "completed") {
|
|
1492
|
+
const endpoint = planeEndpointForBaseUrl(deps, ctx.access.baseUrl);
|
|
1493
|
+
const probe = endpoint
|
|
1494
|
+
? await deps.probePlanePort(endpoint.host, endpoint.port).catch(() => ({ kind: "error" }))
|
|
1495
|
+
: null;
|
|
1496
|
+
if (probe?.kind === "refused")
|
|
1497
|
+
return await coldStartPlane(ctx);
|
|
1498
|
+
}
|
|
1464
1499
|
const attachable = await findAttachablePlane(ctx);
|
|
1465
1500
|
if (attachable !== null) {
|
|
1466
1501
|
return await attachToPlane(ctx, attachable, readiness);
|
|
1467
1502
|
}
|
|
1503
|
+
// BAPI-1476: no manifest in this checkout belongs to the plane serving the
|
|
1504
|
+
// endpoint (a sibling worktree's, or none), so the snapshot is unverified
|
|
1505
|
+
// here; the database check still runs. Decided before setup-epic.
|
|
1506
|
+
const staleSibling = await planeFreshnessCreateGuard(ctx, {
|
|
1507
|
+
repoRoot: deps.cwd(),
|
|
1508
|
+
ownsManifest: false,
|
|
1509
|
+
});
|
|
1510
|
+
if (staleSibling !== null)
|
|
1511
|
+
return staleSibling;
|
|
1512
|
+
await surfaceWorkerDatabaseAdvisory(ctx);
|
|
1468
1513
|
return await createRunWithRunExecutor(ctx, readiness);
|
|
1469
1514
|
}
|
|
1470
1515
|
// Operator-blocked: ONE named reason, and nothing is started. `plane up`
|
|
@@ -1472,8 +1517,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1472
1517
|
// so bringing it up would leave a running plane behind and change nothing.
|
|
1473
1518
|
if (classification.kind === "operator_blocked") {
|
|
1474
1519
|
const refusal = [
|
|
1475
|
-
`${epicKey}: the engine path still needs ${classification.describe}
|
|
1476
|
-
`(${classification.id}).`,
|
|
1520
|
+
`${epicKey}: the engine path still needs ${classification.describe}.`,
|
|
1477
1521
|
"",
|
|
1478
1522
|
"This is not something starting the runtime can fix, so nothing was started.",
|
|
1479
1523
|
// The requirement's OWN remedy when it has one. Telling an operator to
|
|
@@ -1497,6 +1541,8 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1497
1541
|
}
|
|
1498
1542
|
// Runtime-only: this invocation composes bring-up with run creation.
|
|
1499
1543
|
const repoRoot = deps.cwd();
|
|
1544
|
+
// BAPI-1496: a plane bound to a finished run is torn down before preflight.
|
|
1545
|
+
await cleanUpTerminalRunPlane(ctx, repoRoot);
|
|
1500
1546
|
const preflight = await deps.runPlanePreflight(repoRoot);
|
|
1501
1547
|
// BAPI-1153 — ATTACH. A port serving this repository's own live, healthy
|
|
1502
1548
|
// plane is not a blocker when the Bridge API this command talks to IS that
|
|
@@ -1517,7 +1563,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1517
1563
|
});
|
|
1518
1564
|
if (precondition) {
|
|
1519
1565
|
const lines = [
|
|
1520
|
-
`${epicKey}: cannot start the
|
|
1566
|
+
`${epicKey}: cannot start the Epic Implementer runtime — ${precondition.check} failed.`,
|
|
1521
1567
|
formatPlaneDiagnostic(precondition),
|
|
1522
1568
|
];
|
|
1523
1569
|
// BAPI-1153: an occupied port names WHICH kind of occupant it is and its
|
|
@@ -1548,7 +1594,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1548
1594
|
// The new order is the only one that can work: the server has to be up
|
|
1549
1595
|
// before a run can be created, and the run has to exist before a lane can
|
|
1550
1596
|
// be scoped to it.
|
|
1551
|
-
deps.log(`${epicKey}: the
|
|
1597
|
+
deps.log(`${epicKey}: the Epic Implementer runtime is not live; starting its control plane before creating the run.`);
|
|
1552
1598
|
// The SAME preflight result is handed to the launch, so the port that was
|
|
1553
1599
|
// probed is the port that gets launched and the two cannot disagree.
|
|
1554
1600
|
const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
|
|
@@ -1562,6 +1608,12 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1562
1608
|
// the detached runtime is its own process-group leader and outlives us by
|
|
1563
1609
|
// design, which is what "the plane is left running" means.
|
|
1564
1610
|
void controlPlane.lifetime.catch(() => undefined);
|
|
1611
|
+
// BAPI-1476: the plane this invocation just started, checked before any
|
|
1612
|
+
// setup or run-create. A refusal leaves the plane running and says so.
|
|
1613
|
+
const staleLaunched = await planeFreshnessCreateGuard(ctx, { repoRoot, ownsManifest: true, planeId: controlPlane.planeId }, LAUNCHED_PLANE_LEFT_RUNNING);
|
|
1614
|
+
if (staleLaunched !== null)
|
|
1615
|
+
return staleLaunched;
|
|
1616
|
+
await surfaceWorkerDatabaseAdvisory(ctx);
|
|
1565
1617
|
// Readiness is NOT re-read as a gate here. A successful bring-up IS the
|
|
1566
1618
|
// runtime fact: the launch already waited for this plane's own reconciler
|
|
1567
1619
|
// heartbeat. (`reconciler_live` now reads the deployment heartbeat, but the
|
|
@@ -1573,7 +1625,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1573
1625
|
repoRoot,
|
|
1574
1626
|
planeId: controlPlane.planeId,
|
|
1575
1627
|
awaitPlaneReady: controlPlane.awaitPlaneReady,
|
|
1576
|
-
});
|
|
1628
|
+
}, readiness);
|
|
1577
1629
|
}
|
|
1578
1630
|
// No plan file, and readiness is not green: v2 is not ready and there is no
|
|
1579
1631
|
// second conductor to offer. Report it as a not-ready refusal on stderr with
|
|
@@ -1583,8 +1635,9 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1583
1635
|
`${epicKey}: ${selection.reason}.`,
|
|
1584
1636
|
"",
|
|
1585
1637
|
"The v2 engine is not ready to drive this epic, and there is no alternative",
|
|
1586
|
-
"
|
|
1587
|
-
"
|
|
1638
|
+
"engine to fall back to. Resolve Epic Implementer readiness (`install conductor`,",
|
|
1639
|
+
"then `doctor`) and run drive-epic again.",
|
|
1640
|
+
`See ${CONSUMER_GUIDE_POINTER}.`,
|
|
1588
1641
|
].join("\n");
|
|
1589
1642
|
assertSingleConductorInvocation(refusal);
|
|
1590
1643
|
deps.errorLog(refusal);
|
|
@@ -1633,7 +1686,7 @@ async function surfaceContainmentAdvisory(deps, planFile) {
|
|
|
1633
1686
|
* A constant so the preview's `readiness.remedy` is the very line the real
|
|
1634
1687
|
* refusal prints, not a second copy that can drift.
|
|
1635
1688
|
*/
|
|
1636
|
-
const ESCALATE_REMEDY = "Restore Bridge API access and run drive-epic again, or ask an operator to resolve
|
|
1689
|
+
const ESCALATE_REMEDY = "Restore Bridge API access and run drive-epic again, or ask an operator to resolve Epic Implementer readiness.";
|
|
1637
1690
|
/** The reason id the preview reports for an unknown readiness read. */
|
|
1638
1691
|
const READINESS_UNKNOWN_REASON_ID = "readiness-unknown";
|
|
1639
1692
|
/**
|
|
@@ -1642,8 +1695,9 @@ const READINESS_UNKNOWN_REASON_ID = "readiness-unknown";
|
|
|
1642
1695
|
* the preview cannot disagree.
|
|
1643
1696
|
*/
|
|
1644
1697
|
function operatorBlockedRemedy(classification) {
|
|
1645
|
-
|
|
1646
|
-
`Resolve it with \`${PRINTED_CONDUCTOR_INSTALL_COMMAND}\` (${PRINTED_CHECK_NAME_INSTRUCTION}), then run drive-epic again
|
|
1698
|
+
const remedy = classification.remedy ??
|
|
1699
|
+
`Resolve it with \`${PRINTED_CONDUCTOR_INSTALL_COMMAND}\` (${PRINTED_CHECK_NAME_INSTRUCTION}), then run drive-epic again.`;
|
|
1700
|
+
return `${remedy} See ${CONSUMER_GUIDE_POINTER}.`;
|
|
1647
1701
|
}
|
|
1648
1702
|
/**
|
|
1649
1703
|
* The escalation branch. Prints NO conductor invocation at all — naming a
|
|
@@ -1652,10 +1706,10 @@ function operatorBlockedRemedy(classification) {
|
|
|
1652
1706
|
*/
|
|
1653
1707
|
function escalate(deps, epicKey, reason) {
|
|
1654
1708
|
const lines = [
|
|
1655
|
-
`Cannot determine which
|
|
1709
|
+
`Cannot determine which engine owns ${epicKey}: ${reason}`,
|
|
1656
1710
|
"",
|
|
1657
1711
|
"This is not a not-ready result — it is an unknown one, and starting the wrong",
|
|
1658
|
-
"
|
|
1712
|
+
"engine on an epic wedges it permanently.",
|
|
1659
1713
|
ESCALATE_REMEDY,
|
|
1660
1714
|
];
|
|
1661
1715
|
const text = lines.join("\n");
|
|
@@ -1664,6 +1718,10 @@ function escalate(deps, epicKey, reason) {
|
|
|
1664
1718
|
return 1;
|
|
1665
1719
|
}
|
|
1666
1720
|
/** Resolve policy intent once for the setup delegation, after a fresh run read. */
|
|
1721
|
+
/** Narrow an effective-policy channel label to the closed identity set. */
|
|
1722
|
+
function channelIdentityOf(label) {
|
|
1723
|
+
return label === "configured webhook" || label === "account email" ? label : "unavailable";
|
|
1724
|
+
}
|
|
1667
1725
|
async function prepareSetupSelection(ctx, coldStart = false) {
|
|
1668
1726
|
const live = coldStart ? { kind: "none" } : await ctx.deps.readLiveEpicRun(ctx.access, ctx.epicKey);
|
|
1669
1727
|
if (live.kind === "conflict")
|
|
@@ -1701,12 +1759,157 @@ async function prepareSetupSelection(ctx, coldStart = false) {
|
|
|
1701
1759
|
policy,
|
|
1702
1760
|
escalationChannel: pinnedPolicy
|
|
1703
1761
|
? policyDeclaresNotifyWebhook(pinnedPolicy) ? "configured webhook" : "account email"
|
|
1704
|
-
: live.kind === "live" ?
|
|
1762
|
+
: live.kind === "live" ? channelIdentityOf(live.effectivePolicy?.escalation_channel) : "account email",
|
|
1705
1763
|
});
|
|
1706
1764
|
if (pinnedPolicy)
|
|
1707
1765
|
pinEpicLaunchPolicy(selection, pinnedPolicy);
|
|
1708
1766
|
return { ok: true, handoff: { selection, access: ctx.access, profile: ctx.profile } };
|
|
1709
1767
|
}
|
|
1768
|
+
// ---------------------------------------------------------------------------
|
|
1769
|
+
// BAPI-1510 — already-merged child detection
|
|
1770
|
+
// ---------------------------------------------------------------------------
|
|
1771
|
+
/** The refusal reason shared by the preview and a real create when nothing is left to run. */
|
|
1772
|
+
export const EPIC_ALREADY_COMPLETE_REASON_ID = "epic-already-complete";
|
|
1773
|
+
const EPIC_ALREADY_COMPLETE_REMEDY = "Every child in this plan is already merged and on the indexed commit, so there is nothing to run. " +
|
|
1774
|
+
"If you still want a child to run, pass --keep-child <KEY> for it.";
|
|
1775
|
+
/** The fixed text a failed explicit skip is refused with. */
|
|
1776
|
+
function skipRefusalRemedy(refusals) {
|
|
1777
|
+
const keys = (reason) => refusals.filter((r) => r.reason === reason).map((r) => r.ticketKey);
|
|
1778
|
+
const parts = [];
|
|
1779
|
+
const notInPlan = keys("not_in_plan");
|
|
1780
|
+
if (notInPlan.length > 0)
|
|
1781
|
+
parts.push(`${notInPlan.join(", ")} not in this plan`);
|
|
1782
|
+
const unavailable = keys("check_unavailable");
|
|
1783
|
+
if (unavailable.length > 0) {
|
|
1784
|
+
parts.push(`${unavailable.join(", ")} could not be confirmed because the already-merged check could not run`);
|
|
1785
|
+
}
|
|
1786
|
+
const weak = keys("not_skippable");
|
|
1787
|
+
if (weak.length > 0) {
|
|
1788
|
+
parts.push(`${weak.join(", ")} not currently shown as merged on the indexed commit`);
|
|
1789
|
+
}
|
|
1790
|
+
return (`--skip-child was refused: ${parts.join("; ")}. ` +
|
|
1791
|
+
"Remove the flag for those children (they will run), or fix the cause and try again.");
|
|
1792
|
+
}
|
|
1793
|
+
/** Parse a plan sidecar into its DAG and child keys, or `null` when it is not usable for the check. */
|
|
1794
|
+
async function readPlanChildren(deps, planPath) {
|
|
1795
|
+
try {
|
|
1796
|
+
const parsed = JSON.parse(await deps.readPlanDag(planPath));
|
|
1797
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
1798
|
+
return null;
|
|
1799
|
+
const nodes = parsed.nodes;
|
|
1800
|
+
if (!Array.isArray(nodes))
|
|
1801
|
+
return null;
|
|
1802
|
+
const keys = [];
|
|
1803
|
+
for (const node of nodes) {
|
|
1804
|
+
const key = node?.ticket_key;
|
|
1805
|
+
if (typeof key !== "string" || !EPIC_KEY_PATTERN.test(key))
|
|
1806
|
+
return null;
|
|
1807
|
+
keys.push(key);
|
|
1808
|
+
}
|
|
1809
|
+
if (new Set(keys).size !== keys.length)
|
|
1810
|
+
return null;
|
|
1811
|
+
return { plan: parsed, keys };
|
|
1812
|
+
}
|
|
1813
|
+
catch {
|
|
1814
|
+
return null;
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
function notChecked(reason, base = {}) {
|
|
1818
|
+
return {
|
|
1819
|
+
state: { status: "not_checked", reason, children: [] },
|
|
1820
|
+
childKeys: [],
|
|
1821
|
+
plan: null,
|
|
1822
|
+
cutCommitSha: null,
|
|
1823
|
+
skipKeys: new Set(),
|
|
1824
|
+
confirmedKeys: new Set(),
|
|
1825
|
+
refusals: [],
|
|
1826
|
+
...base,
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Classify the plan's children against the canonical indexed commit and decide the
|
|
1831
|
+
* skip set. Read-only; every failure degrades to "not checked, keep every child",
|
|
1832
|
+
* except that an explicit `--skip-child` it cannot support becomes a refusal.
|
|
1833
|
+
* Detection that is turned off (`--no-merged-check`) never reads anything.
|
|
1834
|
+
*/
|
|
1835
|
+
async function evaluateMergedChildren(ctx, planPath) {
|
|
1836
|
+
const { deps, options, access, epicKey } = ctx;
|
|
1837
|
+
if (options.noMergedCheck === true)
|
|
1838
|
+
return notChecked("disabled");
|
|
1839
|
+
const children = await readPlanChildren(deps, planPath);
|
|
1840
|
+
if (children === null)
|
|
1841
|
+
return notChecked("no_plan");
|
|
1842
|
+
const base = { childKeys: children.keys, plan: children.plan };
|
|
1843
|
+
const skipChildren = options.skipChildren ?? [];
|
|
1844
|
+
const keepChildren = options.keepChildren ?? [];
|
|
1845
|
+
const failed = (reason, cutCommitSha) => ({
|
|
1846
|
+
...notChecked(reason, base),
|
|
1847
|
+
cutCommitSha,
|
|
1848
|
+
refusals: selectSkips({ planKeys: children.keys, results: null, keepChildren, skipChildren }).refusals,
|
|
1849
|
+
});
|
|
1850
|
+
let cutCommitSha;
|
|
1851
|
+
try {
|
|
1852
|
+
const truth = await (deps.readCanonicalParseTruth
|
|
1853
|
+
? deps.readCanonicalParseTruth(access)
|
|
1854
|
+
: readSetupCanonicalParseTruth(access, globalThis.fetch));
|
|
1855
|
+
if (!truth.ok)
|
|
1856
|
+
return failed("cut_commit_unavailable", null);
|
|
1857
|
+
cutCommitSha = truth.cutCommitSha;
|
|
1858
|
+
}
|
|
1859
|
+
catch {
|
|
1860
|
+
return failed("cut_commit_unavailable", null);
|
|
1861
|
+
}
|
|
1862
|
+
const classify = deps.classifyChildMergeStatus
|
|
1863
|
+
? (request) => deps.classifyChildMergeStatus(access, request)
|
|
1864
|
+
: (request) => classifyChildMergeStatusClient(access, request);
|
|
1865
|
+
const checked = await classifyPlanChildren(classify, epicKey, children.keys, cutCommitSha);
|
|
1866
|
+
if (!checked.ok)
|
|
1867
|
+
return failed("check_failed", cutCommitSha);
|
|
1868
|
+
const selection = selectSkips({
|
|
1869
|
+
planKeys: children.keys,
|
|
1870
|
+
results: checked.results,
|
|
1871
|
+
keepChildren,
|
|
1872
|
+
skipChildren,
|
|
1873
|
+
});
|
|
1874
|
+
return {
|
|
1875
|
+
state: { status: "checked", children: buildAlreadyDoneEntries(checked.results, selection.skipKeys) },
|
|
1876
|
+
childKeys: children.keys,
|
|
1877
|
+
plan: children.plan,
|
|
1878
|
+
cutCommitSha,
|
|
1879
|
+
skipKeys: selection.skipKeys,
|
|
1880
|
+
confirmedKeys: selection.confirmedKeys,
|
|
1881
|
+
refusals: selection.refusals,
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
/** The PR number for a skipped child, only when it is a valid positive integer. */
|
|
1885
|
+
function skippedChildPr(state, key) {
|
|
1886
|
+
const entry = state.children.find((child) => child.ticket_key === key);
|
|
1887
|
+
return entry !== undefined && entry.pr_number !== null && Number.isSafeInteger(entry.pr_number) && entry.pr_number > 0
|
|
1888
|
+
? entry.pr_number
|
|
1889
|
+
: null;
|
|
1890
|
+
}
|
|
1891
|
+
/** Post-create audit recording: best effort, one plain warning, never changes the outcome. */
|
|
1892
|
+
async function recordSkippedChildrenBestEffort(ctx, epicRunId, skipped) {
|
|
1893
|
+
const { deps, access } = ctx;
|
|
1894
|
+
const children = skipped.map((s) => ({ ticketKey: s.key, operatorConfirmed: s.confirmed }));
|
|
1895
|
+
let allRecorded = true;
|
|
1896
|
+
try {
|
|
1897
|
+
for (let i = 0; i < children.length; i += CHILD_MERGE_STATUS_BATCH_SIZE) {
|
|
1898
|
+
const batch = children.slice(i, i + CHILD_MERGE_STATUS_BATCH_SIZE);
|
|
1899
|
+
const response = deps.recordSkippedChildren
|
|
1900
|
+
? await deps.recordSkippedChildren(access, epicRunId, batch)
|
|
1901
|
+
: await recordSkippedChildrenClient(access, epicRunId, batch);
|
|
1902
|
+
if (response.results.some((r) => r.outcome === "refused"))
|
|
1903
|
+
allRecorded = false;
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
catch {
|
|
1907
|
+
allRecorded = false;
|
|
1908
|
+
}
|
|
1909
|
+
if (!allRecorded) {
|
|
1910
|
+
deps.errorLog("The run was created, but the skipped-child record could not be saved.");
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1710
1913
|
async function runSelectedSetupEpic(ctx) {
|
|
1711
1914
|
const prepared = ctx.handoff
|
|
1712
1915
|
? { ok: true, handoff: ctx.handoff }
|
|
@@ -1715,7 +1918,61 @@ async function runSelectedSetupEpic(ctx) {
|
|
|
1715
1918
|
ctx.deps.errorLog(`${ctx.epicKey}: REFUSED (${prepared.reasonId}) — the selected run or policy could not be verified.`);
|
|
1716
1919
|
return { exitCode: 1 };
|
|
1717
1920
|
}
|
|
1718
|
-
const
|
|
1921
|
+
const { deps, options, epicKey } = ctx;
|
|
1922
|
+
// BAPI-1510 — fresh classification immediately before setup. Preview verdicts are
|
|
1923
|
+
// never consumed: each create starts from the ORIGINAL plan and re-checks.
|
|
1924
|
+
const planPath = options.planFile;
|
|
1925
|
+
if (planPath === undefined || options.noMergedCheck === true) {
|
|
1926
|
+
return await deps.runSetupEpic(buildSetupEpicArgv(options), prepared.handoff);
|
|
1927
|
+
}
|
|
1928
|
+
const evaluation = await evaluateMergedChildren(ctx, planPath);
|
|
1929
|
+
if (evaluation.refusals.length > 0) {
|
|
1930
|
+
deps.errorLog(`${epicKey}: REFUSED (skip-child-refused) — ${skipRefusalRemedy(evaluation.refusals)}`);
|
|
1931
|
+
deps.errorLog("No run was created.");
|
|
1932
|
+
return { exitCode: 1 };
|
|
1933
|
+
}
|
|
1934
|
+
if (evaluation.state.status === "not_checked") {
|
|
1935
|
+
if (evaluation.state.reason === "cut_commit_unavailable" || evaluation.state.reason === "check_failed") {
|
|
1936
|
+
deps.errorLog(`${epicKey}: could not check for already-merged children; every child stays in the plan.`);
|
|
1937
|
+
}
|
|
1938
|
+
return await deps.runSetupEpic(buildSetupEpicArgv(options), prepared.handoff);
|
|
1939
|
+
}
|
|
1940
|
+
if (evaluation.skipKeys.size === 0 || evaluation.plan === null || evaluation.cutCommitSha === null) {
|
|
1941
|
+
return await deps.runSetupEpic(buildSetupEpicArgv(options), prepared.handoff);
|
|
1942
|
+
}
|
|
1943
|
+
if (evaluation.skipKeys.size >= evaluation.childKeys.length) {
|
|
1944
|
+
deps.errorLog(`${epicKey}: REFUSED (${EPIC_ALREADY_COMPLETE_REASON_ID}) — ${EPIC_ALREADY_COMPLETE_REMEDY}`);
|
|
1945
|
+
deps.errorLog("No run was created.");
|
|
1946
|
+
return { exitCode: 1 };
|
|
1947
|
+
}
|
|
1948
|
+
const skipped = evaluation.childKeys
|
|
1949
|
+
.filter((key) => evaluation.skipKeys.has(key))
|
|
1950
|
+
.map((key) => ({ key, confirmed: evaluation.confirmedKeys.has(key) }));
|
|
1951
|
+
for (const { key, confirmed } of skipped) {
|
|
1952
|
+
const pr = skippedChildPr(evaluation.state, key);
|
|
1953
|
+
deps.log(`${epicKey}: ${confirmed ? "skipping (confirmed by you)" : "skipping already-merged child"} ${key}` +
|
|
1954
|
+
`${pr !== null ? ` (#${pr})` : ""}`);
|
|
1955
|
+
}
|
|
1956
|
+
const trimmed = trimDonePlanNodes(evaluation.plan, evaluation.skipKeys);
|
|
1957
|
+
const withPath = deps.withPlanPath ?? withPlanPath;
|
|
1958
|
+
const result = await withPath({
|
|
1959
|
+
originalPath: planPath,
|
|
1960
|
+
trimmedJson: JSON.stringify(trimmed, null, 2),
|
|
1961
|
+
skipped: true,
|
|
1962
|
+
warn: (message) => deps.errorLog(message),
|
|
1963
|
+
}, (trimmedPath) => deps.runSetupEpic(buildSetupEpicArgv({ ...options, planFile: trimmedPath }), {
|
|
1964
|
+
...prepared.handoff,
|
|
1965
|
+
expectedCutCommitSha: evaluation.cutCommitSha,
|
|
1966
|
+
attachOnlyOnReuse: true,
|
|
1967
|
+
}));
|
|
1968
|
+
if (result.reusedExistingRun === true) {
|
|
1969
|
+
// Reuse or race adoption: the trimmed plan was never applied and nothing is recorded.
|
|
1970
|
+
deps.log(`${epicKey}: an existing run was found; the already-merged check was not applied and existing runs are unchanged.`);
|
|
1971
|
+
return result;
|
|
1972
|
+
}
|
|
1973
|
+
if (result.epicRunId !== undefined && result.runCreated === true && result.planStored === true) {
|
|
1974
|
+
await recordSkippedChildrenBestEffort(ctx, result.epicRunId, skipped);
|
|
1975
|
+
}
|
|
1719
1976
|
return result;
|
|
1720
1977
|
}
|
|
1721
1978
|
/** Emit one refusal, guarded so it can never name a second conductor. */
|
|
@@ -1725,6 +1982,200 @@ function refuse(deps, lines) {
|
|
|
1725
1982
|
deps.errorLog(text);
|
|
1726
1983
|
return 1;
|
|
1727
1984
|
}
|
|
1985
|
+
/** The launch evidence rows exist only for an unattended launch. */
|
|
1986
|
+
function isUnattendedLaunch(options) {
|
|
1987
|
+
return options.attended !== true && options.intoBase !== true;
|
|
1988
|
+
}
|
|
1989
|
+
/** The launch row ids a launch can be refused on, without their namespace. */
|
|
1990
|
+
const LAUNCH_REFUSAL_IDS = ["merge-credential-capability", "worktree-preparation", ...Object.values(READINESS_PRE_CREATE_CODES)];
|
|
1991
|
+
/**
|
|
1992
|
+
* The credential evidence. Probes ONLY a locally owned executor's credential in
|
|
1993
|
+
* `local` / `machine_token` mode, against the server's explicit `owner/name`;
|
|
1994
|
+
* never the launcher's unrelated credential for an external executor, and never
|
|
1995
|
+
* anything for `github_actions`. Every gap is a value-free warning reason.
|
|
1996
|
+
*/
|
|
1997
|
+
async function collectCredentialEvidence(deps, readiness, topology, box) {
|
|
1998
|
+
const mode = readiness.resolved_merge_execution_mode;
|
|
1999
|
+
if (mode === undefined)
|
|
2000
|
+
return { kind: "unverified", reason: "server_fields_missing" };
|
|
2001
|
+
if (mode === null)
|
|
2002
|
+
return { kind: "unverified", reason: "mode_unresolved" };
|
|
2003
|
+
if (mode === "github_actions")
|
|
2004
|
+
return { kind: "github_actions" };
|
|
2005
|
+
if (topology.kind === "plane_lane")
|
|
2006
|
+
return { kind: "unverified", reason: "external_executor_plane", mode };
|
|
2007
|
+
if (topology.kind === "durable_profile") {
|
|
2008
|
+
return { kind: "unverified", reason: "external_executor_durable", mode };
|
|
2009
|
+
}
|
|
2010
|
+
const repository = readiness.provider_repository;
|
|
2011
|
+
if (repository === undefined)
|
|
2012
|
+
return { kind: "unverified", reason: "server_fields_missing", mode };
|
|
2013
|
+
if (!isProviderRepositoryIdentity(repository)) {
|
|
2014
|
+
return { kind: "unverified", reason: "repository_unresolved", mode };
|
|
2015
|
+
}
|
|
2016
|
+
if (deps.probeLaunchMergeCredential === undefined) {
|
|
2017
|
+
return { kind: "unverified", reason: "probe_unavailable", mode };
|
|
2018
|
+
}
|
|
2019
|
+
// BAPI-1461: resolve once, with the stored-entry fallback, and probe exactly
|
|
2020
|
+
// the credential the executor will be handed — never a second env lookup.
|
|
2021
|
+
const resolved = await resolveLaunchCredentialOnce(deps, mode, repository, box);
|
|
2022
|
+
let verdict;
|
|
2023
|
+
try {
|
|
2024
|
+
verdict = classifyLocalGhPreflight(await deps.probeLaunchMergeCredential({
|
|
2025
|
+
kind: mode,
|
|
2026
|
+
repository,
|
|
2027
|
+
// A store-sourced credential is handed over explicitly; an env one is the
|
|
2028
|
+
// same value the probe resolves itself, so its call is unchanged.
|
|
2029
|
+
...(resolved !== undefined && resolved.fromStore
|
|
2030
|
+
? { resolved: { credential: resolved.credential, invalid: resolved.invalid } }
|
|
2031
|
+
: {}),
|
|
2032
|
+
}));
|
|
2033
|
+
}
|
|
2034
|
+
catch {
|
|
2035
|
+
// Nothing from the thrown value is kept: an unexpected probe failure is
|
|
2036
|
+
// simply unavailable evidence.
|
|
2037
|
+
return { kind: "unverified", reason: "probe_unavailable", mode };
|
|
2038
|
+
}
|
|
2039
|
+
switch (verdict) {
|
|
2040
|
+
case "passed":
|
|
2041
|
+
return { kind: "verified", mode, repository };
|
|
2042
|
+
case "credential_missing":
|
|
2043
|
+
return { kind: "credential_missing", mode };
|
|
2044
|
+
case "credential_invalid":
|
|
2045
|
+
return { kind: "credential_invalid", mode };
|
|
2046
|
+
case "no_access":
|
|
2047
|
+
return { kind: "no_access", mode, repository };
|
|
2048
|
+
case "gh_unavailable":
|
|
2049
|
+
return { kind: "unverified", reason: "gh_unavailable", mode };
|
|
2050
|
+
case "probe_unavailable":
|
|
2051
|
+
return { kind: "unverified", reason: "probe_unavailable", mode };
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* BAPI-1461 — resolve the launch's merge credential once and remember it in the
|
|
2056
|
+
* launch's box. `undefined` means no resolver was supplied (or it failed), so the
|
|
2057
|
+
* probe falls back to its own environment lookup and nothing is handed over.
|
|
2058
|
+
*/
|
|
2059
|
+
async function resolveLaunchCredentialOnce(deps, kind, repository, box) {
|
|
2060
|
+
if (deps.resolveLaunchMergeCredential === undefined)
|
|
2061
|
+
return undefined;
|
|
2062
|
+
let resolution;
|
|
2063
|
+
try {
|
|
2064
|
+
resolution = await deps.resolveLaunchMergeCredential({ kind, repository });
|
|
2065
|
+
}
|
|
2066
|
+
catch {
|
|
2067
|
+
return undefined;
|
|
2068
|
+
}
|
|
2069
|
+
const credential = resolution.status === "resolved" ? resolution.credential : null;
|
|
2070
|
+
if (box !== undefined)
|
|
2071
|
+
box.resolved = credential;
|
|
2072
|
+
return {
|
|
2073
|
+
credential,
|
|
2074
|
+
invalid: resolution.status === "invalid",
|
|
2075
|
+
fromStore: (resolution.status === "resolved" || resolution.status === "invalid") && resolution.source === "store",
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
/**
|
|
2079
|
+
* BAPI-1461 — the credential to hand a run-scoped executor: the one this launch
|
|
2080
|
+
* already resolved, or (on the live-run attach path, which runs no launch gate)
|
|
2081
|
+
* a fresh resolution for a locally owned, `local`-mode executor. Only the local
|
|
2082
|
+
* kind is ever handed over; nothing for machine-user or GitHub Actions mode.
|
|
2083
|
+
*/
|
|
2084
|
+
async function launchCredentialForExecutor(ctx, readiness) {
|
|
2085
|
+
const box = ctx.launchCredential;
|
|
2086
|
+
if (box !== undefined && box.resolved !== undefined) {
|
|
2087
|
+
return box.resolved?.kind === "local" ? box.resolved : null;
|
|
2088
|
+
}
|
|
2089
|
+
if (readiness.resolved_merge_execution_mode !== "local")
|
|
2090
|
+
return null;
|
|
2091
|
+
const repository = readiness.provider_repository;
|
|
2092
|
+
if (typeof repository !== "string" || !isProviderRepositoryIdentity(repository))
|
|
2093
|
+
return null;
|
|
2094
|
+
const resolved = await resolveLaunchCredentialOnce(ctx.deps, "local", repository, box);
|
|
2095
|
+
return resolved?.credential ?? null;
|
|
2096
|
+
}
|
|
2097
|
+
/**
|
|
2098
|
+
* The worktree evidence, from Child 1's NON-WRITING resolver against the clone
|
|
2099
|
+
* the executor will own. Blocks only when the exclude is proven neither present
|
|
2100
|
+
* nor writable; anything the resolver cannot establish is a warning. The
|
|
2101
|
+
* absolute exclude path never leaves this function.
|
|
2102
|
+
*/
|
|
2103
|
+
async function collectWorktreeEvidence(deps, topology) {
|
|
2104
|
+
if (topology.kind === "plane_lane")
|
|
2105
|
+
return { kind: "unverified", reason: "external_executor_plane" };
|
|
2106
|
+
if (topology.kind === "durable_profile")
|
|
2107
|
+
return { kind: "unverified", reason: "external_executor_durable" };
|
|
2108
|
+
if (topology.repoRoot === null || deps.inspectWorktreeExclude === undefined) {
|
|
2109
|
+
return { kind: "unverified", reason: "inspection_unavailable" };
|
|
2110
|
+
}
|
|
2111
|
+
let resolution;
|
|
2112
|
+
try {
|
|
2113
|
+
resolution = await deps.inspectWorktreeExclude(topology.repoRoot);
|
|
2114
|
+
}
|
|
2115
|
+
catch {
|
|
2116
|
+
return { kind: "unverified", reason: "inspection_unavailable" };
|
|
2117
|
+
}
|
|
2118
|
+
if (resolution.kind !== "resolved")
|
|
2119
|
+
return { kind: "unverified", reason: "inspection_unavailable" };
|
|
2120
|
+
if (resolution.present || resolution.writable)
|
|
2121
|
+
return { kind: "ready", present: resolution.present };
|
|
2122
|
+
return { kind: "blocked" };
|
|
2123
|
+
}
|
|
2124
|
+
/** Collect both halves of the launch evidence. */
|
|
2125
|
+
async function collectLaunchEvidence(deps, readiness, topology, box) {
|
|
2126
|
+
return {
|
|
2127
|
+
credential: await collectCredentialEvidence(deps, readiness, topology, box),
|
|
2128
|
+
worktree: await collectWorktreeEvidence(deps, topology),
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* The launch capability gate (BAPI-1452). Runs after readiness and executor
|
|
2133
|
+
* topology are resolved and BEFORE the run is created, dispatched, or given an
|
|
2134
|
+
* executor. Prints the compact launch summary, then refuses (exit 1) only on a
|
|
2135
|
+
* proven blocker; `null` means continue.
|
|
2136
|
+
*
|
|
2137
|
+
* Attended and `--into-base` launches skip it: the unattended requirements do
|
|
2138
|
+
* not apply there, so there is nothing for the evidence to refuse.
|
|
2139
|
+
*/
|
|
2140
|
+
async function launchCapabilityGate(ctx, readiness, topology, outcome = "Nothing was created or started.") {
|
|
2141
|
+
const { deps, epicKey, options } = ctx;
|
|
2142
|
+
if (!isUnattendedLaunch(options))
|
|
2143
|
+
return null;
|
|
2144
|
+
const evidence = await collectLaunchEvidence(deps, readiness, topology, ctx.launchCredential);
|
|
2145
|
+
(deps.launchReadinessLog ?? deps.errorLog)(formatLaunchReadinessSummary(buildLaunchReadinessChecks(readiness, { launchEvidence: evidence }), { all: ctx.options.all, isProduction: readiness.is_production, prerequisites: readiness.prerequisites }).join("\n"));
|
|
2146
|
+
const classification = classifyV2Readiness(readiness, { attended: false, intoBase: false, effectivePolicy: ctx.handoff?.selection }, evidence);
|
|
2147
|
+
if (classification.kind !== "operator_blocked" || !LAUNCH_REFUSAL_IDS.includes(classification.id)) {
|
|
2148
|
+
return null;
|
|
2149
|
+
}
|
|
2150
|
+
return refuse(deps, [
|
|
2151
|
+
`${epicKey}: REFUSED (${classification.id}) — the launch still needs ${classification.describe}.`,
|
|
2152
|
+
operatorBlockedRemedy(classification),
|
|
2153
|
+
outcome,
|
|
2154
|
+
]);
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* The launch rows a preview reports, and the refusal they imply (BAPI-1452).
|
|
2158
|
+
* Uses the SAME evidence collection and classification as a real launch; the
|
|
2159
|
+
* worktree check reads the clone the read-only inspection names and never
|
|
2160
|
+
* prepares or writes it.
|
|
2161
|
+
*/
|
|
2162
|
+
async function previewLaunchCapability(ctx, readiness, topology) {
|
|
2163
|
+
const { deps, options } = ctx;
|
|
2164
|
+
const context = { attended: options.attended === true, intoBase: options.intoBase === true };
|
|
2165
|
+
if (!isUnattendedLaunch(options)) {
|
|
2166
|
+
return { checks: buildLaunchReadinessChecks(readiness, context), refusal: null };
|
|
2167
|
+
}
|
|
2168
|
+
const evidence = await collectLaunchEvidence(deps, readiness, topology);
|
|
2169
|
+
const checks = buildLaunchReadinessChecks(readiness, { ...context, launchEvidence: evidence });
|
|
2170
|
+
const classification = classifyV2Readiness(readiness, { ...context, effectivePolicy: ctx.handoff?.selection }, evidence);
|
|
2171
|
+
if (classification.kind === "operator_blocked" && LAUNCH_REFUSAL_IDS.includes(classification.id)) {
|
|
2172
|
+
return {
|
|
2173
|
+
checks,
|
|
2174
|
+
refusal: { reasonId: classification.id, remedy: operatorBlockedRemedy(classification) },
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
return { checks, refusal: null };
|
|
2178
|
+
}
|
|
1728
2179
|
function durableActorAdmissionDenied(readiness) {
|
|
1729
2180
|
const evidence = readiness.admission_evidence;
|
|
1730
2181
|
return evidence?.schema_version === 1 &&
|
|
@@ -1743,7 +2194,7 @@ function planeEndpointForBaseUrl(deps, baseUrl) {
|
|
|
1743
2194
|
/** What to tell the operator about a plane THIS invocation started and left up. */
|
|
1744
2195
|
const LAUNCHED_PLANE_LEFT_RUNNING = [
|
|
1745
2196
|
"",
|
|
1746
|
-
"The
|
|
2197
|
+
"The Epic Implementer CONTROL PLANE was started by this invocation and is STILL RUNNING.",
|
|
1747
2198
|
"No run was created and no executor lane was spawned. Inspect it with `plane status`,",
|
|
1748
2199
|
"and wind it down with `plane down` once the item above is fixed.",
|
|
1749
2200
|
];
|
|
@@ -1782,10 +2233,430 @@ async function checkPlaneAdmission(ctx, readiness, manifest) {
|
|
|
1782
2233
|
`${ctx.epicKey}: REFUSED (plane-reconciler-unconfirmed) — the plane's own reconciler could ` +
|
|
1783
2234
|
`not be confirmed as the live transition authority (its heartbeat reads: ${state}).`,
|
|
1784
2235
|
"Another reconciler may be standing in for this plane — often a reparented worker.py from an",
|
|
1785
|
-
`earlier plane. Check \`ps aux | grep worker.py\` (${BRING_UP_RUNBOOK}
|
|
2236
|
+
`earlier plane. Check \`ps aux | grep worker.py\` (see ${BRING_UP_RUNBOOK}), stop any`,
|
|
1786
2237
|
"survivor, and run drive-epic again.",
|
|
1787
2238
|
];
|
|
1788
2239
|
}
|
|
2240
|
+
// ---------------------------------------------------------------------------
|
|
2241
|
+
// BAPI-1476 — local-plane freshness: one evidence collection, one policy
|
|
2242
|
+
// ---------------------------------------------------------------------------
|
|
2243
|
+
//
|
|
2244
|
+
// `plane/plane-freshness.ts` OBSERVES. This section DECIDES, and the decision
|
|
2245
|
+
// differs by mode: a launch that would CREATE a run refuses on a confirmed stale
|
|
2246
|
+
// snapshot or database head; a launch that only ATTACHES to a run that already
|
|
2247
|
+
// exists never stops mid-run on freshness — it warns. `--allow-stale-plane`
|
|
2248
|
+
// downgrades the creation refusals for one invocation and nothing else.
|
|
2249
|
+
/** Refusal names for a confirmed freshness problem. Unique to this check. */
|
|
2250
|
+
export const DRIVE_EPIC_FRESHNESS_REFUSALS = [
|
|
2251
|
+
/** The plane's pinned snapshot is behind `origin/main`. */
|
|
2252
|
+
"plane-snapshot-stale",
|
|
2253
|
+
/** The database revision set differs from the repository migration head. */
|
|
2254
|
+
"plane-database-head-mismatch",
|
|
2255
|
+
];
|
|
2256
|
+
function createPlaneFreshnessCache() {
|
|
2257
|
+
return { observations: new Map() };
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* Collect snapshot and database evidence ONCE per (checkout, ownership) for this
|
|
2261
|
+
* invocation. `null` when nothing applies: a non-plane target gets no reads at
|
|
2262
|
+
* all, so a production launch's output is byte-for-byte unchanged.
|
|
2263
|
+
*/
|
|
2264
|
+
async function collectFreshnessObservations(ctx, own) {
|
|
2265
|
+
const { deps } = ctx;
|
|
2266
|
+
if (planeEndpointForBaseUrl(deps, ctx.access.baseUrl) === null)
|
|
2267
|
+
return null;
|
|
2268
|
+
const collect = deps.collectPlaneFreshness;
|
|
2269
|
+
if (collect === undefined)
|
|
2270
|
+
return null;
|
|
2271
|
+
const cache = ctx.freshness ?? createPlaneFreshnessCache();
|
|
2272
|
+
const key = `${own.repoRoot}\u0000${own.ownsManifest ? own.planeId ?? "" : "-"}`;
|
|
2273
|
+
let pending = cache.observations.get(key);
|
|
2274
|
+
if (pending === undefined) {
|
|
2275
|
+
pending = collect({
|
|
2276
|
+
repoRoot: own.repoRoot,
|
|
2277
|
+
ownsManifest: own.ownsManifest,
|
|
2278
|
+
...(own.planeId !== undefined ? { expectedPlaneId: own.planeId } : {}),
|
|
2279
|
+
}).catch(() => null);
|
|
2280
|
+
cache.observations.set(key, pending);
|
|
2281
|
+
}
|
|
2282
|
+
return pending;
|
|
2283
|
+
}
|
|
2284
|
+
async function collectHostSleep(ctx, repoRoot) {
|
|
2285
|
+
const { deps } = ctx;
|
|
2286
|
+
if (planeEndpointForBaseUrl(deps, ctx.access.baseUrl) === null)
|
|
2287
|
+
return null;
|
|
2288
|
+
const inspect = deps.inspectHostSleep;
|
|
2289
|
+
if (inspect === undefined)
|
|
2290
|
+
return null;
|
|
2291
|
+
const cache = ctx.freshness ?? createPlaneFreshnessCache();
|
|
2292
|
+
cache.sleep ??= inspect(repoRoot).catch(() => null);
|
|
2293
|
+
return cache.sleep;
|
|
2294
|
+
}
|
|
2295
|
+
/**
|
|
2296
|
+
* BAPI-1496 — the bound run a stale-snapshot remedy should name, or `undefined`
|
|
2297
|
+
* for an unbound plane. Read only when the plane is this checkout's own, bound,
|
|
2298
|
+
* local, and its snapshot is confirmed stale, so the common path issues no
|
|
2299
|
+
* request. A failed read is an UNKNOWN status, never assumed terminal.
|
|
2300
|
+
*/
|
|
2301
|
+
async function boundRunForStaleRemedy(ctx, own, reports) {
|
|
2302
|
+
if (!own.ownsManifest || own.epicRunId === undefined || reports === null)
|
|
2303
|
+
return undefined;
|
|
2304
|
+
// The bound remedy is shown only on a confirmed stale refusal, so only that reads.
|
|
2305
|
+
if (freshnessVerdictOf(reports.snapshot).verdict !== "refuse")
|
|
2306
|
+
return undefined;
|
|
2307
|
+
if (planeEndpointForBaseUrl(ctx.deps, ctx.access.baseUrl) === null)
|
|
2308
|
+
return undefined;
|
|
2309
|
+
const state = await ctx.deps.readRunState(ctx.access, own.epicRunId).catch(() => null);
|
|
2310
|
+
return { epicRunId: own.epicRunId, status: state?.status ?? null };
|
|
2311
|
+
}
|
|
2312
|
+
function freshnessVerdictOf(report) {
|
|
2313
|
+
const outcome = report.outcomes[0];
|
|
2314
|
+
const detail = outcome?.detail ?? "";
|
|
2315
|
+
switch (outcome?.status) {
|
|
2316
|
+
case "pass":
|
|
2317
|
+
return { verdict: "pass", detail };
|
|
2318
|
+
case "fail":
|
|
2319
|
+
return { verdict: "refuse", detail };
|
|
2320
|
+
case "skip":
|
|
2321
|
+
return { verdict: "skip", detail };
|
|
2322
|
+
default:
|
|
2323
|
+
return { verdict: "unverified", detail };
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* Project the observations into launch rows and the creation blockers.
|
|
2328
|
+
*
|
|
2329
|
+
* Row `status` stays in the existing four-state vocabulary (`fail` for a
|
|
2330
|
+
* confirmed refusal, `warn` for unverified evidence); the verdict word — `pass`,
|
|
2331
|
+
* `refuse`, `unverified` — leads the row's detail so human and JSON output carry
|
|
2332
|
+
* the same meaning.
|
|
2333
|
+
*/
|
|
2334
|
+
function assessPlaneFreshness(reports, sleep, mode, allowStalePlane,
|
|
2335
|
+
/**
|
|
2336
|
+
* BAPI-1496 — the stale plane's bound run and its read-only server status.
|
|
2337
|
+
* Present only for a plane bound to a run; it selects the ownership-specific
|
|
2338
|
+
* remedy, which never advises `plane down` for a run that is not finished.
|
|
2339
|
+
* Absent keeps `PLANE_SNAPSHOT_STALE_REMEDY` byte for byte.
|
|
2340
|
+
*/
|
|
2341
|
+
boundRun) {
|
|
2342
|
+
const rows = [];
|
|
2343
|
+
const blockers = [];
|
|
2344
|
+
let overridden = false;
|
|
2345
|
+
const flagNote = allowStalePlane ? "; overridden for this invocation by --allow-stale-plane" : "";
|
|
2346
|
+
const project = (id, label, report, refusal) => {
|
|
2347
|
+
const { verdict, detail } = freshnessVerdictOf(report);
|
|
2348
|
+
if (verdict === "pass") {
|
|
2349
|
+
rows.push({ id, label, status: "pass", blocking: false, detail: `pass — ${detail}` });
|
|
2350
|
+
}
|
|
2351
|
+
else if (verdict === "unverified") {
|
|
2352
|
+
rows.push({
|
|
2353
|
+
id,
|
|
2354
|
+
label,
|
|
2355
|
+
status: "warn",
|
|
2356
|
+
blocking: false,
|
|
2357
|
+
detail: `unverified — ${detail}`,
|
|
2358
|
+
remedy: PLANE_FRESHNESS_UNVERIFIED_REMEDY,
|
|
2359
|
+
});
|
|
2360
|
+
}
|
|
2361
|
+
else if (verdict === "refuse" && mode === "attach") {
|
|
2362
|
+
// Warning-only: an existing run is never stopped for freshness.
|
|
2363
|
+
rows.push({
|
|
2364
|
+
id,
|
|
2365
|
+
label,
|
|
2366
|
+
status: "warn",
|
|
2367
|
+
blocking: false,
|
|
2368
|
+
detail: `refuse (warning only; attachment proceeds) — ${detail}${flagNote}`,
|
|
2369
|
+
remedy: refusal.remedy,
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
else if (verdict === "refuse" && allowStalePlane) {
|
|
2373
|
+
overridden = true;
|
|
2374
|
+
rows.push({
|
|
2375
|
+
id,
|
|
2376
|
+
label,
|
|
2377
|
+
status: "fail",
|
|
2378
|
+
blocking: false,
|
|
2379
|
+
detail: `refuse (overridden for this invocation) — ${detail}`,
|
|
2380
|
+
remedy: refusal.remedy,
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
else if (verdict === "refuse") {
|
|
2384
|
+
blockers.push(refusal);
|
|
2385
|
+
rows.push({
|
|
2386
|
+
id,
|
|
2387
|
+
label,
|
|
2388
|
+
status: "fail",
|
|
2389
|
+
blocking: true,
|
|
2390
|
+
detail: `refuse — ${detail}`,
|
|
2391
|
+
remedy: refusal.remedy,
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
};
|
|
2395
|
+
if (reports !== null) {
|
|
2396
|
+
project(PLANE_SNAPSHOT_FRESHNESS_ID, "Plane snapshot freshness", reports.snapshot, {
|
|
2397
|
+
reasonId: "plane-snapshot-stale",
|
|
2398
|
+
remedy: boundRun !== undefined ? formatBoundPlaneStaleRemedy(boundRun) : PLANE_SNAPSHOT_STALE_REMEDY,
|
|
2399
|
+
});
|
|
2400
|
+
project(PLANE_DATABASE_HEAD_ID, "Plane database head", reports.database, {
|
|
2401
|
+
reasonId: "plane-database-head-mismatch",
|
|
2402
|
+
remedy: PLANE_DATABASE_HEAD_REMEDY,
|
|
2403
|
+
});
|
|
2404
|
+
}
|
|
2405
|
+
if (sleep !== null) {
|
|
2406
|
+
const { verdict, detail } = freshnessVerdictOf(sleep);
|
|
2407
|
+
if (verdict === "skip") {
|
|
2408
|
+
rows.push({ id: PLANE_HOST_SLEEP_ID, label: "Host sleep", status: "skip", blocking: false, detail: `not applicable — ${detail}` });
|
|
2409
|
+
}
|
|
2410
|
+
else if (verdict === "unverified") {
|
|
2411
|
+
rows.push({
|
|
2412
|
+
id: PLANE_HOST_SLEEP_ID,
|
|
2413
|
+
label: "Host sleep",
|
|
2414
|
+
status: "warn",
|
|
2415
|
+
blocking: false,
|
|
2416
|
+
detail: `unverified — ${detail}`,
|
|
2417
|
+
remedy: PLANE_FRESHNESS_UNVERIFIED_REMEDY,
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
else if (hostPowerFactFromReport(sleep).state === "present") {
|
|
2421
|
+
// BAPI-1506: a battery or lid advisory is a warning with its remedy, not
|
|
2422
|
+
// a pass. It still never blocks.
|
|
2423
|
+
rows.push({
|
|
2424
|
+
id: PLANE_HOST_SLEEP_ID,
|
|
2425
|
+
label: "Host sleep",
|
|
2426
|
+
status: "warn",
|
|
2427
|
+
blocking: false,
|
|
2428
|
+
detail: `present — ${detail}`,
|
|
2429
|
+
remedy: PLANE_SLEEP_REMEDY,
|
|
2430
|
+
});
|
|
2431
|
+
}
|
|
2432
|
+
else {
|
|
2433
|
+
// Readable evidence never blocks setup. "pass" means exactly that.
|
|
2434
|
+
rows.push({ id: PLANE_HOST_SLEEP_ID, label: "Host sleep", status: "pass", blocking: false, detail: `pass — ${detail}` });
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
return { rows, blockers, overridden };
|
|
2438
|
+
}
|
|
2439
|
+
/** Rows worth printing on a real launch: everything except a plain pass. */
|
|
2440
|
+
function adverseFreshnessRows(rows) {
|
|
2441
|
+
return rows.filter((row) => row.status !== "pass");
|
|
2442
|
+
}
|
|
2443
|
+
/** The blockers as one named refusal; both remedies are kept, in fixed order. */
|
|
2444
|
+
function freshnessRefusalOf(blockers) {
|
|
2445
|
+
const first = blockers[0];
|
|
2446
|
+
if (first === undefined)
|
|
2447
|
+
return null;
|
|
2448
|
+
return { reasonId: first.reasonId, remedy: blockers.map((blocker) => blocker.remedy).join(" ") };
|
|
2449
|
+
}
|
|
2450
|
+
function renderFreshnessRows(rows, heading = 'Plane freshness:') {
|
|
2451
|
+
return [heading, ...rows.flatMap(row => [
|
|
2452
|
+
` [${row.status === 'fail' && row.blocking ? 'Blocking' : row.status === 'warn' ? 'Warning' : 'Informational'}] ${row.label}${row.detail ? ` — ${row.detail}` : ''}`,
|
|
2453
|
+
...(row.remedy ? [` → ${row.remedy}`] : []),
|
|
2454
|
+
])];
|
|
2455
|
+
}
|
|
2456
|
+
/**
|
|
2457
|
+
* The guard for a CREATING launch, placed beside `checkPlaneAdmission` and before
|
|
2458
|
+
* the first setup or run-create side effect. Returns an exit code only when it
|
|
2459
|
+
* refuses; otherwise it prints any non-blocking finding and returns `null`.
|
|
2460
|
+
*
|
|
2461
|
+
* Fail-open on missing evidence: unreadable or absent evidence is a warning, so
|
|
2462
|
+
* it is never proof of staleness and never permission to skip another safeguard.
|
|
2463
|
+
*/
|
|
2464
|
+
async function planeFreshnessCreateGuard(ctx, own, trailer = []) {
|
|
2465
|
+
try {
|
|
2466
|
+
const reports = await collectFreshnessObservations(ctx, own);
|
|
2467
|
+
if (reports === null)
|
|
2468
|
+
return null;
|
|
2469
|
+
const boundRun = await boundRunForStaleRemedy(ctx, own, reports);
|
|
2470
|
+
let assessment = assessPlaneFreshness(reports, null, "create", ctx.options.allowStalePlane === true, boundRun);
|
|
2471
|
+
if (assessment.blockers.length > 0) {
|
|
2472
|
+
// setup-epic REUSES the epic's live run instead of creating one, and a reuse
|
|
2473
|
+
// is an attachment: it is warned about, never refused. Asked only when a
|
|
2474
|
+
// confirmed finding would otherwise block, so the common path reads nothing.
|
|
2475
|
+
const live = await ctx.deps
|
|
2476
|
+
.readLiveEpicRun(ctx.access, ctx.epicKey)
|
|
2477
|
+
.catch(() => ({ kind: "unknown" }));
|
|
2478
|
+
if (live.kind === "live") {
|
|
2479
|
+
assessment = assessPlaneFreshness(reports, null, "attach", ctx.options.allowStalePlane === true, boundRun);
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
const refusal = freshnessRefusalOf(assessment.blockers);
|
|
2483
|
+
const adverse = adverseFreshnessRows(assessment.rows);
|
|
2484
|
+
if (refusal !== null) {
|
|
2485
|
+
return refuse(ctx.deps, [
|
|
2486
|
+
`${ctx.epicKey}: REFUSED (${refusal.reasonId}) — the local plane is not current enough to ` +
|
|
2487
|
+
"create a run against.",
|
|
2488
|
+
...renderFreshnessRows(adverse),
|
|
2489
|
+
"",
|
|
2490
|
+
"Nothing was created.",
|
|
2491
|
+
...trailer,
|
|
2492
|
+
]);
|
|
2493
|
+
}
|
|
2494
|
+
if (adverse.length > 0) {
|
|
2495
|
+
const log = ctx.deps.launchReadinessLog ?? ctx.deps.errorLog;
|
|
2496
|
+
log([
|
|
2497
|
+
assessment.overridden
|
|
2498
|
+
? `${ctx.epicKey}: plane freshness — proceeding; a confirmed refusal was overridden for this invocation.`
|
|
2499
|
+
: `${ctx.epicKey}: plane freshness — proceeding; some evidence could not be verified.`,
|
|
2500
|
+
...renderFreshnessRows(adverse),
|
|
2501
|
+
].join("\n"));
|
|
2502
|
+
}
|
|
2503
|
+
return null;
|
|
2504
|
+
}
|
|
2505
|
+
catch {
|
|
2506
|
+
// An unexpected failure of an advisory read never stops a launch.
|
|
2507
|
+
return null;
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
/** The warning-only counterpart for an existing run. Never refuses, never throws. */
|
|
2511
|
+
async function surfacePlaneFreshnessForExistingRun(ctx, own) {
|
|
2512
|
+
try {
|
|
2513
|
+
const reports = await collectFreshnessObservations(ctx, own);
|
|
2514
|
+
if (reports === null)
|
|
2515
|
+
return;
|
|
2516
|
+
const boundRun = await boundRunForStaleRemedy(ctx, own, reports);
|
|
2517
|
+
const assessment = assessPlaneFreshness(reports, null, "attach", ctx.options.allowStalePlane === true, boundRun);
|
|
2518
|
+
const adverse = adverseFreshnessRows(assessment.rows);
|
|
2519
|
+
if (adverse.length === 0)
|
|
2520
|
+
return;
|
|
2521
|
+
const log = ctx.deps.launchReadinessLog ?? ctx.deps.errorLog;
|
|
2522
|
+
log([
|
|
2523
|
+
`${ctx.epicKey}: plane freshness — attaching to the existing run; findings below are warnings only.`,
|
|
2524
|
+
...renderFreshnessRows(adverse),
|
|
2525
|
+
].join("\n"));
|
|
2526
|
+
}
|
|
2527
|
+
catch {
|
|
2528
|
+
// Advisory only.
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
// ---------------------------------------------------------------------------
|
|
2532
|
+
// BAPI-1496 — worker-database advisories on a LOCAL launch
|
|
2533
|
+
// ---------------------------------------------------------------------------
|
|
2534
|
+
/**
|
|
2535
|
+
* Run the worker-database inspection for a local-plane target, once.
|
|
2536
|
+
*
|
|
2537
|
+
* `null` when nothing applies: a target that is not this machine's plane never
|
|
2538
|
+
* runs the inspector, so a remote launch's requests and output are unchanged.
|
|
2539
|
+
* An inspector that throws or times out prints one fixed line naming only the
|
|
2540
|
+
* closed classification, then yields the doctor's all-unknown section — whose
|
|
2541
|
+
* `worker-db-unverifiable` row is a warning, never a refusal.
|
|
2542
|
+
*/
|
|
2543
|
+
async function collectWorkerDatabaseAdvisory(deps, baseUrl, cache) {
|
|
2544
|
+
if (baseUrl === null || planeEndpointForBaseUrl(deps, baseUrl) === null)
|
|
2545
|
+
return null;
|
|
2546
|
+
const inspect = deps.inspectWorkerDatabase;
|
|
2547
|
+
if (inspect === undefined)
|
|
2548
|
+
return null;
|
|
2549
|
+
const run = async () => {
|
|
2550
|
+
let failed = false;
|
|
2551
|
+
const section = await collectWorkerDatabaseDoctorSection(inspect, WORKER_DATABASE_DOCTOR_TIMEOUT_MS, (failure) => {
|
|
2552
|
+
failed = true;
|
|
2553
|
+
deps.errorLog(`worker-db inspection failed: ${failure}`);
|
|
2554
|
+
}).catch(() => null);
|
|
2555
|
+
// A failed or timed-out inspection is ONE fact — the evidence is unverifiable —
|
|
2556
|
+
// so a launch shows only the `worker-db-unverifiable` warning, not six unknown
|
|
2557
|
+
// rows. The doctor's own all-unknown section is left as it is.
|
|
2558
|
+
if (section !== null && failed) {
|
|
2559
|
+
return { ...section, rows: section.rows.filter((row) => row.id === "worker-db-unverifiable") };
|
|
2560
|
+
}
|
|
2561
|
+
return section;
|
|
2562
|
+
};
|
|
2563
|
+
if (cache === undefined)
|
|
2564
|
+
return run();
|
|
2565
|
+
cache.workerDatabase ??= run();
|
|
2566
|
+
return cache.workerDatabase;
|
|
2567
|
+
}
|
|
2568
|
+
/** The advisory rows for a local launch: non-healthy `worker-db-*` warnings only. */
|
|
2569
|
+
async function workerDatabaseAdvisoryRows(deps, baseUrl, cache) {
|
|
2570
|
+
const section = await collectWorkerDatabaseAdvisory(deps, baseUrl, cache);
|
|
2571
|
+
return section === null ? [] : previewLaunchRows(workerDatabaseReadinessChecks(section));
|
|
2572
|
+
}
|
|
2573
|
+
/**
|
|
2574
|
+
* Print the worker-database warnings on a real local launch, beside the other
|
|
2575
|
+
* launch readiness output. Called after the creating launch's freshness guard
|
|
2576
|
+
* on every local creating path, attended and `--into-base` included. Advisory by
|
|
2577
|
+
* construction: it never refuses, never changes a decision or an exit code, and
|
|
2578
|
+
* an unexpected failure prints nothing.
|
|
2579
|
+
*/
|
|
2580
|
+
async function surfaceWorkerDatabaseAdvisory(ctx) {
|
|
2581
|
+
try {
|
|
2582
|
+
const rows = await workerDatabaseAdvisoryRows(ctx.deps, ctx.access.baseUrl, ctx.freshness);
|
|
2583
|
+
if (rows.length === 0)
|
|
2584
|
+
return;
|
|
2585
|
+
const log = ctx.deps.launchReadinessLog ?? ctx.deps.errorLog;
|
|
2586
|
+
log([
|
|
2587
|
+
`${ctx.epicKey}: worker databases — warnings only; they never block a launch.`,
|
|
2588
|
+
...renderFreshnessRows(rows).map((line, index) => (index === 0 ? "Worker databases (advisory):" : line)),
|
|
2589
|
+
].join("\n"));
|
|
2590
|
+
}
|
|
2591
|
+
catch {
|
|
2592
|
+
// Advisory only.
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
/**
|
|
2596
|
+
* Append the worker-database warnings to a LOCAL-plane preview's
|
|
2597
|
+
* `launch_checks`, modeled on {@link appendExecutorBuildFreshnessWarning}.
|
|
2598
|
+
* Gated on the preview's own `target.kind`, so a remote preview never runs the
|
|
2599
|
+
* inspector and its JSON is unchanged. Never changes the decision, a refusal,
|
|
2600
|
+
* readiness, or the exit code.
|
|
2601
|
+
*/
|
|
2602
|
+
async function appendWorkerDatabaseAdvisoryRows(deps, preview) {
|
|
2603
|
+
if (preview.target.kind !== "local_plane")
|
|
2604
|
+
return null;
|
|
2605
|
+
try {
|
|
2606
|
+
// BAPI-1506: the section is returned so the launch-need step reuses this one
|
|
2607
|
+
// inspection instead of running a second.
|
|
2608
|
+
const section = await collectWorkerDatabaseAdvisory(deps, preview.target.base_url);
|
|
2609
|
+
const rows = section === null ? [] : previewLaunchRows(workerDatabaseReadinessChecks(section));
|
|
2610
|
+
if (rows.length > 0)
|
|
2611
|
+
preview.launch_checks = [...(preview.launch_checks ?? []), ...rows];
|
|
2612
|
+
return section;
|
|
2613
|
+
}
|
|
2614
|
+
catch {
|
|
2615
|
+
// Leave the preview as it was.
|
|
2616
|
+
return null;
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
// ---------------------------------------------------------------------------
|
|
2620
|
+
// BAPI-1496 — terminal-run plane cleanup before a LOCAL creating launch
|
|
2621
|
+
// ---------------------------------------------------------------------------
|
|
2622
|
+
/**
|
|
2623
|
+
* Tear down this checkout's plane when it serves a finished run, before the
|
|
2624
|
+
* launch can attach to it or its preflight can refuse on it.
|
|
2625
|
+
*
|
|
2626
|
+
* `null` when nothing applies: a target that is not this machine's plane never
|
|
2627
|
+
* reaches the cleanup, so a remote launch makes no new call and sends no
|
|
2628
|
+
* signal. Eligibility, ownership re-validation, and the guarded teardown all
|
|
2629
|
+
* live in `plane/terminal-run-cleanup.ts`; this only routes. An incomplete or
|
|
2630
|
+
* aborted cleanup prints its one line and the launch continues into ordinary
|
|
2631
|
+
* preflight, whose `existing-plane` refusal still applies. A completed cleanup
|
|
2632
|
+
* drops every cached freshness observation, because they described the plane
|
|
2633
|
+
* that is now gone.
|
|
2634
|
+
*/
|
|
2635
|
+
async function cleanUpTerminalRunPlane(ctx, repoRoot) {
|
|
2636
|
+
const { deps } = ctx;
|
|
2637
|
+
const cleanup = deps.cleanupTerminalRunPlane;
|
|
2638
|
+
if (cleanup === undefined)
|
|
2639
|
+
return null;
|
|
2640
|
+
const endpoint = planeEndpointForBaseUrl(deps, ctx.access.baseUrl);
|
|
2641
|
+
if (endpoint === null)
|
|
2642
|
+
return null;
|
|
2643
|
+
let result;
|
|
2644
|
+
try {
|
|
2645
|
+
result = await cleanup({ repoRoot, endpoint });
|
|
2646
|
+
}
|
|
2647
|
+
catch {
|
|
2648
|
+
deps.errorLog("terminal-run plane cleanup could not run; inspect `plane status`.");
|
|
2649
|
+
return null;
|
|
2650
|
+
}
|
|
2651
|
+
const line = terminalRunCleanupStderrLine(result);
|
|
2652
|
+
if (line !== null)
|
|
2653
|
+
deps.errorLog(line);
|
|
2654
|
+
if (result.kind === "completed" && ctx.freshness !== undefined) {
|
|
2655
|
+
ctx.freshness.observations.clear();
|
|
2656
|
+
ctx.freshness.sleep = undefined;
|
|
2657
|
+
}
|
|
2658
|
+
return result;
|
|
2659
|
+
}
|
|
1789
2660
|
/**
|
|
1790
2661
|
* COLD START: nothing is listening on the plane endpoint, so bring the control
|
|
1791
2662
|
* plane up and compose the run against it.
|
|
@@ -1816,13 +2687,15 @@ async function coldStartPlane(ctx) {
|
|
|
1816
2687
|
// before anything is started, exactly as every other plan-file route does.
|
|
1817
2688
|
if (ctx.options.planFile)
|
|
1818
2689
|
await surfaceContainmentAdvisory(deps, ctx.options.planFile);
|
|
1819
|
-
deps.log(`${epicKey}: nothing is listening on the plane endpoint; cold-starting the
|
|
2690
|
+
deps.log(`${epicKey}: nothing is listening on the plane endpoint; cold-starting the local plane.`);
|
|
1820
2691
|
const repoRoot = deps.cwd();
|
|
2692
|
+
// BAPI-1496: a plane bound to a finished run is torn down before preflight.
|
|
2693
|
+
await cleanUpTerminalRunPlane(ctx, repoRoot);
|
|
1821
2694
|
const preflight = await deps.runPlanePreflight(repoRoot);
|
|
1822
2695
|
const precondition = firstPreconditionRefusal(preflight);
|
|
1823
2696
|
if (precondition) {
|
|
1824
2697
|
return refuse(deps, [
|
|
1825
|
-
`${epicKey}: cannot start the
|
|
2698
|
+
`${epicKey}: cannot start the Epic Implementer runtime — ${precondition.check} failed.`,
|
|
1826
2699
|
formatPlaneDiagnostic(precondition),
|
|
1827
2700
|
"",
|
|
1828
2701
|
"Nothing was started. Fix the item above and run drive-epic again.",
|
|
@@ -1841,8 +2714,11 @@ async function coldStartPlane(ctx) {
|
|
|
1841
2714
|
readiness = await deps.readReadiness(ctx.access);
|
|
1842
2715
|
}
|
|
1843
2716
|
catch (err) {
|
|
2717
|
+
if (isSchemaIncompatibleError(err)) {
|
|
2718
|
+
return refuse(deps, [...formatSchemaIncompatibleRefusal(epicKey, err), ...LAUNCHED_PLANE_LEFT_RUNNING]);
|
|
2719
|
+
}
|
|
1844
2720
|
return refuse(deps, [
|
|
1845
|
-
`${epicKey}: the plane came up, but its
|
|
2721
|
+
`${epicKey}: the plane came up, but its Epic Implementer readiness could not be read: ` +
|
|
1846
2722
|
safeDiagnosticMessage(err, "readiness request failed"),
|
|
1847
2723
|
...LAUNCHED_PLANE_LEFT_RUNNING,
|
|
1848
2724
|
]);
|
|
@@ -1862,7 +2738,7 @@ async function coldStartPlane(ctx) {
|
|
|
1862
2738
|
});
|
|
1863
2739
|
if (classification.kind === "operator_blocked") {
|
|
1864
2740
|
return refuse(deps, [
|
|
1865
|
-
`${epicKey}: the engine path still needs ${classification.describe}
|
|
2741
|
+
`${epicKey}: the engine path still needs ${classification.describe}.`,
|
|
1866
2742
|
operatorBlockedRemedy(classification),
|
|
1867
2743
|
...LAUNCHED_PLANE_LEFT_RUNNING,
|
|
1868
2744
|
]);
|
|
@@ -1879,12 +2755,17 @@ async function coldStartPlane(ctx) {
|
|
|
1879
2755
|
if (admission !== null) {
|
|
1880
2756
|
return refuse(deps, [...admission, ...LAUNCHED_PLANE_LEFT_RUNNING]);
|
|
1881
2757
|
}
|
|
2758
|
+
// BAPI-1476: before the first setup/run-create side effect.
|
|
2759
|
+
const staleCold = await planeFreshnessCreateGuard(ctx, { repoRoot, ownsManifest: true, planeId: controlPlane.planeId }, LAUNCHED_PLANE_LEFT_RUNNING);
|
|
2760
|
+
if (staleCold !== null)
|
|
2761
|
+
return staleCold;
|
|
2762
|
+
await surfaceWorkerDatabaseAdvisory(ctx);
|
|
1882
2763
|
return createRunAndRequestLanes(ctx, {
|
|
1883
2764
|
origin: "launched",
|
|
1884
2765
|
repoRoot,
|
|
1885
2766
|
planeId: controlPlane.planeId,
|
|
1886
2767
|
awaitPlaneReady: controlPlane.awaitPlaneReady,
|
|
1887
|
-
});
|
|
2768
|
+
}, readiness);
|
|
1888
2769
|
}
|
|
1889
2770
|
/**
|
|
1890
2771
|
* ATTACH: the plane endpoint already serves this repository's live plane.
|
|
@@ -1945,6 +2826,16 @@ async function attachToPlane(ctx, manifest, readiness) {
|
|
|
1945
2826
|
if (admission !== null) {
|
|
1946
2827
|
return refuse(deps, [...admission, "", "Nothing was started."]);
|
|
1947
2828
|
}
|
|
2829
|
+
// BAPI-1476: both branches below create a run, so the guard precedes both.
|
|
2830
|
+
const staleAttach = await planeFreshnessCreateGuard(attachCtx, {
|
|
2831
|
+
repoRoot: manifest.repoRoot,
|
|
2832
|
+
ownsManifest: true,
|
|
2833
|
+
planeId: manifest.planeId,
|
|
2834
|
+
...(manifest.epicRunId ? { epicRunId: manifest.epicRunId } : {}),
|
|
2835
|
+
});
|
|
2836
|
+
if (staleAttach !== null)
|
|
2837
|
+
return staleAttach;
|
|
2838
|
+
await surfaceWorkerDatabaseAdvisory(attachCtx);
|
|
1948
2839
|
if (dedicatedExecutorReason !== null) {
|
|
1949
2840
|
deps.log(`${epicKey}: attaching to this repository's running plane (${dedicatedExecutorReason}); ` +
|
|
1950
2841
|
"its reconciler will drive this run too, and a dedicated executor will be started for it.");
|
|
@@ -1956,7 +2847,7 @@ async function attachToPlane(ctx, manifest, readiness) {
|
|
|
1956
2847
|
repoRoot: manifest.repoRoot,
|
|
1957
2848
|
planeId: manifest.planeId,
|
|
1958
2849
|
...(manifest.epicRunId !== undefined ? { boundRunId: manifest.epicRunId } : {}),
|
|
1959
|
-
});
|
|
2850
|
+
}, readiness);
|
|
1960
2851
|
}
|
|
1961
2852
|
/**
|
|
1962
2853
|
* The shared tail: create/approve/cut through `setup-epic`, then request lanes
|
|
@@ -1967,9 +2858,16 @@ async function attachToPlane(ctx, manifest, readiness) {
|
|
|
1967
2858
|
* `executorClaimScopeArgs` as `--epic-run-id`, and `--repo-wide` is not
|
|
1968
2859
|
* constructible here.
|
|
1969
2860
|
*/
|
|
1970
|
-
async function createRunAndRequestLanes(ctx, plane) {
|
|
2861
|
+
async function createRunAndRequestLanes(ctx, plane, readiness) {
|
|
1971
2862
|
const { deps, epicKey } = ctx;
|
|
1972
2863
|
const launched = plane.origin === "launched";
|
|
2864
|
+
// BAPI-1452: a plane lane owns this run's executor, so the launch evidence
|
|
2865
|
+
// can only warn here; printed before the run exists.
|
|
2866
|
+
if (readiness !== undefined) {
|
|
2867
|
+
const gate = await launchCapabilityGate(ctx, readiness, { kind: "plane_lane" });
|
|
2868
|
+
if (gate !== null)
|
|
2869
|
+
return gate;
|
|
2870
|
+
}
|
|
1973
2871
|
// ---- PHASE TWO, part one: create the run ------------------------------
|
|
1974
2872
|
const setup = await runSelectedSetupEpic(ctx);
|
|
1975
2873
|
if (setup.exitCode !== 0) {
|
|
@@ -1981,7 +2879,7 @@ async function createRunAndRequestLanes(ctx, plane) {
|
|
|
1981
2879
|
deps.errorLog((launched
|
|
1982
2880
|
? [
|
|
1983
2881
|
"",
|
|
1984
|
-
`${epicKey}: the
|
|
2882
|
+
`${epicKey}: the Epic Implementer CONTROL PLANE was started by this invocation and is STILL RUNNING.`,
|
|
1985
2883
|
"No executor lane was spawned, so nothing is claiming jobs.",
|
|
1986
2884
|
"It was not wound down automatically, because doing so would stop a run that may",
|
|
1987
2885
|
"already have been created and approved. Inspect it with `plane status`, and wind it",
|
|
@@ -2158,7 +3056,7 @@ function remoteRuntimeRefusal(epicKey, access, readiness) {
|
|
|
2158
3056
|
const lines = [`${epicKey}: REFUSED (${reasonId}) — ${remedy}`];
|
|
2159
3057
|
if (bringUp === null) {
|
|
2160
3058
|
lines.push("This Bridge API version cannot report deployment liveness, so a launch against it cannot " +
|
|
2161
|
-
"be confirmed.
|
|
3059
|
+
"be confirmed. Try again later; if it keeps happening, email info@bridgegpt.ai.");
|
|
2162
3060
|
}
|
|
2163
3061
|
lines.push("", "No run was created.", REMOTE_NO_PLANE_LINE);
|
|
2164
3062
|
return { reasonId, remedy, lines };
|
|
@@ -2171,7 +3069,7 @@ function remoteRuntimeRefusal(epicKey, access, readiness) {
|
|
|
2171
3069
|
function durableCoverageRefusal(epicKey, access, profile, coverage, outcome) {
|
|
2172
3070
|
const remedy = [
|
|
2173
3071
|
"Start the durable host's executor with --repo-wide",
|
|
2174
|
-
" (`install conductor --execution-mode durable --executor-id <id> --repo-wide`),",
|
|
3072
|
+
" (`install conductor --execution-mode durable --executor-id <id> --repo-wide`; see `drive-epic --help-advanced`),",
|
|
2175
3073
|
"or launch without the durable profile.",
|
|
2176
3074
|
].join("\n");
|
|
2177
3075
|
return {
|
|
@@ -2272,6 +3170,11 @@ async function createRunWithRunExecutor(ctx, readiness) {
|
|
|
2272
3170
|
if (coverage !== true) {
|
|
2273
3171
|
return refuse(deps, durableCoverageRefusal(epicKey, access, profile, coverage, "No run was created").lines);
|
|
2274
3172
|
}
|
|
3173
|
+
// BAPI-1452: the durable host owns the executor clone and credential, so
|
|
3174
|
+
// this can only warn — but it says so before the run exists.
|
|
3175
|
+
const gate = await launchCapabilityGate(ctx, readiness, { kind: "durable_profile" }, "No run was created and no local executor was started.");
|
|
3176
|
+
if (gate !== null)
|
|
3177
|
+
return gate;
|
|
2275
3178
|
const setup = await runSelectedSetupEpic(ctx);
|
|
2276
3179
|
if (setup.exitCode === 0) {
|
|
2277
3180
|
deps.log([
|
|
@@ -2287,6 +3190,11 @@ async function createRunWithRunExecutor(ctx, readiness) {
|
|
|
2287
3190
|
if (!prepared.ok) {
|
|
2288
3191
|
return refuse(deps, executorPreconditionRefusal(epicKey, prepared.message, "No run was created.").lines);
|
|
2289
3192
|
}
|
|
3193
|
+
// BAPI-1452: the launch capability gate checks the SAME clone the executor
|
|
3194
|
+
// will own (`prepared.repoRoot`, its cwd) — not a second preparation.
|
|
3195
|
+
const gate = await launchCapabilityGate(ctx, readiness, { kind: "local", repoRoot: prepared.prepared.repoRoot }, "No run was created.");
|
|
3196
|
+
if (gate !== null)
|
|
3197
|
+
return gate;
|
|
2290
3198
|
const setup = await runSelectedSetupEpic(ctx);
|
|
2291
3199
|
if (setup.exitCode !== 0) {
|
|
2292
3200
|
// setup-epic already said why. Nothing was started for a run that may not exist.
|
|
@@ -2303,13 +3211,16 @@ async function createRunWithRunExecutor(ctx, readiness) {
|
|
|
2303
3211
|
"server-side before retrying; retrying blindly could create a second run.",
|
|
2304
3212
|
]);
|
|
2305
3213
|
}
|
|
2306
|
-
// POST-CREATE: start or reuse exactly one executor scoped to this run.
|
|
3214
|
+
// POST-CREATE: start or reuse exactly one executor scoped to this run. It
|
|
3215
|
+
// receives the merge credential this launch resolved (BAPI-1461), so a token
|
|
3216
|
+
// stored once works without exporting it or restarting the editor.
|
|
2307
3217
|
const ensured = await deps.ensureRunExecutor({
|
|
2308
3218
|
prepared: prepared.prepared,
|
|
2309
3219
|
repoName: access.repoName,
|
|
2310
3220
|
epicRunId: setup.epicRunId,
|
|
2311
3221
|
access,
|
|
2312
3222
|
profile,
|
|
3223
|
+
mergeCredential: await launchCredentialForExecutor(ctx, readiness),
|
|
2313
3224
|
});
|
|
2314
3225
|
return reportExecutorOutcome(ctx, ensured, setup.epicRunId, "created");
|
|
2315
3226
|
}
|
|
@@ -2400,7 +3311,7 @@ async function planeServesLiveRun(ctx, live) {
|
|
|
2400
3311
|
* - this checkout's plane bound to THIS run: its lanes serve it, start nothing;
|
|
2401
3312
|
* - otherwise: prepare, then ensure a run-scoped executor for the LIVE run's id.
|
|
2402
3313
|
*/
|
|
2403
|
-
async function attachToLiveRun(ctx, readiness, live) {
|
|
3314
|
+
async function attachToLiveRun(ctx, readiness, live, unusedDiscoveredPlan) {
|
|
2404
3315
|
const { deps, epicKey, access, profile } = ctx;
|
|
2405
3316
|
const classification = classifyV2Readiness(readiness, {
|
|
2406
3317
|
attended: ctx.options.attended === true,
|
|
@@ -2409,7 +3320,7 @@ async function attachToLiveRun(ctx, readiness, live) {
|
|
|
2409
3320
|
});
|
|
2410
3321
|
if (classification.kind === "operator_blocked") {
|
|
2411
3322
|
return refuse(deps, [
|
|
2412
|
-
`${epicKey}: the engine path still needs ${classification.describe}
|
|
3323
|
+
`${epicKey}: the engine path still needs ${classification.describe}.`,
|
|
2413
3324
|
"",
|
|
2414
3325
|
"This is not something starting the runtime can fix, so nothing was started.",
|
|
2415
3326
|
operatorBlockedRemedy(classification),
|
|
@@ -2428,9 +3339,22 @@ async function attachToLiveRun(ctx, readiness, live) {
|
|
|
2428
3339
|
if (admission !== null)
|
|
2429
3340
|
return refuse(deps, admission);
|
|
2430
3341
|
}
|
|
3342
|
+
// BAPI-1476: warning-only. An existing run is never stopped for freshness,
|
|
3343
|
+
// and the plane is neither repaired nor restarted here.
|
|
3344
|
+
await surfacePlaneFreshnessForExistingRun(ctx, manifest !== null
|
|
3345
|
+
? {
|
|
3346
|
+
repoRoot: manifest.repoRoot,
|
|
3347
|
+
ownsManifest: true,
|
|
3348
|
+
planeId: manifest.planeId,
|
|
3349
|
+
...(manifest.epicRunId ? { epicRunId: manifest.epicRunId } : {}),
|
|
3350
|
+
}
|
|
3351
|
+
: { repoRoot: deps.cwd(), ownsManifest: false });
|
|
2431
3352
|
}
|
|
2432
|
-
deps.log(
|
|
2433
|
-
|
|
3353
|
+
deps.log(unusedDiscoveredPlan === undefined
|
|
3354
|
+
? `${epicKey}: run ${live.epicRunId} is already live (${live.status}) and no plan was supplied or ` +
|
|
3355
|
+
"found, so nothing will be created; making sure it has an executor."
|
|
3356
|
+
: `${epicKey}: run ${live.epicRunId} is already live (${live.status}); the plan found by epic key ` +
|
|
3357
|
+
`(${unusedDiscoveredPlan}) is not used, so nothing will be created; making sure it has an executor.`);
|
|
2434
3358
|
if (live.effectivePolicy) {
|
|
2435
3359
|
deps.log(`Effective policy: stored; serial execution; caps ${live.effectivePolicy.caps ? JSON.stringify(live.effectivePolicy.caps) : "unavailable"}; ` +
|
|
2436
3360
|
`timeouts ${live.effectivePolicy.job_timeouts ? JSON.stringify(live.effectivePolicy.job_timeouts) : "unavailable"}; ` +
|
|
@@ -2472,6 +3396,7 @@ async function attachToLiveRun(ctx, readiness, live) {
|
|
|
2472
3396
|
epicRunId: live.epicRunId,
|
|
2473
3397
|
access,
|
|
2474
3398
|
profile,
|
|
3399
|
+
mergeCredential: await launchCredentialForExecutor(ctx, readiness),
|
|
2475
3400
|
});
|
|
2476
3401
|
return reportExecutorOutcome(ctx, ensured, live.epicRunId, "attached");
|
|
2477
3402
|
}
|
|
@@ -2486,8 +3411,8 @@ function localReconcilerRefusal(epicKey, readiness) {
|
|
|
2486
3411
|
const reasonId = reason ?? "local-reconciler-not-live";
|
|
2487
3412
|
const remedy = reason !== null
|
|
2488
3413
|
? BRING_UP_REFUSAL_MESSAGES[reason]
|
|
2489
|
-
: "the local
|
|
2490
|
-
"Start it
|
|
3414
|
+
: "the local plane reports no live reconciler, so nothing would drive this run. " +
|
|
3415
|
+
"Start it as `drive-epic --help-advanced` describes, or pass --plan-file so drive-epic can bring the runtime up.";
|
|
2491
3416
|
return {
|
|
2492
3417
|
reasonId,
|
|
2493
3418
|
remedy,
|
|
@@ -2537,9 +3462,20 @@ export const DRIVE_EPIC_ACCESS_REFUSAL_REASON_IDS = {
|
|
|
2537
3462
|
"credentials-unavailable": "access-credentials-unavailable",
|
|
2538
3463
|
"credential-unbound": "access-credential-unbound",
|
|
2539
3464
|
};
|
|
3465
|
+
/** BAPI-1452 — project launch rows into the stable preview shape. */
|
|
3466
|
+
function previewLaunchRows(checks) {
|
|
3467
|
+
return checks.map((check) => ({
|
|
3468
|
+
id: check.id,
|
|
3469
|
+
label: check.label,
|
|
3470
|
+
status: check.status,
|
|
3471
|
+
blocking: check.blocking,
|
|
3472
|
+
...(check.detail ? { detail: check.detail } : {}),
|
|
3473
|
+
...(check.remediation ? { remedy: check.remediation } : {}),
|
|
3474
|
+
}));
|
|
3475
|
+
}
|
|
2540
3476
|
/** The readiness reason a preview reports for a generic not-ready engine. */
|
|
2541
3477
|
const ENGINE_NOT_READY_REASON_ID = "engine-not-ready";
|
|
2542
|
-
const ENGINE_NOT_READY_REMEDY = `Resolve
|
|
3478
|
+
const ENGINE_NOT_READY_REMEDY = `Resolve Epic Implementer readiness (\`${PRINTED_CONDUCTOR_INSTALL_COMMAND}\`, then \`${PRINTED_DOCTOR_COMMAND}\`) and run drive-epic again. ${PRINTED_CHECK_NAME_INSTRUCTION} See ${CONSUMER_GUIDE_POINTER}.`;
|
|
2543
3479
|
/**
|
|
2544
3480
|
* Read exactly one well-shaped setup-epic dry-run JSON document out of captured
|
|
2545
3481
|
* `log` output. Anything else — none, several, or one with the wrong shape — is
|
|
@@ -2608,8 +3544,19 @@ function readSetupPreviewPayload(stdout) {
|
|
|
2608
3544
|
return {
|
|
2609
3545
|
existingRunId: typeof existing === "string" && existing !== "" ? existing : null,
|
|
2610
3546
|
featureBranch: typeof branch === "string" && branch !== "" ? branch : null,
|
|
3547
|
+
// An older bootstrap omits it; anything not a valid branch name is dropped.
|
|
3548
|
+
scopeBaseBranch: typeof doc.scope_base_branch === "string" && validateBranchName(doc.scope_base_branch) === null
|
|
3549
|
+
? doc.scope_base_branch
|
|
3550
|
+
: null,
|
|
2611
3551
|
serverValidated: doc.server_validated,
|
|
2612
3552
|
effectivePolicy,
|
|
3553
|
+
ticketReview: typeof doc.ticket_review === 'string' && /^(on|off \(legacy choice not recorded\)|off requested \(admin key required; choice recorded at create\)|off \(set by actor_[a-z0-9]{4,32} on \d{4}-\d{2}-\d{2}\))$/.test(doc.ticket_review) ? doc.ticket_review : 'on',
|
|
3554
|
+
launchReadiness: (() => { try {
|
|
3555
|
+
return doc.launch_readiness ? parseConductorReadinessResponse(doc.launch_readiness) : null;
|
|
3556
|
+
}
|
|
3557
|
+
catch {
|
|
3558
|
+
return null;
|
|
3559
|
+
} })(),
|
|
2613
3560
|
};
|
|
2614
3561
|
}
|
|
2615
3562
|
/** The plan DAG's node count, or `null` unless it parses to `{ nodes: [...] }`. */
|
|
@@ -2639,12 +3586,15 @@ async function runDriveEpicPreview(deps, options) {
|
|
|
2639
3586
|
readiness: { kind: "unknown" },
|
|
2640
3587
|
live_run: null,
|
|
2641
3588
|
plan: { status: "not_found" },
|
|
3589
|
+
already_done_children: { status: "not_checked", reason: "no_plan", children: [] },
|
|
2642
3590
|
summary: {
|
|
2643
3591
|
child_count: null,
|
|
3592
|
+
children_to_run: null,
|
|
2644
3593
|
branch: null,
|
|
2645
3594
|
posture,
|
|
2646
3595
|
auto_merge_scope: null,
|
|
2647
3596
|
escalation_channel: null,
|
|
3597
|
+
ticket_review: "on",
|
|
2648
3598
|
server_validation: "not_run",
|
|
2649
3599
|
},
|
|
2650
3600
|
decision: "refuse",
|
|
@@ -2667,8 +3617,11 @@ async function runDriveEpicPreview(deps, options) {
|
|
|
2667
3617
|
preview.refusal = { reason_id: named.reasonId, remedy: named.remedy };
|
|
2668
3618
|
preview.decision = "refuse";
|
|
2669
3619
|
};
|
|
3620
|
+
// The readiness read previewDecision observed, for the reachability row. It stays
|
|
3621
|
+
// `null` when no read succeeded, which the row reports as unconfirmed.
|
|
3622
|
+
const observed = { readiness: null };
|
|
2670
3623
|
try {
|
|
2671
|
-
await previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel);
|
|
3624
|
+
await previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel, observed);
|
|
2672
3625
|
}
|
|
2673
3626
|
catch {
|
|
2674
3627
|
// Sanitized on purpose: nothing from the thrown value reaches the document.
|
|
@@ -2680,6 +3633,10 @@ async function runDriveEpicPreview(deps, options) {
|
|
|
2680
3633
|
});
|
|
2681
3634
|
}
|
|
2682
3635
|
ensureRefusalExplained(preview);
|
|
3636
|
+
const workerDatabase = await appendWorkerDatabaseAdvisoryRows(deps, preview);
|
|
3637
|
+
appendEscalationReachabilityRow(preview, observed.readiness);
|
|
3638
|
+
await appendExecutorBuildFreshnessWarning(deps, preview);
|
|
3639
|
+
await appendLaunchNeedRows(deps, preview, observed, workerDatabase);
|
|
2683
3640
|
const emitted = await emitPreview(deps, options, preview);
|
|
2684
3641
|
// BAPI-1397 (F3): a `--json` document that could not reach stdout whole is a
|
|
2685
3642
|
// failed preview, never a silently truncated one.
|
|
@@ -2687,9 +3644,159 @@ async function runDriveEpicPreview(deps, options) {
|
|
|
2687
3644
|
return 1;
|
|
2688
3645
|
return preview.decision === "create" || preview.decision === "attach" ? 0 : 1;
|
|
2689
3646
|
}
|
|
3647
|
+
/**
|
|
3648
|
+
* Give every `create` or `attach` preview exactly one non-blocking
|
|
3649
|
+
* `escalation-reachability` row (BAPI-1490).
|
|
3650
|
+
*
|
|
3651
|
+
* Built from the readiness the preview already read and the channel the preview
|
|
3652
|
+
* already resolved; it adds no request and never changes the decision, a refusal,
|
|
3653
|
+
* a reason, or the exit code. A readiness that was not read (cold start, remote
|
|
3654
|
+
* read failure) is passed as unavailable, so the row warns rather than vanishing.
|
|
3655
|
+
* A refused or stopped preview describes no launch, so it gets none.
|
|
3656
|
+
*/
|
|
3657
|
+
function appendEscalationReachabilityRow(preview, readiness) {
|
|
3658
|
+
if (preview.decision !== "create" && preview.decision !== "attach")
|
|
3659
|
+
return;
|
|
3660
|
+
const [row] = previewLaunchRows([
|
|
3661
|
+
buildEscalationReachabilityCheck(readiness, preview.summary.escalation_channel),
|
|
3662
|
+
]);
|
|
3663
|
+
preview.launch_checks = [
|
|
3664
|
+
...(preview.launch_checks ?? []).filter((existing) => existing.id !== ESCALATION_REACHABILITY_CHECK_ID),
|
|
3665
|
+
row,
|
|
3666
|
+
];
|
|
3667
|
+
}
|
|
3668
|
+
/**
|
|
3669
|
+
* Append a non-blocking `executor-build-freshness` warning to the preview when a
|
|
3670
|
+
* local CLI override (`BAPI_MCP_CLI`) is set and the executor build is stale or
|
|
3671
|
+
* unreadable. Fail-open and read-only: it never changes the decision, a refusal,
|
|
3672
|
+
* readiness, or the exit code. A passing check, an unset or blank override, or a
|
|
3673
|
+
* missing dependency leaves `launch_checks` untouched. A thrown check prints one
|
|
3674
|
+
* fixed line and nothing from the exception.
|
|
3675
|
+
*/
|
|
3676
|
+
async function appendExecutorBuildFreshnessWarning(deps, preview) {
|
|
3677
|
+
const override = process.env[BRIDGE_LAUNCHER_ENV];
|
|
3678
|
+
if (override === undefined || override.trim() === "")
|
|
3679
|
+
return;
|
|
3680
|
+
if (deps.checkExecutorBuildFreshness === undefined)
|
|
3681
|
+
return;
|
|
3682
|
+
try {
|
|
3683
|
+
const report = await deps.checkExecutorBuildFreshness(deps.cwd());
|
|
3684
|
+
if (report.diagnostic === null)
|
|
3685
|
+
return;
|
|
3686
|
+
const outcome = report.outcomes.find((entry) => entry.status === "warn" || entry.status === "fail") ?? report.outcomes[0];
|
|
3687
|
+
const row = {
|
|
3688
|
+
id: "executor-build-freshness",
|
|
3689
|
+
label: outcome?.label ?? "Executor build freshness",
|
|
3690
|
+
status: "warn",
|
|
3691
|
+
blocking: false,
|
|
3692
|
+
...(outcome?.detail ? { detail: outcome.detail } : {}),
|
|
3693
|
+
remedy: PLANE_BUILD_FIX_REMEDIATION,
|
|
3694
|
+
};
|
|
3695
|
+
preview.launch_checks = [...(preview.launch_checks ?? []), row];
|
|
3696
|
+
}
|
|
3697
|
+
catch {
|
|
3698
|
+
deps.errorLog("Executor build freshness check could not run.");
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
/**
|
|
3702
|
+
* The base branch setup-epic's dry run reported, per preview. Kept off the
|
|
3703
|
+
* preview document so its JSON shape is unchanged.
|
|
3704
|
+
*/
|
|
3705
|
+
const previewScopeBaseBranch = new WeakMap();
|
|
3706
|
+
/** Run one probe; a missing boundary or any rejection becomes the fallback fact. */
|
|
3707
|
+
async function guardedProbe(run, fallback) {
|
|
3708
|
+
if (run === undefined)
|
|
3709
|
+
return fallback;
|
|
3710
|
+
try {
|
|
3711
|
+
return await run();
|
|
3712
|
+
}
|
|
3713
|
+
catch {
|
|
3714
|
+
return fallback;
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
/** Power evidence for the host the run's executor will run on. */
|
|
3718
|
+
async function collectHostPowerFact(deps, topology) {
|
|
3719
|
+
// `local` and `plane_lane` executors run on this host (a plane lane is chosen
|
|
3720
|
+
// only for this machine's own plane). Anything else runs elsewhere.
|
|
3721
|
+
if (topology === undefined)
|
|
3722
|
+
return { state: "unverifiable", reason: "unverified" };
|
|
3723
|
+
if (topology.kind === "durable_profile")
|
|
3724
|
+
return { state: "unverifiable", reason: "remote_executor" };
|
|
3725
|
+
const inspect = deps.inspectHostSleep;
|
|
3726
|
+
if (inspect === undefined)
|
|
3727
|
+
return { state: "unverifiable", reason: "probe_failed" };
|
|
3728
|
+
const repoRoot = topology.kind === "local" && topology.repoRoot !== null ? topology.repoRoot : deps.cwd();
|
|
3729
|
+
const report = await inspect(repoRoot).catch(() => null);
|
|
3730
|
+
return hostPowerFactFromReport(report);
|
|
3731
|
+
}
|
|
3732
|
+
/**
|
|
3733
|
+
* Append the five launch-need rows to a `create` preview (BAPI-1506): worker DB
|
|
3734
|
+
* role, self-hosted runner, host power (`host-sleep`), usage reset after the
|
|
3735
|
+
* run, and the information-only epic branch behind base.
|
|
3736
|
+
*
|
|
3737
|
+
* Every probe runs concurrently and is guarded on its own, so a missing
|
|
3738
|
+
* boundary, a rejection or a timeout is an `unverifiable` warning row. Rows
|
|
3739
|
+
* are `blocking: false` and NEVER change the decision, a refusal, readiness or
|
|
3740
|
+
* the exit code. An `attach`, refused or stopped preview gets none of them.
|
|
3741
|
+
* The merged `worker-db-role` warning row is decorated in place rather than
|
|
3742
|
+
* duplicated.
|
|
3743
|
+
*/
|
|
3744
|
+
async function appendLaunchNeedRows(deps, preview, observed, workerDatabase) {
|
|
3745
|
+
if (preview.decision !== "create")
|
|
3746
|
+
return;
|
|
3747
|
+
try {
|
|
3748
|
+
const repoRoot = deps.cwd();
|
|
3749
|
+
const local = preview.target.kind === "local_plane";
|
|
3750
|
+
const readiness = observed.readiness;
|
|
3751
|
+
const access = observed.access;
|
|
3752
|
+
const nowMs = (deps.now ?? Date.now)();
|
|
3753
|
+
const [runner, power, usage, branch] = await Promise.all([
|
|
3754
|
+
guardedProbe(deps.inspectRequiredRunners === undefined
|
|
3755
|
+
? undefined
|
|
3756
|
+
: () => deps.inspectRequiredRunners({
|
|
3757
|
+
repoRoot,
|
|
3758
|
+
providerRepository: readiness?.provider_repository,
|
|
3759
|
+
gateProfile: readiness?.gate_profile,
|
|
3760
|
+
}), { state: "unverifiable", reason: "probe_failed" }),
|
|
3761
|
+
guardedProbe(() => collectHostPowerFact(deps, observed.launchTopology), { state: "unverifiable", reason: "probe_failed" }),
|
|
3762
|
+
guardedProbe(deps.fetchUsageAdvisory === undefined || access === undefined
|
|
3763
|
+
? undefined
|
|
3764
|
+
: () => deps.fetchUsageAdvisory(access), null),
|
|
3765
|
+
guardedProbe(deps.inspectEpicBranchBehind === undefined
|
|
3766
|
+
? undefined
|
|
3767
|
+
: () => deps.inspectEpicBranchBehind({
|
|
3768
|
+
repoRoot,
|
|
3769
|
+
branch: preview.summary.branch,
|
|
3770
|
+
baseBranch: previewScopeBaseBranch.get(preview) ?? null,
|
|
3771
|
+
}), preview.summary.branch === null
|
|
3772
|
+
? { state: "not_applicable", reason: "no_epic_branch" }
|
|
3773
|
+
: { state: "unverifiable", reason: "git_failed" }),
|
|
3774
|
+
]);
|
|
3775
|
+
const workerRow = buildWorkerDbRoleLaunchRow(workerDatabase, local);
|
|
3776
|
+
const rows = [
|
|
3777
|
+
buildSelfHostedRunnerLaunchRow(runner),
|
|
3778
|
+
buildHostPowerLaunchRow(power),
|
|
3779
|
+
buildUsageResetAfterRunLaunchRow({
|
|
3780
|
+
advisory: usage ?? REPOSITORY_USAGE_ADVISORY_UNAVAILABLE,
|
|
3781
|
+
childCount: preview.summary.child_count,
|
|
3782
|
+
nowMs,
|
|
3783
|
+
}),
|
|
3784
|
+
buildEpicBranchBehindBaseLaunchRow(branch),
|
|
3785
|
+
];
|
|
3786
|
+
const existing = preview.launch_checks ?? [];
|
|
3787
|
+
const roleIndex = existing.findIndex((row) => row.id === workerRow.id);
|
|
3788
|
+
const merged = roleIndex >= 0
|
|
3789
|
+
? existing.map((row, index) => (index === roleIndex ? workerRow : row))
|
|
3790
|
+
: [...existing, workerRow];
|
|
3791
|
+
preview.launch_checks = [...merged, ...rows];
|
|
3792
|
+
}
|
|
3793
|
+
catch {
|
|
3794
|
+
// Advisory only: leave the preview as it was.
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
2690
3797
|
/** The fixed explanation used when a refusal reached the end with none of its own. */
|
|
2691
3798
|
export const PREVIEW_REFUSAL_FALLBACK_REASON_ID = "preview-refused";
|
|
2692
|
-
const PREVIEW_REFUSAL_FALLBACK_REMEDY =
|
|
3799
|
+
const PREVIEW_REFUSAL_FALLBACK_REMEDY = `The preview refused this launch without naming a specific cause. Resolve Epic Implementer readiness and preview again; nothing was created or started. See ${CONSUMER_GUIDE_POINTER}.`;
|
|
2693
3800
|
function isNamedRefusal(value) {
|
|
2694
3801
|
if (value === null || typeof value !== "object")
|
|
2695
3802
|
return false;
|
|
@@ -2717,7 +3824,7 @@ export function ensureRefusalExplained(preview) {
|
|
|
2717
3824
|
: { reason_id: PREVIEW_REFUSAL_FALLBACK_REASON_ID, remedy: PREVIEW_REFUSAL_FALLBACK_REMEDY };
|
|
2718
3825
|
}
|
|
2719
3826
|
/** Fill in `preview` by walking the same decisions a real launch makes, read-only. */
|
|
2720
|
-
async function previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel) {
|
|
3827
|
+
async function previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel, observed = { readiness: null }) {
|
|
2721
3828
|
const { epicKey } = options;
|
|
2722
3829
|
const unknownReadiness = () => refuseWithReadiness("unknown", {
|
|
2723
3830
|
reasonId: READINESS_UNKNOWN_REASON_ID,
|
|
@@ -2753,11 +3860,22 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2753
3860
|
? { source: accessResult.targetSource }
|
|
2754
3861
|
: {}),
|
|
2755
3862
|
};
|
|
2756
|
-
const ctx = {
|
|
3863
|
+
const ctx = {
|
|
3864
|
+
deps,
|
|
3865
|
+
epicKey,
|
|
3866
|
+
options,
|
|
3867
|
+
access,
|
|
3868
|
+
profile,
|
|
3869
|
+
targetSource: accessResult.targetSource ?? "default",
|
|
3870
|
+
freshness: createPlaneFreshnessCache(),
|
|
3871
|
+
launchCredential: {},
|
|
3872
|
+
};
|
|
2757
3873
|
// Discover before readiness so a selected local plane can preview its guarded
|
|
2758
3874
|
// cold start. Keep the candidate out of ordinary failure documents.
|
|
2759
3875
|
let planPath = options.planFile;
|
|
2760
3876
|
let plan = { status: "not_found" };
|
|
3877
|
+
// The full discovery result, kept for the guarded plane-down stop below.
|
|
3878
|
+
let discovery = null;
|
|
2761
3879
|
let docsRootUnresolved = false;
|
|
2762
3880
|
if (planPath !== undefined) {
|
|
2763
3881
|
plan = { status: "supplied", path: planPath };
|
|
@@ -2769,6 +3887,7 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2769
3887
|
}
|
|
2770
3888
|
else {
|
|
2771
3889
|
const discovered = await discoverEpicPlan(epicKey, docs.docsDir, deps.discoveryFs);
|
|
3890
|
+
discovery = discovered;
|
|
2772
3891
|
plan = {
|
|
2773
3892
|
status: discovered.status === "found" ? "found" : discovered.status === "plan_ambiguous" ? "ambiguous" : "not_found",
|
|
2774
3893
|
...(discovered.status === "found" ? { path: discovered.path } : {}),
|
|
@@ -2782,12 +3901,21 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2782
3901
|
planPath = discovered.path;
|
|
2783
3902
|
}
|
|
2784
3903
|
}
|
|
3904
|
+
// Every later exit, refusal or not, reports the plan state as discovered.
|
|
3905
|
+
preview.plan = plan;
|
|
2785
3906
|
// --- Readiness. -----------------------------------------------------------
|
|
2786
3907
|
let readiness;
|
|
2787
3908
|
try {
|
|
2788
3909
|
readiness = await deps.readReadiness(access);
|
|
2789
3910
|
}
|
|
2790
3911
|
catch (err) {
|
|
3912
|
+
if (isSchemaIncompatibleError(err)) {
|
|
3913
|
+
return refuseTopLevel({
|
|
3914
|
+
reasonId: "schema-incompatible",
|
|
3915
|
+
remedy: "Local schema incompatible: launch-critical schema objects are missing. " +
|
|
3916
|
+
`${SCHEMA_INCOMPATIBLE_REPAIR_ROUTE} Nothing was created or started.`,
|
|
3917
|
+
});
|
|
3918
|
+
}
|
|
2791
3919
|
if (planPath !== undefined && endpoint !== null && profile?.executionMode !== "durable" &&
|
|
2792
3920
|
err instanceof ConductorBridgeApiError && err.kind === "network") {
|
|
2793
3921
|
const probe = await deps.probePlanePort(endpoint.host, endpoint.port).catch(() => ({ kind: "error" }));
|
|
@@ -2795,11 +3923,17 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2795
3923
|
const preflight = await deps.runPlanePreflight(deps.cwd());
|
|
2796
3924
|
const blocker = firstPreconditionRefusal(preflight) ??
|
|
2797
3925
|
preflight.diagnostics.find((diagnostic) => diagnostic.severity === "blocking");
|
|
2798
|
-
preview.plan = plan;
|
|
2799
3926
|
if (blocker) {
|
|
3927
|
+
// Keep the blocker's own evidence (e.g. Alembic revisions) and its
|
|
3928
|
+
// fix. The fixed sentence is only for a blocker that carries neither.
|
|
3929
|
+
const evidence = [blocker.message, blocker.remediation]
|
|
3930
|
+
.filter((part) => typeof part === "string" && part.trim() !== "")
|
|
3931
|
+
.join(" ");
|
|
2800
3932
|
return refuseTopLevel({
|
|
2801
3933
|
reasonId: blocker.check,
|
|
2802
|
-
remedy:
|
|
3934
|
+
remedy: evidence !== ""
|
|
3935
|
+
? evidence
|
|
3936
|
+
: `The local plane preflight refused ${blocker.check}. Fix that condition and preview again.`,
|
|
2803
3937
|
});
|
|
2804
3938
|
}
|
|
2805
3939
|
if (!preflight.ok) {
|
|
@@ -2813,12 +3947,39 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2813
3947
|
if (preview.decision === "create") {
|
|
2814
3948
|
preview.cold_start = { plane_endpoint: access.baseUrl, will_start_plane: true, live_run_checked: false };
|
|
2815
3949
|
}
|
|
3950
|
+
// No plane exists yet, so there is no snapshot or database to compare;
|
|
3951
|
+
// the host-sleep advisory still applies to the plane about to start.
|
|
3952
|
+
observed.access = access;
|
|
3953
|
+
observed.launchTopology = { kind: "local", repoRoot: null };
|
|
3954
|
+
await applyPreviewFreshness(ctx, preview, {}, true);
|
|
3955
|
+
return;
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
// The narrow no-plan stop. Mirrors the real launch exactly: a local,
|
|
3959
|
+
// non-durable plane that refuses connections, and no usable plan. This is
|
|
3960
|
+
// the pre-existing `stop` outcome with no refusal and no new reason id; the
|
|
3961
|
+
// probe is the only port check the preview makes.
|
|
3962
|
+
if (planPath === undefined &&
|
|
3963
|
+
endpoint !== null &&
|
|
3964
|
+
profile?.executionMode !== "durable" &&
|
|
3965
|
+
err instanceof ConductorBridgeApiError &&
|
|
3966
|
+
err.kind === "network" &&
|
|
3967
|
+
discovery !== null &&
|
|
3968
|
+
(discovery.status === "plan_not_found" || discovery.status === "plan_ambiguous")) {
|
|
3969
|
+
const probe = await deps.probePlanePort(endpoint.host, endpoint.port).catch(() => ({ kind: "error" }));
|
|
3970
|
+
if (probe.kind === "refused") {
|
|
3971
|
+
preview.readiness = {
|
|
3972
|
+
kind: "unknown",
|
|
3973
|
+
remedy: planeDownNoPlanStopText(epicKey, { profile, options }, discovery),
|
|
3974
|
+
};
|
|
3975
|
+
preview.refusal = null;
|
|
3976
|
+
preview.decision = "stop";
|
|
2816
3977
|
return;
|
|
2817
3978
|
}
|
|
2818
3979
|
}
|
|
2819
3980
|
return unknownReadiness();
|
|
2820
3981
|
}
|
|
2821
|
-
|
|
3982
|
+
observed.readiness = readiness;
|
|
2822
3983
|
if (docsRootUnresolved)
|
|
2823
3984
|
return refuseTopLevel(docsRootRefusal(epicKey));
|
|
2824
3985
|
const live = await deps.readLiveEpicRun(access, epicKey);
|
|
@@ -2826,8 +3987,13 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2826
3987
|
return refuseTopLevel(liveRunAmbiguousRefusal(epicKey));
|
|
2827
3988
|
if (live.kind === "unknown")
|
|
2828
3989
|
return refuseTopLevel(liveRunUnknownRefusal(epicKey, live.reason));
|
|
2829
|
-
|
|
2830
|
-
|
|
3990
|
+
// BAPI-1532 — a DISCOVERED plan beside a live run is unused: the launch attaches,
|
|
3991
|
+
// so the effective plan path is cleared while the preview still reports the plan
|
|
3992
|
+
// as found. An explicit --plan-file keeps its create-path behavior unchanged.
|
|
3993
|
+
const attachingDiscoveredPlan = options.planFile === undefined && plan.status === "found" && live.kind === "live";
|
|
3994
|
+
const effectivePlanPath = attachingDiscoveredPlan ? undefined : planPath;
|
|
3995
|
+
const previewCtx = { ...ctx, options: { ...options, ...(effectivePlanPath ? { planFile: effectivePlanPath } : {}) } };
|
|
3996
|
+
const prepared = effectivePlanPath ? await prepareSetupSelection(previewCtx) : null;
|
|
2831
3997
|
if (prepared !== null && !prepared.ok) {
|
|
2832
3998
|
return refuseTopLevel({
|
|
2833
3999
|
reasonId: prepared.reasonId,
|
|
@@ -2838,13 +4004,19 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2838
4004
|
attended: options.attended === true,
|
|
2839
4005
|
intoBase: options.intoBase === true,
|
|
2840
4006
|
effectivePolicy: prepared?.ok ? prepared.handoff.selection : undefined,
|
|
2841
|
-
approvedStoredPolicy: live.kind === "live" && !
|
|
4007
|
+
approvedStoredPolicy: live.kind === "live" && !effectivePlanPath,
|
|
2842
4008
|
});
|
|
2843
4009
|
preview.readiness = { kind: classification.kind };
|
|
4010
|
+
if (classification.kind === 'operator_blocked') {
|
|
4011
|
+
const requirement = V2_READINESS_REQUIREMENTS.find(req => req.id === classification.id);
|
|
4012
|
+
if (requirement)
|
|
4013
|
+
preview.launch_checks = [{ id: requirement.id, label: requirement.describe, status: 'fail', blocking: true, remedy: classification.remedy }];
|
|
4014
|
+
}
|
|
2844
4015
|
const selection = selectConductor(readiness);
|
|
2845
4016
|
// --- The live run. Conflict and unknown refuse whatever the plan says. ------
|
|
2846
4017
|
if (live.kind === "live") {
|
|
2847
4018
|
preview.live_run = { epic_run_id: live.epicRunId, status: live.status };
|
|
4019
|
+
preview.summary.ticket_review = live.ticketReview ?? "on";
|
|
2848
4020
|
if (live.effectivePolicy) {
|
|
2849
4021
|
preview.summary.policy_source = live.effectivePolicy.source;
|
|
2850
4022
|
preview.summary.serial_execution = live.effectivePolicy.serial_execution;
|
|
@@ -2852,11 +4024,14 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2852
4024
|
preview.summary.job_timeouts = live.effectivePolicy.job_timeouts;
|
|
2853
4025
|
preview.summary.escalation_channel_label = live.effectivePolicy.escalation_channel;
|
|
2854
4026
|
preview.summary.posture = live.effectivePolicy.posture;
|
|
2855
|
-
preview.summary.escalation_channel =
|
|
4027
|
+
preview.summary.escalation_channel =
|
|
4028
|
+
live.effectivePolicy.escalation_channel === "configured webhook"
|
|
4029
|
+
? "webhook"
|
|
4030
|
+
: live.effectivePolicy.escalation_channel === "account email" ? "email" : null;
|
|
2856
4031
|
}
|
|
2857
4032
|
}
|
|
2858
4033
|
// --- No plan: attach to a live run, or stop. --------------------------------
|
|
2859
|
-
if (
|
|
4034
|
+
if (effectivePlanPath === undefined) {
|
|
2860
4035
|
if (live.kind !== "live") {
|
|
2861
4036
|
if (selection.kind === "not_ready") {
|
|
2862
4037
|
return refuseWithReadiness(classification.kind, {
|
|
@@ -2870,20 +4045,47 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2870
4045
|
const blocked = previewReadinessRefusal(ctx, classification, readiness, planeTarget, "attach");
|
|
2871
4046
|
if (blocked !== null)
|
|
2872
4047
|
return refuseWithReadiness(classification.kind, blocked);
|
|
2873
|
-
const
|
|
4048
|
+
const attachTopology = {};
|
|
4049
|
+
const facts = await previewExecutorFacts(ctx, readiness, planeTarget, live, "No run was changed", attachTopology);
|
|
2874
4050
|
if (facts !== null)
|
|
2875
4051
|
return refuseWithReadiness(classification.kind, facts, true);
|
|
2876
4052
|
preview.decision = "attach";
|
|
4053
|
+
await applyPreviewFreshness(ctx, preview, attachTopology, false);
|
|
2877
4054
|
return;
|
|
2878
4055
|
}
|
|
4056
|
+
// The create path below always has a plan.
|
|
4057
|
+
const planPathForCreate = effectivePlanPath;
|
|
2879
4058
|
// --- A plan is available: the create path's gates, then setup's own dry run. --
|
|
2880
4059
|
const blocked = previewReadinessRefusal(ctx, classification, readiness, planeTarget, "create");
|
|
2881
|
-
if (blocked !== null)
|
|
4060
|
+
if (blocked !== null) {
|
|
4061
|
+
// BAPI-1452: a disabled Setup-page posture keeps its exact control name and
|
|
4062
|
+
// turn-it-back-on remedy in the structured output as well as the refusal.
|
|
4063
|
+
if (classification.kind === "operator_blocked" && classification.id === "unattended_consent") {
|
|
4064
|
+
preview.launch_checks = previewLaunchRows(projectReadinessRowBlocking([unattendedPostureCheck(readiness)], {
|
|
4065
|
+
attended: options.attended === true,
|
|
4066
|
+
intoBase: options.intoBase === true,
|
|
4067
|
+
}));
|
|
4068
|
+
}
|
|
2882
4069
|
return refuseWithReadiness(classification.kind, blocked);
|
|
2883
|
-
|
|
4070
|
+
}
|
|
4071
|
+
const topology = {};
|
|
4072
|
+
const facts = await previewExecutorFacts(ctx, readiness, planeTarget, null, "No run was created", topology);
|
|
2884
4073
|
if (facts !== null)
|
|
2885
4074
|
return refuseWithReadiness(classification.kind, facts, true);
|
|
2886
|
-
|
|
4075
|
+
// BAPI-1452: the same launch evidence a real launch collects, before any
|
|
4076
|
+
// setup preview. A local plane that still needs its runtime will run this
|
|
4077
|
+
// launch on plane lanes, whose executor this launcher cannot inspect.
|
|
4078
|
+
const launchTopology = planeTarget && classification.kind === "runtime_only"
|
|
4079
|
+
? { kind: "plane_lane" }
|
|
4080
|
+
: topology.value ?? { kind: "local", repoRoot: null };
|
|
4081
|
+
const launch = await previewLaunchCapability(prepared?.ok ? { ...previewCtx, handoff: prepared.handoff } : previewCtx, readiness, launchTopology);
|
|
4082
|
+
preview.launch_checks = previewLaunchRows(launch.checks);
|
|
4083
|
+
if (launch.refusal !== null)
|
|
4084
|
+
return refuseWithReadiness("operator_blocked", launch.refusal);
|
|
4085
|
+
observed.access = access;
|
|
4086
|
+
observed.launchTopology = launchTopology;
|
|
4087
|
+
await finalizeCreatePreview(prepared?.ok ? { ...previewCtx, handoff: prepared.handoff } : previewCtx, preview, planPathForCreate, false, refuseTopLevel, live.kind === "live");
|
|
4088
|
+
await applyPreviewFreshness(ctx, preview, topology, false);
|
|
2887
4089
|
}
|
|
2888
4090
|
async function finalizeCreatePreview(ctx, preview, planPath, coldStart, refuseTopLevel, liveRunKnown = false) {
|
|
2889
4091
|
const { deps, options, access } = ctx;
|
|
@@ -2905,11 +4107,59 @@ async function finalizeCreatePreview(ctx, preview, planPath, coldStart, refuseTo
|
|
|
2905
4107
|
: prepared.handoff.selection.escalationChannel === "account email" ? "email" : null;
|
|
2906
4108
|
}
|
|
2907
4109
|
preview.summary.child_count = await readPlanChildCount(deps, planPath);
|
|
2908
|
-
|
|
2909
|
-
|
|
4110
|
+
preview.summary.children_to_run = preview.summary.child_count;
|
|
4111
|
+
// BAPI-1510 — the already-merged check, after plan discovery and BEFORE the
|
|
4112
|
+
// setup dry run, so the dry run validates the plan a real create would submit.
|
|
4113
|
+
// An attach, a disabled check, and a cold start (no plane to ask yet) classify
|
|
4114
|
+
// nothing and keep every child.
|
|
4115
|
+
let evaluation = null;
|
|
4116
|
+
if (liveRunKnown) {
|
|
4117
|
+
preview.already_done_children = { status: "not_checked", reason: "attach", children: [] };
|
|
4118
|
+
}
|
|
4119
|
+
else if (options.noMergedCheck === true) {
|
|
4120
|
+
preview.already_done_children = { status: "not_checked", reason: "disabled", children: [] };
|
|
4121
|
+
}
|
|
4122
|
+
else if (coldStart) {
|
|
4123
|
+
preview.already_done_children = { status: "not_checked", reason: "cold_start", children: [] };
|
|
4124
|
+
}
|
|
4125
|
+
else {
|
|
4126
|
+
evaluation = await evaluateMergedChildren(ctx, planPath);
|
|
4127
|
+
preview.already_done_children = evaluation.state;
|
|
4128
|
+
if (evaluation.refusals.length > 0) {
|
|
4129
|
+
return refuseTopLevel({
|
|
4130
|
+
reasonId: "skip-child-refused",
|
|
4131
|
+
remedy: `${skipRefusalRemedy(evaluation.refusals)} Nothing was created.`,
|
|
4132
|
+
});
|
|
4133
|
+
}
|
|
4134
|
+
if (evaluation.skipKeys.size > 0 && evaluation.skipKeys.size >= evaluation.childKeys.length) {
|
|
4135
|
+
return refuseTopLevel({
|
|
4136
|
+
reasonId: EPIC_ALREADY_COMPLETE_REASON_ID,
|
|
4137
|
+
remedy: EPIC_ALREADY_COMPLETE_REMEDY,
|
|
4138
|
+
});
|
|
4139
|
+
}
|
|
4140
|
+
if (preview.summary.child_count !== null) {
|
|
4141
|
+
preview.summary.children_to_run = preview.summary.child_count - evaluation.skipKeys.size;
|
|
4142
|
+
}
|
|
4143
|
+
}
|
|
4144
|
+
const trim = evaluation !== null &&
|
|
4145
|
+
evaluation.skipKeys.size > 0 &&
|
|
4146
|
+
evaluation.plan !== null &&
|
|
4147
|
+
evaluation.cutCommitSha !== null
|
|
4148
|
+
? { plan: trimDonePlanNodes(evaluation.plan, evaluation.skipKeys), cut: evaluation.cutCommitSha }
|
|
4149
|
+
: null;
|
|
4150
|
+
const runPreviewSetup = (effectivePlanPath) => deps.previewSetupEpic({
|
|
4151
|
+
argv: [...buildSetupEpicArgv({ ...options, planFile: effectivePlanPath }), ...(coldStart ? ["--local-only"] : []), "--dry-run", "--json"],
|
|
2910
4152
|
baseUrl: access.baseUrl,
|
|
2911
|
-
handoff: prepared.handoff,
|
|
4153
|
+
handoff: trim !== null ? { ...prepared.handoff, expectedCutCommitSha: trim.cut } : prepared.handoff,
|
|
2912
4154
|
});
|
|
4155
|
+
const setup = trim !== null
|
|
4156
|
+
? await (deps.withPlanPath ?? withPlanPath)({
|
|
4157
|
+
originalPath: planPath,
|
|
4158
|
+
trimmedJson: JSON.stringify(trim.plan, null, 2),
|
|
4159
|
+
skipped: true,
|
|
4160
|
+
warn: (message) => deps.errorLog(message),
|
|
4161
|
+
}, runPreviewSetup)
|
|
4162
|
+
: await runPreviewSetup(planPath);
|
|
2913
4163
|
if (setup.exitCode !== 0) {
|
|
2914
4164
|
preview.summary.server_validation = "failed";
|
|
2915
4165
|
return refuseTopLevel({
|
|
@@ -2929,6 +4179,16 @@ async function finalizeCreatePreview(ctx, preview, planPath, coldStart, refuseTo
|
|
|
2929
4179
|
}
|
|
2930
4180
|
preview.summary.server_validation = payload.serverValidated ? "passed" : "partial";
|
|
2931
4181
|
preview.summary.branch = payload.featureBranch;
|
|
4182
|
+
if (payload.scopeBaseBranch !== null)
|
|
4183
|
+
previewScopeBaseBranch.set(preview, payload.scopeBaseBranch);
|
|
4184
|
+
preview.summary.ticket_review = payload.ticketReview;
|
|
4185
|
+
if (payload.launchReadiness) {
|
|
4186
|
+
preview.prerequisites = payload.launchReadiness.prerequisites;
|
|
4187
|
+
preview.is_production = payload.launchReadiness.is_production;
|
|
4188
|
+
const launch = classifyV2Readiness(payload.launchReadiness, { attended: options.attended, intoBase: options.intoBase });
|
|
4189
|
+
if (launch.kind === 'operator_blocked')
|
|
4190
|
+
return refuseTopLevel({ reasonId: launch.id, remedy: launch.remedy ?? 'Retry readiness.' });
|
|
4191
|
+
}
|
|
2932
4192
|
if (payload.effectivePolicy) {
|
|
2933
4193
|
const facts = payload.effectivePolicy;
|
|
2934
4194
|
preview.summary.policy_source = facts.source;
|
|
@@ -2945,6 +4205,9 @@ async function finalizeCreatePreview(ctx, preview, planPath, coldStart, refuseTo
|
|
|
2945
4205
|
preview.summary.posture === "unattended" ? payload.featureBranch : null;
|
|
2946
4206
|
// The bootstrap reusing a run IS an attach: it reports the run's id.
|
|
2947
4207
|
if (payload.existingRunId !== null) {
|
|
4208
|
+
// Existing runs are never trimmed: report the check as not applied.
|
|
4209
|
+
preview.already_done_children = { status: "not_checked", reason: "attach", children: [] };
|
|
4210
|
+
preview.summary.children_to_run = preview.summary.child_count;
|
|
2948
4211
|
preview.live_run = {
|
|
2949
4212
|
epic_run_id: payload.existingRunId,
|
|
2950
4213
|
status: preview.live_run?.epic_run_id === payload.existingRunId ? preview.live_run.status : null,
|
|
@@ -2973,15 +4236,67 @@ function previewReadinessRefusal(ctx, classification, readiness, planeTarget, pa
|
|
|
2973
4236
|
}
|
|
2974
4237
|
return null;
|
|
2975
4238
|
}
|
|
4239
|
+
/**
|
|
4240
|
+
* Append the freshness rows to a preview and, for a decision that would CREATE a
|
|
4241
|
+
* run, apply the creation policy. An `attach` decision (an existing live run, or a
|
|
4242
|
+
* bootstrap that reuses one) only ever warns. Reads nothing for a non-plane target.
|
|
4243
|
+
*
|
|
4244
|
+
* Advisory by construction: an unexpected failure here leaves the preview exactly
|
|
4245
|
+
* as it was.
|
|
4246
|
+
*/
|
|
4247
|
+
async function applyPreviewFreshness(ctx, preview, topology, coldStart) {
|
|
4248
|
+
if (preview.decision !== "create" && preview.decision !== "attach")
|
|
4249
|
+
return;
|
|
4250
|
+
const { deps } = ctx;
|
|
4251
|
+
if (planeEndpointForBaseUrl(deps, ctx.access.baseUrl) === null)
|
|
4252
|
+
return;
|
|
4253
|
+
try {
|
|
4254
|
+
const manifest = topology.manifest ?? null;
|
|
4255
|
+
const own = manifest !== null
|
|
4256
|
+
? {
|
|
4257
|
+
repoRoot: manifest.repoRoot,
|
|
4258
|
+
ownsManifest: true,
|
|
4259
|
+
planeId: manifest.planeId,
|
|
4260
|
+
...(manifest.epicRunId ? { epicRunId: manifest.epicRunId } : {}),
|
|
4261
|
+
}
|
|
4262
|
+
: { repoRoot: deps.cwd(), ownsManifest: false };
|
|
4263
|
+
const reports = coldStart ? null : await collectFreshnessObservations(ctx, own);
|
|
4264
|
+
// BAPI-1506: a `create` preview reports host sleep as a launch need on every
|
|
4265
|
+
// target (see appendLaunchNeedRows); only `attach` keeps it here.
|
|
4266
|
+
const sleep = preview.decision === "attach" ? await collectHostSleep(ctx, own.repoRoot) : null;
|
|
4267
|
+
// Read-only: one run-state read, local plane only, and only for a bound plane.
|
|
4268
|
+
const boundRun = await boundRunForStaleRemedy(ctx, own, reports);
|
|
4269
|
+
const assessment = assessPlaneFreshness(reports, sleep, preview.decision === "create" ? "create" : "attach", ctx.options.allowStalePlane === true, boundRun);
|
|
4270
|
+
if (assessment.rows.length > 0) {
|
|
4271
|
+
preview.launch_checks = [...(preview.launch_checks ?? []), ...assessment.rows];
|
|
4272
|
+
}
|
|
4273
|
+
const refusal = freshnessRefusalOf(assessment.blockers);
|
|
4274
|
+
if (refusal !== null) {
|
|
4275
|
+
preview.decision = "refuse";
|
|
4276
|
+
preview.refusal = { reason_id: refusal.reasonId, remedy: refusal.remedy };
|
|
4277
|
+
}
|
|
4278
|
+
}
|
|
4279
|
+
catch {
|
|
4280
|
+
// Leave the preview as it was; nothing from the failure reaches the document.
|
|
4281
|
+
}
|
|
4282
|
+
}
|
|
2976
4283
|
/**
|
|
2977
4284
|
* The executor-side refusals a real launch would hit, decided read-only: the
|
|
2978
4285
|
* durable profile's coverage check, else whether an executor could be started
|
|
2979
4286
|
* from this checkout at all. Returns `null` when neither refuses, including when
|
|
2980
4287
|
* this checkout's plane already serves the live run and needs no executor.
|
|
2981
4288
|
*/
|
|
2982
|
-
async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome
|
|
4289
|
+
async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome,
|
|
4290
|
+
/**
|
|
4291
|
+
* BAPI-1452 — receives the executor topology a real launch would use.
|
|
4292
|
+
* BAPI-1476 — and the attachable plane manifest this checkout owns, if any, so
|
|
4293
|
+
* freshness compares only the selected plane's snapshot.
|
|
4294
|
+
*/
|
|
4295
|
+
topologyOut) {
|
|
2983
4296
|
const { deps, epicKey, access, profile } = ctx;
|
|
2984
4297
|
if (profile !== null && profile.executionMode === "durable") {
|
|
4298
|
+
if (topologyOut)
|
|
4299
|
+
topologyOut.value = { kind: "durable_profile" };
|
|
2985
4300
|
if (durableActorAdmissionDenied(readiness)) {
|
|
2986
4301
|
return {
|
|
2987
4302
|
reasonId: "actor-admission-denied",
|
|
@@ -2996,6 +4311,8 @@ async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome)
|
|
|
2996
4311
|
if (planeTarget) {
|
|
2997
4312
|
if (live !== null) {
|
|
2998
4313
|
const manifest = await findAttachablePlane(ctx).catch(() => null);
|
|
4314
|
+
if (topologyOut)
|
|
4315
|
+
topologyOut.manifest = manifest;
|
|
2999
4316
|
if (manifest?.epicRunId === live.epicRunId) {
|
|
3000
4317
|
const admission = await checkPlaneAdmission(ctx, readiness, manifest);
|
|
3001
4318
|
if (admission !== null) {
|
|
@@ -3005,15 +4322,140 @@ async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome)
|
|
|
3005
4322
|
if (await planeServesLiveRun(ctx, live))
|
|
3006
4323
|
return null;
|
|
3007
4324
|
}
|
|
3008
|
-
else
|
|
3009
|
-
|
|
4325
|
+
else {
|
|
4326
|
+
const attachable = await findAttachablePlane(ctx).catch(() => null);
|
|
4327
|
+
if (topologyOut)
|
|
4328
|
+
topologyOut.manifest = attachable;
|
|
4329
|
+
if (attachable !== null) {
|
|
4330
|
+
if (topologyOut)
|
|
4331
|
+
topologyOut.value = { kind: "plane_lane" };
|
|
4332
|
+
return null;
|
|
4333
|
+
}
|
|
3010
4334
|
}
|
|
3011
4335
|
}
|
|
3012
4336
|
const inspected = await deps.inspectRunExecutorPrecondition(deps.cwd());
|
|
4337
|
+
// BAPI-1452: the read-only inspection names the clone a run-scoped executor
|
|
4338
|
+
// would own; the preview never calls `prepareRunExecutor` to learn it.
|
|
4339
|
+
if (topologyOut && inspected.ok) {
|
|
4340
|
+
topologyOut.value = { kind: "local", repoRoot: inspected.repoRoot ?? null };
|
|
4341
|
+
}
|
|
3013
4342
|
return inspected.ok
|
|
3014
4343
|
? null
|
|
3015
4344
|
: executorPreconditionRefusal(epicKey, inspected.message, outcome === "No run was created" ? "No run was created." : "No run was changed.");
|
|
3016
4345
|
}
|
|
4346
|
+
/**
|
|
4347
|
+
* True for the guarded plane-down no-plan stop: a `stop` that is not a refusal,
|
|
4348
|
+
* with unknown readiness carrying the shared handoff text as its remedy.
|
|
4349
|
+
*/
|
|
4350
|
+
function isPlaneDownStop(preview) {
|
|
4351
|
+
return (preview.decision === "stop" &&
|
|
4352
|
+
preview.refusal === null &&
|
|
4353
|
+
preview.readiness.kind === "unknown" &&
|
|
4354
|
+
typeof preview.readiness.remedy === "string" &&
|
|
4355
|
+
preview.readiness.remedy !== "");
|
|
4356
|
+
}
|
|
4357
|
+
/**
|
|
4358
|
+
* The escalation-reachability row as text, directly under the `Escalation:` line.
|
|
4359
|
+
* An explicit `WARN` or `PASS` word carries the meaning (never colour), and the
|
|
4360
|
+
* explanation and fix sit on their own indented lines so a narrow terminal wraps
|
|
4361
|
+
* them beneath the label.
|
|
4362
|
+
*/
|
|
4363
|
+
function reachabilityLines(preview) {
|
|
4364
|
+
const row = preview.launch_checks?.find((entry) => entry.id === ESCALATION_REACHABILITY_CHECK_ID);
|
|
4365
|
+
if (row === undefined)
|
|
4366
|
+
return [];
|
|
4367
|
+
return [
|
|
4368
|
+
` ${row.status === "pass" ? "PASS" : "WARN"} ${ESCALATION_REACHABILITY_CHECK_ID}`,
|
|
4369
|
+
...(row.detail ? [` ${row.detail}`] : []),
|
|
4370
|
+
...(row.remedy ? [` Fix: ${row.remedy}`] : []),
|
|
4371
|
+
];
|
|
4372
|
+
}
|
|
4373
|
+
/** Operator wording for each reason a child that was checked could not be proven merged. */
|
|
4374
|
+
const UNKNOWN_REASON_TEXT = {
|
|
4375
|
+
ancestry_unknown: "its merge could not be compared with the indexed commit",
|
|
4376
|
+
binding_ambiguous: "its delivery records name more than one pull request",
|
|
4377
|
+
provider_unavailable: "the code host could not be read",
|
|
4378
|
+
binding_read_failed: "its delivery records could not be read",
|
|
4379
|
+
merge_sha_unavailable: "its merge commit could not be confirmed",
|
|
4380
|
+
deadline_exceeded: "the check ran out of time",
|
|
4381
|
+
};
|
|
4382
|
+
const NOT_CHECKED_TEXT = {
|
|
4383
|
+
disabled: "Merged check: turned off (--no-merged-check). Every child will run.",
|
|
4384
|
+
attach: "Merged check: not applied; existing runs are unchanged.",
|
|
4385
|
+
cold_start: "Merged check: will be checked at create.",
|
|
4386
|
+
cut_commit_unavailable: "Merged check: the indexed commit could not be read. Every child will run.",
|
|
4387
|
+
check_failed: "Merged check: could not complete. Every child will run.",
|
|
4388
|
+
no_plan: null,
|
|
4389
|
+
};
|
|
4390
|
+
/**
|
|
4391
|
+
* The already-merged summary for the human preview (BAPI-1510). Leads with the
|
|
4392
|
+
* children to run and skipped, then the groups in a fixed order. Meaning is carried
|
|
4393
|
+
* by words, never colour; only customer ticket keys and validated PR numbers appear.
|
|
4394
|
+
*/
|
|
4395
|
+
function alreadyDoneLines(preview) {
|
|
4396
|
+
const s = preview.summary;
|
|
4397
|
+
const state = preview.already_done_children ?? { status: "not_checked", reason: "no_plan", children: [] };
|
|
4398
|
+
const lines = [];
|
|
4399
|
+
if (preview.refusal?.reason_id === EPIC_ALREADY_COMPLETE_REASON_ID) {
|
|
4400
|
+
lines.push(" Status: no work to run — every child is already merged", ` ${preview.refusal.remedy}`);
|
|
4401
|
+
return lines;
|
|
4402
|
+
}
|
|
4403
|
+
const skipped = state.children.filter((child) => child.will_skip);
|
|
4404
|
+
if (state.status === "checked" || s.child_count !== null) {
|
|
4405
|
+
if (s.children_to_run !== null && s.children_to_run !== undefined) {
|
|
4406
|
+
lines.push(` To run: ${s.children_to_run}`);
|
|
4407
|
+
}
|
|
4408
|
+
lines.push(` Skipped: ${skipped.length}`);
|
|
4409
|
+
}
|
|
4410
|
+
if (state.status === "not_checked") {
|
|
4411
|
+
const text = NOT_CHECKED_TEXT[state.reason];
|
|
4412
|
+
if (text !== null)
|
|
4413
|
+
lines.push(` ${text}`);
|
|
4414
|
+
return lines;
|
|
4415
|
+
}
|
|
4416
|
+
const pr = (child) => child.pr_number !== null && Number.isSafeInteger(child.pr_number) && child.pr_number > 0
|
|
4417
|
+
? ` (#${child.pr_number})`
|
|
4418
|
+
: "";
|
|
4419
|
+
if (skipped.length > 0) {
|
|
4420
|
+
lines.push(" Skipped (already merged):");
|
|
4421
|
+
for (const child of skipped) {
|
|
4422
|
+
lines.push(` ${child.ticket_key}${pr(child)} — ${child.classification === "done" ? "already merged" : "confirmed skip"}`);
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4425
|
+
const probably = state.children.filter((child) => child.classification === "probably_done" && !child.will_skip);
|
|
4426
|
+
if (probably.length > 0) {
|
|
4427
|
+
lines.push(" Probably done — confirmation needed:");
|
|
4428
|
+
for (const child of probably) {
|
|
4429
|
+
lines.push(` ${child.ticket_key}${pr(child)} — on the indexed commit but has no delivery record; it will run. ` +
|
|
4430
|
+
`To skip it, pass --skip-child ${child.ticket_key}.`);
|
|
4431
|
+
}
|
|
4432
|
+
}
|
|
4433
|
+
const behind = state.children.filter((child) => child.reason === "merged_after_index");
|
|
4434
|
+
for (const child of behind) {
|
|
4435
|
+
lines.push(` ${child.ticket_key}${pr(child)} — merged after the indexed commit; it will run. ` +
|
|
4436
|
+
"Re-index the repository, or promote the branch it merged into, to skip it.");
|
|
4437
|
+
}
|
|
4438
|
+
const unknown = state.children.filter((child) => child.classification === "unknown");
|
|
4439
|
+
if (unknown.some((child) => child.reason === "ancestry_unavailable_for_provider")) {
|
|
4440
|
+
lines.push(" Merged-child detection is not supported for this repository's code host; every child will run.");
|
|
4441
|
+
}
|
|
4442
|
+
const byReason = new Map();
|
|
4443
|
+
for (const child of unknown) {
|
|
4444
|
+
if (child.reason === "ancestry_unavailable_for_provider")
|
|
4445
|
+
continue;
|
|
4446
|
+
byReason.set(child.reason, [...(byReason.get(child.reason) ?? []), child.ticket_key]);
|
|
4447
|
+
}
|
|
4448
|
+
for (const [reason, keys] of byReason) {
|
|
4449
|
+
lines.push(` Could not confirm ${keys.join(", ")}: ${UNKNOWN_REASON_TEXT[reason] ?? "the check was inconclusive"}. ` +
|
|
4450
|
+
`${keys.length === 1 ? "It" : "They"} will run.`);
|
|
4451
|
+
}
|
|
4452
|
+
if (skipped.length > 0 && s.children_to_run === 1) {
|
|
4453
|
+
lines.push(s.branch !== null
|
|
4454
|
+
? ` Strategy: one child remains and runs on ${s.branch}`
|
|
4455
|
+
: " Strategy: one child remains and runs directly on the repository base branch");
|
|
4456
|
+
}
|
|
4457
|
+
return lines;
|
|
4458
|
+
}
|
|
3017
4459
|
/**
|
|
3018
4460
|
* Print the preview: one JSON line with `--json`, short readable lines otherwise.
|
|
3019
4461
|
*
|
|
@@ -3035,8 +4477,13 @@ async function emitPreview(deps, options, preview) {
|
|
|
3035
4477
|
` Readiness: ${preview.readiness.kind}${preview.readiness.reason_id ? ` (${preview.readiness.reason_id})` : ""}`,
|
|
3036
4478
|
` Live run: ${preview.live_run
|
|
3037
4479
|
? `${preview.live_run.epic_run_id}${preview.live_run.status ? ` (${preview.live_run.status})` : ""}`
|
|
3038
|
-
: preview.cold_start ? "unchecked (plane not yet running)"
|
|
3039
|
-
|
|
4480
|
+
: preview.cold_start ? "unchecked (plane not yet running)"
|
|
4481
|
+
: isPlaneDownStop(preview) ? "unchecked (plane not running)" : "none"}`,
|
|
4482
|
+
// BAPI-1532 — a plan found beside a live run is "Found, unused": the launch
|
|
4483
|
+
// attaches and the local file is not read as approved input.
|
|
4484
|
+
` Plan: ${preview.plan.status === "found" && preview.decision === "attach"
|
|
4485
|
+
? "Found, unused"
|
|
4486
|
+
: preview.plan.status}${preview.plan.path ? ` — ${preview.plan.path}` : ""}`,
|
|
3040
4487
|
];
|
|
3041
4488
|
for (const candidate of preview.plan.candidates ?? [])
|
|
3042
4489
|
lines.push(` candidate: ${candidate}`);
|
|
@@ -3044,15 +4491,35 @@ async function emitPreview(deps, options, preview) {
|
|
|
3044
4491
|
lines.push(` skipped: ${skipped.path} (${skipped.reason})`);
|
|
3045
4492
|
}
|
|
3046
4493
|
const s = preview.summary;
|
|
3047
|
-
lines.push(` Posture: ${s.posture ?? "n/a"}`, ` Children: ${s.child_count ?? "n/a"}`, ` Branch: ${s.branch ?? "n/a"}`, ` Auto-merge: ${s.auto_merge_scope ?? "n/a"}`, ` Escalation: ${s.escalation_channel ?? "n/a"}`, ` Validation: ${preview.cold_start ? "Partial — plane is not yet running" : s.server_validation ?? "n/a"}`, ` Decision: ${preview.decision}`);
|
|
3048
|
-
if (preview
|
|
4494
|
+
lines.push(` Ticket review: ${s.ticket_review ?? "on"}`, ` Posture: ${s.posture ?? "n/a"}`, ` Children: ${s.child_count ?? "n/a"}`, ...alreadyDoneLines(preview), ` Branch: ${s.branch ?? "n/a"}`, ` Auto-merge: ${s.auto_merge_scope ?? "n/a"}`, ` Escalation: ${s.escalation_channel ?? "n/a"}`, ...reachabilityLines(preview), ` Validation: ${preview.cold_start ? "Partial — plane is not yet running" : s.server_validation ?? "n/a"}`, ` Decision: ${preview.decision}`);
|
|
4495
|
+
if (isPlaneDownStop(preview)) {
|
|
4496
|
+
// The shared stop text, verbatim and unindented, so it reads exactly as the
|
|
4497
|
+
// real launch prints it. It is a stop, not a refusal.
|
|
4498
|
+
lines.push("", preview.readiness.remedy);
|
|
4499
|
+
}
|
|
4500
|
+
else if (preview.readiness.remedy &&
|
|
3049
4501
|
preview.readiness.remedy !== preview.refusal?.remedy &&
|
|
3050
4502
|
!preview.refusal?.reason_id.startsWith("access-")) {
|
|
3051
4503
|
lines.push(` Remedy: ${preview.readiness.remedy}`);
|
|
3052
4504
|
}
|
|
3053
|
-
if (preview.refusal) {
|
|
4505
|
+
if (preview.refusal && preview.refusal.reason_id !== EPIC_ALREADY_COMPLETE_REASON_ID) {
|
|
3054
4506
|
lines.push(` Refusal: ${preview.refusal.reason_id} — ${preview.refusal.remedy}`);
|
|
3055
4507
|
}
|
|
4508
|
+
const summaryRows = (preview.launch_checks ?? []).filter(row => row.id !== ESCALATION_REACHABILITY_CHECK_ID);
|
|
4509
|
+
lines.unshift(...formatLaunchReadinessSummary(summaryRows.map(row => ({
|
|
4510
|
+
id: row.id, source: row.id.startsWith('server.') ? 'server' : 'conductor',
|
|
4511
|
+
label: row.label, status: row.status, blocking: row.blocking,
|
|
4512
|
+
...(row.detail ? { detail: row.detail } : {}),
|
|
4513
|
+
...(row.remedy ? { remediation: row.remedy } : {}),
|
|
4514
|
+
})), { all: options.all, isProduction: preview.is_production, prerequisites: preview.prerequisites }));
|
|
4515
|
+
const localRows = summaryRows.filter(row => !READINESS_PREREQUISITE_BY_ID.has(READINESS_LEGACY_IDS[row.id] ?? row.id));
|
|
4516
|
+
// BAPI-1506: launch-need rows get their own heading; they are not plane freshness.
|
|
4517
|
+
const freshnessRows = localRows.filter(row => row.launch_need === undefined);
|
|
4518
|
+
const launchNeedRows = localRows.filter(row => row.launch_need !== undefined);
|
|
4519
|
+
if (freshnessRows.length)
|
|
4520
|
+
lines.push(...renderFreshnessRows(freshnessRows));
|
|
4521
|
+
if (launchNeedRows.length)
|
|
4522
|
+
lines.push(...renderFreshnessRows(launchNeedRows, 'Launch needs (advisory; never blocks):'));
|
|
3056
4523
|
deps.log(lines.join("\n"));
|
|
3057
4524
|
return true;
|
|
3058
4525
|
}
|