Tasks: Persistente Aufgaben
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?
- 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. - 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. - 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
task-registry.types.ts:1-146— Alle Typdeklarationen:TaskRuntime/TaskStatus/TaskDeliveryStatus/TaskNotifyPolicy/TaskRecord.task-executor.ts:46-127—ensureSingleTaskFlow+createQueuedTaskRun+createRunningTaskRun; Task-Anlege-Eintritt (mit one-task-flow-Wrapping).detached-task-runtime.ts:32-66—DetachedTaskLifecycleRuntime-Interface, default-runtime, Plugin-Registrierung.tryRecoverTaskBeforeMarkLost:134-171— Recovery-Hook: best-effort, 5-Sekunden-Schwellwarnung, jeder Throw fällt auf mark-lost zurück.task-registry.process-state.ts:1-32— Prozesslevel In-Memory-Index (tasks/taskIdsByRunId/taskIdsByOwnerKey/taskIdsByParentFlowId/taskIdsByRelatedSessionKey/tasksWithPendingDelivery);Symbol.for("openclaw.taskRegistry.state")an globalThis angehängt.task-registry.store.sqlite.ts:48-77— Kysely-gemapptetask_runs+task_delivery_state-Tabellen, gespiegelt inopenclaw-state.db.task-registry.maintenance.ts:73-100— Hintergrund-Sweeper, Periode 60 s,TASK_STALE_RUNNING_MS=30*60_000,TASK_RECONCILE_GRACE_MS=5*60_000.task-retention.ts:4-29— Endzustand-Tasks werden 7 Tage behalten, lost-Tasks nur 1 Tag.src/tasks/task-flow-registry.store.sqlite.ts— Flow-Persistenz; unterstütztparentFlowId-Kaskade.cron-task-cancel.ts:12-77— Cancel-Handle für prozesslevel aktive cron-task-runs; settlement grace 60 s.task-registry.reconcile.ts— Öffentliche Fassade:reconcileInspectableTasks/reconcileTaskLookupToken.
Datenfluss
Die Zustandsmaschine der Task hat nur 7 Zustände (TaskStatus:7-14):
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 markiertcreateQueuedTaskRun / createRunningTaskRun sind die Anlege-Eintritte; beide rufen ensureSingleTaskFlow (ensureSingleTaskFlow:56):
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:
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 überSymbol.for("openclaw.taskRegistry.state")anglobalThis, 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
ensureSingleTaskFlowliefert die ursprünglichetaskzurü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 keinerequesterSessionKey(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.