Skip to content

Tasks: persistent tasks

源码版本v2026.6.11

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?

  1. 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.
  2. 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.
  3. 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

Data flow

The task state machine has only 7 states (TaskStatus:L7-L14):

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

createQueuedTaskRun / createRunningTaskRun are the creation entries; both call ensureSingleTaskFlow (ensureSingleTaskFlow:L56):

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

typescript
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) uses Symbol.for("openclaw.taskRegistry.state") on globalThis, 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 (including recordTaskRunProgressByRunId) 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 ensureSingleTaskFlow throwing returns the original task (no re-wrapping), with a warn in logs. Successful task creation matters more than complete flow wrapping.
  • scopeKind distinction: scopeKind: "system" tasks have no requesterSessionKey (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.