|
| 1 | +// THE DECISIVE TEST (Nathan mandate, convoy incident 2026-07-22) — the permanent regression guard for |
| 2 | +// the Nomad decoupling invariant: STOPPING OR CRASHING `convoy up` MUST NOT KILL ITS AGENTS. |
| 3 | +// |
| 4 | +// The incident: a `convoy up` restart mid-cutover self-severed and took the whole hetz fleet down (exit |
| 5 | +// 143 across 11). The forensic question was a-vs-b: (a) a teardown MISUSE (someone ran `convoy down` / |
| 6 | +// a session kill), or (b) a real bug where the decoupling does not hold in practice. This test settles |
| 7 | +// it AND locks the answer in: it stands up real, detached agent daemons (via convoy's OWN production |
| 8 | +// spawn primitive, `spawnFromPtyFile` → `spawnDaemon`), supervises them with a real `convoy up` |
| 9 | +// subprocess, then KILLS that subprocess both ways (SIGTERM and SIGKILL) and asserts every agent is |
| 10 | +// still alive at the SAME pid. Then a fresh `convoy up` must ADOPT the survivors (not cold-boot), and |
| 11 | +// the inverse — `convoy down` — must actually tear them down. |
| 12 | +// |
| 13 | +// If the survive-asserts pass, the decoupling is real in practice (=> the incident was misuse (a)). If |
| 14 | +// they ever fail, THIS is the reproduction of bug (b). Either way the invariant is now guarded. |
| 15 | +// |
| 16 | +// PROCESS-LEVEL, by necessity: it spawns real daemons + a real `convoy up` and sends real signals. It |
| 17 | +// lives in the vitest gate (test.yml, a normal runner) — the lane that already shells out to real |
| 18 | +// `bin/convoy` — NOT the hermetic nix flake check (which only runs the curated completions/typecheck |
| 19 | +// subset). Everything is scoped to a throwaway XDG_STATE_HOME so it can never touch a live network. |
| 20 | + |
| 21 | +import { afterAll, beforeAll, describe, expect, it } from "vitest"; |
| 22 | +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; |
| 23 | +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 24 | +import { tmpdir } from "node:os"; |
| 25 | +import { dirname, join } from "node:path"; |
| 26 | +import { fileURLToPath } from "node:url"; |
| 27 | +import { PtyHost, processAlive, spawnFromPtyFile } from "./host.ts"; |
| 28 | + |
| 29 | +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); |
| 30 | +const bin = join(repoRoot, "bin", "convoy"); |
| 31 | + |
| 32 | +// The two dummy agents. Each is a single PERMANENT session running a long-lived `sleep` — a stand-in for |
| 33 | +// a real harness that is a real detached pty daemon without dragging claude/st onto the box. `strategy = |
| 34 | +// "permanent"` is what makes `convoy up` supervise (and a fresh up ADOPT) them. |
| 35 | +const AGENTS = [ |
| 36 | + { key: "claude", id: "dtest-alpha" }, |
| 37 | + { key: "claude", id: "dtest-beta" }, |
| 38 | +] as const; |
| 39 | + |
| 40 | +let home = ""; |
| 41 | +let net = ""; |
| 42 | +const savedPtyRoot = process.env["PTY_ROOT"]; |
| 43 | + |
| 44 | +/** The isolated-network env every child `convoy` inherits: a throwaway XDG_STATE_HOME, ambient |
| 45 | + * ST_ROOT/PTY_ROOT scrubbed so nothing leaks in from the real box (same guard run.test.ts uses). */ |
| 46 | +function childEnv(): NodeJS.ProcessEnv { |
| 47 | + return { ...process.env, XDG_STATE_HOME: home, ST_ROOT: "", PTY_ROOT: "" }; |
| 48 | +} |
| 49 | + |
| 50 | +/** Write one dummy agent's `.convoy/pty.toml` (the launch manifest convoy replays) into its workspace. */ |
| 51 | +function writeDummyManifest(workspace: string, id: string): void { |
| 52 | + mkdirSync(join(workspace, ".convoy"), { recursive: true }); |
| 53 | + const toml = |
| 54 | + `prefix = "${id}"\n\n` + |
| 55 | + `[sessions.claude]\n` + |
| 56 | + `id = "${id}"\n` + |
| 57 | + `command = "exec sleep 2000000"\n\n` + |
| 58 | + `[sessions.claude.tags]\n` + |
| 59 | + `strategy = "permanent"\n` + |
| 60 | + `role = "agent"\n\n` + |
| 61 | + `[sessions.claude.env]\n` + |
| 62 | + `ST_AGENT = "${id}"\n`; |
| 63 | + writeFileSync(join(workspace, ".convoy", "pty.toml"), toml); |
| 64 | +} |
| 65 | + |
| 66 | +/** name → pid for our dummy sessions as pty currently reports them. */ |
| 67 | +async function agentPids(): Promise<Map<string, number | null>> { |
| 68 | + const ids = new Set<string>(AGENTS.map((a) => a.id)); |
| 69 | + const out = new Map<string, number | null>(); |
| 70 | + for (const s of await new PtyHost(net).sessions()) if (ids.has(s.name)) out.set(s.name, s.pid); |
| 71 | + return out; |
| 72 | +} |
| 73 | + |
| 74 | +const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); |
| 75 | + |
| 76 | +/** The live pid recorded in the host lock (`<net>/convoy.pid`), or null if absent/stale/dead. */ |
| 77 | +function lockedHostPid(): number | null { |
| 78 | + try { |
| 79 | + const pid = Number.parseInt(readFileSync(join(net, "convoy.pid"), "utf8").trim(), 10); |
| 80 | + return Number.isInteger(pid) && processAlive(pid) ? pid : null; |
| 81 | + } catch { |
| 82 | + return null; |
| 83 | + } |
| 84 | +} |
| 85 | + |
| 86 | +/** Start a foreground `convoy up <net>` subprocess and wait until it is provably HOSTING (it has written |
| 87 | + * its live pid into the host lock — done at startup, before the first reconcile) plus a short grace so |
| 88 | + * its immediate first tick adopts the running agents. Returns the child. */ |
| 89 | +async function startHostAndWait(): Promise<ChildProcess> { |
| 90 | + const child = spawn(process.execPath, [bin, "up", net, "--json"], { env: childEnv(), stdio: ["ignore", "pipe", "pipe"] }); |
| 91 | + let stderr = ""; |
| 92 | + child.stderr?.on("data", (d: Buffer) => (stderr += d.toString())); |
| 93 | + const deadline = Date.now() + 15000; |
| 94 | + while (Date.now() < deadline) { |
| 95 | + if (child.exitCode !== null) throw new Error(`convoy up exited early (code ${child.exitCode}) before hosting:\n${stderr}`); |
| 96 | + if (lockedHostPid() === child.pid) { |
| 97 | + await sleep(700); // let the immediate first tick run so it is genuinely supervising |
| 98 | + return child; |
| 99 | + } |
| 100 | + await sleep(50); |
| 101 | + } |
| 102 | + throw new Error(`convoy up never acquired the host lock within 15s:\n${stderr}`); |
| 103 | +} |
| 104 | + |
| 105 | +/** Signal the host and await its exit. */ |
| 106 | +async function killHost(child: ChildProcess, signal: NodeJS.Signals): Promise<void> { |
| 107 | + const exited = new Promise<void>((r) => child.once("exit", () => r())); |
| 108 | + child.kill(signal); |
| 109 | + await exited; |
| 110 | +} |
| 111 | + |
| 112 | +beforeAll(async () => { |
| 113 | + home = mkdtempSync(join(tmpdir(), "cvy-dec-")); |
| 114 | + net = join(home, "convoy", "default"); |
| 115 | + mkdirSync(join(net, "catalog"), { recursive: true }); // empty catalog: up supervises, launches nothing new |
| 116 | + mkdirSync(join(net, "smalltalk"), { recursive: true }); |
| 117 | + // Stand up the dummy agents as REAL detached daemons, via convoy's own production spawn path. |
| 118 | + for (const a of AGENTS) { |
| 119 | + const workspace = join(net, "agents", a.id); |
| 120 | + writeDummyManifest(workspace, a.id); |
| 121 | + const { spawned, failed } = await spawnFromPtyFile(workspace, net); |
| 122 | + if (failed.length > 0 || spawned.length === 0) throw new Error(`failed to spawn dummy agent ${a.id}: ${JSON.stringify({ spawned, failed })}`); |
| 123 | + } |
| 124 | + // Sanity: both agents are alive before any convoy up touches them. |
| 125 | + const pids = await agentPids(); |
| 126 | + expect(pids.size, "both dummy agents should be running before the test").toBe(AGENTS.length); |
| 127 | + for (const [name, pid] of pids) expect(processAlive(pid), `${name} should be alive at setup`).toBe(true); |
| 128 | +}, 60000); |
| 129 | + |
| 130 | +afterAll(() => { |
| 131 | + // Best-effort teardown: tear the network down, hard-kill any stragglers, restore env, drop the tmp dir. |
| 132 | + try { |
| 133 | + spawnSync(process.execPath, [bin, "down", net, "--force"], { env: childEnv() }); |
| 134 | + } catch { |
| 135 | + /* ignore — the tmp-dir removal below is the backstop */ |
| 136 | + } |
| 137 | + if (savedPtyRoot === undefined) delete process.env["PTY_ROOT"]; |
| 138 | + else process.env["PTY_ROOT"] = savedPtyRoot; |
| 139 | + if (home) rmSync(home, { recursive: true, force: true }); |
| 140 | +}); |
| 141 | + |
| 142 | +describe("convoy up ↔ agent DECOUPLING — killing the supervisor must not kill its agents (the incident)", () => { |
| 143 | + let baseline: Map<string, number | null>; |
| 144 | + |
| 145 | + it("SIGTERM to `convoy up` leaves EVERY agent alive at the SAME pid (a clean stop detaches)", async () => { |
| 146 | + baseline = await agentPids(); |
| 147 | + const host = await startHostAndWait(); |
| 148 | + await killHost(host, "SIGTERM"); |
| 149 | + |
| 150 | + const after = await agentPids(); |
| 151 | + expect(after.size, "no agent record should have vanished").toBe(baseline.size); |
| 152 | + for (const [name, pid] of baseline) { |
| 153 | + expect(after.get(name), `${name} must keep its exact pid across a SIGTERM stop`).toBe(pid); |
| 154 | + expect(processAlive(pid), `${name} (pid ${pid}) must still be ALIVE after SIGTERM to convoy up`).toBe(true); |
| 155 | + } |
| 156 | + }, 45000); |
| 157 | + |
| 158 | + it("SIGKILL to `convoy up` leaves EVERY agent alive at the SAME pid (even a hard crash detaches)", async () => { |
| 159 | + const host = await startHostAndWait(); |
| 160 | + await killHost(host, "SIGKILL"); // hardest case: no graceful teardown runs at all |
| 161 | + |
| 162 | + const after = await agentPids(); |
| 163 | + expect(after.size).toBe(baseline.size); |
| 164 | + for (const [name, pid] of baseline) { |
| 165 | + expect(after.get(name), `${name} must keep its exact pid across a SIGKILL crash`).toBe(pid); |
| 166 | + expect(processAlive(pid), `${name} (pid ${pid}) must still be ALIVE after SIGKILL of convoy up`).toBe(true); |
| 167 | + } |
| 168 | + }, 45000); |
| 169 | + |
| 170 | + it("a FRESH `convoy up` ADOPTS the survivors — same pids, and no launch/respawn/replay of them", async () => { |
| 171 | + // A restart after the (SIGKILL'd) prior host: it must re-attach to the running agents, not cold-boot |
| 172 | + // duplicates. `--once` runs a single reconcile and exits. Adoption ground-truth = the pids are |
| 173 | + // UNCHANGED; the JSON stream must carry no launch/respawn/replay for our sessions. |
| 174 | + const r = spawnSync(process.execPath, [bin, "up", net, "--once", "--json"], { env: childEnv(), encoding: "utf8" }); |
| 175 | + expect(r.status, `convoy up --once should exit 0\nstderr:\n${r.stderr}`).toBe(0); |
| 176 | + |
| 177 | + const after = await agentPids(); |
| 178 | + for (const [name, pid] of baseline) { |
| 179 | + expect(after.get(name), `${name} must keep its exact pid — a fresh up ADOPTED it, did not cold-boot it`).toBe(pid); |
| 180 | + expect(processAlive(pid), `${name} must still be alive after the adopting reconcile`).toBe(true); |
| 181 | + } |
| 182 | + const ids = new Set<string>(AGENTS.map((a) => a.id)); |
| 183 | + for (const line of r.stdout.split("\n")) { |
| 184 | + if (!line.trim()) continue; |
| 185 | + let rec: { type?: string; session?: string; identity?: string }; |
| 186 | + try { |
| 187 | + rec = JSON.parse(line); |
| 188 | + } catch { |
| 189 | + continue; |
| 190 | + } |
| 191 | + if (rec.type === "launch" || rec.type === "respawn" || rec.type === "replay") { |
| 192 | + expect(ids.has(rec.session ?? ""), `a fresh up must not ${rec.type} an adopted survivor (${rec.session})`).toBe(false); |
| 193 | + } |
| 194 | + } |
| 195 | + }, 45000); |
| 196 | + |
| 197 | + it("the INVERSE — `convoy down` DOES tear the agents down (the one true kill path)", async () => { |
| 198 | + const r = spawnSync(process.execPath, [bin, "down", net], { env: childEnv(), encoding: "utf8" }); |
| 199 | + expect(r.status, `convoy down should succeed\nstderr:\n${r.stderr}`).toBe(0); |
| 200 | + |
| 201 | + // Give the kills a moment to land, then assert every agent is actually gone. |
| 202 | + await sleep(500); |
| 203 | + for (const [name, pid] of baseline) { |
| 204 | + expect(processAlive(pid), `${name} (pid ${pid}) must be DEAD after convoy down — down is the only teardown`).toBe(false); |
| 205 | + } |
| 206 | + }, 45000); |
| 207 | +}); |
0 commit comments