forked from Keelrleek/Harbor
policy submits_per_hour: an agent can be capped at N submissions or resubmissions per hour (the User asked the DeepSeeks to slow down and check their work)
This commit is contained in:
1 parent
a3fc48802c
commit
c77ed1a4db
3 files changed
+303
-278
No files matched your search
@@ -762,6 +762,7 @@ class Harbor(WorkMixin, ExtrasMixin, FlowMixin, LaunchMixin):
|
|||||||
raise HarborError("no reviewer: name one, or set reviewers in the project file")
|
raise HarborError("no reviewer: name one, or set reviewers in the project file")
|
||||||
canonical = parse_scope(scope)[0] if scope else None
|
canonical = parse_scope(scope)[0] if scope else None
|
||||||
commit = self.resolve_commit(author, commit)
|
commit = self.resolve_commit(author, commit)
|
||||||
|
self.check_submit_policy(author)
|
||||||
|
|
||||||
def do(c):
|
def do(c):
|
||||||
now = self.now()
|
now = self.now()
|
||||||
@@ -825,6 +826,8 @@ class Harbor(WorkMixin, ExtrasMixin, FlowMixin, LaunchMixin):
|
|||||||
self.check_landing_policy(item_id)
|
self.check_landing_policy(item_id)
|
||||||
if is_author and commit:
|
if is_author and commit:
|
||||||
commit = self.resolve_commit(agent, commit)
|
commit = self.resolve_commit(agent, commit)
|
||||||
|
if is_author and to_state == "submitted":
|
||||||
|
self.check_submit_policy(agent)
|
||||||
|
|
||||||
def do(c):
|
def do(c):
|
||||||
now = self.now()
|
now = self.now()
|
||||||
|
|||||||
+292
-278
@@ -1,278 +1,292 @@
|
|||||||
"""How work flows: git radar, bulk tasks and templates, the stuck-agent watchdog, and team policies.
|
"""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
|
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)
|
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
|
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
|
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
|
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)
|
policies rules from the project file that Harbor enforces (approvals before landing, message caps)
|
||||||
|
|
||||||
Mixed into Harbor (core.py).
|
Mixed into Harbor (core.py).
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .util import HarborError, first_line, iso, parse_iso, parse_scope, scopes_overlap, utc
|
from .util import HarborError, first_line, iso, parse_iso, parse_scope, scopes_overlap, utc
|
||||||
|
|
||||||
GIT_TIMEOUT = 10
|
GIT_TIMEOUT = 10
|
||||||
_git_cache = {}
|
_git_cache = {}
|
||||||
|
|
||||||
|
|
||||||
class _Values(dict):
|
class _Values(dict):
|
||||||
"""Template placeholders; an unknown {name} stays as written instead of failing."""
|
"""Template placeholders; an unknown {name} stays as written instead of failing."""
|
||||||
def __missing__(self, key):
|
def __missing__(self, key):
|
||||||
return "{" + key + "}"
|
return "{" + key + "}"
|
||||||
|
|
||||||
|
|
||||||
def _git(workdir, *args):
|
def _git(workdir, *args):
|
||||||
"""A read-only git command (never takes locks, never writes the index)."""
|
"""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.
|
# 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",
|
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,
|
"-c", "core.untrackedCache=false", "-C", str(workdir), *args], capture_output=True, text=True,
|
||||||
encoding="utf-8", errors="replace", timeout=GIT_TIMEOUT,
|
encoding="utf-8", errors="replace", timeout=GIT_TIMEOUT,
|
||||||
env={**__import__("os").environ, "GIT_TERMINAL_PROMPT": "0", "GIT_OPTIONAL_LOCKS": "0"})
|
env={**__import__("os").environ, "GIT_TERMINAL_PROMPT": "0", "GIT_OPTIONAL_LOCKS": "0"})
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
raise RuntimeError(r.stderr.strip() or f"git {' '.join(args)} failed")
|
raise RuntimeError(r.stderr.strip() or f"git {' '.join(args)} failed")
|
||||||
return r.stdout
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
class FlowMixin:
|
class FlowMixin:
|
||||||
def resolve_commit(self, agent, commit):
|
def resolve_commit(self, agent, commit):
|
||||||
"""The full hash of a commit an item names, checked in agent's workdir (or the Logbook's decomp checkout).
|
"""The full hash of a commit an item names, checked in agent's workdir (or the Logbook's decomp checkout).
|
||||||
A placeholder ('HEAD', 'PLACEHOLDER') or a hash git does not know is refused; without any git checkout to
|
A placeholder ('HEAD', 'PLACEHOLDER') or a hash git does not know is refused; without any git checkout to
|
||||||
ask, the value is kept as given."""
|
ask, the value is kept as given."""
|
||||||
if not commit:
|
if not commit:
|
||||||
return commit
|
return commit
|
||||||
commit = str(commit).strip()
|
commit = str(commit).strip()
|
||||||
spec = next((a for a in self.project.agents if a["name"] == agent), {})
|
spec = next((a for a in self.project.agents if a["name"] == agent), {})
|
||||||
places = [spec.get("workdir"), (self.project.logbook or {}).get("decomp")]
|
places = [spec.get("workdir"), (self.project.logbook or {}).get("decomp")]
|
||||||
repos = [p for p in places if p and (Path(p) / ".git").exists()]
|
repos = [p for p in places if p and (Path(p) / ".git").exists()]
|
||||||
if not repos:
|
if not repos:
|
||||||
return commit
|
return commit
|
||||||
if not re.fullmatch(r"[0-9a-fA-F]{7,40}", commit):
|
if not re.fullmatch(r"[0-9a-fA-F]{7,40}", commit):
|
||||||
raise HarborError(f"commit {commit!r} is not a hash: give the commit's hash (git rev-parse HEAD), "
|
raise HarborError(f"commit {commit!r} is not a hash: give the commit's hash (git rev-parse HEAD), "
|
||||||
"not a branch name, HEAD or a placeholder")
|
"not a branch name, HEAD or a placeholder")
|
||||||
for repo in repos:
|
for repo in repos:
|
||||||
try:
|
try:
|
||||||
return _git(repo, "rev-parse", "--verify", "--quiet", f"{commit}^{{commit}}").strip()
|
return _git(repo, "rev-parse", "--verify", "--quiet", f"{commit}^{{commit}}").strip()
|
||||||
except (RuntimeError, subprocess.TimeoutExpired):
|
except (RuntimeError, subprocess.TimeoutExpired):
|
||||||
continue
|
continue
|
||||||
raise HarborError(f"commit {commit} is not in the repository: commit your work first and give the hash "
|
raise HarborError(f"commit {commit} is not in the repository: commit your work first and give the hash "
|
||||||
"git rev-parse HEAD prints")
|
"git rev-parse HEAD prints")
|
||||||
|
|
||||||
def worktree_change(self, agent):
|
def worktree_change(self, agent):
|
||||||
"""(mtime, path) of the most recently modified uncommitted file in agent's worktree, or None."""
|
"""(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), {})
|
spec = next((a for a in self.project.agents if a["name"] == agent), {})
|
||||||
workdir = spec.get("workdir")
|
workdir = spec.get("workdir")
|
||||||
if not workdir or not Path(workdir).is_dir():
|
if not workdir or not Path(workdir).is_dir():
|
||||||
return None
|
return None
|
||||||
newest = None
|
newest = None
|
||||||
for line in _git(workdir, "status", "--porcelain", "-uall").splitlines()[:3000]:
|
for line in _git(workdir, "status", "--porcelain", "-uall").splitlines()[:3000]:
|
||||||
path = line[3:].split(" -> ")[-1].strip().strip('"')
|
path = line[3:].split(" -> ")[-1].strip().strip('"')
|
||||||
try:
|
try:
|
||||||
mtime = (Path(workdir) / path).stat().st_mtime
|
mtime = (Path(workdir) / path).stat().st_mtime
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
if newest is None or mtime > newest[0]:
|
if newest is None or mtime > newest[0]:
|
||||||
newest = (mtime, path)
|
newest = (mtime, path)
|
||||||
return newest
|
return newest
|
||||||
|
|
||||||
# ----------------------------------------------------------------- git radar
|
# ----------------------------------------------------------------- git radar
|
||||||
def git_status(self, agent, max_age=60):
|
def git_status(self, agent, max_age=60):
|
||||||
"""Git facts for agent's workdir (cached for max_age seconds)."""
|
"""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)
|
spec = next((a for a in self.project.agents if a["name"] == agent), None)
|
||||||
workdir = spec and spec.get("workdir")
|
workdir = spec and spec.get("workdir")
|
||||||
if not workdir or not Path(workdir).is_dir():
|
if not workdir or not Path(workdir).is_dir():
|
||||||
return None
|
return None
|
||||||
key = (str(workdir), self.project.name)
|
key = (str(workdir), self.project.name)
|
||||||
hit = _git_cache.get(key)
|
hit = _git_cache.get(key)
|
||||||
if hit and time.monotonic() - hit[0] < max_age:
|
if hit and time.monotonic() - hit[0] < max_age:
|
||||||
return hit[1]
|
return hit[1]
|
||||||
main = self.project.git_main
|
main = self.project.git_main
|
||||||
out = {"agent": agent, "workdir": workdir}
|
out = {"agent": agent, "workdir": workdir}
|
||||||
try:
|
try:
|
||||||
out["branch"] = _git(workdir, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
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]
|
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["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["dirty"] = len([l for l in _git(workdir, "status", "--porcelain").splitlines() if l.strip()])
|
||||||
out["behind_main"] = out["ahead_of_main"] = 0
|
out["behind_main"] = out["ahead_of_main"] = 0
|
||||||
if out["branch"] != main:
|
if out["branch"] != main:
|
||||||
try:
|
try:
|
||||||
behind, ahead = _git(workdir, "rev-list", "--left-right", "--count", f"{main}...HEAD").split()
|
behind, ahead = _git(workdir, "rev-list", "--left-right", "--count", f"{main}...HEAD").split()
|
||||||
out["behind_main"], out["ahead_of_main"] = int(behind), int(ahead)
|
out["behind_main"], out["ahead_of_main"] = int(behind), int(ahead)
|
||||||
except (RuntimeError, ValueError):
|
except (RuntimeError, ValueError):
|
||||||
out["main_missing"] = True
|
out["main_missing"] = True
|
||||||
since = self._last_submit_at(agent)
|
since = self._last_submit_at(agent)
|
||||||
# Author dates survive a rebase (commit dates do not), so rebased work does not look new again.
|
# 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))
|
cutoff = since or iso(utc(self.clock() - 86400))
|
||||||
args = ["log", "--format=%h%x1f%at%x1f%s", "-200"]
|
args = ["log", "--format=%h%x1f%at%x1f%s", "-200"]
|
||||||
if out["branch"] != main and not out.get("main_missing"):
|
if out["branch"] != main and not out.get("main_missing"):
|
||||||
args.append(f"{main}..HEAD")
|
args.append(f"{main}..HEAD")
|
||||||
commits = []
|
commits = []
|
||||||
for line in _git(workdir, *args).splitlines():
|
for line in _git(workdir, *args).splitlines():
|
||||||
parts = line.split("\x1f")
|
parts = line.split("\x1f")
|
||||||
if len(parts) == 3 and iso(utc(int(parts[1]))) > cutoff:
|
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.append({"sha": parts[0], "at": iso(utc(int(parts[1]))), "subject": parts[2]})
|
||||||
commits = commits[:50]
|
commits = commits[:50]
|
||||||
known = {r[0][:7] for r in self.conn.execute("SELECT commit_hash FROM items WHERE commit_hash IS NOT NULL")
|
known = {r[0][:7] for r in self.conn.execute("SELECT commit_hash FROM items WHERE commit_hash IS NOT NULL")
|
||||||
if r[0]}
|
if r[0]}
|
||||||
submits = out["branch"] != main and str(spec.get("git_submits", True)).lower() not in ("false", "0", "no")
|
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["unsubmitted"] = [c for c in commits if c["sha"][:7] not in known] if submits else []
|
||||||
out["since"] = since
|
out["since"] = since
|
||||||
except (RuntimeError, OSError, subprocess.SubprocessError, ValueError) as error:
|
except (RuntimeError, OSError, subprocess.SubprocessError, ValueError) as error:
|
||||||
out["error"] = str(error)[:200]
|
out["error"] = str(error)[:200]
|
||||||
_git_cache[key] = (time.monotonic(), out)
|
_git_cache[key] = (time.monotonic(), out)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def _last_submit_at(self, agent):
|
def _last_submit_at(self, agent):
|
||||||
row = self.conn.execute("SELECT MAX(at) a FROM item_events WHERE agent=? AND to_state='submitted'",
|
row = self.conn.execute("SELECT MAX(at) a FROM item_events WHERE agent=? AND to_state='submitted'",
|
||||||
(agent,)).fetchone()
|
(agent,)).fetchone()
|
||||||
return row["a"] if row and row["a"] else None
|
return row["a"] if row and row["a"] else None
|
||||||
|
|
||||||
def git_radar(self):
|
def git_radar(self):
|
||||||
return {a["name"]: self.git_status(a["name"]) for a in self.project.agents
|
return {a["name"]: self.git_status(a["name"]) for a in self.project.agents
|
||||||
if a["kind"] != "human" and a.get("workdir")}
|
if a["kind"] != "human" and a.get("workdir")}
|
||||||
|
|
||||||
# ----------------------------------------------------------------- bulk tasks and templates
|
# ----------------------------------------------------------------- bulk tasks and templates
|
||||||
def band_owner(self, scope):
|
def band_owner(self, scope):
|
||||||
canonical, lo, hi = parse_scope(scope)
|
canonical, lo, hi = parse_scope(scope)
|
||||||
if lo is None:
|
if lo is None:
|
||||||
return None
|
return None
|
||||||
now = self.now()
|
now = self.now()
|
||||||
for c in self.conn.execute("SELECT * FROM claims WHERE kind='band' AND released_at IS NULL AND "
|
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,)):
|
"(expires_at IS NULL OR expires_at > ?)", (now,)):
|
||||||
if scopes_overlap((canonical, lo, hi), (c["scope"], c["lo"], c["hi"])):
|
if scopes_overlap((canonical, lo, hi), (c["scope"], c["lo"], c["hi"])):
|
||||||
return c["agent"]
|
return c["agent"]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def run_template(self, by, template, scope=None, title=None, owner=None, priority="normal", origin="cli"):
|
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}."""
|
"""Create the steps of a template as linked tasks. Placeholders: {scope}, {title}, {owner}."""
|
||||||
spec = self.project.templates.get(template)
|
spec = self.project.templates.get(template)
|
||||||
if not spec:
|
if not spec:
|
||||||
raise HarborError(f"no template {template!r}; the project file defines: "
|
raise HarborError(f"no template {template!r}; the project file defines: "
|
||||||
+ (", ".join(self.project.templates) or "none"))
|
+ (", ".join(self.project.templates) or "none"))
|
||||||
owner = owner or (self._route(by, scope) if scope else 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 "")
|
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
|
for step in spec.get("steps", []): # check every step before creating any
|
||||||
assignee = (step.get("assignee") or "").format_map(values) or None
|
assignee = (step.get("assignee") or "").format_map(values) or None
|
||||||
if assignee:
|
if assignee:
|
||||||
self._check_agent(assignee, allow_human=False)
|
self._check_agent(assignee, allow_human=False)
|
||||||
if assignee != by and by not in self.coordinators:
|
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")
|
raise HarborError(f"template {template!r} assigns a step to {assignee}; only a coordinator does that")
|
||||||
created, by_name = [], {}
|
created, by_name = [], {}
|
||||||
for step in spec.get("steps", []):
|
for step in spec.get("steps", []):
|
||||||
assignee = (step.get("assignee") or "").format_map(values) or None
|
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]
|
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"),
|
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,
|
scope=scope if step.get("scoped", True) else None, assignee=assignee,
|
||||||
priority=step.get("priority", priority), after=after, origin=origin)
|
priority=step.get("priority", priority), after=after, origin=origin)
|
||||||
by_name[step.get("name", str(len(by_name)))] = task["id"]
|
by_name[step.get("name", str(len(by_name)))] = task["id"]
|
||||||
created.append(task)
|
created.append(task)
|
||||||
return created
|
return created
|
||||||
|
|
||||||
LINE = re.compile(r"^\s*(?:fn:)?(0x[0-9A-Fa-f]+)\b[\s:|,-]*(.*)$")
|
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"):
|
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'.
|
"""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."""
|
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:
|
if assignee and assignee != by and by not in self.coordinators:
|
||||||
raise HarborError("only a coordinator assigns tasks to someone else")
|
raise HarborError("only a coordinator assigns tasks to someone else")
|
||||||
created, skipped = [], []
|
created, skipped = [], []
|
||||||
open_scopes = {t["scope"] for t in self.tasks(states=("open", "in_progress", "blocked", "review"), limit=100000)}
|
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
|
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
|
m = self.LINE.match(raw.strip()) if raw.strip() and not raw.strip().startswith("#") else None
|
||||||
if m:
|
if m:
|
||||||
parse_scope(f"fn:{m.group(1)}")
|
parse_scope(f"fn:{m.group(1)}")
|
||||||
for raw in str(text).splitlines():
|
for raw in str(text).splitlines():
|
||||||
line = raw.strip()
|
line = raw.strip()
|
||||||
if not line or line.startswith("#"):
|
if not line or line.startswith("#"):
|
||||||
continue
|
continue
|
||||||
m = self.LINE.match(line)
|
m = self.LINE.match(line)
|
||||||
scope = f"fn:{m.group(1)}" if m else None
|
scope = f"fn:{m.group(1)}" if m else None
|
||||||
name = (m.group(2).strip() if m else line) or None
|
name = (m.group(2).strip() if m else line) or None
|
||||||
if scope:
|
if scope:
|
||||||
canonical = parse_scope(scope)[0]
|
canonical = parse_scope(scope)[0]
|
||||||
if canonical in open_scopes:
|
if canonical in open_scopes:
|
||||||
skipped.append(f"{canonical} already has an open task")
|
skipped.append(f"{canonical} already has an open task")
|
||||||
continue
|
continue
|
||||||
open_scopes.add(canonical)
|
open_scopes.add(canonical)
|
||||||
if template:
|
if template:
|
||||||
created += self.run_template(by, template, scope=scope, title=name, owner=assignee, priority=priority,
|
created += self.run_template(by, template, scope=scope, title=name, owner=assignee, priority=priority,
|
||||||
origin=origin)
|
origin=origin)
|
||||||
else:
|
else:
|
||||||
title = (f"Make {scope.split(':', 1)[1]} readable" + (f" ({name})" if name else "")) if scope else name
|
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,
|
created.append(self.add_task(by, title, scope=scope,
|
||||||
assignee=assignee or (self._route(by, scope) if scope else None),
|
assignee=assignee or (self._route(by, scope) if scope else None),
|
||||||
priority=priority, origin=origin, notify=template is not None))
|
priority=priority, origin=origin, notify=template is not None))
|
||||||
if not template: # one notice per agent, not one thread per task
|
if not template: # one notice per agent, not one thread per task
|
||||||
mine = {}
|
mine = {}
|
||||||
for t in created:
|
for t in created:
|
||||||
if t.get("assignee") and t["assignee"] != by:
|
if t.get("assignee") and t["assignee"] != by:
|
||||||
mine.setdefault(t["assignee"], []).append(t)
|
mine.setdefault(t["assignee"], []).append(t)
|
||||||
for agent, items in mine.items():
|
for agent, items in mine.items():
|
||||||
lines = "\n".join(f"#{t['id']} {t['title']}" for t in 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 "
|
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",
|
f"with next:\n{lines}", thread="tasks", subject="New tasks", kind="request",
|
||||||
origin="system")
|
origin="system")
|
||||||
return {"created": created, "skipped": skipped}
|
return {"created": created, "skipped": skipped}
|
||||||
|
|
||||||
def _route(self, by, scope):
|
def _route(self, by, scope):
|
||||||
"""The band owner of scope - when the caller may assign to them (a coordinator, or the owner itself)."""
|
"""The band owner of scope - when the caller may assign to them (a coordinator, or the owner itself)."""
|
||||||
owner = self.band_owner(scope)
|
owner = self.band_owner(scope)
|
||||||
return owner if owner and (by in self.coordinators or owner == by) else None
|
return owner if owner and (by in self.coordinators or owner == by) else None
|
||||||
|
|
||||||
# ----------------------------------------------------------------- stuck-agent watchdog
|
# ----------------------------------------------------------------- stuck-agent watchdog
|
||||||
def busy_without_progress(self, hours=2, min_tools=150):
|
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
|
"""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."""
|
new `doing` line - possibly stuck in a loop."""
|
||||||
since = iso(utc(self.clock() - hours * 3600))
|
since = iso(utc(self.clock() - hours * 3600))
|
||||||
out = []
|
out = []
|
||||||
for a in self.agents():
|
for a in self.agents():
|
||||||
if a["kind"] == "human" or self.stopped(a["name"]):
|
if a["kind"] == "human" or self.stopped(a["name"]):
|
||||||
continue
|
continue
|
||||||
tools = self.conn.execute("SELECT COALESCE(SUM(tools), 0) FROM pulse WHERE agent=? AND bucket>=?",
|
tools = self.conn.execute("SELECT COALESCE(SUM(tools), 0) FROM pulse WHERE agent=? AND bucket>=?",
|
||||||
(a["name"], since)).fetchone()[0]
|
(a["name"], since)).fetchone()[0]
|
||||||
if tools < min_tools:
|
if tools < min_tools:
|
||||||
continue
|
continue
|
||||||
progress = any(self.conn.execute(sql, (a["name"], since)).fetchone()[0] for sql in (
|
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 messages WHERE sender=? AND created_at>=? AND origin NOT IN ('hook', 'system')",
|
||||||
"SELECT COUNT(*) FROM item_events WHERE agent=? AND at>=?",
|
"SELECT COUNT(*) FROM item_events WHERE agent=? AND at>=?",
|
||||||
"SELECT COUNT(*) FROM task_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 handoffs WHERE agent=? AND created_at>=?",
|
||||||
"SELECT COUNT(*) FROM agents WHERE name=? AND doing_at>=?"))
|
"SELECT COUNT(*) FROM agents WHERE name=? AND doing_at>=?"))
|
||||||
if not progress:
|
if not progress:
|
||||||
out.append({"agent": a["name"], "tools": tools, "hours": hours})
|
out.append({"agent": a["name"], "tools": tools, "hours": hours})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# ----------------------------------------------------------------- policies
|
# ----------------------------------------------------------------- policies
|
||||||
def policy(self, key, default=None):
|
def policy(self, key, default=None):
|
||||||
return self.project.policy.get(key, default)
|
return self.project.policy.get(key, default)
|
||||||
|
|
||||||
def check_landing_policy(self, item_id):
|
def check_landing_policy(self, item_id):
|
||||||
required = list(self.policy("land_requires") or [])
|
required = list(self.policy("land_requires") or [])
|
||||||
author = self.conn.execute("SELECT author FROM items WHERE id=?", (int(item_id),)).fetchone()
|
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
|
required = [r for r in required if not author or r != author["author"]] # authors cannot approve
|
||||||
if not required:
|
if not required:
|
||||||
return
|
return
|
||||||
last_submit = self.conn.execute("SELECT MAX(id) FROM item_events WHERE item_id=? AND to_state='submitted'",
|
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
|
(int(item_id),)).fetchone()[0] or 0
|
||||||
approvers = {r["agent"] for r in self.conn.execute(
|
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))}
|
"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]
|
missing = [r for r in required if r not in approvers]
|
||||||
if missing:
|
if missing:
|
||||||
raise HarborError(f"policy: item #{item_id} needs approval from {', '.join(missing)} before it lands "
|
raise HarborError(f"policy: item #{item_id} needs approval from {', '.join(missing)} before it lands "
|
||||||
"(since its last submission)")
|
"(since its last submission)")
|
||||||
|
|
||||||
def check_message_policy(self, sender, to_all):
|
def check_submit_policy(self, author):
|
||||||
cap = self.policy("all_per_hour")
|
"""policy submits_per_hour: an agent capped at N submissions (new or resubmitted) per hour must slow down
|
||||||
if not cap or not to_all or sender in self.coordinators:
|
and check its work before sending more."""
|
||||||
return
|
cap = (self.policy("submits_per_hour") or {}).get(author)
|
||||||
since = iso(utc(self.clock() - 3600))
|
if not cap:
|
||||||
count = self.conn.execute("SELECT COUNT(*) FROM messages WHERE sender=? AND created_at>=? AND "
|
return
|
||||||
"recipients='[\"All\"]' AND origin NOT IN ('legacy', 'system', 'hook')",
|
since = iso(utc(self.clock() - 3600))
|
||||||
(sender, since)).fetchone()[0]
|
count = self.conn.execute("SELECT COUNT(*) FROM item_events WHERE agent=? AND to_state='submitted' AND at>=?",
|
||||||
if count >= int(cap):
|
(author, since)).fetchone()[0]
|
||||||
raise HarborError(f"policy: at most {cap} messages to everyone per hour - use `doing` for progress, or "
|
if count >= int(cap):
|
||||||
"a thread with the people concerned")
|
raise HarborError(f"policy: at most {cap} submissions per hour for {author}. Slow down: re-read the "
|
||||||
|
"disassembly against your C line by line, run quick_readable and re_behaviour, list "
|
||||||
|
"your changed annotation rows, and submit only work you have checked twice")
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -196,6 +196,14 @@ def _policy(raw, names):
|
|||||||
policy["all_per_hour"] = int(policy["all_per_hour"])
|
policy["all_per_hour"] = int(policy["all_per_hour"])
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise ProjectError("policy all_per_hour must be a whole number")
|
raise ProjectError("policy all_per_hour must be a whole number")
|
||||||
|
if "submits_per_hour" in policy: # {agent: n}: submissions and resubmissions per hour
|
||||||
|
caps = policy["submits_per_hour"]
|
||||||
|
if not isinstance(caps, dict) or not all(k in names for k in caps):
|
||||||
|
raise ProjectError("policy submits_per_hour must map agents on the roster to whole numbers")
|
||||||
|
try:
|
||||||
|
policy["submits_per_hour"] = {k: int(v) for k, v in caps.items()}
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ProjectError("policy submits_per_hour must map agents on the roster to whole numbers")
|
||||||
return policy
|
return policy
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user