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:
parent
3fdd45f617
commit
4e56d14e6a
5 changed files with 333 additions and 2 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
34
AGENTS.md
34
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 <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/`)
|
||||
|
||||
**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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
192
action/action-agent.sh
Normal file
192
action/action-agent.sh
Normal 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
75
action/action-poll.sh
Normal 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
|
||||
|
|
@ -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 <id> or REJECT <id> from reply
|
||||
VAULT_CMD=$(echo "$BODY" | tr '[:lower:]' '[:upper:]' | grep -oP '^\s*(APPROVE|REJECT)\s+\S+' | head -1 || true)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue