ClawFlow: add linear flow control surface (#58227)

* ClawFlow: add linear flow control surface

* Flows: clear blocked metadata on resume
This commit is contained in:
Mariano 2026-03-31 10:08:50 +02:00 committed by GitHub
parent ab4ddff7f1
commit f86e5c0a08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1108 additions and 8 deletions

View File

@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
- Flows/tasks: add a minimal SQLite-backed flow registry plus task-to-flow linkage scaffolding, so orchestrated work can start gaining a first-class parent record without changing current task delivery behavior.
- Flows/tasks: route one-task ACP and subagent updates through a parent flow owner context, so detached work can emerge back through the intended parent thread/session instead of speaking only as a raw child task.
- Flows/tasks: persist blocked state on one-task flows and let the same flow reopen cleanly on retry, so blocked detached work can carry a parent-level reason and continue without fragmenting into a new job.
- ClawFlow: add the first linear flow control surface with `openclaw flows list|show|cancel`, keep manual multi-task flows separate from one-task auto-sync flows, and surface doctor recovery hints for obviously orphaned or broken flow/task linkage.
- Matrix/history: add optional room history context for Matrix group triggers via `channels.matrix.historyLimit`, with per-agent watermarks and retry-safe snapshots so failed trigger retries do not drift into newer room messages. (#57022) thanks @chain710.
- Diffs: skip unused viewer-versus-file SSR preload work so `diffs` view-only and file-only runs do less render work while keeping mode outputs aligned. (#57909) thanks @gumadeiras.
- Matrix/threads: add per-DM `threadReplies` overrides and keep thread session isolation aligned with the effective room or DM thread policy from the triggering message onward. (#57995) thanks @teconomix.
@ -33,6 +34,7 @@ Docs: https://docs.openclaw.ai
- Slack/exec approvals: add native Slack approval routing and approver authorization so exec approval prompts can stay in Slack instead of falling back to the Web UI or terminal. Thanks @vincentkoc.
### Fixes
- Image generation/build: write stable runtime alias files into `dist/` and route provider-auth runtime lookups through those aliases so image-generation providers keep resolving auth/runtime modules after rebuilds instead of crashing on missing hashed chunk files.
- Config/runtime: pin the first successful config load in memory for the running process and refresh that snapshot on successful writes/reloads, so hot paths stop reparsing `openclaw.json` between watcher-driven swaps.
- Config/legacy cleanup: stop probing obsolete alternate legacy config names and service labels during local config/service detection, while keeping the active `~/.openclaw/openclaw.json` path canonical.

View File

@ -0,0 +1,62 @@
---
summary: "ClawFlow workflow orchestration for background tasks and detached runs"
read_when:
- You want a flow to own one or more detached tasks
- You want to inspect or cancel a background job as a unit
- You want to understand how flows relate to tasks and background work
title: "ClawFlow"
---
# ClawFlow
ClawFlow is the flow layer above [Background Tasks](/automation/tasks). Tasks still track detached work. ClawFlow groups those task runs into a single job, keeps the parent owner context, and gives you a flow-level control surface.
Use ClawFlow when the work is more than a single detached run. A flow can still be one task, but it can also coordinate multiple tasks in a simple linear sequence.
## TL;DR
- Tasks are the execution records.
- ClawFlow is the job-level wrapper above tasks.
- A flow keeps one owner/session context for the whole job.
- Use `openclaw flows list`, `openclaw flows show`, and `openclaw flows cancel` to inspect or manage flows.
## Quick start
```bash
openclaw flows list
openclaw flows show <flow-id-or-owner-session>
openclaw flows cancel <flow-id-or-owner-session>
```
## How it relates to tasks
Background tasks still do the low-level work:
- ACP runs
- subagent runs
- cron executions
- CLI-initiated runs
ClawFlow sits above that ledger:
- it keeps related task runs under one flow id
- it tracks the flow state separately from the individual task state
- it makes blocked or multi-step work easier to inspect from one place
For a single detached run, the flow can be a one-task flow. For more structured work, ClawFlow can keep multiple task runs under the same job.
## CLI surface
The flow CLI is intentionally small:
- `openclaw flows list` shows active and recent flows
- `openclaw flows show <lookup>` shows one flow and its linked tasks
- `openclaw flows cancel <lookup>` cancels the flow and any active child tasks
The lookup token accepts either a flow id or the owner session key.
## Related
- [Background Tasks](/automation/tasks) — detached work ledger
- [CLI: flows](/cli/flows) — flow inspection and control commands
- [Cron Jobs](/automation/cron-jobs) — scheduled jobs that may create tasks

View File

@ -51,11 +51,19 @@ The most effective setups combine multiple mechanisms:
3. **Hooks** react to specific events (tool calls, session resets, compaction) with custom scripts.
4. **Standing Orders** give the agent persistent context ("always check the project board before replying").
5. **Background Tasks** automatically track all detached work so you can inspect and audit it.
6. **ClawFlow** groups related detached tasks into a single flow when the work needs a higher-level job view.
See [Cron vs Heartbeat](/automation/cron-vs-heartbeat) for a detailed comparison of the two scheduling mechanisms.
## ClawFlow
ClawFlow sits above [Background Tasks](/automation/tasks). Tasks still track the detached runs, while ClawFlow groups related task runs into one job that you can inspect or cancel from the CLI.
See [ClawFlow](/automation/clawflow) for the flow overview and [CLI: flows](/cli/flows) for the command surface.
## Related
- [Cron vs Heartbeat](/automation/cron-vs-heartbeat) — detailed comparison guide
- [ClawFlow](/automation/clawflow) — flow-level orchestration above tasks
- [Troubleshooting](/automation/troubleshooting) — debugging automation issues
- [Configuration Reference](/gateway/configuration-reference) — all config keys

View File

@ -210,6 +210,12 @@ A sweeper runs every **60 seconds** and handles three things:
## How tasks relate to other systems
### Tasks and ClawFlow
ClawFlow is the flow layer above tasks. A flow groups one or more task runs into a single job, owns the parent session context, and gives you a higher-level control surface for blocked or multi-step work.
See [ClawFlow](/automation/clawflow) for the flow overview and [CLI: flows](/cli/flows) for the command surface.
### Tasks and cron
A cron job **definition** lives in `~/.openclaw/cron/jobs.json`. **Every** cron execution creates a task record — both main-session and isolated. Main-session cron tasks default to `silent` notify policy so they track without generating notifications.
@ -233,7 +239,9 @@ A task's `runId` links to the agent run doing the work. Agent lifecycle events (
## Related
- [Automation Overview](/automation) — all automation mechanisms at a glance
- [ClawFlow](/automation/clawflow) — job-level orchestration above tasks
- [Cron Jobs](/automation/cron-jobs) — scheduling background work
- [Cron vs Heartbeat](/automation/cron-vs-heartbeat) — choosing the right mechanism
- [Heartbeat](/gateway/heartbeat) — periodic main-session turns
- [CLI: flows](/cli/flows) — flow inspection and control commands
- [CLI: Tasks](/cli/index#tasks) — CLI command reference

54
docs/cli/flows.md Normal file
View File

@ -0,0 +1,54 @@
---
summary: "CLI reference for `openclaw flows` (list, inspect, cancel)"
read_when:
- You want to inspect or cancel a flow
- You want to see how background tasks roll up into a higher-level job
title: "flows"
---
# `openclaw flows`
Inspect and manage [ClawFlow](/automation/clawflow) jobs.
```bash
openclaw flows list
openclaw flows show <lookup>
openclaw flows cancel <lookup>
```
## Commands
### `flows list`
List tracked flows and their task counts.
```bash
openclaw flows list
openclaw flows list --status blocked
openclaw flows list --json
```
### `flows show`
Show one flow by flow id or owner session key.
```bash
openclaw flows show <lookup>
openclaw flows show <lookup> --json
```
The output includes the flow status, current step, blocked summary when present, and linked tasks.
### `flows cancel`
Cancel a flow and any active child tasks.
```bash
openclaw flows cancel <lookup>
```
## Related
- [ClawFlow](/automation/clawflow) — job-level orchestration above tasks
- [Background Tasks](/automation/tasks) — detached work ledger
- [CLI reference](/cli/index) — full command tree

View File

@ -45,6 +45,7 @@ This page describes the current CLI behavior. If commands change, update this do
- [`tui`](/cli/tui)
- [`browser`](/cli/browser)
- [`cron`](/cli/cron)
- [`flows`](/cli/flows)
- [`dns`](/cli/dns)
- [`docs`](/cli/docs)
- [`hooks`](/cli/hooks)
@ -171,6 +172,10 @@ openclaw [--dev] [--profile <name>] <command>
show
notify
cancel
flows
list
show
cancel
gateway
call
health
@ -809,6 +814,14 @@ List and manage [background task](/automation/tasks) runs across agents.
- `tasks cancel <id>` — cancel a running task
- `tasks audit` — surface operational issues (stale, lost, delivery failures)
### `flows`
List and manage [ClawFlow](/automation/clawflow) jobs across agents.
- `flows list` — show active and recent flows
- `flows show <id>` — show details for a specific flow
- `flows cancel <id>` — cancel a flow and its active child tasks
## Gateway
### `gateway`

View File

@ -1121,6 +1121,7 @@
"automation/cron-jobs",
"automation/cron-vs-heartbeat",
"automation/tasks",
"automation/clawflow",
"automation/troubleshooting",
"automation/webhook",
"automation/gmail-pubsub",
@ -1432,6 +1433,7 @@
"cli/approvals",
"cli/browser",
"cli/cron",
"cli/flows",
"cli/node",
"cli/nodes",
"cli/sandbox"

View File

@ -5,6 +5,9 @@ import { registerStatusHealthSessionsCommands } from "./register.status-health-s
const mocks = vi.hoisted(() => ({
statusCommand: vi.fn(),
healthCommand: vi.fn(),
flowsListCommand: vi.fn(),
flowsShowCommand: vi.fn(),
flowsCancelCommand: vi.fn(),
sessionsCommand: vi.fn(),
sessionsCleanupCommand: vi.fn(),
tasksListCommand: vi.fn(),
@ -23,6 +26,9 @@ const mocks = vi.hoisted(() => ({
const statusCommand = mocks.statusCommand;
const healthCommand = mocks.healthCommand;
const flowsListCommand = mocks.flowsListCommand;
const flowsShowCommand = mocks.flowsShowCommand;
const flowsCancelCommand = mocks.flowsCancelCommand;
const sessionsCommand = mocks.sessionsCommand;
const sessionsCleanupCommand = mocks.sessionsCleanupCommand;
const tasksListCommand = mocks.tasksListCommand;
@ -42,6 +48,12 @@ vi.mock("../../commands/health.js", () => ({
healthCommand: mocks.healthCommand,
}));
vi.mock("../../commands/flows.js", () => ({
flowsListCommand: mocks.flowsListCommand,
flowsShowCommand: mocks.flowsShowCommand,
flowsCancelCommand: mocks.flowsCancelCommand,
}));
vi.mock("../../commands/sessions.js", () => ({
sessionsCommand: mocks.sessionsCommand,
}));
@ -79,6 +91,9 @@ describe("registerStatusHealthSessionsCommands", () => {
runtime.exit.mockImplementation(() => {});
statusCommand.mockResolvedValue(undefined);
healthCommand.mockResolvedValue(undefined);
flowsListCommand.mockResolvedValue(undefined);
flowsShowCommand.mockResolvedValue(undefined);
flowsCancelCommand.mockResolvedValue(undefined);
sessionsCommand.mockResolvedValue(undefined);
sessionsCleanupCommand.mockResolvedValue(undefined);
tasksListCommand.mockResolvedValue(undefined);
@ -317,4 +332,39 @@ describe("registerStatusHealthSessionsCommands", () => {
runtime,
);
});
it("runs flows list from the parent command", async () => {
await runCli(["flows", "--json", "--status", "blocked"]);
expect(flowsListCommand).toHaveBeenCalledWith(
expect.objectContaining({
json: true,
status: "blocked",
}),
runtime,
);
});
it("runs flows show subcommand with lookup forwarding", async () => {
await runCli(["flows", "show", "flow-123", "--json"]);
expect(flowsShowCommand).toHaveBeenCalledWith(
expect.objectContaining({
lookup: "flow-123",
json: true,
}),
runtime,
);
});
it("runs flows cancel subcommand with lookup forwarding", async () => {
await runCli(["flows", "cancel", "flow-123"]);
expect(flowsCancelCommand).toHaveBeenCalledWith(
expect.objectContaining({
lookup: "flow-123",
}),
runtime,
);
});
});

View File

@ -1,4 +1,5 @@
import type { Command } from "commander";
import { flowsCancelCommand, flowsListCommand, flowsShowCommand } from "../../commands/flows.js";
import { healthCommand } from "../../commands/health.js";
import { sessionsCleanupCommand } from "../../commands/sessions-cleanup.js";
import { sessionsCommand } from "../../commands/sessions.js";
@ -373,4 +374,84 @@ export function registerStatusHealthSessionsCommands(program: Command) {
);
});
});
const flowsCmd = program
.command("flows")
.description("Inspect ClawFlow state")
.option("--json", "Output as JSON", false)
.option(
"--status <name>",
"Filter by status (queued, running, waiting, blocked, succeeded, failed, cancelled, lost)",
)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await flowsListCommand(
{
json: Boolean(opts.json),
status: opts.status as string | undefined,
},
defaultRuntime,
);
});
});
flowsCmd.enablePositionalOptions();
flowsCmd
.command("list")
.description("List tracked ClawFlow runs")
.option("--json", "Output as JSON", false)
.option(
"--status <name>",
"Filter by status (queued, running, waiting, blocked, succeeded, failed, cancelled, lost)",
)
.action(async (opts, command) => {
const parentOpts = command.parent?.opts() as
| {
json?: boolean;
status?: string;
}
| undefined;
await runCommandWithRuntime(defaultRuntime, async () => {
await flowsListCommand(
{
json: Boolean(opts.json || parentOpts?.json),
status: (opts.status as string | undefined) ?? parentOpts?.status,
},
defaultRuntime,
);
});
});
flowsCmd
.command("show")
.description("Show one ClawFlow by flow id or owner session key")
.argument("<lookup>", "Flow id or owner session key")
.option("--json", "Output as JSON", false)
.action(async (lookup, opts, command) => {
const parentOpts = command.parent?.opts() as { json?: boolean } | undefined;
await runCommandWithRuntime(defaultRuntime, async () => {
await flowsShowCommand(
{
lookup,
json: Boolean(opts.json || parentOpts?.json),
},
defaultRuntime,
);
});
});
flowsCmd
.command("cancel")
.description("Cancel a ClawFlow and its active child tasks")
.argument("<lookup>", "Flow id or owner session key")
.action(async (lookup) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await flowsCancelCommand(
{
lookup,
},
defaultRuntime,
);
});
});
}

