Merge origin/main (5225e35, v1.92.42.0) into gsta215-ceo-only-driver

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-01 21:52:30 +00:00
commit f4547e07cc
249 files changed
+15292 -6073

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* ci-tree-reuse — run each free suite once per tree (GSTA-156, docs/pregate.md#ci-reuse).
*
* bun run scripts/ci-tree-reuse.ts record --plan <plan.json> --results <dir> --os <OS>[,<OS>…] --out <tree.json>
* The producer. A Free Tests or Windows Free Tests aggregate runs it only after its strict verification
* passed, and uploads the file as the `tested-tree` artifact: run_id, run_attempt, event, sha (GITHUB_SHA),
* head_sha (the pull request head for pull_request, else GITHUB_SHA), tree (git's tree of GITHUB_SHA),
* workflow, os (the OSes the run's test jobs ran on), files (every test file in the plan) and tests.
*
* bun run scripts/ci-tree-reuse.ts find --workflow <file.yml> --profile <run_profiles name>
* The main-push consumer (the `reuse` job). A push to main whose tree a pull request already tested green
* needs no second run: it finds the pull request whose merge commit is the pushed commit (same-repository
* head, merged into main), requires that head's tree to equal the pushed tree, lists that head's runs of
* this workflow (pull_request or workflow_dispatch, newest first, at most MAX_CANDIDATES), and accepts the
* first one lib/run-evidence.ts verifyRun accepts under the profile in the pushed tree's
* .gstack/pregate.json. A pull_request run counts only when its merge commit's tree is its head's tree
* (verifyRun). It writes reused=true, run_id, run_url and tree to GITHUB_OUTPUT, or reused=false and a
* reason, with one REUSE: line in the step summary. Every lookup failure is a refusal, never a pass: the
* workflow then runs every job as before. Pull request and dispatch runs never consume.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { ghApiJson, ghRunDeps, runProfileErrors, verifyRun, type ApiRun, type RunDeps, type RunProfile } from '../lib/run-evidence';
export const MAX_PULLS = 10;
export const MAX_CANDIDATES = 10;
const SHA = /^[0-9a-f]{40}$/;
const OSES = ['Linux', 'macOS', 'Windows'];
export interface ApiPull { number: number; merged_at: string | null; merge_commit_sha: string | null; base: { ref: string; repo?: { full_name: string } | null }; head: { sha: string; repo: { full_name: string } | null } }
export interface ReuseDeps extends RunDeps { pullsForCommit(sha: string): ApiPull[] }
export type ReuseVerdict =
| { reused: true; run_id: number; run_attempt: number; event: string; pr: number; head_sha: string; tree: string }
| { reused: false; reason: string; detail: string };
const refuse = (reason: string, detail: string): ReuseVerdict => ({ reused: false, reason, detail });
const startedAt = (r: ApiRun) => r.run_started_at ?? r.created_at ?? '';
/** The run a push of `pushSha` to `base` may reuse, or why none. Pure over `deps`, so every refusal has a fixture. */
export function findReuse(deps: ReuseDeps, o: { repo: string; base: string; pushSha: string; workflow: string; profileName: string; profile: RunProfile; now: Date }): ReuseVerdict {
if (!SHA.test(o.pushSha)) return refuse('usage', `${JSON.stringify(o.pushSha)} is not a commit sha`);
if (o.profile.workflow !== o.workflow) return refuse('profile_mismatch', `profile ${o.profileName} names ${o.profile.workflow}, not ${o.workflow}`);
try {
const pulls = deps.pullsForCommit(o.pushSha).slice(0, MAX_PULLS);
const merged = pulls.filter(p => p.merged_at && p.base.ref === o.base && p.merge_commit_sha === o.pushSha && (!p.base.repo || p.base.repo.full_name.toLowerCase() === o.repo.toLowerCase()));
if (!merged.length) return refuse('no_pr', `no pull request merged into ${o.base} has ${o.pushSha.slice(0, 12)} as its merge commit (${pulls.length} associated)`);
if (merged.length > 1) return refuse('ambiguous_pr', `${merged.length} merged pull requests name ${o.pushSha.slice(0, 12)}: ${merged.map(p => `#${p.number}`).join(', ')}`);
const pr = merged[0]!;
if ((pr.head.repo?.full_name ?? '').toLowerCase() !== o.repo.toLowerCase()) return refuse('fork_head', `#${pr.number}'s head is on ${pr.head.repo?.full_name ?? 'a deleted repository'}, not ${o.repo}`);
if (!SHA.test(pr.head.sha)) return refuse('api_error', `#${pr.number} has no head commit`);
const pushTree = deps.commitTree(o.pushSha);
const headTree = deps.commitTree(pr.head.sha);
if (!pushTree || !headTree) return refuse('api_error', `cannot read the tree of ${pushTree ? pr.head.sha : o.pushSha}`);
if (headTree !== pushTree) return refuse('tree_mismatch', `#${pr.number}'s head ${pr.head.sha.slice(0, 12)} has tree ${headTree.slice(0, 12)}, the push has ${pushTree.slice(0, 12)}`);
const events: string[] = o.profile.events.filter(e => e === 'pull_request' || e === 'workflow_dispatch');
const candidates = deps.runsForHead(o.workflow, pr.head.sha).filter(r => events.includes(r.event))
.sort((a, b) => startedAt(b).localeCompare(startedAt(a)) || b.id - a.id).slice(0, MAX_CANDIDATES);
if (!candidates.length) return refuse('no_candidate', `#${pr.number}'s head ${pr.head.sha.slice(0, 12)} has no ${events.join(' or ') || 'pull_request or workflow_dispatch'} run of ${o.workflow}`);
const refusals: string[] = [];
for (const run of candidates) {
const v = verifyRun(deps, { repo: o.repo, runRef: String(run.id), profileName: o.profileName, profile: o.profile, now: o.now });
if (!v.ok) { refusals.push(`run ${run.id} (${run.event}) reason=${v.reason} ${v.detail}`); continue; }
if (v.evidence.tree !== pushTree) { refusals.push(`run ${run.id} tested tree ${v.evidence.tree.slice(0, 12)}`); continue; }
return { reused: true, run_id: v.evidence.run_id, run_attempt: v.evidence.run_attempt, event: v.evidence.event, pr: pr.number, head_sha: pr.head.sha, tree: pushTree };
}
return refuse('no_verified_run', refusals.join(' | '));
} catch (e: any) {
return refuse('api_error', String(e?.message ?? e).slice(0, 300));
}
}
/** tree.json for the `tested-tree` artifact; throws on any missing or malformed input, so a broken record fails the aggregate instead of uploading. */
export function treeRecord(env: NodeJS.ProcessEnv, o: { plan: { shards: Array<{ files: string[] }> }; results: Array<{ outcome?: { summary?: { testsRan?: number } } }>; os: string[]; tree: string }) {
const int = (name: string) => { const v = Number(env[name]); if (!Number.isSafeInteger(v) || v < 1) throw new Error(`${name} is not a positive integer`); return v; };
const sha = env.GITHUB_SHA ?? '';
const event = env.GITHUB_EVENT_NAME ?? '';
const head = event === 'pull_request' ? env.PR_HEAD_SHA ?? '' : sha;
if (!SHA.test(sha) || !SHA.test(head)) throw new Error(`GITHUB_SHA ${JSON.stringify(sha)} or the head ${JSON.stringify(head)} is not a commit sha (PR_HEAD_SHA is required for pull_request)`);
if (!/^[a-z_]+$/.test(event)) throw new Error(`GITHUB_EVENT_NAME ${JSON.stringify(event)} is not an event name`);
const workflow = /^[^/]+\/[^/]+\/(\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml)@/.exec(env.GITHUB_WORKFLOW_REF ?? '')?.[1];
if (!workflow) throw new Error(`GITHUB_WORKFLOW_REF ${JSON.stringify(env.GITHUB_WORKFLOW_REF)} names no workflow file`);
if (!/^[0-9a-f]{40,64}$/.test(o.tree)) throw new Error(`${JSON.stringify(o.tree)} is not a tree id`);
if (!o.os.length || o.os.some(x => !OSES.includes(x))) throw new Error(`--os must list some of ${OSES.join(', ')}`);
const files = [...new Set(o.plan.shards.flatMap(s => s.files))].sort();
if (!files.length) throw new Error('the plan lists no test files');
const counts = o.results.map(r => r.outcome?.summary?.testsRan);
const tests = o.results.length && counts.every(n => Number.isSafeInteger(n) && n! >= 0) ? counts.reduce((a, b) => a! + b!, 0)! : files.length;
return { run_id: int('GITHUB_RUN_ID'), run_attempt: int('GITHUB_RUN_ATTEMPT'), event, sha, head_sha: head, tree: o.tree, workflow, os: o.os, files, tests };
}
function flag(args: string[], name: string): string | undefined { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }
function need(args: string[], name: string): string { const v = flag(args, name); if (!v || v.startsWith('--')) throw new Error(`${name} <value> is required`); return v; }
function record(args: string[]): number {
const plan = JSON.parse(fs.readFileSync(need(args, '--plan'), 'utf8'));
const dir = need(args, '--results');
const results = fs.readdirSync(dir).filter(f => f.endsWith('.json')).map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
const git = spawnSync('git', ['rev-parse', '--verify', `${process.env.GITHUB_SHA ?? ''}^{tree}`], { encoding: 'utf8', timeout: 30_000 });
const rec = treeRecord(process.env, { plan, results, os: need(args, '--os').split(','), tree: git.status === 0 ? git.stdout.trim() : '' });
const out = need(args, '--out');
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
fs.writeFileSync(out, JSON.stringify(rec, null, 2) + '\n');
console.log(`TESTED_TREE: run ${rec.run_id}/${rec.run_attempt} ${rec.event} head ${rec.head_sha.slice(0, 12)} tree ${rec.tree.slice(0, 12)} files=${rec.files.length} tests=${rec.tests} os=${rec.os.join(',')}`);
return 0;
}
/** The consumer's GITHUB_OUTPUT and step-summary text for a verdict. reused=true is written last, after the run it names. */
export function reuseOutputs(v: ReuseVerdict, repo: string, server = 'https://github.com'): { line: string; output: string } {
if (!v.reused) return { line: `REUSE: refused reason=${v.reason} — ${v.detail}; every job runs`, output: `reused=false\nreason=${v.reason}\n` };
const url = `${server}/${repo}/actions/runs/${v.run_id}`;
return { line: `REUSE: accepted run ${v.run_id} (${v.event}, attempt ${v.run_attempt}) of #${v.pr} at ${v.head_sha.slice(0, 12)}, tree ${v.tree.slice(0, 12)}: ${url}`, output: `run_id=${v.run_id}\nrun_url=${url}\ntree=${v.tree}\nreused=true\n` };
}
function find(args: string[]): number {
const env = process.env;
const workflow = need(args, '--workflow');
const profileName = need(args, '--profile');
const repo = env.GITHUB_REPOSITORY ?? '';
const settle = (v: ReuseVerdict) => {
const { line, output } = reuseOutputs(v, repo, env.GITHUB_SERVER_URL);
console.log(line);
if (env.GITHUB_STEP_SUMMARY) fs.appendFileSync(env.GITHUB_STEP_SUMMARY, `${line}\n`);
if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT, output);
return 0;
};
if (env.GITHUB_EVENT_NAME !== 'push' || env.GITHUB_REF !== 'refs/heads/main') return settle(refuse('not_main_push', `${env.GITHUB_EVENT_NAME ?? 'no'} event on ${env.GITHUB_REF ?? 'no ref'}; only a push to main consumes`));
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) return settle(refuse('usage', `GITHUB_REPOSITORY ${JSON.stringify(repo)} is not owner/name`));
let profiles: Record<string, RunProfile>;
try {
const config = JSON.parse(fs.readFileSync('.gstack/pregate.json', 'utf8'));
const errors = runProfileErrors(config.run_profiles, undefined);
if (errors.length) return settle(refuse('profile_invalid', errors.map(e => `${e.path}: ${e.message}`).join('; ')));
profiles = config.run_profiles ?? {};
} catch (e: any) { return settle(refuse('profile_invalid', `.gstack/pregate.json: ${String(e?.message ?? e).slice(0, 200)}`)); }
const profile = profiles[profileName];
if (!profile) return settle(refuse('profile_invalid', `.gstack/pregate.json declares no run profile ${profileName}`));
const deps: ReuseDeps = {
...ghRunDeps(repo, process.cwd()),
commitTree: sha => (SHA.test(sha) ? ghApiJson<{ tree?: { sha?: string } }>(`repos/${repo}/git/commits/${sha}`).tree?.sha ?? null : null),
pullsForCommit: sha => ghApiJson<ApiPull[]>(`repos/${repo}/commits/${sha}/pulls?per_page=${MAX_PULLS}`),
};
return settle(findReuse(deps, { repo, base: 'main', pushSha: env.GITHUB_SHA ?? '', workflow, profileName, profile, now: new Date() }));
}
if (import.meta.main) {
const [verb, ...args] = process.argv.slice(2);
try {
if (verb === 'record') process.exitCode = record(args);
else if (verb === 'find') process.exitCode = find(args);
else { console.error('usage: ci-tree-reuse.ts record --plan <f> --results <dir> --os <OS,…> --out <f> | find --workflow <f.yml> --profile <name>'); process.exitCode = 2; }
} catch (e: any) {
console.error(`ci-tree-reuse ${verb}: ${e?.message ?? e}`);
process.exitCode = 1;
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -29,7 +29,7 @@
"test/helpers/eval-budgets.ts": {"blob":"667d0e09b20fe5d7366f57b77b5fb9b04baa37ef","since":6},
"test/helpers/eval-store.ts": {"blob":"a50ea2c28f508ad9f35ea9353dd2f502ee0dcfc2","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
"test/helpers/hermetic-env.ts": {"blob":"cc072895461dc52ea317d1beff72121342420d30","since":6},
"test/helpers/llm-judge.ts": {"blob":"095f9c85dd0ae29da8ba10fed91510313a4442a4","since":6,"nonBehavioral":"callJudge emits one llm-judge-aborted diagnostic line (wrapper attempts, HTTP request and status counts seen through a pass-through fetch, 429 waits, connect and event timings, block kind, character counts, message and request ids, a capped reason; no content) when its caller's signal aborts it; observers swallow their own errors, and the request, retries, parsing, scores and the thrown abort reason are unchanged, so no case's pass or fail changes"},
"test/helpers/llm-judge.ts": {"blob":"f14ca0db6504480698acd662351b6f0d6ef31a7b","since":6,"nonBehavioral":"llm-judge.ts adds a pass-through byte counter (bytesReceived, lastByteMs) to the aborted-judge diagnostic; the request, response bytes, deadline and scoring are unchanged"},
"test/helpers/paid-test-set.ts": {"blob":"ff9bfe43c43ec2bdd11114f7b8ec7c5736116f75","since":6},
"test/helpers/pty-screen.ts": {"blob":"4f77569f9ce11f9cee9f347216e468cd7c956820","since":6},
"test/helpers/pty/auq.ts": {"blob":"16e5ea2bb5b9f28f44808a1a09024dbd3e6f28c4","since":6},
@@ -41,9 +41,9 @@
"test/helpers/pty/launch.ts": {"blob":"e4413de380b799659170e7da248d51afda3b0e3f","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
"test/helpers/pty/plan-native.ts": {"blob":"ddba9cb68c7c0da935cff7c1cea258dee2dff740","since":6},
"test/helpers/pty/runners/counting.ts": {"blob":"8c5c05e54abf961259d34d9c3d77db67138d7794","since":6},
"test/helpers/pty/runners/floor.ts": {"blob":"54e2145832bb98913644d10ff303792d67f9362e","since":6,"nonBehavioral":"Removes the plan-design 0D focus picker from the shared harness; floor.ts gains an optional pickSetupChoice hook that no other caller passes, so no other case's input or verdict can change. plan-design-finding-floor, the only case whose seed raises that menu, passes the picker from its owned fixture test/fixtures/design-floor-focus-picker.ts, so its owned bytes start its own new series."},
"test/helpers/pty/runners/floor.ts": {"blob":"a9938cb98f285474ebc4860cd61012504a36466e","since":6,"nonBehavioral":"The PTY floor and observation runners end a plan-mode turn at an idle prompt (the plan-mode footer without its running 'esc to interrupt' segment, after a 10 s settle) instead of waiting out the deadline. The floor runner exits as api_error only after the idle screen with its error panel held still for 10 s and every tick's question and assessment steps found nothing to credit (the transcript is re-read each tick), so a native or prose question already on screen is credited as before, even when its transcript line lands after the screen draws done (test/helpers/claude-pty-runner.runners.unit.test.ts, the prose-question-before-panel case). With no qualifying question an idle turn stays idle, so a trial that failed at the deadline as observer_timeout now fails earlier as api_error. An observation returns what the deadline would have returned. A running footer, extra footer text and a cut footer still wait for the deadline. No pass becomes a fail or a fail a pass. The session ledger may now record failure_cause api_error or refusal instead of observer_timeout or session_timeout for a plan-mode session whose last idle turn shows a panel; failure_class, the gate red class and pass rates are unchanged, so no pass-rate series resets"},
"test/helpers/pty/runners/observation.ts": {"blob":"1c2b75fbb984708725abaa8939125f7cab969f9f","since":6},
"test/helpers/pty/screen.ts": {"blob":"f3743152fc9bb7a8e8c70002f75bf45914f260d5","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
"test/helpers/pty/screen.ts": {"blob":"1384f765e48e511d5f843df2660b2ec70bbcb6b2","since":6,"nonBehavioral":"The PTY floor and observation runners end a plan-mode turn at an idle prompt (the plan-mode footer without its running 'esc to interrupt' segment, after a 10 s settle) instead of waiting out the deadline. The floor runner exits as api_error only after the idle screen with its error panel held still for 10 s and every tick's question and assessment steps found nothing to credit (the transcript is re-read each tick), so a native or prose question already on screen is credited as before, even when its transcript line lands after the screen draws done (test/helpers/claude-pty-runner.runners.unit.test.ts, the prose-question-before-panel case). With no qualifying question an idle turn stays idle, so a trial that failed at the deadline as observer_timeout now fails earlier as api_error. An observation returns what the deadline would have returned. A running footer, extra footer text and a cut footer still wait for the deadline. No pass becomes a fail or a fail a pass. The session ledger may now record failure_cause api_error or refusal instead of observer_timeout or session_timeout for a plan-mode session whose last idle turn shows a panel; failure_class, the gate red class and pass rates are unchanged, so no pass-rate series resets"},
"test/helpers/pty/session.ts": {"blob":"54fe04b61d953e4e55e2fc251532c04a49fab66f","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
"test/helpers/session-drain-policy.ts": {"blob":"6211239dd0485e6495a700b824a7e0e61d1bcd97","since":6},
"test/helpers/session-ledger.ts": {"blob":"a10bf28bf100f135b93c73818670dd147253cdd3","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"},
+59
View File
@@ -258,6 +258,26 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
file: 'test/claude-overlay-setup-installs.test.ts',
reason: 'every case runs the real ./setup under describe.skipIf(win32); its bin/ spawns live in test/helpers/claude-overlay-fixture.ts, so the source scan misses them',
},
{
file: 'test/claude-overlay-setup-lifecycle.test.ts',
reason: 'every case runs the real ./setup under describe.skipIf(win32); its bin/ spawns live in test/helpers/claude-overlay-fixture.ts, so the source scan misses them',
},
{
file: 'test/claude-overlay-setup-project.test.ts',
reason: 'every case runs the real ./setup under describe.skipIf(win32); its bin/ spawns live in test/helpers/claude-overlay-fixture.ts, so the source scan misses them',
},
{
file: 'test/host-renders-prune-shared-root.test.ts',
reason: 'every case runs the real ./setup under describe.skipIf(win32); its bin/ spawns live in test/helpers/install-fixture.ts, so the source scan misses them',
},
{
file: 'test/host-renders-prune-migration.test.ts',
reason: 'every case runs the real ./setup under describe.skipIf(win32); its bin/ spawns live in test/helpers/install-fixture.ts, so the source scan misses them',
},
{
file: 'test/setup-codex-scope-runtime-links.test.ts',
reason: 'every case runs the real ./setup under describe.skipIf(win32); its bin/ spawns live in test/helpers/install-fixture.ts and test/helpers/setup-codex-scope-cyclic.ts, so the source scan misses them',
},
{
file: 'test/cso-scanner-cli.test.ts',
reason: 'drives the prebuilt POSIX CSO launcher with /usr/bin/git and a POSIX-only PATH; native Windows launcher behavior is covered by the dedicated cso-windows-launcher gate',
@@ -299,10 +319,47 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
file: 'test/ship-queue-train.test.ts',
reason: 'drives bin/gstack-ship-queue against the stateful #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead of the shim (windows-free-shard run 39152702056)',
},
{
file: 'test/ship-queue-train-bisect.test.ts',
reason: 'split from test/ship-queue-train.test.ts (GSTA-151): drives bin/gstack-ship-queue against the same #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts',
},
{
file: 'test/ship-queue-train-verify.test.ts',
reason: 'split from test/ship-queue-train.test.ts (GSTA-151): drives bin/gstack-ship-queue against the same #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts',
},
{
file: 'test/ubicloud-runner-tiers.test.ts',
reason: 'split from test/ubicloud-runner.test.ts, excluded as a whole: runs scripts/ubicloud/ubi-runner.sh through bash with the #!/usr/bin/env bash curl, ssh and make shims test/helpers/ubi-runner-fake-vm.ts puts on a colon-joined PATH, like test/ubicloud-runner-run.test.ts',
},
{
file: 'test/ship-queue-train-freeze.test.ts',
reason: 'split from test/ship-queue-train.test.ts (GSTA-151): drives bin/gstack-ship-queue against the same #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts',
},
{
file: 'test/ship-queue-train-cost.test.ts',
reason: 'drives bin/gstack-ship-queue train verdict against the same #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead of the shim (windows-free-shard 2 on #3478 at d5e6a7d)',
},
// GSTA-151 splits: parts of files the curation excluded as a whole keep that platform scope.
{
file: 'test/docsync-fault-interface-legacy.test.ts',
reason: 'split from test/docsync-fault-interface.test.ts, excluded as a whole by its POSIX mode-bit checks; replays the same docsync callbacks through the generated bun-test child',
},
{
file: 'test/docsync-fault-interface-faults.test.ts',
reason: 'split from test/docsync-fault-interface.test.ts, excluded as a whole by its POSIX mode-bit checks; replays the same docsync callbacks through the generated bun-test child',
},
{
file: 'test/shared-libs-checker-interface-evidence.test.ts',
reason: 'runs bin/gstack-review-log (shebang) through test/helpers/shared-libs-checker-evidence.ts captures (Windows CreateProcess does not parse shebangs)',
},
{
file: 'test/shared-libs-checker-interface-evidence-declared.test.ts',
reason: 'split from test/shared-libs-checker-interface-evidence.test.ts: runs bin/gstack-review-log (shebang) through test/helpers/shared-libs-checker-evidence.ts captures (Windows CreateProcess does not parse shebangs)',
},
{
file: 'test/shared-libs-checker-interface-evidence-replays.test.ts',
reason: 'split from test/shared-libs-checker-interface-evidence.test.ts, which spawned bin/gstack-review-log (shebang); its prerequisite checks run the same review-log commands through bash',
},
{
file: 'test/ship-queue-train-ops.test.ts',
reason: 'drives bin/gstack-ship-queue (screen verdicts, scratch-merge screens, prune --stale, watch, forget, verify --post-merge, ready --dispatch-ci, --stack-tests) against the same #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts',
@@ -355,6 +412,8 @@ export const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [
{ file: 'test/gstack-config-gbrain-refresh.test.ts', reason: 'E6: runs bin/gstack-config through explicit bash; its gbrain/python3 shims are found by bash PATH lookup, never launched by CreateProcess' },
{ file: 'test/hook-scripts.test.ts', reason: '#3067 PowerShell and NotebookEdit hook coverage: every hook runs through spawnSync("bash", [script]) with the payload on stdin, scratch state lives under os.tmpdir(); its /tmp/ literals are payload strings, never opened; symlink, newline-path and POSIX-PATH cases are skipIf win32' },
{ file: 'test/gstack-doctor.test.ts', reason: 'runs bin/gstack-doctor and setup --status through explicit bash argv in an os.tmpdir fixture; its bun/codex/claude/probe stubs are found by bash PATH lookup, and expected paths use bash spellings' },
{ file: 'test/gstack-doctor-check.test.ts', reason: 'split from test/gstack-doctor.test.ts: runs bin/gstack-doctor --check through explicit bash argv in the test/helpers/doctor-fixture.ts os.tmpdir fixture; its stubs are found by bash PATH lookup, and expected paths use bash spellings' },
{ file: 'test/gstack-doctor-launch.test.ts', reason: 'split from test/gstack-doctor.test.ts: runs bin/gstack-doctor through explicit bash argv in the test/helpers/doctor-fixture.ts os.tmpdir fixture; Windows bash and platform rows are injected through GSTACK_DOCTOR_PLATFORM, and POSIX-path cases skip on win32' },
// Fix wave PR 5 (item 10b, #3197): the fixture runBin launches bins through lib/script-invocation.ts
// (bun bins as Bun argv, bash bins through Git Bash); passed on windows-latest in the fix wave PR 5 windows-free-tests run.
{ file: 'test/gstack-regen.test.ts', reason: 'runs bin/gstack-regen through the fixture runBin (lib/script-invocation.ts) and its regen commands through the resolved Git Bash' },
+1 -1
View File
@@ -241,6 +241,7 @@
"test/helpers/qa-functional-evidence.ts\tTS7006\tParameter 'row' implicitly has an 'any' type.": 2,
"test/helpers/session-runner.ts\tTS2352\tConversion of type 'ReadableStream<any>' to type 'ReadableStream<Uint8Array<ArrayBufferLike>>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Types of property 'getReader' are incompatible. Type '{ (): ReadableStreamDefaultReader<any>; (options: { mode: \"byob\"; }): ReadableStreamBYOBReader; (options?: ReadableStreamGetReaderOptions | undefined): ReadableStreamReader<...>; }' is not comparable to type '{ (): ReadableStreamDefaultReader<Uint8Array<ArrayBufferLike>>; (options: { mode: \"byob\"; }): ReadableStreamBYOBReader; (options?: ReadableStreamGetReaderOptions | undefined): ReadableStreamReader<...>; }'. Target signature provides too few arguments. Expected 1 or more, but got 0.": 1,
"test/helpers/setup-gbrain-sandbox.ts\tTS2345\tArgument of type '(event: any) => { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }[] | { message: { content: any; id: any; role: any; }; parent_tool_use_id: any; session_id: any; type: any; }[]' is not assignable to parameter of type '(this: undefined, value: unknown, index: number, array: unknown[]) => readonly { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }[] | { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }'. Type '{ claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }[] | { message: { content: any; id: any; role: any; }; parent_tool_use_id: any; session_id: any; type: any; }[]' is not assignable to type 'readonly { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }[] | { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }'. Type '{ message: { content: any; id: any; role: any; }; parent_tool_use_id: any; session_id: any; type: any; }[]' is not assignable to type 'readonly { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }[] | { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }'. Type '{ message: { content: any; id: any; role: any; }; parent_tool_use_id: any; session_id: any; type: any; }[]' is not assignable to type 'readonly { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }[]'. Type '{ message: { content: any; id: any; role: any; }; parent_tool_use_id: any; session_id: any; type: any; }' is missing the following properties from type '{ claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }': subtype, cwd, model, tools, claude_code_version": 1,
"test/helpers/shared-libs-checker-evidence.ts\tTS18048\t'current' is possibly 'undefined'.": 1,
"test/helpers/shared-libs-eval-fixture.ts\tTS7006\tParameter 'candidate' implicitly has an 'any' type.": 2,
"test/helpers/shared-libs-path-fixture.ts\tTS2352\tConversion of type '{ env: { GSTACK_HOME: string; }; repo: string; root: string; state: string; }' to type 'SharedLibsFixture' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Type '{ env: { GSTACK_HOME: string; }; repo: string; root: string; state: string; }' is missing the following properties from type 'SharedLibsFixture': bin, trace, hookTrace, tip": 1,
"test/helpers/shared-libs-plan-actor.ts\tTS18046\t'questions' is of type 'unknown'.": 1,
@@ -431,7 +432,6 @@
"test/setup-gbrain-path4-caller.test.ts\tTS2339\tProperty 'unregistered' does not exist on type '{ 'no-init': number; 'no-install': number; 'no-registration': number; 'no-verifier': number; }'.": 1,
"test/setup-gbrain-path4-caller.test.ts\tTS2339\tProperty 'wrong-engine' does not exist on type '{ 'no-init': number; 'no-install': number; 'no-registration': number; 'no-verifier': number; }'.": 1,
"test/setup-gbrain-remote-caller.test.ts\tTS18048\t'lateDecision' is possibly 'undefined'.": 1,
"test/shared-libs-checker-interface-evidence.test.ts\tTS18048\t'current' is possibly 'undefined'.": 1,
"test/shared-libs-fixture.test.ts\tTS18046\t'packet' is of type 'unknown'.": 4,
"test/shared-libs-fixture.test.ts\tTS2345\tArgument of type '(options: { label: string; } | { description: string; label: string; } | { description: string; label: string; }) => Promise<void>' is not assignable to parameter of type '(...args: [{ label: string; } | { description: string; label: string; } | { description: string; label: string; }, done: (err?: unknown) => void] | [{ label: string; } | { description: string; label: string; } | { ...; }, { ...; } | undefined, done: (err?: unknown) => void]) => void | Promise<...>'. Types of parameters 'options' and 'args' are incompatible. Type '[{ label: string; } | { description: string; label: string; } | { description: string; label: string; }, done: (err?: unknown) => void] | [{ label: string; } | { description: string; label: string; } | { description: string; label: string; }, { ...; } | undefined, done: (err?: unknown) => void]' is not assignable to type '[options: { label: string; } | { description: string; label: string; } | { description: string; label: string; }]'. Type '[{ label: string; } | { description: string; label: string; } | { description: string; label: string; }, done: (err?: unknown) => void]' is not assignable to type '[options: { label: string; } | { description: string; label: string; } | { description: string; label: string; }]'. Source has 2 element(s) but target allows only 1.": 1,
"test/shared-libs-fixture.test.ts\tTS2345\tArgument of type '(options: { label: string; } | { description: string; label: string; } | { label: string; preview: string; } | { description: string; label: string; preview: string; } | { label: string; } | { description: string; label: string; } | { ...; }) => Promise<...>' is not assignable to parameter of type '(...args: [{ label: string; } | { description: string; label: string; } | { label: string; preview: string; } | { description: string; label: string; preview: string; } | { label: string; } | { description: string; label: string; } | { ...; }, done: (err?: unknown) => void] | [...]) => void | Promise<...>'. Types of parameters 'options' and 'args' are incompatible. Type '[{ label: string; } | { description: string; label: string; } | { label: string; preview: string; } | { description: string; label: string; preview: string; } | { label: string; } | { description: string; label: string; } | { ...; }, done: (err?: unknown) => void] | [...]' is not assignable to type '[options: { label: string; } | { description: string; label: string; } | { label: string; preview: string; } | { description: string; label: string; preview: string; } | { label: string; } | { description: string; label: string; } | { ...; }]'. Type '[{ label: string; } | { description: string; label: string; } | { label: string; preview: string; } | { description: string; label: string; preview: string; } | { label: string; } | { description: string; label: string; } | { ...; }, done: (err?: unknown) => void]' is not assignable to type '[options: { label: string; } | { description: string; label: string; } | { label: string; preview: string; } | { description: string; label: string; preview: string; } | { label: string; } | { description: string; label: string; } | { ...; }]'. Source has 2 element(s) but target allows only 1.": 1,
+49 -12
View File
@@ -740,6 +740,7 @@ run_tiers() {
vm_rows() {
local loc name owner created expires
cli vm list -N -f location,name | while read -r loc name; do
[ -n "$name" ] || continue
owner=. created="" expires=-
if [[ "$name" =~ ^${PREFIX}-([a-z][a-z0-9]{0,11})-([0-9]{10})- ]]; then
owner=${BASH_REMATCH[1]} created=${BASH_REMATCH[2]}
@@ -804,21 +805,39 @@ cmd_list() {
}
# usage: VMs and vCPUs per owner, from each VM's size (standard-16 = 16 vCPUs).
# Exits 1 with no table when the VM list or any VM's size cannot be read, so a
# partial count is never taken for the project's usage. A VM destroyed between
# the list and its size read (404) no longer counts and is not a failure.
cmd_usage() {
local tmp loc name owner i=0
local tmp rows loc name owner f i=0 unread=0
rows=$(vm_rows) || { log "usage: could not list the project's VMs"; return 1; }
tmp=$(mktemp -d)
while read -r loc name owner _; do
[ -n "$name" ] || continue
i=$(( i + 1 ))
cli vm "$loc/$name" show 2>/dev/null | sed -n "s/^size: /$owner /p" >"$tmp/$i" &
{
case $(api "$tmp/$i.show" vm "$loc/$name" show) in
200) sed -n "s/^size: /$owner /p" "$tmp/$i.show" >"$tmp/$i" ;;
404) ;;
*) : >"$tmp/$i" ;;
esac
rm -f "$tmp/$i.show"
} &
[ $(( i % 8 )) -ne 0 ] || wait
done < <(vm_rows)
done <<<"$rows"
wait
cat "$tmp"/* 2>/dev/null | awk -v me="$OWNER" '
for f in "$tmp"/*; do [ ! -e "$f" ] || [ -s "$f" ] || unread=$(( unread + 1 )); done
if [ "$unread" -gt 0 ]; then
rm -rf "$tmp"
log "usage: could not read the size of $unread of $i VMs"
return 1
fi
find "$tmp" -type f -exec cat {} + | awk -v me="$OWNER" '
{ n = $2; sub(/^.*-/, "", n); o = $1 == "-" ? "(untagged)" : $1 == "." ? "(other)" : $1
vms[o]++; cpu[o] += n; total += n; count++ }
END {
printf "%-14s %4s %6s\n", "OWNER", "VMS", "VCPUS"
for (o in cpu) printf "%-14s %4d %6d%s\n", o, vms[o], cpu[o], o == me ? " (you)" : "" | "sort -k3,3nr"
for (o in cpu) printf "%-14s %4d %6d%s\n", o, vms[o], cpu[o], (o == me ? " (you)" : "") | "sort -k3,3nr"
close("sort -k3,3nr")
printf "%-14s %4d %6d\n", "total", count, total
}'
@@ -881,8 +900,17 @@ quota_preflight() {
# between them: a slow first usage read must not shave the printed budget.
remaining=$QUOTA_WAIT
while :; do
used=$(cmd_usage 2>/dev/null | awk '$1=="total" {print $3}')
[[ "$used" =~ ^[0-9]+$ ]] || return 0
if ! used=$(cmd_usage 2>/dev/null | awk '$1=="total" {print $3}') || ! [[ "$used" =~ ^[0-9]+$ ]]; then
if [ "$remaining" -le 0 ]; then
log "quota preflight: could not read the project's vCPU usage; the API's own answer decides"
return 0
fi
log "quota: could not read the project's vCPU usage; retrying, up to ${remaining}s more"
step=$(( remaining < 30 ? remaining : 30 ))
sleep "$step"
remaining=$(( remaining - step ))
continue
fi
[ $(( used + need )) -gt "$max" ] || return 0
if [ "$remaining" -le 0 ]; then
log "quota preflight refused $location/$name ($size): it needs $need vCPUs and the project's VMs already use $used of $max (ceiling from $source; other usage such as managed runners counts on top)"
@@ -1074,7 +1102,12 @@ cmd_down() {
# pointer into the main repository, useless on the VM; the same snapshot
# replaces it, on the worktree's own branch and index. Not carried: submodule
# repositories (.git/modules) and a partial clone's promisor (the fetch pulls
# every missing blob first); gstack has neither.
# every missing blob first); gstack has neither. The archive is built in two
# passes inside the snapshot directory because macOS's bsdtar stops reading
# options at the first operand and resolves -T names after a -C, so no single
# tar command line works on both it and GNU tar (#3680). COPYFILE_DISABLE=1
# keeps Apple's tar from adding ._* AppleDouble entries for files with
# extended attributes, which the VM would see as untracked files.
cmd_pack() {
local src tmp gitdir branch head entry
src=$(cd "${1:-.}" && pwd)
@@ -1105,9 +1138,10 @@ cmd_pack() {
# index's blobs that HEAD's tree lacks (staged, intent-to-add, the stages
# of an unmerged entry) are behind no ref, so they travel as a pack of
# their own; without them the VM's write-tree (gstack-wtree) cannot read a
# staged file.
# staged file. cat, not cp: git renames index.lock over the index, which
# cp reports as "replaced while being copied"; one open() reads one whole index.
if [ "$(git -C "$src" config --bool core.splitIndex 2>/dev/null)" != true ]; then
cp "$(cd "$src" && cd "$(dirname "$(git rev-parse --git-path index)")" && pwd)/index" "$tmp/.git/index"
cat "$(cd "$src" && cd "$(dirname "$(git rev-parse --git-path index)")" && pwd)/index" > "$tmp/.git/index"
# Read the copy, not the live index, so the pack matches it; a gitlink's
# sha (mode 160000) is a submodule commit this object store lacks.
GIT_INDEX_FILE="$tmp/.git/index" git -C "$src" ls-files -s | awk '$1 != "160000" {print $2}' | sort -u \
@@ -1118,15 +1152,18 @@ cmd_pack() {
fi
(
cd "$src"
export COPYFILE_DISABLE=1
git ls-files -z -co --exclude-standard \
| while IFS= read -r -d '' f; do
if [ -e "$f" ] || [ -L "$f" ]; then printf '%s\0' "$f"; fi
done \
| tar --null -T - -c -C "$tmp" .git -zf -
| tar --null -T - -cf "$tmp/files.tar" \
&& tar -rf "$tmp/files.tar" -C "$tmp" .git \
&& gzip -c "$tmp/files.tar"
)
rm -rf "$tmp"; trap - EXIT
else
tar -C "$src" -czf - .
COPYFILE_DISABLE=1 tar -C "$src" -czf - .
fi
}