feat: gemini + grok workers via Huey, cross-review PRs
- gemini_worker: picks assigned issues, runs aider, opens PR (every 20m) - grok_worker: picks assigned issues, runs opencode, opens PR (every 20m) - cross_review_prs: gemini reviews grok's PRs, grok reviews gemini's (every 30m) No bash loops. All Huey. One consumer, one SQLite, full observability.
This commit is contained in:
264
tasks.py
264
tasks.py
@@ -584,3 +584,267 @@ def repo_watchdog():
|
||||
state_file.write_text(json.dumps(state, indent=2))
|
||||
|
||||
return {"new_items": len(new_items), "items": new_items[:10]}
|
||||
|
||||
|
||||
# ── AGENT WORKERS: Gemini + Grok ─────────────────────────────────────
|
||||
|
||||
WORKTREE_BASE = Path.home() / "worktrees"
|
||||
AGENT_LOG_DIR = HERMES_HOME / "logs"
|
||||
|
||||
AGENT_CONFIG = {
|
||||
"gemini": {
|
||||
"tool": "aider",
|
||||
"model": "gemini/gemini-2.5-pro-preview-05-06",
|
||||
"api_key_env": "GEMINI_API_KEY",
|
||||
"gitea_token_file": HERMES_HOME / "gemini_token",
|
||||
"timeout": 600,
|
||||
},
|
||||
"grok": {
|
||||
"tool": "opencode",
|
||||
"model": "xai/grok-3-fast",
|
||||
"api_key_env": "XAI_API_KEY",
|
||||
"gitea_token_file": HERMES_HOME / "grok_gitea_token",
|
||||
"timeout": 600,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _get_agent_issue(agent_name):
|
||||
"""Find the next issue assigned to this agent that hasn't been worked."""
|
||||
token_file = AGENT_CONFIG[agent_name]["gitea_token_file"]
|
||||
if not token_file.exists():
|
||||
return None, None
|
||||
|
||||
g = GiteaClient(token=token_file.read_text().strip())
|
||||
for repo in REPOS:
|
||||
try:
|
||||
issues = g.find_agent_issues(repo, agent_name, limit=10)
|
||||
for issue in issues:
|
||||
# Skip if already has a PR branch or "dispatched" comment
|
||||
comments = g.list_comments(repo, issue.number, limit=10)
|
||||
if any(c.body and "working on" in c.body.lower() and agent_name in c.body.lower() for c in comments):
|
||||
continue
|
||||
return repo, issue
|
||||
except Exception:
|
||||
continue
|
||||
return None, None
|
||||
|
||||
|
||||
def _run_agent(agent_name, repo, issue):
|
||||
"""Clone, branch, run agent tool, push, open PR."""
|
||||
cfg = AGENT_CONFIG[agent_name]
|
||||
token = cfg["gitea_token_file"].read_text().strip()
|
||||
repo_owner, repo_name = repo.split("/")
|
||||
branch = f"{agent_name}/issue-{issue.number}"
|
||||
workdir = WORKTREE_BASE / f"{agent_name}-{issue.number}"
|
||||
log_file = AGENT_LOG_DIR / f"{agent_name}-worker.log"
|
||||
|
||||
def log(msg):
|
||||
with open(log_file, "a") as f:
|
||||
f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n")
|
||||
|
||||
log(f"=== Starting #{issue.number}: {issue.title} ===")
|
||||
|
||||
# Comment that we're working on it
|
||||
g = GiteaClient(token=token)
|
||||
g.create_comment(repo, issue.number,
|
||||
f"🔧 `{agent_name}` working on this via Huey. Branch: `{branch}`")
|
||||
|
||||
# Clone
|
||||
clone_url = f"http://{agent_name}:{token}@143.198.27.163:3000/{repo}.git"
|
||||
if workdir.exists():
|
||||
subprocess.run(["rm", "-rf", str(workdir)], timeout=30)
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "clone", "--depth", "50", clone_url, str(workdir)],
|
||||
capture_output=True, text=True, timeout=120
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log(f"Clone failed: {result.stderr}")
|
||||
return {"status": "clone_failed", "error": result.stderr[:200]}
|
||||
|
||||
# Create branch
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", branch],
|
||||
cwd=str(workdir), capture_output=True, timeout=10
|
||||
)
|
||||
|
||||
# Build prompt
|
||||
prompt = (
|
||||
f"Fix issue #{issue.number}: {issue.title}\n\n"
|
||||
f"{issue.body or 'No description.'}\n\n"
|
||||
f"Make minimal, focused changes. Only modify files directly related to this issue."
|
||||
)
|
||||
|
||||
# Run agent tool
|
||||
env = os.environ.copy()
|
||||
if cfg["api_key_env"] == "XAI_API_KEY":
|
||||
env["XAI_API_KEY"] = Path(Path.home() / ".config/grok/api_key").read_text().strip()
|
||||
|
||||
if cfg["tool"] == "aider":
|
||||
cmd = [
|
||||
"aider",
|
||||
"--model", cfg["model"],
|
||||
"--no-auto-commits",
|
||||
"--yes-always",
|
||||
"--no-suggest-shell-commands",
|
||||
"--message", prompt,
|
||||
]
|
||||
else: # opencode
|
||||
cmd = [
|
||||
"opencode", "run",
|
||||
"-m", cfg["model"],
|
||||
"--no-interactive",
|
||||
prompt,
|
||||
]
|
||||
|
||||
log(f"Running: {cfg['tool']} with {cfg['model']}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, cwd=str(workdir), capture_output=True, text=True,
|
||||
timeout=cfg["timeout"], env=env
|
||||
)
|
||||
log(f"Exit code: {result.returncode}")
|
||||
log(f"Stdout (last 500): {result.stdout[-500:]}")
|
||||
if result.stderr:
|
||||
log(f"Stderr (last 300): {result.stderr[-300:]}")
|
||||
except subprocess.TimeoutExpired:
|
||||
log("TIMEOUT")
|
||||
return {"status": "timeout"}
|
||||
|
||||
# Check if anything changed
|
||||
diff_result = subprocess.run(
|
||||
["git", "diff", "--stat"], cwd=str(workdir),
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if not diff_result.stdout.strip():
|
||||
log("No changes produced")
|
||||
g.create_comment(repo, issue.number,
|
||||
f"⚠️ `{agent_name}` produced no changes for this issue. Skipping.")
|
||||
subprocess.run(["rm", "-rf", str(workdir)], timeout=30)
|
||||
return {"status": "no_changes"}
|
||||
|
||||
# Commit, push, open PR
|
||||
subprocess.run(["git", "add", "-A"], cwd=str(workdir), timeout=10)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"[{agent_name}] {issue.title} (#{issue.number})"],
|
||||
cwd=str(workdir), capture_output=True, timeout=30
|
||||
)
|
||||
push_result = subprocess.run(
|
||||
["git", "push", "-u", "origin", branch],
|
||||
cwd=str(workdir), capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if push_result.returncode != 0:
|
||||
log(f"Push failed: {push_result.stderr}")
|
||||
return {"status": "push_failed", "error": push_result.stderr[:200]}
|
||||
|
||||
# Open PR
|
||||
try:
|
||||
pr = g.create_pull(
|
||||
repo,
|
||||
title=f"[{agent_name}] {issue.title} (#{issue.number})",
|
||||
head=branch,
|
||||
base="main",
|
||||
body=f"Closes #{issue.number}\n\nGenerated by `{agent_name}` via Huey worker.",
|
||||
)
|
||||
log(f"PR #{pr.number} created")
|
||||
return {"status": "pr_created", "pr": pr.number}
|
||||
except Exception as e:
|
||||
log(f"PR creation failed: {e}")
|
||||
return {"status": "pr_failed", "error": str(e)[:200]}
|
||||
finally:
|
||||
subprocess.run(["rm", "-rf", str(workdir)], timeout=30)
|
||||
|
||||
|
||||
@huey.periodic_task(crontab(minute="*/20"))
|
||||
def gemini_worker():
|
||||
"""Gemini picks up an assigned issue, codes it with aider, opens a PR."""
|
||||
repo, issue = _get_agent_issue("gemini")
|
||||
if not issue:
|
||||
return {"status": "idle", "reason": "no issues assigned to gemini"}
|
||||
return _run_agent("gemini", repo, issue)
|
||||
|
||||
|
||||
@huey.periodic_task(crontab(minute="*/20"))
|
||||
def grok_worker():
|
||||
"""Grok picks up an assigned issue, codes it with opencode, opens a PR."""
|
||||
repo, issue = _get_agent_issue("grok")
|
||||
if not issue:
|
||||
return {"status": "idle", "reason": "no issues assigned to grok"}
|
||||
return _run_agent("grok", repo, issue)
|
||||
|
||||
|
||||
# ── PR Cross-Review ──────────────────────────────────────────────────
|
||||
|
||||
@huey.periodic_task(crontab(minute="*/30"))
|
||||
def cross_review_prs():
|
||||
"""Gemini reviews Grok's PRs. Grok reviews Gemini's PRs."""
|
||||
results = []
|
||||
|
||||
for reviewer, author in [("gemini", "grok"), ("grok", "gemini")]:
|
||||
cfg = AGENT_CONFIG[reviewer]
|
||||
token_file = cfg["gitea_token_file"]
|
||||
if not token_file.exists():
|
||||
continue
|
||||
|
||||
g = GiteaClient(token=token_file.read_text().strip())
|
||||
|
||||
for repo in REPOS:
|
||||
try:
|
||||
prs = g.list_pulls(repo, state="open", limit=10)
|
||||
for pr in prs:
|
||||
# Only review the other agent's PRs
|
||||
if not pr.title.startswith(f"[{author}]"):
|
||||
continue
|
||||
|
||||
# Skip if already reviewed
|
||||
comments = g.list_comments(repo, pr.number, limit=10)
|
||||
if any(c.body and f"reviewed by {reviewer}" in c.body.lower() for c in comments):
|
||||
continue
|
||||
|
||||
# Get the diff
|
||||
files = g.get_pull_files(repo, pr.number)
|
||||
net = sum(f.additions - f.deletions for f in files)
|
||||
file_list = ", ".join(f.filename for f in files[:5])
|
||||
|
||||
# Build review prompt
|
||||
review_prompt = (
|
||||
f"Review PR #{pr.number}: {pr.title}\n"
|
||||
f"Files: {file_list}\n"
|
||||
f"Net change: +{net} lines\n\n"
|
||||
f"Is this PR focused, correct, and ready to merge? "
|
||||
f"Reply with APPROVE or REQUEST_CHANGES and a brief reason."
|
||||
)
|
||||
|
||||
# Run reviewer's tool for analysis
|
||||
env = os.environ.copy()
|
||||
if cfg["api_key_env"] == "XAI_API_KEY":
|
||||
env["XAI_API_KEY"] = Path(Path.home() / ".config/grok/api_key").read_text().strip()
|
||||
|
||||
if cfg["tool"] == "aider":
|
||||
cmd = ["aider", "--model", cfg["model"],
|
||||
"--no-auto-commits", "--yes-always",
|
||||
"--no-suggest-shell-commands",
|
||||
"--message", review_prompt]
|
||||
else:
|
||||
cmd = ["opencode", "run", "-m", cfg["model"],
|
||||
"--no-interactive", review_prompt]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True,
|
||||
timeout=120, env=env, cwd="/tmp"
|
||||
)
|
||||
review_text = result.stdout[-1000:] if result.stdout else "No output"
|
||||
except Exception as e:
|
||||
review_text = f"Review failed: {e}"
|
||||
|
||||
# Post review as comment
|
||||
g.create_comment(repo, pr.number,
|
||||
f"**Reviewed by `{reviewer}`:**\n\n{review_text}")
|
||||
results.append({"reviewer": reviewer, "pr": pr.number, "repo": repo})
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return {"reviews": len(results), "details": results}
|
||||
|
||||
Reference in New Issue
Block a user