@jiggai/recipes 0.4.49 → 0.4.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,12 +53,17 @@ It is built for people who want durable artifacts on disk, not hidden app state.
53
53
 
54
54
  ```bash
55
55
  openclaw plugins install @jiggai/recipes
56
+ openclaw plugins enable recipes
56
57
  openclaw gateway restart
57
58
  openclaw plugins list
58
59
  ```
59
60
 
61
+ > If you see `plugins.allow is empty; discovered non-bundled plugins may auto-load`, run `openclaw plugins enable recipes` to explicitly allowlist the plugin and suppress the warning.
62
+
60
63
  > **Note:** The OpenClaw plugin installer enforces `pluginApi` version checks. If you see a version mismatch error, use the npm install method below instead.
61
64
 
65
+ > During install you may see: `Plugin "recipes" has 2 suspicious code pattern(s)`. This is expected — ClawRecipes reads API keys from your OpenClaw config and passes them to media generation scripts (e.g., DALL-E, Kling). This is required for workflow execution and is not a security concern.
66
+
62
67
  **npm install**
63
68
 
64
69
  ```bash
@@ -183,8 +188,28 @@ openclaw recipes workflows resume \
183
188
  --run-id <runId>
184
189
  ```
185
190
 
191
+ ### Bundled workflow examples
192
+
193
+ Four ready-to-run workflow examples ship under
194
+ [`examples/workflows/`](examples/workflows/):
195
+
196
+ - [`marketing-cadence-v1`](examples/workflows/marketing-cadence-v1/) — text-only marketing cadence
197
+ - [`marketing-image-generation-handoff`](examples/workflows/marketing-image-generation-handoff/) — image generation + handoff to social publish
198
+ - [`marketing-video-generation-handoff`](examples/workflows/marketing-video-generation-handoff/) — video generation cadence
199
+ - [`social-media-publish`](examples/workflows/social-media-publish/) — handoff target that publishes via kitchen-plugin-marketing + Postiz
200
+
201
+ Each example directory contains the workflow JSON, a `cron-jobs.example.json`,
202
+ an `install-crons.sh` helper, and a per-example README with prerequisites
203
+ and install steps. See [docs/WORKFLOW_EXAMPLES_BUNDLED.md](docs/WORKFLOW_EXAMPLES_BUNDLED.md)
204
+ for the catalog, required cron jobs, and a recommendation on
205
+ `agents.defaults.maxConcurrent` sizing.
206
+
186
207
  See also:
187
208
  - [docs/WORKFLOW_RUNS_FILE_FIRST.md](docs/WORKFLOW_RUNS_FILE_FIRST.md)
209
+ - [docs/WORKFLOW_NODES.md](docs/WORKFLOW_NODES.md)
210
+ - [docs/WORKFLOW_APPROVALS.md](docs/WORKFLOW_APPROVALS.md)
211
+ - [docs/WORKFLOW_EXAMPLES.md](docs/WORKFLOW_EXAMPLES.md) — small, hand-rolled pattern snippets
212
+ - [docs/WORKFLOW_EXAMPLES_BUNDLED.md](docs/WORKFLOW_EXAMPLES_BUNDLED.md) — full bundled examples + cron/concurrency guide
188
213
  - [docs/OUTBOUND_POSTING.md](docs/OUTBOUND_POSTING.md)
189
214
 
190
215
  ---
@@ -6,6 +6,11 @@ ClawRecipes ships with bundled recipes in:
6
6
  recipes/default/
7
7
  ```
8
8
 
9
+ > **Looking for bundled workflow examples** (not team recipes)? See
10
+ > [`WORKFLOW_EXAMPLES_BUNDLED.md`](./WORKFLOW_EXAMPLES_BUNDLED.md) and the
11
+ > [`examples/workflows/`](../examples/workflows/) directory for four
12
+ > ready-to-run workflow definitions with matching cron-install scripts.
13
+
9
14
  You can browse them with:
10
15
 
11
16
  ```bash
@@ -1,9 +1,18 @@
1
1
  # Workflow examples
2
2
 
3
- This document gives you copyable workflow patterns for ClawRecipes.
3
+ This document gives you copyable workflow **patterns** for ClawRecipes
4
+ small snippets that illustrate individual node kinds and graph shapes.
4
5
 
5
- Use it together with:
6
+ > **Looking for full, production-style workflows?**
7
+ > See [`WORKFLOW_EXAMPLES_BUNDLED.md`](./WORKFLOW_EXAMPLES_BUNDLED.md) and
8
+ > [`examples/workflows/`](../examples/workflows/). Those are four end-to-end
9
+ > workflows (marketing cadence variants + social publish) complete with
10
+ > matching cron jobs and install scripts, ready to drop into a fresh team.
11
+
12
+ Use this doc together with:
6
13
  - [WORKFLOW_RUNS_FILE_FIRST.md](WORKFLOW_RUNS_FILE_FIRST.md) — concepts, node kinds, triggers, runs, edges
14
+ - [WORKFLOW_NODES.md](WORKFLOW_NODES.md) — reference for every node type
15
+ - [WORKFLOW_EXAMPLES_BUNDLED.md](WORKFLOW_EXAMPLES_BUNDLED.md) — full bundled examples + cron + concurrency guide
7
16
  - [OUTBOUND_POSTING.md](OUTBOUND_POSTING.md) — publishing/posting setup
8
17
 
9
18
  ---
