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>
cold-topic-explainer
Toy multi-agent task that exercises the subprocess harness end-to-end. Three Gemini agents on different models collaborate via SynapBus DMs to produce a 3-paragraph explainer for a topic, with a writer ↔ critic refinement loop.
Roles
| Agent | Model | Job |
|---|---|---|
decomposer-pro |
gemini-2.5-pro |
Receives the topic, splits it into what / why / how, DMs writer-flash |
writer-flash |
gemini-2.5-flash |
Drafts (or revises) the 3-paragraph explainer, DMs critic-lite |
critic-lite |
gemini-2.5-flash-lite |
Rates each paragraph 1–10. Scores all ≥ 8 → DMs algis with FINAL:. Else DMs writer-flash with REVISE: and specific fixes |
This exercises:
- Decomposition —
decomposer-prosplits one request into 3 sub-questions - Delegation — each agent DMs the next, routed by the SynapBus reactor
- Recursive update — the writer↔critic loop runs until convergence or
max_trigger_depthfires (default 6, giving ~3 full refinement rounds)
Every hop is a subprocess reactive run, subject to the same depth /
budget / cooldown guards as a K8s reactive run. Each hop writes a
harness_runs row with usage, cost, duration, and trace id.
Prereqs
geminiCLI installed and authenticated (gemini auth logindone once)- Go 1.25+
jq,curl,sqlite3available on PATH- An unused TCP port (default 18088)
Run it
./start.sh
./run_task.sh "how does the SynapBus reactor's pending_work flag coalesce bursts of DMs?"
./stop.sh
What happens
start.shbuildssynapbusfrom the current checkout, launches a separate instance on port 18088 with a local./datadirectory, creates useralgis(passwordalgis), creates three AI agents, and configures each agent'sharness_config_jsonwith GEMINI.md, MCP pointer, role env, and the wrapper script invocation.run_task.shkicks off the chain by sending an initial DM fromalgistodecomposer-provia the admin socket, then polls for a DM toalgiswhose body starts withFINAL:. Prints the body when it arrives (or gives up after 4 min).stop.shsignals the synapbus PID and waits for it to exit cleanly.
View during the run
- Web UI: http://localhost:18088 — log in as
algis/algis-demo-pw - Agent detail (see Harness panel + traces):
- Live slog JSON:
tail -f synapbus.log | jq -c 'select(.component=="reactor" or .harness)' - All DMs in order:
./bin/synapbus --socket ./data/synapbus.sock messages list --limit 50 - Harness runs:
sqlite3 ./data/synapbus.db 'SELECT run_id, agent_name, backend, status, duration_ms, tokens_in, tokens_out, cost_usd FROM harness_runs ORDER BY id'
OpenTelemetry
Off by default. To ship spans to a collector while you run the task:
SYNAPBUS_OTEL_ENABLED=1 SYNAPBUS_OTEL_ENDPOINT=otel-collector.synapbus.svc.cluster.local:4318 ./start.sh
Or stand up a local collector first using deploy/kubic/otel-collector.yaml.
Without a collector, the same information is available in synapbus.log
as slog JSON and in the harness_runs table.
Cost
Rough cost per successful run, assuming 2 writer-critic iterations:
| Hops | Model | Cost |
|---|---|---|
| 1 | gemini-2.5-pro | ~$0.01 |
| 2 | gemini-2.5-flash | ~$0.01 |
| 3 | gemini-2.5-flash-lite | ~$0.002 |
| Total | ~$0.02 |
The daily trigger budget per agent is capped at 20 (see start.sh) so
this example cannot accidentally spend more than pennies per day even
if the reactor loops on a bug.
Files
start.sh— launch separate synapbus + configure agentsrun_task.sh— kickoff DM + poll for finalstop.sh— graceful shutdownwrapper.sh— shell wrapper used as the agents'local_command; readsmessage.json, callsgemini, routes the result back via the admin socketconfigs/*.json— per-agentharness_config_jsonblobs