mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-23 14:32:33 +02:00
* feat: model taxonomy gains gpt-5.6-sol + per-host generation defaults
Adds 'gpt-5.6-sol' to the model taxonomy with exact-match-only resolution
(Terra/Luna/suffixed IDs deliberately fall back to generic gpt) and replaces
the hardcoded 'claude' generation default with a validated
HostConfig.defaultModel: codex renders the gpt profile when --model is
absent, every other host keeps claude. Codex ship golden regenerated
accordingly; ADDING_A_HOST documents the new field.
* feat: gpt-5.6-sol bounded-scope overlay + scope-aware resolvers
The Sol profile pins the explicit task as the lake: adjacent work is
report-only, investigation is bounded, runs terminate on one clean
verification pass, and the AskUserQuestion decision-brief format is never
trimmed. The overlay wrapper grants scope-interpretation precedence while
concrete workflow steps, gates, and skill-mandated re-verification loops
still win. Sol-specific Completeness Principle and first-run intro copy.
New SETUP_COMMAND resolver renders './setup --host <host>' for every
non-claude host so generated upgrade skills reinstall their own host.
* feat: setup reads the Codex model from config.toml
New resolve-codex-generation-model.ts reads the top-level model from
${CODEX_HOME:-~/.codex}/config.toml, validates against the model allowlist,
strips control characters from every config-derived string it surfaces,
guards against non-absolute config locations, and warns on Sol near-misses.
setup runs it on EVERY invocation (read-only TOML lookup) so a plain
./setup can never clobber a Sol user's rendered profile with the hardcoded
fallback; --model <id> overrides for one run and prints the persistence
hint. Kiro installs render the claude profile before copying (Kiro fronts
Claude-family models), rewrite the baked setup command to --host kiro, and
restore the resolved Codex profile after; the codex skills path honors
CODEX_HOME. Static pins cover the resolver wiring, fail-closed exit,
quoted argv, and the Kiro sandwich.
* feat: hermetic Codex runner hardening + Sol scope-termination E2E
The Codex E2E runner copies auth.json only (operator plugins, MCP servers,
rules, and skills no longer leak into hermetic evals), pins CODEX_HOME to
the temp dir, and supports per-run model, TOML overrides, and
--ignore-user-config. New periodic E2E installs the FULL generated
investigate skill on gpt-5.6-sol against a planted one-line bug with decoy
TODOs: the fix must land inside the boundary (untracked files counted via
git status --porcelain), decoys stay byte-identical, the regression oracle
survives unweakened, nothing gets committed, all within 30 tool calls.
The shared .agents tree is snapshotted and restored exactly in beforeAll;
fixture commits disable gpg signing. Wired into the periodic CI matrix,
paid-shard globs, eval scripts, touchfiles/E2E_TIERS
(codex-sol-scope-termination), and diff-based selection. Real-file
periodic-tier classification pins both codex E2Es out of the gate tier.
Free-tier test proves an explicit --model overrides the host default
through the real generation CLI.
* chore: bump version and changelog (v1.67.2.0)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: post-ship documentation sync for v1.67.2.0
- README: Codex skills path is CODEX_HOME-aware; state that
--model overrides detection for one run only (persist via
the Codex config.toml model key)
- CONTRIBUTING: add the model-overlay axis to the per-host
config table (per-host defaultModel, override precedence)
- CLAUDE.md: eval results dir is ~/.gstack/projects/<slug>/evals/
(legacy fallback ~/.gstack-dev/evals/), matching eval-store.ts
and the eval:* CLI headers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: post-ship documentation sync (v1.67.2.0)
Sol exact-match and near-miss warning documented in README; CODEX_HOME-aware
uninstall and troubleshooting paths; hermetic auth.json-only detail and the
build-clobber gotcha in CLAUDE.md; eval-store location corrected in
ARCHITECTURE.md; defaultModel row in the ADDING_A_HOST field reference;
resolver test count corrected in the CHANGELOG entry.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
294 lines
14 KiB
TypeScript
294 lines
14 KiB
TypeScript
/**
|
|
* Pins the paid-tier sharded runner (scripts/test-paid-shards.ts).
|
|
*
|
|
* Two properties matter, and both are why `test:gate` has never finished a run:
|
|
* 1. Enumeration + sharding — every file `test:gate`'s globs expand to gets
|
|
* its own process, and tier exclusion only ever fires on explicit evidence.
|
|
* 2. A spinning shard is killed externally and the run CONTINUES. The fake
|
|
* command here is a real busy loop, so an in-process timer could not save
|
|
* it — exactly the failure mode `sample` caught on the wedged run.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
import {
|
|
PAID_TEST_GLOBS,
|
|
classifyPaidTestFile,
|
|
collectPaidTestFiles,
|
|
computePaidDiffSelection,
|
|
diffSkipDecisionForFile,
|
|
formatSummary,
|
|
isPaidTestFile,
|
|
knownTestNamesInSource,
|
|
partitionShardsByDiffSelection,
|
|
planPaidShards,
|
|
runPaidShards,
|
|
summarize,
|
|
summaryExitCode,
|
|
type ShardOutcome,
|
|
} from '../scripts/test-paid-shards';
|
|
|
|
describe('paid test enumeration', () => {
|
|
test('matches the globs package.json test:gate expands', () => {
|
|
expect(isPaidTestFile('test/skill-e2e-qa-workflow.test.ts')).toBe(true);
|
|
expect(isPaidTestFile('test/skill-llm-eval.test.ts')).toBe(true);
|
|
expect(isPaidTestFile('test/codex-e2e.test.ts')).toBe(true);
|
|
expect(isPaidTestFile('test/codex-e2e-sol-scope.test.ts')).toBe(true);
|
|
expect(isPaidTestFile('test/skill-e2e-triage-audit.test.ts')).toBe(true);
|
|
// Outside the globs: no dash, extra suffix, or a free test.
|
|
// 'test/skill-e2e.test.ts' is the DELETED pre-split monolith's name,
|
|
// kept here as a regression pin: its glob-invisibility is exactly how
|
|
// two gate tests went unexecuted for ~8 releases before the rehoming.
|
|
expect(isPaidTestFile('test/skill-e2e.test.ts')).toBe(false);
|
|
expect(isPaidTestFile('test/codex-e2e-recommendation-substance.test.ts')).toBe(false);
|
|
expect(isPaidTestFile('test/paid-shards.test.ts')).toBe(false);
|
|
});
|
|
|
|
test('discovers files and gives each one its own shard', () => {
|
|
const files = collectPaidTestFiles();
|
|
expect(files.length).toBeGreaterThan(0);
|
|
expect(files.every(isPaidTestFile)).toBe(true);
|
|
expect(PAID_TEST_GLOBS.length).toBe(6);
|
|
|
|
const shards = planPaidShards(files);
|
|
expect(shards.flat().sort()).toEqual([...files].sort());
|
|
expect(shards.every((shard) => shard.length === 1)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('tier classification', () => {
|
|
test('excludes only on an explicit other-tier guard', () => {
|
|
const gateGuard = "const shouldRun = !!process.env.EVALS && process.env.EVALS_TIER === 'gate';";
|
|
const periodicGuard = "const shouldRun = !!process.env.EVALS && process.env.EVALS_TIER === 'periodic';";
|
|
|
|
expect(classifyPaidTestFile(gateGuard, 'gate').included).toBe(true);
|
|
expect(classifyPaidTestFile(periodicGuard, 'gate').included).toBe(false);
|
|
expect(classifyPaidTestFile(gateGuard, 'periodic').included).toBe(false);
|
|
expect(classifyPaidTestFile(periodicGuard, 'periodic').included).toBe(true);
|
|
});
|
|
|
|
test('recognizes the consolidated e2e-gate helper guard (both forms)', () => {
|
|
// The shape test/helpers/e2e-gate.ts consumers use after consolidation.
|
|
const helperGate = "const describeE2E = describeE2ETier('gate');";
|
|
const helperPeriodic = "const describeE2E = describeE2ETier('periodic');";
|
|
const boolPeriodic = "const shouldRun = CODEX_AVAILABLE && e2eTierEnabled('periodic');";
|
|
|
|
expect(classifyPaidTestFile(helperGate, 'gate').included).toBe(true);
|
|
expect(classifyPaidTestFile(helperGate, 'periodic').included).toBe(false);
|
|
expect(classifyPaidTestFile(helperPeriodic, 'periodic').included).toBe(true);
|
|
expect(classifyPaidTestFile(helperPeriodic, 'gate').included).toBe(false);
|
|
expect(classifyPaidTestFile(boolPeriodic, 'gate').included).toBe(false);
|
|
expect(classifyPaidTestFile(boolPeriodic, 'periodic').included).toBe(true);
|
|
});
|
|
|
|
test('keeps files whose tier is decided per-test at runtime', () => {
|
|
// Naming an E2E_TIERS key is not evidence — 'retro' appears in the
|
|
// LLM-judge file, which test:gate does run.
|
|
const noGuard = "runSkillTest('retro', async () => {});";
|
|
expect(classifyPaidTestFile(noGuard, 'gate').included).toBe(true);
|
|
expect(classifyPaidTestFile(noGuard, 'periodic').included).toBe(true);
|
|
expect(classifyPaidTestFile('', 'gate').included).toBe(true);
|
|
});
|
|
|
|
test('the REAL external-CLI test files classify as periodic-only', () => {
|
|
// Synthetic guard shapes above can drift from the actual files — the
|
|
// inert-demotion defect class. Pin the real sources: a guard-shape edit
|
|
// in either file that silently runs it in gate fails here.
|
|
for (const file of ['test/codex-e2e.test.ts', 'test/codex-e2e-sol-scope.test.ts']) {
|
|
const source = fs.readFileSync(path.join(ROOT, file), 'utf8');
|
|
expect(classifyPaidTestFile(source, 'gate').included, `${file} leaked into gate tier`).toBe(false);
|
|
expect(classifyPaidTestFile(source, 'periodic').included, `${file} dropped from periodic tier`).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('shard execution', () => {
|
|
const BUSY_LOOP = 'const end = Date.now() + 600000; while (Date.now() < end) {}';
|
|
|
|
const commandFor = (files: string[]) => {
|
|
if (files[0] === 'spin') return { command: process.execPath, args: ['-e', BUSY_LOOP] };
|
|
if (files[0] === 'fail') return { command: process.execPath, args: ['-e', 'process.exit(3)'] };
|
|
return { command: process.execPath, args: ['-e', 'console.log("ok")'] };
|
|
};
|
|
|
|
test('a spinning shard times out, is killed, and the run continues', async () => {
|
|
const lines: string[] = [];
|
|
const summary = await runPaidShards([['spin'], ['fail'], ['pass']], {
|
|
timeoutMs: 1_200,
|
|
jobs: 1,
|
|
commandFor,
|
|
log: (line) => lines.push(line),
|
|
});
|
|
|
|
const byName = (name: string) => summary.outcomes.find((o) => o.files[0] === name) as ShardOutcome;
|
|
expect(byName('spin').status).toBe('timed-out');
|
|
expect(byName('fail').status).toBe('failed');
|
|
expect(byName('pass').status).toBe('passed');
|
|
|
|
// The run never aborted: every shard reports, none is 'never-started'.
|
|
expect(summary).toMatchObject({
|
|
total: 3, executed: 3, passed: 1, failed: 1, timedOut: 1, neverStarted: 0,
|
|
});
|
|
|
|
// The spinner was killed at the deadline, not left to burn a core.
|
|
expect(byName('spin').elapsedMs).toBeLessThan(30_000);
|
|
expect(byName('spin').groupPid).toBeGreaterThan(0);
|
|
if (process.platform !== 'win32') {
|
|
expect(() => process.kill(byName('spin').groupPid as number, 0)).toThrow();
|
|
}
|
|
|
|
// Heartbeat: a START and a terminal line per shard, with elapsed seconds.
|
|
expect(lines.filter((l) => l.includes(' START ')).length).toBe(3);
|
|
expect(lines.some((l) => /TIMED-OUT in \d+s/.test(l))).toBe(true);
|
|
expect(lines.some((l) => /PASSED in \d+s/.test(l))).toBe(true);
|
|
}, 30_000);
|
|
|
|
test('summarize reports shards that never ran', () => {
|
|
const summary = summarize([
|
|
{ shard: 1, files: ['a'], status: 'passed', exitCode: 0, elapsedMs: 1, groupPid: 1 },
|
|
{ shard: 2, files: ['b'], status: 'never-started', exitCode: null, elapsedMs: 0, groupPid: null },
|
|
]);
|
|
expect(summary).toMatchObject({ total: 2, executed: 1, passed: 1, neverStarted: 1 });
|
|
});
|
|
});
|
|
|
|
describe('parent-side diff shard skipping', () => {
|
|
const ALL_NAMES = ['alpha-test', 'beta-test', 'gamma-registered'];
|
|
const TOUCHFILES: Record<string, string[]> = {
|
|
'alpha-test': ['a/**'],
|
|
'beta-test': ['b/**'],
|
|
'gamma-registered': ['g/**', 'test/skill-e2e-gamma.test.ts'],
|
|
};
|
|
const SOURCES: Record<string, string> = {
|
|
'test/skill-e2e-alpha.test.ts': "runSkillTest('alpha-test', async () => {});",
|
|
'test/skill-e2e-beta.test.ts': 'describeIfSelected("beta", ["beta-test"], () => {});',
|
|
// Constructed testName — invisible by quotes, mapped only via registration.
|
|
'test/skill-e2e-gamma.test.ts': 'const name = buildName(); test(name, async () => {});',
|
|
// No recognizable names, no registration — the fail-open class.
|
|
'test/skill-e2e-opaque.test.ts': "const shouldRun = process.env.EVALS_TIER === 'periodic';",
|
|
'test/codex-e2e.test.ts': 'codex tests keyed off CODEX_E2E_TOUCHFILES',
|
|
};
|
|
const opts = {
|
|
readSource: (file: string) => {
|
|
if (!(file in SOURCES)) throw new Error(`unreadable: ${file}`);
|
|
return SOURCES[file];
|
|
},
|
|
allNames: ALL_NAMES,
|
|
e2eTouchfiles: TOUCHFILES,
|
|
};
|
|
|
|
test('knownTestNamesInSource matches only exact quoted strings', () => {
|
|
expect(knownTestNamesInSource("x 'alpha-test' y", ['alpha-test', 'beta-test'])).toEqual(['alpha-test']);
|
|
expect(knownTestNamesInSource('x "beta-test" y', ['alpha-test', 'beta-test'])).toEqual(['beta-test']);
|
|
expect(knownTestNamesInSource('`alpha-test`', ['alpha-test'])).toEqual(['alpha-test']);
|
|
// Substring inside a longer quoted string is not a hit.
|
|
expect(knownTestNamesInSource("'alpha-test-extended'", ['alpha-test'])).toEqual([]);
|
|
});
|
|
|
|
test('selected name in file → shard kept', () => {
|
|
const d = diffSkipDecisionForFile('test/skill-e2e-alpha.test.ts', new Set(['alpha-test']), opts);
|
|
expect(d.kept).toBe(true);
|
|
expect(d.reason).toContain('alpha-test');
|
|
});
|
|
|
|
test('no selected names in file → skipped-by-diff', () => {
|
|
const d = diffSkipDecisionForFile('test/skill-e2e-beta.test.ts', new Set(['alpha-test']), opts);
|
|
expect(d.kept).toBe(false);
|
|
expect(d.reason).toContain('mapped test(s)');
|
|
});
|
|
|
|
test('dep-list registration maps files with constructed test names', () => {
|
|
const selected = diffSkipDecisionForFile('test/skill-e2e-gamma.test.ts', new Set(['gamma-registered']), opts);
|
|
expect(selected.kept).toBe(true);
|
|
const unselected = diffSkipDecisionForFile('test/skill-e2e-gamma.test.ts', new Set(['alpha-test']), opts);
|
|
expect(unselected.kept).toBe(false);
|
|
});
|
|
|
|
test('FAIL-OPEN: unmapped file kept, child self-skip authoritative', () => {
|
|
const d = diffSkipDecisionForFile('test/skill-e2e-opaque.test.ts', new Set(['alpha-test']), opts);
|
|
expect(d.kept).toBe(true);
|
|
expect(d.reason).toContain('fail-open');
|
|
});
|
|
|
|
test('FAIL-OPEN: unreadable source kept', () => {
|
|
const d = diffSkipDecisionForFile('test/skill-e2e-missing.test.ts', new Set(['alpha-test']), opts);
|
|
expect(d.kept).toBe(true);
|
|
expect(d.reason).toContain('fail-open');
|
|
});
|
|
|
|
test('FAIL-OPEN: non-skill-e2e paid files always kept', () => {
|
|
const d = diffSkipDecisionForFile('test/codex-e2e.test.ts', new Set(['alpha-test']), opts);
|
|
expect(d.kept).toBe(true);
|
|
expect(d.reason).toContain('non-skill-e2e');
|
|
});
|
|
|
|
test('run-all selection (null) bypasses skipping entirely', () => {
|
|
const shards = [['test/skill-e2e-alpha.test.ts'], ['test/skill-e2e-beta.test.ts']];
|
|
const { runnable, skipped } = partitionShardsByDiffSelection(shards, null, opts);
|
|
expect(runnable).toEqual(shards);
|
|
expect(skipped).toEqual([]);
|
|
});
|
|
|
|
test('EVALS_ALL=1 yields run-all selection (no git consulted)', () => {
|
|
const selection = computePaidDiffSelection({ EVALS_ALL: '1' } as NodeJS.ProcessEnv);
|
|
expect(selection.selectedNames).toBeNull();
|
|
expect(selection.reason).toContain('EVALS_ALL=1');
|
|
expect(selection.totalTests).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('partition drops only all-skippable shards', () => {
|
|
const shards = [
|
|
['test/skill-e2e-alpha.test.ts'],
|
|
['test/skill-e2e-beta.test.ts'],
|
|
['test/skill-e2e-opaque.test.ts'],
|
|
['test/codex-e2e.test.ts'],
|
|
];
|
|
const { runnable, skipped } = partitionShardsByDiffSelection(shards, new Set(['alpha-test']), opts);
|
|
expect(runnable).toEqual([
|
|
['test/skill-e2e-alpha.test.ts'],
|
|
['test/skill-e2e-opaque.test.ts'],
|
|
['test/codex-e2e.test.ts'],
|
|
]);
|
|
expect(skipped.length).toBe(1);
|
|
expect(skipped[0].files).toEqual(['test/skill-e2e-beta.test.ts']);
|
|
});
|
|
|
|
test('taxonomy: skipped-by-diff counted separately, never conflated with never-started', () => {
|
|
const summary = summarize([
|
|
{ shard: 1, files: ['a'], status: 'passed', exitCode: 0, elapsedMs: 1, groupPid: 1 },
|
|
{ shard: 2, files: ['b'], status: 'skipped-by-diff', exitCode: null, elapsedMs: 0, groupPid: null },
|
|
{ shard: 3, files: ['c'], status: 'never-started', exitCode: null, elapsedMs: 0, groupPid: null },
|
|
]);
|
|
expect(summary).toMatchObject({
|
|
total: 3, executed: 1, passed: 1, skippedByDiff: 1, neverStarted: 1,
|
|
});
|
|
const lines = formatSummary(summary);
|
|
expect(lines[1]).toContain('1 skipped by diff');
|
|
expect(lines[1]).toContain('1 never started');
|
|
expect(lines.some((l) => l.includes('skipped-by-diff') && l.includes('b'))).toBe(true);
|
|
});
|
|
|
|
test('exit code ignores skipped-by-diff shards (they are successes)', () => {
|
|
const allGood = summarize([
|
|
{ shard: 1, files: ['a'], status: 'passed', exitCode: 0, elapsedMs: 1, groupPid: 1 },
|
|
{ shard: 2, files: ['b'], status: 'skipped-by-diff', exitCode: null, elapsedMs: 0, groupPid: null },
|
|
]);
|
|
expect(summaryExitCode(allGood)).toBe(0);
|
|
|
|
const withFailure = summarize([
|
|
{ shard: 1, files: ['a'], status: 'failed', exitCode: 1, elapsedMs: 1, groupPid: 1 },
|
|
{ shard: 2, files: ['b'], status: 'skipped-by-diff', exitCode: null, elapsedMs: 0, groupPid: null },
|
|
]);
|
|
expect(summaryExitCode(withFailure)).toBe(1);
|
|
|
|
const withNeverStarted = summarize([
|
|
{ shard: 1, files: ['a'], status: 'never-started', exitCode: null, elapsedMs: 0, groupPid: null },
|
|
{ shard: 2, files: ['b'], status: 'skipped-by-diff', exitCode: null, elapsedMs: 0, groupPid: null },
|
|
]);
|
|
expect(summaryExitCode(withNeverStarted)).toBe(1);
|
|
});
|
|
});
|