mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
v1.92.71.0 test(free): end four non-subject waits early and drop a duplicated test block (GSTA-151 D) (#3582)
* test(free): end four non-subject waits early and drop a duplicated test block setup's _wait_with_deadline polls every 0.1 s on the SECONDS clock; pregate-supervise and gstack-detach wait on pids and go-files instead of fixed sleeps; qa-probe-gates loses its duplicated C2 block; context-identity runs one restore. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(setup): mark the sub-second wait bound as the contract (gstack-timing-ok) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): unreleased entry for the free-suite wait pass (GSTA-151 D) 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
cd788faec7
commit
69c14b17d5
10 files changed
+50
-54
No files matched your search
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [1.92.71.0] - 2026-11-02
|
||||
|
||||
**Four free-suite waits now end when the thing they wait for happens: the six files involved spend 18–29 s less in summed per-file runtime, with every assertion intact.** `./setup` returns from a finished child up to ~0.9 s sooner too.
|
||||
|
||||
### Changed
|
||||
- **`setup` `_wait_with_deadline`**: polls every 0.1 s and tracks the deadline on bash's `SECONDS` clock instead of sleeping a whole second per poll. A child that exits at once no longer costs a second. The kill still lands no earlier than the deadline, the exit codes (the child's status, or 124 after the kill) are unchanged, and it runs under bash 3.2.
|
||||
|
||||
### Tests
|
||||
- `test/setup-playwright-best-effort.test.ts`: a child that exits with 3 after 50 ms returns 3 in under 500 ms (the old loop took 1,008 ms); the deadline and kill cases are unchanged. The file drops from ~20.8 s to ~5.9 s on a CI-mirror VM.
|
||||
- `test/pregate-supervise.test.ts`: the leftover-child case waits for that child's pid to exit (bounded at 3 s) instead of a fixed 2.5 s sleep.
|
||||
- `test/gstack-detach.test.ts`: the detach and lock bodies wait on files the test writes (bounded at ~10 s, also written in `finally`) instead of `sleep 2` and `sleep 3`.
|
||||
- `test/qa-probe-gates.test.ts`: the duplicated "judge-named QA workflow gaps (C2)" block is gone (3 tests that repeated the first block).
|
||||
- `test/context-identity.test.ts`: one `restore(mono)` call serves both assertions.
|
||||
|
||||
## [1.92.70.0] - 2026-11-02
|
||||
|
||||
**Five free-test files stop rebuilding the same git repositories for every test: they spend 17–24 s less in summed per-file runtime, with every assertion intact.** Each test still gets its own private copy of the fixture.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.92.70.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.92.71.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,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.92.70",
|
||||
"version": "1.92.71",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -1539,15 +1539,15 @@ _kill_tree() {
|
||||
# poll the PID and SIGKILL the whole probe tree past the deadline. Returns
|
||||
# the probe's exit code, or 124 on timeout.
|
||||
_wait_with_deadline() {
|
||||
local pid="$1" deadline_s="$2" waited=0
|
||||
local pid="$1" deadline_s="$2" end
|
||||
end=$((SECONDS + deadline_s))
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
if [ "$waited" -ge "$deadline_s" ]; then
|
||||
if [ "$SECONDS" -gt "$end" ]; then
|
||||
_kill_tree "$pid"
|
||||
wait "$pid" 2>/dev/null || true
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
sleep 0.1
|
||||
done
|
||||
wait "$pid"
|
||||
}
|
||||
|
||||
@@ -353,8 +353,9 @@ describe('context-restore offers newer checkpoints it used to skip (#3065)', ()
|
||||
const sibling = repo('elsewhere', 'git@github.com:acme/elsewhere.git');
|
||||
const outside = save(sibling, 'sibling work', '20261002-100000');
|
||||
const below = save(pkg, 'pkg feature', '20261001-100000');
|
||||
expect(restore(mono).out).not.toContain(outside);
|
||||
expect(restore(mono).lines).toContain(`BELOW_CWD ${below}`);
|
||||
const r = restore(mono);
|
||||
expect(r.out).not.toContain(outside);
|
||||
expect(r.lines).toContain(`BELOW_CWD ${below}`);
|
||||
|
||||
// 200 newer checkpoints of another project push the below-cwd one out of the read window.
|
||||
const dir = path.dirname(outside);
|
||||
|
||||
@@ -25,6 +25,7 @@ function waitFor(pred: () => boolean, ms: number): boolean {
|
||||
}
|
||||
return pred();
|
||||
}
|
||||
const GO_FILE_WAIT = 'i=0; while [ ! -e "$1" ] && [ "$i" -lt 100 ]; do sleep 0.1; i=$((i + 1)); done; [ -e "$1" ]';
|
||||
function logHas(p: string, needle: string): boolean {
|
||||
try { return fs.readFileSync(p, 'utf-8').includes(needle); } catch { return false; }
|
||||
}
|
||||
@@ -33,19 +34,21 @@ describe('gstack-detach', () => {
|
||||
test('detaches (different pgid), returns immediately, completes, writes EXIT sentinel', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gd-'));
|
||||
const log = path.join(dir, 'run.log');
|
||||
const go = path.join(dir, 'go');
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const r = spawnSync(DETACH, ['--log', log, '--', 'bash', '-c', 'sleep 2; echo body-ran'], { encoding: 'utf-8', timeout: 10000 });
|
||||
const r = spawnSync(DETACH, ['--log', log, '--', 'bash', '-c', GO_FILE_WAIT + ' && echo body-ran', 'body', go], { encoding: 'utf-8', timeout: 10000 });
|
||||
const elapsed = Date.now() - t0;
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain(`gstack-detach LOG ${log}`);
|
||||
expect(elapsed).toBeLessThan(1500); // non-blocking
|
||||
fs.writeFileSync(go, '');
|
||||
expect(waitFor(() => logHas(log, '### gstack-detach EXIT=0 ###'), 8000)).toBe(true);
|
||||
expect(logHas(log, 'body-ran')).toBe(true); // ran to completion after launcher returned
|
||||
const m = fs.readFileSync(log, 'utf-8').match(/pgid=(\d+)/);
|
||||
expect(m).not.toBeNull();
|
||||
expect(m![1]).not.toBe(ownPgid()); // detached into its own group
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
} finally { fs.writeFileSync(go, ''); fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
}, 15000);
|
||||
|
||||
test('default log is run-scoped under ~/.gstack-dev/eval-runs (no shared /tmp)', () => {
|
||||
@@ -127,17 +130,20 @@ describe('gstack-detach', () => {
|
||||
const lock = `gstack-detach-test-${process.pid}`;
|
||||
const logA = path.join(dir, 'a.log');
|
||||
const logB = path.join(dir, 'b.log');
|
||||
const release = path.join(dir, 'release');
|
||||
try {
|
||||
// First holds the lock for ~3s; second must wait then acquire.
|
||||
spawnSync(DETACH, ['--log', logA, '--lock', lock, '--', 'sleep', '3'], { encoding: 'utf-8', timeout: 10000, env: { ...process.env, HOME: dir } });
|
||||
// First holds the lock until the test writes the release file; second must wait then acquire.
|
||||
spawnSync(DETACH, ['--log', logA, '--lock', lock, '--', 'bash', '-c', GO_FILE_WAIT, 'holder', release], { encoding: 'utf-8', timeout: 10000, env: { ...process.env, HOME: dir } });
|
||||
waitFor(() => logHas(logA, "ACQUIRED"), 4000);
|
||||
spawnSync(DETACH, ['--log', logB, '--lock', lock, '--', 'echo', 'second-ran'], { encoding: 'utf-8', timeout: 10000, env: { ...process.env, HOME: dir } });
|
||||
// Second should report WAITING (first still holds it) then ACQUIRE after release.
|
||||
expect(waitFor(() => logHas(logB, 'WAITING for lock'), 4000)).toBe(true);
|
||||
fs.writeFileSync(release, '');
|
||||
expect(waitFor(() => logHas(logB, '### gstack-detach EXIT=0 ###'), 12000)).toBe(true);
|
||||
expect(logHas(logB, 'second-ran')).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, '.gstack', 'locks', `${lock}.lock`))).toBe(true);
|
||||
} finally {
|
||||
fs.writeFileSync(release, '');
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
@@ -226,9 +226,11 @@ describe('gstack-pregate: the test stage never hangs silently', () => {
|
||||
expect(pregate(during, []).status).toBe(EXIT.fail);
|
||||
expect(lanes(during).detail).toContain('working tree changed during the check');
|
||||
const after = makeFixtureRepo(); repos.push(after);
|
||||
commit(after, { 'test/leftover.test.js': "const test = require('node:test');\nconst { spawn } = require('node:child_process');\ntest('leaves a child', () => { spawn('sh', ['-c', 'sleep 2; echo changed >> README.md'], { stdio: 'ignore' }).unref(); });\n" });
|
||||
commit(after, { 'test/leftover.test.js': "const test = require('node:test');\nconst { spawn } = require('node:child_process');\ntest('leaves a child', () => { const c = spawn('sh', ['-c', 'sleep 2; echo changed >> README.md'], { stdio: 'ignore' }); require('node:fs').writeFileSync(require('node:path').join(__dirname, '..', '..', 'leftover.pid'), String(c.pid)); c.unref(); });\n" });
|
||||
expect(pregate(after, []).status).toBe(EXIT.ok);
|
||||
await Bun.sleep(2500);
|
||||
const until = Date.now() + 3000;
|
||||
const leftover = await pidFrom(path.join(after.root, 'leftover.pid'), 3000).catch(() => 0);
|
||||
if (leftover) await goneSoon(leftover, Math.max(0, until - Date.now()));
|
||||
expect(fs.readFileSync(path.join(after.work, 'README.md'), 'utf8')).toBe('# fixture\n');
|
||||
}, 90_000);
|
||||
|
||||
|
||||
@@ -344,42 +344,3 @@ describe('judge-named QA workflow gaps (C2)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Lane S C2: gaps the qa-only/review/ship workflow judges named in their rationales.
|
||||
describe('judge-named QA workflow gaps (C2)', () => {
|
||||
const ctx = (skillName: string) => ({ host: 'claude' as const, skillName, tmplPath: '', paths: HOST_PATHS.claude });
|
||||
|
||||
test('Browser Quick leaves time for its six page probes, and the method states the same budget', () => {
|
||||
for (const skillName of ['qa', 'qa-only']) {
|
||||
const seconds = Number(/Browser Quick: SECONDS=(\d+)\./.exec(generateQAExploratory(ctx(skillName)))?.[1]);
|
||||
// homepage + top 5 targets, each a checkpoint write plus one guarded command: 30 s left ~5 s per probe.
|
||||
expect(seconds / 6).toBeGreaterThanOrEqual(20);
|
||||
const patterns = fs.readFileSync(path.join(import.meta.dir, '..', 'qa', 'sections', 'qa-patterns.md'), 'utf8');
|
||||
expect(patterns).toContain(`${seconds / 60} minutes: homepage + top 5 navigation targets`);
|
||||
}
|
||||
});
|
||||
|
||||
test('PROBE_DIR names its source line, and stopping goes to the final report steps', () => {
|
||||
for (const skillName of ['qa', 'qa-only']) {
|
||||
const text = generateQAExploratory(ctx(skillName));
|
||||
const layout = text.indexOf('mixed standalone runs use REPORT_DIR/browser and REPORT_DIR/functional');
|
||||
const probeDir = text.indexOf("PROBE_DIR: this surface's owned probe directory per the line above.");
|
||||
expect(layout).toBeGreaterThan(-1);
|
||||
expect(probeDir).toBeGreaterThan(layout);
|
||||
expect(text.slice(layout, probeDir).split('\n')).toHaveLength(3);
|
||||
expect(text).toContain('write the report (§4), not a checkpoint.');
|
||||
const final = text.indexOf('## 4. Final report');
|
||||
expect(final).toBeGreaterThan(-1);
|
||||
expect(text.indexOf('annotations.json', final)).toBeLessThan(text.indexOf('materialize PROBE_DIR', final));
|
||||
}
|
||||
});
|
||||
|
||||
test('/review and /ship: smoke rechecks after smoke expiry are not-run, before step 4c revalidation', () => {
|
||||
for (const skillName of ['review', 'ship']) {
|
||||
const text = generateQAReview(ctx(skillName));
|
||||
const rule = text.indexOf('Post-expiry smoke rechecks are not-run.');
|
||||
expect(rule).toBeGreaterThan(-1);
|
||||
expect(rule).toBeLessThan(text.indexOf('c. Re-review changed or uncertain coverage and repeat step 3 for affected checks.'));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -271,6 +271,18 @@ describe('setup: Chromium bootstrap block executes best-effort', () => {
|
||||
expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('killed');
|
||||
}, 15_000);
|
||||
|
||||
test('_wait_with_deadline returns a quickly exiting child\'s status without waiting out a whole second', () => {
|
||||
const t0 = Date.now();
|
||||
const r = runBashScript([
|
||||
extractFn('_kill_tree'), extractFn('_wait_with_deadline'),
|
||||
'(sleep 0.05; exit 3) &',
|
||||
'_wait_with_deadline $! 90 || echo "RC=$?"',
|
||||
].join('\n'), { timeout: 10_000 });
|
||||
expect(r.stdout).toBe('RC=3\n');
|
||||
// gstack-timing-ok: a sub-second return is the contract; the old 1 s poll took 1,008 ms, this path takes ~0.2 s
|
||||
expect(Date.now() - t0).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('non-numeric timeout knob falls back to the default instead of erroring', () => {
|
||||
const r = runBlock({ probe: 'fail', bunx: 'exit 3', env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: 'soon' } });
|
||||
expect(r.status).toBe(0);
|
||||
|
||||
Reference in new issue
Block a user