323 lines
12 KiB
Bash
Executable file
323 lines
12 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# =============================================================================
|
|
# action-agent.sh — Synchronous action agent: SDK + shared libraries
|
|
#
|
|
# Synchronous bash loop using claude -p (one-shot invocation).
|
|
# No tmux sessions, no phase files — the bash script IS the state machine.
|
|
#
|
|
# Usage: ./action-agent.sh <issue-number> [project.toml]
|
|
#
|
|
# Flow:
|
|
# 1. Preflight: issue_check_deps(), memory guard, concurrency lock
|
|
# 2. Parse model from YAML front matter in issue body (custom model selection)
|
|
# 3. Worktree: worktree_create() for action isolation
|
|
# 4. Load formula from issue body
|
|
# 5. Build prompt: formula + prior non-bot comments (resume context)
|
|
# 6. agent_run(worktree, prompt) → Claude executes action, may push
|
|
# 7. If pushed: pr_walk_to_merge() from lib/pr-lifecycle.sh
|
|
# 8. Cleanup: worktree_cleanup(), issue_close()
|
|
#
|
|
# Action-specific (stays in runner):
|
|
# - YAML front matter parsing (model selection)
|
|
# - Bot username filtering for prior comments
|
|
# - Lifetime watchdog (MAX_LIFETIME=8h wall-clock cap)
|
|
# - Child process cleanup (docker compose, background jobs)
|
|
#
|
|
# From shared libraries:
|
|
# - Issue lifecycle: lib/issue-lifecycle.sh
|
|
# - Worktree: lib/worktree.sh
|
|
# - PR lifecycle: lib/pr-lifecycle.sh
|
|
# - Agent SDK: lib/agent-sdk.sh
|
|
#
|
|
# Log: action/action-poll-{project}.log
|
|
# =============================================================================
|
|
set -euo pipefail
|
|
|
|
ISSUE="${1:?Usage: action-agent.sh <issue-number> [project.toml]}"
|
|
export PROJECT_TOML="${2:-${PROJECT_TOML:-}}"
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
FACTORY_ROOT="$(dirname "$SCRIPT_DIR")"
|
|
|
|
# shellcheck source=../lib/env.sh
|
|
source "$FACTORY_ROOT/lib/env.sh"
|
|
# Use action-bot's own Forgejo identity (#747)
|
|
FORGE_TOKEN="${FORGE_ACTION_TOKEN:-${FORGE_TOKEN}}"
|
|
# shellcheck source=../lib/ci-helpers.sh
|
|
source "$FACTORY_ROOT/lib/ci-helpers.sh"
|
|
# shellcheck source=../lib/worktree.sh
|
|
source "$FACTORY_ROOT/lib/worktree.sh"
|
|
# shellcheck source=../lib/issue-lifecycle.sh
|
|
source "$FACTORY_ROOT/lib/issue-lifecycle.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"
|
|
|
|
BRANCH="action/issue-${ISSUE}"
|
|
WORKTREE="/tmp/action-${ISSUE}-$(date +%s)"
|
|
LOCKFILE="/tmp/action-agent-${ISSUE}.lock"
|
|
LOGFILE="${DISINTO_LOG_DIR}/action/action-poll-${PROJECT_NAME:-default}.log"
|
|
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
|
|
SID_FILE="/tmp/action-session-${PROJECT_NAME:-default}-${ISSUE}.sid"
|
|
MAX_LIFETIME="${ACTION_MAX_LIFETIME:-28800}" # 8h default wall-clock cap
|
|
SESSION_START_EPOCH=$(date +%s)
|
|
|
|
log() {
|
|
printf '[%s] action#%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$ISSUE" "$*" >> "$LOGFILE"
|
|
}
|
|
|
|
# --- Concurrency lock (per issue) ---
|
|
if [ -f "$LOCKFILE" ]; then
|
|
LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || echo "")
|
|
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
|
|
log "SKIP: action-agent already running for #${ISSUE} (PID ${LOCK_PID})"
|
|
exit 0
|
|
fi
|
|
rm -f "$LOCKFILE"
|
|
fi
|
|
echo $$ > "$LOCKFILE"
|
|
|
|
cleanup() {
|
|
local exit_code=$?
|
|
# Kill lifetime watchdog if running
|
|
if [ -n "${LIFETIME_WATCHDOG_PID:-}" ] && kill -0 "$LIFETIME_WATCHDOG_PID" 2>/dev/null; then
|
|
kill "$LIFETIME_WATCHDOG_PID" 2>/dev/null || true
|
|
wait "$LIFETIME_WATCHDOG_PID" 2>/dev/null || true
|
|
fi
|
|
rm -f "$LOCKFILE"
|
|
# Kill any remaining child processes spawned during the run
|
|
local children
|
|
children=$(jobs -p 2>/dev/null) || true
|
|
if [ -n "$children" ]; then
|
|
# shellcheck disable=SC2086 # intentional word splitting
|
|
kill $children 2>/dev/null || true
|
|
# shellcheck disable=SC2086
|
|
wait $children 2>/dev/null || true
|
|
fi
|
|
# Best-effort docker cleanup for containers started during this action
|
|
(cd "${WORKTREE}" 2>/dev/null && docker compose down 2>/dev/null) || true
|
|
# Preserve worktree on crash for debugging; clean up on success
|
|
if [ "$exit_code" -ne 0 ]; then
|
|
worktree_preserve "$WORKTREE" "crashed (exit=$exit_code)"
|
|
else
|
|
worktree_cleanup "$WORKTREE"
|
|
fi
|
|
rm -f "$SID_FILE"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# --- Memory guard ---
|
|
memory_guard 2000
|
|
|
|
# --- Fetch issue ---
|
|
log "fetching issue #${ISSUE}"
|
|
ISSUE_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
|
"${FORGE_API}/issues/${ISSUE}") || true
|
|
|
|
if [ -z "$ISSUE_JSON" ] || ! printf '%s' "$ISSUE_JSON" | jq -e '.id' >/dev/null 2>&1; then
|
|
log "ERROR: failed to fetch issue #${ISSUE}"
|
|
exit 1
|
|
fi
|
|
|
|
ISSUE_TITLE=$(printf '%s' "$ISSUE_JSON" | jq -r '.title')
|
|
ISSUE_BODY=$(printf '%s' "$ISSUE_JSON" | jq -r '.body // ""')
|
|
ISSUE_STATE=$(printf '%s' "$ISSUE_JSON" | jq -r '.state')
|
|
|
|
if [ "$ISSUE_STATE" != "open" ]; then
|
|
log "SKIP: issue #${ISSUE} is ${ISSUE_STATE}"
|
|
exit 0
|
|
fi
|
|
|
|
log "Issue: ${ISSUE_TITLE}"
|
|
|
|
# --- Dependency check (shared library) ---
|
|
if ! issue_check_deps "$ISSUE"; then
|
|
log "SKIP: issue #${ISSUE} blocked by: ${_ISSUE_BLOCKED_BY[*]}"
|
|
exit 0
|
|
fi
|
|
|
|
# --- Extract model from YAML front matter (if present) ---
|
|
YAML_MODEL=$(printf '%s' "$ISSUE_BODY" | \
|
|
sed -n '/^---$/,/^---$/p' | grep '^model:' | awk '{print $2}' | tr -d '"' || true)
|
|
if [ -n "$YAML_MODEL" ]; then
|
|
export CLAUDE_MODEL="$YAML_MODEL"
|
|
log "model from front matter: ${YAML_MODEL}"
|
|
fi
|
|
|
|
# --- Resolve bot username(s) for comment filtering ---
|
|
_bot_login=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
|
"${FORGE_API%%/repos*}/user" | jq -r '.login // empty' 2>/dev/null || true)
|
|
|
|
# Build list: token owner + any extra names from FORGE_BOT_USERNAMES (comma-separated)
|
|
_bot_logins="${_bot_login}"
|
|
if [ -n "${FORGE_BOT_USERNAMES:-}" ]; then
|
|
_bot_logins="${_bot_logins:+${_bot_logins},}${FORGE_BOT_USERNAMES}"
|
|
fi
|
|
|
|
# --- Fetch existing comments (resume context, excluding bot comments) ---
|
|
COMMENTS_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
|
"${FORGE_API}/issues/${ISSUE}/comments?limit=50") || true
|
|
|
|
PRIOR_COMMENTS=""
|
|
if [ -n "$COMMENTS_JSON" ] && [ "$COMMENTS_JSON" != "null" ] && [ "$COMMENTS_JSON" != "[]" ]; then
|
|
PRIOR_COMMENTS=$(printf '%s' "$COMMENTS_JSON" | \
|
|
jq -r --arg bots "$_bot_logins" \
|
|
'($bots | split(",") | map(select(. != ""))) as $bl |
|
|
.[] | select(.user.login as $u | $bl | index($u) | not) |
|
|
"[\(.user.login) at \(.created_at[:19])]\n\(.body)\n---"' 2>/dev/null || true)
|
|
fi
|
|
|
|
# --- Determine git remote ---
|
|
cd "${PROJECT_REPO_ROOT}"
|
|
_forge_host=$(echo "$FORGE_URL" | sed 's|https\?://||; s|/.*||')
|
|
FORGE_REMOTE=$(git remote -v | awk -v host="$_forge_host" '$2 ~ host && /\(push\)/ {print $1; exit}')
|
|
FORGE_REMOTE="${FORGE_REMOTE:-origin}"
|
|
export FORGE_REMOTE
|
|
|
|
# --- Create isolated worktree ---
|
|
log "creating worktree: ${WORKTREE}"
|
|
git fetch "${FORGE_REMOTE}" "${PRIMARY_BRANCH}" 2>/dev/null || true
|
|
if ! worktree_create "$WORKTREE" "$BRANCH"; then
|
|
log "ERROR: worktree creation failed"
|
|
exit 1
|
|
fi
|
|
log "worktree ready: ${WORKTREE}"
|
|
|
|
# --- Build prompt ---
|
|
PRIOR_SECTION=""
|
|
if [ -n "$PRIOR_COMMENTS" ]; then
|
|
PRIOR_SECTION="## Prior comments (resume context)
|
|
|
|
${PRIOR_COMMENTS}
|
|
|
|
"
|
|
fi
|
|
|
|
GIT_INSTRUCTIONS=$(build_phase_protocol_prompt "$BRANCH" "$FORGE_REMOTE")
|
|
|
|
PROMPT="You are an action agent. Your job is to execute the action formula
|
|
in the issue below.
|
|
|
|
## Issue #${ISSUE}: ${ISSUE_TITLE}
|
|
|
|
${ISSUE_BODY}
|
|
|
|
${PRIOR_SECTION}## Instructions
|
|
|
|
1. Read the action formula steps in the issue body carefully.
|
|
|
|
2. Execute each step in order using your Bash tool and any other tools available.
|
|
|
|
3. Post progress as comments on issue #${ISSUE} after significant steps:
|
|
curl -sf -X POST \\
|
|
-H \"Authorization: token \${FORGE_TOKEN}\" \\
|
|
-H 'Content-Type: application/json' \\
|
|
\"${FORGE_API}/issues/${ISSUE}/comments\" \\
|
|
-d \"{\\\"body\\\": \\\"your comment here\\\"}\"
|
|
|
|
4. If a step requires human input or approval, post a comment explaining what
|
|
is needed and stop — the orchestrator will block the issue.
|
|
|
|
### Path A: If this action produces code changes (e.g. config updates, baselines):
|
|
- You are already in an isolated worktree at: ${WORKTREE}
|
|
- You are on branch: ${BRANCH}
|
|
- Make your changes, commit, and push: git push ${FORGE_REMOTE} ${BRANCH}
|
|
- **IMPORTANT:** The worktree is destroyed after completion. Push all
|
|
results before finishing — unpushed work will be lost.
|
|
|
|
### Path B: If this action produces no code changes (investigation, report):
|
|
- Post results as a comment on issue #${ISSUE}.
|
|
- **IMPORTANT:** The worktree is destroyed after completion. Copy any
|
|
files you need to persistent paths before finishing.
|
|
|
|
5. Environment variables available in your bash sessions:
|
|
FORGE_TOKEN, FORGE_API, FORGE_REPO, FORGE_WEB, PROJECT_NAME
|
|
(all sourced from ${FACTORY_ROOT}/.env)
|
|
|
|
### CRITICAL: Never embed secrets in issue bodies, comments, or PR descriptions
|
|
- NEVER put API keys, tokens, passwords, or private keys in issue text or comments.
|
|
- Always reference secrets via env var names (e.g. \\\$BASE_RPC_URL, \\\${FORGE_TOKEN}).
|
|
- If a formula step needs a secret, read it from .env or the environment at runtime.
|
|
- Before posting any comment, verify it contains no credentials, hex keys > 32 chars,
|
|
or URLs with embedded API keys.
|
|
|
|
If the prior comments above show work already completed, resume from where it
|
|
left off.
|
|
|
|
${GIT_INSTRUCTIONS}"
|
|
|
|
# --- Wall-clock lifetime watchdog (background) ---
|
|
# Caps total run time independently of claude -p timeout. When the cap is
|
|
# hit the watchdog kills the main process, which triggers cleanup via trap.
|
|
_lifetime_watchdog() {
|
|
local remaining=$(( MAX_LIFETIME - ($(date +%s) - SESSION_START_EPOCH) ))
|
|
[ "$remaining" -le 0 ] && remaining=1
|
|
sleep "$remaining"
|
|
local hours=$(( MAX_LIFETIME / 3600 ))
|
|
log "MAX_LIFETIME (${hours}h) reached — killing agent"
|
|
# Post summary comment on issue
|
|
local body="Action agent killed: wall-clock lifetime cap (${hours}h) reached."
|
|
curl -sf -X POST \
|
|
-H "Authorization: token ${FORGE_TOKEN}" \
|
|
-H 'Content-Type: application/json' \
|
|
"${FORGE_API}/issues/${ISSUE}/comments" \
|
|
-d "{\"body\": \"${body}\"}" >/dev/null 2>&1 || true
|
|
kill $$ 2>/dev/null || true
|
|
}
|
|
_lifetime_watchdog &
|
|
LIFETIME_WATCHDOG_PID=$!
|
|
|
|
# --- Run agent ---
|
|
log "running agent (worktree: ${WORKTREE})"
|
|
agent_run --worktree "$WORKTREE" "$PROMPT"
|
|
log "agent_run complete"
|
|
|
|
# --- Detect if branch was pushed (Path A vs Path B) ---
|
|
PUSHED=false
|
|
# Check if remote branch exists
|
|
git fetch "${FORGE_REMOTE}" "$BRANCH" 2>/dev/null || true
|
|
if git rev-parse --verify "${FORGE_REMOTE}/${BRANCH}" >/dev/null 2>&1; then
|
|
PUSHED=true
|
|
fi
|
|
# Fallback: check local commits ahead of base
|
|
if [ "$PUSHED" = false ]; then
|
|
if git -C "$WORKTREE" log "${FORGE_REMOTE}/${PRIMARY_BRANCH}..${BRANCH}" --oneline 2>/dev/null | grep -q .; then
|
|
PUSHED=true
|
|
fi
|
|
fi
|
|
|
|
if [ "$PUSHED" = true ]; then
|
|
# --- Path A: code changes pushed — create PR and walk to merge ---
|
|
log "branch pushed — creating PR"
|
|
PR_NUMBER=""
|
|
PR_NUMBER=$(pr_create "$BRANCH" "action: ${ISSUE_TITLE}" \
|
|
"Closes #${ISSUE}
|
|
|
|
Automated action execution by action-agent.") || true
|
|
|
|
if [ -n "$PR_NUMBER" ]; then
|
|
log "walking PR #${PR_NUMBER} to merge"
|
|
pr_walk_to_merge "$PR_NUMBER" "$_AGENT_SESSION_ID" "$WORKTREE" || true
|
|
|
|
case "${_PR_WALK_EXIT_REASON:-}" in
|
|
merged)
|
|
log "PR #${PR_NUMBER} merged — closing issue"
|
|
issue_close "$ISSUE"
|
|
;;
|
|
*)
|
|
log "PR #${PR_NUMBER} not merged (reason: ${_PR_WALK_EXIT_REASON:-unknown})"
|
|
issue_block "$ISSUE" "pr_not_merged: ${_PR_WALK_EXIT_REASON:-unknown}"
|
|
;;
|
|
esac
|
|
else
|
|
log "ERROR: failed to create PR"
|
|
issue_block "$ISSUE" "pr_creation_failed"
|
|
fi
|
|
else
|
|
# --- Path B: no code changes — close issue directly ---
|
|
log "no branch pushed — closing issue (Path B)"
|
|
issue_close "$ISSUE"
|
|
fi
|
|
|
|
log "action-agent finished for issue #${ISSUE}"
|