15 Commits
Author SHA1 Message Date
Algis DumbrisandClaude Opus 4.6 121d05f875 feat(docker): auto-stage host OAuth credentials into agent containers
The docker harness now detects and stages host CLI auth files
(~/.gemini/oauth_creds.json, ~/.claude/.credentials.json) into a
writable agent-home directory mounted at /home/agent. This lets
containerized agents reuse the host's Gemini Pro / Claude Pro OAuth
sessions without manual secret management or API keys.

Only auth files are copied — not the host's settings.json or MCP
configs (which contain stale localhost URLs that would hang Gemini CLI
inside containers). The staged dir is writable so CLIs can create
projects.json, history, etc. alongside the auth files.

Also sets GEMINI_DEFAULT_AUTH_TYPE=oauth-personal and
GEMINI_CLI_NO_RELAUNCH=true when OAuth creds are detected, writes
Claude's hasCompletedOnboarding flag, and simplifies the doc-gardener
example to use the harness-level credential staging instead of manual
HOME directory seeding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:17:54 +03:00
Algis DumbrisandClaude Opus 4.6 42f8256df6 feat(goals): complete_goal MCP tool + draft→active auto-transition
Three improvements that turn the doc-gardener demo from "runs but
stays in 'draft' forever" into a goal that properly transitions
through its lifecycle and renders a completion summary on /goals/<id>.

### 1. complete_goal MCP tool (#59, #62)

New tool surface: complete_goal(goal_id, status, summary, completion_message_id?)

The critic calls this from inside the sandbox after it sends its FINAL:
DM. Records the one-paragraph human-readable summary on the goal row
plus a pointer to the message that carried the FINAL text, so the Web
UI /goals/<id> page has both the verdict and a deep link to the full
findings JSON.

Status parameter accepts completed | stuck | cancelled. Idempotent
when called with the current status. Rejects callers owned by a
different human than the goal owner.

Plumbing:
- New migration 026_goals_completion_summary.sql adds two columns
  to goals: completion_summary TEXT, completion_message_id INTEGER
  (FK messages.id, ON DELETE SET NULL).
- internal/goals/types.go: new CompletionSummary + CompletionMessageID
  fields on Goal struct.
- internal/goals/store.go: Get/List Scan both new columns;
  SetCompletion(goalID, status, summary, messageID) helper that
  updates status+summary+message_id atomically and populates
  completed_at for terminal states.
- internal/goals/service.go: Complete(ctx, goalID, status, summary,
  messageID) wraps the store method with legalTransition gating.
  legalTransition expanded so draft can jump straight to completed
  (no mandatory "active" hop required).
- internal/mcp/goals_tools.go: completeGoalTool definition +
  handleCompleteGoal handler. Tool count 6 → 7.
- internal/api/goals_handler.go: surfaces completion_summary,
  completion_message_id, and completed_at on both list and detail
  endpoints so the Svelte /goals UI can render them.

### 2. Draft → active auto-transition in propose_task_tree (#60)

handleProposeTaskTree now flips the goal from draft to active at the
end. Previously the coordinator would call create_goal +
propose_task_tree and dispatch inspector, but the goal stayed in
draft forever because nothing transitioned it. Now the mere fact
of having a task tree means the goal is active.

Safe: the transition is best-effort and ignores the legal-transition
error when the goal is already beyond draft.

### 3. REVISE round cap (#61)

Two-layer enforcement:

- Server-side: examples/doc-gardener/start.sh drops max_trigger_depth
  from 8 to 4. Each REVISE round costs 2 hops (critic→inspector +
  inspector→critic), so depth=4 caps the loop at roughly 2 rounds
  before the reactor refuses further dispatches.

- Prompt-side: inspector now includes revision_round (starting at
  0, incremented when it sees a REVISE: input) in its findings JSON.
  Critic reads revision_round and force-FINALs when >= 1. Prompt
  explicitly tells the critic to call complete_goal after sending
  FINAL, so the goal row gets a proper completion_summary.

### 4. run_task.sh terminal-state detection

