I Deleted 3,873 Lines at Midnight: How I Built a Local-First Multi-Agent DAG Engine in Go
When I first learned Go, I wanted to build a complete project in the language so that I could learn it properly while building something useful. I wanted to build the entire system from scratch, and figure things out along the way.
Around the same time, I was exploring LLM agents and how they could be used to automate tasks by breaking them down into smaller steps.
So, I decided to build my own LLM Orchestrator from scratch in Go, learning and figuring things out as I went.
Before I get into how I designed the engine and the challenges I ran into, here is the core idea of the system in simple terms:
flowchart TB
subgraph DAG["DAG Compiler & Validator (Go)"]
direction TB
SPEC["Workflow Definition
YAML / Visual Canvas Nodes"]
CYCLE["DFS Cycle Detector
Zero Recursion / Safe DAG"]
KAHN["Kahn's Topo Sorter
Level Staging: Stage 0 → N"]
SPEC --> CYCLE --> KAHN
end
subgraph EXEC["Stage-Level Concurrent Runner"]
direction TB
SEM["Worker Semaphore Pool
Bounded Goroutine Limiter"]
A1["Agent: Researcher
Gathers Context & Web Data"]
A2["Agent: Coder
Generates Structured Artifacts"]
A3["Agent: Reviewer
Validates & Summarizes"]
INTERP["Upstream Output Pipes
Dynamic Prompt Interpolation"]
SEM --> A1 & A2
A1 & A2 --> INTERP --> A3
end
subgraph STATE["State & Checkpointing Layer"]
direction TB
CHECK["Stage Checkpoint Commit
Atomic Disk Persistence"]
RECOVER["Failure Recovery Manager
Resume from Last Clean State"]
CHECK -. "On Crash" .-> RECOVER
end
subgraph TELEMETRY["Real-Time UI Observability"]
direction TB
WS["WebSocket Event Hub
Broadcasts Node Status & Tokens"]
CANVAS["Interactive Visual Builder
Live Node Glow & Execution Traces"]
WS --> CANVAS
end
%% End-to-End Pipeline Connections
KAHN ==> SEM
A3 --> CHECK
SEM -- "State Updates" --> WS
CHECK -- "State Saved" --> WS
CANVAS -. "Edit / Run" .-> SPEC
%% Excalidraw Color Styles
style DAG fill:#EFF6FF,stroke:#3B82F6,stroke-width:2px,color:#1E3A8A
style EXEC fill:#FFFBEB,stroke:#F59E0B,stroke-width:2px,color:#78350F
style STATE fill:#FDF2F8,stroke:#EC4899,stroke-width:2px,color:#831843
style TELEMETRY fill:#ECFDF5,stroke:#10B981,stroke-width:2px,color:#064E3B
style SPEC fill:#DBEAFE,stroke:#2563EB,color:#1E40AF
style CYCLE fill:#DBEAFE,stroke:#2563EB,color:#1E40AF
style KAHN fill:#DBEAFE,stroke:#2563EB,color:#1E40AF
style SEM fill:#FEF3C7,stroke:#D97706,color:#92400E
style A1 fill:#FEF3C7,stroke:#D97706,color:#92400E
style A2 fill:#FEF3C7,stroke:#D97706,color:#92400E
style A3 fill:#FEF3C7,stroke:#D97706,color:#92400E
style INTERP fill:#FEF3C7,stroke:#D97706,color:#92400E
style CHECK fill:#FCE7F3,stroke:#DB2777,color:#831843
style RECOVER fill:#FCE7F3,stroke:#DB2777,color:#831843
style WS fill:#D1FAE5,stroke:#059669,color:#065F46
style CANVAS fill:#D1FAE5,stroke:#059669,color:#065F46A workflow is defined as a Directed Acyclic Graph (DAG) of discrete tasks. Tasks have explicit dependencies, assigned agent personas, and input prompts. The Go backend validates the graph, computes the optimal parallel execution stages using topological sorting, executes tasks concurrently across worker pools, feeds upstream outputs directly into downstream prompts, and streams real-time state changes over WebSockets to an interactive visual builder.
Building it sounded straightforward on paper. In practice, getting it to run reliably without race conditions, data corruption, or memory leaks turned into one of the most intense engineering challenges I’ve tackled.
The Midnight Meltdown & The Architecture Pivot
My journey with this codebase did not start smoothly.
When I first sat down to write the engine, I fell into the classic developer trap: premature optimization and architecture astronauting. I thought, “If I’m building an orchestrator, it has to be a horizontally scalable distributed system from day one.”
I started coding a distributed architecture with Redis-based leader election, distributed leases, heartbeat daemons, and cluster node synchronization. I spent days wiring together lease renewal loops, Redis pub/sub coordination, and split-brain recovery logic.
Then came the night of May 12th. I was staring at nearly 4,000 lines of code across 36 files. The orchestrator couldn’t even execute a basic two-node task reliably because the distributed state machine was constantly deadlocking or dropping leases during local tests. The abstractions were getting in the way of the actual product.
At 11:58 PM, I hit a breaking point. I stopped everything, took a deep breath, and ran a hard reset.
The Git log still has the commit:
commit a8f92f842430c5b0107bdd1cde8ce2f5c8495857
Author: AniruthKarthik <aniruthkarthik10@gmail.com>
Date: Tue May 12 23:58:28 2026 +0530
I MESSED UP THE FLOW, DOING AGAIN FROM SCRATCH
36 files changed, 82 insertions(+), 3873 deletions(-)
I literally deleted 3,873 lines of code in a single command. I stripped out every single piece of distributed infrastructure, wiped the queues and dead-letter topics, and started over with just a clean, single struct: internal/core/task.go.
A few days later, after experimenting with Redis coordination one last time, I made the official architectural pivot in commit e907376:
commit e907376ca5f9420d49599cd72a30ab0f7baa32ea
Author: AniruthKarthik <aniruthkarthik10@gmail.com>
Date: Mon May 18 10:25:44 2026 +0530
pivot: move to single-node local architecture
14 files changed, 82 insertions(+), 509 deletions(-)
I scrapped the distributed coordinator completely and refocused on building a local-first execution runtime.
Why Go? Because for a local runtime, Go is an absolute superpower:
- Goroutines and Channels: I can spawn thousands of concurrent tasks without worrying about thread overhead or Python GIL lockups.
- Predictable Memory and Latency: No heavy JVM warmup or Python garbage collector pauses choking the UI stream.
- Single Binary Distribution: The entire server, API, and embedded SPA frontend compile into a single static binary. You run
./bin/orchand everything works out of the box.
Once I embraced the local-first philosophy, the system finally started taking shape.
Compiling Graphs: Topological Planning & Stage Concurrency
The heart of the orchestrator is transforming declarative workflow definitions into an execution plan. Whether a workflow is defined in YAML or created through the visual drag-and-drop canvas, the engine treats it as a directed acyclic graph where nodes represent tasks and directed edges represent dependencies.
Before executing a single task, the graph must pass strict static validation in internal/dag/validator.go:
- Reference Integrity: Every dependency identifier must exist in the task map.
- Self-Dependency Guards: A task cannot list itself as a dependency.
- Acyclicity: The engine runs a Depth-First Search (DFS) with a recursion stack. If any node in the current traversal path is revisited, the engine rejects the graph immediately with an
ErrCircularDependencybefore any LLM API is called.
Once validated, internal/dag/topo.go breaks the graph into discrete, parallelizable execution stages using Kahn’s algorithm:
func (p *TopologicalPlanner) BuildExecutionPlan(
workflow *core.Workflow,
) (*ExecutionPlan, error) {
if err := p.validator.Validate(workflow); err != nil {
return nil, err
}
tasks := workflow.GetTasks()
plan := NewExecutionPlan(workflow.ID)
indegree := p.buildIndegreeMap(tasks)
adjacency := p.buildAdjacencyList(tasks)
processed := make(map[string]bool)
level := 0
for {
currentLevelTasks := p.collectZeroIndegree(indegree, processed)
if len(currentLevelTasks) == 0 {
break
}
stage := ExecutionStage{
Level: level,
TaskIDs: currentLevelTasks,
}
plan.AddStage(stage)
for _, taskID := range currentLevelTasks {
processed[taskID] = true
p.decrementNeighbors(taskID, adjacency, indegree)
}
level++
}
return plan, nil
}
This staging model is key to how the orchestrator handles concurrency.
All tasks in stage[0] have an indegree of 0, meaning they have no dependencies and can run immediately in parallel. As tasks in stage[0] finish, the indegrees of their downstream neighbors are decremented. Once all dependencies for a downstream task are resolved, that task enters the next stage.
In the executor loop (internal/executor/executor.go), each stage is dispatched using a sync.WaitGroup bounded by a semaphore concurrency limiter:
var wg sync.WaitGroup
errChan := make(chan error, len(stage.TaskIDs))
for _, taskID := range stage.TaskIDs {
task, _ := workflow.GetTask(taskID)
if task.Status == core.TaskCompleted {
continue
}
wg.Add(1)
go func(t *core.Task) {
defer wg.Done()
if err := e.executeTask(taskCtx, execCtx, workflow, t); err != nil {
errChan <- err
if workflow.GetFailurePolicy() != core.FailurePolicyContinueOnFailure {
workflowCancel()
}
}
}(task)
}
wg.Wait()
After every successful stage, the orchestrator serializes the entire workflow state and persists a snapshot (workflow.CreateCheckpoint()). If the server crashes or an unexpected system reboot occurs, the supervisor can inspect the database, look up the latest checkpoint, and resume execution right from the start of the uncompleted stage instead of re-running the entire workflow and re-billing expensive API calls.
The Visual Client (With Candid Vibecoding Admission)
Now, to be completely transparent: I am really not great with frontend development.
My natural comfort zone is backend systems, concurrency, APIs, and databases. I didn’t want to spend three weeks wrestling with CSS layouts, SVG edge math, and state machines in React. But at the same time, trying to debug an agent DAG through raw terminal logs and JSON dumps is like flying blind. When five agents are running in parallel, you need to see which node is active, which edge is passing data, and where a failure occurred in real time.
So, I basically vibecoded the entire frontend using an LLM.
I used React Flow, TypeScript, and Vite, connecting it to the Go backend via WebSockets.
flowchart TB
subgraph WORKFLOW["React Flow Visual Multi-Agent Workflow"]
direction TB
subgraph S1["Stage 0: Parallel Collection"]
direction TB
N1["Research Topic
Agent: Researcher
Status: COMPLETED"]
N2["Competitor Scan
Agent: Scraper
Status: COMPLETED"]
end
subgraph S2["Stage 1: Verification"]
direction TB
N3["Fact Checker
Agent: Reviewer
Status: RUNNING"]
end
subgraph S3["Stage 2: Aggregation"]
direction TB
N4["Executive Summary
Agent: Writer
Status: PENDING"]
end
N1 --> N3
N3 --> N4
N2 --> N4
end
style WORKFLOW fill:#EFF6FF,stroke:#3B82F6,stroke-width:2px,color:#1E3A8A
style S1 fill:#DBEAFE,stroke:#2563EB,color:#1E40AF
style S2 fill:#FEF3C7,stroke:#D97706,color:#78350F
style S3 fill:#FDF2F8,stroke:#EC4899,color:#831843
style N1 fill:#DCFCE7,stroke:#16A34A,color:#14532D
style N2 fill:#DCFCE7,stroke:#16A34A,color:#14532D
style N3 fill:#FFFBEB,stroke:#F59E0B,color:#78350F
style N4 fill:#F3F4F6,stroke:#9CA3AF,color:#374151And because I vibecoded it in rapid bursts, the frontend initially had plenty of quirks and frustrating bugs that I had to track down:
- The Canvas Magnification Break: The workflow canvas would randomly collapse to zero height or scale unpredictably inside flex containers. I had to explicitly lock the builder wrapper height with
style={{ height: 'calc(100vh - 3rem)' }}and clamp React Flow’s zoom boundaries (minZoom={0.2}, maxZoom={1.5}) in commits290973bandf1bc5d6. - The Handle Dot Rage: In standard React Flow, connecting two nodes requires clicking a tiny 6-pixel dot handle and dragging it precisely onto another 6-pixel handle. Trying to connect five tasks in a row was infuriating. In commit
9a1b0d4, I patched the canvas to allow dropping connection lines anywhere onto the destination node body. - Off-Screen Spawning: Initially, clicking “Add Task” placed new nodes at hardcoded coordinates
(0, 0)or directly on top of the previously selected node. You had to drag the node across the entire canvas every time you added one. In commit7312eccandd9971f9, I added viewport-aware node placement and dedicated “Add Child” buttons directly onto each task card.
It is far from a textbook frontend masterpiece, and there are still UI styling quirks I want to refine. But it gave me a live, interactive window into the simulation that made debugging the engine possible. I also recorded a walkthrough showing the visual DAG builder and live execution: LLM Orchestrator Demo Video.
The Context Problem: Feeding Upstream Results into Downstream Prompts
Once the DAG planner and executor were running, I hit an unexpected realization during end-to-end testing: my agents were running completely blind.
The DAG was ordering the tasks properly, the stages were executing concurrently, and the nodes were turning green in the UI. But when I inspected the generated output of downstream tasks, they had no clue what the upstream tasks had done! A “Summarize Report” task that depended on “Research Market Trends” was just generating a generic essay because it never received the output of the research task.
The execution engine was handling scheduling, but it was not handling data flow.
To solve this, I built a three-part context delivery pipeline in internal/executor/executor.go and internal/agents/agents.go:
flowchart TB
subgraph STAGE["Upstream Completed Tasks"]
direction LR
U1["Market Research
JSON Output"]
U2["Competitor Analysis
JSON Output"]
end
subgraph PIPELINE["Three-Part Context Delivery Pipeline"]
direction TB
subgraph STEP1["1. Direct Dependency Injection"]
direction TB
DEP["Collect dep_outputs Map
Injected into task.Input"]
end
subgraph STEP2["2. Template Interpolation"]
direction TB
INTERP["Regex Parser
Replaces {{Task.field}} in Prompt"]
end
subgraph STEP3["3. Context Stitching & Budgeting"]
direction TB
STITCH["ContextStitcher Safety Gate
Clamps to 32k Chars & Truncates"]
end
STEP1 --> STEP2 --> STEP3
end
subgraph AGENT["Downstream Agent Execution"]
direction TB
PROMPT["Structured System Prompt
+ Sanitized Context"]
CALL["Model Provider API Call
Groq / OpenAI / Anthropic"]
PROMPT --> CALL
end
STAGE ==> STEP1
STEP3 ==> PROMPT
style STAGE fill:#EFF6FF,stroke:#3B82F6,stroke-width:2px,color:#1E3A8A
style PIPELINE fill:#FFFBEB,stroke:#F59E0B,stroke-width:2px,color:#78350F
style AGENT fill:#ECFDF5,stroke:#10B981,stroke-width:2px,color:#064E3B
style U1 fill:#DBEAFE,stroke:#2563EB,color:#1E40AF
style U2 fill:#DBEAFE,stroke:#2563EB,color:#1E40AF
style STEP1 fill:#FEF3C7,stroke:#D97706,color:#92400E
style STEP2 fill:#FEF3C7,stroke:#D97706,color:#92400E
style STEP3 fill:#FEF3C7,stroke:#D97706,color:#92400E
style DEP fill:#FFFBEB,stroke:#F59E0B,color:#78350F
style INTERP fill:#FFFBEB,stroke:#F59E0B,color:#78350F
style STITCH fill:#FFFBEB,stroke:#F59E0B,color:#78350F
style PROMPT fill:#D1FAE5,stroke:#059669,color:#065F46
style CALL fill:#D1FAE5,stroke:#059669,color:#065F461. Direct Dependency Injection (dep_outputs)
In internal/executor/executor.go, right before a task is handed to a worker or agent, the executor pulls the outputs of all direct dependency tasks and packs them into the task’s input payload:
if len(task.Dependencies) > 0 {
if task.Input == nil {
task.Input = make(map[string]any)
}
depOutputs := make(map[string]any, len(task.Dependencies))
for _, depID := range task.Dependencies {
depTask, err := workflow.GetTask(depID)
if err != nil {
continue
}
depOut := depTask.GetOutput()
if len(depOut) == 0 {
continue
}
depOutputs[depID] = depOut
depOutputs[depTask.Name] = depOut
}
if len(depOutputs) > 0 {
task.Input["dep_outputs"] = depOutputs
}
}
In internal/agents/agents.go, the AgentExecutor inspects dep_outputs and automatically constructs a structured markdown section in the agent’s system prompt:
## Outputs from upstream tasks you depend on
The following are the COMPLETE outputs of the tasks that ran before you.
Use them as the primary context and input for your work.
### Task: Market Research
{ ... full JSON response ... }
2. Regex-Based Template Interpolation
Sometimes an agent prompt needs fine-grained access to a specific field from an upstream task rather than dumping the entire JSON blob. I implemented regex-based interpolation allowing users to write prompts like:
Analyze the following competitors mentioned in {{Market Research.competitors}}
and draft a pricing comparison against my product.
The agent compiler parses {{TaskName}} to inject the entire artifact or {{TaskName.field}} to inject specific keys directly into the user prompt.
3. Context Stitching & Token Budgeting
If an upstream task produces a 40-page report, dumping five such artifacts into a downstream task will blow past model context limits or spike API costs unnecessarily.
In internal/agents/stitching.go, I introduced ContextStitcher. It acts as a safety gate, monitoring cumulative characters and token estimates. If artifacts exceed the configured budget (e.g. 32,000 characters), it stitches together the most relevant direct dependency artifacts and cleanly truncates or omits peripheral data with an explicit marker: [FURTHER ARTIFACTS OMITTED DUE TO CONTEXT LIMITS].
Real-Time Observability & The Bounded Event Bus
An orchestrator without real-time observability is impossible to operate. I didn’t want the frontend polling the REST API every 500 milliseconds—it creates unnecessary server load, introduces UI lag, and makes state transitions feel jittery.
Instead, I built an asynchronous, pub/sub event bus (internal/events/bus.go).
type EventBus struct {
subscribers map[EventType][]subscription
mutex sync.RWMutex
eventChan chan Event
workerCount int
nextID SubscriptionID
}
The bus decouples the core execution loop from telemetry. When a task starts, completes, retries, or records token usage, the executor fires an event to a buffered channel:
func (b *EventBus) Publish(event Event) {
select {
case b.eventChan <- event:
default:
// Channel full — drop event to avoid blocking callers in high-load scenarios.
}
}
A dedicated worker pool reads from eventChan and dispatches events to registered subscribers:
- The Audit Logger: Persists immutable records of every task transition and LLM interaction.
- The Token Tracker: Accumulates running prompt, completion, and total token counts per model in memory.
- The WebSocket Hub: Broadcasts events directly to connected browser clients.
In the frontend, useWebSocket.ts listens for incoming events and instantly turns node borders blue on TaskStarted, green on TaskCompleted, or red on TaskFailed, while streaming live execution logs into the slide-over drawer.
The Forensic Debugging Journey (The Nightmare Bugs)
Engineering posts that only show the clean, finished product always feel dishonest to me. The real work of building systems is spent banging your head against obscure bugs, strange edge cases, and unexpected runtime breakdowns.
Here is an honest breakdown of the five biggest nightmare bugs that broke my system during development:
+=============================================================================+
| THE FORENSIC DEBUGGING AUDIT LOG |
+---+----------------------------+--------------------------------------------+
| # | BUG CLASSIFICATION | ROOT CAUSE & ARCHITECTURAL RESOLUTION |
+---+----------------------------+--------------------------------------------+
| 1 | Concurrent Map Mutation | Go shallow-copying map[string]any pointers |
| | & Store Data Race | Fix: Recursive DeepCopyMap & DeepCopySlice |
+---+----------------------------+--------------------------------------------+
| 2 | Silent WebSocket Hijack | Logging middleware hiding http.Hijacker |
| | Interface Drop | Fix: Explicit Hijack() on responseWriter |
+---+----------------------------+--------------------------------------------+
| 3 | Sibling Zombie Tasks & | Stage-only cancel leaking background tasks |
| | Unbounded Token Bleed | Fix: Cascading workflowCtx cancellation |
+---+----------------------------+--------------------------------------------+
| 4 | PascalCase JSON Amnesia | Missing struct tags + server binary cache |
| | & The Phantom Fix | Fix: camelCase tags + binary rebuild |
+---+----------------------------+--------------------------------------------+
| 5 | Re-execution Stale Trap | Re-runs skipping COMPLETED task records |
| | & Zombie Outputs | Fix: Explicit PENDING reset & error wiping |
+---+----------------------------+--------------------------------------------+
Bug 1: The Concurrent Map Mutation & Data Race
- The Symptom: Under parallel load, tasks in the same execution stage would randomly fail with strange type errors, or worse, one task’s input payload would mysteriously appear inside a completely unrelated task’s input.
- The Root Cause: In Go, maps and slices are reference types. When converting tasks from the domain model to storage records (
TaskToRecord), I originally wrote a simple loop:This is a shallow copy. If// THE FLAWED CODE input := make(map[string]any) for k, v := range task.Input { input[k] = v }vis a nestedmap[string]anyor a slice of JSON parameters (which is true for almost all agent payloads), both the in-memory task and the store record pointed to the exact same underlying memory block. When two goroutines executed concurrently in the same stage and mutated their inputs, they triggered concurrent map read/write panics and cross-task state corruption. - The Fix: In commit
c8885f1, I wrote a recursive deep-copy engine ininternal/core/utils.go:I enforcedfunc DeepCopyValue(v any) any { switch val := v.(type) { case map[string]any: return DeepCopyMap(val) case []any: return DeepCopySlice(val) case []string: return DeepCopyStringSlice(val) default: return v } }DeepCopyMapandDeepCopySliceacross all task constructors, store saves, and checkpoint captures, ensuring total memory isolation across parallel goroutines.
Bug 2: The Silent WebSocket Hijacker Drop
- The Symptom: The backend compiled cleanly, the HTTP REST endpoints worked, but the browser could never establish a WebSocket connection. The client sat in a perpetual reconnect loop, and the server logged:
WebSocket upgrade error: webserver doesn't support hijacking - The Root Cause: In
internal/api/server.go, I had implemented custom HTTP middleware to log request latency and status codes. To capture the HTTP status code, I wrapped Go’s standardhttp.ResponseWriterin a custom struct:In Go, WebSocket upgrading (type responseWriter struct { http.ResponseWriter statusCode int }upgrader.Upgrade) works by type-asserting the incominghttp.ResponseWritertohttp.Hijackerin order to take over the raw underlying TCP connection. Because myresponseWriterstruct wrappedhttp.ResponseWriterwithout explicitly implementingHijack(), the type assertion failed, and Gorilla WebSocket threw an error every single time. - The Fix: In commit
aaa4bdb, I explicitly forwarded theHijackmethod on the custom wrapper:The moment this was added, the WebSocket connection upgraded immediately and live DAG updates began flowing to the UI.func (rw *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { hj, ok := rw.ResponseWriter.(http.Hijacker) if !ok { return nil, nil, fmt.Errorf("webserver doesn't support hijacking") } return hj.Hijack() }
Bug 3: Sibling Zombie Tasks & Unbounded Token Bleed
- The Symptom: If a task in a parallel stage failed due to an invalid prompt or model error, the workflow was marked as failed, but my API logs showed that other tasks in that stage continued executing in the background, making calls to Anthropic and OpenAI and burning API credits.
- The Root Cause: In
internal/executor/executor.go, I was instantiating a context withcontext.WithCancelfor individual stages (stageCancel). If a task failed in a fail-fast workflow, callingstageCancel()only signaled sibling tasks in that immediate loop, but did not cleanly propagate cancellation upstream or cancel active HTTP client request bodies. - The Fix: In commit
ba2c4e0, I introduced a top-levelworkflowCtxtied to the entire workflow lifecycle:If any task fails under theworkflowCtx, workflowCancel := context.WithCancel(context.Background()) defer workflowCancel()FailFastpolicy,workflowCancel()is called immediately. BecauseworkflowCtxis passed directly into provider HTTP clients, all active HTTP connections to OpenAI, Anthropic, or Groq are aborted within milliseconds, cutting off unnecessary token spend instantly.
Bug 4: PascalCase JSON Amnesia & The Phantom Fix
The Symptom: The workflow builder UI loaded, but the workflow table was completely blank. No workflows showed up, task counts displayed as zero, and updating a task node crashed the UI with undefined property errors.
The Root Cause: In
internal/store/models.go, my database record structs (WorkflowRecord,TaskRecord) were defined without JSON tags:type WorkflowRecord struct { ID string Name string Description string Status string }Go’s standard
encoding/jsonserializes untagged struct fields using their exact Go names (PascalCase:ID,Name,Status). But the React/TypeScript frontend expected standard camelCase (id,name,status). Because the JSON keys didn’t match, TypeScript silently set fields toundefined.To make it more embarrassing, in commit
2e74563I added the missingjson:"..."tags, refreshed my browser, and… the UI was still completely broken! I spent nearly an hour double-checking TypeScript interfaces before realizing: I had edited the Go code but forgot to recompile and restart the backend server binary! The old binary with PascalCase JSON was still happily serving traffic on port 8080.The Fix: In commit
620e536, I rebuilt the binary, restarted the service, and added explicitjson:"..."struct tags across all models.
Bug 5: The Re-Execution Stale State Trap
- The Symptom: Clicking “Execute” on an existing workflow that had previously completed or failed resulted in an instant no-op. The UI showed no new logs, tasks never transitioned back to running, and old output artifacts remained frozen.
- The Root Cause: In
internal/api/server.go, the execute endpoint loaded the workflow and its task records from the database and passed them directly to the executor. But because the records in the database still hadStatus: "COMPLETED", the executor loop checked:and skipped every single task! The engine assumed the work was already finished.if task.Status == core.TaskCompleted { continue } - The Fix: In commit
b6ef25a, I updatedhandleExecuteWorkflowto explicitly reset execution state before launching the executor:This guaranteed that every re-run starts from a clean slate.workflow.Status = core.WorkflowPending workflow.StartedAt = nil workflow.FinishedAt = nil for _, task := range workflow.Tasks { task.Status = core.TaskPending task.StartedAt = nil task.FinishedAt = nil task.Output = nil task.Error = "" }
Honest Evaluation: What Worked, What Failed, and Real Benchmarks
When building an engine like this, it is easy to accumulate features that sound great on paper but turn out to be clunky or unnecessary in practice. Being honest about what didn’t work was just as important as writing the core code.
1. Removing the Fake Dry-Run Mode
At one point, I added a “Dry Run” toggle in the UI and backend (FUTURE_FEAT.md). The idea was to allow users to simulate workflow execution without making actual LLM API calls.
In practice, the implementation in internal/agents/agents.go was returning hardcoded stub text:
// What dry run was doing:
return map[string]any{
"status": "simulated",
"output": fmt.Sprintf("This is a simulated response for task: %s", task.Name),
}
This turned out to be actively misleading. Because downstream tasks rely on upstream outputs for context and schema validation, passing fake stub text caused downstream tasks to fail or behave completely differently than they would with real LLM responses. It wasn’t a true simulation; it was just a fake path that cluttered the code. In commit 7c3455e, I ripped the dry-run feature out completely.
2. Disabling the “Auto-Route” Heuristic
Early on, the UI included an “Auto-Route (Recommended)” option in the provider dropdown. The vision was that the orchestrator would inspect the task description and automatically pick the optimal model based on cost and reasoning requirements.
In reality, the logic in internal/executor/router.go was just a naive heuristic that looked for any registered executor agent. If you didn’t have an exact agent role configured, it picked whatever model was available or threw an error. Calling it “Auto-Route” gave users a false sense of intelligence. In commit 23ced85, I disabled the auto-route option in the UI and required explicit provider and model selection. Pragmatic determinism is always better than half-baked magic.
3. Concurrency Stress Benchmarks
To verify that the engine could handle high throughput without race conditions, I built an end-to-end concurrency stress test in internal/executor/stress_test.go:
func TestConcurrencyStress(t *testing.T) {
s := store.NewMemoryStore()
eb := events.NewEventBus(100)
// ... setup dependencies ...
exec := NewExecutor(wr, ar, art, mem, tr, tp, eb, s)
exec.WithConcurrencyLimit(50)
var wg sync.WaitGroup
numWorkflows := 20
for i := 0; i < numWorkflows; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// Spawn workflow with 5 dependent tasks
// ...
_ = exec.Execute(workflow)
}(i)
}
wg.Wait()
}
Running this test across 20 concurrent workflows (100 total tasks executing topological planning, stage dispatching, and state persistence) completed in 0.06 seconds:
=== RUN TestConcurrencyStress
--- PASS: TestConcurrencyStress (0.06s)
=== RUN TestSupervisor_CheckStuckTasks
--- PASS: TestSupervisor_CheckStuckTasks (0.00s)
=== RUN TestOutputValidationMiddleware
--- PASS: TestOutputValidationMiddleware (0.00s)
PASS
ok github.com/AniruthKarthik/llm-orchestrator/internal/executor 0.062s
Because memory isolation is strictly guaranteed by recursive deep copying and the topological stages are grouped via Kahn’s algorithm, the Go runtime dispatches parallel tasks with microsecond overhead.
Lessons learned and What’s Next
Building LLM Orchestrator taught me several lessons about agent architectures that I wouldn’t have understood by just reading documentation:
- Local-First Beats Premature Distributed Complexity: Starting with Redis coordination and distributed leases was a mistake. Most agent pipelines don’t need multi-node consensus; they need ultra-fast local execution, instant feedback, and clean state persistence. Stripping out the distributed layer made the system 10x more reliable and easier to develop.
- Concurrency Requires Absolute Memory Isolation: In Go, shallow-copying maps of arbitrary JSON data (
map[string]any) is an open invitation for data races. If multiple goroutines operate on task inputs or outputs, recursive deep copies are mandatory. - Context Injection Is the Real Bottleneck: Scheduling tasks in a DAG is relatively easy. The hard part is ensuring that downstream agents receive the exact right context from upstream tasks without exceeding token limits or blowing up API costs.
- Visual Debugging Is Essential: You cannot operate an agent system through terminal JSON logs alone. Even a vibecoded UI with rough edges is infinitely better than flying blind when tracking complex multi-agent workflows.
What’s Next
The engine is currently running stably, but there is still plenty of room for improvement:
- Conditional Branching (
If/ElseNodes): Enabling dynamic DAG execution where a task output can conditionally skip or activate downstream branches. - Vector DB & RAG Integration: Connecting
ContextStitcherdirectly to a local vector store so long-running workflows can retrieve historical context on demand. - Polishing the Frontend: Refactoring the React Flow canvas to clean up remaining CSS quirks and improve mobile responsiveness.
If you are interested in looking through the code, checking out the architecture, or running the engine locally, the repository is open source on GitHub: AniruthKarthik/llm-orchestrator.