feat: make a submission idempotent with --dedupe-key - #17
Conversation
An agent that loses its context runs its script again. Without a key, qex starts a second copy of a four-hour run beside the first copy, and both copies hold the machine. `qex submit --dedupe-key build:$(pwd)` starts no second job while a job with that key waits or operates. qex writes the id of that job to stdout and exits with the code 0, so `ID=$(qex submit ...)` is correct in both cases and a script needs no test of its own. The reason goes to stderr. The coordinator makes the test of the key and the reservation of the key ONE lock operation. Two agents that run the same script in the same moment thus get one job and one id. A test that a script makes itself (read `qex list`, decide, submit) has a gap between the read and the submission, and both agents start a job in that gap. A key holds a job while that job waits or operates, and the key is free when the job stops. A key that held a job for ever would give an agent the id of a job of yesterday, and that answer looks like a success. `--dedupe-window 1h` keeps the key of a job that SUCCEEDED for a time. A job that did not succeed never keeps its key, because the one remedy for a failure is another run. The key is in the record of the job, so `qex status` shows it and a coordinator that starts again gives each key back to its job. The capability `dedupe` gates the option. A coordinator that does not know the field would ignore it, start the second copy, and say nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
…else
`qex run --dedupe-key` can receive the job of a different caller. Ctrl-C
then sent a kill for that job, and the first agent lost its run with no
cause that it could see. Measured: the job of agent A went from
`running` to `killed` when the command of agent B received SIGTERM.
`qex run` now stops the job only when it started the job. For a job that
a key gave, a signal stops the wait, and the command gives the code 124:
your wait stopped, and the job continues. The message names the command
that stops the job. `qex run` says the rule when it attaches, and it
says it after the handler exists, so the message is proof that the rule
is active.
Two documents were not complete, and this commit corrects them:
* The window of the submission that ASKS applies, and not the window
of the job that holds the key. A reader could not infer that.
* A comment said that a coordinator that starts again reads the key
from the status record. It reads `spec.dedupe_key` from `spec.json`.
A later maintainer could delete that field and free every key at a
restart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
|
Review findings addressed in 50c7631. 1. New e2e tests:
Documented in 2. Whose window applies is now stated. "The window of the submission that asks applies, and not the window of the job that holds the key" — with the reviewer's three-command bypass as the worked example, and the reason: the window is a question (how old an answer do I accept?), not a property of the earlier job, which keeps policy out of the coordinator. Each place says it concerns a job that already succeeded only, so the no-double-start property is untouched, and each says to give the same window in every command that shares a key. In 3. The comment now points at the right field. Test results after the changes:
Understood on the merge order: this must go in after #22 ( |
Closes part of #10: nothing makes a submission idempotent.
An agent that loses its context runs its script again. Without a key, qex starts
a second copy of a four-hour training run beside the first copy. The agent page
told that agent to run
qex listand look — a proxy check, which is the faultthis tool exists to remove.
The second run of that script gives the same id and exits with the code 0.
What I decided, and why
"Already operates" means a job that has not stopped. A key holds a job while
that job waits in the queue or operates. When the job stops, the key is free and
the next submission starts a new job. A key that held a job for ever would give
an agent the id of a job of yesterday, and that answer looks like a success. The
rule is one sentence: the key stops a second copy of the work, and it does
nothing else.
The answer is selectable:
--dedupe-window 1h. The window keeps the key of ajob that succeeded for that time, for a caller that wants a completed result
to count. A job that did not succeed never keeps its key, whatever the window
is. A window that blocked a retry would make the option dangerous: the one remedy
for a failure is another run. The window comes from the submission that asks, and
not from the job that holds the key, so the coordinator keeps no policy of its
own and the caller says how old an answer it accepts.
The key is also free when the record goes.
qex cleanandqex gcdelete thekey with the record, because a key that named a job with no record would give an
id that
qex statuscannot answer.How a caller tells the two apart. stdout holds the id alone and the exit code
is 0 in both cases, because
ID=$(qex submit ...)must not change. Thedifference is on the other stream:
For a script that must know whether it started the work,
qex submit --jsonwrites
{"id": "...", "deduplicated": true}.qex runrefuses--jsonwith amessage, because its stdout holds the output of the job.
qex status <id>showsthe key of a job, so a caller can see which key gave it an id.
The race. The check and the reservation are ONE lock operation in the
coordinator (
State::dedupe, a map from key to job id, read and written insidehandle_submitwhile the state lock is held). The reservation goes in before thelock is given back, so a second submission in the same moment finds the id. A
reservation that names a job with no record yet counts as taken; that case
cannot mean "the record went away", because
cleandeletes the entry with therecord. A submission that qex then refuses, or whose record it cannot write,
frees the key again.
Where the key lives. In
JobSpecand inJobStatus, soqex list --jsonandqex statusshow it and it survives a coordinator that stops.recover()giveseach key back to its job at start: a job that has not stopped wins over a job
that has, and after that the latest job wins.
The capability.
dedupeis incapabilities::ALLand inrequired_by. Thisis the most dangerous option to ignore in silence: an earlier coordinator would
start the second copy, give a new id, and say nothing.
qex rerunclears the key. That command exists to run the work again; withthe key it would give the first job's id and do nothing.
A pipeline stage has no key. A key on one stage would answer for that stage
alone, and the stages after it would wait for a job of an earlier run — two runs
mixed in one graph. A pipeline needs one key for the whole group, which is a
different feature. The
Stagetype holds no such field, so a file that names onegives an error and qex accepts no part of it in silence.
dedupe_keyanddedupe_windoware job-file fields, becauseqex submit --job train.tomlisone of the commands that a script repeats.
What I measured
--test-threads=2.directory, and 20 equal ids.
What I tested
New e2e tests in
tests/e2e.rs:a_second_submission_with_one_key_gives_the_first_job_and_starts_no_job— thesame id, the code 0, one job in
qex list, one job directory, the reason onstderr, and the key in the record.
many_submissions_with_one_key_at_once_make_one_job— 20 submissions at thesame moment give one job and one id.
a_job_that_stopped_frees_its_key_and_a_window_keeps_it— a job that stoppedfrees the key; the window keeps the key of a job that succeeded; a job that
failed frees its key inside the window.
submit_json_says_if_this_command_started_the_work.a_key_stays_with_its_job_after_the_coordinator_stops— the coordinator iskilled (its pid comes from
qex info --json, never from a search of theprocess list) and the new coordinator gives the key back to its job.
New unit tests: the rules of
State::dedupe_holderfor each job state, thereservation that has no record yet, the release with the record, the capability
refusal (
a_dedupe_key_is_refused_by_a_coordinator_that_has_no_dedupe), and theresolution of the key and the window from the command line and the job file.
Also updated:
docs/agents.md(a section that tells an agent to use the key andNOT to read
qex listand decide),docs/reference.md,README.md,skills/qex/SKILL.md,qex help agents,qex help job-file, the job schema andthe status schema. The repository generates no shell completions on
main.Cargo.tomlmoves to 0.8.0.🤖 Generated with Claude Code
https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9