@@ -3,6 +3,7 @@ package board
33import (
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+ }
0 commit comments