@@ -0,0 +1,313 @@
1
+ # Bundled workflow examples
2
+
3
+ ClawRecipes ships four ready-to-run workflow examples under
4
+ [`examples/workflows/`](../examples/workflows/). Each example is a copy of a
5
+ workflow currently running in production, with all team-specific ids,
6
+ credentials, and secrets replaced by placeholders. You can drop any of them
7
+ into a freshly scaffolded team, install the matching crons, and run it
8
+ end-to-end.
9
+
10
+ Unlike the hand-rolled snippets in [`WORKFLOW_EXAMPLES.md`](./WORKFLOW_EXAMPLES.md),
11
+ these are complete, multi-node, multi-agent workflows. Use this doc if you
12
+ want to:
13
+
14
+ - See what a full production cadence looks like (research → draft → QC →
15
+ approve → post, with optional image/video generation and social-team
16
+ handoff)
17
+ - Bootstrap your own team's workflows by copying and adapting one
18
+ - Understand the cron setup a real workflow needs
19
+
20
+ ## The four examples at a glance
21
+
22
+ | Example | Media | Pattern | Agents used |
23
+ |---|---|---|---|
24
+ | [`marketing-cadence-v1`](../examples/workflows/marketing-cadence-v1/) | none | Self-posting (text only) | analyst, copywriter, compliance, lead |
25
+ | [`marketing-image-generation-handoff`](../examples/workflows/marketing-image-generation-handoff/) | image | **Handoff** to social-team workflow | analyst, copywriter, **designer**, compliance, lead |
26
+ | [`marketing-video-generation-handoff`](../examples/workflows/marketing-video-generation-handoff/) | video | Self-posting (see note in README) | analyst, copywriter, **designer**, compliance, lead |
27
+ | [`social-media-publish`](../examples/workflows/social-media-publish/) | passthrough | Handoff target — publishes via kitchen-plugin-marketing + Postiz | social-team-lead |
28
+
29
+ Each example directory contains:
30
+
31
+ - `*.workflow.json` — the workflow definition, ready to copy into a team's
32
+ `shared-context/workflows/` directory
33
+ - `cron-jobs.example.json` — the cron jobs required for the workflow, in a
34
+ format suitable for `openclaw cron import --from-file` bulk import
35
+ - `install-crons.sh` — the same cron jobs as an idempotent shell script
36
+ using individual `openclaw cron add` calls
37
+ - `README.md` — per-example prerequisites, install steps, gotchas, and
38
+ configuration points
39
+
40
+ ## Quick start
41
+
42
+ ### 1. Scaffold the teams
43
+
44
+ The marketing examples expect `teamId=marketing-team`; the social example
45
+ expects `teamId=social-team`. ClawRecipes' built-in recipes create all the
46
+ required agents:
47
+
48
+ ```bash
49
+ openclaw recipes scaffold --recipe marketing-team --team-id marketing-team
50
+ openclaw recipes scaffold --recipe social-team --team-id social-team
51
+ ```
52
+
53
+ If you want different team ids, edit the relevant fields in the workflow
54
+ JSON (all `*-team-*` agentId references) and set `TEAM_ID` before running
55
+ `install-crons.sh`.
56
+
57
+ ### 2. Copy a workflow into the team's workspace
58
+
59
+ ```bash
60
+ cp examples/workflows/marketing-image-generation-handoff/marketing-image-generation-handoff.workflow.json \
61
+ ~/.openclaw/workspace-marketing-team/shared-context/workflows/
62
+
63
+ cp examples/workflows/social-media-publish/social-media-publish.workflow.json \
64
+ ~/.openclaw/workspace-social-team/shared-context/workflows/
65
+ ```
66
+
67
+ ### 3. Install the required crons
68
+
69
+ ```bash
70
+ bash examples/workflows/marketing-image-generation-handoff/install-crons.sh
71
+ bash examples/workflows/social-media-publish/install-crons.sh
72
+ ```
73
+
74
+ Or bulk-import via JSON:
75
+
76
+ ```bash
77
+ openclaw cron import --from-file examples/workflows/marketing-image-generation-handoff/cron-jobs.example.json
78
+ openclaw cron import --from-file examples/workflows/social-media-publish/cron-jobs.example.json
79
+ ```
80
+
81
+ ### 4. Tune placeholders
82
+
83
+ Before running, **edit the workflow JSON** to replace the placeholders left
84
+ in the examples:
85
+
86
+ - `<your-telegram-user-id>` — the `approvalTarget` in the marketing examples'
87
+ `meta` block and on the `approval` node
88
+ - `<your-postiz-integration-id>` — the `integrationIds` in
89
+ `marketing-image-generation-handoff`'s handoff variable mapping
90
+
91
+ And (for `social-media-publish`) set the gateway env vars:
92
+
93
+ - `CK_BASE_URL` — a URL the gateway process can use to reach its own Kitchen
94
+ HTTP endpoints (e.g. `http://127.0.0.1:7777`)
95
+ - `CK_AUTH` — `<user>:<password>` for Kitchen basic-auth
96
+
97
+ ### 5. Trigger a run
98
+
99
+ ```bash
100
+ openclaw recipes workflows enqueue \
101
+ --team-id marketing-team \
102
+ --workflow-id marketing-image-generation-handoff
103
+ ```
104
+
105
+ Or use the ClawKitchen UI: **teams → workflows → Run**.
106
+
107
+ ## Cron requirements
108
+
109
+ Every workflow requires **two classes of crons** running periodically:
110
+
111
+ ### Runner-tick (1 per team)
112
+
113
+ `workflow-runner-tick:<teamId>` — claims runs in `queued` status, auto-skips
114
+ `start`/`end` nodes, and enqueues the first runnable node onto its owning
115
+ agent's worker queue. Without this, new runs never leave `queued`.
116
+
117
+ Default schedule in bundled example cron files: `*/5 * * * *` (every 5 minutes).
118
+ Default timeout: 900 seconds.
119
+
120
+ ### Worker-tick (1 per agent referenced in any workflow)
121
+
122
+ `workflow-worker:<teamId>:<agentId>` — drains that agent's queue and
123
+ executes the node work. Each tick:
124
+
125
+ 1. Dequeues up to 5 tasks from the queue
126
+ 2. For each task: acquires a per-node lock, loads the run file, resolves
127
+ the node config, executes the node (`llm`, `tool`, `media-*`,
128
+ `human_approval`, `handoff`), writes results, and enqueues the next
129
+ runnable node
130
+
131
+ Default schedule: `*/5 * * * *`. Default timeout: 120 seconds per session.
132
+
133
+ ### How many crons?
134
+
135
+ Rule of thumb: **1 runner + N workers per team**, where N is the number of
136
+ distinct `agentId` values referenced by any node across all workflows
137
+ installed in that team. Worker crons are shared between workflows — if two
138
+ workflows both use `marketing-team-lead`, you still only need one lead
139
+ worker.
140
+
141
+ The four bundled examples need:
142
+
143
+ | Team | Workflows installed | Runners | Workers | Total |
144
+ |---|---|---|---|---|
145
+ | `marketing-team` | v1 only | 1 | 4 (analyst, copywriter, compliance, lead) | 5 |
146
+ | `marketing-team` | v1 + image-handoff | 1 | 5 (+designer) | 6 |
147
+ | `marketing-team` | v1 + image-handoff + video-handoff | 1 | 5 | 6 |
148
+ | `social-team` | social-media-publish | 1 | 1 (lead) | 2 |
149
+
150
+ ## Gateway concurrency — raise `agents.defaults.maxConcurrent`
151
+
152
+ > ⚠️ **This is the single most common cause of workflow runs appearing
153
+ > "stuck"** on first install.
154
+
155
+ Every workflow cron fires an isolated agent session through the gateway's
156
+ agent system. That session invokes the `openclaw recipes workflows
157
+ (runner|worker)-tick` CLI command via the `exec` tool, waits for it, then
158
+ responds `NO_REPLY`. Each of those in-flight sessions counts against the
159
+ gateway's `agents.defaults.maxConcurrent` limit.
160
+
161
+ The default is:
162
+
163
+ ```json
164
+ "agents": {
165
+ "defaults": {
166
+ "maxConcurrent": 8,
167
+ "subagents": { "maxConcurrent": 8 }
168
+ }
169
+ }
170
+ ```
171
+
172
+ A minimal marketing+social setup (image-handoff + social-publish) already
173
+ runs **1 marketing-runner + 5 marketing-workers + 1 social-runner +
174
+ 1 social-worker = 8 workflow crons**. Add any memory/heartbeat crons on top
175
+ and you are over budget. When that happens:
176
+
177
+ - Crons back up into a scheduler queue and fire 15–30 minutes later than
178
+ intended
179
+ - Workflow runs appear to stall mid-pipeline
180
+ - Session failures cascade because nothing can retry while the queue is
181
+ saturated
182
+
183
+ ### Recommended setting for example-backed setups
184
+
185
+ Edit `~/.openclaw/openclaw.json`:
186
+
187
+ ```json
188
+ "agents": {
189
+ "defaults": {
190
+ "maxConcurrent": 24,
191
+ "subagents": { "maxConcurrent": 24 }
192
+ }
193
+ }
194
+ ```
195
+
196
+ Then restart the gateway:
197
+
198
+ ```bash
199
+ openclaw gateway restart
200
+ ```
201
+
202
+ Adjust higher if you run many workflows or many teams. A reasonable formula:
203
+
204
+ > `maxConcurrent ≥ (total workflow crons across all teams) + (other crons firing on overlapping schedules) + 4 headroom`
205
+
206
+ ## Model selection
207
+
208
+ Bundled cron payloads **do not hard-code a model**. When the gateway runs
209
+ a tick session, it falls back to its default model (typically configured
210
+ under `agents.defaults.model` in `openclaw.json`).
211
+
212
+ You can pin a specific model in three ways:
213
+
214
+ 1. **Shell install**: `MODEL=openai/gpt-5.4 bash install-crons.sh`
215
+
216
+ 2. **JSON import**: uncomment the `model` field in the example
217
+ `cron-jobs.example.json` before importing
218
+
219
+ 3. **After install**: `openclaw cron edit <cron-id> --model openai/gpt-5.4`
220
+
221
+ Worker-tick sessions don't need smart models — they just run one
222
+ predictable shell command and respond `NO_REPLY`. A cheap/fast model is
223
+ ideal. Runner-ticks similarly just invoke a CLI command.
224
+
225
+ ### ClawKitchen default-cron model override
226
+
227
+ If you use ClawKitchen's **"Install worker cron(s)"** button in the workflow
228
+ editor instead of the bundled example files, you can set a model override
229
+ globally via env var on the gateway:
230
+
231
+ ```json
232
+ "env": {
233
+ "vars": {
234
+ "KITCHEN_WORKFLOW_CRON_MODEL": "openai/gpt-5.4"
235
+ }
236
+ }
237
+ ```
238
+
239
+ When set, every workflow cron that Kitchen installs will carry that
240
+ `--model` flag. Leave unset to fall back to the gateway default.
241
+
242
+ You can also override the schedule:
243
+
244
+ ```json
245
+ "env": {
246
+ "vars": {
247
+ "KITCHEN_WORKFLOW_CRON_SCHEDULE": "*/10 * * * *"
248
+ }
249
+ }
250
+ ```
251
+
252
+ ## Handoff chain
253
+
254
+ `marketing-image-generation-handoff` contains a `handoff` node that targets
255
+ `social-media-publish`. To use the handoff end-to-end:
256
+
257
+ 1. Install both workflows in their respective teams (`marketing-team` and
258
+ `social-team`)
259
+ 2. Ensure `social-media-publish`'s runtime prerequisites are satisfied
260
+ (kitchen-plugin-marketing installed, Postiz account configured, gateway
261
+ env vars set — see the example's README)
262
+ 3. Trigger a run of `marketing-image-generation-handoff`
263
+
264
+ When the approval step clears, the `handoff_social_instagram` node fires
265
+ and enqueues a new run in `social-team`. You'll see it in that team's runs
266
+ page (or via `openclaw recipes workflows list-runs --team-id social-team`).
267
+
268
+ The handoff is **fire-and-forget** by default — the marketing run completes
269
+ without waiting for the social publish to succeed. Check the target team's
270
+ runs page for downstream status.
271
+
272
+ ## Troubleshooting
273
+
274
+ ### Runs start but nothing happens past the first node
275
+ Check that the **worker-tick cron for the agent owning that node** is
276
+ installed and enabled. Each agent referenced in the workflow needs its own
277
+ worker-tick.
278
+
279
+ ### Worker-tick returns `skipped_locked` on every task
280
+ A previous worker session crashed mid-execution and left a lock file in
281
+ `workflow-runs/<runId>/locks/<nodeId>.lock`. Locks expire after ~12 minutes.
282
+ Either wait, or manually delete the stale lock file.
283
+
284
+ ### `fetch failed` during `store_and_publish` (social-media-publish)
285
+ The gateway process can't reach its own Kitchen HTTP endpoint via the URL
286
+ in the request's `host` header. Set `CK_BASE_URL` in the gateway env to a
287
+ URL that IS reachable from inside the gateway process (e.g.
288
+ `http://127.0.0.1:7777` if Kitchen binds loopback).
289
+
290
+ ### Runs hang at `waiting_workers` forever with no new events
291
+ Check `openclaw cron list --json` for your workflow crons — are they
292
+ firing? If not, the gateway cron scheduler is probably saturated. Bump
293
+ `agents.defaults.maxConcurrent` (see the Gateway concurrency section
294
+ above) and restart.
295
+
296
+ ### Node locks from crashed workers pile up duplicate queue entries
297
+ Fixed in ClawRecipes 0.4.50+ via a `hasPendingTaskFor` guard in the
298
+ worker-tick. If you're on an older version, upgrade.
299
+
300
+ ## See also
301
+
302
+ - [`examples/workflows/README.md`](../examples/workflows/README.md) — top-level
303
+ index with install commands
304
+ - [`WORKFLOW_RUNS_FILE_FIRST.md`](./WORKFLOW_RUNS_FILE_FIRST.md) — how the
305
+ runner/worker split works internally
306
+ - [`WORKFLOW_NODES.md`](./WORKFLOW_NODES.md) — reference for all node types
307
+ used in these examples
308
+ - [`WORKFLOW_APPROVALS.md`](./WORKFLOW_APPROVALS.md) — how the `human_approval`
309
+ node integrates with Telegram, Slack, and the web UI
310
+ - [`MEDIA_GENERATION.md`](./MEDIA_GENERATION.md) — provider setup for
311
+ `media-image` / `media-video` nodes
312
+ - [`WORKFLOW_EXAMPLES.md`](./WORKFLOW_EXAMPLES.md) — hand-rolled smaller
313
+ workflow examples showing individual node types
package/index.ts CHANGED
@@ -46,7 +46,7 @@ import {
46
46
  import { handleScaffold, scaffoldAgentFromRecipe } from "./src/handlers/scaffold";
47
47
  import { handleAddRoleToTeam } from "./src/handlers/team-add-role";
48
48
  import { reconcileRecipeCronJobs } from "./src/handlers/cron";
49
- import { handleWorkflowsApprove, handleWorkflowsPollApprovals, handleWorkflowsResume, handleWorkflowsRun, handleWorkflowsRunnerOnce, handleWorkflowsRunnerTick, handleWorkflowsWorkerTick } from "./src/handlers/workflows";
49
+ import { handleWorkflowsApprove, handleWorkflowsCleanupQueues, handleWorkflowsPollApprovals, handleWorkflowsResume, handleWorkflowsRun, handleWorkflowsRunnerOnce, handleWorkflowsRunnerTick, handleWorkflowsWorkerTick } from "./src/handlers/workflows";
50
50
  import { handleMediaDriversList } from "./src/handlers/media-drivers";
51
51
  import { listRecipeFiles, loadRecipeById, workspacePath } from "./src/lib/recipes";
52
52
  import {
@@ -750,6 +750,17 @@ workflows
750
750
  console.log(JSON.stringify(res, null, 2));
751
751
  });
752
752
 
753
+ workflows
754
+ .command("cleanup-queues")
755
+ .description("Remove stale queue tasks for runs that are completed, errored, or deleted")
756
+ .requiredOption("--team-id <teamId>", "Team id (workspace-<teamId>)")
757
+ .action(async (options: { teamId?: string }) => {
758
+ const res = await handleWorkflowsCleanupQueues(api, {
759
+ teamId: String(options.teamId ?? ''),
760
+ });
761
+ console.log(JSON.stringify(res, null, 2));
762
+ });
763
+
753
764
  cmd
754
765
  .command("move-ticket")
755
766
  .description("Move a ticket between backlog/in-progress/testing/done (updates Status: line)")
@@ -2,7 +2,7 @@
2
2
  "id": "recipes",
3
3
  "name": "Recipes",
4
4
  "description": "Markdown recipes that scaffold agents and teams (workspace-local).",
5
- "version": "0.4.49",
5
+ "version": "0.4.51",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiggai/recipes",
3
- "version": "0.4.49",
3
+ "version": "0.4.51",
4
4
  "description": "ClawRecipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
5
5
  "main": "index.ts",
6
6
  "type": "commonjs",
@@ -9,7 +9,7 @@
9
9
  "./index.ts"
10
10
  ],
