Until now the doc-gardener example was a single monolithic
orchestrator binary writing synthetic messages directly to SQLite.
That's now obsolete: the feature runs as a true multi-agent flow
where the SynapBus reactor fires subprocess runs for every DM, each
agent is its own reactive subprocess invocation, and follow-up DMs
go through the real MessagingService.Send → dispatcher path so the
reactor picks them up.
Changes:
- cmd/synapbus/main.go: gate the three legacy background workers
(expiry, retention, stalemate) behind SYNAPBUS_DISABLE_*_WORKER env
flags. These workers manage the legacy channel task-auction /
message retention features the doc-gardener demo doesn't use, but
they held the single-connection write pool long enough to wedge
the whole server for interactive sessions. All three are disabled
in the example's start.sh.
- cmd/docgardener/agent.go (new): the per-agent subprocess entry the
reactor harness invokes for every reactive trigger. Reads
message.json from the workdir, routes by SYNAPBUS_AGENT to either
coordinator-kickoff, coordinator-completion, or specialist-work
logic. Writes prompt.txt + response.txt for harness capture. Uses
the admin socket (`synapbus messages send`) for follow-up DMs so
the real MessagingService.Send path fires the dispatcher.
- cmd/docgardener/main.go: adds `docgardener agent` subcommand, plus
helpers freshAPIKey / bcryptHash / absPath / selfPath used by the
spawn flow.
- examples/doc-gardener/start.sh: provisions user + coordinator
agent + algis human agent + approvals/requests channels; the
coordinator is created with trigger_mode=reactive,
harness_name=subprocess, local_command pointing to docgardener
agent, and harness_config_json.env carrying SYNAPBUS_AGENT,
SYNAPBUS_BIN, SYNAPBUS_SOCKET. Specialists are spawned
dynamically by the coordinator at runtime (not pre-registered),
so the demo exercises dynamic agent spawning end-to-end.
- examples/doc-gardener/run_task.sh: collapsed to a 3-line kickoff
that just DMs the coordinator and polls algis's inbox for the
coordinator's FINAL: reply. Everything else happens via the
reactor.
Verified end-to-end in Chrome on a fresh instance:
- 4 agents registered (coordinator + 3 specialists dynamically
spawned by the coordinator on receipt of the first DM)
- 7 reactive_runs + 6 harness_runs across the goal lifecycle:
algis → coordinator (kickoff, 624ms, builds goal+tree+spawns)
coordinator → docs-scanner (claim task 2)
coordinator → cli-verifier (claim task 3)
coordinator → drift-reporter (claim task 4)
docs-scanner → coordinator (DONE task=2)
cli-verifier → coordinator (DONE task=3)
drift-reporter → coordinator (DONE task=4, coalesced)
- Web UI Agent Runs page shows all 7 runs with the real
"DM from X" trigger lines and correct sender/receiver chain
- Goal ends at status=completed with all 3 leaf tasks at status=done
- Each specialist run posts a real subprocess artifact to the
goal channel (#finding, #verified, #summary) and appends a real
reputation_evidence row keyed by config_hash.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
153 lines
4.5 KiB
Go
153 lines
4.5 KiB
Go
// docgardener is a self-contained demo driver for the dynamic
|
|
// agent spawning feature (spec 018). It talks directly to the
|
|
// SynapBus SQLite database that a running `synapbus serve` instance
|
|
// created, drives a goal → task tree → spawned specialists flow
|
|
// through the new primitives, and renders a rich HTML report.
|
|
//
|
|
// It is intentionally NOT wired through the MCP tool layer or the
|
|
// reactor — the MVP's goal is to prove the core data primitives
|
|
// (goals, goal_tasks, config_hash, delegation cap, reputation ledger,
|
|
// atomic claim, cost rollup) work end-to-end and produce a
|
|
// human-readable report. Real LLM autonomy + subprocess execution
|
|
// is a follow-up PR (see specs/018-dynamic-agent-spawning/tasks.md).
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
var (
|
|
flagDBPath string
|
|
flagGoalID int64
|
|
flagOutputPath string
|
|
)
|
|
|
|
func main() {
|
|
root := &cobra.Command{
|
|
Use: "docgardener",
|
|
Short: "Dynamic-agent-spawning demo driver",
|
|
}
|
|
|
|
runCmd := &cobra.Command{
|
|
Use: "run",
|
|
Short: "Execute the doc-gardener demo flow end-to-end",
|
|
RunE: runDemo,
|
|
}
|
|
runCmd.Flags().StringVar(&flagDBPath, "db", "./data/synapbus.db", "Path to SynapBus SQLite DB")
|
|
|
|
reportCmd := &cobra.Command{
|
|
Use: "report",
|
|
Short: "Render the HTML report for a completed run",
|
|
RunE: renderReport,
|
|
}
|
|
reportCmd.Flags().StringVar(&flagDBPath, "db", "./data/synapbus.db", "Path to SynapBus SQLite DB")
|
|
reportCmd.Flags().Int64Var(&flagGoalID, "goal", 0, "Goal id to report on (0 = latest)")
|
|
reportCmd.Flags().StringVar(&flagOutputPath, "out", "./report.html", "Output HTML file path")
|
|
|
|
agentCmd := &cobra.Command{
|
|
Use: "agent",
|
|
Short: "Per-agent subprocess entry — invoked by the reactor harness for every reactive trigger",
|
|
RunE: runAgent,
|
|
}
|
|
agentCmd.Flags().StringVar(&flagDBPath, "db", "./data/synapbus.db", "Path to SynapBus SQLite DB")
|
|
|
|
root.AddCommand(runCmd, reportCmd, agentCmd)
|
|
|
|
if err := root.Execute(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// openDB opens the SynapBus SQLite DB with the same settings the
|
|
// server uses (WAL, foreign keys on) so direct writes interleave
|
|
// safely with the running process.
|
|
func openDB(path string) (*sql.DB, error) {
|
|
if _, err := os.Stat(path); err != nil {
|
|
return nil, fmt.Errorf("db not found at %s (did you run ./start.sh?): %w", path, err)
|
|
}
|
|
abs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dsn := fmt.Sprintf("file:%s?_foreign_keys=on&_pragma=busy_timeout(5000)&_pragma=journal_mode(wal)", abs)
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
return db, nil
|
|
}
|
|
|
|
// freshAPIKey mints a random 48-hex-char key with the "sk-dg-" prefix.
|
|
func freshAPIKey() (string, error) {
|
|
buf := make([]byte, 24)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return "sk-dg-" + hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
// bcryptHash wraps bcrypt.GenerateFromPassword at the default cost.
|
|
func bcryptHash(s string) (string, error) {
|
|
h, err := bcrypt.GenerateFromPassword([]byte(s), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(h), nil
|
|
}
|
|
|
|
// absPath resolves a (possibly relative) path to absolute form.
|
|
func absPath(p string) (string, error) { return filepath.Abs(p) }
|
|
|
|
// selfPath returns the absolute path of the running docgardener binary.
|
|
// Used to build the local_command for spawned specialists.
|
|
func selfPath() string {
|
|
if p, err := os.Executable(); err == nil {
|
|
return p
|
|
}
|
|
return "docgardener"
|
|
}
|
|
|
|
func runDemo(_ *cobra.Command, _ []string) error {
|
|
db, err := openDB(flagDBPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
ctx := context.Background()
|
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
logger = logger.With("component", "docgardener")
|
|
|
|
flow := newFlow(db, logger)
|
|
if err := flow.bootstrap(ctx); err != nil {
|
|
return fmt.Errorf("bootstrap: %w", err)
|
|
}
|
|
goalID, err := flow.run(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("demo run: %w", err)
|
|
}
|
|
|
|
// Leave a marker so report.sh knows which goal is "latest".
|
|
if err := os.WriteFile(".last_goal_id", []byte(fmt.Sprintf("%d\n", goalID)), 0644); err != nil {
|
|
logger.Warn("could not write .last_goal_id", "err", err)
|
|
}
|
|
|
|
fmt.Printf("\n✓ Demo run complete. Goal id: %d\n", goalID)
|
|
fmt.Printf(" Render report: ./report.sh\n")
|
|
return nil
|
|
}
|