Skip to content

Commit 7b44140

Browse files
authored
Merge pull request #3516 from dgageot/worktree-board-a88649158fa61dbc
feat(board): show intermediate startup statuses on cards
2 parents 7243da9 + feeabae commit 7b44140

5 files changed

Lines changed: 143 additions & 12 deletions

File tree

pkg/board/controller.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,10 @@ func (c *controller) watch(ctx context.Context, cardID string) {
242242
} else {
243243
c.resume(card)
244244
}
245+
} else if card.Status.StartingUp() {
246+
// The agent is up but has not answered yet: report how far
247+
// its startup got, so a stuck launch shows where it stopped.
248+
c.setStatus(cardID, startupPhase(card))
245249
}
246250
if sleep(ctx, delay) {
247251
return
@@ -255,14 +259,15 @@ func (c *controller) watch(ctx context.Context, cardID string) {
255259
}
256260

257261
// The control plane answers: the agent has started. If the card is
258-
// still marked starting, default to waiting; the event replay below
259-
// promptly corrects it if a turn is already underway. Checking the
260-
// loop-top read is safe: this watcher is the only status writer.
262+
// still in a startup phase, default to waiting; the event replay
263+
// below promptly corrects it if a turn is already underway. Checking
264+
// the loop-top read is safe: besides this watcher, the only other
265+
// status writer (relaunch) writes StatusStarting, a startup phase.
261266
// Exception: a launch that carried an initial prompt is about to run
262267
// its first turn, so the card stays "starting" until stream_started
263268
// flips it to running — flashing "ready" before the first turn would
264269
// misreport when the card is really done.
265-
if card.Status == StatusStarting && !c.turnExpected(cardID) {
270+
if card.Status.StartingUp() && !c.turnExpected(cardID) {
266271
c.setStatus(cardID, StatusWaiting)
267272
}
268273

@@ -385,6 +390,21 @@ func (c *controller) watch(ctx context.Context, cardID string) {
385390
}
386391
}
387392

393+
// startupPhase derives how far a launching agent got from the milestones it
394+
// materializes on disk: the worktree first, then the control-plane socket.
395+
// relaunch removes the stale socket before starting, so within one launch
396+
// the phase only moves forward (a watcher racing a concurrent relaunch may
397+
// report a phase one poll stale, which the next poll corrects).
398+
func startupPhase(card *Card) CardStatus {
399+
if _, err := os.Stat(socketPath(card.AgentSession)); err == nil {
400+
return StatusAttaching
401+
}
402+
if _, err := os.Stat(card.Worktree); err == nil {
403+
return StatusLoading
404+
}
405+
return StatusStarting
406+
}
407+
388408
// resume relaunches the card's session in the background recovery paths
389409
// (dead pane, session_exited). A session that cannot even be recreated
390410
// (tmux failure, missing worktree…) is surfaced as an errored card rather

pkg/board/controller_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package board
33
import (
44
"context"
55
"errors"
6+
"os"
67
"path/filepath"
78
"sync/atomic"
89
"testing"
@@ -510,3 +511,81 @@ func TestTeardownForgetsControllerState(t *testing.T) {
510511
require.NoError(t, c.LaunchError("c1"))
511512
assert.Equal(t, 1, c.launchFailed("c1"), "failure count should have been dropped")
512513
}
514+
515+
func TestStartupPhase(t *testing.T) {
516+
t.Parallel()
517+
518+
// The socket dir is per-user and process-global: use a unique session
519+
// id so parallel tests cannot collide.
520+
session := "phase-" + newID()
521+
card := &Card{AgentSession: session, Worktree: filepath.Join(t.TempDir(), "wt")}
522+
523+
// Nothing on disk yet: the agent process is still booting.
524+
assert.Equal(t, StatusStarting, startupPhase(card))
525+
526+
// The worktree appeared: the agent is loading models and tools.
527+
require.NoError(t, os.MkdirAll(card.Worktree, 0o755))
528+
assert.Equal(t, StatusLoading, startupPhase(card))
529+
530+
// The control-plane socket is bound: the board is attaching.
531+
socket := socketPath(session)
532+
require.NoError(t, os.WriteFile(socket, nil, 0o600))
533+
t.Cleanup(func() { _ = os.Remove(socket) })
534+
assert.Equal(t, StatusAttaching, startupPhase(card))
535+
}
536+
537+
// TestControllerStartupPhaseProgression proves the watcher surfaces the
538+
// startup milestones of a live agent whose control plane has not answered
539+
// yet, so a stuck launch shows how far it got.
540+
func TestControllerStartupPhaseProgression(t *testing.T) {
541+
t.Parallel()
542+
543+
session := "progress-" + newID()
544+
store := testStore(t)
545+
wt := filepath.Join(t.TempDir(), "wt")
546+
require.NoError(t, store.InsertCard(&Card{ID: "c1", Column: "dev", Status: StatusStarting, Session: "s", AgentSession: session, Worktree: wt}))
547+
548+
ctx, cancel := context.WithCancel(t.Context())
549+
t.Cleanup(cancel)
550+
551+
// The pane is alive but the control plane never answers.
552+
c := newController(ctx, store, fakeSessions{}, func() {})
553+
c.clientFor = func(_, _ string) sessionClient { return downClient{} }
554+
card, err := store.GetCard("c1")
555+
require.NoError(t, err)
556+
c.Start(card)
557+
t.Cleanup(func() { c.Stop("c1") })
558+
559+
require.NoError(t, os.MkdirAll(wt, 0o755))
560+
waitForStatus(t, store, StatusLoading)
561+
562+
socket := socketPath(session)
563+
require.NoError(t, os.WriteFile(socket, nil, 0o600))
564+
t.Cleanup(func() { _ = os.Remove(socket) })
565+
waitForStatus(t, store, StatusAttaching)
566+
}
567+
568+
// TestControllerNoDowngradeToStartupPhase proves a card mid-turn is not
569+
// demoted to a startup phase when its control plane is transiently
570+
// unreachable while the pane is still alive.
571+
func TestControllerNoDowngradeToStartupPhase(t *testing.T) {
572+
t.Parallel()
573+
574+
store := testStore(t)
575+
require.NoError(t, store.InsertCard(&Card{ID: "c1", Column: "dev", Status: StatusRunning, Session: "s", Worktree: t.TempDir()}))
576+
577+
ctx, cancel := context.WithCancel(t.Context())
578+
t.Cleanup(cancel)
579+
580+
c := newController(ctx, store, fakeSessions{}, func() {})
581+
c.clientFor = func(_, _ string) sessionClient { return downClient{} }
582+
card, err := store.GetCard("c1")
583+
require.NoError(t, err)
584+
c.Start(card)
585+
t.Cleanup(func() { c.Stop("c1") })
586+
587+
assert.Never(t, func() bool {
588+
card, err := store.GetCard("c1")
589+
return err == nil && card.Status != StatusRunning
590+
}, 300*time.Millisecond, 10*time.Millisecond)
591+
}

pkg/board/model.go

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,24 @@ func ColumnsFromConfig(cols []userconfig.BoardColumn) []Column {
5050
type CardStatus string
5151

5252
const (
53-
// StatusStarting marks a card whose agent is launching but has not yet
54-
// answered on its control plane. The watcher replaces it with a real
55-
// status as soon as the agent emits events.
53+
// StatusStarting, StatusLoading, and StatusAttaching are the startup
54+
// phases, in launch order. The agent has not answered on its control
55+
// plane yet; the watcher refines the phase from the milestones the
56+
// agent materializes on disk, so a stuck launch shows how far it got,
57+
// and replaces it with a real status as soon as the agent emits events.
58+
//
59+
// StatusStarting: the tmux session is created and the agent process is
60+
// booting; it has not created the card's worktree yet.
5661
StatusStarting CardStatus = "starting"
57-
StatusRunning CardStatus = "running"
58-
StatusWaiting CardStatus = "waiting"
62+
// StatusLoading: the worktree exists, so the agent is loading its
63+
// configuration, models, and tools.
64+
StatusLoading CardStatus = "loading"
65+
// StatusAttaching: the control-plane socket is bound; the board is
66+
// waiting for the agent to answer its first snapshot.
67+
StatusAttaching CardStatus = "attaching"
68+
69+
StatusRunning CardStatus = "running"
70+
StatusWaiting CardStatus = "waiting"
5971
// StatusPaused marks a card whose turn is blocked on /pause. It lasts
6072
// until the runtime emits events again (resume) or the turn ends.
6173
StatusPaused CardStatus = "paused"
@@ -64,10 +76,16 @@ const (
6476
StatusError CardStatus = "error"
6577
)
6678

79+
// StartingUp reports whether the card is in a startup phase: its agent was
80+
// launched but its control plane has not answered yet.
81+
func (s CardStatus) StartingUp() bool {
82+
return s == StatusStarting || s == StatusLoading || s == StatusAttaching
83+
}
84+
6785
// Busy reports whether the card's agent cannot accept a prompt right now: it
6886
// is either still starting or in the middle of a turn.
6987
func (s CardStatus) Busy() bool {
70-
return s == StatusStarting || s == StatusRunning
88+
return s.StartingUp() || s == StatusRunning
7189
}
7290

7391
// Card is one task on the board.

pkg/board/model_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,26 @@ func TestCardStatusBusy(t *testing.T) {
5757
t.Parallel()
5858

5959
assert.True(t, StatusStarting.Busy())
60+
assert.True(t, StatusLoading.Busy())
61+
assert.True(t, StatusAttaching.Busy())
6062
assert.True(t, StatusRunning.Busy())
6163
assert.False(t, StatusWaiting.Busy())
6264
assert.False(t, StatusPaused.Busy())
6365
assert.False(t, StatusError.Busy())
6466
}
6567

68+
func TestCardStatusStartingUp(t *testing.T) {
69+
t.Parallel()
70+
71+
assert.True(t, StatusStarting.StartingUp())
72+
assert.True(t, StatusLoading.StartingUp())
73+
assert.True(t, StatusAttaching.StartingUp())
74+
assert.False(t, StatusRunning.StartingUp())
75+
assert.False(t, StatusWaiting.StartingUp())
76+
assert.False(t, StatusPaused.StartingUp())
77+
assert.False(t, StatusError.StartingUp())
78+
}
79+
6680
func TestNewWorktreeName(t *testing.T) {
6781
t.Parallel()
6882

pkg/board/tui/view.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,8 +393,8 @@ func splitTitle(title string, width int) (string, string) {
393393
func (m *model) renderStatus(status board.CardStatus, width int) string {
394394
spinner := spinnerFrames[m.frame%len(spinnerFrames)]
395395
switch status {
396-
case board.StatusStarting:
397-
return styles.WarningStyle.Render(toolcommon.TruncateText(spinner+" starting", width))
396+
case board.StatusStarting, board.StatusLoading, board.StatusAttaching:
397+
return styles.WarningStyle.Render(toolcommon.TruncateText(spinner+" "+string(status), width))
398398
case board.StatusRunning:
399399
return styles.InfoStyle.Render(toolcommon.TruncateText(spinner+" running", width))
400400
case board.StatusPaused:

0 commit comments

Comments
 (0)