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
|
||||
|
||||
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{
|
||||
|
||||
Reference in New Issue
Block a user