@bridge_gpt/mcp-server 0.2.52 → 0.2.54
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 +121 -15
- package/build/agent-launchers/claude.js +3 -3
- package/build/agent-launchers/prompt.js +8 -11
- package/build/base-ref.js +33 -9
- package/build/bounded-wait.js +174 -0
- package/build/commands.generated.js +7 -5
- package/build/conductor/bridge-api-client.js +97 -8
- package/build/conductor/cli.js +23 -0
- package/build/conductor/doctor.js +428 -5
- package/build/conductor/epic-runtime.js +133 -97
- package/build/conductor/install-doctor.js +65 -656
- package/build/conductor/readiness-cli.js +152 -0
- package/build/conductor/readiness-sections.js +666 -0
- package/build/conductor/readiness.js +795 -0
- package/build/conductor/run-branch.js +137 -0
- package/build/conductor/test-run-branch-vectors.js +165 -0
- package/build/conductor/tools.js +56 -3
- package/build/conductor-bin.js +21 -17
- package/build/doctor.js +68 -1
- package/build/drive-epic.js +287 -51
- 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/job-runner.js +60 -0
- package/build/index.js +4496 -4697
- package/build/install-doctor.js +154 -2
- package/build/local-artifact-storage.js +130 -0
- package/build/pipelines.generated.js +17 -10
- package/build/plane/alembic-head.js +40 -11
- package/build/plane/build-freshness.js +22 -11
- package/build/plane/cli.js +285 -36
- package/build/plane/manifest.js +209 -1
- package/build/plane/member-roster.js +70 -0
- package/build/plane/preflight.js +363 -48
- package/build/plane/shutdown.js +14 -1
- package/build/plane/status.js +35 -1
- package/build/plane/supervisor.js +546 -164
- package/build/plane/types.js +61 -2
- package/build/polling-policy.js +72 -0
- package/build/readiness-check.js +412 -0
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +219 -0
- package/build/run-unit-tests-launcher.js +5 -0
- package/build/setup-epic.js +514 -23
- 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/docs/install/mcp-tool-integrations.md +23 -1
- package/package.json +2 -2
- package/pipelines/{full-automation.json → idea-to-pr.json} +1 -1
- package/pipelines/review-ticket.json +17 -4
|
@@ -13,10 +13,16 @@
|
|
|
13
13
|
* condition it guards is already reported loudly by the server at dispatch time.
|
|
14
14
|
*/
|
|
15
15
|
import path from "path";
|
|
16
|
+
import { planeCheckFinding, planeCheckPassed } from "./types.js";
|
|
16
17
|
/** Remediation embedded in a confirmed-mismatch diagnostic. */
|
|
17
18
|
export const PLANE_ALEMBIC_REMEDIATION = "alembic -c alembic.ini upgrade head";
|
|
18
19
|
/** Fixed prefix of the fail-open warning, so tests and greps can match it. */
|
|
19
20
|
export const PLANE_ALEMBIC_UNVERIFIED_PREFIX = "could not verify database migration head";
|
|
21
|
+
/** Structured fix for a confirmed migration mismatch. */
|
|
22
|
+
export const PLANE_ALEMBIC_MISMATCH_REMEDIATION = `run \`${PLANE_ALEMBIC_REMEDIATION}\` to bring the database up to the migration head, then retry.`;
|
|
23
|
+
/** Structured fix for an unverifiable migration head. Never blocking. */
|
|
24
|
+
export const PLANE_ALEMBIC_UNVERIFIED_REMEDIATION = "verify by hand with `alembic -c alembic.ini heads` and `alembic -c alembic.ini current`; " +
|
|
25
|
+
"startup continues, and the server reports CONTRACT_MIGRATION_BEHIND at dispatch if the database is behind.";
|
|
20
26
|
/**
|
|
21
27
|
* Path to the repository virtualenv's Alembic executable.
|
|
22
28
|
*
|
|
@@ -72,39 +78,62 @@ export function parseAlembicRevisions(output) {
|
|
|
72
78
|
export async function checkAlembicHead(repoRoot, deps) {
|
|
73
79
|
const alembic = resolveRepositoryAlembicCommand(repoRoot, deps.platform);
|
|
74
80
|
if (!(await deps.fileExists(alembic))) {
|
|
75
|
-
return unverified("the repository virtualenv Alembic executable was not found");
|
|
81
|
+
return unverified("the repository virtualenv Alembic executable was not found", "the repository virtualenv Alembic executable was not found");
|
|
76
82
|
}
|
|
77
83
|
const heads = await deps.execFile(alembic, ["-c", "alembic.ini", "heads"], { cwd: repoRoot });
|
|
78
84
|
if (!heads.ok) {
|
|
79
|
-
|
|
85
|
+
// The interpolated `error` stays in the legacy message for the operator at
|
|
86
|
+
// the terminal; the consolidated detail is fixed prose. In production that
|
|
87
|
+
// field is an errno code, but this outcome flows into a wider report and
|
|
88
|
+
// must not depend on every caller keeping it that way.
|
|
89
|
+
return unverified(`\`alembic heads\` did not complete (${heads.error})`, "`alembic heads` did not complete");
|
|
80
90
|
}
|
|
81
91
|
const current = await deps.execFile(alembic, ["-c", "alembic.ini", "current"], { cwd: repoRoot });
|
|
82
92
|
if (!current.ok) {
|
|
83
|
-
return unverified(`\`alembic current\` did not complete (${current.error})`);
|
|
93
|
+
return unverified(`\`alembic current\` did not complete (${current.error})`, "`alembic current` did not complete");
|
|
84
94
|
}
|
|
85
95
|
const headRevisions = parseAlembicRevisions(heads.stdout);
|
|
86
96
|
const currentRevisions = parseAlembicRevisions(current.stdout);
|
|
87
97
|
if (headRevisions === null || currentRevisions === null) {
|
|
88
|
-
return unverified("Alembic output did not contain a readable revision");
|
|
98
|
+
return unverified("Alembic output did not contain a readable revision", "Alembic output did not contain a readable revision");
|
|
89
99
|
}
|
|
90
100
|
const matches = headRevisions.length === currentRevisions.length &&
|
|
91
101
|
headRevisions.every((rev, i) => rev === currentRevisions[i]);
|
|
92
|
-
if (matches)
|
|
93
|
-
return
|
|
94
|
-
|
|
102
|
+
if (matches) {
|
|
103
|
+
return planeCheckPassed("alembic-head", ALEMBIC_CHECK_LABEL, "the database is at the repository migration head");
|
|
104
|
+
}
|
|
105
|
+
return planeCheckFinding({
|
|
95
106
|
check: "alembic-head",
|
|
96
107
|
severity: "blocking",
|
|
97
108
|
message: `the database is not at the migration head — heads [${headRevisions.join(", ")}], ` +
|
|
98
109
|
`current [${currentRevisions.join(", ")}]. A database behind the head blocks ` +
|
|
99
110
|
`conductor dispatch. Run \`${PLANE_ALEMBIC_REMEDIATION}\``,
|
|
100
|
-
|
|
111
|
+
remediation: PLANE_ALEMBIC_MISMATCH_REMEDIATION,
|
|
112
|
+
}, ALEMBIC_CHECK_LABEL,
|
|
113
|
+
// Revision ids are repository-authored slugs, not secrets, and they are the
|
|
114
|
+
// one fact that makes this finding actionable.
|
|
115
|
+
`database at [${currentRevisions.join(", ")}], repository head [${headRevisions.join(", ")}]`);
|
|
101
116
|
}
|
|
102
|
-
|
|
103
|
-
|
|
117
|
+
/** Human-readable name for the migration-head prerequisite. */
|
|
118
|
+
const ALEMBIC_CHECK_LABEL = "Database migration head";
|
|
119
|
+
/**
|
|
120
|
+
* The fail-open branch: warning severity, and never blocking.
|
|
121
|
+
*
|
|
122
|
+
* TWO details, deliberately. `messageDetail` is the legacy operator-facing prose
|
|
123
|
+
* and may name the command's own `error` field; `outcomeDetail` is fixed prose
|
|
124
|
+
* only, because the outcome flows into the consolidated readiness report. The
|
|
125
|
+
* production `execFile` adapter puts an errno code in `error`, but this
|
|
126
|
+
* separation means the consolidated report stays safe even if some caller ever
|
|
127
|
+
* puts raw stderr, an interpreter path, or a thrown message there.
|
|
128
|
+
*/
|
|
129
|
+
function unverified(messageDetail, outcomeDetail) {
|
|
130
|
+
const diagnostic = {
|
|
104
131
|
check: "alembic-head",
|
|
105
132
|
severity: "warning",
|
|
106
|
-
message: `${PLANE_ALEMBIC_UNVERIFIED_PREFIX}: ${
|
|
133
|
+
message: `${PLANE_ALEMBIC_UNVERIFIED_PREFIX}: ${messageDetail}. Startup continues — verify with ` +
|
|
107
134
|
`\`alembic -c alembic.ini heads\` and \`alembic -c alembic.ini current\` if a run ` +
|
|
108
135
|
"later reports CONTRACT_MIGRATION_BEHIND.",
|
|
136
|
+
remediation: PLANE_ALEMBIC_UNVERIFIED_REMEDIATION,
|
|
109
137
|
};
|
|
138
|
+
return planeCheckFinding(diagnostic, ALEMBIC_CHECK_LABEL, `${PLANE_ALEMBIC_UNVERIFIED_PREFIX}: ${outcomeDetail}`);
|
|
110
139
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* can be older than a rebuild that overwrote files in place.
|
|
14
14
|
*/
|
|
15
15
|
import path from "path";
|
|
16
|
+
import { planeCheckFinding, planeCheckPassed } from "./types.js";
|
|
16
17
|
/** Remediation text embedded in every blocking build diagnostic. */
|
|
17
18
|
export const PLANE_BUILD_REMEDIATION = "cd mcp_server && npm run build";
|
|
18
19
|
/**
|
|
@@ -93,42 +94,49 @@ export async function findBuildMtime(executorEntrypoint, deps) {
|
|
|
93
94
|
* stale or absent build is exactly the failure this check exists to prevent, so
|
|
94
95
|
* unlike the Alembic check there is no fail-open branch.
|
|
95
96
|
*/
|
|
97
|
+
export const PLANE_BUILD_FIX_REMEDIATION = "run `cd mcp_server && npm run build` to rebuild the executor entrypoint, then retry.";
|
|
96
98
|
export async function checkPlaneBuildFreshness(repoRoot, deps) {
|
|
99
|
+
const label = "Executor build freshness";
|
|
97
100
|
const packageRoot = path.join(repoRoot, "mcp_server");
|
|
98
101
|
const buildDir = path.join(packageRoot, "build");
|
|
99
102
|
const executorEntrypoint = path.join(buildDir, "index.js");
|
|
100
103
|
const srcDir = path.join(packageRoot, "src");
|
|
101
104
|
const buildMtime = await findBuildMtime(executorEntrypoint, deps);
|
|
102
105
|
if (!buildMtime.ok) {
|
|
103
|
-
return {
|
|
106
|
+
return planeCheckFinding({
|
|
104
107
|
check: "executor-build",
|
|
105
108
|
severity: "blocking",
|
|
106
109
|
message: `the executor build entrypoint is missing or unreadable at ` +
|
|
107
110
|
`mcp_server/build/index.js — run \`${PLANE_BUILD_REMEDIATION}\``,
|
|
108
|
-
|
|
111
|
+
remediation: PLANE_BUILD_FIX_REMEDIATION,
|
|
112
|
+
}, label, "the executor build entrypoint is missing or unreadable");
|
|
109
113
|
}
|
|
110
114
|
const sourceMtime = await findNewestSourceMtime(srcDir, deps);
|
|
111
115
|
if (!sourceMtime.ok) {
|
|
112
|
-
return {
|
|
116
|
+
return planeCheckFinding({
|
|
113
117
|
check: "executor-build",
|
|
114
118
|
severity: "blocking",
|
|
115
119
|
message: `could not read mcp_server/src to compare against the build ` +
|
|
116
120
|
`(${sourceMtime.error}) — run \`${PLANE_BUILD_REMEDIATION}\``,
|
|
117
|
-
|
|
121
|
+
remediation: PLANE_BUILD_FIX_REMEDIATION,
|
|
122
|
+
}, label,
|
|
123
|
+
// `sourceMtime.error` is an errno code, already sanitized by `sanitize()`.
|
|
124
|
+
`the executor sources could not be read for comparison (${sourceMtime.error})`);
|
|
118
125
|
}
|
|
119
126
|
// Equality passes: a build written in the same millisecond as its newest
|
|
120
127
|
// source is current, and treating the boundary as stale would refuse a
|
|
121
128
|
// perfectly good build on a coarse-resolution filesystem.
|
|
122
129
|
if (sourceMtime.mtimeMs !== null && sourceMtime.mtimeMs > buildMtime.mtimeMs) {
|
|
123
|
-
return {
|
|
130
|
+
return planeCheckFinding({
|
|
124
131
|
check: "executor-build",
|
|
125
132
|
severity: "blocking",
|
|
126
133
|
message: "mcp_server/build/ is STALE — a source file under mcp_server/src is newer " +
|
|
127
134
|
`than mcp_server/build/index.js, so executors would run old code. ` +
|
|
128
135
|
`Run \`${PLANE_BUILD_REMEDIATION}\``,
|
|
129
|
-
|
|
136
|
+
remediation: PLANE_BUILD_FIX_REMEDIATION,
|
|
137
|
+
}, label, "mcp_server/build/ is stale — a source file is newer than the build entrypoint");
|
|
130
138
|
}
|
|
131
|
-
return
|
|
139
|
+
return planeCheckPassed("executor-build", label, "the executor build is present and current");
|
|
132
140
|
}
|
|
133
141
|
/**
|
|
134
142
|
* Fixed refusal prose for an unresolvable runtime re-exec target (BAPI-768).
|
|
@@ -153,13 +161,16 @@ export const PLANE_RUNTIME_ENTRYPOINT_REFUSAL = "the currently executing MCP bui
|
|
|
153
161
|
* degraded plane, it is no plane at all.
|
|
154
162
|
*/
|
|
155
163
|
export function checkPlaneRuntimeEntrypoint(resolution) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
164
|
+
const label = "Runtime re-exec entrypoint";
|
|
165
|
+
if (resolution.ok) {
|
|
166
|
+
return planeCheckPassed("runtime-entrypoint", label, "the executing build can re-enter itself");
|
|
167
|
+
}
|
|
168
|
+
return planeCheckFinding({
|
|
159
169
|
check: "runtime-entrypoint",
|
|
160
170
|
severity: "blocking",
|
|
161
171
|
message: PLANE_RUNTIME_ENTRYPOINT_REFUSAL,
|
|
162
|
-
|
|
172
|
+
remediation: PLANE_BUILD_FIX_REMEDIATION,
|
|
173
|
+
}, label, "the currently executing MCP build could not be re-entered");
|
|
163
174
|
}
|
|
164
175
|
function sanitize(err) {
|
|
165
176
|
const code = err?.code;
|
package/build/plane/cli.js
CHANGED
|
@@ -13,14 +13,15 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { randomUUID } from "crypto";
|
|
15
15
|
import { PLANE_ENTRYPOINT_ACTION, PLANE_ID_ENV_VAR, PLANE_MANIFEST_SCHEMA_VERSION, PLANE_OBSERVER_CHANNEL_TYPE_ENV, PLANE_OBSERVER_COMMAND, PLANE_OBSERVER_DESTINATION_ENV, PLANE_RUNTIME_ACTION, PLANE_RUNTIME_DIR, PLANE_RUNTIME_LOG_PATH, } from "./types.js";
|
|
16
|
+
import { validateExecutorClaimScope, } from "../executor/claim-scope.js";
|
|
16
17
|
import { PLANE_RUNTIME_ENTRYPOINT_REFUSAL } from "./build-freshness.js";
|
|
17
18
|
import { relativeLogPathFor } from "./manifest.js";
|
|
18
|
-
import { claimPlaneManifest } from "./manifest.js";
|
|
19
|
+
import { claimPlaneManifest, requestPlaneManifestLanes } from "./manifest.js";
|
|
19
20
|
import { runPlanePreflight } from "./preflight.js";
|
|
20
|
-
import { buildPlaneMemberRoster, resolvePlaneServerEndpoint } from "./member-roster.js";
|
|
21
|
+
import { buildPlaneControlPlaneRoster, buildPlaneExecutorLaneRoster, buildPlaneMemberRoster, buildPlaneObserverRoster, resolvePlaneServerEndpoint, } from "./member-roster.js";
|
|
21
22
|
import { getPlaneStatus, formatPlaneStatus } from "./status.js";
|
|
22
23
|
import { shutdownPlane, formatPlaneShutdown } from "./shutdown.js";
|
|
23
|
-
import { launchPlaneSupervisor, runPlaneRuntime, } from "./supervisor.js";
|
|
24
|
+
import { launchPlaneSupervisor, runPlaneRuntime, PLANE_RUNTIME_TWO_PHASE_FLAG, } from "./supervisor.js";
|
|
24
25
|
import { createMemberLogDeps, createPlaneClock, createPlaneExecFile, createPlaneFsDeps, createPlaneProcessDeps, createPlaneSpawn, probeAutomationHealth, probeTcpPort, registerSignalHandler, resolveHomedir, resolveHostname, resolveNodeExecutable, resolvePackageEntrypoint, } from "./defaults.js";
|
|
25
26
|
import { resolveBapiCredentials } from "../credential-store.js";
|
|
26
27
|
import { resolveRequiredStartTicketsRepoName } from "../start-tickets-repo.js";
|
|
@@ -72,8 +73,48 @@ export function parsePlaneArgs(argv) {
|
|
|
72
73
|
};
|
|
73
74
|
}
|
|
74
75
|
let executors = null;
|
|
76
|
+
// BAPI-1102 — the claim scope every executor lane will be started with.
|
|
77
|
+
// `--epic-run-id` is REPEATABLE and order-preserving, matching the executor's
|
|
78
|
+
// own semantics exactly; `--repo-wide` is valueless and once-only, matching
|
|
79
|
+
// `--executors`' precedent for a flag that may not be restated.
|
|
80
|
+
const epicRunIds = [];
|
|
81
|
+
let repoWide = false;
|
|
82
|
+
let twoPhase = false;
|
|
75
83
|
for (let i = 0; i < rest.length; i += 1) {
|
|
76
84
|
const arg = rest[i];
|
|
85
|
+
if (arg === "--epic-run-id") {
|
|
86
|
+
const raw = rest[i + 1];
|
|
87
|
+
i += 1;
|
|
88
|
+
if (raw === undefined) {
|
|
89
|
+
return { kind: "error", message: "--epic-run-id requires a value" };
|
|
90
|
+
}
|
|
91
|
+
if (raw.trim().length === 0) {
|
|
92
|
+
return { kind: "error", message: "--epic-run-id requires a non-blank value" };
|
|
93
|
+
}
|
|
94
|
+
epicRunIds.push(raw.trim());
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// BAPI-1102 — private, and accepted ONLY on the detached-runtime action, so
|
|
98
|
+
// it cannot appear on a public `plane up`. It selects the two-phase
|
|
99
|
+
// lifecycle; the claim scope then arrives through the manifest, which is why
|
|
100
|
+
// the scope requirement below is lifted for it.
|
|
101
|
+
if (arg === PLANE_RUNTIME_TWO_PHASE_FLAG && action === PLANE_RUNTIME_ACTION) {
|
|
102
|
+
if (twoPhase) {
|
|
103
|
+
return {
|
|
104
|
+
kind: "error",
|
|
105
|
+
message: `${PLANE_RUNTIME_TWO_PHASE_FLAG} may be supplied only once`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
twoPhase = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (arg === "--repo-wide") {
|
|
112
|
+
if (repoWide) {
|
|
113
|
+
return { kind: "error", message: "--repo-wide may be supplied only once" };
|
|
114
|
+
}
|
|
115
|
+
repoWide = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
77
118
|
if (arg === "--executors") {
|
|
78
119
|
if (executors !== null) {
|
|
79
120
|
return { kind: "error", message: "--executors may be supplied only once" };
|
|
@@ -99,7 +140,43 @@ export function parsePlaneArgs(argv) {
|
|
|
99
140
|
}
|
|
100
141
|
return { kind: "error", message: `unexpected argument '${arg}' for \`plane ${action}\`` };
|
|
101
142
|
}
|
|
102
|
-
const
|
|
143
|
+
const resolvedExecutors = executors ?? PLANE_DEFAULT_EXECUTORS;
|
|
144
|
+
// BAPI-1102 — the claim-scope rule, enforced through the EXECUTOR's own shared
|
|
145
|
+
// validator and rendered with the executor's own wording. Refusing here, in the
|
|
146
|
+
// pure parser, is what makes "a lane is never spawned unscoped" structural: the
|
|
147
|
+
// coordinator below cannot touch the filesystem, open a socket, resolve a
|
|
148
|
+
// credential, or spawn a child until this function has returned an `up`.
|
|
149
|
+
//
|
|
150
|
+
// Spawning the lanes anyway would be the worse failure and is the one this
|
|
151
|
+
// replaces: since BAPI-1026 an unscoped executor exits at startup, so `plane up`
|
|
152
|
+
// would report a member that "failed to start" and roll the whole plane back —
|
|
153
|
+
// a confusing, expensive way to say "you forgot a flag".
|
|
154
|
+
//
|
|
155
|
+
// A public `plane up` always resolves to at least one lane (`--executors` has a
|
|
156
|
+
// floor of 1 and no `--executors 0` exists), so the scope is effectively
|
|
157
|
+
// mandatory here. The zero-lane control-plane phase is INTERNAL and never
|
|
158
|
+
// reaches this parser.
|
|
159
|
+
// BAPI-1102 — the two-phase runtime is the ONE invocation that legitimately
|
|
160
|
+
// carries no scope in its argv: it has not been told which run it serves yet,
|
|
161
|
+
// and will read the scope from the manifest once the composed caller records
|
|
162
|
+
// it. Every other path resolves to at least one lane and must be scoped.
|
|
163
|
+
if (twoPhase) {
|
|
164
|
+
if (epicRunIds.length > 0 || repoWide) {
|
|
165
|
+
return {
|
|
166
|
+
kind: "error",
|
|
167
|
+
message: `${PLANE_RUNTIME_TWO_PHASE_FLAG} takes its claim scope from the manifest, not from argv`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return { kind: "runtime", options: { executors: resolvedExecutors, twoPhase: true } };
|
|
171
|
+
}
|
|
172
|
+
const scope = validateExecutorClaimScope({ epicRunIds, repoWide });
|
|
173
|
+
if (!scope.ok) {
|
|
174
|
+
return { kind: "error", message: scope.message };
|
|
175
|
+
}
|
|
176
|
+
const options = {
|
|
177
|
+
executors: resolvedExecutors,
|
|
178
|
+
claimScope: scope.scope,
|
|
179
|
+
};
|
|
103
180
|
return action === "up" ? { kind: "up", options } : { kind: "runtime", options };
|
|
104
181
|
}
|
|
105
182
|
/**
|
|
@@ -138,8 +215,14 @@ export function getPlaneUsage() {
|
|
|
138
215
|
"is already live, or to recover after a composed bring-up reported a live plane it",
|
|
139
216
|
"did not wind down. Nothing about this command's behavior has changed.",
|
|
140
217
|
"",
|
|
218
|
+
"Every executor lane needs a CLAIM SCOPE, and `plane up` refuses without one",
|
|
219
|
+
"(BAPI-1026): an executor started with no scope exits immediately, so spawning",
|
|
220
|
+
"lanes unscoped would report a member crash instead of a missing flag. Pass",
|
|
221
|
+
"--epic-run-id <id> (repeatable) to serve specific runs, or --repo-wide.",
|
|
222
|
+
"",
|
|
141
223
|
"Actions:",
|
|
142
|
-
" plane up [--executors N]
|
|
224
|
+
" plane up <scope> [--executors N]",
|
|
225
|
+
" Preflight, then start the plane. Refuses as a",
|
|
143
226
|
" whole if any spawn-blocking check fails.",
|
|
144
227
|
" plane status Read the manifest and report each member's state.",
|
|
145
228
|
" plane down Stop the bound server-side epic run FIRST (BAPI-872),",
|
|
@@ -150,6 +233,11 @@ export function getPlaneUsage() {
|
|
|
150
233
|
"",
|
|
151
234
|
"Options:",
|
|
152
235
|
` --executors N Executor lanes to start (default ${PLANE_DEFAULT_EXECUTORS}, max ${PLANE_MAX_EXECUTORS}).`,
|
|
236
|
+
" --epic-run-id <id> Claim scope: serve this epic run. REPEATABLE, and",
|
|
237
|
+
" every id is passed to every lane in the order given.",
|
|
238
|
+
" Mutually exclusive with --repo-wide.",
|
|
239
|
+
" --repo-wide Claim scope: deliberately claim repository-wide.",
|
|
240
|
+
" Mutually exclusive with --epic-run-id.",
|
|
153
241
|
" -h, --help Show this help.",
|
|
154
242
|
"",
|
|
155
243
|
"Behavior you can rely on:",
|
|
@@ -166,6 +254,11 @@ export function getPlaneUsage() {
|
|
|
166
254
|
" * The reconciler worker is not reported ready until it has published a FRESH",
|
|
167
255
|
" durable heartbeat, read back through GET /automation/health. `plane up`",
|
|
168
256
|
" cannot print a success banner over a reconciler that never ticked.",
|
|
257
|
+
" * Executor lanes start SCOPED. Every lane is spawned with the claim scope you",
|
|
258
|
+
" passed, and each is gated on its OWN per-instance heartbeat, so a lane that",
|
|
259
|
+
" exits on a rejected credential or an unbuilt bundle is never reported ready.",
|
|
260
|
+
" * The observer starts LAST, after every executor lane's gate has closed, so",
|
|
261
|
+
" its first sweep cannot alert on lanes that have not published yet.",
|
|
169
262
|
"",
|
|
170
263
|
`Runtime artifacts (manifest + per-member logs) live under ${PLANE_RUNTIME_DIR}/.`,
|
|
171
264
|
].join("\n");
|
|
@@ -230,9 +323,9 @@ export async function runPlaneCli(argv, overrides = {}) {
|
|
|
230
323
|
case "down":
|
|
231
324
|
return await runDownAction(sinks, overrides);
|
|
232
325
|
case "up":
|
|
233
|
-
return await runUpAction(parsed.options
|
|
326
|
+
return await runUpAction(parsed.options, sinks, overrides);
|
|
234
327
|
case "runtime":
|
|
235
|
-
return await runRuntimeAction(parsed.options
|
|
328
|
+
return await runRuntimeAction(parsed.options, overrides);
|
|
236
329
|
// Dispatched here, ahead of every action that touches the repository, so
|
|
237
330
|
// the diagnostic answers from an empty directory with no credentials.
|
|
238
331
|
case "entrypoint":
|
|
@@ -344,7 +437,8 @@ async function stopBoundEpicRun(epicRunId, manifest) {
|
|
|
344
437
|
};
|
|
345
438
|
}
|
|
346
439
|
}
|
|
347
|
-
async function runUpAction(
|
|
440
|
+
async function runUpAction(options, sinks, overrides) {
|
|
441
|
+
const executors = options.executors;
|
|
348
442
|
const repoRoot = overrides.cwd ?? process.cwd();
|
|
349
443
|
const env = overrides.env ?? process.env;
|
|
350
444
|
const preflight = overrides.preflight
|
|
@@ -354,19 +448,20 @@ async function runUpAction(executors, sinks, overrides) {
|
|
|
354
448
|
sinks.stdout(formatDiagnostic(diagnostic));
|
|
355
449
|
}
|
|
356
450
|
if (!preflight.ok) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
for (const diagnostic of preflight.diagnostics.filter((d) => d.severity === "blocking")) {
|
|
360
|
-
sinks.stderr(formatDiagnostic(diagnostic));
|
|
361
|
-
}
|
|
362
|
-
sinks.stderr("");
|
|
363
|
-
sinks.stderr(`Fix every item above and retry. ${PLANE_NOTHING_STARTED}.`);
|
|
451
|
+
for (const line of formatPlanePreflightRefusal(preflight))
|
|
452
|
+
sinks.stderr(line);
|
|
364
453
|
return 1;
|
|
365
454
|
}
|
|
366
455
|
const planeId = (overrides.newPlaneId ?? randomUUID)();
|
|
456
|
+
// Public `plane up` sequences BOTH phases in one invocation and stays attached
|
|
457
|
+
// for the plane's lifetime, exactly as it always has. The two-phase lifecycle
|
|
458
|
+
// is what the COMPOSED caller needs, not what a hand-run `plane up` needs, and
|
|
459
|
+
// giving the public command a second shape would make the manual recovery path
|
|
460
|
+
// behave differently from the one operators already know.
|
|
461
|
+
const launchOptions = { ...(options.claimScope ? { claimScope: options.claimScope } : {}) };
|
|
367
462
|
const launch = overrides.launch
|
|
368
|
-
? await overrides.launch(preflight, executors)
|
|
369
|
-
: await launchPlane(preflight, executors, planeId, sinks, env);
|
|
463
|
+
? await overrides.launch(preflight, executors, launchOptions)
|
|
464
|
+
: await launchPlane(preflight, executors, planeId, sinks, env, launchOptions);
|
|
370
465
|
if (!launch.ok) {
|
|
371
466
|
for (const line of formatPlaneLaunchFailure(launch))
|
|
372
467
|
sinks.stderr(line);
|
|
@@ -374,6 +469,24 @@ async function runUpAction(executors, sinks, overrides) {
|
|
|
374
469
|
}
|
|
375
470
|
return launch.exitCode;
|
|
376
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* The aggregate preflight refusal, as lines.
|
|
474
|
+
*
|
|
475
|
+
* Extracted by BAPI-1102 so the composed control-plane launch renders the SAME
|
|
476
|
+
* refusal `plane up` does. A second rendering would have been the fourth place
|
|
477
|
+
* in this flow where two paths report the same blocking check differently.
|
|
478
|
+
*/
|
|
479
|
+
export function formatPlanePreflightRefusal(preflight) {
|
|
480
|
+
return [
|
|
481
|
+
"",
|
|
482
|
+
"plane up REFUSED — preflight found blocking problems:",
|
|
483
|
+
...preflight.diagnostics
|
|
484
|
+
.filter((d) => d.severity === "blocking")
|
|
485
|
+
.map((d) => formatDiagnostic(d)),
|
|
486
|
+
"",
|
|
487
|
+
`Fix every item above and retry. ${PLANE_NOTHING_STARTED}.`,
|
|
488
|
+
];
|
|
489
|
+
}
|
|
377
490
|
/** Stable label so a startup death is greppable in a terminal scrollback. */
|
|
378
491
|
export const PLANE_STARTUP_FAILED = "plane up FAILED — the runtime never came up";
|
|
379
492
|
/**
|
|
@@ -416,17 +529,24 @@ export function formatPlaneLaunchFailure(launch) {
|
|
|
416
529
|
return lines;
|
|
417
530
|
}
|
|
418
531
|
/** Claim the manifest, print the banner, then run the detached supervisor. */
|
|
419
|
-
async function launchPlane(preflight, executors, planeId, sinks, env) {
|
|
532
|
+
async function launchPlane(preflight, executors, planeId, sinks, env, launchOptions = {}) {
|
|
420
533
|
const fs = createPlaneFsDeps();
|
|
421
534
|
const clock = createPlaneClock();
|
|
422
535
|
const context = preflight.context;
|
|
423
536
|
const nodeExecutable = resolveNodeExecutable();
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
537
|
+
// BAPI-1102 — in two-phase mode the manifest is claimed listing only the
|
|
538
|
+
// CONTROL-PLANE members. Listing lanes that have not been asked to start would
|
|
539
|
+
// make `plane status` report members that do not exist, and `plane down` would
|
|
540
|
+
// have nothing to signal for them.
|
|
541
|
+
const roster = launchOptions.twoPhase
|
|
542
|
+
? buildPlaneControlPlaneRoster({ context, parentEnv: env, nodeExecutable })
|
|
543
|
+
: buildPlaneMemberRoster({
|
|
544
|
+
context,
|
|
545
|
+
executors,
|
|
546
|
+
parentEnv: env,
|
|
547
|
+
nodeExecutable,
|
|
548
|
+
...(launchOptions.claimScope ? { claimScope: launchOptions.claimScope } : {}),
|
|
549
|
+
});
|
|
430
550
|
const timestamp = clock.now().toISOString();
|
|
431
551
|
const claim = await claimPlaneManifest({
|
|
432
552
|
manifest: {
|
|
@@ -455,7 +575,13 @@ async function launchPlane(preflight, executors, planeId, sinks, env) {
|
|
|
455
575
|
// report — `startup: null` is the honest shape, not a placeholder.
|
|
456
576
|
if (!claim.ok)
|
|
457
577
|
return { ok: false, error: claim.message, startup: null };
|
|
458
|
-
return launchPlaneSupervisor({
|
|
578
|
+
return launchPlaneSupervisor({
|
|
579
|
+
context,
|
|
580
|
+
manifest: claim.manifest,
|
|
581
|
+
executors,
|
|
582
|
+
...(launchOptions.claimScope ? { claimScope: launchOptions.claimScope } : {}),
|
|
583
|
+
...(launchOptions.twoPhase ? { twoPhase: true } : {}),
|
|
584
|
+
}, {
|
|
459
585
|
fs,
|
|
460
586
|
clock,
|
|
461
587
|
spawn: createPlaneSpawn(),
|
|
@@ -470,10 +596,19 @@ async function launchPlane(preflight, executors, planeId, sinks, env) {
|
|
|
470
596
|
// The banner is now a consequence of the runtime's readiness handshake
|
|
471
597
|
// rather than of the launcher reaching the end of its own function. That
|
|
472
598
|
// is the whole point: `spawn()` succeeding never meant the plane was up.
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
})
|
|
599
|
+
//
|
|
600
|
+
// In two-phase mode the member count is not known until the lanes are
|
|
601
|
+
// requested, so the banner reports the count the plane ENDED with.
|
|
602
|
+
onReady: ({ supervisorPid, supervisorPgid }) => {
|
|
603
|
+
printStartupBanner(sinks, context, launchOptions.twoPhase ? roster.length + executors + 1 : roster.length, executors, { supervisorPid, supervisorPgid });
|
|
604
|
+
launchOptions.onPlaneReady?.({ planeId });
|
|
605
|
+
},
|
|
606
|
+
...(launchOptions.onControlPlaneReady
|
|
607
|
+
? { onControlPlaneReady: () => launchOptions.onControlPlaneReady?.({ planeId }) }
|
|
608
|
+
: {}),
|
|
609
|
+
...(launchOptions.onLanesFailed
|
|
610
|
+
? { onLanesFailed: () => launchOptions.onLanesFailed?.({ planeId }) }
|
|
611
|
+
: {}),
|
|
477
612
|
});
|
|
478
613
|
}
|
|
479
614
|
function printStartupBanner(sinks, context, memberCount, executors, launch) {
|
|
@@ -490,8 +625,107 @@ function printStartupBanner(sinks, context, memberCount, executors, launch) {
|
|
|
490
625
|
sinks.stdout(" crash policy members are NOT restarted; a member exit is reported loudly.");
|
|
491
626
|
sinks.stdout("");
|
|
492
627
|
}
|
|
628
|
+
/**
|
|
629
|
+
* Start the control plane (server + reconciler) and RESOLVE WHEN IT IS READY.
|
|
630
|
+
*
|
|
631
|
+
* The composed caller needs the server up so it can create a run, and needs
|
|
632
|
+
* control back to do it. Both phases still run inside the one detached runtime;
|
|
633
|
+
* this only waits for the first.
|
|
634
|
+
*
|
|
635
|
+
* Preflight is INJECTED rather than re-run, so the port that was probed is the
|
|
636
|
+
* port that gets launched and the composed caller's refusal and this launch can
|
|
637
|
+
* never disagree about the world.
|
|
638
|
+
*/
|
|
639
|
+
export async function launchPlaneControlPlane(args) {
|
|
640
|
+
if (!args.preflight.ok) {
|
|
641
|
+
return { ok: false, lines: formatPlanePreflightRefusal(args.preflight) };
|
|
642
|
+
}
|
|
643
|
+
const preflight = args.preflight;
|
|
644
|
+
const planeId = (args.newPlaneId ?? randomUUID)();
|
|
645
|
+
let signalControlReady;
|
|
646
|
+
const controlReady = new Promise((resolve) => {
|
|
647
|
+
signalControlReady = resolve;
|
|
648
|
+
});
|
|
649
|
+
let signalPlaneReady;
|
|
650
|
+
const planeReady = new Promise((resolve) => {
|
|
651
|
+
signalPlaneReady = resolve;
|
|
652
|
+
});
|
|
653
|
+
// BAPI-1102 — the held-failure signal. Raced alongside readiness and the
|
|
654
|
+
// runtime's lifetime because the held path resolves NEITHER of those: the
|
|
655
|
+
// plane never goes ready and the runtime never exits, so without this the
|
|
656
|
+
// caller would wait on a plane that is alive and never going to start a lane.
|
|
657
|
+
let signalLanesFailed;
|
|
658
|
+
const lanesFailed = new Promise((resolve) => {
|
|
659
|
+
signalLanesFailed = resolve;
|
|
660
|
+
});
|
|
661
|
+
const lifetime = launchPlane(preflight, args.executors, planeId, args.sinks, args.env, {
|
|
662
|
+
twoPhase: true,
|
|
663
|
+
onControlPlaneReady: () => signalControlReady?.(),
|
|
664
|
+
onPlaneReady: () => signalPlaneReady?.(),
|
|
665
|
+
onLanesFailed: () => signalLanesFailed?.(),
|
|
666
|
+
});
|
|
667
|
+
// Whichever comes first: the control plane going ready, or the launch failing.
|
|
668
|
+
// Racing them is what turns a runtime that died during phase one into a
|
|
669
|
+
// refusal instead of a hang — the ready callback would simply never fire.
|
|
670
|
+
const outcome = await Promise.race([
|
|
671
|
+
controlReady.then(() => ({ kind: "ready" })),
|
|
672
|
+
lifetime.then((result) => ({ kind: "settled", result })),
|
|
673
|
+
]);
|
|
674
|
+
if (outcome.kind === "settled") {
|
|
675
|
+
// Settled before ready ⇒ it never came up. A successful settle here would
|
|
676
|
+
// mean a plane that went up and down before reporting readiness, which is
|
|
677
|
+
// still not a plane the caller can create a run against.
|
|
678
|
+
const lines = outcome.result.ok
|
|
679
|
+
? ["plane up FAILED: the runtime exited before the control plane was ready"]
|
|
680
|
+
: formatPlaneLaunchFailure(outcome.result);
|
|
681
|
+
return { ok: false, lines };
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
ok: true,
|
|
685
|
+
planeId,
|
|
686
|
+
lifetime,
|
|
687
|
+
awaitPlaneReady: async () => {
|
|
688
|
+
const settled = await Promise.race([
|
|
689
|
+
planeReady.then(() => ({ kind: "ready" })),
|
|
690
|
+
lanesFailed.then(() => ({ kind: "held" })),
|
|
691
|
+
lifetime.then((result) => ({ kind: "settled", result })),
|
|
692
|
+
]);
|
|
693
|
+
if (settled.kind === "ready")
|
|
694
|
+
return { ok: true };
|
|
695
|
+
if (settled.kind === "held") {
|
|
696
|
+
// The runtime already wrote the operator-facing explanation to stderr as
|
|
697
|
+
// it happened, and it is STILL RUNNING. Repeating its text here would
|
|
698
|
+
// print the same paragraph twice; the caller adds only what it alone
|
|
699
|
+
// knows — what became of the run.
|
|
700
|
+
return {
|
|
701
|
+
ok: false,
|
|
702
|
+
lines: ["the plane's executor lanes did not start; its control plane is still running"],
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
return {
|
|
706
|
+
ok: false,
|
|
707
|
+
lines: settled.result.ok
|
|
708
|
+
? ["the plane runtime exited before its executor lanes became ready"]
|
|
709
|
+
: formatPlaneLaunchFailure(settled.result),
|
|
710
|
+
};
|
|
711
|
+
},
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Request phase two: bind the run and record the scope its lanes will use.
|
|
716
|
+
*
|
|
717
|
+
* Writes the request into the manifest and returns. The DETACHED RUNTIME
|
|
718
|
+
* performs the spawn and the readiness gating — this process may well have
|
|
719
|
+
* exited by then, which is precisely why the request is durable state rather
|
|
720
|
+
* than a function call.
|
|
721
|
+
*/
|
|
722
|
+
export async function requestPlaneScopedLanes(args) {
|
|
723
|
+
const result = await requestPlaneManifestLanes(args.repoRoot, args.planeId, args.epicRunId, args.claimScope, createPlaneFsDeps());
|
|
724
|
+
return result.ok ? { ok: true } : { ok: false, reason: result.reason, message: result.message };
|
|
725
|
+
}
|
|
493
726
|
/** The private detached-runtime action. Never part of the documented surface. */
|
|
494
|
-
async function runRuntimeAction(
|
|
727
|
+
async function runRuntimeAction(options, overrides) {
|
|
728
|
+
const executors = options.executors;
|
|
495
729
|
const repoRoot = overrides.cwd ?? process.cwd();
|
|
496
730
|
if (overrides.runtime)
|
|
497
731
|
return overrides.runtime(repoRoot, executors);
|
|
@@ -533,12 +767,19 @@ async function runRuntimeAction(executors, overrides) {
|
|
|
533
767
|
sinks.stderr(`${PLANE_NOTHING_STARTED}.`);
|
|
534
768
|
return 1;
|
|
535
769
|
}
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
770
|
+
const nodeExecutable = resolveNodeExecutable();
|
|
771
|
+
const rosterBase = { context: preflight.context, parentEnv: env, nodeExecutable };
|
|
772
|
+
// BAPI-1102 — in two-phase mode the runtime is handed the CONTROL-PLANE roster
|
|
773
|
+
// and the two builders it will need later, rather than a finished roster it
|
|
774
|
+
// cannot build yet: the lane roster depends on a claim scope that does not
|
|
775
|
+
// exist until the run does.
|
|
776
|
+
const roster = options.twoPhase
|
|
777
|
+
? buildPlaneControlPlaneRoster(rosterBase)
|
|
778
|
+
: buildPlaneMemberRoster({
|
|
779
|
+
...rosterBase,
|
|
780
|
+
executors,
|
|
781
|
+
...(options.claimScope ? { claimScope: options.claimScope } : {}),
|
|
782
|
+
});
|
|
542
783
|
const result = await runPlaneRuntime(repoRoot, roster, {
|
|
543
784
|
fs: createPlaneFsDeps(),
|
|
544
785
|
clock: createPlaneClock(),
|
|
@@ -560,6 +801,14 @@ async function runRuntimeAction(executors, overrides) {
|
|
|
560
801
|
hostname: resolveHostname,
|
|
561
802
|
selfPid: process.pid,
|
|
562
803
|
planeId,
|
|
804
|
+
...(options.twoPhase
|
|
805
|
+
? {
|
|
806
|
+
twoPhase: {
|
|
807
|
+
buildLaneRoster: (scope) => buildPlaneExecutorLaneRoster({ ...rosterBase, executors, claimScope: scope }),
|
|
808
|
+
observerRoster: buildPlaneObserverRoster(rosterBase),
|
|
809
|
+
},
|
|
810
|
+
}
|
|
811
|
+
: {}),
|
|
563
812
|
});
|
|
564
813
|
return result.exitCode;
|
|
565
814
|
}
|