@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.
Files changed (52) hide show
  1. package/README.md +121 -15
  2. package/build/agent-launchers/claude.js +3 -3
  3. package/build/agent-launchers/prompt.js +8 -11
  4. package/build/base-ref.js +33 -9
  5. package/build/bounded-wait.js +174 -0
  6. package/build/commands.generated.js +7 -5
  7. package/build/conductor/bridge-api-client.js +97 -8
  8. package/build/conductor/cli.js +23 -0
  9. package/build/conductor/doctor.js +428 -5
  10. package/build/conductor/epic-runtime.js +133 -97
  11. package/build/conductor/install-doctor.js +65 -656
  12. package/build/conductor/readiness-cli.js +152 -0
  13. package/build/conductor/readiness-sections.js +666 -0
  14. package/build/conductor/readiness.js +795 -0
  15. package/build/conductor/run-branch.js +137 -0
  16. package/build/conductor/test-run-branch-vectors.js +165 -0
  17. package/build/conductor/tools.js +56 -3
  18. package/build/conductor-bin.js +21 -17
  19. package/build/doctor.js +68 -1
  20. package/build/drive-epic.js +287 -51
  21. package/build/executor/claim-scope.js +104 -0
  22. package/build/executor/cli.js +14 -25
  23. package/build/executor/env-file-guard.js +82 -3
  24. package/build/executor/job-runner.js +60 -0
  25. package/build/index.js +4496 -4697
  26. package/build/install-doctor.js +154 -2
  27. package/build/local-artifact-storage.js +130 -0
  28. package/build/pipelines.generated.js +17 -10
  29. package/build/plane/alembic-head.js +40 -11
  30. package/build/plane/build-freshness.js +22 -11
  31. package/build/plane/cli.js +285 -36
  32. package/build/plane/manifest.js +209 -1
  33. package/build/plane/member-roster.js +70 -0
  34. package/build/plane/preflight.js +363 -48
  35. package/build/plane/shutdown.js +14 -1
  36. package/build/plane/status.js +35 -1
  37. package/build/plane/supervisor.js +546 -164
  38. package/build/plane/types.js +61 -2
  39. package/build/polling-policy.js +72 -0
  40. package/build/readiness-check.js +412 -0
  41. package/build/readme.generated.js +1 -1
  42. package/build/review-generation.js +219 -0
  43. package/build/run-unit-tests-launcher.js +5 -0
  44. package/build/setup-epic.js +514 -23
  45. package/build/ticket-key-utils.js +4 -3
  46. package/build/ticket-review-artifact-gate.js +461 -0
  47. package/build/upgrade-cli.js +5 -26
  48. package/build/version.generated.js +3 -3
  49. package/docs/install/mcp-tool-integrations.md +23 -1
  50. package/package.json +2 -2
  51. package/pipelines/{full-automation.json → idea-to-pr.json} +1 -1
  52. 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
- return unverified(`\`alembic heads\` did not complete (${heads.error})`);
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 null;
94
- return {
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
- function unverified(detail) {
103
- return {
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}: ${detail}. Startup continues — verify with ` +
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 null;
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
- if (resolution.ok)
157
- return null;
158
- return {
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;
@@ -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 options = { executors: executors ?? PLANE_DEFAULT_EXECUTORS };
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] Preflight, then start the plane. Refuses as a",
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.executors, sinks, overrides);
326
+ return await runUpAction(parsed.options, sinks, overrides);
234
327
  case "runtime":
235
- return await runRuntimeAction(parsed.options.executors, overrides);
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(executors, sinks, overrides) {
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
- sinks.stderr("");
358
- sinks.stderr("plane up REFUSED — preflight found blocking problems:");
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
- const roster = buildPlaneMemberRoster({
425
- context,
426
- executors,
427
- parentEnv: env,
428
- nodeExecutable,
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({ context, manifest: claim.manifest, executors }, {
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
- onReady: ({ supervisorPid, supervisorPgid }) => printStartupBanner(sinks, context, roster.length, executors, {
474
- supervisorPid,
475
- supervisorPgid,
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(executors, overrides) {
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 roster = buildPlaneMemberRoster({
537
- context: preflight.context,
538
- executors,
539
- parentEnv: env,
540
- nodeExecutable: resolveNodeExecutable(),
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
  }