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

@@ -527,6 +527,20 @@ sessions = d.setdefault('herdr_sessions', [])
# P0-D: 같은 이름 엔트리가 status=running 이면만 거부. terminated/archived 는
# 재사용 가능 — 낡은 엔트리를 제거하고 새로 append (create -> delete -> create).
running_same = [s for s in sessions if s.get('name') == name and s.get('status') == 'running']
# Issue #50 follow-up: a reconcile pass (another create's end-of-run `--once`,
# the subscriber's cycle) can adopt the pane this create started moments ago,
# before this row lands. A running row of this name that the monitor
# registered, with no job, for a pane created after this create began, is
# this create's own pane - herdr keeps live names unique per server and the
# preflight refused an older session of this name - so it is replaced, not
# refused as a second live session.
def _adopted_own(s):
return (s.get('last_visible_note') == 'auto-registered by monitor' and not s.get('delegate_job_id')
and epoch.isdigit() and int(s.get('herdr_session_epoch') or 0) >= int(epoch) - 1)
if running_same and all(_adopted_own(s) for s in running_same):
print(f"NOTE: the monitor registered {name} while it was being created; that row is replaced by this one", flush=True)
running_same = []
old_roles.pop(name, None) # its role was read off the name; --role is the fact (atomic_yaml's role guard)
if running_same:
print(f"ERROR: {name} already running in agent-sessions.yaml", flush=True)
raise SystemExit(4)