@relaymessenger/openclaw-plugin 0.3.3 → 0.3.4

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.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Which group invocation an in-flight turn belongs to.
3
+ *
4
+ * Relay mints an invocation when a human invokes an agent in a group, and
5
+ * every call the agent then makes about that message has to carry the id back:
6
+ * `/typing` and `/responding` refuse without it, and so does the reply itself.
7
+ *
8
+ * The reply does not leave through this plugin's own code. It leaves through
9
+ * core's durable message adapter, whose send context carries `to`, `text`, and
10
+ * delivery bookkeeping and nothing about the message being answered
11
+ * (`ChannelMessageSendTextContext`). There is no field to thread the id
12
+ * through, so the turn parks it here for the adapter to find.
13
+ *
14
+ * Keyed by (accountId, conversationId) because `to` and `accountId` are all
15
+ * the adapter knows. Two agents in one group get separate slots. Two
16
+ * overlapping turns for ONE agent in ONE group share a slot and the later one
17
+ * wins — bounded by the server, which spends an invocation exactly once and
18
+ * refuses the loser rather than misattributing it.
19
+ */
20
+ const pendingInvocations = new Map<string, string>();
21
+
22
+ function slotKey(accountId: string, conversationId: string): string {
23
+ return `${accountId}\0${conversationId}`;
24
+ }
25
+
26
+ /**
27
+ * Hold `invocationId` for the life of one turn. Returns the release function;
28
+ * call it in a `finally` so a thrown turn cannot strand the slot.
29
+ *
30
+ * Releasing only clears the slot if this turn still owns it, so a turn that
31
+ * finishes after being superseded cannot delete its successor's id.
32
+ */
33
+ export function rememberRelayInvocation(params: {
34
+ accountId: string;
35
+ conversationId: string;
36
+ invocationId: string;
37
+ }): () => void {
38
+ const key = slotKey(params.accountId, params.conversationId);
39
+ pendingInvocations.set(key, params.invocationId);
40
+ return () => {
41
+ if (pendingInvocations.get(key) === params.invocationId) {
42
+ pendingInvocations.delete(key);
43
+ }
44
+ };
45
+ }
46
+
47
+ /** The invocation an outbound send in this conversation belongs to, if any. */
48
+ export function relayInvocationFor(params: {
49
+ accountId: string;
50
+ conversationId: string;
51
+ }): string | undefined {
52
+ return pendingInvocations.get(slotKey(params.accountId, params.conversationId));
53
+ }
54
+
55
+ /** Test seam: drop every slot. */
56
+ export function resetRelayInvocationsForTest(): void {
57
+ pendingInvocations.clear();
58
+ }
package/src/outbound.ts CHANGED
@@ -4,8 +4,7 @@
4
4
  // (server contract: commitMessage.ts idempotent replay).
5
5
  import { createHash } from "node:crypto";
6
6
  import { RelayApiError } from "./client.js";
7
- import type { RelayClient } from "./client.js";
8
- import type { RelayMessage } from "./types.js";
7
+ import type { RelayClient, RelaySentMessage } from "./client.js";
9
8
 
10
9
  /**
11
10
  * Per-part text ceiling declared to core's renderer so long agent replies are
@@ -72,7 +71,7 @@ export type RelayOutboundSendResult = {
72
71
  * commits exactly one, but the 202 is always an array and the receipt
73
72
  * should name everything the server stored.
74
73
  */
75
- messages: RelayMessage[];
74
+ messages: RelaySentMessage[];
76
75
  };
77
76
 
