@agentrux/agentrux-openclaw-plugin 0.3.4 → 0.3.6

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Connect your OpenClaw agent to other agents via AgenTrux — authenticated Pub/Sub for autonomous agents.
4
4
 
5
- **v0.3.3**: Ingress mode with image attachments, auto topic resolution for uploads, SSE/webhook dual support.
5
+ **v0.3.4**: Ingress mode with image attachments, auto topic resolution for uploads, SSE/webhook dual support.
6
6
 
7
7
  ## Install
8
8
 
@@ -10,7 +10,7 @@ Connect your OpenClaw agent to other agents via AgenTrux — authenticated Pub/S
10
10
  > config 未設定だと OpenClaw CLI がロードエラーで停止します。
11
11
 
12
12
  ```bash
13
- openclaw plugins install @agentrux/agentrux-openclaw-plugin@0.3.3
13
+ openclaw plugins install @agentrux/agentrux-openclaw-plugin@0.3.4
14
14
  ```
15
15
 
16
16
  ## Quick Start
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Dispatcher transient error handling tests.
3
+ *
4
+ * Verifies:
5
+ * 1. Transport retry: callDispatchEndpoint retries on transient errors
6
+ * 2. Transient drop: transport failure after retries does NOT write to outbox
7
+ * 3. Application failure: non-transport errors still write to outbox (existing behavior)
8
+ * 4. isTransientError classification
9
+ */
10
+ export {};
@@ -0,0 +1,232 @@
1
+ "use strict";
2
+ /**
3
+ * Dispatcher transient error handling tests.
4
+ *
5
+ * Verifies:
6
+ * 1. Transport retry: callDispatchEndpoint retries on transient errors
7
+ * 2. Transient drop: transport failure after retries does NOT write to outbox
8
+ * 3. Application failure: non-transport errors still write to outbox (existing behavior)
9
+ * 4. isTransientError classification
10
+ */
11
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
+ if (k2 === undefined) k2 = k;
13
+ var desc = Object.getOwnPropertyDescriptor(m, k);
14
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
15
+ desc = { enumerable: true, get: function() { return m[k]; } };
16
+ }
17
+ Object.defineProperty(o, k2, desc);
18
+ }) : (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ o[k2] = m[k];
21
+ }));
22
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
23
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
24
+ }) : function(o, v) {
25
+ o["default"] = v;
26
+ });
27
+ var __importStar = (this && this.__importStar) || (function () {
28
+ var ownKeys = function(o) {
29
+ ownKeys = Object.getOwnPropertyNames || function (o) {
30
+ var ar = [];
31
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
32
+ return ar;
33
+ };
34
+ return ownKeys(o);
35
+ };
36
+ return function (mod) {
37
+ if (mod && mod.__esModule) return mod;
38
+ var result = {};
39
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
40
+ __setModuleDefault(result, mod);
41
+ return result;
42
+ };
43
+ })();
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ const http = __importStar(require("http"));
46
+ // --- isTransientError (extracted for testing) ---
47
+ function isTransientError(e) {
48
+ const code = e?.code;
49
+ if (code && ["ECONNREFUSED", "ETIMEDOUT", "ECONNRESET", "EHOSTUNREACH"].includes(code)) {
50
+ return true;
51
+ }
52
+ const msg = e?.message || "";
53
+ return msg.includes("socket hang up") || msg.includes("Dispatch timeout");
54
+ }
55
+ describe("isTransientError", () => {
56
+ test("ECONNREFUSED → transient", () => {
57
+ const e = new Error("connect ECONNREFUSED 127.0.0.1:18789");
58
+ e.code = "ECONNREFUSED";
59
+ expect(isTransientError(e)).toBe(true);
60
+ });
61
+ test("ETIMEDOUT → transient", () => {
62
+ const e = new Error("connect ETIMEDOUT");
63
+ e.code = "ETIMEDOUT";
64
+ expect(isTransientError(e)).toBe(true);
65
+ });
66
+ test("ECONNRESET → transient", () => {
67
+ const e = new Error("read ECONNRESET");
68
+ e.code = "ECONNRESET";
69
+ expect(isTransientError(e)).toBe(true);
70
+ });
71
+ test("EHOSTUNREACH → transient", () => {
72
+ const e = new Error("connect EHOSTUNREACH");
73
+ e.code = "EHOSTUNREACH";
74
+ expect(isTransientError(e)).toBe(true);
75
+ });
76
+ test("socket hang up → transient", () => {
77
+ expect(isTransientError(new Error("socket hang up"))).toBe(true);
78
+ });
79
+ test("Dispatch timeout → transient", () => {
80
+ expect(isTransientError(new Error("Dispatch timeout"))).toBe(true);
81
+ });
82
+ test("JSON parse error → NOT transient", () => {
83
+ expect(isTransientError(new Error("Dispatch response parse error: <html>"))).toBe(false);
84
+ });
85
+ test("generic Error → NOT transient", () => {
86
+ expect(isTransientError(new Error("something unexpected"))).toBe(false);
87
+ });
88
+ test("null/undefined → NOT transient", () => {
89
+ expect(isTransientError(null)).toBe(false);
90
+ expect(isTransientError(undefined)).toBe(false);
91
+ });
92
+ });
93
+ // --- Transport retry integration test ---
94
+ describe("callDispatchEndpoint transport retry", () => {
95
+ test("retries on ECONNREFUSED then succeeds", async () => {
96
+ let requestCount = 0;
97
+ const PORT = 19876;
98
+ let server;
99
+ // _doDispatchRequest equivalent
100
+ function doRequest() {
101
+ return new Promise((resolve, reject) => {
102
+ const body = JSON.stringify({ sessionKey: "test", message: "hi", idempotencyKey: "k1", timeoutMs: 5000 });
103
+ const req = http.request({ hostname: "127.0.0.1", port: PORT, path: "/agentrux/dispatch", method: "POST",
104
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }, timeout: 5000 }, (res) => {
105
+ let raw = "";
106
+ res.on("data", (c) => (raw += c.toString()));
107
+ res.on("end", () => { try {
108
+ resolve(JSON.parse(raw));
109
+ }
110
+ catch {
111
+ reject(new Error("parse error"));
112
+ } });
113
+ });
114
+ req.on("error", reject);
115
+ req.on("timeout", () => { req.destroy(); reject(new Error("Dispatch timeout")); });
116
+ req.write(body);
117
+ req.end();
118
+ });
119
+ }
120
+ // Retry wrapper (mirrors callDispatchEndpoint)
121
+ async function callWithRetry() {
122
+ const MAX = 3;
123
+ const BASE = 200; // shorter for test
124
+ for (let attempt = 0; attempt < MAX; attempt++) {
125
+ try {
126
+ return await doRequest();
127
+ }
128
+ catch (e) {
129
+ if (isTransientError(e) && attempt < MAX - 1) {
130
+ await new Promise((r) => setTimeout(r, BASE * Math.pow(2, attempt)));
131
+ continue;
132
+ }
133
+ throw e;
134
+ }
135
+ }
136
+ throw new Error("max retries");
137
+ }
138
+ // Start server after 300ms (1st attempt fails, 2nd succeeds)
139
+ setTimeout(() => {
140
+ server = http.createServer((req, res) => {
141
+ requestCount++;
142
+ let body = "";
143
+ req.on("data", (c) => (body += c));
144
+ req.on("end", () => {
145
+ res.writeHead(200, { "Content-Type": "application/json" });
146
+ res.end(JSON.stringify({ responseText: "ok", status: "ok" }));
147
+ });
148
+ });
149
+ server.listen(PORT);
150
+ }, 300);
151
+ const result = await callWithRetry();
152
+ expect(result.status).toBe("ok");
153
+ expect(requestCount).toBe(1);
154
+ await new Promise((resolve) => server.close(() => resolve()));
155
+ }, 15000);
156
+ test("all retries fail → throws transient error", async () => {
157
+ // Simulate 3 ECONNREFUSED failures without real sockets
158
+ let attempts = 0;
159
+ async function fakeRequest() {
160
+ attempts++;
161
+ const e = new Error("connect ECONNREFUSED 127.0.0.1:19877");
162
+ e.code = "ECONNREFUSED";
163
+ throw e;
164
+ }
165
+ async function callWithRetry() {
166
+ const MAX = 3;
167
+ const BASE = 50; // short for test
168
+ for (let attempt = 0; attempt < MAX; attempt++) {
169
+ try {
170
+ return await fakeRequest();
171
+ }
172
+ catch (e) {
173
+ if (isTransientError(e) && attempt < MAX - 1) {
174
+ await new Promise((r) => setTimeout(r, BASE));
175
+ continue;
176
+ }
177
+ throw e;
178
+ }
179
+ }
180
+ }
181
+ let threw = false;
182
+ try {
183
+ await callWithRetry();
184
+ }
185
+ catch (e) {
186
+ threw = true;
187
+ expect(isTransientError(e)).toBe(true);
188
+ }
189
+ expect(threw).toBe(true);
190
+ expect(attempts).toBe(3); // all 3 attempts made
191
+ }, 10000);
192
+ });
193
+ // --- Transient vs Application error routing ---
194
+ describe("processEvent error routing", () => {
195
+ test("transient error should NOT produce outbox entry", () => {
196
+ // Simulate the decision logic from processEvent catch block
197
+ const e = new Error("connect ECONNREFUSED 127.0.0.1:18789");
198
+ e.code = "ECONNREFUSED";
199
+ let outboxWritten = false;
200
+ let eventRecorded = false;
201
+ let completed = false;
202
+ if (isTransientError(e)) {
203
+ // Transport failure path: drop event
204
+ completed = true;
205
+ // outboxWritten stays false
206
+ // eventRecorded stays false
207
+ }
208
+ else {
209
+ outboxWritten = true;
210
+ eventRecorded = true;
211
+ }
212
+ expect(outboxWritten).toBe(false);
213
+ expect(eventRecorded).toBe(false);
214
+ expect(completed).toBe(true);
215
+ });
216
+ test("application error SHOULD produce outbox entry", () => {
217
+ const e = new Error("Subagent execution failed");
218
+ let outboxWritten = false;
219
+ let eventRecorded = false;
220
+ let completed = false;
221
+ if (isTransientError(e)) {
222
+ completed = true;
223
+ }
224
+ else {
225
+ outboxWritten = true;
226
+ eventRecorded = true;
227
+ }
228
+ expect(outboxWritten).toBe(true);
229
+ expect(eventRecorded).toBe(true);
230
+ expect(completed).toBe(false);
231
+ });
232
+ });
@@ -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;
@@ -35,4 +36,5 @@ export declare class Dispatcher {
35
36
  private tick;
36
37
  private processEvent;
37
38
  private callDispatchEndpoint;
39
+ private _doDispatchRequest;
38
40
  }
