31850c4a81
One tick polls in-flight runs, posts results, classifies the first line and moves state/assignee; failures get a structured note with transient retry and backoff, timeouts are killed and handed over, resume failures and long threads rebuild with a handoff summary. Then the lowest Todo items are dispatched within terminal concurrency and serialize groups, and a heartbeat is written. CLI: tick, new, show, comment, state, assign, smoke, check-config.
211 lines
8.6 KiB
Python
211 lines
8.6 KiB
Python
"""agentd command line. Tokens come from the environment (agent-access.py exec --secret-env ...)."""
|
|
import argparse
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
|
|
from . import config as config_mod
|
|
from .core import Agentd
|
|
from .plane import Plane, brief_to_html, html_to_text, text_to_html
|
|
from .store import Busy
|
|
|
|
|
|
def identity(cfg, wanted=None):
|
|
"""Terminal whose token the CLI uses: --as, then agentd.cli_terminal, then the first with a token."""
|
|
for term in [wanted, cfg.agentd.get("cli_terminal")] + list(cfg.terminals):
|
|
if term and term in cfg.terminals and cfg.token(term):
|
|
return term
|
|
sys.exit("no Plane token in the environment; run under agent-access.py exec %s" % " ".join(cfg.secret_env_args()))
|
|
|
|
|
|
def client(cfg, term):
|
|
return Plane(cfg.plane["base_url"], cfg.plane["workspace"], cfg.token(term), cfg.plane.get("timeout", 60))
|
|
|
|
|
|
def seq_of(ref):
|
|
m = re.search(r"(\d+)$", ref)
|
|
if not m:
|
|
sys.exit("bad work item reference %r (use 12 or GW-12)" % ref)
|
|
return int(m.group(1))
|
|
|
|
|
|
def cmd_tick(cfg, a):
|
|
d = Agentd(cfg)
|
|
if a.loop:
|
|
d.loop(a.interval or cfg.agentd.get("interval", 120))
|
|
try:
|
|
d.tick(project=a.project)
|
|
except Busy as exc:
|
|
print("skip: %s" % exc)
|
|
|
|
|
|
def cmd_new(cfg, a, pl=None, quiet=False):
|
|
p = cfg.project(a.project)
|
|
pl = pl or client(cfg, identity(cfg, a.as_))
|
|
text = sys.stdin.read() if a.file == "-" else open(a.file, encoding="utf-8").read()
|
|
body = {"name": a.title, "description_html": brief_to_html(text), "state": pl.states(p["id"])[a.state]}
|
|
if a.labels:
|
|
labels = pl.labels(p["id"])
|
|
body["labels"] = [labels[x] for x in a.labels.split(",")]
|
|
if a.assign:
|
|
body["assignees"] = [pl.members()[a.assign]]
|
|
issue = pl.create_issue(p["id"], body)
|
|
if a.module:
|
|
mods = pl.modules(p["id"])
|
|
mid = mods.get(a.module) or pl.create_module(p["id"], a.module)["id"]
|
|
pl.add_to_module(p["id"], mid, issue["id"])
|
|
if not quiet:
|
|
print("created %s-%s %s" % (p.get("key_prefix", a.project), issue["sequence_id"], issue["id"]))
|
|
return issue
|
|
|
|
|
|
def cmd_show(cfg, a):
|
|
p = cfg.project(a.project)
|
|
pl = client(cfg, identity(cfg, a.as_))
|
|
i = pl.find_issue(p["id"], seq_of(a.ref))
|
|
sn = {v: k for k, v in pl.states(p["id"]).items()}
|
|
ln = {v: k for k, v in pl.labels(p["id"]).items()}
|
|
un = {v: k for k, v in pl.members().items()}
|
|
print("%s-%s %s | state=%s | assignees=%s | labels=%s" % (
|
|
p.get("key_prefix", a.project), i["sequence_id"], i["name"], sn.get(i.get("state"), i.get("state")),
|
|
[un.get(x, x) for x in i.get("assignees") or []], [ln.get(x, x) for x in i.get("labels") or []]))
|
|
cs = pl.list_comments(p["id"], i["id"])
|
|
for c in cs[-a.n:] if a.n else []:
|
|
who = c.get("actor") or c.get("created_by")
|
|
print("--- %s (%s)" % (c.get("created_at"), un.get(who, who)))
|
|
print(html_to_text(c.get("comment_html"))[:2500])
|
|
|
|
|
|
def cmd_comment(cfg, a):
|
|
p = cfg.project(a.project)
|
|
pl = client(cfg, identity(cfg, a.as_))
|
|
i = pl.find_issue(p["id"], seq_of(a.ref))
|
|
text = sys.stdin.read() if a.file == "-" else open(a.file, encoding="utf-8").read()
|
|
pl.post_comment(p["id"], i["id"], text_to_html(text))
|
|
print("commented %s" % a.ref)
|
|
|
|
|
|
def cmd_state(cfg, a):
|
|
p = cfg.project(a.project)
|
|
pl = client(cfg, identity(cfg, a.as_))
|
|
i = pl.find_issue(p["id"], seq_of(a.ref))
|
|
name = p["states"].get(a.state, a.state) # accepts keys (todo) or Plane names (Todo)
|
|
pl.set_state(p["id"], i["id"], pl.states(p["id"])[name])
|
|
print("%s -> %s" % (a.ref, name))
|
|
|
|
|
|
def cmd_assign(cfg, a):
|
|
p = cfg.project(a.project)
|
|
pl = client(cfg, identity(cfg, a.as_))
|
|
i = pl.find_issue(p["id"], seq_of(a.ref))
|
|
pl.set_assignees(p["id"], i["id"], [pl.members()[a.user]])
|
|
print("%s assigned %s" % (a.ref, a.user))
|
|
|
|
|
|
SMOKE_BRIEF = ("<p>agentd 冒烟测试。不要读写任何文件,不要运行任何命令。</p>"
|
|
"<p>你的最后一条消息必须恰好是下面这一行(不要加任何别的内容):</p><pre>[review] ok</pre>")
|
|
|
|
|
|
def cmd_smoke(cfg, a):
|
|
p = cfg.project(a.project)
|
|
if a.terminal not in cfg.terminals:
|
|
sys.exit("unknown terminal %s" % a.terminal)
|
|
pl = client(cfg, identity(cfg, a.as_))
|
|
user = cfg.terminals[a.terminal]["plane_user"]
|
|
body = {"name": "agentd smoke %s %s" % (a.terminal, time.strftime("%Y-%m-%d %H:%M")),
|
|
"description_html": SMOKE_BRIEF, "state": pl.states(p["id"])[p["states"]["todo"]],
|
|
"assignees": [pl.members()[user]]}
|
|
issue = pl.create_issue(p["id"], body)
|
|
key = "%s-%s" % (p.get("key_prefix", a.project), issue["sequence_id"])
|
|
print("created %s; ticking every %ss (timeout %s min)" % (key, a.interval, a.timeout))
|
|
d = Agentd(cfg)
|
|
states = pl.states(p["id"])
|
|
done_states = {states[p["states"][k]] for k in ("review", "waiting")}
|
|
deadline = time.time() + a.timeout * 60
|
|
state = None
|
|
while time.time() < deadline:
|
|
try:
|
|
d.tick(project=a.project, only_issue=issue["id"])
|
|
except Busy as exc:
|
|
print("skip: %s" % exc)
|
|
state = pl.get_issue(p["id"], issue["id"]).get("state")
|
|
if state in done_states:
|
|
break
|
|
time.sleep(a.interval)
|
|
last = ""
|
|
for c in reversed(pl.list_comments(p["id"], issue["id"])):
|
|
text = html_to_text(c.get("comment_html"))
|
|
if not text.startswith("[worker]"):
|
|
last = text
|
|
break
|
|
ok = state == states[p["states"]["review"]] and re.search(r"(?m)^\[review\] ok\s*$", last)
|
|
print("%s %s: state=%s\n%s" % ("PASS" if ok else "FAIL", key, {v: k for k, v in states.items()}.get(state, state), last[-800:]))
|
|
if ok:
|
|
pl.set_state(p["id"], issue["id"], states[p["states"]["done"]])
|
|
sys.exit(0 if ok else 1)
|
|
|
|
|
|
def cmd_check_config(cfg, a):
|
|
print("config %s is valid" % cfg.path)
|
|
print("projects: %s" % ", ".join(cfg.projects))
|
|
for name, t in cfg.terminals.items():
|
|
env = cfg.token_env(name)
|
|
print("terminal %-10s %-13s %-8s executors=%s token %s: %s" % (
|
|
name, t["transport"], t["os"], ",".join(t["executors"]), env, "set" if os.environ.get(env) else "MISSING"))
|
|
print("cron/exec secret args: %s" % " ".join(cfg.secret_env_args()))
|
|
|
|
|
|
def parser():
|
|
ap = argparse.ArgumentParser(prog="agentd", description=__doc__)
|
|
ap.add_argument("--config", help="YAML config (default: $AGENTD_CONFIG, config/agentd.local.yaml, config/agentd.yaml)")
|
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
s = sub.add_parser("tick", help="run the dispatcher once or in a loop")
|
|
g = s.add_mutually_exclusive_group()
|
|
g.add_argument("--once", action="store_true")
|
|
g.add_argument("--loop", action="store_true")
|
|
s.add_argument("--interval", type=int)
|
|
s.add_argument("--project")
|
|
s.set_defaults(fn=cmd_tick)
|
|
|
|
def item(name, fn, help_):
|
|
s = sub.add_parser(name, help=help_)
|
|
s.add_argument("project")
|
|
s.add_argument("ref", help="sequence number or KEY-n")
|
|
s.add_argument("--as", dest="as_", help="terminal identity to act as")
|
|
s.set_defaults(fn=fn)
|
|
return s
|
|
|
|
s = sub.add_parser("new", help="create a work item from a brief file")
|
|
s.add_argument("project")
|
|
s.add_argument("title")
|
|
s.add_argument("--file", required=True, help="brief file (HTML passes through, text goes in <pre>); - = stdin")
|
|
s.add_argument("--assign")
|
|
s.add_argument("--labels", default="")
|
|
s.add_argument("--module")
|
|
s.add_argument("--state", default="Backlog")
|
|
s.add_argument("--as", dest="as_")
|
|
s.set_defaults(fn=cmd_new)
|
|
item("show", cmd_show, "show a work item and its last n comments").add_argument("n", nargs="?", type=int, default=1)
|
|
item("comment", cmd_comment, "post a comment from a file (- = stdin)").add_argument("file")
|
|
item("state", cmd_state, "set state (Plane name or config key)").add_argument("state")
|
|
item("assign", cmd_assign, "set the assignee").add_argument("user")
|
|
s = sub.add_parser("smoke", help="end-to-end round on a terminal: expects '[review] ok'")
|
|
s.add_argument("project")
|
|
s.add_argument("terminal")
|
|
s.add_argument("--interval", type=int, default=30)
|
|
s.add_argument("--timeout", type=int, default=30, help="minutes")
|
|
s.add_argument("--as", dest="as_")
|
|
s.set_defaults(fn=cmd_smoke)
|
|
sub.add_parser("check-config", help="validate the config and list token env vars").set_defaults(fn=cmd_check_config)
|
|
return ap
|
|
|
|
|
|
def main(argv=None):
|
|
a = parser().parse_args(argv)
|
|
try:
|
|
cfg = config_mod.load(a.config)
|
|
except config_mod.ConfigError as exc:
|
|
sys.exit(str(exc))
|
|
a.fn(cfg, a)
|