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:
2026-05-31 14:16:28 -04:00
parent 25a33f027a
commit 463ac4f23c
2 changed files with 246 additions and 0 deletions
+129
View File
@@ -5,12 +5,15 @@
package session package session
import ( import (
"context"
"errors"
"log" "log"
"path/filepath" "path/filepath"
"sort" "sort"
"sync" "sync"
"time" "time"
"antidrift/internal/ai"
"antidrift/internal/domain" "antidrift/internal/domain"
"antidrift/internal/evidence" "antidrift/internal/evidence"
"antidrift/internal/statemachine" "antidrift/internal/statemachine"
@@ -24,6 +27,17 @@ const (
sessionRetention = 30 * 24 * time.Hour 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. // bucketKey identifies a time bucket; Title is the scrubbed title.
type bucketKey struct{ Class, Title string } type bucketKey struct{ Class, Title string }
@@ -53,6 +67,11 @@ type Controller struct {
latestWindow evidence.WindowSnapshot latestWindow evidence.WindowSnapshot
stats *EvidenceStats stats *EvidenceStats
outcomePending string outcomePending string
coach ai.Coach
coachStatus string
coachProposal *ai.Proposal
coachErr string
coachGen int
} }
// CommitmentView is the UI projection of the active commitment. // CommitmentView is the UI projection of the active commitment.
@@ -63,6 +82,19 @@ type CommitmentView struct {
DeadlineUnixSecs int64 `json:"deadline_unix_secs"` 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. // WindowView / BucketView / EvidenceView are the evidence projection.
type WindowView struct { type WindowView struct {
Class string `json:"class"` Class string `json:"class"`
@@ -88,6 +120,7 @@ type State struct {
RuntimeState domain.RuntimeState `json:"runtime_state"` RuntimeState domain.RuntimeState `json:"runtime_state"`
Commitment *CommitmentView `json:"commitment"` Commitment *CommitmentView `json:"commitment"`
Evidence *EvidenceView `json:"evidence"` Evidence *EvidenceView `json:"evidence"`
Coach *CoachView `json:"coach,omitempty"`
} }
// New loads any persisted snapshot, prunes stale session logs, and rebuilds // 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), 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 return st
} }
@@ -217,9 +265,89 @@ func (c *Controller) EnterPlanning() error {
return err return err
} }
c.runtimeState = next c.runtimeState = next
c.resetCoachLocked()
return c.persistLocked() 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 // StartManualCommitment validates input, activates a new commitment, mints a
// session, seeds evidence stats from the latest window, and moves Planning -> // session, seeds evidence stats from the latest window, and moves Planning ->
// Active. // Active.
@@ -243,6 +371,7 @@ func (c *Controller) StartManualCommitment(nextAction, successCondition string,
c.commitment = &commitment c.commitment = &commitment
c.deadline = now.Add(timebox) c.deadline = now.Add(timebox)
c.outcomePending = "" c.outcomePending = ""
c.resetCoachLocked()
sessionID := "session-" + uuid.Must(uuid.NewV7()).String() sessionID := "session-" + uuid.Must(uuid.NewV7()).String()
c.stats = &EvidenceStats{ c.stats = &EvidenceStats{
+117
View File
@@ -1,10 +1,13 @@
package session package session
import ( import (
"context"
"errors"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
"antidrift/internal/ai"
"antidrift/internal/domain" "antidrift/internal/domain"
"antidrift/internal/evidence" "antidrift/internal/evidence"
"antidrift/internal/store" "antidrift/internal/store"
@@ -235,3 +238,117 @@ func TestEndWritesAuditSummary(t *testing.T) {
t.Fatalf("chain should verify: %v", err) 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")
}
}