Tasks: persistent tasks
Responsibilities
task-executor.ts and detached-task-runtime.ts form OpenClaw's "task" subsystem: modeling any potentially cross-process, cross-session, cross-restart async work as a record with a lifecycle. No matter which runtime it runs in (subagent / acp / cli / cron), it shares the same state machine, the same SQLite persistence, and the same cancel + recovery semantics. CronService handles scheduling timing; task-executor.ts handles "how a record goes from queued to succeeded/failed/timed_out/cancelled/lost".
A task is not a thread or a worker — it's just a state object (TaskRecord, TaskRecord:L116-L146) plus lifecycle hooks implemented by the corresponding runtime. The runtime (subagent/acp/cli/cron, TaskRuntime:L5) decides how to actually execute; the task system only: stores records, transitions state, delivers back to the original session, periodically cleans up expired records.
Design motivation
Why isn't cron enough — why abstract another task layer?
- Cross-runtime: cron is time-scheduled; ACP is IDE-triggered prompts; subagent is sub-tasks spawned by the agent main loop; CLI is commands the user types in the terminal. The four runtimes have completely different trigger timings, but they all face the same problems: how does a process crash know some work didn't finish? How does a gateway restart recover? How do results return to the original session? Abstracting these into a task registry lets each runtime only implement its own
DetachedTaskLifecycleRuntime(DEFAULT_DETACHED_TASK_LIFECYCLE_RUNTIME:L35-L49); the rest of the logic is shared. - flow concept: many tasks belong to a larger flow (e.g. an agent run triggers three sub-tasks; cancelling the main task cancels the three sub-tasks too).
task-flow-registry(src/tasks/task-flow-registry.ts) gives tasks a parent flow for batch cancel and batch state query. For detached ACP/subagent runs, the executor automatically wraps them in a "one-task flow" (isOneTaskFlowEligible + ensureSingleTaskFlow:L46-L92), letting these single runs also enjoy flow's state/retry surface. - delivery status: after a task ends, the result doesn't have to return to the original session — the user may have closed the IDE; the cron may be an isolated session with no original session.
TaskDeliveryStatus(TaskDeliveryStatus:L16-L23) separately tracks this state:pending/delivered/session_queued/failed/parent_missing/not_applicable, decoupling "task executed successfully" from "result delivered to client".
Key files
task-registry.types.ts:L1-L146—TaskRuntime/TaskStatus/TaskDeliveryStatus/TaskNotifyPolicy/TaskRecordall type declarations.task-executor.ts:L46-L127—ensureSingleTaskFlow+createQueuedTaskRun+createRunningTaskRun, task creation entries (including one-task flow wrapping).detached-task-runtime.ts:L32-L66—DetachedTaskLifecycleRuntimeinterface, default runtime, plugin registration mechanism.tryRecoverTaskBeforeMarkLost:L134-L171— recovery hook: best-effort, 5-second threshold warning, any throw falls back to mark-lost.task-registry.process-state.ts:L1-L32— process-level in-memory indexes (tasks/taskIdsByRunId/taskIdsByOwnerKey/taskIdsByParentFlowId/taskIdsByRelatedSessionKey/tasksWithPendingDelivery);Symbol.for("openclaw.taskRegistry.state")hung off globalThis.task-registry.store.sqlite.ts:L48-L77— Kysely-mappedtask_runs+task_delivery_statetables, mirrored toopenclaw-state.db.task-registry.maintenance.ts:L73-L100— background sweeper, 60s cycle;TASK_STALE_RUNNING_MS=30*60_000,TASK_RECONCILE_GRACE_MS=5*60_000.task-retention.ts:L4-L29— terminal-state tasks retained 7 days; lost tasks only 1 day.src/tasks/task-flow-registry.store.sqlite.ts— flow persistence, supportsparentFlowIdcascade.cron-task-cancel.ts:L12-L77— process-level active cron task run cancellation handle, 60s settlement grace.task-registry.reconcile.ts— public facade:reconcileInspectableTasks/reconcileTaskLookupToken.
Data flow
The task state machine has only 7 states (TaskStatus:L7-L14):
export type TaskStatus =
| "queued" // persisted, waiting for runtime to pick up
| "running" // runtime reports execution started
| "succeeded" // terminal: success
| "failed" // terminal: failure (with error)
| "timed_out" // terminal: timeout
| "cancelled" // terminal: user/system cancel
| "lost"; // terminal: sweeper hasn't observed activity after grace, marks lostcreateQueuedTaskRun / createRunningTaskRun are the creation entries; both call ensureSingleTaskFlow (ensureSingleTaskFlow:L56):
function isOneTaskFlowEligible(task: TaskRecord): boolean {
if (task.parentFlowId?.trim() || task.scopeKind !== "session") return false;
if (task.deliveryStatus === "not_applicable") return false;
return task.runtime === "acp" || task.runtime === "subagent";
}
function ensureSingleTaskFlow(params: { task; requesterOrigin? }): TaskRecord {
if (!isOneTaskFlowEligible(params.task)) return params.task;
try {
const flow = createTaskFlowForTask({ task: params.task, requesterOrigin: params.requesterOrigin });
if (!flow) return params.task;
const linked = linkTaskToFlowById({ taskId: params.task.taskId, flowId: flow.flowId });
if (!linked) { deleteTaskFlowRecordById(flow.flowId); return params.task; }
if (linked.parentFlowId !== flow.flowId) { deleteTaskFlowRecordById(flow.flowId); return linked; }
return linked;
} catch (error) {
log.warn("Failed to create one-task flow for detached run", { ... });
return params.task;
}
}Key check: scopeKind === "session" and runtime ∈ {acp, subagent} and deliveryStatus !== "not_applicable" — only when all three hold is it auto-wrapped in a flow. System-level tasks (scopeKind: "system") and cron tasks have their own separate cancel/delivery paths, not reusing flow. On failure, only warn, no throw — task creation itself must not be blocked by flow wrapping failure.
detached-task-runtime.ts (getDetachedTaskLifecycleRuntime:L47) exposes a unified API, but the implementation can be plugin-replaced:
const DEFAULT_DETACHED_TASK_LIFECYCLE_RUNTIME: DetachedTaskLifecycleRuntime = {
createQueuedTaskRun: createQueuedTaskRunFromExecutor,
createRunningTaskRun: createRunningTaskRunFromExecutor,
startTaskRunByRunId: startTaskRunByRunIdFromExecutor,
recordTaskRunProgressByRunId: recordTaskRunProgressByRunIdFromExecutor,
finalizeTaskRunByRunId: finalizeTaskRunByRunIdFromExecutor,
completeTaskRunByRunId: completeTaskRunByRunIdFromExecutor,
failTaskRunByRunId: failTaskRunByRunIdFromExecutor,
setDetachedTaskDeliveryStatusByRunId: setDetachedTaskDeliveryStatusByRunIdFromExecutor,
cancelDetachedTaskRunById: cancelDetachedTaskRunByIdInCore,
};getDetachedTaskLifecycleRuntime() prefers a plugin-registered implementation, otherwise uses the default — this lets a plugin take over the entire task lifecycle (e.g. running tasks in a separate worker process) without modifying task-executor.
Boundaries and failures
- process-level index symbol-keyed globalThis:
getTaskRegistryProcessState()(getTaskRegistryProcessState:L18) usesSymbol.for("openclaw.taskRegistry.state")onglobalThis, so the index is shared across module hot-reloads (dev watch) or test isolation — otherwise different module instances would each maintain their own in-memory index, and tasks persisted to SQLite would mismatch the in-memory index. - sweeper batch yield:
SWEEP_YIELD_BATCH_SIZE = 25(SWEEP_YIELD_BATCH_SIZE:L83) — after every 25 tasks processed, yields the event loop, avoiding blocking the main thread during bulk cleanup. - stale running detection:
TASK_STALE_RUNNING_MS = 30 * 60_000— running state with no event updates (includingrecordTaskRunProgressByRunId) for 30 minutes triggers sweeper recovery. This gives slow tasks a wide window, preventing false-killing of normal long runs. - recovery hook fault tolerance:
tryRecoverTaskBeforeMarkLost(tryRecoverTaskBeforeMarkLost:L134) wraps the entire hook call in try/catch — any plugin implementation throw, illegal object returned, or exceeding the 5s threshold only warns, then continues to mark-lost. Reason: recovery is best-effort; a broken plugin must not block cleanup. - retention dual-track: terminal-state tasks retained 7 days; lost tasks only 1 day (
resolveTaskRetentionMs:L5-L9). Lost is "sweeper-inferred terminal state" with lower confidence than runtime-explicitly-reported succeeded/failed, so the retention is shorter — giving an investigation window without letting "ghost tasks" occupy DB long-term. - one-task flow failure degradation: any step of
ensureSingleTaskFlowthrowing returns the originaltask(no re-wrapping), with a warn in logs. Successful task creation matters more than complete flow wrapping. - scopeKind distinction:
scopeKind: "system"tasks have norequesterSessionKey(system-scope requesterSessionKey:L92-L94); ownerKey is the only lookup anchor — this prevents system tasks colliding with some session's ownerKey. - cron-task-cancel settlement grace: on gateway restart,
abortActiveCronTaskRuns(abortActiveCronTaskRunSettlementGrace:L50) starts a 60-second "settlement grace" letting the aborted promises have time to clean up resources and write terminal state in finally — directly clearing the table would lose cleanup.
Summary
The Tasks subsystem abstracts "a piece of async work" into a 7-state TaskRecord; four runtimes (subagent/acp/cli/cron) share one state machine + SQLite persistence + sweeper recovery. It has a bidirectional relationship with Cron: scheduled tasks: cron hooks itself into the task process-level table via registerActiveCronTaskRun, and the task system gives cron "interrupt active runs on restart" capability via cron-task-cancel.ts. Detached prompts started by ACP use the same set (see ACP: IDE bridge), just tagged runtime: "acp". Session-related state and task delivery back to the original client use the session store path from Memory files.