@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.
Files changed (76) hide show
  1. package/README.md +24 -8
  2. package/build/agent-capabilities/probe-context.js +15 -7
  3. package/build/agent-capabilities/probes.js +42 -6
  4. package/build/agent-launchers/claude-executor-adapter.js +98 -14
  5. package/build/commands.generated.js +1 -1
  6. package/build/conduct-epic/bridge-client.js +115 -1
  7. package/build/conduct-epic/cli.js +351 -33
  8. package/build/conduct-epic/cut-protocol.js +65 -0
  9. package/build/conductor/bridge-api-client.js +171 -5
  10. package/build/conductor/deny-enforcement-preflight.js +107 -10
  11. package/build/conductor/local-merge.js +170 -11
  12. package/build/conductor-bin.js +2 -2
  13. package/build/connect-bitbucket-api.js +370 -0
  14. package/build/connect-bitbucket.js +437 -0
  15. package/build/docs.generated.js +1 -1
  16. package/build/doctor.js +230 -1
  17. package/build/drive-epic.js +423 -11
  18. package/build/env-file-link.js +164 -0
  19. package/build/epic-integration-pr.js +290 -0
  20. package/build/executor/cli.js +41 -6
  21. package/build/executor/deps.js +5 -1
  22. package/build/executor/env-file-guard.js +113 -0
  23. package/build/executor/env.js +78 -1
  24. package/build/executor/heartbeat.js +9 -0
  25. package/build/executor/http-client.js +90 -22
  26. package/build/executor/job-errors.js +43 -2
  27. package/build/executor/job-runner.js +137 -29
  28. package/build/executor/merge-job.js +102 -6
  29. package/build/executor/permissions.js +106 -0
  30. package/build/executor/preflight.js +38 -13
  31. package/build/executor/resume-pre-spawn.js +2 -1
  32. package/build/executor/runner.js +175 -4
  33. package/build/executor/service-unit.js +15 -0
  34. package/build/executor/terminal-mutation.js +22 -1
  35. package/build/executor/types.js +86 -0
  36. package/build/executor/worker-command.js +21 -5
  37. package/build/executor/worker-guard-hook.js +939 -0
  38. package/build/executor/worker-log.js +56 -0
  39. package/build/executor/worktree.js +11 -0
  40. package/build/git-reachability.js +147 -0
  41. package/build/index.js +535 -95
  42. package/build/install-bridge.js +95 -0
  43. package/build/pipelines.generated.js +10 -2
  44. package/build/plan-epic-conductor-eligibility.js +213 -0
  45. package/build/plane/cli.js +78 -15
  46. package/build/plane/defaults.js +165 -0
  47. package/build/plane/manifest.js +63 -8
  48. package/build/plane/member-logs.js +6 -0
  49. package/build/plane/member-roster.js +195 -11
  50. package/build/plane/preflight.js +43 -0
  51. package/build/plane/shutdown.js +25 -3
  52. package/build/plane/status.js +11 -0
  53. package/build/plane/supervisor.js +343 -14
  54. package/build/plane/test-fakes.js +43 -0
  55. package/build/plane/types.js +82 -11
  56. package/build/pr-base-contract.js +20 -0
  57. package/build/readme.generated.js +1 -1
  58. package/build/review-synthesis-config.js +60 -0
  59. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  60. package/build/setup-epic.js +592 -139
  61. package/build/sfcc/log-query.js +2 -1
  62. package/build/sfcc/reads-custom-object-def.js +10 -13
  63. package/build/sfcc/reads-site-preference.js +5 -5
  64. package/build/sfcc/reads-system-object.js +4 -4
  65. package/build/sfcc/writes-custom-object-def.js +7 -7
  66. package/build/sfcc/writes-site-preference.js +4 -3
  67. package/build/sfcc/writes-system-object.js +7 -6
  68. package/build/start-tickets-conductor.js +11 -2
  69. package/build/start-tickets.js +69 -2
  70. package/build/version.generated.js +3 -3
  71. package/build/worker-containment-diagnostic.js +97 -0
  72. package/build/worker-guard-hook-bin.js +6 -0
  73. package/docs/CONDUCTOR.md +27 -0
  74. package/docs/install/mcp-tool-integrations.md +3 -2
  75. package/package.json +5 -3
  76. package/pipelines/plan-epic.json +5 -0
@@ -17,7 +17,7 @@
17
17
  * argv, and no credential field, and unknown keys are rejected outright.
18
18
  */
19
19
  import path from "path";
20
- import { PLANE_MANIFEST_FILENAME, PLANE_MANIFEST_SCHEMA_VERSION, PLANE_RUNTIME_DIR, } from "./types.js";
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
- /** `server`, `worker`, or `executor-<positive integer>`. Nothing else. */
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 !== PLANE_MANIFEST_SCHEMA_VERSION) {
102
- return { ok: false, error: "manifest schema version is not supported" };
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
- return { ok: false, error: "manifest member name is not a recognized plane member" };
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
- schemaVersion: PLANE_MANIFEST_SCHEMA_VERSION,
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 { kind: "valid", manifest: result.manifest };
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 absent by construction there is no observer
14
- * member, pid, log path, or manifest record anywhere in this file (R-3).
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 complete roster: exactly one server, one ordinary worker, and `executors`
110
- * executor lanes. Never an observer.
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
- // The reconciler has no listening socket, so "spawned and still alive" is
151
- // the only readiness signal available without inventing a health endpoint.
152
- readiness: null,
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
- buildExecutorId(context.repoName, lane),
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.
@@ -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
  *
@@ -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. Inspect the file and remove it by hand once you have confirmed no plane " +
57
- "is running.",
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
@@ -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