Skip to content

Tasks: Persistente Aufgaben

源码版本v2026.6.11

Verantwortung

task-executor.ts und detached-task-runtime.ts bilden das „Aufgaben-(task-)Subsystem" von OpenClaw: Jede prozess-, session- und neustartübergreifende asynchrone Arbeit wird als Datensatz mit Lebenszyklus modelliert; egal in welchem Runtime (subagent / acp / cli / cron) sie läuft, sie teilt sich dieselbe Zustandsmaschine (state machine), dieselbe SQLite-Persistenz (persistence) und dieselbe Cancel- und Recovery-Semantik. CronService ist für den Scheduling-Zeitpunkt zuständig; task-executor.ts bestimmt, „wie ein Datensatz von queued zu succeeded/failed/timed_out/cancelled/lost wandert".

Eine Task ist weder Thread noch Worker — sie ist nur ein Zustandsobjekt (TaskRecord, TaskRecord:116-146) plus Lebenszyklus-Hooks, die der entsprechende Runtime selbst implementiert. Das Runtime (subagent/acp/cli/cron, TaskRuntime:5) entscheidet über die echte Ausführung; das Task-System verwaltet nur: Datensatz speichern, Zustand wechseln, an die Originalsession liefern, periodisch veraltete Datensätze aufräumen.

Designmotivation

Warum reicht cron nicht, warum eine zusätzliche Task-Schicht abstrahieren?

  1. Runtime-übergreifend: cron ist zeitgesteuert, ACP ein durch IDE getriggerter Prompt, subagent ein vom Agent-Hauptschleife abgeleitetes Unterauftrag, CLI ein Befehl im Terminal des Nutzers. Vier Runtimes mit völlig unterschiedlichen Trigger-Zeitpunkten, aber allen denselben Fragen: Wie weiß man nach Prozess-Crash, dass eine Arbeit nicht fertig wurde? Wie wird nach Gateway-Neustart recoveriert? Wie kommt das Ergebnis in die Originalsession? Diese Fragen in einer task registry zu bündeln, lässt jedes Runtime nur sein eigenes DetachedTaskLifecycleRuntime (DEFAULT_DETACHED_TASK_LIFECYCLE_RUNTIME:35-49) implementieren; die restliche Logik ist geteilt.
  2. Flow-Begriff: Viele Tasks gehören zu einem größeren Flow (z. B. ein agent-run stößt drei Sub-Tasks an; wird die Hauptaufgabe abgebrochen, werden auch die drei Sub-Tasks abgebrochen). task-flow-registry (src/tasks/task-flow-registry.ts) gibt einer Task einen Eltern-Flow und erlaubt Batch-Cancel und Batch-Statusabfragen. Für detached ACP-/subagent-runs wickelt der Executor automatisch einen „one-task flow" (isOneTaskFlowEligible + ensureSingleTaskFlow:46-92) ein, sodass auch diese einmaligen runs den Status-/Retry-Radius des Flows genießen.
  3. Delivery-Status: Nach Task-Ende muss das Ergebnis nicht unbedingt in die Originalsession zurück — vielleicht hat der Nutzer die IDE geschlossen, vielleicht ist die cron-Session isoliert ohne Originalsession. TaskDeliveryStatus (TaskDeliveryStatus:16-23) verfolgt diesen Zustand separat: pending/delivered/session_queued/failed/parent_missing/not_applicable — entkoppelt „Task erfolgreich ausgeführt" von „Ergebnis dem Client zugestellt".

Schlüsseldateien

Datenfluss

Die Zustandsmaschine der Task hat nur 7 Zustände (TaskStatus:7-14):

typescript
export type TaskStatus =
  | "queued"       // persistiert, wartet auf Runtime-Aufruf
  | "running"      // Runtime meldet Begin der Ausführung
  | "succeeded"    // Endzustand: Erfolg
  | "failed"       // Endzustand: Fehlschlag (mit error)
  | "timed_out"    // Endzustand: Timeout
  | "cancelled"    // Endzustand: Nutzer/System cancel
  | "lost";        // Endzustand: Sweeper hat nach grace keine Aktivität beobachtet, als verloren markiert

createQueuedTaskRun / createRunningTaskRun sind die Anlege-Eintritte; beide rufen ensureSingleTaskFlow (ensureSingleTaskFlow:56):

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;
  }
}

Schlüsselprüfung: scopeKind === "session" und runtime ∈ {acp, subagent} und deliveryStatus !== "not_applicable" — nur wenn alle drei erfüllt sind, bekommt die Task automatisch einen Flow. System-Tasks (scopeKind: "system") und cron-Tasks haben eigene separate cancel/delivery-Pfade und gehen nicht erneut durch den Flow. Bei Misserfolg nur warn, kein Throw — das Task-Anlegen selbst darf am Flow-Wrapping nicht scheitern.

