feat: update dashboard and process manager for unified launcher

Actualiza el dashboard TUI y el process manager para el modelo de launcher
unificado donde todos los agentes corren en un solo proceso.

Dashboard (pkg/tui):
- model.go: campos de estado del launcher (PID, uptime, memory, CPU, log size)
- model.go: ServerMenuOptions(running) contextual, AgentActionOptions(enabled)
- messages.go: MsgAgentsLoaded incluye estado del launcher, MsgServerActionDone/MsgRebuildDone simplificados
- update.go: intents nuevos (Enable/Disable agent, Start/Stop/Restart/Kill launcher)
- view.go: vista de servidor muestra stats del launcher, agentes muestran enabled/disabled

Shell adapter (shell/tui):
- adapter.go: reescrito para usar métodos unificados (StartUnified, StopUnified, ToggleEnabled, StatusAllUnified, UnifiedStats, UnifiedLogTail)

Process manager (shell/process):
- manager.go: métodos StartUnified, StopUnified, KillUnified, IsUnifiedRunning, UnifiedPID, UnifiedStats, UnifiedLogTail, StatusAllUnified, ToggleEnabled

Los agentes ya no se inician/detienen individualmente desde el dashboard.
Se habilitan/deshabilitan en config y se reinicia el launcher para aplicar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 09:05:57 +00:00
parent 2667af52cc
commit 1af0457c1f
6 changed files with 432 additions and 370 deletions
+180
View File
@@ -82,6 +82,8 @@ func (osProber) isAlive(pid int) bool {
return syscall.Kill(pid, 0) == nil
}
const unifiedID = "launcher" // PID/log file ID for the unified launcher
// Manager handles agent process lifecycle.
type Manager struct {
runDir string
@@ -388,6 +390,184 @@ func (m *Manager) Build() (string, error) {
return string(out), err
}
// ── Unified launcher ─────────────────────────────────────────────────────
// The unified launcher runs ALL enabled agents + orchestrator in a single
// process. PID → run/launcher.pid, log → run/launcher.log.
// StartUnified launches the unified launcher (no -c flag → discovers all agents).
func (m *Manager) StartUnified() error {
if m.IsUnifiedRunning() {
return fmt.Errorf("unified launcher is already running (PID %d)", m.readPID(unifiedID))
}
if err := os.MkdirAll(m.runDir, 0o755); err != nil {
return fmt.Errorf("create run dir: %w", err)
}
logFile, err := os.OpenFile(m.logPath(unifiedID), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open log: %w", err)
}
bin := m.resolvedBin()
var cmd *exec.Cmd
if strings.HasPrefix(bin, "go run") {
cmd = exec.Command("go", "run", "-tags", "goolm", "./cmd/launcher", "--log-level", "info")
} else {
cmd = exec.Command(bin, "--log-level", "info")
}
cmd.Env = m.buildEnv()
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
logFile.Close()
return fmt.Errorf("exec: %w", err)
}
if err := os.WriteFile(m.pidPath(unifiedID), []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil {
return fmt.Errorf("write PID: %w", err)
}
go func() { _ = cmd.Wait() }()
return nil
}
// StopUnified stops the unified launcher process.
func (m *Manager) StopUnified() error {
return m.Stop(unifiedID)
}
// KillUnified sends SIGKILL to the unified launcher.
func (m *Manager) KillUnified() error {
return m.Kill(unifiedID)
}
// IsUnifiedRunning checks if the unified launcher is alive.
func (m *Manager) IsUnifiedRunning() bool {
pid := m.readPID(unifiedID)
if pid > 0 && m.isAlive(pid) {
return true
}
// Fallback: search for launcher running without -c flag
pids := m.findUnifiedPIDs()
return len(pids) > 0
}
// UnifiedPID returns the PID of the running unified launcher, or 0.
func (m *Manager) UnifiedPID() int {
pid := m.readPID(unifiedID)
if pid > 0 && m.isAlive(pid) {
return pid
}
pids := m.findUnifiedPIDs()
if len(pids) > 0 {
// Repair PID file
_ = os.WriteFile(m.pidPath(unifiedID), []byte(strconv.Itoa(pids[0])), 0o644)
return pids[0]
}
return 0
}
// UnifiedStats returns resource usage for the unified launcher process.
func (m *Manager) UnifiedStats() (ProcessStats, error) {
pid := m.UnifiedPID()
if pid == 0 {
return ProcessStats{}, fmt.Errorf("unified launcher is not running")
}
return m.statsForPID(pid, unifiedID), nil
}
// UnifiedLogTail returns the last N lines of the unified launcher log.
func (m *Manager) UnifiedLogTail(lines int) ([]string, error) {
return m.LogTail(unifiedID, lines)
}
// StatusAllUnified returns status for all agents, deriving "running" from
// whether the unified launcher is running + the agent is enabled.
func (m *Manager) StatusAllUnified() ([]AgentStatus, error) {
agents, err := m.Scan()
if err != nil {
return nil, err
}
launcherRunning := m.IsUnifiedRunning()
launcherPID := m.UnifiedPID()
statuses := make([]AgentStatus, len(agents))
for i, a := range agents {
running := launcherRunning && a.Enabled
pid := 0
instances := 0
if running {
pid = launcherPID
instances = 1
}
statuses[i] = AgentStatus{
AgentInfo: a,
Running: running,
PID: pid,
Instances: instances,
}
}
return statuses, nil
}
// ToggleEnabled sets the enabled field in an agent's config.yaml.
func (m *Manager) ToggleEnabled(id string, enabled bool) error {
agents, err := m.Scan()
if err != nil {
return err
}
for _, a := range agents {
if a.ID == id {
return m.setEnabledInConfig(a.ConfigPath, enabled)
}
}
return fmt.Errorf("agent %q not found", id)
}
// setEnabledInConfig rewrites the enabled field in a config.yaml.
func (m *Manager) setEnabledInConfig(path string, enabled bool) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
val := "false"
if enabled {
val = "true"
}
lines := strings.Split(string(data), "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "enabled:") {
// Preserve indentation
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
lines[i] = indent + "enabled: " + val
break
}
}
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
}
// findUnifiedPIDs finds launcher processes running without -c flag.
func (m *Manager) findUnifiedPIDs() []int {
// Search for launcher processes that do NOT have -c flag
raw := m.prober.pgrepPIDs("launcher.*--log-level")
var pids []int
for _, p := range raw {
comm := m.prober.processComm(p)
if comm == "go" {
continue
}
pids = append(pids, p)
}
return pids
}
// ── internal helpers ─────────────────────────────────────────────────────
func (m *Manager) pidPath(id string) string { return filepath.Join(m.runDir, id+".pid") }
+89 -233
View File
@@ -30,32 +30,26 @@ func (a *Adapter) RunIntent(intent puretui.Intent) tea.Cmd {
case puretui.IntentLoadAgents:
return a.loadAgents()
case puretui.IntentStartAgent:
return a.startAgent(intent.AgentID)
case puretui.IntentEnableAgent:
return a.enableAgent(intent.AgentID)
case puretui.IntentStopAgent:
return a.stopAgent(intent.AgentID)
case puretui.IntentKillAgent:
return a.killAgent(intent.AgentID)
case puretui.IntentRestartAgent:
return a.restartAgent(intent.AgentID)
case puretui.IntentDisableAgent:
return a.disableAgent(intent.AgentID)
case puretui.IntentLoadLogs:
return a.loadLogs(intent.AgentID)
case puretui.IntentStartAll:
return a.startAll()
case puretui.IntentStartLauncher:
return a.startLauncher()
case puretui.IntentStopAll:
return a.stopAll()
case puretui.IntentStopLauncher:
return a.stopLauncher()
case puretui.IntentRestartAll:
return a.restartAll()
case puretui.IntentRestartLauncher:
return a.restartLauncher()
case puretui.IntentKillAll:
return a.killAll()
case puretui.IntentKillLauncher:
return a.killLauncher()
case puretui.IntentRebuildRestart:
return a.rebuildRestart()
@@ -73,287 +67,149 @@ func (a *Adapter) RunIntent(intent puretui.Intent) tea.Cmd {
func (a *Adapter) loadAgents() tea.Cmd {
return func() tea.Msg {
statuses, err := a.mgr.StatusAll()
statuses, err := a.mgr.StatusAllUnified()
if err != nil {
return puretui.MsgAgentsLoaded{}
}
views := make([]puretui.AgentView, len(statuses))
for i, s := range statuses {
v := puretui.AgentView{
ID: s.ID,
Name: s.Name,
Version: s.Version,
Desc: s.Desc,
Enabled: s.Enabled,
Running: s.Running,
PID: s.PID,
Instances: s.Instances,
views[i] = puretui.AgentView{
ID: s.ID,
Name: s.Name,
Version: s.Version,
Desc: s.Desc,
Enabled: s.Enabled,
Running: s.Running,
PID: s.PID,
}
if s.Running {
if stats, err := a.mgr.Stats(s.ID); err == nil {
v.Uptime = formatUptime(stats.UptimeSecs)
v.Memory = formatBytes(stats.MemRSSKB * 1024)
v.CPU = fmt.Sprintf("%.1f%%", stats.CPUPct)
v.LogSize = formatBytes(stats.LogBytes)
}
}
views[i] = v
}
return puretui.MsgAgentsLoaded{Agents: views}
msg := puretui.MsgAgentsLoaded{
Agents: views,
LauncherRunning: a.mgr.IsUnifiedRunning(),
LauncherPID: a.mgr.UnifiedPID(),
}
// Launcher stats
if msg.LauncherRunning {
if stats, err := a.mgr.UnifiedStats(); err == nil {
msg.LauncherUptime = formatUptime(stats.UptimeSecs)
msg.LauncherMemory = formatBytes(stats.MemRSSKB * 1024)
msg.LauncherCPU = fmt.Sprintf("%.1f%%", stats.CPUPct)
msg.LauncherLogSize = formatBytes(stats.LogBytes)
}
}
return msg
}
}
func (a *Adapter) startAgent(id string) tea.Cmd {
func (a *Adapter) enableAgent(id string) tea.Cmd {
return func() tea.Msg {
agents, err := a.mgr.Scan()
if err != nil {
return puretui.MsgActionDone{AgentID: id, Action: "Start", Err: err}
}
for _, agent := range agents {
if agent.ID == id {
err = a.mgr.Start(agent)
// Give the process a moment to start.
if err == nil {
time.Sleep(500 * time.Millisecond)
}
return puretui.MsgActionDone{AgentID: id, Action: "Start", Err: err}
}
}
return puretui.MsgActionDone{AgentID: id, Action: "Start", Err: fmt.Errorf("agent not found")}
err := a.mgr.ToggleEnabled(id, true)
return puretui.MsgActionDone{AgentID: id, Action: "Enable", Err: err}
}
}
func (a *Adapter) stopAgent(id string) tea.Cmd {
func (a *Adapter) disableAgent(id string) tea.Cmd {
return func() tea.Msg {
err := a.mgr.Stop(id)
return puretui.MsgActionDone{AgentID: id, Action: "Stop", Err: err}
err := a.mgr.ToggleEnabled(id, false)
return puretui.MsgActionDone{AgentID: id, Action: "Disable", Err: err}
}
}
func (a *Adapter) killAgent(id string) tea.Cmd {
func (a *Adapter) startLauncher() tea.Cmd {
return func() tea.Msg {
err := a.mgr.Kill(id)
return puretui.MsgActionDone{AgentID: id, Action: "Kill", Err: err}
}
}
func (a *Adapter) restartAgent(id string) tea.Cmd {
return func() tea.Msg {
// Stop first (ignore error if not running)
_ = a.mgr.Stop(id)
time.Sleep(300 * time.Millisecond)
agents, err := a.mgr.Scan()
if err != nil {
return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: err}
}
for _, agent := range agents {
if agent.ID == id {
err = a.mgr.Start(agent)
if err == nil {
time.Sleep(500 * time.Millisecond)
}
return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: err}
}
}
return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: fmt.Errorf("agent not found")}
}
}
func (a *Adapter) startAll() tea.Cmd {
return func() tea.Msg {
agents, err := a.mgr.Scan()
if err != nil {
return puretui.MsgServerActionDone{Action: "Start All", Errors: []string{err.Error()}, Failed: 1}
}
var total, failed int
var errs []string
for _, agent := range agents {
if !agent.Enabled {
continue
}
if a.mgr.IsRunning(agent.ID) {
continue
}
total++
if err := a.mgr.Start(agent); err != nil {
failed++
errs = append(errs, fmt.Sprintf("%s: %v", agent.ID, err))
}
}
if total > 0 {
err := a.mgr.StartUnified()
if err == nil {
time.Sleep(500 * time.Millisecond)
}
return puretui.MsgServerActionDone{Action: "Start All", Total: total, Failed: failed, Errors: errs}
return puretui.MsgServerActionDone{Action: "Start", Err: err}
}
}
func (a *Adapter) stopAll() tea.Cmd {
func (a *Adapter) stopLauncher() tea.Cmd {
return func() tea.Msg {
statuses, err := a.mgr.StatusAll()
if err != nil {
return puretui.MsgServerActionDone{Action: "Stop All", Errors: []string{err.Error()}, Failed: 1}
}
var total, failed int
var errs []string
for _, s := range statuses {
if !s.Running {
continue
}
total++
if err := a.mgr.Stop(s.ID); err != nil {
failed++
errs = append(errs, fmt.Sprintf("%s: %v", s.ID, err))
}
}
return puretui.MsgServerActionDone{Action: "Stop All", Total: total, Failed: failed, Errors: errs}
err := a.mgr.StopUnified()
return puretui.MsgServerActionDone{Action: "Stop", Err: err}
}
}
func (a *Adapter) restartAll() tea.Cmd {
func (a *Adapter) restartLauncher() tea.Cmd {
return func() tea.Msg {
agents, err := a.mgr.Scan()
if err != nil {
return puretui.MsgServerActionDone{Action: "Restart All", Errors: []string{err.Error()}, Failed: 1}
}
// Stop all running first
for _, agent := range agents {
if agent.Enabled && a.mgr.IsRunning(agent.ID) {
_ = a.mgr.Stop(agent.ID)
}
}
time.Sleep(300 * time.Millisecond)
// Start all enabled
var total, failed int
var errs []string
for _, agent := range agents {
if !agent.Enabled {
continue
}
total++
if err := a.mgr.Start(agent); err != nil {
failed++
errs = append(errs, fmt.Sprintf("%s: %v", agent.ID, err))
}
}
if total > 0 {
_ = a.mgr.StopUnified()
time.Sleep(500 * time.Millisecond)
err := a.mgr.StartUnified()
if err == nil {
time.Sleep(500 * time.Millisecond)
}
return puretui.MsgServerActionDone{Action: "Restart All", Total: total, Failed: failed, Errors: errs}
return puretui.MsgServerActionDone{Action: "Restart", Err: err}
}
}
func (a *Adapter) killAll() tea.Cmd {
func (a *Adapter) killLauncher() tea.Cmd {
return func() tea.Msg {
statuses, err := a.mgr.StatusAll()
if err != nil {
return puretui.MsgServerActionDone{Action: "Kill All", Errors: []string{err.Error()}, Failed: 1}
}
var total, failed int
var errs []string
for _, s := range statuses {
if !s.Running {
continue
}
total++
if err := a.mgr.Kill(s.ID); err != nil {
failed++
errs = append(errs, fmt.Sprintf("%s: %v", s.ID, err))
}
}
return puretui.MsgServerActionDone{Action: "Kill All", Total: total, Failed: failed, Errors: errs}
err := a.mgr.KillUnified()
return puretui.MsgServerActionDone{Action: "Kill", Err: err}
}
}
func (a *Adapter) rebuildRestart() tea.Cmd {
return func() tea.Msg {
// 1. Remember which agents are currently running
statuses, err := a.mgr.StatusAll()
if err != nil {
return puretui.MsgRebuildDone{Errors: []string{err.Error()}}
}
var wasRunning []string
for _, s := range statuses {
if s.Running {
wasRunning = append(wasRunning, s.ID)
}
}
wasRunning := a.mgr.IsUnifiedRunning()
// 2. Stop all running agents
for _, id := range wasRunning {
_ = a.mgr.Stop(id)
}
if len(wasRunning) > 0 {
// Stop if running
if wasRunning {
_ = a.mgr.StopUnified()
time.Sleep(500 * time.Millisecond)
}
// 3. Build
// Build
buildOut, buildErr := a.mgr.Build()
if buildErr != nil {
// Build failed — try to restart what was running before
for _, id := range wasRunning {
agents, _ := a.mgr.Scan()
for _, ag := range agents {
if ag.ID == id {
_ = a.mgr.Start(ag)
break
}
}
// Build failed — try to restart if was running
if wasRunning {
_ = a.mgr.StartUnified()
}
// Return last lines of build output
lines := strings.Split(strings.TrimSpace(buildOut), "\n")
tail := buildOut
if len(lines) > 5 {
tail = strings.Join(lines[len(lines)-5:], "\n")
}
return puretui.MsgRebuildDone{BuildLog: tail}
return puretui.MsgRebuildDone{BuildOK: false, BuildLog: tail}
}
// 4. Restart only the agents that were running before
agents, _ := a.mgr.Scan()
agentMap := make(map[string]process.AgentInfo)
for _, ag := range agents {
agentMap[ag.ID] = ag
}
var restarted, failed int
var errs []string
for _, id := range wasRunning {
ag, ok := agentMap[id]
if !ok {
failed++
errs = append(errs, fmt.Sprintf("%s: not found after build", id))
continue
// Restart launcher
started := false
var startErr error
if wasRunning {
startErr = a.mgr.StartUnified()
if startErr == nil {
started = true
time.Sleep(500 * time.Millisecond)
}
if err := a.mgr.Start(ag); err != nil {
failed++
errs = append(errs, fmt.Sprintf("%s: %v", id, err))
} else {
restarted++
}
}
if restarted > 0 {
time.Sleep(500 * time.Millisecond)
}
return puretui.MsgRebuildDone{
BuildOK: true,
Restarted: restarted,
Failed: failed,
Errors: errs,
BuildOK: true,
Started: started,
Err: startErr,
}
}
}
func (a *Adapter) loadLogs(id string) tea.Cmd {
return func() tea.Msg {
lines, err := a.mgr.LogTail(id, 100)
var lines []string
var err error
if id == "" {
// Launcher logs
lines, err = a.mgr.UnifiedLogTail(100)
} else {
// Agent logs — in unified mode, all go to launcher log
lines, err = a.mgr.UnifiedLogTail(100)
}
if err != nil {
return puretui.MsgLogsLoaded{Lines: []string{"Error: " + err.Error()}}
}