@bridge_gpt/mcp-server 0.2.49 → 0.2.51

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 (62) hide show
  1. package/README.md +25 -8
  2. package/build/base-ref.js +28 -3
  3. package/build/claude-review-workflow-drift-probe.js +130 -0
  4. package/build/claude-review-workflow-drift.js +173 -0
  5. package/build/claude-review-workflow.js +81 -16
  6. package/build/commands.generated.js +5 -5
  7. package/build/conduct-epic/bridge-client.js +115 -1
  8. package/build/conduct-epic/cli.js +351 -33
  9. package/build/conduct-epic/cut-protocol.js +51 -0
  10. package/build/conductor/done-gate.js +25 -3
  11. package/build/conductor/install-doctor.js +65 -5
  12. package/build/conductor/latest-check-selector.js +170 -0
  13. package/build/conductor/local-merge.js +8 -6
  14. package/build/conductor-bin.js +1 -1
  15. package/build/{brainstorm-files.js → council-files.js} +15 -15
  16. package/build/decision-page-schema.js +1 -1
  17. package/build/docs.generated.js +1 -1
  18. package/build/doctor.js +352 -4
  19. package/build/epic-integration-pr.js +280 -0
  20. package/build/executor/job-runner.js +7 -1
  21. package/build/executor/merge-job.js +46 -1
  22. package/build/executor/worktree.js +46 -1
  23. package/build/index.js +153 -65
  24. package/build/init.js +9 -2
  25. package/build/install-bridge.js +60 -2
  26. package/build/install-reexec.js +47 -9
  27. package/build/pipelines.generated.js +8 -2
  28. package/build/plan-epic-conductor-eligibility.js +183 -0
  29. package/build/plane/cli.js +12 -2
  30. package/build/plane/manifest.js +25 -1
  31. package/build/plane/member-roster.js +61 -7
  32. package/build/plane/preflight.js +24 -9
  33. package/build/plane/supervisor.js +77 -5
  34. package/build/plane/types.js +23 -3
  35. package/build/readme.generated.js +1 -1
  36. package/build/run-unit-tests-launcher.js +2 -1
  37. package/build/setup-epic.js +32 -0
  38. package/build/sfcc/reads-custom-object-def.js +10 -13
  39. package/build/sfcc/reads-site-preference.js +5 -5
  40. package/build/sfcc/reads-system-object.js +4 -4
  41. package/build/sfcc/writes-custom-object-def.js +7 -7
  42. package/build/sfcc/writes-site-preference.js +4 -3
  43. package/build/sfcc/writes-system-object.js +7 -6
  44. package/build/stale-worktree-doctor.js +120 -0
  45. package/build/start-tickets-prereqs.js +70 -0
  46. package/build/start-tickets.js +91 -3
  47. package/build/version.generated.js +3 -2
  48. package/package.json +6 -3
  49. package/pipelines/plan-epic.json +5 -0
  50. package/build/chain-orchestrator.js +0 -1457
  51. package/build/chain-utils.js +0 -68
  52. package/build/command-catalog.js +0 -376
  53. package/build/schedule-run.js +0 -1300
  54. package/build/schedule-store.js +0 -172
  55. package/build/scheduled-prompt.js +0 -115
  56. package/build/scheduler-backends/at-fallback.js +0 -139
  57. package/build/scheduler-backends/escaping.js +0 -143
  58. package/build/scheduler-backends/index.js +0 -72
  59. package/build/scheduler-backends/launchd.js +0 -225
  60. package/build/scheduler-backends/systemd-user.js +0 -250
  61. package/build/scheduler-backends/task-scheduler.js +0 -214
  62. package/build/scheduler-backends/types.js +0 -23
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Combined merge/review conductor-eligibility assessment for `plan-epic` (BAPI-964).
3
+ *
4
+ * Runs once the epic decomposition's child manifest is frozen and every
5
+ * sub-task's exploration text is written, so this module can predict — before
6
+ * any conductor worker is spawned — which children are likely to trip the
7
+ * runtime workflow-file merge guard (`api/library/vcs/conductor_merge_service.py`)
8
+ * and which of those additionally touch `claude-review.yml` itself, where
9
+ * automated review cannot run at all (BAPI-941's supply-chain preflight refuses
10
+ * to review a workflow that differs from the base branch's copy).
11
+ *
12
+ * The BROADER merge classification is delegated entirely to the Python
13
+ * classifier via one Bridge API call for the complete child set — this module
14
+ * never reimplements "what counts as a workflow file". The NARROWER review
15
+ * subclass is classified locally by checking each child's combined text for the
16
+ * full `CLAUDE_REVIEW_WORKFLOW_RELPATH`, imported (not re-declared) from
17
+ * `claude-review-workflow-drift.js`.
18
+ *
19
+ * Fails open, deliberately: any validation failure, classification failure, or
20
+ * internal contradiction (a review-subset child the backend did not also mark
21
+ * merge-blocked) becomes the explicit `unavailable` result — never a fabricated
22
+ * zero count. A caller that cannot get a real answer must say so, not guess.
23
+ */
24
+ import { CLAUDE_REVIEW_WORKFLOW_RELPATH } from "./claude-review-workflow-drift.js";
25
+ /**
26
+ * Build the real network-backed classification client that POSTs the complete
27
+ * child set to `/jira/planning/epic-workflow-eligibility` exactly once.
28
+ */
29
+ export function createBridgeEpicConductorEligibilityClient(deps) {
30
+ return {
31
+ async classifyChildren(children) {
32
+ const fetchImpl = deps.fetchImpl ?? fetch;
33
+ const resp = await fetchImpl(deps.buildUrl("/planning/epic-workflow-eligibility"), {
34
+ method: "POST",
35
+ headers: await deps.getPostHeaders(),
36
+ body: JSON.stringify({ repo_name: deps.repoName, children }),
37
+ });
38
+ if (!resp.ok) {
39
+ throw new Error(`epic-workflow-eligibility classification request failed: ${resp.status}`);
40
+ }
41
+ return (await resp.json());
42
+ },
43
+ };
44
+ }
45
+ const UNAVAILABLE = { status: "unavailable" };
46
+ function isNonBlankString(value) {
47
+ return typeof value === "string" && value.trim().length > 0;
48
+ }
49
+ /** Treat a missing/undefined optional field as empty text; reject a non-string value. */
50
+ function optionalTextOrNull(value) {
51
+ if (value === undefined || value === null)
52
+ return "";
53
+ return typeof value === "string" ? value : null;
54
+ }
55
+ /** Deterministically assemble one child's scan text from its authoritative fields. */
56
+ function buildCombinedText(child) {
57
+ return [child.title, child.scope, child.description, child.requirements]
58
+ .filter((part) => typeof part === "string" && part.length > 0)
59
+ .join("\n\n");
60
+ }
61
+ /** Validate the child collection at the module boundary. `null` means malformed. */
62
+ function validateChildren(children) {
63
+ if (!Array.isArray(children) || children.length === 0)
64
+ return null;
65
+ const validated = [];
66
+ for (const child of children) {
67
+ if (child === null || typeof child !== "object")
68
+ return null;
69
+ if (!isNonBlankString(child.id) || !isNonBlankString(child.title))
70
+ return null;
71
+ const scope = optionalTextOrNull(child.scope);
72
+ const description = optionalTextOrNull(child.description);
73
+ const requirements = optionalTextOrNull(child.requirements);
74
+ if (scope === null || description === null || requirements === null)
75
+ return null;
76
+ validated.push({
77
+ id: child.id,
78
+ title: child.title,
79
+ combinedText: buildCombinedText({
80
+ id: child.id,
81
+ title: child.title,
82
+ scope,
83
+ description,
84
+ requirements,
85
+ }),
86
+ });
87
+ }
88
+ return validated;
89
+ }
90
+ /**
91
+ * Assess conductor merge/review eligibility for one frozen epic child set.
92
+ *
93
+ * Sends the complete ordered child set to the injected Bridge client exactly
94
+ * once. Never throws — every failure path (malformed input, client rejection,
95
+ * malformed backend response, an internal merge/review contradiction) resolves
96
+ * to the explicit `unavailable` result, diagnosed with `console.error` (never
97
+ * `console.log`, which is reserved for the MCP stdio transport).
98
+ */
99
+ export async function assessEpicConductorEligibility(children, deps) {
100
+ const validated = validateChildren(children);
101
+ if (validated === null) {
102
+ console.error("assessEpicConductorEligibility: malformed child collection; unavailable.");
103
+ return UNAVAILABLE;
104
+ }
105
+ let response;
106
+ try {
107
+ response = await deps.client.classifyChildren(validated.map((child) => ({ child_id: child.id, combined_text: child.combinedText })));
108
+ }
109
+ catch (err) {
110
+ console.error("assessEpicConductorEligibility: Bridge classification call failed:", err);
111
+ return UNAVAILABLE;
112
+ }
113
+ if (response === null ||
114
+ typeof response !== "object" ||
115
+ !Array.isArray(response.predictions) ||
116
+ typeof response.total_children !== "number" ||
117
+ typeof response.predicted_workflow_children !== "number" ||
118
+ typeof response.reason !== "string") {
119
+ console.error("assessEpicConductorEligibility: malformed backend response shape; unavailable.");
120
+ return UNAVAILABLE;
121
+ }
122
+ if (response.predictions.length !== validated.length) {
123
+ console.error("assessEpicConductorEligibility: backend prediction count does not match child count; unavailable.");
124
+ return UNAVAILABLE;
125
+ }
126
+ // The backend is contractually order-preserving; still verify identity and
127
+ // order rather than trusting it, so a reordered/duplicate/unknown response
128
+ // never silently mislabels a child's prediction.
129
+ const predictionById = new Map();
130
+ for (const prediction of response.predictions) {
131
+ if (prediction === null ||
132
+ typeof prediction !== "object" ||
133
+ !isNonBlankString(prediction.child_id) ||
134
+ typeof prediction.predicted_workflow_modified !== "boolean" ||
135
+ !Array.isArray(prediction.matched_paths)) {
136
+ console.error("assessEpicConductorEligibility: malformed per-child prediction; unavailable.");
137
+ return UNAVAILABLE;
138
+ }
139
+ if (predictionById.has(prediction.child_id)) {
140
+ console.error("assessEpicConductorEligibility: duplicate child_id in backend response; unavailable.");
141
+ return UNAVAILABLE;
142
+ }
143
+ predictionById.set(prediction.child_id, prediction);
144
+ }
145
+ const affectedChildren = [];
146
+ let reviewSubsetChildren = 0;
147
+ let predictedWorkflowChildren = 0;
148
+ for (const child of validated) {
149
+ const prediction = predictionById.get(child.id);
150
+ if (!prediction) {
151
+ console.error(`assessEpicConductorEligibility: backend response is missing child ${child.id}; unavailable.`);
152
+ return UNAVAILABLE;
153
+ }
154
+ const reviewPathReferenced = child.combinedText.includes(CLAUDE_REVIEW_WORKFLOW_RELPATH);
155
+ if (reviewPathReferenced && !prediction.predicted_workflow_modified) {
156
+ // Subset invariant violated: a child naming claude-review.yml itself must
157
+ // also be in the broader merge-blocked set. Publishing this as a
158
+ // "review-only" child would contradict the merge count, so refuse instead.
159
+ console.error(`assessEpicConductorEligibility: child ${child.id} references the review workflow ` +
160
+ "but the backend did not mark it merge-blocked; refusing contradictory result.");
161
+ return UNAVAILABLE;
162
+ }
163
+ if (!prediction.predicted_workflow_modified)
164
+ continue;
165
+ predictedWorkflowChildren += 1;
166
+ if (reviewPathReferenced)
167
+ reviewSubsetChildren += 1;
168
+ affectedChildren.push({
169
+ id: child.id,
170
+ title: child.title,
171
+ matchedPaths: [...prediction.matched_paths],
172
+ requiresHandReview: reviewPathReferenced,
173
+ });
174
+ }
175
+ return {
176
+ status: "assessed",
177
+ totalChildren: validated.length,
178
+ predictedWorkflowChildren,
179
+ reason: response.reason,
180
+ reviewSubsetChildren,
181
+ affectedChildren,
182
+ };
183
+ }
@@ -17,7 +17,7 @@ import { PLANE_RUNTIME_ENTRYPOINT_REFUSAL } from "./build-freshness.js";
17
17
  import { relativeLogPathFor } from "./manifest.js";
18
18
  import { claimPlaneManifest } from "./manifest.js";
19
19
  import { runPlanePreflight } from "./preflight.js";
20
- import { buildPlaneMemberRoster } from "./member-roster.js";
20
+ import { buildPlaneMemberRoster, resolvePlaneServerEndpoint } from "./member-roster.js";
21
21
  import { getPlaneStatus, formatPlaneStatus } from "./status.js";
22
22
  import { shutdownPlane, formatPlaneShutdown } from "./shutdown.js";
23
23
  import { launchPlaneSupervisor, runPlaneRuntime, } from "./supervisor.js";
@@ -454,7 +454,7 @@ function printStartupBanner(sinks, context, memberCount, executors, launch) {
454
454
  sinks.stdout("");
455
455
  sinks.stdout(`Plane up — ${memberCount} members (${executors} executor lane(s))`);
456
456
  sinks.stdout(` repository ${context.repoName} (${context.repoRoot})`);
457
- sinks.stdout(` server ${context.baseUrl} (no --reload)`);
457
+ sinks.stdout(` server ${context.endpoint.baseUrl} (no --reload)`);
458
458
  sinks.stdout(` supervisor pid ${launch.supervisorPid}, process group ${launch.supervisorPgid}`);
459
459
  sinks.stdout(` logs ${PLANE_RUNTIME_DIR}/`);
460
460
  sinks.stdout(" crash policy members are NOT restarted; a member exit is reported loudly.");
@@ -525,9 +525,19 @@ async function runRuntimeAction(executors, overrides) {
525
525
  });
526
526
  return result.exitCode;
527
527
  }
