mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 14:17:40 +00:00
Two fixes: 1. Remove assistant prefill from team task reminders. The injected [user]+[assistant]+[user] pattern caused LLMs to treat the canned ack as "turn complete", returning NO_REPLY for every user message in group sessions with active tasks. Reminders are now merged into the user message as prefix tags. 2. Add PeerKind propagation to team notification routing. TaskTicker and progress notifications were missing PeerKind on InboundMessage, causing them to route to phantom DM sessions instead of the correct group session. PeerKind is now carried through event payloads, notify queue metadata, and all inbound message publications.
222 lines
7.3 KiB
Go
222 lines
7.3 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// startTaskLockRenewal extends the task lock every 5 min while the agent works.
|
|
// Returns a stop func — call it when the run finishes. Returns nil if taskID is
|
|
// invalid or teamStore is nil.
|
|
func startTaskLockRenewal(ctx context.Context, teamStore store.TeamStore, taskID, teamID uuid.UUID) (stop func()) {
|
|
if taskID == uuid.Nil || teamStore == nil {
|
|
return nil
|
|
}
|
|
ch := make(chan struct{})
|
|
go func() {
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
if err := teamStore.RenewTaskLock(ctx, taskID, teamID); err != nil {
|
|
slog.Warn("teammate lock renewal failed", "task_id", taskID, "error", err)
|
|
return
|
|
}
|
|
slog.Debug("teammate lock renewed", "task_id", taskID)
|
|
case <-ch:
|
|
return
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
return func() { close(ch) }
|
|
}
|
|
|
|
// teammateTaskMeta holds resolved task context for post-turn processing.
|
|
type teammateTaskMeta struct {
|
|
TaskID uuid.UUID
|
|
TeamID uuid.UUID
|
|
ToAgent string
|
|
Channel string
|
|
ChatID string
|
|
PeerKind string // "group" or "direct" — for correct notification routing (#266)
|
|
Subject string
|
|
TaskNumber int
|
|
}
|
|
|
|
// resolveTeamTaskOutcome determines the correct task lifecycle action
|
|
// after a teammate run: error→fail, completed/escalated→skip, reviewed→renew,
|
|
// loopKilled→fail, default→auto-complete.
|
|
// Returns the resolved team (for announce routing) and dispatches unblocked tasks.
|
|
func resolveTeamTaskOutcome(
|
|
ctx context.Context,
|
|
deps *ConsumerDeps,
|
|
outcome scheduler.RunOutcome,
|
|
flags *tools.TaskActionFlags,
|
|
meta teammateTaskMeta,
|
|
) *store.TeamData {
|
|
if meta.TaskID == uuid.Nil || deps.TeamStore == nil {
|
|
return nil
|
|
}
|
|
|
|
cachedTeam, _ := deps.TeamStore.GetTeam(ctx, meta.TeamID)
|
|
if cachedTeam == nil {
|
|
return nil
|
|
}
|
|
|
|
// Check current task status — agent may have already updated it via tool.
|
|
currentTask, taskErr := deps.TeamStore.GetTask(ctx, meta.TaskID)
|
|
alreadyTerminal := taskErr == nil && currentTask != nil &&
|
|
(currentTask.Status == store.TeamTaskStatusCompleted ||
|
|
currentTask.Status == store.TeamTaskStatusFailed ||
|
|
currentTask.Status == store.TeamTaskStatusCancelled)
|
|
|
|
if alreadyTerminal {
|
|
// Always dispatch unblocked tasks even if already terminal.
|
|
if deps.PostTurn != nil {
|
|
deps.PostTurn.DispatchUnblockedTasks(ctx, meta.TeamID)
|
|
}
|
|
return cachedTeam
|
|
}
|
|
|
|
toAgent := meta.ToAgent
|
|
now := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
|
taskSubject := meta.Subject
|
|
taskNumber := meta.TaskNumber
|
|
taskChannel := meta.Channel
|
|
taskChatID := meta.ChatID
|
|
taskPeerKind := meta.PeerKind
|
|
|
|
// Enrich with live task data if available.
|
|
if currentTask != nil {
|
|
if currentTask.Subject != "" {
|
|
taskSubject = currentTask.Subject
|
|
}
|
|
if currentTask.TaskNumber != 0 {
|
|
taskNumber = currentTask.TaskNumber
|
|
}
|
|
if currentTask.Channel != "" {
|
|
taskChannel = currentTask.Channel
|
|
}
|
|
if currentTask.ChatID != "" {
|
|
taskChatID = currentTask.ChatID
|
|
}
|
|
if taskPeerKind == "" && currentTask.Metadata != nil {
|
|
if pk, ok := currentTask.Metadata[tools.TaskMetaPeerKind].(string); ok && pk != "" {
|
|
taskPeerKind = pk
|
|
}
|
|
}
|
|
}
|
|
|
|
// Smart post-turn decision based on action flags.
|
|
// Only error, completed/escalated, and reviewed block auto-complete.
|
|
switch {
|
|
case outcome.Err != nil:
|
|
// Agent errored → auto-fail.
|
|
if err := deps.TeamStore.FailTask(ctx, meta.TaskID, meta.TeamID, outcome.Err.Error()); err != nil {
|
|
slog.Warn("auto-complete: FailTask error", "task_id", meta.TaskID, "error", err)
|
|
} else {
|
|
bus.BroadcastForTenant(deps.MsgBus, protocol.EventTeamTaskFailed, store.TenantIDFromContext(ctx), tools.BuildTaskEventPayload(
|
|
meta.TeamID.String(), meta.TaskID.String(),
|
|
store.TeamTaskStatusFailed,
|
|
"agent", toAgent,
|
|
tools.WithTaskInfo(taskNumber, taskSubject),
|
|
tools.WithReason(outcome.Err.Error()),
|
|
tools.WithChannel(taskChannel),
|
|
tools.WithChatID(taskChatID),
|
|
tools.WithPeerKind(taskPeerKind),
|
|
tools.WithTimestamp(now),
|
|
))
|
|
}
|
|
|
|
case flags.Completed || flags.Escalated:
|
|
// Tool already completed/failed the task — skip auto-complete.
|
|
slog.Info("post-turn: tool handled task", "task_id", meta.TaskID,
|
|
"completed", flags.Completed, "escalated", flags.Escalated)
|
|
|
|
case flags.Reviewed:
|
|
// Task submitted for review — skip auto-complete, renew lock.
|
|
_ = deps.TeamStore.RenewTaskLock(ctx, meta.TaskID, meta.TeamID)
|
|
slog.Info("post-turn: task submitted for review", "task_id", meta.TaskID)
|
|
|
|
case outcome.Result != nil && outcome.Result.LoopKilled:
|
|
// Loop detector killed the run → auto-fail instead of auto-complete.
|
|
// The agent was terminated (not stuck in_progress) — mark as failed
|
|
// so the leader sees clear failure signal and can retry directly.
|
|
failMsg := outcome.Result.Content
|
|
if failMsg == "" {
|
|
failMsg = "Agent run terminated by loop detector"
|
|
}
|
|
if err := deps.TeamStore.FailTask(ctx, meta.TaskID, meta.TeamID, failMsg); err != nil {
|
|
slog.Warn("auto-fail: FailTask error (loop kill)", "task_id", meta.TaskID, "error", err)
|
|
} else {
|
|
bus.BroadcastForTenant(deps.MsgBus, protocol.EventTeamTaskFailed, store.TenantIDFromContext(ctx), tools.BuildTaskEventPayload(
|
|
meta.TeamID.String(), meta.TaskID.String(),
|
|
store.TeamTaskStatusFailed,
|
|
"system", "loop_detector",
|
|
tools.WithTaskInfo(taskNumber, taskSubject),
|
|
tools.WithReason("loop_detector_kill"),
|
|
tools.WithChannel(taskChannel),
|
|
tools.WithChatID(taskChatID),
|
|
tools.WithPeerKind(taskPeerKind),
|
|
tools.WithTimestamp(now),
|
|
))
|
|
}
|
|
slog.Warn("post-turn: loop detector killed member run",
|
|
"task_id", meta.TaskID, "agent", toAgent)
|
|
|
|
default:
|
|
// Agent turn ended without terminal action — auto-complete.
|
|
// Covers: Progressed, Commented, Claimed, or no flags at all.
|
|
result := ""
|
|
if outcome.Result != nil {
|
|
result = outcome.Result.Content
|
|
if len(outcome.Result.Deliverables) > 0 {
|
|
result = strings.Join(outcome.Result.Deliverables, "\n\n---\n\n")
|
|
}
|
|
}
|
|
if result == "" {
|
|
result = "Agent run ended without explicit result"
|
|
}
|
|
if len(result) > 100_000 {
|
|
result = result[:100_000] + "\n[truncated]"
|
|
}
|
|
if err := deps.TeamStore.CompleteTask(ctx, meta.TaskID, meta.TeamID, result); err != nil {
|
|
slog.Warn("auto-complete: CompleteTask error", "task_id", meta.TaskID, "error", err)
|
|
} else {
|
|
bus.BroadcastForTenant(deps.MsgBus, protocol.EventTeamTaskCompleted, store.TenantIDFromContext(ctx), tools.BuildTaskEventPayload(
|
|
meta.TeamID.String(), meta.TaskID.String(),
|
|
store.TeamTaskStatusCompleted,
|
|
"agent", toAgent,
|
|
tools.WithTaskInfo(taskNumber, taskSubject),
|
|
tools.WithOwnerAgentKey(toAgent),
|
|
tools.WithChannel(taskChannel),
|
|
tools.WithChatID(taskChatID),
|
|
tools.WithPeerKind(taskPeerKind),
|
|
tools.WithTimestamp(now),
|
|
))
|
|
}
|
|
}
|
|
|
|
// Always dispatch unblocked tasks after member turn ends,
|
|
// regardless of whether the task was already completed by the tool.
|
|
// This ensures dependent tasks start only after the member's run finishes.
|
|
if deps.PostTurn != nil {
|
|
deps.PostTurn.DispatchUnblockedTasks(ctx, meta.TeamID)
|
|
}
|
|
|
|
return cachedTeam
|
|
}
|