diff --git a/.woodpecker/agent-smoke.sh b/.woodpecker/agent-smoke.sh index 94e9258..dd8bf6a 100644 --- a/.woodpecker/agent-smoke.sh +++ b/.woodpecker/agent-smoke.sh @@ -96,7 +96,6 @@ 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) @@ -116,7 +115,7 @@ echo "=== 2/2 Function resolution ===" # If a new lib file is added and sourced by agents, add it to LIB_FUNS below # 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/agent-sdk.sh lib/env.sh lib/ci-helpers.sh lib/load-project.sh lib/secret-scan.sh lib/file-action-issue.sh lib/formula-session.sh lib/mirrors.sh lib/guard.sh lib/pr-lifecycle.sh lib/issue-lifecycle.sh lib/worktree.sh; do + for f in lib/agent-session.sh lib/env.sh lib/ci-helpers.sh lib/load-project.sh lib/secret-scan.sh lib/file-action-issue.sh lib/formula-session.sh lib/mirrors.sh lib/guard.sh lib/pr-lifecycle.sh lib/issue-lifecycle.sh lib/worktree.sh; do if [ -f "$f" ]; then get_fns "$f"; fi done | sort -u ) @@ -181,7 +180,6 @@ 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 @@ -205,7 +203,7 @@ check_script dev/phase-handler.sh action/action-agent.sh lib/secret-scan.sh check_script dev/dev-poll.sh check_script dev/phase-test.sh check_script gardener/gardener-run.sh -check_script review/review-pr.sh lib/agent-sdk.sh +check_script review/review-pr.sh lib/agent-session.sh check_script review/review-poll.sh check_script 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 ef6924d..3ec1ce0 100755 --- a/bin/disinto +++ b/bin/disinto @@ -260,37 +260,10 @@ 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-deploy: + staging: image: alpine:3 profiles: ["staging"] security_opt: @@ -306,7 +279,6 @@ volumes: woodpecker-data: agent-data: project-repos: - caddy_data: networks: disinto-net: @@ -349,95 +321,6 @@ 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. @@ -1716,8 +1599,6 @@ 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 3a78f53..bd33136 100755 --- a/dev/dev-agent.sh +++ b/dev/dev-agent.sh @@ -29,7 +29,6 @@ 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 @@ -57,6 +56,43 @@ 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 # ============================================================================= @@ -243,7 +279,10 @@ if [ -n "$PR_NUMBER" ]; then fi # Recover session_id from .sid file (crash recovery) -agent_recover_session +if [ -f "$SID_FILE" ]; then + _AGENT_SESSION_ID=$(cat "$SID_FILE") + log "recovered session_id: ${_AGENT_SESSION_ID:0:12}..." +fi # ============================================================================= # WORKTREE SETUP diff --git a/dev/dev-poll.sh b/dev/dev-poll.sh index 98b8b7d..bddd05f 100755 --- a/dev/dev-poll.sh +++ b/dev/dev-poll.sh @@ -1,9 +1,6 @@ #!/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. @@ -19,39 +16,38 @@ set -euo pipefail -# Load shared environment and libraries +# Load shared environment (with optional project TOML override) 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 -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)" +# 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}" -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) -# ============================================================================= +# Track CI fix attempts per PR to avoid infinite respawn loops 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 " @@ -94,14 +90,44 @@ 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. +# 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() { local pr_num="$1" issue_num="$2" @@ -115,6 +141,11 @@ 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:*) @@ -124,7 +155,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" - issue_block "$issue_num" "ci_exhausted_poll (${CI_FIX_ATTEMPTS} attempts, PR #${pr_num})" + _post_ci_blocked_comment "$issue_num" "$pr_num" "$CI_FIX_ATTEMPTS" ;; exhausted:*) CI_FIX_ATTEMPTS="${result#exhausted:}" @@ -139,7 +170,7 @@ handle_ci_exhaustion() { } # ============================================================================= -# HELPER: merge an approved PR directly via pr_merge() (no Claude needed) +# HELPER: merge an approved PR directly (no Claude needed) # # Merging an approved, CI-green PR is a single API call. Spawning dev-agent # for this fails when the issue is already closed (forge auto-closes issues @@ -150,15 +181,30 @@ try_direct_merge() { log "PR #${pr_num} (issue #${issue_num}) approved + CI green → attempting direct merge" - if pr_merge "$pr_num"; then + local merge_resp merge_http + merge_resp=$(curl -sf -w '\n%{http_code}' -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H 'Content-Type: application/json' \ + "${API}/pulls/${pr_num}/merge" \ + -d '{"Do":"merge","delete_branch_after_merge":true}' 2>/dev/null) || true + + merge_http=$(echo "$merge_resp" | tail -1) + + if [ "${merge_http:-0}" = "200" ] || [ "${merge_http:-0}" = "204" ]; then log "PR #${pr_num} merged successfully" if [ "$issue_num" -gt 0 ]; then - issue_close "$issue_num" - # Remove in-progress label (don't re-add backlog — issue is closed) + # 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 curl -sf -X DELETE \ -H "Authorization: token ${FORGE_TOKEN}" \ "${API}/issues/${issue_num}/labels/in-progress" >/dev/null 2>&1 || true - rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.sid" \ + # Clean up phase/session artifacts + rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase" \ "/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt" fi # Pull merged primary branch and push to mirrors @@ -166,70 +212,201 @@ try_direct_merge() { git -C "${PROJECT_REPO_ROOT:-}" checkout "${PRIMARY_BRANCH:-}" 2>/dev/null || true git -C "${PROJECT_REPO_ROOT:-}" 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 — falling back to dev-agent" + log "PR #${pr_num} direct merge failed (HTTP ${merge_http:-?}) — falling back to dev-agent" return 1 } # ============================================================================= -# HELPER: extract issue number from PR branch/title/body +# HELPER: inject text into a tmux session via load-buffer + paste (#771) +# All tmux calls guarded with || true to prevent aborting under set -euo pipefail. +# Args: session text # ============================================================================= -extract_issue_from_pr() { - 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" +_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" } # ============================================================================= -# DEPENDENCY HELPERS +# HELPER: handle events for a running dev session (#771) +# +# When a tmux session is alive, check for injectable events instead of skipping. +# Handles: externally merged/closed PRs, CI results (awaiting_ci), and +# review feedback (awaiting_review). +# +# Args: session_name issue_num [pr_num] +# Sets: ACTIVE_SESSION_ACTION = "cleaned" | "injected" | "skip" # ============================================================================= -dep_is_merged() { - 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 +# 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 fi - return 0 -} -get_deps() { - local issue_body="$1" - echo "$issue_body" | bash "${FACTORY_ROOT}/lib/parse-deps.sh" -} - -issue_is_ready() { - local issue_num="$1" - local issue_body="$2" - local deps - deps=$(get_deps "$issue_body") - - if [ -z "$deps" ]; then + # 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 - 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 + # --- 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 - done <<< "$deps" + + if ci_failed "$ci_state"; then + local pipeline_num error_log + pipeline_num=$(ci_pipeline_number "$pr_sha") || true + error_log="" + if [ -n "$pipeline_num" ]; then + error_log=$(bash "${FACTORY_ROOT}/lib/ci-debug.sh" failures "$pipeline_num" 2>/dev/null \ + | tail -80 | head -c 4000 || true) + fi + _inject_into_session "$session" "CI failed on PR #${pr_num} (pipeline #${pipeline_num:-?}). + +Error excerpt: +${error_log:-No logs available. Run: bash ${FACTORY_ROOT}/lib/ci-debug.sh failures ${pipeline_num:-0}} + +Fix the issue, commit, push, then write: + echo \"PHASE:awaiting_ci\" > \"${phase_file}\"" + printf '%s' "$pr_sha" > "$sentinel" + log "injected CI failure into session ${session} for PR #${pr_num}" + ACTIVE_SESSION_ACTION="injected" + return 0 + fi + fi + + # --- Inject review feedback into awaiting_review session --- + if [ "$phase" = "PHASE:awaiting_review" ] && [ -n "$pr_sha" ]; then + local reviews_json has_changes review_body + reviews_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ + "${API}/pulls/${pr_num}/reviews") || true + has_changes=$(printf '%s' "$reviews_json" | \ + jq -r '[.[] | select(.state == "REQUEST_CHANGES") | select(.stale == false)] | length') || true + + if [ "${has_changes:-0}" -gt 0 ]; then + review_body=$(printf '%s' "$reviews_json" | \ + jq -r '[.[] | select(.state == "REQUEST_CHANGES") | select(.stale == false)] | last | .body // ""') || true + + # Prefer bot review comment if available (richer content) + local review_comment + review_comment=$(forge_api_all "/issues/${pr_num}/comments" | \ + jq -r --arg sha "$pr_sha" \ + '[.[] | select(.body | contains(""))]|length') @@ -111,10 +61,6 @@ HAS_CMT=$(printf '%s' "$ALL_COMMENTS" | jq --arg s "$PR_SHA" \ HAS_FML=$(forge_api_all "/pulls/${PR_NUMBER}/reviews" | jq --arg s "$PR_SHA" \ '[.[]|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}*" \ @@ -235,15 +162,11 @@ 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 @@ -261,9 +184,6 @@ POST_RC=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ [ "$POST_RC" != "201" ] && { log "ERROR: comment HTTP ${POST_RC}"; exit 1; } 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 @@ -284,18 +204,10 @@ curl -s -o /dev/null -X POST -H "Authorization: token ${FORGE_REVIEW_TOKEN}" \ --data-binary @"${REVIEW_TMPDIR}/formal.json" >/dev/null 2>&1 || true log "formal ${REVENT} submitted" -# ============================================================================= -# FINAL CLEANUP -# ============================================================================= case "$VERDICT" in - 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" - ;; + 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 ;; esac - log "DONE: ${VERDICT} (re-review: ${IS_RE_REVIEW})"