# Async tasks (/docs/async-tasks)



`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](/docs/channels) for the `asyncTasks`
channel the reducer keys into, and [Subagents](/docs/subagents)
for the in-tree `"subagent"` task kind.

```typescript
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-runtimeasync-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](/docs/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](/docs/facets) for the broader facet
surface.

<SourceMeta source="{ label: &#x22;src/async/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/async&#x22; }" />

## `AsyncTask` shape [#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-asynctaskexecutor-contract]

The seam each task kind implements:

```typescript
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 [#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 [#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 [#the-reducer]

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

```typescript
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` [#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.

```typescript
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 [#wiring-an-executor]

```typescript
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 [#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).

```typescript
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 [#cancelling-a-live-task]

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

```typescript
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 [#where-to-go-next]

<Cards>
  <Card title="Subagents" href="/docs/subagents" description="When a long-running job spawns a child runtime, subagents bridge the two." />

  <Card title="Stream events" href="/docs/stream-events" description="The `job.*` events fired around task start, progress, completion, and cancellation." />

  <Card title="Interrupts" href="/docs/interrupts" description="The other long-pause surface — what's emitted when a task waits on a human decision." />

  <Card title="Audit ledger" href="/docs/audit-ledger" description="Where task transitions show up at the channel boundary when the surrounding turn checkpoints." />

  <Card title="Channels" href="/docs/channels" description="The `asyncTasks` channel that the reducer above keys into." />
</Cards>
