agentexec 0.1.6__tar.gz → 0.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. {agentexec-0.1.6 → agentexec-0.1.7}/CHANGELOG.md +21 -0
  2. {agentexec-0.1.6 → agentexec-0.1.7}/PKG-INFO +36 -2
  3. {agentexec-0.1.6 → agentexec-0.1.7}/README.md +34 -1
  4. {agentexec-0.1.6 → agentexec-0.1.7}/pyproject.toml +2 -1
  5. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/config.py +16 -0
  6. agentexec-0.1.7/src/agentexec/schedule.py +144 -0
  7. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/state/__init__.py +2 -0
  8. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/state/backend.py +43 -0
  9. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/state/redis_backend.py +48 -0
  10. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/worker/pool.py +106 -3
  11. agentexec-0.1.7/tests/test_schedule.py +447 -0
  12. {agentexec-0.1.6 → agentexec-0.1.7}/ui/package.json +1 -1
  13. {agentexec-0.1.6 → agentexec-0.1.7}/.claude/settings.local.json +0 -0
  14. {agentexec-0.1.6 → agentexec-0.1.7}/.claude/skills/prepare-release/SKILL.md +0 -0
  15. {agentexec-0.1.6 → agentexec-0.1.7}/.github/workflows/docker-publish.yml +0 -0
  16. {agentexec-0.1.6 → agentexec-0.1.7}/.github/workflows/npm-publish.yml +0 -0
  17. {agentexec-0.1.6 → agentexec-0.1.7}/.github/workflows/publish.yml +0 -0
  18. {agentexec-0.1.6 → agentexec-0.1.7}/.gitignore +0 -0
  19. {agentexec-0.1.6 → agentexec-0.1.7}/docker/Dockerfile +0 -0
  20. {agentexec-0.1.6 → agentexec-0.1.7}/docker/README.md +0 -0
  21. {agentexec-0.1.6 → agentexec-0.1.7}/docker/entrypoint.py +0 -0
  22. {agentexec-0.1.6 → agentexec-0.1.7}/docs/api-reference/activity.md +0 -0
  23. {agentexec-0.1.6 → agentexec-0.1.7}/docs/api-reference/core.md +0 -0
  24. {agentexec-0.1.6 → agentexec-0.1.7}/docs/api-reference/pipeline.md +0 -0
  25. {agentexec-0.1.6 → agentexec-0.1.7}/docs/api-reference/runner.md +0 -0
  26. {agentexec-0.1.6 → agentexec-0.1.7}/docs/concepts/activity-tracking.md +0 -0
  27. {agentexec-0.1.6 → agentexec-0.1.7}/docs/concepts/architecture.md +0 -0
  28. {agentexec-0.1.6 → agentexec-0.1.7}/docs/concepts/task-lifecycle.md +0 -0
  29. {agentexec-0.1.6 → agentexec-0.1.7}/docs/concepts/worker-pool.md +0 -0
  30. {agentexec-0.1.6 → agentexec-0.1.7}/docs/contributing.md +0 -0
  31. {agentexec-0.1.6 → agentexec-0.1.7}/docs/deployment/docker.md +0 -0
  32. {agentexec-0.1.6 → agentexec-0.1.7}/docs/deployment/production.md +0 -0
  33. {agentexec-0.1.6 → agentexec-0.1.7}/docs/getting-started/configuration.md +0 -0
  34. {agentexec-0.1.6 → agentexec-0.1.7}/docs/getting-started/installation.md +0 -0
  35. {agentexec-0.1.6 → agentexec-0.1.7}/docs/getting-started/quickstart.md +0 -0
  36. {agentexec-0.1.6 → agentexec-0.1.7}/docs/guides/basic-usage.md +0 -0
  37. {agentexec-0.1.6 → agentexec-0.1.7}/docs/guides/custom-runners.md +0 -0
  38. {agentexec-0.1.6 → agentexec-0.1.7}/docs/guides/fastapi-integration.md +0 -0
  39. {agentexec-0.1.6 → agentexec-0.1.7}/docs/guides/openai-runner.md +0 -0
  40. {agentexec-0.1.6 → agentexec-0.1.7}/docs/guides/pipelines.md +0 -0
  41. {agentexec-0.1.6 → agentexec-0.1.7}/docs/index.md +0 -0
  42. {agentexec-0.1.6 → agentexec-0.1.7}/examples/multi-tenancy/README.md +0 -0
  43. {agentexec-0.1.6 → agentexec-0.1.7}/examples/multi-tenancy/example.py +0 -0
  44. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/README.md +0 -0
  45. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic/README +0 -0
  46. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic/env.py +0 -0
  47. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic/script.py.mako +0 -0
  48. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic.ini +0 -0
  49. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/compose.yml +0 -0
  50. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/context.py +0 -0
  51. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/db.py +0 -0
  52. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/main.py +0 -0
  53. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/models.py +0 -0
  54. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/pipeline.py +0 -0
  55. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/pyproject.toml +0 -0
  56. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/tools.py +0 -0
  57. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/.gitignore +0 -0
  58. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/bun.lock +0 -0
  59. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/index.html +0 -0
  60. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/package.json +0 -0
  61. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/public/vite.svg +0 -0
  62. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/App.tsx +0 -0
  63. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/api/agents.ts +0 -0
  64. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/api/queries.ts +0 -0
  65. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/components/Layout.tsx +0 -0
  66. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/index.css +0 -0
  67. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/main.tsx +0 -0
  68. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/pages/AgentDetailPage.tsx +0 -0
  69. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/pages/AgentListPage.tsx +0 -0
  70. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/styles/github-dark.css +0 -0
  71. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/tsconfig.json +0 -0
  72. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/tsconfig.node.json +0 -0
  73. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/vite.config.ts +0 -0
  74. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/views.py +0 -0
  75. {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/worker.py +0 -0
  76. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/__init__.py +0 -0
  77. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/activity/__init__.py +0 -0
  78. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/activity/models.py +0 -0
  79. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/activity/schemas.py +0 -0
  80. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/activity/tracker.py +0 -0
  81. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/core/__init__.py +0 -0
  82. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/core/db.py +0 -0
  83. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/core/logging.py +0 -0
  84. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/core/queue.py +0 -0
  85. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/core/results.py +0 -0
  86. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/core/task.py +0 -0
  87. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/pipeline.py +0 -0
  88. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/runners/__init__.py +0 -0
  89. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/runners/base.py +0 -0
  90. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/runners/openai.py +0 -0
  91. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/tracker.py +0 -0
  92. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/worker/__init__.py +0 -0
  93. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/worker/event.py +0 -0
  94. {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/worker/logging.py +0 -0
  95. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_activity_schemas.py +0 -0
  96. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_activity_tracking.py +0 -0
  97. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_activity_tracking.py.bak +0 -0
  98. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_config.py +0 -0
  99. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_db.py +0 -0
  100. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_pipeline.py +0 -0
  101. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_pipeline_flow.py +0 -0
  102. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_public_api.py +0 -0
  103. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_queue.py +0 -0
  104. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_results.py +0 -0
  105. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_runners.py +0 -0
  106. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_self_describing_results.py +0 -0
  107. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_state.py +0 -0
  108. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_state_backend.py +0 -0
  109. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_task.py +0 -0
  110. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_task_locking.py +0 -0
  111. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_task_types.py +0 -0
  112. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_worker_event.py +0 -0
  113. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_worker_logging.py +0 -0
  114. {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_worker_pool.py +0 -0
  115. {agentexec-0.1.6 → agentexec-0.1.7}/ui/.gitignore +0 -0
  116. {agentexec-0.1.6 → agentexec-0.1.7}/ui/README.md +0 -0
  117. {agentexec-0.1.6 → agentexec-0.1.7}/ui/bun.lock +0 -0
  118. {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/components/ActiveAgentsBadge.tsx +0 -0
  119. {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/components/ProgressBar.tsx +0 -0
  120. {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/components/StatusBadge.tsx +0 -0
  121. {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/components/TaskDetail.tsx +0 -0
  122. {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/components/TaskList.tsx +0 -0
  123. {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/components/index.ts +0 -0
  124. {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/index.ts +0 -0
  125. {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/types.ts +0 -0
  126. {agentexec-0.1.6 → agentexec-0.1.7}/ui/tsconfig.json +0 -0
  127. {agentexec-0.1.6 → agentexec-0.1.7}/ui/vite.config.ts +0 -0
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.1.7
4
+
5
+ ### New Features
6
+
7
+ **Scheduled tasks with cron expressions**
8
+ - `@pool.schedule("task_name", "*/5 * * * *")` decorator registers and schedules a task in one step
9
+ - `pool.add_schedule()` for imperative scheduling of already-registered tasks
10
+ - Cron expressions evaluated in configurable timezone (`AGENTEXEC_SCHEDULER_TIMEZONE`, default UTC)
11
+ - Repeat budget: `-1` for forever (default), `0` for one-shot, `N` for N more executions
12
+ - Scheduler runs automatically inside `pool.run()` — no extra setup needed
13
+ - Idempotent registration: keyed by task name, so restarts and multiple pool instances overwrite instead of duplicating
14
+ - Clock-drift resilient: next run computed from intended anchor time, not wall clock
15
+ - Skips missed intervals after downtime instead of enqueuing a burst of catch-up tasks
16
+ - New `croniter` dependency for cron expression parsing
17
+
18
+ ### Improvements
19
+
20
+ **State backend sorted set operations**
21
+ - Added `zadd()`, `zrangebyscore()`, `zrem()` to `StateBackend` protocol and Redis implementation
22
+ - Used internally by the scheduler for efficient due-task polling
23
+
3
24
  ## v0.1.6
4
25
 
5
26
  ### New Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentexec
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Production-ready orchestration for OpenAI Agents with Redis-backed coordination, activity tracking, and workflow management
5
5
  Project-URL: Homepage, https://github.com/Agent-CI/agentexec
6
6
  Project-URL: Documentation, https://github.com/Agent-CI/agentexec#readme
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
18
  Requires-Python: >=3.12
19
+ Requires-Dist: croniter>=6.0.0
19
20
  Requires-Dist: openai-agents>=0.1.0
20
21
  Requires-Dist: pydantic-settings>=2.5.0
21
22
  Requires-Dist: pydantic>=2.12.0
@@ -233,6 +234,31 @@ The lock TTL (`AGENTEXEC_LOCK_TTL`, default 1800s) is a safety net for worker pr
233
234
 
234
235
  **Note:** When a task is requeued due to a held lock, it goes to the back of the queue. This means strict FIFO ordering is not guaranteed between tasks sharing the same lock key — if tasks T2 and T3 are both waiting on T1's lock, either could run next after T1 completes.
235
236
 
237
+ ### Scheduled Tasks
238
+
239
+ Run tasks on a recurring interval using cron expressions:
240
+
241
+ ```python
242
+ # Decorator — registers the task and schedules it in one step
243
+ @pool.schedule("refresh_cache", "*/5 * * * *")
244
+ async def refresh(agent_id: UUID, context: RefreshContext):
245
+ ...
246
+
247
+ # With context and repeat limit
248
+ @pool.schedule("sync_users", "0 * * * *", context=SyncContext(full=True), repeat=3)
249
+ async def sync(agent_id: UUID, context: SyncContext):
250
+ ...
251
+ ```
252
+
253
+ For tasks registered separately, use `pool.add_schedule()`:
254
+
255
+ ```python
256
+ pool.add_schedule("refresh_cache", "*/5 * * * *", RefreshContext(scope="all"))
257
+ pool.add_schedule("refresh_cache", "0 * * * *", RefreshContext(scope="users"), repeat=3)
258
+ ```
259
+
260
+ The scheduler runs automatically inside `pool.run()`. Cron expressions are evaluated in the configured timezone (`AGENTEXEC_SCHEDULER_TIMEZONE`, default UTC) so schedules read naturally regardless of server timezone. Next-run times are computed from the intended anchor time, not wall clock, to prevent cumulative drift.
261
+
236
262
  ### Priority Queue
237
263
 
238
264
  Control task execution order:
@@ -642,7 +668,12 @@ async def handler(agent_id: UUID, context: MyContext) -> None: ...
642
668
  @pool.task("name", lock_key="user:{user_id}") # Sequential per user
643
669
  async def locked(agent_id: UUID, context: MyContext) -> None: ...
644
670
 
645
- pool.run() # Blocking - runs workers
671
+ @pool.schedule("name", "*/5 * * * *") # Register + schedule in one step
672
+ async def scheduled(agent_id: UUID, context: MyContext) -> None: ...
673
+
674
+ pool.add_schedule("name", "0 * * * *", MyContext(), repeat=3) # Schedule separately
675
+
676
+ pool.run() # Blocking - runs workers + scheduler
646
677
  pool.start() # Non-blocking - starts workers in background
647
678
  pool.shutdown() # Graceful shutdown
648
679
  ```
@@ -745,6 +776,9 @@ AGENTEXEC_RESULT_TTL=3600
745
776
  # Task locking
746
777
  AGENTEXEC_LOCK_TTL=1800
747
778
 
779
+ # Scheduling
780
+ AGENTEXEC_SCHEDULER_TIMEZONE=UTC
781
+
748
782
  # State backend
749
783
  AGENTEXEC_STATE_BACKEND=agentexec.state.redis_backend
750
784
  AGENTEXEC_KEY_PREFIX=agentexec
@@ -208,6 +208,31 @@ The lock TTL (`AGENTEXEC_LOCK_TTL`, default 1800s) is a safety net for worker pr
208
208
 
209
209
  **Note:** When a task is requeued due to a held lock, it goes to the back of the queue. This means strict FIFO ordering is not guaranteed between tasks sharing the same lock key — if tasks T2 and T3 are both waiting on T1's lock, either could run next after T1 completes.
210
210
 
211
+ ### Scheduled Tasks
212
+
213
+ Run tasks on a recurring interval using cron expressions:
214
+
215
+ ```python
216
+ # Decorator — registers the task and schedules it in one step
217
+ @pool.schedule("refresh_cache", "*/5 * * * *")
218
+ async def refresh(agent_id: UUID, context: RefreshContext):
219
+ ...
220
+
221
+ # With context and repeat limit
222
+ @pool.schedule("sync_users", "0 * * * *", context=SyncContext(full=True), repeat=3)
223
+ async def sync(agent_id: UUID, context: SyncContext):
224
+ ...
225
+ ```
226
+
227
+ For tasks registered separately, use `pool.add_schedule()`:
228
+
229
+ ```python
230
+ pool.add_schedule("refresh_cache", "*/5 * * * *", RefreshContext(scope="all"))
231
+ pool.add_schedule("refresh_cache", "0 * * * *", RefreshContext(scope="users"), repeat=3)
232
+ ```
233
+
234
+ The scheduler runs automatically inside `pool.run()`. Cron expressions are evaluated in the configured timezone (`AGENTEXEC_SCHEDULER_TIMEZONE`, default UTC) so schedules read naturally regardless of server timezone. Next-run times are computed from the intended anchor time, not wall clock, to prevent cumulative drift.
235
+
211
236
  ### Priority Queue
212
237
 
213
238
  Control task execution order:
@@ -617,7 +642,12 @@ async def handler(agent_id: UUID, context: MyContext) -> None: ...
617
642
  @pool.task("name", lock_key="user:{user_id}") # Sequential per user
618
643
  async def locked(agent_id: UUID, context: MyContext) -> None: ...
619
644
 
620
- pool.run() # Blocking - runs workers
645
+ @pool.schedule("name", "*/5 * * * *") # Register + schedule in one step
646
+ async def scheduled(agent_id: UUID, context: MyContext) -> None: ...
647
+
648
+ pool.add_schedule("name", "0 * * * *", MyContext(), repeat=3) # Schedule separately
649
+
650
+ pool.run() # Blocking - runs workers + scheduler
621
651
  pool.start() # Non-blocking - starts workers in background
622
652
  pool.shutdown() # Graceful shutdown
623
653
  ```
@@ -720,6 +750,9 @@ AGENTEXEC_RESULT_TTL=3600
720
750
  # Task locking
721
751
  AGENTEXEC_LOCK_TTL=1800
722
752
 
753
+ # Scheduling
754
+ AGENTEXEC_SCHEDULER_TIMEZONE=UTC
755
+
723
756
  # State backend
724
757
  AGENTEXEC_STATE_BACKEND=agentexec.state.redis_backend
725
758
  AGENTEXEC_KEY_PREFIX=agentexec
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentexec"
3
- version = "0.1.6"
3
+ version = "0.1.7"
4
4
  description = "Production-ready orchestration for OpenAI Agents with Redis-backed coordination, activity tracking, and workflow management"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -25,6 +25,7 @@ dependencies = [
25
25
  "pydantic-settings>=2.5.0",
26
26
  "sqlalchemy>=2.0.44",
27
27
  "openai-agents>=0.1.0",
28
+ "croniter>=6.0.0",
28
29
  ]
29
30
 
30
31
 
@@ -1,3 +1,5 @@
1
+ from zoneinfo import ZoneInfo
2
+
1
3
  from pydantic import AliasChoices, Field
2
4
  from pydantic_settings import BaseSettings, SettingsConfigDict
3
5
 
@@ -86,6 +88,14 @@ class Config(BaseSettings):
86
88
  validation_alias="AGENTEXEC_KEY_PREFIX",
87
89
  )
88
90
 
91
+ scheduler_timezone: str = Field(
92
+ default="UTC",
93
+ description=(
94
+ "IANA timezone for cron schedule evaluation (e.g. 'America/New_York', 'UTC'). "
95
+ "Set this so cron expressions read naturally in your local time."
96
+ ),
97
+ validation_alias="AGENTEXEC_SCHEDULER_TIMEZONE",
98
+ )
89
99
  lock_ttl: int = Field(
90
100
  default=1800,
91
101
  description=(
@@ -99,4 +109,10 @@ class Config(BaseSettings):
99
109
  )
100
110
 
101
111
 
112
+ @property
113
+ def scheduler_tz(self) -> ZoneInfo:
114
+ """Resolved ZoneInfo for the configured scheduler timezone."""
115
+ return ZoneInfo(self.scheduler_timezone)
116
+
117
+
102
118
  CONF = Config()
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from datetime import datetime
5
+ from typing import Any
6
+ from croniter import croniter
7
+ from pydantic import BaseModel, Field, ValidationError
8
+
9
+ from agentexec import state
10
+ from agentexec.config import CONF
11
+ from agentexec.core.logging import get_logger
12
+ from agentexec.core.queue import enqueue
13
+
14
+ logger = get_logger(__name__)
15
+
16
+ __all__ = [
17
+ "register",
18
+ "tick",
19
+ ]
20
+
21
+ REPEAT_FOREVER: int = -1
22
+
23
+
24
+ class ScheduledTask(BaseModel):
25
+ """A task scheduled to run on a recurring interval.
26
+
27
+ Stored in Redis with a sorted-set index for efficient due-time polling.
28
+ Each time it fires, a fresh Task (with its own agent_id) is enqueued
29
+ for the worker pool. Stays in Redis until its repeat budget is exhausted.
30
+ """
31
+
32
+ task_name: str
33
+ context: bytes
34
+ cron: str
35
+ repeat: int = REPEAT_FOREVER
36
+ next_run: float = 0
37
+ created_at: float = Field(default_factory=lambda: time.time())
38
+ metadata: dict[str, Any] | None = None
39
+
40
+ def model_post_init(self, __context: Any) -> None:
41
+ """Compute next_run from cron if not explicitly set."""
42
+ if self.next_run == 0:
43
+ self.next_run = self._next_after(self.created_at)
44
+
45
+ def advance(self) -> None:
46
+ """Advance next_run to the next future cron occurrence.
47
+
48
+ Skips past any missed intervals so we don't enqueue a burst of
49
+ catch-up tasks after downtime. Decrements repeat for each skipped
50
+ interval (finite schedules only; -1 stays unchanged).
51
+ """
52
+ now = time.time()
53
+ while True:
54
+ self.next_run = self._next_after(self.next_run)
55
+ if self.repeat >= 0:
56
+ self.repeat -= 1
57
+ if self.next_run > now or self.repeat == 0:
58
+ break
59
+
60
+ def _next_after(self, anchor: float) -> float:
61
+ """Compute the next cron occurrence after anchor."""
62
+ dt = datetime.fromtimestamp(anchor, tz=CONF.scheduler_tz)
63
+ return float(croniter(self.cron, dt).get_next(float))
64
+
65
+
66
+ def _schedule_key(schedule_id: str) -> str:
67
+ """Redis key for a schedule definition."""
68
+ return state.backend.format_key(*state.KEY_SCHEDULE, schedule_id)
69
+
70
+
71
+ def _queue_key() -> str:
72
+ """Redis sorted-set key that indexes schedules by next_run."""
73
+ return state.backend.format_key(*state.KEY_SCHEDULE_QUEUE)
74
+
75
+
76
+ def register(
77
+ task_name: str,
78
+ every: str,
79
+ context: BaseModel,
80
+ *,
81
+ repeat: int = REPEAT_FOREVER,
82
+ metadata: dict[str, Any] | None = None,
83
+ ) -> None:
84
+ """Register a new scheduled task in Redis.
85
+
86
+ The task will first fire at the next cron occurrence from now.
87
+
88
+ Args:
89
+ task_name: Name of the registered task to enqueue on each tick.
90
+ every: Schedule expression (cron syntax: min hour dom mon dow).
91
+ context: Pydantic context payload passed to the handler each time.
92
+ repeat: How many additional executions after the first.
93
+ -1 = forever (default), 0 = one-shot, N = N more times.
94
+ metadata: Optional metadata dict (e.g. for multi-tenancy).
95
+ """
96
+ task = ScheduledTask(
97
+ task_name=task_name,
98
+ context=state.backend.serialize(context),
99
+ cron=every,
100
+ repeat=repeat,
101
+ metadata=metadata,
102
+ )
103
+
104
+ state.backend.set(
105
+ _schedule_key(task_name),
106
+ task.model_dump_json().encode(),
107
+ )
108
+ state.backend.zadd(_queue_key(), {task_name: task.next_run})
109
+ logger.info(f"Scheduled {task_name}")
110
+
111
+
112
+ async def tick() -> None:
113
+ """Process all scheduled tasks that are due right now.
114
+
115
+ For each due task, enqueues it into the normal task queue. If repeats
116
+ remain, advances to the next run time. Otherwise removes the schedule.
117
+ """
118
+ for _task_name in await state.backend.zrangebyscore(_queue_key(), 0, time.time()):
119
+ task_name = _task_name.decode("utf-8")
120
+
121
+ try:
122
+ data = state.backend.get(_schedule_key(task_name))
123
+ task = ScheduledTask.model_validate_json(data)
124
+ except ValidationError:
125
+ logger.warning(f"Failed to load schedule {task_name}, skipping")
126
+ continue
127
+
128
+ await enqueue(
129
+ task.task_name,
130
+ context=state.backend.deserialize(task.context),
131
+ metadata=task.metadata,
132
+ )
133
+
134
+ if task.repeat == 0:
135
+ state.backend.zrem(_queue_key(), task_name)
136
+ state.backend.delete(_schedule_key(task_name))
137
+ logger.info(f"Schedule for '{task_name}' exhausted")
138
+ else:
139
+ task.advance()
140
+ state.backend.set(
141
+ _schedule_key(task_name),
142
+ task.model_dump_json().encode(),
143
+ )
144
+ state.backend.zadd(_queue_key(), {task_name: task.next_run})
@@ -12,6 +12,8 @@ from agentexec.state.backend import StateBackend
12
12
  KEY_RESULT = (CONF.key_prefix, "result")
13
13
  KEY_EVENT = (CONF.key_prefix, "event")
14
14
  KEY_LOCK = (CONF.key_prefix, "lock")
15
+ KEY_SCHEDULE = (CONF.key_prefix, "schedule")
16
+ KEY_SCHEDULE_QUEUE = (CONF.key_prefix, "schedule_queue")
15
17
  CHANNEL_LOGS = (CONF.key_prefix, "logs")
16
18
 
17
19
  __all__ = [
@@ -281,6 +281,49 @@ class StateBackend(Protocol):
281
281
  """
282
282
  ...
283
283
 
284
+ # Sorted set operations
285
+ @staticmethod
286
+ def zadd(key: str, mapping: dict[str, float]) -> int:
287
+ """Add members to a sorted set with scores.
288
+
289
+ Args:
290
+ key: Sorted set key
291
+ mapping: Dict of {member: score}
292
+
293
+ Returns:
294
+ Number of new members added
295
+ """
296
+ ...
297
+
298
+ @staticmethod
299
+ async def zrangebyscore(
300
+ key: str, min_score: float, max_score: float
301
+ ) -> list[bytes]:
302
+ """Get members with scores between min and max.
303
+
304
+ Args:
305
+ key: Sorted set key
306
+ min_score: Minimum score (inclusive)
307
+ max_score: Maximum score (inclusive)
308
+
309
+ Returns:
310
+ List of members as bytes
311
+ """
312
+ ...
313
+
314
+ @staticmethod
315
+ def zrem(key: str, *members: str) -> int:
316
+ """Remove members from a sorted set.
317
+
318
+ Args:
319
+ key: Sorted set key
320
+ *members: Members to remove
321
+
322
+ Returns:
323
+ Number of members removed
324
+ """
325
+ ...
326
+
284
327
  # Cleanup operations
285
328
  @staticmethod
286
329
  def clear_keys() -> int:
@@ -27,6 +27,9 @@ __all__ = [
27
27
  "publish",
28
28
  "subscribe",
29
29
  "close",
30
+ "zadd",
31
+ "zrangebyscore",
32
+ "zrem",
30
33
  "clear_keys",
31
34
  ]
32
35
 
@@ -410,6 +413,51 @@ async def subscribe(channel: str) -> AsyncGenerator[str, None]:
410
413
  _pubsub = None
411
414
 
412
415
 
416
+ def zadd(key: str, mapping: dict[str, float]) -> int:
417
+ """Add members to a sorted set with scores.
418
+
419
+ Args:
420
+ key: Sorted set key
421
+ mapping: Dict of {member: score}
422
+
423
+ Returns:
424
+ Number of new members added
425
+ """
426
+ client = _get_sync_client()
427
+ return client.zadd(key, mapping) # type: ignore[return-value]
428
+
429
+
430
+ async def zrangebyscore(
431
+ key: str, min_score: float, max_score: float
432
+ ) -> list[bytes]:
433
+ """Get members with scores between min and max.
434
+
435
+ Args:
436
+ key: Sorted set key
437
+ min_score: Minimum score (inclusive)
438
+ max_score: Maximum score (inclusive)
439
+
440
+ Returns:
441
+ List of members as bytes
442
+ """
443
+ client = _get_async_client()
444
+ return await client.zrangebyscore(key, min_score, max_score) # type: ignore[return-value]
445
+
446
+
447
+ def zrem(key: str, *members: str) -> int:
448
+ """Remove members from a sorted set.
449
+
450
+ Args:
451
+ key: Sorted set key
452
+ *members: Members to remove
453
+
454
+ Returns:
455
+ Number of members removed
456
+ """
457
+ client = _get_sync_client()
458
+ return client.zrem(key, *members) # type: ignore[return-value]
459
+
460
+
413
461
  def clear_keys() -> int:
414
462
  """Clear all Redis keys managed by this application.
415
463
 
@@ -4,7 +4,7 @@ import asyncio
4
4
  import logging
5
5
  import multiprocessing as mp
6
6
  from dataclasses import dataclass
7
- from typing import Callable
7
+ from typing import Any, Callable
8
8
  from uuid import uuid4
9
9
 
10
10
  from pydantic import BaseModel
@@ -15,6 +15,7 @@ from agentexec.config import CONF
15
15
  from agentexec.core.db import remove_global_session, set_global_session
16
16
  from agentexec.core.queue import dequeue, requeue
17
17
  from agentexec.core.task import Task, TaskDefinition, TaskHandler
18
+ from agentexec import schedule
18
19
  from agentexec.worker.event import StateEvent
19
20
  from agentexec.worker.logging import (
20
21
  DEFAULT_FORMAT,
@@ -28,6 +29,12 @@ __all__ = [
28
29
  ]
29
30
 
30
31
 
32
+ class _EmptyContext(BaseModel):
33
+ """Default context for scheduled tasks that don't need one."""
34
+
35
+ pass
36
+
37
+
31
38
  def _get_pool_id() -> str:
32
39
  """Get a unique pool ID for shutdown event keys."""
33
40
  return str(uuid4())
@@ -275,6 +282,98 @@ class Pool:
275
282
  )
276
283
  self._context.tasks[name] = definition
277
284
 
285
+ def schedule(
286
+ self,
287
+ name: str,
288
+ every: str,
289
+ *,
290
+ context: BaseModel | None = None,
291
+ repeat: int = -1,
292
+ lock_key: str | None = None,
293
+ metadata: dict[str, Any] | None = None,
294
+ ) -> Callable[[TaskHandler], TaskHandler]:
295
+ """Decorator to register and schedule a task in one step.
296
+
297
+ Combines ``@pool.task()`` and ``pool.add_schedule()`` — registers the
298
+ handler as a task and schedules it to run on the given interval.
299
+
300
+ Args:
301
+ name: Task name used when enqueueing and for worker routing.
302
+ every: Schedule expression (cron syntax: min hour dom mon dow).
303
+ context: Pydantic context payload passed to the handler each time.
304
+ Defaults to an empty BaseModel if not provided.
305
+ repeat: How many additional executions after the first.
306
+ -1 = forever (default), 0 = one-shot, N = N more times.
307
+ lock_key: Optional string template for distributed locking.
308
+ metadata: Optional metadata dict (e.g. for multi-tenancy).
309
+
310
+ Returns:
311
+ Decorator function that returns the handler.
312
+
313
+ Example:
314
+ @pool.schedule("refresh_cache", "*/5 * * * *")
315
+ async def refresh(agent_id: UUID, context: RefreshContext):
316
+ ...
317
+
318
+ @pool.schedule("sync_users", "0 * * * *", context=SyncContext(full=True), repeat=3)
319
+ async def sync(agent_id: UUID, context: SyncContext):
320
+ ...
321
+ """
322
+
323
+ def decorator(func: TaskHandler) -> TaskHandler:
324
+ self.add_task(name, func, lock_key=lock_key)
325
+ self.add_schedule(
326
+ name, every, context or _EmptyContext(),
327
+ repeat=repeat, metadata=metadata,
328
+ )
329
+ return func
330
+
331
+ return decorator
332
+
333
+ def add_schedule(
334
+ self,
335
+ task_name: str,
336
+ every: str,
337
+ context: BaseModel,
338
+ *,
339
+ repeat: int = -1,
340
+ metadata: dict[str, Any] | None = None,
341
+ ) -> None:
342
+ """Schedule a registered task to run on a recurring interval.
343
+
344
+ The task must already be registered via ``@pool.task()`` or
345
+ ``pool.add_task()``. The scheduler loop runs automatically
346
+ inside ``pool.run()`` — no extra setup needed.
347
+
348
+ Args:
349
+ task_name: Name of a registered task.
350
+ every: Schedule expression (cron syntax: min hour dom mon dow).
351
+ context: Pydantic context payload passed to the handler each time.
352
+ repeat: How many additional executions after the first.
353
+ -1 = forever (default), 0 = one-shot, N = N more times.
354
+ metadata: Optional metadata dict (e.g. for multi-tenancy).
355
+
356
+ Raises:
357
+ ValueError: If the task name is not registered with this pool.
358
+
359
+ Example:
360
+ pool.add_schedule("refresh_cache", "*/5 * * * *", RefreshContext(scope="all"))
361
+ pool.add_schedule("refresh_cache", "0 * * * *", RefreshContext(scope="users"), repeat=3)
362
+ """
363
+ if task_name not in self._context.tasks:
364
+ raise ValueError(
365
+ f"Task '{task_name}' is not registered. "
366
+ f"Use @pool.task() or pool.add_task() first."
367
+ )
368
+
369
+ schedule.register(
370
+ task_name=task_name,
371
+ every=every,
372
+ context=context,
373
+ repeat=repeat,
374
+ metadata=metadata,
375
+ )
376
+
278
377
  def start(self) -> None:
279
378
  """Start worker processes (non-blocking).
280
379
 
@@ -301,6 +400,9 @@ class Pool:
301
400
 
302
401
  Spawns worker processes and runs an async event loop in the main
303
402
  process that collects logs from workers via Redis pubsub.
403
+ The scheduler loop also runs automatically alongside the workers,
404
+ polling for due scheduled tasks and enqueuing them.
405
+
304
406
  Blocks until all workers exit or KeyboardInterrupt, then shuts
305
407
  down gracefully.
306
408
  """
@@ -335,16 +437,17 @@ class Pool:
335
437
  print(f"Started worker {worker_id} (PID: {process.pid})")
336
438
 
337
439
  async def _collect_logs(self) -> None:
338
- """Listen for log messages from workers via state backend pubsub."""
440
+ """Listen for log messages from workers and run scheduler ticks."""
339
441
  assert self._log_handler, "Log handler not initialized"
340
442
 
341
443
  # Create task to subscribe to logs
342
444
  log_task = asyncio.create_task(self._process_log_stream())
343
445
 
344
446
  try:
345
- # Poll worker processes
447
+ # Poll worker processes and run scheduler
346
448
  while any(p.is_alive() for p in self._processes):
347
449
  await asyncio.sleep(0.1)
450
+ await schedule.tick()
348
451
  finally:
349
452
  log_task.cancel()
350
453
  try: