@bridge_gpt/mcp-server 0.2.65 → 0.2.67

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 (132) hide show
  1. package/README.md +269 -82
  2. package/build/agent-capabilities/probes.js +15 -5
  3. package/build/agent-launchers/claude-executor-adapter.js +55 -34
  4. package/build/agent-launchers/executor-adapter.js +34 -4
  5. package/build/agents.generated.js +1 -1
  6. package/build/bounded-wait.js +67 -10
  7. package/build/bridge-config.js +104 -0
  8. package/build/codex-bridge-profile.js +10 -1
  9. package/build/commands.generated.js +12 -12
  10. package/build/conductor/bridge-api-client.js +372 -10
  11. package/build/conductor/bring-up-facts.js +1 -1
  12. package/build/conductor/doctor.js +3 -3
  13. package/build/conductor/epic-child-continuation.js +61 -34
  14. package/build/conductor/epic-implementer-cli.js +34 -6
  15. package/build/conductor/errors.js +4 -3
  16. package/build/conductor/guided-repair.js +327 -0
  17. package/build/conductor/hook-runtime-qualification.js +1 -1
  18. package/build/conductor/merge-execution-contract.js +27 -8
  19. package/build/conductor/merge-execution-eligibility.js +3 -2
  20. package/build/conductor/pr-ci-producer.js +1 -1
  21. package/build/conductor/readiness-cli.js +46 -9
  22. package/build/conductor/readiness-sections.js +41 -6
  23. package/build/conductor/readiness.js +204 -30
  24. package/build/conductor/recovery-cli.js +703 -20
  25. package/build/conductor/recovery-operations.js +392 -3
  26. package/build/conductor/repair-contract.js +112 -3
  27. package/build/conductor/repository-workflow-remedy.js +125 -0
  28. package/build/conductor/tools.js +2 -2
  29. package/build/conductor-bundle-artifacts.js +8 -0
  30. package/build/credential-store.js +5 -4
  31. package/build/diagnostic-text.js +36 -0
  32. package/build/docs.generated.js +8 -3
  33. package/build/doctor.js +70 -10
  34. package/build/drive-epic-key.js +6 -5
  35. package/build/drive-epic.js +479 -131
  36. package/build/epic-coherence-input.js +2 -1
  37. package/build/epic-implementer-bin.js +30 -26
  38. package/build/epic-implementer-bundle-cli.js +2 -2
  39. package/build/epic-launch-diagnostic.js +31 -0
  40. package/build/epic-launch-profile.js +44 -14
  41. package/build/executor/cli.js +3 -2
  42. package/build/executor/completion-contract.js +48 -5
  43. package/build/executor/completion-supervision.js +9 -0
  44. package/build/executor/correction-dispatch.js +60 -0
  45. package/build/executor/env.js +25 -0
  46. package/build/executor/heartbeat.js +1 -1
  47. package/build/executor/http-client.js +35 -0
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +38 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +485 -74
  52. package/build/executor/job-types.js +8 -13
  53. package/build/executor/observation.js +6 -1
  54. package/build/executor/preflight.js +136 -10
  55. package/build/executor/prompt-spec.js +113 -9
  56. package/build/executor/required-work-ledger.js +6 -1
  57. package/build/executor/resume-pre-spawn.js +2 -0
  58. package/build/executor/runner.js +31 -2
  59. package/build/executor/service-unit.js +7 -1
  60. package/build/executor/stale-artifacts.js +5 -4
  61. package/build/executor/types.js +1 -0
  62. package/build/executor/watch-cli.js +35 -8
  63. package/build/executor/worker-command.js +43 -43
  64. package/build/executor/worker-finalization.js +161 -43
  65. package/build/executor/worker-guard-hook.js +152 -12
  66. package/build/executor/worker-log.js +137 -70
  67. package/build/executor/worktree-gc.js +30 -6
  68. package/build/executor/worktree.js +82 -1
  69. package/build/index.js +482 -660
  70. package/build/init.js +107 -4
  71. package/build/install-bridge-conductor.js +8 -6
  72. package/build/install-bridge.js +287 -23
  73. package/build/install-doctor.js +172 -30
  74. package/build/install-reexec.js +69 -7
  75. package/build/macos-terminal-permission-guidance.js +16 -0
  76. package/build/model-thresholds-tool.js +185 -0
  77. package/build/phase-context-git-probe.js +68 -0
  78. package/build/phase-result-artifacts.js +173 -5
  79. package/build/pipelines.generated.js +19 -25
  80. package/build/plane/alembic-head.js +12 -3
  81. package/build/plane/base-worktree-guard.js +147 -0
  82. package/build/plane/cli.js +217 -17
  83. package/build/plane/code-snapshot.js +468 -0
  84. package/build/plane/defaults.js +48 -3
  85. package/build/plane/endpoint.js +41 -0
  86. package/build/plane/manifest.js +120 -1
  87. package/build/plane/member-roster.js +94 -19
  88. package/build/plane/preflight.js +30 -2
  89. package/build/plane/process-cwd.js +67 -0
  90. package/build/plane/restart.js +542 -0
  91. package/build/plane/shutdown.js +31 -0
  92. package/build/plane/status.js +145 -6
  93. package/build/plane/supervisor.js +588 -177
  94. package/build/plane/types.js +54 -2
  95. package/build/plane/worker-exit-code.js +2 -0
  96. package/build/printed-remedy-commands.js +37 -0
  97. package/build/project-root.js +99 -0
  98. package/build/readiness-check.js +1 -1
  99. package/build/readme.generated.js +1 -1
  100. package/build/review-generation.js +22 -0
  101. package/build/review-tickets.js +156 -13
  102. package/build/setup-epic.js +224 -127
  103. package/build/sfcc/config.js +9 -0
  104. package/build/sfcc/credentials.js +100 -38
  105. package/build/sfcc/hostname.js +67 -0
  106. package/build/sfcc/permissions.js +17 -13
  107. package/build/sfcc/register.js +1 -1
  108. package/build/sfcc/setup-status.js +2 -0
  109. package/build/sfcc/tool-wrapper.js +41 -43
  110. package/build/sfcc/tools-configured.js +96 -0
  111. package/build/sfcc/write-grants.js +17 -11
  112. package/build/sfcc/write-result.js +4 -3
  113. package/build/sfcc/writes-site-preference.js +20 -8
  114. package/build/start-tickets.js +332 -79
  115. package/build/tool-error-envelope.js +61 -0
  116. package/build/upgrade-cli.js +109 -24
  117. package/build/version.generated.js +3 -3
  118. package/build/visual-diff.js +22 -1
  119. package/build/windows-npm-entry.js +103 -0
  120. package/build/worker-guard-hook-bin.js +3 -5
  121. package/build/worker-guard-hook-main.js +47 -0
  122. package/build/worktree-core.js +136 -9
  123. package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
  124. package/docs/EPIC-IMPLEMENTER.md +662 -0
  125. package/docs/install/mcp-tool-integrations.md +25 -15
  126. package/docs/install/sfcc-integration.md +21 -8
  127. package/package.json +1 -1
  128. package/pipelines/implement-ticket.json +1 -1
  129. package/pipelines/plan-epic.json +0 -5
  130. package/pipelines/review-ticket.json +1 -1
  131. package/docs/CONDUCTOR.md +0 -605
  132. package/pipelines/idea-to-pr.json +0 -49
@@ -25,11 +25,13 @@
25
25
  * mystery.
26
26
  */
27
27
  import path from "path";
28
- import { PLANE_ID_ENV_VAR, PLANE_RUNTIME_ACTION, PLANE_RUNTIME_LOG_FILENAME, PLANE_RUNTIME_LOG_PATH, } from "./types.js";
29
- import { appendPlaneManifestMembers, getPlanePaths, planeManifestLifecycle, readPlaneManifest, transitionPlaneManifestLifecycle, writePlaneManifest, } from "./manifest.js";
28
+ import { PLANE_ID_ENV_VAR, PLANE_RESTART_SIGNAL, PLANE_RUNTIME_ACTION, PLANE_RUNTIME_LOG_FILENAME, PLANE_RUNTIME_LOG_PATH, } from "./types.js";
29
+ import { appendPlaneManifestMembers, getPlanePaths, isCanonicalAbsolutePath, planeManifestLifecycle, planeManifestSnapshot, readPlaneManifest, transitionPlaneManifestLifecycle, writePlaneManifest, } from "./manifest.js";
30
+ import { consumePlaneRestartRequest, isPlaneRestartableMember, writePlaneRestartResult, } from "./restart.js";
30
31
  import { executorClaimScopeArgs, } from "../executor/claim-scope.js";
31
32
  import { attachMemberOutput, openMemberLog, openPlaneRuntimeLog, writeMemberStartupFailure, writeRuntimeTraceLine, PLANE_MEMBER_STARTUP_FAILURES, PLANE_SUPERVISOR_LOG_PREFIX, } from "./member-logs.js";
32
33
  import { absoluteLogPath, describePlaneMember } from "./member-roster.js";
34
+ import { WORKER_STALE_EXIT_CODE } from "./worker-exit-code.js";
33
35
  import { PLANE_SHUTDOWN_GRACE_MS, PLANE_SHUTDOWN_KILL_WAIT_MS, PLANE_SHUTDOWN_POLL_MS, shutdownPlane, } from "./shutdown.js";
34
36
  /** How long the runtime waits for the launcher to record its pid. */
35
37
  export const PLANE_HANDSHAKE_TIMEOUT_MS = 10_000;
