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>
288 lines
8.1 KiB
Go
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")
|
|
}
|
|
}
|