Skip to content

Commit f60e751

Browse files
committed
Honor requested shell timeouts and run evals in parallel
1 parent c2e72ce commit f60e751

6 files changed

Lines changed: 211 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### Plugins
17+
18+
- **Requested `run_shell` timeouts are no longer capped at 10 minutes.** The 15s
19+
default when timeout is omitted is unchanged. A ceiling applies only when
20+
settings set `shell.maxTimeoutMs`. Capability evals accept `--concurrency <n>`
21+
(env `CORBITS_EVAL_CONCURRENCY`, default 1) so a live matrix can run
22+
independent case×variant×repeat cells in parallel.
23+
1424
## [0.2.99] - 2026-08-21
1525

1626
Skywalker is the primary orchestrator over a closed director fleet: product write tools stay off the primary, and you cannot spawn Skywalker as a task leaf. Workers are not done until they return the four-heading report. First-party action skills ship as slashes; eval runners require an explicit provider/model pair; the style skill no longer refuses non-git folders.

‎evals/capability/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ bun run eval:capability -- \
117117
--matrix "xai:grok-4.5,openai:gpt-4.1" \
118118
--out evals/capability/results/matrix.json
119119

120+
# Faster live matrix (independent cells; default is serial)
121+
bun run eval:capability -- --provider <name> --model <id> --concurrency 4
122+
120123
# Labeled variants
121124
bun run eval:capability -- --matrix "fast=xai:grok-4.5,strong=openai:gpt-4.1"
122125

@@ -165,6 +168,7 @@ Flags:
165168
| `--agent-timeout-ms <n>` | Wall-clock limit for `runExec` (default `600000`, env `CORBITS_EVAL_AGENT_TIMEOUT_MS`) |
166169
| `--verify-timeout-ms <n>` | Wall-clock limit for `verify.sh` (default `120000`, env `CORBITS_EVAL_VERIFY_TIMEOUT_MS`) |
167170
| `--repeats <n>` | Runs per case×variant cell (default `1`; gate runs use `5`, baseline freezes `3`). Results record every repeat plus per-cell aggregates |
171+
| `--concurrency <n>` | Independent case×variant×repeat cells in parallel (default `1`, env `CORBITS_EVAL_CONCURRENCY`). Each cell still uses its own temp workdir. Use `--concurrency 4` (or similar) to run a live matrix faster |
168172
| `--dry-run` | Load cases × variants and print plan; no inference. Still requires `--provider`/`--model` or `--matrix` |
169173

170174
## Case format

‎scripts/eval-capability.test.ts‎

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,33 @@
1-
import { afterEach, describe, expect, test } from "bun:test";
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
22
import { mkdir, mkdtemp, writeFile, rm } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { execFile } from "node:child_process";
66
import { promisify } from "node:util";
77

8-
import { initEvalGitRepo, parseArgs } from "./eval-capability.ts";
8+
import { initEvalGitRepo, mapPool, parseArgs } from "./eval-capability.ts";
99

1010
const execFileAsync = promisify(execFile);
1111

