From 4e56d14e6ac3e622a81ca7416548515a083576aa Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 19 Mar 2026 07:25:25 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20feat:=20action-agent=20=E2=80=94=20tmux?= =?UTF-8?q?=20+=20Claude=20+=20formula=20for=20operational=20tasks=20(#139?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .woodpecker/agent-smoke.sh | 4 +- AGENTS.md | 34 ++++++- action/action-agent.sh | 192 +++++++++++++++++++++++++++++++++++++ action/action-poll.sh | 75 +++++++++++++++ lib/matrix_listener.sh | 30 ++++++ 5 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 action/action-agent.sh create mode 100644 action/action-poll.sh diff --git a/.woodpecker/agent-smoke.sh b/.woodpecker/agent-smoke.sh index 7bdcae3..fe322ec 100644 --- a/.woodpecker/agent-smoke.sh +++ b/.woodpecker/agent-smoke.sh @@ -78,7 +78,7 @@ while IFS= read -r -d '' f; do printf 'FAIL [syntax] %s\n' "$f" FAILED=1 fi -done < <(find dev gardener review planner supervisor lib vault -name "*.sh" -print0 2>/dev/null) +done < <(find dev gardener review planner supervisor lib vault action -name "*.sh" -print0 2>/dev/null) echo "syntax check done" # ── 2. Function-resolution check ───────────────────────────────────────────── @@ -165,6 +165,8 @@ check_script vault/vault-agent.sh check_script vault/vault-fire.sh check_script vault/vault-poll.sh check_script vault/vault-reject.sh +check_script action/action-poll.sh +check_script action/action-agent.sh echo "function resolution check done" diff --git a/AGENTS.md b/AGENTS.md index 2418912..73ab287 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ disinto/ ├── planner/ planner-poll.sh, planner-agent.sh — vision gap analysis ├── supervisor/ supervisor-poll.sh — health monitoring ├── vault/ vault-poll.sh, vault-agent.sh, vault-fire.sh — action gating +├── action/ action-poll.sh, action-agent.sh — operational task execution ├── lib/ env.sh, agent-session.sh, ci-helpers.sh, ci-debug.sh, load-project.sh, parse-deps.sh, matrix_listener.sh ├── projects/ *.toml — per-project config ├── formulas/ Issue templates @@ -165,6 +166,37 @@ gaps. - `CLAUDE_TIMEOUT` - `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` +### Action (`action/`) + +**Role**: Execute operational tasks described by action formulas — run scripts, +call APIs, send messages, collect human approval. Unlike the dev-agent, the +action-agent produces no PRs: Claude closes the issue directly after executing +all formula steps. + +**Trigger**: `action-poll.sh` runs every 10 min via cron. It scans for open +issues labeled `action` that have no active tmux session, then spawns +`action-agent.sh `. + +**Key files**: +- `action/action-poll.sh` — Cron scheduler: finds open action issues with no active tmux session, spawns action-agent.sh +- `action/action-agent.sh` — Orchestrator: fetches issue body + prior comments, creates tmux session (`action-{issue_num}`) with interactive `claude`, injects formula prompt, monitors session until Claude exits or 4h idle timeout + +**Session lifecycle**: +1. `action-poll.sh` finds open `action` issues with no active tmux session. +2. Spawns `action-agent.sh `. +3. Agent creates tmux session `action-{issue_num}`, injects prompt (formula + prior comments). +4. Claude executes formula steps using Bash and other tools, posts progress as issue comments. +5. For human input: Claude sends a Matrix message and waits; the reply is injected into the session by `matrix_listener.sh`. +6. When complete: Claude closes the issue with a summary comment. Session exits. +7. Poll detects no active session on next run — nothing further to do. + +**Environment variables consumed**: +- `CODEBERG_TOKEN`, `CODEBERG_REPO`, `CODEBERG_API`, `PROJECT_NAME`, `CODEBERG_WEB` +- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` — Matrix notifications + human input +- `ACTION_IDLE_TIMEOUT` — Max seconds before killing idle session (default 14400 = 4h) + +--- + ### Vault (`vault/`) **Role**: Safety gate for dangerous or irreversible actions. Actions enter a @@ -199,7 +231,7 @@ sourced as needed. | `lib/ci-debug.sh` | CLI tool for Woodpecker CI: `list`, `status`, `logs`, `failures` subcommands. Not sourced — run directly. | Humans / dev-agent (tool access) | | `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `CODEBERG_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, Matrix config, etc.). | env.sh (when `PROJECT_TOML` is set), supervisor-poll (per-project iteration) | | `lib/parse-deps.sh` | Extracts dependency issue numbers from an issue body (stdin → stdout, one number per line). Matches `## Dependencies` / `## Depends on` / `## Blocked by` sections and inline `depends on #N` patterns. Not sourced — executed via `bash lib/parse-deps.sh`. | dev-poll, supervisor-poll | -| `lib/matrix_listener.sh` | Long-poll Matrix sync daemon. Dispatches thread replies to the correct agent via well-known files (`/tmp/{agent}-escalation-reply`). Handles supervisor, gardener, dev, review, and vault reply routing. Run as systemd service. | Standalone daemon | +| `lib/matrix_listener.sh` | Long-poll Matrix sync daemon. Dispatches thread replies to the correct agent via well-known files (`/tmp/{agent}-escalation-reply`). Handles supervisor, gardener, dev, review, vault, and action reply routing. Run as systemd service. | Standalone daemon | | `lib/agent-session.sh` | Shared tmux + Claude session helpers: `create_agent_session()`, `inject_formula()`, `agent_wait_for_claude_ready()`, `agent_inject_into_session()`, `agent_kill_session()`, `monitor_phase_loop()`, `read_phase()`. | dev-agent.sh, gardener-agent.sh | --- diff --git a/action/action-agent.sh b/action/action-agent.sh new file mode 100644 index 0000000..2427f22 --- /dev/null +++ b/action/action-agent.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# action-agent.sh — Autonomous action agent: tmux + Claude + action formula +# +# Usage: ./action-agent.sh +# +# Lifecycle: +# 1. Fetch issue body (action formula) + existing comments +# 2. Create tmux session: action-{issue_num} with interactive claude +# 3. Inject initial prompt: formula + comments + instructions +# 4. Claude executes formula steps, posts progress comments, closes issue +# 5. For human input: Claude asks via Matrix; reply injected via matrix_listener +# 6. Monitor session until Claude exits or idle timeout reached +# +# Session: action-{issue_num} (tmux) +# Log: action/action-poll-{project}.log + +set -euo pipefail + +source "$(dirname "$0")/../lib/env.sh" +source "$(dirname "$0")/../lib/agent-session.sh" + +ISSUE="${1:?Usage: action-agent.sh }" +SESSION_NAME="action-${ISSUE}" +LOCKFILE="/tmp/action-agent-${ISSUE}.lock" +LOGFILE="${FACTORY_ROOT}/action/action-poll-${PROJECT_NAME:-harb}.log" +THREAD_FILE="/tmp/action-thread-${ISSUE}" +IDLE_TIMEOUT="${ACTION_IDLE_TIMEOUT:-14400}" # 4h default + +log() { + printf '[%s] #%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$ISSUE" "$*" >> "$LOGFILE" +} + +# --- Concurrency lock (per issue) --- +if [ -f "$LOCKFILE" ]; then + LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || echo "") + if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then + log "SKIP: action-agent already running for #${ISSUE} (PID ${LOCK_PID})" + exit 0 + fi + rm -f "$LOCKFILE" +fi +echo $$ > "$LOCKFILE" + +cleanup() { + rm -f "$LOCKFILE" + agent_kill_session "$SESSION_NAME" +} +trap cleanup EXIT + +# --- Memory guard --- +AVAIL_MB=$(awk '/MemAvailable/ {printf "%d", $2/1024}' /proc/meminfo) +if [ "$AVAIL_MB" -lt 2000 ]; then + log "SKIP: only ${AVAIL_MB}MB available (need 2000MB)" + exit 0 +fi + +# --- Fetch issue --- +log "fetching issue #${ISSUE}" +ISSUE_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ + "${CODEBERG_API}/issues/${ISSUE}") || true + +if [ -z "$ISSUE_JSON" ] || ! printf '%s' "$ISSUE_JSON" | jq -e '.id' >/dev/null 2>&1; then + log "ERROR: failed to fetch issue #${ISSUE}" + exit 1 +fi + +ISSUE_TITLE=$(printf '%s' "$ISSUE_JSON" | jq -r '.title') +ISSUE_BODY=$(printf '%s' "$ISSUE_JSON" | jq -r '.body // ""') +ISSUE_STATE=$(printf '%s' "$ISSUE_JSON" | jq -r '.state') + +if [ "$ISSUE_STATE" != "open" ]; then + log "SKIP: issue #${ISSUE} is ${ISSUE_STATE}" + exit 0 +fi + +log "Issue: ${ISSUE_TITLE}" + +# --- Fetch existing comments (resume context) --- +COMMENTS_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ + "${CODEBERG_API}/issues/${ISSUE}/comments?limit=50") || true + +PRIOR_COMMENTS="" +if [ -n "$COMMENTS_JSON" ] && [ "$COMMENTS_JSON" != "null" ] && [ "$COMMENTS_JSON" != "[]" ]; then + PRIOR_COMMENTS=$(printf '%s' "$COMMENTS_JSON" | \ + jq -r '.[] | "[\(.user.login) at \(.created_at[:19])]\n\(.body)\n---"' 2>/dev/null || true) +fi + +# --- Create Matrix thread for this issue --- +ISSUE_URL="${CODEBERG_WEB}/issues/${ISSUE}" +_thread_id=$(matrix_send_ctx "action" \ + "⚡ Action #${ISSUE}: ${ISSUE_TITLE} — ${ISSUE_URL}" \ + "⚡ Action #${ISSUE}: ${ISSUE_TITLE}") || true + +THREAD_ID="" +if [ -n "${_thread_id:-}" ]; then + printf '%s' "$_thread_id" > "$THREAD_FILE" + THREAD_ID="$_thread_id" + # Register thread root in map for listener dispatch (column 4 = issue number) + printf '%s\t%s\t%s\t%s\n' "$_thread_id" "action" "$(date +%s)" "${ISSUE}" \ + >> "${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}" 2>/dev/null || true +fi + +# --- Build initial prompt --- +PRIOR_SECTION="" +if [ -n "$PRIOR_COMMENTS" ]; then + PRIOR_SECTION="## Prior comments (resume context) + +${PRIOR_COMMENTS} + +" +fi + +THREAD_HINT="" +if [ -n "$THREAD_ID" ]; then + THREAD_HINT=" + The Matrix thread ID for this issue is: ${THREAD_ID} + Use it as the thread_event_id when sending Matrix messages so replies + are routed back to this session." +fi + +INITIAL_PROMPT="You are an action agent. Your job is to execute the action formula +in the issue below and then close the issue. + +## Issue #${ISSUE}: ${ISSUE_TITLE} + +${ISSUE_BODY} + +${PRIOR_SECTION}## Instructions + +1. Read the action formula steps in the issue body carefully. + +2. Execute each step in order using your Bash tool and any other tools available. + +3. Post progress as comments on issue #${ISSUE} after significant steps: + curl -sf -X POST \\ + -H \"Authorization: token \${CODEBERG_TOKEN}\" \\ + -H 'Content-Type: application/json' \\ + \"${CODEBERG_API}/issues/${ISSUE}/comments\" \\ + -d \"{\\\"body\\\": \\\"your comment here\\\"}\" + +4. If a step requires human input or approval, send a Matrix message explaining + what you need, then wait. A human will reply and the reply will be injected + into this session automatically.${THREAD_HINT} + +5. When all steps are complete, close issue #${ISSUE} with a summary: + curl -sf -X PATCH \\ + -H \"Authorization: token \${CODEBERG_TOKEN}\" \\ + -H 'Content-Type: application/json' \\ + \"${CODEBERG_API}/issues/${ISSUE}\" \\ + -d '{\"state\": \"closed\"}' + +6. Environment variables available in your bash sessions: + CODEBERG_TOKEN, CODEBERG_API, CODEBERG_REPO, CODEBERG_WEB, PROJECT_NAME + (all sourced from ${FACTORY_ROOT}/.env) + +**Important**: You do NOT need to create PRs or write a phase file. Just execute +the formula steps, post comments, and close the issue when done. If the prior +comments above show work already completed, resume from where it left off." + +# --- Create tmux session --- +log "creating tmux session: ${SESSION_NAME}" +if ! create_agent_session "${SESSION_NAME}" "${FACTORY_ROOT}"; then + log "ERROR: failed to create tmux session" + exit 1 +fi + +# --- Inject initial prompt --- +inject_formula "${SESSION_NAME}" "${INITIAL_PROMPT}" +log "initial prompt injected into session" + +matrix_send "action" "⚡ #${ISSUE}: session started — ${ISSUE_TITLE}" \ + "${THREAD_ID}" 2>/dev/null || true + +# --- Monitor session until Claude exits or idle timeout --- +log "monitoring session: ${SESSION_NAME} (idle_timeout=${IDLE_TIMEOUT}s)" +ELAPSED=0 +POLL_INTERVAL=30 + +while tmux has-session -t "${SESSION_NAME}" 2>/dev/null; do + sleep "$POLL_INTERVAL" + ELAPSED=$((ELAPSED + POLL_INTERVAL)) + + if [ "$ELAPSED" -ge "$IDLE_TIMEOUT" ]; then + log "idle timeout (${IDLE_TIMEOUT}s) — killing session for issue #${ISSUE}" + matrix_send "action" "⚠️ #${ISSUE}: session idle for $((IDLE_TIMEOUT / 3600))h — killed" \ + "${THREAD_ID}" 2>/dev/null || true + agent_kill_session "${SESSION_NAME}" + break + fi +done + +log "action-agent finished for issue #${ISSUE}" diff --git a/action/action-poll.sh b/action/action-poll.sh new file mode 100644 index 0000000..0271482 --- /dev/null +++ b/action/action-poll.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# action-poll.sh — Cron scheduler: find open 'action' issues, spawn action-agent +# +# An issue is ready for action if: +# - It is open and labeled 'action' +# - No tmux session named action-{issue_num} is already active +# +# Usage: +# cron every 10min +# action-poll.sh [projects/foo.toml] # optional project config + +set -euo pipefail + +export PROJECT_TOML="${1:-}" +source "$(dirname "$0")/../lib/env.sh" + +LOGFILE="${FACTORY_ROOT}/action/action-poll-${PROJECT_NAME:-harb}.log" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +log() { + printf '[%s] poll: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE" +} + +# --- Memory guard --- +AVAIL_MB=$(awk '/MemAvailable/{printf "%d", $2/1024}' /proc/meminfo) +if [ "$AVAIL_MB" -lt 2000 ]; then + log "SKIP: only ${AVAIL_MB}MB available (need 2000MB)" + matrix_send "action" "⚠️ Low memory (${AVAIL_MB}MB) — skipping action-poll" 2>/dev/null || true + exit 0 +fi + +# --- Find open 'action' issues --- +log "scanning for open action issues" +ACTION_ISSUES=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ + "${CODEBERG_API}/issues?state=open&labels=action&limit=50&type=issues") || true + +if [ -z "$ACTION_ISSUES" ] || [ "$ACTION_ISSUES" = "null" ]; then + log "no action issues found" + exit 0 +fi + +COUNT=$(printf '%s' "$ACTION_ISSUES" | jq 'length') +if [ "$COUNT" -eq 0 ]; then + log "no action issues found" + exit 0 +fi + +log "found ${COUNT} open action issue(s)" + +# Spawn action-agent for each issue that has no active tmux session. +# Only one agent is spawned per poll to avoid memory pressure; the next +# poll picks up remaining issues. +for i in $(seq 0 $((COUNT - 1))); do + ISSUE_NUM=$(printf '%s' "$ACTION_ISSUES" | jq -r ".[$i].number") + SESSION="action-${ISSUE_NUM}" + + if tmux has-session -t "$SESSION" 2>/dev/null; then + log "issue #${ISSUE_NUM}: session ${SESSION} already active, skipping" + continue + fi + + LOCKFILE="/tmp/action-agent-${ISSUE_NUM}.lock" + if [ -f "$LOCKFILE" ]; then + LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || echo "") + if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then + log "issue #${ISSUE_NUM}: agent starting (PID ${LOCK_PID}), skipping" + continue + fi + fi + + log "spawning action-agent for issue #${ISSUE_NUM}" + nohup "${SCRIPT_DIR}/action-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 & + log "started action-agent PID $! for issue #${ISSUE_NUM}" + break +done diff --git a/lib/matrix_listener.sh b/lib/matrix_listener.sh index 0b23073..4868f16 100755 --- a/lib/matrix_listener.sh +++ b/lib/matrix_listener.sh @@ -225,6 +225,36 @@ Please answer this question about your review. Explain your reasoning." matrix_send "review" "review session not available" "$THREAD_ROOT" >/dev/null 2>&1 || true fi ;; + action) + # Route reply into the action tmux session using context_tag (issue number) + ACTION_ISSUE=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $4}' "$THREAD_MAP" 2>/dev/null || true) + if [ -n "$ACTION_ISSUE" ]; then + ACTION_SESSION="action-${ACTION_ISSUE}" + if tmux has-session -t "$ACTION_SESSION" 2>/dev/null; then + ACTION_INJECT_MSG="Human reply from ${SENDER} in Matrix: + +${BODY} + +Continue with the action formula based on this response." + ACTION_INJECT_TMP=$(mktemp /tmp/action-q-inject-XXXXXX) + printf '%s' "$ACTION_INJECT_MSG" > "$ACTION_INJECT_TMP" + tmux load-buffer -b "action-q-${ACTION_ISSUE}" "$ACTION_INJECT_TMP" || true + tmux paste-buffer -t "$ACTION_SESSION" -b "action-q-${ACTION_ISSUE}" || true + sleep 0.5 + tmux send-keys -t "$ACTION_SESSION" "" Enter || true + tmux delete-buffer -b "action-q-${ACTION_ISSUE}" 2>/dev/null || true + rm -f "$ACTION_INJECT_TMP" + log "human reply from ${SENDER} injected into ${ACTION_SESSION}" + matrix_send "action" "✓ reply forwarded to action session for issue #${ACTION_ISSUE}" "$THREAD_ROOT" >/dev/null 2>&1 || true + else + log "action session ${ACTION_SESSION} not found for issue #${ACTION_ISSUE}" + matrix_send "action" "action session not active for issue #${ACTION_ISSUE}" "$THREAD_ROOT" >/dev/null 2>&1 || true + fi + else + log "action thread ${THREAD_ROOT:0:20} has no issue mapping" + matrix_send "action" "✓ received, no active session found" "$THREAD_ROOT" >/dev/null 2>&1 || true + fi + ;; vault) # Parse APPROVE or REJECT from reply VAULT_CMD=$(echo "$BODY" | tr '[:lower:]' '[:upper:]' | grep -oP '^\s*(APPROVE|REJECT)\s+\S+' | head -1 || true)