Files
orchestrator/pm/pm-task.sh

191 lines
7.4 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# pm-task.sh <owner>/<repo> <parent-issue-id>
#
# Webhook-triggered: an issue acquired the label "pm:plan". Reads the parent
# issue body as a free-form goal, asks claude to decompose into 15 structured
# agent-issues, creates each as a child issue with label "agent:assign", and
# comments on the parent linking to the children.
#
# Env knobs:
# MODEL default sonnet
# GITEA_URL default https://gitea.dannyhaslund.dk
# GITEA_TOKEN_FILE default /etc/agent/gitea-token
# PM_PLAN_LABEL_ID default 16 (the pm:plan label id on the target repo)
# PM_PLANNED_LABEL_ID default 17 (the pm:planned label id)
# AGENT_LABEL_ID default 15 (the agent:assign label id)
set -euo pipefail
REPO="${1:-}"
PARENT="${2:-}"
[[ -z "$REPO" || -z "$PARENT" ]] && { echo "usage: $0 <owner>/<repo> <parent-issue-id>" >&2; exit 64; }
MODEL="${MODEL:-sonnet}"
GITEA_URL="${GITEA_URL:-https://gitea.dannyhaslund.dk}"
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-/etc/agent/gitea-token}"
PM_PLAN_LABEL_ID="${PM_PLAN_LABEL_ID:-16}"
PM_PLANNED_LABEL_ID="${PM_PLANNED_LABEL_ID:-17}"
AGENT_LABEL_ID="${AGENT_LABEL_ID:-15}"
[[ -r "$GITEA_TOKEN_FILE" ]] || { echo "missing $GITEA_TOKEN_FILE" >&2; exit 65; }
GITEA_TOKEN="$(cat "$GITEA_TOKEN_FILE")"
# Per-issue flock so duplicate webhook deliveries (Gitea fires both 'opened'
# and 'label_updated' for an issue opened with a label) don't double-plan.
LOCK_DIR="${LOCK_DIR:-/var/lib/agent-pm}"
mkdir -p "$LOCK_DIR"
LOCK_FILE="$LOCK_DIR/${REPO//\//_}-${PARENT}.lock"
exec 201>"$LOCK_FILE"
if ! flock -n 201; then
echo "[pm-task] another pm-task already running for $REPO#$PARENT; exiting" >&2
exit 0
fi
LOG_FILE="/var/log/agent-webhook/pm-task-${REPO//\//_}-${PARENT}-$(date -u +%Y%m%dT%H%M%S).log"
mkdir -p "$(dirname "$LOG_FILE")"
log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" | tee -a "$LOG_FILE"; }
api() { curl -fsS -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" "$@"; }
# ---------- 1. Fetch parent issue + idempotency check ----------
log "Fetching parent $REPO#$PARENT"
parent_json="$(api "$GITEA_URL/api/v1/repos/$REPO/issues/$PARENT")"
parent_title="$(jq -r .title <<<"$parent_json")"
goal_body="$(jq -r .body <<<"$parent_json")"
parent_labels="$(jq -r '.labels[].name' <<<"$parent_json" | tr '\n' ' ')"
if echo "$parent_labels" | grep -qw "pm:planned"; then
log "Parent already has pm:planned label — refusing to re-plan. Remove pm:planned first if you want to re-plan."
exit 0
fi
# ---------- 2. Probe repo state ----------
# Cheap context: list the top-level file names on the default branch so the
# PM doesn't propose duplicating things that already exist.
default_branch="$(api "$GITEA_URL/api/v1/repos/$REPO" | jq -r .default_branch)"
contents_json="$(api "$GITEA_URL/api/v1/repos/$REPO/contents?ref=$default_branch")"
file_list="$(jq -r '.[].name' <<<"$contents_json" 2>/dev/null | head -25 || echo "(empty repo)")"
# Open PRs as context (may be relevant to whether work is in flight)
open_prs="$(api "$GITEA_URL/api/v1/repos/$REPO/pulls?state=open&limit=10" | \
jq -r '.[] | " #\(.number) — \(.title) (branch \(.head.ref))"' || echo " (none)")"
REPO_STATE="default branch: $default_branch
top-level files on $default_branch:
$(printf '%s\n' "$file_list" | sed 's/^/ - /')
open PRs:
$open_prs"
# ---------- 3. Decompose via claude ----------
SCHEMA='{
"type": "object",
"required": ["issues"],
"properties": {
"issues": {
"type": "array",
"minItems": 1,
"maxItems": 5,
"items": {
"type": "object",
"required": ["title", "goal", "done_criteria", "model"],
"properties": {
"title": { "type": "string", "maxLength": 80 },
"goal": { "type": "string", "minLength": 20 },
"done_criteria": { "type": "array", "minItems": 1, "items": { "type": "string" } },
"hints": { "type": "string" },
"model": { "type": "string", "enum": ["sonnet", "opus"] }
}
}
}
}
}'
PROMPT="You are the project manager for an autonomous coding empire. The human just opened parent issue #$PARENT in repo '$REPO' with the following goal. Decompose it into 1 to 5 structured agent-issues that, implemented in order, achieve the goal.
# Parent issue title
$parent_title
# Parent issue body (the goal)
$goal_body
# Current repo state
$REPO_STATE
# Constraints on the issues you produce
- Each issue must fit in <30 minutes of autonomous agent time on Sonnet.
- Done criteria must be specific and mechanically verifiable (file exists,
endpoint returns X, HTTP code, test passes). No taste calls.
- Order issues by dependency (foundation first).
- Use 'opus' only for tasks requiring design tradeoffs; routine
scaffolding/CRUD/refactors are 'sonnet'.
- Prefer fewer larger issues over many trivial ones, as long as each fits in
30 min.
- Avoid issues that require human judgment (\"make it look good\").
- Do NOT call any tools — produce JSON from the prompt text alone.
Return only the JSON matching the schema."
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
log "Decomposing via claude (model=$MODEL)"
claude -p "$PROMPT" \
--model "$MODEL" \
--output-format json \
--json-schema "$SCHEMA" \
--allowedTools "" \
< /dev/null > "$TMP" 2>>"$LOG_FILE"
issues_json="$(jq -c '.structured_output.issues // empty' "$TMP")"
if [[ -z "$issues_json" || "$issues_json" == "null" ]]; then
log "ERROR: claude did not return structured_output.issues"
api -X POST -d "$(jq -nc --arg b "agent PM: failed to decompose. See log \`$LOG_FILE\` and remove + re-add the pm:plan label to retry." '{body:$b}')" \
"$GITEA_URL/api/v1/repos/$REPO/issues/$PARENT/comments" >/dev/null
exit 1
fi
count="$(jq 'length' <<<"$issues_json")"
log "claude proposed $count child issue(s)"
# ---------- 4. Create child issues with agent:assign label ----------
created=()
for i in $(seq 0 $((count - 1))); do
issue="$(jq -c ".[$i]" <<<"$issues_json")"
title="$(jq -r .title <<<"$issue")"
goal_t="$(jq -r .goal <<<"$issue")"
done_b="$(jq -r '.done_criteria | map("- [ ] " + .) | join("\n")' <<<"$issue")"
hints="$(jq -r '.hints // ""' <<<"$issue")"
model_t="$(jq -r '.model // "sonnet"' <<<"$issue")"
body="$(printf 'Parent: #%s\n\n## Goal\n\n%s\n\n## Done criteria\n\n%s\n\n## Hints\n\n%s\n\n## Model\n\n%s\n' \
"$PARENT" "$goal_t" "$done_b" "${hints:-(none)}" "$model_t")"
resp="$(api -X POST -d "$(jq -nc \
--arg t "$title" --arg b "$body" --argjson lid "$AGENT_LABEL_ID" \
'{title:$t, body:$b, labels:[$lid]}')" \
"$GITEA_URL/api/v1/repos/$REPO/issues")"
num="$(jq -r .number <<<"$resp")"
created+=("$num")
log " created #$num$title"
done
# ---------- 5. Update parent: remove pm:plan, add pm:planned, comment ----------
api -X DELETE "$GITEA_URL/api/v1/repos/$REPO/issues/$PARENT/labels/$PM_PLAN_LABEL_ID" >/dev/null 2>&1 || true
api -X POST -d "$(jq -nc --argjson lid "$PM_PLANNED_LABEL_ID" '{labels:[$lid]}')" \
"$GITEA_URL/api/v1/repos/$REPO/issues/$PARENT/labels" >/dev/null
comment_lines=""
for n in "${created[@]}"; do
comment_lines+="- #$n\n"
done
parent_comment="$(printf 'agent PM: decomposed into %d child issue(s):\n\n%b\nEach is labeled `agent:assign` and will be picked up by a dev agent (one at a time).' "${#created[@]}" "$comment_lines")"
api -X POST -d "$(jq -nc --arg b "$parent_comment" '{body:$b}')" \
"$GITEA_URL/api/v1/repos/$REPO/issues/$PARENT/comments" >/dev/null
log "Done. created=${created[*]}"