Compare commits

..

No commits in common. "2b8e250247ae58914f7f6d0e425caf03b417f266" and "27c5ab996d038ffa2f57d6fd45b30c7b1d83e5db" have entirely different histories.

8 changed files with 931 additions and 526 deletions

View file

@ -96,7 +96,6 @@ echo "=== 2/2 Function resolution ==="
# Included — these are inline-sourced by agent scripts: # Included — these are inline-sourced by agent scripts:
# lib/env.sh — sourced by every agent (log, forge_api, etc.) # lib/env.sh — sourced by every agent (log, forge_api, etc.)
# lib/agent-session.sh — sourced by orchestrators (create_agent_session, monitor_phase_loop, etc.) # lib/agent-session.sh — sourced by orchestrators (create_agent_session, monitor_phase_loop, etc.)
# lib/agent-sdk.sh — sourced by SDK agents (agent_run, agent_recover_session)
# lib/ci-helpers.sh — sourced by pollers and review (ci_passed, classify_pipeline_failure, etc.) # lib/ci-helpers.sh — sourced by pollers and review (ci_passed, classify_pipeline_failure, etc.)
# lib/load-project.sh — sourced by env.sh when PROJECT_TOML is set # lib/load-project.sh — sourced by env.sh when PROJECT_TOML is set
# lib/file-action-issue.sh — sourced by gardener-run.sh (file_action_issue) # lib/file-action-issue.sh — sourced by gardener-run.sh (file_action_issue)
@ -116,7 +115,7 @@ echo "=== 2/2 Function resolution ==="
# If a new lib file is added and sourced by agents, add it to LIB_FUNS below # If a new lib file is added and sourced by agents, add it to LIB_FUNS below
# and add a check_script call for it in the lib files section further down. # and add a check_script call for it in the lib files section further down.
LIB_FUNS=$( LIB_FUNS=$(
for f in lib/agent-session.sh lib/agent-sdk.sh lib/env.sh lib/ci-helpers.sh lib/load-project.sh lib/secret-scan.sh lib/file-action-issue.sh lib/formula-session.sh lib/mirrors.sh lib/guard.sh lib/pr-lifecycle.sh lib/issue-lifecycle.sh lib/worktree.sh; do for f in lib/agent-session.sh lib/env.sh lib/ci-helpers.sh lib/load-project.sh lib/secret-scan.sh lib/file-action-issue.sh lib/formula-session.sh lib/mirrors.sh lib/guard.sh lib/pr-lifecycle.sh lib/issue-lifecycle.sh lib/worktree.sh; do
if [ -f "$f" ]; then get_fns "$f"; fi if [ -f "$f" ]; then get_fns "$f"; fi
done | sort -u done | sort -u
) )
@ -181,7 +180,6 @@ check_script() {
# but this verifies calls *within* each lib file are also resolvable. # but this verifies calls *within* each lib file are also resolvable.
check_script lib/env.sh lib/mirrors.sh check_script lib/env.sh lib/mirrors.sh
check_script lib/agent-session.sh check_script lib/agent-session.sh
check_script lib/agent-sdk.sh
check_script lib/ci-helpers.sh check_script lib/ci-helpers.sh
check_script lib/secret-scan.sh check_script lib/secret-scan.sh
check_script lib/file-action-issue.sh lib/secret-scan.sh check_script lib/file-action-issue.sh lib/secret-scan.sh
@ -205,7 +203,7 @@ check_script dev/phase-handler.sh action/action-agent.sh lib/secret-scan.sh
check_script dev/dev-poll.sh check_script dev/dev-poll.sh
check_script dev/phase-test.sh check_script dev/phase-test.sh
check_script gardener/gardener-run.sh check_script gardener/gardener-run.sh
check_script review/review-pr.sh lib/agent-sdk.sh check_script review/review-pr.sh lib/agent-session.sh
check_script review/review-poll.sh check_script review/review-poll.sh
check_script planner/planner-run.sh lib/agent-session.sh lib/formula-session.sh check_script planner/planner-run.sh lib/agent-session.sh lib/formula-session.sh
check_script supervisor/supervisor-poll.sh check_script supervisor/supervisor-poll.sh

View file

@ -260,37 +260,10 @@ services:
networks: networks:
- disinto-net - disinto-net
# Edge proxy — reverse proxy to Forgejo, Woodpecker, and staging
# Serves on ports 80/443, routes based on path
edge:
image: caddy:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./docker/Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
depends_on:
- forgejo
- woodpecker
- staging
networks:
- disinto-net
# Staging container — static file server for staging artifacts
# Edge proxy routes to this container for default requests
staging:
image: caddy:alpine
command: ["caddy", "file-server", "--root", "/srv/site"]
volumes:
- ./docker:/srv/site:ro
networks:
- disinto-net
# Staging deployment slot — activated by Woodpecker staging pipeline (#755). # Staging deployment slot — activated by Woodpecker staging pipeline (#755).
# Profile-gated: only starts when explicitly targeted by deploy commands. # Profile-gated: only starts when explicitly targeted by deploy commands.
# Customize image/ports/volumes for your project after init. # Customize image/ports/volumes for your project after init.
staging-deploy: staging:
image: alpine:3 image: alpine:3
profiles: ["staging"] profiles: ["staging"]
security_opt: security_opt:
@ -306,7 +279,6 @@ volumes:
woodpecker-data: woodpecker-data:
agent-data: agent-data:
project-repos: project-repos:
caddy_data:
networks: networks:
disinto-net: disinto-net:
@ -349,95 +321,6 @@ generate_agent_docker() {
fi fi
} }
# Generate docker/Caddyfile template for edge proxy.
generate_caddyfile() {
local docker_dir="${FACTORY_ROOT}/docker"
local caddyfile="${docker_dir}/Caddyfile"
if [ -f "$caddyfile" ]; then
echo "Caddyfile: ${caddyfile} (already exists, skipping)"
return
fi
cat > "$caddyfile" <<'CADDYFILEEOF'
# Caddyfile — edge proxy configuration
# IP-only binding at bootstrap; domain + TLS added later via vault resource request
:80 {
# Reverse proxy to Forgejo
handle /forgejo/* {
reverse_proxy forgejo:3000
}
# Reverse proxy to Woodpecker CI
handle /ci/* {
reverse_proxy woodpecker:8000
}
# Default: proxy to staging container
handle {
reverse_proxy staging:80
}
}
CADDYFILEEOF
echo "Created: ${caddyfile}"
}
# Generate docker/index.html default page.
generate_staging_index() {
local docker_dir="${FACTORY_ROOT}/docker"
local index_file="${docker_dir}/index.html"
if [ -f "$index_file" ]; then
echo "Staging: ${index_file} (already exists, skipping)"
return
fi
cat > "$index_file" <<'INDEXEOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nothing shipped yet</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
font-size: 3rem;
margin: 0 0 1rem 0;
}
p {
font-size: 1.25rem;
opacity: 0.9;
}
</style>
</head>
<body>
<div class="container">
<h1>Nothing shipped yet</h1>
<p>CI pipelines will update this page with your staging artifacts.</p>
</div>
</body>
</html>
INDEXEOF
echo "Created: ${index_file}"
}
# Generate template .woodpecker/ deployment pipeline configs in a project repo. # Generate template .woodpecker/ deployment pipeline configs in a project repo.
# Creates staging.yml and production.yml alongside the project's existing CI config. # Creates staging.yml and production.yml alongside the project's existing CI config.
# These pipelines trigger on Woodpecker's deployment event with environment filters. # These pipelines trigger on Woodpecker's deployment event with environment filters.
@ -1716,8 +1599,6 @@ p.write_text(text)
forge_port="${forge_port:-3000}" forge_port="${forge_port:-3000}"
generate_compose "$forge_port" generate_compose "$forge_port"
generate_agent_docker generate_agent_docker
generate_caddyfile
generate_staging_index
# Create empty .env so docker compose can parse the agents service # Create empty .env so docker compose can parse the agents service
# env_file reference before setup_forge generates the real tokens (#769) # env_file reference before setup_forge generates the real tokens (#769)
touch "${FACTORY_ROOT}/.env" touch "${FACTORY_ROOT}/.env"

View file

@ -29,7 +29,6 @@ source "$(dirname "$0")/../lib/issue-lifecycle.sh"
source "$(dirname "$0")/../lib/worktree.sh" source "$(dirname "$0")/../lib/worktree.sh"
source "$(dirname "$0")/../lib/pr-lifecycle.sh" source "$(dirname "$0")/../lib/pr-lifecycle.sh"
source "$(dirname "$0")/../lib/mirrors.sh" source "$(dirname "$0")/../lib/mirrors.sh"
source "$(dirname "$0")/../lib/agent-sdk.sh"
# Auto-pull factory code to pick up merged fixes before any logic runs # Auto-pull factory code to pick up merged fixes before any logic runs
git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true
@ -57,6 +56,43 @@ status() {
log "$*" log "$*"
} }
# =============================================================================
# agent_run — synchronous Claude invocation (one-shot claude -p)
# =============================================================================
# Usage: agent_run [--resume SESSION_ID] [--worktree DIR] PROMPT
# Sets: _AGENT_SESSION_ID (updated each call, persisted to SID_FILE)
_AGENT_SESSION_ID=""
agent_run() {
local resume_id="" worktree_dir=""
while [[ "${1:-}" == --* ]]; do
case "$1" in
--resume) shift; resume_id="${1:-}"; shift ;;
--worktree) shift; worktree_dir="${1:-}"; shift ;;
*) shift ;;
esac
done
local prompt="${1:-}"
local -a args=(-p "$prompt" --output-format json --dangerously-skip-permissions --max-turns 200)
[ -n "$resume_id" ] && args+=(--resume "$resume_id")
[ -n "${CLAUDE_MODEL:-}" ] && args+=(--model "$CLAUDE_MODEL")
local run_dir="${worktree_dir:-$(pwd)}"
local output
log "agent_run: starting (resume=${resume_id:-(new)}, dir=${run_dir})"
output=$(cd "$run_dir" && timeout "${CLAUDE_TIMEOUT:-7200}" claude "${args[@]}" 2>>"$LOGFILE") || true
# Extract and persist session_id
local new_sid
new_sid=$(printf '%s' "$output" | jq -r '.session_id // empty' 2>/dev/null) || true
if [ -n "$new_sid" ]; then
_AGENT_SESSION_ID="$new_sid"
printf '%s' "$new_sid" > "$SID_FILE"
log "agent_run: session_id=${new_sid:0:12}..."
fi
}
# ============================================================================= # =============================================================================
# CLEANUP # CLEANUP
# ============================================================================= # =============================================================================
@ -243,7 +279,10 @@ if [ -n "$PR_NUMBER" ]; then
fi fi
# Recover session_id from .sid file (crash recovery) # Recover session_id from .sid file (crash recovery)
agent_recover_session if [ -f "$SID_FILE" ]; then
_AGENT_SESSION_ID=$(cat "$SID_FILE")
log "recovered session_id: ${_AGENT_SESSION_ID:0:12}..."
fi
# ============================================================================= # =============================================================================
# WORKTREE SETUP # WORKTREE SETUP

View file

@ -1,9 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# dev-poll.sh — Pull-based scheduler: find the next ready issue and start dev-agent # dev-poll.sh — Pull-based scheduler: find the next ready issue and start dev-agent
# #
# SDK version: No tmux — checks PID lockfile for active agents.
# Uses pr_merge() and issue_block() from shared libraries.
#
# Pull system: issues labeled "backlog" are candidates. An issue is READY when # Pull system: issues labeled "backlog" are candidates. An issue is READY when
# ALL its dependency issues are closed (and their PRs merged). # ALL its dependency issues are closed (and their PRs merged).
# No "todo" label needed — readiness is derived from reality. # No "todo" label needed — readiness is derived from reality.
@ -19,39 +16,38 @@
set -euo pipefail set -euo pipefail
# Load shared environment and libraries # Load shared environment (with optional project TOML override)
export PROJECT_TOML="${1:-}" export PROJECT_TOML="${1:-}"
source "$(dirname "$0")/../lib/env.sh" source "$(dirname "$0")/../lib/env.sh"
source "$(dirname "$0")/../lib/ci-helpers.sh" source "$(dirname "$0")/../lib/ci-helpers.sh"
# shellcheck source=../lib/pr-lifecycle.sh
source "$(dirname "$0")/../lib/pr-lifecycle.sh"
# shellcheck source=../lib/issue-lifecycle.sh
source "$(dirname "$0")/../lib/issue-lifecycle.sh"
# shellcheck source=../lib/mirrors.sh # shellcheck source=../lib/mirrors.sh
source "$(dirname "$0")/../lib/mirrors.sh" source "$(dirname "$0")/../lib/mirrors.sh"
# shellcheck source=../lib/guard.sh # shellcheck source=../lib/guard.sh
source "$(dirname "$0")/../lib/guard.sh" source "$(dirname "$0")/../lib/guard.sh"
check_active dev check_active dev
API="${FORGE_API}" # Gitea labels API requires []int64 — look up the "underspecified" label ID once
LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-default}.lock" UNDERSPECIFIED_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \
LOGFILE="${DISINTO_LOG_DIR}/dev/dev-agent-${PROJECT_NAME:-default}.log" | jq -r '.[] | select(.name == "underspecified") | .id' 2>/dev/null || true)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" UNDERSPECIFIED_LABEL_ID="${UNDERSPECIFIED_LABEL_ID:-1300816}"
log() { # Track CI fix attempts per PR to avoid infinite respawn loops
printf '[%s] poll: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
}
# =============================================================================
# CI FIX TRACKER: per-PR counter to avoid infinite respawn loops (max 3)
# =============================================================================
CI_FIX_TRACKER="${DISINTO_LOG_DIR}/dev/ci-fixes-${PROJECT_NAME:-default}.json" CI_FIX_TRACKER="${DISINTO_LOG_DIR}/dev/ci-fixes-${PROJECT_NAME:-default}.json"
CI_FIX_LOCK="${CI_FIX_TRACKER}.lock" CI_FIX_LOCK="${CI_FIX_TRACKER}.lock"
ci_fix_count() { ci_fix_count() {
local pr="$1" local pr="$1"
flock "$CI_FIX_LOCK" python3 -c "import json,sys;d=json.load(open('$CI_FIX_TRACKER')) if __import__('os').path.exists('$CI_FIX_TRACKER') else {};print(d.get(str($pr),0))" 2>/dev/null || echo 0 flock "$CI_FIX_LOCK" python3 -c "import json,sys;d=json.load(open('$CI_FIX_TRACKER')) if __import__('os').path.exists('$CI_FIX_TRACKER') else {};print(d.get(str($pr),0))" 2>/dev/null || echo 0
} }
ci_fix_increment() {
local pr="$1"
flock "$CI_FIX_LOCK" python3 -c "
import json,os
f='$CI_FIX_TRACKER'
d=json.load(open(f)) if os.path.exists(f) else {}
d[str($pr)]=d.get(str($pr),0)+1
json.dump(d,open(f,'w'))
" 2>/dev/null || true
}
ci_fix_reset() { ci_fix_reset() {
local pr="$1" local pr="$1"
flock "$CI_FIX_LOCK" python3 -c " flock "$CI_FIX_LOCK" python3 -c "
@ -94,14 +90,44 @@ is_blocked() {
| jq -e '.[] | select(.name == "blocked")' >/dev/null 2>&1 | jq -e '.[] | select(.name == "blocked")' >/dev/null 2>&1
} }
# Post a CI-exhaustion diagnostic comment and label issue as blocked.
# Args: issue_num pr_num attempts
_post_ci_blocked_comment() {
local issue_num="$1" pr_num="$2" attempts="$3"
local blocked_id
blocked_id=$(ensure_blocked_label_id)
[ -z "$blocked_id" ] && return 0
local comment
comment="### Session failure diagnostic
| Field | Value |
|---|---|
| Exit reason | \`ci_exhausted_poll (${attempts} attempts)\` |
| Timestamp | \`$(date -u +%Y-%m-%dT%H:%M:%SZ)\` |
| PR | #${pr_num} |"
curl -sf -X POST \
-H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \
"${FORGE_API}/issues/${issue_num}/comments" \
-d "$(jq -nc --arg b "$comment" '{body:$b}')" >/dev/null 2>&1 || true
curl -sf -X POST \
-H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \
"${FORGE_API}/issues/${issue_num}/labels" \
-d "{\"labels\":[${blocked_id}]}" >/dev/null 2>&1 || true
}
# ============================================================================= # =============================================================================
# HELPER: handle CI-exhaustion check/block (DRY for 3 call sites) # HELPER: handle CI-exhaustion check/block (DRY for 3 call sites)
# Sets CI_FIX_ATTEMPTS for caller use. Returns 0 if exhausted, 1 if not. # Sets CI_FIX_ATTEMPTS for caller use. Returns 0 if exhausted, 1 if not.
# Uses issue_block() from lib/issue-lifecycle.sh for blocking.
# #
# Pass "check_only" as third arg for the backlog scan path: ok-counts are # Pass "check_only" as third arg for the backlog scan path: ok-counts are
# returned without incrementing (deferred to launch time so a WAITING_PRS # returned without incrementing (deferred to launch time so a WAITING_PRS
# exit cannot waste a fix attempt). The 3->4 sentinel bump is always atomic. # exit cannot waste a fix attempt). The 3→4 sentinel bump is always atomic
# regardless of mode, preventing duplicate blocked labels from concurrent
# pollers.
# ============================================================================= # =============================================================================
handle_ci_exhaustion() { handle_ci_exhaustion() {
local pr_num="$1" issue_num="$2" local pr_num="$1" issue_num="$2"
@ -115,6 +141,11 @@ handle_ci_exhaustion() {
return 0 return 0
fi fi
# Single flock-protected call: read + threshold-check + conditional bump.
# In check_only mode, ok-counts are returned without incrementing (deferred
# to launch time). In both modes, the 3→4 sentinel bump is atomic, so only
# one concurrent poller can ever receive exhausted_first_time:3 and label
# the issue blocked.
result=$(ci_fix_check_and_increment "$pr_num" "$check_only") result=$(ci_fix_check_and_increment "$pr_num" "$check_only")
case "$result" in case "$result" in
ok:*) ok:*)
@ -124,7 +155,7 @@ handle_ci_exhaustion() {
exhausted_first_time:*) exhausted_first_time:*)
CI_FIX_ATTEMPTS="${result#exhausted_first_time:}" CI_FIX_ATTEMPTS="${result#exhausted_first_time:}"
log "PR #${pr_num} (issue #${issue_num}) CI exhausted (${CI_FIX_ATTEMPTS} attempts) — marking blocked" log "PR #${pr_num} (issue #${issue_num}) CI exhausted (${CI_FIX_ATTEMPTS} attempts) — marking blocked"
issue_block "$issue_num" "ci_exhausted_poll (${CI_FIX_ATTEMPTS} attempts, PR #${pr_num})" _post_ci_blocked_comment "$issue_num" "$pr_num" "$CI_FIX_ATTEMPTS"
;; ;;
exhausted:*) exhausted:*)
CI_FIX_ATTEMPTS="${result#exhausted:}" CI_FIX_ATTEMPTS="${result#exhausted:}"
@ -139,7 +170,7 @@ handle_ci_exhaustion() {
} }
# ============================================================================= # =============================================================================
# HELPER: merge an approved PR directly via pr_merge() (no Claude needed) # HELPER: merge an approved PR directly (no Claude needed)
# #
# Merging an approved, CI-green PR is a single API call. Spawning dev-agent # Merging an approved, CI-green PR is a single API call. Spawning dev-agent
# for this fails when the issue is already closed (forge auto-closes issues # for this fails when the issue is already closed (forge auto-closes issues
@ -150,15 +181,30 @@ try_direct_merge() {
log "PR #${pr_num} (issue #${issue_num}) approved + CI green → attempting direct merge" log "PR #${pr_num} (issue #${issue_num}) approved + CI green → attempting direct merge"
if pr_merge "$pr_num"; then local merge_resp merge_http
merge_resp=$(curl -sf -w '\n%{http_code}' -X POST \
-H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \
"${API}/pulls/${pr_num}/merge" \
-d '{"Do":"merge","delete_branch_after_merge":true}' 2>/dev/null) || true
merge_http=$(echo "$merge_resp" | tail -1)
if [ "${merge_http:-0}" = "200" ] || [ "${merge_http:-0}" = "204" ]; then
log "PR #${pr_num} merged successfully" log "PR #${pr_num} merged successfully"
if [ "$issue_num" -gt 0 ]; then if [ "$issue_num" -gt 0 ]; then
issue_close "$issue_num" # Close the issue (may already be closed by forge auto-close)
# Remove in-progress label (don't re-add backlog — issue is closed) curl -sf -X PATCH \
-H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \
"${API}/issues/${issue_num}" \
-d '{"state":"closed"}' >/dev/null 2>&1 || true
# Remove in-progress label
curl -sf -X DELETE \ curl -sf -X DELETE \
-H "Authorization: token ${FORGE_TOKEN}" \ -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${issue_num}/labels/in-progress" >/dev/null 2>&1 || true "${API}/issues/${issue_num}/labels/in-progress" >/dev/null 2>&1 || true
rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.sid" \ # Clean up phase/session artifacts
rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase" \
"/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt" "/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt"
fi fi
# Pull merged primary branch and push to mirrors # Pull merged primary branch and push to mirrors
@ -166,70 +212,201 @@ try_direct_merge() {
git -C "${PROJECT_REPO_ROOT:-}" checkout "${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 origin "${PRIMARY_BRANCH:-}" 2>/dev/null || true git -C "${PROJECT_REPO_ROOT:-}" pull --ff-only origin "${PRIMARY_BRANCH:-}" 2>/dev/null || true
mirror_push mirror_push
# Clean up CI fix tracker
ci_fix_reset "$pr_num" ci_fix_reset "$pr_num"
return 0 return 0
fi fi
log "PR #${pr_num} direct merge failed — falling back to dev-agent" log "PR #${pr_num} direct merge failed (HTTP ${merge_http:-?}) — falling back to dev-agent"
return 1 return 1
} }
# ============================================================================= # =============================================================================
# HELPER: extract issue number from PR branch/title/body # HELPER: inject text into a tmux session via load-buffer + paste (#771)
# All tmux calls guarded with || true to prevent aborting under set -euo pipefail.
# Args: session text
# ============================================================================= # =============================================================================
extract_issue_from_pr() { _inject_into_session() {
local branch="$1" title="$2" body="$3" local session="$1" text="$2"
local issue local tmpfile
issue=$(echo "$branch" | grep -oP '(?<=fix/issue-)\d+' || true) tmpfile=$(mktemp /tmp/dev-poll-inject-XXXXXX)
if [ -z "$issue" ]; then printf '%s' "$text" > "$tmpfile"
issue=$(echo "$title" | grep -oP '#\K\d+' | tail -1 || true) tmux load-buffer -b "poll-inject-$$" "$tmpfile" || true
fi tmux paste-buffer -t "$session" -b "poll-inject-$$" || true
if [ -z "$issue" ]; then sleep 0.5
issue=$(echo "$body" | grep -oiP '(?:closes|fixes|resolves)\s*#\K\d+' | head -1 || true) tmux send-keys -t "$session" "" Enter || true
fi tmux delete-buffer -b "poll-inject-$$" 2>/dev/null || true
printf '%s' "$issue" rm -f "$tmpfile"
} }
# ============================================================================= # =============================================================================
# DEPENDENCY HELPERS # HELPER: handle events for a running dev session (#771)
#
# When a tmux session is alive, check for injectable events instead of skipping.
# Handles: externally merged/closed PRs, CI results (awaiting_ci), and
# review feedback (awaiting_review).
#
# Args: session_name issue_num [pr_num]
# Sets: ACTIVE_SESSION_ACTION = "cleaned" | "injected" | "skip"
# ============================================================================= # =============================================================================
dep_is_merged() { # shellcheck disable=SC2034 # ACTIVE_SESSION_ACTION is read by callers
local dep_num="$1" handle_active_session() {
local dep_state local session="$1" issue_num="$2" pr_num="${3:-}"
dep_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ local phase_file="/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase"
"${API}/issues/${dep_num}" | jq -r '.state // "open"') local sentinel="/tmp/dev-poll-injected-${PROJECT_NAME}-${issue_num}"
if [ "$dep_state" != "closed" ]; then ACTIVE_SESSION_ACTION="skip"
return 1
local phase
phase=$(head -1 "$phase_file" 2>/dev/null | tr -d '[:space:]' || true)
local pr_json="" pr_sha="" pr_branch=""
# --- Detect externally merged/closed PR ---
if [ -n "$pr_num" ]; then
local pr_state pr_merged
pr_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${pr_num}") || true
pr_state=$(printf '%s' "$pr_json" | jq -r '.state // "unknown"')
pr_sha=$(printf '%s' "$pr_json" | jq -r '.head.sha // ""')
pr_branch=$(printf '%s' "$pr_json" | jq -r '.head.ref // ""')
if [ "$pr_state" != "open" ]; then
pr_merged=$(printf '%s' "$pr_json" | jq -r '.merged // false')
tmux kill-session -t "$session" 2>/dev/null || true
rm -f "$phase_file" "/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt" "$sentinel"
if [ "$pr_merged" = "true" ]; then
curl -sf -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${issue_num}" -d '{"state":"closed"}' >/dev/null 2>&1 || true
fi
curl -sf -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${issue_num}/labels/in-progress" >/dev/null 2>&1 || true
ci_fix_reset "$pr_num"
log "PR #${pr_num} (issue #${issue_num}) merged/closed externally — cleaned up session ${session}"
ACTIVE_SESSION_ACTION="cleaned"
return 0
fi
else
# No PR number — check if a merged PR exists for this issue's branch
local closed_pr closed_merged
closed_pr=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls?state=closed&limit=10" | \
jq -r --arg branch "fix/issue-${issue_num}" \
'.[] | select(.head.ref == $branch) | .number' | head -1) || true
if [ -n "$closed_pr" ]; then
closed_merged=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${closed_pr}" | jq -r '.merged // false') || true
if [ "$closed_merged" = "true" ]; then
tmux kill-session -t "$session" 2>/dev/null || true
rm -f "$phase_file" "/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt" "$sentinel"
curl -sf -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${issue_num}" -d '{"state":"closed"}' >/dev/null 2>&1 || true
curl -sf -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${issue_num}/labels/in-progress" >/dev/null 2>&1 || true
log "issue #${issue_num} PR #${closed_pr} merged externally — cleaned up session ${session}"
ACTIVE_SESSION_ACTION="cleaned"
return 0
fi
fi
return 0 # no PR — can't inject CI/review events
fi fi
return 0
}
get_deps() { # Sentinel: avoid re-injecting for the same SHA across poll cycles
local issue_body="$1" local last_injected
echo "$issue_body" | bash "${FACTORY_ROOT}/lib/parse-deps.sh" last_injected=$(cat "$sentinel" 2>/dev/null || true)
} if [ -n "$last_injected" ] && [ "$last_injected" = "$pr_sha" ]; then
log "already injected for ${session} SHA ${pr_sha:0:7} — skipping"
issue_is_ready() {
local issue_num="$1"
local issue_body="$2"
local deps
deps=$(get_deps "$issue_body")
if [ -z "$deps" ]; then
return 0 return 0
fi fi
while IFS= read -r dep; do # --- Inject CI result into awaiting_ci session ---
[ -z "$dep" ] && continue if [ "$phase" = "PHASE:awaiting_ci" ] && [ -n "$pr_sha" ]; then
if ! dep_is_merged "$dep"; then local ci_state
log " #${issue_num} blocked: dep #${dep} not merged" ci_state=$(ci_commit_status "$pr_sha") || true
return 1
if ci_passed "$ci_state"; then
_inject_into_session "$session" "CI passed on PR #${pr_num}.
Write PHASE:awaiting_review to the phase file, then stop and wait for review feedback:
echo \"PHASE:awaiting_review\" > \"${phase_file}\""
printf '%s' "$pr_sha" > "$sentinel"
log "injected CI success into session ${session} for PR #${pr_num}"
ACTIVE_SESSION_ACTION="injected"
return 0
fi fi
done <<< "$deps"
if ci_failed "$ci_state"; then
local pipeline_num error_log
pipeline_num=$(ci_pipeline_number "$pr_sha") || true
error_log=""
if [ -n "$pipeline_num" ]; then
error_log=$(bash "${FACTORY_ROOT}/lib/ci-debug.sh" failures "$pipeline_num" 2>/dev/null \
| tail -80 | head -c 4000 || true)
fi
_inject_into_session "$session" "CI failed on PR #${pr_num} (pipeline #${pipeline_num:-?}).
Error excerpt:
${error_log:-No logs available. Run: bash ${FACTORY_ROOT}/lib/ci-debug.sh failures ${pipeline_num:-0}}
Fix the issue, commit, push, then write:
echo \"PHASE:awaiting_ci\" > \"${phase_file}\""
printf '%s' "$pr_sha" > "$sentinel"
log "injected CI failure into session ${session} for PR #${pr_num}"
ACTIVE_SESSION_ACTION="injected"
return 0
fi
fi
# --- Inject review feedback into awaiting_review session ---
if [ "$phase" = "PHASE:awaiting_review" ] && [ -n "$pr_sha" ]; then
local reviews_json has_changes review_body
reviews_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${pr_num}/reviews") || true
has_changes=$(printf '%s' "$reviews_json" | \
jq -r '[.[] | select(.state == "REQUEST_CHANGES") | select(.stale == false)] | length') || true
if [ "${has_changes:-0}" -gt 0 ]; then
review_body=$(printf '%s' "$reviews_json" | \
jq -r '[.[] | select(.state == "REQUEST_CHANGES") | select(.stale == false)] | last | .body // ""') || true
# Prefer bot review comment if available (richer content)
local review_comment
review_comment=$(forge_api_all "/issues/${pr_num}/comments" | \
jq -r --arg sha "$pr_sha" \
'[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last | .body // empty') || true
[ -n "$review_comment" ] && review_body="$review_comment"
_inject_into_session "$session" "Review: REQUEST_CHANGES on PR #${pr_num}:
${review_body:-Review requested changes but no details provided.}
Instructions:
1. Address each piece of feedback carefully.
2. Run lint and tests when done.
3. Commit your changes and push: git push origin ${pr_branch}
4. Write: echo \"PHASE:awaiting_ci\" > \"${phase_file}\"
5. Stop and wait for the next CI result."
printf '%s' "$pr_sha" > "$sentinel"
log "injected review feedback into session ${session} for PR #${pr_num}"
ACTIVE_SESSION_ACTION="injected"
return 0
fi
fi
return 0 return 0
} }
API="${FORGE_API}"
LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-default}.lock"
LOGFILE="${DISINTO_LOG_DIR}/dev/dev-agent-${PROJECT_NAME:-default}.log"
PREFLIGHT_RESULT="/tmp/dev-agent-preflight.json"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
log() {
printf '[%s] poll: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
}
# ============================================================================= # =============================================================================
# PRE-LOCK: merge approved + CI-green PRs (no Claude session needed) # PRE-LOCK: merge approved + CI-green PRs (no Claude session needed)
# #
@ -249,7 +426,14 @@ for i in $(seq 0 $(($(echo "$PL_PRS" | jq 'length') - 1))); do
PL_PR_TITLE=$(echo "$PL_PRS" | jq -r ".[$i].title") PL_PR_TITLE=$(echo "$PL_PRS" | jq -r ".[$i].title")
PL_PR_BODY=$(echo "$PL_PRS" | jq -r ".[$i].body // \"\"") PL_PR_BODY=$(echo "$PL_PRS" | jq -r ".[$i].body // \"\"")
PL_ISSUE=$(extract_issue_from_pr "$PL_PR_BRANCH" "$PL_PR_TITLE" "$PL_PR_BODY") # Extract issue number from branch name, PR title, or PR body
PL_ISSUE=$(echo "$PL_PR_BRANCH" | grep -oP '(?<=fix/issue-)\d+' || true)
if [ -z "$PL_ISSUE" ]; then
PL_ISSUE=$(echo "$PL_PR_TITLE" | grep -oP '#\K\d+' | tail -1 || true)
fi
if [ -z "$PL_ISSUE" ]; then
PL_ISSUE=$(echo "$PL_PR_BODY" | grep -oiP '(?:closes|fixes|resolves)\s*#\K\d+' | head -1 || true)
fi
if [ -z "$PL_ISSUE" ]; then if [ -z "$PL_ISSUE" ]; then
# Allow chore PRs from gardener/planner/predictor to merge without issue number # Allow chore PRs from gardener/planner/predictor to merge without issue number
if [[ "$PL_PR_BRANCH" =~ ^chore/(gardener|planner|predictor)- ]]; then if [[ "$PL_PR_BRANCH" =~ ^chore/(gardener|planner|predictor)- ]]; then
@ -290,7 +474,7 @@ if [ "$PL_MERGED_ANY" = true ]; then
fi fi
log "pre-lock: no PRs merged, checking agent lock" log "pre-lock: no PRs merged, checking agent lock"
# --- Check if dev-agent already running (PID lockfile) --- # --- Check if dev-agent already running ---
if [ -f "$LOCKFILE" ]; then if [ -f "$LOCKFILE" ]; then
LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || echo "") LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || echo "")
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
@ -303,6 +487,61 @@ fi
# --- Memory guard --- # --- Memory guard ---
memory_guard 2000 memory_guard 2000
# =============================================================================
# HELPER: check if a dependency issue is fully resolved (closed + PR merged)
# =============================================================================
dep_is_merged() {
local dep_num="$1"
# Check issue is closed
local dep_state
dep_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/issues/${dep_num}" | jq -r '.state // "open"')
if [ "$dep_state" != "closed" ]; then
return 1
fi
# Issue closed = dep satisfied. The scheduler only closes issues after
# merging, so closed state is trustworthy. No need to hunt for the
# specific PR — that was over-engineering that caused false negatives.
return 0
}
# =============================================================================
# HELPER: extract dependency numbers from issue body
# =============================================================================
get_deps() {
local issue_body="$1"
# Shared parser: lib/parse-deps.sh (single source of truth)
echo "$issue_body" | bash "${FACTORY_ROOT}/lib/parse-deps.sh"
}
# =============================================================================
# HELPER: check if issue is ready (all deps merged)
# =============================================================================
issue_is_ready() {
local issue_num="$1"
local issue_body="$2"
local deps
deps=$(get_deps "$issue_body")
if [ -z "$deps" ]; then
# No dependencies — always ready
return 0
fi
while IFS= read -r dep; do
[ -z "$dep" ] && continue
if ! dep_is_merged "$dep"; then
log " #${issue_num} blocked: dep #${dep} not merged"
return 1
fi
done <<< "$deps"
return 0
}
# ============================================================================= # =============================================================================
# PRIORITY 1: orphaned in-progress issues # PRIORITY 1: orphaned in-progress issues
# ============================================================================= # =============================================================================
@ -355,20 +594,35 @@ if [ "$ORPHAN_COUNT" -gt 0 ]; then
exit 0 exit 0
fi fi
# Direct merge failed (conflicts?) — fall back to dev-agent # Direct merge failed (conflicts?) — fall back to dev-agent
log "falling back to dev-agent for PR #${HAS_PR} merge" SESSION_NAME="dev-${PROJECT_NAME}-${ISSUE_NUM}"
nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 & if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
log "started dev-agent PID $! for issue #${ISSUE_NUM} (agent-merge)" handle_active_session "$SESSION_NAME" "$ISSUE_NUM" "$HAS_PR"
else
log "falling back to dev-agent for PR #${HAS_PR} merge"
nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 &
log "started dev-agent PID $! for issue #${ISSUE_NUM} (agent-merge)"
fi
exit 0 exit 0
# Do NOT gate REQUEST_CHANGES on ci_passed: act immediately even if CI is # Do NOT gate REQUEST_CHANGES on ci_passed: act immediately even if CI is
# pending/unknown. Definitive CI failure is handled by the elif below. # pending/unknown. Definitive CI failure is handled by the elif below.
elif [ "${HAS_CHANGES:-0}" -gt 0 ] && { ci_passed "$CI_STATE" || [ "$CI_STATE" = "pending" ] || [ "$CI_STATE" = "unknown" ] || [ -z "$CI_STATE" ]; }; then elif [ "${HAS_CHANGES:-0}" -gt 0 ] && { ci_passed "$CI_STATE" || [ "$CI_STATE" = "pending" ] || [ "$CI_STATE" = "unknown" ] || [ -z "$CI_STATE" ]; }; then
log "issue #${ISSUE_NUM} PR #${HAS_PR} has REQUEST_CHANGES — spawning agent" SESSION_NAME="dev-${PROJECT_NAME}-${ISSUE_NUM}"
nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 & if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
log "started dev-agent PID $! for issue #${ISSUE_NUM} (review fix)" handle_active_session "$SESSION_NAME" "$ISSUE_NUM" "$HAS_PR"
else
log "issue #${ISSUE_NUM} PR #${HAS_PR} has REQUEST_CHANGES — spawning agent"
nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 &
log "started dev-agent PID $! for issue #${ISSUE_NUM} (review fix)"
fi
exit 0 exit 0
elif ci_failed "$CI_STATE"; then elif ci_failed "$CI_STATE"; then
SESSION_NAME="dev-${PROJECT_NAME}-${ISSUE_NUM}"
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
handle_active_session "$SESSION_NAME" "$ISSUE_NUM" "$HAS_PR"
exit 0
fi
if handle_ci_exhaustion "$HAS_PR" "$ISSUE_NUM" "check_only"; then if handle_ci_exhaustion "$HAS_PR" "$ISSUE_NUM" "check_only"; then
# Fall through to backlog scan instead of exit # Fall through to backlog scan instead of exit
: :
@ -387,9 +641,14 @@ if [ "$ORPHAN_COUNT" -gt 0 ]; then
log "issue #${ISSUE_NUM} has open PR #${HAS_PR} (CI: ${CI_STATE}, waiting)" log "issue #${ISSUE_NUM} has open PR #${HAS_PR} (CI: ${CI_STATE}, waiting)"
fi fi
else else
log "recovering orphaned issue #${ISSUE_NUM} (no PR found)" SESSION_NAME="dev-${PROJECT_NAME}-${ISSUE_NUM}"
nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 & if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
log "started dev-agent PID $! for issue #${ISSUE_NUM} (recovery)" handle_active_session "$SESSION_NAME" "$ISSUE_NUM" ""
else
log "recovering orphaned issue #${ISSUE_NUM} (no PR found)"
nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 &
log "started dev-agent PID $! for issue #${ISSUE_NUM} (recovery)"
fi
exit 0 exit 0
fi fi
fi fi
@ -405,10 +664,17 @@ for i in $(seq 0 $(($(echo "$OPEN_PRS" | jq 'length') - 1))); do
PR_NUM=$(echo "$OPEN_PRS" | jq -r ".[$i].number") PR_NUM=$(echo "$OPEN_PRS" | jq -r ".[$i].number")
PR_BRANCH=$(echo "$OPEN_PRS" | jq -r ".[$i].head.ref") PR_BRANCH=$(echo "$OPEN_PRS" | jq -r ".[$i].head.ref")
PR_SHA=$(echo "$OPEN_PRS" | jq -r ".[$i].head.sha") PR_SHA=$(echo "$OPEN_PRS" | jq -r ".[$i].head.sha")
# Extract issue number from branch name (fix/issue-NNN), PR title (#NNN), or PR body (Closes #NNN)
PR_TITLE=$(echo "$OPEN_PRS" | jq -r ".[$i].title") PR_TITLE=$(echo "$OPEN_PRS" | jq -r ".[$i].title")
PR_BODY=$(echo "$OPEN_PRS" | jq -r ".[$i].body // \"\"") PR_BODY=$(echo "$OPEN_PRS" | jq -r ".[$i].body // \"\"")
STUCK_ISSUE=$(echo "$PR_BRANCH" | grep -oP '(?<=fix/issue-)\d+' || true)
STUCK_ISSUE=$(extract_issue_from_pr "$PR_BRANCH" "$PR_TITLE" "$PR_BODY") if [ -z "$STUCK_ISSUE" ]; then
STUCK_ISSUE=$(echo "$PR_TITLE" | grep -oP '#\K\d+' | tail -1 || true)
fi
if [ -z "$STUCK_ISSUE" ]; then
STUCK_ISSUE=$(echo "$PR_BODY" | grep -oiP '(?:closes|fixes|resolves)\s*#\K\d+' | head -1 || true)
fi
if [ -z "$STUCK_ISSUE" ]; then if [ -z "$STUCK_ISSUE" ]; then
# Allow chore PRs from gardener/planner/predictor to merge without issue number # Allow chore PRs from gardener/planner/predictor to merge without issue number
if [[ "$PR_BRANCH" =~ ^chore/(gardener|planner|predictor)- ]]; then if [[ "$PR_BRANCH" =~ ^chore/(gardener|planner|predictor)- ]]; then
@ -446,9 +712,14 @@ for i in $(seq 0 $(($(echo "$OPEN_PRS" | jq 'length') - 1))); do
continue continue
fi fi
# Direct merge failed (conflicts?) — fall back to dev-agent # Direct merge failed (conflicts?) — fall back to dev-agent
log "falling back to dev-agent for PR #${PR_NUM} merge" SESSION_NAME="dev-${PROJECT_NAME}-${STUCK_ISSUE}"
nohup "${SCRIPT_DIR}/dev-agent.sh" "$STUCK_ISSUE" >> "$LOGFILE" 2>&1 & if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
log "started dev-agent PID $! for stuck PR #${PR_NUM} (agent-merge)" handle_active_session "$SESSION_NAME" "$STUCK_ISSUE" "$PR_NUM"
else
log "falling back to dev-agent for PR #${PR_NUM} merge"
nohup "${SCRIPT_DIR}/dev-agent.sh" "$STUCK_ISSUE" >> "$LOGFILE" 2>&1 &
log "started dev-agent PID $! for stuck PR #${PR_NUM} (agent-merge)"
fi
exit 0 exit 0
fi fi
@ -457,13 +728,27 @@ for i in $(seq 0 $(($(echo "$OPEN_PRS" | jq 'length') - 1))); do
continue continue
fi fi
# Stuck: REQUEST_CHANGES or CI failure -> spawn agent # Stuck: REQUEST_CHANGES or CI failure → spawn agent
# Do NOT gate REQUEST_CHANGES on ci_passed: if a reviewer leaves REQUEST_CHANGES
# while CI is still pending/unknown, we must act immediately rather than wait for
# CI to settle. Definitive CI failure (non-pending, non-unknown) is handled by
# the elif below, so we only spawn here when CI has not definitively failed.
if [ "${HAS_CHANGES:-0}" -gt 0 ] && { ci_passed "$CI_STATE" || [ "$CI_STATE" = "pending" ] || [ "$CI_STATE" = "unknown" ] || [ -z "$CI_STATE" ]; }; then if [ "${HAS_CHANGES:-0}" -gt 0 ] && { ci_passed "$CI_STATE" || [ "$CI_STATE" = "pending" ] || [ "$CI_STATE" = "unknown" ] || [ -z "$CI_STATE" ]; }; then
SESSION_NAME="dev-${PROJECT_NAME}-${STUCK_ISSUE}"
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
handle_active_session "$SESSION_NAME" "$STUCK_ISSUE" "$PR_NUM"
continue
fi
log "PR #${PR_NUM} (issue #${STUCK_ISSUE}) has REQUEST_CHANGES — fixing first" log "PR #${PR_NUM} (issue #${STUCK_ISSUE}) has REQUEST_CHANGES — fixing first"
nohup "${SCRIPT_DIR}/dev-agent.sh" "$STUCK_ISSUE" >> "$LOGFILE" 2>&1 & nohup "${SCRIPT_DIR}/dev-agent.sh" "$STUCK_ISSUE" >> "$LOGFILE" 2>&1 &
log "started dev-agent PID $! for stuck PR #${PR_NUM}" log "started dev-agent PID $! for stuck PR #${PR_NUM}"
exit 0 exit 0
elif ci_failed "$CI_STATE"; then elif ci_failed "$CI_STATE"; then
SESSION_NAME="dev-${PROJECT_NAME}-${STUCK_ISSUE}"
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
handle_active_session "$SESSION_NAME" "$STUCK_ISSUE" "$PR_NUM"
continue
fi
if handle_ci_exhaustion "$PR_NUM" "$STUCK_ISSUE" "check_only"; then if handle_ci_exhaustion "$PR_NUM" "$STUCK_ISSUE" "check_only"; then
continue # skip this PR, check next stuck PR or fall through to backlog continue # skip this PR, check next stuck PR or fall through to backlog
fi fi
@ -515,13 +800,12 @@ log "found ${BACKLOG_COUNT} backlog issues (${PRIORITY_COUNT} priority)"
# Check each for readiness # Check each for readiness
READY_ISSUE="" READY_ISSUE=""
READY_PR_FOR_INCREMENT=""
WAITING_PRS=""
for i in $(seq 0 $((BACKLOG_COUNT - 1))); do for i in $(seq 0 $((BACKLOG_COUNT - 1))); do
ISSUE_NUM=$(echo "$BACKLOG_JSON" | jq -r ".[$i].number") ISSUE_NUM=$(echo "$BACKLOG_JSON" | jq -r ".[$i].number")
ISSUE_BODY=$(echo "$BACKLOG_JSON" | jq -r ".[$i].body // \"\"") ISSUE_BODY=$(echo "$BACKLOG_JSON" | jq -r ".[$i].body // \"\"")
# Formula guard: formula-labeled issues must not be picked up by dev-agent. # Formula guard: formula-labeled issues must not be picked up by dev-agent.
# A formula issue that accidentally acquires the backlog label should be skipped.
ISSUE_LABELS=$(echo "$BACKLOG_JSON" | jq -r ".[$i].labels[].name" 2>/dev/null) || true ISSUE_LABELS=$(echo "$BACKLOG_JSON" | jq -r ".[$i].labels[].name" 2>/dev/null) || true
SKIP_LABEL=$(echo "$ISSUE_LABELS" | grep -oE '^(formula|action|prediction/dismissed|prediction/unreviewed)$' | head -1) || true SKIP_LABEL=$(echo "$ISSUE_LABELS" | grep -oE '^(formula|action|prediction/dismissed|prediction/unreviewed)$' | head -1) || true
if [ -n "$SKIP_LABEL" ]; then if [ -n "$SKIP_LABEL" ]; then
@ -611,6 +895,9 @@ fi
# LAUNCH: start dev-agent for the ready issue # LAUNCH: start dev-agent for the ready issue
# ============================================================================= # =============================================================================
# Deferred CI fix increment — only now that we're certain we are launching. # Deferred CI fix increment — only now that we're certain we are launching.
# Uses the atomic ci_fix_check_and_increment (inside handle_ci_exhaustion) so
# the counter is bumped exactly once even under concurrent poll invocations,
# and a WAITING_PRS exit above cannot silently consume a fix attempt.
if [ -n "${READY_PR_FOR_INCREMENT:-}" ]; then if [ -n "${READY_PR_FOR_INCREMENT:-}" ]; then
if handle_ci_exhaustion "$READY_PR_FOR_INCREMENT" "$READY_ISSUE"; then if handle_ci_exhaustion "$READY_PR_FOR_INCREMENT" "$READY_ISSUE"; then
# exhausted (another poller incremented between scan and launch) — bail out # exhausted (another poller incremented between scan and launch) — bail out
@ -619,5 +906,52 @@ if [ -n "${READY_PR_FOR_INCREMENT:-}" ]; then
fi fi
log "launching dev-agent for #${READY_ISSUE}" log "launching dev-agent for #${READY_ISSUE}"
rm -f "$PREFLIGHT_RESULT"
nohup "${SCRIPT_DIR}/dev-agent.sh" "$READY_ISSUE" >> "$LOGFILE" 2>&1 & nohup "${SCRIPT_DIR}/dev-agent.sh" "$READY_ISSUE" >> "$LOGFILE" 2>&1 &
log "started dev-agent PID $! for issue #${READY_ISSUE}" AGENT_PID=$!
# Wait briefly for preflight (agent writes result before claiming)
for _w in $(seq 1 30); do
if [ -f "$PREFLIGHT_RESULT" ]; then
break
fi
if ! kill -0 "$AGENT_PID" 2>/dev/null; then
break
fi
sleep 2
done
if [ -f "$PREFLIGHT_RESULT" ]; then
PREFLIGHT_STATUS=$(jq -r '.status // "unknown"' < "$PREFLIGHT_RESULT")
rm -f "$PREFLIGHT_RESULT"
case "$PREFLIGHT_STATUS" in
ready)
log "dev-agent running for #${READY_ISSUE}"
;;
unmet_dependency)
log "#${READY_ISSUE} has code-level dependency (preflight blocked)"
wait "$AGENT_PID" 2>/dev/null || true
;;
too_large)
REASON=$(jq -r '.reason // "unspecified"' < "$PREFLIGHT_RESULT" 2>/dev/null || echo "unspecified")
log "#${READY_ISSUE} too large: ${REASON}"
# Label as underspecified
curl -sf -X POST -H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${READY_ISSUE}/labels" \
-d "{\"labels\":[${UNDERSPECIFIED_LABEL_ID}]}" >/dev/null 2>&1 || true
;;
already_done)
log "#${READY_ISSUE} already done"
;;
*)
log "#${READY_ISSUE} unknown preflight: ${PREFLIGHT_STATUS}"
;;
esac
elif kill -0 "$AGENT_PID" 2>/dev/null; then
log "dev-agent running for #${READY_ISSUE} (passed preflight)"
else
log "dev-agent exited for #${READY_ISSUE} without preflight result"
fi

View file

@ -1,38 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nothing shipped yet</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
font-size: 3rem;
margin: 0 0 1rem 0;
}
p {
font-size: 1.25rem;
opacity: 0.9;
}
</style>
</head>
<body>
<div class="container">
<h1>Nothing shipped yet</h1>
<p>CI pipelines will update this page with your staging artifacts.</p>
</div>
</body>
</html>

View file

@ -1,18 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================= # =============================================================================
# gardener-run.sh — Cron wrapper: gardener execution via SDK + formula # gardener-run.sh — Cron wrapper: gardener execution via Claude + formula
# #
# Synchronous bash loop using claude -p (one-shot invocation). # Runs 4x/day (or on-demand). Guards against concurrent runs and low memory.
# No tmux sessions, no phase files — the bash script IS the state machine. # Creates a tmux session with Claude (sonnet) reading formulas/run-gardener.toml.
# # No action issues — the gardener is a nervous system component, not work (AD-001).
# Flow:
# 1. Guards: cron 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: # Usage:
# gardener-run.sh [projects/disinto.toml] # project config (default: disinto) # gardener-run.sh [projects/disinto.toml] # project config (default: disinto)
@ -30,6 +22,8 @@ export PROJECT_TOML="${1:-$FACTORY_ROOT/projects/disinto.toml}"
source "$FACTORY_ROOT/lib/env.sh" source "$FACTORY_ROOT/lib/env.sh"
# Use gardener-bot's own Forgejo identity (#747) # Use gardener-bot's own Forgejo identity (#747)
FORGE_TOKEN="${FORGE_GARDENER_TOKEN:-${FORGE_TOKEN}}" FORGE_TOKEN="${FORGE_GARDENER_TOKEN:-${FORGE_TOKEN}}"
# shellcheck source=../lib/agent-session.sh
source "$FACTORY_ROOT/lib/agent-session.sh"
# shellcheck source=../lib/formula-session.sh # shellcheck source=../lib/formula-session.sh
source "$FACTORY_ROOT/lib/formula-session.sh" source "$FACTORY_ROOT/lib/formula-session.sh"
# shellcheck source=../lib/worktree.sh # shellcheck source=../lib/worktree.sh
@ -40,20 +34,26 @@ source "$FACTORY_ROOT/lib/ci-helpers.sh"
source "$FACTORY_ROOT/lib/mirrors.sh" source "$FACTORY_ROOT/lib/mirrors.sh"
# shellcheck source=../lib/guard.sh # shellcheck source=../lib/guard.sh
source "$FACTORY_ROOT/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="$SCRIPT_DIR/gardener.log" LOG_FILE="$SCRIPT_DIR/gardener.log"
# shellcheck disable=SC2034 # consumed by agent-sdk.sh # shellcheck disable=SC2034 # consumed by run_formula_and_monitor
LOGFILE="$LOG_FILE" SESSION_NAME="gardener-${PROJECT_NAME}"
# shellcheck disable=SC2034 # consumed by agent-sdk.sh PHASE_FILE="/tmp/gardener-session-${PROJECT_NAME}.phase"
SID_FILE="/tmp/gardener-session-${PROJECT_NAME}.sid"
# shellcheck disable=SC2034 # read by monitor_phase_loop in lib/agent-session.sh
PHASE_POLL_INTERVAL=15
SCRATCH_FILE="/tmp/gardener-${PROJECT_NAME}-scratch.md" SCRATCH_FILE="/tmp/gardener-${PROJECT_NAME}-scratch.md"
RESULT_FILE="/tmp/gardener-result-${PROJECT_NAME}.txt" RESULT_FILE="/tmp/gardener-result-${PROJECT_NAME}.txt"
GARDENER_PR_FILE="/tmp/gardener-pr-${PROJECT_NAME}.txt" GARDENER_PR_FILE="/tmp/gardener-pr-${PROJECT_NAME}.txt"
WORKTREE="/tmp/${PROJECT_NAME}-gardener-run"
# Merge-through state (used by _gardener_on_phase_change callback)
_GARDENER_PR=""
_GARDENER_MERGE_START=0
_GARDENER_MERGE_TIMEOUT=1800 # 30 min
_GARDENER_CI_FIX_COUNT=0
_GARDENER_REVIEW_ROUND=0
_GARDENER_CRASH_COUNT=0
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; } log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
@ -72,7 +72,7 @@ build_context_block AGENTS.md
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE") SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE") SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
# ── Build prompt ───────────────────────────────────────────────────────── # ── Build prompt (manifest format reference for deferred actions) ─────────
GARDENER_API_EXTRA=" GARDENER_API_EXTRA="
## Pending-actions manifest (REQUIRED) ## Pending-actions manifest (REQUIRED)
@ -91,18 +91,28 @@ Supported actions:
The commit-and-pr step converts JSONL to JSON array. The orchestrator executes 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." actions after the PR merges. Do NOT call mutation APIs directly during the run."
# Reuse shared footer (API reference + environment), replace phase protocol
# shellcheck disable=SC2034 # consumed by build_prompt_footer
PHASE_FILE="" # not used in SDK mode
build_prompt_footer "$GARDENER_API_EXTRA" build_prompt_footer "$GARDENER_API_EXTRA"
PROMPT_FOOTER="${PROMPT_FOOTER%%## Phase protocol*}## 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. # Extend phase protocol with merge-through instructions for compaction survival
PROMPT_FOOTER="${PROMPT_FOOTER}
## Merge-through protocol (commit-and-pr step)
After creating the PR, write the PR number and signal CI:
echo \"\$PR_NUMBER\" > '${GARDENER_PR_FILE}'
echo 'PHASE:awaiting_ci' > '${PHASE_FILE}'
Then STOP and WAIT for CI results.
When 'CI passed' is injected:
echo 'PHASE:awaiting_review' > '${PHASE_FILE}'
Then STOP and WAIT.
When 'CI failed' is injected:
Fix, commit, push, then: echo 'PHASE:awaiting_ci' > '${PHASE_FILE}'
When review feedback is injected:
Address all feedback, commit, push, then: echo 'PHASE:awaiting_ci' > '${PHASE_FILE}'
If no file changes in commit-and-pr:
echo 'PHASE:done' > '${PHASE_FILE}'"
# shellcheck disable=SC2034 # consumed by run_formula_and_monitor
PROMPT="You are the issue gardener for ${FORGE_REPO}. Work through the formula below. Follow the phase protocol: if the commit-and-pr step creates a PR, write PHASE:awaiting_ci and wait for orchestrator CI/review/merge handling. If no file changes, write PHASE:done. The orchestrator will time you out if you return to the prompt without signalling.
You have full shell access and --dangerously-skip-permissions. 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. Fix what you can. File vault items for what you cannot. Do NOT ask permission — act first, report after.
@ -120,21 +130,14 @@ ${FORMULA_CONTENT}
${SCRATCH_INSTRUCTION} ${SCRATCH_INSTRUCTION}
${PROMPT_FOOTER}" ${PROMPT_FOOTER}"
# ── Create worktree ────────────────────────────────────────────────────── # ── Phase callback for merge-through ─────────────────────────────────────
cd "$PROJECT_REPO_ROOT" # Handles CI polling, review injection, merge, and cleanup after PR creation.
git fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true # Lighter than dev/phase-handler.sh — tailored for gardener doc-only PRs.
worktree_cleanup "$WORKTREE"
git worktree add "$WORKTREE" "origin/${PRIMARY_BRANCH}" --detach 2>/dev/null
cleanup() { # ── Post-merge manifest execution ─────────────────────────────────────
worktree_cleanup "$WORKTREE"
rm -f "$GARDENER_PR_FILE"
}
trap cleanup EXIT
# ── Post-merge manifest execution ────────────────────────────────────────
# Reads gardener/pending-actions.json and executes each action via API. # Reads gardener/pending-actions.json and executes each action via API.
# Failed actions are logged but do not block completion. # Failed actions are logged but do not block completion.
# shellcheck disable=SC2317 # called indirectly via _gardener_merge
_gardener_execute_manifest() { _gardener_execute_manifest() {
local manifest_file="$PROJECT_REPO_ROOT/gardener/pending-actions.json" local manifest_file="$PROJECT_REPO_ROOT/gardener/pending-actions.json"
if [ ! -f "$manifest_file" ]; then if [ ! -f "$manifest_file" ]; then
@ -292,50 +295,387 @@ _gardener_execute_manifest() {
log "manifest: execution complete (${count} actions processed)" log "manifest: execution complete (${count} actions processed)"
} }
# ── Reset result file ──────────────────────────────────────────────────── # shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
rm -f "$RESULT_FILE" "$GARDENER_PR_FILE" _gardener_merge() {
touch "$RESULT_FILE" local merge_response merge_http_code
merge_response=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \
"${FORGE_API}/pulls/${_GARDENER_PR}/merge" \
-d '{"Do":"merge","delete_branch_after_merge":true}') || true
merge_http_code=$(echo "$merge_response" | tail -1)
# ── Run agent ───────────────────────────────────────────────────────────── if [ "$merge_http_code" = "200" ] || [ "$merge_http_code" = "204" ]; then
export CLAUDE_MODEL="sonnet" log "gardener PR #${_GARDENER_PR} merged"
# Pull merged primary branch and push to mirrors
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 origin "$PRIMARY_BRANCH" 2>/dev/null || true git -C "$PROJECT_REPO_ROOT" fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true
git -C "$PROJECT_REPO_ROOT" checkout "$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 origin "$PRIMARY_BRANCH" 2>/dev/null || true git -C "$PROJECT_REPO_ROOT" pull --ff-only origin "$PRIMARY_BRANCH" 2>/dev/null || true
mirror_push mirror_push
_gardener_execute_manifest _gardener_execute_manifest
rm -f "$SCRATCH_FILE" printf 'PHASE:done\n' > "$PHASE_FILE"
log "gardener PR #${PR_NUMBER} merged — manifest executed" return 0
else
log "PR #${PR_NUMBER} not merged (reason: ${_PR_WALK_EXIT_REASON:-unknown})"
fi fi
else
log "no PR created — gardener run complete" # Already merged (race)?
if [ "$merge_http_code" = "405" ]; then
local pr_merged
pr_merged=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.merged // false') || true
if [ "$pr_merged" = "true" ]; then
log "gardener PR #${_GARDENER_PR} already merged"
# Pull merged primary branch and push to mirrors
git -C "$PROJECT_REPO_ROOT" fetch origin "$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 origin "$PRIMARY_BRANCH" 2>/dev/null || true
mirror_push
_gardener_execute_manifest
printf 'PHASE:done\n' > "$PHASE_FILE"
return 0
fi
log "gardener merge blocked (HTTP 405)"
printf 'PHASE:failed\nReason: gardener PR #%s merge blocked (HTTP 405)\n' \
"$_GARDENER_PR" > "$PHASE_FILE"
return 0
fi
# Other failure (likely conflicts) — tell Claude to rebase
log "gardener merge failed (HTTP ${merge_http_code}) — requesting rebase"
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
"Merge failed for PR #${_GARDENER_PR} (likely conflicts). Rebase and push:
git fetch origin ${PRIMARY_BRANCH} && git rebase origin/${PRIMARY_BRANCH}
git push --force-with-lease origin HEAD
echo \"PHASE:awaiting_ci\" > \"${PHASE_FILE}\"
If rebase fails, write PHASE:failed with a reason."
}
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
_gardener_timeout_cleanup() {
log "gardener merge-through timed out (${_GARDENER_MERGE_TIMEOUT}s) — closing PR"
if [ -n "$_GARDENER_PR" ]; then
curl -sf -X PATCH \
-H "Authorization: token ${FORGE_TOKEN}" \
-H 'Content-Type: application/json' \
"${FORGE_API}/pulls/${_GARDENER_PR}" \
-d '{"state":"closed"}' >/dev/null 2>&1 || true
fi
printf 'PHASE:failed\nReason: merge-through timeout (%ss)\n' \
"$_GARDENER_MERGE_TIMEOUT" > "$PHASE_FILE"
}
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
_gardener_handle_ci() {
# Start merge-through timer on first CI phase
if [ "$_GARDENER_MERGE_START" -eq 0 ]; then
_GARDENER_MERGE_START=$(date +%s)
fi
# Check merge-through timeout
local elapsed
elapsed=$(( $(date +%s) - _GARDENER_MERGE_START ))
if [ "$elapsed" -ge "$_GARDENER_MERGE_TIMEOUT" ]; then
_gardener_timeout_cleanup
return 0
fi
# Discover PR number if unknown
if [ -z "$_GARDENER_PR" ]; then
if [ -f "$GARDENER_PR_FILE" ]; then
_GARDENER_PR=$(tr -d '[:space:]' < "$GARDENER_PR_FILE")
fi
# Fallback: search for open gardener PRs
if [ -z "$_GARDENER_PR" ]; then
_GARDENER_PR=$(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
if [ -z "$_GARDENER_PR" ]; then
log "ERROR: cannot find gardener PR"
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
"ERROR: Could not find the gardener PR. Verify branch was pushed and PR created. Write the PR number to ${GARDENER_PR_FILE}, then write PHASE:awaiting_ci again."
return 0
fi
log "tracking gardener PR #${_GARDENER_PR}"
fi
# Skip CI for doc-only PRs
if ! ci_required_for_pr "$_GARDENER_PR" 2>/dev/null; then
log "CI not required (doc-only) — treating as passed"
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
"CI passed on PR #${_GARDENER_PR} (doc-only changes, CI not required).
Write PHASE:awaiting_review to the phase file, then stop and wait:
echo \"PHASE:awaiting_review\" > \"${PHASE_FILE}\""
return 0
fi
# No CI configured?
if [ "${WOODPECKER_REPO_ID:-2}" = "0" ]; then
log "no CI configured — treating as passed"
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
"CI passed on PR #${_GARDENER_PR} (no CI configured).
Write PHASE:awaiting_review to the phase file, then stop and wait:
echo \"PHASE:awaiting_review\" > \"${PHASE_FILE}\""
return 0
fi
# Get HEAD SHA from PR
local head_sha
head_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true
if [ -z "$head_sha" ]; then
log "WARNING: could not get HEAD SHA for PR #${_GARDENER_PR}"
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
"WARNING: Could not read HEAD SHA for PR #${_GARDENER_PR}. Verify push succeeded. Then write PHASE:awaiting_ci again."
return 0
fi
# Poll CI (15 min max within this phase)
local ci_done=false ci_state="unknown" ci_elapsed=0 ci_timeout=900
while [ "$ci_elapsed" -lt "$ci_timeout" ]; do
sleep 30
ci_elapsed=$((ci_elapsed + 30))
# Session health check
if [ -f "/tmp/claude-exited-${_MONITOR_SESSION:-$SESSION_NAME}.ts" ] || \
! tmux has-session -t "${_MONITOR_SESSION:-$SESSION_NAME}" 2>/dev/null; then
log "session died during CI wait"
return 0
fi
# Merge-through timeout check
elapsed=$(( $(date +%s) - _GARDENER_MERGE_START ))
if [ "$elapsed" -ge "$_GARDENER_MERGE_TIMEOUT" ]; then
_gardener_timeout_cleanup
return 0
fi
# Re-fetch HEAD in case Claude pushed new commits
head_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true
ci_state=$(ci_commit_status "$head_sha") || ci_state="unknown"
case "$ci_state" in
success|failure|error) ci_done=true; break ;;
esac
done
if ! $ci_done; then
log "CI timeout for PR #${_GARDENER_PR}"
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
"CI TIMEOUT: CI did not complete within 15 minutes for PR #${_GARDENER_PR}. Write PHASE:failed with a reason if you cannot proceed."
return 0
fi
log "CI: ${ci_state} for PR #${_GARDENER_PR}"
if [ "$ci_state" = "success" ]; then
_GARDENER_CI_FIX_COUNT=0
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
"CI passed on PR #${_GARDENER_PR}.
Write PHASE:awaiting_review to the phase file, then stop and wait:
echo \"PHASE:awaiting_review\" > \"${PHASE_FILE}\""
else
_GARDENER_CI_FIX_COUNT=$(( _GARDENER_CI_FIX_COUNT + 1 ))
if [ "$_GARDENER_CI_FIX_COUNT" -gt 3 ]; then
log "CI exhausted after ${_GARDENER_CI_FIX_COUNT} attempts"
printf 'PHASE:failed\nReason: gardener CI exhausted after %d attempts\n' \
"$_GARDENER_CI_FIX_COUNT" > "$PHASE_FILE"
return 0
fi
# Get error details
local pipeline_num ci_error_log
pipeline_num=$(ci_pipeline_number "$head_sha")
ci_error_log=""
if [ -n "$pipeline_num" ]; then
ci_error_log=$(bash "${FACTORY_ROOT}/lib/ci-debug.sh" failures "$pipeline_num" 2>/dev/null \
| tail -80 | head -c 8000 || true)
fi
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
"CI failed on PR #${_GARDENER_PR} (attempt ${_GARDENER_CI_FIX_COUNT}/3).
${ci_error_log:+Error output:
${ci_error_log}
}Fix the issue, commit, push, then write:
echo \"PHASE:awaiting_ci\" > \"${PHASE_FILE}\"
Then stop and wait."
fi
}
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
_gardener_handle_review() {
log "waiting for review on PR #${_GARDENER_PR:-?}"
_GARDENER_CI_FIX_COUNT=0 # Reset CI fix budget for next review cycle
local review_elapsed=0 review_timeout=1800
while [ "$review_elapsed" -lt "$review_timeout" ]; do
sleep 60 # 1 min between review checks (gardener PRs are fast-tracked)
review_elapsed=$((review_elapsed + 60))
# Session health check
if [ -f "/tmp/claude-exited-${_MONITOR_SESSION:-$SESSION_NAME}.ts" ] || \
! tmux has-session -t "${_MONITOR_SESSION:-$SESSION_NAME}" 2>/dev/null; then
log "session died during review wait"
return 0
fi
# Merge-through timeout check
local elapsed
elapsed=$(( $(date +%s) - _GARDENER_MERGE_START ))
if [ "$elapsed" -ge "$_GARDENER_MERGE_TIMEOUT" ]; then
_gardener_timeout_cleanup
return 0
fi
# Check if phase changed while we wait (e.g. review-poll injected feedback)
local new_mtime
new_mtime=$(stat -c %Y "$PHASE_FILE" 2>/dev/null || echo 0)
if [ "$new_mtime" -gt "${LAST_PHASE_MTIME:-0}" ]; then
log "phase changed during review wait — returning to monitor loop"
return 0
fi
# Check for review on current HEAD
local review_sha review_comment
review_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true
review_comment=$(forge_api_all "/issues/${_GARDENER_PR}/comments" 2>/dev/null | \
jq -r --arg sha "${review_sha:-none}" \
'[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty') || true
if [ -n "$review_comment" ] && [ "$review_comment" != "null" ]; then
local review_text verdict
review_text=$(echo "$review_comment" | jq -r '.body')
# Skip error reviews
if echo "$review_text" | grep -q "review-error\|Review — Error"; then
continue
fi
verdict=$(echo "$review_text" | grep -oP '\*\*(APPROVE|REQUEST_CHANGES|DISCUSS)\*\*' | head -1 | tr -d '*' || true)
# Check formal forge reviews as fallback
if [ -z "$verdict" ]; then
verdict=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/pulls/${_GARDENER_PR}/reviews" | \
jq -r '[.[] | select(.stale == false)] | last | .state // empty' || true)
[ "$verdict" = "APPROVED" ] && verdict="APPROVE"
[[ "$verdict" != "REQUEST_CHANGES" && "$verdict" != "APPROVE" ]] && verdict=""
fi
# Check review-poll sentinel to avoid double injection
local review_sentinel="/tmp/review-injected-${PROJECT_NAME}-${_GARDENER_PR}"
if [ -n "$verdict" ] && [ -f "$review_sentinel" ] && [ "$verdict" != "APPROVE" ]; then
log "review already injected by review-poll — skipping"
rm -f "$review_sentinel"
break
fi
rm -f "$review_sentinel"
if [ "$verdict" = "APPROVE" ]; then
log "gardener PR #${_GARDENER_PR} approved — merging"
_gardener_merge
return 0
elif [ "$verdict" = "REQUEST_CHANGES" ] || [ "$verdict" = "DISCUSS" ]; then
_GARDENER_REVIEW_ROUND=$(( _GARDENER_REVIEW_ROUND + 1 ))
log "review REQUEST_CHANGES on PR #${_GARDENER_PR} (round ${_GARDENER_REVIEW_ROUND})"
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
"Review feedback on PR #${_GARDENER_PR} (round ${_GARDENER_REVIEW_ROUND}):
${review_text}
Address all feedback, commit, push, then write:
echo \"PHASE:awaiting_ci\" > \"${PHASE_FILE}\"
Then stop and wait."
return 0
fi
fi
# Check if PR was merged or closed externally
local pr_json pr_state pr_merged
pr_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_API}/pulls/${_GARDENER_PR}") || true
pr_state=$(echo "$pr_json" | jq -r '.state // "unknown"')
pr_merged=$(echo "$pr_json" | jq -r '.merged // false')
if [ "$pr_merged" = "true" ]; then
log "gardener PR #${_GARDENER_PR} merged externally"
_gardener_execute_manifest
printf 'PHASE:done\n' > "$PHASE_FILE"
return 0
fi
if [ "$pr_state" != "open" ]; then
log "gardener PR #${_GARDENER_PR} closed without merge"
printf 'PHASE:failed\nReason: PR closed without merge\n' > "$PHASE_FILE"
return 0
fi
log "waiting for review on PR #${_GARDENER_PR} (${review_elapsed}s)"
done
if [ "$review_elapsed" -ge "$review_timeout" ]; then
log "review wait timed out for PR #${_GARDENER_PR}"
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
"No review received after ${review_timeout}s for PR #${_GARDENER_PR}. Write PHASE:failed with a reason if you cannot proceed."
fi
}
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
_gardener_on_phase_change() {
local phase="$1"
log "phase: ${phase}"
case "$phase" in
PHASE:awaiting_ci)
_gardener_handle_ci
;;
PHASE:awaiting_review)
_gardener_handle_review
;;
PHASE:done|PHASE:merged)
agent_kill_session "${_MONITOR_SESSION:-$SESSION_NAME}"
;;
PHASE:failed|PHASE:escalate)
agent_kill_session "${_MONITOR_SESSION:-$SESSION_NAME}"
;;
PHASE:crashed)
if [ "${_GARDENER_CRASH_COUNT:-0}" -gt 0 ]; then
log "ERROR: session crashed again — giving up"
return 0
fi
_GARDENER_CRASH_COUNT=$(( _GARDENER_CRASH_COUNT + 1 ))
log "WARNING: session crashed — attempting recovery"
if create_agent_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
"${_FORMULA_SESSION_WORKDIR:-$PROJECT_REPO_ROOT}" "$PHASE_FILE" 2>/dev/null; then
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" "$PROMPT"
log "recovery session started"
else
log "ERROR: could not restart session after crash"
fi
;;
*)
log "WARNING: unknown phase: ${phase}"
;;
esac
}
# ── Reset result file ────────────────────────────────────────────────────
rm -f "$RESULT_FILE"
touch "$RESULT_FILE"
# ── Run session ──────────────────────────────────────────────────────────
export CLAUDE_MODEL="sonnet"
run_formula_and_monitor "gardener" 7200 "_gardener_on_phase_change"
# ── Cleanup on exit ──────────────────────────────────────────────────────
# FINAL_PHASE already set by run_formula_and_monitor
if [ "${FINAL_PHASE:-}" = "PHASE:done" ]; then
rm -f "$SCRATCH_FILE" rm -f "$SCRATCH_FILE"
fi fi
rm -f "$GARDENER_PR_FILE" rm -f "$GARDENER_PR_FILE"
log "--- Gardener run done ---" [ -n "$_GARDENER_PR" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${_GARDENER_PR}"

View file

@ -1,61 +0,0 @@
#!/usr/bin/env bash
# agent-sdk.sh — Shared SDK for synchronous Claude agent invocations
#
# Provides agent_run(): one-shot `claude -p` with session persistence.
# Source this from any agent script after defining:
# SID_FILE — path to persist session ID (e.g. /tmp/dev-session-proj-123.sid)
# LOGFILE — path for log output
# log() — logging function
#
# Usage:
# source "$(dirname "$0")/../lib/agent-sdk.sh"
# agent_run [--resume SESSION_ID] [--worktree DIR] PROMPT
#
# After each call, _AGENT_SESSION_ID holds the session ID (also saved to SID_FILE).
# Call agent_recover_session() on startup to restore a previous session.
set -euo pipefail
_AGENT_SESSION_ID=""
# agent_recover_session — restore session_id from SID_FILE if it exists.
# Call this before agent_run --resume to enable session continuity.
agent_recover_session() {
if [ -f "$SID_FILE" ]; then
_AGENT_SESSION_ID=$(cat "$SID_FILE")
log "agent_recover_session: ${_AGENT_SESSION_ID:0:12}..."
fi
}
# agent_run — synchronous Claude invocation (one-shot claude -p)
# Usage: agent_run [--resume SESSION_ID] [--worktree DIR] PROMPT
# Sets: _AGENT_SESSION_ID (updated each call, persisted to SID_FILE)
agent_run() {
local resume_id="" worktree_dir=""
while [[ "${1:-}" == --* ]]; do
case "$1" in
--resume) shift; resume_id="${1:-}"; shift ;;
--worktree) shift; worktree_dir="${1:-}"; shift ;;
*) shift ;;
esac
done
local prompt="${1:-}"
local -a args=(-p "$prompt" --output-format json --dangerously-skip-permissions --max-turns 200)
[ -n "$resume_id" ] && args+=(--resume "$resume_id")
[ -n "${CLAUDE_MODEL:-}" ] && args+=(--model "$CLAUDE_MODEL")
local run_dir="${worktree_dir:-$(pwd)}"
local output
log "agent_run: starting (resume=${resume_id:-(new)}, dir=${run_dir})"
output=$(cd "$run_dir" && timeout "${CLAUDE_TIMEOUT:-7200}" claude "${args[@]}" 2>>"$LOGFILE") || true
# Extract and persist session_id
local new_sid
new_sid=$(printf '%s' "$output" | jq -r '.session_id // empty' 2>/dev/null) || true
if [ -n "$new_sid" ]; then
_AGENT_SESSION_ID="$new_sid"
printf '%s' "$new_sid" > "$SID_FILE"
log "agent_run: session_id=${new_sid:0:12}..."
fi
}

View file

@ -1,79 +1,41 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# shellcheck disable=SC2015,SC2016 # shellcheck disable=SC2015,SC2016
# review-pr.sh — Synchronous reviewer agent for a single PR # review-pr.sh — Thin orchestrator for AI PR review (formula: formulas/review-pr.toml)
#
# Usage: ./review-pr.sh <pr-number> [--force] # Usage: ./review-pr.sh <pr-number> [--force]
#
# Architecture:
# Synchronous bash loop using claude -p (one-shot invocations).
# Session continuity via --resume and .sid file.
# Re-review resumes the original session — Claude remembers its prior review.
#
# Flow:
# 1. Fetch PR metadata (title, body, head, base, SHA, CI state)
# 2. Detect re-review (previous review at different SHA, incremental diff)
# 3. Create review worktree, checkout PR head
# 4. Build structural analysis graph
# 5. Load review formula
# 6. agent_run(worktree, prompt) → Claude reviews, writes verdict JSON
# 7. Parse verdict, post as Forge review (APPROVE / REQUEST_CHANGES / COMMENT)
# 8. Save session ID to .sid file for re-review continuity
#
# Session file: /tmp/review-session-{project}-{pr}.sid
set -euo pipefail set -euo pipefail
# Load shared environment and libraries
source "$(dirname "$0")/../lib/env.sh" source "$(dirname "$0")/../lib/env.sh"
source "$(dirname "$0")/../lib/ci-helpers.sh" source "$(dirname "$0")/../lib/ci-helpers.sh"
source "$(dirname "$0")/../lib/worktree.sh" source "$(dirname "$0")/../lib/agent-session.sh"
source "$(dirname "$0")/../lib/agent-sdk.sh"
# Auto-pull factory code to pick up merged fixes before any logic runs
git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true
# --- Config ---
PR_NUMBER="${1:?Usage: review-pr.sh <pr-number> [--force]}" PR_NUMBER="${1:?Usage: review-pr.sh <pr-number> [--force]}"
FORCE="${2:-}" FORCE="${2:-}"
API="${FORGE_API}" API="${FORGE_API}"
LOGFILE="${DISINTO_LOG_DIR}/review/review.log" LOGFILE="${DISINTO_LOG_DIR}/review/review.log"
WORKTREE="/tmp/${PROJECT_NAME}-review-${PR_NUMBER}" SESSION="review-${PROJECT_NAME}-${PR_NUMBER}"
SID_FILE="/tmp/review-session-${PROJECT_NAME}-${PR_NUMBER}.sid" PHASE_FILE="/tmp/review-session-${PROJECT_NAME}-${PR_NUMBER}.phase"
OUTPUT_FILE="/tmp/${PROJECT_NAME}-review-output-${PR_NUMBER}.json" OUTPUT_FILE="/tmp/${PROJECT_NAME}-review-output-${PR_NUMBER}.json"
WORKTREE="/tmp/${PROJECT_NAME}-review-${PR_NUMBER}"
LOCKFILE="/tmp/${PROJECT_NAME}-review.lock" LOCKFILE="/tmp/${PROJECT_NAME}-review.lock"
STATUSFILE="/tmp/${PROJECT_NAME}-review-status" STATUSFILE="/tmp/${PROJECT_NAME}-review-status"
MAX_DIFF=25000 MAX_DIFF=25000
REVIEW_TMPDIR=$(mktemp -d) REVIEW_TMPDIR=$(mktemp -d)
log() { printf '[%s] PR#%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$PR_NUMBER" "$*" >> "$LOGFILE"; } log() { printf '[%s] PR#%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$PR_NUMBER" "$*" >> "$LOGFILE"; }
status() { printf '[%s] PR #%s: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$PR_NUMBER" "$*" > "$STATUSFILE"; log "$*"; } status() { printf '[%s] PR #%s: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$PR_NUMBER" "$*" > "$STATUSFILE"; log "$*"; }
cleanup() { rm -rf "$REVIEW_TMPDIR" "$LOCKFILE" "$STATUSFILE" "/tmp/${PROJECT_NAME}-review-graph-${PR_NUMBER}.json"; } cleanup() { rm -rf "$REVIEW_TMPDIR" "$LOCKFILE" "$STATUSFILE" "/tmp/${PROJECT_NAME}-review-graph-${PR_NUMBER}.json"; }
trap cleanup EXIT trap cleanup EXIT
# =============================================================================
# LOG ROTATION
# =============================================================================
if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt 102400 ]; then if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt 102400 ]; then
mv "$LOGFILE" "$LOGFILE.old" mv "$LOGFILE" "$LOGFILE.old"
fi fi
AVAIL=$(awk '/MemAvailable/{printf "%d", $2/1024}' /proc/meminfo)
# ============================================================================= [ "$AVAIL" -lt 1500 ] && { log "SKIP: ${AVAIL}MB available"; exit 0; }
# MEMORY GUARD
# =============================================================================
memory_guard 1500
# =============================================================================
# CONCURRENCY LOCK
# =============================================================================
if [ -f "$LOCKFILE" ]; then if [ -f "$LOCKFILE" ]; then
LPID=$(cat "$LOCKFILE" 2>/dev/null || true) LPID=$(cat "$LOCKFILE" 2>/dev/null || true)
[ -n "$LPID" ] && kill -0 "$LPID" 2>/dev/null && { log "SKIP: locked"; exit 0; } [ -n "$LPID" ] && kill -0 "$LPID" 2>/dev/null && { log "SKIP: locked"; exit 0; }
rm -f "$LOCKFILE" rm -f "$LOCKFILE"
fi fi
echo $$ > "$LOCKFILE" echo $$ > "$LOCKFILE"
# =============================================================================
# FETCH PR METADATA
# =============================================================================
status "fetching metadata" status "fetching metadata"
PR_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" "${API}/pulls/${PR_NUMBER}") PR_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" "${API}/pulls/${PR_NUMBER}")
PR_TITLE=$(printf '%s' "$PR_JSON" | jq -r '.title') PR_TITLE=$(printf '%s' "$PR_JSON" | jq -r '.title')
@ -83,27 +45,15 @@ PR_BASE=$(printf '%s' "$PR_JSON" | jq -r '.base.ref')
PR_SHA=$(printf '%s' "$PR_JSON" | jq -r '.head.sha') PR_SHA=$(printf '%s' "$PR_JSON" | jq -r '.head.sha')
PR_STATE=$(printf '%s' "$PR_JSON" | jq -r '.state') PR_STATE=$(printf '%s' "$PR_JSON" | jq -r '.state')
log "${PR_TITLE} (${PR_HEAD}${PR_BASE} ${PR_SHA:0:7})" log "${PR_TITLE} (${PR_HEAD}${PR_BASE} ${PR_SHA:0:7})"
if [ "$PR_STATE" != "open" ]; then if [ "$PR_STATE" != "open" ]; then
log "SKIP: state=${PR_STATE}" log "SKIP: state=${PR_STATE}"; agent_kill_session "$SESSION"
worktree_cleanup "$WORKTREE" cd "${PROJECT_REPO_ROOT}"; git worktree remove "$WORKTREE" --force 2>/dev/null || true
rm -f "$OUTPUT_FILE" "$SID_FILE" 2>/dev/null || true rm -rf "$WORKTREE" "$PHASE_FILE" "$OUTPUT_FILE" 2>/dev/null || true; exit 0
exit 0
fi fi
# =============================================================================
# CI CHECK
# =============================================================================
CI_STATE=$(ci_commit_status "$PR_SHA") CI_STATE=$(ci_commit_status "$PR_SHA")
CI_NOTE="" CI_NOTE=""; if ! ci_passed "$CI_STATE"; then
if ! ci_passed "$CI_STATE"; then
ci_required_for_pr "$PR_NUMBER" && { log "SKIP: CI=${CI_STATE}"; exit 0; } ci_required_for_pr "$PR_NUMBER" && { log "SKIP: CI=${CI_STATE}"; exit 0; }
CI_NOTE=" (not required — non-code PR)" CI_NOTE=" (not required — non-code PR)"; fi
fi
# =============================================================================
# DUPLICATE CHECK — skip if already reviewed at this SHA
# =============================================================================
ALL_COMMENTS=$(forge_api_all "/issues/${PR_NUMBER}/comments") ALL_COMMENTS=$(forge_api_all "/issues/${PR_NUMBER}/comments")
HAS_CMT=$(printf '%s' "$ALL_COMMENTS" | jq --arg s "$PR_SHA" \ HAS_CMT=$(printf '%s' "$ALL_COMMENTS" | jq --arg s "$PR_SHA" \
'[.[]|select(.body|contains("<!-- reviewed: "+$s+" -->"))]|length') '[.[]|select(.body|contains("<!-- reviewed: "+$s+" -->"))]|length')
@ -111,10 +61,6 @@ HAS_CMT=$(printf '%s' "$ALL_COMMENTS" | jq --arg s "$PR_SHA" \
HAS_FML=$(forge_api_all "/pulls/${PR_NUMBER}/reviews" | jq --arg s "$PR_SHA" \ HAS_FML=$(forge_api_all "/pulls/${PR_NUMBER}/reviews" | jq --arg s "$PR_SHA" \
'[.[]|select(.commit_id==$s)|select(.state!="COMMENT")]|length') '[.[]|select(.commit_id==$s)|select(.state!="COMMENT")]|length')
[ "${HAS_FML:-0}" -gt 0 ] && [ "$FORCE" != "--force" ] && { log "SKIP: formal review"; exit 0; } [ "${HAS_FML:-0}" -gt 0 ] && [ "$FORCE" != "--force" ] && { log "SKIP: formal review"; exit 0; }
# =============================================================================
# RE-REVIEW DETECTION
# =============================================================================
PREV_CONTEXT="" IS_RE_REVIEW=false PREV_SHA="" PREV_CONTEXT="" IS_RE_REVIEW=false PREV_SHA=""
PREV_REV=$(printf '%s' "$ALL_COMMENTS" | jq -r --arg s "$PR_SHA" \ PREV_REV=$(printf '%s' "$ALL_COMMENTS" | jq -r --arg s "$PR_SHA" \
'[.[]|select(.body|contains("<!-- reviewed:"))|select(.body|contains($s)|not)]|last // empty') '[.[]|select(.body|contains("<!-- reviewed:"))|select(.body|contains($s)|not)]|last // empty')
@ -133,13 +79,6 @@ if [ -n "$PREV_REV" ] && [ "$PREV_REV" != "null" ]; then
"${PREV_SHA:0:7}" "$PREV_BODY" "$DEV_SEC" "${PREV_SHA:0:7}" "${PR_SHA:0:7}" "$INCR") "${PREV_SHA:0:7}" "$PREV_BODY" "$DEV_SEC" "${PREV_SHA:0:7}" "${PR_SHA:0:7}" "$INCR")
fi fi
fi fi
# Recover session_id from .sid file (re-review continuity)
agent_recover_session
# =============================================================================
# FETCH DIFF
# =============================================================================
status "fetching diff" status "fetching diff"
curl -s -H "Authorization: token ${FORGE_TOKEN}" \ curl -s -H "Authorization: token ${FORGE_TOKEN}" \
"${API}/pulls/${PR_NUMBER}.diff" > "${REVIEW_TMPDIR}/full.diff" "${API}/pulls/${PR_NUMBER}.diff" > "${REVIEW_TMPDIR}/full.diff"
@ -147,25 +86,15 @@ FSIZE=$(stat -c%s "${REVIEW_TMPDIR}/full.diff" 2>/dev/null || echo 0)
DIFF=$(head -c "$MAX_DIFF" "${REVIEW_TMPDIR}/full.diff") DIFF=$(head -c "$MAX_DIFF" "${REVIEW_TMPDIR}/full.diff")
FILES=$(grep -E '^\+\+\+ b/' "${REVIEW_TMPDIR}/full.diff" | sed 's|^+++ b/||' | grep -v '/dev/null' | sort -u || true) FILES=$(grep -E '^\+\+\+ b/' "${REVIEW_TMPDIR}/full.diff" | sed 's|^+++ b/||' | grep -v '/dev/null' | sort -u || true)
DNOTE=""; [ "$FSIZE" -gt "$MAX_DIFF" ] && DNOTE=" (truncated from ${FSIZE} bytes)" DNOTE=""; [ "$FSIZE" -gt "$MAX_DIFF" ] && DNOTE=" (truncated from ${FSIZE} bytes)"
cd "${PROJECT_REPO_ROOT}"; git fetch origin "$PR_HEAD" 2>/dev/null || true
# =============================================================================
# WORKTREE SETUP
# =============================================================================
cd "${PROJECT_REPO_ROOT}"
git fetch origin "$PR_HEAD" 2>/dev/null || true
if [ -d "$WORKTREE" ]; then if [ -d "$WORKTREE" ]; then
cd "$WORKTREE"; git checkout --detach "$PR_SHA" 2>/dev/null || { cd "$WORKTREE"; git checkout --detach "$PR_SHA" 2>/dev/null || {
cd "${PROJECT_REPO_ROOT}"; worktree_cleanup "$WORKTREE" cd "${PROJECT_REPO_ROOT}"; git worktree remove "$WORKTREE" --force 2>/dev/null || true
git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null; } rm -rf "$WORKTREE"; git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null; }
else else git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null; fi
git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null status "preparing review session"
fi
# ============================================================================= # ── Build structural analysis graph for changed files ────────────────────
# BUILD STRUCTURAL ANALYSIS GRAPH
# =============================================================================
status "preparing review"
GRAPH_REPORT="/tmp/${PROJECT_NAME}-review-graph-${PR_NUMBER}.json" GRAPH_REPORT="/tmp/${PROJECT_NAME}-review-graph-${PR_NUMBER}.json"
GRAPH_SECTION="" GRAPH_SECTION=""
# shellcheck disable=SC2086 # shellcheck disable=SC2086
@ -180,43 +109,42 @@ else
log "WARN: build-graph.py failed — continuing without structural analysis" log "WARN: build-graph.py failed — continuing without structural analysis"
fi fi
# =============================================================================
# BUILD PROMPT
# =============================================================================
FORMULA=$(cat "${FACTORY_ROOT}/formulas/review-pr.toml") FORMULA=$(cat "${FACTORY_ROOT}/formulas/review-pr.toml")
{ {
printf 'You are the review agent for %s. Follow the formula to review PR #%s.\n\n' \ printf 'You are the review agent for %s. Follow the formula to review PR #%s.\nYou MUST write PHASE:done to '\''%s'\'' when finished.\n\n' \
"${FORGE_REPO}" "${PR_NUMBER}" "${FORGE_REPO}" "${PR_NUMBER}" "${PHASE_FILE}"
printf '## PR Context\n**%s** (%s → %s) | SHA: %s | CI: %s%s\nRe-review: %s\n\n' \ printf '## PR Context\n**%s** (%s → %s) | SHA: %s | CI: %s%s\nRe-review: %s\n\n' \
"$PR_TITLE" "$PR_HEAD" "$PR_BASE" "$PR_SHA" "$CI_STATE" "$CI_NOTE" "$IS_RE_REVIEW" "$PR_TITLE" "$PR_HEAD" "$PR_BASE" "$PR_SHA" "$CI_STATE" "$CI_NOTE" "$IS_RE_REVIEW"
printf '### Description\n%s\n\n### Changed Files\n%s\n\n### Diff%s\n```diff\n%s\n```\n' \ printf '### Description\n%s\n\n### Changed Files\n%s\n\n### Diff%s\n```diff\n%s\n```\n' \
"$PR_BODY" "$FILES" "$DNOTE" "$DIFF" "$PR_BODY" "$FILES" "$DNOTE" "$DIFF"
[ -n "$PREV_CONTEXT" ] && printf '%s\n' "$PREV_CONTEXT" [ -n "$PREV_CONTEXT" ] && printf '%s\n' "$PREV_CONTEXT"
[ -n "$GRAPH_SECTION" ] && printf '%s\n' "$GRAPH_SECTION" [ -n "$GRAPH_SECTION" ] && printf '%s\n' "$GRAPH_SECTION"
printf '\n## Formula\n%s\n\n## Environment\nREVIEW_OUTPUT_FILE=%s\nFORGE_API=%s\nPR_NUMBER=%s\nFACTORY_ROOT=%s\n' \ printf '\n## Formula\n%s\n\n## Environment\nREVIEW_OUTPUT_FILE=%s\nPHASE_FILE=%s\nFORGE_API=%s\nPR_NUMBER=%s\nFACTORY_ROOT=%s\n' \
"$FORMULA" "$OUTPUT_FILE" "$API" "$PR_NUMBER" "$FACTORY_ROOT" "$FORMULA" "$OUTPUT_FILE" "$PHASE_FILE" "$API" "$PR_NUMBER" "$FACTORY_ROOT"
printf 'NEVER echo the actual token — always reference ${FORGE_TOKEN} or ${FORGE_REVIEW_TOKEN}.\n' printf 'NEVER echo the actual token — always reference ${FORGE_TOKEN} or ${FORGE_REVIEW_TOKEN}.\n'
printf '\n## Completion\nAfter writing the JSON file to REVIEW_OUTPUT_FILE, stop.\nDo NOT write to any phase file — completion is automatic.\n'
} > "${REVIEW_TMPDIR}/prompt.md" } > "${REVIEW_TMPDIR}/prompt.md"
PROMPT=$(cat "${REVIEW_TMPDIR}/prompt.md") PROMPT=$(cat "${REVIEW_TMPDIR}/prompt.md")
# ============================================================================= rm -f "$OUTPUT_FILE" "$PHASE_FILE"; agent_kill_session "$SESSION"
# RUN REVIEW AGENT
# =============================================================================
status "running review"
rm -f "$OUTPUT_FILE"
export CLAUDE_MODEL="sonnet" export CLAUDE_MODEL="sonnet"
create_agent_session "$SESSION" "$WORKTREE" "$PHASE_FILE" || { log "ERROR: session failed"; exit 1; }
agent_inject_into_session "$SESSION" "$PROMPT"
log "prompt injected (${#PROMPT} bytes, re-review: ${IS_RE_REVIEW})"
if [ "$IS_RE_REVIEW" = true ] && [ -n "$_AGENT_SESSION_ID" ]; then status "waiting for review"
agent_run --resume "$_AGENT_SESSION_ID" --worktree "$WORKTREE" "$PROMPT" _REVIEW_CRASH=0
else review_cb() {
agent_run --worktree "$WORKTREE" "$PROMPT" log "phase: $1"
fi case "$1" in
log "agent_run complete (re-review: ${IS_RE_REVIEW})" PHASE:crashed)
[ "$_REVIEW_CRASH" -gt 0 ] && return 0; _REVIEW_CRASH=$((_REVIEW_CRASH + 1))
create_agent_session "${_MONITOR_SESSION}" "$WORKTREE" "$PHASE_FILE" 2>/dev/null && \
agent_inject_into_session "${_MONITOR_SESSION}" "$PROMPT" ;;
PHASE:done|PHASE:failed|PHASE:escalate) agent_kill_session "${_MONITOR_SESSION}" ;;
esac
}
monitor_phase_loop "$PHASE_FILE" 600 "review_cb" "$SESSION"
# =============================================================================
# PARSE REVIEW OUTPUT
# =============================================================================
REVIEW_JSON="" REVIEW_JSON=""
if [ -f "$OUTPUT_FILE" ]; then if [ -f "$OUTPUT_FILE" ]; then
RAW=$(cat "$OUTPUT_FILE") RAW=$(cat "$OUTPUT_FILE")
@ -227,7 +155,6 @@ if [ -f "$OUTPUT_FILE" ]; then
[ -n "${EXT:-}" ] && printf '%s' "$EXT" | jq -e '.verdict' >/dev/null 2>&1 && REVIEW_JSON="$EXT" [ -n "${EXT:-}" ] && printf '%s' "$EXT" | jq -e '.verdict' >/dev/null 2>&1 && REVIEW_JSON="$EXT"
fi fi
fi fi
if [ -z "$REVIEW_JSON" ]; then if [ -z "$REVIEW_JSON" ]; then
log "ERROR: no valid review output" log "ERROR: no valid review output"
jq -n --arg b "## AI Review — Error\n<!-- review-error: ${PR_SHA} -->\nReview failed.\n---\n*${PR_SHA:0:7}*" \ jq -n --arg b "## AI Review — Error\n<!-- review-error: ${PR_SHA} -->\nReview failed.\n---\n*${PR_SHA:0:7}*" \
@ -235,15 +162,11 @@ if [ -z "$REVIEW_JSON" ]; then
-H "Content-Type: application/json" "${API}/issues/${PR_NUMBER}/comments" -d @- || true -H "Content-Type: application/json" "${API}/issues/${PR_NUMBER}/comments" -d @- || true
exit 1 exit 1
fi fi
VERDICT=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict' | tr '[:lower:]' '[:upper:]' | tr '-' '_') VERDICT=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict' | tr '[:lower:]' '[:upper:]' | tr '-' '_')
REASON=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict_reason // ""') REASON=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict_reason // ""')
REVIEW_MD=$(printf '%s' "$REVIEW_JSON" | jq -r '.review_markdown // ""') REVIEW_MD=$(printf '%s' "$REVIEW_JSON" | jq -r '.review_markdown // ""')
log "verdict: ${VERDICT}" log "verdict: ${VERDICT}"
# =============================================================================
# POST REVIEW
# =============================================================================
status "posting review" status "posting review"
RTYPE="Review" RTYPE="Review"
if [ "$IS_RE_REVIEW" = true ]; then if [ "$IS_RE_REVIEW" = true ]; then
@ -261,9 +184,6 @@ POST_RC=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
[ "$POST_RC" != "201" ] && { log "ERROR: comment HTTP ${POST_RC}"; exit 1; } [ "$POST_RC" != "201" ] && { log "ERROR: comment HTTP ${POST_RC}"; exit 1; }
log "posted review comment" log "posted review comment"
# =============================================================================
# POST FORMAL REVIEW
# =============================================================================
REVENT="COMMENT" REVENT="COMMENT"
case "$VERDICT" in APPROVE) REVENT="APPROVED" ;; REQUEST_CHANGES|DISCUSS) REVENT="REQUEST_CHANGES" ;; esac case "$VERDICT" in APPROVE) REVENT="APPROVED" ;; REQUEST_CHANGES|DISCUSS) REVENT="REQUEST_CHANGES" ;; esac
if [ "$REVENT" = "APPROVED" ]; then if [ "$REVENT" = "APPROVED" ]; then
@ -284,18 +204,10 @@ curl -s -o /dev/null -X POST -H "Authorization: token ${FORGE_REVIEW_TOKEN}" \
--data-binary @"${REVIEW_TMPDIR}/formal.json" >/dev/null 2>&1 || true --data-binary @"${REVIEW_TMPDIR}/formal.json" >/dev/null 2>&1 || true
log "formal ${REVENT} submitted" log "formal ${REVENT} submitted"
# =============================================================================
# FINAL CLEANUP
# =============================================================================
case "$VERDICT" in case "$VERDICT" in
REQUEST_CHANGES|DISCUSS) REQUEST_CHANGES|DISCUSS) printf 'PHASE:awaiting_changes\nSHA:%s\n' "$PR_SHA" > "$PHASE_FILE" ;;
# Keep session and worktree for re-review continuity *) rm -f "$PHASE_FILE" "$OUTPUT_FILE"; cd "${PROJECT_REPO_ROOT}"
log "keeping session for re-review (SID: ${_AGENT_SESSION_ID:0:12}...)" git worktree remove "$WORKTREE" --force 2>/dev/null || true
;; rm -rf "$WORKTREE" 2>/dev/null || true ;;
*)
rm -f "$SID_FILE" "$OUTPUT_FILE"
worktree_cleanup "$WORKTREE"
;;
esac esac
log "DONE: ${VERDICT} (re-review: ${IS_RE_REVIEW})" log "DONE: ${VERDICT} (re-review: ${IS_RE_REVIEW})"