11
11
  "compat": {
12
- "pluginApi": "2026.4.9",
12
+ "pluginApi": ">=1.0.0",
13
13
  "pluginApiRange": ">=2026.3"
14
14
  },
15
15
  "build": {
@@ -1,5 +1,7 @@
1
1
  import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
2
2
  import { approveWorkflowRun, enqueueWorkflowRun, pollWorkflowApprovals, resumeWorkflowRun, runWorkflowRunnerOnce, runWorkflowRunnerTick, runWorkflowWorkerTick } from '../lib/workflows/workflow-runner';
3
+ import { cleanupQueues } from '../lib/workflows/workflow-queue';
4
+ import { resolveTeamDir } from '../lib/workspace';
3
5
 
4
6
  export async function handleWorkflowsRun(api: OpenClawPluginApi, opts: {
5
7
  teamId: string;
@@ -79,3 +81,11 @@ export async function handleWorkflowsPollApprovals(api: OpenClawPluginApi, opts:
79
81
  if (!opts.teamId) throw new Error('--team-id is required');
80
82
  return pollWorkflowApprovals(api, { teamId: opts.teamId, limit: opts.limit });
81
83
  }
84
+
85
+ export async function handleWorkflowsCleanupQueues(api: OpenClawPluginApi, opts: {
86
+ teamId: string;
87
+ }) {
88
+ if (!opts.teamId) throw new Error('--team-id is required');
89
+ const teamDir = resolveTeamDir(api, opts.teamId);
90
+ return cleanupQueues(teamDir);
91
+ }
@@ -1,6 +1,5 @@
1
1
  import { MediaDriver, MediaDriverInvokeOpts, MediaDriverResult } from './types';
2
2
  import { findSkillDir, findVenvPython, runScript, parseMediaOutput, findScriptInSkill, loadConfigEnv } from './utils';
3
- import { loadConfigEnv } from './utils';
4
3
 
5
4
  export class GenericDriver implements MediaDriver {
6
5
  slug: string;
@@ -1,7 +1,6 @@
1
1
  import * as path from 'path';
2
2
  import { MediaDriver, MediaDriverInvokeOpts, MediaDriverResult } from './types';
3
3
  import { findSkillDir, findVenvPython, runScript, loadConfigEnv } from './utils';
4
- import { loadConfigEnv } from './utils';
5
4
 
6
5
  export class NanoBananaPro implements MediaDriver {
7
6
  slug = 'nano-banana-pro';
@@ -1,7 +1,6 @@
1
1
  import * as path from 'path';
2
2
  import { MediaDriver, MediaDriverInvokeOpts, MediaDriverResult } from './types';
3
3
  import { findSkillDir, findVenvPython, runScript, parseMediaOutput, loadConfigEnv } from './utils';
4
- import { loadConfigEnv } from './utils';
5
4
 
6
5
  export class OpenAIImageGen implements MediaDriver {
7
6
  slug = 'openai-image-gen';
@@ -84,10 +84,58 @@ export async function enqueueTask(teamDir: string, agentId: string, task: Omit<Q
84
84
  ...task,
85
85
  };
86
86
  const p = queuePathFor(teamDir, agentId);
87
+
88
+ // If the cursor is at or beyond the current file size, the file was
89
+ // truncated/rotated since the cursor was written. Reset to 0 so the
90
+ // worker will see this new task after it is appended.
91
+ const st = await loadState(teamDir, agentId);
92
+ let fileSize = 0;
93
+ try {
94
+ fileSize = (await fs.stat(p)).size;
95
+ } catch { /* file doesn't exist yet — fileSize stays 0 */ }
96
+ if (st.offsetBytes > 0 && st.offsetBytes >= fileSize) {
97
+ await writeState(teamDir, agentId, { offsetBytes: 0, updatedAt: new Date().toISOString() });
98
+ }
99
+
87
100
  await fs.appendFile(p, JSON.stringify(entry) + '\n', 'utf8');
88
101
  return { ok: true as const, path: p, task: entry };
89
102
  }
90
103
 
104
+ /**
105
+ * Check whether a pending task matching {runId, nodeId} already exists in
106
+ * the queue past the current cursor. Used to avoid enqueueing duplicates
107
+ * when a re-queue would otherwise race with an in-flight tick.
108
+ */
109
+ export async function hasPendingTaskFor(
110
+ teamDir: string,
111
+ agentId: string,
112
+ match: { runId: string; nodeId: string }
113
+ ): Promise<boolean> {
114
+ const qPath = queuePathFor(teamDir, agentId);
115
+ if (!(await fileExists(qPath))) return false;
116
+
117
+ const st = await loadState(teamDir, agentId);
118
+ let raw: string;
119
+ try {
120
+ raw = await fs.readFile(qPath, 'utf8');
121
+ } catch { // intentional: best-effort read
122
+ return false;
123
+ }
124
+
125
+ // Only consider lines at/after the cursor.
126
+ const tail = raw.slice(st.offsetBytes);
127
+ for (const line of tail.split('\n')) {
128
+ if (!line.trim()) continue;
129
+ try {
130
+ const t = JSON.parse(line) as QueueTask;
131
+ if (t && t.runId === match.runId && t.nodeId === match.nodeId) return true;
132
+ } catch { // intentional: skip malformed queue line
133
+ continue;
134
+ }
135
+ }
136
+ return false;
137
+ }
138
+
91
139
  type QueueState = {
92
140
  offsetBytes: number;
93
141
  updatedAt: string;
@@ -123,10 +171,16 @@ export async function readNextTasks(teamDir: string, agentId: string, opts?: { l
123
171
  return { ok: true as const, tasks: [] as QueueTask[], consumed: 0, message: 'Queue file not present.' };
124
172
  }
125
173
 
126
- const st = await loadState(teamDir, agentId);
174
+ let st = await loadState(teamDir, agentId);
127
175
  const fh = await fs.open(qPath, 'r');
128
176
  try {
129
177
  const stat = await fh.stat();
178
+ // If the queue file was truncated/rotated, the cursor may point past EOF.
179
+ // Reset to 0 so we re-scan from the beginning instead of silently skipping tasks.
180
+ if (st.offsetBytes > stat.size) {
181
+ st = { offsetBytes: 0, updatedAt: new Date().toISOString() };
182
+ await writeState(teamDir, agentId, st);
183
+ }
130
184
  if (st.offsetBytes >= stat.size) {
131
185
  return { ok: true as const, tasks: [] as QueueTask[], consumed: 0, message: 'No new tasks.' };
132
186
  }
@@ -173,7 +227,7 @@ export async function dequeueNextTask(
173
227
  return { ok: true as const, task: null as DequeuedTask | null, message: 'Queue file not present.' };
174
228
  }
175
229
 
176
- const st = await loadState(teamDir, agentId);
230
+ let st = await loadState(teamDir, agentId);
177
231
  const workerId = String(opts?.workerId ?? `worker:${process.pid}`);
178
232
  const leaseSeconds = typeof opts?.leaseSeconds === 'number' ? opts.leaseSeconds : undefined;
179
233
 
@@ -219,6 +273,12 @@ export async function dequeueNextTask(
219
273
  const fh = await fs.open(qPath, 'r');
220
274
  try {
221
275
  const stat = await fh.stat();
276
+ // If the queue file was truncated/rotated, the cursor may point past EOF.
277
+ // Reset to 0 so we re-scan from the beginning instead of silently skipping tasks.
278
+ if (st.offsetBytes > stat.size) {
279
+ st = { offsetBytes: 0, updatedAt: new Date().toISOString() };
280
+ await writeState(teamDir, agentId, st);
281
+ }
222
282
  if (st.offsetBytes < stat.size) {
223
283
  const toRead = Math.min(stat.size - st.offsetBytes, 256 * 1024);
224
284
  const buf = Buffer.alloc(toRead);
@@ -336,3 +396,77 @@ export async function compactQueue(teamDir: string, agentId: string, opts?: { mi
336
396
 
337
397
  return { ok: true as const, compacted: true, removedBytes: st.offsetBytes, remainingBytes: remaining.length };
338
398
  }
399
+
400
+ const TERMINAL_STATUSES = new Set(['completed', 'error', 'canceled', 'done', 'failed']);
401
+
402
+ /**
403
+ * Remove queue tasks whose runs no longer exist or are in a terminal state.
404
+ * Returns summary of what was cleaned.
405
+ */
406
+ export async function cleanupQueues(teamDir: string): Promise<{
407
+ ok: true;
408
+ queuesProcessed: number;
409
+ tasksRemoved: number;
410
+ tasksKept: number;
411
+ }> {
412
+ const qDir = queueDir(teamDir);
413
+ const runsDir = path.join(teamDir, 'shared-context', 'workflow-runs');
414
+
415
+ let files: string[];
416
+ try {
417
+ files = (await fs.readdir(qDir)).filter((f) => f.endsWith('.jsonl'));
418
+ } catch {
419
+ return { ok: true, queuesProcessed: 0, tasksRemoved: 0, tasksKept: 0 };
420
+ }
421
+
422
+ let totalRemoved = 0;
423
+ let totalKept = 0;
424
+
425
+ for (const file of files) {
426
+ const qPath = path.join(qDir, file);
427
+ let raw: string;
428
+ try {
429
+ raw = await fs.readFile(qPath, 'utf8');
430
+ } catch { continue; }
431
+
432
+ const lines = raw.split('\n').filter((l) => l.trim());
433
+ if (!lines.length) continue;
434
+
435
+ const kept: string[] = [];
436
+ for (const line of lines) {
437
+ try {
438
+ const task = JSON.parse(line) as QueueTask;
439
+ const runPath = path.join(runsDir, task.runId, 'run.json');
440
+
441
+ let remove = false;
442
+ try {
443
+ const runRaw = await fs.readFile(runPath, 'utf8');
444
+ const run = JSON.parse(runRaw) as { status?: string };
445
+ if (TERMINAL_STATUSES.has(run.status ?? '')) remove = true;
446
+ } catch {
447
+ // Run file doesn't exist — orphaned task
448
+ remove = true;
449
+ }
450
+
451
+ if (remove) {
452
+ totalRemoved++;
453
+ } else {
454
+ kept.push(line);
455
+ totalKept++;
456
+ }
457
+ } catch {
458
+ // Malformed line — discard
459
+ totalRemoved++;
460
+ }
461
+ }
462
+
463
+ if (kept.length !== lines.length) {
464
+ await fs.writeFile(qPath, kept.length ? kept.join('\n') + '\n' : '', 'utf8');
465
+ // Reset cursor state since we rewrote the file
466
+ const agentId = file.replace(/\.jsonl$/, '');
467
+ await writeState(teamDir, agentId, { offsetBytes: 0, updatedAt: new Date().toISOString() });
468
+ }
469
+ }
470
+
471
+ return { ok: true, queuesProcessed: files.length, tasksRemoved: totalRemoved, tasksKept: totalKept };
472
+ }
@@ -340,6 +340,10 @@ export async function loadRunFile(teamDir: string, runsDir: string, runId: strin
340
340
  export async function writeRunFile(runPath: string, fn: (cur: RunLog) => RunLog) {
341
341
  const raw = await readTextFile(runPath);
342
342
  const cur = JSON.parse(raw) as RunLog;
343
- const next = fn(cur);
343
+ const next0 = fn(cur);
344
+ const next = {
345
+ ...next0,
346
+ updatedAt: new Date().toISOString(),
347
+ };
344
348
  await fs.writeFile(runPath, JSON.stringify(next, null, 2), 'utf8');
345
349
  }
@@ -9,7 +9,7 @@ import { resolveTeamDir } from '../workspace';
9
9
  import { getDriver } from './media-drivers/registry';
10
10
  import { GenericDriver } from './media-drivers/generic.driver';
11
11
  import type { WorkflowLane, WorkflowNode, RunLog } from './workflow-types';
12
- import { dequeueNextTask, enqueueTask, releaseTaskClaim, compactQueue } from './workflow-queue';
12
+ import { dequeueNextTask, enqueueTask, hasPendingTaskFor, releaseTaskClaim, compactQueue } from './workflow-queue';
13
13
  import { loadPriorLlmInput, loadProposedPostTextFromPriorNode } from './workflow-node-output-readers';
14
14
  import { readTextFile } from './workflow-runner-io';
15
15
  import { resolveApprovalBindingTarget } from './workflow-node-executor';
@@ -552,7 +552,17 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
552
552
  return Math.max(MIN_NODE_LOCK_TTL_MS, timeoutMs + LOCK_TTL_BUFFER_MS);
553
553
  };
554
554
 
555
- for (let i = 0; i < limit; i++) {
555
+ // We want to process up to `limit` ACTUAL executions per tick. Stale tasks
556
+ // (runs that are already past the dequeued node — e.g. after a terminal
557
+ // run leaves dead entries in a shared agent queue) don't do any real work,
558
+ // so they shouldn't consume the execution budget; otherwise a backlog of
559
+ // stale tasks can starve in-flight runs for several ticks.
560
+ //
561
+ // Cap the total number of dequeue attempts to keep a pathological queue
562
+ // from turning a single tick into an unbounded scan.
563
+ let executedCount = 0;
564
+ const maxDequeues = Math.max(limit * 4, limit + 20);
565
+ for (let totalDequeues = 0; executedCount < limit && totalDequeues < maxDequeues; totalDequeues++) {
556
566
  const dq = await dequeueNextTask(teamDir, agentId, { workerId, leaseSeconds: 120 });
557
567
  if (!dq.ok || !dq.task) break;
558
568
 
@@ -562,6 +572,21 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
562
572
  const lockDir = path.join(runDir, 'locks');
563
573
  const lockPath = path.join(lockDir, `${task.nodeId}.lock`);
564
574
  let lockHeld = false;
575
+ // Tracks whether this dequeue should count against the execution budget.
576
+ // Defaults to true; the stale-skip path below flips it to false so stale
577
+ // tasks (queue entries for nodes that already advanced past success)
578
+ // don't starve real work from the `limit` budget.
579
+ //
580
+ // NOTE: `skipped_locked` does NOT flip this flag. Lock contention means
581
+ // real work is pending (just held up by another worker or a ghost lock),
582
+ // and the skipped_locked branch re-enqueues the task to avoid losing it.
583
+ // Letting lock contention escape the budget here would amplify that
584
+ // re-enqueue within a single tick: each dequeue past the cursor would
585
+ // drop a new copy at the tail (hasPendingTaskFor can't see it yet), and
586
+ // the loop would fabricate up to `maxDequeues` duplicates for one stuck
587
+ // lock. Bounding lock contention by the normal limit keeps the queue
588
+ // growth to O(limit) per tick.
589
+ let countedTowardLimit = true;
565
590
 
566
591
  try {
567
592
  if (task.kind !== 'execute_node') continue;
@@ -622,17 +647,29 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
622
647
  await fs.writeFile(lockPath, JSON.stringify(lockInfo, null, 2), { encoding: 'utf8', flag: 'wx' });
623
648
  lockHeld = true;
624
649
  } catch { // intentional: lock contention, skip task
650
+ // Counts against the budget — see note on `countedTowardLimit`.
625
651
  results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_locked' });
626
652
  continue;
627
653
  }
628
654
  } else {
629
- // Requeue to avoid task loss since dequeueNextTask already advanced the queue cursor.
630
- await enqueueTask(teamDir, agentId, {
631
- teamId,
655
+ // The lock is still live and held by another worker. The cursor has
656
+ // already advanced past this task, so under naive logic we'd lose it.
657
+ // Re-enqueue — but ONLY if no equivalent task is already pending.
658
+ // Otherwise repeated ticks against a stuck lock would pile up dozens
659
+ // of duplicates for the same {runId, nodeId}.
660
+ const alreadyPending = await hasPendingTaskFor(teamDir, agentId, {
632
661
  runId: task.runId,
633
662
  nodeId: task.nodeId,
634
- kind: 'execute_node',
635
663
  });
664
+ if (!alreadyPending) {
665
+ await enqueueTask(teamDir, agentId, {
666
+ teamId,
667
+ runId: task.runId,
668
+ nodeId: task.nodeId,
669
+ kind: 'execute_node',
670
+ });
671
+ }
672
+ // Counts against the budget — see note on `countedTowardLimit`.
636
673
  results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_locked' });
637
674
  continue;
638
675
  }
@@ -678,6 +715,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
678
715
  currentlyRunnableIdx === null ||
679
716
  String(workflow.nodes[currentlyRunnableIdx]?.id ?? '') !== String(node.id)
680
717
  ) {
718
+ countedTowardLimit = false;
681
719
  results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_stale' });
682
720
  continue;
683
721
  }
@@ -739,77 +777,8 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
739
777
  await ensureDir(path.dirname(nodeOutputAbs));
740
778
 
741
779
  const promptRaw = promptTemplateInline ? promptTemplateInline : await readTextFile(promptPathAbs);
742
-
743
- // Build template variables (same as fs.write/fs.append)
744
- const vars = {
745
- date: new Date().toISOString(),
746
- 'run.id': runId,
747
- 'run.timestamp': runId,
748
- 'workflow.id': String(workflow.id ?? ''),
749
- 'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
750
- };
751
-
752
- // Load node outputs and make them available as template variables
753
- const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
754
780
 
755
- // Expose triggerInput as template variables (for handoff-injected data)
756
- if (runSnap.triggerInput && typeof runSnap.triggerInput === 'object') {
757
- for (const [key, value] of Object.entries(runSnap.triggerInput)) {
758
- if (typeof value === 'string') {
759
- vars[`trigger.${key}`] = value;
760
- } else if (value !== null && value !== undefined) {
761
- vars[`trigger.${key}`] = JSON.stringify(value);
762
- }
763
- }
764
- }
765
- for (const nr of (runSnap.nodeResults ?? [])) {
766
- const nid = String((nr as Record<string, unknown>).nodeId ?? '');
767
- const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
768
- if (nid && nrOutPath) {
769
- try {
770
- const outAbs = path.resolve(teamDir, nrOutPath);
771
- const outputContent = await fs.readFile(outAbs, 'utf8');
772
- vars[`${nid}.output`] = outputContent;
773
-
774
- // Parse JSON outputs and make fields accessible
775
- try {
776
- const parsed = JSON.parse(outputContent.trim());
777
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
778
- for (const [key, value] of Object.entries(parsed)) {
779
- if (typeof value === 'string') {
780
- vars[`${nid}.${key}`] = value;
781
-
782
- // Special handling for 'text' field - try to parse as nested JSON
783
- if (key === 'text') {
784
- try {
785
- const nestedParsed = JSON.parse(value);
786
- if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
787
- for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
788
- if (typeof nestedValue === 'string') {
789
- vars[`${nid}.${nestedKey}`] = nestedValue;
790
- } else if (nestedValue !== null && nestedValue !== undefined) {
791
- vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
792
- }
793
- }
794
- }
795
- } catch {
796
- // If nested parsing fails, just keep the text field as is
797
- }
798
- }
799
- } else if (value !== null && value !== undefined) {
800
- // For non-string values, provide JSON representation
801
- vars[`${nid}.${key}_json`] = JSON.stringify(value);
802
- }
803
- }
804
- }
805
- } catch {
806
- // If output isn't valid JSON, skip parsing but keep raw output
807
- }
808
- } catch { /* node output may not exist */ }
809
- }
810
- }
811
-
812
- // Apply template variable replacement
781
+ const vars = await buildTemplateVars(teamDir, runsDir, runId, workflowFile, workflow);
813
782
  const prompt = templateReplace(promptRaw, vars);
814
783
 
815
784
  // Build output format instructions from outputFields when defined
@@ -1071,7 +1040,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1071
1040
  `\nReply with:`,
1072
1041
  `- approve ${code}`,
1073
1042
  `- decline ${code} <what to change>`,
1074
- `\n(You can also review in Kitchen: http://localhost:7777/teams/${teamId}/workflows/${workflow.id ?? ''})`,
1043
+ `\n(You can also review in Kitchen: ${process.env['CK_BASE_URL'] || 'http://localhost:7777'}/teams/${teamId}/workflows/${workflow.id ?? ''})`,
1075
1044
  ].join('\n');
1076
1045
 
1077
1046
  await toolsInvoke<ToolTextResult>(api, {
@@ -1114,61 +1083,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1114
1083
  if (!relPathRaw) throw new Error('fs.append requires args.path');
1115
1084
  if (!contentRaw) throw new Error('fs.append requires args.content');
1116
1085
 
1117
- const vars = {
1118
- date: new Date().toISOString(),
1119
- 'run.id': runId,
1120
- 'workflow.id': String(workflow.id ?? ''),
1121
- 'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
1122
- };
1123
-
1124
- // Load node outputs (same as fs.write)
1125
- const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
1126
- for (const nr of (runSnap.nodeResults ?? [])) {
1127
- const nid = String((nr as Record<string, unknown>).nodeId ?? '');
1128
- const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
1129
- if (nid && nrOutPath) {
1130
- try {
1131
- const outAbs = path.resolve(teamDir, nrOutPath);
1132
- const outputContent = await fs.readFile(outAbs, 'utf8');
1133
- vars[`${nid}.output`] = outputContent;
1134
-
1135
- // Parse JSON outputs and make fields accessible
1136
- try {
1137
- const parsed = JSON.parse(outputContent.trim());
1138
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1139
- for (const [key, value] of Object.entries(parsed)) {
1140
- if (typeof value === 'string') {
1141
- vars[`${nid}.${key}`] = value;
1142
-
1143
- // Special handling for 'text' field - try to parse as nested JSON
1144
- if (key === 'text') {
1145
- try {
1146
- const nestedParsed = JSON.parse(value);
1147
- if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
1148
- for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
1149
- if (typeof nestedValue === 'string') {
1150
- vars[`${nid}.${nestedKey}`] = nestedValue;
1151
- } else if (nestedValue !== null && nestedValue !== undefined) {
1152
- vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
1153
- }
1154
- }
1155
- }
1156
- } catch {
1157
- // If nested parsing fails, just keep the text field as is
1158
- }
1159
- }
1160
- } else if (value !== null && value !== undefined) {
1161
- // For non-string values, provide JSON representation
1162
- vars[`${nid}.${key}_json`] = JSON.stringify(value);
1163
- }
1164
- }
1165
- }
1166
- } catch {
1167
- // If output isn't valid JSON, skip parsing but keep raw output
1168
- }
1169
- } catch { /* node output may not exist */ }
1170
- }
1171
- }
1086
+ const vars = await buildTemplateVars(teamDir, runsDir, runId, workflowFile, workflow);
1172
1087
 
1173
1088
  const relPath = templateReplace(relPathRaw, vars);
1174
1089
  const content = templateReplace(contentRaw, vars);
@@ -1184,67 +1099,12 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1184
1099
  const result = { appendedTo: path.relative(teamDir, abs), bytes: Buffer.byteLength(content, 'utf8') };
1185
1100
  await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2) + '\n', 'utf8');
1186
1101
 
1187
-
1188
1102
  } else if (toolName === 'fs.write') {
1189
1103
  const relPathRaw = String(toolArgs.path ?? '').trim();
1190
1104
  const contentRaw = String(toolArgs.content ?? '');
1191
1105
  if (!relPathRaw) throw new Error('fs.write requires args.path');
1192
1106
 
1193
- const vars = {
1194
- date: new Date().toISOString(),
1195
- 'run.id': runId,
1196
- 'run.timestamp': runId,
1197
- 'workflow.id': String(workflow.id ?? ''),
1198
- 'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
1199
- };
1200
- // Also inject node outputs so templates like {{brand_review.output}} resolve
1201
- const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
1202
- for (const nr of (runSnap.nodeResults ?? [])) {
1203
- const nid = String((nr as Record<string, unknown>).nodeId ?? '');
1204
- const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
1205
- if (nid && nrOutPath) {
1206
- try {
1207
- const outAbs = path.resolve(teamDir, nrOutPath);
1208
- const outputContent = await fs.readFile(outAbs, 'utf8');
1209
- vars[`${nid}.output`] = outputContent;
1210
-
1211
- // Parse JSON outputs and make fields accessible
1212
- try {
1213
- const parsed = JSON.parse(outputContent.trim());
1214
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1215
- for (const [key, value] of Object.entries(parsed)) {
1216
- if (typeof value === 'string') {
1217
- vars[`${nid}.${key}`] = value;
1218
-
1219
- // Special handling for 'text' field - try to parse as nested JSON
1220
- if (key === 'text') {
1221
- try {
1222
- const nestedParsed = JSON.parse(value);
1223
- if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
1224
- for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
1225
- if (typeof nestedValue === 'string') {
1226
- vars[`${nid}.${nestedKey}`] = nestedValue;
1227
- } else if (nestedValue !== null && nestedValue !== undefined) {
1228
- vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
1229
- }
1230
- }
1231
- }
1232
- } catch {
1233
- // If nested parsing fails, just keep the text field as is
1234
- }
1235
- }
1236
- } else if (value !== null && value !== undefined) {
1237
- // For non-string values, provide JSON representation
1238
- vars[`${nid}.${key}_json`] = JSON.stringify(value);
1239
- }
1240
- }
1241
- }
1242
- } catch {
1243
- // If output isn't valid JSON, skip parsing but keep raw output
1244
- }
1245
- } catch { /* node output may not exist */ }
1246
- }
1247
- }
1107
+ const vars = await buildTemplateVars(teamDir, runsDir, runId, workflowFile, workflow);
1248
1108
  const relPath = templateReplace(relPathRaw, vars);
1249
1109
  const content = templateReplace(contentRaw, vars);
1250
1110
 
@@ -1260,75 +1120,8 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1260
1120
  await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2) + '\n', 'utf8');
1261
1121
 
1262
1122
  } else {
1263
- // Build template variables for general tool nodes (same as fs.write/fs.append)
1264
- const vars = {
1265
- date: new Date().toISOString(),
1266
- 'run.id': runId,
1267
- 'run.timestamp': runId,
1268
- 'workflow.id': String(workflow.id ?? ''),
1269
- 'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
1270
- };
1123
+ const vars = await buildTemplateVars(teamDir, runsDir, runId, workflowFile, workflow);
1271
1124
 
1272
- // Load node outputs and make them available as template variables
1273
- const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
1274
-
1275
- // Expose triggerInput as template variables (for handoff-injected data)
1276
- if (runSnap.triggerInput && typeof runSnap.triggerInput === 'object') {
1277
- for (const [key, value] of Object.entries(runSnap.triggerInput)) {
1278
- if (typeof value === 'string') {
1279
- vars[`trigger.${key}`] = value;
1280
- } else if (value !== null && value !== undefined) {
1281
- vars[`trigger.${key}`] = JSON.stringify(value);
1282
- }
1283
- }
1284
- }
1285
- for (const nr of (runSnap.nodeResults ?? [])) {
1286
- const nid = String((nr as Record<string, unknown>).nodeId ?? '');
1287
- const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
1288
- if (nid && nrOutPath) {
1289
- try {
1290
- const outAbs = path.resolve(teamDir, nrOutPath);
1291
- const outputContent = await fs.readFile(outAbs, 'utf8');
1292
- vars[`${nid}.output`] = outputContent;
1293
-
1294
- // Parse JSON outputs and make fields accessible
1295
- try {
1296
- const parsed = JSON.parse(outputContent.trim());
1297
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1298
- for (const [key, value] of Object.entries(parsed)) {
1299
- if (typeof value === 'string') {
1300
- vars[`${nid}.${key}`] = value;
1301
-
1302
- // Special handling for 'text' field - try to parse as nested JSON
1303
- if (key === 'text') {
1304
- try {
1305
- const nestedParsed = JSON.parse(value);
1306
- if (nestedParsed && typeof nestedParsed === 'object' && !Array.isArray(nestedParsed)) {
1307
- for (const [nestedKey, nestedValue] of Object.entries(nestedParsed)) {
1308
- if (typeof nestedValue === 'string') {
1309
- vars[`${nid}.${nestedKey}`] = nestedValue;
1310
- } else if (nestedValue !== null && nestedValue !== undefined) {
1311
- vars[`${nid}.${nestedKey}_json`] = JSON.stringify(nestedValue);
1312
- }
1313
- }
1314
- }
1315
- } catch {
1316
- // If nested parsing fails, just keep the text field as is
1317
- }
1318
- }
1319
- } else if (value !== null && value !== undefined) {
1320
- // For non-string values, provide JSON representation
1321
- vars[`${nid}.${key}_json`] = JSON.stringify(value);
1322
- }
1323
- }
1324
- }
1325
- } catch {
1326
- // If output isn't valid JSON, skip parsing but keep raw output
1327
- }
1328
- } catch { /* node output may not exist */ }
1329
- }
1330
- }
1331
-
1332
1125
  // Apply template variable replacement to all string values in toolArgs
1333
1126
  const processedToolArgs: Record<string, unknown> = {};
1334
1127
  for (const [key, value] of Object.entries(toolArgs)) {
@@ -1357,13 +1150,20 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1357
1150
  // to most workflow worker sessions.
1358
1151
  const command = String((processedToolArgs as Record<string, unknown>).command ?? '');
1359
1152
  const workdir = String((processedToolArgs as Record<string, unknown>).workdir ?? teamDir);
1360
- const timeoutSec = Number((processedToolArgs as Record<string, unknown>).timeout) || 120;
1153
+ // Timeout priority: args.timeout (seconds) > config.timeoutMs (ms) > 120s default
1154
+ const nodeConfig = asRecord((node as unknown as Record<string, unknown>)['config']);
1155
+ const argsTimeoutSec = Number((processedToolArgs as Record<string, unknown>).timeout) || 0;
1156
+ const configTimeoutMs = Number(nodeConfig['timeoutMs']) || 0;
1157
+ const timeoutSec = argsTimeoutSec || (configTimeoutMs > 0 ? Math.ceil(configTimeoutMs / 1000) : 120);
1361
1158
  const result = await api.runtime.system.runCommandWithTimeout(
1362
1159
  ['bash', '-c', command],
1363
1160
  { timeoutMs: timeoutSec * 1000, cwd: workdir },
1364
1161
  );
1365
1162
  if (result.code !== 0) {
1366
- throw new Error(`exec failed (code=${result.code}):\n${result.stderr || result.stdout}`);
1163
+ const stderr = String(result.stderr ?? '').trim();
1164
+ const stdout = String(result.stdout ?? '').trim();
1165
+ const combined = [stderr, stdout].filter(Boolean).join('\n---stdout---\n');
1166
+ throw new Error(`exec failed (code=${result.code}):\n${combined}`);
1367
1167
  }
1368
1168
  toolRes = { stdout: result.stdout, stderr: result.stderr, code: result.code };
1369
1169
  } else {
@@ -1887,6 +1687,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1887
1687
  } catch { // intentional: best-effort claim release
1888
1688
  // ignore
1889
1689
  }
1690
+ if (countedTowardLimit) executedCount++;
1890
1691
  }
1891
1692
 
1892
1693
  }