From 35e21dd676e596a755a4b655386b1d5571d124bb Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 13 Oct 2026 17:45:21 +0200 Subject: [PATCH] fix(mcp): keep the input seq strictly increasing within one process (#615 merge-time fix) send_prompt and interrupt_session tagged input with seq = Date.now() under one clientId. The server drops a seq at or below the last one it saw for that clientId and still answers 200, so two inputs in one millisecond (parallel tool calls) or a clock stepped back lost the second input while the tool reported it sent. The seq is now max(last + 1, now). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/mcp-server.ts | 11 ++++++++--- test/mcp-server.test.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/mcp-server.ts b/src/mcp-server.ts index c02916ef..692977b4 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -391,7 +391,7 @@ export interface ToolRunnerDeps { request: ApiRequest; /** Ids of sessions this process started: the default allowlist for prompting and deleting. */ started: Set; - /** Exactly-once tag for this process's input (`seq` is the clock). */ + /** Exactly-once tag for this process's input (`seq` is the clock, kept strictly increasing). */ clientId: string; now?: () => number; } @@ -470,6 +470,11 @@ function readEnum(args: Record, key: string, export function createToolRunner(deps: ToolRunnerDeps) { const { config } = deps; + // The server drops any seq at or below the last one it saw for this clientId (and + // still answers 200), so two inputs in one millisecond, or a clock stepped back, + // must not reuse a seq: keep it strictly increasing for the life of the process. + let lastSeq = 0; + const nextSeq = (): number => (lastSeq = Math.max(lastSeq + 1, (deps.now ?? Date.now)())); function context(): AgentContext { if (!config.apiUrl) { @@ -664,7 +669,7 @@ export function createToolRunner(deps: ToolRunnerDeps) { const body = buildSendBody(text, { enter: true, clientId: deps.clientId, - seq: (deps.now ?? Date.now)(), + seq: nextSeq(), wait: wait ? true : undefined, waitTimeout: wait ? timeoutMs : undefined, }); @@ -788,7 +793,7 @@ export function createToolRunner(deps: ToolRunnerDeps) { { method: 'POST', path: `/api/v1/sessions/${encodeURIComponent(row.id)}/input`, - body: buildInterruptBody(deps.clientId, (deps.now ?? Date.now)()), + body: buildInterruptBody(deps.clientId, nextSeq()), }, signal ); diff --git a/test/mcp-server.test.ts b/test/mcp-server.test.ts index 9facc51a..ce34a217 100644 --- a/test/mcp-server.test.ts +++ b/test/mcp-server.test.ts @@ -411,6 +411,16 @@ describe('the tools', () => { expect(result.structured).toMatchObject({ outcome: 'not_delivered' }); }); + it('input seq stays strictly increasing within one millisecond, across send_prompt and interrupt_session', async () => { + // The runner's clock is frozen at 1: the server drops a repeated seq while still answering 200. + const { tools, calls } = runner(config({ allowAnySession: true })); + await tools.run('send_prompt', { session_id: USERS, text: 'one', wait: false }); + await tools.run('send_prompt', { session_id: USERS, text: 'two', wait: false }); + await tools.run('interrupt_session', { session_id: USERS }); + const seqs = calls.filter((c) => c.path.endsWith('/input')).map((c) => c.body?.seq); + expect(seqs).toEqual([1, 2, 3]); + }); + it('wait_for_session needs exactly one of until and match, and passes a marker literally', async () => { const { tools, calls } = runner(config()); expect((await tools.run('wait_for_session', { session_id: USERS })).isError).toBe(true);