Files
synapbus/internal/harness/subprocess/subprocess.go
T
Algis DumbrisandClaude Opus 4.6 fee73e33a0 feat(ux): run detail page + reaction pills + captured prompt/response
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>
2026-04-14 09:26:18 +03:00

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
}