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.
- {agentexec-0.1.6 → agentexec-0.1.7}/CHANGELOG.md +21 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/PKG-INFO +36 -2
- {agentexec-0.1.6 → agentexec-0.1.7}/README.md +34 -1
- {agentexec-0.1.6 → agentexec-0.1.7}/pyproject.toml +2 -1
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/config.py +16 -0
- agentexec-0.1.7/src/agentexec/schedule.py +144 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/state/__init__.py +2 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/state/backend.py +43 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/state/redis_backend.py +48 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/worker/pool.py +106 -3
- agentexec-0.1.7/tests/test_schedule.py +447 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/ui/package.json +1 -1
- {agentexec-0.1.6 → agentexec-0.1.7}/.claude/settings.local.json +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/.claude/skills/prepare-release/SKILL.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/.github/workflows/docker-publish.yml +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/.github/workflows/npm-publish.yml +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/.github/workflows/publish.yml +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/.gitignore +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docker/Dockerfile +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docker/README.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docker/entrypoint.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/api-reference/activity.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/api-reference/core.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/api-reference/pipeline.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/api-reference/runner.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/concepts/activity-tracking.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/concepts/architecture.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/concepts/task-lifecycle.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/concepts/worker-pool.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/contributing.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/deployment/docker.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/deployment/production.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/getting-started/configuration.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/getting-started/installation.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/getting-started/quickstart.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/guides/basic-usage.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/guides/custom-runners.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/guides/fastapi-integration.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/guides/openai-runner.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/guides/pipelines.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/docs/index.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/multi-tenancy/README.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/multi-tenancy/example.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/README.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic/README +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic/env.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic/script.py.mako +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic.ini +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/compose.yml +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/context.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/db.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/main.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/models.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/pipeline.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/pyproject.toml +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/tools.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/.gitignore +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/bun.lock +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/index.html +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/package.json +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/public/vite.svg +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/App.tsx +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/api/agents.ts +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/api/queries.ts +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/components/Layout.tsx +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/index.css +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/main.tsx +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/pages/AgentDetailPage.tsx +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/pages/AgentListPage.tsx +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/styles/github-dark.css +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/tsconfig.json +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/tsconfig.node.json +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/vite.config.ts +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/views.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/examples/openai-agents-fastapi/worker.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/__init__.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/activity/__init__.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/activity/models.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/activity/schemas.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/activity/tracker.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/core/__init__.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/core/db.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/core/logging.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/core/queue.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/core/results.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/core/task.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/pipeline.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/runners/__init__.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/runners/base.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/runners/openai.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/tracker.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/worker/__init__.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/worker/event.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/src/agentexec/worker/logging.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_activity_schemas.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_activity_tracking.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_activity_tracking.py.bak +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_config.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_db.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_pipeline.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_pipeline_flow.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_public_api.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_queue.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_results.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_runners.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_self_describing_results.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_state.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_state_backend.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_task.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_task_locking.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_task_types.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_worker_event.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_worker_logging.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/tests/test_worker_pool.py +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/ui/.gitignore +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/ui/README.md +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/ui/bun.lock +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/components/ActiveAgentsBadge.tsx +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/components/ProgressBar.tsx +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/components/StatusBadge.tsx +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/components/TaskDetail.tsx +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/components/TaskList.tsx +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/components/index.ts +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/index.ts +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/ui/src/types.ts +0 -0
- {agentexec-0.1.6 → agentexec-0.1.7}/ui/tsconfig.json +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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:
|