Skip to content

fix: a sweep honours --ttl/--idle-timeout/--cost-limit instead of logging them (#525) - #529

Merged
scttfrdmn merged 1 commit into
mainfrom
fix/525-sweep-drops-cli-spend-controls
Aug 19, 2026
Merged

fix: a sweep honours --ttl/--idle-timeout/--cost-limit instead of logging them (#525)#529
scttfrdmn merged 1 commit into
mainfrom
fix/525-sweep-drops-cli-spend-controls

Conversation

@scttfrdmn

Copy link
Copy Markdown
Contributor

Closes #525.

Stacked on #528 (fix/524-estimate-only-sweep). Review that one first; this branch's diff against main will include it until #528 merges.

The bug

buildLaunchConfigFromParams starts from an empty aws.LaunchConfig, and the sweep dispatch copies only Region/InstanceType/Name off the base config. So --ttl, --idle-timeout and --cost-limit were 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 the buildLaunchConfigFromParams switch, so it fell through to the unknown-key arm and became a PARAM_cost_limit env var and a spawn:param:cost_limit tag. 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:

⚠️  WARNING: --no-detach specified
   Using safeguards: ttl=1h, idle-timeout=

⚠️  Auto-setting --idle-timeout=1h for all sweep instances

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

Defaults is 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 into paramFormat.Defaults right after the param file is parsed, before anything else looks at it:

appliedControls := applyCLISpendControlsToSweep(paramFormat)

Precedence is most-specific-wins: a row's own params: > the CLI flag > the file's defaults:. 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 mistyped ttl costs 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 spelled cost_limit: 0.

Two knock-on fixes fall out of injecting before the other consumers of Defaults:

  1. --estimate-only now prices the TTL you passed. EstimateSweepCost derives per-row hours from row ttldefaults["ttl"] → a "1h" fallback, so --ttl 4h on 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.
  2. The --no-detach zombie guard is per row. It used to read the CLI ttl variable, so a param file carrying its own ttl: was refused even though that value is the one that actually reached the instances — the only way through was to pass --ttl to 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 spored and 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:

test pins
SweepAppliesCLISpendControls spawn:ttl=1h + spawn:cost-limit=3.0000 on every row; no idle timeout substituted behind an explicit --ttl
SweepRowTTLBeatsCLI row ttl: outranks --ttl
SweepCLIBeatsFileDefaults --ttl outranks the file's defaults:
SweepParamFileCostLimit cost_limit: alone works, and no longer leaks to spawn:param:cost_limit
SweepFileTTLSatisfiesNoDetach a file-provided bound satisfies the guard, and reaches the instance
SweepNoDetachNamesUnboundedRows partly-bounded file refused, refusal names c5.xlarge, does not name the bounded row
SweepRejectsUnusableCostLimit cost_limit: eight dollars fails the launch, message names cost_limit, nothing launched

Plus unit tests in cmd/sweep_spend_controls_test.go for the precedence table, sweepRowsWithoutBound, and parseCostLimit.

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---ttl guard rejected the file before anything looked at cost_limit, so the non-zero exit proved nothing. It now passes --ttl on the command line and asserts the message names cost_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 same defaults: map — but the sweep-orchestrator Lambda does not live in this repo, so its half of the fix is not verifiable here. If it reads ttl/cost_limit out 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 the cost_limit: failure), #527.

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.08108% with 14 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cmd/launch_sweep.go 72.09% 12 Missing ⚠️
cmd/sweep.go 93.54% 1 Missing and 1 partial ⚠️

📢 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
scttfrdmn force-pushed the fix/525-sweep-drops-cli-spend-controls branch from cfbabe3 to d53dbce Compare August 19, 2026 06:58
@scttfrdmn
scttfrdmn merged commit 3a31007 into main Aug 19, 2026
7 checks passed
@scttfrdmn
scttfrdmn deleted the fix/525-sweep-drops-cli-spend-controls branch August 19, 2026 07:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working spend-safety Bugs where a cost control does not do what it says — real-money risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sweep dispatch silently drops --cost-limit and --ttl, then logs "Using safeguards: ttl=..."

1 participant