mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 02:14:39 +00:00
* 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>
103 lines
5.0 KiB
Go
103 lines
5.0 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
var (
|
|
ErrSubagentRootAgentIDRequired = errors.New("subagent root agent ID required")
|
|
ErrSubagentTaskNotFound = errors.New("subagent task not found in owner scope")
|
|
)
|
|
|
|
const (
|
|
InterruptedSubagentTaskResult = "interrupted: gateway stopped before terminal completion was durably recorded"
|
|
// SubagentTaskStatusMaxLength matches the durable PG and SQLite schema contract.
|
|
SubagentTaskStatusMaxLength = 20
|
|
)
|
|
|
|
// IsTerminalSubagentTaskStatus reports whether a status ends the task
|
|
// lifecycle and therefore receives a completed_at timestamp.
|
|
func IsTerminalSubagentTaskStatus(status string) bool {
|
|
switch status {
|
|
case "completed", "failed", "cancelled":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// SubagentTaskData represents a persisted subagent task for audit trail and cost attribution.
|
|
type SubagentTaskData struct {
|
|
BaseModel
|
|
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
|
|
RootAgentID uuid.UUID `json:"root_agent_id" db:"root_agent_id"`
|
|
ParentAgentKey string `json:"parent_agent_key" db:"parent_agent_key"`
|
|
SessionKey *string `json:"session_key,omitempty" db:"session_key"`
|
|
Subject string `json:"subject" db:"subject"`
|
|
Description string `json:"description" db:"description"`
|
|
Status string `json:"status" db:"status"`
|
|
Result *string `json:"result,omitempty" db:"result"`
|
|
Depth int `json:"depth" db:"depth"`
|
|
Model *string `json:"model,omitempty" db:"model"`
|
|
Provider *string `json:"provider,omitempty" db:"provider"`
|
|
Iterations int `json:"iterations" db:"iterations"`
|
|
InputTokens int64 `json:"input_tokens" db:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens" db:"output_tokens"`
|
|
OriginChannel *string `json:"origin_channel,omitempty" db:"origin_channel"`
|
|
OriginChatID *string `json:"origin_chat_id,omitempty" db:"origin_chat_id"`
|
|
OriginPeerKind *string `json:"origin_peer_kind,omitempty" db:"origin_peer_kind"`
|
|
OriginUserID *string `json:"origin_user_id,omitempty" db:"origin_user_id"`
|
|
SpawnedBy *uuid.UUID `json:"spawned_by,omitempty" db:"spawned_by"`
|
|
CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"`
|
|
ArchivedAt *time.Time `json:"archived_at,omitempty" db:"archived_at"`
|
|
Metadata map[string]any `json:"metadata,omitempty" db:"metadata"`
|
|
}
|
|
|
|
// SubagentTaskStore persists subagent task lifecycle for audit trail and cost attribution.
|
|
// In-memory SubagentManager remains the source of truth for active operations.
|
|
type SubagentTaskStore interface {
|
|
// Create persists a new subagent task at spawn time.
|
|
Create(ctx context.Context, task *SubagentTaskData) error
|
|
|
|
// Get retrieves a task owned by the tenant and immutable root-agent UUID.
|
|
Get(ctx context.Context, rootAgentID, id uuid.UUID) (*SubagentTaskData, error)
|
|
|
|
// UpdateStatus updates status, result, iterations, and token counts on completion/failure.
|
|
UpdateStatus(ctx context.Context, rootAgentID, id uuid.UUID, status string, result *string, iterations int, inputTokens, outputTokens int64) error
|
|
|
|
// ListByParent returns tasks owned by a root-agent UUID, optionally filtered by status.
|
|
// Empty statusFilter returns all statuses. Ordered by created_at DESC.
|
|
ListByParent(ctx context.Context, rootAgentID uuid.UUID, statusFilter string) ([]SubagentTaskData, error)
|
|
|
|
// ListBySession returns tasks for a session owned by the tenant and immutable root-agent UUID.
|
|
ListBySession(ctx context.Context, rootAgentID uuid.UUID, sessionKey string) ([]SubagentTaskData, error)
|
|
|
|
// ListDelegationsByChat returns delegation tasks raised in one chat, owned by
|
|
// the tenant and immutable root-agent UUID. ListByParent and ListBySession
|
|
// both exclude delegations on purpose — they serve spawn — so this is the
|
|
// only listing that returns them. Ordered by created_at DESC.
|
|
ListDelegationsByChat(ctx context.Context, rootAgentID uuid.UUID, chatID string) ([]SubagentTaskData, error)
|
|
|
|
// Archive marks at most limit old terminal tasks owned by the tenant and
|
|
// immutable root-agent UUID as archived. Returns the number of rows affected.
|
|
Archive(ctx context.Context, rootAgentID uuid.UUID, olderThan time.Duration, limit int) (int64, error)
|
|
|
|
// UpdateMetadata merges metadata on an existing task.
|
|
UpdateMetadata(ctx context.Context, rootAgentID, id uuid.UUID, metadata map[string]any) error
|
|
}
|
|
|
|
// SubagentTaskRecoveryStore exposes the startup-only, cross-tenant maintenance
|
|
// operation separately from tenant/root-scoped task CRUD.
|
|
type SubagentTaskRecoveryStore interface {
|
|
// RecoverInterrupted marks every non-terminal task from the previous
|
|
// process as failed. It is a cross-tenant startup operation and must run
|
|
// before the gateway accepts new child runs. The current gateway
|
|
// architecture is single-process; multi-replica deployments would need
|
|
// coordinated ownership before invoking this recovery.
|
|
RecoverInterrupted(ctx context.Context) (int64, error)
|
|
}
|