diff --git a/packages/app/src/utils/agent-grouping.ts b/packages/app/src/utils/agent-grouping.ts index 8f7ebe8d9..f5dcfad26 100644 --- a/packages/app/src/utils/agent-grouping.ts +++ b/packages/app/src/utils/agent-grouping.ts @@ -192,6 +192,10 @@ export interface ProjectGroup { projectKey: string; projectName: string; agents: AggregatedAgent[]; + /** Number of truly active agents (running or requires attention) */ + activeCount: number; + /** Total agents before any limit was applied */ + totalCount: number; } export interface DateGroup { @@ -204,7 +208,7 @@ export interface GroupedAgents { inactiveGroups: DateGroup[]; } -const ACTIVE_GRACE_PERIOD_MS = 15 * 60 * 1000; // 15 minutes +const ACTIVE_GRACE_PERIOD_MS = 24 * 60 * 60 * 1000; // 24 hours interface GroupAgentsOptions { /** @@ -214,9 +218,15 @@ interface GroupAgentsOptions { getRemoteUrl?: (agent: AggregatedAgent) => string | null; } +const MAX_INACTIVE_PER_PROJECT = 5; + /** * Groups agents into active (by project) and inactive (by date) sections. - * Active = running, requires attention, or had activity within the last 5 minutes. + * Active = running, requires attention, or had activity within the grace period (24 hours). + * + * Within each project group: + * - All truly active agents (running/requires attention) are always shown + * - Recently active (within grace period but not running) are limited to MAX_INACTIVE_PER_PROJECT */ export function groupAgents( agents: AggregatedAgent[], @@ -240,28 +250,61 @@ export function groupAgents( } } - // Group active agents by project - const projectMap = new Map(); + // Group active agents by project, tracking truly active vs recently active + const projectMap = new Map< + string, + { trulyActive: AggregatedAgent[]; recentlyActive: AggregatedAgent[] } + >(); for (const agent of activeAgents) { const remoteKey = deriveRemoteProjectKey( options?.getRemoteUrl?.(agent) ?? null ); const projectKey = remoteKey ?? deriveProjectKey(agent.cwd); - const existing = projectMap.get(projectKey) || []; - existing.push(agent); + const existing = projectMap.get(projectKey) || { + trulyActive: [], + recentlyActive: [], + }; + + const isTrulyActive = + agent.status === "running" || agent.requiresAttention; + if (isTrulyActive) { + existing.trulyActive.push(agent); + } else { + existing.recentlyActive.push(agent); + } + projectMap.set(projectKey, existing); } - // Sort agents within each project by lastActivityAt (newest first) + // Build project groups with limits applied const activeGroups: ProjectGroup[] = []; - for (const [projectKey, projectAgents] of projectMap) { - projectAgents.sort( + for (const [projectKey, { trulyActive, recentlyActive }] of projectMap) { + // Sort both arrays by lastActivityAt (newest first) + trulyActive.sort( (a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime() ); + recentlyActive.sort( + (a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime() + ); + + // All truly active agents shown, limit recently active to MAX_INACTIVE_PER_PROJECT + const limitedRecentlyActive = recentlyActive.slice( + 0, + MAX_INACTIVE_PER_PROJECT + ); + const combinedAgents = [...trulyActive, ...limitedRecentlyActive]; + + // Re-sort combined list by lastActivityAt + combinedAgents.sort( + (a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime() + ); + activeGroups.push({ projectKey, projectName: deriveProjectName(projectKey), - agents: projectAgents, + agents: combinedAgents, + activeCount: trulyActive.length, + totalCount: trulyActive.length + recentlyActive.length, }); } diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index 980abc0a7..9728bd226 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -1046,6 +1046,10 @@ export class DaemonClientV2 { if (!requestId) { const existing = this.checkoutStatusInFlight.get(agentId); if (existing) { + this.logger.debug( + { agentId, inFlightCount: this.checkoutStatusInFlight.size }, + "getCheckoutStatus: returning existing in-flight request" + ); return existing; } } @@ -1057,6 +1061,11 @@ export class DaemonClientV2 { requestId: resolvedRequestId, }); + this.logger.debug( + { agentId, requestId: resolvedRequestId, waiterCount: this.waiters.size }, + "getCheckoutStatus: creating new request" + ); + const responsePromise = (async () => { const response = this.waitFor( (msg) => { @@ -1077,11 +1086,24 @@ export class DaemonClientV2 { if (!requestId) { this.checkoutStatusInFlight.set(agentId, responsePromise); - responsePromise.finally(() => { - if (this.checkoutStatusInFlight.get(agentId) === responsePromise) { - this.checkoutStatusInFlight.delete(agentId); - } - }); + responsePromise + .then(() => { + this.logger.debug( + { agentId, requestId: resolvedRequestId }, + "getCheckoutStatus: request completed successfully" + ); + }) + .catch((err) => { + this.logger.debug( + { agentId, requestId: resolvedRequestId, error: err?.message }, + "getCheckoutStatus: request failed" + ); + }) + .finally(() => { + if (this.checkoutStatusInFlight.get(agentId) === responsePromise) { + this.checkoutStatusInFlight.delete(agentId); + } + }); } return responsePromise; @@ -2099,6 +2121,10 @@ export class DaemonClientV2 { this.lastErrorValue = reason.trim(); } + // Clear all pending waiters since the connection was lost and responses + // from the previous connection will never arrive. + this.clearWaiters(new Error(reason ?? "Connection lost")); + this.updateConnectionState({ status: "disconnected", ...(reason ? { reason } : {}), diff --git a/packages/server/src/server/daemon-e2e/checkout-debug.ts b/packages/server/src/server/daemon-e2e/checkout-debug.ts new file mode 100644 index 000000000..453ae87c7 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/checkout-debug.ts @@ -0,0 +1,168 @@ +#!/usr/bin/env npx tsx +/** + * Ad-hoc script to debug checkout_status_request timeouts. + * + * Usage: + * npx tsx packages/server/src/server/daemon-e2e/checkout-debug.ts [agentId1] [agentId2] + * + * To test against a different daemon: + * PASEO_PORT=7777 npx tsx packages/server/src/server/daemon-e2e/checkout-debug.ts + */ + +import { WebSocket } from "ws"; +import { DaemonClientV2 } from "../../client/daemon-client-v2.js"; + +// Patch WebSocket to log all messages +const OriginalWebSocket = WebSocket; +class LoggingWebSocket extends OriginalWebSocket { + constructor(url: string, ...args: any[]) { + super(url, ...args); + console.log(`[WS] Connecting to ${url}`); + this.on("open", () => console.log("[WS] Connection opened")); + this.on("close", (code, reason) => console.log(`[WS] Connection closed: ${code} ${reason}`)); + this.on("error", (err) => console.log(`[WS] Error: ${err}`)); + this.on("message", (data) => { + const str = data.toString().slice(0, 200); + console.log(`[WS] Message received (${data.toString().length} bytes): ${str}...`); + }); + } +} + +const PASEO_HOME = process.env.PASEO_HOME ?? "/Users/moboudra/.paseo"; +const PASEO_PORT = process.env.PASEO_PORT ?? "6767"; +const DAEMON_URL = `ws://127.0.0.1:${PASEO_PORT}/ws`; + +async function testMultiAgentSequence() { + console.log("\n=== Testing multi-agent checkout sequence ==="); + console.log(`Daemon URL: ${DAEMON_URL}`); + + const client = new DaemonClientV2({ + url: DAEMON_URL, + webSocketFactory: (url) => new LoggingWebSocket(url) as any, + reconnect: { enabled: false }, + }); + + const agents: Array<{ id: string; title: string }> = []; + + // Subscribe to all events for debugging + const unsub = client.subscribe((event) => { + console.log(`[Event] type=${event.type}`); + if (event.type === "session_state") { + console.log(` ${event.agents.length} agents`); + agents.length = 0; + for (const a of event.agents) { + agents.push({ id: a.id, title: a.title ?? "(untitled)" }); + } + } + }); + + // Also log ALL raw messages + client.on("session_state", (msg: any) => { + console.log(`[RAW session_state] agents=${msg.agents?.length}`); + }); + + // Also log raw messages for debugging + client.on("checkout_status_response", (msg: any) => { + console.log(`[RAW checkout_status_response] requestId=${msg.payload.requestId} agentId=${msg.payload.agentId}`); + }); + + // Listen to connection state changes + client.subscribeConnectionStatus((state) => { + console.log(`[Connection] status=${state.status}`); + }); + + try { + await client.connect(); + console.log("Connected to daemon"); + console.log(`Connection state: ${JSON.stringify(client.getConnectionState())}`); + + // Request session state (the app does this after connecting) + console.log("Requesting session state..."); + client.requestSessionState(); + + // Wait a bit for session state to arrive + console.log("Waiting 3s for session state..."); + await new Promise((r) => setTimeout(r, 3000)); + + if (agents.length === 0) { + console.log("No agents found!"); + return; + } + + console.log("\nAvailable agents:"); + for (const a of agents.slice(0, 10)) { + console.log(` - ${a.id.slice(0, 8)}... ${a.title}`); + } + if (agents.length > 10) { + console.log(` ... and ${agents.length - 10} more`); + } + + // Pick first two agents (or use command line args) + const agent1Id = process.argv[2] ?? agents[0]?.id; + const agent2Id = process.argv[3] ?? agents[1]?.id ?? agents[0]?.id; + + if (!agent1Id) { + console.log("No agents available to test"); + return; + } + + console.log(`\n=== Test 1: Request checkout for agent1 (${agent1Id.slice(0, 8)}...) ===`); + const start1 = Date.now(); + try { + const status1 = await client.getCheckoutStatus(agent1Id); + console.log(`✓ Agent1 completed in ${Date.now() - start1}ms - branch: ${status1.currentBranch}`); + } catch (err) { + console.log(`✗ Agent1 failed after ${Date.now() - start1}ms:`, err); + } + + console.log(`\n=== Test 2: Request checkout for agent2 (${agent2Id.slice(0, 8)}...) ===`); + const start2 = Date.now(); + try { + const status2 = await client.getCheckoutStatus(agent2Id); + console.log(`✓ Agent2 completed in ${Date.now() - start2}ms - branch: ${status2.currentBranch}`); + } catch (err) { + console.log(`✗ Agent2 failed after ${Date.now() - start2}ms:`, err); + } + + console.log(`\n=== Test 3: Request checkout for agent1 again ===`); + const start3 = Date.now(); + try { + const status3 = await client.getCheckoutStatus(agent1Id); + console.log(`✓ Agent1 (retry) completed in ${Date.now() - start3}ms - branch: ${status3.currentBranch}`); + } catch (err) { + console.log(`✗ Agent1 (retry) failed after ${Date.now() - start3}ms:`, err); + } + + console.log(`\n=== Test 4: Request both agents in parallel ===`); + const start4 = Date.now(); + try { + const [p1, p2] = await Promise.all([ + client.getCheckoutStatus(agent1Id), + client.getCheckoutStatus(agent2Id), + ]); + console.log(`✓ Parallel completed in ${Date.now() - start4}ms`); + console.log(` Agent1 branch: ${p1.currentBranch}`); + console.log(` Agent2 branch: ${p2.currentBranch}`); + } catch (err) { + console.log(`✗ Parallel failed after ${Date.now() - start4}ms:`, err); + } + + } catch (error) { + console.error("Test failed:", error); + } finally { + unsub(); + await client.close(); + } +} + +async function main() { + console.log("Checkout Debug Script - Multi-Agent Sequence Test"); + console.log("=================================================="); + console.log(`PASEO_HOME: ${PASEO_HOME}`); + + await testMultiAgentSequence(); + + console.log("\n=== Done ==="); +} + +main().catch(console.error);