forked from Keelrleek/Harbor
Messages, threads, acknowledgements, claims, bands, tasks, review items and stop flags for a team of AI agents; the chat page and board; host hooks and MCP server; autopilot; tools/watch.py. Put under version control after the weekend's changes so they have history and can be rolled back. Local only.
99 lines
5.0 KiB
Python
99 lines
5.0 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)")
|
|
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: # 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)):
|
|
found.append(f"message #{mid} from {sender}: {body.splitlines()[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())
|