feat: run a command from the config file when a job stops - #16
Conversation
A person who starts a job of four hours walks away. `[hooks] on_stop` names a command that qex runs when the job stops, so that person learns the result: a message on the screen, a line in a file, or a message to a chat. The hook is in the config file and not on the job. It belongs to the machine and to the person at it, and not to the work. The supervisor runs the hook, at the end and after the record on the disk says that the job stopped. The supervisor exists for each job that ran, and it knows the result first. The coordinator can stop and start again while a job runs, so a coordinator that ran the hook would miss the jobs of that period. For the jobs that no supervisor sees — cancelled, skipped, and a job whose supervisor left no result — the coordinator runs the hook in a thread of its own. The file `hook.ran` gives one run for each job. Each process that makes a job terminal makes that file with `create_new` before it starts the hook, and that operation succeeds for one process only. A notification that arrives two times teaches a person to ignore every notification. The job supplies its values in the environment: QEX_JOB_ID, QEX_JOB_NAME, QEX_STATE, QEX_EXIT_CODE, QEX_SIGNAL, QEX_ELAPSED_SECS, QEX_CWD, QEX_JOB_DIR, QEX_ATTEMPTS, QEX_MAX_RSS and QEX_TAGS. qex builds no command line from them, so a job name with a shell character stays a name. `on_stop` is a program and its arguments, in the same way as a job command. qex starts no shell. To use a shell feature, name the shell. `on_stop_states` selects the jobs that give a message. The default list holds each state of a job that ran. `cancelled` and `skipped` need a word from the user: the person cancelled the job, and one failure in a pipeline of twenty stages would give twenty messages. The hook cannot damage the queue. It starts after the final state is on the disk, so the job has its result, the budget is free and the next job starts. A hook that uses more than `[hooks] timeout` receives TERM and then KILL, in its own process group. A hook that fails changes no job. Its output goes to `hook.log` in the job directory with mode 0600. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
The review of the hook found six faults. This commit corrects them. A job name can hold a NUL byte, and the name comes from the person who submitted the job. `Command::env` refuses such a value, so the hook did not start, the record of the run was already there, and the message of that job was lost for ever. The log named the config file, which was correct. qex now replaces each control character with a space, so the hook always starts, and an escape sequence from a job name does not reach a screen. The supervisor wrote the terminal record and then ran the hook. A signal between those two steps left a job with a correct result and no message. The coordinator now runs the hook also when it reads a terminal record from a supervisor that stopped. The record of the run makes the second call safe. The guarantee is now in the documentation in full: qex never runs the hook two times, and it loses one message when a process stops between the record of the run and the run. A message that arrives two times is worse than a message that is lost. The verdict of qex went to the log of the supervisor or of the coordinator, and no command reads those files. It now goes to `hook.log`, and `qex logs <id> --hook` gives that file. A user whose message did not arrive can thus learn the reason with a qex command. The time limit is not a limit on the output. A hook of three seconds that wrote with no stop made a file of 3.7GB in the state directory, for one job. qex now stops a hook that writes more than 1MB, and it cuts the file to that size. The message for an unknown state omitted `skipped`, which qex uses, and it offered `queued`, `starting` and `running`, which the next test refuses. Two smaller corrections. The comment on the coordinator path now says that the time limit stops with the coordinator. The supervisor now leaves the cgroup of a job that did not start, before the hook runs, so the hook does not receive the memory limit of the job. The error for a config file that qex cannot parse now names the version as a possible cause. `[hooks]` is the first section that a user adds by hand, and an older qex refuses the whole file and each command with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
|
Review answered in BLOCKING 1 — a NUL in a job name. Fixed in 2 — at most once, and the documentation. Took the free fix: the terminal arm 3 — the list of states. 4 — the verdict reaches no command. The verdict now goes into 5 — the output had no limit. It has one now: 1MB. qex stops the hook when 6 — the coordinator path loses its limit. The comment says so now, in The The cgroup. The spawn-failure path now leaves and removes the cgroup of the |
The coordinator reads its configuration one time, at its start, and it operates for hours. The hook took that copy on each path of the coordinator. A user who deleted the hook from the file thus met the hook again on each job, and a user who added a hook received nothing, while `qex config show` gave the new value. Nothing said that a restart was necessary. A configuration that is old made qex do nothing before this feature. Here it made qex RUN A COMMAND THAT THE USER DELETED, so it is a fault of a different type. `hook::fire` now reads the config file itself, at the moment that the job stops. Each caller gives the job only. The size limit did not hold for a hook that stopped quickly. The loop tests the size between two sleeps and it leaves at once when the hook stops, so a hook that wrote 20MB in one interval kept each byte. qex now tests the size again after the hook, whatever stopped it, and cuts the file there. The stop of a hook waited for the first process during the grace time. That `wait` takes the process out of the process table, and the machine can then give its number to different work, which the second signal would reach. The grace time is now a sleep, in the same way as `wait_without_reaping` in the supervisor. qex cuts a log that is too large in the middle of a line, and the verdict then joined that line. `qex logs --hook --tail 1` gave a megabyte. The verdict now starts on a line of its own. `qex logs --hook --json` wrote nothing for a job with no hook, so a reader that asks for JSON received a fault from its parser. It now writes the document, and the sentence goes to stderr. `--hook` with `--stdout` or `--stderr` now gives an error: those options select a stream of the job, and the hook is not a stream of the job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
|
Second review answered in BLOCKING — a stale configuration ran a command that the user deleted. Took Measured with the reviewer's two directions, one coordinator, no restart:
Regression test: The 1MB cap for a fast hook. qex now tests the size again after the loop,
The verdict in a cut log.
PR #25. Understood: at the rebase or the merge I take the |
Closes part of #10 ("Smaller, agreed": A hook when a job stops, in the
configuration and not on the job).
What it does
[hooks] on_stopnames a command that qex runs when a job reaches its finalstate. A person who started a job of four hours and walked away learns that it
stopped.
With no
[hooks]section there is no hook, and nothing changes.The decisions, and the reason for each
Which process runs the hook: the supervisor, at the end. The supervisor
exists for each job that ran, and it knows the result first. The coordinator
can stop and start again while a job runs, so a coordinator that ran the hook
would miss every job of that period. The coordinator runs the hook for the jobs
that no supervisor sees:
cancelled,skipped, a job whose supervisor left noresult, and a job that a restart found dead. It does that in a thread of its
own, because it makes those jobs terminal while it holds the lock of the queue.
One run for each job: a claim file, and not a code path. There is no single
place in the program that every terminal transition passes through.
hook.rangives the guarantee instead: each process makes that file with
create_newbefore it starts the hook, and the operating system gives the file to one
process only. The file lives beside the record of the job, so the guarantee
holds after a restart of the coordinator as well. A notification that arrives
two times teaches a person to ignore every notification.
What the hook receives: the environment.
QEX_JOB_ID,QEX_JOB_NAME,QEX_STATE,QEX_EXIT_CODE,QEX_SIGNAL,QEX_ELAPSED_SECS,QEX_CWD,QEX_JOB_DIR,QEX_ATTEMPTS,QEX_MAX_RSSandQEX_TAGS. The set answers thequestions that a person asks when the message arrives: which job, what
happened, how long, and where do I look now. A hook that needs more reads
spec.jsonandstatus.jsoninQEX_JOB_DIR, so the list stays short. Avariable with no value is empty text and not an absent variable, so a shell line
needs no test.
An argument list, and not a shell line. The rest of qex takes an argument
list and starts no shell, and
qex helpsays so. The hook keeps that position,and it uses the same escape that the job file documents: name the shell,
["bash", "-lc", "..."]. The exception is not necessary, because the values ofthe job arrive as variables. The shell that the user names expands
$QEX_JOB_NAMEfrom the environment; qex never builds that text.Security. The command is in the user's own config file, so it is the user's
command. The data of the job is NOT the user's: a job name comes from whoever
submitted the job. Every value therefore goes in the environment, and qex builds
no command line from any of it. A job named
; rm -rf ~gives a hook a variablewith those letters in it, and never a command. A unit test holds that.
Which jobs fire it.
on_stop_statesselects them. The default list holdseach state of a job that ran:
completed,failed,killed,timeout,oom.cancelledandskippedneed a word from the user, because the personcancelled the job, and one failure in a pipeline of twenty stages would give
twenty messages. A job that failed and ran again gives ONE message, with the
final result: a retry returns the job to
queued, which is not a final state.An unknown state name, or a state that is not final, gives an error at start.
A hook cannot damage the queue. qex starts it after the final state is on
the disk. The job thus has its result, its claim has left the budget, and the
next job starts before the hook does anything. The hook runs in a process group
of its own; at
[hooks] timeoutqex sends TERM to that group and KILL twoseconds later, so a hook that starts children leaves nothing behind. The output
goes to
hook.login the job directory with mode 0600, and the time limitbounds how much a hook can write. A hook that fails, or that does not exist,
writes a line in the log and changes no job.
What I measured
qex waitgave its answerto the shell. The hook is not in the path of the result.
sleep 300, limit 2s) on the first job, with a budget ofone core: the second job went from
submitto a result in 0.53 s. Thehanging hook delayed the next job by nothing, and the first job still says
completed.[hooks]section, the added work is one test of an empty list.What I tested
New unit tests in
src/hook.rs: one run for each job with three calls; thevariables that the hook receives; a job name with shell characters that must not
become a command; a hook that hangs and stops at its limit; a hook that does not
exist; the filter; a job that did not stop. New unit tests in
src/config.rs:no hook by default, the default filter, and a state name that qex refuses.
New end-to-end tests in
tests/e2e.rs:a_job_that_stops_runs_the_stop_hook_one_time_with_its_resulta_stop_hook_that_hangs_holds_neither_the_job_nor_the_queuethe_configured_states_select_the_jobs_that_run_the_stop_hook(this one goesthrough the coordinator, for a
skippedjob)Notes
Cargo.tomlmoves to0.8.0.docs/reference.md,docs/design.md(the files of a job),docs/security.md(the mode ofhook.log, and the reason for theenvironment), and
qex help config.qex config shownow names the hook,because a command that starts on each job and that the reader forgot is a
program with no visible cause.
cover the job file, the status and the pipeline file, and none of them holds a
hook field, because the hook is not a property of a job.
[hooks]stops a coordinator of 0.7.1 with a parse error. That is the behaviour of
every new section, and
qexcompares the versions and warns.🤖 Generated with Claude Code
https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9