@jiggai/recipes 0.4.21 → 0.4.23

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.
@@ -0,0 +1,580 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
4
+ import type { ToolTextResult } from '../../toolsInvoke';
5
+ import { toolsInvoke } from '../../toolsInvoke';
6
+ import { resolveTeamDir } from '../workspace';
7
+ import type { WorkflowLane } from './workflow-types';
8
+ import { dequeueNextTask, enqueueTask, releaseTaskClaim, compactQueue } from './workflow-queue';
9
+ import { loadPriorLlmInput, loadProposedPostTextFromPriorNode } from './workflow-node-output-readers';
10
+ import { readTextFile } from './workflow-runner-io';
11
+ import { resolveApprovalBindingTarget } from './workflow-node-executor';
12
+ import {
13
+ asRecord, asString, isRecord,
14
+ normalizeWorkflow,
15
+ assertLane, ensureDir, fileExists,
16
+ moveRunTicket, appendRunLog, writeRunFile, loadRunFile,
17
+ runFilePathFor, nodeLabel,
18
+ loadNodeStatesFromRun, pickNextRunnableNodeIndex,
19
+ sanitizeDraftOnlyText, templateReplace,
20
+ } from './workflow-utils';
21
+
22
+ // eslint-disable-next-line complexity, max-lines-per-function
23
+ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
24
+ teamId: string;
25
+ agentId: string;
26
+ limit?: number;
27
+ workerId?: string;
28
+ }) {
29
+ const teamId = String(opts.teamId);
30
+ const agentId = String(opts.agentId);
31
+ if (!teamId) throw new Error('--team-id is required');
32
+ if (!agentId) throw new Error('--agent-id is required');
33
+
34
+ const teamDir = resolveTeamDir(api, teamId);
35
+ const sharedContextDir = path.join(teamDir, 'shared-context');
36
+ const workflowsDir = path.join(sharedContextDir, 'workflows');
37
+ const runsDir = path.join(sharedContextDir, 'workflow-runs');
38
+
39
+ const workerId = String(opts.workerId ?? `workflow-worker:${process.pid}`);
40
+ const limit = typeof opts.limit === 'number' && opts.limit > 0 ? Math.floor(opts.limit) : 1;
41
+
42
+ const results: Array<{ taskId: string; runId: string; nodeId: string; status: string }> = [];
43
+
44
+ for (let i = 0; i < limit; i++) {
45
+ const dq = await dequeueNextTask(teamDir, agentId, { workerId, leaseSeconds: 120 });
46
+ if (!dq.ok || !dq.task) break;
47
+
48
+ const { task } = dq.task;
49
+ const runPath = runFilePathFor(runsDir, task.runId);
50
+ const runDir = path.dirname(runPath);
51
+ const lockDir = path.join(runDir, 'locks');
52
+ const lockPath = path.join(lockDir, `${task.nodeId}.lock`);
53
+ let lockHeld = false;
54
+
55
+ try {
56
+ if (task.kind !== 'execute_node') continue;
57
+
58
+ await ensureDir(lockDir);
59
+
60
+ // Node-level lock to prevent double execution.
61
+ try {
62
+ await fs.writeFile(lockPath, JSON.stringify({ workerId, taskId: task.id, claimedAt: new Date().toISOString() }, null, 2), { encoding: 'utf8', flag: 'wx' });
63
+ lockHeld = true;
64
+ } catch {
65
+ // Lock exists. Treat it as contention unless it looks stale.
66
+ // (If a worker crashed, the lock file can stick around and block retries/revisions forever.)
67
+ let unlocked = false;
68
+ try {
69
+ const raw = await readTextFile(lockPath);
70
+ const parsed = JSON.parse(raw) as { claimedAt?: string };
71
+ const claimedAtMs = parsed?.claimedAt ? Date.parse(String(parsed.claimedAt)) : NaN;
72
+ const ageMs = Number.isFinite(claimedAtMs) ? Date.now() - claimedAtMs : NaN;
73
+ const stale = Number.isFinite(ageMs) && ageMs > 10 * 60 * 1000;
74
+ if (stale) {
75
+ await fs.unlink(lockPath);
76
+ unlocked = true;
77
+ }
78
+ } catch { // intentional: best-effort stale lock removal
79
+ // ignore
80
+ }
81
+
82
+ if (unlocked) {
83
+ try {
84
+ await fs.writeFile(lockPath, JSON.stringify({ workerId, taskId: task.id, claimedAt: new Date().toISOString() }, null, 2), { encoding: 'utf8', flag: 'wx' });
85
+ lockHeld = true;
86
+ } catch { // intentional: lock contention, skip task
87
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_locked' });
88
+ continue;
89
+ }
90
+ } else {
91
+ // Requeue to avoid task loss since dequeueNextTask already advanced the queue cursor.
92
+ await enqueueTask(teamDir, agentId, {
93
+ teamId,
94
+ runId: task.runId,
95
+ nodeId: task.nodeId,
96
+ kind: 'execute_node',
97
+ });
98
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_locked' });
99
+ continue;
100
+ }
101
+ }
102
+
103
+ const runId = task.runId;
104
+
105
+ const { run } = await loadRunFile(teamDir, runsDir, runId);
106
+ const workflowFile = String(run.workflow.file);
107
+ const workflowPath = path.join(workflowsDir, workflowFile);
108
+ const workflowRaw = await readTextFile(workflowPath);
109
+ const workflow = normalizeWorkflow(JSON.parse(workflowRaw));
110
+
111
+ const nodeIdx = workflow.nodes.findIndex((n) => String(n.id) === String(task.nodeId));
112
+ if (nodeIdx < 0) throw new Error(`Node not found in workflow: ${task.nodeId}`);
113
+ const node = workflow.nodes[nodeIdx]!;
114
+
115
+ // Stale-task guard: expired claim recovery can surface older queue entries from behind the
116
+ // cursor. Before executing a dequeued task, verify that this node is still actually runnable
117
+ // for the current run state. Otherwise we can resurrect pre-approval work and overwrite
118
+ // canonical node outputs for runs that already advanced.
119
+ const currentRun = (await loadRunFile(teamDir, runsDir, task.runId)).run;
120
+ const currentNodeStates = loadNodeStatesFromRun(currentRun);
121
+ const currentStatus = currentNodeStates[String(node.id)]?.status;
122
+ const currentlyRunnableIdx = pickNextRunnableNodeIndex({ workflow, run: currentRun });
123
+ if (
124
+ currentStatus === 'success' ||
125
+ currentStatus === 'error' ||
126
+ currentStatus === 'waiting' ||
127
+ currentlyRunnableIdx === null ||
128
+ String(workflow.nodes[currentlyRunnableIdx]?.id ?? '') !== String(node.id)
129
+ ) {
130
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_stale' });
131
+ continue;
132
+ }
133
+
134
+ // Determine current lane + ticket path.
135
+ const laneRaw = String(run.ticket.lane);
136
+ assertLane(laneRaw);
137
+ let curLane: WorkflowLane = laneRaw as WorkflowLane;
138
+ let curTicketPath = path.join(teamDir, run.ticket.file);
139
+
140
+ // Lane transitions.
141
+ const laneNodeRaw = node?.lane ? String(node.lane) : null;
142
+ if (laneNodeRaw) {
143
+ assertLane(laneNodeRaw);
144
+ if (laneNodeRaw !== curLane) {
145
+ const moved = await moveRunTicket({ teamDir, ticketPath: curTicketPath, toLane: laneNodeRaw });
146
+ curLane = laneNodeRaw;
147
+ curTicketPath = moved.ticketPath;
148
+ await appendRunLog(runPath, (cur) => ({
149
+ ...cur,
150
+ ticket: { ...cur.ticket, file: path.relative(teamDir, curTicketPath), lane: curLane },
151
+ events: [...cur.events, { ts: new Date().toISOString(), type: 'ticket.moved', lane: curLane, nodeId: node.id }],
152
+ }));
153
+ }
154
+ }
155
+
156
+ const kind = String(node.kind ?? '');
157
+
158
+ // start/end are no-op.
159
+ if (kind === 'start' || kind === 'end') {
160
+ const completedTs = new Date().toISOString();
161
+ await appendRunLog(runPath, (cur) => ({
162
+ ...cur,
163
+ nextNodeIndex: nodeIdx + 1,
164
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
165
+ events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind, noop: true }],
166
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, noop: true }],
167
+ }));
168
+ } else if (kind === 'llm') {
169
+ // Reuse the existing runner logic by executing just this node (sequential model).
170
+ // This keeps the worker deterministic and file-first.
171
+ const runLogPath = runPath;
172
+ const runId = task.runId;
173
+
174
+ const agentIdExec = String(node?.assignedTo?.agentId ?? '');
175
+ const action = asRecord(node.action);
176
+ const promptTemplatePath = asString(action['promptTemplatePath']).trim();
177
+ const promptTemplateInline = asString(action['promptTemplate']).trim();
178
+ if (!agentIdExec) throw new Error(`Node ${nodeLabel(node)} missing assignedTo.agentId`);
179
+ if (!promptTemplatePath && !promptTemplateInline) throw new Error(`Node ${nodeLabel(node)} missing action.promptTemplatePath or action.promptTemplate`);
180
+
181
+ const promptPathAbs = promptTemplatePath ? path.resolve(teamDir, promptTemplatePath) : '';
182
+ const defaultNodeOutputRel = path.join('node-outputs', `${String(nodeIdx).padStart(3, '0')}-${node.id}.json`);
183
+ const nodeOutputRel = String(node?.output?.path ?? '').trim() || defaultNodeOutputRel;
184
+ const nodeOutputAbs = path.resolve(runDir, nodeOutputRel);
185
+ if (!nodeOutputAbs.startsWith(runDir + path.sep) && nodeOutputAbs !== runDir) {
186
+ throw new Error(`Node output.path must be within the run directory: ${nodeOutputRel}`);
187
+ }
188
+ await ensureDir(path.dirname(nodeOutputAbs));
189
+
190
+ const prompt = promptTemplateInline ? promptTemplateInline : await readTextFile(promptPathAbs);
191
+ const taskText = [
192
+ `You are executing a workflow run for teamId=${teamId}.`,
193
+ `Workflow: ${workflow.name ?? workflow.id ?? workflowFile}`,
194
+ `RunId: ${runId}`,
195
+ `Node: ${nodeLabel(node)}`,
196
+ `\n---\nPROMPT TEMPLATE\n---\n`,
197
+ prompt.trim(),
198
+ `\n---\nOUTPUT FORMAT\n---\n`,
199
+ `Return ONLY the final content (the worker will store it as JSON).`,
200
+ ].join('\n');
201
+
202
+ let text = '';
203
+ try {
204
+
205
+ const priorInput = await loadPriorLlmInput({ runDir, workflow, currentNode: node, currentNodeIndex: nodeIdx });
206
+
207
+ const timeoutMsRaw = Number(asString(action['timeoutMs'] ?? (node as unknown as { config?: unknown })?.config?.['timeoutMs'] ?? '120000'));
208
+ const timeoutMs = Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0 ? timeoutMsRaw : 120000;
209
+
210
+ const llmRes = await toolsInvoke<unknown>(api, {
211
+ tool: 'llm-task',
212
+ action: 'json',
213
+ args: {
214
+ prompt: taskText,
215
+ input: { teamId, runId, nodeId: node.id, agentId, ...priorInput },
216
+ timeoutMs,
217
+ },
218
+ });
219
+
220
+ const llmRec = asRecord(llmRes);
221
+ const details = asRecord(llmRec['details']);
222
+ const payload = details['json'] ?? (Object.keys(details).length ? details : llmRes) ?? null;
223
+ text = JSON.stringify(payload, null, 2);
224
+ } catch (e) {
225
+ // Record the error on the run so it doesn't stay stuck in waiting_workers.
226
+ const errMsg = `LLM execution failed for node ${nodeLabel(node)}: ${e instanceof Error ? e.message : String(e)}`;
227
+ const errorTs = new Date().toISOString();
228
+ await appendRunLog(runPath, (cur) => ({
229
+ ...cur,
230
+ status: 'error',
231
+ updatedAt: errorTs,
232
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, error: errMsg } },
233
+ events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg }],
234
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdExec, error: errMsg }],
235
+ }));
236
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error' });
237
+ continue;
238
+ }
239
+
240
+ const outputObj = {
241
+ runId,
242
+ teamId,
243
+ nodeId: node.id,
244
+ kind: node.kind,
245
+ agentId: agentIdExec,
246
+ completedAt: new Date().toISOString(),
247
+ text,
248
+ };
249
+ await fs.writeFile(nodeOutputAbs, JSON.stringify(outputObj, null, 2) + '\n', 'utf8');
250
+
251
+ const completedTs = new Date().toISOString();
252
+ await appendRunLog(runLogPath, (cur) => ({
253
+ ...cur,
254
+ nextNodeIndex: nodeIdx + 1,
255
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
256
+ events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind: node.kind, nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
257
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdExec, nodeOutputPath: path.relative(teamDir, nodeOutputAbs), bytes: Buffer.byteLength(text, 'utf8') }],
258
+ }));
259
+ } else if (kind === 'human_approval') {
260
+ // For now, approval nodes are executed by workers (message send + awaiting state).
261
+ // Note: approval files live inside the run folder.
262
+ const approvalBindingId = String(node?.action?.approvalBindingId ?? '');
263
+ const config = asRecord((node as unknown as Record<string, unknown>)['config']);
264
+ const action = asRecord(node.action);
265
+ const provider = asString(config['provider'] ?? action['provider']).trim();
266
+ const targetRaw = config['target'] ?? action['target'];
267
+ const accountIdRaw = config['accountId'] ?? action['accountId'];
268
+
269
+ let channel = provider || 'telegram';
270
+ let target = String(targetRaw ?? '');
271
+ let accountId = accountIdRaw ? String(accountIdRaw) : undefined;
272
+
273
+ // ClawKitchen UI sometimes stores placeholder targets like "(set in UI)".
274
+ // Treat these as unset.
275
+ if (target && /^\(set in ui\)$/i.test(target.trim())) {
276
+ target = '';
277
+ }
278
+
279
+ if (approvalBindingId) {
280
+ try {
281
+ const resolved = await resolveApprovalBindingTarget(api, approvalBindingId);
282
+ channel = resolved.channel;
283
+ target = resolved.target;
284
+ accountId = resolved.accountId;
285
+ } catch {
286
+ // Back-compat for ClawKitchen UI: treat approvalBindingId as an inline provider/target hint if it looks like one.
287
+ // Example: "telegram:account:shawnjbot".
288
+ if (!target && approvalBindingId.startsWith('telegram:')) {
289
+ channel = 'telegram';
290
+ accountId = approvalBindingId.replace(/^telegram:account:/, '');
291
+ } else {
292
+ // If it's a telegram account hint, we can still proceed as long as we can derive a target.
293
+ // Otherwise, fail loudly.
294
+ throw new Error(
295
+ `Missing approval binding: approvalBindingId=${approvalBindingId}. Expected a config binding entry OR provide config.target.`
296
+ );
297
+ }
298
+ }
299
+ }
300
+
301
+ if (!target && channel === 'telegram') {
302
+ // Back-compat shims (dev/testing):
303
+ // - If Kitchen stored a telegram account hint (telegram:account:<id>) without a full binding,
304
+ // use known chat ids for local testing.
305
+ if (accountId === 'shawnjbot') target = '6477250615';
306
+ }
307
+
308
+ if (!target) {
309
+ throw new Error(`Node ${nodeLabel(node)} missing approval target (provide config.target or binding mapping)`);
310
+ }
311
+
312
+ const approvalsDir = path.join(runDir, 'approvals');
313
+ await ensureDir(approvalsDir);
314
+ const approvalPath = path.join(approvalsDir, 'approval.json');
315
+
316
+ const code = Math.random().toString(36).slice(2, 8).toUpperCase();
317
+
318
+ const approvalObj = {
319
+ runId: task.runId,
320
+ teamId,
321
+ workflowFile,
322
+ nodeId: node.id,
323
+ bindingId: approvalBindingId || undefined,
324
+ requestedAt: new Date().toISOString(),
325
+ status: 'pending',
326
+ code,
327
+ ticket: path.relative(teamDir, curTicketPath),
328
+ runLog: path.relative(teamDir, runPath),
329
+ };
330
+ await fs.writeFile(approvalPath, JSON.stringify(approvalObj, null, 2), 'utf8');
331
+
332
+ // Include a proposed-post preview in the approval request.
333
+ let proposed = '';
334
+ try {
335
+ const nodeOutputsDir = path.join(runDir, 'node-outputs');
336
+ // Prefer qc_brand output if present; otherwise use the most recent prior node.
337
+ const qcId = 'qc_brand';
338
+ const hasQc = (await fileExists(nodeOutputsDir)) && (await fs.readdir(nodeOutputsDir)).some((f) => f.endsWith(`-${qcId}.json`));
339
+ const priorId = hasQc ? qcId : String(workflow.nodes?.[Math.max(0, nodeIdx - 1)]?.id ?? '');
340
+ if (priorId) proposed = await loadProposedPostTextFromPriorNode({ runDir, nodeOutputsDir, priorNodeId: priorId });
341
+ } catch { // intentional: best-effort proposed text load
342
+ proposed = '';
343
+ }
344
+ proposed = sanitizeDraftOnlyText(proposed);
345
+
346
+ const msg = [
347
+ `Approval requested: ${workflow.name ?? workflow.id ?? workflowFile}`,
348
+ `Ticket: ${path.relative(teamDir, curTicketPath)}`,
349
+ `Code: ${code}`,
350
+ proposed ? `\n---\nPROPOSED POST (X)\n---\n${proposed}` : `\n(Warning: no proposed text found to preview)`,
351
+ `\nReply with:`,
352
+ `- approve ${code}`,
353
+ `- decline ${code} <what to change>`,
354
+ `\n(You can also review in Kitchen: http://localhost:7777/teams/${teamId}/workflows/${workflow.id ?? ''})`,
355
+ ].join('\n');
356
+
357
+ await toolsInvoke<ToolTextResult>(api, {
358
+ tool: 'message',
359
+ args: {
360
+ action: 'send',
361
+ channel,
362
+ target,
363
+ ...(accountId ? { accountId } : {}),
364
+ message: msg,
365
+ },
366
+ });
367
+
368
+ const waitingTs = new Date().toISOString();
369
+ await appendRunLog(runPath, (cur) => ({
370
+ ...cur,
371
+ status: 'awaiting_approval',
372
+ nextNodeIndex: nodeIdx + 1,
373
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'waiting', ts: waitingTs } },
374
+ events: [...cur.events, { ts: waitingTs, type: 'node.awaiting_approval', nodeId: node.id, bindingId: approvalBindingId, approvalFile: path.relative(teamDir, approvalPath) }],
375
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, approvalBindingId, approvalFile: path.relative(teamDir, approvalPath) }],
376
+ }));
377
+
378
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'awaiting_approval' });
379
+ continue;
380
+ } else if (kind === 'tool') {
381
+ const action = asRecord(node.action);
382
+ const toolName = asString(action['tool']).trim();
383
+ const toolArgs = isRecord(action['args']) ? (action['args'] as Record<string, unknown>) : {};
384
+ if (!toolName) throw new Error(`Node ${nodeLabel(node)} missing action.tool`);
385
+
386
+ const artifactsDir = path.join(runDir, 'artifacts');
387
+ await ensureDir(artifactsDir);
388
+ const artifactPath = path.join(artifactsDir, `${String(nodeIdx).padStart(3, '0')}-${node.id}.tool.json`);
389
+ try {
390
+ // Runner-native tools (preferred): do NOT depend on gateway tool exposure.
391
+ if (toolName === 'fs.append') {
392
+ const relPathRaw = String(toolArgs.path ?? '').trim();
393
+ const contentRaw = String(toolArgs.content ?? '');
394
+ if (!relPathRaw) throw new Error('fs.append requires args.path');
395
+ if (!contentRaw) throw new Error('fs.append requires args.content');
396
+
397
+ const vars = {
398
+ date: new Date().toISOString(),
399
+ 'run.id': runId,
400
+ 'workflow.id': String(workflow.id ?? ''),
401
+ 'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
402
+ };
403
+ const relPath = templateReplace(relPathRaw, vars);
404
+ const content = templateReplace(contentRaw, vars);
405
+
406
+ const abs = path.resolve(teamDir, relPath);
407
+ if (!abs.startsWith(teamDir + path.sep) && abs !== teamDir) {
408
+ throw new Error('fs.append path must be within the team workspace');
409
+ }
410
+
411
+ await ensureDir(path.dirname(abs));
412
+ await fs.appendFile(abs, content, 'utf8');
413
+
414
+ const result = { appendedTo: path.relative(teamDir, abs), bytes: Buffer.byteLength(content, 'utf8') };
415
+ await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2) + '\n', 'utf8');
416
+
417
+
418
+ } else if (toolName === 'marketing.post_all') {
419
+ // Disabled by default: do not ship plugins that spawn local processes for posting.
420
+ // Use an approval-gated workflow node that calls a dedicated posting tool/plugin instead.
421
+ throw new Error(
422
+ 'marketing.post_all is disabled in this build (install safety). Use an external posting tool/plugin (approval-gated) instead.'
423
+ );
424
+ } else {
425
+ const toolRes = await toolsInvoke<unknown>(api, {
426
+ tool: toolName,
427
+ args: toolArgs,
428
+ });
429
+
430
+ await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, result: toolRes }, null, 2) + '\n', 'utf8');
431
+ }
432
+
433
+ const defaultNodeOutputRel = path.join('node-outputs', `${String(nodeIdx).padStart(3, '0')}-${node.id}.json`);
434
+ const nodeOutputRel = String(node?.output?.path ?? '').trim() || defaultNodeOutputRel;
435
+ const nodeOutputAbs = path.resolve(runDir, nodeOutputRel);
436
+ await ensureDir(path.dirname(nodeOutputAbs));
437
+ await fs.writeFile(nodeOutputAbs, JSON.stringify({
438
+ runId: task.runId,
439
+ teamId,
440
+ nodeId: node.id,
441
+ kind: node.kind,
442
+ completedAt: new Date().toISOString(),
443
+ tool: toolName,
444
+ artifactPath: path.relative(teamDir, artifactPath),
445
+ }, null, 2) + '\n', 'utf8');
446
+
447
+ const completedTs = new Date().toISOString();
448
+ await appendRunLog(runPath, (cur) => ({
449
+ ...cur,
450
+ nextNodeIndex: nodeIdx + 1,
451
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
452
+ events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind: node.kind, artifactPath: path.relative(teamDir, artifactPath), nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
453
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath), nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
454
+ }));
455
+ } catch (e) {
456
+ await fs.writeFile(artifactPath, JSON.stringify({ ok: false, tool: toolName, error: (e as Error).message }, null, 2) + '\n', 'utf8');
457
+ const errorTs = new Date().toISOString();
458
+ await appendRunLog(runPath, (cur) => ({
459
+ ...cur,
460
+ status: 'error',
461
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs } },
462
+ 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) }],
463
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, error: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
464
+ }));
465
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', error: (e as Error).message });
466
+ continue;
467
+ }
468
+ } else {
469
+ throw new Error(`Worker does not yet support node kind: ${kind}`);
470
+ }
471
+
472
+ // After node completion, enqueue next node.
473
+ // Graph-aware: if workflow.edges exist, compute the next runnable node from nodeStates + edges.
474
+
475
+ let updated = (await loadRunFile(teamDir, runsDir, task.runId)).run;
476
+
477
+ if (updated.status === 'awaiting_approval') {
478
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'awaiting_approval' });
479
+ continue;
480
+ }
481
+
482
+ let enqueueIdx = pickNextRunnableNodeIndex({ workflow, run: updated });
483
+
484
+ // Auto-complete start/end nodes.
485
+ while (enqueueIdx !== null) {
486
+ const n = workflow.nodes[enqueueIdx]!;
487
+ const k = String(n.kind ?? '');
488
+ if (k !== 'start' && k !== 'end') break;
489
+ const ts = new Date().toISOString();
490
+ await appendRunLog(runPath, (cur) => ({
491
+ ...cur,
492
+ nextNodeIndex: enqueueIdx! + 1,
493
+ nodeStates: { ...(cur.nodeStates ?? {}), [n.id]: { status: 'success', ts } },
494
+ events: [...cur.events, { ts, type: 'node.completed', nodeId: n.id, kind: k, noop: true }],
495
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: n.id, kind: k, noop: true }],
496
+ }));
497
+ updated = (await loadRunFile(teamDir, runsDir, task.runId)).run;
498
+ enqueueIdx = pickNextRunnableNodeIndex({ workflow, run: updated });
499
+ }
500
+
501
+ if (enqueueIdx === null) {
502
+ await writeRunFile(runPath, (cur) => ({
503
+ ...cur,
504
+ updatedAt: new Date().toISOString(),
505
+ status: 'completed',
506
+ events: [...cur.events, { ts: new Date().toISOString(), type: 'run.completed' }],
507
+ }));
508
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'completed' });
509
+ continue;
510
+ }
511
+
512
+ const nextNode = workflow.nodes[enqueueIdx]!;
513
+
514
+ // Some nodes (human approval) may not have an assigned agent; they are executed
515
+ // by the runner/worker loop itself (they send a message + set awaiting state).
516
+ const nextKind = String(nextNode.kind ?? '');
517
+ if (nextKind === 'human_approval' || nextKind === 'start' || nextKind === 'end') {
518
+ // Re-enqueue onto the same agent so it can execute the next node deterministically.
519
+ await enqueueTask(teamDir, agentId, {
520
+ teamId,
521
+ runId: task.runId,
522
+ nodeId: nextNode.id,
523
+ kind: 'execute_node',
524
+ });
525
+
526
+ await writeRunFile(runPath, (cur) => ({
527
+ ...cur,
528
+ updatedAt: new Date().toISOString(),
529
+ status: 'waiting_workers',
530
+ nextNodeIndex: enqueueIdx,
531
+ events: [...cur.events, { ts: new Date().toISOString(), type: 'node.enqueued', nodeId: nextNode.id, agentId }],
532
+ }));
533
+
534
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'ok' });
535
+ continue;
536
+ }
537
+
538
+ const nextAgentId = String(nextNode?.assignedTo?.agentId ?? '').trim();
539
+ if (!nextAgentId) throw new Error(`Next node ${nextNode.id} missing assignedTo.agentId`);
540
+
541
+ await enqueueTask(teamDir, nextAgentId, {
542
+ teamId,
543
+ runId: task.runId,
544
+ nodeId: nextNode.id,
545
+ kind: 'execute_node',
546
+ });
547
+
548
+ await writeRunFile(runPath, (cur) => ({
549
+ ...cur,
550
+ updatedAt: new Date().toISOString(),
551
+ status: 'waiting_workers',
552
+ nextNodeIndex: enqueueIdx,
553
+ events: [...cur.events, { ts: new Date().toISOString(), type: 'node.enqueued', nodeId: nextNode.id, agentId: nextAgentId }],
554
+ }));
555
+
556
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'ok' });
557
+ } finally {
558
+ if (lockHeld) {
559
+ try {
560
+ await fs.unlink(lockPath);
561
+ } catch { // intentional: best-effort lock cleanup
562
+ // ignore
563
+ }
564
+ }
565
+ try {
566
+ await releaseTaskClaim(teamDir, agentId, task.id);
567
+ } catch { // intentional: best-effort claim release
568
+ // ignore
569
+ }
570
+ }
571
+
572
+ }
573
+
574
+ // Compact the queue to prevent unbounded growth from processed entries.
575
+ try {
576
+ await compactQueue(teamDir, agentId);
577
+ } catch { /* intentional: best-effort compaction */ }
578
+
579
+ return { ok: true as const, teamId, agentId, workerId, results };
580
+ }
@@ -2,7 +2,7 @@
2
2
  // flag "file read + network send" when both patterns live in the same file.
3
3
  // This module intentionally contains the network call (fetch) but no filesystem reads.
4
4
 
5
- export const TOOLS_INVOKE_TIMEOUT_MS = 30_000;
5
+ 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