disinto/gardener/gardener-run.sh
Claude c7168b58e5
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
fix: bug: architect-run.sh exits silently — runs git from baked /home/agent/disinto which is not a git repo (#569)
2026-04-10 12:36:51 +00:00

383 lines
17 KiB
Bash
Executable file

#!/usr/bin/env bash
# =============================================================================
# gardener-run.sh — Polling-loop wrapper: gardener execution via SDK + formula
#
# Synchronous bash loop using claude -p (one-shot invocation).
# No tmux sessions, no phase files — the bash script IS the state machine.
#
# Flow:
# 1. Guards: run lock, memory check
# 2. Load formula (formulas/run-gardener.toml)
# 3. Build context: AGENTS.md, scratch file, prompt footer
# 4. agent_run(worktree, prompt) → Claude does maintenance, pushes if needed
# 5. If pushed: pr_walk_to_merge() from lib/pr-lifecycle.sh
# 6. Post-merge: execute pending actions manifest (gardener/pending-actions.json)
# 7. Mirror push
#
# Usage:
# gardener-run.sh [projects/disinto.toml] # project config (default: disinto)
#
# Called by: entrypoint.sh polling loop (every 6 hours)
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
FACTORY_ROOT="$(dirname "$SCRIPT_DIR")"
# Accept project config from argument; default to disinto
export PROJECT_TOML="${1:-$FACTORY_ROOT/projects/disinto.toml}"
# shellcheck source=../lib/env.sh
source "$FACTORY_ROOT/lib/env.sh"
# Use gardener-bot's own Forgejo identity (#747)
FORGE_TOKEN="${FORGE_GARDENER_TOKEN:-${FORGE_TOKEN}}"
# shellcheck source=../lib/formula-session.sh
source "$FACTORY_ROOT/lib/formula-session.sh"
# shellcheck source=../lib/worktree.sh
source "$FACTORY_ROOT/lib/worktree.sh"
# shellcheck source=../lib/ci-helpers.sh
source "$FACTORY_ROOT/lib/ci-helpers.sh"
# shellcheck source=../lib/mirrors.sh
source "$FACTORY_ROOT/lib/mirrors.sh"
# shellcheck source=../lib/guard.sh
source "$FACTORY_ROOT/lib/guard.sh"
# shellcheck source=../lib/agent-sdk.sh
source "$FACTORY_ROOT/lib/agent-sdk.sh"
# shellcheck source=../lib/pr-lifecycle.sh
source "$FACTORY_ROOT/lib/pr-lifecycle.sh"
LOG_FILE="${DISINTO_LOG_DIR}/gardener/gardener.log"
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
LOGFILE="$LOG_FILE"
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
SID_FILE="/tmp/gardener-session-${PROJECT_NAME}.sid"
SCRATCH_FILE="/tmp/gardener-${PROJECT_NAME}-scratch.md"
RESULT_FILE="/tmp/gardener-result-${PROJECT_NAME}.txt"
GARDENER_PR_FILE="/tmp/gardener-pr-${PROJECT_NAME}.txt"
WORKTREE="/tmp/${PROJECT_NAME}-gardener-run"
LAST_SHA_FILE="${DISINTO_DATA_DIR}/gardener-last-sha.txt"
# Override LOG_AGENT for consistent agent identification
# shellcheck disable=SC2034 # consumed by agent-sdk.sh and env.sh log()
LOG_AGENT="gardener"
# ── Guards ────────────────────────────────────────────────────────────────
check_active gardener
acquire_run_lock "/tmp/gardener-run.lock"
memory_guard 2000
log "--- Gardener run start ---"
# ── Resolve forge remote for git operations ─────────────────────────────
# Run git operations from the project checkout, not the baked code dir
cd "$PROJECT_REPO_ROOT"
resolve_forge_remote
# ── Precondition checks: skip if nothing to do ────────────────────────────
# Check for new commits since last run
CURRENT_SHA=$(git -C "$FACTORY_ROOT" rev-parse HEAD 2>/dev/null || echo "")
LAST_SHA=$(cat "$LAST_SHA_FILE" 2>/dev/null || echo "")
# Check for open issues needing grooming
backlog_count=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/issues?labels=backlog&state=open&limit=1" 2>/dev/null | jq length) || backlog_count=0
tech_debt_count=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/issues?labels=tech-debt&state=open&limit=1" 2>/dev/null | jq length) || tech_debt_count=0
if [ "$CURRENT_SHA" = "$LAST_SHA" ] && [ "${backlog_count:-0}" -eq 0 ] && [ "${tech_debt_count:-0}" -eq 0 ]; then
log "no new commits and no issues to groom — skipping"
exit 0
fi
log "current sha: ${CURRENT_SHA:0:8}..., backlog issues: ${backlog_count}, tech-debt issues: ${tech_debt_count}"
# ── Resolve agent identity for .profile repo ────────────────────────────
resolve_agent_identity || true
# ── Load formula + context ───────────────────────────────────────────────
load_formula_or_profile "gardener" "$FACTORY_ROOT/formulas/run-gardener.toml" || exit 1
build_context_block AGENTS.md
# ── Prepare .profile context (lessons injection) ─────────────────────────
formula_prepare_profile_context
# ── Read scratch file (compaction survival) ───────────────────────────────
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
# ── Build prompt ─────────────────────────────────────────────────────────
GARDENER_API_EXTRA="
## Pending-actions manifest (REQUIRED)
All repo mutations (comments, closures, label changes, issue creation) MUST be
written to the JSONL manifest instead of calling APIs directly. Append one JSON
object per line to: \$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl
Supported actions:
{\"action\":\"add_label\", \"issue\":NNN, \"label\":\"priority\"}
{\"action\":\"remove_label\", \"issue\":NNN, \"label\":\"backlog\"}
{\"action\":\"close\", \"issue\":NNN, \"reason\":\"already implemented\"}
{\"action\":\"comment\", \"issue\":NNN, \"body\":\"Relates to issue 1031\"}
{\"action\":\"create_issue\", \"title\":\"...\", \"body\":\"...\", \"labels\":[\"backlog\"]}
{\"action\":\"edit_body\", \"issue\":NNN, \"body\":\"new body\"}
{\"action\":\"close_pr\", \"pr\":NNN}
The commit-and-pr step converts JSONL to JSON array. The orchestrator executes
actions after the PR merges. Do NOT call mutation APIs directly during the run."
build_sdk_prompt_footer "$GARDENER_API_EXTRA"
PROMPT_FOOTER="${PROMPT_FOOTER}## Completion protocol (REQUIRED)
When the commit-and-pr step creates a PR, write the PR number and stop:
echo \"\$PR_NUMBER\" > '${GARDENER_PR_FILE}'
Then STOP. Do NOT write PHASE: signals — the orchestrator handles CI, review, and merge.
If no file changes exist (empty commit-and-pr), just stop — no PR needed."
PROMPT="You are the issue gardener for ${FORGE_REPO}. Work through the formula below.
You have full shell access and --dangerously-skip-permissions.
Fix what you can. File vault items for what you cannot. Do NOT ask permission — act first, report after.
## Project context
${CONTEXT_BLOCK}$(formula_lessons_block)
${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT}
}
## Result file
Write actions and dust items to: ${RESULT_FILE}
## Formula
${FORMULA_CONTENT}
${SCRATCH_INSTRUCTION}
${PROMPT_FOOTER}"
# ── Create worktree ──────────────────────────────────────────────────────
formula_worktree_setup "$WORKTREE"
# ── Post-merge manifest execution ────────────────────────────────────────
# Reads gardener/pending-actions.json and executes each action via API.
# Failed actions are logged but do not block completion.
_gardener_execute_manifest() {
local manifest_file="$PROJECT_REPO_ROOT/gardener/pending-actions.json"
if [ ! -f "$manifest_file" ]; then
log "manifest: no pending-actions.json — skipping"
return 0
fi
local count
count=$(jq 'length' "$manifest_file" 2>/dev/null || echo 0)
if [ "$count" -eq 0 ]; then
log "manifest: empty — skipping"
return 0
fi
log "manifest: executing ${count} actions"
local i=0
while [ "$i" -lt "$count" ]; do
local action issue
action=$(jq -r ".[$i].action" "$manifest_file")
issue=$(jq -r ".[$i].issue // empty" "$manifest_file")
case "$action" in
add_label)
local label label_id http_code resp
label=$(jq -r ".[$i].label" "$manifest_file")
label_id=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/labels" | jq -r --arg n "$label" \
'.[] | select(.name == $n) | .id') || true
if [ -n "$label_id" ]; then
resp=$(curl -sf -w "\n%{http_code}" -X POST -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \
"${FORGE_API}/issues/${issue}/labels" \
-d "{\"labels\":[${label_id}]}" 2>/dev/null) || true
http_code=$(echo "$resp" | tail -1)
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
log "manifest: add_label '${label}' to #${issue}"
else
log "manifest: FAILED add_label '${label}' to #${issue}: HTTP ${http_code}"
fi
else
log "manifest: FAILED add_label — label '${label}' not found"
fi
;;
remove_label)
local label label_id http_code resp
label=$(jq -r ".[$i].label" "$manifest_file")
label_id=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/labels" | jq -r --arg n "$label" \
'.[] | select(.name == $n) | .id') || true
if [ -n "$label_id" ]; then
resp=$(curl -sf -w "\n%{http_code}" -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/issues/${issue}/labels/${label_id}" 2>/dev/null) || true
http_code=$(echo "$resp" | tail -1)
if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then
log "manifest: remove_label '${label}' from #${issue}"
else
log "manifest: FAILED remove_label '${label}' from #${issue}: HTTP ${http_code}"
fi
else
log "manifest: FAILED remove_label — label '${label}' not found"
fi
;;
close)
local reason http_code resp
reason=$(jq -r ".[$i].reason // empty" "$manifest_file")
resp=$(curl -sf -w "\n%{http_code}" -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \
"${FORGE_API}/issues/${issue}" \
-d '{"state":"closed"}' 2>/dev/null) || true
http_code=$(echo "$resp" | tail -1)
if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then
log "manifest: closed #${issue} (${reason})"
else
log "manifest: FAILED close #${issue}: HTTP ${http_code}"
fi
;;
comment)
local body escaped_body http_code resp
body=$(jq -r ".[$i].body" "$manifest_file")
escaped_body=$(printf '%s' "$body" | jq -Rs '.')
resp=$(curl -sf -w "\n%{http_code}" -X POST -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \
"${FORGE_API}/issues/${issue}/comments" \
-d "{\"body\":${escaped_body}}" 2>/dev/null) || true
http_code=$(echo "$resp" | tail -1)
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
log "manifest: commented on #${issue}"
else
log "manifest: FAILED comment on #${issue}: HTTP ${http_code}"
fi
;;
create_issue)
local title body labels escaped_title escaped_body label_ids http_code resp
title=$(jq -r ".[$i].title" "$manifest_file")
body=$(jq -r ".[$i].body" "$manifest_file")
labels=$(jq -r ".[$i].labels // [] | .[]" "$manifest_file")
escaped_title=$(printf '%s' "$title" | jq -Rs '.')
escaped_body=$(printf '%s' "$body" | jq -Rs '.')
# Resolve label names to IDs
label_ids="[]"
if [ -n "$labels" ]; then
local all_labels ids_json=""
all_labels=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/labels") || true
while IFS= read -r lname; do
local lid
lid=$(echo "$all_labels" | jq -r --arg n "$lname" \
'.[] | select(.name == $n) | .id') || true
[ -n "$lid" ] && ids_json="${ids_json:+${ids_json},}${lid}"
done <<< "$labels"
[ -n "$ids_json" ] && label_ids="[${ids_json}]"
fi
resp=$(curl -sf -w "\n%{http_code}" -X POST -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \
"${FORGE_API}/issues" \
-d "{\"title\":${escaped_title},\"body\":${escaped_body},\"labels\":${label_ids}}" 2>/dev/null) || true
http_code=$(echo "$resp" | tail -1)
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
log "manifest: created issue '${title}'"
else
log "manifest: FAILED create_issue '${title}': HTTP ${http_code}"
fi
;;
edit_body)
local body escaped_body http_code resp
body=$(jq -r ".[$i].body" "$manifest_file")
escaped_body=$(printf '%s' "$body" | jq -Rs '.')
resp=$(curl -sf -w "\n%{http_code}" -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \
"${FORGE_API}/issues/${issue}" \
-d "{\"body\":${escaped_body}}" 2>/dev/null) || true
http_code=$(echo "$resp" | tail -1)
if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then
log "manifest: edited body of #${issue}"
else
log "manifest: FAILED edit_body #${issue}: HTTP ${http_code}"
fi
;;
close_pr)
local pr http_code resp
pr=$(jq -r ".[$i].pr" "$manifest_file")
resp=$(curl -sf -w "\n%{http_code}" -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \
"${FORGE_API}/pulls/${pr}" \
-d '{"state":"closed"}' 2>/dev/null) || true
http_code=$(echo "$resp" | tail -1)
if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then
log "manifest: closed PR #${pr}"
else
log "manifest: FAILED close_pr #${pr}: HTTP ${http_code}"
fi
;;
*)
log "manifest: unknown action '${action}' — skipping"
;;
esac
i=$((i + 1))
done
log "manifest: execution complete (${count} actions processed)"
}
# ── Reset result file ────────────────────────────────────────────────────
rm -f "$RESULT_FILE" "$GARDENER_PR_FILE"
touch "$RESULT_FILE"
# ── Run agent ─────────────────────────────────────────────────────────────
export CLAUDE_MODEL="sonnet"
agent_run --worktree "$WORKTREE" "$PROMPT"
log "agent_run complete"
# ── Detect PR ─────────────────────────────────────────────────────────────
PR_NUMBER=""
if [ -f "$GARDENER_PR_FILE" ]; then
PR_NUMBER=$(tr -d '[:space:]' < "$GARDENER_PR_FILE")
fi
# Fallback: search for open gardener PRs
if [ -z "$PR_NUMBER" ]; then
PR_NUMBER=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/pulls?state=open&limit=10" | \
jq -r '[.[] | select(.head.ref | startswith("chore/gardener-"))] | .[0].number // empty') || true
fi
# ── Walk PR to merge ──────────────────────────────────────────────────────
if [ -n "$PR_NUMBER" ]; then
log "walking PR #${PR_NUMBER} to merge"
pr_walk_to_merge "$PR_NUMBER" "$_AGENT_SESSION_ID" "$WORKTREE" || true
if [ "$_PR_WALK_EXIT_REASON" = "merged" ]; then
# Post-merge: pull primary, mirror push, execute manifest
git -C "$PROJECT_REPO_ROOT" fetch "${FORGE_REMOTE}" "$PRIMARY_BRANCH" 2>/dev/null || true
git -C "$PROJECT_REPO_ROOT" checkout "$PRIMARY_BRANCH" 2>/dev/null || true
git -C "$PROJECT_REPO_ROOT" pull --ff-only "${FORGE_REMOTE}" "$PRIMARY_BRANCH" 2>/dev/null || true
mirror_push
_gardener_execute_manifest
rm -f "$SCRATCH_FILE"
log "gardener PR #${PR_NUMBER} merged — manifest executed"
else
log "PR #${PR_NUMBER} not merged (reason: ${_PR_WALK_EXIT_REASON:-unknown})"
fi
else
log "no PR created — gardener run complete"
rm -f "$SCRATCH_FILE"
fi
# Write journal entry post-session
profile_write_journal "gardener-run" "Gardener run $(date -u +%Y-%m-%d)" "complete" "" || true
rm -f "$GARDENER_PR_FILE"
# Persist last-seen SHA for next run comparison
echo "$CURRENT_SHA" > "$LAST_SHA_FILE"
log "--- Gardener run done ---"