Task Orchestration
One turn is produced exactly once, into a Redis stream buffer keyed by taskId — the live response and a mid-stream reconnect read the same buffer, not two code paths but two readers of one stream.
The orchestrator produces the turn once; everyone reads it back
The orchestration layer is two files, split by concern. The transport shell
apps/server/src/services/task-orchestrator.ts is thin — it only decides how a turn’s stream is delivered and, if
dropped, recovered. The real execution core is apps/server/src/services/task-runner.ts (startTaskUiStream),
decoupled from both transport and host process — which is exactly why the API and a BullMQ worker drive the same code.
(Desktop mirrors the runner in apps/desktop/src/main/handlers/agent-handler.ts.)
The load-bearing decision is that there is one read core. On the SSE path the run does not stream straight to the
client — it produces its UIMessageChunks into a Redis stream buffer keyed by taskId, and execute() responds by
reading that buffer back. A client that drops and calls resumeStream(taskId) reads the same buffer the same way. So
“live” and “resume” are not two code paths; they are two readers of one produced stream. The run itself never touches
the transport — it returns a transport-neutral ReadableStream<UIMessageChunk> and lets the caller frame it.
The orchestrator never touches LLM mechanics; everything inside the agent stream is documented in Agent Engine and its subsystem pages.
The request path: preflight, then enqueue
execute(taskId, user, body) must return an HTTP Response fast, but without running the turn itself — the real
work is left to the enqueued run. So it does only transport-shaped work, four steps:
- Clear the stale abort flag —
clearAbortRequest(taskId), first, before any DB round-trip, so a same-turn Stop pressed during the client’s “submitting” phase lands after the clear and survives to be honored at run start. - Preflight on the request path —
preflightTask(deps, …):getTaskForExecution(ownership / not-found → HTTP 404),creditService.checkQuota(→ HTTP 402), and — crucially —saveReceivedMessagepersists the inbound message here. A job that is enqueued but never runs must not silently lose the user’s turn, so the durable write lives on the request path, not inside the run. - Enqueue the run —
deps.jobQueue.enqueue("task.run", …, () => runTaskToStore(deps, …)). On BullMQ this hands off to a worker; with an inline queue it runs in-process. Either way the run produces into the buffer. - Respond by reading the buffer —
streamBuffer.read(taskId), the same read a reconnect uses.
The no-Redis fallback (dev / desktop) skips the buffer entirely: startTaskUiStream runs inline and streams straight
back. executeWs(taskId, userId, body, send) is the WebSocket wrapper — it drives the same startTaskUiStream and
sends each chunk as a task:stream:frame; no Redis buffering, and dropped WS clients refetch DB history on reconnect.
The run: startTaskUiStream
This is where a turn actually runs, returned as a transport-neutral stream. Its rhythm is eager on call, lazy on drain — the setup below runs the moment the function is called; the agent loop and finalize wait until the returned stream is drained (by the buffer producer, the WS reader, or the inline SSE response).
Eager (before the stream):
- Acquire the lock —
acquireTaskLock(taskId)(Redis, 409 if already held) +startLockHeartbeatto renew the TTL so a dead process’s lock expires rather than wedging the task. - Bind the task row —
getTaskForExecutionagain (model / approval / planning are task-bound and immutable across turns, because they shape the cached prompt prefix) +creditService.checkQuota(the run is self-contained so a worker re-checks). - Load task data —
loadTaskData(taskId)→ rawuiMessages, a freshassistantMessageId, andoldAssistantMessage(on resume). Turn start needs only the raw stream; the engine’sbuildTurnInputrebuilds the compacted prefix from it every turn (see Compaction). - Sandbox + uploads —
createSandbox({ id: taskId }), thenseedUploadsIntoSandboxmaterializes any files the user attached this turn soread_file/ shell can reach them. - Session + abort wiring — create the
ExecutionSession;abortManager.create(taskId)for in-process Stop, plussubscribeAbort(Redis abort-bus) so a Stop reaches the run even when it lives in a worker, plus anisAbortRequestedre-check to cover the pre-subscribe window.
On drain (createUIMessageStream’s execute callback): setup streams progress states as it goes
(agent_building → context_building → mcp_connecting → agent_running; see the
state machine):
- Kick off
connectMcpToolsconcurrently (network-bound, overlaps the rest of setup). buildAgentSetup— parallel resolve of tier, provider keys, agent config, subagent defs, sandbox readiness.- Memory service +
loadIndex. createRuntimeContext(...)— built once, after the full tool loadout is known.- Assemble
toolServices(memory, browser bridge, kanban, delivery,wait_and_resume, subagents); strip theteamtool (task’s request-response stream has no idle-wake lifecycle for teams). - Compaction repos (
toolCompaction/snapshot/taskBudget) +buildCompactionDeps— kept on the session so finalize can write the cross-turn anchor. - Await MCP tools +
tryAttachToolDiscovery;injectUserSkillsfor any/skill-nameactivations. runAgentLoop(...)— returns a plainAgentLoopResult { agentStream, stepUsages }. A Stop that lands during setup (before the model emits) is caught and swallowed so the stream closes cleanly as an abort, not an error.writer.merge(toAgentUIMessageStream(...))— chunks start flowing.
Finalize — onEnd → finalizeExecution
Only once the stream has fully drained does finalize run. Five steps:
- Stamp the terminal verdict — abort wins over a stray error (a late Stop can surface a spurious
onError); a server-shutdown abort is labelledinterruptedrather thanabortedto keep restart churn out of the user-abort metric. Orphaned in-flight tool parts are terminated so the client doesn’t shimmer forever. taskService.finalizeTurn(...)— persists the assistant message, accumulates usage, updates task metadata; returns{ roundUsage, lifecycle }. Atask:eventWS message tells the client the terminal kind.saveTurnAnchor({ ctx, deps, repos })— writes the cross-turn real-usage anchor (meta:anchor) intotask_compaction_snapshotfor the next turn’s preflight. It never writestask_messageand enqueues no job; failure is logged, never fatal. (This is the server name; desktop’sagent-handler.tscalls the same stepfinalizeTurnCompaction.)saveExecutionRecords— written inline, not as a job, because the admin UI needs them immediately.- Enqueue three background jobs —
credit.consume,resource.index,memory.extraction(fire-and-forget via JobQueue; BullMQ on a worker, in-process on the inline fallback).
Cleanup — the onEnd finally
Unconditional, on both success and failure (and mirrored in the setup-threw catch): unsubscribeAbort →
abortManager.remove(taskId) → mcpClientManager.disconnect(taskId) → stopLockHeartbeat() → releaseTaskLock(taskId).
The entities that cross callbacks
createUIMessageStream is callback-driven with no return-value passing between execute / onEnd / onError, so a
single mutable ExecutionSession is the only channel between them.
| Entity | What it carries | Lifetime |
|---|---|---|
ExecutionSession | abortController, streamStartedAt, stepUsages, and (set in execute) resolvedModelId, context, compactionDeps, compactionRepos, memoryService; error set by onError | created eager, read in onEnd |
RuntimeContext | the platform-agnostic agent environment — writer, sandbox, memorySandbox, subagentDefs, todos, reminders, and write() / writeTransient() helpers | built once in execute, used through finalize |
CompactionDeps | the shared compaction bundle (createModel, contextWindow, compactionModel) — kept on the session for the memory.extraction job; the engine builds its own internally via buildCompactionDeps | built in execute, consumed by a background job |
The session is kept deliberately narrow: only cross-callback data lives on it (sandbox stays a local in execute,
never read in onEnd). This is one of three closure-isolation patterns that keep the per-request object graph
GC-eligible the moment onEnd returns — see below.
Abort — a real Stop, across processes
A real Stop has to cross process boundaries — the run may be in the API process or in a worker.
taskOrchestrator.abort(userId, taskId) (from POST /:id/abort and task:stream:abort) runs in a deliberate order:
taskService.abort— ownership check +isActive = false. It throws on unauthorized access, so an attacker can’t enumeratetaskIds to cancel other users’ runs. Running it first means an unauthorized caller never reaches step 2.abortManager.abort(taskId)(in-process) +publishAbort(taskId)(Redis abort-bus). The run may be in this process or in a worker, so both fire; aborting is idempotent. The run’ssubscribeAbort(installed instartTaskUiStream) receives the bus message; theisAbortRequestedre-check covers a Stop that raced the subscription. The signal is threaded intorunAgentLoop, which interrupts at its next await point.
Closure isolation and prompt GC
A run’s object graph is heavy — message history, the step runner, the StreamTextResult all hang off it. Three
patterns keep it from being pinned, so it drops the moment it should:
| Pattern | Effect |
|---|---|
Keep ExecutionSession fields minimal | Anything not read in onEnd stays a local in execute (e.g. sandbox) |
| Return a plain result, not a closure over the run | runAgentLoop returns { agentStream, stepUsages }; the orchestrator holds no accessor into the run, so dropping the locals releases the StreamTextResult + step-runner chain |
| Build long-lived closures via module-level factories | e.g. the title .then closure captures only taskId + writer, never the enclosing scope |
Under BullMQ the run’s closures are discarded immediately (the payload is serialized to Redis and the worker rebuilds
its deps); under the inline fallback the discipline is what lets the runResult + step runner + message history drop
together the moment onEnd returns.
Desktop parallel
agent-handler.ts mirrors the runner’s shape but drops HTTP-centric concerns:
| Concern | Server | Desktop |
|---|---|---|
| Distributed lock | Redis SET NX + heartbeat | not needed (single process) |
| Credit quota | checked in preflight + run | not needed |
| Produce/read-back | Redis stream buffer | not needed (IPC stays alive) |
| Cross-process abort | Redis abort-bus | in-process abortManager only |
| Job queue | BullMQ on Redis | inline fire-and-forget |
| Turn-end anchor | saveTurnAnchor | finalizeTurnCompaction |
The phase shape and the closure-isolation discipline are identical — the GC cost is most visible on desktop, where jobs run inline.
Related docs
- Agent Engine — the loop the runner hosts
- Context Compaction — the part-addressed checkpoint and the cross-turn anchor
saveTurnAnchorwrites - Streaming Architecture — the resumable SSE buffer, the produce/consume split, and WS transport
- Background Job Queue — BullMQ vs inline, and the three post-turn jobs
- Memory System — the memory sandbox and the
memory.extractionjob