@bridge_gpt/mcp-server 0.2.50 → 0.2.52
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 +24 -8
- package/build/agent-capabilities/probe-context.js +15 -7
- package/build/agent-capabilities/probes.js +42 -6
- package/build/agent-launchers/claude-executor-adapter.js +98 -14
- package/build/commands.generated.js +1 -1
- package/build/conduct-epic/bridge-client.js +115 -1
- package/build/conduct-epic/cli.js +351 -33
- package/build/conduct-epic/cut-protocol.js +65 -0
- package/build/conductor/bridge-api-client.js +171 -5
- package/build/conductor/deny-enforcement-preflight.js +107 -10
- package/build/conductor/local-merge.js +170 -11
- package/build/conductor-bin.js +2 -2
- package/build/connect-bitbucket-api.js +370 -0
- package/build/connect-bitbucket.js +437 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +230 -1
- package/build/drive-epic.js +423 -11
- package/build/env-file-link.js +164 -0
- package/build/epic-integration-pr.js +290 -0
- package/build/executor/cli.js +41 -6
- package/build/executor/deps.js +5 -1
- package/build/executor/env-file-guard.js +113 -0
- package/build/executor/env.js +78 -1
- package/build/executor/heartbeat.js +9 -0
- package/build/executor/http-client.js +90 -22
- package/build/executor/job-errors.js +43 -2
- package/build/executor/job-runner.js +137 -29
- package/build/executor/merge-job.js +102 -6
- package/build/executor/permissions.js +106 -0
- package/build/executor/preflight.js +38 -13
- package/build/executor/resume-pre-spawn.js +2 -1
- package/build/executor/runner.js +175 -4
- package/build/executor/service-unit.js +15 -0
- package/build/executor/terminal-mutation.js +22 -1
- package/build/executor/types.js +86 -0
- package/build/executor/worker-command.js +21 -5
- package/build/executor/worker-guard-hook.js +939 -0
- package/build/executor/worker-log.js +56 -0
- package/build/executor/worktree.js +11 -0
- package/build/git-reachability.js +147 -0
- package/build/index.js +535 -95
- package/build/install-bridge.js +95 -0
- package/build/pipelines.generated.js +10 -2
- package/build/plan-epic-conductor-eligibility.js +213 -0
- package/build/plane/cli.js +78 -15
- package/build/plane/defaults.js +165 -0
- package/build/plane/manifest.js +63 -8
- package/build/plane/member-logs.js +6 -0
- package/build/plane/member-roster.js +195 -11
- package/build/plane/preflight.js +43 -0
- package/build/plane/shutdown.js +25 -3
- package/build/plane/status.js +11 -0
- package/build/plane/supervisor.js +343 -14
- package/build/plane/test-fakes.js +43 -0
- package/build/plane/types.js +82 -11
- package/build/pr-base-contract.js +20 -0
- package/build/readme.generated.js +1 -1
- package/build/review-synthesis-config.js +60 -0
- package/build/scripts/executor-protocol-contract-driver.js +311 -0
- package/build/setup-epic.js +592 -139
- package/build/sfcc/log-query.js +2 -1
- 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/start-tickets-conductor.js +11 -2
- package/build/start-tickets.js +69 -2
- package/build/version.generated.js +3 -3
- package/build/worker-containment-diagnostic.js +97 -0
- package/build/worker-guard-hook-bin.js +6 -0
- package/docs/CONDUCTOR.md +27 -0
- package/docs/install/mcp-tool-integrations.md +3 -2
- package/package.json +5 -3
- package/pipelines/plan-epic.json +5 -0
|
@@ -0,0 +1,213 @@
|
|
|
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
|
+
/** Shape-validate an evidence array: every element must be a string. */
|
|
50
|
+
function isStringArray(value) {
|
|
51
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
52
|
+
}
|
|
53
|
+
/** Treat a missing/undefined optional field as empty text; reject a non-string value. */
|
|
54
|
+
function optionalTextOrNull(value) {
|
|
55
|
+
if (value === undefined || value === null)
|
|
56
|
+
return "";
|
|
57
|
+
return typeof value === "string" ? value : null;
|
|
58
|
+
}
|
|
59
|
+
/** Deterministically assemble one child's scan text from its authoritative fields. */
|
|
60
|
+
function buildCombinedText(child) {
|
|
61
|
+
return [child.title, child.scope, child.description, child.requirements]
|
|
62
|
+
.filter((part) => typeof part === "string" && part.length > 0)
|
|
63
|
+
.join("\n\n");
|
|
64
|
+
}
|
|
65
|
+
/** Validate the child collection at the module boundary. `null` means malformed. */
|
|
66
|
+
function validateChildren(children) {
|
|
67
|
+
if (!Array.isArray(children) || children.length === 0)
|
|
68
|
+
return null;
|
|
69
|
+
const validated = [];
|
|
70
|
+
for (const child of children) {
|
|
71
|
+
if (child === null || typeof child !== "object")
|
|
72
|
+
return null;
|
|
73
|
+
if (!isNonBlankString(child.id) || !isNonBlankString(child.title))
|
|
74
|
+
return null;
|
|
75
|
+
const scope = optionalTextOrNull(child.scope);
|
|
76
|
+
const description = optionalTextOrNull(child.description);
|
|
77
|
+
const requirements = optionalTextOrNull(child.requirements);
|
|
78
|
+
if (scope === null || description === null || requirements === null)
|
|
79
|
+
return null;
|
|
80
|
+
validated.push({
|
|
81
|
+
id: child.id,
|
|
82
|
+
title: child.title,
|
|
83
|
+
combinedText: buildCombinedText({
|
|
84
|
+
id: child.id,
|
|
85
|
+
title: child.title,
|
|
86
|
+
scope,
|
|
87
|
+
description,
|
|
88
|
+
requirements,
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return validated;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Assess conductor merge/review eligibility for one frozen epic child set.
|
|
96
|
+
*
|
|
97
|
+
* Sends the complete ordered child set to the injected Bridge client exactly
|
|
98
|
+
* once. Never throws — every failure path (malformed input, client rejection,
|
|
99
|
+
* malformed backend response, an internal merge/review contradiction) resolves
|
|
100
|
+
* to the explicit `unavailable` result, diagnosed with `console.error` (never
|
|
101
|
+
* `console.log`, which is reserved for the MCP stdio transport).
|
|
102
|
+
*/
|
|
103
|
+
export async function assessEpicConductorEligibility(children, deps) {
|
|
104
|
+
const validated = validateChildren(children);
|
|
105
|
+
if (validated === null) {
|
|
106
|
+
console.error("assessEpicConductorEligibility: malformed child collection; unavailable.");
|
|
107
|
+
return UNAVAILABLE;
|
|
108
|
+
}
|
|
109
|
+
let response;
|
|
110
|
+
try {
|
|
111
|
+
response = await deps.client.classifyChildren(validated.map((child) => ({ child_id: child.id, combined_text: child.combinedText })));
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
console.error("assessEpicConductorEligibility: Bridge classification call failed:", err);
|
|
115
|
+
return UNAVAILABLE;
|
|
116
|
+
}
|
|
117
|
+
if (response === null ||
|
|
118
|
+
typeof response !== "object" ||
|
|
119
|
+
!Array.isArray(response.predictions) ||
|
|
120
|
+
!Number.isInteger(response.total_children) ||
|
|
121
|
+
response.total_children < 0 ||
|
|
122
|
+
!Number.isInteger(response.predicted_workflow_children) ||
|
|
123
|
+
response.predicted_workflow_children < 0 ||
|
|
124
|
+
!Number.isInteger(response.containment_children) ||
|
|
125
|
+
response.containment_children < 0 ||
|
|
126
|
+
typeof response.reason !== "string") {
|
|
127
|
+
console.error("assessEpicConductorEligibility: malformed backend response shape; unavailable.");
|
|
128
|
+
return UNAVAILABLE;
|
|
129
|
+
}
|
|
130
|
+
if (response.predictions.length !== validated.length) {
|
|
131
|
+
console.error("assessEpicConductorEligibility: backend prediction count does not match child count; unavailable.");
|
|
132
|
+
return UNAVAILABLE;
|
|
133
|
+
}
|
|
134
|
+
// The backend is contractually order-preserving; still verify identity and
|
|
135
|
+
// order rather than trusting it, so a reordered/duplicate/unknown response
|
|
136
|
+
// never silently mislabels a child's prediction.
|
|
137
|
+
const predictionById = new Map();
|
|
138
|
+
for (const prediction of response.predictions) {
|
|
139
|
+
if (prediction === null ||
|
|
140
|
+
typeof prediction !== "object" ||
|
|
141
|
+
!isNonBlankString(prediction.child_id) ||
|
|
142
|
+
typeof prediction.predicted_workflow_modified !== "boolean" ||
|
|
143
|
+
!isStringArray(prediction.matched_paths) ||
|
|
144
|
+
typeof prediction.predicted_containment_hazard !== "boolean" ||
|
|
145
|
+
!isStringArray(prediction.matched_hazards)) {
|
|
146
|
+
console.error("assessEpicConductorEligibility: malformed per-child prediction; unavailable.");
|
|
147
|
+
return UNAVAILABLE;
|
|
148
|
+
}
|
|
149
|
+
if (prediction.predicted_containment_hazard !== (prediction.matched_hazards.length > 0)) {
|
|
150
|
+
console.error(`assessEpicConductorEligibility: child ${prediction.child_id} has a containment flag that ` +
|
|
151
|
+
"disagrees with whether matched hazard evidence is present; unavailable.");
|
|
152
|
+
return UNAVAILABLE;
|
|
153
|
+
}
|
|
154
|
+
if (predictionById.has(prediction.child_id)) {
|
|
155
|
+
console.error("assessEpicConductorEligibility: duplicate child_id in backend response; unavailable.");
|
|
156
|
+
return UNAVAILABLE;
|
|
157
|
+
}
|
|
158
|
+
predictionById.set(prediction.child_id, prediction);
|
|
159
|
+
}
|
|
160
|
+
const affectedChildren = [];
|
|
161
|
+
let reviewSubsetChildren = 0;
|
|
162
|
+
let predictedWorkflowChildren = 0;
|
|
163
|
+
let containmentChildren = 0;
|
|
164
|
+
for (const child of validated) {
|
|
165
|
+
const prediction = predictionById.get(child.id);
|
|
166
|
+
if (!prediction) {
|
|
167
|
+
console.error(`assessEpicConductorEligibility: backend response is missing child ${child.id}; unavailable.`);
|
|
168
|
+
return UNAVAILABLE;
|
|
169
|
+
}
|
|
170
|
+
const reviewPathReferenced = child.combinedText.includes(CLAUDE_REVIEW_WORKFLOW_RELPATH);
|
|
171
|
+
if (reviewPathReferenced && !prediction.predicted_workflow_modified) {
|
|
172
|
+
// Subset invariant violated: a child naming claude-review.yml itself must
|
|
173
|
+
// also be in the broader merge-blocked set. Publishing this as a
|
|
174
|
+
// "review-only" child would contradict the merge count, so refuse instead.
|
|
175
|
+
console.error(`assessEpicConductorEligibility: child ${child.id} references the review workflow ` +
|
|
176
|
+
"but the backend did not mark it merge-blocked; refusing contradictory result.");
|
|
177
|
+
return UNAVAILABLE;
|
|
178
|
+
}
|
|
179
|
+
if (prediction.predicted_workflow_modified) {
|
|
180
|
+
predictedWorkflowChildren += 1;
|
|
181
|
+
if (reviewPathReferenced)
|
|
182
|
+
reviewSubsetChildren += 1;
|
|
183
|
+
}
|
|
184
|
+
if (prediction.predicted_containment_hazard)
|
|
185
|
+
containmentChildren += 1;
|
|
186
|
+
// A child is affected — and therefore included — when it is workflow-modifying,
|
|
187
|
+
// containment-flagged, or both. Skip only a child that is neither.
|
|
188
|
+
if (!prediction.predicted_workflow_modified && !prediction.predicted_containment_hazard)
|
|
189
|
+
continue;
|
|
190
|
+
affectedChildren.push({
|
|
191
|
+
id: child.id,
|
|
192
|
+
title: child.title,
|
|
193
|
+
matchedPaths: [...prediction.matched_paths],
|
|
194
|
+
requiresHandReview: reviewPathReferenced,
|
|
195
|
+
requiresContainmentReview: prediction.predicted_containment_hazard,
|
|
196
|
+
matchedHazards: [...prediction.matched_hazards],
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (containmentChildren !== response.containment_children) {
|
|
200
|
+
console.error("assessEpicConductorEligibility: containment_children count disagrees with the validated " +
|
|
201
|
+
"per-child containment flags; unavailable.");
|
|
202
|
+
return UNAVAILABLE;
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
status: "assessed",
|
|
206
|
+
totalChildren: validated.length,
|
|
207
|
+
predictedWorkflowChildren,
|
|
208
|
+
reason: response.reason,
|
|
209
|
+
reviewSubsetChildren,
|
|
210
|
+
containmentChildren,
|
|
211
|
+
affectedChildren,
|
|
212
|
+
};
|
|
213
|
+
}
|
package/build/plane/cli.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* if it is ever reused on the server path.
|
|
13
13
|
*/
|
|
14
14
|
import { randomUUID } from "crypto";
|
|
15
|
-
import { PLANE_ENTRYPOINT_ACTION, PLANE_ID_ENV_VAR, PLANE_MANIFEST_SCHEMA_VERSION, PLANE_OBSERVER_CHANNEL_TYPE_ENV, PLANE_OBSERVER_COMMAND, PLANE_OBSERVER_DESTINATION_ENV, PLANE_RUNTIME_ACTION, PLANE_RUNTIME_DIR, } from "./types.js";
|
|
15
|
+
import { PLANE_ENTRYPOINT_ACTION, PLANE_ID_ENV_VAR, PLANE_MANIFEST_SCHEMA_VERSION, PLANE_OBSERVER_CHANNEL_TYPE_ENV, PLANE_OBSERVER_COMMAND, PLANE_OBSERVER_DESTINATION_ENV, PLANE_RUNTIME_ACTION, PLANE_RUNTIME_DIR, PLANE_RUNTIME_LOG_PATH, } from "./types.js";
|
|
16
16
|
import { PLANE_RUNTIME_ENTRYPOINT_REFUSAL } from "./build-freshness.js";
|
|
17
17
|
import { relativeLogPathFor } from "./manifest.js";
|
|
18
18
|
import { claimPlaneManifest } from "./manifest.js";
|
|
@@ -21,7 +21,7 @@ import { buildPlaneMemberRoster, resolvePlaneServerEndpoint } from "./member-ros
|
|
|
21
21
|
import { getPlaneStatus, formatPlaneStatus } from "./status.js";
|
|
22
22
|
import { shutdownPlane, formatPlaneShutdown } from "./shutdown.js";
|
|
23
23
|
import { launchPlaneSupervisor, runPlaneRuntime, } from "./supervisor.js";
|
|
24
|
-
import { createMemberLogDeps, createPlaneClock, createPlaneExecFile, createPlaneFsDeps, createPlaneProcessDeps, createPlaneSpawn, probeTcpPort, registerSignalHandler, resolveHomedir, resolveNodeExecutable, resolvePackageEntrypoint, } from "./defaults.js";
|
|
24
|
+
import { createMemberLogDeps, createPlaneClock, createPlaneExecFile, createPlaneFsDeps, createPlaneProcessDeps, createPlaneSpawn, probeAutomationHealth, probeTcpPort, registerSignalHandler, resolveHomedir, resolveHostname, resolveNodeExecutable, resolvePackageEntrypoint, } from "./defaults.js";
|
|
25
25
|
import { resolveBapiCredentials } from "../credential-store.js";
|
|
26
26
|
import { resolveRequiredStartTicketsRepoName } from "../start-tickets-repo.js";
|
|
27
27
|
/** Upper bound on executor lanes. Guards a typo from forking a fleet. */
|
|
@@ -128,7 +128,15 @@ export function getPlaneUsage() {
|
|
|
128
128
|
"Usage: mcp-server plane <up|status|down> [options]",
|
|
129
129
|
"",
|
|
130
130
|
"Brings up (and winds down) the attended conductor plane in one command:",
|
|
131
|
-
"the Bridge API server, the reconciler worker, and
|
|
131
|
+
"the Bridge API server, the dead-man observer, the reconciler worker, and",
|
|
132
|
+
"one executor per lane.",
|
|
133
|
+
"",
|
|
134
|
+
"This is the MANUAL / two-step path (BAPI-1054). To stand a plane up and create",
|
|
135
|
+
"the run together, use `drive-epic <EPIC> --plan-file <path>`, which starts the",
|
|
136
|
+
"plane for you when that is the only thing missing. Reach for `plane up` directly",
|
|
137
|
+
"when you want the runtime without a run, when you are driving an epic whose plane",
|
|
138
|
+
"is already live, or to recover after a composed bring-up reported a live plane it",
|
|
139
|
+
"did not wind down. Nothing about this command's behavior has changed.",
|
|
132
140
|
"",
|
|
133
141
|
"Actions:",
|
|
134
142
|
" plane up [--executors N] Preflight, then start the plane. Refuses as a",
|
|
@@ -151,18 +159,32 @@ export function getPlaneUsage() {
|
|
|
151
159
|
" (mcp_server/build/index.js) — never npx, never a published package.",
|
|
152
160
|
" * A member that exits is reported loudly and is NOT restarted. There is no",
|
|
153
161
|
" auto-restart, retry, or backoff: a crash is a finding, not noise.",
|
|
154
|
-
" * The dead-man observer
|
|
155
|
-
" by
|
|
156
|
-
|
|
162
|
+
" * The dead-man observer runs as an INDEPENDENT same-host OS process, started",
|
|
163
|
+
" by `plane up`, listed in `plane status`, and stopped by `plane down`. It is",
|
|
164
|
+
" started before the reconciler worker, so it is already watching while the",
|
|
165
|
+
" worker comes up. Same-host only — this is not separate-host resilience.",
|
|
166
|
+
" * The reconciler worker is not reported ready until it has published a FRESH",
|
|
167
|
+
" durable heartbeat, read back through GET /automation/health. `plane up`",
|
|
168
|
+
" cannot print a success banner over a reconciler that never ticked.",
|
|
157
169
|
"",
|
|
158
170
|
`Runtime artifacts (manifest + per-member logs) live under ${PLANE_RUNTIME_DIR}/.`,
|
|
159
171
|
].join("\n");
|
|
160
172
|
}
|
|
161
|
-
/**
|
|
162
|
-
|
|
173
|
+
/**
|
|
174
|
+
* How to start the dead-man observer BY HAND.
|
|
175
|
+
*
|
|
176
|
+
* No longer printed by `plane up` (BAPI-1029): the plane starts the observer
|
|
177
|
+
* itself, and telling an operator to start a second one would give them two
|
|
178
|
+
* evaluators racing the same outage. It is retained for the documented
|
|
179
|
+
* session-background FALLBACK, which runs the reconciler outside the plane and
|
|
180
|
+
* therefore has no observer of its own — the one arrangement where this command
|
|
181
|
+
* is still the right instruction.
|
|
182
|
+
*/
|
|
183
|
+
export function getPlaneObserverFallbackInstructions() {
|
|
163
184
|
return [
|
|
164
185
|
"",
|
|
165
|
-
"Dead-man observer
|
|
186
|
+
"Dead-man observer for the SESSION-BACKGROUND FALLBACK only (`plane up` starts",
|
|
187
|
+
"its own observer and needs none of this):",
|
|
166
188
|
` ${PLANE_OBSERVER_COMMAND}`,
|
|
167
189
|
` with ${PLANE_OBSERVER_CHANNEL_TYPE_ENV} (slack_webhook|generic_webhook) and`,
|
|
168
190
|
` ${PLANE_OBSERVER_DESTINATION_ENV} (the NAME of an env var holding the URL).`,
|
|
@@ -327,7 +349,7 @@ async function runUpAction(executors, sinks, overrides) {
|
|
|
327
349
|
const env = overrides.env ?? process.env;
|
|
328
350
|
const preflight = overrides.preflight
|
|
329
351
|
? await overrides.preflight(repoRoot)
|
|
330
|
-
: await
|
|
352
|
+
: await runDefaultPlanePreflight(repoRoot, env);
|
|
331
353
|
for (const diagnostic of preflight.diagnostics.filter((d) => d.severity === "warning")) {
|
|
332
354
|
sinks.stdout(formatDiagnostic(diagnostic));
|
|
333
355
|
}
|
|
@@ -366,9 +388,16 @@ export const PLANE_STARTUP_FAILED = "plane up FAILED — the runtime never came
|
|
|
366
388
|
*/
|
|
367
389
|
export function formatPlaneLaunchFailure(launch) {
|
|
368
390
|
if (!launch.startup) {
|
|
369
|
-
// No child exit information exists, so none is invented
|
|
370
|
-
//
|
|
371
|
-
|
|
391
|
+
// No child exit information exists, so none is invented. The trace pointer
|
|
392
|
+
// is still emitted (BAPI-1029): a launch that never produced a usable child
|
|
393
|
+
// used to print one bare line with nowhere to look next, which is the same
|
|
394
|
+
// dead end BAPI-768 presented as. The file may be empty or absent — saying
|
|
395
|
+
// where it would be costs nothing and is the only lead this branch has.
|
|
396
|
+
return [
|
|
397
|
+
`plane up FAILED: ${launch.error}`,
|
|
398
|
+
` trace ${PLANE_RUNTIME_LOG_PATH} (may be empty — the runtime never started)`,
|
|
399
|
+
"The plane is not running. `no processes were started`.",
|
|
400
|
+
];
|
|
372
401
|
}
|
|
373
402
|
const { exitCode, signal, stderrExcerpt, tracePath } = launch.startup;
|
|
374
403
|
const status = signal !== null ? `signal ${signal}` : `exit code ${exitCode ?? "unknown"}`;
|
|
@@ -457,9 +486,8 @@ function printStartupBanner(sinks, context, memberCount, executors, launch) {
|
|
|
457
486
|
sinks.stdout(` server ${context.endpoint.baseUrl} (no --reload)`);
|
|
458
487
|
sinks.stdout(` supervisor pid ${launch.supervisorPid}, process group ${launch.supervisorPgid}`);
|
|
459
488
|
sinks.stdout(` logs ${PLANE_RUNTIME_DIR}/`);
|
|
489
|
+
sinks.stdout(" observer dead-man-only worker.py, its own OS process (same host)");
|
|
460
490
|
sinks.stdout(" crash policy members are NOT restarted; a member exit is reported loudly.");
|
|
461
|
-
for (const line of getPlaneObserverReminder())
|
|
462
|
-
sinks.stdout(line);
|
|
463
491
|
sinks.stdout("");
|
|
464
492
|
}
|
|
465
493
|
/** The private detached-runtime action. Never part of the documented surface. */
|
|
@@ -520,6 +548,16 @@ async function runRuntimeAction(executors, overrides) {
|
|
|
520
548
|
log: createMemberLogDeps(),
|
|
521
549
|
onSignal: registerSignalHandler,
|
|
522
550
|
probePort: probeTcpPort,
|
|
551
|
+
// The heartbeat readiness reader (BAPI-1029; component-aware since
|
|
552
|
+
// BAPI-1036). Bound here beside the TCP probe rather than constructed inside
|
|
553
|
+
// the supervisor, so a unit test substitutes it the same way it substitutes
|
|
554
|
+
// every other real-I/O seam.
|
|
555
|
+
probeHealth: probeAutomationHealth,
|
|
556
|
+
// Half of the local reconciler's published identity (`<hostname>:<pid>`).
|
|
557
|
+
// Injected for the same reason as every other platform read here: the
|
|
558
|
+
// supervisor must rebuild a string another process minted, and a test has to
|
|
559
|
+
// be able to pin that reconstruction without a real hostname.
|
|
560
|
+
hostname: resolveHostname,
|
|
523
561
|
selfPid: process.pid,
|
|
524
562
|
planeId,
|
|
525
563
|
});
|
|
@@ -550,6 +588,31 @@ function buildPreflightDeps(env) {
|
|
|
550
588
|
resolveCredentials: resolveBapiCredentials,
|
|
551
589
|
};
|
|
552
590
|
}
|
|
591
|
+
/**
|
|
592
|
+
* The production preflight, as a named seam other commands can reuse (BAPI-1054).
|
|
593
|
+
*
|
|
594
|
+
* `drive-epic` composes `plane up`, and it must inspect the SAME preflight this
|
|
595
|
+
* command runs — same endpoint resolution, same credential resolution, same port
|
|
596
|
+
* probe, same worktree predicate. Re-resolving any of that at the caller is how
|
|
597
|
+
* two answers to one question appear: a caller could refuse on a port the plane
|
|
598
|
+
* was never going to use, or approve one it was.
|
|
599
|
+
*
|
|
600
|
+
* Thin by design. It owns no policy — only the `(repoRoot, env)` -> result
|
|
601
|
+
* binding, so there is exactly one place that knows how a real preflight is built.
|
|
602
|
+
*/
|
|
603
|
+
export function runDefaultPlanePreflight(repoRoot, env = process.env) {
|
|
604
|
+
return runPlanePreflight(repoRoot, buildPreflightDeps(env));
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Render ONE diagnostic exactly as `plane up` renders it (BAPI-1054).
|
|
608
|
+
*
|
|
609
|
+
* Exported so a composing caller surfaces a plane refusal in the plane's own
|
|
610
|
+
* words. A second renderer would drift, and an operator reading two different
|
|
611
|
+
* spellings of one check has to work out whether they are the same finding.
|
|
612
|
+
*/
|
|
613
|
+
export function formatPlaneDiagnostic(diagnostic) {
|
|
614
|
+
return formatDiagnostic(diagnostic);
|
|
615
|
+
}
|
|
553
616
|
function formatDiagnostic(diagnostic) {
|
|
554
617
|
const label = diagnostic.severity === "blocking" ? "BLOCKED" : "WARN ";
|
|
555
618
|
return ` ${label} [${diagnostic.check}] ${diagnostic.message}`;
|
package/build/plane/defaults.js
CHANGED
|
@@ -15,6 +15,7 @@ import net from "net";
|
|
|
15
15
|
import os from "os";
|
|
16
16
|
import path from "path";
|
|
17
17
|
import { fileURLToPath } from "url";
|
|
18
|
+
import { PLANE_HEARTBEAT_HEALTH_STATES, } from "./types.js";
|
|
18
19
|
import { isProcessAlive } from "./manifest.js";
|
|
19
20
|
/** Filesystem primitives bound to `node:fs/promises`. */
|
|
20
21
|
export function createPlaneFsDeps() {
|
|
@@ -269,6 +270,170 @@ function defaultIsFile(filePath) {
|
|
|
269
270
|
return false;
|
|
270
271
|
}
|
|
271
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* Per-request budget for one automation-health readiness poll.
|
|
275
|
+
*
|
|
276
|
+
* Deliberately much smaller than the readiness budget it runs inside: a hung
|
|
277
|
+
* request must cost one poll, not the whole 120-second window. Without a
|
|
278
|
+
* per-request bound, a server that accepts the connection and never answers
|
|
279
|
+
* would consume the entire budget in a single call and the failure would be
|
|
280
|
+
* reported as "the reconciler never became fresh" rather than as a hung route.
|
|
281
|
+
*/
|
|
282
|
+
export const PLANE_HEALTH_PROBE_TIMEOUT_MS = 5_000;
|
|
283
|
+
/**
|
|
284
|
+
* Read one component's durable-heartbeat verdict from `GET /automation/health`.
|
|
285
|
+
*
|
|
286
|
+
* ## What this does not do
|
|
287
|
+
*
|
|
288
|
+
* It does not compute an age, compare against a threshold, or decide what
|
|
289
|
+
* "fresh" means. `api/library/reconciler_liveness.py` already derives the
|
|
290
|
+
* verdict against `RECONCILER_STALE_SECONDS` / `EXECUTOR_STALE_SECONDS` and
|
|
291
|
+
* every other consumer reads that same derivation; a second threshold here is
|
|
292
|
+
* exactly how a launcher and a health page end up disagreeing at the boundary.
|
|
293
|
+
* This transports the backend's answer, normalizes its observation timestamp,
|
|
294
|
+
* and classifies the ways the answer can fail to arrive.
|
|
295
|
+
*
|
|
296
|
+
* ## Instance scoping (BAPI-1036)
|
|
297
|
+
*
|
|
298
|
+
* `component` selects which block of the response is read. `instanceId`, when
|
|
299
|
+
* given, additionally asks the route to derive THAT block from one published
|
|
300
|
+
* identity. The two travel together or not at all, because the route refuses a
|
|
301
|
+
* partial pair with a 422 — which this probe would classify as `unavailable`
|
|
302
|
+
* and then wait out, turning a caller's mistake into a budget burn.
|
|
303
|
+
*
|
|
304
|
+
* ## Secret handling
|
|
305
|
+
*
|
|
306
|
+
* The API key is sent in `X-API-Key`, the convention every other Bridge client
|
|
307
|
+
* in this package uses. It is never placed in the URL (which reaches proxy logs
|
|
308
|
+
* and error strings), never returned, and never rendered: every failure arm of
|
|
309
|
+
* {@link PlaneHealthProbeResult} is a bare tag with no body, header, status
|
|
310
|
+
* text, or exception message attached. The observation timestamp returned
|
|
311
|
+
* BESIDE that classification is a server-derived time and nothing else; see
|
|
312
|
+
* {@link PlaneHealthObservation} for why it is kept outside the union.
|
|
313
|
+
*/
|
|
314
|
+
export async function probeAutomationHealth(request) {
|
|
315
|
+
let url;
|
|
316
|
+
try {
|
|
317
|
+
const target = new URL("/automation/health", request.baseUrl);
|
|
318
|
+
target.searchParams.set("repo_name", request.repoName);
|
|
319
|
+
// BOTH or NEITHER. The route validates the pair and 422s on half of it.
|
|
320
|
+
if (request.instanceId !== undefined) {
|
|
321
|
+
target.searchParams.set("component", request.component);
|
|
322
|
+
target.searchParams.set("instance_id", request.instanceId);
|
|
323
|
+
}
|
|
324
|
+
url = target.toString();
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
// An unparseable base URL is a configuration fault, not a transport blip.
|
|
328
|
+
return unclassified({ kind: "malformed" });
|
|
329
|
+
}
|
|
330
|
+
const doFetch = request.fetchImpl ?? fetch;
|
|
331
|
+
let response;
|
|
332
|
+
try {
|
|
333
|
+
response = await doFetch(url, {
|
|
334
|
+
method: "GET",
|
|
335
|
+
headers: { "X-API-Key": request.apiKey, Accept: "application/json" },
|
|
336
|
+
signal: AbortSignal.timeout(request.timeoutMs ?? PLANE_HEALTH_PROBE_TIMEOUT_MS),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// Connection refused, DNS failure, abort on timeout — all retryable, and all
|
|
341
|
+
// capable of carrying the request URL in their message. None is rendered.
|
|
342
|
+
return unclassified({ kind: "unavailable" });
|
|
343
|
+
}
|
|
344
|
+
// 401/403 are terminal for readiness: the key in the member environment is not
|
|
345
|
+
// accepted, and polling for two more minutes will not change that.
|
|
346
|
+
if (response.status === 401 || response.status === 403) {
|
|
347
|
+
return unclassified({ kind: "unauthorized" });
|
|
348
|
+
}
|
|
349
|
+
if (!response.ok)
|
|
350
|
+
return unclassified({ kind: "unavailable" });
|
|
351
|
+
let body;
|
|
352
|
+
try {
|
|
353
|
+
body = await response.json();
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return unclassified({ kind: "malformed" });
|
|
357
|
+
}
|
|
358
|
+
const reading = readComponentObservation(body, request.component);
|
|
359
|
+
if (reading === null)
|
|
360
|
+
return unclassified({ kind: "malformed" });
|
|
361
|
+
return { result: { kind: "state", state: reading.state }, observedAtMs: reading.observedAtMs };
|
|
362
|
+
}
|
|
363
|
+
/** A reading that carries a classification and no observation. */
|
|
364
|
+
function unclassified(result) {
|
|
365
|
+
return { result, observedAtMs: null };
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* The requested component's state and observation time, or `null` for a body
|
|
369
|
+
* this code does not recognize.
|
|
370
|
+
*
|
|
371
|
+
* Every hop is guarded rather than asserted. An unrecognized string is
|
|
372
|
+
* `malformed`, NOT `unknown`: the backend's `unknown` means "the durable source
|
|
373
|
+
* could not be read" and is a retryable waiting state, whereas a state string
|
|
374
|
+
* this code does not recognize means the contract moved — and quietly treating
|
|
375
|
+
* a renamed state as a transient read failure would make the launcher wait out
|
|
376
|
+
* its whole budget and then report the wrong reason.
|
|
377
|
+
*
|
|
378
|
+
* The two blocks sit at different paths and spell their timestamp field
|
|
379
|
+
* differently, which is precisely why this reads them by component rather than
|
|
380
|
+
* from one hardcoded key:
|
|
381
|
+
*
|
|
382
|
+
* - reconciler → `body.reconciler.state` / `body.reconciler.heartbeat_last_seen_at`
|
|
383
|
+
* - executor → `body.executor.process_heartbeat.state` / `….last_seen_at`
|
|
384
|
+
*/
|
|
385
|
+
function readComponentObservation(body, component) {
|
|
386
|
+
if (typeof body !== "object" || body === null)
|
|
387
|
+
return null;
|
|
388
|
+
let block;
|
|
389
|
+
let timestampKey;
|
|
390
|
+
if (component === "reconciler") {
|
|
391
|
+
block = body.reconciler;
|
|
392
|
+
timestampKey = "heartbeat_last_seen_at";
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
const executor = body.executor;
|
|
396
|
+
if (typeof executor !== "object" || executor === null)
|
|
397
|
+
return null;
|
|
398
|
+
block = executor.process_heartbeat;
|
|
399
|
+
timestampKey = "last_seen_at";
|
|
400
|
+
}
|
|
401
|
+
if (typeof block !== "object" || block === null)
|
|
402
|
+
return null;
|
|
403
|
+
const state = block.state;
|
|
404
|
+
if (typeof state !== "string")
|
|
405
|
+
return null;
|
|
406
|
+
if (!PLANE_HEARTBEAT_HEALTH_STATES.includes(state))
|
|
407
|
+
return null;
|
|
408
|
+
const known = state;
|
|
409
|
+
const raw = block[timestampKey];
|
|
410
|
+
if (raw === null || raw === undefined) {
|
|
411
|
+
// A timestamp is REQUIRED for the two states derived from an actual row.
|
|
412
|
+
// `never_seen` has no row and `unknown` means the source could not be read,
|
|
413
|
+
// so for those an absent time is the contract, not a violation.
|
|
414
|
+
return known === "fresh" || known === "stale" ? null : { state: known, observedAtMs: null };
|
|
415
|
+
}
|
|
416
|
+
if (typeof raw !== "string")
|
|
417
|
+
return null;
|
|
418
|
+
const observedAtMs = Date.parse(raw);
|
|
419
|
+
// An unparseable timestamp on a state that must have one is a moved contract,
|
|
420
|
+
// not a transient failure — the same reasoning as an unrecognized state.
|
|
421
|
+
if (Number.isNaN(observedAtMs))
|
|
422
|
+
return null;
|
|
423
|
+
return { state: known, observedAtMs };
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* The host identity the local reconciler publishes half of its instance id from.
|
|
427
|
+
*
|
|
428
|
+
* A seam, not a convenience: `_resolve_process_instance_id` builds
|
|
429
|
+
* `<hostname>:<pid>` from Python's `socket.gethostname()`, and the supervisor
|
|
430
|
+
* must reproduce that string exactly to scope a readiness probe to the process
|
|
431
|
+
* it just spawned. Injecting it keeps the resolver unit-testable without a real
|
|
432
|
+
* hostname, and keeps the comparison visible rather than buried in a global.
|
|
433
|
+
*/
|
|
434
|
+
export function resolveHostname() {
|
|
435
|
+
return os.hostname();
|
|
436
|
+
}
|
|
272
437
|
/** Node executable used for the runtime re-exec and executor members. */
|
|
273
438
|
export function resolveNodeExecutable() {
|
|
274
439
|
return process.execPath;
|