Files
civitai__civitai/scripts/test-unit-run.mjs
T

222 lines
9.8 KiB
JavaScript
Raw Normal View History

feat(dev-server): serialise unit-test runs behind the daemon (#3947) * feat(dev-server): serialise unit-test runs behind the daemon The unit suite takes every core. One run is fine; several agents each starting one at the same moment is what flattens the machine, and capping the worker pool per run does not stop that. This adds the scheduler. Two calls, because a caller needs to know where it stands before it decides how to wait. `test run` returns immediately with either "started" or a position plus the command to wait on; `test wait` blocks until the run finishes and exits with its exit code. The wait polls from the CLI rather than holding a daemon request, since the daemon is single-process and a held request would stall every other agent's polling. The daemon owns the run, not the caller, which is what makes a dead agent harmless: it releases nothing because it was holding nothing. Slots are held while a run is tracked and released on child exit, never on a status field -- status is a report, not an observation, and cannot see a grandchild that outlived a kill. Three deadlines close the rest: a queued run whose caller stopped polling is dropped, a run that overruns its ceiling is killed, and a kill that produces no exit frees the slot anyway after a grace period. Concurrency defaults to 1 and is configurable. 0 is legal and means paused, with the pause reported to callers rather than left to be inferred from a position that never moves. The 404 on an unknown run id is load-bearing rather than incidental: it is how a waiter learns the daemon was restarted and its run is gone, instead of polling for a result nobody will produce. * fix(dev-server): close four holes an adversarial review found in the queue 1. `test wait` exited 0 for a cancelled or timed-out run whose child happened to exit 0 -- the window between a kill being issued and it landing. This is the worst possible failure for a command meant to substitute for the suite in a verification chain, because it reports a green run that never finished. Only a completed run that itself exited 0 is a pass now, and the decision lives in one exported function so it can be tested rather than inferred. 2. A malformed TEST_CONCURRENCY killed the daemon at import. The queue is built at module scope and the constructor throws, so a typo in an optional test setting stopped the daemon binding at all -- taking every agent's dev server, session list and worktree tooling with it. It now falls back to 1 and says so, like every other setting in that file. 3. A runner reporting its exit synchronously lost the event, because the listener was attached after the runner returned. The finished run held the only slot until the 30-minute ceiling while everything behind it was abandoned rather than run, and it then settled as `timeout` with an error string that was false. The exit path is now built before the runner is called. 4. The line whose removal produces exactly the permanent wedge this feature exists to prevent had no coverage: 15/15 passed with it deleted. The test sweeps inside the grace the way the daemon's own 5s timer does, so a reset deadline now fails as `expected [] to deeply equal ['<id>']`. Also: children get their own process group on POSIX, without which killing by negative pid names no group and silently leaves vitest running while its slot is handed on -- serialisation quietly becoming concurrency 2 under exactly the load this is for. Daemon shutdown kills synchronously so the child cannot outlive it. Every fix has a mutation control: reverting each one fails on a named value. * fix(dev-server): three more from the second review pass The identity half of the exit guard was untested -- 21/21 passed with it removed. It only fires when a late exit arrives after a force-release, and no test delivered one. It was defended in practice only by the exit-code rule catching the consequence, which is two fixes covering for each other rather than either being pinned. Now a test force-releases a slot and then delivers the exit. `exitCode || 1` passed a signal death straight through as -1, which a shell sees as 255 -- and `detached` + SIGKILL means that is now the normal shape of every cancel and timeout on POSIX. Real failing codes still pass through; anything that is not a positive integer becomes 1. The exit-code table gains a row with a distinctive code, since every row it had was one whose answer is 1 anyway, so it could not tell a passed-through code from a hardcoded one. A child that dies by signal reports no code at all. That is an OOM kill or an outside hand, not a verdict on the tests, so it settles as `error` rather than claiming a test result nothing produced. `execFileSync` on shutdown had no timeout. It runs on the daemon's event loop and is also reached from the SIGINT handler, so a taskkill that blocked would leave the daemon unkillable by signal. A kill we cannot complete is better abandoned -- the sweep frees the slot regardless. * fix(dev-server): keep the verdict when a runner reports then throws The catch around startRun neither checked nor set the settled flag, so a runner that reported an exit and then threw had its real result overwritten by the noise that followed it -- completed/0 became error/null. Degrades safe and the shipped runner cannot produce it, but a verdict that exists should not be discarded. * feat(dev-server): route test:unit:run through the queue when opted in Replaces the hook approach. A PreToolUse hook has to decide from the command text whether a run is happening, and three adversarial passes showed that cannot be done: it ended with 20 known bypasses and 6 false denials, including refusing a commit whose message merely mentioned the suite. Inside the script there is nothing to parse -- whatever shell, wrapper, quoting or directory reaches it gets the queue. It routes rather than refuses, which is the difference that makes it work: no second command to learn, nothing to wrap around, and an agent that never read the guidance still gets queued instead of an error it will work around. Off unless CIVITAI_TEST_QUEUE is set, and off in CI regardless, so this is a no-op for everyone who does not run the daemon -- the same vitest invocation as before. A file-scoped run stays direct: queueing a two-second check behind a nine-minute suite would break the fast loop and push callers toward batching more into each run, which is the opposite of the point. The queue being unreachable falls back to running directly. Nobody should be unable to run tests because a daemon is down.
2026-08-14 19:09:25 -06:00
#!/usr/bin/env node
/**
* `pnpm run test:unit:run` — runs the unit suite, optionally through the dev-server queue.
*
* Off by default: with no flag set this spawns exactly the vitest command the script used to be,
* so CI and anyone who does not run the daemon see no change at all.
*
* With CIVITAI_TEST_QUEUE set, a full-suite run is routed through the daemon's queue instead, which
* serialises it against every other agent on the machine. Routing rather than refusing is the whole
* point: there is no second command to learn, nothing to wrap around, and an agent that never read
* the guidance still gets queued.
*/
import { spawn } from 'child_process';
import { existsSync } from 'fs';
import { dirname, resolve } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
feat(dev-server): serialise unit-test runs behind the daemon (#3947) * feat(dev-server): serialise unit-test runs behind the daemon The unit suite takes every core. One run is fine; several agents each starting one at the same moment is what flattens the machine, and capping the worker pool per run does not stop that. This adds the scheduler. Two calls, because a caller needs to know where it stands before it decides how to wait. `test run` returns immediately with either "started" or a position plus the command to wait on; `test wait` blocks until the run finishes and exits with its exit code. The wait polls from the CLI rather than holding a daemon request, since the daemon is single-process and a held request would stall every other agent's polling. The daemon owns the run, not the caller, which is what makes a dead agent harmless: it releases nothing because it was holding nothing. Slots are held while a run is tracked and released on child exit, never on a status field -- status is a report, not an observation, and cannot see a grandchild that outlived a kill. Three deadlines close the rest: a queued run whose caller stopped polling is dropped, a run that overruns its ceiling is killed, and a kill that produces no exit frees the slot anyway after a grace period. Concurrency defaults to 1 and is configurable. 0 is legal and means paused, with the pause reported to callers rather than left to be inferred from a position that never moves. The 404 on an unknown run id is load-bearing rather than incidental: it is how a waiter learns the daemon was restarted and its run is gone, instead of polling for a result nobody will produce. * fix(dev-server): close four holes an adversarial review found in the queue 1. `test wait` exited 0 for a cancelled or timed-out run whose child happened to exit 0 -- the window between a kill being issued and it landing. This is the worst possible failure for a command meant to substitute for the suite in a verification chain, because it reports a green run that never finished. Only a completed run that itself exited 0 is a pass now, and the decision lives in one exported function so it can be tested rather than inferred. 2. A malformed TEST_CONCURRENCY killed the daemon at import. The queue is built at module scope and the constructor throws, so a typo in an optional test setting stopped the daemon binding at all -- taking every agent's dev server, session list and worktree tooling with it. It now falls back to 1 and says so, like every other setting in that file. 3. A runner reporting its exit synchronously lost the event, because the listener was attached after the runner returned. The finished run held the only slot until the 30-minute ceiling while everything behind it was abandoned rather than run, and it then settled as `timeout` with an error string that was false. The exit path is now built before the runner is called. 4. The line whose removal produces exactly the permanent wedge this feature exists to prevent had no coverage: 15/15 passed with it deleted. The test sweeps inside the grace the way the daemon's own 5s timer does, so a reset deadline now fails as `expected [] to deeply equal ['<id>']`. Also: children get their own process group on POSIX, without which killing by negative pid names no group and silently leaves vitest running while its slot is handed on -- serialisation quietly becoming concurrency 2 under exactly the load this is for. Daemon shutdown kills synchronously so the child cannot outlive it. Every fix has a mutation control: reverting each one fails on a named value. * fix(dev-server): three more from the second review pass The identity half of the exit guard was untested -- 21/21 passed with it removed. It only fires when a late exit arrives after a force-release, and no test delivered one. It was defended in practice only by the exit-code rule catching the consequence, which is two fixes covering for each other rather than either being pinned. Now a test force-releases a slot and then delivers the exit. `exitCode || 1` passed a signal death straight through as -1, which a shell sees as 255 -- and `detached` + SIGKILL means that is now the normal shape of every cancel and timeout on POSIX. Real failing codes still pass through; anything that is not a positive integer becomes 1. The exit-code table gains a row with a distinctive code, since every row it had was one whose answer is 1 anyway, so it could not tell a passed-through code from a hardcoded one. A child that dies by signal reports no code at all. That is an OOM kill or an outside hand, not a verdict on the tests, so it settles as `error` rather than claiming a test result nothing produced. `execFileSync` on shutdown had no timeout. It runs on the daemon's event loop and is also reached from the SIGINT handler, so a taskkill that blocked would leave the daemon unkillable by signal. A kill we cannot complete is better abandoned -- the sweep frees the slot regardless. * fix(dev-server): keep the verdict when a runner reports then throws The catch around startRun neither checked nor set the settled flag, so a runner that reported an exit and then threw had its real result overwritten by the noise that followed it -- completed/0 became error/null. Degrades safe and the shipped runner cannot produce it, but a verdict that exists should not be discarded. * feat(dev-server): route test:unit:run through the queue when opted in Replaces the hook approach. A PreToolUse hook has to decide from the command text whether a run is happening, and three adversarial passes showed that cannot be done: it ended with 20 known bypasses and 6 false denials, including refusing a commit whose message merely mentioned the suite. Inside the script there is nothing to parse -- whatever shell, wrapper, quoting or directory reaches it gets the queue. It routes rather than refuses, which is the difference that makes it work: no second command to learn, nothing to wrap around, and an agent that never read the guidance still gets queued instead of an error it will work around. Off unless CIVITAI_TEST_QUEUE is set, and off in CI regardless, so this is a no-op for everyone who does not run the daemon -- the same vitest invocation as before. A file-scoped run stays direct: queueing a two-second check behind a nine-minute suite would break the fast loop and push callers toward batching more into each run, which is the opposite of the point. The queue being unreachable falls back to running directly. Nobody should be unable to run tests because a daemon is down.
2026-08-14 19:09:25 -06:00
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const CLI = resolve(repoRoot, '.claude/skills/dev-server/cli.mjs');
// The queue module owns the one rule that decides pass from fail. It is imported dynamically
// rather than at the top of the file because it lives under `.claude/`, which the direct path
// must keep working without: a static import would fail the whole script when the skill is absent.
const QUEUE = resolve(repoRoot, '.claude/skills/dev-server/scripts/test-queue.mjs');
fix(dev-server): the daemon never read DEV_DAEMON_PORT, so setting it broke the CLI instead of moving the daemon (#4181) * fix(dev-server): the daemon never read DEV_DAEMON_PORT, so setting it broke the CLI instead of moving the daemon The variable was read where the client decides what to CONNECT to and ignored where the daemon decides what to LISTEN on. `DEV_DAEMON_PORT=9555 cli.mjs status` therefore pointed the CLI at :9555, spawned a daemon that bound :9444, and could not reach it — the daemon has always accepted `--port`, the spawn simply passed no arguments and the daemon read no environment. Four files each decided the port for themselves and two of them were wrong: cli.mjs and scripts/test-unit-run.mjs read the variable, console.mjs hardcoded 9444, and daemon.mjs saw only argv. That is not a bug any one of them contains — it is a bug in the set, so the number now lives in exactly one module and all four resolve it there. A daemon a client spawns inherits that client's environment, so both ends read the same variable through the same function; an explicit `--port` still wins for a daemon started by hand. resolveDaemonPort also refuses a value that is not a port rather than handing back parseInt's NaN, which used to reach a URL as `http://127.0.0.1:NaN` and fail a long way from its cause. Verified by reproducing the reported path, not by reading the code. On pre-change code with DEV_DAEMON_PORT=19461 the daemon logged `Daemon port: 9444`, emitted no ready line, and nothing ever listened on 19461. After the change it reports and binds 19461, and the pid it reports is the child that was spawned — a port that answers proves a listener, not THIS listener. Six isolated mutants, each killed by its own named assertion: daemon ignores the environment -> both behavioural cases daemon ignores --port -> the --port precedence case resolver stops validating -> the rejects-a-non-port case console.mjs re-hardcodes 9444 -> both halves of the ledger test-unit-run.mjs re-hardcodes -> both halves of the ledger the default drifts to 9445 -> the default case console.mjs is a TUI with no end-to-end case here, which is why the ledger exists: it fails when the set of files deciding the port grows or shrinks. Closes ClickUp 868kuaa4e. * fix(dev-server): the audit round — the test deleted the pid file it was written to protect, and the ledger could not see the set grow 🔴 The blocking one. `POST /shutdown` replies 200 and only THEN schedules `unlinkSync(pidFile); process.exit(0)` on a 100 ms timer, so the helper that saved and restored the developer's `daemon.pid` restored it ~100 ms before the daemon deleted it. `scripts/**/*.test.ts` is in the `unit` project, so every `pnpm test:unit:run` removed `.claude/skills/dev-server/daemon.pid` — and the file is only written at daemon START, so it did not come back. That breaks the recipe SKILL.md itself gives for checking the daemon's interpreter, and makes `cli.mjs shutdown`'s cleanup a no-op. `shutdown()` now waits for the port to stop answering before returning. The ledger scanned a FIXED list of four readers, so it could not detect the set GROWING — which the PR description claimed it did. A fifth hardcoded 9444 already existed while the test was green: `.claude/hooks/check-writable.mjs` baked it into the dev-port regex, so `DEV_DAEMON_PORT=9555` silently switched that nudge off for the daemon, the one long-lived server it most exists for. The ledger now walks the tree, so a file that does not exist yet is covered, and the hook reads the port from the module. Verified with a negative control: with DEV_DAEMON_PORT=9555, `curl :9555` nudges and `curl :7777` stays silent. The ledger's assertions were also spelled rather than structural. `includes('daemon-port.mjs')` is satisfied by a COMMENT — and this file's own prose names the module in several — and `includes('resolveDaemonPort(')` is satisfied by any call whatever is done with the result, so `resolveDaemonPort() + 1` passed all of it while console.mjs has no behavioural test. Both clients now take a whole URL from `resolveDaemonUrl()` and do no arithmetic on a port; the assertions pin an import line and that call. Three more the audit found: - `scripts/test-unit-run.mjs` lost its "never leave a caller unable to run tests" guarantee. Resolution moved inside `runQueued` but OUTSIDE its try, and `runQueued` is un-awaited, so a malformed DEV_DAEMON_PORT became an unhandled rejection and no tests ran. Measured against base: base printed "running directly", HEAD died. Restored, with the outer `.catch()` as the general net. - `--port` still open-coded `parseInt`, so the HIGHER-precedence input was the unvalidated one — `--port abc` gave ERR_SOCKET_BAD_PORT, which via cli.mjs is invisible (detached, stdio ignored) and reads only as "Failed to start daemon". Both inputs now go through `parsePort`, which names the input it is complaining about. - Resolving at module load made a bad DEV_DAEMON_PORT throw on merely IMPORTING daemon.mjs, which would have broken collection of the port-reservation suite. Resolution moved into `parseArgs`. Nits: SKILL.md said `cli.mjs:69`, the line is 70 — my own earlier check of that number had gone stale under a later edit. Out-of-range is now a different message from unparseable ('0' is a number, just not a port). The 9444 scan uses a word boundary so 19444 is not a false positive. Seven mutants, each applied alone, tree restored and cmp-verified between: shutdown() stops waiting -> the pid-file case a NEW file hardcodes 9444 -> the ledger (the growth case) the hook re-hardcodes 9444 -> the ledger console.mjs does resolvePort() + 1 -> the client-address case the targeted fallback is removed -> the degrade-to-direct case --port back to parseInt -> the argv validation case port resolved at module load -> the import-safety case 🔴 One of them SURVIVED the first run and the fix is the point: the fallback case asserted the generic phrase "running directly", which BOTH guards on that path print, so deleting the targeted guard left the suite green — the mutant died to the other guard. The assertion now names the specific message. scripts/__tests__ 324 passed. typecheck 0 errors. Hook selftest green. * fix(dev-server): round 3 — the last round's hook fix un-guarded the default port, and its catch would have double-run the suite Both regressions were introduced by the previous commit, not pre-existing, and a delta re-audit of 7dd65a9d62..85cb922567 found them. 🔴-in-effect: `DEV_DAEMON_PORT` REPLACED the guarded port instead of adding to it, so setting it un-guarded the shared daemon. Reproduced through the hook's real stdin contract: with the override set, `curl http://localhost:9444/...` went from ask to allowed, and the hook's own selftest went from `all green` to `1 FAILURES`. SKILL.md documents the override as standing a daemon BESIDE the shared one — both are live, so both need guarding. `daemonPortsGuarded()` now returns the union, and the selftest is green both plain and under the override. The other one is subtler and worse in effect. The outer `.catch()` I added wrapped the whole `runQueued` lifecycle, not just queue acquisition — so a socket dropped mid-poll abandoned a run the daemon had ALREADY ACCEPTED and started a second, unqueued full suite beside it. That defeats the serialisation this script exists for, and the file had already decided the opposite for the status-code form of the same condition (`Lost contact with the test queue` -> exit 2). It now tracks whether the run was accepted: before acceptance it degrades to a direct run, after acceptance it exits 2. `err?.message`, because a non-Error throw printed `(undefined)` and a null throw raised inside the handler — back to the unhandled rejection the catch exists to prevent. Also from the re-audit: - The port scrape in the hook is a dependency on one line's formatting in another file and nothing tested it. A vitest case now drives the real `unboundedDevRequest`, with a negative control on a port the skill does not use. Breaking the scrape regex now fails two tests; it used to fail none. - `console.mjs` had no behavioural coverage, and every structural assertion was walkable by resolving the URL correctly and then drifting it. It now runs for real against a stub daemon on an ephemeral port — via `--tail`, since the dashboard refuses to start without a TTY and exits before contacting anything. - `--base-dev-port` was still `parseInt`, which made "argv gets the same validation the environment gets" false about half of argv. - The port range's upper bound was untested: fixtures were 0 and 70000, so 65535 could drift to 65536 unnoticed. Now pinned ON the boundary. - SKILL.md's `console.mjs:88` — the previous commit fixed the cli.mjs citation and moved console.mjs's line to 89 in the same change. Now 89. - The module header claimed the scan covers "the whole skill ... anywhere". It covers three roots and four extensions; prose and test files are deliberately out of scope. Said so instead. 🔴 The ledger caught ME during this round: a comment I wrote explaining the un-guarding bug spelled the port, and `spells the port in exactly one source file` went red. That is the guard working on its author — reworded, not exempted. Round-3 battery, each mutant alone, tree cmp-verified between: outer .catch() deleted -> the accepted-run case accepted-check removed -> the accepted-run case hook override replaces default -> the union case hook scrape regex broken -> both hook cases upper bound 65535 -> 65536 -> the boundary case console.mjs drifts the URL -> the behavioural case + the ledger --base-dev-port back to parseInt -> the base-dev-port case All killed. One deliberate survivor: a copy OUTSIDE the three scanned roots, which is the documented scope and is now stated in the header rather than overclaimed. 330 passed. typecheck 0 errors. Hook selftest green plain and under override. * style: prettier the added test file — the CI gate checks ADDED files and I never ran it `ESLint + Prettier (changed files)` went red on the round-3 push for one reason: `scripts/__tests__/dev-server-daemon-port.test.ts` was not prettier-formatted. Four lines, all wrapping. Reproduced the gate's exact scope locally rather than guessing at it — it runs `prettier --list-different` over ADDED files only, which is why the four `.mjs` files this branch MODIFIES were never checked. Those four are unformatted, and the control says that is not mine: at the merge base `bc74ba06ea`, all four are already unformatted. Reformatting them would rewrite files this change does not own, which is the breadth rule in CLAUDE.md, so they are left alone. Local prettier is 2.8.8, the same version CI installs, so the local format is the one the gate will read. 21 tests still pass. * fix(dev-server): round-4 tidy — the test that proves the console bug was itself destroying the pid file on its red path Follow-ups from the round-3 delta re-audit, which found no 🔴 and confirmed round 3 did not reintroduce a regression. These are the 🟡/🟢 it did find. The console test was not wrapped in `withPidFilePreserved`, and the exposure is specifically on the FAILING path: on green the stub answers `/`, so the console never starts a daemon. Under the mutant the test exists to catch, it cannot reach the stub, falls through to its own `startDaemon`, and overwrites `daemon.pid` with a dead pid — the test that proves the bug also damaged the thing the rest of the file is careful about. Controlled both ways: with the drift mutant applied the test now goes red AND the pid file's md5 is unchanged. The port scrape in the hook is anchored on `export const` again. Dropping the anchor was justified as surviving a reformat, and that reasoning was wrong — a reformat does not rewrite `export const NAME =`. What it actually bought was letting the FIRST match anywhere in the file win, comments included: a `// historical: DEFAULT_DAEMON_PORT = 9999` line above the declaration made the hook guard 9999 and stop guarding the real port. Controlled: that same line now yields 9444. Three comments that no longer described their code. `test-unit-run.mjs` had the previous round's two lines left verbatim above the block that replaced them, so the same sentence appeared twice. The hook still said the port "is overridable via DEV_DAEMON_PORT" — the exact semantics round 3 removed, since the set is now additive — and its "resolved lazily" note sat above `daemonPortsGuarded`, which is neither lazy nor cached; the laziness is in `devPorts`. Numeric line references (`:123`, `:128`) replaced with named ones. Both had already rotted by three lines. This is the same class as the SKILL.md citation fixed last round, and a line number in a comment will rot again — a quoted message will not. Named the residual hole rather than implying it away: the daemon enqueues INSIDE the response write, so the slot is taken before the client can observe it. Lose the response between those points and `accepted` is still false while the run is queued. That window needs an idempotency key on the enqueue, not a flag here. Not changed, deliberately. `.claude/**/*.mjs` fails `prettier --check` at the merge base as well as here, so it is pre-existing and reformatting it would rewrite files this change does not own. And `scripts/__tests__/*.ts` is typechecked by nothing — `tsconfig.json`'s `include` omits it — so this PR's "typecheck 0 errors" says nothing about the new test file's annotations. Both are stated rather than quietly folded in. 330 passed. Hook selftest green plain and under the override. Test file prettier-clean.
2026-08-20 01:23:47 -05:00
// Same override the CLI and the daemon honour, and from the same module, so no two of the three
// can disagree about where the daemon is. Without it this file could only ever talk to the shared
// daemon, which is why the verdict below had no test: there was no way to stand a fake one up
// beside it. Imported on the queue path only, for the reason given above QUEUE.
const PORT_MODULE = resolve(repoRoot, '.claude/skills/dev-server/scripts/daemon-port.mjs');
let DAEMON = null;
// Whether the queue has taken ownership of this run. Once it has, a later failure must NOT be
// answered by starting a second, unqueued suite — see the note where this is set.
let accepted = false;
feat(dev-server): serialise unit-test runs behind the daemon (#3947) * feat(dev-server): serialise unit-test runs behind the daemon The unit suite takes every core. One run is fine; several agents each starting one at the same moment is what flattens the machine, and capping the worker pool per run does not stop that. This adds the scheduler. Two calls, because a caller needs to know where it stands before it decides how to wait. `test run` returns immediately with either "started" or a position plus the command to wait on; `test wait` blocks until the run finishes and exits with its exit code. The wait polls from the CLI rather than holding a daemon request, since the daemon is single-process and a held request would stall every other agent's polling. The daemon owns the run, not the caller, which is what makes a dead agent harmless: it releases nothing because it was holding nothing. Slots are held while a run is tracked and released on child exit, never on a status field -- status is a report, not an observation, and cannot see a grandchild that outlived a kill. Three deadlines close the rest: a queued run whose caller stopped polling is dropped, a run that overruns its ceiling is killed, and a kill that produces no exit frees the slot anyway after a grace period. Concurrency defaults to 1 and is configurable. 0 is legal and means paused, with the pause reported to callers rather than left to be inferred from a position that never moves. The 404 on an unknown run id is load-bearing rather than incidental: it is how a waiter learns the daemon was restarted and its run is gone, instead of polling for a result nobody will produce. * fix(dev-server): close four holes an adversarial review found in the queue 1. `test wait` exited 0 for a cancelled or timed-out run whose child happened to exit 0 -- the window between a kill being issued and it landing. This is the worst possible failure for a command meant to substitute for the suite in a verification chain, because it reports a green run that never finished. Only a completed run that itself exited 0 is a pass now, and the decision lives in one exported function so it can be tested rather than inferred. 2. A malformed TEST_CONCURRENCY killed the daemon at import. The queue is built at module scope and the constructor throws, so a typo in an optional test setting stopped the daemon binding at all -- taking every agent's dev server, session list and worktree tooling with it. It now falls back to 1 and says so, like every other setting in that file. 3. A runner reporting its exit synchronously lost the event, because the listener was attached after the runner returned. The finished run held the only slot until the 30-minute ceiling while everything behind it was abandoned rather than run, and it then settled as `timeout` with an error string that was false. The exit path is now built before the runner is called. 4. The line whose removal produces exactly the permanent wedge this feature exists to prevent had no coverage: 15/15 passed with it deleted. The test sweeps inside the grace the way the daemon's own 5s timer does, so a reset deadline now fails as `expected [] to deeply equal ['<id>']`. Also: children get their own process group on POSIX, without which killing by negative pid names no group and silently leaves vitest running while its slot is handed on -- serialisation quietly becoming concurrency 2 under exactly the load this is for. Daemon shutdown kills synchronously so the child cannot outlive it. Every fix has a mutation control: reverting each one fails on a named value. * fix(dev-server): three more from the second review pass The identity half of the exit guard was untested -- 21/21 passed with it removed. It only fires when a late exit arrives after a force-release, and no test delivered one. It was defended in practice only by the exit-code rule catching the consequence, which is two fixes covering for each other rather than either being pinned. Now a test force-releases a slot and then delivers the exit. `exitCode || 1` passed a signal death straight through as -1, which a shell sees as 255 -- and `detached` + SIGKILL means that is now the normal shape of every cancel and timeout on POSIX. Real failing codes still pass through; anything that is not a positive integer becomes 1. The exit-code table gains a row with a distinctive code, since every row it had was one whose answer is 1 anyway, so it could not tell a passed-through code from a hardcoded one. A child that dies by signal reports no code at all. That is an OOM kill or an outside hand, not a verdict on the tests, so it settles as `error` rather than claiming a test result nothing produced. `execFileSync` on shutdown had no timeout. It runs on the daemon's event loop and is also reached from the SIGINT handler, so a taskkill that blocked would leave the daemon unkillable by signal. A kill we cannot complete is better abandoned -- the sweep frees the slot regardless. * fix(dev-server): keep the verdict when a runner reports then throws The catch around startRun neither checked nor set the settled flag, so a runner that reported an exit and then threw had its real result overwritten by the noise that followed it -- completed/0 became error/null. Degrades safe and the shipped runner cannot produce it, but a verdict that exists should not be discarded. * feat(dev-server): route test:unit:run through the queue when opted in Replaces the hook approach. A PreToolUse hook has to decide from the command text whether a run is happening, and three adversarial passes showed that cannot be done: it ended with 20 known bypasses and 6 false denials, including refusing a commit whose message merely mentioned the suite. Inside the script there is nothing to parse -- whatever shell, wrapper, quoting or directory reaches it gets the queue. It routes rather than refuses, which is the difference that makes it work: no second command to learn, nothing to wrap around, and an agent that never read the guidance still gets queued instead of an error it will work around. Off unless CIVITAI_TEST_QUEUE is set, and off in CI regardless, so this is a no-op for everyone who does not run the daemon -- the same vitest invocation as before. A file-scoped run stays direct: queueing a two-second check behind a nine-minute suite would break the fast loop and push callers toward batching more into each run, which is the opposite of the point. The queue being unreachable falls back to running directly. Nobody should be unable to run tests because a daemon is down.
2026-08-14 19:09:25 -06:00
const POLL_MS = 2000;
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
export function queueDecision(args, env) {
if (env.CI) return { queue: false, why: 'CI runs the suite directly' };
if (!env.CIVITAI_TEST_QUEUE || /^(0|false|off|no)$/i.test(env.CIVITAI_TEST_QUEUE)) {
return { queue: false, why: 'CIVITAI_TEST_QUEUE is not set' };
}
// A narrow run is cheap and is the fast iteration loop. Queueing it behind a full suite would
// turn a two-second check into a nine-minute wait, and push callers toward batching more work
// into each run — the opposite of what this is for.
if (args.some((a) => TEST_FILE.test(a))) return { queue: false, why: 'run names specific files' };
return { queue: true };
}
function runDirect(args) {
// Resolved from node_modules rather than PATH, so this behaves the same when run directly as it
// does under `pnpm run`, which is the only context that puts .bin on PATH.
const local = resolve(
repoRoot,
'node_modules/.bin',
process.platform === 'win32' ? 'vitest.cmd' : 'vitest'
);
const bin = existsSync(local) ? local : 'vitest';
perf(tests): split sharp-executing tests onto their own pool, pre-bundle five externals (#3960) * perf(tests): route sharp-executing tests to their own forks project sharp 0.32.6's addon is not context-aware, so a worker_threads worker that has run a libvips operation segfaults at thread teardown - after the tests pass and the summary prints. That takes the whole run down with an exit code and no failing test. It is a race, not a threshold. On the six affected files, three repeats per width: vmThreads crashed 3/3 at 1 worker, 2/3 at 2, 1/3 at 3, 0/3 at 4; threads crashed at every width. A green run is not evidence of safety. Importing sharp is harmless; only executing an operation arms it. 100 test files carry sharp in their static closure and exactly six call it. That set was measured by aliasing sharp to a recording proxy and running all 100 under forks, not by grepping and not by a crash-scan - with a race, "ran alone and didn't crash" builds the list out of the files that got lucky. - unit -> the suite minus those six, pool unchanged (forks) - unit-native -> pool: forks pinned, including only those six unit deliberately does NOT move to threads. threads measured 1.04x at 4 workers and 0.94x at 16 - no win - and it segfaults mid-run on the full suite at roughly 1 in 4, after completing hundreds of files cleanly and with no unit-native file having run, so a second crasher exists that is not sharp and is not diagnosed. What the split buys is that the sharp crash is deterministic and gone, so anyone experimenting with --pool=threads no longer has to fight it too. Excluded from unit rather than merely claimed by unit-native, so that a run naming a sharp file under --project unit reports "No test files found" rather than running it on a thread pool if one is ever selected. Shared settings are hoisted into one object both projects spread, so they cannot drift apart. Every selector moves to a unit* project pattern. Verified by file count rather than by a green summary: vitest list over unit* is 1065 files and unit-native is 6, summing to the pre-split baseline. no-sharp-outside-native-project.test.ts is the positive control, since nothing else in the suite can notice this breaking: the realistic failure is a rename, which stops matching unit-native's include AND unit's exclude. Mutation-tested, not assumed green. * perf(tests): pre-bundle five externals nothing mocks Every test file gets a fresh module registry - under forks with isolate: true it is literally a fresh child process per file (N files at maxWorkers=1 give N distinct pids), so Node's module cache dies with it and each externalised package is imported cold once per file that reaches it. Pre-bundling collapses a package's many-hundred-file native load into one chunk, paid once per run. Full suite, control then treatment in one window: control wall 217.2s collect 4730s 1066 files 16787 tests 17 failed treatment wall 192.9s collect 4082s 1066 files 16787 tests 17 failed The failure SET is unchanged, diffed both directions - nothing appeared, nothing cleared. This alters timing, not behaviour. The effect tracks exposure, which is what separates it from ambient drift: reaches 0 of the 5 353 files collect 113s -> 105s -6.9% reaches 1 115 files collect 150s -> 103s -31.2% reaches 2 209 files collect 529s -> 373s -29.6% reaches 3 18 files collect 69s -> 52s -24.3% reaches 5 371 files collect 3869s -> 3449s -10.9% Exposure 5 shows the smallest percentage and the largest absolute saving (420s of 648s) because those are the heavyweight files: five packages are a small share of a 1,300-module closure and a large share of a small one. Percentage tracks share-of-closure, absolute tracks file weight. A control-vs-control run to size run-to-run drift directly was attempted and died to an unrelated crash, so the residual drift term is unmeasured and the figure above should be read as an upper bound. The list is confined to packages nothing mocks, and that is load-bearing. Pre-bundling wraps a package as a CJS-interop chunk, so a vi.mock factory returning only named exports stops satisfying its consumers - adding redis and the mocked aws-sdk clients takes four mock-holding files from 92 tests passing to 7 collected. The importOriginal form does not protect against this. Those three are worth ~275s more and need a default export added to six mock factories first; that is a separate change. The treatment paid its cold optimize pass inside the measured run - the shared .vite cache was not cleared - so the number is not flattered by a warm cache, and a fresh CI runner pays the same thing. * test(perf): pin the native project's pool independently of unit's The guard asserted unit ran on threads, which was the state the split shipped in for about ten minutes. It caught its own config change, which is the behaviour wanted, but the assertion was aimed at the wrong invariant: what must hold is that unit-native stays on a process-based pool whatever unit is pointed at, not that unit is on any particular one. * test(perf): acceptance harness for the six external-mock factories The change that brings redis and the aws-sdk clients into the pre-bundling safelist cannot be verified on a tree that does not enable the optimizer: without pre-bundling the package is not wrapped as a CJS-interop chunk, the missing default export never bites, and a green run proves only that the old config still works. This runs the six affected files under a config with all three candidates pre-bundled. Compares per-file collected counts rather than the total. s3-utils is 66 of the 106, so a sum of 40 could be one file collecting zero and still read as a partial pass. Negative control on the unchanged tree: 5 of 6 files collect 0, and the harness exits 1 naming each one. * docs(test-perf): record the measurement envelope this box imposes Two identical full runs, back to back, nothing changed between them, came out +20.5% apart on collect. That pair was contaminated, so it is not a drift figure - but it demonstrates the box can move further than most of the effects measured today, which means any comparison assembled from two windows is unreadable. Collects the methodology that follows: quote in-pair controls rather than cross-window deltas; a control group must be comparable in cost and not merely in count; a dose-response on an axis confounded with file cost is suggestive rather than conclusive; a crashed run's wall clock is not a fast run; and check for the workload rather than for the runtime when deciding the box is quiet. A clean drift pair still has not been taken and is the denominator for everything else here. * docs(test-perf): scout Bun and node:test as vitest replacements Recommendation is to stay on vitest, but the measurement overturns the cost model we spent the day optimising against. Same 84-module first-party graph: vitest collect 5298ms, bun 3.3ms, node+tsx 8.5ms. Whole-suite arithmetic gives vitest 10.2ms per static module-instance against 0.04ms for bun. The cost is the module runner, not the modules - which matches this morning's tracer result (569 module bodies in ~0.4s against a 25.4s import phase) and locates the time in vite-node's per-module fetch/instantiate rather than in compile-and-evaluate. Unrealisable, though. Bun cannot load any graph reaching the React/Next side - it dies resolving use-sidecar's package exports - so the numbers are measured on the light stratum only, which is the flattering-slice trap: 383 of 1065 files have no infra dependency and the largest of those is an 86-module closure. Module-scope env aborts the import under both runtimes, and cache-helpers hung past 300s under bun after the env gate was satisfied. The mock surface is the wall: 1053 of 1065 files import from vitest, 3883 vi.mock sites across 651 files, plus 8053 vi.fn and the fake-timer, spy and importActual surface. The canonical mock system, its guard, the allowlist ratchet, reporter.mjs, the dashboard and the queue integration are all vitest-shaped as well. Retarget rather than switch: if per-module cost is vite-node overhead, shrinking the graph attacks a term worth ~0.04ms of real work per module, and the leverage is in how many times a module is INSTANTIATED - which is what isolate:false removes. * docs(test-perf): retract the per-module ratio in the runner scouting It divided collect by inventory.json's static module counts, and that artifact was wrong by up to 75x and selectively so - it followed lazy dynamic import edges that never execute and ignored vi.mock factories. Honest suite union is 1321, not 3230. The per-file wall clock the recommendation rests on needs no denominator and is unaffected: same 84-module closure, vitest collect 5298ms against bun 3.3ms and node+tsx 8.5ms. So is the tracer result behind it - 569 module bodies executing in ~0.4s against a 25.4s import phase, measured with no static count at all. No counterpart figure is quoted for bun, because its denominator came from the same artifact. * docs(test-perf): scrub the remaining per-module claims from the runner scout Two survivors of the retraction: an 'orders of magnitude per module' headline and a stratum characterisation quoting closure sizes, both resting on the same broken counts. Restated against the per-file wall clock, which needs no denominator, and the observed hard failure, which is not a count. * docs(test-perf): correct the runner comparison to like-for-like The headline compared vitest's collect for a TEST FILE against a probe importing only the SOURCE module underneath it - a different and much smaller graph. That is where '~1600x' came from. Like-for-like, on the same 82-module test-file closure: vitest collect 5298ms, bun 259ms (median of 5, 250-262). ~20x, not three orders of magnitude. node+tsx cannot import a test file at all - 'Vitest cannot be imported in a CommonJS module using require()'. Per-module refit against aidan's honest closures.json (mode: 'real') joined to the pre-ctl full run: 1065 files, 104797 real module-instances, collect 4729s -> vitest 45.1 ms/module, independently agreeing with aidan's 43.6. bun 3.2 ms/module on the file both can load. The recommendation is unchanged and the mechanism finding is unchanged; the size of the gap was overstated. * docs(vitest): say why the unit projects set no per-project maxWorkers Per-project maxWorkers does apply at runtime, but two projects with different counts need different sequence.groupOrder values, and different groups run serially. For a 1059/6 split that trades the concurrency between them for a knob nobody needs - the six-file project would gate the other 1059 instead of filling spare capacity beside it. Currently reads as an omission, so a future reader adds one and loses concurrency without knowing they traded for it. * docs(test-perf): final form of the runner scouting result Leads with both corrections stated in place rather than silently edited out, and records that neither changed the recommendation. Promotes the cross-validation to a finding of its own: 45.1 ms per module-instance here against aidan's independent 43.6, from a different artifact by a different route. Two wrong denominators would not have agreed, so the pair is what licenses everything downstream that divides by a module count. Names what both errors had in common - each a denominator error producing a number right about the thing it measured and wrong about what that thing was. Checking two runtimes are comparable is not checking the two quantities are. * fix(test): pin unit-native's pool against a CLI --pool, and correct the project selector `unit-native`'s static `pool: 'forks'` loses to a CLI `--pool=threads`: resolveProjects builds cliOverrides from a list that includes `pool` and spreads it after options.test, so the flag wins. The six sharp-executing files would then follow `unit` onto a thread pool and segfault AFTER printing a green summary. configureVitest hooks run after resolveProjects(cliOptions), and getFilePoolName -- `browser.enabled ? 'browser' : project.config.pool` -- is what stamps each spec's pool, so re-asserting there outranks the flag. The other two readers of project.config.pool populate task metadata from the same field and cannot disagree with it. The comment already claimed this guarantee; without the plugin it was false, and false in the reassuring direction. Also corrects CLAUDE.md: the unit suite is two projects now, so `--project unit` silently runs 1059 of 1065 files and exits 0. Select it as `--project 'unit*'`, which is what package.json's own scripts already do. Bound: the pin covers `pool` and nothing else. isolate, fileParallelism, sequence, testTimeout and retry are on the same cliOverrides list and remain overridable. Not verified by a run -- the mechanism was read from vitest 4.0.18's cli-api chunk twice, independently, by two readers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtTG4QQR29eWf7kjM6HiLU --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 17:07:44 -06:00
const child = spawn(bin, ['run', '--project', 'unit*', ...args], {
feat(dev-server): serialise unit-test runs behind the daemon (#3947) * feat(dev-server): serialise unit-test runs behind the daemon The unit suite takes every core. One run is fine; several agents each starting one at the same moment is what flattens the machine, and capping the worker pool per run does not stop that. This adds the scheduler. Two calls, because a caller needs to know where it stands before it decides how to wait. `test run` returns immediately with either "started" or a position plus the command to wait on; `test wait` blocks until the run finishes and exits with its exit code. The wait polls from the CLI rather than holding a daemon request, since the daemon is single-process and a held request would stall every other agent's polling. The daemon owns the run, not the caller, which is what makes a dead agent harmless: it releases nothing because it was holding nothing. Slots are held while a run is tracked and released on child exit, never on a status field -- status is a report, not an observation, and cannot see a grandchild that outlived a kill. Three deadlines close the rest: a queued run whose caller stopped polling is dropped, a run that overruns its ceiling is killed, and a kill that produces no exit frees the slot anyway after a grace period. Concurrency defaults to 1 and is configurable. 0 is legal and means paused, with the pause reported to callers rather than left to be inferred from a position that never moves. The 404 on an unknown run id is load-bearing rather than incidental: it is how a waiter learns the daemon was restarted and its run is gone, instead of polling for a result nobody will produce. * fix(dev-server): close four holes an adversarial review found in the queue 1. `test wait` exited 0 for a cancelled or timed-out run whose child happened to exit 0 -- the window between a kill being issued and it landing. This is the worst possible failure for a command meant to substitute for the suite in a verification chain, because it reports a green run that never finished. Only a completed run that itself exited 0 is a pass now, and the decision lives in one exported function so it can be tested rather than inferred. 2. A malformed TEST_CONCURRENCY killed the daemon at import. The queue is built at module scope and the constructor throws, so a typo in an optional test setting stopped the daemon binding at all -- taking every agent's dev server, session list and worktree tooling with it. It now falls back to 1 and says so, like every other setting in that file. 3. A runner reporting its exit synchronously lost the event, because the listener was attached after the runner returned. The finished run held the only slot until the 30-minute ceiling while everything behind it was abandoned rather than run, and it then settled as `timeout` with an error string that was false. The exit path is now built before the runner is called. 4. The line whose removal produces exactly the permanent wedge this feature exists to prevent had no coverage: 15/15 passed with it deleted. The test sweeps inside the grace the way the daemon's own 5s timer does, so a reset deadline now fails as `expected [] to deeply equal ['<id>']`. Also: children get their own process group on POSIX, without which killing by negative pid names no group and silently leaves vitest running while its slot is handed on -- serialisation quietly becoming concurrency 2 under exactly the load this is for. Daemon shutdown kills synchronously so the child cannot outlive it. Every fix has a mutation control: reverting each one fails on a named value. * fix(dev-server): three more from the second review pass The identity half of the exit guard was untested -- 21/21 passed with it removed. It only fires when a late exit arrives after a force-release, and no test delivered one. It was defended in practice only by the exit-code rule catching the consequence, which is two fixes covering for each other rather than either being pinned. Now a test force-releases a slot and then delivers the exit. `exitCode || 1` passed a signal death straight through as -1, which a shell sees as 255 -- and `detached` + SIGKILL means that is now the normal shape of every cancel and timeout on POSIX. Real failing codes still pass through; anything that is not a positive integer becomes 1. The exit-code table gains a row with a distinctive code, since every row it had was one whose answer is 1 anyway, so it could not tell a passed-through code from a hardcoded one. A child that dies by signal reports no code at all. That is an OOM kill or an outside hand, not a verdict on the tests, so it settles as `error` rather than claiming a test result nothing produced. `execFileSync` on shutdown had no timeout. It runs on the daemon's event loop and is also reached from the SIGINT handler, so a taskkill that blocked would leave the daemon unkillable by signal. A kill we cannot complete is better abandoned -- the sweep frees the slot regardless. * fix(dev-server): keep the verdict when a runner reports then throws The catch around startRun neither checked nor set the settled flag, so a runner that reported an exit and then threw had its real result overwritten by the noise that followed it -- completed/0 became error/null. Degrades safe and the shipped runner cannot produce it, but a verdict that exists should not be discarded. * feat(dev-server): route test:unit:run through the queue when opted in Replaces the hook approach. A PreToolUse hook has to decide from the command text whether a run is happening, and three adversarial passes showed that cannot be done: it ended with 20 known bypasses and 6 false denials, including refusing a commit whose message merely mentioned the suite. Inside the script there is nothing to parse -- whatever shell, wrapper, quoting or directory reaches it gets the queue. It routes rather than refuses, which is the difference that makes it work: no second command to learn, nothing to wrap around, and an agent that never read the guidance still gets queued instead of an error it will work around. Off unless CIVITAI_TEST_QUEUE is set, and off in CI regardless, so this is a no-op for everyone who does not run the daemon -- the same vitest invocation as before. A file-scoped run stays direct: queueing a two-second check behind a nine-minute suite would break the fast loop and push callers toward batching more into each run, which is the opposite of the point. The queue being unreachable falls back to running directly. Nobody should be unable to run tests because a daemon is down.
2026-08-14 19:09:25 -06:00
cwd: repoRoot,
stdio: 'inherit',
shell: process.platform === 'win32',
});
child.on('exit', (code, signal) => process.exit(signal ? 1 : code ?? 1));
child.on('error', (err) => {
console.error(`Failed to start vitest: ${err.message}`);
process.exit(1);
});
}
async function post(path, body) {
const res = await fetch(`${DAEMON}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`daemon returned ${res.status}`);
return res.json();
}
function ensureDaemon() {
return new Promise((done) => {
const child = spawn(process.execPath, [CLI, 'status'], { cwd: repoRoot, stdio: 'ignore' });
child.on('exit', () => done());
child.on('error', () => done());
});
}
/**
* The queue keeps a bounded window of a run's output. Dropping the oldest lines is fine; dropping
* them SILENTLY is not, because a truncated log is indistinguishable from a complete one — that is
* how a clipped log gets quoted as a full-suite pass. Say the number out loud instead.
*/
function warnIfLogsDropped(state) {
if (!state.logsDropped) return;
console.error(
`WARNING: this log is INCOMPLETE — the queue dropped the oldest ${state.logsDropped} of ` +
`${state.logIndex} output lines. Do not read the text above as the whole run.`
);
}
feat(dev-server): serialise unit-test runs behind the daemon (#3947) * feat(dev-server): serialise unit-test runs behind the daemon The unit suite takes every core. One run is fine; several agents each starting one at the same moment is what flattens the machine, and capping the worker pool per run does not stop that. This adds the scheduler. Two calls, because a caller needs to know where it stands before it decides how to wait. `test run` returns immediately with either "started" or a position plus the command to wait on; `test wait` blocks until the run finishes and exits with its exit code. The wait polls from the CLI rather than holding a daemon request, since the daemon is single-process and a held request would stall every other agent's polling. The daemon owns the run, not the caller, which is what makes a dead agent harmless: it releases nothing because it was holding nothing. Slots are held while a run is tracked and released on child exit, never on a status field -- status is a report, not an observation, and cannot see a grandchild that outlived a kill. Three deadlines close the rest: a queued run whose caller stopped polling is dropped, a run that overruns its ceiling is killed, and a kill that produces no exit frees the slot anyway after a grace period. Concurrency defaults to 1 and is configurable. 0 is legal and means paused, with the pause reported to callers rather than left to be inferred from a position that never moves. The 404 on an unknown run id is load-bearing rather than incidental: it is how a waiter learns the daemon was restarted and its run is gone, instead of polling for a result nobody will produce. * fix(dev-server): close four holes an adversarial review found in the queue 1. `test wait` exited 0 for a cancelled or timed-out run whose child happened to exit 0 -- the window between a kill being issued and it landing. This is the worst possible failure for a command meant to substitute for the suite in a verification chain, because it reports a green run that never finished. Only a completed run that itself exited 0 is a pass now, and the decision lives in one exported function so it can be tested rather than inferred. 2. A malformed TEST_CONCURRENCY killed the daemon at import. The queue is built at module scope and the constructor throws, so a typo in an optional test setting stopped the daemon binding at all -- taking every agent's dev server, session list and worktree tooling with it. It now falls back to 1 and says so, like every other setting in that file. 3. A runner reporting its exit synchronously lost the event, because the listener was attached after the runner returned. The finished run held the only slot until the 30-minute ceiling while everything behind it was abandoned rather than run, and it then settled as `timeout` with an error string that was false. The exit path is now built before the runner is called. 4. The line whose removal produces exactly the permanent wedge this feature exists to prevent had no coverage: 15/15 passed with it deleted. The test sweeps inside the grace the way the daemon's own 5s timer does, so a reset deadline now fails as `expected [] to deeply equal ['<id>']`. Also: children get their own process group on POSIX, without which killing by negative pid names no group and silently leaves vitest running while its slot is handed on -- serialisation quietly becoming concurrency 2 under exactly the load this is for. Daemon shutdown kills synchronously so the child cannot outlive it. Every fix has a mutation control: reverting each one fails on a named value. * fix(dev-server): three more from the second review pass The identity half of the exit guard was untested -- 21/21 passed with it removed. It only fires when a late exit arrives after a force-release, and no test delivered one. It was defended in practice only by the exit-code rule catching the consequence, which is two fixes covering for each other rather than either being pinned. Now a test force-releases a slot and then delivers the exit. `exitCode || 1` passed a signal death straight through as -1, which a shell sees as 255 -- and `detached` + SIGKILL means that is now the normal shape of every cancel and timeout on POSIX. Real failing codes still pass through; anything that is not a positive integer becomes 1. The exit-code table gains a row with a distinctive code, since every row it had was one whose answer is 1 anyway, so it could not tell a passed-through code from a hardcoded one. A child that dies by signal reports no code at all. That is an OOM kill or an outside hand, not a verdict on the tests, so it settles as `error` rather than claiming a test result nothing produced. `execFileSync` on shutdown had no timeout. It runs on the daemon's event loop and is also reached from the SIGINT handler, so a taskkill that blocked would leave the daemon unkillable by signal. A kill we cannot complete is better abandoned -- the sweep frees the slot regardless. * fix(dev-server): keep the verdict when a runner reports then throws The catch around startRun neither checked nor set the settled flag, so a runner that reported an exit and then threw had its real result overwritten by the noise that followed it -- completed/0 became error/null. Degrades safe and the shipped runner cannot produce it, but a verdict that exists should not be discarded. * feat(dev-server): route test:unit:run through the queue when opted in Replaces the hook approach. A PreToolUse hook has to decide from the command text whether a run is happening, and three adversarial passes showed that cannot be done: it ended with 20 known bypasses and 6 false denials, including refusing a commit whose message merely mentioned the suite. Inside the script there is nothing to parse -- whatever shell, wrapper, quoting or directory reaches it gets the queue. It routes rather than refuses, which is the difference that makes it work: no second command to learn, nothing to wrap around, and an agent that never read the guidance still gets queued instead of an error it will work around. Off unless CIVITAI_TEST_QUEUE is set, and off in CI regardless, so this is a no-op for everyone who does not run the daemon -- the same vitest invocation as before. A file-scoped run stays direct: queueing a two-second check behind a nine-minute suite would break the fast loop and push callers toward batching more into each run, which is the opposite of the point. The queue being unreachable falls back to running directly. Nobody should be unable to run tests because a daemon is down.
2026-08-14 19:09:25 -06:00
async function runQueued(args) {
// Resolved once, up front: this is the module that decides pass from fail, and a waiter that
// discovers it cannot load that rule at the moment it must apply it has no verdict to give.
const { exitCodeFor } = await import(pathToFileURL(QUEUE).href);
fix(dev-server): the daemon never read DEV_DAEMON_PORT, so setting it broke the CLI instead of moving the daemon (#4181) * fix(dev-server): the daemon never read DEV_DAEMON_PORT, so setting it broke the CLI instead of moving the daemon The variable was read where the client decides what to CONNECT to and ignored where the daemon decides what to LISTEN on. `DEV_DAEMON_PORT=9555 cli.mjs status` therefore pointed the CLI at :9555, spawned a daemon that bound :9444, and could not reach it — the daemon has always accepted `--port`, the spawn simply passed no arguments and the daemon read no environment. Four files each decided the port for themselves and two of them were wrong: cli.mjs and scripts/test-unit-run.mjs read the variable, console.mjs hardcoded 9444, and daemon.mjs saw only argv. That is not a bug any one of them contains — it is a bug in the set, so the number now lives in exactly one module and all four resolve it there. A daemon a client spawns inherits that client's environment, so both ends read the same variable through the same function; an explicit `--port` still wins for a daemon started by hand. resolveDaemonPort also refuses a value that is not a port rather than handing back parseInt's NaN, which used to reach a URL as `http://127.0.0.1:NaN` and fail a long way from its cause. Verified by reproducing the reported path, not by reading the code. On pre-change code with DEV_DAEMON_PORT=19461 the daemon logged `Daemon port: 9444`, emitted no ready line, and nothing ever listened on 19461. After the change it reports and binds 19461, and the pid it reports is the child that was spawned — a port that answers proves a listener, not THIS listener. Six isolated mutants, each killed by its own named assertion: daemon ignores the environment -> both behavioural cases daemon ignores --port -> the --port precedence case resolver stops validating -> the rejects-a-non-port case console.mjs re-hardcodes 9444 -> both halves of the ledger test-unit-run.mjs re-hardcodes -> both halves of the ledger the default drifts to 9445 -> the default case console.mjs is a TUI with no end-to-end case here, which is why the ledger exists: it fails when the set of files deciding the port grows or shrinks. Closes ClickUp 868kuaa4e. * fix(dev-server): the audit round — the test deleted the pid file it was written to protect, and the ledger could not see the set grow 🔴 The blocking one. `POST /shutdown` replies 200 and only THEN schedules `unlinkSync(pidFile); process.exit(0)` on a 100 ms timer, so the helper that saved and restored the developer's `daemon.pid` restored it ~100 ms before the daemon deleted it. `scripts/**/*.test.ts` is in the `unit` project, so every `pnpm test:unit:run` removed `.claude/skills/dev-server/daemon.pid` — and the file is only written at daemon START, so it did not come back. That breaks the recipe SKILL.md itself gives for checking the daemon's interpreter, and makes `cli.mjs shutdown`'s cleanup a no-op. `shutdown()` now waits for the port to stop answering before returning. The ledger scanned a FIXED list of four readers, so it could not detect the set GROWING — which the PR description claimed it did. A fifth hardcoded 9444 already existed while the test was green: `.claude/hooks/check-writable.mjs` baked it into the dev-port regex, so `DEV_DAEMON_PORT=9555` silently switched that nudge off for the daemon, the one long-lived server it most exists for. The ledger now walks the tree, so a file that does not exist yet is covered, and the hook reads the port from the module. Verified with a negative control: with DEV_DAEMON_PORT=9555, `curl :9555` nudges and `curl :7777` stays silent. The ledger's assertions were also spelled rather than structural. `includes('daemon-port.mjs')` is satisfied by a COMMENT — and this file's own prose names the module in several — and `includes('resolveDaemonPort(')` is satisfied by any call whatever is done with the result, so `resolveDaemonPort() + 1` passed all of it while console.mjs has no behavioural test. Both clients now take a whole URL from `resolveDaemonUrl()` and do no arithmetic on a port; the assertions pin an import line and that call. Three more the audit found: - `scripts/test-unit-run.mjs` lost its "never leave a caller unable to run tests" guarantee. Resolution moved inside `runQueued` but OUTSIDE its try, and `runQueued` is un-awaited, so a malformed DEV_DAEMON_PORT became an unhandled rejection and no tests ran. Measured against base: base printed "running directly", HEAD died. Restored, with the outer `.catch()` as the general net. - `--port` still open-coded `parseInt`, so the HIGHER-precedence input was the unvalidated one — `--port abc` gave ERR_SOCKET_BAD_PORT, which via cli.mjs is invisible (detached, stdio ignored) and reads only as "Failed to start daemon". Both inputs now go through `parsePort`, which names the input it is complaining about. - Resolving at module load made a bad DEV_DAEMON_PORT throw on merely IMPORTING daemon.mjs, which would have broken collection of the port-reservation suite. Resolution moved into `parseArgs`. Nits: SKILL.md said `cli.mjs:69`, the line is 70 — my own earlier check of that number had gone stale under a later edit. Out-of-range is now a different message from unparseable ('0' is a number, just not a port). The 9444 scan uses a word boundary so 19444 is not a false positive. Seven mutants, each applied alone, tree restored and cmp-verified between: shutdown() stops waiting -> the pid-file case a NEW file hardcodes 9444 -> the ledger (the growth case) the hook re-hardcodes 9444 -> the ledger console.mjs does resolvePort() + 1 -> the client-address case the targeted fallback is removed -> the degrade-to-direct case --port back to parseInt -> the argv validation case port resolved at module load -> the import-safety case 🔴 One of them SURVIVED the first run and the fix is the point: the fallback case asserted the generic phrase "running directly", which BOTH guards on that path print, so deleting the targeted guard left the suite green — the mutant died to the other guard. The assertion now names the specific message. scripts/__tests__ 324 passed. typecheck 0 errors. Hook selftest green. * fix(dev-server): round 3 — the last round's hook fix un-guarded the default port, and its catch would have double-run the suite Both regressions were introduced by the previous commit, not pre-existing, and a delta re-audit of 7dd65a9d62..85cb922567 found them. 🔴-in-effect: `DEV_DAEMON_PORT` REPLACED the guarded port instead of adding to it, so setting it un-guarded the shared daemon. Reproduced through the hook's real stdin contract: with the override set, `curl http://localhost:9444/...` went from ask to allowed, and the hook's own selftest went from `all green` to `1 FAILURES`. SKILL.md documents the override as standing a daemon BESIDE the shared one — both are live, so both need guarding. `daemonPortsGuarded()` now returns the union, and the selftest is green both plain and under the override. The other one is subtler and worse in effect. The outer `.catch()` I added wrapped the whole `runQueued` lifecycle, not just queue acquisition — so a socket dropped mid-poll abandoned a run the daemon had ALREADY ACCEPTED and started a second, unqueued full suite beside it. That defeats the serialisation this script exists for, and the file had already decided the opposite for the status-code form of the same condition (`Lost contact with the test queue` -> exit 2). It now tracks whether the run was accepted: before acceptance it degrades to a direct run, after acceptance it exits 2. `err?.message`, because a non-Error throw printed `(undefined)` and a null throw raised inside the handler — back to the unhandled rejection the catch exists to prevent. Also from the re-audit: - The port scrape in the hook is a dependency on one line's formatting in another file and nothing tested it. A vitest case now drives the real `unboundedDevRequest`, with a negative control on a port the skill does not use. Breaking the scrape regex now fails two tests; it used to fail none. - `console.mjs` had no behavioural coverage, and every structural assertion was walkable by resolving the URL correctly and then drifting it. It now runs for real against a stub daemon on an ephemeral port — via `--tail`, since the dashboard refuses to start without a TTY and exits before contacting anything. - `--base-dev-port` was still `parseInt`, which made "argv gets the same validation the environment gets" false about half of argv. - The port range's upper bound was untested: fixtures were 0 and 70000, so 65535 could drift to 65536 unnoticed. Now pinned ON the boundary. - SKILL.md's `console.mjs:88` — the previous commit fixed the cli.mjs citation and moved console.mjs's line to 89 in the same change. Now 89. - The module header claimed the scan covers "the whole skill ... anywhere". It covers three roots and four extensions; prose and test files are deliberately out of scope. Said so instead. 🔴 The ledger caught ME during this round: a comment I wrote explaining the un-guarding bug spelled the port, and `spells the port in exactly one source file` went red. That is the guard working on its author — reworded, not exempted. Round-3 battery, each mutant alone, tree cmp-verified between: outer .catch() deleted -> the accepted-run case accepted-check removed -> the accepted-run case hook override replaces default -> the union case hook scrape regex broken -> both hook cases upper bound 65535 -> 65536 -> the boundary case console.mjs drifts the URL -> the behavioural case + the ledger --base-dev-port back to parseInt -> the base-dev-port case All killed. One deliberate survivor: a copy OUTSIDE the three scanned roots, which is the documented scope and is now stated in the header rather than overclaimed. 330 passed. typecheck 0 errors. Hook selftest green plain and under override. * style: prettier the added test file — the CI gate checks ADDED files and I never ran it `ESLint + Prettier (changed files)` went red on the round-3 push for one reason: `scripts/__tests__/dev-server-daemon-port.test.ts` was not prettier-formatted. Four lines, all wrapping. Reproduced the gate's exact scope locally rather than guessing at it — it runs `prettier --list-different` over ADDED files only, which is why the four `.mjs` files this branch MODIFIES were never checked. Those four are unformatted, and the control says that is not mine: at the merge base `bc74ba06ea`, all four are already unformatted. Reformatting them would rewrite files this change does not own, which is the breadth rule in CLAUDE.md, so they are left alone. Local prettier is 2.8.8, the same version CI installs, so the local format is the one the gate will read. 21 tests still pass. * fix(dev-server): round-4 tidy — the test that proves the console bug was itself destroying the pid file on its red path Follow-ups from the round-3 delta re-audit, which found no 🔴 and confirmed round 3 did not reintroduce a regression. These are the 🟡/🟢 it did find. The console test was not wrapped in `withPidFilePreserved`, and the exposure is specifically on the FAILING path: on green the stub answers `/`, so the console never starts a daemon. Under the mutant the test exists to catch, it cannot reach the stub, falls through to its own `startDaemon`, and overwrites `daemon.pid` with a dead pid — the test that proves the bug also damaged the thing the rest of the file is careful about. Controlled both ways: with the drift mutant applied the test now goes red AND the pid file's md5 is unchanged. The port scrape in the hook is anchored on `export const` again. Dropping the anchor was justified as surviving a reformat, and that reasoning was wrong — a reformat does not rewrite `export const NAME =`. What it actually bought was letting the FIRST match anywhere in the file win, comments included: a `// historical: DEFAULT_DAEMON_PORT = 9999` line above the declaration made the hook guard 9999 and stop guarding the real port. Controlled: that same line now yields 9444. Three comments that no longer described their code. `test-unit-run.mjs` had the previous round's two lines left verbatim above the block that replaced them, so the same sentence appeared twice. The hook still said the port "is overridable via DEV_DAEMON_PORT" — the exact semantics round 3 removed, since the set is now additive — and its "resolved lazily" note sat above `daemonPortsGuarded`, which is neither lazy nor cached; the laziness is in `devPorts`. Numeric line references (`:123`, `:128`) replaced with named ones. Both had already rotted by three lines. This is the same class as the SKILL.md citation fixed last round, and a line number in a comment will rot again — a quoted message will not. Named the residual hole rather than implying it away: the daemon enqueues INSIDE the response write, so the slot is taken before the client can observe it. Lose the response between those points and `accepted` is still false while the run is queued. That window needs an idempotency key on the enqueue, not a flag here. Not changed, deliberately. `.claude/**/*.mjs` fails `prettier --check` at the merge base as well as here, so it is pre-existing and reformatting it would rewrite files this change does not own. And `scripts/__tests__/*.ts` is typechecked by nothing — `tsconfig.json`'s `include` omits it — so this PR's "typecheck 0 errors" says nothing about the new test file's annotations. Both are stated rather than quietly folded in. 330 passed. Hook selftest green plain and under the override. Test file prettier-clean.
2026-08-20 01:23:47 -05:00
// Resolving the daemon's address can THROW — a malformed DEV_DAEMON_PORT is rejected rather
// than silently becoming NaN. That must not cost the caller their test run: the
// "Test queue unreachable" guarantee below is that an unusable queue degrades to a direct run,
// and an unusable ADDRESS is the queue being unusable. Before this catch existed the throw
// escaped an un-awaited `runQueued` as an unhandled rejection and no tests ran at all.
try {
const { resolveDaemonUrl } = await import(pathToFileURL(PORT_MODULE).href);
DAEMON = resolveDaemonUrl();
} catch (err) {
console.error(`Test queue address unusable (${err.message}); running directly.`);
return runDirect(args);
}
feat(dev-server): serialise unit-test runs behind the daemon (#3947) * feat(dev-server): serialise unit-test runs behind the daemon The unit suite takes every core. One run is fine; several agents each starting one at the same moment is what flattens the machine, and capping the worker pool per run does not stop that. This adds the scheduler. Two calls, because a caller needs to know where it stands before it decides how to wait. `test run` returns immediately with either "started" or a position plus the command to wait on; `test wait` blocks until the run finishes and exits with its exit code. The wait polls from the CLI rather than holding a daemon request, since the daemon is single-process and a held request would stall every other agent's polling. The daemon owns the run, not the caller, which is what makes a dead agent harmless: it releases nothing because it was holding nothing. Slots are held while a run is tracked and released on child exit, never on a status field -- status is a report, not an observation, and cannot see a grandchild that outlived a kill. Three deadlines close the rest: a queued run whose caller stopped polling is dropped, a run that overruns its ceiling is killed, and a kill that produces no exit frees the slot anyway after a grace period. Concurrency defaults to 1 and is configurable. 0 is legal and means paused, with the pause reported to callers rather than left to be inferred from a position that never moves. The 404 on an unknown run id is load-bearing rather than incidental: it is how a waiter learns the daemon was restarted and its run is gone, instead of polling for a result nobody will produce. * fix(dev-server): close four holes an adversarial review found in the queue 1. `test wait` exited 0 for a cancelled or timed-out run whose child happened to exit 0 -- the window between a kill being issued and it landing. This is the worst possible failure for a command meant to substitute for the suite in a verification chain, because it reports a green run that never finished. Only a completed run that itself exited 0 is a pass now, and the decision lives in one exported function so it can be tested rather than inferred. 2. A malformed TEST_CONCURRENCY killed the daemon at import. The queue is built at module scope and the constructor throws, so a typo in an optional test setting stopped the daemon binding at all -- taking every agent's dev server, session list and worktree tooling with it. It now falls back to 1 and says so, like every other setting in that file. 3. A runner reporting its exit synchronously lost the event, because the listener was attached after the runner returned. The finished run held the only slot until the 30-minute ceiling while everything behind it was abandoned rather than run, and it then settled as `timeout` with an error string that was false. The exit path is now built before the runner is called. 4. The line whose removal produces exactly the permanent wedge this feature exists to prevent had no coverage: 15/15 passed with it deleted. The test sweeps inside the grace the way the daemon's own 5s timer does, so a reset deadline now fails as `expected [] to deeply equal ['<id>']`. Also: children get their own process group on POSIX, without which killing by negative pid names no group and silently leaves vitest running while its slot is handed on -- serialisation quietly becoming concurrency 2 under exactly the load this is for. Daemon shutdown kills synchronously so the child cannot outlive it. Every fix has a mutation control: reverting each one fails on a named value. * fix(dev-server): three more from the second review pass The identity half of the exit guard was untested -- 21/21 passed with it removed. It only fires when a late exit arrives after a force-release, and no test delivered one. It was defended in practice only by the exit-code rule catching the consequence, which is two fixes covering for each other rather than either being pinned. Now a test force-releases a slot and then delivers the exit. `exitCode || 1` passed a signal death straight through as -1, which a shell sees as 255 -- and `detached` + SIGKILL means that is now the normal shape of every cancel and timeout on POSIX. Real failing codes still pass through; anything that is not a positive integer becomes 1. The exit-code table gains a row with a distinctive code, since every row it had was one whose answer is 1 anyway, so it could not tell a passed-through code from a hardcoded one. A child that dies by signal reports no code at all. That is an OOM kill or an outside hand, not a verdict on the tests, so it settles as `error` rather than claiming a test result nothing produced. `execFileSync` on shutdown had no timeout. It runs on the daemon's event loop and is also reached from the SIGINT handler, so a taskkill that blocked would leave the daemon unkillable by signal. A kill we cannot complete is better abandoned -- the sweep frees the slot regardless. * fix(dev-server): keep the verdict when a runner reports then throws The catch around startRun neither checked nor set the settled flag, so a runner that reported an exit and then threw had its real result overwritten by the noise that followed it -- completed/0 became error/null. Degrades safe and the shipped runner cannot produce it, but a verdict that exists should not be discarded. * feat(dev-server): route test:unit:run through the queue when opted in Replaces the hook approach. A PreToolUse hook has to decide from the command text whether a run is happening, and three adversarial passes showed that cannot be done: it ended with 20 known bypasses and 6 false denials, including refusing a commit whose message merely mentioned the suite. Inside the script there is nothing to parse -- whatever shell, wrapper, quoting or directory reaches it gets the queue. It routes rather than refuses, which is the difference that makes it work: no second command to learn, nothing to wrap around, and an agent that never read the guidance still gets queued instead of an error it will work around. Off unless CIVITAI_TEST_QUEUE is set, and off in CI regardless, so this is a no-op for everyone who does not run the daemon -- the same vitest invocation as before. A file-scoped run stays direct: queueing a two-second check behind a nine-minute suite would break the fast loop and push callers toward batching more into each run, which is the opposite of the point. The queue being unreachable falls back to running directly. Nobody should be unable to run tests because a daemon is down.
2026-08-14 19:09:25 -06:00
let run;
try {
run = await post('/test-runs', { worktree: repoRoot, args });
} catch {
await ensureDaemon();
try {
run = await post('/test-runs', { worktree: repoRoot, args });
} catch (err) {
// Never leave a caller unable to run tests because the queue is unavailable.
console.error(`Test queue unreachable (${err.message}); running directly.`);
return runDirect(args);
}
}
fix(dev-server): the daemon never read DEV_DAEMON_PORT, so setting it broke the CLI instead of moving the daemon (#4181) * fix(dev-server): the daemon never read DEV_DAEMON_PORT, so setting it broke the CLI instead of moving the daemon The variable was read where the client decides what to CONNECT to and ignored where the daemon decides what to LISTEN on. `DEV_DAEMON_PORT=9555 cli.mjs status` therefore pointed the CLI at :9555, spawned a daemon that bound :9444, and could not reach it — the daemon has always accepted `--port`, the spawn simply passed no arguments and the daemon read no environment. Four files each decided the port for themselves and two of them were wrong: cli.mjs and scripts/test-unit-run.mjs read the variable, console.mjs hardcoded 9444, and daemon.mjs saw only argv. That is not a bug any one of them contains — it is a bug in the set, so the number now lives in exactly one module and all four resolve it there. A daemon a client spawns inherits that client's environment, so both ends read the same variable through the same function; an explicit `--port` still wins for a daemon started by hand. resolveDaemonPort also refuses a value that is not a port rather than handing back parseInt's NaN, which used to reach a URL as `http://127.0.0.1:NaN` and fail a long way from its cause. Verified by reproducing the reported path, not by reading the code. On pre-change code with DEV_DAEMON_PORT=19461 the daemon logged `Daemon port: 9444`, emitted no ready line, and nothing ever listened on 19461. After the change it reports and binds 19461, and the pid it reports is the child that was spawned — a port that answers proves a listener, not THIS listener. Six isolated mutants, each killed by its own named assertion: daemon ignores the environment -> both behavioural cases daemon ignores --port -> the --port precedence case resolver stops validating -> the rejects-a-non-port case console.mjs re-hardcodes 9444 -> both halves of the ledger test-unit-run.mjs re-hardcodes -> both halves of the ledger the default drifts to 9445 -> the default case console.mjs is a TUI with no end-to-end case here, which is why the ledger exists: it fails when the set of files deciding the port grows or shrinks. Closes ClickUp 868kuaa4e. * fix(dev-server): the audit round — the test deleted the pid file it was written to protect, and the ledger could not see the set grow 🔴 The blocking one. `POST /shutdown` replies 200 and only THEN schedules `unlinkSync(pidFile); process.exit(0)` on a 100 ms timer, so the helper that saved and restored the developer's `daemon.pid` restored it ~100 ms before the daemon deleted it. `scripts/**/*.test.ts` is in the `unit` project, so every `pnpm test:unit:run` removed `.claude/skills/dev-server/daemon.pid` — and the file is only written at daemon START, so it did not come back. That breaks the recipe SKILL.md itself gives for checking the daemon's interpreter, and makes `cli.mjs shutdown`'s cleanup a no-op. `shutdown()` now waits for the port to stop answering before returning. The ledger scanned a FIXED list of four readers, so it could not detect the set GROWING — which the PR description claimed it did. A fifth hardcoded 9444 already existed while the test was green: `.claude/hooks/check-writable.mjs` baked it into the dev-port regex, so `DEV_DAEMON_PORT=9555` silently switched that nudge off for the daemon, the one long-lived server it most exists for. The ledger now walks the tree, so a file that does not exist yet is covered, and the hook reads the port from the module. Verified with a negative control: with DEV_DAEMON_PORT=9555, `curl :9555` nudges and `curl :7777` stays silent. The ledger's assertions were also spelled rather than structural. `includes('daemon-port.mjs')` is satisfied by a COMMENT — and this file's own prose names the module in several — and `includes('resolveDaemonPort(')` is satisfied by any call whatever is done with the result, so `resolveDaemonPort() + 1` passed all of it while console.mjs has no behavioural test. Both clients now take a whole URL from `resolveDaemonUrl()` and do no arithmetic on a port; the assertions pin an import line and that call. Three more the audit found: - `scripts/test-unit-run.mjs` lost its "never leave a caller unable to run tests" guarantee. Resolution moved inside `runQueued` but OUTSIDE its try, and `runQueued` is un-awaited, so a malformed DEV_DAEMON_PORT became an unhandled rejection and no tests ran. Measured against base: base printed "running directly", HEAD died. Restored, with the outer `.catch()` as the general net. - `--port` still open-coded `parseInt`, so the HIGHER-precedence input was the unvalidated one — `--port abc` gave ERR_SOCKET_BAD_PORT, which via cli.mjs is invisible (detached, stdio ignored) and reads only as "Failed to start daemon". Both inputs now go through `parsePort`, which names the input it is complaining about. - Resolving at module load made a bad DEV_DAEMON_PORT throw on merely IMPORTING daemon.mjs, which would have broken collection of the port-reservation suite. Resolution moved into `parseArgs`. Nits: SKILL.md said `cli.mjs:69`, the line is 70 — my own earlier check of that number had gone stale under a later edit. Out-of-range is now a different message from unparseable ('0' is a number, just not a port). The 9444 scan uses a word boundary so 19444 is not a false positive. Seven mutants, each applied alone, tree restored and cmp-verified between: shutdown() stops waiting -> the pid-file case a NEW file hardcodes 9444 -> the ledger (the growth case) the hook re-hardcodes 9444 -> the ledger console.mjs does resolvePort() + 1 -> the client-address case the targeted fallback is removed -> the degrade-to-direct case --port back to parseInt -> the argv validation case port resolved at module load -> the import-safety case 🔴 One of them SURVIVED the first run and the fix is the point: the fallback case asserted the generic phrase "running directly", which BOTH guards on that path print, so deleting the targeted guard left the suite green — the mutant died to the other guard. The assertion now names the specific message. scripts/__tests__ 324 passed. typecheck 0 errors. Hook selftest green. * fix(dev-server): round 3 — the last round's hook fix un-guarded the default port, and its catch would have double-run the suite Both regressions were introduced by the previous commit, not pre-existing, and a delta re-audit of 7dd65a9d62..85cb922567 found them. 🔴-in-effect: `DEV_DAEMON_PORT` REPLACED the guarded port instead of adding to it, so setting it un-guarded the shared daemon. Reproduced through the hook's real stdin contract: with the override set, `curl http://localhost:9444/...` went from ask to allowed, and the hook's own selftest went from `all green` to `1 FAILURES`. SKILL.md documents the override as standing a daemon BESIDE the shared one — both are live, so both need guarding. `daemonPortsGuarded()` now returns the union, and the selftest is green both plain and under the override. The other one is subtler and worse in effect. The outer `.catch()` I added wrapped the whole `runQueued` lifecycle, not just queue acquisition — so a socket dropped mid-poll abandoned a run the daemon had ALREADY ACCEPTED and started a second, unqueued full suite beside it. That defeats the serialisation this script exists for, and the file had already decided the opposite for the status-code form of the same condition (`Lost contact with the test queue` -> exit 2). It now tracks whether the run was accepted: before acceptance it degrades to a direct run, after acceptance it exits 2. `err?.message`, because a non-Error throw printed `(undefined)` and a null throw raised inside the handler — back to the unhandled rejection the catch exists to prevent. Also from the re-audit: - The port scrape in the hook is a dependency on one line's formatting in another file and nothing tested it. A vitest case now drives the real `unboundedDevRequest`, with a negative control on a port the skill does not use. Breaking the scrape regex now fails two tests; it used to fail none. - `console.mjs` had no behavioural coverage, and every structural assertion was walkable by resolving the URL correctly and then drifting it. It now runs for real against a stub daemon on an ephemeral port — via `--tail`, since the dashboard refuses to start without a TTY and exits before contacting anything. - `--base-dev-port` was still `parseInt`, which made "argv gets the same validation the environment gets" false about half of argv. - The port range's upper bound was untested: fixtures were 0 and 70000, so 65535 could drift to 65536 unnoticed. Now pinned ON the boundary. - SKILL.md's `console.mjs:88` — the previous commit fixed the cli.mjs citation and moved console.mjs's line to 89 in the same change. Now 89. - The module header claimed the scan covers "the whole skill ... anywhere". It covers three roots and four extensions; prose and test files are deliberately out of scope. Said so instead. 🔴 The ledger caught ME during this round: a comment I wrote explaining the un-guarding bug spelled the port, and `spells the port in exactly one source file` went red. That is the guard working on its author — reworded, not exempted. Round-3 battery, each mutant alone, tree cmp-verified between: outer .catch() deleted -> the accepted-run case accepted-check removed -> the accepted-run case hook override replaces default -> the union case hook scrape regex broken -> both hook cases upper bound 65535 -> 65536 -> the boundary case console.mjs drifts the URL -> the behavioural case + the ledger --base-dev-port back to parseInt -> the base-dev-port case All killed. One deliberate survivor: a copy OUTSIDE the three scanned roots, which is the documented scope and is now stated in the header rather than overclaimed. 330 passed. typecheck 0 errors. Hook selftest green plain and under override. * style: prettier the added test file — the CI gate checks ADDED files and I never ran it `ESLint + Prettier (changed files)` went red on the round-3 push for one reason: `scripts/__tests__/dev-server-daemon-port.test.ts` was not prettier-formatted. Four lines, all wrapping. Reproduced the gate's exact scope locally rather than guessing at it — it runs `prettier --list-different` over ADDED files only, which is why the four `.mjs` files this branch MODIFIES were never checked. Those four are unformatted, and the control says that is not mine: at the merge base `bc74ba06ea`, all four are already unformatted. Reformatting them would rewrite files this change does not own, which is the breadth rule in CLAUDE.md, so they are left alone. Local prettier is 2.8.8, the same version CI installs, so the local format is the one the gate will read. 21 tests still pass. * fix(dev-server): round-4 tidy — the test that proves the console bug was itself destroying the pid file on its red path Follow-ups from the round-3 delta re-audit, which found no 🔴 and confirmed round 3 did not reintroduce a regression. These are the 🟡/🟢 it did find. The console test was not wrapped in `withPidFilePreserved`, and the exposure is specifically on the FAILING path: on green the stub answers `/`, so the console never starts a daemon. Under the mutant the test exists to catch, it cannot reach the stub, falls through to its own `startDaemon`, and overwrites `daemon.pid` with a dead pid — the test that proves the bug also damaged the thing the rest of the file is careful about. Controlled both ways: with the drift mutant applied the test now goes red AND the pid file's md5 is unchanged. The port scrape in the hook is anchored on `export const` again. Dropping the anchor was justified as surviving a reformat, and that reasoning was wrong — a reformat does not rewrite `export const NAME =`. What it actually bought was letting the FIRST match anywhere in the file win, comments included: a `// historical: DEFAULT_DAEMON_PORT = 9999` line above the declaration made the hook guard 9999 and stop guarding the real port. Controlled: that same line now yields 9444. Three comments that no longer described their code. `test-unit-run.mjs` had the previous round's two lines left verbatim above the block that replaced them, so the same sentence appeared twice. The hook still said the port "is overridable via DEV_DAEMON_PORT" — the exact semantics round 3 removed, since the set is now additive — and its "resolved lazily" note sat above `daemonPortsGuarded`, which is neither lazy nor cached; the laziness is in `devPorts`. Numeric line references (`:123`, `:128`) replaced with named ones. Both had already rotted by three lines. This is the same class as the SKILL.md citation fixed last round, and a line number in a comment will rot again — a quoted message will not. Named the residual hole rather than implying it away: the daemon enqueues INSIDE the response write, so the slot is taken before the client can observe it. Lose the response between those points and `accepted` is still false while the run is queued. That window needs an idempotency key on the enqueue, not a flag here. Not changed, deliberately. `.claude/**/*.mjs` fails `prettier --check` at the merge base as well as here, so it is pre-existing and reformatting it would rewrite files this change does not own. And `scripts/__tests__/*.ts` is typechecked by nothing — `tsconfig.json`'s `include` omits it — so this PR's "typecheck 0 errors" says nothing about the new test file's annotations. Both are stated rather than quietly folded in. 330 passed. Hook selftest green plain and under the override. Test file prettier-clean.
2026-08-20 01:23:47 -05:00
// From here the daemon has ACCEPTED the run, and that changes what a failure may do. Falling
// back to a direct run now would start a second, unqueued full suite beside one the queue is
// already holding a slot for — which is precisely the serialisation this script exists to
// provide. The "Lost contact with the test queue" branch below already decided this for the
// status-code form of the same condition; the network form gets the same answer.
//
// Not airtight, and the gap is worth naming rather than implying it away: the daemon enqueues
// INSIDE the response write, so the slot is taken before the client can observe it. Lose the
// response between those two points and this flag is still false while the run is queued —
// the one window where a duplicate can still happen. Closing it needs an idempotency key on
// the enqueue, not a flag here.
accepted = true;
feat(dev-server): serialise unit-test runs behind the daemon (#3947) * feat(dev-server): serialise unit-test runs behind the daemon The unit suite takes every core. One run is fine; several agents each starting one at the same moment is what flattens the machine, and capping the worker pool per run does not stop that. This adds the scheduler. Two calls, because a caller needs to know where it stands before it decides how to wait. `test run` returns immediately with either "started" or a position plus the command to wait on; `test wait` blocks until the run finishes and exits with its exit code. The wait polls from the CLI rather than holding a daemon request, since the daemon is single-process and a held request would stall every other agent's polling. The daemon owns the run, not the caller, which is what makes a dead agent harmless: it releases nothing because it was holding nothing. Slots are held while a run is tracked and released on child exit, never on a status field -- status is a report, not an observation, and cannot see a grandchild that outlived a kill. Three deadlines close the rest: a queued run whose caller stopped polling is dropped, a run that overruns its ceiling is killed, and a kill that produces no exit frees the slot anyway after a grace period. Concurrency defaults to 1 and is configurable. 0 is legal and means paused, with the pause reported to callers rather than left to be inferred from a position that never moves. The 404 on an unknown run id is load-bearing rather than incidental: it is how a waiter learns the daemon was restarted and its run is gone, instead of polling for a result nobody will produce. * fix(dev-server): close four holes an adversarial review found in the queue 1. `test wait` exited 0 for a cancelled or timed-out run whose child happened to exit 0 -- the window between a kill being issued and it landing. This is the worst possible failure for a command meant to substitute for the suite in a verification chain, because it reports a green run that never finished. Only a completed run that itself exited 0 is a pass now, and the decision lives in one exported function so it can be tested rather than inferred. 2. A malformed TEST_CONCURRENCY killed the daemon at import. The queue is built at module scope and the constructor throws, so a typo in an optional test setting stopped the daemon binding at all -- taking every agent's dev server, session list and worktree tooling with it. It now falls back to 1 and says so, like every other setting in that file. 3. A runner reporting its exit synchronously lost the event, because the listener was attached after the runner returned. The finished run held the only slot until the 30-minute ceiling while everything behind it was abandoned rather than run, and it then settled as `timeout` with an error string that was false. The exit path is now built before the runner is called. 4. The line whose removal produces exactly the permanent wedge this feature exists to prevent had no coverage: 15/15 passed with it deleted. The test sweeps inside the grace the way the daemon's own 5s timer does, so a reset deadline now fails as `expected [] to deeply equal ['<id>']`. Also: children get their own process group on POSIX, without which killing by negative pid names no group and silently leaves vitest running while its slot is handed on -- serialisation quietly becoming concurrency 2 under exactly the load this is for. Daemon shutdown kills synchronously so the child cannot outlive it. Every fix has a mutation control: reverting each one fails on a named value. * fix(dev-server): three more from the second review pass The identity half of the exit guard was untested -- 21/21 passed with it removed. It only fires when a late exit arrives after a force-release, and no test delivered one. It was defended in practice only by the exit-code rule catching the consequence, which is two fixes covering for each other rather than either being pinned. Now a test force-releases a slot and then delivers the exit. `exitCode || 1` passed a signal death straight through as -1, which a shell sees as 255 -- and `detached` + SIGKILL means that is now the normal shape of every cancel and timeout on POSIX. Real failing codes still pass through; anything that is not a positive integer becomes 1. The exit-code table gains a row with a distinctive code, since every row it had was one whose answer is 1 anyway, so it could not tell a passed-through code from a hardcoded one. A child that dies by signal reports no code at all. That is an OOM kill or an outside hand, not a verdict on the tests, so it settles as `error` rather than claiming a test result nothing produced. `execFileSync` on shutdown had no timeout. It runs on the daemon's event loop and is also reached from the SIGINT handler, so a taskkill that blocked would leave the daemon unkillable by signal. A kill we cannot complete is better abandoned -- the sweep frees the slot regardless. * fix(dev-server): keep the verdict when a runner reports then throws The catch around startRun neither checked nor set the settled flag, so a runner that reported an exit and then threw had its real result overwritten by the noise that followed it -- completed/0 became error/null. Degrades safe and the shipped runner cannot produce it, but a verdict that exists should not be discarded. * feat(dev-server): route test:unit:run through the queue when opted in Replaces the hook approach. A PreToolUse hook has to decide from the command text whether a run is happening, and three adversarial passes showed that cannot be done: it ended with 20 known bypasses and 6 false denials, including refusing a commit whose message merely mentioned the suite. Inside the script there is nothing to parse -- whatever shell, wrapper, quoting or directory reaches it gets the queue. It routes rather than refuses, which is the difference that makes it work: no second command to learn, nothing to wrap around, and an agent that never read the guidance still gets queued instead of an error it will work around. Off unless CIVITAI_TEST_QUEUE is set, and off in CI regardless, so this is a no-op for everyone who does not run the daemon -- the same vitest invocation as before. A file-scoped run stays direct: queueing a two-second check behind a nine-minute suite would break the fast loop and push callers toward batching more into each run, which is the opposite of the point. The queue being unreachable falls back to running directly. Nobody should be unable to run tests because a daemon is down.
2026-08-14 19:09:25 -06:00
if (run.status === 'queued') {
console.error(
run.paused
? `Queued at position ${run.position}. The queue is PAUSED (concurrency 0) — nothing starts until it is raised.`
: `Queued at position ${run.position} of ${run.queueLength} (${run.running}/${run.concurrency} running).`
);
}
let lastLog = -1;
for (;;) {
const res = await fetch(`${DAEMON}/test-runs/${run.id}`);
if (res.status === 404) {
console.error(
`The daemon forgot run ${run.id} — it was most likely restarted. Re-run this command.`
);
process.exit(2);
}
if (!res.ok) {
console.error(`Lost contact with the test queue (${res.status}).`);
process.exit(2);
}
const state = await res.json();
const logs = await fetch(`${DAEMON}/test-runs/${run.id}/logs?since=${lastLog}`).then((r) =>
r.json()
);
for (const entry of logs.logs ?? []) {
console.log(entry.message);
lastLog = entry.index;
}
if (state.status !== 'queued' && state.status !== 'running') {
if (state.status !== 'completed')
console.error(`Run ${state.status}${state.error ? `: ${state.error}` : ''}`);
warnIfLogsDropped(state);
// The verdict comes from the queue's own `exitCodeFor`, never from a second copy of the rule
// here. The copy that used to live on this line read `state.exitCode || 1`, which passes a
// signal-killed run's recorded -1 straight through: `process.exit(-1)` gives the shell 255,
// the exact number `exitCodeFor` exists to avoid, and `[ $? -eq 1 ]` misreads it.
process.exit(exitCodeFor(state));
feat(dev-server): serialise unit-test runs behind the daemon (#3947) * feat(dev-server): serialise unit-test runs behind the daemon The unit suite takes every core. One run is fine; several agents each starting one at the same moment is what flattens the machine, and capping the worker pool per run does not stop that. This adds the scheduler. Two calls, because a caller needs to know where it stands before it decides how to wait. `test run` returns immediately with either "started" or a position plus the command to wait on; `test wait` blocks until the run finishes and exits with its exit code. The wait polls from the CLI rather than holding a daemon request, since the daemon is single-process and a held request would stall every other agent's polling. The daemon owns the run, not the caller, which is what makes a dead agent harmless: it releases nothing because it was holding nothing. Slots are held while a run is tracked and released on child exit, never on a status field -- status is a report, not an observation, and cannot see a grandchild that outlived a kill. Three deadlines close the rest: a queued run whose caller stopped polling is dropped, a run that overruns its ceiling is killed, and a kill that produces no exit frees the slot anyway after a grace period. Concurrency defaults to 1 and is configurable. 0 is legal and means paused, with the pause reported to callers rather than left to be inferred from a position that never moves. The 404 on an unknown run id is load-bearing rather than incidental: it is how a waiter learns the daemon was restarted and its run is gone, instead of polling for a result nobody will produce. * fix(dev-server): close four holes an adversarial review found in the queue 1. `test wait` exited 0 for a cancelled or timed-out run whose child happened to exit 0 -- the window between a kill being issued and it landing. This is the worst possible failure for a command meant to substitute for the suite in a verification chain, because it reports a green run that never finished. Only a completed run that itself exited 0 is a pass now, and the decision lives in one exported function so it can be tested rather than inferred. 2. A malformed TEST_CONCURRENCY killed the daemon at import. The queue is built at module scope and the constructor throws, so a typo in an optional test setting stopped the daemon binding at all -- taking every agent's dev server, session list and worktree tooling with it. It now falls back to 1 and says so, like every other setting in that file. 3. A runner reporting its exit synchronously lost the event, because the listener was attached after the runner returned. The finished run held the only slot until the 30-minute ceiling while everything behind it was abandoned rather than run, and it then settled as `timeout` with an error string that was false. The exit path is now built before the runner is called. 4. The line whose removal produces exactly the permanent wedge this feature exists to prevent had no coverage: 15/15 passed with it deleted. The test sweeps inside the grace the way the daemon's own 5s timer does, so a reset deadline now fails as `expected [] to deeply equal ['<id>']`. Also: children get their own process group on POSIX, without which killing by negative pid names no group and silently leaves vitest running while its slot is handed on -- serialisation quietly becoming concurrency 2 under exactly the load this is for. Daemon shutdown kills synchronously so the child cannot outlive it. Every fix has a mutation control: reverting each one fails on a named value. * fix(dev-server): three more from the second review pass The identity half of the exit guard was untested -- 21/21 passed with it removed. It only fires when a late exit arrives after a force-release, and no test delivered one. It was defended in practice only by the exit-code rule catching the consequence, which is two fixes covering for each other rather than either being pinned. Now a test force-releases a slot and then delivers the exit. `exitCode || 1` passed a signal death straight through as -1, which a shell sees as 255 -- and `detached` + SIGKILL means that is now the normal shape of every cancel and timeout on POSIX. Real failing codes still pass through; anything that is not a positive integer becomes 1. The exit-code table gains a row with a distinctive code, since every row it had was one whose answer is 1 anyway, so it could not tell a passed-through code from a hardcoded one. A child that dies by signal reports no code at all. That is an OOM kill or an outside hand, not a verdict on the tests, so it settles as `error` rather than claiming a test result nothing produced. `execFileSync` on shutdown had no timeout. It runs on the daemon's event loop and is also reached from the SIGINT handler, so a taskkill that blocked would leave the daemon unkillable by signal. A kill we cannot complete is better abandoned -- the sweep frees the slot regardless. * fix(dev-server): keep the verdict when a runner reports then throws The catch around startRun neither checked nor set the settled flag, so a runner that reported an exit and then threw had its real result overwritten by the noise that followed it -- completed/0 became error/null. Degrades safe and the shipped runner cannot produce it, but a verdict that exists should not be discarded. * feat(dev-server): route test:unit:run through the queue when opted in Replaces the hook approach. A PreToolUse hook has to decide from the command text whether a run is happening, and three adversarial passes showed that cannot be done: it ended with 20 known bypasses and 6 false denials, including refusing a commit whose message merely mentioned the suite. Inside the script there is nothing to parse -- whatever shell, wrapper, quoting or directory reaches it gets the queue. It routes rather than refuses, which is the difference that makes it work: no second command to learn, nothing to wrap around, and an agent that never read the guidance still gets queued instead of an error it will work around. Off unless CIVITAI_TEST_QUEUE is set, and off in CI regardless, so this is a no-op for everyone who does not run the daemon -- the same vitest invocation as before. A file-scoped run stays direct: queueing a two-second check behind a nine-minute suite would break the fast loop and push callers toward batching more into each run, which is the opposite of the point. The queue being unreachable falls back to running directly. Nobody should be unable to run tests because a daemon is down.
2026-08-14 19:09:25 -06:00
}
await new Promise((r) => setTimeout(r, POLL_MS));
}
}
if (
import.meta.url === `file://${process.argv[1]}` ||
process.argv[1] === fileURLToPath(import.meta.url)
) {
const args = process.argv.slice(2);
const decision = queueDecision(args, process.env);
fix(dev-server): the daemon never read DEV_DAEMON_PORT, so setting it broke the CLI instead of moving the daemon (#4181) * fix(dev-server): the daemon never read DEV_DAEMON_PORT, so setting it broke the CLI instead of moving the daemon The variable was read where the client decides what to CONNECT to and ignored where the daemon decides what to LISTEN on. `DEV_DAEMON_PORT=9555 cli.mjs status` therefore pointed the CLI at :9555, spawned a daemon that bound :9444, and could not reach it — the daemon has always accepted `--port`, the spawn simply passed no arguments and the daemon read no environment. Four files each decided the port for themselves and two of them were wrong: cli.mjs and scripts/test-unit-run.mjs read the variable, console.mjs hardcoded 9444, and daemon.mjs saw only argv. That is not a bug any one of them contains — it is a bug in the set, so the number now lives in exactly one module and all four resolve it there. A daemon a client spawns inherits that client's environment, so both ends read the same variable through the same function; an explicit `--port` still wins for a daemon started by hand. resolveDaemonPort also refuses a value that is not a port rather than handing back parseInt's NaN, which used to reach a URL as `http://127.0.0.1:NaN` and fail a long way from its cause. Verified by reproducing the reported path, not by reading the code. On pre-change code with DEV_DAEMON_PORT=19461 the daemon logged `Daemon port: 9444`, emitted no ready line, and nothing ever listened on 19461. After the change it reports and binds 19461, and the pid it reports is the child that was spawned — a port that answers proves a listener, not THIS listener. Six isolated mutants, each killed by its own named assertion: daemon ignores the environment -> both behavioural cases daemon ignores --port -> the --port precedence case resolver stops validating -> the rejects-a-non-port case console.mjs re-hardcodes 9444 -> both halves of the ledger test-unit-run.mjs re-hardcodes -> both halves of the ledger the default drifts to 9445 -> the default case console.mjs is a TUI with no end-to-end case here, which is why the ledger exists: it fails when the set of files deciding the port grows or shrinks. Closes ClickUp 868kuaa4e. * fix(dev-server): the audit round — the test deleted the pid file it was written to protect, and the ledger could not see the set grow 🔴 The blocking one. `POST /shutdown` replies 200 and only THEN schedules `unlinkSync(pidFile); process.exit(0)` on a 100 ms timer, so the helper that saved and restored the developer's `daemon.pid` restored it ~100 ms before the daemon deleted it. `scripts/**/*.test.ts` is in the `unit` project, so every `pnpm test:unit:run` removed `.claude/skills/dev-server/daemon.pid` — and the file is only written at daemon START, so it did not come back. That breaks the recipe SKILL.md itself gives for checking the daemon's interpreter, and makes `cli.mjs shutdown`'s cleanup a no-op. `shutdown()` now waits for the port to stop answering before returning. The ledger scanned a FIXED list of four readers, so it could not detect the set GROWING — which the PR description claimed it did. A fifth hardcoded 9444 already existed while the test was green: `.claude/hooks/check-writable.mjs` baked it into the dev-port regex, so `DEV_DAEMON_PORT=9555` silently switched that nudge off for the daemon, the one long-lived server it most exists for. The ledger now walks the tree, so a file that does not exist yet is covered, and the hook reads the port from the module. Verified with a negative control: with DEV_DAEMON_PORT=9555, `curl :9555` nudges and `curl :7777` stays silent. The ledger's assertions were also spelled rather than structural. `includes('daemon-port.mjs')` is satisfied by a COMMENT — and this file's own prose names the module in several — and `includes('resolveDaemonPort(')` is satisfied by any call whatever is done with the result, so `resolveDaemonPort() + 1` passed all of it while console.mjs has no behavioural test. Both clients now take a whole URL from `resolveDaemonUrl()` and do no arithmetic on a port; the assertions pin an import line and that call. Three more the audit found: - `scripts/test-unit-run.mjs` lost its "never leave a caller unable to run tests" guarantee. Resolution moved inside `runQueued` but OUTSIDE its try, and `runQueued` is un-awaited, so a malformed DEV_DAEMON_PORT became an unhandled rejection and no tests ran. Measured against base: base printed "running directly", HEAD died. Restored, with the outer `.catch()` as the general net. - `--port` still open-coded `parseInt`, so the HIGHER-precedence input was the unvalidated one — `--port abc` gave ERR_SOCKET_BAD_PORT, which via cli.mjs is invisible (detached, stdio ignored) and reads only as "Failed to start daemon". Both inputs now go through `parsePort`, which names the input it is complaining about. - Resolving at module load made a bad DEV_DAEMON_PORT throw on merely IMPORTING daemon.mjs, which would have broken collection of the port-reservation suite. Resolution moved into `parseArgs`. Nits: SKILL.md said `cli.mjs:69`, the line is 70 — my own earlier check of that number had gone stale under a later edit. Out-of-range is now a different message from unparseable ('0' is a number, just not a port). The 9444 scan uses a word boundary so 19444 is not a false positive. Seven mutants, each applied alone, tree restored and cmp-verified between: shutdown() stops waiting -> the pid-file case a NEW file hardcodes 9444 -> the ledger (the growth case) the hook re-hardcodes 9444 -> the ledger console.mjs does resolvePort() + 1 -> the client-address case the targeted fallback is removed -> the degrade-to-direct case --port back to parseInt -> the argv validation case port resolved at module load -> the import-safety case 🔴 One of them SURVIVED the first run and the fix is the point: the fallback case asserted the generic phrase "running directly", which BOTH guards on that path print, so deleting the targeted guard left the suite green — the mutant died to the other guard. The assertion now names the specific message. scripts/__tests__ 324 passed. typecheck 0 errors. Hook selftest green. * fix(dev-server): round 3 — the last round's hook fix un-guarded the default port, and its catch would have double-run the suite Both regressions were introduced by the previous commit, not pre-existing, and a delta re-audit of 7dd65a9d62..85cb922567 found them. 🔴-in-effect: `DEV_DAEMON_PORT` REPLACED the guarded port instead of adding to it, so setting it un-guarded the shared daemon. Reproduced through the hook's real stdin contract: with the override set, `curl http://localhost:9444/...` went from ask to allowed, and the hook's own selftest went from `all green` to `1 FAILURES`. SKILL.md documents the override as standing a daemon BESIDE the shared one — both are live, so both need guarding. `daemonPortsGuarded()` now returns the union, and the selftest is green both plain and under the override. The other one is subtler and worse in effect. The outer `.catch()` I added wrapped the whole `runQueued` lifecycle, not just queue acquisition — so a socket dropped mid-poll abandoned a run the daemon had ALREADY ACCEPTED and started a second, unqueued full suite beside it. That defeats the serialisation this script exists for, and the file had already decided the opposite for the status-code form of the same condition (`Lost contact with the test queue` -> exit 2). It now tracks whether the run was accepted: before acceptance it degrades to a direct run, after acceptance it exits 2. `err?.message`, because a non-Error throw printed `(undefined)` and a null throw raised inside the handler — back to the unhandled rejection the catch exists to prevent. Also from the re-audit: - The port scrape in the hook is a dependency on one line's formatting in another file and nothing tested it. A vitest case now drives the real `unboundedDevRequest`, with a negative control on a port the skill does not use. Breaking the scrape regex now fails two tests; it used to fail none. - `console.mjs` had no behavioural coverage, and every structural assertion was walkable by resolving the URL correctly and then drifting it. It now runs for real against a stub daemon on an ephemeral port — via `--tail`, since the dashboard refuses to start without a TTY and exits before contacting anything. - `--base-dev-port` was still `parseInt`, which made "argv gets the same validation the environment gets" false about half of argv. - The port range's upper bound was untested: fixtures were 0 and 70000, so 65535 could drift to 65536 unnoticed. Now pinned ON the boundary. - SKILL.md's `console.mjs:88` — the previous commit fixed the cli.mjs citation and moved console.mjs's line to 89 in the same change. Now 89. - The module header claimed the scan covers "the whole skill ... anywhere". It covers three roots and four extensions; prose and test files are deliberately out of scope. Said so instead. 🔴 The ledger caught ME during this round: a comment I wrote explaining the un-guarding bug spelled the port, and `spells the port in exactly one source file` went red. That is the guard working on its author — reworded, not exempted. Round-3 battery, each mutant alone, tree cmp-verified between: outer .catch() deleted -> the accepted-run case accepted-check removed -> the accepted-run case hook override replaces default -> the union case hook scrape regex broken -> both hook cases upper bound 65535 -> 65536 -> the boundary case console.mjs drifts the URL -> the behavioural case + the ledger --base-dev-port back to parseInt -> the base-dev-port case All killed. One deliberate survivor: a copy OUTSIDE the three scanned roots, which is the documented scope and is now stated in the header rather than overclaimed. 330 passed. typecheck 0 errors. Hook selftest green plain and under override. * style: prettier the added test file — the CI gate checks ADDED files and I never ran it `ESLint + Prettier (changed files)` went red on the round-3 push for one reason: `scripts/__tests__/dev-server-daemon-port.test.ts` was not prettier-formatted. Four lines, all wrapping. Reproduced the gate's exact scope locally rather than guessing at it — it runs `prettier --list-different` over ADDED files only, which is why the four `.mjs` files this branch MODIFIES were never checked. Those four are unformatted, and the control says that is not mine: at the merge base `bc74ba06ea`, all four are already unformatted. Reformatting them would rewrite files this change does not own, which is the breadth rule in CLAUDE.md, so they are left alone. Local prettier is 2.8.8, the same version CI installs, so the local format is the one the gate will read. 21 tests still pass. * fix(dev-server): round-4 tidy — the test that proves the console bug was itself destroying the pid file on its red path Follow-ups from the round-3 delta re-audit, which found no 🔴 and confirmed round 3 did not reintroduce a regression. These are the 🟡/🟢 it did find. The console test was not wrapped in `withPidFilePreserved`, and the exposure is specifically on the FAILING path: on green the stub answers `/`, so the console never starts a daemon. Under the mutant the test exists to catch, it cannot reach the stub, falls through to its own `startDaemon`, and overwrites `daemon.pid` with a dead pid — the test that proves the bug also damaged the thing the rest of the file is careful about. Controlled both ways: with the drift mutant applied the test now goes red AND the pid file's md5 is unchanged. The port scrape in the hook is anchored on `export const` again. Dropping the anchor was justified as surviving a reformat, and that reasoning was wrong — a reformat does not rewrite `export const NAME =`. What it actually bought was letting the FIRST match anywhere in the file win, comments included: a `// historical: DEFAULT_DAEMON_PORT = 9999` line above the declaration made the hook guard 9999 and stop guarding the real port. Controlled: that same line now yields 9444. Three comments that no longer described their code. `test-unit-run.mjs` had the previous round's two lines left verbatim above the block that replaced them, so the same sentence appeared twice. The hook still said the port "is overridable via DEV_DAEMON_PORT" — the exact semantics round 3 removed, since the set is now additive — and its "resolved lazily" note sat above `daemonPortsGuarded`, which is neither lazy nor cached; the laziness is in `devPorts`. Numeric line references (`:123`, `:128`) replaced with named ones. Both had already rotted by three lines. This is the same class as the SKILL.md citation fixed last round, and a line number in a comment will rot again — a quoted message will not. Named the residual hole rather than implying it away: the daemon enqueues INSIDE the response write, so the slot is taken before the client can observe it. Lose the response between those points and `accepted` is still false while the run is queued. That window needs an idempotency key on the enqueue, not a flag here. Not changed, deliberately. `.claude/**/*.mjs` fails `prettier --check` at the merge base as well as here, so it is pre-existing and reformatting it would rewrite files this change does not own. And `scripts/__tests__/*.ts` is typechecked by nothing — `tsconfig.json`'s `include` omits it — so this PR's "typecheck 0 errors" says nothing about the new test file's annotations. Both are stated rather than quietly folded in. 330 passed. Hook selftest green plain and under the override. Test file prettier-clean.
2026-08-20 01:23:47 -05:00
if (decision.queue && existsSync(CLI) && existsSync(QUEUE) && existsSync(PORT_MODULE)) {
// Un-awaited at top level, so anything runQueued throws would otherwise be an unhandled
// rejection that kills the process with no tests run.
//
// What it does about it depends on whether the queue took the run. Before acceptance,
// degrading to a direct run keeps the "Test queue unreachable" guarantee. AFTER acceptance
// it would start a
// second, unqueued suite beside the one the queue is holding a slot for, so it exits 2 — the
// same verdict :159 already gives when the poll comes back with a bad status.
//
// `err?.message` rather than `err.message`: a non-Error throw would otherwise print
// `(undefined)`, and a null throw would raise inside the handler and land back at the
// unhandled rejection this exists to prevent.
runQueued(args).catch((err) => {
const detail = err?.message ?? String(err);
if (accepted) {
console.error(`Lost contact with the test queue (${detail}). The run may still be queued.`);
process.exit(2);
}
console.error(`Test queue failed (${detail}); running directly.`);
runDirect(args);
});
} else runDirect(args);
feat(dev-server): serialise unit-test runs behind the daemon (#3947) * feat(dev-server): serialise unit-test runs behind the daemon The unit suite takes every core. One run is fine; several agents each starting one at the same moment is what flattens the machine, and capping the worker pool per run does not stop that. This adds the scheduler. Two calls, because a caller needs to know where it stands before it decides how to wait. `test run` returns immediately with either "started" or a position plus the command to wait on; `test wait` blocks until the run finishes and exits with its exit code. The wait polls from the CLI rather than holding a daemon request, since the daemon is single-process and a held request would stall every other agent's polling. The daemon owns the run, not the caller, which is what makes a dead agent harmless: it releases nothing because it was holding nothing. Slots are held while a run is tracked and released on child exit, never on a status field -- status is a report, not an observation, and cannot see a grandchild that outlived a kill. Three deadlines close the rest: a queued run whose caller stopped polling is dropped, a run that overruns its ceiling is killed, and a kill that produces no exit frees the slot anyway after a grace period. Concurrency defaults to 1 and is configurable. 0 is legal and means paused, with the pause reported to callers rather than left to be inferred from a position that never moves. The 404 on an unknown run id is load-bearing rather than incidental: it is how a waiter learns the daemon was restarted and its run is gone, instead of polling for a result nobody will produce. * fix(dev-server): close four holes an adversarial review found in the queue 1. `test wait` exited 0 for a cancelled or timed-out run whose child happened to exit 0 -- the window between a kill being issued and it landing. This is the worst possible failure for a command meant to substitute for the suite in a verification chain, because it reports a green run that never finished. Only a completed run that itself exited 0 is a pass now, and the decision lives in one exported function so it can be tested rather than inferred. 2. A malformed TEST_CONCURRENCY killed the daemon at import. The queue is built at module scope and the constructor throws, so a typo in an optional test setting stopped the daemon binding at all -- taking every agent's dev server, session list and worktree tooling with it. It now falls back to 1 and says so, like every other setting in that file. 3. A runner reporting its exit synchronously lost the event, because the listener was attached after the runner returned. The finished run held the only slot until the 30-minute ceiling while everything behind it was abandoned rather than run, and it then settled as `timeout` with an error string that was false. The exit path is now built before the runner is called. 4. The line whose removal produces exactly the permanent wedge this feature exists to prevent had no coverage: 15/15 passed with it deleted. The test sweeps inside the grace the way the daemon's own 5s timer does, so a reset deadline now fails as `expected [] to deeply equal ['<id>']`. Also: children get their own process group on POSIX, without which killing by negative pid names no group and silently leaves vitest running while its slot is handed on -- serialisation quietly becoming concurrency 2 under exactly the load this is for. Daemon shutdown kills synchronously so the child cannot outlive it. Every fix has a mutation control: reverting each one fails on a named value. * fix(dev-server): three more from the second review pass The identity half of the exit guard was untested -- 21/21 passed with it removed. It only fires when a late exit arrives after a force-release, and no test delivered one. It was defended in practice only by the exit-code rule catching the consequence, which is two fixes covering for each other rather than either being pinned. Now a test force-releases a slot and then delivers the exit. `exitCode || 1` passed a signal death straight through as -1, which a shell sees as 255 -- and `detached` + SIGKILL means that is now the normal shape of every cancel and timeout on POSIX. Real failing codes still pass through; anything that is not a positive integer becomes 1. The exit-code table gains a row with a distinctive code, since every row it had was one whose answer is 1 anyway, so it could not tell a passed-through code from a hardcoded one. A child that dies by signal reports no code at all. That is an OOM kill or an outside hand, not a verdict on the tests, so it settles as `error` rather than claiming a test result nothing produced. `execFileSync` on shutdown had no timeout. It runs on the daemon's event loop and is also reached from the SIGINT handler, so a taskkill that blocked would leave the daemon unkillable by signal. A kill we cannot complete is better abandoned -- the sweep frees the slot regardless. * fix(dev-server): keep the verdict when a runner reports then throws The catch around startRun neither checked nor set the settled flag, so a runner that reported an exit and then threw had its real result overwritten by the noise that followed it -- completed/0 became error/null. Degrades safe and the shipped runner cannot produce it, but a verdict that exists should not be discarded. * feat(dev-server): route test:unit:run through the queue when opted in Replaces the hook approach. A PreToolUse hook has to decide from the command text whether a run is happening, and three adversarial passes showed that cannot be done: it ended with 20 known bypasses and 6 false denials, including refusing a commit whose message merely mentioned the suite. Inside the script there is nothing to parse -- whatever shell, wrapper, quoting or directory reaches it gets the queue. It routes rather than refuses, which is the difference that makes it work: no second command to learn, nothing to wrap around, and an agent that never read the guidance still gets queued instead of an error it will work around. Off unless CIVITAI_TEST_QUEUE is set, and off in CI regardless, so this is a no-op for everyone who does not run the daemon -- the same vitest invocation as before. A file-scoped run stays direct: queueing a two-second check behind a nine-minute suite would break the fast loop and push callers toward batching more into each run, which is the opposite of the point. The queue being unreachable falls back to running directly. Nobody should be unable to run tests because a daemon is down.
2026-08-14 19:09:25 -06:00
}