@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.
- package/README.md +269 -82
- package/build/agent-capabilities/probes.js +15 -5
- package/build/agent-launchers/claude-executor-adapter.js +55 -34
- package/build/agent-launchers/executor-adapter.js +34 -4
- package/build/agents.generated.js +1 -1
- package/build/bounded-wait.js +67 -10
- package/build/bridge-config.js +104 -0
- package/build/codex-bridge-profile.js +10 -1
- package/build/commands.generated.js +12 -12
- package/build/conductor/bridge-api-client.js +372 -10
- package/build/conductor/bring-up-facts.js +1 -1
- package/build/conductor/doctor.js +3 -3
- package/build/conductor/epic-child-continuation.js +61 -34
- package/build/conductor/epic-implementer-cli.js +34 -6
- package/build/conductor/errors.js +4 -3
- package/build/conductor/guided-repair.js +327 -0
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/merge-execution-contract.js +27 -8
- package/build/conductor/merge-execution-eligibility.js +3 -2
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +46 -9
- package/build/conductor/readiness-sections.js +41 -6
- package/build/conductor/readiness.js +204 -30
- package/build/conductor/recovery-cli.js +703 -20
- package/build/conductor/recovery-operations.js +392 -3
- package/build/conductor/repair-contract.js +112 -3
- package/build/conductor/repository-workflow-remedy.js +125 -0
- package/build/conductor/tools.js +2 -2
- package/build/conductor-bundle-artifacts.js +8 -0
- package/build/credential-store.js +5 -4
- package/build/diagnostic-text.js +36 -0
- package/build/docs.generated.js +8 -3
- package/build/doctor.js +70 -10
- package/build/drive-epic-key.js +6 -5
- package/build/drive-epic.js +479 -131
- package/build/epic-coherence-input.js +2 -1
- package/build/epic-implementer-bin.js +30 -26
- package/build/epic-implementer-bundle-cli.js +2 -2
- package/build/epic-launch-diagnostic.js +31 -0
- package/build/epic-launch-profile.js +44 -14
- package/build/executor/cli.js +3 -2
- package/build/executor/completion-contract.js +48 -5
- package/build/executor/completion-supervision.js +9 -0
- package/build/executor/correction-dispatch.js +60 -0
- package/build/executor/env.js +25 -0
- package/build/executor/heartbeat.js +1 -1
- package/build/executor/http-client.js +35 -0
- package/build/executor/integration-job.js +6 -1
- package/build/executor/job-errors.js +38 -4
- package/build/executor/job-log-registry.js +340 -73
- package/build/executor/job-runner.js +485 -74
- package/build/executor/job-types.js +8 -13
- package/build/executor/observation.js +6 -1
- package/build/executor/preflight.js +136 -10
- package/build/executor/prompt-spec.js +113 -9
- package/build/executor/required-work-ledger.js +6 -1
- package/build/executor/resume-pre-spawn.js +2 -0
- package/build/executor/runner.js +31 -2
- package/build/executor/service-unit.js +7 -1
- package/build/executor/stale-artifacts.js +5 -4
- package/build/executor/types.js +1 -0
- package/build/executor/watch-cli.js +35 -8
- package/build/executor/worker-command.js +43 -43
- package/build/executor/worker-finalization.js +161 -43
- package/build/executor/worker-guard-hook.js +152 -12
- package/build/executor/worker-log.js +137 -70
- package/build/executor/worktree-gc.js +30 -6
- package/build/executor/worktree.js +82 -1
- package/build/index.js +482 -660
- package/build/init.js +107 -4
- package/build/install-bridge-conductor.js +8 -6
- package/build/install-bridge.js +287 -23
- package/build/install-doctor.js +172 -30
- package/build/install-reexec.js +69 -7
- package/build/macos-terminal-permission-guidance.js +16 -0
- package/build/model-thresholds-tool.js +185 -0
- package/build/phase-context-git-probe.js +68 -0
- package/build/phase-result-artifacts.js +173 -5
- package/build/pipelines.generated.js +19 -25
- package/build/plane/alembic-head.js +12 -3
- package/build/plane/base-worktree-guard.js +147 -0
- package/build/plane/cli.js +217 -17
- package/build/plane/code-snapshot.js +468 -0
- package/build/plane/defaults.js +48 -3
- package/build/plane/endpoint.js +41 -0
- package/build/plane/manifest.js +120 -1
- package/build/plane/member-roster.js +94 -19
- package/build/plane/preflight.js +30 -2
- package/build/plane/process-cwd.js +67 -0
- package/build/plane/restart.js +542 -0
- package/build/plane/shutdown.js +31 -0
- package/build/plane/status.js +145 -6
- package/build/plane/supervisor.js +588 -177
- package/build/plane/types.js +54 -2
- package/build/plane/worker-exit-code.js +2 -0
- package/build/printed-remedy-commands.js +37 -0
- package/build/project-root.js +99 -0
- package/build/readiness-check.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +22 -0
- package/build/review-tickets.js +156 -13
- package/build/setup-epic.js +224 -127
- package/build/sfcc/config.js +9 -0
- package/build/sfcc/credentials.js +100 -38
- package/build/sfcc/hostname.js +67 -0
- package/build/sfcc/permissions.js +17 -13
- package/build/sfcc/register.js +1 -1
- package/build/sfcc/setup-status.js +2 -0
- package/build/sfcc/tool-wrapper.js +41 -43
- package/build/sfcc/tools-configured.js +96 -0
- package/build/sfcc/write-grants.js +17 -11
- package/build/sfcc/write-result.js +4 -3
- package/build/sfcc/writes-site-preference.js +20 -8
- package/build/start-tickets.js +332 -79
- package/build/tool-error-envelope.js +61 -0
- package/build/upgrade-cli.js +109 -24
- package/build/version.generated.js +3 -3
- package/build/visual-diff.js +22 -1
- package/build/windows-npm-entry.js +103 -0
- package/build/worker-guard-hook-bin.js +3 -5
- package/build/worker-guard-hook-main.js +47 -0
- package/build/worktree-core.js +136 -9
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
- package/docs/EPIC-IMPLEMENTER.md +662 -0
- package/docs/install/mcp-tool-integrations.md +25 -15
- package/docs/install/sfcc-integration.md +21 -8
- package/package.json +1 -1
- package/pipelines/implement-ticket.json +1 -1
- package/pipelines/plan-epic.json +0 -5
- package/pipelines/review-ticket.json +1 -1
- package/docs/CONDUCTOR.md +0 -605
- 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(() =>
|
|
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) =>
|
|
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
|
-
// ----
|
|
831
|
+
// ---- Member instances (BAPI-1362) ----------------------------------------
|
|
761
832
|
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
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
|
-
//
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
//
|
|
781
|
-
//
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
802
|
-
readiness,
|
|
803
|
-
|
|
933
|
+
baseUrl: readiness.baseUrl,
|
|
934
|
+
repoName: readiness.repoName,
|
|
935
|
+
apiKey: spec.env.BAPI_API_KEY ?? "",
|
|
804
936
|
});
|
|
805
|
-
if (
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
869
|
-
|
|
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: "
|
|
873
|
-
timestamp
|
|
1074
|
+
kind: "exit",
|
|
1075
|
+
timestamp,
|
|
874
1076
|
logPath: spec.logPath,
|
|
875
|
-
detail:
|
|
1077
|
+
detail: signal !== null ? `signal ${signal}` : `code ${code ?? "unknown"}`,
|
|
876
1078
|
});
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
1086
|
+
...(manifest.snapshotSha !== undefined ? { pinnedSha: manifest.snapshotSha } : {}),
|
|
893
1087
|
});
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
//
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
|
|
947
|
-
|
|
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
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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
|
-
|
|
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
|
}
|