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.
256 lines
14 KiB
Python
256 lines
14 KiB
Python
"""How work flows: git radar, bulk tasks and templates, the stuck-agent watchdog, and team policies.
|
|
|
|
git radar read-only git facts per agent workdir: branch, last commit, uncommitted files, commits behind the main
|
|
branch, and commits made since the agent last submitted work (work not yet in the review queue)
|
|
templates multi-step task pipelines defined in the project file, created in one call with dependencies
|
|
bulk many tasks from a pasted list ("0x445310 RouteCellCost"), routed to the band owner
|
|
watchdog an agent busy with tools for hours without any visible progress
|
|
policies rules from the project file that Harbor enforces (approvals before landing, message caps)
|
|
|
|
Mixed into Harbor (core.py).
|
|
"""
|
|
import re
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from .util import HarborError, first_line, iso, parse_iso, parse_scope, scopes_overlap, utc
|
|
|
|
GIT_TIMEOUT = 10
|
|
_git_cache = {}
|
|
|
|
|
|
class _Values(dict):
|
|
"""Template placeholders; an unknown {name} stays as written instead of failing."""
|
|
def __missing__(self, key):
|
|
return "{" + key + "}"
|
|
|
|
|
|
def _git(workdir, *args):
|
|
"""A read-only git command (never takes locks, never writes the index)."""
|
|
# The worktree's own config is agent-controlled: never let it start programs (fsmonitor, pagers) from here.
|
|
r = subprocess.run(["git", "--no-pager", "--no-optional-locks", "-c", "core.fsmonitor=false",
|
|
"-c", "core.untrackedCache=false", "-C", str(workdir), *args], capture_output=True, text=True,
|
|
encoding="utf-8", errors="replace", timeout=GIT_TIMEOUT,
|
|
env={**__import__("os").environ, "GIT_TERMINAL_PROMPT": "0", "GIT_OPTIONAL_LOCKS": "0"})
|
|
if r.returncode != 0:
|
|
raise RuntimeError(r.stderr.strip() or f"git {' '.join(args)} failed")
|
|
return r.stdout
|
|
|
|
|
|
class FlowMixin:
|
|
def worktree_change(self, agent):
|
|
"""(mtime, path) of the most recently modified uncommitted file in agent's worktree, or None."""
|
|
spec = next((a for a in self.project.agents if a["name"] == agent), {})
|
|
workdir = spec.get("workdir")
|
|
if not workdir or not Path(workdir).is_dir():
|
|
return None
|
|
newest = None
|
|
for line in _git(workdir, "status", "--porcelain", "-uall").splitlines()[:3000]:
|
|
path = line[3:].split(" -> ")[-1].strip().strip('"')
|
|
try:
|
|
mtime = (Path(workdir) / path).stat().st_mtime
|
|
except OSError:
|
|
continue
|
|
if newest is None or mtime > newest[0]:
|
|
newest = (mtime, path)
|
|
return newest
|
|
|
|
# ----------------------------------------------------------------- git radar
|
|
def git_status(self, agent, max_age=60):
|
|
"""Git facts for agent's workdir (cached for max_age seconds)."""
|
|
spec = next((a for a in self.project.agents if a["name"] == agent), None)
|
|
workdir = spec and spec.get("workdir")
|
|
if not workdir or not Path(workdir).is_dir():
|
|
return None
|
|
key = (str(workdir), self.project.name)
|
|
hit = _git_cache.get(key)
|
|
if hit and time.monotonic() - hit[0] < max_age:
|
|
return hit[1]
|
|
main = self.project.git_main
|
|
out = {"agent": agent, "workdir": workdir}
|
|
try:
|
|
out["branch"] = _git(workdir, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
|
sha, stamp, subject = (_git(workdir, "log", "-1", "--format=%h%x1f%ct%x1f%s").strip().split("\x1f") + ["", "", ""])[:3]
|
|
out["last_commit"] = {"sha": sha, "at": iso(utc(int(stamp))) if stamp else None, "subject": subject}
|
|
out["dirty"] = len([l for l in _git(workdir, "status", "--porcelain").splitlines() if l.strip()])
|
|
out["behind_main"] = out["ahead_of_main"] = 0
|
|
if out["branch"] != main:
|
|
try:
|
|
behind, ahead = _git(workdir, "rev-list", "--left-right", "--count", f"{main}...HEAD").split()
|
|
out["behind_main"], out["ahead_of_main"] = int(behind), int(ahead)
|
|
except (RuntimeError, ValueError):
|
|
out["main_missing"] = True
|
|
since = self._last_submit_at(agent)
|
|
# Author dates survive a rebase (commit dates do not), so rebased work does not look new again.
|
|
cutoff = since or iso(utc(self.clock() - 86400))
|
|
args = ["log", "--format=%h%x1f%at%x1f%s", "-200"]
|
|
if out["branch"] != main and not out.get("main_missing"):
|
|
args.append(f"{main}..HEAD")
|
|
commits = []
|
|
for line in _git(workdir, *args).splitlines():
|
|
parts = line.split("\x1f")
|
|
if len(parts) == 3 and iso(utc(int(parts[1]))) > cutoff:
|
|
commits.append({"sha": parts[0], "at": iso(utc(int(parts[1]))), "subject": parts[2]})
|
|
commits = commits[:50]
|
|
known = {r[0][:7] for r in self.conn.execute("SELECT commit_hash FROM items WHERE commit_hash IS NOT NULL")
|
|
if r[0]}
|
|
submits = out["branch"] != main and str(spec.get("git_submits", True)).lower() not in ("false", "0", "no")
|
|
out["unsubmitted"] = [c for c in commits if c["sha"][:7] not in known] if submits else []
|
|
out["since"] = since
|
|
except (RuntimeError, OSError, subprocess.SubprocessError, ValueError) as error:
|
|
out["error"] = str(error)[:200]
|
|
_git_cache[key] = (time.monotonic(), out)
|
|
return out
|
|
|
|
def _last_submit_at(self, agent):
|
|
row = self.conn.execute("SELECT MAX(at) a FROM item_events WHERE agent=? AND to_state='submitted'",
|
|
(agent,)).fetchone()
|
|
return row["a"] if row and row["a"] else None
|
|
|
|
def git_radar(self):
|
|
return {a["name"]: self.git_status(a["name"]) for a in self.project.agents
|
|
if a["kind"] != "human" and a.get("workdir")}
|
|
|
|
# ----------------------------------------------------------------- bulk tasks and templates
|
|
def band_owner(self, scope):
|
|
canonical, lo, hi = parse_scope(scope)
|
|
if lo is None:
|
|
return None
|
|
now = self.now()
|
|
for c in self.conn.execute("SELECT * FROM claims WHERE kind='band' AND released_at IS NULL AND "
|
|
"(expires_at IS NULL OR expires_at > ?)", (now,)):
|
|
if scopes_overlap((canonical, lo, hi), (c["scope"], c["lo"], c["hi"])):
|
|
return c["agent"]
|
|
return None
|
|
|
|
def run_template(self, by, template, scope=None, title=None, owner=None, priority="normal", origin="cli"):
|
|
"""Create the steps of a template as linked tasks. Placeholders: {scope}, {title}, {owner}."""
|
|
spec = self.project.templates.get(template)
|
|
if not spec:
|
|
raise HarborError(f"no template {template!r}; the project file defines: "
|
|
+ (", ".join(self.project.templates) or "none"))
|
|
owner = owner or (self._route(by, scope) if scope else None)
|
|
values = _Values(scope=scope or "", title=title or scope or template, owner=owner or "")
|
|
for step in spec.get("steps", []): # check every step before creating any
|
|
assignee = (step.get("assignee") or "").format_map(values) or None
|
|
if assignee:
|
|
self._check_agent(assignee, allow_human=False)
|
|
if assignee != by and by not in self.coordinators:
|
|
raise HarborError(f"template {template!r} assigns a step to {assignee}; only a coordinator does that")
|
|
created, by_name = [], {}
|
|
for step in spec.get("steps", []):
|
|
assignee = (step.get("assignee") or "").format_map(values) or None
|
|
after = [by_name[n] for n in step.get("after", []) if n in by_name]
|
|
task = self.add_task(by, step.get("title", "{title}").format_map(values), body=step.get("body"),
|
|
scope=scope if step.get("scoped", True) else None, assignee=assignee,
|
|
priority=step.get("priority", priority), after=after, origin=origin)
|
|
by_name[step.get("name", str(len(by_name)))] = task["id"]
|
|
created.append(task)
|
|
return created
|
|
|
|
LINE = re.compile(r"^\s*(?:fn:)?(0x[0-9A-Fa-f]+)\b[\s:|,-]*(.*)$")
|
|
|
|
def bulk_tasks(self, by, text, template=None, priority="normal", assignee=None, origin="cli"):
|
|
"""One task (or template pipeline) per line: '0x445310 RouteCellCost' or 'fn:0x445310 | Make it readable'.
|
|
Lines without an address become plain tasks. Each address goes to its band owner unless assignee is set."""
|
|
if assignee and assignee != by and by not in self.coordinators:
|
|
raise HarborError("only a coordinator assigns tasks to someone else")
|
|
created, skipped = [], []
|
|
open_scopes = {t["scope"] for t in self.tasks(states=("open", "in_progress", "blocked", "review"), limit=100000)}
|
|
for raw in str(text).splitlines(): # validate everything first: no half-created batches
|
|
m = self.LINE.match(raw.strip()) if raw.strip() and not raw.strip().startswith("#") else None
|
|
if m:
|
|
parse_scope(f"fn:{m.group(1)}")
|
|
for raw in str(text).splitlines():
|
|
line = raw.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
m = self.LINE.match(line)
|
|
scope = f"fn:{m.group(1)}" if m else None
|
|
name = (m.group(2).strip() if m else line) or None
|
|
if scope:
|
|
canonical = parse_scope(scope)[0]
|
|
if canonical in open_scopes:
|
|
skipped.append(f"{canonical} already has an open task")
|
|
continue
|
|
open_scopes.add(canonical)
|
|
if template:
|
|
created += self.run_template(by, template, scope=scope, title=name, owner=assignee, priority=priority,
|
|
origin=origin)
|
|
else:
|
|
title = (f"Make {scope.split(':', 1)[1]} readable" + (f" ({name})" if name else "")) if scope else name
|
|
created.append(self.add_task(by, title, scope=scope,
|
|
assignee=assignee or (self._route(by, scope) if scope else None),
|
|
priority=priority, origin=origin, notify=template is not None))
|
|
if not template: # one notice per agent, not one thread per task
|
|
mine = {}
|
|
for t in created:
|
|
if t.get("assignee") and t["assignee"] != by:
|
|
mine.setdefault(t["assignee"], []).append(t)
|
|
for agent, items in mine.items():
|
|
lines = "\n".join(f"#{t['id']} {t['title']}" for t in items)
|
|
self.send(by, [agent], f"{len(items)} new task{'s' if len(items) > 1 else ''} for you; take them "
|
|
f"with next:\n{lines}", thread="tasks", subject="New tasks", kind="request",
|
|
origin="system")
|
|
return {"created": created, "skipped": skipped}
|
|
|
|
def _route(self, by, scope):
|
|
"""The band owner of scope - when the caller may assign to them (a coordinator, or the owner itself)."""
|
|
owner = self.band_owner(scope)
|
|
return owner if owner and (by in self.coordinators or owner == by) else None
|
|
|
|
# ----------------------------------------------------------------- stuck-agent watchdog
|
|
def busy_without_progress(self, hours=2, min_tools=150):
|
|
"""Agents with many tool calls in the window but no message, submit, review move, task change, handoff or
|
|
new `doing` line - possibly stuck in a loop."""
|
|
since = iso(utc(self.clock() - hours * 3600))
|
|
out = []
|
|
for a in self.agents():
|
|
if a["kind"] == "human" or self.stopped(a["name"]):
|
|
continue
|
|
tools = self.conn.execute("SELECT COALESCE(SUM(tools), 0) FROM pulse WHERE agent=? AND bucket>=?",
|
|
(a["name"], since)).fetchone()[0]
|
|
if tools < min_tools:
|
|
continue
|
|
progress = any(self.conn.execute(sql, (a["name"], since)).fetchone()[0] for sql in (
|
|
"SELECT COUNT(*) FROM messages WHERE sender=? AND created_at>=? AND origin NOT IN ('hook', 'system')",
|
|
"SELECT COUNT(*) FROM item_events WHERE agent=? AND at>=?",
|
|
"SELECT COUNT(*) FROM task_events WHERE agent=? AND at>=?",
|
|
"SELECT COUNT(*) FROM handoffs WHERE agent=? AND created_at>=?",
|
|
"SELECT COUNT(*) FROM agents WHERE name=? AND doing_at>=?"))
|
|
if not progress:
|
|
out.append({"agent": a["name"], "tools": tools, "hours": hours})
|
|
return out
|
|
|
|
# ----------------------------------------------------------------- policies
|
|
def policy(self, key, default=None):
|
|
return self.project.policy.get(key, default)
|
|
|
|
def check_landing_policy(self, item_id):
|
|
required = list(self.policy("land_requires") or [])
|
|
author = self.conn.execute("SELECT author FROM items WHERE id=?", (int(item_id),)).fetchone()
|
|
required = [r for r in required if not author or r != author["author"]] # authors cannot approve
|
|
if not required:
|
|
return
|
|
last_submit = self.conn.execute("SELECT MAX(id) FROM item_events WHERE item_id=? AND to_state='submitted'",
|
|
(int(item_id),)).fetchone()[0] or 0
|
|
approvers = {r["agent"] for r in self.conn.execute(
|
|
"SELECT agent FROM item_events WHERE item_id=? AND to_state='approved' AND id>?", (int(item_id), last_submit))}
|
|
missing = [r for r in required if r not in approvers]
|
|
if missing:
|
|
raise HarborError(f"policy: item #{item_id} needs approval from {', '.join(missing)} before it lands "
|
|
"(since its last submission)")
|
|
|
|
def check_message_policy(self, sender, to_all):
|
|
cap = self.policy("all_per_hour")
|
|
if not cap or not to_all or sender in self.coordinators:
|
|
return
|
|
since = iso(utc(self.clock() - 3600))
|
|
count = self.conn.execute("SELECT COUNT(*) FROM messages WHERE sender=? AND created_at>=? AND "
|
|
"recipients='[\"All\"]' AND origin NOT IN ('legacy', 'system', 'hook')",
|
|
(sender, since)).fetchone()[0]
|
|
if count >= int(cap):
|
|
raise HarborError(f"policy: at most {cap} messages to everyone per hour - use `doing` for progress, or "
|
|
"a thread with the people concerned")
|