feat(schedules): add exact skip and unskip actions - #357
Conversation
Co-authored-by: Cursor <cursoragent@cursor.com>
✱ Stainless preview buildsThis PR will update the openapi python typescript
|
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
Curious, is there a customer need around this or are we pre-emptively implementing this? |
| ) | ||
|
|
||
| @model_validator(mode="after") | ||
| def require_scheduled_time_timezone(self): |
There was a problem hiding this comment.
Validate that time is in the future?
There was a problem hiding this comment.
this is just checking that the passed in time includes timezone information
There was a problem hiding this comment.
a non-future time would be a no-op in worst case and we have a clean up for accumulated past times
There was a problem hiding this comment.
yeah but would prefer to have stronger validation here (especially now that i read only exact times are provided). Returning a 200 for a 11:00 request OR a request in the past would mean that server accepted my request. Would prefer to through a validation error if invalid time (no scheduled run at that time OR past run)
There was a problem hiding this comment.
I can add this in. What do you think of the following:
For skip:
- Reject if scheduled_time is in the past.
- Reject if scheduled_time is not one of Temporal’s upcoming next_action_times.
- Otherwise add the one-off skip.
For unskip:
- Reject if scheduled_time is in the past.
- Reject if scheduled_time is not currently in skipped_action_times.
- Otherwise remove that one-off skip.
There was a problem hiding this comment.
Perfect sounds good. Thanks!
| class SkipRunScheduleRequest(BaseModel): | ||
| scheduled_time: datetime = Field( | ||
| ..., | ||
| description="Specific scheduled fire time to skip.", |
There was a problem hiding this comment.
How would this work if lets say I have agent configured to run at 12:00 each day and I say pass in skip request for 11:00? Will it skip the 12:00 run?
There was a problem hiding this comment.
It will not skip the 12:00 run it skips only the exact time provided. There is a separate "snooze" ability that will skip all runs in a specified period
There was a problem hiding this comment.
attached a screenshot below.. on the client side, we’ll always know the exact occurrence the user is trying to skip because the UI renders concrete upcoming run times
@rpatel-scale it is preemptive but motivated by the UI/UX designs we have mocked up for this feature For some context here is a rough mock up I have been working on |
Co-authored-by: Cursor <cursoragent@cursor.com>

Summary
scheduled_timefor both skip and unskip.Test plan
uv run python scripts/generate_openapi_spec.pyuv run ruff format src/adapters/temporal/adapter_temporal.py src/adapters/temporal/port.py src/api/routes/agent_run_schedules.py src/api/schemas/agent_run_schedules.py src/domain/services/agent_run_schedule_service.py src/domain/use_cases/agent_run_schedules_use_case.py tests/unit/api/test_agent_run_schedules_authz.py tests/unit/services/test_agent_run_schedule_service.py tests/unit/adapters/test_temporal_schedule_skips.pyuv run ruff check src/adapters/temporal/adapter_temporal.py src/adapters/temporal/port.py src/api/routes/agent_run_schedules.py src/api/schemas/agent_run_schedules.py src/domain/services/agent_run_schedule_service.py src/domain/use_cases/agent_run_schedules_use_case.py tests/unit/api/test_agent_run_schedules_authz.py tests/unit/services/test_agent_run_schedule_service.py tests/unit/adapters/test_temporal_schedule_skips.pyuv run python -m py_compile src/adapters/temporal/adapter_temporal.py src/adapters/temporal/port.py src/api/routes/agent_run_schedules.py src/api/schemas/agent_run_schedules.py src/domain/services/agent_run_schedule_service.py src/domain/use_cases/agent_run_schedules_use_case.py tests/unit/api/test_agent_run_schedules_authz.py tests/unit/services/test_agent_run_schedule_service.py tests/unit/adapters/test_temporal_schedule_skips.pyuv run pytest tests/unit/api/test_agent_run_schedules_authz.py tests/unit/services/test_agent_run_schedule_service.py tests/unit/adapters/test_temporal_schedule_skips.py(blocked locally: missingtestcontainersduring test collection)Greptile Summary
This PR adds backend-only skip and unskip endpoints for Temporal schedule occurrences, storing one-off exclusions as
ScheduleCalendarSpecentries in the schedule'sspec.skiplist and surfacing them through a newskipped_action_timesfield on the response schema.skip_schedule_actionandunskip_schedule_actionvalidate the target time against Temporal's upcoming or currently-skipped action times, then apply the mutation atomically viahandle.update(_apply); a family of static helpers (_one_off_skip_spec,_same_one_off_skip,_without_past_one_off_skips,extract_one_off_skip_times) encodes and decodes exact-instantCalendarSpecentries and prunes expired ones on every write./{schedule_id}/skipand/{schedule_id}/unskip) require a timezone-awarescheduled_timevalidated at schema parse time, and both useAuthorizedOperationType.updateconsistent with other mutating endpoints.Confidence Score: 4/5
Safe to merge as a backend-only change with no data-loss risk; the skip/unskip correctness depends on Temporal's optimistic-concurrency update semantics, and a narrow race window between the pre-validation describe and the commit remains in the adapter.
The adapter's skip_schedule_action validates scheduled_time against next_action_times from an initial handle.describe(), then applies the mutation in a separate handle.update(_apply) callback. If the schedule fires in the window between those two calls, the pre-validation passes on a stale snapshot and the skip is added for a time that already executed. The guard inside _apply uses a now captured before the update call, so it won't catch the race. All other logic — deduplication, expired-skip pruning, timezone round-trip, authz — is solid and well-tested.
agentex/src/adapters/temporal/adapter_temporal.py — the pre-validate / commit window in skip_schedule_action and the stale now guard inside the _apply closure.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Client participant Route as API Route participant UseCase as AgentRunSchedulesUseCase participant Service as AgentRunScheduleService participant Adapter as TemporalAdapter participant Temporal as Temporal Server Client->>Route: "POST /{schedule_id}/skip {scheduled_time}" Route->>Route: _check_schedule_or_collapse_to_404 (AuthorizedOperationType.update) Route->>UseCase: skip_schedule_action(agent_id, schedule_id, scheduled_time) UseCase->>Service: skip_schedule_action(agent_id, schedule_id, scheduled_time) Service->>Service: get_by_agent_id_and_id_or_raise(agent_id, schedule_id) Service->>Adapter: skip_schedule_action(temporal_id, scheduled_time) Adapter->>Temporal: handle.describe() Temporal-->>Adapter: ScheduleDescription (next_action_times, spec.skip) Adapter->>Adapter: _validate_future_scheduled_time() Adapter->>Adapter: _contains_instant(scheduled_time, next_action_times) Adapter->>Adapter: _one_off_skip_spec(scheduled_time, time_zone_name) Adapter->>Temporal: handle.update(_apply) Note over Adapter,Temporal: _apply: prune past one-off skips, dedup, append new CalendarSpec Temporal-->>Adapter: OK Adapter-->>Service: None Service->>Adapter: describe_schedule(temporal_id) Temporal-->>Adapter: ScheduleDescription Adapter-->>Service: ScheduleDescription Service->>Service: _extract_live_fields() → skipped_action_times Service-->>Route: AgentRunScheduleResponse Route-->>Client: 200 AgentRunScheduleResponse (with skipped_action_times)%%{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 Client participant Route as API Route participant UseCase as AgentRunSchedulesUseCase participant Service as AgentRunScheduleService participant Adapter as TemporalAdapter participant Temporal as Temporal Server Client->>Route: "POST /{schedule_id}/skip {scheduled_time}" Route->>Route: _check_schedule_or_collapse_to_404 (AuthorizedOperationType.update) Route->>UseCase: skip_schedule_action(agent_id, schedule_id, scheduled_time) UseCase->>Service: skip_schedule_action(agent_id, schedule_id, scheduled_time) Service->>Service: get_by_agent_id_and_id_or_raise(agent_id, schedule_id) Service->>Adapter: skip_schedule_action(temporal_id, scheduled_time) Adapter->>Temporal: handle.describe() Temporal-->>Adapter: ScheduleDescription (next_action_times, spec.skip) Adapter->>Adapter: _validate_future_scheduled_time() Adapter->>Adapter: _contains_instant(scheduled_time, next_action_times) Adapter->>Adapter: _one_off_skip_spec(scheduled_time, time_zone_name) Adapter->>Temporal: handle.update(_apply) Note over Adapter,Temporal: _apply: prune past one-off skips, dedup, append new CalendarSpec Temporal-->>Adapter: OK Adapter-->>Service: None Service->>Adapter: describe_schedule(temporal_id) Temporal-->>Adapter: ScheduleDescription Adapter-->>Service: ScheduleDescription Service->>Service: _extract_live_fields() → skipped_action_times Service-->>Route: AgentRunScheduleResponse Route-->>Client: 200 AgentRunScheduleResponse (with skipped_action_times)Reviews (4): Last reviewed commit: "Merge branch 'main' into jerome/schedule..." | Re-trigger Greptile