cmd_logs always passed the wrapper's cwd-relative default registry, so the
usual run (registry not moved, cwd not the workspace) no longer found its
jobs. Without the option registry.py's own default decides, as before:
MAM_JOBS_DIR, then WORKSPACE_ROOT, then the cwd.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
control_plane and status.sh imported sibling_logs_dir from mqtt_common,
which imports paho at module level, so create/resume's binding and status
died on an interpreter without paho-mqtt (_delegate_py_bin's last fallback
is the PATH's python3). The registry/audit-log path rules move to
lib_py/delegation/logroot.py (standard library only) and mqtt_common
re-exports them. The package's own import chain also reached paho through
service -> publish_event -> publisher, so mqtt_common and publisher import
paho tolerantly and make_client() refuses with a clear error when a broker
is actually needed. control_plane, status.sh, lib.sh's delegate_publish_cmd
and delegate_logs_dir, and the wrapper's _logs_dir_abs import from logroot.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
New: prefix()/CLI forward three keys for a sibling binding and four for an
explicit root; create records the sibling on the row, omits it from the
pane's --env and still pins it in the injected publish commands; the
issue's scene (a pane with WORKSPACE_ROOT and MAM_JOBS_DIR only) moves its
audit log with --registry-dir and leaves the control root alone;
worktree_prepare's pane --env carries no DELEGATE_JOB_LOGS_DIR.
The three existing four-key forwarding assertions (user-approved) compare
FORWARDED_ENV = CP_ENV[:3]; CP_ENV and the negative assertions are kept.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
_check_disk_fallback converted transition_seq outside its try, so a
status.json whose sequence is not an integer would raise out of the
subscriber's main loop; such a status is now ignored with a warning, like a
stale one. delegate_started_recorded returned 1 before looking at the
registry's events.log when the audit-log root could not be resolved; it now
skips only the audit check.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The audit mirror is written after the registry commits, so a redispatched
record (attempt 2, pending) can sit next to attempt 1's completed
status.json, and a failed mirror leaves it there. The fallback validated the
token and attempt against the record, which say nothing about the audit
status's provenance, and closed attempt 2 with attempt 1's completion
(review P1, Issue #57). A versioned record (transition_seq) now takes the
audit status only when its transition_seq is at least as new; an audit
status without a sequence cannot prove which attempt it describes and is
ignored by a versioned record. An unversioned record (never transitioned,
or MAM_ENGINE_CAS=0) and a missing record keep the old rule.
Regressions: the redispatch/flush interval, the failed-mirror leftover, the
unversioned audit status, a newer audit-only terminal, and conflict
refusals and legacy claims next to a moved registry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
New file tests/test_issue57_logs_follow_registry.py: the resolver (L1),
registry.py (L2), the wrapper's submit/wait/logs (L3), publish_event.py
(L4), control_plane.from_env, worktree_prepare, status.sh, the comment-5
disk fallback, and the invariant that an explicit DELEGATE_JOB_LOGS_DIR
still wins. Every test ends by asserting the workspace's own
.mam/delegate_job_logs was never created.
Two existing tests gain monkeypatch.delenv("MAM_JOBS_DIR"): conftest's
autouse _isolate_mam_state_dirs gives every test a MAM_JOBS_DIR, and under
the sibling rule the WORKSPACE_ROOT/cwd fallbacks they assert would
otherwise resolve next to that registry. The order they assert (explicit
root, workspace, cwd) is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
read_logged_status() returns the status.json dict; _check_disk_fallback
compared that dict with the terminal status strings, so a terminal status
recorded only in the audit log was never recognised (Issue #57, comment 5).
The dispatch-token and attempt checks are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
test_u1e_a_dry_run_record_is_not_an_open_job (PR #56) ran the delegate-job
wrapper without WORKSPACE_ROOT; the wrapper sources lib.sh, which then
defaults it to the checkout and rewrites <checkout>/.mam/shim/herdr
(found by review 38a64423). The node joins W1's list, which runs its
nodes in a copy of the checkout and fails on any .mam written there.
A scan of the #50/#55 and leak modules from a clean worktree found no
other writer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
A suite run from an agent pane with --basetemp .../pytest-pr56-shim left
seven workspaces on that pane's real server (multi-agent-mux):
- the real-herdr resolvers (lib.sh _is_shim_path and the generated shim)
skipped every PATH directory with "-shim" anywhere in its path, so the
basetemp hid the mock's bin/ and the real herdr ran; only a directory
that is itself *-shim is a wrapper now;
- mam_sandbox kept HERDR_SOCKET_PATH (and the pane/tab ids), which the real
herdr follows over the sandbox HOME; it drops them now.
tests/test_herdr_real_server_leak.py: L1 (ancestor *-shim, both resolvers)
and L2 (pane routing dropped) fail before the fix; L1b keeps a *-shim
wrapper directory excluded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
A `default` scan runs without -L, so the shim answers it from the inherited
HERDR_SESSION_NAME; a create's reconcile --once labelled that server's panes
`default`, and since create adopts only a row of its own server (1e25cf2f
F2), parallel creates in one folder were refused (test_tier4_e2e scenario 4).
The skill-blocks data follows create_session.sh's SystemExit line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
- admission: a `submit --dry-run` record is marked dry_run and is not an
open job of its session (review 1e25cf2f F1)
- create: the monitor-row adoption requires the row's server to be the one
this create spawned on (review 1e25cf2f F2)
- pane lock: retry only on contention (BlockingIOError); the docs say the
flock lasts while any fd-9 holder (the claim shell and the shim) lives
(review 66680d9d)
- tests on macOS: real-herdr box HOMEs under /tmp (sun_path is 104 bytes),
resolved paths, the fake claude reads non-canonical input (MAX_CANON 1024)
and the pane is read unwrapped; lock holders exec their sleep so killing
them frees the lock; the adoption tests name the server
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
The live onboarding test asserted that the first job's wrapper registered in
the same wall-clock second as the onboarding it had just placed at a second
boundary. Process start-up has no sub-second contract, so the setup assertion
failed now and then before any dispatch behaviour was checked. The test now
stamps the onboarding record, which it owns and which nothing else depends on
yet, with the job's created_at once that record exists; the stamp is written
by atomic replace so a scanning wrapper never skips a half-written record.
The lifecycle assertions and the fixed-id tie coverage in test_u2h are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
open_jobs reports a live pending record nobody signals on as `injected`:
its producer registered and injected it without a waiter (create
--onboard, a job sent by hand), so it holds the session like a running or
admitted job, and a waiter of the same second with a lower job id, or one
registered a moment earlier and still in its preflight, no longer passes
it in the FIFO. The waiter signals on its own record before its first
scan, so its record is never mistaken for one.
lib_py.pane_lock resolves the shim's ambient target (no -L,
HERDR_SESSION_NAME empty or `default`) to the server of the pane the
caller runs in - HERDR_SESSION, else HERDR_SOCKET_PATH's layout, as
lib.sh _herdr_caller_server reads them - so an ambient and a named call
into one server claim under one lock; outside a pane it stays the
default server's.
Tests: u2h (the tie, the later-registered onboarding, the signalled
control), u1/u4 labels, c10 forces the same-second registration, p1b,
s2 (real herdr: held lock stops both call forms; mixed concurrent calls
take distinct panes). Two FIFO fixtures now carry a waiter's signal.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The registry wait judged its record by one rule: not this attempt's
pending record in this session -> superseded (redispatched, moved or
closed by someone else). That also caught the record's own `started`
landing before the prompt went out - the Issue #14 registry ack winning
over an unreachable broker, as test_issue30_stall_watch_stop acknowledges
at registration - and the wrapper left on exit 5 instead of its
broker_unavailable path (rc 3).
A record that is running under this attempt's token in this session is
this attempt's own progress: the waiter answers `clear` at once, writes
no admission and starts no hold. Another token, another session or a
terminal status stay `superseded` (review 55219be1 R1 unchanged).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three `create --onboard` at once into one folder (the control root, so no
control-plane prefix reaches the pane and the shim reuses an idle pane):
the shim's new-session chooses a pane - an idle one in a workspace on the
same cwd, a split, a fresh workspace's root pane - and then claims it with
`agent start`; two shims could choose the pane one of them had just made,
and the later start failed with agent_pane_busy (one create in three).
mam_herdr now runs every new-session call holding one lock per server: a
flock on fd 9, opened for the block and taken by lib_py.pane_lock, which
also bounds the wait (MAM_PANE_CLAIM_WAIT_S, default 120). It is released
with the block or with the process, however it ends. The shim heredoc is
unchanged (S11). Other servers' creates are not held.
Serialising the claims exposed a second race: a create's end-of-run
`reconcile.sh --once` adopted the pane of another create that had not yet
written its row, and that create refused its own name as already running
(exit 4). Such a row - registered by the monitor, no job, for a pane
created after this create began - is now replaced, not refused.
Tests: real herdr - shim-level and create-level three-way races into one
folder (the real herdr's pane-creating replies held back so the window is
hit on every run; both fail on the base with the orchestrator's error),
the different-folder test kept; pane_lock unit tests (per-server file,
timeout, kill -9 holder, handover, knob); the adoption rule and its three
refusals; shim contract h26 (held lock: no pane calls). The herdr box gets
a ~/.zshrc: without it every first `agent start` typed into zsh's first-run
menu and timed out at 30 s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
R1: wait-open-jobs takes --dispatch-token (the token the wrapper read for
this attempt) and keeps it, with the session it asked about, for its whole
life; every write (signal, admission, the hold's renewal) is a guarded
engine merge that re-reads the record under the registry lock and refuses
once the record is not that attempt's pending one in that session. A record
redispatched, moved or closed by someone else makes the waiter answer
`superseded`: the bash wrapper exits 5 without a close, the record is its
new owner's. hold-admission is given the waiter's pinned identity instead
of reading a token from disk, so a hold that starts late holds nothing.
R2: a pending record that never carried a waiter's signal (create --onboard
registers and injects directly and waits ack_timeout_sec for `started`) is
live for that budget from created_at, not for the 30 s signal window; the
window applies only once a dispatcher has signalled.
Tests: u1 rows for the two pending lifetimes, u3b asserts the guard, u3f/u3g/u3h
(cross-session redispatch, in-place attempt change, token changed before the
waiter started) with the real waiter subprocess, u4e/u4f (late hold, running
hold released by a redispatch), s1 pins the token argument and the no-close
branch; real herdr C10 (onboarding valid before and after 30 s blocks the
session), C11 (expired onboarding is stale), C12 (redispatched waiter leaves
its record alone, exit 5).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F1: `clear` is now an admission persisted on the released record - a merge
through the engine whose new `guard` re-reads the session's open jobs under
the registry lock, so two waiters cannot both be admitted. It is scoped to
the attempt by the dispatch token (a redispatch voids it) and reported as
`admitted` to every other waiter, which holds the session as a running job
does, whatever its FIFO place: a lower id in the same second or a
redispatched older loop job now waits.
F2: a pending record's liveness window is the dispatcher's signal cadence
times three misses (10 s x 3 = 30 s), no longer ack_timeout_sec, which the
agent's budget takes down to 1 s. After `clear` a detached hold renews the
admitted record's signal until the record leaves pending, its attempt
changes, the wrapper is gone, or the wall clock ends.
Tests: u1 boundaries, u1d/u2e/u2f/u2g/u3d/u3e for the admission, u4c/u4d for
the hold, s1 pins --hold; real herdr: C3/C7/C8 adjusted, C9 is C8's
live-owner counterpart with short ack windows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The `--status pending` reset is a redispatch and issues a new dispatch
token, but the loop read `_disp_tok` only after the registry and idle waits.
A busy or idle_wait close from phase two on therefore carried the previous
phase's token and the engine refused it, leaving the record pending while
the caller saw session_busy. The read now follows the reset directly; s1
pins reset < token read < registry wait.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F1: from phase two on, the loop's `_registry_wait` ran before the record was
reset to pending, so the waiter held a terminal record: the engine refused its
dispatch_wait_at merge and other waiters never saw it in the FIFO. The wait
(and the idle wait after it) now follows the `--status pending` update; s1
pins the order and u3c shows why a terminal self record cannot signal.
F2: the real-herdr box teardown survives a hung stop/delete, kills a server
bootstrapped by a late client call, and asserts the server and HOME are gone.
F3: the delegate-job SKILL.md exit 5 row names the registry wait.
Also drops the dead loop in s1.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The FIFO is (created_at, job_id) at second resolution; B and C registered
within the same second once in the full suite, C sorted first by id and
cleared while B's signal was still fresh, so the stale line never printed.
C7 already keeps the 1.2 s gap; C8 now does too and asserts the order.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MAM_ONBOARD_WAIT_S (min 1, 600) and MAM_DISPATCH_WAIT_S (min 0, 600) in
test_issue26_ints.INT_KNOBS, which pins every mam_int_knob call. The
real-herdr box's teardown removes its HOME once more after a second:
herdr writes .local/state/herdr/agent-detection a moment after the
server is gone, which left an empty /tmp/mam50-* behind.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reproduction first (herdr 0.9.3, a real claude, one-off server, job
51ccd800): after a job that left a background subagent running
(`← 1 agent` on screen for 5 min), herdr reported `working` only while
the job ran and `idle` ~10 s after `completed`; a second submit with a
20 s idle wait was injected, not cancelled. The 2026-10-04 observation
(0.9.1, 10+ min of `working`) does not reproduce on 0.9.3, so the herdr
idle wait stays and the registry wait runs before it (plan §4.3 (나)).
- lib_py/delegation/wrapper.py: `open-jobs` (diagnostic) and
`wait-open-jobs`. An open job of the session is live when the #18
contract says so: `running` with an event in events.log within
idle_timeout_sec; `pending` with a dispatch signal (dispatch_wait_at,
else created_at) within ack_timeout_sec. A dead record is reported
(`stale open job <id> ignored (...)`) and never waited for. The waiter
signals every 10 s by merging dispatch_wait_at into its own pending
record through the registry's transition boundary (status untouched, no
event), so later waiters see it alive; a waiter whose wrapper is gone
stops and its signal goes stale (Creator's challenge, job bf8863e1).
Release is FIFO by (created_at, job_id): `clear` when no live running
job remains and this job is the earliest live pending one; `busy <ids>`
at the bound min(MAM_DISPATCH_WAIT_S, remaining wall clock).
- the wrapper: _registry_wait after the preflight in both dispatch paths;
`busy` closes the job as cancelled/session_busy (phase registry_wait),
exit 5 - the existing contract.
- tests/test_issue50_dispatch.py: the open-job table (status x age x
signal x spelling), FIFO and the tie, the signal as a registry merge,
the bound, a dead wrapper, finalize-end's new phase, both call sites.
- tests/test_issue50_dispatch_herdr.py: a real herdr 0.9.3 server per
test (short mkdtemp HOME, fake claude the server reports idle, the real
publish path over the suite's broker): C1 wait-then-run, C2 the bound,
C3 a dead running record, C4 the loop runner's first job behind an
onboarding, C5 a session without a row, C7 two waiters in FIFO order
with no cancellation, C8 a killed waiter skipped once stale; and three
concurrent `create --onboard` / `worktree_prepare.sh --onboard` in one
server (one server process survives the shims' concurrent bootstrap,
so no bootstrap lock is needed).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The handshake only said the agent saw the prompt (#23). create_session.sh
now waits, after `started`, for the onboarding job to reach a terminal
status and makes that its own result, so the orchestrator learns it from
create's exit (MULTI_AGENT_RULES §6) instead of guessing from herdr's
agent_status:
- lib_py/delegation/wrapper.py: `wait-terminal` (completed|error|cancelled,
`timeout` after --timeout, `missing` when the record is gone; stops with
its caller via --watch-pid) and `onboard-exit` (completed 0, error or
cancelled 44, timeout or missing 45). bash branches on the printed answer.
- lib.sh: delegate_wait_terminal / delegate_onboard_exit, thin calls.
- create_session.sh: `onboarding completed: job <id> (<n>s)` on stdout, or
`last_visible_status: onboarding-<status>` on the row, the reason on stderr
and `exit 44` / `exit 45` after `trap - EXIT` - the session is kept, as for
a missing handshake. The "no started" path (error, exit 0 before) now ends
with 44. MAM_ONBOARD_WAIT_S (default 600, floor 1) bounds the wait.
- worktree_prepare.sh: 44/45 pass through with "session and worktree kept".
- create SKILL.md exit table (44, 45) and the knob; the #42 inventory follows.
- tests/conftest.py: --onboard is the default, so ~60 sandbox create tests
reach the new wait. The mock herdr now acts as the agent for create's
onboarding prompt - it runs the publish commands the prompt names through
the real publish_event.py - and MAM_ONBOARD_WAIT_S=3 caps a test that turns
that off (MAM_MOCK_ONBOARD_HANDSHAKE=0 -> 44, `started` -> 45). The
delegate-job wrapper's prompt has another shape and is never answered.
- tests/test_issue50_onboard.py: the decision tables, create in the sandbox
(0 / 44 / 45, session kept, row note, trap window), the knob's integer
rule, worktree_prepare's pass-through.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An agent pane inherits HOME_DIR, CLAUDE_PROJECT_DIR and LOCAL_BIN from
lib.sh's exports (and the adapters read CODEX_HOME). DiscoveryContext
prefers CLAUDE_PROJECT_DIR over the home_dir a test passes and every
adapter path reads HOME_DIR before HOME, so a suite run by an agent
wrote fake conversation folders into the user's real ~/.claude/projects
(test_adapter_discover, 40 folders on 2026-10-10).
- conftest._isolate_agent_homes (autouse): drops the four pane variables
for every test, so HOME - which the suite's tests set for an isolated
home - governs, as in a plain shell. Dropped rather than pointed into
tmp_path: a fixture-set HOME_DIR would shadow the 11 adapter-contract
tests that set HOME alone, the same way the pane's value did.
- conftest.agent_home_leak_guard (session autouse) + tests/_agent_home_guard.py:
snapshots the entry names of the real claude/agy/codex/grok/opencode/
hermes conversation directories (as the ambient environment names them,
recorded in pytest_configure) and fails on new ones; read-only. A claude
folder whose name decodes (against the file system) to a real directory
outside the temp roots is an operating agent's and is not counted.
- tests/test_issue55_agent_homes.py: the dropped-variable list is pinned
against the env reads of lib_py/agents, lib_py/paths.py and lib.sh;
guard unit tests; the issue's reproduction as a subprocess regression
(on base 42e5173 it leaves <dir>/-…-test-adapter-discover0-ws/u-c1.jsonl).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
test_b13_reexec_preserves_original_argv and test_b13_no_freeze_switch_
disables_reexec ran the checkout's run_loop.sh with --creator, which gets
past argument parsing to mam_acquire_loop_lock on the default marker,
$MAM_REAL_ROOT/.mam/loop-guard-active with MAM_REAL_ROOT = the script's own
checkout. In the main checkout that is the live loop's lock (taken and
released for a moment: a real loop refused, or the test refused); in a review
tree it left an empty .mam. Both now set MAM_LOOP_MARKER under their tmp_path.
A sweep of the 16 modules that use run_loop.sh (a per-test watcher for a new
.mam in the checkout, an empty .mam removed before each test) found only
these two; the others pin MAM_LOOP_MARKER already, end before the lock
(--help, argument errors), or run a copy of the tree.
Tests: test_loop_marker_isolation.py L1 runs every test that reaches the lock
through the checkout's run_loop.sh in a copy of the checkout and requires no
.mam there; W1 of the Issue #17 isolation test now also requires that no .mam
exists (it only looked for files, so the empty lock directory passed it). At
97c2946 both fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
Two modules still ran the checkout's run_loop.sh - which sources lib.sh
before it parses an option - with the process environment and no root of
their own: test_o2_race_free_lock's test_o2_12 (review claude; it was in the
sweep's list and missed in 8b47cc7) and test_o1_rebuttal's
test_v3_rebut_option_parsing_and_budget, whose calls pass no env (review
grok; outside the env-copy grep). Both now use own_workspace_root, and W1
runs one node of each: at 8b47cc7 W1 fails with the copy's .mam/shim/herdr.
A last pass over the test files that run lib.sh, run_loop.sh, the wrapper or a
skill script: every one is marked, uses mam_sandbox or pins WORKSPACE_ROOT,
or does not source lib.sh (b7 diff_collect, deploy, issue42, issue45 config,
issue5_phase0, local_mqtt).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
d198dd5 fixed one helper; the same defect was in eleven more modules
(reviews grok and codex R1/R2): their shells inherited the process
environment, so each `source lib.sh` rewrote <WORKSPACE_ROOT>/.mam/shim/herdr
- the checkout's own when the variable is unset or names this checkout (which
pytest_configure keeps), another workspace's under --noconftest.
- conftest: own_workspace_root, an opt-in fixture setting WORKSPACE_ROOT to
<tmp_path>/.mam-test-state/workspace. Not autouse: runner.py and
mqtt_common read WORKSPACE_ROOT in-process, and some tests unset it on
purpose. mam_sandbox and explicit envs run later and still win.
- pytestmark usefixtures("own_workspace_root") in test_version_consistency,
test_b19_headless_reconcile_fixes, test_b8_send_keys_verification,
test_issue6_fail_closed, test_loop_cli, test_issue5_phase3_loop,
test_issue5_phase4_runtime, test_o3_scoped_guard, test_issue25_plan_reuse,
test_orc_onboard (their shells inherit os.environ, many with no env at all).
- test_orc_onboard (R2): SCRUBBED_ENV is taken at import and no longer
carries WORKSPACE_ROOT; every call builds _scrubbed_env(root) with the root
of the call (ws / cwd / mam_sandbox, else this test's).
- test_workspace_scope (unittest) and test_issue21_worktree_prereqs._mutate
pin the root they already create.
Tests (test_issue17_workspace_root_isolation.py): W1 copies the checkout and
runs one test of each of those modules there with WORKSPACE_ROOT naming the
copy - the copy gets no .mam; W2 imports test_orc_onboard under a sentinel
root, then calls it under another - the sentinel stays empty. At d198dd5 both
fail (the copy gets .mam/shim/herdr; the sentinel gets it).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
_env kept an inherited WORKSPACE_ROOT, so every helper that sources the
wrapper (lib.sh's _init_herdr_isolation) rewrote <that workspace>/.mam/shim/
herdr - another workspace's during the PR #51 review (v4.8.7 review, claude).
Only dropping it is not enough: lib.sh then derives the checkout itself, and
conftest keeps a WORKSPACE_ROOT that names this checkout, so the run would
rewrite the checkout's own shim (the real one when this is the main
checkout). _env now sets WORKSPACE_ROOT to the test's tmp dir.
Test: with a sentinel WORKSPACE_ROOT in the environment, _run_both and a
direct _env run leave the sentinel empty and the checkout's shim untouched,
and the shell sees the tmp dir. With the old _env the sentinel gets
.mam/shim/herdr.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
subscriber-up and stall-watch answered only by exit code, so a call that
could not run (module error rc 1, missing interpreter 127) read as "no":
with a subscriber that had ended 0, the error path mapped 0 to completed
and the submit ended with 0; a failed stall-watch silently skipped the
watcher.
Both now take --print: the answer is printed as yes/no and the exit code
is 0 for either. The wrapper asks with --print through _decided_yes, so a
nonzero exit, no answer or anything else is _decision_failed (ERROR line,
exit 1). Without --print the rc contract is unchanged (yes 0, no 1).
Tests: subscriber-up (direct and loop/discuss call sites) and stall-watch,
each failing by traceback, rc 127 or no answer with a subscriber that
ended 0, end the submit with 1 and the ERROR line - at 8241bd5 eight of the
nine ended with 0 (the loop/no-answer one ended with 1 through the loop's
own result and failed only on the ERROR line); yes and no keep their
branches in both paths; --print unit table; the static guard forbids an
rc-read yes/no decision.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
a670ef5 routed the wrapper's sub-status and exit-code decisions through
_decided / _exit_for, but test_tier1_unit G-10 and test_issue5's
test_wrapper_exit_code_mapping_literals_are_frozen still looked for the raw
`_decide sub-status "$sub_rc"` and `_decide exit-code "$job_status"`. They now
pin `_decided job_status sub-status "$sub_rc"` and `_exit_for "$job_status"`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
Sourcing the wrapper sources lib.sh, which writes $WORKSPACE_ROOT/.mam/shim/
herdr; without WORKSPACE_ROOT that is the checkout. The new N2 wait-rc test
now runs with its tmp dir as WORKSPACE_ROOT, so the N2 tests leave the
checkout untouched. (The existing C tests in this file still write the
checkout's .mam/shim/herdr, as at 56d0bfd - not changed here.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
`exit "$(_decide exit-code ...)"` became `exit ""` when wrapper.py could
not answer (its module or $PY broken): rc 255 on bash 3.2. The other printed
decisions failed open in their own ways: a failed wait-rc returned "" (255),
a failed finalize-begin left finalize-end with an empty --final (argparse
rc 2), and a failed sub-status or finalize-end ended under set -e with the
interpreter's own code (1 for a traceback, 127 for one that cannot run).
Printed decisions now go through _decided <var> <subcommand>, and the
exits through _exit_for <status>: a failed call, an empty answer or a
non-numeric exit code / wait rc prints one ERROR line and exits 1. finalize-
end may legitimately answer nothing (its wrapper gone), so only its rc is
checked. The normal path is unchanged: same answers, exit codes and output.
Left as they are: subscriber-up and stall-watch are answered by rc, so a
failure reads as "no" - the subscriber is then treated as dead (the error
path, whose sub-status/exit-code are now fail-closed) and the notify-only
stall watcher is not started.
Tests (F): the real submit (human job, unreachable broker, 3 s budget) with
a $PY that fails one decision - exit-code, sub-status, finalize-begin,
finalize-end; traceback, rc 127 or no answer - ends with 1 and the ERROR
line; the same run without a failure still ends with 3. cmd_wait with a
failing wait-rc ends with 1. A static guard keeps printed decisions off
raw $(...). At 56d0bfd: exit-code 255/255, finalize-begin 2/2, wait-rc
255/255, sub-status and finalize-end 127 for "gone"; the traceback and
no-answer cases there already ended with 1 (by set -e or by the empty
status mapping to 1) and fail only on the missing ERROR line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
With the cwd premise fixed, test_b still failed on herdr 0.9.3: a pane whose
saved directory is gone is restored as a placeholder - `restore_error`
"Saved directory is unavailable...", no process - and `pane process-info`
answers pane_not_found. close_restored_shells raised on it and exited 1
before looking at the other panes, so the resumed session's removed-worktree
pane (the case #45 closes on herdr 0.9.1, where it is a shell in $HOME) was
left behind.
Such a pane has nothing running, so it now meets condition 4. Only that
exact answer counts - restore_error set AND pane_not_found; any other
process-info failure still stops the run with exit 1 as before.
Tests: test_issue45_restore.py passes on herdr 0.9.3 (5/5). Unit tests with a
stub herdr: the placeholder is closed (fails at 56d0bfd); pane_not_found
without restore_error, or restore_error with another error, still raises.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
test_b_only_the_resumed_sessions_restored_shell_is_closed asserted that a
pane whose directory was removed comes back in $HOME. That is herdr 0.9.1;
herdr 0.9.3 keeps the old path. The cwd check now takes either, with a note
on which version does what; closing never looked at the cwd.
herdr_restore.py's docstring said the same "$HOME when that directory is
gone" and now names both versions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
codex, PR #51 round 1. wrapper._sleep computed the span and the remainder
with time.time(). The broker re-observation (up to 3 s) and the disk-heartbeat
grace (1 s) use it, so a wall clock stepped back during the wait stretched it
by the step: on a mocked clock stepped back 10 s, an already completed job was
seen after 11 s / 13 s instead of 1 s / 3 s. The old bash ran `sleep 1` /
`sleep "$left"`, which do not follow the wall clock.
_sleep now measures on time.monotonic(). The deadline (START_TIME + TIMEOUT),
the evidence window and event timestamps stay on the wall clock (_now), as in
the bash.
test_u_a_wait_is_not_stretched_by_a_wall_clock_step_back (1 s, 3 s) swaps the
module's time for a clock stepped back 10 s during the first sleep; on the
previous code it measures 11.0 / 13.0 and fails. The comparison test and the
finalize-related behaviour tests (issue33, issue18, issue14) on macOS /
Python 3.9: 321 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
kill(pid, 0) succeeds for a zombie. A caller that captures the wrapper's
output and calls communicate() after TERM leaves the wrapper unreaped while
the finalizer holds its pipes, so the owner check passed until the deadline
and the job was still closed (codex, round 2; reproduced with the real
registry). _alive now asks `ps -o stat=` (POSIX, no /proc) once kill(0)
succeeds and treats state Z as gone, as tests/conftest.py::_pid_alive does;
no ps answer keeps kill(0)'s verdict. The owner is looked at every 0.5 s.
Tests: TERM + communicate() with no wait() for both graces (stand-ins) and
for the timeout grace with the real registry, old vs new; a zombie is not
alive. All four fail on c5cebe6.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
finalize-end ran the disk grace and the broker re-observation as a child
of the wrapper; TERM to the wrapper left it running, and at the deadline it
still closed the job and published the error (codex R1, reproduced with the
real registry). The bash loops it replaced ended with the wrapper.
The wrapper now passes its own pid (--watch-pid "$$", as for the stall
watcher). finalize-end checks it every 0.2 s through both waits and before
the close and the event, and leaves without either once it is gone.
A comparison test sends TERM to the old and the new wrapper inside each
grace: both exit at once, leave no process behind and make no call.
Also: no blank line at the end of the old-decisions fixture, and the #42
inventory follows main() one line down.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wrapper keeps spawn / wait / kill, key injection and the herdr
diagnostic probe; what it decided in bash is now lib_py/delegation/wrapper.py,
reached through one `_decide` helper:
- finalize_unfinished: the clock choice, the disk grace and broker
re-observation, the close request / label, the close and the event
(finalize-begin / finalize-end; bash runs only the busy probe between them)
- start_stall_watch: whether to watch (MAM_STALL_WATCH, remote, human)
- _map_sub_rc, _rc_for (+ MAM_DELEGATE_FAIL_CLOSED), the subscriber
early-exit check and cmd_wait's rc normalisation
- mam_int / mam_int_knob run lib_py/ints.py, which is also the runner's
_positive_int: one implementation of the Issue #26 rule
No behaviour change: tests/test_issue17_item4_decisions.py runs the old
bash functions (copied verbatim from 7209750) and the new wrapper on the
same inputs and compares output, FINAL_JOB_STATUS and the registry /
publish calls. Three static tests that pinned the old bash literals now
pin the Python entry points instead.
Refs #17
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex round 2 (PR #48).
- R1: when `herdr config check` could not run (timeout, OSError), the
merged config was handed to the server as checked. A merge with a schema
error makes herdr drop the whole file, restore back on - the #45 failure.
The merged body is no longer used unchecked: _write_checked falls to the
fixed MINIMAL config with a warning. When the check cannot run on MINIMAL
either, MINIMAL is still used (with its own warning) rather than no
config: returning none would leave restore on, and MINIMAL is a fixed
two-line file the real-herdr test accepts. Unknown non-zero exits stay as
they were - herdr exits non-zero for harmless warnings (an unknown
section) that do not drop the file.
- R2: _shell_in_foreground compared shell_pid and the foreground group with
==, so 0/0, False/False, ""/"" or -1/-1 closed the pane. Both must now be
positive ints (bool excluded) before they are compared.
- R3: the re-parse check used ==, and nan != nan, so a valid TOML nan threw
away every other user setting even on Python 3.11. _same_doc compares
documents recursively with nan equal to nan.
Tests: R1 (timeout, OSError) and R3 (top-level, array, nested nan) in
test_issue45_herdr_config.py; R2 in the new tests/test_issue45_restore_unit.py
(a herdr stub: zero, false, empty, negative, null, string, true, float;
missing info; a real shell closed; a busy pane kept). On the previous code
12 of them fail. Python 3.11: 67 passed; Python 3.9: 33 passed / 34 skipped
(tomllib-only). Real-herdr restore tests and related files on 3.9: 197
passed / 1 skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
claude round 1, B2 and B3 (B1 was the previous commit).
- B2, tests/test_issue45_restore.py: the throwaway root is resolved, because
on macOS /tmp links to /private/tmp and herdr reports real paths (two B
tests compared /tmp/... with /private/tmp/...). The server's environment
is read from /proc on Linux and from `ps -E` elsewhere (macOS has no /proc;
two A tests failed with FileNotFoundError).
- B3, tests/test_issue9_server_env.py: the bootstrap builds the config with
the python3 on PATH, which here is 3.9 without tomllib, so the test's
merged-settings expectation could not hold and its own `import tomllib`
failed. The test now parses the result with that same python3; when it
has no tomllib, it expects the key-only config and the bootstrap's
"no tomllib" warning instead.
macOS, Python 3.9 venv: test_issue45_restore, test_issue9_server_env and
test_issue45_herdr_config 42 passed / 29 skipped (the tomllib-only checks).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
tests/test_issue45_herdr_config.py imported tomllib at module level. On
Python 3.9 - macOS's /usr/bin/python3, which the venvs and the bootstrap's
python3 use here - the import failed during collection and pytest ran no
test of the whole suite.
The import is optional now, and the 29 tests that parse a result with
tomllib or expect the user's settings kept are marked needs_tomllib: on
Python < 3.11 herdr_config deliberately writes the key-only config (restore
still off, the other settings dropped, a warning on stderr), which
test_without_tomllib_restore_is_still_off covers on every Python.
Python 3.9: 22 passed / 29 skipped; Python 3.11: 51 passed.
create SKILL.md (same line, so the #42 inventory is unchanged) and the
4.8.5 entry state that building the server config needs tomllib (Python
3.11+) in the python3 on PATH, and what an older python3 loses. Checked
with Python 3.9 in a throwaway HOME: the warning reaches stderr (the
bootstrap captures only stdout) and the user's file is untouched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
The fallback wrote <.mam>/herdr-server-config.toml unchecked, so it could
replace a foreign file there, the source itself, or a link to it. It now goes
through output_path like the normal path (source identity incl. aliases,
generated-for-this-source ownership, then a per-source name) and writes
nothing when no name is safe.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>