diff --git a/tasks.py b/tasks.py index 9a9376e0..01188092 100644 --- a/tasks.py +++ b/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}