@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.
@@ -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 >= stat.size) {
157
- return { ok: true as const, task: null as DequeuedTask | null, message: 'No new tasks.' };
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
- const toRead = Math.min(stat.size - st.offsetBytes, 256 * 1024);
161
- const buf = Buffer.alloc(toRead);
162
- const { bytesRead } = await fh.read(buf, 0, toRead, st.offsetBytes);
163
- const chunk = buf.subarray(0, bytesRead).toString('utf8');
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
- const lines = chunk.split('\n');
166
- const fullLines = lines.slice(0, -1);
167
- let cursor = st.offsetBytes;
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
- for (const line of fullLines) {
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
- if (!t || !t.id || !t.runId || !t.nodeId) {
190
- await writeState(teamDir, agentId, { offsetBytes: cursor, updatedAt: new Date().toISOString() });
191
- continue;
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 full line available yet.' };
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 { resolveWorkspaceRoot } from '../workspace';
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 workspaceRoot = resolveWorkspaceRoot(api);
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 workspaceRoot = resolveWorkspaceRoot(api);
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 workspaceRoot = resolveWorkspaceRoot(api);
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 workspaceRoot = resolveWorkspaceRoot(api);
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 workspaceRoot = resolveWorkspaceRoot(api);
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 workspaceRoot = resolveWorkspaceRoot(api);
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 workspaceRoot = resolveWorkspaceRoot(api);
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; resume after it.
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
- const idx0 = Math.max(0, Number(startNodeIndex ?? 0));
1672
- if (idx0 >= (workflow.nodes?.length ?? 0)) {
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[idx0]!;
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(`Node ${node.id} missing assignedTo.agentId (required for pull-based execution)`);
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: idx0,
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 workspaceRoot = resolveWorkspaceRoot(api);
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
- await fs.writeFile(lockPath, JSON.stringify({ workerId, taskId: task.id, claimedAt: new Date().toISOString() }, null, 2), { encoding: 'utf8', flag: 'wx' });
1742
- } catch {
1743
- // Lock exists. Treat it as contention unless it looks stale.
1744
- // (If a worker crashed, the lock file can stick around and block retries/revisions forever.)
1745
- let unlocked = false;
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
- const raw = await readTextFile(lockPath);
1748
- const parsed = JSON.parse(raw) as { claimedAt?: string };
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
- // ignore
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
- await fs.writeFile(lockPath, JSON.stringify({ workerId, taskId: task.id, claimedAt: new Date().toISOString() }, null, 2), { encoding: 'utf8', flag: 'wx' });
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
- // Local-only controller patch for RJ: re-enable X posting via xurl. Supports args.dryRun=true.
2073
- const { execFile } = await import('node:child_process');
2074
- const { promisify } = await import('node:util');
2075
- const execFileP = promisify(execFile);
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
- results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'ok' });
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 fs.unlink(lockPath);
2250
+ await releaseTaskClaim(teamDir, agentId, task.id);
2264
2251
  } catch {
2265
2252
  // ignore
2266
2253
  }
@@ -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);