fix(alarms): give up after three runs without delivery (FLAW-12)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
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:
1 parent
b5f926bcf7
commit
d1982816c1
8 files changed
+80
-13
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 '';",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user