* fix(beads): cache version check and add timeout to prevent cli lag
* fix(mail_queue): add nil check for queue config
Prevents potential nil pointer panic when queue config exists
in map but has nil value. Added || queueCfg == nil check to
the queue lookup condition in runMailClaim function.
Fixes potential panic that could occur if a queue entry exists
in config but with a nil value.
* fix(migrate_agents_test): fix icon expectations to match actual output
The printMigrationResult function uses icons with two leading spaces
(" ✓", " ⊘", " ✗") but the test expected icons without spaces.
This fixes the test expectations to match the actual output format.
* fix(hook): handle error from events.LogFeed
Previously the error from LogFeed was silently ignored with _.
Now we log the error to stderr at warning level but don't fail
the operation since the primary hook action succeeded.
* fix(tmux): security and error handling improvements
- Fix unchecked regexp error in IsClaudeRunning (CVE-like)
- Add input sanitization to SetPaneDiedHook to prevent shell injection
- Add session name validation to SetDynamicStatus
- Sanitize mail from/subject in SendNotificationBanner
- Return error on parse failure in GetEnvironment
- Track skipped lines in ListSessionIDs for debuggability
See: tmux.fix for full analysis
* fix(daemon): improve error handling and security
- Capture stderr in syncWorkspace for better debuggability
- Fail fast on git fetch failures to prevent stale code
- Add logging to previously silent bd list errors
- Change notification state file permissions to 0600
- Improve error messages with actual stderr content
This prevents agents from starting with stale code and provides
better visibility into daemon operations.
213 lines
5.2 KiB
Go
213 lines
5.2 KiB
Go
package daemon
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// NotificationSlot tracks a pending notification for deduplication.
|
|
// Only the latest notification per slot matters - earlier ones are replaced.
|
|
type NotificationSlot struct {
|
|
Slot string `json:"slot"`
|
|
Session string `json:"session"`
|
|
Message string `json:"message"`
|
|
SentAt time.Time `json:"sent_at"`
|
|
Consumed bool `json:"consumed"`
|
|
ConsumedAt time.Time `json:"consumed_at,omitempty"`
|
|
}
|
|
|
|
// NotificationManager handles slot-based notification deduplication.
|
|
// It ensures that for a given (session, slot) pair, only one notification
|
|
// is pending at a time. Sending a new notification to the same slot
|
|
// replaces the previous one.
|
|
type NotificationManager struct {
|
|
stateDir string // Directory for slot state files
|
|
maxAge time.Duration // Max age before considering a slot stale
|
|
}
|
|
|
|
// NewNotificationManager creates a new notification manager.
|
|
// stateDir is where slot state files are stored (e.g., ~/gt/daemon/notifications/)
|
|
func NewNotificationManager(stateDir string, maxAge time.Duration) *NotificationManager {
|
|
return &NotificationManager{
|
|
stateDir: stateDir,
|
|
maxAge: maxAge,
|
|
}
|
|
}
|
|
|
|
// slotPath returns the path to the slot state file.
|
|
func (m *NotificationManager) slotPath(session, slot string) string {
|
|
// Sanitize session name (replace / with -)
|
|
safeSession := session
|
|
for i := range safeSession {
|
|
if safeSession[i] == '/' {
|
|
safeSession = safeSession[:i] + "-" + safeSession[i+1:]
|
|
}
|
|
}
|
|
return filepath.Join(m.stateDir, fmt.Sprintf("slot-%s-%s.json", safeSession, slot))
|
|
}
|
|
|
|
// GetSlot reads the current state of a notification slot.
|
|
func (m *NotificationManager) GetSlot(session, slot string) (*NotificationSlot, error) {
|
|
path := m.slotPath(session, slot)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil // No slot state
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var ns NotificationSlot
|
|
if err := json.Unmarshal(data, &ns); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &ns, nil
|
|
}
|
|
|
|
// ShouldSend checks if a notification should be sent for this slot.
|
|
// Returns true if:
|
|
// - No pending notification exists for this slot
|
|
// - The pending notification is stale (older than maxAge)
|
|
// - The pending notification was consumed
|
|
func (m *NotificationManager) ShouldSend(session, slot string) (bool, error) {
|
|
ns, err := m.GetSlot(session, slot)
|
|
if err != nil {
|
|
return true, err // On error, allow sending
|
|
}
|
|
|
|
if ns == nil {
|
|
return true, nil // No pending notification
|
|
}
|
|
|
|
if ns.Consumed {
|
|
return true, nil // Previous was consumed
|
|
}
|
|
|
|
// Check if stale
|
|
if time.Since(ns.SentAt) > m.maxAge {
|
|
return true, nil // Stale, allow new send
|
|
}
|
|
|
|
return false, nil // Recent pending notification exists
|
|
}
|
|
|
|
// RecordSend records that a notification was sent for a slot.
|
|
func (m *NotificationManager) RecordSend(session, slot, message string) error {
|
|
// Ensure directory exists
|
|
if err := os.MkdirAll(m.stateDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
ns := &NotificationSlot{
|
|
Slot: slot,
|
|
Session: session,
|
|
Message: message,
|
|
SentAt: time.Now(),
|
|
Consumed: false,
|
|
}
|
|
|
|
data, err := json.Marshal(ns)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(m.slotPath(session, slot), data, 0600)
|
|
}
|
|
|
|
// MarkConsumed marks a slot's notification as consumed (agent responded).
|
|
func (m *NotificationManager) MarkConsumed(session, slot string) error {
|
|
ns, err := m.GetSlot(session, slot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if ns == nil {
|
|
return nil // Nothing to mark
|
|
}
|
|
|
|
ns.Consumed = true
|
|
ns.ConsumedAt = time.Now()
|
|
|
|
data, err := json.Marshal(ns)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(m.slotPath(session, slot), data, 0600)
|
|
}
|
|
|
|
// MarkSessionActive marks all slots for a session as consumed.
|
|
// Call this when the session shows activity (keepalive update).
|
|
func (m *NotificationManager) MarkSessionActive(session string) error {
|
|
// List all slot files for this session
|
|
pattern := filepath.Join(m.stateDir, fmt.Sprintf("slot-%s-*.json", session))
|
|
matches, err := filepath.Glob(pattern)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, path := range matches {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var ns NotificationSlot
|
|
if err := json.Unmarshal(data, &ns); err != nil {
|
|
continue
|
|
}
|
|
|
|
if !ns.Consumed {
|
|
ns.Consumed = true
|
|
ns.ConsumedAt = time.Now()
|
|
if data, err := json.Marshal(&ns); err == nil {
|
|
_ = os.WriteFile(path, data, 0644) // non-fatal: state file update
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ClearSlot removes the state file for a slot.
|
|
func (m *NotificationManager) ClearSlot(session, slot string) error {
|
|
path := m.slotPath(session, slot)
|
|
err := os.Remove(path)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ClearStaleSlots removes slot files older than maxAge.
|
|
func (m *NotificationManager) ClearStaleSlots() error {
|
|
pattern := filepath.Join(m.stateDir, "slot-*.json")
|
|
matches, err := filepath.Glob(pattern)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, path := range matches {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if time.Since(info.ModTime()) > m.maxAge {
|
|
_ = os.Remove(path) // best-effort cleanup
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Common notification slots
|
|
const (
|
|
SlotHeartbeat = "heartbeat"
|
|
SlotStatus = "status"
|
|
)
|