@bridge_gpt/mcp-server 0.2.53 → 0.2.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +116 -56
  2. package/build/agent-guidance.generated.js +3 -0
  3. package/build/agent-launchers/claude-executor-adapter.js +3 -0
  4. package/build/agent-launchers/claude.js +3 -3
  5. package/build/agent-launchers/prompt.js +8 -11
  6. package/build/agent-notes.js +178 -0
  7. package/build/agent-registry.js +5 -2
  8. package/build/agent-utils.js +58 -0
  9. package/build/agents.generated.js +1 -1
  10. package/build/base-ref.js +33 -9
  11. package/build/bounded-wait.js +174 -0
  12. package/build/codex-skill-adapter.js +55 -0
  13. package/build/commands.generated.js +6 -5
  14. package/build/conductor/bridge-api-client.js +235 -14
  15. package/build/conductor/bring-up-facts.js +187 -0
  16. package/build/conductor/claude-hook.js +7 -5
  17. package/build/conductor/cli.js +28 -0
  18. package/build/conductor/doctor.js +80 -9
  19. package/build/conductor/epic-implementer-cli.js +1298 -0
  20. package/build/conductor/epic-runtime.js +134 -98
  21. package/build/conductor/errors.js +2 -2
  22. package/build/conductor/git-ci-types.js +1 -1
  23. package/build/conductor/git-hooks.js +28 -14
  24. package/build/conductor/install-doctor.js +11 -5
  25. package/build/conductor/readiness-cli.js +10 -10
  26. package/build/conductor/readiness-sections.js +58 -9
  27. package/build/conductor/readiness.js +120 -4
  28. package/build/conductor/recovery-cli.js +289 -10
  29. package/build/conductor/recovery-operations.js +125 -2
  30. package/build/conductor/repair-contract.js +58 -0
  31. package/build/conductor/run-branch.js +137 -0
  32. package/build/conductor/store.js +2 -2
  33. package/build/conductor/supervisor-runtime.js +1 -1
  34. package/build/conductor/test-run-branch-vectors.js +165 -0
  35. package/build/conductor-bin.js +2 -139
  36. package/build/conductor-claude-hook-bin.js +2 -2
  37. package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
  38. package/build/conductor-removed-stub-bin.js +30 -0
  39. package/build/docs.generated.js +1 -1
  40. package/build/doctor.js +145 -18
  41. package/build/drive-epic.js +752 -90
  42. package/build/epic-implementer-bin.js +145 -0
  43. package/build/epic-implementer-bundle-cli.js +264 -0
  44. package/build/epic-implementer-claude-hook-bin.js +3 -0
  45. package/build/epic-integration-pr.js +5 -3
  46. package/build/executor/claim-scope.js +104 -0
  47. package/build/executor/cli.js +14 -25
  48. package/build/executor/env-file-guard.js +82 -3
  49. package/build/executor/env.js +6 -0
  50. package/build/executor/interrupted-worktree.js +60 -0
  51. package/build/executor/job-errors.js +45 -0
  52. package/build/executor/job-runner.js +334 -9
  53. package/build/executor/job-types.js +25 -9
  54. package/build/executor/merge-tree-classifier.js +171 -0
  55. package/build/executor/reconcile-overlap-governance.js +129 -0
  56. package/build/executor/reconcile-overlap-job.js +989 -0
  57. package/build/executor/reconcile-overlap-types.js +14 -0
  58. package/build/executor/spawn-job-driver.js +1 -0
  59. package/build/executor/types.js +2 -0
  60. package/build/executor/worker-finalization.js +25 -2
  61. package/build/executor/worker-guard-hook.js +15 -7
  62. package/build/implement-epic/bridge-client.js +773 -0
  63. package/build/implement-epic/checkpoint-store.js +542 -0
  64. package/build/implement-epic/cli.js +3158 -0
  65. package/build/implement-epic/cut-protocol.js +392 -0
  66. package/build/implement-epic/lock.js +302 -0
  67. package/build/implement-epic/pr-state.js +286 -0
  68. package/build/implement-epic/spawn.js +113 -0
  69. package/build/index.js +657 -481
  70. package/build/init.js +72 -8
  71. package/build/install-bridge-conductor.js +5 -5
  72. package/build/install-bridge.js +403 -70
  73. package/build/local-artifact-storage.js +130 -0
  74. package/build/mcp-host-config.js +22 -60
  75. package/build/mcp-host-entry-adapter.js +18 -0
  76. package/build/mcp-host-targets.js +1 -21
  77. package/build/merge-pull-request.js +1 -1
  78. package/build/pipelines.generated.js +23 -16
  79. package/build/plan-epic-conductor-eligibility.js +1 -1
  80. package/build/plane/cli.js +321 -41
  81. package/build/plane/manifest.js +209 -1
  82. package/build/plane/member-roster.js +70 -0
  83. package/build/plane/preflight.js +128 -12
  84. package/build/plane/shutdown.js +18 -5
  85. package/build/plane/status.js +35 -1
  86. package/build/plane/supervisor.js +546 -164
  87. package/build/plane/types.js +25 -2
  88. package/build/polling-policy.js +72 -0
  89. package/build/readiness-check.js +3 -3
  90. package/build/readme.generated.js +1 -1
  91. package/build/review-generation.js +219 -0
  92. package/build/run-unit-tests-launcher.js +6 -1
  93. package/build/setup-epic.js +567 -38
  94. package/build/start-tickets-conductor.js +8 -7
  95. package/build/ticket-key-utils.js +4 -3
  96. package/build/ticket-review-artifact-gate.js +461 -0
  97. package/build/upgrade-cli.js +5 -26
  98. package/build/version.generated.js +3 -3
  99. package/build/worker-guard-hook-bin.js +1 -1
  100. package/docs/CONDUCTOR.md +8 -6
  101. package/docs/install/mcp-tool-integrations.md +23 -1
  102. package/package.json +5 -3
  103. package/pipelines/review-ticket.json +17 -4
