Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
794c4cc
Stop retrying subscriptions for missing threads
Quicksaver Jul 21, 2026
a4f6fd3
Keep draft subscriptions alive until thread creation
Quicksaver Jul 21, 2026
78cb397
Harden missing thread snapshot handling
Quicksaver Jul 21, 2026
8b86c84
Use catchTags for missing thread recovery
Quicksaver Jul 21, 2026
46651af
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 22, 2026
b337803
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 22, 2026
0eda3f2
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 23, 2026
506b583
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 23, 2026
b6e1a17
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 23, 2026
4ed5d7d
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 23, 2026
27c1c7a
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 23, 2026
03650e3
Prevent deleted and draft thread resubscriptions
Quicksaver Jul 23, 2026
2a63728
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 23, 2026
62367a7
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 23, 2026
ab823aa
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 23, 2026
2d25d20
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 24, 2026
45dac5e
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 24, 2026
285f15b
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 24, 2026
b6bb311
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 25, 2026
e933fa5
Keep authoritative HTTP thread_not_found responses terminal
Quicksaver Jul 26, 2026
388cbb2
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 27, 2026
63a41d6
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 27, 2026
474f669
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 27, 2026
dacee90
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Jul 27, 2026
730363c
Merge commit '32843c2551bd5832d436ef46bff5ab1198cdc39c' into fix/thre…
Quicksaver Jul 27, 2026
ca19570
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 28, 2026
17ce0fd
Consolidate draft thread detail gating
Jul 28, 2026
c388555
Document canonical draft detail gate
Jul 28, 2026
f2cbd5c
Harden consolidated thread detail gating
Jul 28, 2026
043eb0c
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 28, 2026
649499c
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 28, 2026
420083d
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 28, 2026
17284df
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 29, 2026
1a6dd5c
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 29, 2026
721e5be
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 29, 2026
5adc4a2
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 29, 2026
210483f
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 29, 2026
2f61f5c
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 29, 2026
1ec6abf
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 29, 2026
1ae98f4
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 30, 2026
3a1214f
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 30, 2026
113a026
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 30, 2026
1e6b31d
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 30, 2026
ef8d66b
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 30, 2026
88c947d
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 31, 2026
ff11ae9
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 31, 2026
4b53153
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 31, 2026
b288cd3
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 31, 2026
e6de390
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Jul 31, 2026
9a8e1a1
Merge commit 'bfc31507f8483b0ba22cf10f5862947b49b20a53' into fix/thre…
Jul 31, 2026
65e7c38
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 2, 2026
9feeb68
Handle missing threads after bounded resume fallback
Aug 2, 2026
993af0d
Document bounded missing-thread fallback
Aug 2, 2026
d54a158
Prevent deleted thread cache resurrection
Aug 3, 2026
82d6e88
Preserve missing thread error message
Aug 3, 2026
00b2257
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 3, 2026
bfd79b7
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 6, 2026
0b846d1
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 6, 2026
931a5c5
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 7, 2026
f3d4d81
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 7, 2026
fe3ea6e
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 7, 2026
f6f7948
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 8, 2026
27f4344
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 8, 2026
3eec12c
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 8, 2026
0ee9834
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 9, 2026
805c29b
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 9, 2026
2e17e8c
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 9, 2026
a43fb6e
test: preserve draft subscription guard across workspace modes
Aug 9, 2026
62d729b
docs: record draft workspace guard coverage
Aug 9, 2026
92a3030
Verify draft identity across workspace mode changes
Aug 9, 2026
04a3fa7
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 10, 2026
4669b6f
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 10, 2026
b4e5d7f
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 10, 2026
ef5b1e8
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Aug 10, 2026
69a1588
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Aug 16, 2026
5b79dd2
Merge remote-tracking branch 'upstream/main' into fix/thread-not-foun…
Quicksaver Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions BRANCH_DETAILS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Thread Detail Subscription Reliability

Thread-detail synchronization distinguishes an authoritative missing resource from a transient snapshot failure across both HTTP snapshot loading and WebSocket snapshot fallback so stale thread state cannot enter an unbounded subscription retry loop.

Expected behavior:

