fix(teams): deduplicate task notifications and batch with debounce

Remove premature EventTeamTaskAssigned broadcast in executeCreate() that
caused duplicate "assigned to" Telegram notifications. Assignment
notification now only fires at actual dispatch (post-turn, fallback, or
unblocked).

Add TeamNotifyQueue (2s debounce, cap 20) to batch rapid-fire task
notifications per chat — reduces N messages to 1 when leader dispatches
multiple tasks at once. In leader mode this also reduces agent turns
from N to 1.

Also fix: ResetTaskStatus now clears progress_percent/progress_step on
retry, and retry broadcast includes TaskNumber/Subject for correct
notification formatting.
This commit is contained in:
viettranx committed 2026-03-17 14:29:52 +07:00
1 parent 8c662e7af4
commit aeadb20ba7
5 files changed
+140 -36

No files matched your search

+34 -20
View File
@@ -512,16 +512,37 @@ func runGateway() {
// Team progress notification subscriber — forwards task events to chat channels.
// Reads team.settings.notifications config; direct mode sends outbound, leader mode
// injects into leader agent session.
// injects into leader agent session. Notifications are batched per chat
// with 2s debounce to avoid spamming users when multiple tasks dispatch at once.
if pgStores.Teams != nil {
notifyTeamStore := pgStores.Teams
notifyAgentStore := pgStores.Agents
teamNotifyQueue := tools.NewTeamNotifyQueue(2000, func(items []string, meta tools.NotifyRoutingMeta) {
content := tools.FormatBatchedNotify(items)
if meta.Mode == "leader" {
leaderContent := fmt.Sprintf("[Auto-status — relay to user, NO task actions]\n%s\n\nBriefly inform the user. Do NOT create, retry, reassign, or modify any tasks.", content)
msgBus.TryPublishInbound(bus.InboundMessage{
Channel: meta.Channel,
SenderID: "notification:progress",
ChatID: meta.ChatID,
AgentID: meta.LeadAgent,
UserID: meta.UserID,
Content: leaderContent,
})
} else {
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: meta.Channel,
ChatID: meta.ChatID,
Content: content,
})
}
})
msgBus.Subscribe("consumer.team-notify", func(evt bus.Event) {
payload, ok := evt.Payload.(protocol.TeamTaskEventPayload)
if !ok || payload.TeamID == "" || payload.Channel == "" {
return
}
// Only forward assigned/failed events (completed handled by announce-back).
// Only forward assigned/failed/progress events (completed handled by announce-back).
var notifyType string
switch evt.Name {
case protocol.EventTeamTaskAssigned:
@@ -588,9 +609,9 @@ func runGateway() {
content = fmt.Sprintf("❌ Task #%d \"%s\" failed: %s", payload.TaskNumber, payload.Subject, reason)
}
// Resolve leader agent for leader mode routing.
var leadAgent string
if cfg.Mode == "leader" {
// Route through leader agent — model reformulates.
leadAgent := ""
if notifyAgentStore != nil {
if la, err := notifyAgentStore.GetByID(context.Background(), team.LeadAgentID); err == nil {
leadAgent = la.AgentKey
@@ -599,23 +620,16 @@ func runGateway() {
if leadAgent == "" {
return
}
leaderContent := fmt.Sprintf("[Auto-status — relay to user, NO task actions]\n%s\n\nBriefly inform the user. Do NOT create, retry, reassign, or modify any tasks.", content)
msgBus.TryPublishInbound(bus.InboundMessage{
Channel: payload.Channel,
SenderID: "notification:progress",
ChatID: payload.ChatID,
AgentID: leadAgent,
UserID: payload.UserID,
Content: leaderContent,
})
} else {
// Direct mode — send outbound directly to channel.
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: payload.Channel,
ChatID: payload.ChatID,
Content: content,
})
}
batchKey := payload.TeamID + ":" + payload.ChatID
teamNotifyQueue.Enqueue(batchKey, content, tools.NotifyRoutingMeta{
Mode: cfg.Mode,
Channel: payload.Channel,
ChatID: payload.ChatID,
UserID: payload.UserID,
LeadAgent: leadAgent,
})
})
slog.Info("team progress notification subscriber registered")
}
+2 -1
View File
@@ -180,7 +180,8 @@ func scanRecoveredTaskInfoRows(rows interface {
func (s *PGTeamStore) ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error {
now := time.Now()
res, err := s.db.ExecContext(ctx,
`UPDATE team_tasks SET status = $1, locked_at = NULL, lock_expires_at = NULL, result = NULL, updated_at = $2
`UPDATE team_tasks SET status = $1, locked_at = NULL, lock_expires_at = NULL, result = NULL,
progress_percent = NULL, progress_step = NULL, updated_at = $2
WHERE id = $3 AND team_id = $4 AND status IN ($5, $6)`,
store.TeamTaskStatusPending, now,
taskID, teamID, store.TeamTaskStatusStale, store.TeamTaskStatusFailed,
+102
View File
@@ -0,0 +1,102 @@
package tools
import (
"strings"
"sync"
"time"
)
// NotifyRoutingMeta carries routing info for batched team notifications.
type NotifyRoutingMeta struct {
Mode string // "direct" or "leader"
Channel string
ChatID string
UserID string
LeadAgent string // agent key (only used in leader mode)
}
// TeamNotifyQueue batches team task notifications per chat with debounce,
// following the same pattern as AnnounceQueue for subagent results.
type TeamNotifyQueue struct {
mu sync.Mutex
batches map[string]*notifyBatch // key: "teamID:chatID"
debounce time.Duration
cap int // immediate drain threshold
onDrain func(items []string, meta NotifyRoutingMeta)
}
type notifyBatch struct {
items []string
timer *time.Timer
meta NotifyRoutingMeta
}
// NewTeamNotifyQueue creates a notification queue with debounce and drain callback.
func NewTeamNotifyQueue(debounceMs int, onDrain func(items []string, meta NotifyRoutingMeta)) *TeamNotifyQueue {
if debounceMs <= 0 {
debounceMs = 2000
}
return &TeamNotifyQueue{
batches: make(map[string]*notifyBatch),
debounce: time.Duration(debounceMs) * time.Millisecond,
cap: 20,
onDrain: onDrain,
}
}
// Enqueue adds a formatted notification line to the batch for the given key.
// Resets debounce timer. Drains immediately if cap is reached.
func (q *TeamNotifyQueue) Enqueue(key string, content string, meta NotifyRoutingMeta) {
q.mu.Lock()
defer q.mu.Unlock()
b, ok := q.batches[key]
if !ok {
b = &notifyBatch{meta: meta}
q.batches[key] = b
}
b.items = append(b.items, content)
// Immediate drain if cap reached.
if len(b.items) >= q.cap {
if b.timer != nil {
b.timer.Stop()
}
items := b.items
bMeta := b.meta
delete(q.batches, key)
go q.drain(items, bMeta)
return
}
// Reset debounce timer.
if b.timer != nil {
b.timer.Stop()
}
b.timer = time.AfterFunc(q.debounce, func() {
q.mu.Lock()
b, ok := q.batches[key]
if !ok {
q.mu.Unlock()
return
}
items := b.items
bMeta := b.meta
delete(q.batches, key)
q.mu.Unlock()
q.drain(items, bMeta)
})
}
func (q *TeamNotifyQueue) drain(items []string, meta NotifyRoutingMeta) {
if len(items) == 0 || q.onDrain == nil {
return
}
q.onDrain(items, meta)
}
// FormatBatchedNotify joins notification lines into a single message.
func FormatBatchedNotify(items []string) string {
return strings.Join(items, "\n")
}
+2
View File
@@ -136,6 +136,8 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) *
t.manager.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
Status: store.TeamTaskStatusInProgress,
OwnerAgentKey: t.manager.agentKeyFromID(ctx, *task.OwnerAgentID),
UserID: store.UserIDFromContext(ctx),
-15
View File
@@ -176,21 +176,6 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
ActorType: "agent",
ActorID: agentKey,
})
t.manager.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: task.ID.String(),
TaskNumber: task.TaskNumber,
Subject: task.Subject,
Status: status,
OwnerAgentKey: t.manager.agentKeyFromID(ctx, assigneeID),
UserID: store.UserIDFromContext(ctx),
Channel: ToolChannelFromCtx(ctx),
ChatID: chatID,
Timestamp: task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
ActorType: "agent",
ActorID: agentKey,
})
// Track for post-turn dispatch. If no post-turn hook (e.g. HTTP API), dispatch immediately.
if status == store.TeamTaskStatusPending {
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil {