Skip to content

Commit 1896429

Browse files
Kowserclaude
andcommitted
Move schedule lifecycle from SchedulerClient ABC to OrkesSchedulerClient
The ABC returns to a pure endpoint contract (main's shape + reason kwarg and typed get_schedule annotation). The six lifecycle methods live on OrkesSchedulerClient; run_now starts workflows via workflowResourceApi directly, dissolving the _start_workflow hook. get_scheduler_client() is annotated with the concrete class since the lifecycle is no longer part of the ABC contract. Test doubles subclass OrkesSchedulerClient (skipping HTTP init); a guard pins lifecycle placement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 88d8e3f commit 1896429

13 files changed

Lines changed: 167 additions & 181 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Canonical metrics mode: opt-in harmonized metric surface via `WORKER_CANONICAL_METRICS=true` -- [details](METRICS.md#detailed-technical-notes--unreleased)
1313
- `MetricsSettings` gains `clean_directory` and `clean_dead_pids` for opt-in stale `.db` file cleanup (both default to `False`)
14-
- `SchedulerClient` now carries the schedule lifecycle operations itself: `pause(reason=)`, `resume`, `delete`, `run_now`, `preview_next`, `reconcile` (declarative tri-state sync) — with typed errors (`ScheduleNotFound`, `InvalidCronExpression`, ...). `pause_schedule` gains an optional `reason=` (stored by OSS Conductor servers; ignored by Orkes servers)
14+
- `OrkesSchedulerClient` now carries the schedule lifecycle operations itself: `pause(reason=)`, `resume`, `delete`, `run_now`, `preview_next`, `reconcile` (declarative tri-state sync) — with typed errors (`ScheduleNotFound`, `InvalidCronExpression`, ...). `pause_schedule` gains an optional `reason=` (stored by OSS Conductor servers; ignored by Orkes servers)
1515

1616
### Changed
1717

@@ -23,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323

2424
### Removed
2525

26-
- **Breaking:** `AgentScheduleClient` (alias `ScheduleClient`) and `OrkesClients.get_agent_schedule_client()` are removed with no compatibility shim — `SchedulerClient` (via `OrkesClients.get_scheduler_client()`) is the one schedule client and carries the full lifecycle itself. Replace `AgentScheduleClient(scheduler_client, workflow_client)` constructions with `get_scheduler_client()`; `runtime.schedules_client()` and `AgentClient.schedules` now return that `SchedulerClient` directly (same methods, same shared instance)
26+
- **Breaking:** `AgentScheduleClient` (alias `ScheduleClient`) and `OrkesClients.get_agent_schedule_client()` are removed with no compatibility shim — `OrkesSchedulerClient` (via `OrkesClients.get_scheduler_client()`) is the one schedule client and carries the full lifecycle itself. Replace `AgentScheduleClient(scheduler_client, workflow_client)` constructions with `get_scheduler_client()`; `runtime.schedules_client()` and `AgentClient.schedules` now return that client directly (same methods, same shared instance)
2727

2828
### Fixed
2929

docs/SCHEDULE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Operations for controlling schedule execution state.
4343
4444
## Schedule Lifecycle Helpers
4545

46-
Beyond the endpoint methods above, `SchedulerClient` carries higher-level lifecycle
46+
Beyond the endpoint methods above, `OrkesSchedulerClient` carries higher-level lifecycle
4747
operations (shared with the agents layer). They raise typed errors
4848
(`ScheduleNotFound`, `InvalidCronExpression`, `ScheduleNameConflict` from
4949
`conductor.client.ai.schedule_errors`) instead of raw `ApiException`.

docs/agents/advanced.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ with AgentRuntime() as runtime:
127127
Key methods: `run`/`run_async`, `start`/`start_async`, `deploy`/`deploy_async`,
128128
`schedule(agent, schedules)`, `get_status`, `respond`, `stop`, `signal`,
129129
`stream_sse`, and `.schedules` (the schedule lifecycle — `pause`/`resume`/`delete`/
130-
`run_now`/`preview_next`/`reconcile`, now carried by `SchedulerClient` itself). Both
130+
`run_now`/`preview_next`/`reconcile`, now carried by `OrkesSchedulerClient` itself). Both
131131
sync and async forms exist. Most users call `runtime.run/start/deploy` instead,
132132
which add local-worker management on top of this client.
133133

docs/agents/api-reference.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Context manager (sync and async: `with` / `async with`).
4646
| `signal` | `(execution_id, message) -> None` | Inject persistent context |
4747
| `shutdown` | `() -> None` | Stop all workers |
4848
| `client` (property) | `-> AgentClient` | Control-plane client |
49-
| `schedules_client` | `() -> SchedulerClient` | Shared schedule client |
49+
| `schedules_client` | `() -> OrkesSchedulerClient` | Shared schedule client |
5050

5151
Async variants exist for status/respond/approve/reject/send/stop/shutdown
5252
(`*_async`). Module-level wrappers using a singleton runtime: `run`, `run_async`,
@@ -190,7 +190,7 @@ start_at=None, end_at=None, description=None)` — `cron` is a 5- or 6-field exp
190190
`ScheduleInfo` (read model) fields include `name`, `short_name`, `agent`, `cron`,
191191
`timezone`, `input`, `paused`, `catchup`, `next_run`, `create_time`, `update_time`, ...
192192

193-
The schedule lifecycle lives on `SchedulerClient` itself (via
193+
The schedule lifecycle lives on `OrkesSchedulerClient` itself (via
194194
`runtime.schedules_client()`, `runtime.client.schedules`, or
195195
`OrkesClients.get_scheduler_client()`):
196196

@@ -284,7 +284,7 @@ The control-plane client (formerly `AgentHttpClient`, alias kept). Reach it via
284284
| `stop` | `(execution_id) -> None` | |
285285
| `signal` | `(execution_id, message) -> None` | |
286286
| `stream_sse` | `(execution_id) -> AsyncIterator[dict]` | |
287-
| `schedules` (property) | `-> SchedulerClient` | |
287+
| `schedules` (property) | `-> OrkesSchedulerClient` | |
288288
| `close` | `() -> None` (async) | |
289289

290290
Lower-level endpoint methods (`start_agent`, `deploy_agent`, `compile_agent`) are also

e2e/test_suite21_scheduling.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
ScheduleNameConflict,
3434
)
3535
from conductor.client.ai.schedule import _from_workflow_schedule
36-
from conductor.client.scheduler_client import SchedulerClient
36+
from conductor.client.orkes.orkes_scheduler_client import OrkesSchedulerClient
3737

3838
pytestmark = [pytest.mark.e2e]
3939

@@ -125,12 +125,12 @@ def agent_name(conductor_clients) -> Iterator[str]:
125125

126126

127127
@pytest.fixture()
128-
def schedule_client(conductor_clients) -> SchedulerClient:
128+
def schedule_client(conductor_clients) -> OrkesSchedulerClient:
129129
return conductor_clients.get_scheduler_client()
130130

131131

132132
@pytest.fixture(autouse=True)
133-
def clean_schedules(schedule_client: SchedulerClient, agent_name: str):
133+
def clean_schedules(schedule_client: OrkesSchedulerClient, agent_name: str):
134134
"""Purge any leftover schedules for this agent before each test."""
135135
schedule_client.reconcile(agent_name, [])
136136
yield

src/conductor/ai/agents/runtime/http_client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,13 @@ def _workflow_client(self) -> Any:
203203

204204
@property
205205
def schedules(self) -> Any:
206-
"""Cron schedule lifecycle client (:class:`SchedulerClient`).
206+
"""Cron schedule lifecycle client (:class:`OrkesSchedulerClient`).
207207
208208
Exposes ``get_schedule/save_schedule/get_all_schedules`` plus the
209209
lifecycle methods ``pause/resume/delete/run_now/preview_next/
210210
reconcile``. When bound to a runtime, this is the *same*
211-
:class:`SchedulerClient` instance the runtime uses — there is one
212-
shared schedule surface, not two.
211+
:class:`OrkesSchedulerClient` instance the runtime uses — there is
212+
one shared schedule surface, not two.
213213
"""
214214
if self._runtime is not None:
215215
return self._runtime.schedules_client()

src/conductor/ai/agents/runtime/runtime.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2231,7 +2231,7 @@ def client(self) -> Any:
22312231
return self._http
22322232

22332233
def schedules_client(self) -> Any:
2234-
"""Return the shared :class:`SchedulerClient` for this runtime.
2234+
"""Return the shared :class:`OrkesSchedulerClient` for this runtime.
22352235
22362236
Delegates to :attr:`client` so the runtime and the control-plane
22372237
client expose the *same* schedule surface (one instance, not two).

src/conductor/client/ai/schedule.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99
prefixed wire name plus server-computed fields like ``next_run``.
1010
1111
The private helpers below (payload mapping, wire-name prefixing, typed error
12-
translation) are shared by ``SchedulerClient``'s schedule-lifecycle methods
13-
and the module-level ``schedules.*`` API. ``SchedulerClient``'s native
14-
``get_schedule``/``save_schedule``/``get_all_schedules`` are the source of
15-
truth for reads/writes/lists; the ``ScheduleInfo`` view exists for the
16-
module-level API.
12+
translation) are shared by ``OrkesSchedulerClient``'s schedule-lifecycle
13+
methods and the module-level ``schedules.*`` API. The native
14+
``get_schedule``/``save_schedule``/``get_all_schedules`` endpoint methods are
15+
the source of truth for reads/writes/lists; the ``ScheduleInfo`` view exists
16+
for the module-level API.
1717
"""
1818

1919
from __future__ import annotations

src/conductor/client/orkes/orkes_scheduler_client.py

Lines changed: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from __future__ import annotations
2+
import logging
23
from typing import Optional, List
34

45
from conductor.client.configuration.configuration import Configuration
@@ -11,6 +12,8 @@
1112
from conductor.client.orkes.orkes_base_client import OrkesBaseClient
1213
from conductor.client.scheduler_client import SchedulerClient
1314

15+
logger = logging.getLogger(__name__)
16+
1417

1518
class OrkesSchedulerClient(OrkesBaseClient, SchedulerClient):
1619
def __init__(self, configuration: Configuration):
@@ -133,7 +136,107 @@ def get_scheduler_tags(self, name: str) -> List[MetadataTag]:
133136
def delete_scheduler_tags(self, tags: List[MetadataTag], name: str) -> List[MetadataTag]:
134137
self.schedulerResourceApi.delete_tag_for_schedule(tags, name)
135138

136-
def _start_workflow(self, request) -> str:
137-
# Enables SchedulerClient.run_now — OrkesBaseClient already carries the
138-
# workflow resource API.
139-
return self.workflowResourceApi.start_workflow(body=request)
139+
# ── schedule lifecycle (domain surface) ──────────────────────────
140+
#
141+
# Built over the endpoint methods above. Reads/writes/lists have NO domain
142+
# twins by design — get_schedule/save_schedule/get_all_schedules are the
143+
# source of truth. These methods raise the typed errors from
144+
# conductor.client.ai.schedule_errors (ScheduleNotFound,
145+
# InvalidCronExpression, ...) instead of raw ApiException.
146+
# The conductor.client.ai imports are deliberately lazy: this module is on
147+
# the OrkesClients import path and must not pull the agent surface at
148+
# import time.
149+
150+
def pause(self, wire_name: str, reason: Optional[str] = None) -> None:
151+
"""Pause one schedule by wire name (typed errors; ``reason`` is OSS-only)."""
152+
from conductor.client.ai.schedule import _translate
153+
154+
try:
155+
if reason is None:
156+
self.pause_schedule(wire_name)
157+
else:
158+
self.pause_schedule(wire_name, reason=reason)
159+
except Exception as exc: # noqa: BLE001
160+
raise _translate(exc) from exc
161+
162+
def resume(self, wire_name: str) -> None:
163+
"""Resume one schedule by wire name (typed errors)."""
164+
from conductor.client.ai.schedule import _translate
165+
166+
try:
167+
self.resume_schedule(wire_name)
168+
except Exception as exc: # noqa: BLE001
169+
raise _translate(exc) from exc
170+
171+
def delete(self, wire_name: str) -> None:
172+
"""Delete one schedule by wire name (typed errors)."""
173+
from conductor.client.ai.schedule import _translate
174+
175+
try:
176+
self.delete_schedule(wire_name)
177+
except Exception as exc: # noqa: BLE001
178+
raise _translate(exc) from exc
179+
180+
def preview_next(
181+
self, cron: str, n: int = 5, start_at: Optional[int] = None, end_at: Optional[int] = None
182+
) -> List[int]:
183+
"""Next ``n`` epoch-ms fire times for ``cron`` (typed errors)."""
184+
from conductor.client.ai.schedule import _translate
185+
186+
try:
187+
times = self.get_next_few_schedule_execution_times(
188+
cron_expression=cron,
189+
schedule_start_time=start_at,
190+
schedule_end_time=end_at,
191+
limit=n,
192+
)
193+
return list(times) if times else []
194+
except Exception as exc: # noqa: BLE001
195+
raise _translate(exc) from exc
196+
197+
def run_now(self, info: "ScheduleInfo") -> str: # noqa: F821
198+
"""Fire the schedule's agent once with its stored input. Returns execution id."""
199+
from conductor.client.ai.schedule import _translate
200+
from conductor.client.http.models.start_workflow_request import StartWorkflowRequest
201+
202+
req = StartWorkflowRequest(name=info.agent, input=dict(info.input))
203+
try:
204+
return self.workflowResourceApi.start_workflow(body=req)
205+
except Exception as exc: # noqa: BLE001
206+
raise _translate(exc) from exc
207+
208+
def reconcile(self, agent_name: str, desired: Optional[List["Schedule"]]) -> None: # noqa: F821
209+
"""Apply the declarative tri-state semantics from spec §5.1:
210+
211+
- ``desired is None``: no-op
212+
- ``desired == []``: delete every schedule whose workflow == agent_name
213+
- ``desired == [...]``: upsert listed, delete others scoped to this agent
214+
"""
215+
from conductor.client.ai.schedule import (
216+
_check_unique_names,
217+
_list_infos,
218+
_prefix,
219+
_to_save_request,
220+
_translate,
221+
)
222+
223+
if desired is None:
224+
return
225+
_check_unique_names(desired)
226+
existing = _list_infos(self, agent_name)
227+
existing_wire_by_short = {info.short_name: info.name for info in existing}
228+
desired_short = {s.name for s in desired}
229+
230+
for short, wire in existing_wire_by_short.items():
231+
if short not in desired_short:
232+
logger.info("Pruning schedule %s for agent %s", wire, agent_name)
233+
self.delete(wire)
234+
235+
for s in desired:
236+
logger.info(
237+
"Upserting schedule %s for agent %s", _prefix(agent_name, s.name), agent_name
238+
)
239+
try:
240+
self.save_schedule(_to_save_request(s, agent_name))
241+
except Exception as exc: # noqa: BLE001
242+
raise _translate(exc) from exc

src/conductor/client/orkes_clients.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ def get_authorization_client(self) -> AuthorizationClient:
4343
def get_metadata_client(self) -> MetadataClient:
4444
return OrkesMetadataClient(self.configuration)
4545

46-
def get_scheduler_client(self) -> SchedulerClient:
46+
def get_scheduler_client(self) -> OrkesSchedulerClient:
47+
# Annotated with the concrete class: the schedule-lifecycle methods
48+
# (pause/resume/delete/run_now/preview_next/reconcile) live on
49+
# OrkesSchedulerClient, not on the SchedulerClient endpoint ABC.
4750
return OrkesSchedulerClient(self.configuration)
4851

4952
def get_secret_client(self) -> SecretClient:

0 commit comments

Comments
 (0)