@@ -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 } : {}),
@@ -220,23 +234,61 @@ class Dispatcher {
220
234
  this.logger.info(`Outboxed: seq=${seq} requestId=${requestId}`);
221
235
  }
222
236
  catch (e) {
223
- this.logger.error(`Event processing failed: seq=${seq} error=${e.message}`);
224
- (0, outbox_1.addToOutbox)({
225
- eventId,
226
- requestId,
227
- sequenceNo: seq,
228
- result: {
229
- message: `Agent error: ${e.message}`,
230
- status: "failed",
231
- },
232
- });
233
- (0, cursor_1.recordProcessedEvent)(this.cursor, eventId);
237
+ if (isTransientError(e)) {
238
+ // Transport failure: Gateway に届かなかった。
239
+ // outbox に書かない → findByRequestId で後続をブロックしない。
240
+ // recordProcessedEvent しない → event_id dedup で後続をブロックしない。
241
+ // markCompleted で waterline を進める → このイベントはドロップ。
242
+ this.logger.error(`Transport failed after retries, dropping: seq=${seq} error=${e.message}`);
243
+ (0, cursor_1.markCompleted)(this.cursor, seq);
244
+ try {
245
+ await (0, http_client_1.publishEvent)(this.creds, this.config.resultTopicId, "openclaw.status", {
246
+ request_id: requestId,
247
+ status: "dropped",
248
+ error: e.message,
249
+ });
250
+ }
251
+ catch { }
252
+ }
253
+ else {
254
+ // Application failure: Gateway に届いた上でのエラー → 既存処理
255
+ this.logger.error(`Event processing failed: seq=${seq} error=${e.message}`);
256
+ (0, outbox_1.addToOutbox)({
257
+ eventId,
258
+ requestId,
259
+ sequenceNo: seq,
260
+ result: {
261
+ message: `Agent error: ${e.message}`,
262
+ status: "failed",
263
+ },
264
+ });
265
+ (0, cursor_1.recordProcessedEvent)(this.cursor, eventId);
266
+ }
234
267
  }
