mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-10-02 00:27:21 +08:00
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:
co-authored by
v-openrig-build
Claude Opus 5.5
OpenRig contributors
parent
bee4be8cad
commit
bfa66821ea
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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." });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user