Phase 2: the suite waits, and says so¶
Parent plan: PLAN-transient-capacity-refusals.md.
Planning effort: high, as the master plan specifies. The wrapper
itself is thirty lines. What keeps this above mechanical is that the
wrapper has to decide when to stop waiting, and the obvious
predicate -- the headroom figure /admin/resources publishes -- is
not the quantity admission refuses on. A wrapper that waits on the
wrong number does not fail loudly: it stops waiting early, re-issues
a create that refuses again, and turns one 507 into six while
reporting that it waited. The second hard part is that this phase's
output is evidence phase 5 will make a decision from, so a summary
which quietly loses waits is worse than no summary.
This phase continues the plan's decision sequence at D7. There is one namespace across the whole plan; phase 1 used D1-D6.
Context¶
The master plan's Situation section establishes that a capacity
refusal in CI is usually a statement about a moment rather than about
the cluster: the instance the refusing node is holding is often
already being deleted by a sibling test. The suite treats every one
of them as a hard failure, because create_instance() is called raw
at every site and a 507 raises.
Phase 1 removed one of the two causes. A cluster's first minutes no longer admit placements against nothing: the measured time-to-first-capacity-row went from 165 s to 0 s on the same probe (see phase 1's Outcome section). That matters here in a way worth stating, because it changes what this phase measures. Before phase 1, a wait early in a run could be a cold ledger. After it, a wait is what it appears to be -- some other test is holding the capacity -- which is exactly the quantity phase 5 needs and could not previously have read cleanly.
What this phase does not do is make refusals rarer. It makes them survivable, and it writes down how long survival took.
Scope¶
In:
- A
create_instance()wrapper onBaseTestCasewhich waits out a transient capacity refusal, and every call site inshakenfist/deploy/shakenfist_ci/routed through it. - A unit test which walks the suite's source with
astand fails on a raw call outside an explicit allowlist marker. - A per-wait record written where the CI bundle already collects it,
and a summary in
tools/ci_headroom_report.py. - The load-budget declaration for the wrapper's polling.
- Two ride-along test fixes the survey confirmed are real.
Out:
- Any change to what the server does about a refusal.
Retry-Afterand the machine-readable transient marker are phase 4; the wrapper here reads only what/admin/resourcesalready publishes. - Any change to the scheduler, its pre-filters, the demand guard or
the guarded
UPDATE. Those are scheduler-reservations' (D11). - Deciding anything about a server-side placement queue. This phase produces the numbers phase 5 decides from and takes no position.
- Making the suite use less capacity. Reducing the number of pinned creates is a ride-along where the survey found the pin was not load-bearing, and is not a goal in itself.
What the survey found¶
The master plan's phase 2 section was written on 2026-09-08, before phase 1 executed. Most of it holds. Five claims do not, and two of those change the design rather than the wording.
1. The call-site count is wrong, and so is the scope it names.
The section says "115 call sites across 39 files" in
cluster_ci_tests/ and guest_ci_tests/. Those two directories hold
88 calls in 32 files today. The count that matches 115 is the one
over all of shakenfist/deploy/shakenfist_ci/: 117 calls in 41
files. The difference is smoke_ci_tests/ (27 calls in 7 files),
plus one call each in base.py:1227 (TestDistroBoots) and
database_tier.py:275. The smoke tier is not a rounding error: it is
the tier that runs on the smallest cloud, where a single sibling
instance is a larger fraction of the cluster. Corrected at source in
the master plan.
2. The published headroom figure is not what admission refuses
on. summarize_resources() computes
per_node[n]['cpu_available'] = cpu_hard_max - max(measured,
committed) (shakenfist/scheduler.py:1086-1088), where
cpu_hard_max is the live overcommit arithmetic. Admission refuses
against the capacity row's limit_cpus, which is the same arithmetic
refreshed once a reconcile period. The endpoint publishes both
deliberately and says why: the comment at :1071-1078 is explicit
that cpu_limit has no fallback precisely so a reader can see the
two ledgers disagree. A wait predicate reading only cpu_available
therefore returns "there is room" during exactly the window where the
two differ -- which is the window this plan exists to describe. See
D8.
3. total['cpu_available'] is a sum, and an instance is not.
The same function accumulates total['cpu_available'] by adding each
node's headroom, flooring negatives at zero (:1091-1093). An
instance must fit on one node. total['cpu_available'] >= cpus is
therefore satisfied by a cluster of ten nodes with one spare thread
each, which will refuse a two-vCPU create every time. See D8.
4. The target node can be absent from per_node, not merely
short of headroom. per_node skips any node whose metrics say it
is not a hypervisor and any node over UNREASONABLE_QUEUE_LENGTH
(scheduler.py:1039-1044), and a node with no metrics row at all
never appears. Phase 1 hit this in test_nodes.py and had to assert
assertIsNotNone(per_node) separately from the headroom check. A
predicate written as per_node[target]['cpu_available'] >= cpus
raises KeyError inside the wait loop. See D9.
5. retry_while_transient is a blind re-issue, and its module is
deliberately import-free. shakenfist/deploy/shakenfist_ci/retries.py
calls request() every 10 s until the status stops being transient.
Reusing it directly means issuing a create every 10 s, and every
refused create builds an instance object and then
enqueue_delete_due_errors it (shakenfist/external_api/instance.py:901-906)
-- so a 420 s wait would leave 42 error-deleted instances behind and
add its own load to the cluster it is waiting for. Worse, the
module's docstring records that it imports nothing from the suite or
from shakenfist_client so the unit tests can load it by path; a
poll of system_client.get_cluster_resources() cannot live in it.
See D11.
6. The bundle plumbing needs no change in the actions
repository. The master plan says this phase "carries the same
operator-push obligation the sizing plan's phase 1 did". Half of that
is true. /srv/ci/traces on the primary is created and chowned to
the base-image user by smoke-cluster.yml:212, and the entire
directory is scp'd into the 90-day artifact bundle by the "Gather
logs" step at :550. The functional suite runs on the primary as
that user. So a file the suite writes to /srv/ci/traces reaches the
bundle with no change to shakenfist/actions at all. Only printing
the summary in the job log needs one, because that happens in
tools/ci_headroom_collect.sh. See D14, which splits the phase so
the evidence does not depend on the cross-repo push.
7. Claims the survey confirmed. BaseTestCase
(base.py:119) has no create_instance(). _uniquifier() exists at
base.py:193. CLUSTER_HEADROOM_WAIT = 420 is at
test_namespace_claims.py:134, with the existing transient-retry
usage at :324. InsufficientResourcesException is exported by
shakenfist_client.apiclient and is what a 507 raises.
HARNESS_DRIVEN_PAIRS is at load_budget.py:309, and
test_the_suite_still_probes_cluster_headroom -- which holds its
prose up -- is at shakenfist/tests/test_database_tier_harness.py:374.
The ast precedent is shakenfist/tests/test_ci_claims_headroom.py
(:41, helpers at :171-190). test_system_namespace.py:9
subclasses base.BaseTestCase, creates inline at :27 and deletes
at :56 with no addCleanup. capacity_degraded is published on
total.
8. One ride-along is already done, and the other is
mis-described. The master plan asks for
test_commandline_artifacts.py's second instance to target
inst1['node'] "only where co-location is actually load-bearing" --
but :182-190 already pins it to inst1['node'], which is the shape
being asked for, and inst1 at :139 is not pinned at all. There is
nothing to change there. test_imagefetch.py has four pinned
creates, not one. In the first test (:130, :152) both are pinned
to n, the first hypervisor in the node list; the source URL is
deleted between them, so inst2 must land where inst1 did, but
inst1's pin to an arbitrary node is pure convenience. In the second
test (:222, :247) the first/second pins are load-bearing by
construction -- the comment says "on a node which has not seen this
image before" -- and must stay. Net: one pin is removable and one
becomes inst1['node']. See D17.
Corrections 1, 6 and 8 have been made at their source in the master plan's phase 2 section as part of the planning commit, so a later step does not need to redo them.
Decisions¶
D7 -- The wrapper covers every call site in shakenfist_ci¶
All 117, not the 88 in the two directories the master plan named.
smoke_ci_tests/ gets the wrapper for the same reason the others do,
and more so: it runs on the smallest cloud. base.py:1227 and
database_tier.py:275 are inside the harness rather than in a test,
but they are creates against the same cluster and a refusal fails
them the same way.
The two harness call sites route through the wrapper by calling it as
self.create_instance(...), which is available to them because both
are on classes descending from BaseTestCase. No call site outside a
BaseTestCase subclass exists today; the AST guard in D15 fails if
one appears.
D8 -- Wait on the ledger the guard reads, not the headroom published¶
The predicate for "there is now room for this create" is, per node:
available = per_node[n]['cpu_available']
if per_node[n]['cpu_limit'] is not None:
available = min(available,
per_node[n]['cpu_limit'] - per_node[n]['cpu_committed'])
and the create proceeds when available >= cpus for the pinned
target, or when any node satisfies it for an unpinned create --
max over nodes, never total['cpu_available'], per survey finding
3.
cpu_limit is None exactly when the node has no capacity row
(scheduler.py:1078), which is also when cpu_committed_row_present
is false. After phase 1 that state should last about a minute at
cluster start and never recur; treating it as "the published headroom
is all we know" is the right reading, and it is what the fallback to
cpu_available alone does.
Amended by the automated review of PR #4166: the predicate was right
about which ledger to read and wrong about how to find the row. The
per_node mapping is keyed by node UUID -- summarize_resources()
builds it from the metrics dict, which get_active_node_metrics() keys
by str(n.uuid) -- while the suite pins by node name almost everywhere
('sf-2', node['name'], socket.getfqdn()). The target was therefore
absent from the roster on every poll for a name-pinned create, which
best_node() reads as zero headroom in every dimension rather than as
"no entry", so the wait could never be satisfied however empty the
cluster was. One transient 507 on a name-pinned create became a
guaranteed 420 s failure whose message said there was room everywhere.
BaseTestCase._placement_roster_key() now resolves the pin to the
roster's key space before waiting; a pin that resolves to nothing waits
blind (mode: degraded) rather than reading an absent key as an empty
node.
This is the decision a reviewer is most likely to argue with, because it makes the wrapper wait longer than the master plan's predicate would in exactly the case where the two ledgers disagree. That is the point. Stopping early does not avoid the wait, it converts it into another refused create, another error-deleted instance, and a wait record that understates what the run actually spent. The cost of being wrong in this direction is bounded by the 420 s deadline; the cost of being wrong in the other direction is the measurement phase 5 reads.
Corrected in review (2026-09-11). D8 as written models one
dimension, and the automated reviewer was right that it is the wrong
one to model alone. The scheduler pre-filters a candidate on three --
_has_sufficient_cpu(), _has_sufficient_ram() and
_has_sufficient_disk() -- and turns any of their
LowResourceExceptions into the same 507. CI instances are 1 vCPU but
1-2 GB with 8 GB disks, so memory and disk bind far more often than
cpus do. A one-dimension predicate is therefore not a rougher version
of the right answer; it is permanently satisfied while the server is
permanently refusing, which is a different failure with a different
shape. The predicate now requires every dimension the create asks for
to fit on one node (retries.coverage_of()), which is the same
error as total['cpu_available'] one level down: an instance is placed
on one node and needs all three of its dimensions there.
Two per-instance ceilings join the ledgers above, for the same reason:
cpu_max_per_instance and ram_max_per_instance bound the largest
single instance a node will take whatever its aggregate headroom, so a
create larger than one of them is refused permanently. They differ in
how a zero reads -- a missing cpu_max_per_instance metric publishes
as 0, where ram_max_per_instance is legitimately 0 on a full node --
so the cpu one is read only when non-zero and the memory one whenever
present.
The wait record now names the binding_dimension it was short of at
the first poll, which is the question phase 5 actually asks of this
data: a duration with no cause attached to it cannot be acted on.
Also corrected in review: the predicate is not the only thing that
has to be right. A satisfied wait hands control straight back to a
caller which has just been refused, and wait_for_capacity() returns
after one poll and zero sleeps when the predicate is already met. If
the refusal is for anything the predicate cannot see -- a pre-filter it
does not model, the admission guard losing a race it cannot observe --
then satisfied is the permanent answer, and the wrapper re-issues as
fast as two HTTP round trips allow for the whole 420 s, leaving an
error-deleted instance, a trace line and an addDetail entry behind on
every turn. That is the risk section's "turns one 507 into six" with
three more orders of magnitude on it.
Widening the predicate shrinks that window but cannot close it, because
the whole point is that some refusals are invisible from
/admin/resources. So the loop is paced as well: the first wait after
a refusal may return immediately, because capacity returning between
the refusal and the poll is the common case and the reason this phase
exists, and every wait after that sleeps at least one poll interval
(minimum_sleep). MAX_CREATE_ATTEMPTS is a hard ceiling under the
derived bound, and the failure it raises says the refusal is for
something the predicate cannot see rather than blaming the cluster.
D9 -- A node missing from per_node is "not yet", not "never"¶
The wait loop treats an absent per_node[target] as zero available
and keeps waiting, rather than raising. Survey finding 4 gives the
three ways a node goes missing, two of which -- a queue over the
unreasonable length, and metrics not yet published -- are transient
and are precisely what the wrapper is waiting out. The third, a node
that is genuinely not a hypervisor, is a test bug, and the deadline
turns it into a failure with the per_node roster attached, which is
the diagnosis a reader needs.
The same treatment applies to a get_cluster_resources() call that
raises: log it into the wait record and keep waiting. The endpoint
being briefly unavailable is not evidence about capacity.
Amended by the automated review of PR #4166: survey finding 4's three
causes were not all of them, and the fourth was a bug in this decision
rather than in the code implementing it. /admin/resources keys
per_node by node uuid (summarize_resources() builds it from
get_active_node_metrics(), which keys by str(n.uuid)), while the
suite pins by name almost everywhere. So a name-pinned create missed on
every poll, and "absent means zero available, keep waiting" turned one
transient 507 into a guaranteed seven-minute failure whose message said
there was room everywhere -- strictly worse than the behaviour this
phase set out to fix. BaseTestCase._placement_roster_key() now
resolves a pin into the roster's key space before waiting. The rule
above still holds for a genuine absence; a pin which resolves to
nothing waits blind and records mode: degraded, because not knowing
which entry to read is not evidence that the entry is empty.
D10 -- A degraded capacity read falls back to a blind wait¶
When total['capacity_degraded'] is set, the capacity mapping the
whole predicate rests on could not be read, so cpu_limit is None
for every node for a reason that has nothing to do with capacity.
Waiting informed on that is waiting on noise. Fall back to sleeping
the poll interval and retrying the create at the deadline's cadence,
and record mode: degraded on the wait so a reader can tell a wait
that was informed from one that was not.
This is the same distinction phase 1 drew between an empty answer and an unknown one (D2, D3), for the same reason.
D11 -- The informed wait is a new function, with the poll injected¶
retries.py keeps its property of importing nothing from the suite.
The new wait_for_capacity(...) lives beside retry_while_transient
in the same module and takes the poll as a callable returning the
resources dict, plus clock and sleep as retry_while_transient
already does. The suite passes
self.system_client.get_cluster_resources; the unit tests pass a
fake returning a scripted sequence of dicts.
retry_while_transient is not reused as the outer loop. Its
contract is "re-issue the request until its status stops being
transient", and this loop's shape is "poll a different thing, then
re-issue once". Bending one into the other would leave a helper whose
docstring no longer describes either caller.
D12 -- Only an insufficient-resources refusal is waited on¶
The wrapper catches apiclient.InsufficientResourcesException and
nothing else. In particular it does not catch the 409 from
AffinityConstraintUnsatisfiable, which the API path deliberately
orders above the 507 clause because the exception is a subclass and
the ordering is what keeps them distinct
(external_api/instance.py:890-899). An affinity constraint that
cannot be satisfied does not become satisfiable by waiting, and a
wrapper that retried it for 420 s would convert a clear failure into
a slow one.
D13 -- Re-create under a fresh uniquified name¶
The refused instance is created and then enqueue_delete_due_errored
(external_api/instance.py:901-906), so its name is in use until the
delete completes asynchronously. Every retry appends
'-%s' % self._uniquifier() to the caller's name. The wrapper
returns the instance it finally created, so the caller never sees the
name it did not choose except in the failure message and the wait
record, both of which state it.
Amended by the automated review of PR #4166: D13 holds, but its stated
mechanism was incomplete and the consequence was not carried through to
the callers. Nothing refuses a duplicate instance name on create -- the
only name validation is the DNS hostname check at
external_api/instance.py:592-600. What actually keeps the name in use
is resolution: Instance.ACTIVE_STATES (instance.py:239-244) includes
the error states, so the refused instance still answers a name lookup
until its delete finishes, and reusing the name would make that lookup
ambiguous rather than refused. The consequence is that a retried create
does not carry the caller's name, which breaks any test that resolves an
instance by the literal it passed. test_object_names.py did, in two
different ways, and both are fixed: the first keys on the name the
instance actually got, and the two duplicate-name tests -- whose subject
is the name itself, and which would have asserted nothing against a
renamed instance -- take # raw-create: markers. The rename is now
stated in ci.md rather than only in the wrapper's docstring.
D14 -- The wait record lands in the bundle without a cross-repo push¶
Each wait appends one JSON object as a line to
/srv/ci/traces/instance-waits.jsonl on the machine running the
suite, opened 'a' per write. One line per wait, not per run, so
concurrent stestr workers do not have to agree on anything and a
crashed worker loses at most its own in-flight line. Per survey
finding 6 this reaches the artifact bundle through the existing
"Gather logs" scp with no change to shakenfist/actions.
tools/ci_headroom_report.py grows a --waits <file> argument that
prints total seconds waited, number of waits, the longest wait and
its test, and the split between informed and degraded waits. The
report already runs over a downloaded bundle -- that is how phase 1
verified its own definition-of-done item 9 -- so the evidence is
available from the moment this phase merges, whether or not the
job log ever prints it.
Printing it in the job log is a one-line addition to
ci_headroom_collect.sh in shakenfist/actions (scp the file, pass
the flag) and is step 2f. It is deliberately last and deliberately
not depended on by anything else in this phase. If the directory is
unwritable the wrapper still waits and the test still passes: nothing
about recording a wait may fail a test, for the same reason
ci_headroom_collect.sh's header gives for never failing a job.
D15 -- The AST guard names an allowlist marker¶
A unit test in shakenfist/tests/ walks every .py file under
shakenfist/deploy/shakenfist_ci/ and fails on any
*.create_instance( call whose line is not preceded by a
# raw-create: comment giving a reason. It follows
test_ci_claims_headroom.py's pattern of parsing the suite's source
rather than importing it, because the suite imports
shakenfist_client, which is not a test dependency of this
repository.
The marker exists for callers that must see the refusal: the sizing
plan's phase 3 saturation tests, and the wrapper's own call inside
base.py. The test asserts the marker's reason is non-empty, so
the allowlist cannot grow by copy-paste.
D16 -- The load-budget prose names the wrapper as a second producer¶
GetNodeMetrics/api is already in HARNESS_DRIVEN_PAIRS
(load_budget.py:309), but the prose there names the headroom probe
as the producer, and
test_the_suite_still_probes_cluster_headroom
(test_database_tier_harness.py:374) holds that prose up. Extend the
comment to name the wrapper as a second, activity-coupled producer --
it polls only while a create is being retried, so it adds nothing to
an idle cluster and adds load in proportion to how full the cluster
already is.
Not a new budget entry: the pair is already exempt, and adding a second entry for the same pair would make the exemption look like two separate allowances.
D17 -- Only the ride-alongs the survey confirmed¶
test_imagefetch.py's first test drops inst1's pin and changes
inst2's to inst1['node'], per survey finding 8. Its second test
is left alone. test_commandline_artifacts.py is left alone: it
already has the shape the master plan asked for.
test_system_namespace.py gains an addCleanup for its inline
instance, so a failure between :27 and :56 no longer strands a
charged instance in the system namespace for the rest of the run.
Nothing else. A phase which is about measuring how long the suite waits should not quietly change how much capacity the suite uses beyond what it can justify, or the numbers it reports are about a different suite than the one that produced the baseline.
Step plan¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 2a | high | opus | none | Add wait_for_capacity() to shakenfist/deploy/shakenfist_ci/retries.py beside retry_while_transient, and create_instance() to BaseTestCase in shakenfist/deploy/shakenfist_ci/base.py:119. Read retries.py's docstring first: it imports nothing from the suite or from shakenfist_client so shakenfist/tests/test_ci_claims_headroom.py can load it by path, and that must survive -- take the resources poll as a callable, with clock and sleep injected exactly as retry_while_transient does (D11). Implement the predicate in D8 literally: per node, available = per_node[n]['cpu_available'], and where per_node[n]['cpu_limit'] is not None, available = min(available, cpu_limit - cpu_committed); proceed when the pinned target satisfies available >= cpus, or when any node does for an unpinned create. Do not use total['cpu_available']: it is a sum across nodes (shakenfist/scheduler.py:1091-1093) and an instance must fit on one. An absent per_node[target] is zero available and keeps waiting, never a KeyError (D9); a poll that raises is logged into the record and the wait continues. When total['capacity_degraded'] is set, sleep the interval instead of evaluating the predicate and mark the wait mode: degraded (D10). The wrapper catches only apiclient.InsufficientResourcesException -- not the 409 from AffinityConstraintUnsatisfiable, which is a subclass of LowResourceException on the server and is kept distinct only by clause ordering at shakenfist/external_api/instance.py:890-899 (D12). Deadline 420 s, the CLUSTER_HEADROOM_WAIT the claims suite already uses (cluster_ci_tests/test_namespace_claims.py:134); poll interval 10 s. Each retry uses a fresh name suffixed with self._uniquifier() (base.py:193), because the refused instance is created and then enqueue_delete_due_errored and its name is in use until that completes (D13). On exhausting the deadline, fail with the refusal body, the node waited for, and the per_node roster in the message. Attach every wait to the test as a detail: seconds waited, node, mode, and the headroom at first refusal and at admission. Unit tests in shakenfist/tests/ against a fake clock and a scripted poll: an immediate success, a wait that ends informed, a wait that ends at the deadline, a degraded wait, a target absent from per_node that later appears, a node whose cpu_available is generous while cpu_limit - cpu_committed is not (the D8 case -- assert it does not proceed, and confirm that deleting the cpu_limit clause makes this test fail), and a 409 passing straight through. Single quotes, 120 columns, no trailing whitespace. |
| 2b | medium | sonnet | none | Route every call site through the wrapper. There are 117 across 41 files in shakenfist/deploy/shakenfist_ci/ -- grep -rn '\.create_instance(' shakenfist/deploy/shakenfist_ci/ is the list, and note it includes smoke_ci_tests/ (27 in 7 files), base.py:1227 and database_tier.py:275, which the master plan's section omitted (D7). Each becomes self.create_instance(...) with the same arguments. Then add the AST guard per D15 as a new test in shakenfist/tests/: parse every .py under shakenfist/deploy/shakenfist_ci/ with ast -- do not import the package, it imports shakenfist_client, which is not a test dependency here -- and fail on any create_instance attribute call not preceded by a # raw-create: <reason> comment with a non-empty reason. Follow the file-walking and call-finding helpers in shakenfist/tests/test_ci_claims_headroom.py:171-190 rather than inventing new ones. The wrapper's own call inside base.py carries the marker. Run the full unit suite; it takes about ten minutes. |
| 2c | medium | sonnet | none | The record and the summary, per D14. The wrapper appends one JSON object per wait as a line to /srv/ci/traces/instance-waits.jsonl, opened 'a' per write, with keys: test id, instance name requested, node waited for or null, cpus, seconds waited, mode (informed/degraded), attempts, and the headroom at first refusal and at admission. Every failure to write is swallowed -- an instrument may never fail the thing it measures, exactly as ci_headroom_collect.sh's header argues. The directory already exists and is writable by the suite's user: smoke-cluster.yml:212 in shakenfist/actions creates and chowns it, and :550 scp's the whole directory into the bundle, so this needs no change there. Then add --waits <file> to tools/ci_headroom_report.py, printing total seconds waited, number of waits, longest wait and its test, and the informed/degraded split; absent or empty file prints one line saying so, never zero. Extend shakenfist/tests/test_ci_headroom_report.py with a file of several waits, an empty file, an absent file and a file with a malformed line among good ones (which is skipped, counted and reported, not fatal). Finally D16: extend the prose above HARNESS_DRIVEN_PAIRS at shakenfist/deploy/shakenfist_ci/load_budget.py:309 to name the wrapper as a second activity-coupled producer of GetNodeMetrics/api, and check test_the_suite_still_probes_cluster_headroom (shakenfist/tests/test_database_tier_harness.py:374) still passes -- it holds that prose up. No new budget entry. |
| 2d | low | haiku | none | The two ride-alongs D17 allows, and no others. In shakenfist/deploy/shakenfist_ci/cluster_ci_tests/test_imagefetch.py, the first test pins both creates to n, an arbitrary first hypervisor (:130-138, :152-160): remove inst1's force_placement entirely and change inst2's to inst1['node'], which is what the test actually needs since the source URL is deleted between them. Leave the second test's first/second pins at :222 and :247 exactly as they are -- its comment says the second instance must land on a node which has not seen the image, so those pins are the assertion. In cluster_ci_tests/test_system_namespace.py, the instance created inline at :27 is deleted at :56; add an addCleanup immediately after the create so a failure between the two does not strand a charged instance in the system namespace. Do not touch test_commandline_artifacts.py: :182-190 already pins to inst1['node'], which is the shape the master plan asked for. |
| 2e | medium | sonnet | none | Documentation. docs/developer_guide/ is the home for how to write a functional test; find where it describes creating instances in the suite and say that self.create_instance() is the call, that a raw client call needs a # raw-create: marker and why, and what the 420 s deadline means for a test that is genuinely asking for more than the cloud has. Document the wait summary beside the headroom report's own documentation, including that it can be read from a downloaded bundle with tools/ci_headroom_report.py --waits. Do not touch AGENTS.md unless the # raw-create: marker counts as a convention an agent could not infer -- it does, so add one line there and no more. ARCHITECTURE.md does not change: no component boundary moves. |
| 2f | low | haiku | none | The operator push, and the only step outside this repository. In shakenfist/actions, add to tools/ci_headroom_collect.sh an scp of /srv/ci/traces/instance-waits.jsonl beside the two it already fetches, and pass --waits to the report when the file is non-empty -- guarded by the same grep -q -- '--waits' "${report}" pattern the script already uses for --census-limit, because the report comes from the triggering component ref and may predate the flag. Nothing here may fail the job. This step depends on 2c having merged and nothing depends on it: the waits reach the bundle without it. |
| 2g | low | haiku | none | Set the phase 2 row to Complete in the master plan's Execution table and in docs/plans/index.md, update the index arithmetic, then run python3 tools/check-plan-status.py. Only after 2a-2e are reviewed and merged, 2f is pushed or explicitly deferred, and a real CI run's bundle has been read with tools/ci_headroom_report.py --waits to confirm the summary is populated. Record that reading in an Outcome section, as phase 1 did. |
The survey corrections that would otherwise have been a step here were made in the planning commit, per the note at the end of What the survey found.
Risks and mitigations¶
-
The wrapper waits on the wrong quantity and stops early. The headline risk, and the reason for the high planning effort. It fails silently: the create is retried, refuses again, and the run looks like the status quo with extra latency. Mitigation: D8 writes the predicate against the ledger the guard reads, and 2a requires a unit test for exactly the disagreement case plus a confirmation that deleting the
cpu_limitclause makes that test fail. Mutation-checking an assertion that claims to prove a fix is the standing lesson from a previous phase where five of seven definition-of-done checks turned out not to test what they said. -
The wrapper hides a real capacity regression. If the cloud is genuinely too small, waiting turns a fast, loud failure into a slow one, and the suite stops being a signal about sizing. Mitigation: the wait summary is the signal instead, and it is strictly more informative than the failure was -- but only if someone reads it. The sizing plan's phase 5 guardrail is the intended reader, and D14 makes the data available from the bundle whether or not the job log prints it. The 420 s deadline bounds the hiding.
-
117 mechanical edits break a test in a way unit tests do not see. The suite is functional; nothing in
shakenfist/tests/executes these call sites. Mitigation: the edits are uniform and the AST guard proves completeness rather than correctness; the real check is a full CI run before 2g, which is why 2g requires one. -
The wait record fails the tests it is measuring. A full disk, an unwritable directory, a worker crash mid-line. Mitigation: every write is swallowed, one line per wait so a partial line costs one record, and the report skips and counts malformed lines rather than failing on them (2c).
-
The cross-repo step never happens.
shakenfist/actionsneeds an operator push, which has stalled phases before. Mitigation: D14 restructures the phase so nothing depends on it. 2f is the last step and 2g accepts an explicit deferral.
Definition of done¶
Falsifiable, in order:
BaseTestCase.create_instance()exists and every one of the 117create_instancecall sites undershakenfist/deploy/shakenfist_ci/either goes through it or carries a# raw-create:marker with a non-empty reason.grep -rn '\.create_instance(' shakenfist/deploy/shakenfist_ci/and the AST test agree on the count.- The AST test fails when a raw call is added without a marker, and fails when a marker is added with an empty reason. Both halves confirmed by running them, not by reading them.
- A unit test asserts that a node publishing generous
cpu_availablebut a bindingcpu_limit - cpu_committeddoes not satisfy the wait predicate, and deleting thecpu_limitclause fromwait_for_capacity()makes that test fail.
Amended at closeout: met, by
test_a_binding_limit_beats_generous_published_headroom. But read
this item as narrower than what shipped. The review of #4166 found
the cpu-only predicate was not a rough version of the right answer
but a permanently-satisfied one, since the scheduler pre-filters on
memory and disk too and turns all three into the same 507 -- so the
ledger clause is now applied per dimension, and
test_a_node_must_cover_every_dimension_at_once and the per-instance
ceiling tests carry the weight this item was written to carry.
4. A unit test asserts that a target absent from per_node
continues the wait rather than raising, and that it proceeds when
the target appears.
5. A unit test asserts that total['capacity_degraded'] produces a
blind wait recorded as mode: degraded, and that an unpinned
create never consults total['cpu_available'].
Amended at closeout: met, by
test_a_degraded_read_waits_blind_and_admits_to_it and
test_an_unpinned_create_reads_nodes_and_never_the_cluster_total.
mode: degraded acquired a second producer in review -- a pin which
does not resolve to a roster key -- for the reason under D9 below.
6. A unit test asserts a 409 affinity refusal is not retried.
7. retries.py still imports nothing from the suite or from
shakenfist_client, and the tests which load it by path still
pass.
8. tools/ci_headroom_report.py --waits prints the five figures in
D14 for a populated file, says so for an empty or absent one, and
counts rather than dies on a malformed line. Unit tests for all
four.
9. A real CI bundle read with --waits shows a populated summary, or
shows zero waits with the run's own evidence that no create was
refused. Read from a downloaded artifact, as phase 1's item 9
ultimately was -- this is checkable from a worktree and is not to
be written off as operator-only.
10. test_imagefetch.py's first test has one pin, not two, and its
second test still has both. test_system_namespace.py's inline
instance has an addCleanup. test_commandline_artifacts.py is
unchanged.
11. The prose above HARNESS_DRIVEN_PAIRS names the wrapper, and
test_the_suite_still_probes_cluster_headroom passes.
12. No document states the wait deadline as a number different from
CLUSTER_HEADROOM_WAIT, and none implies the wrapper makes
refusals rarer.
13. python3 tools/check-plan-status.py passes and
pre-commit run --all-files passes.
What later phases inherit¶
- A suite that survives a transient refusal, so a run's pass/fail stops being a coin flip on sibling timing and phase 5's data is not truncated by the runs that failed.
- A per-wait record in every bundle, which is the input phase 5 decides the server-side-queue question from, and which the sizing plan's phase 5 guardrail can read.
wait_for_capacity()with an injected poll, which phase 4's client-side retry should match the semantics of rather than reinvent -- the master plan's ordering puts phase 4 after this one for exactly that reason.- A
# raw-create:convention the sizing plan's phase 3 saturation tests need in order to assert a refusal.
Back brief¶
Before implementation begins, the implementer confirms in writing:
- Which of D8's two halves they think is most likely to be wrong --
the
cpu_limitclause or the per-nodemax-- and what they would need to see to change it. This is the decision the phase rests on and the one a reviewer should push back on. - That they have read
retries.py's docstring and understand whywait_for_capacity()cannot import the client. - The exact call-site count they measured, before editing. If it is not 117 across 41 files, the tree has moved and the survey needs re-running before 2b starts.
Gate: step 2b touches 41 files mechanically and is expensive to
redo. It does not start until 2a has merged, or at minimum until
create_instance()'s signature is agreed -- a signature change after
2b means editing 117 call sites twice.
Outcome¶
Complete. Steps 2a-2e merged as #4166; step 2f is prepared and awaiting an operator push, and this section is 2g.
The measurement (definition of done item 9)¶
Three real waits, read from downloaded merge-run bundles with
tools/ci_headroom_report.py --waits. Both runs carry the phase 2
suite; both debian-12-slim-tier:
| Run | Waits | Total | Longest | Test |
|---|---|---|---|---|
| 34663526177 | 1 | 190.5 s | 190.5 s | test_imagefetch.TestHTTPFetch.test_vanished_source_server_instance |
| 34681505274 | 2 | 301.0 s | 160.4 s | test_network_lifecycle.TestNetworkPlumbingLifecycle.test_network_plumbing_lifecycle |
All three are mode: informed -- the predicate, not the blind
fallback, ended every one of them -- and all three record
binding_dimension: cpus, which is the dimension the master plan's
journal reading predicted and the one the original cpu-only predicate
would also have caught. Each was a test failure before this phase.
Three things in that data are worth carrying forward rather than leaving in a table:
- The waits are long. 140-190 s each, against a 420 s deadline. These are not "the poll caught it on the second tick"; they are a third to a half of the budget. Phase 5 should read that as the central number rather than the totals, because a deadline reached is a failed run and the margin here is smaller than the totals suggest.
roster_keyearned its place immediately. The second run's first wait recordsnodeas the namesf1androster_keyas that node's uuid -- a name pin resolved into the uuid-keyed roster. (The uuid itself is not quoted here: a bare uuid after akey:reads as a high-entropy secret to the credential scan, and the reading costs nothing to avoid.) That is exactly the case the second review round fixed (see the amendment under D9); without it this wait could not have been satisfied at all and would have become a seven-minute failure.- The
slim-primarybundles of both runs have no waits file at all. That is the wrapper behaving correctly -- the file is created on first write -- and the report reads it honestly as unknown rather than as zero. See the finding below.
The two runs' D3 band verdicts (0.500 WITHIN BAND, 0.833
OVERSUBSCRIBED) are recorded here only because the same command
prints them. They belong to the sizing plan, and nothing here reads
them.
A defect this phase's own guard found on develop¶
The AST guard added by step 2b was failing on develop when this
closeout began, and had blocked the merge queue since 04:47 on
2026-09-12.
#4170 (the
sizing plan's phase 3a) landed
cluster_ci_tests/test_saturation.py with four raw
create_instance calls and no markers, about five hours after #4166
added the guard. Neither pull request was wrong and neither could see
the other: #4170's branch predates the guard, and the guard cannot
see a file which does not exist yet. The merge queue did not catch
the pair either, and at least two subsequent merge_group runs
(34681505274,
34685164595)
failed Sanity checks on
test_ci_raw_creates.RawCreateInstanceMarkerTestCase.test_no_unmarked_raw_create_calls_in_the_suite
as a result.
Fixed here, as markers rather than as routing, which is what this
phase's scope already said those tests would need: "the sizing plan's
phase 3 saturation tests must call the raw client and assert the
refusal; the allowlist marker exists for them." All four sites must
see the raw refusal -- three are the assertRaises in the impossible
-request tests, and the fourth is _create_fill_instance(), whose
two callers each handle the InsufficientResourcesException
themselves. test_saturation.py:1137 already carried a comment
saying so in as many words: "Deliberately not wrapped in
retries.retry_while_transient(): a refusal under assertion must
never be retried away."
This is the collision the guard exists to make loud, and it made it loud on the first try. It is recorded as an outcome rather than as a bug in either plan.
Amended 2026-09-13, at the sizing plan's phase 3 close-out. The
fix landed as
#4186 at 15:31
on 2026-09-12, and run
34698074952
shows Sanity checks green again. The count above is low: because
Sanity checks is not a required status check, the queue went on
merging over the breakage, and f654d4a05, f8c801ebe and
6435e3fde all failed the same test with two of them merging anyway.
One run in between -- a documentation-only sync -- skipped Sanity
checks on the changed-paths filter rather than passing it, so for a
while a green tick sat on a red tree. Making the check required would
have stopped the follow-on batches but not the original pair, whose
two halves each passed against their own base; that is a repository
configuration question and is recorded, not decided, in both plans.
Step 2f¶
Prepared on the capacity-wait-summary branch of
shakenfist/actions, not yet pushed: tools/ci_headroom_collect.sh
scp's /srv/ci/traces/instance-waits.jsonl beside the series and the
census, and passes --waits when the report understands the flag and
the file is non-empty, with an else branch saying what an absent
file does and does not mean. pre-commit run --all-files passes
there.
Nothing depends on it, exactly as D14 arranged: the measurement above was read from bundles produced without it. All it buys is the summary being printed in the job log rather than only being readable by downloading the artifact -- which is worth having, but was never allowed to gate this phase.
A finding for phase 5¶
An absent waits file and a run with no waits are the same
artifact. The wrapper creates the file on its first write, so a run
which was never refused leaves nothing behind, and
tools/ci_headroom_report.py --waits correctly declines to read that
as zero. A human with the run in front of them can tell the two
apart; a harvest over twenty bundles cannot, and phase 5's denominator
is exactly that harvest.
Not fixed here -- it is a change to the wrapper, and this phase is closed. The cheap fix is for the suite to write an empty file once at start-up, which turns "absent" back into "the suite predates this or its writes failed" and lets "empty" mean zero. Recorded in the master plan's Future work.
Deviations from this plan¶
Three, all recorded above or at their source rather than only here:
- D8's predicate widened from cpus to every dimension, plus the two per-instance ceilings, and the loop gained pacing and an attempt ceiling. From the automated review of #4166; see the amendment under D8 and the note added to definition-of-done item 3. The original predicate was not a rough version of the right answer but a permanently-satisfied one.
- D9 acquired a fourth cause of an absent roster entry, which was
a bug in the decision rather than in its implementation:
/admin/resourceskeysper_nodeby uuid and the suite pins by name. See the amendment under D9. - The saturation-test markers above, which are new work this phase did not plan for because the file did not exist when it was planned.
Definition-of-done items 3, 5 and 9 carry closeout amendments saying what was actually checked where it differs from what was written.