@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
@@ -17,7 +17,7 @@
17
17
  * argv, and no credential field, and unknown keys are rejected outright.
18
18
  */
19
19
  import path from "path";
20
- import { PLANE_MANIFEST_FILENAME, PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS, PLANE_RUNTIME_DIR, } from "./types.js";
20
+ import { PLANE_LIFECYCLE_PHASES, PLANE_MANIFEST_FILENAME, PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS, PLANE_RUNTIME_DIR, } from "./types.js";
21
21
  /**
22
22
  * Derive every runtime path beneath a validated repository root.
23
23
  *
@@ -54,7 +54,13 @@ const MANIFEST_KEYS = new Set([
54
54
  "members",
55
55
  // BAPI-872: the optional server-side epic-run binding.
56
56
  "epicRunId",
57
+ // BAPI-1102: the two-phase lifecycle state and the claim scope phase two
58
+ // starts its lanes with. Both optional; both absent on a v1/v2 manifest.
59
+ "lifecycle",
60
+ "laneScope",
57
61
  ]);
62
+ /** Every legal `lifecycle` value, as a set for the strict parse below. */
63
+ const LIFECYCLE_PHASES = new Set(PLANE_LIFECYCLE_PHASES);
58
64
  /** Non-blank string, trimmed equal to itself (no leading/trailing whitespace). */
59
65
  function isNonBlankTrimmedString(value) {
60
66
  return typeof value === "string" && value.trim().length > 0 && value === value.trim();
@@ -144,6 +150,16 @@ export function parsePlaneManifest(value) {
144
150
  if (record.epicRunId !== undefined && !isNonBlankTrimmedString(record.epicRunId)) {
145
151
  return { ok: false, error: "manifest epic run id is malformed" };
146
152
  }
153
+ // BAPI-1102: both are optional and both are validated STRICTLY when present.
154
+ // Absence means a v1/v2 manifest or a single-phase bring-up, and the reader
155
+ // resolves absence to `"ready"` — an older build only ever wrote a manifest for
156
+ // a plane whose whole roster had started.
157
+ if (record.lifecycle !== undefined && !LIFECYCLE_PHASES.has(record.lifecycle)) {
158
+ return { ok: false, error: "manifest lifecycle phase is malformed" };
159
+ }
160
+ const laneScope = parseManifestLaneScope(record.laneScope);
161
+ if (!laneScope.ok)
162
+ return { ok: false, error: laneScope.error };
147
163
  const members = [];
148
164
  const unrecognizedMemberNames = [];
149
165
  const seen = new Set();
@@ -235,9 +251,63 @@ export function parsePlaneManifest(value) {
235
251
  updatedAt: record.updatedAt,
236
252
  members,
237
253
  ...(record.epicRunId !== undefined ? { epicRunId: record.epicRunId } : {}),
254
+ ...(record.lifecycle !== undefined
255
+ ? { lifecycle: record.lifecycle }
256
+ : {}),
257
+ ...(laneScope.scope !== undefined ? { laneScope: laneScope.scope } : {}),
238
258
  },
239
259
  };
240
260
  }
