@jiggai/recipes 0.4.50 → 0.4.52
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 +25 -0
- package/docs/BUNDLED_RECIPES.md +5 -0
- package/docs/WORKFLOW_EXAMPLES.md +11 -2
- package/docs/WORKFLOW_EXAMPLES_BUNDLED.md +313 -0
- package/index.ts +12 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/src/handlers/workflows.ts +10 -0
- package/src/lib/workflows/kitchen-review-url.ts +49 -0
- package/src/lib/workflows/workflow-queue.ts +136 -2
- package/src/lib/workflows/workflow-utils.ts +5 -1
- package/src/lib/workflows/workflow-worker.ts +61 -258
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
|
---
|
package/docs/BUNDLED_RECIPES.md
CHANGED
|
@@ -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
|
-
|
|
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)")
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jiggai/recipes",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.52",
|
|
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": "
|
|
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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
2
|
+
|
|
3
|
+
function asRecord(v: unknown): Record<string, unknown> | null {
|
|
4
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function asString(v: unknown): string {
|
|
8
|
+
return typeof v === 'string' ? v : (v == null ? '' : String(v));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function asPort(v: unknown): number | null {
|
|
12
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
|
|
13
|
+
if (typeof v === 'string' && v.trim()) {
|
|
14
|
+
const parsed = Number(v.trim());
|
|
15
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function trimTrailingSlash(url: string): string {
|
|
21
|
+
return url.replace(/\/+$/, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildBaseUrl(host: string, port: number | null): string {
|
|
25
|
+
const trimmedHost = host.trim();
|
|
26
|
+
if (!trimmedHost) return 'http://localhost:7777';
|
|
27
|
+
if (/^https?:\/\//i.test(trimmedHost)) return trimTrailingSlash(trimmedHost);
|
|
28
|
+
const safeHost = trimmedHost.includes(':') && !trimmedHost.startsWith('[') ? `[${trimmedHost}]` : trimmedHost;
|
|
29
|
+
return trimTrailingSlash(`http://${safeHost}${port ? `:${port}` : ''}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getKitchenBaseUrl(api: OpenClawPluginApi): string {
|
|
33
|
+
const config = asRecord((api as unknown as { config?: unknown }).config) ?? {};
|
|
34
|
+
const envVars = asRecord(asRecord(config.env)?.vars);
|
|
35
|
+
const envBaseUrl = asString(envVars?.CK_BASE_URL).trim();
|
|
36
|
+
if (envBaseUrl) return trimTrailingSlash(envBaseUrl);
|
|
37
|
+
|
|
38
|
+
const kitchenConfig = asRecord(asRecord(asRecord(config.plugins)?.entries)?.kitchen)?.config;
|
|
39
|
+
const host = asString(kitchenConfig?.host).trim();
|
|
40
|
+
const port = asPort(kitchenConfig?.port);
|
|
41
|
+
if (host) return buildBaseUrl(host, port);
|
|
42
|
+
|
|
43
|
+
return 'http://localhost:7777';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function buildKitchenWorkflowReviewUrl(api: OpenClawPluginApi, teamId: string, workflowId: string): string {
|
|
47
|
+
const baseUrl = getKitchenBaseUrl(api);
|
|
48
|
+
return `${baseUrl}/teams/${encodeURIComponent(teamId)}/workflows/${encodeURIComponent(workflowId)}`;
|
|
49
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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,10 +9,11 @@ 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';
|
|
16
|
+
import { buildKitchenWorkflowReviewUrl } from './kitchen-review-url';
|
|
16
17
|
import {
|
|
17
18
|
asRecord, asString, isRecord,
|
|
18
19
|
normalizeWorkflow,
|
|
@@ -552,7 +553,17 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
552
553
|
return Math.max(MIN_NODE_LOCK_TTL_MS, timeoutMs + LOCK_TTL_BUFFER_MS);
|
|
553
554
|
};
|
|
554
555
|
|
|
555
|
-
|
|
556
|
+
// We want to process up to `limit` ACTUAL executions per tick. Stale tasks
|
|
557
|
+
// (runs that are already past the dequeued node — e.g. after a terminal
|
|
558
|
+
// run leaves dead entries in a shared agent queue) don't do any real work,
|
|
559
|
+
// so they shouldn't consume the execution budget; otherwise a backlog of
|
|
560
|
+
// stale tasks can starve in-flight runs for several ticks.
|
|
561
|
+
//
|
|
562
|
+
// Cap the total number of dequeue attempts to keep a pathological queue
|
|
563
|
+
// from turning a single tick into an unbounded scan.
|
|
564
|
+
let executedCount = 0;
|
|
565
|
+
const maxDequeues = Math.max(limit * 4, limit + 20);
|
|
566
|
+
for (let totalDequeues = 0; executedCount < limit && totalDequeues < maxDequeues; totalDequeues++) {
|
|
556
567
|
const dq = await dequeueNextTask(teamDir, agentId, { workerId, leaseSeconds: 120 });
|
|
557
568
|
if (!dq.ok || !dq.task) break;
|
|
558
569
|
|
|
@@ -562,6 +573,21 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
562
573
|
const lockDir = path.join(runDir, 'locks');
|
|
563
574
|
const lockPath = path.join(lockDir, `${task.nodeId}.lock`);
|
|
564
575
|
let lockHeld = false;
|
|
576
|
+
// Tracks whether this dequeue should count against the execution budget.
|
|
577
|
+
// Defaults to true; the stale-skip path below flips it to false so stale
|
|
578
|
+
// tasks (queue entries for nodes that already advanced past success)
|
|
579
|
+
// don't starve real work from the `limit` budget.
|
|
580
|
+
//
|
|
581
|
+
// NOTE: `skipped_locked` does NOT flip this flag. Lock contention means
|
|
582
|
+
// real work is pending (just held up by another worker or a ghost lock),
|
|
583
|
+
// and the skipped_locked branch re-enqueues the task to avoid losing it.
|
|
584
|
+
// Letting lock contention escape the budget here would amplify that
|
|
585
|
+
// re-enqueue within a single tick: each dequeue past the cursor would
|
|
586
|
+
// drop a new copy at the tail (hasPendingTaskFor can't see it yet), and
|
|
587
|
+
// the loop would fabricate up to `maxDequeues` duplicates for one stuck
|
|
588
|
+
// lock. Bounding lock contention by the normal limit keeps the queue
|
|
589
|
+
// growth to O(limit) per tick.
|
|
590
|
+
let countedTowardLimit = true;
|
|
565
591
|
|
|
566
592
|
try {
|
|
567
593
|
if (task.kind !== 'execute_node') continue;
|
|
@@ -622,17 +648,29 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
622
648
|
await fs.writeFile(lockPath, JSON.stringify(lockInfo, null, 2), { encoding: 'utf8', flag: 'wx' });
|
|
623
649
|
lockHeld = true;
|
|
624
650
|
} catch { // intentional: lock contention, skip task
|
|
651
|
+
// Counts against the budget — see note on `countedTowardLimit`.
|
|
625
652
|
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_locked' });
|
|
626
653
|
continue;
|
|
627
654
|
}
|
|
628
655
|
} else {
|
|
629
|
-
//
|
|
630
|
-
|
|
631
|
-
|
|
656
|
+
// The lock is still live and held by another worker. The cursor has
|
|
657
|
+
// already advanced past this task, so under naive logic we'd lose it.
|
|
658
|
+
// Re-enqueue — but ONLY if no equivalent task is already pending.
|
|
659
|
+
// Otherwise repeated ticks against a stuck lock would pile up dozens
|
|
660
|
+
// of duplicates for the same {runId, nodeId}.
|
|
661
|
+
const alreadyPending = await hasPendingTaskFor(teamDir, agentId, {
|
|
632
662
|
runId: task.runId,
|
|
633
663
|
nodeId: task.nodeId,
|
|
634
|
-
kind: 'execute_node',
|
|
635
664
|
});
|
|
665
|
+
if (!alreadyPending) {
|
|
666
|
+
await enqueueTask(teamDir, agentId, {
|
|
667
|
+
teamId,
|
|
668
|
+
runId: task.runId,
|
|
669
|
+
nodeId: task.nodeId,
|
|
670
|
+
kind: 'execute_node',
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
// Counts against the budget — see note on `countedTowardLimit`.
|
|
636
674
|
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_locked' });
|
|
637
675
|
continue;
|
|
638
676
|
}
|
|
@@ -678,6 +716,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
678
716
|
currentlyRunnableIdx === null ||
|
|
679
717
|
String(workflow.nodes[currentlyRunnableIdx]?.id ?? '') !== String(node.id)
|
|
680
718
|
) {
|
|
719
|
+
countedTowardLimit = false;
|
|
681
720
|
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_stale' });
|
|
682
721
|
continue;
|
|
683
722
|
}
|
|
@@ -739,77 +778,8 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
739
778
|
await ensureDir(path.dirname(nodeOutputAbs));
|
|
740
779
|
|
|
741
780
|
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
781
|
|
|
755
|
-
|
|
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
|
|
782
|
+
const vars = await buildTemplateVars(teamDir, runsDir, runId, workflowFile, workflow);
|
|
813
783
|
const prompt = templateReplace(promptRaw, vars);
|
|
814
784
|
|
|
815
785
|
// Build output format instructions from outputFields when defined
|
|
@@ -1063,6 +1033,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1063
1033
|
}
|
|
1064
1034
|
proposed = sanitizeDraftOnlyText(proposed);
|
|
1065
1035
|
|
|
1036
|
+
const kitchenReviewUrl = buildKitchenWorkflowReviewUrl(api, teamId, String(workflow.id ?? ''));
|
|
1066
1037
|
const msg = [
|
|
1067
1038
|
`Approval requested: ${workflow.name ?? workflow.id ?? workflowFile}`,
|
|
1068
1039
|
`Ticket: ${path.relative(teamDir, curTicketPath)}`,
|
|
@@ -1071,7 +1042,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1071
1042
|
`\nReply with:`,
|
|
1072
1043
|
`- approve ${code}`,
|
|
1073
1044
|
`- decline ${code} <what to change>`,
|
|
1074
|
-
`\n(You can also review in Kitchen:
|
|
1045
|
+
`\n(You can also review in Kitchen: ${kitchenReviewUrl})`,
|
|
1075
1046
|
].join('\n');
|
|
1076
1047
|
|
|
1077
1048
|
await toolsInvoke<ToolTextResult>(api, {
|
|
@@ -1114,61 +1085,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1114
1085
|
if (!relPathRaw) throw new Error('fs.append requires args.path');
|
|
1115
1086
|
if (!contentRaw) throw new Error('fs.append requires args.content');
|
|
1116
1087
|
|
|
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
|
-
}
|
|
1088
|
+
const vars = await buildTemplateVars(teamDir, runsDir, runId, workflowFile, workflow);
|
|
1172
1089
|
|
|
1173
1090
|
const relPath = templateReplace(relPathRaw, vars);
|
|
1174
1091
|
const content = templateReplace(contentRaw, vars);
|
|
@@ -1184,67 +1101,12 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1184
1101
|
const result = { appendedTo: path.relative(teamDir, abs), bytes: Buffer.byteLength(content, 'utf8') };
|
|
1185
1102
|
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2) + '\n', 'utf8');
|
|
1186
1103
|
|
|
1187
|
-
|
|
1188
1104
|
} else if (toolName === 'fs.write') {
|
|
1189
1105
|
const relPathRaw = String(toolArgs.path ?? '').trim();
|
|
1190
1106
|
const contentRaw = String(toolArgs.content ?? '');
|
|
1191
1107
|
if (!relPathRaw) throw new Error('fs.write requires args.path');
|
|
1192
1108
|
|
|
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
|
-
}
|
|
1109
|
+
const vars = await buildTemplateVars(teamDir, runsDir, runId, workflowFile, workflow);
|
|
1248
1110
|
const relPath = templateReplace(relPathRaw, vars);
|
|
1249
1111
|
const content = templateReplace(contentRaw, vars);
|
|
1250
1112
|
|
|
@@ -1260,75 +1122,8 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1260
1122
|
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2) + '\n', 'utf8');
|
|
1261
1123
|
|
|
1262
1124
|
} else {
|
|
1263
|
-
|
|
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
|
-
};
|
|
1125
|
+
const vars = await buildTemplateVars(teamDir, runsDir, runId, workflowFile, workflow);
|
|
1271
1126
|
|
|
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
1127
|
// Apply template variable replacement to all string values in toolArgs
|
|
1333
1128
|
const processedToolArgs: Record<string, unknown> = {};
|
|
1334
1129
|
for (const [key, value] of Object.entries(toolArgs)) {
|
|
@@ -1357,13 +1152,20 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1357
1152
|
// to most workflow worker sessions.
|
|
1358
1153
|
const command = String((processedToolArgs as Record<string, unknown>).command ?? '');
|
|
1359
1154
|
const workdir = String((processedToolArgs as Record<string, unknown>).workdir ?? teamDir);
|
|
1360
|
-
|
|
1155
|
+
// Timeout priority: args.timeout (seconds) > config.timeoutMs (ms) > 120s default
|
|
1156
|
+
const nodeConfig = asRecord((node as unknown as Record<string, unknown>)['config']);
|
|
1157
|
+
const argsTimeoutSec = Number((processedToolArgs as Record<string, unknown>).timeout) || 0;
|
|
1158
|
+
const configTimeoutMs = Number(nodeConfig['timeoutMs']) || 0;
|
|
1159
|
+
const timeoutSec = argsTimeoutSec || (configTimeoutMs > 0 ? Math.ceil(configTimeoutMs / 1000) : 120);
|
|
1361
1160
|
const result = await api.runtime.system.runCommandWithTimeout(
|
|
1362
1161
|
['bash', '-c', command],
|
|
1363
1162
|
{ timeoutMs: timeoutSec * 1000, cwd: workdir },
|
|
1364
1163
|
);
|
|
1365
1164
|
if (result.code !== 0) {
|
|
1366
|
-
|
|
1165
|
+
const stderr = String(result.stderr ?? '').trim();
|
|
1166
|
+
const stdout = String(result.stdout ?? '').trim();
|
|
1167
|
+
const combined = [stderr, stdout].filter(Boolean).join('\n---stdout---\n');
|
|
1168
|
+
throw new Error(`exec failed (code=${result.code}):\n${combined}`);
|
|
1367
1169
|
}
|
|
1368
1170
|
toolRes = { stdout: result.stdout, stderr: result.stderr, code: result.code };
|
|
1369
1171
|
} else {
|
|
@@ -1887,6 +1689,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1887
1689
|
} catch { // intentional: best-effort claim release
|
|
1888
1690
|
// ignore
|
|
1889
1691
|
}
|
|
1692
|
+
if (countedTowardLimit) executedCount++;
|
|
1890
1693
|
}
|
|
1891
1694
|
|
|
1892
1695
|
}
|