@bridge_gpt/mcp-server 0.2.60 → 0.2.65

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 (89) hide show
  1. package/README.md +58 -11
  2. package/build/agent-launchers/claude-executor-adapter.js +134 -3
  3. package/build/agent-launchers/executor-adapter.js +54 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/commands.generated.js +8 -7
  7. package/build/conductor/bridge-api-client.js +678 -14
  8. package/build/conductor/bring-up-facts.js +3 -1
  9. package/build/conductor/doctor.js +23 -14
  10. package/build/conductor/epic-child-continuation.js +324 -0
  11. package/build/conductor/epic-implementer-cli.js +142 -17
  12. package/build/conductor/git-hooks.js +529 -118
  13. package/build/conductor/hook-runtime-qualification.js +340 -0
  14. package/build/conductor/merge-execution-contract.js +30 -5
  15. package/build/conductor/merge-execution-eligibility.js +10 -9
  16. package/build/conductor/readiness-sections.js +104 -20
  17. package/build/conductor/readiness.js +129 -25
  18. package/build/conductor/recovery-cli.js +354 -13
  19. package/build/conductor/recovery-operations.js +251 -5
  20. package/build/conductor/repair-contract.js +8 -0
  21. package/build/conductor-bundle-artifacts.js +1 -0
  22. package/build/credential-store.js +194 -0
  23. package/build/docs.generated.js +2 -1
  24. package/build/doctor.js +161 -7
  25. package/build/drive-epic-key.js +12 -0
  26. package/build/drive-epic.js +1318 -111
  27. package/build/epic-coherence-input-cli.js +137 -0
  28. package/build/epic-coherence-input.js +920 -0
  29. package/build/epic-coherence-input.test-fixtures.js +175 -0
  30. package/build/epic-implementer-bin.js +35 -29
  31. package/build/epic-implementer-bundle-cli.js +14 -1
  32. package/build/epic-integration-pr.js +61 -15
  33. package/build/epic-launch-profile.js +464 -0
  34. package/build/epic-run-status.js +5 -0
  35. package/build/epic-shared/bridge-client.js +46 -4
  36. package/build/executor/cli.js +48 -0
  37. package/build/executor/completion-contract.js +423 -0
  38. package/build/executor/completion-supervision.js +286 -0
  39. package/build/executor/credentials.js +19 -1
  40. package/build/executor/deps.js +65 -1
  41. package/build/executor/env.js +6 -0
  42. package/build/executor/heartbeat.js +13 -3
  43. package/build/executor/http-client.js +117 -1
  44. package/build/executor/integration-job.js +759 -0
  45. package/build/executor/job-errors.js +48 -0
  46. package/build/executor/job-runner.js +1386 -35
  47. package/build/executor/job-types.js +10 -2
  48. package/build/executor/observation.js +114 -7
  49. package/build/executor/preflight.js +99 -0
  50. package/build/executor/process-containment.js +756 -0
  51. package/build/executor/process.js +235 -5
  52. package/build/executor/prompt-spec.js +262 -1
  53. package/build/executor/provider-checks.js +69 -0
  54. package/build/executor/rebase-finalization.js +351 -0
  55. package/build/executor/reconcile-overlap-job.js +77 -84
  56. package/build/executor/required-verification.js +313 -0
  57. package/build/executor/required-work-ledger.js +1121 -0
  58. package/build/executor/results.js +142 -0
  59. package/build/executor/resume-pre-spawn.js +87 -4
  60. package/build/executor/runner.js +246 -1
  61. package/build/executor/test-declaration.js +20 -0
  62. package/build/executor/types.js +55 -0
  63. package/build/executor/worker-command.js +49 -13
  64. package/build/executor/worker-finalization.js +88 -11
  65. package/build/executor/worker-guard-hook.js +29 -4
  66. package/build/executor/worktree.js +13 -0
  67. package/build/executor-launch.js +658 -0
  68. package/build/index.js +772 -954
  69. package/build/install-bridge-conductor.js +622 -40
  70. package/build/install-bridge.js +92 -16
  71. package/build/merge-pull-request.js +1 -1
  72. package/build/merge-workflow-config.js +54 -0
  73. package/build/merge-workflow-drift.js +35 -4
  74. package/build/merge-workflow.js +386 -2
  75. package/build/pipelines.generated.js +9 -8
  76. package/build/plan-discovery.js +137 -0
  77. package/build/plan-epic-implementer-eligibility.js +2 -2
  78. package/build/plane/cli.js +37 -3
  79. package/build/plane/member-logs.js +3 -0
  80. package/build/plane/member-roster.js +7 -1
  81. package/build/plane/shutdown.js +59 -1
  82. package/build/plane/supervisor.js +148 -0
  83. package/build/readme.generated.js +1 -1
  84. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  85. package/build/setup-epic.js +130 -48
  86. package/build/version.generated.js +3 -3
  87. package/build/worker-guard-hook-bin.js +2 -2
  88. package/docs/CONDUCTOR.md +116 -2
  89. package/package.json +2 -1
