Skip to content

@helix-agents/runtime-js ​

JavaScript runtime for in-process agent execution. Non-durable execution suitable for development, testing, and single-process deployments.

Installation ​

bash
npm install @helix-agents/runtime-js

JSAgentExecutor ​

The main executor class for running agents in-process.

Constructor ​

typescript
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.

typescript
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 from defineAgent()
  • input - String message, or { message: string | UserInputMessage[], state?: Partial<TState> }

Returns: AgentExecutionHandle<TOutput>

getHandle ​

Get a handle to an existing execution.

typescript
const handle = await executor.getHandle(MyAgent, 'session-123');

if (handle) {
  const result = await handle.result();
}

Parameters:

  • agent - Agent configuration
  • sessionId - Session identifier

Returns: AgentExecutionHandle | null

canResume ​

Check if an execution can be resumed.

typescript
const result = await executor.canResume(MyAgent, 'session-123');

if (result.canResume) {
  // Can continue execution
} else {
  console.log('Cannot resume:', result.reason);
}

Returns:

typescript
interface CanResumeResult {
  canResume: boolean;
  reason?: string;
  state?: AgentState;
}

resume ​

Resume a paused or interrupted execution.

typescript
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):

typescript
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 ​

typescript
handle.sessionId; // Unique session identifier (readonly)

stream ​

Get a stream of events from the execution. Returns null if streaming is not available.

typescript
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.

typescript
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:

typescript
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.

typescript
await handle.abort('User requested cancellation');

interrupt ​

Interrupt execution for later resumption. This is a SOFT stop - the agent can be resumed.

typescript
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.

typescript
const { canResume, reason } = await handle.canResume();

if (canResume) {
  const resumed = await handle.resume();
}

Returns:

typescript
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.

typescript
// 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:

ModeDescription
continueResume from last checkpoint (default)
with_messageResume 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_confirmationResume with data for pending tool
from_checkpointTime-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 AgentAlreadyRunningError having 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 a completed / failed session, the session is also committed failed with a typed errorDetail and 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 rewindHeld hold of earlier releases is no longer written.

retry ​

Retry a failed execution.

typescript
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:

typescript
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.

typescript
interface RetryOptions {
  checkpointId?: string;
  message?: string;
  abortSignal?: AbortSignal;
}
PropertyTypeDefaultDescription
checkpointIdstringThe failed run's last step boundaryCheckpoint to restore from. Unknown → state_checkpoint_not_found; another session's → state_checkpoint_foreign (both before any change)
messagestringOriginalMessage 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
abortSignalAbortSignal-Abort signal

retry() also honours usageStore (core RetryOptions.usageStore), as execute() and resume() do.

Entry cancellation, refusals and pre-commit failures ​

  • options.abortSignal is honoured on every entry. execute(), resume(), retry() and recover() 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 a HelixError framework_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 core ResumeRefusedError (state_not_resumable, with sessionStatus and awaitingToolCallIds): a completed or failed session (non-retryable), or a paused session 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 SESSION failed with the classified errorDetail through core failSessionWithoutRun({ 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-commit resume() 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. An approval-response is 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: then SubmitOwnerNotReadyError (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 is unknown_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 and unknown_tool_call once it is not: an accepted cost, nothing is stored either way.
  • A root session: core routeSubmitToolResult finds the owner. A call another host owns is delivered through the executor option submitForwarder?: SubmitForwarder ((ownerSessionId, submit) => Promise<SubmitToolResultResponse>; the Cloudflare DO supplies one that forwards to the owning DO), else applied locally.
  • With an agentRegistry configured, a submit whose submitting session's agent type the registry does not know fails framework_agent_not_found instead 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 option settleStoppedSubmit?: StoppedSubmitSettle replaces 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_message over a stopped run (§7.3, Ruling 57). A recorded stop on the paused run is settled BEFORE the batch gate (as a continue does, Ruling 41): the run ends interrupted, its pending calls are discarded, and the entry re-plans once as a new turn — with or without abandonPending (a no-op over the now-ended session), never the partial-batch refusal. A stop already recorded on a running run is met by the run-start (expected_run_stopped) the same way. An abort ends the session failed (framework_cancelled): the re-plan is then refused typed (ResumeRefusedError, state_not_resumable; retry() is the path). Without a stop, a partial batch without abandonPending is 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 a with_message entry does, and re-plans once as a new turn. The stopped run ends interrupted (or failedframework_cancelled for 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, resume with_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's onRunSettled). 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.

typescript
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[].

typescript
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 ​

typescript
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:

typescript
// If LLM requests multiple tool calls, they run concurrently
const tools = [searchTool, fetchTool, analyzeTool];
// All three execute in parallel

Sub-Agent Handling ​

Sub-agents are executed recursively in the same process:

typescript
// When a sub-agent tool is invoked:
// 1. New execution context is created
// 2. Sub-agent runs to completion
// 3. Result is returned to parent

Persistent 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.

typescript
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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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:

  1. The run-start commit: appendMessages(sessionId, inputRows, { streamSequence, runId, startRun }), possibly with zero rows, and with startRun.truncateTo for 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 of startRun.entryStatuses), and superseding every other live run. See Entry gates.
  2. 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.

ExportDescription
JS_RUN_LEASE_MSThe 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 }.
CommitRunStartInputThe 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, ...
RunStartIdentityWho drives the run: { mintOwnerToken, ownerless, committer? } (JS mints a token per run; the DO asserts ownerless).
RunStartCommitter / RunStartEntryStateThe 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.
SubmitForwarderThe 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.

typescript
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 fails
  • retry() - 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).

typescript
import { StaleStateError } from '@helix-agents/core';

// Indicates another process modified the state
// The current process should stop execution

Limitations ​

  • 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.

See Also ​

Released under the MIT License.