235 lines
8.1 KiB
Python
Executable File
235 lines
8.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Worker Runner — actual worker that picks up prompts and runs mimo via hermes CLI.
|
|
|
|
This is what the cron jobs SHOULD call instead of asking the LLM to check files.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import glob
|
|
import subprocess
|
|
import json
|
|
from datetime import datetime, timezone
|
|
|
|
STATE_DIR = os.path.expanduser("~/.hermes/mimo-swarm/state")
|
|
LOG_DIR = os.path.expanduser("~/.hermes/mimo-swarm/logs")
|
|
|
|
|
|
def log(msg):
|
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
print(f"[{ts}] {msg}")
|
|
log_file = os.path.join(LOG_DIR, f"runner-{datetime.now().strftime('%Y%m%d')}.log")
|
|
with open(log_file, "a") as f:
|
|
f.write(f"[{ts}] {msg}\n")
|
|
|
|
|
|
def write_result(worker_id, status, repo=None, issue=None, branch=None, pr=None, error=None):
|
|
"""Write a result file — always, even on failure."""
|
|
result_file = os.path.join(STATE_DIR, f"result-{worker_id}.json")
|
|
data = {
|
|
"status": status,
|
|
"worker": worker_id,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
if repo: data["repo"] = repo
|
|
if issue: data["issue"] = int(issue) if str(issue).isdigit() else issue
|
|
if branch: data["branch"] = branch
|
|
if pr: data["pr"] = pr
|
|
if error: data["error"] = error
|
|
with open(result_file, "w") as f:
|
|
json.dump(data, f)
|
|
|
|
|
|
def get_oldest_prompt():
|
|
"""Get the oldest prompt file with file locking (atomic rename)."""
|
|
prompts = sorted(glob.glob(os.path.join(STATE_DIR, "prompt-*.txt")))
|
|
if not prompts:
|
|
return None
|
|
# Prefer non-review prompts
|
|
impl = [p for p in prompts if "review" not in os.path.basename(p)]
|
|
target = impl[0] if impl else prompts[0]
|
|
|
|
# Atomic claim: rename to .processing
|
|
claimed = target + ".processing"
|
|
try:
|
|
os.rename(target, claimed)
|
|
return claimed
|
|
except OSError:
|
|
# Another worker got it first
|
|
return None
|
|
|
|
|
|
def run_worker(prompt_file):
|
|
"""Run the worker: read prompt, execute via hermes, create PR."""
|
|
worker_id = os.path.basename(prompt_file).replace("prompt-", "").replace(".txt", "")
|
|
|
|
with open(prompt_file) as f:
|
|
prompt = f.read()
|
|
|
|
# Extract repo and issue from prompt
|
|
repo = None
|
|
issue = None
|
|
for line in prompt.split("\n"):
|
|
if line.startswith("Repository:"):
|
|
repo = line.split(":", 1)[1].strip()
|
|
if line.startswith("Issue:"):
|
|
issue = line.split("#", 1)[1].strip() if "#" in line else line.split(":", 1)[1].strip()
|
|
|
|
log(f"Worker {worker_id}: repo={repo}, issue={issue}")
|
|
|
|
if not repo or not issue:
|
|
log(f" SKIPPING: couldn't parse repo/issue from prompt")
|
|
write_result(worker_id, "parse_error", error="could not parse repo/issue from prompt")
|
|
os.remove(prompt_file)
|
|
return False
|
|
|
|
# Clone/pull the repo — unique workspace per worker
|
|
import tempfile
|
|
work_dir = tempfile.mkdtemp(prefix=f"mimo-{worker_id}-")
|
|
clone_url = f"https://forge.alexanderwhitestone.com/{repo}.git"
|
|
branch = f"mimo/{worker_id.split('-')[1] if '-' in worker_id else 'code'}/issue-{issue}"
|
|
|
|
log(f" Workspace: {work_dir}")
|
|
result = subprocess.run(
|
|
["git", "clone", clone_url, work_dir],
|
|
capture_output=True, text=True, timeout=120
|
|
)
|
|
if result.returncode != 0:
|
|
log(f" CLONE FAILED: {result.stderr[:200]}")
|
|
write_result(worker_id, "clone_failed", repo=repo, issue=issue, error=result.stderr[:200])
|
|
os.remove(prompt_file)
|
|
return False
|
|
|
|
# Checkout branch
|
|
subprocess.run(["git", "fetch", "origin", "main"], cwd=work_dir, capture_output=True, timeout=60)
|
|
subprocess.run(["git", "checkout", "main"], cwd=work_dir, capture_output=True, timeout=30)
|
|
subprocess.run(["git", "pull"], cwd=work_dir, capture_output=True, timeout=30)
|
|
subprocess.run(["git", "checkout", "-b", branch], cwd=work_dir, capture_output=True, timeout=30)
|
|
|
|
# Run mimo via hermes CLI
|
|
log(f" Dispatching to hermes (nous/mimo-v2-pro)...")
|
|
result = subprocess.run(
|
|
["hermes", "chat", "-q", prompt, "--provider", "nous", "-m", "xiaomi/mimo-v2-pro",
|
|
"--yolo", "-t", "terminal,code_execution", "-Q"],
|
|
capture_output=True, text=True, timeout=900, # 15 min timeout
|
|
cwd=work_dir
|
|
)
|
|
|
|
log(f" Hermes exit: {result.returncode}")
|
|
log(f" Output: {result.stdout[-500:]}")
|
|
|
|
# Check for changes
|
|
status = subprocess.run(
|
|
["git", "status", "--porcelain"],
|
|
capture_output=True, text=True, cwd=work_dir
|
|
)
|
|
|
|
if not status.stdout.strip():
|
|
# Check for commits
|
|
log_count = subprocess.run(
|
|
["git", "log", "main..HEAD", "--oneline"],
|
|
capture_output=True, text=True, cwd=work_dir
|
|
)
|
|
if not log_count.stdout.strip():
|
|
log(f" NO CHANGES — abandoning")
|
|
# Release the claim
|
|
token = open(os.path.expanduser("~/.config/gitea/token")).read().strip()
|
|
import urllib.request
|
|
try:
|
|
req = urllib.request.Request(
|
|
f"https://forge.alexanderwhitestone.com/api/v1/repos/{repo}/issues/{issue}/labels/mimo-claimed",
|
|
headers={"Authorization": f"token {token}"},
|
|
method="DELETE"
|
|
)
|
|
urllib.request.urlopen(req, timeout=10)
|
|
except:
|
|
pass
|
|
write_result(worker_id, "abandoned", repo=repo, issue=issue, error="no changes produced")
|
|
if os.path.exists(prompt_file):
|
|
os.remove(prompt_file)
|
|
return False
|
|
|
|
# Commit dirty files (salvage)
|
|
if status.stdout.strip():
|
|
subprocess.run(["git", "add", "-A"], cwd=work_dir, capture_output=True, timeout=30)
|
|
subprocess.run(
|
|
["git", "commit", "-m", f"WIP: issue #{issue} (mimo swarm)"],
|
|
cwd=work_dir, capture_output=True, timeout=30
|
|
)
|
|
|
|
# Push
|
|
log(f" Pushing {branch}...")
|
|
push = subprocess.run(
|
|
["git", "push", "origin", branch],
|
|
capture_output=True, text=True, cwd=work_dir, timeout=60
|
|
)
|
|
if push.returncode != 0:
|
|
log(f" Push failed, trying force...")
|
|
subprocess.run(
|
|
["git", "push", "-f", "origin", branch],
|
|
capture_output=True, text=True, cwd=work_dir, timeout=60
|
|
)
|
|
|
|
# Create PR via API
|
|
token = open(os.path.expanduser("~/.config/gitea/token")).read().strip()
|
|
import urllib.request
|
|
|
|
# Get issue title
|
|
try:
|
|
req = urllib.request.Request(
|
|
f"https://forge.alexanderwhitestone.com/api/v1/repos/{repo}/issues/{issue}",
|
|
headers={"Authorization": f"token {token}", "Accept": "application/json"}
|
|
)
|
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
issue_data = json.loads(resp.read())
|
|
title = issue_data.get("title", f"Issue #{issue}")
|
|
except:
|
|
title = f"Issue #{issue}"
|
|
|
|
pr_body = json.dumps({
|
|
"title": f"fix: {title}",
|
|
"head": branch,
|
|
"base": "main",
|
|
"body": f"Closes #{issue}\n\nAutomated by mimo-v2-pro swarm.\nWorker: {worker_id}"
|
|
}).encode()
|
|
|
|
try:
|
|
req = urllib.request.Request(
|
|
f"https://forge.alexanderwhitestone.com/api/v1/repos/{repo}/pulls",
|
|
data=pr_body,
|
|
headers={
|
|
"Authorization": f"token {token}",
|
|
"Content-Type": "application/json"
|
|
},
|
|
method="POST"
|
|
)
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
pr_data = json.loads(resp.read())
|
|
pr_num = pr_data.get("number", "?")
|
|
log(f" PR CREATED: #{pr_num}")
|
|
except Exception as e:
|
|
log(f" PR FAILED: {e}")
|
|
pr_num = "?"
|
|
|
|
# Write result
|
|
write_result(worker_id, "completed", repo=repo, issue=issue, branch=branch, pr=pr_num)
|
|
|
|
# Remove prompt
|
|
# Remove prompt file (handles .processing extension)
|
|
if os.path.exists(prompt_file):
|
|
os.remove(prompt_file)
|
|
log(f" DONE — prompt removed")
|
|
return True
|
|
|
|
|
|
if __name__ == "__main__":
|
|
prompt = get_oldest_prompt()
|
|
if not prompt:
|
|
print("No prompts in queue")
|
|
sys.exit(0)
|
|
|
|
print(f"Processing: {os.path.basename(prompt)}")
|
|
success = run_worker(prompt)
|
|
sys.exit(0 if success else 1)
|