HOMELAB-2634: fix(herdr): recognise relieved sessions, fail closed on everything else #93
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!93
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-2634-janitor-relief"
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 — recognise relieved sessions, fail closed on everything else
Recovered work (the previous orchestrator was killed mid-run at 2026-11-04T15:27Z with no handoff) plus one follow-up commit. Branch was local-only until now; the ticket's "janitor ready" report is on HOMELAB-2634.
What this changes
scripts/herdr_janitor/janitor.pyalready closed successor/retire-marker panes, but it trustedagent_status. Measured on this fleet that is wrong:sup-gits-e1-5readdonewhile generating (1 shell still running,← 1 agent),infra-mem-overseerreaddonewith4 shells still running, and pi panes running subagents readidle. The close authority is now evidence the pane cannot fake:N running agents,N/N agents,N shells still running,← N agent); any non-zero count refuses.working-files/ldv0/directorio-claude.md), cross-checked on every close path; an unreadable roster refuses everything.HANDOFF READYline.sup-<rest>-<N>,<rest>-orchestrator-<N>); everything else is report-only, forever.wS/wH, neverld-hypervisor/el-director, never a focused pane, never an unnamed or duplicate name.968bbf3addsel-directortoPROTECTED_NAMES(the top Claude session's new herdr name), keepingld-hypervisor.Safety contract, unchanged
Dry-run by default; real closes need both
--applyand a non-symlink activation file containing exactlyHOMELAB-2634. Every close is re-decided from fresh herdr state immediately beforepane close. The only subcommands ever invoked arepane list,agent list,agent readandpane close— noserver stop, no prompt, no send-text.Evidence
decide()against footers captured verbatim from the live fleet.REPORT=9, zero CLOSE_CANDIDATE.Not proven
Screen stability across two real cycles on this VM — proved on fixtures and captured footers only. HOMELAB-2634's Verify is the first real close of a relieved session, which cannot happen until the timer is armed.
Verdict: APPROVED
model: omniroute/deepseek-flash
head:
968bbf32c4session: homelab-2634-review-1
Scope and method
I reviewed PR #93 at its exact head
968bbf32c40238815e810f150837e6f8b97a9b27(basemain@48c5000) in a clean worktree:git rev-parse HEADreturned that sha andgit status --porcelainwas empty, so what I read is what is being merged. I readdecide()in full (all five branches),_base_protections,live_work,text_hash,screen_stable,successor_age,directory_gate,directory_successor_state,role_parts,interesting_pane_ids,Janitor.require_activation,Janitor._execute,allowed_argv, andmain(), and I diffeddecide()against48c5000to know which guarantees this PR adds from which it inherits. I also drovedecide()directly with hand-built panes/agents/seen/markers rather than trusting the test names, and I ran the CLI for real.Executed, not described:
python3 -m unittest discover -s tests -p 'test_herdr_janitor*.py'→ 80 tests, all pass (32 intest_herdr_janitor.py, 48 intest_herdr_janitor_relief.py), 0.10 s. Live read-only dry-run into an isolated state dir (--state-dir "$(mktemp -d)" --dry-run): exit 0,summary REPORT=10 total=10 directory_unreadable_refusals=0, zeroCLOSE_CANDIDATEand zeroWOULD_CLOSEin the output. CI on this exact sha is green: Actions run #254 / PR #93 — all 15 jobs success, and run #255pythonsuccess.Taken on trust, and named so it is not mistaken for proof: that
/home/aaron/repos/homelab/working-files/ldv0/directorio-claude.mdis the roster the operator means; that herdr 0.9.1 genuinely exposes no pane creation time (which is why first-seen is the only age available); that~/.pi/agent/extensions/scripts/herdr_janitor/janitor.pywill receive exactly this reviewed code, since the unit runs the deployed copy and not this branch.The six contract points, checked against the code
1. Close only when relieved or retired, and never with live work. Every one of the five branches in
decide()appends at least one reason, andCLOSE_CANDIDATEis only ever set inside a branch that then appends its authorising reason, so no decision can reach a close with an emptyreasonslist — I checked each branch for the "appends nothing" shape rather than assuming it. The successor branch requires: same role base, same workspace, strictly higher generation, successor status in{idle, done, working},successor_ageproven and>= min_successor_seconds, non-empty old-pane text withlive_work()empty,screen_stable()true, relief evidence from either side, and no directory objection. The marker branch requires the same base protections, non-empty text and emptylive_work(). Fail-closed on the successor path is belt-and-braces:if decision.reasons: path_reasons.append("successor-close-held")refuses even a fully green successor pair when a base protection or directory objection fired.2.
wS/wHare never closed, even when named explicitly.Config.allowed_workspacesisworkspaces - NEVER_WORKSPACES, and_base_protectionstestsworkspace in NEVER_WORKSPACESbefore the allowlist. I ran it:--workspaces wS,wHyieldsworkspaces=(empty allowlist) andsummary REPORT=10 total=10. There is no expression of workspace policy outside those two places, so the allowlist cannot be widened from the command line or the environment.3. Fail closed when unsure. Unreadable/row-less roster →
directory_gatereturnsdirectory-unreadablefor every pane, which lands indecision.reasonsand therefore holds both the marker branch (if marker and decision.reasons) and the successor branch (successor-close-held);render_reportalso emits a loud header line and counts them. Empty or failedagent read→status-read-failedon both close paths. Unproven successor age →successor-age-unproven;first_seen > nowis refused rather than accepted. Unstable screen →screen-unstable(hash must equal the previous run's hash andnow - hash_since >= 600). Missing handoff →handoff-evidence-missing. Duplicates →duplicate-name, counted across all workspaces, so the stricter answer wins. Unnamed →unnamedplusrole-not-relievable. Focused →focused-panebyfocused: trueregardless of name.4. Only the four permitted herdr calls.
allowed_argvis an exact-sequence allowlist (pane list,agent list,pane close <id>,agent read <id> --source recent-unwrapped --lines 240 --format text) andHerdrCLI._runraises beforesubprocess.runon anything else.grepforsubprocess./os.system/exec(finds a singlesubprocess.runinside_run, and the test suite asserts refusal ofserver stop,pane kill,pane send-text,agent prompt,upgrade, barepane close, and a read with extra flags.5. Dry-run default; a real close needs
--applyand the activation file.applyis False unless--applyis passed, andrun()callsrequire_activation()beforecollect(), so an unactivated--applymakes zero herdr calls — I confirmed:--applywith an isolated state dir exits 1 withreal mode is not activated, printing no pane data.require_activationrefuses a symlink and requires the stripped content to equalHOMELAB-2634exactly.main()refuses--min-successor-seconds 599with exit 2, and I ran it: the 600 s floor cannot be lowered from the CLI.6. Protections.
PROTECTED_NAMES = frozenset({"ld-hypervisor", "el-director"}), checked in_base_protections, which runs before and independently of every close path, so neither a marker nor a successor can bypass it. No name-based bypass exists either:role_partsonly parsessup-<rest>[-N]and<rest>-orchestrator[-N], so anything else — includingld-hypervisorandel-director— isrole-not-relievableforever.I also probed the two shapes the diff could most plausibly get wrong. No fallback to an older generation: with
sup-a-5, a healthysup-a-6and ablockedsup-a-7present, the old pane reportssuccessor=wT:p7 reasons=successor-status-blockedand is not closed onsup-a-6. And a successor inwSnever relieves awTpane (reasons=no-successor), while thewSpane itself isnever-workspace.Net assessment versus
main@48c5000: this is a strict safety improvement on every axis. Main had no screen-stability check, no roster cross-check, nofocusedprotection, noel-directorprotection, and no successor revalidation before the destructive call.Findings
F1 — medium, not blocking this PR (pre-existing on
main). The retire-marker branch never consultsseenat all: no previous observation, nohash_since, no stillness floor. A pane seen for the first time, carrying a marker, whose last 40 lines print no live-work count, goes straight toCLOSE_CANDIDATE. I reproduced it:panes/agentswith oneidlepane named for a marker and text"Thinking... (no counts printed at all)"→('CLOSE_CANDIDATE', ['retire-marker'])withseenhashNoneandhash_sincenever read. Contract point 1(b) asks for "no live workers"; on the marker path that is currently "no live-workers count was printed in one instantaneous read", which is exactly the false negative the docs identify for a pi pane mid-turn with zero subagents — the successor path answers it with 600 s of byte-identical screen, the marker path answers it with nothing. This is identical inmain@48c5000, so I am not blocking the merge on it, but it is the one close path whose only evidence of "no live work" has no corroboration, and a marker close is destructive. Suggested before the timer is armed (a separate, operator-gated step): require the samescreen_stable/hash_since >= min_successor_secondscondition on the marker branch, or state the residual risk explicitly indocs/herdr-janitor.mdunder "What this cannot prove" so a supervisor knows a marker can end an in-flight turn.F2 — low, apply-mode reporting only.
_executebuildsby_pane = {d.pane_id: d for d in decisions}and returns[by_pane[key] for key in by_pane]. The absent-marker rows synthesised indecide()all usepane_id == "-", so two or more absent markers collapse to one row and the count drops (I confirmed: 2 rows in, 1 out). No close risk — those rows areREPORT/absent— but an operator reading an apply-mode report can under-count stale markers.F3 — informational.
require_activationchecksis_symlink()and then callsread_text(), which is a TOCTOU window in principle. Exploiting it needs write access to the 0700 state directory, which already implies the ability to write the activation file directly, so I do not consider it a hole.What this PR does not prove
Nothing here has ever closed a relieved session. The ticket's Verify — the first real close of a relieved session — cannot happen until the timer is armed, and this PR deliberately does not arm it: no unit is installed, no timer enabled, no activation file created. "Armed, not proven" is the correct state, and the live dry-run I pasted above is the strongest evidence available today: it reports ten panes, refusals, and zero close candidates. Two further limits are honest ones the docs already state: a pi pane that prints nothing for ten minutes is indistinguishable from a finished one, so stillness is evidence of quiet, never of idleness; and
SUCCESSOR_STARTEDis self-declared text, held in check only by the numeric rule, the workspace rule, the age floor and the roster.I am approving the code as written. F1 is a recommendation for the arming step, not a request to change this diff, and I have said so explicitly so nobody reads this approval as evidence that a marker close has been shown safe on a busy pane.
Verdict: REQUEST_CHANGES
model: omniroute/deepseek-flash
head:
968bbf32c4session: homelab-2634-review-2
What I did
Independent second review of head
968bbf32c4(base48c5000). I verifiedgit -C .worktrees/HOMELAB-2634-janitor-relief rev-parse HEAD= that sha andgit status --porcelainempty before trusting any file, ran the suite myself, ran a live read-only dry-run into an isolated state dir, and probeddecide()/_executedirectly from Python instead of only reading them. I did not rely on the first review. I have not taken the deployment state on trust: I did not verify that the timer or the activation file exist, and this review covers only the code at this head.Contract points, with code evidence
decide()has exactly two branches that can setCLOSE_CANDIDATE. The successor branch requires a higher-generation pane of the same role base in the same workspace whose status is inNEW_SAFE_STATUSESand whosesuccessor_ageis at leastmin_successor_seconds, plusscreen_stable, plus a readable non-empty screen withlive_work(text)empty, plus relief evidence. The retire-marker branch requires the marker name, an emptydecision.reasons, a readable non-empty screen, and emptylive_work(text)(finding M1 below). Onlysup-<rest>-<N>and<rest>-orchestrator-<N>parse into a relievable role; everything else isrole-not-relievable. A blocked orunknownsuccessor refuses (successor-status-blocked,successor-status-unknown) and there is no fallback: with gen1 idle, gen2 idle and gen3 blocked, both gen1 and gen2 stayedREPORT, because onlynewer[0]is ever examined.Config.allowed_workspacesisworkspaces - NEVER_WORKSPACES, and_base_protectionschecksworkspace in NEVER_WORKSPACESbefore the allowlist.--workspaces wS,wHyieldsworkspaces=empty in the report header and every row refused;--workspaces wT,wSleaves only wT; a pane with a marker in wS or wH reportsnever-workspace|retire-marker-held. An empty allowlist fails closed on everything.status-read-failed; unreadable roster blocks every path including markers (directory-unreadable); unproven age givessuccessor-too-new/successor-age-unproven; unstable screen givesscreen-unstable; missing handoff evidence giveshandoff-evidence-missing; duplicates refuse on both panes; unnamed panes andfocusedpanes refuse.focusedis not dead code: the live dry-run flagged the real focused panewT:p17withfocused-pane.allowed_argvadmitspane list,agent list,agent read <pane> --source recent-unwrapped --lines 240 --format text, andpane close <pane-id>, andHerdrCLI._runrefuses anything else. No stop, prompt, send-text or upgrade argv can be constructed.--applyand activation.--dry-run/--applyare mutually exclusive and dry-run is the default;require_activation()runs before any herdr call and refuses a missing file, an empty one, wrong content, and a symlink (all four exercised). The dry-run created onlyreport.txtandjanitor.logunder the isolated state dir — noseen.json, no close, no marker deletion.PROTECTED_NAMESis{ld-hypervisor, el-director}and_base_protectionsapplies it in every branch, so no marker path bypasses it at exact spelling. Case variants are not covered (finding L1).Tests and dry-run
python3 -m unittest discover -s tests -p 'test_herdr_janitor*.py'→ 80 tests, OK (32 in test_herdr_janitor.py, 48 in test_herdr_janitor_relief.py, no skips).Live dry-run:
summary REPORT=9 total=9 directory_unreadable_refusals=0, exit 0, zeroCLOSE_CANDIDATEand zeroWOULD_CLOSE, over 9 real panes (wT/wR allowed, wH/wS refused asnever-workspace). CI on the exact head is green: runs #254 and #255 on968bbf32c4all pass.Findings
M1 (blocks) — the retire-marker branch has no minimum-observation floor and relies on text it has already declared untrustworthy. Code path: in
decide(), theelif marker:branch checks onlytextreadability andlive_work(text), then setsCLOSE_CANDIDATEwith reasonretire-marker. It never callsscreen_stable(), never consultsseen, and never looks atmin_successor_seconds;safe_markers()does not stat the marker's mtime either. So the first run that sees a marker closes on a single glance, with no prior observation of any kind. I confirmed three cases that close withseen={}(empty state dir, i.e. a first run): a marker plus a quiet screen, a marker plus a screen whose hash changed one second ago (hash_since = now - 1, which the successor branch callsscreen-unstable), and a marker plus a pane mid-turn carrying no footer count. That last case is the exact hazard this module documents atscreen_stable(): "a pi pane mid-turn with zero subagents prints no count at all, so 'no live work' cannot be distinguished from 'busy' by text alone". The successor branch spends 600 seconds and two observations defending against it; the marker branch defends against it with nothing, so contract point 1's "no live workers" and point 3's "unstable screen" are unproven on that path, not satisfied. The docs at docs/herdr-janitor.md ("does not need a successor or handoff", "uses the same protected/status/no-live-work/directory checks") match the code, so the defect is shared by the documentation rather than contradicted by it. Fix is small: requirescreen_stable()on the marker branch as well, or require the marker file's mtime to be at leastmin_successor_secondsold — either gives the operator's intent time to be re-checked against a quiescent pane. A deliberate marker is real authority, but authority to retire is not evidence that nothing is running, and a closed pane takes in-flight work with it.L1 (does not block) —
PROTECTED_NAMESis matched case-sensitively while every other name comparison in the file folds case throughnormalise_cell(). A pane namedLD-HypervisororEl-Directorwith a marker of the same spelling reachesCLOSE_CANDIDATE; I confirmed both. It needs two deliberate acts (a case-variant pane name and a matching case-variant marker), and in the live deployment the case-folded roster usually catches the same pane asdirectory-current— but el-director is not a roster row today, so for that name the protection rests solely on the exact-set match. Casefold the set and compare folded, then the "no path can bypass it" claim in contract point 6 holds for casing too.L2 (does not block) —
_execute's result mapping does not preserve one row per input row when several retire markers name absent panes.by_paneis keyed bydecision.pane_idand every absent-marker row is synthesized withpane_id="-", so N absent markers collapse to one row:decide()returned 2 rows and_execute()returned 1 (betawon,alphavanished). Real panes have unique pane ids, so no close decision can be lost and no close is affected; the damage is an undercount in the apply-mode report. Key the synthetic rows by marker name or build the result from an index instead of a pane-id map.L3 (not a defect, noted) — the 600-second floor lives in
main()(--min-successor-seconds 599→ "refusing successor observation below 600 seconds", exit 2, before any herdr call), not inConfig, so a library caller can constructConfig(min_successor_seconds=0). The tests do exactly that, which is fine; the CLI is the contract and it holds.No other way to
CLOSE_CANDIDATEoccurred to me and survived probing._executecannot be bypassed: it runs only under--applybehindrequire_activation, it re-collects and re-decides immediately before each destructive call, and in a state-flip test (successor idle at decide time, blocked at revalidation time) the decision becameSKIP_CHANGEDwithrevalidation-failedandclose_panewas never called.What this does not prove
Armed, not proven. No real close has happened against this code — not in my session and, as far as the artefact shows, in any session. Nothing here demonstrates that herdr's
pane closebehaves as the janitor assumes, that a relief sequence resolves correctly on a real fleet, or that the marker deletion after a successful close leaves the right state. The dry-run is a read-only snapshot with an empty observation state, so it cannot exercisescreen_stableat all. Above 600 seconds of identical-screen observation on a real successor pair remains untested, and it is the whole mechanism.Verdict
REQUEST_CHANGES on M1 only. M1 is a fail-open on the one branch that needs no successor, no age, no anchor and no second observation — the cheapest path to closing a pane that may be working, and the one branch whose safety rests on the text this module itself says it cannot read. L1 and L2 are real but do not block; I would merge a fix for M1 with L1 and L2 folded in, and I will re-review promptly.
Verdict: APPROVED
model: omniroute/deepseek-flash
head:
649897a9cfsession: homelab-2634-review-4
I approve this head. I reviewed it from its own detached worktree created with
git worktree addunder a temporary directory, so no checkout was shared with another session. Everything below was executed against649897a9cf88ddb68fdf21bdea7e2b42d8fe85e6and nothing earlier. I am the second independent approval for a dangerous-class change.The M1 fix is real and it is complete
I did not take the commit message's word for it. I imported
decide()from the reviewed file and drove the retire-marker branch directly, holding the pane, the marker set and the text fixed so that only the previous observation varied. With an emptyseenthe marker path now returnsREPORT reasons=screen-unstable. Aseenrow whosenamediffers givesscreen-unstable. A row whosetext_hashdiffers givesscreen-unstable. Ahash_sinceone second old givesscreen-unstable. Exactly 600 seconds old givesCLOSE_CANDIDATEand 601 seconds givesCLOSE_CANDIDATE, which is the inclusive floor the successor branch already had and the boundary I would expect.hash_sinceset to NaN, to +Inf, toTrue, toFalse, to the string"400", toNone, and to a value in the future all refuse. The boolean and non-finite cases are refused becausescreen_stablevalidates throughvalid_first_seen, so a JSONtruecannot be coerced into an epoch.I then tried to reach a close on a first sight through every other branch. The successor path on an empty
seenrefuses twice over, withsuccessor-age-unprovenandscreen-unstable, which is belt and braces and correct. A marker plus a live higher-generation successor also refuses, because the marker branch is evaluated before the successor branch and carries the stillness floor with it. A pane with no marker and a name outside the two named role families getsrole-not-relievableand stays report only. An unnamed pane getsunnamed. I found no path toCLOSE_CANDIDATEon a first observation.The L1 fix is also real.
PROTECTED_NAME_KEYSis derived fromPROTECTED_NAMESthroughnormalise_cell, so the set stays the single source of truth rather than becoming a second hand-maintained list.ld-hypervisor,LD-Hypervisor,El-Director,el-director, padded" EL-DIRECTOR ",LD-HYPERVISORand even the accentedĺd-hypervisorall now carryprotected-name. The folding is strictly additive to protection; there is no spelling that matched before and fails now. Names likeld_hypervisororld hypervisorare not folded to a protected key, but neither can they be closed except through a marker file the operator must touch under that exact name, and they are outside both role families, so this is not a hole I can turn into a close.Never-workspaces and the allowlist
The
wS/wHguarantee holds by construction rather than by blacklist luck.Config.allowed_workspacessubtractsNEVER_WORKSPACESand every close path then requires membership in the remainder. So a pane inwSorwHis refused even when the operator passes--workspaces wS,wH,wT,wR: the subtraction still leaveswT,wRand the pane getsnever-workspace. Spellingsws,WS,Wh,"wS "," wS","wS,wH"and the empty workspace all getworkspace-not-allowed, because the allowlist is positive and the default iswT,wR. Duplicate and empty names are refused asduplicate-nameandunnamed. A focused pane in an allowed workspace with a stable marker is refused asfocused-pane. I cannot getwSorwHinto the allowlist by any spelling I tried.The per-close revalidation
_executere-collects from herdr and re-runsdecideagainst the same loadedseenimmediately before each close, and it compares successor, evidence, directory state and marker before callingpane close. I confirmed by construction that this is strict in the direction that matters: because the previousseenis not refreshed between candidates, a screen that changed between the report and the close failsscreen_stablein the fresh decision and the close degrades toSKIP_CHANGED. A pane that became focused, that moved intowS, or that changed status all gain base reasons and therefore degrade as well. A failing close yieldsCLOSE_FAILEDand retains the marker; the activation gate refuses a symlink, a directory, a missing file and wrong text.The
pane_id="-"row collapse reported against the previous head is still present._executebuildsby_pane = {decision.pane_id: decision ...}and returns[by_pane[key] for key in by_pane], so every absent-retire-marker row collapses onto the single key"-". Two ghost markers returned two rows of which one was the ghost and the report printed oneretire-marker-pane-absentline for two markers. I checked whether this can produce a wrong close and it cannot: the destructive loop iterates the original list by identity and the dict is only used to build the returned list, so a candidate is never dropped or rerouted. It is a report undercount, and in a contrived case where herdr itself returned a pane whose id is literally-, it could hide aCLOSE_FAILEDfrom the summary and frommain's exit code. I would still like both of these cleaned up.Tests, CI, dry-run
89 unit tests pass under
python3 -m unittest discover -s tests -v. I also ran the previous head's suite for comparison: 80 tests. So this commit adds nine, which is exactly the sevenMarkerStillnessTestsplus twoProtectedNameFoldingTests, and no test method was deleted (git diffreports zero removeddef test_). The two tests that previously asserted a marker close on emptyseen.jsonnow seed the prior byte-identical observation and then still assert that dry-run writes noseen.jsonand makes no herdr call, so they are strengthened rather than weakened: they now exercise a real candidate instead of a vacuous one.CI on the exact head: the
pythonjob in run 257 is green (7s, the workflow that actually runs this suite). The second run on the same commit, run 256, is green on fourteen jobs and still runningpi-swarm-suite, so the aggregate suite has not concluded at the time of this review; nothing on this head is red.Read-only dry-run against the live fleet into an isolated state directory: 9 panes,
REPORT=9, zeroCLOSE_CANDIDATEand zeroWOULD_CLOSE,directory_unreadable_refusals=0. The threewS/wHpanes were reported and refused,el-directorwas refused asprotected-name|focused-pane, and the only files created werereport.txtandjanitor.log. Worth stating plainly: because a dry-run never writesseen.json, a dry-run into a fresh state directory can never show a candidate at all, so dry-run is a safety check and not a preview of the next apply cycle.Findings
pane_id="-"collapse in_executeundercounts absent-marker rows in the report and could in a contrived case mask a failed close from the exit code. No wrong close is reachable through it.load_seenvalidatesfirst_seenfor finiteness but never validateshash_since. Aseen.jsoncarryinghash_since: -1e9yields instant stillness and aCLOSE_CANDIDATE. Reproduced. This requires write access to the state directory, which already contains theactivatefile, so it grants no authority the attacker did not already hold; still, ahash_sincethat is not finite, not a number, or negative should be discarded, andhash_since > last_seenlikewise.--min-successor-seconds 599exits 2) anddecideusesconfig.min_successor_seconds, but nothing stops a programmatic caller from constructingConfig(min_successor_seconds=1), which I confirmed closes at one second. The deployed unit runs the CLI, so this is a library-boundary note.text_hashcovers the last 40 lines only, so a change above the tail does not restart the floor. The documentation names this window and a working pane's growth lands in the tail, so I accept it.What I took on trust
That herdr 0.9.1's
agent read --source recent-unwrapped --lines 240 --format textreturns the operator's visible screen and that the last 40 lines are the part that moves while a turn is in flight; I saw those 240-line reads succeed live but I did not correlate a mid-turn pane's tail over two real cycles.allowed_argvI verified only by direct calls:pane closewith no argument, with an extra argument, with an empty id,pane kill,agent send-text,server stopandpane list extraare all refused, and only the exact four allowed argv shapes pass. I did not run--applyagainst the live fleet and I have not: this review closes nothing. The change is armed, not proven; no real close has happened, the timer is not installed, and the first genuine relief close remains HOMELAB-2634's own verification.Verdict: APPROVED
model: omniroute/deepseek-flash
head:
649897a9cfsession: homelab-2634-review-3
Reviewed the exact head
649897a9cf88ddb68fdf21bdea7e2b42d8fe85e6(basemain@48c5000) in the worktreeplane/HOMELAB-2634-janitor-relief.git rev-parse HEADmatched the head andgit status --porcelainwas empty before I read anything, so the files I probed are the files under review.This is the third review session on HOMELAB-2634 and my second pass over the M1 fix. I did not take either finding on trust: I drove
decide()from Python directly, importingscripts/herdr_janitorand handing it a default-constructedJ.Directory()(readable, zero rows) so no roster objection could mask the branch behaviour I was trying to observe — every refusal I report below is the branch's own, not the roster gate's.Probes, with what I actually saw
seen⇒REPORT,reasons=screen-unstable. Not a candidate, as required. This is M1 itself: a marker appearing in this cycle closes nothing in this cycle.seenrow{name, text_hash(quiet), hash_since=now-700}⇒CLOSE_CANDIDATE,evidence=retire-marker,successor=None,reasons=retire-marker. Three things at once: the floor is satisfiable, the marker is still independent authority, and the close path still needs neither a successor nor handoff evidence.hash_since=now-5⇒REPORT,screen-unstable. The floor is a real duration test, not a presence test.text_hashdiffers from the current text ⇒REPORT,screen-unstable. A changed screen restarts the clock rather than reusing it.3 running agents⇒REPORT,reasons=live-3-agent. The live-work refusal still fires first, before any stillness consideration.Noneread ⇒REPORT,status-read-failed; marker + empty-string read ⇒REPORT,status-read-failed. An unreadable screen is never mistaken for a quiet one.el-director,El-Director,ld-hypervisor,LD-Hypervisor— all four ⇒REPORTwithprotected-name. L1 is genuinely closed: the check now folds through the file's existingnormalise_cell, so a capitalisation change can no longer silently unprotect the voice. I confirmed folding only ever widens protection and thatnormalise_cell("")cannot match, so an unnamed pane is not accidentally swept into the protected set.--workspaces wS,wH⇒ effective allowlist[];--workspaces wT,wS⇒['wT'];--workspaces wT,wH,wR⇒['wR','wT'].wS/wHare subtracted, never merely defaulted. Note that the report header prints the effective set (workspaces=wR,wT) alongsidenever_workspaces=wH,wS, so an operator reading the artefact can see the subtraction happened.--min-successor-seconds 599⇒ stderrrefusing successor observation below 600 seconds, exit 2. The CLI floor is still enforced independently ofConfig.--applywith no activation file (against a logging fakeherdr) ⇒ exit 1, messagereal mode is not activated: .../state/activate, and the fake herdr recorded zero calls. Activation is checked before any read or close.Diff check: nothing the fix broke
The two changed hunks are exactly the two findings.
PROTECTED_NAMESremains the single source of truth andPROTECTED_NAME_KEYSis derived from it; the successor branch's text is byte-identical to the previous head. I verified empirically that the marker branch still requires no successor and no handoff evidence (probe 2:successor=None,evidence=retire-marker), which is the correct contract — the marker is authority for what to close, and the succession rule is authority for a different class of close. The marker branch only borrowsscreen_stable, which is authority for when. Those are three separate questions and the code keeps them separate.I also confirmed the ordering is fail-closed:
status-read-failedis checked before the stillness call, so a missing read never reachesscreen_stableand never produces a candidate;live-*is appended before the stillness call too, so a busy pane refuses on work rather than on quietness. Both refusals were preserved, not replaced.One consequence worth stating plainly for the record: a marker close now needs at least two cycles — the cycle that first sees the marker and the cycle after the screen has been byte-identical for the floor. That is intended and is documented in
docs/herdr-janitor.md, which now says "Every close — numbered successor or retire marker".Tests
python3 -m unittest discover -s tests -p 'test_herdr_janitor*.py'in the worktree: 89 tests, OK, run in 0.118s. No skips, no failures.On whether the fix weakened existing tests: it did not. No test function was deleted (
git showof the fix commit contains zero removeddef test_orassertlines acrosstests/). Counting definitions,test_herdr_janitor.pyis flat at 32 → 32 andtest_herdr_janitor_relief.pygrows 48 → 57, so nine new tests were added and none removed. Each of the six updated bodies is a stronger assertion, not a relaxed one:test_read_error_and_valid_no_live_footer_controls(marker subTest): the quiet case now supplies a prior byte-identical observation and still assertsCLOSE_CANDIDATE; theNonecase still supplies{}and still assertsREPORT/status-read-failed. It now proves the close needs a prior observation rather than assuming one.test_retire_marker_does_not_need_successor_or_handoff: supplies theseenrow and still assertsCLOSE_CANDIDATE+evidence=retire-marker. The point of the test — independence from succession — is preserved and now demonstrated under the floor.test_dry_run_has_no_pane_marker_or_seen_mutations: previously reached "candidate" partly because of the bug; now it is a legitimately eligible candidate, and the assertion that a dry run mutates neither the marker norseen.jsonbytes is therefore stronger, not weaker.test_real_retire_close_deletes_marker_only_after_successandtest_failed_close_retains_marker: both now seed a stable observation, so the close is attempted through the real contract instead of around it.test_dry_run_with_a_candidate_closes_nothing_and_writes_no_seen(relief): same shape — a genuine candidate, same no-mutation andWOULD_CLOSEassertions.The nine new tests cover what the fix claims: first sight never closes, an observation started this cycle never closes, a screen that changed since the last look never closes, a renamed pane restarts the floor, the floor does eventually permit a close,
live-1-agentstill refuses, unreadable/empty screens still refuse, all four protected spellings are protected, and the protected set stays the single source of truth.Dry run
python3 scripts/herdr_janitor/janitor.py --state-dir "$(mktemp -d)" --dry-run, read-only against the live fleet with the real roster readable:Zero
CLOSE_CANDIDATE/WOULD_CLOSElines, exit 0. Nine panes reported, all refused, and the roster parsed (directory_unreadable_refusals=0), so the quiet result is real observation and not a silent unreadable-directory stop. The refusals are what I would expect from the current fleet:never-workspaceon thewH/wSpanes,protected-name|focused-paneonel-director,no-successoroninfra-stab-orchestrator-5,role-not-relievableon the unnamed and out-of-family panes.CI
On the exact head I read two Actions runs, and both pass. Run #256 (the full matrix) is green:
ask-user-question,forgejo,forgejo-ci,mcp-bridge,nats-intercom,pi-friction,pi-otel-telemetry,pi-swarm-suite,plane,pr-title,redact,refocus,skill-argsandtodoall succeeded. Run #257 (python) also succeeded. So CI on649897ais green, and I am calling it green on that evidence rather than on the earlier reading — when I first polled, run #256 was still in progress withpi-swarm-suiterunning, and I did not call it green then. The change touchesscripts/herdr_janitor/, its two test files, anddocs/herdr-janitor.md.What this PR has not proven
No real close has happened. Not one, by me or by the janitor. Every close I exercised was a
CLOSE_CANDIDATEinside adecide()call or aWOULD_CLOSEin a dry-run report, and the one--applyinvocation I ran refused at the activation gate before issuing a single herdr call. This is armed, not proven: the code is a defensible close authority on paper and in probes, and the first real close will be the first actual evidence thatpane closeon a relieved or retired pane does what the operator wants and nothing more. I would want the first live cycle run with the marker list empty, watchingreport.txtandjanitor.log, before trusting it with a marker at all.I found no blocker and no lesser finding worth filing. Approving.