diff --git a/cmd/anvilsrv/main.go b/cmd/anvilsrv/main.go index 6d614ad..2c531ad 100644 --- a/cmd/anvilsrv/main.go +++ b/cmd/anvilsrv/main.go @@ -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 diff --git a/internal/p9/roles.go b/internal/p9/roles.go index 4b2fae5..22eb387 100644 --- a/internal/p9/roles.go +++ b/internal/p9/roles.go @@ -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() diff --git a/internal/supervisor/supervisor.go b/internal/supervisor/supervisor.go deleted file mode 100644 index adce65b..0000000 --- a/internal/supervisor/supervisor.go +++ /dev/null @@ -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() - } - } -} diff --git a/mkfile b/mkfile index 2154b69..4399189 100644 --- a/mkfile +++ b/mkfile @@ -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 diff --git a/scripts/anvillm-orphan-check b/scripts/anvillm-orphan-check deleted file mode 100644 index 8527734..0000000 --- a/scripts/anvillm-orphan-check +++ /dev/null @@ -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" diff --git a/scripts/anvillm-supervisor b/scripts/anvillm-supervisor new file mode 100644 index 0000000..93ea994 --- /dev/null +++ b/scripts/anvillm-supervisor @@ -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