261
+ /**
262
+ * Validate the optional `laneScope` record (BAPI-1102).
263
+ *
264
+ * Strict, and strict for a specific reason: this value crosses a PROCESS
265
+ * boundary. The composed caller writes it and the detached runtime reads it, so
266
+ * it is the one place an untrusted-looking value can become executor argv. Every
267
+ * shape is checked before it is trusted, and a malformed value is a parse
268
+ * failure rather than a coerced default.
269
+ *
270
+ * It carries run ids and a discriminant only. No credential, environment object,
271
+ * or argv array may ever be added here — the manifest lives on disk for the
272
+ * lifetime of the plane, which is the reason the record has always been free of
273
+ * secrets.
274
+ */
275
+ function parseManifestLaneScope(value) {
276
+ if (value === undefined)
277
+ return { ok: true };
278
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
279
+ return { ok: false, error: "manifest lane scope is not an object" };
280
+ }
281
+ const record = value;
282
+ if (record.kind === "repo-wide") {
283
+ if (Object.keys(record).length !== 1) {
284
+ return { ok: false, error: "manifest lane scope has unsupported fields" };
285
+ }
286
+ return { ok: true, scope: { kind: "repo-wide" } };
287
+ }
288
+ if (record.kind !== "epic-runs") {
289
+ return { ok: false, error: "manifest lane scope kind is malformed" };
290
+ }
291
+ if (Object.keys(record).length !== 2 || !Array.isArray(record.epicRunIds)) {
292
+ return { ok: false, error: "manifest lane scope epic run ids are malformed" };
293
+ }
294
+ const ids = record.epicRunIds;
295
+ if (ids.length === 0 || !ids.every((id) => isNonBlankTrimmedString(id))) {
296
+ return { ok: false, error: "manifest lane scope epic run ids are malformed" };
297
+ }
298
+ return { ok: true, scope: { kind: "epic-runs", epicRunIds: ids } };
299
+ }
300
+ /**
301
+ * The phase this manifest describes, resolving ABSENCE to `"ready"`.
302
+ *
303
+ * The single reader of that rule. A manifest written before BAPI-1102 describes a
304
+ * plane whose entire roster started before the file was last written, so `ready`
305
+ * is the truthful reading — and it is the reading that keeps `plane status` and
306
+ * `plane down` behaving identically on a legacy plane.
307
+ */
308
+ export function planeManifestLifecycle(manifest) {
309
+ return manifest.lifecycle ?? "ready";
310
+ }
241
311
  /**
242
312
  * Read and validate the manifest, distinguishing every outcome.
243
313
  *
@@ -463,6 +533,144 @@ export async function bindPlaneManifestEpicRun(repoRoot, planeId, epicRunId, fs)
463
533
  }
464
534
  return { ok: true, alreadyBound: false };
465
535
  }
536
+ /**
537
+ * Read + validate + identity-check the manifest for a guarded write.
538
+ *
539
+ * Shared by every operation below so the three refusals that precede any mutation
540
+ * are stated once. A caller that wrote its own version of this would eventually
541
+ * mutate a record it had not validated.
542
+ */
543
+ async function readOwnedManifest(repoRoot, planeId, fs) {
544
+ const read = await readPlaneManifest(repoRoot, fs);
545
+ if (read.kind === "missing") {
546
+ return {
547
+ ok: false,
548
+ reason: "no-manifest",
549
+ message: "no plane manifest found for this repository",
550
+ };
551
+ }
552
+ if (read.kind !== "valid") {
553
+ return {
554
+ ok: false,
555
+ reason: "unvalidated-manifest",
556
+ message: `plane manifest could not be validated (${read.error})`,
557
+ };
558
+ }
559
+ if (read.manifest.planeId !== planeId || read.manifest.repoRoot !== repoRoot) {
560
+ return {
561
+ ok: false,
562
+ reason: "identity-mismatch",
563
+ message: "plane manifest belongs to a different plane identity",
564
+ };
565
+ }
566
+ return { ok: true, manifest: read.manifest };
567
+ }
568
+ /** Persist a manifest mutation, converting a filesystem failure into a result. */
569
+ async function writeGuardedManifest(manifest, fs) {
570
+ try {
571
+ const updated = { ...manifest, updatedAt: new Date().toISOString() };
572
+ await writePlaneManifest(updated, fs);
573
+ return { ok: true, manifest: updated };
574
+ }
575
+ catch (err) {
576
+ // The CODE only. A thrown filesystem error carries a path, and this message
577
+ // reaches an operator's terminal.
578
+ const code = err?.code;
579
+ return {
580
+ ok: false,
581
+ reason: "error",
582
+ message: `plane manifest could not be written${code ? ` (${code})` : ""}`,
583
+ };
584
+ }
585
+ }
586
+ /**
587
+ * Move the plane from one lifecycle phase to the next, atomically.
588
+ *
589
+ * `expected` is REQUIRED and is the concurrency control. Writing the phase
590
+ * unconditionally would let a stale writer — a retried request, or a runtime that
591
+ * resumed after a pause — rewind a plane that has already progressed, which for
592
+ * `lanes-ready` → `control-plane-ready` would mean the runtime waiting forever
593
+ * for a phase-two request that had already been served.
594
+ */
595
+ export async function transitionPlaneManifestLifecycle(repoRoot, planeId, expected, next, fs) {
596
+ const owned = await readOwnedManifest(repoRoot, planeId, fs);
597
+ if (!owned.ok)
598
+ return owned;
599
+ const current = planeManifestLifecycle(owned.manifest);
600
+ if (current !== expected) {
601
+ return {
602
+ ok: false,
603
+ reason: "phase-conflict",
604
+ message: `plane is ${current}, not ${expected}`,
605
+ };
606
+ }
607
+ return writeGuardedManifest({ ...owned.manifest, lifecycle: next }, fs);
608
+ }
609
+ /**
610
+ * Request phase two: bind the run and record the claim scope the lanes will use.
611
+ *
612
+ * The single writer of `laneScope`, and it binds `epicRunId` in the SAME atomic
613
+ * write. Doing them together is what makes "one composed plane serves exactly one
614
+ * run" hold: a plane already bound to a different run is refused here, before any
615
+ * lane exists, rather than being discovered after lanes are already claiming for
616
+ * the wrong run.
617
+ *
618
+ * A composed plane is deliberately SINGLE-RUN. Appending a second run scope to
619
+ * live lanes was considered and rejected: the lanes are already running with a
620
+ * fixed argv, so a second scope would require restarting them, and a restart
621
+ * mid-run is indistinguishable to the reconciler from a lane crash. A second epic
622
+ * gets a second plane.
623
+ *
624
+ * Re-requesting the SAME run is an idempotent no-op when the plane has already
625
+ * moved past `control-plane-ready`, so a retried composed bring-up does not
626
+ * refuse a plane it already set up.
627
+ */
628
+ export async function requestPlaneManifestLanes(repoRoot, planeId, epicRunId, laneScope, fs) {
629
+ const owned = await readOwnedManifest(repoRoot, planeId, fs);
630
+ if (!owned.ok)
631
+ return owned;
632
+ const manifest = owned.manifest;
633
+ if (manifest.epicRunId !== undefined && manifest.epicRunId !== epicRunId) {
634
+ return {
635
+ ok: false,
636
+ reason: "run-conflict",
637
+ message: `plane is already bound to a different epic run (${manifest.epicRunId})`,
638
+ };
639
+ }
640
+ const current = planeManifestLifecycle(manifest);
641
+ if (current !== "control-plane-ready") {
642
+ // Already served for THIS run: report success rather than a conflict, so a
643
+ // retry of the composed flow converges instead of failing on its own work.
644
+ if (manifest.epicRunId === epicRunId && manifest.laneScope !== undefined) {
645
+ return { ok: true, manifest };
646
+ }
647
+ return {
648
+ ok: false,
649
+ reason: "phase-conflict",
650
+ message: `plane is ${current}, not control-plane-ready`,
651
+ };
652
+ }
653
+ return writeGuardedManifest({ ...manifest, epicRunId, laneScope }, fs);
654
+ }
655
+ /**
656
+ * Append members to a live manifest, guarded by plane identity.
657
+ *
658
+ * Used by the detached runtime as each phase-two cohort begins spawning, so
659
+ * `plane status` and `plane down` can see and signal members that did not exist
660
+ * when the manifest was first claimed. A name already listed is REPLACED rather
661
+ * than duplicated: the manifest's own parse rejects a repeated member name, and
662
+ * producing a record this module's reader would refuse is never the right way to
663
+ * report progress.
664
+ */
665
+ export async function appendPlaneManifestMembers(repoRoot, planeId, members, fs) {
666
+ const owned = await readOwnedManifest(repoRoot, planeId, fs);
667
+ if (!owned.ok)
668
+ return owned;
669
+ const byName = new Map(owned.manifest.members.map((m) => [m.name, m]));
670
+ for (const member of members)
671
+ byName.set(member.name, member);
672
+ return writeGuardedManifest({ ...owned.manifest, members: [...byName.values()] }, fs);
673
+ }
466
674
  /**
467
675
  * Take exclusive ownership of `.bridge/plane/plane.json`.
468
676
  *
@@ -25,6 +25,7 @@
25
25
  import path from "path";
26
26
  import { PLANE_OBSERVER_MODE_ENV, PLANE_OBSERVER_MODE_VALUE, PLANE_SERVER_HOST, PLANE_SERVER_PORT, PLANE_SERVER_PORT_ENV_VAR, } from "./types.js";
27
27
  import { relativeLogPathFor } from "./manifest.js";
28
+ import { EXECUTOR_CLAIM_SCOPE_REQUIRED_MESSAGE, executorClaimScopeArgs, } from "../executor/claim-scope.js";
28
29
  /** How long a member with a readiness probe gets to start listening. */