View File

@ -13,6 +13,8 @@ const mocks = vi.hoisted(() => ({
buildWorkspaceSkillStatus: vi.fn(),
buildPluginStatusReport: vi.fn(),
buildPluginCompatibilityWarnings: vi.fn(),
listFlowRecords: vi.fn(),
listTasksForFlowId: vi.fn(),
}));
vi.mock("../agents/agent-scope.js", () => ({
@ -30,6 +32,14 @@ vi.mock("../plugins/status.js", () => ({
mocks.buildPluginCompatibilityWarnings(...args),
}));
vi.mock("../tasks/flow-registry.js", () => ({
listFlowRecords: (...args: unknown[]) => mocks.listFlowRecords(...args),
}));
vi.mock("../tasks/task-registry.js", () => ({
listTasksForFlowId: (...args: unknown[]) => mocks.listTasksForFlowId(...args),
}));
async function runNoteWorkspaceStatusForTest(
loadResult: ReturnType<typeof createPluginLoadResult>,
compatibilityWarnings: string[] = [],
@ -44,6 +54,8 @@ async function runNoteWorkspaceStatusForTest(
...loadResult,
});
mocks.buildPluginCompatibilityWarnings.mockReturnValue(compatibilityWarnings);
mocks.listFlowRecords.mockReturnValue([]);
mocks.listTasksForFlowId.mockReturnValue([]);
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
noteWorkspaceStatus({});
@ -159,4 +171,51 @@ describe("noteWorkspaceStatus", () => {
noteSpy.mockRestore();
}
});
it("surfaces ClawFlow recovery guidance for suspicious linear flows", async () => {
const noteSpy = await runNoteWorkspaceStatusForTest(createPluginLoadResult({ plugins: [] }));
mocks.listFlowRecords.mockReturnValue([
{
flowId: "flow-orphaned",
shape: "linear",
ownerSessionKey: "agent:main:main",
status: "waiting",
notifyPolicy: "done_only",
goal: "Process PRs",
createdAt: 10,
updatedAt: 20,
},
{
flowId: "flow-blocked",
shape: "single_task",
ownerSessionKey: "agent:main:main",
status: "blocked",
notifyPolicy: "done_only",
goal: "Patch file",
blockedTaskId: "task-missing",
createdAt: 10,
updatedAt: 20,
},
]);
mocks.listTasksForFlowId.mockImplementation((flowId: string) => {
if (flowId === "flow-blocked") {
return [{ taskId: "task-other" }];
}
return [];
});
noteWorkspaceStatus({});
try {
const recoveryCalls = noteSpy.mock.calls.filter(([, title]) => title === "ClawFlow recovery");
expect(recoveryCalls).toHaveLength(1);
const body = String(recoveryCalls[0]?.[0]);
expect(body).toContain("flow-orphaned: waiting linear flow has no linked tasks");
expect(body).toContain("flow-blocked: blocked flow points at missing task task-missing");
expect(body).toContain("openclaw flows show <flow-id>");
expect(body).toContain("openclaw flows cancel <flow-id>");
} finally {
noteSpy.mockRestore();
}
});
});

