ADR-042: One role-switching worker fleet — a bounded, budgeted host for every execution resource

Status: proposed (2026-09-09, ergo task ZXSGTX, epic GTX). Settled in the originating architecture conversation and recorded here so every fleet task can execute without it. Implementation is gated on a user sign-off checkpoint: the role/state table and the budget allocation table in the design doc must be approved before fleet-core work begins. The canonical, maintained form of the design lives in the design doc; this ADR records the decisions and the evidence. The user sign-off checkpoint is satisfied (2026-09-09) — fleet-core (O4CCVX) may begin.

Context

The bot carries a per-era pile of parallelism mechanisms, each with its own private sizing rule that independently derives a worker/thread/slot count from the cgroup budget, and nothing that bounds their sum:

  • the solve-executor fleet (dedicated multi-thread runtime, host + one persistent worker per LPT bin, cpu_budget::solve_worker_count);

  • the SimSlots cap (solve_sim_inflight), a global semaphore sized as leftover × 2 independently of everything else;

  • the inline-sim runtime (a second dedicated multi-thread runtime inside the Python-installed sim hook, sim-hook workers);

  • the rayon global pool and the RAYPAR T3 rayon partitions for resolve;

  • the detached merge sidecar (one std thread);

  • the ambient I/O runtime (crate::runtime, sized from the same cpu_budget after SMTH6M/VJGZJ2);

  • the per-cycle arb-sim-* std-thread fan-out (~85–105 short-lived threads per cycle, 3.3 ms CPU each, mostly parked).

Measured sizing inputs (2026-09-08/09 survey + the post-ADR-041 solve-cycle tail, task CIRZHH; full distributions in logs/solve-tail-20260908.md, a gitignored logs/ run artifact not in the tracked tree):

  • Steady state on an 8-core cgroup quota: instrumented solve+sim CPU is ~1.8% of quota; the rayon global pool is idle (0.4 s CPU / 1158 s across 6 threads); the solve-executor fleet does ~12 s CPU/worker over the window; 6 LPT bins are balanced (bin-thread lifetime CPUs equal within 0.75%).

  • The solve cycle tail is bursty, not flat: whole-cycle detached solve span avg 208 ms, p95 384 ms, max 480 ms; per-bin walk load ~86 ms vs a ~175 ms bin window — bins park about half the window in SimSlots joins. Per-bin makespan is not instrumented (no bin span/label — a gap this design closes). LPT weights are not the bottleneck; rebalancing headroom sits in sim/join pooling.

  • Burst overlap per cycle reaches ~10–11 runnable threads against 8 cores; cgroup throttling fires ~2.7 events/min at steady state (0.18% duty) and 798 events lifetime including registration storms.

  • degenbot.cgroup.throttled exists as a metric with no consumer.

  • Quota consumer census: a worker census registry (epic FPNT36 / PE4FPM) is queued so every execution resource self-registers (name/count/sizing rule) with distinct thread names and metric export.

Demand is bursty and role-shifting; pools are static. A fleet in which a worker’s role is the scheduling unit — bounded by one budget authority — replaces per-era mechanisms with one host.

Decision

1. One fleet host in a new crate: degenbot-workers

A new standalone crate rust/crates/engine/degenbot-workers owns the fleet host, the WorkerRole state machine, and the budget authority. It depends on degenbot-core (cgroup detection) and nothing engine-specific; degenbot-bot plugs roles in as closures/tasks. It must be lower in the dependency graph than the engine because future roles (pool-state updater, registrar, verifier, submitter) are bot-state-level, not arb_engine concerns — and the umbrella degenbot crate re-exports it, so a pure-Rust consumer gets the fleet.

3. One budget authority bounding the SUM

FleetBudget (in degenbot-workers, sourcing degenbot-core::cpu_budget detection) is the single authority: every consumer — ambient I/O runtime, resolve, solver pins, merge, sim slots, and the reserve for Python/pump/ OTel — declares a peak-CPU share and a thread count, and the fleet refuses to start if the declared shares exceed the quota. Today each pool derives its own count and the sum is bounded only by luck; the burst evidence (10–11 runnable vs 8) is that luck running out.

Fractional-quota ceil policy — reviewed here, verdict recorded in the design doc: detection keeps v2_quota_cpus’ ceil (a worker that exists must be schedulable), but the allocation arithmetic floors: integer shares sum against floor(Q) and the fractional remainder is spendable only by I/O-dominant consumers (sim slots, ambient I/O), whose measured duty is partial.

4. Throttle reaction: a Nominal ⇄ Cordoned posture FSM

