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") 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") FORGEJO_BASE_URL = os.environ.get("FORGEJO_BASE_URL", "https://git.ctz.fyi")
IN_REVIEW_BUCKET_ID = os.environ.get("IN_REVIEW_BUCKET_ID", "") 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")) HOME = Path(os.environ.get("HOME", "/home/agent"))
CONFIG_DIR = HOME / ".config" / "opencode" 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) 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: def build_prompt(task_id: str, task_title: str) -> str:
return f"""You are the AutoJanet agent for role: {AGENT_ROLE} return f"""You are the AutoJanet agent for role: {AGENT_ROLE}
@ -154,10 +166,12 @@ Your current task (Vikunja task #{task_id}):
Instructions: Instructions:
1. Read the task carefully. Fetch full task details from Vikunja if needed. 1. Read the task carefully. Fetch full task details from Vikunja if needed.
2. Complete the task using the tools available to you. 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. 3. If you wrote code, commit with a message that includes "refs #{task_id}" so the task
4. Leave a comment on the Vikunja task summarising what you did and linking any PR. is linked in Forgejo. Example: "feat: add login endpoint refs #{task_id}"
5. Do not mark the task as done the entrypoint will move it to In Review when you finish. 4. Open a PR on Forgejo do not merge it yourself.
6. Do not ask for confirmation act autonomously within your role constraints. 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 return result.returncode
def move_task_to_in_review(vikunja_token: str) -> None: def move_task_to_bucket(pm_token: str, bucket_id: str) -> None:
"""Move the task bucket to In Review after work is complete.""" """Move the task to a bucket using the kanban view endpoint (requires pm token)."""
if not IN_REVIEW_BUCKET_ID: if not all([bucket_id, VIKUNJA_PROJECT_ID, VIKUNJA_VIEW_ID]):
log.warning("IN_REVIEW_BUCKET_ID not set, skipping bucket move") log.warning("Missing bucket/project/view IDs, skipping bucket move")
return return
try: try:
resp = httpx.post( resp = httpx.post(
f"{VIKUNJA_BASE_URL}/api/v1/tasks/{TASK_ID}", f"{VIKUNJA_BASE_URL}/api/v1/projects/{VIKUNJA_PROJECT_ID}/views/{VIKUNJA_VIEW_ID}/buckets/{bucket_id}/tasks",
headers={"Authorization": f"Bearer {vikunja_token}", "Content-Type": "application/json"}, headers={"Authorization": f"Bearer {pm_token}", "Content-Type": "application/json"},
json={"bucket_id": int(IN_REVIEW_BUCKET_ID)}, json={"task_id": int(TASK_ID)},
timeout=10, timeout=10,
) )
resp.raise_for_status() 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: 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: def main() -> None:
@ -195,21 +209,22 @@ def main() -> None:
secrets = fetch_role_secrets(bao_token, AGENT_ROLE) secrets = fetch_role_secrets(bao_token, AGENT_ROLE)
write_opencode_config(secrets, AGENT_ROLE) write_opencode_config(secrets, AGENT_ROLE)
configure_git_identity()
# Fetch vikunja token for bucket moves # Fetch pm token for bucket moves (admin operation)
vikunja_token = "" pm_token = ""
try: try:
vikunja_token = get_secret(bao_token, f"autojanet/{AGENT_ROLE}/vikunja-token", "token") pm_token = get_secret(bao_token, "autojanet/pm/vikunja-token", "token")
log.info("Fetched vikunja-token") log.info("Fetched pm vikunja-token")
except Exception as e: 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) prompt = build_prompt(TASK_ID, TASK_TITLE)
rc = run_opencode(prompt) rc = run_opencode(prompt)
# Move to In Review regardless of exit code — work was attempted # Move to In Review regardless of exit code — work was attempted
if vikunja_token: if pm_token:
move_task_to_in_review(vikunja_token) move_task_to_bucket(pm_token, IN_REVIEW_BUCKET_ID)
if rc != 0: if rc != 0:
log.error("opencode exited with code %d", rc) 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) 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 ──────────────────────────────────────────────────────────────── # ── Kubernetes ────────────────────────────────────────────────────────────────
def load_k8s_config() -> None: def load_k8s_config() -> None:
@ -217,6 +253,8 @@ def spawn_agent_job(
task_id: int, task_id: int,
task_title: str, task_title: str,
in_review_bucket_id: int, in_review_bucket_id: int,
project_id: int,
view_id: int,
) -> None: ) -> None:
name = job_name(role, task_id) name = job_name(role, task_id)
if job_already_exists(batch_v1, name): 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="LITELLM_BASE_URL", value="https://llm.ctz.fyi"),
k8s_client.V1EnvVar(name="FORGEJO_BASE_URL", value="https://git.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="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( k8s_client.V1EnvVar(
name="OPENBAO_ROLE_ID", name="OPENBAO_ROLE_ID",
value_from=k8s_client.V1EnvVarSource( value_from=k8s_client.V1EnvVarSource(
@ -323,8 +363,9 @@ def main() -> None:
todo_id = buckets.get(BUCKET_TODO) todo_id = buckets.get(BUCKET_TODO)
in_progress_id = buckets.get(BUCKET_IN_PROGRESS) in_progress_id = buckets.get(BUCKET_IN_PROGRESS)
in_review_id = buckets.get(BUCKET_IN_REVIEW) 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())) log.error("Could not find all standard buckets. Found: %s", list(buckets.keys()))
sys.exit(1) sys.exit(1)
@ -332,6 +373,9 @@ def main() -> None:
load_k8s_config() load_k8s_config()
batch_v1 = k8s_client.BatchV1Api() 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 # Scan Todo bucket for claimable tasks
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))
@ -351,7 +395,7 @@ def main() -> None:
continue continue
try: 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 claimed += 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)