Files
Duc Nguyenandntduc bb7712a9ff fix(collaboration): harden delegated task isolation (#1486)
* feat(collaboration): isolate delegated artifacts and child runs

Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution.

* fix(collaboration): harden delegated task isolation

Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces.

* fix(collaboration): recover interrupted async tasks

* fix(collaboration): normalize persisted child-run status

---------

Co-authored-by: ntduc <ntduc@cpp.ai.vn>
2026-07-30 14:17:40 +07:00

298 lines
8.4 KiB
Go

package tools
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
)
var ErrAnnounceQueueDrainTimeout = errors.New("announce_queue_drain_timeout")
// AnnounceQueueItem represents a single subagent result waiting to be announced.
type AnnounceQueueItem struct {
SubagentID string
CompletionID uuid.UUID
DurablyPersisted bool
ParentTaskID string
Depth int
Label string
Status string // "completed", "failed", "cancelled"
Result string
Media []bus.MediaFile // media files from tool results
Runtime time.Duration
Iterations int
InputTokens int64
OutputTokens int64
}
// AnnounceMetadata carries origin info for routing the batched announce.
type AnnounceMetadata struct {
OriginChannel string
OriginChatID string
OriginPeerKind string
OriginLocalKey string // composite key with topic/thread suffix for routing
OriginUserID string
OriginSenderID string // real acting sender; preserves permission attribution through re-ingress (#915)
OriginRole string // caller's RBAC role; bypasses per-user grants for admin/operator/owner (#915)
OriginSessionKey string // exact parent session key (WS uses non-standard format)
OriginTenantID uuid.UUID // parent tenant for announce routing
RootAgentID uuid.UUID
ParentAgent string
OriginTraceID string // parent trace UUID for announce linking
OriginRootSpanID string // parent agent's root span UUID
}
// AnnounceQueue batches subagent announces per session with debounce,
// matching TS subagent-announce-queue.ts "collect" mode.
type AnnounceQueue struct {
mu sync.Mutex
queues map[string]*sessionQueue // session key → queue
debounce time.Duration // default 1000ms
cap int // max items per session before immediate drain (default 20)
onDrain func(sessionKey string, items []AnnounceQueueItem, meta AnnounceMetadata)
closed bool
drainWG sync.WaitGroup
}
type sessionQueue struct {
items []AnnounceQueueItem
timer *time.Timer
meta AnnounceMetadata
}
// NewAnnounceQueue creates an announce queue with the given debounce and drain callback.
func NewAnnounceQueue(
debounceMs int,
cap int,
onDrain func(sessionKey string, items []AnnounceQueueItem, meta AnnounceMetadata),
) *AnnounceQueue {
if debounceMs <= 0 {
debounceMs = 1000 // TS default
}
if cap <= 0 {
cap = 20 // TS default
}
return &AnnounceQueue{
queues: make(map[string]*sessionQueue),
debounce: time.Duration(debounceMs) * time.Millisecond,
cap: cap,
onDrain: onDrain,
}
}
// Enqueue adds an announce item to the session queue.
// Resets the debounce timer. Drains immediately if cap is reached.
func (aq *AnnounceQueue) Enqueue(sessionKey string, item AnnounceQueueItem, meta AnnounceMetadata) {
aq.mu.Lock()
defer aq.mu.Unlock()
if aq.closed {
return
}
sq, ok := aq.queues[sessionKey]
if !ok {
sq = &sessionQueue{meta: meta}
aq.queues[sessionKey] = sq
}
sq.items = append(sq.items, item)
// Drain immediately if cap reached
if len(sq.items) >= aq.cap {
if sq.timer != nil {
sq.timer.Stop()
}
items := sq.items
sqMeta := sq.meta
delete(aq.queues, sessionKey)
aq.startDrainLocked(sessionKey, items, sqMeta)
return
}
// Reset debounce timer
if sq.timer != nil {
sq.timer.Stop()
}
sq.timer = time.AfterFunc(aq.debounce, func() {
aq.mu.Lock()
sq, ok := aq.queues[sessionKey]
if !ok {
aq.mu.Unlock()
return
}
items := sq.items
sqMeta := sq.meta
delete(aq.queues, sessionKey)
aq.startDrainLocked(sessionKey, items, sqMeta)
aq.mu.Unlock()
})
}
func (aq *AnnounceQueue) startDrainLocked(
sessionKey string,
items []AnnounceQueueItem,
meta AnnounceMetadata,
) {
if aq.closed {
return
}
aq.drainWG.Add(1)
go func() {
defer aq.drainWG.Done()
aq.drain(sessionKey, items, meta)
}()
}
// CloseContext closes intake, drops pending debounce batches, and waits for any
// drain callback that already started. Dropping pending parent-resume messages
// during process shutdown avoids starting new agent runs against dependencies
// that are being torn down; task terminal state remains durable.
func (aq *AnnounceQueue) CloseContext(ctx context.Context) error {
if aq == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
aq.mu.Lock()
if !aq.closed {
aq.closed = true
for _, queue := range aq.queues {
if queue.timer != nil {
queue.timer.Stop()
}
}
clear(aq.queues)
}
aq.mu.Unlock()
done := make(chan struct{})
go func() {
aq.drainWG.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return fmt.Errorf("%w: %v", ErrAnnounceQueueDrainTimeout, ctx.Err())
}
}
// drain merges items into a single announce message and calls onDrain.
func (aq *AnnounceQueue) drain(sessionKey string, items []AnnounceQueueItem, meta AnnounceMetadata) {
if len(items) == 0 || aq.onDrain == nil {
return
}
aq.onDrain(sessionKey, items, meta)
}
// FormatBatchedAnnounce builds the announce content for a batch of items.
// roster provides deterministic task labels+statuses to prevent LLM hallucination.
func FormatBatchedAnnounce(items []AnnounceQueueItem, roster SubagentRoster) string {
if len(items) == 1 {
// Single item: use the same format as before (no batching overhead)
item := items[0]
statusLabel := "completed successfully"
if item.Status == "failed" {
statusLabel = "failed: " + item.Result
} else if item.Status == "cancelled" {
statusLabel = "was cancelled"
}
replyInstruction := BuildReplyInstruction(roster)
return fmt.Sprintf(
"[System Message] A subagent task %q just %s (task=%s, parent=%s, depth=%d).\n\n"+
"Result:\n%s\n\n"+
"Stats: runtime %s, iterations %d, tokens %d in / %d out\n\n"+
"%s",
item.Label, statusLabel, item.SubagentID, item.ParentTaskID, item.Depth, item.Result,
item.Runtime.Round(time.Millisecond), item.Iterations,
item.InputTokens, item.OutputTokens,
replyInstruction,
)
}
// Multiple items: batched format
var sb strings.Builder
sb.WriteString("[System Message] Multiple subagent tasks completed:\n")
for i, item := range items {
statusLabel := "completed"
if item.Status == "failed" {
statusLabel = "failed"
} else if item.Status == "cancelled" {
statusLabel = "cancelled"
}
sb.WriteString(fmt.Sprintf(
"\n---\nTask #%d: %q %s (task=%s, parent=%s, depth=%d, runtime %s, iterations %d, tokens %d/%d)\nResult: %s\n",
i+1, item.Label, statusLabel, item.SubagentID, item.ParentTaskID, item.Depth,
item.Runtime.Round(time.Millisecond), item.Iterations,
item.InputTokens, item.OutputTokens,
item.Result,
))
}
sb.WriteString("---\n\n")
sb.WriteString(BuildReplyInstruction(roster))
return sb.String()
}
// buildReplyInstruction generates the instruction block for the parent LLM,
// including a deterministic roster of all subagent tasks with their statuses.
func BuildReplyInstruction(roster SubagentRoster) string {
running := roster.Active
if running == 0 {
for _, entry := range roster.Entries {
if entry.Status == TaskStatusRunning {
running++
}
}
}
// Build roster block
var rosterBlock string
if len(roster.Entries) > 0 {
var sb strings.Builder
sb.WriteString(fmt.Sprintf(
"Subagent roster (%d active / %d max; %d retained total):\n",
running, roster.MaxPerAgent, roster.Total,
))
for _, e := range roster.Entries {
sb.WriteString(fmt.Sprintf(" [%-9s] %s\n", e.Status, e.Label))
}
rosterBlock = sb.String()
}
if running > 0 {
return fmt.Sprintf(
"%s\n"+
"%d subagent(s) still running. "+
"If they are part of the same workflow, wait for the remaining results "+
"before sending a user update. If they are unrelated, respond normally "+
"using only the result above. "+
"Do NOT copy or echo the [System Message] block verbatim — rewrite in your own voice. "+
"Reply ONLY: NO_REPLY if this result was already delivered to the user.",
rosterBlock, running,
)
}
return rosterBlock +
"\nAll subagent tasks completed. Convert the result above into your normal assistant voice and " +
"send that user-facing update now. Keep this internal context private " +
"(don't mention system/log/stats/session details or announce type), " +
"and do NOT copy the [System Message] block verbatim. " +
"Reply ONLY: NO_REPLY if this exact result was already delivered to the user."
}