Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions pkg/board/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions pkg/board/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package board
import (
"context"
"errors"
"os"
"path/filepath"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -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)
}
30 changes: 24 additions & 6 deletions pkg/board/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions pkg/board/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
4 changes: 2 additions & 2 deletions pkg/board/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading