watchdog.py: unsynchronized read-modify-write on tasks.json silently drops registrations when arms run concurrently
Summary
tools/watchdog.py's --register / --unregister perform an unsynchronized read-modify-write over the whole of tasks.json. When two ARIS arms register at the same time against the same --base-dir (the default /tmp/aris-watchdog is shared machine-wide), the later writer silently discards the earlier one's task, and --register still prints registered: <name>.
The result is worse than a missing watchdog: the operator has a success message and believes an unattended loop is being watched when it is not.
Where
tools/watchdog.py, register_task() — read at L100-104, filter at L107, whole-file write; and unregister_task() L118-128, same shape.
tasks = []
if paths["tasks"].exists():
try:
tasks = json.loads(paths["tasks"].read_text())
except (json.JSONDecodeError, OSError):
tasks = [] # <-- see "amplification" below
tasks = [t for t in tasks if t["name"] != task["name"]]
...
paths["tasks"].write_text(json.dumps(tasks, indent=2))There is no flock/fcntl, no lockfile, and no atomic os.replace on this path. (The atomic at L93 is a comment about the watched loop's state file, not about tasks.json.)
Reproduction
# two shells, or one:
python3 tools/watchdog.py --register '{"name":"armA","type":"loop","state_file":"/tmp/a.json","stale_after_seconds":21600}' &
python3 tools/watchdog.py --register '{"name":"armB","type":"loop","state_file":"/tmp/b.json","stale_after_seconds":21600}' &
wait
python3 -c "import json;print([t['name'] for t in json.load(open('/tmp/aris-watchdog/tasks.json'))])"
# often prints only one of the two; both commands printed "registered:"Observed in practice
Three ARIS arms were active on one machine. Arm C registered and got registered: <name>; ~30 minutes later tasks.json contained the other two arms' tasks and not arm C's. Nothing in the tooling surfaced the loss — it was found only by reading tasks.json directly.
This is exactly the failure mode skills/shared-references/external-cadence.md L197-216 exists to prevent ("A /loop or CronCreate heartbeat is parasitic on a living session; if it dies nothing notices"), so a lost registration removes the one mechanism meant to catch a silent death.
Amplification: a torn read wipes every task
The except (json.JSONDecodeError, OSError): tasks = [] fallback means a reader that lands on a partially written tasks.json does not retry or fail — it treats the registry as empty and then writes a file containing only its own task. One unlucky interleaving therefore unregisters every other arm on the machine, not just one.
Suggested fix
Serialize the read-modify-write and make the write atomic. Stdlib-only, ~10 lines:
import fcntl, os, tempfile
from contextlib import contextmanager
@contextmanager
def _registry_lock(paths):
paths["base"].mkdir(parents=True, exist_ok=True)
lock = paths["base"] / "tasks.lock"
with open(lock, "w") as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(fh, fcntl.LOCK_UN)
def _write_tasks_atomic(paths, tasks):
d = paths["tasks"].parent
fd, tmp = tempfile.mkstemp(dir=d, prefix=".tasks.", suffix=".json")
with os.fdopen(fd, "w") as fh:
json.dump(tasks, fh, indent=2)
fh.flush(); os.fsync(fh.fileno())
os.replace(tmp, paths["tasks"]) # atomic within a filesystemthen wrap the whole read-filter-write of both register_task and unregister_task in with _registry_lock(paths): and swap write_text for _write_tasks_atomic.
Two smaller points worth folding in:
- Do not silently empty the registry on a parse error. With the lock plus atomic replace a torn read becomes impossible, but a genuinely corrupt file should still be reported rather than treated as
[]— otherwise a real corruption silently unregisters everything. --registercould verify and exit non-zero if its own task is absent from the file it just wrote. That turns "registered but not watched" into a loud failure, which matters because the whole point of this tool is that nobody is watching.
⚠ fcntl is POSIX-only and the repo ships tools/install_aris.ps1, so Windows needs msvcrt.locking or a portable lockfile; happy to send a PR either way if you have a preference.
Environment
- macOS (darwin 24.5.0), Python 3.13, default
--base-dir /tmp/aris-watchdog - three concurrent arms, watchdog daemon running with
--interval 300
Source: wanshuiyin/Auto-claude-code-research-in-sleep