@bridge_gpt/mcp-server 0.2.42 → 0.2.44
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 +321 -182
- package/build/agents.generated.js +2 -2
- package/build/claude-review-workflow.js +510 -45
- package/build/commands.generated.js +4 -3
- package/build/conduct-epic/cli.js +195 -0
- package/build/conductor/bridge-api-client.js +121 -0
- package/build/conductor/cli.js +63 -0
- package/build/conductor/recovery-cli.js +313 -0
- package/build/conductor/recovery-operations.js +219 -0
- package/build/conductor-bin.js +9 -5
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +13 -3
- package/build/drive-epic.js +375 -0
- package/build/executor/http-client.js +71 -3
- package/build/executor/job-errors.js +9 -0
- package/build/executor/job-runner.js +50 -3
- package/build/executor/observation.js +105 -13
- package/build/executor/runner.js +219 -0
- package/build/executor/worker-finalization.js +233 -56
- package/build/executor/worktree.js +8 -1
- package/build/index.js +2156 -98
- package/build/install-bridge.js +23 -9
- package/build/pipelines.generated.js +304 -14
- package/build/plane/cli.js +73 -7
- package/build/plane/defaults.js +14 -4
- package/build/plane/manifest.js +90 -0
- package/build/plane/preflight.js +19 -0
- package/build/plane/shutdown.js +71 -3
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +75 -5
- package/build/setup-epic.js +82 -8
- package/build/version.generated.js +2 -1
- package/build/worktree-core.js +31 -17
- package/package.json +1 -1
- package/pipelines/greenfield-setup.json +286 -0
package/build/plane/cli.js
CHANGED
|
@@ -134,8 +134,11 @@ export function getPlaneUsage() {
|
|
|
134
134
|
" plane up [--executors N] Preflight, then start the plane. Refuses as a",
|
|
135
135
|
" whole if any spawn-blocking check fails.",
|
|
136
136
|
" plane status Read the manifest and report each member's state.",
|
|
137
|
-
" plane down
|
|
138
|
-
"
|
|
137
|
+
" plane down Stop the bound server-side epic run FIRST (BAPI-872),",
|
|
138
|
+
" then signal the recorded process group, escalating",
|
|
139
|
+
" to SIGKILL, and clear the manifest. Process shutdown",
|
|
140
|
+
" alone is NOT a run stop — a manifest with no bound",
|
|
141
|
+
" run, or an unreachable Bridge API, only warns.",
|
|
139
142
|
"",
|
|
140
143
|
"Options:",
|
|
141
144
|
` --executors N Executor lanes to start (default ${PLANE_DEFAULT_EXECUTORS}, max ${PLANE_MAX_EXECUTORS}).`,
|
|
@@ -272,11 +275,53 @@ async function runDownAction(sinks, overrides) {
|
|
|
272
275
|
fs: createPlaneFsDeps(),
|
|
273
276
|
proc: createPlaneProcessDeps(),
|
|
274
277
|
clock: createPlaneClock(),
|
|
278
|
+
stopBoundRun: stopBoundEpicRun,
|
|
275
279
|
});
|
|
276
280
|
const text = formatPlaneShutdown(result);
|
|
277
281
|
(result.ok ? sinks.stdout : sinks.stderr)(text);
|
|
278
282
|
return result.ok ? 0 : 1;
|
|
279
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* `plane down`'s pre-signal run-stop capability (BAPI-872).
|
|
286
|
+
*
|
|
287
|
+
* Resolves Bridge access scoped to the MANIFEST's own repository root (never
|
|
288
|
+
* the process's `cwd`, which is not necessarily the same directory) and calls
|
|
289
|
+
* the shared `stopEpicRunRecovery` — the identical stop semantics
|
|
290
|
+
* `conductor stop-run` uses, so the two surfaces can never drift apart.
|
|
291
|
+
*
|
|
292
|
+
* Never throws. `shutdownPlane` awaits this directly with no try/catch of its
|
|
293
|
+
* own, so a credential, transport, or unexpected failure here is converted
|
|
294
|
+
* into a fixed `unavailable` outcome rather than propagating — a failure to
|
|
295
|
+
* stop the run must never prevent the validated process-group shutdown that
|
|
296
|
+
* follows it.
|
|
297
|
+
*/
|
|
298
|
+
async function stopBoundEpicRun(epicRunId, manifest) {
|
|
299
|
+
try {
|
|
300
|
+
const { resolveConductorBridgeApiAccess } = await import("../conductor/bridge-api-client.js");
|
|
301
|
+
const { stopEpicRunRecovery } = await import("../conductor/recovery-operations.js");
|
|
302
|
+
const accessResult = await resolveConductorBridgeApiAccess({ cwd: manifest.repoRoot });
|
|
303
|
+
if (!accessResult.ok) {
|
|
304
|
+
return { kind: "unavailable", epicRunId, message: accessResult.error };
|
|
305
|
+
}
|
|
306
|
+
const result = await stopEpicRunRecovery(accessResult.access, { epicRunId });
|
|
307
|
+
if (result.ok) {
|
|
308
|
+
return result.kind === "committed"
|
|
309
|
+
? { kind: "committed", epicRunId }
|
|
310
|
+
: { kind: "already-stopped", epicRunId };
|
|
311
|
+
}
|
|
312
|
+
if (result.kind === "terminal") {
|
|
313
|
+
return { kind: "terminal", epicRunId, status: result.status };
|
|
314
|
+
}
|
|
315
|
+
return { kind: "unavailable", epicRunId, message: result.message };
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
return {
|
|
319
|
+
kind: "unavailable",
|
|
320
|
+
epicRunId,
|
|
321
|
+
message: err instanceof Error ? err.constructor.name : "run-stop failed unexpectedly",
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
280
325
|
async function runUpAction(executors, sinks, overrides) {
|
|
281
326
|
const repoRoot = overrides.cwd ?? process.cwd();
|
|
282
327
|
const env = overrides.env ?? process.env;
|
|
@@ -424,7 +469,12 @@ async function runRuntimeAction(executors, overrides) {
|
|
|
424
469
|
return overrides.runtime(repoRoot, executors);
|
|
425
470
|
const env = overrides.env ?? process.env;
|
|
426
471
|
const planeId = env[PLANE_ID_ENV_VAR];
|
|
427
|
-
|
|
472
|
+
// BAPI-882: honour injected sinks. The runtime writes straight to the real
|
|
473
|
+
// process streams in production, but a test that cannot capture this output
|
|
474
|
+
// cannot assert the refusal at all — which is why the refusal below had never
|
|
475
|
+
// executed under test and its message string appeared nowhere else in the
|
|
476
|
+
// repository.
|
|
477
|
+
const sinks = overrides.sinks ?? {
|
|
428
478
|
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
429
479
|
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
430
480
|
};
|
|
@@ -432,11 +482,27 @@ async function runRuntimeAction(executors, overrides) {
|
|
|
432
482
|
sinks.stderr(`plane runtime refused: ${PLANE_ID_ENV_VAR} was not provided by the launcher.`);
|
|
433
483
|
return 1;
|
|
434
484
|
}
|
|
435
|
-
|
|
485
|
+
// BAPI-882: hand preflight this runtime's own plane id. The launcher claimed
|
|
486
|
+
// `plane.json` with `supervisorPid: process.pid` as a placeholder BEFORE
|
|
487
|
+
// spawning us, so a preflight without an identity sees that live claim,
|
|
488
|
+
// classifies it as "another plane is running", and refuses — the runtime
|
|
489
|
+
// deadlocking on its own launcher. `planeId` is validated non-empty above.
|
|
490
|
+
const preflight = overrides.preflight
|
|
491
|
+
? await overrides.preflight(repoRoot)
|
|
492
|
+
: await runPlanePreflight(repoRoot, { ...buildPreflightDeps(env), ownPlaneId: planeId });
|
|
436
493
|
if (!preflight.ok) {
|
|
437
|
-
// The launcher already ran preflight; reaching here means the world
|
|
438
|
-
// between claim and spawn. Refuse rather than start a half-valid
|
|
439
|
-
|
|
494
|
+
// The launcher already ran preflight; reaching here means the world genuinely
|
|
495
|
+
// changed between claim and spawn. Refuse rather than start a half-valid
|
|
496
|
+
// plane — but say WHICH check failed. Naming only "preflight no longer
|
|
497
|
+
// passes" threw away diagnostics that had just been computed and left the
|
|
498
|
+
// operator with nothing to act on.
|
|
499
|
+
sinks.stderr("");
|
|
500
|
+
sinks.stderr("plane runtime REFUSED — preflight found blocking problems:");
|
|
501
|
+
for (const diagnostic of preflight.diagnostics.filter((d) => d.severity === "blocking")) {
|
|
502
|
+
sinks.stderr(formatDiagnostic(diagnostic));
|
|
503
|
+
}
|
|
504
|
+
sinks.stderr("");
|
|
505
|
+
sinks.stderr(`${PLANE_NOTHING_STARTED}.`);
|
|
440
506
|
return 1;
|
|
441
507
|
}
|
|
442
508
|
const roster = buildPlaneMemberRoster({
|
package/build/plane/defaults.js
CHANGED
|
@@ -54,13 +54,23 @@ export function createPlaneFsDeps() {
|
|
|
54
54
|
},
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Wall-clock time and real sleeps.
|
|
59
|
+
*
|
|
60
|
+
* The sleep timer is deliberately NOT `.unref()`'d. `plane down`'s wait loop
|
|
61
|
+
* (`shutdown.ts#waitForDeath`) awaits this from a short-lived CLI process's
|
|
62
|
+
* top-level `await` (`index.ts`) — an unref'd timer lets Node consider the
|
|
63
|
+
* event loop empty while that await is still pending, which Node reports as
|
|
64
|
+
* an "unsettled top-level await" and answers by forcing exit code 13,
|
|
65
|
+
* corrupting the CLI's real exit code and losing its buffered stdout. A
|
|
66
|
+
* long-running `plane up` supervisor is unaffected either way: it stays alive
|
|
67
|
+
* on its own child-process and signal-handler activity regardless of this
|
|
68
|
+
* timer's ref state.
|
|
69
|
+
*/
|
|
58
70
|
export function createPlaneClock() {
|
|
59
71
|
return {
|
|
60
72
|
now: () => new Date(),
|
|
61
|
-
sleep: (ms) => new Promise((resolve) =>
|
|
62
|
-
setTimeout(resolve, ms).unref?.();
|
|
63
|
-
}),
|
|
73
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
64
74
|
};
|
|
65
75
|
}
|
|
66
76
|
/** Liveness and signal delivery bound to `process.kill`. */
|
package/build/plane/manifest.js
CHANGED
|
@@ -52,7 +52,13 @@ const MANIFEST_KEYS = new Set([
|
|
|
52
52
|
"createdAt",
|
|
53
53
|
"updatedAt",
|
|
54
54
|
"members",
|
|
55
|
+
// BAPI-872: the optional server-side epic-run binding.
|
|
56
|
+
"epicRunId",
|
|
55
57
|
]);
|
|
58
|
+
/** Non-blank string, trimmed equal to itself (no leading/trailing whitespace). */
|
|
59
|
+
function isNonBlankTrimmedString(value) {
|
|
60
|
+
return typeof value === "string" && value.trim().length > 0 && value === value.trim();
|
|
61
|
+
}
|
|
56
62
|
const MEMBER_KEYS = new Set([
|
|
57
63
|
"name",
|
|
58
64
|
"pid",
|
|
@@ -113,6 +119,14 @@ export function parsePlaneManifest(value) {
|
|
|
113
119
|
if (!Array.isArray(record.members) || record.members.length === 0) {
|
|
114
120
|
return { ok: false, error: "manifest members are missing" };
|
|
115
121
|
}
|
|
122
|
+
// BAPI-872: `epicRunId` is optional — absent means a legacy or still-unbound
|
|
123
|
+
// manifest, which is a perfectly valid state. When present it must be a
|
|
124
|
+
// genuine non-blank, untrimmed-clean string; anything else is rejected here
|
|
125
|
+
// rather than silently coerced, because a malformed value would otherwise
|
|
126
|
+
// reach `plane down`'s run-stop lookup as an unvalidated identifier.
|
|
127
|
+
if (record.epicRunId !== undefined && !isNonBlankTrimmedString(record.epicRunId)) {
|
|
128
|
+
return { ok: false, error: "manifest epic run id is malformed" };
|
|
129
|
+
}
|
|
116
130
|
const members = [];
|
|
117
131
|
const seen = new Set();
|
|
118
132
|
for (const raw of record.members) {
|
|
@@ -169,6 +183,7 @@ export function parsePlaneManifest(value) {
|
|
|
169
183
|
createdAt: record.createdAt,
|
|
170
184
|
updatedAt: record.updatedAt,
|
|
171
185
|
members,
|
|
186
|
+
...(record.epicRunId !== undefined ? { epicRunId: record.epicRunId } : {}),
|
|
172
187
|
},
|
|
173
188
|
};
|
|
174
189
|
}
|
|
@@ -294,6 +309,81 @@ export async function clearPlaneManifest(repoRoot, planeId, fs) {
|
|
|
294
309
|
};
|
|
295
310
|
}
|
|
296
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* Atomically bind `epicRunId` onto the plane manifest (BAPI-872).
|
|
314
|
+
*
|
|
315
|
+
* The one and only writer of `PlaneManifest.epicRunId`. Fully validates the
|
|
316
|
+
* manifest AND the calling plane's identity before ANY mutation — a
|
|
317
|
+
* malformed/untrusted record is refused rather than bound, and a manifest that
|
|
318
|
+
* legitimately belongs to a DIFFERENT plane (a stale record from an earlier
|
|
319
|
+
* run at the same repository root) is refused rather than silently claimed.
|
|
320
|
+
*
|
|
321
|
+
* Binding the SAME `epicRunId` a second time is a safe, idempotent no-op
|
|
322
|
+
* (`alreadyBound: true`) — a re-run of `setup-epic` reusing a live run must
|
|
323
|
+
* not fail here. Binding a DIFFERENT `epicRunId` onto an already-bound
|
|
324
|
+
* manifest is refused (`conflict`): this function never repoints a plane at a
|
|
325
|
+
* different run, because guessing which one is right is exactly the ambiguity
|
|
326
|
+
* this binding exists to remove.
|
|
327
|
+
*/
|
|
328
|
+
export async function bindPlaneManifestEpicRun(repoRoot, planeId, epicRunId, fs) {
|
|
329
|
+
const read = await readPlaneManifest(repoRoot, fs);
|
|
330
|
+
if (read.kind === "missing") {
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
reason: "no-manifest",
|
|
334
|
+
message: "no plane manifest found for this repository",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
if (read.kind !== "valid") {
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
reason: "unvalidated-manifest",
|
|
341
|
+
message: `plane manifest could not be validated (${read.error})`,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
const manifest = read.manifest;
|
|
345
|
+
if (manifest.planeId !== planeId) {
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
reason: "identity-mismatch",
|
|
349
|
+
message: "plane manifest belongs to a different plane identity",
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (manifest.repoRoot !== repoRoot) {
|
|
353
|
+
// Defense in depth: the manifest is already located BY `repoRoot` (it can
|
|
354
|
+
// only be found at `<repoRoot>/.bridge/plane/plane.json`), so this fires
|
|
355
|
+
// only for a manifest file that was copied or symlinked from elsewhere and
|
|
356
|
+
// still carries its origin's `repoRoot`. Refusing here keeps that stale
|
|
357
|
+
// self-description from ever being trusted as this repository's own.
|
|
358
|
+
return {
|
|
359
|
+
ok: false,
|
|
360
|
+
reason: "identity-mismatch",
|
|
361
|
+
message: "plane manifest belongs to a different repository root",
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (manifest.epicRunId === epicRunId) {
|
|
365
|
+
return { ok: true, alreadyBound: true };
|
|
366
|
+
}
|
|
367
|
+
if (manifest.epicRunId !== undefined) {
|
|
368
|
+
return {
|
|
369
|
+
ok: false,
|
|
370
|
+
reason: "conflict",
|
|
371
|
+
message: `plane manifest is already bound to a different epic run (${manifest.epicRunId})`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
await writePlaneManifest({ ...manifest, epicRunId, updatedAt: new Date().toISOString() }, fs);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
const code = err?.code;
|
|
379
|
+
return {
|
|
380
|
+
ok: false,
|
|
381
|
+
reason: "error",
|
|
382
|
+
message: `could not bind the epic run to the plane manifest (${typeof code === "string" ? code : "write failed"})`,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
return { ok: true, alreadyBound: false };
|
|
386
|
+
}
|
|
297
387
|
/**
|
|
298
388
|
* Take exclusive ownership of `.bridge/plane/plane.json`.
|
|
299
389
|
*
|
package/build/plane/preflight.js
CHANGED
|
@@ -336,6 +336,25 @@ export async function checkExistingPlane(repoRoot, deps) {
|
|
|
336
336
|
"it by hand if no plane is running.",
|
|
337
337
|
};
|
|
338
338
|
}
|
|
339
|
+
// BAPI-882: a manifest carrying THIS process's own plane id is its own claim,
|
|
340
|
+
// not a competitor. Checked after validation and BEFORE the liveness probe,
|
|
341
|
+
// because the launcher writes `supervisorPid: process.pid` as a placeholder
|
|
342
|
+
// before spawning the runtime — so the process the probe finds alive is the
|
|
343
|
+
// very launcher that started us.
|
|
344
|
+
//
|
|
345
|
+
// Plane ids are minted per launch (`randomUUID` in cli.ts), so the only
|
|
346
|
+
// process that can have written a manifest bearing this id is the launcher
|
|
347
|
+
// that spawned this runtime. That makes identity, not liveness, the correct
|
|
348
|
+
// discriminator here. This is not a new predicate: `clearPlaneManifest` and
|
|
349
|
+
// `bindPlaneManifestEpicRun` already gate on exactly `manifest.planeId ===
|
|
350
|
+
// <own id>`, and `runPlaneRuntime` applies it one layer later. This moves the
|
|
351
|
+
// recognition earlier, to the only place that was still missing it.
|
|
352
|
+
//
|
|
353
|
+
// A live manifest with a DIFFERENT plane id still blocks, unchanged — that is
|
|
354
|
+
// a genuine second plane and the whole point of the check.
|
|
355
|
+
if (deps.ownPlaneId !== undefined && read.manifest.planeId === deps.ownPlaneId) {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
339
358
|
if (!manifestHasLiveProcess(read.manifest, deps.proc)) {
|
|
340
359
|
// Stale. Reported as information so the operator understands why an old
|
|
341
360
|
// manifest is about to be replaced; the claim revalidates before it does.
|
package/build/plane/shutdown.js
CHANGED
|
@@ -42,11 +42,13 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
42
42
|
manifestCleared: false,
|
|
43
43
|
members: [],
|
|
44
44
|
messages: ["No plane manifest found — nothing to wind down."],
|
|
45
|
+
runShutdown: { kind: "unbound" },
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
48
|
if (read.kind !== "valid") {
|
|
48
49
|
// Deliberately before any liveness probe: an unvalidated record must never
|
|
49
|
-
// become a signal target, not even a signal-zero one
|
|
50
|
+
// become a signal target, not even a signal-zero one — and, as of BAPI-872,
|
|
51
|
+
// must never authorize a run-stop lookup either.
|
|
50
52
|
return {
|
|
51
53
|
ok: false,
|
|
52
54
|
reason: "unvalidated-manifest",
|
|
@@ -54,10 +56,25 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
54
56
|
"signalled. Inspect the file and remove it by hand once you have confirmed no plane " +
|
|
55
57
|
"is running.",
|
|
56
58
|
members: [],
|
|
59
|
+
runShutdown: { kind: "unbound" },
|
|
57
60
|
};
|
|
58
61
|
}
|
|
59
62
|
const manifest = read.manifest;
|
|
60
63
|
const messages = [];
|
|
64
|
+
// BAPI-872: the server-side run stop, computed ONCE right after the manifest
|
|
65
|
+
// has passed every validation and identity check above — strictly before the
|
|
66
|
+
// first process-group signal in every branch that sends one (the sole branch
|
|
67
|
+
// that never signals, `already-dead`, still runs it first since it is the
|
|
68
|
+
// earliest safe point and the outcome is reused unchanged in every return
|
|
69
|
+
// below). A manifest with no bound run never invokes the capability at all.
|
|
70
|
+
const runShutdown = manifest.epicRunId
|
|
71
|
+
? await (deps.stopBoundRun?.(manifest.epicRunId, manifest) ??
|
|
72
|
+
Promise.resolve({
|
|
73
|
+
kind: "unavailable",
|
|
74
|
+
epicRunId: manifest.epicRunId,
|
|
75
|
+
message: "no run-stop capability was configured for this wind-down",
|
|
76
|
+
}))
|
|
77
|
+
: { kind: "unbound" };
|
|
61
78
|
if (!isAnythingAlive(manifest, deps.proc, deps.selfPid)) {
|
|
62
79
|
const cleared = await clearPlaneManifest(repoRoot, manifest.planeId, deps.fs);
|
|
63
80
|
if (!cleared.ok) {
|
|
@@ -66,6 +83,7 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
66
83
|
reason: "clear-failed",
|
|
67
84
|
message: cleared.message,
|
|
68
85
|
members: reportMembers(manifest, deps.proc),
|
|
86
|
+
runShutdown,
|
|
69
87
|
};
|
|
70
88
|
}
|
|
71
89
|
return {
|
|
@@ -75,6 +93,7 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
75
93
|
manifestCleared: cleared.removed,
|
|
76
94
|
members: reportMembers(manifest, deps.proc),
|
|
77
95
|
messages: ["Every recorded plane process was already gone; manifest cleared."],
|
|
96
|
+
runShutdown,
|
|
78
97
|
};
|
|
79
98
|
}
|
|
80
99
|
messages.push(deps.selfPid === undefined
|
|
@@ -104,11 +123,12 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
104
123
|
message: `wind-down could not confirm every process was terminated: ${survivors.join(", ")}. ` +
|
|
105
124
|
"The manifest was RETAINED so `plane down` can be retried.",
|
|
106
125
|
members,
|
|
126
|
+
runShutdown,
|
|
107
127
|
};
|
|
108
128
|
}
|
|
109
129
|
const cleared = await clearPlaneManifest(repoRoot, manifest.planeId, deps.fs);
|
|
110
130
|
if (!cleared.ok) {
|
|
111
|
-
return { ok: false, reason: "clear-failed", message: cleared.message, members };
|
|
131
|
+
return { ok: false, reason: "clear-failed", message: cleared.message, members, runShutdown };
|
|
112
132
|
}
|
|
113
133
|
return {
|
|
114
134
|
ok: true,
|
|
@@ -117,6 +137,7 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
117
137
|
manifestCleared: cleared.removed,
|
|
118
138
|
members,
|
|
119
139
|
messages,
|
|
140
|
+
runShutdown,
|
|
120
141
|
};
|
|
121
142
|
}
|
|
122
143
|
/**
|
|
@@ -171,9 +192,56 @@ async function waitForDeath(manifest, deps, budgetMs, pollMs) {
|
|
|
171
192
|
waited += pollMs;
|
|
172
193
|
}
|
|
173
194
|
}
|
|
174
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* Render the server-side run-shutdown outcome (BAPI-872) as its own message
|
|
197
|
+
* lines — never claims signalling local processes changed server-side state,
|
|
198
|
+
* and always names the supported `conductor stop-run` check/remediation
|
|
199
|
+
* surface for every outcome that is not a fresh committed stop.
|
|
200
|
+
*/
|
|
201
|
+
function formatRunShutdown(outcome) {
|
|
202
|
+
switch (outcome.kind) {
|
|
203
|
+
case "unbound":
|
|
204
|
+
return [
|
|
205
|
+
"Run shutdown: no epic run is bound to this plane. If a server-side run is " +
|
|
206
|
+
"active, this command did NOT stop it and its queued work may remain live. " +
|
|
207
|
+
"Run `conductor stop-run --epic-run-id <epic_run_id>` to stop it explicitly.",
|
|
208
|
+
];
|
|
209
|
+
case "committed":
|
|
210
|
+
return [`Run shutdown: stopped run ${outcome.epicRunId} — its queued work was cancelled.`];
|
|
211
|
+
case "already-stopped":
|
|
212
|
+
return [`Run shutdown: run ${outcome.epicRunId} was already stopped; no further action.`];
|
|
213
|
+
case "terminal":
|
|
214
|
+
return [
|
|
215
|
+
`Run shutdown: run ${outcome.epicRunId} is terminal (${outcome.status}) and cannot be ` +
|
|
216
|
+
"stopped. Signalling local processes does not change this — run " +
|
|
217
|
+
`\`conductor stop-run --epic-run-id ${outcome.epicRunId}\` to check its state.`,
|
|
218
|
+
];
|
|
219
|
+
case "unavailable":
|
|
220
|
+
return [
|
|
221
|
+
`Run shutdown: could not reach the Bridge API to stop run ${outcome.epicRunId} ` +
|
|
222
|
+
`(${outcome.message}). The run may remain active with queued work — run ` +
|
|
223
|
+
`\`conductor stop-run --epic-run-id ${outcome.epicRunId}\` to retry.`,
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Render a wind-down result for the terminal.
|
|
229
|
+
*
|
|
230
|
+
* BAPI-872: "Run shutdown" (the server-side epic run) is rendered as its own
|
|
231
|
+
* section BEFORE "Process shutdown" (the local plane processes) — the two are
|
|
232
|
+
* independent outcomes, and the ordering makes it visible even when the
|
|
233
|
+
* process section reads as an unqualified success. The one exception is an
|
|
234
|
+
* unvalidated manifest: there is no trustworthy record to read a run binding
|
|
235
|
+
* from, so nothing is claimed about run shutdown at all.
|
|
236
|
+
*/
|
|
175
237
|
export function formatPlaneShutdown(result) {
|
|
176
238
|
const lines = [];
|
|
239
|
+
const skipRunSection = !result.ok && result.reason === "unvalidated-manifest";
|
|
240
|
+
if (!skipRunSection) {
|
|
241
|
+
lines.push(...formatRunShutdown(result.runShutdown));
|
|
242
|
+
lines.push("");
|
|
243
|
+
lines.push("Process shutdown:");
|
|
244
|
+
}
|
|
177
245
|
if (!result.ok) {
|
|
178
246
|
lines.push(`plane down FAILED: ${result.message}`);
|
|
179
247
|
}
|