pleach
Architecture

Async tasks

Long-running job dispatch + cancellation — the manager, the executor contract, and the per-thread reducer.

AsyncTaskManager is the per-runtime catalog of in-flight long-running jobs — subagent runs, sandboxed code execution, anything a tool kicks off that outlives a single LLM turn. Tasks dispatch through a registered AsyncTaskExecutor keyed by AsyncTask.type, persist through an injected AsyncTaskStore, and surface to tools through a small set of synchronous read methods. See Channels for the asyncTasks channel the reducer keys into, and Subagents for the in-tree "subagent" task kind.

import {
  AsyncTaskManager,
  asyncTasksReducer,
  type AsyncTask,
  type AsyncTaskExecutor,
  type AsyncTaskStore,
} from "@pleach/core/async";

AsyncTask.type is string, not a closed union. The only in-tree executor is SubagentTaskExecutor — auto-registered under the "subagent" kind when the manager is lazily built, but its execute throws [SubagentTaskExecutor] SubagentExecutor is server-only, so it does no work on bare @pleach/core until the host wires the server-side executor. There is no "sandbox_exec" executor class in-tree: "sandbox_exec" is an example kind a host must supply. Neither SubagentTaskExecutor nor any sandbox_exec executor is exported from @pleach/core/async (the barrel exposes the AsyncTaskExecutor contract, not the implementations). Domain plugins register additional kinds through HarnessPlugin.registerAsyncExecutors; other kinds are host/plugin-supplied obligations.

The runtime.async facet

The canonical access path is the runtime.async facet — the flat runtime.getAsyncTaskManager() / runtime.spawnAgent(...) methods remain callable but are @deprecated per the facet migration sweep.

MemberReturnsNotes
runtime.async.taskManagerAsyncTaskManager | undefinedThe catalog described below; lazily wired.
runtime.async.managerSubagentManager | undefinedThe sub-agent registry (see Subagents).
runtime.async.spawn(config, options?)Promise<SubAgentResult>Imperative sub-agent spawn.
runtime.async.spawnStream(config, options?)AsyncGenerator<StreamEvent>Streaming variant.
runtime.async.getResult(subAgentId)SubAgentResult | undefinedReplay a recorded sub-agent result.
runtime.async.getResultWithContext(subAgentId)SubAgentResultWithContext | undefinedSame payload plus the execution context (trace anchor, parent ids, timings) captured at completion time. Use for deterministic replay / re-attribution into a parent graph turn.

runtime.async.taskManager is the entry point for arbitrary async-task kinds ("sandbox_exec", plugin-registered) — every method on the table below is reachable through it. The two getResult accessors are the sub-agent counterpart and key on the sub-agent id, not the taskId. See Facets for the broader facet surface.

AsyncTask shape

FieldTypeNotes
taskIdstringtask_<ms>_<rand> — generated at start
typestringExecutor key ("subagent", "sandbox_exec", plugin-registered)
threadIdstringSession id; scopes the reducer namespace
statusenumqueued / running / success / error / cancelled / timeout
inputrecordOpaque to the manager; the executor reads it
resultrecord?Set on success
errorstring?Set on error / timeout
createdAtISO-8601Frozen at start
updatedAtISO-8601?Bumps on every status transition
checkedAtISO-8601?Set by check() when the executor refreshes status
agentNamestring?Free-form provenance (subagent name, etc.)
jobTypestring?Free-form provenance
timeoutMsnumber?Optional hard ceiling — flips status to timeout and aborts

The AsyncTaskExecutor contract

The seam each task kind implements:

interface AsyncTaskExecutor {
  execute(taskId: string, input: Record<string, unknown>): Promise<Record<string, unknown>>;
  checkStatus?(taskId: string): Promise<Partial<AsyncTask>>;
  sendUpdate?(taskId: string, message: string): Promise<void>;
  cancel?(taskId: string): Promise<void>;
}

execute is the only required method. The optional three are how the manager promotes a polling-only executor into one that supports live status, inbound messages, and remote cancellation. The manager calls each method only if the executor declares it; otherwise it falls back to the in-memory task as the source of truth.

AsyncTaskManager methods

MethodReturnsNotes
registerExecutor(type, executor)voidWire a kind. Last registration wins.
start(options)Promise<AsyncTask>Persists queued, dispatches, returns the seeded task.
startBackground(options){ taskId, status, error? }Synchronous launch — returns immediately, fires persist + execute in the background.
check(taskId)Promise<AsyncTask | null>Calls executor.checkStatus if defined; merges + persists.
getTaskSync(taskId)AsyncTask | nullIn-memory read; no executor round-trip.
update(taskId, message)Promise<boolean>Forwards to executor.sendUpdate if defined.
cancel(taskId)Promise<boolean>Aborts, calls executor.cancel best-effort, marks cancelled.
list(filter?)AsyncTask[]In-memory filter on threadId / type / status.
get(taskId)AsyncTask | nullSynchronous in-memory lookup.

