#!/usr/bin/env bash # dev-poll.sh — Pull-based scheduler: find the next ready issue and start dev-agent # # 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. # # Priority: # 1. Orphaned "in-progress" issues (agent died or PR needs attention) # 2. Ready "priority" + "backlog" issues (FIFO within tier) # 3. Ready "backlog" issues without "priority" (FIFO within tier) # # Usage: # cron every 10min # dev-poll.sh [projects/harb.toml] # optional project config set -euo pipefail # 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/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}" # 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 " import json,os f='$CI_FIX_TRACKER' d=json.load(open(f)) if os.path.exists(f) else {} d.pop(str($pr),None) json.dump(d,open(f,'w')) " 2>/dev/null || true } ci_fix_check_and_increment() { local pr="$1" local check_only="${2:-}" flock "$CI_FIX_LOCK" python3 -c " import json,os f='$CI_FIX_TRACKER' check_only = '${check_only}' == 'check_only' d=json.load(open(f)) if os.path.exists(f) else {} count=d.get(str($pr),0) if count>3: print('exhausted:'+str(count)) elif count==3: d[str($pr)]=4 json.dump(d,open(f,'w')) print('exhausted_first_time:3') elif check_only: print('ok:'+str(count)) else: count+=1 d[str($pr)]=count json.dump(d,open(f,'w')) print('ok:'+str(count)) " 2>/dev/null || echo "exhausted:99" } # Check whether an issue already has the "blocked" label is_blocked() { local issue="$1" forge_api GET "/issues/${issue}/labels" 2>/dev/null \ | 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. # # 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. # ============================================================================= handle_ci_exhaustion() { local pr_num="$1" issue_num="$2" local check_only="${3:-}" local result # Fast path: already blocked — skip without touching counter. if is_blocked "$issue_num"; then CI_FIX_ATTEMPTS=$(ci_fix_count "$pr_num") log "PR #${pr_num} (issue #${issue_num}) already blocked (${CI_FIX_ATTEMPTS} attempts) — skipping" 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:*) CI_FIX_ATTEMPTS="${result#ok:}" return 1 ;; 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" ;; exhausted:*) CI_FIX_ATTEMPTS="${result#exhausted:}" log "PR #${pr_num} (issue #${issue_num}) CI exhausted (${CI_FIX_ATTEMPTS} attempts) — already blocked, skipping" ;; *) CI_FIX_ATTEMPTS=99 log "PR #${pr_num} (issue #${issue_num}) CI exhausted (${CI_FIX_ATTEMPTS} attempts) — already blocked, skipping" ;; esac return 0 } # ============================================================================= # 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 # on PR creation when body contains "Fixes #N"), causing a respawn loop (#344). # ============================================================================= try_direct_merge() { local pr_num="$1" issue_num="$2" 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 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 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" \ "/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt" fi # 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 # 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" 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("