watchdog.py: unsynchronized read-modify-write on tasks.json silently drops registrations when arms run concurrently

Author: jackiectlCreated Aug 17, 2026Updated Aug 17, 2026

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.

python
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

bash
# 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:

python
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 filesystem

then 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:

  1. 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.
  2. --register could 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