Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"id": "clawflow",
"name": "ClawFlow",
"description": "The n8n for agents. Declarative, AI-native workflow engine \u2014 LLM-writable, Cloudflare-portable.",
"version": "1.7.0",
"version": "1.7.1",
"skills": [
"./skills/clawflow"
],
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@clawnify/clawflow",
"version": "1.7.0",
"version": "1.7.1",
"description": "The n8n for agents. A declarative, AI-native workflow format that agents can read, write, and run.",
"type": "module",
"main": "./dist/index.js",
Expand Down
1 change: 1 addition & 0 deletions skills/clawflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ A flow is JSON with a `flow` name, an optional `env` block, and a `nodes` array.
- **CRITICAL: `output` is required to store a node's result in state.** Without it, the result is discarded. This applies to ALL nodes including `loop`, `branch`, `parallel`, `condition`. If a downstream node references a result, the producing node MUST have `output`.
- Always add `schema` to `ai` nodes when downstream nodes need typed fields
- Use `retry` on `http` and `ai` nodes: `{ "limit": 3, "delay": "2s", "backoff": "exponential" }`
- `timeout` limits one node (default 30s, `maxNodeDurationMs`). Give slow steps their own (`agent` nodes often need minutes). Containers (`condition`, `branch`, `loop`, `parallel`) have no default limit: each step inside keeps its own, and a `timeout` set on the container caps the whole group
- Use `do: exec` for deterministic operations (scripts, file processing, CLI tools) — never use `do: agent` for pure shell commands
- Use `do: agent` for tasks that need tools (browser, exec, memory, MCP, CLI) — delegates to a real OpenClaw agent
- Use `do: ai` for structured extraction and single-turn LLM calls
Expand Down
45 changes: 30 additions & 15 deletions src/core/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ function applyFilter(val: unknown, filter: string): unknown {

// ---- Runner ---------------------------------------------------------------------

/** Nodes that only run other nodes. See runWithRetry for why they get no
* default time limit. */
const CONTAINER_NODES: ReadonlySet<string> = new Set(["condition", "branch", "loop", "parallel"]);

export class FlowRunner {
private cfg: PluginConfig;
private store: StateStore;
Expand Down Expand Up @@ -456,36 +460,47 @@ export class FlowRunner {
attempts?: number;
}> {
const policy: RetryPolicy = node.retry ?? { limit: 1, delay: 0 };
// The default limit is for one step. A container (condition, branch, loop,
// parallel) only groups steps that each carry their own limit, so it gets
// none by default; an explicit `timeout` on it still caps the whole group.
const nodeTimeoutMs = node.timeout
? parseDuration(node.timeout)
: (this.cfg.maxNodeDurationMs ?? 30_000);
: CONTAINER_NODES.has(node.do)
? undefined
: (this.cfg.maxNodeDurationMs ?? 30_000);

let lastError: Error = new Error("Unknown error");

for (let attempt = 1; attempt <= policy.limit; attempt++) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const work = this.execNode(node, state, flow, instanceId);
const result = await Promise.race([
work,
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error(
`Node "${node.name}" timed out after ${nodeTimeoutMs}ms`,
),
),
nodeTimeoutMs,
),
),
]);
const result =
nodeTimeoutMs === undefined
? await work
: await Promise.race([
work,
new Promise<never>((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
`Node "${node.name}" timed out after ${nodeTimeoutMs}ms`,
),
),
nodeTimeoutMs,
);
}),
]);
return { ...result, attempts: attempt };
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < policy.limit) {
const delayMs = this.calcDelay(policy, attempt);
await new Promise((r) => setTimeout(r, delayMs));
}
} finally {
clearTimeout(timer);
}
}
throw lastError;
Expand Down
3 changes: 3 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,9 @@ export interface PluginConfig {
*/
defaultProvider?: string;
memoryDir?: string;
/** Default time limit for a single node without its own `timeout`
* (default 30000). Not applied to containers (condition, branch, loop,
* parallel): the nodes inside carry their own limits. */
maxNodeDurationMs?: number;
stateDir?: string; // where to persist flow state across restarts
/**
Expand Down
46 changes: 46 additions & 0 deletions tests/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2493,3 +2493,49 @@ describe("FlowRunner — ai node providers", () => {
assert.match(JSON.stringify(result), /no API key/);
});
});

describe("FlowRunner — container node timeouts", () => {
after(cleanup);

// The default per-node limit is for a single step. A condition or loop only
// groups steps that carry their own limits, so it must not cut them short.
const shortDefault: PluginConfig = { ...cfg, maxNodeDurationMs: 100 };
const slowStep = { name: "slow", do: "exec" as const, command: "sleep 0.4 && echo done", timeout: "5s", output: "slow_out" };

it("a condition does not time out while its branch runs within the branch's own limits", async () => {
const flow: FlowDefinition = {
flow: "test-condition-slow-branch",
nodes: [{ name: "gate", do: "condition" as const, if: "inputs.go", then: [slowStep] }],
};
const result = await new FlowRunner(shortDefault).run(flow, { go: true });
assert.equal(result.ok, true, JSON.stringify(result.error));
});

it("a loop does not time out while its iterations run within their own limits", async () => {
const flow: FlowDefinition = {
flow: "test-loop-slow-body",
nodes: [{ name: "each", do: "loop" as const, over: "inputs.items", as: "item", nodes: [slowStep], output: "all" }],
};
const result = await new FlowRunner(shortDefault).run(flow, { items: [1] });
assert.equal(result.ok, true, JSON.stringify(result.error));
});

it("an explicit timeout on a container still caps the whole branch", async () => {
const flow: FlowDefinition = {
flow: "test-condition-explicit-timeout",
nodes: [{ name: "gate", do: "condition" as const, if: "inputs.go", timeout: "100ms", then: [slowStep] }],
};
const result = await new FlowRunner(cfg).run(flow, { go: true });
assert.equal(result.ok, false);
assert.match(String(result.error), /timed out after 100ms/);
});

it("a single step still gets the default limit", async () => {
const flow: FlowDefinition = {
flow: "test-leaf-default-timeout",
nodes: [{ name: "slow", do: "exec" as const, command: "sleep 0.4", output: "out" }],
};
const result = await new FlowRunner(shortDefault).run(flow, {});
assert.equal(result.ok, false);
});
});
Loading