From c5e5a14b91a8f4410f342b57177b1f50c526b550 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 26 Mar 2026 19:35:44 +0000 Subject: [PATCH] fix: Dev-poll must inject CI failures and review feedback into running sessions (#771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a dev-agent tmux session is alive, dev-poll and review-poll previously skipped it entirely — leaving the agent deaf to CI results and review feedback if the orchestrator (dev-agent.sh) had died. Changes in dev-poll.sh: - Add handle_active_session() helper that checks running sessions for injectable events instead of blindly skipping - Detect externally merged/closed PRs and clean up stale sessions - Inject CI success/failure into sessions in PHASE:awaiting_ci - Inject review feedback into sessions in PHASE:awaiting_review - SHA-based sentinel prevents duplicate injections across poll cycles - Replace all 7 tmux skip blocks with handle_active_session calls Changes in review-poll.sh: - inject_review_into_dev_session() now falls back to formal forge reviews when no bot review comment is found - Call injection when skipping already-reviewed PRs (previously only called after performing new reviews) Evidence: PR #767 (#757) — CI failed twice with agent stuck in awaiting_ci; PR merged manually with session blocking new backlog. Co-Authored-By: Claude Opus 4.6 (1M context) --- dev/dev-poll.sh | 190 ++++++++++++++++++++++++++++++++++++++++-- review/review-poll.sh | 31 +++++-- 2 files changed, 209 insertions(+), 12 deletions(-) diff --git a/dev/dev-poll.sh b/dev/dev-poll.sh index 1f81287..e348894 100755 --- a/dev/dev-poll.sh +++ b/dev/dev-poll.sh @@ -221,6 +221,182 @@ try_direct_merge() { 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 +# ============================================================================= +_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" +} + +# ============================================================================= +# 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" +# ============================================================================= +# 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 + + # 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(" marker) local review_comment review_comment=$(forge_api_all "/issues/${pr_num}/comments" | \ jq -r --arg sha "${pr_sha}" \ '[.[] | select(.body | contains("