M2: controller async coach with generation guard and ephemeral state
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"antidrift/internal/ai"
|
||||
"antidrift/internal/domain"
|
||||
"antidrift/internal/evidence"
|
||||
"antidrift/internal/store"
|
||||
@@ -235,3 +238,117 @@ func TestEndWritesAuditSummary(t *testing.T) {
|
||||
t.Fatalf("chain should verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCoach struct {
|
||||
prop ai.Proposal
|
||||
err error
|
||||
gate chan struct{} // if non-nil, Coach blocks until it receives
|
||||
}
|
||||
|
||||
func (f *fakeCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) {
|
||||
if f.gate != nil {
|
||||
<-f.gate
|
||||
}
|
||||
return f.prop, f.err
|
||||
}
|
||||
|
||||
// waitCoachStatus polls until the coach view reaches want, or fails after 2s.
|
||||
func waitCoachStatus(t *testing.T, c *Controller, want string) State {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
st := c.State()
|
||||
if st.Coach != nil && st.Coach.Status == want {
|
||||
return st
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("coach status never reached %q (last: %+v)", want, c.State().Coach)
|
||||
return State{}
|
||||
}
|
||||
|
||||
func TestRequestCoachReady(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
_ = c.EnterPlanning()
|
||||
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "X done", TimeboxSecs: 1500}})
|
||||
if err := c.RequestCoach("do x"); err != nil {
|
||||
t.Fatalf("request coach: %v", err)
|
||||
}
|
||||
st := waitCoachStatus(t, c, "ready")
|
||||
if st.Coach.Proposal == nil || st.Coach.Proposal.NextAction != "Do X" || st.Coach.Proposal.TimeboxSecs != 1500 {
|
||||
t.Fatalf("ready proposal wrong: %+v", st.Coach)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestCoachError(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
_ = c.EnterPlanning()
|
||||
c.SetCoach(&fakeCoach{err: errors.New("backend down")})
|
||||
if err := c.RequestCoach("x"); err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
st := waitCoachStatus(t, c, "error")
|
||||
if st.Coach.Error == "" {
|
||||
t.Fatal("error status should carry a message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestCoachUnavailable(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
_ = c.EnterPlanning()
|
||||
if err := c.RequestCoach("x"); err != nil {
|
||||
t.Fatalf("nil coach must degrade, not error: %v", err)
|
||||
}
|
||||
st := c.State()
|
||||
if st.Coach == nil || st.Coach.Status != "error" {
|
||||
t.Fatalf("want error status for nil coach, got %+v", st.Coach)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestCoachWrongState(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
|
||||
if err := c.RequestCoach("x"); err == nil {
|
||||
t.Fatal("coaching from Locked should error")
|
||||
}
|
||||
if c.State().Coach != nil {
|
||||
t.Fatal("no coach view outside planning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestCoachStaleResultDiscarded(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
_ = c.EnterPlanning()
|
||||
|
||||
slow := &fakeCoach{prop: ai.Proposal{NextAction: "OLD", SuccessCondition: "old", TimeboxSecs: 60}, gate: make(chan struct{})}
|
||||
c.SetCoach(slow)
|
||||
_ = c.RequestCoach("first") // gen1 pending, goroutine blocks on gate
|
||||
|
||||
fast := &fakeCoach{prop: ai.Proposal{NextAction: "NEW", SuccessCondition: "new", TimeboxSecs: 120}}
|
||||
c.SetCoach(fast)
|
||||
_ = c.RequestCoach("second") // gen2 -> ready NEW
|
||||
st := waitCoachStatus(t, c, "ready")
|
||||
if st.Coach.Proposal.NextAction != "NEW" {
|
||||
t.Fatalf("expected NEW, got %+v", st.Coach.Proposal)
|
||||
}
|
||||
|
||||
close(slow.gate) // release gen1; it must be discarded
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := c.State().Coach.Proposal.NextAction; got != "NEW" {
|
||||
t.Fatalf("stale gen1 overwrote state: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeavingPlanningClearsCoach(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
_ = c.EnterPlanning()
|
||||
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
|
||||
_ = c.RequestCoach("x")
|
||||
waitCoachStatus(t, c, "ready")
|
||||
if err := c.StartManualCommitment("a", "b", 25*time.Minute); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
if c.State().Coach != nil {
|
||||
t.Fatal("coach view must be gone once Active")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user