Fetch today's tasks asynchronously on entering planning

The controller fetches the Marvin task list when entering planning,
mirroring the planning coach: generation-guarded goroutine, status enum
(idle/pending/ready/error), projected into State only while planning and
only when a provider is set. nil provider degrades to no tasks view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 22:02:28 -04:00
parent 569264be11
commit 8b7bec1173
2 changed files with 155 additions and 0 deletions
+84
View File
@@ -19,6 +19,7 @@ import (
"antidrift/internal/evidence" "antidrift/internal/evidence"
"antidrift/internal/statemachine" "antidrift/internal/statemachine"
"antidrift/internal/store" "antidrift/internal/store"
"antidrift/internal/tasks"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -37,6 +38,15 @@ const (
coachError = "error" coachError = "error"
) )
const tasksTimeout = 30 * time.Second
const (
tasksIdle = "idle"
tasksPending = "pending"
tasksReady = "ready"
tasksError = "error"
)
const ( const (
driftDebounce = 10 * time.Second driftDebounce = 10 * time.Second
driftTimeout = 30 * time.Second driftTimeout = 30 * time.Second
@@ -92,6 +102,11 @@ type Controller struct {
coachErr string coachErr string
coachGen int coachGen int
tasksProvider tasks.Provider
tasksStatus string
tasksList []tasks.Task
tasksGen 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
@@ -139,6 +154,19 @@ type CoachView struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// TaskView is one to-do item in the planning tasks list.
type TaskView struct {
ID string `json:"id"`
Title string `json:"title"`
Day string `json:"day,omitempty"`
}
// TasksView projects the ephemeral planning tasks state (Marvin's today list).
type TasksView struct {
Status string `json:"status"`
Tasks []TaskView `json:"tasks,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"`
@@ -165,6 +193,7 @@ type State struct {
Commitment *CommitmentView `json:"commitment"` Commitment *CommitmentView `json:"commitment"`
Evidence *EvidenceView `json:"evidence"` Evidence *EvidenceView `json:"evidence"`
Coach *CoachView `json:"coach,omitempty"` Coach *CoachView `json:"coach,omitempty"`
Tasks *TasksView `json:"tasks,omitempty"`
Drift *DriftView `json:"drift,omitempty"` Drift *DriftView `json:"drift,omitempty"`
} }
@@ -279,6 +308,17 @@ func (c *Controller) stateLocked() State {
} }
} }
st.Coach = cv st.Coach = cv
if c.tasksProvider != nil {
tstatus := c.tasksStatus
if tstatus == "" {
tstatus = tasksIdle
}
tv := &TasksView{Status: tstatus}
for _, t := range c.tasksList {
tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day})
}
st.Tasks = tv
}
} }
if c.runtimeState == domain.RuntimeActive { if c.runtimeState == domain.RuntimeActive {
status := c.driftStatus status := c.driftStatus
@@ -325,6 +365,7 @@ func (c *Controller) EnterPlanning() error {
} }
c.runtimeState = next c.runtimeState = next
c.resetCoachLocked() c.resetCoachLocked()
c.startTasksFetchLocked()
return c.persistLocked() return c.persistLocked()
} }
@@ -345,6 +386,49 @@ func (c *Controller) resetCoachLocked() {
c.coachGen++ c.coachGen++
} }
// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps
// the planning tasks list absent.
func (c *Controller) SetTasks(p tasks.Provider) {
c.mu.Lock()
c.tasksProvider = p
c.mu.Unlock()
}
// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider
// is set. Mirrors RequestCoach: generation-guarded, discards stale or
// post-planning results, and notifies on completion. Caller holds mu.
func (c *Controller) startTasksFetchLocked() {
c.tasksList = nil
if c.tasksProvider == nil {
c.tasksStatus = tasksIdle
return
}
c.tasksGen++
gen := c.tasksGen
c.tasksStatus = tasksPending
provider := c.tasksProvider
go func() {
ctx, cancel := context.WithTimeout(context.Background(), tasksTimeout)
defer cancel()
list, err := provider.Today(ctx)
c.mu.Lock()
if gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning {
c.mu.Unlock()
return // stale or left planning: discard
}
if err != nil {
c.tasksStatus = tasksError
c.tasksList = nil
} else {
c.tasksStatus = tasksReady
c.tasksList = list
}
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()
+71
View File
@@ -13,6 +13,7 @@ import (
"antidrift/internal/domain" "antidrift/internal/domain"
"antidrift/internal/evidence" "antidrift/internal/evidence"
"antidrift/internal/store" "antidrift/internal/store"
"antidrift/internal/tasks"
) )
func newTestController(t *testing.T) (*Controller, string) { func newTestController(t *testing.T) (*Controller, string) {
@@ -772,3 +773,73 @@ func TestRecentTitlesRingCapsAtTen(t *testing.T) {
t.Fatalf("ring should drop oldest first, got first=%q", titles[0]) t.Fatalf("ring should drop oldest first, got first=%q", titles[0])
} }
} }
type fakeProvider struct {
list []tasks.Task
err error
gate chan struct{} // if non-nil, Today blocks until it receives
}
func (f *fakeProvider) Today(ctx context.Context) ([]tasks.Task, error) {
if f.gate != nil {
<-f.gate
}
return f.list, f.err
}
// waitTasksStatus polls until the tasks view reaches want, or fails after 2s.
func waitTasksStatus(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.Tasks != nil && st.Tasks.Status == want {
return st
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("tasks status never reached %q (last: %+v)", want, c.State().Tasks)
return State{}
}
func TestEnterPlanningFetchesTasks(t *testing.T) {
c, _ := newTestController(t)
c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "Write spec", Day: "2026-05-31"}}})
if err := c.EnterPlanning(); err != nil {
t.Fatalf("enter planning: %v", err)
}
st := waitTasksStatus(t, c, "ready")
if len(st.Tasks.Tasks) != 1 || st.Tasks.Tasks[0].Title != "Write spec" {
t.Fatalf("tasks list wrong: %+v", st.Tasks)
}
}
func TestTasksFetchError(t *testing.T) {
c, _ := newTestController(t)
c.SetTasks(&fakeProvider{err: errors.New("am down")})
if err := c.EnterPlanning(); err != nil {
t.Fatalf("enter planning: %v", err)
}
st := waitTasksStatus(t, c, "error")
if len(st.Tasks.Tasks) != 0 {
t.Fatalf("error state should carry no tasks: %+v", st.Tasks)
}
}
func TestNoProviderNoTasksView(t *testing.T) {
c, _ := newTestController(t)
if err := c.EnterPlanning(); err != nil {
t.Fatalf("enter planning: %v", err)
}
if c.State().Tasks != nil {
t.Fatalf("nil provider should yield no tasks view: %+v", c.State().Tasks)
}
}
func TestTasksViewAbsentOutsidePlanning(t *testing.T) {
c, _ := newTestController(t)
c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "x"}}})
if c.State().Tasks != nil {
t.Fatalf("no tasks view while Locked: %+v", c.State().Tasks)
}
}