The update skill publishes .mam/update-in-progress (pid + lstart, the loop_lock.sh rules) while it holds .mam/update-admission.lock exclusively. create, resume, run_loop.sh and delegate submit (decision 8) and stop and orc-onboard (U2: a rollback would restore the registry without their write) take that lock shared (lib.sh mam_update_admit, fd 7), refuse while the marker is live or unknown, and hold the lock until their record is written. A stale marker refuses nothing. lib_py.pane_lock acquire gains --shared and --wait-env (MAM_UPDATE_ADMISSION_WAIT_S, default 120); create's wrapper path closes fd 7 for the long-lived child. The exit-1 rows of create/resume/stop name the refusal; skill_code_blocks.json pins follow the shifted lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
108 lines
4.7 KiB
Python
108 lines
4.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. S empty
|
|
(or `default`) is the shim's ambient target:
|
|
herdr then answers on the server of the pane
|
|
this runs in (HERDR_SESSION / HERDR_SOCKET_PATH,
|
|
as lib.sh _herdr_caller_server reads them), or
|
|
the default server outside any pane - the
|
|
lock is that server's (review e8e471ff R1)
|
|
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
|
|
[--shared] [--wait-env VAR] Issue #72: LOCK_SH instead, and the wait read
|
|
from VAR (same default and minimum) - the
|
|
update admission lock (lib.sh mam_update_admit)
|
|
|
|
The lock is a BSD flock, which belongs to the open file on fd 9: it outlives
|
|
this helper and ends when the last process holding that fd ends, however it
|
|
ends (exit, kill -9), so a dead holder leaves nothing to steal. The holders are
|
|
the shell running the claim block and the command it runs (the shim); a child
|
|
that outlived them would keep it too, so nothing long-lived may inherit fd 9 -
|
|
the herdr server is spawned with close_fds (review 66680d9d). 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 caller_server(env: Mapping[str, str]) -> str:
|
|
"""The server whose pane this process runs in, by the pane's exports:
|
|
HERDR_SESSION when present, else HERDR_SOCKET_PATH's layout
|
|
($HOME/.config/herdr/herdr.sock -> default, .../sessions/<name>/herdr.sock
|
|
-> name); "" outside a pane or for an unrecognised layout."""
|
|
if env.get("HERDR_SESSION"):
|
|
return env["HERDR_SESSION"]
|
|
sock = env.get("HERDR_SOCKET_PATH") or ""
|
|
if sock.endswith("/.config/herdr/herdr.sock"):
|
|
return "default"
|
|
if sock.endswith("/herdr.sock") and "/.config/herdr/sessions/" in sock:
|
|
return sock[:-len("/herdr.sock")].rsplit("/", 1)[-1]
|
|
return ""
|
|
|
|
|
|
def lock_path(session: Optional[str], env: Mapping[str, str]) -> str:
|
|
home = env.get("HOME") or env.get("HOME_DIR") or os.path.expanduser("~")
|
|
if session in (None, "", "default"):
|
|
session = caller_server(env) or "default"
|
|
return os.path.join(home, ".local", "state", "mam", "pane-lock", session + ".lock")
|
|
|
|
|
|
def wait_s(env: Mapping[str, str], var: str = "MAM_PANE_CLAIM_WAIT_S") -> int:
|
|
return ints.positive_int(env.get(var), 1) or DEFAULT_WAIT_S
|
|
|
|
|
|
def acquire(fd: int, wait: float, shared: bool = False) -> 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. Only contention is waited out: any other error (a bad
|
|
fd) raises, which the shim reports as an undecided lock."""
|
|
deadline = time.monotonic() + wait
|
|
while True:
|
|
try:
|
|
fcntl.flock(fd, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB)
|
|
return "locked"
|
|
except BlockingIOError:
|
|
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)
|
|
s.add_argument("--shared", action="store_true")
|
|
s.add_argument("--wait-env", default="MAM_PANE_CLAIM_WAIT_S")
|
|
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, a.wait_env), a.shared))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|