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.
| Member | Returns | Notes |
|---|---|---|
runtime.async.taskManager | AsyncTaskManager | undefined | The catalog described below; lazily wired. |
runtime.async.manager | SubagentManager | undefined | The 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 | undefined | Replay a recorded sub-agent result. |
runtime.async.getResultWithContext(subAgentId) | SubAgentResultWithContext | undefined | Same 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
| Field | Type | Notes |
|---|---|---|
taskId | string | task_<ms>_<rand> — generated at start |
type | string | Executor key ("subagent", "sandbox_exec", plugin-registered) |
threadId | string | Session id; scopes the reducer namespace |
status | enum | queued / running / success / error / cancelled / timeout |
input | record | Opaque to the manager; the executor reads it |
result | record? | Set on success |
error | string? | Set on error / timeout |
createdAt | ISO-8601 | Frozen at start |
updatedAt | ISO-8601? | Bumps on every status transition |
checkedAt | ISO-8601? | Set by check() when the executor refreshes status |
agentName | string? | Free-form provenance (subagent name, etc.) |
jobType | string? | Free-form provenance |
timeoutMs | number? | 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
| Method | Returns | Notes |
|---|---|---|
registerExecutor(type, executor) | void | Wire 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 | null | In-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 | null | Synchronous 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:
- The
AbortControllerfor the task fires; any in-flight executor work observingcontroller.signalshort-circuits. - If the executor implements
cancel, the manager calls it best-effort — exceptions are swallowed. - The task transitions to
cancelledand 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"; // trueIf 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
Subagents
When a long-running job spawns a child runtime, subagents bridge the two.
Stream events
The `job.*` events fired around task start, progress, completion, and cancellation.
Interrupts
The other long-pause surface — what's emitted when a task waits on a human decision.
Audit ledger
Where task transitions show up at the channel boundary when the surrounding turn checkpoints.
Channels
The `asyncTasks` channel that the reducer above keys into.