diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go index 6dc773ed68..1d690f0623 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go @@ -943,9 +943,28 @@ func (m *Manager) resumePassthroughCommand(ctx context.Context, execution *Agent // without --resume, effectively clearing the agent's conversation context. // The workflow step prompt is delivered afterwards via stdin (autoStartPassthroughPrompt). func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *AgentExecution) error { + interactiveRunner := m.GetInteractiveRunner() + if interactiveRunner == nil { + return fmt.Errorf("interactive runner not available") + } execution.passthroughLifecycleMu.Lock() - defer execution.passthroughLifecycleMu.Unlock() + processID, err := m.replacePassthroughProcess(ctx, execution, interactiveRunner) + execution.passthroughLifecycleMu.Unlock() + if err != nil { + return err + } + // First-idle callbacks acquire the lifecycle lock. Settle startup outside + // that lock before a workflow prompt can claim the next running turn. + readyCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + if err := interactiveRunner.WaitForFirstIdle(readyCtx, processID); err != nil { + return fmt.Errorf("wait for restarted passthrough readiness: %w", err) + } + return nil +} + +func (m *Manager) replacePassthroughProcess(ctx context.Context, execution *AgentExecution, interactiveRunner *process.InteractiveRunner) (string, error) { m.logger.Info("restarting passthrough process for context reset", zap.String("execution_id", execution.ID), zap.String("session_id", execution.SessionID), @@ -953,19 +972,14 @@ func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *Agen // 1. Resolve the replacement command and environment before touching the // current PTY. A profile-secret failure must leave the active session usable. - interactiveRunner := m.GetInteractiveRunner() - if interactiveRunner == nil { - return fmt.Errorf("interactive runner not available") - } - // Build fresh command (no SessionID, no Resume, no Prompt). pt, rt, cmd, err := m.freshPassthroughCommand(ctx, execution) if err != nil { - return err + return "", err } env, err := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv) if err != nil { - return err + return "", err } // 2. Stop the current PTY process. Clear PassthroughProcessID before @@ -989,7 +1003,7 @@ func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *Agen processInfo, err := interactiveRunner.Start(ctx, startReq) if err != nil { m.updateExecutionError(execution.ID, "failed to restart passthrough session: "+err.Error()) - return fmt.Errorf("failed to restart passthrough session: %w", err) + return "", fmt.Errorf("failed to restart passthrough session: %w", err) } // 4. Update execution with new process ID @@ -1013,7 +1027,7 @@ func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *Agen // 6. Publish context reset event m.eventPublisher.PublishAgentEvent(ctx, events.AgentContextReset, execution) - return nil + return processInfo.ID, nil } // ResumePassthroughSession restarts a passthrough session after backend restart. diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_reset_ready_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_reset_ready_test.go new file mode 100644 index 0000000000..78d18ef5ad --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_reset_ready_test.go @@ -0,0 +1,81 @@ +package lifecycle + +import ( + "context" + "runtime" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/events" + eventbus "github.com/kandev/kandev/internal/events/bus" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +func newPassthroughResetReadyTest(t *testing.T, command string) (*Manager, *process.InteractiveRunner, *AgentExecution) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("requires POSIX shell prompt output") + } + mgr, runner, execution := newPassthroughRunnerManager(t) + const agentID = "reset-ready-test-agent" + require.NoError(t, mgr.registry.Register(&testAgent{ + id: agentID, enabled: true, + StandardPassthrough: agents.StandardPassthrough{Cfg: agents.PassthroughConfig{ + Supported: true, PassthroughCmd: agents.NewCommand("sh", "-c", command), + PromptPattern: "READY> $", IdleTimeout: time.Minute, SubmitSequence: "\r", + }}, + })) + mgr.profileResolver = &mockPassthroughProfileResolver{agentName: agentID, cliPassthrough: true} + execution.AgentID = agentID + execution.Status = v1.AgentStatusReady + t.Cleanup(func() { + _ = runner.Stop(context.Background(), execution.PassthroughProcessID) + }) + return mgr, runner, execution +} + +func TestRestartPassthroughProcessSettlesStartupBeforeNextTurn(t *testing.T) { + mgr, runner, execution := newPassthroughResetReadyTest(t, "printf 'READY> '; read -r input") + var startupSettled atomic.Bool + runner.SetTurnCompleteCallback(func(sessionID, processID string) { + mgr.handlePassthroughTurnComplete(sessionID, processID) + startupSettled.Store(true) + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + require.NoError(t, mgr.restartPassthroughProcess(ctx, execution)) + require.True(t, startupSettled.Load(), "reset must settle boot readiness before admitting a prompt") + require.NoError(t, mgr.MarkPassthroughRunning(execution.SessionID)) + mgr.handlePassthroughTurnComplete(execution.SessionID, execution.PassthroughProcessID) + require.Equal(t, v1.AgentStatusReady, execution.Status) + bus := mgr.eventBus.(*MockEventBus) + var readyCount int + for _, event := range bus.PublishedEvents { + if event.Type == events.AgentReady { + readyCount++ + } + } + require.Equal(t, 1, readyCount, "the next turn must retain its completion event") +} + +func TestRestartPassthroughProcessCancelsStartupWait(t *testing.T) { + mgr, _, execution := newPassthroughResetReadyTest(t, "read -r input") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + bus := mgr.eventBus.(*MockEventBus) + bus.OnPublish = func(subject string, _ *eventbus.Event) { + if subject == events.AgentContextReset { + cancel() + } + } + + err := mgr.restartPassthroughProcess(ctx, execution) + + require.ErrorIs(t, err, context.Canceled) +} diff --git a/apps/web/components/system-metrics/status-surface-metrics.test.tsx b/apps/web/components/system-metrics/status-surface-metrics.test.tsx index 09ba1780ac..58cdd19725 100644 --- a/apps/web/components/system-metrics/status-surface-metrics.test.tsx +++ b/apps/web/components/system-metrics/status-surface-metrics.test.tsx @@ -178,15 +178,13 @@ describe("StatusSurfaceMetrics", () => { expect(screen.queryByTestId(metricsTestId)).toBeNull(); }); - it("subscribes and renders rows when the phone Status drawer opens", () => { + it("subscribes and renders host metrics when the phone Status drawer opens", () => { responsiveState.isMobile = true; renderMetrics(true); expect(subscribeMock).toHaveBeenCalledWith(true); - const metricsRow = screen.getByLabelText("Host metrics").parentElement; - expect(metricsRow?.className).toContain("min-h-11"); - expect(metricsRow?.className).toContain("w-full"); - expect(metricsRow?.className).toContain("min-w-0"); + expect(screen.getByLabelText("Host metrics")).toBeTruthy(); + expect(screen.getByLabelText(cpuAccessibleName)).toBeTruthy(); }); it("renders every received host metric in the phone Status drawer", () => { diff --git a/apps/web/components/system-metrics/status-surface-metrics.tsx b/apps/web/components/system-metrics/status-surface-metrics.tsx index 9e27af16c6..78eab93652 100644 --- a/apps/web/components/system-metrics/status-surface-metrics.tsx +++ b/apps/web/components/system-metrics/status-surface-metrics.tsx @@ -18,6 +18,7 @@ import { useAppStore } from "@/components/state-provider"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useSystemMetricsSubscription } from "@/hooks/use-system-metrics-subscription"; import type { SystemMetricSample, SystemMetricsSource } from "@/lib/types/system"; +import { cn } from "@/lib/utils"; type StatusSurfaceMetricsProps = { presentation: "bar" | "mobile-drawer"; @@ -49,18 +50,29 @@ export function StatusSurfaceMetrics({ return (
-

- {t("system:systemMetrics")} -

+
+

+ {t("system:systemMetrics")} +

+ {host && !simplified ? ( + + ) : null} +
{!host ? ( ) : ( - @@ -144,35 +156,6 @@ function BarSourceMetrics({ ); } -function DrawerSourceMetrics({ - source, - updatedAt, - simplified, - iconSize, -}: { - source: SystemMetricsSource; - updatedAt?: string; - simplified: boolean; - iconSize: string; -}) { - return ( -
- {!simplified ? ( -
- -
- ) : null} - -
- ); -} - function SourceBadge({ source, updatedAt, @@ -239,12 +222,7 @@ function MetricValues({ /> )); if (layout === "grid") { - return ( - // Keep detailed cells compact enough for two columns in the Pixel 5 drawer. -
- {values} -
- ); + return
{values}
; } return {values}; } @@ -267,16 +245,34 @@ function MetricValue({ const { t } = useTranslation(); const locale = useDateLocale(); const help = metric.id === "io_load" ? t("system:ioLoadHelp") : null; + const detailedTile = layout === "grid" && !simplified; return ( - {metricIcon(metric.id, iconSize)} - {!simplified ? : null} - + {detailedTile ? ( + + {metricIcon(metric.id, iconSize)} + {metricLabel(t, metric.id)} + + ) : ( + metricIcon(metric.id, iconSize) + )} + {!simplified ? : null} + {formatMetric(metric)} @@ -324,16 +320,19 @@ function metricIcon(id: string, iconSize: string) { cpu_temp: IconFlame, io_load: IconGauge, }[id] ?? IconActivity; - return ; + return ; } -function MetricMeter({ metric }: { metric: SystemMetricSample }) { +function MetricMeter({ metric, wide = false }: { metric: SystemMetricSample; wide?: boolean }) { if (metric.unit !== "%" || typeof metric.value !== "number") return null; const width = `${Math.max(0, Math.min(100, metric.value))}%`; return (