29
30
  export const PLANE_READINESS_TIMEOUT_MS = 60_000;
30
31
  /**
@@ -310,6 +311,18 @@ export function buildPlaneMemberRoster(params) {
310
311
  timeoutMs: PLANE_RECONCILER_READINESS_TIMEOUT_MS,
311
312
  },
312
313
  };
314
+ // BAPI-1102 — refused HERE, not merely documented. A lane built without a
315
+ // scope is a lane that exits at startup (BAPI-1026), and a roster that could
316
+ // produce one would turn a missing flag into a member crash and a full plane
317
+ // rollback. The public parser already refuses first; this is the structural
318
+ // backstop for every internal caller.
319
+ if (executors > 0 && params.claimScope === undefined) {
320
+ throw new Error(EXECUTOR_CLAIM_SCOPE_REQUIRED_MESSAGE);
321
+ }
322
+ // Built ONCE, outside the lane loop: every lane of one plane serves the same
323
+ // scope, and rendering it per-lane would be a chance for two lanes of the same
324
+ // plane to disagree.
325
+ const scopeArgs = params.claimScope === undefined ? [] : executorClaimScopeArgs(params.claimScope);
313
326
  const executorMembers = [];
314
327
  for (let lane = 1; lane <= executors; lane += 1) {
315
328
  const name = `executor-${lane}`;
@@ -334,6 +347,9 @@ export function buildPlaneMemberRoster(params) {
334
347
  endpoint.baseUrl,
335
348
  "--executor-id",
336
349
  executorId,
350
+ // Appended LAST and rendered by the executor's OWN argv builder, so the
351
+ // flag spelling here cannot drift from the parser that reads it.
352
+ ...scopeArgs,
337
353
  ],
338
354
  cwd,
339
355
  env,
@@ -361,6 +377,60 @@ export function buildPlaneMemberRoster(params) {
361
377
  }
362
378
  return [server, worker, ...executorMembers, observer];
363
379
  }
380
+ // ---------------------------------------------------------------------------
381
+ // BAPI-1102 — the two-phase roster selectors.
382
+ // ---------------------------------------------------------------------------
383
+ // Each is a FILTER over the full roster rather than a second builder, and that
384
+ // is the whole design. Every spec — argv, environment, log path, readiness gate,
385
+ // the executor id minted once and used twice — keeps exactly one definition, so
386
+ // a phase roster cannot drift from the roster the single-phase path builds. It
387
+ // also makes the composition law checkable and cheap:
388
+ //
389
+ // [...controlPlane, ...executorLanes, ...observer] === buildPlaneMemberRoster(...)
390
+ //
391
+ // which a unit test asserts, so the split can never silently drop or reorder a
392
+ // member. A pair of independent builders would have needed that property
393
+ // re-established by inspection after every future edit.
394
+ /** Is this the name of an executor lane? */
395
+ function isExecutorLaneName(name) {
396
+ return name.startsWith("executor-");
397
+ }
398
+ /**
399
+ * Phase one: the server and the reconciler worker, in that order.
400
+ *
401
+ * ZERO executor lanes, so no claim scope is required or accepted — which is the
402
+ * one legitimate zero-lane roster in the system. It is INTERNAL: there is no
403
+ * public `plane up --executors 0`, because "start a plane that can never do any
404
+ * work" is not a state an operator should be able to ask for by hand.
405
+ *
406
+ * The observer is deliberately NOT here. See {@link PlaneLifecyclePhase}.
407
+ */
408
+ export function buildPlaneControlPlaneRoster(params) {
409
+ return buildPlaneMemberRoster({ ...params, executors: 0 }).filter((member) => member.name === "server" || member.name === "worker");
410
+ }
411
+ /**
412
+ * Phase two, part one: the scoped executor lanes.
413
+ *
414
+ * `claimScope` is REQUIRED by the type, not merely by the runtime check inside
415
+ * the builder — a lane roster is the one thing that cannot be built without a
416
+ * scope, and making that a compile-time fact removes the whole class of caller
417
+ * that forgets.
418
+ */
419
+ export function buildPlaneExecutorLaneRoster(params) {
420
+ return buildPlaneMemberRoster(params).filter((member) => isExecutorLaneName(member.name));
421
+ }
422
+ /**
423
+ * Phase two, part two: the dead-man observer, started LAST.
424
+ *
425
+ * Split out so the runtime can start it only after every lane's per-instance
426
+ * heartbeat gate has closed. Starting it at control-plane time would put it on a
427
+ * plane with zero executor lanes for however long run creation takes — precisely
428
+ * the state its executor-component sweep would alert on, and precisely the boot
429
+ * noise the ordering contract above exists to prevent.
430
+ */
431
+ export function buildPlaneObserverRoster(params) {
432
+ return buildPlaneMemberRoster({ ...params, executors: 0 }).filter((member) => member.name === "observer");
433
+ }
364
434
  /**
365
435
  * Deterministic, distinct, CLI-safe executor id for one lane.
366
436
  *
@@ -15,11 +15,97 @@ import path from "path";
15
15
  import { PLANE_SERVER_PORT_ENV_VAR, planeCheckFinding, planeCheckPassed, } from "./types.js";
16
16
  import { checkPlaneBuildFreshness, checkPlaneRuntimeEntrypoint } from "./build-freshness.js";
17
17
  import { checkAlembicHead } from "./alembic-head.js";
18
- import { manifestHasLiveProcess, readPlaneManifest } from "./manifest.js";
18
+ import { manifestHasLiveProcess, planeManifestLifecycle, probeManifestMembers, readPlaneManifest, } from "./manifest.js";
19
19
  import { detectClaudeLogin, formatClaudeLoginAdvisory } from "../claude-login.js";
20
20
  import { createReadinessCheck, createReadinessCheckSafely, } from "../readiness-check.js";
21
21
  /** Files that must exist for a path to be this repository's root. */
