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
+33 -10
View File
@@ -116,14 +116,18 @@ func main() {
logger.Info("orchestrator initialized")
}
// ── Process manager (shared: API reflection + per-agent goroutine hooks) ──
mgr := newProcessManager(logDir)
// ── Shared dependencies for agent registry ──
deps := &launchDeps{
agentBus: agentBus,
orch: orch,
logDir: logDir,
logLevel: lvl,
parentCtx: ctx,
secPolicy: secPolicy,
agentBus: agentBus,
orch: orch,
logDir: logDir,
logLevel: lvl,
parentCtx: ctx,
secPolicy: secPolicy,
procMgr: mgr,
}
registry := newAgentRegistry(deps)
@@ -281,10 +285,11 @@ func main() {
if key == "" {
logger.Warn("api-port set but AGENTS_API_KEY is empty — HTTP API disabled (set AGENTS_API_KEY in .env)")
} else {
// Build a process.Manager that reflects the live launcher state.
// The manager uses run/ for PID files and agents/*/config.yaml for discovery.
mgr := newProcessManager(logDir)
srv := api.New(mgr, key, apiPort, logger)
// mgr already created above; share it between API and registry.
ctrl := &agentController{reg: registry, mgr: mgr}
srv := api.New(mgr, key, apiPort, logger).
WithController(ctrl).
WithDataDir("agents")
go func() {
if err := srv.Run(ctx); err != nil {
logger.Error("api server stopped", "err", err)
@@ -400,6 +405,24 @@ func newProcessManager(logDir string) *process.Manager {
return process.NewManager("run", "agents/*/config.yaml", "bin/launcher")
}
// agentController adapts agentRegistry + process.Manager to the api.AgentController
// interface, allowing the HTTP API to start/stop individual agent goroutines without
// restarting the whole launcher process.
type agentController struct {
reg *agentRegistry
mgr *process.Manager
}
// StopUnifiedAgent cancels the per-agent goroutine context without stopping the launcher.
func (c *agentController) StopUnifiedAgent(id string) error {
return c.mgr.StopUnifiedAgent(id)
}
// StartUnifiedAgent re-launches the agent goroutine for the given ID.
func (c *agentController) StartUnifiedAgent(id string) error {
return c.reg.startAgent(id, rulesFor)
}
// isSpecialConfig checks whether a config path belongs to a middleware special
// (e.g. orchestrator) by detecting a "special:" top-level key with a non-empty
// id. This avoids config.Load() failing with "agent.id is required" when the
+51 -8
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
@@ -34,6 +35,15 @@ type launchDeps struct {
logLevel slog.Level
parentCtx context.Context
secPolicy pksecurity.SecurityPolicy // centralized security policy loaded from security/
procMgr procManagerHook // optional: per-agent goroutine registration for API
}
// procManagerHook allows the registry to register/unregister per-agent goroutine
// contexts with the process.Manager so the API can reflect and control individual
// agent goroutines in unified mode.
type procManagerHook interface {
RegisterUnifiedAgent(id string, cancel context.CancelFunc)
UnregisterUnifiedAgent(id string)
}
// agentRegistry tracks all running agents by ID, enabling individual hot-reload.
@@ -61,10 +71,33 @@ func (r *agentRegistry) register(ra *runningAgent) {
runtimeType = "agent"
}
r.launchGoroutine(ra, runtimeType)
}
// launchGoroutine starts a runner goroutine, registering its cancel context with
// the process manager hook when available for per-agent stop/start control.
func (r *agentRegistry) launchGoroutine(ra *runningAgent, runtimeType string) {
agentID := ra.cfg.Agent.ID
go func() {
// Create a per-agent context derived from parent so we can cancel just
// this goroutine without stopping the launcher or other agents.
agentCtx, cancel := context.WithCancel(r.deps.parentCtx)
defer cancel()
// Register with process manager for API control (unified mode).
if r.deps.procMgr != nil {
r.deps.procMgr.RegisterUnifiedAgent(agentID, cancel)
defer r.deps.procMgr.UnregisterUnifiedAgent(agentID)
}
ra.logger.Info("runner started", "type", runtimeType)
if err := ra.runner.Run(r.deps.parentCtx); err != nil {
ra.logger.Error("runner stopped with error", "err", err, "type", runtimeType)
if err := ra.runner.Run(agentCtx); err != nil {
if agentCtx.Err() == nil {
// Not cancelled externally — log as real error
ra.logger.Error("runner stopped with error", "err", err, "type", runtimeType)
} else {
ra.logger.Info("runner stopped (context cancelled)", "type", runtimeType)
}
}
}()
}
@@ -90,6 +123,21 @@ func (r *agentRegistry) stopAndWait(id string) {
r.deps.agentBus.Unsubscribe(bus.AgentID(id))
}
// startAgent re-launches a stopped (but registered) agent by calling reload.
// Used by the API StartUnifiedAgent flow.
// Returns error if agent is not found in the registry.
func (r *agentRegistry) startAgent(id string, rulesFor func(string, *slog.Logger) []decision.Rule) error {
r.mu.Lock()
_, exists := r.agents[id]
r.mu.Unlock()
if !exists {
return fmt.Errorf("agent %q not found in registry", id)
}
// reload re-reads config and restarts the runner
r.reload(id, rulesFor)
return nil
}
// reload stops an agent, re-reads its config, recreates it, and restarts it.
func (r *agentRegistry) reload(id string, rulesFor func(string, *slog.Logger) []decision.Rule) {
r.mu.Lock()
@@ -192,12 +240,7 @@ func (r *agentRegistry) reload(id string, rulesFor func(string, *slog.Logger) []
if runtimeType == "" {
runtimeType = "agent"
}
go func() {
newLogger.Info("runner started", "type", runtimeType)
if err := newRunner.Run(r.deps.parentCtx); err != nil {
newLogger.Error("runner stopped with error", "err", err, "type", runtimeType)
}
}()
r.launchGoroutine(newRA, runtimeType)
newLogger.Info("runner_reloaded", "id", id, "type", runtimeType)
}