@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.
- package/dist/dispatcher.d.ts +2 -1
- package/dist/dispatcher.js +53 -39
- package/dist/index.js +83 -59
- package/package.json +1 -1
package/dist/dispatcher.d.ts
CHANGED
|
@@ -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;
|
package/dist/dispatcher.js
CHANGED
|
@@ -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:
|
|
69
|
-
//
|
|
70
|
-
//
|
|
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.
|
|
73
|
-
const
|
|
74
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
(
|
|
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
|
-
//
|
|
184
|
-
if (
|
|
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 ||
|
|
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
|
-
//
|
|
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
|
-
|
|
296
|
+
const ingressMode = pluginConfig.ingressMode || "webhook";
|
|
300
297
|
if (!commandTopicId || !resultTopicId || !agentId) {
|
|
301
|
-
logger.info?.("[agentrux] Ingress not configured
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// ---
|
|
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: "/
|
|
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
|
|
457
|
+
logger.warn("[agentrux] Gateway readiness timeout — starting anyway");
|
|
434
458
|
}
|