mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-12 16:08:59 +02:00
spawnSync/execSync/Bun.spawnSync BLOCK the main thread, so bun's in-process per-test timeout can never fire while one waits — a hung child (stdin read, network probe, dead daemon) wedges the whole shard until the runner's external wall-clock SIGKILL. This exact class reached main: free-tests run 33262077256, test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2 at the 360s wall while its five siblings finished in ~65s. Mechanical sweep in two waves (12 + 4 fan-out agents, every edit verified against its call site): default timeout: 30_000 (matches the free runner's per-test budget), 120_000 for genuinely slow ops (installs, builds, playwright, provider CLIs), helper wrappers fixed ONCE where call sites route through them. Sites that only LOOK like calls (string fixtures, grep needles, comments) were skipped with reasons — the enforcement commit that follows marks them exempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
146 lines
6.5 KiB
TypeScript
146 lines
6.5 KiB
TypeScript
/**
|
|
* AUTO_DECIDE opt-in preserved under Conductor flags (periodic-tier, paid, real-PTY).
|
|
*
|
|
* Regression test for v1.21+ fix: the new "Tool resolution" preamble
|
|
* (scripts/resolvers/preamble/generate-ask-user-format.ts) tells the model
|
|
* to prefer mcp__*__AskUserQuestion variants and fall back to plan-file
|
|
* decisions when neither is callable. This must NOT break the legitimate
|
|
* `/plan-tune` AUTO_DECIDE path: when the user has explicitly opted into
|
|
* auto-deciding a specific question via `gstack-question-preference --write
|
|
* never-ask`, the model is supposed to honor that — it should still
|
|
* auto-pick the recommended option and emit the AUTO_DECIDE annotation
|
|
* ("Auto-decided <summary> → <option> (your preference). Change with
|
|
* /plan-tune.") instead of opening a question prompt.
|
|
*
|
|
* Periodic tier: AUTO_DECIDE behavior depends on the model adhering to
|
|
* the QUESTION_TUNING preamble injection. Non-deterministic; runs weekly
|
|
* or manually rather than gating CI.
|
|
*
|
|
* Set up:
|
|
* - tmpDir as GSTACK_HOME (isolated state, doesn't touch the user's
|
|
* real ~/.gstack)
|
|
* - question_tuning=true in the tmp config
|
|
* - preference for plan-ceo-review-mode → never-ask (source: plan-tune)
|
|
*
|
|
* Spawn:
|
|
* claude --permission-mode plan --disallowedTools AskUserQuestion
|
|
* /plan-ceo-review
|
|
*
|
|
* Expected:
|
|
* - outcome === 'auto_decided' (the AUTO_DECIDE preamble fired and the
|
|
* "Auto-decided ... (your preference)" text rendered)
|
|
*
|
|
* If outcome is 'asked', the model ignored the user's `/plan-tune`
|
|
* preference — that's a regression against the opt-in feature. If outcome
|
|
* is 'plan_ready' with no AUTO_DECIDE text, the model auto-decided BUT
|
|
* skipped the annotation (acceptable; AUTO_DECIDE annotation is good
|
|
* practice but not the load-bearing behavior).
|
|
*/
|
|
|
|
import { test, expect } from 'bun:test';
|
|
import { CAPTURE_LONG_MS, PTY_MS } from './helpers/eval-budgets';
|
|
import { describeE2ETier } from './helpers/e2e-gate';
|
|
import { runPlanSkillObservation } from './helpers/claude-pty-runner';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { spawnSync } from 'child_process';
|
|
|
|
const describeE2E = describeE2ETier('periodic');
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
|
|
describeE2E('AUTO_DECIDE opt-in preserved under Conductor flags (periodic)', () => {
|
|
test('user-opted-in question still auto-decides when AskUserQuestion is --disallowedTools', async () => {
|
|
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-auto-decide-'));
|
|
try {
|
|
// 1. Bootstrap the tmp GSTACK_HOME with question_tuning=true.
|
|
const configBin = path.join(ROOT, 'bin', 'gstack-config');
|
|
const setRes = spawnSync(configBin, ['set', 'question_tuning', 'true'], {
|
|
env: { ...process.env, GSTACK_HOME: tmpHome },
|
|
encoding: 'utf-8',
|
|
timeout: 30_000,
|
|
});
|
|
if (setRes.status !== 0) {
|
|
throw new Error(`gstack-config set failed: ${setRes.stderr || setRes.stdout}`);
|
|
}
|
|
|
|
// 2. Resolve slug for the project (uses git remote — same as the spawned
|
|
// claude would resolve). The preference file path keys on this slug.
|
|
const slugBin = path.join(ROOT, 'bin', 'gstack-slug');
|
|
const slugRes = spawnSync(slugBin, [], {
|
|
// LIVE-REPO CWD: gstack-slug resolves the slug from this repo's git
|
|
// remote — must match what the spawned claude (repo cwd) resolves.
|
|
cwd: ROOT,
|
|
env: { ...process.env, GSTACK_HOME: tmpHome },
|
|
encoding: 'utf-8',
|
|
timeout: 30_000,
|
|
});
|
|
// gstack-slug emits `eval`-able shell exports like `SLUG=garrytan-gstack`.
|
|
const slug = (slugRes.stdout.match(/SLUG=([^\s;]+)/)?.[1] ?? 'unknown').replace(/['"]/g, '');
|
|
|
|
// 3. Write the preference: plan-ceo-review-mode → never-ask. The
|
|
// 'plan-tune' source bypasses the inline-user origin gate.
|
|
const prefBin = path.join(ROOT, 'bin', 'gstack-question-preference');
|
|
const writeRes = spawnSync(
|
|
prefBin,
|
|
['--write', JSON.stringify({
|
|
question_id: 'plan-ceo-review-mode',
|
|
preference: 'never-ask',
|
|
source: 'plan-tune',
|
|
})],
|
|
{
|
|
env: { ...process.env, GSTACK_HOME: tmpHome },
|
|
encoding: 'utf-8',
|
|
timeout: 30_000,
|
|
},
|
|
);
|
|
if (writeRes.status !== 0) {
|
|
throw new Error(`gstack-question-preference --write failed: ${writeRes.stderr || writeRes.stdout}`);
|
|
}
|
|
|
|
// Sanity: the preference file landed where we expect.
|
|
const prefFile = path.join(tmpHome, 'projects', slug, 'question-preferences.json');
|
|
if (!fs.existsSync(prefFile)) {
|
|
throw new Error(`expected preference file at ${prefFile}; not found. slug=${slug}`);
|
|
}
|
|
|
|
// 4. Run /plan-ceo-review with the Conductor flag set + isolated state.
|
|
// GSTACK_HOME=tmpHome is REQUIRED: the preference + question_tuning were
|
|
// seeded there. Without it the spawned claude reads the real ~/.gstack,
|
|
// never sees the never-ask preference, and the test silently exercises
|
|
// the wrong state root (pre-existing bug, Codex #9 / Issue 13).
|
|
// CONDUCTOR_WORKSPACE_PATH additionally proves auto-decide still WINS
|
|
// over the Conductor prose redirect (precedence: settled preference
|
|
// beats transport-avoidance).
|
|
const obs = await runPlanSkillObservation({
|
|
skillName: 'plan-ceo-review',
|
|
inPlanMode: true,
|
|
extraArgs: ['--disallowedTools', 'AskUserQuestion'],
|
|
timeoutMs: CAPTURE_LONG_MS,
|
|
env: { GSTACK_HOME: tmpHome, CONDUCTOR_WORKSPACE_PATH: tmpHome },
|
|
});
|
|
|
|
// 5. Pass: 'auto_decided' (the strongest signal) or 'plan_ready' with
|
|
// no question rendered. Fail: 'asked' (model ignored the opt-in).
|
|
if (obs.outcome === 'asked') {
|
|
throw new Error(
|
|
`AUTO_DECIDE regression: the model surfaced an AskUserQuestion despite the user's never-ask preference.\n` +
|
|
`summary: ${obs.summary}\n` +
|
|
`--- evidence (last 2KB visible) ---\n${obs.evidence}`,
|
|
);
|
|
}
|
|
if (obs.outcome === 'silent_write' || obs.outcome === 'exited' || obs.outcome === 'timeout') {
|
|
throw new Error(
|
|
`AUTO_DECIDE preserve test inconclusive: outcome=${obs.outcome}\n` +
|
|
`summary: ${obs.summary}\n` +
|
|
`--- evidence (last 2KB visible) ---\n${obs.evidence}`,
|
|
);
|
|
}
|
|
expect(['auto_decided', 'plan_ready']).toContain(obs.outcome);
|
|
} finally {
|
|
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
}
|
|
}, PTY_MS);
|
|
});
|