fix: bucket name is 'review' not 'in review'; add concurrency limits; intake service scaffold
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
c6ad5d008f
commit
be03d042ad
3 changed files with 177 additions and 7 deletions
|
|
@ -42,11 +42,15 @@ VIKUNJA_PROJECT_NAME = os.environ.get("VIKUNJA_PROJECT_NAME", "Autonomous Agent
|
||||||
K8S_NAMESPACE = os.environ.get("K8S_NAMESPACE", "autojanet")
|
K8S_NAMESPACE = os.environ.get("K8S_NAMESPACE", "autojanet")
|
||||||
AGENT_IMAGE = os.environ.get("AGENT_IMAGE", "registry.ctz.fyi/library/autojanet-agent:latest")
|
AGENT_IMAGE = os.environ.get("AGENT_IMAGE", "registry.ctz.fyi/library/autojanet-agent:latest")
|
||||||
|
|
||||||
|
# Max concurrent jobs per role (and global across all roles)
|
||||||
|
MAX_JOBS_PER_ROLE = int(os.environ.get("MAX_JOBS_PER_ROLE", "2"))
|
||||||
|
MAX_JOBS_TOTAL = int(os.environ.get("MAX_JOBS_TOTAL", "10"))
|
||||||
|
|
||||||
# Standard bucket names (case-insensitive match)
|
# Standard bucket names (case-insensitive match)
|
||||||
BUCKET_BACKLOG = "backlog"
|
BUCKET_BACKLOG = "backlog"
|
||||||
BUCKET_TODO = "todo"
|
BUCKET_TODO = "todo"
|
||||||
BUCKET_IN_PROGRESS = "in progress"
|
BUCKET_IN_PROGRESS = "in progress"
|
||||||
BUCKET_IN_REVIEW = "in review"
|
BUCKET_IN_REVIEW = "review"
|
||||||
BUCKET_DONE = "done"
|
BUCKET_DONE = "done"
|
||||||
|
|
||||||
VALID_ROLES = {
|
VALID_ROLES = {
|
||||||
|
|
@ -232,6 +236,26 @@ def load_k8s_config() -> None:
|
||||||
k8s_config.load_kube_config()
|
k8s_config.load_kube_config()
|
||||||
|
|
||||||
|
|
||||||
|
def count_active_jobs(batch_v1: k8s_client.BatchV1Api) -> tuple[int, dict[str, int]]:
|
||||||
|
"""Return (total_active, {role: count}) for all non-completed agent jobs."""
|
||||||
|
jobs = batch_v1.list_namespaced_job(
|
||||||
|
namespace=K8S_NAMESPACE,
|
||||||
|
label_selector="autojanet/type=agent",
|
||||||
|
_request_timeout=15,
|
||||||
|
)
|
||||||
|
total = 0
|
||||||
|
by_role: dict[str, int] = {}
|
||||||
|
for job in jobs.items:
|
||||||
|
# Skip completed/failed jobs
|
||||||
|
status = job.status
|
||||||
|
if (status.succeeded or 0) > 0 or (status.failed or 0) >= (job.spec.backoff_limit or 1) + 1:
|
||||||
|
continue
|
||||||
|
total += 1
|
||||||
|
role = job.metadata.labels.get("autojanet/role", "unknown")
|
||||||
|
by_role[role] = by_role.get(role, 0) + 1
|
||||||
|
return total, by_role
|
||||||
|
|
||||||
|
|
||||||
def job_name(role: str, task_id: int) -> str:
|
def job_name(role: str, task_id: int) -> str:
|
||||||
safe_role = role.replace("-", "")[:12]
|
safe_role = role.replace("-", "")[:12]
|
||||||
return f"agent-{safe_role}-{task_id}"
|
return f"agent-{safe_role}-{task_id}"
|
||||||
|
|
@ -380,6 +404,10 @@ def main() -> None:
|
||||||
tasks = list_todo_tasks(vikunja_token, project_id, todo_id)
|
tasks = list_todo_tasks(vikunja_token, project_id, todo_id)
|
||||||
log.info("Found %d candidate tasks in Todo bucket", len(tasks))
|
log.info("Found %d candidate tasks in Todo bucket", len(tasks))
|
||||||
|
|
||||||
|
# Check current job counts before spawning anything
|
||||||
|
total_active, active_by_role = count_active_jobs(batch_v1)
|
||||||
|
log.info("Active jobs: total=%d limits(per_role=%d, total=%d)", total_active, MAX_JOBS_PER_ROLE, MAX_JOBS_TOTAL)
|
||||||
|
|
||||||
claimed = 0
|
claimed = 0
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
task_id = task["id"]
|
task_id = task["id"]
|
||||||
|
|
@ -390,6 +418,14 @@ def main() -> None:
|
||||||
log.debug("Task %d has no valid agent label, skipping", task_id)
|
log.debug("Task %d has no valid agent label, skipping", task_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Enforce concurrency limits
|
||||||
|
if total_active >= MAX_JOBS_TOTAL:
|
||||||
|
log.info("Global job limit reached (%d/%d), stopping", total_active, MAX_JOBS_TOTAL)
|
||||||
|
break
|
||||||
|
if active_by_role.get(role, 0) >= MAX_JOBS_PER_ROLE:
|
||||||
|
log.info("Role %s at limit (%d/%d), skipping task %d", role, active_by_role.get(role, 0), MAX_JOBS_PER_ROLE, task_id)
|
||||||
|
continue
|
||||||
|
|
||||||
log.info("Claiming task %d (%s) for role=%s", task_id, title[:60], role)
|
log.info("Claiming task %d (%s) for role=%s", task_id, title[:60], role)
|
||||||
if not claim_task(vikunja_token, task_id, in_progress_id):
|
if not claim_task(vikunja_token, task_id, in_progress_id):
|
||||||
continue
|
continue
|
||||||
|
|
@ -397,6 +433,8 @@ def main() -> None:
|
||||||
try:
|
try:
|
||||||
spawn_agent_job(batch_v1, role, task_id, title, in_review_id, project_id, view_id)
|
spawn_agent_job(batch_v1, role, task_id, title, in_review_id, project_id, view_id)
|
||||||
claimed += 1
|
claimed += 1
|
||||||
|
total_active += 1
|
||||||
|
active_by_role[role] = active_by_role.get(role, 0) + 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Failed to spawn job for task %d: %s", task_id, e)
|
log.error("Failed to spawn job for task %d: %s", task_id, e)
|
||||||
unclaim_task(vikunja_token, task_id, todo_id)
|
unclaim_task(vikunja_token, task_id, todo_id)
|
||||||
|
|
|
||||||
134
intake/main.py
Normal file
134
intake/main.py
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
AutoJanet Intake Service
|
||||||
|
|
||||||
|
Accepts task submissions and creates Vikunja tasks with the appropriate
|
||||||
|
agent label so the dispatcher picks them up automatically.
|
||||||
|
|
||||||
|
POST /task
|
||||||
|
{
|
||||||
|
"title": "Add dark mode to the dashboard",
|
||||||
|
"description": "...", # optional
|
||||||
|
"role": "coder" # optional, defaults to "pm" (PM decomposes)
|
||||||
|
}
|
||||||
|
|
||||||
|
GET /health
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", stream=sys.stdout)
|
||||||
|
log = logging.getLogger("intake")
|
||||||
|
|
||||||
|
VIKUNJA_BASE_URL = os.environ["VIKUNJA_BASE_URL"]
|
||||||
|
VIKUNJA_PM_TOKEN = os.environ["VIKUNJA_PM_TOKEN"]
|
||||||
|
VIKUNJA_PROJECT_ID = int(os.environ.get("VIKUNJA_PROJECT_ID", "78"))
|
||||||
|
VIKUNJA_TODO_BUCKET_ID = int(os.environ.get("VIKUNJA_TODO_BUCKET_ID", "116"))
|
||||||
|
VIKUNJA_VIEW_ID = int(os.environ.get("VIKUNJA_VIEW_ID", "114"))
|
||||||
|
|
||||||
|
# Label IDs for agent roles (from Vikunja)
|
||||||
|
ROLE_LABEL_IDS = {
|
||||||
|
"pm": 1,
|
||||||
|
"coder": 3,
|
||||||
|
"code-reviewer": 4,
|
||||||
|
"test-engineer": 5,
|
||||||
|
"devsecops": 6,
|
||||||
|
"secops": 7,
|
||||||
|
"sre": 8,
|
||||||
|
"kubernetes-pilot": 9,
|
||||||
|
"linux-admin": 10,
|
||||||
|
"systems-engineer": 11,
|
||||||
|
"networking": 12,
|
||||||
|
"dba": 13,
|
||||||
|
"prometheus-expert": 14,
|
||||||
|
"tofu-engineer": 15,
|
||||||
|
"release-manager": 16,
|
||||||
|
"doc-updater": 17,
|
||||||
|
"doc-writer": 18,
|
||||||
|
"technical-writer": 19,
|
||||||
|
"cost-optimizer": 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
app = FastAPI(title="AutoJanet Intake", version="1.0.0")
|
||||||
|
|
||||||
|
VIKUNJA_HEADERS = {
|
||||||
|
"Authorization": f"Bearer {VIKUNJA_PM_TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRequest(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: str = ""
|
||||||
|
role: str = "pm"
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResponse(BaseModel):
|
||||||
|
task_id: int
|
||||||
|
title: str
|
||||||
|
role: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/task", response_model=TaskResponse)
|
||||||
|
def submit_task(req: TaskRequest):
|
||||||
|
role = req.role.lower()
|
||||||
|
if role not in ROLE_LABEL_IDS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown role '{role}'. Valid: {sorted(ROLE_LABEL_IDS)}")
|
||||||
|
|
||||||
|
label_id = ROLE_LABEL_IDS[role]
|
||||||
|
|
||||||
|
# Create the task
|
||||||
|
with httpx.Client(timeout=15) as client:
|
||||||
|
resp = client.put(
|
||||||
|
f"{VIKUNJA_BASE_URL}/api/v1/projects/{VIKUNJA_PROJECT_ID}/tasks",
|
||||||
|
headers=VIKUNJA_HEADERS,
|
||||||
|
json={
|
||||||
|
"title": req.title,
|
||||||
|
"description": req.description,
|
||||||
|
"labels": [{"id": label_id}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
log.error("Vikunja task creation failed: %s %s", resp.status_code, resp.text)
|
||||||
|
raise HTTPException(status_code=502, detail="Failed to create Vikunja task")
|
||||||
|
|
||||||
|
task = resp.json()
|
||||||
|
task_id = task["id"]
|
||||||
|
log.info("Created task %d: %s (role=%s)", task_id, req.title, role)
|
||||||
|
|
||||||
|
# Move to Todo bucket
|
||||||
|
bucket_resp = client.post(
|
||||||
|
f"{VIKUNJA_BASE_URL}/api/v1/projects/{VIKUNJA_PROJECT_ID}/views/{VIKUNJA_VIEW_ID}/buckets/{VIKUNJA_TODO_BUCKET_ID}/tasks",
|
||||||
|
headers=VIKUNJA_HEADERS,
|
||||||
|
json={"task_id": task_id},
|
||||||
|
)
|
||||||
|
if bucket_resp.status_code not in (200, 201):
|
||||||
|
log.warning("Failed to move task %d to Todo bucket: %s", task_id, bucket_resp.text)
|
||||||
|
|
||||||
|
# Set the label explicitly (belt and suspenders)
|
||||||
|
label_resp = client.put(
|
||||||
|
f"{VIKUNJA_BASE_URL}/api/v1/tasks/{task_id}/labels",
|
||||||
|
headers=VIKUNJA_HEADERS,
|
||||||
|
json={"label_id": label_id},
|
||||||
|
)
|
||||||
|
if label_resp.status_code not in (200, 201):
|
||||||
|
log.warning("Failed to set label on task %d: %s", task_id, label_resp.text)
|
||||||
|
|
||||||
|
return TaskResponse(
|
||||||
|
task_id=task_id,
|
||||||
|
title=req.title,
|
||||||
|
role=role,
|
||||||
|
url=f"https://tasks.ctz.fyi/projects/{VIKUNJA_PROJECT_ID}/tasks/{task_id}",
|
||||||
|
)
|
||||||
|
|
@ -42,16 +42,14 @@ spec:
|
||||||
key: secret_id
|
key: secret_id
|
||||||
- name: VIKUNJA_BASE_URL
|
- name: VIKUNJA_BASE_URL
|
||||||
value: "http://vikunja.vikunja.svc.cluster.local:3456"
|
value: "http://vikunja.vikunja.svc.cluster.local:3456"
|
||||||
- name: VIKUNJA_PROJECT_ID
|
|
||||||
value: "78"
|
|
||||||
- name: VIKUNJA_TODO_BUCKET_ID
|
|
||||||
value: "116"
|
|
||||||
- name: VIKUNJA_IN_PROGRESS_BUCKET_ID
|
|
||||||
value: "117"
|
|
||||||
- name: K8S_NAMESPACE
|
- name: K8S_NAMESPACE
|
||||||
value: "autojanet"
|
value: "autojanet"
|
||||||
- name: AGENT_IMAGE
|
- name: AGENT_IMAGE
|
||||||
value: "registry.ctz.fyi/library/autojanet-agent:latest"
|
value: "registry.ctz.fyi/library/autojanet-agent:latest"
|
||||||
|
- name: MAX_JOBS_PER_ROLE
|
||||||
|
value: "2"
|
||||||
|
- name: MAX_JOBS_TOTAL
|
||||||
|
value: "10"
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: "100m"
|
cpu: "100m"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue