@bridge_gpt/mcp-server 0.2.54 → 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 +38 -54
- package/build/agent-guidance.generated.js +3 -0
- package/build/agent-launchers/claude-executor-adapter.js +3 -0
- 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/codex-skill-adapter.js +55 -0
- package/build/commands.generated.js +5 -4
- package/build/conductor/bridge-api-client.js +199 -6
- 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 +1 -1
- 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 +37 -6
- 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/store.js +2 -2
- package/build/conductor/supervisor-runtime.js +1 -1
- 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 +77 -17
- package/build/drive-epic.js +541 -115
- 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/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 +274 -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 +586 -138
- package/build/init.js +72 -8
- package/build/install-bridge-conductor.js +5 -5
- package/build/install-bridge.js +403 -70
- 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 +7 -7
- package/build/plan-epic-conductor-eligibility.js +1 -1
- package/build/plane/cli.js +36 -5
- package/build/plane/preflight.js +128 -12
- package/build/plane/shutdown.js +4 -4
- package/build/readiness-check.js +3 -3
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +1 -1
- package/build/setup-epic.js +69 -31
- package/build/start-tickets-conductor.js +8 -7
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +1 -1
- package/docs/CONDUCTOR.md +8 -6
- package/package.json +5 -3
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";
|
|
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";
|
|
57
63
|
import { runSetupEpicWorkflow, } from "./setup-epic.js";
|
|
58
64
|
import { formatPlaneDiagnostic, launchPlaneControlPlane, requestPlaneScopedLanes, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
|
|
59
|
-
import {
|
|
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"];
|
|
@@ -131,7 +142,7 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
131
142
|
? "This Bridge API deploy does not report a repository's unattended " +
|
|
132
143
|
"prerequisites at all, so none of them could be confirmed. Upgrade the " +
|
|
133
144
|
"deploy, or pass --attended."
|
|
134
|
-
: "Set `
|
|
145
|
+
: "Set `unattended_epic_implementer_allowed` for this repository " +
|
|
135
146
|
"(docs/claude/account-settings-operator-runbook.md), or pass --attended.",
|
|
136
147
|
satisfied: (r) => r.unattended?.conductor_allowed === true,
|
|
137
148
|
},
|
|
@@ -147,6 +158,10 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
147
158
|
id: "repository_readiness",
|
|
148
159
|
describe: "confirmed review and conductor-CI workflows on the default branch",
|
|
149
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.
|
|
150
165
|
remedy: "Run `install-bridge conductor` to install them, or pass --attended.",
|
|
151
166
|
satisfied: (r) => r.unattended?.repository_readiness_confirmed === true,
|
|
152
167
|
},
|
|
@@ -261,13 +276,23 @@ export function getDriveEpicUsage() {
|
|
|
261
276
|
"GitHub App credentials, or an unattended prerequisite is NOT started: those are",
|
|
262
277
|
"operator-owned, and drive-epic exits with that one named reason instead.",
|
|
263
278
|
"",
|
|
264
|
-
"
|
|
265
|
-
|
|
266
|
-
"
|
|
267
|
-
|
|
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.",
|
|
268
292
|
"",
|
|
269
|
-
"
|
|
270
|
-
"
|
|
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.",
|
|
271
296
|
"",
|
|
272
297
|
"Options:",
|
|
273
298
|
" --plan-file <path> Plan DAG sidecar. When supplied and the v2 path is",
|
|
@@ -537,7 +562,7 @@ export function renderConductorHandoff(selection, epicKey) {
|
|
|
537
562
|
"",
|
|
538
563
|
` 1. Run /plan-epic ${epicKey} in an interactive session. It writes the`,
|
|
539
564
|
" sidecar to {docs_dir}/epic-plans/{epic_slug}/epic-plan.dag.json.",
|
|
540
|
-
" If the epic's tickets already exist, run emit-
|
|
565
|
+
" If the epic's tickets already exist, run emit-epic-implementer-bundle",
|
|
541
566
|
" finalize instead — it resolves the placeholder keys in an existing",
|
|
542
567
|
" sidecar and attaches touched_files.",
|
|
543
568
|
"",
|
|
@@ -706,18 +731,89 @@ export const DRIVE_EPIC_PRECONDITION_CHECKS = [
|
|
|
706
731
|
* preflight happened to append them, so the reason an operator sees for a given
|
|
707
732
|
* pair of failures is stable.
|
|
708
733
|
*/
|
|
709
|
-
export function firstPreconditionRefusal(preflight) {
|
|
734
|
+
export function firstPreconditionRefusal(preflight, options = {}) {
|
|
710
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;
|
|
711
742
|
const found = preflight.diagnostics.find((d) => d.check === check && d.severity === "blocking");
|
|
712
743
|
if (found)
|
|
713
744
|
return found;
|
|
714
745
|
}
|
|
715
746
|
return null;
|
|
716
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"];
|
|
717
810
|
export function createDefaultDriveEpicDeps() {
|
|
718
811
|
return {
|
|
719
|
-
resolveAccess: async (repo) => {
|
|
720
|
-
const result = await resolveConductorBridgeApiAccess(
|
|
812
|
+
resolveAccess: async (repo, cwd) => {
|
|
813
|
+
const result = await resolveConductorBridgeApiAccess({
|
|
814
|
+
...(repo ? { repoName: repo } : {}),
|
|
815
|
+
...(cwd ? { cwd } : {}),
|
|
816
|
+
});
|
|
721
817
|
return result.ok ? { ok: true, access: result.access } : { ok: false, error: result.error };
|
|
722
818
|
},
|
|
723
819
|
readReadiness: (access) => fetchConductorReadiness(access, globalThis.fetch),
|
|
@@ -739,6 +835,30 @@ export function createDefaultDriveEpicDeps() {
|
|
|
739
835
|
}),
|
|
740
836
|
requestScopedLanes: (args) => requestPlaneScopedLanes(args),
|
|
741
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()),
|
|
742
862
|
// stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
|
|
743
863
|
// construction, so nothing has claimed stdout for the protocol transport.
|
|
744
864
|
log: (message) => console.log(message),
|
|
@@ -771,12 +891,32 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
771
891
|
if (!accessResult.ok) {
|
|
772
892
|
return escalate(deps, epicKey, `conductor readiness could not be read: ${accessResult.error}`);
|
|
773
893
|
}
|
|
774
|
-
// --- Read readiness. Any failure here is UNKNOWN.
|
|
894
|
+
// --- Read readiness. Any failure here is UNKNOWN — with ONE exception. ---
|
|
775
895
|
let readiness;
|
|
776
896
|
try {
|
|
777
897
|
readiness = await deps.readReadiness(accessResult.access);
|
|
778
898
|
}
|
|
779
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
|
+
}
|
|
780
920
|
return escalate(deps, epicKey, `conductor readiness could not be read: ${safeDiagnosticMessage(err, "readiness request failed")}`);
|
|
781
921
|
}
|
|
782
922
|
// --- Route. Exactly one path from here on. -------------------------------
|
|
@@ -787,19 +927,8 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
787
927
|
// artifact, bad JSON, malformed/inconsistent shape, an unsafe rendered
|
|
788
928
|
// value — silently yields no advisory and never affects routing, the
|
|
789
929
|
// delegated argv, or the exit code.
|
|
790
|
-
if (planFile)
|
|
791
|
-
|
|
792
|
-
const advisory = await readContainmentEligibilityAdvisory(planFile, deps.readEligibilityArtifact);
|
|
793
|
-
if (advisory) {
|
|
794
|
-
const rendered = renderContainmentEligibilityAdvisory(advisory);
|
|
795
|
-
if (rendered)
|
|
796
|
-
deps.log(rendered);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
catch {
|
|
800
|
-
// Fail open — a containment advisory is never allowed to block dispatch.
|
|
801
|
-
}
|
|
802
|
-
}
|
|
930
|
+
if (planFile)
|
|
931
|
+
await surfaceContainmentAdvisory(deps, planFile);
|
|
803
932
|
// Decide delegation BEFORE rendering. The v2 branch's text is specifically
|
|
804
933
|
// about a missing plan file, and rendering it on the path that has one
|
|
805
934
|
// would produce a message contradicting what is about to happen.
|
|
@@ -852,22 +981,44 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
852
981
|
// Runtime-only: this invocation composes bring-up with run creation.
|
|
853
982
|
const repoRoot = deps.cwd();
|
|
854
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;
|
|
855
995
|
// Composition preconditions FIRST, and only these two. Preflight is
|
|
856
996
|
// read-only, so a refusal here provably created no manifest, spawned no
|
|
857
997
|
// process, and issued no run-creating request — AC-1's "no partial run"
|
|
858
998
|
// holds structurally rather than by cleanup.
|
|
859
|
-
const precondition = firstPreconditionRefusal(preflight
|
|
999
|
+
const precondition = firstPreconditionRefusal(preflight, {
|
|
1000
|
+
attachable: attachManifest !== null,
|
|
1001
|
+
});
|
|
860
1002
|
if (precondition) {
|
|
861
|
-
const
|
|
1003
|
+
const lines = [
|
|
862
1004
|
`${epicKey}: cannot start the conductor runtime — ${precondition.check} failed.`,
|
|
863
1005
|
formatPlaneDiagnostic(precondition),
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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");
|
|
867
1015
|
assertSingleConductorInvocation(refusal);
|
|
868
1016
|
deps.errorLog(refusal);
|
|
869
1017
|
return 1;
|
|
870
1018
|
}
|
|
1019
|
+
if (attachManifest !== null) {
|
|
1020
|
+
return attachToPlane({ deps, epicKey, options: parsed.options, access: accessResult.access }, attachManifest, readiness);
|
|
1021
|
+
}
|
|
871
1022
|
// ---- PHASE ONE: the control plane, and NOTHING that claims jobs --------
|
|
872
1023
|
//
|
|
873
1024
|
// BAPI-1102 reordered this whole block. It used to be `plane up` (which
|
|
@@ -903,91 +1054,12 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
903
1054
|
// never run an epic — so gating on a re-read would hang the one-command
|
|
904
1055
|
// flow permanently on exactly the first-time-operator case it exists to
|
|
905
1056
|
// serve. A successful bring-up IS the runtime fact.
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
if (setup.exitCode !== 0) {
|
|
909
|
-
// The control plane is deliberately LEFT RUNNING, and NO EXECUTOR LANE
|
|
910
|
-
// was spawned — there is nothing to claim a job that may not exist.
|
|
911
|
-
// Winding down automatically would stop the very run `setup-epic` may
|
|
912
|
-
// have just created, because `plane down` stops the manifest-bound run
|
|
913
|
-
// before terminating processes.
|
|
914
|
-
deps.errorLog([
|
|
915
|
-
"",
|
|
916
|
-
`${epicKey}: the conductor CONTROL PLANE was started by this invocation and is STILL RUNNING.`,
|
|
917
|
-
"No executor lane was spawned, so nothing is claiming jobs.",
|
|
918
|
-
"It was not wound down automatically, because doing so would stop a run that may",
|
|
919
|
-
"already have been created and approved. Inspect it with `plane status`, and wind it",
|
|
920
|
-
"down with `plane down` once you have decided what to do with the run.",
|
|
921
|
-
].join("\n"));
|
|
922
|
-
return setup.exitCode;
|
|
923
|
-
}
|
|
924
|
-
if (setup.epicRunId === undefined) {
|
|
925
|
-
// Exit zero with no run id is an ORCHESTRATION failure, not a success:
|
|
926
|
-
// there is no authoritative identity to scope lanes to, and the one thing
|
|
927
|
-
// this reordering exists to prevent is a lane started against a run
|
|
928
|
-
// nobody named. Guessing — a repository-wide lookup, say — could scope
|
|
929
|
-
// lanes to a DIFFERENT run on the same repository.
|
|
930
|
-
deps.errorLog([
|
|
931
|
-
"",
|
|
932
|
-
`${epicKey}: setup reported success but returned no epic run id, so no executor lane`,
|
|
933
|
-
"could be scoped to this run. NO LANE WAS SPAWNED. The control plane is still",
|
|
934
|
-
"running: inspect it with `plane status` and wind it down with `plane down`.",
|
|
935
|
-
].join("\n"));
|
|
936
|
-
return 1;
|
|
937
|
-
}
|
|
938
|
-
// ---- PHASE TWO, part two: scoped lanes, then the observer -------------
|
|
939
|
-
const laneRequest = await deps.requestScopedLanes({
|
|
1057
|
+
return createRunAndRequestLanes({ deps, epicKey, options: parsed.options, access: accessResult.access }, {
|
|
1058
|
+
origin: "launched",
|
|
940
1059
|
repoRoot,
|
|
941
1060
|
planeId: controlPlane.planeId,
|
|
942
|
-
|
|
943
|
-
claimScope: { kind: "epic-runs", epicRunIds: [setup.epicRunId] },
|
|
1061
|
+
awaitPlaneReady: controlPlane.awaitPlaneReady,
|
|
944
1062
|
});
|
|
945
|
-
if (!laneRequest.ok) {
|
|
946
|
-
deps.errorLog([
|
|
947
|
-
"",
|
|
948
|
-
`${epicKey}: the run was created and approved, but executor lanes could not be`,
|
|
949
|
-
`requested from the live plane (${laneRequest.reason}): ${laneRequest.message}.`,
|
|
950
|
-
laneRequest.reason === "run-conflict"
|
|
951
|
-
? "A composed plane serves exactly ONE run. Start a second plane for a second epic."
|
|
952
|
-
: "Inspect it with `plane status`, and wind it down with `plane down`.",
|
|
953
|
-
"The run itself is untouched and still committed server-side.",
|
|
954
|
-
].join("\n"));
|
|
955
|
-
return 1;
|
|
956
|
-
}
|
|
957
|
-
const planeReady = await controlPlane.awaitPlaneReady();
|
|
958
|
-
if (!planeReady.ok) {
|
|
959
|
-
// No rollback of the RUN, deliberately. A lane that failed to start says
|
|
960
|
-
// nothing about whether the run is valid, and stopping a committed,
|
|
961
|
-
// approved run because a local process died would destroy state the
|
|
962
|
-
// operator may well want to retry into.
|
|
963
|
-
for (const line of planeReady.lines)
|
|
964
|
-
deps.errorLog(line);
|
|
965
|
-
deps.errorLog([
|
|
966
|
-
"",
|
|
967
|
-
`${epicKey}: no executor lane became ready, and the dead-man observer was NOT started.`,
|
|
968
|
-
"The control plane and the committed run are both intact and were not rolled back:",
|
|
969
|
-
"the runtime stops the lane cohort it started and keeps supervising the server and",
|
|
970
|
-
"reconciler, so nothing is claiming jobs and the run keeps its server-side state.",
|
|
971
|
-
"Read the per-lane logs under .bridge/plane/, then wind the plane down with",
|
|
972
|
-
"`plane down` and re-run this command once the cause is fixed. A plane cannot be",
|
|
973
|
-
"asked for lanes a second time: the runtime waits for that request exactly once.",
|
|
974
|
-
].join("\n"));
|
|
975
|
-
return 1;
|
|
976
|
-
}
|
|
977
|
-
// Plane readiness is reported SEPARATELY from run dispatchability, and the
|
|
978
|
-
// separation is the point: every process is up, but the reconciler still
|
|
979
|
-
// holds dispatch while a feature branch's index scope finishes preparing.
|
|
980
|
-
// An operator told "ready" who then sees no work start would go looking for
|
|
981
|
-
// a broken plane that is working perfectly.
|
|
982
|
-
deps.log([
|
|
983
|
-
"",
|
|
984
|
-
`${epicKey}: the plane is READY — control plane, scoped executor lane(s) for run ` +
|
|
985
|
-
`${setup.epicRunId}, and the dead-man observer are all up.`,
|
|
986
|
-
"That reports PROCESSES. If this run cuts an epic branch, the reconciler holds ticket",
|
|
987
|
-
"dispatch until its index scope finishes preparing; `plane status` and the run's own",
|
|
988
|
-
"status report that separately.",
|
|
989
|
-
].join("\n"));
|
|
990
|
-
return 0;
|
|
991
1063
|
}
|
|
992
1064
|
const handoff = renderConductorHandoff(selection, epicKey);
|
|
993
1065
|
const text = handoff.lines.join("\n");
|
|
@@ -1003,6 +1075,28 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1003
1075
|
return 1;
|
|
1004
1076
|
}
|
|
1005
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
|
+
}
|
|
1006
1100
|
/**
|
|
1007
1101
|
* The escalation branch. Prints NO conductor invocation at all — naming a
|
|
1008
1102
|
* fallback here is the dual-authority hazard, and an operator who does not know
|
|
@@ -1021,3 +1115,335 @@ function escalate(deps, epicKey, reason) {
|
|
|
1021
1115
|
deps.errorLog(text);
|
|
1022
1116
|
return 1;
|
|
1023
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
|
+
}
|