Default client RPC waits to 60 seconds (#1789)

* fix(client): wait longer for session responses

* fix(client): default RPC waits to sixty seconds

* fix(app): preserve detached stream scroll on delayed history

Code drift: longer client RPC waits let delayed timeline responses arrive after a user scroll-away, so web stream anchoring must not reattach on transient scroll-top resets.

Restore the 15s connect deadline and leave app initialization slack above the default 60s session RPC wait.

* fix(client): keep helper waits within caller deadlines

Review fix: the 60s default session RPC wait leaked into wait previews and waitForAgentUpsert helper fetches. Bound those helper RPCs to the caller deadline or a short best-effort preview timeout, and allow small scroll ranges to reattach at bottom.

* fix(client): respect caller timeout budgets

* fix(cli): keep diagnostic probes responsive

* Refactor daemon client request options

* Preserve daemon client legacy overloads
This commit is contained in:
Mohamed Boudra
2026-06-29 17:23:18 +02:00
committed by GitHub
parent 57800a0f17
commit b613bea9f6
58 changed files with 1234 additions and 281 deletions

View File

@@ -6,7 +6,10 @@ export function addAttachOptions(cmd: Command): Command {
.argument("<id>", "Agent ID (or prefix)");
}
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { fetchProjectedTimelineItems } from "../../utils/timeline.js";
import {
fetchProjectedTimelineItems,
LIVE_HISTORY_FETCH_TIMEOUT_MS,
} from "../../utils/timeline.js";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import type { AgentStreamEventPayload, AgentStreamMessage } from "@getpaseo/protocol/messages";
@@ -121,7 +124,7 @@ export async function runAttachCommand(
}
try {
const fetchResult = await client.fetchAgent(id);
const fetchResult = await client.fetchAgent({ agentId: id });
if (!fetchResult) {
console.error(`Error: No agent found matching: ${id}`);
console.error("Use `paseo ls` to list available agents");
@@ -139,6 +142,7 @@ export async function runAttachCommand(
const timelineItems = await fetchProjectedTimelineItems({
client,
agentId: resolvedId,
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
});
for (const item of timelineItems) {
printTimelineItem(item);

View File

@@ -76,7 +76,7 @@ export async function runDeleteCommand(
return isSameOrDescendantPath(options.cwd!, a.cwd);
});
} else if (id) {
const fetchResult = await client.fetchAgent(id);
const fetchResult = await client.fetchAgent({ agentId: id });
if (!fetchResult) {
const error: CommandError = {
code: "AGENT_NOT_FOUND",

View File

@@ -243,7 +243,7 @@ export async function runInspectCommand(
}
try {
const fetchResult = await client.fetchAgent(agentIdArg);
const fetchResult = await client.fetchAgent({ agentId: agentIdArg });
if (!fetchResult) {
const error: CommandError = {
code: "AGENT_NOT_FOUND",

View File

@@ -1,7 +1,10 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions } from "../../output/index.js";
import { fetchProjectedTimelineItems } from "../../utils/timeline.js";
import {
fetchProjectedTimelineItems,
LIVE_HISTORY_FETCH_TIMEOUT_MS,
} from "../../utils/timeline.js";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import type { AgentStreamMessage } from "@getpaseo/protocol/messages";
@@ -32,8 +35,9 @@ export const NO_ACTIVITY_MESSAGE = "No activity to display.";
export async function fetchAgentTimelineItems(
client: DaemonClient,
agentId: string,
options?: { timeoutMs?: number },
): Promise<AgentTimelineItem[]> {
return fetchProjectedTimelineItems({ client, agentId });
return fetchProjectedTimelineItems({ client, agentId, timeoutMs: options?.timeoutMs });
}
export function formatAgentActivityTranscript(
@@ -107,7 +111,7 @@ export async function runLogsCommand(
}
try {
const fetchResult = await client.fetchAgent(id);
const fetchResult = await client.fetchAgent({ agentId: id });
if (!fetchResult) {
console.error(`Error: No agent found matching: ${id}`);
console.error("Use `paseo ls` to list available agents");
@@ -173,7 +177,14 @@ async function runFollowMode(
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
// First, get existing timeline.
let existingItems = await fetchAgentTimelineItems(client, agentId);
let existingItems: AgentTimelineItem[] = [];
try {
existingItems = await fetchAgentTimelineItems(client, agentId, {
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
});
} catch (error) {
console.warn("Warning: failed to fetch existing timeline", error);
}
// Apply filter to existing items
if (options.filter) {

View File

@@ -65,7 +65,7 @@ export async function runModeCommand(
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
try {
client = await connectToDaemon({ host: options.host });
const fetchResult = await client.fetchAgent(id);
const fetchResult = await client.fetchAgent({ agentId: id });
if (!fetchResult) {
const error: CommandError = {
code: "AGENT_NOT_FOUND",

View File

@@ -83,7 +83,7 @@ export async function runStopCommand(
});
} else if (id) {
// Stop specific agent
const fetchResult = await client.fetchAgent(id);
const fetchResult = await client.fetchAgent({ agentId: id });
if (!fetchResult) {
const error: CommandError = {
code: "AGENT_NOT_FOUND",

View File

@@ -132,7 +132,7 @@ export async function runUpdateCommand(
}
try {
const fetchResult = await client.fetchAgent(agentIdArg);
const fetchResult = await client.fetchAgent({ agentId: agentIdArg });
if (!fetchResult) {
const error: CommandError = {
code: "AGENT_NOT_FOUND",
@@ -148,7 +148,7 @@ export async function runUpdateCommand(
...(Object.keys(labels).length > 0 ? { labels } : {}),
});
const updatedResult = await client.fetchAgent(agentId);
const updatedResult = await client.fetchAgent({ agentId });
if (!updatedResult) {
throw new Error(`Agent not found after update: ${agentId}`);
}

View File

@@ -32,6 +32,7 @@ export interface AgentWaitOptions extends CommandOptions {
}
const WAIT_ACTIVITY_PREVIEW_COUNT = 5;
const WAIT_ACTIVITY_PREVIEW_TIMEOUT_MS = 2_000;
function appendRecentActivity(message: string, transcript: string | null): string {
if (!transcript || transcript.trim().length === 0) {
@@ -46,7 +47,9 @@ async function getRecentActivityTranscript(
agentId: string,
): Promise<string | null> {
try {
const timelineItems = await fetchAgentTimelineItems(client, agentId);
const timelineItems = await fetchAgentTimelineItems(client, agentId, {
timeoutMs: WAIT_ACTIVITY_PREVIEW_TIMEOUT_MS,
});
return formatAgentActivityTranscript(timelineItems, WAIT_ACTIVITY_PREVIEW_COUNT);
} catch {
return null;

View File

@@ -26,6 +26,7 @@ export async function connectChatClient(host?: string) {
export async function attachAgentNamesToMessages(
client: Awaited<ReturnType<typeof connectToDaemon>>,
messages: ChatMessageRow[],
options: { timeout?: number; bestEffort?: boolean } = {},
): Promise<ChatMessageRow[]> {
const agentIds = new Set<string>();
for (const message of messages) {
@@ -39,9 +40,18 @@ export async function attachAgentNamesToMessages(
return messages;
}
const payload = await client.fetchAgents({
filter: { includeArchived: true },
});
let payload: Awaited<ReturnType<typeof client.fetchAgents>>;
try {
payload = await client.fetchAgents({
filter: { includeArchived: true },
...(typeof options.timeout === "number" ? { timeout: options.timeout } : {}),
});
} catch (error) {
if (options.bestEffort) {
return messages;
}
throw error;
}
const agentNames = new Map<string, string>();
for (const entry of payload.entries) {
const title = entry.agent.title?.trim();

View File

@@ -13,26 +13,44 @@ export interface ChatWaitOptions extends ChatCommandOptions {
timeout?: string;
}
const CHAT_WAIT_PREFLIGHT_TIMEOUT_MS = 2000;
export async function runWaitCommand(
room: string,
options: ChatWaitOptions,
_command: Command,
): Promise<ListResult<ChatMessageRow>> {
const timeoutMs = parseTimeoutMs(options.timeout);
const { client } = await connectChatClient(options.host);
const deadline = typeof timeoutMs === "number" ? Date.now() + timeoutMs : null;
const hasExplicitTimeout = deadline !== null;
const remainingTimeoutMs = () =>
deadline === null ? undefined : Math.max(1, deadline - Date.now());
try {
const latest = await client.readChatMessages({
room,
limit: 1,
...(hasExplicitTimeout
? {
timeout: Math.min(remainingTimeoutMs() ?? 1, CHAT_WAIT_PREFLIGHT_TIMEOUT_MS),
}
: {}),
});
const afterMessageId = latest.messages[0]?.id;
const payload = await client.waitForChatMessages({
room,
afterMessageId,
timeoutMs: parseTimeoutMs(options.timeout),
timeoutMs: remainingTimeoutMs() ?? timeoutMs,
});
const messages = await attachAgentNamesToMessages(
client,
payload.messages.map(toChatMessageRow),
hasExplicitTimeout
? {
timeout: remainingTimeoutMs(),
bestEffort: true,
}
: {},
);
return {
type: "list",

View File

@@ -667,7 +667,9 @@ async function requestLifecycleShutdown(
};
}
const client = await tryConnectToDaemon({ host, timeout: Math.min(timeoutMs, 5000) });
const deadline = Date.now() + timeoutMs;
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
const client = await tryConnectToDaemon({ host, timeout: Math.min(remainingTimeoutMs(), 5000) });
if (!client) {
return {
requested: false,
@@ -676,7 +678,7 @@ async function requestLifecycleShutdown(
}
try {
await client.shutdownServer();
await client.shutdownServer({ timeout: Math.min(remainingTimeoutMs(), 5000) });
return { requested: true };
} catch (error) {
return {
@@ -696,8 +698,10 @@ export async function stopLocalDaemon(
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
const state = resolveLocalDaemonState({ home: options.home });
const deadline = Date.now() + timeoutMs;
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs);
const shutdownAttempt = await requestLifecycleShutdown(state, remainingTimeoutMs());
const lifecycleRequested = shutdownAttempt.requested;
if (!state.pidInfo || (!state.running && !lifecycleRequested)) {
@@ -720,7 +724,7 @@ export async function stopLocalDaemon(
const { stopped, forced } = await waitForStopAfterRequest({
state,
pid,
timeoutMs,
timeoutMs: remainingTimeoutMs(),
killTimeoutMs,
force: options.force,
});

View File

@@ -10,6 +10,8 @@ interface PairOptions {
json?: boolean;
}
const PAIRING_DAEMON_RPC_TIMEOUT_MS = 1500;
export function pairCommand(): Command {
return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
@@ -35,7 +37,9 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
if (supportsDaemonStatusRpc) {
try {
const offer = await client.getDaemonPairingOffer();
const offer = await client.getDaemonPairingOffer({
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
});
await client.close().catch(() => {});
outputPairingResult(
{ relayEnabled: offer.relayEnabled, url: offer.url, qr: offer.qr ?? null },

View File

@@ -6,6 +6,8 @@ import type { CommandOptions, ListResult, OutputSchema } from "../../output/inde
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
import { resolveNodePathFromPid } from "./runtime-toolchain.js";
const DAEMON_STATUS_PROBE_TIMEOUT_MS = 1500;
interface ProviderBinaryStatus {
label: string;
path: string | null;
@@ -276,7 +278,10 @@ async function probeDaemonOverWebsocket(args: {
const supportsDaemonStatusRpc =
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agentsPayload = await client.fetchAgents({
filter: { includeArchived: true },
timeout: DAEMON_STATUS_PROBE_TIMEOUT_MS,
});
const agents = agentsPayload.entries.map((entry) => entry.agent);
const runningAgents = agents.filter((a) => a.status === "running").length;
const idleAgents = agents.filter((a) => a.status === "idle").length;
@@ -284,7 +289,9 @@ async function probeDaemonOverWebsocket(args: {
let daemonProviders: ProviderBinaryStatus[] | undefined;
if (supportsDaemonStatusRpc) {
try {
const statusPayload = await client.getDaemonStatus();
const statusPayload = await client.getDaemonStatus({
timeout: DAEMON_STATUS_PROBE_TIMEOUT_MS,
});
const labelMap = new Map(PROVIDER_BINARIES.map((p) => [p.binary, p.label]));
daemonProviders = statusPayload.providers.map((p) => ({
label: labelMap.get(p.provider) ?? p.provider,

View File

@@ -40,6 +40,7 @@ type OnboardPersistedConfig = PersistedConfig & {
};
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000;
const READY_PROBE_TIMEOUT_MS = 1200;
class OnboardCancelledError extends Error {}
@@ -201,15 +202,22 @@ function renderProgressLine(progress: DownloadProgress): string {
type ProbeResult = { kind: "ready"; listen: string; host: string | null } | { kind: "pending" };
async function probeDaemonReady(home: string): Promise<ProbeResult> {
async function probeDaemonReady(home: string, timeoutMs: number): Promise<ProbeResult> {
const state = resolveLocalDaemonState({ home });
const host = resolveTcpHostFromListen(state.listen);
const deadline = Date.now() + timeoutMs;
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
if (state.running && host) {
const client = await tryConnectToDaemon({ host, timeout: 1200 });
const client = await tryConnectToDaemon({
host,
timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS),
});
if (client) {
try {
await client.fetchAgents();
await client.fetchAgents({
timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS),
});
return { kind: "ready", listen: state.listen, host };
} catch {
// Daemon process is alive but not API-ready yet.
@@ -255,23 +263,29 @@ async function waitForDaemonReady(args: {
onStatus?: (message: string) => void;
}): Promise<{ listen: string; host: string | null }> {
const deadline = Date.now() + args.timeoutMs;
const createTimeoutError = () => {
const recentLogs = tailDaemonLog(args.home, 60);
return new Error(
[
`Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`,
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
]
.filter(Boolean)
.join("\n\n"),
);
};
async function poll(state: ProgressState): Promise<{ listen: string; host: string | null }> {
const probe = await probeDaemonReady(args.home);
if (Date.now() >= deadline) {
throw createTimeoutError();
}
const probe = await probeDaemonReady(args.home, Math.max(1, deadline - Date.now()));
if (probe.kind === "ready") {
return { listen: probe.listen, host: probe.host };
}
const nextState = announceProgress(args.home, state, args.onStatus);
if (Date.now() >= deadline) {
const recentLogs = tailDaemonLog(args.home, 60);
throw new Error(
[
`Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`,
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
]
.filter(Boolean)
.join("\n\n"),
);
throw createTimeoutError();
}
await sleep(200);
return poll(nextState);

View File

@@ -79,7 +79,7 @@ export async function runAllowCommand(
}
try {
const fetchResult = await client.fetchAgent(agentIdOrPrefix);
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
const error: CommandError = {

View File

@@ -45,7 +45,7 @@ export async function runDenyCommand(
}
try {
const fetchResult = await client.fetchAgent(agentIdOrPrefix);
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
const error: CommandError = {

View File

@@ -1,9 +1,12 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
export const LIVE_HISTORY_FETCH_TIMEOUT_MS = 2_000;
interface FetchProjectedTimelineItemsInput {
client: DaemonClient;
agentId: string;
timeoutMs?: number;
}
export async function fetchProjectedTimelineItems(
@@ -13,6 +16,7 @@ export async function fetchProjectedTimelineItems(
direction: "tail",
limit: 0,
projection: "projected",
timeout: input.timeoutMs,
});
return timeline.entries.map((entry) => entry.item);
}