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';
|
2026-08-18 19:15:02 -05:00
|
|
|
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');
|
2026-08-18 19:15:02 -05:00
|
|
|
// 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());
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 19:15:02 -05:00
|
|
|
/**
|
|
|
|
|
* 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) {
|
2026-08-18 19:15:02 -05:00
|
|
|
// 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}` : ''}`);
|
2026-08-18 19:15:02 -05:00
|
|
|
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
|
|
|
}
|