22
22
  const REQUIRED_REPO_FILES = ["main.py", "worker.py", "alembic.ini"];
23
+ /** Phases in which a plane already serving a BOUND run can be re-attached to it. */
24
+ const BOUND_RUN_ATTACHABLE_PHASES = [
25
+ "lanes-starting",
26
+ "lanes-ready",
27
+ "ready",
28
+ ];
29
+ /**
30
+ * Classify a VALID, LIVE manifest found at `repoRoot` for attachment. Pure.
31
+ *
32
+ * The exact-root comparison is the same one `bindPlaneManifestEpicRun` and the
33
+ * guarded lifecycle writers apply (`manifest.repoRoot !== repoRoot`), so a
34
+ * manifest this repository could not bind or request lanes on is never reported
35
+ * attachable.
36
+ */
37
+ export function classifyLivePlaneForAttach(repoRoot, manifest, proc) {
38
+ if (manifest.repoRoot !== repoRoot)
39
+ return { kind: "foreign-root" };
40
+ const lifecycle = planeManifestLifecycle(manifest);
41
+ const probes = probeManifestMembers(manifest, proc);
42
+ const memberAlive = (name) => probes.some((probe) => probe.member.name === name && probe.liveness === "alive");
43
+ if (!memberAlive("server") || !memberAlive("worker")) {
44
+ return { kind: "not-attachable", lifecycle };
45
+ }
46
+ if (lifecycle === "control-plane-ready")
47
+ return { kind: "attachable", manifest };
48
+ if (BOUND_RUN_ATTACHABLE_PHASES.includes(lifecycle) &&
49
+ manifest.epicRunId !== undefined &&
50
+ manifest.laneScope !== undefined) {
51
+ return { kind: "attachable", manifest };
52
+ }
53
+ return { kind: "not-attachable", lifecycle };
54
+ }
55
+ /** Fixed remediation for an occupied port that is not this repository's plane. */
56
+ export const PLANE_PORT_UNRELATED_LISTENER_REMEDIATION = "the listener is not a live plane of this repository — it is often a SIBLING WORKTREE's " +
57
+ "server. Stop it (or wind down its plane from its own worktree) and run drive-epic again.";
58
+ /** Fixed remediation for a live manifest naming a different repository root. */
59
+ export const PLANE_FOREIGN_ROOT_REMEDIATION = "the plane manifest here names a different repository root (copied or symlinked). " +
60
+ "Inspect .bridge/plane/plane.json, wind that plane down from its own repository, and run " +
61
+ "drive-epic again.";
62
+ /** Fixed remediation for this repository's live plane that cannot take a run. */
63
+ export const PLANE_NOT_ATTACHABLE_REMEDIATION = "this repository's plane is running but cannot take a new run — it is still starting, its " +
64
+ "server or reconciler is gone, its lanes failed, or its lanes were started by hand with " +
65
+ "their own claim scope. Inspect it with `plane status`; wait for it to finish starting, or " +
66
+ "wind it down with `plane down` and run drive-epic again to cold-start.";
67
+ /** Fixed remediation when this repository's attachable plane was not attached to. */
68
+ export const PLANE_ATTACHABLE_BASE_URL_REMEDIATION = `do not stop it — point BAPI_BASE_URL at the plane endpoint (http://127.0.0.1:<port>, the port ` +
69
+ `${PLANE_SERVER_PORT_ENV_VAR} or the default, with no trailing slash) and run drive-epic again to attach.`;
70
+ /**
71
+ * Describe what occupies the plane port, for a single operator refusal. Pure.
72
+ *
73
+ * Every non-attachable occupant keeps its own classification and fix, but they
74
+ * all render as ONE refusal: the operator has one thing to do, and it depends on
75
+ * which of these it is.
76
+ */
77
+ export function describePlanePortOccupant(observation) {
78
+ switch (observation?.kind) {
79
+ case "unvalidated":
80
+ return {
81
+ classification: "an existing plane manifest could not be validated",
82
+ remediation: PLANE_MANIFEST_UNREADABLE_REMEDIATION,
83
+ };
84
+ case "foreign-root":
85
+ return {
86
+ classification: "a live plane manifest names a different repository root",
87
+ remediation: PLANE_FOREIGN_ROOT_REMEDIATION,
88
+ };
89
+ case "not-attachable":
90
+ return {
91
+ classification: `this repository's plane is live but not attachable (lifecycle: ${observation.lifecycle})`,
92
+ remediation: PLANE_NOT_ATTACHABLE_REMEDIATION,
93
+ };
94
+ case "attachable":
95
+ // Reached only when the caller declined to attach — the Bridge API it
96
+ // talks to is not this plane. Telling the operator to stop the listener
97
+ // would stop a healthy plane; the fix is the base URL.
98
+ return {
99
+ classification: "this repository's own healthy plane holds the port, but BAPI_BASE_URL does not name it",
100
+ remediation: PLANE_ATTACHABLE_BASE_URL_REMEDIATION,
101
+ };
102
+ default:
103
+ return {
104
+ classification: "the port is held by a listener that is not a live plane of this repository",
105
+ remediation: PLANE_PORT_UNRELATED_LISTENER_REMEDIATION,
106
+ };
107
+ }
108
+ }
23
109
  /** Fixed remediation for a `plane up` run started outside the repository root. */