Rewrote the poll loop to watch goals.status/completion_summary as
the definitive "done" signal rather than parsing DM bodies. Keeps
a message-based fallback for TRIVIAL/CANNOT paths that don't create
a goal. Treats "Received system trigger..." and "Coalesced
trigger..." as informational (they're `__coalesced__` reactor
synthetic events leaking through the coordinator reply, not real
user-facing output). Bare coordinator replies are terminal only
when no goal was created.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:52:44 +03:00
Algis DumbrisandClaude Opus 4.6 b5b5d6d26c fix(reactor): don't truncate body for subprocess + docker dispatch
dispatchHarness was capping event.Body at 4096 bytes before handing
it to the subprocess/docker harness. Those backends write the body
to a message.json file in the per-run workdir (bind-mounted into
the container) and have no shell/env-var size limits, so silent
truncation was hostile.

The doc-gardener inspector routinely produces 10-20 KiB findings
JSON (drift report with per-flag evidence). Truncation cut off the
trailing artifact.findings entries + artifact.recommendation,
making the report look incomplete to the critic — which then
spuriously REVISE'd, blowing the 600s deadline.

The K8s job path still truncates in createJob() because Kubernetes
imposes a 1 MiB env-var cap and most shells misbehave past a few
KiB. That's a separate code path, untouched.

Also: critic prompt rewrite (examples/doc-gardener/configs/critic.json).
The old critic spec told the critic to "spot-check evidence by
re-running the inspector's commands". That's structurally wrong:
the critic runs in a fresh container with no install state, so
re-running mcpproxy --help always fails and produces a false REVISE.
New prompt says: audit by structural consistency only, never run
shell commands to re-verify, default to FINAL, never REVISE more
than once, and FINAL the failure summary back to the owner when
the inspector reports status: failed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:31:36 +03:00
Algis DumbrisandClaude Opus 4.6 ddbb1bd329 fix(docker): tmpfs /tmp mounted rw,exec,size=128m
Docker Desktop's default noexec on tmpfs broke the "download a CLI
to /tmp, chmod +x, run it" workflow — exactly what the doc-gardener
inspector needs to verify docs.mcpproxy.app against the real
mcpproxy binary. Previously the agent spent ~10 minutes in a self-
debug loop discovering the noexec, falling back to /home/agent,
running into externally-managed Python, missing python3-venv, etc.

With /tmp exec, the inspector's own install pipeline works on the
first try: curl | tar | chmod | run. First real run produced a
72-claim drift report (21 matched / 1 drifted / 50 missing) against
mcpproxy v0.24.4 in ~8 minutes, no REVISE loop.

The 64m → 128m bump gives breathing room for curl'd tarballs that
need a temp extraction directory alongside the final binary.

Inspector prompt updated to tell the agent about the /tmp install
path explicitly and forbid the previous /home/agent detours. Also
updated the coordinator brief template to match.

Note the image itself is UNCHANGED — we deliberately do NOT bake
mcpproxy (or any other domain-specific tool) into synapbus-agent.
The image stays a blank Linux shell with Node + Python + core tools,
and each example's prompt teaches its agent how to install whatever
it needs. This keeps the gardener universal: swap in any other docs
domain and the inspector figures out what to install on demand.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:07:58 +03:00
Algis DumbrisandClaude Opus 4.6 bdec096190 feat(doc-gardener): default coordinator to gemini-3.1-pro-preview
Align with the goal-coordinator example — both now use Gemini 3 Pro
as the default for triage/coordination. Workers stay on 2.5 Flash.
Override via SYNAPBUS_COORDINATOR_MODEL when the preview model is
rate-limited.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:03:55 +03:00
Algis DumbrisandClaude Opus 4.6 f1e2b1fa38 feat(doc-gardener): MCP-native + docker-isolated multi-agent demo
Replace the legacy cmd/docgardener orchestration (~2400 LOC of Go
spawning subprocess workers via local_command + admin socket) with
three Docker-isolated agents that all reach SynapBus through MCP:

  doc-coordinator   — Gemini Pro, triages goal, calls create_goal +
                      propose_task_tree + send_message via MCP
  docs-inspector    — Gemini Flash, fetches docs, installs mcpproxy,
                      shells out to verify, reports findings via MCP
  docs-critic       — Gemini Flash, independent reviewer with its
                      own MCP API key + config_hash, audits the
                      inspector's evidence and DMs the owner

