@jiggai/recipes 0.4.38 → 0.4.40

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.
@@ -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.38",
5
+ "version": "0.4.40",
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.38",
3
+ "version": "0.4.40",
4
4
  "description": "ClawRecipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
5
5
  "main": "index.ts",
6
6
  "type": "commonjs",
@@ -43,7 +43,8 @@ export class GenericDriver implements MediaDriver {
43
43
  }
44
44
 
45
45
  // Execute the script with stdin input (most common interface)
46
- const scriptOutput = runScript({
46
+ const scriptOutput = await runScript({
47
+ api: opts.api,
47
48
  runner,
48
49
  script: scriptPath,
49
50
  stdin: prompt,
@@ -78,7 +78,8 @@ export class KlingVideo implements MediaDriver {
78
78
  // The official skill is a Node.js script (not Python)
79
79
  const runner = 'node';
80
80
 
81
- const scriptOutput = runScript({
81
+ const scriptOutput = await runScript({
82
+ api: opts.api,
82
83
  runner,
83
84
  script: scriptPath,
84
85
  args: [
@@ -26,7 +26,8 @@ export class LumaVideo implements MediaDriver {
26
26
  const runner = await findVenvPython(skillDir);
27
27
 
28
28
  // Execute the script with stdin input
29
- const scriptOutput = runScript({
29
+ const scriptOutput = await runScript({
30
+ api: opts.api,
30
31
  runner,
31
32
  script: scriptPath,
32
33
  stdin: prompt,
@@ -42,7 +43,7 @@ export class LumaVideo implements MediaDriver {
42
43
 
43
44
  // Parse the MEDIA: output
44
45
  const filePath = parseMediaOutput(scriptOutput);
45
-
46
+
46
47
  if (!filePath) {
47
48
  throw new Error(`No MEDIA: path found in script output. Output: ${scriptOutput}`);
48
49
  }
@@ -39,7 +39,8 @@ export class NanoBananaPro implements MediaDriver {
39
39
  const resolution = maxDim >= 3840 ? '4K' : maxDim >= 1792 ? '2K' : '1K';
40
40
 
41
41
  // Execute the script with argparse CLI interface
42
- const scriptOutput = runScript({
42
+ const scriptOutput = await runScript({
43
+ api: opts.api,
43
44
  runner,
44
45
  script: scriptPath,
45
46
  args: ['--prompt', prompt, '--filename', filename, '--resolution', resolution],
@@ -28,7 +28,8 @@ export class OpenAIImageGen implements MediaDriver {
28
28
  const size = String(config?.size ?? '1024x1024');
29
29
 
30
30
  // Execute the script with stdin input
31
- const scriptOutput = runScript({
31
+ const scriptOutput = await runScript({
32
+ api: opts.api,
32
33
  runner,
33
34
  script: scriptPath,
34
35
  stdin: prompt,
@@ -26,7 +26,8 @@ export class RunwayVideo implements MediaDriver {
26
26
  const runner = await findVenvPython(skillDir);
27
27
 
28
28
  // Execute the script with stdin input
29
- const scriptOutput = runScript({
29
+ const scriptOutput = await runScript({
30
+ api: opts.api,
30
31
  runner,
31
32
  script: scriptPath,
32
33
  stdin: prompt,
@@ -42,7 +43,7 @@ export class RunwayVideo implements MediaDriver {
42
43
 
43
44
  // Parse the MEDIA: output
44
45
  const filePath = parseMediaOutput(scriptOutput);
45
-
46
+
46
47
  if (!filePath) {
47
48
  throw new Error(`No MEDIA: path found in script output. Output: ${scriptOutput}`);
48
49
  }
@@ -1,4 +1,7 @@
1
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
2
+
1
3
  export interface MediaDriverInvokeOpts {
4
+ api: OpenClawPluginApi;
2
5
  prompt: string;
3
6
  outputDir: string;
4
7
  env: Record<string, string>;
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
- import { execFileSync } from 'child_process';
3
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
4
4
 
5
5
  /**
6
6
  * Find a skill directory by searching common skill roots
@@ -33,7 +33,7 @@ export async function findSkillDir(slug: string): Promise<string | null> {
33
33
  */
34
34
  export async function findVenvPython(skillDir: string): Promise<string> {
35
35
  const venvPython = path.join(skillDir, '.venv', 'bin', 'python');
36
-
36
+
37
37
  try {
38
38
  await fs.access(venvPython);
39
39
  return venvPython;
@@ -48,7 +48,7 @@ export async function findVenvPython(skillDir: string): Promise<string> {
48
48
  export async function loadConfigEnv(): Promise<Record<string, string>> {
49
49
  const homedir = process.env.HOME || '/home/control';
50
50
  const configPath = path.join(homedir, '.openclaw', 'openclaw.json');
51
-
51
+
52
52
  try {
53
53
  const cfgRaw = await fs.readFile(configPath, 'utf8');
54
54
  const cfgParsed = JSON.parse(cfgRaw);
@@ -82,9 +82,12 @@ export function parseMediaOutput(stdout: string): string {
82
82
  }
83
83
 
84
84
  /**
85
- * Execute a script with proper error handling and output capture
85
+ * Execute a script via the OpenClaw exec tool so this plugin package does not
86
+ * directly import child_process. We still pass argv as discrete args and feed
87
+ * prompt text via stdin through a small Python wrapper script.
86
88
  */
87
89
  export interface RunScriptOpts {
90
+ api: OpenClawPluginApi;
88
91
  runner: string;
89
92
  script: string;
90
93
  args?: string[];
@@ -94,26 +97,75 @@ export interface RunScriptOpts {
94
97
  timeout: number;
95
98
  }
96
99
 
97
- export function runScript(opts: RunScriptOpts): string {
100
+ function buildPythonExecSnippet(opts: RunScriptOpts): string {
98
101
  const { runner, script, args = [], stdin, env, cwd, timeout } = opts;
102
+ const mergedEnv = {
103
+ ...env,
104
+ MEDIA_OUTPUT_DIR: cwd,
105
+ };
106
+
107
+ const payload = {
108
+ runner,
109
+ script,
110
+ args,
111
+ stdin: stdin ?? '',
112
+ env: mergedEnv,
113
+ cwd,
114
+ timeoutMs: timeout,
115
+ };
116
+
117
+ // Base64-encode the payload to avoid shell injection and heredoc delimiter collisions.
118
+ const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64');
119
+
120
+ return [
121
+ `python3 -c '`,
122
+ `import base64, json, os, subprocess, sys;`,
123
+ `payload = json.loads(base64.b64decode("${payloadB64}").decode());`,
124
+ `env = os.environ.copy();`,
125
+ `env.update({k: str(v) for k, v in payload["env"].items()});`,
126
+ `res = subprocess.run(`,
127
+ ` [payload["runner"], payload["script"], *payload.get("args", [])],`,
128
+ ` input=payload.get("stdin", ""),`,
129
+ ` text=True,`,
130
+ ` capture_output=True,`,
131
+ ` cwd=payload["cwd"],`,
132
+ ` env=env,`,
133
+ ` timeout=max(1, int(payload.get("timeoutMs", 1000) / 1000))`,
134
+ `);`,
135
+ `sys.stdout.write(res.stdout);`,
136
+ `sys.stderr.write(res.stderr);`,
137
+ `raise SystemExit(res.returncode)`,
138
+ `'`,
139
+ ].join('\n');
140
+ }
141
+
142
+ export async function runScript(opts: RunScriptOpts): Promise<string> {
143
+ const { api, timeout } = opts;
144
+ const timeoutMs = Math.max(1000, timeout + 5000);
145
+ const command = buildPythonExecSnippet(opts);
99
146
 
100
147
  try {
101
- return execFileSync(runner, [script, ...args], {
102
- cwd,
103
- timeout,
104
- encoding: 'utf8',
105
- input: stdin,
106
- env: {
107
- ...process.env,
108
- ...env,
109
- MEDIA_OUTPUT_DIR: cwd,
110
- },
111
- }).trim();
148
+ // Use the plugin SDK's runtime exec — available to all plugins without
149
+ // gateway tool permissions (unlike toolsInvoke('exec') which is session-gated).
150
+ const result = await api.runtime.system.runCommandWithTimeout(
151
+ ['bash', '-c', command],
152
+ { timeoutMs, cwd: opts.cwd },
153
+ );
154
+
155
+ if (result.code !== 0) {
156
+ const msg = [
157
+ `Script execution failed with exit code ${result.code}`,
158
+ result.stdout ? `\n--- stdout ---\n${result.stdout.trim()}` : '',
159
+ result.stderr ? `\n--- stderr ---\n${result.stderr.trim()}` : '',
160
+ ].filter(Boolean).join('');
161
+ throw new Error(msg);
162
+ }
163
+
164
+ return (result.stdout || '').trim();
112
165
  } catch (err) {
113
- // Surface stderr/stdout to make debugging skill scripts possible
114
- const e = err as any;
115
- const stdout = typeof e?.stdout === 'string' ? e.stdout : (Buffer.isBuffer(e?.stdout) ? e.stdout.toString('utf8') : '');
116
- const stderr = typeof e?.stderr === 'string' ? e.stderr : (Buffer.isBuffer(e?.stderr) ? e.stderr.toString('utf8') : '');
166
+ const e = err as Error & { stdout?: string; stderr?: string };
167
+ const stdout = e?.stdout ?? '';
168
+ const stderr = e?.stderr ?? '';
117
169
  const msg = [
118
170
  e?.message ? String(e.message) : 'Script execution failed',
119
171
  stdout ? `\n--- stdout ---\n${stdout.trim()}` : '',
@@ -128,7 +180,7 @@ export function runScript(opts: RunScriptOpts): string {
128
180
  */
129
181
  export async function findScriptInSkill(skillDir: string, scriptCandidates: string[]): Promise<string | null> {
130
182
  const searchDirs = [skillDir, path.join(skillDir, 'scripts')];
131
-
183
+
132
184
  for (const dir of searchDirs) {
133
185
  for (const candidate of scriptCandidates) {
134
186
  const scriptPath = path.join(dir, candidate);
@@ -140,6 +192,6 @@ export async function findScriptInSkill(skillDir: string, scriptCandidates: stri
140
192
  }
141
193
  }
142
194
  }
143
-
195
+
144
196
  return null;
145
- }
197
+ }
@@ -0,0 +1,69 @@
1
+ import { ToolsInvokeError } from '../../toolsInvoke.js';
2
+
3
+ export type ErrorCategory = 'funding' | 'rate-limit' | 'auth' | 'timeout' | 'unknown';
4
+
5
+ const FUNDING_PATTERNS = [
6
+ /insufficient.*(credits?|funds?|balance)/i,
7
+ /billing/i,
8
+ /payment\s+required/i,
9
+ /quota\s+exceeded/i,
10
+ /out\s+of\s+credits/i,
11
+ /budget\s+(exceeded|limit)/i,
12
+ /no\s+(active\s+)?subscription/i,
13
+ /plan\s+(limit|exceeded)/i,
14
+ ];
15
+
16
+ const RATE_LIMIT_PATTERNS = [
17
+ /rate\s+limit/i,
18
+ /too\s+many\s+requests/i,
19
+ /throttl/i,
20
+ ];
21
+
22
+ const AUTH_PATTERNS = [
23
+ /unauthorized/i,
24
+ /invalid.*api.?key/i,
25
+ /forbidden/i,
26
+ /authentication\s+failed/i,
27
+ /access\s+denied/i,
28
+ ];
29
+
30
+ function classifyByHttpStatus(status: number): ErrorCategory | null {
31
+ if (status === 402) return 'funding';
32
+ if (status === 429) return 'rate-limit';
33
+ if (status === 401 || status === 403) return 'auth';
34
+ if (status === 408 || status === 504) return 'timeout';
35
+ return null;
36
+ }
37
+
38
+ function classifyByMessage(message: string, error: unknown): ErrorCategory | null {
39
+ if (FUNDING_PATTERNS.some((p) => p.test(message))) return 'funding';
40
+ if (RATE_LIMIT_PATTERNS.some((p) => p.test(message))) return 'rate-limit';
41
+ if (AUTH_PATTERNS.some((p) => p.test(message))) return 'auth';
42
+ if (error instanceof Error && error.name === 'AbortError') return 'timeout';
43
+ if (/timed?\s*out/i.test(message)) return 'timeout';
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Classify an error into a category based on HTTP status and message content.
49
+ * Returns 'unknown' if the error doesn't match any known pattern.
50
+ */
51
+ export function classifyError(error: unknown): ErrorCategory {
52
+ const httpStatus = error instanceof ToolsInvokeError ? error.httpStatus : 0;
53
+ const message = error instanceof Error ? error.message : String(error ?? '');
54
+
55
+ return classifyByHttpStatus(httpStatus) ?? classifyByMessage(message, error) ?? 'unknown';
56
+ }
57
+
58
+ const CATEGORY_LABELS: Record<ErrorCategory, string> = {
59
+ 'funding': 'Funding issue — the model provider may be out of credits or require payment',
60
+ 'rate-limit': 'Rate limit — the model provider is throttling requests',
61
+ 'auth': 'Authentication failure — the API key may be invalid or expired',
62
+ 'timeout': 'Timeout — the request took too long to complete',
63
+ 'unknown': 'Unknown error',
64
+ };
65
+
66
+ /** Human-readable label for an error category. */
67
+ export function errorCategoryLabel(category: ErrorCategory): string {
68
+ return CATEGORY_LABELS[category] ?? CATEGORY_LABELS['unknown'];
69
+ }
@@ -499,6 +499,12 @@ export async function executeWorkflowNodes(opts: {
499
499
  }
500
500
  }
501
501
 
502
+ if (kind === 'handoff') {
503
+ // Handoff nodes are supported in the pull-based worker (workflow-worker.ts).
504
+ // The synchronous executor doesn't support them yet — use `enqueue` + worker-tick instead.
505
+ throw new Error(`Node ${nodeLabel(node)}: handoff nodes require pull-based execution (use 'openclaw recipes workflows enqueue' + worker-tick)`);
506
+ }
507
+
502
508
  throw new Error(`Unsupported node kind: ${node.kind} (${nodeLabel(node)})`);
503
509
  }
504
510
 
@@ -27,6 +27,7 @@ export async function enqueueWorkflowRun(api: OpenClawPluginApi, opts: {
27
27
  teamId: string;
28
28
  workflowFile: string; // filename under shared-context/workflows/
29
29
  trigger?: { kind: string; at?: string };
30
+ triggerInput?: Record<string, unknown>;
30
31
  }) {
31
32
  const teamId = String(opts.teamId);
32
33
  const teamDir = resolveTeamDir(api, teamId);
@@ -94,6 +95,7 @@ export async function enqueueWorkflowRun(api: OpenClawPluginApi, opts: {
94
95
  workflow: { file: opts.workflowFile, id: workflow.id ?? null, name: workflow.name ?? null },
95
96
  ticket: { file: path.relative(teamDir, ticketPath), number: ticketNum, lane: initialLane },
96
97
  trigger,
98
+ ...(opts.triggerInput && Object.keys(opts.triggerInput).length > 0 ? { triggerInput: opts.triggerInput } : {}),
97
99
  status: 'queued',
98
100
  priority: 0,
99
101
  claimedBy: null,
@@ -1,6 +1,6 @@
1
1
  export type WorkflowLane = 'backlog' | 'in-progress' | 'testing' | 'done';
2
2
 
3
- export type WorkflowNodeKind = 'llm' | 'human_approval' | 'writeback' | 'tool' | 'start' | 'end' | string;
3
+ export type WorkflowNodeKind = 'llm' | 'human_approval' | 'writeback' | 'tool' | 'handoff' | 'start' | 'end' | string;
4
4
 
5
5
  export type WorkflowEdgeOn = 'success' | 'error' | 'always';
6
6
 
@@ -85,6 +85,7 @@ export type RunLog = {
85
85
  workflow: { file: string; id: string | null; name: string | null };
86
86
  ticket: { file: string; number: string; lane: WorkflowLane };
87
87
  trigger: { kind: string; at?: string };
88
+ triggerInput?: Record<string, unknown>;
88
89
  status: string;
89
90
  // Scheduler/runner fields
90
91
  priority?: number;
@@ -1,13 +1,15 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import crypto from 'node:crypto';
3
4
  import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
4
5
  import type { ToolTextResult } from '../../toolsInvoke';
5
6
  import { toolsInvoke } from '../../toolsInvoke';
7
+ import { classifyError, errorCategoryLabel } from './workflow-error-classify';
6
8
  import { resolveTeamDir } from '../workspace';
7
9
  import { getDriver } from './media-drivers/registry';
8
10
  import { GenericDriver } from './media-drivers/generic.driver';
9
11
  import { loadConfigEnv } from './media-drivers/utils';
10
- import type { WorkflowLane } from './workflow-types';
12
+ import type { WorkflowLane, WorkflowNode, RunLog } from './workflow-types';
11
13
  import { dequeueNextTask, enqueueTask, releaseTaskClaim, compactQueue } from './workflow-queue';
12
14
  import { loadPriorLlmInput, loadProposedPostTextFromPriorNode } from './workflow-node-output-readers';
13
15
  import { readTextFile } from './workflow-runner-io';
@@ -16,6 +18,7 @@ import {
16
18
  asRecord, asString, isRecord,
17
19
  normalizeWorkflow,
18
20
  assertLane, ensureDir, fileExists,
21
+ isoCompact, nextTicketNumber, laneToStatus,
19
22
  moveRunTicket, appendRunLog, writeRunFile, loadRunFile,
20
23
  runFilePathFor, nodeLabel,
21
24
  loadNodeStatesFromRun, pickNextRunnableNodeIndex,
@@ -129,6 +132,18 @@ async function buildTemplateVars(
129
132
  } as Record<string, string>;
130
133
 
131
134
  const { run: runSnap } = await loadRunFile(teamDir, runsDir, runId);
135
+
136
+ // Expose triggerInput as template variables (for handoff-injected data)
137
+ if (runSnap.triggerInput && typeof runSnap.triggerInput === 'object') {
138
+ for (const [key, value] of Object.entries(runSnap.triggerInput)) {
139
+ if (typeof value === 'string') {
140
+ vars[`trigger.${key}`] = value;
141
+ } else if (value !== null && value !== undefined) {
142
+ vars[`trigger.${key}`] = JSON.stringify(value);
143
+ }
144
+ }
145
+ }
146
+
132
147
  for (const nr of (runSnap.nodeResults ?? [])) {
133
148
  const nid = String((nr as Record<string, unknown>).nodeId ?? '');
134
149
  const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
@@ -171,6 +186,330 @@ async function buildTemplateVars(
171
186
  return vars;
172
187
  }
173
188
 
189
+ /**
190
+ * Enqueue a workflow run from a handoff node.
191
+ * This is a lightweight version of enqueueWorkflowRun that lives in the worker
192
+ * to avoid circular imports (workflow-runner re-exports workflow-worker).
193
+ */
194
+ async function enqueueWorkflowRunForHandoff(api: OpenClawPluginApi, opts: {
195
+ teamId: string;
196
+ workflowFile: string;
197
+ trigger?: { kind: string; at?: string };
198
+ triggerInput?: Record<string, unknown>;
199
+ }): Promise<{ runId: string; runLogPath: string }> {
200
+ const teamId = String(opts.teamId);
201
+ const teamDir = resolveTeamDir(api, teamId);
202
+ const sharedContextDir = path.join(teamDir, 'shared-context');
203
+ const workflowsDir = path.join(sharedContextDir, 'workflows');
204
+ const runsDir = path.join(sharedContextDir, 'workflow-runs');
205
+
206
+ const workflowPath = path.join(workflowsDir, opts.workflowFile);
207
+ const raw = await readTextFile(workflowPath);
208
+ const workflow = normalizeWorkflow(JSON.parse(raw));
209
+
210
+ if (!workflow.nodes?.length) throw new Error('Handoff target workflow has no nodes');
211
+
212
+ const firstLaneRaw = String(
213
+ workflow.nodes.find(n => n?.config && typeof n.config === 'object' && 'lane' in n.config)?.config?.lane ?? 'backlog'
214
+ );
215
+ assertLane(firstLaneRaw);
216
+ const initialLane: WorkflowLane = firstLaneRaw;
217
+
218
+ const runId = `${isoCompact()}-${crypto.randomBytes(4).toString('hex')}`;
219
+ await ensureDir(runsDir);
220
+
221
+ const runDir = path.join(runsDir, runId);
222
+ await ensureDir(runDir);
223
+ await Promise.all([
224
+ ensureDir(path.join(runDir, 'node-outputs')),
225
+ ensureDir(path.join(runDir, 'artifacts')),
226
+ ensureDir(path.join(runDir, 'approvals')),
227
+ ]);
228
+
229
+ const runLogPath = path.join(runDir, 'run.json');
230
+
231
+ const ticketNum = await nextTicketNumber(teamDir);
232
+ const slug = `workflow-run-${(workflow.id ?? path.basename(opts.workflowFile, path.extname(opts.workflowFile))).replace(/[^a-z0-9-]+/gi, '-').toLowerCase()}`;
233
+ const ticketFile = `${ticketNum}-${slug}.md`;
234
+
235
+ const laneDir = path.join(teamDir, 'work', initialLane);
236
+ await ensureDir(laneDir);
237
+ const ticketPath = path.join(laneDir, ticketFile);
238
+
239
+ const trigger = opts.trigger ?? { kind: 'handoff' };
240
+ const createdAt = new Date().toISOString();
241
+ const handoffMeta = opts.triggerInput?._handoff as Record<string, unknown> | undefined;
242
+
243
+ const md = [
244
+ `# ${ticketNum} — Workflow run: ${workflow.name ?? workflow.id ?? opts.workflowFile}\n\n`,
245
+ `Owner: lead`,
246
+ `Status: ${laneToStatus(initialLane)}`,
247
+ `\n## Run`,
248
+ `- workflow: ${path.relative(teamDir, workflowPath)}`,
249
+ `- run dir: ${path.relative(teamDir, runDir)}`,
250
+ `- run file: ${path.relative(teamDir, runLogPath)}`,
251
+ `- trigger: ${trigger.kind}${trigger.at ? ` @ ${trigger.at}` : ''}`,
252
+ `- runId: ${runId}`,
253
+ handoffMeta ? `- handoff from: team=${handoffMeta.sourceTeamId}, workflow=${handoffMeta.sourceWorkflowName}, run=${handoffMeta.sourceRunId}` : '',
254
+ `\n## Notes`,
255
+ `- Created by: handoff node`,
256
+ ``,
257
+ ].filter(Boolean).join('\n');
258
+
259
+ const initialLog: RunLog = {
260
+ runId,
261
+ createdAt,
262
+ updatedAt: createdAt,
263
+ teamId,
264
+ workflow: { file: opts.workflowFile, id: workflow.id ?? null, name: workflow.name ?? null },
265
+ ticket: { file: path.relative(teamDir, ticketPath), number: ticketNum, lane: initialLane },
266
+ trigger,
267
+ ...(opts.triggerInput && Object.keys(opts.triggerInput).length > 0 ? { triggerInput: opts.triggerInput } : {}),
268
+ status: 'queued',
269
+ priority: 0,
270
+ claimedBy: null,
271
+ claimExpiresAt: null,
272
+ nextNodeIndex: 0,
273
+ events: [{ ts: createdAt, type: 'run.enqueued', lane: initialLane, trigger: trigger.kind }],
274
+ nodeResults: [],
275
+ };
276
+
277
+ await Promise.all([
278
+ fs.writeFile(ticketPath, md, 'utf8'),
279
+ fs.writeFile(runLogPath, JSON.stringify(initialLog, null, 2), 'utf8'),
280
+ ]);
281
+
282
+ return { runId, runLogPath };
283
+ }
284
+
285
+ /**
286
+ * Check for waiting_handoff runs and resolve them if the target run has completed.
287
+ * Called at the start of each worker tick before processing the normal queue.
288
+ */
289
+ async function checkWaitingHandoffs(api: OpenClawPluginApi, teamId: string, teamDir: string): Promise<Array<{ runId: string; nodeId: string; status: string }>> {
290
+ const results: Array<{ runId: string; nodeId: string; status: string }> = [];
291
+ const runsDir = path.join(teamDir, 'shared-context', 'workflow-runs');
292
+
293
+ // Scan all active runs for handoff-waits directories
294
+ let runDirs: string[] = [];
295
+ try {
296
+ const entries = await fs.readdir(runsDir, { withFileTypes: true });
297
+ runDirs = entries.filter(e => e.isDirectory()).map(e => e.name);
298
+ } catch { return results; }
299
+
300
+ for (const runDirName of runDirs) {
301
+ const runDir = path.join(runsDir, runDirName);
302
+ const handoffWaitDir = path.join(runDir, 'handoff-waits');
303
+
304
+ let waitFiles: string[] = [];
305
+ try {
306
+ const entries = await fs.readdir(handoffWaitDir);
307
+ waitFiles = entries.filter(f => f.endsWith('.json'));
308
+ } catch { continue; } // No handoff-waits dir
309
+
310
+ if (waitFiles.length === 0) continue;
311
+
312
+ // Load current run to verify it's still waiting_handoff
313
+ const runPath = path.join(runDir, 'run.json');
314
+ let run: RunLog;
315
+ try {
316
+ const raw = await fs.readFile(runPath, 'utf8');
317
+ run = JSON.parse(raw) as RunLog;
318
+ } catch { continue; }
319
+
320
+ if (run.status !== 'waiting_handoff') {
321
+ // Clean up stale wait markers
322
+ for (const wf of waitFiles) {
323
+ try { await fs.unlink(path.join(handoffWaitDir, wf)); } catch { /* ignore */ }
324
+ }
325
+ continue;
326
+ }
327
+
328
+ for (const waitFile of waitFiles) {
329
+ const waitPath = path.join(handoffWaitDir, waitFile);
330
+ let marker: {
331
+ nodeId: string; nodeIdx: number;
332
+ targetTeamId: string; targetWorkflowId: string; targetWorkflowFile: string;
333
+ targetRunId: string; startedAt: string; timeoutAt: string;
334
+ nodeOutputRel: string;
335
+ };
336
+ try {
337
+ marker = JSON.parse(await fs.readFile(waitPath, 'utf8'));
338
+ } catch { continue; }
339
+
340
+ // Check timeout
341
+ const now = Date.now();
342
+ if (new Date(marker.timeoutAt).getTime() <= now) {
343
+ // Timeout — fail the node
344
+ const failTs = new Date().toISOString();
345
+ await appendRunLog(runPath, (cur) => ({
346
+ ...cur,
347
+ status: 'error',
348
+ nodeStates: { ...(cur.nodeStates ?? {}), [marker.nodeId]: { status: 'error', ts: failTs, message: 'Handoff wait timed out' } },
349
+ events: [...cur.events, {
350
+ ts: failTs, type: 'node.error', nodeId: marker.nodeId, kind: 'handoff',
351
+ error: `Handoff wait timed out after ${Math.round((now - new Date(marker.startedAt).getTime()) / 1000)}s`,
352
+ }],
353
+ }));
354
+ try { await fs.unlink(waitPath); } catch { /* ignore */ }
355
+ results.push({ runId: run.runId, nodeId: marker.nodeId, status: 'timeout' });
356
+ continue;
357
+ }
358
+
359
+ // Check target run status
360
+ const targetTeamDir = resolveTeamDir(api, marker.targetTeamId);
361
+ const targetRunsDir = path.join(targetTeamDir, 'shared-context', 'workflow-runs');
362
+ let targetRun: RunLog;
363
+ try {
364
+ const loaded = await loadRunFile(targetTeamDir, targetRunsDir, marker.targetRunId);
365
+ targetRun = loaded.run;
366
+ } catch {
367
+ // Target run not found — may have been cleaned up; fail
368
+ const failTs = new Date().toISOString();
369
+ await appendRunLog(runPath, (cur) => ({
370
+ ...cur,
371
+ status: 'error',
372
+ nodeStates: { ...(cur.nodeStates ?? {}), [marker.nodeId]: { status: 'error', ts: failTs, message: 'Target run not found' } },
373
+ events: [...cur.events, {
374
+ ts: failTs, type: 'node.error', nodeId: marker.nodeId, kind: 'handoff',
375
+ error: `Target run ${marker.targetRunId} not found in team ${marker.targetTeamId}`,
376
+ }],
377
+ }));
378
+ try { await fs.unlink(waitPath); } catch { /* ignore */ }
379
+ results.push({ runId: run.runId, nodeId: marker.nodeId, status: 'error' });
380
+ continue;
381
+ }
382
+
383
+ if (targetRun.status === 'completed' || targetRun.status === 'done') {
384
+ // Target completed — resolve handoff node with target's output
385
+ const targetOutput: Record<string, unknown> = {};
386
+ if (Array.isArray(targetRun.nodeResults)) {
387
+ for (const nr of targetRun.nodeResults) {
388
+ if (nr.nodeId && typeof nr.nodeId === 'string') {
389
+ targetOutput[nr.nodeId as string] = nr;
390
+ }
391
+ }
392
+ }
393
+
394
+ const nodeOutputAbs = path.resolve(runDir, marker.nodeOutputRel);
395
+ await ensureDir(path.dirname(nodeOutputAbs));
396
+ const outputObj = {
397
+ runId: run.runId,
398
+ teamId,
399
+ nodeId: marker.nodeId,
400
+ kind: 'handoff',
401
+ completedAt: new Date().toISOString(),
402
+ text: JSON.stringify({
403
+ targetTeamId: marker.targetTeamId,
404
+ targetWorkflowId: marker.targetWorkflowId,
405
+ targetRunId: marker.targetRunId,
406
+ status: 'completed',
407
+ targetOutput,
408
+ }, null, 2),
409
+ };
410
+ await fs.writeFile(nodeOutputAbs, JSON.stringify(outputObj, null, 2) + '\n', 'utf8');
411
+
412
+ const completedTs = new Date().toISOString();
413
+
414
+ // Load workflow to find next node
415
+ const workflowsDir = path.join(teamDir, 'shared-context', 'workflows');
416
+ let workflow;
417
+ try {
418
+ const wfRaw = await fs.readFile(path.join(workflowsDir, run.workflow.file), 'utf8');
419
+ workflow = normalizeWorkflow(JSON.parse(wfRaw));
420
+ } catch { workflow = null; }
421
+
422
+ await appendRunLog(runPath, (cur) => ({
423
+ ...cur,
424
+ status: 'waiting_workers',
425
+ nextNodeIndex: marker.nodeIdx + 1,
426
+ nodeStates: { ...(cur.nodeStates ?? {}), [marker.nodeId]: { status: 'success', ts: completedTs } },
427
+ events: [...cur.events, {
428
+ ts: completedTs, type: 'node.completed', nodeId: marker.nodeId, kind: 'handoff',
429
+ targetTeamId: marker.targetTeamId, targetWorkflowId: marker.targetWorkflowId,
430
+ targetRunId: marker.targetRunId, mode: 'wait-for-completion',
431
+ nodeOutputPath: marker.nodeOutputRel,
432
+ }],
433
+ }));
434
+
435
+ // Enqueue next node if workflow is available
436
+ if (workflow) {
437
+ const updatedRun = (await loadRunFile(teamDir, runsDir, run.runId)).run;
438
+ const nextIdx = pickNextRunnableNodeIndex({ workflow, run: updatedRun });
439
+
440
+ if (nextIdx !== null && nextIdx >= 0 && nextIdx < workflow.nodes.length) {
441
+ const nextNode = workflow.nodes[nextIdx];
442
+ if (nextNode.type === 'end' || nextNode.type === 'start') {
443
+ // Auto-complete start/end
444
+ const autoTs = new Date().toISOString();
445
+ await appendRunLog(runPath, (cur) => ({
446
+ ...cur,
447
+ nextNodeIndex: nextIdx + 1,
448
+ nodeStates: { ...(cur.nodeStates ?? {}), [nextNode.id]: { status: 'success', ts: autoTs } },
449
+ events: [...cur.events, { ts: autoTs, type: 'node.completed', nodeId: nextNode.id, kind: nextNode.type }],
450
+ }));
451
+ // Check if run is done
452
+ const afterAutoRun = (await loadRunFile(teamDir, runsDir, run.runId)).run;
453
+ const afterNext = pickNextRunnableNodeIndex({ workflow, run: afterAutoRun });
454
+ if (afterNext === null) {
455
+ const doneTs = new Date().toISOString();
456
+ await appendRunLog(runPath, (cur) => ({
457
+ ...cur,
458
+ status: 'completed',
459
+ events: [...cur.events, { ts: doneTs, type: 'run.completed' }],
460
+ }));
461
+ }
462
+ } else {
463
+ // Enqueue next real node to the appropriate agent's queue
464
+ const assignedAgent = String(nextNode.assignedTo ?? '').trim();
465
+ const targetAgent = assignedAgent || run.claimedBy || '';
466
+ if (targetAgent) {
467
+ await enqueueTask(teamDir, targetAgent, {
468
+ teamId,
469
+ runId: run.runId,
470
+ nodeId: nextNode.id,
471
+ kind: 'execute_node',
472
+ });
473
+ }
474
+ }
475
+ } else if (nextIdx === null) {
476
+ // All nodes done
477
+ const doneTs = new Date().toISOString();
478
+ await appendRunLog(runPath, (cur) => ({
479
+ ...cur,
480
+ status: 'completed',
481
+ events: [...cur.events, { ts: doneTs, type: 'run.completed' }],
482
+ }));
483
+ }
484
+ }
485
+
486
+ try { await fs.unlink(waitPath); } catch { /* ignore */ }
487
+ results.push({ runId: run.runId, nodeId: marker.nodeId, status: 'completed' });
488
+ } else if (targetRun.status === 'error' || targetRun.status === 'failed') {
489
+ // Target failed — fail the handoff node too
490
+ const failTs = new Date().toISOString();
491
+ const lastError = targetRun.events?.filter(e => e.type === 'node.error').pop();
492
+ await appendRunLog(runPath, (cur) => ({
493
+ ...cur,
494
+ status: 'error',
495
+ nodeStates: { ...(cur.nodeStates ?? {}), [marker.nodeId]: {
496
+ status: 'error', ts: failTs,
497
+ message: `Target workflow failed: ${lastError?.error ?? 'unknown error'}`,
498
+ } },
499
+ events: [...cur.events, {
500
+ ts: failTs, type: 'node.error', nodeId: marker.nodeId, kind: 'handoff',
501
+ error: `Target run ${marker.targetRunId} failed`,
502
+ }],
503
+ }));
504
+ try { await fs.unlink(waitPath); } catch { /* ignore */ }
505
+ results.push({ runId: run.runId, nodeId: marker.nodeId, status: 'error' });
506
+ }
507
+ // else: still running — do nothing, check again next tick
508
+ }
509
+ }
510
+ return results;
511
+ }
512
+
174
513
  // eslint-disable-next-line complexity, max-lines-per-function
175
514
  export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
176
515
  teamId: string;
@@ -193,6 +532,14 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
193
532
 
194
533
  const results: Array<{ taskId: string; runId: string; nodeId: string; status: string }> = [];
195
534
 
535
+ // Check for waiting_handoff runs before processing normal queue
536
+ try {
537
+ const handoffResults = await checkWaitingHandoffs(api, teamId, teamDir);
538
+ for (const hr of handoffResults) {
539
+ results.push({ taskId: '', runId: hr.runId, nodeId: hr.nodeId, status: `handoff:${hr.status}` });
540
+ }
541
+ } catch { /* handoff check is best-effort */ }
542
+
196
543
  // Default lock TTL (used when we don't know the node config yet).
197
544
  // This must be comfortably larger than typical media generation durations.
198
545
  const DEFAULT_LOCK_TTL_MS = 30 * 60 * 1000;
@@ -565,6 +912,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
565
912
  text = JSON.stringify(payload, null, 2);
566
913
  } catch (e) {
567
914
  const eRec = asRecord(e);
915
+ const errorCategory = classifyError(e);
568
916
  const errorDetails = {
569
917
  message: e instanceof Error ? e.message : String(e),
570
918
  name: e instanceof Error ? e.name : undefined,
@@ -573,6 +921,8 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
573
921
  details: eRec['details'],
574
922
  data: eRec['data'],
575
923
  cause: e instanceof Error && 'cause' in e ? (e as Error & { cause?: unknown }).cause : undefined,
924
+ errorCategory,
925
+ errorCategoryLabel: errorCategory !== 'unknown' ? errorCategoryLabel(errorCategory) : undefined,
576
926
  };
577
927
  const errMsg = `LLM execution failed for node ${nodeLabel(node)}: ${errorDetails.message}`;
578
928
  const errorTs = new Date().toISOString();
@@ -582,18 +932,18 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
582
932
  updatedAt: errorTs,
583
933
  nodeStates: {
584
934
  ...(cur.nodeStates ?? {}),
585
- [node.id]: { status: 'error', ts: errorTs, error: errMsg, details: errorDetails },
935
+ [node.id]: { status: 'error', ts: errorTs, error: errMsg, details: errorDetails, errorCategory },
586
936
  },
587
937
  events: [
588
938
  ...cur.events,
589
- { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, details: errorDetails },
939
+ { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, details: errorDetails, errorCategory },
590
940
  ],
591
941
  nodeResults: [
592
942
  ...(cur.nodeResults ?? []),
593
- { nodeId: node.id, kind: node.kind, agentId: agentIdExec, error: errMsg, details: errorDetails },
943
+ { nodeId: node.id, kind: node.kind, agentId: agentIdExec, error: errMsg, details: errorDetails, errorCategory },
594
944
  ],
595
945
  }));
596
- results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error' });
946
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', errorCategory });
597
947
  continue;
598
948
  }
599
949
 
@@ -1010,16 +1360,17 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1010
1360
  nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath), nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
1011
1361
  }));
1012
1362
  } catch (e) {
1013
- await fs.writeFile(artifactPath, JSON.stringify({ ok: false, tool: toolName, error: (e as Error).message }, null, 2) + '\n', 'utf8');
1363
+ const errorCategory = classifyError(e);
1364
+ await fs.writeFile(artifactPath, JSON.stringify({ ok: false, tool: toolName, error: (e as Error).message, errorCategory }, null, 2) + '\n', 'utf8');
1014
1365
  const errorTs = new Date().toISOString();
1015
1366
  await appendRunLog(runPath, (cur) => ({
1016
1367
  ...cur,
1017
1368
  status: 'error',
1018
- nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs } },
1019
- events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, tool: toolName, message: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
1020
- nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, error: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
1369
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, errorCategory } },
1370
+ events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, tool: toolName, message: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath), errorCategory }],
1371
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, error: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath), errorCategory }],
1021
1372
  }));
1022
- results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', error: (e as Error).message });
1373
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', error: (e as Error).message, errorCategory });
1023
1374
  continue;
1024
1375
  }
1025
1376
  } else if (kind === 'media-image' || kind === 'media-video' || kind === 'media-audio') {
@@ -1131,6 +1482,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1131
1482
  let payload: Record<string, unknown>;
1132
1483
  if (driver) {
1133
1484
  const result = await driver.invoke({
1485
+ api,
1134
1486
  prompt: refinedPrompt,
1135
1487
  outputDir: mediaDir,
1136
1488
  env: mergedEnv,
@@ -1157,6 +1509,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1157
1509
  }
1158
1510
  text = JSON.stringify(payload, null, 2);
1159
1511
  } catch (e) {
1512
+ const errorCategory = classifyError(e);
1160
1513
  const errDetails = e instanceof Error
1161
1514
  ? { message: e.message, name: e.name, stack: e.stack?.split('\n').slice(0, 5).join(' | ') }
1162
1515
  : { message: String(e) };
@@ -1166,11 +1519,11 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1166
1519
  ...cur,
1167
1520
  status: 'error',
1168
1521
  updatedAt: errorTs,
1169
- nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, error: errMsg } },
1170
- events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg }],
1171
- nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdMedia || agentId, error: errMsg }],
1522
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, error: errMsg, errorCategory } },
1523
+ events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, errorCategory }],
1524
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdMedia || agentId, error: errMsg, errorCategory }],
1172
1525
  }));
1173
- results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error' });
1526
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', errorCategory });
1174
1527
  continue;
1175
1528
  }
1176
1529
 
@@ -1198,6 +1551,190 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1198
1551
  events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind: node.kind, nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
1199
1552
  nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, mediaType, agentId: agentIdMedia || agentId, nodeOutputPath: path.relative(teamDir, nodeOutputAbs), bytes: new TextEncoder().encode(text).byteLength }],
1200
1553
  }));
1554
+ } else if (kind === 'handoff') {
1555
+ // ── Handoff node: trigger a run on another workflow (optionally on a different team) ──
1556
+ const config = asRecord((node as unknown as Record<string, unknown>)['config']);
1557
+ const action = asRecord(node.action);
1558
+
1559
+ const targetTeamId = asString(config['targetTeamId'] ?? action['targetTeamId']).trim() || teamId;
1560
+ const targetWorkflowId = asString(config['targetWorkflowId'] ?? action['targetWorkflowId']).trim();
1561
+ if (!targetWorkflowId) throw new Error(`Node ${nodeLabel(node)} missing config.targetWorkflowId`);
1562
+
1563
+ // Resolve variable mapping: each key is the target's trigger input key, each value is a {{template}} expression
1564
+ const variableMapping = asRecord(config['variableMapping'] ?? action['variableMapping']);
1565
+
1566
+ // Build template vars from prior node outputs
1567
+ const vars = await buildTemplateVars(teamDir, runsDir, task.runId, workflowFile, workflow);
1568
+ vars['node.id'] = node.id;
1569
+
1570
+ // Resolve mapped variables
1571
+ const triggerInput: Record<string, unknown> = {
1572
+ _handoff: {
1573
+ sourceTeamId: teamId,
1574
+ sourceWorkflowId: String(workflow.id ?? ''),
1575
+ sourceWorkflowName: String(workflow.name ?? workflow.id ?? workflowFile),
1576
+ sourceRunId: task.runId,
1577
+ sourceNodeId: node.id,
1578
+ },
1579
+ };
1580
+ for (const [targetKey, templateExpr] of Object.entries(variableMapping)) {
1581
+ if (typeof templateExpr === 'string') {
1582
+ triggerInput[targetKey] = templateReplace(templateExpr, vars);
1583
+ }
1584
+ }
1585
+
1586
+ // Find the target workflow file
1587
+ const targetTeamDir = resolveTeamDir(api, targetTeamId);
1588
+ const targetWorkflowsDir = path.join(targetTeamDir, 'shared-context', 'workflows');
1589
+ let targetWorkflowFile = '';
1590
+
1591
+ // Try exact filename match first, then search by workflow id
1592
+ const candidateFiles = [
1593
+ `${targetWorkflowId}.json`,
1594
+ `${targetWorkflowId}`,
1595
+ ];
1596
+ for (const candidate of candidateFiles) {
1597
+ const candidatePath = path.join(targetWorkflowsDir, candidate);
1598
+ if (await fileExists(candidatePath)) {
1599
+ targetWorkflowFile = candidate;
1600
+ break;
1601
+ }
1602
+ }
1603
+
1604
+ // If not found by filename, scan workflows for matching id
1605
+ if (!targetWorkflowFile) {
1606
+ try {
1607
+ const wfFiles = await fs.readdir(targetWorkflowsDir);
1608
+ for (const wf of wfFiles) {
1609
+ if (!wf.endsWith('.json')) continue;
1610
+ try {
1611
+ const wfPath = path.join(targetWorkflowsDir, wf);
1612
+ const wfRaw = await fs.readFile(wfPath, 'utf8');
1613
+ const wfParsed = JSON.parse(wfRaw);
1614
+ if (String(wfParsed.id ?? '') === targetWorkflowId || String(wfParsed.name ?? '') === targetWorkflowId) {
1615
+ targetWorkflowFile = wf;
1616
+ break;
1617
+ }
1618
+ } catch { /* skip unparseable workflows */ }
1619
+ }
1620
+ } catch { /* target workflows dir may not exist */ }
1621
+ }
1622
+
1623
+ if (!targetWorkflowFile) {
1624
+ throw new Error(`Handoff target workflow "${targetWorkflowId}" not found in team "${targetTeamId}"`);
1625
+ }
1626
+
1627
+ // Enqueue the target workflow run with triggerInput
1628
+ const enqueueResult = await enqueueWorkflowRunForHandoff(api, {
1629
+ teamId: targetTeamId,
1630
+ workflowFile: targetWorkflowFile,
1631
+ trigger: { kind: 'handoff', at: new Date().toISOString() },
1632
+ triggerInput,
1633
+ });
1634
+
1635
+ const handoffMode = asString(config['mode'] ?? 'fire-and-forget').trim() || 'fire-and-forget';
1636
+
1637
+ // Save initial handoff output
1638
+ const defaultNodeOutputRel = path.join('node-outputs', `${String(nodeIdx).padStart(3, '0')}-${node.id}.json`);
1639
+ const nodeOutputRel = String(node?.output?.path ?? '').trim() || defaultNodeOutputRel;
1640
+ const nodeOutputAbs = path.resolve(runDir, nodeOutputRel);
1641
+ await ensureDir(path.dirname(nodeOutputAbs));
1642
+
1643
+ if (handoffMode === 'wait-for-completion') {
1644
+ // Phase 2: Wait for target run to complete
1645
+ const waitTimeoutMs = typeof config['waitTimeoutMs'] === 'number' ? config['waitTimeoutMs'] as number : 5 * 60 * 1000;
1646
+
1647
+ const outputObj = {
1648
+ runId: task.runId,
1649
+ teamId,
1650
+ nodeId: node.id,
1651
+ kind: 'handoff',
1652
+ text: JSON.stringify({
1653
+ targetTeamId,
1654
+ targetWorkflowId,
1655
+ targetWorkflowFile,
1656
+ targetRunId: enqueueResult.runId,
1657
+ status: 'waiting',
1658
+ triggerInputKeys: Object.keys(triggerInput),
1659
+ }, null, 2),
1660
+ };
1661
+ await fs.writeFile(nodeOutputAbs, JSON.stringify(outputObj, null, 2) + '\n', 'utf8');
1662
+
1663
+ // Write handoff wait marker so the polling loop can find it
1664
+ const handoffWaitDir = path.join(runDir, 'handoff-waits');
1665
+ await ensureDir(handoffWaitDir);
1666
+ const waitMarker = {
1667
+ nodeId: node.id,
1668
+ nodeIdx,
1669
+ targetTeamId,
1670
+ targetWorkflowId,
1671
+ targetWorkflowFile,
1672
+ targetRunId: enqueueResult.runId,
1673
+ startedAt: new Date().toISOString(),
1674
+ timeoutAt: new Date(Date.now() + waitTimeoutMs).toISOString(),
1675
+ nodeOutputRel,
1676
+ };
1677
+ await fs.writeFile(
1678
+ path.join(handoffWaitDir, `${node.id}.json`),
1679
+ JSON.stringify(waitMarker, null, 2) + '\n',
1680
+ 'utf8',
1681
+ );
1682
+
1683
+ const waitingTs = new Date().toISOString();
1684
+ await appendRunLog(runPath, (cur) => ({
1685
+ ...cur,
1686
+ status: 'waiting_handoff',
1687
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'waiting', ts: waitingTs } },
1688
+ events: [...cur.events, {
1689
+ ts: waitingTs, type: 'node.waiting_handoff', nodeId: node.id, kind: 'handoff',
1690
+ targetTeamId, targetWorkflowId, targetRunId: enqueueResult.runId,
1691
+ mode: 'wait-for-completion', timeoutAt: waitMarker.timeoutAt,
1692
+ }],
1693
+ nodeResults: [...(cur.nodeResults ?? []), {
1694
+ nodeId: node.id, kind: 'handoff',
1695
+ targetTeamId, targetWorkflowId, targetRunId: enqueueResult.runId,
1696
+ nodeOutputPath: path.relative(teamDir, nodeOutputAbs),
1697
+ }],
1698
+ }));
1699
+
1700
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'waiting_handoff' });
1701
+ continue; // Skip the normal next-node enqueue logic
1702
+ } else {
1703
+ // Fire-and-forget: complete immediately
1704
+ const outputObj = {
1705
+ runId: task.runId,
1706
+ teamId,
1707
+ nodeId: node.id,
1708
+ kind: 'handoff',
1709
+ completedAt: new Date().toISOString(),
1710
+ text: JSON.stringify({
1711
+ targetTeamId,
1712
+ targetWorkflowId,
1713
+ targetWorkflowFile,
1714
+ targetRunId: enqueueResult.runId,
1715
+ status: 'enqueued',
1716
+ triggerInputKeys: Object.keys(triggerInput),
1717
+ }, null, 2),
1718
+ };
1719
+ await fs.writeFile(nodeOutputAbs, JSON.stringify(outputObj, null, 2) + '\n', 'utf8');
1720
+
1721
+ const completedTs = new Date().toISOString();
1722
+ await appendRunLog(runPath, (cur) => ({
1723
+ ...cur,
1724
+ nextNodeIndex: nodeIdx + 1,
1725
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
1726
+ events: [...cur.events, {
1727
+ ts: completedTs, type: 'node.completed', nodeId: node.id, kind: 'handoff',
1728
+ targetTeamId, targetWorkflowId, targetRunId: enqueueResult.runId,
1729
+ nodeOutputPath: path.relative(teamDir, nodeOutputAbs),
1730
+ }],
1731
+ nodeResults: [...(cur.nodeResults ?? []), {
1732
+ nodeId: node.id, kind: 'handoff',
1733
+ targetTeamId, targetWorkflowId, targetRunId: enqueueResult.runId,
1734
+ nodeOutputPath: path.relative(teamDir, nodeOutputAbs),
1735
+ }],
1736
+ }));
1737
+ }
1201
1738
  } else {
1202
1739
  throw new Error(`Worker does not yet support node kind: ${kind}`);
1203
1740
  }
@@ -1212,6 +1749,11 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1212
1749
  continue;
1213
1750
  }
1214
1751
 
1752
+ if (updated.status === 'waiting_handoff') {
1753
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'waiting_handoff' });
1754
+ continue;
1755
+ }
1756
+
1215
1757
  let enqueueIdx = pickNextRunnableNodeIndex({ workflow, run: updated });
1216
1758
 
1217
1759
  // Auto-complete start/end nodes.
@@ -6,6 +6,19 @@ export const TOOLS_INVOKE_TIMEOUT_MS = 120_000;
6
6
  export const RETRY_DELAY_BASE_MS = 150;
7
7
  export const GATEWAY_DEFAULT_PORT = 18789;
8
8
 
9
+ /**
10
+ * Custom error class that preserves HTTP status from gateway responses.
11
+ * Used downstream to classify errors (e.g. 402 → funding, 429 → rate-limit).
12
+ */
13
+ export class ToolsInvokeError extends Error {
14
+ httpStatus: number;
15
+ constructor(message: string, httpStatus: number) {
16
+ super(message);
17
+ this.name = 'ToolsInvokeError';
18
+ this.httpStatus = httpStatus;
19
+ }
20
+ }
21
+
9
22
  export type ToolTextResult = { content?: Array<{ type: string; text?: string }> };
10
23
 
11
24
  export type ToolsInvokeRequest = {
@@ -43,7 +56,7 @@ async function doSingleToolsInvoke<T>(url: string, token: string, req: ToolsInvo
43
56
  }).finally(() => clearTimeout(t));
44
57
 
45
58
  const json = (await res.json()) as ToolsInvokeResponse;
46
- if (!res.ok || !json.ok) throw new Error(parseToolsInvokeError(json, res.status));
59
+ if (!res.ok || !json.ok) throw new ToolsInvokeError(parseToolsInvokeError(json, res.status), res.status);
47
60
  return json.result as T;
48
61
  }
49
62