24
110
  export const PLANE_REPOSITORY_ROOT_REMEDIATION = "run `plane up` from the Bridge API repository root (the directory holding main.py, worker.py, and alembic.ini).";
25
111
  /** Fixed remediation for an unresolvable Bridge repository identity. */
@@ -148,8 +234,11 @@ export async function runPlanePreflight(repoRoot, deps) {
148
234
  // An unresolvable override is a configuration failure, not a port failure:
149
235
  // there is no port to probe, so the probe is skipped entirely rather than
150
236
  // falling back to 8000 and reporting on a port the operator did not ask for.
237
+ const observations = {};
151
238
  if (deps.endpoint.ok) {
152
- record(await checkServerPort(deps, deps.endpoint.endpoint));
239
+ const port = await checkServerPort(deps, deps.endpoint.endpoint);
240
+ record(port);
241
+ observations.serverPort = port.probe;
153
242
  }
154
243
  else {
155
244
  // The endpoint resolver owns this diagnostic's prose; the structured fix is
@@ -166,7 +255,9 @@ export async function runPlanePreflight(repoRoot, deps) {
166
255
  fileExists: (filePath) => fileExists(filePath, deps.fs),
167
256
  platform: deps.platform,
168
257
  }));
169
- record(await checkExistingPlane(repoRoot, deps));
258
+ const existing = await checkExistingPlane(repoRoot, deps);
259
+ record(existing);
260
+ observations.existingPlane = existing.observation;
170
261
  const blocking = diagnostics.filter((d) => d.severity === "blocking");
171
262
  // `!runtimeEntrypoint.ok` is already covered by the blocking count; it is
172
263
  // repeated here so the compiler narrows the union rather than requiring a
@@ -175,12 +266,13 @@ export async function runPlanePreflight(repoRoot, deps) {
175
266
  // here, like `!runtimeEntrypoint.ok`, so the compiler narrows the union rather
176
267
  // than requiring a non-null assertion on the context field below.
177
268
  if (blocking.length > 0 || !credentials.ok || !runtimeEntrypoint.ok || !deps.endpoint.ok) {
178
- return { ok: false, diagnostics, outcomes };
269
+ return { ok: false, diagnostics, outcomes, observations };
179
270
  }
180
271
  return {
181
272
  ok: true,
182
273
  diagnostics,
183
274
  outcomes,
275
+ observations,
184
276
  context: {
185
277
  repoRoot,
186
278
  repoName: credentials.repoName,
@@ -559,16 +651,22 @@ function createResolverWarningBuffer() {
559
651
  * refuse a perfectly good bring-up.
560
652
  */
561
653
  export async function checkServerPort(deps, endpoint) {
654
+ // BAPI-1153: the probe's own result kind rides beside the unchanged report, so
655
+ // a composing caller reads what was observed instead of re-probing the port.
656
+ const report = await checkServerPortReport(deps, endpoint);
657
+ return { ...report.report, probe: report.probe };
658
+ }
659
+ async function checkServerPortReport(deps, endpoint) {
562
660
  const label = "Local server port";
563
661
  const target = `${endpoint.host}:${endpoint.port}`;
564
662
  const result = await deps.probePort(endpoint.host, endpoint.port, PLANE_PORT_PROBE_TIMEOUT_MS);
565
663
  if (result.kind === "refused") {
566
- return planeCheckPassed("server-port", label, `${target} is free`);
664
+ return { report: planeCheckPassed("server-port", label, `${target} is free`), probe: "refused" };
567
665
  }
568
666
  // Severity is unchanged in both branches: `connected` still blocks, an
569
667
  // indeterminate probe still only warns.
570
668
  if (result.kind === "connected") {
571
- return planeCheckFinding({
669
+ const occupied = planeCheckFinding({
572
670
  check: "server-port",
573
671
  severity: "blocking",
574
672
  message: `${target} is already accepting connections. ` +
@@ -577,8 +675,9 @@ export async function checkServerPort(deps, endpoint) {
577
675
  "to a free port.",
578
676
  remediation: PLANE_SERVER_PORT_OCCUPIED_REMEDIATION,
579
677
  }, label, `${target} is already accepting connections`);
678
+ return { report: occupied, probe: "connected" };
580
679
  }
581
- return planeCheckFinding({
680
+ const indeterminate = planeCheckFinding({
582
681
  check: "server-port",
583
682
  severity: "warning",
584
683
  message: `could not determine whether ${target} is free ` +
@@ -588,6 +687,7 @@ export async function checkServerPort(deps, endpoint) {
588
687
  // The probe's own `error` text stays in the legacy message for the operator
589
688
  // at the terminal and is not copied into the consolidated detail.
590
689
  `could not determine whether ${target} is free`);
690
+ return { report: indeterminate, probe: "error" };
591
691
  }
592
692
  /**
593
693
  * Refuse to start over a plane that is still alive.
@@ -602,11 +702,18 @@ export async function checkExistingPlane(repoRoot, deps) {
602
702
  // Three distinct states, three distinct fixes (BAPI-1055): unreadable/malformed,
603
703
  // stale, and live. Manifest validation, the liveness probe, and the
604
704
  // never-touch-disk rule are all unchanged.
705
+ //
706
+ // BAPI-1153: each arm also returns its ATTACH classification. The diagnostics
707
+ // are untouched — a live plane still blocks `plane up` — and the classification
708
+ // is read from this one manifest read, never a second one.
605
709
  if (read.kind === "missing") {
606
- return planeCheckPassed("existing-plane", label, "no plane manifest is present");
710
+ return {
711
+ ...planeCheckPassed("existing-plane", label, "no plane manifest is present"),
712
+ observation: { kind: "none" },
713
+ };
607
714
  }
608
715
  if (read.kind !== "valid") {
609
- return planeCheckFinding({
716
+ const unvalidated = planeCheckFinding({
610
717
  check: "existing-plane",
611
718
  severity: "blocking",
612
719
  message: `an existing .bridge/plane/plane.json could not be validated (${read.error}). ` +
@@ -614,6 +721,7 @@ export async function checkExistingPlane(repoRoot, deps) {
614
721
  "it by hand if no plane is running.",
615
722
  remediation: PLANE_MANIFEST_UNREADABLE_REMEDIATION,
616
723
  }, label, "an existing plane manifest could not be validated");
724
+ return { ...unvalidated, observation: { kind: "unvalidated" } };
617
725
  }
618
726
  // BAPI-882: a manifest carrying THIS process's own plane id is its own claim,
619
727
  // not a competitor. Checked after validation and BEFORE the liveness probe,
@@ -632,13 +740,17 @@ export async function checkExistingPlane(repoRoot, deps) {
632
740
  // A live manifest with a DIFFERENT plane id still blocks, unchanged — that is
633
741
  // a genuine second plane and the whole point of the check.
634
742
  if (deps.ownPlaneId !== undefined && read.manifest.planeId === deps.ownPlaneId) {
635
- return planeCheckPassed("existing-plane", label, "the existing manifest carries this launch's own plane id");
743
+ return {
744
+ ...planeCheckPassed("existing-plane", label, "the existing manifest carries this launch's own plane id"),
745
+ observation: { kind: "own-plane" },
746
+ };
636
747
  }
637
748
  if (!manifestHasLiveProcess(read.manifest, deps.proc)) {
638
749
  // Stale. Reported as information so the operator understands why an old
639
750
  // manifest is about to be replaced; the claim revalidates before it does.
640
751
  // No remediation: the operator has nothing to do — the claim replaces it.
641
752
  return {
753
+ observation: { kind: "stale" },
642
754
  diagnostic: {
643
755
  check: "existing-plane",
644
756
  severity: "warning",
@@ -656,7 +768,7 @@ export async function checkExistingPlane(repoRoot, deps) {
656
768
  ],
657
769
  };
658
770
  }
659
- return planeCheckFinding({
771
+ const running = planeCheckFinding({
660
772
  check: "existing-plane",
661
773
  severity: "blocking",
662
774
  message: `a plane is already running (supervisor pid ${read.manifest.supervisorPid}). ` +
@@ -666,6 +778,10 @@ export async function checkExistingPlane(repoRoot, deps) {
666
778
  // The supervisor pid stays in the legacy message; the consolidated detail
667
779
  // reports the fact, not the process identifier.
668
780
  "a plane is already running for this repository");
781
+ return {
782
+ ...running,
783
+ observation: classifyLivePlaneForAttach(repoRoot, read.manifest, deps.proc),
784
+ };
669
785
  }
670
786
  async function fileExists(filePath, fs) {
671
787
  try {
@@ -702,7 +818,7 @@ export const PLANE_READINESS_DESCRIPTORS = [
702
818
  /** Fixed detail for a plane prerequisite the preflight never reported on. */
703
819
  const PLANE_UNREPORTED_DETAIL = "not reported — the plane preflight produced no outcome for this prerequisite";
704
820
  /** Fixed remediation for a plane prerequisite the preflight never reported on. */
705
- const PLANE_UNREPORTED_REMEDIATION = "run `plane up` (or `conductor readiness` again) to re-collect the plane preflight; this " +
821
+ const PLANE_UNREPORTED_REMEDIATION = "run `plane up` (or `epic-implementer readiness` again) to re-collect the plane preflight; this " +
706
822
  "prerequisite's state is unknown, not healthy.";
707
823
  /**
708
824
  * Project a plane preflight result into canonical readiness checks.
@@ -18,7 +18,7 @@
18
18
  * liveness is unknown, the manifest is retained so a later `plane down` can
19
19
  * retry. Clearing it would orphan the survivor permanently.
20
20
  */
21
- import { clearPlaneManifest, probeManifestMembers, readPlaneManifest, } from "./manifest.js";
21
+ import { clearPlaneManifest, planeManifestLifecycle, probeManifestMembers, readPlaneManifest, } from "./manifest.js";
22
22
  /** SIGTERM→SIGKILL grace period. */
23
23
  export const PLANE_SHUTDOWN_GRACE_MS = 10_000;
24
24
  /** How long stragglers get after SIGKILL before they are declared unknown. */
@@ -72,6 +72,19 @@ export async function shutdownPlane(repoRoot, deps) {
72
72
  }
73
73
  const manifest = read.manifest;
74
74
  const messages = [];
75
+ // BAPI-1102 — say WHICH phase is being wound down when it is not a complete
76
+ // plane. Every intermediate phase is windable by exactly the same mechanism as
77
+ // a finished one (the whole plane shares one process group, and members that
78
+ // were never spawned have no pid to probe), so nothing special happens here —
79
+ // but an operator stopping a plane that is still starting deserves to be told
80
+ // that is what they stopped, rather than reading a report that looks like a
81
+ // plane with missing members.
82
+ const lifecycle = planeManifestLifecycle(manifest);
83
+ if (lifecycle !== "ready") {
84
+ messages.push(`This plane had not finished starting (${lifecycle}). Members that had not been ` +
85
+ "spawned yet have no pid and are reported as such; every member that HAD started is " +
86
+ "signalled with the rest of the process group.");
87
+ }
75
88
  if (read.unrecognizedMemberNames.length > 0) {
76
89
  // Said out loud, never assumed harmless (BAPI-1029). The wind-down still
77
90
  // reaches these members — the whole plane shares one process group, and the
@@ -217,7 +230,7 @@ async function waitForDeath(manifest, deps, budgetMs, pollMs) {
217
230
  /**
218
231
  * Render the server-side run-shutdown outcome (BAPI-872) as its own message
219
232
  * lines — never claims signalling local processes changed server-side state,
220
- * and always names the supported `conductor stop-run` check/remediation
233
+ * and always names the supported `epic-implementer stop-run` check/remediation
221
234
  * surface for every outcome that is not a fresh committed stop.
222
235
  */
223
236
  function formatRunShutdown(outcome) {
@@ -226,7 +239,7 @@ function formatRunShutdown(outcome) {
226
239
  return [
227
240
  "Run shutdown: no epic run is bound to this plane. If a server-side run is " +
228
241
  "active, this command did NOT stop it and its queued work may remain live. " +
229
- "Run `conductor stop-run --epic-run-id <epic_run_id>` to stop it explicitly.",
242
+ "Run `epic-implementer stop-run --epic-run-id <epic_run_id>` to stop it explicitly.",
230
243
  ];
231
244
  case "committed":
232
245
  return [`Run shutdown: stopped run ${outcome.epicRunId} — its queued work was cancelled.`];
@@ -236,13 +249,13 @@ function formatRunShutdown(outcome) {
236
249
  return [
237
250
  `Run shutdown: run ${outcome.epicRunId} is terminal (${outcome.status}) and cannot be ` +
238
251
  "stopped. Signalling local processes does not change this — run " +
239
- `\`conductor stop-run --epic-run-id ${outcome.epicRunId}\` to check its state.`,
252
+ `\`epic-implementer stop-run --epic-run-id ${outcome.epicRunId}\` to check its state.`,
240
253
  ];
241
254
  case "unavailable":
242
255
  return [
243
256
  `Run shutdown: could not reach the Bridge API to stop run ${outcome.epicRunId} ` +
244
257
  `(${outcome.message}). The run may remain active with queued work — run ` +
245
- `\`conductor stop-run --epic-run-id ${outcome.epicRunId}\` to retry.`,
258
+ `\`epic-implementer stop-run --epic-run-id ${outcome.epicRunId}\` to retry.`,
246
259
  ];
247
260
  }
248
261
  }