78
77
  export async function sendRelayText(params: {
@@ -80,6 +79,11 @@ export async function sendRelayText(params: {
80
79
  conversationId: string;
81
80
  text: string;
82
81
  replyToId?: string | null;
82
+ /**
83
+ * Required when replying into a group: the server refuses an agent's group
84
+ * message that does not name the invocation it is answering.
85
+ */
86
+ invocationId?: string;
83
87
  idempotencyKey: string;
84
88
  signal?: AbortSignal;
85
89
  }): Promise<RelayOutboundSendResult> {
@@ -90,6 +94,7 @@ export async function sendRelayText(params: {
90
94
  conversationId: params.conversationId,
91
95
  parts: [{ type: "text", text: params.text }],
92
96
  ...(params.replyToId ? { replyTo: { message_id: params.replyToId } } : {}),
97
+ ...(params.invocationId ? { invocationId: params.invocationId } : {}),
93
98
  idempotencyKey: params.idempotencyKey,
94
99
  ...(params.signal ? { signal: params.signal } : {}),
95
100
  });
@@ -109,7 +114,7 @@ export async function sendRelayText(params: {
109
114
  }
110
115
 
111
116
  export type RelayUnknownSendVerdict =
112
- | { status: "sent"; messageId: string; messages: RelayMessage[] }
117
+ | { status: "sent"; messageId: string; messages: RelaySentMessage[] }
113
118
  | { status: "not_sent" }
114
119
  | { status: "unresolved"; error?: string; retryable?: boolean };
115
120
 
@@ -125,6 +130,7 @@ export async function reconcileRelayUnknownSend(params: {
125
130
  conversationId: string;
126
131
  text: string;
127
132
  replyToId?: string | null;
133
+ invocationId?: string;
128
134
  idempotencyKey: string;
129
135
  }): Promise<RelayUnknownSendVerdict> {
130
136
  try {
@@ -133,6 +139,7 @@ export async function reconcileRelayUnknownSend(params: {
133
139
  conversationId: params.conversationId,
134
140
  text: params.text,
135
141
  replyToId: params.replyToId ?? null,
142
+ ...(params.invocationId ? { invocationId: params.invocationId } : {}),
136
143
  idempotencyKey: params.idempotencyKey,
137
144
  });
138
145
  return { status: "sent", messageId: result.messageId, messages: result.messages };
package/src/poll-loop.ts CHANGED
@@ -130,6 +130,18 @@ export async function runRelayPollLoop(params: RelayPollLoopParams): Promise<voi
130
130
  } catch (error) {
131
131
  if (!attempted) {
132
132
  params.deduper.releaseEvent(event.event_id);
133
+ // A rejection is the server's final answer: replaying the identical
134
+ // request produces the identical refusal. Holding the cursor for it
135
+ // is a livelock, and the cursor is ONE watermark for the whole
136
+ // channel — so a single permanently-refused event would starve every
137
+ // later message, direct ones included (REL-167). Losing one event is
138
+ // strictly better than losing the channel, so log it loudly and let
139
+ // the page cursor move past it.
140
+ if (error instanceof RelayApiError && error.kind === "rejected") {
141
+ log(`[relay] event ${event.event_id} was permanently rejected by the server, ` +
142
+ `skipping it so later messages are not starved: ${String(error)}`);
143
+ continue;
144
+ }
133
145
  log(`[relay] event ${event.event_id} safe preflight failed, will replay: ${String(error)}`);
134
146
  batchFailed = true;
135
147
  break;
package/src/responding.ts CHANGED
@@ -1,21 +1,52 @@
1
+ import { RelayApiError } from "./client.js";
1
2
  import type { RelayClient } from "./client.js";
3
+ import type { RelayInboundFacts } from "./inbound.js";
2
4
 
3
5
  /**
4
- * The receipt must commit before OpenClaw can run an agent or tool. A rejected
5
- * receipt leaves the durable attempt marker untouched, so the poll loop can
6
- * replay the event safely instead of hiding the failure after execution.
6
+ * Record the read/responding receipt, then commit the durable attempt marker.
7
+ *
8
+ * The receipt is a courtesy to the person waiting: it turns their message Read
9
+ * and shows that something is composing. It is NOT permission to answer, and
10
+ * it used to be treated as such — a rejected receipt threw here, before
11
+ * `markAttempt`, which sent the poll loop down its replay branch and froze the
12
+ * channel's single delivery cursor. One group mention whose receipt the server
13
+ * refused therefore starved every later message, direct ones included
14
+ * (REL-167).
15
+ *
16
+ * So a failed receipt is reported and the turn continues. The ordering that
17
+ * mattered is kept: the receipt is still attempted BEFORE the attempt marker,
18
+ * so a receipt that succeeds still precedes any agent or tool work.
7
19
  */
8
20
  export async function markRespondingBeforeAttempt(params: {
9
21
  client: RelayClient;
10
- conversationId: string;
11
- messageId: string;
22
+ /**
23
+ * The whole fact bundle, not its fields one at a time. The receipt needs the
24
+ * conversation, the message, AND the invocation when there is one, and a
25
+ * caller that copies two of those three out by hand can silently forget the
26
+ * third — which is how the group receipt shipped without its invocation id.
27
+ */
28
+ facts: Pick<RelayInboundFacts, "conversationId" | "messageId" | "invocationId">;
12
29
  label: string;
13
30
  markAttempt: () => Promise<void>;
31
+ onReceiptFailure?: (line: string) => void;
14
32
  }): Promise<void> {
15
- await params.client.setResponding({
16
- conversationId: params.conversationId,
17
- messageId: params.messageId,
18
- label: params.label,
19
- });
33
+ const { facts } = params;
34
+ try {
35
+ await params.client.setResponding({
36
+ conversationId: facts.conversationId,
37
+ messageId: facts.messageId,
38
+ label: params.label,
39
+ ...(facts.invocationId ? { invocationId: facts.invocationId } : {}),
40
+ });
41
+ } catch (error) {
42
+ // An aborted shutdown is not a receipt failure; let it settle the loop.
43
+ if (error instanceof Error && error.name === "AbortError") {
44
+ throw error;
45
+ }
46
+ const detail = error instanceof RelayApiError ? error.message : String(error);
47
+ params.onReceiptFailure?.(
48
+ `responding receipt for message ${facts.messageId} failed, answering anyway: ${detail}`,
49
+ );
50
+ }
20
51
  await params.markAttempt();
21
52
  }
@@ -95,6 +95,89 @@ function lockRetryDelayMs(attempt: number, remainingMs: number): number {
95
95
  return Math.max(1, Math.min(backoff * (0.5 + Math.random() / 2), remainingMs));
96
96
  }
97
97
 
98
+ /**
99
+ * The sidecar lock has no in-process fast path: every waiter polls the lock
100
+ * file, and losing an attempt costs an exclusive create plus a snapshot read.
101
+ * Relay mutates one document from several tasks at once — a poll batch
102
+ * registers one dedupe entry per inbound message — so N in-process writers
103
+ * become N pollers competing with the holder for the same file. Funnelling
104
+ * them through one in-memory queue leaves a single poller per process, which
105
+ * matters most on Windows: same-process losers no longer race the holder's
106
+ * unlink, so contention stops manifesting as delete-pending denials.
107
+ *
108
+ * Keyed by the store's file path. Two paths spelled differently for one file
109
+ * would each get a queue and simply fall back to the sidecar lock for
110
+ * correctness, so a miss costs throughput rather than serialization.
111
+ */
112
+ const RELAY_STATE_MUTEX_KEY = Symbol.for("relay.stateFileMutexes");
113
+
114
+ function stateFileMutexes(): Map<string, Promise<void>> {
115
+ const container = globalThis as typeof globalThis & {
116
+ [RELAY_STATE_MUTEX_KEY]?: Map<string, Promise<void>>;
117
+ };
118
+ container[RELAY_STATE_MUTEX_KEY] ??= new Map<string, Promise<void>>();
119
+ return container[RELAY_STATE_MUTEX_KEY];
120
+ }
121
+
122
+ function fileLockTimeout(filePath: string): Error {
123
+ return Object.assign(new Error(`file lock timeout for ${filePath}`), {
124
+ code: "file_lock_timeout",
125
+ });
126
+ }
127
+
128
+ /** Waits for our turn, but never past the caller's lock deadline. */
129
+ async function awaitTurn(
130
+ turn: Promise<void>,
131
+ deadline: number,
132
+ filePath: string,
133
+ ): Promise<void> {
134
+ const remaining = deadline - Date.now();
135
+ if (remaining <= 0) throw fileLockTimeout(filePath);
136
+ let timer: ReturnType<typeof setTimeout> | undefined;
137
+ try {
138
+ await Promise.race([
139
+ turn,
140
+ new Promise<never>((_resolve, reject) => {
141
+ timer = setTimeout(() => reject(fileLockTimeout(filePath)), remaining);
142
+ }),
143
+ ]);
144
+ } finally {
145
+ if (timer) clearTimeout(timer);
146
+ }
147
+ }
148
+
149
+ async function withStateFileMutex<R>(
150
+ filePath: string,
151
+ deadline: number,
152
+ run: () => Promise<R>,
153
+ ): Promise<R> {
154
+ const mutexes = stateFileMutexes();
155
+ const previous = mutexes.get(filePath);
156
+ let release!: () => void;
157
+ const ours = new Promise<void>((resolve) => {
158
+ release = resolve;
159
+ });
160
+ // Chain even when we abandon our turn on timeout: later waiters still queue
161
+ // behind the holder we were waiting on, so ordering survives a giving-up
162
+ // waiter.
163
+ const tail = previous ? previous.then(() => ours) : ours;
164
+ mutexes.set(filePath, tail);
165
+ let tookTurn = false;
166
+ try {
167
+ if (previous) await awaitTurn(previous, deadline, filePath);
168
+ tookTurn = true;
169
+ return await run();
170
+ } finally {
171
+ release();
172
+ // Forgetting the queue is only safe once it has drained. A waiter that gave
173
+ // up is still queued behind a holder that is running, so dropping the entry
174
+ // there would let the next caller past the holder and back onto the lock
175
+ // file the queue exists to keep it off. Leaving it costs one settled promise
176
+ // until the next caller drains it.
177
+ if (tookTurn && mutexes.get(filePath) === tail) mutexes.delete(filePath);
178
+ }
179
+ }
180
+
98
181
  function canRecoverRelayStateLock(value: unknown): boolean {
99
182
  return (
100
183
  isRelayStateLockOwner(value) &&
@@ -147,53 +230,56 @@ export function openRelayStateDocument<T>(params: {
147
230
  });
148
231
  const withMutationLock = async <R>(run: () => Promise<R>): Promise<R> => {
149
232
  const deadline = Date.now() + lockTimeoutMs;
150
- for (let attempt = 0; ; attempt += 1) {
151
- // Only acquisition is retried. Once the mutation itself has started it has
152
- // observed state under the lock, so replaying it could double-apply.
153
- let mutationStarted = false;
154
- try {
155
- return await withFileLock(
156
- store.filePath,
157
- {
158
- managerKey: `relay-state:${store.filePath}`,
159
- staleMs: RELAY_STATE_LOCK_TIMEOUT_MS,
160
- timeoutMs: Math.max(1, deadline - Date.now()),
161
- staleRecovery: "remove-if-unchanged",
162
- retry: {
163
- retries: 300,
164
- minTimeout: 25,
165
- maxTimeout: 250,
166
- randomize: true,
233
+ return await withStateFileMutex(store.filePath, deadline, async () => {
234
+ for (let attempt = 0; ; attempt += 1) {
235
+ // Only acquisition is retried. Once the mutation itself has started it
236
+ // has observed state under the lock, so replaying it could double-apply.
237
+ let mutationStarted = false;
238
+ try {
239
+ return await withFileLock(
240
+ store.filePath,
241
+ {
242
+ managerKey: `relay-state:${store.filePath}`,
243
+ staleMs: RELAY_STATE_LOCK_TIMEOUT_MS,
244
+ timeoutMs: Math.max(1, deadline - Date.now()),
245
+ staleRecovery: "remove-if-unchanged",
246
+ retry: {
247
+ retries: 300,
248
+ minTimeout: 25,
249
+ maxTimeout: 250,
250
+ randomize: true,
251
+ },
252
+ payload: (): RelayStateLockOwner => ({
253
+ version: RELAY_STATE_LOCK_VERSION,
254
+ kind: "relay-state",
255
+ pid: process.pid,
256
+ host: hostname(),
257
+ createdAt: new Date().toISOString(),
258
+ }),
259
+ shouldReclaim: ({ payload }) => canRecoverRelayStateLock(payload),
260
+ shouldRemoveStaleLock: ({ payload }) =>
261
+ canRecoverRelayStateLock(payload),
167
262
  },
168
- payload: (): RelayStateLockOwner => ({
169
- version: RELAY_STATE_LOCK_VERSION,
170
- kind: "relay-state",
171
- pid: process.pid,
172
- host: hostname(),
173
- createdAt: new Date().toISOString(),
174
- }),
175
- shouldReclaim: ({ payload }) => canRecoverRelayStateLock(payload),
176
- shouldRemoveStaleLock: ({ payload }) => canRecoverRelayStateLock(payload),
177
- },
178
- async () => {
179
- mutationStarted = true;
180
- return await run();
181
- },
182
- );
183
- } catch (error) {
184
- const remaining = deadline - Date.now();
185
- if (
186
- mutationStarted ||
187
- remaining <= 0 ||
188
- !isWindowsLockAcquisitionContention(error)
189
- ) {
190
- throw error;
263
+ async () => {
264
+ mutationStarted = true;
265
+ return await run();
266
+ },
267
+ );
268
+ } catch (error) {
269
+ const remaining = deadline - Date.now();
270
+ if (
271
+ mutationStarted ||
272
+ remaining <= 0 ||
273
+ !isWindowsLockAcquisitionContention(error)
274
+ ) {
275
+ throw error;
276
+ }
277
+ await new Promise((resolve) =>
278
+ setTimeout(resolve, lockRetryDelayMs(attempt, remaining)),
279
+ );
191
280
  }
192
- await new Promise((resolve) =>
193
- setTimeout(resolve, lockRetryDelayMs(attempt, remaining)),
194
- );
195
281
  }
196
- }
282
+ });
197
283
  };
198
284
 
199
285
  return {
package/src/types.ts CHANGED
@@ -3,7 +3,10 @@
3
3
  // { events, next_cursor }, cursor N acknowledges everything <= N.
4
4
 
5
5
  export type RelaySender = {
6
- kind: "user" | "agent";
6
+ // `system` is real on the wire: a group's own notices are authored by it
7
+ // (creating a group commits "<name> created <title>"). Only `user` messages
8
+ // start a turn, and `buildRelayInboundFacts` drops everything else.
9
+ kind: "user" | "agent" | "system";
7
10
  id: string;
8
11
  };
9
12
 
@@ -105,6 +108,17 @@ export type RelayEvent = {
105
108
  created_at: string;
106
109
  data: {
107
110
  message?: RelayMessage;
111
+ /**
112
+ * Present only when this event belongs to a group invocation: a human
113
+ * mentioned the agent, replied to it, or picked it. In a group the server
114
+ * delivers nothing to an agent that was not invoked, and every call the
115
+ * agent then makes about the message must carry this id back
116
+ * (`/typing`, `/responding`, and the reply itself all refuse without it).
117
+ * A direct message never carries one — the server rejects
118
+ * `invoked_agent_ids` outside a group — so its presence is also how the
119
+ * plugin knows it is in a group.
120
+ */
121
+ invocation_id?: string;
108
122
  [key: string]: unknown;
109
123
  };
110
124
  };
@@ -0,0 +1,28 @@
1
+ # Vendored `@relaymessenger/sdk` client
2
+
3
+ `client.ts`, `errors.ts`, `types.ts`, and `url.ts` are **verbatim copies** of
4
+ `packages/sdk/src/` in this same repository. Do not edit them here. Fix the SDK
5
+ and re-copy.
6
+
7
+ ## Why a copy
8
+
9
+ The plugin used to carry its own hand-rolled Relay client. The two drifted, and
10
+ the drift shipped a defect: the plugin's client had no `invocationId` on
11
+ `sendMessage`, `setTyping`, or `setResponding`, so the first group mention an
12
+ agent received wedged its whole event stream (REL-167). The SDK client has
13
+ always had those parameters.
14
+
15
+ The SDK is not published to npm yet (`packages/cli/CLAUDE.md`), so the plugin
16
+ cannot depend on it. Vendoring adopts the correct client now instead of growing
17
+ a second one.
18
+
19
+ ## Removing this directory
20
+
21
+ When `@relaymessenger/sdk` ships:
22
+
23
+ 1. Add it to `dependencies` in `integrations/openclaw/package.json`.
24
+ 2. Point `src/client.ts` at `@relaymessenger/sdk` instead of `./vendor/relay-sdk/*`.
25
+ 3. Delete this directory.
26
+
27
+ `src/client.ts` is the only file that imports from here, so that is the whole
28
+ swap.