Skip to content

Architecture

C4 Diagrams

See the C4 Diagrams page for all rendered diagrams.

C4 Containers

Clean Architecture

The API follows Clean Architecture — dependencies point inward only.

┌─────────────────────────────────────────────────────┐
│ ConductorE.Api (Frameworks & Drivers)               │
│                                                     │
│  Program.cs          DI wiring, endpoint routing    │
│  Adapters/                                          │
│    MartenEventStore  Implements IEventStore          │
│    MartenIssueQuery  Implements IIssueQuery          │
│    MartenAgentQuery  Implements IAgentQuery          │
│    MartenProjections Marten-specific projections     │
│                                                     │
│  ┌─────────────────────────────────────────────┐    │
│  │ ConductorE.Core (Domain + Use Cases)        │    │
│  │                                             │    │
│  │  Domain/                                    │    │
│  │    Events.cs      Pure event records        │    │
│  │    ReadModels.cs  Pure read model records   │    │
│  │                                             │    │
│  │  Ports/                                     │    │
│  │    IEventStore    Interface                  │    │
│  │    IIssueQuery    Interface                  │    │
│  │    IAgentQuery    Interface                  │    │
│  │                                             │    │
│  │  UseCases/                                  │    │
│  │    SubmitEvent    Maps request → event       │    │
│  │                                             │    │
│  │  ⚠ ZERO external dependencies              │    │
│  └─────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────┘

Core has zero NuGet packages. No Marten, no ASP.NET, no framework code. Domain events, read models, ports, and use cases are pure C#.

Marten is only in the Api project (adapters). If we ever swap PostgreSQL for another event store, only the adapters change — Core stays untouched.

Two-Component Design

Conductor-E runs as two components in the same Kubernetes namespace:

┌──────────────────── conductor-e namespace ────────────────────┐
│                                                               │
│  ┌──────────────────┐        ┌──────────────────┐            │
│  │ Conductor-E      │  HTTP  │ Conductor-E API  │            │
│  │ (Rig Agent)      │───────▶│ (.NET 10)        │            │
│  │                  │        │                  │            │
│  │ Discord bot      │        │ Domain:          │            │
│  │ GitHub MCP tools │        │   Events         │            │
│  │ Claude Haiku     │        │   ReadModels     │            │
│  │ 1-year sub token │        │ Ports:           │            │
│  └──────────────────┘        │   IEventStore    │            │
│                              │   IIssueQuery    │            │
│                              │ Adapters:        │            │
│                              │   Marten → PG    │            │
│                              └────────┬─────────┘            │
│                                       │                      │
│                              ┌────────▼─────────┐            │
│                              │ PostgreSQL 16    │            │
│                              │ Marten schema    │            │
│                              └──────────────────┘            │
└───────────────────────────────────────────────────────────────┘

Event Flow

sequenceDiagram
    participant H as Human/Agent
    participant A as Rig Agent Runtime (Discord)
    participant UC as SubmitEvent (Use Case)
    participant P as IEventStore (Port)
    participant M as MartenEventStore (Adapter)
    participant PG as PostgreSQL

    H->>A: Message in #conductor-e
    A->>A: Claude processes message
    A->>UC: SubmitEventRequest
    UC->>UC: MapToEvent (domain logic)
    UC->>P: AppendAsync(streamId, event)
    P->>M: Marten session.Events.Append
    M->>PG: INSERT + inline projection update
    PG-->>M: Stored
    M-->>UC: Done
    UC-->>A: SubmitEventResponse
    A-->>H: "Issue #547 queued"

Stream Identity

String-based (not Guid):

  • Issue streams: dashecorp/conductor-e#42
  • Agent streams: dev-e-1

Projections

Marten inline projections (update synchronously with event append):

Projection Source Events Key Fields
IssueStatus All lifecycle events state, agentId, prNumber, priority, attempt
AgentStatus Heartbeat, IssueAssigned, IssueDone, AgentStuck status, currentIssue, completed/failed counts

Dashboard

Dashboard.html is a single-page control plane UI served at /. It auto-refreshes every 30 seconds.

Panel Data source Description
Agents GET /api/agents Online/offline status, current task, heartbeat age
Queue GET /api/queue, GET /api/issues?state=in_progress Pending and active issues
Queues GET /api/streams/status, GET /api/streams/{agentId} Per-agent Valkey stream depths with last-assigned entry. Rows with >20 queued items are highlighted amber. Clicking a row expands to show the last 10 queued items with GitHub links.
Costs GET /api/costs/summary 30-day cost breakdown per agent
Logs GET /api/agent-logs + SSE Real-time agent log stream

Duplicate PR Auto-Close

When two agents claim the same issue in parallel (claim-race), each opens a PR. After the winning PR merges, Conductor-E automatically closes any sibling PRs that reference the same issue.

Trigger: MERGED event (from MergeGate or GitHub webhook pull_request.closed)

Behaviour: 1. Scans all open PRs in the target repo for bodies matching Closes/Fixes/Resolves #N (or cross-repo owner/repo#N). 2. Closes each sibling PR with a comment:

**Duplicate of #<MERGED_PR>** — closed automatically by Conductor-E.
PR #<MERGED_PR> covered the same issue #<N> and merged at <MERGED_AT>.
Two agents claimed the same issue; this one lost the race.
3. Deletes the Valkey key session:{repo}#{N} (the agent claim lock). 4. Emits a DUPLICATE_PR_CLOSED event with fields: repo, mergedPrNumber, closedPrNumber, issueNumber.

Implementation: src/ConductorE.Api/Services/DuplicateCloseService.cs, called from MergeGate.Merge() and the webhook pull_request.closed handler.

Note: This does not prevent the double-claim — atomic SETNX claim:{repo}#{N} is tracked in a separate issue.

Test Coverage

Suite Tests Line Branch
Core (unit) 44 70.3% 90%
API (unit, DuplicateCloseService) 13
API (integration, Testcontainers PostgreSQL) 11 32.9% 25.4%
Total 68

Core coverage gap is auto-generated record methods. API coverage gap is ASP.NET framework-generated code. All business logic is covered.