Understanding the Source-Activation-Drain Mechanism in MCP Server Lifecycle Management
The source-activation-drain mechanism prevents race conditions by deferring session restarts until a safe boundary is reached, ensuring parallel tool results are never orphaned in the session journal.
The craft-ai-agents/craft-agents-oss repository implements a sophisticated turn-based lifecycle for its MCP (Multi-Channel Processor) server. When session-scoped tools activate new sources mid-turn, the source-activation-drain mechanism ensures the agent can safely end the current assistant turn without losing sibling tool results or corrupting the session history.
Why Mid-Turn Source Activation Creates Race Conditions
When a tool like mcp__session__source_test activates a new source during a turn, the Agent must immediately end the current assistant turn. This allows the renderer to resend the original user message with the newly-available tools included in the context.
The naive approach—aborting the turn as soon as the first tool_result appears—drops any sibling tool_result events belonging to the same parallel-tool batch. These orphaned tool_use IDs persist in the session journal and block all subsequent sends, effectively deadlocking the session. The source-activation-drain mechanism solves this by introducing a controlled drain boundary that defers the abort until all sibling events are safely ignored.
How the SourceActivationDrainController Works
The SourceActivationDrainController class in packages/shared/src/agent/source-activation-drain.ts manages the deferred restart lifecycle. It operates as a state machine that captures activations, drains subsequent events, and fires a synthetic source_activated event at the appropriate boundary.
Capturing the Pending Activation
The controller monitors every yielded AgentEvent through its observe() method. When it encounters a tool_result whose tool reported a pending source activation (via consumePending()), it stores a PendingActivationRestart record containing the sourceSlug and userMessage.
// Conceptual flow inside the controller
if (event.type === 'tool_result' && pendingRestart) {
this.capturedSlug = pendingRestart.sourceSlug;
this.userMessage = pendingRestart.userMessage;
// Entering drain mode...
}
Draining Subsequent Events
While a pending activation is captured, the controller returns true from observe(), signaling the caller to skip normal per-event handling. This includes bypassing inactive-source detection and compaction reset operations. The drain continues until the boundary policy determines it is safe to fire, ensuring the rest of the parallel-tool batch is safely discarded without journal corruption.
Boundary Policies and Firing Points
The controller supports two distinct firing policies selected at construction time:
batch-boundary (Claude backend): Used when the adapted event array contains synthetic events like task_backgrounded, shell_backgrounded, or shell_killed. The controller waits for the end of the batch via shouldFireAtBoundary() before emitting the source_activated event.
fire-on-non-tool-result (Pi backend): Used when the adapter is 1:1. The first non-tool_result event after capture marks the start of the next assistant turn. The shouldFireBeforeEvent() method checks each incoming event to determine if the boundary has been reached.
When the boundary is reached, takeFire() produces a SourceActivatedEvent (type: 'source_activated') that the caller yields before any further events. The controller toggles its fired flag to ensure the operation is idempotent.
Implementation in Pi and Claude Agents
The controller is instantiated with policy-specific configurations in the two main agent implementations:
Pi Agent uses the fire-on-non-tool-result policy at line 2086 of packages/shared/src/agent/pi-agent.ts:
const drain = new SourceActivationDrainController('fire-on-non-tool-result');
Claude Agent uses the batch-boundary policy at line 1455 of packages/shared/src/agent/claude-agent.ts:
const drain = new SourceActivationDrainController('batch-boundary');
Both implementations follow the same event-loop pattern, integrating the controller into their async generators to manage the lifecycle safely.
Code Implementation Examples
The following pattern appears in both pi-agent.ts and claude-agent.ts, demonstrating the integration of the drain controller into the agent's event generation loop:
import { SourceActivationDrainController } from './source-activation-drain.ts';
async function* runAgent(drain: SourceActivationDrainController) {
for await (const ev of agentEvents) {
// 1. Give the controller a chance to fire before we yield
const pre = drain.shouldFireBeforeEvent(ev);
if (pre) {
yield pre; // emit source_activated
break; // abort current turn
}
// 2. Let the controller observe the event (may capture pending restart)
const shouldDrain = drain.observe(ev, () => pendingRestart);
if (shouldDrain) continue; // skip normal handling while draining
// 3. Normal per-event processing
yield ev;
}
// 4. At the end of the batch/stream, fire if still pending
const final = drain.shouldFireAtBoundary();
if (final) yield final;
}
You can inspect the controller state for debugging purposes:
if (drain.capturedSlug) {
console.log('Captured source:', drain.capturedSlug);
}
if (drain.hasFired) {
console.log('Source activation already emitted');
}
Summary
- The source-activation-drain mechanism prevents session journal corruption by deferring source restarts until safe boundaries are reached.
- The
SourceActivationDrainControllerinpackages/shared/src/agent/source-activation-drain.tsmanages capture, drain, and firing phases throughobserve(),shouldFireBeforeEvent(), andshouldFireAtBoundary(). - Two policies handle different backend architectures:
batch-boundaryfor Claude (synthetic event arrays) andfire-on-non-tool-resultfor Pi (1:1 event mapping). - The controller guarantees exactly one
source_activatedevent per activation and ensures idempotency via thefiredflag.
Frequently Asked Questions
What happens if the controller fires before processing all parallel tool results?
If the controller fires prematurely, sibling tool_result events would be orphaned in the session journal with unacknowledged tool_use IDs. This would block all subsequent sends from the MCP server. The drain mechanism specifically prevents this by returning true from observe() to skip normal handling until the boundary is reached.
How does the controller distinguish between the two boundary policies?
The constructor accepts a policy string: 'batch-boundary' or 'fire-on-non-tool-result'. The batch-boundary policy relies on shouldFireAtBoundary() called at the end of event batches, while fire-on-non-tool-result uses shouldFireBeforeEvent() checked before every event to detect the first non-tool result.
Can multiple source activations occur within a single turn?
The controller is designed to handle one activation at a time. Once takeFire() executes and sets the fired flag, the controller becomes inactive until explicitly reset. This prevents duplicate source_activated events and ensures the session journal maintains a consistent 1:1 relationship between activations and restarts.
Where can I find the test coverage for this mechanism?
Comprehensive unit tests covering both policies are located in packages/shared/src/agent/__tests__/source-activation-drain.test.ts. These tests verify boundary detection, idempotent firing, and proper handling of mixed event sequences across both Claude and Pi backend configurations.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →