From ba5205213f6f1995ca4e64b08925bcc6c48b8211 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 14 Jul 2026 20:30:16 +0000 Subject: feat(tasks): add periodic due-date check for recurring tasks A recurring task's successor now also gets created once its due date passes, independent of completion -- an ignored/overdue recurring task no longer blocks the next occurrence from appearing. Runs every 15 minutes via a new goroutine in main.go, cancelled on shutdown. --- internal/store/native_tasks.go | 43 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) (limited to 'internal/store/native_tasks.go') diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go index 501c0bc..d4cc896 100644 --- a/internal/store/native_tasks.go +++ b/internal/store/native_tasks.go @@ -298,6 +298,49 @@ func (s *Store) SetNextOccurrenceOverride(id string, date time.Time) error { return checkRowsAffected(result) } +// GetSeriesNeedingNextIteration returns the latest row of every recurring +// series whose due_date has arrived (<= now) and which has no newer row +// yet in its series -- regardless of completed state, so a series whose +// synchronous CreateNextIteration call (from CompleteNativeTask) somehow +// failed still gets healed on the next tick, and so an uncompleted, +// ignored recurring task doesn't block its successor from appearing. +func (s *Store) GetSeriesNeedingNextIteration(now time.Time) ([]models.Task, error) { + rows, err := s.db.Query(` + SELECT id, content, description, project_name, due_date, priority, completed, labels, created_at, + recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override + FROM native_tasks t1 + WHERE recurrence_series_id != '' + AND due_date IS NOT NULL AND due_date <= ? + AND NOT EXISTS ( + SELECT 1 FROM native_tasks t2 + WHERE t2.recurrence_series_id = t1.recurrence_series_id + AND (t2.due_date > t1.due_date + OR (t2.due_date = t1.due_date AND t2.created_at > t1.created_at)) + ) + `, now) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + return scanNativeTasks(rows) +} + +// AdvanceDueRecurringTasks creates the next iteration for every recurring +// series whose latest row is due (or overdue) and has no successor yet. +// Returns the number of iterations created. +func (s *Store) AdvanceDueRecurringTasks(now time.Time) (int, error) { + series, err := s.GetSeriesNeedingNextIteration(now) + if err != nil { + return 0, err + } + for _, task := range series { + if err := s.CreateNextIteration(task); err != nil { + return 0, err + } + } + return len(series), nil +} + // checkRowsAffected returns ErrNativeTaskNotFound if the update matched no // rows -- mirrors the RowsAffected() check already used in sqlite.go's // ApproveAgentSession/DenyAgentSession for the same "silent 0-row update" -- cgit v1.2.3