presence: persist the resolved bucket, so hysteresis has a yesterday (V-532)
SavePresenceState had no caller outside tests. GatherState computed the score, resolved the bucket against the last one and threw the result away, so the singleton row was never written at all. Two things were broken by the one missing write. Hysteresis was dead. lastBucket read the cold-start Away every tick, so store.Resolve only ever took the `last == Away` arm and demanded a full PresenceEnter score to say he is at the desk. The 0.30-0.55 hold band the function exists to provide never applied once — with a 60s desk poster and tau=8min, presence dropped at about four minutes of idle instead of holding to the exit threshold at about nine. And every readout lied. /dash and ipc.Presence read this row, so they showed "away — score 0.00 (never)" while desk_active facts arrived every sixty seconds from workpc. The write goes in the tick, not in GatherState: that method holds a read-only transaction on purpose, one consistent snapshot per tick, and a write inside it would either break that guarantee or quietly upgrade the transaction. A failure logs and the tick continues, because the gate reads the in-memory bucket — which is why nudge routing kept working through all of this, and why the defect lived long enough to be found by looking at a dashboard. The existing hysteresis test scores the pure function and passed throughout, which is why nobody caught it. The new tests assert the round trip instead: the tick writes what gather resolved, a later write overwrites rather than appends, and the persisted bucket is what makes the hold band apply. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
This commit is contained in:
@@ -160,6 +160,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
log.Printf("tick: gather: %v", err)
|
||||
return
|
||||
}
|
||||
t.savePresence(ctx, state, now)
|
||||
|
||||
// proactive: at most one candidate, max severity.
|
||||
cand, trace := loop.ExplainTick(state, t.rules)
|
||||
@@ -260,6 +261,31 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// savePresence writes back the bucket GatherState just resolved.
|
||||
//
|
||||
// It lives here and not in GatherState because that method holds a read-only
|
||||
// transaction on purpose — one consistent snapshot per tick — and a write
|
||||
// inside it would either break that guarantee or quietly upgrade the
|
||||
// transaction. The tick is the layer that already owns writes.
|
||||
//
|
||||
// Nothing wrote this row before (Vikunja #532), and the row is the whole
|
||||
// mechanism, so two things were broken at once. Hysteresis was dead: lastBucket
|
||||
// read the cold-start Away on every tick, so store.Resolve only ever took the
|
||||
// `last == Away` arm and demanded a full PresenceEnter score to say he is
|
||||
// there. The 0.30-0.55 hold band the function exists to provide never applied
|
||||
// once. And every readout lied: /dash and ipc.Presence read this row, so they
|
||||
// showed "away — score 0.00 (never)" while desk_active facts were arriving
|
||||
// every sixty seconds.
|
||||
//
|
||||
// A failure logs and the tick continues. The gate reads the in-memory bucket,
|
||||
// which is why nudge routing kept working through all of this — losing the
|
||||
// write costs the next tick's hysteresis, not this tick's decisions.
|
||||
func (t *tickLoop) savePresence(ctx context.Context, state loop.State, now time.Time) {
|
||||
if err := t.store.SavePresenceState(ctx, state.Presence, state.PresenceScore, now); err != nil {
|
||||
log.Printf("tick: save presence state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// repeatableRules drops keys whose rule is not wired any more.
|
||||
//
|
||||
// The repeat path reads the nudges table, not the rule set: any sev4 telegram
|
||||
|
||||
Reference in New Issue
Block a user