105 lines
5.5 KiB
Python
105 lines
5.5 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("--ignore", default="", help="comma-separated agents whose stalls are already reported")
|
|
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):
|
|
if agent in {a.strip() for a in args.ignore.split(",") if a.strip()}:
|
|
continue
|
|
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())
|