From 48683e508c3bfaac0f34932339ed0ef5c0210a3c Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 17 Mar 2026 22:33:28 +0000 Subject: [PATCH 1/3] fix: feat: supervisor-poll.sh and gardener-poll.sh inject human replies into needs_human dev sessions (#81) Co-Authored-By: Claude Opus 4.6 --- dev/dev-agent.sh | 4 +- docs/PHASE-PROTOCOL.md | 8 +++- gardener/gardener-poll.sh | 41 ++++++++++++++++++++ lib/matrix_listener.sh | 4 ++ supervisor/supervisor-poll.sh | 70 +++++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 4 deletions(-) diff --git a/dev/dev-agent.sh b/dev/dev-agent.sh index 55de5d0..b1208f9 100755 --- a/dev/dev-agent.sh +++ b/dev/dev-agent.sh @@ -1271,8 +1271,8 @@ Instructions: status "needs human input on issue #${ISSUE}" HUMAN_REASON=$(sed -n '2p' "$PHASE_FILE" 2>/dev/null | sed 's/^Reason: //' || echo "") notify "⚠️ Issue #${ISSUE} (PR #${PR_NUMBER:-none}) needs human input.${HUMAN_REASON:+ Reason: ${HUMAN_REASON}}" - log "phase: needs_human — notified via Matrix, waiting for injection from #81/#82/#83" - # Don't inject anything — other scripts (#81, #82, #83) will inject human replies + log "phase: needs_human — notified via Matrix, waiting for external injection" + # Don't inject anything — supervisor-poll.sh (#81) and review-poll.sh (#82) inject replies # ── PHASE: done ───────────────────────────────────────────────────────────── elif [ "$CURRENT_PHASE" = "PHASE:done" ]; then diff --git a/docs/PHASE-PROTOCOL.md b/docs/PHASE-PROTOCOL.md index 4dbd7cb..ce97690 100644 --- a/docs/PHASE-PROTOCOL.md +++ b/docs/PHASE-PROTOCOL.md @@ -78,8 +78,10 @@ PHASE:awaiting_review → wait for review-poll.sh to post review comment on timeout (3h) → inject "no review, escalating" PHASE:needs_human → send Matrix notification with issue/PR link - on reply → inject human reply into session - on timeout → re-notify, then escalate after 24h + on reply → supervisor-poll.sh injects reply into tmux session + (gardener-poll.sh as backup if supervisor missed it) + reply file: /tmp/dev-escalation-reply (written by matrix_listener.sh) + on timeout → re-notify at 6h, escalate at 24h (supervisor-poll.sh) PHASE:done → verify PR merged on Codeberg if merged → kill tmux session, clean labels, close issue @@ -139,6 +141,8 @@ file and git history. | `/tmp/dev-session-{proj}-{issue}.phase` | Claude (in session) | Current phase | | `/tmp/ci-result-{proj}-{issue}.txt` | Orchestrator | Last CI output for injection | | `/tmp/dev-{proj}-{issue}.log` | Orchestrator | Session transcript (aspirational — path TBD when tmux session manager is implemented in #80) | +| `/tmp/dev-escalation-reply` | matrix_listener.sh | Human reply to `needs_human` escalation (consumed by supervisor-poll.sh) | +| `/tmp/dev-renotify-{proj}-{issue}` | supervisor-poll.sh | Marker to prevent duplicate 6h re-notifications | | `WORKTREE` (git worktree) | dev-agent.sh | Code checkpoint | ## Sequence Diagram diff --git a/gardener/gardener-poll.sh b/gardener/gardener-poll.sh index f363257..3dd58a6 100755 --- a/gardener/gardener-poll.sh +++ b/gardener/gardener-poll.sh @@ -64,6 +64,47 @@ if [ -s /tmp/gardener-escalation-reply ]; then log "Got escalation reply: $(echo "$ESCALATION_REPLY" | head -1)" fi +# ── Inject human replies into needs_human dev sessions (backup to supervisor) ─ +HUMAN_REPLY_FILE="/tmp/dev-escalation-reply" +if [ -s "$HUMAN_REPLY_FILE" ]; then + _gr_reply=$(cat "$HUMAN_REPLY_FILE") + for _gr_phase_file in /tmp/dev-session-"${PROJECT_NAME}"-*.phase; do + [ -f "$_gr_phase_file" ] || continue + _gr_phase=$(head -1 "$_gr_phase_file" 2>/dev/null | tr -d '[:space:]' || true) + [ "$_gr_phase" = "PHASE:needs_human" ] || continue + + _gr_issue=$(basename "$_gr_phase_file" .phase) + _gr_issue="${_gr_issue#dev-session-${PROJECT_NAME}-}" + [ -z "$_gr_issue" ] && continue + _gr_session="dev-${PROJECT_NAME}-${_gr_issue}" + + tmux has-session -t "$_gr_session" 2>/dev/null || continue + + _gr_inject_msg="Human reply received for issue #${_gr_issue}: + +${_gr_reply} + +Instructions: +1. Read the human's guidance carefully. +2. Continue your work based on their input. +3. When done, push your changes and write the appropriate phase: + echo \"PHASE:awaiting_ci\" > \"${_gr_phase_file}\"" + + _gr_tmpfile=$(mktemp /tmp/human-inject-XXXXXX) + printf '%s' "$_gr_inject_msg" > "$_gr_tmpfile" + tmux load-buffer -b "human-inject-${_gr_issue}" "$_gr_tmpfile" || true + tmux paste-buffer -t "$_gr_session" -b "human-inject-${_gr_issue}" || true + sleep 0.5 + tmux send-keys -t "$_gr_session" "" Enter || true + tmux delete-buffer -b "human-inject-${_gr_issue}" 2>/dev/null || true + rm -f "$_gr_tmpfile" + + rm -f "$HUMAN_REPLY_FILE" + log "${PROJECT_NAME}: #${_gr_issue} human reply injected into session ${_gr_session} (gardener)" + break # only one reply to deliver + done +fi + # ── Fetch all open issues ───────────────────────────────────────────────── ISSUES_JSON=$(codeberg_api GET "/issues?state=open&type=issues&limit=50&sort=updated&direction=desc" 2>/dev/null || true) if [ -z "$ISSUES_JSON" ] || [ "$ISSUES_JSON" = "null" ]; then diff --git a/lib/matrix_listener.sh b/lib/matrix_listener.sh index 5e0a82f..a87e57c 100755 --- a/lib/matrix_listener.sh +++ b/lib/matrix_listener.sh @@ -141,6 +141,10 @@ while true; do printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SENDER" "$BODY" >> /tmp/gardener-escalation-reply matrix_send "gardener" "✓ received, will act on next poll" "$THREAD_ROOT" >/dev/null 2>&1 || true ;; + dev) + printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SENDER" "$BODY" >> /tmp/dev-escalation-reply + matrix_send "dev" "✓ received, will inject on next supervisor poll" "$THREAD_ROOT" >/dev/null 2>&1 || true + ;; vault) # Parse APPROVE or REJECT from reply VAULT_CMD=$(echo "$BODY" | tr '[:lower:]' '[:upper:]' | grep -oP '^\s*(APPROVE|REJECT)\s+\S+' | head -1 || true) diff --git a/supervisor/supervisor-poll.sh b/supervisor/supervisor-poll.sh index a62a1a1..b1615f0 100755 --- a/supervisor/supervisor-poll.sh +++ b/supervisor/supervisor-poll.sh @@ -513,6 +513,76 @@ check_project() { --argjson prs "${_PR_COUNT:-0}" \ '{ts:$ts,type:"dev",project:$proj,issues_in_backlog:$backlog,issues_blocked:$blocked,pr_open:$prs}' 2>/dev/null)" 2>/dev/null || true + # =========================================================================== + # P2d: NEEDS_HUMAN — inject human replies into blocked dev sessions + # =========================================================================== + status "P2: ${proj_name}: checking needs_human sessions" + + HUMAN_REPLY_FILE="/tmp/dev-escalation-reply" + + for _nh_phase_file in /tmp/dev-session-"${proj_name}"-*.phase; do + [ -f "$_nh_phase_file" ] || continue + _nh_phase=$(head -1 "$_nh_phase_file" 2>/dev/null | tr -d '[:space:]' || true) + [ "$_nh_phase" = "PHASE:needs_human" ] || continue + + _nh_issue=$(basename "$_nh_phase_file" .phase) + _nh_issue="${_nh_issue#dev-session-${proj_name}-}" + [ -z "$_nh_issue" ] && continue + _nh_session="dev-${proj_name}-${_nh_issue}" + + # Check tmux session is alive + if ! tmux has-session -t "$_nh_session" 2>/dev/null; then + flog "${proj_name}: #${_nh_issue} phase=needs_human but tmux session gone" + continue + fi + + # Inject human reply if available + if [ -s "$HUMAN_REPLY_FILE" ]; then + _nh_reply=$(cat "$HUMAN_REPLY_FILE") + _nh_inject_msg="Human reply received for issue #${_nh_issue}: + +${_nh_reply} + +Instructions: +1. Read the human's guidance carefully. +2. Continue your work based on their input. +3. When done, push your changes and write the appropriate phase: + echo \"PHASE:awaiting_ci\" > \"${_nh_phase_file}\"" + + _nh_tmpfile=$(mktemp /tmp/human-inject-XXXXXX) + printf '%s' "$_nh_inject_msg" > "$_nh_tmpfile" + # All tmux calls guarded: session may die between has-session and here + tmux load-buffer -b "human-inject-${_nh_issue}" "$_nh_tmpfile" || true + tmux paste-buffer -t "$_nh_session" -b "human-inject-${_nh_issue}" || true + sleep 0.5 + tmux send-keys -t "$_nh_session" "" Enter || true + tmux delete-buffer -b "human-inject-${_nh_issue}" 2>/dev/null || true + rm -f "$_nh_tmpfile" + + rm -f "$HUMAN_REPLY_FILE" + rm -f "/tmp/dev-renotify-${proj_name}-${_nh_issue}" + flog "${proj_name}: #${_nh_issue} human reply injected into session ${_nh_session}" + fixed "${proj_name}: Injected human reply into dev session #${_nh_issue}" + else + # No reply yet — check for timeout (re-notify at 6h, alert at 24h) + _nh_mtime=$(stat -c %Y "$_nh_phase_file" 2>/dev/null || echo 0) + _nh_now=$(date +%s) + _nh_age=$(( _nh_now - _nh_mtime )) + + if [ "$_nh_age" -gt 86400 ]; then + p2 "${proj_name}: Dev session #${_nh_issue} stuck in needs_human for >24h" + elif [ "$_nh_age" -gt 21600 ]; then + _nh_renotify="/tmp/dev-renotify-${proj_name}-${_nh_issue}" + if [ ! -f "$_nh_renotify" ]; then + _nh_age_h=$(( _nh_age / 3600 )) + matrix_send "dev" "⏰ Reminder: Issue #${_nh_issue} still needs human input (waiting ${_nh_age_h}h)" 2>/dev/null || true + touch "$_nh_renotify" + flog "${proj_name}: #${_nh_issue} re-notified (needs_human for ${_nh_age_h}h)" + fi + fi + fi + done + # =========================================================================== # P4-PROJECT: Clean stale worktrees for this project # =========================================================================== From bfe0c09b5c4f6dbcaefce7c3e874daeb2af03418 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 17 Mar 2026 22:40:54 +0000 Subject: [PATCH 2/3] fix: address review findings from issue #81 - Fix dev-agent.sh comment: gardener-poll.sh is the backup injector, not review-poll.sh - Add renotify marker cleanup to gardener injection path - Use atomic mv to claim reply file, preventing double-injection race between supervisor and gardener - Add break after supervisor injection for symmetry with gardener - Remove overly prescriptive PHASE:awaiting_ci hardcode from injection instructions Co-Authored-By: Claude Opus 4.6 --- dev/dev-agent.sh | 2 +- gardener/gardener-poll.sh | 11 ++++++----- supervisor/supervisor-poll.sh | 13 +++++++------ 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/dev/dev-agent.sh b/dev/dev-agent.sh index b1208f9..dffe7e9 100755 --- a/dev/dev-agent.sh +++ b/dev/dev-agent.sh @@ -1272,7 +1272,7 @@ Instructions: HUMAN_REASON=$(sed -n '2p' "$PHASE_FILE" 2>/dev/null | sed 's/^Reason: //' || echo "") notify "⚠️ Issue #${ISSUE} (PR #${PR_NUMBER:-none}) needs human input.${HUMAN_REASON:+ Reason: ${HUMAN_REASON}}" log "phase: needs_human — notified via Matrix, waiting for external injection" - # Don't inject anything — supervisor-poll.sh (#81) and review-poll.sh (#82) inject replies + # Don't inject anything — supervisor-poll.sh (#81) injects human replies, gardener-poll.sh as backup # ── PHASE: done ───────────────────────────────────────────────────────────── elif [ "$CURRENT_PHASE" = "PHASE:done" ]; then diff --git a/gardener/gardener-poll.sh b/gardener/gardener-poll.sh index 3dd58a6..896d77a 100755 --- a/gardener/gardener-poll.sh +++ b/gardener/gardener-poll.sh @@ -66,8 +66,10 @@ fi # ── Inject human replies into needs_human dev sessions (backup to supervisor) ─ HUMAN_REPLY_FILE="/tmp/dev-escalation-reply" -if [ -s "$HUMAN_REPLY_FILE" ]; then - _gr_reply=$(cat "$HUMAN_REPLY_FILE") +_gr_claimed="/tmp/dev-escalation-reply.gardener.$$" +if [ -s "$HUMAN_REPLY_FILE" ] && mv "$HUMAN_REPLY_FILE" "$_gr_claimed" 2>/dev/null; then + _gr_reply=$(cat "$_gr_claimed") + rm -f "$_gr_claimed" for _gr_phase_file in /tmp/dev-session-"${PROJECT_NAME}"-*.phase; do [ -f "$_gr_phase_file" ] || continue _gr_phase=$(head -1 "$_gr_phase_file" 2>/dev/null | tr -d '[:space:]' || true) @@ -87,8 +89,7 @@ ${_gr_reply} Instructions: 1. Read the human's guidance carefully. 2. Continue your work based on their input. -3. When done, push your changes and write the appropriate phase: - echo \"PHASE:awaiting_ci\" > \"${_gr_phase_file}\"" +3. When done, push your changes and write the appropriate phase." _gr_tmpfile=$(mktemp /tmp/human-inject-XXXXXX) printf '%s' "$_gr_inject_msg" > "$_gr_tmpfile" @@ -99,7 +100,7 @@ Instructions: tmux delete-buffer -b "human-inject-${_gr_issue}" 2>/dev/null || true rm -f "$_gr_tmpfile" - rm -f "$HUMAN_REPLY_FILE" + rm -f "/tmp/dev-renotify-${PROJECT_NAME}-${_gr_issue}" log "${PROJECT_NAME}: #${_gr_issue} human reply injected into session ${_gr_session} (gardener)" break # only one reply to deliver done diff --git a/supervisor/supervisor-poll.sh b/supervisor/supervisor-poll.sh index b1615f0..124fcef 100755 --- a/supervisor/supervisor-poll.sh +++ b/supervisor/supervisor-poll.sh @@ -536,9 +536,11 @@ check_project() { continue fi - # Inject human reply if available - if [ -s "$HUMAN_REPLY_FILE" ]; then - _nh_reply=$(cat "$HUMAN_REPLY_FILE") + # Inject human reply if available (atomic mv to prevent double-injection with gardener) + _nh_claimed="/tmp/dev-escalation-reply.supervisor.$$" + if [ -s "$HUMAN_REPLY_FILE" ] && mv "$HUMAN_REPLY_FILE" "$_nh_claimed" 2>/dev/null; then + _nh_reply=$(cat "$_nh_claimed") + rm -f "$_nh_claimed" _nh_inject_msg="Human reply received for issue #${_nh_issue}: ${_nh_reply} @@ -546,8 +548,7 @@ ${_nh_reply} Instructions: 1. Read the human's guidance carefully. 2. Continue your work based on their input. -3. When done, push your changes and write the appropriate phase: - echo \"PHASE:awaiting_ci\" > \"${_nh_phase_file}\"" +3. When done, push your changes and write the appropriate phase." _nh_tmpfile=$(mktemp /tmp/human-inject-XXXXXX) printf '%s' "$_nh_inject_msg" > "$_nh_tmpfile" @@ -559,10 +560,10 @@ Instructions: tmux delete-buffer -b "human-inject-${_nh_issue}" 2>/dev/null || true rm -f "$_nh_tmpfile" - rm -f "$HUMAN_REPLY_FILE" rm -f "/tmp/dev-renotify-${proj_name}-${_nh_issue}" flog "${proj_name}: #${_nh_issue} human reply injected into session ${_nh_session}" fixed "${proj_name}: Injected human reply into dev session #${_nh_issue}" + break # one reply to deliver else # No reply yet — check for timeout (re-notify at 6h, alert at 24h) _nh_mtime=$(stat -c %Y "$_nh_phase_file" 2>/dev/null || echo 0) From 63e60de9d6e124cb6e9a01924814aaf57dd900b2 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 17 Mar 2026 22:59:05 +0000 Subject: [PATCH 3/3] fix: address round 2 review findings from issue #81 - Move atomic mv inside gardener loop so reply is only claimed when a matching needs_human session exists (fixes reply-loss regression) - Delay rm of claimed file until after successful injection in both supervisor and gardener (OOM/SIGKILL leaves file recoverable) - Fix matrix_listener ack message: 'next poll' instead of 'next supervisor poll' Co-Authored-By: Claude Opus 4.6 --- gardener/gardener-poll.sh | 56 +++++++++++++++++------------------ lib/matrix_listener.sh | 2 +- supervisor/supervisor-poll.sh | 3 +- 3 files changed, 30 insertions(+), 31 deletions(-) diff --git a/gardener/gardener-poll.sh b/gardener/gardener-poll.sh index 896d77a..c87d57b 100755 --- a/gardener/gardener-poll.sh +++ b/gardener/gardener-poll.sh @@ -66,23 +66,24 @@ fi # ── Inject human replies into needs_human dev sessions (backup to supervisor) ─ HUMAN_REPLY_FILE="/tmp/dev-escalation-reply" -_gr_claimed="/tmp/dev-escalation-reply.gardener.$$" -if [ -s "$HUMAN_REPLY_FILE" ] && mv "$HUMAN_REPLY_FILE" "$_gr_claimed" 2>/dev/null; then +for _gr_phase_file in /tmp/dev-session-"${PROJECT_NAME}"-*.phase; do + [ -f "$_gr_phase_file" ] || continue + _gr_phase=$(head -1 "$_gr_phase_file" 2>/dev/null | tr -d '[:space:]' || true) + [ "$_gr_phase" = "PHASE:needs_human" ] || continue + + _gr_issue=$(basename "$_gr_phase_file" .phase) + _gr_issue="${_gr_issue#dev-session-${PROJECT_NAME}-}" + [ -z "$_gr_issue" ] && continue + _gr_session="dev-${PROJECT_NAME}-${_gr_issue}" + + tmux has-session -t "$_gr_session" 2>/dev/null || continue + + # Atomic claim — only take the file once we know a session needs it + _gr_claimed="/tmp/dev-escalation-reply.gardener.$$" + [ -s "$HUMAN_REPLY_FILE" ] && mv "$HUMAN_REPLY_FILE" "$_gr_claimed" 2>/dev/null || continue _gr_reply=$(cat "$_gr_claimed") - rm -f "$_gr_claimed" - for _gr_phase_file in /tmp/dev-session-"${PROJECT_NAME}"-*.phase; do - [ -f "$_gr_phase_file" ] || continue - _gr_phase=$(head -1 "$_gr_phase_file" 2>/dev/null | tr -d '[:space:]' || true) - [ "$_gr_phase" = "PHASE:needs_human" ] || continue - _gr_issue=$(basename "$_gr_phase_file" .phase) - _gr_issue="${_gr_issue#dev-session-${PROJECT_NAME}-}" - [ -z "$_gr_issue" ] && continue - _gr_session="dev-${PROJECT_NAME}-${_gr_issue}" - - tmux has-session -t "$_gr_session" 2>/dev/null || continue - - _gr_inject_msg="Human reply received for issue #${_gr_issue}: + _gr_inject_msg="Human reply received for issue #${_gr_issue}: ${_gr_reply} @@ -91,20 +92,19 @@ Instructions: 2. Continue your work based on their input. 3. When done, push your changes and write the appropriate phase." - _gr_tmpfile=$(mktemp /tmp/human-inject-XXXXXX) - printf '%s' "$_gr_inject_msg" > "$_gr_tmpfile" - tmux load-buffer -b "human-inject-${_gr_issue}" "$_gr_tmpfile" || true - tmux paste-buffer -t "$_gr_session" -b "human-inject-${_gr_issue}" || true - sleep 0.5 - tmux send-keys -t "$_gr_session" "" Enter || true - tmux delete-buffer -b "human-inject-${_gr_issue}" 2>/dev/null || true - rm -f "$_gr_tmpfile" + _gr_tmpfile=$(mktemp /tmp/human-inject-XXXXXX) + printf '%s' "$_gr_inject_msg" > "$_gr_tmpfile" + tmux load-buffer -b "human-inject-${_gr_issue}" "$_gr_tmpfile" || true + tmux paste-buffer -t "$_gr_session" -b "human-inject-${_gr_issue}" || true + sleep 0.5 + tmux send-keys -t "$_gr_session" "" Enter || true + tmux delete-buffer -b "human-inject-${_gr_issue}" 2>/dev/null || true + rm -f "$_gr_tmpfile" "$_gr_claimed" - rm -f "/tmp/dev-renotify-${PROJECT_NAME}-${_gr_issue}" - log "${PROJECT_NAME}: #${_gr_issue} human reply injected into session ${_gr_session} (gardener)" - break # only one reply to deliver - done -fi + rm -f "/tmp/dev-renotify-${PROJECT_NAME}-${_gr_issue}" + log "${PROJECT_NAME}: #${_gr_issue} human reply injected into session ${_gr_session} (gardener)" + break # only one reply to deliver +done # ── Fetch all open issues ───────────────────────────────────────────────── ISSUES_JSON=$(codeberg_api GET "/issues?state=open&type=issues&limit=50&sort=updated&direction=desc" 2>/dev/null || true) diff --git a/lib/matrix_listener.sh b/lib/matrix_listener.sh index a87e57c..d083243 100755 --- a/lib/matrix_listener.sh +++ b/lib/matrix_listener.sh @@ -143,7 +143,7 @@ while true; do ;; dev) printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SENDER" "$BODY" >> /tmp/dev-escalation-reply - matrix_send "dev" "✓ received, will inject on next supervisor poll" "$THREAD_ROOT" >/dev/null 2>&1 || true + matrix_send "dev" "✓ received, will inject on next poll" "$THREAD_ROOT" >/dev/null 2>&1 || true ;; vault) # Parse APPROVE or REJECT from reply diff --git a/supervisor/supervisor-poll.sh b/supervisor/supervisor-poll.sh index 124fcef..0b0ba9b 100755 --- a/supervisor/supervisor-poll.sh +++ b/supervisor/supervisor-poll.sh @@ -540,7 +540,6 @@ check_project() { _nh_claimed="/tmp/dev-escalation-reply.supervisor.$$" if [ -s "$HUMAN_REPLY_FILE" ] && mv "$HUMAN_REPLY_FILE" "$_nh_claimed" 2>/dev/null; then _nh_reply=$(cat "$_nh_claimed") - rm -f "$_nh_claimed" _nh_inject_msg="Human reply received for issue #${_nh_issue}: ${_nh_reply} @@ -558,7 +557,7 @@ Instructions: sleep 0.5 tmux send-keys -t "$_nh_session" "" Enter || true tmux delete-buffer -b "human-inject-${_nh_issue}" 2>/dev/null || true - rm -f "$_nh_tmpfile" + rm -f "$_nh_tmpfile" "$_nh_claimed" rm -f "/tmp/dev-renotify-${proj_name}-${_nh_issue}" flog "${proj_name}: #${_nh_issue} human reply injected into session ${_nh_session}"