refactor(tasks): split task registry internals (#113279)

* refactor(tasks): split task registry internals

* test(tasks): align registry ownership boundary
This commit is contained in:
Peter Steinberger
2026-07-24 02:09:04 -07:00
committed by GitHub
parent 741ed2d6a8
commit b233faa057
12 changed files with 3065 additions and 2800 deletions
-1
View File
@@ -1034,7 +1034,6 @@ src/tasks/task-flow-registry.ts
src/tasks/task-registry.maintenance.ts
src/tasks/task-registry.store.test.ts
src/tasks/task-registry.test.ts
src/tasks/task-registry.ts
src/trajectory/export.test.ts
src/trajectory/export.ts
src/tui/embedded-backend.test.ts
+1 -1
View File
@@ -20,7 +20,7 @@ const RAW_TASK_MUTATORS = [
const RAW_TASK_MUTATOR_ALLOWED_CALLERS = new Set([
"tasks/task-executor.ts",
"tasks/task-registry.ts",
"tasks/task-registry-record-api.ts",
"tasks/task-registry.maintenance.ts",
]);
+283
View File
@@ -0,0 +1,283 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { isBackgroundExecTask } from "./background-exec-task-contract.js";
import { SUBAGENT_KILL_TASK_ERROR } from "./detached-task-runtime-contract.js";
import { isChildlessNativeSubagentTask } from "./native-subagent-task.js";
import { isProvisionalSubagentKillTask } from "./task-cancellation-state.js";
import { isTerminalTaskStatus } from "./task-executor-policy.js";
import { ensureLinkedTaskFlowRegistryReady } from "./task-registry-common.js";
import { maybeDeliverTaskTerminalUpdate } from "./task-registry-delivery.js";
import { updateTask } from "./task-registry-mutation.js";
import { finalizeTaskRunByRunId, updateTaskStateByRunId } from "./task-registry-record-api.js";
import { cloneTaskRecord } from "./task-registry-records.js";
import {
ensureTaskRegistryReady,
getTasksByRunScope,
loadTaskRegistryControlRuntime,
tasks,
} from "./task-registry-state.js";
import type { TaskRecord } from "./task-registry.types.js";
function ensureTaskCancellationReady(task: TaskRecord): void {
const runId = task.runId?.trim();
const linkedTasks =
runId && (task.runtime === "acp" || task.runtime === "subagent")
? getTasksByRunScope({
runId,
runtime: task.runtime,
sessionKey: task.childSessionKey,
})
: [task];
for (const linkedTask of linkedTasks.length > 0 ? linkedTasks : [task]) {
ensureLinkedTaskFlowRegistryReady(linkedTask);
}
}
export async function cancelTaskById(params: {
cfg: OpenClawConfig;
taskId: string;
reason?: string;
}): Promise<{ found: boolean; cancelled: boolean; reason?: string; task?: TaskRecord }> {
ensureTaskRegistryReady();
const task = tasks.get(params.taskId.trim());
if (!task) {
return { found: false, cancelled: false, reason: "Task not found." };
}
const requestedReason = params.reason?.trim();
const cancellationError =
requestedReason && requestedReason !== SUBAGENT_KILL_TASK_ERROR
? requestedReason
: "Cancelled by operator.";
let isProvisionalSubagentKill =
task.runtime === "subagent" &&
task.status === "cancelled" &&
task.error === SUBAGENT_KILL_TASK_ERROR;
if (
!isProvisionalSubagentKill &&
(task.status === "succeeded" ||
task.status === "failed" ||
task.status === "timed_out" ||
task.status === "lost" ||
task.status === "cancelled")
) {
return {
found: true,
cancelled: false,
reason: "Task is already terminal.",
task: cloneTaskRecord(task),
};
}
const childSessionKey = task.childSessionKey?.trim();
try {
ensureTaskCancellationReady(task);
// A direct kill is only a provisional terminal projection. Re-read the
// owning subagent run before promotion so its canonical completion can win.
if (isBackgroundExecTask(task)) {
const processSessionId = task.sourceId?.trim();
const { cancelBackgroundExecSession } = await loadTaskRegistryControlRuntime();
if (!processSessionId || !cancelBackgroundExecSession?.(processSessionId)) {
return {
found: true,
cancelled: false,
reason: "Background command has no active cancellation handle.",
task: cloneTaskRecord(task),
};
}
} else if (task.runtime !== "cli") {
if (task.runtime === "cron") {
const { cancelActiveCronTaskRun } = await loadTaskRegistryControlRuntime();
if (
!cancelActiveCronTaskRun({
runId: task.runId,
reason: params.reason?.trim() || "Cancelled by operator.",
})
) {
if (childSessionKey) {
return {
found: true,
cancelled: false,
reason: "Cron task has no active cancellation handle.",
task: cloneTaskRecord(task),
};
}
// Childless cron rows are stale legacy ledger records; with no live
// runner handle and no child session to cancel, clear the task row.
}
} else if (!childSessionKey) {
if (!isChildlessNativeSubagentTask(task)) {
return {
found: true,
cancelled: false,
reason: "Task has no cancellable child session.",
task: cloneTaskRecord(task),
};
}
}
if (task.runtime === "cron") {
// The live cron service owns the abort signal; registry finalization below
// keeps CLI/Gateway callers aligned while the run unwinds.
} else if (!childSessionKey) {
// Codex native subagents are mirrored from the Codex app server and do
// not have OpenClaw child sessions to terminate. Cancellation clears
// the stale task-registry record only.
} else if (task.runtime === "acp") {
const { getAcpSessionManager } = await loadTaskRegistryControlRuntime();
await getAcpSessionManager().cancelSession({
cfg: params.cfg,
sessionKey: childSessionKey,
reason: params.reason?.trim() || "task-cancel",
});
} else if (task.runtime === "subagent") {
const { killSubagentRunAdmin } = await loadTaskRegistryControlRuntime();
const result = await killSubagentRunAdmin({
cfg: params.cfg,
sessionKey: childSessionKey,
});
const current = tasks.get(task.taskId);
if (current?.status === "cancelled" && current.error === SUBAGENT_KILL_TASK_ERROR) {
isProvisionalSubagentKill = true;
}
if (current?.status === "succeeded") {
return {
found: true,
cancelled: false,
reason: "Subagent completed while cancellation was in progress.",
task: cloneTaskRecord(current),
};
}
if (current && isTerminalTaskStatus(current.status) && current.status !== "cancelled") {
return {
found: true,
cancelled: false,
reason: `Subagent became ${current.status} while cancellation was in progress.`,
task: cloneTaskRecord(current),
};
}
if (current?.status === "cancelled" && !isProvisionalSubagentKill) {
return {
found: true,
cancelled: false,
reason: "Subagent was cancelled while cancellation was in progress.",
task: cloneTaskRecord(current),
};
}
if (result.found && result.targetState?.state === "terminal") {
// A subagent run becomes terminal before its task projection settles.
// Reconcile the original task scope: steer/orphan recovery may have
// replaced the registry run ID without remapping durable task rows.
const taskRunId = task.runId?.trim() || result.runId;
const reconciledTasks = finalizeTaskRunByRunId({
runId: taskRunId,
runtime: "subagent",
sessionKey: childSessionKey,
...result.targetState.task,
});
const reconciled = reconciledTasks.find((candidate) => candidate.taskId === task.taskId);
if (!reconciled) {
return {
found: true,
cancelled: false,
reason: "Subagent became terminal, but task state reconciliation failed to persist.",
task: cloneTaskRecord(tasks.get(task.taskId) ?? task),
};
}
if (
result.targetState.task.status === "cancelled" &&
result.targetState.task.error === SUBAGENT_KILL_TASK_ERROR
) {
isProvisionalSubagentKill = true;
} else {
const reason =
result.targetState.task.status === "succeeded"
? "Subagent completed while cancellation was in progress."
: `Subagent became ${result.targetState.task.status} while cancellation was in progress.`;
return {
found: true,
cancelled: false,
reason,
task: cloneTaskRecord(reconciled),
};
}
}
if (result.found && result.targetState?.state === "finalizing") {
return {
found: true,
cancelled: false,
reason: "Subagent completion is still being finalized.",
task: cloneTaskRecord(current ?? task),
};
}
if ((!result.found || !result.killed) && !isProvisionalSubagentKill) {
return {
found: true,
cancelled: false,
reason: result.found ? "Subagent was not running." : "Subagent task not found.",
task: cloneTaskRecord(current ?? task),
};
}
} else {
return {
found: true,
cancelled: false,
reason: "Task runtime does not support cancellation yet.",
task: cloneTaskRecord(task),
};
}
}
const eventAt = Date.now();
const current = tasks.get(task.taskId) ?? task;
const endedAt = isProvisionalSubagentKill ? (current.endedAt ?? eventAt) : eventAt;
const updated =
(task.runtime === "acp" || task.runtime === "subagent") && task.runId?.trim()
? (updateTaskStateByRunId({
runId: task.runId,
runtime: task.runtime,
sessionKey: childSessionKey,
status: "cancelled",
endedAt,
lastEventAt: eventAt,
error: cancellationError,
}).find((record) => record.taskId === task.taskId) ?? null)
: updateTask(task.taskId, {
status: "cancelled",
endedAt,
lastEventAt: eventAt,
error: cancellationError,
});
if (!updated) {
return {
found: true,
cancelled: false,
reason: "Task persistence failed.",
task: cloneTaskRecord(task),
};
}
if (updated) {
void maybeDeliverTaskTerminalUpdate(updated.taskId);
}
return {
found: true,
cancelled: true,
task: updated ?? cloneTaskRecord(task),
};
} catch (error) {
return {
found: true,
cancelled: false,
reason: formatErrorMessage(error),
task: cloneTaskRecord(task),
};
}
}
export function assertTaskCancellationReadyById(taskId: string): TaskRecord | null {
ensureTaskRegistryReady();
const task = tasks.get(taskId.trim());
if (!task) {
return null;
}
if (!isTerminalTaskStatus(task.status) || isProvisionalSubagentKillTask(task)) {
ensureTaskCancellationReady(task);
}
return cloneTaskRecord(task);
}
+334
View File
@@ -0,0 +1,334 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
buildAgentRunTerminalOutcome,
type AgentRunTerminalOutcome,
} from "../agents/agent-run-terminal-outcome.js";
import { SUBAGENT_KILL_TASK_ERROR } from "./detached-task-runtime-contract.js";
import { isTerminalTaskStatus } from "./task-executor-policy.js";
import type { TaskFlowRecord } from "./task-flow-registry.types.js";
import { ensureTaskFlowRegistryReady, getTaskFlowById } from "./task-flow-runtime-internal.js";
import type {
TaskDeliveryState,
TaskDeliveryStatus,
TaskEventKind,
TaskEventRecord,
TaskNotifyPolicy,
TaskRecord,
TaskRuntime,
TaskScopeKind,
TaskStatus,
TaskTerminalOutcome,
} from "./task-registry.types.js";
export type TaskDeliveryOwner = {
sessionKey?: string;
requesterOrigin?: TaskDeliveryState["requesterOrigin"];
flowId?: string;
};
type ParentFlowLinkErrorCode =
| "scope_kind_not_session"
| "parent_flow_not_found"
| "owner_key_mismatch"
| "cancel_requested"
| "terminal";
class ParentFlowLinkError extends Error {
constructor(
public readonly code: ParentFlowLinkErrorCode,
message: string,
public readonly details?: {
flowId?: string;
status?: TaskFlowRecord["status"];
},
) {
super(message);
this.name = "ParentFlowLinkError";
}
}
export function isParentFlowLinkError(error: unknown): error is ParentFlowLinkError {
return error instanceof ParentFlowLinkError;
}
export function isActiveTaskStatus(status: TaskStatus): boolean {
return status === "queued" || status === "running";
}
export function isTerminalFlowStatus(status: TaskFlowRecord["status"]): boolean {
return (
status === "succeeded" || status === "failed" || status === "cancelled" || status === "lost"
);
}
export function assertTaskOwner(params: { ownerKey: string; scopeKind: TaskScopeKind }) {
const ownerKey = params.ownerKey.trim();
if (!ownerKey && params.scopeKind !== "system") {
throw new Error("Task ownerKey is required.");
}
}
export function assertParentFlowLinkAllowed(params: {
ownerKey: string;
scopeKind: TaskScopeKind;
parentFlowId?: string;
}) {
const flowId = params.parentFlowId?.trim();
if (!flowId) {
return;
}
if (params.scopeKind !== "session") {
throw new ParentFlowLinkError(
"scope_kind_not_session",
"Only session-scoped tasks can link to flows.",
{ flowId },
);
}
const flow = getTaskFlowById(flowId);
if (!flow) {
throw new ParentFlowLinkError("parent_flow_not_found", `Parent flow not found: ${flowId}`, {
flowId,
});
}
if (normalizeOptionalString(flow.ownerKey) !== normalizeOptionalString(params.ownerKey)) {
throw new ParentFlowLinkError(
"owner_key_mismatch",
"Task ownerKey must match parent flow ownerKey.",
{ flowId },
);
}
if (flow.cancelRequestedAt != null) {
throw new ParentFlowLinkError(
"cancel_requested",
"Parent flow cancellation has already been requested.",
{ flowId, status: flow.status },
);
}
if (isTerminalFlowStatus(flow.status)) {
throw new ParentFlowLinkError("terminal", `Parent flow is already ${flow.status}.`, {
flowId,
status: flow.status,
});
}
}
export function ensureLinkedTaskFlowRegistryReady(task: Pick<TaskRecord, "parentFlowId">): void {
if (task.parentFlowId?.trim()) {
ensureTaskFlowRegistryReady();
}
}
export function ensureDeliveryStatus(params: {
ownerKey: string;
scopeKind: TaskScopeKind;
}): TaskDeliveryStatus {
if (params.scopeKind === "system") {
return "not_applicable";
}
return params.ownerKey.trim() ? "pending" : "parent_missing";
}
export function ensureNotifyPolicy(params: {
notifyPolicy?: TaskNotifyPolicy;
deliveryStatus?: TaskDeliveryStatus;
ownerKey: string;
scopeKind: TaskScopeKind;
}): TaskNotifyPolicy {
if (params.notifyPolicy) {
return params.notifyPolicy;
}
const deliveryStatus =
params.deliveryStatus ??
ensureDeliveryStatus({
ownerKey: params.ownerKey,
scopeKind: params.scopeKind,
});
return deliveryStatus === "not_applicable" ? "silent" : "done_only";
}
export function resolveTaskScopeKind(params: {
scopeKind?: TaskScopeKind;
requesterSessionKey: string;
}): TaskScopeKind {
if (params.scopeKind) {
return params.scopeKind;
}
return params.requesterSessionKey.trim() ? "session" : "system";
}
export function resolveTaskRequesterSessionKey(params: {
requesterSessionKey?: string;
ownerKey?: string;
scopeKind?: TaskScopeKind;
}): string {
const requesterSessionKey = params.requesterSessionKey?.trim();
if (requesterSessionKey) {
return requesterSessionKey;
}
if (params.scopeKind === "system") {
return "";
}
return params.ownerKey?.trim() ?? "";
}
export function resolveTaskOwnerKey(params: {
requesterSessionKey: string;
ownerKey?: string;
}): string {
return params.ownerKey?.trim() || params.requesterSessionKey.trim();
}
export function normalizeTaskSummary(value: string | null | undefined): string | undefined {
const normalized = value?.replace(/\s+/g, " ").trim();
return normalized || undefined;
}
export function normalizeTaskStatus(value: TaskStatus | null | undefined): TaskStatus {
return value === "running" ||
value === "queued" ||
value === "succeeded" ||
value === "failed" ||
value === "timed_out" ||
value === "cancelled" ||
value === "lost"
? value
: "queued";
}
function normalizeTaskTerminalOutcome(
value: TaskTerminalOutcome | null | undefined,
): TaskTerminalOutcome | undefined {
return value === "succeeded" || value === "blocked" ? value : undefined;
}
export function shouldApplyRunScopedStatusUpdate(params: {
currentStatus: TaskStatus;
currentRuntime: TaskRuntime;
currentChildSessionKey?: string;
currentError?: string;
currentEndedAt?: number;
nextStatus: TaskStatus;
nextError?: string;
nextEndedAt?: number;
}): boolean {
if (
params.currentRuntime === "subagent" &&
params.nextStatus === "cancelled" &&
params.nextError === SUBAGENT_KILL_TASK_ERROR &&
isTerminalTaskStatus(params.currentStatus) &&
!(params.currentStatus === "cancelled" && params.currentError === SUBAGENT_KILL_TASK_ERROR)
) {
// The kill marker is provisional. It may refresh only its own tombstone;
// canonical completion or operator cancellation already won this race.
return false;
}
if (params.currentStatus === params.nextStatus) {
return true;
}
if (!isTerminalTaskStatus(params.currentStatus)) {
return true;
}
if (!isTerminalTaskStatus(params.nextStatus)) {
return false;
}
// Direct subagent termination is provisional. An operator cancellation is
// sticky only against outcomes that completed at or after cancellation.
if (
params.currentStatus === "cancelled" &&
(params.nextStatus === "succeeded" ||
params.nextStatus === "failed" ||
params.nextStatus === "timed_out")
) {
const canonicalOutcomePredatesCancellation =
params.currentRuntime === "subagent" &&
params.currentEndedAt !== undefined &&
params.nextEndedAt !== undefined &&
params.nextEndedAt < params.currentEndedAt;
return (
canonicalOutcomePredatesCancellation ||
(params.currentRuntime === "subagent" &&
Boolean(params.currentChildSessionKey?.trim()) &&
params.currentError === SUBAGENT_KILL_TASK_ERROR)
);
}
return params.currentStatus === "succeeded" && params.nextStatus !== "lost";
}
export function resolveTaskTerminalOutcome(params: {
status: TaskStatus;
terminalOutcome?: TaskTerminalOutcome | null;
}): TaskTerminalOutcome | undefined {
const normalized = normalizeTaskTerminalOutcome(params.terminalOutcome);
if (normalized) {
return normalized;
}
return params.status === "succeeded" ? "succeeded" : undefined;
}
export function mapAgentRunTerminalOutcomeToTaskStatus(
outcome: AgentRunTerminalOutcome,
): Extract<TaskStatus, "succeeded" | "failed" | "timed_out" | "cancelled"> {
switch (outcome.reason) {
case "completed":
return "succeeded";
case "hard_timeout":
case "timed_out":
return "timed_out";
case "cancelled":
case "aborted":
return "cancelled";
case "blocked":
case "abandoned":
case "failed":
return "failed";
default:
return outcome.reason satisfies never;
}
}
export function resolveTaskLifecycleTerminalError(params: {
runtime: TaskRuntime;
status: TaskStatus;
error?: string;
}): string | undefined {
// A runner abort can race either an accepted task cancellation or a real
// completion. Keep it provisional until the task-control owner decides.
return params.runtime === "subagent" && params.status === "cancelled"
? SUBAGENT_KILL_TASK_ERROR
: params.error;
}
export function buildTaskLifecycleTerminalOutcome(params: {
phase: "end" | "error";
data?: Record<string, unknown>;
startedAt?: number;
endedAt?: number;
}): AgentRunTerminalOutcome {
const status =
params.phase === "error" ? "error" : params.data?.aborted === true ? "timeout" : "ok";
// Lifecycle events carry runner/provider terminal facts. Keep the precedence
// centralized so task projections match agent.wait and gateway snapshots.
return buildAgentRunTerminalOutcome({
status,
error: params.data?.error,
stopReason: params.data?.stopReason,
livenessState: params.data?.livenessState,
timeoutPhase: params.data?.timeoutPhase,
providerStarted: params.data?.providerStarted,
startedAt: params.startedAt,
endedAt: params.endedAt,
});
}
export function appendTaskEvent(event: {
at: number;
kind: TaskEventKind;
summary?: string | null;
}): TaskEventRecord {
const summary = normalizeTaskSummary(event.summary);
return {
at: event.at,
kind: event.kind,
...(summary ? { summary } : {}),
};
}
+194
View File
@@ -0,0 +1,194 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
import {
assertParentFlowLinkAllowed,
ensureLinkedTaskFlowRegistryReady,
ensureNotifyPolicy,
} from "./task-registry-common.js";
import { updateTask, upsertTaskDeliveryState } from "./task-registry-mutation.js";
import { cloneTaskRecord } from "./task-registry-records.js";
import {
getTasksByRunId,
pickPreferredRunIdTask,
taskDeliveryStates,
} from "./task-registry-state.js";
import type {
JsonValue,
TaskDeliveryState,
TaskDeliveryStatus,
TaskNotifyPolicy,
TaskRecord,
TaskRuntime,
TaskScopeKind,
} from "./task-registry.types.js";
export function findExistingTaskForCreate(params: {
runtime: TaskRuntime;
ownerKey: string;
scopeKind: TaskScopeKind;
childSessionKey?: string;
parentFlowId?: string;
runId?: string;
label?: string;
task: string;
}): TaskRecord | undefined {
const runId = params.runId?.trim();
const runScopeMatches = runId
? getTasksByRunId(runId).filter((task) => {
if (
task.runtime !== params.runtime ||
task.scopeKind !== params.scopeKind ||
(normalizeOptionalString(task.ownerKey) ?? "") !==
(normalizeOptionalString(params.ownerKey) ?? "") ||
(normalizeOptionalString(task.childSessionKey) ?? "") !==
(normalizeOptionalString(params.childSessionKey) ?? "")
) {
return false;
}
if (params.runtime === "acp") {
// ACP one-task flow ids can be derived after creation; they must not
// split one logical ACP run into duplicate task rows.
return true;
}
return (
(normalizeOptionalString(task.parentFlowId) ?? "") ===
(normalizeOptionalString(params.parentFlowId) ?? "")
);
})
: [];
const exact = runId
? runScopeMatches.find(
(task) =>
(normalizeOptionalString(task.label) ?? "") ===
(normalizeOptionalString(params.label) ?? "") &&
(normalizeOptionalString(task.task) ?? "") ===
(normalizeOptionalString(params.task) ?? ""),
)
: undefined;
if (exact) {
return exact;
}
if (!runId || params.runtime !== "acp") {
return undefined;
}
if (runScopeMatches.length === 0) {
return undefined;
}
return pickPreferredRunIdTask(runScopeMatches);
}
export function mergeExistingTaskForCreate(
existing: TaskRecord,
params: {
taskKind?: string;
requesterOrigin?: TaskDeliveryState["requesterOrigin"];
sourceId?: string;
parentFlowId?: string;
parentTaskId?: string;
agentId?: string;
requesterAgentId?: string;
label?: string;
task: string;
preferMetadata?: boolean;
deliveryStatus?: TaskDeliveryStatus;
notifyPolicy?: TaskNotifyPolicy;
detail?: JsonValue;
},
): TaskRecord | null {
ensureLinkedTaskFlowRegistryReady(existing);
const patch: Partial<TaskRecord> = {};
const requesterOrigin = normalizeDeliveryContext(params.requesterOrigin);
const currentDeliveryState = taskDeliveryStates.get(existing.taskId);
if (requesterOrigin && !currentDeliveryState?.requesterOrigin) {
const deliveryState = upsertTaskDeliveryState({
taskId: existing.taskId,
requesterOrigin,
lastNotifiedEventAt: currentDeliveryState?.lastNotifiedEventAt,
});
if (!deliveryState.requesterOrigin) {
return null;
}
}
if (params.sourceId?.trim() && !existing.sourceId?.trim()) {
patch.sourceId = params.sourceId.trim();
}
if (params.taskKind?.trim() && !existing.taskKind?.trim()) {
patch.taskKind = params.taskKind.trim();
}
if (params.parentFlowId?.trim() && !existing.parentFlowId?.trim()) {
assertParentFlowLinkAllowed({
ownerKey: existing.ownerKey,
scopeKind: existing.scopeKind,
parentFlowId: params.parentFlowId,
});
patch.parentFlowId = params.parentFlowId.trim();
}
if (params.parentTaskId?.trim() && !existing.parentTaskId?.trim()) {
patch.parentTaskId = params.parentTaskId.trim();
}
if (params.agentId?.trim() && !existing.agentId?.trim()) {
patch.agentId = params.agentId.trim();
}
if (params.requesterAgentId?.trim() && !existing.requesterAgentId?.trim()) {
patch.requesterAgentId = params.requesterAgentId.trim();
}
const nextLabel = params.label?.trim();
if (params.preferMetadata) {
if (nextLabel && (normalizeOptionalString(existing.label) ?? "") !== nextLabel) {
patch.label = nextLabel;
}
const nextTask = params.task.trim();
if (nextTask && (normalizeOptionalString(existing.task) ?? "") !== nextTask) {
patch.task = nextTask;
}
} else if (nextLabel && !existing.label?.trim()) {
patch.label = nextLabel;
}
if (params.deliveryStatus === "pending" && existing.deliveryStatus !== "delivered") {
patch.deliveryStatus = "pending";
}
const notifyPolicy = ensureNotifyPolicy({
notifyPolicy: params.notifyPolicy,
deliveryStatus: params.deliveryStatus,
ownerKey: existing.ownerKey,
scopeKind: existing.scopeKind,
});
if (notifyPolicy !== existing.notifyPolicy && existing.notifyPolicy === "silent") {
patch.notifyPolicy = notifyPolicy;
}
if (params.detail !== undefined) {
patch.detail = params.detail;
}
if (Object.keys(patch).length === 0) {
return cloneTaskRecord(existing);
}
return updateTask(existing.taskId, patch);
}
export function resolveTaskAgentId(params: {
explicitAgentId?: string;
childSessionKey?: string;
ownerKey: string;
requesterSessionKey: string;
}): string | undefined {
return (
normalizeOptionalString(params.explicitAgentId) ??
parseAgentSessionKey(params.childSessionKey)?.agentId ??
parseAgentSessionKey(params.ownerKey)?.agentId ??
parseAgentSessionKey(params.requesterSessionKey)?.agentId
);
}
export function resolveTaskRequesterAgentId(params: {
explicitRequesterAgentId?: string;
ownerKey: string;
requesterSessionKey: string;
}): string | undefined {
const explicitRequesterAgentId = normalizeOptionalString(params.explicitRequesterAgentId);
return (
(explicitRequesterAgentId ? normalizeAgentId(explicitRequesterAgentId) : undefined) ??
parseAgentSessionKey(params.ownerKey)?.agentId ??
parseAgentSessionKey(params.requesterSessionKey)?.agentId
);
}
+450
View File
@@ -0,0 +1,450 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { shouldRouteCompletionThroughRequesterSession } from "../auto-reply/reply/completion-delivery-policy.js";
import { requestHeartbeat } from "../infra/heartbeat-wake.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import {
isGatewayRestartDraining,
runWithGatewayIndependentRootWorkAdmission,
} from "../process/gateway-work-admission.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
import { isDeliverableMessageChannel } from "../utils/message-channel.js";
import {
formatTaskBlockedFollowupMessage,
formatTaskStateChangeMessage,
formatTaskTerminalMessage,
shouldAutoDeliverTaskStateChange,
shouldAutoDeliverTaskTerminalUpdate,
shouldSuppressDuplicateTerminalDelivery,
shouldUseParentReviewTaskTerminalMessage,
} from "./task-executor-policy.js";
import { getTaskFlowById } from "./task-flow-runtime-internal.js";
import type { TaskDeliveryOwner } from "./task-registry-common.js";
import {
getTaskDeliveryState,
updateTask,
upsertTaskDeliveryState,
} from "./task-registry-mutation.js";
import { cloneTaskRecord } from "./task-registry-records.js";
import {
ensureTaskRegistryReady,
getPeerTasksForDelivery,
loadTaskRegistryDeliveryRuntime,
log,
pickPreferredRunIdTask,
taskDeliveryStates,
tasks,
tasksWithPendingDelivery,
} from "./task-registry-state.js";
import type {
TaskDeliveryState,
TaskDeliveryStatus,
TaskEventRecord,
TaskRecord,
} from "./task-registry.types.js";
function taskTerminalDeliveryIdempotencyKey(task: TaskRecord): string {
const outcome = task.status === "succeeded" ? (task.terminalOutcome ?? "default") : "default";
return `task-terminal:${task.taskId}:${task.status}:${outcome}`;
}
function resolveTaskStateChangeIdempotencyKey(params: {
task: TaskRecord;
latestEvent: TaskEventRecord;
owner: TaskDeliveryOwner;
}): string {
if (params.owner.flowId) {
return `flow-event:${params.owner.flowId}:${params.task.taskId}:${params.latestEvent.at}:${params.latestEvent.kind}`;
}
return `task-event:${params.task.taskId}:${params.latestEvent.at}:${params.latestEvent.kind}`;
}
function resolveTaskTerminalIdempotencyKey(task: TaskRecord): string {
const owner = resolveTaskDeliveryOwner(task);
if (owner.flowId) {
const outcome = task.status === "succeeded" ? (task.terminalOutcome ?? "default") : "default";
return `flow-terminal:${owner.flowId}:${task.taskId}:${task.status}:${outcome}`;
}
return taskTerminalDeliveryIdempotencyKey(task);
}
function getLinkedFlowForDelivery(task: TaskRecord) {
const flowId = task.parentFlowId?.trim();
if (!flowId || task.scopeKind !== "session") {
return undefined;
}
const flow = getTaskFlowById(flowId);
if (!flow) {
return undefined;
}
if (normalizeOptionalString(flow.ownerKey) !== normalizeOptionalString(task.ownerKey)) {
return undefined;
}
return flow;
}
function resolveTaskDeliveryOwner(task: TaskRecord): TaskDeliveryOwner {
const flow = getLinkedFlowForDelivery(task);
if (flow) {
return {
sessionKey: flow.ownerKey.trim(),
requesterOrigin: normalizeDeliveryContext(
flow.requesterOrigin ?? taskDeliveryStates.get(task.taskId)?.requesterOrigin,
),
flowId: flow.flowId,
};
}
if (task.scopeKind !== "session") {
return {};
}
return {
sessionKey: task.ownerKey.trim(),
requesterOrigin: normalizeDeliveryContext(taskDeliveryStates.get(task.taskId)?.requesterOrigin),
};
}
function canDeliverTaskToRequesterOrigin(task: TaskRecord): boolean {
const owner = resolveTaskDeliveryOwner(task);
if (shouldRouteCompletionThroughRequesterSession(owner.sessionKey)) {
return false;
}
return canDeliverToRequesterOrigin(owner.requesterOrigin);
}
function canDeliverToRequesterOrigin(origin: TaskDeliveryState["requesterOrigin"]): boolean {
const channel = origin?.channel?.trim();
const to = origin?.to?.trim();
return Boolean(channel && to && isDeliverableMessageChannel(channel));
}
function canDeliverParentReviewTaskToBoundDiscordThread(task: TaskRecord): boolean {
if (!shouldUseParentReviewTaskTerminalMessage(task)) {
return false;
}
const owner = resolveTaskDeliveryOwner(task);
const origin = owner.requesterOrigin;
const channel = origin?.channel?.trim().toLowerCase();
const to = origin?.to?.trim().toLowerCase();
const threadId = String(origin?.threadId ?? "").trim();
// This is a narrow transport exception for explicitly bound Discord threads,
// not a general parent-review direct-delivery relaxation.
return Boolean(
channel === "discord" &&
to?.startsWith("channel:") &&
threadId &&
canDeliverToRequesterOrigin(origin),
);
}
function resolveMissingOwnerDeliveryStatus(task: TaskRecord): TaskDeliveryStatus {
return task.scopeKind === "system" ? "not_applicable" : "parent_missing";
}
function queueTaskSystemEvent(task: TaskRecord, text: string) {
const owner = resolveTaskDeliveryOwner(task);
const ownerKey = owner.sessionKey?.trim();
if (!ownerKey) {
return false;
}
enqueueSystemEvent(text, {
sessionKey: ownerKey,
contextKey: `task:${task.taskId}`,
deliveryContext: owner.requesterOrigin,
});
requestHeartbeat({
source: "background-task",
intent: "immediate",
reason: "background-task",
sessionKey: ownerKey,
});
return true;
}
function queueBlockedTaskFollowup(task: TaskRecord) {
const followupText = formatTaskBlockedFollowupMessage(task);
if (!followupText) {
return false;
}
const owner = resolveTaskDeliveryOwner(task);
const ownerKey = owner.sessionKey?.trim();
if (!ownerKey) {
return false;
}
enqueueSystemEvent(followupText, {
sessionKey: ownerKey,
contextKey: `task:${task.taskId}:blocked-followup`,
deliveryContext: owner.requesterOrigin,
});
requestHeartbeat({
source: "background-task-blocked",
intent: "immediate",
reason: "background-task-blocked",
sessionKey: ownerKey,
});
return true;
}
export async function maybeDeliverTaskTerminalUpdate(taskId: string): Promise<TaskRecord | null> {
return await runTaskDeliveryWithIndependentAdmission(taskId, async () =>
maybeDeliverTaskTerminalUpdateUnderAdmission(taskId),
);
}
async function runTaskDeliveryWithIndependentAdmission(
taskId: string,
deliver: () => Promise<TaskRecord | null>,
): Promise<TaskRecord | null> {
ensureTaskRegistryReady();
let admitted = false;
try {
return await runWithGatewayIndependentRootWorkAdmission(async () => {
admitted = true;
return await deliver();
});
} catch (error) {
// Late lifecycle callbacks must not leak a rejected detached promise after
// restart closes admission. An already-admitted delivery still reports its
// own failures instead of hiding them behind a concurrent restart.
if (!admitted && isGatewayRestartDraining()) {
ensureTaskRegistryReady();
const current = tasks.get(taskId);
return current ? cloneTaskRecord(current) : null;
}
throw error;
}
}
async function maybeDeliverTaskTerminalUpdateUnderAdmission(
taskId: string,
): Promise<TaskRecord | null> {
ensureTaskRegistryReady();
const current = tasks.get(taskId);
if (!current || !shouldAutoDeliverTaskTerminalUpdate(current)) {
return current ? cloneTaskRecord(current) : null;
}
if (tasksWithPendingDelivery.has(taskId)) {
return cloneTaskRecord(current);
}
tasksWithPendingDelivery.add(taskId);
try {
const latest = tasks.get(taskId);
if (!latest || !shouldAutoDeliverTaskTerminalUpdate(latest)) {
return latest ? cloneTaskRecord(latest) : null;
}
const peers = latest.runId ? getPeerTasksForDelivery(latest) : [];
const isSubagentCancellation = latest.runtime === "subagent" && latest.status === "cancelled";
const preferred = pickPreferredRunIdTask(
isSubagentCancellation
? peers.filter((candidate) => shouldAutoDeliverTaskTerminalUpdate(candidate))
: peers,
);
const peerDeliveryCovered =
isSubagentCancellation &&
peers.some(
(candidate) =>
candidate.taskId !== latest.taskId &&
(candidate.deliveryStatus === "delivered" ||
candidate.deliveryStatus === "session_queued"),
);
if (
shouldSuppressDuplicateTerminalDelivery({
task: latest,
preferredTaskId: preferred?.taskId,
peerDeliveryCovered,
})
) {
return updateTask(taskId, {
deliveryStatus: "not_applicable",
lastEventAt: Date.now(),
});
}
const owner = resolveTaskDeliveryOwner(latest);
const ownerSessionKey = owner.sessionKey?.trim();
if (!ownerSessionKey) {
return updateTask(taskId, {
deliveryStatus: resolveMissingOwnerDeliveryStatus(latest),
lastEventAt: Date.now(),
});
}
const shouldRouteParentReview = shouldUseParentReviewTaskTerminalMessage(latest);
const shouldDeliverParentReviewDirect = canDeliverParentReviewTaskToBoundDiscordThread(latest);
const canDeliverDirect =
canDeliverTaskToRequesterOrigin(latest) || shouldDeliverParentReviewDirect;
const directEventText = formatTaskTerminalMessage(latest);
const sessionEventText = formatTaskTerminalMessage(
latest,
shouldRouteParentReview ? { surface: "parent_session" } : undefined,
);
if ((shouldRouteParentReview && !shouldDeliverParentReviewDirect) || !canDeliverDirect) {
try {
queueTaskSystemEvent(latest, sessionEventText);
if (latest.terminalOutcome === "blocked") {
queueBlockedTaskFollowup(latest);
}
return updateTask(taskId, {
deliveryStatus:
shouldRouteParentReview && canDeliverDirect ? "pending" : "session_queued",
lastEventAt: Date.now(),
});
} catch (error) {
log.warn("Failed to queue background task session delivery", {
taskId,
ownerKey: latest.ownerKey,
error,
});
return updateTask(taskId, {
deliveryStatus: "failed",
lastEventAt: Date.now(),
});
}
}
try {
const { sendMessage } = await loadTaskRegistryDeliveryRuntime();
const beforeSend = tasks.get(taskId);
if (!beforeSend || !shouldAutoDeliverTaskTerminalUpdate(beforeSend)) {
return beforeSend ? cloneTaskRecord(beforeSend) : null;
}
const requesterAgentId = parseAgentSessionKey(ownerSessionKey)?.agentId;
const idempotencyKey = resolveTaskTerminalIdempotencyKey(latest);
await sendMessage({
channel: owner.requesterOrigin?.channel,
to: owner.requesterOrigin?.to ?? "",
accountId: owner.requesterOrigin?.accountId,
threadId: owner.requesterOrigin?.threadId,
content: shouldDeliverParentReviewDirect ? sessionEventText : directEventText,
agentId: requesterAgentId,
idempotencyKey,
mirror: {
sessionKey: ownerSessionKey,
agentId: requesterAgentId,
idempotencyKey,
},
});
const afterSend = tasks.get(taskId);
if (!afterSend || !shouldAutoDeliverTaskTerminalUpdate(afterSend)) {
return afterSend ? cloneTaskRecord(afterSend) : null;
}
if (afterSend.terminalOutcome === "blocked") {
queueBlockedTaskFollowup(afterSend);
}
return updateTask(taskId, {
deliveryStatus: "delivered",
lastEventAt: Date.now(),
});
} catch (error) {
log.warn("Failed to deliver background task update", {
taskId,
ownerKey: ownerSessionKey,
requesterOrigin: owner.requesterOrigin,
error,
});
const beforeFallback = tasks.get(taskId);
if (!beforeFallback || !shouldAutoDeliverTaskTerminalUpdate(beforeFallback)) {
return beforeFallback ? cloneTaskRecord(beforeFallback) : null;
}
try {
queueTaskSystemEvent(beforeFallback, sessionEventText);
if (beforeFallback.terminalOutcome === "blocked") {
queueBlockedTaskFollowup(beforeFallback);
}
} catch (fallbackError) {
log.warn("Failed to queue background task fallback event", {
taskId,
ownerKey: latest.ownerKey,
error: fallbackError,
});
}
return updateTask(taskId, {
deliveryStatus: "failed",
lastEventAt: Date.now(),
});
}
} finally {
tasksWithPendingDelivery.delete(taskId);
}
}
export async function maybeDeliverTaskStateChangeUpdate(
taskId: string,
latestEvent?: TaskEventRecord,
): Promise<TaskRecord | null> {
return await runTaskDeliveryWithIndependentAdmission(taskId, async () =>
maybeDeliverTaskStateChangeUpdateUnderAdmission(taskId, latestEvent),
);
}
async function maybeDeliverTaskStateChangeUpdateUnderAdmission(
taskId: string,
latestEvent?: TaskEventRecord,
): Promise<TaskRecord | null> {
ensureTaskRegistryReady();
const current = tasks.get(taskId);
if (!current || !shouldAutoDeliverTaskStateChange(current)) {
return current ? cloneTaskRecord(current) : null;
}
const deliveryState = getTaskDeliveryState(taskId);
if (!latestEvent || (deliveryState?.lastNotifiedEventAt ?? 0) >= latestEvent.at) {
return cloneTaskRecord(current);
}
const eventText = formatTaskStateChangeMessage(current, latestEvent);
if (!eventText) {
return cloneTaskRecord(current);
}
try {
const owner = resolveTaskDeliveryOwner(current);
const ownerSessionKey = owner.sessionKey?.trim();
if (!ownerSessionKey) {
return updateTask(taskId, {
deliveryStatus: resolveMissingOwnerDeliveryStatus(current),
lastEventAt: Date.now(),
});
}
if (!canDeliverTaskToRequesterOrigin(current)) {
queueTaskSystemEvent(current, eventText);
upsertTaskDeliveryState({
taskId,
requesterOrigin: deliveryState?.requesterOrigin,
lastNotifiedEventAt: latestEvent.at,
});
return updateTask(taskId, {
lastEventAt: Date.now(),
});
}
const { sendMessage } = await loadTaskRegistryDeliveryRuntime();
const requesterAgentId = parseAgentSessionKey(ownerSessionKey)?.agentId;
const idempotencyKey = resolveTaskStateChangeIdempotencyKey({
task: current,
latestEvent,
owner,
});
await sendMessage({
channel: owner.requesterOrigin?.channel,
to: owner.requesterOrigin?.to ?? "",
accountId: owner.requesterOrigin?.accountId,
threadId: owner.requesterOrigin?.threadId,
content: eventText,
agentId: requesterAgentId,
idempotencyKey,
mirror: {
sessionKey: ownerSessionKey,
agentId: requesterAgentId,
idempotencyKey,
},
});
upsertTaskDeliveryState({
taskId,
requesterOrigin: deliveryState?.requesterOrigin,
lastNotifiedEventAt: latestEvent.at,
});
return updateTask(taskId, {
lastEventAt: Date.now(),
});
} catch (error) {
log.warn("Failed to deliver background task state change", {
taskId,
ownerKey: current.ownerKey,
error,
});
return cloneTaskRecord(current);
}
}
+124
View File
@@ -0,0 +1,124 @@
import { onAgentEvent } from "../infra/agent-events.js";
import { isTerminalTaskStatus } from "./task-executor-policy.js";
import {
appendTaskEvent,
buildTaskLifecycleTerminalOutcome,
mapAgentRunTerminalOutcomeToTaskStatus,
resolveTaskLifecycleTerminalError,
} from "./task-registry-common.js";
import {
maybeDeliverTaskStateChangeUpdate,
maybeDeliverTaskTerminalUpdate,
} from "./task-registry-delivery.js";
import { updateTask } from "./task-registry-mutation.js";
import {
claimTaskRegistryListenerStart,
getTasksByRunScope,
restoreTaskRegistryOnce,
setTaskRegistryListenerStarter,
setTaskRegistryListenerStop,
} from "./task-registry-state.js";
import type { TaskRecord } from "./task-registry.types.js";
function ensureListener() {
if (!claimTaskRegistryListenerStart()) {
return;
}
const stop = onAgentEvent((evt) => {
restoreTaskRegistryOnce();
const scopedTasks = getTasksByRunScope({
runId: evt.runId,
sessionKey: evt.sessionKey,
});
if (scopedTasks.length === 0) {
return;
}
const now = evt.ts || Date.now();
for (const current of scopedTasks) {
if (isTerminalTaskStatus(current.status)) {
continue;
}
const patch: Partial<TaskRecord> = {
lastEventAt: now,
};
if (evt.stream === "lifecycle") {
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : undefined;
const eventStartedAt = evt.data?.startedAt;
const startedAt =
typeof eventStartedAt === "number" && Number.isFinite(eventStartedAt)
? eventStartedAt
: current.startedAt;
const endedAt = typeof evt.data?.endedAt === "number" ? evt.data.endedAt : undefined;
if (startedAt !== undefined) {
patch.startedAt = startedAt;
}
if (phase === "start") {
patch.status = "running";
} else if (phase === "end") {
const terminal = buildTaskLifecycleTerminalOutcome({
phase,
data: evt.data,
startedAt,
endedAt: endedAt ?? now,
});
patch.status = mapAgentRunTerminalOutcomeToTaskStatus(terminal);
patch.endedAt = terminal.endedAt ?? now;
const error = resolveTaskLifecycleTerminalError({
runtime: current.runtime,
status: patch.status,
error: terminal.error,
});
if (error) {
patch.error = error;
}
} else if (phase === "error") {
const terminal = buildTaskLifecycleTerminalOutcome({
phase,
data: evt.data,
startedAt,
endedAt: endedAt ?? now,
});
patch.status = mapAgentRunTerminalOutcomeToTaskStatus(terminal);
patch.endedAt = terminal.endedAt ?? now;
patch.error =
resolveTaskLifecycleTerminalError({
runtime: current.runtime,
status: patch.status,
error: terminal.error,
}) ?? current.error;
}
} else if (evt.stream === "error") {
patch.error = typeof evt.data?.error === "string" ? evt.data.error : current.error;
} else if (evt.stream === "tool" && evt.data?.phase === "start") {
// Tool starts are the activity signal surfaced in task summaries; ends
// and outputs only refresh lastEventAt.
const toolName = typeof evt.data.name === "string" ? evt.data.name.trim() : "";
if (toolName) {
patch.toolUseCount = (current.toolUseCount ?? 0) + 1;
patch.lastToolName = toolName;
}
}
const stateChangeEvent =
patch.status && patch.status !== current.status
? appendTaskEvent({
at: now,
kind: patch.status,
summary:
patch.status === "failed"
? (patch.error ?? current.error)
: patch.status === "succeeded"
? current.terminalSummary
: undefined,
})
: undefined;
const updated = updateTask(current.taskId, patch);
if (updated) {
void maybeDeliverTaskStateChangeUpdate(current.taskId, stateChangeEvent);
void maybeDeliverTaskTerminalUpdate(current.taskId);
}
}
});
setTaskRegistryListenerStop(stop);
}
setTaskRegistryListenerStarter(ensureListener);
+243
View File
@@ -0,0 +1,243 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { runWithGatewayIndependentRootWorkAdmission } from "../process/gateway-work-admission.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
import { isTaskFlowCancellationPending } from "./task-cancellation-state.js";
import { isTerminalTaskStatus } from "./task-executor-policy.js";
import {
getTaskFlowById,
syncFlowFromTaskResult,
updateFlowRecordByIdExpectedRevision,
} from "./task-flow-runtime-internal.js";
import { ensureLinkedTaskFlowRegistryReady, isTerminalFlowStatus } from "./task-registry-common.js";
import { findLatestTaskForFlowId, listTasksForFlowId } from "./task-registry-query.js";
import {
cloneTaskDeliveryState,
cloneTaskRecord,
normalizeTaskTimestamps,
} from "./task-registry-records.js";
import {
addOwnerKeyIndex,
addParentFlowIdIndex,
addRelatedSessionKeyIndex,
deleteOwnerKeyIndex,
deleteParentFlowIdIndex,
deleteRelatedSessionKeyIndex,
emitTaskRegistryObserverEvent,
log,
rebuildRunIdIndex,
taskDeliveryStates,
taskFlowSyncRetryTimers,
tasks,
TASK_FLOW_SYNC_RETRY_DELAYS_MS,
tryPersistTaskDeliveryStateUpsert,
tryPersistTaskUpsert,
} from "./task-registry-state.js";
import type { TaskDeliveryState, TaskRecord } from "./task-registry.types.js";
import { resolveTaskCleanupAfter } from "./task-retention.js";
function syncManagedFlowCancellationFromTask(task: TaskRecord): void {
const flowId = task.parentFlowId?.trim();
if (!flowId) {
return;
}
let flow = getTaskFlowById(flowId);
if (
!flow ||
flow.syncMode !== "managed" ||
flow.cancelRequestedAt == null ||
isTerminalFlowStatus(flow.status)
) {
return;
}
if (listTasksForFlowId(flowId).some(isTaskFlowCancellationPending)) {
return;
}
const endedAt = task.endedAt ?? task.lastEventAt ?? Date.now();
for (let attempt = 0; attempt < 2; attempt += 1) {
const result = updateFlowRecordByIdExpectedRevision({
flowId,
expectedRevision: flow.revision,
patch: {
status: "cancelled",
blockedTaskId: null,
blockedSummary: null,
waitJson: null,
endedAt,
updatedAt: endedAt,
},
});
if (result.applied || result.reason === "not_found") {
return;
}
flow = result.current;
if (
!flow ||
flow.syncMode !== "managed" ||
flow.cancelRequestedAt == null ||
isTerminalFlowStatus(flow.status)
) {
return;
}
if (listTasksForFlowId(flowId).some(isTaskFlowCancellationPending)) {
return;
}
}
}
function scheduleTaskFlowSyncRetry(task: TaskRecord, operation: string, attempt = 0): void {
const taskId = task.taskId.trim();
if (!taskId || taskFlowSyncRetryTimers.has(taskId)) {
return;
}
const delayMs = TASK_FLOW_SYNC_RETRY_DELAYS_MS[attempt];
if (delayMs == null) {
log.warn("Exhausted parent flow sync retries from task", {
operation,
taskId,
flowId: task.parentFlowId,
});
return;
}
const retryTimer = setTimeout(() => {
taskFlowSyncRetryTimers.delete(taskId);
// A terminal task no longer blocks suspension, but its durable parent-flow
// projection still mutates state. Keep every delayed attempt visible and
// prevent it from crossing a prepared host snapshot boundary.
void runWithGatewayIndependentRootWorkAdmission(async () => {
const current = tasks.get(taskId);
if (!current) {
return;
}
const flowId = current.parentFlowId?.trim();
if (!flowId || findLatestTaskForFlowId(flowId)?.taskId !== taskId) {
return;
}
const result = syncFlowFromTaskResult(current);
if (!result.ok) {
log.warn("Failed to retry parent flow sync from task", {
operation,
taskId,
flowId: current.parentFlowId,
reason: result.reason,
});
scheduleTaskFlowSyncRetry(current, operation, attempt + 1);
}
}).catch((error: unknown) => {
log.warn("Failed to admit parent flow sync retry from task", {
operation,
taskId,
flowId: task.parentFlowId,
error,
});
});
}, delayMs);
retryTimer.unref?.();
taskFlowSyncRetryTimers.set(taskId, retryTimer);
}
export function syncFlowFromTaskAfterTaskMutation(task: TaskRecord, operation: string): void {
const result = syncFlowFromTaskResult(task);
if (result.ok) {
return;
}
log.warn("Failed to sync parent flow from task mutation", {
operation,
taskId: task.taskId,
flowId: task.parentFlowId,
reason: result.reason,
});
scheduleTaskFlowSyncRetry(task, operation);
}
export function updateTask(taskId: string, patch: Partial<TaskRecord>): TaskRecord | null {
const current = tasks.get(taskId);
if (!current) {
return null;
}
const next = normalizeTaskTimestamps({
...current,
...patch,
...(patch.detail !== undefined ? { detail: structuredClone(patch.detail) } : {}),
});
if (Object.hasOwn(patch, "error") && patch.error === undefined) {
delete next.error;
}
if (Object.hasOwn(patch, "childSessionKey") && patch.childSessionKey === undefined) {
delete next.childSessionKey;
}
if (isTerminalTaskStatus(next.status) && typeof next.cleanupAfter !== "number") {
const createdAt = next.createdAt ?? Date.now();
const cleanupAfter = resolveTaskCleanupAfter({ ...next, createdAt });
Object.assign(next, cleanupAfter === undefined ? {} : { cleanupAfter });
}
const sessionIndexChanged =
normalizeOptionalString(current.ownerKey) !== normalizeOptionalString(next.ownerKey) ||
normalizeOptionalString(current.childSessionKey) !==
normalizeOptionalString(next.childSessionKey);
const parentFlowIndexChanged = current.parentFlowId?.trim() !== next.parentFlowId?.trim();
ensureLinkedTaskFlowRegistryReady(current);
ensureLinkedTaskFlowRegistryReady(next);
// Persist before mutating memory. If the store rejects the write, keep the
// in-memory mirror at the durable value and report that no mutation applied.
if (!tryPersistTaskUpsert(next, "update")) {
return null;
}
tasks.set(taskId, next);
if (patch.runId && patch.runId !== current.runId) {
rebuildRunIdIndex();
}
if (sessionIndexChanged) {
deleteOwnerKeyIndex(taskId, current);
addOwnerKeyIndex(taskId, next);
deleteRelatedSessionKeyIndex(taskId, current);
addRelatedSessionKeyIndex(taskId, next);
}
if (parentFlowIndexChanged) {
deleteParentFlowIdIndex(taskId, current);
addParentFlowIdIndex(taskId, next);
}
syncFlowFromTaskAfterTaskMutation(next, "update");
try {
syncManagedFlowCancellationFromTask(next);
} catch (error) {
log.warn("Failed to finalize managed flow cancellation from task update", {
taskId,
flowId: next.parentFlowId,
error,
});
}
emitTaskRegistryObserverEvent(() => ({
kind: "upserted",
task: cloneTaskRecord(next),
previous: cloneTaskRecord(current),
}));
return cloneTaskRecord(next);
}
export function upsertTaskDeliveryState(state: TaskDeliveryState): TaskDeliveryState {
const current = taskDeliveryStates.get(state.taskId);
const next: TaskDeliveryState = {
taskId: state.taskId,
...(state.requesterOrigin
? { requesterOrigin: normalizeDeliveryContext(state.requesterOrigin) }
: {}),
...(state.lastNotifiedEventAt != null
? { lastNotifiedEventAt: state.lastNotifiedEventAt }
: {}),
};
if (!next.requesterOrigin && typeof next.lastNotifiedEventAt !== "number" && !current) {
return cloneTaskDeliveryState({ taskId: state.taskId });
}
if (!tryPersistTaskDeliveryStateUpsert(next)) {
return current
? cloneTaskDeliveryState(current)
: cloneTaskDeliveryState({ taskId: state.taskId });
}
taskDeliveryStates.set(state.taskId, next);
return cloneTaskDeliveryState(next);
}
export function getTaskDeliveryState(taskId: string): TaskDeliveryState | undefined {
const state = taskDeliveryStates.get(taskId);
return state ? cloneTaskDeliveryState(state) : undefined;
}
+275
View File
@@ -0,0 +1,275 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { isActiveTaskStatus, ensureLinkedTaskFlowRegistryReady } from "./task-registry-common.js";
import type { TaskRegistryControlRuntime } from "./task-registry-control.types.js";
import { cloneTaskRecord, normalizeTaskTimestamps } from "./task-registry-records.js";
import {
TASK_REGISTRY_CONTROL_RUNTIME_OVERRIDE_KEY,
TASK_REGISTRY_DELIVERY_RUNTIME_OVERRIDE_KEY,
clearTaskRegistryMemory,
compareTasksNewestFirst,
controlRuntimeLoader,
deleteOwnerKeyIndex,
deleteParentFlowIdIndex,
deleteRelatedSessionKeyIndex,
deliveryRuntimeLoader,
emitTaskRegistryObserverEvent,
ensureTaskRegistryReady,
getTasksByRunId,
log,
persistTaskRegistry,
pickPreferredRunIdTask,
rebuildRunIdIndex,
resetTaskRegistryListenerState,
resetTaskRegistryRestoreState,
snapshotTaskRecords,
taskDeliveryStates,
taskIdsByOwnerKey,
taskIdsByParentFlowId,
taskIdsByRelatedSessionKey,
tasks,
tryPersistTaskDelete,
type TaskRegistryDeliveryRuntime,
type TaskRegistryGlobalWithRuntimeOverrides,
} from "./task-registry-state.js";
import { getTaskRegistryStore, resetTaskRegistryRuntimeForTests } from "./task-registry.store.js";
import type { TaskRecord } from "./task-registry.types.js";
export function listTaskRecordsUnsorted(): TaskRecord[] {
ensureTaskRegistryReady();
return snapshotTaskRecords(tasks);
}
export function listTaskRecords(): TaskRecord[] {
ensureTaskRegistryReady();
return [...tasks.values()]
.map((task, insertionIndex) => Object.assign({}, cloneTaskRecord(task), { insertionIndex }))
.toSorted(compareTasksNewestFirst)
.map(({ insertionIndex: _, ...task }) => task);
}
export function hasActiveTaskForChildSessionKey(params: {
sessionKey: string;
excludeTaskId?: string;
}): boolean {
ensureTaskRegistryReady();
const sessionKey = normalizeOptionalString(params.sessionKey);
if (!sessionKey) {
return false;
}
const ids = taskIdsByRelatedSessionKey.get(sessionKey);
if (!ids) {
return false;
}
for (const taskId of ids) {
if (taskId === params.excludeTaskId) {
continue;
}
const task = tasks.get(taskId);
if (
task &&
isActiveTaskStatus(task.status) &&
normalizeOptionalString(task.childSessionKey) === sessionKey
) {
return true;
}
}
return false;
}
export function getTaskById(taskId: string): TaskRecord | undefined {
ensureTaskRegistryReady();
const task = tasks.get(taskId.trim());
return task ? cloneTaskRecord(task) : undefined;
}
export function findTaskByRunId(runId: string): TaskRecord | undefined {
ensureTaskRegistryReady();
const task = pickPreferredRunIdTask(getTasksByRunId(runId));
return task ? cloneTaskRecord(task) : undefined;
}
function listTasksFromIndex(index: Map<string, Set<string>>, key: string): TaskRecord[] {
const ids = index.get(key);
if (!ids || ids.size === 0) {
return [];
}
return [...ids]
.map((taskId, insertionIndex) => {
const task = tasks.get(taskId);
return task ? Object.assign({}, cloneTaskRecord(task), { insertionIndex }) : null;
})
.filter(
(
task,
): task is TaskRecord & {
insertionIndex: number;
} => Boolean(task),
)
.toSorted(compareTasksNewestFirst)
.map(({ insertionIndex: _, ...task }) => task);
}
export function listTasksForSessionKey(sessionKey: string): TaskRecord[] {
ensureTaskRegistryReady();
const key = normalizeOptionalString(sessionKey);
if (!key) {
return [];
}
return listTasksFromIndex(taskIdsByRelatedSessionKey, key);
}
export function listTasksForAgentId(agentId: string): TaskRecord[] {
ensureTaskRegistryReady();
const lookup = agentId.trim();
if (!lookup) {
return [];
}
return snapshotTaskRecords(tasks)
.filter((task) => task.agentId?.trim() === lookup)
.toSorted(compareTasksNewestFirst);
}
export function findLatestTaskForFlowId(flowId: string): TaskRecord | undefined {
const task = listTasksForFlowId(flowId)[0];
return task ? cloneTaskRecord(task) : undefined;
}
export function listTasksForOwnerKey(ownerKey: string): TaskRecord[] {
ensureTaskRegistryReady();
const key = normalizeOptionalString(ownerKey);
if (!key) {
return [];
}
return listTasksFromIndex(taskIdsByOwnerKey, key);
}
export function listFreshTasksForOwnerKey(ownerKey: string): TaskRecord[] {
ensureTaskRegistryReady();
const key = normalizeOptionalString(ownerKey);
if (!key) {
return [];
}
const store = getTaskRegistryStore();
if (store.listTasksForOwnerKey) {
try {
const merged = new Map<string, TaskRecord>();
for (const task of store.listTasksForOwnerKey(key)) {
merged.set(task.taskId, cloneTaskRecord(normalizeTaskTimestamps(task)));
}
return [...merged.values()]
.map((task, insertionIndex) => Object.assign({}, task, { insertionIndex }))
.toSorted(compareTasksNewestFirst)
.map(({ insertionIndex: _, ...task }) => task);
} catch (error) {
log.warn("Failed to read fresh owner task registry records", {
ownerKey: key,
error,
});
}
}
return listTasksFromIndex(taskIdsByOwnerKey, key);
}
export function listTasksForFlowId(flowId: string): TaskRecord[] {
ensureTaskRegistryReady();
const key = flowId.trim();
if (!key) {
return [];
}
return listTasksFromIndex(taskIdsByParentFlowId, key);
}
function findLatestTaskForRelatedSessionKey(sessionKey: string): TaskRecord | undefined {
const task = listTasksForRelatedSessionKey(sessionKey)[0];
return task ? cloneTaskRecord(task) : undefined;
}
export function listTasksForRelatedSessionKey(sessionKey: string): TaskRecord[] {
ensureTaskRegistryReady();
const key = normalizeOptionalString(sessionKey);
if (!key) {
return [];
}
return listTasksFromIndex(taskIdsByRelatedSessionKey, key);
}
export function resolveTaskForLookupToken(token: string): TaskRecord | undefined {
const lookup = token.trim();
if (!lookup) {
return undefined;
}
return (
getTaskById(lookup) ?? findTaskByRunId(lookup) ?? findLatestTaskForRelatedSessionKey(lookup)
);
}
export function deleteTaskRecordById(taskId: string): boolean {
ensureTaskRegistryReady();
const current = tasks.get(taskId);
if (!current) {
return false;
}
ensureLinkedTaskFlowRegistryReady(current);
// Persist the delete before mutating memory, as a single atomic store
// operation. If persistence fails, leave the in-memory record intact and
// report that no delete was applied.
if (!tryPersistTaskDelete(taskId)) {
return false;
}
deleteOwnerKeyIndex(taskId, current);
deleteParentFlowIdIndex(taskId, current);
deleteRelatedSessionKeyIndex(taskId, current);
tasks.delete(taskId);
taskDeliveryStates.delete(taskId);
rebuildRunIdIndex();
emitTaskRegistryObserverEvent(() => ({
kind: "deleted",
taskId: current.taskId,
previous: cloneTaskRecord(current),
}));
return true;
}
export function resetTaskRegistryForTests(opts?: { persist?: boolean }) {
clearTaskRegistryMemory();
resetTaskRegistryRestoreState();
resetTaskRegistryRuntimeForTests();
resetTaskRegistryListenerState();
deliveryRuntimeLoader.clear();
controlRuntimeLoader.clear();
if (opts?.persist !== false) {
persistTaskRegistry();
}
// Always close the sqlite handle so Windows temp-dir cleanup can remove the
// state directory even when a test intentionally skips persisting the reset.
getTaskRegistryStore().close?.();
}
export function resetTaskRegistryDeliveryRuntimeForTests() {
(globalThis as TaskRegistryGlobalWithRuntimeOverrides)[
TASK_REGISTRY_DELIVERY_RUNTIME_OVERRIDE_KEY
] = null;
deliveryRuntimeLoader.clear();
}
export function setTaskRegistryDeliveryRuntimeForTests(runtime: TaskRegistryDeliveryRuntime): void {
(globalThis as TaskRegistryGlobalWithRuntimeOverrides)[
TASK_REGISTRY_DELIVERY_RUNTIME_OVERRIDE_KEY
] = runtime;
deliveryRuntimeLoader.clear();
}
export function resetTaskRegistryControlRuntimeForTests() {
(globalThis as TaskRegistryGlobalWithRuntimeOverrides)[
TASK_REGISTRY_CONTROL_RUNTIME_OVERRIDE_KEY
] = null;
controlRuntimeLoader.clear();
}
export function setTaskRegistryControlRuntimeForTests(runtime: TaskRegistryControlRuntime): void {
(globalThis as TaskRegistryGlobalWithRuntimeOverrides)[
TASK_REGISTRY_CONTROL_RUNTIME_OVERRIDE_KEY
] = runtime;
controlRuntimeLoader.clear();
}
+562
View File
@@ -0,0 +1,562 @@
import crypto from "node:crypto";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js";
import { isTerminalTaskStatus } from "./task-executor-policy.js";
import {
appendTaskEvent,
assertParentFlowLinkAllowed,
assertTaskOwner,
ensureDeliveryStatus,
ensureNotifyPolicy,
normalizeTaskStatus,
normalizeTaskSummary,
resolveTaskOwnerKey,
resolveTaskRequesterSessionKey,
resolveTaskScopeKind,
resolveTaskTerminalOutcome,
shouldApplyRunScopedStatusUpdate,
} from "./task-registry-common.js";
import {
findExistingTaskForCreate,
mergeExistingTaskForCreate,
resolveTaskAgentId,
resolveTaskRequesterAgentId,
} from "./task-registry-create-helpers.js";
import {
maybeDeliverTaskStateChangeUpdate,
maybeDeliverTaskTerminalUpdate,
} from "./task-registry-delivery.js";
import { syncFlowFromTaskAfterTaskMutation, updateTask } from "./task-registry-mutation.js";
import { cloneTaskRecord, normalizeTaskTimestamps } from "./task-registry-records.js";
import {
addOwnerKeyIndex,
addParentFlowIdIndex,
addRelatedSessionKeyIndex,
addRunIdIndex,
emitTaskRegistryObserverEvent,
ensureTaskRegistryReady,
getTasksByRunScope,
taskDeliveryStates,
tasks,
tryPersistTaskUpsert,
} from "./task-registry-state.js";
import type {
JsonValue,
TaskDeliveryState,
TaskDeliveryStatus,
TaskNotifyPolicy,
TaskRecord,
TaskRuntime,
TaskScopeKind,
TaskStatus,
TaskTerminalOutcome,
} from "./task-registry.types.js";
import { resolveTaskCleanupAfter } from "./task-retention.js";
export function setTaskCleanupAfterById(params: {
taskId: string;
cleanupAfter: number;
}): TaskRecord | null {
ensureTaskRegistryReady();
return updateTask(params.taskId, {
cleanupAfter: params.cleanupAfter,
});
}
export function markTaskTerminalById(params: {
taskId: string;
status: Extract<TaskStatus, "succeeded" | "failed" | "timed_out" | "cancelled">;
childSessionKey?: string | null;
endedAt: number;
lastEventAt?: number;
error?: string;
terminalSummary?: string | null;
preserveTerminalSummary?: boolean;
terminalOutcome?: TaskTerminalOutcome | null;
detail?: JsonValue;
}): TaskRecord | null {
ensureTaskRegistryReady();
const patch: Partial<TaskRecord> = {
status: params.status,
...(params.childSessionKey !== undefined
? { childSessionKey: params.childSessionKey?.trim() || undefined }
: {}),
endedAt: params.endedAt,
lastEventAt: params.lastEventAt ?? params.endedAt,
...(params.terminalSummary !== undefined
? {
terminalSummary: params.preserveTerminalSummary
? (params.terminalSummary ?? undefined)
: normalizeTaskSummary(params.terminalSummary),
}
: {}),
...(params.terminalOutcome !== undefined
? {
terminalOutcome: resolveTaskTerminalOutcome({
status: params.status,
terminalOutcome: params.terminalOutcome,
}),
}
: {}),
...(params.detail !== undefined ? { detail: structuredClone(params.detail) } : {}),
};
if (Object.hasOwn(params, "error")) {
patch.error = params.error;
}
return updateTask(params.taskId, patch);
}
export function markTaskLostById(params: {
taskId: string;
endedAt: number;
lastEventAt?: number;
error?: string;
cleanupAfter?: number;
}): TaskRecord | null {
ensureTaskRegistryReady();
return updateTask(params.taskId, {
status: "lost",
endedAt: params.endedAt,
lastEventAt: params.lastEventAt ?? params.endedAt,
...(params.error !== undefined ? { error: params.error } : {}),
...(params.cleanupAfter !== undefined ? { cleanupAfter: params.cleanupAfter } : {}),
});
}
function updateTasksByRunId(params: {
runId: string;
patch: Partial<TaskRecord>;
runtime?: TaskRuntime;
sessionKey?: string;
}): TaskRecord[] {
const matches = getTasksByRunScope(params);
if (matches.length === 0) {
return [];
}
const updated: TaskRecord[] = [];
for (const match of matches) {
const task = updateTask(match.taskId, params.patch);
if (task) {
updated.push(task);
}
}
return updated;
}
export function createTaskRecord(params: {
runtime: TaskRuntime;
taskKind?: string;
sourceId?: string;
requesterSessionKey?: string;
ownerKey?: string;
scopeKind?: TaskScopeKind;
requesterOrigin?: TaskDeliveryState["requesterOrigin"];
childSessionKey?: string;
parentFlowId?: string;
parentTaskId?: string;
agentId?: string;
requesterAgentId?: string;
runId?: string;
label?: string;
task: string;
preferMetadata?: boolean;
status?: TaskStatus;
deliveryStatus?: TaskDeliveryStatus;
notifyPolicy?: TaskNotifyPolicy;
startedAt?: number;
lastEventAt?: number;
cleanupAfter?: number;
progressSummary?: string | null;
terminalSummary?: string | null;
terminalOutcome?: TaskTerminalOutcome | null;
detail?: JsonValue;
}): TaskRecord | null {
ensureTaskRegistryReady();
const requesterSessionKey = resolveTaskRequesterSessionKey(params);
const scopeKind = resolveTaskScopeKind({
scopeKind: params.scopeKind,
requesterSessionKey,
});
const ownerKey = resolveTaskOwnerKey({
requesterSessionKey,
ownerKey: params.ownerKey,
});
const agentId = resolveTaskAgentId({
explicitAgentId: params.agentId,
childSessionKey: params.childSessionKey,
ownerKey,
requesterSessionKey,
});
const requesterAgentId = resolveTaskRequesterAgentId({
explicitRequesterAgentId: params.requesterAgentId,
ownerKey,
requesterSessionKey,
});
assertTaskOwner({
ownerKey,
scopeKind,
});
assertParentFlowLinkAllowed({
ownerKey,
scopeKind,
parentFlowId: params.parentFlowId,
});
const existing = findExistingTaskForCreate({
runtime: params.runtime,
ownerKey,
scopeKind,
childSessionKey: params.childSessionKey,
parentFlowId: params.parentFlowId,
runId: params.runId,
label: params.label,
task: params.task,
});
if (existing) {
return mergeExistingTaskForCreate(existing, { ...params, agentId });
}
const now = Date.now();
const taskId = crypto.randomUUID();
const status = normalizeTaskStatus(params.status);
const deliveryStatus =
params.deliveryStatus ??
ensureDeliveryStatus({
ownerKey,
scopeKind,
});
const notifyPolicy = ensureNotifyPolicy({
notifyPolicy: params.notifyPolicy,
deliveryStatus,
ownerKey,
scopeKind,
});
const lastEventAt = params.lastEventAt ?? params.startedAt ?? now;
const record: TaskRecord = normalizeTaskTimestamps({
taskId,
runtime: params.runtime,
taskKind: normalizeOptionalString(params.taskKind),
sourceId: normalizeOptionalString(params.sourceId),
requesterSessionKey,
ownerKey,
scopeKind,
childSessionKey: params.childSessionKey,
parentFlowId: normalizeOptionalString(params.parentFlowId),
parentTaskId: normalizeOptionalString(params.parentTaskId),
agentId,
requesterAgentId,
runId: normalizeOptionalString(params.runId),
label: normalizeOptionalString(params.label),
task: params.task,
status,
deliveryStatus,
notifyPolicy,
createdAt: now,
startedAt: params.startedAt,
lastEventAt,
cleanupAfter: params.cleanupAfter,
progressSummary: normalizeTaskSummary(params.progressSummary),
terminalSummary: normalizeTaskSummary(params.terminalSummary),
terminalOutcome: resolveTaskTerminalOutcome({
status,
terminalOutcome: params.terminalOutcome,
}),
...(params.detail !== undefined ? { detail: structuredClone(params.detail) } : {}),
});
if (isTerminalTaskStatus(record.status) && typeof record.cleanupAfter !== "number") {
const cleanupAfter = resolveTaskCleanupAfter(record);
Object.assign(record, cleanupAfter === undefined ? {} : { cleanupAfter });
}
const requesterOrigin = normalizeDeliveryContext(params.requesterOrigin);
const deliveryState = requesterOrigin
? {
taskId,
requesterOrigin,
}
: undefined;
if (!tryPersistTaskUpsert(record, "create", deliveryState)) {
return null;
}
tasks.set(taskId, record);
if (requesterOrigin) {
taskDeliveryStates.set(taskId, deliveryState!);
}
addRunIdIndex(taskId, record.runId);
addOwnerKeyIndex(taskId, record);
addParentFlowIdIndex(taskId, record);
addRelatedSessionKeyIndex(taskId, record);
syncFlowFromTaskAfterTaskMutation(record, "create");
emitTaskRegistryObserverEvent(() => ({
kind: "upserted",
task: cloneTaskRecord(record),
}));
if (isTerminalTaskStatus(record.status)) {
void maybeDeliverTaskTerminalUpdate(taskId);
}
return cloneTaskRecord(record);
}
export function updateTaskStateByRunId(params: {
runId: string;
runtime?: TaskRuntime;
sessionKey?: string;
childSessionKey?: string | null;
status?: TaskStatus;
startedAt?: number;
endedAt?: number;
lastEventAt?: number;
error?: string;
clearError?: boolean;
progressSummary?: string | null;
terminalSummary?: string | null;
preserveTerminalSummary?: boolean;
terminalOutcome?: TaskTerminalOutcome | null;
detail?: JsonValue;
eventSummary?: string | null;
suppressDelivery?: boolean;
}) {
ensureTaskRegistryReady();
const matches = getTasksByRunScope(params);
if (matches.length === 0) {
return [];
}
const updated: TaskRecord[] = [];
for (const current of matches) {
const patch: Partial<TaskRecord> = {};
const nextStatus = params.status ? normalizeTaskStatus(params.status) : current.status;
if (
params.status &&
!shouldApplyRunScopedStatusUpdate({
currentStatus: current.status,
currentRuntime: current.runtime,
currentChildSessionKey: current.childSessionKey,
currentError: current.error,
currentEndedAt: current.endedAt,
nextStatus,
nextError: params.error,
nextEndedAt: params.endedAt,
})
) {
continue;
}
const eventAt = params.lastEventAt ?? params.endedAt ?? Date.now();
if (params.status) {
patch.status = normalizeTaskStatus(params.status);
}
if (params.startedAt != null) {
patch.startedAt = params.startedAt;
}
if (params.endedAt != null) {
patch.endedAt = params.endedAt;
}
if (params.lastEventAt != null) {
patch.lastEventAt = params.lastEventAt;
}
if (params.childSessionKey !== undefined) {
patch.childSessionKey = params.childSessionKey?.trim() || undefined;
}
if (params.clearError) {
patch.error = undefined;
} else if (
current.status === "cancelled" &&
nextStatus !== "cancelled" &&
params.error === undefined
) {
patch.error = undefined;
} else if (params.error !== undefined) {
patch.error = params.error;
}
if (params.progressSummary !== undefined) {
patch.progressSummary = normalizeTaskSummary(params.progressSummary);
}
if (params.terminalSummary !== undefined) {
patch.terminalSummary = params.preserveTerminalSummary
? (params.terminalSummary ?? undefined)
: normalizeTaskSummary(params.terminalSummary);
}
if (params.terminalOutcome !== undefined) {
patch.terminalOutcome = resolveTaskTerminalOutcome({
status: nextStatus,
terminalOutcome: params.terminalOutcome,
});
}
if (params.detail !== undefined) {
patch.detail = params.detail;
}
if (params.suppressDelivery) {
// Teardown suppression must survive redundant lifecycle finalizers that
// arrive after queues are cleared, or they can repopulate the stopped session.
patch.deliveryStatus = "not_applicable";
}
const eventSummary =
normalizeTaskSummary(params.eventSummary) ??
(nextStatus === "failed"
? normalizeTaskSummary(params.error ?? current.error)
: nextStatus === "succeeded"
? normalizeTaskSummary(params.terminalSummary ?? current.terminalSummary)
: undefined);
const shouldAppendEvent =
(params.status && params.status !== current.status) ||
Boolean(normalizeTaskSummary(params.eventSummary));
const nextEvent = shouldAppendEvent
? appendTaskEvent({
at: eventAt,
kind:
params.status && normalizeTaskStatus(params.status) !== current.status
? normalizeTaskStatus(params.status)
: "progress",
summary: eventSummary,
})
: undefined;
const task = updateTask(current.taskId, patch);
if (task) {
updated.push(task);
if (!params.suppressDelivery) {
void maybeDeliverTaskStateChangeUpdate(task.taskId, nextEvent);
void maybeDeliverTaskTerminalUpdate(task.taskId);
}
}
}
return updated;
}
function updateTaskDeliveryByRunId(params: {
runId: string;
runtime?: TaskRuntime;
sessionKey?: string;
deliveryStatus: TaskDeliveryStatus;
error?: string;
}) {
ensureTaskRegistryReady();
const patch: Partial<TaskRecord> = {
deliveryStatus: params.deliveryStatus,
};
if (params.error !== undefined) {
patch.error = params.error;
}
return updateTasksByRunId({
runId: params.runId,
runtime: params.runtime,
sessionKey: params.sessionKey,
patch,
});
}
export function markTaskRunningByRunId(params: {
runId: string;
runtime?: TaskRuntime;
sessionKey?: string;
startedAt?: number;
lastEventAt?: number;
progressSummary?: string | null;
eventSummary?: string | null;
}) {
return updateTaskStateByRunId({
runId: params.runId,
runtime: params.runtime,
sessionKey: params.sessionKey,
status: "running",
startedAt: params.startedAt,
lastEventAt: params.lastEventAt,
progressSummary: params.progressSummary,
eventSummary: params.eventSummary,
});
}
export function recordTaskProgressByRunId(params: {
runId: string;
runtime?: TaskRuntime;
sessionKey?: string;
lastEventAt?: number;
progressSummary?: string | null;
eventSummary?: string | null;
}) {
return updateTaskStateByRunId({
runId: params.runId,
runtime: params.runtime,
sessionKey: params.sessionKey,
lastEventAt: params.lastEventAt,
progressSummary: params.progressSummary,
eventSummary: params.eventSummary,
});
}
export function finalizeTaskRunByRunId(params: {
runId: string;
runtime?: TaskRuntime;
sessionKey?: string;
childSessionKey?: string | null;
status: Extract<TaskStatus, "succeeded" | "failed" | "timed_out" | "cancelled">;
startedAt?: number;
endedAt: number;
lastEventAt?: number;
error?: string;
clearError?: boolean;
progressSummary?: string | null;
terminalSummary?: string | null;
preserveTerminalSummary?: boolean;
terminalOutcome?: TaskTerminalOutcome | null;
detail?: JsonValue;
suppressDelivery?: boolean;
}) {
return updateTaskStateByRunId({
runId: params.runId,
runtime: params.runtime,
sessionKey: params.sessionKey,
childSessionKey: params.childSessionKey,
status: params.status,
startedAt: params.startedAt,
endedAt: params.endedAt,
lastEventAt: params.lastEventAt,
error: params.error,
clearError: params.clearError,
progressSummary: params.progressSummary,
terminalSummary: params.terminalSummary,
preserveTerminalSummary: params.preserveTerminalSummary,
terminalOutcome: params.terminalOutcome,
detail: params.detail,
suppressDelivery: params.suppressDelivery,
});
}
export function setTaskRunDeliveryStatusByRunId(params: {
runId: string;
runtime?: TaskRuntime;
sessionKey?: string;
deliveryStatus: TaskDeliveryStatus;
error?: string;
}) {
return updateTaskDeliveryByRunId(params);
}
export function updateTaskNotifyPolicyById(params: {
taskId: string;
notifyPolicy: TaskNotifyPolicy;
}): TaskRecord | null {
ensureTaskRegistryReady();
return updateTask(params.taskId, {
notifyPolicy: params.notifyPolicy,
lastEventAt: Date.now(),
});
}
export function linkTaskToFlowById(params: { taskId: string; flowId: string }): TaskRecord | null {
ensureTaskRegistryReady();
const flowId = params.flowId.trim();
if (!flowId) {
return null;
}
const current = tasks.get(params.taskId);
if (!current) {
return null;
}
if (current.parentFlowId?.trim()) {
return cloneTaskRecord(current);
}
assertParentFlowLinkAllowed({
ownerKey: current.ownerKey,
scopeKind: current.scopeKind,
parentFlowId: flowId,
});
return updateTask(params.taskId, {
parentFlowId: flowId,
});
}
+558
View File
@@ -0,0 +1,558 @@
import { createRequire } from "node:module";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { createLazyPromiseLoader } from "../shared/lazy-runtime.js";
import type { TaskRegistryControlRuntime } from "./task-registry-control.types.js";
import {
cloneTaskDeliveryState,
cloneTaskRecord,
normalizeTaskTimestamps,
} from "./task-registry-records.js";
import { getTaskRegistryProcessState } from "./task-registry.process-state.js";
import {
getTaskRegistryObservers,
getTaskRegistryStore,
type TaskRegistryObserverEvent,
} from "./task-registry.store.js";
import type { TaskDeliveryState, TaskRecord, TaskRuntime } from "./task-registry.types.js";
export const log = createSubsystemLogger("tasks/registry");
export const TASK_FLOW_SYNC_RETRY_DELAYS_MS = [1_000, 5_000, 25_000, 120_000, 600_000] as const;
const taskRegistryProcessState = getTaskRegistryProcessState();
export const tasks = taskRegistryProcessState.tasks;
export const taskDeliveryStates = taskRegistryProcessState.taskDeliveryStates;
const taskIdsByRunId = taskRegistryProcessState.taskIdsByRunId;
export const taskIdsByOwnerKey = taskRegistryProcessState.taskIdsByOwnerKey;
export const taskIdsByParentFlowId = taskRegistryProcessState.taskIdsByParentFlowId;
export const taskIdsByRelatedSessionKey = taskRegistryProcessState.taskIdsByRelatedSessionKey;
export const tasksWithPendingDelivery = taskRegistryProcessState.tasksWithPendingDelivery;
let listenerStarted = false;
let listenerStop: (() => void) | null = null;
type TaskRegistryRestoreState =
| { status: "uninitialized" }
| { status: "restoring" }
| { status: "ready" }
| { status: "failed"; error: Error };
let taskRegistryRestoreState: TaskRegistryRestoreState = { status: "uninitialized" };
export const taskFlowSyncRetryTimers = new Map<string, ReturnType<typeof setTimeout>>();
export type TaskRegistryDeliveryRuntime = Pick<
typeof import("./task-registry-delivery-runtime.js"),
"sendMessage"
>;
export const TASK_REGISTRY_DELIVERY_RUNTIME_OVERRIDE_KEY = Symbol.for(
"openclaw.taskRegistry.deliveryRuntimeOverride",
);
export const TASK_REGISTRY_CONTROL_RUNTIME_OVERRIDE_KEY = Symbol.for(
"openclaw.taskRegistry.controlRuntimeOverride",
);
const require = createRequire(import.meta.url);
const TASK_REGISTRY_CONTROL_RUNTIME_CANDIDATES = [
"./task-registry-control.runtime.js",
"./task-registry-control.runtime.ts",
] as const;
export type TaskRegistryGlobalWithRuntimeOverrides = typeof globalThis & {
[TASK_REGISTRY_DELIVERY_RUNTIME_OVERRIDE_KEY]?: TaskRegistryDeliveryRuntime | null;
[TASK_REGISTRY_CONTROL_RUNTIME_OVERRIDE_KEY]?: TaskRegistryControlRuntime | null;
};
export const deliveryRuntimeLoader = createLazyPromiseLoader(
() => import("./task-registry-delivery-runtime.js"),
{ cacheRejections: true },
);
export const controlRuntimeLoader = createLazyPromiseLoader(
() =>
Promise.resolve().then(() => {
for (const candidate of TASK_REGISTRY_CONTROL_RUNTIME_CANDIDATES) {
try {
return require(candidate) as TaskRegistryControlRuntime;
} catch {
// Try runtime/source candidates in order.
}
}
throw new Error("Failed to load task registry control runtime.");
}),
{ cacheRejections: true },
);
let listenerStarter: () => void = () => {};
export function setTaskRegistryListenerStarter(starter: () => void): void {
listenerStarter = starter;
}
export function claimTaskRegistryListenerStart(): boolean {
if (listenerStarted) {
return false;
}
listenerStarted = true;
return true;
}
export function setTaskRegistryListenerStop(stop: (() => void) | null): void {
listenerStop = stop;
}
export function resetTaskRegistryListenerState(): void {
listenerStop?.();
listenerStop = null;
listenerStarted = false;
}
function clearTaskFlowSyncRetries(): void {
for (const timer of taskFlowSyncRetryTimers.values()) {
clearTimeout(timer);
}
taskFlowSyncRetryTimers.clear();
}
export function snapshotTaskRecords(source: ReadonlyMap<string, TaskRecord>): TaskRecord[] {
return [...source.values()].map((record) => cloneTaskRecord(record));
}
export function emitTaskRegistryObserverEvent(createEvent: () => TaskRegistryObserverEvent): void {
const observers = getTaskRegistryObservers();
if (!observers?.onEvent) {
return;
}
try {
observers.onEvent(createEvent());
} catch (error) {
log.warn("Task registry observer failed", {
event: "task-registry",
error,
});
}
}
export function persistTaskRegistry(): boolean {
try {
getTaskRegistryStore().saveSnapshot({
tasks,
deliveryStates: taskDeliveryStates,
});
return true;
} catch (error) {
log.warn("Failed to persist task registry snapshot", { error });
return false;
}
}
function persistTaskUpsert(task: TaskRecord, pendingDeliveryState?: TaskDeliveryState): void {
const store = getTaskRegistryStore();
const deliveryState = pendingDeliveryState ?? taskDeliveryStates.get(task.taskId);
if (store.upsertTaskWithDeliveryState) {
store.upsertTaskWithDeliveryState({
task,
...(deliveryState ? { deliveryState } : {}),
});
return;
}
if (!deliveryState && store.upsertTask) {
store.upsertTask(task);
return;
}
// Snapshot fallback: project the pending upsert so the snapshot is correct
// even though we persist before mutating memory. Delivery state must stay in
// the same write as its task; split upserts can leave a durable half-create.
store.saveSnapshot({
tasks: new Map(tasks).set(task.taskId, task),
deliveryStates: deliveryState
? new Map(taskDeliveryStates).set(task.taskId, deliveryState)
: taskDeliveryStates,
});
}
export function tryPersistTaskUpsert(
task: TaskRecord,
operation: string,
pendingDeliveryState?: TaskDeliveryState,
): boolean {
try {
persistTaskUpsert(task, pendingDeliveryState);
return true;
} catch (error) {
log.warn("Failed to persist task registry upsert", {
operation,
taskId: task.taskId,
runId: task.runId,
error,
});
return false;
}
}
function persistTaskDelete(taskId: string) {
const store = getTaskRegistryStore();
if (store.deleteTaskWithDeliveryState) {
// Composite delete removes the task row and its delivery state in a single
// transaction. This is the only atomic "remove both records" store
// primitive, and the one the default sqlite store uses.
store.deleteTaskWithDeliveryState(taskId);
return;
}
// No atomic composite delete is available: persist the removal of BOTH the
// task and its delivery state in one projected snapshot. saveSnapshot is a
// required store method and writes atomically. Using the separate deleteTask
// / deleteDeliveryState methods instead would either leave the delivery-state
// row behind (a task-only delete) or, if both were called, reintroduce a
// two-write divergence window when the second delete threw before the
// in-memory mutation. Projecting both deletions into a single snapshot keeps
// the persisted store consistent under the persist-before-in-memory ordering.
const projectedTasks = new Map(tasks);
projectedTasks.delete(taskId);
const projectedDeliveryStates = new Map(taskDeliveryStates);
projectedDeliveryStates.delete(taskId);
store.saveSnapshot({
tasks: projectedTasks,
deliveryStates: projectedDeliveryStates,
});
}
export function tryPersistTaskDelete(taskId: string): boolean {
try {
persistTaskDelete(taskId);
return true;
} catch (error) {
log.warn("Failed to persist task registry delete", {
taskId,
error,
});
return false;
}
}
function persistTaskDeliveryStateUpsert(state: TaskDeliveryState) {
const store = getTaskRegistryStore();
if (store.upsertDeliveryState) {
store.upsertDeliveryState(state);
return;
}
const projectedDeliveryStates = new Map(taskDeliveryStates);
projectedDeliveryStates.set(state.taskId, cloneTaskDeliveryState(state));
store.saveSnapshot({
tasks,
deliveryStates: projectedDeliveryStates,
});
}
export function tryPersistTaskDeliveryStateUpsert(state: TaskDeliveryState): boolean {
try {
persistTaskDeliveryStateUpsert(state);
return true;
} catch (error) {
log.warn("Failed to persist task delivery state", {
taskId: state.taskId,
error,
});
return false;
}
}
export function clearTaskRegistryMemory(): void {
clearTaskFlowSyncRetries();
tasks.clear();
taskDeliveryStates.clear();
taskIdsByRunId.clear();
taskIdsByOwnerKey.clear();
taskIdsByParentFlowId.clear();
taskIdsByRelatedSessionKey.clear();
tasksWithPendingDelivery.clear();
}
export function loadTaskRegistryDeliveryRuntime() {
const deliveryRuntimeOverride = (globalThis as TaskRegistryGlobalWithRuntimeOverrides)[
TASK_REGISTRY_DELIVERY_RUNTIME_OVERRIDE_KEY
];
if (deliveryRuntimeOverride) {
return Promise.resolve(deliveryRuntimeOverride);
}
return deliveryRuntimeLoader.load();
}
export function loadTaskRegistryControlRuntime() {
const controlRuntimeOverride = (globalThis as TaskRegistryGlobalWithRuntimeOverrides)[
TASK_REGISTRY_CONTROL_RUNTIME_OVERRIDE_KEY
];
if (controlRuntimeOverride) {
return Promise.resolve(controlRuntimeOverride);
}
// Registry reads happen far more often than task cancellation, so keep the ACP/subagent
// control graph off the default import path until a cancellation flow actually needs it.
return controlRuntimeLoader.load();
}
export function addRunIdIndex(taskId: string, runId?: string) {
const trimmed = runId?.trim();
if (!trimmed) {
return;
}
let ids = taskIdsByRunId.get(trimmed);
if (!ids) {
ids = new Set<string>();
taskIdsByRunId.set(trimmed, ids);
}
ids.add(taskId);
}
function addIndexedKey(index: Map<string, Set<string>>, key: string, taskId: string) {
let ids = index.get(key);
if (!ids) {
ids = new Set<string>();
index.set(key, ids);
}
ids.add(taskId);
}
function deleteIndexedKey(index: Map<string, Set<string>>, key: string, taskId: string) {
const ids = index.get(key);
if (!ids) {
return;
}
ids.delete(taskId);
if (ids.size === 0) {
index.delete(key);
}
}
function getTaskRelatedSessionIndexKeys(task: Pick<TaskRecord, "ownerKey" | "childSessionKey">) {
return uniqueStrings(
[normalizeOptionalString(task.ownerKey), normalizeOptionalString(task.childSessionKey)].filter(
Boolean,
) as string[],
);
}
export function addOwnerKeyIndex(taskId: string, task: Pick<TaskRecord, "ownerKey">) {
const key = normalizeOptionalString(task.ownerKey);
if (!key) {
return;
}
addIndexedKey(taskIdsByOwnerKey, key, taskId);
}
export function deleteOwnerKeyIndex(taskId: string, task: Pick<TaskRecord, "ownerKey">) {
const key = normalizeOptionalString(task.ownerKey);
if (!key) {
return;
}
deleteIndexedKey(taskIdsByOwnerKey, key, taskId);
}
export function addParentFlowIdIndex(taskId: string, task: Pick<TaskRecord, "parentFlowId">) {
const key = task.parentFlowId?.trim();
if (!key) {
return;
}
addIndexedKey(taskIdsByParentFlowId, key, taskId);
}
export function deleteParentFlowIdIndex(taskId: string, task: Pick<TaskRecord, "parentFlowId">) {
const key = task.parentFlowId?.trim();
if (!key) {
return;
}
deleteIndexedKey(taskIdsByParentFlowId, key, taskId);
}
export function addRelatedSessionKeyIndex(
taskId: string,
task: Pick<TaskRecord, "ownerKey" | "childSessionKey">,
) {
for (const sessionKey of getTaskRelatedSessionIndexKeys(task)) {
addIndexedKey(taskIdsByRelatedSessionKey, sessionKey, taskId);
}
}
export function deleteRelatedSessionKeyIndex(
taskId: string,
task: Pick<TaskRecord, "ownerKey" | "childSessionKey">,
) {
for (const sessionKey of getTaskRelatedSessionIndexKeys(task)) {
deleteIndexedKey(taskIdsByRelatedSessionKey, sessionKey, taskId);
}
}
export function rebuildRunIdIndex() {
taskIdsByRunId.clear();
for (const [taskId, task] of tasks.entries()) {
addRunIdIndex(taskId, task.runId);
}
}
function rebuildOwnerKeyIndex() {
taskIdsByOwnerKey.clear();
for (const [taskId, task] of tasks.entries()) {
addOwnerKeyIndex(taskId, task);
}
}
function rebuildParentFlowIdIndex() {
taskIdsByParentFlowId.clear();
for (const [taskId, task] of tasks.entries()) {
addParentFlowIdIndex(taskId, task);
}
}
function rebuildRelatedSessionKeyIndex() {
taskIdsByRelatedSessionKey.clear();
for (const [taskId, task] of tasks.entries()) {
addRelatedSessionKeyIndex(taskId, task);
}
}
export function getTasksByRunId(runId: string): TaskRecord[] {
const ids = taskIdsByRunId.get(runId.trim());
if (!ids || ids.size === 0) {
return [];
}
return [...ids]
.map((taskId) => tasks.get(taskId))
.filter((task): task is TaskRecord => Boolean(task));
}
function taskRunScopeKey(
task: Pick<TaskRecord, "runtime" | "scopeKind" | "ownerKey" | "childSessionKey">,
): string {
return [
task.runtime,
task.scopeKind,
normalizeOptionalString(task.ownerKey) ?? "",
normalizeOptionalString(task.childSessionKey) ?? "",
].join("\u0000");
}
export function getTasksByRunScope(params: {
runId: string;
runtime?: TaskRuntime;
sessionKey?: string;
}): TaskRecord[] {
const matches = getTasksByRunId(params.runId).filter(
(task) => !params.runtime || task.runtime === params.runtime,
);
const sessionKey = normalizeOptionalString(params.sessionKey);
if (sessionKey) {
const childMatches = matches.filter(
(task) => normalizeOptionalString(task.childSessionKey) === sessionKey,
);
if (childMatches.length > 0) {
return childMatches;
}
const ownerMatches = matches.filter(
(task) =>
task.scopeKind === "session" && normalizeOptionalString(task.ownerKey) === sessionKey,
);
return ownerMatches;
}
const scopeKeys = new Set(matches.map((task) => taskRunScopeKey(task)));
return scopeKeys.size <= 1 ? matches : [];
}
export function getPeerTasksForDelivery(task: TaskRecord): TaskRecord[] {
if (!task.runId?.trim()) {
return [];
}
return getTasksByRunId(task.runId).filter(
(candidate) =>
candidate.runtime === task.runtime &&
candidate.scopeKind === task.scopeKind &&
(normalizeOptionalString(candidate.ownerKey) ?? "") ===
(normalizeOptionalString(task.ownerKey) ?? "") &&
(normalizeOptionalString(candidate.childSessionKey) ?? "") ===
(normalizeOptionalString(task.childSessionKey) ?? ""),
);
}
function taskLookupPriority(task: TaskRecord): number {
const runtimePriority = task.runtime === "cli" ? 1 : 0;
return runtimePriority;
}
export function pickPreferredRunIdTask(matches: TaskRecord[]): TaskRecord | undefined {
return [...matches].toSorted((left, right) => {
const priorityDiff = taskLookupPriority(left) - taskLookupPriority(right);
if (priorityDiff !== 0) {
return priorityDiff;
}
return left.createdAt - right.createdAt;
})[0];
}
export function compareTasksNewestFirst(
left: Pick<TaskRecord, "createdAt"> & { insertionIndex?: number },
right: Pick<TaskRecord, "createdAt"> & { insertionIndex?: number },
): number {
const createdAtDiff = right.createdAt - left.createdAt;
if (createdAtDiff !== 0) {
return createdAtDiff;
}
return (right.insertionIndex ?? 0) - (left.insertionIndex ?? 0);
}
export function restoreTaskRegistryOnce() {
switch (taskRegistryRestoreState.status) {
case "ready":
return;
case "failed":
throw taskRegistryRestoreState.error;
case "restoring":
throw new Error("Task registry restore is already in progress.");
case "uninitialized":
break;
}
taskRegistryRestoreState = { status: "restoring" };
try {
const restored = getTaskRegistryStore().loadSnapshot();
const restoredTasks = new Map<string, TaskRecord>();
for (const [taskId, task] of restored.tasks.entries()) {
restoredTasks.set(taskId, normalizeTaskTimestamps(task));
}
const restoredDeliveryStates = new Map(restored.deliveryStates);
clearTaskRegistryMemory();
for (const [taskId, task] of restoredTasks.entries()) {
tasks.set(taskId, task);
}
for (const [taskId, state] of restoredDeliveryStates.entries()) {
taskDeliveryStates.set(taskId, state);
}
rebuildRunIdIndex();
rebuildOwnerKeyIndex();
rebuildParentFlowIdIndex();
rebuildRelatedSessionKeyIndex();
taskRegistryRestoreState = { status: "ready" };
if (restoredTasks.size > 0 || restoredDeliveryStates.size > 0) {
emitTaskRegistryObserverEvent(() => ({
kind: "restored",
tasks: snapshotTaskRecords(tasks),
}));
}
} catch (error) {
clearTaskRegistryMemory();
const message = formatErrorMessage(error);
const restoreError = new Error(`Task registry restore failed: ${message}`, { cause: error });
taskRegistryRestoreState = { status: "failed", error: restoreError };
// Compact console logs omit structured metadata, so keep the rejected value visible there too.
log.warn("Failed to restore task registry", {
error: message,
consoleMessage: `Failed to restore task registry: ${message}`,
});
throw restoreError;
}
}
export function ensureTaskRegistryReady(): void {
restoreTaskRegistryOnce();
listenerStarter();
}
export function reloadTaskRegistryFromStore(): void {
clearTaskRegistryMemory();
taskRegistryRestoreState = { status: "uninitialized" };
ensureTaskRegistryReady();
}
export function resetTaskRegistryRestoreState(): void {
taskRegistryRestoreState = { status: "uninitialized" };
}
+41 -2798
View File
File diff suppressed because it is too large Load Diff