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:
@@ -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()
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"antidrift/internal/domain"
|
||||
"antidrift/internal/evidence"
|
||||
"antidrift/internal/store"
|
||||
"antidrift/internal/tasks"
|
||||
)
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user