diff --git a/agent-roles-catalog/bundles/assistants/en.yaml b/agent-roles-catalog/bundles/assistants/en.yaml index 1d2adfce..306bc432 100644 --- a/agent-roles-catalog/bundles/assistants/en.yaml +++ b/agent-roles-catalog/bundles/assistants/en.yaml @@ -346,6 +346,9 @@ roles: □ Working sections ("Log", "Open Questions", "Revision") are moved to an appendix at the end of the document or clearly separated from the report body. + □ Once the report is complete, call save_page_version to pin the finished + document as a restorable named version (a checkpoint the reader can + return to). A repeat call after no further edits is a harmless no-op. Be honest about gaps. If you couldn't find something, say so — don't disguise a guess as a fact. autoStart: false @@ -442,6 +445,7 @@ roles: - **The language of the notes = the main language of the call.** Technical terms — in their canonical spelling (usually Latin). - **Don't evaluate the participants** and don't comment on the quality of the discussion. - The output is the notes only, with no preambles or meta-comments, apart from targeted uncertainty marks. + - **Pin the result.** Once the notes are complete on the page, call save_page_version to save them as a restorable named version (a checkpoint). Calling it again after no further edits is a harmless no-op. ## Style example (excerpt) diff --git a/agent-roles-catalog/bundles/assistants/ru.yaml b/agent-roles-catalog/bundles/assistants/ru.yaml index 5bf36f24..67b97493 100644 --- a/agent-roles-catalog/bundles/assistants/ru.yaml +++ b/agent-roles-catalog/bundles/assistants/ru.yaml @@ -345,6 +345,9 @@ roles: □ Working sections («Журнал», «Открытые вопросы», «Ревизия») are moved to an appendix at the end of the document or clearly separated from the report body. + □ Once the report is complete, call save_page_version to pin the finished + document as a restorable named version (a checkpoint the reader can + return to). A repeat call after no further edits is a harmless no-op. Be honest about gaps. If you couldn't find something, say so — don't disguise a guess as a fact. autoStart: false @@ -441,6 +444,7 @@ roles: - **Язык конспекта = основной язык созвона.** Технические термины — в каноническом написании (обычно латиницей). - **Не оценивай участников** и не комментируй качество обсуждения. - На выходе — только конспект, без преамбул и мета-комментариев, кроме точечных пометок неуверенности. + - **Зафиксируй результат.** Когда конспект готов на странице, вызови save_page_version, чтобы сохранить его как именованную версию (точку восстановления). Повторный вызов без новых правок — безвредный no-op. ## Пример стиля (фрагмент) diff --git a/agent-roles-catalog/index.yaml b/agent-roles-catalog/index.yaml index 1b2fe728..572c4413 100644 --- a/agent-roles-catalog/index.yaml +++ b/agent-roles-catalog/index.yaml @@ -33,6 +33,6 @@ bundles: - en roles: - slug: researcher - version: 9 + version: 10 - slug: call-summarizer - version: 1 + version: 2 diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts index 2e8e2fa4..e6ba7c39 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts @@ -106,6 +106,7 @@ function __assertClientCallContract(client: DocmostClientLike): void { void client.sharePage(s, true); void client.unsharePage(s); void client.restorePageVersion(s); + void client.savePageVersion(s); void client.transformPage(s, s, { dryRun: true }); void client.stashPage(s); // --- write (image / footnote), in-app since #410 --- diff --git a/packages/mcp/src/client/pages.ts b/packages/mcp/src/client/pages.ts index d3520040..47cc7257 100644 --- a/packages/mcp/src/client/pages.ts +++ b/packages/mcp/src/client/pages.ts @@ -11,6 +11,7 @@ import { markdownToProseMirror, markdownToProseMirrorCanonical, mutatePageContent, + savePageVersionRealtime, assertYjsEncodable, MutationResult, } from "../lib/collaboration.js"; @@ -55,6 +56,7 @@ export interface IPagesMixin { listPageHistory(pageId: string, cursor?: string): any; getPageHistory(historyId: string): any; restorePageVersion(historyId: string): any; + savePageVersion(pageId: string): any; diffPageVersions(pageId: string, from?: string, to?: string): any; } @@ -587,6 +589,22 @@ export function PagesMixin>(Bas }; } + /** + * Save an intentional NAMED version of a page's CURRENT live content (#370). + * The write goes over the same agent-authenticated collab session content edits + * use, so the server derives kind='agent' from the signed actor. Returns the + * created (or promoted) history id, its kind, and whether it was already saved. + */ + async savePageVersion(pageId: string) { + const collabToken = await this.getCollabTokenWithReauth(); + const pageUuid = await this.resolvePageId(pageId); + // Self-heal a rejected WS handshake once (#486): re-mint the collab token and + // retry, symmetric to the content-write path. + return this.writeWithCollabAuthRetry(collabToken, (token) => + savePageVersionRealtime(pageUuid, token, this.apiUrl), + ); + } + /** * Diff two versions of a page and return a Docmost-equivalent change set. * `from`/`to` each resolve to a ProseMirror doc: diff --git a/packages/mcp/src/lib/collab-session.ts b/packages/mcp/src/lib/collab-session.ts index ae0153f5..177268c1 100644 --- a/packages/mcp/src/lib/collab-session.ts +++ b/packages/mcp/src/lib/collab-session.ts @@ -139,7 +139,13 @@ export interface CollabProviderLike { unsyncedChanges: number; destroy(): void; on(event: "unsyncedChanges", handler: (data: { number: number }) => void): void; + on(event: "stateless", handler: (data: { payload: string }) => void): void; off(event: "unsyncedChanges", handler: (data: { number: number }) => void): void; + off(event: "stateless", handler: (data: { payload: string }) => void): void; + // Send a stateless (out-of-band, non-doc) message to the server. Used by the + // #370 explicit save-version path (payload `{type:'save-version'}`); the server + // replies with a broadcast `{type:'version.saved', …}` on the same channel. + sendStateless(payload: string): void; } /** The configuration object passed to the provider factory. */ @@ -564,6 +570,125 @@ export class CollabSession { }); } + /** + * Send a stateless message over the live collaboration connection and resolve + * with the FIRST reply whose parsed payload satisfies `predicate`, rejecting on + * a bounded `timeoutMs`. + * + * Used by the #370 explicit save-version path: the client sends + * `{type:'save-version'}` and awaits the server's broadcast + * `{type:'version.saved', historyId, kind, alreadySaved}`. The stateless channel + * (not a REST read) is deliberate — the server versions the LIVE in-memory ydoc, + * which the up-to-10s-stale page row would not yet reflect. + * + * `predicate` receives each incoming stateless message (already JSON-parsed) and + * returns the resolved value for a match or `undefined` to keep waiting; a + * malformed / unrelated payload is ignored. The listener is registered BEFORE + * the send so a fast reply is never missed. + * + * Lifecycle mirrors mutate(): it registers through `inflightReject` so a + * disconnect/close/auth-failure/teardown rejects THIS wait with the same + * connection-loss error, refuses to run on a non-ready session, fails fast on a + * concurrent in-flight op (the caller MUST hold the per-page lock), and re-arms + * the idle TTL (or self-destroys an ephemeral session) on completion. + * + * NOTE: the server broadcast carries no request-correlation id, so under a + * genuinely concurrent save on the SAME page (e.g. a human Cmd+S racing the + * agent) this resolves on whichever `version.saved` arrives first. The per-page + * lock serializes THIS process's saves; a cross-client race is inherent to the + * broadcast design and is benign (the result still describes a real save of this + * page's current content, and the server's save is promote-not-duplicate). + */ + sendStatelessAndAwait( + payload: string, + predicate: (message: any) => T | undefined, + timeoutMs: number, + ): Promise { + // Belt-and-suspenders (acquire already validated): refuse to send on a + // session that is not in a live, synced, ready state. + if ( + this.dead || + this.state !== "ready" || + this.connectionLost || + !this.provider || + this.provider.synced !== true + ) { + return Promise.reject( + new Error("Collaboration session is not in a ready state"), + ); + } + + // Fail-fast on concurrent use: a second overlapping op would overwrite the + // first's inflightReject and hang it on disconnect (same guard as mutate). + if (this.inflightReject) { + return Promise.reject( + new Error( + "stateless op already in-flight; caller must serialize (hold the page lock)", + ), + ); + } + + return new Promise((resolve, reject) => { + let settled = false; + let timer: ReturnType | undefined; + let statelessHandler: ((data: { payload: string }) => void) | undefined; + + const localFinish = (err: Error | null, value?: T) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + if (statelessHandler && this.provider) { + try { + this.provider.off("stateless", statelessHandler); + } catch (e) {} + } + this.inflightReject = undefined; + if (err) reject(err); + else resolve(value as T); + // Post-settle lifecycle, identical to mutate's localFinish. + if (this.ephemeral) { + this.destroy("ephemeral op complete"); + } else if (!this.dead) { + this.armIdle(); + } + }; + + // Register so a disconnect/close/auth-failure/teardown rejects THIS op with + // the connection-loss error text (the `settled` guard makes a racing + // teardown + normal resolve safe — first one wins). + this.inflightReject = (e: Error) => localFinish(e); + + // Register the reply listener BEFORE sending so a fast server broadcast is + // never missed. + statelessHandler = (data: { payload: string }) => { + if (settled) return; + let message: any; + try { + message = JSON.parse(data.payload); + } catch { + return; // unrelated / malformed stateless message — keep waiting + } + const matched = predicate(message); + if (matched !== undefined) localFinish(null, matched); + }; + this.provider!.on("stateless", statelessHandler); + + timer = setTimeout(() => { + localFinish( + new Error( + `Timeout waiting for a stateless reply from the collaboration server ${this.hint()}`, + ), + ); + }, timeoutMs); + + try { + this.provider!.sendStateless(payload); + } catch (e) { + localFinish(e instanceof Error ? e : new Error(String(e))); + } + }); + } + /** (Re)arm the idle TTL so the clock starts from the most recent activity. */ armIdle(): void { if (this.dead || this.ephemeral) return; diff --git a/packages/mcp/src/lib/collaboration.ts b/packages/mcp/src/lib/collaboration.ts index 379c99dc..3e7513bf 100644 --- a/packages/mcp/src/lib/collaboration.ts +++ b/packages/mcp/src/lib/collaboration.ts @@ -324,6 +324,76 @@ export async function mutatePageContent( }); } +/** + * Stateless-channel message types for the #370 explicit save-version handshake. + * These MUST match the server constants in + * apps/server/src/collaboration/extensions/persistence.extension.ts + * (SAVE_VERSION_MESSAGE_TYPE / the 'version.saved' broadcast) — the mcp package + * cannot import server code, so the literals are duplicated here with this note. + */ +const SAVE_VERSION_MESSAGE_TYPE = "save-version"; +const VERSION_SAVED_MESSAGE_TYPE = "version.saved"; + +/** + * Bounded wait for the server's `version.saved` broadcast after we ask it to save + * a version. The server flushes the live ydoc through its store path and writes a + * history row inside a DB transaction before broadcasting, so allow the same + * headroom as a persistence ack; reject (do NOT hang) past it. + */ +const SAVE_VERSION_ACK_TIMEOUT_MS = 20000; + +/** The resolved shape of an explicit save-version, surfaced to the tool caller. */ +export interface SaveVersionResult { + historyId: string; + kind: string; + alreadySaved: boolean; +} + +/** + * Save an intentional version of a page's CURRENT live collaboration content + * (#370). Runs under the per-page lock, acquires the SAME cached CollabSession the + * content writes use (#400) — so it authenticates with the caller's agent collab + * token, and the server derives kind='agent' from that signed actor — then sends a + * `{type:'save-version'}` stateless message and awaits the server's + * `{type:'version.saved', …}` reply on the same channel. + * + * Deliberately does NOT read `pages.content` over REST: the versioned content is + * the live in-memory ydoc, which the debounced (up-to-10s-stale) page row would + * not yet reflect. The stateless round-trip is what makes the save exact. + * + * On any failure the session is destroyed so the next call reconnects fresh, and + * the error propagates. A save is safe to retry — the server promotes-not- + * duplicates an identical latest version — so the caller (and its agent) may + * re-issue it without risking a duplicate heavy history row. + */ +export async function savePageVersionRealtime( + pageId: PageId, + collabToken: string, + baseUrl: string, +): Promise { + return withPageLock(pageId, async () => { + const session = await acquireCollabSession(pageId, collabToken, baseUrl); + try { + return await session.sendStatelessAndAwait( + JSON.stringify({ type: SAVE_VERSION_MESSAGE_TYPE }), + (message) => + message && message.type === VERSION_SAVED_MESSAGE_TYPE + ? { + historyId: String(message.historyId), + kind: String(message.kind), + alreadySaved: !!message.alreadySaved, + } + : undefined, + SAVE_VERSION_ACK_TIMEOUT_MS, + ); + } catch (e) { + // Drop the session on any failure so the next call reconnects fresh. + session.destroy("save-version failed"); + throw e; + } + }); +} + /** * Replace the live content of a page over the collaboration websocket. * Accepts a ready ProseMirror JSON document; the caller controls whether diff --git a/packages/mcp/src/server-instructions.ts b/packages/mcp/src/server-instructions.ts index c919b864..adda4dac 100644 --- a/packages/mcp/src/server-instructions.ts +++ b/packages/mcp/src/server-instructions.ts @@ -44,7 +44,7 @@ export const ROUTING_PROSE = "EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> PREFER the high-level semantic tools that hide coordinates/styles: drawioFromGraph (architecture/cloud/network diagrams — describe nodes/groups/edges by kind+icon, the server picks layout, colors and verified icons; hints layer/sameLayerAs/pinned and layout:full|incremental|none) and drawioFromMermaid (standard flowcharts — write Mermaid, get an editable diagram). For targeted tweaks of an existing diagram use drawioEditCells (id-based add/update/delete with cascade delete + baseHash lock). Raw mxGraph XML via drawioCreate/drawioUpdate is the escape-hatch for exotic/wireframe diagrams; drawioGet reads a diagram as mxGraph XML + a hash (pass it as baseHash to drawioUpdate/drawioEditCells for optimistic locking). Before authoring raw XML, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" + "PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" + "COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" + - "HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown."; + "HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Pin the page's CURRENT content as a restorable named checkpoint when you finish a coherent editing pass -> savePageVersion (kind derived server-side as an agent version; an identical-to-last save is promoted/no-op'd, so a redundant call is harmless). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown."; /** * Non-tool camelCase identifiers that legitimately appear in ROUTING_PROSE: @@ -218,6 +218,7 @@ const TOOL_FAMILY: Record = { diffPageVersions: "HISTORY", listPageHistory: "HISTORY", restorePageVersion: "HISTORY", + savePageVersion: "HISTORY", exportPageMarkdown: "HISTORY", // importPageMarkdown is now inAppOnly (#411) — it is not registered on the // external MCP host, so it no longer appears in the generated inventory. diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts index c94eba12..ea501be8 100644 --- a/packages/mcp/src/tool-specs.ts +++ b/packages/mcp/src/tool-specs.ts @@ -93,6 +93,7 @@ export type DocmostClientLike = Pick< | 'sharePage' | 'unsharePage' | 'restorePageVersion' + | 'savePageVersion' | 'stashPage' | 'insertFootnote' | 'insertImage' @@ -743,6 +744,28 @@ export const SHARED_TOOL_SPECS = { client.restorePageVersion(historyId as string), }, + savePageVersion: { + mcpName: 'savePageVersion', + inAppKey: 'savePageVersion', + writeClass: 'write', + description: + 'Save an intentional, NAMED version (kind=agent) of the page\'s CURRENT ' + + 'live content — a restorable checkpoint pinned into its history. Call it ' + + 'when you have FINISHED a coherent editing pass (not after every small ' + + 'edit), so the reader can see and roll back to the state you left. The ' + + 'version type is derived SERVER-SIDE from your signed agent identity (you ' + + 'cannot mislabel it); a save whose content is IDENTICAL to the last saved ' + + 'version is promoted/no-op\'d server-side, so a redundant call is harmless ' + + 'and never duplicates a version. Returns { historyId, kind, alreadySaved }.', + tier: 'deferred', + catalogLine: + 'savePageVersion — pin the page\'s current content as a named agent version (restorable checkpoint).', + buildShape: (z) => ({ + pageId: z.string().min(1), + }), + execute: (client, { pageId }) => client.savePageVersion(pageId as string), + }, + // --- markdown round-trip --- importPageMarkdown: { diff --git a/packages/mcp/test/unit/save-page-version.test.mjs b/packages/mcp/test/unit/save-page-version.test.mjs new file mode 100644 index 00000000..6e15a593 --- /dev/null +++ b/packages/mcp/test/unit/save-page-version.test.mjs @@ -0,0 +1,158 @@ +import { test, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; + +import { + destroyAllSessions, + __setCollabProviderFactory, +} from "../../build/lib/collab-session.js"; +import { savePageVersionRealtime } from "../../build/lib/collaboration.js"; + +// #370 — unit coverage for the MCP client transport of the explicit save-version. +// There is no collaboration server in the test env, so a fake HocuspocusProvider +// drives the stateless round-trip: it auto-completes the connect/sync handshake on +// a microtask (like the real provider), records sends, and — depending on the +// `reply` opt — either broadcasts a `version.saved` stateless message back or stays +// silent (to exercise the bounded-timeout path). +class FakeProvider extends EventEmitter { + static instances = []; + + static reset() { + FakeProvider.instances = []; + } + + constructor(config, opts = {}) { + super(); + this.config = config; + this.synced = false; + this.unsyncedChanges = 0; + this.destroyed = false; + this.sent = []; + this.opts = opts; + FakeProvider.instances.push(this); + // Real HocuspocusProvider fires onSynced asynchronously after the handshake; + // a microtask reproduces that without depending on timers (so mock.timers on + // setTimeout does not stall the connect). + queueMicrotask(() => { + if (this.destroyed) return; + this.config.onConnect?.(); + this.synced = true; + this.config.onSynced?.(); + }); + } + + destroy() { + this.destroyed = true; + } + + sendStateless(payload) { + this.sent.push(payload); + const reply = this.opts.reply; + if (!reply) return; // silent → the caller must hit its timeout + // Broadcast the configured server reply on a microtask, mirroring + // document.broadcastStateless landing back on this provider's `stateless` event. + queueMicrotask(() => { + if (this.destroyed) return; + this.emit("stateless", { payload: JSON.stringify(reply) }); + }); + } +} + +const ENV_KEYS = [ + "MCP_COLLAB_SESSION_IDLE_MS", + "MCP_COLLAB_SESSION_MAX_AGE_MS", + "MCP_COLLAB_SESSION_MAX_ENTRIES", + "MCP_COLLAB_TOKEN_TTL_MS", +]; +let savedEnv; + +beforeEach(() => { + savedEnv = {}; + for (const k of ENV_KEYS) savedEnv[k] = process.env[k]; + FakeProvider.reset(); +}); + +afterEach(() => { + destroyAllSessions(); + __setCollabProviderFactory(null); + mock.timers.reset(); + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } +}); + +test("resolves with the server's version.saved reply (historyId/kind/alreadySaved)", async () => { + __setCollabProviderFactory( + (config) => + new FakeProvider(config, { + reply: { + type: "version.saved", + historyId: "hist-42", + kind: "agent", + alreadySaved: false, + }, + }), + ); + + const result = await savePageVersionRealtime( + "11111111-1111-4111-8111-111111111111", + "collab-tok", + "http://host/api", + ); + + assert.deepEqual(result, { + historyId: "hist-42", + kind: "agent", + alreadySaved: false, + }); + // The client actually asked the server to save a version (the stateless send). + const provider = FakeProvider.instances.at(-1); + assert.deepEqual( + provider.sent.map((p) => JSON.parse(p)), + [{ type: "save-version" }], + ); +}); + +test("surfaces alreadySaved=true (promote-not-dup / no-op save)", async () => { + __setCollabProviderFactory( + (config) => + new FakeProvider(config, { + reply: { + type: "version.saved", + historyId: "hist-7", + kind: "manual", + alreadySaved: true, + }, + }), + ); + + const result = await savePageVersionRealtime( + "22222222-2222-4222-8222-222222222222", + "collab-tok", + "http://host/api", + ); + + assert.deepEqual(result, { + historyId: "hist-7", + kind: "manual", + alreadySaved: true, + }); +}); + +test("rejects on a bounded timeout when the server never replies", async () => { + __setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent + + mock.timers.enable({ apis: ["setTimeout"] }); + const p = savePageVersionRealtime("33333333-3333-4333-8333-333333333333", "collab-tok", "http://host/api"); + // Swallow the eventual rejection so an unhandled-rejection does not race the tick. + p.catch(() => {}); + + // Flush the (microtask-driven) connect handshake + the stateless send so the + // ack timer is armed before we advance the clock. + await new Promise((r) => setImmediate(r)); + // Advance well past the 20s ack window; the connect timer (25s) does not fire. + mock.timers.tick(20000 + 100); + + await assert.rejects(p, /Timeout waiting for a stateless reply/); +});