CI as infrastructure, and the bug that made it necessary
The suite had only ever run on one laptop, which means green had only ever meant green there. Moving it to GitHub Actions found four defects a green local run had been hiding.
The build splits into one parallel job per functional area — smoke-safety, pages-editor, database-core, planner-calendar, agent-automation and seven more — and only the second reason is about speed. A red job names a broken capability rather than a shard number, and reports to the board the moment it finishes instead of waiting behind the slowest job. The honest caveat is written into the workflow file: twelve runners cost roughly three times the billed minutes of one.
The failure mode of sharding is silence — a spec that matches no group simply stops running while the build stays green — so ci/plan.js refuses to emit a matrix at all unless every spec file belongs to exactly one suite. Every job carries an explicit timeout, sized at several times its measured duration, because the platform default is 360 minutes and one wedged step otherwise burns six hours of runner time and reports nothing.
Four bugs that only exist on a real runner
A dead mount. The fixture generator replaced the very directory bind-mounted into the container, leaving the mount pointing at a dead inode so every write inside failed.
Ownership that Docker Desktop had been hiding. Fixture files belonged to the runner user and were not group-writable, so a container running as uid 1000 got EACCES — permission denied, not read-only. It passed on the laptop every single time because Docker Desktop ignores Unix ownership on bind mounts.
A timezone split. The container defaults to Asia/Ho_Chi_Minh while Cypress runs on the runner; bare deadlines resolve in the server's zone, so a seven-hour offset made "today" read as already past.
An artifact path that moved itself. upload-artifact roots an archive at the least common ancestor of its paths, so adding a console log beside the JSON silently pushed the JSON down a directory — a flat cp then matched nothing and every suite read as crashed. The coverage guard caught it, which is what it is for.
Alongside the CI work, the pilot itself got its sharpest bug report from the person using it: "the AI isn't managing tasks, it's just idle." It was not idle. Sixteen managed boards, 55 overdue tasks, and a run log reading SonicDrop 47 runs · Private space docker 24 · eleven others 1–2.
Earliest-deadline-first was the cause, and the mechanism has to be stated to be believed: the board holding the oldest overdue deadline wins every selection, and working it does not stop it holding the oldest, because a run touches a handful of its 22 overdue tasks and the rest keep their old dates. A board with one overdue task drops out of contention after a single run. Big boards monopolise; small boards starve. Overdue boards are now served least-recently-first, so waiting time is bounded by boards × cycle rather than left to chance.
And the first fix was dead code
The first attempt asked nextWakeAt(board) <= now to find overdue boards. But nextWakeAt ends with Math.max(best, now + MIN_WAKE_MS) and never returns a time in the past — so nothing was ever overdue, the whole branch was unreachable, and it degraded silently to the exact behaviour it was replacing.
The tell was that the tests passed identically before and after the change. A test suite that cannot tell your fix from your bug is telling you something about the test, and that is the note that got written down.
The most expensive bug in the project
A page block renders content and nothing else. Every server-side generator had been writing text instead — so 54 of 72 pages rendered as blank lines while the data sat intact in the database, invisible on screen and invisible to full-text search, which also reads content.
Four Cypress specs asserted on text and stayed green throughout. The fix was not just the field: src/blocks.js is now the single factory every generator must use, and asserting on text in a spec is a written prohibition.