2026-03-13 09:17:09 +00:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# =============================================================================
|
2026-03-18 16:21:07 +01:00
|
|
|
# gardener-poll.sh — Cron wrapper for the gardener agent
|
2026-03-13 09:17:09 +00:00
|
|
|
#
|
2026-03-18 16:21:07 +01:00
|
|
|
# Cron: daily (or 2x/day). Handles lock management, escalation reply
|
2026-03-21 10:19:27 +00:00
|
|
|
# injection for dev sessions, and files an action issue for backlog
|
|
|
|
|
# grooming via formulas/run-gardener.toml (picked up by action-agent).
|
2026-03-13 09:17:09 +00:00
|
|
|
# =============================================================================
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
|
|
|
FACTORY_ROOT="$(dirname "$SCRIPT_DIR")"
|
|
|
|
|
|
refactor: split supervisor into infra + per-project, make poll scripts config-driven
Supervisor split (#26):
- Layer 1 (infra): P0 memory, P1 disk, P4 housekeeping — runs once, project-agnostic
- Layer 2 (per-project): P2 CI/dev-agent, P3 PRs/deps — iterates projects/*.toml
- Adding a new project requires only a new TOML file, no code changes
Poll scripts accept project TOML arg (#27):
- dev-poll.sh, review-poll.sh, gardener-poll.sh accept optional project TOML as $1
- env.sh loads PROJECT_TOML if set, overriding .env defaults
- Cron: `dev-poll.sh projects/versi.toml` targets that project
New files:
- lib/load-project.sh: TOML to env var loader (Python tomllib)
- projects/versi.toml: current project config extracted from .env
Backwards compatible: scripts without a TOML arg fall back to .env config.
Closes #26, Closes #27
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:57:18 +01:00
|
|
|
# Load shared environment (with optional project TOML override)
|
2026-03-21 04:18:43 +00:00
|
|
|
# Usage: gardener-poll.sh [projects/harb.toml]
|
refactor: split supervisor into infra + per-project, make poll scripts config-driven
Supervisor split (#26):
- Layer 1 (infra): P0 memory, P1 disk, P4 housekeeping — runs once, project-agnostic
- Layer 2 (per-project): P2 CI/dev-agent, P3 PRs/deps — iterates projects/*.toml
- Adding a new project requires only a new TOML file, no code changes
Poll scripts accept project TOML arg (#27):
- dev-poll.sh, review-poll.sh, gardener-poll.sh accept optional project TOML as $1
- env.sh loads PROJECT_TOML if set, overriding .env defaults
- Cron: `dev-poll.sh projects/versi.toml` targets that project
New files:
- lib/load-project.sh: TOML to env var loader (Python tomllib)
- projects/versi.toml: current project config extracted from .env
Backwards compatible: scripts without a TOML arg fall back to .env config.
Closes #26, Closes #27
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:57:18 +01:00
|
|
|
export PROJECT_TOML="${1:-}"
|
2026-03-13 09:17:09 +00:00
|
|
|
# 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 ---"
|
|
|
|
|
|
2026-03-14 16:25:33 +01:00
|
|
|
# ── Check for escalation replies from Matrix ──────────────────────────────
|
|
|
|
|
ESCALATION_REPLY=""
|
|
|
|
|
if [ -s /tmp/gardener-escalation-reply ]; then
|
2026-03-21 09:27:31 +00:00
|
|
|
_raw_reply=$(cat /tmp/gardener-escalation-reply)
|
2026-03-14 16:25:33 +01:00
|
|
|
rm -f /tmp/gardener-escalation-reply
|
2026-03-21 09:27:31 +00:00
|
|
|
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
|
2026-03-14 16:25:33 +01:00
|
|
|
fi
|
2026-03-21 10:19:27 +00:00
|
|
|
# ESCALATION_REPLY is used below when constructing the action issue body
|
2026-03-14 16:25:33 +01:00
|
|
|
|
2026-03-17 22:33:28 +00:00
|
|
|
# ── Inject human replies into needs_human dev sessions (backup to supervisor) ─
|
|
|
|
|
HUMAN_REPLY_FILE="/tmp/dev-escalation-reply"
|
2026-03-17 22:59:05 +00:00
|
|
|
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}"
|
2026-03-17 22:33:28 +00:00
|
|
|
|
2026-03-17 22:59:05 +00:00
|
|
|
tmux has-session -t "$_gr_session" 2>/dev/null || continue
|
2026-03-17 22:33:28 +00:00
|
|
|
|
2026-03-17 22:59:05 +00:00
|
|
|
# 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")
|
2026-03-17 22:33:28 +00:00
|
|
|
|
2026-03-17 22:59:05 +00:00
|
|
|
_gr_inject_msg="Human reply received for issue #${_gr_issue}:
|
2026-03-17 22:33:28 +00:00
|
|
|
|
|
|
|
|
${_gr_reply}
|
|
|
|
|
|
|
|
|
|
Instructions:
|
|
|
|
|
1. Read the human's guidance carefully.
|
|
|
|
|
2. Continue your work based on their input.
|
2026-03-17 22:40:54 +00:00
|
|
|
3. When done, push your changes and write the appropriate phase."
|
2026-03-17 22:33:28 +00:00
|
|
|
|
2026-03-17 22:59:05 +00:00
|
|
|
_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
|
2026-03-17 22:33:28 +00:00
|
|
|
|
2026-03-21 10:19:27 +00:00
|
|
|
# ── Backlog grooming (file action issue for run-gardener formula) ─────────
|
|
|
|
|
# shellcheck source=../lib/file-action-issue.sh
|
|
|
|
|
source "$FACTORY_ROOT/lib/file-action-issue.sh"
|
|
|
|
|
|
|
|
|
|
ESCALATION_CONTEXT=""
|
|
|
|
|
if [ -n "${ESCALATION_REPLY:-}" ]; then
|
|
|
|
|
ESCALATION_CONTEXT="
|
|
|
|
|
|
|
|
|
|
## Pending escalation replies
|
|
|
|
|
|
|
|
|
|
Human responses to previous gardener escalations. Process these FIRST.
|
|
|
|
|
Format: '1a 2c 3b' means question 1→option (a), 2→option (c), 3→option (b).
|
|
|
|
|
|
|
|
|
|
${ESCALATION_REPLY}"
|
|
|
|
|
fi
|
2026-03-18 02:53:03 +00:00
|
|
|
|
2026-03-21 10:19:27 +00:00
|
|
|
ISSUE_BODY="---
|
|
|
|
|
formula: run-gardener
|
|
|
|
|
model: opus
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
Periodic gardener housekeeping run. The action-agent reads \`formulas/run-gardener.toml\`
|
|
|
|
|
and executes the steps: preflight, grooming, blocked-review,
|
|
|
|
|
AGENTS.md update, and commit-and-pr.${ESCALATION_CONTEXT}
|
|
|
|
|
|
|
|
|
|
Filed automatically by \`gardener-poll.sh\`."
|
|
|
|
|
|
|
|
|
|
_rc=0
|
|
|
|
|
file_action_issue "run-gardener" "action: run-gardener — periodic housekeeping" "$ISSUE_BODY" || _rc=$?
|
|
|
|
|
case "$_rc" in
|
|
|
|
|
0) log "Filed action issue #${FILED_ISSUE_NUM} for run-gardener formula"
|
|
|
|
|
matrix_send "gardener" "Filed action #${FILED_ISSUE_NUM}: run-gardener — periodic housekeeping" 2>/dev/null || true
|
|
|
|
|
;;
|
|
|
|
|
1) log "Open run-gardener action issue already exists — skipping" ;;
|
|
|
|
|
2) log "ERROR: 'action' label not found — cannot file gardener issue" ;;
|
|
|
|
|
4) log "ERROR: issue body contains potential secrets — skipping" ;;
|
|
|
|
|
*) log "WARNING: failed to create action issue for run-gardener (rc=$_rc)" ;;
|
|
|
|
|
esac
|
2026-03-17 17:32:56 +00:00
|
|
|
|
2026-03-13 09:17:09 +00:00
|
|
|
log "--- Gardener poll done ---"
|