Files
sentiment-engine/pi_wake_agent.sh
Codex 5e9168ac1d pi_wake_agent: --pane targeting + crontab backend fallback + cronicle HCL fix + hermetic tests
- --pane/--pane-id: write-chars AND write(13) hit terminal_1 (bottom pane)
- cronicle_available()=shutil.which + daemon-live check; crontab/crnd auto-fallback
  (cronicle daemon down here -> live scheduler is system crnd)
- fix cronicle_install invalid HCL (command=[...] array, was bare comma-list)
- rewrite TestCrontabEntry hermetic (subprocess mocked; real crontab never touched)
- add --pane-id / no-pane omission / HCL-array mutation-litmus tests; .sh --pane
- cronicle.hcl: corrected pi_wake_pi_test_20m (msg + --pane terminal_1), valid HCL
- live 20m crontab doorbell installed for pi_test/terminal_1, bell verified delivered
- SIGQUIT rescued a locked pi (final_test @1318s); steer queued + bus-posted
2026-09-14 21:31:37 +02:00

369 lines
13 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# pi_wake_agent.sh — Reusable multi-agent wake-up timer with self-cron/daemon
set -euo pipefail
# ─── Constants ────────────────────────────────────────────────────────────
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
LOG_FILE="/tmp/pi_wake_agent.log"
LOG_MAX_SIZE=$((10 * 1024 * 1024))
LOG_MAX_FILES=5
CRON_COMMENT_PREFIX="pi_wake_agent"
AGENT_NICK="pi_nvnemo"
H5I_AGENT="pi_nvnemo"
H5I_BUS_ROOT="/mnt/dolphinng5_predict"
DEFAULT_INTERVAL="1h"
PANE=""
CRON_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
PYTHON_BIN="/usr/bin/python3"
# ─── Logging ──────────────────────────────────────────────────────────────
log_rotate() {
[[ -f "$LOG_FILE" ]] || return 0
local size
size=$(stat -c%s "$LOG_FILE" 2>/dev/null || echo 0)
[[ $size -lt $LOG_MAX_SIZE ]] && return 0
for i in $(seq $((LOG_MAX_FILES - 1)) -1 1); do
[[ -f "${LOG_FILE}.${i}" ]] && mv "${LOG_FILE}.${i}" "${LOG_FILE}.$((i + 1))"
done
mv "$LOG_FILE" "${LOG_FILE}.1"
}
log_write() {
local level="$1"; shift
local msg="$*"
local ts
ts=$(date '+%Y-%m-%d %H:%M:%S')
log_rotate
echo "[$ts] [$level] $msg" >> "$LOG_FILE"
if [[ "$level" == "ERROR" || "$level" == "WARN" ]]; then
echo "[$ts] [$level] $msg" >&2
fi
return 0
}
log_debug() { [[ ${DEBUG:-0} == "1" ]] && log_write "DEBUG" "$@"; }
log_info() { log_write "INFO" "$@"; }
log_warn() { log_write "WARN" "$@"; }
log_error() { log_write "ERROR" "$@"; }
# ─── Interval Parsing ────────────────────────────────────────────────────
parse_interval() {
local interval="$1"
local seconds=0
if [[ "$interval" =~ ^([0-9]+)h$ ]]; then
seconds=$((BASH_REMATCH[1] * 3600))
elif [[ "$interval" =~ ^([0-9]+)m$ ]]; then
seconds=$((BASH_REMATCH[1] * 60))
elif [[ "$interval" =~ ^([0-9]+)s$ ]]; then
seconds=$((BASH_REMATCH[1]))
else
log_error "Invalid interval format '$interval'. Use like 1h, 30m, 90m, 2h"
return 1
fi
echo "$seconds"
return 0
}
interval_to_cron() {
local interval="$1"
if [[ "$interval" =~ ^([0-9]+)h$ ]]; then
local hours="${BASH_REMATCH[1]}"
echo "0 */${hours} * * *"
elif [[ "$interval" =~ ^([0-9]+)m$ ]]; then
local mins="${BASH_REMATCH[1]}"
if (( mins >= 60 )); then
log_error "For minutes >= 60, use hours (e.g., 1h not 60m)"
return 1
fi
echo "*/${mins} * * * *"
else
log_error "Cron only supports minutes/hours intervals"
return 1
fi
}
interval_to_human() {
local interval="$1"
if [[ "$interval" =~ ^([0-9]+)h$ ]]; then
echo "${BASH_REMATCH[1]} hour(s)"
elif [[ "$interval" =~ ^([0-9]+)m$ ]]; then
echo "${BASH_REMATCH[1]} minute(s)"
elif [[ "$interval" =~ ^([0-9]+)s$ ]]; then
echo "${BASH_REMATCH[1]} second(s)"
else
echo "$interval"
fi
}
# ─── Session Parsing ─────────────────────────────────────────────────────
parse_sessions() {
local raw="${1:-}"
[[ -z "$raw" ]] && return 0
IFS=',' read -ra SESSIONS <<< "$raw"
for i in "${!SESSIONS[@]}"; do
SESSIONS[i]=$(echo "${SESSIONS[i]}" | xargs)
done
}
# ─── Cron Management ────────────────────────────────────────────────────
cron_comment() {
local sessions_str
sessions_str=$(IFS=,; echo "${SESSIONS[*]}")
echo "${CRON_COMMENT_PREFIX}:${sessions_str}:${INTERVAL}"
}
install_cron() {
local cron_sched
cron_sched=$(interval_to_cron "$INTERVAL") || return 1
local comment
comment=$(cron_comment)
local pane_arg=""
[[ -n "$PANE" ]] && pane_arg="--pane ${PANE}"
local cmd="cd ${H5I_BUS_ROOT} && export PATH=${CRON_PATH} && export H5I_AGENT=${H5I_AGENT} && ${PYTHON_BIN} ${SCRIPT_PATH} --run --sessions \"${SESSIONS[*]}\" --msg \"${MESSAGE}\" ${pane_arg}"
(crontab -l 2>/dev/null | grep -v "${comment}") | crontab -
(crontab -l 2>/dev/null; echo "${cron_sched} ${cmd} # ${comment}") | crontab -
log_info "Installed cron: ${cron_sched} -> ${SESSIONS[*]} every ${INTERVAL}"
}
remove_cron() {
local comment
comment=$(cron_comment)
(crontab -l 2>/dev/null | grep -v "${comment}") | crontab -
log_info "Removed cron for ${SESSIONS[*]} (${INTERVAL})"
}
list_cron() {
echo "=== pi_wake_agent cron entries ==="
crontab -l 2>/dev/null | grep "${CRON_COMMENT_PREFIX}" || echo "None found"
}
# ─── Zellij Operations ──────────────────────────────────────────────────
zellij_session_exists() {
local session="$1"
zellij list-sessions 2>/dev/null | grep -q "^${session} "
}
zellij_write_chars() {
local session="$1"
local text="$2"
zellij --session "$session" action write-chars ${PANE:+--pane-id "$PANE"} "$text" 2>/dev/null || true
}
zellij_write_enter() {
local session="$1"
zellij --session "$session" action write ${PANE:+--pane-id "$PANE"} 13 2>/dev/null || true
}
# ─── Wake Action ────────────────────────────────────────────────────────
run_wake() {
local sessions_str
sessions_str=$(IFS=,; echo "${SESSIONS[*]}")
log_info "Waking ${sessions_str} with message: ${MESSAGE}"
for session in "${SESSIONS[@]}"; do
zellij_write_chars "$session" "[${AGENT_NICK} via zellij] ${MESSAGE} Run: h5i-bus msg inbox"
for i in 1 2 3 4 5; do
zellij_write_enter "$session"
sleep 1
done
done
cd "${H5I_BUS_ROOT}" && export H5I_AGENT="${H5I_AGENT}" && h5i msg send Fable "${MESSAGE} (timer wakeup)" 2>/dev/null || true
log_info "Wake sent to ${sessions_str}"
}
# ─── Modes ──────────────────────────────────────────────────────────────
run_once() {
local interval_seconds
interval_seconds=$(parse_interval "$INTERVAL") || return 1
log_info "One-shot timer set for ${INTERVAL} ($(interval_to_human "$INTERVAL"))"
(
sleep "$interval_seconds"
run_wake
) &
local pid=$!
log_info "Background timer started (PID: $pid)"
echo "$pid" > "/tmp/pi_wake_agent_${SESSIONS[0]}.pid"
return 0
}
run_daemon() {
local interval_seconds
interval_seconds=$(parse_interval "$INTERVAL") || return 1
log_info "=== DAEMON START ==="
log_info "Interval: ${INTERVAL} ($(interval_to_human "$INTERVAL"))"
log_info "Sessions: ${SESSIONS[*]}"
log_info "Message: ${MESSAGE}"
run_wake
while true; do
log_debug "Sleeping for ${interval_seconds}s..."
sleep "$interval_seconds"
run_wake
done
}
status() {
echo "=== pi_wake_agent Status ==="
echo "Script: ${SCRIPT_PATH}"
echo "Log: ${LOG_FILE}"
echo "Log size: $(du -h "$LOG_FILE" 2>/dev/null | cut -f1 || echo 'N/A')"
echo "Agent: ${AGENT_NICK}"
echo ""
list_cron
echo ""
echo "=== Active one-shot timers ==="
local found=0
for pidfile in /tmp/pi_wake_agent_*.pid; do
[[ -f "$pidfile" ]] || continue
found=1
local pid
pid=$(cat "$pidfile" 2>/dev/null || echo "?")
if kill -0 "$pid" 2>/dev/null; then
echo " PID $pid (active)"
else
echo " PID $pid (dead, cleaning up)"
rm -f "$pidfile"
fi
done
[[ $found -eq 0 ]] && echo " (none)"
}
validate_sessions() {
log_info "Validating sessions: ${SESSIONS[*]}"
for session in "${SESSIONS[@]}"; do
if zellij_session_exists "$session"; then
log_info " $session: EXISTS"
else
log_warn " $session: NOT FOUND (may be dead/EXITED)"
fi
done
}
# ─── Usage ──────────────────────────────────────────────────────────────
usage() {
cat <<USAGE
pi_wake_agent.sh — Reusable multi-agent wake-up timer with self-cron/daemon
USAGE:
pi_wake_agent.sh --install [OPTIONS] Install recurring cron timer
pi_wake_agent.sh --once [OPTIONS] One-shot wake (no cron)
pi_wake_agent.sh --daemon [OPTIONS] Run as long-lived daemon (no cron)
pi_wake_agent.sh --run [OPTIONS] Internal: run wake action (called by cron)
pi_wake_agent.sh --remove [OPTIONS] Remove cron timer
pi_wake_agent.sh --list List active cron timers
pi_wake_agent.sh --status Show status (cron + one-shot timers)
pi_wake_agent.sh --validate [OPTIONS] Validate sessions exist in zellij
OPTIONS:
--interval DURATION Interval (default: 1h). Formats: 30m, 1h, 90m, 2h, etc.
--session SESSION Zellij session name (can repeat or comma-separate)
--sessions "A,B,C" Comma-separated list of sessions
--msg "MESSAGE" Wake message (default: "Operator says CONTINUE. Pi here!")
--pane PANE Zjet pane ID to target (focused pane if unset), e.g. terminal_1
--debug Enable debug logging
--help Show this help
EXAMPLES:
# Install recurring 1-hour timer for one session
pi_wake_agent.sh --install --interval 1h --session cc_UV_dev0_Fb
# Install 30-minute timer for multiple sessions
pi_wake_agent.sh --install --interval 30m --sessions "cc_UV_dev0_Fb,cc_UV_dev1_48" --msg "Wake up!"
# One-shot wake in 2 hours (no cron)
pi_wake_agent.sh --once --interval 2h --session cc_UV_dev0_Fb --msg "Time's up!"
# Run as daemon (long-lived process, no cron)
pi_wake_agent.sh --daemon --interval 1h --session cc_UV_dev0_Fb
# Remove timer
pi_wake_agent.sh --remove --session cc_UV_dev0_Fb --interval 1h
# List all timers
pi_wake_agent.sh --list
# Show status
pi_wake_agent.sh --status
# Validate sessions
pi_wake_agent.sh --validate --session cc_UV_dev0_Fb
NOTES:
- Identifies as ${AGENT_NICK} on h5i bus
- Sends 5× ENTER keypresses (1s delay) for reliable submission
- Self-cron: installs/removes own cron entries
- Structured logging to ${LOG_FILE} (rotated at 10MB, 5 files)
- Daemon mode runs indefinitely until killed (SIGTERM/SIGINT handled)
- Cron comments format: ${CRON_COMMENT_PREFIX}:<sessions>:<interval>
USAGE
}
# ─── Main ───────────────────────────────────────────────────────────────
MODE="install"
INTERVAL="${DEFAULT_INTERVAL}"
SESSIONS=()
MESSAGE="Operator says CONTINUE. ${AGENT_NICK} here, saying hi!"
SESSIONS_RAW=""
DEBUG="${DEBUG:-0}"
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--install) MODE="install"; shift ;;
--once) MODE="once"; shift ;;
--daemon) MODE="daemon"; shift ;;
--run) MODE="run"; shift ;;
--remove) MODE="remove"; shift ;;
--list) MODE="list"; shift ;;
--status) MODE="status"; shift ;;
--validate) MODE="validate"; shift ;;
--interval) INTERVAL="$2"; shift 2 ;;
--session) SESSIONS_RAW="${SESSIONS_RAW:+${SESSIONS_RAW},}$2"; shift 2 ;;
--sessions) SESSIONS_RAW="$2"; shift 2 ;;
--msg) MESSAGE="$2"; shift 2 ;;
--pane) PANE="$2"; shift 2 ;;
--debug) DEBUG=1; shift ;;
--help) usage; exit 0 ;;
*) log_error "Unknown option: $1"; usage; exit 1 ;;
esac
done
# Parse sessions
parse_sessions "${SESSIONS_RAW}"
# Validate
if [[ ${#SESSIONS[@]} -eq 0 && "$MODE" != "list" && "$MODE" != "status" ]]; then
log_error "--session or --sessions required"
usage
exit 1
fi
# Log startup
log_info "=== pi_wake_agent ${MODE} ==="
log_info "Interval: ${INTERVAL} ($(interval_to_human "$INTERVAL"))"
log_info "Sessions: ${SESSIONS[*]}"
[[ -n "$PANE" ]] && log_info "Pane target: ${PANE}"
[[ -n "$MESSAGE" ]] && log_info "Message: ${MESSAGE}"
# Execute mode
case "$MODE" in
install) install_cron ;;
once) run_once ;;
daemon) run_daemon ;;
run) run_wake ;;
remove) remove_cron ;;
list) list_cron ;;
status) status ;;
validate) validate_sessions ;;
*) log_error "Unknown mode: $MODE"; usage; exit 1 ;;
esac