fix: a sweep honours --ttl/--idle-timeout/--cost-limit instead of logging them (#525) - #529
Merged
Merged
Conversation
This was referenced Aug 19, 2026
Closed
scttfrdmn
force-pushed
the
fix/524-estimate-only-sweep
branch
from
August 19, 2026 06:36
da8e0dd to
4c6e603
Compare
scttfrdmn
force-pushed
the
fix/525-sweep-drops-cli-spend-controls
branch
from
August 19, 2026 06:37
7f13678 to
cfbabe3
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…ging them (#525) buildLaunchConfigFromParams "start[s] with an empty config" and the sweep dispatch copied only Region/InstanceType/Name off the base config, so no CLI spend control reached a single row. The --no-detach branch then printed "Using safeguards: ttl=4h, idle-timeout=" from the very variables it was about to throw away, which is worse than dropping them quietly: it affirmatively tells the operator a cap is active at the instant it is discarded. cost_limit: in a param file did not work either -- there was no parser case, so it fell through to the unknown-key arm and became a PARAM_cost_limit env var that capped nothing. With both routes broken a sweep had NO per-instance dollar cap at all, and its only bound was the zombie guard's unrelated 1h *idle* timeout: never fires on a compute-bound row, yet still shows up in `spawn list` and reads as bounded. The flags are now applied as sweep defaults:, which is the one place that reaches both orchestration paths -- the foreground path merges defaults into every row, and the detached path uploads them to S3 for the Lambda orchestrator, which does the same. (That orchestrator is not in this repo, so its half is not verifiable here; the seam is the shared defaults map.) cost_limit: is now a real parser case that ERRORS on a value it cannot read instead of leaving the cap at 0, since a silently zeroed cost limit means "no cap" -- the opposite of what was written. Precedence is most-specific-wins: a row's own params: > the CLI flag > the file's defaults:. The row level keeps a GPU row on a shorter leash than the rest of the sweep; the CLI level makes `--ttl 30m` on a file that says `ttl: 8h` do what it looks like it does. Two knock-on fixes in the same path: * --estimate-only now prices the TTL you passed. The estimator reads the per-row ttl and defaults to 1h, so `--ttl 4h` on a file with no ttl: was previously quoted at a quarter of its worst case. * the --no-detach guard checks the merged PER-ROW bound instead of the CLI variable. A ttl: in the param file used to be refused by that guard even though it was the value that actually reached the instances, so the only way through was to pass --ttl to satisfy the check and have it discarded -- one flag to pass the guard, another to take effect, with nothing saying so. The per-row form also catches a file that bounds only SOME of its rows (invisible to the old check, since one --ttl satisfied it and then reached nothing) and names the offending rows, because "some row is unbounded" is not actionable on a 30-row sweep. Tests assert on the EC2 tags spored and the reaper actually enforce, not on any in-process value: a test that checked "the flag was parsed" would have passed for the whole life of the bug, because the flag WAS parsed -- onto a config nobody copied from.
scttfrdmn
force-pushed
the
fix/525-sweep-drops-cli-spend-controls
branch
from
August 19, 2026 06:58
cfbabe3 to
d53dbce
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #525.
Stacked on #528 (
fix/524-estimate-only-sweep). Review that one first; this branch's diff againstmainwill include it until #528 merges.The bug
buildLaunchConfigFromParamsstarts from an emptyaws.LaunchConfig, and the sweep dispatch copies onlyRegion/InstanceType/Nameoff the base config. So--ttl,--idle-timeoutand--cost-limitwere parsed onto a config nothing ever read: every row launched with no TTL and no cost limit.The param-file route did not work either —
cost_limit:had no case in thebuildLaunchConfigFromParamsswitch, so it fell through to the unknown-key arm and became aPARAM_cost_limitenv var and aspawn:param:cost_limittag. A spend control that parsed, launched, tagged, and capped nothing.Both routes failed, which left a sweep with no per-instance dollar cap at all.
The worst part is not the drop, it is the log line. Pre-fix stderr on
--ttl 1h --cost-limit 8:It affirmatively reports the safeguard as active at the instant it is discarded, and then substitutes an idle timeout in its place — which never fires on a compute-bound row while still displaying as a bound. A user reading that output has every reason to believe the sweep is capped.
The fix
Defaultsis the one seam both orchestration paths read: the foreground path merges it per row, and the detached path uploads it to S3 for the Lambda orchestrator. So the CLI flags are folded intoparamFormat.Defaultsright after the param file is parsed, before anything else looks at it:Precedence is most-specific-wins: a row's own
params:> the CLI flag > the file'sdefaults:. That is what lets a matrix put one expensive GPU row on a shorter leash than the sweep it belongs to.cost_limit:now has a real parser case (parseCostLimit), accepting every numeric shape YAML/JSON/CSV can produce —8,8.50,"8.50"— and erroring on anything else rather than leaving the field at 0. Unlike the string cases around it, a value it cannot read fails the launch: a mistypedttlcosts you the difference between two timeouts, a mistyped cost limit silently removes the only per-instance dollar cap on the path. Disabling the cap has to be spelledcost_limit: 0.Two knock-on fixes fall out of injecting before the other consumers of
Defaults:--estimate-onlynow prices the TTL you passed.EstimateSweepCostderives per-row hours from rowttl→defaults["ttl"]→ a"1h"fallback, so--ttl 4hon a sweep was previously estimated at 1h — a 4× understatement on the command whose entire job is to tell you the number before you spend it.--no-detachzombie guard is per row. It used to read the CLIttlvariable, so a param file carrying its ownttl:was refused even though that value is the one that actually reached the instances — the only way through was to pass--ttlto satisfy the guard and have its value discarded. One flag to pass the check, another to take effect: the mirror image of this bug. The guard now checks each row's merged bound and names the unbounded rows, because "some row is unbounded" is not actionable on a 30-row sweep.The safeguards log now prints the merged sweep defaults and says where they came from, instead of echoing variables it is about to drop.
Tests
Regression coverage is on EC2 tags, not on any in-process value, because the tags are what
sporedand the out-of-band reaper actually enforce. A test asserting "the flag was parsed" would have passed throughout the bug's life — the flag was parsed.test/e2e/tier0_sweep_spend_controls_test.go(Tier 0, Substrate, no AWS account, zero spend), 7 tests:SweepAppliesCLISpendControlsspawn:ttl=1h+spawn:cost-limit=3.0000on every row; no idle timeout substituted behind an explicit--ttlSweepRowTTLBeatsCLIttl:outranks--ttlSweepCLIBeatsFileDefaults--ttloutranks the file'sdefaults:SweepParamFileCostLimitcost_limit:alone works, and no longer leaks tospawn:param:cost_limitSweepFileTTLSatisfiesNoDetachSweepNoDetachNamesUnboundedRowsc5.xlarge, does not name the bounded rowSweepRejectsUnusableCostLimitcost_limit: eight dollarsfails the launch, message namescost_limit, nothing launchedPlus unit tests in
cmd/sweep_spend_controls_test.gofor the precedence table,sweepRowsWithoutBound, andparseCostLimit.All 7 fail against the pre-fix tree (verified in a worktree at
da8e0dd) and pass here. The last one is worth calling out: as first written it passed pre-fix, because the old CLI---ttlguard rejected the file before anything looked atcost_limit, so the non-zero exit proved nothing. It now passes--ttlon the command line and asserts the message namescost_limit. A check that cannot fail is worse than no check.Caveat, stated rather than implied
All the e2e tests take the foreground path (
--no-detach), which is the one that provisions in-process and is therefore the one Tier 0 can observe end to end. The detached path is covered at the same seam by construction — both paths read the samedefaults:map — but the sweep-orchestrator Lambda does not live in this repo, so its half of the fix is not verifiable here. If it readsttl/cost_limitout of the uploaded defaults it now gets them; that needs confirming wherever that function is deployed.Cross-refs: #524 / #528 (same launch path, same "the flag lies" shape), #526 (unknown param-file keys become
PARAM_*silently — the general form of thecost_limit:failure), #527.