HOMELAB-2634: feat(herdr): clean up background sessions, count sessions, notice only failures #94
No reviewers
Labels
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Homelab/pi-extensions!94
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-2634-janitor-background"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
HOMELAB-2634 — the cleaner learns background sessions, session counts and notices
Follow-up to #93 (merged
945d97c), which armed the herdr session janitor. Aaron, 2026-11-04 18:37Z: "make it so it can keep cleaning up our space. we need to be clean" and "this way the operators (jesus and me) only interact with active supervisors".1. Background Claude sessions with no pane
The janitor only knew herdr panes. There are also
claude --backgroundsessions: processes that outlive their pane and stay alive with no tab and no name. They are now collected fromclaude agents --jsonand reported:A background session may be stopped only when all of these hold: a retire marker exists for it (
retire/<name>orretire/<id>);kind == background; the process is alive;status != busy;state == done; noclaude attach <id>client; the name is not protected (ld-hypervisor/el-director, case-folded); and its cwd is underrepos/homelaborrepos/pi-extensions. Anything else is report-only.The write is
claude stop <id>— its conversation is kept and it is resumable. This code never callsclaude rm, neverclaude daemon stop, neverkill, and never disturbs an attach client. Liveness and the attach check read/proc; no signal is ever sent to a foreign process. The allowlist permits exactlyagents --jsonandstop <id>;rm,daemon stopandstop --allare refused before a subprocess exists.2. One-line session count
Before the per-pane detail:
An unreadable source increments
unreachable— it is never silently 0.3. A notice only when something could not be closed
NOTICE:is emitted only for a failedpane close, aSKIP_CHANGEDrevalidation, a marker whose close was refused, a failedclaude stop, or an unreadable source. When everything either closed cleanly or was refused by rule, there is noNOTICE:at all. An ordinary REPORT row is the normal state and stays silent.4. Herdr sessions that do not fit the session-rules table
Per
working-files/ldv0/analisis-macro/change-package/session-rules.md, the only herdr sessions that should exist are El Director / project supervisors / Jesús's Director, and epic orchestrators. A named herdr session that fits neither is reported:A
juez-/judge-named session is specifically reported aswhy=judge-should-be-a-subagent. Report only — it never closes one.Safety contract unchanged
Dry-run by default; real actions need
--applyand the non-symlink activation file containingHOMELAB-2634; every close re-decided from fresh state immediately before the destructive call; neverwS/wH; never a focused, unnamed or protected pane; the 600-second stillness floor still applies to both the successor and the retire-marker paths.Evidence
89 → 117 tests, 28 new (
tests/test_herdr_janitor_sessions.py, fakeclaudeon PATH and a fake/proc), full suite green,py_compileclean. Live dry-run against the real fleet:SESSIONS herdr=9 named=5 background=2 background-alive=1 unreachable=0, zeroCLOSE_CANDIDATE, noNOTICE:.Not proven
No background session has been stopped for real — proved on fakes only. Two caveats are recorded in
docs/herdr-janitor.md:/procliveness can in theory be fooled by pid reuse (the code then reportsSTOP_FAILEDand does not retry), and theRULEline is a name-grammar heuristic that reports rather than acts.Verdict: REQUEST_CHANGES
model: omniroute/deepseek-flash
head:
e01532f3e2session: homelab-2634-review-5
Review of
plane/HOMELAB-2634-janitor-backgroundate01532f3e21bcb7d4adca90c49f9f4b34b73c556(baseorigin/main=945d97c786f87cfcfbab3fd1833997ec1678bb11). I reviewed the commit objects in my own detached worktree; I did not use a shared checkout. Sessionhomelab-2634-review-5, modelomniroute/deepseek-flash.What I verified myself
Verified on this VM, with the real binaries. I ran the janitor
--dry-runinto an isolatedmktempstate dir against the live herdr and the liveclaude. It printedSESSIONS herdr=10 named=6 background=2 background-alive=1 unreachable=0, tenREPORTrows, zeroCLOSE_CANDIDATE/WOULD_CLOSE, zeroNOTICE:and zeroRULElines. I then re-ran it with a logging wrapper installed as--claude; the only argv the dry-run ever issued wasagents --json, andstopwas invoked zero times, which is the read-only claim demonstrated rather than asserted.Tests.
python3 -m unittest discover -s tests -p 'test_herdr_janitor*.py'— 120 tests, OK, 0 failures, 0 errors.CI on the exact head. Runs #266 and #267 are both
[pass]ate01532f3e2; thepythonjob is green on both, and the full extension suite is green on #266.The 600-second floor is untouched. This is the cleanest result of the review:
git diff 945d97c e01532f -- scripts/herdr_janitor/janitor.pyremoves no line fromdecide,screen_stable,successor_ageormin_successor_seconds, and touches none of them at all. The only removed behaviour-bearing lines are the old two-memberPROTECTED_NAMESliteral andsafe_markersbeing superseded bymarkers_with_state(a strict superset that additionally reports an unreadable directory).screen_stableis still called on both herdr branches, still requiresnow - hash_since >= config.min_successor_seconds, the default is still 600, and--min-successor-seconds 599still refuses with exit 2. Nothing #93 fixed was loosened.The argv allowlist cannot be widened.
claude_allowed_argvadmits exactly["agents","--json"]and["stop", <one token>]. I brute-forced every argv of length 1–3 over the token setstop rm daemon all agents attach --json -a --all x ""and no combination escaped.rm,daemon stop,stop --all,stop --daemon, extra arguments, any leading-,/,..,a/b, empty and whitespace ids are all refused.ClaudeCLI._runre-checks the allowlist so the subprocess cannot be reached by another path, and it refusedrm x,daemon stopandstop --allwhen I called it directly. There is noos.kill, nosignal, noterminateand no shell in the file; the onlysubprocess.runcalls are the two allowlisted ones./procaccess is genuinely read-only.Processesuses onlyis_dir(),iterdir()andread_bytes()on/proc/<pid>/cmdline. Nothing is signalled, so noticing an attach client cannot disturb it.Every "never stop" condition refuses, and names itself. I broke each one at a time against the real decision function:
status=busy→status-busy;state=running→state-running; missingstate→state-unknown; attach by id and attach by full name →attach-client-present; no marker →no-retire-marker; a marker naming a different session →no-retire-marker;analista-macro,Analista-Macro,ld-hypervisor,El-Director→protected-name;cwd=/home/aaron/repos/other, empty cwd and the near-miss/home/aaron/repos/homelabX→cwd-not-allowed;pid=Noneand a non-existent pid →process-gone;kind=interactivewith a marker →kind-interactive;kind=interactivewithout a marker is not even reported. A cwd genuinely under either root (/home/aaron/repos/homelab/sub/dir) is allowed. Marker matching by id and by name both work.MARKER_NAME_RErefuses..,../../etc/passwd,/etc/passwd,a/band a leading-, so the unlink cannot escape the retire dir, and..cannot even be created as a marker.The marker is deleted only after a successful stop.
_execute_backgroundsetsSTOP_FAILEDandcontinues before theunlink; the marker survives a failed stop andtest_failed_stop_keeps_the_marker_and_noticesproves it. A delete that fails after a successful stop becomesSTOPPED_MARKER_DELETE_FAILEDplus aNOTICE:.unreachableis never a silent zero. I forced each source to fail:clauderaisingOSError→ 1;claudereturning bad JSON → 1;claudenot wired in at all → 1; retire dirchmod 000→ 1; unreadable roster → 1; one failed pane status read → 1. A missing retire dir correctly stays 0, because nobody has asked for a retirement. Notice emission matches:REPORT,STOP_CANDIDATEandSTOPPEDare silent, whileSTOP_FAILED,SKIP_CHANGED,STOPPED_MARKER_DELETE_FAILEDand every unreadable source each emit exactly oneNOTICE:.The
RULEchange works as described.analista-*in any casing,Analista-Macro,ANALISTA-MACRO,analista-riesgoand a hypotheticalanalista-nuevo-xproduce noRULEline.juez-macro,Juez-Outliers,judge-1,JUDGE,juezandjudge.xproducewhy=judge-should-be-a-subagent.stray-sessionandscratchstill producenot-a-director-or-orchestrator, so the silencing is scoped to the prefix and did not widen.analista-macrois inPROTECTED_NAMESand is protected on both the pane path (_base_protections) and the background path, in every casing I tried, including accent-foldedÁnalista-Mácro; near-misses likeanalista-macro-2andxanalista-macroare correctly not protected.Findings
F1 — the attach-client guard fails open on two invocations that
claude --helpdocuments as normal. BLOCKING.This is the one thing that must change before merge. The attach check is the only guard standing between the janitor and a human's live terminal, and it can be walked around without any exotic input.
claude --helpon this box lists-d, --debug,--debug-fileand--verboseas top-level flags, andclaude attach --helpsays verbatim: "Part of the session name works in place of the id." The janitor's/procscan, however, only records anattachtoken when it is not argv[0] and the immediately preceding argv element containsclaude, and it then compares the collected string for exact equality againstsession.agent_idandsession.name.I put the real background row from this VM —
id=9e011d8b,name=Ghosttly-in-the-shell Android port of rootshell,kind=background,state=done,status=idle, live pid, cwd/home/aaron/repos/homelab— through the realdecide_backgroundwith a matching marker and thesecmdlines:claude attach 9e011d8b→ detected →REPORT,attach-client-present. Correct.claude attach 'Ghosttly-in-the-shell Android port of rootshell'→ detected, and correctly refused only because claude's ownattachpath wants the name quoted as one argv, which is what I tested. Correct.claude attach ghosttly→ the scan collectsghosttly;ghosttly != 9e011d8bandghosttly != "Ghosttly-in-the-shell Android port of rootshell"→STOP_CANDIDATE. A person watching that session in their terminal, having typed the name prefix claude's help tells them to type, is stopped.claude attach 'Ghosttly-in-the-shell'→ same,STOP_CANDIDATE.claude --debug attach 9e011d8b→ the scan skips theattachtoken because argv[i-1] is--debug, not aclaudeelement → empty attached set →STOP_CANDIDATE.claude --verbose attach 9e011d8b→STOP_CANDIDATE.sh -c 'claude attach 9e011d8b'→ no argv element equalsattach→STOP_CANDIDATE.node /opt/claude/bin/claude.js attach 9e011d8b→ detected. Correct.So five of the eight spellings I tried stop a session a human is attached to, and two of those five are invocations claude itself advertises. Nothing else in the pipeline catches this:
claude attachkeeps the session running "either way", sostatestaysdoneandstatusstaysidle, and every other guard passes. Thedocs/herdr-janitor.mdsentence added by this PR — "no attach client for that id (claude attach <id>in anycmdline)" — is therefore false in exactly the cases that matter, and it is the sentence a future reader will rely on. The stop is resumable so nothing is destroyed, but this is a safety claim the change makes and does not honour; it is cheap to fix (match the id as a prefix, match the name by substring/prefix, and treat anyattachoccurrence in acmdlinethat also mentionsclaudeas an attach client) and should be fixed here rather than in a follow-up.F2 —
statusis compared for exact equality, so the busy guard fails open on any unrecognised value. Medium, not blocking.session.status == "busy"with no folding is asymmetric withstate != "done", which fails closed.Busy,active,waiting,working, or an absentstatusall pass the busy check. On this VM the real wire format is lowercaseidle/busy, and a background row can omitstatusentirely — the livestate: blockedrow has nostatuskey — so today there is no exposure, and thestate == "done"requirement covers it. But the guard is fail-open on a vocabulary nothing pins down, and the next status name claude invents silently widens the stop. The same treatmentstategets (compare against a known-safe set) would make it fail closed like its neighbour.F3 — an unreadable
/procmakes the janitor more willing, not less, and is not counted. Low, not blocking.Processes.attached_ids()returnsfrozenset()onOSErrorfromiterdir()and skips any/proc/<pid>/cmdlineit cannot read. An empty attach set means "stop everything", so this is the one read failure in the file that errs permissive, and it is not included inunreachable— every other unreadable source is. On a single-user box with a readable/procthis is theoretical; it becomes real the day an attach client runs as another user whosecmdlineis unreadable.F4 — a failed background stop does not affect the exit code. Low, not blocking.
main()returns 1 only when a pane close returnedCLOSE_FAILED. ASTOP_FAILEDorSTOPPED_MARKER_DELETE_FAILEDon the background path leaves exit 0 — confirmed by my live run, which exited 0 with no notices. TheNOTICE:line is there for a human readingreport.txt, but anything keying off exit status (cron, a monitor, a caller) will not see a failed background stop.F5 — nit.
rule_violationsilences any folded name starting withanalista-, including the degenerateanalista-itself.What this does not prove
No background session has ever been stopped by this code for real, and this review does not change that. The live evidence is read-only by construction: the dry-run issued
agents --jsonand nothing else, and zerostopargv. The stop path is proved on fakes — a fakeclaudeinstalled onPATHin the tests, and my direct calls throughdecide_backgroundandClaudeCLI._run— so what is established is that the janitor's decision and its argv surface are correct, not thatclaude stop <id>behaves as documented, that it is idempotent, what it returns for an unknown or ambiguous id, or that a real stopped session is genuinely resumable afterwards. The docs say this plainly and that honesty is to their credit.Three further limits: liveness is a
/proc/<pid>directory test, so a recycled pid can make a dead entry look alive (the stop then fails and is reported, which is the safe direction, and this is documented); the background path has no continuous-observation floor at all, so a session that finished its turn one second ago is stoppable if a marker exists — defensible, since there is no screen to be unstable and the stop is resumable, but it is a weaker gate than the pane path and nothing says so; andnormalise_cellfolds case and accents but not separators, soEL DIRECTORwith a space is not inPROTECTED_NAME_KEYS. That last one is pre-existing — the function is untouched by this PR — and on the pane path the name is still not relievable, so it fails closed there; on the background path a space-spelled protected name would rely on the cwd and marker guards alone.Verdict
REQUEST_CHANGES, on F1 alone. Everything else in this change is careful, narrowly scoped, and honest about its own limits: the allowlist is proven rather than asserted, the 600-second floor is untouched to the character, the census cannot lie about a failed source, notices are exactly as silent as claimed, and theanalista-*change is scoped so it does not swallow theRULEreport. But this PR's headline promise is that it will not disturb an attach client, and an ordinaryclaude --debug attach <id>orclaude attach <prefix>disturbs one. Fix the detection (or narrow the documentation to the one spelling it actually catches, which I would not accept for a stop-authority guard), add the four spellings above as failing tests, and this is a straightforward re-approve.Verdict: APPROVED
model: omniroute/deepseek-flash
head:
e01532f3e2session: homelab-2634-review-6
I reviewed this as an independent seat on head
e01532f3e21bcb7d4adca90c49f9f4b34b73c556(PR #94, baseorigin/main=945d97c), from my own worktree, not a shared one. The change adds a second kind of thing to the janitor:claude --backgroundsessions, processes with no pane that outlive their tab, plus a fleet census line, a notice policy, and aRULEreport for named herdr sessions that fit none of the allowed shapes. The bar I applied is the one that matters for this change: can anything here stop, kill or disturb a live session that should have been left alone, and can any write other thanclaude stop <id>reach the shell.What I verified myself, not trusted from the PR text. I read the whole diff against
origin/main, not against a localmain(my first pass used a stale local branch and over-attributed existing code to this PR; the corrected diff touches onlyPROTECTED_NAMES, the new background/census/notice/RULE machinery,Snapshot,_execute's revalidation andrender_report). The pane-close decision function,screen_stable, the 600-second floor and the marker branch are byte-identical betweenorigin/mainand this head — I diffed them and confirmed the PR does not editdecide()ordecide_background's siblingscreen_stableat all._executebecomes strictly stricter: revalidation now also comparesdirectoryandmarker. So the answer to "did this loosen the 600-second stillness floor on the herdr-pane paths" is a proven no: nothing in the pane close path moved, and the only new pane-path change is an extra equality check beforeherdr pane close.Allowlist probes. I drove
claude_allowed_argvdirectly over 24 argv shapes and confirmedagents --jsonandstop <id>are the only two that pass.rm <id>,rm --force <id>,daemon stop,stop --all,stop all,stop daemon,stop rm,stop attach,stop agents,stop <id> --force,stop -<id>,kill <id>,agents --json extra,agents,stop ../etc/passwd,stop a/b,stop "a b"and[]are all refused. I then replacedsubprocess.runwith a spy that raises on entry and calledClaudeCLI._runwith the destructive forms: every one raisedValueErrorand the spy recorded zero invocations, so the refusal happens before any subprocess exists.claude stop ALLis the one shape that passes the shape check, because the deny list is case-sensitive whileMARKER_NAME_REallows uppercase; that is not an escalation (it would be an unknown session id and the command errors), but the deny check should fold case — informational, non-blocking.Decision-function probes. I drove
decide_backgroundwith constructed fixtures for every refusal the ticket asked about. Confirmed STOP_CANDIDATE only on the clean case (marker +kind=background+state=done+status=idle+ live pid + allowed cwd + no attach). Each single deviation produced REPORT with the reason named and no stop:status=busy→status-busy,state=working/blocked/empty →state-*, attach client by id or by exact name →attach-client-present, no marker →no-retire-marker,El-Director/LD-Hypervisor/EL-DIRECTOR/Analista-Macro→protected-name, cwd/home/aaron/repos/intellimationor/opt/sendrast-directoror/or empty →cwd-not-allowed,/home/aaron/repos/homelabXand/home/aaron/repos/homelab-other→cwd-not-allowed(the parent test is a real containment test, not astartswith), a dead pid,pid=None,pid=True,pid=0andpid=-1→process-gone. Akind=interactiverow with a marker naming it is reported askind-interactiveand never stopped; without a marker it never enters the table at all. A path under an allowed root that is a symlink resolving outside is refused, becausecwd_allowedresolves before comparing.Liveness and attach detection are read-only.
Processes.aliveis a/proc/<pid>is_dir()test;attached_idsreads/proc/*/cmdlineand decodes it. Noos.kill, no signal, no write anywhere in the class. I confirmed against the live table:attached_ids()returned[]on the real fleet right now, and nothing in this path can disturb what it reads.Marker lifetime. I ran the full janitor in apply mode with a fake
claudeon PATH and a fake/proc. Withclaude stopfailing, the row becameSTOP_FAILED reasons=retire-marker|claude-stop-failed, the marker file was still there, and aNOTICE: claude stop failed id=9e011d8b name=epic-workwas emitted. Withclaude stopsucceeding, the single id was written to the stops log and the marker was removed. In dry-run nothing was stopped and the marker stayed. Revalidation refuses rather than closes when the marker set becomes unreadable, because the marker then reads asNoneand no longer equals the original.Census and notices.
SESSIONScounts an unreachable source as non-zero in every case I could construct:claude agentsfailing →unreachable=1, an unreadable retire directory →unreachable=1, a failed pane read →unreachable=1, andJanitor(claude=None)→unreachable=1rather than a quiet zero. I did not find a path where a failed source silently reportsunreachable=0.Tests. I ran the suite myself:
python3 -m unittest discover -s tests→ 120 tests, OK (32 intest_herdr_janitor.py, 58 intest_herdr_janitor_relief.py, 30 intest_herdr_janitor_sessions.py). The PR body says 117; the real count on this head is 120. CI on the exact head is green: runs #266 and #267 fore01532f3e2all pass.Live read-only dry-run. Isolated state dir under
mktemp -d, mode 0700, no--apply, real fleet:Zero
CLOSE_CANDIDATE, zeroWOULD_STOP, zeroNOTICE:lines. The liveel-directorpane came backreasons=protected-name|focused-paneandanalista-macrocame backprotected-name|directory-current, which is the fail-closed behaviour the operator asked for. The intellimation session is exactly the case the cwd rule exists for and it is refused three ways over.Plainly: no real background session has been stopped. Not by me, not by this code. All my stop-path evidence comes from a fake
claudeand a fake/proc. The live dry-run issued no stop. The two live background sessions are still alive after my review.Findings, none of which I consider blocking.
The attach-guard is exact-match only — medium, non-blocking.
claude attach --helpon this machine says "Part of the session name works in place of the id". The detector collects the raw args afterattachand compares them for equality against the session id or the whole name. I fed it a fake/procwith six shapes and it caughtclaude attach <id>via the symlink and via the realclaude.exepath, and the full name; it did not catchclaude attach Ghosttly(a documented partial name),claude attach 9e01(an id prefix), orclaude attach <id>with flags beforeattach. In those cases a human is attached and the guard is inert, so the session remains a STOP_CANDIDATE. The consequence is bounded: a stop still requires an operator retire marker for that exact id or name, andclaude stopkeeps the conversation resumable, so the worst case is a live terminal closing under a watching operator, not lost work. Not blocking, but the comment inProcessesand the doc line "an attach client is something a person is looking at" overstate what the exact-match check proves, and matching on containment rather than equality would close it.The protected-name list does not cover every Director spelling the code itself accepts — medium, non-blocking.
DIRECTOR_NAMESincludesel director,jesus-delivery,jesus-directorandsendrast-director;PROTECTED_NAMEScovers onlyld-hypervisor,el-directorand the newly addedanalista-macro. I confirmedel director(space) reaches STOP_CANDIDATE on the background path whileEl-Directoris refused. It is only reachable through a by-<id>marker, sinceMARKER_NAME_REforbids spaces soretire/el directorcannot be created — which is why I am not blocking. But the two lists should be one set, because "the single source of truth for protected names" currently disagrees with the code's own definition of who a Director is, and session names on background sessions are free text.The
RULEanalyst suppression is prefix-based and slightly broad — low, non-blocking.analista-as a bare prefix silencesanalista-,analista-ghost-2andanalista-macro junk copy. That matches the documentedanalista-<x>grammar and, importantly, this branch can only ever suppress a report line, never authorise or refuse a close, so the failure direction is a non-conforming session going unlisted rather than anything being touched. Fine to leave.A failed pane read is counted but not noticed — low, non-blocking. With
herdr=1 named=1 unreachable=1and a failingagent read, the report produced noNOTICE:line, because the notice only fires when the pane actually reached a close branch. The doc's Notices section promises a notice when "a source could not be read". The census still tells the truth, so this is a documentation-versus-behaviour gap, not silence where it matters.The roster is not consulted on the new stop path — informational.
decide_backgroundtakes noDirectory, so the operator's roster, which is otherwise a cross-check on every close, constrains nothing here. Given that the roster's aliases are role names and background session names are free text, it would rarely fire; worth knowing rather than fixing now.The "not armed" framing is stale for this fleet — informational but worth saying out loud.
docs/herdr-janitor.mdopens with "The implementation PR does not install or enable the timer and does not authorize a real close", and the PR body says the same. That is already untrue here:herdr-janitor.timeris enabled and has been running--applyevery 15 minutes since 19:05Z with a valid activation file, and the deployed copy at~/.pi/agent/extensions/scripts/herdr_janitor/janitor.pyis byte-identical toorigin/main. So the deployed code cannot stop a background session today, but the deploy step is routine — merging this and refreshing the deployed copy puts background stops live on a 15-minute schedule with no further decision. The retire marker stays the explicit operator act, which is why I still do not treat this as blocking; the doc sentence should be corrected so nobody reads "not armed" as a safety property of this change.Two smaller notes. A duplicate id in
claude agents --jsonyields two STOP_CANDIDATE rows and would issue two stops, but the per-row revalidation turns the second intoSKIP_CHANGEDbecause the session is already gone, so it is benign;parse_claude_sessionscould still dedupe. AndConfig(directory=None)(library use only, not reachable from the CLI, which always resolves a path) yields a readable roster with no rows, so the roster gate passes silently;parse_directory(None)returningreadable=Trueis the one shape I would tighten.Verdict: APPROVE. The risk this change carries — stopping a live background session — is gated behind an explicit operator retire marker, and every doubt I could construct is report-only with the reason named. The write surface is exactly two argv, refused before a subprocess exists. The pane-close path, including the 600-second stillness floor, is untouched. My findings are about the completeness of two guards under conditions that each already require an explicit operator act, so I am not blocking on them; I would want the attach matcher widened and the director name sets merged before anyone relies on this in a hurry.
Verdict: APPROVED
model: omniroute/deepseek-flash
head:
bca96b3f31session: homelab-2634-review-7
How this was verified
Fresh review at head
bca96b3f3195c1fe772e345d8315431bcc10f02a(baseorigin/main=945d97c), read from the git object in a privategit worktreeunder/tmp. Every decision function was imported and driven with fake process tables (Processes.proc_rootpointed at a temp tree) — 46 probes, no real/procdependency, and noclaude stop,herdr close, marker or session was touched at any point.Probes 1–8 requested by the brief, and their results:
attach-client-present):claude attach <id>,claude --debug attach <id>,claude --verbose attach <id>,sh -c 'claude attach <id>',bash -lc 'exec claude attach <prefix10>', id prefix (8 chars), full UPPERCASE id, NUL-separated argv (claude\0--debug\0attach\0<prefix6>), the session name, andclaude --debug attach --force <id>. 10/10.stat) both carryingattach <id>in their cmdlines, the scan returns an empty attach set and the session remains aSTOP_CANDIDATE; a third unrelated pid carrying the same text is caught. The exclusion is by pid and parent, not by text./procthat cannot be listed ⇒unreachable=True, reasonattach-scan-unreachable,unreachablecounted in the census, andNOTICE: source unreadable attach-scan: …. Acmdlinethat exists but is unreadable (mode 000) ⇒ the same. A failed pane read (texts[pane]=None) ⇒ noCLOSE_CANDIDATE, counted, andNOTICE: source unreadable pane-read: <pane>."",unknown,working,busy,IDLE,"idle ",doneall refuse withstatus-<value>;stateof"",running,busyrefuse withstate-<value>. Only exactlyidle+doneis safe.sendrast-director,Sendrast-Director,jesus,jesus-anything,El Director,El-Director,EL_DIRECTOR,él director,LD-Hypervisor,ld.hypervisor,Analista-Macro— every one isREPORTwithprotected-nameon the background path andREPORT/protected-nameon the herdr-pane path, even with the name itself as a retire marker. One list (PROTECTED_NAMES+jesusprefix) folded throughname_key, no second narrower list.kind=background,state=done,status=idle, live pid, allowed cwd ⇒STOP_CANDIDATE. Without a marker, with a foreign cwd (/tmp/elsewhere), or withkind=pane, no stop and (for the last) not even a row.claudeon the path that wouldtoucha sentinel, all ofrm <id>,rm --all,daemon stop,stop --all,stop,stop <id> --force,stop -x,stop all,stop attach,attach <id>,agents,agents --json --all,stop <id> extra,kill <id>,--debug stop <id>,stop ../etc,stop ""raiseValueErrorand the sentinel never appears.agents --jsonandstop <8-hex id>are the only two accepted forms; the module contains noos.kill, noSIGTERM, noPopen.screen_stableanddecideare byte-identical toorigin/main(945d97c); the only herdr-path change is_base_protectionsswitching tois_protected_name(strictly wider protection).--min-successor-seconds 120still exits 2 withrefusing successor observation below 600 seconds, and--applywithout an activation file is refused.Test suite
python3 -m unittest discover -s tests -p 'test_herdr_janitor*.py'⇒ 131 tests, OK (2.3 s).Live read-only dry run (isolated state dir)
Zero
CLOSE_CANDIDATE, zeroSTOP_CANDIDATE, noNOTICEline. The two real background rows:83e833d6(cwd/home/aaron/repos/intellimation,state=blocked, no pid) ⇒REPORT reasons=cwd-not-allowed|process-gone|status-unknown|state-blocked|no-retire-marker;9e011d8b(cwd/home/aaron/repos/homelab,state=done status=idle,process=alive) ⇒REPORT reasons=no-retire-marker. That second row is the healthy case and it is held by the marker alone — as designed, and worth the operator knowing.No real background session has been stopped by this code, and none can be until it is installed. Live context I checked: the user timer
herdr-janitor.timerruns--applyevery 15 minutes against the deployed copy at~/.pi/agent/extensions/scripts/herdr_janitor/janitor.py, which is md5-identical toorigin/main(c454094c…, no background code at all); at 21:00:07Z that deployed copy closed one real pane (wT:p13 infra-stab-orchestrator-5,evidence=retire-marker) and the retire dir is now empty. So the background path has never executed against a real session, and the moment this PR is installed the same armed 15-minute timer starts exercising it unattended.Findings
F-1 — attach reference floor (severity: low; blocking: no). The content scan is a real fix and defeats every spelling I could construct (probe 1). Two spellings still escape: an id prefix shorter than
MIN_ATTACH_REFERENCE = 4(claude attach 9e0), and an id glued to a flag with=(claude --session=9e011d8b attach—REFERENCE_STRIPdoes not strip=). I could not establish reachability for either without risking a real attach:claude attach zzandclaude attach zzqboth resolve (the CLI complains about no job matching, not about a short argument), so a 2–3 character prefix is not rejected by length, but I would not point one at a live id to see whether id prefixes resolve at all. What I can say is that the code comment atMIN_ATTACH_REFERENCEis inverted: it says a sub-4-character token "is only ever a false positive (the safe direction)", but skipping the match is the unsafe direction — a real attach client that names the session goes unseen. Lowering the floor costs only extra refusals (fail-safe), so I recommend doing exactly that and correcting the comment, in a follow-up. Not blocking here because every concrete spelling in the brief refuses, and the guard is one of several (state==done,status==idle, marker, live pid, allowed cwd).F-2 — the doc understates the guard it documents (severity: low; blocking: no).
docs/herdr-janitor.mdstill states that a background stop requiresstatus != "busy"andstate == "done", and describes attach detection as "claude attach <id>in anycmdline". The code now requires exactlyidle(F2 of this PR) and detects attach by scanning forattachplus a full id / ≥4-char prefix / session name, with an unreadable scan refusing every stop. The code is stricter than the doc in both places, so this is not a fail-open — but a supervisor reading the doc would believeworkingsessions are stoppable, and a future maintainer "aligning" code to the doc would erode the guard. The protected-name list in the same section (ld-hypervisor,el-director) also omitssendrast-director,analista-macroand thejesusprefix. Please fix the bullets in a follow-up.Residual risks I could not close
/proc; an operator who attaches from inside an already-running interactive Claude process (the agent view's ownattach) would not appear as a new cmdline. Whether that shows up inclaude agents --json(state/status) is untested — I would not attach to a live session to find out — so treat the attach scan as the primary barrier, not as one of two./procis only the local pid namespace, and a recycled pid can make a dead entry look alive (documented; the stop then fails and is reportedSTOP_FAILED, never retried).no-retire-marker— fail-safe, but silent; theSESSIONS … unreachable=census is the only signal, and it counts sources, not markers.Trusted, not verified
claude agents --jsonschema: I read it live (keys observed:id,sessionId,name,kind,state,status,pid,cwd,startedAt), andparse_claude_sessionsconsumes exactly those;idis preferred oversessionIdwhen both exist, and the 8-charidis a prefix ofsessionId, so a human attaching with the full UUID is still caught as a substring.claude stop <id>succeeds and keeps the conversation resumable: asserted by the CLI's own--help, never executed by me or by this code.is_protected_name's accent folding (él director) is whatnormalise_cellactually produces — I verified it through the decision output, not by unit-reading the fold.I approve this head. The F1 fix is complete for every spelling I was asked to probe and materially stronger than what the previous round rejected; F-1 and F-2 are real but narrow, and both fixes are strictly safety-increasing.
Verdict: REQUEST_CHANGES
model: omniroute/deepseek-flash
head:
bca96b3f31session: homelab-2634-review-8
Fresh, independent review of
plane/HOMELAB-2634-janitor-backgroundatbca96b3f3195c1fe772e345d8315431bcc10f02a(baseorigin/main=945d97c), from my own detached worktree under$(mktemp -d)— not a shared checkout. Sessionhomelab-2634-review-8, modelomniroute/deepseek-flash. I did not read the other seats' bodies before forming this verdict.I did not run
--apply, and no real background session was stopped by me. I did not even attach to one. The only things I ran against the live fleet were reads:herdr pane list,claude agents --json,claude attach --help,claude stop --help, one dry run into an isolated state dir, and a read of/proc.Verified myself (not trusted from the PR text)
The attach guard, live
/proc, real processes. I built a fakeclaudeonPATHand a fake/procand droveProcesses.attachments/names_sessiondirectly, then repeated the important cases against the real/procwith real live child processes. Detected: plainclaude attach <id>;claude --debug attach <id>(flag before the verb);sh -c '… attach …';bash -lc …;claude ATTACH <id>;Claude Attach; id prefixes of 4 and 6 chars;claude reattach <id>(attachinside a longer token — over-detection, the safe direction);claude agents attach --id <id>;env FOO=1 claude attach <id>;nohup …;./claude attach <id>; the full session name; andclaude at"tach" <id>/claude at$'tach' <id>— both of which the scan text misses but which is recovered because bash removes the quotes beforeexec, so the child's owncmdlinereads… attach …. That recovery is real: I confirmed it live with real processes, so the spellings that defeated earlier heads are closed.Fail-closed holes are real. An unreadable
/proc/<pid>/cmdline(I madecmdlinea directory) givesunreachable=Trueand reports every session as attached; an unlistable/procdoes the same.unreachablethen increments in the census.The restore of the 600-second floor is untouched.
git diff 945d97c bca96b3 -- scripts/herdr_janitor/janitor.pyremoves no line mentioningmin_successor_seconds,screen_stable,OLD_SAFE_STATUSES,NEW_SAFE_STATUSES,NEVER_WORKSPACESorDEFAULT_WORKSPACES.screen_stableis still required on the successor path and on the retire-marker path, still compares the ~40-line hash and still demandsnow - hash_since >= 600.--min-successor-seconds 599still exits 2 withrefusing successor observation below 600 seconds. The only removals are the old two-memberPROTECTED_NAMESliteral andnormalise_cell-based protection, both replaced by a strictly wider rule. Nothing #93 fixed was loosened.The argv allowlist cannot be widened. 22 shapes driven through
claude_allowed_argv: exactly["agents","--json"]and["stop",<one token>]pass. Refused:rm <id>,daemon stop,stop --all,stop all,stop daemon,stop agents,stop attach,stop <id> --force,stop --force <id>,stop "",stop -x,stop "abc; rm -rf /",stop "abc && rm -rf /",stop "$(id)",stop ../../etc/passwd,stop "9e011d8b\n",kill <id>,agents,agents --json -a.ClaudeCLI._runre-checks the allowlist, so the subprocess is unreachable by another path. There is noshell=True, noos.kill, nosignal, noos.remove, noshutil.rmtree, no.kill(and nopkillanywhere in the file; the only twosubprocess.runcalls are the two allowlisted argv.Read-only liveness.
Processesonly usesis_dir(),iterdir(),read_bytes(),read_text(). Nothing is signalled, so noticing an attach client cannot disturb it. The dry run wrote exactlyreport.txtandjanitor.loginto the isolated state dir and noseen.json.Marker lifecycle. With a fake
claudewhosestopraises:action=STOP_FAILED,reasons=…|claude-stop-failed, and the marker is still present. With astopthat succeeds:action=STOPPED, marker removed. So the marker survives a failed stop and is removed only on success — as documented.Notices. Four ordinary refusal rows (protected name, attach client present, no marker + foreign cwd,
status-busy) plus a protected pane refusal produce[]— silent, as promised. A failedclaude stop, aSKIP_CHANGED, a failed pane read, an unreadableclaude agents --json, an unreachable attach scan, an unreadable retire dir and an unreadable roster each produce their ownNOTICE:.unreachableis never a silent zero. Each source broken one at a time: claude unreadable →unreachable=1; attach-scan unreachable →1; markers unreadable →1; roster unreadable →1; one failed pane read →1. Clean run →0. Alist_panesfailure prints to stderr and exits 1 with no census line at all, which is loud rather than quiet.Protected names, every spelling I could construct. Protected:
sendrast-director,Sendrast-Director,SENDRAST_DIRECTOR,sendrast director,jesus,jesus-director,Jesús-Director,jésus-director,JESUS-orchestrator-9,El Director,el-director,EL.DIRECTOR,el__director," El Director ",LD-Hypervisor,ld_hypervisor,Ld.Hypervisor,Analista-Macro,ANALISTA_MACRO.name_keyfolds case, accents, spacing and the- _ .separators, and thejesusprefix protects the whole person family. On the pane path a liveel-directortab reportsreasons=protected-name|focused-pane|role-not-relievable.Tests, CI, dry run
python3 -m unittest discover -s tests -p 'test_herdr_janitor*.py'→ 131 tests, OK, 0 failures, 0 errors.py_compileclean. CI on the exact headbca96b3: runs #269 (python, pass) and #268 (all 15 jobs, pass). Nothing red at this head.Live read-only dry run, isolated
mktempstate dir, real herdr and realclaude:Zero
CLOSE_CANDIDATE/WOULD_CLOSE(nineREPORTrows,summary REPORT=9 total=9), zeroNOTICE:, zeroRULElines. The one live, alive,idle/done, homelab-cwd background session (9e011d8b) reportsreasons=no-retire-marker— i.e. exactly one marker away from being stoppable, which is why F1 below matters.Findings
F1 — BLOCKING. The attach guard misses every partial session reference, including the form the CLI documents.
claude attach --helpon the real binary (v2.1.318) says:Processes.names_sessionmodels only: the full id as a substring, an id prefix of ≥4 chars, and the full name as a substring. It does not model part of a name. Against a fabricated copy of the live session (name=Ghosttly-in-the-shell Android port of rootshell,status=idle,state=done, alive, allowed cwd, retire marker present), each of these producedaction=STOP_CANDIDATEwith no attach reason at all:claude attach Ghosttly-in-the-shell(first word of the name)claude attach Ghostt(six chars of the name)claude attach "Ghosttly-in"claude attach(bare — the agent picker)while
claude attach <full name>andclaude attach 9e011dcorrectly gaveREPORT+attach-client-present. In--applymode each of the missed forms would have issuedclaude stop <id>on a session a human is looking at. This is the exact invariant the PR claims to have repaired ("an attach client is something a person is looking at: none of those exist here"; "a guard a flag defeats is not a guard") and it is still defeated — not by a shell trick, but by a human typing the first word of a long name, which is what the CLI's own help invites for names like the two live ones. The test suite only covers the full-name case (("name", ["claude", "--debug", "attach", "epic-work"])), so nothing catches this.Fix I would accept: also treat any token ≥4 chars that is a prefix of
name_key(session.name)as a reference, and fail closed (unreachable) for anattach-bearing command line that cannot be attributed to any session — a bareclaude attachnames someone, just not in its argv. The bounded stop is recoverable (claude stopkeeps the conversation), and nothing is armed until the activation file exists, which is why I ask for a fix and not a redesign; but the guard must be right before the timer is ever armed.F2 — Low, non-blocking. Two documented safeguards are described more loosely than they are implemented.
docs/herdr-janitor.mdand the PR description say a stop requiresstatus != "busy"and lists the protected set asld-hypervisor/el-director. The code is stricter than the doc (status == "idle"exactly, and six protected spellings plus ajesusprefix). Stricter-than-documented is the safe direction, so this is not a risk to sessions — but a reader of the doc would believe aworkingbackground session is stoppable, and would not know thatsendrast-director,analista-macroandjesus-*are protected.F3 — Low, non-blocking.
el–director(en dash) and hybrid role names are not protected.name_keyfolds- _ .but not–/—, soel–directoris unprotected; and because protection is tested on the whole name,el-director-orchestrator-1andsendrast-director-orchestrator-1are a relievable family and do reachCLOSE_CANDIDATEon the pane path (I reproduced both;jesus-director-orchestrator-1is still caught by the prefix, andld-hypervisoris caught because it fits no role family). The live fleet's director tab is namedel-director, so I do not believe this is reachable today; it is a hardening gap against the stated contract, not a demonstrated stop.F4 — Low, non-blocking. Exit status does not reflect a failed stop.
main()returns 1 only for a paneCLOSE_FAILED. A backgroundSTOP_FAILED,SKIP_CHANGEDorSTOPPED_MARKER_DELETE_FAILEDstill exits 0. TheNOTICE:line carries it for a human, but the machine-readable signal a timer or alert would read says success.F5 — Informational.
analista-*is a blanketRULEexemption.rule_violationreturnsNonefor any folded name starting withanalista-, soanalista-<anything>never appears as aRULErow even when it is not the operator's named analyst.RULEis report-only, so nothing can be stopped through it; the cost is reporting precision, which the operator can tighten if they want it.What I trusted rather than proved
claude attach's acceptance of a partial name is the CLI's own help text; I did not attach to a live session to observe it (I judged touching9e011d8bto be the exact disturbance this review is meant to prevent). The dashless-uuid form (claude attach 9e011d8b9e01…without dashes) is also missed bynames_sessionand equally unverified against a real session — if the CLI resolves it, it is the same hole by another route.claude stop <id>'s own behaviour (conversation kept, resumable) is documented inclaude stop --helpand not exercised. The pid-reuse caveat is the PR's own and remains true.The change is close: the argv allowlist, the fail-closed scan, the marker lifecycle, the census, the notices, the protected family and the untouched 600-second floor are all what they claim to be, and I verified each of them rather than reading them. I am asking for changes for one reason: the attach guard repairs the argv axis and still misses the reference axis, and that is the one thing this change must not get wrong.
Verdict: APPROVED
model: omniroute/deepseek-flash
head:
bcb8ef0e02session: homelab-2634-review-9
base:
945d97c786(origin/main)I attacked this head as an adversary: my goal was to stop a background session that
should have been left alone. I did not get one. Every fabricated process table that
places a person on a session refuses the stop, and every table I could not resolve
refuses everything rather than nothing.
Verified (I read the code and ran it)
Isolated worktree
/tmp/rev94-bcb8ef0at the head above; all probes use a fabricated/proc(Processes.proc_rootpointed at a temp tree, everycmdline/statwritten bythe probe) and fabricated
claude agents --jsonrows. No real session was named,attached to, or stopped.
F1b — partial attach references. 24 of 25 attack lines refuse the stop; the 25th is
the intended one (an attach line naming a different known session marks that session,
not mine, which is the documented contract).
claude attach Ghosttly-in-the-shell(full name)attach-client-present— refusedclaude attach Ghostt(partial name)claude attach the-shell/in-theclaude attach 'Ghostt'/"Ghosttly"(quoted partial)9e)attach-client-unidentified-> every session attached, refusedsh -c "claude attach --name <full name>"(name inside token)claude ATTACH <name>(uppercase)claude --debug attach 9e0117aash -c '... attach ...',env FOO=1 ...,nohup ...,sudo -u aaron ...claude attach 9e0117aa --verbose(trailing flag)claude attachattach-client-unidentified-> refusedclaude attach --json(flag-only)attach-client-unidentified-> refusedclaude attach some-random-session(unknown name)attach-client-unidentified-> refusedclaude attach th(2-char fragment)attach-client-unidentified-> refusedclaude attach ''(empty arg)attach-client-unidentified-> refused/usr/local/bin/claude attach 9e0117aa(path component)claude attach <other session id prefix>Ambiguity refuses everything. An unresolvable attach-bearing line sets
unidentifiedandAttachmentScan.attached()then answersTruefor every id, sodecide_backgroundrefuses all of them withattach-client-unidentified. Verified: withtwo sessions in the roster and one bare
claude attachline, both sessions carried therefusal. Two sessions with same-family names (
sup-a-orchestrator-1,sup-b-orchestrator-1) plus the ambiguous fragmentattach orchestratormarks both.F3 — protected names. 31 of 31 spellings protected, including every name the ticket
names as a collision:
el-director,El-Director,EL_DIRECTOR,el.director,el director, double space, leading/trailing space,El\u2013Director(en dash),em/box/figure/hyphen dashes,
ld-hypervisor,LD-Hypervisor,ld\u2013hypervisor,ld-hypervisor-2,ld-hypervisor-99,sendrast-director,sendrast-director-x,jesus,jesus-director,jesus-anything-1,Jes\u00fas-Director,el-director-orchestrator-1,el-director-orchestrator-9,ld-hypervisor-orchestrator-2,sup-el-director-2,analista-macro. A protectedbackground session with a marker is refused by both marker spellings (name and id):
reasons=protected-name|retire-marker-held. The collisionel-director-orchestrator-1is caught by
_base_protectionson the pane path too (protected-name-> successorbranch takes
successor-close-held), andrule_violation()returnsNonefor it, so itgets no RULE line either — the two paths agree.
Allowlist — refusal precedes any subprocess. 19 of 19 forbidden argv refused by
claude_allowed_argvbeforesubprocess.runis reached (_runraises first):rm <id>,stop all,stop --all,stop -x,stop <id> --force,stopalone,agentswithout--json,agents --json --x,daemon stop,stop daemon,stop attach, and injectionshapes
stop ../../etc/passwd,stop 'x;rm -rf /',stop '$(id)',stop 'a b',stop '',stop <200 chars>,attach <id>. Onlyagents --jsonandstop <one id>pass.
Read-only on every path. AST scan of the module: no
os.kill/os.killpg/signal/system/popen/rmtree/remove/rename/chmod; the onlyos.replaceis insideatomic_write, and the only.unlink()sites are the atomic-write temp file and the tworetire-marker deletions that follow a successful close/stop.
Processesonly callsiterdir,read_bytes,read_text,is_dir.Marker survives a failed stop.
_execute_background:STOP_FAILEDleaves the marker(even with
--applyand a valid activation file:marker_exists=True),STOPPEDremovesit (
marker_exists=False); a failed delete becomesSTOPPED_MARKER_DELETE_FAILED+NOTICE: retire marker delete failed. Revalidation readsa fresh roster and re-decides before the single write; a marker source that became
unreadable yields
SKIP_CHANGED, not a stop.600-second stillness floor is identical to
origin/main.screen_stable,successor_age,update_seen,text_hash,live_work,load_seen,save_seen,valid_first_seen,directory_gate,directory_successor_stateare byte-identicalbetween
origin/mainand this head (extracted and diffed). A 36-cell differential overmin_successor_secondsin {0,1,599,600,601,3600} x screen age in {0,1,599,600,601,5000}produced zero divergences. Defaults stay 600 (
Config, argparse), and--min-successor-seconds 0and--min-successor-seconds 599still exit 2 with"refusing successor observation below 600 seconds". The pane-path diff is limited to
is_protected_namereplacing the narrower equality check (strictly more protective) andthe report getting
SESSIONS/BACKGROUND/RULE/NOTICElines.NOTICE discipline and the census. Fabricated-run probes: a plain
REPORT(no marker) -> 0 notices; a quiet refusal (
attach-client-present,attach-client-unidentified,status-busy,cwd-not-allowed,protected-name) -> 0notices; a real failure (
claude agents --jsonraising,claude stopraising, anunreadable
/proccmdline, a failed pane read) -> exactly one notice naming the source,and
unreachableincrements (1, 1, 1).unreachableis an explicit sum overclaude-agents, the attach scan, the retire dir, the roster, and every failed pane read,so it cannot collapse to a silent 0; a
claudesource that is not wired at all counts asunreachable too.
Test count
python3 -m unittest discover -s tests -p 'test_herdr_janitor*.py'-> Ran 138 tests …OK (0.40 s) at
bcb8ef0.Dry-run (read-only, isolated state dir)
python3 scripts/herdr_janitor/janitor.py --dry-run --state-dir <mktemp>:Zero
CLOSE_CANDIDATE, zeroWOULD_CLOSE, zeroWOULD_STOP, zeroNOTICE:lines, andthe only filesystem effect was
report.txt+janitor.loginside the isolated state dir.The live
el-directorpane reportsprotected-name|focused-pane.analista-macroisdirectory-current. No real background session has been stopped by this code, ever: thedeployed
herdr-janitor.timercopy at~/.pi/agent/extensions/scripts/herdr_janitor/janitor.pyis the
origin/mainbuild (it prints noSESSIONS/BACKGROUNDlines), soclaude stophas never been issued from this repository. The first real stop will happen only after this
lands, is deployed, and someone creates a retire marker.
Trusted, not verified
claude attach's documented rule "part of the session name works in place of the id" istaken from the ticket and the docstring; I did not run
claude attachto re-derive whichfragment forms the real CLI accepts. The guard is conservative against that rule, not
derived from the binary.
claude agents --jsonfield semantics (kind,state,status,pid,cwd) aretrusted; the schema I exercised is the one the PR's own parser accepts.
--applyagainst a real session, and I did not create a real retiremarker: every
STOPPED/STOP_FAILEDprobe used a fabricated roster and a spy stop.Findings (none blocking)
docs/herdr-janitor.md. The background-stop listsays
status != "busy" and state == "done"; the code requires exactlystatus == "idle"(stricter,BACKGROUND_IDLE_STATUS). The document understates theguard it describes. Fix with the next touch; it cannot cause a stop.
cmdline, so an unrelated process blocks every background stop for its lifetime. Aline whose argv merely contains
attachand resolves to no known session —tmux attach,herdr attach, evencat attach.log— setsattach-client-unidentifiedand refusesall stops, not just one. That is the correct fail-closed direction and nothing was
closed by it, but it means the feature can silently never fire on a host that has such a
process. I verified this with a fabricated
cat attached.log. Worth a follow-up ticket(scope the token to a
claude/attachargv shape, staying fail-closed) rather than ablock on this PR.
name_key. Separator, case,accent, and the whole Unicode dash family are folded; these are not:
eldirector(no separator at all),el\u200bdirector(zero-width space),el\u00b7director(middle dot),\u0435l-director(Cyrillic е),el-direct0r. Asession deliberately named with a zero-width space would escape the protection. A marker
is still required to reach a stop, and every spelling the ticket names is covered, so I
do not block; the folding table deserves the next ticket.
retire/<id>with noprotected-namecheck, because there is no name to fold. Marker-by-id is explicitoperator authority, so this is the documented contract, not a leak.
claude stop.directory-unreadable/directory-currentrefuse every pane close, butdecide_backgroundnever consultsthe
Directory. The bounds on a background stop are marker +kind+ protected name +liveness +
status/state+ cwd + attach scan. A background session running a numberedsuccessor role could therefore be stopped by a marker without a roster cross-check; the
marker is still explicit human authority. Worth stating in the doc if it is intended.
Verdict
APPROVE. Both fixes the earlier reviewers asked for are present at this head and survive an
adversarial read: partial and quoted attach references, id prefixes of any resolvable
length,
ATTACH/--debug/wrapper forms and the whole unresolvable class all refuse, andthe protected-name check now contains rather than equals on a separator-folded key so the
el-director-orchestrator-1collision is protected on every path. The 600-second stillnessfloor that made the pane janitor safe is provably unchanged. CI is green on this exact head.
The findings above are follow-ups, not blockers.
Verdict: APPROVED
model: omniroute/deepseek-flash
head:
bcb8ef0e02session: homelab-2634-review-10
tier: premium
I reviewed head
bcb8ef0e02cc2a8bb52914cad124947101a86717(baseorigin/main=945d97c) from the git object and a throwaway worktree, as an independent session. Verdict: APPROVE. Everything the head claims is fixed, is fixed; the attach guard refuses every form I could construct; the protected director family cannot be closed under any spelling I probed, on either path. I record three findings below; none of them stops a session that the operator's limits protect, and none is a merge blocker. F-A must be closed before the timer is ever activated.No real background session has been stopped by me. Every probe used a fabricated process table, a fake
claude, and a fakeherdr; the only live work was one read-only dry run in an isolated state directory.Verified, not trusted
/proctables, each with one livekind=background,state=done,status=idle, allow-listed-cwd session carrying a retire marker, so that anything the guard misses reachesSTOP_CANDIDATE. All 20 refused: full id, full name, 3-char name prefix, mid-name prefix, 3-char id prefix, 1-char id prefix (too short to match a name, so the line resolves to nobody and the ambiguity rule fires), quoted fragment, upper-case trick,sh -cwrapper,env FOO=1wrapper,nohup,--debugbefore and after the subcommand, bareclaude attach, flag-onlyclaude attach --json, unknown name, name suffix only, glob token, upper-case-with-dashes. Seven of those are refused byattach-client-present(the line matched a fragment), thirteen byattach-client-unidentified(the line named nobody we know and every session was marked attached). Both directions of the refusal hold a stop for the cycle and leave the marker in place.unidentifiedand refuse every session. A token split across argv cannot hide afterexec(the words are rejoined incmdline).complete=False(an unreadablecmdline) marks every session attached and addsunreachable=1.claude agents --jsonfailing, a failed pane read, an unreadable roster and an unreadable marker directory each add tounreachableand each emit their ownNOTICE:; a normal refusal (no marker, protected name, live work, unstable screen, ambiguity) emits none. Verified on both the census line and the tail.el-director,El-Director,EL_DIRECTOR,el director,El–Director(en dash),El—Director,el‐director/el‑director,el.director,el-director-orchestrator-1,el-director-orchestrator-12,ld-hypervisor-2,sendrast-director-x,analista-macro,Analista-Macro,jesus,jesus-voice,Jesús-director, and a padded" EL-DIRECTOR ". On the herdr-pane path those panes reachREPORTwithprotected-name, neverCLOSE_CANDIDATE, even with a retire marker present and a 600-second-stable screen. On the background path the same names refuse withprotected-nameeven when the retire marker is named after the agent id, so a marker cannot launder a protected session. Names outside the family (sup-gits-e1-5,gits-e0r-orchestrator-4,analista-ventas,epic-work) stay eligible, so the guard did not simply block everything.claudeaccepts exactlyagents --jsonandstop <one id>; refused:stop --all,stop all,stop daemon,stop stop,stop attach,stop <id> <id>,stop <id>;rm -rf /,stop $(id),stop <id> --force,rm <id>,daemon stop,logs <id>.herdraccepts exactlypane list,agent list,pane close <id>,agent read <id> --source recent-unwrapped --lines 240 --format text; refused:server stop, barepane close,kill,agent prompt,upgrade, and a shortenedagent read. There is no source line containingos.kill,signal.,SIGTERM,SIGKILL,kill(,pkill,terminate(,rmtree,os.system,shell=True, orshutil.— so no path signals anything or writes anywhere butreport.txt,janitor.log,seen.jsonand a marker unlink.STOP_FAILED, marker kept,NOTICE: claude stop failed, nostopargv reached the fake (worst case:claude stopruns and fails, nothing else). Stop succeeds →STOPPED, marker deleted, exactly onestop <id>call. Same for panes:pane closeraising →CLOSE_FAILED, marker kept, notice; success →CLOSED, marker gone.origin/main.screen_stable,successor_age,update_seen,LIVE_WORK_RES, the--min-successor-secondsparser default (600),Config.min_successor_seconds = 600, andmain()'sif args.min_successor_seconds < 600refusal are unchanged blocks. Thedecide/_base_protectionsdiff touches only the protected-name call, and widens it.Pathparents, not string prefixes:/home/aaron/repos/homelab-forkand/home/aaron/repos/homelab/../intellimationare refused, as are/home/aaron/reposand"".busy,working, empty,state=runningand empty state; only exactidle+donegoes on to the marker check.Tests and CI
138 tests, all passing, with the repo's own command (
python3 -m unittest discover -s tests): 32 + 58 + 48 acrosstest_herdr_janitor.py,test_herdr_janitor_relief.py,test_herdr_janitor_sessions.py. CI on the exact headbcb8ef0e02cc2a8bb52914cad124947101a86717: run #270 (14 jobs,python,pr-title, and every gated package) and #271 (python) are both green. No red job on this head.Dry run (read-only, isolated state dir)
SESSIONS herdr=9 named=5 background=2 background-alive=1 unreachable=0Zero
CLOSE_CANDIDATE, zeroWOULD_STOP, 9REPORTrows, noNOTICE:. The two background rows areIntellimation SWOT and AI strategy(foreign cwd, blocked, gone — refused four ways) andGhosttly-in-the-shell Android port of rootshell(done/idle, alive, allow-listed cwd — held only byno-retire-marker, which is the documented authority model). The live panes namedel-director(protected-name|focused-pane) andanalista-macro(protected-name|directory-current) are refused on the pane path too. Onlyreport.txtandjanitor.logwere written.Findings
F-A — medium, not a merge blocker, must be fixed before activation. The background path never consults the operator's roster:
decide_backgroundhas noDirectoryparameter, anddirectory_gateis only applied indecide. With--directory /nonexistent/directorio.md, the report printsdirectory=unreadable EVERY close is refusedand every pane is refused withdirectory-unreadable, while the background row still readsaction=WOULD_STOP reasons=retire-marker. That is false againstdocs/herdr-janitor.md("directory-unreadableanddirectory-currentblock every close path") and against the header line printed intoreport.txt. This is the file's own stated principle — "a name protected on one path and closable on another is not protected at all" — applied to the roster instead of to a name. The absolute limits still hold on that path (the director family is protected by name, and a marker is still required), which is why I do not block the merge; but the timer must not be enabled while a stale or renamed roster silently stops nothing on one path and everything on the other. Suggested fix: threaddirectoryintodecide_background, refuse onunreadable, and refuse a session the roster names as its role's current session, exactly asdirectory_gatedoes.F-B — low.
Processes.attachmentsexempts a line whose pid is the session's own pid or its direct parent, and does so before the ambiguity test:if any(pid in pids for pids in own.values()): continue. An attach-bearing line sitting at either pid therefore neither marks a session attached nor setsunidentified. My probe: one session (pid 4001, ppid 4000) and the lineclaude attach <id>at pid 4000 →STOP_CANDIDATE.test_the_sessions_own_process_still_never_sets_the_flagasserts this deliberately (with--attach-capablein the parent's argv). Exempting the daemon's own pid is necessary — its argv may legitimately contain the token — but the parent exemption is not: a parent whose argv saysclaude attach <id>is the attach client, and the only way to reach it is a pane shell thatexecs into the attach client (or a recycled ppid). Narrow, pre-existing, and given the operator's "never disturb an attach client" absolute I would tighten it: keep the own-pid exemption, and for the parent build the same reference test before exempting it, or setunidentifiedwhen an exempt line references the session by fragment.F-C — low, informational. The retire-marker namespace is shared between the two paths with no namespace separation: a marker named after a herdr role (
retire/sup-gits-e1-5) also authorises aclaude stopof a background session that happens to carry the same name. My probe:STOP_CANDIDATE. This is documented ("a marker atretire/<name>orretire/<id>") and names are unique in the fleet today, so it is a note rather than a defect; a<kind>-prefix on marker names would remove the coupling.Nits.
docs/herdr-janitor.mdstates the stop condition asstatus != "busy" and state == "done"; the code requires exactlystatus == "idle". The code is the stricter of the two, so the doc understates the guard. Separately, the background path has no 600-second stillness floor (onlydone+idle); documented as such, andclaude stopkeeps the conversation resumable, so the blast radius of a wrong stop here is small — but it is a real asymmetry with the pane path worth stating in "What this cannot prove".What I could not prove
The real semantics of
claude attach's partial-name resolution — I tested the code against the documented claim, never against the real binary, and I deliberately did not attach to any live session. Nothing has been run against a real attach client, so the guard's behaviour on a genuine one is inferred from fabricated command lines, not observed. And no live background stop has been executed by this head or by me.