@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
@@ -101,6 +101,44 @@ export function createLineReader(onLine) {
101
101
  }
102
102
  };
103
103
  }
104
+ /**
105
+ * Rebuild the instance identity a spawned member publishes under.
106
+ *
107
+ * Mirrors `_resolve_process_instance_id` in
108
+ * `api/library/epic_conductor/reconciler.py` exactly, because the string has to
109
+ * MATCH: `DYNO` when that variable is set and non-blank after trimming,
110
+ * otherwise `<hostname>:<pid>`, truncated to 128 characters. Python resolves it
111
+ * once at import from the process's own environment and pid; this rebuilds it
112
+ * from the environment the supervisor handed that same process and the pid
113
+ * `spawn` returned.
114
+ *
115
+ * Returns `undefined` — meaning "do not scope, use the unscoped verdict" —
116
+ * rather than guessing, when no hostname capability was injected. A wrong
117
+ * identity would be worse than no scoping: it would report `never_seen` forever
118
+ * and time out a perfectly healthy member.
119
+ *
120
+ * Deliberately NOT called for a member whose spec already carries an
121
+ * `instanceId`: an executor lane's identity is minted by the plane itself and is
122
+ * known before the spawn.
123
+ */
124
+ export function resolveMemberInstanceId(env, childPid, hostname) {
125
+ const dyno = (env.DYNO ?? "").trim();
126
+ if (dyno)
127
+ return dyno.slice(0, 128);
128
+ if (!hostname)
129
+ return undefined;
130
+ let host;
131
+ try {
132
+ host = hostname();
133
+ }
134
+ catch {
135
+ // Python falls back to the literal "unknown" when `gethostname` raises, and
136
+ // an identity that merely MATCHES a real writer is the whole point — so this
137
+ // reproduces that fallback rather than abandoning scoping.
138
+ host = "unknown";
139
+ }
140
+ return `${host}:${childPid}`.slice(0, 128);
141
+ }
104
142
  /** Render one attributed event line. */
105
143
  export function formatMemberEvent(event) {
106
144
  const detail = event.detail ? ` ${event.detail}` : "";
@@ -415,6 +453,14 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
415
453
  members: manifest.members.map((m) => (m.name === name ? { ...m, ...patch } : m)),
416
454
  };
417
455
  };
456
+ /**
457
+ * Has this member's `close` handler already fired?
458
+ *
459
+ * Read off the manifest projection the handler patches, rather than a second
460
+ * parallel set: one record of member lifecycle means a readiness wait and
461
+ * `plane status` can never disagree about whether a child is still alive.
462
+ */
463
+ const memberExited = (name) => manifest.members.some((m) => m.name === name && m.state === "exited");
418
464
  let shuttingDown = false;
419
465
  const requestShutdown = async (signal) => {
420
466
  if (shuttingDown)
@@ -442,6 +488,45 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
442
488
  if (!opened.ok) {
443
489
  return finishPartialStart(spec.name, `log could not be opened (${opened.error})`);
444
490
  }
491
+ // Probed BEFORE the spawn, for every heartbeat-gated member (BAPI-1036;
492
+ // BAPI-1029 took one such probe, for the reconciler only, and used it merely
493
+ // to disclose an ambiguity). The baseline is what turns "a fresh heartbeat
494
+ // exists" into "a heartbeat landed after we started this process" — see
495
+ // `PlaneHeartbeatBaseline`.
496
+ //
497
+ // Taken AFTER the member's log is opened, so a terminal baseline failure is
498
+ // annotated in the log an operator will actually open for that member —
499
+ // still before the spawn, which is the property that matters.
500
+ //
501
+ // An executor lane is scoped here by the identity the roster already minted;
502
+ // the reconciler's local identity needs a pid, so its pre-spawn baseline is
503
+ // the UNSCOPED observation, which is a sound lower bound: the unscoped read
504
+ // returns the newest row across the deployment, so any row this member later
505
+ // publishes must be at least as new. A `DYNO` deployment scopes exactly,
506
+ // because that identity needs no pid.
507
+ let baseline = { observedAtMs: null };
508
+ let baselineElapsedMs = 0;
509
+ if (spec.readiness?.kind === "process-heartbeat") {
510
+ const readiness = spec.readiness;
511
+ const baselineInstanceId = readiness.instanceId ?? resolveMemberInstanceId(spec.env, 0, undefined);
512
+ const outcome = await probeHeartbeatBaseline({
513
+ deps,
514
+ spec,
515
+ readiness,
516
+ instanceId: baselineInstanceId,
517
+ });
518
+ if (outcome.kind === "failed") {
519
+ // Terminal before the spawn: a rejected credential or a moved contract
520
+ // cannot be waited out, and starting the member first would leave a live
521
+ // process to roll back for no reason.
522
+ return finishPartialStart(spec.name, describeBaselineFailure(outcome.reason), {
523
+ log: opened.stream,
524
+ failure: PLANE_MEMBER_STARTUP_FAILURES.readinessUnestablished,
525
+ });
526
+ }
527
+ baseline = outcome.baseline;
528
+ baselineElapsedMs = outcome.elapsedMs;
529
+ }
445
530
  let child;
446
531
  try {
447
532
  child = deps.spawn(spec.command, spec.args, {
@@ -508,9 +593,49 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
508
593
  /* surfaced through the close handler's exit status */
509
594
  }));
510
595
  if (spec.readiness) {
511
- const ready = await waitForMemberReady(spec, deps);
512
- if (!ready) {
513
- return finishPartialStart(spec.name, `it did not start listening on ${spec.readiness.host}:${spec.readiness.port} in time`, { log: opened.stream, failure: PLANE_MEMBER_STARTUP_FAILURES.readinessTimeout });
596
+ let resolvedInstanceId;
597
+ if (spec.readiness.kind === "process-heartbeat") {
598
+ // Resolved HERE, not on the roster: an executor lane's identity is
599
+ // minted before the spawn, but the local reconciler's is
600
+ // `<hostname>:<pid>` and the pid only exists now. The window between
601
+ // `spawn` returning and this probe is milliseconds, and the child cannot
602
+ // publish inside it — it has to open its database pools and start its
603
+ // scheduler first — but it is a window, and it is documented rather than
604
+ // asserted away. Supplying the identity TO the child would be race-free
605
+ // and would change a durable heartbeat writer, which this work forbids.
606
+ resolvedInstanceId =
607
+ spec.readiness.instanceId ??
608
+ resolveMemberInstanceId(spec.env, child.pid, deps.hostname);
609
+ // Announced BEFORE the wait, not after it. This is the slowest step in
610
+ // bring-up by an order of magnitude, and an operator watching a silent
611
+ // terminal for two minutes has no way to tell waiting from wedged. The
612
+ // wording is the status itself — no colour, no spinner, no symbol — so
613
+ // it survives a pipe, a CI log, and a screen reader identically. The
614
+ // resolved identity is NOT rendered: it is scoping, not news.
615
+ emitMemberEvent(deps.sinks, {
616
+ member: spec.name,
617
+ kind: "start",
618
+ timestamp: deps.clock.now().toISOString(),
619
+ logPath: spec.logPath,
620
+ detail: `Starting — awaiting this member's first post-start ${spec.readiness.component} ` +
621
+ `heartbeat (expected within ${Math.round(spec.readiness.timeoutMs / 1000)}s; watch ${spec.logPath})`,
622
+ });
623
+ }
624
+ // `memberExited` is read through the closure the `close` handler patches,
625
+ // so a child that dies mid-wait ends the wait immediately instead of
626
+ // burning the remaining budget and reporting a timeout it did not have.
627
+ const outcome = await waitForMemberReady(spec, deps, 250, () => !memberExited(spec.name), {
628
+ baseline,
629
+ instanceId: resolvedInstanceId,
630
+ elapsedMs: baselineElapsedMs,
631
+ });
632
+ if (outcome.kind !== "ready") {
633
+ return finishPartialStart(spec.name, describeReadinessFailure(spec, outcome), {
634
+ log: opened.stream,
635
+ failure: spec.readiness.kind === "process-heartbeat"
636
+ ? PLANE_MEMBER_STARTUP_FAILURES.heartbeatReadinessTimeout
637
+ : PLANE_MEMBER_STARTUP_FAILURES.readinessTimeout,
638
+ });
514
639
  }
515
640
  }
516
641
  patchMember(spec.name, { state: "ready" });
@@ -584,23 +709,227 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
584
709
  }
585
710
  }