235
268
  finally {
236
269
  this.processingSeqs.delete(seq);
237
270
  }
238
271
  }
239
- callDispatchEndpoint(params) {
272
+ async callDispatchEndpoint(params) {
273
+ const MAX_TRANSPORT_RETRIES = 3;
274
+ const RETRY_BASE_MS = 2000;
275
+ for (let attempt = 0; attempt < MAX_TRANSPORT_RETRIES; attempt++) {
276
+ try {
277
+ return await this._doDispatchRequest(params);
278
+ }
279
+ catch (e) {
280
+ if (isTransientError(e) && attempt < MAX_TRANSPORT_RETRIES - 1) {
281
+ const delay = RETRY_BASE_MS * Math.pow(2, attempt);
282
+ this.logger.warn(`Dispatch transport retry ${attempt + 1}/${MAX_TRANSPORT_RETRIES} in ${delay}ms: ${e.message}`);
283
+ await sleep(delay);
284
+ continue;
285
+ }
286
+ throw e;
287
+ }
288
+ }
289
+ throw new Error("Dispatch: max transport retries exceeded");
290
+ }
291
+ _doDispatchRequest(params) {
240
292
  return new Promise((resolve, reject) => {
241
293
  const body = JSON.stringify(params);
242
294
  const req = http.request({
@@ -269,3 +321,12 @@ exports.Dispatcher = Dispatcher;
269
321
  function sleep(ms) {
270
322
  return new Promise((r) => setTimeout(r, ms));
271
323
  }
324
+ /** Transport-layer errors: Gateway に届かなかった場合のみ true */
325
+ function isTransientError(e) {
326
+ const code = e?.code;
327
+ if (code && ["ECONNREFUSED", "ETIMEDOUT", "ECONNRESET", "EHOSTUNREACH"].includes(code)) {
328
+ return true;
329
+ }
330
+ const msg = e?.message || "";
331
+ return msg.includes("socket hang up") || msg.includes("Dispatch timeout");
332
+ }
package/dist/index.js CHANGED
@@ -330,12 +330,37 @@ function default_1(api) {
330
330
  auth: "plugin",
331
331
  match: "exact",
332
332
  handler: async (req, res) => {
333
+ // Reject non-POST (e.g. HEAD from readiness probe)
334
+ if (req.method !== "POST") {
335
+ res.statusCode = 405;
336
+ res.setHeader("Allow", "POST");
337
+ res.setHeader("Content-Type", "application/json");
338
+ res.end('{"error":"method not allowed"}');
339
+ return true;
340
+ }
333
341
  const chunks = [];
334
- await new Promise((resolve) => {
342
+ await new Promise((resolve, reject) => {
335
343
  req.on("data", (c) => chunks.push(c));
336
344
  req.on("end", resolve);
337
- });
338
- const params = JSON.parse(Buffer.concat(chunks).toString());
345
+ req.on("error", reject);
346
+ }).catch(() => { }); // aborted/truncated request → empty body → 400 below
347
+ const raw = Buffer.concat(chunks).toString();
348
+ if (!raw) {
349
+ res.statusCode = 400;
350
+ res.setHeader("Content-Type", "application/json");
351
+ res.end('{"error":"empty body"}');
352
+ return true;
353
+ }
354
+ let params;
355
+ try {
356
+ params = JSON.parse(raw);
357
+ }
358
+ catch (e) {
359
+ res.statusCode = 400;
360
+ res.setHeader("Content-Type", "application/json");
361
+ res.end(JSON.stringify({ error: "invalid JSON", detail: e.message }));
362
+ return true;
363
+ }
339
364
  try {
340
365
  // Track active session for attachment registry
341
366
  activeSessionKey = params.sessionKey;
@@ -365,6 +390,10 @@ function default_1(api) {
365
390
  }
366
391
  }
367
392
  }
393
+ else {
394
+ // Subagent failed (rate limit, timeout, etc.) — include reason in response
395
+ responseText = waitResult.error || waitResult.message || `Agent failed: ${waitResult.status}`;
396
+ }
368
397
  // Collect attachments uploaded during this run
369
398
  const attachments = consumeAttachments(params.sessionKey);
370
399
  activeSessionKey = null;
@@ -396,8 +425,12 @@ function default_1(api) {
396
425
  logger.warn?.("[agentrux] ingressMode=webhook but webhookSecret not set. Falling back to poll-only.");
397
426
  }
398
427
  // --- Start Dispatcher + (Webhook|SSE) + Poller ---
399
- setTimeout(() => {
428
+ // Gateway readiness probe: wait until /health responds.
429
+ // Even if /health comes up before plugin routes, the transport retry
430
+ // in callDispatchEndpoint (3 attempts, 2s/4s backoff) covers the gap.
431
+ (async () => {
400
432
  try {
433
+ await waitForGateway(18789, 30_000, logger);
401
434
  dispatcher.start().catch((e) => logger.error("[agentrux] Dispatcher failed:", e));
402
435
  if (sseListener) {
403
436
  sseListener.start().catch((e) => logger.error("[agentrux] SSE failed:", e));
@@ -408,5 +441,24 @@ function default_1(api) {
408
441
  catch (e) {
409
442
  logger.error("[agentrux] Ingress startup error:", e);
410
443
  }
411
- }, 5000);
444
+ })();
445
+ }
446
+ async function waitForGateway(port, timeoutMs, logger) {
447
+ const start = Date.now();
448
+ while (Date.now() - start < timeoutMs) {
449
+ try {
450
+ await new Promise((resolve, reject) => {
451
+ const req = require("http").request({ hostname: "127.0.0.1", port, path: "/health", method: "GET", timeout: 2000 }, (res) => { res.resume(); resolve(); });
452
+ req.on("error", reject);
453
+ req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
454
+ req.end();
455
+ });
456
+ logger.info("[agentrux] Gateway is ready");
457
+ return;
458
+ }
459
+ catch {
460
+ await new Promise((r) => setTimeout(r, 2000));
461
+ }
462
+ }
463
+ logger.warn("[agentrux] Gateway readiness timeout — starting Dispatcher anyway");
412
464
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentrux/agentrux-openclaw-plugin",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "OpenClaw plugin for AgenTrux — Agent-to-Agent authenticated Pub/Sub",
5
5
  "keywords": [
6
6
  "openclaw",