From 16649b7b00ed0b3be03aa039922536a853993597 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 15 Jul 2026 01:22:08 +0000 Subject: fix(tasks): make CreateNextIteration atomically self-guarding Folds the "is this still the latest row in its series?" check directly into the INSERT as a single atomic INSERT...SELECT...WHERE NOT EXISTS statement, instead of a separate SELECT-then-INSERT sequence. Closes a narrow race where the completion trigger and the periodic due-date check could both pass their "still latest" check before either had inserted, producing two duplicate successor rows for the same series. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VTUSAEKfsPc6WGDq45yPHD --- internal/store/native_tasks.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) (limited to 'internal/store/native_tasks.go') diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go index 3bd87cb..90cf275 100644 --- a/internal/store/native_tasks.go +++ b/internal/store/native_tasks.go @@ -228,6 +228,10 @@ func (s *Store) UncompleteNativeTask(id string) error { // same recurrence_series_id, completed=false, next_occurrence_override=""), // with due_date set to old.NextOccurrenceOverride if present, else // ComputeNextOccurrence(old.DueDate, ...). old itself is left untouched. +// The INSERT is self-guarding: it atomically no-ops if a newer row already +// exists in the series, so two concurrent triggers (the completion path and +// the periodic due-date check) can never both create a successor for the +// same predecessor. func (s *Store) CreateNextIteration(old models.Task) error { var nextDue *time.Time switch { @@ -244,9 +248,16 @@ func (s *Store) CreateNextIteration(old models.Task) error { id, content, description, project_name, due_date, priority, labels, recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + WHERE NOT EXISTS ( + SELECT 1 FROM native_tasks + WHERE recurrence_series_id = ? + AND (due_date > ? OR (due_date = ? AND created_at > ?)) + ) `, newTaskID(), old.Content, old.Description, old.ProjectName, nextDue, old.Priority, string(labelsJSON), - old.RecurrenceFreq, old.RecurrenceInterval, formatWeekdays(old.RecurrenceWeekdays), old.RecurrenceSeriesID) + old.RecurrenceFreq, old.RecurrenceInterval, formatWeekdays(old.RecurrenceWeekdays), old.RecurrenceSeriesID, + old.RecurrenceSeriesID, old.DueDate, old.DueDate, old.CreatedAt) return err } -- cgit v1.2.3