This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(restart): refuse to restart a stateful agent session unless --force

Incident: `pty restart -y org.myobie.cos` on a live `claude --resume` agent
wedged it — the abrupt kill + re-exec of the stored argv killed in-progress work
and left claude frozen on its exit screen with an orphaned daemon (and, since
restart re-execs under the OPERATOR's shell env, the respawn inherited the wrong
ST_AGENT and came back under the wrong bus identity, exit 129).

`pty restart` is a dumb "SIGTERM the daemon + re-run the stored argv" — great for
a stateless daemon, a footgun for a stateful interactive agent. Refuse it for
agent-shaped sessions unless --force, converting "claude agents cycle via their
supervisor, not pty restart" from convention into a CLI-enforced guardrail.

Detection (statefulAgentReason): a `role=agent` tag, or `claude --resume` in the
stored command. On a match, `pty restart` exits nonzero pointing at `convoy up`
and `--force` to override; non-agent sessions are unaffected.

tests/restart-guardrail.test.ts: role=agent refuses; `claude --resume` argv
refuses; a normal session restarts fine; --force overrides. Existing restart
tests (displayName preservation, gc-flap clear) still pass.

Nathan Herald (Jul 12, 2026, 4:01 PM +0200) ec2f877e 2daae72e

+109
+29
src/cli.ts
··· 29 29 getSessionDir, 30 30 DEFAULT_SESSION_DIR, 31 31 type SessionInfo, 32 + type SessionMetadata, 32 33 } from "./sessions.ts"; 33 34 import { spawnDaemon, resolveCommand } from "./spawn.ts"; 34 35 import { ··· 2986 2987 } 2987 2988 } 2988 2989 2990 + /** Detect a session that should NOT be blindly `pty restart`ed: a stateful 2991 + * interactive agent. Two signals — a `role=agent` tag, or a `claude --resume` 2992 + * in the stored command. Returns a short human reason, or null. */ 2993 + function statefulAgentReason(meta: SessionMetadata): string | null { 2994 + if (meta.tags?.role === "agent") return "role=agent tag"; 2995 + const argv = [meta.command, ...(meta.args ?? []), meta.displayCommand].filter(Boolean).join(" "); 2996 + if (/(^|\s|\/)claude(\s|$)/.test(argv) && /(^|\s)--resume(\s|=|$)/.test(argv)) { 2997 + return "claude --resume command"; 2998 + } 2999 + return null; 3000 + } 3001 + 2989 3002 async function cmdRestart( 2990 3003 name: string, 2991 3004 yes = false, ··· 3002 3015 if (!meta) { 3003 3016 console.error(`Session "${name}" has no metadata — cannot restart.`); 3004 3017 cleanupAll(name); 3018 + process.exit(1); 3019 + } 3020 + 3021 + // Guardrail: `pty restart` blindly re-runs the stored argv — fine for a 3022 + // stateless daemon, a footgun for a stateful interactive agent. Restarting a 3023 + // `claude --resume` agent kills its in-progress work AND can wedge the resume 3024 + // (the re-exec races the old pts teardown; claude freezes on its exit screen 3025 + // and the daemon orphans). Refuse for agent-shaped sessions unless --force — 3026 + // the right way to cycle an agent is through its supervisor (e.g. convoy). 3027 + const agentReason = statefulAgentReason(meta); 3028 + if (agentReason && !forceNested) { 3029 + console.error(`Session "${name}" looks like a stateful agent (${agentReason}).`); 3030 + console.error( 3031 + "`pty restart` kills its in-progress work and can wedge a `claude --resume`. " + 3032 + "Cycle it through its supervisor (e.g. `convoy up`) instead — or pass --force to restart anyway." 3033 + ); 3005 3034 process.exit(1); 3006 3035 } 3007 3036
+80
tests/restart-guardrail.test.ts
··· 1 + import { describe, it, expect, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawnSync } from "node:child_process"; 7 + 8 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 + const nodeBin = process.execPath; 10 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-rg-")); 12 + const bgPids: number[] = []; 13 + afterAll(() => { 14 + for (const pid of bgPids) { try { process.kill(pid, "SIGKILL"); } catch {} } 15 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 16 + }); 17 + 18 + // PTY_SESSION set => restart takes its "already inside a session, not attaching" 19 + // branch and returns instead of hanging on a non-TTY attach. 20 + function runCli(dir: string, args: string[], timeout = 15000) { 21 + return spawnSync(nodeBin, [cliPath, ...args], { 22 + env: { ...process.env, PTY_SESSION_DIR: dir, PTY_ROOT_LEGACY_SILENT: "1", PTY_SESSION: "outer" }, 23 + encoding: "utf8", timeout, 24 + }); 25 + } 26 + 27 + function createSession(dir: string, name: string, extra: string[], cmd: string[]): void { 28 + const r = runCli(dir, ["run", "-d", "--id", name, ...extra, "--", ...cmd]); 29 + expect(r.status).toBe(0); 30 + try { 31 + bgPids.push(Number(fs.readFileSync(path.join(dir, `${name}.pid`), "utf8").trim())); 32 + } catch {} 33 + } 34 + 35 + describe("pty restart guardrail for stateful agent sessions", () => { 36 + it("refuses to restart a role=agent session (exit nonzero, points at convoy)", () => { 37 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 38 + createSession(dir, "ag", ["--tag", "role=agent"], ["sleep", "300"]); 39 + 40 + const r = runCli(dir, ["restart", "-y", "ag"]); 41 + expect(r.status).not.toBe(0); 42 + expect(r.stderr).toMatch(/stateful agent/); 43 + expect(r.stderr).toMatch(/role=agent/); 44 + expect(r.stderr).toMatch(/--force/); 45 + expect(r.stderr).toMatch(/convoy/); 46 + }, 20000); 47 + 48 + it("refuses to restart a `claude --resume` command session", () => { 49 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 50 + // No role tag — detection is purely on the stored argv. 51 + createSession(dir, "cr", ["--no-display-name"], ["claude", "--resume", "ABC-123"]); 52 + 53 + const r = runCli(dir, ["restart", "-y", "cr"]); 54 + expect(r.status).not.toBe(0); 55 + expect(r.stderr).toMatch(/stateful agent/); 56 + expect(r.stderr).toMatch(/claude --resume/); 57 + }, 20000); 58 + 59 + it("does NOT block a normal session (no agent tag, no claude --resume)", () => { 60 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 61 + createSession(dir, "plain", [], ["sleep", "300"]); 62 + 63 + const r = runCli(dir, ["restart", "-y", "plain"]); 64 + expect(r.status).toBe(0); 65 + expect(r.stdout).toContain("restarted"); 66 + expect(r.stderr).not.toMatch(/stateful agent/); 67 + }, 20000); 68 + 69 + it("--force overrides the guardrail (restarts anyway)", () => { 70 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 71 + createSession(dir, "ag2", ["--tag", "role=agent"], ["sleep", "300"]); 72 + 73 + // --force bypasses the guard AND the nesting guard, so it proceeds to 74 + // attach and would hang in this non-TTY test — a short timeout is fine; we 75 + // only assert it got PAST the guard ("restarted" printed, no refusal). 76 + const r = runCli(dir, ["restart", "-y", "--force", "ag2"], 4000); 77 + expect(r.stderr).not.toMatch(/stateful agent/); 78 + expect(r.stdout).toContain("restarted"); 79 + }, 20000); 80 + });