Skip to content
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
34 changes: 34 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,37 @@ describe("CLI model validation", () => {
assert.equal(isKnownModel("unknown"), false);
});
});

describe("CLI --no-timeout flag", () => {
function parseTimeout(optsTimeout: string | false): number {
// Mirrors cli.ts logic: --no-timeout sets opts.timeout to false
return optsTimeout === false ? 0 : parseInt(optsTimeout as string, 10);
}

function validateTimeout(optsTimeout: string | false): string | null {
const timeout = parseTimeout(optsTimeout);
if (optsTimeout !== false && (Number.isNaN(timeout) || timeout < 10 || timeout > 1800)) {
return "Error: --timeout must be a number between 10 and 1800 seconds";
}
return null;
}

it("--no-timeout sets timeout to 0", () => {
assert.equal(parseTimeout(false), 0);
});

it("--no-timeout passes validation", () => {
assert.equal(validateTimeout(false), null);
});

it("normal timeout values still validate", () => {
assert.equal(validateTimeout("300"), null);
assert.equal(parseTimeout("300"), 300);
});

it("invalid timeout values are rejected", () => {
assert.notEqual(validateTimeout("5"), null);
assert.notEqual(validateTimeout("abc"), null);
assert.notEqual(validateTimeout("9999"), null);
});
});
10 changes: 6 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ program
String(cfg.testTimeout),
)
.option("--timeout <seconds>", "Timeout per agent in seconds", String(cfg.timeout))
.option("--no-timeout", "Disable agent timeout entirely")
.option("--model <model>", "Claude model to use", cfg.model)
.option("-r, --runner <name>", "AI coding tool to use", cfg.runner)
.option(
Expand All @@ -46,7 +47,7 @@ program
)
.option("--scoring <method>", "Scoring method: copeland (default) or weighted", "copeland")
.option("--no-color", "Disable colored output")
.option("--output-format <format>", "Output format: text (default) or json", "text")
.option("--output-format <format>", "Output format: text (default), json, or diff", "text")
.option("--verbose", "Show detailed output from each agent")
.option("--whitespace-insensitive", "Ignore whitespace differences in convergence comparison")
.option("--retry", "Re-run only failed/timed-out agents from the last run")
Expand All @@ -57,8 +58,9 @@ program
process.exit(1);
}

const timeout = parseInt(opts.timeout, 10);
if (Number.isNaN(timeout) || timeout < 10 || timeout > 1800) {
// --no-timeout: commander sets opts.timeout to false
const timeout = opts.timeout === false ? 0 : parseInt(opts.timeout, 10);
if (opts.timeout !== false && (Number.isNaN(timeout) || timeout < 10 || timeout > 1800)) {
console.error("Error: --timeout must be a number between 10 and 1800 seconds");
process.exit(1);
}
Expand All @@ -80,7 +82,7 @@ program
process.env.NO_COLOR = "1";
}

const validFormats = ["text", "json"];
const validFormats = ["text", "json", "diff"];
if (!validFormats.includes(opts.outputFormat)) {
console.error(`Error: --output-format must be one of: ${validFormats.join(", ")}`);
process.exit(1);
Expand Down
6 changes: 6 additions & 0 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,9 @@ export async function retry(opts: RunOptions): Promise<void> {
// Display results
if (opts.outputFormat === "json") {
console.log(JSON.stringify(result));
} else if (opts.outputFormat === "diff") {
const recAgent = result.agents.find((a) => a.id === result.recommended);
if (recAgent?.diff) process.stdout.write(recAgent.diff);
} else {
displayResults(result);
displayApplyInstructions(result);
Expand Down Expand Up @@ -506,6 +509,9 @@ export async function run(opts: RunOptions): Promise<void> {
// Display results
if (opts.outputFormat === "json") {
console.log(JSON.stringify(result));
} else if (opts.outputFormat === "diff") {
const recAgent = result.agents.find((a) => a.id === result.recommended);
if (recAgent?.diff) process.stdout.write(recAgent.diff);
} else {
displayResults(result);
displayApplyInstructions(result);
Expand Down
16 changes: 16 additions & 0 deletions src/runners/claude-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,20 @@ describe("claude-code runner", () => {
});
}
});

describe("timeout=0 skips timer", () => {
it("does not call setTimeout when timeout is 0", () => {
// Mirrors the runner logic: timer is only created when timeout > 0
const timeout = 0;
const timer = timeout > 0 ? setTimeout(() => {}, timeout * 1000) : null;
assert.equal(timer, null);
});

it("creates timer when timeout is positive", () => {
const timeout = 300;
const timer = timeout > 0 ? setTimeout(() => {}, timeout * 1000) : null;
assert.notEqual(timer, null);
if (timer) clearTimeout(timer);
});
});
});
45 changes: 24 additions & 21 deletions src/runners/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,28 +82,31 @@ export const claudeCodeRunner: Runner = {
error += data.toString();
});

const timer = setTimeout(() => {
if (!settled) {
settled = true;
child.kill("SIGTERM");
resolve({
id,
worktree: opts.worktreePath,
status: "timeout",
exitCode: -1,
duration: Date.now() - start,
output,
error: `Timed out after ${opts.timeout}s`,
diff: "",
filesChanged: [],
linesAdded: 0,
linesRemoved: 0,
});
}
}, opts.timeout * 1000);
const timer =
opts.timeout > 0
? setTimeout(() => {
if (!settled) {
settled = true;
child.kill("SIGTERM");
resolve({
id,
worktree: opts.worktreePath,
status: "timeout",
exitCode: -1,
duration: Date.now() - start,
output,
error: `Timed out after ${opts.timeout}s`,
diff: "",
filesChanged: [],
linesAdded: 0,
linesRemoved: 0,
});
}
}, opts.timeout * 1000)
: null;

child.on("close", async (code) => {
clearTimeout(timer);
if (timer) clearTimeout(timer);
if (settled) return;
settled = true;

Expand Down Expand Up @@ -136,7 +139,7 @@ export const claudeCodeRunner: Runner = {
});

child.on("error", (err) => {
clearTimeout(timer);
if (timer) clearTimeout(timer);
if (settled) return;
settled = true;

Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface RunOptions {
verbose: boolean;
runner?: string;
scoring: "weighted" | "copeland";
outputFormat: "text" | "json";
outputFormat: "text" | "json" | "diff";
retry?: boolean;
whitespaceInsensitive?: boolean;
}
Expand Down
Loading