Files
synapbus/cmd/docgardener/main.go
T
Algis DumbrisandClaude Opus 4.6 3b94fab226 feat(018): real reactor-driven multi-agent doc-gardener flow
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>
2026-04-14 17:09:08 +03:00

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
}