CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m10s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 2m10s
CI and images / smoke-web (push) Successful in 52s
CI and images / promote (push) Skipped
Operator, 2026-09-23, on the screenshot: *"I feel that we can probably combine the two sections into a single table and to format it in such a way that it appears more bounded and less free-form or open. also there's nothing to describe what 'auto' means or why their needs to be or should be on/off toggles. almost all of it always needs to run there's only one optional piece and it is killed by moving the 'cap' to zero."* Three separate things, all correct. ## The four lanes were listed twice The roster (milestone 365) said "ML tagging is running", and four hundred pixels below it the lanes pane said "ML tagging · 1/1 busy". Two answers to one question from two endpoints, free to disagree on screen. I moved the second pane onto this tab yesterday and did not notice it duplicated the first. Now one row per part, with controls on the rows that have a lane and none on the rows that do not. The join is on the QUEUE SET, because that is what `service_roster` keys a celery part on — as a set, not as a string, so neither side has to agree about order. It lives in `utils/systemParts.js` rather than inline, and has a spec, because its failure is SILENT and is the exact thing it exists to prevent: a lane that stops matching its part does not throw, it grows a second row for the same worker. The duplication, returning through the code that removed it. ## Bounded, not free-form A real table — header, column rules, one bordered card — instead of dotted rows floating on the page background with nothing saying where the list began or what a column meant. ## The dial is the switch There was an `On` switch per lane beside the slots dial. Of four lanes, three must run for the application to work at all, so that switch offered a choice that was never real — and for the one lane that IS optional, "off" and "zero slots" were two ways of saying the same thing that could disagree with each other. So `enabled` is now DERIVED from the number: `set_lane` sets it from `slots > 0` when the caller did not say. It stays on the API and in the model — it is still the mechanism, and a drain-before-restart may still want a lane holding its process with consumers cancelled without destroying the operator's slot count to say so. Two things fell out that a test now pins: - The consumer command is sent on the CHANGE, not on the field being present. Otherwise every slots write re-sends a command that changes nothing — lesson #4183's churn, arriving through the new derivation. - The model fetch fires on the off→on TRANSITION. It used to test `enabled is True`, the field having been sent. The UI no longer sends it, so the download that makes the ML lane usable would simply never have fired and the lane would have come on to consume a queue it had no model for. ## And Auto now says what it is A legend under the table, in the operator's terms: what a slot is, that zero turns a lane off, that three of the four are not optional, what `of N` means, and that Auto lets a lane add slots by itself when its queue is backed up AND every slot is busy — with why it is off by default, since it is the only thing on the page that acts without being asked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
284 lines
9.9 KiB
Python
284 lines
9.9 KiB
Python
"""/api/system/workers — the lane dial (milestone 422 step 2).
|
|
|
|
Exercises the real endpoint against the real database. Only `celery inspect`
|
|
is stubbed, and only to keep the suite fast: an unstubbed inspect blocks for
|
|
its full 2s timeout per call with no workers to answer, which several writes
|
|
would turn into most of the lane's runtime.
|
|
"""
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from sqlalchemy import select
|
|
|
|
from backend.app.models import WorkerLane
|
|
from backend.app.services import worker_control as wc
|
|
from backend.app.services.worker_lanes import LANES
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def no_live_workers(monkeypatch):
|
|
"""Nothing is running — which is the CI lane's actual truth, asserted
|
|
rather than waited for. Makes every push fail, which is the interesting
|
|
half: the setting must still be stored."""
|
|
monkeypatch.setattr(
|
|
wc, "inspect_lanes_sync",
|
|
lambda: {lane.name: wc.LaneLiveState() for lane in LANES},
|
|
)
|
|
|
|
|
|
async def _lane_row(db, name: str) -> WorkerLane:
|
|
return (await db.execute(
|
|
select(WorkerLane).where(WorkerLane.name == name)
|
|
)).scalar_one()
|
|
|
|
|
|
# --- reading -----------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_lists_every_lane_with_its_ceiling(client, no_live_workers):
|
|
resp = await client.get("/api/system/workers")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
|
|
by_name = {lane["name"]: lane for lane in body["lanes"]}
|
|
assert set(by_name) == {"worker", "scheduler", "maintenance_long", "ml"}
|
|
for lane in body["lanes"]:
|
|
assert lane["ceiling"] >= 0
|
|
assert lane["slots"] <= lane["slots_cap"]
|
|
# Nothing is running, so live state must say so rather than report
|
|
# zeroes that read like a healthy idle lane.
|
|
assert lane["live"]["present"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ml_ships_off(client, no_live_workers):
|
|
"""Rule 164's carve-out and the weak-hardware default in one row: enabling
|
|
the lane is what triggers the SigLIP download, so a fresh install must not
|
|
find it on."""
|
|
body = await (await client.get("/api/system/workers")).get_json()
|
|
ml = next(lane for lane in body["lanes"] if lane["name"] == "ml")
|
|
assert ml["enabled"] is False
|
|
assert ml["slots"] == 0
|
|
|
|
|
|
# --- the persist / push split ------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_value_is_stored_even_when_it_cannot_be_pushed(
|
|
client, db, no_live_workers
|
|
):
|
|
"""THE test for this step. `pool_grow` is not durable and the lane is not
|
|
answering, so the push fails — and the setting must survive anyway, or a
|
|
UI that saved while a worker was restarting would silently lose it
|
|
(lesson #4202). 200 with `applied: false`, not an error.
|
|
"""
|
|
resp = await client.post("/api/system/workers/worker", json={"slots": 3})
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
|
|
assert body["slots"] == 3
|
|
assert body["applied"] is False
|
|
assert "not running" in body["apply_error"]
|
|
|
|
assert (await _lane_row(db, "worker")).slots == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_partial_update_leaves_the_other_fields_alone(
|
|
client, db, no_live_workers
|
|
):
|
|
"""The stepper sends `{"slots": n}` without restating a cap it did not
|
|
touch."""
|
|
before = await _lane_row(db, "worker")
|
|
original_cap = before.slots_cap
|
|
|
|
await client.post("/api/system/workers/worker", json={"slots": 2})
|
|
|
|
await db.refresh(before)
|
|
assert before.slots == 2
|
|
assert before.slots_cap == original_cap
|
|
|
|
|
|
# --- what is refused ---------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slots_above_the_cap_are_refused_and_nothing_is_stored(
|
|
client, db, no_live_workers
|
|
):
|
|
row = await _lane_row(db, "worker")
|
|
resp = await client.post(
|
|
"/api/system/workers/worker", json={"slots": row.slots_cap + 1},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "refused"
|
|
# The sentence the UI shows. A greyed control with no reason reads as a bug.
|
|
assert "cap" in body["detail"]
|
|
|
|
await db.refresh(row)
|
|
assert row.slots <= row.slots_cap
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_cap_above_the_derived_ceiling_is_refused(
|
|
client, db, no_live_workers
|
|
):
|
|
"""The operator's cap is theirs to set, but not past what the container
|
|
can hold — the whole point of deriving a ceiling rather than typing one."""
|
|
resp = await client.post(
|
|
"/api/system/workers/ml", json={"slots_cap": 10_000},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "refused"
|
|
assert "container can hold" in body["detail"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_boolean_is_not_accepted_as_a_slot_count(client, no_live_workers):
|
|
"""`True` is an int in Python. Coerced, it would silently set one slot —
|
|
a control that appears to work and does something nobody asked for."""
|
|
resp = await client.post("/api/system/workers/worker", json={"slots": True})
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "invalid_body"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_unknown_lane_is_refused_and_names_the_known_ones(
|
|
client, no_live_workers
|
|
):
|
|
resp = await client.post("/api/system/workers/nonsense", json={"slots": 1})
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "unknown_lane"
|
|
assert "worker" in body["known"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_empty_body_is_refused_rather_than_treated_as_a_no_op(
|
|
client, no_live_workers
|
|
):
|
|
"""A POST that changes nothing and returns 200 is indistinguishable from
|
|
one that worked, which is how a broken UI control goes unnoticed."""
|
|
resp = await client.post("/api/system/workers/worker", json={})
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "invalid_body"
|
|
|
|
|
|
# --- the dial is the switch --------------------------------------------------
|
|
#
|
|
# Operator, 2026-09-23: *"almost all of it always needs to run there's only one
|
|
# optional piece and it is killed by moving the 'cap' to zero."* So `enabled`
|
|
# is derived from the number rather than being a second control the operator
|
|
# has to keep in agreement with it. It stays on the API — these assert that it
|
|
# still does, because it is the mechanism the reconcile and the healthcheck
|
|
# read.
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dialling_a_lane_to_zero_turns_it_off(client, db, no_live_workers):
|
|
await client.post("/api/system/workers/worker", json={"slots": 0})
|
|
|
|
row = await _lane_row(db, "worker")
|
|
assert row.slots == 0
|
|
assert row.enabled is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dialling_it_back_up_turns_it_on(client, db, no_live_workers):
|
|
await client.post("/api/system/workers/ml", json={"slots": 1})
|
|
|
|
row = await _lane_row(db, "ml")
|
|
assert row.slots == 1
|
|
assert row.enabled is True, "the lane the operator just asked for work from"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_explicit_enabled_still_wins(client, db, no_live_workers):
|
|
"""The field is not removed, only derived when absent. Something that
|
|
genuinely wants a lane holding its process with consumers cancelled — a
|
|
drain before a restart — must still be able to say so without having to
|
|
destroy the operator's slot count to express it."""
|
|
await client.post(
|
|
"/api/system/workers/worker", json={"slots": 3, "enabled": False},
|
|
)
|
|
|
|
row = await _lane_row(db, "worker")
|
|
assert (row.slots, row.enabled) == (3, False)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_cap_only_write_does_not_decide_the_switch(
|
|
client, db, no_live_workers,
|
|
):
|
|
"""Only the SLOTS dial derives it. A cap is a ceiling, not a request for
|
|
work, and letting it flip the lane would make raising a ceiling start
|
|
something."""
|
|
before = await _lane_row(db, "ml")
|
|
assert before.enabled is False
|
|
|
|
await client.post("/api/system/workers/ml", json={"slots_cap": 1})
|
|
|
|
await db.refresh(before)
|
|
assert (before.slots, before.enabled) == (0, False)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_model_fetch_fires_on_the_transition_not_on_the_field(
|
|
client, db, no_live_workers, monkeypatch,
|
|
):
|
|
"""The trap the derivation set, caught here rather than in production.
|
|
|
|
The fetch used to be conditioned on `enabled is True` — the FIELD having
|
|
been sent. The UI no longer sends it at all, so the download that makes
|
|
the ML lane usable would simply never have fired, and the lane would have
|
|
come on and sat there consuming a queue it had no model for.
|
|
"""
|
|
fired = []
|
|
monkeypatch.setattr(wc, "_enqueue_model_fetch", lambda: fired.append(1) or True)
|
|
monkeypatch.setattr(
|
|
wc, "set_lane_enabled_sync", lambda lane, enabled, live=None: (True, None),
|
|
)
|
|
monkeypatch.setattr(
|
|
wc, "set_lane_slots_sync", lambda lane, target, live=None: (True, None),
|
|
)
|
|
|
|
body = await (await client.post(
|
|
"/api/system/workers/ml", json={"slots": 1},
|
|
)).get_json()
|
|
|
|
assert body["fetching_models"] is True
|
|
assert fired == [1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_it_does_not_fire_again_on_a_lane_already_running(
|
|
client, db, no_live_workers, monkeypatch,
|
|
):
|
|
"""The other half. A second nudge of the dial on a lane that is already on
|
|
must not re-enqueue a multi-GB download."""
|
|
monkeypatch.setattr(
|
|
wc, "set_lane_enabled_sync", lambda lane, enabled, live=None: (True, None),
|
|
)
|
|
monkeypatch.setattr(
|
|
wc, "set_lane_slots_sync", lambda lane, target, live=None: (True, None),
|
|
)
|
|
await client.post("/api/system/workers/ml", json={"slots": 1})
|
|
|
|
fired = []
|
|
monkeypatch.setattr(wc, "_enqueue_model_fetch", lambda: fired.append(1) or True)
|
|
|
|
body = await (await client.post(
|
|
"/api/system/workers/ml", json={"slots": 1},
|
|
)).get_json()
|
|
|
|
assert body["fetching_models"] is False
|
|
assert fired == []
|