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:
@@ -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{
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user