@@ -0,0 +1,137 @@
1
+ /**
2
+ * BAPI-1285: find an epic's plan DAG from the epic key alone.
3
+ *
4
+ * Identity comes from ONE place: `{docs_dir}/epic-plans/<dir>/epic-plan.meta.json`,
5
+ * a file holding `{"epic_key": "<KEY>"}` that `emit-epic-implementer-bundle
6
+ * finalize` writes beside the DAG. Discovery never guesses. It does not match a
7
+ * folder slug, a child ticket key, `sibling-ticket-manifest.json`, or anything
8
+ * inside the DAG — every one of those is a way to hand the wrong plan to a run
9
+ * that then creates real tickets' worth of work.
10
+ *
11
+ * Every filesystem read goes through the injected {@link PlanDiscoveryFs}, so a
12
+ * unit test reaches no real disk. Nothing here throws for a filesystem failure:
13
+ * an unreadable or malformed entry is named in `skipped` with a fixed reason and
14
+ * never with raw error text, which can carry paths or secrets.
15
+ */
16
+ import path from "node:path";
17
+ import { EPIC_KEY_PATTERN } from "./drive-epic-key.js";
18
+ /** Why a metadata file was named but not counted. Fixed vocabulary, never raw text. */
19
+ export const PLAN_DISCOVERY_SKIP_REASONS = [
20
+ "unreadable",
21
+ "malformed-json",
22
+ "not-an-object",
23
+ "missing-or-non-string-epic-key",
24
+ "missing-dag",
25
+ "unlistable-directory",
26
+ ];
27
+ const META_FILE = "epic-plan.meta.json";
28
+ const DAG_FILE = "epic-plan.dag.json";
29
+ function isEnoent(err) {
30
+ return (typeof err === "object" && err !== null && err.code === "ENOENT");
31
+ }
32
+ function byPath(a, b) {
33
+ return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
34
+ }
35
+ /**
36
+ * Discover the one plan DAG whose metadata names `epicKey`.
37
+ *
38
+ * - An invalid requested key returns `plan_not_found` before any filesystem call.
39
+ * - A missing `epic-plans/` directory is `plan_not_found`, not an error.
40
+ * - Exactly one match with a sibling DAG is `found`; two or more is
41
+ * `plan_ambiguous` with every candidate, sorted; zero is `plan_not_found`.
42
+ */
43
+ export async function discoverEpicPlan(epicKey, docsDir, fs) {
44
+ const epicPlansDir = path.resolve(docsDir, "epic-plans");
45
+ const notFound = (skipped = []) => ({
46
+ status: "plan_not_found",
47
+ epic_plans_dir: epicPlansDir,
48
+ skipped: [...skipped].sort(byPath),
49
+ });
50
+ if (!EPIC_KEY_PATTERN.test(epicKey))
51
+ return notFound();
52
+ const skipped = [];
53
+ const candidates = [];
54
+ let planDirs;
55
+ try {
56
+ planDirs = await fs.readdir(epicPlansDir);
57
+ }
58
+ catch (err) {
59
+ if (isEnoent(err))
60
+ return notFound();
61
+ return notFound([{ path: epicPlansDir, reason: "unlistable-directory" }]);
62
+ }
63
+ for (const planDir of planDirs) {
64
+ // Real directories only. A symlink could point outside the docs directory,
65
+ // and a file is not a plan folder.
66
+ if (planDir.isSymbolicLink?.() === true || !planDir.isDirectory())
67
+ continue;
68
+ const dirPath = path.join(epicPlansDir, planDir.name);
69
+ let files;
70
+ try {
71
+ files = await fs.readdir(dirPath);
72
+ }
73
+ catch (err) {
74
+ if (!isEnoent(err))
75
+ skipped.push({ path: dirPath, reason: "unlistable-directory" });
76
+ continue;
77
+ }
78
+ const isRegular = (entry) => entry.isFile() && entry.isSymbolicLink?.() !== true;
79
+ const meta = files.find((entry) => entry.name === META_FILE && isRegular(entry));
80
+ if (meta === undefined)
81
+ continue;
82
+ const metaPath = path.join(dirPath, META_FILE);
83
+ let raw;
84
+ try {
85
+ raw = await fs.readFile(metaPath);
86
+ }
87
+ catch {
88
+ skipped.push({ path: metaPath, reason: "unreadable" });
89
+ continue;
90
+ }
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(raw);
94
+ }
95
+ catch {
96
+ skipped.push({ path: metaPath, reason: "malformed-json" });
97
+ continue;
98
+ }
99
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
100
+ skipped.push({ path: metaPath, reason: "not-an-object" });
101
+ continue;
102
+ }
103
+ const key = parsed.epic_key;
104
+ if (typeof key !== "string") {
105
+ skipped.push({ path: metaPath, reason: "missing-or-non-string-epic-key" });
106
+ continue;
107
+ }
108
+ // Exact comparison, and both sides must satisfy the shared pattern. Metadata
109
+ // naming a key the CLI would reject is never a match.
110
+ if (!EPIC_KEY_PATTERN.test(key) || key !== epicKey)
111
+ continue;
112
+ const dag = files.find((entry) => entry.name === DAG_FILE && isRegular(entry));
113
+ if (dag === undefined) {
114
+ skipped.push({ path: metaPath, reason: "missing-dag" });
115
+ continue;
116
+ }
117
+ candidates.push(path.join(dirPath, DAG_FILE));
118
+ }
119
+ candidates.sort();
120
+ if (candidates.length === 1) {
121
+ return {
122
+ status: "found",
123
+ path: candidates[0],
124
+ epic_plans_dir: epicPlansDir,
125
+ skipped: [...skipped].sort(byPath),
126
+ };
127
+ }
128
+ if (candidates.length >= 2) {
129
+ return {
130
+ status: "plan_ambiguous",
131
+ candidates,
132
+ epic_plans_dir: epicPlansDir,
133
+ skipped: [...skipped].sort(byPath),
134
+ };
135
+ }
136
+ return notFound(skipped);
137
+ }
@@ -18,7 +18,7 @@
18
18
  *
