fix: passthrough PTY process survives page refresh (#353)

This commit is contained in:
Carlos Florêncio
2026-03-02 23:30:56 +00:00
committed by GitHub
parent cd96c6f699
commit c8a692022a
4 changed files with 190 additions and 7 deletions
@@ -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
@@ -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 {
@@ -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",
+1 -1
View File
@@ -91,7 +91,7 @@ function SectionHeader({ label, count }: { label: string; count: number }) {
return (
<div
data-testid={`sidebar-section-${label}`}
className="flex items-center justify-between px-3 py-1.5 bg-foreground/[0.03]"
className="flex items-center justify-between px-3 py-1.5 bg-background"
>
<span className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{icon && <icon.Icon className={`h-3 w-3 ${icon.className}`} />}