Skip to content

Commit 7559784

Browse files
authored
Merge pull request #73 from compoundingtech/feat/restart-agent-guardrail
feat(restart): refuse to restart a stateful agent session unless --force
2 parents 2daae72 + ec2f877 commit 7559784

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

src/cli.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
getSessionDir,
3030
DEFAULT_SESSION_DIR,
3131
type SessionInfo,
32+
type SessionMetadata,
3233
} from "./sessions.ts";
3334
import { spawnDaemon, resolveCommand } from "./spawn.ts";
3435
import {
@@ -2986,6 +2987,18 @@ async function cmdDown(dir: string | undefined, names: string[]): Promise<void>
29862987
}
29872988
}
29882989

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+
29893002
async function cmdRestart(
29903003
name: string,
29913004
yes = false,
@@ -3005,6 +3018,22 @@ async function cmdRestart(
30053018
process.exit(1);
30063019
}
30073020

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+
);
3034+
process.exit(1);
3035+
}
3036+
30083037
if (session.status === "running" && session.pid) {
30093038
if (!yes) {
30103039
const answer = await ask(`Session "${name}" is running. Kill and restart? [Y/n] `);

tests/restart-guardrail.test.ts

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

0 commit comments

Comments
 (0)