@bridge_gpt/mcp-server 0.2.51 → 0.2.53

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 (79) hide show
  1. package/README.md +59 -13
  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 +7 -5
  6. package/build/conduct-epic/cut-protocol.js +17 -3
  7. package/build/conductor/bridge-api-client.js +232 -5
  8. package/build/conductor/cli.js +23 -0
  9. package/build/conductor/deny-enforcement-preflight.js +107 -10
  10. package/build/conductor/doctor.js +428 -5
  11. package/build/conductor/install-doctor.js +65 -656
  12. package/build/conductor/local-merge.js +170 -11
  13. package/build/conductor/readiness-cli.js +152 -0
  14. package/build/conductor/readiness-sections.js +666 -0
  15. package/build/conductor/readiness.js +710 -0
  16. package/build/conductor/tools.js +56 -3
  17. package/build/conductor-bin.js +21 -17
  18. package/build/connect-bitbucket-api.js +370 -0
  19. package/build/connect-bitbucket.js +437 -0
  20. package/build/docs.generated.js +1 -1
  21. package/build/doctor.js +40 -1
  22. package/build/drive-epic.js +423 -11
  23. package/build/env-file-link.js +164 -0
  24. package/build/epic-integration-pr.js +10 -0
  25. package/build/executor/cli.js +41 -6
  26. package/build/executor/deps.js +5 -1
  27. package/build/executor/env-file-guard.js +113 -0
  28. package/build/executor/env.js +78 -1
  29. package/build/executor/heartbeat.js +9 -0
  30. package/build/executor/http-client.js +90 -22
  31. package/build/executor/job-errors.js +43 -2
  32. package/build/executor/job-runner.js +130 -28
  33. package/build/executor/merge-job.js +67 -16
  34. package/build/executor/permissions.js +106 -0
  35. package/build/executor/preflight.js +38 -13
  36. package/build/executor/resume-pre-spawn.js +2 -1
  37. package/build/executor/runner.js +175 -4
  38. package/build/executor/service-unit.js +15 -0
  39. package/build/executor/terminal-mutation.js +22 -1
  40. package/build/executor/types.js +86 -0
  41. package/build/executor/worker-command.js +21 -5
  42. package/build/executor/worker-guard-hook.js +939 -0
  43. package/build/executor/worker-log.js +56 -0
  44. package/build/executor/worktree.js +11 -0
  45. package/build/git-reachability.js +147 -0
  46. package/build/index.js +4734 -4270
  47. package/build/install-bridge.js +95 -0
  48. package/build/install-doctor.js +154 -2
  49. package/build/pipelines.generated.js +6 -4
  50. package/build/plan-epic-conductor-eligibility.js +37 -7
  51. package/build/plane/alembic-head.js +40 -11
  52. package/build/plane/build-freshness.js +22 -11
  53. package/build/plane/cli.js +78 -15
  54. package/build/plane/defaults.js +165 -0
  55. package/build/plane/manifest.js +63 -8
  56. package/build/plane/member-logs.js +6 -0
  57. package/build/plane/member-roster.js +195 -11
  58. package/build/plane/preflight.js +402 -44
  59. package/build/plane/shutdown.js +25 -3
  60. package/build/plane/status.js +11 -0
  61. package/build/plane/supervisor.js +343 -14
  62. package/build/plane/test-fakes.js +43 -0
  63. package/build/plane/types.js +118 -11
  64. package/build/pr-base-contract.js +20 -0
  65. package/build/readiness-check.js +412 -0
  66. package/build/readme.generated.js +1 -1
  67. package/build/review-synthesis-config.js +60 -0
  68. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  69. package/build/setup-epic.js +560 -139
  70. package/build/sfcc/log-query.js +2 -1
  71. package/build/start-tickets-conductor.js +11 -2
  72. package/build/start-tickets.js +69 -2
  73. package/build/version.generated.js +3 -3
  74. package/build/worker-containment-diagnostic.js +97 -0
  75. package/build/worker-guard-hook-bin.js +6 -0
  76. package/docs/CONDUCTOR.md +27 -0
  77. package/docs/install/mcp-tool-integrations.md +3 -2
  78. package/package.json +4 -3
  79. package/pipelines/{full-automation.json → idea-to-pr.json} +1 -1
