@agentrux/agentrux-openclaw-plugin 0.3.5 → 0.3.7

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.
@@ -12,6 +12,8 @@ export interface DispatcherConfig {
12
12
  maxConcurrency: number;
13
13
  subagentTimeoutMs: number;
14
14
  gatewayPort: number;
15
+ /** Publish "processing" status to result topic before dispatch. Debug/dev only. */
16
+ publishProcessingStatus?: boolean;
15
17
  }
16
18
  export declare class Dispatcher {
17
19
  private config;
@@ -23,7 +25,6 @@ export declare class Dispatcher {
23
25
  private stopped;
24
26
  private processedRequestIds;
25
27
  private processingSeqs;
26
- private statusPublished;
27
28
  constructor(config: DispatcherConfig, creds: Credentials, cursor: CursorState, queue: BoundedQueue, logger: {
28
29
  info: (...a: any[]) => void;
29
30
  error: (...a: any[]) => void;
@@ -57,36 +57,24 @@ class Dispatcher {
57
57
  // In-memory dedup: prevents same seq/request from being processed twice
58
58
  this.processedRequestIds = new Set();
59
59
  this.processingSeqs = new Set();
60
- // Status publish tracking: only publish "processing" once per request
61
- this.statusPublished = new Set();
62
60
  }
63
61
  async start() {
64
62
  if (this.running)
65
63
  throw new Error("Dispatcher already running");
66
64
  this.running = true;
67
65
  this.stopped = false;
68
- // Crash recovery: re-enqueue inFlight seqs from previous run
69
- // NOTE: do NOT clear inFlight here they stay in-flight until processEvent
70
- // completes and the outbox flush finalizes them via markCompleted.
66
+ // Crash recovery: clear ALL inFlight seqs by marking them completed.
67
+ // Rationale: Dispatcher is a part, not an orchestrator. If a crash interrupted
68
+ // processing, we drop those events rather than risk an infinite restart cycle.
69
+ // The alternative (re-enqueueing) can loop forever if processEvent's dedup
70
+ // (processedEvents/findByRequestId) blocks the re-enqueued event before
71
+ // markCompleted is reached, leaving inFlight permanently dirty.
71
72
  if (this.cursor.inFlight.size > 0) {
72
- this.logger.info(`Crash recovery: re-enqueueing ${this.cursor.inFlight.size} in-flight seqs`);
73
- const recovered = [];
74
- for (const seq of this.cursor.inFlight) {
75
- const ok = this.queue.enqueue({
76
- topicId: this.config.commandTopicId,
77
- latestSequenceNo: seq,
78
- timestamp: Date.now(),
79
- });
80
- if (ok)
81
- recovered.push(seq);
82
- }
83
- // Only remove from inFlight the ones we failed to enqueue (queue full)
84
- // The successfully enqueued ones will be cleared by processEvent → markCompleted
85
- for (const seq of this.cursor.inFlight) {
86
- if (!recovered.includes(seq)) {
87
- this.logger.warn(`Crash recovery: could not re-enqueue seq=${seq} (queue full)`);
88
- }
73
+ this.logger.warn(`Crash recovery: clearing ${this.cursor.inFlight.size} stale in-flight seqs`);
74
+ for (const seq of [...this.cursor.inFlight]) {
75
+ (0, cursor_1.markCompleted)(this.cursor, seq);
89
76
  }
77
+ this.logger.info("Crash recovery: all stale in-flight seqs cleared");
90
78
  }
91
79
  this.loop();
92
80
  }
@@ -112,17 +100,42 @@ class Dispatcher {
112
100
  async tick() {
113
101
  // 1. Dequeue hints
114
102
  const hints = this.queue.dequeue(this.config.maxConcurrency);
115
- // 2. Pull full events for hints
116
- if (hints.length > 0) {
117
- const events = await (0, http_client_1.pullEvents)(this.creds, this.config.commandTopicId, this.cursor.waterline, 50);
118
- for (const evt of events) {
119
- await this.processEvent(evt);
103
+ // 2. Pull + process events (errors must NOT prevent step 3 outbox flush)
104
+ try {
105
+ if (hints.length > 0) {
106
+ const events = await (0, http_client_1.pullEvents)(this.creds, this.config.commandTopicId, this.cursor.waterline, 50);
107
+ for (const evt of events) {
108
+ try {
109
+ await this.processEvent(evt);
110
+ }
111
+ catch (e) {
112
+ // Safety net: force-complete to prevent waterline stall
113
+ const seq = evt.sequence_no;
114
+ this.logger.error(`processEvent unexpected error, force-completing seq=${seq}: ${e.message}`);
115
+ try {
116
+ (0, cursor_1.markCompleted)(this.cursor, seq);
117
+ }
118
+ catch { }
119
+ this.processingSeqs.delete(seq);
120
+ }
121
+ }
120
122
  }
121
123
  }
122
- // 3. Flush outbox (always, regardless of hints)
123
- const finalized = await (0, outbox_1.flushOutbox)(this.creds, this.config.resultTopicId, this.logger);
124
- for (const seq of finalized) {
125
- (0, cursor_1.markCompleted)(this.cursor, seq);
124
+ catch (e) {
125
+ // pullEvents failed (network error, auth error, etc.)
126
+ // Log but continue to step 3 — outbox flush must always run
127
+ this.logger.error(`Pull failed: ${e.message}`);
128
+ }
129
+ // 3. Flush outbox — MUST always run, even if pull/processEvent failed above.
130
+ // This is the only path that finalizes outboxed seqs → markCompleted → waterline advance.
131
+ try {
132
+ const finalized = await (0, outbox_1.flushOutbox)(this.creds, this.config.resultTopicId, this.logger);
133
+ for (const seq of finalized) {
134
+ (0, cursor_1.markCompleted)(this.cursor, seq);
135
+ }
136
+ }
137
+ catch (e) {
138
+ this.logger.error(`Outbox flush failed: ${e.message}`);
126
139
  }
127
140
  // 4. Periodic cleanup (every ~50 ticks)
128
141
  if (Math.random() < 0.02) {
@@ -132,9 +145,6 @@ class Dispatcher {
132
145
  if (this.processedRequestIds.size > 10_000) {
133
146
  this.processedRequestIds.clear();
134
147
  }
135
- if (this.statusPublished.size > 10_000) {
136
- this.statusPublished.clear();
137
- }
138
148
  }
139
149
  }
140
150
  async processEvent(evt) {
@@ -151,8 +161,13 @@ class Dispatcher {
151
161
  // but allow re-processing for crash recovery (inFlight from previous run)
152
162
  if (this.cursor.inFlight.has(seq) && this.processingSeqs.has(seq))
153
163
  return;
154
- if ((0, cursor_1.isEventProcessed)(this.cursor, eventId))
164
+ if ((0, cursor_1.isEventProcessed)(this.cursor, eventId)) {
165
+ // Event already processed — markCompleted to prevent waterline stall.
166
+ // Without this, a seq stuck at waterline+1 with processedEvents=true
167
+ // would block all subsequent events forever.
168
+ (0, cursor_1.markCompleted)(this.cursor, seq);
155
169
  return;
170
+ }
156
171
  const payload = evt.payload || {};
157
172
  const requestId = payload.request_id || eventId;
158
173
  // ---- DEDUP LAYER 2: Application (request_id) ----
@@ -180,9 +195,8 @@ class Dispatcher {
180
195
  const wrappedMessage = (0, sanitize_1.wrapMessage)(messageText);
181
196
  const idempotencyKey = crypto.randomUUID();
182
197
  this.logger.info(`Processing: seq=${seq} requestId=${requestId}`);
183
- // Publish "processing" status ONCE per request
184
- if (!this.statusPublished.has(requestId)) {
185
- this.statusPublished.add(requestId);
198
+ // Optional: publish "processing" status (debug/dev only)
199
+ if (this.config.publishProcessingStatus) {
186
200
  try {
187
201
  await (0, http_client_1.publishEvent)(this.creds, this.config.resultTopicId, "openclaw.status", {
188
202
  request_id: requestId,
@@ -209,7 +223,7 @@ class Dispatcher {
209
223
  requestId,
210
224
  sequenceNo: seq,
211
225
  result: {
212
- message: (0, sanitize_1.sanitizeResponse)(responseText || "No response from agent"),
226
+ message: (0, sanitize_1.sanitizeResponse)(responseText || dispatchResult.error || ""),
213
227
  status: dispatchResult.status === "ok" ? "completed" : "failed",
214
228
  conversationKey,
215
229
  ...(attachments.length > 0 ? { attachments } : {}),
package/dist/index.js CHANGED
@@ -286,61 +286,56 @@ function default_1(api) {
286
286
  // =======================================================================
287
287
  if (api.registrationMode !== "full")
288
288
  return;
289
- // Prevent multiple ingress starts (plugin loaded multiple times by OpenClaw)
289
+ // Guard: prevent duplicate startup. Set to true only after successful start.
290
290
  if (globalThis.__agentruxIngressStarted)
291
291
  return;
292
- globalThis.__agentruxIngressStarted = true;
293
- // Clear token cache on startup to pick up latest grant scopes
294
- (0, http_client_1.invalidateToken)();
295
292
  const commandTopicId = pluginConfig.commandTopicId;
296
293
  const resultTopicId = pluginConfig.resultTopicId;
297
294
  const webhookSecret = pluginConfig.webhookSecret;
298
295
  const agentId = pluginConfig.agentId;
299
- // Skip ingress if not configured
296
+ const ingressMode = pluginConfig.ingressMode || "webhook";
300
297
  if (!commandTopicId || !resultTopicId || !agentId) {
301
- logger.info?.("[agentrux] Ingress not configured (missing commandTopicId/resultTopicId/agentId). Tools-only mode.");
302
- return;
303
- }
304
- const creds = (0, credentials_1.loadCredentials)();
305
- if (!creds) {
306
- logger.warn?.("[agentrux] No credentials found. Run agentrux_activate first. Ingress disabled.");
298
+ logger.info?.("[agentrux] Ingress not configured. Tools-only mode.");
307
299
  return;
308
300
  }
309
- // Initialize components
310
- const cursor = (0, cursor_1.loadCursor)();
311
- const queue = new queue_1.BoundedQueue(100);
312
- const dispatcher = new dispatcher_1.Dispatcher({
313
- commandTopicId,
314
- resultTopicId,
315
- agentId,
316
- maxConcurrency: pluginConfig.maxConcurrency || 3,
317
- subagentTimeoutMs: pluginConfig.subagentTimeoutMs || 120_000,
318
- gatewayPort: 18789, // OpenClaw default gateway port
319
- }, creds, cursor, queue, logger);
320
- const ingressMode = pluginConfig.ingressMode || "webhook";
321
- const poller = new poller_1.SafetyPoller(creds, commandTopicId, cursor, queue, pluginConfig.pollIntervalMs || 60_000, // default 60s
322
- logger);
323
- // SSE listener (only when ingressMode=sse)
324
- const sseListener = ingressMode === "sse"
325
- ? new sse_listener_1.SSEListener(creds, commandTopicId, queue, logger)
326
- : null;
327
- // --- Internal dispatch endpoint (subagent.run() in Gateway request context) ---
301
+ // --- /agentrux/dispatch (called by Dispatcher via localhost) ---
328
302
  api.registerHttpRoute({
329
303
  path: "/agentrux/dispatch",
330
304
  auth: "plugin",
331
305
  match: "exact",
332
306
  handler: async (req, res) => {
307
+ if (req.method !== "POST") {
308
+ res.statusCode = 405;
309
+ res.setHeader("Allow", "POST");
310
+ res.setHeader("Content-Type", "application/json");
311
+ res.end('{"error":"method not allowed"}');
312
+ return true;
313
+ }
333
314
  const chunks = [];
334
- await new Promise((resolve) => {
315
+ await new Promise((resolve, reject) => {
335
316
  req.on("data", (c) => chunks.push(c));
336
317
  req.on("end", resolve);
337
- });
338
- const params = JSON.parse(Buffer.concat(chunks).toString());
318
+ req.on("error", reject);
319
+ }).catch(() => { });
320
+ const raw = Buffer.concat(chunks).toString();
321
+ if (!raw) {
322
+ res.statusCode = 400;
323
+ res.setHeader("Content-Type", "application/json");
324
+ res.end('{"error":"empty body"}');
325
+ return true;
326
+ }
327
+ let params;
328
+ try {
329
+ params = JSON.parse(raw);
330
+ }
331
+ catch (e) {
332
+ res.statusCode = 400;
333
+ res.setHeader("Content-Type", "application/json");
334
+ res.end(JSON.stringify({ error: "invalid JSON", detail: e.message }));
335
+ return true;
336
+ }
339
337
  try {
340
- // Track active session for attachment registry
341
338
  activeSessionKey = params.sessionKey;
342
- // OpenClaw 2026.3.24+: provider/model override is not authorized
343
- // for plugin subagent runs. The agent's configured model is used.
344
339
  const { runId } = await api.runtime.subagent.run({
345
340
  sessionKey: params.sessionKey,
346
341
  message: params.message,
@@ -349,8 +344,7 @@ function default_1(api) {
349
344
  ...(params.extraSystemPrompt ? { extraSystemPrompt: params.extraSystemPrompt } : {}),
350
345
  });
351
346
  const waitResult = await api.runtime.subagent.waitForRun({
352
- runId,
353
- timeoutMs: params.timeoutMs || 60_000,
347
+ runId, timeoutMs: params.timeoutMs || 60_000,
354
348
  });
355
349
  let responseText = "";
356
350
  if (waitResult.status === "ok") {
@@ -365,7 +359,9 @@ function default_1(api) {
365
359
  }
366
360
  }
367
361
  }
368
- // Collect attachments uploaded during this run
362
+ else {
363
+ responseText = waitResult.error || waitResult.message || `Agent failed: ${waitResult.status}`;
364
+ }
369
365
  const attachments = consumeAttachments(params.sessionKey);
370
366
  activeSessionKey = null;
371
367
  res.statusCode = 200;
@@ -382,30 +378,58 @@ function default_1(api) {
382
378
  return true;
383
379
  },
384
380
  });
385
- // --- Webhook endpoint (only when ingressMode=webhook) ---
386
- if (ingressMode === "webhook" && webhookSecret) {
387
- api.registerHttpRoute({
388
- path: "/agentrux/webhook",
389
- auth: "plugin",
390
- match: "exact",
391
- handler: (0, webhook_handler_1.createWebhookHandler)(queue, webhookSecret, commandTopicId),
392
- });
393
- logger.info?.("[agentrux] Webhook endpoint registered: /agentrux/webhook");
394
- }
395
- else if (ingressMode === "webhook" && !webhookSecret) {
396
- logger.warn?.("[agentrux] ingressMode=webhook but webhookSecret not set. Falling back to poll-only.");
397
- }
398
- // --- Start Dispatcher + (Webhook|SSE) + Poller ---
399
- // Gateway readiness probe: wait until /agentrux/dispatch is reachable
400
- // before starting Dispatcher. Prevents startup ECONNREFUSED from dropping events.
381
+ // --- Async startup: credentials construct → start ---
401
382
  (async () => {
402
383
  try {
384
+ // 1. Credentials: load or auto-activate
385
+ (0, http_client_1.invalidateToken)();
386
+ let creds = (0, credentials_1.loadCredentials)();
387
+ if (!creds) {
388
+ const activationCode = pluginConfig.activationCode;
389
+ if (!activationCode) {
390
+ logger.warn?.("[agentrux] No credentials and no activationCode. Ingress disabled.");
391
+ return;
392
+ }
393
+ logger.info?.("[agentrux] Auto-activating...");
394
+ const baseUrl = pluginConfig.baseUrl || "https://api.agentrux.com";
395
+ const r = await (0, http_client_1.httpJson)("POST", `${baseUrl}/auth/activate`, { activation_code: activationCode });
396
+ if (r.status !== 200 || !r.data.script_id) {
397
+ logger.error?.(`[agentrux] Auto-activation failed (HTTP ${r.status}): ${JSON.stringify(r.data)}`);
398
+ return;
399
+ }
400
+ creds = { base_url: baseUrl, script_id: r.data.script_id, clientSecret: r.data.client_secret };
401
+ (0, credentials_1.saveCredentials)(creds);
402
+ credentials = creds;
403
+ logger.info?.(`[agentrux] Activated: script_id=${r.data.script_id}`);
404
+ }
405
+ // 2. Construct
406
+ const cursor = (0, cursor_1.loadCursor)();
407
+ const queue = new queue_1.BoundedQueue(100);
408
+ const dispatcher = new dispatcher_1.Dispatcher({
409
+ commandTopicId, resultTopicId, agentId,
410
+ maxConcurrency: pluginConfig.maxConcurrency || 3,
411
+ subagentTimeoutMs: pluginConfig.subagentTimeoutMs || 120_000,
412
+ gatewayPort: 18789,
413
+ publishProcessingStatus: !!pluginConfig.publishProcessingStatus,
414
+ }, creds, cursor, queue, logger);
415
+ const poller = new poller_1.SafetyPoller(creds, commandTopicId, cursor, queue, pluginConfig.pollIntervalMs || 60_000, logger);
416
+ const sseListener = ingressMode === "sse"
417
+ ? new sse_listener_1.SSEListener(creds, commandTopicId, queue, logger) : null;
418
+ // Webhook (only if configured)
419
+ if (ingressMode === "webhook" && webhookSecret) {
420
+ api.registerHttpRoute({
421
+ path: "/agentrux/webhook", auth: "plugin", match: "exact",
422
+ handler: (0, webhook_handler_1.createWebhookHandler)(queue, webhookSecret, commandTopicId),
423
+ });
424
+ }
425
+ // 3. Wait for Gateway
403
426
  await waitForGateway(18789, 30_000, logger);
427
+ // 4. Start
404
428
  dispatcher.start().catch((e) => logger.error("[agentrux] Dispatcher failed:", e));
405
- if (sseListener) {
429
+ if (sseListener)
406
430
  sseListener.start().catch((e) => logger.error("[agentrux] SSE failed:", e));
407
- }
408
431
  poller.start().catch((e) => logger.error("[agentrux] Poller failed:", e));
432
+ globalThis.__agentruxIngressStarted = true;
409
433
  logger.info(`[agentrux] Ingress started: mode=${ingressMode} topic=${commandTopicId} agent=${agentId} poll@${pluginConfig.pollIntervalMs || 60000}ms`);
410
434
  }
411
435
  catch (e) {
@@ -418,7 +442,7 @@ async function waitForGateway(port, timeoutMs, logger) {
418
442
  while (Date.now() - start < timeoutMs) {
419
443
  try {
420
444
  await new Promise((resolve, reject) => {
421
- const req = require("http").request({ hostname: "127.0.0.1", port, path: "/agentrux/dispatch", method: "HEAD", timeout: 2000 }, (res) => { res.resume(); resolve(); });
445
+ const req = require("http").request({ hostname: "127.0.0.1", port, path: "/health", method: "GET", timeout: 2000 }, (res) => { res.resume(); resolve(); });
422
446
  req.on("error", reject);
423
447
  req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
424
448
  req.end();
@@ -430,5 +454,5 @@ async function waitForGateway(port, timeoutMs, logger) {
430
454
  await new Promise((r) => setTimeout(r, 2000));
431
455
  }
432
456
  }
433
- logger.warn("[agentrux] Gateway readiness timeout — starting Dispatcher anyway");
457
+ logger.warn("[agentrux] Gateway readiness timeout — starting anyway");
434
458
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentrux/agentrux-openclaw-plugin",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "OpenClaw plugin for AgenTrux — Agent-to-Agent authenticated Pub/Sub",
5
5
  "keywords": [
6
6
  "openclaw",