mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
- Planner budget mode (--slice-budget S --jobs J): recorded per-tier wall times pack into as many ~9-minute executors as the work needs; the plan records per-slice estimates and the CI job timeout (supervised worst case + 20 min). evals.yml and evals-periodic.yml derive matrix size and timeout-minutes from it; max-parallel covers every slice at once. - Case shards: plan/design/review-army/shared-libs(-paths) run one registered case per process (<file>#<case id>, exact name pattern, exactly one case). - Retry rule: a timed-out attempt is a verdict. Only files whose every case budget is CAPTURE tier or shorter keep one retry; walls shrink to match. - Marathon tier: positive selection, excluded from gate/periodic planners, run by the new evals-marathon.yml (weekly + dispatch, fresh, own report). - PR-lane E2E reuse of verified first-attempt passes on identical inputs; the report rejects reuse outside the fast PR profile. - Duration seed from census run 36385945043, per tier and per case shard.
250 lines
14 KiB
TypeScript
250 lines
14 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { OVERLAY_CASE_FILES, OVERLAY_MIN_FILE_WALL_MS } from './helpers/overlay-case-policy';
|
|
import {
|
|
applyHollowShardGuard, buildPaidShardArgs, buildRunManifest,
|
|
DEFAULT_SHARD_TIMEOUT_MS, isOverlayTestFile, OVERLAY_MAX_ACTIVE_SHARDS,
|
|
parseCliOptions, parseRunManifest, planPaidShards, resolvePaidShardBudget, resolvePaidShardTimeoutMs,
|
|
retriesForFiles, runPaidShards, summarize, summaryExitCode,
|
|
} from '../scripts/test-paid-shards';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const overlayFiles = Object.values(OVERLAY_CASE_FILES).map(id => `test/skill-e2e-overlay-harness-${id}.test.ts`);
|
|
const normalFile = 'test/skill-e2e-normal-fixture.test.ts';
|
|
const fakeEnv = {
|
|
PATH: path.dirname(process.execPath),
|
|
GSTACK_CLAUDE_CLI_VERSION: 'free-fixture',
|
|
GSTACK_SDK_MAX_CONCURRENCY: '7',
|
|
};
|
|
|
|
describe('overlay file policy', () => {
|
|
test('grouped planning isolates every overlay and preserves ordinary retries', () => {
|
|
// Two short-case files keep their one retry (timeout-is-a-verdict rule).
|
|
const workflow = 'test/skill-e2e-review.test.ts';
|
|
const files = [...overlayFiles, 'test/skill-e2e-triage.test.ts', workflow];
|
|
for (const maxFilesPerShard of [2, 3, 10]) {
|
|
const shards = planPaidShards(files, { maxFilesPerShard });
|
|
expect(shards.flat().sort()).toEqual([...files].sort());
|
|
for (const file of overlayFiles) expect(shards).toContainEqual([file]);
|
|
const workflowShard = shards.find(shard => shard.includes(workflow))!;
|
|
expect(workflowShard.some(isOverlayTestFile)).toBe(false);
|
|
expect(retriesForFiles(workflowShard)).toBe(1);
|
|
const args = buildPaidShardArgs(workflowShard, resolvePaidShardTimeoutMs(workflowShard), 2, retriesForFiles(workflowShard));
|
|
expect(args[args.indexOf('--retry') + 1]).toBe('1');
|
|
expect(planPaidShards(files.map(file => file.replaceAll('/', '\\')), { maxFilesPerShard })).toEqual(shards);
|
|
}
|
|
});
|
|
|
|
test('mixed or grouped overlay jobs reject before any child starts', async () => {
|
|
const invalidGroups = [[overlayFiles[0], normalFile], [overlayFiles[0], overlayFiles[1]]];
|
|
for (const files of invalidGroups) {
|
|
expect(() => resolvePaidShardBudget(files)).toThrow('own shard');
|
|
expect(() => resolvePaidShardBudget(files, 1_900_000)).toThrow('own shard');
|
|
let launched = 0;
|
|
await expect(runPaidShards([[normalFile], files], {
|
|
commandFor: () => { launched++; throw new Error('must never launch'); },
|
|
})).rejects.toThrow('own shard');
|
|
expect(launched).toBe(0);
|
|
}
|
|
});
|
|
|
|
test('only the exact wrapper family gets one attempt and the extra process grace', () => {
|
|
expect(overlayFiles).toHaveLength(4);
|
|
expect(OVERLAY_MAX_ACTIVE_SHARDS).toBe(1);
|
|
expect(OVERLAY_MIN_FILE_WALL_MS).toBe(1_830_000);
|
|
for (const file of overlayFiles) {
|
|
expect(isOverlayTestFile(file)).toBe(true);
|
|
expect(isOverlayTestFile(file.replaceAll('/', '\\'))).toBe(true);
|
|
expect(resolvePaidShardTimeoutMs([file])).toBe(1_830_000);
|
|
expect(retriesForFiles([file])).toBe(0);
|
|
expect(buildPaidShardArgs([file], resolvePaidShardTimeoutMs([file]), 2, retriesForFiles([file])))
|
|
.toContain('--timeout=1830000');
|
|
}
|
|
for (const file of [normalFile, 'test/skill-e2e-overlay-harness.test.ts', 'test/model-overlays.test.ts']) {
|
|
expect(isOverlayTestFile(file)).toBe(false);
|
|
expect(resolvePaidShardTimeoutMs([file])).toBe(DEFAULT_SHARD_TIMEOUT_MS);
|
|
// Not overlays; unlisted files run once because their case budget is unknown.
|
|
expect(retriesForFiles([file])).toBe(0);
|
|
}
|
|
expect(retriesForFiles(['test/skill-e2e-review.test.ts'])).toBe(1);
|
|
expect(resolvePaidShardTimeoutMs([normalFile], 1234)).toBe(1234);
|
|
expect(resolvePaidShardTimeoutMs([overlayFiles[0]], 1_900_000)).toBe(1_900_000);
|
|
expect(() => resolvePaidShardTimeoutMs([overlayFiles[0]], 1_800_000)).toThrow('explicit wall');
|
|
});
|
|
|
|
test('default, env and CLI walls retain their distinct meanings and CLI precedence', () => {
|
|
expect(parseCliOptions([], {}).timeoutExplicit).toBe(false);
|
|
const env = { EVALS_SHARD_TIMEOUT_MS: '1800000' };
|
|
const configured = parseCliOptions([], env);
|
|
expect(configured.timeoutExplicit).toBe(true);
|
|
expect(() => resolvePaidShardTimeoutMs([overlayFiles[0]], configured.timeoutMs)).toThrow('explicit wall');
|
|
const overridden = parseCliOptions(['--timeout', '1900'], env);
|
|
expect(overridden.timeoutExplicit).toBe(true);
|
|
expect(resolvePaidShardTimeoutMs([overlayFiles[0]], overridden.timeoutMs)).toBe(1_900_000);
|
|
expect(env.EVALS_SHARD_TIMEOUT_MS).toBe('1800000');
|
|
});
|
|
|
|
test('an invalid explicit wall rejects the whole batch before starting any normal or overlay child', async () => {
|
|
let launched = 0;
|
|
await expect(runPaidShards([[normalFile], [overlayFiles[0]]], {
|
|
timeoutMs: 1_800_000,
|
|
commandFor: () => { launched++; throw new Error('must never launch'); },
|
|
})).rejects.toThrow('explicit wall');
|
|
expect(launched).toBe(0);
|
|
});
|
|
|
|
test('CLI rejects insufficient overlay walls before API preflight', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'overlay-wall-preflight-'));
|
|
try {
|
|
const manifest = {
|
|
version: 1, tier: 'periodic', evalsAll: true, sliceCount: 1, selectionReason: 'free fixture',
|
|
entries: [{ file: overlayFiles[0], slice: 1, status: 'planned' }],
|
|
};
|
|
const planPath = path.join(dir, 'manifest.json');
|
|
fs.writeFileSync(planPath, JSON.stringify(manifest));
|
|
const result = spawnSync(process.execPath, [path.join(ROOT, 'scripts/test-paid-shards.ts'),
|
|
'--tier', 'periodic', '--plan', planPath, '--slice', '1', '--timeout', '1800'], {
|
|
cwd: ROOT, encoding: 'utf8', timeout: 10_000,
|
|
// No credentials or preflight bypass. A nonempty missing PATH avoids
|
|
// runtime fallback to a default shell search path during negative probes.
|
|
env: { PATH: path.join(dir, 'no-executables'), HOME: dir, GSTACK_EVAL_DIR: dir, GSTACK_CLAUDE_CLI_VERSION: 'free-fixture',
|
|
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) },
|
|
});
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain('Overlay shard requires at least 1830000ms; explicit wall 1800000ms');
|
|
expect(result.stderr).not.toContain('preflight');
|
|
expect(fs.existsSync(path.join(dir, 'slice-1.json'))).toBe(false);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('overlay manifest affinity and CI capacity', () => {
|
|
test('actual lifecycle wrapper guards include all four in periodic and exclude all four from gate', () => {
|
|
for (const tier of ['periodic', 'gate'] as const) {
|
|
const manifest = buildRunManifest({ tier, sliceCount: 6, evalsAll: true, env: { EVALS_ALL: '1' } });
|
|
const entries = manifest.entries.filter(entry => isOverlayTestFile(entry.file));
|
|
expect(entries).toHaveLength(4);
|
|
expect(entries.every(entry => entry.status === (tier === 'periodic' ? 'planned' : 'excluded'))).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('93 files retain every case, reserve slice six, and fit 330 minutes with actual family walls', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'overlay-affinity-'));
|
|
const normalFiles = Array.from({ length: 89 }, (_, i) => `test/skill-e2e-normal-${i.toString().padStart(2, '0')}.test.ts`);
|
|
const discovered = [...normalFiles, ...overlayFiles];
|
|
try {
|
|
fs.mkdirSync(path.join(dir, 'test'));
|
|
for (const file of discovered) {
|
|
fs.writeFileSync(path.join(dir, file), isOverlayTestFile(file)
|
|
? "const shouldRun = process.env.EVALS_TIER === 'periodic';\n" : '// Free census fixture only.\n');
|
|
}
|
|
const opts = { tier: 'periodic' as const, sliceCount: 6, evalsAll: true, discovered, rootDir: dir, env: { EVALS_ALL: '1' } };
|
|
const manifest = buildRunManifest(opts);
|
|
expect(manifest.entries).toHaveLength(93);
|
|
expect(new Set(manifest.entries.map(e => e.file)).size).toBe(93);
|
|
expect(manifest.entries.every(e => e.status === 'planned')).toBe(true);
|
|
const counts = [1, 2, 3, 4, 5, 6].map(slice => manifest.entries.filter(e => e.slice === slice).length);
|
|
expect(counts).toEqual([18, 18, 18, 18, 17, 4]);
|
|
expect(manifest.entries.filter(e => e.slice === 6).map(e => e.file).sort()).toEqual([...overlayFiles].sort());
|
|
expect(buildRunManifest({ ...opts, discovered: [...discovered].reverse() })).toEqual(manifest);
|
|
expect(parseRunManifest(JSON.stringify(manifest))).toEqual(manifest);
|
|
const stale = { ...manifest, entries: manifest.entries.map(e => isOverlayTestFile(e.file) ? { ...e, slice: 1 } : e) };
|
|
expect(() => parseRunManifest(JSON.stringify(stale))).toThrow('final ordinary slice');
|
|
|
|
const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github/workflows/evals-periodic.yml'), 'utf8')) as {
|
|
jobs: Record<string, {
|
|
steps: Array<{ run?: string; env?: NodeJS.ProcessEnv }>;
|
|
strategy: { matrix: { slice: string } };
|
|
'timeout-minutes': string;
|
|
}>;
|
|
};
|
|
const job = workflow.jobs['eval-slices'];
|
|
const step = job.steps.find(step => step.run?.includes('--plan '))!;
|
|
const jobs = parseCliOptions([], step.env).jobs;
|
|
expect(jobs).toBe(2);
|
|
expect(parseCliOptions([], step.env).withinShardConcurrency).toBe(2);
|
|
expect(job.strategy.matrix.slice).toBe('${{ fromJSON(needs.plan-slices.outputs.periodic_slices) }}');
|
|
expect(job['timeout-minutes']).toBe('${{ fromJSON(needs.plan-slices.outputs.periodic_timeout_minutes) }}');
|
|
const normalMinutes = Math.ceil(18 / jobs) * resolvePaidShardTimeoutMs([normalFiles[0]]) / 60_000;
|
|
const overlayMinutes = Math.ceil(overlayFiles.length / OVERLAY_MAX_ACTIVE_SHARDS)
|
|
* Math.max(...overlayFiles.map(file => resolvePaidShardTimeoutMs([file]))) / 60_000;
|
|
expect(normalMinutes).toBe(270);
|
|
expect(overlayMinutes).toBe(122);
|
|
// The CI budget plan (what the workflow runs) keeps the four overlays
|
|
// in one final one-at-a-time slice and its job cap covers them.
|
|
const budget = buildRunManifest({ ...opts, sliceCount: undefined, sliceBudgetMs: 540_000, jobs });
|
|
const lastSlice = budget.entries.filter(e => e.slice === budget.sliceCount).map(e => e.file).sort();
|
|
expect(lastSlice).toEqual([...overlayFiles].sort());
|
|
expect(budget.plan!.ciTimeoutMinutes).toBeGreaterThanOrEqual(overlayMinutes + 20);
|
|
expect(budget.plan!.ciTimeoutMinutes).toBe(Math.ceil(overlayMinutes) + 20);
|
|
|
|
// Gate selection keeps its original periodic exclusion and all six
|
|
// ordinary slices; reservation does not spend an empty slot in gate.
|
|
const gate = buildRunManifest({ ...opts, tier: 'gate' });
|
|
expect(gate.entries.filter(e => e.status === 'excluded').map(e => e.file).sort()).toEqual([...overlayFiles].sort());
|
|
expect(gate.entries.filter(e => e.status === 'planned' && e.slice === 6)).toHaveLength(14);
|
|
const single = buildRunManifest({ ...opts, sliceCount: 1 });
|
|
expect(single.entries.every(e => e.slice === 1)).toBe(true);
|
|
expect(parseRunManifest(JSON.stringify(single))).toEqual(single);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('overlay admission uses real child processes', () => {
|
|
test('one overlay holds its slot while ordinary work proceeds; failure releases admission', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'overlay-admission-'));
|
|
const receipts = path.join(dir, 'events.jsonl');
|
|
const lines: string[] = [];
|
|
try {
|
|
const summary = await runPaidShards([[overlayFiles[0]], [overlayFiles[1]], [normalFile]], {
|
|
jobs: 2, env: fakeEnv, logDir: dir, log: line => lines.push(line),
|
|
commandFor: files => {
|
|
const role = files[0] === overlayFiles[0] ? 'first' : files[0] === overlayFiles[1] ? 'second' : 'normal';
|
|
return {
|
|
command: process.execPath,
|
|
args: [path.join(ROOT, 'test/fixtures/overlay-admission-child.ts'), dir, role, receipts],
|
|
};
|
|
},
|
|
});
|
|
expect(summary.outcomes.map(o => o.status)).toEqual(['failed', 'passed', 'passed']);
|
|
expect(summary.outcomes[0].exitCode).toBe(3);
|
|
const events = fs.readFileSync(receipts, 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
|
expect(events.map(e => `${e.role}:${e.event}`)).toEqual([
|
|
'first:start', 'normal:ran-during-overlay', 'first:end', 'second:start', 'second:end',
|
|
]);
|
|
expect(events.every(e => e.sdk === '7')).toBe(true);
|
|
expect(lines.filter(line => line.includes('START') && line.includes('timeout 1830s'))).toHaveLength(2);
|
|
expect(lines.filter(line => line.includes('START') && line.includes('timeout 1800s'))).toHaveLength(1);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}, 30_000);
|
|
|
|
test('a thrown overlay launch releases admission and hollow output remains a failing verdict', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'overlay-admission-throw-'));
|
|
try {
|
|
const summary = await runPaidShards([[overlayFiles[0]], [overlayFiles[1]], [normalFile]], {
|
|
jobs: 8, env: fakeEnv, logDir: dir, log: () => {},
|
|
commandFor: files => {
|
|
if (files[0] === overlayFiles[0]) throw new Error('fake overlay launch failure');
|
|
const count = files[0] === overlayFiles[1] ? 0 : 1;
|
|
return { command: process.execPath, args: ['-e', `console.log('Ran ${count} tests across 1 file. [1ms]')`] };
|
|
},
|
|
});
|
|
const guarded = applyHollowShardGuard(summary.outcomes, { evalsAll: true });
|
|
expect(guarded.map(o => o.status)).toEqual(['failed', 'passed-empty', 'passed']);
|
|
expect(summaryExitCode(summarize(guarded))).toBe(1);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|