mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
Merge main dd9834d44d (v1.92.61.0) into gsta203-pr3-subshards (rule 19 grant, GSTA-203 PR 3)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # .github/workflows/free-tests.yml # docs/TESTING_INTERNALS.md # scripts/harness-version.json # scripts/lib/shard-jobs.ts # scripts/test-free-shards.ts # test/shard-jobs.test.ts # test/windows-native-workflows.test.ts
This commit is contained in:
commit
f02e95e158
566 files changed
+36561
-8385
No files matched your search
Executable
+157
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export type CsoRuntimeBuildStack = typeof CSO_RUNTIME_STACKS[number];
|
||||
|
||||
const IMAGE = /^[a-z0-9.-]+(?::[0-9]+)?\/[a-z0-9][a-z0-9._/-]*@sha256:[a-f0-9]{64}$/;
|
||||
const TAGGED_IMAGE = /^[a-z0-9.-]+(?::[0-9]+)?\/[a-z0-9][a-z0-9._/-]*:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const RELEASE_TAG = /:v?\d+\.\d+\.\d+(?:[._-][A-Za-z0-9._-]*)?$/;
|
||||
const TWO_PART_RELEASE_TAG = /:v?\d+\.\d+(?:\.\d+)?(?:[._-][A-Za-z0-9._-]*)?$/;
|
||||
const REVISION = /^[a-z0-9][a-z0-9._-]{0,100}$/;
|
||||
const REQUIRED_VERSIONS: Record<CsoRuntimeBuildStack, string[]> = {
|
||||
node: ['node', 'npm', 'cso-preparation'],
|
||||
@@ -56,8 +58,9 @@ function repositoryOfTag(image: string): string {
|
||||
return image.slice(0, colon);
|
||||
}
|
||||
|
||||
function assertPinnedFamily(source: unknown, indexImage: unknown, images: unknown, label: string): Record<CsoRuntimeBuildPlatform, string> {
|
||||
function assertPinnedFamily(source: unknown, indexImage: unknown, images: unknown, label: string, releaseTag = RELEASE_TAG): Record<CsoRuntimeBuildPlatform, string> {
|
||||
if (typeof source !== 'string' || !TAGGED_IMAGE.test(source)) throw new Error(`INVALID_${label}_SOURCE`);
|
||||
if (!releaseTag.test(source)) throw new Error(`FLOATING_${label}_SOURCE: ${source} must name an exact release version, not a moving major, minor or alias tag`);
|
||||
if (typeof indexImage !== 'string' || !IMAGE.test(indexImage)) throw new Error(`UNPINNED_${label}_INDEX`);
|
||||
if (!record(images)) throw new Error(`MISSING_${label}_IMAGES`);
|
||||
const repository = repositoryOfTag(source);
|
||||
@@ -101,7 +104,7 @@ export function imageBuildMatrix(input: unknown): { include: ImageBuildRow[] } {
|
||||
if (typeof stack !== 'string' || !CSO_RUNTIME_STACKS.includes(stack as CsoRuntimeBuildStack) || seenStacks.has(stack)) throw new Error('INVALID_STACK');
|
||||
seenStacks.add(stack);
|
||||
const typedStack = stack as CsoRuntimeBuildStack;
|
||||
const bases = assertPinnedFamily(source, indexImage, baseImages, 'BASE');
|
||||
const bases = assertPinnedFamily(source, indexImage, baseImages, 'BASE', typedStack === 'postgresql' ? TWO_PART_RELEASE_TAG : RELEASE_TAG);
|
||||
if (!record(versions)) throw new Error('MISSING_TOOL_VERSIONS');
|
||||
const exactVersion = (value: unknown) => typeof value === 'string' &&
|
||||
(typedStack === 'postgresql'
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+1446
-1260
File diff suppressed because it is too large.
Load diff
@@ -17,7 +17,7 @@
|
||||
"scripts/lib/published-text.ts": {"blob":"3f2e4d616b5dd0204035710c66b64d49055c5714","since":6},
|
||||
"scripts/lib/shard-engine.ts": {"blob":"dc714d7d897aed185c0ec3d2c92ad22d954039e5","since":6,"nonBehavioral":"createShardSandbox drops the Capy launch markers (GSTACK_SESSION_KIND, GSTACK_CAPY, GSTACK_EPHEMERAL, GSTACK_INSTALL_VERDICT) from shard environments; CI never sets them, so no case's input or verdict changes there, and on a Capy machine fixtures now see the session kind they set themselves instead of the installer's"},
|
||||
"scripts/test-paid-shards.ts": {"blob":"5439d0a8f6e1569fa7c924af20d67c30c81d5d30","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"},
|
||||
"scripts/test-pr-profile.ts": {"blob":"594f9764f98add6e5e215b3a2d5f5067eb5c9e0f","since":6,"nonBehavioral":"FREE_ONLY_PR_FILES gains scripts/lib/shard-jobs.ts, a free-runner module; it changes which PR paths select paid cases, never how a case runs or is judged"},
|
||||
"scripts/test-pr-profile.ts": {"blob":"44ae88bebf8f716f70a3bd836c8bedb03e6653e8","since":6,"nonBehavioral":"FREE_ONLY_PR_FILES gains scripts/lib/shard-jobs.ts (free-runner module) and .github/workflows/windows-smoke.yml (a workflow no paid case reads); both change only which PR paths select paid cases, never how a case runs or is judged"},
|
||||
"scripts/test-strict-output.ts": {"blob":"01185d20dc9761b04b9b2f751ec98664fe0eaf93","since":6},
|
||||
"test/helpers/agent-sdk-runner.ts": {"blob":"210bba92d241a90df578b2c7fc943ac5755d0025","since":6},
|
||||
"test/helpers/auq-substance-panel.ts": {"blob":"4881260ef793668e4963a062db6d06a5e1f7a40f","since":6},
|
||||
@@ -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"},
|
||||
|
||||
@@ -322,9 +322,16 @@ function flag(args: string[], name: string): string | undefined {
|
||||
return index >= 0 ? args[index + 1] : undefined;
|
||||
}
|
||||
|
||||
async function main(args: string[]): Promise<void> {
|
||||
/** The W2 per-judge table plus the arm benchmark's paired judge (run by --config only; the manifest prices the W2 table). */
|
||||
export async function loadCalibrationConfigs(): Promise<{ w2: CalibrationConfig[]; all: CalibrationConfig[] }> {
|
||||
// The per-judge table (builders, schemas, verdict rules) is committed separately from this harness.
|
||||
const { CALIBRATION_CONFIGS } = await import(path.join(import.meta.dir, 'judge-calibration-configs.ts')) as { CALIBRATION_CONFIGS: CalibrationConfig[] };
|
||||
const { ARM_PAIR_CALIBRATION } = await import(path.join(import.meta.dir, '..', 'test', 'helpers', 'arm-pair-judge.ts')) as { ARM_PAIR_CALIBRATION: CalibrationConfig };
|
||||
return { w2: CALIBRATION_CONFIGS, all: [...CALIBRATION_CONFIGS, ARM_PAIR_CALIBRATION] };
|
||||
}
|
||||
|
||||
async function main(args: string[]): Promise<void> {
|
||||
const { w2: CALIBRATION_CONFIGS, all: configs } = await loadCalibrationConfigs();
|
||||
const command = args[0];
|
||||
if (command === 'manifest') {
|
||||
const client = new Anthropic();
|
||||
@@ -338,8 +345,8 @@ async function main(args: string[]): Promise<void> {
|
||||
console.table(manifest.entries);
|
||||
return;
|
||||
}
|
||||
const config = CALIBRATION_CONFIGS.find(candidate => candidate.id === flag(args, 'config'));
|
||||
if (!config) throw new Error(`unknown --config; expected one of ${CALIBRATION_CONFIGS.map(candidate => candidate.id).join(', ')}`);
|
||||
const config = configs.find(candidate => candidate.id === flag(args, 'config'));
|
||||
if (!config) throw new Error(`unknown --config; expected one of ${configs.map(candidate => candidate.id).join(', ')}`);
|
||||
const items = loadCorpus(config.id);
|
||||
const runsDir = path.join(CORPUS_ROOT, config.id, 'runs');
|
||||
const samplesPath = path.join(runsDir, 'comparison-1.samples.jsonl');
|
||||
|
||||
@@ -259,7 +259,38 @@ export function readFlakeLedgerDir(dir: string): FlakeLedgerEntry[] {
|
||||
export const FLAKE_POLICY = { maxRate: 0.05, minRuns: 20 } as const;
|
||||
|
||||
export interface FlakyFile { file: string; flakyRuns: number; entries: number; rate: number; enforced: boolean; failing: boolean }
|
||||
export interface FlakeWindow { runs: number; flakyRuns: number; since: string; files: FlakyFile[]; enforceable: boolean }
|
||||
/** `skipped`: reused main pushes left out of the window (reuseChecker); `note`: runs that could not be checked. */
|
||||
export interface FlakeWindow { runs: number; flakyRuns: number; since: string; files: FlakyFile[]; enforceable: boolean; skipped?: number; note?: string }
|
||||
|
||||
/** The most job listings one reuseChecker spends. */
|
||||
export const REUSE_LOOKUPS_MAX = 40;
|
||||
|
||||
/**
|
||||
* Finds runs that reused their merged pull request's verified run (#3709): such
|
||||
* a run uploads no artifact and its `reuse` job succeeds, because every suite
|
||||
* job was skipped. It holds no flake evidence, so flake counts leave it out of
|
||||
* their runs and their denominator (#3725). A run with any artifact costs no
|
||||
* call. A run with none costs one job listing, at most REUSE_LOOKUPS_MAX per
|
||||
* checker; past the cap, or when the listing fails, the run counts as it did
|
||||
* before #3725 and note() says so.
|
||||
*/
|
||||
export function reuseChecker(client: GhClient, repo: string) {
|
||||
let lookups = 0;
|
||||
let unchecked = 0;
|
||||
let reason = '';
|
||||
return {
|
||||
reused(run: Pick<CiRun, 'id' | 'attempt'>, artifactNames: string[]): boolean {
|
||||
if (artifactNames.length) return false;
|
||||
if (lookups >= REUSE_LOOKUPS_MAX) { unchecked++; reason ||= `cap of ${REUSE_LOOKUPS_MAX} job listings reached`; return false; }
|
||||
lookups++;
|
||||
try {
|
||||
return listRunJobs(client, repo, run.id, run.attempt).some(job => job.name === 'reuse' && job.conclusion === 'success');
|
||||
} catch (error) { unchecked++; reason ||= unavailableReason(error); return false; }
|
||||
},
|
||||
note: (): string | undefined => (unchecked
|
||||
? `${unchecked} run(s) with no artifacts not checked for main-push reuse (${reason}); counted as before #3725` : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank files by the share of runs in which they needed the retry pass. A file
|
||||
@@ -286,9 +317,10 @@ export function rankFlakyFiles(runEntries: Map<number, FlakeLedgerEntry[]>, runC
|
||||
/**
|
||||
* Read the flake ledgers of main's completed free-tests runs: the runs created
|
||||
* in the last `sinceDays`, widened to the newest `minRuns` runs (within
|
||||
* `maxLookbackDays`) so the 5% rule has a window of at least 20 runs. A run
|
||||
* with no `flake-ledger-*` artifact had no flaky passes (the upload is skipped
|
||||
* when the ledger file does not exist).
|
||||
* `maxLookbackDays`) so the 5% rule has a window of at least 20 runs. Reused
|
||||
* main pushes (reuseChecker) are left out and older runs take their place. A
|
||||
* run with no `flake-ledger-*` artifact had no flaky passes (the upload is
|
||||
* skipped when the ledger file does not exist).
|
||||
*/
|
||||
export function readCiFlakeLedgers(opts: { client: GhClient; repo: string; sinceDays: number; now: number; cacheDir: string;
|
||||
minRuns?: number; maxLookbackDays?: number; workflow?: string }): FlakeWindow {
|
||||
@@ -297,16 +329,25 @@ export function readCiFlakeLedgers(opts: { client: GhClient; repo: string; since
|
||||
const all = listWorkflowRuns(opts.client, opts.repo, opts.workflow ?? 'free-tests.yml',
|
||||
{ since: iso(Math.max(opts.sinceDays, opts.maxLookbackDays ?? 60)), branch: 'main' })
|
||||
.filter(run => run.conclusion === 'success' || run.conclusion === 'failure');
|
||||
const recent = all.filter(run => run.createdAt >= iso(opts.sinceDays));
|
||||
const window = recent.length >= minRuns ? recent : all.slice(0, minRuns);
|
||||
const since = iso(opts.sinceDays);
|
||||
const recent = all.filter(run => run.createdAt >= since).length;
|
||||
const fetcher = historyFetcher(opts.client);
|
||||
const reuse = reuseChecker(opts.client, opts.repo);
|
||||
const window: CiRun[] = [];
|
||||
let skipped = 0;
|
||||
for (const [index, run] of all.entries()) {
|
||||
if (index >= recent && window.length >= minRuns) break;
|
||||
if (reuse.reused(run, fetcher.listArtifacts(opts.repo, run.id).map(artifact => artifact.name))) skipped++;
|
||||
else window.push(run);
|
||||
}
|
||||
const runEntries = new Map<number, FlakeLedgerEntry[]>();
|
||||
for (const run of window) {
|
||||
const dirs = downloadRunArtifacts({ repo: opts.repo, run, cacheDir: opts.cacheDir, fetcher, match: name => name.startsWith('flake-ledger-') });
|
||||
const entries = dirs.flatMap(readFlakeLedgerDir);
|
||||
if (entries.length) runEntries.set(run.id, entries);
|
||||
}
|
||||
return rankFlakyFiles(runEntries, window.length, window[window.length - 1]?.createdAt ?? iso(opts.sinceDays));
|
||||
const note = reuse.note();
|
||||
return { ...rankFlakyFiles(runEntries, window.length, window[window.length - 1]?.createdAt ?? since), skipped, ...(note ? { note } : {}) };
|
||||
}
|
||||
|
||||
/** Markdown table of one run's merged flake ledgers, for a job summary. */
|
||||
|
||||
@@ -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',
|
||||
@@ -295,18 +315,63 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
|
||||
file: 'test/ship-queue-train-land.test.ts',
|
||||
reason: 'drives train land against test/helpers/ship-queue-train-fixture.ts plus an extensionless node gh wrapper on a colon-joined PATH; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead, as for test/ship-queue-train.test.ts and test/ship-queue-land.test.ts (windows-free-shard 6, run 40824965988)',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-queue-land-lock.test.ts',
|
||||
reason: 'drives land and train land against test/helpers/ship-queue-train-fixture.ts plus an extensionless node gh wrapper on a colon-joined PATH; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead, as for test/ship-queue-train-land.test.ts and test/ship-queue-land.test.ts (windows-free-shard 6, run 40824965988)',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-queue-train-generic.test.ts',
|
||||
reason: 'drives bin/gstack-ship-queue train build, verdict, land, watch, init and doctor against the same #!/bin/sh gh shim plus an extensionless node gh wrapper on a colon-joined PATH; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead (windows-free-shard 1, run 41218228855)',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-policy-compat.test.ts',
|
||||
reason: 'spawns bin/gstack-ship-policy, bin/gstack-restamp and bin/gstack-tree-receipt (shebangs) through test/helpers/restamp-fixture.ts',
|
||||
@@ -323,6 +388,10 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
|
||||
file: 'test/ship-release.test.ts',
|
||||
reason: 'spawns bin/gstack-ship-release (shebang) directly and writes #!/bin/sh gh stubs through test/helpers/ship-bin-fixture.ts; windows-latest exits before the bin runs (windows-free-shard 3, run 41073592483)',
|
||||
},
|
||||
{
|
||||
file: 'test/ship-rereview-triggers.test.ts',
|
||||
reason: 'drives bin/gstack-ship-release, gstack-docs-candidate and gstack-wtree (shebangs) through test/helpers/ship-bin-fixture.ts like test/ship-release.test.ts; windows-latest fails every bin case at the first spawn (windows-free-shard 6, run 41193336209)',
|
||||
},
|
||||
];
|
||||
|
||||
// Force-include overrides: files a WINDOWS_FRAGILE_PATTERNS regex excludes for
|
||||
@@ -337,16 +406,22 @@ export const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [
|
||||
{ file: 'test/cso-windows-docker.test.ts', reason: '/cso docker.exe discovery under known-folder roots (real path, no reparse points) and the native-transport-unsupported outcome; pure win32 path logic' },
|
||||
{ file: 'test/copilot-windows-bash.test.ts', reason: 'the Copilot glossary line that runs bash blocks in Git for Windows Bash; reads host config only' },
|
||||
{ file: 'test/ship-hook-windows-paths.test.ts', reason: 'runs bin/ helpers through explicit bash and Bun argv with forward-slash paths; never executes a shebang; the path-spelling simulation is skipIf win32' },
|
||||
{ file: 'test/review-scope-windows-launch.test.ts', reason: '#3541: runs gstack-review-scope through lib/script-invocation.ts, which must reach the bash gstack-review-read through Git Bash (red at main: windows-free-shard 3, run 41228091417); os.tmpdir state only' },
|
||||
{ file: 'test/jsonl-merge-encoding.test.ts', reason: '#3659: runs gstack-jsonl-merge through lib/script-invocation.ts (Git Bash on win32); the CRLF half of the bug only reproduces with a Windows Python' },
|
||||
{ file: 'test/gstack-wtree-windows-paths.test.ts', reason: '#3101: runs bin/gstack-wtree through explicit bash in an os.tmpdir linked worktree with forward-slash paths; never executes a shebang; the drive-letter simulation is skipIf win32' },
|
||||
{ file: 'test/setup-hook-syntax-gate.test.ts', reason: '#3088/#3100: runs bin/gstack-hook-check and the setup hook sections through explicit bash with forward-slash paths; never executes a shebang. Its Bun drive-path spelling is the Windows bug it guards' },
|
||||
{ file: 'test/bun-config-shell-bins.test.ts', reason: '#3415: runs the record bins through lib/script-invocation.ts (Git Bash on Windows) with MSYS path conversion off, the case it guards; its bun and cygpath stand-ins and simulated-OSTYPE cases are skipIf win32' },
|
||||
{ file: 'test/ship-receipt-session-kind.test.ts', reason: '#3541: runs gstack-ship-receipt write through lib/script-invocation.ts, which must reach the bash gstack-session-kind through Git Bash; os.tmpdir git fixture only' },
|
||||
{ file: 'test/path-spelling.test.ts', reason: 'sources bin/gstack-path-spelling.sh through explicit bash and checks lib/path-spelling.ts; on Windows it exercises the real Git Bash spellings' },
|
||||
{ file: 'test/timeline-stop-hook-stdin.test.ts', reason: '#3628: the hook runs through Bun argv with a stdin pipe held open; the return-within-budget timing must meet real Windows process startup' },
|
||||
{ file: 'test/state-root-parity.test.ts', reason: 'runs the bash twin and lib/state-root.ts over an env table with PATH empty; no shebang execution, raw-string comparison is platform-neutral' },
|
||||
{ file: 'test/generator-eexist.test.ts',
|
||||
reason: 'E4: runs the generators through Bun argv with the Windows EEXIST emulation preload; no shebang execution' },
|
||||
{ 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' },
|
||||
|
||||
@@ -216,7 +216,11 @@
|
||||
"test/skill-e2e-triage.test.ts": 1000,
|
||||
"test/skill-e2e-workflow.test.ts": 121594,
|
||||
"test/skill-llm-eval.test.ts": 339704,
|
||||
"test/skill-routing-e2e.test.ts": 81360
|
||||
"test/skill-routing-e2e.test.ts": 81360,
|
||||
"test/skill-e2e-planning-wave.test.ts#planning-wave-planted-facts": 74000,
|
||||
"test/skill-e2e-planning-wave.test.ts#planning-wave-schema": 52000,
|
||||
"test/skill-e2e-planning-wave.test.ts#planning-wave-tasks-rerun": 37000,
|
||||
"test/skill-e2e-planning-wave.test.ts#planning-wave-closing-signals": 27000
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export const SELECTION_DATA_MODULES: readonly string[] = [
|
||||
// The build entry list: every compiled binary, which skills reach through their `--outfile` path instead.
|
||||
'scripts/build.sh',
|
||||
// The reason-code table: its `fix:` prose names the command a person reruns, not a file the module consumes.
|
||||
'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts',
|
||||
'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts',
|
||||
// The exercised-path map: observed commands per case, read only by exercisedConsumers() (shadow data; a change selects no paid case).
|
||||
'test/helpers/exercised-commands.json',
|
||||
];
|
||||
|
||||
@@ -17,17 +17,17 @@ import type { ResolverFn } from './types';
|
||||
|
||||
/** One entry per command line the loop runs, as the field-wave plan specifies them. */
|
||||
export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [
|
||||
{ cmd: 'gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--scaffold]', does: `first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default ${DEFAULT_MAX_ROUNDS} unattended), and an unattended Capy run reserves ${DEFAULT_HOST_ESTIMATE_USD.toFixed(2)} per host attempt unless --estimate-usd <n> says otherwise, so --spend-cap binds` },
|
||||
{ cmd: 'gstack-autoplan next --out <dir> --plan <plan.md> [scope flags] [--light] [--spend-cap <usd>] [--max-rounds <n>] [--estimate-usd <n>] [--scaffold] [--ceo-only]', does: `first call: init the run, print PHASE:, SCOPE:, DRIVER: and one ATTEMPT: per voice; --max-rounds caps review rounds per phase (default ${DEFAULT_MAX_ROUNDS} unattended); a run with --spend-cap, in any session kind (or any unattended Capy run), reserves ${DEFAULT_HOST_ESTIMATE_USD.toFixed(2)} per voice for each attempt, so the cap binds; --estimate-usd <n> sets that reservation and the run keeps it for later phases; --ceo-only (command line only, never a scope marker) reviews CEO alone and ends \`status=reviewed\` with \`<dir>/ceo-review.md\` (\`REVIEWED:\`): no gate, and the gate verbs refuse it (\`CEO_ONLY_NO_GATE\`)` },
|
||||
{ cmd: 'gstack-autoplan run --out <dir> --plan <plan.md> --native-model <id> --outside-model <id> --native-runner api --runner api [--max-tokens <n>] [--spend-cap <usd>]', does: 'hosts without subagents: one command calls both models directly (ANTHROPIC_API_KEY, OPENAI_API_KEY) and loops next/submit to the gate; a response cut off at the output limit is reported truncated, so raise --max-tokens (default 16384 for both providers; OpenAI counts reasoning tokens toward it); each api call reserves its priced worst case (a model with no price reserves the per-voice default under a cap), a native call that went out but bound no result settles at its usage, or its full reservation when the usage is unknown, and a failed outside call stays counted at its reservation' },
|
||||
{ cmd: 'gstack-autoplan next --out <dir> [--redispatch <attempt>]', does: 'later calls: open the next phase or reprint open attempts; never redispatches on its own' },
|
||||
{ cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --record <file.md> --model <id> [--attempt <id>]', does: 'bind a free-form review record (canonical fence, one findings table or list, or `No findings.`)' },
|
||||
{ cmd: 'gstack-autoplan submit ... --record <file.md> --attest "<reason>" --attempt <id>', does: 'bind a record with no INPUT: line as receipt: attested (a wrong INPUT: always refuses)' },
|
||||
{ cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id>', does: 'bind a canonical result (the RESULT FORMAT below)' },
|
||||
{ cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice native|outside --result <file> --model <id> --attempt <id> [--usage-usd <n>] [--reconcile <jsonl>]', does: 'bind a canonical result (the RESULT FORMAT below); `--usage-usd` settles the attempt at what the reviewer actually cost (else it stays priced unknown at its reservation); on the submit that closes the phase, `--reconcile` takes your own pairing of findings both voices raised, one `{"outside_id","native_id","disposition","resolution"}` row each, over the structural one' },
|
||||
{ cmd: 'gstack-autoplan submit --out <dir> --phase <p> --voice outside --unavailable|--failed "<reason>"', does: 'close the phase native-only when no outside voice can run' },
|
||||
{ cmd: 'gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>" --by human|agent:<id> [--owner-reply <text>|-] [--rec-hash <h>]', does: 'answer the gate with the reply grammar below; an agent answer stores the owner\'s words verbatim' },
|
||||
{ cmd: 'gstack-autoplan answer --out <dir> --gate-rev <n> --reply "<reply>" --by human|agent:<id> [--owner-reply <text>|-] [--words-by agent:<id>] [--rec-hash <h>]', does: 'answer the gate with the reply grammar below; an agent answer stores the owner\'s words verbatim, and passes --words-by agent:<id> whenever the words are its own (the default is owner)' },
|
||||
{ cmd: 'gstack-autoplan recommend --out <dir> --gate-rev <n> --item <id> --option <k> --basis "<why>"', does: 'change one recommendation before relay; then render and relay again (a `rec` needs the new `--rec-hash`)' },
|
||||
{ cmd: 'gstack-autoplan revise --out <dir> --plan <plan v2> --incorporates <decision ids>', does: 'bind the amended plan; reopens every phase whose reviewed plan changed, Eng last' },
|
||||
{ cmd: 'gstack-autoplan revise --out <dir> --settle <decision id|d4>', does: 'discharge a packaging or ordering change with no plan edit; reopens nothing' },
|
||||
{ cmd: 'gstack-autoplan revise --out <dir> --withdraw <decision id>', does: 'drop a pending amendment instead of applying it' },
|
||||
{ cmd: 'gstack-autoplan revise --out <dir> --settle <decision id|d4> | --withdraw <decision id>', does: '--settle discharges a packaging or ordering change with no plan edit and reopens nothing; --withdraw drops a pending amendment instead of applying it' },
|
||||
{ cmd: 'gstack-autoplan close --out <dir> --phase <p> --deferred "<reason>" --by human|agent:<id>', does: 'close a phase accepted-with-findings: its open findings go to tasks.jsonl, and Critical, urgent or user-challenge findings become gate items; also the recovery from `STOP: reason=budget_exhausted`' },
|
||||
{ cmd: 'gstack-autoplan budget --out <dir> [--spend-cap <usd>] [--max-rounds <n>] --by human|agent:<id>', does: 'the owner\'s journaled cap raise; after a budget stop, `resume` continues the run' },
|
||||
{ cmd: 'gstack-autoplan export --out <dir>', does: 'write the artifact set; then `gstack-artifact validate <dir>/run.json`' },
|
||||
@@ -40,7 +40,7 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [
|
||||
export const GATE_REPLY_FORMS: Array<{ reply: string; means: string }> = [
|
||||
{ reply: 'rec', means: 'every item takes its recommendation, plan changes included (each prints APPLY:); `all` and `do it` are aliases' },
|
||||
{ reply: 'as-is', means: 'keep every direction; no plan change' },
|
||||
{ reply: 'd3b uc1a', means: 'one <id><option> token per item you set; unanswered auto items take their default' },
|
||||
{ reply: 'd3b uc1a', means: 'one <id><option> token per item you set; unanswered auto items take their default (a finding item keeps the plan as written; `p1b` applies it)' },
|
||||
{ reply: 'rec except d3b', means: 'recommendations, with the listed overrides' },
|
||||
];
|
||||
|
||||
|
||||
@@ -593,11 +593,10 @@ Show the screenshot to the user. Ask: "Does this feel right? Want to iterate on
|
||||
If they want changes, regenerate the HTML with their feedback and re-render.
|
||||
If they approve or say "good enough," proceed.
|
||||
|
||||
**Step 5: Include in design doc**
|
||||
**Step 5: Save the sketch**
|
||||
|
||||
Reference the wireframe screenshot in the design doc's "Recommended Approach" section.
|
||||
The screenshot file at \`<sketch-dir>/sketch.png\` (name the full path in the doc) can be referenced by downstream skills
|
||||
(\`/plan-design-review\`, \`/design-review\`) to see what was originally envisioned.
|
||||
Copy the temporary \`sketch.png\` and \`sketch.html\` to \`<PROJECT_DIR>/{user}-{branch}-sketch-{datetime}.*\`.
|
||||
In "Recommended Approach" add \`Wireframe: projects/<slug>/<png name>\` (state-root relative), or \`Wireframe: none (<reason>)\`.
|
||||
|
||||
**Step 6: Outside design voices** (optional)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TemplateContext } from '../types';
|
||||
import { getHostConfig } from '../../../hosts/index';
|
||||
import { runtimeRootPrelude } from '../runtime-root';
|
||||
import { skillStartLines } from '../runtime-root';
|
||||
|
||||
/**
|
||||
* Preamble bootstrap (token-reduction Phase 1).
|
||||
@@ -25,11 +25,10 @@ import { runtimeRootPrelude } from '../runtime-root';
|
||||
*/
|
||||
export function generatePreambleBash(ctx: TemplateContext): string {
|
||||
const hostConfig = getHostConfig(ctx.host);
|
||||
const runtimeRoot = hostConfig.usesEnvVars ? `${runtimeRootPrelude(ctx)}\n` : '';
|
||||
const start = hostConfig.usesEnvVars ? skillStartLines(ctx) : null;
|
||||
const runtimeRoot = start ? `${start.lookup}\n` : '';
|
||||
const brainHealthFlag = ctx.host === 'gbrain' || ctx.host === 'hermes' ? ' --brain-health' : '';
|
||||
const startCommand = hostConfig.usesEnvVars
|
||||
? `"${ctx.paths.binDir}/gstack-skill-start"`
|
||||
: `${ctx.paths.binDir}/gstack-skill-start`;
|
||||
const startCommand = start ? start.command : `${ctx.paths.binDir}/gstack-skill-start`;
|
||||
|
||||
const entry = ['plan-design-review', 'plan-eng-review'].includes(ctx.skillName)
|
||||
? `## Preamble (after scope gate)
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* bash finds gstack on Codex and every other `usesEnvVars` host.
|
||||
*
|
||||
* Those hosts run each fenced block in a fresh shell, so `GSTACK_ROOT`, `B`,
|
||||
* `D` and `P` set by an earlier block are gone. The preamble's root resolution and
|
||||
* the per-fence prelude come from `runtimeRootPrelude()`; `insertRuntimePreludes()`
|
||||
* `D` and `P` set by an earlier block are gone. The preamble's skill-start fence
|
||||
* comes from `skillStartLines()` and the per-fence prelude from
|
||||
* `runtimeRootPrelude()`; `insertRuntimePreludes()`
|
||||
* is the single post-render pass (gen-skill-docs, before rewriteInstallRoot)
|
||||
* that puts it into every fence that uses these variables without assigning
|
||||
* them. Claude output is returned unchanged.
|
||||
@@ -13,6 +14,9 @@
|
||||
* on a per-install render, the literal install root (no git call); otherwise the
|
||||
* repo-local install, then the host's global root (`CODEX_HOME` on Codex). No
|
||||
* root prints one line naming what was tried and the setup command, and exits.
|
||||
* The skill-start fence differs (GST-882): an install-root render calls the
|
||||
* literal install root's start script and ignores an exported `GSTACK_ROOT`, and
|
||||
* with no root either render prints that line and lets the call fail.
|
||||
*/
|
||||
import { getHostConfig } from '../../hosts/index';
|
||||
import { toShellPath, type TemplateContext } from './types';
|
||||
@@ -37,13 +41,45 @@ const uses = (name: string) => new RegExp(`\\$\\{?${name}\\b`);
|
||||
*/
|
||||
function resolveRoot(ctx: TemplateContext): string {
|
||||
const host = getHostConfig(ctx.host);
|
||||
const fix = `Fix: ./setup --host ${host.name} from your gstack checkout; ./setup --status shows it.`;
|
||||
const fix = installFix(ctx);
|
||||
const exported = '[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||';
|
||||
if (ctx.installRoot) {
|
||||
return `${exported}{ GSTACK_ROOT="${ctx.installRoot.replace(/\/+$/, '')}";[ -d "$GSTACK_ROOT/bin" ]||{ echo "gstack: no install found (tried $GSTACK_ROOT). ${fix}">&2;exit 1;};}`;
|
||||
}
|
||||
const global = host.name === 'codex' ? '${CODEX_HOME:-~/.codex}/skills/gstack' : `~/${host.globalRoot}`;
|
||||
return `${exported}{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/${host.localSkillRoot};[ -d "$_r/bin" ]||_r=${global};[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). ${fix}">&2;exit 1;};GSTACK_ROOT=$_r;}`;
|
||||
return `${exported}{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/${host.localSkillRoot};[ -d "$_r/bin" ]||_r=${globalRoot(ctx)};[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). ${fix}">&2;exit 1;};GSTACK_ROOT=$_r;}`;
|
||||
}
|
||||
|
||||
const installFix = (ctx: TemplateContext) => `Fix: ./setup --host ${getHostConfig(ctx.host).name} from your gstack checkout; ./setup --status shows it.`;
|
||||
const globalRoot = (ctx: TemplateContext) => {
|
||||
const host = getHostConfig(ctx.host);
|
||||
return host.name === 'codex' ? '${CODEX_HOME:-~/.codex}/skills/gstack' : `~/${host.globalRoot}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The skill-start fence on env-var hosts (GST-882). Hermes's Tirith blocks a
|
||||
* start command it cannot resolve statically, a grouped fallback or a
|
||||
* variable-selected path alike, and no allowlist entry clears that finding. An
|
||||
* install-root render therefore names the literal start path; a checked-in
|
||||
* render keeps resolveRoot's lookup order as plain lines. Either way a missing
|
||||
* install prints the fix, and the call then fails.
|
||||
*/
|
||||
export function skillStartLines(ctx: TemplateContext): { lookup: string; command: string } {
|
||||
const fix = installFix(ctx);
|
||||
if (ctx.installRoot) {
|
||||
const root = ctx.installRoot.replace(/\/+$/, '');
|
||||
return {
|
||||
lookup: `[ -x "${root}/bin/gstack-skill-start" ]||echo "gstack: no install found (tried ${root}). ${fix}">&2`,
|
||||
command: `"${root}/bin/gstack-skill-start"`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
lookup: [
|
||||
`[ -d "\${GSTACK_ROOT:-/-}/lib" ]&&[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)/${getHostConfig(ctx.host).localSkillRoot}`,
|
||||
`[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=${globalRoot(ctx)}`,
|
||||
`[ -d "$GSTACK_ROOT/bin" ]||echo "gstack: no install found (tried $GSTACK_ROOT). ${fix}">&2`,
|
||||
].join('\n'),
|
||||
command: '"$GSTACK_ROOT/bin/gstack-skill-start"',
|
||||
};
|
||||
}
|
||||
|
||||
const DERIVED: Record<string, string> = { BIN: 'bin', BROWSE: 'browse/dist', DESIGN: 'design/dist', MAKE_PDF: 'make-pdf/dist' };
|
||||
|
||||
@@ -32,6 +32,10 @@ empty JSONL files array. Each task needs a concrete verification step.` : `Befor
|
||||
build-actionable tasks. Each task derives from a specific finding — no padding.`}
|
||||
${conditionalWrites ? `Always emit the markdown section. Write its JSONL artifact for \`/autoplan\` only when the ${storagePolicy} permits it; otherwise label the complete task output not persisted and do not claim an aggregation artifact exists.` : 'Emit the markdown section AND write a JSONL artifact that `/autoplan` can\naggregate across phases.'}
|
||||
|
||||
### Earlier lists on this branch
|
||||
|
||||
Before listing tasks, run \`~/.claude/skills/gstack/bin/gstack-tasks prior --phase ${phase}\` and reconcile what it prints, so a rerun never leaves two lists.
|
||||
|
||||
### Markdown section (always emit)
|
||||
|
||||
\`\`\`markdown
|
||||
@@ -54,49 +58,11 @@ Rules:
|
||||
|
||||
### JSONL artifact (${conditionalWrites ? 'write when permitted, including zero tasks' : 'always write, even if zero tasks'})
|
||||
|
||||
\`/autoplan\` reads this file to aggregate across phases. Build each line with
|
||||
\`jq -nc\` so titles and source findings containing quotes, newlines, or
|
||||
backslashes serialize cleanly — never use hand-rolled \`echo\` / \`printf\`.
|
||||
\`/autoplan\` aggregates this list. With your file-write tool, write one JSON object per line to \`.gstack/tmp/tasks-${phase}-rows.jsonl\` at the repo root (no free text in a command; no rows, no file): a task is \`{"id":"T1","priority":"P1","component":"","title":"","source_finding":"","effort_human":"~2h","effort_cc":"~15min","files":[]}\`, plus \`"carried_from":"<run_id>/<id>"\` when carried; a closed earlier task is \`{"prior":"<run_id>/<id>","disposition":"done|dropped","reason":""}\`. Then write the list (empty means "ran, no findings", distinct from "didn't run"):
|
||||
|
||||
\`\`\`bash
|
||||
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "\${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
|
||||
SLUG=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG 2>/dev/null)
|
||||
TASKS_DIR="$GSTACK_STATE_ROOT/projects/\${SLUG:-unknown}"
|
||||
mkdir -p "$TASKS_DIR"
|
||||
TASKS_FILE="$TASKS_DIR/tasks-${phase}-$(date +%Y%m%d-%H%M%S).jsonl"
|
||||
COMMIT=$(git rev-parse HEAD 2>/dev/null || echo unknown)
|
||||
# The task record keeps the raw git branch; artifact filenames keep gstack-slug's sanitized BRANCH.
|
||||
GIT_BRANCH=$(git branch --show-current 2>/dev/null || echo unknown)
|
||||
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||||
|
||||
# Repeat ONE jq invocation per task identified during this review.
|
||||
# Substitute the placeholders inline with shell variables you set per task:
|
||||
# TASK_ID (T1, T2, ...), PRIORITY (P1/P2/P3), COMPONENT, TITLE,
|
||||
# SOURCE_FINDING, EFFORT_HUMAN, EFFORT_CC, FILES_JSON (a JSON array literal
|
||||
# like '["browse/src/sanitize.ts","browse/src/server.ts"]').
|
||||
jq -nc \\
|
||||
--arg phase '${phase}' \\
|
||||
--arg run_id "$RUN_ID" \\
|
||||
--arg branch "$GIT_BRANCH" \\
|
||||
--arg commit "$COMMIT" \\
|
||||
--arg id "$TASK_ID" \\
|
||||
--arg priority "$PRIORITY" \\
|
||||
--arg component "$COMPONENT" \\
|
||||
--arg effort_human "$EFFORT_HUMAN" \\
|
||||
--arg effort_cc "$EFFORT_CC" \\
|
||||
--arg title "$TITLE" \\
|
||||
--arg source_finding "$SOURCE_FINDING" \\
|
||||
--argjson files "$FILES_JSON" \\
|
||||
'{phase:$phase, run_id:$run_id, branch:$branch, commit:$commit, id:$id, priority:$priority, component:$component, files:$files, effort_human:$effort_human, effort_cc:$effort_cc, title:$title, source_finding:$source_finding}' \\
|
||||
>> "$TASKS_FILE"
|
||||
~/.claude/skills/gstack/bin/gstack-tasks add --phase ${phase}
|
||||
\`\`\`
|
||||
|
||||
If \`jq\` is not installed, fall back to skipping the JSONL write and warn
|
||||
the user to install jq for autoplan aggregation. Never hand-roll JSONL.
|
||||
|
||||
${conditionalWrites ? 'When writes are permitted and zero tasks were identified, touch the JSONL file' : 'If zero tasks were identified in this review, still touch the JSONL file'}
|
||||
(\`: > "$TASKS_FILE"\`) so the aggregator sees that the phase produced output
|
||||
this run (an empty file means "ran, no findings" — distinct from "didn't run").
|
||||
`;
|
||||
};
|
||||
|
||||
|
||||
@@ -128,12 +128,18 @@ export function flakeCheck(window: FlakeWindow | Error): Check {
|
||||
fix: 'Fix the race at source (docs/TESTING_INTERNALS.md#flake-ledger); never raise the timeout or add a retry. Reproduce with `bun test <file>` under load.' };
|
||||
if (window instanceof Error) return { ...base, status: 'unavailable', detail: `unavailable: ${unavailableReason(window)}` };
|
||||
const failing = window.files.filter(file => file.failing);
|
||||
const left = flakeWindowNotes(window);
|
||||
if (!window.enforceable) {
|
||||
return { ...base, status: 'pass', detail: `report-only: ${window.runs} main run(s) since ${window.since.slice(0, 10)} (< 20 needed to enforce)` };
|
||||
return { ...base, status: 'pass', detail: `report-only: ${window.runs} main run(s) since ${window.since.slice(0, 10)} (< 20 needed to enforce)${left}` };
|
||||
}
|
||||
if (failing.length === 0) return { ...base, status: 'pass', detail: `no file above 5% over ${window.runs} main runs` };
|
||||
if (failing.length === 0) return { ...base, status: 'pass', detail: `no file above 5% over ${window.runs} main runs${left}` };
|
||||
return { ...base, status: 'fail',
|
||||
detail: failing.map(file => `${file.file}: flaky in ${file.flakyRuns}/${window.runs} main runs (${pct(file.rate)} > 5%)`).join('; ') };
|
||||
detail: failing.map(file => `${file.file}: flaky in ${file.flakyRuns}/${window.runs} main runs (${pct(file.rate)} > 5%)`).join('; ') + left };
|
||||
}
|
||||
|
||||
/** The reused main pushes a flake window left out and the runs it could not check, or ''. */
|
||||
function flakeWindowNotes(window: FlakeWindow): string {
|
||||
return `${window.skipped ? `; ${window.skipped} reused main push(es) left out` : ''}${window.note ? `; ${window.note}` : ''}`;
|
||||
}
|
||||
|
||||
export function unseededCheck(root: string): Check {
|
||||
@@ -286,19 +292,19 @@ export function collectHealth(inputs: HealthInputs): HealthReport {
|
||||
metrics.push(...censusMetrics(inputs, artifactDirs));
|
||||
|
||||
const flakes = attempt(() => readCiFlakeLedgers({ client, repo, sinceDays, now, cacheDir }));
|
||||
metrics.push(metric('free-flaky-pass-rate', 'Free-suite flaky-pass rate', 'share of main free-tests runs (success or failure) with at least one flaky-pass ledger entry; per-file rate = runs with that file / runs', () => {
|
||||
metrics.push(metric('free-flaky-pass-rate', 'Free-suite flaky-pass rate', 'share of main free-tests runs (success or failure) with at least one flaky-pass ledger entry; per-file rate = runs with that file / runs; a main push that reused its pull request\'s run (no artifacts, `reuse` job green) is left out', () => {
|
||||
const window = need(flakes);
|
||||
return { value: `${pct(window.runs ? window.flakyRuns / window.runs : 0)} (${window.flakyRuns}/${window.runs} main runs since ${window.since.slice(0, 10)} needed the retry pass; ${window.files.length} file(s))`,
|
||||
return { value: `${pct(window.runs ? window.flakyRuns / window.runs : 0)} (${window.flakyRuns}/${window.runs} main runs since ${window.since.slice(0, 10)} needed the retry pass; ${window.files.length} file(s)${flakeWindowNotes(window)})`,
|
||||
detail: window.files.slice(0, 15).map(file => `${file.file}: ${file.flakyRuns}/${window.runs} runs (${pct(file.rate)})${file.failing ? ' FAIL > 5%' : ''}`) };
|
||||
}));
|
||||
|
||||
metrics.push(metric('free-wall', 'Free-suite and Windows wall', 'p50 of run_started_at to updated_at for successful main runs of free-tests.yml and successful pull_request runs of windows-free-tests.yml (its workflow_dispatch duration recordings excluded)', () => {
|
||||
metrics.push(metric('free-wall', 'Free-suite and Windows wall', 'p50 of run_started_at to updated_at for successful main runs of free-tests.yml and successful pull_request runs of windows-smoke.yml, the required Windows check (its workflow_dispatch runs excluded)', () => {
|
||||
const walls = (workflow: string, filter: { branch?: string; event?: string }) =>
|
||||
listWorkflowRuns(client, repo, workflow, { since, ...filter }).filter(run => run.conclusion === 'success').map(run => span(run.startedAt, run.updatedAt)!);
|
||||
const free = walls('free-tests.yml', { branch: 'main' });
|
||||
const windows = walls('windows-free-tests.yml', { event: 'pull_request' });
|
||||
const windows = walls('windows-smoke.yml', { event: 'pull_request' });
|
||||
const windowsValue = windows.length ? `${minutes(percentile(windows, 0.5))} (${windows.length} runs)` : 'no successful PR runs in window';
|
||||
return { value: `free-tests ${minutes(percentile(free, 0.5))} (${free.length} runs); windows-free-tests (successful PR runs) ${windowsValue}` };
|
||||
return { value: `free-tests ${minutes(percentile(free, 0.5))} (${free.length} runs); windows-smoke (successful PR runs) ${windowsValue}` };
|
||||
}));
|
||||
|
||||
metrics.push(metric('free-serial', 'Free-suite serial time', 'sum of scripts/free-test-durations.json (the committed seed)', () => {
|
||||
|
||||
@@ -295,6 +295,7 @@ export const FREE_ONLY_PR_FILES = [
|
||||
'.github/workflows/windows-free-tests.yml',
|
||||
'.github/workflows/windows-pregate-e2e.yml',
|
||||
'.github/workflows/windows-setup-e2e.yml',
|
||||
'.github/workflows/windows-smoke.yml',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -44,12 +44,13 @@
|
||||
#
|
||||
# TTL and reaper: a name this script generates ends in -t<hours> (UBI_TTL_HOURS,
|
||||
# 1-999, default 3), the creator's own declaration of how long the VM may live.
|
||||
# `reap` destroys every VM in the project whose TTL expired more than ten
|
||||
# minutes ago, whichever owner made it, and this owner's own ubirun-<owner>-*
|
||||
# names with no TTL once they are UBI_LEGACY_HOURS old (default 4; another
|
||||
# owner's or an untagged TTL-less VM is never reaped); every `up` runs it first (UBI_REAP=0 skips it), so a runner
|
||||
# killed by a sleeping machine leaves nothing behind for longer than its TTL.
|
||||
# Names outside the ubirun- prefix are never reaped.
|
||||
# `reap` destroys only this owner's VMs: its ubirun-<owner>-* names whose TTL
|
||||
# expired more than ten minutes ago, and those with no TTL once they are
|
||||
# UBI_LEGACY_HOURS old (default 4). Another owner's VM, an untagged one and
|
||||
# names outside the ubirun- prefix are never reaped, expired or not; reap only
|
||||
# counts other owners' expired VMs in its log (cloud deletes stay owner-scoped,
|
||||
# GST-573). Every `up` runs it first (UBI_REAP=0 skips it), so a lane killed by
|
||||
# a sleeping machine has its leftovers destroyed by its own next `up`.
|
||||
#
|
||||
# Leftover line: `run` and `verify` end with
|
||||
# UBICLOUD: left=<0|1|unknown> owner=<owner> vm=<name> others=<n>
|
||||
@@ -740,6 +741,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]}
|
||||
@@ -753,23 +755,26 @@ vm_rows() {
|
||||
done
|
||||
}
|
||||
|
||||
# reap: destroy every VM whose name carries a TTL that ended more than
|
||||
# REAP_GRACE seconds ago, whichever owner created it (the TTL is that owner's
|
||||
# own declaration), and the caller's own TTL-less ubirun-<UBI_OWNER>-* names at
|
||||
# least LEGACY_HOURS old. Another owner's TTL-less VM, an untagged one, and names
|
||||
# outside the prefix are never touched: only a TTL lets reap judge another lane's VM.
|
||||
# reap: destroy the caller's own VMs whose TTL ended more than REAP_GRACE
|
||||
# seconds ago, and its TTL-less ubirun-<UBI_OWNER>-* names at least LEGACY_HOURS
|
||||
# old. Every other VM is never touched, whatever its TTL says: a delete stays
|
||||
# with the owner that created the VM. Other owners' expired VMs are only counted.
|
||||
cmd_reap() {
|
||||
local now rows loc name owner expires created n=0 failed=0
|
||||
local now rows loc name owner expires created n=0 failed=0 others=0
|
||||
[[ "$LEGACY_HOURS" =~ ^[1-9][0-9]{0,3}$ ]] || die "UBI_LEGACY_HOURS must be a whole number of hours from 1 to 9999 with no leading zero (how old a TTL-less ubirun-* VM must be before reap destroys it)"
|
||||
now=$(date +%s)
|
||||
rows=$(vm_rows) || { log "reap: could not list the project's VMs; nothing destroyed"; return 1; }
|
||||
while read -r loc name owner expires created; do
|
||||
[ -n "$name" ] || continue
|
||||
if [ "$owner" != "$OWNER" ]; then
|
||||
[ "$expires" = - ] || [ "$now" -lt $(( expires + REAP_GRACE )) ] || others=$(( others + 1 ))
|
||||
continue
|
||||
fi
|
||||
if [ "$expires" != - ]; then
|
||||
[ "$now" -ge $(( expires + REAP_GRACE )) ] || continue
|
||||
log "reaping expired $loc/$name (owner $owner; its TTL ended $(( (now - expires) / 60 )) min ago)"
|
||||
else
|
||||
[ -n "${UBI_OWNER:-}" ] && [ "$owner" = "$OWNER" ] && [ "$created" != - ] || continue
|
||||
[ -n "${UBI_OWNER:-}" ] && [ "$created" != - ] || continue
|
||||
[ $(( now - 10#$created )) -ge $(( LEGACY_HOURS * 3600 )) ] || continue
|
||||
log "reaping TTL-less $loc/$name (owner $owner; $(( (now - 10#$created) / 3600 ))h old, legacy sweep at ${LEGACY_HOURS}h)"
|
||||
fi
|
||||
@@ -777,6 +782,7 @@ cmd_reap() {
|
||||
cmd_down "$name" -l "$loc" || { failed=$(( failed + 1 )); log "failed to reap $loc/$name"; }
|
||||
done <<<"$rows"
|
||||
[ "$n" -gt 0 ] || log "reap: no expired VMs"
|
||||
[ "$others" -eq 0 ] || log "reap: left $others expired VMs of other owners; only their owners reap them"
|
||||
[ "$failed" -eq 0 ] || { log "reap: $failed of $n expired VMs could not be destroyed"; return 1; }
|
||||
}
|
||||
|
||||
@@ -804,21 +810,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 +905,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 +1107,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 +1143,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 +1157,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
|
||||
}
|
||||
|
||||
@@ -1326,7 +1368,7 @@ cmd_run() {
|
||||
trap 'exit 130' INT TERM QUIT
|
||||
trap on_hup HUP
|
||||
if [[ "$name" =~ -t([0-9]+)$ ]]; then
|
||||
log "$name carries a ${BASH_REMATCH[1]}h TTL (any later run of this script reaps it); if this process dies first: $0 down $name"
|
||||
log "$name carries a ${BASH_REMATCH[1]}h TTL (this owner's next up or reap destroys it once it expires); if this process dies first: $0 down $name"
|
||||
elif [[ "$name" =~ ^$PREFIX-$OWNER-[0-9]{10}- ]]; then
|
||||
log "$name has no TTL (reap destroys it once it is ${LEGACY_HOURS}h old); if this process dies before its teardown line, destroy it with: $0 down $name"
|
||||
else
|
||||
@@ -1542,8 +1584,8 @@ usage: ubi-runner.sh <command> [args]
|
||||
owner print this caller's owner tag ($OWNER)
|
||||
records VMs this machine created and has not confirmed gone ($VM_RECORD)
|
||||
gc HOURS destroy this owner's VMs older than HOURS (never other owners')
|
||||
reap destroy every VM whose -t<hours> TTL has ended (any owner) and this
|
||||
owner's TTL-less ubirun-* VMs older than UBI_LEGACY_HOURS
|
||||
reap destroy this owner's VMs whose -t<hours> TTL has ended and its
|
||||
TTL-less ubirun-* VMs older than UBI_LEGACY_HOURS (never other owners')
|
||||
cli ARGS... raw Ubicloud CLI passthrough (e.g. cli vm list)
|
||||
|
||||
signals: INT and TERM tear the VM down. HUP, once a remote phase is live, detaches
|
||||
|
||||
Reference in new issue
Block a user