detached-task-runtime.ts (getDetachedTaskLifecycleRuntime:47) exponiert eine einheitliche API, deren Implementierung ein Plugin ersetzen kann:

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() liefert bevorzugt die Plugin-registrierte Implementierung, sonst die default — das erlaubt einem Plugin, den gesamten Task-Lebenszyklus zu übernehmen (z. B. Tasks in einem separaten Worker-Prozess laufen zu lassen), ohne task-executor zu ändern.

Grenzen und Fehler

  • symbol-keyed globalThis des prozesslevel Index: getTaskRegistryProcessState() (getTaskRegistryProcessState:18) hängt über Symbol.for("openclaw.taskRegistry.state") an globalThis, sodass der Index bei Modul-Hot-Reload (dev watch) oder Test-Isolation geteilt bleibt — sonst würden verschiedene Modulinstanzen各自 eigene In-Memory-Indizes pflegen, und die in SQLite gespeicherten Tasks und der In-Memory-Index auseinanderlaufen.
  • sweeper Batch-Yield: SWEEP_YIELD_BATCH_SIZE = 25 (SWEEP_YIELD_BATCH_SIZE:83); nach je 25 verarbeiteten Tasks wird der Eventloop yielded, um bei großem Cleaning den Main-Thread nicht zu blockieren.
  • stale-running-Bestimmung: TASK_STALE_RUNNING_MS = 30 * 60_000 — running-Zustand ohne jegliches Ereignis-Update (inkl. recordTaskRunProgressByRunId) über 30 Minuten; erst dann beginnt der sweeper mit Recovery. Das gibt langen Tasks ein großzügiges Fenster, keine normalen Läufe fälschlich abgebrochen.
  • Recovery-Hook-Fehlertoleranz: tryRecoverTaskBeforeMarkLost (tryRecoverTaskBeforeMarkLost:134) wickelt den gesamten Hook-Aufruf in try/catch — egal ob die Plugin-Implementierung wirft, illegale Objekte zurückgibt oder den 5s-Schwellwert überschreitet, es erfolgt nur warn, dann geht es weiter zu mark-lost. Begründung: Recovery ist best-effort; ein kaputtes Plugin darf Cleaning nicht blockieren.
  • Retention zweigleisig: Endzustand-Tasks 7 Tage behalten, lost-Tasks nur 1 Tag (resolveTaskRetentionMs:5-9). lost ist ein „vom Sweeper erschlossener Endzustand" mit geringerer Konfidenz als explizit gemeldete succeeded/failed; kürzere Aufbewahrung — genug Diagnosefenster, ohne „Ghost-Tasks" langfristig die DB belegen zu lassen.
  • one-task flow Fehlschlagsdegradation: Jeder Throw in ensureSingleTaskFlow liefert die ursprüngliche task zurück (nicht neu gewrapped); im Log nur warn. Task erfolgreich anzulegen ist wichtiger als vollständiges Flow-Wrapping.
  • scopeKind-Unterscheidung: Tasks mit scopeKind: "system" haben keine requesterSessionKey (system-scope requesterSessionKey:92-94); ownerKey ist der einzige Lookup-Anker — verhindert Kollisionen zwischen System-Tasks und dem ownerKey einer Session.
  • cron-task-cancel settlement grace: Beim Gateway-Neustart startet abortActiveCronTaskRuns (abortActiveCronTaskRunSettlementGrace:50) eine 60-Sekunden-Settlement-Grace-Periode, damit abgebrochenen Prompts Zeit bleibt, in finally Ressourcen freizuräumen und den Endzustand zu schreiben — ein direktes clear der Tabelle ließe Cleanup verloren gehen.

Zusammenfassung

Das Tasks-Subsystem abstrahiert „eine asynchrone Arbeit" als TaskRecord mit 7 Zuständen; vier Runtimes (subagent/acp/cli/cron) teilen sich eine Zustandsmaschine + SQLite-Persistenz + sweeper-Recovery. Es steht in doppelter Beziehung zu Cron: zeitgesteuerte Aufgaben: cron hängt sich über registerActiveCronTaskRun in die prozesslevel-Tabelle der Tasks ein; das Task-System liefert über cron-task-cancel.ts dem cron die Fähigkeit, „beim Neustart aktive runs abzubrechen". Detached Prompts, die durch ACP gestartet werden, laufen über dasselbe System (siehe ACP: IDE-Brücke), nur mit runtime "acp". Session-bezogener Zustand und Task-Delivery-Rücklieferung nutzen den session-store-Pfad aus Memory-Dateien.