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>
74 lines
2.4 KiB
Go
74 lines
2.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
)
|
|
|
|
// dbChannelCreator implements goals.ChannelCreator without taking a
|
|
// dependency on the internal/channels service (which would drag in
|
|
// half the server). It talks to the channels and channel_members
|
|
// tables directly. This is only safe because the demo driver runs
|
|
// against the same process's DB and the channels schema is stable.
|
|
type dbChannelCreator struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// CreateGoalChannel satisfies goals.ChannelCreator.
|
|
func (c *dbChannelCreator) CreateGoalChannel(ctx context.Context, slug, title, description, ownerUsername string) (int64, error) {
|
|
name := "goal-" + slug
|
|
id, err := c.ensureChannel(ctx, name, description, "blackboard", ownerUsername)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if ownerUsername != "" {
|
|
if err := c.addMember(ctx, id, ownerUsername); err != nil {
|
|
return 0, fmt.Errorf("add owner %q to goal channel: %w", ownerUsername, err)
|
|
}
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// ensureChannel upserts a channel row by name. Returns the id.
|
|
func (c *dbChannelCreator) ensureChannel(ctx context.Context, name, description, channelType, createdBy string) (int64, error) {
|
|
var id int64
|
|
err := c.db.QueryRowContext(ctx, `SELECT id FROM channels WHERE name = ?`, name).Scan(&id)
|
|
if err == nil {
|
|
return id, nil
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return 0, err
|
|
}
|
|
res, err := c.db.ExecContext(ctx, `
|
|
INSERT INTO channels (name, description, type, is_private, is_system, created_by)
|
|
VALUES (?, ?, ?, 0, 0, ?)`, name, description, channelType, createdBy)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create channel %q: %w", name, err)
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
// getByName resolves a channel id by name.
|
|
func (c *dbChannelCreator) getByName(ctx context.Context, name string) (int64, error) {
|
|
var id int64
|
|
err := c.db.QueryRowContext(ctx, `SELECT id FROM channels WHERE name = ?`, name).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
// addMember is idempotent — does nothing if the member is already present.
|
|
func (c *dbChannelCreator) addMember(ctx context.Context, channelID int64, agentName string) error {
|
|
var exists int
|
|
_ = c.db.QueryRowContext(ctx,
|
|
`SELECT COUNT(1) FROM channel_members WHERE channel_id=? AND agent_name=?`,
|
|
channelID, agentName).Scan(&exists)
|
|
if exists > 0 {
|
|
return nil
|
|
}
|
|
_, err := c.db.ExecContext(ctx, `
|
|
INSERT INTO channel_members (channel_id, agent_name, role)
|
|
VALUES (?, ?, 'member')`, channelID, agentName)
|
|
return err
|
|
}
|