@@ -4,7 +4,7 @@
4
4
  * Runs once the epic decomposition's child manifest is frozen and every
5
5
  * sub-task's exploration text is written, so this module can predict — before
6
6
  * any conductor worker is spawned — which children are likely to trip the
7
- * runtime workflow-file merge guard (`api/library/vcs/conductor_merge_service.py`)
7
+ * runtime workflow-file merge guard (`api/library/vcs/epic_implementer_merge_service.py`)
8
8
  * and which of those additionally touch `claude-review.yml` itself, where
9
9
  * automated review cannot run at all (BAPI-941's supply-chain preflight refuses
10
10
  * to review a workflow that differs from the base branch's copy).
@@ -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":
@@ -309,7 +402,7 @@ async function runDownAction(sinks, overrides) {
309
402
  * Resolves Bridge access scoped to the MANIFEST's own repository root (never
310
403
  * the process's `cwd`, which is not necessarily the same directory) and calls
311
404
  * the shared `stopEpicRunRecovery` — the identical stop semantics
312
- * `conductor stop-run` uses, so the two surfaces can never drift apart.
405
+ * `epic-implementer stop-run` uses, so the two surfaces can never drift apart.
313
406
  *
314
407
  * Never throws. `shutdownPlane` awaits this directly with no try/catch of its
315
408
  * own, so a credential, transport, or unexpected failure here is converted
@@ -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,141 @@ 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
+ }
726
+ /**
727
+ * The detached runtime's own output sinks (BAPI-1153).
728
+ *
729
+ * The runtime's stdout and stderr are pipes to the LAUNCHER, and the launcher is
730
+ * allowed to exit long before the plane does — `drive-epic` returns once its run
731
+ * is created, and a composed bring-up that was refused leaves its control plane
732
+ * running for a later attach. Once the reading end is gone, Node turns the
733
+ * runtime's next write into an asynchronous EPIPE that, unhandled, terminates
734
+ * the process: the plane lost its supervisor at its first event after the
735
+ * launcher left — a lane request served by attach, a member exit, a shutdown
736
+ * signal. The launcher side already refuses to let a broken feed take down a
737
+ * healthy plane; this is the same rule on the runtime side. The feed is
738
+ * advisory — the manifest and the member logs are the durable record — so a
739
+ * write that cannot be delivered is dropped, never fatal.
740
+ */
741
+ function createDetachedRuntimeSinks() {
742
+ for (const stream of [process.stdout, process.stderr]) {
743
+ stream.on("error", () => {
744
+ /* the launcher that was reading this feed has gone; the plane stays up */
745
+ });
746
+ }
747
+ const write = (stream, line) => {
748
+ try {
749
+ stream.write(`${line}\n`);
750
+ }
751
+ catch {
752
+ /* a destroyed feed is not a reason to stop supervising */
753
+ }
754
+ };
755
+ return {
756
+ stdout: (line) => write(process.stdout, line),
757
+ stderr: (line) => write(process.stderr, line),
758
+ };
759
+ }
493
760
  /** The private detached-runtime action. Never part of the documented surface. */
494
- async function runRuntimeAction(executors, overrides) {
761
+ async function runRuntimeAction(options, overrides) {
762
+ const executors = options.executors;
495
763
  const repoRoot = overrides.cwd ?? process.cwd();
496
764
  if (overrides.runtime)
497
765
  return overrides.runtime(repoRoot, executors);
@@ -502,10 +770,7 @@ async function runRuntimeAction(executors, overrides) {
502
770
  // cannot assert the refusal at all — which is why the refusal below had never
503
771
  // executed under test and its message string appeared nowhere else in the
504
772
  // repository.
505
- const sinks = overrides.sinks ?? {
506
- stdout: (line) => process.stdout.write(`${line}\n`),
507
- stderr: (line) => process.stderr.write(`${line}\n`),
508
- };
773
+ const sinks = overrides.sinks ?? createDetachedRuntimeSinks();
509
774
  if (typeof planeId !== "string" || planeId.length === 0) {
510
775
  sinks.stderr(`plane runtime refused: ${PLANE_ID_ENV_VAR} was not provided by the launcher.`);
511
776
  return 1;
@@ -533,12 +798,19 @@ async function runRuntimeAction(executors, overrides) {
533
798
  sinks.stderr(`${PLANE_NOTHING_STARTED}.`);
534
799
  return 1;
535
800
  }
536
- const roster = buildPlaneMemberRoster({
537
- context: preflight.context,
538
- executors,
539
- parentEnv: env,
540
- nodeExecutable: resolveNodeExecutable(),
541
- });
801
+ const nodeExecutable = resolveNodeExecutable();
802
+ const rosterBase = { context: preflight.context, parentEnv: env, nodeExecutable };
803
+ // BAPI-1102 — in two-phase mode the runtime is handed the CONTROL-PLANE roster
804
+ // and the two builders it will need later, rather than a finished roster it
805
+ // cannot build yet: the lane roster depends on a claim scope that does not
806
+ // exist until the run does.
807
+ const roster = options.twoPhase
808
+ ? buildPlaneControlPlaneRoster(rosterBase)
809
+ : buildPlaneMemberRoster({
810
+ ...rosterBase,
811
+ executors,
812
+ ...(options.claimScope ? { claimScope: options.claimScope } : {}),
813
+ });
542
814
  const result = await runPlaneRuntime(repoRoot, roster, {
543
815
  fs: createPlaneFsDeps(),
544
816
  clock: createPlaneClock(),
@@ -560,6 +832,14 @@ async function runRuntimeAction(executors, overrides) {
560
832
  hostname: resolveHostname,
561
833
  selfPid: process.pid,
562
834
  planeId,
835
+ ...(options.twoPhase
836
+ ? {
837
+ twoPhase: {
838
+ buildLaneRoster: (scope) => buildPlaneExecutorLaneRoster({ ...rosterBase, executors, claimScope: scope }),
839
+ observerRoster: buildPlaneObserverRoster(rosterBase),
840
+ },
841
+ }
842
+ : {}),
563
843
  });
564
844
  return result.exitCode;
565
845
  }