feat: implement bucket transitions - dispatcher claims Todo→InProgress, agent moves to InReview on completion
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Zoë 2026-05-30 18:53:41 -07:00
parent 7bf51d5489
commit 35fd9c055c
2 changed files with 91 additions and 44 deletions

View file

@ -46,6 +46,7 @@ TASK_TITLE = os.environ.get("TASK_TITLE", f"Task {TASK_ID}")
LITELLM_BASE_URL = os.environ.get("LITELLM_BASE_URL", "https://llm.ctz.fyi")
VIKUNJA_BASE_URL = os.environ.get("VIKUNJA_BASE_URL", "https://tasks.ctz.fyi")
FORGEJO_BASE_URL = os.environ.get("FORGEJO_BASE_URL", "https://git.ctz.fyi")
IN_REVIEW_BUCKET_ID = os.environ.get("IN_REVIEW_BUCKET_ID", "")
HOME = Path(os.environ.get("HOME", "/home/agent"))
CONFIG_DIR = HOME / ".config" / "opencode"
@ -151,12 +152,12 @@ Your current task (Vikunja task #{task_id}):
{task_title}
Instructions:
1. Read the task carefully.
2. Fetch full task details from Vikunja if needed.
3. Complete the task using the tools available to you.
4. Move the task to Done in Vikunja when complete.
5. Open a PR if code was written.
6. Do not ask for confirmation act autonomously within your constraints.
1. Read the task carefully. Fetch full task details from Vikunja if needed.
2. Complete the task using the tools available to you.
3. If you wrote code, open a PR on Forgejo do not merge it yourself.
4. Leave a comment on the Vikunja task summarising what you did and linking any PR.
5. Do not mark the task as done the entrypoint will move it to In Review when you finish.
6. Do not ask for confirmation act autonomously within your role constraints.
"""
@ -164,11 +165,28 @@ def run_opencode(prompt: str) -> int:
"""Run opencode non-interactively with the given prompt."""
cmd = ["opencode", "run", prompt]
log.info("Running: %s", " ".join(cmd))
result = subprocess.run(cmd, check=False)
return result.returncode
def move_task_to_in_review(vikunja_token: str) -> None:
"""Move the task bucket to In Review after work is complete."""
if not IN_REVIEW_BUCKET_ID:
log.warning("IN_REVIEW_BUCKET_ID not set, skipping bucket move")
return
try:
resp = httpx.post(
f"{VIKUNJA_BASE_URL}/api/v1/tasks/{TASK_ID}",
headers={"Authorization": f"Bearer {vikunja_token}", "Content-Type": "application/json"},
json={"bucket_id": int(IN_REVIEW_BUCKET_ID)},
timeout=10,
)
resp.raise_for_status()
log.info("Moved task %s → In Review (bucket %s)", TASK_ID, IN_REVIEW_BUCKET_ID)
except Exception as e:
log.warning("Failed to move task to In Review: %s", e)
def main() -> None:
log.info("Agent entrypoint: role=%s task=%s", AGENT_ROLE, TASK_ID)
@ -178,9 +196,21 @@ def main() -> None:
secrets = fetch_role_secrets(bao_token, AGENT_ROLE)
write_opencode_config(secrets, AGENT_ROLE)
# Fetch vikunja token for bucket moves
vikunja_token = ""
try:
vikunja_token = get_secret(bao_token, f"autojanet/{AGENT_ROLE}/vikunja-token", "token")
log.info("Fetched vikunja-token")
except Exception as e:
log.warning("Could not fetch vikunja-token: %s", e)
prompt = build_prompt(TASK_ID, TASK_TITLE)
rc = run_opencode(prompt)
# Move to In Review regardless of exit code — work was attempted
if vikunja_token:
move_task_to_in_review(vikunja_token)
if rc != 0:
log.error("opencode exited with code %d", rc)
sys.exit(rc)

View file

@ -136,8 +136,19 @@ def discover_buckets(vikunja_token: str, project_id: int, view_id: int) -> dict[
return mapping
def list_todo_tasks(vikunja_token: str, project_id: int) -> list[dict]:
"""Return all undone tasks with agent labels from the project."""
def vikunja_post(vikunja_token: str, path: str, body: dict) -> dict:
resp = httpx.post(
f"{VIKUNJA_BASE_URL}/api/v1/{path}",
headers={"Authorization": f"Bearer {vikunja_token}", "Content-Type": "application/json"},
json=body,
timeout=15,
)
resp.raise_for_status()
return resp.json()
def list_todo_tasks(vikunja_token: str, project_id: int, todo_id: int) -> list[dict]:
"""Return all undone tasks in the Todo bucket with agent labels."""
tasks = []
page = 1
while True:
@ -148,30 +159,32 @@ def list_todo_tasks(vikunja_token: str, project_id: int) -> list[dict]:
if len(batch) < 50:
break
page += 1
return [t for t in tasks if not t.get("done") and t.get("labels")]
return [
t for t in tasks
if not t.get("done")
and t.get("labels")
and t.get("bucket_id") == todo_id
]
def extract_agent_role(task: dict) -> str | None:
labels = task.get("labels") or []
roles_found = []
for label in labels:
title = label.get("title", "")
m = re.match(r"^agent:(.+)$", title)
if m:
role = m.group(1)
if role in VALID_ROLES:
roles_found.append(role)
return roles_found[0] if len(roles_found) == 1 else None
def claim_task(task_id: int) -> bool:
"""Placeholder — bucket moving deferred. Always returns True."""
def claim_task(vikunja_token: str, task_id: int, in_progress_id: int) -> bool:
"""Move task from Todo → In Progress."""
try:
vikunja_post(vikunja_token, f"tasks/{task_id}", {"bucket_id": in_progress_id})
log.info("Moved task %d → In Progress (bucket %d)", task_id, in_progress_id)
return True
except Exception as e:
log.error("Failed to claim task %d: %s", task_id, e)
return False
def unclaim_task(task_id: int) -> None:
"""Placeholder — bucket moving deferred."""
pass
def unclaim_task(vikunja_token: str, task_id: int, todo_id: int) -> None:
"""Move task back to Todo on job spawn failure."""
try:
vikunja_post(vikunja_token, f"tasks/{task_id}", {"bucket_id": todo_id})
log.info("Unclaimed task %d → Todo", task_id)
except Exception as e:
log.warning("Failed to unclaim task %d: %s", task_id, e)
# ── Kubernetes ────────────────────────────────────────────────────────────────
@ -203,6 +216,7 @@ def spawn_agent_job(
role: str,
task_id: int,
task_title: str,
in_review_bucket_id: int,
) -> None:
name = job_name(role, task_id)
if job_already_exists(batch_v1, name):
@ -246,9 +260,10 @@ def spawn_agent_job(
k8s_client.V1EnvVar(name="TASK_ID", value=str(task_id)),
k8s_client.V1EnvVar(name="TASK_TITLE", value=task_title),
k8s_client.V1EnvVar(name="OPENBAO_ADDR", value=OPENBAO_ADDR),
k8s_client.V1EnvVar(name="VIKUNJA_BASE_URL",value=VIKUNJA_BASE_URL),
k8s_client.V1EnvVar(name="LITELLM_BASE_URL",value="https://llm.ctz.fyi"),
k8s_client.V1EnvVar(name="FORGEJO_BASE_URL",value="https://git.ctz.fyi"),
k8s_client.V1EnvVar(name="VIKUNJA_BASE_URL", value=VIKUNJA_BASE_URL),
k8s_client.V1EnvVar(name="LITELLM_BASE_URL", value="https://llm.ctz.fyi"),
k8s_client.V1EnvVar(name="FORGEJO_BASE_URL", value="https://git.ctz.fyi"),
k8s_client.V1EnvVar(name="IN_REVIEW_BUCKET_ID", value=str(in_review_bucket_id)),
k8s_client.V1EnvVar(
name="OPENBAO_ROLE_ID",
value_from=k8s_client.V1EnvVarSource(
@ -307,17 +322,19 @@ def main() -> None:
todo_id = buckets.get(BUCKET_TODO)
in_progress_id = buckets.get(BUCKET_IN_PROGRESS)
in_review_id = buckets.get(BUCKET_IN_REVIEW)
if not todo_id or not in_progress_id:
log.warning("Could not find all standard buckets. Found: %s", list(buckets.keys()))
if not all([todo_id, in_progress_id, in_review_id]):
log.error("Could not find all standard buckets. Found: %s", list(buckets.keys()))
sys.exit(1)
# k8s
load_k8s_config()
batch_v1 = k8s_client.BatchV1Api()
# Scan + claim tasks
tasks = list_todo_tasks(vikunja_token, project_id)
log.info("Found %d candidate tasks", len(tasks))
# Scan Todo bucket for claimable tasks
tasks = list_todo_tasks(vikunja_token, project_id, todo_id)
log.info("Found %d candidate tasks in Todo bucket", len(tasks))
claimed = 0
for task in tasks:
@ -330,15 +347,15 @@ def main() -> None:
continue
log.info("Claiming task %d (%s) for role=%s", task_id, title[:60], role)
if not claim_task(task_id):
if not claim_task(vikunja_token, task_id, in_progress_id):
continue
try:
spawn_agent_job(batch_v1, role, task_id, title)
spawn_agent_job(batch_v1, role, task_id, title, in_review_id)
claimed += 1
except Exception as e:
log.error("Failed to spawn job for task %d: %s", task_id, e)
unclaim_task(task_id)
unclaim_task(vikunja_token, task_id, todo_id)
log.info("Dispatcher done. Claimed %d tasks.", claimed)