Files
Harbor/tools/watch.py
T

102 lines
5.3 KiB
Python

"""Wait until something in a Harbor project needs the coordinator, then print what and exit.
py -3.12 -B tools/watch.py [--project open-seas] [--as Claude] [--minutes 40] [--every 60]
It exits (status 0) as soon as any of these appears after it started: a review item submitted or resubmitted, an item
approved (ready to land), a new message to the coordinator that is not an automatic notice, an agent resting under
autopilot, or a stop changing. It also exits after --minutes with a short status line, so a caller that re-runs it
gets a regular check. Read-only: it never writes to Harbor.
"""
import argparse
import json
import sqlite3
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from harbor import projects # noqa: E402
def snapshot(conn, me):
q = lambda sql, *a: conn.execute(sql, a).fetchall()
return {
"events": q("SELECT id, item_id, to_state, agent FROM item_events ORDER BY id DESC LIMIT 1"),
"messages": q("SELECT m.id FROM messages m JOIN deliveries d ON d.message_id=m.id WHERE d.agent=? AND "
"m.origin != 'system' ORDER BY m.id DESC LIMIT 1", me),
"flags": q("SELECT key, value, set_at FROM flags ORDER BY key"),
"resting": q("SELECT key FROM meta WHERE key LIKE 'autopilot:%' AND value LIKE '%\"resting\": true%'"),
}
def stalled(conn, project, me, minutes):
"""Running AI agents (not stopped, not me) whose last tool call is older than minutes: their session ended and
nothing restarted it (an app whose stop hook does not fire, or a turn that ended on its own)."""
if minutes <= 0:
return []
import datetime
cutoff = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=minutes)).strftime(
"%Y-%m-%dT%H:%M:%SZ")
stopped = {k for (k,) in conn.execute("SELECT key FROM flags WHERE value='on'")}
out = []
for spec in project.agents:
name = spec["name"]
if spec.get("kind") == "human" or name == me or "stop:all" in stopped or f"stop:{name}" in stopped:
continue
last = conn.execute("SELECT MAX(at) FROM activity_log WHERE agent=?", (name,)).fetchone()[0]
if last and last < cutoff:
out.append((name, last))
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--project")
ap.add_argument("--as", dest="me", default="Claude")
ap.add_argument("--minutes", type=float, default=40)
ap.add_argument("--every", type=float, default=60)
ap.add_argument("--stall", type=float, default=15, help="minutes without a tool call before a running agent "
"counts as stalled (0: off)")
ap.add_argument("--quiet-traffic", action="store_true",
help="skip review items and messages (helpers handle them); wake only on stalls, stops and resting")
args = ap.parse_args()
project = projects.resolve(args.project)
conn = sqlite3.connect(f"file:{project.db}?mode=ro", uri=True, timeout=30)
start = snapshot(conn, args.me)
last_event = start["events"][0][0] if start["events"] else 0
last_msg = start["messages"][0][0] if start["messages"] else 0
deadline = time.time() + args.minutes * 60
while time.time() < deadline:
time.sleep(args.every)
now = snapshot(conn, args.me)
found = []
for eid, item, state, agent in conn.execute(
"SELECT id, item_id, to_state, agent FROM item_events WHERE id > ? ORDER BY id", (last_event,)):
if state in ("submitted", "approved", "changes_requested") and agent != args.me and not args.quiet_traffic: # not my own moves
title = conn.execute("SELECT title FROM items WHERE id=?", (item,)).fetchone()[0]
found.append(f"item #{item} {state} by {agent}: {title}")
for mid, sender, body in conn.execute(
"SELECT m.id, m.sender, m.body FROM messages m JOIN deliveries d ON d.message_id=m.id WHERE d.agent=? "
"AND m.id > ? AND m.origin != 'system' ORDER BY m.id", (args.me, last_msg)):
if not args.quiet_traffic:
found.append(f"message #{mid} from {sender}: {(body.splitlines() or [''])[0][:140]}")
if now["flags"] != start["flags"]:
found.append("stop flags changed")
if now["resting"] != start["resting"] and now["resting"]:
found.append("resting under autopilot: " + ", ".join(k.split(":", 1)[1] for (k,) in now["resting"]))
for agent, last in stalled(conn, project, args.me, args.stall):
found.append(f"stalled: {agent} has made no tool call since {last[11:16]}Z (not stopped) - wake it")
if found:
print("\n".join(found))
return 0
open_items = conn.execute("SELECT COUNT(*) FROM items WHERE state IN ('submitted','in_review','approved')").fetchone()[0]
tasks = conn.execute("SELECT state, COUNT(*) FROM tasks GROUP BY state").fetchall()
last = conn.execute("SELECT agent, MAX(at) FROM activity_log GROUP BY agent").fetchall()
print(f"quiet for {args.minutes:g} min; open review items {open_items}; tasks {dict(tasks)}; last tool call "
+ ", ".join(f"{a} {t[11:16]}Z" for a, t in last))
return 0
if __name__ == "__main__":
sys.exit(main())