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:
openhands 2026-03-26 14:53:56 +00:00
parent 7996bb6c06
commit 23949083c0
43 changed files with 73 additions and 1157 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/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}"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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:-}"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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