Skip to content

End event waits when their daemon dies, and keep followers idle (#218) - #219

Merged
myobie merged 2 commits into
mainfrom
agent/events-waits-end-and-stay-idle
Nov 5, 2026
Merged

myobie merged 2 commits into
mainfrom
agent/events-waits-end-and-stay-idle

Conversation

@myobie

@myobie myobie commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the part of #218 that main still has. The diagnosis, with what main already fixed, is on the issue.

What was left on main 8dd3fd42

  1. An unknown token silently ended option parsing. Everything after it was dropped, so pty events NAME --wait T --timeout=60 waited with no deadline, forever. So did -t=60, --wait=T and any typo. Such a token is now an error, Unexpected argument: <token>, with exit 1. This matches pty send's message.
    • When an unknown dash token comes before the name (--timeout=60 NAME), the error names that token. A lone pty events --bogus still says Session "--bogus" not found., as in Node.
    • This departs from Node on purpose, so it has decision 0021, with Node, main and this branch measured side by side, and a row in docs/parity.md.
    • It needs no ledger entry, because no conformance test passes pty events an unknown argument. I listed every events argv in crates/pty-conformance/tests.
  2. A daemon that died without recording an exit never ended a wait. That covers SIGKILL, OOM and a host restart. With no -t, the wait ran forever.
    • The follower now retires a named session when its daemon recorded a pid and has vanished, unless a respawn holds the creation lock. A daemon writes its own pid when it starts.
    • A record that no daemon has claimed keeps waiting, since its launcher may still start it. That's the case 2bf0c4e kept for generic waits.
    • It applies whether the wait started before the daemon died or after.
    • A session_exit wait reports Session "NAME" vanished without a recorded exit., which is the startup check's message. Other waits report Session "NAME" vanished before a "T" event was observed. Both exit 1, and a named follow ends with 0.
  3. Every write in the shared registry directory woke every follower. A named follower now wakes only for its sessions' <id>.events.jsonl and <id>.json, and --all only for logs. A notification without paths still wakes, and the 250 ms poll still covers anything a notification misses.
  4. A matched wait exited up to 250 ms late. The match was printed at once, but EventFollower::stop only set a flag, which the thread read at its next poll. On main, other sessions' writes often woke it first. Item 3 removes those wake-ups, which made the delay show every time. stop now wakes the thread.

Overlap with #201 (draft). #201 covers item 2 for session_exit waits only, with an opt-in flag that classifies the session on every scan. Here the rule applies to every named follower. A record that no daemon has claimed is still not treated as dead, which is what kept #201 to lifecycle waits. #201's two tests pass unchanged on this branch, its messages included.

Not changed

  • A wait still matches only events appended after it starts. Only session_exit also reads recorded state (fix: complete event waits from session lifecycle state #197). The README states this contract, and changing it is a separate decision.
  • A follower still rereads the whole log when it changes. Each append replaces the log with a new file, the prefix comparison is how the follower notices a retention rewrite, and the log is capped at 1000 lines. Other sessions' writes never reach that path, because the inode and length check returns first.
  • Nothing written to events.jsonl or <id>.json changes. The follower only reads them.

Measured on Linux, debug builds. Each run used a fresh registry with 20 sessions that set their title and rang the bell every 100 ms (load.sh, about 195 logged events/s):

main 8dd3fd42 this branch
a user.* waiter, -t 6, under the load 0.20 s CPU 0.08 s CPU
five waiters for 8 s 1.58 s CPU 0.50 s CPU
an empty registry, one waiter, 6 s 0.00 s 0.02 s
match printed / process exited, after pty emit returns (8 runs) 0-1 ms / 0-255 ms 0 ms / 0-2 ms
daemon SIGKILLed during a user.* wait ran until killed at 8 s exit 1, 0.1 s after the kill
daemon SIGKILLed during a session_exit wait ran until killed at 8 s exit 1, 0.1 s after the kill
NAME --wait T --timeout=2 ran until killed at 5 s exit 1, Unexpected argument: --timeout=2

The reported build 063ff7f spun at 10.9 s CPU in 9.6 s with no load at all, and five waiters used 29.5 s in 8 s. Main fixed that in ad48f88.

Tests

  • cli_events::an_unexpected_argument_is_refused_rather_than_dropping_the_options_after_it: seven spellings, each exiting 1 at once with the exact message.
  • cli_events::waits_and_follows_end_when_their_daemon_vanishes: a real daemon, SIGKILLed. A user.* wait, a session_exit wait and a named follow, each started once before the kill and once after, all end within 3 s with the expected status and message. The kept case, a record no daemon claimed, is wait_prints_the_first_matching_event_or_times_out, which still runs to its timeout.
  • events::follow::tests::only_changes_to_followed_files_wake_the_follower: other sessions' logs and metadata, temporary files and pid files don't wake a named follower; its own log and metadata, and a notification with no paths, do. --all wakes for logs only.
  • events_follow::stop_does_not_wait_for_the_next_poll: with a 30 s poll, stop returns within 5 s.
  • Controls: I reverted each of the four parts separately, and each one's test failed. The stop test needs its 200 ms settle. Without it, the stop landed before the thread reached its wait, so the reverted code passed too.
  • cargo test --workspace --no-fail-fast: 2183 passed, 0 failed, and all 184 test binaries ran.
  • scripts/conformance-both.sh over events, events_emit, metadata_events and peek_wait, against the pinned Node build: Node passed 56 and failed 8, and this branch passed 64 and failed 0. The 8 are existing ledger entries. check-divergences.py finds none of them unrecorded, and no entry for these files stale.

Callers with a stray argument. I found none.

  • I searched fractal main 8aad68af. It never spawns pty events, and its pty runtime has no events call.
  • I searched this host's seat skills, agent definitions, ~/.local/bin scripts and st work directories.
  • I searched the scripts, bin, .claude and skills directories of every clone on the host, 675 directories in all.
  • I searched the OMP 18.10.2 package and this repository.
  • The only hit was a doc example, pty events --recent api, which is well formed.

Release note. pty events now rejects an unknown argument instead of ignoring it and every option after it. Pass -t <sec> (not -t=60 / --timeout=60) and --wait <type>. A wait or named follow now ends when its session's daemon dies without recording an exit.

Main already fixed most of #218: followers ignore their own reads
(ad48f88), animated titles are no longer logged (d5e370c), options may
follow the session name (ad48f88), and a wait ends when its session
exits or is removed (#197). Four gaps remained.

- A token pty events did not know ended option parsing silently, and
  every option after it was dropped. pty events NAME --wait T
  --timeout=60 waited forever with no deadline. Such a token is now an
  error: Unexpected argument: <token>, exit 1.
- A wait or named follow whose daemon died without recording an exit
  (SIGKILL, OOM, a host restart) never ended without -t. The follower
  now retires a session whose daemon recorded its pid and has vanished,
  unless a respawn holds the creation lock. A record that no daemon has
  claimed yet keeps waiting, since its launcher may still start it.
  - A session_exit wait reports "vanished without a recorded exit".
  - Other waits report "vanished before a <type> event was observed".
  - A named follow ends with 0.
- Every write in the shared registry directory woke every follower. A
  named follower now wakes only for its sessions' logs and metadata,
  and --all only for logs. The 250 ms poll still covers missed
  notifications.
- A wait that matched exited up to 250 ms later, because stop() waited
  for the follower's next poll. stop() now wakes it.
pty events now refuses an argument it does not know, where Node ignores
everything after the session name and Rust before ignored everything
after the unknown token. Decision 0021 records the departure, measured
against the pinned Node build and main. parity.md gets a row. No
conformance test passes pty events an unknown argument, so the ledger
needs no entry.

When an unknown dash token comes before the name (--timeout=60 NAME),
the error now names that token instead of the name after it. A lone
unknown dash token is still taken as the name, as in Node.
@myobie

myobie commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Independent read GO at exact head 9a9aca9456241eded65ea52dab9e6e8336bdb9e3. All 9 checks are SUCCESS on this head and the PR is CLEAN. I read the first head (dfce4283) in full and the follow-up commit as a delta.

Read:

  • A wait or named follow ends when its daemon vanishes. A daemon that recorded its pid and then died without recording an exit (SIGKILL, out of memory, a reboot) can append nothing more, so a wait on it used to run until its deadline, or forever. Waits and named follows now end, after a last drain of the log, unless a respawn holds the creation lock. A record that no daemon has claimed keeps waiting, since its launcher may still start it. --all follows are unaffected.
  • A named follower wakes only for its own files. Every session writes into the one registry directory, so every follower used to wake on every session's writes. A named follower now wakes only for its own log and metadata (--all only for logs); the 250 ms poll covers anything a notification misses. The author measured a loaded waiter dropping from 0.20 s to 0.08 s of CPU per 6 s.
  • A matched wait exits at once. stop() now wakes the poll, so a matched wait exits in 0 to 2 ms instead of up to 250 ms.
  • An unknown argument after the session name is an error. pty events NAME --timeout=60, -t=60 and --wait=<type> used to end option parsing silently and drop the deadline or the wait, so the command ran forever with no error. They now exit 1 with Unexpected argument: <token>. An unknown dash token taken as the name is the one named; a lone pty events --bogus still says the session was not found, as Node does. This departs from Node deliberately and is recorded in decision 0021 with a measured table, plus a parity row.
  • Tests cover each part; the author reports each fails alone when its part is reverted. The follower only reads events.jsonl and the session record: nothing new is written, no wire-format change, no tier-1 file touched.
  • The author searched fractal's source, the seat scripts and skills, 675 clone directories and the OMP package for pty events calls with a stray argument and found none.

Release note: pty events now rejects an unknown argument (exit 1) instead of ignoring it and every option after it. Pass -t <sec>, not -t=60 or --timeout=60, and --wait <type>. Waits and named follows also end when the session's daemon vanishes without an exit record.

Risks (low):

  • A script that passed a stray argument to pty events now fails fast instead of running with the argument ignored.
  • The follower still re-reads its own whole log when that log changes; the CPU win comes from not waking on other sessions' writes.
  • A host still running a build from before the earlier follower fixes also needs the release to benefit.

@myobie
myobie merged commit d50ad1b into main Nov 5, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant