@agentrux/agentrux-openclaw-plugin 0.3.5 → 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/dist/dispatcher.d.ts +2 -1
- package/dist/dispatcher.js +53 -39
- package/dist/index.js +36 -6
- 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
|
@@ -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
|
-
|
|
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,9 @@ 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
|
-
// Gateway readiness probe: wait until /
|
|
400
|
-
//
|
|
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.
|
|
401
431
|
(async () => {
|
|
402
432
|
try {
|
|
403
433
|
await waitForGateway(18789, 30_000, logger);
|
|
@@ -418,7 +448,7 @@ async function waitForGateway(port, timeoutMs, logger) {
|
|
|
418
448
|
while (Date.now() - start < timeoutMs) {
|
|
419
449
|
try {
|
|
420
450
|
await new Promise((resolve, reject) => {
|
|
421
|
-
const req = require("http").request({ hostname: "127.0.0.1", port, path: "/
|
|
451
|
+
const req = require("http").request({ hostname: "127.0.0.1", port, path: "/health", method: "GET", timeout: 2000 }, (res) => { res.resume(); resolve(); });
|
|
422
452
|
req.on("error", reject);
|
|
423
453
|
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
|
|
424
454
|
req.end();
|