fix: Remove Matrix integration — notifications move to forge + OpenClaw (#732)
Remove all Matrix/Dendrite infrastructure: - Delete lib/matrix_listener.sh (long-poll daemon), lib/matrix_listener.service (systemd unit), lib/hooks/on-stop-matrix.sh (response streaming hook) - Remove matrix_send() and matrix_send_ctx() from lib/env.sh - Remove MATRIX_HOMESERVER auto-detection, MATRIX_THREAD_MAP from lib/env.sh - Remove [matrix] section parsing from lib/load-project.sh - Remove Matrix hook installation from lib/agent-session.sh - Remove notify/notify_ctx helpers and Matrix thread tracking from dev/dev-agent.sh and action/action-agent.sh - Remove all matrix_send calls from dev-poll.sh, phase-handler.sh, action-poll.sh, vault-poll.sh, vault-fire.sh, vault-reject.sh, review-poll.sh, review-pr.sh, supervisor-poll.sh, formula-session.sh - Remove Matrix listener startup from docker/agents/entrypoint.sh - Remove append_dendrite_compose() and setup_matrix() from bin/disinto - Remove --matrix flag from disinto init - Clean Matrix references from .env.example, projects/*.toml.example, formulas/*.toml, AGENTS.md, BOOTSTRAP.md, README.md, RESOURCES.md, PHASE-PROTOCOL.md, and all agent AGENTS.md/PROMPT.md files Status visibility now via Codeberg PR/issue activity. Human interaction via vault items through forge. Proactive alerts via OpenClaw heartbeats. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7996bb6c06
commit
23949083c0
43 changed files with 73 additions and 1157 deletions
10
.env.example
10
.env.example
|
|
@ -38,16 +38,6 @@ WOODPECKER_DB_USER=woodpecker # [CONFIG] Postgres user
|
||||||
WOODPECKER_DB_HOST=127.0.0.1 # [CONFIG] Postgres host
|
WOODPECKER_DB_HOST=127.0.0.1 # [CONFIG] Postgres host
|
||||||
WOODPECKER_DB_NAME=woodpecker # [CONFIG] Postgres database name
|
WOODPECKER_DB_NAME=woodpecker # [CONFIG] Postgres database name
|
||||||
|
|
||||||
# ── Matrix (optional — real-time notifications & escalation replies) ──────
|
|
||||||
# In compose mode, Dendrite runs inside the Docker network. `disinto init`
|
|
||||||
# provisions the bot user, room, and token automatically.
|
|
||||||
# Compose: MATRIX_HOMESERVER defaults to http://dendrite:8008 (set by env.sh)
|
|
||||||
# Bare metal: MATRIX_HOMESERVER defaults to http://localhost:8008
|
|
||||||
MATRIX_HOMESERVER=http://dendrite:8008 # [CONFIG] Dendrite URL (compose default)
|
|
||||||
MATRIX_BOT_USER=@factory-bot:disinto.local # [CONFIG] bot's Matrix user ID
|
|
||||||
MATRIX_TOKEN= # [SECRET] bot's access token (auto-provisioned)
|
|
||||||
MATRIX_ROOM_ID= # [CONFIG] coordination room ID (auto-provisioned)
|
|
||||||
|
|
||||||
# ── Project-specific secrets ──────────────────────────────────────────────
|
# ── Project-specific secrets ──────────────────────────────────────────────
|
||||||
# Store all project secrets here so formulas reference env vars, never hardcode.
|
# Store all project secrets here so formulas reference env vars, never hardcode.
|
||||||
BASE_RPC_URL= # [SECRET] on-chain RPC endpoint
|
BASE_RPC_URL= # [SECRET] on-chain RPC endpoint
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,6 @@ echo "=== 2/2 Function resolution ==="
|
||||||
# Excluded — not sourced inline by agents:
|
# Excluded — not sourced inline by agents:
|
||||||
# lib/tea-helpers.sh — sourced conditionally by env.sh (tea_file_issue, etc.); checked standalone below
|
# lib/tea-helpers.sh — sourced conditionally by env.sh (tea_file_issue, etc.); checked standalone below
|
||||||
# lib/ci-debug.sh — standalone CLI tool, run directly (not sourced)
|
# lib/ci-debug.sh — standalone CLI tool, run directly (not sourced)
|
||||||
# lib/matrix_listener.sh — standalone systemd daemon (not sourced)
|
|
||||||
# lib/parse-deps.sh — executed via `bash lib/parse-deps.sh` (not sourced)
|
# lib/parse-deps.sh — executed via `bash lib/parse-deps.sh` (not sourced)
|
||||||
# lib/hooks/*.sh — Claude Code hook scripts, executed by the harness (not sourced)
|
# lib/hooks/*.sh — Claude Code hook scripts, executed by the harness (not sourced)
|
||||||
#
|
#
|
||||||
|
|
@ -189,7 +188,6 @@ check_script lib/guard.sh
|
||||||
# Standalone lib scripts (not sourced by agents; run directly or as services).
|
# Standalone lib scripts (not sourced by agents; run directly or as services).
|
||||||
# Still checked for function resolution against LIB_FUNS + own definitions.
|
# Still checked for function resolution against LIB_FUNS + own definitions.
|
||||||
check_script lib/ci-debug.sh
|
check_script lib/ci-debug.sh
|
||||||
check_script lib/matrix_listener.sh
|
|
||||||
check_script lib/parse-deps.sh
|
check_script lib/parse-deps.sh
|
||||||
|
|
||||||
# Agent scripts — list cross-sourced files where function scope flows across files.
|
# Agent scripts — list cross-sourced files where function scope flows across files.
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ disinto/
|
||||||
│ supervisor-poll.sh — legacy bash orchestrator (superseded)
|
│ supervisor-poll.sh — legacy bash orchestrator (superseded)
|
||||||
├── vault/ vault-poll.sh, vault-agent.sh, vault-fire.sh — action gating + procurement
|
├── vault/ vault-poll.sh, vault-agent.sh, vault-fire.sh — action gating + procurement
|
||||||
├── action/ action-poll.sh, action-agent.sh — operational task execution
|
├── 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, guard.sh, mirrors.sh, build-graph.py
|
├── lib/ env.sh, agent-session.sh, ci-helpers.sh, ci-debug.sh, load-project.sh, parse-deps.sh, guard.sh, mirrors.sh, build-graph.py
|
||||||
├── projects/ *.toml.example — templates; *.toml — local per-box config (gitignored)
|
├── projects/ *.toml.example — templates; *.toml — local per-box config (gitignored)
|
||||||
├── formulas/ Issue templates (TOML specs for multi-step agent tasks)
|
├── formulas/ Issue templates (TOML specs for multi-step agent tasks)
|
||||||
└── docs/ Protocol docs (PHASE-PROTOCOL.md, EVIDENCE-ARCHITECTURE.md)
|
└── docs/ Protocol docs (PHASE-PROTOCOL.md, EVIDENCE-ARCHITECTURE.md)
|
||||||
|
|
@ -43,7 +43,7 @@ disinto/
|
||||||
- **AI**: `claude -p` (one-shot) or `claude` (interactive/tmux sessions)
|
- **AI**: `claude -p` (one-shot) or `claude` (interactive/tmux sessions)
|
||||||
- **CI**: Woodpecker CI (queried via REST API + Postgres)
|
- **CI**: Woodpecker CI (queried via REST API + Postgres)
|
||||||
- **VCS**: Forgejo (git + Gitea-compatible REST API)
|
- **VCS**: Forgejo (git + Gitea-compatible REST API)
|
||||||
- **Notifications**: Matrix (optional)
|
- **Notifications**: Forge activity (PR/issue comments), OpenClaw heartbeats
|
||||||
|
|
||||||
## Coding conventions
|
## Coding conventions
|
||||||
|
|
||||||
|
|
|
||||||
38
BOOTSTRAP.md
38
BOOTSTRAP.md
|
|
@ -88,12 +88,6 @@ WOODPECKER_DB_USER=woodpecker
|
||||||
WOODPECKER_DB_HOST=127.0.0.1
|
WOODPECKER_DB_HOST=127.0.0.1
|
||||||
WOODPECKER_DB_NAME=woodpecker
|
WOODPECKER_DB_NAME=woodpecker
|
||||||
|
|
||||||
# ── Optional: Matrix notifications ──────────────────────────
|
|
||||||
# MATRIX_HOMESERVER=http://localhost:8008
|
|
||||||
# MATRIX_BOT_USER=@factory:your.server
|
|
||||||
# MATRIX_TOKEN=
|
|
||||||
# MATRIX_ROOM_ID=
|
|
||||||
|
|
||||||
# ── Tuning ──────────────────────────────────────────────────
|
# ── Tuning ──────────────────────────────────────────────────
|
||||||
CLAUDE_TIMEOUT=7200 # seconds per Claude invocation
|
CLAUDE_TIMEOUT=7200 # seconds per Claude invocation
|
||||||
```
|
```
|
||||||
|
|
@ -395,38 +389,6 @@ tail -30 dev/dev-agent.log
|
||||||
tail -30 review/review.log
|
tail -30 review/review.log
|
||||||
```
|
```
|
||||||
|
|
||||||
## 10. Optional: Matrix Notifications
|
|
||||||
|
|
||||||
If you want real-time notifications and human-in-the-loop escalation:
|
|
||||||
|
|
||||||
1. Set `MATRIX_*` vars in `.env`
|
|
||||||
2. Install the listener as a systemd service:
|
|
||||||
```bash
|
|
||||||
sudo cp lib/matrix_listener.service /etc/systemd/system/
|
|
||||||
sudo systemctl enable --now matrix_listener
|
|
||||||
```
|
|
||||||
3. The supervisor and gardener will post status updates and escalation threads to the configured room. Reply in-thread to answer escalations.
|
|
||||||
|
|
||||||
### Per-project Matrix setup
|
|
||||||
|
|
||||||
Each project can post to its own Matrix room. For each project:
|
|
||||||
|
|
||||||
1. **Create a Matrix room** and note its room ID (e.g. `!abc123:matrix.example.org`)
|
|
||||||
2. **Create a bot user** (or reuse one) and join it to the room
|
|
||||||
3. **Add the token** to `.env` using a project-prefixed name:
|
|
||||||
```bash
|
|
||||||
PROJECTNAME_MATRIX_TOKEN=syt_xxxxx
|
|
||||||
```
|
|
||||||
4. **Configure the TOML** with a `[matrix]` section:
|
|
||||||
```toml
|
|
||||||
[matrix]
|
|
||||||
room_id = "!abc123:matrix.example.org"
|
|
||||||
bot_user = "@projectname-bot:matrix.example.org"
|
|
||||||
token_env = "PROJECTNAME_MATRIX_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
The `token_env` field points to the environment variable name, not the token value itself, so you can have multiple bots with separate credentials in a single `.env`.
|
|
||||||
|
|
||||||
## Lifecycle
|
## Lifecycle
|
||||||
|
|
||||||
Once running, the system operates autonomously:
|
Once running, the system operates autonomously:
|
||||||
|
|
|
||||||
11
README.md
11
README.md
|
|
@ -40,10 +40,6 @@ cron (weekly) ──→ planner-poll.sh ← gap-analyse VISION.md, create backl
|
||||||
cron (*/30) ──→ vault-poll.sh ← safety gate for dangerous/irreversible actions
|
cron (*/30) ──→ vault-poll.sh ← safety gate for dangerous/irreversible actions
|
||||||
└── claude -p: classify → auto-approve/reject or escalate
|
└── claude -p: classify → auto-approve/reject or escalate
|
||||||
|
|
||||||
systemd ──→ matrix_listener.sh ← long-poll daemon for human replies
|
|
||||||
└── dispatches thread replies → supervisor/gardener/dev/review/vault
|
|
||||||
|
|
||||||
all agents ──→ matrix_send() ← status updates, escalations, merge notifications
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
@ -58,7 +54,6 @@ all agents ──→ matrix_send() ← status updates, escalations, merge no
|
||||||
|
|
||||||
**Optional:**
|
**Optional:**
|
||||||
|
|
||||||
- [Matrix](https://matrix.org/) homeserver ([Dendrite](https://github.com/matrix-org/dendrite) or Synapse) — real-time notifications, escalation threads with human-in-the-loop replies
|
|
||||||
- [Foundry](https://getfoundry.sh/) (`forge`, `cast`, `anvil`) — only needed if your target project uses Solidity
|
- [Foundry](https://getfoundry.sh/) (`forge`, `cast`, `anvil`) — only needed if your target project uses Solidity
|
||||||
- [Node.js](https://nodejs.org/) — only needed if your target project uses Node
|
- [Node.js](https://nodejs.org/) — only needed if your target project uses Node
|
||||||
|
|
||||||
|
|
@ -115,10 +110,8 @@ disinto/
|
||||||
├── .env.example # Template — copy to .env, add secrets + project config
|
├── .env.example # Template — copy to .env, add secrets + project config
|
||||||
├── .gitignore # Excludes .env, logs, state files
|
├── .gitignore # Excludes .env, logs, state files
|
||||||
├── lib/
|
├── lib/
|
||||||
│ ├── env.sh # Shared: load .env, PATH, API helpers, matrix_send()
|
│ ├── env.sh # Shared: load .env, PATH, API helpers
|
||||||
│ ├── ci-debug.sh # Woodpecker CI log/failure helper
|
│ └── ci-debug.sh # Woodpecker CI log/failure helper
|
||||||
│ ├── matrix_listener.sh # Matrix long-poll daemon (dispatches replies)
|
|
||||||
│ └── matrix_listener.service # systemd unit for the listener
|
|
||||||
├── dev/
|
├── dev/
|
||||||
│ ├── dev-poll.sh # Cron entry: find ready issues
|
│ ├── dev-poll.sh # Cron entry: find ready issues
|
||||||
│ └── dev-agent.sh # Implementation agent (claude -p)
|
│ └── dev-agent.sh # Implementation agent (claude -p)
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,6 @@
|
||||||
- domain: disinto.ai, www.disinto.ai
|
- domain: disinto.ai, www.disinto.ai
|
||||||
- note: served by Caddy on harb-staging
|
- note: served by Caddy on harb-staging
|
||||||
|
|
||||||
## matrix-bot
|
|
||||||
- type: communication
|
|
||||||
- capability: post factory status, receive human replies, escalation channel
|
|
||||||
- env: MATRIX_TOKEN
|
|
||||||
- note: used by supervisor and dev-agent for notifications
|
|
||||||
|
|
||||||
## telegram-clawy
|
## telegram-clawy
|
||||||
- type: communication
|
- type: communication
|
||||||
- capability: notify human, collect decisions, relay vault requests
|
- capability: notify human, collect decisions, relay vault requests
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,14 @@ session, and spawns `action-agent.sh <issue-number>`.
|
||||||
**Session lifecycle**:
|
**Session lifecycle**:
|
||||||
1. `action-poll.sh` finds open `action` issues with no active tmux session.
|
1. `action-poll.sh` finds open `action` issues with no active tmux session.
|
||||||
2. Spawns `action-agent.sh <issue_num>`.
|
2. Spawns `action-agent.sh <issue_num>`.
|
||||||
3. Agent creates Matrix thread, exports `MATRIX_THREAD_ID` so Claude's output streams to the thread via a Stop hook (`on-stop-matrix.sh`).
|
3. Agent creates tmux session `action-{project}-{issue_num}`, injects prompt (formula + prior comments + phase protocol).
|
||||||
4. Agent creates tmux session `action-{project}-{issue_num}`, injects prompt (formula + prior comments + phase protocol).
|
4. Agent enters `monitor_phase_loop` (shared with dev-agent via `dev/phase-handler.sh`).
|
||||||
5. Agent enters `monitor_phase_loop` (shared with dev-agent via `dev/phase-handler.sh`).
|
5. **Path A (git output):** Claude pushes branch → `PHASE:awaiting_ci` → handler creates PR, polls CI → injects failures → Claude fixes → push → re-poll → CI passes → `PHASE:awaiting_review` → handler polls reviews → injects REQUEST_CHANGES → Claude fixes → approved → merge → cleanup.
|
||||||
6. **Path A (git output):** Claude pushes branch → `PHASE:awaiting_ci` → handler creates PR, polls CI → injects failures → Claude fixes → push → re-poll → CI passes → `PHASE:awaiting_review` → handler polls reviews → injects REQUEST_CHANGES → Claude fixes → approved → merge → cleanup.
|
6. **Path B (no git output):** Claude posts results as comment, closes issue → `PHASE:done` → handler cleans up (kill session, docker compose down, remove temp files).
|
||||||
7. **Path B (no git output):** Claude posts results as comment, closes issue → `PHASE:done` → handler cleans up (kill session, docker compose down, remove temp files).
|
7. For human input: Claude writes `PHASE:escalate`; human responds via vault/forge.
|
||||||
8. For human input: Claude sends a Matrix message and waits; the reply is injected into the session by `matrix_listener.sh`.
|
|
||||||
|
|
||||||
**Environment variables consumed**:
|
**Environment variables consumed**:
|
||||||
- `FORGE_TOKEN`, `FORGE_REPO`, `FORGE_API`, `FORGE_URL`, `PROJECT_NAME`, `FORGE_WEB`
|
- `FORGE_TOKEN`, `FORGE_REPO`, `FORGE_API`, `FORGE_URL`, `PROJECT_NAME`, `FORGE_WEB`
|
||||||
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` — Matrix notifications + human input
|
|
||||||
- `ACTION_IDLE_TIMEOUT` — Max seconds before killing idle session (default 14400 = 4h)
|
- `ACTION_IDLE_TIMEOUT` — Max seconds before killing idle session (default 14400 = 4h)
|
||||||
- `ACTION_MAX_LIFETIME` — Max total session wall-clock seconds (default 28800 = 8h); caps session independently of idle timeout
|
- `ACTION_MAX_LIFETIME` — Max total session wall-clock seconds (default 28800 = 8h); caps session independently of idle timeout
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
# Path A (git output): Claude pushes → handler creates PR → CI poll → review
|
# Path A (git output): Claude pushes → handler creates PR → CI poll → review
|
||||||
# injection → merge → cleanup (same loop as dev-agent via phase-handler.sh)
|
# injection → merge → cleanup (same loop as dev-agent via phase-handler.sh)
|
||||||
# Path B (no git output): Claude posts results → PHASE:done → cleanup
|
# Path B (no git output): Claude posts results → PHASE:done → cleanup
|
||||||
# 6. For human input: Claude asks via Matrix; reply injected via matrix_listener
|
# 6. For human input: Claude writes PHASE:escalate; human responds via vault/forge
|
||||||
# 7. Cleanup on terminal phase: kill children, destroy worktree, remove temp files
|
# 7. Cleanup on terminal phase: kill children, destroy worktree, remove temp files
|
||||||
#
|
#
|
||||||
# Key principle: The runtime creates and destroys. The formula preserves.
|
# Key principle: The runtime creates and destroys. The formula preserves.
|
||||||
|
|
@ -35,7 +35,6 @@ source "$(dirname "$0")/../dev/phase-handler.sh"
|
||||||
SESSION_NAME="action-${PROJECT_NAME}-${ISSUE}"
|
SESSION_NAME="action-${PROJECT_NAME}-${ISSUE}"
|
||||||
LOCKFILE="/tmp/action-agent-${ISSUE}.lock"
|
LOCKFILE="/tmp/action-agent-${ISSUE}.lock"
|
||||||
LOGFILE="${FACTORY_ROOT}/action/action-poll-${PROJECT_NAME:-default}.log"
|
LOGFILE="${FACTORY_ROOT}/action/action-poll-${PROJECT_NAME:-default}.log"
|
||||||
THREAD_FILE="/tmp/action-thread-${ISSUE}"
|
|
||||||
IDLE_TIMEOUT="${ACTION_IDLE_TIMEOUT:-14400}" # 4h default
|
IDLE_TIMEOUT="${ACTION_IDLE_TIMEOUT:-14400}" # 4h default
|
||||||
MAX_LIFETIME="${ACTION_MAX_LIFETIME:-28800}" # 8h default wall-clock cap
|
MAX_LIFETIME="${ACTION_MAX_LIFETIME:-28800}" # 8h default wall-clock cap
|
||||||
SESSION_START_EPOCH=$(date +%s)
|
SESSION_START_EPOCH=$(date +%s)
|
||||||
|
|
@ -55,22 +54,6 @@ log() {
|
||||||
printf '[%s] action#%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$ISSUE" "$*" >> "$LOGFILE"
|
printf '[%s] action#%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$ISSUE" "$*" >> "$LOGFILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
notify() {
|
|
||||||
local thread_id=""
|
|
||||||
[ -f "${THREAD_FILE:-}" ] && thread_id=$(cat "$THREAD_FILE" 2>/dev/null || true)
|
|
||||||
matrix_send "action" "⚡ #${ISSUE}: $*" "${thread_id}" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
notify_ctx() {
|
|
||||||
local plain="$1" html="$2" thread_id=""
|
|
||||||
[ -f "${THREAD_FILE:-}" ] && thread_id=$(cat "$THREAD_FILE" 2>/dev/null || true)
|
|
||||||
if [ -n "$thread_id" ]; then
|
|
||||||
matrix_send_ctx "action" "⚡ #${ISSUE}: ${plain}" "⚡ #${ISSUE}: ${html}" "${thread_id}" 2>/dev/null || true
|
|
||||||
else
|
|
||||||
matrix_send "action" "⚡ #${ISSUE}: ${plain}" "" "${ISSUE}" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
status() {
|
status() {
|
||||||
log "$*"
|
log "$*"
|
||||||
}
|
}
|
||||||
|
|
@ -211,23 +194,6 @@ if [ -n "$COMMENTS_JSON" ] && [ "$COMMENTS_JSON" != "null" ] && [ "$COMMENTS_JSO
|
||||||
"[\(.user.login) at \(.created_at[:19])]\n\(.body)\n---"' 2>/dev/null || true)
|
"[\(.user.login) at \(.created_at[:19])]\n\(.body)\n---"' 2>/dev/null || true)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Create Matrix thread for this issue ---
|
|
||||||
ISSUE_URL="${FORGE_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"
|
|
||||||
# Export for on-stop-matrix.sh hook (streams Claude output to thread)
|
|
||||||
export MATRIX_THREAD_ID="$_thread_id"
|
|
||||||
# Register thread root in map for listener dispatch (column 4 = issue number)
|
|
||||||
printf '%s\t%s\t%s\t%s\t%s\n' "$_thread_id" "action" "$(date +%s)" "${ISSUE}" "${PROJECT_NAME}" \
|
|
||||||
>> "${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Create isolated worktree ---
|
# --- Create isolated worktree ---
|
||||||
log "creating worktree: ${WORKTREE}"
|
log "creating worktree: ${WORKTREE}"
|
||||||
cd "${PROJECT_REPO_ROOT}"
|
cd "${PROJECT_REPO_ROOT}"
|
||||||
|
|
@ -241,7 +207,6 @@ export FORGE_REMOTE
|
||||||
git fetch "${FORGE_REMOTE}" "${PRIMARY_BRANCH}" 2>/dev/null || true
|
git fetch "${FORGE_REMOTE}" "${PRIMARY_BRANCH}" 2>/dev/null || true
|
||||||
if ! git worktree add "$WORKTREE" "${FORGE_REMOTE}/${PRIMARY_BRANCH}" 2>&1; then
|
if ! git worktree add "$WORKTREE" "${FORGE_REMOTE}/${PRIMARY_BRANCH}" 2>&1; then
|
||||||
log "ERROR: worktree creation failed"
|
log "ERROR: worktree creation failed"
|
||||||
notify "failed to create worktree for #${ISSUE}"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
log "worktree ready: ${WORKTREE}"
|
log "worktree ready: ${WORKTREE}"
|
||||||
|
|
@ -260,14 +225,6 @@ ${PRIOR_COMMENTS}
|
||||||
"
|
"
|
||||||
fi
|
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
|
|
||||||
|
|
||||||
# Build phase protocol from shared function (Path B covered in Instructions section above)
|
# Build phase protocol from shared function (Path B covered in Instructions section above)
|
||||||
PHASE_PROTOCOL_INSTRUCTIONS="$(build_phase_protocol_prompt "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$BRANCH")"
|
PHASE_PROTOCOL_INSTRUCTIONS="$(build_phase_protocol_prompt "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$BRANCH")"
|
||||||
|
|
||||||
|
|
@ -294,9 +251,8 @@ ${PRIOR_SECTION}## Instructions
|
||||||
\"${FORGE_API}/issues/${ISSUE}/comments\" \\
|
\"${FORGE_API}/issues/${ISSUE}/comments\" \\
|
||||||
-d \"{\\\"body\\\": \\\"your comment here\\\"}\"
|
-d \"{\\\"body\\\": \\\"your comment here\\\"}\"
|
||||||
|
|
||||||
4. If a step requires human input or approval, send a Matrix message explaining
|
4. If a step requires human input or approval, write PHASE:escalate with a reason.
|
||||||
what you need, then wait. A human will reply and the reply will be injected
|
A human will review and respond via the forge.
|
||||||
into this session automatically.${THREAD_HINT}
|
|
||||||
|
|
||||||
### Path A: If this action produces code changes (e.g. config updates, baselines):
|
### Path A: If this action produces code changes (e.g. config updates, baselines):
|
||||||
- You are already in an isolated worktree at: ${WORKTREE}
|
- You are already in an isolated worktree at: ${WORKTREE}
|
||||||
|
|
@ -348,9 +304,6 @@ fi
|
||||||
inject_formula "${SESSION_NAME}" "${INITIAL_PROMPT}"
|
inject_formula "${SESSION_NAME}" "${INITIAL_PROMPT}"
|
||||||
log "initial prompt injected into session"
|
log "initial prompt injected into session"
|
||||||
|
|
||||||
matrix_send "action" "⚡ #${ISSUE}: session started — ${ISSUE_TITLE}" \
|
|
||||||
"${THREAD_ID}" 2>/dev/null || true
|
|
||||||
|
|
||||||
# --- Wall-clock lifetime watchdog (background) ---
|
# --- Wall-clock lifetime watchdog (background) ---
|
||||||
# Caps total session time independently of idle timeout. When the cap is
|
# Caps total session time independently of idle timeout. When the cap is
|
||||||
# hit the watchdog kills the tmux session, posts a summary comment on the
|
# hit the watchdog kills the tmux session, posts a summary comment on the
|
||||||
|
|
@ -383,31 +336,25 @@ monitor_phase_loop "$PHASE_FILE" "$IDLE_TIMEOUT" _on_phase_change "$SESSION_NAME
|
||||||
# Handle exit reason from monitor_phase_loop
|
# Handle exit reason from monitor_phase_loop
|
||||||
case "${_MONITOR_LOOP_EXIT:-}" in
|
case "${_MONITOR_LOOP_EXIT:-}" in
|
||||||
idle_timeout)
|
idle_timeout)
|
||||||
notify_ctx \
|
|
||||||
"session idle for $((IDLE_TIMEOUT / 3600))h — killed" \
|
|
||||||
"session idle for $((IDLE_TIMEOUT / 3600))h — killed"
|
|
||||||
# Post diagnostic comment + label blocked
|
# Post diagnostic comment + label blocked
|
||||||
post_blocked_diagnostic "idle_timeout"
|
post_blocked_diagnostic "idle_timeout"
|
||||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE"
|
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$SCRATCH_FILE"
|
||||||
;;
|
;;
|
||||||
idle_prompt)
|
idle_prompt)
|
||||||
# Notification + blocked label already handled by _on_phase_change(PHASE:failed) callback
|
# Notification + blocked label already handled by _on_phase_change(PHASE:failed) callback
|
||||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE"
|
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$SCRATCH_FILE"
|
||||||
;;
|
;;
|
||||||
PHASE:failed)
|
PHASE:failed)
|
||||||
# Check if this was a max_lifetime kill (phase file contains the reason)
|
# Check if this was a max_lifetime kill (phase file contains the reason)
|
||||||
if grep -q 'max_lifetime' "$PHASE_FILE" 2>/dev/null; then
|
if grep -q 'max_lifetime' "$PHASE_FILE" 2>/dev/null; then
|
||||||
notify_ctx \
|
|
||||||
"session killed — wall-clock cap ($((MAX_LIFETIME / 3600))h) reached" \
|
|
||||||
"session killed — wall-clock cap ($((MAX_LIFETIME / 3600))h) reached"
|
|
||||||
post_blocked_diagnostic "max_lifetime"
|
post_blocked_diagnostic "max_lifetime"
|
||||||
fi
|
fi
|
||||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE"
|
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$SCRATCH_FILE"
|
||||||
;;
|
;;
|
||||||
done)
|
done)
|
||||||
# Belt-and-suspenders: callback handles primary cleanup,
|
# Belt-and-suspenders: callback handles primary cleanup,
|
||||||
# but ensure sentinel files are removed if callback was interrupted
|
# but ensure sentinel files are removed if callback was interrupted
|
||||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE"
|
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$IMPL_SUMMARY_FILE" "$SCRATCH_FILE"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ log() {
|
||||||
AVAIL_MB=$(awk '/MemAvailable/{printf "%d", $2/1024}' /proc/meminfo)
|
AVAIL_MB=$(awk '/MemAvailable/{printf "%d", $2/1024}' /proc/meminfo)
|
||||||
if [ "$AVAIL_MB" -lt 2000 ]; then
|
if [ "$AVAIL_MB" -lt 2000 ]; then
|
||||||
log "SKIP: only ${AVAIL_MB}MB available (need 2000MB)"
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
225
bin/disinto
225
bin/disinto
|
|
@ -156,7 +156,7 @@ generate_compose() {
|
||||||
cat > "$compose_file" <<'COMPOSEEOF'
|
cat > "$compose_file" <<'COMPOSEEOF'
|
||||||
# docker-compose.yml — generated by disinto init
|
# docker-compose.yml — generated by disinto init
|
||||||
# Brings up Forgejo, Woodpecker, and the agent runtime.
|
# Brings up Forgejo, Woodpecker, and the agent runtime.
|
||||||
# Dendrite (Matrix) is added only when init is called with --matrix.
|
# Brings up Forgejo, Woodpecker, and the agent runtime.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
forgejo:
|
forgejo:
|
||||||
|
|
@ -272,70 +272,6 @@ COMPOSEEOF
|
||||||
echo "Created: ${compose_file}"
|
echo "Created: ${compose_file}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Append Dendrite (Matrix) service to docker-compose.yml and generate config.
|
|
||||||
# Called only when --matrix flag is passed to init.
|
|
||||||
append_dendrite_compose() {
|
|
||||||
local compose_file="${FACTORY_ROOT}/docker-compose.yml"
|
|
||||||
local dendrite_config_dir="${FACTORY_ROOT}/docker/dendrite"
|
|
||||||
local dendrite_yaml="${dendrite_config_dir}/dendrite.yaml"
|
|
||||||
|
|
||||||
mkdir -p "$dendrite_config_dir"
|
|
||||||
|
|
||||||
# Generate a minimal dendrite.yaml
|
|
||||||
cat > "$dendrite_yaml" <<'DENDRITECFG'
|
|
||||||
# dendrite.yaml — generated by disinto init --matrix
|
|
||||||
version: 2
|
|
||||||
global:
|
|
||||||
server_name: disinto.local
|
|
||||||
private_key: matrix_key.pem
|
|
||||||
database:
|
|
||||||
connection_string: file:dendrite.db
|
|
||||||
cache:
|
|
||||||
max_size_estimated: 512mb
|
|
||||||
jetstream:
|
|
||||||
storage_path: /etc/dendrite/jetstream
|
|
||||||
client_api:
|
|
||||||
registration_disabled: true
|
|
||||||
DENDRITECFG
|
|
||||||
echo "Created: ${dendrite_yaml}"
|
|
||||||
|
|
||||||
# Append dendrite service before the volumes: section
|
|
||||||
python3 -c "
|
|
||||||
import sys, pathlib
|
|
||||||
p = pathlib.Path(sys.argv[1])
|
|
||||||
text = p.read_text()
|
|
||||||
dendrite_service = '''
|
|
||||||
dendrite:
|
|
||||||
image: matrixdotorg/dendrite-monolith:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- dendrite-data:/etc/dendrite
|
|
||||||
- ./docker/dendrite/dendrite.yaml:/etc/dendrite/dendrite.yaml:ro
|
|
||||||
environment:
|
|
||||||
DENDRITE_DOMAIN: disinto.local
|
|
||||||
networks:
|
|
||||||
- disinto-net
|
|
||||||
'''
|
|
||||||
# Insert dendrite service before 'volumes:' line
|
|
||||||
text = text.replace('\nvolumes:\n', dendrite_service + '\nvolumes:\n', 1)
|
|
||||||
# Add dendrite-data volume
|
|
||||||
text = text.replace(' agent-data:', ' dendrite-data:\n agent-data:')
|
|
||||||
# Add MATRIX_HOMESERVER env var to agents service
|
|
||||||
text = text.replace(
|
|
||||||
' DISINTO_CONTAINER: \"1\"',
|
|
||||||
' MATRIX_HOMESERVER: http://dendrite:8008\n DISINTO_CONTAINER: \"1\"'
|
|
||||||
)
|
|
||||||
# Add dendrite dependency to agents service
|
|
||||||
text = text.replace(
|
|
||||||
' - woodpecker\n networks:',
|
|
||||||
' - woodpecker\n - dendrite\n networks:'
|
|
||||||
)
|
|
||||||
p.write_text(text)
|
|
||||||
" "$compose_file"
|
|
||||||
|
|
||||||
echo "Updated: ${compose_file} (added Dendrite service)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Generate docker/agents/ files if they don't already exist.
|
# Generate docker/agents/ files if they don't already exist.
|
||||||
generate_agent_docker() {
|
generate_agent_docker() {
|
||||||
local docker_dir="${FACTORY_ROOT}/docker/agents"
|
local docker_dir="${FACTORY_ROOT}/docker/agents"
|
||||||
|
|
@ -1108,151 +1044,6 @@ activate_woodpecker_repo() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Provision Dendrite Matrix homeserver: create bot user, room, and access token.
|
|
||||||
# Stores MATRIX_TOKEN, MATRIX_ROOM_ID, MATRIX_BOT_USER in .env.
|
|
||||||
setup_matrix() {
|
|
||||||
local use_bare="${DISINTO_BARE:-false}"
|
|
||||||
local env_file="${FACTORY_ROOT}/.env"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "── Matrix setup ───────────────────────────────────────"
|
|
||||||
|
|
||||||
# In compose mode, Dendrite runs inside the network at http://dendrite:8008.
|
|
||||||
# For provisioning from the host during init, we exec into the container.
|
|
||||||
local matrix_host="http://dendrite:8008"
|
|
||||||
|
|
||||||
# Skip if MATRIX_TOKEN is already configured
|
|
||||||
if [ -n "${MATRIX_TOKEN:-}" ]; then
|
|
||||||
echo "Matrix: already configured (MATRIX_TOKEN set)"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$use_bare" = true ]; then
|
|
||||||
echo "Matrix: skipped in bare mode (configure manually or install Dendrite)"
|
|
||||||
echo " See: https://matrix-org.github.io/dendrite/"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Wait for Dendrite to become healthy
|
|
||||||
echo -n "Waiting for Dendrite to start"
|
|
||||||
local retries=0
|
|
||||||
while true; do
|
|
||||||
# Probe Dendrite via docker compose exec since it's not exposed on the host
|
|
||||||
local version_resp
|
|
||||||
version_resp=$(docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T dendrite \
|
|
||||||
curl -sf --max-time 3 "http://localhost:8008/_matrix/client/versions" 2>/dev/null) || version_resp=""
|
|
||||||
if [ -n "$version_resp" ]; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
retries=$((retries + 1))
|
|
||||||
if [ "$retries" -gt 60 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "Warning: Dendrite did not become ready within 60s — skipping Matrix setup" >&2
|
|
||||||
echo " Run 'disinto init' again after Dendrite is healthy" >&2
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
echo -n "."
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
echo " ready"
|
|
||||||
|
|
||||||
# Create bot user via Dendrite's create-account tool
|
|
||||||
local bot_localpart="factory-bot"
|
|
||||||
local bot_user="@${bot_localpart}:disinto.local"
|
|
||||||
local bot_pass
|
|
||||||
bot_pass="matrix-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 24)"
|
|
||||||
|
|
||||||
echo "Creating Matrix bot user: ${bot_user}"
|
|
||||||
docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T dendrite \
|
|
||||||
/usr/bin/create-account \
|
|
||||||
-config /etc/dendrite/dendrite.yaml \
|
|
||||||
-username "${bot_localpart}" \
|
|
||||||
-password "${bot_pass}" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Log in to get an access token
|
|
||||||
local login_resp
|
|
||||||
login_resp=$(docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T dendrite \
|
|
||||||
curl -sf -X POST "http://localhost:8008/_matrix/client/v3/login" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"${bot_localpart}\"},\"password\":\"${bot_pass}\"}" \
|
|
||||||
2>/dev/null) || login_resp=""
|
|
||||||
|
|
||||||
local access_token
|
|
||||||
access_token=$(printf '%s' "$login_resp" | jq -r '.access_token // empty' 2>/dev/null) || access_token=""
|
|
||||||
|
|
||||||
if [ -z "$access_token" ]; then
|
|
||||||
echo "Warning: failed to obtain Matrix access token — skipping Matrix setup" >&2
|
|
||||||
echo " Create the bot user manually via Dendrite admin API" >&2
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " Bot login successful"
|
|
||||||
|
|
||||||
# Create coordination room
|
|
||||||
local room_resp
|
|
||||||
room_resp=$(docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T dendrite \
|
|
||||||
curl -sf -X POST "http://localhost:8008/_matrix/client/v3/createRoom" \
|
|
||||||
-H "Authorization: Bearer ${access_token}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"room_alias_name":"factory","name":"disinto factory","topic":"Autonomous code factory coordination room","preset":"private_chat"}' \
|
|
||||||
2>/dev/null) || room_resp=""
|
|
||||||
|
|
||||||
local room_id
|
|
||||||
room_id=$(printf '%s' "$room_resp" | jq -r '.room_id // empty' 2>/dev/null) || room_id=""
|
|
||||||
|
|
||||||
if [ -z "$room_id" ]; then
|
|
||||||
# Room might already exist — try resolving the alias
|
|
||||||
local alias_resp
|
|
||||||
alias_resp=$(docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T dendrite \
|
|
||||||
curl -sf "http://localhost:8008/_matrix/client/v3/directory/room/%23factory%3Adisinto.local" \
|
|
||||||
-H "Authorization: Bearer ${access_token}" \
|
|
||||||
2>/dev/null) || alias_resp=""
|
|
||||||
room_id=$(printf '%s' "$alias_resp" | jq -r '.room_id // empty' 2>/dev/null) || room_id=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$room_id" ]; then
|
|
||||||
echo "Warning: failed to create or find coordination room — skipping Matrix setup" >&2
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " Room: ${room_id} (alias: #factory:disinto.local)"
|
|
||||||
|
|
||||||
# Store Matrix credentials in .env
|
|
||||||
local matrix_vars=(
|
|
||||||
"MATRIX_HOMESERVER=${matrix_host}"
|
|
||||||
"MATRIX_BOT_USER=${bot_user}"
|
|
||||||
"MATRIX_TOKEN=${access_token}"
|
|
||||||
"MATRIX_ROOM_ID=${room_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
for var_line in "${matrix_vars[@]}"; do
|
|
||||||
local var_name="${var_line%%=*}"
|
|
||||||
if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then
|
|
||||||
# Use Python to avoid sed delimiter collisions with opaque tokens
|
|
||||||
python3 -c "
|
|
||||||
import sys, re, pathlib
|
|
||||||
p = pathlib.Path(sys.argv[1])
|
|
||||||
text = p.read_text()
|
|
||||||
text = re.sub(r'^' + re.escape(sys.argv[2]) + r'=.*$', sys.argv[3], text, flags=re.MULTILINE)
|
|
||||||
p.write_text(text)
|
|
||||||
" "$env_file" "$var_name" "$var_line"
|
|
||||||
else
|
|
||||||
printf '%s\n' "$var_line" >> "$env_file"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
export MATRIX_TOKEN="$access_token"
|
|
||||||
export MATRIX_BOT_USER="$bot_user"
|
|
||||||
export MATRIX_ROOM_ID="$room_id"
|
|
||||||
export MATRIX_HOMESERVER="$matrix_host"
|
|
||||||
|
|
||||||
echo " Credentials saved to .env"
|
|
||||||
echo ""
|
|
||||||
echo " To receive notifications in your Matrix client:"
|
|
||||||
echo " 1. Add 'ports: [\"8008:8008\"]' to the dendrite service in docker-compose.yml"
|
|
||||||
echo " 2. Join #factory:disinto.local from Element or another Matrix client"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── init command ─────────────────────────────────────────────────────────────
|
# ── init command ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
disinto_init() {
|
disinto_init() {
|
||||||
|
|
@ -1265,7 +1056,7 @@ disinto_init() {
|
||||||
shift
|
shift
|
||||||
|
|
||||||
# Parse flags
|
# Parse flags
|
||||||
local branch="" repo_root="" ci_id="0" auto_yes=false forge_url_flag="" bare=false enable_matrix=false
|
local branch="" repo_root="" ci_id="0" auto_yes=false forge_url_flag="" bare=false
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--branch) branch="$2"; shift 2 ;;
|
--branch) branch="$2"; shift 2 ;;
|
||||||
|
|
@ -1273,7 +1064,6 @@ disinto_init() {
|
||||||
--ci-id) ci_id="$2"; shift 2 ;;
|
--ci-id) ci_id="$2"; shift 2 ;;
|
||||||
--forge-url) forge_url_flag="$2"; shift 2 ;;
|
--forge-url) forge_url_flag="$2"; shift 2 ;;
|
||||||
--bare) bare=true; shift ;;
|
--bare) bare=true; shift ;;
|
||||||
--matrix) enable_matrix=true; shift ;;
|
|
||||||
--yes) auto_yes=true; shift ;;
|
--yes) auto_yes=true; shift ;;
|
||||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
@ -1357,9 +1147,6 @@ p.write_text(text)
|
||||||
forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|')
|
forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|')
|
||||||
forge_port="${forge_port:-3000}"
|
forge_port="${forge_port:-3000}"
|
||||||
generate_compose "$forge_port"
|
generate_compose "$forge_port"
|
||||||
if [ "$enable_matrix" = true ]; then
|
|
||||||
append_dendrite_compose
|
|
||||||
fi
|
|
||||||
generate_agent_docker
|
generate_agent_docker
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -1457,13 +1244,7 @@ p.write_text(text)
|
||||||
echo ""
|
echo ""
|
||||||
echo "── Starting full stack ────────────────────────────────"
|
echo "── Starting full stack ────────────────────────────────"
|
||||||
docker compose -f "${FACTORY_ROOT}/docker-compose.yml" up -d
|
docker compose -f "${FACTORY_ROOT}/docker-compose.yml" up -d
|
||||||
if [ "$enable_matrix" = true ]; then
|
echo "Stack: running (forgejo + woodpecker + agents)"
|
||||||
echo "Stack: running (forgejo + woodpecker + dendrite + agents)"
|
|
||||||
# Provision Matrix now that Dendrite is running
|
|
||||||
setup_matrix
|
|
||||||
else
|
|
||||||
echo "Stack: running (forgejo + woodpecker + agents)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Activate repo in Woodpecker now that stack is running
|
# Activate repo in Woodpecker now that stack is running
|
||||||
activate_woodpecker_repo "$forge_repo"
|
activate_woodpecker_repo "$forge_repo"
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ check so approved PRs get merged even while a dev-agent session is active.
|
||||||
**Key files**:
|
**Key files**:
|
||||||
- `dev/dev-poll.sh` — Cron scheduler: finds next ready issue, handles merge/rebase of approved PRs, tracks CI fix attempts. Formula guard skips issues labeled `formula`, `action`, `prediction/dismissed`, or `prediction/unreviewed` (replaced `prediction/backlog` — that label no longer exists)
|
- `dev/dev-poll.sh` — Cron scheduler: finds next ready issue, handles merge/rebase of approved PRs, tracks CI fix attempts. Formula guard skips issues labeled `formula`, `action`, `prediction/dismissed`, or `prediction/unreviewed` (replaced `prediction/backlog` — that label no longer exists)
|
||||||
- `dev/dev-agent.sh` — Orchestrator: claims issue, creates worktree + tmux session with interactive `claude`, monitors phase file, injects CI results and review feedback, merges on approval
|
- `dev/dev-agent.sh` — Orchestrator: claims issue, creates worktree + tmux session with interactive `claude`, monitors phase file, injects CI results and review feedback, merges on approval
|
||||||
- `dev/phase-handler.sh` — Phase callback functions: `post_refusal_comment()`, `_on_phase_change()`, `build_phase_protocol_prompt()`. `do_merge()` detects already-merged PRs on HTTP 405 (race with dev-poll's pre-lock scan) and returns success instead of escalating. Sources `lib/mirrors.sh` and calls `mirror_push()` after every successful merge. Matrix escalation notifications include `MATRIX_MENTION_USER` HTML mention when set.
|
- `dev/phase-handler.sh` — Phase callback functions: `post_refusal_comment()`, `_on_phase_change()`, `build_phase_protocol_prompt()`. `do_merge()` detects already-merged PRs on HTTP 405 (race with dev-poll's pre-lock scan) and returns success instead of escalating. Sources `lib/mirrors.sh` and calls `mirror_push()` after every successful merge.
|
||||||
- `dev/phase-test.sh` — Integration test for the phase protocol
|
- `dev/phase-test.sh` — Integration test for the phase protocol
|
||||||
|
|
||||||
**Environment variables consumed** (via `lib/env.sh` + project TOML):
|
**Environment variables consumed** (via `lib/env.sh` + project TOML):
|
||||||
|
|
@ -26,12 +26,10 @@ check so approved PRs get merged even while a dev-agent session is active.
|
||||||
- `PRIMARY_BRANCH` — Branch to merge into (e.g. `main`, `master`)
|
- `PRIMARY_BRANCH` — Branch to merge into (e.g. `main`, `master`)
|
||||||
- `WOODPECKER_REPO_ID` — CI pipeline lookups
|
- `WOODPECKER_REPO_ID` — CI pipeline lookups
|
||||||
- `CLAUDE_TIMEOUT` — Max seconds for a Claude session (default 7200)
|
- `CLAUDE_TIMEOUT` — Max seconds for a Claude session (default 7200)
|
||||||
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` — Notifications (optional)
|
|
||||||
|
|
||||||
**FORGE_REMOTE**: `dev-agent.sh` auto-detects which git remote corresponds to `FORGE_URL` by matching the remote's push URL hostname. This is exported as `FORGE_REMOTE` and used for all git push/pull/worktree operations. Defaults to `origin` if no match found. This ensures correct behaviour when the forge is local Forgejo (remote typically named `forgejo`) rather than Codeberg (`origin`).
|
**FORGE_REMOTE**: `dev-agent.sh` auto-detects which git remote corresponds to `FORGE_URL` by matching the remote's push URL hostname. This is exported as `FORGE_REMOTE` and used for all git push/pull/worktree operations. Defaults to `origin` if no match found. This ensures correct behaviour when the forge is local Forgejo (remote typically named `forgejo`) rather than Codeberg (`origin`).
|
||||||
|
|
||||||
**Lifecycle**: dev-poll.sh (`check_active dev`) → dev-agent.sh → create Matrix
|
**Lifecycle**: dev-poll.sh (`check_active dev`) → dev-agent.sh → tmux `dev-{project}-{issue}` → phase file
|
||||||
thread + export `MATRIX_THREAD_ID` → tmux `dev-{project}-{issue}` → phase file
|
|
||||||
drives CI/review loop → merge + `mirror_push()` → close issue. On respawn after
|
drives CI/review loop → merge + `mirror_push()` → close issue. On respawn after
|
||||||
`PHASE:escalate`, the stale phase file is cleared first so the session starts
|
`PHASE:escalate`, the stale phase file is cleared first so the session starts
|
||||||
clean; the reinject prompt tells Claude not to re-escalate for the same reason.
|
clean; the reinject prompt tells Claude not to re-escalate for the same reason.
|
||||||
|
|
|
||||||
|
|
@ -56,23 +56,6 @@ log() {
|
||||||
printf '[%s] #%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$ISSUE" "$*" >> "$LOGFILE"
|
printf '[%s] #%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$ISSUE" "$*" >> "$LOGFILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
notify() {
|
|
||||||
local thread_id=""
|
|
||||||
[ -f "${THREAD_FILE:-}" ] && thread_id=$(cat "$THREAD_FILE" 2>/dev/null || true)
|
|
||||||
matrix_send "dev" "🔧 #${ISSUE}: $*" "${thread_id}" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
notify_ctx() {
|
|
||||||
local plain="$1" html="$2"
|
|
||||||
local thread_id=""
|
|
||||||
[ -f "${THREAD_FILE:-}" ] && thread_id=$(cat "$THREAD_FILE" 2>/dev/null || true)
|
|
||||||
if [ -n "$thread_id" ]; then
|
|
||||||
matrix_send_ctx "dev" "🔧 #${ISSUE}: ${plain}" "🔧 #${ISSUE}: ${html}" "${thread_id}" 2>/dev/null || true
|
|
||||||
else
|
|
||||||
matrix_send "dev" "🔧 #${ISSUE}: ${plain}" "" "${ISSUE}" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
status() {
|
status() {
|
||||||
printf '[%s] dev-agent #%s: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$ISSUE" "$*" > "$STATUSFILE"
|
printf '[%s] dev-agent #%s: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$ISSUE" "$*" > "$STATUSFILE"
|
||||||
log "$*"
|
log "$*"
|
||||||
|
|
@ -87,9 +70,6 @@ PHASE_FILE="/tmp/dev-session-${PROJECT_NAME}-${ISSUE}.phase"
|
||||||
SESSION_NAME="dev-${PROJECT_NAME}-${ISSUE}"
|
SESSION_NAME="dev-${PROJECT_NAME}-${ISSUE}"
|
||||||
IMPL_SUMMARY_FILE="/tmp/dev-impl-summary-${PROJECT_NAME}-${ISSUE}.txt"
|
IMPL_SUMMARY_FILE="/tmp/dev-impl-summary-${PROJECT_NAME}-${ISSUE}.txt"
|
||||||
|
|
||||||
# Matrix thread tracking — one thread per issue for conversational notifications
|
|
||||||
THREAD_FILE="/tmp/dev-thread-${PROJECT_NAME}-${ISSUE}"
|
|
||||||
|
|
||||||
# Scratch file for context compaction survival
|
# Scratch file for context compaction survival
|
||||||
SCRATCH_FILE="/tmp/dev-${PROJECT_NAME}-${ISSUE}-scratch.md"
|
SCRATCH_FILE="/tmp/dev-${PROJECT_NAME}-${ISSUE}-scratch.md"
|
||||||
|
|
||||||
|
|
@ -246,7 +226,6 @@ log "Issue: ${ISSUE_TITLE}"
|
||||||
ISSUE_LABELS=$(echo "$ISSUE_JSON" | jq -r '[.labels[].name] | join(",")') || true
|
ISSUE_LABELS=$(echo "$ISSUE_JSON" | jq -r '[.labels[].name] | join(",")') || true
|
||||||
if echo "$ISSUE_LABELS" | grep -qw 'formula'; then
|
if echo "$ISSUE_LABELS" | grep -qw 'formula'; then
|
||||||
log "SKIP: issue #${ISSUE} has 'formula' label but formula dispatch is not yet implemented (feat/formula branch not merged)"
|
log "SKIP: issue #${ISSUE} has 'formula' label but formula dispatch is not yet implemented (feat/formula branch not merged)"
|
||||||
notify "issue #${ISSUE} skipped — formula label requires feat/formula branch (not yet merged to main)"
|
|
||||||
echo '{"status":"unmet_dependency","blocked_by":"formula dispatch not implemented — feat/formula branch not merged to main","suggestion":null}' > "$PREFLIGHT_RESULT"
|
echo '{"status":"unmet_dependency","blocked_by":"formula dispatch not implemented — feat/formula branch not merged to main","suggestion":null}' > "$PREFLIGHT_RESULT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
@ -362,7 +341,6 @@ This issue depends on ${BLOCKED_LIST}, which $(if [ "${#BLOCKED_BY[@]}" -eq 1 ];
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "BLOCKED: unmet dependencies: ${BLOCKED_BY[*]}$(if [ -n "$SUGGESTION" ]; then echo ", suggest #${SUGGESTION}"; fi)"
|
log "BLOCKED: unmet dependencies: ${BLOCKED_BY[*]}$(if [ -n "$SUGGESTION" ]; then echo ", suggest #${SUGGESTION}"; fi)"
|
||||||
notify "blocked by unmet dependencies: ${BLOCKED_BY[*]}"
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -726,27 +704,6 @@ ${SCRATCH_INSTRUCTION}
|
||||||
${PHASE_PROTOCOL_INSTRUCTIONS}"
|
${PHASE_PROTOCOL_INSTRUCTIONS}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# CREATE MATRIX THREAD (before tmux so MATRIX_THREAD_ID is available for Stop hook)
|
|
||||||
# =============================================================================
|
|
||||||
if [ ! -f "${THREAD_FILE}" ] || [ -z "$(cat "$THREAD_FILE" 2>/dev/null)" ]; then
|
|
||||||
ISSUE_URL="${FORGE_WEB}/issues/${ISSUE}"
|
|
||||||
_thread_id=$(matrix_send_ctx "dev" \
|
|
||||||
"🔧 Issue #${ISSUE}: ${ISSUE_TITLE} — ${ISSUE_URL}" \
|
|
||||||
"🔧 <a href='${ISSUE_URL}'>Issue #${ISSUE}</a>: ${ISSUE_TITLE}") || true
|
|
||||||
if [ -n "${_thread_id:-}" ]; then
|
|
||||||
printf '%s' "$_thread_id" > "$THREAD_FILE"
|
|
||||||
# Register thread root in map for listener dispatch
|
|
||||||
printf '%s\t%s\t%s\t%s\t%s\n' "$_thread_id" "dev" "$(date +%s)" "${ISSUE}" "${PROJECT_NAME}" >> "${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Export for on-stop-matrix.sh hook (streams Claude output to thread)
|
|
||||||
_thread_id=$(cat "$THREAD_FILE" 2>/dev/null || true)
|
|
||||||
if [ -n "${_thread_id:-}" ]; then
|
|
||||||
export MATRIX_THREAD_ID="$_thread_id"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CREATE TMUX SESSION
|
# CREATE TMUX SESSION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -765,8 +722,6 @@ log "initial prompt sent to tmux session"
|
||||||
|
|
||||||
# Signal to dev-poll.sh that we're running (session is up)
|
# Signal to dev-poll.sh that we're running (session is up)
|
||||||
echo '{"status":"ready"}' > "$PREFLIGHT_RESULT"
|
echo '{"status":"ready"}' > "$PREFLIGHT_RESULT"
|
||||||
notify "tmux session ${SESSION_NAME} started for issue #${ISSUE}: ${ISSUE_TITLE}"
|
|
||||||
|
|
||||||
|
|
||||||
status "monitoring phase: ${PHASE_FILE}"
|
status "monitoring phase: ${PHASE_FILE}"
|
||||||
monitor_phase_loop "$PHASE_FILE" "$IDLE_TIMEOUT" _on_phase_change
|
monitor_phase_loop "$PHASE_FILE" "$IDLE_TIMEOUT" _on_phase_change
|
||||||
|
|
@ -774,15 +729,6 @@ monitor_phase_loop "$PHASE_FILE" "$IDLE_TIMEOUT" _on_phase_change
|
||||||
# Handle exit reason from monitor_phase_loop
|
# Handle exit reason from monitor_phase_loop
|
||||||
case "${_MONITOR_LOOP_EXIT:-}" in
|
case "${_MONITOR_LOOP_EXIT:-}" in
|
||||||
idle_timeout|idle_prompt)
|
idle_timeout|idle_prompt)
|
||||||
if [ "${_MONITOR_LOOP_EXIT:-}" = "idle_prompt" ]; then
|
|
||||||
notify_ctx \
|
|
||||||
"session finished without phase signal — killed. Marking blocked." \
|
|
||||||
"session finished without phase signal — killed. Marking blocked.${PR_NUMBER:+ PR <a href='${FORGE_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>}"
|
|
||||||
else
|
|
||||||
notify_ctx \
|
|
||||||
"session idle for 2h — killed. Marking blocked." \
|
|
||||||
"session idle for 2h — killed. Marking blocked.${PR_NUMBER:+ PR <a href='${FORGE_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>}"
|
|
||||||
fi
|
|
||||||
# Post diagnostic comment + label issue blocked
|
# Post diagnostic comment + label issue blocked
|
||||||
post_blocked_diagnostic "${_MONITOR_LOOP_EXIT:-idle_timeout}"
|
post_blocked_diagnostic "${_MONITOR_LOOP_EXIT:-idle_timeout}"
|
||||||
if [ -n "${PR_NUMBER:-}" ]; then
|
if [ -n "${PR_NUMBER:-}" ]; then
|
||||||
|
|
@ -791,7 +737,7 @@ case "${_MONITOR_LOOP_EXIT:-}" in
|
||||||
cleanup_worktree
|
cleanup_worktree
|
||||||
fi
|
fi
|
||||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" \
|
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" \
|
||||||
"$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE" \
|
"$IMPL_SUMMARY_FILE" "$SCRATCH_FILE" \
|
||||||
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
||||||
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
||||||
;;
|
;;
|
||||||
|
|
@ -807,7 +753,7 @@ case "${_MONITOR_LOOP_EXIT:-}" in
|
||||||
# Belt-and-suspenders: callback in phase-handler.sh handles primary cleanup,
|
# Belt-and-suspenders: callback in phase-handler.sh handles primary cleanup,
|
||||||
# but ensure sentinel files are removed if callback was interrupted
|
# but ensure sentinel files are removed if callback was interrupted
|
||||||
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" \
|
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" \
|
||||||
"$IMPL_SUMMARY_FILE" "$THREAD_FILE" "$SCRATCH_FILE" \
|
"$IMPL_SUMMARY_FILE" "$SCRATCH_FILE" \
|
||||||
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
||||||
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
||||||
CLAIMED=false
|
CLAIMED=false
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,6 @@ handle_ci_exhaustion() {
|
||||||
CI_FIX_ATTEMPTS="${result#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"
|
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"
|
_post_ci_blocked_comment "$issue_num" "$pr_num" "$CI_FIX_ATTEMPTS"
|
||||||
matrix_send "dev" "🚨 PR #${pr_num} (issue #${issue_num}) CI failed after ${CI_FIX_ATTEMPTS} attempts — marked blocked" 2>/dev/null || true
|
|
||||||
;;
|
;;
|
||||||
exhausted:*)
|
exhausted:*)
|
||||||
CI_FIX_ATTEMPTS="${result#exhausted:}"
|
CI_FIX_ATTEMPTS="${result#exhausted:}"
|
||||||
|
|
@ -207,9 +206,6 @@ try_direct_merge() {
|
||||||
# Clean up phase/session artifacts
|
# Clean up phase/session artifacts
|
||||||
rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase" \
|
rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase" \
|
||||||
"/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt"
|
"/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt"
|
||||||
matrix_send "dev" "✅ PR #${pr_num} (issue #${issue_num}) merged directly by dev-poll" 2>/dev/null || true
|
|
||||||
else
|
|
||||||
matrix_send "dev" "✅ PR #${pr_num} merged directly by dev-poll (chore)" 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
# Pull merged primary branch and push to mirrors
|
# 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:-}" fetch origin "${PRIMARY_BRANCH:-}" 2>/dev/null || true
|
||||||
|
|
@ -316,7 +312,6 @@ fi
|
||||||
AVAIL_MB=$(awk '/MemAvailable/{printf "%d", $2/1024}' /proc/meminfo)
|
AVAIL_MB=$(awk '/MemAvailable/{printf "%d", $2/1024}' /proc/meminfo)
|
||||||
if [ "$AVAIL_MB" -lt 2000 ]; then
|
if [ "$AVAIL_MB" -lt 2000 ]; then
|
||||||
log "SKIP: only ${AVAIL_MB}MB available (need 2000MB)"
|
log "SKIP: only ${AVAIL_MB}MB available (need 2000MB)"
|
||||||
matrix_send "dev" "⚠️ Low memory (${AVAIL_MB}MB) — skipping dev-agent" 2>/dev/null || true
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -739,7 +734,6 @@ if [ -n "${READY_PR_FOR_INCREMENT:-}" ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "launching dev-agent for #${READY_ISSUE}"
|
log "launching dev-agent for #${READY_ISSUE}"
|
||||||
matrix_send "dev" "🚀 Starting dev-agent on issue #${READY_ISSUE}" 2>/dev/null || true
|
|
||||||
rm -f "$PREFLIGHT_RESULT"
|
rm -f "$PREFLIGHT_RESULT"
|
||||||
|
|
||||||
nohup "${SCRIPT_DIR}/dev-agent.sh" "$READY_ISSUE" >> "$LOGFILE" 2>&1 &
|
nohup "${SCRIPT_DIR}/dev-agent.sh" "$READY_ISSUE" >> "$LOGFILE" 2>&1 &
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
#
|
#
|
||||||
# Required globals (set by calling agent before or after sourcing):
|
# Required globals (set by calling agent before or after sourcing):
|
||||||
# ISSUE, FORGE_TOKEN, API, FORGE_WEB, PROJECT_NAME, FACTORY_ROOT
|
# ISSUE, FORGE_TOKEN, API, FORGE_WEB, PROJECT_NAME, FACTORY_ROOT
|
||||||
# BRANCH, PHASE_FILE, WORKTREE, IMPL_SUMMARY_FILE, THREAD_FILE
|
# BRANCH, PHASE_FILE, WORKTREE, IMPL_SUMMARY_FILE
|
||||||
# PRIMARY_BRANCH, SESSION_NAME, LOGFILE, ISSUE_TITLE
|
# PRIMARY_BRANCH, SESSION_NAME, LOGFILE, ISSUE_TITLE
|
||||||
# WOODPECKER_REPO_ID, WOODPECKER_TOKEN, WOODPECKER_SERVER
|
# WOODPECKER_REPO_ID, WOODPECKER_TOKEN, WOODPECKER_SERVER
|
||||||
#
|
#
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
# CLAIMED, PHASE_POLL_INTERVAL
|
# CLAIMED, PHASE_POLL_INTERVAL
|
||||||
#
|
#
|
||||||
# Calls back to agent-defined helpers:
|
# Calls back to agent-defined helpers:
|
||||||
# cleanup_worktree(), cleanup_labels(), notify(), notify_ctx(), status(), log()
|
# cleanup_worktree(), cleanup_labels(), status(), log()
|
||||||
#
|
#
|
||||||
# shellcheck shell=bash
|
# shellcheck shell=bash
|
||||||
# shellcheck disable=SC2154 # globals are set in dev-agent.sh before calling
|
# shellcheck disable=SC2154 # globals are set in dev-agent.sh before calling
|
||||||
|
|
@ -296,10 +296,6 @@ _on_phase_change() {
|
||||||
if [ "$PR_HTTP_CODE" = "201" ] || [ "$PR_HTTP_CODE" = "200" ]; then
|
if [ "$PR_HTTP_CODE" = "201" ] || [ "$PR_HTTP_CODE" = "200" ]; then
|
||||||
PR_NUMBER=$(echo "$PR_RESPONSE_BODY" | jq -r '.number')
|
PR_NUMBER=$(echo "$PR_RESPONSE_BODY" | jq -r '.number')
|
||||||
log "created PR #${PR_NUMBER}"
|
log "created PR #${PR_NUMBER}"
|
||||||
PR_URL="${FORGE_WEB}/pulls/${PR_NUMBER}"
|
|
||||||
notify_ctx \
|
|
||||||
"PR #${PR_NUMBER} created: ${ISSUE_TITLE}" \
|
|
||||||
"PR <a href='${PR_URL}'>#${PR_NUMBER}</a> created: ${ISSUE_TITLE}"
|
|
||||||
elif [ "$PR_HTTP_CODE" = "409" ]; then
|
elif [ "$PR_HTTP_CODE" = "409" ]; then
|
||||||
# PR already exists (race condition) — find it
|
# PR already exists (race condition) — find it
|
||||||
FOUND_PR=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
FOUND_PR=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||||
|
|
@ -316,7 +312,6 @@ _on_phase_change() {
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
log "ERROR: PR creation failed (HTTP ${PR_HTTP_CODE})"
|
log "ERROR: PR creation failed (HTTP ${PR_HTTP_CODE})"
|
||||||
notify "failed to create PR (HTTP ${PR_HTTP_CODE})"
|
|
||||||
agent_inject_into_session "$SESSION_NAME" "ERROR: Could not create PR (HTTP ${PR_HTTP_CODE}). Check branch was pushed: git push ${FORGE_REMOTE:-origin} ${BRANCH}. Then write PHASE:awaiting_ci again."
|
agent_inject_into_session "$SESSION_NAME" "ERROR: Could not create PR (HTTP ${PR_HTTP_CODE}). Check branch was pushed: git push ${FORGE_REMOTE:-origin} ${BRANCH}. Then write PHASE:awaiting_ci again."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
@ -362,7 +357,6 @@ Write PHASE:awaiting_review to the phase file, then stop and wait for review fee
|
||||||
|
|
||||||
if ! $CI_DONE; then
|
if ! $CI_DONE; then
|
||||||
log "TIMEOUT: CI didn't complete in ${CI_POLL_TIMEOUT}s"
|
log "TIMEOUT: CI didn't complete in ${CI_POLL_TIMEOUT}s"
|
||||||
notify "CI timeout on PR #${PR_NUMBER}"
|
|
||||||
agent_inject_into_session "$SESSION_NAME" "CI TIMEOUT: CI did not complete within 30 minutes for PR #${PR_NUMBER} (SHA: ${CI_CURRENT_SHA:0:7}). This may be an infrastructure issue. Write PHASE:escalate if you cannot proceed."
|
agent_inject_into_session "$SESSION_NAME" "CI TIMEOUT: CI did not complete within 30 minutes for PR #${PR_NUMBER} (SHA: ${CI_CURRENT_SHA:0:7}). This may be an infrastructure issue. Write PHASE:escalate if you cannot proceed."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
@ -412,11 +406,6 @@ Write PHASE:awaiting_review to the phase file, then stop and wait for review fee
|
||||||
_ci_pipeline_url="${WOODPECKER_SERVER}/repos/${WOODPECKER_REPO_ID}/pipeline/${PIPELINE_NUM:-0}"
|
_ci_pipeline_url="${WOODPECKER_SERVER}/repos/${WOODPECKER_REPO_ID}/pipeline/${PIPELINE_NUM:-0}"
|
||||||
if [ "$CI_FIX_COUNT" -gt "$MAX_CI_FIXES" ]; then
|
if [ "$CI_FIX_COUNT" -gt "$MAX_CI_FIXES" ]; then
|
||||||
log "CI failure not recoverable after ${CI_FIX_COUNT} fix attempts — escalating"
|
log "CI failure not recoverable after ${CI_FIX_COUNT} fix attempts — escalating"
|
||||||
local _mention_html=""
|
|
||||||
[ -n "${MATRIX_MENTION_USER:-}" ] && _mention_html="<a href='https://matrix.to/#/${MATRIX_MENTION_USER}'>${MATRIX_MENTION_USER}</a> "
|
|
||||||
notify_ctx \
|
|
||||||
"CI exhausted after ${CI_FIX_COUNT} attempts — escalating for human help" \
|
|
||||||
"${_mention_html}CI exhausted after ${CI_FIX_COUNT} attempts on PR <a href='${PR_URL:-${FORGE_WEB}/pulls/${PR_NUMBER}}'>#${PR_NUMBER}</a> | <a href='${_ci_pipeline_url}'>Pipeline</a><br>Step: <code>${FAILED_STEP:-unknown}</code> — escalating for human help"
|
|
||||||
printf 'PHASE:escalate\nReason: ci_exhausted after %d attempts (step: %s)\n' "$CI_FIX_COUNT" "${FAILED_STEP:-unknown}" > "$PHASE_FILE"
|
printf 'PHASE:escalate\nReason: ci_exhausted after %d attempts (step: %s)\n' "$CI_FIX_COUNT" "${FAILED_STEP:-unknown}" > "$PHASE_FILE"
|
||||||
# Do NOT update LAST_PHASE_MTIME here — let the main loop detect PHASE:escalate
|
# Do NOT update LAST_PHASE_MTIME here — let the main loop detect PHASE:escalate
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -432,12 +421,6 @@ Write PHASE:awaiting_review to the phase file, then stop and wait for review fee
|
||||||
"$CI_FIX_COUNT" "$MAX_CI_FIXES" "${FAILED_STEP:-unknown}" "${FAILED_EXIT:-?}" "$CI_ERROR_LOG" \
|
"$CI_FIX_COUNT" "$MAX_CI_FIXES" "${FAILED_STEP:-unknown}" "${FAILED_EXIT:-?}" "$CI_ERROR_LOG" \
|
||||||
> "/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt" 2>/dev/null || true
|
> "/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt" 2>/dev/null || true
|
||||||
|
|
||||||
# Notify Matrix with rich CI failure context
|
|
||||||
_ci_snippet=$(printf '%s' "${CI_ERROR_LOG:-}" | tail -5 | head -c 500 | sed 's/&/\&/g; s/</\</g; s/>/\>/g')
|
|
||||||
notify_ctx \
|
|
||||||
"CI failed on PR #${PR_NUMBER}: step=${FAILED_STEP:-unknown} (attempt ${CI_FIX_COUNT}/${MAX_CI_FIXES})" \
|
|
||||||
"CI failed on PR <a href='${PR_URL:-${FORGE_WEB}/pulls/${PR_NUMBER}}'>#${PR_NUMBER}</a> | <a href='${_ci_pipeline_url}'>Pipeline #${PIPELINE_NUM:-?}</a><br>Step: <code>${FAILED_STEP:-unknown}</code> (exit ${FAILED_EXIT:-?})<br>Attempt ${CI_FIX_COUNT}/${MAX_CI_FIXES}<br><pre>${_ci_snippet:-no logs}</pre>"
|
|
||||||
|
|
||||||
agent_inject_into_session "$SESSION_NAME" "CI failed on PR #${PR_NUMBER} (attempt ${CI_FIX_COUNT}/${MAX_CI_FIXES}).
|
agent_inject_into_session "$SESSION_NAME" "CI failed on PR #${PR_NUMBER} (attempt ${CI_FIX_COUNT}/${MAX_CI_FIXES}).
|
||||||
|
|
||||||
Failed step: ${FAILED_STEP:-unknown} (exit code ${FAILED_EXIT:-?}, pipeline #${PIPELINE_NUM:-?})
|
Failed step: ${FAILED_STEP:-unknown} (exit code ${FAILED_EXIT:-?}, pipeline #${PIPELINE_NUM:-?})
|
||||||
|
|
@ -584,7 +567,7 @@ If rebase repeatedly fails, write PHASE:escalate with a reason."
|
||||||
REVIEW_ROUND=$(( REVIEW_ROUND + 1 ))
|
REVIEW_ROUND=$(( REVIEW_ROUND + 1 ))
|
||||||
if [ "$REVIEW_ROUND" -ge "$MAX_REVIEW_ROUNDS" ]; then
|
if [ "$REVIEW_ROUND" -ge "$MAX_REVIEW_ROUNDS" ]; then
|
||||||
log "hit max review rounds (${MAX_REVIEW_ROUNDS})"
|
log "hit max review rounds (${MAX_REVIEW_ROUNDS})"
|
||||||
notify "PR #${PR_NUMBER}: hit ${MAX_REVIEW_ROUNDS} review rounds, needs human attention"
|
log "PR #${PR_NUMBER}: hit ${MAX_REVIEW_ROUNDS} review rounds, needs human attention"
|
||||||
fi
|
fi
|
||||||
REVIEW_FOUND=true
|
REVIEW_FOUND=true
|
||||||
agent_inject_into_session "$SESSION_NAME" "Review feedback (round ${REVIEW_ROUND}) on PR #${PR_NUMBER}:
|
agent_inject_into_session "$SESSION_NAME" "Review feedback (round ${REVIEW_ROUND}) on PR #${PR_NUMBER}:
|
||||||
|
|
@ -615,20 +598,16 @@ Instructions:
|
||||||
if [ "$PR_STATE" != "open" ]; then
|
if [ "$PR_STATE" != "open" ]; then
|
||||||
if [ "$PR_MERGED" = "true" ]; then
|
if [ "$PR_MERGED" = "true" ]; then
|
||||||
log "PR #${PR_NUMBER} was merged externally"
|
log "PR #${PR_NUMBER} was merged externally"
|
||||||
notify_ctx \
|
|
||||||
"✅ PR #${PR_NUMBER} merged externally! Issue #${ISSUE} done." \
|
|
||||||
"✅ PR <a href='${FORGE_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a> merged externally! <a href='${FORGE_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> done."
|
|
||||||
curl -sf -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
|
curl -sf -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
"${API}/issues/${ISSUE}" -d '{"state":"closed"}' >/dev/null 2>&1 || true
|
"${API}/issues/${ISSUE}" -d '{"state":"closed"}' >/dev/null 2>&1 || true
|
||||||
cleanup_labels
|
cleanup_labels
|
||||||
agent_kill_session "$SESSION_NAME"
|
agent_kill_session "$SESSION_NAME"
|
||||||
cleanup_worktree
|
cleanup_worktree
|
||||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "${SCRATCH_FILE:-}"
|
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "${SCRATCH_FILE:-}"
|
||||||
exit 0
|
exit 0
|
||||||
else
|
else
|
||||||
log "PR #${PR_NUMBER} was closed WITHOUT merge — NOT closing issue"
|
log "PR #${PR_NUMBER} was closed WITHOUT merge — NOT closing issue"
|
||||||
notify "⚠️ PR #${PR_NUMBER} closed without merge. Issue #${ISSUE} remains open."
|
|
||||||
cleanup_labels
|
cleanup_labels
|
||||||
agent_kill_session "$SESSION_NAME"
|
agent_kill_session "$SESSION_NAME"
|
||||||
cleanup_worktree
|
cleanup_worktree
|
||||||
|
|
@ -641,7 +620,6 @@ Instructions:
|
||||||
|
|
||||||
if ! $REVIEW_FOUND && [ "$REVIEW_POLL_ELAPSED" -ge "$REVIEW_POLL_TIMEOUT" ]; then
|
if ! $REVIEW_FOUND && [ "$REVIEW_POLL_ELAPSED" -ge "$REVIEW_POLL_TIMEOUT" ]; then
|
||||||
log "TIMEOUT: no review after 3h"
|
log "TIMEOUT: no review after 3h"
|
||||||
notify "no review received for PR #${PR_NUMBER} after 3h"
|
|
||||||
agent_inject_into_session "$SESSION_NAME" "TIMEOUT: No review received after 3 hours for PR #${PR_NUMBER}. Write PHASE:escalate to escalate to a human reviewer."
|
agent_inject_into_session "$SESSION_NAME" "TIMEOUT: No review received after 3 hours for PR #${PR_NUMBER}. Write PHASE:escalate to escalate to a human reviewer."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -649,30 +627,16 @@ Instructions:
|
||||||
elif [ "$phase" = "PHASE:escalate" ]; then
|
elif [ "$phase" = "PHASE:escalate" ]; then
|
||||||
status "escalated — waiting for human input on issue #${ISSUE}"
|
status "escalated — waiting for human input on issue #${ISSUE}"
|
||||||
ESCALATE_REASON=$(sed -n '2p' "$PHASE_FILE" 2>/dev/null | sed 's/^Reason: //' || echo "")
|
ESCALATE_REASON=$(sed -n '2p' "$PHASE_FILE" 2>/dev/null | sed 's/^Reason: //' || echo "")
|
||||||
_issue_url="${FORGE_WEB}/issues/${ISSUE}"
|
log "phase: escalate — reason: ${ESCALATE_REASON:-none}"
|
||||||
_pr_link=""
|
# Session stays alive — human input arrives via vault/forge
|
||||||
[ -n "${PR_NUMBER:-}" ] && _pr_link=" | PR <a href='${FORGE_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>"
|
|
||||||
local _mention_html=""
|
|
||||||
[ -n "${MATRIX_MENTION_USER:-}" ] && _mention_html="<a href='https://matrix.to/#/${MATRIX_MENTION_USER}'>${MATRIX_MENTION_USER}</a> "
|
|
||||||
notify_ctx \
|
|
||||||
"⚠️ Issue #${ISSUE} (PR #${PR_NUMBER:-none}) escalated — needs human input.${ESCALATE_REASON:+ Reason: ${ESCALATE_REASON}}" \
|
|
||||||
"${_mention_html}⚠️ <a href='${_issue_url}'>Issue #${ISSUE}</a>${_pr_link} escalated — needs human input.${ESCALATE_REASON:+ Reason: ${ESCALATE_REASON}}<br>Reply in this thread to send guidance to the agent."
|
|
||||||
log "phase: escalate — notified via Matrix, session stays alive waiting for reply"
|
|
||||||
# Session stays alive — matrix_listener injects human reply directly
|
|
||||||
|
|
||||||
# ── PHASE: done ─────────────────────────────────────────────────────────────
|
# ── PHASE: done ─────────────────────────────────────────────────────────────
|
||||||
# PR merged and issue closed (by orchestrator or Claude). Just clean up local state.
|
# PR merged and issue closed (by orchestrator or Claude). Just clean up local state.
|
||||||
elif [ "$phase" = "PHASE:done" ]; then
|
elif [ "$phase" = "PHASE:done" ]; then
|
||||||
if [ -n "${PR_NUMBER:-}" ]; then
|
if [ -n "${PR_NUMBER:-}" ]; then
|
||||||
status "phase done — PR #${PR_NUMBER} merged, cleaning up"
|
status "phase done — PR #${PR_NUMBER} merged, cleaning up"
|
||||||
notify_ctx \
|
|
||||||
"✅ PR #${PR_NUMBER} merged! Issue #${ISSUE} done." \
|
|
||||||
"✅ PR <a href='${FORGE_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a> merged! <a href='${FORGE_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> done."
|
|
||||||
else
|
else
|
||||||
status "phase done — issue #${ISSUE} complete, cleaning up"
|
status "phase done — issue #${ISSUE} complete, cleaning up"
|
||||||
notify_ctx \
|
|
||||||
"✅ Issue #${ISSUE} done." \
|
|
||||||
"✅ <a href='${FORGE_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> done."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Belt-and-suspenders: ensure in-progress label removed (idempotent)
|
# Belt-and-suspenders: ensure in-progress label removed (idempotent)
|
||||||
|
|
@ -681,7 +645,7 @@ Instructions:
|
||||||
# Local cleanup
|
# Local cleanup
|
||||||
agent_kill_session "$SESSION_NAME"
|
agent_kill_session "$SESSION_NAME"
|
||||||
cleanup_worktree
|
cleanup_worktree
|
||||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "${SCRATCH_FILE:-}" \
|
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "${SCRATCH_FILE:-}" \
|
||||||
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
||||||
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
||||||
CLAIMED=false # Don't unclaim again in cleanup()
|
CLAIMED=false # Don't unclaim again in cleanup()
|
||||||
|
|
@ -735,7 +699,6 @@ ${BLOCKED_BY_MSG}"
|
||||||
**Suggestion:** Work on #${SUGGESTION} first."
|
**Suggestion:** Work on #${SUGGESTION} first."
|
||||||
fi
|
fi
|
||||||
post_refusal_comment "🚧" "Unmet dependency" "$COMMENT_BODY"
|
post_refusal_comment "🚧" "Unmet dependency" "$COMMENT_BODY"
|
||||||
notify "refused #${ISSUE}: unmet dependency — ${BLOCKED_BY_MSG}"
|
|
||||||
;;
|
;;
|
||||||
too_large)
|
too_large)
|
||||||
REASON=$(printf '%s' "$REFUSAL_JSON" | jq -r '.reason // "unspecified"')
|
REASON=$(printf '%s' "$REFUSAL_JSON" | jq -r '.reason // "unspecified"')
|
||||||
|
|
@ -753,7 +716,6 @@ A maintainer should split this issue or add more detail to the spec."
|
||||||
curl -sf -X DELETE \
|
curl -sf -X DELETE \
|
||||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||||
"${API}/issues/${ISSUE}/labels/${BACKLOG_LABEL_ID}" >/dev/null 2>&1 || true
|
"${API}/issues/${ISSUE}/labels/${BACKLOG_LABEL_ID}" >/dev/null 2>&1 || true
|
||||||
notify "refused #${ISSUE}: too large — ${REASON}"
|
|
||||||
;;
|
;;
|
||||||
already_done)
|
already_done)
|
||||||
REASON=$(printf '%s' "$REFUSAL_JSON" | jq -r '.reason // "unspecified"')
|
REASON=$(printf '%s' "$REFUSAL_JSON" | jq -r '.reason // "unspecified"')
|
||||||
|
|
@ -767,7 +729,6 @@ Closing as already implemented."
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
"${API}/issues/${ISSUE}" \
|
"${API}/issues/${ISSUE}" \
|
||||||
-d '{"state":"closed"}' >/dev/null 2>&1 || true
|
-d '{"state":"closed"}' >/dev/null 2>&1 || true
|
||||||
notify "refused #${ISSUE}: already done — ${REASON}"
|
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
post_refusal_comment "❓" "Unable to proceed" "The dev-agent could not process this issue.
|
post_refusal_comment "❓" "Unable to proceed" "The dev-agent could not process this issue.
|
||||||
|
|
@ -776,14 +737,13 @@ Raw response:
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
$(printf '%s' "$REFUSAL_JSON" | head -c 2000)
|
$(printf '%s' "$REFUSAL_JSON" | head -c 2000)
|
||||||
\`\`\`"
|
\`\`\`"
|
||||||
notify "refused #${ISSUE}: unknown reason"
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
CLAIMED=false # Don't unclaim again in cleanup()
|
CLAIMED=false # Don't unclaim again in cleanup()
|
||||||
agent_kill_session "$SESSION_NAME"
|
agent_kill_session "$SESSION_NAME"
|
||||||
cleanup_worktree
|
cleanup_worktree
|
||||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "${SCRATCH_FILE:-}" \
|
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "${SCRATCH_FILE:-}" \
|
||||||
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
||||||
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
||||||
return 1
|
return 1
|
||||||
|
|
@ -791,9 +751,6 @@ $(printf '%s' "$REFUSAL_JSON" | head -c 2000)
|
||||||
else
|
else
|
||||||
# Genuine unrecoverable failure — label blocked with diagnostic
|
# Genuine unrecoverable failure — label blocked with diagnostic
|
||||||
log "session failed: ${FAILURE_REASON}"
|
log "session failed: ${FAILURE_REASON}"
|
||||||
notify_ctx \
|
|
||||||
"❌ Issue #${ISSUE} session failed: ${FAILURE_REASON}" \
|
|
||||||
"❌ <a href='${FORGE_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> session failed: ${FAILURE_REASON}${PR_NUMBER:+ | PR <a href='${FORGE_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>}"
|
|
||||||
post_blocked_diagnostic "$FAILURE_REASON"
|
post_blocked_diagnostic "$FAILURE_REASON"
|
||||||
|
|
||||||
agent_kill_session "$SESSION_NAME"
|
agent_kill_session "$SESSION_NAME"
|
||||||
|
|
@ -802,7 +759,7 @@ $(printf '%s' "$REFUSAL_JSON" | head -c 2000)
|
||||||
else
|
else
|
||||||
cleanup_worktree
|
cleanup_worktree
|
||||||
fi
|
fi
|
||||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "${SCRATCH_FILE:-}" \
|
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "${SCRATCH_FILE:-}" \
|
||||||
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
||||||
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
||||||
return 1
|
return 1
|
||||||
|
|
@ -813,12 +770,9 @@ $(printf '%s' "$REFUSAL_JSON" | head -c 2000)
|
||||||
# diagnostic comment so humans can triage directly on the issue.
|
# diagnostic comment so humans can triage directly on the issue.
|
||||||
elif [ "$phase" = "PHASE:crashed" ]; then
|
elif [ "$phase" = "PHASE:crashed" ]; then
|
||||||
log "session crashed for issue #${ISSUE}"
|
log "session crashed for issue #${ISSUE}"
|
||||||
notify_ctx \
|
|
||||||
"session crashed unexpectedly — marking blocked" \
|
|
||||||
"session crashed unexpectedly — marking blocked${PR_NUMBER:+ | PR <a href='${FORGE_WEB}/pulls/${PR_NUMBER}'>#${PR_NUMBER}</a>}"
|
|
||||||
post_blocked_diagnostic "crashed"
|
post_blocked_diagnostic "crashed"
|
||||||
log "PRESERVED crashed worktree for debugging: $WORKTREE"
|
log "PRESERVED crashed worktree for debugging: $WORKTREE"
|
||||||
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" "${SCRATCH_FILE:-}" \
|
rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "${SCRATCH_FILE:-}" \
|
||||||
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
"/tmp/ci-result-${PROJECT_NAME}-${ISSUE}.txt"
|
||||||
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
[ -n "${PR_NUMBER:-}" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${PR_NUMBER}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,14 +87,6 @@ else
|
||||||
log "tea login: skipped (tea not found or FORGE_TOKEN/FORGE_URL not set)"
|
log "tea login: skipped (tea not found or FORGE_TOKEN/FORGE_URL not set)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start matrix listener in background (if configured)
|
|
||||||
if [ -n "${MATRIX_TOKEN:-}" ] && [ -n "${MATRIX_ROOM_ID:-}" ]; then
|
|
||||||
log "Starting matrix listener in background"
|
|
||||||
su -s /bin/bash agent -c "${DISINTO_DIR}/lib/matrix_listener.sh" &
|
|
||||||
else
|
|
||||||
log "Matrix listener: skipped (MATRIX_TOKEN or MATRIX_ROOM_ID not set)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Run cron in the foreground. Cron jobs execute as the agent user.
|
# Run cron in the foreground. Cron jobs execute as the agent user.
|
||||||
log "Starting cron daemon"
|
log "Starting cron daemon"
|
||||||
exec cron -f
|
exec cron -f
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ Claude writes exactly one of these lines to the phase file when a phase ends:
|
||||||
|----------|---------|---------------------|
|
|----------|---------|---------------------|
|
||||||
| `PHASE:awaiting_ci` | PR pushed, waiting for CI to run | Poll CI; inject result when done |
|
| `PHASE:awaiting_ci` | PR pushed, waiting for CI to run | Poll CI; inject result when done |
|
||||||
| `PHASE:awaiting_review` | CI passed, PR open, waiting for review | Wait for `review-poll` to inject feedback |
|
| `PHASE:awaiting_review` | CI passed, PR open, waiting for review | Wait for `review-poll` to inject feedback |
|
||||||
| `PHASE:escalate` | Needs human input (any reason) | Send Matrix notification; session stays alive; 24h timeout → blocked |
|
| `PHASE:escalate` | Needs human input (any reason) | Send vault/forge notification; session stays alive; 24h timeout → blocked |
|
||||||
| `PHASE:done` | Work complete, PR merged | Verify merge, kill tmux session, clean up |
|
| `PHASE:done` | Work complete, PR merged | Verify merge, kill tmux session, clean up |
|
||||||
| `PHASE:failed` | Unrecoverable failure | Escalate to gardener/supervisor |
|
| `PHASE:failed` | Unrecoverable failure | Escalate to gardener/supervisor |
|
||||||
|
|
||||||
|
|
@ -77,9 +77,8 @@ PHASE:awaiting_review → wait for review-poll.sh to post review comment
|
||||||
on APPROVE → inject "approved" into session
|
on APPROVE → inject "approved" into session
|
||||||
on timeout (3h) → inject "no review, escalating"
|
on timeout (3h) → inject "no review, escalating"
|
||||||
|
|
||||||
PHASE:escalate → send Matrix notification with context (issue/PR link, reason)
|
PHASE:escalate → send vault/forge notification with context (issue/PR link, reason)
|
||||||
session stays alive waiting for human reply
|
session stays alive waiting for human reply
|
||||||
on reply → matrix_listener.sh injects reply into tmux session
|
|
||||||
on timeout → 24h: label issue blocked, kill session
|
on timeout → 24h: label issue blocked, kill session
|
||||||
|
|
||||||
PHASE:done → verify PR merged on forge
|
PHASE:done → verify PR merged on forge
|
||||||
|
|
@ -118,7 +117,7 @@ signal to the phase file.
|
||||||
- **Post-loop exit handler (`case $_MONITOR_LOOP_EXIT`):** Must include an
|
- **Post-loop exit handler (`case $_MONITOR_LOOP_EXIT`):** Must include an
|
||||||
`idle_prompt)` branch. Typical actions: log the event, clean up temp files,
|
`idle_prompt)` branch. Typical actions: log the event, clean up temp files,
|
||||||
and (for agents that use escalation) write an escalation entry or notify via
|
and (for agents that use escalation) write an escalation entry or notify via
|
||||||
Matrix. See `dev/dev-agent.sh`, `action/action-agent.sh`, and
|
vault/forge. See `dev/dev-agent.sh`, `action/action-agent.sh`, and
|
||||||
`gardener/gardener-agent.sh` for reference implementations.
|
`gardener/gardener-agent.sh` for reference implementations.
|
||||||
|
|
||||||
## Crash Recovery
|
## Crash Recovery
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
#
|
#
|
||||||
# Trigger: action issue created by planner or any formula.
|
# Trigger: action issue created by planner or any formula.
|
||||||
# The action-agent picks up the issue, executes these steps, writes a draft
|
# The action-agent picks up the issue, executes these steps, writes a draft
|
||||||
# to vault/outreach/{platform}/drafts/, notifies the human via Matrix,
|
# to vault/outreach/{platform}/drafts/, notifies the human via the forge,
|
||||||
# and closes the issue.
|
# and closes the issue.
|
||||||
#
|
#
|
||||||
# YAML front matter in the dispatching action issue:
|
# YAML front matter in the dispatching action issue:
|
||||||
|
|
@ -150,21 +150,17 @@ Write the drafted content to the outreach directory, commit, and push.
|
||||||
|
|
||||||
[[steps]]
|
[[steps]]
|
||||||
id = "notify-human"
|
id = "notify-human"
|
||||||
title = "Notify human via Matrix"
|
title = "Notify human and create PR"
|
||||||
needs = ["write-draft"]
|
needs = ["write-draft"]
|
||||||
description = """
|
description = """
|
||||||
Notify the human, create a PR, and hand off to the orchestrator for CI.
|
Create a PR for the draft and hand off to the orchestrator for CI.
|
||||||
|
|
||||||
1. Read saved state:
|
1. Read saved state:
|
||||||
DRAFT_FILE=$(cat /tmp/rent-a-human-path)
|
DRAFT_FILE=$(cat /tmp/rent-a-human-path)
|
||||||
DRAFT_TITLE=$(cat /tmp/rent-a-human-title)
|
DRAFT_TITLE=$(cat /tmp/rent-a-human-title)
|
||||||
BRANCH=$(cat /tmp/rent-a-human-branch)
|
BRANCH=$(cat /tmp/rent-a-human-branch)
|
||||||
|
|
||||||
2. Send the Matrix notification:
|
2. Create a PR for the draft:
|
||||||
source "$FACTORY_ROOT/lib/env.sh"
|
|
||||||
matrix_send "rent-a-human" "New {{platform}} {{action_type}} draft ready: ${DRAFT_TITLE}"
|
|
||||||
|
|
||||||
3. Create a PR for the draft:
|
|
||||||
PR_RESPONSE=$(curl -sf -X POST \
|
PR_RESPONSE=$(curl -sf -X POST \
|
||||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@
|
||||||
#
|
#
|
||||||
# Key differences from planner/gardener:
|
# Key differences from planner/gardener:
|
||||||
# - Runs every 20min — lightweight health check
|
# - Runs every 20min — lightweight health check
|
||||||
# - Primarily READS state, rarely WRITES (no PRs, just Matrix + journal)
|
# - Primarily READS state, rarely WRITES (no PRs, just journal)
|
||||||
# - Checks vault state for pending procurement items
|
# - Checks vault state for pending procurement items
|
||||||
# - Conversation memory via Matrix thread and journal
|
# - Conversation memory via journal
|
||||||
|
|
||||||
name = "run-supervisor"
|
name = "run-supervisor"
|
||||||
description = "Factory health monitoring: assess metrics, fix issues, report via Matrix, write journal"
|
description = "Factory health monitoring: assess metrics, fix issues, write journal"
|
||||||
version = 1
|
version = 1
|
||||||
model = "sonnet"
|
model = "sonnet"
|
||||||
|
|
||||||
|
|
@ -174,20 +174,16 @@ needs = ["health-assessment"]
|
||||||
|
|
||||||
[[steps]]
|
[[steps]]
|
||||||
id = "report"
|
id = "report"
|
||||||
title = "Post health summary to Matrix"
|
title = "Log health summary"
|
||||||
description = """
|
description = """
|
||||||
Post a status summary to Matrix. Use the matrix_send function:
|
Log a status summary to the journal.
|
||||||
source "$FACTORY_ROOT/lib/env.sh"
|
|
||||||
matrix_send "supervisor" "<message>"
|
|
||||||
|
|
||||||
### When everything is healthy
|
### When everything is healthy
|
||||||
Post a brief "all clear" only if the PREVIOUS run had alerts (check journal).
|
Log a brief "all clear" only if the PREVIOUS run had alerts (check journal).
|
||||||
Do NOT post "all clear" every 20 minutes — that would be noise.
|
Do NOT log "all clear" every 20 minutes — that would be noise.
|
||||||
|
|
||||||
### When there are findings
|
### When there are findings
|
||||||
Post a summary grouped by priority:
|
Log a summary grouped by priority with:
|
||||||
matrix_send "supervisor" "Supervisor health check:
|
|
||||||
|
|
||||||
Fixed:
|
Fixed:
|
||||||
- <what was auto-fixed>
|
- <what was auto-fixed>
|
||||||
|
|
||||||
|
|
@ -195,18 +191,12 @@ Post a summary grouped by priority:
|
||||||
- [P2] <description>
|
- [P2] <description>
|
||||||
- [P3] <description>
|
- [P3] <description>
|
||||||
|
|
||||||
Status: RAM=<X>MB Disk=<Y>% Load=<Z>"
|
Status: RAM=<X>MB Disk=<Y>% Load=<Z>
|
||||||
|
|
||||||
### When vault items were filed (P0-P2 unresolved)
|
### When vault items were filed (P0-P2 unresolved)
|
||||||
Note the vault items in the status summary:
|
Note the vault items in the status summary.
|
||||||
matrix_send "supervisor" "Supervisor health check:
|
|
||||||
|
|
||||||
Filed vault items:
|
Keep messages concise. Do not log identical messages to what was logged
|
||||||
- vault/pending/<id>.md — <summary>
|
|
||||||
|
|
||||||
Status: RAM=<X>MB Disk=<Y>% Load=<Z>"
|
|
||||||
|
|
||||||
Keep messages concise. Do not post identical messages to what was posted
|
|
||||||
in the previous run (check journal for prior messages).
|
in the previous run (check journal for prior messages).
|
||||||
"""
|
"""
|
||||||
needs = ["decide-actions"]
|
needs = ["decide-actions"]
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ directly from cron like the planner, predictor, and supervisor.
|
||||||
**Environment variables consumed**:
|
**Environment variables consumed**:
|
||||||
- `FORGE_TOKEN`, `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
|
- `FORGE_TOKEN`, `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
|
||||||
- `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by gardener-run.sh)
|
- `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by gardener-run.sh)
|
||||||
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER`
|
|
||||||
|
|
||||||
**Lifecycle**: gardener-run.sh (cron 0,6,12,18) → `check_active gardener` → lock + memory guard →
|
**Lifecycle**: gardener-run.sh (cron 0,6,12,18) → `check_active gardener` → lock + memory guard →
|
||||||
load formula + context → create tmux session →
|
load formula + context → create tmux session →
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ sourced as needed.
|
||||||
|
|
||||||
| File | What it provides | Sourced by |
|
| File | What it provides | Sourced by |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `lib/env.sh` | Loads `.env`, sets `FACTORY_ROOT`, exports project config (`FORGE_REPO`, `PROJECT_NAME`, etc.), defines `log()`, `forge_api()`, `forge_api_all()` (accepts optional second TOKEN parameter, defaults to `$FORGE_TOKEN`), `woodpecker_api()`, `wpdb()`, `matrix_send()`, `matrix_send_ctx()`. Auto-loads project TOML if `PROJECT_TOML` is set. Auto-detects `MATRIX_HOMESERVER`: defaults to `http://dendrite:8008` inside a container (`DISINTO_CONTAINER=1`) or `http://localhost:8008` on bare metal; can be overridden via `.env`. **Container note**: when `DISINTO_CONTAINER=1`, `.env` is NOT re-sourced — compose already injects env vars (including `FORGE_URL=http://forgejo:3000`) and re-sourcing would clobber them. | Every agent |
|
| `lib/env.sh` | Loads `.env`, sets `FACTORY_ROOT`, exports project config (`FORGE_REPO`, `PROJECT_NAME`, etc.), defines `log()`, `forge_api()`, `forge_api_all()` (accepts optional second TOKEN parameter, defaults to `$FORGE_TOKEN`), `woodpecker_api()`, `wpdb()`. Auto-loads project TOML if `PROJECT_TOML` is set. **Container note**: when `DISINTO_CONTAINER=1`, `.env` is NOT re-sourced — compose already injects env vars (including `FORGE_URL=http://forgejo:3000`) and re-sourcing would clobber them. | Every agent |
|
||||||
| `lib/ci-helpers.sh` | `ci_passed()` — returns 0 if CI state is "success" (or no CI configured). `ci_required_for_pr()` — returns 0 if PR has code files (CI required), 1 if non-code only (CI not required). `is_infra_step()` — returns 0 if a single CI step failure matches infra heuristics (clone/git exit 128, any exit 137, log timeout patterns). `classify_pipeline_failure()` — returns "infra \<reason>" if any failed Woodpecker step matches infra heuristics via `is_infra_step()`, else "code". `ensure_priority_label()` — looks up (or creates) the `priority` label and returns its ID; caches in `_PRIORITY_LABEL_ID`. `ci_commit_status <sha>` — queries Woodpecker directly for CI state, falls back to forge commit status API. `ci_pipeline_number <sha>` — returns the Woodpecker pipeline number for a commit, falls back to parsing forge status `target_url`. | dev-poll, review-poll, review-pr, supervisor-poll |
|
| `lib/ci-helpers.sh` | `ci_passed()` — returns 0 if CI state is "success" (or no CI configured). `ci_required_for_pr()` — returns 0 if PR has code files (CI required), 1 if non-code only (CI not required). `is_infra_step()` — returns 0 if a single CI step failure matches infra heuristics (clone/git exit 128, any exit 137, log timeout patterns). `classify_pipeline_failure()` — returns "infra \<reason>" if any failed Woodpecker step matches infra heuristics via `is_infra_step()`, else "code". `ensure_priority_label()` — looks up (or creates) the `priority` label and returns its ID; caches in `_PRIORITY_LABEL_ID`. `ci_commit_status <sha>` — queries Woodpecker directly for CI state, falls back to forge commit status API. `ci_pipeline_number <sha>` — returns the Woodpecker pipeline number for a commit, falls back to parsing forge status `target_url`. | dev-poll, review-poll, review-pr, supervisor-poll |
|
||||||
| `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`, `FORGE_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`, `FORGE_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, mirror 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` / `blocked by #N` patterns. Inline scan skips fenced code blocks to prevent false positives from code examples in issue bodies. 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` / `blocked by #N` patterns. Inline scan skips fenced code blocks to prevent false positives from code examples in issue bodies. 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 tmux session injection (dev, action, vault, review) or well-known files (`/tmp/{agent}-escalation-reply` for supervisor/gardener). Handles all agent reply routing. Uses `nohup` for robustness and validates TOML path before passing to exec-inject.sh. In compose mode, started as a background process by `docker/agents/entrypoint.sh`; on bare metal, run as systemd service (see `matrix_listener.service`). | Standalone daemon |
|
|
||||||
| `lib/formula-session.sh` | `acquire_cron_lock()`, `check_memory()`, `load_formula()`, `build_context_block()`, `consume_escalation_reply()`, `start_formula_session()`, `formula_phase_callback()`, `build_prompt_footer()`, `build_graph_section()`, `run_formula_and_monitor(AGENT [TIMEOUT] [CALLBACK])` — shared helpers for formula-driven cron agents (lock, memory guard, formula loading, prompt assembly, tmux session, monitor loop, crash recovery). `build_graph_section()` generates the structural-analysis section (runs `lib/build-graph.py`, formats JSON output) — previously duplicated in planner-run.sh and predictor-run.sh, now shared here. `formula_phase_callback()` handles `PHASE:escalate` (unified escalation path — kills the session; callers may follow up via Matrix). `run_formula_and_monitor` accepts an optional CALLBACK (default: `formula_phase_callback`) so callers can install custom merge-through or escalation handlers. | planner-run.sh, predictor-run.sh, gardener-run.sh, supervisor-run.sh, dev-agent.sh, action-agent.sh |
|
| `lib/formula-session.sh` | `acquire_cron_lock()`, `check_memory()`, `load_formula()`, `build_context_block()`, `consume_escalation_reply()`, `start_formula_session()`, `formula_phase_callback()`, `build_prompt_footer()`, `build_graph_section()`, `run_formula_and_monitor(AGENT [TIMEOUT] [CALLBACK])` — shared helpers for formula-driven cron agents (lock, memory guard, formula loading, prompt assembly, tmux session, monitor loop, crash recovery). `build_graph_section()` generates the structural-analysis section (runs `lib/build-graph.py`, formats JSON output) — previously duplicated in planner-run.sh and predictor-run.sh, now shared here. `formula_phase_callback()` handles `PHASE:escalate` (unified escalation path — kills the session; callers may follow up via Matrix). `run_formula_and_monitor` accepts an optional CALLBACK (default: `formula_phase_callback`) so callers can install custom merge-through or escalation handlers. | planner-run.sh, predictor-run.sh, gardener-run.sh, supervisor-run.sh, dev-agent.sh, action-agent.sh |
|
||||||
| `lib/guard.sh` | `check_active(agent_name)` — reads `$FACTORY_ROOT/state/.{agent_name}-active`; exits 0 (skip) if the file is absent. Factory is off by default — state files must be created to enable each agent. **Logs a message to stderr** when skipping (`[check_active] SKIP: state file not found`), so agent dropout is visible in cron logs. Sourced by dev-poll.sh, review-poll.sh, action-poll.sh, predictor-run.sh, supervisor-run.sh. | cron entry points |
|
| `lib/guard.sh` | `check_active(agent_name)` — reads `$FACTORY_ROOT/state/.{agent_name}-active`; exits 0 (skip) if the file is absent. Factory is off by default — state files must be created to enable each agent. **Logs a message to stderr** when skipping (`[check_active] SKIP: state file not found`), so agent dropout is visible in cron logs. Sourced by dev-poll.sh, review-poll.sh, action-poll.sh, predictor-run.sh, supervisor-run.sh. | cron entry points |
|
||||||
| `lib/mirrors.sh` | `mirror_push()` — pushes `$PRIMARY_BRANCH` + tags to all configured mirror remotes (fire-and-forget background pushes). Reads `MIRROR_NAMES` and `MIRROR_*` vars exported by `load-project.sh` from the `[mirrors]` TOML section. Failures are logged but never block the pipeline. Sourced by dev-poll.sh and dev/phase-handler.sh — called after every successful merge. | dev-poll.sh, phase-handler.sh |
|
| `lib/mirrors.sh` | `mirror_push()` — pushes `$PRIMARY_BRANCH` + tags to all configured mirror remotes (fire-and-forget background pushes). Reads `MIRROR_NAMES` and `MIRROR_*` vars exported by `load-project.sh` from the `[mirrors]` TOML section. Failures are logged but never block the pipeline. Sourced by dev-poll.sh and dev/phase-handler.sh — called after every successful merge. | dev-poll.sh, phase-handler.sh |
|
||||||
|
|
@ -19,4 +18,4 @@ sourced as needed.
|
||||||
| `lib/secret-scan.sh` | `scan_for_secrets()` — detects potential secrets (API keys, bearer tokens, private keys, URLs with embedded credentials) in text; returns 1 if secrets found. `redact_secrets()` — replaces detected secret patterns with `[REDACTED]`. | file-action-issue.sh, phase-handler.sh |
|
| `lib/secret-scan.sh` | `scan_for_secrets()` — detects potential secrets (API keys, bearer tokens, private keys, URLs with embedded credentials) in text; returns 1 if secrets found. `redact_secrets()` — replaces detected secret patterns with `[REDACTED]`. | file-action-issue.sh, phase-handler.sh |
|
||||||
| `lib/file-action-issue.sh` | `file_action_issue()` — dedup check, secret scan, label lookup, and issue creation for formula-driven cron wrappers. Sets `FILED_ISSUE_NUM` on success. Returns 4 if secrets detected in body. | (available for future use) |
|
| `lib/file-action-issue.sh` | `file_action_issue()` — dedup check, secret scan, label lookup, and issue creation for formula-driven cron wrappers. Sets `FILED_ISSUE_NUM` on success. Returns 4 if secrets detected in body. | (available for future use) |
|
||||||
| `lib/tea-helpers.sh` | `tea_file_issue(title, body, labels...)` — create issue via tea CLI with secret scanning; sets `FILED_ISSUE_NUM`. `tea_relabel(issue_num, labels...)` — replace labels using tea's `edit` subcommand (not `label`). `tea_comment(issue_num, body)` — add comment with secret scanning. `tea_close(issue_num)` — close issue. All use `TEA_LOGIN` and `FORGE_REPO` from env.sh. Labels by name (no ID lookup). Tea binary download verified via sha256 checksum. Sourced by env.sh when `tea` binary is available. | env.sh (conditional) |
|
| `lib/tea-helpers.sh` | `tea_file_issue(title, body, labels...)` — create issue via tea CLI with secret scanning; sets `FILED_ISSUE_NUM`. `tea_relabel(issue_num, labels...)` — replace labels using tea's `edit` subcommand (not `label`). `tea_comment(issue_num, body)` — add comment with secret scanning. `tea_close(issue_num)` — close issue. All use `TEA_LOGIN` and `FORGE_REPO` from env.sh. Labels by name (no ID lookup). Tea binary download verified via sha256 checksum. Sourced by env.sh when `tea` binary is available. | env.sh (conditional) |
|
||||||
| `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()`, `write_compact_context()`. `create_agent_session(session, workdir, [phase_file])` optionally installs a PostToolUse hook (matcher `Bash\|Write`) that detects phase file writes in real-time — when Claude writes to the phase file, the hook writes a marker so `monitor_phase_loop` reacts on the next poll instead of waiting for mtime changes. Also installs a StopFailure hook (matcher `rate_limit\|server_error\|authentication_failed\|billing_error`) that writes `PHASE:failed` with an `api_error` reason to the phase file and touches the phase-changed marker, so the orchestrator discovers API errors within one poll cycle instead of waiting for idle timeout. Also installs a SessionStart hook (matcher `compact`) that re-injects phase protocol instructions after context compaction — callers write the context file via `write_compact_context(phase_file, content)`, and the hook (`on-compact-reinject.sh`) outputs the file content to stdout so Claude retains critical instructions. When `MATRIX_THREAD_ID` is exported, also installs a Stop hook (`on-stop-matrix.sh`) that streams each Claude response to the Matrix thread. When `phase_file` is set, passes it to the idle stop hook (`on-idle-stop.sh`) so the hook can **nudge Claude** (up to 2 times) if Claude returns to the prompt without writing to the phase file — the hook injects a tmux reminder asking Claude to signal PHASE:done or PHASE:awaiting_ci. The PreToolUse guard hook (`on-pretooluse-guard.sh`) receives the session name as a third argument — formula agents (`gardener-*`, `planner-*`, `predictor-*`, `supervisor-*`) are identified this way and allowed to access `FACTORY_ROOT` from worktrees (they need env.sh, AGENTS.md, formulas/, lib/). **OAuth flock**: when `DISINTO_CONTAINER=1`, Claude CLI is wrapped in `flock -w 300 ~/.claude/session.lock` to queue concurrent token refresh attempts and prevent rotation races across agents sharing the same credentials. `monitor_phase_loop` sets `_MONITOR_LOOP_EXIT` to one of: `done`, `idle_timeout`, `idle_prompt` (Claude returned to `>` for 3 consecutive polls without writing any phase — callback invoked with `PHASE:failed`, session already dead), `crashed`, or `PHASE:escalate` / other `PHASE:*` string. **Unified escalation**: `PHASE:escalate` is the signal that a session needs human input (renamed from `PHASE:needs_human`). **Callers must handle `idle_prompt`** in both their callback and their post-loop exit handler — see [`docs/PHASE-PROTOCOL.md` idle_prompt](docs/PHASE-PROTOCOL.md#idle_prompt-exit-reason) for the full contract. | dev-agent.sh, action-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()`, `write_compact_context()`. `create_agent_session(session, workdir, [phase_file])` optionally installs a PostToolUse hook (matcher `Bash\|Write`) that detects phase file writes in real-time — when Claude writes to the phase file, the hook writes a marker so `monitor_phase_loop` reacts on the next poll instead of waiting for mtime changes. Also installs a StopFailure hook (matcher `rate_limit\|server_error\|authentication_failed\|billing_error`) that writes `PHASE:failed` with an `api_error` reason to the phase file and touches the phase-changed marker, so the orchestrator discovers API errors within one poll cycle instead of waiting for idle timeout. Also installs a SessionStart hook (matcher `compact`) that re-injects phase protocol instructions after context compaction — callers write the context file via `write_compact_context(phase_file, content)`, and the hook (`on-compact-reinject.sh`) outputs the file content to stdout so Claude retains critical instructions. When `phase_file` is set, passes it to the idle stop hook (`on-idle-stop.sh`) so the hook can **nudge Claude** (up to 2 times) if Claude returns to the prompt without writing to the phase file — the hook injects a tmux reminder asking Claude to signal PHASE:done or PHASE:awaiting_ci. The PreToolUse guard hook (`on-pretooluse-guard.sh`) receives the session name as a third argument — formula agents (`gardener-*`, `planner-*`, `predictor-*`, `supervisor-*`) are identified this way and allowed to access `FACTORY_ROOT` from worktrees (they need env.sh, AGENTS.md, formulas/, lib/). **OAuth flock**: when `DISINTO_CONTAINER=1`, Claude CLI is wrapped in `flock -w 300 ~/.claude/session.lock` to queue concurrent token refresh attempts and prevent rotation races across agents sharing the same credentials. `monitor_phase_loop` sets `_MONITOR_LOOP_EXIT` to one of: `done`, `idle_timeout`, `idle_prompt` (Claude returned to `>` for 3 consecutive polls without writing any phase — callback invoked with `PHASE:failed`, session already dead), `crashed`, or `PHASE:escalate` / other `PHASE:*` string. **Unified escalation**: `PHASE:escalate` is the signal that a session needs human input (renamed from `PHASE:needs_human`). **Callers must handle `idle_prompt`** in both their callback and their post-loop exit handler — see [`docs/PHASE-PROTOCOL.md` idle_prompt](docs/PHASE-PROTOCOL.md#idle_prompt-exit-reason) for the full contract. | dev-agent.sh, action-agent.sh |
|
||||||
|
|
|
||||||
|
|
@ -290,32 +290,6 @@ create_agent_session() {
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Install Stop hook for Matrix streaming: when MATRIX_THREAD_ID is set,
|
|
||||||
# each Claude response is posted to the Matrix thread so humans can follow.
|
|
||||||
local matrix_hook_script="${FACTORY_ROOT}/lib/hooks/on-stop-matrix.sh"
|
|
||||||
if [ -n "${MATRIX_THREAD_ID:-}" ] && [ -x "$matrix_hook_script" ]; then
|
|
||||||
if [ -f "$settings" ]; then
|
|
||||||
jq --arg cmd "$matrix_hook_script" '
|
|
||||||
if (.hooks.Stop // [] | any(.[]; .hooks[]?.command == $cmd))
|
|
||||||
then .
|
|
||||||
else .hooks.Stop = (.hooks.Stop // []) + [{
|
|
||||||
matcher: "",
|
|
||||||
hooks: [{type: "command", command: $cmd}]
|
|
||||||
}]
|
|
||||||
end
|
|
||||||
' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings"
|
|
||||||
else
|
|
||||||
jq -n --arg cmd "$matrix_hook_script" '{
|
|
||||||
hooks: {
|
|
||||||
Stop: [{
|
|
||||||
matcher: "",
|
|
||||||
hooks: [{type: "command", command: $cmd}]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}' > "$settings"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f "$idle_marker"
|
rm -f "$idle_marker"
|
||||||
local model_flag=""
|
local model_flag=""
|
||||||
if [ -n "${CLAUDE_MODEL:-}" ]; then
|
if [ -n "${CLAUDE_MODEL:-}" ]; then
|
||||||
|
|
|
||||||
86
lib/env.sh
86
lib/env.sh
|
|
@ -85,18 +85,6 @@ export CLAUDE_TIMEOUT="${CLAUDE_TIMEOUT:-7200}"
|
||||||
# Factory processes must never phone home or auto-update mid-session (#725).
|
# Factory processes must never phone home or auto-update mid-session (#725).
|
||||||
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
||||||
|
|
||||||
# Matrix homeserver: inside compose Dendrite is at http://dendrite:8008,
|
|
||||||
# on bare metal it defaults to http://localhost:8008.
|
|
||||||
if [ -z "${MATRIX_HOMESERVER:-}" ]; then
|
|
||||||
if [ "${DISINTO_CONTAINER:-}" = "1" ]; then
|
|
||||||
export MATRIX_HOMESERVER="http://dendrite:8008"
|
|
||||||
else
|
|
||||||
export MATRIX_HOMESERVER="http://localhost:8008"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
export MATRIX_HOMESERVER
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Shared log helper
|
# Shared log helper
|
||||||
log() {
|
log() {
|
||||||
printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*"
|
printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*"
|
||||||
|
|
@ -158,80 +146,6 @@ wpdb() {
|
||||||
-t "$@" 2>/dev/null
|
-t "$@" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
# Matrix messaging helper — usage: matrix_send <prefix> <message> [thread_event_id] [context_tag]
|
|
||||||
# Returns event_id on stdout. Registers threads for listener dispatch.
|
|
||||||
# context_tag is stored in the thread map (e.g. issue number) for routing replies.
|
|
||||||
# Thread map: use persistent data dir inside container, /tmp on bare metal
|
|
||||||
if [ "${DISINTO_CONTAINER:-}" = "1" ]; then
|
|
||||||
MATRIX_THREAD_MAP="${MATRIX_THREAD_MAP:-${DISINTO_DATA_DIR}/matrix-thread-map}"
|
|
||||||
else
|
|
||||||
MATRIX_THREAD_MAP="${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}"
|
|
||||||
fi
|
|
||||||
matrix_send() {
|
|
||||||
[ -z "${MATRIX_TOKEN:-}" ] && return 0
|
|
||||||
local prefix="$1" msg="$2" thread_id="${3:-}" ctx_tag="${4:-}"
|
|
||||||
local room_encoded="${MATRIX_ROOM_ID//!/%21}"
|
|
||||||
local txn
|
|
||||||
txn="$(date +%s%N)$$"
|
|
||||||
local body
|
|
||||||
if [ -n "$thread_id" ]; then
|
|
||||||
body=$(jq -nc --arg m "[${prefix}] ${msg}" --arg t "$thread_id" \
|
|
||||||
'{msgtype:"m.text",body:$m,"m.relates_to":{rel_type:"m.thread",event_id:$t}}')
|
|
||||||
else
|
|
||||||
body=$(jq -nc --arg m "[${prefix}] ${msg}" '{msgtype:"m.text",body:$m}')
|
|
||||||
fi
|
|
||||||
local response
|
|
||||||
response=$(curl -s -X PUT \
|
|
||||||
-H "Authorization: Bearer ${MATRIX_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${MATRIX_HOMESERVER}/_matrix/client/v3/rooms/${room_encoded}/send/m.room.message/${txn}" \
|
|
||||||
-d "$body" 2>/dev/null) || return 0
|
|
||||||
local event_id
|
|
||||||
event_id=$(printf '%s' "$response" | jq -r '.event_id // empty' 2>/dev/null)
|
|
||||||
if [ -n "$event_id" ]; then
|
|
||||||
printf '%s' "$event_id"
|
|
||||||
# Register thread root for listener dispatch (escalations only)
|
|
||||||
if [ -z "$thread_id" ]; then
|
|
||||||
printf '%s\t%s\t%s\t%s\t%s\n' "$event_id" "$prefix" "$(date +%s)" "${ctx_tag}" "${PROJECT_NAME:-}" >> "$MATRIX_THREAD_MAP" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# matrix_send_ctx — Send rich Matrix message with HTML formatting
|
|
||||||
# Usage: matrix_send_ctx <prefix> <plain_text> <html_body> [thread_event_id]
|
|
||||||
# Use for notifications that benefit from links, code blocks, or structured content.
|
|
||||||
matrix_send_ctx() {
|
|
||||||
[ -z "${MATRIX_TOKEN:-}" ] && return 0
|
|
||||||
local prefix="$1" plain="$2" html="$3" thread_id="${4:-}"
|
|
||||||
local room_encoded="${MATRIX_ROOM_ID//!/%21}"
|
|
||||||
local txn
|
|
||||||
txn="$(date +%s%N)$$"
|
|
||||||
local body
|
|
||||||
if [ -n "$thread_id" ]; then
|
|
||||||
body=$(jq -nc \
|
|
||||||
--arg m "[${prefix}] ${plain}" \
|
|
||||||
--arg h "<b>[${prefix}]</b> ${html}" \
|
|
||||||
--arg t "$thread_id" \
|
|
||||||
'{msgtype:"m.text",body:$m,format:"org.matrix.custom.html",formatted_body:$h,"m.relates_to":{rel_type:"m.thread",event_id:$t}}')
|
|
||||||
else
|
|
||||||
body=$(jq -nc \
|
|
||||||
--arg m "[${prefix}] ${plain}" \
|
|
||||||
--arg h "<b>[${prefix}]</b> ${html}" \
|
|
||||||
'{msgtype:"m.text",body:$m,format:"org.matrix.custom.html",formatted_body:$h}')
|
|
||||||
fi
|
|
||||||
local response
|
|
||||||
response=$(curl -s -X PUT \
|
|
||||||
-H "Authorization: Bearer ${MATRIX_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${MATRIX_HOMESERVER}/_matrix/client/v3/rooms/${room_encoded}/send/m.room.message/${txn}" \
|
|
||||||
-d "$body" 2>/dev/null) || return 0
|
|
||||||
local event_id
|
|
||||||
event_id=$(printf '%s' "$response" | jq -r '.event_id // empty' 2>/dev/null)
|
|
||||||
if [ -n "$event_id" ]; then
|
|
||||||
printf '%s' "$event_id"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Source tea helpers (available when tea binary is installed)
|
# Source tea helpers (available when tea binary is installed)
|
||||||
if command -v tea &>/dev/null; then
|
if command -v tea &>/dev/null; then
|
||||||
# shellcheck source=tea-helpers.sh
|
# shellcheck source=tea-helpers.sh
|
||||||
|
|
|
||||||
|
|
@ -327,7 +327,6 @@ run_formula_and_monitor() {
|
||||||
|
|
||||||
agent_inject_into_session "$SESSION_NAME" "$PROMPT"
|
agent_inject_into_session "$SESSION_NAME" "$PROMPT"
|
||||||
log "Prompt sent to tmux session"
|
log "Prompt sent to tmux session"
|
||||||
matrix_send "$agent_name" "${agent_name^} session started for ${FORGE_REPO}" 2>/dev/null || true
|
|
||||||
|
|
||||||
log "Monitoring phase file: ${PHASE_FILE}"
|
log "Monitoring phase file: ${PHASE_FILE}"
|
||||||
_FORMULA_CRASH_COUNT=0
|
_FORMULA_CRASH_COUNT=0
|
||||||
|
|
@ -351,8 +350,6 @@ run_formula_and_monitor() {
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
matrix_send "$agent_name" "${agent_name^} session finished (${FINAL_PHASE:-no phase})" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Preserve worktree on crash for debugging; clean up on success
|
# Preserve worktree on crash for debugging; clean up on success
|
||||||
if [ "${_MONITOR_LOOP_EXIT:-}" = "crashed" ]; then
|
if [ "${_MONITOR_LOOP_EXIT:-}" = "crashed" ]; then
|
||||||
log "PRESERVED crashed worktree for debugging: ${_FORMULA_SESSION_WORKDIR:-}"
|
log "PRESERVED crashed worktree for debugging: ${_FORMULA_SESSION_WORKDIR:-}"
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# on-stop-matrix.sh — Stop hook: post Claude response to Matrix thread.
|
|
||||||
#
|
|
||||||
# Called by Claude Code after each assistant turn. Reads the response from
|
|
||||||
# the hook JSON and posts it to the Matrix thread for this action session.
|
|
||||||
#
|
|
||||||
# Requires env vars: MATRIX_THREAD_ID, MATRIX_TOKEN, MATRIX_HOMESERVER, MATRIX_ROOM_ID
|
|
||||||
#
|
|
||||||
# Usage (in .claude/settings.json):
|
|
||||||
# {"type": "command", "command": "/path/to/on-stop-matrix.sh"}
|
|
||||||
|
|
||||||
# Exit early if Matrix thread not configured
|
|
||||||
if [ -z "${MATRIX_THREAD_ID:-}" ] || [ -z "${MATRIX_TOKEN:-}" ] \
|
|
||||||
|| [ -z "${MATRIX_HOMESERVER:-}" ] || [ -z "${MATRIX_ROOM_ID:-}" ]; then
|
|
||||||
cat > /dev/null
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
input=$(cat)
|
|
||||||
|
|
||||||
# Extract response text from hook JSON
|
|
||||||
response=$(printf '%s' "$input" | jq -r '.last_assistant_message // empty' 2>/dev/null)
|
|
||||||
[ -z "$response" ] && exit 0
|
|
||||||
|
|
||||||
# Truncate long output for readability (keep to ~4000 chars)
|
|
||||||
MAX_LEN=4000
|
|
||||||
if [ "${#response}" -gt "$MAX_LEN" ]; then
|
|
||||||
response="${response:0:$MAX_LEN}
|
|
||||||
... [truncated]"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Post to Matrix thread
|
|
||||||
room_encoded="${MATRIX_ROOM_ID//!/%21}"
|
|
||||||
txn="$(date +%s%N)$$"
|
|
||||||
|
|
||||||
body=$(jq -nc \
|
|
||||||
--arg m "$response" \
|
|
||||||
--arg t "$MATRIX_THREAD_ID" \
|
|
||||||
'{msgtype:"m.text",body:$m,"m.relates_to":{rel_type:"m.thread",event_id:$t}}')
|
|
||||||
|
|
||||||
curl -s -X PUT \
|
|
||||||
-H "Authorization: Bearer ${MATRIX_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${MATRIX_HOMESERVER}/_matrix/client/v3/rooms/${room_encoded}/send/m.room.message/${txn}" \
|
|
||||||
-d "$body" > /dev/null 2>&1 || true
|
|
||||||
|
|
@ -58,17 +58,6 @@ svc = cfg.get('services', {})
|
||||||
if 'containers' in svc:
|
if 'containers' in svc:
|
||||||
emit('PROJECT_CONTAINERS', svc['containers'])
|
emit('PROJECT_CONTAINERS', svc['containers'])
|
||||||
|
|
||||||
# [matrix] section
|
|
||||||
mx = cfg.get('matrix', {})
|
|
||||||
if 'room_id' in mx:
|
|
||||||
emit('MATRIX_ROOM_ID', mx['room_id'])
|
|
||||||
if 'bot_user' in mx:
|
|
||||||
emit('MATRIX_BOT_USER', mx['bot_user'])
|
|
||||||
if 'token_env' in mx:
|
|
||||||
emit('MATRIX_TOKEN_ENV', mx['token_env'])
|
|
||||||
if 'mention_user' in mx:
|
|
||||||
emit('MATRIX_MENTION_USER', mx['mention_user'])
|
|
||||||
|
|
||||||
# [monitoring] section
|
# [monitoring] section
|
||||||
mon = cfg.get('monitoring', {})
|
mon = cfg.get('monitoring', {})
|
||||||
for key in ['check_prs', 'check_dev_agent', 'check_pipeline_stall']:
|
for key in ['check_prs', 'check_dev_agent', 'check_pipeline_stall']:
|
||||||
|
|
@ -110,10 +99,4 @@ if [ -z "${PROJECT_REPO_ROOT:-}" ] && [ -n "${PROJECT_NAME:-}" ]; then
|
||||||
export PROJECT_REPO_ROOT="/home/${USER}/${PROJECT_NAME}"
|
export PROJECT_REPO_ROOT="/home/${USER}/${PROJECT_NAME}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Resolve MATRIX_TOKEN from env var name (token_env points to an env var, not the token itself)
|
|
||||||
if [ -n "${MATRIX_TOKEN_ENV:-}" ]; then
|
|
||||||
export MATRIX_TOKEN="${!MATRIX_TOKEN_ENV:-}"
|
|
||||||
unset MATRIX_TOKEN_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
unset _PROJECT_TOML _PROJECT_VARS _key _val
|
unset _PROJECT_TOML _PROJECT_VARS _key _val
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
# Legacy systemd unit for bare-metal deployments (disinto init --bare).
|
|
||||||
# In compose mode, the matrix listener runs inside the agent container
|
|
||||||
# as a background process — see docker/agents/entrypoint.sh.
|
|
||||||
[Unit]
|
|
||||||
Description=Disinto Matrix Listener
|
|
||||||
After=network.target dendrite.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/home/admin/disinto/lib/matrix_listener.sh
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
User=admin
|
|
||||||
WorkingDirectory=/home/admin/disinto
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
|
|
@ -1,350 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# matrix_listener.sh — Long-poll Matrix sync daemon
|
|
||||||
#
|
|
||||||
# Listens for replies in the Matrix coordination room and dispatches them
|
|
||||||
# to the appropriate agent via well-known files.
|
|
||||||
#
|
|
||||||
# Dispatch:
|
|
||||||
# Thread reply to [supervisor] message → /tmp/supervisor-escalation-reply
|
|
||||||
# Thread reply to [gardener] message → /tmp/gardener-escalation-reply
|
|
||||||
# Thread reply to [dev] message → injected into dev tmux session (or /tmp/dev-escalation-reply)
|
|
||||||
# Thread reply to [review] message → injected into review tmux session
|
|
||||||
# Thread reply to [vault] message → APPROVE/REJECT dispatched via vault-fire/vault-reject
|
|
||||||
# Thread reply to [action] message → injected into action tmux session
|
|
||||||
#
|
|
||||||
# In compose mode, started by docker/agents/entrypoint.sh as a background process.
|
|
||||||
# On bare metal, run as systemd service (see matrix_listener.service) or manually:
|
|
||||||
# ./matrix_listener.sh
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Load shared environment
|
|
||||||
source "$(dirname "$0")/../lib/env.sh"
|
|
||||||
|
|
||||||
# Pidfile guard — prevent duplicate listener processes.
|
|
||||||
# Inside a container the PID file from a previous run is stale (container
|
|
||||||
# restart resets the PID namespace), so we only honour it when the recorded
|
|
||||||
# PID is still alive.
|
|
||||||
PIDFILE="/tmp/matrix-listener.pid"
|
|
||||||
if [ -f "$PIDFILE" ]; then
|
|
||||||
OLD_PID=$(cat "$PIDFILE" 2>/dev/null || true)
|
|
||||||
if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then
|
|
||||||
echo "Listener already running (PID $OLD_PID)" >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
# Stale pidfile (previous container run or crashed process) — remove it
|
|
||||||
rm -f "$PIDFILE"
|
|
||||||
fi
|
|
||||||
echo $$ > "$PIDFILE"
|
|
||||||
trap 'rm -f "$PIDFILE"' EXIT
|
|
||||||
|
|
||||||
SINCE_FILE="/tmp/matrix-listener-since"
|
|
||||||
THREAD_MAP="${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}"
|
|
||||||
ACKED_FILE="/tmp/matrix-listener-acked"
|
|
||||||
LOGFILE="${FACTORY_ROOT}/supervisor/matrix-listener.log"
|
|
||||||
SYNC_TIMEOUT=30000 # 30s long-poll
|
|
||||||
BACKOFF=5
|
|
||||||
MAX_BACKOFF=60
|
|
||||||
|
|
||||||
log() {
|
|
||||||
printf '[%s] listener: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Validate Matrix config
|
|
||||||
if [ -z "${MATRIX_TOKEN:-}" ] || [ -z "${MATRIX_ROOM_ID:-}" ]; then
|
|
||||||
echo "MATRIX_TOKEN and MATRIX_ROOM_ID must be set in .env" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build sync filter — only our room, only messages
|
|
||||||
FILTER=$(jq -nc --arg room "$MATRIX_ROOM_ID" '{
|
|
||||||
room: {
|
|
||||||
rooms: [$room],
|
|
||||||
timeline: {types: ["m.room.message"], limit: 20},
|
|
||||||
state: {types: []},
|
|
||||||
ephemeral: {types: []}
|
|
||||||
},
|
|
||||||
presence: {types: []}
|
|
||||||
}')
|
|
||||||
|
|
||||||
# Load previous sync token
|
|
||||||
SINCE=""
|
|
||||||
if [ -f "$SINCE_FILE" ]; then
|
|
||||||
SINCE=$(cat "$SINCE_FILE" 2>/dev/null || true)
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "started (since=${SINCE:-initial})"
|
|
||||||
|
|
||||||
# Do an initial sync without timeout to catch up, then switch to long-poll
|
|
||||||
INITIAL=true
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
# Build sync URL
|
|
||||||
SYNC_URL="${MATRIX_HOMESERVER}/_matrix/client/v3/sync?filter=$(jq -rn --arg f "$FILTER" '$f | @uri')&timeout=${SYNC_TIMEOUT}"
|
|
||||||
if [ -n "$SINCE" ]; then
|
|
||||||
SYNC_URL="${SYNC_URL}&since=${SINCE}"
|
|
||||||
fi
|
|
||||||
if [ "$INITIAL" = true ]; then
|
|
||||||
# First sync: no timeout, just catch up
|
|
||||||
SYNC_URL="${MATRIX_HOMESERVER}/_matrix/client/v3/sync?filter=$(jq -rn --arg f "$FILTER" '$f | @uri')"
|
|
||||||
[ -n "$SINCE" ] && SYNC_URL="${SYNC_URL}&since=${SINCE}"
|
|
||||||
INITIAL=false
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Long-poll
|
|
||||||
RESPONSE=$(curl -s --max-time $((SYNC_TIMEOUT / 1000 + 30)) \
|
|
||||||
-H "Authorization: Bearer ${MATRIX_TOKEN}" \
|
|
||||||
"$SYNC_URL" 2>/dev/null) || {
|
|
||||||
log "sync failed, backing off ${BACKOFF}s"
|
|
||||||
sleep "$BACKOFF"
|
|
||||||
BACKOFF=$((BACKOFF * 2 > MAX_BACKOFF ? MAX_BACKOFF : BACKOFF * 2))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
# Reset backoff on success
|
|
||||||
BACKOFF=5
|
|
||||||
|
|
||||||
# Extract next_batch
|
|
||||||
NEXT_BATCH=$(printf '%s' "$RESPONSE" | jq -r '.next_batch // empty' 2>/dev/null)
|
|
||||||
if [ -z "$NEXT_BATCH" ]; then
|
|
||||||
log "no next_batch in response"
|
|
||||||
sleep 5
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Save cursor
|
|
||||||
printf '%s' "$NEXT_BATCH" > "$SINCE_FILE"
|
|
||||||
SINCE="$NEXT_BATCH"
|
|
||||||
|
|
||||||
# Extract timeline events from our room
|
|
||||||
EVENTS=$(printf '%s' "$RESPONSE" | jq -c --arg room "$MATRIX_ROOM_ID" '
|
|
||||||
.rooms.join[$room].timeline.events[]? |
|
|
||||||
select(.type == "m.room.message") |
|
|
||||||
select(.sender != "'"${MATRIX_BOT_USER}"'")
|
|
||||||
' 2>/dev/null) || continue
|
|
||||||
|
|
||||||
[ -z "$EVENTS" ] && continue
|
|
||||||
|
|
||||||
while IFS= read -r event; do
|
|
||||||
SENDER=$(printf '%s' "$event" | jq -r '.sender')
|
|
||||||
BODY=$(printf '%s' "$event" | jq -r '.content.body // ""')
|
|
||||||
# Check if this is a thread reply
|
|
||||||
THREAD_ROOT=$(printf '%s' "$event" | jq -r '.content."m.relates_to" | select(.rel_type == "m.thread") | .event_id // empty' 2>/dev/null)
|
|
||||||
|
|
||||||
if [ -z "$THREAD_ROOT" ] || [ -z "$BODY" ]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Look up thread root in our mapping
|
|
||||||
if [ ! -f "$THREAD_MAP" ]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
AGENT=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $2}' "$THREAD_MAP" 2>/dev/null)
|
|
||||||
|
|
||||||
if [ -z "$AGENT" ]; then
|
|
||||||
log "reply to unknown thread ${THREAD_ROOT:0:20} from ${SENDER}"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "reply from ${SENDER} to [${AGENT}] thread: ${BODY:0:100}"
|
|
||||||
|
|
||||||
case "$AGENT" in
|
|
||||||
supervisor)
|
|
||||||
printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SENDER" "$BODY" >> /tmp/supervisor-escalation-reply
|
|
||||||
# Acknowledge
|
|
||||||
matrix_send "supervisor" "✓ received, will act on next poll" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
;;
|
|
||||||
gardener)
|
|
||||||
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)
|
|
||||||
# Route reply into the dev tmux session using context_tag (issue number)
|
|
||||||
# Thread map columns: 1=thread_id, 2=agent, 3=timestamp, 4=issue, 5=project
|
|
||||||
DEV_ISSUE=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $4}' "$THREAD_MAP" 2>/dev/null || true)
|
|
||||||
DEV_PROJECT=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $5}' "$THREAD_MAP" 2>/dev/null || true)
|
|
||||||
DEV_INJECTED=false
|
|
||||||
if [ -n "$DEV_ISSUE" ]; then
|
|
||||||
DEV_SESSION="dev-${DEV_PROJECT}-${DEV_ISSUE}"
|
|
||||||
DEV_PHASE_FILE="/tmp/dev-session-${DEV_PROJECT}-${DEV_ISSUE}.phase"
|
|
||||||
if tmux has-session -t "$DEV_SESSION" 2>/dev/null; then
|
|
||||||
DEV_CUR_PHASE=$(head -1 "$DEV_PHASE_FILE" 2>/dev/null | tr -d '[:space:]' || true)
|
|
||||||
if [ "$DEV_CUR_PHASE" = "PHASE:escalate" ] || [ "$DEV_CUR_PHASE" = "PHASE:awaiting_review" ]; then
|
|
||||||
DEV_INJECT_MSG="Human guidance from ${SENDER} in Matrix:
|
|
||||||
|
|
||||||
${BODY}
|
|
||||||
|
|
||||||
Consider this guidance for your current work."
|
|
||||||
DEV_INJECT_TMP=$(mktemp /tmp/dev-q-inject-XXXXXX)
|
|
||||||
printf '%s' "$DEV_INJECT_MSG" > "$DEV_INJECT_TMP"
|
|
||||||
tmux load-buffer -b "dev-q-${DEV_ISSUE}" "$DEV_INJECT_TMP" || true
|
|
||||||
tmux paste-buffer -t "$DEV_SESSION" -b "dev-q-${DEV_ISSUE}" || true
|
|
||||||
sleep 0.5
|
|
||||||
tmux send-keys -t "$DEV_SESSION" "" Enter || true
|
|
||||||
tmux delete-buffer -b "dev-q-${DEV_ISSUE}" 2>/dev/null || true
|
|
||||||
rm -f "$DEV_INJECT_TMP"
|
|
||||||
DEV_INJECTED=true
|
|
||||||
log "human guidance from ${SENDER} injected into ${DEV_SESSION}"
|
|
||||||
# Reply on first successful injection only — no reply on subsequent ones
|
|
||||||
if ! grep -qF "$THREAD_ROOT" "$ACKED_FILE" 2>/dev/null; then
|
|
||||||
matrix_send "dev" "✓ Guidance forwarded to dev session for #${DEV_ISSUE}" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
printf '%s\n' "$THREAD_ROOT" >> "$ACKED_FILE"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "WARN: dev session '${DEV_SESSION}' busy (phase: ${DEV_CUR_PHASE:-active}), queuing message for issue #${DEV_ISSUE}"
|
|
||||||
matrix_send "dev" "❌ Could not inject: dev session for #${DEV_ISSUE} is busy (phase: ${DEV_CUR_PHASE:-active}), message queued" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "WARN: tmux session '${DEV_SESSION}' not found for issue #${DEV_ISSUE} (project: ${DEV_PROJECT:-UNSET})"
|
|
||||||
matrix_send "dev" "❌ Could not inject: tmux session '${DEV_SESSION}' not found (project: ${DEV_PROJECT:-UNSET})" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "dev thread ${THREAD_ROOT:0:20} has no issue mapping"
|
|
||||||
matrix_send "dev" "❌ Could not inject: no issue mapping for this thread" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
# Only write to flat file when direct injection didn't happen,
|
|
||||||
# to avoid supervisor/gardener poll re-injecting the same message.
|
|
||||||
if [ "$DEV_INJECTED" = false ]; then
|
|
||||||
printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SENDER" "$BODY" >> /tmp/dev-escalation-reply
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
review)
|
|
||||||
# Route human questions to persistent review tmux session
|
|
||||||
# Thread map columns: 1=thread_id, 2=agent, 3=timestamp, 4=pr_num, 5=project
|
|
||||||
REVIEW_PR_NUM=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $4}' "$THREAD_MAP" 2>/dev/null || true)
|
|
||||||
REVIEW_PROJECT=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $5}' "$THREAD_MAP" 2>/dev/null || true)
|
|
||||||
if [ -n "$REVIEW_PR_NUM" ]; then
|
|
||||||
REVIEW_SESSION="review-${REVIEW_PROJECT}-${REVIEW_PR_NUM}"
|
|
||||||
REVIEW_PHASE_FILE="/tmp/review-session-${REVIEW_PROJECT}-${REVIEW_PR_NUM}.phase"
|
|
||||||
if tmux has-session -t "$REVIEW_SESSION" 2>/dev/null; then
|
|
||||||
# Skip injection if Claude is mid-review (phase file absent = actively writing)
|
|
||||||
REVIEW_CUR_PHASE=$(head -1 "$REVIEW_PHASE_FILE" 2>/dev/null | tr -d '[:space:]' || true)
|
|
||||||
if [ -z "$REVIEW_CUR_PHASE" ]; then
|
|
||||||
log "WARN: review session '${REVIEW_SESSION}' is mid-review, deferring question for PR #${REVIEW_PR_NUM}"
|
|
||||||
matrix_send "review" "❌ Could not inject: reviewer is mid-review for PR #${REVIEW_PR_NUM}, try again shortly" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
else
|
|
||||||
REVIEW_INJECT_MSG="Human question from ${SENDER} in Matrix:
|
|
||||||
|
|
||||||
${BODY}
|
|
||||||
|
|
||||||
Please answer this question about your review. Explain your reasoning."
|
|
||||||
REVIEW_INJECT_TMP=$(mktemp /tmp/review-q-inject-XXXXXX)
|
|
||||||
printf '%s' "$REVIEW_INJECT_MSG" > "$REVIEW_INJECT_TMP"
|
|
||||||
tmux load-buffer -b "review-q-${REVIEW_PR_NUM}" "$REVIEW_INJECT_TMP" || true
|
|
||||||
tmux paste-buffer -t "$REVIEW_SESSION" -b "review-q-${REVIEW_PR_NUM}" || true
|
|
||||||
sleep 0.5
|
|
||||||
tmux send-keys -t "$REVIEW_SESSION" "" Enter || true
|
|
||||||
tmux delete-buffer -b "review-q-${REVIEW_PR_NUM}" 2>/dev/null || true
|
|
||||||
rm -f "$REVIEW_INJECT_TMP"
|
|
||||||
log "review question from ${SENDER} injected into ${REVIEW_SESSION}"
|
|
||||||
# Reply on first successful injection only
|
|
||||||
if ! grep -qF "$THREAD_ROOT" "$ACKED_FILE" 2>/dev/null; then
|
|
||||||
matrix_send "review" "✓ Question forwarded to reviewer session for PR #${REVIEW_PR_NUM}" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
printf '%s\n' "$THREAD_ROOT" >> "$ACKED_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "WARN: tmux session '${REVIEW_SESSION}' not found for PR #${REVIEW_PR_NUM} (project: ${REVIEW_PROJECT:-UNSET})"
|
|
||||||
matrix_send "review" "❌ Could not inject: tmux session '${REVIEW_SESSION}' not found (project: ${REVIEW_PROJECT:-UNSET})" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "review thread ${THREAD_ROOT:0:20} has no PR mapping"
|
|
||||||
matrix_send "review" "❌ Could not inject: no PR mapping for this thread" "$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)
|
|
||||||
ACTION_PROJECT=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $5}' "$THREAD_MAP" 2>/dev/null || true)
|
|
||||||
if [ -n "$ACTION_ISSUE" ]; then
|
|
||||||
ACTION_SESSION="action-${ACTION_PROJECT}-${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}"
|
|
||||||
# Reply on first successful injection only
|
|
||||||
if ! grep -qF "$THREAD_ROOT" "$ACKED_FILE" 2>/dev/null; then
|
|
||||||
matrix_send "action" "✓ Reply forwarded to action session for issue #${ACTION_ISSUE}" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
printf '%s\n' "$THREAD_ROOT" >> "$ACKED_FILE"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "WARN: tmux session '${ACTION_SESSION}' not found for issue #${ACTION_ISSUE}"
|
|
||||||
matrix_send "action" "❌ Could not inject: tmux session '${ACTION_SESSION}' not found" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "action thread ${THREAD_ROOT:0:20} has no issue mapping"
|
|
||||||
matrix_send "action" "❌ Could not inject: no issue mapping for this thread" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
vault)
|
|
||||||
# Route reply to vault tmux session if one exists (unified escalation path)
|
|
||||||
VAULT_ISSUE=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $4}' "$THREAD_MAP" 2>/dev/null || true)
|
|
||||||
VAULT_PROJECT=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $5}' "$THREAD_MAP" 2>/dev/null || true)
|
|
||||||
VAULT_INJECTED=false
|
|
||||||
if [ -n "$VAULT_ISSUE" ]; then
|
|
||||||
VAULT_SESSION="vault-${VAULT_PROJECT:-default}-${VAULT_ISSUE}"
|
|
||||||
if tmux has-session -t "$VAULT_SESSION" 2>/dev/null; then
|
|
||||||
VAULT_INJECT_MSG="Human reply from ${SENDER} in Matrix:
|
|
||||||
|
|
||||||
${BODY}
|
|
||||||
|
|
||||||
Interpret this response and decide how to proceed."
|
|
||||||
VAULT_INJECT_TMP=$(mktemp /tmp/vault-q-inject-XXXXXX)
|
|
||||||
printf '%s' "$VAULT_INJECT_MSG" > "$VAULT_INJECT_TMP"
|
|
||||||
tmux load-buffer -b "vault-q-${VAULT_ISSUE}" "$VAULT_INJECT_TMP" || true
|
|
||||||
tmux paste-buffer -t "$VAULT_SESSION" -b "vault-q-${VAULT_ISSUE}" || true
|
|
||||||
sleep 0.5
|
|
||||||
tmux send-keys -t "$VAULT_SESSION" "" Enter || true
|
|
||||||
tmux delete-buffer -b "vault-q-${VAULT_ISSUE}" 2>/dev/null || true
|
|
||||||
rm -f "$VAULT_INJECT_TMP"
|
|
||||||
VAULT_INJECTED=true
|
|
||||||
log "human reply from ${SENDER} injected into ${VAULT_SESSION}"
|
|
||||||
if ! grep -qF "$THREAD_ROOT" "$ACKED_FILE" 2>/dev/null; then
|
|
||||||
matrix_send "vault" "✓ Reply forwarded to vault session" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
printf '%s\n' "$THREAD_ROOT" >> "$ACKED_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
# Fallback: parse APPROVE/REJECT for non-session vault actions
|
|
||||||
if [ "$VAULT_INJECTED" = false ]; then
|
|
||||||
VAULT_CMD=$(echo "$BODY" | tr '[:lower:]' '[:upper:]' | grep -oP '^\s*(APPROVE|REJECT)\s+\S+' | head -1 || true)
|
|
||||||
if [ -n "$VAULT_CMD" ]; then
|
|
||||||
VAULT_ACTION=$(echo "$VAULT_CMD" | awk '{print $1}')
|
|
||||||
VAULT_ID=$(echo "$BODY" | awk '{print $2}') # preserve original case for ID
|
|
||||||
log "vault dispatch: $VAULT_ACTION $VAULT_ID"
|
|
||||||
VAULT_DIR="${FACTORY_ROOT}/vault"
|
|
||||||
if [ "$VAULT_ACTION" = "APPROVE" ]; then
|
|
||||||
if bash "${VAULT_DIR}/vault-fire.sh" "$VAULT_ID" >> "${VAULT_DIR}/vault.log" 2>&1; then
|
|
||||||
matrix_send "vault" "✓ approved and fired: ${VAULT_ID}" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
else
|
|
||||||
matrix_send "vault" "✓ approved but fire failed — will retry: ${VAULT_ID}" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
bash "${VAULT_DIR}/vault-reject.sh" "$VAULT_ID" "rejected by ${SENDER}" >> "${VAULT_DIR}/vault.log" 2>&1 || true
|
|
||||||
matrix_send "vault" "✓ rejected: ${VAULT_ID}" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "vault: free-text reply (no session, no APPROVE/REJECT): ${BODY:0:100}"
|
|
||||||
matrix_send "vault" "⚠️ No active vault session. Reply with APPROVE <id> or REJECT <id>, or wait for a vault session to start." "$THREAD_ROOT" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
log "no handler for agent '${AGENT}'"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
done <<< "$EVENTS"
|
|
||||||
done
|
|
||||||
|
|
@ -76,4 +76,3 @@ all milestones" pattern that produced premature work in planner v1/v2.
|
||||||
**Environment variables consumed**:
|
**Environment variables consumed**:
|
||||||
- `FORGE_TOKEN`, `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
|
- `FORGE_TOKEN`, `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
|
||||||
- `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to opus by planner-run.sh)
|
- `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to opus by planner-run.sh)
|
||||||
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER`
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ RAM < 2000 MB).
|
||||||
**Environment variables consumed**:
|
**Environment variables consumed**:
|
||||||
- `FORGE_TOKEN`, `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
|
- `FORGE_TOKEN`, `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
|
||||||
- `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by predictor-run.sh)
|
- `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by predictor-run.sh)
|
||||||
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` — Notifications (optional)
|
|
||||||
|
|
||||||
**Lifecycle**: predictor-run.sh (daily 06:00 cron) → lock + memory guard →
|
**Lifecycle**: predictor-run.sh (daily 06:00 cron) → lock + memory guard →
|
||||||
load formula + context (AGENTS.md, RESOURCES.md, VISION.md, prerequisite-tree.md)
|
load formula + context (AGENTS.md, RESOURCES.md, VISION.md, prerequisite-tree.md)
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,6 @@ stale_minutes = 60
|
||||||
[services]
|
[services]
|
||||||
containers = []
|
containers = []
|
||||||
|
|
||||||
[matrix]
|
|
||||||
room_id = "!your_room_id:matrix.example.org"
|
|
||||||
bot_user = "@disinto-factory:matrix.example.org"
|
|
||||||
token_env = "DISINTO_MATRIX_TOKEN"
|
|
||||||
mention_user = "@johba:matrix.allf.in" # Matrix user to @mention on escalations
|
|
||||||
|
|
||||||
[monitoring]
|
[monitoring]
|
||||||
check_prs = true
|
check_prs = true
|
||||||
check_dev_agent = true
|
check_dev_agent = true
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,6 @@ stale_minutes = 60
|
||||||
[services]
|
[services]
|
||||||
containers = ["ponder"]
|
containers = ["ponder"]
|
||||||
|
|
||||||
[matrix]
|
|
||||||
room_id = "!your_room_id:matrix.example.org"
|
|
||||||
bot_user = "@harb-factory:matrix.example.org"
|
|
||||||
token_env = "MATRIX_TOKEN"
|
|
||||||
mention_user = "@johba:matrix.allf.in" # Matrix user to @mention on escalations
|
|
||||||
|
|
||||||
[monitoring]
|
[monitoring]
|
||||||
check_prs = true
|
check_prs = true
|
||||||
check_dev_agent = true
|
check_dev_agent = true
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,3 @@ spawns `review-pr.sh <pr-number>`.
|
||||||
- `FORGE_REVIEW_TOKEN` — Review-agent token for approvals (use human/admin account; branch protection: in approvals whitelist)
|
- `FORGE_REVIEW_TOKEN` — Review-agent token for approvals (use human/admin account; branch protection: in approvals whitelist)
|
||||||
- `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
|
- `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
|
||||||
- `PRIMARY_BRANCH`, `WOODPECKER_REPO_ID`
|
- `PRIMARY_BRANCH`, `WOODPECKER_REPO_ID`
|
||||||
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER`
|
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,6 @@ if [ -n "$REVIEW_SESSIONS" ]; then
|
||||||
tmux kill-session -t "$session" 2>/dev/null || true
|
tmux kill-session -t "$session" 2>/dev/null || true
|
||||||
rm -f "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json" \
|
rm -f "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json" \
|
||||||
"/tmp/review-injected-${PROJECT_NAME}-${pr_num}"
|
"/tmp/review-injected-${PROJECT_NAME}-${pr_num}"
|
||||||
# Prune thread-map entries for this PR
|
|
||||||
sed -i "/\treview\t[^\t]*\t${pr_num}\t/d" "${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}" 2>/dev/null || true
|
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
git worktree remove "/tmp/${PROJECT_NAME}-review-${pr_num}" --force 2>/dev/null || true
|
git worktree remove "/tmp/${PROJECT_NAME}-review-${pr_num}" --force 2>/dev/null || true
|
||||||
rm -rf "/tmp/${PROJECT_NAME}-review-${pr_num}" 2>/dev/null || true
|
rm -rf "/tmp/${PROJECT_NAME}-review-${pr_num}" 2>/dev/null || true
|
||||||
|
|
@ -71,8 +69,6 @@ if [ -n "$REVIEW_SESSIONS" ]; then
|
||||||
tmux kill-session -t "$session" 2>/dev/null || true
|
tmux kill-session -t "$session" 2>/dev/null || true
|
||||||
rm -f "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json" \
|
rm -f "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json" \
|
||||||
"/tmp/review-injected-${PROJECT_NAME}-${pr_num}"
|
"/tmp/review-injected-${PROJECT_NAME}-${pr_num}"
|
||||||
# Prune thread-map entries for this PR
|
|
||||||
sed -i "/\treview\t[^\t]*\t${pr_num}\t/d" "${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}" 2>/dev/null || true
|
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
git worktree remove "/tmp/${PROJECT_NAME}-review-${pr_num}" --force 2>/dev/null || true
|
git worktree remove "/tmp/${PROJECT_NAME}-review-${pr_num}" --force 2>/dev/null || true
|
||||||
rm -rf "/tmp/${PROJECT_NAME}-review-${pr_num}" 2>/dev/null || true
|
rm -rf "/tmp/${PROJECT_NAME}-review-${pr_num}" 2>/dev/null || true
|
||||||
|
|
@ -86,7 +82,6 @@ if [ -n "$REVIEW_SESSIONS" ]; then
|
||||||
tmux kill-session -t "$session" 2>/dev/null || true
|
tmux kill-session -t "$session" 2>/dev/null || true
|
||||||
rm -f "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json" \
|
rm -f "$phase_file" "/tmp/${PROJECT_NAME}-review-output-${pr_num}.json" \
|
||||||
"/tmp/review-injected-${PROJECT_NAME}-${pr_num}"
|
"/tmp/review-injected-${PROJECT_NAME}-${pr_num}"
|
||||||
sed -i "/\treview\t[^\t]*\t${pr_num}\t/d" "${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}" 2>/dev/null || true
|
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
git worktree remove "/tmp/${PROJECT_NAME}-review-${pr_num}" --force 2>/dev/null || true
|
git worktree remove "/tmp/${PROJECT_NAME}-review-${pr_num}" --force 2>/dev/null || true
|
||||||
rm -rf "/tmp/${PROJECT_NAME}-review-${pr_num}" 2>/dev/null || true
|
rm -rf "/tmp/${PROJECT_NAME}-review-${pr_num}" 2>/dev/null || true
|
||||||
|
|
@ -217,7 +212,6 @@ if [ -n "${REVIEW_SESSIONS:-}" ]; then
|
||||||
inject_review_into_dev_session "$pr_num" "${FRESH_SHA:-$current_sha}" "$pr_branch"
|
inject_review_into_dev_session "$pr_num" "${FRESH_SHA:-$current_sha}" "$pr_branch"
|
||||||
else
|
else
|
||||||
log " #${pr_num} re-review failed"
|
log " #${pr_num} re-review failed"
|
||||||
matrix_send "review" "❌ PR #${pr_num} re-review failed" 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[ "$REVIEWED" -lt "$MAX_REVIEWS" ] || break
|
[ "$REVIEWED" -lt "$MAX_REVIEWS" ] || break
|
||||||
|
|
@ -265,7 +259,6 @@ while IFS= read -r line; do
|
||||||
inject_review_into_dev_session "$PR_NUM" "${FRESH_SHA:-$PR_SHA}" "$PR_BRANCH"
|
inject_review_into_dev_session "$PR_NUM" "${FRESH_SHA:-$PR_SHA}" "$PR_BRANCH"
|
||||||
else
|
else
|
||||||
log " #${PR_NUM} review failed"
|
log " #${PR_NUM} review failed"
|
||||||
matrix_send "review" "❌ PR #${PR_NUM} review failed" 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$REVIEWED" -ge "$MAX_REVIEWS" ]; then
|
if [ "$REVIEWED" -ge "$MAX_REVIEWS" ]; then
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ if [ -z "$REVIEW_JSON" ]; then
|
||||||
jq -n --arg b "## AI Review — Error\n<!-- review-error: ${PR_SHA} -->\nReview failed.\n---\n*${PR_SHA:0:7}*" \
|
jq -n --arg b "## AI Review — Error\n<!-- review-error: ${PR_SHA} -->\nReview failed.\n---\n*${PR_SHA:0:7}*" \
|
||||||
'{body: $b}' | curl -sf -o /dev/null -X POST -H "Authorization: token ${FORGE_TOKEN}" \
|
'{body: $b}' | curl -sf -o /dev/null -X POST -H "Authorization: token ${FORGE_TOKEN}" \
|
||||||
-H "Content-Type: application/json" "${API}/issues/${PR_NUMBER}/comments" -d @- || true
|
-H "Content-Type: application/json" "${API}/issues/${PR_NUMBER}/comments" -d @- || true
|
||||||
matrix_send "review" "PR #${PR_NUMBER} review failed" 2>/dev/null || true; exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
VERDICT=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict' | tr '[:lower:]' '[:upper:]' | tr '-' '_')
|
VERDICT=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict' | tr '[:lower:]' '[:upper:]' | tr '-' '_')
|
||||||
REASON=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict_reason // ""')
|
REASON=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict_reason // ""')
|
||||||
|
|
@ -204,7 +204,6 @@ curl -s -o /dev/null -X POST -H "Authorization: token ${FORGE_REVIEW_TOKEN}" \
|
||||||
--data-binary @"${REVIEW_TMPDIR}/formal.json" >/dev/null 2>&1 || true
|
--data-binary @"${REVIEW_TMPDIR}/formal.json" >/dev/null 2>&1 || true
|
||||||
log "formal ${REVENT} submitted"
|
log "formal ${REVENT} submitted"
|
||||||
|
|
||||||
matrix_send "review" "PR #${PR_NUMBER} ${RTYPE}: ${VERDICT} — ${PR_TITLE}" "" "$PR_NUMBER" >/dev/null 2>&1 || true
|
|
||||||
case "$VERDICT" in
|
case "$VERDICT" in
|
||||||
REQUEST_CHANGES|DISCUSS) printf 'PHASE:awaiting_changes\nSHA:%s\n' "$PR_SHA" > "$PHASE_FILE" ;;
|
REQUEST_CHANGES|DISCUSS) printf 'PHASE:awaiting_changes\nSHA:%s\n' "$PR_SHA" > "$PHASE_FILE" ;;
|
||||||
*) rm -f "$PHASE_FILE" "$OUTPUT_FILE"; cd "${PROJECT_REPO_ROOT}"
|
*) rm -f "$PHASE_FILE" "$OUTPUT_FILE"; cd "${PROJECT_REPO_ROOT}"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
**Role**: Health monitoring and auto-remediation, executed as a formula-driven
|
**Role**: Health monitoring and auto-remediation, executed as a formula-driven
|
||||||
Claude agent. Collects system and project metrics via a bash pre-flight script,
|
Claude agent. Collects system and project metrics via a bash pre-flight script,
|
||||||
then runs an interactive Claude session (sonnet) that assesses health, auto-fixes
|
then runs an interactive Claude session (sonnet) that assesses health, auto-fixes
|
||||||
issues, reports via Matrix, and writes a daily journal. When blocked on external
|
issues, and writes a daily journal. When blocked on external
|
||||||
resources or human decisions, files vault items instead of escalating directly.
|
resources or human decisions, files vault items instead of escalating directly.
|
||||||
|
|
||||||
**Trigger**: `supervisor-run.sh` runs every 20 min via cron. Sources `lib/guard.sh`
|
**Trigger**: `supervisor-run.sh` runs every 20 min via cron. Sources `lib/guard.sh`
|
||||||
|
|
@ -40,17 +40,11 @@ runs directly from cron like the planner and predictor.
|
||||||
**Alert priorities**: P0 (memory crisis), P1 (disk), P2 (factory stopped/stalled),
|
**Alert priorities**: P0 (memory crisis), P1 (disk), P2 (factory stopped/stalled),
|
||||||
P3 (degraded PRs, circular deps, stale deps), P4 (housekeeping).
|
P3 (degraded PRs, circular deps, stale deps), P4 (housekeeping).
|
||||||
|
|
||||||
**Matrix integration**: The supervisor has its own Matrix thread. Posts health
|
|
||||||
summaries when there are changes, reports P0-P2 issues, and processes replies
|
|
||||||
from humans ("ignore disk warning", "kill that agent", "what's stuck?").
|
|
||||||
|
|
||||||
**Environment variables consumed**:
|
**Environment variables consumed**:
|
||||||
- `FORGE_TOKEN`, `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
|
- `FORGE_TOKEN`, `FORGE_REPO`, `FORGE_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
|
||||||
- `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by supervisor-run.sh)
|
- `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by supervisor-run.sh)
|
||||||
- `WOODPECKER_TOKEN`, `WOODPECKER_SERVER`, `WOODPECKER_DB_PASSWORD`, `WOODPECKER_DB_USER`, `WOODPECKER_DB_HOST`, `WOODPECKER_DB_NAME` — CI database queries
|
- `WOODPECKER_TOKEN`, `WOODPECKER_SERVER`, `WOODPECKER_DB_PASSWORD`, `WOODPECKER_DB_USER`, `WOODPECKER_DB_HOST`, `WOODPECKER_DB_NAME` — CI database queries
|
||||||
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` — Matrix notifications + human input
|
|
||||||
|
|
||||||
**Lifecycle**: supervisor-run.sh (cron */20) → lock + memory guard → run
|
**Lifecycle**: supervisor-run.sh (cron */20) → lock + memory guard → run
|
||||||
preflight.sh (collect metrics) → consume Matrix replies → load formula +
|
preflight.sh (collect metrics) → load formula + context → create tmux
|
||||||
context → create tmux session → Claude assesses health, auto-fixes, posts
|
session → Claude assesses health, auto-fixes, writes journal → `PHASE:done`.
|
||||||
Matrix summary, writes journal → `PHASE:done`.
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ This gives you:
|
||||||
- `$PROJECT_NAME` — short project name (for worktree prefixes, container names)
|
- `$PROJECT_NAME` — short project name (for worktree prefixes, container names)
|
||||||
- `$PRIMARY_BRANCH` — main branch (master or main)
|
- `$PRIMARY_BRANCH` — main branch (master or main)
|
||||||
- `$FACTORY_ROOT` — path to the disinto repo
|
- `$FACTORY_ROOT` — path to the disinto repo
|
||||||
- `matrix_send <prefix> <message>` — send notifications to the Matrix coordination room
|
|
||||||
|
|
||||||
## Handling Dependency Alerts
|
## Handling Dependency Alerts
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,11 +134,9 @@ if [ "${AVAIL_MB:-9999}" -lt 500 ] || { [ "${SWAP_USED_MB:-0}" -gt 3000 ] && [ "
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# P0 is urgent — send immediately before per-project checks can crash the script
|
# P0 alerts already logged — clear so they are not duplicated in the final consolidated log
|
||||||
if [ -n "$P0_ALERTS" ]; then
|
if [ -n "$P0_ALERTS" ]; then
|
||||||
matrix_send "supervisor" "🚨 Supervisor P0 alerts:
|
P0_ALERTS=""
|
||||||
$(printf '%b' "$P0_ALERTS")" 2>/dev/null || true
|
|
||||||
P0_ALERTS="" # clear so it is not duplicated in the final consolidated send
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -184,11 +182,9 @@ if [ "${DISK_PERCENT:-0}" -gt 80 ]; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# P1 is urgent — send immediately before per-project checks can crash the script
|
# P1 alerts already logged — clear so they are not duplicated in the final consolidated log
|
||||||
if [ -n "$P1_ALERTS" ]; then
|
if [ -n "$P1_ALERTS" ]; then
|
||||||
matrix_send "supervisor" "⚠️ Supervisor P1 alerts:
|
P1_ALERTS=""
|
||||||
$(printf '%b' "$P1_ALERTS")" 2>/dev/null || true
|
|
||||||
P1_ALERTS="" # clear so it is not duplicated in the final consolidated send
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Emit infra metric
|
# Emit infra metric
|
||||||
|
|
@ -618,7 +614,6 @@ Instructions:
|
||||||
_nh_renotify="/tmp/dev-renotify-${proj_name}-${_nh_issue}"
|
_nh_renotify="/tmp/dev-renotify-${proj_name}-${_nh_issue}"
|
||||||
if [ ! -f "$_nh_renotify" ]; then
|
if [ ! -f "$_nh_renotify" ]; then
|
||||||
_nh_age_h=$(( _nh_age / 3600 ))
|
_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"
|
touch "$_nh_renotify"
|
||||||
flog "${proj_name}: #${_nh_issue} re-notified (escalate for ${_nh_age_h}h)"
|
flog "${proj_name}: #${_nh_issue} re-notified (escalate for ${_nh_age_h}h)"
|
||||||
fi
|
fi
|
||||||
|
|
@ -785,10 +780,6 @@ ALL_ALERTS="${P0_ALERTS}${P1_ALERTS}${P2_ALERTS}${P3_ALERTS}${P4_ALERTS}"
|
||||||
if [ -n "$ALL_ALERTS" ]; then
|
if [ -n "$ALL_ALERTS" ]; then
|
||||||
ALERT_TEXT=$(echo -e "$ALL_ALERTS")
|
ALERT_TEXT=$(echo -e "$ALL_ALERTS")
|
||||||
|
|
||||||
# Notify Matrix
|
|
||||||
matrix_send "supervisor" "⚠️ Supervisor alerts:
|
|
||||||
${ALERT_TEXT}" 2>/dev/null || true
|
|
||||||
|
|
||||||
flog "Invoking claude -p for alerts"
|
flog "Invoking claude -p for alerts"
|
||||||
|
|
||||||
CLAUDE_PROMPT="$(cat "$PROMPT_FILE" 2>/dev/null || echo "You are a supervisor agent. Fix the issue below.")
|
CLAUDE_PROMPT="$(cat "$PROMPT_FILE" 2>/dev/null || echo "You are a supervisor agent. Fix the issue below.")
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
**Pipeline A — Action Gating (*.json)**: Actions enter a pending queue and are
|
**Pipeline A — Action Gating (*.json)**: Actions enter a pending queue and are
|
||||||
classified by Claude via `vault-agent.sh`, which can auto-approve (call
|
classified by Claude via `vault-agent.sh`, which can auto-approve (call
|
||||||
`vault-fire.sh` directly), auto-reject (call `vault-reject.sh`), or escalate
|
`vault-fire.sh` directly), auto-reject (call `vault-reject.sh`), or escalate
|
||||||
to a human by writing `PHASE:escalate` to a phase file and sending a Matrix
|
to a human by writing `PHASE:escalate` to a phase file — using the same
|
||||||
message — using the same unified escalation path as dev/action agents.
|
unified escalation path as dev/action agents.
|
||||||
|
|
||||||
**Pipeline B — Procurement (*.md)**: The planner files resource requests as
|
**Pipeline B — Procurement (*.md)**: The planner files resource requests as
|
||||||
markdown files in `vault/pending/`. `vault-poll.sh` notifies the human via
|
markdown files in `vault/pending/`. `vault-poll.sh` notifies the human via
|
||||||
Matrix. The human fulfills the request (creates accounts, provisions infra,
|
vault/forge. The human fulfills the request (creates accounts, provisions infra,
|
||||||
adds secrets to `.env`) and moves the file to `vault/approved/`.
|
adds secrets to `.env`) and moves the file to `vault/approved/`.
|
||||||
`vault-fire.sh` then extracts the proposed entry and appends it to
|
`vault-fire.sh` then extracts the proposed entry and appends it to
|
||||||
`RESOURCES.md`.
|
`RESOURCES.md`.
|
||||||
|
|
@ -20,7 +20,7 @@ adds secrets to `.env`) and moves the file to `vault/approved/`.
|
||||||
`run-rent-a-human` formula (via an `action` issue) when a task requires a human
|
`run-rent-a-human` formula (via an `action` issue) when a task requires a human
|
||||||
touch — posting on Reddit, commenting on HN, signing up for a service, etc.
|
touch — posting on Reddit, commenting on HN, signing up for a service, etc.
|
||||||
Claude drafts copy-paste-ready content to `vault/outreach/{platform}/drafts/`
|
Claude drafts copy-paste-ready content to `vault/outreach/{platform}/drafts/`
|
||||||
and notifies the human via Matrix for one-click execution. No vault approval
|
and notifies the human via vault/forge for one-click execution. No vault approval
|
||||||
needed — the human reviews and publishes directly.
|
needed — the human reviews and publishes directly.
|
||||||
|
|
||||||
**Trigger**: `vault-poll.sh` runs every 30 min via cron.
|
**Trigger**: `vault-poll.sh` runs every 30 min via cron.
|
||||||
|
|
@ -31,15 +31,14 @@ needed — the human reviews and publishes directly.
|
||||||
- `vault/PROMPT.md` — System prompt for the vault agent's Claude invocation
|
- `vault/PROMPT.md` — System prompt for the vault agent's Claude invocation
|
||||||
- `vault/vault-fire.sh` — Executes an approved action (JSON) or writes RESOURCES.md entry (procurement MD)
|
- `vault/vault-fire.sh` — Executes an approved action (JSON) or writes RESOURCES.md entry (procurement MD)
|
||||||
- `vault/vault-reject.sh` — Marks a JSON action as rejected
|
- `vault/vault-reject.sh` — Marks a JSON action as rejected
|
||||||
- `formulas/run-rent-a-human.toml` — Formula for human-action drafts: Claude researches target platform norms, drafts copy-paste content, writes to `vault/outreach/{platform}/drafts/`, notifies human via Matrix
|
- `formulas/run-rent-a-human.toml` — Formula for human-action drafts: Claude researches target platform norms, drafts copy-paste content, writes to `vault/outreach/{platform}/drafts/`, notifies human via vault/forge
|
||||||
|
|
||||||
**Procurement flow**:
|
**Procurement flow**:
|
||||||
1. Planner drops `vault/pending/<name>.md` with what/why/proposed RESOURCES.md entry
|
1. Planner drops `vault/pending/<name>.md` with what/why/proposed RESOURCES.md entry
|
||||||
2. `vault-poll.sh` notifies human via Matrix
|
2. `vault-poll.sh` notifies human via vault/forge
|
||||||
3. Human fulfills: creates account, adds secrets to `.env`, moves file to `vault/approved/`
|
3. Human fulfills: creates account, adds secrets to `.env`, moves file to `vault/approved/`
|
||||||
4. `vault-fire.sh` extracts proposed entry, appends to RESOURCES.md, moves to `vault/fired/`
|
4. `vault-fire.sh` extracts proposed entry, appends to RESOURCES.md, moves to `vault/fired/`
|
||||||
5. Next planner run reads RESOURCES.md → new capability available → unblocks prerequisite tree
|
5. Next planner run reads RESOURCES.md → new capability available → unblocks prerequisite tree
|
||||||
|
|
||||||
**Environment variables consumed**:
|
**Environment variables consumed**:
|
||||||
- All from `lib/env.sh`
|
- All from `lib/env.sh`
|
||||||
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` — Escalation channel
|
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ For each pending JSON action, decide: **auto-approve**, **escalate**, or **rejec
|
||||||
|----------|------------|---------------------------------------------|
|
|----------|------------|---------------------------------------------|
|
||||||
| low | true | auto-approve → fire immediately |
|
| low | true | auto-approve → fire immediately |
|
||||||
| low | false | auto-approve → fire, log prominently |
|
| low | false | auto-approve → fire, log prominently |
|
||||||
| medium | true | auto-approve → fire, matrix notify |
|
| medium | true | auto-approve → fire, notify via vault/forge |
|
||||||
| medium | false | escalate via matrix → wait for human reply |
|
| medium | false | escalate via vault/forge → wait for human reply |
|
||||||
| high | any | always escalate → wait for human reply |
|
| high | any | always escalate → wait for human reply |
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
@ -94,18 +94,9 @@ source ${FACTORY_ROOT}/lib/env.sh
|
||||||
bash ${FACTORY_ROOT}/vault/vault-fire.sh <action-id>
|
bash ${FACTORY_ROOT}/vault/vault-fire.sh <action-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Escalate via Matrix
|
### Escalate
|
||||||
```bash
|
```bash
|
||||||
matrix_send "vault" "🔒 VAULT — approval required
|
echo "PHASE:escalate" > "$PHASE_FILE"
|
||||||
|
|
||||||
Source: <source>
|
|
||||||
Type: <type>
|
|
||||||
Risk: <risk> / <reversible|irreversible>
|
|
||||||
Created: <created>
|
|
||||||
|
|
||||||
<one-line summary of what the action does>
|
|
||||||
|
|
||||||
Reply APPROVE <id> or REJECT <id>" 2>/dev/null
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reject
|
### Reject
|
||||||
|
|
@ -125,8 +116,7 @@ ROUTE: <action-id> → <auto-approve|escalate|reject> — <reason>
|
||||||
|
|
||||||
- Process ALL pending JSON actions in the batch. Never skip silently.
|
- Process ALL pending JSON actions in the batch. Never skip silently.
|
||||||
- For auto-approved actions, fire them immediately via `vault-fire.sh`.
|
- For auto-approved actions, fire them immediately via `vault-fire.sh`.
|
||||||
- For escalated actions, move to `vault/approved/` only AFTER human approval
|
- For escalated actions, move to `vault/approved/` only AFTER human approval.
|
||||||
(vault-poll handles this via matrix_listener dispatch).
|
|
||||||
- Read the action JSON carefully. Check the payload, not just the metadata.
|
- Read the action JSON carefully. Check the payload, not just the metadata.
|
||||||
- Ignore `.md` files in pending/ — those are procurement requests handled
|
- Ignore `.md` files in pending/ — those are procurement requests handled
|
||||||
separately by vault-poll.sh and the human.
|
separately by vault-poll.sh and the human.
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,6 @@ ${ACTIONS_BATCH}
|
||||||
- Vault directory: ${VAULT_DIR}
|
- Vault directory: ${VAULT_DIR}
|
||||||
- vault-fire.sh: bash ${VAULT_DIR}/vault-fire.sh <action-id>
|
- vault-fire.sh: bash ${VAULT_DIR}/vault-fire.sh <action-id>
|
||||||
- vault-reject.sh: bash ${VAULT_DIR}/vault-reject.sh <action-id> \"<reason>\"
|
- vault-reject.sh: bash ${VAULT_DIR}/vault-reject.sh <action-id> \"<reason>\"
|
||||||
- matrix_send is available after: source ${FACTORY_ROOT}/lib/env.sh
|
|
||||||
|
|
||||||
Process each action now. For auto-approve, fire immediately. For reject, call vault-reject.sh.
|
Process each action now. For auto-approve, fire immediately. For reject, call vault-reject.sh.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,6 @@ if [ "$IS_PROCUREMENT" = true ]; then
|
||||||
|
|
||||||
if [ -z "$ENTRY" ]; then
|
if [ -z "$ENTRY" ]; then
|
||||||
log "ERROR: $ACTION_ID has no '## Proposed RESOURCES.md Entry' section"
|
log "ERROR: $ACTION_ID has no '## Proposed RESOURCES.md Entry' section"
|
||||||
matrix_send "vault" "❌ Procurement $ACTION_ID has no RESOURCES.md entry — cannot fire" 2>/dev/null || true
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -95,7 +94,6 @@ if [ "$IS_PROCUREMENT" = true ]; then
|
||||||
mv "$ACTION_FILE" "${VAULT_DIR}/fired/${ACTION_ID}.md"
|
mv "$ACTION_FILE" "${VAULT_DIR}/fired/${ACTION_ID}.md"
|
||||||
rm -f "${LOCKS_DIR}/${ACTION_ID}.notified"
|
rm -f "${LOCKS_DIR}/${ACTION_ID}.notified"
|
||||||
log "$ACTION_ID: approved → fired (procurement)"
|
log "$ACTION_ID: approved → fired (procurement)"
|
||||||
matrix_send "vault" "✅ Procurement fulfilled: ${ACTION_ID} — RESOURCES.md updated" 2>/dev/null || true
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -175,9 +173,7 @@ if [ "$FIRE_EXIT" -eq 0 ]; then
|
||||||
&& mv "$TMP" "${VAULT_DIR}/fired/${ACTION_ID}.json"
|
&& mv "$TMP" "${VAULT_DIR}/fired/${ACTION_ID}.json"
|
||||||
rm -f "$ACTION_FILE"
|
rm -f "$ACTION_FILE"
|
||||||
log "$ACTION_ID: approved → fired"
|
log "$ACTION_ID: approved → fired"
|
||||||
matrix_send "vault" "✅ Vault fired: ${ACTION_ID} (${ACTION_TYPE} from ${ACTION_SOURCE})" 2>/dev/null || true
|
|
||||||
else
|
else
|
||||||
log "ERROR: $ACTION_ID fire failed (exit $FIRE_EXIT) — stays in approved/ for retry"
|
log "ERROR: $ACTION_ID fire failed (exit $FIRE_EXIT) — stays in approved/ for retry"
|
||||||
matrix_send "vault" "❌ Vault fire failed: ${ACTION_ID} (${ACTION_TYPE}) — will retry" 2>/dev/null || true
|
|
||||||
exit "$FIRE_EXIT"
|
exit "$FIRE_EXIT"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,6 @@ for action_file in "${VAULT_DIR}/approved/"*.json; do
|
||||||
log "fired $ACTION_ID (retry)"
|
log "fired $ACTION_ID (retry)"
|
||||||
else
|
else
|
||||||
log "ERROR: fire failed for $ACTION_ID (retry)"
|
log "ERROR: fire failed for $ACTION_ID (retry)"
|
||||||
matrix_send "vault" "❌ Vault fire failed on retry: ${ACTION_ID}" 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
unlock_action "$ACTION_ID"
|
unlock_action "$ACTION_ID"
|
||||||
|
|
@ -112,7 +111,6 @@ for req_file in "${VAULT_DIR}/approved/"*.md; do
|
||||||
log "fired procurement $REQ_ID (retry)"
|
log "fired procurement $REQ_ID (retry)"
|
||||||
else
|
else
|
||||||
log "ERROR: fire failed for procurement $REQ_ID (retry)"
|
log "ERROR: fire failed for procurement $REQ_ID (retry)"
|
||||||
matrix_send "vault" "❌ Vault fire failed on retry: ${REQ_ID} (procurement)" 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
unlock_action "$REQ_ID"
|
unlock_action "$REQ_ID"
|
||||||
|
|
@ -143,7 +141,6 @@ for action_file in "${VAULT_DIR}/pending/"*.json; do
|
||||||
AGE_HOURS=$((AGE_SECS / 3600))
|
AGE_HOURS=$((AGE_SECS / 3600))
|
||||||
log "timeout: $ACTION_ID escalated ${AGE_HOURS}h ago with no reply — auto-rejecting"
|
log "timeout: $ACTION_ID escalated ${AGE_HOURS}h ago with no reply — auto-rejecting"
|
||||||
bash "${VAULT_DIR}/vault-reject.sh" "$ACTION_ID" "timeout (${AGE_HOURS}h, no human reply)" >> "$LOGFILE" 2>&1 || true
|
bash "${VAULT_DIR}/vault-reject.sh" "$ACTION_ID" "timeout (${AGE_HOURS}h, no human reply)" >> "$LOGFILE" 2>&1 || true
|
||||||
matrix_send "vault" "⏰ Vault auto-rejected ${ACTION_ID} — no reply after ${AGE_HOURS}h" 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
@ -184,7 +181,6 @@ if [ "$PENDING_COUNT" -gt 0 ]; then
|
||||||
|
|
||||||
bash "${VAULT_DIR}/vault-agent.sh" >> "$LOGFILE" 2>&1 || {
|
bash "${VAULT_DIR}/vault-agent.sh" >> "$LOGFILE" 2>&1 || {
|
||||||
log "ERROR: vault-agent failed"
|
log "ERROR: vault-agent failed"
|
||||||
matrix_send "vault" "❌ vault-agent.sh failed — check vault.log" 2>/dev/null || true
|
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -216,15 +212,6 @@ for req_file in "${VAULT_DIR}/pending/"*.md; do
|
||||||
|
|
||||||
log "new procurement request: $REQ_ID — $REQ_TITLE"
|
log "new procurement request: $REQ_ID — $REQ_TITLE"
|
||||||
|
|
||||||
# Notify human via Matrix
|
|
||||||
matrix_send "vault" "🔑 PROCUREMENT REQUEST — ${REQ_TITLE}
|
|
||||||
|
|
||||||
ID: ${REQ_ID}
|
|
||||||
Action: review vault/pending/${REQ_ID}.md
|
|
||||||
To approve: fulfill the request, add secrets to .env, move file to vault/approved/
|
|
||||||
|
|
||||||
$(head -20 "$req_file")" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Mark as notified so we don't re-send
|
# Mark as notified so we don't re-send
|
||||||
mkdir -p "${VAULT_DIR}/.locks"
|
mkdir -p "${VAULT_DIR}/.locks"
|
||||||
touch "${VAULT_DIR}/.locks/${REQ_ID}.notified"
|
touch "${VAULT_DIR}/.locks/${REQ_ID}.notified"
|
||||||
|
|
|
||||||
|
|
@ -43,4 +43,3 @@ rm -f "$ACTION_FILE"
|
||||||
rm -f "${VAULT_DIR}/.locks/${ACTION_ID}.lock"
|
rm -f "${VAULT_DIR}/.locks/${ACTION_ID}.lock"
|
||||||
|
|
||||||
log "$ACTION_ID: rejected — $REASON"
|
log "$ACTION_ID: rejected — $REASON"
|
||||||
matrix_send "vault" "🚫 Vault rejected: ${ACTION_ID} (${ACTION_TYPE} from ${ACTION_SOURCE}) — ${REASON}" 2>/dev/null || true
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue