Files
Tirth Kanani 0442a67f5f fix(watch): discover directories by polling, not by directory events
Review of the first cut found that adoption of new directories could never
work on macOS: `FSEventsEmitter._is_recursive_event` drops every directory
event whose path is not the watch path itself, and every file event whose
dirname is not the watch path. A probe confirms it — under a non-recursive
watch, `mkdir child` and `child/file.txt` produce no events at all, while a
file at the watch root arrives normally. The earlier end-to-end run that
appeared to prove adoption was invalid: a hung previous run had already
created the directory, so the watcher planned it at startup rather than
adopting it.

Correctness no longer depends on events:

- the supervisor diffs one `scandir` per non-recursive watch each tick and
  adopts or releases from that (0.08 ms/tick on this repo, 0.47 ms on a
  synthetic 300-directory monorepo), then feeds the difference through the
  debouncer so a new directory's existing files are indexed and a deleted
  one's nodes are reconciled away;
- a dead thread whose watch root no longer exists is a release, not a death.
  Both backends stop an emitter when its own root disappears, so `rm -rf lib/`
  used to exit the watcher, and the daemon restarted it every 30s forever;
- the daemon now backs off exponentially between restarts and counts them,
  resetting once a watcher has stayed up;
- the repository root is resolved once, at the `watch()` boundary. `--repo .`
  previously broke adoption and release outright, and — pre-existing — made
  the startup reconciliation treat every stored absolute path as stale and
  delete it. A graph built under a genuinely different root is now refused
  with a clear error rather than reconciled away;
- running out of watch budget degrades to a recursive watch on the parent
  instead of silently covering less, and is reported as `partial` in
  `crg-daemon status`;
- health is published before the first build (a long initial build no longer
  reads as a stall), SIGTERM unwinds like Ctrl+C so `crg-daemon stop` leaves
  no stale health file, the daemon also clears it when it reaps a child, and
  the temp file is named per writer rather than per process;
- `unschedule` is time-boxed like the rest of teardown, since a wedged emitter
  is the premise of this whole change;
- nested build-output exclusions are logged at info level during a build and
  can be spared one at a time with `!path` in `.code-review-graphignore`.

A real-`Observer` regression test covers the new-directory case end to end and
asserts the file reaches the graph; it fails on the previous implementation and
passes now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fHfGDiZedoxjpKzanHri3
2026-08-19 21:07:57 +01:00

394 lines
11 KiB
Python

