Load the knowledge profile on planning and ground the coach

The controller loads the profile asynchronously on entering planning,
mirroring the tasks fetch: generation-guarded goroutine, status enum
(idle/pending/ready/absent/error), projected into State only while
planning and only when a source is set. The loaded text is cached and
passed to the coach as grounding; SetKnowledgePath repoints the file at
runtime. nil source degrades to no view and an ungrounded coach.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 08:04:13 -04:00
parent 0f1790c8d5
commit 8bd37ed46d
2 changed files with 223 additions and 2 deletions
+111 -1
View File
@@ -17,6 +17,7 @@ import (
"antidrift/internal/ai" "antidrift/internal/ai"
"antidrift/internal/domain" "antidrift/internal/domain"
"antidrift/internal/evidence" "antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/statemachine" "antidrift/internal/statemachine"
"antidrift/internal/store" "antidrift/internal/store"
"antidrift/internal/tasks" "antidrift/internal/tasks"
@@ -47,6 +48,16 @@ const (
tasksError = "error" tasksError = "error"
) )
const knowledgeTimeout = 10 * time.Second
const (
knowledgeIdle = "idle"
knowledgePending = "pending"
knowledgeReady = "ready"
knowledgeAbsent = "absent"
knowledgeError = "error"
)
const ( const (
driftDebounce = 10 * time.Second driftDebounce = 10 * time.Second
driftTimeout = 30 * time.Second driftTimeout = 30 * time.Second
@@ -107,6 +118,13 @@ type Controller struct {
tasksList []tasks.Task tasksList []tasks.Task
tasksGen int tasksGen int
knowledgeSrc knowledge.Source
knowledgeStatus string
knowledgeText string // cached grounding the coach reads
knowledgePath string // selected path; "" = adapter default
knowledgeChars int
knowledgeGen int
allowedClasses []string // durable: the active session's allowed window classes allowedClasses []string // durable: the active session's allowed window classes
judge ai.DriftJudge judge ai.DriftJudge
driftStatus string driftStatus string
@@ -167,6 +185,15 @@ type TasksView struct {
Tasks []TaskView `json:"tasks,omitempty"` Tasks []TaskView `json:"tasks,omitempty"`
} }
// KnowledgeView projects the ephemeral planning knowledge state (the standing
// profile that grounds the coach). The profile text is intentionally omitted —
// only its presence, source path, and size cross the wire.
type KnowledgeView struct {
Status string `json:"status"`
Path string `json:"path,omitempty"`
Chars int `json:"chars,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"`
@@ -194,6 +221,7 @@ type State struct {
Evidence *EvidenceView `json:"evidence"` Evidence *EvidenceView `json:"evidence"`
Coach *CoachView `json:"coach,omitempty"` Coach *CoachView `json:"coach,omitempty"`
Tasks *TasksView `json:"tasks,omitempty"` Tasks *TasksView `json:"tasks,omitempty"`
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
Drift *DriftView `json:"drift,omitempty"` Drift *DriftView `json:"drift,omitempty"`
} }
@@ -319,6 +347,13 @@ func (c *Controller) stateLocked() State {
} }
st.Tasks = tv st.Tasks = tv
} }
if c.knowledgeSrc != nil {
kstatus := c.knowledgeStatus
if kstatus == "" {
kstatus = knowledgeIdle
}
st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars}
}
} }
if c.runtimeState == domain.RuntimeActive { if c.runtimeState == domain.RuntimeActive {
status := c.driftStatus status := c.driftStatus
@@ -366,6 +401,7 @@ func (c *Controller) EnterPlanning() error {
c.runtimeState = next c.runtimeState = next
c.resetCoachLocked() c.resetCoachLocked()
c.startTasksFetchLocked() c.startTasksFetchLocked()
c.startKnowledgeFetchLocked()
return c.persistLocked() return c.persistLocked()
} }
@@ -429,6 +465,79 @@ func (c *Controller) startTasksFetchLocked() {
}() }()
} }
// SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source
// keeps the planning knowledge view absent and the coach ungrounded.
func (c *Controller) SetKnowledge(s knowledge.Source) {
c.mu.Lock()
c.knowledgeSrc = s
c.mu.Unlock()
}
// SetKnowledgePath selects an explicit profile path (session-only; not
// persisted). While planning, it re-loads immediately so the indicator and the
// cached grounding update. An empty path resets to the adapter default.
func (c *Controller) SetKnowledgePath(path string) {
c.mu.Lock()
c.knowledgePath = strings.TrimSpace(path)
planning := c.runtimeState == domain.RuntimePlanning
if planning {
c.startKnowledgeFetchLocked()
}
c.mu.Unlock()
if planning {
c.notify()
}
}
// startKnowledgeFetchLocked kicks off an asynchronous Load when a source is set.
// Mirrors startTasksFetchLocked: generation-guarded, discards stale or
// post-planning results, and notifies on completion. The loaded text is cached
// in knowledgeText for the coach to read. Caller holds mu.
func (c *Controller) startKnowledgeFetchLocked() {
c.knowledgeText = ""
c.knowledgeChars = 0
if c.knowledgeSrc == nil {
c.knowledgeStatus = knowledgeIdle
return
}
c.knowledgeGen++
gen := c.knowledgeGen
c.knowledgeStatus = knowledgePending
src := c.knowledgeSrc
path := c.knowledgePath
go func() {
ctx, cancel := context.WithTimeout(context.Background(), knowledgeTimeout)
defer cancel()
prof, err := src.Load(ctx, path)
c.mu.Lock()
if gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning {
c.mu.Unlock()
return // stale or left planning: discard
}
if err != nil {
c.knowledgeStatus = knowledgeError
c.knowledgeText = ""
c.knowledgeChars = 0
if prof.Path != "" {
c.knowledgePath = prof.Path
}
} else if prof.Text == "" {
c.knowledgeStatus = knowledgeAbsent
c.knowledgeText = ""
c.knowledgeChars = 0
c.knowledgePath = prof.Path
} else {
c.knowledgeStatus = knowledgeReady
c.knowledgeText = prof.Text
c.knowledgeChars = len(prof.Text)
c.knowledgePath = prof.Path
}
c.mu.Unlock()
c.notify()
}()
}
// AllowedClassesForTest exposes the session allowed classes for tests. // AllowedClassesForTest exposes the session allowed classes for tests.
func (c *Controller) AllowedClassesForTest() []string { func (c *Controller) AllowedClassesForTest() []string {
c.mu.Lock() c.mu.Lock()
@@ -545,13 +654,14 @@ func (c *Controller) RequestCoach(intent string) error {
c.coachErr = "" c.coachErr = ""
c.coachProposal = nil c.coachProposal = nil
coach := c.coach coach := c.coach
grounding := c.knowledgeText
c.mu.Unlock() c.mu.Unlock()
c.notify() c.notify()
go func() { go func() {
ctx, cancel := context.WithTimeout(context.Background(), coachTimeout) ctx, cancel := context.WithTimeout(context.Background(), coachTimeout)
defer cancel() defer cancel()
prop, err := coach.Coach(ctx, intent) prop, err := coach.Coach(ctx, intent, grounding)
c.mu.Lock() c.mu.Lock()
if gen != c.coachGen || c.runtimeState != domain.RuntimePlanning { if gen != c.coachGen || c.runtimeState != domain.RuntimePlanning {
+112 -1
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@@ -12,6 +13,7 @@ import (
"antidrift/internal/ai" "antidrift/internal/ai"
"antidrift/internal/domain" "antidrift/internal/domain"
"antidrift/internal/evidence" "antidrift/internal/evidence"
"antidrift/internal/knowledge"
"antidrift/internal/store" "antidrift/internal/store"
"antidrift/internal/tasks" "antidrift/internal/tasks"
) )
@@ -246,15 +248,27 @@ type fakeCoach struct {
prop ai.Proposal prop ai.Proposal
err error err error
gate chan struct{} // if non-nil, Coach blocks until it receives gate chan struct{} // if non-nil, Coach blocks until it receives
mu sync.Mutex
gotGrounding string
} }
func (f *fakeCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) { func (f *fakeCoach) Coach(ctx context.Context, intent, grounding string) (ai.Proposal, error) {
f.mu.Lock()
f.gotGrounding = grounding
f.mu.Unlock()
if f.gate != nil { if f.gate != nil {
<-f.gate <-f.gate
} }
return f.prop, f.err return f.prop, f.err
} }
func (f *fakeCoach) grounding() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.gotGrounding
}
// waitCoachStatus polls until the coach view reaches want, or fails after 2s. // waitCoachStatus polls until the coach view reaches want, or fails after 2s.
func waitCoachStatus(t *testing.T, c *Controller, want string) State { func waitCoachStatus(t *testing.T, c *Controller, want string) State {
t.Helper() t.Helper()
@@ -918,3 +932,100 @@ func TestStaleTasksFetchDiscardedAfterLeavingPlanning(t *testing.T) {
t.Fatalf("stale fetch result clobbered the fresh tasks: %+v", st.Tasks) t.Fatalf("stale fetch result clobbered the fresh tasks: %+v", st.Tasks)
} }
} }
type fakeSource struct {
profile knowledge.Profile
err error
}
func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) {
return f.profile, f.err
}
// waitKnowledgeStatus polls until the knowledge view reaches want, or fails after 2s.
func waitKnowledgeStatus(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.Knowledge != nil && st.Knowledge.Status == want {
return st
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("knowledge status never reached %q (last: %+v)", want, c.State().Knowledge)
return State{}
}
func TestEnterPlanningLoadsKnowledge(t *testing.T) {
c, _ := newTestController(t)
c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/p/knowledge.md"}})
if err := c.EnterPlanning(); err != nil {
t.Fatalf("enter planning: %v", err)
}
st := waitKnowledgeStatus(t, c, "ready")
if st.Knowledge.Path != "/p/knowledge.md" || st.Knowledge.Chars != len("I value small diffs.") {
t.Fatalf("knowledge view wrong: %+v", st.Knowledge)
}
}
func TestKnowledgeAbsentWhenEmptyText(t *testing.T) {
c, _ := newTestController(t)
c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Path: "/p/missing.md"}})
if err := c.EnterPlanning(); err != nil {
t.Fatalf("enter planning: %v", err)
}
st := waitKnowledgeStatus(t, c, "absent")
if st.Knowledge.Path != "/p/missing.md" {
t.Fatalf("absent view should still carry the path: %+v", st.Knowledge)
}
}
func TestKnowledgeLoadError(t *testing.T) {
c, _ := newTestController(t)
c.SetKnowledge(&fakeSource{err: errors.New("permission denied")})
if err := c.EnterPlanning(); err != nil {
t.Fatalf("enter planning: %v", err)
}
waitKnowledgeStatus(t, c, "error")
}
func TestNoSourceNoKnowledgeView(t *testing.T) {
c, _ := newTestController(t)
if err := c.EnterPlanning(); err != nil {
t.Fatalf("enter planning: %v", err)
}
if c.State().Knowledge != nil {
t.Fatalf("nil source should yield no knowledge view: %+v", c.State().Knowledge)
}
}
func TestKnowledgeViewAbsentOutsidePlanning(t *testing.T) {
c, _ := newTestController(t)
c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "x"}})
if c.State().Knowledge != nil {
t.Fatalf("no knowledge view while Locked: %+v", c.State().Knowledge)
}
}
func TestCoachReceivesCachedGrounding(t *testing.T) {
c, _ := newTestController(t)
fc := &fakeCoach{prop: ai.Proposal{NextAction: "a", SuccessCondition: "b", TimeboxSecs: 1200}}
c.SetCoach(fc)
c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/p"}})
if err := c.EnterPlanning(); err != nil {
t.Fatalf("enter planning: %v", err)
}
waitKnowledgeStatus(t, c, "ready")
if err := c.RequestCoach("ship it"); err != nil {
t.Fatalf("request coach: %v", err)
}
// Poll until the coach has run (proposal ready), then assert grounding flowed.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && fc.grounding() == "" {
time.Sleep(5 * time.Millisecond)
}
if fc.grounding() != "I value small diffs." {
t.Fatalf("coach grounding = %q, want the cached profile", fc.grounding())
}
}