From 463ac4f23c6b598804670cbfea00a51342ab2051 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 14:16:28 -0400 Subject: [PATCH] M2: controller async coach with generation guard and ephemeral state Co-Authored-By: Claude Opus 4.8 --- internal/session/session.go | 129 +++++++++++++++++++++++++++++++ internal/session/session_test.go | 117 ++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) diff --git a/internal/session/session.go b/internal/session/session.go index 43207a3..8e0e376 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -5,12 +5,15 @@ package session import ( + "context" + "errors" "log" "path/filepath" "sort" "sync" "time" + "antidrift/internal/ai" "antidrift/internal/domain" "antidrift/internal/evidence" "antidrift/internal/statemachine" @@ -24,6 +27,17 @@ const ( sessionRetention = 30 * 24 * time.Hour ) +const coachTimeout = 60 * time.Second + +const ( + coachIdle = "idle" + coachPending = "pending" + coachReady = "ready" + coachError = "error" +) + +var ErrNotPlanning = errors.New("session: coaching is only available while planning") + // bucketKey identifies a time bucket; Title is the scrubbed title. type bucketKey struct{ Class, Title string } @@ -53,6 +67,11 @@ type Controller struct { latestWindow evidence.WindowSnapshot stats *EvidenceStats outcomePending string + coach ai.Coach + coachStatus string + coachProposal *ai.Proposal + coachErr string + coachGen int } // CommitmentView is the UI projection of the active commitment. @@ -63,6 +82,19 @@ type CommitmentView struct { DeadlineUnixSecs int64 `json:"deadline_unix_secs"` } +// ProposalView / CoachView project the ephemeral planning-coach state. +type ProposalView struct { + NextAction string `json:"next_action"` + SuccessCondition string `json:"success_condition"` + TimeboxSecs int64 `json:"timebox_secs"` +} + +type CoachView struct { + Status string `json:"status"` + Proposal *ProposalView `json:"proposal,omitempty"` + Error string `json:"error,omitempty"` +} + // WindowView / BucketView / EvidenceView are the evidence projection. type WindowView struct { Class string `json:"class"` @@ -88,6 +120,7 @@ type State struct { RuntimeState domain.RuntimeState `json:"runtime_state"` Commitment *CommitmentView `json:"commitment"` Evidence *EvidenceView `json:"evidence"` + Coach *CoachView `json:"coach,omitempty"` } // New loads any persisted snapshot, prunes stale session logs, and rebuilds @@ -181,6 +214,21 @@ func (c *Controller) stateLocked() State { Buckets: bucketViews(c.stats.Buckets), } } + if c.runtimeState == domain.RuntimePlanning { + status := c.coachStatus + if status == "" { + status = coachIdle + } + cv := &CoachView{Status: status, Error: c.coachErr} + if c.coachProposal != nil { + cv.Proposal = &ProposalView{ + NextAction: c.coachProposal.NextAction, + SuccessCondition: c.coachProposal.SuccessCondition, + TimeboxSecs: c.coachProposal.TimeboxSecs, + } + } + st.Coach = cv + } return st } @@ -217,9 +265,89 @@ func (c *Controller) EnterPlanning() error { return err } c.runtimeState = next + c.resetCoachLocked() return c.persistLocked() } +// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes +// RequestCoach degrade gracefully. +func (c *Controller) SetCoach(coach ai.Coach) { + c.mu.Lock() + c.coach = coach + c.mu.Unlock() +} + +// resetCoachLocked returns coach state to idle and invalidates any in-flight +// request. Caller holds mu. +func (c *Controller) resetCoachLocked() { + c.coachStatus = coachIdle + c.coachProposal = nil + c.coachErr = "" + c.coachGen++ +} + +// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if +// not in planning; otherwise never a hard error (failures surface as coach +// state). The proposal is ephemeral and never persisted. +func (c *Controller) RequestCoach(intent string) error { + c.mu.Lock() + if c.runtimeState != domain.RuntimePlanning { + c.mu.Unlock() + return ErrNotPlanning + } + if c.coach == nil { + c.coachStatus = coachError + c.coachErr = "coach unavailable" + c.coachProposal = nil + c.mu.Unlock() + c.notify() + return nil + } + c.coachGen++ + gen := c.coachGen + c.coachStatus = coachPending + c.coachErr = "" + c.coachProposal = nil + coach := c.coach + c.mu.Unlock() + c.notify() + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), coachTimeout) + defer cancel() + prop, err := coach.Coach(ctx, intent) + + c.mu.Lock() + if gen != c.coachGen || c.runtimeState != domain.RuntimePlanning { + c.mu.Unlock() + return // stale or left planning: discard + } + if err != nil { + c.coachStatus = coachError + c.coachErr = coachErrorMessage(err) + c.coachProposal = nil + } else { + c.coachStatus = coachReady + c.coachProposal = &prop + c.coachErr = "" + } + c.mu.Unlock() + c.notify() + }() + return nil +} + +func coachErrorMessage(err error) string { + switch { + case errors.Is(err, ai.ErrEmptyResponse), errors.Is(err, ai.ErrNoJSON), errors.Is(err, ai.ErrInvalidProposal): + return "coach returned an unusable response" + case errors.Is(err, context.DeadlineExceeded): + return "coach timed out" + default: + return "coach unavailable" + } +} + // StartManualCommitment validates input, activates a new commitment, mints a // session, seeds evidence stats from the latest window, and moves Planning -> // Active. @@ -243,6 +371,7 @@ func (c *Controller) StartManualCommitment(nextAction, successCondition string, c.commitment = &commitment c.deadline = now.Add(timebox) c.outcomePending = "" + c.resetCoachLocked() sessionID := "session-" + uuid.Must(uuid.NewV7()).String() c.stats = &EvidenceStats{ diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 2ceff3c..16e3cd6 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -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") + } +}