Add drift state, persistence, and override controls to controller
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+115
-4
@@ -10,6 +10,7 @@ import (
|
||||
"log"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -36,8 +37,22 @@ const (
|
||||
coachError = "error"
|
||||
)
|
||||
|
||||
const (
|
||||
driftDebounce = 10 * time.Second
|
||||
driftTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
driftIdle = "idle"
|
||||
driftPending = "pending"
|
||||
driftOnTask = "ontask"
|
||||
driftDrifting = "drifting"
|
||||
)
|
||||
|
||||
var ErrNotPlanning = errors.New("session: coaching is only available while planning")
|
||||
|
||||
var ErrNotActive = errors.New("session: only available while a commitment is active")
|
||||
|
||||
// bucketKey identifies a time bucket; Title is the scrubbed title.
|
||||
type bucketKey struct{ Class, Title string }
|
||||
|
||||
@@ -72,6 +87,14 @@ type Controller struct {
|
||||
coachProposal *ai.Proposal
|
||||
coachErr string
|
||||
coachGen int
|
||||
|
||||
allowedClasses []string // durable: the active session's allowed window classes
|
||||
judge ai.DriftJudge
|
||||
driftStatus string
|
||||
driftReason string
|
||||
driftGen int
|
||||
lastJudgedAt time.Time
|
||||
judgedClasses map[string]ai.Verdict
|
||||
}
|
||||
|
||||
// CommitmentView is the UI projection of the active commitment.
|
||||
@@ -87,6 +110,14 @@ type ProposalView struct {
|
||||
NextAction string `json:"next_action"`
|
||||
SuccessCondition string `json:"success_condition"`
|
||||
TimeboxSecs int64 `json:"timebox_secs"`
|
||||
|
||||
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
||||
}
|
||||
|
||||
// DriftView is the active-only drift projection.
|
||||
type DriftView struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type CoachView struct {
|
||||
@@ -121,6 +152,7 @@ type State struct {
|
||||
Commitment *CommitmentView `json:"commitment"`
|
||||
Evidence *EvidenceView `json:"evidence"`
|
||||
Coach *CoachView `json:"coach,omitempty"`
|
||||
Drift *DriftView `json:"drift,omitempty"`
|
||||
}
|
||||
|
||||
// New loads any persisted snapshot, prunes stale session logs, and rebuilds
|
||||
@@ -148,6 +180,7 @@ func New(snapshotPath string) (*Controller, error) {
|
||||
}
|
||||
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
|
||||
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
|
||||
c.allowedClasses = s.AllowedWindowClasses
|
||||
c.replayStats(s.SessionID)
|
||||
}
|
||||
return c, nil
|
||||
@@ -222,13 +255,21 @@ func (c *Controller) stateLocked() State {
|
||||
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,
|
||||
NextAction: c.coachProposal.NextAction,
|
||||
SuccessCondition: c.coachProposal.SuccessCondition,
|
||||
TimeboxSecs: c.coachProposal.TimeboxSecs,
|
||||
AllowedWindowClasses: c.coachProposal.AllowedWindowClasses,
|
||||
}
|
||||
}
|
||||
st.Coach = cv
|
||||
}
|
||||
if c.runtimeState == domain.RuntimeActive {
|
||||
status := c.driftStatus
|
||||
if status == "" {
|
||||
status = driftIdle
|
||||
}
|
||||
st.Drift = &DriftView{Status: status, Reason: c.driftReason}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
@@ -253,6 +294,7 @@ func (c *Controller) persistLocked() error {
|
||||
if c.stats != nil {
|
||||
snap.SessionID = c.stats.SessionID
|
||||
}
|
||||
snap.AllowedWindowClasses = c.allowedClasses
|
||||
return store.Save(c.snapshotPath, snap)
|
||||
}
|
||||
|
||||
@@ -286,6 +328,71 @@ func (c *Controller) resetCoachLocked() {
|
||||
c.coachGen++
|
||||
}
|
||||
|
||||
// AllowedClassesForTest exposes the session allowed classes for tests.
|
||||
func (c *Controller) AllowedClassesForTest() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.allowedClasses...)
|
||||
}
|
||||
|
||||
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
|
||||
// working; unmatched windows simply stay idle.
|
||||
func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
|
||||
c.mu.Lock()
|
||||
c.judge = j
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
|
||||
func (c *Controller) resetDriftLocked() {
|
||||
c.driftStatus = driftIdle
|
||||
c.driftReason = ""
|
||||
c.driftGen++
|
||||
c.lastJudgedAt = time.Time{}
|
||||
c.judgedClasses = map[string]ai.Verdict{}
|
||||
}
|
||||
|
||||
// OnTask appends the current window class to the session allowed-context, clears
|
||||
// drift, drops any cached verdict for that class, and persists. The class now
|
||||
// matches locally and is never re-judged.
|
||||
func (c *Controller) OnTask() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.runtimeState != domain.RuntimeActive {
|
||||
return ErrNotActive
|
||||
}
|
||||
var class string
|
||||
if c.stats != nil {
|
||||
class = c.stats.Current.Class
|
||||
}
|
||||
if strings.TrimSpace(class) != "" {
|
||||
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
|
||||
if !evidence.MatchesAllowed(ac, class, "") {
|
||||
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class))
|
||||
}
|
||||
delete(c.judgedClasses, class)
|
||||
}
|
||||
c.driftStatus = driftOnTask
|
||||
c.driftReason = ""
|
||||
return c.persistLocked()
|
||||
}
|
||||
|
||||
// Refocus clears the current drift verdict without changing allowed-context, and
|
||||
// drops the cached verdict for the current class so it may be judged again later.
|
||||
func (c *Controller) Refocus() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.runtimeState != domain.RuntimeActive {
|
||||
return ErrNotActive
|
||||
}
|
||||
if c.stats != nil {
|
||||
delete(c.judgedClasses, c.stats.Current.Class)
|
||||
}
|
||||
c.driftStatus = driftIdle
|
||||
c.driftReason = ""
|
||||
return c.persistLocked()
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -351,7 +458,7 @@ func coachErrorMessage(err error) string {
|
||||
// StartManualCommitment validates input, activates a new commitment, mints a
|
||||
// session, seeds evidence stats from the latest window, and moves Planning ->
|
||||
// Active.
|
||||
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration) error {
|
||||
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
commitment, err := domain.NewManual(nextAction, successCondition, timebox)
|
||||
@@ -372,6 +479,8 @@ func (c *Controller) StartManualCommitment(nextAction, successCondition string,
|
||||
c.deadline = now.Add(timebox)
|
||||
c.outcomePending = ""
|
||||
c.resetCoachLocked()
|
||||
c.allowedClasses = append([]string(nil), allowedClasses...)
|
||||
c.resetDriftLocked()
|
||||
|
||||
sessionID := "session-" + uuid.Must(uuid.NewV7()).String()
|
||||
c.stats = &EvidenceStats{
|
||||
@@ -436,6 +545,8 @@ func (c *Controller) End() error {
|
||||
c.deadline = time.Time{}
|
||||
c.stats = nil
|
||||
c.outcomePending = ""
|
||||
c.allowedClasses = nil
|
||||
c.resetDriftLocked()
|
||||
return c.persistLocked()
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestHappyPathDrivesStates(t *testing.T) {
|
||||
if err := c.EnterPlanning(); err != nil {
|
||||
t.Fatalf("enter planning: %v", err)
|
||||
}
|
||||
if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute); err != nil {
|
||||
if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute, nil); err != nil {
|
||||
t.Fatalf("start commitment: %v", err)
|
||||
}
|
||||
st := c.State()
|
||||
@@ -61,7 +61,7 @@ func TestHappyPathDrivesStates(t *testing.T) {
|
||||
func TestStartCommitmentRejectsInvalidInput(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
_ = c.EnterPlanning()
|
||||
if err := c.StartManualCommitment("", "x", 25*time.Minute); err == nil {
|
||||
if err := c.StartManualCommitment("", "x", 25*time.Minute, nil); err == nil {
|
||||
t.Fatalf("empty next action should error")
|
||||
}
|
||||
if c.State().RuntimeState != domain.RuntimePlanning {
|
||||
@@ -76,7 +76,7 @@ func TestStateRestoresFromSnapshot(t *testing.T) {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
_ = first.EnterPlanning()
|
||||
_ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute)
|
||||
_ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute, nil)
|
||||
|
||||
second, err := New(path)
|
||||
if err != nil {
|
||||
@@ -98,7 +98,7 @@ func TestRestoredCommitmentKeepsDeadline(t *testing.T) {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
_ = first.EnterPlanning()
|
||||
_ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute)
|
||||
_ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute, nil)
|
||||
want := first.State().Commitment.DeadlineUnixSecs
|
||||
|
||||
second, err := New(path)
|
||||
@@ -141,7 +141,7 @@ func TestAccumulatesBucketsAndSwitches(t *testing.T) {
|
||||
c.RecordWindow(snap("code", "antidrift")) // latest window before start
|
||||
|
||||
_ = c.EnterPlanning()
|
||||
_ = c.StartManualCommitment("work", "done", 25*time.Minute) // seeds at t=1000 with code/antidrift
|
||||
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seeds at t=1000 with code/antidrift
|
||||
|
||||
clk.advance(10 * time.Second)
|
||||
c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox
|
||||
@@ -176,7 +176,7 @@ func TestUnavailableAccruesToReservedBucket(t *testing.T) {
|
||||
clk := &fakeClock{now: time.Unix(1000, 0)}
|
||||
c.SetClock(clk.fn())
|
||||
_ = c.EnterPlanning()
|
||||
_ = c.StartManualCommitment("work", "done", 25*time.Minute) // seed: empty unavailable latestWindow
|
||||
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seed: empty unavailable latestWindow
|
||||
// latestWindow was zero-value (unavailable) at seed time.
|
||||
clk.advance(5 * time.Second)
|
||||
c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket
|
||||
@@ -193,7 +193,7 @@ func TestCrashReplayRebuildsStats(t *testing.T) {
|
||||
first.SetClock(clk.fn())
|
||||
first.RecordWindow(snap("code", "antidrift"))
|
||||
_ = first.EnterPlanning()
|
||||
_ = first.StartManualCommitment("work", "done", 25*time.Minute)
|
||||
_ = first.StartManualCommitment("work", "done", 25*time.Minute, nil)
|
||||
clk.advance(15 * time.Second)
|
||||
first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch
|
||||
|
||||
@@ -222,7 +222,7 @@ func TestEndWritesAuditSummary(t *testing.T) {
|
||||
c.SetClock(clk.fn())
|
||||
c.RecordWindow(snap("code", "antidrift"))
|
||||
_ = c.EnterPlanning()
|
||||
_ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute)
|
||||
_ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil)
|
||||
clk.advance(30 * time.Second)
|
||||
c.RecordWindow(snap("firefox", "docs"))
|
||||
clk.advance(10 * time.Second)
|
||||
@@ -339,13 +339,60 @@ func TestRequestCoachStaleResultDiscarded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnTaskRequiresActive(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
if err := c.OnTask(); !errors.Is(err, ErrNotActive) {
|
||||
t.Fatalf("OnTask outside Active should error, got %v", err)
|
||||
}
|
||||
if err := c.Refocus(); !errors.Is(err, ErrNotActive) {
|
||||
t.Fatalf("Refocus outside Active should error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartCommitmentPersistsAllowedClasses(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
first, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
_ = first.EnterPlanning()
|
||||
if err := first.StartManualCommitment("a", "b", 25*time.Minute, []string{"code"}); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
second, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if got := second.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
|
||||
t.Fatalf("allowed classes not restored: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnTaskAppendsCurrentClass(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
_ = c.EnterPlanning()
|
||||
_ = c.StartManualCommitment("a", "b", 25*time.Minute, nil)
|
||||
c.RecordWindow(evidence.WindowSnapshot{Class: "slack", Title: "chat", Health: evidence.EvidenceHealth{Available: true}})
|
||||
if err := c.OnTask(); err != nil {
|
||||
t.Fatalf("ontask: %v", err)
|
||||
}
|
||||
got := c.AllowedClassesForTest()
|
||||
if len(got) != 1 || got[0] != "slack" {
|
||||
t.Fatalf("OnTask should append current class, got %#v", got)
|
||||
}
|
||||
st := c.State()
|
||||
if st.Drift == nil || st.Drift.Status != "ontask" {
|
||||
t.Fatalf("OnTask should set drift ontask, got %+v", st.Drift)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if err := c.StartManualCommitment("a", "b", 25*time.Minute, nil); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
if c.State().Coach != nil {
|
||||
|
||||
@@ -19,6 +19,8 @@ type Snapshot struct {
|
||||
DeadlineUnixSecs int64 `json:"deadline_unix_secs,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
OutcomePending string `json:"outcome_pending,omitempty"`
|
||||
|
||||
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultPath returns ~/.antidrift/state.json.
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ func (s *Server) handleCommitment(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second)
|
||||
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, nil)
|
||||
if err == nil {
|
||||
s.armExpiry()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user