Skip to content

v1.10.5 ·

expected false to be true: a flake that stopped a release, and a prompt no one could answer

The v1.10.2 release stopped at pnpm test with expected false to be true. All three CI retries failed the same way, and nothing else was printed.

The flake was a real bug

Lifecycle actions and plugin hooks ran through execSync with the event as stdin input. That forks the child first and writes the payload afterwards. A hook like echo never reads stdin, so it can exit before the write lands. The write hits a closed pipe and Node throws EPIPE, for a command that ran and exited 0.

Above the 64 KiB pipe buffer this is deterministic. Below it, it depends on load, and a two-core CI runner with three suites running at once was enough to lose it with a payload of about 100 bytes. All three layers of the code discarded the error, so it surfaced as a bare boolean.

I did not raise the timeout or add a serial lane. The failing file ran in 81 milliseconds and all three attempts failed inside that window, so a longer timeout and a retry were both useless. A serial lane would also have fixed only the test. The same race lives in aidlc transition on a user's machine, where nothing retries.

Now one shared runner, packages/cli/src/events/run-command.ts, handles both actions and hooks. A clean exit is a success even if the payload could not be delivered. A non-zero exit still fails. A timeout is reported as a timeout. A transient spawn error such as EAGAIN, EMFILE, ENFILE or ENOMEM gets one retry. I rejected swallowing EPIPE outright, because it would hide a hook that really failed, and I rejected dropping stdin, because hooks are documented to receive the event there.

The second half mattered more. Failures now carry exit status, signal, errno and the child's stderr, emit prints non-blocking action failures, and every assertion on a spawned command prints what it captured. That paid off the same afternoon, when an unrelated website test that had failed with expected 1 to be +0 started naming its own cause.

Seventeen new tests force the race with an oversized payload instead of waiting for load. Four fail against the old code, which is the only evidence they cover the defect.

Prompts that hang without a terminal

The release also fixes prompts that waited forever with no terminal. The report named one call site. There were twelve unguarded prompts across five commands.

Twelve local fixes would have left a thirteenth place to reintroduce it, so the check lives in one module under core, not menu, which would have made an import cycle. A test walks the registered commands and fails if any can exit 13 or hang without a terminal, with two documented exceptions.

The consent prompts changed meaning too. With no terminal, add skill and install-merge-driver write nothing, and add action declines to save. Treating an unanswerable question as a yes is how a tool authorises a write nobody approved. Twenty-six tests cover it.

What stays

The vitest retry stays as a backstop for failures no code can prevent. It no longer covers a known defect.