mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
v1.92.47.0 fix(ship): /ship Step 5 runs test lanes one at a time on 4 cores or fewer, with --lanes serial|parallel (GST-851) (#3821)
* fix(ship-preflight): run Step 5 lanes one at a time on 4 cores or fewer, with --lanes serial|parallel (GST-851) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: GST-851 fragment Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship): Step 5 says lanes run one at a time on 4 or fewer cores and names --lanes (GST-851 docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
dd2416f4eb
commit
35a676ff68
10 files changed
+100
-19
No files matched your search
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## [1.92.47.0] - 2026-11-01
|
||||
|
||||
- **`/ship` Step 5 runs its test lanes one at a time on machines with 4 or fewer cores, and `--lanes serial|parallel` picks the mode by hand** (`gstack-ship-preflight`, GST-851). Preflight used to start every `--lane` at once. On garrytan/gstack-evals#115 six lanes (a 1,451-test bun suite, tsc, a Docker tier and an arm tarball trial) started within 60 ms of each other on a 4-core, 16 GB machine, and running lanes together has taken machines down before (gstack-evals #101, #106, #107). Now, with no flag, preflight runs one lane at a time when `os.availableParallelism()` is 4 or less and all of them at once above that. `--lanes serial` or `--lanes parallel` overrides the default, and any other value, or `--lanes` with no value, is a usage error (exit 2). A new `LANES: serial|parallel (--lanes|<n> cores)` line before the `LANE` lines says which mode ran and why. Each lane still prints its own `LANE` line with its own `tests_ran` count, in the order the lanes were given. Serial runs take the sum of the lane times instead of the longest one. There is no ship-policy key for this: a new key needs key versioning and a `min_gstack_version` bump, and the core-count default already covers the 4-core case.
|
||||
|
||||
### Tests
|
||||
- `test/ship-preflight.test.ts`: `--lanes serial` runs lanes one after another and keeps each lane's count; `--lanes parallel` keeps lane order when a later lane finishes first; an unflagged run names the core count; a bad or missing `--lanes` value is a usage error; and the core-count default is serial at 4 cores or fewer.
|
||||
|
||||
## [1.92.46.0] - 2026-11-01
|
||||
|
||||
**brain-sync no longer publishes a synced JSON file that doesn't parse, and the profile and doctor now say when one is broken.** When two machines edit `developer-profile.json` before syncing, git's merge can leave conflict markers in it. brain-sync committed and pushed that file, so every machine got a profile that no longer parsed, while `gstack-developer-profile --profile` and `gstack-doctor` both reported everything ok (#3763).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.92.46.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.92.47.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+27
-7
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { fragmentSlug } from './changelog-fragments';
|
||||
import { parseUnifiedDiff, stampOnlyLine, versionAtRef } from './restamp';
|
||||
@@ -150,13 +151,32 @@ export class Report {
|
||||
}
|
||||
}
|
||||
|
||||
/** Run each lane through gstack-evidence run in parallel and read its recorded line. */
|
||||
export async function runLanes(cwd: string, list: { label: string; command: string }[]): Promise<{ label: string; exit: number | null; testsRan: string; log: string }[]> {
|
||||
return Promise.all(list.map(async l => {
|
||||
const r = await binAsync(cwd, 'gstack-evidence', ['run', '--label', l.label, '--', l.command]);
|
||||
const m = /gstack-evidence: recorded label=\S+ exit=(\S+) tests_ran=(\S+) log=(\S+)/.exec(r.stderr);
|
||||
return { label: l.label, exit: r.status, testsRan: m?.[2] ?? 'unknown', log: m?.[3] ?? '-' };
|
||||
}));
|
||||
/**
|
||||
* How many lanes run at once (GST-851): `--lanes serial` one, `--lanes parallel` all; unset, one on a machine with 4 or fewer
|
||||
* cores (six lanes at once took 4-core machines down on gstack-evals) and all above that. Any other value is a usage error.
|
||||
*/
|
||||
export function laneConcurrency(mode: string | undefined, cpus = os.availableParallelism()): number | string {
|
||||
if (mode === 'serial') return 1;
|
||||
if (mode === 'parallel') return Infinity;
|
||||
if (mode !== undefined) return `--lanes ${mode} is not serial or parallel`;
|
||||
return cpus <= 4 ? 1 : Infinity;
|
||||
}
|
||||
|
||||
/** Run each lane through gstack-evidence run, at most `concurrency` at a time, and read its recorded line; results keep the lanes' order. */
|
||||
export async function runLanes(cwd: string, list: { label: string; command: string }[], concurrency = Infinity): Promise<{ label: string; exit: number | null; testsRan: string; log: string }[]> {
|
||||
const out: { label: string; exit: number | null; testsRan: string; log: string }[] = [];
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < list.length) {
|
||||
const i = next++;
|
||||
const l = list[i]!;
|
||||
const r = await binAsync(cwd, 'gstack-evidence', ['run', '--label', l.label, '--', l.command]);
|
||||
const m = /gstack-evidence: recorded label=\S+ exit=(\S+) tests_ran=(\S+) log=(\S+)/.exec(r.stderr);
|
||||
out[i] = { label: l.label, exit: r.status, testsRan: m?.[2] ?? 'unknown', log: m?.[3] ?? '-' };
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, list.length)) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Step 5's lane verdict: a lane passes on its runner count, never exit 0 alone. */
|
||||
|
||||
+11
-5
@@ -6,22 +6,24 @@
|
||||
* agent (a merge conflict, a failing or zero-run lane, the carry route, the Apple target).
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { classifyDiff } from './review-depth';
|
||||
import { audit } from './ship-audit';
|
||||
import { bin, firstLine, flagReader, gh, git, hostedOrigin, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common';
|
||||
import { bin, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common';
|
||||
|
||||
export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Step 5's pregate and lanes in one call (ship/SKILL.md)
|
||||
|
||||
gstack-ship-preflight --base <base> [--lane <label>=<command>]... Steps 1-3; with lanes also Step 5
|
||||
gstack-ship-preflight --tests-only --base <base> --lane <label>=<command>... Step 5 only (after a repair)
|
||||
--lanes serial|parallel how Step 5 runs its lanes; unset, one at a time on 4 or fewer cores, all at once above
|
||||
--policy-from head Step 5's pregate reads the branch's .gstack/pregate.json, which may only add test lanes
|
||||
(a branch that adds the lane for a file it touches); anything else is PREGATE_HEAD_POLICY_LOOSENS
|
||||
gstack-ship-preflight --audit --base <base> --out <dir> the single reads and Steps 7-8's deterministic audits
|
||||
(CONTEXT, TODOS, COVERAGE_MAP, Coverage, PLAN_*, READER: coverage=needed|none plan=needed|none)
|
||||
|
||||
Prints at most 30 lines: BRANCH, STATUS, DIFF, PR/CARRY, APPLE, SCOPE, MERGE, DISTRIBUTION, POLICY,
|
||||
RUNTIME_LINES, PREGATE (with up to three PREGATE_DETAIL lines when it does not pass), one LANE line per lane,
|
||||
RUNTIME_LINES, PREGATE (with up to three PREGATE_DETAIL lines when it does not pass), LANES, one LANE line per lane,
|
||||
then NEXT: lines. STOP: names what the agent must
|
||||
decide or repair; nothing after it ran. Exit codes: 0 ok · 2 usage · 3 STOP`;
|
||||
|
||||
@@ -122,7 +124,7 @@ function merge(cwd: string, base: string, r: Report): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function tests(cwd: string, base: string, list: { label: string; command: string }[], policyFrom: string, r: Report): Promise<void> {
|
||||
async function tests(cwd: string, base: string, list: { label: string; command: string }[], policyFrom: string, run: { concurrency: number; line: string }, r: Report): Promise<void> {
|
||||
const pg = bin(cwd, 'gstack-pregate', ['--allow-repo-commands', '--base', base, '--policy-from', policyFrom], 3_600_000);
|
||||
const refusal = pg.status === 3 ? /\((PREGATE_[A-Z_]+|POLICY_[A-Z_]+)[;)]/.exec(pg.stderr)?.[1] : undefined;
|
||||
const verdict = refusal ? `refused (${refusal})` : /^PREGATE: (.+?) tree=/m.exec(pg.stdout + pg.stderr)?.[1] ?? `unreadable (exit ${pg.status})`;
|
||||
@@ -133,7 +135,8 @@ async function tests(cwd: string, base: string, list: { label: string; command:
|
||||
return;
|
||||
}
|
||||
if (/uncleared/.test(verdict)) r.next('pregate requires-remote: gstack-pregate clear, or --remote --spend-cap <usd> under a user cap, before Step 18');
|
||||
const results = (await runLanes(cwd, list)).map(laneLine);
|
||||
r.add(run.line);
|
||||
const results = (await runLanes(cwd, list, run.concurrency)).map(laneLine);
|
||||
for (const l of results) r.add(l.line);
|
||||
const bad = results.filter(l => !l.ok);
|
||||
if (bad.length) { r.stop(`${bad.length} lane(s) failed or ran zero tests: Step 5 triage (read each log; a free-suite shard failure reruns its file list per measure.md); in-branch failures stop the ship`); return; }
|
||||
@@ -154,9 +157,12 @@ export async function preflight(argv: readonly string[], cwd: string): Promise<{
|
||||
if (!['base', 'head'].includes(policyFrom)) return { text: 'gstack-ship-preflight: --policy-from needs base|head\n', exit: 2 };
|
||||
if (!base || typeof list === 'string') return { text: `gstack-ship-preflight: ${typeof list === 'string' ? list : '--base <base> is required'}\n`, exit: 2 };
|
||||
if (has('--tests-only') && !list.length) return { text: 'gstack-ship-preflight: --tests-only needs at least one --lane <label>=<command>\n', exit: 2 };
|
||||
const mode = flag('--lanes');
|
||||
const concurrency = has('--lanes') && mode === undefined ? '--lanes needs serial or parallel' : laneConcurrency(mode);
|
||||
if (typeof concurrency === 'string') return { text: `gstack-ship-preflight: ${concurrency}\n`, exit: 2 };
|
||||
const r = new Report();
|
||||
if (has('--tests-only') || integrate(cwd, base, r)) {
|
||||
if (list.length) await tests(cwd, base, list, policyFrom, r);
|
||||
if (list.length) await tests(cwd, base, list, policyFrom, { concurrency, line: `LANES: ${concurrency === 1 ? 'serial' : 'parallel'} (${mode ? '--lanes' : `${os.availableParallelism()} cores`})` }, r);
|
||||
else if (r.exit === 0) r.next('Step 4: confirm the test framework, then rerun with --tests-only --lane <label>=<command> per suite (Step 5)');
|
||||
}
|
||||
return { text: r.render(), exit: r.exit };
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.92.46",
|
||||
"version": "1.92.47",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -213,8 +213,9 @@ Only commit if there are changes. Stage the bootstrap's own files by name (confi
|
||||
**Run it:** `~/.claude/skills/gstack/bin/gstack-ship-preflight --tests-only --base <base> --lane <label>=<command>...`
|
||||
(or the Step 1 call with lanes). It runs the free pre-gate first, before any lane, eval or
|
||||
remote gate (`gstack-pregate --allow-repo-commands`: stage 1 regen, secrets, strays, literals;
|
||||
stage 2 the selected lanes under pipefail), then every lane in parallel inside the evidence
|
||||
ledger, and prints `PREGATE:` and one `LANE: <label> <state> exit= tests_ran= log=` line each.
|
||||
stage 2 the selected lanes under pipefail), then the lanes inside the evidence ledger (one at
|
||||
a time on 4 or fewer cores unless `--lanes serial|parallel`), and prints `PREGATE:`, `LANES:` and
|
||||
one `LANE: <label> <state> exit= tests_ran= log=` line each.
|
||||
`fail` or `incomplete` blocks: fix, rerun. `requires-remote` (other platforms; the remote gate under 8 cores) needs
|
||||
`gstack-pregate clear` or, under a user cap, `--remote --spend-cap <usd>`. Rerun
|
||||
after the final stamp (`gstack-ship-release finish` does); Step 18 refuses a stale one.
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
**Run it:** `~/.claude/skills/gstack/bin/gstack-ship-preflight --tests-only --base <base> --lane <label>=<command>...`
|
||||
(or the Step 1 call with lanes). It runs the free pre-gate first, before any lane, eval or
|
||||
remote gate (`gstack-pregate --allow-repo-commands`: stage 1 regen, secrets, strays, literals;
|
||||
stage 2 the selected lanes under pipefail), then every lane in parallel inside the evidence
|
||||
ledger, and prints `PREGATE:` and one `LANE: <label> <state> exit= tests_ran= log=` line each.
|
||||
stage 2 the selected lanes under pipefail), then the lanes inside the evidence ledger (one at
|
||||
a time on 4 or fewer cores unless `--lanes serial|parallel`), and prints `PREGATE:`, `LANES:` and
|
||||
one `LANE: <label> <state> exit= tests_ran= log=` line each.
|
||||
`fail` or `incomplete` blocks: fix, rerun. `requires-remote` (other platforms; the remote gate under 8 cores) needs
|
||||
`gstack-pregate clear` or, under a user cap, `--remote --spend-cap <usd>`. Rerun
|
||||
after the final stamp (`gstack-ship-release finish` does); Step 18 refuses a stale one.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { laneConcurrency } from '../lib/ship-bin-common';
|
||||
import { lineOf, PREFLIGHT, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
|
||||
setDefaultTimeout(60_000);
|
||||
@@ -107,6 +108,47 @@ describe('gstack-ship-preflight', () => {
|
||||
expect(lineOf(zero.stdout, 'LANE')).toMatch(/^LANE: tests ZERO-RUN exit=0 tests_ran=0/);
|
||||
});
|
||||
|
||||
// Value: protects=Step 5 can run its lanes one at a time, and each lane keeps its own tests_ran (GST-851: GSTA-2 saw six lanes start within
|
||||
// 60 ms on a 4-core machine on gstack-evals#115); fails_when=--lanes serial lanes overlap, a lane loses its count, or a bad --lanes value runs;
|
||||
// why_new=runLanes started every lane at once with no serial mode; seam=runLanes; no_claim=how long a lane takes
|
||||
test('--lanes serial runs one lane at a time and keeps each lane\'s count; a bad --lanes value is usage', () => {
|
||||
const f = fx();
|
||||
const log = path.join(f.root, 'lane-order.log');
|
||||
const lane = (n: string) => `${n}=echo start ${n} >> '${log}'; sleep 1; echo end ${n} >> '${log}'; printf " 2 pass\\n 0 fail\\nRan 2 tests across 1 file. [5.00ms]\\n"`;
|
||||
const r = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lanes', 'serial', '--lane', lane('a'), '--lane', lane('b')]);
|
||||
expect(r.status).toBe(0);
|
||||
const order = fs.readFileSync(log, 'utf8').trim().split('\n');
|
||||
expect(order.length).toBe(4);
|
||||
expect(order.map(l => l.split(' ')[0])).toEqual(['start', 'end', 'start', 'end']);
|
||||
expect(order[1]!.split(' ')[1]).toBe(order[0]!.split(' ')[1]);
|
||||
expect(r.stdout.split('\n').filter(l => l.startsWith('LANE: ')).map(l => l.replace(/ log=\S+$/, ''))).toEqual(['LANE: a pass exit=0 tests_ran=2', 'LANE: b pass exit=0 tests_ran=2']);
|
||||
expect(lineOf(r.stdout, 'LANES')).toBe('LANES: serial (--lanes)');
|
||||
expect(f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lanes', 'fast', '--lane', LANE]).status).toBe(2);
|
||||
expect(f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lane', LANE, '--lanes']).status).toBe(2);
|
||||
});
|
||||
|
||||
// Value: protects=parallel lanes report in the order given even when a later lane finishes first, and an unflagged run names the core count
|
||||
// behind its default (GST-851); fails_when=results come back in finish order or the LANES line drops its reason; why_new=runLanes became a
|
||||
// worker pool; seam=runLanes, LANES line; no_claim=which default any given machine picks
|
||||
test('--lanes parallel keeps lane order when a later lane finishes first; unflagged LANES names the core count', () => {
|
||||
const f = fx();
|
||||
const lane = (n: string, wait: number) => `${n}=sleep ${wait}; printf " 1 pass\\n 0 fail\\nRan 1 tests across 1 file. [5.00ms]\\n"`;
|
||||
const r = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lanes', 'parallel', '--lane', lane('slow', 1), '--lane', lane('fast', 0)]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(lineOf(r.stdout, 'LANES')).toBe('LANES: parallel (--lanes)');
|
||||
expect(r.stdout.split('\n').filter(l => l.startsWith('LANE: ')).map(l => l.split(' ')[1])).toEqual(['slow', 'fast']);
|
||||
expect(lineOf(f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--lane', LANE]).stdout, 'LANES')).toMatch(/^LANES: (serial|parallel) \(\d+ cores\)$/);
|
||||
});
|
||||
|
||||
// Value: protects=a small machine runs lanes one at a time without a flag, a large one keeps running them together, and the flag overrides
|
||||
// either way (GST-851); fails_when=a 4-core default runs lanes at once or the flag is ignored; why_new=no default existed; seam=laneConcurrency;
|
||||
// no_claim=the core count of any CI runner
|
||||
test('lane concurrency: serial on 4 cores or fewer, parallel above, --lanes overrides', () => {
|
||||
expect([laneConcurrency(undefined, 2), laneConcurrency(undefined, 4), laneConcurrency(undefined, 8)]).toEqual([1, 1, Infinity]);
|
||||
expect([laneConcurrency('serial', 64), laneConcurrency('parallel', 2)]).toEqual([1, Infinity]);
|
||||
expect(laneConcurrency('fast', 8)).toBe('--lanes fast is not serial or parallel');
|
||||
});
|
||||
|
||||
// Value: protects=Step 2's candidates and Step 1.4's carry check are reported for the agent to judge, and an open PR's carry verdict is printed; fails_when=a new bin/ artifact goes unmentioned or the carry check is skipped when a PR is open;
|
||||
// why_new=moved from Steps 1.4 and 2; seam=gh, gstack-ship-receipt carry; no_claim=whether a candidate is a distributable
|
||||
test('a new artifact path is a Step 2 candidate; an open PR runs the carry dry-run and prints its verdict', () => {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 66,
|
||||
"why": "GST-851: Step 5 prose says lanes run one at a time on 4 or fewer cores, names --lanes serial|parallel and the LANES line"
|
||||
}
|
||||
Reference in new issue
Block a user