@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.
@@ -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 Signal the recorded process group, escalating to",
138
- " SIGKILL, and clear the manifest.",
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
- const sinks = {
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
- const preflight = await runPlanePreflight(repoRoot, buildPreflightDeps(env));
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 changed
438
- // between claim and spawn. Refuse rather than start a half-valid plane.
439
- sinks.stderr(`plane runtime refused: preflight no longer passes. ${PLANE_NOTHING_STARTED}.`);
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({
@@ -54,13 +54,23 @@ export function createPlaneFsDeps() {
54
54
  },
55
55
  };
56
56
  }
57
- /** Wall-clock time and real sleeps. */
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`. */
@@ -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
  *
@@ -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.
@@ -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
- /** Render a wind-down result for the terminal. */
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
  }