Every agent runs inside synapbus-agent:latest with --cap-drop=ALL,
--security-opt=no-new-privileges, --read-only root + tmpfs /tmp,
--pids-limit, memory + CPU quotas. The container reaches the
SynapBus MCP server on the host at host.docker.internal:18089
because the docker harness rewrites .gemini/settings.json URLs
from 127.0.0.1 automatically.

Wrapper baked into the image at /usr/local/bin/synapbus-agent-wrapper.sh
so configs don't need to mount or template a per-example wrapper.
The harness's default no longer overrides docker CMD — the image's
baked entry script is used unless docker.command is set explicitly.

start.sh changes:
  - Preflight: docker daemon, GEMINI_API_KEY (or ~/.gemini/oauth_creds.json)
  - Builds synapbus-agent image lazily on first run
  - Mints one MCP API key per agent via `agent revoke-key`
  - Templates each config with __PORT__, __*_APIKEY__, __MODEL__,
    __GEMINI_API_KEY__, __EXTRA_MOUNTS__
  - With OAuth fallback: copies host ~/.gemini → data/agent-home/.gemini
    once and bind-mounts the whole agent-home rw at /home/agent so
    in-container gemini has a writable HOME without polluting the host
  - SYNAPBUS_KEEP_WORKDIR=1 preserves per-run docker workdirs for
    debugging
  - Sets harness_name=docker explicitly so the resolver picks the
    right backend even with empty local_command

stop.sh: best-effort cleanup of lingering synapbus-* containers so a
killed parent doesn't leave bind-mount holders that block the next
start.sh from re-mounting the same paths.

run_task.sh: snapshot-baseline pattern (only watches replies newer
than the max msg id at send time), 600s deadline, treats any reply
from doc-coordinator that isn't DELEGATED:/REVISING: as terminal,
plus FINAL:/CANNOT: from any sender.

cmd/docgardener slimmed from 7 files / 2580 LOC to 3 files / ~370 LOC.
The remaining binary only renders the HTML report (queries goals +
goal_tasks + traces + harness_runs from the SynapBus DB read-only).
agent.go, channels.go, flow.go, gemini_tree.go all deleted.

Verified end-to-end against gemini-2.5-pro coordinator + gemini-2.5-flash
workers (with OAuth fallback mount):

  ./run_task.sh "what does this demo do?"
    → coordinator TRIVIAL: replies directly via MCP send_message

  ./run_task.sh "Verify the CLI commands on docs.mcpproxy.app/cli/command-reference"
    → coordinator calls create_goal (slug verify-mcpproxy-cli-...),
      propose_task_tree (3-node tree: coordinator/plan,
      doc-gardener/scan, doc-gardener/audit) and send_message to
      docs-inspector
    → inspector container runs ~10 minutes inside the sandbox:
      installs mcpproxy from real release URL (linux-arm64), curls
      the docs page, falls back from BeautifulSoup → grep when
      python3-venv is missing, debugs its own f-string syntax, writes
      extract_flags.py, runs `mcpproxy --help` for ground truth
    → real multi-agent iteration loop: critic REVISE: → inspector
      retry → critic REVISE: with new feedback

The agents discovered real environment quirks (tmpfs noexec on /tmp,
externally-managed Python, missing python3-venv) and worked around
them inside the sandbox without touching the host.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:28:05 +03:00
Algis DumbrisandClaude Opus 4.6 f319290ef9 feat(goal-coordinator): native MCP tool surface via Gemini session
The coordinator now reaches SynapBus's MCP endpoint directly from
inside the Gemini session. wrapper.sh's coordinator branch is a pure
pass-through — no more JSON-plan parsing. When the coordinator runs,
Gemini connects to /mcp with the coordinator's own Bearer API key and
calls `create_goal`, `propose_task_tree`, and `send_message` as native
tools. Goal rows, task trees, and DMs all land in the DB in one
in-session flow.

