The brief: "a clean, well-architected app which is not going to break in continuous work." Soloist is a long-running supervisor that may stay open for weeks, spawning/killing hundreds of processes, streaming gigabytes of terminal output, and watching filesystems. The enemy is slow rot — leaked PIDs, unbounded buffers, wedged tasks, drifting state. This document defines the architecture and the non-negotiable patterns that prevent that. Every phase must conform to it; Phase 1 builds it as a walking skeleton before any feature lands.
This file is the design contract. 05-solo-reference-and-sources.md is the behavior contract.
Phases cite both.
The domain core is pure and framework-free. Tauri, MCP, the HTTP API, the CLI, SQLite, the OS, and
the PTY library are all adapters plugged into ports (Rust traits). The core never imports
tauri, never speaks JSON-RPC, never knows a UI exists.
┌──────────────── adapters (drive the core) ────────────────┐
│ Tauri commands MCP server HTTP API `soloist` CLI │
└───────┬───────────────┬───────────┬───────────┬───────────┘
▼ ▼ ▼ ▼
╔════════════════════════════════════════════════════════════╗
║ DOMAIN CORE (pure) ║
║ bounded contexts §3 · ports (traits) §2 · event bus §5 ║
╚═══════┬───────────────┬───────────┬───────────┬─────────────╝
▼ ▼ ▼ ▼
┌──────────────── adapters (driven by the core) ─────────────┐
│ ProcessSpawner(PTY) Clock FileWatcher Notifier Store │
│ (portable-pty) (tokio) (notify) (Tauri plugin) (SQLite)│
└────────────────────────────────────────────────────────────┘
Dependency rule (enforced): adapters depend on the core; the core depends on nothing
app-specific. A Cargo lint/CI check forbids tauri, rmcp, axum, rusqlite from appearing in
crates/core. This is what lets us unit-test the entire behavior headless and swap WebKit/MCP/storage
without touching logic.
Ports the core defines (traits), adapters implement:
| Port (trait) | Real adapter | Test adapter |
|---|---|---|
ProcessSpawner (spawn into PTY+pgroup, write, resize, kill) |
portable-pty + nix |
in-memory fake child |
Clock (now, sleep, intervals) |
tokio::time |
deterministic mock clock |
FileWatcher |
notify |
scripted event source |
Notifier (desktop toast) |
Tauri notification plugin (in the app adapter) |
recording spy |
Store (durable repos §7) |
SQLite (rusqlite/sqlx) |
in-memory SQLite / fakes |
Summarizer (agent idle summary) |
user's agent CLI headless | canned responses |
EventSink (emit to UI/MCP) |
Tauri emit / MCP push | channel collector |
Mockable ports + a deterministic Clock make timers, backoff, debounce, and rate-limits testable
without real waiting — essential for the longevity guarantees in §8.
- The core owns all authoritative state. Adapters hold no business state — they translate requests in and project state out.
- The frontend (React) holds a read-model projection pushed via events; it never computes authoritative status. (No business logic in React — see §10.)
- Three different adapters (Tauri UI, MCP, HTTP/CLI) must produce identical behavior because they all funnel through the same core commands. We never reimplement "restart" three times.
Each context is a module in crates/core with one responsibility, a published interface, and its own
tests. Contexts communicate through typed commands and events, not by reaching into each other's
internals. Ask of each: what does it do, how do you use it, what does it depend on?
| # | Bounded context | Owns | Depends on (ports/contexts) | Maps to Solo (ref §) |
|---|---|---|---|---|
| C1 | Projects & Config | project registry, solo.yml load/validate/sync/hash, auto-detection, trust store |
Store, FileWatcher | §3,§4,§9 |
| C2 | Process Supervision | process registry, status FSM, start/stop/restart, orphan adoption, restart policy | ProcessSpawner, Clock, C1(trust) | §2,§4 |
| C3 | Terminal I/O | PTY streams, rendered+raw buffers, input, resize, OSC parsing | ProcessSpawner | §7,§10 |
| C4 | Agents & Idle | agent tool defs, launch, 5-state idle FSM, auto-summarization | C2, C3, Summarizer, Clock | §6 |
| C5 | Monitoring | CPU/mem sampling, port discovery, readiness | Clock, /proc | §7,§10 |
| C6 | Coordination | scratchpads (rev-guarded), todos (blockers/locks/comments), timers, leases, key-value | Store, Clock, C4(idle) | §7 |
| C7 | Notifications | crash/attention/idle toasts, unread/attention-bell state | Notifier, EventSink | §10 |
| C8 | Integration façade | the public command/query API the adapters call; identity & effective-project scope | all other contexts | §7,§8 |
| C9 | Git | working-tree status + diff, staging (file & hunk), commit/amend, sync + ahead/behind, branches & stash, PR create/review/merge, paged log, per-project status cache | GitRepository, GitForge, FileWatcher, Clock, C1(trust), C4(AgentOneShot) |
— (Soloist extension, 02 §VC) |
Why these boundaries: they match Solo's real seams (ref §) and they isolate failure. A bug in auto-summarization (C4) cannot corrupt the process registry (C2). Coordination state (C6) persists independently of live processes (C2), so todos/scratchpads survive restarts while PTY buffers don't. C9 owns no process and no durable aggregate — the repository on disk is the state — so a git subprocess that fails, hangs or is killed degrades that one project's rail and nothing else.
Context map (who calls whom): adapters → C8 only. C8 orchestrates C1–C7 and C9. C2↔C3 are tightly
paired (a process has a PTY) and share a ProcessId. C4 observes C3 output + C2 status. C6 references
ProcessId/ProjectId but never controls processes. C9 reads C1's project root and trust gate and
drives C4's AgentOneShot for assistive text; it controls no process. No cycles.
Stable IDs everywhere (ProjectId, ProcessId, TodoId, ScratchpadId, LockId, ActorId).
Newtypes, not bare strings/ints. Key enums are closed and drive exhaustive match:
enum ProcessKind { Command, Agent, Terminal } // ref §2
enum ProcStatus { Stopped, Starting, Running, Crashed, // ref §4
Restarting, Stopping, RestartExhausted }
enum AgentActivity { Idle, Permission, Thinking, Working, Error } // ref §6
enum Trust { Untrusted, Trusted { variant_hash: Hash } } // ref §4
enum Visibility { Shared, Local } // YAML vs app-state, ref §3State transitions are explicit functions returning Result<NewState, IllegalTransition>, never
ad-hoc field mutation. Illegal transitions are unrepresentable or rejected — the FSM is the contract.
One async runtime (tokio), message-passing, no shared mutable domain state.
- Process actor (the core pattern): each managed process is one supervised
tokiotask that solely owns its child handle, PTY master, stdin sender, and exit watcher. Other contexts interact with it only by sending messages over a boundedmpscand reading its emitted events — never by locking shared fields. This eliminates the classic supervisor races (exit-vs-stop, double-kill). - Cancellation: every task holds a
CancellationToken; stop/restart/shutdown cancel deterministically. A task must clean up its OS resources in its cancellation/Droppath (close PTY, kill pgroup). - Event bus: contexts publish typed
DomainEvents ontokio::sync::broadcast; adapters subscribe. Events are deltas over a snapshot the adapter fetched, so a late/!dropped subscriber re-syncs by re-reading the snapshot (UI does this on focus/reconnect). - CQRS-lite: commands (start, restart, write-scratchpad) mutate via the owning context and
return a typed result; queries read cheap projections (
ProcessView,ProjectView). Reads never block writes. - Single-writer per aggregate: a given process/todo/scratchpad has exactly one task/owner that writes it. Coordination locks (C6) mediate cross-agent intent, not in-process data races.
adapter --command--> C8 --routes--> owning context/actor --emits--> DomainEvent --> all adapters
- Typed errors at boundaries (
thiserror), ergonomicanyhowinside a context; adapters map core errors to their wire format (Tauri error, MCP error object, HTTP status). Every fallible command returns a value the UI can render — no panics as control flow. - No
unwrap()/expect()/panic!in any long-running task (clippy-enforced via#![deny(clippy::unwrap_used, clippy::expect_used)]incore, allowed only in tests). A child process dying, a malformedsolo.yml, a full disk, a missing agent binary — all are expected errors, handled, surfaced, and recoverable. - Panic isolation: each process actor and each background sampler runs under a supervisor that
catches task panics (
JoinHandle+catch_unwindboundary), logs them, marks that unitError, and keeps the rest of the app alive. One wedged process never takes down the supervisor. - Watchdog/self-supervision: long-lived internal tasks (metrics sampler, file-watch dispatcher, event pump) are themselves supervised and auto-restarted with backoff if they exit unexpectedly — the app supervises itself the way it supervises user processes.
Split by lifetime; this is central to "doesn't break in continuous work."
| State | Lifetime | Home |
|---|---|---|
Process registry, ProcStatus, PIDs/pgids, metrics |
ephemeral (rebuilt each run) | in-memory + a small runtime-state file for orphan adoption (ref §4) |
| PTY rendered + raw buffers, scrollback | ephemeral, bounded | in-memory ring/byte buffers (caps in §8) |
| Trust decisions, project registry, settings, agent tool defs | durable | SQLite |
| Todos, scratchpads, key-value, locks/leases | durable | SQLite |
solo.yml |
durable, user-owned | the repo file (we never rewrite it silently) |
- Repository pattern over SQLite (
Storeport): each aggregate (TodoRepo, ScratchpadRepo, KvRepo, LockRepo, TrustRepo, ProjectRepo, SettingsRepo) has a focused trait. SQLite in WAL mode, versioned migrations, all writes in transactions. - Optimistic concurrency for scratchpads/todos: persisted
revision;*_writetakes anexpected_revisionand fails on mismatch (matches Solo's revision-guarded writes, ref §7). This is how concurrent agents don't clobber shared notes. - Leases carry an explicit TTL + owner
ProcessId; expired or owner-closed → auto-released (closes Solo's documented gap, ref §7/§12). - Crash recovery: on launch, reconcile the runtime-state file with live PIDs → adopt/kill/leave orphans (ref §4); durable state in SQLite is always consistent because writes are transactional.
- Data dir honors
SOLOIST_APP_DATA_DIR(our analog ofSOLOTERM_APP_DATA_DIR); default XDG~/.local/share/soloist/.
Every unbounded thing is a future crash. Hard rules:
- Bounded buffers, always. Per-process log ring buffer (default 5,000 lines) and raw scrollback byte cap (default 256 KB); oldest dropped. A global cap across all processes too. No buffer grows without a ceiling.
- Backpressure, never drop silently. Bounded channels between the PTY reader and consumers; if the UI can't keep up with a chatty process, we coalesce output per animation frame and the ring buffer remains source of truth — we slow producers via bounded queues, we don't OOM.
- Rate-limit & debounce by design (matches Solo, ref §4): crash auto-restart capped at 10/60s → RestartExhausted; file-watch events debounced into a quiet window; metrics sampled on an interval (~1s), not per event; idle summaries cadence-limited.
- Guaranteed OS-resource reclamation. Every spawn is into a fresh process group; every
stop/restart/shutdown/cancel signals the group (SIGTERM→grace→SIGKILL) and waits (reaps) so
no zombies/orphans accumulate. PTY masters and file handles are closed in
Drop/cancel paths. A start/stop loop of N processes must end at the same PID/FD count it started with — asserted in a soak test. - No leaks over time. Subscriptions are dropped when panels close; timers/watchers are cancelled when their owner closes; SQLite connections are pooled, not per-call. A multi-hour soak (Phase 13) asserts flat RSS, flat FD count, flat task count.
- Graceful degradation. If summarization (LLM) is unavailable → idle detection falls back to the
heuristic-only signal and the app keeps working. If port discovery (
/proc) fails → ports show "unknown", supervisor unaffected. No optional subsystem can take down the core. - Deterministic shutdown ordering: stop accepting commands → cancel watchers/timers/samplers →
stop_all()processes (reap) → flush SQLite → exit. Matches Solo's "closing stops all processes" (ref §10) and guarantees no orphans on quit.
| Pattern | Where | Why |
|---|---|---|
| Ports & Adapters (hexagonal) | whole app | testability, swap WebKit/MCP/SQLite |
| Actor / supervision tree | C2 process actors, internal tasks | race-free ownership, fault isolation |
| Finite state machines | ProcStatus, AgentActivity, Trust |
legal transitions only |
| Event-driven + CQRS-lite | core↔adapters | one behavior, many frontends; cheap reads |
| Repository + Unit of Work | C6/C1 over SQLite | clean persistence, transactional writes |
| Optimistic concurrency (revisions) | scratchpads/todos | safe concurrent agents |
| Lease/lock | C6 coordination | cooperative multi-agent, auto-release |
| Newtype IDs + closed enums | domain types | compiler-enforced correctness |
| Strategy (per-provider) | C4 idle heuristics, agent tool types | Claude/Codex/Gemini differ cleanly |
| Read-through cache (TTL memo) | core::cache::ReadCache (shell-env capture, agent detect) |
reuse an expensive off-runtime read; single-flight + TTL |
| Anti-corruption layer | C8 façade | adapters never see core internals |
Workspace (Rust):
crates/
core/ # C1–C9, ports (traits), domain types, event bus — NO tauri/mcp/axum/sqlite imports
store/ # SQLite implementation of the Store port (repos, migrations)
pty/ # ProcessSpawner impl over portable-pty + nix (could fold into an adapters crate)
sys/ # MetricsProbe/PortProbe impl over /proc + FileWatcher over notify
exec/ # bounded external-command execution primitive (process group, timeout, reap), used by pty/sys/git/forge
git/ # GitRepository impl over the system `git` CLI (C9)
forge/ # GitForge impl over the `gh` CLI (C9)
app/ # Tauri binary + command/event adapter + bundled UI
mcp/ # `soloist-mcp` binary: MCP (stdio) adapter over the core via IPC
httpapi/ # local HTTP API (127.0.0.1) adapter
cli/ # `soloist` CLI (thin HTTP client, ref §8)
ipc/ # app<->mcp UDS transport + request/response message types shared by app/mcp/httpapi/cli
Frontend (crates/app/ui, React/TS): api.ts (typed invoke/listen only) · store/ (event
reducer → read-model) · components/ · views/. No business logic in React — it renders
projections and sends commands.
Standards (CI-enforced):
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]incore;clippy -D warningsworkspace-wide;rustfmt;tsc --noEmit; ESLint.- Codebase discipline (authoritative rules:
CLAUDE.md§15; gate: §15 below). The brief is a clean, reusable, single-source codebase, not "code that passes." Concretely, every phase keeps:- Domain/service separation — logic in its bounded context (§3), behind ports (§1); adapters thin; React renders projections (§2). No business logic smeared across layers.
- Single source of truth + DRY — every status/kind/event/command/limit/path defined once (Rust
enums in
core; the TS mirror in onedomain.ts); shared logic extracted to one helper, never copy-pasted. - Small, single-purpose files — when a file does two things, split it. Target most source files well under ~300 lines; a non-test file past ~400 lines is a split smell to act on (also keeps each unit in-context for reliable edits). God-files are forbidden in Rust and the frontend.
- Reusable, component-based frontend —
domain.ts(types) ·api.ts(typed IPC) ·store/(pure reducers + hooks) · small presentationalcomponents/. No logic in components, no hugeApp.tsx. - No unnecessary code/comments — doc comments on public items + the rare non-obvious why; no dead code, no speculative abstraction (YAGNI), no comment that restates the code.
- Doc comments on every public item: what it does, how to use it, what it depends on.
- Dependency-direction test in CI: fail the build if
coreimports a forbidden adapter crate. - Errors are values; logging via
tracingspans keyed byProcessId/ProjectId.
- Domain unit tests (headless, deterministic): every FSM transition, restart backoff/rate-limit,
debounce, trust gating, revision conflicts — using the mock
Clockso no real time passes. - Adapter contract tests: the MCP/HTTP/CLI adapters each tested against a fake core to lock their
wire contracts; the SQLite
Storetested against the repo traits. - Integration (real OS, fixture processes):
sleep,bash -c 'exit 3', chatty loop, child-spawner (pgroup kill),python -m http.server(ports/readiness),read x(interactive input). - Property tests:
solo.ymlround-trips; ring buffer never exceeds cap; no FSM reaches an illegal state. - Soak/leak tests (the longevity gate, Phase 13): hours-long run with periodic crashes/restarts → assert flat RSS, FD count, task count, zero leaked PIDs.
- e2e (Playwright via webapp-testing): dashboard, terminal interactivity, trust dialog, palette.
- Trust gate (ref §4) is enforced in the core, not the UI:
start*/restart*/auto-* refuse untrusted command variants regardless of which adapter asks. Trust is per (project, command-variant hash), persisted in SQLite. - HTTP API binds loopback only, requires
X-Soloist-Local-Authon mutations, CORS localhost-only (ref §8). - MCP action tools honor the trust gate and the effective project scope; identity via bound
ProcessId/register_agent/whoami(ref §7). A tool cannot touch another project's state. - Child processes get a sanitized env (ref §5 precedence); Soloist-internal vars stripped except the
injected
SOLOIST_PROCESS_ID.
- Business logic in a Tauri command handler or React component (must live in
core). - Shared mutable domain state behind a big
Mutex(use actors + messages). - Unbounded buffers, channels, or retry loops (every one needs a cap/backoff).
unwrap()/panic!in long-running paths.- Killing a PID instead of its process group (orphan risk).
- Re-implementing an action per adapter (must route to one core command).
- Letting an optional subsystem (summarizer, ports, notifications) be able to crash the core.
- Start/stop 100 processes in a loop → identical PID & FD count at end.
- 6-hour soak with random crashes → flat RSS, flat task count, zero zombies.
- Chatty process (MB/s output) → bounded memory, UI stays responsive (backpressure works).
- Kill the metrics sampler task → it self-restarts; app unaffected.
- Pull the summarizer (LLM offline) → idle detection degrades gracefully, no crash.
- Force-quit the app mid-run → next launch adopts/cleans orphans; SQLite state intact.
-
solo.ymledited 50× rapidly → debounced to few sync prompts, no runaway restarts. - Dependency-direction CI check is green (
corehas no adapter imports).
A phase is not done until these hold for the code it added — independent of whether tests pass. This is the per-phase checkpoint of the §10 standards.
- Separation: new logic sits in the right bounded context (§3) behind a port (§1); the adapter added (if any) is thin and holds no business state; React added (if any) only renders/sends.
- Single source of truth: no concept (status/kind/event/command/limit/path) defined twice; the
Rust↔TS mirror changed in one place per side (
domain.ts). - DRY: no behaviour copy-pasted; shared logic extracted to one helper/module.
- Small files: no new god-file; any non-test source file over ~400 lines was split (or there's a recorded reason it can't be). Frontend included.
- Reusable frontend: components small/presentational; no logic in components; no bloated
App.tsx; no duplicated markup. - Clean: no dead code, no speculative abstraction (YAGNI); comments are doc-comments + rare
why-notes only (no phase numbers, plan citations, or code-restating comments — §10/
CLAUDE.md§8). - Honest tests: every new test exercises real behaviour and can fail for a real reason; no placeholder/tautological tests.
Enforcement:
scripts/check-file-size.shruns injust lintand CI and warns (non-gating) on non-test source files over the threshold, the same waycheck-core-deps.shguards layering. Tightening it into a hard gate stays a later, separate decision (plan/06§9).