Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to this project will be documented in this file.

## Unreleased

- fix: stamp AND expire notification dedup entries with the injectable libtime clock (both sides, not just the write)
- test: add a fake-clock regression spec proving TTL expiry without sleeping

## v0.19.3

- chore: Delete tools.go — tool CLIs no longer declared as module dependencies; pinned via tools.env and `go run pkg@$(VERSION)` in Makefile
Expand Down
6 changes: 4 additions & 2 deletions pkg/notify/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"log/slog"
"sync"
"time"

libtime "github.com/bborbe/time"
)

// NewLogNotifier returns a Notifier that logs notifications to stdout instead of sending HTTP requests.
Expand All @@ -31,7 +33,7 @@ func (l *logNotifier) Notify(_ context.Context, notification Notification) error

l.mu.Lock()
lastSent, exists := l.seen[key]
if exists && time.Since(lastSent) < l.dedupTTL {
if exists && libtime.Now().Sub(lastSent) < l.dedupTTL {
l.mu.Unlock()
slog.Debug("log notifier skipped (duplicate within TTL)",
"task", notification.TaskName,
Expand All @@ -41,7 +43,7 @@ func (l *logNotifier) Notify(_ context.Context, notification Notification) error
)
return nil
}
l.seen[key] = time.Now()
l.seen[key] = libtime.Now()
l.mu.Unlock()

slog.Info("log notifier: task event",
Expand Down
38 changes: 38 additions & 0 deletions pkg/notify/log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"
"time"

libtime "github.com/bborbe/time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

Expand Down Expand Up @@ -130,3 +131,40 @@ var _ = Describe("LogNotifier", func() {
Expect(strings.Count(buf.String(), "log notifier: task event")).To(Equal(2))
})
})

var _ = Describe("LogNotifier dedup clock", func() {
var originalNow func() time.Time
var ctx context.Context

BeforeEach(func() { originalNow = libtime.Now; ctx = context.Background() })
AfterEach(func() { libtime.Now = originalNow })

// Regression: the dedup write and the TTL read must use the SAME clock.
// Before this fix the entry was stamped with libtime.Now() but expiry was
// measured with time.Since(), so advancing a fake clock past the TTL did
// not expire the entry and the second notify was still suppressed.
It("expires a dedup entry when the injected clock passes the TTL", func() {
current := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC)
libtime.Now = func() time.Time { return current }

var buf bytes.Buffer
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))

notifier := notify.NewLogNotifier(50 * time.Millisecond)
n := notify.Notification{TaskName: "t", Phase: "p"}

Expect(notifier.Notify(ctx, n)).To(Succeed())
first := strings.Count(buf.String(), "log notifier: task event")

// Same instant: still inside the TTL, so this one is deduped.
Expect(notifier.Notify(ctx, n)).To(Succeed())
Expect(strings.Count(buf.String(), "log notifier: task event")).To(Equal(first))

// Advance the injected clock past the TTL -- no real sleeping.
current = current.Add(time.Second)
Expect(notifier.Notify(ctx, n)).To(Succeed())
Expect(
strings.Count(buf.String(), "log notifier: task event"),
).To(BeNumerically(">", first))
})
})
5 changes: 3 additions & 2 deletions pkg/notify/notify.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"time"

"github.com/bborbe/errors"
libtime "github.com/bborbe/time"
)

// Notification holds the data sent to the webhook.
Expand Down Expand Up @@ -55,7 +56,7 @@ func (n *notifier) Notify(ctx context.Context, notification Notification) error

n.mu.Lock()
lastSent, exists := n.seen[key]
if exists && time.Since(lastSent) < n.dedupTTL {
if exists && libtime.Now().Sub(lastSent) < n.dedupTTL {
n.mu.Unlock()
slog.Debug("webhook skipped (duplicate within TTL)",
"task", notification.TaskName,
Expand All @@ -65,7 +66,7 @@ func (n *notifier) Notify(ctx context.Context, notification Notification) error
)
return nil
}
n.seen[key] = time.Now()
n.seen[key] = libtime.Now()
n.mu.Unlock()

body, err := json.Marshal(notification)
Expand Down
5 changes: 3 additions & 2 deletions pkg/notify/openclaw.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"time"

"github.com/bborbe/errors"
libtime "github.com/bborbe/time"
)

// openClawPayload is the JSON payload sent to the OpenClaw /hooks/wake endpoint.
Expand Down Expand Up @@ -54,7 +55,7 @@ func (n *openClawNotifier) Notify(ctx context.Context, notification Notification

n.mu.Lock()
lastSent, exists := n.seen[key]
if exists && time.Since(lastSent) < n.dedupTTL {
if exists && libtime.Now().Sub(lastSent) < n.dedupTTL {
n.mu.Unlock()
slog.Debug("webhook skipped (duplicate within TTL)",
"task", notification.TaskName,
Expand All @@ -64,7 +65,7 @@ func (n *openClawNotifier) Notify(ctx context.Context, notification Notification
)
return nil
}
n.seen[key] = time.Now()
n.seen[key] = libtime.Now()
n.mu.Unlock()

payload := openClawPayload{
Expand Down
5 changes: 3 additions & 2 deletions pkg/notify/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"time"

"github.com/bborbe/errors"
libtime "github.com/bborbe/time"
)

// telegramPayload is the JSON payload sent to the Telegram Bot API sendMessage endpoint.
Expand Down Expand Up @@ -75,7 +76,7 @@ func (t *telegramNotifier) Notify(ctx context.Context, notification Notification

t.mu.Lock()
lastSent, exists := t.seen[key]
if exists && time.Since(lastSent) < t.dedupTTL {
if exists && libtime.Now().Sub(lastSent) < t.dedupTTL {
t.mu.Unlock()
slog.Debug("telegram skipped (duplicate within TTL)",
"task", notification.TaskName,
Expand All @@ -85,7 +86,7 @@ func (t *telegramNotifier) Notify(ctx context.Context, notification Notification
)
return nil
}
t.seen[key] = time.Now()
t.seen[key] = libtime.Now()
t.mu.Unlock()

payload := telegramPayload{
Expand Down