1212
describe("parseArgs", () => {
13+
const savedConcurrency = process.env.CORBITS_EVAL_CONCURRENCY;
14+
15+
const restoreConcurrency = (): void => {
16+
if (savedConcurrency === undefined) {
17+
delete process.env.CORBITS_EVAL_CONCURRENCY;
18+
} else {
19+
process.env.CORBITS_EVAL_CONCURRENCY = savedConcurrency;
20+
}
21+
};
22+
23+
afterEach(() => {
24+
restoreConcurrency();
25+
});
26+
27+
beforeEach(() => {
28+
delete process.env.CORBITS_EVAL_CONCURRENCY;
29+
});
30+
1331
test("--help does not require provider or model", () => {
1432
const opts = parseArgs(["--help"]);
1533
expect(opts.help).toBe(true);
@@ -61,6 +79,77 @@ describe("parseArgs", () => {
6179
expect(pair.provider).toBe("foo");
6280
expect(pair.model).toBe("bar");
6381
});
82+
83+
test("defaults concurrency to 1", () => {
84+
delete process.env.CORBITS_EVAL_CONCURRENCY;
85+
const opts = parseArgs(["--provider", "foo", "--model", "bar"]);
86+
expect(opts.concurrency).toBe(1);
87+
});
88+
89+
test("--concurrency 4 is accepted", () => {
90+
delete process.env.CORBITS_EVAL_CONCURRENCY;
91+
const opts = parseArgs(["--provider", "foo", "--model", "bar", "--concurrency", "4"]);
92+
expect(opts.concurrency).toBe(4);
93+
});
94+
95+
test("invalid --concurrency values throw", () => {
96+
const pair = ["--provider", "foo", "--model", "bar"] as const;
97+
expect(() => parseArgs([...pair, "--concurrency", "0"])).toThrow(/positive integer/);
98+
expect(() => parseArgs([...pair, "--concurrency", "-1"])).toThrow(/positive integer/);
99+
expect(() => parseArgs([...pair, "--concurrency", "1.5"])).toThrow(/positive integer/);
100+
expect(() => parseArgs([...pair, "--concurrency", "foo"])).toThrow(/positive integer/);
101+
});
102+
103+
test("CORBITS_EVAL_CONCURRENCY sets the default", () => {
104+
process.env.CORBITS_EVAL_CONCURRENCY = "3";
105+
const opts = parseArgs(["--provider", "foo", "--model", "bar"]);
106+
expect(opts.concurrency).toBe(3);
107+
});
108+
109+
test("--concurrency overrides CORBITS_EVAL_CONCURRENCY", () => {
110+
process.env.CORBITS_EVAL_CONCURRENCY = "8";
111+
const opts = parseArgs(["--provider", "foo", "--model", "bar", "--concurrency", "2"]);
112+
expect(opts.concurrency).toBe(2);
113+
});
114+
115+
test("invalid CORBITS_EVAL_CONCURRENCY throws", () => {
116+
process.env.CORBITS_EVAL_CONCURRENCY = "0";
117+
expect(() => parseArgs(["--provider", "foo", "--model", "bar"])).toThrow(
118+
/CORBITS_EVAL_CONCURRENCY must be a positive integer/,
119+
);
120+
});
121+
});
122+
123+
describe("mapPool", () => {
124+
test("N overlapping jobs with concurrency N finish in ~one job duration", async () => {
125+
const jobMs = 80;
126+
const n = 4;
127+
const start = Date.now();
128+
const results = await mapPool([0, 1, 2, 3], n, async (item) => {
129+
await new Promise((r) => setTimeout(r, jobMs));
130+
return item;
131+
});
132+
const elapsed = Date.now() - start;
133+
expect(results).toEqual([0, 1, 2, 3]);
134+
expect(elapsed).toBeLessThan(jobMs * 2);
135+
expect(elapsed).toBeGreaterThanOrEqual(jobMs - 20);
136+
});
137+
138+
test("preserves input order when later items finish first", async () => {
139+
const results = await mapPool([1, 2, 3], 3, async (item) => {
140+
await new Promise((r) => setTimeout(r, (4 - item) * 30));
141+
return item;
142+
});
143+
expect(results).toEqual([1, 2, 3]);
144+
});
145+
146+
test("empty input returns an empty array", async () => {
147+
expect(await mapPool([], 4, async (item) => item)).toEqual([]);
148+
});
149+
150+
test("rejects non-positive concurrency", async () => {
151+
await expect(mapPool([1], 0, async (item) => item)).rejects.toThrow(/positive integer/);
152+
});
64153
});
65154

66155
describe("initEvalGitRepo", () => {

‎scripts/eval-capability.ts‎

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ type CliOptions = {
6868
verifyTimeoutMs: number;
6969
/** Runs per case×variant cell (gate runs use 5; freeze runs use 3). */
7070
repeats: number;
71+
/** Independent case×variant×repeat cells in parallel (default 1). */
72+
concurrency: number;
7173
dryRun: boolean;
7274
help: boolean;
7375
/**
@@ -93,18 +95,62 @@ function printUsage(): void {
9395
--agent-timeout-ms <n> Wall-clock limit for runExec (default 1200000)
9496
--verify-timeout-ms <n> Wall-clock limit for verify.sh (default 120000)
9597
--repeats <n> Runs per case×variant cell (default 1; gate runs use 5)
98+
--concurrency <n> Independent cells in parallel (default 1, env CORBITS_EVAL_CONCURRENCY)
9699
--dry-run List cases × variants only (still requires --provider/--model or --matrix)
97100
--allow-provider-fallback Allow resolved provider/model to differ from
98101
what was requested (default: hard-fail)
99102
-h, --help Show help
100103
`);
101104
}
102105

106+
function parsePositiveInteger(raw: string, label: string): number {
107+
const n = Number(raw);
108+
if (!Number.isInteger(n) || n <= 0) {
109+
throw new Error(`${label} must be a positive integer`);
110+
}
111+
return n;
112+
}
113+
114+
function defaultConcurrency(): number {
115+
const raw = process.env.CORBITS_EVAL_CONCURRENCY;
116+
if (raw === undefined || raw === "") return 1;
117+
return parsePositiveInteger(raw, "CORBITS_EVAL_CONCURRENCY");
118+
}
119+
120+
/**
121+
* Run `mapper` over `items` with at most `concurrency` in flight.
122+
* Results stay in input order even when later items finish first.
123+
*/
124+
export async function mapPool<T, R>(
125+
items: readonly T[],
126+
concurrency: number,
127+
mapper: (item: T, index: number) => Promise<R>,
128+
): Promise<R[]> {
129+
if (!Number.isInteger(concurrency) || concurrency <= 0) {
130+
throw new Error("concurrency must be a positive integer");
131+
}
132+
if (items.length === 0) return [];
133+
const results: R[] = new Array(items.length);
134+
let nextIndex = 0;
135+
const worker = async (): Promise<void> => {
136+
while (true) {
137+
const index = nextIndex;
138+
nextIndex += 1;
139+
if (index >= items.length) return;
140+
results[index] = await mapper(items[index]!, index);
141+
}
142+
};
143+
const workerCount = Math.min(concurrency, items.length);
144+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
145+
return results;
146+
}
147+
103148
export function parseArgs(argv: readonly string[]): CliOptions {
104149
const opts: CliOptions = {
105150
caseSelector: "all",
106151
skipPermissions: true,
107152
repeats: 1,
153+
concurrency: defaultConcurrency(),
108154
dryRun: false,
109155
help: false,
110156
allowProviderFallback: false,
@@ -175,6 +221,9 @@ export function parseArgs(argv: readonly string[]): CliOptions {
175221
opts.repeats = n;
176222
break;
177223
}
224+
case "--concurrency":
225+
opts.concurrency = parsePositiveInteger(next(), "--concurrency");
226+
break;
178227
case "--dry-run":
179228
opts.dryRun = true;
180229
break;
@@ -771,6 +820,7 @@ async function main(): Promise<number> {
771820
}
772821

773822
console.log(`Repeats per cell: ${opts.repeats}`);
823+
console.log(`Concurrency: ${opts.concurrency}`);
774824

775825
if (opts.dryRun) {
776826
console.log("dry-run: no inference");
@@ -781,14 +831,15 @@ async function main(): Promise<number> {
781831
}
782832

783833
const startedAt = new Date().toISOString();
784-
const results: CaseResult[] = [];
785-
834+
const cells: Array<{ caseDef: EvalCase; variant: EvalVariant; repeat: number }> = [];
786835
for (const { caseDef, variant } of plan) {
787836
for (let repeat = 0; repeat < opts.repeats; repeat++) {
788-
const result = await runCase(caseDef, variant, opts, repeat);
789-
results.push(result);
837+
cells.push({ caseDef, variant, repeat });
790838
}
791839
}
840+
const results = await mapPool(cells, opts.concurrency, ({ caseDef, variant, repeat }) =>
841+
runCase(caseDef, variant, opts, repeat),
842+
);
792843

793844
const finishedAt = new Date().toISOString();
794845
const totals = summarizeRun(results);

‎src/plugins/shell-guard-plugin.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
DEFAULT_SHELL_TIMEOUT_MS,
1414
MAX_SHELL_OUTPUT_BYTES,
1515
advertiseShellGuardTimeout,
16+
resolveShellTimeoutMs,
1617
runGuardedShell,
1718
shellGuardPlugin,
1819
} from "./shell-guard-plugin.js";
@@ -154,6 +155,36 @@ describe("runGuardedShell", () => {
154155
});
155156
});
156157

158+
describe("resolveShellTimeoutMs", () => {
159+
test("omitted timeout uses the 15s default", () => {
160+
expect(resolveShellTimeoutMs(undefined, DEFAULT_SHELL_TIMEOUT_MS)).toBe(15_000);
161+
expect(resolveShellTimeoutMs(undefined, DEFAULT_SHELL_TIMEOUT_MS, undefined)).toBe(
162+
DEFAULT_SHELL_TIMEOUT_MS,
163+
);
164+
});
165+
166+
test("non-positive requested timeout falls back to default", () => {
167+
expect(resolveShellTimeoutMs(0, DEFAULT_SHELL_TIMEOUT_MS)).toBe(DEFAULT_SHELL_TIMEOUT_MS);
168+
expect(resolveShellTimeoutMs(-1, DEFAULT_SHELL_TIMEOUT_MS)).toBe(DEFAULT_SHELL_TIMEOUT_MS);
169+
});
170+
171+
test("requested timeout well above 10 minutes is not clamped when maxMs is omitted", () => {
172+
expect(resolveShellTimeoutMs(5_400_000, DEFAULT_SHELL_TIMEOUT_MS)).toBe(5_400_000);
173+
expect(resolveShellTimeoutMs(5_400_000, DEFAULT_SHELL_TIMEOUT_MS, undefined)).toBe(5_400_000);
174+
expect(resolveShellTimeoutMs(900_000, DEFAULT_SHELL_TIMEOUT_MS)).toBe(900_000);
175+
});
176+
177+
test("configured maxMs still clamps", () => {
178+
expect(resolveShellTimeoutMs(900_000, DEFAULT_SHELL_TIMEOUT_MS, 100)).toBe(100);
179+
expect(resolveShellTimeoutMs(5_400_000, DEFAULT_SHELL_TIMEOUT_MS, 600_000)).toBe(600_000);
180+
expect(resolveShellTimeoutMs(undefined, DEFAULT_SHELL_TIMEOUT_MS, 100)).toBe(100);
181+
});
182+
183+
test("requested below maxMs is unchanged", () => {
184+
expect(resolveShellTimeoutMs(1_000, DEFAULT_SHELL_TIMEOUT_MS, 600_000)).toBe(1_000);
185+
});
186+
});
187+
157188
describe("advertiseShellGuardTimeout", () => {
158189
test("rewrites run_shell timeout default to match the guard", () => {
159190
const rewritten = advertiseShellGuardTimeout({

‎src/plugins/shell-guard-plugin.ts‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@ import {
1818
// so open-ended walks cannot OOM the host.
1919

2020
export const DEFAULT_SHELL_TIMEOUT_MS = 15_000;
21-
// Upper bound on a per-command timeout override, so the model cannot ask for an
22-
// effectively unbounded wait. Configurable via settings.
23-
export const MAX_SHELL_TIMEOUT_MS = 600_000;
2421
export const MAX_SHELL_OUTPUT_BYTES = 512_000;
2522

2623
export type ShellTimeoutConfig = {
@@ -29,6 +26,21 @@ export type ShellTimeoutConfig = {
2926
maxOutputBytes?: number;
3027
};
3128

29+
/**
30+
* Effective run_shell timeout. Omitting `requested` (or a non-positive value)
31+
* uses `defaultMs`. `maxMs` clamps only when settings pass it — there is no
32+
* implicit 10-minute ceiling.
33+
*/
34+
export function resolveShellTimeoutMs(
35+
requested: number | undefined,
36+
defaultMs: number,
37+
maxMs?: number,
38+
): number {
39+
const base = requested !== undefined && requested > 0 ? requested : defaultMs;
40+
if (maxMs === undefined) return base;
41+
return Math.min(base, maxMs);
42+
}
43+
3244
/**
3345
* Stock tools-posix still advertises timeout default 30000. Shell-guard enforces
3446
* 15s default; rewrite the definition the model sees so schema and behavior agree.
@@ -356,7 +368,6 @@ export function shellGuardPlugin(
356368
options: ShellGuardPluginOptions = {},
357369
): ToolPlugin {
358370
const defaultMs = timeoutConfig?.defaultMs ?? DEFAULT_SHELL_TIMEOUT_MS;
359-
const maxMs = timeoutConfig?.maxMs ?? MAX_SHELL_TIMEOUT_MS;
360371
const maxOutputBytes = timeoutConfig?.maxOutputBytes ?? MAX_SHELL_OUTPUT_BYTES;
361372
const sessionRoot = realpathSync(cwd);
362373
let retainedShellCwd = sessionRoot;
@@ -412,9 +423,11 @@ export function shellGuardPlugin(
412423
};
413424
}
414425
const requested = optionalNumber(call.arguments.timeout);
415-
const baseTimeoutMs =
416-
requested !== undefined && requested > 0 ? requested : defaultMs;
417-
const effectiveTimeout = Math.min(baseTimeoutMs, maxMs);
426+
const effectiveTimeout = resolveShellTimeoutMs(
427+
requested,
428+
defaultMs,
429+
timeoutConfig?.maxMs,
430+
);
418431
const wrappedCommand = wrapCommandWithPwdProbe(command);
419432
try {
420433
const { output, exitCode, timedOut, outputTruncated } =

0 commit comments

Comments
 (0)