A capacity refusal is transient¶
Prompt¶
Before responding to questions or discussion points in this document, explore the shakenfist codebase thoroughly. Read relevant source files, understand existing patterns (object lifecycle, state machines, MariaDB storage via the three-layer direct/gRPC/public pattern, Pydantic schemas, daemon architecture, operation queue system, event logging), and ground your answers in what the code actually does today. Do not speculate about the codebase when you could read it instead. Where a question touches on external concepts (KVM/libvirt, VXLAN networking, MariaDB/Galera, gRPC/protobuf), research as needed to give a confident answer. Flag any uncertainty explicitly rather than guessing.
This plan spans three repositories: this one (the scheduler, the
cluster and resources daemons, and the shakenfist_ci functional
suite), client-python (shakenfist_client.apiclient, which is
what the suite and every operator script calls), and, for one
step only, shakenfist/actions (where the reusable
smoke-cluster workflow collects the per-run summary this plan
adds). It is a sibling of
Right-size the CI test clouds and
Atomic scheduling via reservations,
and deliberately does not own anything either of those already
owns: topology shape belongs to the first, the admission ledger
and claims to the second. What is left, and what this plan is, is
the piece both of them explicitly deferred -- that nothing in the
system treats "no capacity right now" as the transient condition
it is.
Consult ARCHITECTURE.md for the system architecture
overview, object types, and daemon structure. Consult
CLAUDE.md for build commands, project conventions, and
database access patterns. Consult GOALS.md for current
development priorities. Key references inside the repo
include shakenfist/scheduler.py (_has_sufficient_cpu, the
pre-filter that raises the refusal this plan is about),
shakenfist/external_api/instance.py (the two 507 branches of
POST /instances), shakenfist/daemons/cluster/main.py
(_force_capacity_reconcile_if_unguarded and the anchored
reconcile), shakenfist/daemons/resources/main.py (the 60 s
metrics cadence that measured_cpus comes from),
shakenfist/deploy/shakenfist_ci/base.py and retries.py (the
suite's await and retry helpers), and
shakenfist/deploy/shakenfist_ci/load_budget.py
(HARNESS_DRIVEN_PAIRS, which any new suite-side polling must be
declared in).
Plan file conventions (shared block; do not edit -- the canonical
copy lives in shakenfist/development at
templates/shared-blocks/plan-file-conventions.md):
- All planning documents live in
docs/plans/. - Detailed planning gets one plan file per phase. Phase files are
named for their master plan, sit in the same directory as it,
and append
-phase-NN-descriptivebefore the.mdextension. - The master plan tracks its phases in a table under its Execution section:
| Phase | Plan | Status |
|---|---|---|
| 1. Schema migration | PLAN-thing-phase-01-schema.md | Not started |
| 2. Public API | PLAN-thing-phase-02-api.md | Not started |
- One commit per logical change, and at minimum one commit per phase. Unrelated changes are not batched into a single commit. Each commit is self-contained: it builds, passes tests, and has a message explaining what changed and why.
Situation¶
The cost¶
Issue #3772 is the signature in essentially every failing merge
group. Over the sixteen days to 2026-09-08 the Functional tests
workflow's merge_group runs split 108 failures, 45 successes,
14 cancelled. Of the thirty most recent failing runs, twenty-four
failed the Debian 12 tier job (the slim-tier topology). Of
eighteen of those runs whose failed-job logs were read, all
eighteen contain
shakenfist_client.apiclient.InsufficientResourcesException: ('API request failed', 'POST',
'http://localhost:13000/instances', 507,
'{"error": "No nodes remaining at scheduling stage sufficient_idle_cpu", "status": 507}')
and in fourteen a known victim of that refusal is the test that
failed: test_network_plumbing_lifecycle (8),
test_disappearing_source_instance (3),
test_stray_torn_down_while_a_hosted_network_survives (3), then
test_artifact_show, test_lifecycle_power_cycle and the
affinity tests. The next-largest cause, the database load-budget
check, appeared in seven of the eighteen -- and in six of those a
507 was in the same run, so fixing the budget family alone would
not have turned one of them green.
What the refusal actually is¶
The evidence below comes from the full CI bundles -- the 15 s
headroom series, the Loki refusal census and every node's
journalctl -u 'sf-*' export -- of six failing merge runs after
PR #4106 landed (34163288637, 34171977552, 34178278720,
34119030297, 34125365386 on slim-tier; 34168326220 on
slim-primary), two before it (33991296717, 33948911843), and the
50 usable slim-tier records of the sizing plan's baseline
dataset. The reproduction recipe and the analysis script are under
docs/plans/data/transient-capacity-refusals/.
+Ns is seconds after the Run functional tests step started.
Every sufficient_idle_cpu abort in the six post-#4106 runs was
a force_placement single-candidate create onto a node whose
capacity ledger was genuinely at its limit, while the cluster as a
whole held 3-9 of its 12 vCPU. The ledger reconstructed from the
instance placed and instance placement released events in
every node's journal matched the pre-filter's committed_cpus
exactly in all ten cases. It is not over-counting, not a race, and
not the warm-up window.
| run | topology | first capacity row | cpu abort(s) | after first row | forced onto | measured / committed / limit |
|---|---|---|---|---|---|---|
| 34163288637 | tier | +181s | +309s | +128s | sf1 | 2 / 3 / 3 |
| 34171977552 | tier | +166s | +512s | +346s | primary | 3 / 3 / 3 |
| 34178278720 | tier | +181s | +200, +201, +202, +314s | +20..+133s | sf1, sf1, sf2, sf1 | 0/3/3, 0/3/3, 1/6/6, 3/3/3 |
| 34119030297 | tier | +165s | +481s | +316s | sf1 | 6 / 0 / 3 |
| 34125365386 | tier | +166s | +696s | +530s | sf2 | 3 / 6 / 6 |
| 34168326220 | primary | +166s | +824s | +658s | sf1 | 3 / 3 / 3 |
| 33991296717 (pre) | tier | +166s | +519, +542, +565, +725s | +354..+559s | none: all three nodes | sf2 6/6, sf1 3/3, primary 3/3 |
| 33948911843 (pre) | tier | +181s | +176, +177, +523s | -5s, -4s, +342s | primary, primary, primary | 0 / 6 / 3, 0/6/3, 3/3/3 |
(The +200 s trio and the +176 s pair are
test_duplicate_network_work_is_coalesced, which pins a burst of
six across the hypervisors and tolerates its own create errors; the
test that failed each run was a later one.)
Three things follow from the table.
- The binding constraint is the 3 vCPU ledger on the two
infra hypervisors.
primary(hypervisor, network node and database node) andsf1(hypervisor and database node) are 4-thread VMs carrying the deploy-time reservation of 4 threads (examples/_shared/site.yml,(1 + infra_role) * 2), socpu_schedulable = max(1, 4 - 4) = 1(shakenfist/daemons/resources/main.py:126) andlimit_cpus = floor(1 x 3.0) = 3(shakenfist/mariadb.py,_derive_cpu_memory_limits).sf2gets 6. Cluster 12, confirmed at exactly 12.0 in all 50 baseline records. - The victim tests pick those nodes deterministically.
test_network_plumbing_lifecycletakes the first two non-network hypervisors (sf1,sf2);test_disappearing_source_instancethe first hypervisor;test_stray_torn_down_while_a_hosted_network_survivesthe first non-network hypervisor (sf1);test_artifact_showits first instance's node. Five stestr workers queue their pinned creates on a node three 1-vCPU instances fill, and the instances that fill it are still fetching images (measured 0, committed 3) when the next one is refused. - The refusals are not concentrated in the warm-up. They land
between +176 s and +824 s, through the first fourteen minutes
of a 25-35 minute step, after the ledger rows exist. In the
baseline, the ledger-3 nodes sat at 100% of ledger at p90 in
65% of node-records (peak at or above 1.0 in 96%); 33 of the
37
slim-tierfailures had at least one abort;slim-tierpassed 13 of 50.
Per-node saturation across the six post-#4106 runs
(max(measured, committed) >= limit):
| node (ledger) | share of the run at ledger | longest stretch |
|---|---|---|
| sf1 (3) | 7-23% | 90-330 s |
| primary (3) | 1-12% | 15-255 s |
| sf2 (6) | 0-15% | 0-240 s |
Three real mechanisms that are not the driver¶
The warm-up window is still open. Issue #4087 found that
scheduler_node_capacity has no rows until the reconciler's first
pass, so every early placement takes P7's fail-open branch and the
first pass then writes the accumulated ground truth onto a row
whose limit it exceeds. PR #4106 anchored the reconcile to
election and added a one-shot
_force_capacity_reconcile_if_unguarded()
(shakenfist/daemons/cluster/main.py:741-778, called at :879).
In all six post-#4106 runs that one-shot fired 130-150 s before
the test step, and the pass it forced logged nodes=0
nodes_added=0: no hypervisor had published metrics yet. The table
then stayed empty until the five-minute cadence created rows at
+155..+181 s, exactly the 135-210 s the baseline measured before
the fix. Ten to seventeen placements per run were admitted
node_not_sized, and the first real pass recorded drift_cpus of
2-8 (34178278720: sf1 +6 on a limit of 3). The bold rows above are
this residue. It is real, it is the whole of the sub-three-minute
exposure, and it is not what fails the runs.
Measurement lags a delete burst by a metrics period. The
34119030297 refusal was measured 6 / committed 0 / limit 3: six
warm-up instances had been deleted and released 32 s earlier and
the ledger was correctly zero, but cpu_total_instance_vcpus is
republished every 60 s (resources/main.py:645, 704) and still
counted six running domains. The next sample read zero. Because
_has_sufficient_cpu() charges max(measured, committed), a node
refuses forced creates for up to a minute after a teardown even
though it is empty. The same rule is why "release the ledger
earlier in Instance.delete()" -- a proposal the research
considered -- would not help: the measured side keeps charging the
node until the domain is actually gone, and the journals show no
deleted instance holding ledger at any refusal.
The demand guard is pure overhead on this topology. 114-140
schedule candidate refused by capacity guard events per run,
100% demand-only: the D13 bound is 0.75 x cpu_schedulable,
which is 0.75 on a 1-thread node against a cpu_load_1 near 3.
Every one was waived on the second walk, so roughly 40% of creates
pay two guarded transactions at the moment the cluster is busiest.
It never produced a 507 in these runs. It is owned by the
scheduler-reservations plan and recorded there.
What has been tried¶
- #3724 added the committed-vCPU ledger so placed-but-unbooted instances are charged immediately. Admission became more accurate; an accurate "no" is still a failed test. Three recurrences followed and the six per-test issues were absorbed into #3772.
- #3722 reordered the load-shedding filters below affinity. Cannot help: the node is removed at admission before ranking.
- #4106 closed #4087 in principle and, as above, not in CI.
- #3813 made the demand guard satisfiable; the waiver rate fell from 62% to 4%. Not a 507 cause.
- #3907 and #3565 were closed test-side, by tolerating the transient refusal and by skipping when the candidate set had collapsed. Both are precedents for the shape of this plan and neither generalised.
What this is not¶
Claims are not the answer to a forced placement. A namespace claim
is cluster-wide and carries no node affinity (scheduler-reservations
D14), shakenfist/scheduler.py never reads namespace_claims, and
the stage that refuses is per-node. Per-test namespaces holding
claims would starve one another's unclaimed pool, and phase 5's
hard ceiling caps the holder rather than helping it. The one
legitimate claim in CI is the conductor holding one per run against
other merge groups on the under-cloud, which is
scheduler-reservations phase 4c. Node-scoped claims are considered
under open question 5 below and declined for now.
Mission and problem statement¶
Make a capacity refusal something a caller can wait out rather than a failure it has to report, without hiding the refusal from the people who need to see it. Concretely:
- A cluster's first guarded placement should happen within seconds of its hypervisors publishing metrics, not five minutes after its cluster daemon started.
- A node should stop charging for instances that no longer exist within seconds of their deletion, not within a metrics period.
- The functional suite should treat a 507 at
create_instanceas "not yet" -- waiting, informed by the cluster's own published headroom, for the node it needs -- and should say, per test and per run, how long it waited, so that a topology that makes the suite wait is visible rather than merely slower. - The API should tell a client that a refusal is transient and when to try again, and the Python client should be able to act on that when asked to.
- Whether the server itself should queue a create until it fits is decided from measurement, not argued from first principles, and the decision is written down either way.
The merge-queue outcome this is measured by is the Debian 12
tier job's pass rate, which is 24% in the baseline and should be
comparable to the other cluster jobs (78-96%) once this plan and
the sizing plan's phase 4 have both landed.
Open questions¶
These were settled at planning time from the research recorded in the Situation section; the reasoning is kept so that a later reader can disagree with it on the evidence rather than re-deriving it.
1. Where does the retry live?¶
In the suite first, in the client second, in the server only
if the data says so. The scheduler-reservations plan left this
undecided ("the client SDK, the CI base class, or server-side
admission queueing") and deliberately held it until #3772 had soak
data from a develop carrying atomic admission. That soak data
now exists -- the phase 2 baseline and the journals above -- and
it says the refusals are correct against the ledger. A suite-side
wait is the smallest change that stops the bleeding, it is where
retry_while_transient already lives, and it is the only place a
wait can be informed: GET /admin/resources publishes per node
cpu_available = cpu_hard_max - max(measured, committed), which is
the pre-filter's own arithmetic, so a pinned test can wait for the
node it needs rather than for the cluster. The client change is
next because it is what makes the behaviour available to operators
and to the downstream repositories' suites. A server-side queue
reverses scheduler-reservations D8 and is phase 5's decision.
2. Does a retry hide the problem?¶
Only if it is silent, so it will not be. Both sibling plans say a retry would mask whether a bigger cloud or atomic admission actually changed the failure rate. The wrapper in phase 2 therefore records every wait as a test detail (how long, on which node, what the headroom looked like) and the run publishes a summary -- total seconds waited, waits per test, longest wait -- through the same bundle the headroom probe uses, so the sizing plan's phase 5 guardrails can warn on it. A cloud that makes the suite wait becomes a number in every run instead of a flake in some. The sizing plan's phase 3 saturation tests, which assert what a full cluster does today, call the raw client and are exempt from the wrapper by an explicit marker.
3. Why is allocation less reliable in the first minutes, and can the window be shortened?¶
Because the one-shot fires before there is anything to
reconcile; yes, from 135-210 s to roughly a
minute. _force_capacity_reconcile_if_unguarded()
runs once, on the election path, and tests if rows:. On a fresh
cluster the cluster daemon wins its election within 2.5-7.5 s of
starting, before sf-resources on any hypervisor has published --
the pass finds nothing, and nothing re-checks. Phase 1 makes the
check a property of the elected loop rather than of election:
compare the set of active hypervisors that have published metrics
against the set with a capacity row, and make the reconcile due
when they differ. That also closes the narrower hole
4106 left -- a node that publishes late, or whose row the¶
reconciler removed on stale metrics, admits unguarded for up to five minutes today.
Note what bounds the improvement, because an earlier draft of this
section claimed seconds. The elected loop polls every
ELECTED_LOOP_POLL_SECONDS = 5
(shakenfist/daemons/cluster/main.py:63) but its maintenance body
sits behind if now - last_loop_run >= 60 (:913), and
_run_due_scheduled_jobs() is inside that gate (:918). Marking
the job due does not run it any sooner than the next 60 s tick, so
a check placed inside the gate closes the window to about a
minute. Closing it faster means running the comparison outside the
gate on the 5 s poll, which is a fixed-rate database read and is
not free. Phase 1 decides between those two; see its plan.
4. Why does a node refuse for a minute after its instances are deleted?¶
Because measurement is a 60 s poll of libvirt and the pre-filter
charges the larger of measurement and ledger. The rule is right:
it is what makes the ledger safe against instances the reconciler
has not yet counted. What is wrong is the cadence. Phase 3 has
sf-resources notice a change in the running-domain set cheaply
(a listAllDomains every few seconds costs nothing and touches no
database) and publish immediately when it changes, keeping the
full 60 s publish for everything else. That is activity-coupled
rather than fixed-rate, so it does not move the database load
budget's idle figure, and it is declared in
database_load_budget.yaml as such.
5. Should there be node-scoped claims?¶
Not now, and not for this problem. The question was raised at planning time because a claim "on hypervisor X for N vCPU" is the shape a pinned test would want: hold the reservation, then create against it, with no window between "the node has room" and "my create landed" for another worker to take it. Against that:
- For CI it buys one thing over an informed wait -- closing that window -- and the window is small at concurrency 5. Every other property (waiting for the right node, bounded by a deadline, visible in the run) the phase 2 wrapper has already.
- It is a second ledger dimension on every
scheduler_node_capacityrow (claimed_cpusbesideused_cpus, with the unclaimed admission guarded againstlimit - claimed), which the guarded UPDATE, the reconciler, the cluster singleton's migration on claim create and delete, and the pre-filter all have to learn. That is scheduler-reservations phases 3 and 4 again, one level down, while phase 5 has not decided what enforcement of the existing claims even means. - The pre-filter is claim-blind today; a node claim only works if
_has_sufficient_cpu()consults it, which makes the pre-filter a ledger reader in a way D1 chose not to.
Where it would earn its place is an operator need, not a test need: evacuating or draining a node (#1364) has to know there is room on the destinations before it starts moving instances, and a live migration wants the same guarantee. When that lifecycle is built, a per-node reservation is the primitive it needs, and it should be designed then, against the claim machinery as it stands after phase 5. Recorded under Future work in the scheduler-reservations plan so it is not lost.
6. Should the demand guard be changed here?¶
No; it is scheduler-reservations' and is recorded there. The
bound of 0.75 x cpu_schedulable cannot be met on a 1- or 2-thread
node under any real load, so on the CI topologies the first walk
never admits and the waiver walk always does. Rescaling or waiving
it below some cpu_schedulable is a one-line change to the demand
clause, but it is phase 4a's clause and the load it saves is the
scheduler-reservations plan's to measure. This plan cites the
evidence and moves on.
7. Should the topology change here?¶
No; it is the sizing plan's phase 4, and the evidence here
sharpens what that phase must choose. Any shape that leaves an
infra hypervisor at limit_cpus 3 leaves this failure in place,
because the pinned tests select those nodes. The sizing plan's
candidate "tier as 3 x 6 vCPU" gives primary and sf1 a ledger
of 6 each and sf2 12, which is the smallest shape in its table
that changes the number that binds. The sizing plan is updated to
say so.
8. Should the server queue a create that does not fit?¶
Decide in phase 5, from phase 2's wait data. The machinery
exists -- BaseClusterOperation.defer_with_backoff() already
re-enqueues with a delay for artifact fetches and network
operations -- and a 202 with the instance held in initial (or a
new scheduling state) until placement succeeds or a deadline
passes is a contained change. Against it: it is exactly the
"hold-until-fittable" that scheduler-reservations D8 rejected as
queue-state surface the project does not want; it has no fairness
model (a waiting 4-vCPU create starves behind a stream of 1-vCPU
ones, and a pinned create starves worst); waiting instances hold
IPAM allocations, so a CPU shortage can become an address shortage;
and the client's _await_instance_create has a 900 s ceiling that
bounds any useful deadline. If phase 2 shows the suite waits are
short and few once the topology is right, the answer is no and the
phase closes as Abandoned with the numbers. If they are long or
many, the phase designs the queue -- FIFO by request time, pinned
placements admitted against their node only -- and reverses D8 in
writing.
9. What should the API say?¶
That it is transient, and when to try again. Both 507
bodies from POST /instances (external_api/instance.py:906 for
the pre-filter, :976-981 for the guard) are bare strings. Phase 4
adds a Retry-After header and a machine-readable stage and
transient: true to the error body, so a client does not have to
parse prose to know which refusal it got. The honest hint is a
fixed conservative constant (15 s, matching defer()'s default)
rather than a computed one: the server has no pending-release
horizon and a number that implies knowledge it lacks is worse than
one that does not. The client's opt-in retry reuses the shape of
its existing 406 loop and is bounded by the same deadline. The
affinity 409 is deliberately ordered first in the handler and
must never be retried.
Execution¶
Plan status vocabulary (shared block; do not edit -- the canonical
copy lives in shakenfist/development at
templates/shared-blocks/plan-status-vocabulary.md):
A status cell -- in the master plan's own Execution phase table, and
in the row docs/plans/index.md carries for the plan -- holds
exactly one of these terms and nothing else:
Proposed-- written down as a concept, not yet scheduled.Not started-- scheduled, but no work has begun.In progress-- work has begun and has not finished.Blocked-- cannot proceed until something outside the plan changes. Say what, in the plan.Complete-- the work is done.Abandoned-- deliberately dropped without being done.Superseded-- replaced by another plan, which the plan names.
The term is the whole cell. No dates, no phase arithmetic, no parenthetical qualifiers, no summary of what happened: a status is read to decide whether a plan still wants attention, and prose in that column has repeatedly grown until it could no longer be read either by a person scanning the table or by tooling. Detail belongs in the plan file, and a one-line summary belongs in the index's own Intent column.
Matching is case-insensitive, so In Progress is accepted, but the
spelling above is the one to write.
In this project
The same term is written twice: once in the phase table
below, and once in the row this plan carries in
docs/plans/index.md. Keep them in step -- the index row is
the whole-plan status, so it only reaches Complete once
every phase has been completed, abandoned or superseded.
| Phase | Plan | Status |
|---|---|---|
| 1. Close the warm-up window: reconcile when a hypervisor has metrics and no capacity row | PLAN-transient-capacity-refusals-phase-01-warm-up.md | Complete |
2. The suite waits, and says so: an informed create_instance wrapper and a per-run wait summary |
PLAN-transient-capacity-refusals-phase-02-suite-wait.md | Not started |
| 3. Publish metrics when the running-domain set changes | PLAN-transient-capacity-refusals-phase-03-metrics-on-change.md | Not started |
4. Retry-After and a machine-readable transient refusal, with an opt-in client retry |
PLAN-transient-capacity-refusals-phase-04-retry-after.md | Not started |
| 5. Decide on server-side queued placement from the phase 2 data | PLAN-transient-capacity-refusals-phase-05-queue-decision.md | Not started |
| 6. Documentation and close-out | PLAN-transient-capacity-refusals-phase-06-docs.md | Not started |
| 7. Push audit | PLAN-transient-capacity-refusals-phase-07-push-audit.md | Not started |
Phases 1, 2 and 3 are independent of one another and can run in
parallel. Phase 4 follows 2, because the client retry should match
the semantics the suite has already proven. Phase 5 needs phase 2
to have reported over a window of merge runs after the sizing
plan's phase 4 has reshaped slim-tier; until then its data would
be measuring the wrong cloud. Phase 6 follows everything else, and
the phase 7 push audit is last.
The ordering against the sibling plans: the sizing plan's phase 3 (saturation coverage) does not gate any phase here, because none of them changes what a full cluster does -- but its phase 4 (reshape) should land before phase 5 here reads its numbers. Nothing here touches the demand guard, the pre-filters, claim enforcement or the guarded UPDATE, all of which are scheduler-reservations' (D11, phase 5).
Phase 1 -- Close the warm-up window¶
Make _force_capacity_reconcile_if_unguarded() a check the
elected loop repeats rather than a one-shot at election
(shakenfist/daemons/cluster/main.py:741, called once at :879).
The check itself is the same comparison either way: if any active
hypervisor has published metrics and has no capacity row, make the
reconcile due now. Keep the
distinction between a degraded read and an empty result that the
existing code is careful about (rows is empty for both; only
degraded says which).
The decision this phase owns is where the check runs, and the
master plan does not pre-empt it. Inside the elected loop's 60 s
maintenance gate (:913) the check costs nothing new -- it rides
a pass that already reads the database -- and closes the warm-up
window to about a minute. Outside the gate, on the
ELECTED_LOOP_POLL_SECONDS = 5 poll, it closes the window to
seconds but adds a fixed-rate ~0.2/s read that needs a
cluster_base_qps entry in
shakenfist/data/database_load_budget.yaml, or
test_no_unbudgeted_fixed_rate_database_polling fails. The phase
plan picks one and says why; both are defensible and the 60 s
version is the smaller change.
Be accurate about the stability gate rather than repeating the
elected loop's shorthand comment. cluster_stable()
(shakenfist/daemons/daemon.py:377) compares object versions
across nodes and reads no metric freshness at all; it catches a
just-restarted cluster only incidentally, because no node has
recorded a version yet and minimum is inf. The pass still
belongs behind it -- issue #4087's second correction explains why
a pass with no fresh metrics deletes rows -- but the protection is
a side effect of the version check, not a freshness check, and the
phase plan should not assume otherwise.
Prove it two ways. A unit test in shakenfist/tests/ drives the
elected loop with a fake capacity table and a fake node roster and
asserts the reconcile becomes due on the iteration a hypervisor
first appears without a row, and not on later iterations where
every hypervisor has one. A functional assertion in the CI
harness reads the headroom probe's own series and requires that
cpu_committed_row_present is true for every hypervisor before
the first instance placed event of the run -- the field already
exists end to end (published at shakenfist/scheduler.py:1073,
harvested by tools/ci_headroom_harvest.py, reported by
tools/ci_headroom_report.py:319) and the baseline dataset
already carries it, so the assertion's premise can be checked
against it before it is written. That assertion also retires a
skipTest: cluster_ci_tests/test_nodes.py:136 currently skips
when a node has no capacity row, which is exactly the condition
this phase makes impossible after start-up. Remove the skip in the
same change rather than leaving unreachable code behind it. Phase
1 also comments on #4087 with the finding and closes it. The issue
is still open -- #4106 never closed it -- so there is nothing to
reopen.
Small, server-side, one file plus tests. Plan at high effort: the placement decision above, the interaction with the stability gate and the degraded-read distinction are the kind of thing a light brief gets wrong.
Phase 2 -- The suite waits, and says so¶
Add BaseTestCase.create_instance() to
shakenfist/deploy/shakenfist_ci/base.py, route every raw
test_client.create_instance(...) call site in cluster_ci_tests/
and guest_ci_tests/ through it (there are 115 call sites across
39 files; a list is a mechanical grep), and add a unit test in
shakenfist/tests/ that walks the suite's source with ast and
fails on any raw call outside an explicit allowlist marker, the way
test_ci_claims_headroom.py already asserts call sites by name.
The wrapper: on InsufficientResourcesException, if the deadline
(420 s, the claims suite's CLUSTER_HEADROOM_WAIT) has not passed,
emit a tracing event, then wait informed: poll
system_client.get_cluster_resources() at 10 s and proceed when
per_node[target]['cpu_available'] >= cpus for a force_placement
create, or total['cpu_available'] >= cpus otherwise, falling back
to a blind sleep when total['capacity_degraded'] is set. Re-create
with a fresh uniquified name, because the refused instance is
enqueue_delete_due_error'd and its name is not reusable
synchronously. Reuse retries.retry_while_transient by converting
the exception to a (status, body) pair so the loop stays
unit-testable against a fake clock. Every wait is attached to the
test as a detail: seconds waited, the node waited for, and the
per_node headroom at the first refusal and at admission.
The run summary: total seconds waited, number of waits, longest
wait and its test, written to the bundle beside the headroom
probe's output and printed in the job log. The collection step is
in shakenfist/actions (the reusable smoke-cluster workflow),
so this phase carries the same operator-push obligation the
sizing plan's phase 1 did, and the summary is designed so that the
sizing plan's phase 5 guardrail can read it.
Budget the poll. GET /admin/resources reads GetNodeMetrics from
the api caller, which is already in HARNESS_DRIVEN_PAIRS
(load_budget.py:309-323) -- but that exemption's prose names the
headroom probe as the producer and
test_the_suite_still_probes_cluster_headroom holds it up. Extend
the comment to name the wrapper as a second, activity-coupled
producer (it polls only while a create is being retried), or the
load-budget test's premise rots silently.
Two test-side changes ride along because they are cheap and reduce
the number of pinned creates: test_commandline_artifacts.py's
second instance and test_imagefetch.py's first are pinned for
convenience rather than for the assertion, and can target
inst1['node'] only where co-location is actually load-bearing.
And test_system_namespace.py subclasses BaseTestCase rather than
the namespaced base, so a failure between its inline create and
delete strands a charged instance in the system namespace for the
rest of the run; give it an addCleanup.
The sizing plan's phase 3 saturation tests must call the raw client and assert the refusal; the allowlist marker exists for them.
Plan at high effort. The wrapper is straightforward; the AST guard, the load-budget declaration and the bundle plumbing across two repositories are where a light brief goes wrong.
Phase 3 -- Publish metrics when the running-domain set changes¶
In shakenfist/daemons/resources/main.py, beside the 60 s publish,
poll libvirt's domain list every few seconds (no database access)
and, when the set of running domains or their vCPU total differs
from what was last published, publish immediately. Keep the 60 s
full publish unchanged. Declare the new publish rate in
shakenfist/data/database_load_budget.yaml as activity_coupled
so the load-budget check models it correctly, and add a unit test
that a domain disappearing between polls produces a publish before
the 60 s tick.
This is what makes "the node is empty" true within seconds of a teardown rather than within a minute, and it is the only change in this plan that touches a daemon other than the cluster daemon. Plan at medium effort; the pattern is the existing loop.
Phase 4 -- Retry-After and a machine-readable transient refusal¶
Server: on both 507 branches of POST /instances
(external_api/instance.py:906, :976-981), set Retry-After: 15
and extend the error body with stage (sufficient_idle_cpu, or
capacity_guard) and transient: true. sf_api.error() returns
a bare flask.Response, so the header is set on the returned
object. The 409 affinity branch is untouched. Update the OpenAPI
declaration and the API-validation plan's error contract if it
describes the body shape.
Client (client-python): carry response headers on
APIException, and add an opt-in retry policy for 507 bodies
carrying transient: true, bounded by the existing async-strategy
deadline and reusing the shape of the 406 loop in _request_url.
Off by default; the suite turns it on, and its phase 2 wrapper then
becomes the informed layer over the client's blind one.
Plan at medium effort; each half is small, and the coordination is a version pin between the two repositories.
Phase 5 -- Decide on server-side queued placement¶
A decision phase, not a build phase. Read the phase 2 wait summaries over at least twenty merge runs after the sizing plan's phase 4 has landed. If total wait per run is small and no test waits near its deadline, close this phase as Abandoned with the numbers and the reasoning in the phase file. Otherwise, design the queue against open question 8's constraints -- FIFO by request time, pinned creates admitted against their node only, IPAM allocated at placement rather than at request, a deadline the client's 900 s create ceiling can contain -- and record the reversal of scheduler-reservations D8 in that plan's decisions file before any code is written.
Plan at high effort if it goes ahead; the state-machine and fairness questions are the expensive kind.
Phase 6 -- Documentation and close-out¶
Document the transient-refusal contract in
docs/operator_guide/scheduler.md and the API reference, the suite
wrapper and its allowlist marker in docs/developer_guide/ci.md,
and the wait summary beside the headroom probe's documentation.
Update docs/plans/index.md and the sibling plans' cross-references
to their final state. Comment on #3772 with the before-and-after
pass rate and close it only if the Debian 12 tier job's failures
are no longer sufficient_idle_cpu; otherwise leave it open with
the numbers.
Phase 7 -- Push audit¶
Runs PUSH-AUDIT.md over the accumulated diff of every phase in
this plan against develop, not the last phase's diff alone.
Findings land as their own pull request, and the plan is not
complete until each is resolved or declined in writing here. If
the audit finds nothing, that is recorded in one sentence.
Agent guidance¶
Execution model¶
Sub-agent execution model (shared block; do not edit -- the
canonical copy lives in shakenfist/development at
templates/shared-blocks/subagent-execution-model.md):
All implementation work is done by sub-agents, never in the management session. The management session is reserved for planning, review, and decision-making. This keeps the management context lean and avoids drowning it in implementation diffs.
The workflow is:
- Plan at high effort in the management session.
- Spawn a sub-agent for each implementation step with the brief from the plan, at the recommended effort level and model.
- Review the sub-agent's output in the management session. Check the actual files -- the sub-agent's summary describes what it intended, not necessarily what it did.
- Fix or retry if the output is wrong. Diagnose whether the brief was insufficient (improve it) or the model was too light (upgrade it), then re-run.
- Commit once the management session is satisfied.
This applies to all steps, including high-effort ones. If a sub-agent cannot succeed even with a detailed brief and the right model, that is a signal the brief needs improving, not that the management session should do the implementation itself.
Use isolation: "worktree" for sub-agents when the change is
risky or experimental; the worktree is discarded if the output is
unsatisfactory. For safe, well-understood changes, sub-agents can
work directly in the main tree.
Planning effort¶
Planning effort (shared block; do not edit -- the canonical copy
lives in shakenfist/development at
templates/shared-blocks/plan-planning-effort.md):
The master plan itself is always created at high effort -- it requires broad codebase understanding, cross-referencing several source files, and judgment calls about scope and sequencing.
Each phase plan states the recommended effort level for planning that phase. Phases that turn on design decisions, cross-component coordination, protocol changes, or subtle correctness questions should be planned at high effort. Phases that are mechanical, or that follow a pattern already established elsewhere in the codebase, can be planned at medium effort.
In this project
Phases 1, 2 and 5 are planned at high effort: the first for its interaction with the stability gate and the degraded-read distinction, the second for its two-repository plumbing and the load-budget declaration, the fifth because it may be a state-machine design. Phases 3, 4 and 6 follow patterns that already exist and are planned at medium effort.
Step-level guidance¶
Sub-agent step guidance (shared block; do not edit -- the
canonical copy lives in shakenfist/development at
templates/shared-blocks/subagent-step-guidance.md):
Each phase plan includes a table like this:
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 1a | medium | sonnet | none | One-sentence summary of what to do and which files to touch |
| 1b | high | opus | worktree | Why this needs high effort: requires understanding X to do Y |
Effort levels, from cheapest to most thorough:
- low -- Purely mechanical changes: rename, reformat, add a log line, regenerate generated code. The brief is a complete instruction.
- medium -- The plan provides enough context to follow a clear brief. The sub-agent may read a few files, but the approach is already decided.
- high -- Requires reading several files, making judgment calls, or understanding non-obvious invariants. The sub-agent needs to think about edge cases.
- xhigh -- The setting for hard coding and agentic steps: long-horizon changes, or steps where the sub-agent must both research and implement.
- max -- Correctness matters more than cost. Expect diminishing returns and occasional overthinking; reserve it for steps where a wrong answer would be expensive to detect.
Brief for sub-agent: this is the key field. Write it as if briefing a colleague who has never seen the codebase. Include what to change, which files to touch, what patterns to follow, and any non-obvious constraints.
A good brief front-loads the research the planner already did, so the implementing agent does not repeat it. Instead of "add storage functions for the new object", name the functions to add, the file they belong in, the existing equivalent to mirror (with line numbers), and any registration the change also needs.
The better the brief, the lower the effort level needed and the lighter the model that can succeed.
In this project
The invariants a brief in this plan must carry, because none of them is inferable from the code being edited:
- Placement transactions open with a guarded
UPDATE, never aSELECT(the snapshot-isolation invariant,AGENTS.md). No phase here touches one, and a brief should say so explicitly so a sub-agent does not "improve" one in passing. - Attribute writes carry a field mask (
CLAUDE.md, common pitfall 3). - Any new suite-side or daemon-side polling is declared in
shakenfist/data/database_load_budget.yamlorHARNESS_DRIVEN_PAIRS, with prose naming the producer, ortest_no_unbudgeted_fixed_rate_database_pollingwill fail the next merge group (#3975, #4028). - The reconcile pass stays behind
cluster_stable(). - The
409affinity refusal is never retried.
Model choice¶
Sub-agent model roster (shared block; do not edit -- the canonical
copy lives in shakenfist/development at
templates/shared-blocks/subagent-model-roster.md):
The planner recommends which model is best suited to each step. This is a judgment call, not a rigid rule -- the right model depends on what the step requires, not on whether it is "planning" or "implementation". The models available to sub-agents are:
- fable -- The most capable model available, for the hardest reasoning and the longest-horizon work: multi-step changes a single sub-agent must carry end to end, or steps whose correctness depends on holding a whole subsystem in mind at once. It costs materially more than opus, so reserve it for steps that have already defeated opus or are expected to.
- opus -- The default for steps needing deep reasoning, architectural understanding, subtle correctness judgment (locking, state machines, migrations), or intricate implementation that would be costly to debug if it were wrong.
- sonnet -- A good default for well-briefed implementation work. Faster and cheaper than opus, and effective when the plan front-loads the research and the brief leaves no broad judgment calls to make.
- haiku -- Suitable for purely mechanical tasks: search-and-replace, regenerating generated code, adding log lines, running commands. The brief must be a near-complete instruction.
Model choice interacts with effort level and brief quality. A detailed brief compensates for a lighter model -- sonnet at medium effort with a thorough brief often matches opus at medium effort with a vague brief. The planner's job is to write briefs good enough that the recommended model can succeed.
The model also determines the context window: fable, opus and sonnet have 1M tokens, haiku has 200K. A step that must hold many files in context at once may need one of the larger-context models for that reason alone, even when the reasoning itself is straightforward.
When in doubt, skew to the more capable model. Saving money only matters if the outcome is still acceptable. A failed or low-quality implementation wastes more time -- and therefore more money -- than the heavier model would have cost. Recommend a lighter model only when you are confident the brief is detailed enough for it to succeed.
Management session review checklist¶
Management session review checklist (shared block; do not edit --
the canonical copy lives in shakenfist/development at
templates/shared-blocks/plan-review-checklist.md):
After a sub-agent completes, the management session verifies:
- The files that were supposed to change actually changed -- read them, do not trust the summary.
- No unrelated files were modified.
- The changes match the intent of the brief: not merely syntactically correct, but semantically right.
- The project's own pre-merge checks pass, including any generated code that has to be regenerated and committed (see the project-specific checks below).
- The commit message follows project conventions, including
the
Co-Authored-Byline recording model, context window, and effort level.
In this project
The project-specific checks referred to above are:
- The code passes
pre-commit run --all-files(flake8, stestr unit tests, mypy). -
python3 tools/check-plan-status.pypasses after any edit to a plan or todocs/plans/index.md. - A change that adds polling has its budget entry, and
test_no_unbudgeted_fixed_rate_database_pollinghas been reasoned about, not merely run.
Administration and logistics¶
Success criteria¶
We will know when this plan has been successfully implemented because the following statements will be true:
- In every merge-run bundle, every hypervisor has a capacity row
before the run's first
instance placedevent, and no first reconcile pass records adrift_cpusabove zero. - A node's published
cpu_total_instance_vcpusfalls within ten seconds of its last instance being undefined. - No functional test fails with
507 sufficient_idle_cpuatcreate_instance; a test that waits for capacity records how long, and the run's summary reports it. POST /instancesrefusals for capacity carryRetry-After,stageandtransient: true, andshakenfist_clientcan be told to honour them.- The decision on server-side queued placement is written down in the phase 5 file with the data it was made from, whichever way it went.
- The
Debian 12 tierjob's pass rate is comparable to the other cluster jobs, and its remaining failures are notsufficient_idle_cpu. - The code passes
pre-commit run --all-files(flake8, stestr unit tests, and mypy type checking). - Lines are wrapped at 120 characters, single quotes for strings, double quotes for docstrings.
- Documentation in
docs/has been updated.ARCHITECTURE.mdandAGENTS.mdare updated only if a convention or the shape of the system changed; a transient-refusal contract is an operator-guide and API-reference matter.
Documentation index maintenance¶
This plan is registered in docs/plans/index.md (one row, in the
Master plans table) and in docs/plans/order.yml. Phase files
are linked from the Execution table above and appear in neither,
which is what tools/check-plan-status.py enforces.
Plan close-out sections (shared block; do not edit -- the
canonical copy lives in shakenfist/development at
templates/shared-blocks/plan-closeout-sections.md):
Future work¶
We should list obvious extensions, known issues, unrelated bugs we encountered, and anything else we should one day do but have chosen to defer to here, so that we do not forget them.
- Stop re-scheduling an already-charged placement.
NodeInstNetdescOp._instance_preflight()(shakenfist/operations/node_inst_netdesc_op.py:156-162) constructs a freshScheduler-- a fullrefresh_metrics(), oneget_node_metricsRPC per node -- and re-runsfind_candidates(inst, candidates=[config.NODE_UUID])for an instance the API already admitted on this node. The ledger term cannot refuse it (the self-charge is subtracted), but the 60-second-oldmeasured_cpuscan, and when it does the op redirects to another node and re-enqueues the artifact fetches. Replacing that with "is my placement still recorded here and is the node healthy" removes a distinct CI failure family (Too many start attempts) and halves per-create scheduler load. Not a 507 cause, so not in this plan. - Suite concurrency denominated in ledger. The sizing plan's Future work already records this; phase 2's wait summary is the measurement that would justify it.
- The queue-depth stage.
_has_reasonable_queue_staterefuses any node with more than twenty waiting jobs. Under suite concurrency queue depth is exactly what spikes; it did not fire in the runs read here but it is untracked and worth a census line. - Every refused create is a full create-and-delete.
enqueue_delete_due_errorat the 507 site means each refusal costs an object, IPAM allocations, an event trail and a delete op, at the moment the cluster is busiest. Phase 5's queue, if it is built, removes this; if it is not, the cost stands and should be measured.
Bugs fixed during this work¶
This section should list any bugs we encounter during development that we fixed. You should also scan the project's issue tracker, where one exists, for directly related issues that we should either resolve as part of this master plan or at least be aware of while planning it.
- #3772 (open, umbrella) -- the refusal this plan is about. Stays open until phase 6 has the before-and-after numbers.
- #4087 (open; #4106 attempted it and did not close it) -- the warm-up window. Phase 1 fixes it, comments with what #4106 did and did not cover, and closes it.
- #3498, #3602, #3670, #3728, #3749, #3767 (closed into #3772) -- the per-test victims. Do not file another; the umbrella exists because per-test tracking stopped paying for itself.
- #3907, #3565 (closed test-side) -- precedents for tolerating a transient refusal in the suite; phase 2 generalises what they did once.
- #3975, #4028 (closed) -- the headroom probe failing the load-budget check. The obligation they left is why phase 2 and phase 3 each carry a budget declaration.
- #1364 (open) -- lame-duck and evacuate. Where node-scoped reservations would earn their place; see open question 5.
Back brief¶
Before executing any step of this plan, please back brief the operator as to your understanding of the plan and how the work you intend to do aligns with that plan.