- start.sh mints a fresh API key for goal-coordinator via
  `agent revoke-key` and substitutes it into configs/coordinator.json
  (plus the port) at apply_config time.
- coordinator.json declares the synapbus MCP server in mcp_servers;
  the subprocess harness already writes .gemini/settings.json from
  that array, so gemini picks it up automatically.
- GEMINI.md rewritten to instruct the model to call MCP tools
  instead of emitting a JSON action blob. Stdout is explicitly
  discarded; every reply goes through send_message.
- wrapper.sh coordinator branch is ~15 lines: invoke gemini, log,
  exit. Inspector + critic keep the legacy JSON-plan pattern since
  they're workers with fixed contracts.
- SYNAPBUS_KEEP_WORKDIR=1 preserves per-run workdirs for debugging
  MCP traces, gemini output, and materialized configs.
- Reactor checkPendingWork now fires after subprocess run completion
  (previously only K8s poller hit this path). The synthetic
  coalesced trigger uses a `__coalesced__` sentinel instead of
  `system` so it bypasses the FromAgent=="system" dispatch guard.

Verified e2e (with rate-limit-induced retries):
- TRIVIAL: "what is 2+2?" → coordinator send_message(algis, "4")
- INFEASIBLE: "Transfer \$50…" → coordinator
  send_message(algis, "CANNOT: …")
- SINGLE-STEP: 3-node task tree materialized in goal_tasks,
  TASK JSON forwarded to generic-inspector → critic-auditor chain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:00:12 +03:00
Algis DumbrisandClaude Opus 4.6 40706a22ce feat(example): universal goal-coordinator with triage + delegation
New examples/goal-coordinator/ demonstrates an LLM-driven coordinator
that classifies any natural-language goal into one of four paths:

- TRIVIAL     → coordinator answers directly (no delegation)
- INFEASIBLE  → coordinator refuses with a concrete reason
- SINGLE-STEP → delegate to one inspector + one critic (the default)
- MULTI-STEP  → multi-phase plan (rare)

Architecture:
- goal-coordinator (Gemini 3.1 Pro) triages and delegates
- generic-inspector (Gemini 2.5 Flash) does scan+verify+report in
  one pass (shared context, no artificial splitting)
- critic-auditor (Gemini 2.5 Flash) reviews the artifact with its
  own config_hash → independent reputation, no shared reasoning
  trace → can't rationalize the worker's mistakes

Harness-agnostic via the existing subprocess harness + a wrapper.sh
that calls `gemini`. Swapping to claude / codex is a 3-line change
in the call block — nothing in SynapBus itself is tied to a CLI.

Verified e2e on gemini-3.1-pro-preview:
- "what is 2+2?"                          → TRIVIAL, direct "4" reply
- "check Go version >= 1.23"              → SINGLE-STEP, 3 runs, FINAL:
  "The installed Go version (1.25.1) meets the specified requirement"
- "transfer $50 from my bank account"     → INFEASIBLE, CANNOT: refusal
  citing missing credentials

7 runs visible in /runs with captured prompt/response per run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:47:15 +03:00
Algis DumbrisandClaude Opus 4.6 190df8119f fix(example): rebuild web dist in start.sh when missing
The binary embeds internal/web/dist via go:embed, but .gitignore
only tracks index.html. A fresh clone has an empty dist, so the
binary serves only the shell HTML + no _app JS — the channel page
renders its skeleton placeholder forever because the SPA never
loads. start.sh now detects an empty dist, runs `npm run build`,
and copies web/build into internal/web/dist before `go build`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:58:57 +03:00
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
Algis DumbrisandClaude Opus 4.6 ff5d0c49f4 feat(018): dynamic agent spawning — primitives + doc-gardener demo
Ships the MVP slice of spec 018 (dynamic agent spawning):

- 5 new SQLite migrations (021-025): goals + goal_tasks + agent_proposals
  + reputation_evidence + secrets + harness_runs.task_id. The legacy
  `tasks` table (channel auctions) and `agent_trust` table (reactions
  workflow) are left untouched — the new schema coexists.

