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/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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user