fix(create): one pane claim at a time per herdr server; a create keeps the pane the monitor registered for it

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>
This commit is contained in:
GodopuandClaude Fable 5.1 committed 2026-10-11 06:05:52 +00:00
1 parent 30c25f193c
commit 7844ae2c5f
9 files changed
+480 -27

No files matched your search

+34
View File
@@ -604,3 +604,37 @@ kill "$_mam_server_pid" 2>/dev/null || true
f"daemon child stayed in the caller's process group (not detached): {fields}"
)
def test_h26_new_session_claims_its_pane_under_the_servers_lock(mam_sandbox, mock_herdr, mock_agents):
"""Issue #50 follow-up: from the pane choice to `agent start` the shim holds
one lock per herdr server (lib_py.pane_lock, a flock on fd 9). While
another shim holds it, this one waits MAM_PANE_CLAIM_WAIT_S and then gives
up without creating, splitting or starting anything; once it is free the
same call goes through as before."""
import glob
import os
tmp_path = mam_sandbox
res = _run_lib(tmp_path, f'herdr new-session -d -s "test-creator-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions"')
assert res.returncode == 0, res.stderr
locks = glob.glob(str(tmp_path / ".local" / "state" / "mam" / "pane-lock" / "*.lock"))
assert len(locks) == 1, locks # one file per server, under the sandbox HOME
n_calls = len(_load_mock_state(mock_herdr).get("calls", []))
holder = subprocess.Popen(["bash", "-c", 'exec 9>>"$1"; python3 -m lib_py.pane_lock acquire --fd 9; sleep 600',
"holder", locks[0]], stdout=subprocess.PIPE, text=True,
env=dict(os.environ, PYTHONPATH=str(tmp_path / ".agents" / "skills")))
try:
assert holder.stdout.readline().strip() == "locked"
os.environ["MAM_PANE_CLAIM_WAIT_S"] = "1"
res = _run_lib(tmp_path, f'herdr new-session -d -s "test-reviewer-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions"')
assert res.returncode == 1 and "did not end within MAM_PANE_CLAIM_WAIT_S" in res.stderr, (res.returncode, res.stderr)
new_calls = [c[2:] if c[:1] == ["--session"] else c for c in _load_mock_state(mock_herdr).get("calls", [])[n_calls:]]
assert not [c for c in new_calls if c[:2] in (["workspace", "create"], ["pane", "split"], ["agent", "start"])], new_calls
assert not [n for n in _load_mock_state(mock_herdr).get("agents", {}) if "test-reviewer-claude" in n]
finally:
os.environ.pop("MAM_PANE_CLAIM_WAIT_S", None)
holder.kill()
holder.wait()
res = _run_lib(tmp_path, f'herdr new-session -d -s "test-reviewer-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions"')
assert res.returncode == 0, res.stderr
assert [n for n in _load_mock_state(mock_herdr).get("agents", {}) if "test-reviewer-claude" in n]