Skip to content
This repository was archived by the owner on Jul 24, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,33 @@ describe("writePtyToml (pinned hostname-prefixed ids, cold start)", () => {
rmSync(dir, { recursive: true, force: true });
}
});

it("crash-ding tags: cos gets convoy.tier=cos + spawner gets convoy.spawner, on the HARNESS session only (not the ding)", () => {
const dir = mkdtempSync(join(tmpdir(), "convoy-ptytoml-tags-"));
try {
// a worker spawned by a supervisor: convoy.spawner recorded, no convoy.tier (not cos)
writePtyToml(dir, spec({ role: "worker", identity: "wk-claude" }), { spawner: "sup-claude" });
const wkToml = readFileSync(join(dir, "pty.toml"), "utf8");
expect(wkToml).toContain('"convoy.spawner" = "sup-claude"');
expect(wkToml).not.toContain("convoy.tier");
// the spawner tag is on the harness session, NOT the ding (else a crash double-dings the same busId)
expect(wkToml.match(/convoy\.spawner/g)?.length).toBe(1);

// the CoS: convoy.tier=cos stamped (the always-ding backstop)
writePtyToml(dir, spec({ role: "chief-of-staff", identity: "cos-claude" }));
const cosToml = readFileSync(join(dir, "pty.toml"), "utf8");
expect(cosToml).toContain('"convoy.tier" = "cos"');
expect(cosToml).not.toContain("convoy.spawner"); // no spawner passed

// no spawner passed + not cos → neither tag (a human-spawned worker → cos-only ding downstream)
writePtyToml(dir, spec({ role: "worker", identity: "wk2-claude" }));
const plainToml = readFileSync(join(dir, "pty.toml"), "utf8");
expect(plainToml).not.toContain("convoy.spawner");
expect(plainToml).not.toContain("convoy.tier");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

describe("discoverSmalltalkDir (fresh-install hook discovery, no SMALLTALK_DIR needed)", () => {
Expand Down
19 changes: 15 additions & 4 deletions src/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,23 @@ export function dingCommand(busId: string, claudeSessionId: string, root?: strin

/** Serialize the per-agent pty.toml (pty's manifest format — NOT a convoy.toml). Pins the session ids
* to `<prefix>.<agentShort>` (claude) and `<prefix>.<agentShort>.ding` so the ding + name refs stay
* stable across respawns; prefix defaults to the short hostname. */
export function writePtyToml(dir: string, spec: AgentSpec): void {
* stable across respawns; prefix defaults to the short hostname. `opts.spawner` (the bus id of whoever ran
* `convoy add`, from their ST_AGENT) is stamped on the HARNESS session so a crash-ding reaches this agent's
* ACTUAL supervisor, not the whole permanent crew (see up.ts crashDingTargets). */
export function writePtyToml(dir: string, spec: AgentSpec, opts?: { spawner?: string | null }): void {
const busId = spec.identity; // the bus identity, e.g. convoy-claude
const root = spec.networkRoot;
const harnessId = sessionId(spec); // e.g. silber.convoy (agentShort strips the -claude/-codex suffix)
const dingId = `${harnessId}.ding`; // e.g. silber.convoy.ding
const permanent = specPermanent(spec);
const stTag = root ? { "st.network": root } : {};
// Crash-ding targeting tags (read by up.ts crashDingTargets), HARNESS session only — never the ding sidecar
// (else a crash double-dings the same busId): `convoy.tier=cos` marks the CoS (the always-ding backstop);
// `convoy.spawner` records who spawned this agent (its supervisor) so a worker crash pages the parent.
const agentTags: Record<string, string> = {
...(spec.role === "chief-of-staff" ? { "convoy.tier": "cos" } : {}),
...(opts?.spawner ? { "convoy.spawner": opts.spawner } : {}),
};
const env: Record<string, string> = { ST_AGENT: busId };
if (root) {
env["ST_ROOT"] = root;
Expand All @@ -141,7 +150,7 @@ export function writePtyToml(dir: string, spec: AgentSpec): void {
[HARNESS_SESSION_KEY[spec.harness]]: {
id: harnessId,
command: harnessCommand(spec.harness, specPermissionMode(spec), bootPrompt(spec.role)),
tags: { role: "agent", ...(permanent ? { strategy: "permanent" } : {}), ...stTag },
tags: { role: "agent", ...(permanent ? { strategy: "permanent" } : {}), ...stTag, ...agentTags },
env: harnessEnv,
},
...(usesDing(spec)
Expand Down Expand Up @@ -255,7 +264,9 @@ export async function nativeLaunch(spec: AgentSpec): Promise<{ spawned: string[]

writeContextFiles(dir, spec);
writeHooks(dir);
writePtyToml(dir, spec);
// The spawner = whoever ran `convoy add` (their bus id, from ST_AGENT) — stamped so a crash-ding reaches
// this agent's actual supervisor, not the whole permanent crew. Null when a human spawns it (→ cos-only ding).
writePtyToml(dir, spec, { spawner: process.env["ST_AGENT"] ?? null });

// Create the agent's bus member folder BEFORE spawning `st ding`. The ding watcher errors at startup
// if `$ST_ROOT/<identity>/{inbox,archive}` is missing → the worker is never poked → it parks. `st
Expand Down
64 changes: 33 additions & 31 deletions src/up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,39 @@ const sess = (name: string, tags: Record<string, string>): SupervisedSession =>
// Test resolver: read the bus id from a plain "busId" tag (the real one reads ST_AGENT out of the pty.toml).
const resolve = (s: SupervisedSession): string | null => s.tags["busId"] ?? null;

describe("crashDingTargets — who gets the crash/flap ding", () => {
it("dings the permanent convoy agents (cos + supervisors); excludes workers, non-agents, and the crasher", () => {
const sessions = [
sess("cos", { "ptyfile.session": "claude", strategy: "permanent", busId: "cos-claude" }),
sess("sup", { "ptyfile.session": "claude", strategy: "permanent", busId: "sup-claude" }),
sess("wk", { "ptyfile.session": "claude", busId: "wk-claude" }), // worker: not permanent → not an orchestrator
sess("bare", { strategy: "permanent", busId: "bare" }), // not a convoy agent (no ptyfile.session)
sess("crasher", { "ptyfile.session": "claude", strategy: "permanent", busId: "crasher-claude" }),
];
expect(crashDingTargets(sessions, "crasher-claude", [], resolve).sort()).toEqual(["cos-claude", "sup-claude"]);
});

it("adds --notify ids, dedups them, and still excludes the crasher", () => {
const sessions = [sess("cos", { "ptyfile.session": "claude", strategy: "permanent", busId: "cos-claude" })];
expect(crashDingTargets(sessions, "crasher", ["extra", "cos-claude", "crasher"], resolve).sort()).toEqual(["cos-claude", "extra"]);
});

it("dedups a member's two sessions (harness + ding) to one bus id", () => {
const sessions = [
sess("cos.claude", { "ptyfile.session": "claude", strategy: "permanent", busId: "cos-claude" }),
sess("cos.ding", { "ptyfile.session": "ding", strategy: "permanent", busId: "cos-claude" }),
];
expect(crashDingTargets(sessions, null, [], resolve)).toEqual(["cos-claude"]);
});

it("a session whose bus id can't be resolved is skipped (never dings a null/empty target)", () => {
const sessions = [
sess("cos", { "ptyfile.session": "claude", strategy: "permanent", busId: "cos-claude" }),
sess("mystery", { "ptyfile.session": "claude", strategy: "permanent" }), // no busId → resolve() null
];
expect(crashDingTargets(sessions, null, [], resolve)).toEqual(["cos-claude"]);
describe("crashDingTargets — cos + the crashed one's spawner (NOT the whole permanent crew)", () => {
const cos = sess("cos", { "ptyfile.session": "claude", strategy: "permanent", "convoy.tier": "cos", busId: "cos-claude" });
// Unrelated repo-owner agents — long-lived, so they run --permanent, but they are NOT orchestrators.
const appApple = sess("app-apple", { "ptyfile.session": "claude", strategy: "permanent", busId: "app-apple-claude" });
const evals = sess("evals", { "ptyfile.session": "claude", strategy: "permanent", busId: "evals-claude" });
const sup = sess("sup", { "ptyfile.session": "claude", strategy: "permanent", busId: "sup-claude" });

it("ACCEPTANCE: a worker crash pages ONLY cos + the worker's spawner — NOT unrelated permanent agents (Nathan's bug)", () => {
const crashed = sess("wk", { "ptyfile.session": "claude", busId: "crashtest", "convoy.spawner": "sup-claude" });
const targets = crashDingTargets(crashed, [cos, sup, appApple, evals, crashed], [], resolve).sort();
expect(targets).toEqual(["cos-claude", "sup-claude"]); // app-apple-claude / evals-claude NOT paged
expect(targets).not.toContain("app-apple-claude");
});

it("dings ONLY cos when the crashed worker has no spawner tag (human-spawned → cos backstop only)", () => {
const crashed = sess("wk", { "ptyfile.session": "claude", busId: "crashtest" }); // no convoy.spawner
expect(crashDingTargets(crashed, [cos, appApple, crashed], [], resolve)).toEqual(["cos-claude"]);
});

it("dedups when the spawner IS cos (cos spawned it directly) → a single cos ding", () => {
const crashed = sess("wk", { "ptyfile.session": "claude", busId: "crashtest", "convoy.spawner": "cos-claude" });
expect(crashDingTargets(crashed, [cos, crashed], [], resolve)).toEqual(["cos-claude"]);
});

it("adds --notify ids, dedups, and NEVER self-dings the crasher (even if it is the cos-tier)", () => {
// cos itself crashes: excluded from its own ding despite being cos-tier; notify still delivered + deduped.
expect(crashDingTargets(cos, [cos], ["extra", "cos-claude", "cos-claude"], resolve).sort()).toEqual(["extra"]);
});

it("skips an unresolvable cos-tier session (never dings a null/empty target)", () => {
const cosNoBus = sess("cos2", { "ptyfile.session": "claude", "convoy.tier": "cos" }); // no busId → resolve() null
const crashed = sess("wk", { "ptyfile.session": "claude", busId: "crashtest", "convoy.spawner": "sup-claude" });
expect(crashDingTargets(crashed, [cos, cosNoBus, sup, crashed], [], resolve).sort()).toEqual(["cos-claude", "sup-claude"]);
});
});

Expand Down
Loading