feat(api): per-agent unified control + clear_memory + delete_cache

- Manager: RegisterUnifiedAgent/UnregisterUnifiedAgent/StopUnifiedAgent/
  IsUnifiedAgentRunning/UptimeSeconds — cancela goroutines individuales sin
  matar el launcher
- Manager: UptimeSeconds en AgentStatus via startedAt map
- api/server: AgentController interface + WithController/WithDataDir builders
  + rutas POST /agents/{id}/clear_memory y /agents/{id}/delete_cache
- api/handlers: handleStartAgent/Stop/Restart delegan a controller en modo
  unified; Messages24h enriquecido via queryMessages24h (cache 30s)
- api/handlers: handleClearMemory — para la goroutine, borra messages+facts de
  memory.db, responde {status,messages_deleted,facts_deleted}
- api/handlers: handleDeleteCache — para la goroutine, elimina crypto/ y cache/,
  responde {status,paths_deleted}
- launcher/registry: launchGoroutine extrae goroutine con contexto per-agente;
  deps.procMgr hookea RegisterUnified; startAgent permite relanzar via reload
- launcher/main: agentController implementa api.AgentController sobre registry;
  mgr compartido entre API y registry; WithController+WithDataDir cableados

Co-Authored-By: fn-orquestador <noreply@fn-registry>
This commit is contained in:
2026-05-22 22:56:46 +02:00
parent 3db4443b65
commit 261f96f71b
5 changed files with 482 additions and 52 deletions
+94 -10
View File
@@ -4,12 +4,14 @@ package process
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
@@ -29,9 +31,10 @@ type AgentInfo struct {
// AgentStatus combines agent metadata with runtime state.
type AgentStatus struct {
AgentInfo
Running bool
PID int
Instances int
Running bool
PID int
Instances int
UptimeSeconds int64 // seconds since agent goroutine started (unified mode) or 0
}
// ProcessStats holds resource usage for a running process.
@@ -91,11 +94,25 @@ type Manager struct {
binPath string
envFile string // path to .env file for child processes
prober processProber
// unifiedMode tracks per-agent goroutine cancel functions and start times
// when the unified launcher is running (all agents as goroutines).
unifiedMu sync.RWMutex
unifiedCancels map[string]context.CancelFunc
startedAt map[string]time.Time
}
// NewManager creates a Manager. binPath can be empty for auto-detection.
func NewManager(runDir, agentsGlob, binPath string) *Manager {
return &Manager{runDir: runDir, agentsGlob: agentsGlob, binPath: binPath, envFile: ".env", prober: osProber{}}
return &Manager{
runDir: runDir,
agentsGlob: agentsGlob,
binPath: binPath,
envFile: ".env",
prober: osProber{},
unifiedCancels: make(map[string]context.CancelFunc),
startedAt: make(map[string]time.Time),
}
}
// Scan discovers all agents from config files.
@@ -484,8 +501,63 @@ func (m *Manager) UnifiedLogTail(lines int) ([]string, error) {
return m.LogTail(unifiedID, lines)
}
// ── Per-agent unified control ─────────────────────────────────────────────
// RegisterUnifiedAgent registers a cancel function and start time for an agent
// goroutine running inside the unified launcher. Called by the launcher runtime.
func (m *Manager) RegisterUnifiedAgent(id string, cancel context.CancelFunc) {
m.unifiedMu.Lock()
defer m.unifiedMu.Unlock()
m.unifiedCancels[id] = cancel
m.startedAt[id] = time.Now()
}
// UnregisterUnifiedAgent removes the cancel function for an agent goroutine.
// Called when the goroutine exits.
func (m *Manager) UnregisterUnifiedAgent(id string) {
m.unifiedMu.Lock()
defer m.unifiedMu.Unlock()
delete(m.unifiedCancels, id)
delete(m.startedAt, id)
}
// StopUnifiedAgent cancels the goroutine context for a specific agent without
// stopping the launcher process. Returns error if agent is not registered.
func (m *Manager) StopUnifiedAgent(id string) error {
m.unifiedMu.RLock()
cancel, ok := m.unifiedCancels[id]
m.unifiedMu.RUnlock()
if !ok {
return fmt.Errorf("agent %q is not registered in unified mode (not running)", id)
}
cancel()
m.UnregisterUnifiedAgent(id)
return nil
}
// IsUnifiedAgentRunning returns true if the agent goroutine is registered.
func (m *Manager) IsUnifiedAgentRunning(id string) bool {
m.unifiedMu.RLock()
defer m.unifiedMu.RUnlock()
_, ok := m.unifiedCancels[id]
return ok
}
// UptimeSeconds returns how long an agent has been running since registration.
// Returns 0 if the agent is not registered or not running.
func (m *Manager) UptimeSeconds(id string) int64 {
m.unifiedMu.RLock()
defer m.unifiedMu.RUnlock()
if t, ok := m.startedAt[id]; ok {
return int64(time.Since(t).Seconds())
}
return 0
}
// StatusAllUnified returns status for all agents, deriving "running" from
// whether the unified launcher is running + the agent is enabled.
// whether the unified launcher is running + per-agent registration.
// When per-agent cancel registration is available (via RegisterUnifiedAgent),
// running reflects the individual goroutine state rather than launcher-wide enabled.
func (m *Manager) StatusAllUnified() ([]AgentStatus, error) {
agents, err := m.Scan()
if err != nil {
@@ -494,9 +566,20 @@ func (m *Manager) StatusAllUnified() ([]AgentStatus, error) {
launcherRunning := m.IsUnifiedRunning()
launcherPID := m.UnifiedPID()
m.unifiedMu.RLock()
hasPerAgentTracking := len(m.unifiedCancels) > 0
m.unifiedMu.RUnlock()
statuses := make([]AgentStatus, len(agents))
for i, a := range agents {
running := launcherRunning && a.Enabled
var running bool
if hasPerAgentTracking {
// Per-agent goroutine tracking: check individual registration
running = m.IsUnifiedAgentRunning(a.ID)
} else {
// Fallback: launcher running + agent enabled
running = launcherRunning && a.Enabled
}
pid := 0
instances := 0
if running {
@@ -504,10 +587,11 @@ func (m *Manager) StatusAllUnified() ([]AgentStatus, error) {
instances = 1
}
statuses[i] = AgentStatus{
AgentInfo: a,
Running: running,
PID: pid,
Instances: instances,
AgentInfo: a,
Running: running,
PID: pid,
Instances: instances,
UptimeSeconds: m.UptimeSeconds(a.ID),
}
}
return statuses, nil