@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
package/build/plane/manifest.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* argv, and no credential field, and unknown keys are rejected outright.
|
|
18
18
|
*/
|
|
19
19
|
import path from "path";
|
|
20
|
-
import { PLANE_MANIFEST_FILENAME,
|
|
20
|
+
import { PLANE_MANIFEST_FILENAME, PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS, PLANE_RUNTIME_DIR, } from "./types.js";
|
|
21
21
|
/**
|
|
22
22
|
* Derive every runtime path beneath a validated repository root.
|
|
23
23
|
*
|
|
@@ -67,11 +67,22 @@ const MEMBER_KEYS = new Set([
|
|
|
67
67
|
"exitSignal",
|
|
68
68
|
"logPath",
|
|
69
69
|
]);
|
|
70
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* `server`, `worker`, `observer`, or `executor-<positive integer>`. Nothing else.
|
|
72
|
+
*
|
|
73
|
+
* `observer` was added in BAPI-1029, which is also why
|
|
74
|
+
* {@link PLANE_MANIFEST_SCHEMA_VERSION} moved to 2. Widening the member set is
|
|
75
|
+
* backward-compatible only in the direction nobody is hurt by — a pre-release
|
|
76
|
+
* manifest simply has no observer entry and still validates here. The reverse
|
|
77
|
+
* is the one that bites: an OLDER build reading a manifest that names `observer`
|
|
78
|
+
* does not recognize it, and before the member-scoped skip in
|
|
79
|
+
* {@link parsePlaneManifest} that aborted the whole parse and orphaned the
|
|
80
|
+
* entire detached plane.
|
|
81
|
+
*/
|
|
71
82
|
export function isPlaneMemberName(value) {
|
|
72
83
|
if (typeof value !== "string")
|
|
73
84
|
return false;
|
|
74
|
-
if (value === "server" || value === "worker")
|
|
85
|
+
if (value === "server" || value === "worker" || value === "observer")
|
|
75
86
|
return true;
|
|
76
87
|
return /^executor-[1-9][0-9]*$/.test(value);
|
|
77
88
|
}
|
|
@@ -98,8 +109,14 @@ export function parsePlaneManifest(value) {
|
|
|
98
109
|
return { ok: false, error: `manifest has an unsupported field '${key}'` };
|
|
99
110
|
}
|
|
100
111
|
}
|
|
101
|
-
if (record.schemaVersion !==
|
|
102
|
-
|
|
112
|
+
if (typeof record.schemaVersion !== "number" ||
|
|
113
|
+
!PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS.includes(record.schemaVersion)) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
error: `manifest schema version ${JSON.stringify(record.schemaVersion)} is not supported ` +
|
|
117
|
+
`(this build reads ${PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS.join(", ")}). A manifest ` +
|
|
118
|
+
"from a NEWER build must be wound down with that build's `plane down`",
|
|
119
|
+
};
|
|
103
120
|
}
|
|
104
121
|
if (typeof record.planeId !== "string" || !/^[0-9a-f-]{8,}$/i.test(record.planeId)) {
|
|
105
122
|
return { ok: false, error: "manifest plane identity is missing or malformed" };
|
|
@@ -128,6 +145,7 @@ export function parsePlaneManifest(value) {
|
|
|
128
145
|
return { ok: false, error: "manifest epic run id is malformed" };
|
|
129
146
|
}
|
|
130
147
|
const members = [];
|
|
148
|
+
const unrecognizedMemberNames = [];
|
|
131
149
|
const seen = new Set();
|
|
132
150
|
for (const raw of record.members) {
|
|
133
151
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
@@ -139,8 +157,26 @@ export function parsePlaneManifest(value) {
|
|
|
139
157
|
return { ok: false, error: `manifest member has an unsupported field '${key}'` };
|
|
140
158
|
}
|
|
141
159
|
}
|
|
160
|
+
// MEMBER-SCOPED, not whole-manifest (BAPI-1029). An unrecognized member
|
|
161
|
+
// name means this build is older than the one that wrote the file, and
|
|
162
|
+
// aborting the entire parse over it is what orphans a fully detached plane:
|
|
163
|
+
// `plane down` returns `unvalidated-manifest`, signals nothing, and leaves
|
|
164
|
+
// the operator deleting the file by hand while every process keeps running.
|
|
165
|
+
// Skipping the member instead keeps the plane windable through its process
|
|
166
|
+
// group — which reaches the unnamed member too, because the whole plane
|
|
167
|
+
// shares one group.
|
|
168
|
+
//
|
|
169
|
+
// Every OTHER member check below stays a hard rejection. Those fire on a
|
|
170
|
+
// member this build DOES understand, where a bad pid, state, or log path is
|
|
171
|
+
// corruption or tampering rather than a version gap.
|
|
172
|
+
if (typeof member.name !== "string" || member.name.length === 0) {
|
|
173
|
+
return { ok: false, error: "manifest member name is missing or not a string" };
|
|
174
|
+
}
|
|
142
175
|
if (!isPlaneMemberName(member.name)) {
|
|
143
|
-
|
|
176
|
+
if (!unrecognizedMemberNames.includes(member.name)) {
|
|
177
|
+
unrecognizedMemberNames.push(member.name);
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
144
180
|
}
|
|
145
181
|
if (seen.has(member.name)) {
|
|
146
182
|
return { ok: false, error: `manifest lists member '${member.name}' more than once` };
|
|
@@ -172,10 +208,25 @@ export function parsePlaneManifest(value) {
|
|
|
172
208
|
logPath: member.logPath,
|
|
173
209
|
});
|
|
174
210
|
}
|
|
211
|
+
// Every listed member was unrecognized: there is nothing this build can act
|
|
212
|
+
// on, and treating that as a valid manifest would report an empty plane over
|
|
213
|
+
// a running one. The `members.length === 0` check above cannot catch it,
|
|
214
|
+
// because the raw array was not empty.
|
|
215
|
+
if (members.length === 0) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
error: "manifest lists no member this build recognizes " +
|
|
219
|
+
`(unrecognized: ${unrecognizedMemberNames.join(", ")}). It was written by a newer ` +
|
|
220
|
+
"build; wind the plane down with that build's `plane down`",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
175
223
|
return {
|
|
176
224
|
ok: true,
|
|
225
|
+
unrecognizedMemberNames,
|
|
177
226
|
manifest: {
|
|
178
|
-
|
|
227
|
+
// The version AS READ, not the version this build writes: a v1 manifest
|
|
228
|
+
// that came back stamped v2 would misreport which build owns the plane.
|
|
229
|
+
schemaVersion: record.schemaVersion,
|
|
179
230
|
planeId: record.planeId,
|
|
180
231
|
repoRoot: record.repoRoot,
|
|
181
232
|
supervisorPid: record.supervisorPid,
|
|
@@ -215,7 +266,11 @@ export async function readPlaneManifest(repoRoot, fs) {
|
|
|
215
266
|
const result = parsePlaneManifest(parsed);
|
|
216
267
|
if (!result.ok)
|
|
217
268
|
return { kind: "malformed", error: result.error };
|
|
218
|
-
return {
|
|
269
|
+
return {
|
|
270
|
+
kind: "valid",
|
|
271
|
+
manifest: result.manifest,
|
|
272
|
+
unrecognizedMemberNames: result.unrecognizedMemberNames,
|
|
273
|
+
};
|
|
219
274
|
}
|
|
220
275
|
/**
|
|
221
276
|
* Signal-zero liveness.
|
|
@@ -76,6 +76,12 @@ export const PLANE_MEMBER_STARTUP_FAILURES = {
|
|
|
76
76
|
spawnThrew: "the process could not be started",
|
|
77
77
|
noPid: "the process started without a pid",
|
|
78
78
|
readinessTimeout: "it did not start listening in time",
|
|
79
|
+
// Two heartbeat-specific annotations (BAPI-1036). Before them, a member gated
|
|
80
|
+
// on a durable heartbeat that never arrived was annotated "it did not start
|
|
81
|
+
// listening in time" — false in a way that sends an operator looking for a
|
|
82
|
+
// socket that was never part of the contract.
|
|
83
|
+
heartbeatReadinessTimeout: "it did not publish a durable heartbeat in time",
|
|
84
|
+
readinessUnestablished: "its readiness baseline could not be established",
|
|
79
85
|
};
|
|
80
86
|
const ALLOWED_STARTUP_FAILURES = new Set(Object.values(PLANE_MEMBER_STARTUP_FAILURES));
|
|
81
87
|
/** Substituted for any reason not in the allow-list. Never echoes the input. */
|
|
@@ -10,17 +10,60 @@
|
|
|
10
10
|
* `npx` and never a published package;
|
|
11
11
|
* - the reconciler worker is the ORDINARY worker, explicitly not the dead-man
|
|
12
12
|
* observer;
|
|
13
|
-
* - the dead-man observer is
|
|
14
|
-
*
|
|
13
|
+
* - the dead-man observer is a SEPARATE member in its own OS process (R-1,
|
|
14
|
+
* BAPI-1029): the plane contains exactly one ordinary worker and exactly one
|
|
15
|
+
* dead-man-only observer, and only the observer's environment carries
|
|
16
|
+
* `CONDUCTOR_DEAD_MAN_ONLY`. The two are built from distinct environment
|
|
17
|
+
* objects so the observer flag can never reach the worker by reference —
|
|
18
|
+
* a plane whose "worker" is a second observer has nothing reconciling it.
|
|
19
|
+
* Independence here is process-level on ONE host; separate-host isolation is
|
|
20
|
+
* A2/R36 and this file does not claim it.
|
|
15
21
|
*
|
|
16
22
|
* Credentials travel in the child environment and nowhere else. No argv entry,
|
|
17
23
|
* member name, log path, or formatted description is built from a secret.
|
|
18
24
|
*/
|
|
19
25
|
import path from "path";
|
|
20
|
-
import { PLANE_SERVER_HOST, PLANE_SERVER_PORT, PLANE_SERVER_PORT_ENV_VAR, } from "./types.js";
|
|
26
|
+
import { PLANE_OBSERVER_MODE_ENV, PLANE_OBSERVER_MODE_VALUE, PLANE_SERVER_HOST, PLANE_SERVER_PORT, PLANE_SERVER_PORT_ENV_VAR, } from "./types.js";
|
|
21
27
|
import { relativeLogPathFor } from "./manifest.js";
|
|
22
28
|
/** How long a member with a readiness probe gets to start listening. */
|
|
23
29
|
export const PLANE_READINESS_TIMEOUT_MS = 60_000;
|
|
30
|
+
/**
|
|
31
|
+
* How long the ordinary worker gets to publish its first FRESH durable
|
|
32
|
+
* `component=reconciler` heartbeat, newer than the pre-start baseline.
|
|
33
|
+
*
|
|
34
|
+
* A FIRST-PUBLICATION budget (BAPI-1036), not a staleness window. The two are
|
|
35
|
+
* different quantities and the earlier rationale conflated them: the staleness
|
|
36
|
+
* window is how old a heartbeat may be and still count as fresh, whereas this is
|
|
37
|
+
* how long bring-up waits for one to ARRIVE. Sized from what the reconciler
|
|
38
|
+
* actually does — `_record_reconciler_heartbeat` runs at the top of `tick()`,
|
|
39
|
+
* `tick()` is registered on `IntervalTrigger(seconds=30)` in
|
|
40
|
+
* `api/library/scheduled_jobs.py`, and APScheduler fires an interval job one
|
|
41
|
+
* full interval AFTER the scheduler starts. So the first heartbeat lands roughly
|
|
42
|
+
* `worker.py` boot plus 30 seconds, and 120_000 ms leaves room for a slow boot
|
|
43
|
+
* plus three further tick opportunities before the plane gives up.
|
|
44
|
+
*
|
|
45
|
+
* That it coincides with `RECONCILER_STALE_SECONDS` is now arithmetic, not a
|
|
46
|
+
* derivation: the number is the same, the reason it was chosen is not.
|
|
47
|
+
*/
|
|
48
|
+
export const PLANE_RECONCILER_READINESS_TIMEOUT_MS = 120_000;
|
|
49
|
+
/**
|
|
50
|
+
* How long ONE executor lane gets to publish its first FRESH durable
|
|
51
|
+
* `component=executor` heartbeat, newer than the pre-spawn baseline.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately NOT `EXECUTOR_STALE_SECONDS` (300s). The executor publishes its
|
|
54
|
+
* first heartbeat immediately on entering its loop — `runProcessHeartbeatLoop`
|
|
55
|
+
* in `mcp_server/src/executor/runner.ts` publishes and only then sleeps
|
|
56
|
+
* `PROCESS_HEARTBEAT_INTERVAL_MS` — so a healthy lane reports within a node boot
|
|
57
|
+
* and one HTTP round trip. A 300s budget would buy nothing except three extra
|
|
58
|
+
* minutes before a DEAD lane is reported, which is the opposite of what a launch
|
|
59
|
+
* gate is for.
|
|
60
|
+
*
|
|
61
|
+
* 90_000 ms is boot plus that immediate publication, plus one full 60-second
|
|
62
|
+
* retry interval in case the very first delivery hits a transient failure — the
|
|
63
|
+
* loop's own next attempt is a whole interval later, so a budget under 60s would
|
|
64
|
+
* fail a lane that recovers on its second try.
|
|
65
|
+
*/
|
|
66
|
+
export const PLANE_EXECUTOR_READINESS_TIMEOUT_MS = 90_000;
|
|
24
67
|
/** Executable used for the Python members. Overridable for a non-default venv. */
|
|
25
68
|
export function resolvePythonExecutable(env) {
|
|
26
69
|
const configured = env.BAPI_PLANE_PYTHON;
|
|
@@ -106,8 +149,70 @@ export function buildPlaneChildEnv(parentEnv, context) {
|
|
|
106
149
|
return env;
|
|
107
150
|
}
|
|
108
151
|
/**
|
|
109
|
-
* The
|
|
110
|
-
*
|
|
152
|
+
* The observer's child environment: the ordinary child environment plus
|
|
153
|
+
* dead-man-only mode, and nothing else.
|
|
154
|
+
*
|
|
155
|
+
* A FRESH OBJECT, never a mutation of the shared one. `buildPlaneChildEnv`'s
|
|
156
|
+
* result is handed to the server, the worker, and every executor, so setting the
|
|
157
|
+
* flag on it in place would turn the reconciler worker into a second observer —
|
|
158
|
+
* the precise failure `buildPlaneChildEnv`'s `delete` exists to prevent, arrived
|
|
159
|
+
* at from the other direction.
|
|
160
|
+
*
|
|
161
|
+
* Any `CONDUCTOR_DEADMAN_CHANNEL_TYPE` / `CONDUCTOR_DEADMAN_DESTINATION_REF` the
|
|
162
|
+
* operator exported is inherited through the copy and is never read, echoed,
|
|
163
|
+
* placed in argv, or written to the manifest here: the observer resolves its own
|
|
164
|
+
* alert channel from its environment, exactly as a hand-started observer does.
|
|
165
|
+
*/
|
|
166
|
+
export function buildPlaneObserverEnv(childEnv) {
|
|
167
|
+
return { ...childEnv, [PLANE_OBSERVER_MODE_ENV]: PLANE_OBSERVER_MODE_VALUE };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The complete roster: exactly one server, one dead-man-only observer, one
|
|
171
|
+
* ordinary worker, and `executors` executor lanes.
|
|
172
|
+
*
|
|
173
|
+
* The ORDER is the contract, and it is not alphabetical:
|
|
174
|
+
*
|
|
175
|
+
* 1. `server` — everything downstream needs it. Its TCP readiness also has to
|
|
176
|
+
* be satisfied before any heartbeat readiness probe has a route to call.
|
|
177
|
+
* 2. `worker` — the ordinary reconciler. Its readiness is a fresh durable
|
|
178
|
+
* heartbeat, so it is the slowest member by an order of magnitude.
|
|
179
|
+
* 3. executor lanes — each gated on its OWN durable heartbeat since BAPI-1036.
|
|
180
|
+
* 4. `observer` — started LAST, and deliberately AFTER every gated member's
|
|
181
|
+
* readiness has closed. See below.
|
|
182
|
+
*
|
|
183
|
+
* ## Why the observer starts after every readiness gate
|
|
184
|
+
*
|
|
185
|
+
* `process_heartbeats` rows are durable across runs, and
|
|
186
|
+
* `dead_man._evaluate_component` opens an outage as soon as the newest row
|
|
187
|
+
* exceeds its threshold — there is no boot grace period, and the sweep runs
|
|
188
|
+
* every 60 seconds. So any plane brought up after more than
|
|
189
|
+
* `RECONCILER_STALE_SECONDS` of idleness starts with an ALREADY-stale reconciler
|
|
190
|
+
* row, and an observer sweeping before the new reconciler's first heartbeat
|
|
191
|
+
* lands would page on ordinary bring-up. The heartbeat readiness gate widens
|
|
192
|
+
* that window rather than narrowing it: the observer would sweep for the entire
|
|
193
|
+
* time a member is held back waiting to go fresh.
|
|
194
|
+
*
|
|
195
|
+
* BAPI-1036 moved the observer past the executor lanes for exactly the same
|
|
196
|
+
* reason it was already placed past the worker. `_evaluate_component` watches
|
|
197
|
+
* the executor component on that same graceless 60-second sweep, so once each
|
|
198
|
+
* lane can consume a real readiness budget, an observer started before them
|
|
199
|
+
* would sweep for MINUTES while lanes are still starting — or while a doomed
|
|
200
|
+
* lane is waiting to be rolled back. The earlier order's only defence was that
|
|
201
|
+
* the executor sweep is deployment-wide and so usually satisfied by some sibling
|
|
202
|
+
* process, and that defence expires the moment the executor alert is scoped per
|
|
203
|
+
* repository (BAPI-1035, this ticket's own recommended predecessor).
|
|
204
|
+
*
|
|
205
|
+
* Ordering the observer last closes the window without touching `dead_man.py`,
|
|
206
|
+
* so the additive-only invariant holds; adding a boot grace to the evaluator was
|
|
207
|
+
* rejected outright because the acceptance criteria forbid changing its
|
|
208
|
+
* decisions. The gate is what makes this safe rather than merely quieter: if a
|
|
209
|
+
* gated member never goes fresh, the plane refuses to start at all and rolls
|
|
210
|
+
* back — before any observer process is started — so there is no unobserved
|
|
211
|
+
* running plane. If every gate closes, the observer starts against fresh rows
|
|
212
|
+
* and its first sweep is truthful.
|
|
213
|
+
*
|
|
214
|
+
* The alternative — starting it first and accepting boot noise — trains
|
|
215
|
+
* operators to dismiss the one alert this epic exists to produce.
|
|
111
216
|
*/
|
|
112
217
|
export function buildPlaneMemberRoster(params) {
|
|
113
218
|
const { context, executors, parentEnv, nodeExecutable } = params;
|
|
@@ -135,25 +240,86 @@ export function buildPlaneMemberRoster(params) {
|
|
|
135
240
|
env,
|
|
136
241
|
logPath: relativeLogPathFor("server"),
|
|
137
242
|
readiness: {
|
|
243
|
+
kind: "tcp",
|
|
138
244
|
host: endpoint.host,
|
|
139
245
|
port: endpoint.port,
|
|
140
246
|
timeoutMs: PLANE_READINESS_TIMEOUT_MS,
|
|
141
247
|
},
|
|
142
248
|
};
|
|
249
|
+
/**
|
|
250
|
+
* The independent dead-man observer (R-1, BAPI-1029).
|
|
251
|
+
*
|
|
252
|
+
* The SAME `worker.py` the ordinary worker runs, in its own OS process, with
|
|
253
|
+
* `CONDUCTOR_DEAD_MAN_ONLY=true` in its environment and nowhere else. No shell
|
|
254
|
+
* string: `PLANE_OBSERVER_COMMAND` remains operator-facing prose while the
|
|
255
|
+
* spawn is an executable plus an argument vector, so the mode flag never
|
|
256
|
+
* passes through a shell that could log it or reorder it.
|
|
257
|
+
*
|
|
258
|
+
* `readiness: null` on purpose, and the ONLY member left without a readiness
|
|
259
|
+
* contract since BAPI-1036 gated the executor lanes. The observer has no
|
|
260
|
+
* listening socket and publishes no durable heartbeat of its own, so "spawned
|
|
261
|
+
* and still alive" is the honest signal — and inventing a second liveness
|
|
262
|
+
* channel for the thing whose job is being the liveness channel would be
|
|
263
|
+
* circular. It inherits the R-4 no-respawn lifecycle unchanged.
|
|
264
|
+
*
|
|
265
|
+
* The residual that exemption accepts: an observer that crashes on boot leaves
|
|
266
|
+
* a plane that is announced ready with no dead-man watcher. It is detected by
|
|
267
|
+
* `plane status`, which reports the member `exited` with its exit code from the
|
|
268
|
+
* durable manifest, and by `.bridge/plane/observer.log`; it is contained by the
|
|
269
|
+
* R-4 no-respawn rule, which leaves the crash visible instead of masking it in
|
|
270
|
+
* a restart loop; and it escalates to the operator, who is the only party that
|
|
271
|
+
* can act — the plane never claims the observer is watching when the manifest
|
|
272
|
+
* says it exited.
|
|
273
|
+
*
|
|
274
|
+
* Ordered LAST of all members, after every readiness gate has closed — see the
|
|
275
|
+
* ordering contract on {@link buildPlaneMemberRoster}.
|
|
276
|
+
*/
|
|
277
|
+
const observer = {
|
|
278
|
+
name: "observer",
|
|
279
|
+
command: resolvePythonExecutable(parentEnv),
|
|
280
|
+
args: ["worker.py"],
|
|
281
|
+
cwd,
|
|
282
|
+
env: buildPlaneObserverEnv(env),
|
|
283
|
+
logPath: relativeLogPathFor("observer"),
|
|
284
|
+
readiness: null,
|
|
285
|
+
};
|
|
143
286
|
const worker = {
|
|
144
287
|
name: "worker",
|
|
145
288
|
command: resolvePythonExecutable(parentEnv),
|
|
146
289
|
args: ["worker.py"],
|
|
147
290
|
cwd,
|
|
148
291
|
env,
|
|
292
|
+
// The reconciler has no listening socket, so it used to be `readiness: null`
|
|
293
|
+
// — "spawned and still alive", which cannot tell a booting reconciler from
|
|
294
|
+
// one that crashed on its first tick. It is now gated on a FRESH durable
|
|
295
|
+
// `component=reconciler` heartbeat read back through the server member's
|
|
296
|
+
// `GET /automation/health`, reusing the backend's existing verdict
|
|
297
|
+
// derivation rather than inventing a health endpoint (BAPI-1029, R9).
|
|
149
298
|
logPath: relativeLogPathFor("worker"),
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
299
|
+
readiness: {
|
|
300
|
+
kind: "process-heartbeat",
|
|
301
|
+
baseUrl: endpoint.baseUrl,
|
|
302
|
+
repoName: context.repoName,
|
|
303
|
+
component: "reconciler",
|
|
304
|
+
// No `instanceId` here, and it is not an omission. The local reconciler
|
|
305
|
+
// publishes `<hostname>:<pid>`, and the pid does not exist until `spawn`
|
|
306
|
+
// returns — the supervisor resolves the identity immediately after the
|
|
307
|
+
// spawn and before the readiness wait begins. A `DYNO` deployment is the
|
|
308
|
+
// one case that resolves without a pid, and the supervisor handles it
|
|
309
|
+
// there for the same reason: one resolver, one rule.
|
|
310
|
+
timeoutMs: PLANE_RECONCILER_READINESS_TIMEOUT_MS,
|
|
311
|
+
},
|
|
153
312
|
};
|
|
154
313
|
const executorMembers = [];
|
|
155
314
|
for (let lane = 1; lane <= executors; lane += 1) {
|
|
156
315
|
const name = `executor-${lane}`;
|
|
316
|
+
// Minted ONCE and used twice, deliberately. The value passed as
|
|
317
|
+
// `--executor-id` is the value the lane publishes as its heartbeat
|
|
318
|
+
// `instance_id` (`runner.ts`: `instance_id: options.executorId`), so the
|
|
319
|
+
// readiness gate can only be honest if it asserts the same string. Two calls
|
|
320
|
+
// to `buildExecutorId` would be equal today and are exactly the shape that
|
|
321
|
+
// stops being equal after an edit to one of them.
|
|
322
|
+
const executorId = buildExecutorId(context.repoName, lane);
|
|
157
323
|
executorMembers.push({
|
|
158
324
|
name,
|
|
159
325
|
command: nodeExecutable,
|
|
@@ -167,15 +333,33 @@ export function buildPlaneMemberRoster(params) {
|
|
|
167
333
|
"--base-url",
|
|
168
334
|
endpoint.baseUrl,
|
|
169
335
|
"--executor-id",
|
|
170
|
-
|
|
336
|
+
executorId,
|
|
171
337
|
],
|
|
172
338
|
cwd,
|
|
173
339
|
env,
|
|
174
340
|
logPath: relativeLogPathFor(name),
|
|
175
|
-
readiness: null
|
|
341
|
+
// Gated since BAPI-1036. `readiness: null` here meant every lane was
|
|
342
|
+
// declared READY the instant it was spawned, so `plane up` printed a green
|
|
343
|
+
// banner over a lane that had already exited on a bad node path, an
|
|
344
|
+
// unbuilt bundle, or a rejected credential.
|
|
345
|
+
//
|
|
346
|
+
// Scoped to THIS lane's identity, not to the repository: every lane of one
|
|
347
|
+
// plane serves the same repository, so a repository-scoped verdict is
|
|
348
|
+
// satisfied by lane 1 for a lane 2 that never started.
|
|
349
|
+
//
|
|
350
|
+
// Carries no credential, exactly like the worker's spec: the API key
|
|
351
|
+
// travels only in `env` and the probe reads it there at call time.
|
|
352
|
+
readiness: {
|
|
353
|
+
kind: "process-heartbeat",
|
|
354
|
+
baseUrl: endpoint.baseUrl,
|
|
355
|
+
repoName: context.repoName,
|
|
356
|
+
component: "executor",
|
|
357
|
+
instanceId: executorId,
|
|
358
|
+
timeoutMs: PLANE_EXECUTOR_READINESS_TIMEOUT_MS,
|
|
359
|
+
},
|
|
176
360
|
});
|
|
177
361
|
}
|
|
178
|
-
return [server, worker, ...executorMembers];
|
|
362
|
+
return [server, worker, ...executorMembers, observer];
|
|
179
363
|
}
|
|
180
364
|
/**
|
|
181
365
|
* Deterministic, distinct, CLI-safe executor id for one lane.
|
package/build/plane/preflight.js
CHANGED
|
@@ -42,6 +42,12 @@ export async function runPlanePreflight(repoRoot, deps) {
|
|
|
42
42
|
// than emitting a cascade of derived failures the operator cannot act on.
|
|
43
43
|
if (rootCheck)
|
|
44
44
|
return { ok: false, diagnostics };
|
|
45
|
+
// BAPI-1054: aggregated with every other independent check rather than
|
|
46
|
+
// short-circuiting. `checkRepositoryRoot` above proved the marker files are
|
|
47
|
+
// here; this proves the directory is a real work tree. They are independent
|
|
48
|
+
// facts and both can be true at once, which is exactly what the all-or-nothing
|
|
49
|
+
// contract exists to report together.
|
|
50
|
+
add(await checkRepositoryWorktree(repoRoot, deps));
|
|
45
51
|
add(await checkClaudeLogin(deps));
|
|
46
52
|
const credentials = await checkBridgeCredentials(repoRoot, deps);
|
|
47
53
|
// Advisories first, then the failure. An insecure credentials file and an
|
|
@@ -134,6 +140,43 @@ async function checkRepositoryRoot(repoRoot, deps) {
|
|
|
134
140
|
"Run `plane up` from the repository root.",
|
|
135
141
|
};
|
|
136
142
|
}
|
|
143
|
+
/** Remediation for a directory that is not a Git work tree. Fixed prose. */
|
|
144
|
+
export const PLANE_WORKTREE_REMEDIATION = "cd into the intended worktree (or create it with `git worktree add`) and rerun.";
|
|
145
|
+
/**
|
|
146
|
+
* Confirm the repository root is inside a real Git work tree (BAPI-1054).
|
|
147
|
+
*
|
|
148
|
+
* `checkRepositoryRoot` cannot answer this. Its four marker paths — `main.py`,
|
|
149
|
+
* `worker.py`, `alembic.ini`, `mcp_server/` — are present in every LINKED
|
|
150
|
+
* worktree and in any plain copy of the tree, so it proves the files are here,
|
|
151
|
+
* never that Git owns them. A plane brought up over an unpacked archive would
|
|
152
|
+
* pass that check and then fail at the first `git` operation a member performs.
|
|
153
|
+
*
|
|
154
|
+
* Linked worktrees are valid and must stay valid: `--is-inside-work-tree` is
|
|
155
|
+
* true in a linked worktree (where `.git` is a FILE, not a directory), which is
|
|
156
|
+
* why the predicate is this command and not a `.git` directory probe.
|
|
157
|
+
*
|
|
158
|
+
* READ-ONLY, like every other check here: it creates no worktree and repairs
|
|
159
|
+
* nothing. A directory that is not a work tree is named, with the fix, and the
|
|
160
|
+
* operator performs it.
|
|
161
|
+
*/
|
|
162
|
+
export async function checkRepositoryWorktree(repoRoot, deps) {
|
|
163
|
+
const result = await deps.execFile("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
164
|
+
cwd: repoRoot,
|
|
165
|
+
});
|
|
166
|
+
// A failed command and a `false` answer are the same operator-facing fact —
|
|
167
|
+
// this is not a work tree — and are reported identically. The command's own
|
|
168
|
+
// stderr is deliberately NOT interpolated: it is unbounded text from a
|
|
169
|
+
// subprocess, and the remediation does not depend on which way it failed.
|
|
170
|
+
if (!result.ok || result.stdout.trim() !== "true") {
|
|
171
|
+
return {
|
|
172
|
+
check: "worktree-presence",
|
|
173
|
+
severity: "blocking",
|
|
174
|
+
message: `${repoRoot} is not inside a Git work tree (git rev-parse --is-inside-work-tree ` +
|
|
175
|
+
`did not report 'true'). No worktree was created — ${PLANE_WORKTREE_REMEDIATION}`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
137
180
|
/**
|
|
138
181
|
* Advisory-only Claude login marker (BAPI-791).
|
|
139
182
|
*
|
package/build/plane/shutdown.js
CHANGED
|
@@ -48,19 +48,41 @@ export async function shutdownPlane(repoRoot, deps) {
|
|
|
48
48
|
if (read.kind !== "valid") {
|
|
49
49
|
// Deliberately before any liveness probe: an unvalidated record must never
|
|
50
50
|
// become a signal target, not even a signal-zero one — and, as of BAPI-872,
|
|
51
|
-
// must never authorize a run-stop lookup either.
|
|
51
|
+
// must never authorize a run-stop lookup either. That property is NOT
|
|
52
|
+
// relaxed here.
|
|
53
|
+
//
|
|
54
|
+
// What BAPI-1029 changed is the advice. "Remove it by hand" was the whole
|
|
55
|
+
// guidance, and it is the wrong instruction for the most likely cause: a
|
|
56
|
+
// manifest written by a NEWER build, which this one cannot parse. Deleting
|
|
57
|
+
// the file there does not stop anything — it destroys the only record of a
|
|
58
|
+
// plane that is still running, and orphans every member permanently.
|
|
52
59
|
return {
|
|
53
60
|
ok: false,
|
|
54
61
|
reason: "unvalidated-manifest",
|
|
55
62
|
message: `.bridge/plane/plane.json could not be validated (${read.error}). No process was ` +
|
|
56
|
-
"signalled
|
|
57
|
-
"
|
|
63
|
+
"signalled, because an unvalidated record must never become a signal target.\n" +
|
|
64
|
+
" If this manifest was written by a NEWER build, wind the plane down with THAT " +
|
|
65
|
+
"build's `plane down` — do not delete the file, which would orphan every member " +
|
|
66
|
+
"with no record of what to stop.\n" +
|
|
67
|
+
" Otherwise, confirm no plane is running (`ps` for the recorded pids) and only " +
|
|
68
|
+
"then remove the file by hand.",
|
|
58
69
|
members: [],
|
|
59
70
|
runShutdown: { kind: "unbound" },
|
|
60
71
|
};
|
|
61
72
|
}
|
|
62
73
|
const manifest = read.manifest;
|
|
63
74
|
const messages = [];
|
|
75
|
+
if (read.unrecognizedMemberNames.length > 0) {
|
|
76
|
+
// Said out loud, never assumed harmless (BAPI-1029). The wind-down still
|
|
77
|
+
// reaches these members — the whole plane shares one process group, and the
|
|
78
|
+
// group is what gets signalled — but the per-member reporting below cannot
|
|
79
|
+
// name them, and an operator reading a short list would otherwise conclude
|
|
80
|
+
// the plane was smaller than it was.
|
|
81
|
+
messages.push(`This manifest lists ${read.unrecognizedMemberNames.length} member(s) this build does ` +
|
|
82
|
+
`not recognize: ${read.unrecognizedMemberNames.join(", ")}. They are signalled with ` +
|
|
83
|
+
"the rest of the process group, but are not reported individually below. The manifest " +
|
|
84
|
+
"was written by a NEWER build.");
|
|
85
|
+
}
|
|
64
86
|
// BAPI-872: the server-side run stop, computed ONCE right after the manifest
|
|
65
87
|
// has passed every validation and identity check above — strictly before the
|
|
66
88
|
// first process-group signal in every branch that sends one (the sole branch
|
package/build/plane/status.js
CHANGED
|
@@ -31,6 +31,7 @@ export async function getPlaneStatus(repoRoot, deps) {
|
|
|
31
31
|
supervisorPid: manifest.supervisorPid,
|
|
32
32
|
supervisorPgid: manifest.supervisorPgid,
|
|
33
33
|
supervisorLiveness: deps.proc.isAlive(manifest.supervisorPid),
|
|
34
|
+
unrecognizedMemberNames: read.unrecognizedMemberNames,
|
|
34
35
|
createdAt: manifest.createdAt,
|
|
35
36
|
updatedAt: manifest.updatedAt,
|
|
36
37
|
members: probeManifestMembers(manifest, deps.proc).map((probe) => ({
|
|
@@ -62,6 +63,16 @@ export function formatPlaneStatus(result) {
|
|
|
62
63
|
lines.push(` ${member.name.padEnd(12)} ${describeMemberState(member, result.supervisorLiveness)} ` +
|
|
63
64
|
`${member.logPath}`);
|
|
64
65
|
}
|
|
66
|
+
if (result.unrecognizedMemberNames.length > 0) {
|
|
67
|
+
// Never a silent omission. The plane really has more members than the list
|
|
68
|
+
// above shows, and the operator needs to know the shortfall is a version
|
|
69
|
+
// gap rather than a crash.
|
|
70
|
+
lines.push("");
|
|
71
|
+
lines.push(`${result.unrecognizedMemberNames.length} member(s) are NOT SHOWN above — this build ` +
|
|
72
|
+
`does not recognize the name(s): ${result.unrecognizedMemberNames.join(", ")}. The ` +
|
|
73
|
+
"manifest was written by a NEWER build; use that build's `plane status` to see them, " +
|
|
74
|
+
"and its `plane down` to stop the plane.");
|
|
75
|
+
}
|
|
65
76
|
const neverStarted = result.members.filter((m) => isPreStart(m) && result.supervisorLiveness === "dead");
|
|
66
77
|
if (neverStarted.length > 0) {
|
|
67
78
|
// These members have no log to read — pointing the operator at an empty file
|