Files
yatulandClaude Opus 5 305bd7c50d fix(teams): grant a delegated lead read access to its own team (#1537)
* fix(teams): scope team tasks to the delegation origin, not the delivery channel

A delegated run is delivered on the internal "delegate" channel while its real
origin is preserved separately — buildAgentLinkRunRequest is explicit about it:

    // preserves the origin's authorization-bearing identity while keeping
    // delegation on its internal delivery channel.
    Channel:          "delegate",
    WorkspaceChannel: req.Channel,
    WorkspaceChatID:  req.ChatID,

The team tools did not consult that origin, so a task created by a lead reached
through delegate was stamped with the delivery channel. Nothing is registered
for it, so every notification about that task — completion, failure, blocker
escalation, ask_user — was dropped:

    unknown channel for outbound message channel=delegate

The delegatee's first answer still arrived, because it travels back as the
delegation result rather than through a channel; everything the lead said after
the delegation closed was lost. One session produced 16 such drops, including a
blocker escalation the user needed to see.

Add OriginChannelFromCtx / OriginChatIDFromCtx next to the existing workspace
scope propagation helpers, and use them where team scoping and notification
routing are decided: task records, list/search scoping, dispatch fallbacks,
event payloads, escalation tasks, ask_user and leader notifications. The
resolution is the identity when no delegation origin is present, so
non-delegated flows are unchanged.

Deliberately not touched: the channel used in authorization decisions
(checkTeamAccess, requireLead, approve/reject lead bypass). Those ask "how did
this call arrive", not "where should the answer go", and widening them is a
separate question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(teams): cover delegated-lead completion routing and origin isolation

Triage on #1529 named two gates this PR had not met: regression coverage for
delegated lead task completion, and a guard that unrelated origins cannot
receive the notification. The existing tests only covered what create persists.

TestDelegatedLeadCompletionNotifiesOrigin completes a task raised in a delegated
context and asserts the completion event is addressed to the caller's origin.
Reverting team_event_helpers.go to the delivery channel fails it with
"addressed to delegate/system".

TestCompletionNotificationStaysWithinItsOwnOrigin puts two tasks from different
origins on one board, completes one, and asserts no completion notification
carries the other origin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(teams): grant a delegated lead read access to its own team

Fixes #1535. Second attempt: the first one set RunRequest.TeamWorkspace on the
delegated run, which loop_context.go rejects outright and deliberately so — that
field also becomes ToolWorkspace, which would displace the exchange outputs
directory. Every delegation then died at setup with "invalid delegation artifact
workspace". This does not go near that field.

The separation the lead needs already exists in the code. A lead addressed
directly resolves its team at loop_context.go:285-321 and gets

    ctx = tools.WithToolTeamWorkspace(ctx, wsDir)
    ctx = tools.WithToolTeamRoot(ctx, teamRoot)

with no WithToolWorkspace call — a read allowance, not an override. Only the
!isArtifactDelegation gate keeps a delegated lead out of it. So the grant is
made inside the artifact branch instead, from the same inputs, and the guard on
req.TeamWorkspace stays exactly as strict as it was.

Both paths are needed. The workspace alone covers only the lead's own chat leaf;
the deliverable in the report sits at teams/<id>/system/review-....md, written by
a member under a different chat scope. buildAllowedPrefixes adds the team root
for reads and not for writes (filesystem.go:366-370), which is precisely the
asymmetry this case wants: the lead reads the team's output, per-chat write
isolation is untouched.

Team ID is deliberately not set. It switches on the workspace interceptor's
write validation, file-change broadcast and task attachment (workspace_
interceptor.go:36,114,203) — none of which a delegated run should trigger, and
none of which reading needs.

Ambiguity is not resolved silently, as triage asked: an agent leading more than
one active team gets nothing. store.GetTeamForAgent would answer in one call but
it is ORDER BY (lead_agent_id = $1) DESC LIMIT 1 and would quietly pick a team.

Hermeticity is unchanged: send_file and message still refuse inside an artifact
run, publication still happens through delegation outputs on completion.

Tests: TestInjectContext_DelegatedLeadReadsTeamWithoutDisplacingOutputs drives
injectContext through the guard that broke the first attempt and pins both
halves — the run is not refused, and ToolWorkspace stays the outputs directory.
Resolution rules, the shared/isolated path shapes, read-not-write on the team
root, and send_file staying blocked are covered alongside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(teams): pin that the delegated lead resolver does not re-scope by tenant

l.dataDir arrives already tenant-scoped from resolver.go (config.TenantDataDir),
which is why resolveDelegatedLeadTeamRead must not apply TenantLayer itself.
That was carried only by a comment, here and in the sibling branch of
injectContext, and nothing failed if someone added the layer back.

The failure it guards against is quiet: the path stays plausible, it just gains
a second tenant segment — teams/<id> under tenants/<slug>/tenants/<slug> — so
the lead is handed a directory its own tasks never write to, and reads come back
empty rather than denied.

Verified the assertion has teeth by reintroducing the layer: the subtest fails
with "tenant segment appears 2 times". Prompted by #1546 in this same area,
where a contract that lived outside the unit under test went unpinned and the
feature shipped green and did nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:05:49 +07:00

413 lines
13 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const listPageSize = 30
const searchPageSize = 5
// blockerSummary is a compact view of a blocker task for blocked_by resolution.
type blockerSummary struct {
ID uuid.UUID `json:"id"`
Subject string `json:"subject"`
Status string `json:"status"`
OwnerAgentKey string `json:"owner_agent_key,omitempty"`
}
// taskListItem is the slim view returned by list/search actions.
// Excludes UUIDs (owner_agent_id, created_by_agent_id) and task_number — model uses
// agent keys and identifier instead.
type taskListItem struct {
ID uuid.UUID `json:"id"`
Identifier string `json:"identifier"`
Subject string `json:"subject"`
Status string `json:"status"`
OwnerAgentKey string `json:"owner_agent_key,omitempty"`
OwnerDisplayName string `json:"owner_display_name,omitempty"`
CreatedByAgentKey string `json:"created_by_agent_key,omitempty"`
CreatedByDisplayName string `json:"created_by_display_name,omitempty"`
ProgressPercent int `json:"progress_percent,omitempty"`
ProgressStep string `json:"progress_step,omitempty"`
BlockedBy []blockerSummary `json:"blocked_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// taskDetailItem is the slim view returned by the get action.
type taskDetailItem struct {
ID uuid.UUID `json:"id"`
Identifier string `json:"identifier"`
Subject string `json:"subject"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
Result *string `json:"result,omitempty"`
OwnerAgentKey string `json:"owner_agent_key,omitempty"`
OwnerDisplayName string `json:"owner_display_name,omitempty"`
CreatedByAgentKey string `json:"created_by_agent_key,omitempty"`
CreatedByDisplayName string `json:"created_by_display_name,omitempty"`
ProgressPercent int `json:"progress_percent,omitempty"`
ProgressStep string `json:"progress_step,omitempty"`
BlockedBy []blockerSummary `json:"blocked_by,omitempty"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// slimComment is the slim comment view for get response.
type slimComment struct {
AgentKey string `json:"agent_key"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
// slimEvent is the slim event view for get response.
type slimEvent struct {
EventType string `json:"event_type"`
ActorID string `json:"actor_id"`
CreatedAt time.Time `json:"created_at"`
}
// teamCreateLocks serializes list→create flows per (teamID:chatID) pair.
var teamCreateLocks sync.Map // key: "teamID:chatID" → *sync.Mutex
func getTeamCreateLock(teamID, chatID string) *sync.Mutex {
key := teamID + ":" + chatID
v, _ := teamCreateLocks.LoadOrStore(key, &sync.Mutex{})
return v.(*sync.Mutex)
}
// resolveBlockers batch-loads blocker tasks and returns slim summaries.
func (t *TeamTasksTool) resolveBlockers(ctx context.Context, blockedBy []uuid.UUID) []blockerSummary {
if len(blockedBy) == 0 {
return nil
}
tasks, err := t.manager.Store().GetTasksByIDs(ctx, blockedBy)
if err != nil {
return nil
}
out := make([]blockerSummary, 0, len(tasks))
for _, task := range tasks {
out = append(out, blockerSummary{
ID: task.ID,
Subject: task.Subject,
Status: task.Status,
OwnerAgentKey: task.OwnerAgentKey,
})
}
return out
}
func (t *TeamTasksTool) toListItem(ctx context.Context, task store.TeamTaskData) taskListItem {
return taskListItem{
ID: task.ID,
Identifier: task.Identifier,
Subject: task.Subject,
Status: task.Status,
OwnerAgentKey: task.OwnerAgentKey,
OwnerDisplayName: t.manager.AgentDisplayName(ctx, task.OwnerAgentKey),
CreatedByAgentKey: task.CreatedByAgentKey,
CreatedByDisplayName: t.manager.AgentDisplayName(ctx, task.CreatedByAgentKey),
ProgressPercent: task.ProgressPercent,
ProgressStep: task.ProgressStep,
BlockedBy: t.resolveBlockers(ctx, task.BlockedBy),
CreatedAt: task.CreatedAt,
}
}
func (t *TeamTasksTool) toDetailItem(ctx context.Context, task *store.TeamTaskData) taskDetailItem {
return taskDetailItem{
ID: task.ID,
Identifier: task.Identifier,
Subject: task.Subject,
Description: task.Description,
Status: task.Status,
Result: task.Result,
OwnerAgentKey: task.OwnerAgentKey,
OwnerDisplayName: t.manager.AgentDisplayName(ctx, task.OwnerAgentKey),
CreatedByAgentKey: task.CreatedByAgentKey,
CreatedByDisplayName: t.manager.AgentDisplayName(ctx, task.CreatedByAgentKey),
ProgressPercent: task.ProgressPercent,
ProgressStep: task.ProgressStep,
BlockedBy: t.resolveBlockers(ctx, task.BlockedBy),
Priority: task.Priority,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
}
}
// buildCreateHint generates task creation guidance with member+model info.
// Injected into search/list results so weaker models (MiniMax, Qwen) get
// actionable hints before calling create.
func (t *TeamTasksTool) buildCreateHint(ctx context.Context, teamID, leadAgentID, callerAgentID uuid.UUID) string {
members, err := t.manager.CachedListMembers(ctx, teamID, callerAgentID)
if err != nil || len(members) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("[Task creation guide]\nAvailable members and their models:\n")
for _, m := range members {
if m.AgentID == leadAgentID {
continue // skip lead from member list
}
model := ""
if ag, err := t.manager.CachedGetAgentByID(ctx, m.AgentID); err == nil {
model = ag.Model
}
entry := fmt.Sprintf("- %s (%s)", m.AgentKey, model)
if m.Frontmatter != "" {
fm := m.Frontmatter
if len([]rune(fm)) > 80 {
fm = string([]rune(fm)[:80]) + "…"
}
entry += " — " + fm
}
sb.WriteString(entry + "\n")
}
sb.WriteString("\nBefore creating a task, check:\n")
sb.WriteString("1. SKILL TEST: Does task need 2+ different skills (research+writing, design+coding)? → Split into separate tasks with blocked_by\n")
sb.WriteString("2. SCOPE TEST: Could a member finish this in one focused session? If not, break it down\n")
sb.WriteString("3. SELF-CONTAINED: Include objective, context, constraints, expected output. Description can be detailed — that's OK. Member only sees this\n")
sb.WriteString("4. MODEL MATCH: Complex reasoning → stronger model. Simple tasks → any model")
return sb.String()
}
func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
statusFilter, _ := args["status"].(string)
page := 1
if p, ok := args["page"].(float64); ok && int(p) > 1 {
page = int(p)
}
offset := (page - 1) * listPageSize
// Teammate/system channels see all tasks; end users only see their own.
filterUserID := ""
channel := OriginChannelFromCtx(ctx)
if channel != ChannelTeammate && channel != ChannelSystem {
filterUserID = store.UserIDFromContext(ctx)
}
chatID := OriginChatIDFromCtx(ctx)
// Shared workspace: show all tasks across chats.
listChatID := chatID
if IsSharedWorkspace(team.Settings) {
listChatID = ""
}
// Acquire team create lock to serialize list→create flows across concurrent goroutines.
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() {
lock := getTeamCreateLock(team.ID.String(), chatID)
lock.Lock()
ptd.SetTeamLock(lock)
ptd.MarkListed()
}
tasks, err := t.manager.Store().ListTasks(ctx, team.ID, "priority", statusFilter, filterUserID, "", listChatID, 0, offset)
if err != nil {
return ErrorResult("failed to list tasks: " + err.Error())
}
hasMore := len(tasks) > listPageSize
if hasMore {
tasks = tasks[:listPageSize]
}
// Pre-warm agent cache to avoid N+1 queries for display names.
agentKeys := make([]string, 0, len(tasks)*2)
for _, task := range tasks {
agentKeys = append(agentKeys, task.OwnerAgentKey, task.CreatedByAgentKey)
}
t.manager.PreWarmAgentKeyCache(ctx, agentKeys)
items := make([]taskListItem, 0, len(tasks))
for _, task := range tasks {
items = append(items, t.toListItem(ctx, task))
}
resp := map[string]any{
"tasks": items,
"count": len(items),
"page": page,
}
if hasMore {
resp["has_more"] = true
}
if hint := t.buildCreateHint(ctx, team.ID, team.LeadAgentID, agentID); hint != "" {
resp["hint"] = hint
}
out, _ := json.Marshal(resp)
return SilentResult(string(out))
}
// resolveTaskID extracts and validates the task_id from tool arguments.
// Falls back to the dispatched task ID from context when task_id is empty or
// not a valid UUID (agents often pass task_number like "1" instead of the UUID).
func resolveTaskID(ctx context.Context, args map[string]any) (uuid.UUID, error) {
taskIDStr, _ := args["task_id"].(string)
// Try parsing as UUID first.
if taskIDStr != "" {
if id, err := uuid.Parse(taskIDStr); err == nil {
return id, nil
}
}
// Fall back to the dispatched team task ID from context.
if ctxID := TeamTaskIDFromCtx(ctx); ctxID != "" {
if id, err := uuid.Parse(ctxID); err == nil {
return id, nil
}
}
if taskIDStr == "" {
return uuid.Nil, fmt.Errorf("task_id is required")
}
return uuid.Nil, fmt.Errorf("invalid task_id %q — use the UUID from task list, not the task number", taskIDStr)
}
func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Result {
team, _, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
task, err := t.manager.Store().GetTask(ctx, taskID)
if err != nil {
return ErrorResult("failed to get task: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
// Truncate result for context protection
const maxResultRunes = 8000
if task.Result != nil {
r := []rune(*task.Result)
if len(r) > maxResultRunes {
s := string(r[:maxResultRunes]) + "..."
task.Result = &s
}
}
// Pre-warm cache for task owner + creator display names.
t.manager.PreWarmAgentKeyCache(ctx, []string{task.OwnerAgentKey, task.CreatedByAgentKey})
detail := t.toDetailItem(ctx, task)
// Load and slim comments/events/attachments
resp := map[string]any{"task": detail}
if comments, _ := t.manager.Store().ListTaskComments(ctx, taskID); len(comments) > 0 {
// Pre-warm agent cache to avoid N+1 queries for comment agent keys.
commentAgentIDs := make([]uuid.UUID, 0, len(comments))
for _, c := range comments {
if c.AgentID != nil {
commentAgentIDs = append(commentAgentIDs, *c.AgentID)
}
}
t.manager.PreWarmAgentIDCache(ctx, commentAgentIDs)
slim := make([]slimComment, 0, len(comments))
for _, c := range comments {
key := ""
if c.AgentID != nil {
key = t.manager.AgentKeyFromID(ctx, *c.AgentID)
}
slim = append(slim, slimComment{
AgentKey: key,
Content: c.Content,
CreatedAt: c.CreatedAt,
})
}
resp["comments"] = slim
}
if events, _ := t.manager.Store().ListTaskEvents(ctx, taskID); len(events) > 0 {
slim := make([]slimEvent, 0, len(events))
for _, e := range events {
slim = append(slim, slimEvent{
EventType: e.EventType,
ActorID: e.ActorID,
CreatedAt: e.CreatedAt,
})
}
resp["events"] = slim
}
if attachments, _ := t.manager.Store().ListTaskAttachments(ctx, taskID); len(attachments) > 0 {
resp["attachments"] = attachments
}
out, _ := json.Marshal(resp)
return SilentResult(string(out))
}
func (t *TeamTasksTool) executeSearch(ctx context.Context, args map[string]any) *Result {
team, agentID, err := t.manager.ResolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
query, _ := args["query"].(string)
if query == "" {
return ErrorResult("query is required for search action")
}
// Teammate/system channels see all tasks; end users only see their own.
filterUserID := ""
channel := OriginChannelFromCtx(ctx)
if channel != ChannelTeammate && channel != ChannelSystem {
filterUserID = store.UserIDFromContext(ctx)
}
// Acquire team create lock so search also satisfies the list-before-create gate.
chatID := OriginChatIDFromCtx(ctx)
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() {
lock := getTeamCreateLock(team.ID.String(), chatID)
lock.Lock()
ptd.SetTeamLock(lock)
ptd.MarkListed()
}
tasks, err := t.manager.Store().SearchTasks(ctx, team.ID, query, searchPageSize, filterUserID)
if err != nil {
return ErrorResult("failed to search tasks: " + err.Error())
}
items := make([]taskListItem, 0, len(tasks))
for _, task := range tasks {
items = append(items, t.toListItem(ctx, task))
}
resp := map[string]any{
"tasks": items,
"count": len(items),
}
if hint := t.buildCreateHint(ctx, team.ID, team.LeadAgentID, agentID); hint != "" {
resp["hint"] = hint
}
out, _ := json.Marshal(resp)
return SilentResult(string(out))
}