@@ -155,6 +157,13 @@ export function emitMemberEvent(sinks, event) {
155
157
  * Loud is the requirement, not a style choice: this banner is the ONLY signal
156
158
  * that a plane has a hole in it, because nothing restarts the member. It goes
157
159
  * to stderr so it survives a stdout redirect of the event feed.
160
+ *
161
+ * BAPI-1362: the banner now names the MEMBER-SCOPED recovery. It used to tell an
162
+ * operator whose worker exited 75 to "run plane down && plane up", which stops the
163
+ * bound run to fix one process. `plane restart <member>` replaces only that
164
+ * member, from the plane's pinned code, without touching the run. Executor lanes
165
+ * are run-scoped and are pointed at run-level recovery instead. Nothing here
166
+ * restarts anything: the banner advises, and the operator acts.
158
167
  */
159
168
  export function reportMemberCrash(sinks, report) {
160
169
  const status = report.exitSignal !== null
@@ -166,6 +175,19 @@ export function reportMemberCrash(sinks, report) {
166
175
  sinks.stderr(`!! at ${report.timestamp}`);
167
176
  sinks.stderr(`!! log: ${report.logPath}`);
168
177
  sinks.stderr("!! It was NOT restarted. Survivors keep running; `plane status` shows the hole.");
178
+ if (isPlaneRestartableMember(report.member)) {
179
+ if ((report.member === "worker" || report.member === "observer") &&
180
+ report.exitCode === WORKER_STALE_EXIT_CODE) {
181
+ sinks.stderr(`!! Code on disk changed under the ${report.member}; it exited deliberately rather than keep running stale code.`);
182
+ }
183
+ sinks.stderr(`!! Recover ${report.member} only: plane restart ${report.member}` +
184
+ (report.pinnedSha !== undefined
185
+ ? ` — it reuses the plane's pinned code (${report.pinnedSha.slice(0, 12)}) and leaves the bound run running.`
186
+ : " — it leaves the rest of the plane and the bound run running."));
187
+ }
188
+ else {
189
+ sinks.stderr(`!! ${report.member} is an executor lane: per-member restart is unavailable. Recover at the run level (re-attach with \`drive-epic\`).`);
190
+ }
169
191
  sinks.stderr(rule);
170
192
  }
171
193
  /**
@@ -551,6 +573,7 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
551
573
  exitCode: null,
552
574
  exitSignal: null,
553
575
  logPath: spec.logPath,
576
+ ...(isCanonicalAbsolutePath(spec.cwd) ? { cwd: spec.cwd } : {}),
554
577
  });
555
578
  let manifest = {
556
579
  ...adopted.manifest,
@@ -609,7 +632,42 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
609
632
  // member patch overwrite an earlier transition's record.
610
633
  manifest = { ...manifest, updatedAt: deps.clock.now().toISOString() };
611
634
  const snapshot = manifest;
612
- const operation = persistTail.then(() => writePlaneManifest(snapshot, deps.fs));
635
+ const operation = persistTail.then(async () => {
636
+ // BAPI-1362 — adopt run bindings another process wrote since this runtime
637
+ // last did. `bindPlaneManifestEpicRun` (setup-epic) and
638
+ // `requestPlaneManifestLanes` write `epicRunId` / `laneScope` straight into
639
+ // plane.json, and this runtime's memory never hears of it. Publishing the
640
+ // in-memory record as-is would then silently drop the bound run on the next
641
+ // write — any member exit, and now every restart — and a later `plane down`
642
+ // could not stop that run. Only a MISSING field is ever added: nothing the
643
+ // runtime already holds is overwritten, a manifest of another plane is never
644
+ // adopted from, and an absent or unreadable file leaves the write unchanged.
645
+ let record = snapshot;
646
+ const onDisk = await readPlaneManifest(repoRoot, deps.fs);
647
+ if (onDisk.kind === "valid" && onDisk.manifest.planeId === deps.planeId) {
648
+ const adopted = {};
649
+ if (record.epicRunId === undefined && onDisk.manifest.epicRunId !== undefined) {
650
+ adopted.epicRunId = onDisk.manifest.epicRunId;
651
+ }
652
+ if (record.laneScope === undefined && onDisk.manifest.laneScope !== undefined) {
653
+ adopted.laneScope = onDisk.manifest.laneScope;
654
+ }
655
+ if (Object.keys(adopted).length > 0) {
656
+ record = { ...record, ...adopted };
657
+ // The shared binding too, so every later write carries it forward.
658
+ manifest = {
659
+ ...manifest,
660
+ ...(manifest.epicRunId === undefined && adopted.epicRunId !== undefined
661
+ ? { epicRunId: adopted.epicRunId }
662
+ : {}),
663
+ ...(manifest.laneScope === undefined && adopted.laneScope !== undefined
664
+ ? { laneScope: adopted.laneScope }
665
+ : {}),
666
+ };
667
+ }
668
+ }
669
+ await writePlaneManifest(record, deps.fs);
670
+ });
613
671
  // The tail is recovered but the caller's promise is not: one failed write
614
672
  // must not poison every write queued behind it, and it must not be
615
673
  // swallowed either. `operation` still rejects with the original error.
@@ -650,7 +708,19 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
650
708
  const patchMember = (name, patch) => {
651
709
  manifest = {
652
710
  ...manifest,
653
- members: manifest.members.map((m) => (m.name === name ? { ...m, ...patch } : m)),
711
+ members: manifest.members.map((m) => {
712
+ if (m.name !== name)
713
+ return m;
714
+ const next = { ...m, ...patch };
715
+ // An explicit `undefined` REMOVES an optional key. Spreading it would
716
+ // leave `restartFailure: undefined` on the record, which the serializer
717
+ // drops but a deep-equality read of the in-memory manifest would not.
718
+ for (const key of Object.keys(patch)) {
719
+ if (patch[key] === undefined)
720
+ delete next[key];
721
+ }
722
+ return next;
723
+ }),
654
724
  };
655
725
  };
656
726
  /**
@@ -679,6 +749,7 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
679
749
  // own liveness must not read as a survivor — and it must not signal
680
750
  // itself before it has confirmed the members are gone.
681
751
  selfPid: deps.selfPid,
752
+ ...(deps.releaseSnapshot ? { releaseSnapshot: deps.releaseSnapshot } : {}),
682
753
  });
683
754
  };
684
755
  /**
@@ -757,212 +828,547 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
757
828
  "`plane status` may still describe it as starting lanes.");
758
829
  }
759
830
  };
760
- // ---- Spawn one cohort of members, in order ------------------------------
831
+ // ---- Member instances (BAPI-1362) ----------------------------------------
761
832
  //
762
- // BAPI-1102 extracted this from a single top-level loop so the two-phase
763
- // lifecycle can run it three times — control plane, scoped lanes, observer —
764
- // against the SAME closure state. Every property the single pass had is
765
- // preserved by construction rather than re-established: one `started` list, one
766
- // serialized manifest writer, one rollback, one set of member events.
833
+ // Every spawn gets an INSTANCE number, and exit handling is keyed to it. A
834
+ // member restart spawns a replacement while the old child's `close` event may
835
+ // still be in flight; keyed only by member NAME, that late event would patch the
836
+ // replacement's record as `exited` and print a crash banner over a healthy
837
+ // process. Keyed by instance, an exit from anything but the current instance —
838
+ // or from one a restart deliberately retired — is recorded and ignored.
839
+ let spawnCounter = 0;
840
+ const currentInstance = new Map();
841
+ /** Instances a restart stopped on purpose. Their exit is not news. */
842
+ const retiredInstances = new Set();
843
+ const exitedInstances = new Set();
844
+ const retiredExits = new Map();
845
+ /** The ORIGINAL spec each member was launched with: a restart reuses it verbatim. */
846
+ const memberSpecs = new Map();
847
+ const memberPids = new Map();
848
+ // ---- Launch ONE member ----------------------------------------------------
767
849
  //
768
- // Returns `null` on success, or the terminal failure result. A cohort failure
769
- // rolls back EVERY member started so far, across all cohorts, which is the
770
- // behavior a partially-started plane needs and the reason `started` is shared.
771
- const startMembers = async (cohort) => {
772
- for (const spec of cohort) {
773
- const absoluteLog = absoluteLogPath(repoRoot, spec);
774
- const opened = openMemberLog(spec.name, paths.planeDir, absoluteLog, deps.log);
775
- if (!opened.ok) {
776
- return finishPartialStart(spec.name, `log could not be opened (${opened.error})`);
850
+ // BAPI-1102 extracted the cohort loop from a single top-level pass so the
851
+ // two-phase lifecycle could run it three times. BAPI-1362 extracts its BODY one
852
+ // level further, for a reason of the same kind: a member restart must spawn,
853
+ // gate, and watch a member EXACTLY as startup does — same log, same baseline,
854
+ // same readiness gate, same exit handling — but must never do what a startup
855
+ // failure does, which is roll the whole plane back. So this returns a
856
+ // description of the failure and lets each caller decide: `startMembers` rolls
857
+ // back, a restart reports and leaves everything else alone.
858
+ //
859
+ // `mode` changes only what must differ for an existing member: it is already in
860
+ // the manifest and the cohort ledgers, and a restart's readiness wait ends if the
861
+ // plane begins shutting down.
862
+ const launchMember = async (spec, mode) => {
863
+ const absoluteLog = absoluteLogPath(repoRoot, spec);
864
+ const opened = openMemberLog(spec.name, paths.planeDir, absoluteLog, deps.log);
865
+ if (!opened.ok) {
866
+ return {
867
+ ok: false,
868
+ reason: `log could not be opened (${opened.error})`,
869
+ restartFailure: "spawn",
870
+ spawnedPid: null,
871
+ };
872
+ }
873
+ if (mode === "restart") {
874
+ // The log is opened for APPEND, so the previous run's output is still above
875
+ // this line; say where the replacement begins.
876
+ writeRuntimeTraceLine(opened.stream, `${PLANE_SUPERVISOR_LOG_PREFIX} plane restart requested — the replacement ${spec.name} starts below`);
877
+ }
878
+ // Probed BEFORE the spawn, for every heartbeat-gated member (BAPI-1036;
879
+ // BAPI-1029 took one such probe, for the reconciler only, and used it merely
880
+ // to disclose an ambiguity). The baseline is what turns "a fresh heartbeat
881
+ // exists" into "a heartbeat landed after we started this process" — see
882
+ // `PlaneHeartbeatBaseline`.
883
+ //
884
+ // Taken AFTER the member's log is opened, so a terminal baseline failure is
885
+ // annotated in the log an operator will actually open for that member —
886
+ // still before the spawn, which is the property that matters.
887
+ //
888
+ // An executor lane is scoped here by the identity the roster already minted;
889
+ // the reconciler's local identity needs a pid, so its pre-spawn baseline is
890
+ // the UNSCOPED observation, which is a sound lower bound: the unscoped read
891
+ // returns the newest row across the deployment, so any row this member later
892
+ // publishes must be at least as new. A `DYNO` deployment scopes exactly,
893
+ // because that identity needs no pid.
894
+ let baseline = { observedAtMs: null };
895
+ let baselineElapsedMs = 0;
896
+ if (spec.readiness?.kind === "process-heartbeat") {
897
+ const readiness = spec.readiness;
898
+ const baselineInstanceId = readiness.instanceId ?? resolveMemberInstanceId(spec.env, 0, undefined);
899
+ const outcome = await probeHeartbeatBaseline({
900
+ deps,
901
+ spec,
902
+ readiness,
903
+ instanceId: baselineInstanceId,
904
+ });
905
+ if (outcome.kind === "failed") {
906
+ // Terminal before the spawn: a rejected credential or a moved contract
907
+ // cannot be waited out, and starting the member first would leave a live
908
+ // process to roll back for no reason.
909
+ return {
910
+ ok: false,
911
+ reason: describeBaselineFailure(outcome.reason),
912
+ annotate: {
913
+ log: opened.stream,
914
+ failure: PLANE_MEMBER_STARTUP_FAILURES.readinessUnestablished,
915
+ },
916
+ restartFailure: "readiness",
917
+ spawnedPid: null,
918
+ };
777
919
  }
778
- // Probed BEFORE the spawn, for every heartbeat-gated member (BAPI-1036;
779
- // BAPI-1029 took one such probe, for the reconciler only, and used it merely
780
- // to disclose an ambiguity). The baseline is what turns "a fresh heartbeat
781
- // exists" into "a heartbeat landed after we started this process" — see
782
- // `PlaneHeartbeatBaseline`.
783
- //
784
- // Taken AFTER the member's log is opened, so a terminal baseline failure is
785
- // annotated in the log an operator will actually open for that member —
786
- // still before the spawn, which is the property that matters.
787
- //
788
- // An executor lane is scoped here by the identity the roster already minted;
789
- // the reconciler's local identity needs a pid, so its pre-spawn baseline is
790
- // the UNSCOPED observation, which is a sound lower bound: the unscoped read
791
- // returns the newest row across the deployment, so any row this member later
792
- // publishes must be at least as new. A `DYNO` deployment scopes exactly,
793
- // because that identity needs no pid.
794
- let baseline = { observedAtMs: null };
795
- let baselineElapsedMs = 0;
796
- if (spec.readiness?.kind === "process-heartbeat") {
797
- const readiness = spec.readiness;
798
- const baselineInstanceId = readiness.instanceId ?? resolveMemberInstanceId(spec.env, 0, undefined);
799
- const outcome = await probeHeartbeatBaseline({
920
+ baseline = outcome.baseline;
921
+ baselineElapsedMs = outcome.elapsedMs;
922
+ // BAPI-1279 — never start a second reconciler on a shared database. The
923
+ // server member is up by now (it is first in the roster), so the check
924
+ // can read the heartbeat; the worker is not spawned until it clears.
925
+ // `plane up` and drive-epic's cold start both reach this, so both get it.
926
+ // BAPI-1362: a worker RESTART reaches it too, deliberately — two reconcilers
927
+ // are two transition authorities whether the second arrives at bring-up or
928
+ // after a restart, and a foreign one that appeared while ours was down is
929
+ // exactly the case the guard exists for.
930
+ if (spec.name === "worker" && readiness.component === "reconciler") {
931
+ const guard = await guardForeignReconciler({
800
932
  deps,
801
- spec,
802
- readiness,
803
- instanceId: baselineInstanceId,
933
+ baseUrl: readiness.baseUrl,
934
+ repoName: readiness.repoName,
935
+ apiKey: spec.env.BAPI_API_KEY ?? "",
804
936
  });
805
- if (outcome.kind === "failed") {
806
- // Terminal before the spawn: a rejected credential or a moved contract
807
- // cannot be waited out, and starting the member first would leave a live
808
- // process to roll back for no reason.
809
- return finishPartialStart(spec.name, describeBaselineFailure(outcome.reason), {
810
- log: opened.stream,
811
- failure: PLANE_MEMBER_STARTUP_FAILURES.readinessUnestablished,
812
- });
813
- }
814
- baseline = outcome.baseline;
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, {
937
+ if (guard.kind === "foreign-live") {
938
+ return {
939
+ ok: false,
940
+ reason: PLANE_FOREIGN_RECONCILER_REASON,
941
+ annotate: {
829
942
  log: opened.stream,
830
943
  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
- }
944
+ },
945
+ foreignReconciler: true,
946
+ restartFailure: "readiness",
947
+ spawnedPid: null,
948
+ };
949
+ }
950
+ if (guard.kind === "unsafe") {
951
+ return {
952
+ ok: false,
953
+ reason: `${guard.reason}, so it could not be confirmed that no other reconciler is live`,
954
+ annotate: {
955
+ log: opened.stream,
956
+ failure: PLANE_MEMBER_STARTUP_FAILURES.readinessUnestablished,
957
+ },
958
+ restartFailure: "readiness",
959
+ spawnedPid: null,
960
+ };
838
961
  }
839
962
  }
840
- let child;
841
- try {
842
- child = deps.spawn(spec.command, spec.args, {
843
- cwd: spec.cwd,
844
- env: spec.env,
845
- // Members stay in the RUNTIME's process group, which is what makes one
846
- // group signal reach the whole plane.
847
- detached: false,
848
- });
849
- }
850
- catch {
851
- return finishPartialStart(spec.name, PLANE_MEMBER_STARTUP_FAILURES.spawnThrew, {
852
- log: opened.stream,
853
- failure: PLANE_MEMBER_STARTUP_FAILURES.spawnThrew,
854
- });
855
- }
856
- if (child.pid === null || child.pid === undefined) {
857
- return finishPartialStart(spec.name, PLANE_MEMBER_STARTUP_FAILURES.noPid, {
858
- log: opened.stream,
859
- failure: PLANE_MEMBER_STARTUP_FAILURES.noPid,
860
- });
861
- }
862
- attachMemberOutput({ stdout: child.stdout, stderr: child.stderr }, opened.stream);
963
+ }
964
+ // A restart must not spawn into a plane that began winding down while it was
965
+ // waiting (a foreign-reconciler guard can take minutes). Checked in the same
966
+ // tick as the spawn, so there is no await between the check and the child.
967
+ if (mode === "restart" && shuttingDown) {
968
+ return {
969
+ ok: false,
970
+ reason: "the plane began shutting down before the replacement could start",
971
+ restartFailure: "spawn",
972
+ spawnedPid: null,
973
+ };
974
+ }
975
+ let child;
976
+ try {
977
+ child = deps.spawn(spec.command, spec.args, {
978
+ cwd: spec.cwd,
979
+ env: spec.env,
980
+ // Members stay in the RUNTIME's process group, which is what makes one
981
+ // group signal reach the whole plane.
982
+ detached: false,
983
+ });
984
+ }
985
+ catch {
986
+ return {
987
+ ok: false,
988
+ reason: PLANE_MEMBER_STARTUP_FAILURES.spawnThrew,
989
+ annotate: { log: opened.stream, failure: PLANE_MEMBER_STARTUP_FAILURES.spawnThrew },
990
+ restartFailure: "spawn",
991
+ spawnedPid: null,
992
+ };
993
+ }
994
+ if (child.pid === null || child.pid === undefined) {
995
+ return {
996
+ ok: false,
997
+ reason: PLANE_MEMBER_STARTUP_FAILURES.noPid,
998
+ annotate: { log: opened.stream, failure: PLANE_MEMBER_STARTUP_FAILURES.noPid },
999
+ restartFailure: "spawn",
1000
+ spawnedPid: null,
1001
+ };
1002
+ }
1003
+ const childPid = child.pid;
1004
+ const instance = (spawnCounter += 1);
1005
+ currentInstance.set(spec.name, instance);
1006
+ memberSpecs.set(spec.name, spec);
1007
+ memberPids.set(spec.name, childPid);
1008
+ attachMemberOutput({ stdout: child.stdout, stderr: child.stderr }, opened.stream);
1009
+ if (mode === "start") {
863
1010
  started.push(spec.name);
864
1011
  // Ledgered separately so a held failure can stop exactly this cohort and
865
1012
  // leave the control plane running (BAPI-1102).
866
1013
  if (phaseTwoBegun)
867
1014
  phaseTwoMembers.push(spec.name);
868
- patchMember(spec.name, { pid: child.pid, state: "running" });
869
- await persist();
1015
+ }
1016
+ patchMember(spec.name, {
1017
+ pid: childPid,
1018
+ state: "running",
1019
+ exitCode: null,
1020
+ exitSignal: null,
1021
+ // The directory the member was launched in, recorded so a later reader can
1022
+ // say which checkout this process is importing from without asking the OS.
1023
+ ...(isCanonicalAbsolutePath(spec.cwd) ? { cwd: spec.cwd } : {}),
1024
+ restartFailure: undefined,
1025
+ });
1026
+ await persist();
1027
+ emitMemberEvent(deps.sinks, {
1028
+ member: spec.name,
1029
+ kind: "start",
1030
+ timestamp: deps.clock.now().toISOString(),
1031
+ logPath: spec.logPath,
1032
+ detail: describePlaneMember(spec),
1033
+ });
1034
+ if (mode === "restart" && shuttingDown) {
1035
+ // Shutdown began between the spawn and the manifest write, so the wind-down
1036
+ // that is running may never have seen this pid. Stop the replacement here
1037
+ // rather than leave a member nobody owns.
1038
+ deps.proc.signal(childPid, "SIGTERM");
1039
+ return {
1040
+ ok: false,
1041
+ reason: "the plane began shutting down while the replacement was starting",
1042
+ restartFailure: "spawn",
1043
+ spawnedPid: childPid,
1044
+ };
1045
+ }
1046
+ // Exit watching is registered BEFORE readiness so a member that dies during
1047
+ // its readiness window is reported as a crash rather than as a silent
1048
+ // readiness timeout.
1049
+ child.on("close", ((code, signal) => {
1050
+ const timestamp = deps.clock.now().toISOString();
1051
+ exitedInstances.add(instance);
1052
+ if (retiredInstances.has(instance) || currentInstance.get(spec.name) !== instance) {
1053
+ // An exit this plane caused (a restart stopped the member) or one that
1054
+ // arrived after a replacement already took over. Recorded for the restart
1055
+ // that is waiting on it, and otherwise ignored: patching the manifest or
1056
+ // printing a crash banner here would report a healthy replacement as dead.
1057
+ retiredExits.set(instance, { code, signal });
1058
+ emitMemberEvent(deps.sinks, {
1059
+ member: spec.name,
1060
+ kind: "exit",
1061
+ timestamp,
1062
+ logPath: spec.logPath,
1063
+ detail: `${signal !== null ? `signal ${signal}` : `code ${code ?? "unknown"}`} (replaced by a member restart)`,
1064
+ });
1065
+ return;
1066
+ }
1067
+ patchMember(spec.name, { state: "exited", exitCode: code, exitSignal: signal });
1068
+ // Enrolled synchronously so simultaneous exits queue in the order their
1069
+ // patches were applied, and tracked so the write is awaited — never
1070
+ // abandoned — before the runtime returns.
1071
+ trackPersist(persist());
870
1072
  emitMemberEvent(deps.sinks, {
871
1073
  member: spec.name,
872
- kind: "start",
873
- timestamp: deps.clock.now().toISOString(),
1074
+ kind: "exit",
1075
+ timestamp,
874
1076
  logPath: spec.logPath,
875
- detail: describePlaneMember(spec),
1077
+ detail: signal !== null ? `signal ${signal}` : `code ${code ?? "unknown"}`,
876
1078
  });
877
- // Exit watching is registered BEFORE readiness so a member that dies during
878
- // its readiness window is reported as a crash rather than as a silent
879
- // readiness timeout.
880
- child.on("close", ((code, signal) => {
881
- const timestamp = deps.clock.now().toISOString();
882
- patchMember(spec.name, { state: "exited", exitCode: code, exitSignal: signal });
883
- // Enrolled synchronously so simultaneous exits queue in the order their
884
- // patches were applied, and tracked so the write is awaited — never
885
- // abandoned — before the runtime returns.
886
- trackPersist(persist());
887
- emitMemberEvent(deps.sinks, {
1079
+ if (!shuttingDown && !intentionallyStopped.has(spec.name)) {
1080
+ reportMemberCrash(deps.sinks, {
888
1081
  member: spec.name,
889
- kind: "exit",
890
1082
  timestamp,
1083
+ exitCode: code,
1084
+ exitSignal: signal,
891
1085
  logPath: spec.logPath,
892
- detail: signal !== null ? `signal ${signal}` : `code ${code ?? "unknown"}`,
1086
+ ...(manifest.snapshotSha !== undefined ? { pinnedSha: manifest.snapshotSha } : {}),
893
1087
  });
894
- if (!shuttingDown && !intentionallyStopped.has(spec.name)) {
895
- reportMemberCrash(deps.sinks, {
896
- member: spec.name,
897
- timestamp,
898
- exitCode: code,
899
- exitSignal: signal,
900
- logPath: spec.logPath,
901
- });
902
- }
903
- // NOTHING follows. No respawn, no retry, no backoff timer, and no
904
- // shutdown of the survivors: one member's crash is one member's crash.
905
- }));
906
- child.on("error", (() => {
907
- /* surfaced through the close handler's exit status */
908
- }));
909
- if (spec.readiness) {
910
- let resolvedInstanceId;
911
- if (spec.readiness.kind === "process-heartbeat") {
912
- // Resolved HERE, not on the roster: an executor lane's identity is
913
- // minted before the spawn, but the local reconciler's is
914
- // `<hostname>:<pid>` and the pid only exists now. The window between
915
- // `spawn` returning and this probe is milliseconds, and the child cannot
916
- // publish inside it — it has to open its database pools and start its
917
- // scheduler first — but it is a window, and it is documented rather than
918
- // asserted away. Supplying the identity TO the child would be race-free
919
- // and would change a durable heartbeat writer, which this work forbids.
920
- resolvedInstanceId =
921
- spec.readiness.instanceId ??
922
- resolveMemberInstanceId(spec.env, child.pid, deps.hostname);
923
- // Announced BEFORE the wait, not after it. This is the slowest step in
924
- // bring-up by an order of magnitude, and an operator watching a silent
925
- // terminal for two minutes has no way to tell waiting from wedged. The
926
- // wording is the status itself — no colour, no spinner, no symbol — so
927
- // it survives a pipe, a CI log, and a screen reader identically. The
928
- // resolved identity is NOT rendered: it is scoping, not news.
929
- emitMemberEvent(deps.sinks, {
930
- member: spec.name,
931
- kind: "start",
932
- timestamp: deps.clock.now().toISOString(),
933
- logPath: spec.logPath,
934
- detail: `Starting — awaiting this member's first post-start ${spec.readiness.component} ` +
935
- `heartbeat (expected within ${Math.round(spec.readiness.timeoutMs / 1000)}s; watch ${spec.logPath})`,
936
- });
937
- }
938
- // `memberExited` is read through the closure the `close` handler patches,
939
- // so a child that dies mid-wait ends the wait immediately instead of
940
- // burning the remaining budget and reporting a timeout it did not have.
941
- const outcome = await waitForMemberReady(spec, deps, 250, () => !memberExited(spec.name), {
942
- baseline,
943
- instanceId: resolvedInstanceId,
944
- elapsedMs: baselineElapsedMs,
1088
+ }
1089
+ // NOTHING follows. No respawn, no retry, no backoff timer, and no
1090
+ // shutdown of the survivors: one member's crash is one member's crash.
1091
+ }));
1092
+ child.on("error", (() => {
1093
+ /* surfaced through the close handler's exit status */
1094
+ }));
1095
+ if (spec.readiness) {
1096
+ let resolvedInstanceId;
1097
+ if (spec.readiness.kind === "process-heartbeat") {
1098
+ // Resolved HERE, not on the roster: an executor lane's identity is
1099
+ // minted before the spawn, but the local reconciler's is
1100
+ // `<hostname>:<pid>` and the pid only exists now. The window between
1101
+ // `spawn` returning and this probe is milliseconds, and the child cannot
1102
+ // publish inside it — it has to open its database pools and start its
1103
+ // scheduler first — but it is a window, and it is documented rather than
1104
+ // asserted away. Supplying the identity TO the child would be race-free
1105
+ // and would change a durable heartbeat writer, which this work forbids.
1106
+ resolvedInstanceId =
1107
+ spec.readiness.instanceId ??
1108
+ resolveMemberInstanceId(spec.env, childPid, deps.hostname);
1109
+ // Announced BEFORE the wait, not after it. This is the slowest step in
1110
+ // bring-up by an order of magnitude, and an operator watching a silent
1111
+ // terminal for two minutes has no way to tell waiting from wedged. The
1112
+ // wording is the status itself — no colour, no spinner, no symbol — so
1113
+ // it survives a pipe, a CI log, and a screen reader identically. The
1114
+ // resolved identity is NOT rendered: it is scoping, not news.
1115
+ emitMemberEvent(deps.sinks, {
1116
+ member: spec.name,
1117
+ kind: "start",
1118
+ timestamp: deps.clock.now().toISOString(),
1119
+ logPath: spec.logPath,
1120
+ detail: `Starting — awaiting this member's first post-start ${spec.readiness.component} ` +
1121
+ `heartbeat (expected within ${Math.round(spec.readiness.timeoutMs / 1000)}s; watch ${spec.logPath})`,
945
1122
  });
946
- if (outcome.kind !== "ready") {
947
- return finishPartialStart(spec.name, describeReadinessFailure(spec, outcome), {
1123
+ }
1124
+ // `memberExited` is read through the closure the `close` handler patches,
1125
+ // so a child that dies mid-wait ends the wait immediately instead of
1126
+ // burning the remaining budget and reporting a timeout it did not have.
1127
+ // A restart's wait also ends when the plane begins shutting down.
1128
+ const outcome = await waitForMemberReady(spec, deps, 250, () => !memberExited(spec.name) && !(mode === "restart" && shuttingDown), {
1129
+ baseline,
1130
+ instanceId: resolvedInstanceId,
1131
+ elapsedMs: baselineElapsedMs,
1132
+ });
1133
+ if (outcome.kind !== "ready") {
1134
+ return {
1135
+ ok: false,
1136
+ reason: describeReadinessFailure(spec, outcome),
1137
+ annotate: {
948
1138
  log: opened.stream,
949
1139
  failure: spec.readiness.kind === "process-heartbeat"
950
1140
  ? PLANE_MEMBER_STARTUP_FAILURES.heartbeatReadinessTimeout
951
1141
  : PLANE_MEMBER_STARTUP_FAILURES.readinessTimeout,
952
- });
1142
+ },
1143
+ restartFailure: "readiness",
1144
+ spawnedPid: childPid,
1145
+ };
1146
+ }
1147
+ }
1148
+ patchMember(spec.name, { state: "ready" });
1149
+ await persist();
1150
+ emitMemberEvent(deps.sinks, {
1151
+ member: spec.name,
1152
+ kind: "ready",
1153
+ timestamp: deps.clock.now().toISOString(),
1154
+ logPath: spec.logPath,
1155
+ });
1156
+ return { ok: true, pid: childPid };
1157
+ };
1158
+ // ---- Spawn one cohort of members, in order ------------------------------
1159
+ //
1160
+ // BAPI-1102 extracted this from a single top-level loop so the two-phase
1161
+ // lifecycle can run it three times — control plane, scoped lanes, observer —
1162
+ // against the SAME closure state. Every property the single pass had is
1163
+ // preserved by construction rather than re-established: one `started` list, one
1164
+ // serialized manifest writer, one rollback, one set of member events.
1165
+ //
1166
+ // Returns `null` on success, or the terminal failure result. A cohort failure
1167
+ // rolls back EVERY member started so far, across all cohorts, which is the
1168
+ // behavior a partially-started plane needs and the reason `started` is shared.
1169
+ const startMembers = async (cohort) => {
1170
+ for (const spec of cohort) {
1171
+ const launched = await launchMember(spec, "start");
1172
+ if (!launched.ok) {
1173
+ const failed = await finishPartialStart(spec.name, launched.reason, launched.annotate);
1174
+ if (launched.foreignReconciler) {
1175
+ deps.sinks.stdout(`${PLANE_FOREIGN_RECONCILER_MARKER}:${lastRollbackConfirmed ? "rolled-back" : "rollback-incomplete"}`);
1176
+ }
1177
+ return failed;
1178
+ }
1179
+ }
1180
+ return null;
1181
+ };
1182
+ // ---- Member-scoped restart (BAPI-1362) ------------------------------------
1183
+ //
1184
+ // One requested replacement, never a loop. The request arrives through
1185
+ // `restart.ts` (a file plus one signal); this section is the detached runtime's
1186
+ // half. Its invariants, each of which a test pins:
1187
+ //
1188
+ // * only `server`, `worker`, or `observer`, and only a member this plane lists;
1189
+ // * NEVER `shutdownPlane`, `stopBoundRun`, or a run-scoped executor shutdown —
1190
+ // restarting a member must not stop the run it serves;
1191
+ // * a pinned plane revalidates its snapshot first and REFUSES on a moved or
1192
+ // missing one, rather than upgrading to the operator checkout's new HEAD;
1193
+ // * serialized: requests are handled one at a time, in arrival order;
1194
+ // * exactly one replacement per request — no retry, no respawn loop, no
1195
+ // automatic second attempt on failure;
1196
+ // * a failure is REPORTED through the result file and the manifest, and the
1197
+ // rest of the plane is left exactly as it was.
1198
+ let runtimeSteady = false;
1199
+ let restartTail = Promise.resolve();
1200
+ const finishRestart = async (request, outcome) => {
1201
+ const result = {
1202
+ version: 1,
1203
+ planeId: request.planeId,
1204
+ requestId: request.requestId,
1205
+ member: request.member,
1206
+ outcome: outcome.outcome,
1207
+ ...(outcome.reason !== undefined ? { reason: outcome.reason } : {}),
1208
+ pid: outcome.pid ?? null,
1209
+ completedAt: deps.clock.now().toISOString(),
1210
+ message: outcome.message,
1211
+ };
1212
+ try {
1213
+ await writePlaneRestartResult(repoRoot, result, deps.fs);
1214
+ }
1215
+ catch {
1216
+ // Fixed prose: the thrown value can carry a path, and this is a terminal.
1217
+ deps.sinks.stderr("A plane restart result could not be published.");
1218
+ }
1219
+ };
1220
+ /**
1221
+ * Stop the member being replaced, bounded, and confirm it is gone.
1222
+ *
1223
+ * SIGTERM to the child's own pid, a grace window on the injected clock, then
1224
+ * SIGKILL — the same escalation `shutdownPlane` uses, applied to ONE child. The
1225
+ * instance is retired first so its `close` handler neither patches the manifest
1226
+ * nor prints a crash banner. Returns false, having un-retired it, when the
1227
+ * member would not exit: replacing a process that is still running would leave
1228
+ * two of them.
1229
+ */
1230
+ const stopMemberForRestart = async (name, instance, previous) => {
1231
+ const pid = memberPids.get(name) ?? previous.pid;
1232
+ if (exitedInstances.has(instance) || pid === null) {
1233
+ retiredInstances.add(instance);
1234
+ return true;
1235
+ }
1236
+ retiredInstances.add(instance);
1237
+ patchMember(name, { state: "restarting" });
1238
+ await persist();
1239
+ const gone = () => exitedInstances.has(instance) || deps.proc.isAlive(pid) === "dead";
1240
+ deps.proc.signal(pid, "SIGTERM");
1241
+ let waited = 0;
1242
+ while (waited < PLANE_SHUTDOWN_GRACE_MS && !gone()) {
1243
+ await deps.clock.sleep(PLANE_SHUTDOWN_POLL_MS);
1244
+ waited += PLANE_SHUTDOWN_POLL_MS;
1245
+ }
1246
+ if (!gone()) {
1247
+ deps.proc.signal(pid, "SIGKILL");
1248
+ let killWaited = 0;
1249
+ while (killWaited < PLANE_SHUTDOWN_KILL_WAIT_MS && !gone()) {
1250
+ await deps.clock.sleep(PLANE_SHUTDOWN_POLL_MS);
1251
+ killWaited += PLANE_SHUTDOWN_POLL_MS;
1252
+ }
1253
+ }
1254
+ if (gone())
1255
+ return true;
1256
+ // Still running: put its record back and let its own exit be reported normally.
1257
+ retiredInstances.delete(instance);
1258
+ patchMember(name, { state: previous.state });
1259
+ return false;
1260
+ };
1261
+ const performRestart = async (request) => {
1262
+ const { member } = request;
1263
+ const refuse = (reason, message) => finishRestart(request, { outcome: "refused", reason, message });
1264
+ if (request.planeId !== deps.planeId) {
1265
+ return refuse("identity-mismatch", "the request names a different plane");
1266
+ }
1267
+ if (shuttingDown)
1268
+ return refuse("plane-stopping", "the plane is shutting down");
1269
+ if (!runtimeSteady)
1270
+ return refuse("plane-starting", "the plane has not finished starting");
1271
+ const spec = memberSpecs.get(member);
1272
+ const previous = manifest.members.find((m) => m.name === member);
1273
+ if (spec === undefined || previous === undefined) {
1274
+ return refuse("member-not-in-plane", `the plane does not list a ${member} member`);
1275
+ }
1276
+ // Launch identity. A pinned plane re-checks its snapshot against the SHA the
1277
+ // manifest recorded — never against the operator checkout, which has moved.
1278
+ // A legacy plane has no snapshot, so it is restarted from the spec it was
1279
+ // originally launched with, and only when the directory that spec names is
1280
+ // still there.
1281
+ const pinned = planeManifestSnapshot(manifest);
1282
+ if (pinned.kind === "pinned") {
1283
+ if (deps.revalidateSnapshot !== undefined) {
1284
+ const revalidated = await deps.revalidateSnapshot(pinned.snapshot);
1285
+ if (!revalidated.ok) {
1286
+ return refuse("snapshot-invalid", `the plane's pinned code could not be revalidated, so nothing was stopped: ${revalidated.message}`);
953
1287
  }
954
1288
  }
955
- patchMember(spec.name, { state: "ready" });
1289
+ }
1290
+ else if (!isCanonicalAbsolutePath(spec.cwd) ||
1291
+ (deps.checkLaunchDirectory !== undefined && !(await deps.checkLaunchDirectory(spec.cwd)))) {
1292
+ return refuse("legacy-unverifiable", "this plane is not pinned to a code snapshot and the directory its members were launched from could not be verified, so nothing was stopped. Between runs, `plane down && plane up` moves the plane to new code");
1293
+ }
1294
+ const oldInstance = currentInstance.get(member) ?? 0;
1295
+ const stopped = await stopMemberForRestart(member, oldInstance, previous);
1296
+ if (!stopped) {
1297
+ patchMember(member, { restartFailure: "stop-incomplete" });
956
1298
  await persist();
957
- emitMemberEvent(deps.sinks, {
958
- member: spec.name,
959
- kind: "ready",
960
- timestamp: deps.clock.now().toISOString(),
961
- logPath: spec.logPath,
1299
+ return finishRestart(request, {
1300
+ outcome: "failed",
1301
+ reason: "stop-incomplete",
1302
+ pid: null,
1303
+ message: `the running ${member} did not exit after SIGTERM and SIGKILL, so no replacement was started`,
962
1304
  });
963
1305
  }
964
- return null;
1306
+ const launched = await launchMember(spec, "restart");
1307
+ if (launched.ok) {
1308
+ patchMember(member, { restartFailure: undefined });
1309
+ await persist();
1310
+ return finishRestart(request, {
1311
+ outcome: "restarted",
1312
+ pid: launched.pid,
1313
+ message: `${member} was replaced from its recorded launch and passed its readiness check`,
1314
+ });
1315
+ }
1316
+ if (launched.annotate !== undefined) {
1317
+ writeMemberStartupFailure(launched.annotate.log, member, launched.annotate.failure);
1318
+ }
1319
+ if (launched.spawnedPid === null) {
1320
+ // Nothing was spawned, so the member has no running process: record the old
1321
+ // one's exit and the reason the replacement never started.
1322
+ const exit = retiredExits.get(oldInstance);
1323
+ patchMember(member, {
1324
+ state: "exited",
1325
+ exitCode: exit?.code ?? previous.exitCode,
1326
+ exitSignal: exit?.signal ?? previous.exitSignal,
1327
+ restartFailure: launched.restartFailure,
1328
+ });
1329
+ }
1330
+ else {
1331
+ // A replacement EXISTS (launchMember already recorded its pid and state). It
1332
+ // is deliberately left running: killing it would be a second action nobody
1333
+ // requested, and it may yet come up.
1334
+ patchMember(member, { restartFailure: launched.restartFailure });
1335
+ }
1336
+ await persist();
1337
+ return finishRestart(request, {
1338
+ outcome: "failed",
1339
+ reason: launched.restartFailure,
1340
+ pid: launched.spawnedPid,
1341
+ message: launched.reason,
1342
+ });
1343
+ };
1344
+ const onRestartSignal = () => {
1345
+ // Chained so requests are handled one at a time, in order. A rejection is
1346
+ // contained inside the link: one failed restart must not poison the next.
1347
+ restartTail = restartTail.then(async () => {
1348
+ const consumed = await consumePlaneRestartRequest(repoRoot, deps.fs);
1349
+ if (consumed.kind === "none")
1350
+ return; // a duplicate signal, or a request already handled
1351
+ if (consumed.kind === "invalid") {
1352
+ deps.sinks.stderr("A plane restart request could not be read and was discarded.");
1353
+ return;
1354
+ }
1355
+ try {
1356
+ await performRestart(consumed.request);
1357
+ }
1358
+ catch {
1359
+ deps.sinks.stderr("A plane member restart failed unexpectedly.");
1360
+ await finishRestart(consumed.request, {
1361
+ outcome: "failed",
1362
+ pid: null,
1363
+ message: "the restart failed unexpectedly; inspect `plane status`",
1364
+ });
1365
+ }
1366
+ });
965
1367
  };
1368
+ // Registered BEFORE any member is spawned, because the signal's default action
1369
+ // terminates the process: a runtime that is up at all must already handle it.
1370
+ // A request that arrives before the plane is steady is answered with a refusal.
1371
+ deps.onSignal(PLANE_RESTART_SIGNAL, onRestartSignal);
966
1372
  // ---- Phase one: the roster this runtime was handed ------------------------
967
1373
  const firstCohort = await startMembers(roster);
968
1374
  if (firstCohort !== null)
@@ -1079,6 +1485,9 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
1079
1485
  // over one that is still half-built.
1080
1486
  deps.sinks.stdout(PLANE_READY_MARKER);
1081
1487
  }
1488
+ // From here the plane is steady — fully up, or held with its control plane — and
1489
+ // a member restart may proceed (BAPI-1362).
1490
+ runtimeSteady = true;
1082
1491
  // ---- Attended lifetime ---------------------------------------------------
1083
1492
  const exitCode = await new Promise((resolve) => {
1084
1493
  let resolved = false;
@@ -1155,6 +1564,7 @@ export async function runPlaneRuntime(repoRoot, roster, deps) {
1155
1564
  proc: deps.proc,
1156
1565
  clock: deps.clock,
1157
1566
  selfPid: deps.selfPid,
1567
+ ...(deps.releaseSnapshot ? { releaseSnapshot: deps.releaseSnapshot } : {}),
1158
1568
  });
1159
1569
  lastRollbackConfirmed = result.ok;
1160
1570
  if (!result.ok)
@@ -1503,5 +1913,6 @@ export async function handleSupervisorSignal(repoRoot, deps) {
1503
1913
  proc: deps.proc,
1504
1914
  clock: deps.clock,
1505
1915
  selfPid: deps.selfPid,
1916
+ ...(deps.releaseSnapshot ? { releaseSnapshot: deps.releaseSnapshot } : {}),
1506
1917
  });
1507
1918
  }