- An HTTP `thread_not_found` response clears the persisted detail cache and marks the client thread state deleted.
- When a bounded WebSocket resume falls back to a fresh snapshot, a dedicated `OrchestrationThreadNotFoundError` applies the same cache removal and deleted-state transition for a warm cached thread.
- Cache removal is serialized with snapshot persistence, and persistence rechecks deleted state under the same lock, so a queued or in-flight save cannot resurrect an authoritatively deleted thread.
- The missing-thread subscription terminates before opening or retrying its WebSocket stream, including after session replacement and application-foreground resubscription signals.
- `resolveThreadDetailRef` is the canonical web detail-subscription gate. `useThread` waits for the shell when either automatic draft-store detection or an explicit `waitForShell` request identifies a pre-creation thread, while direct detail/status consumers such as the server-thread route map their local-draft readiness through the same resolver. Draft workspace-mode changes before shell creation preserve lookup by the reserved thread ref, so this guard remains active while switching between current-checkout and new-worktree modes. The expected pre-creation HTTP 404 therefore cannot mark the draft deleted, and the new shell starts fresh synchronization after the first send.
- Other HTTP snapshot failures remain transient and fall back to the socket snapshot path. Other WebSocket snapshot failures remain transient and retain the existing retry behavior.

Primary files:

- `packages/client-runtime/src/state/threadSnapshotHttp.ts`
- `packages/client-runtime/src/state/threads.ts`
- `packages/client-runtime/src/state/threads-sync.test.ts`
- `packages/contracts/src/orchestration.ts`
- `packages/contracts/src/rpc.ts`
- `apps/server/src/ws.ts`
- `apps/server/src/server.test.ts`
- `apps/web/src/composerDraftStore.test.ts`
- `apps/web/src/state/entities.ts`
- `apps/web/src/state/entities.test.ts`
- `apps/web/src/routes/_chat.$environmentId.$threadId.tsx`

## Development Ports