View File

@ -1,10 +1,53 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { buildPluginCompatibilityWarnings, buildPluginStatusReport } from "../plugins/status.js";
import { listFlowRecords } from "../tasks/flow-registry.js";
import { listTasksForFlowId } from "../tasks/task-registry.js";
import { note } from "../terminal/note.js";
import { detectLegacyWorkspaceDirs, formatLegacyWorkspaceWarning } from "./doctor-workspace.js";
function noteFlowRecoveryHints() {
const suspicious = listFlowRecords().flatMap((flow) => {
const tasks = listTasksForFlowId(flow.flowId);
const findings: string[] = [];
if (
flow.shape === "linear" &&
(flow.status === "running" || flow.status === "waiting" || flow.status === "blocked") &&
tasks.length === 0
) {
findings.push(
`${flow.flowId}: ${flow.status} linear flow has no linked tasks; inspect or cancel it manually.`,
);
}
if (
flow.status === "blocked" &&
flow.blockedTaskId &&
!tasks.some((task) => task.taskId === flow.blockedTaskId)
) {
findings.push(
`${flow.flowId}: blocked flow points at missing task ${flow.blockedTaskId}; inspect before retrying.`,
);
}
return findings;
});
if (suspicious.length === 0) {
return;
}
note(
[
...suspicious.slice(0, 5),
suspicious.length > 5 ? `...and ${suspicious.length - 5} more.` : null,
`Inspect: ${formatCliCommand("openclaw flows show <flow-id>")}`,
`Cancel: ${formatCliCommand("openclaw flows cancel <flow-id>")}`,
]
.filter((line): line is string => Boolean(line))
.join("\n"),
"ClawFlow recovery",
);
}
export function noteWorkspaceStatus(cfg: OpenClawConfig) {
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const legacyWorkspace = detectLegacyWorkspaceDirs({ workspaceDir });
@ -74,5 +117,7 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig) {
note(lines.join("\n"), "Plugin diagnostics");
}
noteFlowRecoveryHints();
return { workspaceDir };
}

