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})"