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

Commit 3b8eedd

Browse files
myobieclaude
andauthored
fix(up): a fresh foreground convoy up UN-PARKS its members — restore the FULL fleet after an outage (#106)
Parking-recovery for the 2026-07-22 incident: a supervisor bring-up after a mass outage brought back only part of the fleet; the rest stayed PARKED from a prior supervisor's give-up and had to be hand-launched. Root cause: `strategy.status=flapping` + the fast-fail counter PERSIST to a session's tags (the on-disk supervision contract), so they outlive the supervisor that wrote them. An outage drives the cap to its limit → the agents park → and a fresh `convoy up`, reading those stale tags, hits classify's `isFlapping → skip` and never relaunches them. Fix: a foreground `convoy up` is a DELIBERATE bring-up — the operator gesture that says "restore the fleet" — so at startup it clears the park AND zeroes the fast-fail counter for permanent members (regardless of prior fail count), giving each a fresh cap budget. The cap still re-accrues tick-to-tick WITHIN this supervisor's watch (the real crash-loop protection). The `--once` shepherd cron does NOT un-park: it runs every few minutes, so un-parking there would relaunch a genuinely broken agent every tick — parking must stay durable for it. - flapping-cap.ts: `clearParkForFreshSupervisor` (pure) — the reset decision, unit-tested. - up.ts: the FRESH-SUPERVISOR UN-PARK startup pass (foreground only), which clears the on-disk park (removes the status tag — updateTags MERGES) and seeds the in-memory classifier state. - Tests: pure cases for the reset + a process-level proof (a parked, gone-but-recorded agent is UN-PARKED and RELAUNCHED by a fresh foreground up, while `--once` leaves it parked). Part 2 of 4 of the decoupling-hardening task. Claude-Session: https://claude.ai/code/session_014gbfntB6cu21sL4YBp21LF Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c9e22d6 commit 3b8eedd

4 files changed

Lines changed: 277 additions & 0 deletions

File tree

src/flapping-cap.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { describe, it, expect } from "vitest";
55
import {
66
classify,
77
classifyFailedAttempt,
8+
clearParkForFreshSupervisor,
89
commandFingerprint,
910
FLAPPING_STATUS,
1011
effectiveLimit,
@@ -197,3 +198,42 @@ describe("classifyFailedAttempt — a recovery attempt that never produced a lea
197198
if (parked.kind === "flap") expect(writtenTags(parked.tags)[TAG.status]).toBe(FLAPPING_STATUS);
198199
});
199200
});
201+
202+
describe("clearParkForFreshSupervisor — a fresh foreground `convoy up` restores the FULL fleet (parking-recovery)", () => {
203+
it("ACCEPTANCE: a PARKED member is un-parked — status cleared AND the counter zeroed (relaunchable again)", () => {
204+
// The reproduced bug: an outage drives the cap to its limit → the agent parks → and a fresh supervisor,
205+
// reading the persisted `status=flapping`, would `skip` it forever. A deliberate bring-up must not inherit
206+
// that. Both fields reset: clearing status alone is not enough — a counter still at the cap re-parks on
207+
// the very next fast fail.
208+
const cleared = clearParkForFreshSupervisor(tags({ status: FLAPPING_STATUS, consecutiveFastFails: LIMIT }));
209+
expect(cleared).not.toBeNull();
210+
expect(cleared?.status).toBeNull();
211+
expect(cleared?.consecutiveFastFails).toBe(0);
212+
});
213+
214+
it("resets a NON-parked member with prior fails too — 'regardless of prior fail count' (Nathan mandate)", () => {
215+
const cleared = clearParkForFreshSupervisor(tags({ status: null, consecutiveFastFails: LIMIT - 1 }));
216+
expect(cleared?.consecutiveFastFails).toBe(0);
217+
expect(cleared?.status).toBeNull();
218+
});
219+
220+
it("is a NO-OP for a clean member (no park, counter 0) — the caller writes no tag needlessly", () => {
221+
expect(clearParkForFreshSupervisor(tags({ status: null, consecutiveFastFails: 0 }))).toBeNull();
222+
});
223+
224+
it("preserves the rest of the strategy state — only status + counter are touched", () => {
225+
const before = tags({ status: FLAPPING_STATUS, consecutiveFastFails: LIMIT, commandHash: HASH_A, lastRespawnAt: at(500), fastFailLimitOverride: 5, fastFailWindowOverride: 120 });
226+
const cleared = clearParkForFreshSupervisor(before);
227+
expect(cleared?.commandHash).toBe(HASH_A);
228+
expect(cleared?.lastRespawnAt).toEqual(at(500));
229+
expect(cleared?.fastFailLimitOverride).toBe(5);
230+
expect(cleared?.fastFailWindowOverride).toBe(120);
231+
});
232+
233+
it("the written tags drop the park status and carry a zeroed counter (what up() persists to disk)", () => {
234+
const cleared = clearParkForFreshSupervisor(tags({ status: FLAPPING_STATUS, consecutiveFastFails: LIMIT }));
235+
const written = writtenTags(cleared!);
236+
expect(written[TAG.status]).toBeUndefined(); // no park written — up() also REMOVES the on-disk status tag
237+
expect(written[TAG.consecutive]).toBe("0");
238+
});
239+
});

