Skip to content

Commit 07d2fea

Browse files
fix(schedules): load upcoming runs with schedule lists
Avoid per-schedule detail requests so authenticated clients receive consistent live fields for upcoming and skip actions. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0d859c2 commit 07d2fea

9 files changed

Lines changed: 180 additions & 58 deletions

File tree

agentex-ui/components/scheduled-tasks/scheduled-tasks-page.tsx

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import {
2727
import { useAgentByName } from '@/hooks/use-agent-by-name';
2828
import {
2929
SCHEDULE_LIST_LIMIT,
30-
useAgentRunScheduleDetailsForItems,
3130
useAgentRunSchedules,
3231
useAgentRunSchedulesForAgents,
3332
} from '@/hooks/use-agent-run-schedules';
@@ -90,30 +89,27 @@ export function ScheduledTasksPage() {
9089

9190
const baseItems =
9291
scheduleScope === ScheduleScope.ALL ? allItems : currentItems;
93-
const detailQueries = useAgentRunScheduleDetailsForItems(
94-
agentexClient,
95-
baseItems
96-
);
97-
const schedulesWithLiveFields = useMemo(
92+
const unavailableLiveDataCount = useMemo(
9893
() =>
99-
baseItems.map((item, index) => ({
100-
...item,
101-
schedule: detailQueries[index]?.data ?? item.schedule,
102-
})),
103-
[baseItems, detailQueries]
94+
baseItems.filter(
95+
item =>
96+
!isSchedulePaused(item.schedule) &&
97+
getNextRunTime(item.schedule) == null
98+
).length,
99+
[baseItems]
104100
);
105101

106102
const visibleItems = useMemo(() => {
107103
const scopedItems =
108104
scheduleView === 'upcoming'
109-
? schedulesWithLiveFields.filter(
105+
? baseItems.filter(
110106
item =>
111107
!isSchedulePaused(item.schedule) &&
112108
getNextRunTime(item.schedule) != null
113109
)
114-
: schedulesWithLiveFields;
110+
: baseItems;
115111
return sortScheduleItems(scopedItems, scheduleView);
116-
}, [scheduleView, schedulesWithLiveFields]);
112+
}, [baseItems, scheduleView]);
117113

118114
const isLoading =
119115
scheduleScope === ScheduleScope.ALL
@@ -181,6 +177,17 @@ export function ScheduledTasksPage() {
181177
Currently showing up to {SCHEDULE_LIST_LIMIT} schedules per agent.
182178
Support for additional schedules is coming soon.
183179
</p>
180+
{scheduleView === 'upcoming' && unavailableLiveDataCount > 0 && (
181+
<p
182+
className="border-border bg-muted/40 text-muted-foreground mx-auto w-full max-w-4xl rounded-lg border px-4 py-3 text-xs"
183+
role="status"
184+
>
185+
Next-run data is temporarily unavailable for{' '}
186+
{unavailableLiveDataCount}{' '}
187+
{unavailableLiveDataCount === 1 ? 'schedule' : 'schedules'}.
188+
Their definitions remain available under Schedules.
189+
</p>
190+
)}
184191
<ScheduleList
185192
agentexClient={agentexClient}
186193
items={visibleItems}

agentex-ui/hooks/use-agent-run-schedules.test.tsx

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import type { ReactNode } from 'react';
22

33
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4-
import { act, renderHook } from '@testing-library/react';
4+
import { act, renderHook, waitFor } from '@testing-library/react';
55
import { describe, expect, it, vi } from 'vitest';
66

7-
import { scheduleKeys, useScheduleAction } from './use-agent-run-schedules';
7+
import {
8+
scheduleKeys,
9+
useAgentRunSchedules,
10+
useScheduleAction,
11+
} from './use-agent-run-schedules';
812

913
import type AgentexSDK from 'agentex';
1014

@@ -16,6 +20,45 @@ function createWrapper(queryClient: QueryClient) {
1620
};
1721
}
1822

23+
describe('useAgentRunSchedules', () => {
24+
it('requests live fields with the bounded schedule list', async () => {
25+
const listSchedules = vi.fn().mockResolvedValue({
26+
run_schedules: [
27+
{
28+
id: 'schedule-1',
29+
agent_id: 'agent-1',
30+
name: 'daily-summary',
31+
initial_input: { content: 'Summarize updates' },
32+
initial_input_method: 'event/send',
33+
next_action_times: ['2026-07-21T13:00:00Z'],
34+
},
35+
],
36+
total: 1,
37+
});
38+
const agentexClient = {
39+
agents: { schedules: { list: listSchedules } },
40+
} as unknown as AgentexSDK;
41+
const queryClient = new QueryClient({
42+
defaultOptions: { queries: { retry: false } },
43+
});
44+
45+
const { result } = renderHook(
46+
() => useAgentRunSchedules(agentexClient, 'agent-1'),
47+
{ wrapper: createWrapper(queryClient) }
48+
);
49+
50+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
51+
52+
expect(listSchedules).toHaveBeenCalledWith('agent-1', {
53+
limit: 50,
54+
include_live: true,
55+
});
56+
expect(result.current.data?.[0]?.next_action_times).toEqual([
57+
'2026-07-21T13:00:00Z',
58+
]);
59+
});
60+
});
61+
1962
describe('useScheduleAction', () => {
2063
it('removes a deleted schedule detail query instead of refetching it', async () => {
2164
const deleteSchedule = vi

agentex-ui/hooks/use-agent-run-schedules.ts

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export const scheduleKeys = {
2727
};
2828

2929
export const SCHEDULE_LIST_LIMIT = 50;
30+
const SCHEDULE_LIST_QUERY = {
31+
limit: SCHEDULE_LIST_LIMIT,
32+
include_live: true,
33+
};
3034

3135
type ScheduleMutationContext = {
3236
agentexClient: AgentexSDK;
@@ -40,11 +44,6 @@ type ScheduleActionInput =
4044
scheduledTime: string;
4145
};
4246

43-
export type AgentRunScheduleListItem = {
44-
agentId: string;
45-
schedule: AgentRunSchedule;
46-
};
47-
4847
function errorMessage(error: unknown) {
4948
return error instanceof Error ? error.message : 'Please try again.';
5049
}
@@ -59,9 +58,10 @@ export function useAgentRunSchedules(
5958
if (!agentId) {
6059
return [];
6160
}
62-
const response = await agentexClient.agents.schedules.list(agentId, {
63-
limit: SCHEDULE_LIST_LIMIT,
64-
});
61+
const response = await agentexClient.agents.schedules.list(
62+
agentId,
63+
SCHEDULE_LIST_QUERY
64+
);
6565
return response.run_schedules.map(normalizeAgentRunSchedule);
6666
},
6767
enabled: !!agentId,
@@ -77,34 +77,17 @@ export function useAgentRunSchedulesForAgents(
7777
queries: agents.map(agent => ({
7878
queryKey: scheduleKeys.byAgentId(agent.id),
7979
queryFn: async () => {
80-
const response = await agentexClient.agents.schedules.list(agent.id, {
81-
limit: SCHEDULE_LIST_LIMIT,
82-
});
80+
const response = await agentexClient.agents.schedules.list(
81+
agent.id,
82+
SCHEDULE_LIST_QUERY
83+
);
8384
return response.run_schedules.map(normalizeAgentRunSchedule);
8485
},
8586
enabled,
8687
})),
8788
});
8889
}
8990

90-
export function useAgentRunScheduleDetailsForItems(
91-
agentexClient: AgentexSDK,
92-
items: AgentRunScheduleListItem[]
93-
) {
94-
return useQueries({
95-
queries: items.map(({ agentId, schedule }) => ({
96-
queryKey: scheduleKeys.detail(agentId, schedule.id),
97-
queryFn: async () =>
98-
normalizeAgentRunSchedule(
99-
await agentexClient.agents.schedules.retrieve(schedule.id, {
100-
agent_id: agentId,
101-
})
102-
),
103-
staleTime: 30_000,
104-
})),
105-
});
106-
}
107-
10891
export function useCreateAgentRunSchedule({
10992
agentexClient,
11093
agentId,

agentex/openapi.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3344,6 +3344,15 @@ paths:
33443344
minimum: 1
33453345
default: 100
33463346
title: Limit
3347+
- name: include_live
3348+
in: query
3349+
required: false
3350+
schema:
3351+
type: boolean
3352+
description: Include live Temporal state and upcoming action times.
3353+
default: false
3354+
title: Include Live
3355+
description: Include live Temporal state and upcoming action times.
33473356
responses:
33483357
'200':
33493358
description: Successful Response

agentex/src/api/routes/agent_run_schedules.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any
1+
from typing import Annotated, Any
22

33
from fastapi import APIRouter, Query, Request
44

@@ -106,7 +106,11 @@ async def list_run_schedules(
106106
agent_id: str,
107107
run_schedules_use_case: DAgentRunSchedulesUseCase,
108108
authorized_schedule_ids: DAuthorizedResourceIds(AgentexResourceType.schedule),
109-
limit: int = Query(default=100, ge=1, le=1000),
109+
limit: Annotated[int, Query(ge=1, le=1000)] = 100,
110+
include_live: Annotated[
111+
bool,
112+
Query(description="Include live Temporal state and upcoming action times."),
113+
] = False,
110114
) -> AgentRunScheduleListResponse:
111115
"""List an agent's run schedules, filtered to those the caller owns.
112116
@@ -117,6 +121,7 @@ async def list_run_schedules(
117121
agent_id,
118122
authorized_schedule_ids=authorized_schedule_ids,
119123
limit=limit,
124+
include_live=include_live,
120125
)
121126

122127

agentex/src/domain/services/agent_run_schedule_service.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
from datetime import UTC, datetime
23
from typing import Annotated, Any, cast
34
from uuid import uuid4
@@ -38,6 +39,7 @@
3839
# these schedules within the shared Temporal namespace and keeps the id stable
3940
# and small (the row id is the only thing the workflow needs).
4041
RUN_SCHEDULE_TEMPORAL_ID_PREFIX = "agent-run-schedule"
42+
MAX_LIVE_ENRICHMENT_CONCURRENCY = 10
4143

4244
# Registered (class) name of the workflow each fire starts. Referenced by name so
4345
# the API/service layer doesn't import the Temporal workflow definition.
@@ -160,6 +162,7 @@ async def list_schedules(
160162
agent_id: str,
161163
authorized_schedule_ids: list[str] | None = None,
162164
limit: int = 100,
165+
include_live: bool = False,
163166
) -> AgentRunScheduleListResponse:
164167
# Fetch without a DB limit so the authorization filter below runs against
165168
# the full set, then truncate to ``limit`` after filtering. Applying the
@@ -178,23 +181,39 @@ async def list_schedules(
178181
else None
179182
)
180183
agent = await self.agent_repository.get(id=agent_id)
181-
items: list[AgentRunScheduleResponse] = []
184+
visible_rows: list[AgentRunScheduleEntity] = []
182185
for row in rows:
183-
if len(items) >= limit:
186+
if len(visible_rows) >= limit:
184187
break
185188
selector = build_run_schedule_authz_selector(agent_id, row.id)
186189
if authorized is not None and selector not in authorized:
187190
continue
188-
temporal_id = build_run_schedule_temporal_id(row.id)
189-
# Serve the list from Postgres only — no per-row Temporal describe.
190-
# Fanning out one RPC per row (up to the route's limit of 1000) makes
191-
# list latency scale with Temporal round-trips; live fields are
192-
# available on the single-schedule GET instead.
193-
items.append(
191+
visible_rows.append(row)
192+
193+
if not include_live:
194+
items = [
194195
await self._to_response(
195-
row, agent=agent, temporal_id=temporal_id, include_live=False
196+
row,
197+
agent=agent,
198+
temporal_id=build_run_schedule_temporal_id(row.id),
199+
include_live=False,
196200
)
197-
)
201+
for row in visible_rows
202+
]
203+
return AgentRunScheduleListResponse(run_schedules=items, total=len(items))
204+
205+
semaphore = asyncio.Semaphore(MAX_LIVE_ENRICHMENT_CONCURRENCY)
206+
207+
async def enrich(row: AgentRunScheduleEntity) -> AgentRunScheduleResponse:
208+
async with semaphore:
209+
return await self._to_response(
210+
row,
211+
agent=agent,
212+
temporal_id=build_run_schedule_temporal_id(row.id),
213+
include_live=True,
214+
)
215+
216+
items = await asyncio.gather(*(enrich(row) for row in visible_rows))
198217
return AgentRunScheduleListResponse(run_schedules=items, total=len(items))
199218

200219
async def get_schedule(

agentex/src/domain/use_cases/agent_run_schedules_use_case.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@ async def list_schedules(
4242
agent_id: str,
4343
authorized_schedule_ids: list[str] | None = None,
4444
limit: int = 100,
45+
include_live: bool = False,
4546
) -> AgentRunScheduleListResponse:
4647
return await self.run_schedule_service.list_schedules(
4748
agent_id,
4849
authorized_schedule_ids=authorized_schedule_ids,
4950
limit=limit,
51+
include_live=include_live,
5052
)
5153

5254
async def get_schedule(

agentex/tests/unit/api/test_agent_run_schedules_authz.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,12 +408,14 @@ async def test_list_route_forwards_authorized_ids_and_limit(self):
408408
run_schedules_use_case=use_case,
409409
authorized_schedule_ids=[_authz_selector("agent-1", "schedule-1")],
410410
limit=5,
411+
include_live=True,
411412
)
412413

413414
use_case.list_schedules.assert_awaited_once_with(
414415
"agent-1",
415416
authorized_schedule_ids=[_authz_selector("agent-1", "schedule-1")],
416417
limit=5,
418+
include_live=True,
417419
)
418420

419421
async def test_authorized_resource_ids_dependency_lists_schedule_reads(self):

0 commit comments

Comments
 (0)