Skip to content

Hydration restores a parent interrupt instead of rejoining an active continuation run #1429

Description

@maulik9898

TanStack AI version

@tanstack/ai 0.55.0

Framework/Library version

@tanstack/ai-client: 0.32.1 | @tanstack/ai-persistence: 0.6.0 | React, custom connection adapter

Describe the bug and the steps to reproduce it

With server-authoritative persistence, reloading the page while a continuation run is
streaming never rejoins that run. The client restores the parent run's interrupt instead,
the reply that is still being produced never appears, and the thread is left parked.

Steps:

  1. ChatClient({ persistence: true }) with a connection adapter that implements connect,
    joinRun and hydrate, and a server using withPersistence(memoryPersistence()) plus a
    detached producer writing every chunk to a StreamDurability log.
  2. Run A pauses on a client-side tool: RUN_FINISHED with
    outcome: { type: "interrupt", interrupts: [client_tool_execution] }.
  3. The browser runs the tool and submits the result. That starts run B with
    parentRunId: A and resume: [{ interruptId, status: "resolved", payload }].
  4. While B is streaming text, reload the page.
  5. attach() → hydrate() returns both of these:
{
  "messages": [ "… the saved transcript …" ],
  "activeRun": { "runId": "B" },
  "interrupts": {
    "runId": "A",
    "pending": [
      {
        "id": "client_tool_call_1",
        "reason": "tanstack:client_tool_execution",
        "toolCallId": "call_1",
        "metadata": { "tanstack:interruptBinding": { "interruptedRunId": "A", "generation": 0 } }
      }
    ]
  }
}

Expected: the client joins B, so the reply finishes in place. A's interrupt must not be
re-run and must not be consumed early, so it stays retryable if B fails.

Actual: joinRun is never called. A's (already answered) interrupt is restored, the
thread is parked, and sendMessage then throws
cannot send normal input while pending interrupts exist.

Cause

Two deliberate behaviours meet in a state neither expects.

@tanstack/ai-persistence commits a resume only at a success boundary, which is documented:

Resumes are committed (resolved/cancelled in the store) only once the run reaches a
successful interrupt or finish boundary.

So while B streams, A's interrupt is still pending — correctly, because B may still fail
and the answer has to stay retryable (middleware.ts:2148-2153 in 0.6.0).

@tanstack/ai-client gives any pending interrupt priority over activeRun
(chat-client.ts:1093-1112 in 0.32.1). Its comment gives the reason:

This is checked BEFORE activeRun on purpose: a run that just paused can momentarily
still read as running on the server, so a racing hydrate reports both an activeRun
cursor AND the pending interrupt. Tailing that "active" run would drop the approval card
(and hang on a stream that never comes), so the interrupt always wins.

That race is real, but in it activeRun.runId === interrupts.runId: the same run paused
and still reads as running. In the case above the ids differ, and the active run is the
continuation that consumed the pending batch. The branch cannot tell the two apart, because
reconstructChat emits no lineage: activeRun is only { runId }
(reconstruct.ts:174-182 in 0.6.0).

Your Minimal, Reproducible Example

No sandbox yet. The setup is a small chat over oRPC, condensed here; the transport is not
involved in the failure, since the decision happens inside hydrateFromServer.

// ── server: four procedures ────────────────────────────────────────────────
const chat = procedure.handler(async ({ input, signal }) => {
  await startRun(input);                 // detached producer, below
  return readRun(input.runId, signal);   // event iterator over the durable log
});
const resume = procedure.handler(({ input, signal }) => readRun(input.runId, signal));
const get    = procedure.handler(({ input }) => getSnapshot(input.threadId));
const stop   = procedure.handler(({ input }) => ({ stopped: stopRun(input.runId) }));

// ── server: the producer outlives the request ──────────────────────────────
const persistence = memoryPersistence();

const startRun = async (body) => {
  const { messages, threadId, runId, parentRunId, resume } =
    await chatParamsFromRequestBody(body);
  const log = memoryStream({ runId });
  await log.append([{ type: "CUSTOM", name: RUN_ACCEPTED_EVENT, value: {}, timestamp: Date.now() }]);

  void (async () => {                    // not tied to the request
    try {
      const stream = chat({
        adapter, messages, tools, threadId, runId, parentRunId, resume,
        middleware: [withPersistence(persistence)],
      });
      for await (const chunk of stream) await log.append([chunk]);
    } finally {
      await log.close();
    }
  })();
};

const readRun = (runId, signal) =>
  replayRunStream(memoryStream({ runId }), undefined, signal);

const getSnapshot = async (threadId) => {
  const url = new URL("http://local/get");
  url.searchParams.set("threadId", threadId);
  const response = await reconstructChat(persistence, new Request(url), {
    authorize: (id) => id === threadId,
  });
  return response.json();               // returned unchanged to the client
};

// ── client: the connection adapter and one attached client per thread ──────
const connection = {
  connect: (messages, data, signal, runContext) =>
    rpc.chat(buildRunAgentInput(messages, data, runContext), { signal }),
  joinRun: (runId, signal) => rpc.resume({ runId }, { signal }),
  hydrate: (threadId) => rpc.get({ threadId }),
};

const client = new ChatClient({ connection, tools, threadId, persistence: true });
client.attach();                         // after a reload: hydrate, then rejoin

A unit test reproduces the branch on its own: return the body above from
connection.hydrate, spy on connection.joinRun, call attach(), and assert that
joinRun is never called. I am happy to send that test as a PR if it helps.

Your Minimal, Reproducible Example - (Sandbox Highly Recommended)

No sandbox yet; condensed reproduction code and isolated unit-test steps are included in the description above.

Screenshots or Videos (Optional)

No response

Do you intend to try to help solve this bug with your own PR?

No, because I do not know how

Terms & Code of Conduct

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions