Files
synapbus/internal/goaltasks/types.go
T
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

139 lines
4.3 KiB
Go

// Package goaltasks implements the work-task tree rooted in a goal.
// Table name is goal_tasks (not tasks) because the legacy channel
// task-auction feature already owns the tasks table.
//
// Tasks are single-assignee, atomically claimable, and carry a
// denormalized goal-ancestry snapshot so every subprocess run can
// see the full root-to-parent context without recursive queries.
package goaltasks
import (
"encoding/json"
"errors"
"time"
)
// Task status values.
const (
StatusProposed = "proposed"
StatusApproved = "approved"
StatusClaimed = "claimed"
StatusInProgress = "in_progress"
StatusAwaitingVerification = "awaiting_verification"
StatusDone = "done"
StatusFailed = "failed"
StatusCancelled = "cancelled"
)
// Verifier kinds.
const (
VerifierKindAuto = "auto"
VerifierKindPeer = "peer"
VerifierKindCommand = "command"
)
// AncestryNode is one entry in a task's denormalized ancestry chain,
// copied from the root down to the parent at create time.
type AncestryNode struct {
ID int64 `json:"id"`
Title string `json:"title"`
AcceptanceCriteria string `json:"acceptance_criteria,omitempty"`
}
// VerifierConfig describes how to verify a task once the assignee
// reports it complete. Exactly one kind is present.
type VerifierConfig struct {
Kind string `json:"kind"`
AgentID int64 `json:"agent_id,omitempty"`
Cmd string `json:"cmd,omitempty"`
Cwd string `json:"cwd,omitempty"`
TimeoutSec int `json:"timeout_sec,omitempty"`
}
// HeartbeatConfig controls how the reactor wakes the assignee.
type HeartbeatConfig struct {
Source string `json:"source"`
IntervalSec int `json:"interval_sec,omitempty"`
}
// Task is a node in a goal's task tree.
type Task struct {
ID int64
GoalID int64
ParentTaskID *int64
Ancestry []AncestryNode
Depth int
Title string
Description string
AcceptanceCriteria string
CreatedByAgentID *int64
CreatedByUserID *int64
AssigneeAgentID *int64
Status string
BillingCode string
BudgetTokens *int64
BudgetDollarsCents *int64
SpentTokens int64
SpentDollarsCents int64
HeartbeatConfig *HeartbeatConfig
VerifierConfig *VerifierConfig
OriginMessageID *int64
ClaimMessageID *int64
CompletionMessageID *int64
FailureReason string
CreatedAt time.Time
ApprovedAt *time.Time
ClaimedAt *time.Time
StartedAt *time.Time
CompletedAt *time.Time
}
// TreeNode is the input shape for CreateTree — a recursive task spec.
type TreeNode struct {
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria string `json:"acceptance_criteria,omitempty"`
BillingCode string `json:"billing_code,omitempty"`
BudgetTokens *int64 `json:"budget_tokens,omitempty"`
BudgetDollarsCents *int64 `json:"budget_dollars_cents,omitempty"`
VerifierConfig *VerifierConfig `json:"verifier_config,omitempty"`
HeartbeatConfig *HeartbeatConfig `json:"heartbeat_config,omitempty"`
Children []TreeNode `json:"children,omitempty"`
}
// MaxAncestryBytes caps the denormalized ancestry blob on any single task.
const MaxAncestryBytes = 16 * 1024
// marshalAncestry serializes an ancestry chain. Returns ErrAncestryOverflow
// if the result exceeds MaxAncestryBytes.
func marshalAncestry(nodes []AncestryNode) (string, error) {
b, err := json.Marshal(nodes)
if err != nil {
return "", err
}
if len(b) > MaxAncestryBytes {
return "", ErrAncestryOverflow
}
return string(b), nil
}
// unmarshalAncestry parses the stored JSON back into a chain.
func unmarshalAncestry(s string) ([]AncestryNode, error) {
if s == "" || s == "[]" {
return nil, nil
}
var nodes []AncestryNode
if err := json.Unmarshal([]byte(s), &nodes); err != nil {
return nil, err
}
return nodes, nil
}
// Sentinel errors.
var (
ErrTaskNotFound = errors.New("task not found")
ErrAlreadyClaimed = errors.New("task already claimed by another agent")
ErrIllegalTransition = errors.New("illegal task status transition")
ErrAncestryOverflow = errors.New("task ancestry exceeds 16 KB cap")
)