@gholl-studio/pier-connector 0.2.6 → 0.2.9

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gholl-studio/pier-connector",
3
3
  "author": "gholl",
4
- "version": "0.2.6",
4
+ "version": "0.2.9",
5
5
  "description": "OpenClaw plugin that connects to the Pier job marketplace. Automatically fetches, executes, and reports distributed tasks for rewards.",
6
6
  "type": "module",
7
7
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -46,6 +46,12 @@ export default function register(api) {
46
46
  const jobStopTimeouts = new Map();
47
47
  const activeNodeJobs = new Map();
48
48
 
49
+ /**
50
+ * Anti-Interference Flag (BUG-34)
51
+ * When true, the robot will ignore new marketplace jobs.
52
+ */
53
+ let isBusy = false;
54
+
49
55
  let jobsReceived = 0;
50
56
  let jobsCompleted = 0;
51
57
  let jobsFailed = 0;
@@ -127,6 +133,39 @@ export default function register(api) {
127
133
  }
128
134
  }
129
135
 
136
+ /**
137
+ * Atomically claims a job from the backend before starting execution.
138
+ * Ensures "one-job-at-a-time" exclusivity.
139
+ */
140
+ async function claimJob(jobId) {
141
+ const config = getActiveConfig();
142
+ try {
143
+ const resp = await fetch(`${config.pierApiUrl}/jobs/${jobId}/claim`, {
144
+ method: 'POST',
145
+ headers: {
146
+ 'Content-Type': 'application/json',
147
+ 'Authorization': `Bearer ${config.secretKey}`,
148
+ }
149
+ });
150
+
151
+ if (!resp.ok) {
152
+ if (resp.status === 409) {
153
+ logger.warn(`[pier-connector] 🚫 Job ${jobId} was already claimed by someone else.`);
154
+ } else {
155
+ const errData = await resp.json().catch(() => ({}));
156
+ logger.error(`[pier-connector] ✖ Failed to claim job ${jobId}: ${errData.error || resp.statusText}`);
157
+ }
158
+ return false;
159
+ }
160
+
161
+ logger.info(`[pier-connector] ✅ Successfully claimed job ${jobId}`);
162
+ return true;
163
+ } catch (err) {
164
+ logger.error(`[pier-connector] ✖ Network error claiming job ${jobId}: ${err.message}`);
165
+ return false;
166
+ }
167
+ }
168
+
130
169
  /**
131
170
  * Routes an incoming message from Pier to the OpenClaw agent.
132
171
  * Replaces the non-existent api.runtime.sendIncoming.
@@ -477,24 +516,26 @@ export default function register(api) {
477
516
  // 5. Subscribe to Subjects
478
517
  const publicSubject = config.subject;
479
518
  const privateSubject = `jobs.node.${config.nodeId}`;
480
-
481
519
  // 5a. Restore active job subscriptions (BUG-32)
482
520
  try {
483
- logger.info(`[pier-connector] 🔍 Checking for active jobs to restore for node ${config.nodeId}...`);
484
- const jobsResp = await fetch(`${config.apiBase}/jobs?assigned_node_id=${config.nodeId}&limit=50`);
485
- if (jobsResp.ok) {
486
- const jobsList = await jobsResp.json();
487
- const activeJobs = (jobsList || []).filter(j => j.status === 'PENDING' || j.status === 'PROCESSING');
488
- if (activeJobs.length > 0) {
489
- logger.info(`[pier-connector] ♻️ Restoring ${activeJobs.length} active job subscriptions...`);
490
- for (const job of activeJobs) {
491
- if (!jobSubscriptions.has(job.id)) {
492
- activeNodeJobs.set(job.id, { pierJobId: job.id });
493
- await subscribeToJobMessages(job.id);
521
+ const activeConfig = getActiveConfig();
522
+ if (activeConfig.apiBase) {
523
+ logger.info(`[pier-connector] 🔍 Checking for active jobs to restore for node ${config.nodeId}...`);
524
+ const jobsResp = await fetch(`${activeConfig.apiBase}/jobs?assigned_node_id=${config.nodeId}&limit=50`);
525
+ if (jobsResp.ok) {
526
+ const jobsList = await jobsResp.json();
527
+ const activeJobs = (jobsList || []).filter(j => j.status === 'PENDING' || j.status === 'PROCESSING');
528
+ if (activeJobs.length > 0) {
529
+ logger.info(`[pier-connector] ♻️ Restoring ${activeJobs.length} active job subscriptions...`);
530
+ for (const job of activeJobs) {
531
+ if (!jobSubscriptions.has(job.id)) {
532
+ activeNodeJobs.set(job.id, { pierJobId: job.id });
533
+ await subscribeToJobMessages(job.id);
534
+ }
494
535
  }
536
+ } else {
537
+ logger.info('[pier-connector] No active jobs found to restore.');
495
538
  }
496
- } else {
497
- logger.info('[pier-connector] No active jobs found to restore.');
498
539
  }
499
540
  }
500
541
  } catch (err) {
@@ -617,14 +658,20 @@ export default function register(api) {
617
658
  // Use a unique durable name per node and job to avoid replaying acknowledged messages
618
659
  const durableName = `pier_chat_${config.nodeId.replace(/[^a-zA-Z0-9]/g, '_')}_${jobId.replace(/[^a-zA-Z0-9]/g, '_')}`;
619
660
 
620
- const cInfo = await jsm.consumers.add(streamName, {
621
- durable_name: durableName,
622
- filter_subject: msgSubject,
623
- deliver_policy: DeliverPolicy.New, // Reverted to New to stop message storms (BUG-27)
624
- ack_policy: AckPolicy.Explicit, // Explicit ACK is crucial for deduplication
625
- ack_wait: 1000 * 60 * 60, // 1 hour ACK wait to allow for long processing (BUG-30)
626
- });
627
- const consumer = await js.consumers.get(streamName, cInfo.name);
661
+ let consumer;
662
+ try {
663
+ consumer = await js.consumers.get(streamName, durableName);
664
+ } catch (err) {
665
+ // Only add if it doesn't exist
666
+ await jsm.consumers.add(streamName, {
667
+ durable_name: durableName,
668
+ filter_subject: msgSubject,
669
+ deliver_policy: DeliverPolicy.New, // Reverted to New to stop message storms (BUG-27)
670
+ ack_policy: AckPolicy.Explicit, // Explicit ACK is crucial for deduplication
671
+ ack_wait: 1000 * 60 * 60, // 1 hour ACK wait to allow for long processing (BUG-30)
672
+ });
673
+ consumer = await js.consumers.get(streamName, durableName);
674
+ }
628
675
 
629
676
  const iter = await consumer.consume();
630
677
  jobSubscriptions.set(jobId, iter);
@@ -756,6 +803,13 @@ export default function register(api) {
756
803
  return;
757
804
  }
758
805
 
806
+ // Anti-Interference: If busy, ignore new marketplace jobs (BUG-34)
807
+ if (isBusy) {
808
+ // NAK so others in the queue group can take it
809
+ msg.nak();
810
+ return;
811
+ }
812
+
759
813
  // V1.1 FEATURE: Task Poisoning / Poaching Protection
760
814
  if (payload.assigned_node_id && payload.assigned_node_id !== config.nodeId) {
761
815
  // Not for us, nak it so others can take it
@@ -763,17 +817,30 @@ export default function register(api) {
763
817
  return;
764
818
  }
765
819
 
766
- // ACK the job early to 'claim' it and prevent redelivery while we process
820
+ // For Marketplace jobs (those without assigned_node_id, or even those with it),
821
+ // we must ATOMICALLY CLAIM it from the backend before setting isBusy.
822
+ const jobIdToClaim = payload.id;
823
+ if (jobIdToClaim) {
824
+ const success = await claimJob(jobIdToClaim);
825
+ if (!success) {
826
+ // Already taken or error, NAK it just in case someone else can try
827
+ msg.nak();
828
+ return;
829
+ }
830
+ }
831
+
832
+ // Claimed successfully! Set busy state
833
+ isBusy = true;
767
834
  msg.ack();
768
835
 
769
836
  jobsReceived++;
770
837
  const parsed = parseJob(msg, logger);
771
838
  if (!parsed.ok) {
772
839
  jobsFailed++;
840
+ isBusy = false; // Reset busy state on parse error
773
841
  logger.error(`[pier-connector] Job parse error: ${parsed.error}`);
774
- // Since we already acked, we must send an error result back to Pier
775
842
  safeRespond(msg, createErrorPayload({
776
- id: 'unknown',
843
+ id: jobIdToClaim || 'unknown',
777
844
  errorCode: 'PARSE_ERROR',
778
845
  errorMessage: parsed.error,
779
846
  workerId: config.nodeId,
@@ -802,17 +869,24 @@ export default function register(api) {
802
869
  text: job.task,
803
870
  };
804
871
 
805
- // SUBSCRIBE to job-specific messages FIRST so we don't miss follow-ups during the agent run
806
- // This handles the user's "can only receive one message" issue
872
+ // SUBSCRIBE to job-specific messages FIRST so we don't miss follow-ups
807
873
  subscribeToJobMessages(job.id);
808
874
 
809
875
  // Trigger agent run
810
876
  await receiveIncoming(inbound, job.id);
811
877
 
878
+ // NOTE: isBusy is reset when the agent completes.
879
+ // Because receiveIncoming (via OpenClaw core) might be async or callback-based,
880
+ // we need to be careful. However, for standard MCP tools/agents,
881
+ // we'll assume isBusy should stay true until the job lifecycle is managed.
882
+ // For now, simple reset after agent returns or in error.
883
+ isBusy = false;
884
+
812
885
  } catch (err) {
886
+ isBusy = false;
813
887
  jobsFailed++;
814
888
  safeRespond(msg, createErrorPayload({
815
- id: job.id,
889
+ id: (job && job.id) || 'unknown',
816
890
  errorCode: 'EXECUTION_FAILED',
817
891
  errorMessage: err.message,
818
892
  workerId: config.nodeId,
@@ -831,6 +905,7 @@ export default function register(api) {
831
905
  logger.info('[pier-connector] NATS connection closed gracefully');
832
906
  }
833
907
  });
908
+
834
909
  } catch (err) {
835
910
  connectionStatus = 'error';
836
911
  if (heartbeatTimer) clearInterval(heartbeatTimer);
@@ -843,13 +918,10 @@ export default function register(api) {
843
918
  logger.info('[pier-connector] 🛑 Stopping background service …');
844
919
  connectionStatus = 'stopping';
845
920
 
846
- if (subscription) {
847
- subscription.unsubscribe();
848
- subscription = null;
921
+ if (nc) {
922
+ await drainConnection(nc, logger);
923
+ nc = null;
849
924
  }
850
-
851
- await drainConnection(nc, logger);
852
- nc = null;
853
925
  connectionStatus = 'disconnected';
854
926
 
855
927
  logger.info('[pier-connector] ✔ Background service stopped');
@@ -879,6 +951,10 @@ export default function register(api) {
879
951
  type: 'number',
880
952
  description: 'Timeout in milliseconds to wait for a result (default 60000)',
881
953
  },
954
+ parentJobId: {
955
+ type: 'string',
956
+ description: 'Optional ID of the parent job triggering this sub-task (for delegation)',
957
+ },
882
958
  },
883
959
  required: ['task'],
884
960
  },
@@ -905,6 +981,7 @@ export default function register(api) {
905
981
  task: params.task,
906
982
  timeoutMs: timeout,
907
983
  meta: params.meta,
984
+ parentJobId: params.parentJobId,
908
985
  });
909
986
 
910
987
  try {
package/src/protocol.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Standardizes outbound and inbound JSON boundaries for NATS.
5
5
  */
