feat: let a person pause the queue, or take a lock, and say so everywhere - #20
feat: let a person pause the queue, or take a lock, and say so everywhere#20stephenc wants to merge 2 commits into
Conversation
…here
A person sometimes needs the machine back for a moment: a video call starts,
the laptop goes on battery, or an interactive task needs the cores. Until now
the only lever was `kill <coordinator pid>`, and the next command started a new
coordinator, which started the queue again.
qex pause queue [--reason TEXT] [--for TIME] [--drain]
qex resume queue
qex pause lock <name>
qex resume lock <name>
qex pause say what is paused now
A paused queue starts NOTHING. Every job in qex has a claim, so a job that
costs nothing does not exist, and `paused` must be one fact that an agent can
act on. The jobs that operate continue, because each one already holds its
capacity and a stop would lose it. `--drain` waits for a quiet machine.
`qex pause lock <name>` gives the lock to the person. It is never refused: the
job that holds the lock keeps it, no other job takes it, and the lock comes to
the person when that job stops. The command is thus safe to type at any moment.
The state is the file `paused.json`, beside the job records. A pause in the
memory of the coordinator would go away when that process stops — and qex's own
messages tell a user to run `kill <pid>` on it. The coordinator writes the file
and reads it at its start, so it has one writer.
The pause reason replaces the capacity reason; it never stands beside one. That
reason holds a clock time and no elapsed time: the scheduler writes
`status.json` with two `fsync` calls for each reason that changes, twice a
second, so a number that changed would rewrite the record of every job in the
queue for the whole length of the pause.
`qex info`, `qex top` and `qex list` say the pause and how long it has lasted,
and they say loudly when the pause has no end.
The capability `pause` gates the new requests. An earlier coordinator answered
"qex could not read this request", which gives no remedy, and the user would
believe that the machine is quiet while the queue started jobs.
Closes #9
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
The review found two ways in which work started while the user believed that the machine was quiet. A retry starts the next attempt INSIDE the supervisor process. That process never gives the job back to the scheduler, so the pause test of `sched::choose` never saw it, and a job with `--retries` started fresh processes minutes after `qex pause queue` answered "paused". The supervisor now reads the pause file before the next attempt and waits, for the queue and for a lock of that job. A file is a state that every process can read, which is the second reason for the file. A `paused.json` that this version could not read gave "nothing is paused", with no log line and no word in any command. That is the wrong direction for this feature: a queue that qex holds by mistake costs latency and `qex resume queue` corrects it, and a queue that operates by mistake cannot be corrected after the work started. qex now HOLDS the queue, says what happened and gives the remedy. An unknown field still parses, so a later version of qex is safe. Also from the review: * `start_job` tests the pause again with the state lock held. `step` chooses a job and releases the lock, and a pause that arrives in that moment must not lose the race — for a lock, that order is the whole claim of the feature. * `qex pause` ends a pause that reached the time of `--for` before it reports. With no coordinator it reported a pause that the next command ended at once. * A second `qex pause queue` keeps the end and the reason of the first. A command that looks idempotent must not change 30 minutes into for ever. * `qex resume` gives its own reason in the capability refusal. The reason of a refused pause is the opposite of the truth for a resume. * `--for 0` is an error. `parse_duration` reads zero as "no limit", which is correct for `--timeout` and is the opposite of what `--for` asks for. * `by_pid` records the process that asked, which the CLI now sends. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
|
Review addressed in 561320d. Both blocking holes are closed, and all six non-blocking items are done. BLOCKING 1 — a retry started work behind the pause. BLOCKING 2 — an unreadable Your framing was right and my doc comment was wrong: "qex must not invent a pause" is a good rule for a tool that reports, and the wrong rule for the tool that holds the machine. 3 — Tests. The |
Closes #9.
What it does
qex resumewith no word starts the queue again, so every message that namesthat command is correct in its short form too.
Pause the queue. qex starts NO job. The jobs that operate continue, because
each one already holds its capacity and a stop would lose that work; the answer
says so and points at
qex killandqex pause queue --drain.--draingivescontrol back when no job of this queue operates.
Pause a lock.
qex pause lock gpu0gives the lock to the person. Every jobthat needs it waits, with the reason
waits for the lockgpu0, which a person holds. The command is never refused while a job holds the lock: qexrecords the request, that job keeps the lock, no other job takes it, and the
lock comes to the person when the job stops.
It survives a coordinator that stops. The state is
paused.jsonin theruntime directory, beside the job records. The coordinator writes it and reads
it in
recover, so it has one writer.It says so.
qex info,qex top,qex listandqex statuseach reportthe pause, how long it has lasted, and — loudly — when it has no end.
qex submitwarns on stderr at the moment of the submission.What I decided, and why
exception for a job that claims little: every job in qex has a claim, the
point is a quiet machine, and
pausedmust be one fact an agent can act onrather than a predicate it must qualify.
the text "… paused it at 14:32, 6 minutes ago". Section 4.2 point 2 forbids a
changing number in
blocked_reason: the scheduler writesstatus.jsonwithtwo
fsynccalls for each reason that changes, and it ticks every 500ms, so"6 minutes ago" would rewrite the record of every job in the queue twice a
second for the whole length of the pause. The clock time is stable, and the
elapsed time is calculated by
qex infoandqex topwhen a person readsthem. A unit test holds this rule.
Infogainsqueue_state, not apausedboolean. Section 4.1 asks forone machine-readable field, because two parallel fields drift. It is
Option<String>and not a Rust enum, so a later version can addheld,waits-for-peerandwaits-for-machine(question 1) without making anearlier CLI refuse the whole answer.
Noneprintsunknown, neverrunning— a guess here is a lie in the one place where the honest answermatters most.
qex resume queue, the command that the user types, inplace of the
qex resumeof section 3.3. Both forms operate.--forand--reasontoo. One shape for both targets, anda lock that a person forgets has the same fault as a queue that a person
forgets.
The four consequences of section 3.4
and pass 2 of
sched::choose, so a job whose dependency failed still becomesskippedandqex waitstill gives an answer.construction, so without this a pause would start every oversized job.
qex resumesetsidle_sinceto now. Without it thesettlewindow isalready satisfied at the resume and the first job to run would be an
oversized one, alone, in front of everything that waited. A pause that ends
by itself (
--for) does the same.recover.Capability
pauseis incapabilities::ALL, andcapabilities::requireis the secondentry point beside
required_by: this is a request name, not a job field. Anearlier coordinator answered "qex could not read this request", which states a
condition and gives no remedy, and the failure is the dangerous one — the person
believes that the machine is quiet while the queue starts jobs.
No JSON schema changed:
qex schema job|status|pipelinecover the job file andstatus.json, and this feature adds no field to either.What I measured
cargo test --bins: 179 passed, 0 failed (7 new).cargo test --test e2e -- --test-threads=2: 77 passed, 0 failed, 58.5s(6 new).
cargo fmt --allandcargo clippy --all-targets -- -D warnings: clean.qex pause queue --reason,qex info,qex list,qex pause,qex pause lock gpu0,qex resume,qex resume lock gpu0.paused.jsonappears at the first pause and is deletedwhen nothing is paused.
What I tested
a_paused_queue_starts_no_job_and_the_jobs_that_operate_continuethe_pause_survives_a_coordinator_that_stops— takes the pid fromqex info --no-start --json, never a process-list search, kills thatcoordinator, lets the next command start a new one, and confirms
queue_stateandpaused_reasonon the NEW pid.a_person_gets_a_lock_when_the_job_that_holds_it_stopsa_job_that_waits_for_a_pause_says_the_pause— and not "waits for …"a_pause_with_a_time_ends_by_itselfa_failed_dependency_is_still_skipped_while_the_queue_is_pausedtext, the loud "NO END" line, the
qex topheader, and the capabilityrefusal.
Each test that measures "nothing started" measures over a period, and not one
time, so a job that starts late cannot pass it.
Documentation
docs/reference.mdgains a section and the command list;qex help pauseis anew topic (
qex help resumeis an alias);Cargo.tomlmoves to0.8.0.🤖 Generated with Claude Code
https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9