Files
multi-agent-mux/.agents/skills/lib_py/pane_lock.py
T
GodopuandClaude Fable 5.1 7844ae2c5f 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>
2026-10-11 06:05:52 +00:00

76 lines
2.7 KiB
Python

"""Issue #50 follow-up: one pane claim at a time per herdr server.
The herdr shim's `new-session` chooses a pane - an idle one in a workspace on
the same cwd (BUG-4), a split of one, or the root pane of a fresh workspace -
and then claims it with `agent start`. Two shims on one server (several
`create` at once into one folder) could choose the same idle pane in between,
and the later `agent start` failed with agent_pane_busy. The shim now holds
one lock per server from the choice to the claim.
pane_lock.py path --session S the lock file of herdr session S, under the
HOME whose socket the session uses
pane_lock.py acquire --fd N flock(LOCK_EX) on the inherited fd N; prints
`locked`, or `timeout` once MAM_PANE_CLAIM_WAIT_S
(default 120, minimum 1) has passed
The lock is a BSD flock, which belongs to the open file the shim holds on fd 9:
it outlives this helper and ends with the shim's process, however that ends
(exit, kill -9), so a dead holder leaves nothing to steal. flock(1) is not on
macOS; fcntl.flock is. Stdlib only, Python 3.9.
"""
from __future__ import annotations
import argparse
import fcntl
import os
import sys
import time
from typing import List, Mapping, Optional
from lib_py import ints
DEFAULT_WAIT_S = 120
def lock_path(session: Optional[str], env: Mapping[str, str]) -> str:
home = env.get("HOME") or env.get("HOME_DIR") or os.path.expanduser("~")
return os.path.join(home, ".local", "state", "mam", "pane-lock", (session or "default") + ".lock")
def wait_s(env: Mapping[str, str]) -> int:
return ints.positive_int(env.get("MAM_PANE_CLAIM_WAIT_S"), 1) or DEFAULT_WAIT_S
def acquire(fd: int, wait: float) -> str:
"""`locked`, or `timeout` when the lock was not free within `wait` s. The
deadline is on the monotonic clock; a step of the wall clock does not
stretch or cut it."""
deadline = time.monotonic() + wait
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return "locked"
except OSError:
if time.monotonic() >= deadline:
return "timeout"
time.sleep(0.2)
def main(argv: Optional[List[str]] = None) -> int:
p = argparse.ArgumentParser(prog="pane_lock.py")
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("path")
s.add_argument("--session", default="")
s = sub.add_parser("acquire")
s.add_argument("--fd", type=int, required=True)
a = p.parse_args(argv)
if a.cmd == "path":
print(lock_path(a.session, os.environ))
else:
print(acquire(a.fd, wait_s(os.environ)))
return 0
if __name__ == "__main__":
sys.exit(main())