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:
KeelrLeek committed 2026-10-07 11:04:54 -05:00
1 parent a3fc48802c
commit c77ed1a4db
3 files changed
+303 -278

No files matched your search

+3
View File
@@ -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
View File
@@ -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")
+8
View File
@@ -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