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

Commit 2d86cb3

Browse files
authored
convoy up/reconcile: recover missing/unhealthy dings + fix the respawn primitive (manifest-replay, not pty restart) (#82) (#94)
Two linked recovery gaps, both surfaced by tonight's mass-ding-kill (14 dings died; agents needed a full restart): 1) host.respawn used 'pty restart -y', which is unusable for a headless supervisor (VERIFIED empirically in an isolated PTY_ROOT): - pty's stateful-agent guard makes 'pty restart' exit 1 on a role=agent session unless --force, so a dead permanent AGENT was never respawned -- reconcile only bumped results.failed. This is the root of issue #82. - otherwise 'pty restart' tries to ATTACH after the respawn, which hangs a non-TTY host. Replace it with manifest-replay via spawnDaemon: read the session's pty.toml def and re-spawn the verbatim command with the durable ST_ROOT/PTY_ROOT env -- the primitive convoy reload/spawnFromPtyFile already use and that convoy-rust independently adopted (pty rm + pty up). 2) reconcile was session-centric, so a ding whose process was killed AND whose record was GC'd is absent from the session list (nothing to respawn), and a killed-but-registered ding lost its strategy=permanent tag (pty kill strips it) so the permanent-respawn branch skipped it -- either way a LIVE agent was left with a DEAD ding until a full restart. Add an agent-centric DING-HEALTH pass (convoy up AND up --once): for each live agent whose manifest declares a ding, ensure the ding process is alive; replay it from the manifest if missing or dead. Health today = process-alive; a richer ding health signal (the Rust ding) plugs in at the same check. New host primitives: spawnManifestSession / readManifestDef / freeSession, shared by spawnFromPtyFile + respawn + the ding-health pass. New pure reconcile.dingHealthPlan (unit-tested, 10 cases). Live end-to-end proof against the real pty daemon: dead ding, GC'd/missing ding, and role=agent harness respawn all recover. Full suite 256/256 green.
1 parent 71469fe commit 2d86cb3

4 files changed

Lines changed: 225 additions & 19 deletions

File tree

src/host.ts

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
import { basename, dirname, join } from "node:path";
77
import { randomBytes } from "node:crypto";
88
import {
9+
cleanupAll,
910
isGone,
1011
listSessions,
1112
readPtyFile,
1213
spawnDaemon,
1314
updateTags,
15+
type PtySessionDef,
1416
type SessionInfo,
1517
} from "@compoundingtech/pty/client";
1618
import { commandFingerprint, parseStrategyTags, type StrategyTags } from "./flapping-cap.ts";
@@ -31,25 +33,55 @@ function cleanEnv(overlay: NodeJS.ProcessEnv): Record<string, string> {
3133
export async function spawnFromPtyFile(dir: string, root: string | null): Promise<{ spawned: string[]; failed: string[] }> {
3234
if (root) process.env["PTY_ROOT"] = `${root}/pty`;
3335
// The manifest lives in the workspace's .convoy/ overlay; the SESSIONS still run in the workspace
34-
// (cwd: dir below), which decouples the manifest location from the working dir.
36+
// (cwd: dir, via spawnManifestSession), which decouples the manifest location from the working dir.
3537
const file = readPtyFile(join(dir, CONVOY_DIR));
36-
const tomlPath = join(dir, CONVOY_DIR, "pty.toml");
3738
const spawned: string[] = [];
3839
const failed: string[] = [];
3940
for (const def of file.sessions) {
40-
const name = def.id ?? `${def.shortName}-${randomBytes(3).toString("hex")}`;
41-
const tags: Record<string, string> = { ...(def.tags ?? {}), ptyfile: tomlPath, "ptyfile.session": def.shortName };
42-
const env = cleanEnv({ ...process.env, ...(def.env ?? {}), ...(root ? { ST_ROOT: stRootOf(root), PTY_ROOT: `${root}/pty` } : {}) });
4341
try {
44-
await spawnDaemon({ name, command: "sh", args: ["-c", def.command], displayCommand: def.command, cwd: dir, displayName: def.displayName, tags, env });
45-
spawned.push(name);
42+
spawned.push(await spawnManifestSession(dir, def, root));
4643
} catch {
4744
failed.push(def.shortName);
4845
}
4946
}
5047
return { spawned, failed };
5148
}
5249

50+
/** Spawn ONE session def from a pty.toml manifest via `spawnDaemon` — the port's launch-absorb, per session.
51+
* Shared by `spawnFromPtyFile` (bring up the whole manifest) and the reconcile respawn / ding-health
52+
* recovery (replay a single session). Bakes the durable ST_ROOT/PTY_ROOT so a replay never loses the network
53+
* pin; the session's own env (incl. ST_AGENT) rides in `def.env`. `workspace` is the session cwd (the manifest
54+
* lives in `<workspace>/.convoy/`). Returns the spawned session name. Does NOT free a stale record — a REPLAY
55+
* caller `freeSession()`s the id first (see `respawn` / the ding-health pass); a fresh bring-up has no record. */
56+
export async function spawnManifestSession(workspace: string, def: PtySessionDef, root: string | null): Promise<string> {
57+
const tomlPath = join(workspace, CONVOY_DIR, "pty.toml");
58+
const name = def.id ?? `${def.shortName}-${randomBytes(3).toString("hex")}`;
59+
const tags: Record<string, string> = { ...(def.tags ?? {}), ptyfile: tomlPath, "ptyfile.session": def.shortName };
60+
const env = cleanEnv({ ...process.env, ...(def.env ?? {}), ...(root ? { ST_ROOT: stRootOf(root), PTY_ROOT: `${root}/pty` } : {}) });
61+
await spawnDaemon({ name, command: "sh", args: ["-c", def.command], displayCommand: def.command, cwd: workspace, displayName: def.displayName, tags, env });
62+
return name;
63+
}
64+
65+
/** Read a single session def (by its toml key / `shortName`) from the pty.toml at `ptyfile` — the
66+
* `<workspace>/.convoy/pty.toml` path stored in a session's `ptyfile` tag. The manifest is the source of
67+
* truth for the verbatim command + durable env, so a respawn REPLAYS it rather than reconstructing from
68+
* live pty metadata (which dropped the `sh -c "exec claude …"` wrapper and came back a bare shell — the
69+
* capstone LOOP-CLOSED regression). Returns null if the file/def is unreadable or absent. */
70+
export function readManifestDef(ptyfile: string, shortName: string): PtySessionDef | null {
71+
try {
72+
const file = readPtyFile(dirname(ptyfile)); // ptyfile is <ws>/.convoy/pty.toml; readPtyFile takes the dir
73+
return file.sessions.find((d) => d.shortName === shortName) ?? null;
74+
} catch {
75+
return null;
76+
}
77+
}
78+
79+
/** Free a session's on-disk record + socket (pty's `cleanupAll`) so its stable id can be re-spawned cleanly.
80+
* Called before a manifest REPLAY drops the stale (gone) record. Safe on a non-existent name (no-op). */
81+
export function freeSession(name: string): void {
82+
cleanupAll(name);
83+
}
84+
5385
/** One session as convoy's host sees it, projected from pty's typed `SessionInfo` + `SessionMetadata`. */
5486
export interface SupervisedSession {
5587
name: string; // pty id (stable across respawn; survives kill)
@@ -194,13 +226,28 @@ export class PtyHost {
194226
updateTags(name, {}, [key]);
195227
}
196228

197-
/** Respawn a gone session IN PLACE via `pty restart -y <name>` — SIGTERM + respawn using the STORED
198-
* metadata.command, which PRESERVES the agent's real command verbatim (pty-claude's guidance).
199-
* Reconstructing it via `spawnDaemon(command, args)` loses it — an agent's `sh -c "… exec claude …"`
200-
* came back a bare shell, failing the capstone's LOOP-CLOSED gate. PTY_ROOT is pinned in the process
201-
* env by the constructor, so the CLI targets the right registry. */
229+
/** Respawn a gone session by REPLAYING its pty.toml manifest via `spawnDaemon` — NOT `pty restart`.
230+
* `pty restart` is unusable for a headless supervisor (VERIFIED): pty's stateful-agent guard makes it
231+
* `exit 1` on a `role=agent` session unless `--force` (so a dead permanent AGENT was never respawned —
232+
* reconcile only bumped `failed`; the root of issue #82), and it otherwise tries to ATTACH after the
233+
* respawn, which HANGS a non-TTY host. Replaying the manifest re-runs the verbatim stored command with
234+
* the durable ST_ROOT/PTY_ROOT env — the primitive `convoy reload`/`spawnFromPtyFile` already use and
235+
* that convoy-rust independently adopted (pty rm + pty up). `freeSession` drops the stale (gone) record so
236+
* the stable id re-spawns cleanly. Returns false if the manifest/def can't be resolved or the spawn throws
237+
* (honest failure the caller surfaces — better than the old silent `pty restart` no-op). */
202238
async respawn(s: SupervisedSession): Promise<boolean> {
203-
return (await run("pty", ["restart", "-y", s.name])).ok;
239+
const ptyfile = s.tags["ptyfile"];
240+
const sessionKey = s.tags["ptyfile.session"];
241+
if (!ptyfile || !sessionKey) return false; // not a convoy manifest session — nothing to replay
242+
const def = readManifestDef(ptyfile, sessionKey);
243+
if (!def) return false;
244+
try {
245+
freeSession(s.name); // free the stale record + socket before re-spawning the same stable id
246+
await spawnManifestSession(workspaceOfPtyfile(ptyfile), def, this.root);
247+
return true;
248+
} catch {
249+
return false;
250+
}
204251
}
205252

206253
/** Stop a session (teardown). Residual `pty kill` shell — the client doesn't export a daemon-kill;

src/reconcile.test.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { describe, it, expect } from "vitest";
2-
import { agentBusId, reconcilePlan, type CatalogEntry } from "./reconcile.ts";
2+
import { agentBusId, dingHealthPlan, reconcilePlan, type CatalogEntry } from "./reconcile.ts";
33
import type { SupervisedSession } from "./host.ts";
44
import type { AgentFile } from "./agent-file.ts";
5+
import type { PtySessionDef } from "@compoundingtech/pty/client";
56

67
// A session carrying its bus id in a plain "busId" tag; the injected resolver reads it (the real one reads
78
// ST_AGENT out of the pty.toml). pid/status drive the gone / adopt-alive logic.
@@ -85,3 +86,82 @@ describe("reconcilePlan — desired (catalog) vs actual (sessions), host-filtere
8586
expect(plan.otherHost).toEqual([theirs]);
8687
});
8788
});
89+
90+
describe("dingHealthPlan — agent-centric ding recovery (reconcile-recreates-missing/unhealthy-ding, #82)", () => {
91+
const PF = "/w/.convoy/pty.toml";
92+
const dingDef: PtySessionDef = { shortName: "ding", id: "silber.wk.ding", displayName: "wk-ding", command: "st ding silber.wk --identity silber.wk-claude --root /net" };
93+
const declaresDing = (): PtySessionDef | null => dingDef;
94+
const declaresNoDing = (): PtySessionDef | null => null;
95+
const alive = (...pids: number[]) => (pid: number | null): boolean => pid !== null && pids.includes(pid);
96+
97+
// A harness or ding session keyed by its role + ptyfile tags. pid + status drive liveness.
98+
function ss(role: string, o: { name?: string; ptyfile?: string; session?: string; pid?: number | null; status?: string } = {}): SupervisedSession {
99+
const tags: Record<string, string> = { role };
100+
if (o.ptyfile) tags["ptyfile"] = o.ptyfile;
101+
if (o.session) tags["ptyfile.session"] = o.session;
102+
return { name: o.name ?? role, cwd: null, command: "", args: [], status: (o.status ?? "running") as never, exitedAt: null, exitCode: null, pid: o.pid ?? 100, tags };
103+
}
104+
const harness = (o: Partial<Parameters<typeof ss>[1]> = {}): SupervisedSession => ss("agent", { name: "silber.wk", ptyfile: PF, session: "claude", pid: 100, ...o });
105+
const ding = (o: Partial<Parameters<typeof ss>[1]> = {}): SupervisedSession => ss("ding", { name: "silber.wk.ding", ptyfile: PF, session: "ding", pid: 200, ...o });
106+
107+
it("HEALTHY: harness alive + ding session alive → no action", () => {
108+
const plan = dingHealthPlan([harness(), ding()], declaresDing, alive(100, 200));
109+
expect(plan).toEqual([]);
110+
});
111+
112+
it("MISSING: harness alive, NO ding session at all (killed + GC'd) → heal, staleDing=null", () => {
113+
const plan = dingHealthPlan([harness()], declaresDing, alive(100));
114+
expect(plan).toHaveLength(1);
115+
expect(plan[0]!.harness.name).toBe("silber.wk");
116+
expect(plan[0]!.staleDing).toBeNull();
117+
expect(plan[0]!.dingDef).toBe(dingDef);
118+
});
119+
120+
it("DEAD: harness alive, ding session present but process dead → heal, staleDing=the dead ding", () => {
121+
const dead = ding({ pid: 999 });
122+
const plan = dingHealthPlan([harness(), dead], declaresDing, alive(100)); // 999 not alive
123+
expect(plan).toHaveLength(1);
124+
expect(plan[0]!.staleDing).toBe(dead);
125+
});
126+
127+
it("gone-but-pid-ALIVE ding (transient CPU-spike report) → NOT healed (never double-spawn a live process)", () => {
128+
const transient = ding({ pid: 200, status: "vanished" });
129+
const plan = dingHealthPlan([harness(), transient], declaresDing, alive(100, 200));
130+
expect(plan).toEqual([]);
131+
});
132+
133+
it("NO DING DECLARED: agent's manifest has no ding (e.g. claude on MCP transport) → skip", () => {
134+
const plan = dingHealthPlan([harness()], declaresNoDing, alive(100));
135+
expect(plan).toEqual([]);
136+
});
137+
138+
it("DEAD HARNESS: harness gone + pid dead → skipped (the respawn/launch paths own it, not this pass)", () => {
139+
const plan = dingHealthPlan([harness({ status: "exited", pid: 999 })], declaresDing, alive()); // nothing alive
140+
expect(plan).toEqual([]);
141+
});
142+
143+
it("gone-but-ALIVE harness (adopt-alive) with a missing ding → still healed", () => {
144+
const plan = dingHealthPlan([harness({ status: "vanished", pid: 100 })], declaresDing, alive(100));
145+
expect(plan).toHaveLength(1);
146+
});
147+
148+
it("non-agent session (role != agent) is ignored even if it looks ding-less", () => {
149+
const web = ss("web", { name: "svc", ptyfile: PF, session: "svc", pid: 100 });
150+
const plan = dingHealthPlan([web], declaresDing, alive(100));
151+
expect(plan).toEqual([]);
152+
});
153+
154+
it("harness without a ptyfile tag → skipped (nothing to replay from)", () => {
155+
const noPf = ss("agent", { name: "silber.wk", session: "claude", pid: 100 }); // no ptyfile
156+
const plan = dingHealthPlan([noPf], declaresDing, alive(100));
157+
expect(plan).toEqual([]);
158+
});
159+
160+
it("MULTI: agent A healthy, agent B missing its ding → only B is healed (per-agent, by ptyfile)", () => {
161+
const aH = ss("agent", { name: "a", ptyfile: "/a/.convoy/pty.toml", session: "claude", pid: 1 });
162+
const aD = ss("ding", { name: "a.ding", ptyfile: "/a/.convoy/pty.toml", session: "ding", pid: 2 });
163+
const bH = ss("agent", { name: "b", ptyfile: "/b/.convoy/pty.toml", session: "claude", pid: 3 }); // ding missing
164+
const plan = dingHealthPlan([aH, aD, bH], declaresDing, alive(1, 2, 3));
165+
expect(plan.map((x) => x.harness.name)).toEqual(["b"]);
166+
});
167+
});

src/reconcile.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
// src/fabric-sync.ts), and B's reconcile sees host==B + launches it. No RPC — the synced folder IS the
1010
// scheduler. Pure (no side effects) so it's unit-testable; up executes the plan.
1111

12+
import type { PtySessionDef } from "@compoundingtech/pty/client";
1213
import type { AgentFile } from "./agent-file.ts";
1314
import { gone, processAlive, type SupervisedSession } from "./host.ts";
1415

@@ -72,3 +73,47 @@ export function reconcilePlan(
7273
}
7374
return plan;
7475
}
76+
77+
/** One ding-health repair: a LIVE harness whose ding sidecar is missing or dead, the manifest def to replay it
78+
* from, and the stale ding session (if any) to free first. */
79+
export interface DingHealAction {
80+
harness: SupervisedSession;
81+
dingDef: PtySessionDef;
82+
staleDing: SupervisedSession | null;
83+
}
84+
85+
/** Which LIVE agents have a missing/unhealthy ding sidecar — AGENT-centric, unlike the SESSION-centric respawn
86+
* loop. For each harness (role=agent) that is alive and whose manifest DECLARES a ding, the ding is healthy iff
87+
* a ding session exists for the same pty.toml AND its process is alive; otherwise it needs a manifest replay.
88+
* `dingDefOf` reads the manifest's ding def for a ptyfile (injected → keeps this pure/testable); null means the
89+
* agent declares no ding, so skip it. `isAlive` defaults to the real pid probe. Pure.
90+
*
91+
* This is the reconcile-recreates-missing/unhealthy-ding fix (issue #82's sibling). Two ways the session loop
92+
* misses a dead ding, both leaving a LIVE agent ding-less until a full restart: (1) a ding whose process was
93+
* killed AND whose record was GC'd is absent from the session list entirely → nothing to respawn; (2) a
94+
* killed-but-registered ding lost its `strategy=permanent` tag (`pty kill` strips it) → the permanent-respawn
95+
* branch skips it. Anchoring on the LIVE harness + the declared manifest catches both. */
96+
export function dingHealthPlan(
97+
sessions: SupervisedSession[],
98+
dingDefOf: (ptyfile: string) => PtySessionDef | null,
99+
isAlive: (pid: number | null) => boolean = processAlive,
100+
): DingHealAction[] {
101+
const dingBy = new Map<string, SupervisedSession>(); // ptyfile → its ding session
102+
for (const s of sessions) {
103+
const pf = s.tags["ptyfile"];
104+
if (pf && s.tags["ptyfile.session"] === "ding") dingBy.set(pf, s);
105+
}
106+
const out: DingHealAction[] = [];
107+
for (const s of sessions) {
108+
if (s.tags["role"] !== "agent") continue; // harness sessions only (the ding sidecar is role=ding)
109+
if (gone(s) && !isAlive(s.pid)) continue; // a DEAD harness is the respawn/launch paths' job, not this one
110+
const ptyfile = s.tags["ptyfile"];
111+
if (!ptyfile) continue;
112+
const dingDef = dingDefOf(ptyfile);
113+
if (!dingDef) continue; // agent declares no ding (e.g. a claude agent on the MCP transport)
114+
const ding = dingBy.get(ptyfile) ?? null;
115+
if (ding && isAlive(ding.pid)) continue; // ding healthy → nothing to do
116+
out.push({ harness: s, dingDef, staleDing: ding });
117+
}
118+
return out;
119+
}

0 commit comments

Comments
 (0)