@bridge_gpt/mcp-server 0.2.53 → 0.2.55
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 +116 -56
- package/build/agent-guidance.generated.js +3 -0
- package/build/agent-launchers/claude-executor-adapter.js +3 -0
- package/build/agent-launchers/claude.js +3 -3
- package/build/agent-launchers/prompt.js +8 -11
- package/build/agent-notes.js +178 -0
- package/build/agent-registry.js +5 -2
- package/build/agent-utils.js +58 -0
- package/build/agents.generated.js +1 -1
- package/build/base-ref.js +33 -9
- package/build/bounded-wait.js +174 -0
- package/build/codex-skill-adapter.js +55 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +235 -14
- package/build/conductor/bring-up-facts.js +187 -0
- package/build/conductor/claude-hook.js +7 -5
- package/build/conductor/cli.js +28 -0
- package/build/conductor/doctor.js +80 -9
- package/build/conductor/epic-implementer-cli.js +1298 -0
- package/build/conductor/epic-runtime.js +134 -98
- package/build/conductor/errors.js +2 -2
- package/build/conductor/git-ci-types.js +1 -1
- package/build/conductor/git-hooks.js +28 -14
- package/build/conductor/install-doctor.js +11 -5
- package/build/conductor/readiness-cli.js +10 -10
- package/build/conductor/readiness-sections.js +58 -9
- package/build/conductor/readiness.js +120 -4
- package/build/conductor/recovery-cli.js +289 -10
- package/build/conductor/recovery-operations.js +125 -2
- package/build/conductor/repair-contract.js +58 -0
- package/build/conductor/run-branch.js +137 -0
- package/build/conductor/store.js +2 -2
- package/build/conductor/supervisor-runtime.js +1 -1
- package/build/conductor/test-run-branch-vectors.js +165 -0
- package/build/conductor-bin.js +2 -139
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
- package/build/conductor-removed-stub-bin.js +30 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +145 -18
- package/build/drive-epic.js +752 -90
- package/build/epic-implementer-bin.js +145 -0
- package/build/epic-implementer-bundle-cli.js +264 -0
- package/build/epic-implementer-claude-hook-bin.js +3 -0
- package/build/epic-integration-pr.js +5 -3
- package/build/executor/claim-scope.js +104 -0
- package/build/executor/cli.js +14 -25
- package/build/executor/env-file-guard.js +82 -3
- package/build/executor/env.js +6 -0
- package/build/executor/interrupted-worktree.js +60 -0
- package/build/executor/job-errors.js +45 -0
- package/build/executor/job-runner.js +334 -9
- package/build/executor/job-types.js +25 -9
- package/build/executor/merge-tree-classifier.js +171 -0
- package/build/executor/reconcile-overlap-governance.js +129 -0
- package/build/executor/reconcile-overlap-job.js +989 -0
- package/build/executor/reconcile-overlap-types.js +14 -0
- package/build/executor/spawn-job-driver.js +1 -0
- package/build/executor/types.js +2 -0
- package/build/executor/worker-finalization.js +25 -2
- package/build/executor/worker-guard-hook.js +15 -7
- package/build/implement-epic/bridge-client.js +773 -0
- package/build/implement-epic/checkpoint-store.js +542 -0
- package/build/implement-epic/cli.js +3158 -0
- package/build/implement-epic/cut-protocol.js +392 -0
- package/build/implement-epic/lock.js +302 -0
- package/build/implement-epic/pr-state.js +286 -0
- package/build/implement-epic/spawn.js +113 -0
- package/build/index.js +657 -481
- package/build/init.js +72 -8
- package/build/install-bridge-conductor.js +5 -5
- package/build/install-bridge.js +403 -70
- package/build/local-artifact-storage.js +130 -0
- package/build/mcp-host-config.js +22 -60
- package/build/mcp-host-entry-adapter.js +18 -0
- package/build/mcp-host-targets.js +1 -21
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +23 -16
- package/build/plan-epic-conductor-eligibility.js +1 -1
- package/build/plane/cli.js +321 -41
- package/build/plane/manifest.js +209 -1
- package/build/plane/member-roster.js +70 -0
- package/build/plane/preflight.js +128 -12
- package/build/plane/shutdown.js +18 -5
- package/build/plane/status.js +35 -1
- package/build/plane/supervisor.js +546 -164
- package/build/plane/types.js +25 -2
- package/build/polling-policy.js +72 -0
- package/build/readiness-check.js +3 -3
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +219 -0
- package/build/run-unit-tests-launcher.js +6 -1
- package/build/setup-epic.js +567 -38
- package/build/start-tickets-conductor.js +8 -7
- package/build/ticket-key-utils.js +4 -3
- package/build/ticket-review-artifact-gate.js +461 -0
- package/build/upgrade-cli.js +5 -26
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +1 -1
- package/docs/CONDUCTOR.md +8 -6
- package/docs/install/mcp-tool-integrations.md +23 -1
- package/package.json +5 -3
- package/pipelines/review-ticket.json +17 -4
package/build/drive-epic.js
CHANGED
|
@@ -34,6 +34,11 @@
|
|
|
34
34
|
* response means we do not know which authority owns this epic, and guessing is
|
|
35
35
|
* the failure mode.
|
|
36
36
|
*
|
|
37
|
+
* One narrow exception (BAPI-1153): with `--plan-file`, when `BAPI_BASE_URL`
|
|
38
|
+
* names the local plane's own endpoint and a TCP probe of that endpoint is
|
|
39
|
+
* actively REFUSED, the failed read is not unknown — it is "no server yet", and
|
|
40
|
+
* drive-epic cold-starts the plane. Every other failed read still escalates.
|
|
41
|
+
*
|
|
37
42
|
* ## Consolidation day
|
|
38
43
|
*
|
|
39
44
|
* Two conductors is a transitional state; one is to be eliminated at the end of
|
|
@@ -53,15 +58,21 @@
|
|
|
53
58
|
*/
|
|
54
59
|
import { readFile } from "node:fs/promises";
|
|
55
60
|
import { dirname, join } from "node:path";
|
|
56
|
-
import { resolveConductorBridgeApiAccess, fetchConductorReadiness, safeDiagnosticMessage, } from "./conductor/bridge-api-client.js";
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
61
|
+
import { ConductorBridgeApiError, resolveConductorBridgeApiAccess, fetchConductorReadiness, fetchEpicRunState, safeDiagnosticMessage, } from "./conductor/bridge-api-client.js";
|
|
62
|
+
import { BRING_UP_REFUSAL_MESSAGES, BRING_UP_RUNBOOK, firstBringUpRefusal, } from "./conductor/bring-up-facts.js";
|
|
63
|
+
import { runSetupEpicWorkflow, } from "./setup-epic.js";
|
|
64
|
+
import { formatPlaneDiagnostic, launchPlaneControlPlane, requestPlaneScopedLanes, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
|
|
65
|
+
import { PLANE_PORT_PROBE_TIMEOUT_MS, describePlanePortOccupant, } from "./plane/preflight.js";
|
|
66
|
+
import { createPlaneFsDeps, probeAutomationHealth, probeTcpPort, resolveHostname, } from "./plane/defaults.js";
|
|
67
|
+
import { readPlaneManifest } from "./plane/manifest.js";
|
|
68
|
+
import { resolvePlaneServerEndpoint, } from "./plane/member-roster.js";
|
|
69
|
+
import { resolveMemberInstanceId } from "./plane/supervisor.js";
|
|
70
|
+
import { PLANE_SERVER_PORT_ENV_VAR, } from "./plane/types.js";
|
|
60
71
|
import { validateBranchName } from "./base-ref.js";
|
|
61
72
|
// BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
|
|
62
73
|
// the printed invocation interpolates it rather than repeating it.
|
|
63
74
|
import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
|
|
64
|
-
/** Jira epic key shape. Matches `
|
|
75
|
+
/** Jira epic key shape. Matches `implement-epic`'s positional rule exactly. */
|
|
65
76
|
const EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
|
|
66
77
|
/** The two conductor paths. Derived types below come from this constant. */
|
|
67
78
|
export const DRIVE_EPIC_CONDUCTORS = ["v2", "pilot"];
|
|
@@ -110,15 +121,66 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
110
121
|
describe: "an executor provisioned and reporting ready",
|
|
111
122
|
satisfied: (r) => r.executor.liveness_readable && r.executor.ready === true,
|
|
112
123
|
},
|
|
124
|
+
// BAPI-1102 — the three unattended prerequisites, in the order `setup-epic`
|
|
125
|
+
// refuses them, so readiness and the refusal name the same first blocker.
|
|
126
|
+
//
|
|
127
|
+
// Each reads the SERVER's `unattended` block and re-derives nothing. A server
|
|
128
|
+
// that does not report the block (older than BAPI-1102) leaves every one of
|
|
129
|
+
// them UNSATISFIED: "could not be read" is not "holds", and these authorize an
|
|
130
|
+
// unattended merge.
|
|
131
|
+
//
|
|
132
|
+
// The FIRST of them therefore carries the older-server remedy too, because it
|
|
133
|
+
// is the one an unreported block always blocks on, and telling that operator
|
|
134
|
+
// to set a consent flag would send them to fix something that may already be
|
|
135
|
+
// set — the server simply never said (BAPI-1102 review).
|
|
136
|
+
{
|
|
137
|
+
id: "unattended_consent",
|
|
138
|
+
describe: "repository consent to unattended conductor runs",
|
|
139
|
+
unattendedOnly: true,
|
|
140
|
+
// No fixed `remedy`: this requirement's action always depends on WHY.
|
|
141
|
+
remedyFor: (r) => r.unattended === null || r.unattended === undefined
|
|
142
|
+
? "This Bridge API deploy does not report a repository's unattended " +
|
|
143
|
+
"prerequisites at all, so none of them could be confirmed. Upgrade the " +
|
|
144
|
+
"deploy, or pass --attended."
|
|
145
|
+
: "Set `unattended_epic_implementer_allowed` for this repository " +
|
|
146
|
+
"(docs/claude/account-settings-operator-runbook.md), or pass --attended.",
|
|
147
|
+
satisfied: (r) => r.unattended?.conductor_allowed === true,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: "unattended_notify_default",
|
|
151
|
+
describe: "a verified notify webhook default for escalation",
|
|
152
|
+
unattendedOnly: true,
|
|
153
|
+
remedy: "Supply a --policy-file declaring notify.webhook_url, or pass --attended. " +
|
|
154
|
+
"notify.local_sink does not satisfy this.",
|
|
155
|
+
satisfied: (r) => r.unattended?.notify_webhook_default_declared === true,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: "repository_readiness",
|
|
159
|
+
describe: "confirmed review and conductor-CI workflows on the default branch",
|
|
160
|
+
unattendedOnly: true,
|
|
161
|
+
// S5 seam: `install-bridge conductor` is the NESTED install-time surface
|
|
162
|
+
// (BAPI-679), a different command from the top-level `conductor` ->
|
|
163
|
+
// `epic-implementer` rename in S4/BAPI-1080. It is intentionally outside
|
|
164
|
+
// S4 and every remedy string below that names it is left unchanged.
|
|
165
|
+
remedy: "Run `install-bridge conductor` to install them, or pass --attended.",
|
|
166
|
+
satisfied: (r) => r.unattended?.repository_readiness_confirmed === true,
|
|
167
|
+
},
|
|
113
168
|
];
|
|
114
169
|
/**
|
|
115
170
|
* The readiness facts a `plane up` can actually repair (BAPI-1054).
|
|
116
171
|
*
|
|
117
|
-
* Both are liveness of a process this machine starts.
|
|
118
|
-
* `supervisor_setup`, `supervisor_config`, `github_credentials
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
172
|
+
* Both are liveness of a process this machine starts. Everything else —
|
|
173
|
+
* `supervisor_setup`, `supervisor_config`, `github_credentials`, and (BAPI-1102)
|
|
174
|
+
* `unattended_consent`, `unattended_notify_default`, `repository_readiness` — is
|
|
175
|
+
* stored server-side state, credentials, or repository configuration, and no
|
|
176
|
+
* amount of starting processes creates any of it. Naming the repairable set
|
|
177
|
+
* here, once, is what keeps the composition from ever attempting a bring-up that
|
|
178
|
+
* could not have helped.
|
|
179
|
+
*
|
|
180
|
+
* This list must NOT grow for the BAPI-1102 prerequisites. A repository that has
|
|
181
|
+
* not consented is `operator_blocked`, and starting a plane for it would leave a
|
|
182
|
+
* running plane behind and change nothing — which is the precise failure the
|
|
183
|
+
* classification exists to prevent.
|
|
122
184
|
*/
|
|
123
185
|
export const V2_RUNTIME_READINESS_IDS = ["reconciler_live", "executor_live"];
|
|
124
186
|
/**
|
|
@@ -133,13 +195,51 @@ export const V2_RUNTIME_READINESS_IDS = ["reconciler_live", "executor_live"];
|
|
|
133
195
|
* three competing actions has to work out which to do first, and the order here
|
|
134
196
|
* already encodes that (setup, then config, then credentials).
|
|
135
197
|
*/
|
|
136
|
-
export function classifyV2Readiness(readiness) {
|
|
137
|
-
|
|
198
|
+
export function classifyV2Readiness(readiness, options = {}) {
|
|
199
|
+
// BAPI-1102 — the explicit `--attended` opt-out makes the unattended-only
|
|
200
|
+
// requirements INAPPLICABLE, not merely tolerated. They authorize unattended
|
|
201
|
+
// operation and automatic merge behavior; an attended run asks for neither, so
|
|
202
|
+
// refusing it for a missing consent flag would block a configuration that is
|
|
203
|
+
// entirely safe (clar-004).
|
|
204
|
+
// BAPI-1102 — the escalation channel is satisfiable per RUN as well as per
|
|
205
|
+
// repository, and no column supplies a repository default today. A
|
|
206
|
+
// `--policy-file` may therefore declare `notify.webhook_url` and satisfy the
|
|
207
|
+
// prerequisite outright, which this layer cannot see: the file stays opaque
|
|
208
|
+
// here and is composed and read by `setup-epic`. Refusing on the repository
|
|
209
|
+
// field alone would refuse every run supplying its own channel — the very form
|
|
210
|
+
// the remedy names — so the requirement is DEFERRED to the composing step,
|
|
211
|
+
// which reads the policy and refuses if it declares none. Nothing is weakened:
|
|
212
|
+
// `setup-epic` gates before the create, and the server re-checks the predicate
|
|
213
|
+
// at approval and probes the URL there.
|
|
214
|
+
const deferred = options.hasPolicyFile
|
|
215
|
+
? V2_READINESS_REQUIREMENTS.filter((req) => req.id !== "unattended_notify_default")
|
|
216
|
+
: V2_READINESS_REQUIREMENTS;
|
|
217
|
+
// `--into-base` is the second opt-out, and it is structural rather than
|
|
218
|
+
// declared: it selects the branch-silent composition, whose policy carries no
|
|
219
|
+
// posture and no auto-merge authorization at all. Such a run is not unattended
|
|
220
|
+
// and never becomes so, so holding it to prerequisites that authorize
|
|
221
|
+
// unattended operation would block a configuration these checks have nothing
|
|
222
|
+
// to say about — the same reasoning `--attended` rests on, reached by a
|
|
223
|
+
// different route. See `composeDefaultSetupEpicPolicy`.
|
|
224
|
+
const optedOut = options.attended === true || options.intoBase === true;
|
|
225
|
+
const applicable = optedOut
|
|
226
|
+
? deferred.filter((req) => req.unattendedOnly !== true)
|
|
227
|
+
: deferred;
|
|
228
|
+
const unmet = applicable.filter((req) => !req.satisfied(readiness));
|
|
138
229
|
if (unmet.length === 0)
|
|
139
230
|
return { kind: "green" };
|
|
140
231
|
const blocker = unmet.find((req) => !V2_RUNTIME_READINESS_IDS.includes(req.id));
|
|
141
|
-
if (blocker)
|
|
142
|
-
return {
|
|
232
|
+
if (blocker) {
|
|
233
|
+
return {
|
|
234
|
+
kind: "operator_blocked",
|
|
235
|
+
id: blocker.id,
|
|
236
|
+
describe: blocker.describe,
|
|
237
|
+
...(() => {
|
|
238
|
+
const remedy = blocker.remedyFor?.(readiness) ?? blocker.remedy;
|
|
239
|
+
return remedy ? { remedy } : {};
|
|
240
|
+
})(),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
143
243
|
return { kind: "runtime_only", unmetIds: unmet.map((req) => req.id) };
|
|
144
244
|
}
|
|
145
245
|
export function getDriveEpicUsage() {
|
|
@@ -159,20 +259,40 @@ export function getDriveEpicUsage() {
|
|
|
159
259
|
"strategy itself is decided by the bootstrap this command delegates to, never",
|
|
160
260
|
"here.",
|
|
161
261
|
"",
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
"
|
|
166
|
-
"
|
|
167
|
-
"that
|
|
262
|
+
"Posture (v2, BAPI-1102): a run that selects an epic branch is created",
|
|
263
|
+
"UNATTENDED by default — it authorizes automatic merge into the epic branch, and",
|
|
264
|
+
"the server stamps its CI gate at first approval. The INTEGRATION PR into the",
|
|
265
|
+
"repository base branch stays human-gated. Pass --attended to opt out. An",
|
|
266
|
+
"unattended run has two one-time repository prerequisites (consent, and a",
|
|
267
|
+
"verified notify webhook default) that readiness lists with their state and that",
|
|
268
|
+
"are refused BY NAME before any run is created.",
|
|
168
269
|
"",
|
|
169
|
-
"
|
|
170
|
-
"
|
|
171
|
-
"
|
|
172
|
-
|
|
270
|
+
"One command (BAPI-1054, reordered by BAPI-1102): with --plan-file, drive-epic",
|
|
271
|
+
"starts the conductor runtime for you when readiness is missing ONLY the",
|
|
272
|
+
"reconciler/executor facts. It brings up the CONTROL PLANE first (server and",
|
|
273
|
+
"reconciler), creates and approves the run, then starts executor lanes SCOPED to",
|
|
274
|
+
"that run, and starts the dead-man observer last — one invocation, no manual",
|
|
275
|
+
"`plane up` first. A report missing supervisor setup, supervisor configuration,",
|
|
276
|
+
"GitHub App credentials, or an unattended prerequisite is NOT started: those are",
|
|
277
|
+
"operator-owned, and drive-epic exits with that one named reason instead.",
|
|
278
|
+
"",
|
|
279
|
+
"The plane port (BAPI-1153): with --plan-file and BAPI_BASE_URL naming the",
|
|
280
|
+
`local plane endpoint (127.0.0.1, port ${PLANE_SERVER_PORT_ENV_VAR} or the default),`,
|
|
281
|
+
"drive-epic handles the port one of three ways, and never asks you to choose:",
|
|
282
|
+
" - cold start: nothing listens there — it brings the plane up, then creates",
|
|
283
|
+
" the run;",
|
|
284
|
+
" - attach: this repository's own healthy plane is there — it creates the run",
|
|
285
|
+
" against that plane and starts NO second server or reconciler;",
|
|
286
|
+
" - refusal: anything else holds the port (another listener, a manifest for a",
|
|
287
|
+
" different repository, an unreadable manifest, a plane that cannot take a",
|
|
288
|
+
" run) — one named reason, and nothing is started.",
|
|
289
|
+
"One epic per plane: a plane already bound to a different or finished run is",
|
|
290
|
+
"refused as plane-already-bound. Executor lanes are always scoped to the one",
|
|
291
|
+
"run with --epic-run-id, on every path.",
|
|
173
292
|
"",
|
|
174
|
-
"
|
|
175
|
-
"
|
|
293
|
+
"Preconditions are validated, never repaired: the current directory must be a Git",
|
|
294
|
+
"work tree. No worktree is created for you and no replacement port is chosen for",
|
|
295
|
+
"you — each failure names the fix. Fix it and rerun.",
|
|
176
296
|
"",
|
|
177
297
|
"Options:",
|
|
178
298
|
" --plan-file <path> Plan DAG sidecar. When supplied and the v2 path is",
|
|
@@ -185,6 +305,9 @@ export function getDriveEpicUsage() {
|
|
|
185
305
|
" repository base branch (the pre-BAPI-1009 behavior).",
|
|
186
306
|
" Contradictory with --feature-branch; passing both is a",
|
|
187
307
|
" parse error.",
|
|
308
|
+
" --attended OPT OUT of the unattended default. Forwarded verbatim to",
|
|
309
|
+
" the bootstrap, which owns what it composes; drive-epic",
|
|
310
|
+
" reads it only to skip the unattended prerequisites.",
|
|
188
311
|
"",
|
|
189
312
|
"Policy (forwarded verbatim; drive-epic interprets none of it):",
|
|
190
313
|
" --policy-file <path> JSON file holding the COMPLETE run policy.",
|
|
@@ -230,12 +353,20 @@ export function parseDriveEpicArgs(argv) {
|
|
|
230
353
|
featureBranch = trimmed;
|
|
231
354
|
return trimmed;
|
|
232
355
|
};
|
|
356
|
+
let attended = false;
|
|
233
357
|
for (let i = 0; i < argv.length; i++) {
|
|
234
358
|
const arg = argv[i];
|
|
235
359
|
if (arg === "--into-base") {
|
|
236
360
|
intoBase = true;
|
|
237
361
|
continue;
|
|
238
362
|
}
|
|
363
|
+
// BAPI-1102 — valueless, forwarded verbatim, and interpreted here for
|
|
364
|
+
// exactly one thing: making the unattended readiness prerequisites
|
|
365
|
+
// inapplicable. drive-epic reads no policy.
|
|
366
|
+
if (arg === "--attended") {
|
|
367
|
+
attended = true;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
239
370
|
if (arg === "--replace-policy") {
|
|
240
371
|
replacePolicy = true;
|
|
241
372
|
continue;
|
|
@@ -330,6 +461,8 @@ export function parseDriveEpicArgs(argv) {
|
|
|
330
461
|
...(repo ? { repo } : {}),
|
|
331
462
|
...(featureBranch ? { featureBranch } : {}),
|
|
332
463
|
...(intoBase ? { intoBase: true } : {}),
|
|
464
|
+
// Added only when supplied, so the no-flag options object stays byte-identical.
|
|
465
|
+
...(attended ? { attended: true } : {}),
|
|
333
466
|
...(policyFile ? { policyFile } : {}),
|
|
334
467
|
...(reviewPolicy ? { reviewPolicy } : {}),
|
|
335
468
|
...(replacePolicy ? { replacePolicy: true } : {}),
|
|
@@ -343,9 +476,23 @@ export function parseDriveEpicArgs(argv) {
|
|
|
343
476
|
* decision. The unknown case is produced by the caller, which is the only place
|
|
344
477
|
* that can observe a failed read — selection itself never happens by exception
|
|
345
478
|
* handling, and so never needs to report that it could not decide.
|
|
479
|
+
*
|
|
480
|
+
* BAPI-1102 — the `unattendedOnly` requirements are EXCLUDED from routing, and
|
|
481
|
+
* the exclusion is load-bearing rather than tidy. This function answers "can the
|
|
482
|
+
* v2 engine drive an epic at all?", and the answer does not depend on whether
|
|
483
|
+
* the repository has consented to UNATTENDED operation: an attended v2 run on a
|
|
484
|
+
* non-consenting repository is a perfectly ordinary, fully supported run.
|
|
485
|
+
* Including them would route every such repository to the pilot conductor the
|
|
486
|
+
* moment this ticket deployed — a silent, repository-wide change of conductor for
|
|
487
|
+
* a reason that has nothing to do with the choice, and the exact opposite of
|
|
488
|
+
* AC-3's "nothing changes for an existing run".
|
|
489
|
+
*
|
|
490
|
+
* They still gate the COMPOSED bring-up, through
|
|
491
|
+
* {@link classifyV2Readiness}, which is where an unattended run is actually
|
|
492
|
+
* about to be created.
|
|
346
493
|
*/
|
|
347
494
|
export function selectConductor(readiness) {
|
|
348
|
-
const missing = V2_READINESS_REQUIREMENTS.filter((req) => !req.satisfied(readiness));
|
|
495
|
+
const missing = V2_READINESS_REQUIREMENTS.filter((req) => req.unattendedOnly !== true && !req.satisfied(readiness));
|
|
349
496
|
if (missing.length === 0) {
|
|
350
497
|
return {
|
|
351
498
|
kind: "selected",
|
|
@@ -372,7 +519,7 @@ export function selectConductor(readiness) {
|
|
|
372
519
|
* no quoting to get wrong.
|
|
373
520
|
*/
|
|
374
521
|
export function buildSetupEpicArgv(options) {
|
|
375
|
-
const { epicKey, planFile, repo, featureBranch, intoBase, policyFile, reviewPolicy, replacePolicy } = options;
|
|
522
|
+
const { epicKey, planFile, repo, featureBranch, intoBase, attended, policyFile, reviewPolicy, replacePolicy, } = options;
|
|
376
523
|
return [
|
|
377
524
|
"--epic-key",
|
|
378
525
|
epicKey,
|
|
@@ -380,6 +527,9 @@ export function buildSetupEpicArgv(options) {
|
|
|
380
527
|
...(repo ? ["--repo", repo] : []),
|
|
381
528
|
...(featureBranch ? ["--feature-branch", featureBranch] : []),
|
|
382
529
|
...(intoBase ? ["--into-base"] : []),
|
|
530
|
+
// Appended beside the other branch/posture pass-throughs, and after
|
|
531
|
+
// `--into-base` so the forwarded order matches the declaration order above.
|
|
532
|
+
...(attended ? ["--attended"] : []),
|
|
383
533
|
...(policyFile ? ["--policy-file", policyFile] : []),
|
|
384
534
|
...(reviewPolicy ? ["--review-policy", reviewPolicy] : []),
|
|
385
535
|
...(replacePolicy ? ["--replace-policy"] : []),
|
|
@@ -412,7 +562,7 @@ export function renderConductorHandoff(selection, epicKey) {
|
|
|
412
562
|
"",
|
|
413
563
|
` 1. Run /plan-epic ${epicKey} in an interactive session. It writes the`,
|
|
414
564
|
" sidecar to {docs_dir}/epic-plans/{epic_slug}/epic-plan.dag.json.",
|
|
415
|
-
" If the epic's tickets already exist, run emit-
|
|
565
|
+
" If the epic's tickets already exist, run emit-epic-implementer-bundle",
|
|
416
566
|
" finalize instead — it resolves the placeholder keys in an existing",
|
|
417
567
|
" sidecar and attaches touched_files.",
|
|
418
568
|
"",
|
|
@@ -581,26 +731,134 @@ export const DRIVE_EPIC_PRECONDITION_CHECKS = [
|
|
|
581
731
|
* preflight happened to append them, so the reason an operator sees for a given
|
|
582
732
|
* pair of failures is stable.
|
|
583
733
|
*/
|
|
584
|
-
export function firstPreconditionRefusal(preflight) {
|
|
734
|
+
export function firstPreconditionRefusal(preflight, options = {}) {
|
|
585
735
|
for (const check of DRIVE_EPIC_PRECONDITION_CHECKS) {
|
|
736
|
+
// BAPI-1153: an occupied port is not a blocker when the occupant is this
|
|
737
|
+
// repository's own attachable plane — attaching is exactly how the port gets
|
|
738
|
+
// used. Every other occupant still blocks, and `plane up`'s own diagnostic is
|
|
739
|
+
// unchanged (a live plane still refuses a second `plane up`).
|
|
740
|
+
if (check === "server-port" && options.attachable === true)
|
|
741
|
+
continue;
|
|
586
742
|
const found = preflight.diagnostics.find((d) => d.check === check && d.severity === "blocking");
|
|
587
743
|
if (found)
|
|
588
744
|
return found;
|
|
589
745
|
}
|
|
590
746
|
return null;
|
|
591
747
|
}
|
|
748
|
+
// ---------------------------------------------------------------------------
|
|
749
|
+
// BAPI-1153 — cold start, attach, or one named refusal
|
|
750
|
+
// ---------------------------------------------------------------------------
|
|
751
|
+
/**
|
|
752
|
+
* The refusals drive-epic itself names on the cold-start and attach paths.
|
|
753
|
+
*
|
|
754
|
+
* The bring-up facts refuse under their OWN names (`second-reconciler-detected`
|
|
755
|
+
* and the rest, from `conductor/bring-up-facts.ts`), and an occupied port keeps
|
|
756
|
+
* the existing `server-port` refusal; these two are the only reasons that exist
|
|
757
|
+
* nowhere else.
|
|
758
|
+
*/
|
|
759
|
+
export const DRIVE_EPIC_PLANE_REFUSALS = [
|
|
760
|
+
/** The plane is bound to a run that is not a live run of this epic (R-7). */
|
|
761
|
+
"plane-already-bound",
|
|
762
|
+
/** The plane's own reconciler is not confirmed as the fresh one. */
|
|
763
|
+
"plane-reconciler-unconfirmed",
|
|
764
|
+
];
|
|
765
|
+
/** Loopback spellings that name the plane's `127.0.0.1` host. */
|
|
766
|
+
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
|
|
767
|
+
/**
|
|
768
|
+
* Does `baseUrl` name the plane's own loopback endpoint? Pure.
|
|
769
|
+
*
|
|
770
|
+
* Cold start and attach are decisions about THE PLANE, so they only make sense
|
|
771
|
+
* when the Bridge API the command talks to IS the plane's server. The conductor
|
|
772
|
+
* default base URL is production; a readiness failure against it says nothing
|
|
773
|
+
* about a local port, and must keep escalating exactly as before. Loopback
|
|
774
|
+
* spellings are equivalent (`localhost` and `127.0.0.1` reach the same socket);
|
|
775
|
+
* the port must match exactly.
|
|
776
|
+
*/
|
|
777
|
+
export function baseUrlNamesPlaneEndpoint(baseUrl, endpoint) {
|
|
778
|
+
let url;
|
|
779
|
+
try {
|
|
780
|
+
url = new URL(baseUrl);
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
if (url.protocol !== "http:")
|
|
786
|
+
return false;
|
|
787
|
+
const port = url.port === "" ? 80 : Number(url.port);
|
|
788
|
+
return LOOPBACK_HOSTNAMES.has(url.hostname) && port === endpoint.port;
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Rebuild the identity the plane's reconciler publishes its heartbeat under. Pure.
|
|
792
|
+
*
|
|
793
|
+
* The reconciler is the manifest's `worker` member; its identity is
|
|
794
|
+
* `<hostname>:<pid>` (or `DYNO`), rebuilt by the supervisor's own
|
|
795
|
+
* `resolveMemberInstanceId` so the string cannot drift from the one the
|
|
796
|
+
* readiness gate used at bring-up. `undefined` when the manifest records no live
|
|
797
|
+
* worker pid — an unidentifiable member is never guessed at.
|
|
798
|
+
*/
|
|
799
|
+
export function resolvePlaneReconcilerInstanceId(manifest, env, hostname) {
|
|
800
|
+
const worker = manifest.members.find((member) => member.name === "worker");
|
|
801
|
+
if (worker === undefined || worker.pid === null)
|
|
802
|
+
return undefined;
|
|
803
|
+
const memberEnv = {};
|
|
804
|
+
if (typeof env.DYNO === "string")
|
|
805
|
+
memberEnv.DYNO = env.DYNO;
|
|
806
|
+
return resolveMemberInstanceId(memberEnv, worker.pid, hostname);
|
|
807
|
+
}
|
|
808
|
+
/** Terminal `epic_runs.status` values; every other status is a live run. */
|
|
809
|
+
const TERMINAL_EPIC_RUN_STATUSES = ["abandoned", "done"];
|
|
592
810
|
export function createDefaultDriveEpicDeps() {
|
|
593
811
|
return {
|
|
594
|
-
resolveAccess: async (repo) => {
|
|
595
|
-
const result = await resolveConductorBridgeApiAccess(
|
|
812
|
+
resolveAccess: async (repo, cwd) => {
|
|
813
|
+
const result = await resolveConductorBridgeApiAccess({
|
|
814
|
+
...(repo ? { repoName: repo } : {}),
|
|
815
|
+
...(cwd ? { cwd } : {}),
|
|
816
|
+
});
|
|
596
817
|
return result.ok ? { ok: true, access: result.access } : { ok: false, error: result.error };
|
|
597
818
|
},
|
|
598
819
|
readReadiness: (access) => fetchConductorReadiness(access, globalThis.fetch),
|
|
599
|
-
runSetupEpic: (argv) =>
|
|
820
|
+
runSetupEpic: (argv) => runSetupEpicWorkflow(argv),
|
|
600
821
|
readEligibilityArtifact: (path) => readFile(path, "utf8"),
|
|
601
822
|
runPlanePreflight: (repoRoot) => runDefaultPlanePreflight(repoRoot),
|
|
602
823
|
runPlane: (argv, overrides) => runPlaneCli(argv, overrides ?? {}),
|
|
824
|
+
launchControlPlane: ({ preflight, executors }) => launchPlaneControlPlane({
|
|
825
|
+
preflight,
|
|
826
|
+
executors,
|
|
827
|
+
// stdout/stderr, not the injected log seams: the plane streams its own
|
|
828
|
+
// member events through these for the plane's whole lifetime, and
|
|
829
|
+
// drive-epic's `log` is a per-message reporter, not a stream sink.
|
|
830
|
+
sinks: {
|
|
831
|
+
stdout: (line) => console.log(line),
|
|
832
|
+
stderr: (line) => console.error(line),
|
|
833
|
+
},
|
|
834
|
+
env: process.env,
|
|
835
|
+
}),
|
|
836
|
+
requestScopedLanes: (args) => requestPlaneScopedLanes(args),
|
|
603
837
|
cwd: () => process.cwd(),
|
|
838
|
+
resolvePlaneEndpoint: () => resolvePlaneServerEndpoint(process.env),
|
|
839
|
+
probePlanePort: (host, port) => probeTcpPort(host, port, PLANE_PORT_PROBE_TIMEOUT_MS),
|
|
840
|
+
readRunState: async (access, epicRunId) => {
|
|
841
|
+
const state = await fetchEpicRunState(access, epicRunId, globalThis.fetch);
|
|
842
|
+
const run = state.epic_run;
|
|
843
|
+
if (typeof run?.status !== "string" || typeof run?.epic_key !== "string") {
|
|
844
|
+
throw new ConductorBridgeApiError("invalid-input");
|
|
845
|
+
}
|
|
846
|
+
return { status: run.status, epicKey: run.epic_key };
|
|
847
|
+
},
|
|
848
|
+
readPlaneReconcilerState: async ({ access, manifest }) => {
|
|
849
|
+
const instanceId = resolvePlaneReconcilerInstanceId(manifest, process.env, resolveHostname);
|
|
850
|
+
if (instanceId === undefined)
|
|
851
|
+
return "unidentifiable";
|
|
852
|
+
const observation = await probeAutomationHealth({
|
|
853
|
+
baseUrl: access.baseUrl,
|
|
854
|
+
repoName: access.repoName,
|
|
855
|
+
apiKey: access.apiKey,
|
|
856
|
+
component: "reconciler",
|
|
857
|
+
instanceId,
|
|
858
|
+
});
|
|
859
|
+
return observation.result.kind === "state" ? observation.result.state : observation.result.kind;
|
|
860
|
+
},
|
|
861
|
+
readPlaneManifest: (repoRoot) => readPlaneManifest(repoRoot, createPlaneFsDeps()),
|
|
604
862
|
// stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
|
|
605
863
|
// construction, so nothing has claimed stdout for the protocol transport.
|
|
606
864
|
log: (message) => console.log(message),
|
|
@@ -633,12 +891,32 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
633
891
|
if (!accessResult.ok) {
|
|
634
892
|
return escalate(deps, epicKey, `conductor readiness could not be read: ${accessResult.error}`);
|
|
635
893
|
}
|
|
636
|
-
// --- Read readiness. Any failure here is UNKNOWN.
|
|
894
|
+
// --- Read readiness. Any failure here is UNKNOWN — with ONE exception. ---
|
|
637
895
|
let readiness;
|
|
638
896
|
try {
|
|
639
897
|
readiness = await deps.readReadiness(accessResult.access);
|
|
640
898
|
}
|
|
641
899
|
catch (err) {
|
|
900
|
+
// BAPI-1153 — the cold-start case. With a plan file, a readiness read that
|
|
901
|
+
// failed at the TRANSPORT against the plane's own endpoint, on a port that
|
|
902
|
+
// actively refuses connections, is not "unknown": it is "no server yet".
|
|
903
|
+
// Everything else — a non-plane base URL, an HTTP or auth or shape error, a
|
|
904
|
+
// timeout, any other probe result — keeps today's escalation byte for byte,
|
|
905
|
+
// because there unknown is still fatal (R14).
|
|
906
|
+
if (planFile !== undefined && err instanceof ConductorBridgeApiError && err.kind === "network") {
|
|
907
|
+
const endpoint = planeEndpointForBaseUrl(deps, accessResult.access.baseUrl);
|
|
908
|
+
if (endpoint !== null) {
|
|
909
|
+
const probe = await deps.probePlanePort(endpoint.host, endpoint.port);
|
|
910
|
+
if (probe.kind === "refused") {
|
|
911
|
+
return coldStartPlane({
|
|
912
|
+
deps,
|
|
913
|
+
epicKey,
|
|
914
|
+
options: parsed.options,
|
|
915
|
+
access: accessResult.access,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
642
920
|
return escalate(deps, epicKey, `conductor readiness could not be read: ${safeDiagnosticMessage(err, "readiness request failed")}`);
|
|
643
921
|
}
|
|
644
922
|
// --- Route. Exactly one path from here on. -------------------------------
|
|
@@ -649,19 +927,8 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
649
927
|
// artifact, bad JSON, malformed/inconsistent shape, an unsafe rendered
|
|
650
928
|
// value — silently yields no advisory and never affects routing, the
|
|
651
929
|
// delegated argv, or the exit code.
|
|
652
|
-
if (planFile)
|
|
653
|
-
|
|
654
|
-
const advisory = await readContainmentEligibilityAdvisory(planFile, deps.readEligibilityArtifact);
|
|
655
|
-
if (advisory) {
|
|
656
|
-
const rendered = renderContainmentEligibilityAdvisory(advisory);
|
|
657
|
-
if (rendered)
|
|
658
|
-
deps.log(rendered);
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
catch {
|
|
662
|
-
// Fail open — a containment advisory is never allowed to block dispatch.
|
|
663
|
-
}
|
|
664
|
-
}
|
|
930
|
+
if (planFile)
|
|
931
|
+
await surfaceContainmentAdvisory(deps, planFile);
|
|
665
932
|
// Decide delegation BEFORE rendering. The v2 branch's text is specifically
|
|
666
933
|
// about a missing plan file, and rendering it on the path that has one
|
|
667
934
|
// would produce a message contradicting what is about to happen.
|
|
@@ -672,12 +939,22 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
672
939
|
// the runtime facts is not a "use the other conductor" situation — it is a
|
|
673
940
|
// plane that has not been started yet.
|
|
674
941
|
if (planFile) {
|
|
675
|
-
|
|
942
|
+
// BAPI-1102 — the explicit `--attended` opt-out makes the unattended
|
|
943
|
+
// prerequisites inapplicable. Both inputs are FLAG checks, not policy
|
|
944
|
+
// reads: whatever a `--policy-file` says stays opaque here, exactly as it
|
|
945
|
+
// always has. Its mere PRESENCE defers the escalation-channel requirement
|
|
946
|
+
// to `setup-epic`, which composes the policy and can actually read it.
|
|
947
|
+
const classification = classifyV2Readiness(readiness, {
|
|
948
|
+
attended: parsed.options.attended === true,
|
|
949
|
+
intoBase: parsed.options.intoBase === true,
|
|
950
|
+
hasPolicyFile: parsed.options.policyFile !== undefined,
|
|
951
|
+
});
|
|
676
952
|
const setupArgv = buildSetupEpicArgv(parsed.options);
|
|
677
953
|
// Green: delegate exactly as before. No plane invocation, no preflight,
|
|
678
|
-
// and byte-identical argv for the no-flag case.
|
|
954
|
+
// and byte-identical argv for the no-flag case. Only the structured
|
|
955
|
+
// result's exit code is returned, so the public contract is unchanged.
|
|
679
956
|
if (classification.kind === "green") {
|
|
680
|
-
return await deps.runSetupEpic(setupArgv);
|
|
957
|
+
return (await deps.runSetupEpic(setupArgv)).exitCode;
|
|
681
958
|
}
|
|
682
959
|
// Operator-blocked: ONE named reason, and nothing is started. `plane up`
|
|
683
960
|
// cannot create stored supervisor state or resolve GitHub App credentials,
|
|
@@ -688,7 +965,11 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
688
965
|
`(${classification.id}).`,
|
|
689
966
|
"",
|
|
690
967
|
"This is not something starting the runtime can fix, so nothing was started.",
|
|
691
|
-
|
|
968
|
+
// The requirement's OWN remedy when it has one. Telling an operator to
|
|
969
|
+
// run `install-bridge conductor` for a missing consent flag sends them
|
|
970
|
+
// to a command that cannot set it.
|
|
971
|
+
classification.remedy ??
|
|
972
|
+
"Resolve it with `install-bridge conductor`, then run drive-epic again.",
|
|
692
973
|
].join("\n");
|
|
693
974
|
// Named as a fixed reason and nothing else: no conductor is offered here,
|
|
694
975
|
// because handing an operator a second authority is the hazard this
|
|
@@ -700,58 +981,85 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
700
981
|
// Runtime-only: this invocation composes bring-up with run creation.
|
|
701
982
|
const repoRoot = deps.cwd();
|
|
702
983
|
const preflight = await deps.runPlanePreflight(repoRoot);
|
|
984
|
+
// BAPI-1153 — ATTACH. A port serving this repository's own live, healthy
|
|
985
|
+
// plane is not a blocker when the Bridge API this command talks to IS that
|
|
986
|
+
// plane: create/approve/cut against it and request a run-scoped lane — no
|
|
987
|
+
// second server, no second reconciler. The classification is the one the
|
|
988
|
+
// preflight made from its single manifest read; nothing is re-read here.
|
|
989
|
+
const existingPlane = preflight.observations?.existingPlane;
|
|
990
|
+
const attachManifest = existingPlane?.kind === "attachable" &&
|
|
991
|
+
preflight.observations?.serverPort === "connected" &&
|
|
992
|
+
planeEndpointForBaseUrl(deps, accessResult.access.baseUrl) !== null
|
|
993
|
+
? existingPlane.manifest
|
|
994
|
+
: null;
|
|
703
995
|
// Composition preconditions FIRST, and only these two. Preflight is
|
|
704
996
|
// read-only, so a refusal here provably created no manifest, spawned no
|
|
705
997
|
// process, and issued no run-creating request — AC-1's "no partial run"
|
|
706
998
|
// holds structurally rather than by cleanup.
|
|
707
|
-
const precondition = firstPreconditionRefusal(preflight
|
|
999
|
+
const precondition = firstPreconditionRefusal(preflight, {
|
|
1000
|
+
attachable: attachManifest !== null,
|
|
1001
|
+
});
|
|
708
1002
|
if (precondition) {
|
|
709
|
-
const
|
|
1003
|
+
const lines = [
|
|
710
1004
|
`${epicKey}: cannot start the conductor runtime — ${precondition.check} failed.`,
|
|
711
1005
|
formatPlaneDiagnostic(precondition),
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
1006
|
+
];
|
|
1007
|
+
// BAPI-1153: an occupied port names WHICH kind of occupant it is and its
|
|
1008
|
+
// one fix, still inside this single refusal.
|
|
1009
|
+
if (precondition.check === "server-port") {
|
|
1010
|
+
const occupant = describePlanePortOccupant(existingPlane);
|
|
1011
|
+
lines.push(` Occupant: ${occupant.classification} — ${occupant.remediation}`);
|
|
1012
|
+
}
|
|
1013
|
+
lines.push("", "Nothing was started. Fix the item above and run drive-epic again.");
|
|
1014
|
+
const refusal = lines.join("\n");
|
|
715
1015
|
assertSingleConductorInvocation(refusal);
|
|
716
1016
|
deps.errorLog(refusal);
|
|
717
1017
|
return 1;
|
|
718
1018
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
// override seam, so the port that was probed is the port that gets launched
|
|
722
|
-
// and the two cannot disagree. Every blocking failure `plane up` finds
|
|
723
|
-
// beyond the two preconditions above is rendered by its own aggregate
|
|
724
|
-
// refusal, unchanged.
|
|
725
|
-
const planeExit = await deps.runPlane(["up"], { preflight: async () => preflight });
|
|
726
|
-
if (planeExit !== 0) {
|
|
727
|
-
// `plane up` already reported why, in its own words, and it rolls its own
|
|
728
|
-
// startup back. Nothing to add, and nothing was created here.
|
|
729
|
-
return 1;
|
|
1019
|
+
if (attachManifest !== null) {
|
|
1020
|
+
return attachToPlane({ deps, epicKey, options: parsed.options, access: accessResult.access }, attachManifest, readiness);
|
|
730
1021
|
}
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
1022
|
+
// ---- PHASE ONE: the control plane, and NOTHING that claims jobs --------
|
|
1023
|
+
//
|
|
1024
|
+
// BAPI-1102 reordered this whole block. It used to be `plane up` (which
|
|
1025
|
+
// spawned executor lanes) and THEN `setup-epic`, which cannot work in two
|
|
1026
|
+
// independent ways: the lanes were spawned with no claim scope, so since
|
|
1027
|
+
// BAPI-1026 they exit at startup; and `plane up` stays attached for the
|
|
1028
|
+
// plane's lifetime, so the composed flow could never reach `setup-epic` at
|
|
1029
|
+
// all. Both are why the BAPI-1061 and BAPI-1085 drivers fell back to
|
|
1030
|
+
// hand-written helpers.
|
|
1031
|
+
//
|
|
1032
|
+
// The new order is the only one that can work: the server has to be up
|
|
1033
|
+
// before a run can be created, and the run has to exist before a lane can
|
|
1034
|
+
// be scoped to it.
|
|
1035
|
+
deps.log(`${epicKey}: the conductor runtime is not live; starting its control plane before creating the run.`);
|
|
1036
|
+
// The SAME preflight result is handed to the launch, so the port that was
|
|
1037
|
+
// probed is the port that gets launched and the two cannot disagree.
|
|
1038
|
+
const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
|
|
1039
|
+
if (!controlPlane.ok) {
|
|
1040
|
+
// The launch already reported why, in its own words, and it rolls its own
|
|
1041
|
+
// startup back. Nothing to add, and no run was created here.
|
|
1042
|
+
for (const line of controlPlane.lines)
|
|
1043
|
+
deps.errorLog(line);
|
|
1044
|
+
return 1;
|
|
753
1045
|
}
|
|
754
|
-
|
|
1046
|
+
// Held so the launcher's lifetime promise is never unobserved. It settles
|
|
1047
|
+
// when the PLANE comes down, which is long after this command returns —
|
|
1048
|
+
// the detached runtime is its own process-group leader and outlives us by
|
|
1049
|
+
// design, which is what "the plane is left running" means.
|
|
1050
|
+
void controlPlane.lifetime.catch(() => undefined);
|
|
1051
|
+
// Readiness is NOT re-read as a gate here, and that has not changed:
|
|
1052
|
+
// `reconciler_live` / `executor_live` derive from `epic_runs.last_tick_at`
|
|
1053
|
+
// and `executor_jobs` rows — tables that are EMPTY on a repository that has
|
|
1054
|
+
// never run an epic — so gating on a re-read would hang the one-command
|
|
1055
|
+
// flow permanently on exactly the first-time-operator case it exists to
|
|
1056
|
+
// serve. A successful bring-up IS the runtime fact.
|
|
1057
|
+
return createRunAndRequestLanes({ deps, epicKey, options: parsed.options, access: accessResult.access }, {
|
|
1058
|
+
origin: "launched",
|
|
1059
|
+
repoRoot,
|
|
1060
|
+
planeId: controlPlane.planeId,
|
|
1061
|
+
awaitPlaneReady: controlPlane.awaitPlaneReady,
|
|
1062
|
+
});
|
|
755
1063
|
}
|
|
756
1064
|
const handoff = renderConductorHandoff(selection, epicKey);
|
|
757
1065
|
const text = handoff.lines.join("\n");
|
|
@@ -767,6 +1075,28 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
767
1075
|
return 1;
|
|
768
1076
|
}
|
|
769
1077
|
}
|
|
1078
|
+
/**
|
|
1079
|
+
* BAPI-1022: surface any containment-hazard advisory before dispatch or handoff.
|
|
1080
|
+
*
|
|
1081
|
+
* Read only when a plan file was supplied (the sidecar has no other resolvable
|
|
1082
|
+
* location). Fail-open: any failure — missing artifact, bad JSON,
|
|
1083
|
+
* malformed/inconsistent shape, an unsafe rendered value — silently yields no
|
|
1084
|
+
* advisory and never affects routing, the delegated argv, or the exit code.
|
|
1085
|
+
* One helper, so the cold-start path (BAPI-1153) cannot drift from the rest.
|
|
1086
|
+
*/
|
|
1087
|
+
async function surfaceContainmentAdvisory(deps, planFile) {
|
|
1088
|
+
try {
|
|
1089
|
+
const advisory = await readContainmentEligibilityAdvisory(planFile, deps.readEligibilityArtifact);
|
|
1090
|
+
if (advisory) {
|
|
1091
|
+
const rendered = renderContainmentEligibilityAdvisory(advisory);
|
|
1092
|
+
if (rendered)
|
|
1093
|
+
deps.log(rendered);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
catch {
|
|
1097
|
+
// Fail open — a containment advisory is never allowed to block dispatch.
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
770
1100
|
/**
|
|
771
1101
|
* The escalation branch. Prints NO conductor invocation at all — naming a
|
|
772
1102
|
* fallback here is the dual-authority hazard, and an operator who does not know
|
|
@@ -785,3 +1115,335 @@ function escalate(deps, epicKey, reason) {
|
|
|
785
1115
|
deps.errorLog(text);
|
|
786
1116
|
return 1;
|
|
787
1117
|
}
|
|
1118
|
+
/** Emit one refusal, guarded so it can never name a second conductor. */
|
|
1119
|
+
function refuse(deps, lines) {
|
|
1120
|
+
const text = lines.join("\n");
|
|
1121
|
+
assertSingleConductorInvocation(text);
|
|
1122
|
+
deps.errorLog(text);
|
|
1123
|
+
return 1;
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* The plane endpoint, when `baseUrl` names it; otherwise `null`.
|
|
1127
|
+
*
|
|
1128
|
+
* `null` for an unresolvable `BAPI_PLANE_PORT` too: with no endpoint there is
|
|
1129
|
+
* no plane to cold-start or attach to, and the caller's existing behavior
|
|
1130
|
+
* applies.
|
|
1131
|
+
*/
|
|
1132
|
+
function planeEndpointForBaseUrl(deps, baseUrl) {
|
|
1133
|
+
const resolution = deps.resolvePlaneEndpoint();
|
|
1134
|
+
if (!resolution.ok)
|
|
1135
|
+
return null;
|
|
1136
|
+
return baseUrlNamesPlaneEndpoint(baseUrl, resolution.endpoint) ? resolution.endpoint : null;
|
|
1137
|
+
}
|
|
1138
|
+
/** What to tell the operator about a plane THIS invocation started and left up. */
|
|
1139
|
+
const LAUNCHED_PLANE_LEFT_RUNNING = [
|
|
1140
|
+
"",
|
|
1141
|
+
"The conductor CONTROL PLANE was started by this invocation and is STILL RUNNING.",
|
|
1142
|
+
"No run was created and no executor lane was spawned. Inspect it with `plane status`,",
|
|
1143
|
+
"and wind it down with `plane down` once the item above is fixed.",
|
|
1144
|
+
];
|
|
1145
|
+
/**
|
|
1146
|
+
* The bring-up facts and the plane-membership check, before any create.
|
|
1147
|
+
*
|
|
1148
|
+
* Returns the refusal lines, or `null` when admitted. Two questions:
|
|
1149
|
+
*
|
|
1150
|
+
* 1. The deployment-wide facts (`bring_up`), under their own refusal names —
|
|
1151
|
+
* a behind database, no worker, or two reconcilers refuse here exactly as
|
|
1152
|
+
* `setup-epic` would, and they must refuse here too because an attach to a
|
|
1153
|
+
* run that already exists never reaches setup-epic's create-path gate.
|
|
1154
|
+
* 2. Is THE PLANE'S OWN reconciler the fresh one? The deployment count cannot
|
|
1155
|
+
* say whose heartbeat it is; this instance-scoped read can. Combined with a
|
|
1156
|
+
* fresh count of exactly one, it proves the single live transition
|
|
1157
|
+
* authority is the reconciler of the plane being used. The identity is
|
|
1158
|
+
* rebuilt from the manifest, sent as a query parameter, and never rendered.
|
|
1159
|
+
*/
|
|
1160
|
+
async function checkPlaneAdmission(ctx, readiness, manifest) {
|
|
1161
|
+
const bringUpRefusal = firstBringUpRefusal(readiness.bring_up ?? null);
|
|
1162
|
+
if (bringUpRefusal !== null) {
|
|
1163
|
+
return [
|
|
1164
|
+
`${ctx.epicKey}: REFUSED (${bringUpRefusal}) — ${BRING_UP_REFUSAL_MESSAGES[bringUpRefusal]}`,
|
|
1165
|
+
];
|
|
1166
|
+
}
|
|
1167
|
+
let state;
|
|
1168
|
+
try {
|
|
1169
|
+
state = await ctx.deps.readPlaneReconcilerState({ access: ctx.access, manifest });
|
|
1170
|
+
}
|
|
1171
|
+
catch {
|
|
1172
|
+
state = "unavailable";
|
|
1173
|
+
}
|
|
1174
|
+
if (state === "fresh")
|
|
1175
|
+
return null;
|
|
1176
|
+
return [
|
|
1177
|
+
`${ctx.epicKey}: REFUSED (plane-reconciler-unconfirmed) — the plane's own reconciler could ` +
|
|
1178
|
+
`not be confirmed as the live transition authority (its heartbeat reads: ${state}).`,
|
|
1179
|
+
"Another reconciler may be standing in for this plane — often a reparented worker.py from an",
|
|
1180
|
+
`earlier plane. Check \`ps aux | grep worker.py\` (${BRING_UP_RUNBOOK} §6), stop any`,
|
|
1181
|
+
"survivor, and run drive-epic again.",
|
|
1182
|
+
];
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* COLD START: nothing is listening on the plane endpoint, so bring the control
|
|
1186
|
+
* plane up and compose the run against it.
|
|
1187
|
+
*
|
|
1188
|
+
* Reuses the BAPI-1102 two-phase launch unchanged — `executors: 1`, whose phase
|
|
1189
|
+
* one spawns the server and reconciler and no claiming lane — so there is one
|
|
1190
|
+
* bring-up ordering, not two.
|
|
1191
|
+
*/
|
|
1192
|
+
async function coldStartPlane(ctx) {
|
|
1193
|
+
const { deps, epicKey } = ctx;
|
|
1194
|
+
// The cold start leaves the readiness `catch` before the ordinary advisory
|
|
1195
|
+
// read is reached, so it surfaces the BAPI-1022 containment advisory itself —
|
|
1196
|
+
// before anything is started, exactly as every other plan-file route does.
|
|
1197
|
+
if (ctx.options.planFile)
|
|
1198
|
+
await surfaceContainmentAdvisory(deps, ctx.options.planFile);
|
|
1199
|
+
deps.log(`${epicKey}: nothing is listening on the plane endpoint; cold-starting the conductor plane.`);
|
|
1200
|
+
const repoRoot = deps.cwd();
|
|
1201
|
+
const preflight = await deps.runPlanePreflight(repoRoot);
|
|
1202
|
+
const precondition = firstPreconditionRefusal(preflight);
|
|
1203
|
+
if (precondition) {
|
|
1204
|
+
return refuse(deps, [
|
|
1205
|
+
`${epicKey}: cannot start the conductor runtime — ${precondition.check} failed.`,
|
|
1206
|
+
formatPlaneDiagnostic(precondition),
|
|
1207
|
+
"",
|
|
1208
|
+
"Nothing was started. Fix the item above and run drive-epic again.",
|
|
1209
|
+
]);
|
|
1210
|
+
}
|
|
1211
|
+
const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
|
|
1212
|
+
if (!controlPlane.ok) {
|
|
1213
|
+
for (const line of controlPlane.lines)
|
|
1214
|
+
deps.errorLog(line);
|
|
1215
|
+
return 1;
|
|
1216
|
+
}
|
|
1217
|
+
void controlPlane.lifetime.catch(() => undefined);
|
|
1218
|
+
// Read ONCE, for route selection only — the pre-launch read had no server to
|
|
1219
|
+
// answer it. Not a runtime gate: runtime facts are not consulted here beyond
|
|
1220
|
+
// the classification, which treats them as repairable.
|
|
1221
|
+
let readiness;
|
|
1222
|
+
try {
|
|
1223
|
+
readiness = await deps.readReadiness(ctx.access);
|
|
1224
|
+
}
|
|
1225
|
+
catch (err) {
|
|
1226
|
+
return refuse(deps, [
|
|
1227
|
+
`${epicKey}: the plane came up, but its conductor readiness could not be read: ` +
|
|
1228
|
+
safeDiagnosticMessage(err, "readiness request failed"),
|
|
1229
|
+
...LAUNCHED_PLANE_LEFT_RUNNING,
|
|
1230
|
+
]);
|
|
1231
|
+
}
|
|
1232
|
+
const classification = classifyV2Readiness(readiness, {
|
|
1233
|
+
attended: ctx.options.attended === true,
|
|
1234
|
+
intoBase: ctx.options.intoBase === true,
|
|
1235
|
+
hasPolicyFile: ctx.options.policyFile !== undefined,
|
|
1236
|
+
});
|
|
1237
|
+
if (classification.kind === "operator_blocked") {
|
|
1238
|
+
return refuse(deps, [
|
|
1239
|
+
`${epicKey}: the engine path still needs ${classification.describe} (${classification.id}).`,
|
|
1240
|
+
classification.remedy ??
|
|
1241
|
+
"Resolve it with `install-bridge conductor`, then run drive-epic again.",
|
|
1242
|
+
...LAUNCHED_PLANE_LEFT_RUNNING,
|
|
1243
|
+
]);
|
|
1244
|
+
}
|
|
1245
|
+
const manifestRead = await deps.readPlaneManifest(repoRoot);
|
|
1246
|
+
if (manifestRead.kind !== "valid" || manifestRead.manifest.planeId !== controlPlane.planeId) {
|
|
1247
|
+
return refuse(deps, [
|
|
1248
|
+
`${epicKey}: REFUSED (plane-reconciler-unconfirmed) — the plane manifest this invocation ` +
|
|
1249
|
+
"just wrote could not be read back, so its reconciler cannot be identified.",
|
|
1250
|
+
...LAUNCHED_PLANE_LEFT_RUNNING,
|
|
1251
|
+
]);
|
|
1252
|
+
}
|
|
1253
|
+
const admission = await checkPlaneAdmission(ctx, readiness, manifestRead.manifest);
|
|
1254
|
+
if (admission !== null) {
|
|
1255
|
+
return refuse(deps, [...admission, ...LAUNCHED_PLANE_LEFT_RUNNING]);
|
|
1256
|
+
}
|
|
1257
|
+
return createRunAndRequestLanes(ctx, {
|
|
1258
|
+
origin: "launched",
|
|
1259
|
+
repoRoot,
|
|
1260
|
+
planeId: controlPlane.planeId,
|
|
1261
|
+
awaitPlaneReady: controlPlane.awaitPlaneReady,
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* ATTACH: the plane endpoint already serves this repository's live plane.
|
|
1266
|
+
*
|
|
1267
|
+
* Starts no server and no reconciler — `launchControlPlane` is never called on
|
|
1268
|
+
* this path. Credentials resolve against the MANIFEST's repository root through
|
|
1269
|
+
* the shared resolver, as `plane down` does for a bound run.
|
|
1270
|
+
*/
|
|
1271
|
+
async function attachToPlane(ctx, manifest, readiness) {
|
|
1272
|
+
const { deps, epicKey } = ctx;
|
|
1273
|
+
const rooted = await deps.resolveAccess(ctx.options.repo, manifest.repoRoot);
|
|
1274
|
+
if (!rooted.ok) {
|
|
1275
|
+
return refuse(deps, [
|
|
1276
|
+
`${epicKey}: cannot attach to the running plane — Bridge API credentials could not be ` +
|
|
1277
|
+
`resolved for its repository: ${rooted.error}`,
|
|
1278
|
+
"",
|
|
1279
|
+
"Nothing was started. Fix the credential and run drive-epic again.",
|
|
1280
|
+
]);
|
|
1281
|
+
}
|
|
1282
|
+
const attachCtx = { ...ctx, access: rooted.access };
|
|
1283
|
+
// R-7, decided BEFORE any create request.
|
|
1284
|
+
if (manifest.epicRunId !== undefined) {
|
|
1285
|
+
let bound = null;
|
|
1286
|
+
try {
|
|
1287
|
+
bound = await deps.readRunState(rooted.access, manifest.epicRunId);
|
|
1288
|
+
}
|
|
1289
|
+
catch {
|
|
1290
|
+
bound = null;
|
|
1291
|
+
}
|
|
1292
|
+
const why = bound === null
|
|
1293
|
+
? "its bound run could not be read"
|
|
1294
|
+
: TERMINAL_EPIC_RUN_STATUSES.includes(bound.status)
|
|
1295
|
+
? `its bound run is ${bound.status}`
|
|
1296
|
+
: bound.epicKey !== epicKey
|
|
1297
|
+
? `its bound run belongs to ${bound.epicKey}`
|
|
1298
|
+
: null;
|
|
1299
|
+
if (why !== null) {
|
|
1300
|
+
return refuse(deps, [
|
|
1301
|
+
`${epicKey}: REFUSED (plane-already-bound) — this repository's plane is bound to run ` +
|
|
1302
|
+
`${manifest.epicRunId}, and ${why}.`,
|
|
1303
|
+
"A plane serves exactly one epic run (one epic per plane). Finish or stop that run, wind the",
|
|
1304
|
+
"plane down with `plane down`, and run drive-epic again.",
|
|
1305
|
+
"",
|
|
1306
|
+
"Nothing was started.",
|
|
1307
|
+
]);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
const admission = await checkPlaneAdmission(attachCtx, readiness, manifest);
|
|
1311
|
+
if (admission !== null) {
|
|
1312
|
+
return refuse(deps, [...admission, "", "Nothing was started."]);
|
|
1313
|
+
}
|
|
1314
|
+
deps.log(`${epicKey}: attaching to this repository's running plane — no second server or reconciler will start.`);
|
|
1315
|
+
return createRunAndRequestLanes(attachCtx, {
|
|
1316
|
+
origin: "attached",
|
|
1317
|
+
repoRoot: manifest.repoRoot,
|
|
1318
|
+
planeId: manifest.planeId,
|
|
1319
|
+
...(manifest.epicRunId !== undefined ? { boundRunId: manifest.epicRunId } : {}),
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* The shared tail: create/approve/cut through `setup-epic`, then request lanes
|
|
1324
|
+
* scoped to exactly that run.
|
|
1325
|
+
*
|
|
1326
|
+
* The `launched` wording is the BAPI-1102 wording, unchanged. The lane scope is
|
|
1327
|
+
* always `epic-runs` naming the one run setup returned — it renders through
|
|
1328
|
+
* `executorClaimScopeArgs` as `--epic-run-id`, and `--repo-wide` is not
|
|
1329
|
+
* constructible here.
|
|
1330
|
+
*/
|
|
1331
|
+
async function createRunAndRequestLanes(ctx, plane) {
|
|
1332
|
+
const { deps, epicKey } = ctx;
|
|
1333
|
+
const launched = plane.origin === "launched";
|
|
1334
|
+
// ---- PHASE TWO, part one: create the run ------------------------------
|
|
1335
|
+
const setup = await deps.runSetupEpic(buildSetupEpicArgv(ctx.options));
|
|
1336
|
+
if (setup.exitCode !== 0) {
|
|
1337
|
+
// The control plane is deliberately LEFT RUNNING, and NO EXECUTOR LANE
|
|
1338
|
+
// was spawned — there is nothing to claim a job that may not exist.
|
|
1339
|
+
// Winding down automatically would stop the very run `setup-epic` may
|
|
1340
|
+
// have just created, because `plane down` stops the manifest-bound run
|
|
1341
|
+
// before terminating processes.
|
|
1342
|
+
deps.errorLog((launched
|
|
1343
|
+
? [
|
|
1344
|
+
"",
|
|
1345
|
+
`${epicKey}: the conductor CONTROL PLANE was started by this invocation and is STILL RUNNING.`,
|
|
1346
|
+
"No executor lane was spawned, so nothing is claiming jobs.",
|
|
1347
|
+
"It was not wound down automatically, because doing so would stop a run that may",
|
|
1348
|
+
"already have been created and approved. Inspect it with `plane status`, and wind it",
|
|
1349
|
+
"down with `plane down` once you have decided what to do with the run.",
|
|
1350
|
+
]
|
|
1351
|
+
: [
|
|
1352
|
+
"",
|
|
1353
|
+
`${epicKey}: the plane this invocation attached to was left running exactly as it was.`,
|
|
1354
|
+
"No executor lane was requested. Inspect it with `plane status`.",
|
|
1355
|
+
]).join("\n"));
|
|
1356
|
+
return setup.exitCode;
|
|
1357
|
+
}
|
|
1358
|
+
if (setup.epicRunId === undefined) {
|
|
1359
|
+
// Exit zero with no run id is an ORCHESTRATION failure, not a success:
|
|
1360
|
+
// there is no authoritative identity to scope lanes to, and the one thing
|
|
1361
|
+
// this reordering exists to prevent is a lane started against a run
|
|
1362
|
+
// nobody named. Guessing — a repository-wide lookup, say — could scope
|
|
1363
|
+
// lanes to a DIFFERENT run on the same repository.
|
|
1364
|
+
deps.errorLog([
|
|
1365
|
+
"",
|
|
1366
|
+
`${epicKey}: setup reported success but returned no epic run id, so no executor lane`,
|
|
1367
|
+
"could be scoped to this run. NO LANE WAS SPAWNED. The control plane is still",
|
|
1368
|
+
"running: inspect it with `plane status` and wind it down with `plane down`.",
|
|
1369
|
+
].join("\n"));
|
|
1370
|
+
return 1;
|
|
1371
|
+
}
|
|
1372
|
+
if (plane.boundRunId !== undefined && setup.epicRunId !== plane.boundRunId) {
|
|
1373
|
+
// The bound run was checked live and of this epic, so setup should have
|
|
1374
|
+
// reused it. A different id means the world moved between the check and the
|
|
1375
|
+
// create; requesting lanes now would point a single-run plane at a second run.
|
|
1376
|
+
deps.errorLog([
|
|
1377
|
+
"",
|
|
1378
|
+
`${epicKey}: setup resolved run ${setup.epicRunId}, but the attached plane is bound to run`,
|
|
1379
|
+
`${plane.boundRunId}. NO LANE WAS REQUESTED. Inspect both with \`plane status\` and the run's`,
|
|
1380
|
+
"own status before retrying.",
|
|
1381
|
+
].join("\n"));
|
|
1382
|
+
return 1;
|
|
1383
|
+
}
|
|
1384
|
+
// ---- PHASE TWO, part two: scoped lanes, then the observer -------------
|
|
1385
|
+
const laneRequest = await deps.requestScopedLanes({
|
|
1386
|
+
repoRoot: plane.repoRoot,
|
|
1387
|
+
planeId: plane.planeId,
|
|
1388
|
+
epicRunId: setup.epicRunId,
|
|
1389
|
+
claimScope: { kind: "epic-runs", epicRunIds: [setup.epicRunId] },
|
|
1390
|
+
});
|
|
1391
|
+
if (!laneRequest.ok) {
|
|
1392
|
+
deps.errorLog([
|
|
1393
|
+
"",
|
|
1394
|
+
`${epicKey}: the run was created and approved, but executor lanes could not be`,
|
|
1395
|
+
`requested from the live plane (${laneRequest.reason}): ${laneRequest.message}.`,
|
|
1396
|
+
laneRequest.reason === "run-conflict"
|
|
1397
|
+
? "A composed plane serves exactly ONE run. Start a second plane for a second epic."
|
|
1398
|
+
: "Inspect it with `plane status`, and wind it down with `plane down`.",
|
|
1399
|
+
"The run itself is untouched and still committed server-side.",
|
|
1400
|
+
].join("\n"));
|
|
1401
|
+
return 1;
|
|
1402
|
+
}
|
|
1403
|
+
if (plane.awaitPlaneReady === undefined) {
|
|
1404
|
+
// Attached: the plane's own detached runtime performs the spawn and the
|
|
1405
|
+
// per-lane readiness gating, and this process may exit first. Its progress
|
|
1406
|
+
// is the plane's to report.
|
|
1407
|
+
deps.log([
|
|
1408
|
+
"",
|
|
1409
|
+
`${epicKey}: attached — executor lane(s) scoped to run ${setup.epicRunId} were requested`,
|
|
1410
|
+
"from the running plane, whose runtime starts them and then the dead-man observer.",
|
|
1411
|
+
"`plane status` reports their progress; the run's own status reports dispatch.",
|
|
1412
|
+
].join("\n"));
|
|
1413
|
+
return 0;
|
|
1414
|
+
}
|
|
1415
|
+
const planeReady = await plane.awaitPlaneReady();
|
|
1416
|
+
if (!planeReady.ok) {
|
|
1417
|
+
// No rollback of the RUN, deliberately. A lane that failed to start says
|
|
1418
|
+
// nothing about whether the run is valid, and stopping a committed,
|
|
1419
|
+
// approved run because a local process died would destroy state the
|
|
1420
|
+
// operator may well want to retry into.
|
|
1421
|
+
for (const line of planeReady.lines)
|
|
1422
|
+
deps.errorLog(line);
|
|
1423
|
+
deps.errorLog([
|
|
1424
|
+
"",
|
|
1425
|
+
`${epicKey}: no executor lane became ready, and the dead-man observer was NOT started.`,
|
|
1426
|
+
"The control plane and the committed run are both intact and were not rolled back:",
|
|
1427
|
+
"the runtime stops the lane cohort it started and keeps supervising the server and",
|
|
1428
|
+
"reconciler, so nothing is claiming jobs and the run keeps its server-side state.",
|
|
1429
|
+
"Read the per-lane logs under .bridge/plane/, then wind the plane down with",
|
|
1430
|
+
"`plane down` and re-run this command once the cause is fixed. A plane cannot be",
|
|
1431
|
+
"asked for lanes a second time: the runtime waits for that request exactly once.",
|
|
1432
|
+
].join("\n"));
|
|
1433
|
+
return 1;
|
|
1434
|
+
}
|
|
1435
|
+
// Plane readiness is reported SEPARATELY from run dispatchability, and the
|
|
1436
|
+
// separation is the point: every process is up, but the reconciler still
|
|
1437
|
+
// holds dispatch while a feature branch's index scope finishes preparing.
|
|
1438
|
+
// An operator told "ready" who then sees no work start would go looking for
|
|
1439
|
+
// a broken plane that is working perfectly.
|
|
1440
|
+
deps.log([
|
|
1441
|
+
"",
|
|
1442
|
+
`${epicKey}: the plane is READY — control plane, scoped executor lane(s) for run ` +
|
|
1443
|
+
`${setup.epicRunId}, and the dead-man observer are all up.`,
|
|
1444
|
+
"That reports PROCESSES. If this run cuts an epic branch, the reconciler holds ticket",
|
|
1445
|
+
"dispatch until its index scope finishes preparing; `plane status` and the run's own",
|
|
1446
|
+
"status report that separately.",
|
|
1447
|
+
].join("\n"));
|
|
1448
|
+
return 0;
|
|
1449
|
+
}
|