fix: the cap dial waited out a broker round trip it did not need (4295)
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 25s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Successful in 2m13s
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 1m59s
CI and images / smoke-web (push) Successful in 58s
CI and images / promote (push) Skipped

Operator: "when the number is changed the change should be queued so that
it isn't blocking of the webui or the system itself. we shouldn't have to
wait for the validation live."

Two waits, and 5b6f2ba removed neither — it stopped a Postgres connection
being HELD across them, which is what had been stalling the whole site,
and left the press itself as slow as it was.

1. The store refetched after every write. GET /api/system/workers runs a
   celery inspect on an eleven-second budget, so the stepper stayed
   disabled through a round trip the press did not need. It now patches
   the row from the reply — cap, ceiling, enabled, the three fields that
   reply actually decides — and lets the 15s poll bring the live columns,
   which are measurements it must not invent.

2. The endpoint pushed to the broker before answering. Turning a lane off
   is four cancel_consumer messages; lowering a cap reads the live pool
   first. Now it stores the cap, answers `queued`, and hands the push to a
   Quart background task. Raising a cap was already free and stays free.

Nothing is lost by not waiting: the stored cap is what the system obeys
and the sizing pass re-reads it every minute. That sweep was already the
backstop for a push that failed, which under `no_live_workers` is every
push in the suite.

Also closes a hole the move exposed: the model fetch was gated on the
consumer change having landed, so raising ML off zero while the lane was
restarting stored the cap, let the sizing pass start the consumers a
minute later, and left the lane running with no model — nothing else ever
asks for one. It now fires on the transition and waits in the ml queue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-23 15:41:32 -04:00
co-authored by Claude Opus 5
parent 48108a3569
commit 1353d346b3
6 changed files with 306 additions and 72 deletions
+62 -20
View File
@@ -5,11 +5,18 @@ import { laneStuckFor, useSystemActivityStore } from '../src/stores/systemActivi
// Milestone 422 step 4. Covers the store half of the worker-lane dial — the
// part that decides what the card can tell the operator.
//
// The distinction being protected: a change that was STORED but not pushed
// (`applied: false`, because the lane is restarting) is not a failure, and a
// REFUSED value (400) is. Collapsing those two into one message is how a
// control stops being trustworthy — one invites waiting, the other invites
// changing what you asked for.
// What is protected here since 2026-09-23: pressing the dial must not wait on
// a broker round trip. Operator: *"when the number is changed the change
// should be queued so that it isn't blocking of the webui or the system
// itself. we shouldn't have to wait for the validation live."* The endpoint
// answers once the cap is STORED, so the store patches its row from that
// reply and lets the 15s poll bring the live columns — it used to refetch,
// and GET /api/system/workers costs a celery inspect on an eleven-second
// budget.
//
// And still: a REFUSED value (400) is a failure and must reach the operator
// as one. A control that silently does nothing is worse than one that
// refuses out loud.
function stubFetch(handler) {
globalThis.fetch = vi.fn(async (url, init) => {
@@ -70,7 +77,7 @@ describe('worker lanes store', () => {
stubFetch((url, init) => {
calls.push({ url, init })
if (init?.method === 'POST') {
return { status: 200, body: { name: 'worker', slots_cap: 2, applied: true } }
return { status: 200, body: { name: 'worker', slots_cap: 2, queued: true } }
}
return { status: 200, body: LANES_BODY }
})
@@ -82,38 +89,73 @@ describe('worker lanes store', () => {
expect(JSON.parse(post.init.body)).toEqual({ slots_cap: 2 })
})
it('setLane refetches so the card shows the server truth, not the guess', async () => {
// The reply is one lane; the table renders all of them plus live pool
// and pending. Patching the local row from the reply would leave every
// other column stale and eventually wrong.
it('setLane does not refetch — that refetch is what blocked the press', async () => {
// It refetched until 2026-09-23, so that the card showed server truth
// rather than a local guess. The cost was the operator's: GET
// /api/system/workers runs a celery inspect, so every press of `+` sat
// with the stepper disabled through a broker round trip the press did not
// need.
let gets = 0
stubFetch((url, init) => {
if (init?.method === 'POST') return { status: 200, body: { applied: true } }
if (init?.method === 'POST') {
return { status: 200, body: { slots_cap: 2, ceiling: 8, enabled: true } }
}
gets += 1
return { status: 200, body: LANES_BODY }
})
const s = useSystemActivityStore()
await s.loadLanes()
gets = 0
await s.setLane('worker', { slots_cap: 2 })
expect(gets).toBe(1)
expect(gets).toBe(0)
})
it('a stored-but-unapplied change comes back as applied:false, not an error', async () => {
// The lane is restarting. The value IS saved and the reconcile will carry
// it — so this must reach the card as information, not as a failure that
// invites the operator to set it again.
it('patches the row from the reply, so the new number shows at once', async () => {
stubFetch((url, init) => {
if (init?.method === 'POST') {
return {
status: 200,
body: { applied: false, apply_error: 'lane is not running', slots_cap: 2 },
body: { name: 'worker', slots_cap: 2, ceiling: 8, enabled: true, queued: true },
}
}
return { status: 200, body: LANES_BODY }
})
const s = useSystemActivityStore()
const reply = await s.setLane('worker', { slots_cap: 2 })
expect(reply.applied).toBe(false)
expect(reply.apply_error).toContain('not running')
await s.loadLanes()
await s.setLane('worker', { slots_cap: 2 })
const worker = s.lanes.lanes.find((l) => l.name === 'worker')
expect(worker.slots_cap).toBe(2)
})
it('does not invent the live columns the reply cannot know', async () => {
// The reply is decided from the stored cap and the machine's ceiling
// alone — it never asked a worker anything. Pool, active and pending are
// MEASUREMENTS, and the poll a few seconds later is what carries them.
// Zeroing or guessing them here would make the table lie in the direction
// that reads as "the lane stopped".
stubFetch((url, init) => {
if (init?.method === 'POST') {
return { status: 200, body: { slots_cap: 2, ceiling: 8, enabled: true } }
}
return { status: 200, body: LANES_BODY }
})
const s = useSystemActivityStore()
await s.loadLanes()
const before = { ...s.lanes.lanes[0].live }
await s.setLane('worker', { slots_cap: 2 })
expect(s.lanes.lanes[0].live).toEqual(before)
expect(s.lanes.lanes[0].pending).toBe(8)
})
it('survives a reply arriving for a lane it has not loaded yet', async () => {
// First paint, or a lane added by a newer build. Patching must not be the
// thing that throws inside the click handler.
stubFetch(() => ({ status: 200, body: { slots_cap: 2 } }))
const s = useSystemActivityStore()
await expect(s.setLane('worker', { slots_cap: 2 })).resolves.toBeTruthy()
})
it('a refused value throws so the card can show the reason', async () => {