@codeyam-editor/codeyam-editor 0.1.0-staging.gbaebe71 → 0.1.0-staging.gde2039f

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 (37) hide show
  1. package/npm/cloud.js +610 -72
  2. package/npm/compose-conflict.js +59 -0
  3. package/npm/fleet-add-failure.js +181 -27
  4. package/npm/fleet-auth-health.js +26 -5
  5. package/npm/fleet-build-data.js +10 -2
  6. package/npm/fleet-cleanup.js +129 -0
  7. package/npm/fleet-destroy-safety.js +182 -13
  8. package/npm/fleet-job-badge.js +28 -12
  9. package/npm/fleet-memory.js +281 -8
  10. package/npm/fleet-probe-transport.js +155 -0
  11. package/npm/fleet-public-url.js +94 -8
  12. package/npm/fleet-reachability.js +80 -0
  13. package/npm/fleet-revision.js +104 -0
  14. package/npm/fleet-running-plan.js +37 -7
  15. package/npm/fleet-vm-access.js +39 -0
  16. package/npm/fleet-vm-probe-target.js +95 -0
  17. package/npm/ingress-invoke.js +52 -8
  18. package/npm/launch-config.js +120 -4
  19. package/npm/scenario-check.js +52 -18
  20. package/npm/scenario-playwright.js +44 -10
  21. package/package.json +5 -5
  22. package/ui/dist/assets/{ContainerInspector-qJy9NyEZ.js → ContainerInspector-Cu1tSN-o.js} +1 -1
  23. package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
  24. package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
  25. package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
  26. package/ui/dist/assets/{ScenarioDataPanelFullPage-CrNwRIZM.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
  27. package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
  28. package/ui/dist/assets/index-CjxWhkSy.css +1 -0
  29. package/ui/dist/assets/{index-Mui6rJUD.js → index-VuyqBbGJ.js} +2 -2
  30. package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
  31. package/ui/dist/index.html +2 -2
  32. package/ui/dist/assets/EditorShell-BM92Hqc1.js +0 -252
  33. package/ui/dist/assets/ProjectLauncherScreen-BLHSXp_H.js +0 -1
  34. package/ui/dist/assets/ScenarioDataPanel-BbkUcHum.js +0 -5
  35. package/ui/dist/assets/ScenarioRenderer-mB7opGvg.js +0 -21
  36. package/ui/dist/assets/index-BygCiJSb.css +0 -1
  37. package/ui/dist/assets/useServerIdentity-COXGxKUT.js +0 -1
package/npm/cloud.js CHANGED
@@ -105,6 +105,10 @@ const {
105
105
  // edit (the invisible-VM gap improve38 closes). `readRoster` enumerates the
106
106
  // fleet for `reconcile-ingress`.
107
107
  const { addToRoster, readRoster } = require("./fleet-roster");
108
+ // Tells a docker container-name conflict (a concurrent run already started this
109
+ // container) apart from a real `docker compose up -d` failure, so the abort path
110
+ // can converge instead of escalating to instance deletion.
111
+ const { classifyComposeUpFailure } = require("./compose-conflict");
108
112
 
109
113
  // =============================================================================
110
114
  // Pure helpers — every export here is unit-tested in cloud.test.js without
@@ -845,6 +849,79 @@ function parseEditorComposeState(stdout) {
845
849
  return null;
846
850
  }
847
851
 
852
+ /** Decide whether `cloud:up`'s abort handler may delete the instance it is
853
+ * unwinding. Pure `{ createdThisRun, containerUp, rostered, probeInconclusive }
854
+ * → 'delete' | 'keep'`, matching the `reapVerdict` / `reattachVerdict` /
855
+ * `reconcileRosteredVm` pattern in npm/fleet-cleanup.js, so the whole matrix is
856
+ * exhaustively testable without a VM.
857
+ *
858
+ * `createdThisRun` stays a NECESSARY condition — never delete an instance this
859
+ * run did not create — but 2026-08-07 proved it is not SUFFICIENT. It answers
860
+ * "did I create this?", not "is it safe to destroy?", and in a concurrent
861
+ * provision race the losing run legitimately owns the flag while the winner has
862
+ * already brought the VM up and rostered it. Deleting there destroyed VM-4 and
863
+ * VM-5. So authorization also requires CURRENT-STATE evidence that the VM is
864
+ * out of service:
865
+ *
866
+ * - `containerUp` — the editor container is running (per the compose-ps probe).
867
+ * A VM serving an editor is in use by definition.
868
+ * - `rostered` — the VM is in the fleet roster, the operator-visible source of
869
+ * truth. Note this is true whether the winning run or THIS run rostered it;
870
+ * either way the VM is visible in the dashboard and removable in one command,
871
+ * so it is not the silent orphan teardown exists to prevent.
872
+ * - `probeInconclusive` — liveness could not be determined. This must fail
873
+ * SAFE: a failing probe is how we reach the abort handler in the first place,
874
+ * so it can never count as evidence the VM is dead. An orphan costs one
875
+ * `npm run cloud:destroy`; a wrong delete costs a 15-40 minute rebuild plus
876
+ * any unpushed work on the VM. */
877
+ function abortTeardownDecision({ createdThisRun, containerUp, rostered, probeInconclusive } = {}) {
878
+ if (!createdThisRun) return "keep";
879
+ if (probeInconclusive) return "keep";
880
+ if (containerUp) return "keep";
881
+ if (rostered) return "keep";
882
+ return "delete";
883
+ }
884
+
885
+ /** Gather the CURRENT-STATE inputs {@link abortTeardownDecision} needs, so the
886
+ * abort handler reads gather → decide → act instead of inlining probes among
887
+ * the delete calls.
888
+ *
889
+ * Only runs the probes when `createdThisRun` is true — the decision is `keep`
890
+ * regardless otherwise, and an abort path should not spend SSH round trips
891
+ * proving something it already knows. In that case it returns the
892
+ * probe-inconclusive shape, which is the safe default at every layer.
893
+ *
894
+ * Both lookups are wrapped: a throwing probe or an unreadable roster must fail
895
+ * SAFE (toward `keep`), never take a second exception path into the delete.
896
+ * `deps` is injectable so the matrix is unit-tested without a VM. */
897
+ function probeAbortTeardownState(cfg, createdThisRun, deps = {}) {
898
+ if (!createdThisRun) {
899
+ return { containerUp: false, rostered: false, probeInconclusive: true };
900
+ }
901
+ const probe = deps.probeEditorComposeState || probeEditorComposeState;
902
+ const roster = deps.readRoster || readRoster;
903
+ const resolveVm = deps.resolveFleetVmNumber || resolveFleetVmNumber;
904
+ const log = deps.log || ((m) => console.error(m));
905
+
906
+ let liveness = { state: null, inconclusive: true };
907
+ try {
908
+ liveness = probe(cfg);
909
+ } catch (e) {
910
+ log(` (liveness probe failed: ${e && e.message})`);
911
+ }
912
+ let rostered = false;
913
+ try {
914
+ const vm = resolveVm(cfg.instanceName);
915
+ rostered = vm != null && (roster() || []).includes(vm);
916
+ } catch { /* unreadable roster — treated as not-rostered; liveness still guards */ }
917
+
918
+ return {
919
+ containerUp: liveness.state === "running",
920
+ rostered,
921
+ probeInconclusive: liveness.inconclusive,
922
+ };
923
+ }
924
+
848
925
  /** Translate one of the wrapper's verbs into the exact `gcloud` argv. The
849
926
  * switch's job is to keep every per-verb command shape in one reviewable
850
927
  * place — tests assert the arrays without ever spawning a process. */
@@ -978,6 +1055,42 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
978
1055
  // yet comfortably under the 600000 ms outer reconcile budget.
979
1056
  const GCLOUD_CALL_TIMEOUT_MS = 120000;
980
1057
 
1058
+ // The project bootstrap is NOT a probe: it runs `git clone` + `npm install` in
1059
+ // the repo root and every `apps[].dir`, each package's `postinstall`, then
1060
+ // `npm run setup` — minutes of remote work on a heavy repo. It once ran as ONE
1061
+ // blocking `runGcloud("ssh-script")` call, so it inherited GCLOUD_CALL_TIMEOUT_MS
1062
+ // (a budget whose own comment scopes it to "any single describe/update/SSH
1063
+ // probe"). That SIGTERMd the gcloud child mid-clone; `spawnSync` then returned
1064
+ // `status: null`, which `!== 0`, so the timeout was indistinguishable from a
1065
+ // genuine remote failure and the caller's abort path DELETED A COMPLETELY
1066
+ // HEALTHY VM.
1067
+ //
1068
+ // Observed twice: 2026-07-28 on client repo `lets-go-compost-portal` (a root
1069
+ // postinstall of `playwright install chromium`, ~293 MB), and 2026-07-29
1070
+ // provisioning six self-host `codeyam-editor` VMs, whose clone alone exceeds the
1071
+ // probe budget — all six were built, torn down mid-clone, and reported as
1072
+ // "scripts/gcp-project-bootstrap.sh failed on the VM".
1073
+ //
1074
+ // The install is now DETACHED (projectBootstrapLaunchCommand + a re-attaching
1075
+ // follow loop, outcome read from VM-side state), so no single call carries the
1076
+ // whole install any more. This constant is therefore the OVERALL deadline across
1077
+ // re-attaches — the point at which we stop waiting on a still-healthy VM and say
1078
+ // exactly that. Deliberately well above the outer reconcile budget: a long remote
1079
+ // install is not a step in a sweep, so the sweep-relative ceiling that governs
1080
+ // GCLOUD_CALL_TIMEOUT_MS does not apply to it.
1081
+ const PROJECT_BOOTSTRAP_TIMEOUT_MS = 40 * 60 * 1000;
1082
+
1083
+ /** Whether a `spawnSync` result is a LOCAL timeout kill rather than a remote
1084
+ * failure. `spawnSync` reports a timeout as `status: null` plus an ETIMEDOUT
1085
+ * error, so a bare `status !== 0` check conflates the two — which is what made
1086
+ * a timed-out bootstrap look like a broken one. Pure. */
1087
+ function isSpawnTimeout(result) {
1088
+ if (!result) return false;
1089
+ if (result.status !== null && result.status !== undefined) return false;
1090
+ const code = result.error && result.error.code;
1091
+ return code === "ETIMEDOUT" || result.signal === "SIGTERM";
1092
+ }
1093
+
981
1094
  function runGcloud(args, opts = {}) {
982
1095
  return spawnSync("gcloud", args, {
983
1096
  stdio: opts.stdio || "inherit",
@@ -1586,6 +1699,267 @@ function bootstrapLogTail(cfg, lines = 40) {
1586
1699
  return String(r.stdout || "").trim() || "(log unavailable)";
1587
1700
  }
1588
1701
 
1702
+ // ─── Detached PROJECT bootstrap ──────────────────────────────────────────────
1703
+ //
1704
+ // The VM bootstrap above solved a problem the PROJECT bootstrap still had: read
1705
+ // success and failure from VM-SIDE STATE, never from the SSH exit code, so a
1706
+ // dropped follow-session over a healthy VM is not misreported as "failed on the
1707
+ // VM". The project bootstrap ran instead as ONE blocking ssh-script call, so a
1708
+ // local budget elapsing (or an SSH pipe breaking) was indistinguishable from a
1709
+ // genuine remote failure and the caller's abort path DELETED A HEALTHY VM.
1710
+ // Raising the budget alone just moves the cliff; these helpers remove it by
1711
+ // mirroring the VM-bootstrap contract — detached launch, re-attaching follow
1712
+ // loop, VM-side outcome probe.
1713
+ //
1714
+ // All VM-side paths are keyed by repo name so two projects on one VM never
1715
+ // collide, and they sit beside the `.done` marker projectBootstrapDoneMarker
1716
+ // already defines. Pure string builders (like their VM-bootstrap counterparts)
1717
+ // so the whole grammar is unit-testable without a live VM.
1718
+ function projectBootstrapScriptPath(repoName) {
1719
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.sh`;
1720
+ }
1721
+ function projectBootstrapRunnerPath(repoName) {
1722
+ return `$HOME/.codeyam-project-bootstrap-${repoName}-run.sh`;
1723
+ }
1724
+ function projectBootstrapLogPath(repoName) {
1725
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.log`;
1726
+ }
1727
+ function projectBootstrapPidPath(repoName) {
1728
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.pid`;
1729
+ }
1730
+ // The runner's recorded EXIT STATUS. Deliberately `.status`, NOT `.done`:
1731
+ // `.done` is already taken by scripts/gcp-project-bootstrap.sh's own terminal
1732
+ // SUCCESS marker (a timestamp, written only after `set -euo pipefail` carried
1733
+ // every step through — see projectBootstrapDoneMarker). Reusing `.done` for an
1734
+ // exit code would overwrite the success signal that both improve40's false-skip
1735
+ // fix and the follow loop below depend on. Two files, two distinct meanings:
1736
+ // `.done` = "the script reached its last line", `.status` = "the runner exited,
1737
+ // here is its code".
1738
+ function projectBootstrapStatusPath(repoName) {
1739
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.status`;
1740
+ }
1741
+ const PROJECT_BOOTSTRAP_LAUNCHED_MARKER = "CODEYAM_PROJECT_BOOTSTRAP_LAUNCHED";
1742
+ const PROJECT_BOOTSTRAP_STATUS_MARKER = "CYPROJ";
1743
+ // One follow session caps at 15 min; the overall wait caps at
1744
+ // PROJECT_BOOTSTRAP_TIMEOUT_MS across re-attaches before we bail loudly with
1745
+ // the VM-side log location. Unlike the VM-bootstrap follow loop — whose follow
1746
+ // calls inherit the 120 s probe budget and so re-attach every two minutes over
1747
+ // a ~14-min build — the project follow call below is given an explicit local
1748
+ // budget just above this remote deadline, so a long install streams in one
1749
+ // session instead of churning re-attaches.
1750
+ const PROJECT_BOOTSTRAP_FOLLOW_SECS = 900;
1751
+ const PROJECT_BOOTSTRAP_FOLLOW_TIMEOUT_MS = (PROJECT_BOOTSTRAP_FOLLOW_SECS + 60) * 1000;
1752
+
1753
+ // The tiny VM-side wrapper the detached launch execs: run the real bootstrap,
1754
+ // redirect all output to the per-repo log, and record the exit status in the
1755
+ // `.status` sentinel — the single source of truth for "did the runner exit, and
1756
+ // with what?" once the SSH follow-session is gone. Contains NO secrets (tokens
1757
+ // arrive via the launch env prefix, never written to disk).
1758
+ function projectBootstrapRunnerScript(repoName) {
1759
+ return [
1760
+ "#!/usr/bin/env bash",
1761
+ "# Detached project-bootstrap runner (written by npm/cloud.js",
1762
+ "# ensureProjectBootstrapped). Survives SSH disconnect: the laptop only",
1763
+ "# *follows* the log below; this process keeps running under its own session",
1764
+ "# if the follow drops. Records its own PID so the status probe can check",
1765
+ "# liveness via `kill -0` (a `pgrep` on the script path would also match the",
1766
+ "# probe command itself).",
1767
+ `echo "$$" > "${projectBootstrapPidPath(repoName)}"`,
1768
+ `bash "${projectBootstrapScriptPath(repoName)}" "$@" > "${projectBootstrapLogPath(repoName)}" 2>&1`,
1769
+ "status=$?",
1770
+ `echo "$status" > "${projectBootstrapStatusPath(repoName)}"`,
1771
+ `rm -f "${projectBootstrapPidPath(repoName)}"`,
1772
+ "",
1773
+ ].join("\n");
1774
+ }
1775
+
1776
+ // Build the remote command that (1) materializes the bootstrap script + runner
1777
+ // on the VM, (2) clears the stale sentinels, (3) launches the runner DETACHED
1778
+ // (`nohup setsid`, new session, stdio to /dev/null) so it outlives the SSH
1779
+ // session, and (4) prints a launched marker. The detach is the durable fix: the
1780
+ // install no longer dies when the laptop's SSH pipe breaks, and the VM is no
1781
+ // longer torn down because it did.
1782
+ //
1783
+ // BOTH sentinels are cleared, `.status` and `.done`. Clearing `.done` is what
1784
+ // makes the outcome probe honest: after a launch, a present `.done` can only
1785
+ // mean THIS run's script reached its terminal line, never a leftover from a
1786
+ // previous half-wired attempt.
1787
+ function projectBootstrapLaunchCommand(scriptBody, scriptArgs = [], envVars = {}, repoName) {
1788
+ const scriptB64 = Buffer.from(scriptBody, "utf8").toString("base64");
1789
+ const runnerB64 = Buffer.from(projectBootstrapRunnerScript(repoName), "utf8").toString("base64");
1790
+ const argsString = scriptArgs.map(shQuote).join(" ");
1791
+ const envPrefix = envAssignmentPrefix(envVars);
1792
+ // Writes run synchronously (fast, before any backgrounding) so a decode error
1793
+ // aborts before launch; then the runner is detached. The env prefix is on the
1794
+ // setsid command so the tokens are inherited through nohup→setsid→bash but
1795
+ // never appear in any argv. `& echo MARKER` (no `;` before echo) keeps the
1796
+ // launch in the background while still confirming it fired.
1797
+ const writes = [
1798
+ `echo '${scriptB64}' | base64 -d > ${projectBootstrapScriptPath(repoName)}`,
1799
+ `echo '${runnerB64}' | base64 -d > ${projectBootstrapRunnerPath(repoName)}`,
1800
+ `rm -f ${projectBootstrapStatusPath(repoName)} ${projectBootstrapDoneMarker(repoName)}`,
1801
+ ].join(" && ");
1802
+ const launch =
1803
+ `${envPrefix}nohup setsid bash ${projectBootstrapRunnerPath(repoName)} ${argsString}`.trimEnd() +
1804
+ ` </dev/null >/dev/null 2>&1 &`;
1805
+ return `${writes}; ${launch} echo ${PROJECT_BOOTSTRAP_LAUNCHED_MARKER}`;
1806
+ }
1807
+
1808
+ // Build the remote command that FOLLOWS the detached install: tail the log live
1809
+ // for the operator, polling for the `.status` sentinel, and exit when the runner
1810
+ // finishes OR a per-session deadline elapses. If the SSH session drops
1811
+ // mid-follow this command dies but the detached install keeps going — the caller
1812
+ // then re-attaches.
1813
+ function projectBootstrapFollowCommand(repoName, deadlineSecs = PROJECT_BOOTSTRAP_FOLLOW_SECS) {
1814
+ const log = projectBootstrapLogPath(repoName);
1815
+ const status = projectBootstrapStatusPath(repoName);
1816
+ return [
1817
+ `touch ${log}`,
1818
+ `tail -n +1 -F ${log} 2>/dev/null & TP=$!`,
1819
+ `waited=0`,
1820
+ `while [ ! -f ${status} ]; do ` +
1821
+ `sleep 3; waited=$((waited+3)); ` +
1822
+ `if [ "$waited" -ge ${deadlineSecs} ]; then kill "$TP" 2>/dev/null; exit 0; fi; ` +
1823
+ `done`,
1824
+ `sleep 1`,
1825
+ `kill "$TP" 2>/dev/null`,
1826
+ ].join("; ");
1827
+ }
1828
+
1829
+ // Build the remote command that reports terminal VM state in one parseable
1830
+ // line: whether the script's terminal `.done` marker exists, the runner's
1831
+ // recorded exit status (or `none`), and whether the runner is still alive.
1832
+ // Cheap — runs over ssh-probe.
1833
+ function projectBootstrapStatusCommand(repoName) {
1834
+ const pid = projectBootstrapPidPath(repoName);
1835
+ const status = projectBootstrapStatusPath(repoName);
1836
+ // Liveness via the runner's PID file + `kill -0`, NOT pgrep: the status
1837
+ // command's own argv contains the bootstrap path, so a `pgrep -f` on it would
1838
+ // match this very probe and always report running. A stale PID file from a
1839
+ // killed runner reads as not-running because `kill -0` fails.
1840
+ return [
1841
+ `done=no; [ -f ${projectBootstrapDoneMarker(repoName)} ] && done=yes`,
1842
+ `st=none; [ -f ${status} ] && st=$(cat ${status} 2>/dev/null)`,
1843
+ `run=no; if [ -f ${pid} ]; then p=$(cat ${pid} 2>/dev/null); ` +
1844
+ `{ [ -n "$p" ] && kill -0 "$p" 2>/dev/null; } && run=yes; fi`,
1845
+ `echo "${PROJECT_BOOTSTRAP_STATUS_MARKER} done=$done status=$st running=$run"`,
1846
+ ].join("; ");
1847
+ }
1848
+
1849
+ // Parse the single status line emitted by projectBootstrapStatusCommand.
1850
+ // Returns `{ done, status, running }` or null when no status line is present
1851
+ // (e.g. the probe ssh itself failed and produced no stdout).
1852
+ function parseProjectBootstrapStatus(stdout) {
1853
+ const m = String(stdout || "").match(
1854
+ new RegExp(`${PROJECT_BOOTSTRAP_STATUS_MARKER} done=(\\S+) status=(\\S+) running=(\\S+)`),
1855
+ );
1856
+ if (!m) return null;
1857
+ return { done: m[1], status: m[2], running: m[3] };
1858
+ }
1859
+
1860
+ // Decide the project-bootstrap outcome from VM-side state alone — never the SSH
1861
+ // exit code. The `.done` marker is the real artifact (the script writes it only
1862
+ // after `set -euo pipefail` carried every step to success), so its presence is
1863
+ // success regardless of how the follow session ended — the same reasoning that
1864
+ // makes the docker image authoritative for the VM bootstrap. A live runner means
1865
+ // "re-attach". A recorded exit status without the marker means the install ran
1866
+ // and failed. Nothing at all means it was never started (or the probe could not
1867
+ // see it).
1868
+ function decideProjectBootstrapOutcome(status) {
1869
+ if (!status) return "absent";
1870
+ const { done, status: exitStatus, running } = status;
1871
+ if (done === "yes") return "success";
1872
+ if (running === "yes") return "running";
1873
+ if (exitStatus && exitStatus !== "none") return "failed";
1874
+ return "absent";
1875
+ }
1876
+
1877
+ // Reduce one follow-loop iteration to an action: "success" | "failed" |
1878
+ // "timeout" | "reattach". Extracted as a pure function because its PRECEDENCE is
1879
+ // load-bearing — it decides whether an operator is told a VM is broken:
1880
+ //
1881
+ // • success and failed outrank the deadline. Otherwise the final poll of a
1882
+ // slow-but-successful bootstrap reports a timeout, and the operator is sent
1883
+ // to reconcile a VM that actually finished.
1884
+ // • a still-running install past the deadline is a TIMEOUT, never a failure.
1885
+ // That is the VM-8 case: healthy VM, still installing, and saying so is what
1886
+ // gets the add classified retryable instead of destroyed.
1887
+ // • an unseen runner ("absent") before the deadline re-attaches rather than
1888
+ // failing — "I could not see it" is not "it broke" — but is still bounded by
1889
+ // the deadline so the loop cannot spin forever.
1890
+ function decideProjectBootstrapFollowAction({ outcome, now, deadline } = {}) {
1891
+ if (outcome === "success") return "success";
1892
+ if (outcome === "failed") return "failed";
1893
+ if (now >= deadline) return "timeout";
1894
+ return "reattach";
1895
+ }
1896
+
1897
+ // Probe the VM for the detached project bootstrap's terminal state and reduce it
1898
+ // to a single outcome string ("success" | "failed" | "running" | "absent").
1899
+ // Used both for the idempotent re-attach decision and after each follow session.
1900
+ function probeProjectBootstrapOutcome(cfg, repoName) {
1901
+ const r = runGcloud(
1902
+ buildGcloudArgs("ssh-probe", cfg, { command: projectBootstrapStatusCommand(repoName) }),
1903
+ { stdio: ["ignore", "pipe", "pipe"] },
1904
+ );
1905
+ return decideProjectBootstrapOutcome(parseProjectBootstrapStatus(r.stdout));
1906
+ }
1907
+
1908
+ /** Assemble the env forwarded into the project-bootstrap shell on the VM.
1909
+ * Extracted from ensureProjectBootstrapped so its CONDITIONAL keys are testable
1910
+ * without a VM — previously you could not assert "FLEET_DOMAIN is omitted for a
1911
+ * non-fleet project" or "a re-provision without --admin stays non-admin"
1912
+ * without invoking the whole gcloud-touching bootstrap.
1913
+ *
1914
+ * Keys, and why each is here:
1915
+ * GITHUB_TOKEN — clone a private client repo.
1916
+ * CY_GIT_USER_{NAME,EMAIL} — rendered into the compose file, then written
1917
+ * to `git config --global` by the entrypoint so
1918
+ * plan_claim_refs::create_claim's commit-tree
1919
+ * call succeeds. Empty is legitimate (a CI
1920
+ * runner with no author); the entrypoint warns
1921
+ * and proceeds, so the keys stay present.
1922
+ * FLEET_DOMAIN — ONLY for a numbered fleet VM with a domain
1923
+ * configured, so the compose render publishes
1924
+ * on 0.0.0.0 for the LB NEG. Omitted entirely
1925
+ * otherwise, preserving the loopback-only
1926
+ * publish a plain client project expects.
1927
+ * <forwarded launch-form keys> — DATABASE_URL et al, whitelisted by the
1928
+ * dashboard via CY_FORWARD_ENV_KEYS, so the
1929
+ * bootstrap-time `npm run setup` sees them on
1930
+ * the first try. Empty on plain `cloud:up`.
1931
+ * CODEYAM_ADMIN — the Home-tab Setup door. Defaults to the
1932
+ * strict non-admin "".
1933
+ *
1934
+ * Later spreads win, matching the original inline assembly exactly. `deps` lets
1935
+ * a test supply the token and git identity instead of reading the real
1936
+ * environment and shelling out to git. */
1937
+ function projectBootstrapEnv(cfg = {}, { env = process.env, githubToken, gitIdentity } = {}) {
1938
+ const git = gitIdentity || resolveLaptopGitIdentity();
1939
+ const out = {
1940
+ GITHUB_TOKEN: githubToken !== undefined ? githubToken : resolveGithubToken(),
1941
+ CY_GIT_USER_NAME: git.name,
1942
+ CY_GIT_USER_EMAIL: git.email,
1943
+ };
1944
+ const ingressPlan = ingressWiringPlan(cfg, env);
1945
+ if (ingressPlan.wire) out.FLEET_DOMAIN = ingressPlan.domain;
1946
+ Object.assign(out, resolveSetupForwardEnv(env));
1947
+ Object.assign(out, buildProvisionEnv(env, cfg));
1948
+ return out;
1949
+ }
1950
+
1951
+ // Fetch the tail of the VM-side project-bootstrap log for an actionable failure
1952
+ // message — the operator sees WHY the install failed, not a bare exit code.
1953
+ function projectBootstrapLogTail(cfg, repoName, lines = 40) {
1954
+ const r = runGcloud(
1955
+ buildGcloudArgs("ssh-probe", cfg, {
1956
+ command: `tail -n ${lines} ${projectBootstrapLogPath(repoName)} 2>/dev/null`,
1957
+ }),
1958
+ { stdio: ["ignore", "pipe", "pipe"] },
1959
+ );
1960
+ return String(r.stdout || "").trim() || "(log unavailable)";
1961
+ }
1962
+
1589
1963
  /** Assemble the extra provision-time env forwarded into the bootstrap shell,
1590
1964
  * where it feeds the compose-render substitution. Currently just the admin
1591
1965
  * toggle: CODEYAM_ADMIN gates the Home-tab Setup door and is read from the
@@ -1623,7 +1997,7 @@ function buildReconcileAdminCommand(repoName, value) {
1623
1997
  );
1624
1998
  }
1625
1999
 
1626
- async function ensureProjectBootstrapped(cfg) {
2000
+ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
1627
2001
  const repoName = resolveProject(cfg).name;
1628
2002
  console.error(`Checking project bootstrap state for ${repoName}...`);
1629
2003
  // Gate on the TERMINAL completion marker the script writes as its very last
@@ -1653,49 +2027,154 @@ async function ensureProjectBootstrapped(cfg) {
1653
2027
  const scriptPath = path.join(rootDir, "scripts", "gcp-project-bootstrap.sh");
1654
2028
  const scriptBody = readStreamedScript(scriptPath, cfg);
1655
2029
  const scriptArgs = clientProjectBootstrapArgs(cfg);
1656
- // Forward the laptop's git author identity so the rendered compose file
1657
- // carries CY_GIT_USER_{NAME,EMAIL} → the entrypoint writes them to
1658
- // `git config --global` → plan_claim_refs::create_claim's commit-tree call
1659
- // succeeds. Empty strings are fine (laptop has no identity, e.g. a CI
1660
- // runner); the entrypoint warns and proceeds.
1661
- const laptopGit = resolveLaptopGitIdentity();
1662
- // Forward the operator's launch-form env keys (DATABASE_URL, etc.) into the
1663
- // bootstrap shell so the bootstrap-time `npm run setup` sees them on the
1664
- // first try — without this, Prisma `db:push`/`db:seed` fail for lack of
1665
- // env and the bootstrap's fail-soft branch leaves the app schema-less until
1666
- // the post-add `reapplyVmEnv` re-fires setup. See
1667
- // `improve37-launch-form-env-injected-into-cloud-up-bootstrap-not-just-applied-after`.
1668
- // Whitelisted by the dashboard via CY_FORWARD_ENV_KEYS — empty on the
1669
- // standalone `cloud:up` path, so that flow is unchanged.
1670
- const forwardEnv = resolveSetupForwardEnv();
1671
- // Forward the fleet domain so the bootstrap's compose render publishes the
1672
- // editor/app ports on 0.0.0.0 (the LB NEG reaches them on the VM's internal
1673
- // IP) instead of loopback-only. Only when a fleet domain is configured AND
1674
- // this is a numbered fleet VM — a non-fleet client project keeps the
1675
- // loopback-only publish (legacy parity). Absent ⇒ key omitted entirely.
1676
- const ingressEnv = {};
1677
- const ingressPlan = ingressWiringPlan(cfg);
1678
- if (ingressPlan.wire) ingressEnv.FLEET_DOMAIN = ingressPlan.domain;
1679
- // Forward the admin-mode toggle (Home-tab Setup door) into the compose
1680
- // render. cfg.admin carries the `--admin` flag; process.env.CODEYAM_ADMIN
1681
- // carries the `CODEYAM_ADMIN=1 npm run cloud:up` form. Default "" =
1682
- // non-admin, so this leaves the normal launch and re-provision unchanged.
1683
- const provisionEnv = buildProvisionEnv(process.env, cfg);
1684
- const command = sshScriptCommand(scriptBody, scriptArgs, {
1685
- GITHUB_TOKEN: resolveGithubToken(),
1686
- CY_GIT_USER_NAME: laptopGit.name,
1687
- CY_GIT_USER_EMAIL: laptopGit.email,
1688
- ...ingressEnv,
1689
- ...forwardEnv,
1690
- ...provisionEnv,
1691
- });
1692
- const result = runGcloud(
1693
- buildGcloudArgs("ssh-script", cfg, { command }),
1694
- { stdio: "inherit" },
2030
+ // Git identity, forwarded launch-form keys, fleet-domain ingress and the admin
2031
+ // toggle — see projectBootstrapEnv for what each key is for and when it is
2032
+ // omitted. Extracted so those conditionals are testable without a VM.
2033
+ const bootstrapEnv = projectBootstrapEnv(cfg);
2034
+
2035
+ // Idempotent re-attach: a prior cloud:up whose follow-session dropped may have
2036
+ // left a detached install still running. Converge on it instead of starting a
2037
+ // second `npm install` on top of the first.
2038
+ //
2039
+ // NOTE the deliberate asymmetry with ensureBootstrapped, which DOES return
2040
+ // early on an outcome of "success". There, the done-probe and the outcome probe
2041
+ // read the same artifact (the docker image), so they always agree. Here the
2042
+ // done-probe above is a strictly STRONGER three-way AND (`.done` marker +
2043
+ // ~/projects/<repo>/.codeyam + docker-compose.cloud.yml) than this probe's
2044
+ // `.done`-alone success, and it has already said no. Honoring a bare `.done`
2045
+ // here would re-open the improve40 VM-7 false-skip from the other side, so only
2046
+ // "running" short-circuits the launch.
2047
+ const initial = probeProjectBootstrapOutcome(cfg, repoName);
2048
+ if (initial === "running") {
2049
+ console.error(" ↺ a detached project bootstrap is already in progress — re-attaching to its log...");
2050
+ } else {
2051
+ // "absent" (never started), "failed" (a prior run died), or "success" (a bare
2052
+ // marker the stricter done-probe rejected) → (re)launch a fresh detached
2053
+ // install that will survive an SSH disconnect. Every step of
2054
+ // gcp-project-bootstrap.sh is skip-on-exists, so re-entering is cheap.
2055
+ console.error(" launching detached project bootstrap on the VM (survives SSH disconnect)...");
2056
+ const launch = projectBootstrapLaunchCommand(scriptBody, scriptArgs, bootstrapEnv, repoName);
2057
+ const launched = await runGcloudSshWithRetry(
2058
+ "ssh-script",
2059
+ cfg,
2060
+ { command: launch },
2061
+ { stdio: ["ignore", "pipe", "pipe"], label: "project bootstrap launch", sleepFn },
2062
+ );
2063
+ if (isSpawnTimeout(launched)) {
2064
+ // The LOCAL budget elapsed and we SIGTERMd the gcloud child. Say so:
2065
+ // the launch is a short write-and-detach, so this is an SSH-reachability
2066
+ // problem, NOT a broken bootstrap script — and the VM may be perfectly
2067
+ // healthy. Conflating the two is what destroyed VM-8.
2068
+ throw new Error(
2069
+ `launching the detached project bootstrap exceeded the local gcloud call budget ` +
2070
+ `and was SIGTERMd locally — the VM may be healthy and simply unreachable over SSH ` +
2071
+ `right now. Re-run to re-attach (each bootstrap step is skip-on-exists).`,
2072
+ );
2073
+ }
2074
+ if (launched.status !== 0) {
2075
+ throw new Error(
2076
+ `failed to launch the detached project bootstrap on the VM (gcloud ssh exit ${launched.status}).\n` +
2077
+ formatGcloudFailure(launched),
2078
+ );
2079
+ }
2080
+ }
2081
+
2082
+ // Follow the live log, re-attaching across any follow-session drops, until the
2083
+ // VM reports a terminal state or the overall deadline elapses. Success and
2084
+ // failure are read from VM state (the `.done` marker + the `.status` sentinel),
2085
+ // never the SSH exit code — so "my laptop's SSH dropped" or "a heavy npm
2086
+ // install outran a local budget" no longer masquerades as a broken bootstrap
2087
+ // and no longer gets a healthy VM deleted.
2088
+ const deadline = Date.now() + PROJECT_BOOTSTRAP_TIMEOUT_MS;
2089
+ for (;;) {
2090
+ runGcloud(
2091
+ buildGcloudArgs("ssh-script", cfg, { command: projectBootstrapFollowCommand(repoName) }),
2092
+ { stdio: "inherit", timeout: PROJECT_BOOTSTRAP_FOLLOW_TIMEOUT_MS },
2093
+ );
2094
+ const outcome = probeProjectBootstrapOutcome(cfg, repoName);
2095
+ const action = decideProjectBootstrapFollowAction({
2096
+ outcome,
2097
+ now: Date.now(),
2098
+ deadline,
2099
+ });
2100
+ if (action === "success") {
2101
+ console.error(` ✓ ${repoName} bootstrap complete`);
2102
+ return;
2103
+ }
2104
+ if (action === "failed") {
2105
+ const tail = projectBootstrapLogTail(cfg, repoName);
2106
+ throw new Error(
2107
+ "scripts/gcp-project-bootstrap.sh failed on the VM. " +
2108
+ `Last lines of ${projectBootstrapLogPath(repoName)}:\n${tail}`,
2109
+ );
2110
+ }
2111
+ if (action === "timeout") {
2112
+ // The install is STILL RUNNING on a healthy VM and we simply stopped
2113
+ // waiting. This message is the signature npm/fleet-add-failure.js keys on
2114
+ // to classify the add as `project-bootstrap-timeout` (retryable) instead of
2115
+ // the non-retryable generic cloud-up-failed that tore down VM-8.
2116
+ throw new Error(
2117
+ `project bootstrap exceeded the local ${Math.round(PROJECT_BOOTSTRAP_TIMEOUT_MS / 60000)}-minute budget ` +
2118
+ `— the VM and its detached bootstrap may be healthy and still running. ` +
2119
+ `Re-run to re-attach (each bootstrap step is skip-on-exists), or inspect ` +
2120
+ `${projectBootstrapLogPath(repoName)} on the VM.`,
2121
+ );
2122
+ }
2123
+ // action === "reattach": the follow session ended but the install is alive
2124
+ // (or the probe could not see it yet). Re-attach to its log.
2125
+ console.error(" ↺ follow session ended but the project bootstrap is still running — re-attaching...");
2126
+ }
2127
+ }
2128
+
2129
+ /** Run the compose-ps liveness probe over SSH and report BOTH the editor
2130
+ * service's state and whether the probe itself could be trusted.
2131
+ *
2132
+ * The `|| true` keeps the remote pipeline's exit status 0 whenever SSH itself
2133
+ * worked, so a non-zero `status` means the transport failed (auth/SSH/IAP) —
2134
+ * not that the container is down. Callers that must fail safe (the abort
2135
+ * teardown) need that distinction; `parseEditorComposeState` alone collapses
2136
+ * "no container" and "could not ask" into the same `null`.
2137
+ *
2138
+ * Returns `{ state, inconclusive }` where `state` is the compose `State` string
2139
+ * (`"running"`, `"exited"`, …) or null when the service is absent, and
2140
+ * `inconclusive` is true only for a transport failure. */
2141
+ function probeEditorComposeState(cfg) {
2142
+ const repoName = resolveProject(cfg).name;
2143
+ const composeCmd = `docker compose -f docker-compose.cloud.yml`;
2144
+ const probe = runGcloud(
2145
+ buildGcloudArgs("ssh-probe", cfg, {
2146
+ command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
2147
+ }),
2148
+ { stdio: ["ignore", "pipe", "ignore"] },
1695
2149
  );
1696
- if (result.status !== 0) {
1697
- throw new Error("scripts/gcp-project-bootstrap.sh failed on the VM");
2150
+ if (probe.status !== 0) {
2151
+ return { state: null, inconclusive: true };
1698
2152
  }
2153
+ return { state: parseEditorComposeState(probe.stdout), inconclusive: false };
2154
+ }
2155
+
2156
+ /** Poll {@link probeEditorComposeState} until the editor service reads
2157
+ * `running`, returning true as soon as it does. Used to close the TOCTOU window
2158
+ * after a container-name conflict: the winning run may still be inside
2159
+ * `Creating`/`Created` when the conflict surfaces, so a single immediate probe
2160
+ * can under-read and report a healthy container as absent.
2161
+ *
2162
+ * Deliberately short (default ~30s): this is not the multi-minute first-boot
2163
+ * budget `waitForContainerRunning` grants, it is the tail of a start that
2164
+ * another process has already begun. `sleepFn` and `probeFn` are injectable so
2165
+ * a unit test drives the whole loop with neither wall-clock delay nor a VM. */
2166
+ async function waitForEditorComposeRunning(cfg, { attempts = 6, delayMs = 5000, sleepFn, probeFn } = {}) {
2167
+ const sleep = sleepFn || ((ms) => new Promise((r) => setTimeout(r, ms)));
2168
+ const probe = probeFn || probeEditorComposeState;
2169
+ for (let i = 0; i < attempts; i++) {
2170
+ if (probe(cfg).state === "running") {
2171
+ return true;
2172
+ }
2173
+ if (i < attempts - 1) {
2174
+ await sleep(delayMs);
2175
+ }
2176
+ }
2177
+ return false;
1699
2178
  }
1700
2179
 
1701
2180
  async function ensureEditorContainerUp(cfg) {
@@ -1705,13 +2184,7 @@ async function ensureEditorContainerUp(cfg) {
1705
2184
  // cloud variant when self-hosting (see gcp-project-bootstrap.sh).
1706
2185
  const composeCmd = `docker compose -f docker-compose.cloud.yml`;
1707
2186
  console.error("Checking editor container state...");
1708
- const probe = runGcloud(
1709
- buildGcloudArgs("ssh-probe", cfg, {
1710
- command: `cd ~/projects/${repoName} && ${composeCmd} ps --format json 2>/dev/null || true`,
1711
- }),
1712
- { stdio: ["ignore", "pipe", "ignore"] },
1713
- );
1714
- if (probe.status === 0 && parseEditorComposeState(probe.stdout) === "running") {
2187
+ if (probeEditorComposeState(cfg).state === "running") {
1715
2188
  console.error(" ✓ editor container is already running");
1716
2189
  // containerStarted: the container is confirmed up, so the CLI install runs
1717
2190
  // its POST-start path with no wait — see the precondition on ensureAgentCliInstalled.
@@ -1719,14 +2192,47 @@ async function ensureEditorContainerUp(cfg) {
1719
2192
  return;
1720
2193
  }
1721
2194
  console.error(" starting editor container (docker compose up -d)...");
2195
+ // CAPTURE rather than `stdio: "inherit"`: the daemon writes its container-name
2196
+ // conflict to stderr, and with "inherit" that text went straight to our parent
2197
+ // and was never observable here — so the conflict could not be told apart from
2198
+ // a real failure (the 2026-08-07 VM-4/VM-5 delete). spawnSync cannot both
2199
+ // stream and capture, so the operator's view is preserved by echoing the
2200
+ // captured streams immediately below instead of live. Do NOT revert this to
2201
+ // "inherit"; classification depends on it.
1722
2202
  const up = runGcloud(
1723
2203
  buildGcloudArgs("ssh-probe", cfg, {
1724
2204
  command: `cd ~/projects/${repoName} && ${composeCmd} up -d`,
1725
2205
  }),
1726
- { stdio: "inherit" },
2206
+ { stdio: ["ignore", "pipe", "pipe"] },
1727
2207
  );
2208
+ if (up.stdout) process.stderr.write(up.stdout);
2209
+ if (up.stderr) process.stderr.write(up.stderr);
1728
2210
  if (up.status !== 0) {
1729
- throw new Error("docker compose up -d failed on the VM");
2211
+ const verdict = classifyComposeUpFailure({ status: up.status, stderr: `${up.stderr || ""}\n${up.stdout || ""}` });
2212
+ if (verdict !== "name-conflict") {
2213
+ throw new Error("docker compose up -d failed on the VM");
2214
+ }
2215
+ // A name conflict means a concurrent run already created this container —
2216
+ // the goal we were about to pursue is being reached by someone else. Confirm
2217
+ // that positively before converging: a conflict whose container never
2218
+ // reaches `running` (e.g. a stale STOPPED container squatting the name) is a
2219
+ // genuine problem and must still fail loudly, or convergence becomes a
2220
+ // catch-all that papers over real breakage.
2221
+ console.error(
2222
+ " a concurrent run already created the editor container — confirming it reaches running...",
2223
+ );
2224
+ if (!(await waitForEditorComposeRunning(cfg))) {
2225
+ throw new Error(
2226
+ "docker compose up -d hit a container-name conflict on the VM, but the conflicting " +
2227
+ "container never reached running (a stale stopped container may be holding the name). " +
2228
+ `Inspect with: gcloud compute ssh ${cfg.instanceName} --command 'cd ~/projects/${repoName} && ${composeCmd} ps -a'`,
2229
+ );
2230
+ }
2231
+ console.error(" ✓ editor container is running (started by a concurrent run)");
2232
+ // containerStarted: the container is confirmed up — same post-start contract
2233
+ // as the already-running short-circuit above.
2234
+ await ensureAgentCliInstalled(cfg, { containerStarted: true });
2235
+ return;
1730
2236
  }
1731
2237
  // containerStarted: `up -d` returned 0, so the CLI install is legitimately a
1732
2238
  // post-start step. A brief wait-for-running here (slow boot settling) is
@@ -1742,18 +2248,10 @@ async function ensureEditorContainerUp(cfg) {
1742
2248
  // success. This throws (non-zero exit) when the editor service isn't
1743
2249
  // `running`, converting a silent partial provision into a loud, actionable
1744
2250
  // failure the operator (and the dashboard's fail-fast-on-nonzero path) can act
1745
- // on. The `|| true` keeps the probe's exit status 0 so the real signal is the
1746
- // parsed compose state, not the SSH/cd exit code.
2251
+ // on. An inconclusive probe is not "running" here — a terminal gate must have
2252
+ // positive evidence, and the caller's job is to fail loudly without it.
1747
2253
  async function assertEditorContainerRunning(cfg) {
1748
- const repoName = resolveProject(cfg).name;
1749
- const composeCmd = `docker compose -f docker-compose.cloud.yml`;
1750
- const probe = runGcloud(
1751
- buildGcloudArgs("ssh-probe", cfg, {
1752
- command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
1753
- }),
1754
- { stdio: ["ignore", "pipe", "ignore"] },
1755
- );
1756
- if (!(probe.status === 0 && parseEditorComposeState(probe.stdout) === "running")) {
2254
+ if (probeEditorComposeState(cfg).state !== "running") {
1757
2255
  throw new Error(
1758
2256
  `provisioning incomplete: editor container is not running on ${cfg.instanceName} ` +
1759
2257
  `(no /workspace or container). Re-run: npm run cloud:up -- --instance-name ${cfg.instanceName}`,
@@ -3771,7 +4269,27 @@ async function cmdUp(args, projectDir = rootDir) {
3771
4269
  );
3772
4270
  }
3773
4271
  } catch (err) {
3774
- if (createdThisRun) {
4272
+ // `createdThisRun` alone MUST NOT authorize deletion. Gather what state the
4273
+ // VM is in right now, then let the pure decision rule — see
4274
+ // abortTeardownDecision's doc comment for why each input is load-bearing.
4275
+ const state = probeAbortTeardownState(cfg, createdThisRun);
4276
+ const verdict = abortTeardownDecision({ createdThisRun, ...state });
4277
+ if (createdThisRun && verdict === "keep") {
4278
+ // Deliberately loud and specific. The old blanket "tearing down the VM
4279
+ // cloud:up created this run (no orphan left behind)" was actively
4280
+ // misleading here — it asserted no orphan while deleting a working VM.
4281
+ const why = state.probeInconclusive
4282
+ ? "its liveness could not be confirmed (probe failed), and an unconfirmed VM is never deleted"
4283
+ : state.containerUp
4284
+ ? "its editor container is RUNNING — another run brought it up"
4285
+ : "it is in the fleet roster and visible to the dashboard";
4286
+ console.error(
4287
+ `✗ provisioning aborted — the VM ${cfg.instanceName} was LEFT IN PLACE because ${why}.\n` +
4288
+ ` This run created the instance, but that alone does not authorize deleting a VM that is in service.\n` +
4289
+ ` If it really should go, remove it deliberately: npm run cloud:destroy`,
4290
+ );
4291
+ }
4292
+ if (verdict === "delete") {
3775
4293
  console.error(
3776
4294
  `✗ provisioning aborted — tearing down the VM cloud:up created this run (no orphan left behind)`,
3777
4295
  );
@@ -3953,14 +4471,7 @@ async function cmdStatus(args, projectDir = rootDir) {
3953
4471
  const imgStatus = String(imgProbe.stdout || "").trim() || "?";
3954
4472
  console.error(` image: ${imgStatus}`);
3955
4473
 
3956
- const repoName = resolveProject(cfg).name;
3957
- const containerProbe = runGcloud(
3958
- buildGcloudArgs("ssh-probe", cfg, {
3959
- command: `cd ~/projects/${repoName} 2>/dev/null && docker compose -f docker-compose.cloud.yml ps --format json 2>/dev/null || echo`,
3960
- }),
3961
- { stdio: ["ignore", "pipe", "ignore"] },
3962
- );
3963
- const containerStatus = parseEditorComposeState(containerProbe.stdout) || "missing";
4474
+ const containerStatus = probeEditorComposeState(cfg).state || "missing";
3964
4475
  console.error(` editor container: ${containerStatus}`);
3965
4476
 
3966
4477
  const appPort = await readVmAppPort(cfg);
@@ -4368,7 +4879,14 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
4368
4879
  });
4369
4880
  const res = run(inv.command, inv.args, { encoding: "utf8", env: { ...process.env, ...inv.env } });
4370
4881
  const verdict = parseIngressTeardownVerdict((res && res.stdout) || "");
4371
- const outcome = classifyOrphanDownResult({ verdict, status: res ? res.status : null });
4882
+ // Pass `signal` too: a teardown the caller's group-kill reaped has a NULL
4883
+ // status, which the classifier would otherwise report as `spawn-failed`
4884
+ // rather than the truthful `killed=SIGKILL`.
4885
+ const outcome = classifyOrphanDownResult({
4886
+ verdict,
4887
+ status: res ? res.status : null,
4888
+ signal: res ? res.signal : null,
4889
+ });
4372
4890
  if (outcome.cleaned) {
4373
4891
  summary.cleaned.push(n);
4374
4892
  console.error(` VM-${n}: orphaned ingress → auto-cleaned (verified clean)`);
@@ -4689,6 +5207,7 @@ async function cloudMain() {
4689
5207
 
4690
5208
  module.exports = {
4691
5209
  // pure helpers
5210
+ isSpawnTimeout,
4692
5211
  agentEnvVarName,
4693
5212
  agentNpmPackage,
4694
5213
  AGENT_ENV_VARS,
@@ -4699,6 +5218,9 @@ module.exports = {
4699
5218
  assertAgentCliInstalled,
4700
5219
  probeContainerRunning,
4701
5220
  probeContainerState,
5221
+ waitForEditorComposeRunning,
5222
+ abortTeardownDecision,
5223
+ probeAbortTeardownState,
4702
5224
  probeAgentBinaryInstalled,
4703
5225
  waitForContainerRunning,
4704
5226
  KNOWN_FLAGS,
@@ -4758,6 +5280,22 @@ module.exports = {
4758
5280
  decideBootstrapOutcome,
4759
5281
  projectBootstrapDoneMarker,
4760
5282
  projectBootstrapDoneProbeCommand,
5283
+ projectBootstrapScriptPath,
5284
+ projectBootstrapRunnerPath,
5285
+ projectBootstrapLogPath,
5286
+ projectBootstrapPidPath,
5287
+ projectBootstrapStatusPath,
5288
+ projectBootstrapRunnerScript,
5289
+ projectBootstrapLaunchCommand,
5290
+ projectBootstrapFollowCommand,
5291
+ projectBootstrapStatusCommand,
5292
+ parseProjectBootstrapStatus,
5293
+ decideProjectBootstrapOutcome,
5294
+ decideProjectBootstrapFollowAction,
5295
+ projectBootstrapEnv,
5296
+ PROJECT_BOOTSTRAP_LAUNCHED_MARKER,
5297
+ PROJECT_BOOTSTRAP_FOLLOW_SECS,
5298
+ ensureProjectBootstrapped,
4761
5299
  SSH_KEEPALIVE_FLAGS,
4762
5300
  resolveSetupForwardEnv,
4763
5301
  FORWARD_ENV_KEYS_ENV,