154
src/commands/flows.test.ts Normal file
View File

@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createCliRuntimeCapture } from "../cli/test-runtime-capture.js";
import { flowsCancelCommand, flowsListCommand, flowsShowCommand } from "./flows.js";
const mocks = vi.hoisted(() => ({
listFlowRecordsMock: vi.fn(),
resolveFlowForLookupTokenMock: vi.fn(),
getFlowByIdMock: vi.fn(),
listTasksForFlowIdMock: vi.fn(),
getFlowTaskSummaryMock: vi.fn(),
cancelFlowByIdMock: vi.fn(),
loadConfigMock: vi.fn(() => ({ loaded: true })),
}));
vi.mock("../tasks/flow-registry.js", () => ({
listFlowRecords: (...args: unknown[]) => mocks.listFlowRecordsMock(...args),
resolveFlowForLookupToken: (...args: unknown[]) => mocks.resolveFlowForLookupTokenMock(...args),
getFlowById: (...args: unknown[]) => mocks.getFlowByIdMock(...args),
}));
vi.mock("../tasks/task-registry.js", () => ({
listTasksForFlowId: (...args: unknown[]) => mocks.listTasksForFlowIdMock(...args),
}));
vi.mock("../tasks/task-executor.js", () => ({
getFlowTaskSummary: (...args: unknown[]) => mocks.getFlowTaskSummaryMock(...args),
cancelFlowById: (...args: unknown[]) => mocks.cancelFlowByIdMock(...args),
}));
vi.mock("../config/config.js", () => ({
loadConfig: () => mocks.loadConfigMock(),
}));
const {
defaultRuntime: runtime,
runtimeLogs,
runtimeErrors,
resetRuntimeCapture,
} = createCliRuntimeCapture();
const flowFixture = {
flowId: "flow-12345678",
shape: "linear",
ownerSessionKey: "agent:main:main",
status: "waiting",
notifyPolicy: "done_only",
goal: "Process related PRs",
currentStep: "wait_for",
createdAt: Date.parse("2026-03-31T10:00:00.000Z"),
updatedAt: Date.parse("2026-03-31T10:05:00.000Z"),
} as const;
const taskSummaryFixture = {
total: 2,
active: 1,
terminal: 1,
failures: 0,
byStatus: {
queued: 0,
running: 1,
succeeded: 1,
failed: 0,
timed_out: 0,
cancelled: 0,
lost: 0,
},
byRuntime: {
subagent: 1,
acp: 1,
cli: 0,
cron: 0,
},
} as const;
const taskFixture = {
taskId: "task-12345678",
runtime: "acp",
requesterSessionKey: "agent:main:main",
parentFlowId: "flow-12345678",
childSessionKey: "agent:codex:acp:child",
runId: "run-12345678",
task: "Review PR",
status: "running",
deliveryStatus: "pending",
notifyPolicy: "done_only",
createdAt: Date.parse("2026-03-31T10:00:00.000Z"),
lastEventAt: Date.parse("2026-03-31T10:05:00.000Z"),
} as const;
describe("flows commands", () => {
beforeEach(() => {
vi.clearAllMocks();
resetRuntimeCapture();
mocks.listFlowRecordsMock.mockReturnValue([]);
mocks.resolveFlowForLookupTokenMock.mockReturnValue(undefined);
mocks.getFlowByIdMock.mockReturnValue(undefined);
mocks.listTasksForFlowIdMock.mockReturnValue([]);
mocks.getFlowTaskSummaryMock.mockReturnValue(taskSummaryFixture);
mocks.cancelFlowByIdMock.mockResolvedValue({
found: false,
cancelled: false,
reason: "missing",
});
});
it("lists flow rows with task summary counts", async () => {
mocks.listFlowRecordsMock.mockReturnValue([flowFixture]);
await flowsListCommand({}, runtime);
expect(runtimeLogs[0]).toContain("Flows: 1");
expect(runtimeLogs[1]).toContain("Flow pressure: 0 active · 0 blocked · 1 total");
expect(runtimeLogs.join("\n")).toContain("Process related PRs");
expect(runtimeLogs.join("\n")).toContain("1 active/2 total");
});
it("shows one flow with linked tasks", async () => {
mocks.resolveFlowForLookupTokenMock.mockReturnValue(flowFixture);
mocks.listTasksForFlowIdMock.mockReturnValue([taskFixture]);
await flowsShowCommand({ lookup: "flow-12345678" }, runtime);
expect(runtimeLogs.join("\n")).toContain("shape: linear");
expect(runtimeLogs.join("\n")).toContain("currentStep: wait_for");
expect(runtimeLogs.join("\n")).toContain("tasks: 2 total · 1 active · 0 issues");
expect(runtimeLogs.join("\n")).toContain("task-12345678 running run-12345678 Review PR");
});
it("cancels a flow and reports the updated state", async () => {
mocks.resolveFlowForLookupTokenMock.mockReturnValue(flowFixture);
mocks.cancelFlowByIdMock.mockResolvedValue({
found: true,
cancelled: true,
flow: {
...flowFixture,
status: "cancelled",
},
});
mocks.getFlowByIdMock.mockReturnValue({
...flowFixture,
status: "cancelled",
});
await flowsCancelCommand({ lookup: "flow-12345678" }, runtime);
expect(mocks.loadConfigMock).toHaveBeenCalled();
expect(mocks.cancelFlowByIdMock).toHaveBeenCalledWith({
cfg: { loaded: true },
flowId: "flow-12345678",
});
expect(runtimeLogs[0]).toContain("Cancelled flow-12345678 (linear) with status cancelled.");
expect(runtimeErrors).toEqual([]);
});
});

215
src/commands/flows.ts Normal file
View File

