@mulmobridge/chat-service 1.3.0 → 1.3.2

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/relay.d.ts CHANGED
@@ -34,6 +34,8 @@ export interface RelayDeps {
34
34
  getRole: (roleId: string) => Role;
35
35
  defaultRoleId: string;
36
36
  logger: Logger;
37
+ /** Monotonic milliseconds for the reply limit. Defaults to `performance.now()`. */
38
+ now?: () => number;
37
39
  }
38
40
  export declare function createRelay(deps: RelayDeps): RelayFn;
39
41
  export declare function resolveDefaultRole(bridgeOptions: Readonly<Record<string, string | number | boolean>> | undefined, getRole: (roleId: string) => Role, fallbackRoleId: string, logger: Logger, transportId: string): string;
package/dist/relay.js CHANGED
@@ -8,6 +8,8 @@
8
8
  // host.
9
9
  import { EVENT_TYPES, resolveReplyTimeoutMs } from "@mulmobridge/protocol";
10
10
  import { createKeyedSerializer } from "./keyed-serializer.js";
11
+ import { remainingReplyMs } from "./reply-deadline.js";
12
+ import { startChatWhenIdle } from "./start-when-idle.js";
11
13
  // ── Factory ──────────────────────────────────────────────────
12
14
  export function createRelay(deps) {
13
15
  const serialize = createKeyedSerializer();
@@ -17,10 +19,19 @@ export function createRelay(deps) {
17
19
  // sessions, and split the conversation across them (#1878). The
18
20
  // JSON pair is an unambiguous composite key for the two ids.
19
21
  const key = JSON.stringify([params.transportId, params.externalChatId]);
20
- return serialize.run(key, () => processRelayMessage(deps, params));
22
+ const budget = {
23
+ receivedAtMs: clockOf(deps)(),
24
+ replyTimeoutMs: resolveBridgeReplyTimeout(params.bridgeOptions, deps.logger, params.transportId),
25
+ };
26
+ return serialize.run(key, () => processRelayMessage(deps, params, budget));
21
27
  };
22
28
  }
