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/statemachine"
"antidrift/internal/store"
"antidrift/internal/tasks"
"github.com/google/uuid"
)
@@ -37,6 +38,15 @@ const (
coachError = "error"
)
const tasksTimeout = 30 * time.Second
const (
tasksIdle = "idle"
tasksPending = "pending"
tasksReady = "ready"
tasksError = "error"
)
const (
driftDebounce = 10 * time.Second
driftTimeout = 30 * time.Second
@@ -92,6 +102,11 @@ type Controller struct {
coachErr string
coachGen int
tasksProvider tasks.Provider
tasksStatus string
tasksList []tasks.Task
tasksGen int
allowedClasses []string // durable: the active session's allowed window classes
judge ai.DriftJudge
driftStatus string
@@ -139,6 +154,19 @@ type CoachView struct {
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.
type WindowView struct {
Class string `json:"class"`
@@ -165,6 +193,7 @@ type State struct {
Commitment *CommitmentView `json:"commitment"`
Evidence *EvidenceView `json:"evidence"`
Coach *CoachView `json:"coach,omitempty"`
Tasks *TasksView `json:"tasks,omitempty"`
Drift *DriftView `json:"drift,omitempty"`
}
@@ -279,6 +308,17 @@ func (c *Controller) stateLocked() State {
}
}
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 {
status := c.driftStatus
@@ -325,6 +365,7 @@ func (c *Controller) EnterPlanning() error {
}
c.runtimeState = next
c.resetCoachLocked()
c.startTasksFetchLocked()
return c.persistLocked()
}
@@ -345,6 +386,49 @@ func (c *Controller) resetCoachLocked() {
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.
func (c *Controller) AllowedClassesForTest() []string {
c.mu.Lock()