@ -0,0 +1,215 @@
import { loadConfig } from "../config/config.js";
import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { getFlowById, listFlowRecords, resolveFlowForLookupToken } from "../tasks/flow-registry.js";
import type { FlowRecord, FlowStatus } from "../tasks/flow-registry.types.js";
import { cancelFlowById, getFlowTaskSummary } from "../tasks/task-executor.js";
import { listTasksForFlowId } from "../tasks/task-registry.js";
import { isRich, theme } from "../terminal/theme.js";
const ID_PAD = 10;
const STATUS_PAD = 10;
const SHAPE_PAD = 12;
function truncate(value: string, maxChars: number) {
if (value.length <= maxChars) {
return value;
}
if (maxChars <= 1) {
return value.slice(0, maxChars);
}
return `${value.slice(0, maxChars - 1)}`;
}
function shortToken(value: string | undefined, maxChars = ID_PAD): string {
const trimmed = value?.trim();
if (!trimmed) {
return "n/a";
}
return truncate(trimmed, maxChars);
}
function formatFlowStatusCell(status: FlowStatus, rich: boolean) {
const padded = status.padEnd(STATUS_PAD);
if (!rich) {
return padded;
}
if (status === "succeeded") {
return theme.success(padded);
}
if (status === "failed" || status === "lost") {
return theme.error(padded);
}
if (status === "running") {
return theme.accentBright(padded);
}
if (status === "blocked") {
return theme.warn(padded);
}
return theme.muted(padded);
}
function formatFlowRows(flows: FlowRecord[], rich: boolean) {
const header = [
"Flow".padEnd(ID_PAD),
"Shape".padEnd(SHAPE_PAD),
"Status".padEnd(STATUS_PAD),
"Owner".padEnd(24),
"Tasks".padEnd(14),
"Goal",
].join(" ");
const lines = [rich ? theme.heading(header) : header];
for (const flow of flows) {
const taskSummary = getFlowTaskSummary(flow.flowId);
const counts = `${taskSummary.active} active/${taskSummary.total} total`;
lines.push(
[
shortToken(flow.flowId).padEnd(ID_PAD),
flow.shape.padEnd(SHAPE_PAD),
formatFlowStatusCell(flow.status, rich),
truncate(flow.ownerSessionKey, 24).padEnd(24),
counts.padEnd(14),
truncate(flow.goal, 80),
].join(" "),
);
}
return lines;
}
function formatFlowListSummary(flows: FlowRecord[]) {
const active = flows.filter(
(flow) => flow.status === "queued" || flow.status === "running",
).length;
const blocked = flows.filter((flow) => flow.status === "blocked").length;
return `${active} active · ${blocked} blocked · ${flows.length} total`;
}
export async function flowsListCommand(
opts: { json?: boolean; status?: string },
runtime: RuntimeEnv,
) {
const statusFilter = opts.status?.trim();
const flows = listFlowRecords().filter((flow) => {
if (statusFilter && flow.status !== statusFilter) {
return false;
}
return true;
});
if (opts.json) {
runtime.log(
JSON.stringify(
{
count: flows.length,
status: statusFilter ?? null,
flows: flows.map((flow) => ({
...flow,
tasks: listTasksForFlowId(flow.flowId),
taskSummary: getFlowTaskSummary(flow.flowId),
})),
},
null,
2,
),
);
return;
}
runtime.log(info(`Flows: ${flows.length}`));
runtime.log(info(`Flow pressure: ${formatFlowListSummary(flows)}`));
if (statusFilter) {
runtime.log(info(`Status filter: ${statusFilter}`));
}
if (flows.length === 0) {
runtime.log("No flows found.");
return;
}
const rich = isRich();
for (const line of formatFlowRows(flows, rich)) {
runtime.log(line);
}
}
export async function flowsShowCommand(
opts: { json?: boolean; lookup: string },
runtime: RuntimeEnv,
) {
const flow = resolveFlowForLookupToken(opts.lookup);
if (!flow) {
runtime.error(`Flow not found: ${opts.lookup}`);
runtime.exit(1);
return;
}
const tasks = listTasksForFlowId(flow.flowId);
const taskSummary = getFlowTaskSummary(flow.flowId);
if (opts.json) {
runtime.log(
JSON.stringify(
{
...flow,
tasks,
taskSummary,
},
null,
2,
),
);
return;
}
const lines = [
"Flow:",
`flowId: ${flow.flowId}`,
`shape: ${flow.shape}`,
`status: ${flow.status}`,
`notify: ${flow.notifyPolicy}`,
`ownerSessionKey: ${flow.ownerSessionKey}`,
`goal: ${flow.goal}`,
`currentStep: ${flow.currentStep ?? "n/a"}`,
`blockedTaskId: ${flow.blockedTaskId ?? "n/a"}`,
`blockedSummary: ${flow.blockedSummary ?? "n/a"}`,
`createdAt: ${new Date(flow.createdAt).toISOString()}`,
`updatedAt: ${new Date(flow.updatedAt).toISOString()}`,
`endedAt: ${flow.endedAt ? new Date(flow.endedAt).toISOString() : "n/a"}`,
`tasks: ${taskSummary.total} total · ${taskSummary.active} active · ${taskSummary.failures} issues`,
];
for (const line of lines) {
runtime.log(line);
}
if (tasks.length === 0) {
runtime.log("Linked tasks: none");
return;
}
runtime.log("Linked tasks:");
for (const task of tasks) {
runtime.log(
`- ${task.taskId} ${task.status} ${task.runId ?? "n/a"} ${task.label ?? task.task}`,
);
}
}
export async function flowsCancelCommand(opts: { lookup: string }, runtime: RuntimeEnv) {
const flow = resolveFlowForLookupToken(opts.lookup);
if (!flow) {
runtime.error(`Flow not found: ${opts.lookup}`);
runtime.exit(1);
return;
}
const result = await cancelFlowById({
cfg: loadConfig(),
flowId: flow.flowId,
});
if (!result.found) {
runtime.error(result.reason ?? `Flow not found: ${opts.lookup}`);
runtime.exit(1);
return;
}
if (!result.cancelled) {
runtime.error(result.reason ?? `Could not cancel flow: ${opts.lookup}`);
runtime.exit(1);
return;
}
const updated = getFlowById(flow.flowId) ?? result.flow ?? flow;
runtime.log(`Cancelled ${updated.flowId} (${updated.shape}) with status ${updated.status}.`);
}

View File

