mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user