Files
Algis DumbrisandClaude Opus 4.6 ff5d0c49f4 feat(018): dynamic agent spawning — primitives + doc-gardener demo
Ships the MVP slice of spec 018 (dynamic agent spawning):

- 5 new SQLite migrations (021-025): goals + goal_tasks + agent_proposals
  + reputation_evidence + secrets + harness_runs.task_id. The legacy
  `tasks` table (channel auctions) and `agent_trust` table (reactions
  workflow) are left untouched — the new schema coexists.

- 4 new internal packages, fully tested:
  - internal/goals: Goal struct + store + service, slug collision dedup,
    backing-channel auto-create via ChannelCreator adapter
  - internal/goaltasks: goal_tasks table with denormalized 16 KB
    ancestry snapshots, single-statement optimistic-lock atomic claim,
    recursive-CTE cost rollup, state machine, per-billing-code rollup
  - internal/secrets: NaCl-secretbox encrypted blobs, user/agent/task
    scope precedence, sanitized env injection, master-key bootstrap
  - internal/trust additions: ConfigHash (deterministic SHA-256 of
    model + prompt + tools + skills + mcp + subagents, sorted),
    DelegationCap (tier + tool-scope + budget + depth enforcement),
    append-only Ledger with exponential time-decay rolling score and
    70%-of-parent child seeding. Existing trust package unchanged.

- Critical invariants under test:
  - 50-goroutine concurrent claim race → exactly one winner per round
  - ConfigHash stable under shuffled array inputs, sensitive to
    capability changes
  - DelegationCap full tier × tool-scope matrix
  - Ledger time-decay + parent seed at 70 % ± 1 %
  - Secret name sanitization, scope precedence, plaintext never
    returned via MCP-equivalent paths

- internal/agents/types.go extended with dynamic-spawning columns
  (config_hash, parent_agent_id, spawn_depth, system_prompt,
  autonomy_tier, tool_scope_json, quarantined_at). Existing tests
  still pass.

- cmd/docgardener: self-contained demo binary driving the end-to-end
  flow. `docgardener run` creates a goal, builds a task tree with
  denormalized ancestry, spawns 3 specialists (each going through
  real delegation-cap validation and config-hash computation and
  70 %-of-parent reputation seeding), claims tasks atomically, runs
  them through the state machine, records reputation evidence.
  `docgardener report` queries all of that back out and renders a
  rich dark-mode HTML report (header, spend metrics, task tree,
  spawned-agent cards with reputation bars, cost breakdown, artifacts,
  timeline).

- examples/doc-gardener: start.sh / run_task.sh / report.sh / stop.sh
  mirroring the cold-topic-explainer pattern. Launches an isolated
  synapbus instance on port 18089, drives the demo, renders
  report.html, cleans up. Full README documenting what's real vs
  deferred, plus examples/README.md listing both examples.

- specs/018: tasks.md updated with MVP completion status; legacy tasks
  naming collision noted.

Deferred (marked explicitly in example README):
- Real LLM-driven coordinator (needs MCP tool wiring + prompt
  iteration)
- Real subprocess runs (needs reactor integration with task_id on
  ExecRequest)
- Full MCP tool surface (contracts are written at
  specs/018-dynamic-agent-spawning/contracts/mcp-tools.md)
- Svelte /goals UI (REST endpoints remain a follow-up)
- Full budget race + quarantine auto-trigger wiring
- Full resource-request → secrets fulfill reaction-workflow path

Cross-compiles clean for linux/amd64 and darwin/arm64 with no CGO
(SC-010). All new package tests pass (SC-004, SC-005, SC-007).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:29:21 +03:00

288 lines
8.1 KiB
Go

