@agentrux/agentrux-openclaw-plugin 0.3.4 → 0.3.5
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 +2 -2
- package/dist/__tests__/dispatcher.test.d.ts +10 -0
- package/dist/__tests__/dispatcher.test.js +232 -0
- package/dist/dispatcher.d.ts +1 -0
- package/dist/dispatcher.js +59 -12
- package/dist/index.js +24 -2
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
+
});
|
package/dist/dispatcher.d.ts
CHANGED
package/dist/dispatcher.js
CHANGED
|
@@ -220,23 +220,61 @@ class Dispatcher {
|
|
|
220
220
|
this.logger.info(`Outboxed: seq=${seq} requestId=${requestId}`);
|
|
221
221
|
}
|
|
222
222
|
catch (e) {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
223
|
+
if (isTransientError(e)) {
|
|
224
|
+
// Transport failure: Gateway に届かなかった。
|
|
225
|
+
// outbox に書かない → findByRequestId で後続をブロックしない。
|
|
226
|
+
// recordProcessedEvent しない → event_id dedup で後続をブロックしない。
|
|
227
|
+
// markCompleted で waterline を進める → このイベントはドロップ。
|
|
228
|
+
this.logger.error(`Transport failed after retries, dropping: seq=${seq} error=${e.message}`);
|
|
229
|
+
(0, cursor_1.markCompleted)(this.cursor, seq);
|
|
230
|
+
try {
|
|
231
|
+
await (0, http_client_1.publishEvent)(this.creds, this.config.resultTopicId, "openclaw.status", {
|
|
232
|
+
request_id: requestId,
|
|
233
|
+
status: "dropped",
|
|
234
|
+
error: e.message,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch { }
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
// Application failure: Gateway に届いた上でのエラー → 既存処理
|
|
241
|
+
this.logger.error(`Event processing failed: seq=${seq} error=${e.message}`);
|
|
242
|
+
(0, outbox_1.addToOutbox)({
|
|
243
|
+
eventId,
|
|
244
|
+
requestId,
|
|
245
|
+
sequenceNo: seq,
|
|
246
|
+
result: {
|
|
247
|
+
message: `Agent error: ${e.message}`,
|
|
248
|
+
status: "failed",
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
(0, cursor_1.recordProcessedEvent)(this.cursor, eventId);
|
|
252
|
+
}
|
|
234
253
|
}
|
|
235
254
|
finally {
|
|
236
255
|
this.processingSeqs.delete(seq);
|
|
237
256
|
}
|
|
238
257
|
}
|
|
239
|
-
callDispatchEndpoint(params) {
|
|
258
|
+
async callDispatchEndpoint(params) {
|
|
259
|
+
const MAX_TRANSPORT_RETRIES = 3;
|
|
260
|
+
const RETRY_BASE_MS = 2000;
|
|
261
|
+
for (let attempt = 0; attempt < MAX_TRANSPORT_RETRIES; attempt++) {
|
|
262
|
+
try {
|
|
263
|
+
return await this._doDispatchRequest(params);
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
if (isTransientError(e) && attempt < MAX_TRANSPORT_RETRIES - 1) {
|
|
267
|
+
const delay = RETRY_BASE_MS * Math.pow(2, attempt);
|
|
268
|
+
this.logger.warn(`Dispatch transport retry ${attempt + 1}/${MAX_TRANSPORT_RETRIES} in ${delay}ms: ${e.message}`);
|
|
269
|
+
await sleep(delay);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
throw e;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
throw new Error("Dispatch: max transport retries exceeded");
|
|
276
|
+
}
|
|
277
|
+
_doDispatchRequest(params) {
|
|
240
278
|
return new Promise((resolve, reject) => {
|
|
241
279
|
const body = JSON.stringify(params);
|
|
242
280
|
const req = http.request({
|
|
@@ -269,3 +307,12 @@ exports.Dispatcher = Dispatcher;
|
|
|
269
307
|
function sleep(ms) {
|
|
270
308
|
return new Promise((r) => setTimeout(r, ms));
|
|
271
309
|
}
|
|
310
|
+
/** Transport-layer errors: Gateway に届かなかった場合のみ true */
|
|
311
|
+
function isTransientError(e) {
|
|
312
|
+
const code = e?.code;
|
|
313
|
+
if (code && ["ECONNREFUSED", "ETIMEDOUT", "ECONNRESET", "EHOSTUNREACH"].includes(code)) {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
const msg = e?.message || "";
|
|
317
|
+
return msg.includes("socket hang up") || msg.includes("Dispatch timeout");
|
|
318
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -396,8 +396,11 @@ function default_1(api) {
|
|
|
396
396
|
logger.warn?.("[agentrux] ingressMode=webhook but webhookSecret not set. Falling back to poll-only.");
|
|
397
397
|
}
|
|
398
398
|
// --- Start Dispatcher + (Webhook|SSE) + Poller ---
|
|
399
|
-
|
|
399
|
+
// Gateway readiness probe: wait until /agentrux/dispatch is reachable
|
|
400
|
+
// before starting Dispatcher. Prevents startup ECONNREFUSED from dropping events.
|
|
401
|
+
(async () => {
|
|
400
402
|
try {
|
|
403
|
+
await waitForGateway(18789, 30_000, logger);
|
|
401
404
|
dispatcher.start().catch((e) => logger.error("[agentrux] Dispatcher failed:", e));
|
|
402
405
|
if (sseListener) {
|
|
403
406
|
sseListener.start().catch((e) => logger.error("[agentrux] SSE failed:", e));
|
|
@@ -408,5 +411,24 @@ function default_1(api) {
|
|
|
408
411
|
catch (e) {
|
|
409
412
|
logger.error("[agentrux] Ingress startup error:", e);
|
|
410
413
|
}
|
|
411
|
-
}
|
|
414
|
+
})();
|
|
415
|
+
}
|
|
416
|
+
async function waitForGateway(port, timeoutMs, logger) {
|
|
417
|
+
const start = Date.now();
|
|
418
|
+
while (Date.now() - start < timeoutMs) {
|
|
419
|
+
try {
|
|
420
|
+
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(); });
|
|
422
|
+
req.on("error", reject);
|
|
423
|
+
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
|
|
424
|
+
req.end();
|
|
425
|
+
});
|
|
426
|
+
logger.info("[agentrux] Gateway is ready");
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
logger.warn("[agentrux] Gateway readiness timeout — starting Dispatcher anyway");
|
|
412
434
|
}
|