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>
173 lines
5.2 KiB
Go
173 lines
5.2 KiB
Go
package goaltasks
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
)
|
|
|
|
// Service is the high-level API for creating, claiming, and advancing tasks.
|
|
type Service struct {
|
|
store *Store
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewService constructs a task service.
|
|
func NewService(store *Store, logger *slog.Logger) *Service {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &Service{store: store, logger: logger}
|
|
}
|
|
|
|
// Store exposes the backing store for callers that need direct access
|
|
// (e.g. the HTML report generator).
|
|
func (s *Service) Store() *Store {
|
|
return s.store
|
|
}
|
|
|
|
// CreateTreeInput captures the arguments of CreateTree.
|
|
type CreateTreeInput struct {
|
|
GoalID int64
|
|
CreatedByAgent *int64
|
|
CreatedByUser *int64
|
|
Root TreeNode
|
|
InitialStatus string // defaults to StatusApproved (for auto-approved flows)
|
|
DefaultBilling string
|
|
}
|
|
|
|
// CreateTree materializes a tree of tasks under a goal in a single
|
|
// transaction. Ancestry is denormalized at create time. Returns the
|
|
// root task id and the flat list of all created ids in insertion order.
|
|
func (s *Service) CreateTree(ctx context.Context, in CreateTreeInput) (rootTaskID int64, allIDs []int64, err error) {
|
|
if in.InitialStatus == "" {
|
|
in.InitialStatus = StatusApproved
|
|
}
|
|
tx, err := s.store.DB().BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
defer func() {
|
|
if err != nil {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
var walk func(node TreeNode, parentID *int64, depth int, ancestry []AncestryNode) (int64, error)
|
|
walk = func(node TreeNode, parentID *int64, depth int, ancestry []AncestryNode) (int64, error) {
|
|
billing := node.BillingCode
|
|
if billing == "" {
|
|
billing = in.DefaultBilling
|
|
}
|
|
t := &Task{
|
|
GoalID: in.GoalID,
|
|
ParentTaskID: parentID,
|
|
Ancestry: ancestry,
|
|
Depth: depth,
|
|
Title: node.Title,
|
|
Description: node.Description,
|
|
AcceptanceCriteria: node.AcceptanceCriteria,
|
|
CreatedByAgentID: in.CreatedByAgent,
|
|
CreatedByUserID: in.CreatedByUser,
|
|
Status: in.InitialStatus,
|
|
BillingCode: billing,
|
|
BudgetTokens: node.BudgetTokens,
|
|
BudgetDollarsCents: node.BudgetDollarsCents,
|
|
VerifierConfig: node.VerifierConfig,
|
|
HeartbeatConfig: node.HeartbeatConfig,
|
|
}
|
|
id, err := s.store.Insert(ctx, tx, t)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
allIDs = append(allIDs, id)
|
|
|
|
if len(node.Children) > 0 {
|
|
childAncestry := append([]AncestryNode(nil), ancestry...)
|
|
childAncestry = append(childAncestry, AncestryNode{
|
|
ID: id,
|
|
Title: node.Title,
|
|
AcceptanceCriteria: node.AcceptanceCriteria,
|
|
})
|
|
for _, child := range node.Children {
|
|
if _, err := walk(child, &id, depth+1, childAncestry); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
rootTaskID, err = walk(in.Root, nil, 0, nil)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
s.logger.Info("task tree created", "goal_id", in.GoalID, "root_task_id", rootTaskID, "total", len(allIDs))
|
|
return rootTaskID, allIDs, nil
|
|
}
|
|
|
|
// Claim atomically locks a task to an agent.
|
|
func (s *Service) Claim(ctx context.Context, taskID, agentID int64, claimMessageID *int64) error {
|
|
return s.store.ClaimAtomic(ctx, taskID, agentID, claimMessageID)
|
|
}
|
|
|
|
// Transition moves a task through the state machine.
|
|
func (s *Service) Transition(ctx context.Context, taskID int64, newStatus string, extras Extras) error {
|
|
t, err := s.store.Get(ctx, taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !legalTransition(t.Status, newStatus) {
|
|
return fmt.Errorf("%w: %s → %s", ErrIllegalTransition, t.Status, newStatus)
|
|
}
|
|
return s.store.TransitionStatus(ctx, taskID, newStatus, extras)
|
|
}
|
|
|
|
// Get exposes the store's Get.
|
|
func (s *Service) Get(ctx context.Context, id int64) (*Task, error) {
|
|
return s.store.Get(ctx, id)
|
|
}
|
|
|
|
// ListByGoal exposes the store's ListByGoal.
|
|
func (s *Service) ListByGoal(ctx context.Context, goalID int64) ([]*Task, error) {
|
|
return s.store.ListByGoal(ctx, goalID)
|
|
}
|
|
|
|
// AddSpend is used by the reactor post-run to increment leaf cost.
|
|
func (s *Service) AddSpend(ctx context.Context, taskID, tokens, dollarsCents int64) error {
|
|
return s.store.AddSpend(ctx, taskID, tokens, dollarsCents)
|
|
}
|
|
|
|
// RollupCosts exposes the store's recursive CTE.
|
|
func (s *Service) RollupCosts(ctx context.Context, rootTaskID int64) (tokens, dollarsCents int64, count int, err error) {
|
|
return s.store.RollupCosts(ctx, rootTaskID)
|
|
}
|
|
|
|
// RollupByBillingCode exposes the per-billing-code rollup.
|
|
func (s *Service) RollupByBillingCode(ctx context.Context, rootTaskID int64) (map[string]Spend, error) {
|
|
return s.store.RollupByBillingCode(ctx, rootTaskID)
|
|
}
|
|
|
|
// legalTransition encodes the task state machine.
|
|
func legalTransition(from, to string) bool {
|
|
if to == StatusCancelled {
|
|
return from != StatusDone && from != StatusFailed && from != StatusCancelled
|
|
}
|
|
switch from {
|
|
case StatusProposed:
|
|
return to == StatusApproved
|
|
case StatusApproved:
|
|
return to == StatusClaimed
|
|
case StatusClaimed:
|
|
return to == StatusInProgress || to == StatusAwaitingVerification
|
|
case StatusInProgress:
|
|
return to == StatusAwaitingVerification
|
|
case StatusAwaitingVerification:
|
|
return to == StatusDone || to == StatusFailed
|
|
}
|
|
return false
|
|
}
|