"""CLI entry point for the crg-daemon multi-repo watcher.
Usage:
crg-daemon start [--foreground]
crg-daemon stop
crg-daemon restart [--foreground]
crg-daemon status
crg-daemon logs [--repo ALIAS] [--follow] [--lines N]
crg-daemon add <path> [--alias ALIAS]
crg-daemon remove <path_or_alias>
"""
from __future__ import annotations
import argparse
import logging
import os
import signal
import subprocess
import sys
import time
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Subcommand handlers
# ---------------------------------------------------------------------------
def _handle_start(args: argparse.Namespace) -> None:
"""Start the daemon process."""
from .daemon import WatchDaemon, is_daemon_running, load_config, write_pid
if is_daemon_running():
print("Error: Daemon is already running.")
sys.exit(1)
config = load_config()
daemon = WatchDaemon(config=config)
if not args.foreground:
# Fork before start() creates watcher and health-check threads.
daemon.daemonize()
else:
write_pid()
try:
if args.foreground:
daemon._setup_signal_handlers()
daemon.start()
daemon.run_forever()
finally:
# Covers normal return, startup failure, KeyboardInterrupt, and signals.
daemon.stop()
def _handle_stop(_args: argparse.Namespace) -> None:
"""Stop the running daemon process."""
from .daemon import clear_pid, is_daemon_running, pid_alive, read_pid
if not is_daemon_running():
print("Daemon is not running.")
sys.exit(1)
pid = read_pid()
if pid is None:
print("Error: Could not read daemon PID.")
sys.exit(1)
print(f"Stopping daemon (PID {pid})...")
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
clear_pid()
print("Daemon stopped (process already gone).")
return
except PermissionError:
print(f"Error: Permission denied sending signal to PID {pid}.")
sys.exit(1)
try:
# Wait up to 5 seconds for process to die.
for _ in range(50):
if not pid_alive(pid):
break
time.sleep(0.1)
else:
print("Daemon did not stop gracefully, force-stopping...")
force_signal = getattr(signal, "SIGKILL", signal.SIGTERM)
try:
os.kill(pid, force_signal)
except ProcessLookupError:
pass
finally:
clear_pid()
print("Daemon stopped.")
def _handle_restart(args: argparse.Namespace) -> None:
"""Restart the daemon (stop + start)."""
from .daemon import is_daemon_running
if is_daemon_running():
_handle_stop(args)
else:
print("Daemon is not running, starting fresh.")
_handle_start(args)
def _format_age(seconds: float | None) -> str:
"""Render an age in seconds compactly: ``12s``, ``4m``, ``3h``, ``2d``."""
if seconds is None:
return "-"
seconds = max(0.0, float(seconds))
if seconds < 60:
return f"{seconds:.0f}s"
if seconds < 3600:
return f"{seconds / 60:.0f}m"
if seconds < 86400:
return f"{seconds / 3600:.0f}h"
return f"{seconds / 86400:.0f}d"
def _handle_status(_args: argparse.Namespace) -> None:
"""Show daemon status and configuration."""
from .daemon import (
is_daemon_running,
load_config,
load_state,
pid_alive,
read_pid,
read_watch_health,
watcher_status,
)
config = load_config()
running = is_daemon_running()
if running:
pid = read_pid()
print(f"Daemon: running (PID {pid})")
else:
print("Daemon: not running")
print(f"Name: {config.session_name}")
print(f"Log dir: {config.log_dir}")
print(f"Poll: {config.poll_interval}s")
print()
if not config.repos:
print("No repositories configured.")
print("Use: crg-daemon add <path> [--alias NAME]")
return
# Header
alias_width = max(len(r.alias) for r in config.repos)
alias_width = max(alias_width, 5) # minimum "Alias" header width
if running:
state = load_state()
header = (
f" {'Alias':<{alias_width}} {'Status':<8} {'Watcher':<8} "
f"{'PID':<8} {'Event':<6} Path"
)
print(header)
print(
f" {'-' * alias_width} {'-' * 8} {'-' * 8} {'-' * 8} "
f"{'-' * 6} {'-' * 40}"
)
stalled = False
degraded = False
for repo in config.repos:
entry = state.get(repo.alias, {})
child_pid: int | None = entry.get("pid")
alive = child_pid is not None and pid_alive(child_pid)
status_str = "alive" if alive else "dead"
pid_str = str(child_pid) if child_pid is not None else "-"
health = read_watch_health(repo.path)
watcher = watcher_status(alive, health)
stalled = stalled or watcher == "stalled"
degraded = degraded or watcher == "partial"
last_event = health.get("last_event_at") if health else None
event_str = (
_format_age(time.time() - last_event)
if isinstance(last_event, (int, float))
else "-"
)
print(
f" {repo.alias:<{alias_width}} {status_str:<8} {watcher:<8} "
f"{pid_str:<8} {event_str:<6} {repo.path}"
)
if stalled:
print()
print(
" A stalled watcher means the process is up but its filesystem "
"observer is not;"
)
print(
" the graph is no longer updating. Check the log, then: "
"crg-daemon restart"
)
if degraded:
print()
print(
" A partial watcher ran out of watch slots and fell back to one "
"recursive watch:"
)
print(
" still complete, but ignored trees are watched again. Raise "
"CRG_MAX_WATCH_SCHEDULES."
)
else:
print(f" {'Alias':<{alias_width}} Path")
print(f" {'-' * alias_width} {'-' * 40}")
for repo in config.repos:
print(f" {repo.alias:<{alias_width}} {repo.path}")
def _handle_logs(args: argparse.Namespace) -> None:
"""Show daemon or per-repo log files."""
from .daemon import load_config
config = load_config()
if args.repo:
log_file = config.log_dir / f"{args.repo}.log"
else:
log_file = config.log_dir / "daemon.log"
if not log_file.exists():
print(f"Log file not found: {log_file}")
sys.exit(1)
if args.follow:
try:
subprocess.run(["tail", "-f", str(log_file)], check=False)
except KeyboardInterrupt:
pass
return
# Read last N lines
lines_count = args.lines
try:
text = log_file.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
print(f"Error reading log file: {exc}")
sys.exit(1)
lines = text.splitlines()
tail = lines[-lines_count:] if len(lines) > lines_count else lines
for line in tail:
print(line)
def _handle_add(args: argparse.Namespace) -> None:
"""Add a repository to the daemon config."""
from .daemon import add_repo_to_config, is_daemon_running
try:
add_repo_to_config(args.path, alias=args.alias)
except ValueError as exc:
print(f"Error: {exc}")
sys.exit(1)
# Find the repo we just added to show confirmation
alias = args.alias or os.path.basename(os.path.abspath(args.path))
print(f"Added repository: {args.path} (alias: {alias})")
if is_daemon_running():
print("Daemon will pick up the change automatically.")
def _handle_remove(args: argparse.Namespace) -> None:
"""Remove a repository from the daemon config."""
from .daemon import is_daemon_running, load_config, remove_repo_from_config
config_before = load_config()
count_before = len(config_before.repos)
config_after = remove_repo_from_config(args.path_or_alias)
count_after = len(config_after.repos)
if count_before == count_after:
print(f"No repository matching '{args.path_or_alias}' found in config.")
sys.exit(1)
print(f"Removed repository: {args.path_or_alias}")
if is_daemon_running():
print("Daemon will pick up the change automatically.")
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def main() -> None:
"""Entry point for the crg-daemon CLI."""
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
ap = argparse.ArgumentParser(
prog="crg-daemon",
description="Multi-repo watch daemon for code-review-graph",
)
sub = ap.add_subparsers(dest="command")
# start
start_cmd = sub.add_parser("start", help="Start the daemon")
start_cmd.add_argument(
"--foreground",
action="store_true",
help="Run in the foreground instead of daemonizing",
)
# stop
sub.add_parser("stop", help="Stop the daemon")
# restart
restart_cmd = sub.add_parser("restart", help="Restart the daemon")
restart_cmd.add_argument(
"--foreground",
action="store_true",
help="Run in the foreground instead of daemonizing",
)
# status
sub.add_parser("status", help="Show daemon status and configuration")
# logs
logs_cmd = sub.add_parser("logs", help="Show daemon or per-repo logs")
logs_cmd.add_argument(
"--repo",
default=None,
metavar="ALIAS",
help="Show logs for a specific repo (by alias)",
)
logs_cmd.add_argument(
"--follow",
"-f",
action="store_true",
help="Follow log output (tail -f)",
)
logs_cmd.add_argument(
"--lines",
"-n",
type=int,
default=50,
help="Number of lines to show (default: 50)",
)
# add
add_cmd = sub.add_parser("add", help="Add a repository to the daemon config")
add_cmd.add_argument("path", help="Path to the repository")
add_cmd.add_argument(
"--alias",
default=None,
help="Short alias for the repository (default: directory name)",
)
# remove
remove_cmd = sub.add_parser("remove", help="Remove a repository from the daemon config")
remove_cmd.add_argument("path_or_alias", help="Repository path or alias to remove")
args = ap.parse_args()
if not args.command:
ap.print_help()
sys.exit(0)
handlers: dict[str, object] = {
"start": _handle_start,
"stop": _handle_stop,
"restart": _handle_restart,
"status": _handle_status,
"logs": _handle_logs,
"add": _handle_add,
"remove": _handle_remove,
}
handler = handlers.get(args.command)
if handler is None:
ap.print_help()
sys.exit(1)
handler(args) # type: ignore[operator]
if __name__ == "__main__":
main()