Files
yatulandClaude Opus 5 0e3eb57317 fix(delegate): add action=list so a lost delegation ID is recoverable (#1546)
* feat(delegate): add action=list, scoped to the originating chat

Fixes #1545. A delegation result was addressable only by the UUID returned once
in a tool result, which the calling model had to carry forward by hand. One
mistyped character orphaned a completed, durably stored result with no way back:
`get` answers "delegation result not found", and there was nothing else to ask.
Observed in production with a 31B-class caller — one flipped character, and
separately a splice of the previous delegation's tail onto the next one's prefix.

`spawn`, the sibling async mechanism over the same table, has had list/wait/cancel
all along; `delegate` had delegate/get.

Scope is tenant and calling agent, as get already resolves, plus the origin chat.
The chat rather than the session, for three reasons:

  - It survives a session reset. Deferring long work, clearing the context and
    coming back to ask for status is ordinary use; a SessionKey predicate would
    return nothing exactly then — when the handle is most likely already lost.
  - It keeps chats apart, which is the enumeration boundary #1525 is about: there
    spawn's list filters on the parent agent key alone and ignores the session,
    so one chat reads another chat's task text.
  - It does not carry a conversation between chats. A delegation raised in a team
    chat stays visible in that team chat and does not surface in someone's DM
    with the same agent. History stays where it began.

In a direct chat that separates users as well, since the chat ID is per person.
Group chats deliberately show the group what the group started.

get is left as it was, deliberately. #1525 is an enumeration defect — no prior
knowledge needed and task text is disclosed. get is access through an unguessable
handle, and adding a predicate there would break fetching a result by an ID kept
across a reset, which is the very failure this fixes.

No schema change: the origin fields are already persisted by
createDelegateCompletion. ListByParent is filtered in Go behind a cap of 20,
which suits handle recovery; a dedicated predicate would be the next step if this
ever needs to page.

Tests pin the chat boundary, the session-reset case, refusal when there is no
chat to scope to (without querying the store), and the cap. The fake store leaves
ListBySession embedded and nil, so a refactor back to session scoping panics
rather than passing quietly.

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

* fix(delegate): list needs its own store query — ListByParent excludes delegations

The action shipped in 506ecba4 always returned an empty list. It read through
SubagentTaskStore.ListByParent, whose SQL carries

    AND COALESCE(metadata->>'completion_kind', 'subagent') <> 'delegate'

ListBySession carries the same clause. Both serve spawn and filter delegations
out on purpose, so no listing in the store could return one — only Get by ID
reaches a delegation. The feature was a no-op in production while its unit tests
were green, because the fake store returned whatever rows the fixture supplied
and never reproduced the predicate that does the damage.

Found by running it against a live cluster: an async delegation was created,
`get` returned it completed with its result, and `list` reported zero.

Adds ListDelegationsByChat to the interface and to both implementations, with
the inverse predicate plus origin_chat_id, and points the tool at it. Chat scope
and delegate-only selection now live in the query rather than in a Go filter over
whatever the store happened to return; an empty chat yields no rows instead of
falling back to everything.

Tests are where the fix matters most:

  - internal/store/sqlitestore exercises the real SQL. It pins that
    ListDelegationsByChat returns the chat's delegation, that a spawn in the same
    chat is not one, that another tenant's identically named chat stays invisible,
    and — the part that would have caught this — that ListByParent and
    ListBySession still do not return delegations, so the complementarity is
    documented rather than assumed.
  - the tool's fake now panics if ListByParent or ListBySession is called, so a
    regression to either fails loudly instead of quietly listing nothing, and it
    applies the chat and kind predicates itself so fixtures behave like the store.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:31:01 +07:00

374 lines
11 KiB
Go

//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteSubagentTaskStore implements store.SubagentTaskStore backed by SQLite.
type SQLiteSubagentTaskStore struct {
db *sql.DB
}
var _ store.SubagentTaskRecoveryStore = (*SQLiteSubagentTaskStore)(nil)
// NewSQLiteSubagentTaskStore creates a new SQLiteSubagentTaskStore.
func NewSQLiteSubagentTaskStore(db *sql.DB) *SQLiteSubagentTaskStore {
return &SQLiteSubagentTaskStore{db: db}
}
const subagentTaskInsertCols = `tenant_id, root_agent_id, parent_agent_key, session_key, subject, description,
status, result, depth, model, provider, iterations, input_tokens, output_tokens,
origin_channel, origin_chat_id, origin_peer_kind, origin_user_id, spawned_by, metadata`
const subagentTaskSelectCols = `id, tenant_id, root_agent_id, parent_agent_key, session_key, subject, description,
status, result, depth, model, provider, iterations, input_tokens, output_tokens,
origin_channel, origin_chat_id, origin_peer_kind, origin_user_id, spawned_by,
completed_at, archived_at, COALESCE(metadata, '{}'), created_at, updated_at`
// Create persists a new subagent task at spawn time.
func (s *SQLiteSubagentTaskStore) Create(ctx context.Context, task *store.SubagentTaskData) error {
tid := tenantIDForInsert(ctx)
if task.RootAgentID == uuid.Nil {
return store.ErrSubagentRootAgentIDRequired
}
metaJSON := []byte("{}")
if len(task.Metadata) > 0 {
if b, err := json.Marshal(task.Metadata); err == nil {
metaJSON = b
}
}
now := time.Now().UTC().Format(time.RFC3339Nano)
q := fmt.Sprintf(`INSERT OR IGNORE INTO subagent_tasks (id, %s, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, subagentTaskInsertCols)
_, err := s.db.ExecContext(ctx, q,
task.ID, tid, task.RootAgentID, task.ParentAgentKey, task.SessionKey, task.Subject, task.Description,
task.Status, task.Result, task.Depth, task.Model, task.Provider,
task.Iterations, task.InputTokens, task.OutputTokens,
task.OriginChannel, task.OriginChatID, task.OriginPeerKind, task.OriginUserID,
task.SpawnedBy, metaJSON,
now, now,
)
return err
}
// scanTask scans a single row into SubagentTaskData.
func scanTask(row interface{ Scan(...any) error }) (*store.SubagentTaskData, error) {
var t store.SubagentTaskData
var metaJSON []byte
var completedAt, archivedAt nullSqliteTime
var createdAt, updatedAt sqliteTime
err := row.Scan(
&t.ID, &t.TenantID, &t.RootAgentID, &t.ParentAgentKey, &t.SessionKey, &t.Subject, &t.Description,
&t.Status, &t.Result, &t.Depth, &t.Model, &t.Provider,
&t.Iterations, &t.InputTokens, &t.OutputTokens,
&t.OriginChannel, &t.OriginChatID, &t.OriginPeerKind, &t.OriginUserID, &t.SpawnedBy,
&completedAt, &archivedAt, &metaJSON, &createdAt, &updatedAt,
)
if err != nil {
return nil, err
}
if completedAt.Valid {
v := completedAt.Time
t.CompletedAt = &v
}
if archivedAt.Valid {
v := archivedAt.Time
t.ArchivedAt = &v
}
t.CreatedAt = createdAt.Time
t.UpdatedAt = updatedAt.Time
if len(metaJSON) > 2 {
_ = json.Unmarshal(metaJSON, &t.Metadata)
}
return &t, nil
}
// Get retrieves a task owned by the tenant and immutable root-agent UUID.
func (s *SQLiteSubagentTaskStore) Get(
ctx context.Context, rootAgentID, id uuid.UUID,
) (*store.SubagentTaskData, error) {
tid, err := requireTenantID(ctx)
if err != nil {
return nil, err
}
if rootAgentID == uuid.Nil {
return nil, store.ErrSubagentRootAgentIDRequired
}
q := fmt.Sprintf(`SELECT %s FROM subagent_tasks
WHERE id = ? AND tenant_id = ? AND root_agent_id = ?`, subagentTaskSelectCols)
row := s.db.QueryRowContext(ctx, q, id, tid, rootAgentID)
t, err := scanTask(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return t, err
}
// UpdateStatus updates status, result, iterations, and token counts on completion/failure.
func (s *SQLiteSubagentTaskStore) UpdateStatus(
ctx context.Context, rootAgentID, id uuid.UUID,
status string, result *string, iterations int,
inputTokens, outputTokens int64,
) error {
tid, err := requireTenantID(ctx)
if err != nil {
return err
}
if rootAgentID == uuid.Nil {
return store.ErrSubagentRootAgentIDRequired
}
now := time.Now().UTC().Format(time.RFC3339Nano)
var completedAt *string
if store.IsTerminalSubagentTaskStatus(status) {
v := now
completedAt = &v
}
q := `UPDATE subagent_tasks SET
status = ?, result = ?, iterations = ?,
input_tokens = ?, output_tokens = ?,
completed_at = ?, updated_at = ?
WHERE id = ? AND tenant_id = ? AND root_agent_id = ?`
res, err := s.db.ExecContext(ctx, q,
status, result, iterations, inputTokens, outputTokens,
completedAt, now, id, tid, rootAgentID,
)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return store.ErrSubagentTaskNotFound
}
return nil
}
// ListByParent returns tasks for a root-agent UUID, optionally filtered by status.
func (s *SQLiteSubagentTaskStore) ListByParent(
ctx context.Context, rootAgentID uuid.UUID, statusFilter string,
) ([]store.SubagentTaskData, error) {
tid, err := requireTenantID(ctx)
if err != nil {
return nil, err
}
if rootAgentID == uuid.Nil {
return nil, store.ErrSubagentRootAgentIDRequired
}
var rows *sql.Rows
if statusFilter != "" {
q := fmt.Sprintf(`SELECT %s FROM subagent_tasks
WHERE tenant_id = ? AND root_agent_id = ? AND status = ?
AND COALESCE(json_extract(metadata, '$.completion_kind'), 'subagent') <> 'delegate'
ORDER BY created_at DESC LIMIT 50`, subagentTaskSelectCols)
rows, err = s.db.QueryContext(ctx, q, tid, rootAgentID, statusFilter)
} else {
q := fmt.Sprintf(`SELECT %s FROM subagent_tasks
WHERE tenant_id = ? AND root_agent_id = ?
AND COALESCE(json_extract(metadata, '$.completion_kind'), 'subagent') <> 'delegate'
ORDER BY created_at DESC LIMIT 50`, subagentTaskSelectCols)
rows, err = s.db.QueryContext(ctx, q, tid, rootAgentID)
}
if err != nil {
return nil, err
}
defer rows.Close()
return collectTasks(rows)
}
// ListBySession returns tasks for a specific session key (tenant-scoped).
func (s *SQLiteSubagentTaskStore) ListBySession(
ctx context.Context, rootAgentID uuid.UUID, sessionKey string,
) ([]store.SubagentTaskData, error) {
tid, err := requireTenantID(ctx)
if err != nil {
return nil, err
}
if rootAgentID == uuid.Nil {
return nil, store.ErrSubagentRootAgentIDRequired
}
q := fmt.Sprintf(`SELECT %s FROM subagent_tasks
WHERE tenant_id = ? AND root_agent_id = ? AND session_key = ?
AND COALESCE(json_extract(metadata, '$.completion_kind'), 'subagent') <> 'delegate'
ORDER BY created_at DESC LIMIT 50`, subagentTaskSelectCols)
rows, err := s.db.QueryContext(ctx, q, tid, rootAgentID, sessionKey)
if err != nil {
return nil, err
}
defer rows.Close()
return collectTasks(rows)
}
// ListDelegationsByChat returns delegations raised in one chat (tenant-scoped).
// The inverse of the predicate ListByParent and ListBySession carry: those serve
// spawn and filter delegations out, so without this there is no way to list a
// delegation at all — only Get by ID reaches one.
func (s *SQLiteSubagentTaskStore) ListDelegationsByChat(
ctx context.Context, rootAgentID uuid.UUID, chatID string,
) ([]store.SubagentTaskData, error) {
tid, err := requireTenantID(ctx)
if err != nil {
return nil, err
}
if rootAgentID == uuid.Nil {
return nil, store.ErrSubagentRootAgentIDRequired
}
if chatID == "" {
return nil, nil
}
q := fmt.Sprintf(`SELECT %s FROM subagent_tasks
WHERE tenant_id = ? AND root_agent_id = ? AND origin_chat_id = ?
AND COALESCE(json_extract(metadata, '$.completion_kind'), 'subagent') = 'delegate'
ORDER BY created_at DESC LIMIT 50`, subagentTaskSelectCols)
rows, err := s.db.QueryContext(ctx, q, tid, rootAgentID, chatID)
if err != nil {
return nil, err
}
defer rows.Close()
return collectTasks(rows)
}
// Archive marks a bounded batch of old terminal tasks as archived.
func (s *SQLiteSubagentTaskStore) Archive(
ctx context.Context, rootAgentID uuid.UUID, olderThan time.Duration, limit int,
) (int64, error) {
tid, err := requireTenantID(ctx)
if err != nil {
return 0, err
}
if rootAgentID == uuid.Nil {
return 0, store.ErrSubagentRootAgentIDRequired
}
if limit <= 0 {
return 0, nil
}
cutoff := time.Now().UTC().Add(-olderThan).Format(time.RFC3339Nano)
now := time.Now().UTC().Format(time.RFC3339Nano)
q := `UPDATE subagent_tasks SET archived_at = ?, updated_at = ?
WHERE tenant_id = ? AND root_agent_id = ? AND id IN (
SELECT id
FROM subagent_tasks
WHERE tenant_id = ? AND root_agent_id = ?
AND status IN ('completed', 'failed', 'cancelled')
AND archived_at IS NULL AND completed_at < ?
ORDER BY completed_at, id
LIMIT ?
)`
res, err := s.db.ExecContext(
ctx, q,
now, now, tid, rootAgentID,
tid, rootAgentID, cutoff, limit,
)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// UpdateMetadata merges metadata keys atomically using json_set().
// Builds a single UPDATE statement to avoid read-merge-write race window.
func (s *SQLiteSubagentTaskStore) UpdateMetadata(
ctx context.Context, rootAgentID, id uuid.UUID, metadata map[string]any,
) error {
tid, err := requireTenantID(ctx)
if err != nil {
return err
}
if rootAgentID == uuid.Nil {
return store.ErrSubagentRootAgentIDRequired
}
if len(metadata) == 0 {
return nil
}
// Build json_set(metadata, '$.key1', ?, '$.key2', ?, ...) expression.
// Validate keys to prevent SQL injection via interpolated JSON path.
var parts []string
var args []any
for k, v := range metadata {
if !validMetadataKey(k) {
return fmt.Errorf("invalid metadata key: %q", k)
}
parts = append(parts, fmt.Sprintf("'$.%s', json(?)", k))
b, _ := json.Marshal(v)
args = append(args, string(b))
}
now := time.Now().UTC().Format(time.RFC3339Nano)
setExpr := "json_set(metadata, " + strings.Join(parts, ", ") + ")"
args = append(args, now, id, tid, rootAgentID)
q := fmt.Sprintf(`UPDATE subagent_tasks SET metadata = %s, updated_at = ?
WHERE id = ? AND tenant_id = ? AND root_agent_id = ?`, setExpr)
res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return store.ErrSubagentTaskNotFound
}
return nil
}
// RecoverInterrupted marks child runs that cannot survive a process restart as
// failed. This cross-tenant update runs before the gateway accepts new work.
func (s *SQLiteSubagentTaskStore) RecoverInterrupted(ctx context.Context) (int64, error) {
if !store.IsMasterScope(ctx) {
return 0, fmt.Errorf("recover interrupted subagent tasks requires master scope")
}
now := time.Now().UTC().Format(time.RFC3339Nano)
q := `UPDATE subagent_tasks SET
status = 'failed', result = ?, completed_at = ?, updated_at = ?
WHERE status NOT IN ('completed', 'failed', 'cancelled')`
res, err := s.db.ExecContext(ctx, q, store.InterruptedSubagentTaskResult, now, now)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func collectTasks(rows *sql.Rows) ([]store.SubagentTaskData, error) {
var tasks []store.SubagentTaskData
for rows.Next() {
t, err := scanTask(rows)
if err != nil {
return nil, err
}
tasks = append(tasks, *t)
}
return tasks, rows.Err()
}
// validMetadataKey returns true if the key is safe for use in json_set() SQL path.
var metadataKeyRe = regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
func validMetadataKey(k string) bool { return metadataKeyRe.MatchString(k) }