feat(teams): split dispatched/assigned events + add completed notifications

- Change agent-side broadcasts from EventTeamTaskAssigned to
  EventTeamTaskDispatched (post-turn, fallback, unblock, retry)
- Add completed notification with leader-completion skip logic
- Add Completed field to TeamNotifyConfig with *bool backwards compat
- Differentiate dispatched messages: unblocked vs regular dispatch
- Add EventTeamTaskDispatched to audit event mapper
This commit is contained in:
viettranx committed 2026-03-17 18:02:54 +07:00
1 parent 4678065887
commit b735c16d93
6 files changed
+76 -26

No files matched your search

+41 -18
View File
@@ -542,15 +542,18 @@ func runGateway() {
if !ok || payload.TeamID == "" || payload.Channel == "" {
return
}
// Only forward assigned/failed/progress events (completed handled by announce-back).
var notifyType string
switch evt.Name {
case protocol.EventTeamTaskAssigned:
case protocol.EventTeamTaskDispatched:
notifyType = "dispatched"
case protocol.EventTeamTaskAssigned:
notifyType = "dispatched" // same config flag — human assign also notifies
case protocol.EventTeamTaskFailed:
notifyType = "failed"
case protocol.EventTeamTaskProgress:
notifyType = "progress"
case protocol.EventTeamTaskCompleted:
notifyType = "completed"
default:
return
}
@@ -579,6 +582,10 @@ func runGateway() {
if !cfg.Progress {
return
}
case "completed":
if !cfg.Completed {
return
}
}
// Skip internal channels.
@@ -586,22 +593,44 @@ func runGateway() {
return
}
// Resolve lead agent key (needed for leader mode routing + completed-by-leader skip).
var leadAgentKey string
if notifyAgentStore != nil {
if la, err := notifyAgentStore.GetByID(context.Background(), team.LeadAgentID); err == nil {
leadAgentKey = la.AgentKey
}
}
// Skip completed notification if task was completed by the leader
// (leader is already talking to the user, notification would be redundant).
if notifyType == "completed" && payload.OwnerAgentKey == leadAgentKey {
return
}
// Build notification message.
var content string
agentName := payload.OwnerAgentKey
if payload.OwnerDisplayName != "" {
agentName = payload.OwnerDisplayName
}
switch notifyType {
case "dispatched":
switch evt.Name {
case protocol.EventTeamTaskDispatched:
if payload.ActorID == "dispatch_unblocked" {
content = fmt.Sprintf("▶️ Task #%d \"%s\" → unblocked, dispatched to %s", payload.TaskNumber, payload.Subject, agentName)
} else {
content = fmt.Sprintf("📋 Task #%d \"%s\" → dispatched to %s", payload.TaskNumber, payload.Subject, agentName)
}
case protocol.EventTeamTaskAssigned:
content = fmt.Sprintf("📋 Task #%d \"%s\" → assigned to %s", payload.TaskNumber, payload.Subject, agentName)
case "progress":
case protocol.EventTeamTaskCompleted:
content = fmt.Sprintf("✅ Task #%d \"%s\" completed", payload.TaskNumber, payload.Subject)
case protocol.EventTeamTaskProgress:
if payload.ProgressStep != "" {
content = fmt.Sprintf("⏳ Task #%d \"%s\": %d%% — %s", payload.TaskNumber, payload.Subject, payload.ProgressPercent, payload.ProgressStep)
} else {
content = fmt.Sprintf("⏳ Task #%d \"%s\": %d%%", payload.TaskNumber, payload.Subject, payload.ProgressPercent)
}
case "failed":
case protocol.EventTeamTaskFailed:
reason := payload.Reason
if len(reason) > 200 {
reason = reason[:200] + "..."
@@ -609,17 +638,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" {
if notifyAgentStore != nil {
if la, err := notifyAgentStore.GetByID(context.Background(), team.LeadAgentID); err == nil {
leadAgent = la.AgentKey
}
}
if leadAgent == "" {
return
}
// In leader mode, require resolved agent key for routing.
if cfg.Mode == "leader" && leadAgentKey == "" {
return
}
batchKey := payload.TeamID + ":" + payload.ChatID
@@ -628,7 +649,7 @@ func runGateway() {
Channel: payload.Channel,
ChatID: payload.ChatID,
UserID: payload.UserID,
LeadAgent: leadAgent,
LeadAgent: leadAgentKey,
})
})
slog.Info("team progress notification subscriber registered")
@@ -889,6 +910,8 @@ func teamTaskEventType(eventName string) string {
return "claimed"
case protocol.EventTeamTaskAssigned:
return "assigned"
case protocol.EventTeamTaskDispatched:
return "dispatched"
case protocol.EventTeamTaskCompleted:
return "completed"
case protocol.EventTeamTaskFailed:
+22 -5
View File
@@ -4,9 +4,10 @@ import "encoding/json"
// TeamNotifyConfig controls which team task events are forwarded to chat channels.
type TeamNotifyConfig struct {
Dispatched bool `json:"dispatched"` // task assigned to member
Dispatched bool `json:"dispatched"` // task dispatched to member
Progress bool `json:"progress"` // member updates progress
Failed bool `json:"failed"` // task failed
Completed bool `json:"completed"` // task completed
Mode string `json:"mode"` // "direct" (outbound) or "leader" (through leader agent)
}
@@ -16,6 +17,7 @@ func DefaultTeamNotifyConfig() TeamNotifyConfig {
Dispatched: true,
Progress: true,
Failed: true,
Completed: true,
Mode: "direct",
}
}
@@ -28,15 +30,30 @@ func ParseTeamNotifyConfig(settings json.RawMessage) TeamNotifyConfig {
return cfg
}
var s struct {
Notifications *TeamNotifyConfig `json:"notifications"`
Notifications *struct {
Dispatched *bool `json:"dispatched"`
Progress *bool `json:"progress"`
Failed *bool `json:"failed"`
Completed *bool `json:"completed"`
Mode string `json:"mode"`
} `json:"notifications"`
}
if json.Unmarshal(settings, &s) != nil || s.Notifications == nil {
return cfg
}
n := s.Notifications
cfg.Dispatched = n.Dispatched
cfg.Progress = n.Progress
cfg.Failed = n.Failed
if n.Dispatched != nil {
cfg.Dispatched = *n.Dispatched
}
if n.Progress != nil {
cfg.Progress = *n.Progress
}
if n.Failed != nil {
cfg.Failed = *n.Failed
}
if n.Completed != nil {
cfg.Completed = *n.Completed
}
if n.Mode == "leader" {
cfg.Mode = "leader"
}
+1 -1
View File
@@ -133,7 +133,7 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) *
return ErrorResult("failed to retry task: " + err.Error())
}
t.manager.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
t.manager.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: task.TaskNumber,
+10
View File
@@ -73,9 +73,19 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any
}
ownerKey := t.manager.agentKeyFromID(ctx, agentID)
// Fetch task for TaskNumber/Subject needed by notification subscriber.
completedTask, _ := t.manager.teamStore.GetTask(ctx, taskID)
var taskNumber int
var taskSubject string
if completedTask != nil {
taskNumber = completedTask.TaskNumber
taskSubject = completedTask.Subject
}
t.manager.broadcastTeamEvent(protocol.EventTeamTaskCompleted, protocol.TeamTaskEventPayload{
TeamID: team.ID.String(),
TaskID: taskID.String(),
TaskNumber: taskNumber,
Subject: taskSubject,
Status: store.TeamTaskStatusCompleted,
OwnerAgentKey: ownerKey,
OwnerDisplayName: t.manager.agentDisplayName(ctx, ownerKey),
+1 -1
View File
@@ -234,7 +234,7 @@ func (m *TeamToolManager) DispatchUnblockedTasks(ctx context.Context, teamID uui
slog.Warn("DispatchUnblockedTasks: assign failed", "task_id", task.ID, "error", err)
continue
}
m.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
m.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: task.ID.String(),
TaskNumber: task.TaskNumber,
+1 -1
View File
@@ -91,7 +91,7 @@ func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.U
slog.Warn("post_turn: assign failed", "task_id", task.ID, "error", err)
continue
}
m.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
m.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: task.ID.String(),
TaskNumber: task.TaskNumber,