19
19
  * Fails open, deliberately: any validation failure, classification failure, or
20
20
  * internal contradiction (a review-subset child the backend did not also mark
21
- * merge-blocked) becomes the explicit `unavailable` result — never a fabricated
21
+ * workflow-modifying) becomes the explicit `unavailable` result — never a fabricated
22
22
  * zero count. A caller that cannot get a real answer must say so, not guess.
23
23
  */
24
24
  import { CLAUDE_REVIEW_WORKFLOW_RELPATH } from "./claude-review-workflow-drift.js";
@@ -172,7 +172,7 @@ export async function assessEpicImplementerEligibility(children, deps) {
172
172
  const reviewPathReferenced = child.combinedText.includes(CLAUDE_REVIEW_WORKFLOW_RELPATH);
173
173
  if (reviewPathReferenced && !prediction.predicted_workflow_modified) {
174
174
  // Subset invariant violated: a child naming claude-review.yml itself must
175
- // also be in the broader merge-blocked set. Publishing this as a
175
+ // also be in the broader workflow-modifying set. Publishing this as a
176
176
  // "review-only" child would contradict the merge count, so refuse instead.
177
177
  console.error(`assessEpicImplementerEligibility: child ${child.id} references the review workflow ` +
178
178
  "but the backend did not mark it merge-blocked; refusing contradictory result.");
@@ -24,6 +24,7 @@ import { shutdownPlane, formatPlaneShutdown } from "./shutdown.js";
24
24
  import { launchPlaneSupervisor, runPlaneRuntime, PLANE_RUNTIME_TWO_PHASE_FLAG, } from "./supervisor.js";
25
25
  import { createMemberLogDeps, createPlaneClock, createPlaneExecFile, createPlaneFsDeps, createPlaneProcessDeps, createPlaneSpawn, probeAutomationHealth, probeTcpPort, registerSignalHandler, resolveHomedir, resolveHostname, resolveNodeExecutable, resolvePackageEntrypoint, } from "./defaults.js";
26
26
  import { resolveBapiCredentials } from "../credential-store.js";
27
+ import { listRunExecutorRecords, verifyRunExecutorProcess } from "../executor-launch.js";
27
28
  import { resolveRequiredStartTicketsRepoName } from "../start-tickets-repo.js";
28
29
  /** Upper bound on executor lanes. Guards a typo from forking a fleet. */
29
30
  export const PLANE_MAX_EXECUTORS = 32;
@@ -386,16 +387,48 @@ async function runDownAction(sinks, overrides) {
386
387
  (injected.ok ? sinks.stdout : sinks.stderr)(injected.text);
387
388
  return injected.ok ? 0 : 1;
388
389
  }
390
+ const fs = createPlaneFsDeps();
391
+ const proc = createPlaneProcessDeps();
392
+ const execFile = createPlaneExecFile();
393
+ const endpoint = resolvePlaneServerEndpoint(process.env);
389
394
  const result = await shutdownPlane(repoRoot, {
390
- fs: createPlaneFsDeps(),
391
- proc: createPlaneProcessDeps(),
395
+ fs,
396
+ proc,
392
397
  clock: createPlaneClock(),
393
398
  stopBoundRun: stopBoundEpicRun,
399
+ // BAPI-1279 — stop the run-scoped executors drive-epic started here that
400
+ // target THIS plane's endpoint (the same rule `plane up` binds by).
401
+ runExecutors: {
402
+ list: () => listRunExecutorRecords(repoRoot, fs),
403
+ targetsThisPlane: (baseUrl) => endpoint.ok && urlNamesLoopbackPort(baseUrl, endpoint.endpoint.port),
404
+ verify: (record) => verifyRunExecutorProcess(record, { isAlive: proc.isAlive, execFile }, repoRoot),
405
+ },
394
406
  });
395
407
  const text = formatPlaneShutdown(result);
396
408
  (result.ok ? sinks.stdout : sinks.stderr)(text);
397
409
  return result.ok ? 0 : 1;
398
410
  }
411
+ /** Loopback spellings that reach the plane's `127.0.0.1` bind. */
412
+ const PLANE_LOOPBACK_HOSTNAMES = new Set([
413
+ "127.0.0.1",
414
+ "localhost",
415
+ "[::1]",
416
+ "::1",
417
+ ]);
418
+ /** Does `baseUrl` name a loopback `http` endpoint on `port`? Pure. */
419
+ function urlNamesLoopbackPort(baseUrl, port) {
420
+ let url;
421
+ try {
422
+ url = new URL(baseUrl);
423
+ }
424
+ catch {
425
+ return false;
426
+ }
427
+ if (url.protocol !== "http:")
428
+ return false;
429
+ const actual = url.port === "" ? 80 : Number(url.port);
430
+ return PLANE_LOOPBACK_HOSTNAMES.has(url.hostname) && actual === port;
431
+ }
399
432
  /**
400
433
  * `plane down`'s pre-signal run-stop capability (BAPI-872).
401
434
  *
@@ -699,7 +732,8 @@ export async function launchPlaneControlPlane(args) {
699
732
  const lines = outcome.result.ok
700
733
  ? ["plane up FAILED: the runtime exited before the control plane was ready"]
701
734
  : formatPlaneLaunchFailure(outcome.result);
702
- return { ok: false, lines };
735
+ const foreignReconciler = outcome.result.ok ? undefined : outcome.result.foreignReconciler;
736
+ return { ok: false, lines, ...(foreignReconciler !== undefined ? { foreignReconciler } : {}) };
703
737
  }
704
738
  return {
705
739
  ok: true,
@@ -82,6 +82,9 @@ export const PLANE_MEMBER_STARTUP_FAILURES = {
82
82
  // socket that was never part of the contract.
83
83
  heartbeatReadinessTimeout: "it did not publish a durable heartbeat in time",
84
84
  readinessUnestablished: "its readiness baseline could not be established",
85
+ // BAPI-1279 — the pre-spawn guard found another instance's reconciler live on
86
+ // the shared database, so the worker was never started.
87
+ foreignReconcilerLive: "another reconciler is already live on this database",
85
88
  };
86
89
  const ALLOWED_STARTUP_FAILURES = new Set(Object.values(PLANE_MEMBER_STARTUP_FAILURES));
87
90
  /** Substituted for any reason not in the allow-list. Never echoes the input. */
@@ -238,7 +238,13 @@ export function buildPlaneMemberRoster(params) {
238
238
  String(endpoint.port),
239
239
  ],
240
240
  cwd,
241
- env,
241
+ // BAPI-1279 — the server member runs NO scheduler. `main.py` starts one
242
+ // whenever DISABLE_SCHEDULER is unset, and that scheduler ticks the
243
+ // reconciler: the plane would run two reconcilers of its own, and the
244
+ // pre-worker foreign-reconciler guard would see this server's heartbeat
245
+ // and refuse the plane's own worker. The `worker` member below is the one
246
+ // reconciler, exactly as a Heroku web dyno leaves scheduling to `worker:`.
247
+ env: { ...env, DISABLE_SCHEDULER: "true" },
242
248
  logPath: relativeLogPathFor("server"),
243
249
  readiness: {
244
250
  kind: "tcp",
@@ -110,6 +110,9 @@ export async function shutdownPlane(repoRoot, deps) {
110
110
  message: "no run-stop capability was configured for this wind-down",
111
111
  }))