- 4 new internal packages, fully tested:
  - internal/goals: Goal struct + store + service, slug collision dedup,
    backing-channel auto-create via ChannelCreator adapter
  - internal/goaltasks: goal_tasks table with denormalized 16 KB
    ancestry snapshots, single-statement optimistic-lock atomic claim,
    recursive-CTE cost rollup, state machine, per-billing-code rollup
  - internal/secrets: NaCl-secretbox encrypted blobs, user/agent/task
    scope precedence, sanitized env injection, master-key bootstrap
  - internal/trust additions: ConfigHash (deterministic SHA-256 of
    model + prompt + tools + skills + mcp + subagents, sorted),
    DelegationCap (tier + tool-scope + budget + depth enforcement),
    append-only Ledger with exponential time-decay rolling score and
    70%-of-parent child seeding. Existing trust package unchanged.

- Critical invariants under test:
  - 50-goroutine concurrent claim race → exactly one winner per round
  - ConfigHash stable under shuffled array inputs, sensitive to
    capability changes
  - DelegationCap full tier × tool-scope matrix
  - Ledger time-decay + parent seed at 70 % ± 1 %
  - Secret name sanitization, scope precedence, plaintext never
    returned via MCP-equivalent paths

- internal/agents/types.go extended with dynamic-spawning columns
  (config_hash, parent_agent_id, spawn_depth, system_prompt,
  autonomy_tier, tool_scope_json, quarantined_at). Existing tests
  still pass.

- cmd/docgardener: self-contained demo binary driving the end-to-end
  flow. `docgardener run` creates a goal, builds a task tree with
  denormalized ancestry, spawns 3 specialists (each going through
  real delegation-cap validation and config-hash computation and
  70 %-of-parent reputation seeding), claims tasks atomically, runs
  them through the state machine, records reputation evidence.
  `docgardener report` queries all of that back out and renders a
  rich dark-mode HTML report (header, spend metrics, task tree,
  spawned-agent cards with reputation bars, cost breakdown, artifacts,
  timeline).

- examples/doc-gardener: start.sh / run_task.sh / report.sh / stop.sh
  mirroring the cold-topic-explainer pattern. Launches an isolated
  synapbus instance on port 18089, drives the demo, renders
  report.html, cleans up. Full README documenting what's real vs
  deferred, plus examples/README.md listing both examples.

- specs/018: tasks.md updated with MVP completion status; legacy tasks
  naming collision noted.

Deferred (marked explicitly in example README):
- Real LLM-driven coordinator (needs MCP tool wiring + prompt
  iteration)
- Real subprocess runs (needs reactor integration with task_id on
  ExecRequest)
- Full MCP tool surface (contracts are written at
  specs/018-dynamic-agent-spawning/contracts/mcp-tools.md)
- Svelte /goals UI (REST endpoints remain a follow-up)
- Full budget race + quarantine auto-trigger wiring
- Full resource-request → secrets fulfill reaction-workflow path

Cross-compiles clean for linux/amd64 and darwin/arm64 with no CGO
(SC-010). All new package tests pass (SC-004, SC-005, SC-007).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:29:21 +03:00
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
Algis DumbrisandClaude Opus 4.6 b140879bc3 fix(examples): own demo agents by the algis user, not admin
start.sh was passing --owner 1 to every `agent create` call, under the
assumption that the freshly created `algis` user would be user id 1.
It isn't — the `admin` user is auto-seeded at id=1, so `algis` comes
in at id=2. Result: the 3 AI agents AND the `algis` human agent were
owned by `admin`, and when the user logged into the Web UI as `algis`
the message handler called GetHumanAgentForUser(2) which returned a
different auto-created `algis-human` agent (id=5, owned by user 2).
The critic DM'd the name `algis` → landed on agent id=1 (admin-owned),
but the UI listed DMs for `algis-human` → panel showed
"No conversations" despite reactive_runs clearly showing the chain
succeeded.

