From 1742d12d21fb1513354f379b1ca616fb24114948 Mon Sep 17 00:00:00 2001 From: pallaoro Date: Sat, 26 Sep 2026 21:49:52 +0200 Subject: [PATCH] Containers no longer time out while the steps inside them are still within their own limits A condition, branch, loop or parallel node got the same default 30s limit as a single step, measured over everything inside it. A branch holding an agent step allowed several minutes failed at 30s. Containers now get no default limit: each inner step keeps its own, and an explicit `timeout` on the container still caps the whole group. The per-attempt timer is also cleared once a node settles. --- openclaw.plugin.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- skills/clawflow/SKILL.md | 1 + src/core/runner.ts | 45 ++++++++++++++++++++++++++------------- src/core/types.ts | 3 +++ tests/core.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 84 insertions(+), 19 deletions(-) diff --git a/openclaw.plugin.json b/openclaw.plugin.json index c111bca..9729bb5 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -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" ], diff --git a/package-lock.json b/package-lock.json index a57b1c4..ac28dcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@clawnify/clawflow", - "version": "1.7.0", + "version": "1.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@clawnify/clawflow", - "version": "1.7.0", + "version": "1.7.1", "license": "MIT", "dependencies": { "croner": "^10.0.1" diff --git a/package.json b/package.json index d790006..d00ceef 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/skills/clawflow/SKILL.md b/skills/clawflow/SKILL.md index 63a3b39..f5e8f21 100644 --- a/skills/clawflow/SKILL.md +++ b/skills/clawflow/SKILL.md @@ -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 diff --git a/src/core/runner.ts b/src/core/runner.ts index f883f7c..ccb4369 100644 --- a/src/core/runner.ts +++ b/src/core/runner.ts @@ -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 = new Set(["condition", "branch", "loop", "parallel"]); + export class FlowRunner { private cfg: PluginConfig; private store: StateStore; @@ -456,29 +460,38 @@ 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 | undefined; try { const work = this.execNode(node, state, flow, instanceId); - const result = await Promise.race([ - work, - new Promise((_, 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((_, 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)); @@ -486,6 +499,8 @@ export class FlowRunner { const delayMs = this.calcDelay(policy, attempt); await new Promise((r) => setTimeout(r, delayMs)); } + } finally { + clearTimeout(timer); } } throw lastError; diff --git a/src/core/types.ts b/src/core/types.ts index 561fc86..4103f0a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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 /** diff --git a/tests/core.test.ts b/tests/core.test.ts index 7032ffa..daca758 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -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); + }); +});