112
112
  : { kind: "unbound" };
113
+ // BAPI-1279 — the run-scoped executors that target this plane go first, while
114
+ // its server can still take their final job reports.
115
+ messages.push(...(await stopRunScopedExecutors(deps)));
113
116
  if (!isAnythingAlive(manifest, deps.proc, deps.selfPid)) {
114
117
  const cleared = await clearPlaneManifest(repoRoot, manifest.planeId, deps.fs);
115
118
  if (!cleared.ok) {
@@ -127,7 +130,7 @@ export async function shutdownPlane(repoRoot, deps) {
127
130
  escalated: false,
128
131
  manifestCleared: cleared.removed,
129
132
  members: reportMembers(manifest, deps.proc),
130
- messages: ["Every recorded plane process was already gone; manifest cleared."],
133
+ messages: [...messages, "Every recorded plane process was already gone; manifest cleared."],
131
134
  runShutdown,
132
135
  };
133
136
  }
@@ -175,6 +178,61 @@ export async function shutdownPlane(repoRoot, deps) {
175
178
  runShutdown,
176
179
  };
177
180
  }
181
+ /**
182
+ * Signal every verified run-scoped executor that targets this plane (BAPI-1279).
183
+ *
184
+ * Returns the message lines to report. SIGTERM reaches the executor's relay,
185
+ * which forwards it, so the executor drains its job and releases its worktree
186
+ * lock exactly as on any graceful stop. Never throws.
187
+ */
188
+ async function stopRunScopedExecutors(deps) {
189
+ const runExecutors = deps.runExecutors;
190
+ if (runExecutors === undefined)
191
+ return [];
192
+ let listed;
193
+ try {
194
+ listed = await runExecutors.list();
195
+ }
196
+ catch {
197
+ return ["Run-scoped executors: the .bridge/executors/ records could not be read; none were stopped."];
198
+ }
199
+ const lines = [];
200
+ let stopped = 0;
201
+ const skipped = [];
202
+ for (const record of listed.records) {
203
+ if (!runExecutors.targetsThisPlane(record.baseUrl)) {
204
+ skipped.push(`run ${record.epicRunId} (targets ${record.baseUrl}, not this plane)`);
205
+ continue;
206
+ }
207
+ let state;
208
+ try {
209
+ state = await runExecutors.verify(record);
210
+ }
211
+ catch {
212
+ state = "unknown";
213
+ }
214
+ if (state === "dead")
215
+ continue;
216
+ if (state !== "verified") {
217
+ skipped.push(`run ${record.epicRunId} (pid ${record.pid} could not be verified as its executor)`);
218
+ continue;
219
+ }
220
+ if (deps.proc.signal(record.pid, "SIGTERM") === "sent")
221
+ stopped += 1;
222
+ else
223
+ skipped.push(`run ${record.epicRunId} (pid ${record.pid} could not be signalled)`);
224
+ }
225
+ for (const name of listed.malformed)
226
+ skipped.push(`${name} (unreadable record)`);
227
+ if (stopped > 0) {
228
+ lines.push(`Stopped ${stopped} run-scoped executor(s) targeting this plane. Their runs remain ` +
229
+ "server-side and resume when an executor and a reconciler are back.");
230
+ }
231
+ if (skipped.length > 0) {
232
+ lines.push(`Run-scoped executors left running: ${skipped.join("; ")}.`);
233
+ }
234
+ return lines;
235
+ }
178
236
  /**
179
237
  * Deliver a signal to the whole plane.
180
238
  *
@@ -263,7 +263,16 @@ export async function launchPlaneSupervisor(params, deps) {
263
263
  // member logs, and this channel carries only attributed events and banners.
264
264
  let controlReadyAnnounced = false;
265
265
  let lanesFailedAnnounced = false;
266
+ let foreignReconciler;
266
267
  const readStdout = createLineReader((line) => {
268
+ if (line.startsWith(`${PLANE_FOREIGN_RECONCILER_MARKER}:`)) {
269
+ // Private. The runtime already explained the refusal on stderr; this
270
+ // carries only the fact and whether the rollback was confirmed.
271
+ foreignReconciler = {
272
+ rolledBack: line === `${PLANE_FOREIGN_RECONCILER_MARKER}:rolled-back`,
273
+ };
274
+ return;
275
+ }
267
276
  if (line === PLANE_CONTROL_READY_MARKER) {
268
277
  // Private, exactly like the ready marker: forwarding it would put an
269
278
  // internal token in the operator's terminal.
@@ -359,6 +368,7 @@ export async function launchPlaneSupervisor(params, deps) {
359
368
  return {
360
369
  ok: false,
361
370
  error,
371
+ ...(foreignReconciler !== undefined ? { foreignReconciler } : {}),
362
372
  startup: {
363
373
  exitCode: outcome.code,
364
374
  signal: outcome.signal,
@@ -405,6 +415,110 @@ export const PLANE_CONTROL_READY_MARKER = "__bapi_plane_control_ready__";
405
415
  * alive, serving a committed run, and never going to start a lane.
406
416
  */
407
417
  export const PLANE_LANES_FAILED_MARKER = "__bapi_plane_lanes_failed__";
418
+ /**
419
+ * The pre-worker guard found another reconciler LIVE on the shared database
420
+ * (BAPI-1279), and the plane refused before spawning its own.
421
+ *
422
+ * Private, like the other markers, and followed by `:rolled-back` or
423
+ * `:rollback-incomplete` so the launcher can say whether nothing was left
424
+ * running — a claim it may make only after a CONFIRMED rollback.
425
+ */
426
+ export const PLANE_FOREIGN_RECONCILER_MARKER = "__bapi_plane_foreign_reconciler__";
427
+ /**
428
+ * How often a live reconciler writes its heartbeat: `tick()` runs on
429
+ * `IntervalTrigger(seconds=30)` in `api/library/scheduled_jobs.py` and writes
430
+ * the heartbeat first. A second read one interval (plus slack) after the first
431
+ * therefore sees a live reconciler's row advance.
432
+ */
433
+ export const PLANE_RECONCILER_HEARTBEAT_INTERVAL_MS = 30_000;
434
+ /** Slack added to the interval so a tick that runs a little late still lands. */
435
+ export const PLANE_RECONCILER_HEARTBEAT_SLACK_MS = 5_000;
436
+ /**
437
+ * The bound on waiting for a fresh-but-still row to go stale. The server's
438
+ * stale window (`RECONCILER_STALE_SECONDS`) is 120 seconds; 150 covers it from
439
+ * the first read with room for the read cadence.
440
+ */
441
+ export const PLANE_FOREIGN_RECONCILER_WAIT_MS = 150_000;
442
+ /** Poll interval while waiting for a stopped reconciler's row to go stale. */
443
+ export const PLANE_FOREIGN_RECONCILER_POLL_MS = 5_000;
444
+ /**
445
+ * Is another reconciler LIVE on this database? (BAPI-1279)
446
+ *
447
+ * Runs after the server member is ready — the heartbeat lives in the database,
448
+ * and this process reads it only through a Bridge API server — and before the
449
+ * worker is spawned, so a refusal never starts a second transition authority.
450
+ *
451
+ * The read is deployment-wide (no instance id), because the question is about
452
+ * ANY reconciler. A row that is not fresh is clear. A fresh row is read again
453
+ * one heartbeat interval later: if it advanced, a reconciler is ticking and the
454
+ * plane refuses. If it did not, it belongs to a reconciler that just stopped
455
+ * (heartbeat rows are not deleted on shutdown), so the guard waits — bounded —
456
+ * for it to go stale, then clears. Any read that cannot answer is `unsafe`:
457
+ * spawning is allowed only when the reads establish that it is safe.
458
+ */
459
+ export async function guardForeignReconciler(input) {
460
+ const { deps } = input;
461
+ const intervalMs = input.intervalMs ?? PLANE_RECONCILER_HEARTBEAT_INTERVAL_MS + PLANE_RECONCILER_HEARTBEAT_SLACK_MS;
462
+ const budgetMs = input.waitBudgetMs ?? PLANE_FOREIGN_RECONCILER_WAIT_MS;
463
+ const pollMs = input.pollMs ?? PLANE_FOREIGN_RECONCILER_POLL_MS;
464
+ const startedAt = deps.clock.now().getTime();
465
+ const elapsed = () => deps.clock.now().getTime() - startedAt;
466
+ // One read, retrying only a route that is still warming.
467
+ const read = async () => {
468
+ for (;;) {
469
+ const observation = await deps.probeHealth({
470
+ baseUrl: input.baseUrl,
471
+ repoName: input.repoName,
472
+ apiKey: input.apiKey,
473
+ component: "reconciler",
474
+ });
475
+ const result = observation.result;
476
+ if (result.kind === "state") {
477
+ return { kind: "row", state: result.state, observedAtMs: observation.observedAtMs };
478
+ }
479
+ if (result.kind !== "unavailable" || elapsed() >= budgetMs) {
480
+ return { kind: "unsafe", reason: `the reconciler heartbeat could not be read (${result.kind})` };
481
+ }
482
+ await deps.clock.sleep(pollMs);
483
+ }
484
+ };
485
+ const first = await read();
486
+ if (first.kind === "unsafe")
487
+ return first;
488
+ if (first.state === "unknown") {
489
+ return { kind: "unsafe", reason: "the server could not read the reconciler heartbeat" };
490
+ }
491
+ if (first.state !== "fresh")
492
+ return { kind: "clear" };
493
+ const firstSeen = first.observedAtMs;
494
+ if (firstSeen === null) {
495
+ return { kind: "unsafe", reason: "a fresh reconciler heartbeat carried no timestamp" };
496
+ }
497
+ await deps.clock.sleep(intervalMs);
498
+ for (;;) {
499
+ const next = await read();
500
+ if (next.kind === "unsafe")
501
+ return next;
502
+ if (next.state === "unknown") {
503
+ return { kind: "unsafe", reason: "the server could not read the reconciler heartbeat" };
504
+ }
505
+ if (next.observedAtMs !== null && next.observedAtMs > firstSeen)
506
+ return { kind: "foreign-live" };
507
+ if (next.state !== "fresh")
508
+ return { kind: "clear" };
509
+ if (elapsed() >= budgetMs) {
510
+ return {
511
+ kind: "unsafe",
512
+ reason: `a recent reconciler heartbeat did not go stale within ${Math.round(budgetMs / 1000)}s`,
513
+ };
514
+ }
515
+ await deps.clock.sleep(pollMs);
516
+ }
517
+ }
518
+ /** Operator-facing reason for the foreign-reconciler refusal. Fixed prose. */
519
+ export const PLANE_FOREIGN_RECONCILER_REASON = "another reconciler is already ticking this database (its heartbeat advanced), so this " +
520
+ "plane's worker was NOT started — two reconcilers on one database are two transition " +
521
+ "authorities";
408
522
  /**
409
523
  * Private argv flag selecting the two-phase lifecycle on `plane __runtime`.
410
524
  *
@@ -448,6 +562,10 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
448
562
  updatedAt: deps.clock.now().toISOString(),
449
563
  };
450
564
  const started = [];
565
+ // BAPI-1279 — whether the most recent rollback was CONFIRMED complete. Read
566
+ // only by the foreign-reconciler refusal, which may say nothing was left
567
+ // running only when this is true.
568
+ let lastRollbackConfirmed = true;
451
569
  /**
452
570
  * Has {@link PLANE_CONTROL_READY_MARKER} been announced? (BAPI-1102)
453
571
  *
@@ -695,6 +813,29 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
695
813
  }
696
814
  baseline = outcome.baseline;
697
815
  baselineElapsedMs = outcome.elapsedMs;
816
+ // BAPI-1279 — never start a second reconciler on a shared database. The
817
+ // server member is up by now (it is first in the roster), so the check
818
+ // can read the heartbeat; the worker is not spawned until it clears.
819
+ // `plane up` and drive-epic's cold start both reach this, so both get it.
820
+ if (spec.name === "worker" && readiness.component === "reconciler") {
821
+ const guard = await guardForeignReconciler({
822
+ deps,
823
+ baseUrl: readiness.baseUrl,
824
+ repoName: readiness.repoName,
825
+ apiKey: spec.env.BAPI_API_KEY ?? "",
826
+ });
827
+ if (guard.kind === "foreign-live") {
828
+ const failed = await finishPartialStart(spec.name, PLANE_FOREIGN_RECONCILER_REASON, {
829
+ log: opened.stream,
830
+ failure: PLANE_MEMBER_STARTUP_FAILURES.foreignReconcilerLive,
831
+ });
832
+ deps.sinks.stdout(`${PLANE_FOREIGN_RECONCILER_MARKER}:${lastRollbackConfirmed ? "rolled-back" : "rollback-incomplete"}`);
833
+ return failed;
834
+ }
835
+ if (guard.kind === "unsafe") {
836
+ return finishPartialStart(spec.name, `${guard.reason}, so it could not be confirmed that no other reconciler is live`, { log: opened.stream, failure: PLANE_MEMBER_STARTUP_FAILURES.readinessUnestablished });
837
+ }
838
+ }
698
839
  }
699
840
  let child;
700
841
  try {
@@ -1015,6 +1156,7 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
1015
1156
  clock: deps.clock,
1016
1157
  selfPid: deps.selfPid,
1017
1158
  });
1159
+ lastRollbackConfirmed = result.ok;
1018
1160
  if (!result.ok)
1019
1161
  deps.sinks.stderr(`Rollback incomplete: ${result.message}`);
1020
1162
  }
@@ -1172,6 +1314,12 @@ function describeBaselineFailure(reason) {
1172
1314
  * sitting out the remaining budget. A worker that crashed on its first tick is
1173
1315
  * the case this readiness gate exists for, and reporting it two minutes late as
1174
1316
  * a timeout would describe the symptom rather than the cause.
1317
+ *
1318
+ * BAPI-1260 — what this does NOT prove. A member that is ready here is a
1319
+ * PROCESS fact about this plane's own identified member. It is not an enrolled
1320
+ * actor handshake: whether an actor can consume a privileged operation is the
1321
+ * server's `durable_actor` admission evidence, and `drive-epic` keeps the two
1322
+ * separate — process readiness admits scoped lanes, never dependent dispatch.
1175
1323
  */
1176
1324
  export async function waitForMemberReady(spec, deps, pollIntervalMs = 250, isAlive = () => true, heartbeat = {}) {
1177
1325
  if (!spec.readiness)