Skip to content
This repository was archived by the owner on Jul 24, 2026. It is now read-only.

Commit c9e22d6

Browse files
myobieclaude
andauthored
test(up): the DECISIVE decoupling test — killing convoy up must not kill its agents (#105)
The permanent regression guard for the Nomad invariant after the 2026-07-22 incident (a `convoy up` restart mid-cutover took the whole hetz fleet down, exit 143 across 11). Settles the forensic a-vs-b: (a) a teardown MISUSE vs (b) a real decoupling bug. It stands up real, detached agent daemons via convoy's OWN production spawn primitive (spawnFromPtyFile → spawnDaemon), supervises them with a real `convoy up` subprocess, then kills that subprocess BOTH ways (SIGTERM and SIGKILL) and asserts every agent is still alive at the SAME pid. A fresh `convoy up` must then ADOPT the survivors (same pids, no launch/respawn/replay), and the inverse — `convoy down` — must actually tear them down. Result: all four pass => the decoupling HOLDS in practice, so the incident was misuse (a), NOT bug (b). If these ever fail, this file is the reproduction of (b). Process-level by necessity (real daemons + real signals), scoped to a throwaway XDG_STATE_HOME so it can never touch a live network. Runs in the vitest gate (test.yml, normal runner) — the lane that already shells out to real bin/convoy — not the hermetic nix flake check. Claude-Session: https://claude.ai/code/session_014gbfntB6cu21sL4YBp21LF Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4c586a4 commit c9e22d6

1 file changed

Lines changed: 207 additions & 0 deletions

File tree

src/up-decoupling.test.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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

Comments
 (0)