mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 10:14:51 +00:00
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:
1 parent
496b7ffce6
commit
bb7712a9ff
186 files changed
+18858
-1233
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user