degenbot.cgroup.throttled gets a consumer: on throttle onset the fleet cordons (no new leases for deferrable/background roles, sim-slot intake throttled), never sheds a running unit, never strands a result pipe, and hysteresis-exits after a clean window. Full policy in the design doc. Threshold tuning (sign-off amendment 2026-09-09): the enter/exit thresholds and hysteresis windows are typed config keys, runtime-adjustable through the operator channel (wired: op set_fleet_posture + degenbot fleet posture set, design doc §6), and calibrated from captured soak data — the posture feeds back into its own thresholds; share arithmetic (design doc section 5) is outside this authority.

5. RAYPAR T3 and the deadlock ledger carry over

Per-bin worker pinning, warm L1/L2 and allocator arenas, no split/steal, sim

solve precedence, and the deadlock ledger — no scoped-rayon join under a held parking_lot guard; merge pipe never stranded; loud abort on executor death — are carried over verbatim (design doc §10). Pinning is job→bin task affinity, which survives role switching by construction: a Pinned(Solver, bin k) slot is leased only to bin k, across cycles, until an explicit epoch-boundary rebalance.

Tokio fact-check (Q8d-1, verified against tokio 1.52/1.53 source): tokio’s multi-thread runtime is work-stealing by design and exposes no task-to-worker affinity API (the source tree contains none); its recommendation for CPU-bound work is spawn_blocking or a separate pool — precisely the two-runtime split this ADR keeps. Stealing only moves runnable tasks between workers at yield/wake boundaries, so fleet bin units (which never yield mid-unit) cannot be migrated by tokio, while pooled SimDriver/Resolve units remain freely stealable — tokio’s good default, not disabled by the fleet. Conclusion: bin pinning must be owned above tokio (as designed); the no-work-stealing non-goal covers fleet-level stealing across bin units, not fighting tokio’s scheduler.

6. Conformance harness: NoopStubFleetHost

A NoopStubEngine-style executable spec (mirroring stage_handlers::ALL_STAGES and its u8-indexed conformance stub) is test-declared only: it walks every role through every legal transition, asserts the budget-sum invariant across a scripted quota resize, asserts pin/arena stability across cycles, and fires the stranded-pipe tripwire. Never runtime-selectable.

7. The Q&A decision record

Q

Decision

Q1

Crate home: new degenbot-workers crate, engine-agnostic, below degenbot-bot

Q2

v1 roles: Solver, SimDriver (incl. folding the arb-sim-* spawn in), Resolve, Merge; future roles declared in the enum’s parent set now

Q3

Pinning/arenas: job→bin affinity keyed by bin id; pinned slots survive cycles and role switches; re-pin only at epoch boundary on quota change

Q4

Bin telemetry: arb.solve.bin span per bin with bin index and makespan histogram — closes the per-bin makespan gap; every fleet worker self-registers with the census registry (FPNT36)

Q5

Python/FFI: cross only for runtime/startup and standalone-result delivery; simulation never round-trips Python; the inline-sim runtime is fleet-hosted

Q6

Migration: DEGENBOT_FLEET stance flag (legacy/fleet), parallel implementations during migration with pinned tests passing in both stances, hard cutover deleting the legacy mechanisms at the end — per the repository switch-over policy (0.6 alpha; no back-compat layer)

Consequences

  • The quota has one authority. Oversubscription by independent sizing becomes a startup failure (fail-loud), not a runtime throttle storm.

  • The thread storm leaves. ~85–105 per-cycle arb-sim-* spawns become pooled SimDriver leases on warm workers.

  • The idle mechanisms retire at cutover — the rayon global pool, the per-mechanism derivation rules, the sim hook’s private runtime.

  • Telemetry gains the missing dimension. Per-bin makespan and fleet posture counters land with the fleet; sizing from the tail (not the mean) becomes measurable.

  • Cost: one more crate and a stance flag during migration. The flag is deleted at cutover; only one implementation survives it.

Non-goals

  • No new scheduling machinery beyond role dispatch. No work-stealing re-introduction, no io_uring, no executor replace: the Tokio CPU/I-O split and RAYPAR T3 semantics are retained, hosted.

  • No auto-tuning of shares. Budget shares are declared, logged, and overridden by config; they are not heuristically re-derived at runtime. Posture thresholds are excluded from this per the sign-off amendment: they stay data-tunable (runtime adjustment via the operator channel + soak-capture feedback, design doc section 6) and never touch shares. Share auto-tuning itself is neither ruled in nor out (Q8d-3, data-gated): v1 keeps shares declared and config-static, revisited as a follow-up once fleet telemetry (per-role busy/idle, census, throttle duty) supplies the evidence.

  • No Python-visible fleet API. The FFI surface is unchanged except for the sim-closure install and result delivery that already exist.

  • No backwards-compatibility layer. Legacy mechanisms are deleted at cutover, mirroring ADR-041’s seam retirement; the 0.7 kill list (ADR-010/011) is untouched.


User checkpoint: sign off on the role/state table and the budget allocation Table in worker-fleet.md before fleet-core implementation begins.