fix(alarms): give up after three runs without delivery (FLAW-12)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s

New column alarm_failures counts ticker runs in which no channel
delivered; at three the alarm is marked as done and a warning is logged
instead of retrying every minute forever. Success, a new due date or
reopening the task starts over with three attempts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 12:22:13 +01:00
1 parent b5f926bcf7
commit d1982816c1
8 files changed
+80 -13

No files matched your search

+2
View File
@@ -151,6 +151,8 @@ func (s *Server) startAlarmTicker() {
if sentAny {
_ = s.store.MarkTaskAlarmSent(a.TaskID)
} else if gaveUp, _ := s.store.RecordTaskAlarmFailure(a.TaskID); gaveUp {
slog.Warn("Aufgaben-Alarm nach 3 Fehlversuchen aufgegeben", "task_id", a.TaskID, "title", a.Title)
}
}
}
+47
View File
@@ -138,3 +138,50 @@ func TestAlarmResetsOnNewDueDateOrReopen(t *testing.T) {
t.Errorf("reopening must re-arm the alarm")
}
}
func TestAlarmGivesUpAfterThreeFailedRuns(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "alarmfail.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
u, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
task, _ := store.CreateThoughtWithDetails(u.ID, "Webhook kaputt", "normal", "manual", "2026-10-30", "", "todo", "")
_, _ = store.UpdateThoughtTriage(u.ID, task.ID, "keep", "task", "", "", "")
pending := func() bool {
alarms, _ := store.GetDueTasksForAlarm()
for _, a := range alarms {
if a.TaskID == task.ID {
return true
}
}
return false
}
for run := 1; run <= MaxAlarmFailures; run++ {
gaveUp, err := store.RecordTaskAlarmFailure(task.ID)
if err != nil {
t.Fatalf("run %d: %v", run, err)
}
if gaveUp != (run == MaxAlarmFailures) || pending() == (run == MaxAlarmFailures) {
t.Fatalf("run %d: gaveUp=%v pending=%v", run, gaveUp, pending())
}
}
// A new due date gets three fresh attempts
later := "2026-11-02"
_, _ = store.UpdateThoughtDetails(u.ID, task.ID, nil, nil, &later, nil, nil, nil, nil, nil, nil)
if !pending() {
t.Fatalf("new due date must re-arm the alarm")
}
if gaveUp, _ := store.RecordTaskAlarmFailure(task.ID); gaveUp {
t.Errorf("failure counter must start over after a new due date")
}
// Success clears the counter
_ = store.MarkTaskAlarmSent(task.ID)
var failures int
_ = store.db.QueryRow(`SELECT alarm_failures FROM thoughts WHERE id = ?`, task.ID).Scan(&failures)
if failures != 0 {
t.Errorf("successful alarm should reset failures, got %d", failures)
}
}
+1
View File
@@ -375,6 +375,7 @@ func (s *Store) migrate() error {
// Idempotent column migrations for existing databases
columnMigrations := []string{
"ALTER TABLE thoughts ADD COLUMN alarm_failures INTEGER NOT NULL DEFAULT 0;",
"ALTER TABLE documents ADD COLUMN archived_at TEXT NOT NULL DEFAULT '';",
"ALTER TABLE documents ADD COLUMN archived_by TEXT NOT NULL DEFAULT '';",
"ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';",
+25 -8
View File
@@ -251,8 +251,9 @@ func (s *Store) ReorderTasks(taskListID string, items []domain.ThoughtReorderIte
}
_, err = tx.Exec(
`UPDATE thoughts SET sort_order = ?, kanban_status = ?, is_completed = ?,
alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END WHERE id = ?`,
it.SortOrder, *it.KanbanStatus, isCompleted, isCompleted, it.ID,
alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END,
alarm_failures = CASE WHEN ? = 0 THEN 0 ELSE alarm_failures END WHERE id = ?`,
it.SortOrder, *it.KanbanStatus, isCompleted, isCompleted, isCompleted, it.ID,
)
} else {
_, err = tx.Exec(
@@ -388,9 +389,10 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
_, err = s.db.Exec(
`UPDATE thoughts
SET status = ?, converted_type = ?, converted_ref_id = ?, due_date = ?, task_list_id = ?, triaged_at = ?,
alarm_sent = CASE WHEN due_date != ? THEN 0 ELSE alarm_sent END
alarm_sent = CASE WHEN due_date != ? THEN 0 ELSE alarm_sent END,
alarm_failures = CASE WHEN due_date != ? THEN 0 ELSE alarm_failures END
WHERE id = ? AND user_id = ?`,
string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, dueDate, thoughtID, userID,
string(newStatus), convertedType, convertedRefID, dueDate, taskListID, now, dueDate, dueDate, thoughtID, userID,
)
if err != nil {
return nil, err
@@ -479,7 +481,7 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
}
// A new due date or a reopened task gets its alarm again
if thought.DueDate != oldDue || (wasCompleted && !thought.IsCompleted) {
_, _ = s.db.Exec(`UPDATE thoughts SET alarm_sent = 0 WHERE id = ?`, thoughtID)
_, _ = s.db.Exec(`UPDATE thoughts SET alarm_sent = 0, alarm_failures = 0 WHERE id = ?`, thoughtID)
thought.AlarmSent = false
}
if taskListID != nil {
@@ -572,8 +574,9 @@ func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) {
doneInt = 1
kanban = "done"
}
res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ?, alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END
WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, doneInt, taskID, userID, doneInt)
res, _ := s.db.Exec(`UPDATE thoughts SET is_completed = ?, kanban_status = ?, alarm_sent = CASE WHEN ? = 0 THEN 0 ELSE alarm_sent END,
alarm_failures = CASE WHEN ? = 0 THEN 0 ELSE alarm_failures END
WHERE id = ? AND user_id = ? AND is_completed != ?`, doneInt, kanban, doneInt, doneInt, taskID, userID, doneInt)
if checked && res != nil {
if n, _ := res.RowsAffected(); n > 0 {
s.spawnNextOccurrence(taskID)
@@ -634,6 +637,20 @@ func (s *Store) GetDueTasksForAlarm() ([]DueTaskAlarm, error) {
}
func (s *Store) MarkTaskAlarmSent(taskID string) error {
_, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1 WHERE id = ?`, taskID)
_, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1, alarm_failures = 0 WHERE id = ?`, taskID)
return err
}
// MaxAlarmFailures: after this many runs in which no channel delivered, the alarm is given up instead of
// being retried every minute. A new due date or reopening the task starts over.
const MaxAlarmFailures = 3
// RecordTaskAlarmFailure counts a run without any successful channel and reports whether the alarm was
// given up now.
func (s *Store) RecordTaskAlarmFailure(taskID string) (gaveUp bool, err error) {
var failures int
err = s.db.QueryRow(`UPDATE thoughts SET alarm_failures = alarm_failures + 1,
alarm_sent = CASE WHEN alarm_failures + 1 >= ? THEN 1 ELSE alarm_sent END
WHERE id = ? RETURNING alarm_failures`, MaxAlarmFailures, taskID).Scan(&failures)
return err == nil && failures >= MaxAlarmFailures, err
}