528
+ /**
529
+ * Bind preflight's dependencies to the real platform for one environment.
530
+ *
531
+ * This is the ONLY production path that reads the plane's server-port override
532
+ * (BAPI-950). Both `plane up` and the private `plane __runtime` action go
533
+ * through here, so the launcher and the detached runtime it spawns resolve the
534
+ * endpoint by exactly the same rule — a second lookup elsewhere is what would
535
+ * let the two disagree about which port the plane is on.
536
+ */
528
537
  function buildPreflightDeps(env) {
529
538
  return {
530
539
  env,
540
+ endpoint: resolvePlaneServerEndpoint(env),
531
541
  platform: process.platform,
532
542
  homedir: resolveHomedir,
533
543
  fs: createPlaneFsDeps(),
@@ -259,14 +259,38 @@ export function manifestHasLiveProcess(manifest, proc) {
259
259
  export function formatPlaneManifest(manifest) {
260
260
  return `${JSON.stringify(manifest, null, 2)}\n`;
261
261
  }
262
+ /**
263
+ * Monotonic per-process discriminator for temporary manifest filenames.
264
+ *
265
+ * Module-local and used for nothing else. It never appears in the published
266
+ * manifest, in a diagnostic, or in any path that outlives the rename below.
267
+ */
268
+ let manifestWriteCounter = 0;
262
269
  /**
263
270
  * Persist a state transition through a same-directory temporary file and an
264
271
  * atomic rename, so a crash mid-write can never leave a half-written manifest
265
272
  * that a subsequent `plane down` would read as a kill list.
273
+ *
274
+ * The temporary name is unique per WRITE, not per plane (BAPI-950). It used to
275
+ * be keyed only by `planeId`, which every writer of the same plane shares — so
276
+ * two overlapping writes wrote the one temp file simultaneously and the rename
277
+ * published their interleaved bytes as `plane.json`. `readPlaneManifest` then
278
+ * reported "manifest is not valid JSON" and `plane down` refused to remove a
279
+ * manifest it could not validate, leaving the plane un-windable.
280
+ *
281
+ * The runtime serializes its own writes (see `runPlaneRuntime`), which is the
282
+ * primary fix; this is defense in depth for INDEPENDENT writers — the launcher,
283
+ * the claim path, an epic-run binding — that no single in-process queue can
284
+ * order. `process.pid` separates processes, the counter separates writes within
285
+ * one process, and `planeId` is retained so a stray temp file is still
286
+ * attributable to the plane that produced it.
266
287
  */
267
288
  export async function writePlaneManifest(manifest, fs) {
268
289
  const { manifestPath } = getPlanePaths(manifest.repoRoot);
269
- const tempPath = `${manifestPath}.${manifest.planeId}.tmp`;
290
+ manifestWriteCounter += 1;
291
+ // Same directory as `plane.json`, so the rename stays a same-filesystem
292
+ // atomic replacement rather than a copy across a mount boundary.
293
+ const tempPath = `${manifestPath}.${manifest.planeId}.${process.pid}.${manifestWriteCounter}.tmp`;
270
294
  await fs.writeFile(tempPath, formatPlaneManifest(manifest));
271
295
  await fs.rename(tempPath, manifestPath);
272
296
  }
@@ -17,7 +17,7 @@
17
17
  * member name, log path, or formatted description is built from a secret.
18
18
  */
19
19
  import path from "path";
20
- import { PLANE_SERVER_HOST, PLANE_SERVER_PORT, } from "./types.js";
20
+ import { PLANE_SERVER_HOST, PLANE_SERVER_PORT, PLANE_SERVER_PORT_ENV_VAR, } from "./types.js";
21
21
  import { relativeLogPathFor } from "./manifest.js";
22
22
  /** How long a member with a readiness probe gets to start listening. */
23
23
  export const PLANE_READINESS_TIMEOUT_MS = 60_000;
@@ -35,6 +35,55 @@ export function resolveUvicornExecutable(env) {
35
35
  ? configured.trim()
36
36
  : "uvicorn";
37
37
  }
38
+ /** Highest port number a TCP endpoint can occupy. */
39
+ const MAX_TCP_PORT = 65_535;
40
+ /**
41
+ * The only diagnostic an invalid override ever produces.
42
+ *
43
+ * Fixed prose, built from the variable's own name and the accepted range and
44
+ * nothing else. The supplied value is deliberately NOT echoed: an operator who
45
+ * pasted a secret into the wrong shell export would otherwise have it printed
46
+ * to the terminal and copied into the preflight trace on disk.
47
+ */
48
+ export const PLANE_SERVER_PORT_INVALID_MESSAGE = `${PLANE_SERVER_PORT_ENV_VAR} must be a whole TCP port number in the range 1..${MAX_TCP_PORT}. ` +
49
+ "Unset it to use the default port " +
50
+ `${PLANE_SERVER_PORT}, or export a valid port and retry.`;
51
+ /** Build an endpoint from a port, deriving the base URL from the same fields. */
52
+ function endpointForPort(port) {
53
+ return { host: PLANE_SERVER_HOST, port, baseUrl: `http://${PLANE_SERVER_HOST}:${port}` };
54
+ }
55
+ /**
56
+ * Resolve the local server endpoint from the environment, exactly once.
57
+ *
58
+ * Absent or blank leaves the historical contract untouched: the default is
59
+ * `127.0.0.1:8000` and `http://127.0.0.1:8000`, identical to the constants that
60
+ * were previously read directly.
61
+ *
62
+ * Anything present is validated STRICTLY and, when invalid, *refused* rather
63
+ * than quietly falling back to 8000. A silent fallback is the failure mode this
64
+ * override exists to remove: an operator who set the variable precisely because
65
+ * 8000 was occupied would watch the plane bind 8000 anyway and see a port
66
+ * collision they had already worked around.
67
+ *
68
+ * The host is never read from the environment — see {@link PLANE_SERVER_HOST}.
69
+ */
70
+ export function resolvePlaneServerEndpoint(env) {
71
+ const configured = env[PLANE_SERVER_PORT_ENV_VAR];
72
+ if (typeof configured !== "string" || configured.trim().length === 0) {
73
+ return { ok: true, endpoint: endpointForPort(PLANE_SERVER_PORT) };
74
+ }
75
+ const raw = configured.trim();
76
+ // Digits only, deliberately: `Number()` accepts "0x1f4", "1e3", " 8000 ", and
77
+ // "+8000", and `parseInt` accepts "8000x" by stopping at the first non-digit.
78
+ // Either would let a typo resolve to a port the operator never wrote.
79
+ if (!/^[0-9]+$/.test(raw))
80
+ return { ok: false, message: PLANE_SERVER_PORT_INVALID_MESSAGE };
81
+ const port = Number(raw);
82
+ if (!Number.isInteger(port) || port < 1 || port > MAX_TCP_PORT) {
83
+ return { ok: false, message: PLANE_SERVER_PORT_INVALID_MESSAGE };
84
+ }
85
+ return { ok: true, endpoint: endpointForPort(port) };
86
+ }
38
87
  /**
39
88
  * Build a child environment: a copy of the parent plus the resolved values the
40
89
  * existing processes already expect.
@@ -51,7 +100,7 @@ export function buildPlaneChildEnv(parentEnv, context) {
51
100
  env[key] = value;
52
101
  }
53
102
  env.BAPI_REPO_NAME = context.repoName;
54
- env.BAPI_BASE_URL = context.baseUrl;
103
+ env.BAPI_BASE_URL = context.endpoint.baseUrl;
55
104
  env.BAPI_API_KEY = context.bridgeApiKey;
56
105
  delete env.CONDUCTOR_DEAD_MAN_ONLY;
57
106
  return env;
@@ -64,6 +113,11 @@ export function buildPlaneMemberRoster(params) {
64
113
  const { context, executors, parentEnv, nodeExecutable } = params;
65
114
  const env = buildPlaneChildEnv(parentEnv, context);
66
115
  const cwd = context.repoRoot;
116
+ // One resolved endpoint for the bind argv, the readiness probe, every
117
+ // executor's `--base-url`, and the child `BAPI_BASE_URL`. Reading the default
118
+ // constants here instead is what would let uvicorn bind one port while the
119
+ // probe and the executors addressed another.
120
+ const endpoint = context.endpoint;
67
121
  const server = {
68
122
  name: "server",
69
123
  command: resolveUvicornExecutable(parentEnv),
@@ -73,16 +127,16 @@ export function buildPlaneMemberRoster(params) {
73
127
  args: [
74
128
  "main:app",
75
129
  "--host",
76
- PLANE_SERVER_HOST,
130
+ endpoint.host,
77
131
  "--port",
78
- String(PLANE_SERVER_PORT),
132
+ String(endpoint.port),
79
133
  ],
80
134
  cwd,
81
135
  env,
82
136
  logPath: relativeLogPathFor("server"),
83
137
  readiness: {
84
- host: PLANE_SERVER_HOST,
85
- port: PLANE_SERVER_PORT,
138
+ host: endpoint.host,
139
+ port: endpoint.port,
86
140
  timeoutMs: PLANE_READINESS_TIMEOUT_MS,
87
141
  },
88
142
  };
@@ -111,7 +165,7 @@ export function buildPlaneMemberRoster(params) {
111
165
  "--repo",
112
166
  context.repoName,
113
167
  "--base-url",
114
- context.baseUrl,
168
+ endpoint.baseUrl,
115
169
  "--executor-id",
116
170
  buildExecutorId(context.repoName, lane),
117
171
  ],
@@ -12,7 +12,7 @@
12
12
  * it — including any manifest belonging to a plane that is already running.
13
13
  */
14
14
  import path from "path";
15
- import { PLANE_SERVER_BASE_URL, PLANE_SERVER_HOST, PLANE_SERVER_PORT, } from "./types.js";
15
+ import { PLANE_SERVER_PORT_ENV_VAR, } from "./types.js";
16
16
  import { checkPlaneBuildFreshness, checkPlaneRuntimeEntrypoint } from "./build-freshness.js";
17
17
  import { checkAlembicHead } from "./alembic-head.js";
18
18
  import { manifestHasLiveProcess, readPlaneManifest } from "./manifest.js";
@@ -56,7 +56,15 @@ export async function runPlanePreflight(repoRoot, deps) {
56
56
  // unresolvable re-exec target aggregates with them instead of short-circuiting.
57
57
  const runtimeEntrypoint = deps.resolveRuntimeEntrypoint();
58
58
  add(checkPlaneRuntimeEntrypoint(runtimeEntrypoint));
59
- add(await checkServerPort(deps));
59
+ // An unresolvable override is a configuration failure, not a port failure:
60
+ // there is no port to probe, so the probe is skipped entirely rather than
61
+ // falling back to 8000 and reporting on a port the operator did not ask for.
62
+ if (deps.endpoint.ok) {
63
+ add(await checkServerPort(deps, deps.endpoint.endpoint));
64
+ }
65
+ else {
66
+ add({ check: "server-port", severity: "blocking", message: deps.endpoint.message });
67
+ }
60
68
  add(await checkAlembicHead(repoRoot, {
61
69
  execFile: deps.execFile,
62
70
  fileExists: (filePath) => fileExists(filePath, deps.fs),
@@ -67,7 +75,10 @@ export async function runPlanePreflight(repoRoot, deps) {
67
75
  // `!runtimeEntrypoint.ok` is already covered by the blocking count; it is
68
76
  // repeated here so the compiler narrows the union rather than requiring a
69
77
  // non-null assertion on the context field below.
70
- if (blocking.length > 0 || !credentials.ok || !runtimeEntrypoint.ok) {
78
+ // `!deps.endpoint.ok` is already covered by the blocking count; it is repeated
79
+ // here, like `!runtimeEntrypoint.ok`, so the compiler narrows the union rather
80
+ // than requiring a non-null assertion on the context field below.
81
+ if (blocking.length > 0 || !credentials.ok || !runtimeEntrypoint.ok || !deps.endpoint.ok) {
71
82
  return { ok: false, diagnostics };
72
83
  }
73
84
  return {
@@ -76,7 +87,9 @@ export async function runPlanePreflight(repoRoot, deps) {
76
87
  context: {
77
88
  repoRoot,
78
89
  repoName: credentials.repoName,
79
- baseUrl: PLANE_SERVER_BASE_URL,
90
+ // The SAME object `checkServerPort` probed above. Never re-resolved, so
91
+ // the probed port and the launched port cannot diverge.
92
+ endpoint: deps.endpoint.endpoint,
80
93
  // Location-chosen: always THIS repository's build tree, so an executor can
81
94
  // never come from a published package.
82
95
  executorEntrypoint: path.join(repoRoot, "mcp_server", "build", "index.js"),
@@ -296,23 +309,25 @@ function createResolverWarningBuffer() {
296
309
  * let two servers race for the port, and silently assuming "occupied" would
297
310
  * refuse a perfectly good bring-up.
298
311
  */
299
- export async function checkServerPort(deps) {
300
- const result = await deps.probePort(PLANE_SERVER_HOST, PLANE_SERVER_PORT, PLANE_PORT_PROBE_TIMEOUT_MS);
312
+ export async function checkServerPort(deps, endpoint) {
313
+ const target = `${endpoint.host}:${endpoint.port}`;
314
+ const result = await deps.probePort(endpoint.host, endpoint.port, PLANE_PORT_PROBE_TIMEOUT_MS);
301
315
  if (result.kind === "refused")
302
316
  return null;
303
317
  if (result.kind === "connected") {
304
318
  return {
305
319
  check: "server-port",
306
320
  severity: "blocking",
307
- message: `${PLANE_SERVER_HOST}:${PLANE_SERVER_PORT} is already accepting connections. ` +
321
+ message: `${target} is already accepting connections. ` +
308
322
  "That port may belong to a SIBLING WORKTREE's server — check before you kill it. " +
309
- "Stop the existing server (or wind down its plane) and retry.",
323
+ `Stop the existing server (or wind down its plane) and retry, or set ${PLANE_SERVER_PORT_ENV_VAR} ` +
324
+ "to a free port.",
310
325
  };
311
326
  }
312
327
  return {
313
328
  check: "server-port",
314
329
  severity: "warning",
315
- message: `could not determine whether ${PLANE_SERVER_HOST}:${PLANE_SERVER_PORT} is free ` +
330
+ message: `could not determine whether ${target} is free ` +
316
331
  `(${result.error}); startup continues and uvicorn will fail loudly if the port is taken.`,
317
332
  };
318
333
  }
@@ -176,7 +176,7 @@ export async function launchPlaneSupervisor(params, deps) {
176
176
  }
177
177
  runtimeEnv[PLANE_ID_ENV_VAR] = manifest.planeId;
178
178
  runtimeEnv.BAPI_REPO_NAME = context.repoName;
179
- runtimeEnv.BAPI_BASE_URL = context.baseUrl;
179
+ runtimeEnv.BAPI_BASE_URL = context.endpoint.baseUrl;
180
180
  runtimeEnv.BAPI_API_KEY = context.bridgeApiKey;
181
181
  let child;
182
182
  try {
@@ -339,12 +339,76 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
339
339
  })),
340
340
  updatedAt: deps.clock.now().toISOString(),
341
341
  };
342
- await writePlaneManifest(manifest, deps.fs);
343
342
  const started = [];
344
- const persist = async () => {
343
+ // ---- Serialized manifest persistence (BAPI-950) --------------------------
344
+ //
345
+ // `writePlaneManifest` publishes through a temporary file and an atomic
346
+ // rename. That is safe for ONE writer and unsafe for two: overlapping writers
347
+ // interleave on the temp file and the rename then publishes a half-written
348
+ // `plane.json`, which `readPlaneManifest` rejects as "manifest is not valid
349
+ // JSON" and `plane down` refuses to act on — the corrupt manifest is then
350
+ // un-removable and poisons every later bring-up. The runtime used to make
351
+ // that overlap routine: the spawn loop awaited `persist()` while every
352
+ // member's `close` handler fired `void persist()` with nothing serializing
353
+ // them, so reaping several members in one window (exactly what `plane down`
354
+ // and a SIGKILL do) raced them against each other.
355
+ //
356
+ // One promise tail removes the overlap for this process's own writes.
357
+ let persistTail = Promise.resolve();
358
+ // Writes nobody awaits inline — the `close`-handler ones. Held so the runtime
359
+ // can wait for them instead of leaving detached filesystem work behind.
360
+ const pendingPersists = new Set();
361
+ // Set the moment the manifest's ownership passes to `shutdownPlane`, which
362
+ // removes it. A write that landed after that removal would resurrect a
363
+ // `plane.json` describing a plane that no longer exists.
364
+ let manifestReleased = false;
365
+ const persist = () => {
366
+ if (manifestReleased)
367
+ return Promise.resolve();
368
+ // Stamped and snapshotted SYNCHRONOUSLY, at enqueue time. A queued write
369
+ // must publish the state as it stood when it was enqueued; reading the
370
+ // mutable `manifest` binding when the write finally runs would let a later
371
+ // member patch overwrite an earlier transition's record.
345
372
  manifest = { ...manifest, updatedAt: deps.clock.now().toISOString() };
346
- await writePlaneManifest(manifest, deps.fs);
373
+ const snapshot = manifest;
374
+ const operation = persistTail.then(() => writePlaneManifest(snapshot, deps.fs));
375
+ // The tail is recovered but the caller's promise is not: one failed write
376
+ // must not poison every write queued behind it, and it must not be
377
+ // swallowed either. `operation` still rejects with the original error.
378
+ persistTail = operation.catch(() => undefined);
379
+ return operation;
347
380
  };
381
+ /**
382
+ * Enroll a write whose completion no caller awaits, and observe its failure.
383
+ *
384
+ * The message is fixed prose. The thrown value can carry a path or an errno
385
+ * struct, and the runtime's stderr is the operator's terminal.
386
+ */
387
+ const trackPersist = (operation) => {
388
+ const tracked = operation.catch(() => {
389
+ deps.sinks.stderr("Plane manifest could not be updated after a member exit.");
390
+ });
391
+ pendingPersists.add(tracked);
392
+ void tracked.then(() => {
393
+ pendingPersists.delete(tracked);
394
+ });
395
+ };
396
+ /** Wait for every manifest write already enqueued, including untracked ones. */
397
+ const drainPersists = async () => {
398
+ // Looped because a member can exit — and enqueue — while an earlier write
399
+ // is still settling. Each close enqueues exactly one write, and `persist`
400
+ // stops enqueuing once the manifest is released, so this terminates.
401
+ while (pendingPersists.size > 0) {
402
+ await Promise.all([...pendingPersists]);
403
+ }
404
+ await persistTail;
405
+ };
406
+ /** Hand the manifest to `shutdownPlane` with no runtime write still in flight. */
407
+ const releaseManifest = async () => {
408
+ manifestReleased = true;
409
+ await drainPersists();
410
+ };
411
+ await persist();
348
412
  const patchMember = (name, patch) => {
349
413
  manifest = {
350
414
  ...manifest,
@@ -357,6 +421,10 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
357
421
  return null;
358
422
  shuttingDown = true;
359
423
  deps.sinks.stdout(`Received ${signal} — winding the plane down.`);
424
+ // Before `shutdownPlane` reads and removes the manifest, not after: it must
425
+ // see this runtime's completed state, and no queued write may outlive its
426
+ // removal.
427
+ await releaseManifest();
360
428
  return shutdownPlane(repoRoot, {
361
429
  fs: deps.fs,
362
430
  proc: deps.proc,
@@ -413,7 +481,10 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
413
481
  child.on("close", ((code, signal) => {
414
482
  const timestamp = deps.clock.now().toISOString();
415
483
  patchMember(spec.name, { state: "exited", exitCode: code, exitSignal: signal });
416
- void persist();
484
+ // Enrolled synchronously so simultaneous exits queue in the order their
485
+ // patches were applied, and tracked so the write is awaited — never
486
+ // abandoned — before the runtime returns.
487
+ trackPersist(persist());
417
488
  emitMemberEvent(deps.sinks, {
418
489
  member: spec.name,
419
490
  kind: "exit",
@@ -499,6 +570,7 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
499
570
  if (started.length > 0) {
500
571
  deps.sinks.stderr(`Rolling back ${started.length} member(s) that had already started.`);
501
572
  shuttingDown = true;
573
+ await releaseManifest();
502
574
  const result = await shutdownPlane(repoRoot, {
503
575
  fs: deps.fs,
504
576
  proc: deps.proc,
@@ -35,12 +35,32 @@ export const PLANE_MANIFEST_FILENAME = "plane.json";
35
35
  export const PLANE_RUNTIME_LOG_FILENAME = "runtime.log";
36
36
  /** Repository-relative path of the runtime startup trace. */
37
37
  export const PLANE_RUNTIME_LOG_PATH = `${PLANE_RUNTIME_DIR}/${PLANE_RUNTIME_LOG_FILENAME}`;
38
- /** Host the local Bridge API server binds for an attended run. */
38
+ /**
39
+ * Host the local Bridge API server binds for an attended run.
40
+ *
41
+ * Loopback, and NOT overridable. The plane binds a development server with no
42
+ * authentication in front of it; letting an operator move it off `127.0.0.1`
43
+ * would expose that server to the local network, which is a different decision
44
+ * from "run it on a different port because 8000 is taken".
45
+ */
39
46
  export const PLANE_SERVER_HOST = "127.0.0.1";
40
- /** Port the local Bridge API server binds for an attended run. */
47
+ /** Default port the local Bridge API server binds for an attended run. */
41
48
  export const PLANE_SERVER_PORT = 8000;
42
- /** Base URL the executor members are pointed at. */
49
+ /** Default base URL the executor members are pointed at. */
43
50
  export const PLANE_SERVER_BASE_URL = `http://${PLANE_SERVER_HOST}:${PLANE_SERVER_PORT}`;
51
+ /**
52
+ * Environment variable that overrides {@link PLANE_SERVER_PORT}.
53
+ *
54
+ * Named to match the existing `BAPI_PLANE_PYTHON` / `BAPI_PLANE_UVICORN`
55
+ * convention: `BAPI_PLANE_*` is the plane's own configuration namespace, kept
56
+ * distinct from `BAPI_*` values (`BAPI_BASE_URL`, `BAPI_API_KEY`) that the
57
+ * spawned members consume.
58
+ *
59
+ * Only the PORT is configurable. An absent or blank value resolves to exactly
60
+ * the default endpoint — `http://127.0.0.1:8000` — so a machine that never sets
61
+ * it is byte-for-byte unchanged.
62
+ */
63
+ export const PLANE_SERVER_PORT_ENV_VAR = "BAPI_PLANE_PORT";
44
64
  /**
45
65
  * Private action name for the detached supervisor runtime.
46
66
  *