mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
v1.92.78.0 test: seven flaky-test repairs and six failure diagnostics from the 2026-11-01 flaky pass (GSTA-240) (#3804)
* test: eight flaky-test repairs and five failure diagnostics from the 2026-11-01 flaky pass (GSTA-240) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(free-shards): keep failing a shard on an unseen browser child, and name it and whether it exited (review finding) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: pregate-regate copies through gitFixtureTemplate after the main merge 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
4bbad24221
commit
eff19e2678
21 files changed
+162
-26
No files matched your search
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.92.78.0] - 2026-11-02
|
||||||
|
|
||||||
|
**Seven flaky tests from the 2026-11-01 flaky pass stop failing correct runs, and six more now name their cause when they fail.** One product lock broke a fresh lock it had misjudged as abandoned, a /ship lane tripped the free runner's home check with its own log, and the rest were fixtures that raced their own setup, leaned on wall-clock sleeps or read the caller's state root.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`bin/gstack-codex-probe`**: a waiter that cannot read an owner-less lock's age (the holder removed it in that instant, or `stat -f` printed filesystem text on Linux) now treats the lock as fresh instead of abandoned, so it no longer deletes a live owner's lock on a misread age and the probe no longer ends `MODEL_PROBE_INCONCLUSIVE` (Free Tests 41216564482, 41235671867, 41263831361). A stat stub for both race windows fails without the fix; 64 stress runs went from 2 failures to 0.
|
||||||
|
- **`scripts/lib/free-home-guard.ts`, `bin/gstack-evidence`**: `gstack-evidence run` names its own lane log in `GSTACK_EVIDENCE_LOG`, and the real-home tripwire ignores exactly that file, so a /ship lane no longer trips over its own output. A foreign write under the home's `~/.gstack` still fails.
|
||||||
|
- **`test/autoplan-publication-guard.test.ts`**: the render-root cases clear the state-root variables they don't name, so a `GSTACK_STATE_ROOT` exported by the gstack launch file no longer picks the root. Three cases failed every time under that variable, and the lookalike controls passed for the wrong reason.
|
||||||
|
- **`test/gstack-design-detect.test.ts`**: the directory scan proves one batch by counting engine calls instead of sleeping near the 1,500 ms detect timeout (macOS job 124813933194). The product timeout is unchanged.
|
||||||
|
- **`test/pregate-regate.test.ts`**: the two fixtures are built once and copied per test through the `gitFixtureTemplate` helper from #3586, so 8 builds become 2.
|
||||||
|
- **`test/ship-queue-train.test.ts`**: the planted seam has its own name, so the fixture's own background `git maintenance` lock no longer collides with it (EEXIST, Free Tests 41288253305, 41288417894), and the test now holds a real `maintenance.lock` beside it.
|
||||||
|
- **`test/ship-budget.test.ts`**: the fake child writes its args to a temporary file and renames it, so the poll never reads an empty file (Free Tests 41242243205).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `scripts/test-free-shards.ts` names the recorded browser child it never saw in the daemon's process tree, and whether it was still alive, when it fails a shard for unconfirmed ownership (Free Tests 41264845860 shard 7). The shard still fails.
|
||||||
|
- `test/bootstrap-retention-shard.test.ts`, `test/office-hours-attempt.test.ts`, `test/session-update-autostash.test.ts`, `test/shard-engine.test.ts` and `test/auq-parallel.test.ts` print the process state, exit status or cleanup events behind their assertion, so the next red names its cause.
|
||||||
|
|
||||||
## [1.92.77.0] - 2026-11-02
|
## [1.92.77.0] - 2026-11-02
|
||||||
|
|
||||||
**The weekly marathon evals can run /design-motion again: the CI eval image now ships ffmpeg (#3687).** The `design-motion-showcase` case joined the marathon tier on 2026-10-26, and its first scheduled run (Actions run 41200316994, main @ 088bfc5) failed 0/3. Every trial built and captured the page, then stopped at `FFMPEG_MISSING`, because the image had no ffmpeg and the skill rightly refuses to export before its checks run. The model did nothing wrong; the case could not pass on that image.
|
**The weekly marathon evals can run /design-motion again: the CI eval image now ships ffmpeg (#3687).** The `design-motion-showcase` case joined the marathon tier on 2026-10-26, and its first scheduled run (Actions run 41200316994, main @ 088bfc5) failed 0/3. Every trial built and captured the page, then stopped at `FFMPEG_MISSING`, because the image had no ffmpeg and the skill rightly refuses to export before its checks run. The model did nothing wrong; the case could not pass on that image.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# gstack digest v1.92.77.0 — regenerate/re-copy after upgrading gstack
|
# gstack digest v1.92.78.0 — regenerate/re-copy after upgrading gstack
|
||||||
|
|
||||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||||
for agent hosts without a full skill install. The full skills add workflows,
|
for agent hosts without a full skill install. The full skills add workflows,
|
||||||
|
|||||||
@@ -662,8 +662,8 @@ _gstack_codex_lock_acquire() {
|
|||||||
if [ "$_ohost" = "$_host" ] && ! kill -0 "$_pid" 2>/dev/null; then _dead=1; fi
|
if [ "$_ohost" = "$_host" ] && ! kill -0 "$_pid" 2>/dev/null; then _dead=1; fi
|
||||||
[ $((_now - _ts)) -ge "$_stale" ] && _dead=1
|
[ $((_now - _ts)) -ge "$_stale" ] && _dead=1
|
||||||
else
|
else
|
||||||
_ts=$(stat -c %Y "$_dir" 2>/dev/null || stat -f %m "$_dir" 2>/dev/null || echo 0)
|
_ts=$(stat -c %Y "$_dir" 2>/dev/null || stat -f %m "$_dir" 2>/dev/null || echo "$_now")
|
||||||
case "$_ts" in ''|*[!0-9]*) _ts=0 ;; esac
|
case "$_ts" in ''|*[!0-9]*) _ts="$_now" ;; esac
|
||||||
[ $((_now - _ts)) -ge 5 ] && _dead=1
|
[ $((_now - _ts)) -ge 5 ] && _dead=1
|
||||||
fi
|
fi
|
||||||
[ "$_dead" -eq 1 ] && _gstack_codex_lock_break "$_dir" "$_owner"
|
[ "$_dead" -eq 1 ] && _gstack_codex_lock_break "$_dir" "$_owner"
|
||||||
|
|||||||
+1
-1
@@ -400,7 +400,7 @@ async function cmdRun(argv: string[]): Promise<number> {
|
|||||||
try {
|
try {
|
||||||
if (shell && !shell.ok) throw new Error(shell.error);
|
if (shell && !shell.ok) throw new Error(shell.error);
|
||||||
const spawnArgv = shell?.ok ? [shell.cmd, ...shell.argv] : cmdArgv;
|
const spawnArgv = shell?.ok ? [shell.cmd, ...shell.argv] : cmdArgv;
|
||||||
proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe" });
|
proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe", env: log ? { ...process.env, GSTACK_EVIDENCE_LOG: log.path } : process.env });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Spawn failure (ENOENT on argv-direct form): record exit 127, propagate 127.
|
// Spawn failure (ENOENT on argv-direct form): record exit 127, propagate 127.
|
||||||
exitCode = 127;
|
exitCode = 127;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gstack",
|
"name": "gstack",
|
||||||
"version": "1.92.77",
|
"version": "1.92.78",
|
||||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
* the real home before and after each free shard, and names the shard's files
|
* the real home before and after each free shard, and names the shard's files
|
||||||
* when one of those entries changed. It watches only these surfaces; it is not
|
* when one of those entries changed. It watches only these surfaces; it is not
|
||||||
* a universal write-containment check. Live agent-session logs that a
|
* a universal write-containment check. Live agent-session logs that a
|
||||||
* concurrently running host keeps writing are excluded, and directory mtimes
|
* concurrently running host keeps writing are excluded, as is the one lane log
|
||||||
* are ignored, so only file, link and directory-set changes count.
|
* `gstack-evidence run` tees this run's own output into (its exact path, from
|
||||||
|
* GSTACK_EVIDENCE_LOG). Directory mtimes are ignored, so only file, link and
|
||||||
|
* directory-set changes count.
|
||||||
*
|
*
|
||||||
* A shard that runs alone owns its window, so its guard names its files.
|
* A shard that runs alone owns its window, so its guard names its files.
|
||||||
* Concurrent shards share one HOME and cannot tell whose write a change was, so
|
* Concurrent shards share one HOME and cannot tell whose write a change was, so
|
||||||
@@ -104,6 +106,7 @@ export type FreeHomeGuardFactory = (files: string[], env: NodeJS.ProcessEnv, sta
|
|||||||
/** Take the baseline now; verify() returns null or the failure to report. */
|
/** Take the baseline now; verify() returns null or the failure to report. */
|
||||||
export function guardFreeHome(files: string[], env: NodeJS.ProcessEnv = process.env, scope: FreeHomeScope = { kind: 'shard' }): FreeHomeGuard {
|
export function guardFreeHome(files: string[], env: NodeJS.ProcessEnv = process.env, scope: FreeHomeScope = { kind: 'shard' }): FreeHomeGuard {
|
||||||
const home = freeHomeDir(env);
|
const home = freeHomeDir(env);
|
||||||
|
const ownLog = env.GSTACK_EVIDENCE_LOG && path.isAbsolute(env.GSTACK_EVIDENCE_LOG) ? path.relative(home, env.GSTACK_EVIDENCE_LOG) : null;
|
||||||
const unreadable = (error: unknown) => `real home surfaces could not be snapshotted (${(error as NodeJS.ErrnoException).code ?? 'error'}); shard files: ${files.join(', ')}`;
|
const unreadable = (error: unknown) => `real home surfaces could not be snapshotted (${(error as NodeJS.ErrnoException).code ?? 'error'}); shard files: ${files.join(', ')}`;
|
||||||
let before: FreeHomeSnapshot | undefined;
|
let before: FreeHomeSnapshot | undefined;
|
||||||
let baselineError: string | undefined;
|
let baselineError: string | undefined;
|
||||||
@@ -112,7 +115,7 @@ export function guardFreeHome(files: string[], env: NodeJS.ProcessEnv = process.
|
|||||||
verify() {
|
verify() {
|
||||||
if (!before) return baselineError!;
|
if (!before) return baselineError!;
|
||||||
try {
|
try {
|
||||||
const changed = diffFreeHome(before, snapshotFreeHome(home));
|
const changed = diffFreeHome(before, snapshotFreeHome(home)).filter(entry => entry !== ownLog);
|
||||||
return changed.length ? formatFreeHomeChange(changed, files, scope) : null;
|
return changed.length ? formatFreeHomeChange(changed, files, scope) : null;
|
||||||
} catch (error) { return unreadable(error); }
|
} catch (error) { return unreadable(error); }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1370,6 +1370,8 @@ function trackShardBrowser(stateDir: string, env: NodeJS.ProcessEnv) {
|
|||||||
check(operation);
|
check(operation);
|
||||||
if (!Number.isSafeInteger(pid) || (pid as number) <= 1) throw new BrowserCleanupError('invalid browser child identity');
|
if (!Number.isSafeInteger(pid) || (pid as number) <= 1) throw new BrowserCleanupError('invalid browser child identity');
|
||||||
const identity = identities.get(pid as number);
|
const identity = identities.get(pid as number);
|
||||||
|
if (!identity && identities.get(daemon as number)?.root) throw new BrowserCleanupError(
|
||||||
|
`browser child ownership is unconfirmed: recorded pid ${pid} (${await inspect(operation, pid as number) ? 'alive' : 'exited'}) was never seen in daemon ${daemon}'s process tree`);
|
||||||
if (!identity || identity.daemon !== daemon || identity.root) throw new BrowserCleanupError('browser child ownership is unconfirmed');
|
if (!identity || identity.daemon !== daemon || identity.root) throw new BrowserCleanupError('browser child ownership is unconfirmed');
|
||||||
if (await live(operation, identity) && (typeof start !== 'string' || !start
|
if (await live(operation, identity) && (typeof start !== 'string' || !start
|
||||||
|| await nativeStart(operation, identity) !== start.replace(/\s+/g, ' '))) throw new BrowserCleanupError('browser child identity was replaced');
|
|| await nativeStart(operation, identity) !== start.replace(/\s+/g, ' '))) throw new BrowserCleanupError('browser child identity was replaced');
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ test.each([
|
|||||||
expect(facts.receipts.filter((receipt: any) => receipt.outcome === 'timeout')).toHaveLength(3 - scenario.passed);
|
expect(facts.receipts.filter((receipt: any) => receipt.outcome === 'timeout')).toHaveLength(3 - scenario.passed);
|
||||||
expect(facts.receipts.every((receipt: any) => receipt.timeoutMs === 240_000 && !receipt.answered)).toBe(true);
|
expect(facts.receipts.every((receipt: any) => receipt.timeoutMs === 240_000 && !receipt.answered)).toBe(true);
|
||||||
expect(facts.active).toBe(0); expect(facts.answers).toBe(0); expect(facts.pendingTimers).toBe(0);
|
expect(facts.active).toBe(0); expect(facts.answers).toBe(0); expect(facts.pendingTimers).toBe(0);
|
||||||
expect(facts.leftovers).toEqual([]); expect(facts.ownedStateLeftovers).toEqual([]);
|
expect(facts.leftovers).toEqual([]);
|
||||||
|
expect(facts.ownedStateLeftovers, JSON.stringify({ fixtures: facts.fixtures, inputs: facts.inputs.map((input: any) => [input.id, input.config, input.state]), events: facts.events.filter((event: any) => ['query-close', 'capture-settle', 'cleanup'].includes(event.kind)) })).toEqual([]);
|
||||||
expect(facts.events.filter((event: any) => event.kind === 'query-close')).toHaveLength(scenario.queries);
|
expect(facts.events.filter((event: any) => event.kind === 'query-close')).toHaveLength(scenario.queries);
|
||||||
expect(facts.events.filter((event: any) => event.kind === 'cleanup')).toHaveLength(3);
|
expect(facts.events.filter((event: any) => event.kind === 'cleanup')).toHaveLength(3);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|||||||
@@ -1326,7 +1326,9 @@ describe('Autoplan phase entry through the gbrain :user render (#2569)', () => {
|
|||||||
edit(rewriteSectionBase(fs.readFileSync(path.join(ROOT, 'autoplan/sections', name), 'utf8'), linkRoot)));
|
edit(rewriteSectionBase(fs.readFileSync(path.join(ROOT, 'autoplan/sections', name), 'utf8'), linkRoot)));
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
function withEnv<T>(env: Record<string, string | undefined>, work: () => T): T {
|
/** The state-root chain the case does not name is cleared, so an ambient GSTACK_STATE_ROOT (every gstack launch file exports one) cannot pick the root. */
|
||||||
|
function withEnv<T>(given: Record<string, string | undefined>, work: () => T): T {
|
||||||
|
const env = { GSTACK_STATE_ROOT: undefined, GSTACK_STATE_DIR: undefined, CLAUDE_PLUGIN_DATA: undefined, ...given };
|
||||||
const previous = Object.fromEntries(Object.keys(env).map(k => [k, process.env[k]]));
|
const previous = Object.fromEntries(Object.keys(env).map(k => [k, process.env[k]]));
|
||||||
for (const [k, v] of Object.entries(env)) if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
for (const [k, v] of Object.entries(env)) if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
||||||
try { return work(); }
|
try { return work(); }
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ describe.skipIf(process.platform !== 'linux')('bootstrap paid-shard cleanup inte
|
|||||||
fs.writeFileSync(path.join(root, 'controller.stdout.log'), controller.stdout ?? '', { mode: 0o600 });
|
fs.writeFileSync(path.join(root, 'controller.stdout.log'), controller.stdout ?? '', { mode: 0o600 });
|
||||||
fs.writeFileSync(path.join(root, 'controller.stderr.log'), controller.stderr ?? '', { mode: 0o600 });
|
fs.writeFileSync(path.join(root, 'controller.stderr.log'), controller.stderr ?? '', { mode: 0o600 });
|
||||||
expect(controller.error).toBeUndefined();
|
expect(controller.error).toBeUndefined();
|
||||||
expect(controller.status).toBe(0);
|
expect(controller.status, `controller exited ${controller.status} (signal ${controller.signal}); stderr tail: ${(controller.stderr ?? '').slice(-600).replace(/\s*\n\s*/g, ' | ')}`).toBe(0);
|
||||||
const outcome = JSON.parse(fs.readFileSync(outcomePath, 'utf8'));
|
const outcome = JSON.parse(fs.readFileSync(outcomePath, 'utf8'));
|
||||||
if (scenario === 'ack-failure') {
|
if (scenario === 'ack-failure') {
|
||||||
expect(controller.stdout).toContain('(fail) qa-bootstrap');
|
expect(controller.stdout).toContain('(fail) qa-bootstrap');
|
||||||
|
|||||||
@@ -247,6 +247,31 @@ describe('probe lock recovery and failure', () => {
|
|||||||
expect(calls(f)).toBe(1);
|
expect(calls(f)).toBe(1);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
test('a lock released while a waiter reads its age is not abandoned; the next owner\'s fresh lock survives', withFixture((f) => {
|
||||||
|
const sig = sigOf(f);
|
||||||
|
const lock = path.join(f.locks, sig);
|
||||||
|
const once = path.join(f.home, 'vanish-once');
|
||||||
|
const real = JSON.stringify(Bun.which('stat'));
|
||||||
|
fs.mkdirSync(lock, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(f.home, 'bin/stat'), `#!/usr/bin/env bash
|
||||||
|
if [ "$3" = "$RACE_LOCK" ] && [ -f "$RACE_ONCE" ]; then
|
||||||
|
case "$1" in
|
||||||
|
-c) rmdir "$RACE_LOCK"; [ "$RACE_PHASE" = early ] && mkdir "$RACE_LOCK"; exit 1 ;;
|
||||||
|
-f) ${real} "$@"; rc=$?; rm -f "$RACE_ONCE"; [ -d "$RACE_LOCK" ] || mkdir "$RACE_LOCK"; exit "$rc" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
exec ${real} "$@"
|
||||||
|
`, { mode: 0o755 });
|
||||||
|
for (const phase of ['early', 'late']) {
|
||||||
|
fs.writeFileSync(once, 'armed');
|
||||||
|
const r = probe(f, { _GSTACK_CODEX_LOCK_WAIT: '1', RACE_LOCK: lock, RACE_ONCE: once, RACE_PHASE: phase });
|
||||||
|
expect(fs.existsSync(once)).toBe(false);
|
||||||
|
expect(r.stdout).toContain('MODEL_PROBE_INCONCLUSIVE (another probe of this model still holds its lock)');
|
||||||
|
expect(fs.existsSync(lock)).toBe(true);
|
||||||
|
expect(calls(f)).toBe(0);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
test('an unreadable cache or uncreatable lock is unverified and launches no paid probe', withFixture((f) => {
|
test('an unreadable cache or uncreatable lock is unverified and launches no paid probe', withFixture((f) => {
|
||||||
fs.mkdirSync(f.cache);
|
fs.mkdirSync(f.cache);
|
||||||
const dir = probe(f);
|
const dir = probe(f);
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ describe('gstack-evidence run', () => {
|
|||||||
expect(fs.readFileSync(rec.log_path, 'utf-8')).toContain('ok');
|
expect(fs.readFileSync(rec.log_path, 'utf-8')).toContain('ok');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the lane command sees its own log path as GSTACK_EVIDENCE_LOG (the free runner\'s home tripwire excludes exactly that file)', () => {
|
||||||
|
const r = run(['run', '--label', 'focused', '--', 'printf "%s" "$GSTACK_EVIDENCE_LOG"']);
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
const rec = records().pop();
|
||||||
|
expect(path.isAbsolute(rec.log_path)).toBe(true);
|
||||||
|
expect(r.stdout).toBe(rec.log_path);
|
||||||
|
});
|
||||||
|
|
||||||
test('propagates a failing exit code and records it', () => {
|
test('propagates a failing exit code and records it', () => {
|
||||||
const r = run(['run', '--label', 'tests', '--', 'exit 3']);
|
const r = run(['run', '--label', 'tests', '--', 'exit 3']);
|
||||||
expect(r.status).toBe(3);
|
expect(r.status).toBe(3);
|
||||||
|
|||||||
@@ -78,6 +78,34 @@ describe('free-suite home-write tripwire (#2895)', () => {
|
|||||||
expect(failure).toContain('only');
|
expect(failure).toContain('only');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.skipIf(process.platform === 'win32')('the wrapping gstack-evidence lane log is excluded by its exact path; a foreign write beside it still trips', async () => {
|
||||||
|
const home = fakeHome();
|
||||||
|
const logs = path.join(home, '.gstack/projects/p/logs');
|
||||||
|
fs.mkdirSync(logs, { recursive: true });
|
||||||
|
const laneLog = path.join(logs, 'focused.log');
|
||||||
|
fs.writeFileSync(laneLog, 'lane start\n');
|
||||||
|
const unexcluded = guardFreeHome(['test/a.test.ts'], { HOME: home });
|
||||||
|
const options = (script: string, name: string) => ({ env: { ...process.env, HOME: home, GSTACK_EVIDENCE_LOG: laneLog }, quiet: true, log: () => {},
|
||||||
|
logFilePath: path.join(home, `${name}.log`),
|
||||||
|
commandFor: () => ({ command: process.execPath, args: ['-e', `${script}console.log('Ran 1 tests across 1 files. [1.00ms]')`] }) });
|
||||||
|
const grow = `require('fs').appendFileSync(${JSON.stringify(laneLog)}, 'lane output\\n');`;
|
||||||
|
const errors: string[] = [];
|
||||||
|
const spy = spyOn(console, 'error').mockImplementation((...args: unknown[]) => { errors.push(args.join(' ')); });
|
||||||
|
try {
|
||||||
|
expect((await runFreeShard(['test/a.test.ts'], 1, 1, options(grow, 'lane'))).status).toBe('passed');
|
||||||
|
expect(unexcluded.verify()).toContain('~/.gstack/projects/p/logs/focused.log');
|
||||||
|
const foreign = `${grow}require('fs').writeFileSync(${JSON.stringify(path.join(logs, 'other.log'))}, 'x');`;
|
||||||
|
expect((await runFreeShard(['test/a.test.ts'], 1, 1, options(foreign, 'foreign'))).status).toBe('failed');
|
||||||
|
expect(errors.join('\n')).toContain('~/.gstack/projects/p/logs/other.log');
|
||||||
|
expect(errors.join('\n')).not.toContain('focused.log');
|
||||||
|
} finally {
|
||||||
|
spy.mockRestore();
|
||||||
|
for (const retained of errors.flatMap(line => /retained (\S+)$/.exec(line)?.[1] ?? [])) {
|
||||||
|
if (/^gstack-free-shard-/.test(path.basename(retained))) fs.rmSync(retained, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('a concurrent phase guard names no shard and points at attribution', () => {
|
test('a concurrent phase guard names no shard and points at attribution', () => {
|
||||||
const home = fakeHome();
|
const home = fakeHome();
|
||||||
const guard = guardFreeHome(['test/a.test.ts', 'test/b.test.ts'], { HOME: home }, { kind: 'concurrent', shards: 3 });
|
const guard = guardFreeHome(['test/a.test.ts', 'test/b.test.ts'], { HOME: home }, { kind: 'concurrent', shards: 3 });
|
||||||
|
|||||||
@@ -1460,8 +1460,13 @@ describe('scan: option-like bases and page-controlled inline ignores', () => {
|
|||||||
fs.mkdirSync(many, { recursive: true });
|
fs.mkdirSync(many, { recursive: true });
|
||||||
for (let i = 0; i < 1100; i++) fs.writeFileSync(path.join(many, `f${i}.css`), 'a{}');
|
for (let i = 0; i < 1100; i++) fs.writeFileSync(path.join(many, `f${i}.css`), 'a{}');
|
||||||
try {
|
try {
|
||||||
const r = run(['scan', '--format', 'gstack', 'src/many'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_SLEEP_MS: '1000', GSTACK_DESIGN_DETECT_TIMEOUT_MS: '1500' } });
|
const log = path.join(SANDBOX, 'argv-many.log');
|
||||||
|
fs.rmSync(log, { force: true });
|
||||||
|
const r = run(['scan', '--format', 'gstack', 'src/many'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_LOG: log } });
|
||||||
expect(r.code).toBe(2); // a directory target is one batch (the fake reports findings): the budget test needs files
|
expect(r.code).toBe(2); // a directory target is one batch (the fake reports findings): the budget test needs files
|
||||||
|
const calls = fs.readFileSync(log, 'utf-8').trim().split('\n').map(l => JSON.parse(l).argv as string[]);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0].some(x => x.endsWith(path.join('src', 'many')))).toBe(true);
|
||||||
const files = fs.readdirSync(many).map(f => path.join('src', 'many', f));
|
const files = fs.readdirSync(many).map(f => path.join('src', 'many', f));
|
||||||
const r2 = run(['scan', '--format', 'gstack', ...files], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_SLEEP_MS: '1000', GSTACK_DESIGN_DETECT_TIMEOUT_MS: '1500' } });
|
const r2 = run(['scan', '--format', 'gstack', ...files], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_SLEEP_MS: '1000', GSTACK_DESIGN_DETECT_TIMEOUT_MS: '1500' } });
|
||||||
expect(r2.err).toMatch(/DETECT_TIMEOUT: whole-scan budget 7500ms exceeded, \d+ of 11 batches not run/);
|
expect(r2.err).toMatch(/DETECT_TIMEOUT: whole-scan budget 7500ms exceeded, \d+ of 11 batches not run/);
|
||||||
|
|||||||
@@ -312,7 +312,8 @@ describe('Office Hours real session runner with fake processes', () => {
|
|||||||
expect(captured.output).toBe('captured output');
|
expect(captured.output).toBe('captured output');
|
||||||
const diagnostic = JSON.parse(fs.readFileSync(path.join(dir, '.gstack/test-transcripts/drain-failure.json'), 'utf8'));
|
const diagnostic = JSON.parse(fs.readFileSync(path.join(dir, '.gstack/test-transcripts/drain-failure.json'), 'utf8'));
|
||||||
expect(diagnostic.stderr).toContain('fixture auth failure');
|
expect(diagnostic.stderr).toContain('fixture auth failure');
|
||||||
expect(running(Number(fs.readFileSync(path.join(dir, 'child.pid'), 'utf8')))).toBe(false);
|
const child = fs.readFileSync(path.join(dir, 'child.pid'), 'utf8').trim();
|
||||||
|
expect(running(Number(child)), `held-pipe child after the runner returned (group ${fs.readFileSync(path.join(dir, 'parent.pid'), 'utf8').trim()}): ${spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,stat=,etime=', '-p', child], { encoding: 'utf8', timeout: 5_000 }).stdout.trim() || 'gone now'}`).toBe(false);
|
||||||
});
|
});
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
* the base branch and every other open PR, and a lookup that cannot finish is
|
* the base branch and every other open PR, and a lookup that cannot finish is
|
||||||
* `unknown`, which withholds `REGATE: not-needed`.
|
* `unknown`, which withholds `REGATE: not-needed`.
|
||||||
*/
|
*/
|
||||||
import { afterEach, describe, expect, test } from 'bun:test';
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { EXIT } from '../lib/headless-artifacts';
|
import { EXIT } from '../lib/headless-artifacts';
|
||||||
import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
import { gitFixtureTemplate, rebindFile, rebindGitDir, type GitFixtureTemplate } from './helpers/git-fixture-template';
|
||||||
|
import { BASE_POLICY, cleanup, fixtureRepoAt, makePr, recordAndBundle, restamp, runBin, seedFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture';
|
||||||
|
|
||||||
const repos: FixtureRepo[] = [];
|
const repos: FixtureRepo[] = [];
|
||||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||||
@@ -39,10 +40,14 @@ console.error("gh shim: unsupported "+a.join(" ")); process.exit(1)' "${file}" "
|
|||||||
}
|
}
|
||||||
const ghCalls = (repo: FixtureRepo) => fs.readFileSync(path.join(repo.root, 'gh-regate', 'calls.log'), 'utf8');
|
const ghCalls = (repo: FixtureRepo) => fs.readFileSync(path.join(repo.root, 'gh-regate', 'calls.log'), 'utf8');
|
||||||
|
|
||||||
|
type Lanes = Array<{ label: string; command: string }>;
|
||||||
|
const TESTS_LANE: Lanes = [{ label: 'tests', command: 'true' }];
|
||||||
|
const DIGEST_LANES: Lanes = [...TESTS_LANE, { label: 'digest', command: 'cat generated/digest.md' }];
|
||||||
|
|
||||||
/** pr-a adds migration v240, is stamped 1.2.4.0 and gated (lanes recorded and bundled); then restamped to 1.2.5.0. */
|
/** pr-a adds migration v240, is stamped 1.2.4.0 and gated (lanes recorded and bundled); then restamped to 1.2.5.0. */
|
||||||
function gatedThenRestamped(lanes: Array<{ label: string; command: string }> = [{ label: 'tests', command: 'true' }]) {
|
function buildGated(root: string, lanes: Lanes) {
|
||||||
const repo = makeFixtureRepo({ prefix: 'pregate-regate-', policy: POLICY });
|
seedFixtureRepo(root, { policy: POLICY });
|
||||||
repos.push(repo);
|
const repo = fixtureRepoAt(root);
|
||||||
makePr(repo, 'pr-a', { expects: '1.2.4.0' });
|
makePr(repo, 'pr-a', { expects: '1.2.4.0' });
|
||||||
fs.mkdirSync(path.join(repo.work, 'migrations'), { recursive: true });
|
fs.mkdirSync(path.join(repo.work, 'migrations'), { recursive: true });
|
||||||
fs.writeFileSync(path.join(repo.work, 'migrations', 'v240-add-index.sql'), 'create index a;\n');
|
fs.writeFileSync(path.join(repo.work, 'migrations', 'v240-add-index.sql'), 'create index a;\n');
|
||||||
@@ -54,7 +59,39 @@ function gatedThenRestamped(lanes: Array<{ label: string; command: string }> = [
|
|||||||
const head = repo.git('rev-parse', 'HEAD');
|
const head = repo.git('rev-parse', 'HEAD');
|
||||||
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
|
expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok);
|
||||||
repo.git('commit', '-qam', 'stamp 1.2.5.0');
|
repo.git('commit', '-qam', 'stamp 1.2.5.0');
|
||||||
return { repo, head, bundle, current: repo.git('rev-parse', 'HEAD') };
|
return { head, current: repo.git('rev-parse', 'HEAD') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each fixture costs four gstack bin runs (two restamps, an evidence run and a bundle); build each lane set once
|
||||||
|
// and give every test its own copy instead of rebuilding it per test.
|
||||||
|
const templates = new Map<Lanes, GitFixtureTemplate<ReturnType<typeof buildGated>>>();
|
||||||
|
beforeAll(() => {
|
||||||
|
for (const lanes of [TESTS_LANE, DIGEST_LANES]) {
|
||||||
|
const template = gitFixtureTemplate({
|
||||||
|
prefix: 'pregate-regate-',
|
||||||
|
build: root => buildGated(root, lanes),
|
||||||
|
rebind: (copy, tpl) => {
|
||||||
|
rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy);
|
||||||
|
rebindFile(path.join(copy, 'bundle.json'), tpl, copy);
|
||||||
|
const projects = path.join(copy, 'home', 'projects');
|
||||||
|
for (const slug of fs.existsSync(projects) ? fs.readdirSync(projects) : []) {
|
||||||
|
const dir = path.join(projects, slug.split(path.basename(tpl)).join(path.basename(copy)));
|
||||||
|
if (dir !== path.join(projects, slug)) fs.renameSync(path.join(projects, slug), dir);
|
||||||
|
for (const file of fs.readdirSync(dir, { recursive: true, withFileTypes: true })) if (file.isFile()) rebindFile(path.join(file.parentPath, file.name), tpl, copy);
|
||||||
|
}
|
||||||
|
fs.rmSync(path.join(copy, 'home', '.capy'), { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
template.copy().root && templates.set(lanes, template);
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
afterAll(() => { for (const t of templates.values()) t.dispose(); });
|
||||||
|
|
||||||
|
function gatedThenRestamped(lanes: Lanes = TESTS_LANE) {
|
||||||
|
const { root, meta: { head, current } } = templates.get(lanes)!.copy();
|
||||||
|
const repo = fixtureRepoAt(root);
|
||||||
|
repos.push(repo);
|
||||||
|
return { repo, head, bundle: path.join(repo.root, 'bundle.json'), current };
|
||||||
}
|
}
|
||||||
const regate = (repo: FixtureRepo, args: string[], env: Record<string, string>) => runBin(repo, 'gstack-pregate', ['regate', '--base', 'main', ...args], env);
|
const regate = (repo: FixtureRepo, args: string[], env: Record<string, string>) => runBin(repo, 'gstack-pregate', ['regate', '--base', 'main', ...args], env);
|
||||||
|
|
||||||
@@ -97,7 +134,7 @@ describe('gstack-pregate regate: evidence, stamps and lane verdicts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a stamp-sensitive lane reruns, so a regate is needed; a code edit is not stamp-only and names the file and line', () => {
|
test('a stamp-sensitive lane reruns, so a regate is needed; a code edit is not stamp-only and names the file and line', () => {
|
||||||
const { repo, head, bundle } = gatedThenRestamped([{ label: 'tests', command: 'true' }, { label: 'digest', command: 'cat generated/digest.md' }]);
|
const { repo, head, bundle } = gatedThenRestamped(DIGEST_LANES);
|
||||||
const env = ghShim(repo, { list: [] });
|
const env = ghShim(repo, { list: [] });
|
||||||
const r = regate(repo, ['--against', head, '--bundle', bundle], env);
|
const r = regate(repo, ['--against', head, '--bundle', bundle], env);
|
||||||
expect(r.status).toBe(EXIT.fail);
|
expect(r.status).toBe(EXIT.fail);
|
||||||
|
|||||||
@@ -284,7 +284,8 @@ describe('gstack-session-update lock identity + TTL (#2613)', () => {
|
|||||||
const r = runScript(base, install, state);
|
const r = runScript(base, install, state);
|
||||||
expect(r.status).toBe(0);
|
expect(r.status).toBe(0);
|
||||||
const log = await waitForLog(state, /UP_TO_DATE|UPDATING/);
|
const log = await waitForLog(state, /UP_TO_DATE|UPDATING/);
|
||||||
expect(log).toMatch(/UP_TO_DATE|UPDATING/);
|
const lockPid = fs.existsSync(path.join(lockDir, 'pid')) ? fs.readFileSync(path.join(lockDir, 'pid'), 'utf8').trim() : 'no lock';
|
||||||
|
expect(log, `planted dead pid ${dead.pid} (alive now: ${isAlive(dead.pid)}); lock pid after 15 s: ${lockPid} (alive: ${isAlive(Number(lockPid))}); script exit ${r.status}, stderr: ${r.stderr}`).toMatch(/UP_TO_DATE|UPDATING/);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(base, { recursive: true, force: true });
|
fs.rmSync(base, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,10 @@ describe('shard engine: process lifecycle', () => {
|
|||||||
});
|
});
|
||||||
expect(paid.status).toBe('timed-out');
|
expect(paid.status).toBe('timed-out');
|
||||||
expect(summaryExitCode(summarize([paid]))).toBe(1);
|
expect(summaryExitCode(summarize([paid]))).toBe(1);
|
||||||
expect(await waitDead(Number(fs.readFileSync(pidFile, 'utf8')))).toBe(true);
|
const paidChild = Number(fs.readFileSync(pidFile, 'utf8'));
|
||||||
|
const paidChildDead = await waitDead(paidChild);
|
||||||
|
const procStat = (pid: number) => { try { return fs.readFileSync(`/proc/${pid}/stat`, 'utf8').trim(); } catch { return 'no /proc entry'; } };
|
||||||
|
expect(paidChildDead, `paid grandchild ${paidChild} of group ${paid.groupPid} 2 s after the timeout kill: ${procStat(paidChild)}`).toBe(true);
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
test('win32 semantics: no process group and never a negative-pid kill', async () => {
|
test('win32 semantics: no process group and never a negative-pid kill', async () => {
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ describe('Claude Code child launch', () => {
|
|||||||
const file = ho.out.match(/^HANDOFF: (\S+)/m)![1]!;
|
const file = ho.out.match(/^HANDOFF: (\S+)/m)![1]!;
|
||||||
const argsFile = path.join(h.root, 'claude-args.txt');
|
const argsFile = path.join(h.root, 'claude-args.txt');
|
||||||
const fake = path.join(h.root, 'fake-claude');
|
const fake = path.join(h.root, 'fake-claude');
|
||||||
fs.writeFileSync(fake, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsFile}"\n`, { mode: 0o755 });
|
fs.writeFileSync(fake, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsFile}.tmp" && mv "${argsFile}.tmp" "${argsFile}"\n`, { mode: 0o755 });
|
||||||
const r = h.sb(['launch', '--in', file], { GSTACK_CLAUDE_BIN: fake });
|
const r = h.sb(['launch', '--in', file], { GSTACK_CLAUDE_BIN: fake });
|
||||||
expect(r.code).toBe(0);
|
expect(r.code).toBe(0);
|
||||||
const session = h.field(r.out, 'CHILD', 'session');
|
const session = h.field(r.out, 'CHILD', 'session');
|
||||||
|
|||||||
@@ -35,13 +35,16 @@ const result = (out: string) => line(out, 'GSTACK_RESULT:');
|
|||||||
const next = (out: string) => line(out, 'NEXT:');
|
const next = (out: string) => line(out, 'NEXT:');
|
||||||
const remoteRefs = (fx: TrainFixture) => fx.repo.git('ls-remote', 'origin');
|
const remoteRefs = (fx: TrainFixture) => fx.repo.git('ls-remote', 'origin');
|
||||||
|
|
||||||
// fails_when=coordinatorClone copies origin.git/objects file by file again, so an entry that appears or vanishes there mid-copy (the detached maintenance.lock every push into the fixture origin leaves) fails the clone, as in two Free Tests reds on 2026-10-28 (GSTA-139 ledger); why_new=no other test clones while origin's objects directory changes; seam=a dangling symlink planted under objects/, which a local copy refuses on every run; no_claim=which process removed the loose object those runs named
|
// fails_when=coordinatorClone copies origin.git/objects file by file again, so an entry that appears or vanishes there mid-copy (the detached maintenance.lock every push into the fixture origin leaves) fails the clone, as in two Free Tests reds on 2026-10-28 (GSTA-139 ledger); why_new=no other test clones while origin's objects directory changes; seam=a dangling symlink planted under objects/ beside a held maintenance.lock, which a local copy refuses on every run; the seam has its own name because the fixture push's own detached maintenance can hold objects/maintenance.lock at that moment (EEXIST in Free Tests 41288253305 and 41288417894, 2026-11-01); no_claim=which process removed the loose object those runs named
|
||||||
test('the coordinator clone reads origin through git, never by copying its objects directory (GSTA-139)', () => {
|
test('the coordinator clone reads origin through git, never by copying its objects directory (GSTA-139)', () => {
|
||||||
const fx = make();
|
const fx = make();
|
||||||
fs.symlinkSync(path.join(fx.repo.root, 'gone'), path.join(fx.repo.origin, 'objects', 'maintenance.lock'));
|
const objects = path.join(fx.repo.origin, 'objects');
|
||||||
|
fs.writeFileSync(path.join(objects, 'maintenance.lock'), '');
|
||||||
|
fs.symlinkSync(path.join(fx.repo.root, 'gone'), path.join(objects, 'gstack-dangling-seam'));
|
||||||
const coord = coordinatorClone(fx.repo);
|
const coord = coordinatorClone(fx.repo);
|
||||||
expect(spawnSync('git', ['-C', coord, 'rev-parse', 'origin/main'], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } }).stdout.trim()).toBe(fx.repo.git('rev-parse', 'origin/main'));
|
expect(spawnSync('git', ['-C', coord, 'rev-parse', 'origin/main'], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } }).stdout.trim()).toBe(fx.repo.git('rev-parse', 'origin/main'));
|
||||||
expect(fs.existsSync(path.join(coord, '.git', 'objects', 'maintenance.lock'))).toBe(false);
|
expect(fs.existsSync(path.join(coord, '.git', 'objects', 'maintenance.lock'))).toBe(false);
|
||||||
|
expect(fs.readdirSync(path.join(coord, '.git', 'objects'))).not.toContain('gstack-dangling-seam');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('the train command surface', () => {
|
describe('the train command surface', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user