Scope headless detection to the path that opens a browser

The new self-detection ran before mode dispatch, so it also caught --wait,
--stop, and --schema. CI failed on the start/wait cycle test: --wait
returned 2 (no browser) where the documented poll loop expects 3
(WAITING). Under CI=1 the suite went 4 pass / 2 fail; it is 6 / 0 now.

Two of those modes were user-facing bugs, not just test breakage. --stop
exited 2 without killing the daemon it was asked to kill, leaking a
server process (verified: one daemon running, CI=1 --stop, still one).
--schema only prints a payload example, and new-work.md tells the agent
to read it before building a payload.

Detection can only tell whether this process can auto-open a browser, not
whether the user has one: SSH with a forwarded port and a harness with an
in-app browser both have a browser and no DISPLAY. The file already
treats serve-without-opening as first class, since --start spawns its own
daemon with --no-open. So the check now gates acquiring a session, not
managing or ending one. The blocking serve path still exits 2 on a
headless box, with a test pinning that.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-21 22:06:55 -07:00
co-authored by Claude
parent 5575a027dc
commit ea68bb4722
2 changed files with 53 additions and 3 deletions
+45
View File
@@ -90,6 +90,51 @@ describe('serve-question', () => {
assert.match(collected.out, /"optionId":"assigned"/);
});
it('headless detection spares the modes that never open a browser', async () => {
// Only the blocking serve path auto-opens a URL. --wait polls a daemon
// that is already running, --stop kills one, --schema just prints text,
// so a headless environment must not turn any of them into exit 2: the
// documented flow polls --wait while it exits 3, and new-work.md tells
// the agent to read --schema first.
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
const headlessEnv = { ...process.env, CI: '1' };
delete headlessEnv.IMPECCABLE_QUESTION_FORCE;
const run = (args) => new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, env: headlessEnv, stdio: ['ignore', 'pipe', 'ignore'] });
let out = '';
child.stdout.on('data', (chunk) => { out += chunk; });
child.on('exit', (code) => resolve({ code, out }));
});
const schema = await run(['--schema']);
assert.equal(schema.code, 0, `--schema under CI must print, got ${schema.code}: ${schema.out}`);
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'hk']);
assert.equal(started.code, 0, started.out);
try {
const waiting = await run(['--wait', '--key', 'hk', '--poll', '1']);
assert.equal(waiting.code, 3, `--wait under CI must report WAITING, got ${waiting.code}: ${waiting.out}`);
} finally {
const stopped = await run(['--stop', '--key', 'hk']);
assert.equal(stopped.code, 0, `--stop under CI must kill the daemon, got ${stopped.code}: ${stopped.out}`);
}
});
it('headless detection still blocks the path that would open a browser', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
const headlessEnv = { ...process.env, CI: '1' };
delete headlessEnv.IMPECCABLE_QUESTION_FORCE;
const code = await new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, '--payload', payloadPath], { cwd: dir, env: headlessEnv, stdio: 'ignore' });
child.on('exit', resolve);
});
assert.equal(code, 2);
});
it('rejects an empty payload', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');