634 lines
22 KiB
Python
634 lines
22 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
Workforce Manager - Epic #204 / Milestone #218
|
||
|
|
|
||
|
|
Reads fleet routing, Wolf evaluation scores, and open Gitea issues across
|
||
|
|
Timmy_Foundation repos. Assigns each issue to the best-available agent,
|
||
|
|
tracks success rates, and dispatches work.
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
python workforce-manager.py # Scan, assign, dispatch
|
||
|
|
python workforce-manager.py --dry-run # Show assignments without dispatching
|
||
|
|
python workforce-manager.py --status # Show agent status and open issue count
|
||
|
|
python workforce-manager.py --cron # Run silently, save to log
|
||
|
|
"""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import logging
|
||
|
|
import os
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any, Dict, List, Optional
|
||
|
|
|
||
|
|
try:
|
||
|
|
import requests
|
||
|
|
except ImportError:
|
||
|
|
print("FATAL: requests is required. pip install requests", file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Constants
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
FLEET_ROUTING_PATH = Path.home() / ".hermes" / "fleet-routing.json"
|
||
|
|
WOLF_RESULTS_DIR = Path.home() / ".hermes" / "wolf" / "results"
|
||
|
|
GITEA_TOKEN_PATH = Path.home() / ".hermes" / "gitea_token_vps"
|
||
|
|
GITEA_API_BASE = "https://forge.alexanderwhitestone.com/api/v1"
|
||
|
|
WORKFORCE_STATE_PATH = Path.home() / ".hermes" / "workforce-state.json"
|
||
|
|
ORG_NAME = "Timmy_Foundation"
|
||
|
|
|
||
|
|
# Role-to-agent-role mapping heuristics
|
||
|
|
ROLE_KEYWORDS = {
|
||
|
|
"code-generation": [
|
||
|
|
"code", "implement", "feature", "function", "class", "script",
|
||
|
|
"build", "create", "add", "module", "component",
|
||
|
|
],
|
||
|
|
"issue-triage": [
|
||
|
|
"triage", "categorize", "tag", "label", "organize",
|
||
|
|
"backlog", "sort", "prioritize", "review issue",
|
||
|
|
],
|
||
|
|
"on-request-queries": [
|
||
|
|
"query", "search", "lookup", "find", "check",
|
||
|
|
"info", "report", "status",
|
||
|
|
],
|
||
|
|
"devops": [
|
||
|
|
"deploy", "ci", "cd", "pipeline", "docker", "container",
|
||
|
|
"server", "infrastructure", "config", "nginx", "cron",
|
||
|
|
"setup", "install", "environment", "provision",
|
||
|
|
"build", "release", "workflow",
|
||
|
|
],
|
||
|
|
"documentation": [
|
||
|
|
"doc", "readme", "document", "write", "guide",
|
||
|
|
"spec", "wiki", "changelog", "tutorial",
|
||
|
|
"explain", "describe",
|
||
|
|
],
|
||
|
|
"code-review": [
|
||
|
|
"review", "refactor", "fix", "bug", "debug",
|
||
|
|
"test", "lint", "style", "improve",
|
||
|
|
"clean up", "optimize", "performance",
|
||
|
|
],
|
||
|
|
"triage-routing": [
|
||
|
|
"route", "assign", "triage", "dispatch",
|
||
|
|
"organize", "categorize",
|
||
|
|
],
|
||
|
|
"small-tasks": [
|
||
|
|
"small", "quick", "minor", "typo", "label",
|
||
|
|
"update", "rename", "cleanup",
|
||
|
|
],
|
||
|
|
"inactive": [],
|
||
|
|
"unknown": [],
|
||
|
|
}
|
||
|
|
|
||
|
|
# Priority keywords (higher = more urgent, route to more capable agent)
|
||
|
|
PRIORITY_KEYWORDS = {
|
||
|
|
"critical": 5,
|
||
|
|
"urgent": 4,
|
||
|
|
"block": 4,
|
||
|
|
"bug": 3,
|
||
|
|
"fix": 3,
|
||
|
|
"security": 5,
|
||
|
|
"deploy": 2,
|
||
|
|
"feature": 1,
|
||
|
|
"enhancement": 1,
|
||
|
|
"documentation": 1,
|
||
|
|
"cleanup": 0,
|
||
|
|
}
|
||
|
|
|
||
|
|
# Cost tier priority (lower index = prefer first)
|
||
|
|
TIER_ORDER = ["free", "cheap", "prepaid", "unknown"]
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Data loading
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def load_json(path: Path) -> Any:
|
||
|
|
if not path.exists():
|
||
|
|
logging.warning("File not found: %s", path)
|
||
|
|
return None
|
||
|
|
with open(path) as f:
|
||
|
|
return json.load(f)
|
||
|
|
|
||
|
|
|
||
|
|
def load_fleet_routing() -> List[dict]:
|
||
|
|
data = load_json(FLEET_ROUTING_PATH)
|
||
|
|
if data and "agents" in data:
|
||
|
|
return data["agents"]
|
||
|
|
return []
|
||
|
|
|
||
|
|
|
||
|
|
def load_wolf_scores() -> Dict[str, dict]:
|
||
|
|
"""Load Wolf evaluation scores from results directory."""
|
||
|
|
scores: Dict[str, dict] = {}
|
||
|
|
if not WOLF_RESULTS_DIR.exists():
|
||
|
|
return scores
|
||
|
|
for f in sorted(WOLF_RESULTS_DIR.glob("*.json")):
|
||
|
|
data = load_json(f)
|
||
|
|
if data and "model_scores" in data:
|
||
|
|
for entry in data["model_scores"]:
|
||
|
|
model = entry.get("model", "")
|
||
|
|
if model:
|
||
|
|
scores[model] = entry
|
||
|
|
return scores
|
||
|
|
|
||
|
|
|
||
|
|
def load_workforce_state() -> dict:
|
||
|
|
if WORKFORCE_STATE_PATH.exists():
|
||
|
|
return load_json(WORKFORCE_STATE_PATH) or {}
|
||
|
|
return {"assignments": [], "agent_stats": {}, "last_run": None}
|
||
|
|
|
||
|
|
|
||
|
|
def save_workforce_state(state: dict) -> None:
|
||
|
|
WORKFORCE_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
with open(WORKFORCE_STATE_PATH, "w") as f:
|
||
|
|
json.dump(state, f, indent=2)
|
||
|
|
logging.info("Workforce state saved to %s", WORKFORCE_STATE_PATH)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Gitea API
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class GiteaAPI:
|
||
|
|
"""Thin wrapper for Gitea REST API."""
|
||
|
|
|
||
|
|
def __init__(self, token: str, base_url: str = GITEA_API_BASE):
|
||
|
|
self.base_url = base_url.rstrip("/")
|
||
|
|
self.session = requests.Session()
|
||
|
|
self.session.headers.update({
|
||
|
|
"Authorization": f"token {token}",
|
||
|
|
"Accept": "application/json",
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
})
|
||
|
|
|
||
|
|
def _get(self, path: str, params: Optional[dict] = None) -> Any:
|
||
|
|
r = self.session.get(f"{self.base_url}{path}", params=params)
|
||
|
|
r.raise_for_status()
|
||
|
|
return r.json()
|
||
|
|
|
||
|
|
def _post(self, path: str, data: dict) -> Any:
|
||
|
|
r = self.session.post(f"{self.base_url}{path}", json=data)
|
||
|
|
r.raise_for_status()
|
||
|
|
return r.json()
|
||
|
|
|
||
|
|
def _patch(self, path: str, data: dict) -> Any:
|
||
|
|
r = self.session.patch(f"{self.base_url}{path}", json=data)
|
||
|
|
r.raise_for_status()
|
||
|
|
return r.json()
|
||
|
|
|
||
|
|
def get_org_repos(self, org: str) -> List[dict]:
|
||
|
|
return self._get(f"/orgs/{org}/repos", params={"limit": 100})
|
||
|
|
|
||
|
|
def get_open_issues(self, owner: str, repo: str, page: int = 1) -> List[dict]:
|
||
|
|
params = {"state": "open", "type": "issues", "limit": 50, "page": page}
|
||
|
|
return self._get(f"/repos/{owner}/{repo}/issues", params=params)
|
||
|
|
|
||
|
|
def get_all_open_issues(self, org: str) -> List[dict]:
|
||
|
|
"""Fetch all open issues across all org repos."""
|
||
|
|
repos = self.get_org_repos(org)
|
||
|
|
all_issues = []
|
||
|
|
for repo in repos:
|
||
|
|
name = repo["name"]
|
||
|
|
try:
|
||
|
|
# Paginate through all issues
|
||
|
|
page = 1
|
||
|
|
while True:
|
||
|
|
issues = self.get_open_issues(org, name, page=page)
|
||
|
|
if not issues:
|
||
|
|
break
|
||
|
|
all_issues.extend(issues)
|
||
|
|
if len(issues) < 50:
|
||
|
|
break
|
||
|
|
page += 1
|
||
|
|
logging.info("Loaded %d open issues from %s/%s", len(all_issues), org, name)
|
||
|
|
except Exception as exc:
|
||
|
|
logging.warning("Failed to load issues from %s/%s: %s", org, name, exc)
|
||
|
|
return all_issues
|
||
|
|
|
||
|
|
def add_issue_comment(self, owner: str, repo: str, issue_num: int, body: str) -> dict:
|
||
|
|
return self._post(f"/repos/{owner}/{repo}/issues/{issue_num}/comments", {"body": body})
|
||
|
|
|
||
|
|
def add_issue_label(self, owner: str, repo: str, issue_num: int, label: str) -> dict:
|
||
|
|
return self._post(
|
||
|
|
f"/repos/{owner}/{repo}/issues/{issue_num}/labels",
|
||
|
|
{"labels": [label]},
|
||
|
|
)
|
||
|
|
|
||
|
|
def assign_issue(self, owner: str, repo: str, issue_num: int, assignee: str) -> dict:
|
||
|
|
return self._patch(
|
||
|
|
f"/repos/{owner}/{repo}/issues/{issue_num}",
|
||
|
|
{"assignees": [assignee]},
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Scoring & Assignment Logic
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def classify_issue(issue: dict) -> str:
|
||
|
|
"""Determine the best agent role for an issue based on title/body."""
|
||
|
|
title = (issue.get("title", "") or "").lower()
|
||
|
|
body = (issue.get("body", "") or "").lower()
|
||
|
|
text = f"{title} {body}"
|
||
|
|
labels = [l.get("name", "").lower() for l in issue.get("labels", [])]
|
||
|
|
text += " " + " ".join(labels)
|
||
|
|
|
||
|
|
best_role = "small-tasks" # default
|
||
|
|
best_score = 0
|
||
|
|
|
||
|
|
for role, keywords in ROLE_KEYWORDS.items():
|
||
|
|
if not keywords:
|
||
|
|
continue
|
||
|
|
score = sum(2 for kw in keywords if kw in text)
|
||
|
|
# Boost if a matching label exists
|
||
|
|
for label in labels:
|
||
|
|
if any(kw in label for kw in keywords):
|
||
|
|
score += 3
|
||
|
|
if score > best_score:
|
||
|
|
best_score = score
|
||
|
|
best_role = role
|
||
|
|
|
||
|
|
return best_role
|
||
|
|
|
||
|
|
|
||
|
|
def compute_priority(issue: dict) -> int:
|
||
|
|
"""Compute issue priority from keywords."""
|
||
|
|
title = (issue.get("title", "") or "").lower()
|
||
|
|
body = (issue.get("body", "") or "").lower()
|
||
|
|
text = f"{title} {body}"
|
||
|
|
return sum(v for k, v in PRIORITY_KEYWORDS.items() if k in text)
|
||
|
|
|
||
|
|
|
||
|
|
def score_agent_for_issue(agent: dict, role: str, wolf_scores: dict, priority: int) -> float:
|
||
|
|
"""Score how well an agent matches an issue. Higher is better."""
|
||
|
|
score = 0.0
|
||
|
|
|
||
|
|
# Primary: role match
|
||
|
|
agent_role = agent.get("role", "unknown")
|
||
|
|
if agent_role == role:
|
||
|
|
score += 10.0
|
||
|
|
elif agent_role == "small-tasks" and role in ("issue-triage", "on-request-queries"):
|
||
|
|
score += 6.0
|
||
|
|
elif agent_role == "triage-routing" and role in ("issue-triage", "triage-routing"):
|
||
|
|
score += 8.0
|
||
|
|
elif agent_role == "code-generation" and role in ("code-review", "devops"):
|
||
|
|
score += 4.0
|
||
|
|
|
||
|
|
# Wolf quality bonus
|
||
|
|
model = agent.get("model", "")
|
||
|
|
wolf_entry = None
|
||
|
|
for wm, ws in wolf_scores.items():
|
||
|
|
if model and model.lower() in wm.lower():
|
||
|
|
wolf_entry = ws
|
||
|
|
break
|
||
|
|
if wolf_entry and wolf_entry.get("success"):
|
||
|
|
score += wolf_entry.get("total", 0) * 3.0
|
||
|
|
|
||
|
|
# Cost efficiency: prefer free/cheap for low priority
|
||
|
|
tier = agent.get("tier", "unknown")
|
||
|
|
tier_idx = TIER_ORDER.index(tier) if tier in TIER_ORDER else 3
|
||
|
|
if priority <= 1 and tier in ("free", "cheap"):
|
||
|
|
score += 4.0
|
||
|
|
elif priority >= 3 and tier in ("prepaid",):
|
||
|
|
score += 3.0
|
||
|
|
else:
|
||
|
|
score += (3 - tier_idx) * 1.0
|
||
|
|
|
||
|
|
# Activity bonus
|
||
|
|
if agent.get("active", False):
|
||
|
|
score += 2.0
|
||
|
|
|
||
|
|
# Repo familiarity bonus: more repos slightly better
|
||
|
|
repo_count = agent.get("repo_count", 0)
|
||
|
|
score += min(repo_count * 0.2, 2.0)
|
||
|
|
|
||
|
|
return round(score, 3)
|
||
|
|
|
||
|
|
|
||
|
|
def find_best_agent(agents: List[dict], role: str, wolf_scores: dict, priority: int,
|
||
|
|
exclude: Optional[List[str]] = None) -> Optional[dict]:
|
||
|
|
"""Find the best agent for the given role and priority."""
|
||
|
|
exclude = exclude or []
|
||
|
|
candidates = []
|
||
|
|
for agent in agents:
|
||
|
|
if agent.get("name") in exclude:
|
||
|
|
continue
|
||
|
|
if not agent.get("active", False):
|
||
|
|
continue
|
||
|
|
s = score_agent_for_issue(agent, role, wolf_scores, priority)
|
||
|
|
candidates.append((s, agent))
|
||
|
|
|
||
|
|
if not candidates:
|
||
|
|
return None
|
||
|
|
|
||
|
|
candidates.sort(key=lambda x: x[0], reverse=True)
|
||
|
|
return candidates[0][1]
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Dispatch
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def dispatch_assignment(api: GiteaAPI, issue: dict, agent: dict, dry_run: bool = False) -> dict:
|
||
|
|
"""Assign an issue to an agent and optionally post a comment."""
|
||
|
|
owner = ORG_NAME
|
||
|
|
repo = issue.get("repository", {}).get("name", "")
|
||
|
|
|
||
|
|
# Extract repo from issue repo_url if not in the repository key
|
||
|
|
if not repo:
|
||
|
|
repo_url = issue.get("repository_url", "")
|
||
|
|
if repo_url:
|
||
|
|
repo = repo_url.rstrip("/").split("/")[-1]
|
||
|
|
|
||
|
|
if not repo:
|
||
|
|
return {"success": False, "error": "Cannot determine repository for issue"}
|
||
|
|
|
||
|
|
issue_num = issue.get("number")
|
||
|
|
agent_name = agent.get("name", "unknown")
|
||
|
|
|
||
|
|
comment_body = (
|
||
|
|
f"🤖 **Workforce Manager assigned this issue to: @{agent_name}**\n\n"
|
||
|
|
f"- **Agent:** {agent_name}\n"
|
||
|
|
f"- **Model:** {agent.get('model', 'unknown')}\n"
|
||
|
|
f"- **Role:** {agent.get('role', 'unknown')}\n"
|
||
|
|
f"- **Tier:** {agent.get('tier', 'unknown')}\n"
|
||
|
|
f"- **Assigned at:** {datetime.now(timezone.utc).isoformat()}\n\n"
|
||
|
|
f"*Automated assignment by Workforce Manager (Epic #204)*"
|
||
|
|
)
|
||
|
|
|
||
|
|
if dry_run:
|
||
|
|
return {
|
||
|
|
"success": True,
|
||
|
|
"dry_run": True,
|
||
|
|
"repo": repo,
|
||
|
|
"issue_number": issue_num,
|
||
|
|
"assignee": agent_name,
|
||
|
|
"comment": comment_body,
|
||
|
|
}
|
||
|
|
|
||
|
|
try:
|
||
|
|
api.assign_issue(owner, repo, issue_num, agent_name)
|
||
|
|
api.add_issue_comment(owner, repo, issue_num, comment_body)
|
||
|
|
return {
|
||
|
|
"success": True,
|
||
|
|
"repo": repo,
|
||
|
|
"issue_number": issue_num,
|
||
|
|
"issue_title": issue.get("title", ""),
|
||
|
|
"assignee": agent_name,
|
||
|
|
}
|
||
|
|
except Exception as exc:
|
||
|
|
return {
|
||
|
|
"success": False,
|
||
|
|
"repo": repo,
|
||
|
|
"issue_number": issue_num,
|
||
|
|
"error": str(exc),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# State Tracking
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def update_agent_stats(state: dict, result: dict) -> None:
|
||
|
|
"""Update per-agent success tracking."""
|
||
|
|
agent_name = result.get("assignee", "unknown")
|
||
|
|
if "agent_stats" not in state:
|
||
|
|
state["agent_stats"] = {}
|
||
|
|
if agent_name not in state["agent_stats"]:
|
||
|
|
state["agent_stats"][agent_name] = {
|
||
|
|
"total_assigned": 0,
|
||
|
|
"successful": 0,
|
||
|
|
"failed": 0,
|
||
|
|
"success_rate": 0.0,
|
||
|
|
"last_assignment": None,
|
||
|
|
"assigned_issues": [],
|
||
|
|
}
|
||
|
|
|
||
|
|
stats = state["agent_stats"][agent_name]
|
||
|
|
stats["total_assigned"] += 1
|
||
|
|
stats["last_assignment"] = datetime.now(timezone.utc).isoformat()
|
||
|
|
stats["assigned_issues"].append({
|
||
|
|
"repo": result.get("repo", ""),
|
||
|
|
"issue_number": result.get("issue_number"),
|
||
|
|
"success": result.get("success", False),
|
||
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||
|
|
})
|
||
|
|
|
||
|
|
if result.get("success"):
|
||
|
|
stats["successful"] += 1
|
||
|
|
else:
|
||
|
|
stats["failed"] += 1
|
||
|
|
|
||
|
|
total = stats["successful"] + stats["failed"]
|
||
|
|
stats["success_rate"] = round(stats["successful"] / total, 3) if total > 0 else 0.0
|
||
|
|
|
||
|
|
|
||
|
|
def print_status(state: dict, agents: List[dict], issues_count: int) -> None:
|
||
|
|
"""Print workforce status."""
|
||
|
|
print(f"\n{'=' * 60}")
|
||
|
|
print(f"Workforce Manager Status - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
||
|
|
print(f"{'=' * 60}")
|
||
|
|
|
||
|
|
# Fleet summary
|
||
|
|
active = [a for a in agents if a.get("active")]
|
||
|
|
print(f"\nFleet: {len(active)} active agents, {len(agents)} total")
|
||
|
|
tier_counts = {}
|
||
|
|
for a in active:
|
||
|
|
t = a.get("tier", "unknown")
|
||
|
|
tier_counts[t] = tier_counts.get(t, 0) + 1
|
||
|
|
for t, c in sorted(tier_counts.items()):
|
||
|
|
print(f" {t}: {c} agents")
|
||
|
|
|
||
|
|
# Agent scores
|
||
|
|
wolf = load_wolf_scores()
|
||
|
|
print(f"\nAgent Details:")
|
||
|
|
print(f" {'Name':<25} {'Model':<30} {'Role':<18} {'Tier':<10}")
|
||
|
|
for a in agents:
|
||
|
|
if not a.get("active"):
|
||
|
|
continue
|
||
|
|
stats = state.get("agent_stats", {}).get(a["name"], {})
|
||
|
|
rate = stats.get("success_rate", 0.0)
|
||
|
|
total = stats.get("total_assigned", 0)
|
||
|
|
wolf_badge = ""
|
||
|
|
for wm, ws in wolf.items():
|
||
|
|
if a["model"] and a["model"].lower() in wm.lower() and ws.get("success"):
|
||
|
|
wolf_badge = f"[wolf:{ws['total']}]"
|
||
|
|
break
|
||
|
|
name_str = f"{a['name']} {wolf_badge}"
|
||
|
|
if total > 0:
|
||
|
|
name_str += f" (s/r: {rate}, n={total})"
|
||
|
|
print(f" {name_str:<45} {a.get('role', 'unknown'):<18} {a.get('tier', '?'):<10}")
|
||
|
|
|
||
|
|
print(f"\nOpen Issues: {issues_count}")
|
||
|
|
print(f"Assignments Made: {len(state.get('assignments', []))}")
|
||
|
|
if state.get("last_run"):
|
||
|
|
print(f"Last Run: {state['last_run']}")
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Main
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
parser = argparse.ArgumentParser(description="Workforce Manager - Assign Gitea issues to AI agents")
|
||
|
|
parser.add_argument("--dry-run", action="store_true", help="Show assignments without dispatching")
|
||
|
|
parser.add_argument("--status", action="store_true", help="Show workforce status only")
|
||
|
|
parser.add_argument("--cron", action="store_true", help="Run silently for cron scheduling")
|
||
|
|
parser.add_argument("--label", type=str, help="Only process issues with this label")
|
||
|
|
parser.add_argument("--max-issues", type=int, default=100, help="Max issues to process per run")
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
# Setup logging
|
||
|
|
if args.cron:
|
||
|
|
logging.basicConfig(level=logging.WARNING, format="%(asctime)s [%(levelname)s] %(message)s")
|
||
|
|
else:
|
||
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||
|
|
|
||
|
|
logging.info("Workforce Manager starting")
|
||
|
|
|
||
|
|
# Load data
|
||
|
|
agents = load_fleet_routing()
|
||
|
|
if not agents:
|
||
|
|
logging.error("No agents found in fleet-routing.json")
|
||
|
|
return 1
|
||
|
|
logging.info("Loaded %d agents from fleet routing", len(agents))
|
||
|
|
|
||
|
|
wolf_scores = load_wolf_scores()
|
||
|
|
if wolf_scores:
|
||
|
|
logging.info("Loaded %d model scores from Wolf results", len(wolf_scores))
|
||
|
|
|
||
|
|
state = load_workforce_state()
|
||
|
|
|
||
|
|
# Load Gitea token
|
||
|
|
if GITEA_TOKEN_PATH.exists():
|
||
|
|
token = GITEA_TOKEN_PATH.read_text().strip()
|
||
|
|
else:
|
||
|
|
logging.error("Gitea token not found at %s", GITEA_TOKEN_PATH)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
api = GiteaAPI(token)
|
||
|
|
|
||
|
|
# Status-only mode
|
||
|
|
if args.status:
|
||
|
|
# Quick open issue count
|
||
|
|
repos = api.get_org_repos(ORG_NAME)
|
||
|
|
total = sum(r.get("open_issues_count", 0) for r in repos)
|
||
|
|
print_status(state, agents, total)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
# Fetch open issues
|
||
|
|
if not args.cron:
|
||
|
|
print(f"Scanning open issues across {ORG_NAME} repos...")
|
||
|
|
|
||
|
|
issues = api.get_all_open_issues(ORG_NAME)
|
||
|
|
|
||
|
|
# Filter by label
|
||
|
|
if args.label:
|
||
|
|
issues = [
|
||
|
|
i for i in issues
|
||
|
|
if any(args.label in (l.get("name", "") or "").lower() for l in i.get("labels", []))
|
||
|
|
]
|
||
|
|
|
||
|
|
if args.label:
|
||
|
|
logging.info("Filtered to %d issues with label '%s'", len(issues), args.label)
|
||
|
|
else:
|
||
|
|
logging.info("Found %d open issues", len(issues))
|
||
|
|
|
||
|
|
# Skip issues already assigned
|
||
|
|
already_assigned_nums = set()
|
||
|
|
for a in state.get("assignments", []):
|
||
|
|
already_assigned_nums.add((a.get("repo"), a.get("issue_number")))
|
||
|
|
|
||
|
|
issues = [
|
||
|
|
i for i in issues
|
||
|
|
if not i.get("assignee") and
|
||
|
|
not (i.get("repository", {}).get("name"), i.get("number")) in already_assigned_nums
|
||
|
|
]
|
||
|
|
logging.info("%d unassigned issues to process", len(issues))
|
||
|
|
|
||
|
|
# Sort by priority
|
||
|
|
issues_with_priority = [(compute_priority(i), i) for i in issues]
|
||
|
|
issues_with_priority.sort(key=lambda x: x[0], reverse=True)
|
||
|
|
issues = [i for _, i in issues_with_priority[:args.max_issues]]
|
||
|
|
|
||
|
|
# Assign issues
|
||
|
|
assignments = []
|
||
|
|
agent_exclusions: Dict[str, List[str]] = {} # repo -> list of assigned agents per run
|
||
|
|
global_exclusions: List[str] = [] # agents already at capacity per run
|
||
|
|
max_per_agent_per_run = 5
|
||
|
|
|
||
|
|
for issue in issues:
|
||
|
|
role = classify_issue(issue)
|
||
|
|
priority = compute_priority(issue)
|
||
|
|
repo = issue.get("repository", {}).get("name", "")
|
||
|
|
|
||
|
|
# Avoid assigning same agent twice to same repo in one run
|
||
|
|
repo_excluded = agent_exclusions.get(repo, [])
|
||
|
|
|
||
|
|
# Also exclude agents already at assignment cap
|
||
|
|
cap_excluded = [
|
||
|
|
name for name, stats in state.get("agent_stats", {}).items()
|
||
|
|
if stats.get("total_assigned", 0) > max_per_agent_per_run
|
||
|
|
]
|
||
|
|
|
||
|
|
excluded = list(set(repo_excluded + global_exclusions + cap_excluded))
|
||
|
|
|
||
|
|
agent = find_best_agent(agents, role, wolf_scores, priority, exclude=excluded)
|
||
|
|
if not agent:
|
||
|
|
# Relax exclusions if no agent found
|
||
|
|
agent = find_best_agent(agents, role, wolf_scores, priority, exclude=[])
|
||
|
|
if not agent:
|
||
|
|
logging.warning("No suitable agent for issue #%d: %s (role=%s)",
|
||
|
|
issue.get("number"), issue.get("title", ""), role)
|
||
|
|
continue
|
||
|
|
|
||
|
|
result = dispatch_assignment(api, issue, agent, dry_run=args.dry_run)
|
||
|
|
assignments.append(result)
|
||
|
|
update_agent_stats(state, result)
|
||
|
|
|
||
|
|
# Track per-repo exclusions
|
||
|
|
if repo not in agent_exclusions:
|
||
|
|
agent_exclusions[repo] = []
|
||
|
|
agent_exclusions[repo].append(agent["name"])
|
||
|
|
|
||
|
|
if args.dry_run:
|
||
|
|
print(f" [DRY] #{issue['number']}: {issue.get('title','')[:60]} → @{agent['name']} ({role}, p={priority})")
|
||
|
|
else:
|
||
|
|
status_str = "OK" if result.get("success") else "FAIL"
|
||
|
|
print(f" [{status_str}] #{issue['number']}: {issue.get('title','')[:60]} → @{agent['name']} ({role}, p={priority})")
|
||
|
|
|
||
|
|
# Save state
|
||
|
|
state["assignments"].extend([{
|
||
|
|
"repo": a.get("repo"),
|
||
|
|
"issue_number": a.get("issue_number"),
|
||
|
|
"assignee": a.get("assignee"),
|
||
|
|
"success": a.get("success", False),
|
||
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||
|
|
} for a in assignments])
|
||
|
|
state["last_run"] = datetime.now(timezone.utc).isoformat()
|
||
|
|
save_workforce_state(state)
|
||
|
|
|
||
|
|
# Summary
|
||
|
|
ok = sum(1 for a in assignments if a.get("success"))
|
||
|
|
fail = len(assignments) - ok
|
||
|
|
logging.info("Done: %d assigned, %d succeeded, %d failed", len(assignments), ok, fail)
|
||
|
|
|
||
|
|
if not args.cron:
|
||
|
|
print(f"\n{'=' * 60}")
|
||
|
|
print(f"Summary: {len(assignments)} assignments, {ok} OK, {fail} failed")
|
||
|
|
# Show agent stats
|
||
|
|
for name, stats in state.get("agent_stats", {}).items():
|
||
|
|
if stats.get("total_assigned", 0) > 0:
|
||
|
|
print(f" @{name}: {stats['successful']}/{stats['total_assigned']} ({stats.get('success_rate', 0):.0%} success)")
|
||
|
|
print(f"{'=' * 60}")
|
||
|
|
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|