feat: git identity as Janet, refs task ID in commits, pm token for bucket moves, done sweep in dispatcher
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Zoë 2026-05-30 19:08:32 -07:00
parent 35fd9c055c
commit acb9992208
2 changed files with 81 additions and 22 deletions

View file

@ -47,6 +47,8 @@ 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", "")
VIKUNJA_PROJECT_ID = os.environ.get("VIKUNJA_PROJECT_ID", "")
VIKUNJA_VIEW_ID = os.environ.get("VIKUNJA_VIEW_ID", "")
HOME = Path(os.environ.get("HOME", "/home/agent"))
CONFIG_DIR = HOME / ".config" / "opencode"
@ -145,6 +147,16 @@ def write_opencode_config(secrets: dict, role: str) -> None:
log.warning("No agent file found at %s", agent_md_src)
def configure_git_identity() -> None:
"""Set git commit name to Janet, keeping the agent's service account email."""
try:
subprocess.run(["git", "config", "--global", "user.name", "Janet"], check=True)
subprocess.run(["git", "config", "--global", "user.email", f"svc-ag-{AGENT_ROLE}@ad.ctz.fyi"], check=True)
log.info("Git identity set: Janet <svc-ag-%s@ad.ctz.fyi>", AGENT_ROLE)
except Exception as e:
log.warning("Could not set git identity: %s", e)
def build_prompt(task_id: str, task_title: str) -> str:
return f"""You are the AutoJanet agent for role: {AGENT_ROLE}
@ -154,10 +166,12 @@ Your current task (Vikunja task #{task_id}):
Instructions:
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.
3. If you wrote code, commit with a message that includes "refs #{task_id}" so the task
is linked in Forgejo. Example: "feat: add login endpoint refs #{task_id}"
4. Open a PR on Forgejo do not merge it yourself.
5. Leave a comment on the Vikunja task summarising what you did and linking any PR.
6. Do not mark the task as done the entrypoint will move it to In Review when you finish.
7. Do not ask for confirmation act autonomously within your role constraints.
"""
@ -169,22 +183,22 @@ def run_opencode(prompt: str) -> int:
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")
def move_task_to_bucket(pm_token: str, bucket_id: str) -> None:
"""Move the task to a bucket using the kanban view endpoint (requires pm token)."""
if not all([bucket_id, VIKUNJA_PROJECT_ID, VIKUNJA_VIEW_ID]):
log.warning("Missing bucket/project/view IDs, 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)},
f"{VIKUNJA_BASE_URL}/api/v1/projects/{VIKUNJA_PROJECT_ID}/views/{VIKUNJA_VIEW_ID}/buckets/{bucket_id}/tasks",
headers={"Authorization": f"Bearer {pm_token}", "Content-Type": "application/json"},
json={"task_id": int(TASK_ID)},
timeout=10,
)
resp.raise_for_status()
log.info("Moved task %sIn Review (bucket %s)", TASK_ID, IN_REVIEW_BUCKET_ID)
log.info("Moved task %sbucket %s", TASK_ID, bucket_id)
except Exception as e:
log.warning("Failed to move task to In Review: %s", e)
log.warning("Failed to move task to bucket %s: %s", bucket_id, e)
def main() -> None:
@ -195,21 +209,22 @@ def main() -> None:
secrets = fetch_role_secrets(bao_token, AGENT_ROLE)
write_opencode_config(secrets, AGENT_ROLE)
configure_git_identity()
# Fetch vikunja token for bucket moves
vikunja_token = ""
# Fetch pm token for bucket moves (admin operation)
pm_token = ""
try:
vikunja_token = get_secret(bao_token, f"autojanet/{AGENT_ROLE}/vikunja-token", "token")
log.info("Fetched vikunja-token")
pm_token = get_secret(bao_token, "autojanet/pm/vikunja-token", "token")
log.info("Fetched pm vikunja-token")
except Exception as e:
log.warning("Could not fetch vikunja-token: %s", e)
log.warning("Could not fetch pm 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 pm_token:
move_task_to_bucket(pm_token, IN_REVIEW_BUCKET_ID)
if rc != 0:
log.error("opencode exited with code %d", rc)

View file

@ -187,6 +187,42 @@ def unclaim_task(vikunja_token: str, task_id: int, todo_id: int) -> None:
log.warning("Failed to unclaim task %d: %s", task_id, e)
def sweep_done_tasks(
vikunja_token: str,
project_id: int,
view_id: int,
done_bucket_id: int,
) -> None:
"""Find tasks marked done=True that aren't in the Done bucket and move them."""
tasks = []
page = 1
while True:
batch = vikunja_get(vikunja_token, f"projects/{project_id}/tasks", page=page, per_page=50)
if not batch:
break
tasks.extend(batch)
if len(batch) < 50:
break
page += 1
moved = 0
for task in tasks:
if task.get("done"):
task_id = task["id"]
try:
vikunja_post(
vikunja_token,
f"projects/{project_id}/views/{view_id}/buckets/{done_bucket_id}/tasks",
{"task_id": task_id},
)
log.info("Swept done task %d → Done bucket", task_id)
moved += 1
except Exception as e:
log.warning("Failed to sweep task %d to Done: %s", task_id, e)
log.info("Done sweep: moved %d tasks", moved)
# ── Kubernetes ────────────────────────────────────────────────────────────────
def load_k8s_config() -> None:
@ -217,6 +253,8 @@ def spawn_agent_job(
task_id: int,
task_title: str,
in_review_bucket_id: int,
project_id: int,
view_id: int,
) -> None:
name = job_name(role, task_id)
if job_already_exists(batch_v1, name):
@ -264,6 +302,8 @@ def spawn_agent_job(
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="VIKUNJA_PROJECT_ID", value=str(project_id)),
k8s_client.V1EnvVar(name="VIKUNJA_VIEW_ID", value=str(view_id)),
k8s_client.V1EnvVar(
name="OPENBAO_ROLE_ID",
value_from=k8s_client.V1EnvVarSource(
@ -323,8 +363,9 @@ 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)
done_id = buckets.get(BUCKET_DONE)
if not all([todo_id, in_progress_id, in_review_id]):
if not all([todo_id, in_progress_id, in_review_id, done_id]):
log.error("Could not find all standard buckets. Found: %s", list(buckets.keys()))
sys.exit(1)
@ -332,6 +373,9 @@ def main() -> None:
load_k8s_config()
batch_v1 = k8s_client.BatchV1Api()
# Sweep: move any done=True tasks into the Done bucket
sweep_done_tasks(vikunja_token, project_id, view_id, done_id)
# 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))
@ -351,7 +395,7 @@ def main() -> None:
continue
try:
spawn_agent_job(batch_v1, role, task_id, title, in_review_id)
spawn_agent_job(batch_v1, role, task_id, title, in_review_id, project_id, view_id)
claimed += 1
except Exception as e:
log.error("Failed to spawn job for task %d: %s", task_id, e)