Skip to content

Commit c045ad0

Browse files
jromualdez-scalecursoragentclaude
authored
fix(schedules): load live fields in schedule lists (#368)
## Problem The schedules UI loads persisted schedule definitions first, then sends one detail request per schedule to obtain live Temporal fields. In authenticated deployments, failures in that detail-request fan-out leave `next_action_times` empty without surfacing an error. As a result, valid schedules disappear from Upcoming and "Skip next run" is unavailable even though their Temporal clocks have future actions. ## Solution - add an opt-in `include_live` query parameter to schedule listing - enrich authorized rows with live Temporal fields using a concurrency limit of 10 - keep the existing database-only list behavior as the default for compatibility and performance - have the UI request enriched lists and remove the per-schedule detail-request fan-out - surface a notice when an active schedule still lacks next-run data ## Test plan - [x] Backend schedule service unit tests - [x] Backend schedule authorization route unit tests - [x] Frontend schedule hook tests - [x] Frontend TypeScript typecheck - [x] Frontend lint and formatting checks - [x] Local smoke test confirms enriched lists return future action times Made with [Cursor](https://cursor.com) <!-- greptile_comment --> <h3>Greptile Summary</h3> This PR moves live Temporal field enrichment from a per-schedule client-side fan-out to a single server-side batch operation gated by an opt-in `include_live` query parameter. The previous approach silently dropped schedules from the "Upcoming" view when any per-schedule detail request failed in authenticated deployments. - **Backend**: `list_schedules` now accepts `include_live=True`, collects visible rows, then concurrently enriches up to `MAX_LIVE_ENRICHMENT_ROWS` (200) via a semaphore-bounded `asyncio.gather(return_exceptions=True)`; rows beyond the cap are returned with `live_data_available=None` rather than a misleading `False`. A new `live_data_available: bool | None` field on the response schema distinguishes enrichment success, enrichment failure, and enrichment not requested. - **Frontend**: Both list hooks now pass `include_live: true` and `staleTime: 30_000`, replacing the removed `useAgentRunScheduleDetailsForItems` fan-out. The "Upcoming" tab shows a targeted status notice when any non-paused schedule has `live_data_available === false`, directing users to the Schedules tab for definitions that are still intact. - **Tests**: Four new service-layer tests cover the live path, semaphore concurrency bound, fan-out cap via monkeypatch, and the guarantee that all sibling tasks settle before re-raising an error. <details><summary><h3>Confidence Score: 5/5</h3></summary> Safe to merge — the change is opt-in (default include_live=False preserves existing behavior), the fan-out ceiling and semaphore prevent overload, and all previously identified issues have been addressed. All three concerns from the prior review round (return_exceptions=True, staleTime on enriched queries, live_data_available distinguishing enrichment failures from genuinely empty schedules) were addressed. The new logic is well-covered by four targeted service tests. The DB-only default path is unchanged, and the include_live=True path degrades gracefully per row rather than failing the whole request. No files require special attention. agentex/src/domain/services/agent_run_schedule_service.py carries the most new logic but is thoroughly tested. </details> <details><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | agentex/src/domain/services/agent_run_schedule_service.py | Core enrichment logic: bounded gather with semaphore, MAX_LIVE_ENRICHMENT_ROWS cap, return_exceptions=True, and live_data_available tri-state field all implemented correctly | | agentex/src/api/routes/agent_run_schedules.py | Adds include_live query parameter with Annotated[bool, Query(...)] annotation and threads it through to the use case | | agentex/src/api/schemas/agent_run_schedules.py | Adds live_data_available: bool | None field to AgentRunScheduleResponse with correct nullable default and clear docstring | | agentex-ui/hooks/use-agent-run-schedules.ts | Removes per-schedule detail fan-out; both list hooks now pass include_live:true and staleTime:30_000; AgentRunScheduleListItem export removed cleanly | | agentex-ui/components/scheduled-tasks/scheduled-tasks-page.tsx | Removes detailQueries fan-out; computes unavailableLiveDataCount from live_data_available===false and shows a targeted status notice on the upcoming tab | | agentex-ui/lib/agent-run-schedules.ts | Adds live_data_available: boolean | null to AgentRunSchedule type and normalizeAgentRunSchedule with correct null coalescing | | agentex/tests/unit/services/test_agent_run_schedule_service.py | Four new tests cover include_live=True path, bounded concurrency, fan-out cap (via monkeypatch), and waiting for all rows before re-raising — solid coverage | | agentex/tests/unit/api/test_agent_run_schedules_authz.py | Updates authz route test to pass and assert include_live=True through the use case call | | agentex-ui/hooks/use-agent-run-schedules.test.tsx | New test verifies the hook passes include_live:true and that live_data_available is surfaced on the returned schedule object | | agentex-ui/lib/schedule-utils.test.ts | Test fixtures updated to include live_data_available: null to match the new field; no logic changes | | agentex/openapi.yaml | Adds include_live query parameter and live_data_available response field to the OpenAPI spec; nullable boolean uses anyOf correctly | | agentex/src/domain/use_cases/agent_run_schedules_use_case.py | Minimal pass-through change: adds include_live parameter to list_schedules and forwards it to the service | </details> <details><summary><h3>Sequence Diagram</h3></summary> <a href="#gh-light-mode-only"> ```mermaid %%{init: {'theme': 'neutral'}}%% sequenceDiagram participant UI as ScheduledTasksPage participant Hook as useAgentRunSchedules participant API as GET /agents/{id}/schedules participant Svc as AgentRunScheduleService participant DB as Postgres participant T as Temporal (xN semaphore10) UI->>Hook: mount / staleTime expires Hook->>API: "?include_live=true&limit=50" API->>Svc: "list_schedules(include_live=True)" Svc->>DB: list_by_agent_id() DB-->>Svc: rows (all) Note over Svc: auth filter to visible_rows Note over Svc: live_rows = visible_rows[:200] par asyncio.gather semaphore 10 Svc->>T: describe_schedule(id_1) T-->>Svc: ScheduleDescription Svc->>T: describe_schedule(id_2) T-->>Svc: ScheduleDescription or Exception end Note over Svc: live_data_available True or False per row Svc-->>API: AgentRunScheduleListResponse API-->>Hook: JSON run_schedules total Hook-->>UI: data staleTime 30s Note over UI: upcoming filter next_action_times not null Note over UI: notice if live_data_available is false ``` </a> <a href="#gh-dark-mode-only"> ```mermaid %%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant UI as ScheduledTasksPage participant Hook as useAgentRunSchedules participant API as GET /agents/{id}/schedules participant Svc as AgentRunScheduleService participant DB as Postgres participant T as Temporal (xN semaphore10) UI->>Hook: mount / staleTime expires Hook->>API: "?include_live=true&limit=50" API->>Svc: "list_schedules(include_live=True)" Svc->>DB: list_by_agent_id() DB-->>Svc: rows (all) Note over Svc: auth filter to visible_rows Note over Svc: live_rows = visible_rows[:200] par asyncio.gather semaphore 10 Svc->>T: describe_schedule(id_1) T-->>Svc: ScheduleDescription Svc->>T: describe_schedule(id_2) T-->>Svc: ScheduleDescription or Exception end Note over Svc: live_data_available True or False per row Svc-->>API: AgentRunScheduleListResponse API-->>Hook: JSON run_schedules total Hook-->>UI: data staleTime 30s Note over UI: upcoming filter next_action_times not null Note over UI: notice if live_data_available is false ``` </a> </details> <sub>Reviews (5): Last reviewed commit: ["Merge branch &#39;main&#39; into jerome/schedule..."](4cf2629) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=45786376)</sub> <!-- /greptile_comment --> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d0ba8bf commit c045ad0

12 files changed

Lines changed: 304 additions & 58 deletions

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+
item.schedule.live_data_available === false
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: 47 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,47 @@ 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+
live_data_available: true,
35+
},
36+
],
37+
total: 1,
38+
});
39+
const agentexClient = {
40+
agents: { schedules: { list: listSchedules } },
41+
} as unknown as AgentexSDK;
42+
const queryClient = new QueryClient({
43+
defaultOptions: { queries: { retry: false } },
44+
});
45+
46+
const { result } = renderHook(
47+
() => useAgentRunSchedules(agentexClient, 'agent-1'),
48+
{ wrapper: createWrapper(queryClient) }
49+
);
50+
51+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
52+
53+
expect(listSchedules).toHaveBeenCalledWith('agent-1', {
54+
limit: 50,
55+
include_live: true,
56+
});
57+
expect(result.current.data?.[0]?.next_action_times).toEqual([
58+
'2026-07-21T13:00:00Z',
59+
]);
60+
expect(result.current.data?.[0]?.live_data_available).toBe(true);
61+
});
62+
});
63+
1964
describe('useScheduleAction', () => {
2065
it('removes a deleted schedule detail query instead of refetching it', async () => {
2166
const deleteSchedule = vi

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

Lines changed: 15 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ 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+
};
34+
const SCHEDULE_LIST_STALE_TIME = 30_000;
3035

3136
type ScheduleMutationContext = {
3237
agentexClient: AgentexSDK;
@@ -40,11 +45,6 @@ type ScheduleActionInput =
4045
scheduledTime: string;
4146
};
4247

43-
export type AgentRunScheduleListItem = {
44-
agentId: string;
45-
schedule: AgentRunSchedule;
46-
};
47-
4848
function errorMessage(error: unknown) {
4949
return error instanceof Error ? error.message : 'Please try again.';
5050
}
@@ -59,12 +59,14 @@ export function useAgentRunSchedules(
5959
if (!agentId) {
6060
return [];
6161
}
62-
const response = await agentexClient.agents.schedules.list(agentId, {
63-
limit: SCHEDULE_LIST_LIMIT,
64-
});
62+
const response = await agentexClient.agents.schedules.list(
63+
agentId,
64+
SCHEDULE_LIST_QUERY
65+
);
6566
return response.run_schedules.map(normalizeAgentRunSchedule);
6667
},
6768
enabled: !!agentId,
69+
staleTime: SCHEDULE_LIST_STALE_TIME,
6870
});
6971
}
7072

