Lessons of record: the first production Supervisor rollout (Wolverine CI)
Written 2026-07-31/08-01, at the end of the session that replaced Wolverine's Nuke test harness with supervised runs — wolverine#3758, Bobcat 0.6.0 and 0.6.1 published to nuget.org the same night. Everything below was measured or observed on that PR's CI rounds; nothing is speculative. It was the pickup document for the sessions that followed; the closing section records where its open items landed.
What shipped
| Where | What |
|---|---|
| Bobcat 0.6.0 | Supervisor.TestFilter — supervisor-level equivalent of dotnet test --filter; first packaged release of Bobcat.Supervisor |
| Bobcat 0.6.1 | Supervisor.ReleaseIdleLanes (#81) — dispose idle lane workers before fresh-process retries |
| Wolverine | build/SupervisedTests.cs replaces the TRX-parse-and-retry harness for all ~30 CI targets; every *Tests project is an MTP host; Servers.cs connection strings env-overridable; CIMarten at workers: 4 (clamped to 2 on hosted runners) with a Postgres database per lane |
Measured
Local (M-series, net9.0): SqliteTests 161/161 at 4 workers, 71s → 36s (1.97x; floor = one 48-test class). MartenTests 543+1 → later 544 clean, 3m56s at 4 workers vs an 18m CI job.
CI (GitHub-hosted, final round, 31/32 green): CIMarten 18m → 9.5m, 544 + 12 passed, 0 retries. Every other job at 1 worker within ±1m of baseline — parity, as intended. Workflow wall clock 18m → 16.9m; CIAzureServiceBus is now the sole pole, and its 10 pass-on-retries all sit in one class (TopicAndSubscriptionWithCustomRuleSendingAndReceivingCompliance), which is where Wolverine's Flaky-tag removal should start. The one red, CIKafka's dead_letter_message_has_exception_headers, predates the branch and fails identically sequential.
The lessons, in the order they were paid for
A supervisor needs a test filter (0.6.0). CI targets live and die by
Category!=Flakyand shard slices; withoutTestFilterthe supervisor could not own a real CI job at all. A shard filter matching nothing must fail — Wolverine treats an empty match as a renamed-namespace error, the inversion ofGuardAgainstAnUnfilteredRun.nuget.org propagation races CI. The PR's first two matrix rounds failed on
NU1101because the jobs started 15 seconds aftergh pr create, before the freshly-pushed package was indexed — and a rerun 90s later still lost. The fix that worked: gate the push on the registration blob (v3/registration5-gz-semver2/<id>/index.json) plus a ~3 minute settle.Committed worker counts are tuned on a developer machine; the machine running decides. Four Marten hosts beside Postgres killed a 4-vCPU/16GB runner outright. Wolverine clamps to
ProcessorCount / 2unless--test-workersexplicitly overrides. The failure signature to recognize:The runner has received a shutdown signal— that is the runner dying, not a test.Idle lanes + a fresh-process retry =
workers + 1peak processes (#81 →ReleaseIdleLanes, 0.6.1). Lane processes sit at peak working set through the retry passes; both runner deaths were timestamped during a retry. Lanes are released only when nothing pending needs one, and aRetryInProcessdecided after the release is recorded inUnsupported, never silently rerun.Warm-process retries cannot work for tests whose failure pollutes the process. Tried as the memory fix before #81 existed; MySqlTests failed all three attempts with leftovers of attempt one (
end_to_end_from_scratchcannot run "from scratch" in a process where scratch no longer exists — "No known message handler", "Sequence contains no matching element"). This is the engine's own doctrine confirmed from outside: every attempt needs the full reset bracket, and xUnit workers have no reset bracket, so out there the fresh process is the bracket.Test identity must be a function of the code alone, never the environment — bug shape #3 in
parallel-ready-suites.md. Theory data computed from a per-worker environment variable gives the same test a different uid in discovery than in the executing lane. Symptom: "the worker finished without reporting a result for this test"; it masquerades as an ordinary flake whenever the retry process happens to compute the environment discovery did. Fix pattern: a stable sentinel argument ("default") resolved inside the test body.Bug shape #1 (name-coupled assertions) is real and plentiful: nine MartenTests asserted URIs or connection strings built from the literal default database name. All fixed by deriving expectations from the configured connection string (
Servers.PostgresDatabaseName). One subtle variant: an ordered expected list must be re-sorted once a name in it becomes variable.Match the old harness's real retry ceiling, not its apparent one. Wolverine's TRX harness effectively allowed three attempts (suite pass + two single-test invocations). The supervised budget's initial
MaxAttemptsPerTest = 2was silently stricter, and chronic ASB emulator flakes that used to pass on the third try surfaced as hard failures. Parity restored at 3 — with every extra attempt in the[FLAKY]ledger instead of invisible.The honest-reporting design paid rent immediately. Indeterminate-with-forensics identified both runner deaths; the flaky ledger localized ASB's instability to one class; CIKafka's pre-existing failure now prints its name and exception in the job summary. Nothing was laundered.
Where the open items landed (updated 2026-09-03)
The list this section replaced is in git history (git log -- docs/wolverine-ci-rollout.md); what matters now is what became of it:
- #82 — fixed (0.7.0): synthesized never-reported outcomes carry the discovery display name, so an indeterminate prints as a test name rather than a uid hash.
- The durations ledger is built (
TestLedger,docs/ledger-design.md): a committed, merge-friendly store of per-run observations.ledger.KnownDurations()feedsSupervisor.KnownTestDurations, so the lane balancer works from measured durations on the first pass instead of going unused as it did all this session; the same store carries duration trends (#142) and flakiness evidence for hint proposals (#44). - The supervisor grew the observability this rollout ran blind without (0.7.0–0.9.1):
ISupervisorObserverlive narration (#84), worker pids surfaced (#146), stall detection and the heartbeat (#145/#148) with the opt-inStallActionkill-and-retry (#173),MtpWorkerFactory.OnBeforeKillfor dump capture before a forced kill (#147), per-worker RSS sampling (#149), andSupervisor.Snapshot()for cancelled runs (#150). On the Wolverine side, wolverine#4108 retired the shell watchdog this rollout had needed — the supervisor now reports what the watchdog used to infer. - Live progress for foreign workers (#195, 0.9.1): a supervised xUnit run's console card no longer sits at
0 / Nfor its whole duration — the supervisor forwards per-test updates for workers that publish nothing themselves. - The Wolverine-side items (attacking CIAzureServiceBus and the broker tier, per-lane emulators, the
Flaky-tag removal, scoping Marten's sibling databases) remain Wolverine's backlog and are tracked there, not here. - Wolverine's local dev note still applies: the user-level NuGet config maps
Bobcat.*to a folder feed (~/code/bobcat/artifacts/local-feed) via packageSourceMapping, exclusively — keep it packed at the referenced version, or remove the mapping to restore from nuget.org.