diff --git a/packages/app/e2e/browser/agent-heartbeat-track.spec.ts b/packages/app/e2e/browser/agent-heartbeat-track.spec.ts index a17e6d0a2d..2fb0819657 100644 --- a/packages/app/e2e/browser/agent-heartbeat-track.spec.ts +++ b/packages/app/e2e/browser/agent-heartbeat-track.spec.ts @@ -17,7 +17,7 @@ import { test("an agent heartbeat stays visible and keeps its workspace running", async ({ page, }, testInfo) => { - test.setTimeout(120_000); + test.setTimeout(150_000); const scenario = await seedAgentWithHeartbeat(); try { @@ -25,27 +25,44 @@ test("an agent heartbeat stays visible and keeps its workspace running", async ( workspaceId: scenario.workspace.workspaceId, agentId: scenario.parent.id, }); + await expectWorkspaceDone(page); + const heartbeat = await scenario.createHeartbeat(); await expectWorkspaceRunning(page); await openHeartbeatAndSubagentTracks(page); - await expectHeartbeatVisible(page, scenario.heartbeat); + await expectHeartbeatVisible(page, heartbeat); await captureHeartbeatEvidence(page, testInfo, "heartbeat-stacked-tracks"); - await openHeartbeat(page, scenario.heartbeat); - await expectHeartbeatDetails(page, scenario.heartbeat); + await openHeartbeat(page, heartbeat); + await expectHeartbeatDetails(page, heartbeat); await captureHeartbeatEvidence(page, testInfo, "heartbeat-schedule-details"); await removeSeededSubagent(scenario); await returnToAgentHeartbeat(page); await expectWorkspaceRunning(page); await page.getByRole("button", { name: "1 heartbeat" }).click(); - await deleteHeartbeat(page, scenario.heartbeat); - await expect.poll(scenario.readHeartbeatExists, { timeout: 30_000 }).toBe(false); + await deleteHeartbeat(page, heartbeat); + await expect + .poll(() => scenario.readHeartbeatExists(heartbeat.id), { timeout: 30_000 }) + .toBe(false); await expect(scenario.readAgentStatuses()).resolves.toMatchObject({ [scenario.parent.id]: "idle", }); await expect.poll(scenario.readWorkspaceStatus, { timeout: 30_000 }).toBe("done"); await expectWorkspaceDone(page); await captureHeartbeatEvidence(page, testInfo, "heartbeat-removed-workspace-done"); + + const expiringHeartbeat = await scenario.createHeartbeat({ + name: "Temporary watch", + expiresAt: new Date(Date.now() + 20_000).toISOString(), + }); + await expectWorkspaceRunning(page); + await page.getByRole("button", { name: "1 heartbeat" }).click(); + await expectHeartbeatVisible(page, expiringHeartbeat); + await expect(page.getByRole("button", { name: "1 heartbeat" })).toHaveCount(0, { + timeout: 30_000, + }); + await expect.poll(scenario.readWorkspaceStatus, { timeout: 30_000 }).toBe("done"); + await expectWorkspaceDone(page); } finally { await scenario.cleanup().catch(() => undefined); } diff --git a/packages/app/e2e/support/helpers/heartbeats.ts b/packages/app/e2e/support/helpers/heartbeats.ts index d1791ccc89..ae6552d666 100644 --- a/packages/app/e2e/support/helpers/heartbeats.ts +++ b/packages/app/e2e/support/helpers/heartbeats.ts @@ -10,18 +10,25 @@ interface HeartbeatScheduleClient { cadence: { type: "every"; everyMs: number }; target: { type: "agent"; agentId: string }; runOnCreate: false; + expiresAt?: string; }): Promise<{ schedule: ScheduleSummary | null; error: string | null }>; scheduleDelete(input: { id: string }): Promise<{ error: string | null }>; scheduleList(): Promise<{ schedules: ScheduleSummary[]; error: string | null }>; } +export interface SeededHeartbeat { + id: string; + name: string; + cadence: string; +} + export interface AgentHeartbeatScenario { workspace: SeededWorkspace; parent: { id: string; title: string }; child: { id: string; title: string }; - heartbeat: { id: string; name: string; cadence: string }; + createHeartbeat(input?: { name?: string; expiresAt?: string }): Promise; readWorkspaceStatus(): Promise; - readHeartbeatExists(): Promise; + readHeartbeatExists(id: string): Promise; readAgentStatuses(): Promise>; cleanup(): Promise; } @@ -47,22 +54,27 @@ export async function seedAgentWithHeartbeat(): Promise (snapshot) => snapshot.status === "idle", 30_000, ); - const created = await scheduleClient.scheduleCreate({ - prompt: "Check the build and report any failures.", - name: "Watch the build", - cadence: { type: "every", everyMs: 15 * 60_000 }, - target: { type: "agent", agentId: agents.parent.id }, - runOnCreate: false, - }); - if (!created.schedule) { - throw new Error(created.error ?? "Failed to create heartbeat"); - } - const heartbeatId = created.schedule.id; + const heartbeatIds = new Set(); return { workspace, parent: agents.parent, child: agents.child, - heartbeat: { id: heartbeatId, name: "Watch the build", cadence: "Every 15 minutes" }, + createHeartbeat: async (input = {}) => { + const name = input.name ?? "Watch the build"; + const created = await scheduleClient.scheduleCreate({ + prompt: "Check the build and report any failures.", + name, + cadence: { type: "every", everyMs: 15 * 60_000 }, + target: { type: "agent", agentId: agents.parent.id }, + runOnCreate: false, + ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}), + }); + if (!created.schedule) { + throw new Error(created.error ?? "Failed to create heartbeat"); + } + heartbeatIds.add(created.schedule.id); + return { id: created.schedule.id, name, cadence: "Every 15 minutes" }; + }, readWorkspaceStatus: async () => { const result = await workspace.client.fetchWorkspaces(); return ( @@ -73,16 +85,20 @@ export async function seedAgentWithHeartbeat(): Promise )?.status ?? null ); }, - readHeartbeatExists: async () => { + readHeartbeatExists: async (id) => { const result = await scheduleClient.scheduleList(); - return result.schedules.some((schedule) => schedule.id === heartbeatId); + return result.schedules.some((schedule) => schedule.id === id); }, readAgentStatuses: async () => { const result = await workspace.client.fetchAgents({ scope: "active" }); return Object.fromEntries(result.entries.map(({ agent }) => [agent.id, agent.status])); }, cleanup: async () => { - await scheduleClient.scheduleDelete({ id: heartbeatId }).catch(() => undefined); + await Promise.all( + Array.from(heartbeatIds, (id) => + scheduleClient.scheduleDelete({ id }).catch(() => undefined), + ), + ); await workspace.cleanup(); }, }; @@ -115,23 +131,20 @@ export async function openHeartbeatAndSubagentTracks(page: Page): Promise export async function expectHeartbeatVisible( page: Page, - heartbeat: AgentHeartbeatScenario["heartbeat"], + heartbeat: SeededHeartbeat, ): Promise { await expect( page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }), ).toBeVisible({ timeout: 30_000 }); } -export async function openHeartbeat( - page: Page, - heartbeat: AgentHeartbeatScenario["heartbeat"], -): Promise { +export async function openHeartbeat(page: Page, heartbeat: SeededHeartbeat): Promise { await page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }).click(); } export async function expectHeartbeatDetails( page: Page, - heartbeat: AgentHeartbeatScenario["heartbeat"], + heartbeat: SeededHeartbeat, ): Promise { const sheet = page.getByTestId("schedule-form-sheet"); await expect(sheet).toBeVisible({ timeout: 30_000 }); @@ -151,10 +164,7 @@ export async function removeSeededSubagent(scenario: AgentHeartbeatScenario): Pr await scenario.workspace.client.archiveAgent(scenario.child.id); } -export async function deleteHeartbeat( - page: Page, - heartbeat: AgentHeartbeatScenario["heartbeat"], -): Promise { +export async function deleteHeartbeat(page: Page, heartbeat: SeededHeartbeat): Promise { const row = page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }); page.once("dialog", (dialog) => void dialog.accept()); await page.getByRole("button", { name: `Delete heartbeat ${heartbeat.name}` }).click(); diff --git a/packages/app/src/heartbeats/use-agent-heartbeats.ts b/packages/app/src/heartbeats/use-agent-heartbeats.ts index 88d709de01..9c1feb4c79 100644 --- a/packages/app/src/heartbeats/use-agent-heartbeats.ts +++ b/packages/app/src/heartbeats/use-agent-heartbeats.ts @@ -15,11 +15,16 @@ export function useAgentHeartbeats(target: { agentId: string; }): AgentHeartbeatRow[] { const { serverId, agentId } = target; - const { loadState } = useSchedules(); + const { loadState, dataUpdatedAt } = useSchedules(); const schedules = loadState.status === "loaded" ? loadState.data : EMPTY_SCHEDULES; return useMemo( - () => selectAgentHeartbeats({ schedules, target: { serverId, agentId }, now: Date.now() }), - [schedules, serverId, agentId], + () => + selectAgentHeartbeats({ + schedules, + target: { serverId, agentId }, + now: dataUpdatedAt || Date.now(), + }), + [schedules, serverId, agentId, dataUpdatedAt], ); } diff --git a/packages/app/src/hooks/use-schedules.ts b/packages/app/src/hooks/use-schedules.ts index e83d87c1a9..d3719003e7 100644 --- a/packages/app/src/hooks/use-schedules.ts +++ b/packages/app/src/hooks/use-schedules.ts @@ -24,8 +24,11 @@ export function schedulesQueryKey(serverIds: readonly string[]) { return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const; } +const SCHEDULE_REFRESH_INTERVAL_MS = 5_000; + export interface UseSchedulesResult { loadState: AggregateLoadState; + dataUpdatedAt: number; hostErrors: ScheduleHostError[]; isError: boolean; error: Error | null; @@ -51,6 +54,7 @@ export function useSchedules(): UseSchedulesResult { queryKey: [...schedulesQueryKey(serverIds), connectionStatusKey], queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }), dataShape: "list", + refetchInterval: SCHEDULE_REFRESH_INTERVAL_MS, staleTimeMs: 5_000, }); @@ -65,6 +69,7 @@ export function useSchedules(): UseSchedulesResult { return { loadState, + dataUpdatedAt: query.dataUpdatedAt, hostErrors: query.data?.status === "loaded" ? query.data.hostErrors : [], isError: query.isError, error: query.error,