@bridge_gpt/mcp-server 0.2.53 → 0.2.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -56
- package/build/agent-guidance.generated.js +3 -0
- package/build/agent-launchers/claude-executor-adapter.js +3 -0
- package/build/agent-launchers/claude.js +3 -3
- package/build/agent-launchers/prompt.js +8 -11
- package/build/agent-notes.js +178 -0
- package/build/agent-registry.js +5 -2
- package/build/agent-utils.js +58 -0
- package/build/agents.generated.js +1 -1
- package/build/base-ref.js +33 -9
- package/build/bounded-wait.js +174 -0
- package/build/codex-skill-adapter.js +55 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +235 -14
- package/build/conductor/bring-up-facts.js +187 -0
- package/build/conductor/claude-hook.js +7 -5
- package/build/conductor/cli.js +28 -0
- package/build/conductor/doctor.js +80 -9
- package/build/conductor/epic-implementer-cli.js +1298 -0
- package/build/conductor/epic-runtime.js +134 -98
- package/build/conductor/errors.js +2 -2
- package/build/conductor/git-ci-types.js +1 -1
- package/build/conductor/git-hooks.js +28 -14
- package/build/conductor/install-doctor.js +11 -5
- package/build/conductor/readiness-cli.js +10 -10
- package/build/conductor/readiness-sections.js +58 -9
- package/build/conductor/readiness.js +120 -4
- package/build/conductor/recovery-cli.js +289 -10
- package/build/conductor/recovery-operations.js +125 -2
- package/build/conductor/repair-contract.js +58 -0
- package/build/conductor/run-branch.js +137 -0
- package/build/conductor/store.js +2 -2
- package/build/conductor/supervisor-runtime.js +1 -1
- package/build/conductor/test-run-branch-vectors.js +165 -0
- package/build/conductor-bin.js +2 -139
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
- package/build/conductor-removed-stub-bin.js +30 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +145 -18
- package/build/drive-epic.js +752 -90
- package/build/epic-implementer-bin.js +145 -0
- package/build/epic-implementer-bundle-cli.js +264 -0
- package/build/epic-implementer-claude-hook-bin.js +3 -0
- package/build/epic-integration-pr.js +5 -3
- package/build/executor/claim-scope.js +104 -0
- package/build/executor/cli.js +14 -25
- package/build/executor/env-file-guard.js +82 -3
- package/build/executor/env.js +6 -0
- package/build/executor/interrupted-worktree.js +60 -0
- package/build/executor/job-errors.js +45 -0
- package/build/executor/job-runner.js +334 -9
- package/build/executor/job-types.js +25 -9
- package/build/executor/merge-tree-classifier.js +171 -0
- package/build/executor/reconcile-overlap-governance.js +129 -0
- package/build/executor/reconcile-overlap-job.js +989 -0
- package/build/executor/reconcile-overlap-types.js +14 -0
- package/build/executor/spawn-job-driver.js +1 -0
- package/build/executor/types.js +2 -0
- package/build/executor/worker-finalization.js +25 -2
- package/build/executor/worker-guard-hook.js +15 -7
- package/build/implement-epic/bridge-client.js +773 -0
- package/build/implement-epic/checkpoint-store.js +542 -0
- package/build/implement-epic/cli.js +3158 -0
- package/build/implement-epic/cut-protocol.js +392 -0
- package/build/implement-epic/lock.js +302 -0
- package/build/implement-epic/pr-state.js +286 -0
- package/build/implement-epic/spawn.js +113 -0
- package/build/index.js +657 -481
- package/build/init.js +72 -8
- package/build/install-bridge-conductor.js +5 -5
- package/build/install-bridge.js +403 -70
- package/build/local-artifact-storage.js +130 -0
- package/build/mcp-host-config.js +22 -60
- package/build/mcp-host-entry-adapter.js +18 -0
- package/build/mcp-host-targets.js +1 -21
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +23 -16
- package/build/plan-epic-conductor-eligibility.js +1 -1
- package/build/plane/cli.js +321 -41
- package/build/plane/manifest.js +209 -1
- package/build/plane/member-roster.js +70 -0
- package/build/plane/preflight.js +128 -12
- package/build/plane/shutdown.js +18 -5
- package/build/plane/status.js +35 -1
- package/build/plane/supervisor.js +546 -164
- package/build/plane/types.js +25 -2
- package/build/polling-policy.js +72 -0
- package/build/readiness-check.js +3 -3
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +219 -0
- package/build/run-unit-tests-launcher.js +6 -1
- package/build/setup-epic.js +567 -38
- package/build/start-tickets-conductor.js +8 -7
- package/build/ticket-key-utils.js +4 -3
- package/build/ticket-review-artifact-gate.js +461 -0
- package/build/upgrade-cli.js +5 -26
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +1 -1
- package/docs/CONDUCTOR.md +8 -6
- package/docs/install/mcp-tool-integrations.md +23 -1
- package/package.json +5 -3
- package/pipelines/review-ticket.json +17 -4
|
@@ -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/
|
|
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).
|
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":
|
|
@@ -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
|
-
* `
|
|
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(
|
|
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,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(
|
|
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
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
}
|