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>
This commit is contained in:
Duc Nguyenandntduc authored and GitHub committed 2026-07-30 14:17:40 +07:00
1 parent 496b7ffce6
commit bb7712a9ff
186 files changed
+18858 -1233

No files matched your search

+9 -2
View File
@@ -6,6 +6,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
@@ -207,6 +208,7 @@ func (s *SQLiteAgentLinkStore) GetLinkBetween(ctx context.Context, fromAgentID,
tenantFilter = " AND tenant_id = ?"
args = append(args, tenantID)
}
args = append(args, fromAgentID)
row := s.db.QueryRowContext(ctx,
`SELECT `+linkSelectCols+`
FROM agent_links WHERE status = 'active'
@@ -214,11 +216,16 @@ func (s *SQLiteAgentLinkStore) GetLinkBetween(ctx context.Context, fromAgentID,
(source_agent_id = ? AND target_agent_id = ? AND direction IN ('outbound', 'bidirectional'))
OR
(source_agent_id = ? AND target_agent_id = ? AND direction IN ('inbound', 'bidirectional'))
)`+tenantFilter+` LIMIT 1`, args...)
)`+tenantFilter+`
ORDER BY CASE WHEN source_agent_id = ? THEN 0 ELSE 1 END, created_at, id
LIMIT 1`, args...)
d, err := scanLinkRow(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return d, nil
}
@@ -0,0 +1,224 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func seedSQLiteLinkAgent(t *testing.T, db *sql.DB, tenantID uuid.UUID, key string) uuid.UUID {
t.Helper()
id := uuid.New()
_, err := db.Exec(
`INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
VALUES (?,?,?,'predefined','active','test','test-model','owner')`,
id.String(), tenantID.String(), key)
if err != nil {
t.Fatalf("seed agent %q: %v", key, err)
}
return id
}
func newSQLiteLinkFixture(t *testing.T) (*SQLiteAgentLinkStore, *sql.DB, context.Context, uuid.UUID, uuid.UUID, uuid.UUID) {
t.Helper()
db := newHookTestDB(t)
tenantID, agentA := seedHookTenantAgent(t, db)
if _, err := db.Exec(`UPDATE agents SET agent_key = 'agent-a' WHERE id = ?`, agentA.String()); err != nil {
t.Fatalf("rename agent A: %v", err)
}
agentB := seedSQLiteLinkAgent(t, db, tenantID, "agent-b")
agentC := seedSQLiteLinkAgent(t, db, tenantID, "agent-c")
ctx := store.WithTenantID(context.Background(), tenantID)
return NewSQLiteAgentLinkStore(db), db, ctx, agentA, agentB, agentC
}
func sqliteLink(source, target uuid.UUID, direction string) *store.AgentLinkData {
return &store.AgentLinkData{
SourceAgentID: source,
TargetAgentID: target,
Direction: direction,
Status: store.LinkStatusActive,
MaxConcurrent: 1,
CreatedBy: "test",
}
}
func targetKeys(t *testing.T, linkStore *SQLiteAgentLinkStore, ctx context.Context, from uuid.UUID) []string {
t.Helper()
targets, err := linkStore.DelegateTargets(ctx, from)
if err != nil {
t.Fatalf("DelegateTargets(%s): %v", from, err)
}
keys := make([]string, len(targets))
for i := range targets {
keys[i] = targets[i].TargetAgentKey
}
return keys
}
func TestSQLiteAgentLinkStoreDelegateTargetsDirections(t *testing.T) {
tests := []struct {
name string
direction string
wantFromA []string
wantFromB []string
}{
{
name: "outbound",
direction: store.LinkDirectionOutbound,
wantFromA: []string{"agent-b"},
},
{
name: "inbound",
direction: store.LinkDirectionInbound,
wantFromB: []string{"agent-a"},
},
{
name: "bidirectional",
direction: store.LinkDirectionBidirectional,
wantFromA: []string{"agent-b"},
wantFromB: []string{"agent-a"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
linkStore, _, ctx, agentA, agentB, _ := newSQLiteLinkFixture(t)
if err := linkStore.CreateLink(ctx, sqliteLink(agentA, agentB, tt.direction)); err != nil {
t.Fatalf("CreateLink: %v", err)
}
if got := targetKeys(t, linkStore, ctx, agentA); !slicesEqual(got, tt.wantFromA) {
t.Fatalf("targets from A = %v, want %v", got, tt.wantFromA)
}
if got := targetKeys(t, linkStore, ctx, agentB); !slicesEqual(got, tt.wantFromB) {
t.Fatalf("targets from B = %v, want %v", got, tt.wantFromB)
}
})
}
}
func TestSQLiteAgentLinkStoreDelegateTargetsRefreshesAfterMutations(t *testing.T) {
linkStore, db, ctx, agentA, agentB, _ := newSQLiteLinkFixture(t)
link := sqliteLink(agentA, agentB, store.LinkDirectionOutbound)
if err := linkStore.CreateLink(ctx, link); err != nil {
t.Fatalf("CreateLink: %v", err)
}
if got := targetKeys(t, linkStore, ctx, agentA); !slicesEqual(got, []string{"agent-b"}) {
t.Fatalf("initial targets = %v", got)
}
if err := linkStore.UpdateLink(ctx, link.ID, map[string]any{"status": store.LinkStatusDisabled}); err != nil {
t.Fatalf("disable link: %v", err)
}
if got := targetKeys(t, linkStore, ctx, agentA); len(got) != 0 {
t.Fatalf("targets after disable = %v, want none", got)
}
if err := linkStore.UpdateLink(ctx, link.ID, map[string]any{
"status": store.LinkStatusActive,
"direction": store.LinkDirectionInbound,
}); err != nil {
t.Fatalf("reverse link direction: %v", err)
}
if got := targetKeys(t, linkStore, ctx, agentA); len(got) != 0 {
t.Fatalf("targets from A after inbound update = %v, want none", got)
}
if got := targetKeys(t, linkStore, ctx, agentB); !slicesEqual(got, []string{"agent-a"}) {
t.Fatalf("targets from B after inbound update = %v", got)
}
if _, err := db.Exec(`UPDATE agents SET status = 'inactive' WHERE id = ?`, agentA.String()); err != nil {
t.Fatalf("deactivate effective target: %v", err)
}
if got := targetKeys(t, linkStore, ctx, agentB); len(got) != 0 {
t.Fatalf("targets with inactive effective target = %v, want none", got)
}
if _, err := db.Exec(`UPDATE agents SET status = 'active' WHERE id = ?`, agentA.String()); err != nil {
t.Fatalf("reactivate target: %v", err)
}
if err := linkStore.DeleteLink(ctx, link.ID); err != nil {
t.Fatalf("DeleteLink: %v", err)
}
if got := targetKeys(t, linkStore, ctx, agentB); len(got) != 0 {
t.Fatalf("targets after delete = %v, want none", got)
}
}
func TestSQLiteAgentLinkStoreDelegateTargetsTenantScoped(t *testing.T) {
linkStore, _, ctx, agentA, agentB, _ := newSQLiteLinkFixture(t)
if err := linkStore.CreateLink(ctx, sqliteLink(agentA, agentB, store.LinkDirectionOutbound)); err != nil {
t.Fatalf("CreateLink: %v", err)
}
otherTenantCtx := store.WithTenantID(context.Background(), uuid.New())
if got := targetKeys(t, linkStore, otherTenantCtx, agentA); len(got) != 0 {
t.Fatalf("cross-tenant targets = %v, want none", got)
}
}
func TestSQLiteAgentLinkStoreGetLinkBetweenNotFound(t *testing.T) {
linkStore, _, ctx, agentA, agentB, _ := newSQLiteLinkFixture(t)
link, err := linkStore.GetLinkBetween(ctx, agentA, agentB)
if err != nil {
t.Fatalf("GetLinkBetween: %v", err)
}
if link != nil {
t.Fatalf("GetLinkBetween = %#v, want nil", link)
}
}
func TestSQLiteAgentLinkStoreGetLinkBetweenPrefersDelegatorAuthoredRow(t *testing.T) {
linkStore, _, ctx, agentA, agentB, _ := newSQLiteLinkFixture(t)
reverse := sqliteLink(agentB, agentA, store.LinkDirectionInbound)
if err := linkStore.CreateLink(ctx, reverse); err != nil {
t.Fatalf("CreateLink(reverse): %v", err)
}
direct := sqliteLink(agentA, agentB, store.LinkDirectionOutbound)
if err := linkStore.CreateLink(ctx, direct); err != nil {
t.Fatalf("CreateLink(direct): %v", err)
}
link, err := linkStore.GetLinkBetween(ctx, agentA, agentB)
if err != nil {
t.Fatalf("GetLinkBetween: %v", err)
}
if link == nil {
t.Fatal("GetLinkBetween returned nil")
}
if link.ID != direct.ID {
t.Fatalf("GetLinkBetween chose %s, want delegator-authored %s", link.ID, direct.ID)
}
}
func TestSQLiteAgentLinkStoreGetLinkBetweenPropagatesDatabaseError(t *testing.T) {
linkStore, db, ctx, agentA, agentB, _ := newSQLiteLinkFixture(t)
if err := db.Close(); err != nil {
t.Fatalf("close fixture DB: %v", err)
}
if _, err := linkStore.GetLinkBetween(ctx, agentA, agentB); err == nil {
t.Fatal("GetLinkBetween error = nil after database close")
}
}
func slicesEqual(got, want []string) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
+3 -1
View File
@@ -34,6 +34,7 @@ func NewSQLiteStores(cfg store.StoreConfig) (*store.Stores, error) {
} else {
slog.Warn("securecli: encryption key empty, store disabled")
}
subagentTasks := NewSQLiteSubagentTaskStore(db)
sqliteStores := &store.Stores{
DB: db,
@@ -67,7 +68,8 @@ func NewSQLiteStores(cfg store.StoreConfig) (*store.Stores, error) {
ConfigPermissions: NewSQLiteConfigPermissionStore(db),
BrowserCookies: NewSQLiteBrowserCookieStore(db, cfg.EncryptionKey),
Memory: NewSQLiteMemoryStore(db),
SubagentTasks: NewSQLiteSubagentTaskStore(db),
SubagentTasks: subagentTasks,
SubagentTaskRecovery: subagentTasks,
AgentLinks: NewSQLiteAgentLinkStore(db),
SecureCLI: secureCLI,
SecureCLIGrants: NewSQLiteSecureCLIAgentGrantStore(db, cfg.EncryptionKey),
+91 -1
View File
@@ -16,7 +16,7 @@ var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
const SchemaVersion = 58
const SchemaVersion = 59
// migrations maps version → SQL to apply when upgrading FROM that version.
// schema.sql always represents the LATEST full schema (for fresh DBs).
@@ -29,7 +29,78 @@ const SchemaVersion = 58
// }
//
// Then bump SchemaVersion to 2.
const sqliteSubagentRootAgentScopeMigrationBody = `UPDATE subagent_tasks
SET root_agent_id = json_extract(metadata, '$.root_agent_id')
WHERE root_agent_id IS NULL
AND json_valid(metadata)
AND typeof(json_extract(metadata, '$.root_agent_id')) = 'text'
AND EXISTS (
SELECT 1
FROM agents
WHERE agents.tenant_id = subagent_tasks.tenant_id
AND agents.id = json_extract(subagent_tasks.metadata, '$.root_agent_id')
);
UPDATE subagent_tasks
SET root_agent_id = (
SELECT MIN(agents.id)
FROM agents
WHERE agents.tenant_id = subagent_tasks.tenant_id
AND agents.agent_key = subagent_tasks.parent_agent_key
AND agents.created_at < subagent_tasks.created_at
)
WHERE root_agent_id IS NULL
AND CASE
WHEN json_valid(metadata) THEN json_type(metadata, '$.root_agent_id') IS NULL
ELSE 1
END
AND (
SELECT COUNT(*)
FROM agents
WHERE agents.tenant_id = subagent_tasks.tenant_id
AND agents.agent_key = subagent_tasks.parent_agent_key
AND agents.created_at < subagent_tasks.created_at
) = 1;
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_root_status
ON subagent_tasks(tenant_id, root_agent_id, status, created_at DESC)
WHERE root_agent_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_root_session
ON subagent_tasks(tenant_id, root_agent_id, session_key, created_at DESC)
WHERE root_agent_id IS NOT NULL AND session_key IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_root_archive
ON subagent_tasks(tenant_id, root_agent_id, completed_at, id)
WHERE root_agent_id IS NOT NULL
AND status IN ('completed', 'failed', 'cancelled')
AND archived_at IS NULL;
CREATE TRIGGER IF NOT EXISTS trg_subagent_tasks_root_tenant_insert
BEFORE INSERT ON subagent_tasks
WHEN NEW.root_agent_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM agents
WHERE agents.id = NEW.root_agent_id
AND agents.tenant_id = NEW.tenant_id
)
BEGIN
SELECT RAISE(ABORT, 'subagent root agent belongs to another tenant');
END;
CREATE TRIGGER IF NOT EXISTS trg_subagent_tasks_root_tenant_update
BEFORE UPDATE OF root_agent_id, tenant_id ON subagent_tasks
WHEN NEW.root_agent_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM agents
WHERE agents.id = NEW.root_agent_id
AND agents.tenant_id = NEW.tenant_id
)
BEGIN
SELECT RAISE(ABORT, 'subagent root agent belongs to another tenant');
END;`
var migrations = map[int]string{
// Version 58 → 59: scope persisted subagent tasks by immutable root-agent UUID.
// Metadata is authoritative; key fallback is allowed only for one matching
// agent that predates the task. Unmatched rows remain inaccessible.
58: `ALTER TABLE subagent_tasks
ADD COLUMN root_agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL;
` + sqliteSubagentRootAgentScopeMigrationBody,
// Version 57 → 58: restore custom skills previously converted by the bundled skill seeder.
57: `UPDATE skills
SET is_system = 0,
@@ -1521,6 +1592,12 @@ func EnsureSchema(db *sql.DB) error {
return fmt.Errorf("inspect channel pending message parent column: %w", err)
}
}
if v == 58 {
patch, err = sqliteSubagentRootAgentMigrationPatch(db)
if err != nil {
return fmt.Errorf("inspect subagent task root-agent column: %w", err)
}
}
// Migrations that rebuild a table referenced by another table's FK
// require foreign_keys=OFF per SQLite altertable §7. The pragma is
// a no-op inside a transaction, so toggle it around BEGIN/COMMIT.
@@ -1681,6 +1758,19 @@ func sqlitePendingMessageParentMigrationPatch(db *sql.DB) (string, error) {
return patch, nil
}
func sqliteSubagentRootAgentMigrationPatch(db *sql.DB) (string, error) {
hasColumn, err := sqliteColumnExists(db, "subagent_tasks", "root_agent_id")
if err != nil {
return "", err
}
patch := ""
if !hasColumn {
patch += "ALTER TABLE subagent_tasks ADD COLUMN root_agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL;\n"
}
patch += sqliteSubagentRootAgentScopeMigrationBody
return patch, nil
}
func sqliteColumnExists(db *sql.DB, tableName, columnName string) (bool, error) {
rows, err := db.Query("PRAGMA table_info(" + tableName + ")")
if err != nil {
+34
View File
@@ -1678,6 +1678,7 @@ CREATE INDEX IF NOT EXISTS idx_system_configs_tenant ON system_configs(tenant_id
CREATE TABLE IF NOT EXISTS subagent_tasks (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
root_agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
parent_agent_key VARCHAR(255) NOT NULL,
session_key VARCHAR(500),
subject VARCHAR(255) NOT NULL,
@@ -1706,6 +1707,39 @@ CREATE TABLE IF NOT EXISTS subagent_tasks (
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_parent_status ON subagent_tasks(tenant_id, parent_agent_key, status);
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_session ON subagent_tasks(session_key);
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_created ON subagent_tasks(tenant_id, created_at);
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_root_status
ON subagent_tasks(tenant_id, root_agent_id, status, created_at DESC)
WHERE root_agent_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_root_session
ON subagent_tasks(tenant_id, root_agent_id, session_key, created_at DESC)
WHERE root_agent_id IS NOT NULL AND session_key IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_subagent_tasks_root_archive
ON subagent_tasks(tenant_id, root_agent_id, completed_at, id)
WHERE root_agent_id IS NOT NULL
AND status IN ('completed', 'failed', 'cancelled')
AND archived_at IS NULL;
CREATE TRIGGER IF NOT EXISTS trg_subagent_tasks_root_tenant_insert
BEFORE INSERT ON subagent_tasks
WHEN NEW.root_agent_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM agents
WHERE agents.id = NEW.root_agent_id
AND agents.tenant_id = NEW.tenant_id
)
BEGIN
SELECT RAISE(ABORT, 'subagent root agent belongs to another tenant');
END;
CREATE TRIGGER IF NOT EXISTS trg_subagent_tasks_root_tenant_update
BEFORE UPDATE OF root_agent_id, tenant_id ON subagent_tasks
WHEN NEW.root_agent_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM agents
WHERE agents.id = NEW.root_agent_id
AND agents.tenant_id = NEW.tenant_id
)
BEGIN
SELECT RAISE(ABORT, 'subagent root agent belongs to another tenant');
END;
-- ============================================================
-- Table: episodic_summaries (V3 Tier 2 memory)
+127 -37
View File
@@ -22,16 +22,18 @@ 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, parent_agent_key, session_key, subject, description,
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, parent_agent_key, session_key, subject, description,
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`
@@ -39,6 +41,9 @@ const subagentTaskSelectCols = `id, tenant_id, parent_agent_key, session_key, su
// 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 {
@@ -49,10 +54,10 @@ func (s *SQLiteSubagentTaskStore) Create(ctx context.Context, task *store.Subage
now := time.Now().UTC().Format(time.RFC3339Nano)
q := fmt.Sprintf(`INSERT OR IGNORE INTO subagent_tasks (id, %s, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, subagentTaskInsertCols)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, subagentTaskInsertCols)
_, err := s.db.ExecContext(ctx, q,
task.ID, tid, task.ParentAgentKey, task.SessionKey, task.Subject, task.Description,
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,
@@ -70,7 +75,7 @@ func scanTask(row interface{ Scan(...any) error }) (*store.SubagentTaskData, err
var createdAt, updatedAt sqliteTime
err := row.Scan(
&t.ID, &t.TenantID, &t.ParentAgentKey, &t.SessionKey, &t.Subject, &t.Description,
&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,
@@ -95,14 +100,20 @@ func scanTask(row interface{ Scan(...any) error }) (*store.SubagentTaskData, err
return &t, nil
}
// Get retrieves a single task by ID (tenant-scoped).
func (s *SQLiteSubagentTaskStore) Get(ctx context.Context, id uuid.UUID) (*store.SubagentTaskData, error) {
// 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
}
q := fmt.Sprintf(`SELECT %s FROM subagent_tasks WHERE id = ? AND tenant_id = ?`, subagentTaskSelectCols)
row := s.db.QueryRowContext(ctx, q, id, tid)
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
@@ -112,7 +123,7 @@ func (s *SQLiteSubagentTaskStore) Get(ctx context.Context, id uuid.UUID) (*store
// UpdateStatus updates status, result, iterations, and token counts on completion/failure.
func (s *SQLiteSubagentTaskStore) UpdateStatus(
ctx context.Context, id uuid.UUID,
ctx context.Context, rootAgentID, id uuid.UUID,
status string, result *string, iterations int,
inputTokens, outputTokens int64,
) error {
@@ -120,10 +131,13 @@ func (s *SQLiteSubagentTaskStore) UpdateStatus(
if err != nil {
return err
}
if rootAgentID == uuid.Nil {
return store.ErrSubagentRootAgentIDRequired
}
now := time.Now().UTC().Format(time.RFC3339Nano)
var completedAt *string
if status != "running" {
if store.IsTerminalSubagentTaskStatus(status) {
v := now
completedAt = &v
}
@@ -132,34 +146,49 @@ func (s *SQLiteSubagentTaskStore) UpdateStatus(
status = ?, result = ?, iterations = ?,
input_tokens = ?, output_tokens = ?,
completed_at = ?, updated_at = ?
WHERE id = ? AND tenant_id = ?`
_, err = s.db.ExecContext(ctx, q,
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,
completedAt, now, id, tid, rootAgentID,
)
return err
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 parent agent key, optionally filtered by status.
// ListByParent returns tasks for a root-agent UUID, optionally filtered by status.
func (s *SQLiteSubagentTaskStore) ListByParent(
ctx context.Context, parentAgentKey string, statusFilter string,
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 parent_agent_key = ? AND status = ?
ORDER BY created_at DESC LIMIT 50`, subagentTaskSelectCols)
rows, err = s.db.QueryContext(ctx, q, tid, parentAgentKey, statusFilter)
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 parent_agent_key = ?
ORDER BY created_at DESC LIMIT 50`, subagentTaskSelectCols)
rows, err = s.db.QueryContext(ctx, q, tid, parentAgentKey)
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
@@ -170,17 +199,21 @@ func (s *SQLiteSubagentTaskStore) ListByParent(
// ListBySession returns tasks for a specific session key (tenant-scoped).
func (s *SQLiteSubagentTaskStore) ListBySession(
ctx context.Context, sessionKey string,
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 session_key = ?
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, sessionKey)
rows, err := s.db.QueryContext(ctx, q, tid, rootAgentID, sessionKey)
if err != nil {
return nil, err
}
@@ -188,14 +221,38 @@ func (s *SQLiteSubagentTaskStore) ListBySession(
return collectTasks(rows)
}
// Archive marks old completed/failed/cancelled tasks as archived.
func (s *SQLiteSubagentTaskStore) Archive(ctx context.Context, olderThan time.Duration) (int64, error) {
// 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 status IN ('completed', 'failed', 'cancelled')
AND archived_at IS NULL AND completed_at < ?`
res, err := s.db.ExecContext(ctx, q, now, now, cutoff)
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
}
@@ -204,11 +261,16 @@ func (s *SQLiteSubagentTaskStore) Archive(ctx context.Context, olderThan time.Du
// 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, id uuid.UUID, metadata map[string]any) error {
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
}
@@ -221,18 +283,46 @@ func (s *SQLiteSubagentTaskStore) UpdateMetadata(ctx context.Context, id uuid.UU
if !validMetadataKey(k) {
return fmt.Errorf("invalid metadata key: %q", k)
}
parts = append(parts, fmt.Sprintf("'$.%s', ?", 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)
args = append(args, now, id, tid, rootAgentID)
q := fmt.Sprintf(`UPDATE subagent_tasks SET metadata = %s, updated_at = ? WHERE id = ? AND tenant_id = ?`, setExpr)
_, err = s.db.ExecContext(ctx, q, args...)
return err
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) {
@@ -0,0 +1,586 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func createSQLiteSubagentTask(
t *testing.T,
taskStore *SQLiteSubagentTaskStore,
ctx context.Context,
rootAgentID uuid.UUID,
rootAgentKey, sessionKey, status string,
) uuid.UUID {
t.Helper()
id := uuid.Must(uuid.NewV7())
task := &store.SubagentTaskData{
RootAgentID: rootAgentID,
ParentAgentKey: rootAgentKey,
SessionKey: &sessionKey,
Subject: "store test",
Description: "verify scoped persistence",
Status: status,
Depth: 1,
Metadata: map[string]any{},
}
task.ID = id
if err := taskStore.Create(ctx, task); err != nil {
t.Fatalf("Create(%s): %v", id, err)
}
return id
}
func TestSQLiteSubagentTaskStoreRequiresTenantAndRootScope(t *testing.T) {
db := newHookTestDB(t)
tenantA, rootAID := seedHookTenantAgent(t, db)
tenantB, tenantBRootID := seedHookTenantAgent(t, db)
ctxA := sqliteTenantCtx(tenantA)
ctxB := sqliteTenantCtx(tenantB)
taskStore := NewSQLiteSubagentTaskStore(db)
const (
rootA = "root-a"
rootB = "root-b"
sessionID = "shared-session"
)
rootBID := uuid.Must(uuid.NewV7())
if _, err := db.Exec(
`INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
VALUES (?,?,?,'predefined','active','test','test-model','owner')`,
rootBID, tenantA, rootB,
); err != nil {
t.Fatalf("seed second root agent: %v", err)
}
taskA := createSQLiteSubagentTask(t, taskStore, ctxA, rootAID, rootA, sessionID, "queued")
taskB := createSQLiteSubagentTask(t, taskStore, ctxA, rootBID, rootB, sessionID, "queued")
delegationTask := createSQLiteSubagentTask(t, taskStore, ctxA, rootAID, rootA, sessionID, "queued")
if err := taskStore.UpdateMetadata(ctxA, rootAID, delegationTask, map[string]any{
"completion_kind": "delegate",
}); err != nil {
t.Fatalf("mark delegation completion: %v", err)
}
_ = createSQLiteSubagentTask(t, taskStore, ctxB, tenantBRootID, rootA, sessionID, "queued")
crossTenantTask := &store.SubagentTaskData{
BaseModel: store.BaseModel{ID: uuid.Must(uuid.NewV7())},
RootAgentID: tenantBRootID,
ParentAgentKey: rootA,
Subject: "cross tenant",
Description: "must be rejected",
Status: "queued",
Depth: 1,
}
if err := taskStore.Create(ctxA, crossTenantTask); err == nil {
t.Fatal("Create accepted root agent from another tenant")
}
got, err := taskStore.Get(ctxA, rootAID, taskA)
if err != nil {
t.Fatalf("Get owning scope: %v", err)
}
if got == nil || got.ID != taskA {
t.Fatalf("Get owning scope = %#v, want task %s", got, taskA)
}
got, err = taskStore.Get(ctxA, rootBID, taskA)
if err != nil {
t.Fatalf("Get cross-root: %v", err)
}
if got != nil {
t.Fatalf("Get cross-root = %#v, want nil", got)
}
got, err = taskStore.Get(ctxB, rootAID, taskA)
if err != nil {
t.Fatalf("Get cross-tenant: %v", err)
}
if got != nil {
t.Fatalf("Get cross-tenant = %#v, want nil", got)
}
if err := taskStore.UpdateStatus(ctxA, rootBID, taskA, "completed", nil, 3, 10, 20); !errors.Is(err, store.ErrSubagentTaskNotFound) {
t.Fatalf("UpdateStatus cross-root error = %v, want scoped not found", err)
}
got, err = taskStore.Get(ctxA, rootAID, taskA)
if err != nil {
t.Fatalf("Get after cross-root status update: %v", err)
}
if got.Status != "queued" || got.CompletedAt != nil {
t.Fatalf("cross-root status update changed task: status=%q completed_at=%v", got.Status, got.CompletedAt)
}
if err := taskStore.UpdateMetadata(ctxA, rootBID, taskA, map[string]any{"denied": true}); !errors.Is(err, store.ErrSubagentTaskNotFound) {
t.Fatalf("UpdateMetadata cross-root error = %v, want scoped not found", err)
}
got, err = taskStore.Get(ctxA, rootAID, taskA)
if err != nil {
t.Fatalf("Get after cross-root metadata update: %v", err)
}
if _, exists := got.Metadata["denied"]; exists {
t.Fatalf("cross-root metadata update changed task: %#v", got.Metadata)
}
if err := taskStore.UpdateMetadata(ctxA, rootAID, taskA, map[string]any{
"announcement_status": "undelivered",
"delivered": false,
}); err != nil {
t.Fatalf("UpdateMetadata owning scope: %v", err)
}
got, err = taskStore.Get(ctxA, rootAID, taskA)
if err != nil {
t.Fatalf("Get after owning metadata update: %v", err)
}
if got.Metadata["announcement_status"] != "undelivered" || got.Metadata["delivered"] != false {
t.Fatalf("metadata JSON types were not preserved: %#v", got.Metadata)
}
parentTasksA, err := taskStore.ListByParent(ctxA, rootAID, "")
if err != nil {
t.Fatalf("ListByParent root A: %v", err)
}
if len(parentTasksA) != 1 || parentTasksA[0].ID != taskA {
t.Fatalf("ListByParent root A = %#v, want only self-clone %s", parentTasksA, taskA)
}
parentTasksB, err := taskStore.ListByParent(ctxA, rootBID, "queued")
if err != nil {
t.Fatalf("ListByParent root B: %v", err)
}
if len(parentTasksB) != 1 || parentTasksB[0].ID != taskB {
t.Fatalf("ListByParent root B = %#v, want only %s", parentTasksB, taskB)
}
tasksA, err := taskStore.ListBySession(ctxA, rootAID, sessionID)
if err != nil {
t.Fatalf("ListBySession root A: %v", err)
}
if len(tasksA) != 1 || tasksA[0].ID != taskA {
t.Fatalf("ListBySession root A = %#v, want only self-clone %s", tasksA, taskA)
}
tasksB, err := taskStore.ListBySession(ctxA, rootBID, sessionID)
if err != nil {
t.Fatalf("ListBySession root B: %v", err)
}
if len(tasksB) != 1 || tasksB[0].ID != taskB {
t.Fatalf("ListBySession root B = %#v, want only %s", tasksB, taskB)
}
if _, err := taskStore.Get(context.Background(), rootAID, taskA); err == nil {
t.Fatal("Get without tenant context returned nil error")
}
if _, err := taskStore.Get(ctxA, uuid.Nil, taskA); !errors.Is(err, store.ErrSubagentRootAgentIDRequired) {
t.Fatalf("Get empty root error = %v, want %v", err, store.ErrSubagentRootAgentIDRequired)
}
}
func TestSQLiteSubagentTaskStoreRejectsRecreatedAgentWithSameKey(t *testing.T) {
db := newHookTestDB(t)
tenantID, oldRootAgentID := seedHookTenantAgent(t, db)
ctx := sqliteTenantCtx(tenantID)
taskStore := NewSQLiteSubagentTaskStore(db)
const rootAgentKey = "recreated-root"
if _, err := db.Exec(
`UPDATE agents SET agent_key = ? WHERE id = ?`,
rootAgentKey, oldRootAgentID,
); err != nil {
t.Fatalf("rename original root agent: %v", err)
}
taskID := createSQLiteSubagentTask(
t, taskStore, ctx, oldRootAgentID, rootAgentKey, "recreated-root-session", "queued",
)
if _, err := db.Exec(`DELETE FROM agents WHERE id = ?`, oldRootAgentID); err != nil {
t.Fatalf("delete original root agent: %v", err)
}
newRootAgentID := uuid.Must(uuid.NewV7())
if _, err := db.Exec(
`INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
VALUES (?,?,?,'predefined','active','test','test-model','owner')`,
newRootAgentID, tenantID, rootAgentKey,
); err != nil {
t.Fatalf("recreate root agent: %v", err)
}
got, err := taskStore.Get(ctx, newRootAgentID, taskID)
if err != nil {
t.Fatalf("Get with recreated root agent: %v", err)
}
if got != nil {
t.Fatalf("Get with recreated root agent returned old task %s", got.ID)
}
if err := taskStore.UpdateStatus(ctx, newRootAgentID, taskID, "completed", nil, 1, 2, 3); !errors.Is(err, store.ErrSubagentTaskNotFound) {
t.Fatalf("UpdateStatus with recreated root agent error = %v, want scoped not found", err)
}
var status string
var rootAgentID sql.NullString
if err := db.QueryRow(
`SELECT status, root_agent_id FROM subagent_tasks WHERE id = ?`, taskID,
).Scan(&status, &rootAgentID); err != nil {
t.Fatalf("read preserved old task: %v", err)
}
if status != "queued" || rootAgentID.Valid {
t.Fatalf("old task after recreate: status=%q root_agent_id=%q, want queued/NULL", status, rootAgentID.String)
}
}
func TestSQLiteSubagentTaskMigrationBackfillsOnlySafeOwners(t *testing.T) {
db := newHookTestDB(t)
tenantID, oldRootAgentID := seedHookTenantAgent(t, db)
const rootAgentKey = "migration-recreated-root"
if _, err := db.Exec(
`UPDATE agents SET agent_key = ?, deleted_at = ? WHERE id = ?`,
rootAgentKey, time.Now().UTC().Format(time.RFC3339Nano), oldRootAgentID,
); err != nil {
t.Fatalf("prepare historical root agent: %v", err)
}
newRootAgentID := uuid.Must(uuid.NewV7())
if _, err := db.Exec(
`INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
VALUES (?,?,?,'predefined','active','test','test-model','owner')`,
newRootAgentID, tenantID, rootAgentKey,
); err != nil {
t.Fatalf("insert recreated root agent: %v", err)
}
const (
safeRootKey = "migration-safe-root"
recreatedOnlyKey = "migration-hard-deleted-root"
)
safeRootAgentID := uuid.Must(uuid.NewV7())
recreatedOnlyAgentID := uuid.Must(uuid.NewV7())
if _, err := db.Exec(
`INSERT INTO agents
(id, tenant_id, agent_key, agent_type, status, provider, model, owner_id, created_at)
VALUES (?,?,?,'predefined','active','test','test-model','owner',?),
(?,?,?,'predefined','active','test','test-model','owner',?)`,
safeRootAgentID, tenantID, safeRootKey, "2026-01-01T00:00:00Z",
recreatedOnlyAgentID, tenantID, recreatedOnlyKey, "2026-03-01T00:00:00Z",
); err != nil {
t.Fatalf("insert temporal migration agents: %v", err)
}
metadataTaskID := uuid.Must(uuid.NewV7())
ambiguousTaskID := uuid.Must(uuid.NewV7())
safeTaskID := uuid.Must(uuid.NewV7())
recreatedTaskID := uuid.Must(uuid.NewV7())
if _, err := db.Exec(
`INSERT INTO subagent_tasks
(id, tenant_id, parent_agent_key, subject, description, status, metadata, created_at)
VALUES (?,?,?,?,?,'queued',?,?),
(?,?,?,?,?,'queued','{}',?),
(?,?,?,?,?,'queued','{}',?),
(?,?,?,?,?,'queued','{}',?)`,
metadataTaskID, tenantID, rootAgentKey, "metadata", "metadata owner",
fmt.Sprintf(`{"root_agent_id":%q}`, oldRootAgentID.String()), "2026-04-01T00:00:00Z",
ambiguousTaskID, tenantID, rootAgentKey, "ambiguous", "ambiguous owner", "2026-04-01T00:00:00Z",
safeTaskID, tenantID, safeRootKey, "safe", "safe temporal owner", "2026-02-01T00:00:00Z",
recreatedTaskID, tenantID, recreatedOnlyKey, "recreated", "older than current owner", "2026-02-01T00:00:00Z",
); err != nil {
t.Fatalf("insert legacy tasks: %v", err)
}
if _, err := db.Exec(`
DROP TRIGGER trg_subagent_tasks_root_tenant_insert;
DROP TRIGGER trg_subagent_tasks_root_tenant_update;
DROP INDEX idx_subagent_tasks_root_archive;
DROP INDEX idx_subagent_tasks_root_session;
DROP INDEX idx_subagent_tasks_root_status;
ALTER TABLE subagent_tasks DROP COLUMN root_agent_id;
UPDATE schema_version SET version = 58;
`); err != nil {
t.Fatalf("prepare v58 schema: %v", err)
}
if err := EnsureSchema(db); err != nil {
t.Fatalf("migrate v58 to v59: %v", err)
}
var metadataOwner sql.NullString
if err := db.QueryRow(
`SELECT root_agent_id FROM subagent_tasks WHERE id = ?`, metadataTaskID,
).Scan(&metadataOwner); err != nil {
t.Fatalf("read metadata-owned task: %v", err)
}
if !metadataOwner.Valid || metadataOwner.String != oldRootAgentID.String() {
t.Fatalf("metadata owner = %q, want %s", metadataOwner.String, oldRootAgentID)
}
var ambiguousOwner sql.NullString
if err := db.QueryRow(
`SELECT root_agent_id FROM subagent_tasks WHERE id = ?`, ambiguousTaskID,
).Scan(&ambiguousOwner); err != nil {
t.Fatalf("read ambiguous task: %v", err)
}
if ambiguousOwner.Valid {
t.Fatalf("ambiguous key owner = %q, want NULL", ambiguousOwner.String)
}
var safeOwner sql.NullString
if err := db.QueryRow(
`SELECT root_agent_id FROM subagent_tasks WHERE id = ?`, safeTaskID,
).Scan(&safeOwner); err != nil {
t.Fatalf("read safely backfilled task: %v", err)
}
if !safeOwner.Valid || safeOwner.String != safeRootAgentID.String() {
t.Fatalf("safe owner = %q, want %s", safeOwner.String, safeRootAgentID)
}
var recreatedOwner sql.NullString
if err := db.QueryRow(
`SELECT root_agent_id FROM subagent_tasks WHERE id = ?`, recreatedTaskID,
).Scan(&recreatedOwner); err != nil {
t.Fatalf("read recreated-owner task: %v", err)
}
if recreatedOwner.Valid {
t.Fatalf("new same-key agent inherited older task: owner=%q", recreatedOwner.String)
}
var version int
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
t.Fatalf("read migrated schema version: %v", err)
}
if version != SchemaVersion {
t.Fatalf("schema version = %d, want %d", version, SchemaVersion)
}
}
func TestSQLiteSubagentTaskStoreCompletedAtOnlyForTerminalStatus(t *testing.T) {
db := newHookTestDB(t)
tenantID, rootAgentID := seedHookTenantAgent(t, db)
ctx := sqliteTenantCtx(tenantID)
taskStore := NewSQLiteSubagentTaskStore(db)
tests := []struct {
status string
terminal bool
}{
{status: "new"},
{status: "queued"},
{status: "running"},
{status: "waiting_child"},
{status: "completed", terminal: true},
{status: "failed", terminal: true},
{status: "cancelled", terminal: true},
}
for _, tt := range tests {
t.Run(tt.status, func(t *testing.T) {
rootAgentKey := "root-" + tt.status
id := createSQLiteSubagentTask(t, taskStore, ctx, rootAgentID, rootAgentKey, "session-"+tt.status, "queued")
if err := taskStore.UpdateStatus(ctx, rootAgentID, id, tt.status, nil, 0, 0, 0); err != nil {
t.Fatalf("UpdateStatus(%q): %v", tt.status, err)
}
got, err := taskStore.Get(ctx, rootAgentID, id)
if err != nil {
t.Fatalf("Get(%q): %v", tt.status, err)
}
if (got.CompletedAt != nil) != tt.terminal {
t.Fatalf("status %q completed_at = %v, terminal=%v", tt.status, got.CompletedAt, tt.terminal)
}
})
}
}
func TestSQLiteSubagentTaskStoreRecoverInterrupted(t *testing.T) {
db := newHookTestDB(t)
tenantA, rootAID := seedHookTenantAgent(t, db)
tenantB, rootBID := seedHookTenantAgent(t, db)
ctxA := sqliteTenantCtx(tenantA)
ctxB := sqliteTenantCtx(tenantB)
taskStore := NewSQLiteSubagentTaskStore(db)
queuedID := createSQLiteSubagentTask(t, taskStore, ctxA, rootAID, "root-a", "queued", "queued")
if _, err := db.Exec(
`UPDATE subagent_tasks SET completed_at = ? WHERE id = ?`,
time.Now().UTC().Add(-time.Hour).Format(time.RFC3339Nano),
queuedID,
); err != nil {
t.Fatalf("seed malformed queued completed_at: %v", err)
}
runningID := createSQLiteSubagentTask(t, taskStore, ctxA, rootAID, "root-a", "running", "running")
waitingID := createSQLiteSubagentTask(
t, taskStore, ctxB, rootBID, "root-b", "waiting", "waiting_child",
)
if err := taskStore.UpdateMetadata(ctxB, rootBID, waitingID, map[string]any{
"completion_kind": "delegate",
"completion_media": []map[string]any{{
"path": ".delegations/completed-before-crash/report.pdf",
"mime_type": "application/pdf",
}},
}); err != nil {
t.Fatalf("record published artifact metadata: %v", err)
}
completedID := createSQLiteSubagentTask(t, taskStore, ctxB, rootBID, "root-b", "completed", "queued")
completedResult := "already completed"
if err := taskStore.UpdateStatus(
ctxB, rootBID, completedID, "completed", &completedResult, 1, 2, 3,
); err != nil {
t.Fatalf("complete terminal task: %v", err)
}
if _, err := taskStore.RecoverInterrupted(ctxA); err == nil {
t.Fatal("RecoverInterrupted accepted tenant-scoped context")
}
recoveryCtx := store.WithTenantID(context.Background(), store.MasterTenantID)
recovered, err := taskStore.RecoverInterrupted(recoveryCtx)
if err != nil {
t.Fatalf("RecoverInterrupted: %v", err)
}
if recovered != 3 {
t.Fatalf("RecoverInterrupted recovered %d tasks, want 3", recovered)
}
for _, item := range []struct {
ctx context.Context
root uuid.UUID
id uuid.UUID
}{
{ctx: ctxA, root: rootAID, id: queuedID},
{ctx: ctxA, root: rootAID, id: runningID},
{ctx: ctxB, root: rootBID, id: waitingID},
} {
got, getErr := taskStore.Get(item.ctx, item.root, item.id)
if getErr != nil {
t.Fatalf("Get recovered task %s: %v", item.id, getErr)
}
if got.Status != "failed" || got.CompletedAt == nil || got.Result == nil ||
!strings.Contains(*got.Result, "gateway stopped") {
t.Fatalf("recovered task %s = %#v", item.id, got)
}
if item.id == waitingID {
if got.Metadata["completion_kind"] != "delegate" ||
got.Metadata["completion_media"] == nil {
t.Fatalf("published artifact metadata was lost: %#v", got.Metadata)
}
}
}
completed, err := taskStore.Get(ctxB, rootBID, completedID)
if err != nil {
t.Fatalf("Get completed task: %v", err)
}
if completed.Status != "completed" || completed.Result == nil ||
*completed.Result != completedResult {
t.Fatalf("completed task changed during recovery: %#v", completed)
}
recovered, err = taskStore.RecoverInterrupted(recoveryCtx)
if err != nil {
t.Fatalf("RecoverInterrupted second pass: %v", err)
}
if recovered != 0 {
t.Fatalf("RecoverInterrupted second pass recovered %d tasks, want 0", recovered)
}
}
func TestSQLiteSubagentTaskStoreArchiveIsScopedAndBounded(t *testing.T) {
db := newHookTestDB(t)
tenantA, rootAID := seedHookTenantAgent(t, db)
tenantB, tenantBRootID := seedHookTenantAgent(t, db)
ctxA := sqliteTenantCtx(tenantA)
ctxB := sqliteTenantCtx(tenantB)
taskStore := NewSQLiteSubagentTaskStore(db)
const (
rootA = "root-a"
rootB = "root-b"
)
rootBID := uuid.Must(uuid.NewV7())
if _, err := db.Exec(
`INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
VALUES (?,?,?,'predefined','active','test','test-model','owner')`,
rootBID, tenantA, rootB,
); err != nil {
t.Fatalf("seed second root agent: %v", err)
}
var rootATasks []uuid.UUID
for i := 0; i < 3; i++ {
rootATasks = append(rootATasks, createSQLiteSubagentTask(
t, taskStore, ctxA, rootAID, rootA, fmt.Sprintf("session-a-%d", i), "queued",
))
}
rootBTask := createSQLiteSubagentTask(t, taskStore, ctxA, rootBID, rootB, "session-b", "queued")
tenantBTask := createSQLiteSubagentTask(t, taskStore, ctxB, tenantBRootID, rootA, "session-other-tenant", "queued")
queuedTask := createSQLiteSubagentTask(t, taskStore, ctxA, rootAID, rootA, "session-queued", "queued")
for _, item := range []struct {
ctx context.Context
root uuid.UUID
id uuid.UUID
}{
{ctx: ctxA, root: rootAID, id: rootATasks[0]},
{ctx: ctxA, root: rootAID, id: rootATasks[1]},
{ctx: ctxA, root: rootAID, id: rootATasks[2]},
{ctx: ctxA, root: rootBID, id: rootBTask},
{ctx: ctxB, root: tenantBRootID, id: tenantBTask},
} {
if err := taskStore.UpdateStatus(item.ctx, item.root, item.id, "completed", nil, 0, 0, 0); err != nil {
t.Fatalf("UpdateStatus(%s): %v", item.id, err)
}
}
oldTime := time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339Nano)
for _, id := range append(append([]uuid.UUID{}, rootATasks...), rootBTask, tenantBTask) {
if _, err := db.Exec(`UPDATE subagent_tasks SET completed_at = ? WHERE id = ?`, oldTime, id); err != nil {
t.Fatalf("backdate terminal task %s: %v", id, err)
}
}
// A non-terminal row must remain unarchived even if malformed legacy data
// happens to carry a completed_at value.
if _, err := db.Exec(`UPDATE subagent_tasks SET completed_at = ? WHERE id = ?`, oldTime, queuedTask); err != nil {
t.Fatalf("backdate queued task: %v", err)
}
archived, err := taskStore.Archive(ctxA, rootAID, time.Hour, 2)
if err != nil {
t.Fatalf("Archive first batch: %v", err)
}
if archived != 2 {
t.Fatalf("Archive first batch affected %d rows, want 2", archived)
}
assertSQLiteArchivedCount(t, db, tenantA, rootAID, 2)
assertSQLiteArchivedCount(t, db, tenantA, rootBID, 0)
assertSQLiteArchivedCount(t, db, tenantB, tenantBRootID, 0)
archived, err = taskStore.Archive(ctxA, rootAID, time.Hour, 2)
if err != nil {
t.Fatalf("Archive second batch: %v", err)
}
if archived != 1 {
t.Fatalf("Archive second batch affected %d rows, want 1", archived)
}
assertSQLiteArchivedCount(t, db, tenantA, rootAID, 3)
var queuedArchived sql.NullString
if err := db.QueryRow(`SELECT archived_at FROM subagent_tasks WHERE id = ?`, queuedTask).Scan(&queuedArchived); err != nil {
t.Fatalf("read queued archived_at: %v", err)
}
if queuedArchived.Valid {
t.Fatalf("queued task archived_at = %q, want NULL", queuedArchived.String)
}
}
func assertSQLiteArchivedCount(
t *testing.T, db *sql.DB, tenantID, rootAgentID uuid.UUID, want int,
) {
t.Helper()
var got int
if err := db.QueryRow(
`SELECT COUNT(*) FROM subagent_tasks
WHERE tenant_id = ? AND root_agent_id = ? AND archived_at IS NOT NULL`,
tenantID, rootAgentID,
).Scan(&got); err != nil {
t.Fatalf("count archived tasks for %s/%s: %v", tenantID, rootAgentID, err)
}
if got != want {
t.Fatalf("archived tasks for %s/%s = %d, want %d", tenantID, rootAgentID, got, want)
}
}