mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807) * ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997) * fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805) * fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847) * feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096) Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com> * fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851) * fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906) * test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910) * chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes * v1.91.14.0: release metadata * test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group * ci: Codex login steps run under bash (the CI container's default sh rejects pipefail) * fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt * fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts * test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call * fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier * test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers) * test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts) * test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep) * fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit * test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently * test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census * fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic * test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output * fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens * test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design) * test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept) * fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files --------- Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
292 lines
13 KiB
TypeScript
292 lines
13 KiB
TypeScript
/**
|
|
* Case and trial shard keys for the paid lane: which files shard per case, how a case shard and its trials are named, and expansion of files into case/trial shards. Moved from scripts/test-paid-shards.ts.
|
|
*/
|
|
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 { createBootstrapRetentionScope } from '../../test/helpers/bootstrap-retention';
|
|
import {
|
|
BunTestOutputClassifier,
|
|
createShardSandbox,
|
|
exactTestFileSelectors,
|
|
forwardAndClassify,
|
|
isTerminationRequested,
|
|
nextShardLogPath,
|
|
normalizeRelativePath,
|
|
openShardLog,
|
|
parseCliFlags,
|
|
readDurationSeed,
|
|
removeShardSandbox,
|
|
runShardChild,
|
|
strictShardStatus,
|
|
writeDurationSeed,
|
|
zeroExecutionVerdict,
|
|
type LanePolicy,
|
|
type ShardChildResult,
|
|
type ShardLog,
|
|
} from './shard-engine';
|
|
import { PAID_TEST_GLOBS, isPaidTestFile } from '../../test/helpers/paid-test-set';
|
|
import { CASE_CI_EXCLUDE, CASE_QUARANTINE, EVAL_POLICY, PERIODIC_CI_EXCLUDE } from '../../test/helpers/periodic-exclude-data';
|
|
import { FILE_RETRY_BUDGETS, STRICT_RETRY_CASE_BUDGETS } from '../../test/helpers/eval-budgets';
|
|
import {
|
|
getProjectEvalDir, getClaudeCliVersion, isFinalizedEvalResultFile, evalEntryOutcome, failureClassOf, panelVerdict,
|
|
sanitizeTrialError, formatTrialOutcomes, CONTRACT_VIOLATIONS_FILE, TRIAL_ENV, TRIAL_OUTCOME_SCHEMA, TRIAL_OUTCOMES_FILE,
|
|
type EvalCaseKind, type PanelShape, type PanelVerdict, type TrialFailureClass, type TrialOutcome, type TrialOutcomeRecord,
|
|
} from '../../test/helpers/eval-store';
|
|
import { E2E_KINDS } from '../../test/helpers/touchfiles-data';
|
|
import { manualReviewProblem } from '../../test/helpers/cookie-workflow-manual-review';
|
|
import { preflightAnthropicApi } from '../../test/helpers/anthropic-preflight';
|
|
import { OVERLAY_MIN_FILE_WALL_MS } from '../../test/helpers/overlay-case-policy';
|
|
import { PR_PROFILE_CASE_IDS, PR_PROFILE_FILES, packageChangeOnlyVersion, selectPrProfile, type PrProfileSelection } from '../test-pr-profile';
|
|
import { e2eReuseLaneProblem, prepareE2EShardReuse, selectPlanReceipts, writeNegativeReceipt, writePanelReceipt } from '../e2e-shard-reuse';
|
|
|
|
import {
|
|
detectBaseBranch,
|
|
getChangedFiles,
|
|
selectTests,
|
|
E2E_TOUCHFILES,
|
|
E2E_TIERS,
|
|
LLM_JUDGE_TOUCHFILES,
|
|
GLOBAL_TOUCHFILES,
|
|
} from '../../test/helpers/touchfiles';
|
|
|
|
export { PAID_TEST_GLOBS, isPaidTestFile };
|
|
export { PERIODIC_CI_EXCLUDE };
|
|
|
|
type E2EShardReuse = NonNullable<ReturnType<typeof prepareE2EShardReuse>>;
|
|
import { type PaidTier, ROOT, fileCaseRegistration } from '../test-paid-shards';
|
|
|
|
/**
|
|
* Files whose cases run in separate processes, one shard per registered E2E
|
|
* case (`<file>#<case id>`): the file's lane wall exceeds one runner's budget
|
|
* while every case is short. Separate processes also give each case its own
|
|
* SDK semaphore, so shared-libs(-paths) capture waves never queue inside a
|
|
* sibling case's wall (the reason paths runs test.serial in one process).
|
|
* Every case must be a registered, literal E2E id whose Bun test name is the
|
|
* id or its CASE_TEST_NAMES label (test/paid-shards.test.ts scans the sources).
|
|
*/
|
|
export const CASE_SHARDED_FILES: readonly string[] = [
|
|
'test/skill-e2e-design.test.ts',
|
|
'test/skill-e2e-plan.test.ts',
|
|
'test/skill-e2e-review-army.test.ts',
|
|
'test/skill-e2e-shared-libs-paths.test.ts',
|
|
'test/skill-e2e-shared-libs.test.ts',
|
|
'test/skill-e2e-ship-docsync.test.ts',
|
|
'test/skill-e2e-qa-callers.test.ts',
|
|
];
|
|
|
|
/** Bun test names that differ from their E2E id. */
|
|
export const CASE_TEST_NAMES: Record<string, string> = {
|
|
'plan-review-report': '/plan-eng-review writes GSTACK REVIEW REPORT to plan file',
|
|
'auq-format-gate': "/plan-ceo-review's first AskUserQuestion is a compliant decision brief (7/7 + substance)",
|
|
'autoplan-dual-voice': 'both Claude + Codex voices produce output in Phase 1 (within timeout)',
|
|
};
|
|
|
|
export const CASE_KEY_SEPARATOR = '#';
|
|
const TRIAL_SUFFIX = /~t([1-9][0-9]*)$/;
|
|
|
|
/** The test file behind a shard key (`<file>`, `<file>#<case id>` or `<file>#<case id>~t<N>`). */
|
|
export function shardFile(key: string): string {
|
|
return normalizeRelativePath(key).split(CASE_KEY_SEPARATOR)[0]!;
|
|
}
|
|
|
|
/** The E2E case id of a case or trial shard key, else null. */
|
|
export function shardCaseId(key: string): string | null {
|
|
const [, id] = normalizeRelativePath(key).split(CASE_KEY_SEPARATOR);
|
|
return id === undefined ? null : id.replace(TRIAL_SUFFIX, '');
|
|
}
|
|
|
|
/** The 1-based trial index of an isolated trial shard key, else null. */
|
|
export function shardTrial(key: string): number | null {
|
|
const [, id] = normalizeRelativePath(key).split(CASE_KEY_SEPARATOR);
|
|
const match = id === undefined ? null : TRIAL_SUFFIX.exec(id);
|
|
return match ? Number(match[1]) : null;
|
|
}
|
|
|
|
/** Shard key of one trial of an isolated case. */
|
|
export function trialShardKey(file: string, id: string, trial: number): string {
|
|
return `${normalizeRelativePath(file)}${CASE_KEY_SEPARATOR}${id}~t${trial}`;
|
|
}
|
|
|
|
/** Trial policy of one case, fixed from the registries before the run. */
|
|
export interface CaseTrialPlan { kind: EvalCaseKind; panel: PanelShape; quarantined: boolean }
|
|
|
|
/**
|
|
* `behavior` cases run EVAL_POLICY.panel; a quarantined case runs a full panel
|
|
* whose k keeps its kind's meaning (k = n for rule); everything else runs one
|
|
* trial. Only behavior and quarantined cases are isolated into trial shards.
|
|
*/
|
|
export function caseTrialPlan(id: string, kinds: Record<string, EvalCaseKind> = E2E_KINDS,
|
|
quarantine: Record<string, unknown> = CASE_QUARANTINE): CaseTrialPlan {
|
|
const kind = kinds[id] ?? 'rule';
|
|
const quarantined = Object.hasOwn(quarantine, id);
|
|
if (kind === 'behavior') return { kind, panel: { ...EVAL_POLICY.panel }, quarantined };
|
|
if (quarantined) return { kind, panel: { n: EVAL_POLICY.panel.n, k: EVAL_POLICY.panel.n }, quarantined };
|
|
return { kind, panel: { n: 1, k: 1 }, quarantined };
|
|
}
|
|
|
|
export function isIsolatedCase(plan: CaseTrialPlan): boolean {
|
|
return plan.kind === 'behavior' || plan.quarantined;
|
|
}
|
|
|
|
export function sameTrialPlan(a: CaseTrialPlan | undefined, b: CaseTrialPlan | undefined): boolean {
|
|
return !!a && !!b && a.kind === b.kind && a.quarantined === b.quarantined && a.panel?.n === b.panel?.n && a.panel?.k === b.panel?.k;
|
|
}
|
|
|
|
/** Bun name pattern that runs every case of a file except `ids` (their trial shards run them). */
|
|
export function excludedCasesNamePattern(ids: string[]): string {
|
|
const escaped = ids.map(id => (CASE_TEST_NAMES[id] ?? id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
return `^(?!.*(?:^|\\s)(?:${escaped.join('|')})$)`;
|
|
}
|
|
|
|
/** Exact Bun name pattern for a set of case ids (labels where the test name differs). */
|
|
export function caseTestNamePattern(ids: string[]): string {
|
|
const escaped = ids.map(id => (CASE_TEST_NAMES[id] ?? id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
return `(?:^|\\s)(?:${escaped.join('|')})$`;
|
|
}
|
|
|
|
/**
|
|
* Replace each case-sharded file with one key per registered case of `tier`.
|
|
* Throws when such a file's registration is not statically complete: an
|
|
* unregistered case would otherwise silently never run.
|
|
*/
|
|
export function expandCaseShards(files: string[], tier: PaidTier, rootDir = ROOT,
|
|
touchfiles: Record<string, string[]> = E2E_TOUCHFILES, tiers: Record<string, string> = E2E_TIERS): string[] {
|
|
return files.flatMap(file => {
|
|
const rel = normalizeRelativePath(file);
|
|
if (!CASE_SHARDED_FILES.includes(rel)) return [file];
|
|
const { registered, known } = fileCaseRegistration(rel, fs.readFileSync(path.join(rootDir, rel), 'utf8'), touchfiles, tiers);
|
|
if (!known) throw new Error(`Case-sharded ${rel} needs a complete literal case registration`);
|
|
return registered.filter(id => tiers[id] === tier).sort().map(id => `${rel}${CASE_KEY_SEPARATOR}${id}`);
|
|
});
|
|
}
|
|
|
|
export interface TrialExpansion {
|
|
keys: string[];
|
|
/** Trial policy per trial shard key. */
|
|
trials: Record<string, CaseTrialPlan>;
|
|
/** File shard key -> isolated case ids its name pattern excludes. */
|
|
excludeCases: Record<string, string[]>;
|
|
}
|
|
|
|
/**
|
|
* Isolate every behavior or quarantined case of `tier` into its panel of trial
|
|
* shards (`<file>#<id>~t1..tn`), each selected by EVALS_SELECTION_JSON=[id] and
|
|
* its exact test name. The file shard keeps the remaining ids of the tier and
|
|
* excludes the isolated ones by name; with none remaining it is dropped. A case
|
|
* may be isolated only when its file's registration is statically known.
|
|
*/
|
|
export function expandTrialShards(keys: string[], tier: PaidTier, rootDir = ROOT, opts: {
|
|
kinds?: Record<string, EvalCaseKind>; quarantine?: Record<string, unknown>;
|
|
touchfiles?: Record<string, string[]>; tiers?: Record<string, string>;
|
|
} = {}): TrialExpansion {
|
|
const touchfiles = opts.touchfiles ?? E2E_TOUCHFILES;
|
|
const tiers = opts.tiers ?? E2E_TIERS;
|
|
const planOf = (id: string) => caseTrialPlan(id, opts.kinds, opts.quarantine);
|
|
const out: TrialExpansion = { keys: [], trials: {}, excludeCases: {} };
|
|
const addPanel = (file: string, id: string) => {
|
|
const plan = planOf(id);
|
|
for (let trial = 1; trial <= plan.panel.n; trial++) {
|
|
const key = trialShardKey(file, id, trial);
|
|
out.keys.push(key);
|
|
out.trials[key] = plan;
|
|
}
|
|
};
|
|
for (const key of keys) {
|
|
const file = shardFile(key);
|
|
const caseId = shardCaseId(key);
|
|
if (caseId !== null) {
|
|
if (isIsolatedCase(planOf(caseId))) addPanel(file, caseId);
|
|
else out.keys.push(key);
|
|
continue;
|
|
}
|
|
const { registered, known } = fileCaseRegistration(file, fs.readFileSync(path.join(rootDir, file), 'utf8'), touchfiles, tiers);
|
|
const inTier = registered.filter(id => tiers[id] === tier);
|
|
const isolated = inTier.filter(id => isIsolatedCase(planOf(id)));
|
|
if (isolated.length === 0) { out.keys.push(key); continue; }
|
|
if (!known) {
|
|
throw new Error(`${file}: behavior or quarantined case(s) ${isolated.join(', ')} need a statically known case registration`);
|
|
}
|
|
for (const id of isolated) addPanel(file, id);
|
|
if (inTier.length > isolated.length) {
|
|
out.keys.push(key);
|
|
out.excludeCases[normalizeRelativePath(key)] = [...isolated].sort();
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Split expanded shard keys into runnable keys and CI-unrunnable cases
|
|
* (CASE_CI_EXCLUDE), each with its surfaced reason; never an empty shard.
|
|
*/
|
|
export function partitionCaseExclusions(keys: string[]): { runnable: string[]; excluded: Array<{ file: string; reason: string }> } {
|
|
const excluded: Array<{ file: string; reason: string }> = [];
|
|
const runnable = keys.filter(key => {
|
|
const exclusion = CASE_CI_EXCLUDE[normalizeRelativePath(key)];
|
|
if (exclusion) excluded.push({ file: key, reason: `excluded: ${exclusion.reason} [${exclusion.tracking}]` });
|
|
return !exclusion;
|
|
});
|
|
return { runnable, excluded };
|
|
}
|
|
|
|
/**
|
|
* Codex access in the CI eval image, scoped per paid shard.
|
|
*
|
|
* The image installs the pinned Codex CLI off PATH (GSTACK_CI_CODEX_BIN_DIR)
|
|
* and the eval workflows log it in under a non-default home
|
|
* (GSTACK_CI_CODEX_HOME). A gate shard never sees either. A non-gate shard
|
|
* whose every file exercises Codex gets the CLI on PATH and the home as
|
|
* CODEX_HOME. A file that mixes one Codex case with other cases keeps only
|
|
* the two variables, and that case opts in on its own session; hermetic
|
|
* child environments drop GSTACK_* names, so its sibling sessions see no
|
|
* Codex. Every other shard sees neither, so a skill's outside-voice probe
|
|
* (`command -v codex`) reports not_installed exactly as it did before Codex
|
|
* entered the image. Outside CI neither variable is set and nothing changes.
|
|
*/
|
|
export const CODEX_CI_ENV = { binDir: 'GSTACK_CI_CODEX_BIN_DIR', home: 'GSTACK_CI_CODEX_HOME' } as const;
|
|
|
|
/** Paid files whose every case exercises the real Codex CLI. */
|
|
export const CODEX_CI_FILES: readonly string[] = [
|
|
'test/codex-e2e.test.ts',
|
|
'test/codex-e2e-sol-scope.test.ts',
|
|
'test/codex-e2e-shared-libs.test.ts',
|
|
'test/codex-e2e-recommendation-substance.test.ts',
|
|
'test/skill-e2e-outside-voice.test.ts',
|
|
'test/skill-e2e-outside-plan-disabled.test.ts',
|
|
];
|
|
|
|
/**
|
|
* Codex cases inside files whose other cases must not see Codex. Each listed
|
|
* case opts in by putting $GSTACK_CI_CODEX_BIN_DIR on its own session PATH
|
|
* (inline, so the file's other touchfile keys do not grow a helper).
|
|
*/
|
|
export const CODEX_CI_CASES: Readonly<Record<string, readonly string[]>> = {
|
|
'test/skill-e2e-workflow.test.ts': ['codex-review'],
|
|
};
|
|
|
|
export type CodexShardAccess = 'none' | 'case-opt-in' | 'path';
|
|
|
|
/** What one shard, given the test files behind its keys, may see of the CI Codex install in `tier`. */
|
|
export function codexShardAccess(files: readonly string[], tier: string | undefined): CodexShardAccess {
|
|
if (tier === 'gate' || files.length === 0) return 'none';
|
|
if (files.every(file => CODEX_CI_FILES.includes(file))) return 'path';
|
|
return files.every(file => file in CODEX_CI_CASES) ? 'case-opt-in' : 'none';
|
|
}
|
|
|
|
/** Scope one shard's child environment in place; `files` are the shard keys' test files. */
|
|
export function scopeCodexAccess(env: NodeJS.ProcessEnv, files: readonly string[]): void {
|
|
const binDir = env[CODEX_CI_ENV.binDir];
|
|
if (!binDir) return;
|
|
const home = env[CODEX_CI_ENV.home];
|
|
const access = codexShardAccess(files, env.EVALS_TIER);
|
|
delete env.CODEX_HOME;
|
|
if (access === 'case-opt-in') return;
|
|
delete env[CODEX_CI_ENV.binDir];
|
|
delete env[CODEX_CI_ENV.home];
|
|
if (access !== 'path') return;
|
|
env.PATH = env.PATH ? `${binDir}${path.delimiter}${env.PATH}` : binDir;
|
|
if (home) env.CODEX_HOME = home;
|
|
}
|