feat: raise the claim and run the job again after a kill for memory - #21
feat: raise the claim and run the job again after a kill for memory#21stephenc wants to merge 2 commits into
Conversation
The kernel stops a job that uses more memory than its claim. That kill said one thing to qex before: the state `oom`. `--retries` did not see that state, the learner threw the measurement away, and the same claim died in the same way on the next run. qex now corrects the claim itself. It multiplies the claim, starts the job again with the same id and the same record, and says in the record that the claim was too small. The correction has its own count, which is separate from `--retries`: a claim is usually the work of qex, so qex must not spend a count that the user gave for a different fault. The ladder has a limit. `[retry] on_oom` gives 2 raises and `growth` gives the multiplier, and the claim never goes above `[budget] mem`. Each attempt costs the full time of the job, so a ladder with no limit can use a day of the machine and give no result. `qex kill` now writes a mark before it sends the signal. The kernel and `qex kill` both use SIGKILL, and the out-of-memory count of a session also counts a kill in a different program of the same user. The mark wins against that count, so a job that a person stopped never runs again with a larger claim. A machine with no cgroup keeps no such count: qex then gives the state `killed`, which starts no new attempt, and the record says that qex could not tell. The learner keeps the lesson. A job that the kernel stopped for memory gives a LOWER BOUND and not a peak, because the job did not finish. The store holds the two kinds apart, and the next claim goes above the largest of both, so a smaller run that succeeds later does not remove a measurement that cost a whole run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
An adversarial review of this branch found two faults. Both are now corrected, and a test holds each one. THE QUEUE NEVER SAW THE RAISED CLAIM. The supervisor raised the claim and started the job again itself. The queue admitted the first claim only, so a job of 600MB that became 1GB ran beside a job that held 400MB of a budget of 1GB. With `[enforce] mode = "hard"` the kernel received the sum of those limits, and the machine met the load that the budget exists to prevent. The supervisor now gives the job back to the coordinator: it writes the record with the state `queued` and the new claim, and it stops. The coordinator puts the job in the queue again and starts it when the machine has capacity. The queue also reads the claim from the RECORD and not from the specification, so one number controls the budget, the limit and the admission. A retry after a failure keeps the same claim, so that retry stays in the supervisor. A KILL THAT QEX COULD NOT CONNECT TO THE JOB STARTED A NEW ATTEMPT. With no memory limit, which is the default, qex reads the out-of-memory count of the login session. That count also rises when the kernel stops a different program of the same user, and a machine that is short of memory is the machine on which a person uses `kill -9`. A job that a person stopped with `kill -9` thus ran again with a doubled claim, and the learner recorded a bound that no measurement supports. qex now separates the two kinds of evidence. It acts on the count of the cgroup that it made for the job, where the kernel stopped the job at the claim. It reports the state `oom` on the count of the session, says what the reader can do, and starts no new attempt. The record of a kill for memory also goes to the learner only with the evidence of the job, and qex keeps one lower bound for each command. A learned claim never goes above the memory budget: qex makes that number itself, and it must not make a number that it then refuses. The review found four smaller faults, and this commit corrects each: - The mark of a kill by a command stayed for the next attempt. The supervisor now deletes both marks at the start of each attempt. - The message at the limit called a claim above the budget "the whole budget", which contradicts itself. An oversized job and a multiplier that gives the same claim now have their own words. - `qex rerun` repeated the claim that the kernel stopped. It now uses the claim in force. - A note about a kill that qex cannot explain replaced an earlier message, and those messages say that the limit is NOT active. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
Review fixes — both blocking findings correctedPushed as BLOCKING 1 — the raised claim now goes through admissionThe supervisor no longer starts the new attempt. It writes the record with The The Falsification: I restored BLOCKING 2 — qex now acts only on job-scoped evidenceNew
The session-scope record says: "qex applies no memory limit to a job in this configuration, so it counts the kills of the whole login session and it cannot prove that the claim of this job was too small: the machine can be full while the claim is correct. qex therefore did NOT start the job again… set Falsification: with the scope gate disabled, This does mean the correction is off in the default configuration. That is the honest position: without a per-job cgroup, qex has no evidence that the claim was the cause. The docs, the help and the README now state it plainly rather than implying the feature always fires. NON-BLOCKING 3 — the "lower bound" is now evidence
NON-BLOCKING 4, 5, 6, 7
TestsNew: The simulated-kill helper's doc comment now also records that the planted evidence says |
Closes part of #10 — "An out-of-memory kill teaches nothing and never retries".
What the feature does
The headline case in the README: a long run with
--mem guessthat the kernelstops at hour four. Before this change, that job got the state
oom,--retriesdid not see it (it fires onfailedonly), and the learner recordedcompleted jobs only. The claim was never corrected, and the next run died the
same way.
Now:
with the same id and the same record, and the log holds every attempt.
same command starts above the claim that failed.
The decisions, and why
How qex tells a kill for memory from every other kill. The source is the
cgroup counter
memory.events oom_kill, read before and after the job. It wasalready there and it needs no limit from qex: every process on Linux is in a
cgroup, so the count exists in the usual configuration.
That counter alone is not sufficient, and this is the part that could do harm.
When qex applies no limit it reads the count of the SESSION, which also counts a
kill in a different program of the same user. So
qex killnow writes a markin the job directory before it sends the signal, and that mark always wins. A
job that a person stopped can never be answered with a larger claim and a new
attempt — qex must not repeat work that somebody stopped on purpose.
When qex cannot tell. macOS has no cgroup and no equivalent count. qex then
gives the state
killed, which is the safe answer because it starts no newattempt, and it writes a note in the record saying that it could not tell the
cause and what the reader should compare. A guess in either direction sends the
reader to the wrong correction.
Its own count, not
--retries.--retriesis for a fault outside the task,and the user chose that number for that fault. A claim is usually the work of
qex (
--mem guessand the learned claim both come from qex), so qex correctsits own fault and does not spend a budget given for something else. A job with
no
--retriesvalue — the README case — still gets the correction.[retry] on_oom(default 2) and[retry] growth(default 2.0) control it, andon_oom = 0turns it off.The bound. Two rules stop the ladder:
usual error of an estimate. Each attempt costs the full time of the job — four
hours in the README case — so a ladder with no limit can use a day of the
machine and give no result.
[budget] mem. qex must notclaim memory that it does not have. A job that already claims the whole budget
keeps the state
oom, and the record says to use a larger machine or a largerbudget.
Where the raised claim lives. In
status.json(mem), and not inspec.json. The specification keeps what the user asked for; the record givesthe claim in force. The coordinator budgets on
status.memand re-reads thatfile every 500ms, so the raised claim reaches the budget accounting on its own,
and the memory limit of the next attempt now comes from the record as well
(before, it came from the specification, so a cgroup limit would have stopped
every new attempt at the size that already failed).
The learner. A sample now has a
kind:peak(the job completed, so thisis the memory the job needs) or
lower-bound(the kernel stopped the job, sothe true need is ABOVE this value). A missing
kindreads aspeak, so anexisting
usage.jsonkeeps its meaning exactly and no claim moves for a jobthat behaves.
suggesttakes the largest peak AND the largest lower bound andgives a claim above both, so three later small runs cannot average away a
measurement that cost a whole run. The lower bound is
max(measured peak, claim): with a limit the kernel stops the job at the claim,and with no limit the peak is the evidence qex has.
What I measured
[retry] on_oom = 2,growth = 2.0: 128MB → 256MB → 512MB, three attempts.command with no
--memat all got 192MB (the bound plus the 1.5 margin)with
claim_source = learned.qex config showgains one section;qex status --jsongains one field.What is tested, and which kills are real
No test makes a genuine kernel kill, and I did not write one that pretends
to. A genuine kill needs a cgroup memory limit or a machine with no free
memory. qex can apply a limit only when the coordinator owns its cgroup, and a
usual machine gives the login session to the root user (
availability()isUnavailablehere); filling the machine is not acceptable when other work runson it, and the kernel chooses its own victim. This is stated in a doc comment on
the test helper.
The e2e tests instead make the evidence that a true kill leaves — the
out-of-memory record in the job directory — and the job then stops itself with
SIGKILL, the same signal the kernel uses. Every step after that point is thereal code: the classification, the new claim, the new attempt, the words in the
record, the log of every attempt, and the learner. The reading of the cgroup
counter itself is unchanged code, covered by the existing
enforcetests.a_job_that_the_kernel_stops_for_memory_runs_again_with_a_larger_claima_job_that_a_user_killed_is_not_retried_and_teaches_the_learner_nothingqex kill+ planted counter evidencea_claim_that_stays_too_small_stops_at_the_limitsupervisor::a_kill_for_memory_gives_the_state_oomsupervisor::a_job_that_a_user_stopped_is_never_an_out_of_memory_killsupervisor::a_kill_that_qex_cannot_explain_says_sosupervisor::the_claim_doubles_after_a_kill_for_memorysupervisor::the_claim_stops_growing_at_the_limitsupervisor::the_claim_never_goes_above_the_memory_budgetsupervisor::the_config_file_can_stop_the_correctionusage::the_next_claim_is_above_a_lower_boundusage::a_lower_bound_is_not_averaged_away_by_the_smaller_runsusage::the_largest_evidence_wins_whatever_its_kindusage::an_earlier_file_keeps_its_meaningusage::a_kill_for_memory_records_the_claim_when_it_is_largerThe user-kill test is the one that protects the feature from doing harm, and it
is falsifiable: with
mark_user_killremoved, it fails — the job is classifiedoomand qex starts it again with a doubled claim. That is exactly the harm themark prevents.
Results:
Also in this change
docs/reference.md: the two kinds of measurement, and a section on a job thatthe kernel stops for memory.
docs/design.md: what the learner records.README.md: the case, with the record that a user reads.src/help.rs: the retries option, the learning topic, the states topic, andthe config topic.
src/schema.rs:oom_raises, andmem,claim_source,attemptsandretries_left, which the status schema did not name.Cargo.toml: 0.7.1 → 0.8.0.🤖 Generated with Claude Code
https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9