package goaltasks
import (
"context"
"database/sql"
"log/slog"
"strings"
"sync"
"sync/atomic"
"testing"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/storage"
)
// testDB spins up an in-memory SQLite with all migrations applied and a
// minimal user/channel/agent/goal/goal_tasks set suitable for service tests.
func testDB(t *testing.T) (*sql.DB, int64, int64) {
t.Helper()
db, err := sql.Open("sqlite", "file::memory:?cache=shared&_foreign_keys=on&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("open: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
ctx := context.Background()
if err := storage.RunMigrations(ctx, db); err != nil {
t.Fatalf("migrate: %v", err)
}
if _, err := db.ExecContext(ctx, `INSERT INTO users (username, password_hash) VALUES ('algis', 'x')`); err != nil {
t.Fatalf("insert user: %v", err)
}
var userID int64
if err := db.QueryRowContext(ctx, `SELECT id FROM users WHERE username='algis'`).Scan(&userID); err != nil {
t.Fatalf("get user: %v", err)
}
if _, err := db.ExecContext(ctx, `
INSERT INTO channels (name, description, type, is_private, is_system, created_by)
VALUES ('goal-test', 'Test goal channel', 'blackboard', 1, 0, 'algis')`); err != nil {
t.Fatalf("insert channel: %v", err)
}
var channelID int64
if err := db.QueryRowContext(ctx, `SELECT id FROM channels WHERE name='goal-test'`).Scan(&channelID); err != nil {
t.Fatalf("get channel: %v", err)
}
if _, err := db.ExecContext(ctx, `
INSERT INTO goals (slug, title, description, owner_user_id, channel_id, status, max_spawn_depth)
VALUES ('test', 'Test', 'Desc', ?, ?, 'active', 3)`, userID, channelID); err != nil {
t.Fatalf("insert goal: %v", err)
}
var goalID int64
if err := db.QueryRowContext(ctx, `SELECT id FROM goals WHERE slug='test'`).Scan(&goalID); err != nil {
t.Fatalf("get goal: %v", err)
}
return db, userID, goalID
}
func insertTestAgent(t *testing.T, db *sql.DB, name string, ownerID int64) int64 {
t.Helper()
res, err := db.ExecContext(context.Background(), `
INSERT INTO agents (name, type, capabilities, owner_id, api_key_hash, status)
VALUES (?, 'ai', '[]', ?, 'hash', 'active')`, name, ownerID)
if err != nil {
t.Fatalf("insert agent: %v", err)
}
id, _ := res.LastInsertId()
return id
}
func TestCreateTree_AncestryAndDepth(t *testing.T) {
db, userID, goalID := testDB(t)
svc := NewService(NewStore(db), slog.Default())
root := TreeNode{
Title: "root",
Description: "root desc",
Children: []TreeNode{
{
Title: "child-1",
Description: "c1 desc",
Children: []TreeNode{
{Title: "grandchild", Description: "gc desc"},
},
},
{Title: "child-2", Description: "c2 desc"},
},
}
rootID, allIDs, err := svc.CreateTree(context.Background(), CreateTreeInput{
GoalID: goalID,
CreatedByUser: &userID,
Root: root,
})
if err != nil {
t.Fatalf("CreateTree: %v", err)
}
if len(allIDs) != 4 {
t.Fatalf("expected 4 tasks, got %d", len(allIDs))
}
tasks, err := svc.ListByGoal(context.Background(), goalID)
if err != nil {
t.Fatalf("ListByGoal: %v", err)
}
byID := map[int64]*Task{}
for _, task := range tasks {
byID[task.ID] = task
}
if r := byID[rootID]; r == nil || r.Depth != 0 || len(r.Ancestry) != 0 {
t.Errorf("root depth/ancestry wrong: %+v", r)
}
// grandchild should have two ancestors
var gc *Task
for _, task := range tasks {
if task.Title == "grandchild" {
gc = task
}
}
if gc == nil || gc.Depth != 2 || len(gc.Ancestry) != 2 {
t.Fatalf("grandchild depth/ancestry wrong: %+v", gc)
}
if gc.Ancestry[0].Title != "root" || gc.Ancestry[1].Title != "child-1" {
t.Errorf("ancestry chain wrong: %+v", gc.Ancestry)
}
}
func TestCreateTree_AncestryOverflow(t *testing.T) {
db, userID, goalID := testDB(t)
svc := NewService(NewStore(db), slog.Default())
// Huge title on an intermediate node — the grandchild's ancestry snapshot
// will contain this title and must exceed the 16 KB cap.
huge := strings.Repeat("x", 20000)
root := TreeNode{
Title: "root",
Description: "d",
Children: []TreeNode{
{
Title: huge,
Description: "d",
Children: []TreeNode{
{Title: "victim", Description: "d"},
},
},
},
}
_, _, err := svc.CreateTree(context.Background(), CreateTreeInput{
GoalID: goalID,
CreatedByUser: &userID,
Root: root,
})
if err == nil {
t.Fatal("expected ancestry overflow error, got nil")
}
}
func TestClaimAtomic_Race(t *testing.T) {
db, userID, goalID := testDB(t)
svc := NewService(NewStore(db), slog.Default())
// Create one task in approved state.
_, allIDs, err := svc.CreateTree(context.Background(), CreateTreeInput{
GoalID: goalID,
CreatedByUser: &userID,
Root: TreeNode{Title: "solo", Description: "d"},
InitialStatus: StatusApproved,
})
if err != nil {
t.Fatalf("CreateTree: %v", err)
}
taskID := allIDs[0]
// Two racing agents.
agent1 := insertTestAgent(t, db, "racer1", userID)
agent2 := insertTestAgent(t, db, "racer2", userID)
const rounds = 50
var oneWinsCount, alreadyClaimedCount int32
for i := 0; i < rounds; i++ {
// Reset the task to approved + unassigned each round.
if _, err := db.ExecContext(context.Background(),
`UPDATE goal_tasks SET status='approved', assignee_agent_id=NULL, claimed_at=NULL WHERE id=?`, taskID); err != nil {
t.Fatalf("reset: %v", err)
}
var wg sync.WaitGroup
wg.Add(2)
for _, a := range []int64{agent1, agent2} {
agentID := a
go func() {
defer wg.Done()
err := svc.Claim(context.Background(), taskID, agentID, nil)
switch err {
case nil:
atomic.AddInt32(&oneWinsCount, 1)
case ErrAlreadyClaimed:
atomic.AddInt32(&alreadyClaimedCount, 1)
default:
t.Errorf("unexpected claim error: %v", err)
}
}()
}
wg.Wait()
}
if oneWinsCount != rounds {
t.Errorf("expected %d wins, got %d", rounds, oneWinsCount)
}
if alreadyClaimedCount != rounds {
t.Errorf("expected %d ErrAlreadyClaimed, got %d", rounds, alreadyClaimedCount)
}
}
func TestRollupCosts(t *testing.T) {
db, userID, goalID := testDB(t)
svc := NewService(NewStore(db), slog.Default())
// Build: root → a, b; a → a1
_, allIDs, err := svc.CreateTree(context.Background(), CreateTreeInput{
GoalID: goalID,
CreatedByUser: &userID,
Root: TreeNode{
Title: "root", Description: "d",
Children: []TreeNode{
{Title: "a", Description: "d", Children: []TreeNode{
{Title: "a1", Description: "d"},
}},
{Title: "b", Description: "d"},
},
},
})
if err != nil {
t.Fatalf("CreateTree: %v", err)
}
if len(allIDs) != 4 {
t.Fatalf("expected 4 tasks, got %d", len(allIDs))
}
rootID := allIDs[0]
// Spend on a1 and b (the leaves).
a1ID := allIDs[2]
bID := allIDs[3]
if err := svc.AddSpend(context.Background(), a1ID, 100, 50); err != nil {
t.Fatal(err)
}
if err := svc.AddSpend(context.Background(), bID, 200, 75); err != nil {
t.Fatal(err)
}
tokens, dollars, count, err := svc.RollupCosts(context.Background(), rootID)
if err != nil {
t.Fatalf("RollupCosts: %v", err)
}
if tokens != 300 || dollars != 125 || count != 4 {
t.Errorf("rollup wrong: tokens=%d dollars=%d count=%d", tokens, dollars, count)
}
}
func TestTransition_StateMachine(t *testing.T) {
db, userID, goalID := testDB(t)
svc := NewService(NewStore(db), slog.Default())
_, allIDs, err := svc.CreateTree(context.Background(), CreateTreeInput{
GoalID: goalID,
CreatedByUser: &userID,
Root: TreeNode{Title: "solo", Description: "d"},
InitialStatus: StatusApproved,
})
if err != nil {
t.Fatal(err)
}
taskID := allIDs[0]
// Legal: approved → claimed → in_progress → awaiting_verification → done
steps := []string{StatusClaimed, StatusInProgress, StatusAwaitingVerification, StatusDone}
for _, step := range steps {
if err := svc.Transition(context.Background(), taskID, step, Extras{}); err != nil {
t.Fatalf("transition to %s: %v", step, err)
}
}
// Illegal: done → approved
if err := svc.Transition(context.Background(), taskID, StatusApproved, Extras{}); err == nil {
t.Error("expected illegal transition from done → approved")
}
}