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
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{