@relaymessenger/openclaw-plugin 0.3.0 → 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.
@@ -24,9 +24,11 @@ import { createRelayClient, isAbortError, isRelayWebhookConflict, RelayApiError,
24
24
  import { createRelayCursorStore, openRelayCursorStateStore } from "./cursor-store.js";
25
25
  import { createRelayInboundDedupeGuard, createRelayInboundDeduper } from "./inbound-dedupe.js";
26
26
  import { buildRelayInboundFacts } from "./inbound.js";
27
+ import { relayInvocationFor, rememberRelayInvocation } from "./invocations.js";
27
28
  import { createRelayAccountLifecycleRegistry } from "./lifecycle.js";
28
29
  import { deriveRelayIdempotencyKey, RELAY_TEXT_CHUNK_LIMIT, reconcileRelayUnknownSend, sendRelayText, } from "./outbound.js";
29
30
  import { runRelayPollLoop } from "./poll-loop.js";
31
+ import { markRespondingBeforeAttempt } from "./responding.js";
30
32
  import { getRelayRuntime } from "./runtime.js";
31
33
  import { relaySenderIsAllowed, resolveRelayAllowedSenderIds } from "./security.js";
32
34
  export const RELAY_CHANNEL_ID = "relay";
@@ -104,11 +106,16 @@ const relayMessageAdapter = defineChannelMessageAdapter({
104
106
  if (text === null) {
105
107
  return null;
106
108
  }
109
+ const invocationId = relayInvocationFor({
110
+ accountId: account.accountId,
111
+ conversationId: ctx.to,
112
+ });
107
113
  const verdict = await reconcileRelayUnknownSend({
108
114
  client: relayClientForAccount(account),
109
115
  conversationId: ctx.to,
110
116
  text,
111
117
  replyToId: ctx.effectiveReplyToId ?? ctx.replyToId ?? null,
118
+ ...(invocationId ? { invocationId } : {}),
112
119
  idempotencyKey: deriveRelayIdempotencyKey({ deliveryQueueId: ctx.queueId }),
113
120
  });
114
121
  if (verdict.status === "sent") {
@@ -137,11 +144,19 @@ const relayMessageAdapter = defineChannelMessageAdapter({
137
144
  if (!account.configured) {
138
145
  throw new Error(`relay: account "${account.accountId}" has no Agent Token configured`);
139
146
  }
147
+ // A group reply must name the invocation it answers. Core's send context
148
+ // has no field for it, so the turn parks it in the invocation registry
149
+ // under (accountId, conversationId) and it is read back here.
150
+ const invocationId = relayInvocationFor({
151
+ accountId: account.accountId,
152
+ conversationId: ctx.to,
153
+ });
140
154
  const result = await sendRelayText({
141
155
  client: relayClientForAccount(account),
142
156
  conversationId: ctx.to,
143
157
  text: ctx.text,
144
158
  replyToId: ctx.replyToId ?? null,
159
+ ...(invocationId ? { invocationId } : {}),
145
160
  // Stable per (queueId, part): internal retries replay the same key,
146
161
  // so the server-side idempotent commit makes duplicates impossible by
147
162
  // contract. On a core with no part index the text names the part.
@@ -241,75 +256,97 @@ async function dispatchRelayInbound(params) {
241
256
  const recordDeliveryError = (error) => {
242
257
  deliveryError ??= error;
243
258
  };
244
- // Admission, runtime resolution, route/session lookup, envelope building,
245
- // and context finalization above are replay-safe. The durable attempt starts
246
- // immediately before OpenClaw can invoke the agent or its tools.
247
- await params.markAttempt();
248
- await runtime.channel.inbound.dispatchReply({
249
- cfg: params.cfg,
250
- channel: RELAY_CHANNEL_ID,
251
- accountId: account.accountId,
252
- agentId: route.agentId,
253
- routeSessionKey: route.sessionKey,
254
- storePath,
255
- ctxPayload,
256
- recordInboundSession: runtime.channel.session.recordInboundSession,
257
- dispatchReplyWithBufferedBlockDispatcher: runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
258
- delivery: {
259
- // Final replies go through the durable message adapter: core renders
260
- // and chunks them (chunker + textChunkLimit) and tracks the send as a
261
- // durable queue intent. Requiring reconcileUnknownSend forces
262
- // `durability: "required"`, so single-payload finals carry a stable
263
- // deliveryQueueId into send.text (stable idempotency key + exact
264
- // replay), and multi-chunk finals get core's queue-level crash
265
- // recovery. Replies land as plain messages, not quotes
266
- // (`replyToId: null`).
267
- durable: {
268
- to: facts.conversationId,
269
- replyToId: null,
270
- requiredCapabilities: { reconcileUnknownSend: true },
271
- },
272
- // Fallback for payloads the durable path does not carry (non-final
273
- // visible blocks). The event id + block/chunk ordinals identify each
274
- // logical send: retries reuse it while identical intentional blocks and
275
- // chunks remain distinct.
276
- deliver: async (payload) => {
277
- const text = payload && typeof payload === "object" && "text" in payload
278
- ? (payload.text ?? "")
279
- : "";
280
- if (!text.trim()) {
281
- return;
282
- }
283
- const logicalBlockId = `${facts.eventId}:block:${fallbackDeliveryIndex}`;
284
- fallbackDeliveryIndex += 1;
285
- try {
286
- let chunkIndex = 0;
287
- for (const chunk of chunkText(text, RELAY_TEXT_CHUNK_LIMIT)) {
288
- await sendRelayText({
289
- client: params.client,
290
- conversationId: facts.conversationId,
291
- text: chunk,
292
- idempotencyKey: deriveRelayIdempotencyKey({
293
- deliveryQueueId: logicalBlockId,
294
- deliveryPartIndex: chunkIndex,
295
- }),
296
- });
297
- chunkIndex += 1;
259
+ // Park the group invocation for the life of the turn. Core's durable send
260
+ // adapter is a separate entry point with no inbound context, so this is how
261
+ // the reply learns which invocation it answers.
262
+ const releaseInvocation = facts.invocationId
263
+ ? rememberRelayInvocation({
264
+ accountId: account.accountId,
265
+ conversationId: facts.conversationId,
266
+ invocationId: facts.invocationId,
267
+ })
268
+ : () => { };
269
+ try {
270
+ // Admission, runtime resolution, route/session lookup, envelope building,
271
+ // and context finalization above are replay-safe. The durable attempt starts
272
+ // immediately before OpenClaw can invoke the agent or its tools.
273
+ await markRespondingBeforeAttempt({
274
+ client: params.client,
275
+ facts,
276
+ label: "OpenClaw",
277
+ markAttempt: params.markAttempt,
278
+ ...(params.warn ? { onReceiptFailure: params.warn } : {}),
279
+ });
280
+ await runtime.channel.inbound.dispatchReply({
281
+ cfg: params.cfg,
282
+ channel: RELAY_CHANNEL_ID,
283
+ accountId: account.accountId,
284
+ agentId: route.agentId,
285
+ routeSessionKey: route.sessionKey,
286
+ storePath,
287
+ ctxPayload,
288
+ recordInboundSession: runtime.channel.session.recordInboundSession,
289
+ dispatchReplyWithBufferedBlockDispatcher: runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
290
+ delivery: {
291
+ // Final replies go through the durable message adapter: core renders
292
+ // and chunks them (chunker + textChunkLimit) and tracks the send as a
293
+ // durable queue intent. Requiring reconcileUnknownSend forces
294
+ // `durability: "required"`, so single-payload finals carry a stable
295
+ // deliveryQueueId into send.text (stable idempotency key + exact
296
+ // replay), and multi-chunk finals get core's queue-level crash
297
+ // recovery. Replies land as plain messages, not quotes
298
+ // (`replyToId: null`).
299
+ durable: {
300
+ to: facts.conversationId,
301
+ replyToId: null,
302
+ requiredCapabilities: { reconcileUnknownSend: true },
303
+ },
304
+ // Fallback for payloads the durable path does not carry (non-final
305
+ // visible blocks). The event id + block/chunk ordinals identify each
306
+ // logical send: retries reuse it while identical intentional blocks and
307
+ // chunks remain distinct.
308
+ deliver: async (payload) => {
309
+ const text = payload && typeof payload === "object" && "text" in payload
310
+ ? (payload.text ?? "")
311
+ : "";
312
+ if (!text.trim()) {
313
+ return;
298
314
  }
299
- }
300
- catch (error) {
301
- recordDeliveryError(error);
302
- throw error;
303
- }
315
+ const logicalBlockId = `${facts.eventId}:block:${fallbackDeliveryIndex}`;
316
+ fallbackDeliveryIndex += 1;
317
+ try {
318
+ let chunkIndex = 0;
319
+ for (const chunk of chunkText(text, RELAY_TEXT_CHUNK_LIMIT)) {
320
+ await sendRelayText({
321
+ client: params.client,
322
+ conversationId: facts.conversationId,
323
+ text: chunk,
324
+ ...(facts.invocationId ? { invocationId: facts.invocationId } : {}),
325
+ idempotencyKey: deriveRelayIdempotencyKey({
326
+ deliveryQueueId: logicalBlockId,
327
+ deliveryPartIndex: chunkIndex,
328
+ }),
329
+ });
330
+ chunkIndex += 1;
331
+ }
332
+ }
333
+ catch (error) {
334
+ recordDeliveryError(error);
335
+ throw error;
336
+ }
337
+ },
338
+ onError: recordDeliveryError,
304
339
  },
305
- onError: recordDeliveryError,
306
- },
307
- replyPipeline: {},
308
- });
309
- if (deliveryError) {
310
- throw deliveryError instanceof Error
311
- ? deliveryError
312
- : new Error(`relay reply delivery failed: ${String(deliveryError)}`);
340
+ replyPipeline: {},
341
+ });
342
+ if (deliveryError) {
343
+ throw deliveryError instanceof Error
344
+ ? deliveryError
345
+ : new Error(`relay reply delivery failed: ${String(deliveryError)}`);
346
+ }
347
+ }
348
+ finally {
349
+ releaseInvocation();
313
350
  }
314
351
  }
315
352
  // ---------------------------------------------------------------------------
@@ -433,12 +470,8 @@ async function startRelayAccount(ctx) {
433
470
  client,
434
471
  allowedSenderIds,
435
472
  markAttempt,
473
+ warn,
436
474
  });
437
- // Read watermark after the turn is handled: read implies delivered;
438
- // best effort — a failed receipt must not replay the event.
439
- await client
440
- .markRead({ conversationId: facts.conversationId, messageId: facts.messageId })
441
- .catch((error) => log(`[relay] markRead failed: ${String(error)}`));
442
475
  },
443
476
  });
444
477
  }
@@ -446,7 +479,11 @@ async function startRelayAccount(ctx) {
446
479
  if (abortSignal.aborted || isAbortError(error)) {
447
480
  return;
448
481
  }
449
- if (error instanceof RelayApiError && error.terminal) {
482
+ // Named as `kind === "auth"`, not `error.terminal`. The SDK client counts
483
+ // every non-retryable kind as terminal, which would swallow the 409 cases
484
+ // below — including `terminated_by_other_consumer`, whose whole point is
485
+ // to fall through to the supervisor's restart arbitration.
486
+ if (error instanceof RelayApiError && error.kind === "auth") {
450
487
  markTerminalDisconnect(error);
451
488
  }
452
489
  else if (isRelayWebhookConflict(error)) {
@@ -1,219 +1,13 @@
1
- // Thin Relay REST client for the OpenClaw channel plugin. Bespoke fetch until
2
- // the Relay SDK ships. Owns the abort-aware long poll, idempotent
3
- // sends, typing, and read watermarks. No SDK imports so unit tests run
4
- // without an OpenClaw runtime.
5
- import { isIP } from "node:net";
6
- export const DEFAULT_RELAY_BASE_URL = "https://api.relayapp.im";
7
- function isLoopbackHostname(hostname) {
8
- const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
9
- const ipVersion = isIP(normalized);
10
- if (ipVersion === 4) {
11
- return normalized.split(".")[0] === "127";
12
- }
13
- if (ipVersion === 6) {
14
- return normalized === "::1";
15
- }
16
- return (normalized === "localhost" ||
17
- normalized.endsWith(".localhost"));
18
- }
19
- /**
20
- * Validate and canonicalize the API origin before a bearer token can be sent
21
- * to it. Production/custom remote origins must use HTTPS. Plain HTTP remains
22
- * available only for an explicit loopback development server.
23
- */
24
- export function normalizeRelayBaseUrl(raw) {
25
- const candidate = raw?.trim() || DEFAULT_RELAY_BASE_URL;
26
- let url;
27
- try {
28
- url = new URL(candidate);
29
- }
30
- catch {
31
- throw new Error(`relay: invalid baseUrl ${JSON.stringify(candidate)}`);
32
- }
33
- if (url.username || url.password) {
34
- throw new Error("relay: baseUrl must not contain credentials");
35
- }
36
- if (url.search || url.hash) {
37
- throw new Error("relay: baseUrl must not contain a query or fragment");
38
- }
39
- if (!/^\/+$/u.test(url.pathname)) {
40
- throw new Error("relay: baseUrl must be an origin without a path");
41
- }
42
- if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) {
43
- throw new Error("relay: baseUrl must use HTTPS (HTTP is allowed only for loopback development)");
44
- }
45
- return url.origin;
46
- }
47
- /** Classified Relay API failure. `terminal` means operator action (bad token). */
48
- export class RelayApiError extends Error {
49
- status;
50
- kind;
51
- /** Server error code from the response body (`error.code`), when present. */
52
- code;
53
- constructor(message, params) {
54
- super(message);
55
- this.name = "RelayApiError";
56
- this.status = params.status;
57
- this.kind = params.kind;
58
- this.code = params.code;
59
- }
60
- get terminal() {
61
- return this.kind === "auth";
62
- }
63
- get retryable() {
64
- return this.kind === "retryable";
65
- }
66
- }
67
- /**
68
- * 409 from the webhook XOR rule: an enabled webhook endpoint makes long
69
- * polling unavailable until the operator disables it (server code
70
- * `conflict`, distinct from `terminated_by_other_consumer`).
71
- */
72
- export function isRelayWebhookConflict(error) {
73
- return (error instanceof RelayApiError &&
74
- error.status === 409 &&
75
- error.code !== "terminated_by_other_consumer");
76
- }
77
- export function classifyRelayHttpStatus(status) {
78
- if (status === 401) {
79
- return "auth";
80
- }
81
- if (status === 409) {
82
- return "conflict";
83
- }
84
- if (status === 408 || status === 429 || status >= 500) {
85
- return "retryable";
86
- }
87
- return "rejected";
88
- }
89
- export function isAbortError(error) {
90
- return error instanceof Error && error.name === "AbortError";
91
- }
92
- async function readErrorDetail(response) {
93
- try {
94
- const body = (await response.json());
95
- return {
96
- ...(body?.error?.code ? { code: body.error.code } : {}),
97
- message: body?.error?.message ?? body?.message ?? "",
98
- };
99
- }
100
- catch {
101
- return { message: "" };
102
- }
103
- }
104
- export function createRelayClient(options) {
105
- const baseUrl = normalizeRelayBaseUrl(options.baseUrl);
106
- const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
107
- const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
108
- const request = async (params) => {
109
- const url = new URL(`${baseUrl}${params.path}`);
110
- for (const [key, value] of Object.entries(params.query ?? {})) {
111
- if (value !== undefined) {
112
- url.searchParams.set(key, String(value));
113
- }
114
- }
115
- let response;
116
- const timeoutSignal = AbortSignal.timeout(params.timeoutMs ?? requestTimeoutMs);
117
- const signal = params.signal
118
- ? AbortSignal.any([params.signal, timeoutSignal])
119
- : timeoutSignal;
120
- try {
121
- response = await fetchImpl(url.toString(), {
122
- method: params.method,
123
- headers: {
124
- authorization: `Bearer ${options.token}`,
125
- ...(params.body === undefined ? {} : { "content-type": "application/json" }),
126
- ...params.headers,
127
- },
128
- ...(params.body === undefined ? {} : { body: JSON.stringify(params.body) }),
129
- signal,
130
- });
131
- }
132
- catch (error) {
133
- if (timeoutSignal.aborted && !params.signal?.aborted) {
134
- throw new RelayApiError(`relay: ${params.method} ${params.path} timed out after ${params.timeoutMs ?? requestTimeoutMs}ms`, { kind: "retryable" });
135
- }
136
- if (isAbortError(error)) {
137
- throw error;
138
- }
139
- // Network-level failure (DNS, reset, offline): always retryable.
140
- throw new RelayApiError(`relay: network error: ${String(error)}`, { kind: "retryable" });
141
- }
142
- if (!response.ok) {
143
- const detail = await readErrorDetail(response);
144
- throw new RelayApiError(`relay: ${params.method} ${params.path} failed with ${response.status}${detail.message ? `: ${detail.message}` : ""}`, {
145
- status: response.status,
146
- kind: classifyRelayHttpStatus(response.status),
147
- ...(detail.code ? { code: detail.code } : {}),
148
- });
149
- }
150
- return response;
151
- };
152
- return {
153
- getMe: async (params) => {
154
- const response = await request({
155
- method: "GET",
156
- path: "/v1/agents/me",
157
- signal: params?.signal,
158
- });
159
- const body = (await response.json());
160
- return body.agent;
161
- },
162
- pollEvents: async (params) => {
163
- const timeoutSeconds = Math.min(Math.max(params.timeoutSeconds ?? 30, 1), 30);
164
- // Guard against a wedged connection: the server holds <= timeout seconds,
165
- // so anything past timeout + slack is a dead socket, not a slow poll.
166
- const response = await request({
167
- method: "GET",
168
- path: "/v1/events",
169
- query: {
170
- cursor: params.cursor,
171
- timeout: timeoutSeconds,
172
- ...(params.limit === undefined ? {} : { limit: params.limit }),
173
- },
174
- signal: params.signal,
175
- timeoutMs: (timeoutSeconds + 15) * 1_000,
176
- });
177
- const body = (await response.json());
178
- const events = Array.isArray(body.events) ? body.events : [];
179
- const nextCursor = typeof body.next_cursor === "number" && Number.isSafeInteger(body.next_cursor)
180
- ? body.next_cursor
181
- : params.cursor;
182
- return { events, nextCursor };
183
- },
184
- sendMessage: async (params) => {
185
- const response = await request({
186
- method: "POST",
187
- path: "/v1/messages",
188
- headers: { "idempotency-key": params.idempotencyKey },
189
- body: {
190
- conversation_id: params.conversationId,
191
- parts: params.parts,
192
- ...(params.replyTo ? { reply_to: params.replyTo } : {}),
193
- },
194
- signal: params.signal,
195
- });
196
- const body = (await response.json());
197
- return { messages: body.messages };
198
- },
199
- setTyping: async (params) => {
200
- await request({
201
- method: "POST",
202
- path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/typing`,
203
- body: {
204
- started: params.started,
205
- ...(params.label ? { label: params.label } : {}),
206
- },
207
- signal: params.signal,
208
- });
209
- },
210
- markRead: async (params) => {
211
- await request({
212
- method: "POST",
213
- path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/read`,
214
- body: { message_id: params.messageId },
215
- signal: params.signal,
216
- });
217
- },
218
- };
219
- }
1
+ // The Relay API client the plugin uses. There is no implementation here: this
2
+ // is `@relaymessenger/sdk`'s client, vendored under `./vendor/relay-sdk`
3
+ // (see that directory's README for why, and for the one-file swap when the
4
+ // package ships).
5
+ //
6
+ // The plugin used to hand-roll its own client beside the SDK's. They drifted,
7
+ // and the drift was a defect: the hand-rolled one had no `invocationId` on
8
+ // `sendMessage`, `setTyping`, or `setResponding`, so an agent's first group
9
+ // mention wedged its entire event stream (REL-167).
10
+ import { createRelayClient as createVendoredRelayClient } from "./vendor/relay-sdk/client.js";
11
+ export { DEFAULT_RELAY_BASE_URL, normalizeRelayBaseUrl } from "./vendor/relay-sdk/url.js";
12
+ export { classifyRelayHttpStatus, isAbortError, isRelayWebhookConflict, RelayApiError, } from "./vendor/relay-sdk/errors.js";
13
+ export const createRelayClient = createVendoredRelayClient;
@@ -81,6 +81,10 @@ export function buildRelayInboundFacts(event, params) {
81
81
  return null;
82
82
  }
83
83
  const createdAtMs = Date.parse(message.created_at);
84
+ const invocationId = typeof event.data.invocation_id === "string"
85
+ && event.data.invocation_id.trim()
86
+ ? event.data.invocation_id
87
+ : undefined;
84
88
  return {
85
89
  eventId: event.event_id,
86
90
  messageId: message.id,
@@ -90,5 +94,6 @@ export function buildRelayInboundFacts(event, params) {
90
94
  ...(message.reply_to?.message_id ? { replyToId: message.reply_to.message_id } : {}),
91
95
  text,
92
96
  ...(Number.isFinite(createdAtMs) ? { timestamp: createdAtMs } : {}),
97
+ ...(invocationId ? { invocationId } : {}),
93
98
  };
94
99
  }
@@ -0,0 +1,47 @@
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();
21
+ function slotKey(accountId, conversationId) {
22
+ return `${accountId}\0${conversationId}`;
23
+ }
24
+ /**
25
+ * Hold `invocationId` for the life of one turn. Returns the release function;
26
+ * call it in a `finally` so a thrown turn cannot strand the slot.
27
+ *
28
+ * Releasing only clears the slot if this turn still owns it, so a turn that
29
+ * finishes after being superseded cannot delete its successor's id.
30
+ */
31
+ export function rememberRelayInvocation(params) {
32
+ const key = slotKey(params.accountId, params.conversationId);
33
+ pendingInvocations.set(key, params.invocationId);
34
+ return () => {
35
+ if (pendingInvocations.get(key) === params.invocationId) {
36
+ pendingInvocations.delete(key);
37
+ }
38
+ };
39
+ }
40
+ /** The invocation an outbound send in this conversation belongs to, if any. */
41
+ export function relayInvocationFor(params) {
42
+ return pendingInvocations.get(slotKey(params.accountId, params.conversationId));
43
+ }
44
+ /** Test seam: drop every slot. */
45
+ export function resetRelayInvocationsForTest() {
46
+ pendingInvocations.clear();
47
+ }
@@ -58,6 +58,7 @@ export async function sendRelayText(params) {
58
58
  conversationId: params.conversationId,
59
59
  parts: [{ type: "text", text: params.text }],
60
60
  ...(params.replyToId ? { replyTo: { message_id: params.replyToId } } : {}),
61
+ ...(params.invocationId ? { invocationId: params.invocationId } : {}),
61
62
  idempotencyKey: params.idempotencyKey,
62
63
  ...(params.signal ? { signal: params.signal } : {}),
63
64
  });
@@ -90,6 +91,7 @@ export async function reconcileRelayUnknownSend(params) {
90
91
  conversationId: params.conversationId,
91
92
  text: params.text,
92
93
  replyToId: params.replyToId ?? null,
94
+ ...(params.invocationId ? { invocationId: params.invocationId } : {}),
93
95
  idempotencyKey: params.idempotencyKey,
94
96
  });
95
97
  return { status: "sent", messageId: result.messageId, messages: result.messages };
@@ -94,6 +94,18 @@ export async function runRelayPollLoop(params) {
94
94
  catch (error) {
95
95
  if (!attempted) {
96
96
  params.deduper.releaseEvent(event.event_id);
97
+ // A rejection is the server's final answer: replaying the identical
98
+ // request produces the identical refusal. Holding the cursor for it
99
+ // is a livelock, and the cursor is ONE watermark for the whole
100
+ // channel — so a single permanently-refused event would starve every
101
+ // later message, direct ones included (REL-167). Losing one event is
102
+ // strictly better than losing the channel, so log it loudly and let
103
+ // the page cursor move past it.
104
+ if (error instanceof RelayApiError && error.kind === "rejected") {
105
+ log(`[relay] event ${event.event_id} was permanently rejected by the server, ` +
106
+ `skipping it so later messages are not starved: ${String(error)}`);
107
+ continue;
108
+ }
97
109
  log(`[relay] event ${event.event_id} safe preflight failed, will replay: ${String(error)}`);
98
110
  batchFailed = true;
99
111
  break;
@@ -0,0 +1,36 @@
1
+ import { RelayApiError } from "./client.js";
2
+ /**
3
+ * Record the read/responding receipt, then commit the durable attempt marker.
4
+ *
5
+ * The receipt is a courtesy to the person waiting: it turns their message Read
6
+ * and shows that something is composing. It is NOT permission to answer, and
7
+ * it used to be treated as such — a rejected receipt threw here, before
8
+ * `markAttempt`, which sent the poll loop down its replay branch and froze the
9
+ * channel's single delivery cursor. One group mention whose receipt the server
10
+ * refused therefore starved every later message, direct ones included
11
+ * (REL-167).
12
+ *
13
+ * So a failed receipt is reported and the turn continues. The ordering that
14
+ * mattered is kept: the receipt is still attempted BEFORE the attempt marker,
15
+ * so a receipt that succeeds still precedes any agent or tool work.
16
+ */
17
+ export async function markRespondingBeforeAttempt(params) {
18
+ const { facts } = params;
19
+ try {
20
+ await params.client.setResponding({
21
+ conversationId: facts.conversationId,
22
+ messageId: facts.messageId,
23
+ label: params.label,
24
+ ...(facts.invocationId ? { invocationId: facts.invocationId } : {}),
25
+ });
26
+ }
27
+ catch (error) {
28
+ // An aborted shutdown is not a receipt failure; let it settle the loop.
29
+ if (error instanceof Error && error.name === "AbortError") {
30
+ throw error;
31
+ }
32
+ const detail = error instanceof RelayApiError ? error.message : String(error);
33
+ params.onReceiptFailure?.(`responding receipt for message ${facts.messageId} failed, answering anyway: ${detail}`);
34
+ }
35
+ await params.markAttempt();
36
+ }