supervisor: replace Go package with anvillm-supervisor shell script

Remove the internal/supervisor Go package and cron-driven orphan check
script. Replace with a single anvillm-supervisor script invoked by cron
that handles both orphan bead recovery (--orphans, every 5m) and idle
worker nudging (--nudge, every 1m). Nudges use send_message.sh as user,
consistent with existing mail processing. anvilsrv no longer runs any
supervisor goroutine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-03-15 14:47:56 +01:00
parent 989558cf7f
commit 47a97e5cea
6 changed files with 88 additions and 147 deletions

View File

@ -9,7 +9,6 @@ import (
"anvillm/internal/maildir"
"anvillm/internal/p9"
"anvillm/internal/session"
"anvillm/internal/supervisor"
"context"
"fmt"
"os"
@ -229,11 +228,6 @@ func start(daemonize bool) {
mdWriter := maildir.New(mailDir, srv.Events())
defer mdWriter.Close()
// Start supervisor
ctx := context.Background()
sup := supervisor.New(mgr, p9.NewRolesFS())
go sup.Run(ctx)
logging.Logger().Info("anvilsrv started successfully", zap.String("socket", srv.SocketPath()))
// Start background monitor for self-healing

View File

@ -22,7 +22,6 @@ type RolesFS struct {
type RoleMeta struct {
Name string
Description string
Worker bool // true if role should run the work polling loop
Path string // file path
}
@ -88,9 +87,6 @@ func parseRoleFrontMatter(rolePath string) (*RoleMeta, error) {
meta.Description = strings.TrimSpace(desc)
hasDescription = true
}
if worker, ok := strings.CutPrefix(line, "worker:"); ok {
meta.Worker = strings.TrimSpace(worker) == "true"
}
}
// If missing description, exclude from list
@ -185,20 +181,6 @@ func (r *RolesFS) Read(path string) ([]byte, error) {
return nil, fmt.Errorf("not found")
}
// IsWorker reports whether the named role has worker: true in its front-matter.
func (r *RolesFS) IsWorker(roleName string) bool {
roles, err := r.listAllRoles()
if err != nil {
return false
}
for _, role := range roles {
if role.Name == roleName {
return role.Worker
}
}
return false
}
// ReadRole reads a role by name, searching all roles directories.
func (r *RolesFS) ReadRole(roleName string) (string, error) {
roles, err := r.listAllRoles()

View File

@ -1,72 +0,0 @@
package supervisor
import (
"context"
"time"
"anvillm/internal/backend/tmux"
"anvillm/internal/logging"
"anvillm/internal/p9"
"anvillm/internal/session"
"go.uber.org/zap"
)
const idleThreshold = 15 * time.Second
type Supervisor struct {
sessions *session.Manager
roles *p9.RolesFS
}
func New(s *session.Manager, r *p9.RolesFS) *Supervisor {
return &Supervisor{sessions: s, roles: r}
}
// nudgeIdleWorkers sends a prompt to any worker-role session that has been
// idle for longer than idleThreshold, reminding it to run its polling loop.
func (s *Supervisor) nudgeIdleWorkers() {
for _, botID := range s.sessions.List() {
sess := s.sessions.Get(botID)
if sess == nil || sess.State() != "idle" {
continue
}
tmuxSess, ok := sess.(*tmux.Session)
if !ok {
continue
}
if tmuxSess.IdleDuration() < idleThreshold {
continue
}
role := tmuxSess.GetRole()
if role == "" || !s.roles.IsWorker(role) {
continue
}
ctx := context.Background()
if _, err := sess.Send(ctx, "You are idle. Run your work polling loop to check for available tasks."); err != nil {
logging.Logger().Warn("supervisor: failed to nudge idle worker",
zap.String("session", botID), zap.Error(err))
}
}
}
func (s *Supervisor) Run(ctx context.Context) {
defer func() {
if r := recover(); r != nil {
logging.Logger().Error("supervisor panic", zap.Any("panic", r))
}
}()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.nudgeIdleWorkers()
}
}
}

14
mkfile
View File

@ -21,8 +21,8 @@ build:V:
chmod 0755 $INSTALL_PATH/9p-read-inbox
cp scripts/anvilspawn $INSTALL_PATH/anvilspawn
chmod 0755 $INSTALL_PATH/anvilspawn
cp scripts/anvillm-orphan-check $INSTALL_PATH/anvillm-orphan-check
chmod 0755 $INSTALL_PATH/anvillm-orphan-check
cp scripts/anvillm-supervisor $INSTALL_PATH/anvillm-supervisor
chmod 0755 $INSTALL_PATH/anvillm-supervisor
mkdir -p $INSTALL_PATH/Teams
cp -f team-templates/* $INSTALL_PATH/Teams/
chmod 0755 $INSTALL_PATH/Teams/*
@ -58,12 +58,14 @@ install:V: build
cron-install:V:
mkdir -p $HOME/.local/share/anvillm
(crontab -l 2>/dev/null | grep -v 'anvillm-orphan-check'; echo "*/5 * * * * $INSTALL_PATH/anvillm-orphan-check >>$HOME/.local/share/anvillm/orphan-check.log 2>&1") | crontab -
echo "cron installed: anvillm-orphan-check (every 5 minutes)"
(crontab -l 2>/dev/null | grep -v 'anvillm-supervisor'; \
echo "*/5 * * * * $INSTALL_PATH/anvillm-supervisor --orphans >>$HOME/.local/share/anvillm/worker-check.log 2>&1"; \
echo "*/1 * * * * $INSTALL_PATH/anvillm-supervisor --nudge >>$HOME/.local/share/anvillm/worker-check.log 2>&1") | crontab -
echo "cron installed: anvillm-supervisor (orphans: every 5m, nudge: every 1m)"
cron-remove:V:
(crontab -l 2>/dev/null | grep -v 'anvillm-orphan-check') | crontab -
echo "cron removed: anvillm-orphan-check"
(crontab -l 2>/dev/null | grep -v 'anvillm-supervisor') | crontab -
echo "cron removed: anvillm-supervisor"
clean:V:
rm -f $INSTALL_PATH/anvilsrv

View File

@ -1,45 +0,0 @@
#!/bin/bash
# Orphan bead recovery: find in_progress beads whose assignee session has died
# and unclaim them. BeadReady fires automatically; waiting bots claim via event.
# Intended to run from cron every 5 minutes.
set -euo pipefail
LOG_PREFIX="[orphan-check $(date '+%Y-%m-%d %H:%M:%S')]"
log() { echo "$LOG_PREFIX $*"; }
# Get live session IDs
LIVE_SESSIONS=$(9p read anvillm/list 2>/dev/null | awk -F'\t' '{print $1}' || true)
# Get all mounts
MTAB=$(9p read anvillm/beads/mtab 2>/dev/null || true)
if [ -z "$MTAB" ]; then
log "no mounts, nothing to do"
exit 0
fi
while IFS=$'\t' read -r MOUNT CWD; do
[ -z "$MOUNT" ] && continue
IN_PROGRESS=$(9p read "anvillm/beads/$MOUNT/list" 2>/dev/null \
| jq -r '.[] | select(.status == "in_progress") | [.id, (.assignee // "")] | @tsv' \
2>/dev/null || true)
[ -z "$IN_PROGRESS" ] && continue
while IFS=$'\t' read -r BEAD_ID ASSIGNEE; do
[ -z "$BEAD_ID" ] && continue
# Skip if claimed by user (human in the loop — not an orphan)
[ "$ASSIGNEE" = "user" ] && continue
# Skip if assignee session is still alive
if [ -n "$ASSIGNEE" ] && echo "$LIVE_SESSIONS" | grep -qx "$ASSIGNEE"; then
continue
fi
log "orphan: $BEAD_ID (was: ${ASSIGNEE:-unassigned}) mount=$MOUNT — unclaiming"
echo "unclaim $BEAD_ID" | 9p write "anvillm/beads/$MOUNT/ctl" 2>/dev/null || \
log "WARNING: unclaim failed for $BEAD_ID"
done <<< "$IN_PROGRESS"
done <<< "$MTAB"

View File

@ -0,0 +1,80 @@
#!/bin/bash
# Periodic worker health check: orphan bead recovery and idle worker nudging.
# Usage: anvillm-worker-check [--orphans] [--nudge]
set -euo pipefail
LOG_PREFIX="[worker-check $(date '+%Y-%m-%d %H:%M:%S')]"
log() { echo "$LOG_PREFIX $*"; }
DO_ORPHANS=0
DO_NUDGE=0
while [[ $# -gt 0 ]]; do
case "$1" in
--orphans) DO_ORPHANS=1; shift ;;
--nudge) DO_NUDGE=1; shift ;;
*) echo "unknown argument: $1" >&2; exit 1 ;;
esac
done
if [ "$DO_ORPHANS" -eq 0 ] && [ "$DO_NUDGE" -eq 0 ]; then
echo "usage: anvillm-worker-check [--orphans] [--nudge]" >&2
exit 1
fi
SESSIONS=$(9p read anvillm/list 2>/dev/null || true)
# --- Orphan recovery ---
if [ "$DO_ORPHANS" -eq 1 ]; then
LIVE_SESSIONS=$(echo "$SESSIONS" | awk -F'\t' '{print $1}' || true)
MTAB=$(9p read anvillm/beads/mtab 2>/dev/null || true)
if [ -z "$MTAB" ]; then
log "orphans: no mounts, nothing to do"
else
while IFS=$'\t' read -r MOUNT CWD; do
[ -z "$MOUNT" ] && continue
IN_PROGRESS=$(9p read "anvillm/beads/$MOUNT/list" 2>/dev/null \
| jq -r '.[] | select(.status == "in_progress") | [.id, (.assignee // "")] | @tsv' \
2>/dev/null || true)
[ -z "$IN_PROGRESS" ] && continue
while IFS=$'\t' read -r BEAD_ID ASSIGNEE; do
[ -z "$BEAD_ID" ] && continue
[ "$ASSIGNEE" = "user" ] && continue
if [ -n "$ASSIGNEE" ] && echo "$LIVE_SESSIONS" | grep -qx "$ASSIGNEE"; then
continue
fi
log "orphan: $BEAD_ID (was: ${ASSIGNEE:-unassigned}) mount=$MOUNT — unclaiming"
echo "unclaim $BEAD_ID" | 9p write "anvillm/beads/$MOUNT/ctl" 2>/dev/null || \
log "WARNING: unclaim failed for $BEAD_ID"
done <<< "$IN_PROGRESS"
done <<< "$MTAB"
fi
fi
# --- Nudge idle workers ---
if [ "$DO_NUDGE" -eq 1 ]; then
[ -z "$SESSIONS" ] && { log "nudge: no sessions"; exit 0; }
BODY='Use the execute_code tool to run wait_for_bead.sh and check for available work.'
while IFS=$'\t' read -r ID BACKEND STATE ALIAS ROLE CWD; do
[ -z "$ID" ] && continue
[ "$STATE" != "idle" ] && continue
[ "$ROLE" = "-" ] || [ -z "$ROLE" ] && continue
ROLE_CONTENT=$(9p read "anvillm/roles/${ROLE}.md" 2>/dev/null || true)
echo "$ROLE_CONTENT" | grep -q '^worker: true' || continue
log "nudge: $ID (role=$ROLE)"
AGENT_ID=user send_message.sh \
--to "$ID" \
--type prompt_request \
--subject work \
--body "$BODY" || \
log "WARNING: nudge failed for $ID"
done <<< "$SESSIONS"
fi