Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions docs/relay-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,25 @@ roster, applied through shared discovery. That makes the channel a sidebar
member and enables the composer, which then receives focus. An accepted but
unconfirmed join reports that it may have taken effect and stays retryable.

### Finite read recovery

A finite read that has no session recovery source retries transient failures
quietly through `read-retry.ts` before its existing Retry action appears. That
covers public search, directory, thread, older-history, profile, catalog,
repository, staff and media reads. The budget is one read plus three retries,
with equal jitter under session recovery's first three ceilings (1/2/4 seconds).
An admission hint is honored within that ceiling; a longer pause surfaces
immediately to the existing cooldown. Exhaustion rethrows the last failure.
Access revocation and cache clears end a session read's backoff under the same
rule that retires its in-flight requests.

Only one owner retries a given read path. Internal roster, head, catch-up,
preference and template reads keep the session recovery coordinator. Callers
keep their cancellation, access-epoch and deadline guards. Denied, invalid and
deterministic local failures, cancellations and writes never retry. Hidden
conversations keep the last confirmed IDs while refreshing; after exhaustion
the error does not opt into indefinite session recovery.

### Channel links

A channel-only link (`buzz://channel/<id>`) shows what the reader may see about
Expand Down
16 changes: 12 additions & 4 deletions src/app/shell/SearchResults.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,10 +395,14 @@ it("shows the real read failure, retains conversation choices, and retries to an
},
]);
await act(async () =>
request.fail(new ReadError("unavailable", "Relay read timed out")),
request.fail(
new ReadError("invalid-response", "Relay response was invalid"),
),
);
expect(
screen.getByText(/Message search couldn’t finish: Relay read timed out/),
screen.getByText(
/Message search couldn’t finish: Relay response was invalid/,
),
).toBeVisible();
expect(screen.getByRole("option", { name: /wes-crew/ })).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Retry messages" }));
Expand Down Expand Up @@ -580,10 +584,14 @@ it("sorts a conversation newest first across pages and restarts paging when the
{ ...text, search_sort: "newest", page: 2 },
]);
await act(async () =>
failed.fail(new ReadError("unavailable", "Relay read timed out")),
failed.fail(
new ReadError("invalid-response", "Relay response was invalid"),
),
);
expect(
screen.getByText("More messages couldn’t load: Relay read timed out"),
screen.getByText(
"More messages couldn’t load: Relay response was invalid",
),
).toBeVisible();
fireEvent.click(screen.getByRole("option", { name: /Show more messages/ }));
const second = await request();
Expand Down
6 changes: 2 additions & 4 deletions src/bundled/builderlab/adapter-type/Create.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ async function fixture(signedIn = true) {
const pending = deferred<void>();
const request = vi.fn<Host["request"]>(async () => {
await pending.promise;
if (fail) throw new Error("offline");
if (fail) return { status: 400, headers: {}, body: "{}" };
return {
status: 200,
headers: {},
Expand Down Expand Up @@ -133,9 +133,7 @@ it("retains instructions when catalog loading fails and is retried", async () =>
);
await act(async () => h.pending.resolve());
const retry = await screen.findByRole("button", { name: "Retry models" });
expect(screen.getByRole("alert")).toHaveTextContent(
"Could not reach Builderlab",
);
expect(screen.getByRole("alert")).toHaveTextContent("HTTP 400");
h.fail(false);
await userEvent.setup().click(retry);
await screen.findByRole("combobox", { name: "Model" });
Expand Down
6 changes: 2 additions & 4 deletions src/bundled/builderlab/adapter-type/Instructions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,9 @@ function fixture() {
it("retries a failed load and refuses to save a draft through another account", async () => {
const h = fixture();
const user = userEvent.setup();
h.request.mockRejectedValueOnce(new Error("offline"));
h.request.mockResolvedValueOnce({ ...response(), status: 400 });
render(<h.Tab agent={agent()} save={vi.fn()} />, { wrapper: StrictMode });
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not reach Builderlab",
);
expect(await screen.findByRole("alert")).toHaveTextContent("HTTP 400");
await user.click(screen.getByRole("button", { name: "Retry" }));
const field = await screen.findByRole("textbox", { name: "Instructions" });
expect(field).toHaveValue("First instructions.");
Expand Down
14 changes: 4 additions & 10 deletions src/bundled/builderlab/adapter-type/ModelSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async function fixture(selection: Partial<RemoteAgent> = {}) {
}));
await session.signIn();
const request = vi.fn<Host["request"]>(async ({ url, body }) => {
if (offline) throw new Error("offline");
if (offline) return { status: 400, headers: {}, body: "{}" };
let result: unknown;
if (url.endsWith("/get-model-catalog"))
result = {
Expand Down Expand Up @@ -192,9 +192,7 @@ it("preserves a failed draft and refuses to save it through a different account"
h.offline(true);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Save model" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not reach Builderlab",
);
expect(await screen.findByRole("alert")).toHaveTextContent("HTTP 400");
expect(
screen.queryByRole("dialog", { name: "Model preferences saved" }),
).not.toBeInTheDocument();
Expand Down Expand Up @@ -222,15 +220,11 @@ it("retries a failed catalog refresh without retrying a failed save", async () =
await screen.findByRole("button", { name: "Refresh models" }),
);
const retry = await screen.findByRole("button", { name: "Retry models" });
expect(screen.getByRole("alert")).toHaveTextContent(
"Could not reach Builderlab",
);
expect(screen.getByRole("alert")).toHaveTextContent("HTTP 400");
h.offline(false);
await user.click(retry);
await screen.findByRole("button", { name: "Refresh models" });
expect(screen.getByRole("alert")).toHaveTextContent(
"Could not reach Builderlab",
);
expect(screen.getByRole("alert")).toHaveTextContent("HTTP 400");
expect(
screen.getByRole("combobox", { name: "Reasoning effort" }),
).toHaveTextContent("low");
Expand Down
4 changes: 2 additions & 2 deletions src/bundled/builderlab/agents/RemoteAgents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ it("shows a held loading state and retries a failed read", async () => {
expect(
screen.getByRole("button", { name: "Refresh agents" }),
).toHaveAttribute("aria-disabled", "true");
await act(async () => held.resolve(response([], 503)));
expect(screen.getByRole("alert")).toHaveTextContent("HTTP 503");
await act(async () => held.resolve(response([], 400)));
expect(screen.getByRole("alert")).toHaveTextContent("HTTP 400");
await userEvent.setup().click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("Helper · Active")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
Expand Down
76 changes: 75 additions & 1 deletion src/bundled/builderlab/agents/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,15 @@ it.each([401, 403, 500])(
vi.mocked(h.host.request).mockResolvedValue(
response({ error: "private-secret" }, status),
);
const error = await h.client.list(h.signal).catch((error: Error) => error);
vi.useFakeTimers();
let error: unknown;
try {
const pending = h.client.list(h.signal).catch((error: Error) => error);
await vi.advanceTimersByTimeAsync(7000);
error = await pending;
} finally {
vi.useRealTimers();
}
expect(error).toBeInstanceOf(Error);
expect(String(error)).not.toContain("private-secret");
expect(h.session.snapshot().status).toBe(
Expand Down Expand Up @@ -772,3 +780,69 @@ it("shares one binding attempt between overlapping links", async () => {
.mock.calls.map(([input]) => input.url.split("/").at(-1)),
).toEqual(["current", "attest-agent"]);
});

it.each(["network", "429"])(
"quietly recovers read-only Builderlab discovery after %s",
async (failure) => {
const h = await fixture();
vi.useFakeTimers();
try {
const request = vi.mocked(h.host.request);
if (failure === "network")
request.mockRejectedValueOnce(new Error("offline"));
else request.mockResolvedValueOnce(response({}, Number(failure)));
const pending = h.client.list(h.signal);
await vi.advanceTimersByTimeAsync(499);
expect(request).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(501);
expect(await pending).toHaveLength(1);
expect(request).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
},
);

it("does not replay a Builderlab mutation after a transport failure", async () => {
const h = await fixture();
vi.useFakeTimers();
try {
const request = vi
.mocked(h.host.request)
.mockRejectedValue(new Error("offline"));
await expect(h.client.delete({ id: "agent-1" }, h.signal)).rejects.toThrow(
"Could not reach Builderlab",
);
await vi.advanceTimersByTimeAsync(7000);
expect(request).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});

it("exhausts Builderlab reads once and cancels the same backoff on account change", async () => {
const h = await fixture();
vi.useFakeTimers();
try {
const request = vi
.mocked(h.host.request)
.mockResolvedValue(response({}, 503));
const rejected = expect(h.client.list(h.signal)).rejects.toThrow(
"HTTP 503",
);
await vi.advanceTimersByTimeAsync(7000);
await rejected;
expect(request).toHaveBeenCalledTimes(4);
const pending = h.client.list(h.signal);
const cancelled = expect(pending).rejects.toMatchObject({
name: "AbortError",
});
await vi.advanceTimersByTimeAsync(0);
h.session.signOut();
await vi.advanceTimersByTimeAsync(1000);
await cancelled;
expect(request).toHaveBeenCalledTimes(5);
} finally {
vi.useRealTimers();
}
});
30 changes: 27 additions & 3 deletions src/bundled/builderlab/agents/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { transientStatus } from "../../../features/relay/errors";
import { retryRead } from "../../../features/relay/read-retry";
import { invoke } from "@tauri-apps/api/core";
import { nativeIdentityEnabled } from "../../../features/identity/service";
import type {
Expand Down Expand Up @@ -98,6 +100,8 @@ const nativeOwner: OwnerIdentity = {
invoke("identity_sign_builderlab_binding", { challenge }),
};

class BuilderlabUnavailable extends Error {}

const LINK_ERRORS: Record<string, string> = {
identity_already_bound:
"This Builderlab account is linked to a different Buzz identity.",
Expand Down Expand Up @@ -157,7 +161,7 @@ export function createAgentClient(
});
} catch {
check(credential, signal);
throw new Error("Could not reach Builderlab. Try again.");
throw new BuilderlabUnavailable("Could not reach Builderlab. Try again.");
}
check(credential, signal);
signOutIfExpired(response);
Expand All @@ -180,6 +184,26 @@ export function createAgentClient(
throw new Error("Builderlab returned an invalid response.");
}
}
function readRequest(
path: "get-model-catalog" | "list-agents",
body: unknown,
signal: AbortSignal,
) {
const credential = remoteCredential();
return retryRead(
(signal) => {
check(credential, signal);
return request(path, body, signal);
},
signal,
(error) =>
error instanceof BuilderlabUnavailable ||
(error instanceof Error &&
"status" in error &&
typeof error.status === "number" &&
transientStatus(error.status)),
);
}
/** Builderlab's identity routes answer failures with `{ error: { code } }`. */
async function identityRequest(
path: string,
Expand Down Expand Up @@ -335,7 +359,7 @@ export function createAgentClient(
async modelCatalog(
signal: AbortSignal,
): Promise<readonly RemoteAgentModel[]> {
const result = await request("get-model-catalog", {}, signal);
const result = await readRequest("get-model-catalog", {}, signal);
const models = result.models ?? [];
if (
!Array.isArray(models) ||
Expand Down Expand Up @@ -595,7 +619,7 @@ export function createAgentClient(
};
},
async list(signal: AbortSignal): Promise<readonly RemoteAgent[]> {
const result = await request(
const result = await readRequest(
"list-agents",
{ include_instructions: true },
signal,
Expand Down
7 changes: 3 additions & 4 deletions src/bundled/channels/useHiddenDms.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,19 +300,18 @@ it("restores for an offline message that sorts behind the previous head", async
expect(restored.result.current.hiddenIds.has("dm")).toBe(false);
});

it("retries a transient hidden-DM history failure", async () => {
it("leaves hidden-DM history retries to the session read", async () => {
vi.useFakeTimers();
const h = fixture();
h.failNextRead();
const view = renderHook(() =>
useHiddenDms("community:alice", h.session, h.list),
);
act(() => view.result.current.hide("dm"));
expect(h.read).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(500);
await vi.advanceTimersByTimeAsync(10_000);
});
expect(h.read).toHaveBeenCalledTimes(2);
expect(h.read).toHaveBeenCalledTimes(1);
expect(view.result.current.hiddenIds.has("dm")).toBe(true);
});

Expand Down
Loading
Loading