nudges: a resolved outcome and a way to close a pending alarm (V-535)
The sev4 repeat path reads the nudges table, so ending an alarm means writing an ending there. 'resolved' is the daemon closing it because the condition cleared, which is neither 'acted' nor 'ignored'. ResolvePendingTelegram is rule-scoped and accepts only the two endings the daemon may write. OldestPendingTelegram backs the age cap and scans into a NullInt64, because MIN over an empty set is one NULL row, not zero rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -269,6 +269,27 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
UPDATE proposed_routines
|
||||
SET accepted_ts = created_ts, reminder_id = NULL
|
||||
WHERE status = 'accepted' AND accepted_ts IS NULL;`,
|
||||
|
||||
// #21 — allow 'resolved' as a nudge outcome (Vikunja #535). The sev4 repeat
|
||||
// path needs an ending that means "the condition cleared, so I stopped
|
||||
// talking", which is neither 'acted' (he answered) nor 'ignored' (nobody
|
||||
// ever did). A CHECK cannot be altered in place, so the table is rebuilt.
|
||||
// Rows carry over unchanged; only the constraint widens.
|
||||
`CREATE TABLE nudges_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
rule TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored','resolved')),
|
||||
outcome_ts INTEGER
|
||||
);
|
||||
INSERT INTO nudges_new (id, ts, rule, channel, message, outcome, outcome_ts)
|
||||
SELECT id, ts, rule, channel, message, outcome, outcome_ts FROM nudges;
|
||||
DROP TABLE nudges;
|
||||
ALTER TABLE nudges_new RENAME TO nudges;
|
||||
CREATE INDEX IF NOT EXISTS idx_nudges_rule_ts ON nudges (rule, ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_nudges_outcome ON nudges (outcome);`,
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
@@ -26,6 +26,18 @@ const (
|
||||
NudgeActed = "acted"
|
||||
NudgeSnoozed = "snoozed"
|
||||
NudgeIgnored = "ignored"
|
||||
|
||||
// NudgeResolved — the thing it was about stopped being true, and nobody
|
||||
// answered. Distinct from acted, which means he did something, and from
|
||||
// ignored, which means he chose not to (Vikunja #535).
|
||||
//
|
||||
// It exists because a repeating alarm needs an ending that is not a lie.
|
||||
// Marking a cleared service_down "acted" would credit him with a response
|
||||
// he never made and would teach the cooldown tuner to nudge harder; leaving
|
||||
// it pending is what made the alarm ring for two hours after the service
|
||||
// came back. This outcome is written by the daemon, never by a person, and
|
||||
// it is deliberately invisible to the feedback loop — see RecentOutcomes.
|
||||
NudgeResolved = "resolved"
|
||||
)
|
||||
|
||||
// SnoozeDuration — how long one `snoozed` outcome keeps its rule quiet.
|
||||
@@ -103,9 +115,14 @@ func (s *Store) ResolveNudge(ctx context.Context, id int64, outcome string, ts t
|
||||
// for a given rule — the feedback loop's only input. used to compute
|
||||
// ignored_rate → cooldown sizing. dead simple at mvp: a ratio over last N.
|
||||
func (s *Store) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
|
||||
// 'resolved' is excluded alongside 'pending' (Vikunja #535). The tuner reads
|
||||
// this as "how often does he answer me", and a resolution the daemon wrote
|
||||
// is not him answering. Counting it would dilute both rates toward zero and
|
||||
// make a service that fixes itself look like a rule he neither acts on nor
|
||||
// ignores, which is a fact about the world and not feedback about her.
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT outcome FROM nudges
|
||||
WHERE rule = ? AND outcome != 'pending'
|
||||
WHERE rule = ? AND outcome NOT IN ('pending', 'resolved')
|
||||
ORDER BY ts DESC, id DESC LIMIT ?`, rule, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recent outcomes: %w", err)
|
||||
@@ -152,6 +169,61 @@ func (s *Store) UnackedTelegramRules(ctx context.Context) ([]string, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ResolvePendingTelegram closes every still-pending telegram nudge for a rule
|
||||
// and reports how many it closed. This is what stops a repeating alarm
|
||||
// (Vikunja #535).
|
||||
//
|
||||
// Rule-scoped and not id-scoped, deliberately: the repeat key IS the rule name,
|
||||
// so a rule with several pending rows repeats once per tick for all of them and
|
||||
// must go quiet for all of them at once. Closing one id would leave the alarm
|
||||
// ringing on the others.
|
||||
//
|
||||
// The outcome is a parameter rather than hardcoded, because "the condition
|
||||
// cleared" and "nobody could ever answer this" are different endings and
|
||||
// /notifications should not show them as the same one.
|
||||
func (s *Store) ResolvePendingTelegram(ctx context.Context, rule, outcome string, ts time.Time) (int64, error) {
|
||||
switch outcome {
|
||||
case NudgeResolved, NudgeIgnored:
|
||||
default:
|
||||
return 0, fmt.Errorf("store: %q is not an ending the daemon may write", outcome)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE nudges SET outcome = ?, outcome_ts = ?
|
||||
WHERE rule = ? AND channel = 'telegram' AND outcome = 'pending'`,
|
||||
outcome, ts.UnixMilli(), rule)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("resolve pending telegram %s: %w", rule, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("resolve pending telegram %s: rows affected: %w", rule, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// OldestPendingTelegram returns when the oldest still-pending telegram nudge
|
||||
// for a rule was sent. Used for the repeat age cap: an alarm nobody has
|
||||
// answered in hours is not one more repeat away from being answered.
|
||||
//
|
||||
// Oldest and not newest, because the age that matters is how long the alarm has
|
||||
// been ringing, not when it last rang — the repeat itself does not create new
|
||||
// rows, but a re-fire of the rule does, and the alarm is the whole run.
|
||||
func (s *Store) OldestPendingTelegram(ctx context.Context, rule string) (time.Time, error) {
|
||||
// MIN over an empty set is one row holding NULL, not zero rows, so this
|
||||
// scans into a NullInt64 and never sees sql.ErrNoRows.
|
||||
var tsMilli sql.NullInt64
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT MIN(ts) FROM nudges
|
||||
WHERE rule = ? AND channel = 'telegram' AND outcome = 'pending'`, rule).Scan(&tsMilli)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("oldest pending telegram %s: %w", rule, err)
|
||||
}
|
||||
if !tsMilli.Valid {
|
||||
return time.Time{}, ErrNudgeNotFound
|
||||
}
|
||||
return time.UnixMilli(tsMilli.Int64).UTC(), nil
|
||||
}
|
||||
|
||||
// SnoozedUntil — per rule, when its most recent snooze runs out. This is the
|
||||
// read behind the gate's snooze check: the `snoozed` outcome already in the
|
||||
// nudges table IS the restraint memory, so there is no snooze table.
|
||||
|
||||
@@ -43,7 +43,9 @@ CREATE TABLE IF NOT EXISTS nudges (
|
||||
rule TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored')),
|
||||
-- 'resolved' is the daemon closing an alarm because the condition cleared,
|
||||
-- as opposed to 'acted' (he answered) or 'ignored' (nobody ever did).
|
||||
outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored','resolved')),
|
||||
outcome_ts INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_nudges_rule_ts ON nudges (rule, ts DESC);
|
||||
|
||||
Reference in New Issue
Block a user