From 348a8894dc12cd5d4808a3eed0572aaf9e6b28cf Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Wed, 11 Mar 2026 00:38:43 +0000 Subject: docs: add system architecture overview --- docs/architecture.md | 392 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 docs/architecture.md (limited to 'docs') diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..27c7601 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,392 @@ +# Claudomator — System Architecture + +## 1. System Purpose and Design Goals + +Claudomator is a local developer tool that captures tasks, dispatches them to AI agents (Claude, +Gemini), and reports results. Its primary use case is unattended, automated execution of agent +tasks with real-time status streaming to a mobile Progressive Web App. + +**Design goals:** + +- **Single binary, zero runtime deps** — Go for the backend; CGo only for SQLite. +- **Bounded concurrency** — a pool of goroutines prevents unbounded subprocess spawning. +- **Durability** — all task state survives server restarts (SQLite + WAL mode). +- **Real-time feedback** — WebSocket pushes task completion events to connected clients. +- **Multi-agent routing** — deterministic load balancing across Claude and Gemini; AI-driven + model-tier selection via a cheap Gemini classifier. +- **Git-isolated execution** — tasks that modify source code run in a temporary git clone + (sandbox) to prevent concurrent corruption and partial-work leakage. +- **Review gate** — top-level tasks wait in `READY` state for operator accept/reject before + reaching `COMPLETED`. + +See [ADR-001](adr/001-language-and-architecture.md) for the language and architecture rationale. + +--- + +## 2. High-Level Architecture + +```mermaid +flowchart TD + CLI["CLI\ncmd/claudomator\n(cobra)"] + API["HTTP API\ninternal/api\nREST + WebSocket"] + Pool["Executor Pool\ninternal/executor.Pool\n(bounded goroutines)"] + Classifier["Gemini Classifier\ninternal/executor.Classifier"] + ClaudeRunner["ClaudeRunner\ninternal/executor.ClaudeRunner"] + GeminiRunner["GeminiRunner\ninternal/executor.GeminiRunner"] + Sandbox["Git Sandbox\n/tmp/claudomator-sandbox-*"] + Subprocess["AI subprocess\nclaude -p / gemini"] + SQLite["SQLite\ntasks.db"] + LogFiles["Log Files\n~/.claudomator/executions/"] + Hub["WebSocket Hub\ninternal/api.Hub"] + Clients["Clients\nbrowser / mobile PWA"] + Notifier["Notifier\ninternal/notify"] + + CLI -->|run / serve| API + CLI -->|direct YAML run| Pool + API -->|POST /api/tasks/run| Pool + Pool -->|pickAgent + Classify| Classifier + Pool -->|Submit / SubmitResume| ClaudeRunner + Pool -->|Submit / SubmitResume| GeminiRunner + ClaudeRunner -->|git clone| Sandbox + Sandbox -->|claude -p| Subprocess + GeminiRunner -->|gemini| Subprocess + Subprocess -->|stream-json stdout| LogFiles + Pool -->|UpdateTaskState| SQLite + Pool -->|resultCh| API + API -->|Broadcast| Hub + Hub -->|fan-out| Clients + API -->|Notify| Notifier +``` + +--- + +## 3. Component Table + +| Package | Role | Key Exported Types | +|---|---|---| +| `internal/task` | `Task` struct, YAML parsing, state machine, validation | `Task`, `AgentConfig`, `RetryConfig`, `State`, `Priority`, `ValidTransition` | +| `internal/executor` | Bounded goroutine pool; subprocess manager; multi-agent routing; classification | `Pool`, `ClaudeRunner`, `GeminiRunner`, `Classifier`, `Runner`, `Result`, `BlockedError`, `QuestionRegistry` | +| `internal/storage` | SQLite wrapper; auto-migrating schema; all task and execution CRUD | `DB`, `Execution`, `TaskFilter` | +| `internal/api` | HTTP server (REST + WebSocket); result forwarding; elaboration; log streaming | `Server`, `Hub` | +| `internal/reporter` | Formats and emits execution results (text, HTML) | `Reporter`, `TextReporter`, `HTMLReporter` | +| `internal/config` | TOML config loading; data-directory layout | `Config` | +| `internal/cli` | Cobra CLI commands (`run`, `serve`, `list`, `status`, `init`) | `RootCmd` | +| `internal/notify` | Webhook notifier for task completion events | `Notifier`, `WebhookNotifier`, `Event` | +| `web` | Embedded PWA static files (served by `internal/api`) | `Files` (embed.FS) | +| `version` | Build-time version string | `Version` | + +--- + +## 4. Package Dependency Graph + +```mermaid +graph LR + cli["internal/cli"] + api["internal/api"] + executor["internal/executor"] + storage["internal/storage"] + task["internal/task"] + config["internal/config"] + reporter["internal/reporter"] + notify["internal/notify"] + web["web"] + version["version"] + + cli --> api + cli --> executor + cli --> storage + cli --> config + cli --> version + api --> executor + api --> storage + api --> task + api --> notify + api --> web + executor --> storage + executor --> task + reporter --> task + reporter --> storage + storage --> task +``` + +--- + +## 5. Task Execution Pipeline + +The following numbered steps trace a task from API submission to final state, with file and line +references to the key logic in each step. + +1. **Task creation** — `POST /api/tasks` calls `task.Validate` and `storage.DB.CreateTask`. + Task is written to SQLite in `PENDING` state. + (`internal/api/server.go:349`, `internal/task/parse.go`, `internal/storage/db.go`) + +2. **Run request** — `POST /api/tasks/{id}/run` calls `storage.DB.ResetTaskForRetry` (validates + the `PENDING → QUEUED` transition) then `executor.Pool.Submit`. + (`internal/api/server.go:460`, `internal/executor/executor.go:125`) + +3. **Pool dispatch** — The `dispatch` goroutine reads from `workCh`, waits for a free slot + (blocks on `doneCh` if at capacity), then spawns `go execute(ctx, task)`. + (`internal/executor/executor.go:102`) + +4. **Agent selection** — `pickAgent(SystemStatus)` selects the available agent with the fewest + active tasks (deterministic, no I/O). If the pool has a `Classifier`, it invokes + `Classifier.Classify` (one Gemini API call) to select the model tier; failures are non-fatal. + (`internal/executor/executor.go:349`, `internal/executor/executor.go:396`) + +5. **Dependency wait** — If `t.DependsOn` is non-empty, `waitForDependencies` polls SQLite + every 5 s until all dependencies reach `COMPLETED` (or a terminal failure state). + (`internal/executor/executor.go:642`) + +6. **Execution record created** — A new `storage.Execution` row is inserted with `RUNNING` + status. Log paths (`stdout.log`, `stderr.log`) are pre-populated via `LogPather` so they are + immediately available for tailing. + (`internal/executor/executor.go:483`) + +7. **Subprocess launch** — `ClaudeRunner.Run` (or `GeminiRunner.Run`) builds the CLI argument + list and calls `exec.CommandContext`. If `project_dir` is set and this is not a resume + execution, `setupSandbox` clones the project to `/tmp/claudomator-sandbox-*` first. + (`internal/executor/claude.go:63`, `internal/executor/claude.go:setupSandbox`) + +8. **Output streaming** — stdout is written to `//stdout.log`; the runner + concurrently parses the `stream-json` lines for cost and session ID. + (`internal/executor/claude.go`) + +9. **Execution outcome → state** — After the subprocess exits, `handleRunResult` maps the error + type to a final task state and calls `storage.DB.UpdateTaskState`. + (`internal/executor/executor.go:256`) + + | Outcome | Final state | + |---|---| + | `runner.Run` → `nil`, top-level, no subtasks | `READY` | + | `runner.Run` → `nil`, top-level, has subtasks | `BLOCKED` | + | `runner.Run` → `nil`, subtask | `COMPLETED` | + | `runner.Run` → `*BlockedError` (question file) | `BLOCKED` | + | `ctx.Err() == DeadlineExceeded` | `TIMED_OUT` | + | `ctx.Err() == Canceled` | `CANCELLED` | + | quota exhausted | `BUDGET_EXCEEDED` | + | any other error | `FAILED` | + +10. **Result broadcast** — The pool emits a `*Result` to `resultCh`. `Server.forwardResults` + reads it, marshals a `task_completed` JSON event, and calls `hub.Broadcast`. + (`internal/api/server.go:123`, `internal/api/server.go:129`) + +11. **Sandbox teardown** — If a sandbox was used and no uncommitted changes remain, + `teardownSandbox` removes the temp directory. If uncommitted changes are detected, the task + fails and the sandbox is preserved for inspection. + (`internal/executor/claude.go:teardownSandbox`) + +12. **Review gate** — Operator calls `POST /api/tasks/{id}/accept` (`READY → COMPLETED`) or + `POST /api/tasks/{id}/reject` (`READY → PENDING`). + (`internal/api/server.go:487`, `internal/api/server.go:507`) + +--- + +## 6. Task State Machine + +```mermaid +stateDiagram-v2 + [*] --> PENDING : task created + + PENDING --> QUEUED : POST /run + PENDING --> CANCELLED : POST /cancel + + QUEUED --> RUNNING : pool goroutine starts + QUEUED --> CANCELLED : POST /cancel + + RUNNING --> READY : exit 0, top-level, no subtasks + RUNNING --> BLOCKED : exit 0, top-level, has subtasks + RUNNING --> BLOCKED : question.json written + RUNNING --> COMPLETED : exit 0, subtask + RUNNING --> FAILED : exit non-zero / stream error + RUNNING --> TIMED_OUT : context deadline exceeded + RUNNING --> CANCELLED : context cancelled + RUNNING --> BUDGET_EXCEEDED : quota exhausted + + READY --> COMPLETED : POST /accept + READY --> PENDING : POST /reject + + BLOCKED --> QUEUED : POST /answer + BLOCKED --> READY : all subtasks COMPLETED + + FAILED --> QUEUED : POST /run (retry) + TIMED_OUT --> QUEUED : POST /resume + CANCELLED --> QUEUED : POST /run (restart) + BUDGET_EXCEEDED --> QUEUED : POST /run (retry) + + COMPLETED --> [*] +``` + +**State definitions** (`internal/task/task.go:9`): + +| State | Meaning | +|---|---| +| `PENDING` | Created; not yet submitted for execution | +| `QUEUED` | Submitted to pool; waiting for a goroutine slot | +| `RUNNING` | Subprocess actively executing | +| `READY` | Top-level task done; awaiting operator accept/reject | +| `COMPLETED` | Fully done (only true terminal state) | +| `FAILED` | Execution error; eligible for retry | +| `TIMED_OUT` | Exceeded configured timeout; resumable | +| `CANCELLED` | Explicitly cancelled by operator | +| `BUDGET_EXCEEDED` | Exceeded `max_budget_usd` | +| `BLOCKED` | Agent wrote a `question.json`, or parent waiting for subtasks | + +`ValidTransition(from, to State) bool` enforces the allowed edges at runtime before every state +write. (`internal/task/task.go:113`) + +--- + +## 7. WebSocket Broadcast Flow + +```mermaid +sequenceDiagram + participant Runner as ClaudeRunner / GeminiRunner + participant Pool as executor.Pool + participant API as api.Server (forwardResults) + participant Hub as api.Hub + participant Client1 as WebSocket client 1 + participant Client2 as WebSocket client 2 + + Runner->>Pool: runner.Run() returns + Pool->>Pool: handleRunResult() — sets exec.Status + Pool->>SQLite: UpdateTaskState + UpdateExecution + Pool->>Pool: resultCh <- &Result{...} + API->>Pool: <-pool.Results() + API->>API: marshal task_completed JSON event + API->>Hub: hub.Broadcast(data) + Hub->>Client1: ws.Write(data) + Hub->>Client2: ws.Write(data) + API->>Notifier: notifier.Notify(Event{...}) [if set] +``` + +**Event payload** (JSON): +```json +{ + "type": "task_completed", + "task_id": "", + "status": "READY | COMPLETED | FAILED | ...", + "exit_code": 0, + "cost_usd": 0.042, + "error": "", + "timestamp": "2026-03-11T12:00:00Z" +} +``` + +The `Hub` also emits `task_question` events via `Server.BroadcastQuestion` when an agent uses +an interactive question tool (currently unused in the primary file-based `BLOCKED` flow). + +WebSocket endpoint: `GET /api/ws`. Supports optional bearer-token auth when `--api-token` is +configured. Up to 1000 concurrent clients; periodic 30-second pings detect dead connections. +(`internal/api/websocket.go`) + +--- + +## 8. Subtask / Parent-Task Dependency Resolution + +Claudomator supports two distinct dependency mechanisms: + +### 8a. `depends_on` — explicit task ordering + +Tasks declare `depends_on: [, ...]` in their YAML or creation payload. When the +executor goroutine starts, `waitForDependencies` polls SQLite every 5 seconds until all listed +tasks reach `COMPLETED`. If any dependency reaches a terminal failure state (`FAILED`, +`TIMED_OUT`, `CANCELLED`, `BUDGET_EXCEEDED`), the waiting task transitions to `FAILED` +immediately. (`internal/executor/executor.go:642`) + +### 8b. Parent / subtask blocking (`BLOCKED` state) + +A top-level task that creates subtasks (tasks with `parent_task_id` pointing back to it) +transitions to `BLOCKED` — not `READY` — when its own runner exits successfully. This allows +an agent to dispatch subtasks and then wait for them. + +``` +Parent task runner exits 0 + ├── no subtasks → READY (normal review gate) + └── has subtasks → BLOCKED (waiting for subtasks) + │ + Each subtask completes → COMPLETED + │ + All subtasks COMPLETED? + ├── yes → maybeUnblockParent() → parent READY + └── no → parent stays BLOCKED +``` + +`maybeUnblockParent(parentID)` is called every time a subtask transitions to `COMPLETED`. It +loads all subtasks and checks whether every one is `COMPLETED`. If so, it calls +`UpdateTaskState(parentID, StateReady)`. (`internal/executor/executor.go:616`) + +The `BLOCKED` state also covers the interactive question flow: when `ClaudeRunner.Run` detects +a `question.json` file in the execution log directory, it returns a `*BlockedError` containing +the question JSON and session ID. The pool stores the question in the `tasks.question_json` +column and the session ID in the `executions.session_id` column. `POST /api/tasks/{id}/answer` +resumes the task by calling `pool.SubmitResume` with a new `Execution` carrying +`ResumeSessionID` and `ResumeAnswer`. (`internal/executor/claude.go:103`, +`internal/api/server.go:221`) + +--- + +## 9. External Go Dependencies + +| Module | Version | Purpose | +|---|---|---| +| `github.com/mattn/go-sqlite3` | v1.14.33 | CGo SQLite driver (requires C compiler) | +| `github.com/google/uuid` | v1.6.0 | UUID generation for task and execution IDs | +| `github.com/spf13/cobra` | v1.10.2 | CLI command framework | +| `github.com/BurntSushi/toml` | v1.6.0 | TOML config file parsing | +| `golang.org/x/net` | v0.49.0 | `golang.org/x/net/websocket` — WebSocket server | +| `gopkg.in/yaml.v3` | v3.0.1 | YAML task definition parsing | + +--- + +## 10. Related Documentation + +### Architectural Decision Records + +| ADR | Title | Summary | +|---|---|---| +| [ADR-001](adr/001-language-and-architecture.md) | Go + SQLite + WebSocket Architecture | Language choice, pipeline design, storage and API rationale | +| [ADR-002](adr/002-task-state-machine.md) | Task State Machine Design | All 10 states, transition table, side effects, known edge cases | +| [ADR-003](adr/003-security-model.md) | Security Model | Trust boundary, no-auth posture, known risks, hardening checklist | +| [ADR-004](adr/004-multi-agent-routing-and-classification.md) | Multi-Agent Routing | `pickAgent` load balancing, Gemini-based model classifier | +| [ADR-005](adr/005-sandbox-execution-model.md) | Git Sandbox Execution Model | Isolated git clone per task, push-back flow, BLOCKED preservation | + +### Package-Level Docs + +Per-package design notes live in [`docs/packages/`](packages/) (in progress). + +### Task YAML Reference + +```yaml +name: "My Task" +agent: + type: "claude" # "claude" | "gemini"; optional — load balancer may override + model: "sonnet" # model tier hint; optional — classifier may override + instructions: | + Do something useful. + project_dir: "/workspace/myproject" # if set, runs in a git sandbox + max_budget_usd: 1.00 + permission_mode: "bypassPermissions" # default + allowed_tools: ["Bash", "Read"] + context_files: ["README.md"] +timeout: "15m" +priority: "normal" # high | normal | low +tags: ["ci", "backend"] +depends_on: [""] # explicit ordering +parent_task_id: "" # set by parent agent when creating subtasks +``` + +Batch files wrap multiple tasks under a `tasks:` key and are accepted by `claudomator run`. + +### Storage Schema + +Two tables auto-migrated on `storage.Open()`: + +- **`tasks`** — `id`, `name`, `description`, `config_json` (AgentConfig), `priority`, + `timeout_ns`, `retry_json`, `tags_json`, `depends_on_json`, `parent_task_id`, `state`, + `question_json`, `rejection_comment`, `created_at`, `updated_at` +- **`executions`** — `id`, `task_id`, `start_time`, `end_time`, `exit_code`, `status`, + `stdout_path`, `stderr_path`, `artifact_dir`, `cost_usd`, `error_msg`, `session_id`, + `resume_session_id`, `resume_answer` + +Indexed columns: `tasks.state`, `tasks.parent_task_id`, `executions.task_id`, +`executions.status`, `executions.start_time`. -- cgit v1.2.3