@@ -77,30 +79,14 @@ export function useAgentRunSchedulesForAgents(
7779
queries: agents.map(agent => ({
7880
queryKey: scheduleKeys.byAgentId(agent.id),
7981
queryFn: async () => {
80-
const response = await agentexClient.agents.schedules.list(agent.id, {
81-
limit: SCHEDULE_LIST_LIMIT,
82-
});
82+
const response = await agentexClient.agents.schedules.list(
83+
agent.id,
84+
SCHEDULE_LIST_QUERY
85+
);
8386
return response.run_schedules.map(normalizeAgentRunSchedule);
8487
},
8588
enabled,
86-
})),
87-
});
88-
}
89-
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,
89+
staleTime: SCHEDULE_LIST_STALE_TIME,
10490
})),
10591
});
10692
}

agentex-ui/lib/agent-run-schedules.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type AgentRunSchedule = {
2323
updated_at: string | null;
2424
state: 'ACTIVE' | 'PAUSED';
2525
next_action_times: string[];
26+
live_data_available: boolean | null;
2627
skipped_action_times: string[];
2728
last_action_time: string | null;
2829
num_actions_taken: number;
@@ -69,6 +70,7 @@ type AgentRunScheduleResponse = {
6970
updated_at?: string | null;
7071
state?: 'ACTIVE' | 'PAUSED';
7172
next_action_times?: string[];
73+
live_data_available?: boolean | null;
7274
skipped_action_times?: string[];
7375
last_action_time?: string | null;
7476
num_actions_taken?: number;
@@ -100,6 +102,7 @@ export function normalizeAgentRunSchedule(
100102
updated_at: schedule.updated_at ?? null,
101103
state: schedule.state ?? 'ACTIVE',
102104
next_action_times: schedule.next_action_times ?? [],
105+
live_data_available: schedule.live_data_available ?? null,
103106
skipped_action_times: schedule.skipped_action_times ?? [],
104107
last_action_time: schedule.last_action_time ?? null,
105108
num_actions_taken: schedule.num_actions_taken ?? 0,

agentex-ui/lib/schedule-utils.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ describe('scheduleToCadence', () => {
131131
updated_at: null,
132132
state: 'ACTIVE',
133133
next_action_times: [],
134+
live_data_available: null,
134135
skipped_action_times: [],
135136
last_action_time: null,
136137
num_actions_taken: 0,
@@ -179,6 +180,7 @@ describe('describeCadence', () => {
179180
updated_at: null,
180181
state: 'ACTIVE',
181182
next_action_times: [],
183+
live_data_available: null,
182184
skipped_action_times: [],
183185
last_action_time: null,
184186
num_actions_taken: 0,

agentex/openapi.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3359,6 +3359,15 @@ paths:
33593359
minimum: 1
33603360
default: 100
33613361
title: Limit
3362+
- name: include_live
3363+
in: query
3364+
required: false
3365+
schema:
3366+
type: boolean
3367+
description: Include live Temporal state and upcoming action times.
3368+
default: false
3369+
title: Include Live
3370+
description: Include live Temporal state and upcoming action times.
33623371
responses:
33633372
'200':
33643373
description: Successful Response
@@ -4492,6 +4501,13 @@ components:
44924501
type: array
44934502
title: Next Action Times
44944503
description: Upcoming scheduled fire times.
4504+
live_data_available:
4505+
anyOf:
4506+
- type: boolean
4507+
- type: 'null'
4508+
title: Live Data Available
4509+
description: Whether requested live Temporal fields were retrieved successfully.
4510+
Null when live enrichment was not requested.
44954511
skipped_action_times:
44964512
items:
44974513
type: string

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/api/schemas/agent_run_schedules.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@ class AgentRunScheduleResponse(BaseModel):
151151
next_action_times: list[datetime] = Field(
152152
default_factory=list, description="Upcoming scheduled fire times."
153153
)
154+
live_data_available: bool | None = Field(
155+
None,
156+
description=(
157+
"Whether requested live Temporal fields were retrieved successfully. "
158+
"Null when live enrichment was not requested."
159+
),
160+
)
154161
skipped_action_times: list[datetime] = Field(
155162
default_factory=list, description="Skipped one-off scheduled fire times."
156163
)

0 commit comments

Comments
 (0)