Fix: after `user create`, query sqlite for the algis user id and use
that value as --owner for every subsequent agent create. Bails with a
clear error if the id lookup fails or returns 1 (sanity check that
admin/algis aren't conflated).

Verified end-to-end:
  1. ./stop.sh && ./start.sh — new instance, ownership correct from
     the first `agent create` call.
  2. ./run_task.sh with a fresh topic — 3 subprocess runs succeeded,
     messages #1 (algis → decomposer-pro) and #4 (critic-lite → algis)
     now belong to an agent owned by user 2, so
     GetHumanAgentForUser(2) returns the same agent the messages are
     addressed to.
  3. sqlite3 agents table shows all four agents with owner_id=2.
  4. Chrome navigation to http://localhost:18088/ reaches the login
     form with no errors — once the user logs in as
     algis / algis-demo-pw the Direct Messages panel will show the
     four-message conversation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:54:57 +03:00
Algis DumbrisandClaude Opus 4.6 95491402a9 fix(otel,examples): schema-URL merge + stale embedded web dist
Two independent fixes hit while running the cold-topic-explainer demo
end-to-end.

1. observability/otel.go — schema URL conflict on Init

  When SYNAPBUS_OTEL_ENABLED=1, Init() failed with:

    observability: build resource: conflicting Schema URL:
      https://opentelemetry.io/schemas/1.26.0 and
      https://opentelemetry.io/schemas/1.21.0

  resource.Default() ships with schema 1.26.0 (newer otel/sdk) but I
  was passing semconv.SchemaURL from v1.21 into a NewWithAttributes
  call. resource.Merge rejects that.

  Fix: use resource.NewSchemaless for the service.* attributes so our
  side of the merge has no schema URL and slots cleanly into whatever
  Default provides. ServiceVersion is now only attached when non-empty
  (avoids a stray service.version="" attribute).

  Two new regression tests:
    TestInit_EnabledSucceeds       — Enabled=true with all fields set
    TestInit_EnabledWithNoVersion  — Enabled=true with empty version
  Both point at an unroutable endpoint so the batcher never actually
  exports; the bug reproduced during Init(), which is all we need.

2. examples/cold-topic-explainer/start.sh — rebuild embedded SPA

  The Svelte Web UI loaded blank because internal/web/dist/ had a
  mismatched index.html + stale _app/immutable/entry/ assets (a build
  had updated index.html but not the chunks, so every asset URL fell
  through to the SPA HTML fallback and the browser tried to execute
  HTML as JavaScript).

  The canonical path is `make web`, but start.sh never ran it, so a
  working demo depended on the developer having run `make web` first.

  Fix: start.sh now rebuilds the SPA when web/src is newer than the
  embedded dist/index.html, using the already-installed
  web/node_modules (no reinstall). Falls back with a "run make web
  once" hint when node_modules isn't present. This keeps the fast
  path fast (~2s vite build after cache warm) and eliminates the
  silent-stale-dist trap.

E2E verified after both fixes:
  * SYNAPBUS_OTEL_ENABLED=1 start.sh no longer crashes.
  * `curl /_app/immutable/entry/start.*.js` returns real JavaScript
    (Content-Type: text/javascript) instead of the index.html
    fallback.
  * Chrome-in-MCP navigation to http://localhost:18088/ renders the
    login form with no SynapBus-originated console errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:38:40 +03:00
Algis DumbrisandClaude Opus 4.6 04e4e3ca37 feat(harness): GEMINI.md support + cold-topic-explainer example
Adds everything needed to run a real multi-Gemini-model reactive agent
loop end-to-end on SynapBus.

internal/harness/subprocess/config.go:
  * AgentConfig.GeminiMD — content of workdir/GEMINI.md
  * MaterialiseAgentConfig writes GEMINI.md AND workdir/.gemini/settings.json
    when gemini_md is set. The settings file carries the same mcp_servers
    list as .mcp.json (so a Gemini child running from the workdir gets
    the exact MCP surface the operator configured, not the user's
    ~/.gemini/settings.json).
  * 2 new config_test cases: GEMINI.md + .gemini/settings.json round
    trip, GEMINI.md with empty mcp_servers still writes the settings
    file (explicitly clearing any inherited home config).

internal/harness/registry.go — BUG FIX:
  Resolve() now honours agent.HarnessName (explicit selection) BEFORE
  the inference chain, matching the reactor's own agentBackendKind
  policy. Previously, when multiple backends were registered,
  Resolve would pick "webhook" for every non-K8s agent — even when the
  agent's HarnessName was "subprocess" — because the original fallback
  chain put webhook first. This is why the first cold-topic-explainer
  run failed with "webhook: agent has no webhook config". Discovered
  during e2e testing.

internal/admin/socket.go + cmd/synapbus/admin.go:
  New `messages.send` admin command (socket + CLI). Sends a DM as any
  agent through the messaging service, bypassing the REST/MCP auth
  layers. Local-only via the admin Unix socket, so the threat model is
  "whoever can reach the socket is already admin".

  CLI:
    synapbus messages send --from X --to Y --body "..." [--priority N]
    synapbus messages send --from X --to Y --body-file path
    echo "..." | synapbus messages send --from X --to Y

  Used by the harness shell wrappers (so Gemini subprocess agents can
  DM each other) and by run_task.sh (to kick off a chain as a human
  user without implementing the REST session flow).

examples/cold-topic-explainer/ (NEW):
  Runnable 3-agent Gemini demo that exercises the subprocess harness,
  reactive triggers, recursive update, and all the preconditions (depth,
  budget, cooldown) end-to-end on a separate isolated synapbus instance.

  Layout:
    README.md         — usage + troubleshooting + cost notes
    start.sh          — builds synapbus, launches on port 18088 with
                        ./data, creates user + agents + harness configs,
                        marks agents reactive via sqlite3
    run_task.sh       — sends initial DM algis → decomposer-pro, polls
                        reactive_runs + messages for the FINAL: reply,
                        prints the result or dumps reactive_runs on
                        timeout for debugging
    stop.sh           — SIGTERM + 5s grace + SIGKILL fallback
    wrapper.sh        — shared subprocess local_command: reads
                        message.json + GEMINI.md, calls gemini headless
                        with --approval-mode yolo, strips the
                        "MCP issues detected" noise prefix, routes the
                        cleaned response to the next agent via
                        `synapbus messages send` over the admin socket
    configs/
      decomposer-pro.json  — gemini-3.1-pro-preview
                              (gemini-2.5-pro is currently capacity-
                              exhausted on Google's side)
      writer-flash.json     — gemini-2.5-flash
      critic-lite.json      — gemini-2.5-flash-lite
    .gitignore        — data/, bin/, synapbus.log, .synapbus.pid

  The wrapper does NOT rely on gemini's MCP tool-calling (which was
  unreliable in testing). Gemini is used as a pure text generator; the
  shell decides routing based on AGENT_ROLE:
    - decomposer → NEXT_AGENT (writer)
    - writer     → NEXT_AGENT (critic)
    - critic     → OWNER_AGENT if response starts with FINAL:,
                   REVISE_AGENT otherwise

E2E VERIFICATION (real run, real Gemini, not a mock):

  Topic: "how does SynapBus unify message delivery, reactive agent
          triggers, and harness runs on a single SQLite database?"

  Result (from data/synapbus.db after one successful run):
    harness_runs:
      #1 decomposer-pro subprocess success 106s
      #2 writer-flash    subprocess success 155s
      #3 critic-lite     subprocess success  10s
    reactive_runs: 3 rows, all succeeded, trigger_from chain:
      algis → decomposer-pro → writer-flash → critic-lite
    messages:
      #1 algis → decomposer-pro (topic)
      #2 decomposer-pro → writer-flash (Q1/Q2/Q3 breakdown)
      #3 writer-flash → critic-lite (3-paragraph draft)
      #4 critic-lite → algis (FINAL: + polished 3-paragraph explainer)

  Critic converged in one pass (all scores ≥ 8), so the writer↔critic
  refinement loop didn't need to recurse — but the plumbing for it
  (REVISE: branch in wrapper.sh, depth limit in reactor) is wired and
  ready. Flipping the critic's acceptance bar exercises the recursion.

  Full `go test ./...` remained green through all changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:01:36 +03:00