@@ -46,6 +46,10 @@ const UNAVAILABLE = { status: "unavailable" };
46
46
  function isNonBlankString(value) {
47
47
  return typeof value === "string" && value.trim().length > 0;
48
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
+ }
49
53
  /** Treat a missing/undefined optional field as empty text; reject a non-string value. */
50
54
  function optionalTextOrNull(value) {
51
55
  if (value === undefined || value === null)
@@ -113,8 +117,12 @@ export async function assessEpicConductorEligibility(children, deps) {
113
117
  if (response === null ||
114
118
  typeof response !== "object" ||
115
119
  !Array.isArray(response.predictions) ||
116
- typeof response.total_children !== "number" ||
117
- typeof response.predicted_workflow_children !== "number" ||
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 ||
118
126
  typeof response.reason !== "string") {
119
127
  console.error("assessEpicConductorEligibility: malformed backend response shape; unavailable.");
120
128
  return UNAVAILABLE;
@@ -132,10 +140,17 @@ export async function assessEpicConductorEligibility(children, deps) {
132
140
  typeof prediction !== "object" ||
133
141
  !isNonBlankString(prediction.child_id) ||
134
142
  typeof prediction.predicted_workflow_modified !== "boolean" ||
135
- !Array.isArray(prediction.matched_paths)) {
143
+ !isStringArray(prediction.matched_paths) ||
144
+ typeof prediction.predicted_containment_hazard !== "boolean" ||
145
+ !isStringArray(prediction.matched_hazards)) {
136
146
  console.error("assessEpicConductorEligibility: malformed per-child prediction; unavailable.");
137
147
  return UNAVAILABLE;
138
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
+ }
139
154
  if (predictionById.has(prediction.child_id)) {
140
155
  console.error("assessEpicConductorEligibility: duplicate child_id in backend response; unavailable.");
141
156
  return UNAVAILABLE;
@@ -145,6 +160,7 @@ export async function assessEpicConductorEligibility(children, deps) {
145
160
  const affectedChildren = [];
146
161
  let reviewSubsetChildren = 0;
147
162
  let predictedWorkflowChildren = 0;
163
+ let containmentChildren = 0;
148
164
  for (const child of validated) {
149
165
  const prediction = predictionById.get(child.id);
150
166
  if (!prediction) {
@@ -160,24 +176,38 @@ export async function assessEpicConductorEligibility(children, deps) {
160
176
  "but the backend did not mark it merge-blocked; refusing contradictory result.");
161
177
  return UNAVAILABLE;
162
178
  }
163
- if (!prediction.predicted_workflow_modified)
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)
164
189
  continue;
165
- predictedWorkflowChildren += 1;
166
- if (reviewPathReferenced)
167
- reviewSubsetChildren += 1;
168
190
  affectedChildren.push({
169
191
  id: child.id,
170
192
  title: child.title,
171
193
  matchedPaths: [...prediction.matched_paths],
172
194
  requiresHandReview: reviewPathReferenced,
195
+ requiresContainmentReview: prediction.predicted_containment_hazard,
196
+ matchedHazards: [...prediction.matched_hazards],
173
197
  });
174
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
+ }
175
204
  return {
176
205
  status: "assessed",
177
206
  totalChildren: validated.length,
178
207
  predictedWorkflowChildren,
179
208
  reason: response.reason,
180
209
  reviewSubsetChildren,
210
+ containmentChildren,
181
211
  affectedChildren,
182
212
  };
183
213
  }
@@ -13,10 +13,16 @@
13
13
  * condition it guards is already reported loudly by the server at dispatch time.
14
14
  */
15
15
  import path from "path";
16
+ import { planeCheckFinding, planeCheckPassed } from "./types.js";
16
17
  /** Remediation embedded in a confirmed-mismatch diagnostic. */
17
18
  export const PLANE_ALEMBIC_REMEDIATION = "alembic -c alembic.ini upgrade head";
18
19
  /** Fixed prefix of the fail-open warning, so tests and greps can match it. */
19
20
  export const PLANE_ALEMBIC_UNVERIFIED_PREFIX = "could not verify database migration head";
21
+ /** Structured fix for a confirmed migration mismatch. */
22
+ export const PLANE_ALEMBIC_MISMATCH_REMEDIATION = `run \`${PLANE_ALEMBIC_REMEDIATION}\` to bring the database up to the migration head, then retry.`;
23
+ /** Structured fix for an unverifiable migration head. Never blocking. */
24
+ export const PLANE_ALEMBIC_UNVERIFIED_REMEDIATION = "verify by hand with `alembic -c alembic.ini heads` and `alembic -c alembic.ini current`; " +
25
+ "startup continues, and the server reports CONTRACT_MIGRATION_BEHIND at dispatch if the database is behind.";
20
26
  /**
21
27
  * Path to the repository virtualenv's Alembic executable.
22
28
  *
@@ -72,39 +78,62 @@ export function parseAlembicRevisions(output) {
72
78
  export async function checkAlembicHead(repoRoot, deps) {
73
79
  const alembic = resolveRepositoryAlembicCommand(repoRoot, deps.platform);
74
80
  if (!(await deps.fileExists(alembic))) {
75
- return unverified("the repository virtualenv Alembic executable was not found");
81
+ return unverified("the repository virtualenv Alembic executable was not found", "the repository virtualenv Alembic executable was not found");
76
82
  }
77
83
  const heads = await deps.execFile(alembic, ["-c", "alembic.ini", "heads"], { cwd: repoRoot });
78
84
  if (!heads.ok) {
79
- return unverified(`\`alembic heads\` did not complete (${heads.error})`);
85
+ // The interpolated `error` stays in the legacy message for the operator at
86
+ // the terminal; the consolidated detail is fixed prose. In production that
87
+ // field is an errno code, but this outcome flows into a wider report and
88
+ // must not depend on every caller keeping it that way.
89
+ return unverified(`\`alembic heads\` did not complete (${heads.error})`, "`alembic heads` did not complete");
80
90
  }
81
91
  const current = await deps.execFile(alembic, ["-c", "alembic.ini", "current"], { cwd: repoRoot });
82
92
  if (!current.ok) {
83
- return unverified(`\`alembic current\` did not complete (${current.error})`);
93
+ return unverified(`\`alembic current\` did not complete (${current.error})`, "`alembic current` did not complete");
84
94
  }
85
95
  const headRevisions = parseAlembicRevisions(heads.stdout);
86
96
  const currentRevisions = parseAlembicRevisions(current.stdout);
87
97
  if (headRevisions === null || currentRevisions === null) {
88
- return unverified("Alembic output did not contain a readable revision");
98
+ return unverified("Alembic output did not contain a readable revision", "Alembic output did not contain a readable revision");
89
99
  }
90
100
  const matches = headRevisions.length === currentRevisions.length &&
91
101
  headRevisions.every((rev, i) => rev === currentRevisions[i]);
92
- if (matches)
93
- return null;
94
- return {
102
+ if (matches) {
103
+ return planeCheckPassed("alembic-head", ALEMBIC_CHECK_LABEL, "the database is at the repository migration head");
104
+ }
105
+ return planeCheckFinding({
95
106
  check: "alembic-head",
96
107
  severity: "blocking",
97
108
  message: `the database is not at the migration head — heads [${headRevisions.join(", ")}], ` +
98
109
  `current [${currentRevisions.join(", ")}]. A database behind the head blocks ` +
99
110
  `conductor dispatch. Run \`${PLANE_ALEMBIC_REMEDIATION}\``,
100
- };
111
+ remediation: PLANE_ALEMBIC_MISMATCH_REMEDIATION,
112
+ }, ALEMBIC_CHECK_LABEL,
113
+ // Revision ids are repository-authored slugs, not secrets, and they are the
114
+ // one fact that makes this finding actionable.
115
+ `database at [${currentRevisions.join(", ")}], repository head [${headRevisions.join(", ")}]`);
101
116
  }
102
- function unverified(detail) {
103
- return {
117
+ /** Human-readable name for the migration-head prerequisite. */
118
+ const ALEMBIC_CHECK_LABEL = "Database migration head";
119
+ /**
120
+ * The fail-open branch: warning severity, and never blocking.
121
+ *
122
+ * TWO details, deliberately. `messageDetail` is the legacy operator-facing prose
123
+ * and may name the command's own `error` field; `outcomeDetail` is fixed prose
124
+ * only, because the outcome flows into the consolidated readiness report. The
125
+ * production `execFile` adapter puts an errno code in `error`, but this
126
+ * separation means the consolidated report stays safe even if some caller ever
127
+ * puts raw stderr, an interpreter path, or a thrown message there.
128
+ */
129
+ function unverified(messageDetail, outcomeDetail) {
130
+ const diagnostic = {
104
131
  check: "alembic-head",
105
132
  severity: "warning",
106
- message: `${PLANE_ALEMBIC_UNVERIFIED_PREFIX}: ${detail}. Startup continues — verify with ` +
133
+ message: `${PLANE_ALEMBIC_UNVERIFIED_PREFIX}: ${messageDetail}. Startup continues — verify with ` +
107
134
  `\`alembic -c alembic.ini heads\` and \`alembic -c alembic.ini current\` if a run ` +
108
135
  "later reports CONTRACT_MIGRATION_BEHIND.",
136
+ remediation: PLANE_ALEMBIC_UNVERIFIED_REMEDIATION,
109
137
  };
138
+ return planeCheckFinding(diagnostic, ALEMBIC_CHECK_LABEL, `${PLANE_ALEMBIC_UNVERIFIED_PREFIX}: ${outcomeDetail}`);
110
139
  }
@@ -13,6 +13,7 @@
13
13
  * can be older than a rebuild that overwrote files in place.
14
14
  */
15
15
  import path from "path";
16
+ import { planeCheckFinding, planeCheckPassed } from "./types.js";
16
17
  /** Remediation text embedded in every blocking build diagnostic. */
17
18
  export const PLANE_BUILD_REMEDIATION = "cd mcp_server && npm run build";
18
19
  /**
@@ -93,42 +94,49 @@ export async function findBuildMtime(executorEntrypoint, deps) {
93
94
  * stale or absent build is exactly the failure this check exists to prevent, so
94
95
  * unlike the Alembic check there is no fail-open branch.
95
96
  */
97
+ export const PLANE_BUILD_FIX_REMEDIATION = "run `cd mcp_server && npm run build` to rebuild the executor entrypoint, then retry.";
96
98
  export async function checkPlaneBuildFreshness(repoRoot, deps) {
99
+ const label = "Executor build freshness";
97
100
  const packageRoot = path.join(repoRoot, "mcp_server");
98
101
  const buildDir = path.join(packageRoot, "build");
99
102
  const executorEntrypoint = path.join(buildDir, "index.js");
100
103
  const srcDir = path.join(packageRoot, "src");
101
104
  const buildMtime = await findBuildMtime(executorEntrypoint, deps);
102
105
  if (!buildMtime.ok) {
103
- return {
106
+ return planeCheckFinding({
104
107
  check: "executor-build",
105
108
  severity: "blocking",
106
109
  message: `the executor build entrypoint is missing or unreadable at ` +
107
110
  `mcp_server/build/index.js — run \`${PLANE_BUILD_REMEDIATION}\``,
108
- };
111
+ remediation: PLANE_BUILD_FIX_REMEDIATION,
112
+ }, label, "the executor build entrypoint is missing or unreadable");
109
113
  }
110
114
  const sourceMtime = await findNewestSourceMtime(srcDir, deps);
111
115
  if (!sourceMtime.ok) {
112
- return {
116
+ return planeCheckFinding({
113
117
  check: "executor-build",
114
118
  severity: "blocking",
115
119
  message: `could not read mcp_server/src to compare against the build ` +
116
120
  `(${sourceMtime.error}) — run \`${PLANE_BUILD_REMEDIATION}\``,
117
- };
121
+ remediation: PLANE_BUILD_FIX_REMEDIATION,
122
+ }, label,
123
+ // `sourceMtime.error` is an errno code, already sanitized by `sanitize()`.
124
+ `the executor sources could not be read for comparison (${sourceMtime.error})`);
118
125
  }
119
126
  // Equality passes: a build written in the same millisecond as its newest
120
127
  // source is current, and treating the boundary as stale would refuse a
121
128
  // perfectly good build on a coarse-resolution filesystem.
122
129
  if (sourceMtime.mtimeMs !== null && sourceMtime.mtimeMs > buildMtime.mtimeMs) {
123
- return {
130
+ return planeCheckFinding({
124
131
  check: "executor-build",
125
132
  severity: "blocking",
126
133
  message: "mcp_server/build/ is STALE — a source file under mcp_server/src is newer " +
127
134
  `than mcp_server/build/index.js, so executors would run old code. ` +
128
135
  `Run \`${PLANE_BUILD_REMEDIATION}\``,
129
- };
136
+ remediation: PLANE_BUILD_FIX_REMEDIATION,
137
+ }, label, "mcp_server/build/ is stale — a source file is newer than the build entrypoint");
130
138
  }
131
- return null;
139
+ return planeCheckPassed("executor-build", label, "the executor build is present and current");
132
140
  }
133
141
  /**
134
142
  * Fixed refusal prose for an unresolvable runtime re-exec target (BAPI-768).
@@ -153,13 +161,16 @@ export const PLANE_RUNTIME_ENTRYPOINT_REFUSAL = "the currently executing MCP bui
153
161
  * degraded plane, it is no plane at all.
154
162
  */
155
163
  export function checkPlaneRuntimeEntrypoint(resolution) {
156
- if (resolution.ok)
157
- return null;
158
- return {
164
+ const label = "Runtime re-exec entrypoint";
165
+ if (resolution.ok) {
166
+ return planeCheckPassed("runtime-entrypoint", label, "the executing build can re-enter itself");
167
+ }
168
+ return planeCheckFinding({
159
169
  check: "runtime-entrypoint",
160
170
  severity: "blocking",
161
171
  message: PLANE_RUNTIME_ENTRYPOINT_REFUSAL,
162
- };
172
+ remediation: PLANE_BUILD_FIX_REMEDIATION,
173
+ }, label, "the currently executing MCP build could not be re-entered");
163
174
  }
164
175
  function sanitize(err) {
165
176
  const code = err?.code;
@@ -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 one executor per lane.",
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 is NOT part of the plane. It is started and stopped",
155
- " by you, so its failure domain stays independent of the supervisor's.",
156
- ` Start it separately: ${PLANE_OBSERVER_COMMAND}`,
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
- /** The observer reminder printed at the end of a successful startup banner. */
162
- export function getPlaneObserverReminder() {
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 (independent not started, listed, or stopped by the plane):",
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 runPlanePreflight(repoRoot, buildPreflightDeps(env));
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 this is the
370
- // pre-existing generic spawn-failure path, preserved verbatim in substance.
371
- return [`plane up FAILED: ${launch.error}`];
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}`;
@@ -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;