Compare commits

..

11 commits

Author SHA1 Message Date
johba
2b8e250247 Merge pull request 'fix: Migrate gardener-run.sh to SDK + pr-lifecycle (#801)' (#811) from fix/issue-801 into main 2026-03-28 08:32:32 +01:00
openhands
6ab1aeb17c fix: Migrate gardener-run.sh to SDK + pr-lifecycle (#801)
Reuse build_prompt_footer() from formula-session.sh instead of
hand-rolling the API reference and environment sections. Replace
the phase protocol section with SDK completion protocol.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 07:05:18 +00:00
openhands
5adf34e695 fix: Migrate gardener-run.sh to SDK + pr-lifecycle (#801)
Replace tmux-based run_formula_and_monitor architecture with synchronous
agent_run() from agent-sdk.sh. Replace custom CI/review/merge phase
callbacks (~350 lines) with pr_walk_to_merge() from pr-lifecycle.sh.

Key changes:
- Source agent-sdk.sh + pr-lifecycle.sh instead of agent-session.sh
- One-shot claude -p invocation replaces tmux session management
- Bash script IS the state machine (no phase files needed)
- Keep _gardener_execute_manifest() for post-merge manifest execution
- Keep all guards, formula loading, context building unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 07:02:55 +00:00
johba
1912a24c46 feat: edge proxy + staging container to docker stack (#807)
This PR implements issue #764 by adding two Caddy-based containers to the disinto docker stack:

## Changes

### Edge Proxy Service
- Caddy reverse proxy serving on ports 80/443
- Routes /forgejo/* -> Forgejo:3000
- Routes /ci/* -> Woodpecker:8000
- Default route -> staging container

### Staging Service
- Caddy static file server for staging artifacts
- Serves a default "Nothing shipped yet" page
- CI pipelines can write to the staging-site volume to update content

### Files Modified
- bin/disinto: Updated generate_compose() to add edge + staging services
- bin/disinto: Added generate_caddyfile() function
- bin/disinto: Added generate_staging_index() function
- docker/staging-index.html: New default staging page

## Acceptance Criteria
- [x] disinto init generates docker-compose.yml with edge + staging services
- [x] Edge proxy routes /forgejo/*, /ci/*, and default routes correctly
- [x] Staging container serves default "Nothing shipped yet" page
- [x] docker/ directory contains Caddyfile template generated by disinto init
- [x] disinto up starts all containers including edge and staging

Co-authored-by: johba <johba@users.noreply.codeberg.org>
Reviewed-on: https://codeberg.org/johba/disinto/pulls/807
2026-03-28 07:58:17 +01:00
johba
15f87ead85 Merge pull request 'fix: Migrate review-pr.sh to SDK + pr-lifecycle (#800)' (#810) from fix/issue-800 into main 2026-03-28 07:47:47 +01:00
openhands
d2c71e5dcd fix: Migrate review-pr.sh to SDK + pr-lifecycle (#800)
Register lib/agent-sdk.sh in the CI smoke test so agent_recover_session
resolves for dev-agent.sh and review-pr.sh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 06:36:32 +00:00
openhands
8f41230fa0 fix: Migrate review-pr.sh to SDK + pr-lifecycle (#800)
Move SID_FILE recovery into agent_recover_session() in lib/agent-sdk.sh
to eliminate remaining duplicate block between dev-agent.sh and
review-pr.sh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 06:34:26 +00:00
openhands
c2e95799a0 fix: Migrate review-pr.sh to SDK + pr-lifecycle (#800)
Extract agent_run() into shared lib/agent-sdk.sh to eliminate code
duplication between dev-agent.sh and review-pr.sh (CI dedup check).

Rewrite review-pr.sh from tmux-based agent-session.sh to synchronous
claude -p invocations via shared agent-sdk.sh, matching the SDK pattern
from dev-agent.sh (#798).

Key changes:
- Create lib/agent-sdk.sh with shared agent_run() function
- Both dev-agent.sh and review-pr.sh now source lib/agent-sdk.sh
  instead of defining agent_run() inline
- Replace agent-session.sh (tmux + monitor_phase_loop) with agent_run()
- Add .sid file for session continuity: re-reviews resume the original
  session via --resume, so Claude remembers its prior review
- Use worktree.sh for worktree cleanup
- Remove phase file signaling — completion is automatic when claude -p
  returns
- Keep all review business logic unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 06:32:12 +00:00
openhands
b9d657f5eb fix: Migrate review-pr.sh to SDK + pr-lifecycle (#800)
Rewrite review-pr.sh from tmux-based agent-session.sh to synchronous
claude -p invocations via inline agent_run(), matching the SDK pattern
established in dev-agent.sh (#798).

Key changes:
- Replace agent-session.sh (tmux + monitor_phase_loop) with inline
  agent_run() using one-shot claude -p and --output-format json
- Add .sid file for session continuity: re-reviews resume the original
  session via --resume, so Claude remembers its prior review
- Use worktree.sh for worktree cleanup instead of manual git commands
- Remove phase file signaling (PHASE:done) — completion is automatic
  when claude -p returns
- Keep all review business logic: PR metadata, diff extraction,
  re-review detection (SHA tracking), incremental diff, build graph,
  formula loading, review posting, formal review submission

Session continuity for re-reviews:
  Initial review → save session_id to .sid file
  Re-review → load session_id, agent_run --resume → Claude remembers
  what it flagged and checks specifically whether concerns were addressed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 06:29:20 +00:00
johba
e8328fb297 Merge pull request 'fix: Restore dev-poll.sh scheduler on SDK (#799)' (#809) from fix/issue-799 into main 2026-03-28 07:21:50 +01:00
openhands
8f93ea3af1 fix: Restore dev-poll.sh scheduler on SDK (#799)
Rewrite dev-poll.sh to remove all tmux session management and use
SDK shared libraries instead:

- Remove _inject_into_session(), handle_active_session() — no tmux
- Replace try_direct_merge() raw curl with pr_merge() from lib/pr-lifecycle.sh
- Replace _post_ci_blocked_comment() with issue_block() from lib/issue-lifecycle.sh
- Check PID lockfile instead of tmux sessions for active agent detection
- Clean up .sid files instead of .phase files
- Remove preflight wait loop (dev-agent.sh handles its own labels)
- Extract extract_issue_from_pr() helper to DRY up issue number extraction

Preserved from main:
- Ready-issue scanning (backlog label + deps met)
- Priority tier system (orphaned > priority+backlog > backlog)
- Orphaned issue detection (in-progress label but no active agent)
- Direct merge shortcut (approved + CI green -> merge without spawning agent)
- CI fix exhaustion tracking (per-PR counter, max 3 attempts -> blocked label)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 06:12:40 +00:00
8 changed files with 527 additions and 932 deletions

View file

@ -96,6 +96,7 @@ 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)
@ -115,7 +116,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/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/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
if [ -f "$f" ]; then get_fns "$f"; fi if [ -f "$f" ]; then get_fns "$f"; fi
done | sort -u done | sort -u
) )
@ -180,6 +181,7 @@ 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
@ -203,7 +205,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-session.sh check_script review/review-pr.sh lib/agent-sdk.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,10 +260,37 @@ 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: staging-deploy:
image: alpine:3 image: alpine:3
profiles: ["staging"] profiles: ["staging"]
security_opt: security_opt:
@ -279,6 +306,7 @@ volumes:
woodpecker-data: woodpecker-data:
agent-data: agent-data:
project-repos: project-repos:
caddy_data:
networks: networks:
disinto-net: disinto-net:
@ -321,6 +349,95 @@ 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.
@ -1599,6 +1716,8 @@ 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,6 +29,7 @@ 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
@ -56,43 +57,6 @@ 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
# ============================================================================= # =============================================================================
@ -279,10 +243,7 @@ if [ -n "$PR_NUMBER" ]; then
fi fi
# Recover session_id from .sid file (crash recovery) # Recover session_id from .sid file (crash recovery)
if [ -f "$SID_FILE" ]; then agent_recover_session
_AGENT_SESSION_ID=$(cat "$SID_FILE")
log "recovered session_id: ${_AGENT_SESSION_ID:0:12}..."
fi
# ============================================================================= # =============================================================================
# WORKTREE SETUP # WORKTREE SETUP

View file

@ -1,6 +1,9 @@
#!/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.
@ -16,38 +19,39 @@
set -euo pipefail set -euo pipefail
# Load shared environment (with optional project TOML override) # Load shared environment and libraries
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
# Gitea labels API requires []int64 — look up the "underspecified" label ID once API="${FORGE_API}"
UNDERSPECIFIED_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \ LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-default}.lock"
| jq -r '.[] | select(.name == "underspecified") | .id' 2>/dev/null || true) LOGFILE="${DISINTO_LOG_DIR}/dev/dev-agent-${PROJECT_NAME:-default}.log"
UNDERSPECIFIED_LABEL_ID="${UNDERSPECIFIED_LABEL_ID:-1300816}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Track CI fix attempts per PR to avoid infinite respawn loops log() {
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 "
@ -90,44 +94,14 @@ 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"
@ -141,11 +115,6 @@ 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:*)
@ -155,7 +124,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"
_post_ci_blocked_comment "$issue_num" "$pr_num" "$CI_FIX_ATTEMPTS" issue_block "$issue_num" "ci_exhausted_poll (${CI_FIX_ATTEMPTS} attempts, PR #${pr_num})"
;; ;;
exhausted:*) exhausted:*)
CI_FIX_ATTEMPTS="${result#exhausted:}" CI_FIX_ATTEMPTS="${result#exhausted:}"
@ -170,7 +139,7 @@ handle_ci_exhaustion() {
} }
# ============================================================================= # =============================================================================
# HELPER: merge an approved PR directly (no Claude needed) # HELPER: merge an approved PR directly via pr_merge() (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
@ -181,30 +150,15 @@ 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"
local merge_resp merge_http if pr_merge "$pr_num"; then
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
# Close the issue (may already be closed by forge auto-close) issue_close "$issue_num"
curl -sf -X PATCH \ # Remove in-progress label (don't re-add backlog — issue is closed)
-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
# Clean up phase/session artifacts rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.sid" \
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
@ -212,199 +166,68 @@ 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 (HTTP ${merge_http:-?}) — falling back to dev-agent" log "PR #${pr_num} direct merge failed — falling back to dev-agent"
return 1 return 1
} }
# ============================================================================= # =============================================================================
# HELPER: inject text into a tmux session via load-buffer + paste (#771) # HELPER: extract issue number from PR branch/title/body
# All tmux calls guarded with || true to prevent aborting under set -euo pipefail.
# Args: session text
# ============================================================================= # =============================================================================
_inject_into_session() { extract_issue_from_pr() {
local session="$1" text="$2" local branch="$1" title="$2" body="$3"
local tmpfile local issue
tmpfile=$(mktemp /tmp/dev-poll-inject-XXXXXX) issue=$(echo "$branch" | grep -oP '(?<=fix/issue-)\d+' || true)
printf '%s' "$text" > "$tmpfile" if [ -z "$issue" ]; then
tmux load-buffer -b "poll-inject-$$" "$tmpfile" || true issue=$(echo "$title" | grep -oP '#\K\d+' | tail -1 || true)
tmux paste-buffer -t "$session" -b "poll-inject-$$" || true fi
sleep 0.5 if [ -z "$issue" ]; then
tmux send-keys -t "$session" "" Enter || true issue=$(echo "$body" | grep -oiP '(?:closes|fixes|resolves)\s*#\K\d+' | head -1 || true)
tmux delete-buffer -b "poll-inject-$$" 2>/dev/null || true fi
rm -f "$tmpfile" printf '%s' "$issue"
} }
# ============================================================================= # =============================================================================
# HELPER: handle events for a running dev session (#771) # DEPENDENCY HELPERS
#
# 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"
# ============================================================================= # =============================================================================
# shellcheck disable=SC2034 # ACTIVE_SESSION_ACTION is read by callers dep_is_merged() {
handle_active_session() { local dep_num="$1"
local session="$1" issue_num="$2" pr_num="${3:-}" local dep_state
local phase_file="/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase" dep_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
local sentinel="/tmp/dev-poll-injected-${PROJECT_NAME}-${issue_num}" "${API}/issues/${dep_num}" | jq -r '.state // "open"')
ACTIVE_SESSION_ACTION="skip" if [ "$dep_state" != "closed" ]; then
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
# Sentinel: avoid re-injecting for the same SHA across poll cycles
local last_injected
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"
return 0
fi
# --- Inject CI result into awaiting_ci session ---
if [ "$phase" = "PHASE:awaiting_ci" ] && [ -n "$pr_sha" ]; then
local ci_state
ci_state=$(ci_commit_status "$pr_sha") || true
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
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}" get_deps() {
LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-default}.lock" local issue_body="$1"
LOGFILE="${DISINTO_LOG_DIR}/dev/dev-agent-${PROJECT_NAME:-default}.log" echo "$issue_body" | bash "${FACTORY_ROOT}/lib/parse-deps.sh"
PREFLIGHT_RESULT="/tmp/dev-agent-preflight.json" }
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
log() { issue_is_ready() {
printf '[%s] poll: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE" local issue_num="$1"
local issue_body="$2"
local deps
deps=$(get_deps "$issue_body")
if [ -z "$deps" ]; then
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
} }
# ============================================================================= # =============================================================================
@ -426,14 +249,7 @@ 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 // \"\"")
# Extract issue number from branch name, PR title, or PR body PL_ISSUE=$(extract_issue_from_pr "$PL_PR_BRANCH" "$PL_PR_TITLE" "$PL_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
@ -474,7 +290,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 --- # --- Check if dev-agent already running (PID lockfile) ---
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
@ -487,61 +303,6 @@ 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
# ============================================================================= # =============================================================================
@ -594,35 +355,20 @@ 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
SESSION_NAME="dev-${PROJECT_NAME}-${ISSUE_NUM}" log "falling back to dev-agent for PR #${HAS_PR} merge"
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 &
handle_active_session "$SESSION_NAME" "$ISSUE_NUM" "$HAS_PR" log "started dev-agent PID $! for issue #${ISSUE_NUM} (agent-merge)"
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
SESSION_NAME="dev-${PROJECT_NAME}-${ISSUE_NUM}" log "issue #${ISSUE_NUM} PR #${HAS_PR} has REQUEST_CHANGES — spawning agent"
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 &
handle_active_session "$SESSION_NAME" "$ISSUE_NUM" "$HAS_PR" log "started dev-agent PID $! for issue #${ISSUE_NUM} (review fix)"
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
: :
@ -641,14 +387,9 @@ 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
SESSION_NAME="dev-${PROJECT_NAME}-${ISSUE_NUM}" log "recovering orphaned issue #${ISSUE_NUM} (no PR found)"
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 &
handle_active_session "$SESSION_NAME" "$ISSUE_NUM" "" log "started dev-agent PID $! for issue #${ISSUE_NUM} (recovery)"
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
@ -664,17 +405,10 @@ 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)
if [ -z "$STUCK_ISSUE" ]; then STUCK_ISSUE=$(extract_issue_from_pr "$PR_BRANCH" "$PR_TITLE" "$PR_BODY")
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
@ -712,14 +446,9 @@ 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
SESSION_NAME="dev-${PROJECT_NAME}-${STUCK_ISSUE}" log "falling back to dev-agent for PR #${PR_NUM} merge"
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then nohup "${SCRIPT_DIR}/dev-agent.sh" "$STUCK_ISSUE" >> "$LOGFILE" 2>&1 &
handle_active_session "$SESSION_NAME" "$STUCK_ISSUE" "$PR_NUM" log "started dev-agent PID $! for stuck PR #${PR_NUM} (agent-merge)"
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
@ -728,27 +457,13 @@ 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
@ -800,12 +515,13 @@ 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
@ -895,9 +611,6 @@ 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
@ -906,52 +619,5 @@ 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 &
AGENT_PID=$! log "started dev-agent PID $! for issue #${READY_ISSUE}"
# 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

38
docker/index.html Normal file
View file

@ -0,0 +1,38 @@
<!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,10 +1,18 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================= # =============================================================================
# gardener-run.sh — Cron wrapper: gardener execution via Claude + formula # gardener-run.sh — Cron wrapper: gardener execution via SDK + formula
# #
# Runs 4x/day (or on-demand). Guards against concurrent runs and low memory. # Synchronous bash loop using claude -p (one-shot invocation).
# Creates a tmux session with Claude (sonnet) reading formulas/run-gardener.toml. # No tmux sessions, no phase files — the bash script IS the state machine.
# 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)
@ -22,8 +30,6 @@ 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
@ -34,26 +40,20 @@ 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 run_formula_and_monitor # shellcheck disable=SC2034 # consumed by agent-sdk.sh
SESSION_NAME="gardener-${PROJECT_NAME}" LOGFILE="$LOG_FILE"
PHASE_FILE="/tmp/gardener-session-${PROJECT_NAME}.phase" # shellcheck disable=SC2034 # consumed by agent-sdk.sh
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 (manifest format reference for deferred actions) ───────── # ── Build prompt ─────────────────────────────────────────────────────────
GARDENER_API_EXTRA=" GARDENER_API_EXTRA="
## Pending-actions manifest (REQUIRED) ## Pending-actions manifest (REQUIRED)
@ -91,28 +91,18 @@ 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)
# Extend phase protocol with merge-through instructions for compaction survival When the commit-and-pr step creates a PR, write the PR number and stop:
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 \"\$PR_NUMBER\" > '${GARDENER_PR_FILE}'
echo 'PHASE:awaiting_ci' > '${PHASE_FILE}' Then STOP. Do NOT write PHASE: signals — the orchestrator handles CI, review, and merge.
Then STOP and WAIT for CI results. If no file changes exist (empty commit-and-pr), just stop — no PR needed."
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.
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.
@ -130,14 +120,21 @@ ${FORMULA_CONTENT}
${SCRATCH_INSTRUCTION} ${SCRATCH_INSTRUCTION}
${PROMPT_FOOTER}" ${PROMPT_FOOTER}"
# ── Phase callback for merge-through ───────────────────────────────────── # ── Create worktree ──────────────────────────────────────────────────────
# Handles CI polling, review injection, merge, and cleanup after PR creation. cd "$PROJECT_REPO_ROOT"
# Lighter than dev/phase-handler.sh — tailored for gardener doc-only PRs. git fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true
worktree_cleanup "$WORKTREE"
git worktree add "$WORKTREE" "origin/${PRIMARY_BRANCH}" --detach 2>/dev/null
# ── Post-merge manifest execution ───────────────────────────────────── cleanup() {
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
@ -295,387 +292,50 @@ _gardener_execute_manifest() {
log "manifest: execution complete (${count} actions processed)" log "manifest: execution complete (${count} actions processed)"
} }
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop # ── Reset result file ────────────────────────────────────────────────────
_gardener_merge() { rm -f "$RESULT_FILE" "$GARDENER_PR_FILE"
local merge_response merge_http_code touch "$RESULT_FILE"
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)
if [ "$merge_http_code" = "200" ] || [ "$merge_http_code" = "204" ]; then # ── Run agent ─────────────────────────────────────────────────────────────
log "gardener PR #${_GARDENER_PR} merged" export CLAUDE_MODEL="sonnet"
# 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
printf 'PHASE:done\n' > "$PHASE_FILE" rm -f "$SCRATCH_FILE"
return 0 log "gardener PR #${PR_NUMBER} merged — manifest executed"
fi
# 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 else
_GARDENER_CI_FIX_COUNT=$(( _GARDENER_CI_FIX_COUNT + 1 )) log "PR #${PR_NUMBER} not merged (reason: ${_PR_WALK_EXIT_REASON:-unknown})"
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 fi
} else
log "no PR created — gardener run complete"
# 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"
[ -n "$_GARDENER_PR" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${_GARDENER_PR}" log "--- Gardener run done ---"

61
lib/agent-sdk.sh Normal file
View file

@ -0,0 +1,61 @@
#!/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,41 +1,79 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# shellcheck disable=SC2015,SC2016 # shellcheck disable=SC2015,SC2016
# review-pr.sh — Thin orchestrator for AI PR review (formula: formulas/review-pr.toml) # review-pr.sh — Synchronous reviewer agent for a single PR
#
# 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/agent-session.sh" source "$(dirname "$0")/../lib/worktree.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"
SESSION="review-${PROJECT_NAME}-${PR_NUMBER}"
PHASE_FILE="/tmp/review-session-${PROJECT_NAME}-${PR_NUMBER}.phase"
OUTPUT_FILE="/tmp/${PROJECT_NAME}-review-output-${PR_NUMBER}.json"
WORKTREE="/tmp/${PROJECT_NAME}-review-${PR_NUMBER}" WORKTREE="/tmp/${PROJECT_NAME}-review-${PR_NUMBER}"
SID_FILE="/tmp/review-session-${PROJECT_NAME}-${PR_NUMBER}.sid"
OUTPUT_FILE="/tmp/${PROJECT_NAME}-review-output-${PR_NUMBER}.json"
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')
@ -45,15 +83,27 @@ 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}"; agent_kill_session "$SESSION" log "SKIP: state=${PR_STATE}"
cd "${PROJECT_REPO_ROOT}"; git worktree remove "$WORKTREE" --force 2>/dev/null || true worktree_cleanup "$WORKTREE"
rm -rf "$WORKTREE" "$PHASE_FILE" "$OUTPUT_FILE" 2>/dev/null || true; exit 0 rm -f "$OUTPUT_FILE" "$SID_FILE" 2>/dev/null || true
exit 0
fi fi
# =============================================================================
# CI CHECK
# =============================================================================
CI_STATE=$(ci_commit_status "$PR_SHA") CI_STATE=$(ci_commit_status "$PR_SHA")
CI_NOTE=""; if ! ci_passed "$CI_STATE"; then CI_NOTE=""
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)"; fi CI_NOTE=" (not required — non-code PR)"
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')
@ -61,6 +111,10 @@ 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')
@ -79,6 +133,13 @@ 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"
@ -86,15 +147,25 @@ 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}"; git worktree remove "$WORKTREE" --force 2>/dev/null || true cd "${PROJECT_REPO_ROOT}"; worktree_cleanup "$WORKTREE"
rm -rf "$WORKTREE"; git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null; } git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null; }
else git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null; fi else
status "preparing review session" git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null
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
@ -109,42 +180,43 @@ 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.\nYou MUST write PHASE:done to '\''%s'\'' when finished.\n\n' \ printf 'You are the review agent for %s. Follow the formula to review PR #%s.\n\n' \
"${FORGE_REPO}" "${PR_NUMBER}" "${PHASE_FILE}" "${FORGE_REPO}" "${PR_NUMBER}"
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\nPHASE_FILE=%s\nFORGE_API=%s\nPR_NUMBER=%s\nFACTORY_ROOT=%s\n' \ printf '\n## Formula\n%s\n\n## Environment\nREVIEW_OUTPUT_FILE=%s\nFORGE_API=%s\nPR_NUMBER=%s\nFACTORY_ROOT=%s\n' \
"$FORMULA" "$OUTPUT_FILE" "$PHASE_FILE" "$API" "$PR_NUMBER" "$FACTORY_ROOT" "$FORMULA" "$OUTPUT_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})"
status "waiting for review" if [ "$IS_RE_REVIEW" = true ] && [ -n "$_AGENT_SESSION_ID" ]; then
_REVIEW_CRASH=0 agent_run --resume "$_AGENT_SESSION_ID" --worktree "$WORKTREE" "$PROMPT"
review_cb() { else
log "phase: $1" agent_run --worktree "$WORKTREE" "$PROMPT"
case "$1" in fi
PHASE:crashed) log "agent_run complete (re-review: ${IS_RE_REVIEW})"
[ "$_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")
@ -155,6 +227,7 @@ 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}*" \
@ -162,11 +235,15 @@ 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
@ -184,6 +261,9 @@ 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
@ -204,10 +284,18 @@ 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) printf 'PHASE:awaiting_changes\nSHA:%s\n' "$PR_SHA" > "$PHASE_FILE" ;; REQUEST_CHANGES|DISCUSS)
*) rm -f "$PHASE_FILE" "$OUTPUT_FILE"; cd "${PROJECT_REPO_ROOT}" # Keep session and worktree for re-review continuity
git worktree remove "$WORKTREE" --force 2>/dev/null || true log "keeping session for re-review (SID: ${_AGENT_SESSION_ID:0:12}...)"
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})"