diff --git a/pkg/board/controller.go b/pkg/board/controller.go index e3998f132a..211c324527 100644 --- a/pkg/board/controller.go +++ b/pkg/board/controller.go @@ -242,6 +242,10 @@ func (c *controller) watch(ctx context.Context, cardID string) { } else { c.resume(card) } + } else if card.Status.StartingUp() { + // The agent is up but has not answered yet: report how far + // its startup got, so a stuck launch shows where it stopped. + c.setStatus(cardID, startupPhase(card)) } if sleep(ctx, delay) { return @@ -255,14 +259,15 @@ func (c *controller) watch(ctx context.Context, cardID string) { } // The control plane answers: the agent has started. If the card is - // still marked starting, default to waiting; the event replay below - // promptly corrects it if a turn is already underway. Checking the - // loop-top read is safe: this watcher is the only status writer. + // still in a startup phase, default to waiting; the event replay + // below promptly corrects it if a turn is already underway. Checking + // the loop-top read is safe: besides this watcher, the only other + // status writer (relaunch) writes StatusStarting, a startup phase. // Exception: a launch that carried an initial prompt is about to run // its first turn, so the card stays "starting" until stream_started // flips it to running — flashing "ready" before the first turn would // misreport when the card is really done. - if card.Status == StatusStarting && !c.turnExpected(cardID) { + if card.Status.StartingUp() && !c.turnExpected(cardID) { c.setStatus(cardID, StatusWaiting) } @@ -385,6 +390,21 @@ func (c *controller) watch(ctx context.Context, cardID string) { } } +// startupPhase derives how far a launching agent got from the milestones it +// materializes on disk: the worktree first, then the control-plane socket. +// relaunch removes the stale socket before starting, so within one launch +// the phase only moves forward (a watcher racing a concurrent relaunch may +// report a phase one poll stale, which the next poll corrects). +func startupPhase(card *Card) CardStatus { + if _, err := os.Stat(socketPath(card.AgentSession)); err == nil { + return StatusAttaching + } + if _, err := os.Stat(card.Worktree); err == nil { + return StatusLoading + } + return StatusStarting +} + // resume relaunches the card's session in the background recovery paths // (dead pane, session_exited). A session that cannot even be recreated // (tmux failure, missing worktree…) is surfaced as an errored card rather diff --git a/pkg/board/controller_test.go b/pkg/board/controller_test.go index ecdd3acea6..aa5c70013d 100644 --- a/pkg/board/controller_test.go +++ b/pkg/board/controller_test.go @@ -3,6 +3,7 @@ package board import ( "context" "errors" + "os" "path/filepath" "sync/atomic" "testing" @@ -510,3 +511,81 @@ func TestTeardownForgetsControllerState(t *testing.T) { require.NoError(t, c.LaunchError("c1")) assert.Equal(t, 1, c.launchFailed("c1"), "failure count should have been dropped") } + +func TestStartupPhase(t *testing.T) { + t.Parallel() + + // The socket dir is per-user and process-global: use a unique session + // id so parallel tests cannot collide. + session := "phase-" + newID() + card := &Card{AgentSession: session, Worktree: filepath.Join(t.TempDir(), "wt")} + + // Nothing on disk yet: the agent process is still booting. + assert.Equal(t, StatusStarting, startupPhase(card)) + + // The worktree appeared: the agent is loading models and tools. + require.NoError(t, os.MkdirAll(card.Worktree, 0o755)) + assert.Equal(t, StatusLoading, startupPhase(card)) + + // The control-plane socket is bound: the board is attaching. + socket := socketPath(session) + require.NoError(t, os.WriteFile(socket, nil, 0o600)) + t.Cleanup(func() { _ = os.Remove(socket) }) + assert.Equal(t, StatusAttaching, startupPhase(card)) +} + +// TestControllerStartupPhaseProgression proves the watcher surfaces the +// startup milestones of a live agent whose control plane has not answered +// yet, so a stuck launch shows how far it got. +func TestControllerStartupPhaseProgression(t *testing.T) { + t.Parallel() + + session := "progress-" + newID() + store := testStore(t) + wt := filepath.Join(t.TempDir(), "wt") + require.NoError(t, store.InsertCard(&Card{ID: "c1", Column: "dev", Status: StatusStarting, Session: "s", AgentSession: session, Worktree: wt})) + + ctx, cancel := context.WithCancel(t.Context()) + t.Cleanup(cancel) + + // The pane is alive but the control plane never answers. + c := newController(ctx, store, fakeSessions{}, func() {}) + c.clientFor = func(_, _ string) sessionClient { return downClient{} } + card, err := store.GetCard("c1") + require.NoError(t, err) + c.Start(card) + t.Cleanup(func() { c.Stop("c1") }) + + require.NoError(t, os.MkdirAll(wt, 0o755)) + waitForStatus(t, store, StatusLoading) + + socket := socketPath(session) + require.NoError(t, os.WriteFile(socket, nil, 0o600)) + t.Cleanup(func() { _ = os.Remove(socket) }) + waitForStatus(t, store, StatusAttaching) +} + +// TestControllerNoDowngradeToStartupPhase proves a card mid-turn is not +// demoted to a startup phase when its control plane is transiently +// unreachable while the pane is still alive. +func TestControllerNoDowngradeToStartupPhase(t *testing.T) { + t.Parallel() + + store := testStore(t) + require.NoError(t, store.InsertCard(&Card{ID: "c1", Column: "dev", Status: StatusRunning, Session: "s", Worktree: t.TempDir()})) + + ctx, cancel := context.WithCancel(t.Context()) + t.Cleanup(cancel) + + c := newController(ctx, store, fakeSessions{}, func() {}) + c.clientFor = func(_, _ string) sessionClient { return downClient{} } + card, err := store.GetCard("c1") + require.NoError(t, err) + c.Start(card) + t.Cleanup(func() { c.Stop("c1") }) + + assert.Never(t, func() bool { + card, err := store.GetCard("c1") + return err == nil && card.Status != StatusRunning + }, 300*time.Millisecond, 10*time.Millisecond) +} diff --git a/pkg/board/model.go b/pkg/board/model.go index b29a1c3476..782744f6ad 100644 --- a/pkg/board/model.go +++ b/pkg/board/model.go @@ -50,12 +50,24 @@ func ColumnsFromConfig(cols []userconfig.BoardColumn) []Column { type CardStatus string const ( - // StatusStarting marks a card whose agent is launching but has not yet - // answered on its control plane. The watcher replaces it with a real - // status as soon as the agent emits events. + // StatusStarting, StatusLoading, and StatusAttaching are the startup + // phases, in launch order. The agent has not answered on its control + // plane yet; the watcher refines the phase from the milestones the + // agent materializes on disk, so a stuck launch shows how far it got, + // and replaces it with a real status as soon as the agent emits events. + // + // StatusStarting: the tmux session is created and the agent process is + // booting; it has not created the card's worktree yet. StatusStarting CardStatus = "starting" - StatusRunning CardStatus = "running" - StatusWaiting CardStatus = "waiting" + // StatusLoading: the worktree exists, so the agent is loading its + // configuration, models, and tools. + StatusLoading CardStatus = "loading" + // StatusAttaching: the control-plane socket is bound; the board is + // waiting for the agent to answer its first snapshot. + StatusAttaching CardStatus = "attaching" + + StatusRunning CardStatus = "running" + StatusWaiting CardStatus = "waiting" // StatusPaused marks a card whose turn is blocked on /pause. It lasts // until the runtime emits events again (resume) or the turn ends. StatusPaused CardStatus = "paused" @@ -64,10 +76,16 @@ const ( StatusError CardStatus = "error" ) +// StartingUp reports whether the card is in a startup phase: its agent was +// launched but its control plane has not answered yet. +func (s CardStatus) StartingUp() bool { + return s == StatusStarting || s == StatusLoading || s == StatusAttaching +} + // Busy reports whether the card's agent cannot accept a prompt right now: it // is either still starting or in the middle of a turn. func (s CardStatus) Busy() bool { - return s == StatusStarting || s == StatusRunning + return s.StartingUp() || s == StatusRunning } // Card is one task on the board. diff --git a/pkg/board/model_test.go b/pkg/board/model_test.go index e1fa3be8c2..4df8e806f4 100644 --- a/pkg/board/model_test.go +++ b/pkg/board/model_test.go @@ -57,12 +57,26 @@ func TestCardStatusBusy(t *testing.T) { t.Parallel() assert.True(t, StatusStarting.Busy()) + assert.True(t, StatusLoading.Busy()) + assert.True(t, StatusAttaching.Busy()) assert.True(t, StatusRunning.Busy()) assert.False(t, StatusWaiting.Busy()) assert.False(t, StatusPaused.Busy()) assert.False(t, StatusError.Busy()) } +func TestCardStatusStartingUp(t *testing.T) { + t.Parallel() + + assert.True(t, StatusStarting.StartingUp()) + assert.True(t, StatusLoading.StartingUp()) + assert.True(t, StatusAttaching.StartingUp()) + assert.False(t, StatusRunning.StartingUp()) + assert.False(t, StatusWaiting.StartingUp()) + assert.False(t, StatusPaused.StartingUp()) + assert.False(t, StatusError.StartingUp()) +} + func TestNewWorktreeName(t *testing.T) { t.Parallel() diff --git a/pkg/board/tui/view.go b/pkg/board/tui/view.go index 0b58d6503e..d81e56275a 100644 --- a/pkg/board/tui/view.go +++ b/pkg/board/tui/view.go @@ -393,8 +393,8 @@ func splitTitle(title string, width int) (string, string) { func (m *model) renderStatus(status board.CardStatus, width int) string { spinner := spinnerFrames[m.frame%len(spinnerFrames)] switch status { - case board.StatusStarting: - return styles.WarningStyle.Render(toolcommon.TruncateText(spinner+" starting", width)) + case board.StatusStarting, board.StatusLoading, board.StatusAttaching: + return styles.WarningStyle.Render(toolcommon.TruncateText(spinner+" "+string(status), width)) case board.StatusRunning: return styles.InfoStyle.Render(toolcommon.TruncateText(spinner+" running", width)) case board.StatusPaused: