From c2d08271c95daa48ff449a349d298b73c50dd883 Mon Sep 17 00:00:00 2001 From: jcfs Date: Mon, 12 Jan 2026 17:14:17 +0000 Subject: [PATCH] fix: reconnect to agent streams after backend restart - Add reconnectStreams() to reconnect WebSocket streams for recovered agents - Reconnect updates stream (for agent responses) - Reconnect permissions stream (for permission requests) - Mark recovered agents as READY so they can accept prompts - Fix worktree cleanup order: clean files before DB cascade deletes records --- .../internal/agent/lifecycle/manager.go | 39 +++++++++++++++++++ apps/backend/internal/task/service/service.go | 13 ++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 9eaf13ade0..6af424e7de 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -234,6 +234,10 @@ func (m *Manager) reconcileWithDocker(ctx context.Context) ([]RecoveredInstance, AgentType: agentType, }) + // Reconnect to WebSocket streams for updates and permissions + // This runs in goroutines so we don't block reconciliation + go m.reconnectStreams(instance) + m.logger.Info("recovered running container", zap.String("instance_id", instanceID), zap.String("task_id", taskID), @@ -640,6 +644,41 @@ func (m *Manager) handlePermissionStream(instance *AgentInstance) { } } +// reconnectStreams reconnects to agent WebSocket streams after backend restart +// This is called for recovered instances that were running before the restart +func (m *Manager) reconnectStreams(instance *AgentInstance) { + m.logger.Info("reconnecting to agent streams after recovery", + zap.String("instance_id", instance.ID), + zap.String("task_id", instance.TaskID)) + + // Wait a moment for any startup operations to settle + time.Sleep(500 * time.Millisecond) + + // Check if agentctl is responsive + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := instance.agentctl.WaitForReady(ctx, 10*time.Second); err != nil { + m.logger.Warn("agentctl not ready for stream reconnection", + zap.String("instance_id", instance.ID), + zap.Error(err)) + // Don't return - still try to connect to streams + } + + // Reconnect to WebSocket streams + go m.handleUpdatesStream(instance) + go m.handlePermissionStream(instance) + + // Mark the instance as READY so it can accept prompts + m.mu.Lock() + instance.Status = v1.AgentStatusReady + m.mu.Unlock() + + m.logger.Info("agent streams reconnected, ready for prompts", + zap.String("instance_id", instance.ID), + zap.String("task_id", instance.TaskID)) +} + // handlePermissionNotification processes incoming permission requests from the agent func (m *Manager) handlePermissionNotification(instance *AgentInstance, notification *agentctl.PermissionNotification) { m.logger.Info("received permission request", diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 8c41545229..0fc23b0b56 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -263,12 +263,8 @@ func (s *Service) DeleteTask(ctx context.Context, id string) error { return err } - if err := s.repo.DeleteTask(ctx, id); err != nil { - s.logger.Error("failed to delete task", zap.String("task_id", id), zap.Error(err)) - return err - } - - // Clean up worktree if configured (best-effort, don't fail deletion) + // Clean up worktree BEFORE deleting task (CASCADE would delete worktree DB records) + // This must happen first so we can find the worktree paths to remove from disk if s.worktreeCleanup != nil { if err := s.worktreeCleanup.OnTaskDeleted(ctx, id); err != nil { s.logger.Warn("failed to cleanup worktree on task deletion", @@ -277,6 +273,11 @@ func (s *Service) DeleteTask(ctx context.Context, id string) error { } } + if err := s.repo.DeleteTask(ctx, id); err != nil { + s.logger.Error("failed to delete task", zap.String("task_id", id), zap.Error(err)) + return err + } + s.publishTaskEvent(ctx, events.TaskDeleted, task, nil) s.logger.Info("task deleted", zap.String("task_id", id))