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
@@ -299,16 +299,13 @@ async function apply(lane, fields) {
text: `${lane.display_name} is on. Downloading its model now — watch `
+ 'progress under Activity. It only happens once.',
}
} else if (reply && reply.applied === false) {
// Saved but not pushed — the lane is restarting, or the broker blipped.
// NOT an error: the reconcile carries it when the lane answers again,
// and saying "failed" would invite setting it a second time.
notice.value = {
type: 'info',
text: `Saved. ${lane.display_name} is not answering right now — `
+ 'it will pick this up within a few minutes.',
}
}
// There is no "saved but not applied" message any more, because there is
// nothing to report yet: the endpoint answers once the cap is STORED and
// tells the running lane afterwards, so the press cannot wait on a broker
// round trip (operator: "we shouldn't have to wait for the validation
// live"). Whether the lane has caught up is what its row says, and the
// row updates on the next poll a few seconds later.
} catch (e) {
// The endpoint's `detail` is written to be read by a person ("cap 10000 is
// above what this container can hold"). Surface it rather than a status
+25 -5
View File
@@ -88,17 +88,37 @@ export const useSystemActivityStore = defineStore('systemActivity', () => {
}
}
// Change one lane. Returns the endpoint's reply so the caller can tell a
// stored-but-not-yet-live change (`applied: false`) from a live one — the
// difference between "saved, the lane is restarting" and "that failed",
// which the UI must not collapse into one message.
// Change one lane, and PATCH the row from the reply.
//
// It refetched until 2026-09-23, on the reasoning that the server's whole
// table beats a local guess. The cost of that was the operator's:
//
// "something about changing the cap number is blocking to the website...
// it shouldn't be" / "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."
//
// GET /api/system/workers costs a celery inspect — an eleven-second budget
// — so every press of `+` disabled the stepper until a broker round trip
// the press did not need had finished. The endpoint now answers as soon as
// the cap is stored and tells the lane separately.
//
// Only the three fields the reply actually decides are copied. The live
// columns (pool, active, pending) are measurements this reply does not
// carry and must not invent — the 15s poller brings them, a beat behind,
// which is what they are anyway.
//
// Deliberately NOT swallowing the error: a refused value (400) carries the
// sentence explaining why, and the card shows it. Returning null on failure
// would leave the operator with a control that silently did nothing.
async function setLane(name, fields) {
const reply = await api.post(`/api/system/workers/${name}`, { body: fields })
await loadLanes()
const row = lanes.value?.lanes?.find((l) => l.name === name)
if (row && reply) {
if (reply.slots_cap !== undefined) row.slots_cap = reply.slots_cap
if (reply.ceiling !== undefined) row.ceiling = reply.ceiling
if (reply.enabled !== undefined) row.enabled = reply.enabled
}
return reply
}
+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 () => {