23
- async function processRelayMessage(deps, params) {
29
+ // Neutral about the cause: a queue behind an earlier message, or slow setup on an idle chat.
30
+ const EXPIRED_BEFORE_START_REPLY = "The request timed out before the agent could start on it. Please send it again.";
31
+ // Monotonic by default, so a wall-clock step between receipt and processing cannot move the limit.
32
+ const clockOf = (deps) => deps.now ?? (() => performance.now());
33
+ const remainingOf = (budget, deps) => remainingReplyMs(budget.receivedAtMs, budget.replyTimeoutMs, clockOf(deps)());
34
+ async function processRelayMessage(deps, params, budget) {
24
35
  const { store, handleCommand, startChat, onSessionEvent, getRole, defaultRoleId, logger } = deps;
25
36
  const { transportId, externalChatId, attachments, bridgeOptions } = params;
26
37
  let { text } = params;
@@ -60,7 +71,14 @@ async function processRelayMessage(deps, params) {
60
71
  chatState = commandResult.nextState;
61
72
  text = commandResult.forwardAs;
62
73
  }
63
- const result = await startChat({
74
+ // Checked here, not on entry: a command above still runs and answers at once, but an agent turn
75
+ // started after the limit would produce a reply nobody is waiting for.
76
+ if (remainingOf(budget, deps) === 0) {
77
+ logger.info("chat-service", "message expired before its turn started", { transportId, externalChatId });
78
+ return { kind: "ok", reply: EXPIRED_BEFORE_START_REPLY };
79
+ }
80
+ const idleStartDeps = { startChat, onSessionEvent, remainingMs: () => remainingOf(budget, deps) };
81
+ const result = await startChatWhenIdle(idleStartDeps, {
64
82
  message: text,
65
83
  roleId: chatState.roleId,
66
84
  chatSessionId: chatState.sessionId,
@@ -70,18 +88,12 @@ async function processRelayMessage(deps, params) {
70
88
  // we forward the whole bag untouched.
71
89
  bridgeOptions,
72
90
  });
91
+ if (result.kind === "expired") {
92
+ logger.info("chat-service", "message expired waiting for the session to finish", { transportId, externalChatId });
93
+ return { kind: "ok", reply: EXPIRED_BEFORE_START_REPLY };
94
+ }
73
95
  if (result.kind === "error") {
74
96
  const status = result.status ?? 500;
75
- if (status === 409) {
76
- // Session busy — tell the bridge to retry. Keep the HTTP
77
- // response shape the old handler returned (status 409 on
78
- // the HTTP side, "ok" reply text on the socket side — both
79
- // layers decide how to serialise).
80
- return {
81
- kind: "ok",
82
- reply: "A previous message is still being processed. Please wait.",
83
- };
84
- }
85
97
  logger.error("chat-service", "startChat failed", {
86
98
  transportId,
87
99
  externalChatId,
@@ -94,8 +106,7 @@ async function processRelayMessage(deps, params) {
94
106
  };
95
107
  }
96
108
  try {
97
- const replyTimeoutMs = resolveBridgeReplyTimeout(bridgeOptions, logger, transportId);
98
- const reply = await collectAgentReply(onSessionEvent, chatState.sessionId, replyTimeoutMs, params.onChunk);
109
+ const reply = await collectAgentReply(onSessionEvent, chatState.sessionId, remainingOf(budget, deps), params.onChunk);
99
110
  await store.setChatState(transportId, {
100
111
  ...chatState,
101
112
  updatedAt: new Date().toISOString(),
@@ -0,0 +1,2 @@
1
+ /** Milliseconds of the reply limit still left at `nowMs`; never negative. */
2
+ export declare function remainingReplyMs(receivedAtMs: number, replyTimeoutMs: number, nowMs: number): number;
@@ -0,0 +1,9 @@
1
+ // A turn's reply limit is counted from when the relay RECEIVED the message,
2
+ // not from when the turn reached the front of its chat's queue. The bridge
3
+ // client starts its ack timer when it sends, so a limit counted from the start
4
+ // of collection let a queued or slow-to-start turn outlive the client's wait
5
+ // and have its reply dropped (#3312).
6
+ /** Milliseconds of the reply limit still left at `nowMs`; never negative. */
7
+ export function remainingReplyMs(receivedAtMs, replyTimeoutMs, nowMs) {
8
+ return Math.max(0, receivedAtMs + replyTimeoutMs - nowMs);
9
+ }
@@ -0,0 +1,11 @@
1
+ import type { OnSessionEventFn, StartChatFn, StartChatParams, StartChatResult } from "./types.js";
2
+ export type IdleStartResult = StartChatResult | {
3
+ kind: "expired";
4
+ };
5
+ export interface IdleStartDeps {
6
+ startChat: StartChatFn;
7
+ onSessionEvent: OnSessionEventFn;
8
+ /** Milliseconds this message may still wait; 0 once its limit has passed. */
9
+ remainingMs: () => number;
10
+ }
11
+ export declare function startChatWhenIdle(deps: IdleStartDeps, params: StartChatParams): Promise<IdleStartResult>;
@@ -0,0 +1,52 @@
1
+ // Start a bridge turn even when the session is still busy with an earlier run.
2
+ //
3
+ // A turn cut off at its reply limit leaves its agent running, so the next
4
+ // turn's `startChat()` used to get 409 and the message was dropped with
5
+ // "please wait" (#3320). Instead, wait for that run to finish — within the
6
+ // message's own remaining time — and start then.
7
+ import { EVENT_TYPES } from "@mulmobridge/protocol";
8
+ // `startChat()` answers 409 before it saves or broadcasts anything, so trying again is safe.
9
+ const isBusy = (result) => result.kind === "error" && result.status === 409;
10
+ function waitForSessionFinished(onSessionEvent, sessionId, timeoutMs) {
11
+ const handles = {};
12
+ const finished = new Promise((resolve) => {
13
+ handles.settle = (value) => {
14
+ handles.done = true;
15
+ clearTimeout(handles.timer);
16
+ handles.unsubscribe?.();
17
+ resolve(value);
18
+ };
19
+ });
20
+ const settle = (value) => handles.settle?.(value);
21
+ handles.timer = setTimeout(() => settle(false), timeoutMs);
22
+ handles.unsubscribe = onSessionEvent(sessionId, (event) => {
23
+ if (event.type === EVENT_TYPES.sessionFinished)
24
+ settle(true);
25
+ });
26
+ // A listener fired during subscription settled before `unsubscribe` existed.
27
+ if (handles.done)
28
+ handles.unsubscribe();
29
+ // A cancelled wait still settles, so nothing is left pending behind it.
30
+ return { finished, cancel: () => settle(false) };
31
+ }
32
+ async function retryWhenFinished(deps, params) {
33
+ const remainingMs = deps.remainingMs();
34
+ if (remainingMs === 0)
35
+ return { kind: "expired" };
36
+ // Subscribe BEFORE trying again: a run that ends between the 409 and the
37
+ // subscription would otherwise never be seen, and this would wait out the limit.
38
+ const wait = waitForSessionFinished(deps.onSessionEvent, params.chatSessionId, remainingMs);
39
+ const retried = await deps.startChat(params).catch((err) => {
40
+ wait.cancel();
41
+ throw err;
42
+ });
43
+ if (!isBusy(retried)) {
44
+ wait.cancel();
45
+ return retried;
46
+ }
47
+ return (await wait.finished) ? retryWhenFinished(deps, params) : { kind: "expired" };
48
+ }
49
+ export async function startChatWhenIdle(deps, params) {
50
+ const first = await deps.startChat(params);
51
+ return isBusy(first) ? retryWhenFinished(deps, params) : first;
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/chat-service",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Server-side chat service for MulmoBridge — socket.io + REST bridge to Claude Code agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",