@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
@@ -48,19 +48,41 @@ export async function shutdownPlane(repoRoot, deps) {
48
48
  if (read.kind !== "valid") {
49
49
  // Deliberately before any liveness probe: an unvalidated record must never
50
50
  // become a signal target, not even a signal-zero one — and, as of BAPI-872,
51
- // must never authorize a run-stop lookup either.
51
+ // must never authorize a run-stop lookup either. That property is NOT
52
+ // relaxed here.
53
+ //
54
+ // What BAPI-1029 changed is the advice. "Remove it by hand" was the whole
55
+ // guidance, and it is the wrong instruction for the most likely cause: a
56
+ // manifest written by a NEWER build, which this one cannot parse. Deleting
57
+ // the file there does not stop anything — it destroys the only record of a
58
+ // plane that is still running, and orphans every member permanently.
52
59
  return {
53
60
  ok: false,
54
61
  reason: "unvalidated-manifest",
55
62
  message: `.bridge/plane/plane.json could not be validated (${read.error}). No process was ` +
56
- "signalled. Inspect the file and remove it by hand once you have confirmed no plane " +
57
- "is running.",
63
+ "signalled, because an unvalidated record must never become a signal target.\n" +
64
+ " If this manifest was written by a NEWER build, wind the plane down with THAT " +
65
+ "build's `plane down` — do not delete the file, which would orphan every member " +
66
+ "with no record of what to stop.\n" +
67
+ " Otherwise, confirm no plane is running (`ps` for the recorded pids) and only " +
68
+ "then remove the file by hand.",
58
69
  members: [],
59
70
  runShutdown: { kind: "unbound" },
60
71
  };
61
72
  }
62
73
  const manifest = read.manifest;
63
74
  const messages = [];
75
+ if (read.unrecognizedMemberNames.length > 0) {
76
+ // Said out loud, never assumed harmless (BAPI-1029). The wind-down still
77
+ // reaches these members — the whole plane shares one process group, and the
78
+ // group is what gets signalled — but the per-member reporting below cannot
79
+ // name them, and an operator reading a short list would otherwise conclude
80
+ // the plane was smaller than it was.
81
+ messages.push(`This manifest lists ${read.unrecognizedMemberNames.length} member(s) this build does ` +
82
+ `not recognize: ${read.unrecognizedMemberNames.join(", ")}. They are signalled with ` +
83
+ "the rest of the process group, but are not reported individually below. The manifest " +
84
+ "was written by a NEWER build.");
85
+ }
64
86
  // BAPI-872: the server-side run stop, computed ONCE right after the manifest
65
87
  // has passed every validation and identity check above — strictly before the
66
88
  // first process-group signal in every branch that sends one (the sole branch
@@ -31,6 +31,7 @@ export async function getPlaneStatus(repoRoot, deps) {
31
31
  supervisorPid: manifest.supervisorPid,
32
32
  supervisorPgid: manifest.supervisorPgid,
33
33
  supervisorLiveness: deps.proc.isAlive(manifest.supervisorPid),
34
+ unrecognizedMemberNames: read.unrecognizedMemberNames,
34
35
  createdAt: manifest.createdAt,
35
36
  updatedAt: manifest.updatedAt,
36
37
  members: probeManifestMembers(manifest, deps.proc).map((probe) => ({
@@ -62,6 +63,16 @@ export function formatPlaneStatus(result) {
62
63
  lines.push(` ${member.name.padEnd(12)} ${describeMemberState(member, result.supervisorLiveness)} ` +
63
64
  `${member.logPath}`);
64
65
  }
66
+ if (result.unrecognizedMemberNames.length > 0) {
67
+ // Never a silent omission. The plane really has more members than the list
68
+ // above shows, and the operator needs to know the shortfall is a version
69
+ // gap rather than a crash.
70
+ lines.push("");
71
+ lines.push(`${result.unrecognizedMemberNames.length} member(s) are NOT SHOWN above — this build ` +
72
+ `does not recognize the name(s): ${result.unrecognizedMemberNames.join(", ")}. The ` +
73
+ "manifest was written by a NEWER build; use that build's `plane status` to see them, " +
74
+ "and its `plane down` to stop the plane.");
75
+ }
65
76
  const neverStarted = result.members.filter((m) => isPreStart(m) && result.supervisorLiveness === "dead");
66
77
  if (neverStarted.length > 0) {
67
78
  // These members have no log to read — pointing the operator at an empty file
@@ -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 = [];