From e34a9930c5d75f9f7f3f3de48c7a6eda5aa4706f Mon Sep 17 00:00:00 2001 From: danny8632 Date: Tue, 12 May 2026 06:41:18 +0000 Subject: [PATCH] Phase 0: dev-task wrapper + LXC bootstrap recipe + arch README --- README.md | 59 ++++++++++- agents/dev-task.sh | 165 ++++++++++++++++++++++++++++++ provisioning/bootstrap-dev-lxc.sh | 59 +++++++++++ 3 files changed, 281 insertions(+), 2 deletions(-) create mode 100755 agents/dev-task.sh create mode 100755 provisioning/bootstrap-dev-lxc.sh diff --git a/README.md b/README.md index f87760d..62e0e83 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,58 @@ -# orchestrator +# agent-coding-empire orchestrator -Glue between Gitea and the dev/auditor LXCs. PM tools, webhook handler, dev-task wrapper, auditor workflow. \ No newline at end of file +Glue between Gitea and the dev/auditor LXCs that run `claude-code` headless. + +## Architecture (v0) + +``` + ┌──────────────────────────────┐ + │ Gitea (CT 101) │ + │ - Issues with labels │ + │ - PRs │ + │ - Actions runner (CT 112) │ + └────────┬─────────────────────┘ + │ webhook + ▼ + ┌──────────────────────────────────────┐ + │ orchestrator (CT 103, this repo) │ + │ - webhook handler (Phase 2) │ + │ - PM helper scripts │ + │ - SSH-triggers dev-task on dev LXCs │ + └────────┬─────────────────────────────┘ + │ SSH + ▼ + ┌──────────────────┐ + │ dev-01 (CT 120) │ one slot active at a time + │ - claude-code │ + │ - tea CLI │ + │ - dev-task.sh │ + └──────────────────┘ +``` + +## Constraints + +- **Max-plan auth shared.** Each dev LXC has a copy of `~/.claude/.credentials.json`. + They all draw from the same rate-limit pool as Danny's interactive sessions. +- **One dev agent active at a time** in v0 (no parallel) to avoid starving the pool. +- **Default model = sonnet.** Opus only when issue body explicitly says `Model: opus`. +- **Hard wallclock cap** = 30 minutes per task (configurable via `MAX_WALLCLOCK`). + +## Repo layout + +``` +agents/ + dev-task.sh # runs inside dev LXC; takes , opens PR +pm/ + (todo) # PM scripts that create structured issues +auditor/ + (todo) # Gitea Actions workflow for PR review +provisioning/ + (todo) # scripts to bootstrap a fresh dev LXC +``` + +## Phase status + +- [x] Phase 0: Gitea repos, dev-01 LXC, claude-code + tea installed, auth verified +- [ ] Phase 1: manual closed loop (PM writes issue → dev-task → PR) +- [ ] Phase 2: webhook-driven automation +- [ ] Phase 3: multi-dev (still one active at a time) diff --git a/agents/dev-task.sh b/agents/dev-task.sh new file mode 100755 index 0000000..821f919 --- /dev/null +++ b/agents/dev-task.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# dev-task.sh — invoked inside a dev LXC. Picks up a Gitea issue, runs +# claude-code headless to implement it, and opens a PR back to the repo. +# +# Usage: dev-task.sh / +# Env knobs (all optional): +# MAX_WALLCLOCK default 30m — coreutils-timeout duration string +# MODEL default sonnet — overridden by "Model: opus" line in issue body +# BASE_BRANCH default main +# GITEA_URL default https://gitea.dannyhaslund.dk +# GITEA_TOKEN_FILE default /etc/agent/gitea-token +# WORK_ROOT default /var/agent/workspaces +# LOG_ROOT default /var/agent/logs + +set -euo pipefail + +REPO="${1:-}" +ISSUE="${2:-}" +[[ -z "$REPO" || -z "$ISSUE" ]] && { echo "usage: $0 / " >&2; exit 64; } + +MAX_WALLCLOCK="${MAX_WALLCLOCK:-30m}" +MODEL_DEFAULT="${MODEL:-sonnet}" +BASE_BRANCH="${BASE_BRANCH:-main}" +GITEA_URL="${GITEA_URL:-https://gitea.dannyhaslund.dk}" +GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-/etc/agent/gitea-token}" +WORK_ROOT="${WORK_ROOT:-/var/agent/workspaces}" +LOG_ROOT="${LOG_ROOT:-/var/agent/logs}" + +[[ -r "$GITEA_TOKEN_FILE" ]] || { echo "missing $GITEA_TOKEN_FILE" >&2; exit 65; } +GITEA_TOKEN="$(cat "$GITEA_TOKEN_FILE")" + +mkdir -p "$WORK_ROOT" "$LOG_ROOT" +LOG_FILE="$LOG_ROOT/issue-${ISSUE}-$(date +%Y%m%dT%H%M%S).log" +log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" | tee -a "$LOG_FILE"; } + +# ---------- 1. Fetch issue ---------- +log "Fetching issue $REPO#$ISSUE" +issue_json="$(curl -fsS -H "Authorization: token $GITEA_TOKEN" \ + "$GITEA_URL/api/v1/repos/$REPO/issues/$ISSUE")" +title="$(jq -r .title <<<"$issue_json")" +body="$(jq -r .body <<<"$issue_json")" + +# ---------- 2. Pick model from body if specified ---------- +if echo "$body" | grep -qiE '^\s*model:\s*opus' ; then + MODEL="opus" +else + MODEL="$MODEL_DEFAULT" +fi +log "Using model: $MODEL (default=$MODEL_DEFAULT)" + +# ---------- 3. Workspace + branch ---------- +slug="$(echo "$title" | tr '[:upper:]' '[:lower:]' \ + | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//' \ + | cut -c1-50)" +[[ -z "$slug" ]] && slug="task" +BRANCH="agent/issue-${ISSUE}-${slug}" + +WORKDIR="$WORK_ROOT/${REPO//\//_}-issue-${ISSUE}" +rm -rf "$WORKDIR" +log "Cloning into $WORKDIR" +git clone --quiet \ + "https://oauth2:${GITEA_TOKEN}@${GITEA_URL#https://}/${REPO}.git" \ + "$WORKDIR" + +cd "$WORKDIR" +git config user.email "agent-dev01@dannyhaslund.dk" +git config user.name "agent dev-01" +git checkout -q "$BASE_BRANCH" +git checkout -q -b "$BRANCH" + +# ---------- 4. Build prompt ---------- +PROMPT_FILE="$(mktemp)" +cat > "$PROMPT_FILE" <>"$LOG_FILE" 2>&1 +claude_rc=$? +set -e +rm -f "$PROMPT_FILE" +log "claude exited with rc=$claude_rc" + +if [[ $claude_rc -eq 124 || $claude_rc -eq 137 ]]; then + log "TIMEOUT — claude was killed at wallclock $MAX_WALLCLOCK" +elif [[ $claude_rc -ne 0 ]]; then + log "claude exited non-zero (see $LOG_FILE)" +fi + +# ---------- 6. Inspect git state ---------- +if [[ -n "$(git status --porcelain)" ]]; then + log "Uncommitted changes found — committing as WIP" + git add -A + git commit -q -m "WIP: leftover changes from claude session" +fi + +commits_ahead="$(git rev-list --count "$BASE_BRANCH..HEAD")" +log "Commits ahead of $BASE_BRANCH: $commits_ahead" + +if [[ "$commits_ahead" -eq 0 ]]; then + log "No commits produced. Posting comment on issue and exiting." + body_json="$(jq -nc --arg b "agent dev-01: no commits produced for issue #$ISSUE. Log: \`$LOG_FILE\`. claude rc=$claude_rc" '{body:$b}')" + curl -fsS -X POST \ + -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \ + -d "$body_json" \ + "$GITEA_URL/api/v1/repos/$REPO/issues/$ISSUE/comments" >/dev/null + exit 2 +fi + +# ---------- 7. Push branch ---------- +log "Pushing branch $BRANCH" +git push --quiet -u origin "$BRANCH" + +# ---------- 8. Open PR via Gitea API ---------- +pr_body="$(printf 'Resolves #%s\n\nAutomated PR from agent dev-01.\n\n- model: `%s`\n- log: `%s`\n- claude rc: `%s`\n' "$ISSUE" "$MODEL" "$LOG_FILE" "$claude_rc")" +pr_json="$(jq -nc \ + --arg title "$title" \ + --arg body "$pr_body" \ + --arg head "$BRANCH" \ + --arg base "$BASE_BRANCH" \ + '{title:$title, body:$body, head:$head, base:$base}')" +pr_resp="$(curl -fsS -X POST \ + -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \ + -d "$pr_json" \ + "$GITEA_URL/api/v1/repos/$REPO/pulls")" +pr_number="$(jq -r .number <<<"$pr_resp")" +pr_url="$(jq -r .html_url <<<"$pr_resp")" +log "PR opened: #$pr_number → $pr_url" + +# ---------- 9. Comment on the issue ---------- +comment_body="$(jq -nc --arg b "agent dev-01: PR opened → $pr_url" '{body:$b}')" +curl -fsS -X POST \ + -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \ + -d "$comment_body" \ + "$GITEA_URL/api/v1/repos/$REPO/issues/$ISSUE/comments" >/dev/null + +log "Done." +echo "$pr_url" diff --git a/provisioning/bootstrap-dev-lxc.sh b/provisioning/bootstrap-dev-lxc.sh new file mode 100755 index 0000000..c872773 --- /dev/null +++ b/provisioning/bootstrap-dev-lxc.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# bootstrap-dev-lxc.sh — provision a fresh dev LXC. +# Run from the PVE host as root. Idempotent. +# +# Usage: +# ./bootstrap-dev-lxc.sh [gitea_token_file] +# +# Assumes: +# - CT already exists and is started +# - You have local copies of claude-code (in /tmp/claude-staging) and tea +# (in /tmp/tea-staging) and credentials (/tmp/claude-creds-staging.json) +# +# This is intentionally not a full image builder — it's the manual recipe +# that documents what dev-01 was bootstrapped with. Codify later. + +set -euo pipefail +CTID="${1:?usage: $0 [gitea_token_file]}" +TOKEN_FILE="${2:-/tmp/gitea-token-staging}" + +[[ -r /tmp/claude-staging ]] || { echo "missing /tmp/claude-staging (claude-code tree)" >&2; exit 1; } +[[ -r /tmp/tea-staging ]] || { echo "missing /tmp/tea-staging (tea binary)" >&2; exit 1; } +[[ -r /tmp/claude-creds-staging.json ]] || { echo "missing /tmp/claude-creds-staging.json" >&2; exit 1; } +[[ -r "$TOKEN_FILE" ]] || { echo "missing token file $TOKEN_FILE" >&2; exit 1; } + +# --- packages --- +pct exec "$CTID" -- bash -c ' + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ + git curl ca-certificates jq openssh-server xz-utils + mkdir -p /root/.claude /root/.local/bin /root/.local/share /root/.ssh /etc/agent /var/agent/workspaces /var/agent/logs + chmod 700 /root/.ssh /root/.claude /etc/agent +' + +# --- claude-code --- +tar -czf /tmp/claude-bundle.tgz -C /tmp claude-staging +pct push "$CTID" /tmp/claude-bundle.tgz /tmp/claude-bundle.tgz +pct push "$CTID" /tmp/claude-creds-staging.json /root/.claude/.credentials.json --perms 600 +pct exec "$CTID" -- bash -c ' + set -e + rm -rf /root/.local/share/claude + tar -xzf /tmp/claude-bundle.tgz -C /root/.local/share/ + mv /root/.local/share/claude-staging /root/.local/share/claude + CLAUDE_VERSION=$(ls /root/.local/share/claude/versions/ | sort -V | tail -1) + ln -sf /root/.local/share/claude/versions/$CLAUDE_VERSION /root/.local/bin/claude + rm -f /tmp/claude-bundle.tgz +' +rm -f /tmp/claude-bundle.tgz + +# --- tea --- +pct push "$CTID" /tmp/tea-staging /usr/local/bin/tea --perms 755 + +# --- gitea token --- +pct push "$CTID" "$TOKEN_FILE" /etc/agent/gitea-token --perms 600 + +# --- enable sshd --- +pct exec "$CTID" -- systemctl enable --now ssh + +echo "Bootstrap complete for CT $CTID. Versions:" +pct exec "$CTID" -- bash -lc 'PATH=/root/.local/bin:/usr/local/bin:$PATH; claude --version; tea --version | head -2'