fix(issue-19): close the trap race, select daemons by argv shape, align docs with the polling fallback

Review round 1 (claude PASS, agy PASS) recommendations:
- reconcile.sh: `_sub_stop` falls back to `$!` for the window before
  `_sub_pid=$!` (R-1); lock identity compared as (st_dev, st_ino).
- lib.sh legacy start_watchdog prints nothing when opted out and no daemon
  runs, byte-identical to the python body.
- conftest/test: `_is_subscriber_daemon_argv` selects only
  `bash|sh <root>/.../reconcile.sh --subscribe`, never a brief-carrying
  wrapper that quotes the words or a prefix-colliding sibling root (R-2);
  one new unit test for the selector.
- monitor SKILL.md / VERSIONS.md: the polling fallback checks the lock
  file's presence only and does not exclude a second daemon; measurement
  wording uses the argv-shape criterion instead of raw pgrep.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Fable 5.1 committed 2026-09-13 16:11:53 +09:00
1 parent 4da4a50682
commit c096cb455b
6 files changed
+69 -16

No files matched your search

+4 -1
View File
@@ -2021,7 +2021,10 @@ _start_watchdog_legacy() {
cd "$orig_pwd" || return 1
fi
echo "$pid"
# Byte-identical to the python body: nothing on stdout when no daemon runs.
if [ -n "$pid" ]; then
echo "$pid"
fi
}
# ---------------------------------------------------------------------------
@@ -76,10 +76,13 @@ per workspace through `lib.sh::start_watchdog` (`lib_py.delegation.daemon`). Tha
- is **one per workspace** — `.mam/monitor.lock` (flock) rejects a second instance, and
`start_watchdog` returns the running PID instead of spawning again;
- **ends on its own only when its workspace is gone** — every wake-up (≤ 5 s on the MQTT
loop, every poll on the broker-down fallback) it checks that the workspace directory
still exists and that `.mam/monitor.lock` is still the inode it holds. A deleted
workspace, a purged `.mam/`, or a re-created lock file makes it disconnect and exit 0;
- **ends on its own only when its workspace is gone** — every wake-up (≤ 5 s) the MQTT
loop checks that the workspace directory still exists and that `.mam/monitor.lock` is
still the `(st_dev, st_ino)` it holds; a deleted workspace, a purged `.mam/`, or a
re-created lock file makes it disconnect and exit 0. The broker-down polling fallback
checks the directory and the lock file's *presence* on every pass — it no longer holds
the lock (the python subscriber released it on exit), so while polling it does not
exclude a second daemon, as before;
- **stops promptly on `SIGTERM`/`SIGINT`** — the bash wrapper forwards the signal to the
python subscriber and exits 143. `SIGKILL` is never required;
- is **not started when `MAM_NO_SUBSCRIBER_DAEMON=1`** — the literal `1` only. Use it when
@@ -79,9 +79,12 @@ if [ "$SUBSCRIBE" = "1" ]; then
_sub_pid=""
_sub_stop() {
trap - TERM INT
if [ -n "$_sub_pid" ]; then
kill -TERM "$_sub_pid" 2>/dev/null || true
wait "$_sub_pid" 2>/dev/null || true
# `$!` covers the window between `&` and `_sub_pid=$!` (review R-1); a
# child that already exited makes kill/wait harmless no-ops.
local child="${_sub_pid:-${!:-}}"
if [ -n "$child" ]; then
kill -TERM "$child" 2>/dev/null || true
wait "$child" 2>/dev/null || true
fi
exit 143
}
@@ -115,7 +118,8 @@ except BlockingIOError:
except Exception as e:
print(f"MQTT Monitor: failed to acquire monitor lock ({e}). Exiting.", flush=True)
sys.exit(1)
lock_ino = os.fstat(lock_file.fileno()).st_ino
_lock_st = os.fstat(lock_file.fileno())
lock_ident = (_lock_st.st_dev, _lock_st.st_ino)
def _workspace_gone():
@@ -131,7 +135,8 @@ def _workspace_gone():
if not os.path.isdir(root):
return f"workspace {root} no longer exists"
try:
if os.stat(lock_file_path).st_ino != lock_ino:
st = os.stat(lock_file_path)
if (st.st_dev, st.st_ino) != lock_ident:
return f"{lock_file_path} was replaced"
except OSError:
return f"{lock_file_path} no longer exists"
@@ -367,6 +372,9 @@ PYEOF
_start=$(date +%s)
# Issue #19: same lifecycle as the MQTT loop - stop when the workspace is
# gone, and sleep in a waited child so SIGTERM/SIGINT are not deferred.
# The python child released .mam/monitor.lock when it exited, so this
# fallback checks the lock file's *presence* only and does not exclude a
# second daemon (pre-existing behaviour of the fallback).
trap 'trap - TERM INT; exit 143' TERM INT
while :; do
if [ ! -d "$WORKSPACE_ROOT" ] || [ ! -f "$WORKSPACE_ROOT/.mam/monitor.lock" ]; then