@ -4,10 +4,11 @@ import { requireNodeSqlite } from "../infra/node-sqlite.js";
import type { DeliveryContext } from "../utils/delivery-context.js";
import { resolveFlowRegistryDir, resolveFlowRegistrySqlitePath } from "./flow-registry.paths.js";
import type { FlowRegistryStoreSnapshot } from "./flow-registry.store.js";
import type { FlowRecord } from "./flow-registry.types.js";
import type { FlowRecord, FlowShape } from "./flow-registry.types.js";
type FlowRegistryRow = {
flow_id: string;
shape: FlowShape | null;
owner_session_key: string;
requester_origin_json: string | null;
status: FlowRecord["status"];
@ -66,6 +67,7 @@ function rowToFlowRecord(row: FlowRegistryRow): FlowRecord {
const requesterOrigin = parseJsonValue<DeliveryContext>(row.requester_origin_json);
return {
flowId: row.flow_id,
shape: row.shape === "linear" ? "linear" : "single_task",
ownerSessionKey: row.owner_session_key,
...(requesterOrigin ? { requesterOrigin } : {}),
status: row.status,
@ -83,6 +85,7 @@ function rowToFlowRecord(row: FlowRegistryRow): FlowRecord {
function bindFlowRecord(record: FlowRecord) {
return {
flow_id: record.flowId,
shape: record.shape,
owner_session_key: record.ownerSessionKey,
requester_origin_json: serializeJson(record.requesterOrigin),
status: record.status,
@ -102,6 +105,7 @@ function createStatements(db: DatabaseSync): FlowRegistryStatements {
selectAll: db.prepare(`
SELECT
flow_id,
shape,
owner_session_key,
requester_origin_json,
status,
@ -119,6 +123,7 @@ function createStatements(db: DatabaseSync): FlowRegistryStatements {
upsertRow: db.prepare(`
INSERT INTO flow_runs (
flow_id,
shape,
owner_session_key,
requester_origin_json,
status,
@ -132,6 +137,7 @@ function createStatements(db: DatabaseSync): FlowRegistryStatements {
ended_at
) VALUES (
@flow_id,
@shape,
@owner_session_key,
@requester_origin_json,
@status,
@ -145,6 +151,7 @@ function createStatements(db: DatabaseSync): FlowRegistryStatements {
@ended_at
)
ON CONFLICT(flow_id) DO UPDATE SET
shape = excluded.shape,
owner_session_key = excluded.owner_session_key,
requester_origin_json = excluded.requester_origin_json,
status = excluded.status,
@ -166,6 +173,7 @@ function ensureSchema(db: DatabaseSync) {
db.exec(`
CREATE TABLE IF NOT EXISTS flow_runs (
flow_id TEXT PRIMARY KEY,
shape TEXT NOT NULL,
owner_session_key TEXT NOT NULL,
requester_origin_json TEXT,
status TEXT NOT NULL,
@ -179,6 +187,7 @@ function ensureSchema(db: DatabaseSync) {
ended_at INTEGER
);
`);
ensureColumn(db, "flow_runs", "shape", "TEXT");
ensureColumn(db, "flow_runs", "blocked_task_id", "TEXT");
ensureColumn(db, "flow_runs", "blocked_summary", "TEXT");
db.exec(`CREATE INDEX IF NOT EXISTS idx_flow_runs_status ON flow_runs(status);`);

View File

@ -9,6 +9,7 @@ import type { FlowRecord } from "./flow-registry.types.js";
function createStoredFlow(): FlowRecord {
return {
flowId: "flow-restored",
shape: "linear",
ownerSessionKey: "agent:main:main",
status: "blocked",
notifyPolicy: "done_only",
@ -61,6 +62,7 @@ describe("flow-registry store runtime", () => {
expect(getFlowById("flow-restored")).toMatchObject({
flowId: "flow-restored",
shape: "linear",
goal: "Restored flow",
blockedTaskId: "task-restored",
blockedSummary: "Writable session required.",
@ -98,6 +100,7 @@ describe("flow-registry store runtime", () => {
expect(getFlowById(created.flowId)).toMatchObject({
flowId: created.flowId,
shape: "linear",
status: "waiting",
currentStep: "ask_user",
});

View File

@ -82,6 +82,28 @@ describe("flow-registry", () => {
});
});
it("lists newest flows first", async () => {
await withFlowRegistryTempDir(async (root) => {
process.env.OPENCLAW_STATE_DIR = root;
resetFlowRegistryForTests();
const earlier = createFlowRecord({
ownerSessionKey: "agent:main:main",
goal: "First flow",
createdAt: 100,
updatedAt: 100,
});
const later = createFlowRecord({
ownerSessionKey: "agent:main:main",
goal: "Second flow",
createdAt: 200,
updatedAt: 200,
});
expect(listFlowRecords().map((flow) => flow.flowId)).toEqual([later.flowId, earlier.flowId]);
});
});
it("applies minimal defaults for new flow records", async () => {
await withFlowRegistryTempDir(async (root) => {
process.env.OPENCLAW_STATE_DIR = root;
@ -94,6 +116,7 @@ describe("flow-registry", () => {
expect(created).toMatchObject({
flowId: expect.any(String),
shape: "linear",
ownerSessionKey: "agent:main:main",
goal: "Background job",
status: "queued",
@ -138,6 +161,7 @@ describe("flow-registry", () => {
resetFlowRegistryForTests();
const created = createFlowRecord({
shape: "single_task",
ownerSessionKey: "agent:main:main",
goal: "Fix permissions",
status: "running",
@ -184,4 +208,41 @@ describe("flow-registry", () => {
expect(resumed?.endedAt).toBeUndefined();
});
});
it("does not auto-sync linear flow state from linked child tasks", async () => {
await withFlowRegistryTempDir(async (root) => {
process.env.OPENCLAW_STATE_DIR = root;
resetFlowRegistryForTests();
const created = createFlowRecord({
ownerSessionKey: "agent:main:main",
goal: "Cluster PRs",
status: "waiting",
currentStep: "wait_for",
});
const synced = syncFlowFromTask({
taskId: "task-child",
parentFlowId: created.flowId,
status: "running",
notifyPolicy: "done_only",
label: "Child task",
task: "Child task",
lastEventAt: 250,
progressSummary: "Running child task",
});
expect(synced).toMatchObject({
flowId: created.flowId,
shape: "linear",
status: "waiting",
currentStep: "wait_for",
});
expect(getFlowById(created.flowId)).toMatchObject({
flowId: created.flowId,
status: "waiting",
currentStep: "wait_for",
});
});
});
});

View File

@ -1,6 +1,6 @@
import crypto from "node:crypto";
import { getFlowRegistryStore, resetFlowRegistryRuntimeForTests } from "./flow-registry.store.js";
import type { FlowRecord, FlowStatus } from "./flow-registry.types.js";
import type { FlowRecord, FlowShape, FlowStatus } from "./flow-registry.types.js";
import type { TaskNotifyPolicy, TaskRecord } from "./task-registry.types.js";
const flows = new Map<string, FlowRecord>();
@ -21,6 +21,10 @@ function ensureNotifyPolicy(notifyPolicy?: TaskNotifyPolicy): TaskNotifyPolicy {
return notifyPolicy ?? "done_only";
}
function ensureFlowShape(shape?: FlowShape): FlowShape {
return shape ?? "linear";
}
function resolveFlowGoal(task: Pick<TaskRecord, "label" | "task">): string {
return task.label?.trim() || task.task.trim() || "Background task";
}
@ -111,6 +115,7 @@ function persistFlowDelete(flowId: string) {
}
export function createFlowRecord(params: {
shape?: FlowShape;
ownerSessionKey: string;
requesterOrigin?: FlowRecord["requesterOrigin"];
status?: FlowStatus;
@ -127,6 +132,7 @@ export function createFlowRecord(params: {
const now = params.createdAt ?? Date.now();
const record: FlowRecord = {
flowId: crypto.randomUUID(),
shape: ensureFlowShape(params.shape),
ownerSessionKey: params.ownerSessionKey,
...(params.requesterOrigin ? { requesterOrigin: { ...params.requesterOrigin } } : {}),
status: params.status ?? "queued",
@ -173,6 +179,7 @@ export function createFlowForTask(params: {
? (params.task.endedAt ?? params.task.lastEventAt ?? params.task.createdAt)
: undefined;
return createFlowRecord({
shape: "single_task",
ownerSessionKey: params.task.requesterSessionKey,
requesterOrigin: params.requesterOrigin,
status: terminalFlowStatus,
@ -238,6 +245,13 @@ export function syncFlowFromTask(
if (!flowId) {
return null;
}
const flow = getFlowById(flowId);
if (!flow) {
return null;
}
if (flow.shape !== "single_task") {
return flow;
}
const terminalFlowStatus = deriveFlowStatusFromTask(task);
const isTerminal =
terminalFlowStatus === "succeeded" ||
@ -249,15 +263,15 @@ export function syncFlowFromTask(
status: terminalFlowStatus,
notifyPolicy: task.notifyPolicy,
goal: resolveFlowGoal(task),
blockedTaskId: terminalFlowStatus === "blocked" ? task.taskId.trim() || undefined : undefined,
blockedTaskId: terminalFlowStatus === "blocked" ? task.taskId.trim() || null : null,
blockedSummary:
terminalFlowStatus === "blocked" ? (resolveFlowBlockedSummary(task) ?? undefined) : undefined,
terminalFlowStatus === "blocked" ? (resolveFlowBlockedSummary(task) ?? null) : null,
updatedAt: task.lastEventAt ?? Date.now(),
...(isTerminal
? {
endedAt: task.endedAt ?? task.lastEventAt ?? Date.now(),
}
: { endedAt: undefined }),
: { endedAt: null }),
});
}
@ -267,11 +281,36 @@ export function getFlowById(flowId: string): FlowRecord | undefined {
return flow ? cloneFlowRecord(flow) : undefined;
}
export function listFlowsForOwnerSessionKey(sessionKey: string): FlowRecord[] {
ensureFlowRegistryReady();
const normalizedSessionKey = sessionKey.trim();
if (!normalizedSessionKey) {
return [];
}
return [...flows.values()]
.filter((flow) => flow.ownerSessionKey.trim() === normalizedSessionKey)
.map((flow) => cloneFlowRecord(flow))
.toSorted((left, right) => right.createdAt - left.createdAt);
}
export function findLatestFlowForOwnerSessionKey(sessionKey: string): FlowRecord | undefined {
const flow = listFlowsForOwnerSessionKey(sessionKey)[0];
return flow ? cloneFlowRecord(flow) : undefined;
}
export function resolveFlowForLookupToken(token: string): FlowRecord | undefined {
const lookup = token.trim();
if (!lookup) {
return undefined;
}
return getFlowById(lookup) ?? findLatestFlowForOwnerSessionKey(lookup);
}
export function listFlowRecords(): FlowRecord[] {
ensureFlowRegistryReady();
return [...flows.values()]
.map((flow) => cloneFlowRecord(flow))
.toSorted((left, right) => left.createdAt - right.createdAt);
.toSorted((left, right) => right.createdAt - left.createdAt);
}
export function deleteFlowRecordById(flowId: string): boolean {

View File

@ -1,6 +1,8 @@
import type { DeliveryContext } from "../utils/delivery-context.js";
import type { TaskNotifyPolicy } from "./task-registry.types.js";
export type FlowShape = "single_task" | "linear";
export type FlowStatus =
| "queued"
| "running"
@ -13,6 +15,7 @@ export type FlowStatus =
export type FlowRecord = {
flowId: string;
shape: FlowShape;
ownerSessionKey: string;
requesterOrigin?: DeliveryContext;
status: FlowStatus;

View File

@ -1,8 +1,15 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import { getFlowById, listFlowRecords, resetFlowRegistryForTests } from "./flow-registry.js";
import {
getFlowById,
listFlowRecords,
resetFlowRegistryForTests,
updateFlowRecordById,
} from "./flow-registry.js";
import {
cancelFlowById,
completeTaskRunByRunId,
createLinearFlow,
createQueuedTaskRun,
createRunningTaskRun,
failTaskRunByRunId,
@ -340,4 +347,118 @@ describe("task-executor", () => {
expect(findTaskByRunId("run-should-not-exist")).toBeUndefined();
});
});
it("keeps linear flows under explicit control instead of auto-syncing child task status", async () => {
await withTaskExecutorStateDir(async () => {
const flow = createLinearFlow({
ownerSessionKey: "agent:main:main",
goal: "Triage a PR cluster",
currentStep: "wait_for",
notifyPolicy: "done_only",
});
const child = createRunningTaskRun({
runtime: "acp",
requesterSessionKey: "agent:main:main",
parentFlowId: flow.flowId,
childSessionKey: "agent:codex:acp:child",
runId: "run-linear-child",
task: "Inspect a PR",
startedAt: 10,
deliveryStatus: "pending",
});
completeTaskRunByRunId({
runId: "run-linear-child",
endedAt: 40,
lastEventAt: 40,
terminalSummary: "Done.",
});
expect(child.parentFlowId).toBe(flow.flowId);
expect(getFlowById(flow.flowId)).toMatchObject({
flowId: flow.flowId,
shape: "linear",
status: "queued",
currentStep: "wait_for",
});
});
});
it("cancels active child tasks and marks a linear flow cancelled", async () => {
await withTaskExecutorStateDir(async () => {
hoisted.cancelSessionMock.mockResolvedValue(undefined);
const flow = createLinearFlow({
ownerSessionKey: "agent:main:main",
goal: "Cluster related PRs",
currentStep: "wait_for",
});
const child = createRunningTaskRun({
runtime: "acp",
requesterSessionKey: "agent:main:main",
parentFlowId: flow.flowId,
childSessionKey: "agent:codex:acp:child",
runId: "run-linear-cancel",
task: "Inspect a PR",
startedAt: 10,
deliveryStatus: "pending",
});
const cancelled = await cancelFlowById({
cfg: {} as never,
flowId: flow.flowId,
});
expect(cancelled).toMatchObject({
found: true,
cancelled: true,
flow: expect.objectContaining({
flowId: flow.flowId,
status: "cancelled",
}),
});
expect(findTaskByRunId("run-linear-cancel")).toMatchObject({
taskId: child.taskId,
status: "cancelled",
});
expect(getFlowById(flow.flowId)).toMatchObject({
flowId: flow.flowId,
status: "cancelled",
});
expect(hoisted.cancelSessionMock).toHaveBeenCalled();
});
});
it("refuses to rewrite terminal linear flows when cancel is requested", async () => {
await withTaskExecutorStateDir(async () => {
const flow = createLinearFlow({
ownerSessionKey: "agent:main:main",
goal: "Cluster related PRs",
currentStep: "finish",
});
updateFlowRecordById(flow.flowId, {
status: "succeeded",
endedAt: 55,
updatedAt: 55,
});
const cancelled = await cancelFlowById({
cfg: {} as never,
flowId: flow.flowId,
});
expect(cancelled).toMatchObject({
found: true,
cancelled: false,
reason: "Flow is already succeeded.",
});
expect(getFlowById(flow.flowId)).toMatchObject({
flowId: flow.flowId,
status: "succeeded",
endedAt: 55,
});
});
});
});

View File

@ -1,22 +1,32 @@
import type { OpenClawConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { createFlowForTask, deleteFlowRecordById, getFlowById } from "./flow-registry.js";
import {
createFlowForTask,
createFlowRecord,
deleteFlowRecordById,
getFlowById,
updateFlowRecordById,
} from "./flow-registry.js";
import type { FlowRecord } from "./flow-registry.types.js";
import {
cancelTaskById,
createTaskRecord,
findLatestTaskForFlowId,
linkTaskToFlowById,
listTasksForFlowId,
markTaskLostById,
markTaskRunningByRunId,
markTaskTerminalByRunId,
recordTaskProgressByRunId,
setTaskRunDeliveryStatusByRunId,
} from "./task-registry.js";
import { summarizeTaskRecords } from "./task-registry.summary.js";
import type {
TaskDeliveryState,
TaskDeliveryStatus,
TaskNotifyPolicy,
TaskRecord,
TaskRegistrySummary,
TaskRuntime,
TaskStatus,
TaskTerminalOutcome,
@ -95,6 +105,32 @@ export function createQueuedTaskRun(params: {
});
}
export function createLinearFlow(params: {
ownerSessionKey: string;
requesterOrigin?: TaskDeliveryState["requesterOrigin"];
goal: string;
notifyPolicy?: TaskNotifyPolicy;
currentStep?: string;
createdAt?: number;
updatedAt?: number;
}): FlowRecord {
return createFlowRecord({
shape: "linear",
ownerSessionKey: params.ownerSessionKey,
requesterOrigin: params.requesterOrigin,
goal: params.goal,
notifyPolicy: params.notifyPolicy,
currentStep: params.currentStep,
status: "queued",
createdAt: params.createdAt,
updatedAt: params.updatedAt,
});
}
export function getFlowTaskSummary(flowId: string): TaskRegistrySummary {
return summarizeTaskRecords(listTasksForFlowId(flowId));
}
type RetryBlockedFlowResult = {
found: boolean;
retried: boolean;
@ -230,6 +266,79 @@ export function retryBlockedFlowAsRunningTaskRun(
});
}
type CancelFlowResult = {
found: boolean;
cancelled: boolean;
reason?: string;
flow?: FlowRecord;
tasks?: TaskRecord[];
};
function isActiveTaskStatus(status: TaskStatus): boolean {
return status === "queued" || status === "running";
}
function isTerminalFlowStatus(status: FlowRecord["status"]): boolean {
return (
status === "succeeded" || status === "failed" || status === "cancelled" || status === "lost"
);
}
export async function cancelFlowById(params: {
cfg: OpenClawConfig;
flowId: string;
}): Promise<CancelFlowResult> {
const flow = getFlowById(params.flowId);
if (!flow) {
return {
found: false,
cancelled: false,
reason: "Flow not found.",
};
}
const linkedTasks = listTasksForFlowId(flow.flowId);
const activeTasks = linkedTasks.filter((task) => isActiveTaskStatus(task.status));
for (const task of activeTasks) {
await cancelTaskById({
cfg: params.cfg,
taskId: task.taskId,
});
}
const refreshedTasks = listTasksForFlowId(flow.flowId);
const remainingActive = refreshedTasks.filter((task) => isActiveTaskStatus(task.status));
if (remainingActive.length > 0) {
return {
found: true,
cancelled: false,
reason: "One or more child tasks are still active.",
flow: getFlowById(flow.flowId),
tasks: refreshedTasks,
};
}
if (isTerminalFlowStatus(flow.status)) {
return {
found: true,
cancelled: false,
reason: `Flow is already ${flow.status}.`,
flow,
tasks: refreshedTasks,
};
}
const updatedFlow = updateFlowRecordById(flow.flowId, {
status: "cancelled",
blockedTaskId: null,
blockedSummary: null,
endedAt: Date.now(),
updatedAt: Date.now(),
});
return {
found: true,
cancelled: true,
flow: updatedFlow ?? getFlowById(flow.flowId),
tasks: refreshedTasks,
};
}
export function createRunningTaskRun(params: {
runtime: TaskRuntime;
sourceId?: string;

View File

@ -1144,6 +1144,7 @@ describe("task-registry", () => {
});
const flow = createFlowRecord({
shape: "single_task",
ownerSessionKey: "agent:flow:owner",
requesterOrigin: {
channel: "discord",
@ -1332,6 +1333,7 @@ describe("task-registry", () => {
});
const flow = createFlowRecord({
shape: "single_task",
ownerSessionKey: "agent:flow:owner",
requesterOrigin: {
channel: "discord",