The idle reaper spares an agent whose backgrounded tool call is still running, and restarts its idle clock when the job ends - #80
Conversation
… running: a job the CLI detached from its terminal keeps the session alive, and the idle clock restarts when it ends Switching away from a session detaches it, and five minutes later `reap_idle_sessions` SIGHUPs then SIGKILLs its whole process tree. For an agent the only exemption was the hook-fed status (Running / NeedsFeedback), and that status turns Finished the moment the turn's Stop fires — while a `run_in_background` Bash call, a Monitor watch, or a Codex shell command started in that turn is still running underneath. The conversation resumed fine, so the lost work was silent (#78). The issue proposed the terminals' `pgrep -P` check, but an agent always has children: every Claude session here holds a stdio MCP server and, while it works, a `caffeinate`, in its own process group. That check would have spared every agent forever. What separates work from furniture is the session: Claude spawns a tool command detached (`setsid`), so it leads a session of its own — `s` in `ps`'s stat column — while MCP servers, `caffeinate` and Codex's code-mode host stay inside the agent's terminal session. Codex's own shell tool detaches the same way. So the reaper now runs one `ps -axo pid=,ppid=,pgid=,stat=` per candidate past the timeout and spares the agent when any strict descendant of its PTY child leads a session. That works under the zsh launch (the agent is the PTY child) and under bash, where the login shell forks the agent as a job of its own group but not its own session. The `ps` parse is shared with the kill sweep's `process_groups_in_table`, which keeps its behaviour. A failed `ps` counts as busy, as it does for terminals. A spared agent is restamped rather than merely skipped: the job ending is the moment the agent wakes to read its result, and a sweep landing in that gap would have killed it mid-notification. It gets the full timeout again from there — an agent with a long-lived detached child (a dev server it started) is never reaped while it runs, the same deal a terminal with a job already gets. Tests: a unit test over synthetic `ps` tables for the idle, busy, and bash-wrapped shapes; an e2e test with two stand-in agents, one whose child runs in a session of its own (spared, then reaped a full timeout after the job ends) and one whose child shares its session (reaped on schedule). The config doc comment also loses its mention of pinned agents, which no longer exist. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| fn ps_table() -> Option<String> { | ||
| let out = std::process::Command::new("ps") | ||
| .args(["-axo", "pid=,ppid=,pgid=,stat="]) | ||
| .output() | ||
| .ok() | ||
| .and_then(|o| String::from_utf8(o.stdout).ok()) | ||
| .unwrap_or_default(); | ||
| process_groups_in_table(&table, root) | ||
| .ok()?; | ||
| String::from_utf8(out.stdout).ok() | ||
| } |
There was a problem hiding this comment.
ps_table() never checks ps's exit status — Command::output() only returns Err on spawn failure, so a ps that spawns but exits non-zero (e.g. a ps that doesn't support the -axo pid=,ppid=,pgid=,stat= format, like BusyBox/Alpine) yields Ok with empty stdout, i.e. Some(""), not None.
That flows into detached_job_under: detached_job_in_table("", root) parses zero rows and returns false ("not busy"), which contradicts that function's own doc comment two lines above it: "A failed ps counts as busy: never kill what can't be inspected."
Since agent_has_detached_job uses this to decide whether the idle reaper spares a Finished-status agent with a live backgrounded job, a ps invocation that runs-but-fails on a given host would silently reintroduce the exact #78 bug this PR fixes: the reaper would kill the session while its backgrounded job is still running.
| fn ps_table() -> Option<String> { | |
| let out = std::process::Command::new("ps") | |
| .args(["-axo", "pid=,ppid=,pgid=,stat="]) | |
| .output() | |
| .ok() | |
| .and_then(|o| String::from_utf8(o.stdout).ok()) | |
| .unwrap_or_default(); | |
| process_groups_in_table(&table, root) | |
| .ok()?; | |
| String::from_utf8(out.stdout).ok() | |
| } | |
| fn ps_table() -> Option<String> { | |
| let out = std::process::Command::new("ps") | |
| .args(["-axo", "pid=,ppid=,pgid=,stat="]) | |
| .output() | |
| .ok()?; | |
| if !out.status.success() { | |
| return None; | |
| } | |
| String::from_utf8(out.stdout).ok() | |
| } |
Closes #78. An agent you switched away from could lose a backgrounded tool call five minutes later, when the IDLE REAPER killed its whole process tree; the reaper now sees that job in the process tree, waits for it, and restarts the idle clock when it ends.
Contents: 🐛 Symptom · 🔍 Cause · ✅ Fix · 📸 Before / After · 🔁 State ·⚠️ Risk · 🔧 Technical overview · 🧪 Proof · 📝 Notes
🐛 Symptom
Start a long background task inside a Claude session — a
run_in_backgroundBash call, a Monitor watch — let the turn end, and switch to another session, project or worktree. Once the old session has sat unwatched forsession_idle_timeout(5m by default), the DAEMON kills its PTY, and the SIGHUP → SIGKILL sweep takes the background child with it. The conversation resumes seamlessly on the next attach, so nothing says the work is gone. Terminals were already spared while a command runs; agents were not.🔍 Cause
The reaper's only exemption for an agent was its hook-fed status, RUNNING or NEEDS FEEDBACK. That status flips to FINISHED the instant the turn's Stop hook fires, and a backgrounded tool call is precisely a child that outlives its turn — the status machine has no notion of it. The issue's suggested one-liner, the terminals' "any child alive"
pgrep -Pcheck, does not work for agents: every Claude session in this repo already holds a stdio MCP server (and acaffeinatewhile it works) as a permanent child, so that check would have exempted every agent from reaping for good.✅ Fix
setsid, thesflag inps.caffeinate, Codex's code-mode host — do not count, so an idle agent still reads as idle and still gets reaped.Settings › Sessions › session_idle_timeoutagain from the moment its job finishes."off"still switches reaping off.session_idle_timeoutdoc comment no longer promises to spare pinned agents, a feature that no longer exists; How it works and Configuration name the new exemption.📸 Before / After
No PNG: this is a DAEMON mechanism with no screen of its own — the affected session looks identical before and after, which is the bug. The proof is the process tree the reaper now reads. A live Claude session in this repo, with a backgrounded
sleep 240Bash call and a Monitor watch running (ps -axo pid=,ppid=,pgid=,stat=,etime=,command=, indented by parent):Before this PR the reaper looked only at the row's status and killed the lot. After it, the two
Ssrows spare the session; theS+rows alone would not. Codex's shell tool places its command the same way (sleep 60undercodex execshows asSNs, whilecodex-code-mode-hostshows asSN).🔁 State
flowchart TD sweep["IDLE REAPER sweep, every 15 s"] --> aged{"unwatched past<br/>session_idle_timeout?"} aged -- no --> keep["keep"] aged -- yes --> status{"RUNNING or<br/>NEEDS FEEDBACK?"} status -- yes --> keep status -- no --> tree{"a process under the CLI<br/>leading its own session?"} tree -- "yes: backgrounded Bash,<br/>Monitor, Codex shell" --> spare["spare + restart the idle clock"] tree -- "no: only MCP servers,<br/>caffeinate, code-mode host" --> kill["kill_session: SIGHUP, then SIGKILL the tree"] status -. "before #78: straight to the kill" .-> kill classDef new fill:#dff5e1,stroke:#2e7d32,color:#000 class tree,spare newVerdict: 🟢 Low risk — one more exemption in an existing sweep, read from a
psthe DAEMON already runs, with no protocol or store change.pswith fixed arguments in the kill sweep; this adds one read-onlystat=column and no user-controlled input reaches argv. The failure mode is conservative — apsthat fails, or an unknown child pid, counts as busy and spares the session.ps -axo(~10 ms) per sweep candidate that is already past the timeout and not RUNNING — a handful at most every 15 s, off the PTY byte path and the TUI draw. Terminals already paid the same per-candidatepgrep.shell_has_childrenexemption; thepsparse is shared with the kill sweep's process-group walk, which keeps its behaviour and its test.Rollback:
git revertof the merge undoes everything — no PROTOCOL VERSION bump, no store migration, no pushed branch; only the config doc comment and two docs pages change alongside the code.🔧 Technical overview
crates/nebula-daemon/src/registry.rs—reap_idle_sessionsspared an agent only onRunning | NeedsFeedback; now a FINISHED agent is spared, and restamped viatouch_session, whenagent_has_detached_jobfinds a session leader under its PTY child.crates/nebula-daemon/src/pty/mod.rs—detached_job_underrunsps -axo pid=,ppid=,pgid=,stat=and asksdetached_job_in_tablewhether any strict descendant of the child has thesflag.parse_ps_tableanddescendantsare shared withprocess_groups_in_table, which the kill sweep uses; a table without astatcolumn still parses.claudefunction) the login shell forks it as a job in a group of its own, so "a group other than the child's" would flag the agent itself. A session boundary is only ever created on purpose, by the CLI, for work it means to keep running.sleep 30and reaps an idle agent; no test gave an agent a child that outlives its turn.pgrep -P. Measured on this machine: every idle Claude session has a live MCP-server child in its own process group, so "any child" would have switched reaping off for agents entirely.🧪 Proof
idle_agent_with_a_detached_job_is_spared_until_it_endsincrates/nebula/tests/e2e_pty.rs— two stand-in agents: one whose child runs in a session of its own is spared while the resident is reaped, then reaped itself a full timeout after the job ends; fails onorigin/main, passes here.detached_job_is_a_session_leader_below_the_agentincrates/nebula-daemon/src/pty/mod.rs— syntheticpstables for the idle, busy, and bash-wrapped shapes, plus the kill sweep's existing table test.cargo clippy --workspace --all-targetsreports no errors (its warnings are all in code from Agent harnesses move to a config registry, with Muse and Grok on board #79, none in the files here);cargo test— 1241 tests pass: nebula-core 30, nebula-daemon 267, nebula-tui 837, thenebulabinary 49, browser_cli 7, config_cli 5, help_cli 6, tunnel_cli 3, e2e_pty 30 (the new test included), e2e_tui 7 of 9 — the two e2e_tui failures (nebula_open_from_inside_a_session_raises_the_file_tabs,tui_projects_worktrees_agents_navigation) fail identically on untouchedmain. Run in a detached worktree atorigin/main+ this commit, so no other session's uncommitted work was in the build.📝 Notes
origin/mainafter Agent harnesses move to a config registry, with Muse and Grok on board #79; no conflicts withmainat the time of writing.cargo fmt --checkis already red onorigin/mainin files this PR does not touch (harness.rs,session_title.rs,agent_picker.rs, the TUIconfig.rs, …, from Agent harnesses move to a config registry, with Muse and Grok on board #79); the files here are formatted, and a follow-upcargo fmt --allonmainwould clear the rest.🤖 Generated with Claude Code