@helix-agents/runtime-js
JavaScript runtime for in-process agent execution. Non-durable execution suitable for development, testing, and single-process deployments.
Installation
npm install @helix-agents/runtime-jsJSAgentExecutor
The main executor class for running agents in-process.
Constructor
import { JSAgentExecutor } from '@helix-agents/runtime-js';
const executor = new JSAgentExecutor(
stateStore, // StateStore implementation
streamManager, // StreamManager implementation
llmAdapter, // LLMAdapter implementation
{
logger, // optional
runLease: { ttlMs: 30_000 }, // optional: the JS run lease (default JS_RUN_LEASE_MS)
llmStepRetry: { limit: 3, delayMs: 1000 }, // optional: retry a retryable LLM error (MA-26)
submitToolResultSchema, // optional: the submit size limit this executor owns (DI-51)
}
);Submit size limit (DI-51). The executor owns it: submitToolResultSchema (any factory-built schema, createSubmitToolResultSchema({ maxResultBytes }); default 1 MB) sets it, executor.submitLimits exposes it, and submitToolResult refuses a result over it with core's typed 413 validation_payload_too_large before anything is read or stored. Hosts (agent-server, handleChatStream) read it from the executor. See the submit size limit.
LLM step retry (MA-26). llmStepRetry (LLMStepRetryPolicy: { limit, delayMs, backoff? }, backoff 'constant' | 'linear' | 'exponential', default 'exponential') is the budget for retrying a model call whose error is classified retryable: true. Default: core DEFAULT_LLM_STEP_RETRY ({ limit: 3, delayMs: 1000, backoff: 'exponential' }); { limit: 0, delayMs: 0 } turns it off. Child runs (sub-agents, companions) use the same budget. Each retry is a new attempt of the same step. See Retrying a failed model call.
The store must implement the run methods (getCurrentRun, getRun, listRuns, updateRunStatus, commitState, renewRunLease); construction throws otherwise. Every store package in this repo implements them. A custom store must implement the commit contract described in the upgrade guide (breaking).
execute
Start executing an agent.
const handle = await executor.execute(MyAgent, 'Hello, agent!');
// Or with initial state
const handle = await executor.execute(MyAgent, {
message: 'Hello',
state: { userId: 'user-123' },
});
// Or with multiple messages (for context injection, file attachments, etc.)
const handle = await executor.execute(MyAgent, {
message: [
{
role: 'user',
content: 'Background: user is a premium subscriber',
metadata: { hidden: true },
},
{ role: 'user', content: 'What features do I have access to?' },
],
});
// Messages can include file attachments
const handle = await executor.execute(MyAgent, {
message: [
{
role: 'user',
content: 'Analyze this image',
files: [{ data: 'data:image/png;base64,...', mediaType: 'image/png', filename: 'chart.png' }],
},
],
});Parameters:
agent- Agent configuration fromdefineAgent()input- String message, or{ message: string | UserInputMessage[], state?: Partial<TState> }
Returns: AgentExecutionHandle<TOutput>
getHandle
Get a handle to an existing execution.
const handle = await executor.getHandle(MyAgent, 'session-123');
if (handle) {
const result = await handle.result();
}Parameters:
agent- Agent configurationsessionId- Session identifier
Returns: AgentExecutionHandle | null
canResume
Check if an execution can be resumed.
const result = await executor.canResume(MyAgent, 'session-123');
if (result.canResume) {
// Can continue execution
} else {
console.log('Cannot resume:', result.reason);
}Returns:
interface CanResumeResult {
canResume: boolean;
reason?: string;
state?: AgentState;
}resume
Resume a paused or interrupted execution.
const handle = await executor.resume(MyAgent, 'session-123', {
additionalMessage: 'Continue with this context',
});A resume of an interrupted or failed session (any mode but from_checkpoint) first pairs each trailing tool call that has no result (for example a client-tool call a stop discarded) with a not-executed result, in the resume's run-start commit, so the model never sees an unpaired tool_use. retry() does the same on its kept prefix (WA MR2 F4-A P1): after a stop of a paused run, the failed run's last step commit ends with the discarded call.
resume(agent, sessionId, { mode: 'continue', owedRunId }) (JsOwedContinueOptions, WA MR2 F4-A D1; the Cloudflare DO's submit continuation) continues only while owedRunId is still the session's current run, suspended, over a paused session. Otherwise it writes nothing and rejects RunStartConflictError (expected_mismatch, classified lost): a stop that ended the run before the continuation's entry is never followed by a heal and a model call.
stop
Stop the session's current run without a handle or an agent definition (WA MR2):
const answer = await executor.stop('session-123', {
kind: 'interrupt',
reason: 'user pressed stop',
});
// { status: 'stopping', runId, kind } | { status: 'interrupted' | 'failed', runId }
// | { status: 'not_stoppable', currentStatus }It is core stopRun through this executor's owner probe: a run executing in this process is signalled; a run another process executes under a live lease (leaseUntil plus one lease TTL ahead of the local clock) gets the durable record only (stopping; that owner ends it at its next boundary); a paused run, or one whose owner is gone, is ended now (interrupted / failed). No live run answers not_stoppable and writes nothing. A missing session throws state_session_not_found. The stop carries no deadline (deadlineAt: null) unless the executor was built with the stopDeadline: { deadlineMs, onStopping? } option (the Cloudflare DO passes its stopDeadlineMs, and re-arms its alarm from onStopping): then every stop the executor records, handle stops and the in-process cascade included, gets deadlineAt = requestedAt + deadlineMs. A stop that names no reason records core DEFAULT_INTERRUPT_REASON / DEFAULT_ABORT_REASON (stopReasonOrDefault), as the handles do. Pass agent to let the stop reach the run's remote children through the agent's remote sub-agent tools (remoteTransportsOf); without it, remote children are not reached.
remoteTransportsOf(tools) (exported, with its type RemoteTransportFor) maps a remote child's SubSessionRef to the transport of the agent's subagent__<type> remote tool; the Cloudflare DO's stop cascade uses it too.
AgentExecutionHandle
Handle returned by execute() for interacting with a running agent.
Properties
handle.sessionId; // Unique session identifier (readonly)stream
Get a stream of events from the execution. Returns null if streaming is not available.
const stream = await handle.stream();
if (stream) {
for await (const chunk of stream) {
switch (chunk.type) {
case 'text_delta':
process.stdout.write(chunk.delta);
break;
case 'tool_start':
console.log(`Tool: ${chunk.toolName}`);
break;
case 'tool_end':
console.log(`Result: ${JSON.stringify(chunk.result)}`);
break;
}
}
}result
Wait for the execution to complete and get the result.
const result = await handle.result();
if (result.status === 'completed') {
console.log('Output:', result.output);
} else if (result.status === 'failed') {
console.error('Error:', result.error);
}Returns:
interface AgentResult<TOutput> {
sessionId: string;
runId: string;
status:
| 'completed'
| 'failed'
| 'interrupted'
| 'suspended_client_tool'
| 'suspended_awaiting_children'
| 'suspended_step_partial'
| 'superseded';
output?: TOutput;
error?: string;
errorDetail?: ErrorDetail;
// ... suspension routing and usage fields
}superseded means a newer entry took the session over while this run was still writing (see Run start and the run lease). The run wrote nothing more, and neither onAgentComplete nor onAgentFail fires (onAgentSuperseded does) (one exception: a supersede that lands after onAgentComplete fired and before the run's terminal commit leaves onAgentComplete then onAgentSuperseded; the last terminal hook is authoritative).
abort
Cancel the execution. This is a HARD stop - the run ends failed with framework_cancelled.
await handle.abort('User requested cancellation');interrupt
Interrupt execution for later resumption. This is a SOFT stop - the agent can be resumed.
await handle.interrupt('user_requested');
// The run ends at its next boundary
const result = await handle.result();
console.log(result.status); // 'interrupted'Both stop the handle's own run through core stopRun and resolve once the stop is durable (WA MR2): they never wait for the run to end. A suspended run is ended at once; on a run that already ended the call resolves and writes nothing. An interrupted run keeps every committed step; the step in flight is not committed. Use resume() to continue execution.
canResume
Check if execution can be resumed. A reconnect handle (getHandle()) answers from a fresh read of the session: once the session is deleted it answers { canResume: false, reason: 'State not found' }, and its resume() throws a typed state_session_not_found (sessionNotFoundError) before it starts anything. A completed or failed session is refused with AgentNotResumableError (state_not_resumable, non-retryable), also before anything starts.
const { canResume, reason } = await handle.canResume();
if (canResume) {
const resumed = await handle.resume();
}Returns:
interface CanResumeResult {
canResume: boolean;
reason?: string; // Why resume isn't possible
}resume
Resume interrupted or paused execution. Returns a new handle for the resumed execution.
// Continue from where it stopped
const newHandle = await handle.resume();
// Resume with a new message
const newHandle = await handle.resume({
mode: 'with_message',
message: 'Continue with this context',
});
// Resume with multiple messages
const newHandle = await handle.resume({
mode: 'with_message',
message: [
{ role: 'user', content: 'Additional context from the system' },
{ role: 'user', content: 'Please continue with this focus' },
],
});
// Resume with confirmation data
const newHandle = await handle.resume({
mode: 'with_confirmation',
data: { approved: true },
});
// Time-travel to a specific checkpoint
const newHandle = await handle.resume({
mode: 'from_checkpoint',
checkpointId: 'cpv1-session-123-s5-...',
});Resume Modes:
| Mode | Description |
|---|---|
continue | Resume from last checkpoint (default) |
with_message | Resume with a new user message; with abandonPending: true, also answer every unanswered client call client_tool_abandoned (one entry). Drained results always precede the message |
with_confirmation | Resume with data for pending tool |
from_checkpoint | Time-travel to specific checkpoint |
from_checkpoint truncates the durable message log to the checkpoint's messageCount and restores its state on this session; pending client-tool calls made after it get an aborted tool_end. The checkpoint must belong to this session: state_checkpoint_not_found / state_checkpoint_foreign are thrown before any change.
The truncation rides the resume's run-start commit (StartRun.truncateTo, planned by core planRewindToCheckpoint), so the rewind and the new run land together or not at all:
- Concurrent resumes give at most one rewind and one run. Each loser throws
AgentAlreadyRunningErrorhaving written nothing. - A throw before the run-start commit changes nothing (no truncation, no status change).
- A throw after the commit, before the loop runs ends the new run
failed(releasing its lease) and rethrows. When the rewind was of acompleted/failedsession, the session is also committedfailedwith a typederrorDetailand its stream failed, so its old status is never shown over a rewound log. - Pending calls the rewind keeps are re-stamped to the new run inside the same commit. The legacy
rewindHeldhold of earlier releases is no longer written.
retry
Retry a failed execution.
const result = await handle.result();
if (result.status === 'failed') {
const retryHandle = await handle.retry();
// Or with options
const retryHandle = await handle.retry({
message: 'Let me try again...',
});
}Signature:
retry(options?: RetryOptions): Promise<AgentExecutionHandle<TOutput>>Throws: Error if not in 'failed' status
The default target is the failed run's last step boundary (core planRetry), never the terminal commit the failure wrote: the run's latest step commit (or a later append commit of that run with no assistant row). A run that failed on its first step is retried from its run-start commit: the log is rewound to just before the run's own triggering user message, and the retried run appends that message again, then options.message when given. With no message, retry() re-runs the trigger (it used to throw, CP-58). The restored custom state, step count and forced completion come from that target. An explicit checkpointId is restored as given. A session that failed before its last entry's run-start commit keeps its log and needs options.message (validation_error otherwise). A session with no checkpoint at all restarts fresh.
RetryOptions
Options for the retry() operation.
interface RetryOptions {
checkpointId?: string;
message?: string;
abortSignal?: AbortSignal;
}| Property | Type | Default | Description |
|---|---|---|---|
checkpointId | string | The failed run's last step boundary | Checkpoint to restore from. Unknown → state_checkpoint_not_found; another session's → state_checkpoint_foreign (both before any change) |
message | string | Original | Message appended after the re-run trigger (first-step failure) or after the restored prefix. Required only when the session failed before its run-start commit |
abortSignal | AbortSignal | - | Abort signal |
retry() also honours usageStore (core RetryOptions.usageStore), as execute() and resume() do.
Entry cancellation, refusals and pre-commit failures
options.abortSignalis honoured on every entry.execute(),resume(),retry()andrecover()compose the caller's signal with the executor's own, and check it immediately before the run-start commit. An entry whose signal is aborted before its commit (an already-aborted signal included) rejects with aHelixErrorframework_cancelled(non-retryable; no handle is returned) and writes nothing to a session that has a run.- Typed resume refusals. A
resume()the session's own status refuses throws coreResumeRefusedError(state_not_resumable, withsessionStatusandawaitingToolCallIds): acompletedorfailedsession (non-retryable), or apausedsession still awaiting client-tool results (retryable; the message names the awaited call ids first:Awaiting client tool call(s) <ids>: submit every result of the batch, then resume session '<id>'.). - A pre-commit failure on a never-run session (
execute()/retry(), any class: a short first history read, a cancellation, a store error) records the SESSIONfailedwith the classifiederrorDetailthrough corefailSessionWithoutRun({ currentRunId: null }): idle-fenced, so an entry that committed meanwhile wins; no stream is created or written and no lifecycle hook (onAgentFail) fires. A session that already has a run is left untouched. A pre-commitresume()failure writes nothing. See Never-run sessions.
Client-tool submits
JSAgentExecutor.submitToolResult(params) owns the whole submit:
- A sub-agent's own session (its state names a different root): the submit goes through the resolver's locked path and is validated against the owning tool's
outputSchema. Anapproval-responseis mapped to{ approved, reason }by the resolver itself. - A call that has not committed (CP-253). Every call's routing entry is written before its frame, so an answer can arrive before the suspension commits. Both paths wait for that commit, up to the executor option
submitOwnerReadyBoundMs(default 5 s), storing nothing: thenSubmitOwnerNotReadyError(state_submit_owner_not_ready, retryable, HTTP 503), and the client sends the same answer again. With no pending entry and no run that could commit one, the answer isunknown_tool_call. There is no pre-submit stub on this runtime. The wait keys on the session's run, so an id no call ever had, or an entry a crash left behind, answers the retryable error while that run is executing andunknown_tool_callonce it is not: an accepted cost, nothing is stored either way. - A root session: core
routeSubmitToolResultfinds the owner. A call another host owns is delivered through the executor optionsubmitForwarder?: SubmitForwarder((ownerSessionId, submit) => Promise<SubmitToolResultResponse>; the Cloudflare DO supplies one that forwards to the owning DO), else applied locally. - With an
agentRegistryconfigured, a submit whose submitting session's agent type the registry does not know failsframework_agent_not_foundinstead of skipping output validation. A store read failure propagates. - A call of a stopped run (WA MR2 §7.1). Both paths read the stop of the call's run before they store anything (core
refuseSubmitIfStopped). A recorded stop (not yet resolved) is settled — by default the same in-process stop a handle makes (stopInProcessRun: re-signal a run executing here, else the stop's K5 now, then the cascade); the executor optionsettleStoppedSubmit?: StoppedSubmitSettlereplaces it (the Cloudflare DO passes its own stop path) — and the answer is{ status: 'unknown_tool_call', reason: 'run_stopped' }: nothing is stored and no continuation is decided. A run the stop already ended answers the same while the call is still unpaired in the log. with_messageover a stopped run (§7.3, Ruling 57). A recorded stop on the paused run is settled BEFORE the batch gate (as acontinuedoes, Ruling 41): the run endsinterrupted, its pending calls are discarded, and the entry re-plans once as a new turn — with or withoutabandonPending(a no-op over the now-ended session), never the partial-batch refusal. A stop already recorded on arunningrun is met by the run-start (expected_run_stopped) the same way. An abort ends the sessionfailed(framework_cancelled): the re-plan is then refused typed (ResumeRefusedError,state_not_resumable;retry()is the path). Without a stop, a partial batch withoutabandonPendingis still refused. An entry whose plan meets pending state its batch gate ruled out because another run took the session over inside its window loses typed (AgentAlreadyRunningError), writing nothing.execute()over a stopped run (CP-244, Ruling 57a). A new turn over a session paused on client calls whose run has a recorded stop settles that run by its stop before the paused-with-pending refusal (C-4), as awith_messageentry does, and re-plans once as a new turn. The stopped run endsinterrupted(orfailedframework_cancelledfor an abort, after which the turn still runs), and its pending calls are paired once before the new input. Without a stop, C-4 still refuses (AgentAlreadyRunningError). The Cloudflare DO runs this path.onStoppedRunSettledByEntry?: StoppedRunSettledByEntry(executor option, WA MR2): a NEW-turn entry (execute,resumewith_message/from_checkpoint,retry) whose run-start met a live run with a recorded stop settles that run by its stop and re-plans; this callback reports the stopped run's settled outcome once ({ sessionId, runId, result }), so a host can deliver it (the Cloudflare DO'sonRunSettled). A continuation never calls it: its own result is the stopped run's outcome.
JsClientToolResolver.submit is internal: call executor.submitToolResult. A call's completed-tombstone retention is recorded on its pending entry when it registers (PendingClientToolCall.completedRetentionMs, from the agent's completedTombstoneRetentionMs, else the 24 h default; registerPending takes an optional completedRetentionMs), and the clear path reads it from there. The resolver's former getCompletedRetentionMsForRoot option is removed.
getState
Get current agent state.
const state = await handle.getState();
console.log('Status:', state.status);
console.log('Step count:', state.stepCount);
console.log('Messages:', state.messages.length);send
Continue the conversation after completion. Returns a new handle. Accepts a string or UserInputMessage[].
const handle1 = await executor.execute(agent, 'Hello');
await handle1.result();
const handle2 = await handle1.send('Tell me more');
const result = await handle2.result();
// Or with multiple messages
const handle3 = await handle1.send([
{ role: 'user', content: 'System context: user upgraded to pro', metadata: { source: 'system' } },
{ role: 'user', content: 'What new features can I use?' },
]);Usage Example
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/store-memory';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
import { defineAgent, defineTool } from '@helix-agents/core';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
// Define agent
const MyAgent = defineAgent({
name: 'my-agent',
systemPrompt: 'You are a helpful assistant.',
outputSchema: z.object({ response: z.string() }),
tools: [
defineTool({
name: 'greet',
description: 'Generate a greeting',
inputSchema: z.object({ name: z.string() }),
outputSchema: z.object({ greeting: z.string() }),
execute: async ({ name }) => ({ greeting: `Hello, ${name}!` }),
}),
],
llmConfig: { model: openai('gpt-6-luna') },
});
// Create executor
const executor = new JSAgentExecutor(
new InMemoryStateStore(),
new InMemoryStreamManager(),
new VercelAIAdapter()
);
// Execute agent
const handle = await executor.execute(MyAgent, 'Greet John');
// Stream results
for await (const chunk of (await handle.stream()) ?? []) {
if (chunk.type === 'text_delta') {
process.stdout.write(chunk.delta);
}
}
// Get final result
const result = await handle.result();
console.log('\nOutput:', result.output);Behavior
Parallel Tool Execution
The JS runtime executes independent tool calls in parallel:
// If LLM requests multiple tool calls, they run concurrently
const tools = [searchTool, fetchTool, analyzeTool];
// All three execute in parallelSub-Agent Handling
Sub-agents are executed recursively in the same process:
// When a sub-agent tool is invoked:
// 1. New execution context is created
// 2. Sub-agent runs to completion
// 3. Result is returned to parentPersistent companions (DO companion lifecycle MR B). companion__spawnAgent answers core CompanionSpawnResult and companion__sendMessage core CompanionDeliveryOutcome. A continue of a completed child is opened only by the child's own run-start commit (CP-155): the commit gates on the child's completed session, turns the parent's ref running inside itself (StartRun.resumedCompanionRef) and writes the turn's entry state; the session's completed → active reopen follows it, fenced on the new run. So a continue that does not start writes nothing but the prior hold, and answers typed (core companionStartOutcome), a successful tool result: { name, status: 'not_spawned', delivered: false, reason } / { delivered: false, reason } with child_busy (a concurrent entry won; the child's errorDetail when it has since failed), child_terminated (a terminate landed first, CP-162), child_entry_failed (a typed refusal or a failed pre-commit read, with its errorDetail) or child_start_unconfirmed (the commit won but the start could not be confirmed). A commit that landed though the call threw (a lost acknowledgement) is found by abandonRunStart on the run id the entry minted (committed; an abandoned id refuses a commit still in flight), and the continue proceeds as started. The continue HOLDS the child's undelivered outcome before its commit (SessionStateStore.holdPriorResult, at the generation of the same ref read) and delivers it as priorResult beside its own answer, started or not; the step commit (commitStep, the suspension commit) releases the hold for a row that carries the prior, and the notifier's held-prior pass at the top of the next iteration settles anything the seam did not (a notice for an outcome whose answer never landed, a release only for one found committed). A blocking spawn's failed answer carries the child's persisted errorDetail.
A first spawn (and a respawn over a failed / terminated child) is answered only after the child's run-start commit (CP-161): spawnPersistentChild mints the child's run id and awaits its entry — the completion-support refusal, createSession (a failure is decided by a sessionExists re-read: the session exists → someone else created it and the entry proceeds; else the entry ends typed with the failure's own detail), the run-start commit under the child's abort signal (a parent stop before the write cancels it), then run_started and the lease — and only then marks the child live and runs its loop detached. The run-start commit always carries the spawn's initialMessage, so the run that wins holds its own input whichever entry created the session. While the entry is awaited the child carries an in-memory "starting" mark beside the liveChildExecutions registry, which companion__waitForResult's orphan detection treats as live, so a slow entry is never compensated failed (the mark dies with the process, as the registry and its detector do; recovery reads only the store). So spawned means the child's run-start committed, at a cost of about 4–6 store / stream round-trips before the answer; a start that does not happen is answered not_spawned with its reason and errorDetail (blocking too, before any park), with no usage pointer. Every throw goes through core companionStartOutcome (a committed verdict adopts the run). A lost claim whose winning run serves THIS call and parent session (a re-run of an uncommitted parent step) attaches (spawned); a blocking attach parks on the child's session, bounded by that run's lease: once its leaseUntil has not moved for one whole lease TTL (runStartIdentity's, 30 s by default) and no other run took the child, its owner is dead and the park answers failed, the ref written failed, delivered — orphan detection's answer. A non-start reads the child's current run: while another run owns it (or it cannot be read), nothing is written — no ref, no session failure (the loser of a race never touches the winner). Otherwise a first spawn's child_busy writes no ref (another entry of the child won), an entry the parent's own stop cancelled writes no ref, and the other definite non-starts write the ref failed (core); every child_entry_failed (a respawn's included) fails the never-run child session on the session only, with the sanitized detail (failSessionWithoutRun), so it is never read active.
import type { CompanionNotifier } from '@helix-agents/runtime-js';
// The `companionNotifier` executor option (the CF Durable Object supplies it: its consult outcome store):
interface CompanionNotifier {
outcomeOf(
parentSessionId: string,
ref: SubSessionRef
): Promise<{ output?: unknown; error?: string; runId: string } | null>;
}runId is REQUIRED: the notifier keys each completion message metadata.helixCompanionNotice = { subSessionId, runId }, and only a key with a run dedupes a recovery's re-injection (a key of { subSessionId } alone would match a re-consulted child's first notice and drop its second outcome). null means "no recorded outcome for this ref yet": nothing is delivered or marked for it.
Abort Handling
Abort signals are propagated to tool executions:
defineTool({
execute: async (input, context) => {
// Check abort signal
if (context.abortSignal?.aborted) {
throw new Error('Execution aborted');
}
// Long-running operation
await doWork();
},
});Interrupt and Resume
The JS runtime supports full interrupt/resume functionality:
// Start execution
const handle = await executor.execute(MyAgent, 'Research AI');
// Later, interrupt
await handle.interrupt('user_requested');
// Even later, resume
const { canResume } = await handle.canResume();
if (canResume) {
const resumed = await handle.resume();
const result = await resumed.result();
}Crash Recovery
With a persistent state store (Redis), execution can be resumed after process restarts:
import { RedisStateStore, RedisStreamManager } from '@helix-agents/store-redis';
const executor = new JSAgentExecutor(
new RedisStateStore({ host: 'localhost' }),
new RedisStreamManager({ host: 'localhost' }),
new VercelAIAdapter()
);
// After crash, reconnect to existing session
const handle = await executor.getHandle(MyAgent, savedSessionId);
if (handle) {
const { canResume } = await handle.canResume();
if (canResume) {
const resumed = await handle.resume();
}
}Distributed Coordination
For multi-process deployments, use a lock manager to prevent concurrent execution of the same session. Lock managers are standalone components — acquire a lock before calling the executor:
import { RedisLockManager } from '@helix-agents/store-redis';
const lockManager = new RedisLockManager(redis);
// Acquire lock before executing
await lockManager.withLock(`session:${sessionId}`, async () => {
await executor.execute(agent, { message: 'Hello' }, { sessionId });
});See Distributed Coordination for details on lock manager implementations.
Request keys
JSAgentExecutor declares supportsRequestKey: true. execute(), resume() and retry() take requestKey (see request keys): the key is looked up before anything else. A live run holding it, or a finished one that is still the session's current run, returns a handle on that run (handle.attached: true; its result() is that run's result, read back from the store when the run executes elsewhere). A finished one with a later run rejects RequestAlreadyCompletedError; the key reused for other input rejects RequestKeyConflictError. None of these writes anything. An entry that loses a race (request_key_taken, or an AgentAlreadyRunningError) re-plans from the key and attaches to the winner.
Run start and the run lease
Every entry into running (execute(), resume() in every mode, retry(), a client-tool or children continuation, an in-process child run) goes through commitRunStart:
- The run-start commit:
appendMessages(sessionId, inputRows, { streamSequence, runId, startRun }), possibly with zero rows, and withstartRun.truncateTofor a rewinding entry. The store decides atomically: idempotency, a compare-and-set on the run the entry saw (its id, then its status:expectCurrentRunStatus), the owner guard, the session gate (the session's status must still be one ofstartRun.entryStatuses), and superseding every other live run. See Entry gates. - For the winner only: claim the stream (a compare-and-set on its owner), make it live, write
run_started.
A loser writes nothing (no session state, no stream status change, no chunk). An entry that lost to another run (a newer run started, or the run it saw moved on) throws RunStartConflictError, which keeps the name AgentAlreadyRunningError; an entry whose session left the status it planned from throws RunStartRejectedError('entry_status_changed'). If the winner's stream steps fail, the run is ended failed and the caller gets RunEntryFailedError (its errorDetail is the original failure's).
The run lease. A JS run owns its session through a lease kept on the store's clock. Each run gets a fresh owner token. The executor renews the lease every ttlMs / 3 (renewRunLease); the lease is released by the run's suspension or terminal commit. Every chunk the run writes is gated on the lease locally. An entry whose run-start commit finds a live run with a valid lease is refused (run_active); once the lease has expired, the entry supersedes that run. An event loop blocked for longer than about ttlMs - ttlMs / 3 can therefore let a concurrent entry supersede a run that is still working: that run then stops, typed, with status: 'superseded' (never failed). In-process child runs keep their own lease.
| Export | Description |
|---|---|
JS_RUN_LEASE_MS | The default lease period, 30 000 ms (core DEFAULT_RUN_LEASE_TTL_MS). |
JSRunLeaseOptions | { ttlMs?, setTimer?, clearTimer? }, the executor's runLease option. The timers are injectable for tests. |
commitRunStart(input) | The JS / DO run-start entry described above. Returns CommitRunStartResult { previousRun, turn, plan, grantedAt }. |
CommitRunStartInput | The entry's store, stream manager, streamId, sessionId, runId, plan(attempt) (returns the required entryStatus, the session status it planned from), seenRun ({ runId, status } | null; omitted for child runs), identity, ownsStream, ... |
RunStartIdentity | Who drives the run: { mintOwnerToken, ownerless, committer? } (JS mints a token per run; the DO asserts ownerless). |
RunStartCommitter / RunStartEntryState | The Cloudflare DO's synchronous claim-block committer and the entry state it writes in the same transaction. RunStartEntryState carries only build, which returns a RunStartEntrySessionState whose required status (RunStartEntryStatus: active, or paused for a rewind that re-suspends at once on a still-partial restored batch, R47) the committer writes as is; the session gate rides StartRun.entryStatuses, which every store enforces. |
SubmitForwarder | The submitForwarder executor option's type (see Client-tool submits). |
finishCommittedRun / committedTerminalStatus (and their types) | Re-exported from core for the DO: finish a run whose terminal commit already landed. |
commitRunStart and the RunStart* types are exported for the Cloudflare DO runtime and cross-runtime tests; an application does not call them.
executor.recover(agent, sessionId, { runId, usageStore?, abortSignal? }) (internal, used by the Cloudflare DO when an evicted instance wakes with a stranded run) is not an entry: it writes no run-start commit and no run_started. It re-enters the loop under the SAME runId, and refuses with RunSupersededError unless that run is still the session's current running run.
Concurrency Safety
The JS runtime prevents concurrent executions on the same session.
AgentAlreadyRunningError
Thrown when attempting to execute/resume/retry a session that is already running or when a concurrent operation has claimed it.
import { AgentAlreadyRunningError } from '@helix-agents/core';
try {
await executor.execute(agent, message, { sessionId });
} catch (error) {
if (error instanceof AgentAlreadyRunningError) {
console.log(`Session ${error.sessionId} is already running`);
console.log(`Current status: ${error.currentStatus}`);
}
}Thrown by:
execute()- When session status is 'running'resume()- When CAS failsretry()- When CAS fails- any entry that loses its run-start commit (
RunStartConflictError,conflictCause: 'run_active' | 'expected_mismatch')
resume() terminal-session refusals are ResumeRefusedError, not AgentNotResumableError (DI-31)
Before the CAS, resume() in continue or with_message mode refuses a completed or failed session with a typed ResumeRefusedError (state_not_resumable, non-retryable), so instanceof AgentNotResumableError does not match it; match on the code. A running session gets AgentAlreadyRunningError in every mode, from_checkpoint included. See DI-31 in the conformance findings.
StaleStateError
Thrown when state version conflicts during save (optimistic locking failure).
import { StaleStateError } from '@helix-agents/core';
// Indicates another process modified the state
// The current process should stop executionLimitations
- No built-in durability - Requires persistent stores for crash recovery
- Single process - No automatic distribution across workers
- No timeout isolation - Tool timeouts must be handled manually
For production workloads requiring built-in durability, consider @helix-agents/runtime-temporal.