Scheduling core, state store, CLI and unit tests
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.
This commit is contained in:
+210
@@ -0,0 +1,210 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user