feat: an event stream, so an agent reads one stream and not twenty answers - #18
feat: an event stream, so an agent reads one stream and not twenty answers#18stephenc wants to merge 2 commits into
Conversation
…y answers An agent that drives twenty jobs asked about each job, one job at a time. It learned of a result late, it used the machine to ask, and it wrote a loop with a timer. `qex events --json` gives one line for each change of state instead, at the moment of the change. The reader connects one time. Each line is one JSON object, and each `job` line carries the whole record, so the reader needs no second command for the exit code, the measured use or the cause of a failure. What counts as an event: a change of the record of a job. That includes the first state, which is the admission of the job, and it includes the reason that a queued job waits. The scheduler writes that reason after the admission, so a reader that gets the admission alone sees `blocked_reason: null` and learns nothing. The stream can start again with no loss. Each event has a number, and `--since N` continues after it. A stream that starts at "now" makes an agent that restarts lose the results that arrived while it was away, and that is the class of fault that qex exists to remove. The coordinator keeps the last 512 events in a ring. It never waits for a reader, and its memory does not grow for one. A reader that falls behind receives a `gap` line that COUNTS what it lost. qex never hides a gap: a reader that loses the line `failed` and hears nothing waits for ever. A reader does not hold the coordinator open. The coordinator retires as before, and it writes a `bye` line first. A stream that ends with no `bye` says that something stopped the coordinator, and the command then exits with the code 1. `qex events` refuses a coordinator that does not say `events` in its capabilities. It names that coordinator and gives the command that stops it. An empty stream and a stream with no events look the same, so a silent refusal would make the reader wait for ever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
Five results of a review of the event stream. A reader that stops while the queue is QUIET left a thread and two file handles behind. The loop found a reader that went away through a write that failed, and on a quiet queue there is nothing to write. That shape is the shape that `--since`, `--count` and `--timeout` exist for: an agent reads for a few seconds, does its work, and comes back. Fifteen such readers left 15 threads and 30 handles for the hour of the idle time, and at the limit of the handles the coordinator accepts no connection. The stream now tests the connection at each tick, with `poll` and a peek of one byte, and it stops when the reader closed its end. Measured: 3 threads and 4 handles before, 3 and 4 again two seconds after 15 bounded readers. A number that came from a coordinator that stopped continued IN SILENCE. The numbers start at 1 in each coordinator, and a new coordinator makes one event for each record that it reads, so the number 348 of the earlier stream names a different event in the new one. The coordinator gave a gap only when the number was above its own last number, and it is above it for a moment only. Each stream now has a name, `stream_id`, and `--since <stream_id>:<seq>` gives that name back. qex compares the two, gives a gap line when the stream is not the same one, and continues with what the new coordinator holds. A number with no name cannot give that comparison, so `qex events` writes a warning that names the remedy. `missed` is now null in that gap, in place of 0. Two streams have no common measure, so qex cannot count what the reader lost, and the number 0 read as "you lost nothing". The stream can go from `starting` to `completed` with no `running` line, because the coordinator reads the record of each job twice each second and a short job stops between two reads. `previous` was already honest. The help text, the schema and the documentation now say so, so that a reader does not wait for a line that will not arrive. The doc comment of `info` documented `qex events`. It is back on `info`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
|
Review findings addressed in 4956dc0. 1 — a reader that goes away on a quiet queue (thread and fd leak). Measured with New e2e test 2 — A bare New e2e test 3 — the doc comment. 4 — a state that the coordinator did not see. Said plainly in 5 — Every reader in every test still has a time limit. |
Closes part of #10.
What it does
One JSON object on one line for each change of state, at the moment of the
change. An agent that drives twenty jobs reads one stream instead of asking
about each job in a loop.
{"event":"stream","time":...,"version":"0.8.0","pid":123,"coordinator_started_at":...,"first_seq":1,"last_seq":9} {"event":"job","seq":10,"time":...,"id":"a1b2...","name":"build","state":"running","previous":"starting","change":"state","job":{ ...the whole record... }} {"event":"gap","time":...,"missed":37,"next_seq":420,"reason":"..."} {"event":"bye","time":...,"reason":"the coordinator stops, because ..."}The socket protocol keeps its framing:
Eventsis one more request name, andthe coordinator answers it with many
Eventresponses on the same connection.There is no second transport and no second format.
The decisions, and why
What counts as an event — a change of the record of a job, and nothing else.
the
queuedline withprevious: null. A separateadmittedevent would saythe same thing twice.
change: "reason"). This oneis not optional. The scheduler writes
blocked_reasona moment AFTER theadmission, so a reader that has the admission line alone sees
blocked_reason: nulland never learns that the job waits for memory, for alock, or for a job that failed. This is the event that makes the stream
usable.
cannot answer itself:
stream(which coordinator, which numbers),gap(youlost events, here is the count),
bye(the coordinator stops, and why).Catch-up: the default is catch-up, not live-only. Every event has a
seq,and
--since Ncontinues after it.--since start(the default) giveseverything the coordinator still holds, and
--since nowgives live only. Astream that silently starts at "now" makes an agent that restarts lose the
results that arrived while it was away — exactly the class of fault this tool
exists to remove, so live-only is not the default.
The numbers belong to one coordinator. The
streamline carriescoordinator_started_at, and a--sincenumber that this coordinator neverissued gives a
gapline that says the coordinator restarted. The stream neverpretends that a number from a dead coordinator was honoured.
A slow reader: drop, and COUNT the drop. The coordinator keeps the last 512
events in a ring behind the same lock as the jobs. It never waits for a reader
and its memory never grows for one. When the ring passes a reader, that reader
gets a
gapline withmissed. A silent gap is worse than a reported one: anagent that loses the line
failedand hears nothing waits for ever. A write toa reader that has stopped reading gives up after 30 seconds and closes that one
connection; the reader then meets the end of the stream, and the coordinator
loses one thread and nothing else.
Capability negotiation.
eventsis now incapabilities::ALL, andqex eventsrefuses a coordinator that does not report it: the message namesthe coordinator, says what it cannot obey, and gives
kill <pid>. The checkhappens BEFORE the request, so the words come from qex and not from a parser.
This matters more here than anywhere else, because an empty stream and a stream
with no events look identical — a silent refusal makes the reader wait for ever.
A new function
capabilities::check_commandcovers a command that needs arequest name, beside
check, which covers a job that carries an option.The coordinator still retires. An attached reader does NOT hold it open: a
stream that keeps a coordinator alive for ever is a leak. The coordinator stops
on the same rule as before, and it writes a
byeline first, then waits up toone second for its readers. A stream that ends with NO
byemeans thatsomething stopped the coordinator;
qex eventssays so on stderr and exits 1. Astream that dies in silence under its reader is a fault, because the reader
cannot tell it from a broken socket.
One writer for the stream.
State::publish_changescompares the recordswith the records it reported last, and every path that changes a job calls it —
the queue, the scheduler,
qex cancel,qex kill, the supervisor, and therefresh that reads the status files. Calling it twice for one change costs a
comparison and gives no repeated line, so a new code path cannot silently miss
the stream. The stream and
qex listread one map, so they can never disagree.While testing this, the shutdown of the coordinator was moved to delete its
socket file FIRST. The window in which a new command connects to a coordinator
that no longer accepts connections — and then reports "version unknown, pid 0" —
was already there; the wait for the readers would have widened it.
What I measured
costs the coordinator about half a megabyte, whatever a reader does.
queued -> starting -> running -> completed, one line each, flushed as ithappens. A job of a few milliseconds gives
queued -> starting -> completed:the stream reports what the coordinator holds, and it does not invent a state
that the coordinator never saw.
the
byeline and exited 0. The coordinator did not stay.{"missed":6}for--since startand{"missed":5}for--since 1, and thestream continued after the gap.
Tests
New unit tests (
src/events.rs,src/capabilities.rs,src/schema.rs,src/proto.rs):each_event_fits_one_line_of_json— a reader splits on the newline.the_ring_holds_the_last_events_and_counts_the_others— a wrong count makes agap line that lies.
a_reader_receives_the_events_after_its_number_onlythe_numbers_never_repeat— a repeated number makes a restarted reader losean event.
each_change_gives_one_line_and_a_repeat_gives_none— several paths publishfor one change; an agent must not act twice.
each_cursor_form_survives_the_wirea_command_is_refused_by_a_coordinator_that_does_not_know_itandthis_build_says_that_it_has_the_event_stream— the capability rule.the_event_schema_names_each_type_of_lineNew end-to-end tests (
tests/e2e.rs). Every reader in a test has a time limit,so no test can hang:
the_event_stream_reports_each_change_of_state_in_order— the reader startsBEFORE the job, and it gets
queued, starting, running, completedin order,one JSON object per line, with increasing numbers and the whole record.
a_reader_continues_from_the_number_that_it_read— no loss and no repetition.the_stream_counts_the_events_that_it_dropped— with a small ring, the readergets a
gapwithmissed > 0and the stream continues.the_coordinator_retires_under_a_reader_and_says_goodbye— the reader doesnot hold the coordinator open, and it hears the stop.
a_coordinator_that_has_no_event_stream_refuses_the_command— the testanswers on the socket like an earlier version.
qex eventsexits 1, names theremedy, and writes no stream. The test starts no qex coordinator, so it kills
no process.
Results:
Also in this change
docs/reference.md,docs/agents.md,docs/index.md,README.mdandskills/qex/SKILL.md.qex help events, a new topic, and the command in the command list.qex schema event— the stream is an interface for a program, so it gets aschema like the other JSON that qex publishes.
there was nothing to add there.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9