feat(worker): daily spend bound, idle deallocate, and cooldown stamp (C3) - #270
Merged
Conversation
…in the controller
…teer recency, and record override use post-admission
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.
Requirement
docs/azure-requirements.md C3 ("Cost guard"): a day's spend cannot quietly reach 100 dollars. Three parts: a daily spend bound with an explicit operator override, an idle-release path for an assigned worker whose task ended (the live example: wkr-04 idled ~4h with no release proof until its TTL fired), and the R10 metering/standing-cost booking note.
What this builds
1. Daily spend bound (create and resume only)
FM_AZURE_WORKER_DAILY_BOUND_USD, default 100 per the requirement sentence. Unset means 100; an explicit zero, negative, or non-numeric value refuses loudly atenvironment()instead of meaning "no bound".daily_cost_baselinerecord{utc_day, actual_usd_at_day_start}is snapshotted under the existing fleet lock on the first observation of each new UTC day (planner entry, live status, resume), and day spend = current actual - baseline, clamped at zero. The UTC month boundary is also a UTC day boundary, so the month-to-date reset always coincides with a baseline roll.createbranch returns an admission-refused action with the exact string naming the day, spend, and bound, andcommand_resumeraises the same string before touching any worker field. An unreadable actual fails the bound closed.next_reconcile_action, and release, surrender, withdraw, and the claim-exempt message lane never route through the check.executeon an already-assigned worker also stays allowed: that capacity is already held and billing, so refusing its work burns the same money for nothing. Both choices are documented indocs/azure-workers.md.FM_AZURE_WORKER_DAILY_BOUND_OVERRIDE=<utc-day>admits past the bound only when it names the exact current UTC day. Its use is printed loudly (reconcile text output, resume stdout), carried on the admitted action itself (daily_bound_overridefield, visible inpublic_action/JSON output), and recorded durably indaily_bound_override_used, which status prints asDAILY BOUND OVERRIDE USED.2. Idle deallocate (not release)
FM_AZURE_WORKER_IDLE_RELEASE_SECONDS, default 14400 (the wkr-04 four hours), floor 600, cap 604800.assignedwith the VM still running, queue item stillassigned, nopending_actionsclaim on the slot (the planner already skips claimed slots, and an in-flight execute always holds a claim), andlast_execution_at(stamped only when a durably applied execution result exists) older than the threshold.idle_deallocated_atdurably, and status output LOUDLY lists every idle-deallocated worker (IDLE-DEALLOCATED WORKER: ... RELEASE IT PROPERLY) plus aidle_deallocated_workersarray in JSON, until the ordinary release/reset completes.3. Cooldown-stamp fix (adjacent known gap)
A release-proved worker whose VM was already dark but whose
cooldown_started_atwas null waited forever: only the controller's own deallocate apply stamps the field, and surrender's dark-compute gate requires an operator-side deallocate that never does. The planner previously computedstarted = nowon every pass, so the cooldown clock restarted forever and delete-compute never became due (live: slot 1, d2-probe-20260819). The planner now stamps the field durably under the lock on first observation of that exact shape, so the clock starts and delete-compute becomes due aftercooldown_seconds.4. Docs and C3 status
docs/azure-workers.mdgains the daily bound, override, ACM-lag, and idle-deallocate contract sentences in the admission, cooldown, and operator-override sections; the wrapper header lists the three new env vars.docs/azure-requirements.mdC3 moves to BUILT (live acceptance pending) with the honest ACM-lag caveat and the R10 booking note: the Slack listener runs on the operator mac in v1 (standing Azure cost ~0) and its per-submitter daily ledger lives under$FM_HOME/state/crosscheck-slack.C2 machinery untouched
No change to
pending_actionsclaims,FencedState, the load fence, revision CAS,slot_lease,provider_mutate,claim_pending/apply_pending/drain_pending, orassert_scoped. The new planner writes (baseline roll, cooldown stamp) happen under the fleet lock in the plan phase, before any claim, exactly like the existing classification refreshes; the one new apply-phase write (idle_deallocated_at) is inside the worker's own compartment.Tests
Three new units in
tests/fm-worker-lifecycle.test.sh(suite: 22 -> 25 executed units, all green, exit 0):daily_bound_and_idle_matrix(direct module): env parsing (unset default 100; "0"/"-5"/"abc"/"nan"/"inf"/empty refuse; idle floor/cap), baseline roll on injected relative datetimes (no hardcoded wall-clock dates), exact refusal string equality, exact-day override recorded / wrong-day override refused / unreadable actual fails closed, idle boundary at exactly 14400 vs 14399 seconds, never-executed and dark-VM and non-assigned-item exclusions, claimed-slot skip throughnext_reconcile_action, and the cooldown stamp landing once with delete-compute due aftercooldown_seconds(injected time).daily_bound_cli(fixture provider, end to end): baseline snapshot on first reconcile; exact refusal string through the real CLI; wind-down (deallocate/delete-compute/reset) still applying while the bound is tripped; wrong-day override refused with the mismatch named; exact-day override admitting with loud print, action field, durable record, and status output; the resume call site refusing then admitting under override; a stale yesterday baseline rolling to today; and loud env-parse refusals. A UTC-midnight crossing mid-unit exits 99 and the unit reruns once on a fresh fixture, so no assertion depends on a date that can go stale.idle_deallocate_cli(fixture provider, end to end): below-threshold no-op, past-threshold unattended deallocate (fixture VM goes dark,idle_releaseon the action, durable marker, queue item stays assigned, no release minted), loud status listing, no re-deallocation of dark compute, never-executed worker untouched, and the ordinary release/reset path completing afterwards.static_contractalso pins the new machinery names in the controller and the new contract sentences in the doc. Adjacent suites re-run green: fm-azure-pilot, fm-spawn-cloud, fm-cloud-state, fm-gate-refuse.Mutation proofs (each applied to the production call site, proven red, reverted)
if bound_refusal is not None:->if False:):daily_bound_clired - "AssertionError: [{... 'type': 'create'}]" (a create was admitted past the tripped bound). 24/25 prior-and-other units unaffected until the failing unit.if override == day:->if override:):daily_bound_and_idle_matrixred at the wrong-day refusal assert.started = parse_time(...) if ... else now):daily_bound_and_idle_matrixred at the stamp/delete-compute-due asserts ("AssertionError: None"); all 22 pre-existing units still green, proving the regression is caught only by the new coverage.return Truegoes red in the existingend_to_end_lifecycle(premature deallocate perturbs the action sequence - defense in depth), and the subtler>= threshold // 2goes red precisely at the new 14399-second boundary assert.raise LifecycleError(bound_refusal)-> clears the refusal):daily_bound_clired at the resume-blocked assert (returncode 0 instead of 2).Final clean run after reverting: 25/25 ok, exit 0.
Honest limits
capacity-reserve/capacity-reserve-shapestay governed by the existing cumulative admission only; the daily bound scope is exactly the worker create/resume lanes the requirement names.tests/behavior-test-durations.tsvstill carries the suite's old 12s baseline; the suite is slower now and the TSV refreshes from CI recordings per its own header.Review round 2 (adversarial review findings, commit dc408f3)
Finding 1 (MAJOR) - specialized reserves now sit under the daily bound.
capacity-reserveandcapacity-reserve-shapegate NEW reservation admissions through the samedaily_bound_refusalas create/resume, with the sameFM_AZURE_WORKER_DAILY_BOUND_OVERRIDElane; the disposable runner performs these automatically, so the ungated lane could quietly burn past 100 with no human anywhere. The three capacity commands stay fully locked and unsplit: the gate sits inside each reserve entrance's existing single lock hold, immediately beforecapacity_admission, identically at both. Exemptions, both documented:capacity-releaseis wind-down and stays ungated; a lineage re-admission of an already-reserved shape constituent is already-held accounting (the execute rationale) and skips the gate. The refusal string now names the reserve lane ("new compute and reservations (create/resume/capacity-reserve) are refused"), and reserve JSON output carriesdaily_bound_overridewhen an override admitted.docs/azure-requirements.mdC3 acceptance is unscoped again. The pre-existingshared_shape_cliunit pins the daily bound to 100000 with a comment, because its 1499-pressure step exists to reach the CUMULATIVE refusal and the daily bound has its own dedicated units.Finding 2 (MINOR) - steer counts as recency, and the terminal boundary is documented.
idle_deallocate_duenow takes the NEWEST durable activity stamp (last_execution_at,last_steer_at) against the threshold, so a worker steered minutes ago is never deallocated; the never-executed exclusion is unchanged. Docs now state plainly that idle-deallocate is operationally terminal for the assignment (execute cannot run on deallocated compute, resume needs the VM absent, no power-on lane exists; release or surrender is the exit) and thatFM_AZURE_WORKER_IDLE_RELEASE_SECONDSis the knob compartment operators must raise - specifically the pending secondmate monitor renews legs at 14,400 seconds, exactly the default threshold, so it must run with the knob above its renewal cadence.Finding 3 (MINOR) -
FM_AZURE_WORKER_IDLE_RELEASE_SECONDS=abcnow refuses through the loud LifecycleError lane ("must be an integer between 600 and 604800") instead of a raw ValueError traceback, mirroring the daily-bound parse.Finding 4 (NOTE) - override use is recorded as an effect, not an intent.
daily_bound_refusalno longer writesdaily_bound_override_used; the newrecord_daily_override_useis called only after the admission decision actually admitted (planner create afteradmission_result, resume after its guard - resume has no further gate - and both reserve entrances afterreservedlands). A unit proves bound-tripped + override-named + cumulative-refusal leaves no durable "used" claim.New/updated tests (still 25 executed units, all green, exit 0): matrix adds non-numeric idle parse, steer-recency boundary (recent steer shields, old steer does not), check-never-records +
record_daily_override_usesemantics, and the planner effect-not-intent scenario (override + cumulative refusal -> no record; override + room -> create carries the day and records).daily_bound_cliadds: reserve refused past the tripped bound with the exact string, shape refused with the exact string, reserve admitted under the exact-day override (output field + durable record), and capacity-release applying while tripped.idle_deallocate_cliadds the steer-recency reconcile check and the loud non-numeric env refusal (asserts no Traceback).New mutation proofs (applied, red, reverted):
6. capacity-reserve daily gate dropped (
if readmission_id is None:->if False:):daily_bound_clired - the reservation came back"status": "reserved"past the tripped bound.7. capacity-reserve-shape daily gate dropped (gate result forced to
None, None):daily_bound_clired - the shape came backreservedpast the tripped bound.CI lint: the SC2034
attemptwarning is fixed by using the variable in the midnight-retry notice line; full-repobin/fm-lint.shrun locally, rc=0.