Merge pull request 'fix: fix: gardener migration — run-gardener.toml via direct cron, remove legacy scripts (#490)' (#491) from fix/issue-490 into main

This commit is contained in:
johba 2026-03-21 14:19:03 +01:00
commit 473319e420
11 changed files with 125 additions and 581 deletions

View file

@ -192,8 +192,6 @@ check_script dev/dev-agent.sh dev/phase-handler.sh
check_script dev/phase-handler.sh dev/dev-agent.sh lib/secret-scan.sh check_script dev/phase-handler.sh dev/dev-agent.sh lib/secret-scan.sh
check_script dev/dev-poll.sh check_script dev/dev-poll.sh
check_script dev/phase-test.sh check_script dev/phase-test.sh
check_script gardener/gardener-agent.sh lib/agent-session.sh
check_script gardener/gardener-poll.sh
check_script gardener/gardener-run.sh check_script gardener/gardener-run.sh
check_script review/review-pr.sh check_script review/review-pr.sh
check_script review/review-poll.sh check_script review/review-poll.sh

View file

@ -16,8 +16,7 @@ See `README.md` for the full architecture and `BOOTSTRAP.md` for setup.
disinto/ disinto/
├── dev/ dev-poll.sh, dev-agent.sh, phase-handler.sh — issue implementation ├── dev/ dev-poll.sh, dev-agent.sh, phase-handler.sh — issue implementation
├── review/ review-poll.sh, review-pr.sh — PR review ├── review/ review-poll.sh, review-pr.sh — PR review
├── gardener/ gardener-run.sh — files action issue for run-gardener formula ├── gardener/ gardener-run.sh — direct cron executor for run-gardener formula
│ gardener-poll.sh, gardener-agent.sh — grooming
├── predictor/ predictor-run.sh — daily cron executor for run-predictor formula ├── predictor/ predictor-run.sh — daily cron executor for run-predictor formula
├── planner/ planner-run.sh — direct cron executor for run-planner formula ├── planner/ planner-run.sh — direct cron executor for run-planner formula
│ planner/journal/ — daily raw logs from each planner run │ planner/journal/ — daily raw logs from each planner run

View file

@ -627,7 +627,7 @@ Instructions:
"⚠️ Issue #${ISSUE} (PR #${PR_NUMBER:-none}) needs human input.${HUMAN_REASON:+ Reason: ${HUMAN_REASON}}" \ "⚠️ Issue #${ISSUE} (PR #${PR_NUMBER:-none}) needs human input.${HUMAN_REASON:+ Reason: ${HUMAN_REASON}}" \
"⚠️ <a href='${_issue_url}'>Issue #${ISSUE}</a>${_pr_link} needs human input.${HUMAN_REASON:+ Reason: ${HUMAN_REASON}}<br>Reply in this thread to send guidance to the dev agent." "⚠️ <a href='${_issue_url}'>Issue #${ISSUE}</a>${_pr_link} needs human input.${HUMAN_REASON:+ Reason: ${HUMAN_REASON}}<br>Reply in this thread to send guidance to the dev agent."
log "phase: needs_human — notified via Matrix, waiting for external injection" log "phase: needs_human — notified via Matrix, waiting for external injection"
# Don't inject anything — supervisor-poll.sh (#81) injects human replies, gardener-poll.sh as backup # Don't inject anything — supervisor-run.sh (#81) injects human replies
# ── 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.

View file

@ -1,7 +1,7 @@
# formulas/run-gardener.toml — Gardener housekeeping formula # formulas/run-gardener.toml — Gardener housekeeping formula
# #
# Defines the gardener's complete run: grooming (Claude session via # Defines the gardener's complete run: grooming (Claude session via
# gardener-agent.sh) + blocked-review + AGENTS.md maintenance + final # gardener-run.sh) + blocked-review + AGENTS.md maintenance + final
# commit-and-pr. # commit-and-pr.
# #
# No memory, no journal. The gardener does mechanical housekeeping # No memory, no journal. The gardener does mechanical housekeeping
@ -48,7 +48,7 @@ id = "grooming"
title = "Backlog grooming — triage all open issues" title = "Backlog grooming — triage all open issues"
description = """ description = """
Groom the open issue backlog. This step is the core Claude-driven analysis Groom the open issue backlog. This step is the core Claude-driven analysis
(currently implemented in gardener-agent.sh with bash pre-checks). (Claude performs pre-checks inline before deeper analysis).
Pre-checks (bash, zero tokens detect problems before invoking Claude): Pre-checks (bash, zero tokens detect problems before invoking Claude):

View file

@ -5,19 +5,25 @@
criteria, oversized issues, stale issues, and circular dependencies. Invoke criteria, oversized issues, stale issues, and circular dependencies. Invoke
Claude to fix or escalate to a human via Matrix. Claude to fix or escalate to a human via Matrix.
**Trigger**: `gardener-run.sh` runs 2x/day via cron. It files an `action` **Trigger**: `gardener-run.sh` runs 4x/day via cron. It creates a tmux
issue referencing `formulas/run-gardener.toml`; the action-agent picks it up session with `claude --model sonnet`, injects `formulas/run-gardener.toml`
and executes the gardener steps in an interactive Claude tmux session. Accepts with escalation replies as context, monitors the phase file, and cleans up
an optional project TOML argument (configures which project the action issue is on completion or timeout (2h max session). No action issues — the gardener
filed against). runs directly from cron like the planner, predictor, and supervisor.
**Key files**: **Key files**:
- `gardener/gardener-run.sh` — Cron wrapper: lock, memory guard, dedup check, files action issue - `gardener/gardener-run.sh` — Cron wrapper + orchestrator: lock, memory guard,
- `gardener/gardener-poll.sh` — Escalation-reply injection for dev sessions, invokes gardener-agent.sh for grooming consumes escalation replies, sources disinto project config, creates tmux session,
- `gardener/gardener-agent.sh` — Orchestrator: bash pre-analysis, creates tmux session (`gardener-{project}`) with interactive `claude`, monitors phase file, parses result file (ACTION:/DUST:/ESCALATE) injects formula prompt, monitors phase file, handles crash recovery via
`run_formula_and_monitor`
- `formulas/run-gardener.toml` — Execution spec: preflight, grooming, dust-bundling, blocked-review, agents-update, commit-and-pr - `formulas/run-gardener.toml` — Execution spec: preflight, grooming, dust-bundling, blocked-review, agents-update, commit-and-pr
**Environment variables consumed**: **Environment variables consumed**:
- `CODEBERG_TOKEN`, `CODEBERG_REPO`, `CODEBERG_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT` - `CODEBERG_TOKEN`, `CODEBERG_REPO`, `CODEBERG_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
- `CLAUDE_TIMEOUT` - `PRIMARY_BRANCH`, `CLAUDE_MODEL` (set to sonnet by gardener-run.sh)
- `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` - `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER`
**Lifecycle**: gardener-run.sh (cron 0,6,12,18) → lock + memory guard →
consume escalation replies → load formula + context → create tmux session →
Claude grooms backlog, bundles dust, reviews blocked issues, updates AGENTS.md,
commits and creates PR → `PHASE:done`.

View file

@ -1,359 +0,0 @@
#!/usr/bin/env bash
# gardener-agent.sh — tmux + Claude interactive gardener session manager
#
# Usage: ./gardener-agent.sh [project-toml]
# Called by: gardener-poll.sh
#
# Lifecycle:
# 1. Read escalation reply (from ESCALATION_REPLY env var)
# 2. Fetch open issues + bash pre-checks (zero tokens)
# 3. If no problems detected, exit 0
# 4. Build prompt with result-file output + phase protocol instructions
# 5. Create tmux session: gardener-{project} with interactive claude
# 6. Inject prompt via tmux
# 7. Monitor phase file — Claude writes PHASE:done when finished
# 8. Parse result file (ACTION:/DUST:/ESCALATE) → Matrix + dust.jsonl
# 9. Dust bundling: groups with 3+ items → one backlog issue
#
# Phase file: /tmp/gardener-session-{project}.phase
# Result file: /tmp/gardener-result-{project}.txt
# Session: gardener-{project} (tmux)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
FACTORY_ROOT="$(dirname "$SCRIPT_DIR")"
export PROJECT_TOML="${1:-}"
# shellcheck source=../lib/env.sh
source "$FACTORY_ROOT/lib/env.sh"
# shellcheck source=../lib/agent-session.sh
source "$FACTORY_ROOT/lib/agent-session.sh"
# shellcheck source=../lib/formula-session.sh
source "$FACTORY_ROOT/lib/formula-session.sh"
LOG_FILE="$SCRIPT_DIR/gardener.log"
SESSION_NAME="gardener-${PROJECT_NAME}"
PHASE_FILE="/tmp/gardener-session-${PROJECT_NAME}.phase"
RESULT_FILE="/tmp/gardener-result-${PROJECT_NAME}.txt"
SCRATCH_FILE="/tmp/gardener-${PROJECT_NAME}-scratch.md"
# shellcheck disable=SC2034 # read by monitor_phase_loop in lib/agent-session.sh
PHASE_POLL_INTERVAL=15
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
# Gitea labels API requires []int64 — look up the "backlog" label ID once
# Falls back to the known Codeberg repo ID if the API call fails
BACKLOG_LABEL_ID=$(codeberg_api GET "/labels" 2>/dev/null \
| jq -r '.[] | select(.name == "backlog") | .id' 2>/dev/null || true)
BACKLOG_LABEL_ID="${BACKLOG_LABEL_ID:-1300815}"
log "--- gardener-agent start ---"
# ── Read escalation reply (passed via env by gardener-poll.sh) ────────────
ESCALATION_REPLY="${ESCALATION_REPLY:-}"
# ── Fetch all open issues ─────────────────────────────────────────────────
ISSUES_JSON=$(codeberg_api GET "/issues?state=open&type=issues&limit=50&sort=updated&direction=desc" 2>/dev/null || true)
if [ -z "$ISSUES_JSON" ] || [ "$ISSUES_JSON" = "null" ]; then
log "Failed to fetch issues"
exit 1
fi
ISSUE_COUNT=$(echo "$ISSUES_JSON" | jq 'length')
log "Found $ISSUE_COUNT open issues"
if [ "$ISSUE_COUNT" -eq 0 ]; then
log "No open issues — nothing to groom"
exit 0
fi
# ── Bash pre-checks (zero tokens) ────────────────────────────────────────
PROBLEMS=""
# 1. Duplicate detection: issues with very similar titles
TITLES=$(echo "$ISSUES_JSON" | jq -r '.[] | "\(.number)\t\(.title)"')
DUPES=""
while IFS=$'\t' read -r num1 title1; do
while IFS=$'\t' read -r num2 title2; do
[ "$num1" -ge "$num2" ] && continue
# Normalize: lowercase, strip prefixes + series names, collapse whitespace
t1=$(echo "$title1" | tr '[:upper:]' '[:lower:]' | sed 's/^feat:\|^fix:\|^refactor://;s/llm seed[^—]*—\s*//;s/push3 evolution[^—]*—\s*//;s/[^a-z0-9 ]//g;s/ */ /g')
t2=$(echo "$title2" | tr '[:upper:]' '[:lower:]' | sed 's/^feat:\|^fix:\|^refactor://;s/llm seed[^—]*—\s*//;s/push3 evolution[^—]*—\s*//;s/[^a-z0-9 ]//g;s/ */ /g')
# Count shared words (>60% overlap = suspect)
WORDS1=$(echo "$t1" | tr ' ' '\n' | sort -u)
WORDS2=$(echo "$t2" | tr ' ' '\n' | sort -u)
SHARED=$(comm -12 <(echo "$WORDS1") <(echo "$WORDS2") | wc -l)
TOTAL1=$(echo "$WORDS1" | wc -l)
TOTAL2=$(echo "$WORDS2" | wc -l)
MIN_TOTAL=$(( TOTAL1 < TOTAL2 ? TOTAL1 : TOTAL2 ))
if [ "$MIN_TOTAL" -gt 2 ] && [ "$SHARED" -gt 0 ]; then
OVERLAP=$(( SHARED * 100 / MIN_TOTAL ))
if [ "$OVERLAP" -ge 60 ]; then
DUPES="${DUPES}possible_dupe: #${num1} vs #${num2} (${OVERLAP}% word overlap)\n"
fi
fi
done <<< "$TITLES"
done <<< "$TITLES"
[ -n "$DUPES" ] && PROBLEMS="${PROBLEMS}${DUPES}"
# 2. Missing acceptance criteria: issues with short body and no checkboxes
while IFS=$'\t' read -r num body_len has_checkbox; do
if [ "$body_len" -lt 100 ] && [ "$has_checkbox" = "false" ]; then
PROBLEMS="${PROBLEMS}thin_issue: #${num} — body < 100 chars, no acceptance criteria\n"
fi
done < <(echo "$ISSUES_JSON" | jq -r '.[] | "\(.number)\t\(.body | length)\t\(.body | test("- \\[[ x]\\]") // false)"')
# 3. Stale issues: no update in 14+ days
NOW_EPOCH=$(date +%s)
while IFS=$'\t' read -r num updated_at; do
UPDATED_EPOCH=$(date -d "$updated_at" +%s 2>/dev/null || echo 0)
AGE_DAYS=$(( (NOW_EPOCH - UPDATED_EPOCH) / 86400 ))
if [ "$AGE_DAYS" -ge 14 ]; then
PROBLEMS="${PROBLEMS}stale: #${num} — no activity for ${AGE_DAYS} days\n"
fi
done < <(echo "$ISSUES_JSON" | jq -r '.[] | "\(.number)\t\(.updated_at)"')
# 5. Blocker detection: find issues blocking backlog items that aren't themselves backlog
# This is the HIGHEST PRIORITY — a non-backlog blocker starves the entire factory
BACKLOG_ISSUES=$(echo "$ISSUES_JSON" | jq -r '.[] | select(.labels | map(.name) | index("backlog")) | .number')
BLOCKER_NUMS=""
for BNUM in $BACKLOG_ISSUES; do
BBODY=$(echo "$ISSUES_JSON" | jq -r --arg n "$BNUM" '.[] | select(.number == ($n | tonumber)) | .body // ""')
# Extract deps from ## Dependencies / ## Depends on / ## Blocked by
IN_SECTION=false
while IFS= read -r line; do
if echo "$line" | grep -qiP '^##?\s*(Dependencies|Depends on|Blocked by)'; then IN_SECTION=true; continue; fi
if echo "$line" | grep -qP '^##?\s' && [ "$IN_SECTION" = true ]; then IN_SECTION=false; fi
if [ "$IN_SECTION" = true ]; then
for dep in $(echo "$line" | grep -oP '#\d+' | grep -oP '\d+'); do
[ "$dep" = "$BNUM" ] && continue
# Check if dep is open but NOT backlog-labeled
DEP_STATE=$(echo "$ISSUES_JSON" | jq -r --arg n "$dep" '.[] | select(.number == ($n | tonumber)) | .state' 2>/dev/null || true)
DEP_LABELS=$(echo "$ISSUES_JSON" | jq -r --arg n "$dep" '.[] | select(.number == ($n | tonumber)) | [.labels[].name] | join(",")' 2>/dev/null || true)
if [ "$DEP_STATE" = "open" ] && ! echo ",$DEP_LABELS," | grep -q ',backlog,'; then
BLOCKER_NUMS="${BLOCKER_NUMS} ${dep}"
fi
done
fi
done <<< "$BBODY"
done
# Deduplicate blockers
BLOCKER_NUMS=$(echo "$BLOCKER_NUMS" | tr ' ' '\n' | sort -un | head -10)
if [ -n "$BLOCKER_NUMS" ]; then
BLOCKER_LIST=""
for bnum in $BLOCKER_NUMS; do
BTITLE=$(echo "$ISSUES_JSON" | jq -r --arg n "$bnum" '.[] | select(.number == ($n | tonumber)) | .title' 2>/dev/null || true)
BLABELS=$(echo "$ISSUES_JSON" | jq -r --arg n "$bnum" '.[] | select(.number == ($n | tonumber)) | [.labels[].name] | join(",")' 2>/dev/null || true)
BLOCKER_LIST="${BLOCKER_LIST}#${bnum} [${BLABELS:-unlabeled}] ${BTITLE}\n"
done
PROBLEMS="${PROBLEMS}PRIORITY_blockers_starving_factory: these issues block backlog items but are NOT labeled backlog — promote them FIRST:\n${BLOCKER_LIST}\n"
fi
# 6. Tech-debt issues needing promotion to backlog (secondary to blockers)
TECH_DEBT_ISSUES=$(echo "$ISSUES_JSON" | jq -r '.[] | select(.labels | map(.name) | index("tech-debt")) | "#\(.number) \(.title)"')
if [ -n "$TECH_DEBT_ISSUES" ]; then
TECH_DEBT_COUNT=$(echo "$TECH_DEBT_ISSUES" | wc -l)
PROBLEMS="${PROBLEMS}tech_debt_promotion: ${TECH_DEBT_COUNT} tech-debt issues need processing (goal: zero tech-debt):\n$(echo "$TECH_DEBT_ISSUES" | head -50)\n"
fi
PROBLEM_COUNT=$(echo -e "$PROBLEMS" | grep -c '.' || true)
log "Detected $PROBLEM_COUNT potential problems"
if [ "$PROBLEM_COUNT" -eq 0 ] && [ -z "$ESCALATION_REPLY" ]; then
log "Backlog is clean — nothing to groom"
exit 0
fi
# ── Load formula ─────────────────────────────────────────────────────────
log "Loading groom-backlog formula"
FORMULA_FILE="$FACTORY_ROOT/formulas/groom-backlog.toml"
if [ ! -f "$FORMULA_FILE" ]; then
log "ERROR: formula not found: $FORMULA_FILE"
exit 1
fi
FORMULA_CONTENT=$(cat "$FORMULA_FILE")
# ── Read context files from project root ──────────────────────────────────
CONTEXT_BLOCK=""
for ctx in README.md AGENTS.md VISION.md; do
ctx_path="${PROJECT_REPO_ROOT}/${ctx}"
if [ -f "$ctx_path" ]; then
CONTEXT_BLOCK="${CONTEXT_BLOCK}
### ${ctx}
$(cat "$ctx_path")
"
fi
done
# ── Build issue context ────────────────────────────────────────────────────
ISSUE_SUMMARY=$(echo "$ISSUES_JSON" | jq -r '.[] | "#\(.number) [\(.labels | map(.name) | join(","))] \(.title)"')
# ── Build optional prompt sections ────────────────────────────────────────
CONTEXT_SECTION=""
if [ -n "$CONTEXT_BLOCK" ]; then
CONTEXT_SECTION="## Project context
${CONTEXT_BLOCK}"
fi
ESCALATION_SECTION=""
if [ -n "$ESCALATION_REPLY" ]; then
ESCALATION_SECTION="
### Human response to previous escalation
Format: '1a 2c 3b' means question 1→option (a), 2→option (c), 3→option (b).
Execute each chosen option via the Codeberg API FIRST, before processing new items.
If a choice is unclear, re-escalate that single item with a clarifying question.
${ESCALATION_REPLY}"
fi
# ── Read scratch file (compaction survival) ───────────────────────────────
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
# ── Build prompt from formula + dynamic context ────────────────────────────
log "Building gardener prompt from formula"
PROMPT="You are the issue gardener for ${CODEBERG_REPO}. Work through the formula below. You MUST write PHASE:done to '${PHASE_FILE}' when finished — the orchestrator will time you out if you return to the prompt without signalling.
${CONTEXT_SECTION}
${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT}
}## Formula
${FORMULA_CONTENT}
## Runtime context (bash pre-analysis)
### All open issues
${ISSUE_SUMMARY}
### Problems detected
$(echo -e "$PROBLEMS")${ESCALATION_SECTION}
## Codeberg API reference
Base URL: ${CODEBERG_API}
Auth header: -H \"Authorization: token \$CODEBERG_TOKEN\"
Read issue: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" '${CODEBERG_API}/issues/{number}' | jq '.body'
Relabel: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" -X PUT -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}/labels' -d '{\"labels\":[LABEL_ID]}'
Comment: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" -X POST -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}/comments' -d '{\"body\":\"...\"}'
Close: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" -X PATCH -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}' -d '{\"state\":\"closed\"}'
Edit body: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" -X PATCH -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}' -d '{\"body\":\"new body\"}'
List labels: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" '${CODEBERG_API}/labels'
NEVER echo or include the actual token value in output — always reference \$CODEBERG_TOKEN.
## Output format (MANDATORY — write each line to result file using bash)
echo \"ACTION: description of what you did\" >> '${RESULT_FILE}'
echo 'DUST: {\"issue\": NNN, \"group\": \"...\", \"title\": \"...\", \"reason\": \"...\"}' >> '${RESULT_FILE}'
printf 'ESCALATE\n1. #NNN \"title\" — reason (a) option1 (b) option2\n' >> '${RESULT_FILE}'
echo 'CLEAN' >> '${RESULT_FILE}' # only if truly nothing to do
${SCRATCH_INSTRUCTION}
## Phase protocol (REQUIRED)
When all work is done and verify confirms zero tech-debt:
echo 'PHASE:done' > '${PHASE_FILE}'
On unrecoverable error:
printf 'PHASE:failed\nReason: %s\n' 'describe error' > '${PHASE_FILE}'"
# ── Reset phase + result files ────────────────────────────────────────────
agent_kill_session "$SESSION_NAME"
rm -f "$PHASE_FILE" "${PHASE_FILE%.phase}.context" "$RESULT_FILE"
touch "$RESULT_FILE"
# ── Create tmux session ───────────────────────────────────────────────────
log "Creating tmux session: ${SESSION_NAME}"
if ! create_agent_session "$SESSION_NAME" "$PROJECT_REPO_ROOT" "$PHASE_FILE"; then
log "ERROR: failed to create tmux session ${SESSION_NAME}"
exit 1
fi
# Write phase protocol to context file for compaction survival
write_compact_context "$PHASE_FILE" "## Phase protocol (REQUIRED)
When all work is done and verify confirms zero tech-debt:
echo 'PHASE:done' > '${PHASE_FILE}'
On unrecoverable error:
printf 'PHASE:failed\nReason: %s\n' 'describe error' > '${PHASE_FILE}'"
agent_inject_into_session "$SESSION_NAME" "$PROMPT"
log "Prompt sent to tmux session"
matrix_send "gardener" "🌱 Gardener session started for ${CODEBERG_REPO}" 2>/dev/null || true
# ── Phase monitoring loop ─────────────────────────────────────────────────
log "Monitoring phase file: ${PHASE_FILE}"
_FORMULA_CRASH_COUNT=0
gardener_phase_callback() {
# Gardener-specific cleanup before shared crash recovery
if [ "$1" = "PHASE:crashed" ]; then
rm -f "$RESULT_FILE"
touch "$RESULT_FILE"
fi
formula_phase_callback "$1"
}
monitor_phase_loop "$PHASE_FILE" 7200 "gardener_phase_callback"
FINAL_PHASE=$(read_phase)
log "Final phase: ${FINAL_PHASE:-none}"
if [ "$FINAL_PHASE" != "PHASE:done" ]; then
case "${_MONITOR_LOOP_EXIT:-}" in
idle_prompt)
log "gardener-agent: Claude returned to prompt without writing phase signal — no phase file written"
;;
idle_timeout)
log "gardener-agent: timed out after 2h with no phase signal"
;;
*)
log "gardener-agent finished without PHASE:done (phase: ${FINAL_PHASE:-none}, exit: ${_MONITOR_LOOP_EXIT:-})"
;;
esac
exit 0
fi
log "claude finished — parsing result file"
# ── Parse result file ─────────────────────────────────────────────────────
CLAUDE_OUTPUT=""
if [ -s "$RESULT_FILE" ]; then
CLAUDE_OUTPUT=$(cat "$RESULT_FILE")
fi
# ── Parse escalations ─────────────────────────────────────────────────────
ESCALATION=$(echo "$CLAUDE_OUTPUT" | awk '/^ESCALATE$/{found=1;next} found && /^(ACTION:|DUST:|CLEAN|PHASE:)/{found=0} found{print}' || true)
if [ -z "$ESCALATION" ]; then
ESCALATION=$(echo "$CLAUDE_OUTPUT" | grep -A50 "^ESCALATE" | grep -E '^[0-9]' || true)
fi
if [ -n "$ESCALATION" ]; then
ITEM_COUNT=$(echo "$ESCALATION" | grep -c '.' || true)
log "Escalating $ITEM_COUNT items to human"
# Send via Matrix (threaded — replies route back via listener)
matrix_send "gardener" "🌱 Issue Gardener — ${ITEM_COUNT} item(s) need attention
${ESCALATION}
Reply with numbers+letters (e.g. 1a 2c) to decide." 2>/dev/null || true
fi
# ── Log actions taken ─────────────────────────────────────────────────────
ACTIONS=$(echo "$CLAUDE_OUTPUT" | grep "^ACTION:" || true)
if [ -n "$ACTIONS" ]; then
echo "$ACTIONS" | while read -r line; do
log " $line"
done
fi
# ── Log dust items (bundling handled by run-gardener formula step) ────
DUST_LINES=$(echo "$CLAUDE_OUTPUT" | grep "^DUST: " || true)
if [ -n "$DUST_LINES" ]; then
DUST_COUNT=$(echo "$DUST_LINES" | grep -c '.' || true)
log "Dust items reported: $DUST_COUNT (bundling handled by run-gardener formula)"
fi
# ── Cleanup scratch file on normal exit ──────────────────────────────────
if [ "$FINAL_PHASE" = "PHASE:done" ]; then
rm -f "$SCRATCH_FILE"
fi
log "--- gardener-agent done ---"

View file

@ -1,138 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# gardener-poll.sh — Cron wrapper for the gardener agent
#
# Cron: daily (or 2x/day). Handles lock management, escalation reply
# injection, and delegates backlog grooming to gardener-agent.sh.
#
# Grooming (delegated to gardener-agent.sh):
# - Duplicate titles / overlapping scope
# - Missing acceptance criteria
# - Stale issues (no activity > 14 days)
# - Blockers starving the factory
# - Tech-debt promotion / dust bundling
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
FACTORY_ROOT="$(dirname "$SCRIPT_DIR")"
# Load shared environment (with optional project TOML override)
# Usage: gardener-poll.sh [projects/harb.toml]
export PROJECT_TOML="${1:-}"
# shellcheck source=../lib/env.sh
source "$FACTORY_ROOT/lib/env.sh"
LOG_FILE="$SCRIPT_DIR/gardener.log"
LOCK_FILE="/tmp/gardener-poll.lock"
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
# ── Lock ──────────────────────────────────────────────────────────────────
if [ -f "$LOCK_FILE" ]; then
LOCK_PID=$(cat "$LOCK_FILE" 2>/dev/null || true)
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
log "poll: gardener running (PID $LOCK_PID)"
exit 0
fi
rm -f "$LOCK_FILE"
fi
echo $$ > "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT
log "--- Gardener poll start ---"
# Gitea labels API requires []int64 — look up the "backlog" label ID once
# Falls back to the known Codeberg repo ID if the API call fails
BACKLOG_LABEL_ID=$(codeberg_api GET "/labels" 2>/dev/null \
| jq -r '.[] | select(.name == "backlog") | .id' 2>/dev/null || true)
BACKLOG_LABEL_ID="${BACKLOG_LABEL_ID:-1300815}"
# ── Check for escalation replies from Matrix ──────────────────────────────
ESCALATION_REPLY=""
if [ -s /tmp/gardener-escalation-reply ]; then
_raw_reply=$(cat /tmp/gardener-escalation-reply)
rm -f /tmp/gardener-escalation-reply
log "Got escalation reply: $(echo "$_raw_reply" | head -1)"
# Filter stale escalation entries referencing already-closed issues (#289).
# Escalation records can persist after the underlying issue resolves; acting
# on them wastes cycles (e.g. creating investigation issues for merged PRs).
while IFS= read -r _reply_line; do
[ -z "$_reply_line" ] && continue
_esc_nums=$(echo "$_reply_line" | grep -oP '#\K\d+' | sort -u || true)
if [ -n "$_esc_nums" ]; then
_any_open=false
for _esc_n in $_esc_nums; do
_esc_st=$(codeberg_api GET "/issues/${_esc_n}" 2>/dev/null \
| jq -r '.state // "open"' 2>/dev/null || echo "open")
if [ "$_esc_st" != "closed" ]; then
_any_open=true
break
fi
done
if [ "$_any_open" = false ]; then
log "Discarding stale escalation (all referenced issues closed): $(echo "$_reply_line" | head -c 120)"
continue
fi
fi
ESCALATION_REPLY="${ESCALATION_REPLY}${_reply_line}
"
done <<< "$_raw_reply"
if [ -n "$ESCALATION_REPLY" ]; then
log "Escalation reply after filtering: $(echo "$ESCALATION_REPLY" | grep -c '.' || echo 0) line(s)"
else
log "All escalation entries were stale — discarded"
fi
fi
export ESCALATION_REPLY
# ── Inject human replies into needs_human dev sessions (backup to supervisor) ─
HUMAN_REPLY_FILE="/tmp/dev-escalation-reply"
for _gr_phase_file in /tmp/dev-session-"${PROJECT_NAME}"-*.phase; do
[ -f "$_gr_phase_file" ] || continue
_gr_phase=$(head -1 "$_gr_phase_file" 2>/dev/null | tr -d '[:space:]' || true)
[ "$_gr_phase" = "PHASE:needs_human" ] || continue
_gr_issue=$(basename "$_gr_phase_file" .phase)
_gr_issue="${_gr_issue#dev-session-${PROJECT_NAME}-}"
[ -z "$_gr_issue" ] && continue
_gr_session="dev-${PROJECT_NAME}-${_gr_issue}"
tmux has-session -t "$_gr_session" 2>/dev/null || continue
# Atomic claim — only take the file once we know a session needs it
_gr_claimed="/tmp/dev-escalation-reply.gardener.$$"
[ -s "$HUMAN_REPLY_FILE" ] && mv "$HUMAN_REPLY_FILE" "$_gr_claimed" 2>/dev/null || continue
_gr_reply=$(cat "$_gr_claimed")
_gr_inject_msg="Human reply received for issue #${_gr_issue}:
${_gr_reply}
Instructions:
1. Read the human's guidance carefully.
2. Continue your work based on their input.
3. When done, push your changes and write the appropriate phase."
_gr_tmpfile=$(mktemp /tmp/human-inject-XXXXXX)
printf '%s' "$_gr_inject_msg" > "$_gr_tmpfile"
tmux load-buffer -b "human-inject-${_gr_issue}" "$_gr_tmpfile" || true
tmux paste-buffer -t "$_gr_session" -b "human-inject-${_gr_issue}" || true
sleep 0.5
tmux send-keys -t "$_gr_session" "" Enter || true
tmux delete-buffer -b "human-inject-${_gr_issue}" 2>/dev/null || true
rm -f "$_gr_tmpfile" "$_gr_claimed"
rm -f "/tmp/dev-renotify-${PROJECT_NAME}-${_gr_issue}"
log "${PROJECT_NAME}: #${_gr_issue} human reply injected into session ${_gr_session} (gardener)"
break # only one reply to deliver
done
# ── Backlog grooming (delegated to gardener-agent.sh) ────────────────────
log "Invoking gardener-agent.sh for backlog grooming"
bash "$SCRIPT_DIR/gardener-agent.sh" "${1:-}" || log "WARNING: gardener-agent.sh exited with error"
log "--- Gardener poll done ---"

View file

@ -1,75 +1,103 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================= # =============================================================================
# gardener-run.sh — Cron wrapper: files action issue for run-gardener formula # gardener-run.sh — Cron wrapper: gardener execution via Claude + formula
# #
# Runs 2x/day (or on-demand). Guards against concurrent runs and low memory. # Runs 4x/day (or on-demand). Guards against concurrent runs and low memory.
# Files an action issue referencing formulas/run-gardener.toml; the action-agent # Creates a tmux session with Claude (sonnet) reading formulas/run-gardener.toml.
# picks it up and executes the gardener steps in an interactive Claude session. # No action issues — the gardener is a nervous system component, not work (AD-001).
#
# Usage:
# gardener-run.sh [projects/disinto.toml] # project config (default: disinto)
#
# Cron: 0 0,6,12,18 * * * cd /home/debian/dark-factory && bash gardener/gardener-run.sh projects/disinto.toml
# ============================================================================= # =============================================================================
set -euo pipefail set -euo pipefail
FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
FACTORY_ROOT="$(dirname "$SCRIPT_DIR")"
# Load shared environment (with optional project TOML override) # Accept project config from argument; default to disinto
# Usage: gardener-run.sh [projects/harb.toml] export PROJECT_TOML="${1:-$FACTORY_ROOT/projects/disinto.toml}"
export PROJECT_TOML="${1:-}"
# shellcheck source=../lib/env.sh # shellcheck source=../lib/env.sh
source "$FACTORY_ROOT/lib/env.sh" source "$FACTORY_ROOT/lib/env.sh"
# shellcheck source=../lib/file-action-issue.sh # shellcheck source=../lib/agent-session.sh
source "$FACTORY_ROOT/lib/file-action-issue.sh" source "$FACTORY_ROOT/lib/agent-session.sh"
# shellcheck source=../lib/formula-session.sh
source "$FACTORY_ROOT/lib/formula-session.sh"
LOG_FILE="$FACTORY_ROOT/gardener/gardener.log" LOG_FILE="$SCRIPT_DIR/gardener.log"
LOCK_FILE="/tmp/gardener-run.lock" # shellcheck disable=SC2034 # consumed by run_formula_and_monitor
SESSION_NAME="gardener-${PROJECT_NAME}"
PHASE_FILE="/tmp/gardener-session-${PROJECT_NAME}.phase"
# shellcheck disable=SC2034 # read by monitor_phase_loop in lib/agent-session.sh
PHASE_POLL_INTERVAL=15
SCRATCH_FILE="/tmp/gardener-${PROJECT_NAME}-scratch.md"
RESULT_FILE="/tmp/gardener-result-${PROJECT_NAME}.txt"
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; } log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
# ── Lock ────────────────────────────────────────────────────────────────── # ── Guards ────────────────────────────────────────────────────────────────
if [ -f "$LOCK_FILE" ]; then acquire_cron_lock "/tmp/gardener-run.lock"
LOCK_PID=$(cat "$LOCK_FILE" 2>/dev/null || true) check_memory 2000
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
log "run: gardener-run running (PID $LOCK_PID)"
exit 0
fi
rm -f "$LOCK_FILE"
fi
echo $$ > "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT
# ── Memory guard ──────────────────────────────────────────────────────────
AVAIL_MB=$(free -m | awk '/Mem:/{print $7}')
if [ "${AVAIL_MB:-0}" -lt 2000 ]; then
log "run: skipping — only ${AVAIL_MB}MB available (need 2000)"
exit 0
fi
log "--- Gardener run start ---" log "--- Gardener run start ---"
# ── File action issue for run-gardener formula ──────────────────────────── # ── Consume escalation replies ────────────────────────────────────────────
ISSUE_BODY="--- consume_escalation_reply "gardener"
formula: run-gardener
model: opus
---
Periodic gardener housekeeping run. The action-agent reads \`formulas/run-gardener.toml\` # ── Load formula + context ───────────────────────────────────────────────
and executes the steps: preflight, grooming, blocked-review, load_formula "$FACTORY_ROOT/formulas/run-gardener.toml"
AGENTS.md update, and commit-and-pr. build_context_block AGENTS.md
Filed automatically by \`gardener-run.sh\`." # ── Read scratch file (compaction survival) ───────────────────────────────
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
_rc=0 # ── Build prompt (gardener needs extra API endpoints for issue management) ─
file_action_issue "run-gardener" "action: run-gardener — periodic housekeeping" "$ISSUE_BODY" || _rc=$? GARDENER_API_EXTRA="
case "$_rc" in Relabel: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" -X PUT -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}/labels' -d '{\"labels\":[LABEL_ID]}'
0) ;; Comment: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" -X POST -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}/comments' -d '{\"body\":\"...\"}'
1) log "run: open run-gardener action issue already exists — skipping" Close: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" -X PATCH -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}' -d '{\"state\":\"closed\"}'
log "--- Gardener run done ---" Edit body: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" -X PATCH -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}' -d '{\"body\":\"new body\"}'
exit 0 ;; "
2) log "ERROR: 'action' label not found — cannot file gardener issue" build_prompt_footer "$GARDENER_API_EXTRA"
exit 1 ;;
*) log "ERROR: failed to create action issue for run-gardener"
exit 1 ;;
esac
log "Filed action issue #${FILED_ISSUE_NUM} for run-gardener formula" # shellcheck disable=SC2034 # consumed by run_formula_and_monitor
matrix_send "gardener" "Filed action #${FILED_ISSUE_NUM}: run-gardener — periodic housekeeping" 2>/dev/null || true PROMPT="You are the issue gardener for ${CODEBERG_REPO}. Work through the formula below. You MUST write PHASE:done to '${PHASE_FILE}' when finished — the orchestrator will time you out if you return to the prompt without signalling.
log "--- Gardener run done ---" You have full shell access and --dangerously-skip-permissions.
Fix what you can. Escalate what you cannot. Do NOT ask permission — act first, report after.
${ESCALATION_REPLY:+
## Escalation Reply (from Matrix — human message)
${ESCALATION_REPLY}
Act on this reply during the grooming step.
}
## Project context
${CONTEXT_BLOCK}
${SCRATCH_CONTEXT:+${SCRATCH_CONTEXT}
}
## Result file
Write actions and dust items to: ${RESULT_FILE}
## Formula
${FORMULA_CONTENT}
${SCRATCH_INSTRUCTION}
${PROMPT_FOOTER}"
# ── Reset result file ────────────────────────────────────────────────────
rm -f "$RESULT_FILE"
touch "$RESULT_FILE"
# ── Run session ──────────────────────────────────────────────────────────
export CLAUDE_MODEL="sonnet"
run_formula_and_monitor "gardener" 7200
# ── Cleanup scratch file on normal exit ──────────────────────────────────
# FINAL_PHASE already set by run_formula_and_monitor
if [ "${FINAL_PHASE:-}" = "PHASE:done" ]; then
rm -f "$SCRATCH_FILE"
fi

View file

@ -12,7 +12,7 @@ sourced as needed.
| `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `CODEBERG_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, Matrix config, etc.). | env.sh (when `PROJECT_TOML` is set), supervisor-poll (per-project iteration) | | `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `CODEBERG_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, Matrix config, etc.). | env.sh (when `PROJECT_TOML` is set), supervisor-poll (per-project iteration) |
| `lib/parse-deps.sh` | Extracts dependency issue numbers from an issue body (stdin → stdout, one number per line). Matches `## Dependencies` / `## Depends on` / `## Blocked by` sections and inline `depends on #N` patterns. Not sourced — executed via `bash lib/parse-deps.sh`. | dev-poll, supervisor-poll | | `lib/parse-deps.sh` | Extracts dependency issue numbers from an issue body (stdin → stdout, one number per line). Matches `## Dependencies` / `## Depends on` / `## Blocked by` sections and inline `depends on #N` patterns. Not sourced — executed via `bash lib/parse-deps.sh`. | dev-poll, supervisor-poll |
| `lib/matrix_listener.sh` | Long-poll Matrix sync daemon. Dispatches thread replies to the correct agent via well-known files (`/tmp/{agent}-escalation-reply`). Handles supervisor, gardener, dev, review, vault, and action reply routing. Run as systemd service. | Standalone daemon | | `lib/matrix_listener.sh` | Long-poll Matrix sync daemon. Dispatches thread replies to the correct agent via well-known files (`/tmp/{agent}-escalation-reply`). Handles supervisor, gardener, dev, review, vault, and action reply routing. Run as systemd service. | Standalone daemon |
| `lib/formula-session.sh` | `acquire_cron_lock()`, `check_memory()`, `load_formula()`, `build_context_block()`, `start_formula_session()`, `formula_phase_callback()`, `build_prompt_footer()`, `run_formula_and_monitor()` — shared helpers for formula-driven cron agents (lock, memory guard, formula loading, prompt assembly, tmux session, monitor loop, crash recovery). | planner-run.sh, predictor-run.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()`, `run_formula_and_monitor()` — shared helpers for formula-driven cron agents (lock, memory guard, formula loading, prompt assembly, tmux session, monitor loop, crash recovery). | planner-run.sh, predictor-run.sh, gardener-run.sh, supervisor-run.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/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. | gardener-run.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/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. `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 a `PHASE:*` string. **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, gardener-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 `MATRIX_THREAD_ID` is exported, also installs a Stop hook (`on-stop-matrix.sh`) that streams each Claude response to the Matrix thread. `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 a `PHASE:*` string. **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

@ -2,7 +2,7 @@
# formula-session.sh — Shared helpers for formula-driven cron agents # formula-session.sh — Shared helpers for formula-driven cron agents
# #
# Provides reusable functions for the common cron-wrapper + tmux-session # Provides reusable functions for the common cron-wrapper + tmux-session
# pattern used by planner-run.sh and gardener-agent.sh. # pattern used by planner-run.sh, predictor-run.sh, gardener-run.sh, and supervisor-run.sh.
# #
# Functions: # Functions:
# acquire_cron_lock LOCK_FILE — PID lock with stale cleanup # acquire_cron_lock LOCK_FILE — PID lock with stale cleanup
@ -81,6 +81,25 @@ $(cat "$ctx_path")
done done
} }
# ── Escalation reply consumption ─────────────────────────────────────────
# consume_escalation_reply AGENT_NAME
# Atomically consumes /tmp/{agent}-escalation-reply if it exists.
# Sets ESCALATION_REPLY to the file contents (empty string if no reply).
consume_escalation_reply() {
local agent="$1"
local reply_file="/tmp/${agent}-escalation-reply"
ESCALATION_REPLY=""
if [ -s "$reply_file" ]; then
local tmp_file="${reply_file}.consumed.$$"
if mv "$reply_file" "$tmp_file" 2>/dev/null; then
ESCALATION_REPLY=$(cat "$tmp_file")
rm -f "$tmp_file"
log "Consumed escalation reply: $(echo "$ESCALATION_REPLY" | head -1)"
fi
fi
}
# ── Session management ─────────────────────────────────────────────────── # ── Session management ───────────────────────────────────────────────────
# start_formula_session SESSION WORKDIR PHASE_FILE # start_formula_session SESSION WORKDIR PHASE_FILE

View file

@ -57,16 +57,7 @@ else
fi fi
# ── Consume escalation replies ──────────────────────────────────────────── # ── Consume escalation replies ────────────────────────────────────────────
# Move the file atomically so matrix_listener can write a new one consume_escalation_reply "supervisor"
ESCALATION_REPLY=""
if [ -s /tmp/supervisor-escalation-reply ]; then
_reply_tmp="/tmp/supervisor-escalation-reply.consumed.$$"
if mv /tmp/supervisor-escalation-reply "$_reply_tmp" 2>/dev/null; then
ESCALATION_REPLY=$(cat "$_reply_tmp")
rm -f "$_reply_tmp"
log "Consumed escalation reply: $(echo "$ESCALATION_REPLY" | head -1)"
fi
fi
# ── Load formula + context ─────────────────────────────────────────────── # ── Load formula + context ───────────────────────────────────────────────
load_formula "$FACTORY_ROOT/formulas/run-supervisor.toml" load_formula "$FACTORY_ROOT/formulas/run-supervisor.toml"