586
711
  /**
587
- * Readiness for a member that listens: poll its port until it accepts a
588
- * connection or the budget runs out. Members without a listening socket are
589
- * ready as soon as they are spawned and still alive.
712
+ * The pre-spawn baseline for one heartbeat-gated member, or a terminal reason.
713
+ *
714
+ * Bounded by the member's OWN readiness budget and it reports how much of that
715
+ * budget it consumed, because the baseline and the wait are two halves of one
716
+ * gate: giving each a full timeout would let a member take twice its documented
717
+ * budget to fail.
590
718
  */
591
- export async function waitForMemberReady(spec, deps, pollIntervalMs = 250) {
592
- if (!spec.readiness)
593
- return true;
594
- const { host, port, timeoutMs } = spec.readiness;
719
+ async function probeHeartbeatBaseline(input) {
720
+ const { deps, spec, readiness, instanceId } = input;
721
+ const pollIntervalMs = input.pollIntervalMs ?? 250;
722
+ // Read from the member's OWN environment at call time, exactly as the wait
723
+ // does. The key is never copied onto the readiness spec or into a local that
724
+ // outlives this call.
725
+ const apiKey = spec.env.BAPI_API_KEY ?? "";
595
726
  let waited = 0;
727
+ while (waited < readiness.timeoutMs) {
728
+ const observation = await deps.probeHealth({
729
+ baseUrl: readiness.baseUrl,
730
+ repoName: readiness.repoName,
731
+ apiKey,
732
+ component: readiness.component,
733
+ instanceId,
734
+ });
735
+ const result = observation.result;
736
+ if (result.kind === "state") {
737
+ // Every state is a usable baseline. `never_seen` and `unknown` carry no
738
+ // observation and mean "nothing to beat", which the first timestamped
739
+ // reading advances past; `fresh` and `stale` carry the time to beat.
740
+ return {
741
+ kind: "baseline",
742
+ baseline: { observedAtMs: observation.observedAtMs },
743
+ elapsedMs: waited,
744
+ };
745
+ }
746
+ if (result.kind === "unauthorized" || result.kind === "malformed") {
747
+ // Terminal by construction, and terminal BEFORE the spawn: waiting cannot
748
+ // fix a rejected credential or a contract this code does not understand,
749
+ // and there is no point starting a process only to roll it back.
750
+ return { kind: "failed", reason: result.kind };
751
+ }
752
+ // `unavailable` only: the server member is up but its route may still be
753
+ // warming, which is ordinary on a cold start and worth retrying.
754
+ await deps.clock.sleep(pollIntervalMs);
755
+ waited += pollIntervalMs;
756
+ }
757
+ // The budget ran out with the route never answering. An EMPTY baseline is the
758
+ // safe reading: it can only make the gate stricter, since every timestamped
759
+ // observation advances past it.
760
+ return { kind: "baseline", baseline: { observedAtMs: null }, elapsedMs: waited };
761
+ }
762
+ /** Operator-facing reason a baseline could not be established. Fixed prose. */
763
+ function describeBaselineFailure(reason) {
764
+ return reason === "unauthorized"
765
+ ? "the Bridge API rejected the plane's credential during the automation " +
766
+ "health check taken before this member started (401/403), so its " +
767
+ "readiness baseline could not be established"
768
+ : "the automation health check taken before this member started answered " +
769
+ "with a body this launcher does not recognize, so its readiness " +
770
+ "baseline could not be established";
771
+ }
772
+ /**
773
+ * Readiness for one member, dispatched on the readiness discriminator.
774
+ *
775
+ * A member with no readiness spec is ready as soon as it is spawned and still
776
+ * alive — the observer, and since BAPI-1036 ONLY the observer. A `tcp` member
777
+ * polls its port exactly as before. A `process-heartbeat` member waits for the
778
+ * backend to report `fresh` FOR ITS OWN IDENTITY, with an observation time
779
+ * strictly later than the baseline taken before it started.
780
+ *
781
+ * That last clause is the whole difference between this gate and a freshness
782
+ * check. The heartbeat upsert arbitrates on `(component, instance_id)` and
783
+ * preserves `started_at` on conflict, and `buildExecutorId` is deterministic
784
+ * across runs — so a previous run of the same lane leaves a row under the very
785
+ * identity this gate asks about. Accepting "fresh" alone would declare a lane
786
+ * that just crashed on boot ready, off its own predecessor's heartbeat, for as
787
+ * long as that row stayed inside the staleness window.
788
+ *
789
+ * `isAlive` lets the wait end the moment the watched child dies instead of
790
+ * sitting out the remaining budget. A worker that crashed on its first tick is
791
+ * the case this readiness gate exists for, and reporting it two minutes late as
792
+ * a timeout would describe the symptom rather than the cause.
793
+ */
794
+ export async function waitForMemberReady(spec, deps, pollIntervalMs = 250, isAlive = () => true, heartbeat = {}) {
795
+ if (!spec.readiness)
796
+ return { kind: "ready" };
797
+ if (spec.readiness.kind === "tcp") {
798
+ const { host, port, timeoutMs } = spec.readiness;
799
+ let waited = 0;
800
+ while (waited < timeoutMs) {
801
+ if (!isAlive())
802
+ return { kind: "member-exited" };
803
+ const probe = await deps.probePort(host, port, 500);
804
+ if (probe.kind === "connected")
805
+ return { kind: "ready" };
806
+ await deps.clock.sleep(pollIntervalMs);
807
+ waited += pollIntervalMs;
808
+ }
809
+ return { kind: "timeout", state: null };
810
+ }
811
+ const { baseUrl, repoName, component, timeoutMs } = spec.readiness;
812
+ // Read from the member's OWN environment at call time. The key is not carried
813
+ // on the readiness spec, is not copied into a local that outlives this call,
814
+ // and is never passed to anything that formats a message.
815
+ const apiKey = spec.env.BAPI_API_KEY ?? "";
816
+ const baselineAtMs = heartbeat.baseline?.observedAtMs ?? null;
817
+ const instanceId = heartbeat.instanceId ?? spec.readiness.instanceId;
818
+ let lastState = null;
819
+ let sawUnadvancedFresh = false;
820
+ // The baseline phase and this wait share ONE per-member budget: a member that
821
+ // spent time waiting for the route to answer before it started does not get a
822
+ // second full timeout afterwards.
823
+ let waited = Math.max(0, heartbeat.elapsedMs ?? 0);
596
824
  while (waited < timeoutMs) {
597
- const probe = await deps.probePort(host, port, 500);
598
- if (probe.kind === "connected")
599
- return true;
825
+ if (!isAlive())
826
+ return { kind: "member-exited" };
827
+ const observation = await deps.probeHealth({
828
+ baseUrl,
829
+ repoName,
830
+ apiKey,
831
+ component,
832
+ instanceId,
833
+ });
834
+ const probe = observation.result;
835
+ if (probe.kind === "state") {
836
+ lastState = probe.state;
837
+ // ONLY `fresh` can be ready. `stale` and `never_seen` are the states a
838
+ // process that has not published yet legitimately passes through on its
839
+ // way up, and `unknown` means the durable source could not be read —
840
+ // none of the three is evidence of readiness, and none is evidence of
841
+ // permanent failure either, so all three keep waiting.
842
+ if (probe.state === "fresh") {
843
+ const observedAtMs = observation.observedAtMs;
844
+ if (observedAtMs !== null && (baselineAtMs === null || observedAtMs > baselineAtMs)) {
845
+ // Strictly later than the baseline — or the first timestamped
846
+ // observation at all, when nothing had ever published under this
847
+ // identity. Either way, this heartbeat cannot be a leftover.
848
+ return { kind: "ready" };
849
+ }
850
+ // Fresh, but not NEW. Almost always the previous run of this same member
851
+ // still inside its staleness window. Keep waiting: the running process
852
+ // will overwrite that row on its own next publication.
853
+ sawUnadvancedFresh = true;
854
+ }
855
+ }
856
+ else if (probe.kind === "unauthorized") {
857
+ // Terminal by construction: the credential in the member environment is
858
+ // rejected, and no amount of further polling changes that.
859
+ return { kind: "unauthorized" };
860
+ }
861
+ else if (probe.kind === "malformed") {
862
+ return { kind: "malformed" };
863
+ }
864
+ // `unavailable` falls through: the server member is up but the route may
865
+ // still be warming, which is ordinary and retryable.
600
866
  await deps.clock.sleep(pollIntervalMs);
601
867
  waited += pollIntervalMs;
602
868
  }
603
- return false;
869
+ return { kind: "timeout", state: lastState, sawUnadvancedFresh };
870
+ }
871
+ /**
872
+ * The operator-facing reason one member never became ready.
873
+ *
874
+ * Built from fixed prose plus a closed-vocabulary state name. Nothing here is
875
+ * derived from a response body, a header, or an exception, so no arm of this
876
+ * function can carry a credential into a terminal or a trace file.
877
+ */
878
+ export function describeReadinessFailure(spec, outcome) {
879
+ if (outcome.kind === "member-exited") {
880
+ return "the process exited while it was still starting up";
881
+ }
882
+ if (outcome.kind === "unauthorized") {
883
+ return ("the Bridge API rejected the plane's credential during the automation " +
884
+ "health check (401/403), so this member's readiness cannot be established");
885
+ }
886
+ if (outcome.kind === "malformed") {
887
+ return ("the automation health check answered with a body this launcher does not " +
888
+ "recognize, so this member's readiness cannot be established");
889
+ }
890
+ if (spec.readiness?.kind === "tcp") {
891
+ return `it did not start listening on ${spec.readiness.host}:${spec.readiness.port} in time`;
892
+ }
893
+ const budgetSeconds = Math.round((spec.readiness?.timeoutMs ?? 0) / 1000);
894
+ // Drawn from the closed component vocabulary, never from a response. The whole
895
+ // reason this function builds prose from fixed strings plus a state name is
896
+ // that it runs on the failure paths, where a body or an exception message is
897
+ // exactly what would carry a credential into a terminal and a log file.
898
+ const component = spec.readiness?.kind === "process-heartbeat" ? spec.readiness.component : null;
899
+ // `ready` never reaches here (the caller only describes failures), but the
900
+ // narrowing is written rather than asserted so a new arm added to
901
+ // `PlaneMemberReadiness` becomes a compile error instead of silent prose.
902
+ const lastState = outcome.kind === "timeout" ? outcome.state : null;
903
+ const sawUnadvancedFresh = outcome.kind === "timeout" && outcome.sawUnadvancedFresh === true;
904
+ if (sawUnadvancedFresh) {
905
+ // The BAPI-1036 case, and the one an operator is most likely to misread: the
906
+ // row is fresh, `plane status` looks healthy, and the member is dead. Say
907
+ // which heartbeat was seen and why it did not count.
908
+ return (`no post-start durable ${component ?? "process"} heartbeat was observed within ` +
909
+ `${budgetSeconds}s. A fresh heartbeat for this member's identity was present, ` +
910
+ "but it never advanced past the reading taken before the member started — " +
911
+ "so it belongs to an earlier run, not to this process");
912
+ }
913
+ switch (lastState) {
914
+ case "fresh":
915
+ // Unreachable in practice — an advanced `fresh` returns ready and an
916
+ // unadvanced one is handled above — but a switch that silently fell
917
+ // through to "never reported" would misdescribe it.
918
+ return `it reported a fresh ${component ?? "process"} heartbeat too late to be accepted`;
919
+ case "stale":
920
+ return (`its durable ${component ?? "process"} heartbeat was still STALE after ` +
921
+ `${budgetSeconds}s. The process started but is not publishing`);
922
+ case "never_seen":
923
+ return (`no durable ${component ?? "process"} heartbeat was EVER recorded for this ` +
924
+ `member within ${budgetSeconds}s. The process started but never reached its ` +
925
+ "first publication");
926
+ case "unknown":
927
+ return (`the durable ${component ?? "process"} heartbeat could not be read within ` +
928
+ `${budgetSeconds}s. This reports a failed read, not a dead process`);
929
+ default:
930
+ return (`the automation health check was unreachable for the whole ${budgetSeconds}s ` +
931
+ "readiness budget, so this member's liveness was never established");
932
+ }
604
933
  }
605
934
  /**
606
935
  * Poll until the manifest names this plane identity AND this runtime's pid.
@@ -150,6 +150,49 @@ export function createFakeProcess(options) {
150
150
  export function createFakePortProbe(result) {
151
151
  return async () => (typeof result === "function" ? result() : result);
152
152
  }
153
+ /**
154
+ * An automation-health probe returning scripted results (BAPI-1029).
155
+ *
156
+ * A single result is returned forever; an ARRAY is consumed one call at a time
157
+ * and the last entry then repeats, which is how a test scripts a reconciler
158
+ * that boots — `never_seen`, `stale`, then `fresh` — without reaching for a
159
+ * timer. Every call is recorded so a test can assert the probe was given the
160
+ * member's own credential and never a hard-coded one.
161
+ */
162
+ export function createFakeHealthProbe(script) {
163
+ const calls = [];
164
+ const queue = Array.isArray(script) ? [...script] : null;
165
+ return {
166
+ calls,
167
+ probe: async (request) => {
168
+ calls.push(request);
169
+ const next = queue === null ? script : queue.length > 1 ? queue.shift() : queue[0];
170
+ return asObservation(next);
171
+ },
172
+ };
173
+ }
174
+ /**
175
+ * Accept either shape in a script (BAPI-1036).
176
+ *
177
+ * Most tests care only about the classification and were written before the
178
+ * observation existed; those keep passing bare `{kind: …}` results and get an
179
+ * observation with a monotonically increasing timestamp, so a `fresh` reading
180
+ * always ADVANCES and the pre-BAPI-1036 expectations still hold. A test about
181
+ * the advance rule itself passes a full observation and pins the number.
182
+ */
183
+ function asObservation(value) {
184
+ if ("result" in value)
185
+ return value;
186
+ return {
187
+ result: value,
188
+ observedAtMs: value.kind === "state" && value.state !== "never_seen" ? nextAutoObservation() : null,
189
+ };
190
+ }
191
+ let autoObservationMs = 1_000_000;
192
+ function nextAutoObservation() {
193
+ autoObservationMs += 1_000;
194
+ return autoObservationMs;
195
+ }
153
196
  /** An `execFile` fake keyed on the last argument (`heads` / `current`). */
154
197
  export function createFakeExecFile(responses) {
155
198
  const calls = [];
@@ -2,23 +2,59 @@
2
2
  * Typed contracts for the conductor plane supervisor (BAPI-756, slice C4).
3
3
  *
4
4
  * The plane is the attended set of processes a conductor run needs: the Bridge
5
- * API server, the reconciler worker, and one executor per parallel lane. This
6
- * module carries every boundary type so the runtime modules (preflight,
7
- * manifest, roster, supervisor, status, shutdown) stay independently testable
8
- * with injected dependencies and never reach for real I/O in a unit test.
5
+ * API server, the reconciler worker, one executor per parallel lane, and the
6
+ * dead-man observer. This module carries every boundary type so the runtime
7
+ * modules (preflight, manifest, roster, supervisor, status, shutdown) stay
8
+ * independently testable with injected dependencies and never reach for real
9
+ * I/O in a unit test.
9
10
  *
10
11
  * Two ratified requirements shape these types and must not be softened:
11
12
  *
12
- * - **R-3, the dead-man observer stays outside.** There is deliberately no
13
- * observer member name, no observer log path, and no observer manifest
14
- * record. Its failure domain is independent of the supervisor's by design
15
- * (`worker.py:80-85`), so the supervisor never owns its lifecycle.
13
+ * - **R-1, the dead-man observer is a same-host, separate-process member**
14
+ * (BAPI-1029, superseding R-3). The observer runs `worker.py` with
15
+ * `CONDUCTOR_DEAD_MAN_ONLY=true` in its OWN OS process, owned by the plane
16
+ * lifecycle: it has a member name, a log path, and a manifest record, and
17
+ * `plane status` / `plane down` treat it like every other member. Its failure
18
+ * domain is independent at the PROCESS level — killing the reconciler leaves
19
+ * the observer alive to report the death, which is the whole point. It is NOT
20
+ * separate-host isolation; that is A2/R36 and is deliberately out of scope.
21
+ * The previous R-3 arrangement (observer absent by construction, started
22
+ * out-of-band) could not detect its own death and is retired.
16
23
  * - **R-4, crashes are never auto-restarted.** No type here carries a restart
17
24
  * count, a backoff delay, or a retry budget, because no such machinery
18
25
  * exists. A member exit is a finding to report, not a condition to repair.
26
+ * The observer inherits this unchanged: it is spawned once and never revived.
19
27
  */
20
- /** Manifest schema version. Bump only with a matching parser change. */
21
- export const PLANE_MANIFEST_SCHEMA_VERSION = 1;
28
+ /**
29
+ * Manifest schema version WRITTEN by this build. Bump only with a matching
30
+ * parser change.
31
+ *
32
+ * Bumped to 2 by BAPI-1029, which added the `observer` member. The member set
33
+ * widened rather than the record shape, so the tempting conclusion was that no
34
+ * bump was needed — but that reasoning only covers the direction nobody is hurt
35
+ * by. Old manifests stay readable by a new binary; a NEW manifest is
36
+ * unreadable by an OLD one, because `isPlaneMemberName` there does not know
37
+ * `observer` and a single unrecognized name aborted the whole parse. `plane
38
+ * down` on the old build then returns `unvalidated-manifest` having signalled
39
+ * nothing, orphaning a fully detached plane and telling the operator to delete
40
+ * the file by hand.
41
+ *
42
+ * The bump does not fix that for an already-shipped old binary — nothing can —
43
+ * but it makes the failure legible ("schema version is not supported" instead
44
+ * of what reads like a corrupt file), and it pairs with the member-scoped
45
+ * rejection in `manifest.ts` so the next member added never reproduces it.
46
+ */
47
+ export const PLANE_MANIFEST_SCHEMA_VERSION = 2;
48
+ /**
49
+ * Every schema version this build can READ.
50
+ *
51
+ * Deliberately wider than what it writes. A plane already running under the
52
+ * previous build has a version-1 manifest on disk, and refusing it would
53
+ * orphan that plane on upgrade — the same failure this bump exists to make
54
+ * legible, inflicted from the other side. Accepting 1 costs nothing: the two
55
+ * versions differ only in whether `observer` may appear among the members.
56
+ */
57
+ export const PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS = [1, 2];
22
58
  /** Runtime artifact directory, relative to the repository root. */
23
59
  export const PLANE_RUNTIME_DIR = ".bridge/plane";
24
60
  /** Manifest filename inside {@link PLANE_RUNTIME_DIR}. */
@@ -87,9 +123,44 @@ export const PLANE_ENTRYPOINT_ACTION = "__entrypoint";
87
123
  * together with the runtime's own pid.
88
124
  */
89
125
  export const PLANE_ID_ENV_VAR = "BAPI_PLANE_ID";
90
- /** The exact independent dead-man observer command (R-3). Never spawned here. */
126
+ /**
127
+ * Operator-facing prose for the dead-man observer, as a copy-pasteable shell
128
+ * line.
129
+ *
130
+ * This is DOCUMENTATION, not an invocation. `plane up` spawns the observer with
131
+ * a shell-free executable plus argument vector and an observer-only child
132
+ * environment (see `member-roster.ts#buildPlaneObserverEnv`) — a shell string is
133
+ * never handed to `spawn`. The constant survives because the documented
134
+ * session-background FALLBACK still starts the observer by hand, and because an
135
+ * operator debugging the plane needs to know exactly what the observer member is.
136
+ */
91
137
  export const PLANE_OBSERVER_COMMAND = "CONDUCTOR_DEAD_MAN_ONLY=true python worker.py";
138
+ /** Env var that puts `worker.py` into dead-man-only mode. Observer member ONLY. */
139
+ export const PLANE_OBSERVER_MODE_ENV = "CONDUCTOR_DEAD_MAN_ONLY";
140
+ /** The exact value {@link PLANE_OBSERVER_MODE_ENV} is set to for the observer. */
141
+ export const PLANE_OBSERVER_MODE_VALUE = "true";
92
142
  /** Env var naming the observer's alert channel type. */
93
143
  export const PLANE_OBSERVER_CHANNEL_TYPE_ENV = "CONDUCTOR_DEADMAN_CHANNEL_TYPE";
94
144
  /** Env var naming the env var that holds the observer's destination URL. */
95
145
  export const PLANE_OBSERVER_DESTINATION_ENV = "CONDUCTOR_DEADMAN_DESTINATION_REF";
146
+ /**
147
+ * Every value {@link PlaneHeartbeatComponent} may take, for runtime guards.
148
+ *
149
+ * A cross-language contract, exactly like the state vocabulary below it: this
150
+ * string is sent to `GET /automation/health` as `component`, and the route
151
+ * refuses any value outside the closed set Python declares in
152
+ * `src/python/llms/process_heartbeats.py`. A component added on one side alone
153
+ * is a 422 on every probe, so the two declarations are pinned together by
154
+ * `tests/pytest/mcp_server/test_plane_liveness_vocabulary_pin.py`.
155
+ */
156
+ export const PLANE_HEARTBEAT_COMPONENTS = [
157
+ "reconciler",
158
+ "executor",
159
+ ];
160
+ /** Every value {@link PlaneHeartbeatHealthState} may take, for runtime guards. */
161
+ export const PLANE_HEARTBEAT_HEALTH_STATES = [
162
+ "fresh",
163
+ "stale",
164
+ "never_seen",
165
+ "unknown",
166
+ ];
@@ -17,6 +17,26 @@
17
17
  * for the worker. Consumed by the worker as `gh pr create --base "$BAPI_BASE_BRANCH"`.
18
18
  */
19
19
  export const PR_BASE_BRANCH_ENV_VAR = "BAPI_BASE_BRANCH";
20
+ /**
21
+ * The environment variable carrying THIS JOB's own branch (BAPI-1020).
22
+ *
23
+ * Read by the deterministic PreToolUse worker guard
24
+ * (`executor/worker-guard-hook.ts`), which cannot otherwise answer the two
25
+ * questions a deny glob is incapable of expressing: "is this `git push`
26
+ * destination the worker's own branch?" and "is this `git reset --hard` ref the
27
+ * worker's own ref?". A glob sees a string; the guard needs the identity.
28
+ *
29
+ * Declared HERE, beside {@link PR_BASE_BRANCH_ENV_VAR}, rather than as a bare
30
+ * string literal at the injection site, so the producing and consuming sides
31
+ * cannot drift apart on the spelling.
32
+ *
33
+ * It is set from the EXPLICIT job value only and is on `EXPLICIT_DENY_KEYS` in
34
+ * `executor/env.ts`, which matters more here than for the base branch: an ambient
35
+ * `BAPI_WORKER_BRANCH` exported in the operator's shell would otherwise reach a
36
+ * worker and REDEFINE which branch the guard considers safe to push to — turning
37
+ * the guard's own input into an attack surface.
38
+ */
39
+ export const WORKER_BRANCH_ENV_VAR = "BAPI_WORKER_BRANCH";
20
40
  /**
21
41
  * A SINGLE-LINE, secret-free worker launch instruction telling the worker to
22
42
  * open its PR against the injected run base. Kept free of newlines, `;`, single