@jiggai/recipes 0.4.19 → 0.4.21
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/docs/WORKFLOW_FIXES_2026-03-13.md +23 -0
- package/index.ts +130 -29
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/recipes/default/business-team.md +28 -28
- package/recipes/default/customer-support-team.md +26 -26
- package/recipes/default/development-team.md +46 -46
- package/recipes/default/marketing-team.md +87 -99
- package/recipes/default/product-team.md +28 -28
- package/recipes/default/research-team.md +26 -26
- package/recipes/default/social-team.md +44 -44
- package/recipes/default/workflow-runner-addon.md +3 -3
- package/recipes/default/writing-team.md +26 -26
- package/src/lib/workflows/workflow-queue.ts +116 -85
- package/src/lib/workflows/workflow-runner.ts +110 -123
- package/src/lib/workspace.ts +20 -0
|
@@ -43,6 +43,30 @@ function claimPathFor(teamDir: string, agentId: string, taskId: string) {
|
|
|
43
43
|
return path.join(claimsDir(teamDir), `${agentId}.${taskId}.json`);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
async function loadClaim(teamDir: string, agentId: string, taskId: string) {
|
|
47
|
+
const p = claimPathFor(teamDir, agentId, taskId);
|
|
48
|
+
try {
|
|
49
|
+
const raw = await fs.readFile(p, 'utf8');
|
|
50
|
+
return JSON.parse(raw) as { workerId?: string; claimedAt?: string; leaseSeconds?: number };
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isExpiredClaim(claim: { claimedAt?: string; leaseSeconds?: number } | null | undefined, fallbackLeaseSeconds?: number) {
|
|
57
|
+
if (!claim) return false;
|
|
58
|
+
const effectiveLease = typeof claim.leaseSeconds === 'number' ? claim.leaseSeconds : fallbackLeaseSeconds;
|
|
59
|
+
const claimedAtMs = claim.claimedAt ? Date.parse(String(claim.claimedAt)) : NaN;
|
|
60
|
+
return typeof effectiveLease === 'number' && Number.isFinite(claimedAtMs) && Date.now() - claimedAtMs > effectiveLease * 1000;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function releaseTaskClaim(teamDir: string, agentId: string, taskId: string) {
|
|
64
|
+
try {
|
|
65
|
+
await fs.unlink(claimPathFor(teamDir, agentId, taskId));
|
|
66
|
+
} catch {
|
|
67
|
+
// ignore missing claims
|
|
68
|
+
}
|
|
69
|
+
}
|
|
46
70
|
|
|
47
71
|
export function queuePathFor(teamDir: string, agentId: string) {
|
|
48
72
|
return path.join(queueDir(teamDir), `${agentId}.jsonl`);
|
|
@@ -150,109 +174,116 @@ export async function dequeueNextTask(
|
|
|
150
174
|
}
|
|
151
175
|
|
|
152
176
|
const st = await loadState(teamDir, agentId);
|
|
177
|
+
const workerId = String(opts?.workerId ?? `worker:${process.pid}`);
|
|
178
|
+
const leaseSeconds = typeof opts?.leaseSeconds === 'number' ? opts.leaseSeconds : undefined;
|
|
179
|
+
|
|
180
|
+
async function tryClaimTask(t: QueueTask, startOffsetBytes: number, endOffsetBytes: number, advanceState: boolean) {
|
|
181
|
+
await ensureDir(claimsDir(teamDir));
|
|
182
|
+
const claimPath = claimPathFor(teamDir, agentId, t.id);
|
|
183
|
+
|
|
184
|
+
async function writeClaim(overwrite: boolean) {
|
|
185
|
+
const claim = {
|
|
186
|
+
taskId: t.id,
|
|
187
|
+
agentId,
|
|
188
|
+
workerId,
|
|
189
|
+
claimedAt: new Date().toISOString(),
|
|
190
|
+
leaseSeconds,
|
|
191
|
+
};
|
|
192
|
+
await fs.writeFile(claimPath, JSON.stringify(claim, null, 2), { encoding: 'utf8', flag: overwrite ? 'w' : 'wx' });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
await writeClaim(false);
|
|
197
|
+
} catch {
|
|
198
|
+
const existing = await loadClaim(teamDir, agentId, t.id);
|
|
199
|
+
if (String(existing?.workerId ?? '') !== workerId) {
|
|
200
|
+
if (!isExpiredClaim(existing, leaseSeconds)) {
|
|
201
|
+
if (advanceState) {
|
|
202
|
+
await writeState(teamDir, agentId, { offsetBytes: endOffsetBytes, updatedAt: new Date().toISOString() });
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
await writeClaim(true);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (advanceState) {
|
|
211
|
+
await writeState(teamDir, agentId, { offsetBytes: endOffsetBytes, updatedAt: new Date().toISOString() });
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
ok: true as const,
|
|
215
|
+
task: { task: t, startOffsetBytes, endOffsetBytes },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
153
219
|
const fh = await fs.open(qPath, 'r');
|
|
154
220
|
try {
|
|
155
221
|
const stat = await fh.stat();
|
|
156
|
-
if (st.offsetBytes
|
|
157
|
-
|
|
158
|
-
|
|
222
|
+
if (st.offsetBytes < stat.size) {
|
|
223
|
+
const toRead = Math.min(stat.size - st.offsetBytes, 256 * 1024);
|
|
224
|
+
const buf = Buffer.alloc(toRead);
|
|
225
|
+
const { bytesRead } = await fh.read(buf, 0, toRead, st.offsetBytes);
|
|
226
|
+
const chunk = buf.subarray(0, bytesRead).toString('utf8');
|
|
227
|
+
|
|
228
|
+
const lines = chunk.split('\n');
|
|
229
|
+
const fullLines = lines.slice(0, -1);
|
|
230
|
+
let cursor = st.offsetBytes;
|
|
231
|
+
|
|
232
|
+
for (const line of fullLines) {
|
|
233
|
+
const lineBytes = Buffer.byteLength(line + '\n');
|
|
234
|
+
const startOffsetBytes = cursor;
|
|
235
|
+
const endOffsetBytes = cursor + lineBytes;
|
|
236
|
+
cursor = endOffsetBytes;
|
|
237
|
+
|
|
238
|
+
if (!line.trim()) {
|
|
239
|
+
await writeState(teamDir, agentId, { offsetBytes: cursor, updatedAt: new Date().toISOString() });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
159
242
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
243
|
+
let t: QueueTask | null = null;
|
|
244
|
+
try {
|
|
245
|
+
t = JSON.parse(line) as QueueTask;
|
|
246
|
+
} catch {
|
|
247
|
+
await writeState(teamDir, agentId, { offsetBytes: cursor, updatedAt: new Date().toISOString() });
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
164
250
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
251
|
+
if (!t || !t.id || !t.runId || !t.nodeId) {
|
|
252
|
+
await writeState(teamDir, agentId, { offsetBytes: cursor, updatedAt: new Date().toISOString() });
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
168
255
|
|
|
169
|
-
|
|
256
|
+
const claimed = await tryClaimTask(t, startOffsetBytes, endOffsetBytes, true);
|
|
257
|
+
if (claimed) return claimed;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Recovery scan: if the cursor has already advanced, revisit older tasks that still have
|
|
262
|
+
// a live claim file and whose lease has expired. This prevents claimed-then-crashed workers
|
|
263
|
+
// from permanently orphaning tasks behind offsetBytes.
|
|
264
|
+
const fullRaw = await fs.readFile(qPath, 'utf8');
|
|
265
|
+
let cursor = 0;
|
|
266
|
+
for (const line of fullRaw.split('\n')) {
|
|
170
267
|
const lineBytes = Buffer.byteLength(line + '\n');
|
|
171
268
|
const startOffsetBytes = cursor;
|
|
172
269
|
const endOffsetBytes = cursor + lineBytes;
|
|
173
270
|
cursor = endOffsetBytes;
|
|
174
|
-
|
|
175
|
-
if (!line.trim()) {
|
|
176
|
-
await writeState(teamDir, agentId, { offsetBytes: cursor, updatedAt: new Date().toISOString() });
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
|
|
271
|
+
if (!line.trim()) continue;
|
|
180
272
|
let t: QueueTask | null = null;
|
|
181
273
|
try {
|
|
182
274
|
t = JSON.parse(line) as QueueTask;
|
|
183
275
|
} catch {
|
|
184
|
-
// Malformed: skip it so we don't get stuck.
|
|
185
|
-
await writeState(teamDir, agentId, { offsetBytes: cursor, updatedAt: new Date().toISOString() });
|
|
186
276
|
continue;
|
|
187
277
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
await ensureDir(claimsDir(teamDir));
|
|
195
|
-
const claimPath = claimPathFor(teamDir, agentId, t.id);
|
|
196
|
-
|
|
197
|
-
// Claim behavior:
|
|
198
|
-
// - If unclaimed: create claim file.
|
|
199
|
-
// - If already claimed by *this* workerId: allow re-processing (idempotent recovery).
|
|
200
|
-
// - If claimed by another workerId:
|
|
201
|
-
// - if lease is expired: allow this worker to steal the claim
|
|
202
|
-
// - otherwise: skip
|
|
203
|
-
const workerId = String(opts?.workerId ?? `worker:${process.pid}`);
|
|
204
|
-
const leaseSeconds = typeof opts?.leaseSeconds === 'number' ? opts.leaseSeconds : undefined;
|
|
205
|
-
const now = Date.now();
|
|
206
|
-
|
|
207
|
-
async function writeClaim(overwrite: boolean) {
|
|
208
|
-
const claim = {
|
|
209
|
-
taskId: t!.id,
|
|
210
|
-
agentId,
|
|
211
|
-
workerId,
|
|
212
|
-
claimedAt: new Date().toISOString(),
|
|
213
|
-
leaseSeconds,
|
|
214
|
-
};
|
|
215
|
-
await fs.writeFile(claimPath, JSON.stringify(claim, null, 2), { encoding: 'utf8', flag: overwrite ? 'w' : 'wx' });
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
try {
|
|
219
|
-
await writeClaim(false);
|
|
220
|
-
} catch {
|
|
221
|
-
try {
|
|
222
|
-
const raw = await fs.readFile(claimPath, 'utf8');
|
|
223
|
-
const existing = JSON.parse(raw) as { workerId?: string; claimedAt?: string; leaseSeconds?: number };
|
|
224
|
-
|
|
225
|
-
// Same worker: allow idempotent re-processing.
|
|
226
|
-
if (String(existing?.workerId ?? '') === workerId) {
|
|
227
|
-
// proceed
|
|
228
|
-
} else {
|
|
229
|
-
const existingLease = typeof existing?.leaseSeconds === 'number' ? existing.leaseSeconds : undefined;
|
|
230
|
-
const effectiveLease = typeof leaseSeconds === 'number' ? leaseSeconds : existingLease;
|
|
231
|
-
const claimedAtMs = existing?.claimedAt ? Date.parse(String(existing.claimedAt)) : NaN;
|
|
232
|
-
const expired = typeof effectiveLease === 'number' && Number.isFinite(claimedAtMs) && now - claimedAtMs > effectiveLease * 1000;
|
|
233
|
-
|
|
234
|
-
if (!expired) {
|
|
235
|
-
await writeState(teamDir, agentId, { offsetBytes: cursor, updatedAt: new Date().toISOString() });
|
|
236
|
-
continue;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Lease expired: steal.
|
|
240
|
-
await writeClaim(true);
|
|
241
|
-
}
|
|
242
|
-
} catch {
|
|
243
|
-
await writeState(teamDir, agentId, { offsetBytes: cursor, updatedAt: new Date().toISOString() });
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
await writeState(teamDir, agentId, { offsetBytes: cursor, updatedAt: new Date().toISOString() });
|
|
249
|
-
return {
|
|
250
|
-
ok: true as const,
|
|
251
|
-
task: { task: t, startOffsetBytes, endOffsetBytes },
|
|
252
|
-
};
|
|
278
|
+
if (!t || !t.id || !t.runId || !t.nodeId) continue;
|
|
279
|
+
const existing = await loadClaim(teamDir, agentId, t.id);
|
|
280
|
+
if (!existing) continue;
|
|
281
|
+
if (String(existing.workerId ?? '') !== workerId && !isExpiredClaim(existing, leaseSeconds)) continue;
|
|
282
|
+
const claimed = await tryClaimTask(t, startOffsetBytes, endOffsetBytes, false);
|
|
283
|
+
if (claimed) return claimed;
|
|
253
284
|
}
|
|
254
285
|
|
|
255
|
-
return { ok: true as const, task: null as DequeuedTask | null, message: 'No
|
|
286
|
+
return { ok: true as const, task: null as DequeuedTask | null, message: 'No new or recoverable tasks.' };
|
|
256
287
|
} finally {
|
|
257
288
|
await fh.close();
|
|
258
289
|
}
|
|
@@ -2,12 +2,12 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
5
|
-
import {
|
|
5
|
+
import { resolveTeamDir } from '../workspace';
|
|
6
6
|
import type { ToolTextResult } from '../../toolsInvoke';
|
|
7
7
|
import { toolsInvoke } from '../../toolsInvoke';
|
|
8
8
|
import { loadOpenClawConfig } from '../recipes-config';
|
|
9
9
|
import type { Workflow, WorkflowEdge, WorkflowLane, WorkflowNode } from './workflow-types';
|
|
10
|
-
import { dequeueNextTask, enqueueTask } from './workflow-queue';
|
|
10
|
+
import { dequeueNextTask, enqueueTask, releaseTaskClaim } from './workflow-queue';
|
|
11
11
|
import { outboundPublish, type OutboundApproval, type OutboundMedia, type OutboundPlatform } from './outbound-client';
|
|
12
12
|
import { sanitizeOutboundPostText } from './outbound-sanitize';
|
|
13
13
|
import { loadPriorLlmInput, loadProposedPostTextFromPriorNode } from './workflow-node-output-readers';
|
|
@@ -870,8 +870,7 @@ export async function enqueueWorkflowRun(api: OpenClawPluginApi, opts: {
|
|
|
870
870
|
trigger?: { kind: string; at?: string };
|
|
871
871
|
}) {
|
|
872
872
|
const teamId = String(opts.teamId);
|
|
873
|
-
const
|
|
874
|
-
const teamDir = path.resolve(workspaceRoot, '..', `workspace-${teamId}`);
|
|
873
|
+
const teamDir = resolveTeamDir(api, teamId);
|
|
875
874
|
const sharedContextDir = path.join(teamDir, 'shared-context');
|
|
876
875
|
const workflowsDir = path.join(sharedContextDir, 'workflows');
|
|
877
876
|
const runsDir = path.join(sharedContextDir, 'workflow-runs');
|
|
@@ -968,8 +967,7 @@ export async function runWorkflowRunnerOnce(api: OpenClawPluginApi, opts: {
|
|
|
968
967
|
leaseSeconds?: number;
|
|
969
968
|
}) {
|
|
970
969
|
const teamId = String(opts.teamId);
|
|
971
|
-
const
|
|
972
|
-
const teamDir = path.resolve(workspaceRoot, '..', `workspace-${teamId}`);
|
|
970
|
+
const teamDir = resolveTeamDir(api, teamId);
|
|
973
971
|
const sharedContextDir = path.join(teamDir, 'shared-context');
|
|
974
972
|
const runsDir = path.join(sharedContextDir, 'workflow-runs');
|
|
975
973
|
const workflowsDir = path.join(sharedContextDir, 'workflows');
|
|
@@ -1121,8 +1119,7 @@ export async function runWorkflowRunnerTick(api: OpenClawPluginApi, opts: {
|
|
|
1121
1119
|
leaseSeconds?: number;
|
|
1122
1120
|
}) {
|
|
1123
1121
|
const teamId = String(opts.teamId);
|
|
1124
|
-
const
|
|
1125
|
-
const teamDir = path.resolve(workspaceRoot, '..', `workspace-${teamId}`);
|
|
1122
|
+
const teamDir = resolveTeamDir(api, teamId);
|
|
1126
1123
|
const sharedContextDir = path.join(teamDir, 'shared-context');
|
|
1127
1124
|
const runsDir = path.join(sharedContextDir, 'workflow-runs');
|
|
1128
1125
|
const workflowsDir = path.join(sharedContextDir, 'workflows');
|
|
@@ -1304,8 +1301,7 @@ export async function runWorkflowOnce(api: OpenClawPluginApi, opts: {
|
|
|
1304
1301
|
trigger?: { kind: string; at?: string };
|
|
1305
1302
|
}) {
|
|
1306
1303
|
const teamId = String(opts.teamId);
|
|
1307
|
-
const
|
|
1308
|
-
const teamDir = path.resolve(workspaceRoot, '..', `workspace-${teamId}`);
|
|
1304
|
+
const teamDir = resolveTeamDir(api, teamId);
|
|
1309
1305
|
const sharedContextDir = path.join(teamDir, 'shared-context');
|
|
1310
1306
|
const workflowsDir = path.join(sharedContextDir, 'workflows');
|
|
1311
1307
|
const runsDir = path.join(sharedContextDir, 'workflow-runs');
|
|
@@ -1422,8 +1418,7 @@ export async function pollWorkflowApprovals(api: OpenClawPluginApi, opts: {
|
|
|
1422
1418
|
limit?: number;
|
|
1423
1419
|
}) {
|
|
1424
1420
|
const teamId = String(opts.teamId);
|
|
1425
|
-
const
|
|
1426
|
-
const teamDir = path.resolve(workspaceRoot, '..', `workspace-${teamId}`);
|
|
1421
|
+
const teamDir = resolveTeamDir(api, teamId);
|
|
1427
1422
|
const runsDir = path.join(teamDir, 'shared-context', 'workflow-runs');
|
|
1428
1423
|
|
|
1429
1424
|
if (!(await fileExists(runsDir))) {
|
|
@@ -1501,8 +1496,7 @@ export async function approveWorkflowRun(api: OpenClawPluginApi, opts: {
|
|
|
1501
1496
|
}) {
|
|
1502
1497
|
const teamId = String(opts.teamId);
|
|
1503
1498
|
const runId = String(opts.runId);
|
|
1504
|
-
const
|
|
1505
|
-
const teamDir = path.resolve(workspaceRoot, '..', `workspace-${teamId}`);
|
|
1499
|
+
const teamDir = resolveTeamDir(api, teamId);
|
|
1506
1500
|
|
|
1507
1501
|
const approvalPath = await approvalsPathFor(teamDir, runId);
|
|
1508
1502
|
if (!(await fileExists(approvalPath))) {
|
|
@@ -1527,8 +1521,7 @@ export async function resumeWorkflowRun(api: OpenClawPluginApi, opts: {
|
|
|
1527
1521
|
}) {
|
|
1528
1522
|
const teamId = String(opts.teamId);
|
|
1529
1523
|
const runId = String(opts.runId);
|
|
1530
|
-
const
|
|
1531
|
-
const teamDir = path.resolve(workspaceRoot, '..', `workspace-${teamId}`);
|
|
1524
|
+
const teamDir = resolveTeamDir(api, teamId);
|
|
1532
1525
|
const sharedContextDir = path.join(teamDir, 'shared-context');
|
|
1533
1526
|
const runsDir = path.join(sharedContextDir, 'workflow-runs');
|
|
1534
1527
|
const workflowsDir = path.join(sharedContextDir, 'workflows');
|
|
@@ -1559,10 +1552,9 @@ export async function resumeWorkflowRun(api: OpenClawPluginApi, opts: {
|
|
|
1559
1552
|
|
|
1560
1553
|
const ticketPath = path.join(teamDir, runLog.ticket.file);
|
|
1561
1554
|
|
|
1562
|
-
// Find the approval node index
|
|
1555
|
+
// Find the approval node index.
|
|
1563
1556
|
const approvalIdx = workflow.nodes.findIndex((n) => n.kind === 'human_approval' && String(n.id) === String(approval.nodeId));
|
|
1564
1557
|
if (approvalIdx < 0) throw new Error(`Approval node not found in workflow: nodeId=${approval.nodeId}`);
|
|
1565
|
-
const startNodeIndex = approvalIdx + 1;
|
|
1566
1558
|
|
|
1567
1559
|
if (approval.status === 'rejected') {
|
|
1568
1560
|
// Denial flow: mark run as needs_revision and loop back to the draft step (or closest prior llm node).
|
|
@@ -1571,9 +1563,6 @@ export async function resumeWorkflowRun(api: OpenClawPluginApi, opts: {
|
|
|
1571
1563
|
const approvalNote = String(approval.note ?? '').trim();
|
|
1572
1564
|
|
|
1573
1565
|
// Find a reasonable "revise" node: prefer a node with id=draft_assets, else the closest prior llm node.
|
|
1574
|
-
const approvalIdx = workflow.nodes.findIndex((n) => n.kind === 'human_approval' && String(n.id) === String(approval.nodeId));
|
|
1575
|
-
if (approvalIdx < 0) throw new Error(`Approval node not found in workflow: nodeId=${approval.nodeId}`);
|
|
1576
|
-
|
|
1577
1566
|
let reviseIdx = workflow.nodes.findIndex((n, idx) => idx < approvalIdx && String(n.id) === 'draft_assets');
|
|
1578
1567
|
if (reviseIdx < 0) {
|
|
1579
1568
|
for (let i = approvalIdx - 1; i >= 0; i--) {
|
|
@@ -1667,9 +1656,28 @@ export async function resumeWorkflowRun(api: OpenClawPluginApi, opts: {
|
|
|
1667
1656
|
: [...cur.events, { ts: approvedTs, type: 'node.approved', nodeId: approval.nodeId }],
|
|
1668
1657
|
}));
|
|
1669
1658
|
|
|
1670
|
-
// Pull-based execution: enqueue the next node and return.
|
|
1671
|
-
|
|
1672
|
-
|
|
1659
|
+
// Pull-based execution: enqueue the next runnable node and return.
|
|
1660
|
+
let updated = (await loadRunFile(teamDir, runsDir, runId)).run;
|
|
1661
|
+
let enqueueIdx = pickNextRunnableNodeIndex({ workflow, run: updated });
|
|
1662
|
+
|
|
1663
|
+
// Auto-complete start/end nodes.
|
|
1664
|
+
while (enqueueIdx !== null) {
|
|
1665
|
+
const n = workflow.nodes[enqueueIdx]!;
|
|
1666
|
+
const k = String(n.kind ?? '');
|
|
1667
|
+
if (k !== 'start' && k !== 'end') break;
|
|
1668
|
+
const ts = new Date().toISOString();
|
|
1669
|
+
await appendRunLog(runLogPath, (cur) => ({
|
|
1670
|
+
...cur,
|
|
1671
|
+
nextNodeIndex: enqueueIdx! + 1,
|
|
1672
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [n.id]: { status: 'success', ts } },
|
|
1673
|
+
events: [...cur.events, { ts, type: 'node.completed', nodeId: n.id, kind: k, noop: true }],
|
|
1674
|
+
nodeResults: [...(cur.nodeResults ?? []), { nodeId: n.id, kind: k, noop: true }],
|
|
1675
|
+
}));
|
|
1676
|
+
updated = (await loadRunFile(teamDir, runsDir, runId)).run;
|
|
1677
|
+
enqueueIdx = pickNextRunnableNodeIndex({ workflow, run: updated });
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
if (enqueueIdx === null) {
|
|
1673
1681
|
await writeRunFile(runLogPath, (cur) => ({
|
|
1674
1682
|
...cur,
|
|
1675
1683
|
updatedAt: new Date().toISOString(),
|
|
@@ -1679,9 +1687,10 @@ export async function resumeWorkflowRun(api: OpenClawPluginApi, opts: {
|
|
|
1679
1687
|
return { ok: true as const, runId, status: 'completed' as const, ticketPath, runLogPath };
|
|
1680
1688
|
}
|
|
1681
1689
|
|
|
1682
|
-
const node = workflow.nodes[
|
|
1690
|
+
const node = workflow.nodes[enqueueIdx]!;
|
|
1691
|
+
const nextKind = String(node.kind ?? '');
|
|
1683
1692
|
const nextAgentId = String(node?.assignedTo?.agentId ?? '').trim();
|
|
1684
|
-
if (!nextAgentId) throw new Error(`
|
|
1693
|
+
if (!nextAgentId) throw new Error(`Next runnable node ${node.id} (${nextKind}) missing assignedTo.agentId (required for pull-based execution)`);
|
|
1685
1694
|
|
|
1686
1695
|
await enqueueTask(teamDir, nextAgentId, {
|
|
1687
1696
|
teamId,
|
|
@@ -1694,7 +1703,7 @@ export async function resumeWorkflowRun(api: OpenClawPluginApi, opts: {
|
|
|
1694
1703
|
...cur,
|
|
1695
1704
|
updatedAt: new Date().toISOString(),
|
|
1696
1705
|
status: 'waiting_workers',
|
|
1697
|
-
nextNodeIndex:
|
|
1706
|
+
nextNodeIndex: enqueueIdx,
|
|
1698
1707
|
events: [...cur.events, { ts: new Date().toISOString(), type: 'node.enqueued', nodeId: node.id, agentId: nextAgentId }],
|
|
1699
1708
|
}));
|
|
1700
1709
|
|
|
@@ -1712,8 +1721,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1712
1721
|
if (!teamId) throw new Error('--team-id is required');
|
|
1713
1722
|
if (!agentId) throw new Error('--agent-id is required');
|
|
1714
1723
|
|
|
1715
|
-
const
|
|
1716
|
-
const teamDir = path.resolve(workspaceRoot, '..', `workspace-${teamId}`);
|
|
1724
|
+
const teamDir = resolveTeamDir(api, teamId);
|
|
1717
1725
|
const sharedContextDir = path.join(teamDir, 'shared-context');
|
|
1718
1726
|
const workflowsDir = path.join(sharedContextDir, 'workflows');
|
|
1719
1727
|
const runsDir = path.join(sharedContextDir, 'workflow-runs');
|
|
@@ -1728,56 +1736,60 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1728
1736
|
if (!dq.ok || !dq.task) break;
|
|
1729
1737
|
|
|
1730
1738
|
const { task } = dq.task;
|
|
1731
|
-
if (task.kind !== 'execute_node') continue;
|
|
1732
|
-
|
|
1733
1739
|
const runPath = runFilePathFor(runsDir, task.runId);
|
|
1734
1740
|
const runDir = path.dirname(runPath);
|
|
1735
1741
|
const lockDir = path.join(runDir, 'locks');
|
|
1736
|
-
await ensureDir(lockDir);
|
|
1737
|
-
|
|
1738
|
-
// Node-level lock to prevent double execution.
|
|
1739
1742
|
const lockPath = path.join(lockDir, `${task.nodeId}.lock`);
|
|
1743
|
+
let lockHeld = false;
|
|
1744
|
+
|
|
1740
1745
|
try {
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
+
if (task.kind !== 'execute_node') continue;
|
|
1747
|
+
|
|
1748
|
+
await ensureDir(lockDir);
|
|
1749
|
+
|
|
1750
|
+
// Node-level lock to prevent double execution.
|
|
1746
1751
|
try {
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
const claimedAtMs = parsed?.claimedAt ? Date.parse(String(parsed.claimedAt)) : NaN;
|
|
1750
|
-
const ageMs = Number.isFinite(claimedAtMs) ? Date.now() - claimedAtMs : NaN;
|
|
1751
|
-
const stale = Number.isFinite(ageMs) && ageMs > 10 * 60 * 1000;
|
|
1752
|
-
if (stale) {
|
|
1753
|
-
await fs.unlink(lockPath);
|
|
1754
|
-
unlocked = true;
|
|
1755
|
-
}
|
|
1752
|
+
await fs.writeFile(lockPath, JSON.stringify({ workerId, taskId: task.id, claimedAt: new Date().toISOString() }, null, 2), { encoding: 'utf8', flag: 'wx' });
|
|
1753
|
+
lockHeld = true;
|
|
1756
1754
|
} catch {
|
|
1757
|
-
//
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
if (unlocked) {
|
|
1755
|
+
// Lock exists. Treat it as contention unless it looks stale.
|
|
1756
|
+
// (If a worker crashed, the lock file can stick around and block retries/revisions forever.)
|
|
1757
|
+
let unlocked = false;
|
|
1761
1758
|
try {
|
|
1762
|
-
|
|
1759
|
+
const raw = await readTextFile(lockPath);
|
|
1760
|
+
const parsed = JSON.parse(raw) as { claimedAt?: string };
|
|
1761
|
+
const claimedAtMs = parsed?.claimedAt ? Date.parse(String(parsed.claimedAt)) : NaN;
|
|
1762
|
+
const ageMs = Number.isFinite(claimedAtMs) ? Date.now() - claimedAtMs : NaN;
|
|
1763
|
+
const stale = Number.isFinite(ageMs) && ageMs > 10 * 60 * 1000;
|
|
1764
|
+
if (stale) {
|
|
1765
|
+
await fs.unlink(lockPath);
|
|
1766
|
+
unlocked = true;
|
|
1767
|
+
}
|
|
1763
1768
|
} catch {
|
|
1769
|
+
// ignore
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
if (unlocked) {
|
|
1773
|
+
try {
|
|
1774
|
+
await fs.writeFile(lockPath, JSON.stringify({ workerId, taskId: task.id, claimedAt: new Date().toISOString() }, null, 2), { encoding: 'utf8', flag: 'wx' });
|
|
1775
|
+
lockHeld = true;
|
|
1776
|
+
} catch {
|
|
1777
|
+
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_locked' });
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
} else {
|
|
1781
|
+
// Requeue to avoid task loss since dequeueNextTask already advanced the queue cursor.
|
|
1782
|
+
await enqueueTask(teamDir, agentId, {
|
|
1783
|
+
teamId,
|
|
1784
|
+
runId: task.runId,
|
|
1785
|
+
nodeId: task.nodeId,
|
|
1786
|
+
kind: 'execute_node',
|
|
1787
|
+
});
|
|
1764
1788
|
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_locked' });
|
|
1765
1789
|
continue;
|
|
1766
1790
|
}
|
|
1767
|
-
} else {
|
|
1768
|
-
// Requeue to avoid task loss since dequeueNextTask already advanced the queue cursor.
|
|
1769
|
-
await enqueueTask(teamDir, agentId, {
|
|
1770
|
-
teamId,
|
|
1771
|
-
runId: task.runId,
|
|
1772
|
-
nodeId: task.nodeId,
|
|
1773
|
-
kind: 'execute_node',
|
|
1774
|
-
});
|
|
1775
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_locked' });
|
|
1776
|
-
continue;
|
|
1777
1791
|
}
|
|
1778
|
-
}
|
|
1779
1792
|
|
|
1780
|
-
try {
|
|
1781
1793
|
const runId = task.runId;
|
|
1782
1794
|
|
|
1783
1795
|
const { run } = await loadRunFile(teamDir, runsDir, runId);
|
|
@@ -1790,6 +1802,25 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1790
1802
|
if (nodeIdx < 0) throw new Error(`Node not found in workflow: ${task.nodeId}`);
|
|
1791
1803
|
const node = workflow.nodes[nodeIdx]!;
|
|
1792
1804
|
|
|
1805
|
+
// Stale-task guard: expired claim recovery can surface older queue entries from behind the
|
|
1806
|
+
// cursor. Before executing a dequeued task, verify that this node is still actually runnable
|
|
1807
|
+
// for the current run state. Otherwise we can resurrect pre-approval work and overwrite
|
|
1808
|
+
// canonical node outputs for runs that already advanced.
|
|
1809
|
+
const currentRun = (await loadRunFile(teamDir, runsDir, task.runId)).run;
|
|
1810
|
+
const currentNodeStates = loadNodeStatesFromRun(currentRun);
|
|
1811
|
+
const currentStatus = currentNodeStates[String(node.id)]?.status;
|
|
1812
|
+
const currentlyRunnableIdx = pickNextRunnableNodeIndex({ workflow, run: currentRun });
|
|
1813
|
+
if (
|
|
1814
|
+
currentStatus === 'success' ||
|
|
1815
|
+
currentStatus === 'error' ||
|
|
1816
|
+
currentStatus === 'waiting' ||
|
|
1817
|
+
currentlyRunnableIdx === null ||
|
|
1818
|
+
String(workflow.nodes[currentlyRunnableIdx]?.id ?? '') !== String(node.id)
|
|
1819
|
+
) {
|
|
1820
|
+
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_stale' });
|
|
1821
|
+
continue;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1793
1824
|
// Determine current lane + ticket path.
|
|
1794
1825
|
const laneRaw = String(run.ticket.lane);
|
|
1795
1826
|
assertLane(laneRaw);
|
|
@@ -2069,62 +2100,11 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
2069
2100
|
|
|
2070
2101
|
|
|
2071
2102
|
} else if (toolName === 'marketing.post_all') {
|
|
2072
|
-
//
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
const platforms = Array.isArray((toolArgs as Record<string, unknown>)?.['platforms'])
|
|
2078
|
-
? (((toolArgs as Record<string, unknown>)['platforms'] as unknown[]) ?? []).map(String)
|
|
2079
|
-
: ['x'];
|
|
2080
|
-
if (!platforms.includes('x')) {
|
|
2081
|
-
throw new Error('marketing.post_all currently supports X-only on this controller.');
|
|
2082
|
-
}
|
|
2083
|
-
|
|
2084
|
-
const nodeOutputsDir = path.join(runDir, 'node-outputs');
|
|
2085
|
-
const draftsFromNode = String((toolArgs as Record<string, unknown>)?.['draftsFromNode'] ?? '').trim() || 'qc_brand';
|
|
2086
|
-
let text = await loadProposedPostTextFromPriorNode({ runDir, nodeOutputsDir, priorNodeId: draftsFromNode });
|
|
2087
|
-
if (!text?.trim()) throw new Error('marketing.post_all: missing draft text');
|
|
2088
|
-
|
|
2089
|
-
const dryRun = Boolean((toolArgs as Record<string, unknown>)?.['dryRun']);
|
|
2090
|
-
|
|
2091
|
-
// Never publish internal draft/instruction/disclaimer copy.
|
|
2092
|
-
text = text
|
|
2093
|
-
.split(/\r?\n/)
|
|
2094
|
-
.filter((line) => !/draft\s*only/i.test(line))
|
|
2095
|
-
.filter((line) => !/do\s+not\s+post\s+without\s+approval/i.test(line))
|
|
2096
|
-
.filter((line) => !/clawrecipes\s+before\s+openclaw/i.test(line))
|
|
2097
|
-
.filter((line) => !/nothing\s+posts\s+without\s+approval/i.test(line))
|
|
2098
|
-
.join('\n')
|
|
2099
|
-
.trim();
|
|
2100
|
-
|
|
2101
|
-
if (dryRun) {
|
|
2102
|
-
const logRel = path.join('shared-context', 'marketing', 'POST_LOG.md');
|
|
2103
|
-
const logAbs = path.join(teamDir, logRel);
|
|
2104
|
-
await ensureDir(path.dirname(logAbs));
|
|
2105
|
-
await fs.appendFile(
|
|
2106
|
-
logAbs,
|
|
2107
|
-
`- ${new Date().toISOString()} [DRY_RUN] run=${runId} node=${node.id} tool=${toolName} platforms=x\n - text: ${JSON.stringify(text)}\n`,
|
|
2108
|
-
'utf8',
|
|
2109
|
-
);
|
|
2110
|
-
|
|
2111
|
-
const result = { dryRun: true, platformsWouldPost: ['x'], draftText: text, logPath: logRel };
|
|
2112
|
-
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2) + '\n', 'utf8');
|
|
2113
|
-
} else {
|
|
2114
|
-
const who = await execFileP('xurl', ['whoami'], { timeout: 60_000, env: { ...process.env, NO_COLOR: '1' } });
|
|
2115
|
-
const whoJson = JSON.parse(String((who as { stdout?: string }).stdout ?? ''));
|
|
2116
|
-
const username = String((whoJson as { data?: { username?: string } })?.data?.username ?? '').trim();
|
|
2117
|
-
if (!username) throw new Error('marketing.post_all: could not resolve X username');
|
|
2118
|
-
|
|
2119
|
-
const postRes = await execFileP('xurl', ['post', text], { timeout: 60_000, env: { ...process.env, NO_COLOR: '1' } });
|
|
2120
|
-
const postJson = JSON.parse(String((postRes as { stdout?: string }).stdout ?? ''));
|
|
2121
|
-
const postId = String((postJson as { data?: { id?: string } })?.data?.id ?? '').trim();
|
|
2122
|
-
if (!postId) throw new Error('marketing.post_all: xurl post did not return an id');
|
|
2123
|
-
|
|
2124
|
-
const url = `https://x.com/${username}/status/${postId}`;
|
|
2125
|
-
const result = { platformsPosted: ['x'], x: { postId, url, username } };
|
|
2126
|
-
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2) + '\n', 'utf8');
|
|
2127
|
-
}
|
|
2103
|
+
// Disabled by default: do not ship plugins that spawn local processes for posting.
|
|
2104
|
+
// Use an approval-gated workflow node that calls a dedicated posting tool/plugin instead.
|
|
2105
|
+
throw new Error(
|
|
2106
|
+
'marketing.post_all is disabled in this build (install safety). Use an external posting tool/plugin (approval-gated) instead.'
|
|
2107
|
+
);
|
|
2128
2108
|
} else {
|
|
2129
2109
|
const toolRes = await toolsInvoke<unknown>(api, {
|
|
2130
2110
|
tool: toolName,
|
|
@@ -2257,10 +2237,17 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
2257
2237
|
events: [...cur.events, { ts: new Date().toISOString(), type: 'node.enqueued', nodeId: nextNode.id, agentId: nextAgentId }],
|
|
2258
2238
|
}));
|
|
2259
2239
|
|
|
2260
|
-
|
|
2240
|
+
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'ok' });
|
|
2261
2241
|
} finally {
|
|
2242
|
+
if (lockHeld) {
|
|
2243
|
+
try {
|
|
2244
|
+
await fs.unlink(lockPath);
|
|
2245
|
+
} catch {
|
|
2246
|
+
// ignore
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2262
2249
|
try {
|
|
2263
|
-
await
|
|
2250
|
+
await releaseTaskClaim(teamDir, agentId, task.id);
|
|
2264
2251
|
} catch {
|
|
2265
2252
|
// ignore
|
|
2266
2253
|
}
|
package/src/lib/workspace.ts
CHANGED
|
@@ -22,6 +22,26 @@ export function resolveWorkspaceRoot(api: OpenClawPluginApi): string {
|
|
|
22
22
|
return path.join(os.homedir(), ".openclaw", "workspace");
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the canonical OpenClaw workspace root even when the current agent workspace
|
|
27
|
+
* is nested under `workspace-<teamId>/roles/<role>`.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveCanonicalWorkspaceRoot(api: OpenClawPluginApi): string {
|
|
30
|
+
const candidate = api.config.agents?.defaults?.workspace;
|
|
31
|
+
if (candidate) {
|
|
32
|
+
const abs = path.resolve(candidate);
|
|
33
|
+
const parts = abs.split(path.sep).filter(Boolean);
|
|
34
|
+
const idx = [...parts].reverse().findIndex((p) => p.startsWith('workspace-'));
|
|
35
|
+
if (idx >= 0) {
|
|
36
|
+
const segIdx = parts.length - 1 - idx;
|
|
37
|
+
const teamDir = path.isAbsolute(abs) ? path.sep + path.join(...parts.slice(0, segIdx + 1)) : path.join(...parts.slice(0, segIdx + 1));
|
|
38
|
+
return path.resolve(teamDir, '..', 'workspace');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return resolveWorkspaceRoot(api);
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
function tryResolveTeamDirFromAnyDir(dir: string, teamId: string): string | undefined {
|
|
26
46
|
const seg = `workspace-${teamId}`;
|
|
27
47
|
const abs = path.resolve(dir);
|