Add drift state, persistence, and override controls to controller

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 16:53:23 -04:00
parent 75d31b5fa8
commit 2977b903c2
4 changed files with 174 additions and 14 deletions
+56 -9
View File
@@ -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 {