fix: feat: action-agent — tmux + Claude + formula for operational tasks (#139)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-19 07:25:25 +00:00
parent 3fdd45f617
commit 4e56d14e6a
5 changed files with 333 additions and 2 deletions

View file

@ -78,7 +78,7 @@ while IFS= read -r -d '' f; do
printf 'FAIL [syntax] %s\n' "$f" printf 'FAIL [syntax] %s\n' "$f"
FAILED=1 FAILED=1
fi 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" echo "syntax check done"
# ── 2. Function-resolution check ───────────────────────────────────────────── # ── 2. Function-resolution check ─────────────────────────────────────────────
@ -165,6 +165,8 @@ check_script vault/vault-agent.sh
check_script vault/vault-fire.sh check_script vault/vault-fire.sh
check_script vault/vault-poll.sh check_script vault/vault-poll.sh
check_script vault/vault-reject.sh check_script vault/vault-reject.sh
check_script action/action-poll.sh
check_script action/action-agent.sh
echo "function resolution check done" echo "function resolution check done"

View file

@ -20,6 +20,7 @@ disinto/
├── planner/ planner-poll.sh, planner-agent.sh — vision gap analysis ├── planner/ planner-poll.sh, planner-agent.sh — vision gap analysis
├── supervisor/ supervisor-poll.sh — health monitoring ├── supervisor/ supervisor-poll.sh — health monitoring
├── vault/ vault-poll.sh, vault-agent.sh, vault-fire.sh — action gating ├── 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 ├── 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 ├── projects/ *.toml — per-project config
├── formulas/ Issue templates ├── formulas/ Issue templates
@ -165,6 +166,37 @@ gaps.
- `CLAUDE_TIMEOUT` - `CLAUDE_TIMEOUT`
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` - `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 <issue-number>`.
**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 <issue_num>`.
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/`) ### Vault (`vault/`)
**Role**: Safety gate for dangerous or irreversible actions. Actions enter a **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/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/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/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 | | `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 |
--- ---

192
action/action-agent.sh Normal file
View file

@ -0,0 +1,192 @@
#!/usr/bin/env bash
# action-agent.sh — Autonomous action agent: tmux + Claude + action formula
#
# Usage: ./action-agent.sh <issue-number>
#
# 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 <issue-number>}"
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}" \
"⚡ <a href='${ISSUE_URL}'>Action #${ISSUE}</a>: ${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}"

75
action/action-poll.sh Normal file
View file

@ -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

View file

@ -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 matrix_send "review" "review session not available" "$THREAD_ROOT" >/dev/null 2>&1 || true
fi 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) vault)
# Parse APPROVE <id> or REJECT <id> from reply # Parse APPROVE <id> or REJECT <id> from reply
VAULT_CMD=$(echo "$BODY" | tr '[:lower:]' '[:upper:]' | grep -oP '^\s*(APPROVE|REJECT)\s+\S+' | head -1 || true) VAULT_CMD=$(echo "$BODY" | tr '[:lower:]' '[:upper:]' | grep -oP '^\s*(APPROVE|REJECT)\s+\S+' | head -1 || true)