Files
the-nexus/mimo-swarm/scripts/mimo-dispatcher.py

543 lines
19 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
Mimo Swarm Dispatcher The Brain
Scans Gitea for open issues, claims them atomically via labels,
routes to lanes, and spawns one-shot mimo-v2-pro workers.
No new issues created. No duplicate claims. No bloat.
"""
2026-04-12 19:27:19 -04:00
import glob
import json
import os
import sys
import time
import subprocess
import urllib.request
import urllib.error
from datetime import datetime, timezone, timedelta
# ── Config ──────────────────────────────────────────────────────────────
GITEA_URL = "https://forge.alexanderwhitestone.com"
TOKEN_FILE = os.path.expanduser("~/.config/gitea/token")
STATE_DIR = os.path.expanduser("~/.hermes/mimo-swarm/state")
LOG_DIR = os.path.expanduser("~/.hermes/mimo-swarm/logs")
WORKER_SCRIPT = os.path.expanduser("~/.hermes/mimo-swarm/scripts/mimo-worker.sh")
# FOCUS MODE: all workers on ONE repo, deep polish
FOCUS_MODE = True
FOCUS_REPO = "Timmy_Foundation/the-nexus"
FOCUS_BUILD_CMD = "npm run build" # validation command before PR
FOCUS_BUILD_DIR = None # set to repo root after clone, auto-detected
# Lane caps (in focus mode, all lanes get more)
if FOCUS_MODE:
MAX_WORKERS_PER_LANE = {"CODE": 15, "BUILD": 8, "RESEARCH": 5, "CREATE": 7}
else:
MAX_WORKERS_PER_LANE = {"CODE": 10, "BUILD": 5, "RESEARCH": 5, "CREATE": 5}
CLAIM_TIMEOUT_MINUTES = 30
CLAIM_LABEL = "mimo-claimed"
2026-04-12 19:27:19 -04:00
MAX_QUEUE_DEPTH = 10 # Don't dispatch if queue already has this many prompts
CLAIM_COMMENT = "/claim"
DONE_COMMENT = "/done"
ABANDON_COMMENT = "/abandon"
# Lane detection from issue labels
LANE_MAP = {
"CODE": ["bug", "fix", "defect", "error", "harness", "config", "ci", "devops",
"critical", "p0", "p1", "backend", "api", "integration", "refactor"],
"BUILD": ["feature", "enhancement", "build", "ui", "frontend", "game", "tool",
"project", "deploy", "infrastructure"],
"RESEARCH": ["research", "investigate", "spike", "audit", "analysis", "study",
"benchmark", "evaluate", "explore"],
"CREATE": ["content", "creative", "write", "docs", "documentation", "story",
"narrative", "design", "art", "media"],
}
# Priority repos (serve first) — ordered by backlog richness
PRIORITY_REPOS = [
"Timmy_Foundation/the-nexus",
"Timmy_Foundation/hermes-agent",
"Timmy_Foundation/timmy-home",
"Timmy_Foundation/timmy-config",
"Timmy_Foundation/the-beacon",
"Timmy_Foundation/the-testament",
"Rockachopa/hermes-config",
"Timmy/claw-agent",
"replit/timmy-tower",
"Timmy_Foundation/fleet-ops",
"Timmy_Foundation/forge-log",
]
# Priority tags — issues with these labels get served FIRST regardless of lane
PRIORITY_TAGS = ["mnemosyne", "p0", "p1", "critical"]
# ── Helpers ─────────────────────────────────────────────────────────────
def load_token():
with open(TOKEN_FILE) as f:
return f.read().strip()
def api_get(path, token):
"""GET request to Gitea API."""
url = f"{GITEA_URL}/api/v1{path}"
req = urllib.request.Request(url, headers={
"Authorization": f"token {token}",
"Accept": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
def api_post(path, token, data):
"""POST request to Gitea API."""
url = f"{GITEA_URL}/api/v1{path}"
body = json.dumps(data).encode()
req = urllib.request.Request(url, data=body, headers={
"Authorization": f"token {token}",
"Content-Type": "application/json",
}, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else ""
log(f" API error {e.code}: {body[:200]}")
return None
def api_delete(path, token):
"""DELETE request to Gitea API."""
url = f"{GITEA_URL}/api/v1{path}"
req = urllib.request.Request(url, headers={
"Authorization": f"token {token}",
}, method="DELETE")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status
except urllib.error.HTTPError as e:
return e.code
def log(msg):
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
line = f"[{ts}] {msg}"
print(line)
log_file = os.path.join(LOG_DIR, f"dispatcher-{datetime.now().strftime('%Y%m%d')}.log")
with open(log_file, "a") as f:
f.write(line + "\n")
def load_state():
"""Load dispatcher state (active claims)."""
state_file = os.path.join(STATE_DIR, "dispatcher.json")
if os.path.exists(state_file):
with open(state_file) as f:
return json.load(f)
return {"active_claims": {}, "stats": {"total_dispatched": 0, "total_released": 0, "total_prs": 0}}
def save_state(state):
state_file = os.path.join(STATE_DIR, "dispatcher.json")
with open(state_file, "w") as f:
json.dump(state, f, indent=2)
# ── Issue Analysis ──────────────────────────────────────────────────────
def get_repos(token):
"""Get all accessible repos (excluding archived)."""
repos = []
page = 1
while True:
data = api_get(f"/repos/search?limit=50&page={page}&sort=updated", token)
if not data or not data.get("data"):
break
# Filter out archived repos
active = [r for r in data["data"] if not r.get("archived", False)]
repos.extend(active)
page += 1
if len(data["data"]) < 50:
break
return repos
def get_open_issues(repo_full_name, token):
"""Get open issues for a repo (not PRs)."""
issues = []
page = 1
while True:
data = api_get(f"/repos/{repo_full_name}/issues?state=open&limit=50&page={page}", token)
if not data:
break
# Filter out pull requests
real_issues = [i for i in data if not i.get("pull_request")]
issues.extend(real_issues)
page += 1
if len(data) < 50:
break
return issues
# Pre-fetched PR references (set by dispatch function before loop)
_PR_REFS = set()
_CLAIMED_COMMENTS = set()
def prefetch_pr_refs(repo_name, token):
"""Fetch all open PRs once and build a set of issue numbers they reference."""
global _PR_REFS
_PR_REFS = set()
prs = api_get(f"/repos/{repo_name}/pulls?state=open&limit=100", token)
if prs:
for pr in prs:
body = pr.get("body", "") or ""
head = pr.get("head", {}).get("ref", "")
# Extract issue numbers from body (Closes #NNN) and branch (issue-NNN)
import re
for match in re.finditer(r'#(\d+)', body):
_PR_REFS.add(int(match.group(1)))
for match in re.finditer(r'issue-(\d+)', head):
_PR_REFS.add(int(match.group(1)))
def is_claimed(issue, repo_name, token):
"""Check if issue is claimed (has mimo-claimed label or existing PR). NO extra API calls."""
labels = [l["name"] for l in issue.get("labels", [])]
if CLAIM_LABEL in labels:
return True
# Check pre-fetched PR refs (no API call)
if issue["number"] in _PR_REFS:
return True
# Skip comment check for speed — label is the primary mechanism
return False
def priority_score(issue):
"""Score an issue's priority. Higher = serve first."""
score = 0
labels = [l["name"].lower() for l in issue.get("labels", [])]
title = issue.get("title", "").lower()
# Mnemosyne gets absolute priority — check title AND labels
if "mnemosyne" in title or any("mnemosyne" in l for l in labels):
score += 300
# Priority tags boost
for tag in PRIORITY_TAGS:
if tag in labels or f"[{tag}]" in title:
score += 100
# Older issues get slight boost (clear backlog)
created = issue.get("created_at", "")
if created:
try:
created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
age_days = (datetime.now(timezone.utc) - created_dt).days
score += min(age_days, 30) # Cap at 30 days
except:
pass
return score
def detect_lane(issue):
"""Detect which lane an issue belongs to based on labels."""
labels = [l["name"].lower() for l in issue.get("labels", [])]
for lane, keywords in LANE_MAP.items():
for label in labels:
if label in keywords:
return lane
# Check title for keywords
title = issue.get("title", "").lower()
for lane, keywords in LANE_MAP.items():
for kw in keywords:
if kw in title:
return lane
return "CODE" # Default
def count_active_in_lane(state, lane):
"""Count currently active workers in a lane."""
count = 0
for claim in state["active_claims"].values():
if claim.get("lane") == lane:
count += 1
return count
# ── Claiming ────────────────────────────────────────────────────────────
def claim_issue(issue, repo_name, lane, token):
"""Claim an issue: add label + comment."""
repo = repo_name
num = issue["number"]
# Add mimo-claimed label
api_post(f"/repos/{repo}/issues/{num}/labels", token, {"labels": [CLAIM_LABEL]})
# Add /claim comment
comment_body = f"/claim — mimo-v2-pro [{lane}] lane. Branch: `mimo/{lane.lower()}/issue-{num}`"
api_post(f"/repos/{repo}/issues/{num}/comments", token, {"body": comment_body})
log(f" CLAIMED #{num} in {repo} [{lane}]")
def release_issue(issue, repo_name, reason, token):
"""Release a claim: remove label, add /done or /abandon comment."""
repo = repo_name
num = issue["number"]
# Remove mimo-claimed label
labels = [l["name"] for l in issue.get("labels", [])]
if CLAIM_LABEL in labels:
api_delete(f"/repos/{repo}/issues/{num}/labels/{CLAIM_LABEL}", token)
# Add completion comment
comment = f"{ABANDON_COMMENT}{reason}" if reason != "done" else f"{DONE_COMMENT} — completed by mimo-v2-pro"
api_post(f"/repos/{repo}/issues/{num}/comments", token, {"body": comment})
log(f" RELEASED #{num} in {repo}: {reason}")
# ── Worker Spawning ─────────────────────────────────────────────────────
def spawn_worker(issue, repo_name, lane, token):
"""Spawn a one-shot mimo worker for an issue."""
repo = repo_name
num = issue["number"]
title = issue["title"]
body = issue.get("body", "")[:2000] # Truncate long bodies
labels = [l["name"] for l in issue.get("labels", [])]
# Build worker prompt
worker_id = f"mimo-{lane.lower()}-{num}-{int(time.time())}"
prompt = build_worker_prompt(repo, num, title, body, labels, lane, worker_id)
# Write prompt to temp file for the cron job to pick up
prompt_file = os.path.join(STATE_DIR, f"prompt-{worker_id}.txt")
with open(prompt_file, "w") as f:
f.write(prompt)
log(f" SPAWNING worker {worker_id} for #{num} [{lane}]")
return worker_id
def build_worker_prompt(repo, num, title, body, labels, lane, worker_id):
"""Build the prompt for a mimo worker. Focus-mode aware with build validation."""
lane_instructions = {
"CODE": """You are a coding worker. Fix bugs, implement features, refactor code.
- Read existing code BEFORE writing anything
- Match the code style of the file you're editing
- If Three.js code: use the existing patterns in the codebase
- If config/infra: be precise, check existing values first""",
"BUILD": """You are a builder. Create new functionality, UI components, tools.
- Study the existing architecture before building
- Create complete, working implementations no stubs
- For UI: match the existing visual style
- For APIs: follow the existing route patterns""",
"RESEARCH": """You are a researcher. Investigate the issue thoroughly.
- Read all relevant code and documentation
- Document findings in a markdown file: FINDINGS-issue-{num}.md
- Include: what you found, what's broken, recommended fix, effort estimate
- Create a summary PR with the findings document""",
"CREATE": """You are a creative worker. Write content, documentation, design.
- Quality over quantity one excellent asset beats five mediocre ones
- Match the existing tone and style of the project
- For docs: include code examples where relevant""",
}
clone_url = f"{GITEA_URL}/{repo}.git"
branch = f"mimo/{lane.lower()}/issue-{num}"
focus_section = ""
if FOCUS_MODE and repo == FOCUS_REPO:
focus_section = f"""
## FOCUS MODE — THIS IS THE NEXUS
The Nexus is a Three.js 3D world Timmy's sovereign home on the web.
Tech stack: vanilla JS, Three.js, WebSocket, HTML/CSS.
Entry point: app.js (root) or public/nexus/app.js
The world features: nebula skybox, portals, memory crystals, batcave terminal.
IMPORTANT: After implementing, you MUST validate:
1. cd /tmp/{worker_id}
2. Check for syntax errors: node --check *.js (if JS files changed)
3. If package.json exists: npm install --legacy-peer-deps && npm run build
4. If build fails: FIX IT before pushing. No broken builds.
5. If no build command exists: just validate syntax on changed files
"""
return f"""You are a mimo-v2-pro swarm worker. {lane_instructions.get(lane, lane_instructions["CODE"])}
## ISSUE
Repository: {repo}
Issue: #{num}
Title: {title}
Labels: {', '.join(labels)}
Description:
{body}
{focus_section}
## WORKFLOW
1. Clone: git clone {clone_url} /tmp/{worker_id} 2>/dev/null || (cd /tmp/{worker_id} && git fetch origin && git checkout main && git pull)
2. cd /tmp/{worker_id}
3. Create branch: git checkout -b {branch}
4. READ THE CODE. Understand the architecture before writing anything.
5. Implement the fix/feature/solution.
6. BUILD VALIDATION:
- Syntax check: node --check <file>.js for any JS changed
- If package.json exists: npm install --legacy-peer-deps 2>/dev/null && npm run build 2>&1
- If build fails: FIX THE BUILD. No broken PRs.
- Ensure git diff shows meaningful changes (>0 lines)
7. Commit: git add -A && git commit -m "fix: {title} (closes #{num})"
8. Push: git push origin {branch}
9. Create PR via API:
curl -s -X POST '{GITEA_URL}/api/v1/repos/{repo}/pulls' \\
-H 'Authorization: token $(cat ~/.config/gitea/token)' \\
-H 'Content-Type: application/json' \\
-d '{{"title":"fix: {title}","head":"{branch}","base":"main","body":"Closes #{num}\\n\\nAutomated by mimo-v2-pro swarm.\\n\\n## Changes\\n- [describe what you changed]\\n\\n## Validation\\n- [x] Syntax check passed\\n- [x] Build passes (if applicable)"}}'
## HARD RULES
- NEVER exit without committing. Even partial progress must be committed.
- NEVER create new issues. Only work on issue #{num}.
- NEVER push to main. Only push to your branch.
- NEVER push a broken build. Fix it or abandon with clear notes.
- If too complex: commit WIP, push, PR body says "WIP — needs human review"
- If build fails and you can't fix: commit anyway, push, PR body says "Build failed — needs human fix"
Worker: {worker_id}
"""
# ── Main ────────────────────────────────────────────────────────────────
def dispatch(token):
"""Main dispatch loop."""
state = load_state()
dispatched = 0
log("=" * 60)
log("MIMO DISPATCHER — scanning for work")
# Clean stale claims first
stale = []
for claim_id, claim in list(state["active_claims"].items()):
started = datetime.fromisoformat(claim["started"])
age = datetime.now(timezone.utc) - started
if age > timedelta(minutes=CLAIM_TIMEOUT_MINUTES):
stale.append(claim_id)
for claim_id in stale:
claim = state["active_claims"].pop(claim_id)
log(f" EXPIRED claim: {claim['repo']}#{claim['issue']} [{claim['lane']}]")
state["stats"]["total_released"] += 1
# Prefetch PR refs once (avoids N API calls in is_claimed)
target_repo = FOCUS_REPO if FOCUS_MODE else PRIORITY_REPOS[0]
prefetch_pr_refs(target_repo, token)
log(f" Prefetched {len(_PR_REFS)} PR references")
2026-04-12 19:27:19 -04:00
# Check queue depth — don't pile up if workers haven't caught up
pending_prompts = len(glob.glob(os.path.join(STATE_DIR, "prompt-*.txt")))
if pending_prompts >= MAX_QUEUE_DEPTH:
log(f" QUEUE THROTTLE: {pending_prompts} prompts pending (max {MAX_QUEUE_DEPTH}) — skipping dispatch")
save_state(state)
return 0
# FOCUS MODE: scan only the focus repo. FIREHOSE: scan all.
if FOCUS_MODE:
ordered = [FOCUS_REPO]
log(f" FOCUS MODE: targeting {FOCUS_REPO} only")
else:
repos = get_repos(token)
repo_names = [r["full_name"] for r in repos]
ordered = []
for pr in PRIORITY_REPOS:
if pr in repo_names:
ordered.append(pr)
for rn in repo_names:
if rn not in ordered:
ordered.append(rn)
# Scan each repo and collect all issues for priority sorting
all_issues = []
for repo_name in ordered[:20 if not FOCUS_MODE else 1]:
issues = get_open_issues(repo_name, token)
for issue in issues:
issue["_repo_name"] = repo_name # Tag with repo
all_issues.append(issue)
# Sort by priority score (highest first)
all_issues.sort(key=priority_score, reverse=True)
for issue in all_issues:
repo_name = issue["_repo_name"]
# Skip if already claimed in state
claim_key = f"{repo_name}#{issue['number']}"
if claim_key in state["active_claims"]:
continue
# Skip if claimed in Gitea
if is_claimed(issue, repo_name, token):
continue
# Detect lane
lane = detect_lane(issue)
# Check lane capacity
active_in_lane = count_active_in_lane(state, lane)
max_in_lane = MAX_WORKERS_PER_LANE.get(lane, 1)
if active_in_lane >= max_in_lane:
continue # Lane full, skip
# Claim and spawn
claim_issue(issue, repo_name, lane, token)
worker_id = spawn_worker(issue, repo_name, lane, token)
state["active_claims"][claim_key] = {
"repo": repo_name,
"issue": issue["number"],
"lane": lane,
"worker_id": worker_id,
"started": datetime.now(timezone.utc).isoformat(),
}
state["stats"]["total_dispatched"] += 1
dispatched += 1
max_dispatch = 35 if FOCUS_MODE else 25
if dispatched >= max_dispatch:
break
save_state(state)
# Summary
active = len(state["active_claims"])
log(f"Dispatch complete: {dispatched} new, {active} active, {state['stats']['total_dispatched']} total dispatched")
log(f"Active by lane: CODE={count_active_in_lane(state,'CODE')}, BUILD={count_active_in_lane(state,'BUILD')}, RESEARCH={count_active_in_lane(state,'RESEARCH')}, CREATE={count_active_in_lane(state,'CREATE')}")
return dispatched
if __name__ == "__main__":
token = load_token()
dispatched = dispatch(token)
sys.exit(0 if dispatched >= 0 else 1)