Feature work happens in D:/OPC/synapbus-wt/issue-<N>; after the branch
is merged into opc/main (and the running binary replaced if needed) the
implementing agent removes the worktree with git worktree remove, after
restoring the skip-worktree go.mod/go.sum patch. Never --force, never
delete branches, never remove unmerged or dirty worktrees.
OPC local patch (opc/main only).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replay dedup now uses a fixed replayedUpTo that live events never advance.
Add ListEventMetaAfter store tests, document current-membership replay,
and skip member/subject lookups when no agent stream is connected.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Document the Gitea fork workflow (origin/upstream, main vs opc/main),
localhost-only runtime, admin socket path, skip-worktree on the built
index.html, and that data/ and API keys must never be committed.
OPC local patch (opc/main only).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
github.com/TFMV/hnsw uses renameio.TempFile, which renameio v1 does not
provide on Windows, so the pristine tree fails to build there with
"undefined: renameio.TempFile". Pin v0.1.0 via a replace directive.
OPC local patch (opc/main only); not intended for upstream as-is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two silent failures making the watchdog's circuit-breaker checks
toothless:
1) The Alpine runtime image had no sqlite3 binary, so every
`kubectl exec -- sqlite3 …` call inside the watchdog returned empty.
$JS/$TIN/$JOK/$JFL/$JCB all defaulted to 0 → every "soft cap" /
"tokens > 30M" / "circuit broke but still firing" check trivially
passed regardless of real state. apk add sqlite (≈700KB).
2) The "today usage" query filtered to owner_id='2' only, but dream
jobs run for any owner (we just saw a clean owner_id=1 dispatch).
Replace with SUM across all rows for date=date('now'); the caps
are intentionally global, not per-owner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The four /api/analytics/* endpoints (timeline, summary, top-agents,
top-channels) all ran their SELECT queries on the write pool
(MaxOpenConns=1, serialized) and would time out at 125s with
context-canceled whenever a long writer (e.g. dream dispatch) held the
single connection. Summary swallows the error and returns {0,0,0}, so
the dashboard rendered an empty-cluster lie.
Plumb ReadDB through RouterConfig from main, fall back to DB if the
read pool is unset, and pass it to NewAnalyticsHandler. Same shape as
the /readyz fix in b03350f.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Repeated production crash: pod ran 27min–3.5h then went ready=false,
restarts=0 (process alive but readiness probe failing). Watchdog
correctly scaled deploy to 0 each time.
Root cause: /readyz calls db.PingContext() on the write pool, which
has MaxOpenConns=1 (serialized writes). The consolidator's dream-job
dispatch (introduced in 020) holds that single connection for 30s+
during one tick: it creates a K8s Job, writes the job row, issues a
dispatch token, all sequentially. /readyz blocks waiting for the
connection through the entire dispatch. With probe period=5s,
failureThreshold=3, the pod flips to NotReady after ~15s — long
before the dispatch finishes.
The new diagnostic: rebuilt v0.17.0 (pre-020) on kubic — runs 5h+
clean, memory flat at 134Mi. v0.21.2 (with 020) dies within hours.
The dispatch path is the only ~30s write holding the conn.
Fix: pass db.QueryDB() to health.NewChecker. QueryDB returns the
read pool (MaxOpenConns=8) when available, write pool when not, so
the readiness probe can run concurrently with any writer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Production v0.18.1 logs showed read_message → "did you mean: send_message"
— two edits away by Levenshtein, but the opposite intent. An agent asking
to READ a single message getting nudged toward SEND is actively
misleading. Same trap was active for anything sharing a verb-less suffix
like _message, _channel, _task.
Constrain the Levenshtein candidates to those whose leading verb (token
before the first underscore) matches the input verb exactly. Substring
matching is unchanged. Drop the now-stale sned_message typo test case
(cross-verb typo correction is no longer in scope) and add a regression
test covering read_message, delete_message, fetch_channel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After v0.18.0 deploy two residual issues remained on kubic.
ExpireTasks: 6 "context deadline exceeded" errors in 2.5h despite the
v0.18.0 partial index. Root cause is connection-pool contention, not
SQL speed — the tasks table is empty (steady state) and the query
plan correctly uses idx_tasks_expiry, but the worker still queues
behind the serialized write connection (MaxOpenConns=1) when another
writer holds it for >30s. Fix: add an EXISTS pre-check on the read
pool. If nothing matches, return (0, nil) without touching the write
pool. Wired via SQLiteTaskStore.WithReadDB to avoid changing the
constructor signature and disrupting tests.
Bridge: bridgeTopLevelOnly only hinted on the misspelled
rewrite_core_memory. Agents have since learned and call the real name
memory_rewrite_core via call(), which fell through to plain "unknown
action: memory_rewrite_core". Add the real name to the hint map so
both spellings get the targeted "this is a top-level MCP tool"
message.
Tests:
- ExpireTasks_EmptyShortCircuits: 0-row table returns (0, nil)
- ExpireTasks_UsesReadPoolForPreCheck: pre-check runs on read pool,
UPDATE still runs on write pool when work is present
- TopLevelToolHint: extended to cover memory_rewrite_core via bridge
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Agents call() into the bridge with action names they guess from prior MCP
conventions or their training data — e.g. `read_channel`, `search`,
`my_status`, `read_dm`, `read_article`. Each produced a useless
"unknown action: X" WARN and no progress.
This change:
- Adds a small alias map (bridgeActionAliases) for observed wrong names
that have a single unambiguous bridge equivalent:
read_channel → get_channel_messages
search → search_messages
read_dm → read_inbox
my_status → read_inbox
read_article → get_article
- Adds bridgeTopLevelOnly for wrong names whose real implementation lives
as a top-level MCP tool, not a bridge action (rewrite_core_memory →
memory_rewrite_core); the error now tells the agent to invoke the
top-level tool instead of failing silently.
- For everything else, the default error includes a "did you mean"
suggestion computed via substring match + Levenshtein (threshold 2-3)
against the known bridge actions. Pure Go, no deps.
Tests cover each alias, the top-level-tool hint, "did you mean"
suggestions for close typos, no suggestion for distant strings, and the
Levenshtein helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause
----------
ExpireTasks in internal/channels/task_store.go ran a single unbounded
UPDATE filtered on (status='open' AND deadline IS NOT NULL AND deadline < now).
The only indexes on tasks were idx_tasks_status(status) and
idx_tasks_channel(channel_id). With status cardinality of ~4 and a growing
auction-tasks table on kubic, the planner used idx_tasks_status to enumerate
all open rows then evaluated deadline per row, holding a SQLite write
transaction the whole time. Under WAL contention with concurrent writers
(message inserts, consolidator) the worker's 30s context regularly expired,
producing the recurring expiry-worker log line.
Fix
---
1. New migration 031_tasks_expiry_index.sql: partial composite index
idx_tasks_expiry(status, deadline) WHERE status='open' AND deadline IS NOT NULL.
This is the exact predicate ExpireTasks uses, so the planner now seeks
straight to eligible rows. The partial form keeps the index empty for the
steady-state majority of rows (completed/cancelled), so writes elsewhere
aren't penalized.
2. Batch the UPDATE in chunks of 500 (rowid IN subquery; UPDATE ... LIMIT
isn't compiled into modernc.org/sqlite by default). Bounded write
transactions stop the worker from starving other writers and let it
observe context cancellation between batches.
Perf
----
New test exercises 2400 mixed rows (1200 expirable). With the index +
batching, expiry finishes in ~3ms inside a 5s context; without the index a
regression to full status-scan would be measurably worse and is also
guarded by an EXPLAIN QUERY PLAN test.
Operational notes
-----------------
- Migration is additive and idempotent (CREATE INDEX IF NOT EXISTS). No
backfill needed; it will apply on next pod startup.
- After rollout, expiry-worker error logs should clear within one tick
(default 1m).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause: ConsolidatorWorker.tryDispatch / launchOne incremented
the per-(owner, day) `jobs_started` counter immediately after
JobsStore.Create, BEFORE the dispatch flip succeeded. When the
subsequent steps fail (token issue, agent lookup, dispatch flip
race) the job row is Completed as `failed` but `jobs_started`
remains incremented — and the error paths never call
RecordCompletion, so `jobs_failed` stays flat while `jobs_started`
drifts upward.
Over hours/days, owners whose dispatches fail consistently (e.g.
owner_id=2 in the kubic deployment, hitting one of the harness
failure modes from commit bfb2551) accumulate phantom
`jobs_started` until the default DreamDailyJobLimit=100 trips. From
that point every tick logs `circuit broken … reason=jobs_exceeded`
for all four job types, even though no real jobs ran — and the
counter never decays until midnight UTC.
Fix: move `usage.RecordStart(...)` to AFTER a successful
`jobs.Dispatch(...)` in both tryDispatch (consolidator.go:518)
and launchOne (consolidator.go:455). Now only dispatches that
actually transitioned a row to `dispatched` count against the
daily-job-limit gate.
Test: TestConsolidator_PreDispatchFailureDoesNotBurnJobsStarted
seeds DreamDailyJobLimit=2, makes the agent lookup fail, calls
ForceRun three times, asserts jobs_started stays 0 and the gate
still allows. Verified to fail without the fix
(jobs_started=2 / reason=jobs_exceeded) and pass with it.
Counterpart TestConsolidator_DispatchSuccessIncrementsJobsStarted
asserts jobs_started=1 on a real successful dispatch so the
counter still feeds the gate correctly.
Operational note: this prevents future inflation. Existing stuck
rows for owner_id=2 in today's `memory_dream_usage` bucket need a
one-shot SQL fix —
UPDATE memory_dream_usage
SET jobs_started = jobs_succeeded + jobs_failed
WHERE date = date('now')
AND owner_id = '2';
or simply wait for the next UTC-midnight reset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundles 10 commits implementing owner-scoped memory + a background
dream worker that dispatches consolidation jobs to a Claude Code agent
through the existing harness seam.
Highlights:
- US1: Proactive injection on MCP tool responses (relevant_context
block, owner-scoped retrieval via existing search.Service hybrid).
- US2: Per-(owner, agent) core memory blob, always included on
session-start tools.
- US3: ConsolidatorWorker + 6 memory MCP tools + 1 SQL view, with
dispatch tokens and an audit log. Worker dispatches via
harness.Harness.Execute → k8sjob backend, NOT via system DMs (per
saved feedback about cascading stalemate retries).
- Configurable parallelism (SYNAPBUS_DREAM_PARALLEL, default 1) +
--parallel N CLI flag for backlog drains.
- Daily-token / daily-job UsageGate circuit breaker.
- 14d recency window (configurable; set to 99999d on kubic to process
all history).
- Grafana dashboard (deploy/kubic/grafana/dream-dashboard.json) and
hourly watchdog CronJob (deploy/kubic/watchdog/) that auto-stops
synapbus on runaway-token-drain signal.
Operational evidence from the kubic drain:
- Backlog: 1411 unprocessed → 0, in 50 minutes via 8-parallel waves.
- 260 reflection memories written (ids 32111–32393).
- 5,577 typed links added (5,331 refines + 246 other).
- 4 agent core-memory blobs distilled by the dream agent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Token-usage wiring (closes the 0-tokens gap in memory_dream_usage):
- internal/harness/k8sjob/k8sjob.go: after extractResultJSON, parse
tokens_in/tokens_out/tokens_cached/cost_usd from the final JSON
envelope and stash into ExecResult.Usage so the dream worker's
UsageGate circuit breaker actually counts consumption.
- dream-agent/dream_runner.py: Max20 OAuth sessions don't surface
per-call tokens through the SDK's ResultMessage.usage. Falls back
to a turn-based estimate so the gate has SOME signal:
tokens_in_est = turns * 5000 + tool_calls * 2000
tokens_out_est = turns * 300
Calibrated against observed reflection runs.
Watchdog (deploy/kubic/watchdog/):
- watchdog.yaml: in-cluster CronJob runs every hour at :05 past UTC,
with a dedicated ServiceAccount + Role granting (get/list/exec on
pods, patch+update on deployments/scale) inside the synapbus
namespace only.
- Health checks: pod readiness + restart count; last-1h job
succ/fail/in_flight counts; today's jobs_started + tokens_in +
circuit_broken.
- Red flags that auto-stop synapbus (scale to 0):
* pod restart count > 3
* failed dream jobs in last 1h > 20
* jobs_started today > 200 OR tokens_in > 30M
* circuit broke AND still firing (started >> completed)
- Dockerfile: slim alpine + kubectl v1.30.5 binary (synapbus-watchdog:v1).
Built locally and imported into kubic's containerd because the
public docker.io/bitnami/kubectl manifest was returning text/html
from kubic's network egress.
Replaces the schedule-skill remote-agent approach because Anthropic
cloud agents can't reach kubic.home.arpa (LAN-only) and can't call
kubectl scale. The k8s CronJob is the right primitive for an
in-cluster safety watchdog.
Live evidence: first manual run on kubic reported
pod=synapbus-... ready=true restarts=0
last_1h jobs total=18 succ=18 fail=0 in_flight=0
today: jobs_started=189 tokens_in=0 succeeded=169 failed=15
HEALTHY — no action
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drain-on-demand: SYNAPBUS_DREAM_PARALLEL (default 1) and
`synapbus memory dream-run --parallel N` fan out N concurrent
dream-agent k8s Jobs per (owner, job_type) in one shot. Set
high (e.g. 8) to drain backlog quickly, then back to 1 for normal
hourly operation.
Schema:
- migration 030_dream_parallelism: adds slot INTEGER NOT NULL DEFAULT 0
to memory_consolidation_jobs. Drops + recreates the partial unique
in-flight index as (owner, job_type, slot) so slots 0..N-1 each hold
one in-flight job independently.
Stores:
- JobsStore.CreateOnSlot + CreateNextAvailableSlot.
- ConsolidatorWorker.ForceRunN dispatches N parallel jobs through the
existing launchOne path (extracted from ForceRun).
- core_rewrite coerces to N=1 regardless of the knob — per-(owner,
agent) blob is wholesale-replace and concurrent rewrites would race.
Three bug fixes discovered while bringing the parallel path up on
kubic:
1. k8s Job names collided on rapid relaunch because runner.go used
"synapbus-<agent>-<msg_id>", and dream dispatches have msg_id=0.
Now appends a unique (timestamp%1e6, 4-byte random) suffix when
msg_id is zero; historical "synapbus-<agent>-<id>" prefix preserved.
2. memory_list_unprocessed didn't actually exclude already-refined
messages — the contract said it should, the implementation
returned the same oldest-50 every cycle. The dream agent kept
re-refining the same set: 221 refines links touched only 55
unique dst messages, so progress flat-lined. Added the
NOT IN (refines/duplicate_of/superseded_by) filter and a
from_agent NOT LIKE 'dream:%' clause so the agent never refines
its own reflections.
3. The k8sjob harness was constructed with nil Waiter in main.go,
so every dream dispatch failed instantly with "k8sjob: no Waiter
configured". Now builds a ClientsetWaiter from the in-cluster
clientset.
Plus admin/server.go gets DreamRunN closure + DefaultDreamParallel
(sourced from MemoryConfig.DreamParallel). admin/socket.go
handleMemoryDreamRun accepts `parallel` arg and returns job_ids[].
CLI admin command grows --parallel N flag.
Live evidence from kubic (image v0.21.0-amd64):
1 CLI call with --parallel 8 produced 8 job rows on slots 0..7,
spawned 8 distinct k8s Jobs with unique suffixes, retired ~86
unprocessed messages in <1 min (vs ~10/cycle for the buggy
serial version pre-fix-2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two short fixes discovered while bringing the dream-claude agent live
on kubic against a Max20 subscription:
1. internal/mcp/server.go: HTTPContextFunc now reads
X-Synapbus-Dispatch-Token from request headers and stuffs it into
ctx via WithDispatchToken. The memory_* tools already expected it
in context; the bridge was missing on the HTTP boundary. Without
this, every memory_* call returned dispatch_token_missing — which
is the failure the live dream-agent hit on first run.
2. Followed searcher's proven pattern for Max20 OAuth: the k8sjob
harness already auto-mounts /home/user/.claude → /app/.claude;
the agent record just needs CLAUDE_CONFIG_DIR=/app/.claude in
k8s_env_json. Documented for future agents in the k8s-job
template, no code change needed here.
Live evidence (kubic, image v0.20.7-amd64):
Job 2015 reflection: status=succeeded, 3 actions, 2 new reflection
memories (ids 32111, 32112) on reflections-mcpproxy, each
synthesizing 14 source memories. End-to-end functional.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend (Go, in this commit):
- migration 029_memory_dream_usage: per (date, owner) counters for
tokens_in/out, jobs_started/succeeded/failed/circuit_broken
- DreamUsageStore + UsageGate (internal/messaging/dream_usage.go).
Gate inspects today's usage against new env knobs:
- SYNAPBUS_DREAM_RECENT_WINDOW (default 336h / 14d)
- SYNAPBUS_DREAM_DAILY_TOKEN_LIMIT_IN (default 1M)
- SYNAPBUS_DREAM_DAILY_TOKEN_LIMIT_OUT (default 200k)
- SYNAPBUS_DREAM_DAILY_JOB_LIMIT (default 100)
- Consolidator now bounds watermarks + core_rewrite eligibility by the
recency window. core_rewrite skipped for owners with no in-window
activity. ForceRun honors the breaker.
- Recency fallback in BuildContextPacket + memory_list_unprocessed now
accept RecentWindowDays so injection and dream queries see the same
14d slice.
- Prometheus metrics registered (internal/metrics/metrics.go):
synapbus_dream_jobs_total{owner,job_type,status},
synapbus_dream_tokens_total{owner,direction},
synapbus_dream_job_duration_seconds{owner,job_type},
synapbus_dream_circuit_broken_total{owner,reason},
synapbus_injection_packets_total{tool},
synapbus_injection_memories_per_packet{tool},
synapbus_injection_packet_chars{tool},
synapbus_injection_skipped_total{tool,reason}.
- deploy/kubic/deployment.yaml: liveness/readiness timeoutSeconds: 1→5
(root-causes the "connection refused" mcpproxy errors at 13:02 today —
/readyz occasionally exceeded 1s under dream-worker tick load, so the
pod fell out of the Service endpoints intermittently).
Dream-claude agent (Python, in /dream-agent/):
- dream_runner.py uses claude-agent-sdk 0.1.48 to drive Claude Code
against SynapBus's MCP server. MCP transport carries
Authorization: Bearer <api_key> AND X-Synapbus-Dispatch-Token from env
via the SDK's McpHttpServerConfig.headers field — confirmed supported.
- Tools restricted via allowed_tools to mcp__synapbus__memory_*.
- Final JSON envelope reports tokens_in/out so harness.Usage stays
populated and the circuit breaker can count consumption.
- Dockerfile builds linux/amd64 at 189 MB, mirroring searcher's
agents/universal recipe.
- k8s-job-template.yaml: backoffLimit 0, ttl 600s, 512Mi/1CPU,
Anthropic credentials via secret-ref.
Grafana dashboard (deploy/kubic/grafana/):
- dream-dashboard.json — 14 panels across 5 rows (dream activity,
token usage vs limit, circuit breaker, injection layer, MCP
transport health), all templated to ${DS_PROMETHEUS}.
- import.sh: resolves the cluster's Prometheus DS uid and POSTs the
dashboard via Grafana API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs discovered while verifying on kubic:
1. HTTP→MCP context propagation lost the full *agents.Agent struct
(only the bare name was copied via ContextWithAgentName). The
injection wrapper called agents.AgentFromContext and got nothing,
silently skipping the packet. Now propagate both: full struct for
middleware that needs the owner_id, name kept for backward-compat.
2. BuildContextPacket gated retrieval on `query != ""`, so my_status —
the highest-value injection target — always returned a nil packet.
FR-009 actually says "use recent owner activity as the implicit
query" in that case. Added recentMemoriesForOwner: a direct SQL
query over memory channels filtered by author owner, sorted by id
DESC. New search_mode "recent" surfaces the fallback path in the
packet so clients can tell it apart from semantic/fulltext.
Live verification on kubic (image v0.20.3-amd64):
- my_status as `claude-code` now returns relevant_context with 2
memories from algis-owned agents, packet sized exactly at the
500-token budget.
- memory_injections audit ring captures each packet (research-mcpproxy
→ search → 1 item; claude-code → my_status → 2 items).
- Cross-owner SC-008 holds: the recency query filters on
agents.owner_id, so an unrelated owner's agent sees nothing.
Dream worker autonomously fired 31 consolidation jobs (4 types × 2
owners × periodic ticks); all failed at the harness step with
"k8sjob: no Waiter configured" — expected, the claude-code agent has
no k8s_image set. Dispatch chain itself works end-to-end (job row →
token → harness.Execute → audit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Background ConsolidatorWorker dispatches consolidation work to a Claude
Code agent through harness.Harness.Execute (NOT via system DMs — per
feedback_system_dm_no_trigger.md) with a one-time 15m dispatch token.
The dispatched agent uses six new MCP tools, all token-gated and
recording every action to memory_consolidation_jobs.actions JSON.
Stores:
- memory_links.go (+ test): typed edges with actor-prefix reserved-type
guard; AddConsolidationLink bypass for memory_mark_duplicate /
memory_supersede (their contractual writers).
- memory_pins.go (+ test): owner pin overlay, bypasses score floor.
- memory_status.go: queries the memory_status view.
- consolidation_jobs.go: Create / Dispatch / Lease / AppendAction /
Complete with ErrJobAlreadyInFlight via partial unique index.
- auto_links.go: MessageListener generating mention / reply_to /
channel_cooccurrence links automatically on send.
Worker:
- consolidator.go (+ test): ticker pattern modeled on StalemateWorker.
Watermark trigger for link_gen / dedup_contradiction; daily 03:00
UTC for sleep-time core rewrite. Wallclock budget via harness Budget.
Global semaphore gates concurrent owners. Mocked-harness test asserts
no system DM is ever sent.
- consolidator_prompts.go: four job-type prompts passed via env to the
dispatched agent.
MCP tools (internal/mcp/memory_tools.go + test):
- memory_list_unprocessed, memory_write_reflection, memory_rewrite_core,
memory_mark_duplicate, memory_supersede, memory_add_link.
- Full error-code matrix tested per contracts/mcp-memory-tools.md.
- Registered only when SYNAPBUS_DREAM_ENABLED=1.
Injection extensions:
- search/injection.go: pin overlay applied after retrieval; status
filter drops soft_deleted / superseded unless pinned. New
PinProvider, StatusProvider, MessageLookup hooks on InjectionOpts.
Wiring:
- cmd/synapbus/main.go: stores constructed, AutoLinkListener attached
to MessagingService, mcpSrv.SetDream wired, ConsolidatorWorker
start/stop, admin DreamRun closure.
- cmd/synapbus/admin.go: synapbus memory dream-run --owner --job
socket-RPC command (forces a single job bypassing trigger).
Cycle workarounds (documented in code):
- messaging.DreamAgent / HarnessDispatcher are local interfaces (the
agents and harness packages import messaging, not the reverse).
main.go wraps the real types via adapter structs.
Stubbed:
- Cron expression parsing (DreamDeepCron). Hardcoded daily 03:00 UTC.
Adding robfig/cron deferred to keep no-new-deps.
Pre-existing reactor test failures (5) are unchanged; confirmed
pre-020 via stash check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps eligible MCP tool handlers (my_status, send_message, search,
execute; get_replies excluded as pure metadata) with a middleware that
appends relevant_context to the JSON response. Retrieval reuses the
existing search.Service hybrid pipeline; owner scoping filters out
memories from other owners' agents (SC-008). Pin overlay is a marked
TODO for US3.
Components:
- internal/search/injection.go (+ test): BuildContextPacket with token
budget greedy fill, score floor, truncation flag, CoreMemoryProvider
interface stubbed for US2.
- internal/mcp/injection_wrap.go (+ test): WrapInjection middleware,
registered via SetInjection on the existing handler.
- internal/mcp/injection_e2e_test.go: adversarial cross-owner test
asserts H1 cannot see H2's memories on any wrapped tool.
- internal/messaging/memory_injections.go (+ test): 24h audit ring,
hourly cleanup tick wired into stalemate worker.
Discovery during impl: claim_messages/read_inbox/read_channel live as
actions inside the execute bridge, not as registered top-level MCP
tools. They inherit injection through the execute wrapper.
This commit also bundles pre-existing working-tree changes for the
027 "remove approval noise" cleanup (migration 027, design doc,
removal of reminder/escalate logic from stalemate worker, related
trims in goals_tools.go and tools_hybrid.go). The two changes touch
the same files (stalemate.go, tools_hybrid.go) and bundling them
keeps history readable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the SQL substrate (6 tables + memory_status view) and the helpers
every user story depends on:
- migration 028_memory_consolidation.sql + smoke test
- internal/messaging/memory_config.go (env-flag plumbing)
- internal/messaging/dispatch_tokens.go (32-byte rand, 15m TTL, single-job-bound)
- internal/messaging/memory_channels.go (open-brain / reflections-* / is_memory flag)
- internal/agents/owner.go (OwnerFor with sentinel errors)
Deviations from spec, all documented in code:
- owner_id is stored as INTEGER FK to users; OwnerFor converts to the
string scope-key the new tables use.
- MemoryChannel is a local struct to avoid an import cycle between
internal/channels and internal/messaging.
- channels.metadata column does not exist yet; IsMemoryChannel honors
it conditionally so MemoryChannelIDs can extend trivially when added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Owner-scoped memory the system pushes onto MCP tool responses, plus a
background "dream" worker that dispatches consolidation jobs to a Claude
Code agent through the existing harness seam. Memory pool reuses the
messages table on memory-flagged channels; six new SQLite tables for
core blob, links, audit, pins, dispatch tokens, and a 24h injection
ring. Zero CGO, no new external deps.
Includes: spec.md (4 user stories), plan.md, research.md (10 decisions),
data-model.md, contracts/ (injection shape + 6 memory tools),
quickstart.md, tasks.md (47 tasks across foundational + 3 stories +
polish, with parallel-subagent cluster plan).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The helm release went into failed state in March 2026 after an out-of-band
kubectl set image broke server-side-apply ownership; every deploy since has
been a direct kubectl set image, leaving the chart values drifting against
live state.
Drop deploy/helm/ entirely. Add deploy/kubic/{namespace,pvc,service,
deployment,secret.example}.yaml mirroring what's actually running, plus
scripts/deploy-kubic.sh encoding the build → docker save → scp → microk8s
ctr image import → kubectl set image flow used for v0.13.x-reactive through
v0.17.0. README documents why no helm and how to back up /data before
schema-touching versions.
kubectl diff -f deploy/kubic/ is empty against the live cluster.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds internal/plugin runtime, plugintest harness, demo plugin, and 103-task
spec under specs/019-plugin-system. ~5k LOC, no overlap with messaging core.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
read_inbox now requires explicit MarkRead (default false). Worker-queue callers
opt in. Resolves bugs-synapbus #30674 where consecutive identical calls returned
0 the second time and produced inconsistent views with the claim/process/done
loop and StalemateWorker.
failTimedOutProcessing UPDATE now re-checks claimed_at < cutoff so a fresh
re-claim between SELECT and UPDATE can't be stomped to failed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
When the reactor sets pending_work while an agent run is in progress,
checkPendingWork fires a synthetic follow-up event with FromAgent=
__coalesced__ and body="Coalesced trigger: process all pending
messages." That event gets delivered to the container as a
message.json with that placeholder body, and the wrapper happily
feeds it to gemini — which then produces a spurious "What does this
demo do?" reply because the only thing the model sees is a generic
filler body.
The doc-gardener coordinator kept emitting stray replies to algis
between real DELEGATED: messages because of this. The reactor would
fail the coalesced run ("Received system trigger..."), the critic
would get confused by intermediate traffic, and the /goals panel
would accumulate garbage.
Wrapper now checks $FROM at the top of main. If it's __coalesced__
we log it and exit 0 without invoking the CLI. The reactor marks
the run succeeded, no tokens burned, no spurious DMs produced. Any
real pending work re-triggers naturally when the next actual
message arrives.
Smoke-verified:
docker run --rm -v /tmp/test:/workspace synapbus-agent:latest \
/usr/local/bin/synapbus-agent-wrapper.sh
[wrapper test] cli=gemini from=__coalesced__ body_bytes=9
[wrapper test] synthetic coalesced trigger — skipping CLI invocation
EXIT=0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
New `internal/harness/docker` package: per-run ephemeral container
backend that runs each agent in `docker run --rm`, bind-mounts the
materialized workdir at /workspace, and captures stdout/stderr/exit
code/result.json the same way the subprocess backend does.
Inspired by scion's pkg/runtime/docker.go: shell out to the docker
CLI (zero new Go deps, zero CGO), per-task ephemeral containers with
no warm pool, host-side scratch dir bind-mounted in.
Default security posture (overridable per agent):
--rm
--cap-drop=ALL
--security-opt=no-new-privileges
--read-only with tmpfs /tmp
--pids-limit=512
--user=<host uid:gid>
--network=bridge (configurable; --network=none for air-gap)
--memory / --cpus from agent config
--add-host host.docker.internal:host-gateway on Linux
The backend reuses subprocess.AgentConfig for gemini_md/claude_md/
mcp_servers/skills materialization so existing example configs work
unchanged. Per-agent docker tunables go under a new `docker` block
in harness_config_json: image, memory, cpus, network, extra_mounts,
cap_add, read_only_root, user, entrypoint, command, extra_args.
MCP host rewrite: `.gemini/settings.json` URLs of the form
http://127.0.0.1:<port>/mcp are rewritten to
http://host.docker.internal:<port>/mcp at materialization time so the
in-container Gemini CLI can reach the SynapBus MCP server on the host
without code changes in the example wrappers.
Wired into the reactor and Registry resolver:
- Registry.Resolve picks "docker" when harness_config_json contains a
`"docker"` block, taking precedence over local_command so explicit
isolation never silently downgrades.
- reactor.agentBackendKind() returns backendDocker for the same case.
- evaluateTrigger's harness-backend gate accepts backendDocker
alongside subprocess + webhook.
- main.go registers docker.Harness with the harness registry, passing
the SynapBus listen port so the URL rewrite uses the correct host
port.
Smoke tests in docker_test.go (skipped when no docker daemon):
- TestExecute_Hello: env injection + bind-mount writeback + message.json
+ result.json + stdout capture using alpine:3.20
- TestExecute_NoImage: rejects agents missing docker.image
- TestExecute_TimeoutCancel: wall-clock budget kills the container
New canonical agent image at image-build/synapbus-agent/:
- Debian bookworm-slim base
- Node 22 + @google/gemini-cli + @anthropic-ai/claude-code
- jq, sqlite3, curl, git, python3, tini (PID 1 for signal forwarding)
- Non-root agent user uid/gid 1000
- ENTRYPOINT tini, CMD /workspace/wrapper.sh
No SynapBus binary inside the image — agents reach the host MCP server
over the network at host.docker.internal:<port>.
Pre-existing reactor test failures (TestReactorNoK8sImage,
TestReactorDepthExceeded, TestReactorBudgetExhausted,
TestReactorCooldownSkipped, TestReactorSequentialExecution) verified
to exist on f319290 unchanged — not introduced by this commit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
Root cause of the Web UI wedge on /conversations/1 (and every other
authenticated page when the reactor is busy): SQLiteSessionStore and
SQLiteUserStore routed every read through the single-connection
write pool. On every authenticated request RequireSession does a
GetSession + GetUserByID — both hit the write pool, so each one
queues behind every reactor / tracer / messaging write. Observed
/api/conversations/1 returning 401 after 113 seconds and login
POST timing out for 15+ seconds.
- SQLiteSessionStore: new NewSQLiteSessionStoreWithRead that takes
separate write + read handles. GetSession routes SELECTs through
readDB; the last_active_at bump and expired-session cleanup now
fire-and-forget on a background goroutine so HTTP handlers never
wait on the write pool for a non-critical liveness poke.
- SQLiteUserStore: same split. GetUserByID / GetUserByEmail /
GetUserByUsername go through readDB.
- main.go: wires db.QueryDB() (the query_only=ON read pool) into
both stores via the new constructors.
Verified: /api/conversations/1 now returns 200 in <2ms even while
the coordinator subprocess is blocking on a long Gemini call.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
- cmd/docgardener: coordinator calls the `gemini` CLI with the goal
brief when SYNAPBUS_GEMINI_MODEL is set, parses the returned JSON
into a goaltasks.TreeNode, and aligns leaf billing codes so the
fixed dispatch table still routes specialists correctly. Falls
back to the hardcoded template on any failure (missing CLI, non-
zero exit, bad JSON) so the demo still works offline.
- internal/mcp: new GoalsToolRegistrar exposing 6 spec-018 tools —
create_goal, propose_task_tree, propose_agent, claim_task,
request_resource, list_resources. All require an authenticated
agent context; wire-only changes on the MCP server side.
- main.go: builds + attaches the new registrar after the hybrid
tool registrar, logs the 6 tools at startup.
Verified e2e: demo run with Gemini produces an LLM-generated root
task title ("Verify and patch mcpproxy documentation drift"), all
3 specialists dispatched and completed, $1.05 cost rollup on the
/goals/1 page, and the MCP server registers 11 tools total (5
hybrid + 6 spec-018) at boot.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- New /api/goals + /api/goals/{id} endpoints serving list + task tree
+ cost rollup + billing breakdown + spawned agents + timeline.
- New Svelte /goals and /goals/[id] pages with sidebar link.
- goals.Service.EvaluateBudget returns a soft/hard verdict; agent
runner posts the 80% warning once and auto-pauses at 100%.
- Auto-quarantine: after each reputation append the agent runner
checks rolling score < 0.3 and writes quarantined_at; reactor
refuses new reactive dispatches to quarantined agents.
- Reactor exposes SetSecretProvider; main.go wires secrets.Store
so reactive subprocess runs inherit user/agent-scoped env vars.
- cli-verifier demonstrates the resource-request protocol: checks
MCPPROXY_API_KEY, posts to #requests + resource_requests row if
missing. New `synapbus secrets set/list` CLI (direct-DB) closes
the loop.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
docgardener: each leaf task now launches a real subprocess via
exec.CommandContext and records a full reactive_runs + harness_runs
row chain with task_id populated, captured prompt, captured response,
exit code, duration, tokens, cost. The Agent Runs page and
/runs/:id detail page now show real data for the doc-gardener demo
— including "What the model saw" and "What the model said" panels —
without needing the coordinator LLM loop.
agents store: agentSelectSQL and both scanAgent functions extended
to read the feature-018 columns (config_hash, parent_agent_id,
spawn_depth, system_prompt, autonomy_tier, tool_scope_json,
quarantined_at, quarantine_reason). /api/agents and
/api/agents/:name now return these fields end-to-end.
Web UI agent detail (web/src/routes/agents/[name]/+page.svelte):
adds a Trust & Spawn section (config_hash, autonomy tier, spawn
depth, parent agent, tool scope chips) and a full-height System
Prompt pre block. Rebuilt internal/web/dist/.
Verified in Chrome against a fresh ./start.sh && ./run_task.sh run:
- Agent Runs page lists 3 completed runs (docs-scanner, cli-verifier,
drift-reporter) with task.claim event and non-zero durations
- /runs/1 detail page renders captured prompt + structured #finding
output with 12 flags
- /agents/docs-scanner shows config_hash=a0b5c6538b2d…, parent=#1,
depth=1, tool-scope chips, and the 170-char system prompt
- #goal-... channel loads all 12 messages (no "Joining..." hang)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>