Makes the Web UI reflect what agents are actually doing: reactions
on DMs that trigger a subprocess run, a per-run detail page that
shows the exact prompt the model received and the raw response, and
cross-linked reactive_runs ↔ harness_runs data for a single composite
API call.
Migration 020 (internal/storage/schema/020_harness_run_detail.sql):
ALTER TABLE harness_runs ADD COLUMN reactive_run_id INTEGER;
ALTER TABLE harness_runs ADD COLUMN prompt TEXT;
ALTER TABLE harness_runs ADD COLUMN response TEXT;
CREATE INDEX idx_harness_runs_reactive ON harness_runs(reactive_run_id);
internal/harness:
* ExecRequest.ReactiveRunID — reactor pins the reactive_runs row id
so the observer can JOIN the two tables.
* ExecResult.Prompt / Response — the subprocess harness reads
prompt.txt / response.txt that wrappers write into the workdir,
and runs.Store persists them (capped at 32 KiB each).
* runs.Run struct now has JSON tags — previously the API returned
PascalCase field names that didn't match the Web UI's snake_case
TypeScript types.
* New runs.Store.GetByReactiveRunID for the composite API endpoint.
* Test schema updated to include the new columns.
internal/reactor:
* New ReactionNotifier interface + SetReactionNotifier.
* dispatchHarness now reacts `in_progress` on the triggering DM
before spawning the goroutine.
* runHarness reacts `done` on success, `reject` on failure. The
existing reactionPriority ordering means the terminal reaction
wins for badge display — no need to remove in_progress first.
* dispatchHarness sets ExecRequest.ReactiveRunID.
cmd/synapbus/main.go:
* reactorReactionAdapter: adapts reactions.Service.Toggle to the
reactor's one-shot AddReaction signature.
* HarnessRunsStore wired into the API router config.
internal/api/runs_handler.go — GetRun composite endpoint:
The GET /api/runs/{id} response now returns everything the Web UI
needs to render the run detail page in one call:
{
"run": <reactive_runs row>,
"harness_run": <linked harness_runs row with prompt/response>,
"agent": <current agent snapshot with harness_config_json>,
"trigger_message": <DM that started the run>,
"outgoing_message": <first DM the agent produced after startedAt>
}
The outgoing-message lookup wraps both sides of the created_at
comparison in datetime() so SQLite parses the stored 'YYYY-MM-DD
HH:MM:SS' and the Go-emitted RFC3339 into the same canonical form
before comparing — a raw string compare was silently returning no
rows.
internal/api/router.go: HarnessRunsStore field in RouterConfig, wired
through to NewRunsHandler.
examples/cold-topic-explainer/wrapper.sh:
Writes prompt.txt and response.txt alongside gemini.stdout.raw so
the subprocess harness can capture "what the model saw" and "what
the model said" post-hoc.
web/src/lib/components/MessageList.svelte:
New ReactionPills render below each message body when the message
carries a `reactions` array (already populated by
EnrichMessages/ReactionEnricher on the server side). Makes the
👀 in_progress / ✔ done / ❌ reject lifecycle visible in every DM
view and conversation.
web/src/routes/runs/[id]/+page.svelte (NEW):
New run detail page at /runs/:id with sections:
1. Header strip — agent, status pill, backend badge, trigger
info, duration, tokens in/out, cost, exit code, trace id.
2. Triggering message — body + sender.
3. What the model saw — GEMINI.md / CLAUDE.md from agent snapshot
+ the captured rendered prompt (byte count on each summary
bar, collapsible details).
4. What the model said — captured response, falling back to
logs_excerpt or error_log when unavailable.
5. Outgoing message — body + recipient + status.
6. Metadata — reactive_run.id, harness_run.run_id, backend,
session_id, tokens_cached, k8s_job, agent trigger config.
Styled against the existing dark tailwind system — no design
overhaul, fits the current aesthetic (editorial sectioning,
monospace for code-like content, accent-blue for links,
accent-purple for system-instructions, accent-green for model
output, accent-red for errors).
web/src/routes/runs/+page.svelte: the inline expand panel now has
a "View full details →" link next to the Retry button.
E2E VERIFIED on a live subprocess run:
* Topic: "why does the subprocess harness materialise GEMINI.md
alongside .gemini/settings.json in the per-run workdir?"
* 3 subprocess runs + 3 reactive_runs + 3 harness_runs, all linked.
* message_reactions: 6 rows — in_progress + done for each hop.
* GET /api/runs/1 returns a composite with
harness_run.prompt=883 bytes, harness_run.response=550 bytes,
reactive_run_id=1, trigger_message populated, outgoing_message
populated (decomposer-pro → writer-flash), agent.gemini_md=747
bytes. All keys are snake_case as the Svelte types expect.
Full go test ./... green. `vite build` green. Demo instance still
running on port 18088 for browser verification.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
396 lines
11 KiB
Go
396 lines
11 KiB
Go
// Package subprocess is the local-process implementation of
|
|
// harness.Harness. It runs an agent as a child process of synapbus
|
|
// using os/exec, captures stdout+stderr, and reads an optional
|
|
// result.json the child may have written into its workdir.
|
|
//
|
|
// Works on Mac and Linux identically (no CGO, no platform-specific
|
|
// syscalls). Windows is not targeted because synapbus itself does not
|
|
// target Windows.
|
|
package subprocess
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/synapbus/synapbus/internal/agents"
|
|
"github.com/synapbus/synapbus/internal/harness"
|
|
)
|
|
|
|
// Config tunes the subprocess harness. Zero-value defaults are sensible
|
|
// for development; production callers typically set BaseDir to a
|
|
// predictable location under SYNAPBUS_DATA_DIR so forensics are easy.
|
|
type Config struct {
|
|
// BaseDir is the parent directory under which a per-run workdir is
|
|
// created. Defaults to os.TempDir() when empty.
|
|
BaseDir string
|
|
|
|
// LogsCap bounds the number of bytes kept in ExecResult.Logs. The
|
|
// full stdout/stderr stream is written to `stdout.log` /
|
|
// `stderr.log` inside the workdir for forensics. Defaults to 64 KiB.
|
|
LogsCap int
|
|
|
|
// KeepWorkdirOnSuccess leaves the workdir behind even for
|
|
// zero-exit runs. Useful for debugging test flakes.
|
|
KeepWorkdirOnSuccess bool
|
|
}
|
|
|
|
// Harness runs agents as local subprocesses.
|
|
type Harness struct {
|
|
cfg Config
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// New constructs a subprocess harness. Pass zero Config for defaults.
|
|
func New(cfg Config, logger *slog.Logger) *Harness {
|
|
if cfg.LogsCap <= 0 {
|
|
cfg.LogsCap = 64 * 1024
|
|
}
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &Harness{
|
|
cfg: cfg,
|
|
logger: logger.With("harness", "subprocess"),
|
|
}
|
|
}
|
|
|
|
// Name returns the registered harness name.
|
|
func (h *Harness) Name() string { return "subprocess" }
|
|
|
|
// Capabilities advertises backend features.
|
|
func (h *Harness) Capabilities() harness.Capabilities {
|
|
return harness.Capabilities{
|
|
SystemPrompt: true,
|
|
SessionResume: true,
|
|
Skills: false,
|
|
OTelNative: true,
|
|
MaxConcurrency: 4,
|
|
}
|
|
}
|
|
|
|
// TestEnvironment is a cheap sanity check: BaseDir (or os.TempDir) must
|
|
// exist and be writable. Per-agent binary reachability is checked at
|
|
// Execute time because the binary is agent-specific.
|
|
func (h *Harness) TestEnvironment(ctx context.Context) error {
|
|
base := h.cfg.BaseDir
|
|
if base == "" {
|
|
base = os.TempDir()
|
|
}
|
|
info, err := os.Stat(base)
|
|
if err != nil {
|
|
return fmt.Errorf("subprocess: base dir %q: %w", base, err)
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("subprocess: base dir %q is not a directory", base)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Provision is a no-op for subprocess. All per-run state goes into the
|
|
// workdir Execute creates on the fly.
|
|
func (h *Harness) Provision(ctx context.Context, agent *agents.Agent) error {
|
|
return nil
|
|
}
|
|
|
|
// ErrNoLocalCommand is returned when an agent has no LocalCommand
|
|
// configured so the subprocess backend cannot know what to run.
|
|
var ErrNoLocalCommand = errors.New("subprocess: agent has no local_command configured")
|
|
|
|
// Execute launches the child, waits for it to exit (or for ctx /
|
|
// Budget to fire), and returns its output.
|
|
func (h *Harness) Execute(ctx context.Context, req *harness.ExecRequest) (*harness.ExecResult, error) {
|
|
if req == nil {
|
|
return nil, errors.New("subprocess: nil ExecRequest")
|
|
}
|
|
if req.Agent == nil {
|
|
return nil, errors.New("subprocess: ExecRequest.Agent is required")
|
|
}
|
|
|
|
argv, err := parseLocalCommand(req.Agent.LocalCommand)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Per-run workdir
|
|
base := h.cfg.BaseDir
|
|
if base == "" {
|
|
base = os.TempDir()
|
|
}
|
|
if err := os.MkdirAll(base, 0o755); err != nil {
|
|
return nil, fmt.Errorf("subprocess: mkdir base: %w", err)
|
|
}
|
|
runDirName := sanitizeRunDir(req.RunID)
|
|
if runDirName == "" {
|
|
runDirName = fmt.Sprintf("run-%d", time.Now().UnixNano())
|
|
}
|
|
workdir := filepath.Join(base, runDirName)
|
|
if err := os.MkdirAll(workdir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("subprocess: mkdir workdir: %w", err)
|
|
}
|
|
|
|
// Context with Budget timeout if set.
|
|
runCtx := ctx
|
|
if req.Budget.MaxWallClock > 0 {
|
|
var cancel context.CancelFunc
|
|
runCtx, cancel = context.WithTimeout(ctx, req.Budget.MaxWallClock)
|
|
defer cancel()
|
|
}
|
|
|
|
// Write the triggering message to message.json for the child to
|
|
// read if it cares. Simple, explicit, no stdin-piping ambiguity.
|
|
if req.Message != nil {
|
|
raw, _ := json.Marshal(req.Message)
|
|
_ = os.WriteFile(filepath.Join(workdir, "message.json"), raw, 0o644)
|
|
}
|
|
|
|
// Materialise the agent's declarative config (CLAUDE.md, AGENTS.md,
|
|
// .mcp.json, .claude/skills/*, .claude/agents/*) into the workdir.
|
|
// The child's CLI (claude, gemini, codex) finds them via the
|
|
// conventions it already uses.
|
|
cfg, err := ParseAgentConfig(req.Agent.HarnessConfigJSON)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := MaterialiseAgentConfig(workdir, cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cmd := exec.CommandContext(runCtx, argv[0], argv[1:]...)
|
|
cmd.Dir = workdir
|
|
cmd.Env = buildEnv(req, workdir, cfg)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = io.MultiWriter(&stdout, limitedFileWriter(workdir, "stdout.log"))
|
|
cmd.Stderr = io.MultiWriter(&stderr, limitedFileWriter(workdir, "stderr.log"))
|
|
|
|
h.logger.Info("subprocess launching",
|
|
"run_id", req.RunID,
|
|
"agent", req.AgentName,
|
|
"cmd", argv[0],
|
|
"workdir", workdir,
|
|
)
|
|
|
|
startedAt := time.Now()
|
|
runErr := cmd.Run()
|
|
duration := time.Since(startedAt)
|
|
|
|
exitCode := 0
|
|
if runErr != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(runErr, &exitErr) {
|
|
exitCode = exitErr.ExitCode()
|
|
} else {
|
|
exitCode = 1
|
|
}
|
|
}
|
|
|
|
// Load optional result.json
|
|
var resultJSON json.RawMessage
|
|
if raw, readErr := os.ReadFile(filepath.Join(workdir, "result.json")); readErr == nil && len(raw) > 0 {
|
|
if json.Valid(raw) {
|
|
resultJSON = json.RawMessage(raw)
|
|
}
|
|
}
|
|
|
|
// Load optional prompt.txt / response.txt that wrappers write as
|
|
// a post-hoc audit trail for the Web UI run detail view.
|
|
promptText := ""
|
|
if raw, readErr := os.ReadFile(filepath.Join(workdir, "prompt.txt")); readErr == nil {
|
|
promptText = string(raw)
|
|
}
|
|
responseText := ""
|
|
if raw, readErr := os.ReadFile(filepath.Join(workdir, "response.txt")); readErr == nil {
|
|
responseText = string(raw)
|
|
}
|
|
|
|
logs := mergeLogs(&stdout, &stderr, h.cfg.LogsCap)
|
|
|
|
// Cleanup policy: remove workdir on success unless configured to
|
|
// keep it; always keep on failure so users can inspect stdout.log /
|
|
// stderr.log / message.json / result.json.
|
|
if exitCode == 0 && !h.cfg.KeepWorkdirOnSuccess {
|
|
_ = os.RemoveAll(workdir)
|
|
}
|
|
|
|
h.logger.Info("subprocess finished",
|
|
"run_id", req.RunID,
|
|
"agent", req.AgentName,
|
|
"exit", exitCode,
|
|
"duration_ms", duration.Milliseconds(),
|
|
)
|
|
|
|
result := &harness.ExecResult{
|
|
ExitCode: exitCode,
|
|
Logs: logs,
|
|
ResultJSON: resultJSON,
|
|
Prompt: promptText,
|
|
Response: responseText,
|
|
}
|
|
|
|
// Distinguish context timeout from plain failures so the caller
|
|
// can tell "budget exceeded" from "the agent crashed".
|
|
if runErr != nil && errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
|
return result, fmt.Errorf("subprocess: wall-clock budget %s exceeded", req.Budget.MaxWallClock)
|
|
}
|
|
if runErr != nil && errors.Is(runCtx.Err(), context.Canceled) {
|
|
return result, runCtx.Err()
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Cancel is a no-op for subprocess today — cancellation happens via the
|
|
// context passed to Execute. A future iteration could track in-flight
|
|
// runs by RunID and send SIGTERM to them.
|
|
func (h *Harness) Cancel(ctx context.Context, runID string) error {
|
|
return nil
|
|
}
|
|
|
|
// -- helpers --------------------------------------------------------------
|
|
|
|
// parseLocalCommand accepts either a JSON array (["claude", "--print"])
|
|
// or a simple space-separated string. Returns the argv slice or an
|
|
// error if neither form parses.
|
|
func parseLocalCommand(raw string) ([]string, error) {
|
|
s := strings.TrimSpace(raw)
|
|
if s == "" {
|
|
return nil, ErrNoLocalCommand
|
|
}
|
|
if strings.HasPrefix(s, "[") {
|
|
var argv []string
|
|
if err := json.Unmarshal([]byte(s), &argv); err != nil {
|
|
return nil, fmt.Errorf("subprocess: parse local_command JSON: %w", err)
|
|
}
|
|
if len(argv) == 0 {
|
|
return nil, ErrNoLocalCommand
|
|
}
|
|
return argv, nil
|
|
}
|
|
// Fall back to whitespace split. Suitable for simple commands.
|
|
parts := strings.Fields(s)
|
|
if len(parts) == 0 {
|
|
return nil, ErrNoLocalCommand
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
// buildEnv constructs the env var list for the child. Starts from the
|
|
// parent's environment (so HOME, PATH, credentials are inherited by
|
|
// default — matches current K8s Pod behaviour). Then layers the
|
|
// agent's k8s_env_json (for cross-backend consistency), then the
|
|
// harness_config_json `env` block (per-agent harness-specific env),
|
|
// then caller overrides, then the SYNAPBUS_* run-context variables.
|
|
func buildEnv(req *harness.ExecRequest, workdir string, cfg AgentConfig) []string {
|
|
env := map[string]string{}
|
|
for _, kv := range os.Environ() {
|
|
if i := strings.IndexByte(kv, '='); i >= 0 {
|
|
env[kv[:i]] = kv[i+1:]
|
|
}
|
|
}
|
|
|
|
// agent env map (K8sEnvJSON is shared across backends today)
|
|
if req.Agent != nil && req.Agent.K8sEnvJSON != "" {
|
|
var m map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(req.Agent.K8sEnvJSON), &m); err == nil {
|
|
for k, v := range m {
|
|
var s string
|
|
if err := json.Unmarshal(v, &s); err == nil {
|
|
env[k] = s
|
|
continue
|
|
}
|
|
env[k] = strings.Trim(string(v), "\"")
|
|
}
|
|
}
|
|
}
|
|
|
|
// harness_config_json env block
|
|
for k, v := range cfg.Env {
|
|
env[k] = v
|
|
}
|
|
|
|
// caller overrides
|
|
for k, v := range req.Env {
|
|
env[k] = v
|
|
}
|
|
|
|
// run context
|
|
env["SYNAPBUS_RUN_ID"] = req.RunID
|
|
env["SYNAPBUS_AGENT"] = req.AgentName
|
|
env["SYNAPBUS_WORKDIR"] = workdir
|
|
if req.Message != nil {
|
|
env["SYNAPBUS_MESSAGE_ID"] = fmt.Sprintf("%d", req.Message.ID)
|
|
env["SYNAPBUS_FROM_AGENT"] = req.Message.FromAgent
|
|
}
|
|
|
|
out := make([]string, 0, len(env))
|
|
for k, v := range env {
|
|
out = append(out, k+"="+v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mergeLogs interleaves stdout then stderr with a header, bounded by cap.
|
|
func mergeLogs(out, errb *bytes.Buffer, cap int) string {
|
|
var b strings.Builder
|
|
if out.Len() > 0 {
|
|
b.WriteString(out.String())
|
|
}
|
|
if errb.Len() > 0 {
|
|
if b.Len() > 0 {
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("-- stderr --\n")
|
|
b.WriteString(errb.String())
|
|
}
|
|
s := b.String()
|
|
if cap > 0 && len(s) > cap {
|
|
// Keep the tail — most informative on failure.
|
|
s = "... [truncated " + fmt.Sprintf("%d", len(s)-cap) + " bytes] ...\n" + s[len(s)-cap:]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// limitedFileWriter returns a writer that appends to a file inside the
|
|
// workdir. Errors are silently ignored — logs are best-effort and must
|
|
// not fail the run.
|
|
func limitedFileWriter(workdir, name string) io.Writer {
|
|
f, err := os.OpenFile(filepath.Join(workdir, name), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
|
if err != nil {
|
|
return io.Discard
|
|
}
|
|
return f
|
|
}
|
|
|
|
// sanitizeRunDir strips characters that cause surprises on case-folded
|
|
// filesystems or in shell globs. Keeps the resulting name readable.
|
|
func sanitizeRunDir(runID string) string {
|
|
if runID == "" {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
for _, r := range runID {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
|
b.WriteRune(r)
|
|
case r == '-' || r == '_':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteByte('-')
|
|
}
|
|
}
|
|
name := b.String()
|
|
if len(name) > 64 {
|
|
name = name[:64]
|
|
}
|
|
return name
|
|
}
|