src/flapping-cap.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,29 @@ export function classifyFailedAttempt(input: {
151151
return { kind: "respawn", tags: { ...tags, lastRespawnAt: now, consecutiveFastFails: nextCounter, commandHash: currentHash, status: null } };
152152
}
153153

154+
/** A fresh FOREGROUND supervisor gives every member a clean cap budget (convoy parking-recovery, 2026-07-22).
155+
*
156+
* The bug this fixes: `strategy.status=flapping` and the fast-fail counter PERSIST to the session's tags
157+
* (the on-disk supervision contract), so they outlive the supervisor that wrote them. A mass outage drives
158+
* the cap to its limit → the agents park → and then a FRESH `convoy up`, reading those stale tags, hits the
159+
* `isFlapping(...) → skip` gate in `classify` and NEVER relaunches them. The reconstructed incident: a
160+
* bring-up after an outage brought back only some of the fleet; the rest stayed parked from a prior
161+
* supervisor's give-up and had to be hand-launched.
162+
*
163+
* A foreground `convoy up` is a DELIBERATE bring-up — the operator gesture that says "restore the fleet" —
164+
* so it must not inherit a prior supervisor's verdict. This clears the park (status) AND zeroes the counter,
165+
* regardless of prior fail count, giving each member a fresh budget; the cap still re-accrues tick-to-tick
166+
* WITHIN this supervisor's watch (the real crash-loop protection). Returns the reset tags, or null when
167+
* nothing needs clearing (not parked, counter already 0) so the caller writes no tag needlessly.
168+
*
169+
* The `--once` shepherd cron does NOT call this: it runs every few minutes, so un-parking there would
170+
* relaunch a genuinely broken agent on every tick — parking MUST stay durable across `--once`. This
171+
* reset is scoped to the rare, intentional foreground bring-up. Pure → unit-testable. */
172+
export function clearParkForFreshSupervisor(tags: StrategyTags): StrategyTags | null {
173+
if (tags.status !== FLAPPING_STATUS && tags.consecutiveFastFails === 0) return null;
174+
return { ...tags, status: null, consecutiveFastFails: 0 };
175+
}
176+
154177
/** Classify one permanent-and-gone session (spec §5.3). Pure: same inputs → same decision. */
155178
export function classify(input: {
156179
session: string;

src/up-parking-recovery.test.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// PARKING-RECOVERY (Nathan mandate, convoy incident 2026-07-22) — a supervisor bring-up after a mass
2+
// outage MUST restore the FULL fleet. The bug: `strategy.status=flapping` + the fast-fail counter persist
3+
// to a session's tags, so an outage that drives the cap to its limit PARKS the agents, and then a fresh
4+
// `convoy up`, reading those stale tags, `skip`s them forever (classify: `isFlapping → skip`). The
5+
// reconstructed incident: a bring-up brought back only part of the fleet; the rest stayed parked from a
6+
// prior supervisor's give-up and had to be hand-launched.
7+
//
8+
// The fix (see up.ts FRESH-SUPERVISOR UN-PARK + flapping-cap.ts clearParkForFreshSupervisor): a foreground
9+
// `convoy up` is a DELIBERATE bring-up, so at startup it clears the park + zeroes the counter for permanent
10+
// members (regardless of prior fail count); the cap re-accrues tick-to-tick within THIS supervisor's watch.
11+
// The `--once` shepherd cron does NOT un-park (it runs every few minutes — un-parking there would relaunch
12+
// a genuinely broken agent every tick), so parking stays durable for it.
13+
//
14+
// This proves it end to end: it stands up a PARKED, gone-but-recorded agent (via convoy's own spawn path
15+
// plus the same `strategy.*` tags a prior supervisor would have written), then asserts a fresh FOREGROUND
16+
// up UN-PARKS and RELAUNCHES it, while a fresh `--once` up leaves it parked. Process-level (real daemons +
17+
// a real `convoy up`), scoped to a throwaway XDG_STATE_HOME. Lives in the vitest gate (test.yml), not the
18+
// hermetic nix flake check.
19+
20+
import { afterEach, describe, expect, it } from "vitest";
21+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
22+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23+
import { tmpdir } from "node:os";
24+
import { dirname, join } from "node:path";
25+
import { fileURLToPath } from "node:url";
26+
import { updateTags } from "@compoundingtech/pty/client";
27+
import { gone, PtyHost, processAlive, spawnFromPtyFile } from "./host.ts";
28+
import { FLAPPING_STATUS, TAG } from "./flapping-cap.ts";
29+
30+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
31+
const bin = join(repoRoot, "bin", "convoy");
32+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
33+
34+
let home = "";
35+
let net = "";
36+
let host: ChildProcess | null = null;
37+
const savedPtyRoot = process.env["PTY_ROOT"];
38+
39+
function childEnv(): NodeJS.ProcessEnv {
40+
return { ...process.env, XDG_STATE_HOME: home, ST_ROOT: "", PTY_ROOT: "" };
41+
}
42+
43+
function freshNet(): void {
44+
home = mkdtempSync(join(tmpdir(), "cvy-park-"));
45+
net = join(home, "convoy", "default");
46+
mkdirSync(join(net, "catalog"), { recursive: true });
47+
mkdirSync(join(net, "smalltalk"), { recursive: true });
48+
}
49+
50+
/** Stand up one permanent agent whose harness EXITS quickly (`sleep 1`), so it lands in the gone-but-
51+
* recorded state a real crashed agent occupies — the shape the park tags attach to and a replay relaunches. */
52+
async function spawnAgent(id: string): Promise<void> {
53+
const workspace = join(net, "agents", id);
54+
mkdirSync(join(workspace, ".convoy"), { recursive: true });
55+
writeFileSync(
56+
join(workspace, ".convoy", "pty.toml"),
57+
`prefix = "${id}"\n\n[sessions.claude]\nid = "${id}"\ncommand = "sleep 1"\n\n[sessions.claude.tags]\nstrategy = "permanent"\nrole = "agent"\n\n[sessions.claude.env]\nST_AGENT = "${id}"\n`,
58+
);
59+
const { spawned, failed } = await spawnFromPtyFile(workspace, net);
60+
if (failed.length > 0 || spawned.length === 0) throw new Error(`spawn ${id} failed: ${JSON.stringify({ spawned, failed })}`);
61+
}
62+
63+
/** Poll until the agent is in the real CRASHED shape: gone-but-recorded with a DEAD pid. The harness
64+
* exits, and ~0.5s later the pty daemon writes its exit record and shuts down (pid clears) — only then is
65+
* the pid dead, so `convoy up` treats it as a genuine death to RESPAWN rather than a transient-gone to
66+
* ADOPT (the adopt-alive guard: reported-gone but pid-alive → adopt, never respawn). */
67+
async function waitCrashed(id: string, timeoutMs = 12000): Promise<void> {
68+
const deadline = Date.now() + timeoutMs;
69+
while (Date.now() < deadline) {
70+
const s = (await new PtyHost(net).sessions()).find((x) => x.name === id);
71+
if (s && gone(s) && !processAlive(s.pid)) return;
72+
await sleep(150);
73+
}
74+
throw new Error(`${id} never reached the crashed (gone + dead-pid) state`);
75+
}
76+
77+
/** Write the park a prior supervisor would have left: status=flapping at the cap. */
78+
function park(id: string): void {
79+
updateTags(id, { [TAG.status]: FLAPPING_STATUS, [TAG.consecutive]: "3" });
80+
}
81+
82+
/** The persisted strategy.status tag for an agent (undefined once cleared). */
83+
async function statusTag(id: string): Promise<string | undefined> {
84+
const s = (await new PtyHost(net).sessions()).find((x) => x.name === id);
85+
return s?.tags[TAG.status];
86+
}
87+
88+
function lockedHostPid(): number | null {
89+
try {
90+
const pid = Number.parseInt(readFileSync(join(net, "convoy.pid"), "utf8").trim(), 10);
91+
return Number.isInteger(pid) && processAlive(pid) ? pid : null;
92+
} catch {
93+
return null;
94+
}
95+
}
96+
97+
/** Start a foreground `convoy up --json`, wait until it is hosting, run for `runMs` (long enough for the
98+
* startup un-park + the immediate first reconcile), then kill it. Returns the captured JSONL stdout. */
99+
async function runForegroundUp(runMs: number): Promise<string> {
100+
const child = spawn(process.execPath, [bin, "up", net, "--json"], { env: childEnv(), stdio: ["ignore", "pipe", "pipe"] });
101+
host = child;
102+
let stdout = "";
103+
child.stdout?.on("data", (d: Buffer) => (stdout += d.toString()));
104+
const deadline = Date.now() + 15000;
105+
while (Date.now() < deadline && lockedHostPid() !== child.pid) {
106+
if (child.exitCode !== null) throw new Error(`convoy up exited early (code ${child.exitCode})`);
107+
await sleep(50);
108+
}
109+
await sleep(runMs);
110+
const exited = new Promise<void>((r) => child.once("exit", () => r()));
111+
child.kill("SIGTERM");
112+
await exited;
113+
host = null;
114+
return stdout;
115+
}
116+
117+
/** Parse a JSONL stream into records. */
118+
function records(stream: string): Array<{ type?: string; session?: string; spawned?: string[] }> {
119+
const out: Array<{ type?: string; session?: string; spawned?: string[] }> = [];
120+
for (const line of stream.split("\n")) {
121+
if (!line.trim()) continue;
122+
try {
123+
out.push(JSON.parse(line));
124+
} catch {
125+
/* human line leaked to stdout? ignore */
126+
}
127+
}
128+
return out;
129+
}
130+
131+
afterEach(() => {
132+
if (host) {
133+
try {
134+
host.kill("SIGKILL");
135+
} catch {
136+
/* ignore */
137+
}
138+
host = null;
139+
}
140+
try {
141+
spawnSync(process.execPath, [bin, "down", net, "--force"], { env: childEnv() });
142+
} catch {
143+
/* ignore */
144+
}
145+
if (home) rmSync(home, { recursive: true, force: true });
146+
if (savedPtyRoot === undefined) delete process.env["PTY_ROOT"];
147+
else process.env["PTY_ROOT"] = savedPtyRoot;
148+
});
149+
150+
describe("parking recovery — a fresh foreground `convoy up` restores a PARKED agent (Nathan mandate)", () => {
151+
it("ACCEPTANCE: a fresh FOREGROUND up UN-PARKS and RELAUNCHES a parked gone agent (regardless of fail count)", async () => {
152+
freshNet();
153+
const id = "prk-alpha";
154+
await spawnAgent(id);
155+
await waitCrashed(id);
156+
park(id);
157+
expect(await statusTag(id), "the agent must be parked before the bring-up").toBe(FLAPPING_STATUS);
158+
159+
const out = records(await runForegroundUp(2500));
160+
161+
// It was UN-PARKED (the startup pass cleared the persisted park)...
162+
expect(out.some((r) => r.type === "unpark" && r.session === id), "a fresh foreground up must emit an unpark for the parked agent").toBe(true);
163+
// ...and then RELAUNCHED (the reconcile respawned it once un-parking made it eligible — a still-parked
164+
// agent would have been skipped and never respawned/replayed).
165+
const relaunched = out.some((r) => (r.type === "respawn" && r.session === id) || (r.type === "replay" && (r.spawned ?? []).includes(id)));
166+
expect(relaunched, "a fresh foreground up must relaunch the un-parked agent").toBe(true);
167+
// The persisted park is gone from disk.
168+
expect(await statusTag(id), "the flapping status tag must be cleared on disk after the bring-up").not.toBe(FLAPPING_STATUS);
169+
}, 45000);
170+
171+
it("CONTROL: a `--once` bring-up does NOT un-park — parking stays durable for the shepherd cron", async () => {
172+
freshNet();
173+
const id = "prk-beta";
174+
await spawnAgent(id);
175+
await waitCrashed(id);
176+
park(id);
177+
178+
const r = spawnSync(process.execPath, [bin, "up", net, "--once", "--json"], { env: childEnv(), encoding: "utf8" });
179+
expect(r.status, `--once should exit 0\nstderr:\n${r.stderr}`).toBe(0);
180+
181+
// No un-park was emitted, and the park is still on disk — `--once` must respect it (else it would
182+
// relaunch a genuinely broken agent every few minutes).
183+
expect(records(r.stdout).some((rec) => rec.type === "unpark"), "--once must NOT un-park anything").toBe(false);
184+
expect(await statusTag(id), "--once must leave the park intact").toBe(FLAPPING_STATUS);
185+
}, 45000);
186+
});

src/up.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { defaultConvoyNetwork, isNetworkName, networkDirForName, networkDirOfStR
1111
import {
1212
classify,
1313
classifyFailedAttempt,
14+
clearParkForFreshSupervisor,
1415
effectiveLimit,
1516
effectiveWindow,
1617
isFlapping,
@@ -359,6 +360,33 @@ export async function up(opts: UpOptions): Promise<number> {
359360
const notify = opts.notify ?? [];
360361
const dingTargets = (crashed: SupervisedSession, sessions: readonly SupervisedSession[]): string[] => crashDingTargets(crashed, sessions, notify, busIdOf);
361362

363+
// FRESH-SUPERVISOR UN-PARK (parking-recovery, 2026-07-22). A foreground `convoy up` is a DELIBERATE
364+
// bring-up — after a mass outage it MUST restore the FULL fleet, not inherit a prior supervisor's
365+
// give-up. `strategy.status=flapping` + the fast-fail counter persist to each session's tags, so a
366+
// parked agent stays parked across a restart (classify's `isFlapping → skip`), and a bring-up brought
367+
// back only part of the fleet — the rest had to be hand-launched. So, ONCE at startup, clear the park
368+
// and zero the counter for permanent members (regardless of prior fail count); the cap re-accrues
369+
// tick-to-tick within THIS supervisor's watch. A fully-gone parked agent (no session record left) is
370+
// relaunched by the catalog pass instead — this handles the gone-but-recorded ones the cap would skip.
371+
//
372+
// `--once` (the shepherd cron) SKIPS this: it runs every few minutes, so un-parking there would
373+
// relaunch a genuinely broken agent every tick. Parking must stay durable across `--once`.
374+
if (opts.once !== true) {
375+
const startupNow = new Date();
376+
for (const s of await host.sessions()) {
377+
if (!isPermanent(s)) continue;
378+
const cleared = clearParkForFreshSupervisor(parseStrategyTags(s.tags));
379+
if (!cleared) continue;
380+
host.removeTag(s.name, TAG.status); // updateTags MERGES — the park must be removed, not just overwritten
381+
host.setTags(s.name, writtenTags(cleared)); // consecutive-fast-fails → 0
382+
state.set(s.name, cleared);
383+
emit(
384+
{ type: "unpark", identity: logicalId(s), session: s.name, ts: isoString(startupNow) },
385+
`[convoy-up] fresh supervisor — cleared parked/flapping state for ${logicalId(s)} session=${s.name}; giving it a fresh cap budget`,
386+
);
387+
}
388+
}
389+
362390
const tick = async (): Promise<void> => {
363391
const now = new Date();
364392
// Manifest replay relaunches EVERY limb of an agent, so it must happen at most once per agent per

0 commit comments

Comments
 (0)