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>
139 lines
4.3 KiB
Go
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")
|
|
)
|