@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.
- package/README.md +25 -8
- package/build/base-ref.js +28 -3
- package/build/claude-review-workflow-drift-probe.js +130 -0
- package/build/claude-review-workflow-drift.js +173 -0
- package/build/claude-review-workflow.js +81 -16
- package/build/commands.generated.js +5 -5
- package/build/conduct-epic/bridge-client.js +115 -1
- package/build/conduct-epic/cli.js +351 -33
- package/build/conduct-epic/cut-protocol.js +51 -0
- package/build/conductor/done-gate.js +25 -3
- package/build/conductor/install-doctor.js +65 -5
- package/build/conductor/latest-check-selector.js +170 -0
- package/build/conductor/local-merge.js +8 -6
- package/build/conductor-bin.js +1 -1
- package/build/{brainstorm-files.js → council-files.js} +15 -15
- package/build/decision-page-schema.js +1 -1
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +352 -4
- package/build/epic-integration-pr.js +280 -0
- package/build/executor/job-runner.js +7 -1
- package/build/executor/merge-job.js +46 -1
- package/build/executor/worktree.js +46 -1
- package/build/index.js +153 -65
- package/build/init.js +9 -2
- package/build/install-bridge.js +60 -2
- package/build/install-reexec.js +47 -9
- package/build/pipelines.generated.js +8 -2
- package/build/plan-epic-conductor-eligibility.js +183 -0
- package/build/plane/cli.js +12 -2
- package/build/plane/manifest.js +25 -1
- package/build/plane/member-roster.js +61 -7
- package/build/plane/preflight.js +24 -9
- package/build/plane/supervisor.js +77 -5
- package/build/plane/types.js +23 -3
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +2 -1
- package/build/setup-epic.js +32 -0
- package/build/sfcc/reads-custom-object-def.js +10 -13
- package/build/sfcc/reads-site-preference.js +5 -5
- package/build/sfcc/reads-system-object.js +4 -4
- package/build/sfcc/writes-custom-object-def.js +7 -7
- package/build/sfcc/writes-site-preference.js +4 -3
- package/build/sfcc/writes-system-object.js +7 -6
- package/build/stale-worktree-doctor.js +120 -0
- package/build/start-tickets-prereqs.js +70 -0
- package/build/start-tickets.js +91 -3
- package/build/version.generated.js +3 -2
- package/package.json +6 -3
- package/pipelines/plan-epic.json +5 -0
- package/build/chain-orchestrator.js +0 -1457
- package/build/chain-utils.js +0 -68
- package/build/command-catalog.js +0 -376
- package/build/schedule-run.js +0 -1300
- package/build/schedule-store.js +0 -172
- package/build/scheduled-prompt.js +0 -115
- package/build/scheduler-backends/at-fallback.js +0 -139
- package/build/scheduler-backends/escaping.js +0 -143
- package/build/scheduler-backends/index.js +0 -72
- package/build/scheduler-backends/launchd.js +0 -225
- package/build/scheduler-backends/systemd-user.js +0 -250
- package/build/scheduler-backends/task-scheduler.js +0 -214
- 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
|
+
}
|
package/build/plane/cli.js
CHANGED
|
@@ -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(),
|
package/build/plane/manifest.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
130
|
+
endpoint.host,
|
|
77
131
|
"--port",
|
|
78
|
-
String(
|
|
132
|
+
String(endpoint.port),
|
|
79
133
|
],
|
|
80
134
|
cwd,
|
|
81
135
|
env,
|
|
82
136
|
logPath: relativeLogPathFor("server"),
|
|
83
137
|
readiness: {
|
|
84
|
-
host:
|
|
85
|
-
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
|
-
|
|
168
|
+
endpoint.baseUrl,
|
|
115
169
|
"--executor-id",
|
|
116
170
|
buildExecutorId(context.repoName, lane),
|
|
117
171
|
],
|
package/build/plane/preflight.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: `${
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/build/plane/types.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
47
|
+
/** Default port the local Bridge API server binds for an attended run. */
|
|
41
48
|
export const PLANE_SERVER_PORT = 8000;
|
|
42
|
-
/**
|
|
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
|
*
|