- Web: `5741`
- Server/WebSocket: `13781`
30 changes: 30 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6195,6 +6195,36 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("subscribeThread identifies a missing large-gap fallback", () =>
Effect.gen(function* () {
yield* buildAppUnderTest({
layers: {
orchestrationEngine: {
latestSequence: Effect.succeed(100_000),
},
projectionSnapshotQuery: {
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
},
},
});

const wsUrl = yield* getWsServerUrl("/ws");
const result = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[ORCHESTRATION_WS_METHODS.subscribeThread]({
threadId: defaultThreadId,
afterSequence: 5,
}).pipe(Stream.runCollect),
).pipe(Effect.result),
);

assertTrue(result._tag === "Failure");
assertTrue(result.failure._tag === "OrchestrationThreadNotFoundError");
assert.equal(result.failure.threadId, defaultThreadId);
assert.equal(result.failure.message, `Thread ${defaultThreadId} was not found`);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("subscribeThread replaces a cursor ahead of the authoritative head", () =>
Effect.gen(function* () {
let readEventsCalls = 0;
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
type OrchestrationThreadStreamItem,
OrchestrationGetFullThreadDiffError,
OrchestrationGetSnapshotError,
OrchestrationThreadNotFoundError,
OrchestrationSearchThreadsError,
OrchestrationGetTurnDiffError,
ORCHESTRATION_WS_METHODS,
Expand Down Expand Up @@ -1412,9 +1413,8 @@ const makeWsRpcLayer = (
);

if (Option.isNone(snapshot)) {
return yield* new OrchestrationGetSnapshotError({
message: `Thread ${input.threadId} was not found`,
cause: input.threadId,
return yield* new OrchestrationThreadNotFoundError({
threadId: input.threadId,
});
}

Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/composerDraftStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,38 @@ describe("composerDraftStore project draft thread mapping", () => {
});
});

it("keeps a pre-shell draft discoverable by thread ref across workspace mode changes", () => {
const store = useComposerDraftStore.getState();
const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
store.setProjectDraftThreadId(projectRef, draftId, {
threadId,
envMode: "local",
worktreePath: null,
});

store.setDraftThreadContext(draftId, {
envMode: "worktree",
worktreePath: null,
});
const worktreeDraft = useComposerDraftStore.getState().getDraftThread(draftId);
expect(useComposerDraftStore.getState().getDraftThreadByRef(threadRef)).toBe(worktreeDraft);
expect(worktreeDraft).toMatchObject({
envMode: "worktree",
worktreePath: null,
});

store.setDraftThreadContext(draftId, {
envMode: "local",
worktreePath: null,
});
const localDraft = useComposerDraftStore.getState().getDraftThread(draftId);
expect(useComposerDraftStore.getState().getDraftThreadByRef(threadRef)).toBe(localDraft);
expect(localDraft).toMatchObject({
envMode: "local",
worktreePath: null,
});
});

it("stores the start-from-origin choice with the draft thread", () => {
const store = useComposerDraftStore.getState();
store.setProjectDraftThreadId(projectRef, draftId, {
Expand Down
22 changes: 12 additions & 10 deletions apps/web/src/routes/_chat.$environmentId.$threadId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import { resolveThreadSyncPhase } from "../threadSync";
import { SidebarInset } from "~/components/ui/sidebar";
import {
useEnvironmentThreadRefs,
useThreadDetail,
useThreadDetailWhenReady,
useThreadShell,
useThreadStatus,
useThreadStatusWhenReady,
} from "../state/entities";
import { useEnvironmentQuery } from "../state/query";
import { environmentShell } from "../state/shell";
Expand All @@ -25,17 +25,19 @@ function ChatThreadRouteView() {
threadRef === null ? null : environmentShell.stateAtom(threadRef.environmentId),
);
const serverThreadShell = useThreadShell(threadRef);
const serverThreadDetail = useThreadDetail(threadRef);
const serverThreadStatus = useThreadStatus(threadRef);
const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null);
const bootstrapComplete = shell.data?.snapshot._tag === "Some";
const environmentHasServerThreads = environmentThreadRefs.length > 0;
const draftThreadExists = useComposerDraftStore((store) =>
threadRef ? store.getDraftThreadByRef(threadRef) !== null : false,
);
const draftThread = useComposerDraftStore((store) =>
threadRef ? store.getDraftThreadByRef(threadRef) : null,
);
const draftThreadExists = draftThread !== null;
const detailReadiness = {
hasLocalDraft: draftThreadExists,
hasServerShell: serverThreadShell !== null,
};
const serverThreadDetail = useThreadDetailWhenReady(threadRef, detailReadiness);
const serverThreadStatus = useThreadStatusWhenReady(threadRef, detailReadiness);
Comment thread
Quicksaver marked this conversation as resolved.
const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null);
const bootstrapComplete = shell.data?.snapshot._tag === "Some";
const environmentHasServerThreads = environmentThreadRefs.length > 0;
const environmentHasDraftThreads = useComposerDraftStore((store) => {
if (!threadRef) {
return false;
Expand Down
146 changes: 136 additions & 10 deletions apps/web/src/state/entities.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,141 @@
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { resolveThreadDetailRef } from "./entities";
import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";

const threadRef = scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-1"));
const mocks = vi.hoisted(() => ({
hasLocalDraft: false,
shell: null as object | null,
detailAtom: vi.fn((ref: ScopedThreadRef) => ({ kind: "detail", ref })),
statusAtom: vi.fn((ref: ScopedThreadRef) => ({ kind: "status", ref })),
threadShellAtom: vi.fn((ref: ScopedThreadRef) => ({ kind: "shell", ref })),
}));

vi.mock("@effect/atom-react", () => ({
useAtomValue: (atom: { readonly kind?: string }) => {
if (atom.kind === "shell") return mocks.shell;
if (atom.kind === "detail") return { id: "detail" };
if (atom.kind === "status") return "deleted";
return null;
},
}));

vi.mock("@t3tools/client-runtime/state/threads", async (importOriginal) => ({
...(await importOriginal<typeof import("@t3tools/client-runtime/state/threads")>()),
mergeEnvironmentThread: (detail: unknown, shell: unknown) => ({ detail, shell }),
}));

vi.mock("../composerDraftStore", () => ({
useComposerDraftStore: (
selector: (store: { getDraftThreadByRef: () => object | null }) => unknown,
) =>
selector({
getDraftThreadByRef: () => (mocks.hasLocalDraft ? {} : null),
}),
}));

vi.mock("./threads", () => ({
environmentThreadDetails: {
detailAtom: mocks.detailAtom,
statusAtom: mocks.statusAtom,
},
environmentThreadShells: {
threadShellAtom: mocks.threadShellAtom,
},
}));

import {
resolveThreadDetailRef,
useThread,
useThreadDetailWhenReady,
useThreadStatusWhenReady,
} from "./entities";

const THREAD_REF: ScopedThreadRef = {
environmentId: EnvironmentId.make("environment-1"),
threadId: ThreadId.make("thread-1"),
};

beforeEach(() => {
mocks.hasLocalDraft = false;
mocks.shell = null;
mocks.detailAtom.mockClear();
mocks.statusAtom.mockClear();
mocks.threadShellAtom.mockClear();
});

describe("thread detail subscription", () => {
it("starts the actual detail hook immediately for an ordinary thread", () => {
function Probe() {
useThread(THREAD_REF);
return null;
}

renderToStaticMarkup(createElement(Probe));
expect(mocks.detailAtom).toHaveBeenCalledOnce();
expect(mocks.detailAtom).toHaveBeenCalledWith(THREAD_REF);
});

it("starts the actual detail hook only after a local draft receives its shell", () => {
mocks.hasLocalDraft = true;

function Probe() {
useThread(THREAD_REF);
return null;
}

renderToStaticMarkup(createElement(Probe));
expect(mocks.detailAtom).not.toHaveBeenCalled();

mocks.shell = { id: THREAD_REF.threadId };
renderToStaticMarkup(createElement(Probe));
expect(mocks.detailAtom).toHaveBeenCalledOnce();
expect(mocks.detailAtom).toHaveBeenCalledWith(THREAD_REF);
});

it("preserves explicit shell gating for callers without a local draft", () => {
function Probe() {
useThread(THREAD_REF, { waitForShell: true });
return null;
}

renderToStaticMarkup(createElement(Probe));
expect(mocks.detailAtom).not.toHaveBeenCalled();

mocks.shell = { id: THREAD_REF.threadId };
renderToStaticMarkup(createElement(Probe));
expect(mocks.detailAtom).toHaveBeenCalledOnce();
expect(mocks.detailAtom).toHaveBeenCalledWith(THREAD_REF);
});

it("gates direct detail state consumers until a local draft receives its shell", () => {
function Probe({ hasServerShell }: { readonly hasServerShell: boolean }) {
const readiness = {
hasLocalDraft: true,
hasServerShell,
};
useThreadDetailWhenReady(THREAD_REF, readiness);
useThreadStatusWhenReady(THREAD_REF, readiness);
return null;
}

renderToStaticMarkup(createElement(Probe, { hasServerShell: false }));
expect(mocks.detailAtom).not.toHaveBeenCalled();
expect(mocks.statusAtom).not.toHaveBeenCalled();

renderToStaticMarkup(createElement(Probe, { hasServerShell: true }));
expect(mocks.detailAtom).toHaveBeenCalledOnce();
expect(mocks.detailAtom).toHaveBeenCalledWith(THREAD_REF);
expect(mocks.statusAtom).toHaveBeenCalledOnce();
expect(mocks.statusAtom).toHaveBeenCalledWith(THREAD_REF);
});
});

describe("resolveThreadDetailRef", () => {
it("does not subscribe to a reserved draft thread before it enters the shell index", () => {
expect(
resolveThreadDetailRef(threadRef, {
resolveThreadDetailRef(THREAD_REF, {
shellExists: false,
waitForShell: true,
}),
Expand All @@ -18,19 +144,19 @@ describe("resolveThreadDetailRef", () => {

it("subscribes once the reserved draft thread enters the shell index", () => {
expect(
resolveThreadDetailRef(threadRef, {
resolveThreadDetailRef(THREAD_REF, {
shellExists: true,
waitForShell: true,
}),
).toBe(threadRef);
).toBe(THREAD_REF);
});

it("keeps direct server-thread lookups enabled when the shell has not loaded it", () => {
expect(
resolveThreadDetailRef(threadRef, {
resolveThreadDetailRef(THREAD_REF, {
shellExists: false,
waitForShell: false,
}),
).toBe(threadRef);
).toBe(THREAD_REF);
});
});
Loading
Loading