diff --git a/.woodpecker/agent-smoke.sh b/.woodpecker/agent-smoke.sh
index dd8bf6a..94e9258 100644
--- a/.woodpecker/agent-smoke.sh
+++ b/.woodpecker/agent-smoke.sh
@@ -96,6 +96,7 @@ echo "=== 2/2 Function resolution ==="
# Included — these are inline-sourced by agent scripts:
# 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-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/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)
@@ -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
# and add a check_script call for it in the lib files section further down.
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
done | sort -u
)
@@ -180,6 +181,7 @@ check_script() {
# but this verifies calls *within* each lib file are also resolvable.
check_script lib/env.sh lib/mirrors.sh
check_script lib/agent-session.sh
+check_script lib/agent-sdk.sh
check_script lib/ci-helpers.sh
check_script 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/phase-test.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 planner/planner-run.sh lib/agent-session.sh lib/formula-session.sh
check_script supervisor/supervisor-poll.sh
diff --git a/bin/disinto b/bin/disinto
index 3ec1ce0..ef6924d 100755
--- a/bin/disinto
+++ b/bin/disinto
@@ -260,10 +260,37 @@ services:
networks:
- 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).
# Profile-gated: only starts when explicitly targeted by deploy commands.
# Customize image/ports/volumes for your project after init.
- staging:
+ staging-deploy:
image: alpine:3
profiles: ["staging"]
security_opt:
@@ -279,6 +306,7 @@ volumes:
woodpecker-data:
agent-data:
project-repos:
+ caddy_data:
networks:
disinto-net:
@@ -321,6 +349,95 @@ generate_agent_docker() {
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'
+
+
+
+
+
+ Nothing shipped yet
+
+
+
+
+
Nothing shipped yet
+
CI pipelines will update this page with your staging artifacts.
+
+
+
+INDEXEOF
+
+ echo "Created: ${index_file}"
+}
+
# Generate template .woodpecker/ deployment pipeline configs in a project repo.
# Creates staging.yml and production.yml alongside the project's existing CI config.
# These pipelines trigger on Woodpecker's deployment event with environment filters.
@@ -1599,6 +1716,8 @@ p.write_text(text)
forge_port="${forge_port:-3000}"
generate_compose "$forge_port"
generate_agent_docker
+ generate_caddyfile
+ generate_staging_index
# Create empty .env so docker compose can parse the agents service
# env_file reference before setup_forge generates the real tokens (#769)
touch "${FACTORY_ROOT}/.env"
diff --git a/dev/dev-agent.sh b/dev/dev-agent.sh
index bd33136..3a78f53 100755
--- a/dev/dev-agent.sh
+++ b/dev/dev-agent.sh
@@ -29,6 +29,7 @@ source "$(dirname "$0")/../lib/issue-lifecycle.sh"
source "$(dirname "$0")/../lib/worktree.sh"
source "$(dirname "$0")/../lib/pr-lifecycle.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
git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true
@@ -56,43 +57,6 @@ status() {
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
# =============================================================================
@@ -279,10 +243,7 @@ if [ -n "$PR_NUMBER" ]; then
fi
# Recover session_id from .sid file (crash recovery)
-if [ -f "$SID_FILE" ]; then
- _AGENT_SESSION_ID=$(cat "$SID_FILE")
- log "recovered session_id: ${_AGENT_SESSION_ID:0:12}..."
-fi
+agent_recover_session
# =============================================================================
# WORKTREE SETUP
diff --git a/dev/dev-poll.sh b/dev/dev-poll.sh
index bddd05f..98b8b7d 100755
--- a/dev/dev-poll.sh
+++ b/dev/dev-poll.sh
@@ -1,6 +1,9 @@
#!/usr/bin/env bash
# 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
# ALL its dependency issues are closed (and their PRs merged).
# No "todo" label needed — readiness is derived from reality.
@@ -16,38 +19,39 @@
set -euo pipefail
-# Load shared environment (with optional project TOML override)
+# Load shared environment and libraries
export PROJECT_TOML="${1:-}"
source "$(dirname "$0")/../lib/env.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
source "$(dirname "$0")/../lib/mirrors.sh"
# shellcheck source=../lib/guard.sh
source "$(dirname "$0")/../lib/guard.sh"
check_active dev
-# Gitea labels API requires []int64 — look up the "underspecified" label ID once
-UNDERSPECIFIED_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \
- | jq -r '.[] | select(.name == "underspecified") | .id' 2>/dev/null || true)
-UNDERSPECIFIED_LABEL_ID="${UNDERSPECIFIED_LABEL_ID:-1300816}"
+API="${FORGE_API}"
+LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-default}.lock"
+LOGFILE="${DISINTO_LOG_DIR}/dev/dev-agent-${PROJECT_NAME:-default}.log"
+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_LOCK="${CI_FIX_TRACKER}.lock"
+
ci_fix_count() {
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
}
-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() {
local pr="$1"
flock "$CI_FIX_LOCK" python3 -c "
@@ -90,44 +94,14 @@ is_blocked() {
| 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)
# 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
# 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
-# regardless of mode, preventing duplicate blocked labels from concurrent
-# pollers.
+# exit cannot waste a fix attempt). The 3->4 sentinel bump is always atomic.
# =============================================================================
handle_ci_exhaustion() {
local pr_num="$1" issue_num="$2"
@@ -141,11 +115,6 @@ handle_ci_exhaustion() {
return 0
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")
case "$result" in
ok:*)
@@ -155,7 +124,7 @@ handle_ci_exhaustion() {
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"
- _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:*)
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
# 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"
- local merge_resp merge_http
- merge_resp=$(curl -sf -w '\n%{http_code}' -X POST \
- -H "Authorization: token ${FORGE_TOKEN}" \
- -H 'Content-Type: application/json' \
- "${API}/pulls/${pr_num}/merge" \
- -d '{"Do":"merge","delete_branch_after_merge":true}' 2>/dev/null) || true
-
- merge_http=$(echo "$merge_resp" | tail -1)
-
- if [ "${merge_http:-0}" = "200" ] || [ "${merge_http:-0}" = "204" ]; then
+ if pr_merge "$pr_num"; then
log "PR #${pr_num} merged successfully"
if [ "$issue_num" -gt 0 ]; then
- # Close the issue (may already be closed by forge auto-close)
- curl -sf -X PATCH \
- -H "Authorization: token ${FORGE_TOKEN}" \
- -H 'Content-Type: application/json' \
- "${API}/issues/${issue_num}" \
- -d '{"state":"closed"}' >/dev/null 2>&1 || true
- # Remove in-progress label
+ issue_close "$issue_num"
+ # Remove in-progress label (don't re-add backlog — issue is closed)
curl -sf -X DELETE \
-H "Authorization: token ${FORGE_TOKEN}" \
"${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}.phase" \
+ rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.sid" \
"/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt"
fi
# 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:-}" pull --ff-only origin "${PRIMARY_BRANCH:-}" 2>/dev/null || true
mirror_push
- # Clean up CI fix tracker
ci_fix_reset "$pr_num"
return 0
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
}
# =============================================================================
-# HELPER: inject text into a tmux session via load-buffer + paste (#771)
-# All tmux calls guarded with || true to prevent aborting under set -euo pipefail.
-# Args: session text
+# HELPER: extract issue number from PR branch/title/body
# =============================================================================
-_inject_into_session() {
- local session="$1" text="$2"
- local tmpfile
- tmpfile=$(mktemp /tmp/dev-poll-inject-XXXXXX)
- printf '%s' "$text" > "$tmpfile"
- tmux load-buffer -b "poll-inject-$$" "$tmpfile" || true
- tmux paste-buffer -t "$session" -b "poll-inject-$$" || true
- sleep 0.5
- tmux send-keys -t "$session" "" Enter || true
- tmux delete-buffer -b "poll-inject-$$" 2>/dev/null || true
- rm -f "$tmpfile"
+extract_issue_from_pr() {
+ local branch="$1" title="$2" body="$3"
+ local issue
+ issue=$(echo "$branch" | grep -oP '(?<=fix/issue-)\d+' || true)
+ if [ -z "$issue" ]; then
+ issue=$(echo "$title" | grep -oP '#\K\d+' | tail -1 || true)
+ fi
+ if [ -z "$issue" ]; then
+ issue=$(echo "$body" | grep -oiP '(?:closes|fixes|resolves)\s*#\K\d+' | head -1 || true)
+ fi
+ printf '%s' "$issue"
}
# =============================================================================
-# HELPER: handle events for a running dev session (#771)
-#
-# When a tmux session is alive, check for injectable events instead of skipping.
-# Handles: externally merged/closed PRs, CI results (awaiting_ci), and
-# review feedback (awaiting_review).
-#
-# Args: session_name issue_num [pr_num]
-# Sets: ACTIVE_SESSION_ACTION = "cleaned" | "injected" | "skip"
+# DEPENDENCY HELPERS
# =============================================================================
-# shellcheck disable=SC2034 # ACTIVE_SESSION_ACTION is read by callers
-handle_active_session() {
- local session="$1" issue_num="$2" pr_num="${3:-}"
- local phase_file="/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase"
- local sentinel="/tmp/dev-poll-injected-${PROJECT_NAME}-${issue_num}"
- ACTIVE_SESSION_ACTION="skip"
-
- 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
+dep_is_merged() {
+ local dep_num="$1"
+ 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
-
- # 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(""))]|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" \
'[.[]|select(.commit_id==$s)|select(.state!="COMMENT")]|length')
[ "${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_REV=$(printf '%s' "$ALL_COMMENTS" | jq -r --arg s "$PR_SHA" \
'[.[]|select(.body|contains("\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
exit 1
fi
+
VERDICT=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict' | tr '[:lower:]' '[:upper:]' | tr '-' '_')
REASON=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict_reason // ""')
REVIEW_MD=$(printf '%s' "$REVIEW_JSON" | jq -r '.review_markdown // ""')
log "verdict: ${VERDICT}"
+# =============================================================================
+# POST REVIEW
+# =============================================================================
status "posting review"
RTYPE="Review"
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; }
log "posted review comment"
+# =============================================================================
+# POST FORMAL REVIEW
+# =============================================================================
REVENT="COMMENT"
case "$VERDICT" in APPROVE) REVENT="APPROVED" ;; REQUEST_CHANGES|DISCUSS) REVENT="REQUEST_CHANGES" ;; esac
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
log "formal ${REVENT} submitted"
+# =============================================================================
+# FINAL CLEANUP
+# =============================================================================
case "$VERDICT" in
- REQUEST_CHANGES|DISCUSS) printf 'PHASE:awaiting_changes\nSHA:%s\n' "$PR_SHA" > "$PHASE_FILE" ;;
- *) rm -f "$PHASE_FILE" "$OUTPUT_FILE"; cd "${PROJECT_REPO_ROOT}"
- git worktree remove "$WORKTREE" --force 2>/dev/null || true
- rm -rf "$WORKTREE" 2>/dev/null || true ;;
+ REQUEST_CHANGES|DISCUSS)
+ # Keep session and worktree for re-review continuity
+ log "keeping session for re-review (SID: ${_AGENT_SESSION_ID:0:12}...)"
+ ;;
+ *)
+ rm -f "$SID_FILE" "$OUTPUT_FILE"
+ worktree_cleanup "$WORKTREE"
+ ;;
esac
+
log "DONE: ${VERDICT} (re-review: ${IS_RE_REVIEW})"