startBackground is a synchronous launch helper — it returns a taskId inside the same tick, then fires persist + execute in the background. It is a host/manager entry point, not the LLM tool path: in bare @pleach/core the start_async_task tool is not bridged to it. The tool-loop short-circuit handler for start_async_task (and check_async_task / update_async_task / cancel_async_task) returns { success: false, error: "ASYNC_TASK_BRIDGE_NOT_WIRED" } and steers the model to call the underlying tool directly instead — AsyncTaskManager.start() is server-side only and no bridge routes the tool call through it.

Cancellation semantics

cancel(taskId) is a no-op if the task isn't in queued or running — terminal states stay terminal. When the task is live:

  1. The AbortController for the task fires; any in-flight executor work observing controller.signal short-circuits.
  2. If the executor implements cancel, the manager calls it best-effort — exceptions are swallowed.
  3. The task transitions to cancelled and persists.

The signal.aborted check inside the background runner means a result that lands after the abort is dropped — late success or error writes from the executor do not overwrite the cancelled status.

Subagent cancellation is currently a console.warn placeholder; the in-tree SubagentTaskExecutor.cancel doesn't wire through to the running subagent. Treat cancellation as cooperative for now.

The reducer

asyncTasksReducer is the per-channel merge function on the asyncTasks channel. The contract is one line:

const asyncTasksReducer = (
  existing: Record<string, AsyncTask> = {},
  update:   Record<string, AsyncTask> = {},
): Record<string, AsyncTask> => ({ ...existing, ...update });

Idempotency rides on taskId — a re-applied update for the same taskId produces the same final map. Updates merge per-key, so a partial update writing only { [taskId]: { status: "success", ... } } replaces the prior entry for that id and leaves siblings untouched.

AsyncTaskStore

The persistence seam. AsyncTaskManager keeps the catalog in memory as the source of truth and persists each transition through the store on a best-effort path — put failures are swallowed.

interface AsyncTaskStore {
  put(namespace: string[], key: string, value: Record<string, unknown>): Promise<void>;
  get(namespace: string[], key: string): Promise<Record<string, unknown> | null>;
}

Namespace is ["async_tasks", threadId]; key is the taskId. The manager never reads from the store on its own — it's a write-through log for offline inspection. Tasks do not write to the event log or the audit ledger directly; status transitions show up at the channel boundary when the surrounding turn checkpoints.

Wiring an executor

import { AsyncTaskManager } from "@pleach/core/async";

const manager = new AsyncTaskManager(myStore);

manager.registerExecutor("sandbox_exec", {
  async execute(taskId, input) {
    const result = await runInSandbox(input.code as string);
    return { stdout: result.stdout, exitCode: result.exitCode };
  },
  async cancel(taskId) {
    await killSandbox(taskId);
  },
});

const task = await manager.start({
  type:     "sandbox_exec",
  threadId: sessionId,
  input:    { code: "print('hi')" },
  timeoutMs: 30_000,
});

Synchronous launch + poll

The pair shows the startBackground / getTaskSync manager path — launch returns inside the same tick, then a later turn reads the latest in-memory snapshot. This is a host-driven flow; it is not reachable from the LLM tool path in bare core (see the ASYNC_TASK_BRIDGE_NOT_WIRED note above).

const { taskId, status } = manager.startBackground({
  type:     "sandbox_exec",
  threadId: sessionId,
  input:    { code: "summarize('search_corpus')" },
});

if (status === "error") {
  // No executor registered for the kind — fall back synchronously.
}

// ... a later turn:
const snapshot = manager.getTaskSync(taskId);
if (snapshot?.status === "success") {
  await respondWith(snapshot.result);
}

getTaskSync never calls the executor. To force an executor-side status refresh, call manager.check(taskId) instead.

Cancelling a live task

The flow cancels a long-running task and shows what the manager guarantees about late results.

const ok = await manager.cancel(taskId);
if (!ok) {
  // Task was already terminal — nothing to do.
}

const snapshot = manager.get(taskId);
snapshot?.status === "cancelled";  // true

If the executor implements cancel, the manager calls it best-effort and swallows exceptions. A success or error write the executor produces after the abort fires is dropped — the cancelled status is final.

Where to go next

On this page