diff --git a/apps/backend/internal/agent/lifecycle/manager_interaction.go b/apps/backend/internal/agent/lifecycle/manager_interaction.go index 8bbc53bd98..fae80b04d1 100644 --- a/apps/backend/internal/agent/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/lifecycle/manager_interaction.go @@ -367,18 +367,20 @@ func (m *Manager) ListExecutions() []*AgentExecution { // IsAgentRunningForSession checks if an agent process is running or starting for a session. // -// This probes agentctl's status endpoint to verify the agent process state. Returns true if: +// For passthrough sessions (direct PTY mode), it checks whether the PTY process is alive +// in the InteractiveRunner. For ACP sessions, it probes agentctl's status endpoint. +// +// Returns true if: +// - Passthrough process is alive in the InteractiveRunner // - Agent status is "running" (actively processing prompts) // - Agent status is "starting" (process launched but not yet ready) // // Returns false if: // - No execution exists for this session +// - Passthrough process ID is set but process is not alive // - agentctl client is not available // - Status check fails (network/timeout error) // - Agent is in any other state (stopped, failed, etc.) -// -// Note: The name "IsAgentRunning" is slightly misleading - it includes "starting" state. -// Use this to check if an agent subprocess exists for the session, regardless of ready state. func (m *Manager) IsAgentRunningForSession(ctx context.Context, sessionID string) bool { // First check if we have an execution tracked for this session execution, exists := m.GetExecutionBySessionID(sessionID) @@ -386,6 +388,15 @@ func (m *Manager) IsAgentRunningForSession(ctx context.Context, sessionID string return false } + // Passthrough sessions run as direct PTY processes via InteractiveRunner, + // bypassing agentctl's ACP protocol. Check the process directly. + if execution.PassthroughProcessID != "" { + if runner := m.GetInteractiveRunner(); runner != nil { + return runner.IsProcessReadyOrPending(execution.PassthroughProcessID) + } + return false + } + // Probe agentctl status to verify the agent process is running if execution.agentctl == nil { return false diff --git a/apps/backend/internal/agent/lifecycle/manager_interaction_test.go b/apps/backend/internal/agent/lifecycle/manager_interaction_test.go index 8a1e283839..3109a6c0d1 100644 --- a/apps/backend/internal/agent/lifecycle/manager_interaction_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_interaction_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/require" "github.com/kandev/kandev/internal/agent/executor" + "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -338,6 +339,177 @@ func TestManager_RestartAgentProcess_SessionInitFailure(t *testing.T) { } } +// --- IsAgentRunningForSession tests --- + +// mockExecutorWithRunner implements ExecutorBackend and returns a real InteractiveRunner. +type mockExecutorWithRunner struct { + MockExecutor + runner *process.InteractiveRunner +} + +func (m *mockExecutorWithRunner) GetInteractiveRunner() *process.InteractiveRunner { + return m.runner +} + +func TestIsAgentRunningForSession(t *testing.T) { + t.Run("no execution returns false", func(t *testing.T) { + store := NewExecutionStore() + mgr := &Manager{executionStore: store, logger: newTestLogger().WithFields()} + require.False(t, mgr.IsAgentRunningForSession(context.Background(), "nonexistent")) + }) + + t.Run("passthrough with alive process returns true", func(t *testing.T) { + log := newTestLogger() + runner := process.NewInteractiveRunner(nil, log, 2*1024*1024) + + // Start a deferred process (pending but alive) + info, err := runner.Start(context.Background(), process.InteractiveStartRequest{ + SessionID: "session-pt", + Command: []string{"cat"}, + }) + require.NoError(t, err) + + store := NewExecutionStore() + store.Add(&AgentExecution{ + ID: "exec-pt", + SessionID: "session-pt", + PassthroughProcessID: info.ID, + Status: v1.AgentStatusRunning, + }) + + execRegistry := NewExecutorRegistry(log) + execRegistry.Register(&mockExecutorWithRunner{ + MockExecutor: MockExecutor{name: executor.NameStandalone}, + runner: runner, + }) + + mgr := &Manager{ + executionStore: store, + executorRegistry: execRegistry, + logger: log.WithFields(), + } + require.True(t, mgr.IsAgentRunningForSession(context.Background(), "session-pt")) + }) + + t.Run("passthrough with dead process returns false", func(t *testing.T) { + log := newTestLogger() + runner := process.NewInteractiveRunner(nil, log, 2*1024*1024) + + store := NewExecutionStore() + store.Add(&AgentExecution{ + ID: "exec-dead", + SessionID: "session-dead", + PassthroughProcessID: "nonexistent-process-id", + Status: v1.AgentStatusRunning, + }) + + execRegistry := NewExecutorRegistry(log) + execRegistry.Register(&mockExecutorWithRunner{ + MockExecutor: MockExecutor{name: executor.NameStandalone}, + runner: runner, + }) + + mgr := &Manager{ + executionStore: store, + executorRegistry: execRegistry, + logger: log.WithFields(), + } + require.False(t, mgr.IsAgentRunningForSession(context.Background(), "session-dead")) + }) + + t.Run("passthrough with nil executor registry returns false", func(t *testing.T) { + store := NewExecutionStore() + store.Add(&AgentExecution{ + ID: "exec-noreg", + SessionID: "session-noreg", + PassthroughProcessID: "some-process-id", + Status: v1.AgentStatusRunning, + }) + + mgr := &Manager{ + executionStore: store, + logger: newTestLogger().WithFields(), + } + require.False(t, mgr.IsAgentRunningForSession(context.Background(), "session-noreg")) + }) + + t.Run("ACP execution with nil agentctl returns false", func(t *testing.T) { + store := NewExecutionStore() + store.Add(&AgentExecution{ + ID: "exec-acp-nil", + SessionID: "session-acp-nil", + Status: v1.AgentStatusRunning, + // No PassthroughProcessID → ACP path + // No agentctl → returns false + }) + + mgr := &Manager{ + executionStore: store, + logger: newTestLogger().WithFields(), + } + require.False(t, mgr.IsAgentRunningForSession(context.Background(), "session-acp-nil")) + }) + + t.Run("ACP execution with running agent returns true", func(t *testing.T) { + // Mock agentctl server that returns "running" status + statusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/status" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"agent_status":"running"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(statusServer.Close) + + client := createTestClient(t, statusServer.URL) + t.Cleanup(client.Close) + + store := NewExecutionStore() + store.Add(&AgentExecution{ + ID: "exec-acp-running", + SessionID: "session-acp-running", + Status: v1.AgentStatusRunning, + agentctl: client, + }) + + mgr := &Manager{ + executionStore: store, + logger: newTestLogger().WithFields(), + } + require.True(t, mgr.IsAgentRunningForSession(context.Background(), "session-acp-running")) + }) + + t.Run("ACP execution with stopped agent returns false", func(t *testing.T) { + statusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/status" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"agent_status":"stopped"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(statusServer.Close) + + client := createTestClient(t, statusServer.URL) + t.Cleanup(client.Close) + + store := NewExecutionStore() + store.Add(&AgentExecution{ + ID: "exec-acp-stopped", + SessionID: "session-acp-stopped", + Status: v1.AgentStatusRunning, + agentctl: client, + }) + + mgr := &Manager{ + executionStore: store, + logger: newTestLogger().WithFields(), + } + require.False(t, mgr.IsAgentRunningForSession(context.Background(), "session-acp-stopped")) + }) +} + // --- IsRemoteSession tests --- type mockWorkspaceInfoProvider struct { diff --git a/apps/backend/internal/agent/lifecycle/manager_passthrough.go b/apps/backend/internal/agent/lifecycle/manager_passthrough.go index 15528dca04..46fec5d2a0 100644 --- a/apps/backend/internal/agent/lifecycle/manager_passthrough.go +++ b/apps/backend/internal/agent/lifecycle/manager_passthrough.go @@ -486,11 +486,11 @@ func (m *Manager) handlePassthroughExit(execution *AgentExecution, status *agent const restartDelay = 500 * time.Millisecond const cleanupDelay = 100 * time.Millisecond // Wait for old process cleanup + sessionID := execution.SessionID + // Wait a bit for the old process to be cleaned up from the process map time.Sleep(cleanupDelay) - sessionID := execution.SessionID - interactiveRunner := m.GetInteractiveRunner() if interactiveRunner == nil { m.logger.Debug("no interactive runner available for auto-restart", diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index d123ec8250..1e3972f42e 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -91,7 +91,7 @@ function SectionHeader({ label, count }: { label: string; count: number }) { return (
{icon && }