fix: passthrough PTY process survives page refresh (#353)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}`} />}
|
||||
|
||||
Reference in New Issue
Block a user