6
6
 
7
- export const PROTOCOL_VERSION = '1.0';
7
+ export const PROTOCOL_VERSION = '1.1';
8
8
 
9
9
  /**
10
10
  * Creates a standard task request payload to send to Pier.
@@ -15,18 +15,20 @@ export const PROTOCOL_VERSION = '1.0';
15
15
  * @param {number} [params.timeoutMs]
16
16
  * @param {object} [params.meta]
17
17
  * @param {string} [params.targetNodeId]
18
+ * @param {string} [params.parentJobId]
18
19
  * @returns {object}
19
20
  */
20
- export function createRequestPayload({ task, systemPrompt, timeoutMs, meta, targetNodeId }) {
21
+ export function createRequestPayload({ task, systemPrompt, timeoutMs, meta, targetNodeId, parentJobId }) {
21
22
  return {
22
23
  version: PROTOCOL_VERSION,
23
24
  id: crypto.randomUUID(),
24
- type: 'task_request',
25
+ type: 'task', // Changed from task_request to task to align with backend models
25
26
  task,
26
27
  systemPrompt,
27
28
  timeoutMs: timeoutMs || 60000,
28
29
  meta: meta || {},
29
30
  targetNodeId: targetNodeId || null,
31
+ parentJobId: parentJobId || null,
30
32
  };
31
33
  }
32
34
 
@@ -91,10 +93,10 @@ export function createErrorPayload({ id, errorCode, errorMessage, workerId, wall
91
93
  * @returns {{ ok: true, job: object } | { ok: false, error: string }}
92
94
  */
93
95
  export function normalizeInboundPayload(payload) {
94
- // If it's a strict v1.0 protocol request
95
- if (payload.type === 'task_request') {
96
+ // If it's a strict v1.1+ protocol request
97
+ if (payload.type === 'task' || payload.type === 'task_request') {
96
98
  if (!payload.task) {
97
- return { ok: false, error: 'Missing "task" field in task_request payload' };
99
+ return { ok: false, error: 'Missing "task" field in task payload' };
98
100
  }
99
101
  return {
100
102
  ok: true,
@@ -104,6 +106,7 @@ export function normalizeInboundPayload(payload) {
104
106
  systemPrompt: payload.systemPrompt,
105
107
  meta: payload.meta || {},
106
108
  targetNodeId: payload.targetNodeId || null,
109
+ parentJobId: payload.parentJobId || null,
107
110
  },
108
111
  };
109
112
  }