fix: give the managed Claude capability query room before refusing a launch (#271)

* fix(daemon): allow a slow Claude capability query before refusing a managed launch

A managed Claude launch with an explicit permission mode first runs
`claude --help` to read the supported modes. The query was killed after one
second, so a valid help that ran slowly while other seats were starting (as in
a multi-seat restore) refused the launch with "capability query failed".

The query now has five seconds. A valid help that takes longer than one second
succeeds. A hung query still ends at the bounded timeout with the same refusal,
so it waits up to four seconds longer than before. Empty or unparseable help,
an unsupported mode and a mismatched target refuse exactly as before.

Refs #260

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8RzdrMhGkYLV4rn96kNMN

* fix(cli): give set-permissions and mutating handover room for the capability query

With the managed Claude capability query allowed five seconds, the CLI's
default five-second request deadline could expire before the daemon answered.
A hung help then reached the user as a client timeout instead of the daemon's
"capability query failed" refusal.

`rig seat set-permissions` now waits up to 10 seconds. A mutating
`rig seat handover` (and `rig handover`, which shares it) gets the existing
120-second launch window, since it launches and readies a successor. A dry-run
handover and every other verb keep the default. The deadline still does not
cancel daemon work, and nothing retries.

The help-timeout test now asserts the query waited for the five-second bound
and was cut off, with a loose ceiling instead of a narrow timing bound.

Refs #260

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8RzdrMhGkYLV4rn96kNMN

* test(cli): expect the set-permissions request deadline in the S03 selection test

347e5ce8 gives `rig seat set-permissions` a 10-second request deadline, so
the client post now carries a third argument. Update the one exact-arguments
expectation; the route, body, refusal JSON, exit and one-request checks are
unchanged.

Refs #260

---------

Co-authored-by: v-openrig-build <v-openrig-build@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: OpenRig contributors <noreply@openrig.dev>
This commit is contained in:
Mike Schwarz
2026-09-30 22:14:59 -07:00
committed by GitHub
co-authored by v-openrig-build Claude Opus 5.5 OpenRig contributors
parent bee4be8cad
commit bfa66821ea
5 changed files with 183 additions and 5 deletions
+13 -3
View File
@@ -482,7 +482,11 @@ Examples:
const route = `/api/seat/${path}/${encodeURIComponent(seat)}`;
res = path === "launch"
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: 120_000 })
: await client.post<Record<string, unknown>>(route, body);
// #260: a dynamic Claude mode waits up to 5 s for the capability query before the
// daemon answers, so the 5 s default deadline would abort before its refusal arrives.
: path === "set-permissions"
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: 10_000 })
: await client.post<Record<string, unknown>>(route, body);
} catch (err) {
if (path !== "launch" || !(err instanceof DaemonTimeoutError)) throw err;
const error = {
@@ -710,12 +714,18 @@ export async function runSeatHandover(seat: string, opts: HandoverActionOpts, de
if (!daemonStatusGuard(daemon)) return; // B8-1b: epistemic-matched
const client = deps.clientFactory(getDaemonUrl(daemon));
const res = await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(`/api/seat/handover/${encodeURIComponent(seat)}`, {
const handoverRoute = `/api/seat/handover/${encodeURIComponent(seat)}`;
const handoverBody = {
source: opts.source,
reason: opts.reason,
operator: opts.operator,
dryRun: opts.dryRun === true,
});
};
// #260: a mutating handover launches and readies the successor, so it gets the
// launch request window. A dry run only plans, and keeps the default deadline.
const res = opts.dryRun === true
? await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(handoverRoute, handoverBody)
: await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(handoverRoute, handoverBody, { timeoutMs: 120_000 });
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
@@ -47,7 +47,7 @@ describe("S03 policy permissions compatibility", () => {
exists: (p: string) => p === STATE_FILE, isProcessAlive: () => true, fetch: async () => ({ ok: true }),
}, clientFactory: () => ({ post: async (...a: unknown[]) => { posts.push(a); return { status: 409, data: response }; } }) };
const result = await capture(seatCommand(deps as never), ["seat", "set-permissions", "owner@inert", "--mode", "auto", "--reason", "user chose", "--json"]);
expect(posts).toEqual([["/api/seat/set-permissions/owner%40inert", { mode: "auto", reason: "user chose" }]]);
expect(posts).toEqual([["/api/seat/set-permissions/owner%40inert", { mode: "auto", reason: "user chose" }, { timeoutMs: 10_000 }]]);
expect(result.exit).toBe(1); expect(JSON.parse(result.logs.join(""))).toEqual(response); expect(result.errors).toEqual([]);
});
});
+85
View File
@@ -744,3 +744,88 @@ describe("rig seat switch-client", () => {
expect(errors.join("\n")).toContain("tmux switch-client failed");
});
});
// #260: the daemon may hold a dynamic Claude permission request for up to 5 s while it
// queries `claude --help`; a mutating handover launches and readies a successor. These
// bind the per-call request deadlines with fake timers (no wall-clock bound).
describe("seat request deadlines (#260)", () => {
afterEach(() => {
vi.useRealTimers();
});
const QUERY_REFUSAL = {
ok: false,
code: "permission_selection_refused",
message: "Claude managed capability query failed; no fallback was selected.",
};
function slowClient(respondAfterMs: number | null, response: () => Response) {
const fetchImpl = vi.fn((_url: unknown, init?: RequestInit) => new Promise<Response>((resolve, reject) => {
const timer = respondAfterMs === null ? undefined : setTimeout(() => resolve(response()), respondAfterMs);
init!.signal!.addEventListener("abort", () => {
if (timer) clearTimeout(timer);
reject(init!.signal!.reason);
}, { once: true });
}));
const deps = makeDeps({ status: 200, data: {} }, []);
deps.clientFactory = url => new DaemonClient(url, { fetchImpl });
return { deps, fetchImpl };
}
async function run(deps: StatusDeps, argv: string[], advanceMs: number) {
const result = captureLogs(() => makeCommand(deps).parseAsync(["node", "rig", ...argv]).then(() => undefined))
.catch(error => ({ error: error as Error }));
await vi.advanceTimersByTimeAsync(advanceMs);
return result;
}
const SET_PERMISSIONS = ["seat", "set-permissions", "dev-impl@seat-rig", "--mode", "auto", "--reason", "slow help", "--json"];
const HANDOVER = ["seat", "handover", "dev-impl@seat-rig", "--reason", "context-wall", "--json"];
it("set-permissions receives the daemon's capability-query refusal after the 5 s default would have aborted", async () => {
vi.useFakeTimers();
const { deps, fetchImpl } = slowClient(5_010, () => Response.json(QUERY_REFUSAL, { status: 409 }));
const output = await run(deps, SET_PERMISSIONS, 5_010);
if (!("logs" in output)) throw output.error;
expect(JSON.parse(output.logs.join("\n"))).toMatchObject(QUERY_REFUSAL);
expect(output.exitCode).toBe(1);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("set-permissions is still bounded, at 10 s", async () => {
vi.useFakeTimers();
const { deps, fetchImpl } = slowClient(null, () => Response.json({}));
const output = await run(deps, SET_PERMISSIONS, 10_000);
expect(output).toHaveProperty("error");
expect((output as { error: Error }).error.message).toContain("timed out after 10000ms");
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("a mutating handover gets the 120 s launch window", async () => {
vi.useFakeTimers();
const { deps, fetchImpl } = slowClient(null, () => Response.json({}));
const output = await run(deps, HANDOVER, 120_000);
expect(output).toHaveProperty("error");
expect((output as { error: Error }).error.message).toContain("timed out after 120000ms");
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("a mutating handover receives a response that arrives after five seconds", async () => {
vi.useFakeTimers();
const { deps } = slowClient(6_000, () => Response.json({ ok: false, code: "handover_refused", message: "synthetic slow refusal" }, { status: 409 }));
const output = await run(deps, HANDOVER, 6_000);
if (!("logs" in output)) throw output.error;
expect(JSON.parse(output.logs.join("\n"))).toMatchObject({ code: "handover_refused" });
});
it.each([
["dry-run handover", [...HANDOVER, "--dry-run"]],
["set-model", ["seat", "set-model", "dev-impl@seat-rig", "--model", "m", "--reason", "r", "--json"]],
])("%s keeps the 5 s default deadline", async (_name, argv) => {
vi.useFakeTimers();
const { deps } = slowClient(null, () => Response.json({}));
const output = await run(deps, argv as string[], 5_000);
expect(output).toHaveProperty("error");
expect((output as { error: Error }).error.message).toContain("timed out after 5000ms");
});
});
@@ -100,7 +100,7 @@ export class ClaudeManagedLaunch {
}
};
const help = await new Promise<string>((resolve, reject) => {
execFile(context.executable, ["--help"], { cwd, env: context.env, encoding: "utf8", timeout: 1000, maxBuffer: 1024 * 1024 },
execFile(context.executable, ["--help"], { cwd, env: context.env, encoding: "utf8", timeout: 5000, maxBuffer: 1024 * 1024 },
(error, stdout) => error ? reject(new Error("Claude managed capability query failed; no fallback was selected.")) : resolve(stdout));
});
assertCurrent();
@@ -0,0 +1,83 @@
// #260: the managed Claude capability query (`claude --help`) must tolerate a slow but
// valid help under load, and still end a hung query at a bounded timeout. Drives the real
// ClaudeManagedLaunch.prepare() and real execFile against private fake executables; no
// provider, tmux, daemon or credentials. Minimal tables as in s03-bound-launch.test.ts.
import { afterEach, describe, expect, it } from "vitest";
import Database from "better-sqlite3";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
const CHOICES = ' --permission-mode <mode> Permission mode to use for the session (choices: "acceptEdits", "auto", "default", "plan")';
const WITHOUT_AUTO = ' --permission-mode <mode> Permission mode to use for the session (choices: "acceptEdits", "default", "plan")';
const QUERY_FAILED = "Claude managed capability query failed; no fallback was selected.";
const open: Database.Database[] = [];
afterEach(() => { for (const db of open.splice(0)) db.close(); });
function fixture(script: string) {
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "claude-help-timeout-")));
const cwd = path.join(root, "seat"); const bin = path.join(root, "bin"); const calls = path.join(root, "calls");
mkdirSync(cwd); mkdirSync(bin); mkdirSync(path.join(root, "home"));
writeFileSync(path.join(bin, "claude"), `#!/bin/sh\necho call >> '${calls}'\n${script}\n`);
chmodSync(path.join(bin, "claude"), 0o755);
const db = new Database(":memory:"); open.push(db);
db.exec(`CREATE TABLE nodes(id TEXT, runtime TEXT, cwd TEXT);
CREATE TABLE bindings(id TEXT, node_id TEXT, tmux_session TEXT, tmux_pane TEXT);
CREATE TABLE occupant_tenures(node_id TEXT, generation_uuid TEXT, generation_ordinal INTEGER);`);
db.prepare("INSERT INTO nodes VALUES ('node','claude-code',?)").run(cwd);
db.exec("INSERT INTO bindings VALUES ('binding','node','seat','%1'); INSERT INTO occupant_tenures VALUES ('node','generation-1',1)");
const managed = new ClaudeManagedLaunch(db, { PATH: `${bin}:/usr/bin:/bin`, HOME: path.join(root, "home") }, {});
const helpCalls = () => existsSync(calls) ? readFileSync(calls, "utf8").trim().split("\n").length : 0;
return { managed, helpCalls };
}
async function prepare(script: string, mode = "auto", target: { cwd?: string } = {}) {
const f = fixture(script);
const started = Date.now();
try {
await f.managed.prepare({ nodeId: "node", ...target }, mode);
return { ok: true as const, ms: Date.now() - started, helpCalls: f.helpCalls() };
} catch (error) {
return { ok: false as const, ms: Date.now() - started, helpCalls: f.helpCalls(), message: (error as Error).message };
}
}
describe("ClaudeManagedLaunch capability query timeout (#260)", () => {
it("accepts a valid help that takes longer than one second", async () => {
const result = await prepare(`sleep 1.5; printf '%s\\n' '${CHOICES}'`);
expect(result).toMatchObject({ ok: true, helpCalls: 1 });
expect(result.ms).toBeGreaterThanOrEqual(1400);
}, 15_000);
it("still ends a hung help at the bounded timeout with the same refusal", async () => {
const result = await prepare("exec sleep 30");
expect(result).toMatchObject({ ok: false, message: QUERY_FAILED, helpCalls: 1 });
// Waited for the 5 s bound, and ended long before the fake's 30 s sleep. The ceiling is
// deliberately loose: it proves the query was cut off, not a scheduling-time bound.
expect(result.ms).toBeGreaterThanOrEqual(4800);
expect(result.ms).toBeLessThan(25_000);
}, 40_000);
it("refuses an immediate non-zero exit without waiting", async () => {
const result = await prepare("exit 1");
expect(result).toMatchObject({ ok: false, message: QUERY_FAILED });
expect(result.ms).toBeLessThan(1000);
});
it("refuses an empty successful help as unavailable options", async () => {
expect(await prepare("exit 0")).toMatchObject({ ok: false, message: "Claude permission options are unavailable; selection was not changed." });
});
it("refuses a requested mode the installed help does not advertise", async () => {
expect(await prepare(`printf '%s\\n' '${WITHOUT_AUTO}'`)).toMatchObject({
ok: false, message: "Claude permission mode 'auto' is not supported by the installed harness." });
});
it("refuses a target that disagrees with the current binding before querying help", async () => {
const result = await prepare(`printf '%s\\n' '${CHOICES}'`, "auto", { cwd: "/not/the/bound/cwd" });
expect(result).toMatchObject({ ok: false, helpCalls: 0,
message: "Claude managed launch target disagrees with the current binding; no input or selection changed." });
});
});