agentexec 0.1.5__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.7/.claude/settings.local.json +7 -0
  2. {agentexec-0.1.5 → agentexec-0.1.7}/CHANGELOG.md +53 -0
  3. {agentexec-0.1.5 → agentexec-0.1.7}/PKG-INFO +82 -2
  4. {agentexec-0.1.5 → agentexec-0.1.7}/README.md +80 -1
  5. agentexec-0.1.7/examples/multi-tenancy/README.md +92 -0
  6. agentexec-0.1.7/examples/multi-tenancy/example.py +188 -0
  7. {agentexec-0.1.5 → agentexec-0.1.7}/pyproject.toml +2 -1
  8. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/activity/models.py +49 -3
  9. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/activity/schemas.py +3 -0
  10. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/activity/tracker.py +30 -4
  11. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/config.py +28 -0
  12. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/core/queue.py +34 -0
  13. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/core/task.py +43 -1
  14. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/pipeline.py +10 -4
  15. agentexec-0.1.7/src/agentexec/schedule.py +144 -0
  16. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/state/__init__.py +50 -0
  17. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/state/backend.py +88 -0
  18. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/state/redis_backend.py +111 -0
  19. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/worker/pool.py +146 -9
  20. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_activity_tracking.py +221 -0
  21. agentexec-0.1.7/tests/test_schedule.py +447 -0
  22. agentexec-0.1.7/tests/test_task_locking.py +260 -0
  23. {agentexec-0.1.5 → agentexec-0.1.7}/ui/package.json +1 -1
  24. {agentexec-0.1.5 → agentexec-0.1.7}/.claude/skills/prepare-release/SKILL.md +0 -0
  25. {agentexec-0.1.5 → agentexec-0.1.7}/.github/workflows/docker-publish.yml +0 -0
  26. {agentexec-0.1.5 → agentexec-0.1.7}/.github/workflows/npm-publish.yml +0 -0
  27. {agentexec-0.1.5 → agentexec-0.1.7}/.github/workflows/publish.yml +0 -0
  28. {agentexec-0.1.5 → agentexec-0.1.7}/.gitignore +0 -0
  29. {agentexec-0.1.5 → agentexec-0.1.7}/docker/Dockerfile +0 -0
  30. {agentexec-0.1.5 → agentexec-0.1.7}/docker/README.md +0 -0
  31. {agentexec-0.1.5 → agentexec-0.1.7}/docker/entrypoint.py +0 -0
  32. {agentexec-0.1.5 → agentexec-0.1.7}/docs/api-reference/activity.md +0 -0
  33. {agentexec-0.1.5 → agentexec-0.1.7}/docs/api-reference/core.md +0 -0
  34. {agentexec-0.1.5 → agentexec-0.1.7}/docs/api-reference/pipeline.md +0 -0
  35. {agentexec-0.1.5 → agentexec-0.1.7}/docs/api-reference/runner.md +0 -0
  36. {agentexec-0.1.5 → agentexec-0.1.7}/docs/concepts/activity-tracking.md +0 -0
  37. {agentexec-0.1.5 → agentexec-0.1.7}/docs/concepts/architecture.md +0 -0
  38. {agentexec-0.1.5 → agentexec-0.1.7}/docs/concepts/task-lifecycle.md +0 -0
  39. {agentexec-0.1.5 → agentexec-0.1.7}/docs/concepts/worker-pool.md +0 -0
  40. {agentexec-0.1.5 → agentexec-0.1.7}/docs/contributing.md +0 -0
  41. {agentexec-0.1.5 → agentexec-0.1.7}/docs/deployment/docker.md +0 -0
  42. {agentexec-0.1.5 → agentexec-0.1.7}/docs/deployment/production.md +0 -0
  43. {agentexec-0.1.5 → agentexec-0.1.7}/docs/getting-started/configuration.md +0 -0
  44. {agentexec-0.1.5 → agentexec-0.1.7}/docs/getting-started/installation.md +0 -0
  45. {agentexec-0.1.5 → agentexec-0.1.7}/docs/getting-started/quickstart.md +0 -0
  46. {agentexec-0.1.5 → agentexec-0.1.7}/docs/guides/basic-usage.md +0 -0
  47. {agentexec-0.1.5 → agentexec-0.1.7}/docs/guides/custom-runners.md +0 -0
  48. {agentexec-0.1.5 → agentexec-0.1.7}/docs/guides/fastapi-integration.md +0 -0
  49. {agentexec-0.1.5 → agentexec-0.1.7}/docs/guides/openai-runner.md +0 -0
  50. {agentexec-0.1.5 → agentexec-0.1.7}/docs/guides/pipelines.md +0 -0
  51. {agentexec-0.1.5 → agentexec-0.1.7}/docs/index.md +0 -0
  52. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/README.md +0 -0
  53. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic/README +0 -0
  54. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic/env.py +0 -0
  55. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic/script.py.mako +0 -0
  56. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/alembic.ini +0 -0
  57. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/compose.yml +0 -0
  58. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/context.py +0 -0
  59. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/db.py +0 -0
  60. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/main.py +0 -0
  61. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/models.py +0 -0
  62. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/pipeline.py +0 -0
  63. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/pyproject.toml +0 -0
  64. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/tools.py +0 -0
  65. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/.gitignore +0 -0
  66. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/bun.lock +0 -0
  67. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/index.html +0 -0
  68. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/package.json +0 -0
  69. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/public/vite.svg +0 -0
  70. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/App.tsx +0 -0
  71. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/api/agents.ts +0 -0
  72. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/api/queries.ts +0 -0
  73. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/components/Layout.tsx +0 -0
  74. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/index.css +0 -0
  75. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/main.tsx +0 -0
  76. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/pages/AgentDetailPage.tsx +0 -0
  77. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/pages/AgentListPage.tsx +0 -0
  78. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/src/styles/github-dark.css +0 -0
  79. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/tsconfig.json +0 -0
  80. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/tsconfig.node.json +0 -0
  81. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/ui/vite.config.ts +0 -0
  82. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/views.py +0 -0
  83. {agentexec-0.1.5 → agentexec-0.1.7}/examples/openai-agents-fastapi/worker.py +0 -0
  84. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/__init__.py +0 -0
  85. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/activity/__init__.py +0 -0
  86. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/core/__init__.py +0 -0
  87. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/core/db.py +0 -0
  88. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/core/logging.py +0 -0
  89. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/core/results.py +0 -0
  90. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/runners/__init__.py +0 -0
  91. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/runners/base.py +0 -0
  92. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/runners/openai.py +0 -0
  93. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/tracker.py +0 -0
  94. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/worker/__init__.py +0 -0
  95. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/worker/event.py +0 -0
  96. {agentexec-0.1.5 → agentexec-0.1.7}/src/agentexec/worker/logging.py +0 -0
  97. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_activity_schemas.py +0 -0
  98. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_activity_tracking.py.bak +0 -0
  99. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_config.py +0 -0
  100. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_db.py +0 -0
  101. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_pipeline.py +0 -0
  102. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_pipeline_flow.py +0 -0
  103. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_public_api.py +0 -0
  104. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_queue.py +0 -0
  105. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_results.py +0 -0
  106. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_runners.py +0 -0
  107. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_self_describing_results.py +0 -0
  108. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_state.py +0 -0
  109. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_state_backend.py +0 -0
  110. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_task.py +0 -0
  111. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_task_types.py +0 -0
  112. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_worker_event.py +0 -0
  113. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_worker_logging.py +0 -0
  114. {agentexec-0.1.5 → agentexec-0.1.7}/tests/test_worker_pool.py +0 -0
  115. {agentexec-0.1.5 → agentexec-0.1.7}/ui/.gitignore +0 -0
  116. {agentexec-0.1.5 → agentexec-0.1.7}/ui/README.md +0 -0
  117. {agentexec-0.1.5 → agentexec-0.1.7}/ui/bun.lock +0 -0
  118. {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/components/ActiveAgentsBadge.tsx +0 -0
  119. {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/components/ProgressBar.tsx +0 -0
  120. {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/components/StatusBadge.tsx +0 -0
  121. {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/components/TaskDetail.tsx +0 -0
  122. {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/components/TaskList.tsx +0 -0
  123. {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/components/index.ts +0 -0
  124. {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/index.ts +0 -0
  125. {agentexec-0.1.5 → agentexec-0.1.7}/ui/src/types.ts +0 -0
  126. {agentexec-0.1.5 → agentexec-0.1.7}/ui/tsconfig.json +0 -0
  127. {agentexec-0.1.5 → agentexec-0.1.7}/ui/vite.config.ts +0 -0
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(uv run:*)"
5
+ ]
6
+ }
7
+ }
@@ -1,5 +1,58 @@
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
+
24
+ ## v0.1.6
25
+
26
+ ### New Features
27
+
28
+ **Task-level distributed locking**
29
+ - New `lock_key` parameter on `@pool.task()` and `pool.add_task()` for sequential execution of tasks sharing state
30
+ - String template evaluated against context fields (e.g., `lock_key="user:{user_id}"`)
31
+ - Workers acquire a Redis lock before execution; tasks requeue automatically on contention
32
+ - Lock released in `finally` block on completion or error
33
+ - Configurable TTL via `AGENTEXEC_LOCK_TTL` (default 1800s) as safety net for worker process death
34
+ - Note: strict FIFO ordering is not guaranteed between tasks sharing the same lock key
35
+
36
+ **Activity metadata for multi-tenancy**
37
+ - Attach arbitrary metadata when creating activities (e.g., `metadata={"organization_id": "org-123"}`)
38
+ - Filter activities by metadata in `activity.list()` and `activity.detail()`
39
+ - Metadata accessible as attribute for programmatic use but excluded from API serialization by default to prevent accidental tenant info leakage
40
+
41
+ ### Improvements
42
+
43
+ **Redis cleanup on shutdown**
44
+ - `state.clear_keys()` removes all agentexec-prefixed keys and the task queue on shutdown
45
+ - Prevents stale tasks from being picked up on restart
46
+
47
+ **State backend lock primitives**
48
+ - Added `acquire_lock()` and `release_lock()` to `StateBackend` protocol
49
+ - Redis implementation uses atomic `SET NX EX` / `DELETE`
50
+
51
+ ### Testing
52
+
53
+ - Added `test_task_locking.py` with 16 tests covering lock acquisition, release, requeue, and template evaluation
54
+ - Fixed `ty` type checker errors in `test_activity_tracking.py` (added narrowing guards for `Activity | None`)
55
+
3
56
  ## v0.1.5
4
57
 
5
58
  ### New Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentexec
3
- Version: 0.1.5
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
@@ -163,6 +164,25 @@ That's it. Tasks are queued to Redis, workers process them in parallel, progress
163
164
 
164
165
  ## Supported Patterns
165
166
 
167
+ ### Activity Metadata (Multi-Tenancy)
168
+
169
+ Attach arbitrary metadata when enqueueing tasks for filtering and tenant isolation:
170
+
171
+ ```python
172
+ task = await ax.enqueue(
173
+ "process_document",
174
+ context,
175
+ metadata={"organization_id": "org-123", "user_id": "user-456"}
176
+ )
177
+
178
+ # Filter activities by metadata
179
+ activities = ax.activity.list(db, metadata_filter={"organization_id": "org-123"})
180
+ detail = ax.activity.detail(db, agent_id, metadata_filter={"organization_id": "org-123"})
181
+
182
+ # Access metadata programmatically (excluded from API serialization by default)
183
+ org_id = detail.metadata["organization_id"]
184
+ ```
185
+
166
186
  ### Automatic Activity Tracking
167
187
 
168
188
  Every task gets full lifecycle tracking without manual updates:
@@ -195,6 +215,50 @@ Update progress explicitly from your task:
195
215
  ax.activity.update(agent_id, "Processing batch 3 of 10", percentage=30)
196
216
  ```
197
217
 
218
+ ### Task Locking
219
+
220
+ When multiple tasks of the same type are queued for the same user, they may need to run sequentially because each task reads and writes shared state. Use `lock_key` to ensure only one task with the same evaluated key runs at a time:
221
+
222
+ ```python
223
+ @pool.task("associate_observation", lock_key="user:{user_id}")
224
+ async def associate(agent_id: UUID, context: ObservationContext):
225
+ ...
226
+
227
+ # Or with add_task()
228
+ pool.add_task("associate_observation", handler, lock_key="user:{user_id}")
229
+ ```
230
+
231
+ The `lock_key` is a string template evaluated against the task context fields. When a worker dequeues a task whose lock is held, it puts the task back at the end of the queue and moves on. The lock is released automatically when the task completes or errors.
232
+
233
+ The lock TTL (`AGENTEXEC_LOCK_TTL`, default 1800s) is a safety net for worker process death — locks are always explicitly released on task completion or error. Set this higher than your longest expected task duration.
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.
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
+
198
262
  ### Priority Queue
199
263
 
200
264
  Control task execution order:
@@ -411,6 +475,7 @@ Activity tracking uses SQLAlchemy with two tables:
411
475
  **`agentexec_activity`** - Main activity records
412
476
  - `agent_id` - Unique identifier (UUID)
413
477
  - `agent_type` - Task name
478
+ - `metadata` - JSON field for custom data (e.g., tenant info)
414
479
  - `created_at`, `updated_at` - Timestamps
415
480
 
416
481
  **`agentexec_activity_log`** - Status and progress
@@ -600,7 +665,15 @@ pool = ax.Pool(database_url="postgresql://...")
600
665
  @pool.task("name")
601
666
  async def handler(agent_id: UUID, context: MyContext) -> None: ...
602
667
 
603
- pool.run() # Blocking - runs workers
668
+ @pool.task("name", lock_key="user:{user_id}") # Sequential per user
669
+ async def locked(agent_id: UUID, context: MyContext) -> None: ...
670
+
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
604
677
  pool.start() # Non-blocking - starts workers in background
605
678
  pool.shutdown() # Graceful shutdown
606
679
  ```
@@ -700,6 +773,12 @@ AGENTEXEC_TABLE_PREFIX=agentexec_
700
773
  # Results
701
774
  AGENTEXEC_RESULT_TTL=3600
702
775
 
776
+ # Task locking
777
+ AGENTEXEC_LOCK_TTL=1800
778
+
779
+ # Scheduling
780
+ AGENTEXEC_SCHEDULER_TIMEZONE=UTC
781
+
703
782
  # State backend
704
783
  AGENTEXEC_STATE_BACKEND=agentexec.state.redis_backend
705
784
  AGENTEXEC_KEY_PREFIX=agentexec
@@ -758,4 +837,5 @@ MIT License - see [LICENSE](LICENSE) for details.
758
837
  - **npm**: [agentexec-ui](https://www.npmjs.com/package/agentexec-ui)
759
838
  - **Documentation**: [docs/](docs/)
760
839
  - **Example App**: [examples/openai-agents-fastapi/](examples/openai-agents-fastapi/)
840
+ - **Multi-Tenancy Example**: [examples/multi-tenancy/](examples/multi-tenancy/)
761
841
  - **Issues**: [GitHub Issues](https://github.com/Agent-CI/agentexec/issues)
@@ -138,6 +138,25 @@ That's it. Tasks are queued to Redis, workers process them in parallel, progress
138
138
 
139
139
  ## Supported Patterns
140
140
 
141
+ ### Activity Metadata (Multi-Tenancy)
142
+
143
+ Attach arbitrary metadata when enqueueing tasks for filtering and tenant isolation:
144
+
145
+ ```python
146
+ task = await ax.enqueue(
147
+ "process_document",
148
+ context,
149
+ metadata={"organization_id": "org-123", "user_id": "user-456"}
150
+ )
151
+
152
+ # Filter activities by metadata
153
+ activities = ax.activity.list(db, metadata_filter={"organization_id": "org-123"})
154
+ detail = ax.activity.detail(db, agent_id, metadata_filter={"organization_id": "org-123"})
155
+
156
+ # Access metadata programmatically (excluded from API serialization by default)
157
+ org_id = detail.metadata["organization_id"]
158
+ ```
159
+
141
160
  ### Automatic Activity Tracking
142
161
 
143
162
  Every task gets full lifecycle tracking without manual updates:
@@ -170,6 +189,50 @@ Update progress explicitly from your task:
170
189
  ax.activity.update(agent_id, "Processing batch 3 of 10", percentage=30)
171
190
  ```
172
191
 
192
+ ### Task Locking
193
+
194
+ When multiple tasks of the same type are queued for the same user, they may need to run sequentially because each task reads and writes shared state. Use `lock_key` to ensure only one task with the same evaluated key runs at a time:
195
+
196
+ ```python
197
+ @pool.task("associate_observation", lock_key="user:{user_id}")
198
+ async def associate(agent_id: UUID, context: ObservationContext):
199
+ ...
200
+
201
+ # Or with add_task()
202
+ pool.add_task("associate_observation", handler, lock_key="user:{user_id}")
203
+ ```
204
+
205
+ The `lock_key` is a string template evaluated against the task context fields. When a worker dequeues a task whose lock is held, it puts the task back at the end of the queue and moves on. The lock is released automatically when the task completes or errors.
206
+
207
+ The lock TTL (`AGENTEXEC_LOCK_TTL`, default 1800s) is a safety net for worker process death — locks are always explicitly released on task completion or error. Set this higher than your longest expected task duration.
208
+
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
+
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
+
173
236
  ### Priority Queue
174
237
 
175
238
  Control task execution order:
@@ -386,6 +449,7 @@ Activity tracking uses SQLAlchemy with two tables:
386
449
  **`agentexec_activity`** - Main activity records
387
450
  - `agent_id` - Unique identifier (UUID)
388
451
  - `agent_type` - Task name
452
+ - `metadata` - JSON field for custom data (e.g., tenant info)
389
453
  - `created_at`, `updated_at` - Timestamps
390
454
 
391
455
  **`agentexec_activity_log`** - Status and progress
@@ -575,7 +639,15 @@ pool = ax.Pool(database_url="postgresql://...")
575
639
  @pool.task("name")
576
640
  async def handler(agent_id: UUID, context: MyContext) -> None: ...
577
641
 
578
- pool.run() # Blocking - runs workers
642
+ @pool.task("name", lock_key="user:{user_id}") # Sequential per user
643
+ async def locked(agent_id: UUID, context: MyContext) -> None: ...
644
+
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
579
651
  pool.start() # Non-blocking - starts workers in background
580
652
  pool.shutdown() # Graceful shutdown
581
653
  ```
@@ -675,6 +747,12 @@ AGENTEXEC_TABLE_PREFIX=agentexec_
675
747
  # Results
676
748
  AGENTEXEC_RESULT_TTL=3600
677
749
 
750
+ # Task locking
751
+ AGENTEXEC_LOCK_TTL=1800
752
+
753
+ # Scheduling
754
+ AGENTEXEC_SCHEDULER_TIMEZONE=UTC
755
+
678
756
  # State backend
679
757
  AGENTEXEC_STATE_BACKEND=agentexec.state.redis_backend
680
758
  AGENTEXEC_KEY_PREFIX=agentexec
@@ -733,4 +811,5 @@ MIT License - see [LICENSE](LICENSE) for details.
733
811
  - **npm**: [agentexec-ui](https://www.npmjs.com/package/agentexec-ui)
734
812
  - **Documentation**: [docs/](docs/)
735
813
  - **Example App**: [examples/openai-agents-fastapi/](examples/openai-agents-fastapi/)
814
+ - **Multi-Tenancy Example**: [examples/multi-tenancy/](examples/multi-tenancy/)
736
815
  - **Issues**: [GitHub Issues](https://github.com/Agent-CI/agentexec/issues)
@@ -0,0 +1,92 @@
1
+ # Multi-Tenancy with Activity Metadata
2
+
3
+ This example demonstrates how to use activity metadata for multi-tenant applications.
4
+
5
+ ## Overview
6
+
7
+ When building multi-tenant applications, you often need to:
8
+ 1. Associate background tasks with specific organizations/tenants
9
+ 2. Filter activity views to only show tasks belonging to the current tenant
10
+ 3. Ensure proper data isolation between tenants
11
+
12
+ The `metadata` parameter on `ax.enqueue()` and `pipeline.enqueue()` enables this by attaching arbitrary key-value pairs to activity records.
13
+
14
+ ## Usage
15
+
16
+ ### Enqueueing with Metadata
17
+
18
+ ```python
19
+ import agentexec as ax
20
+
21
+ # Enqueue a task with organization context
22
+ task = await ax.enqueue(
23
+ "process_document",
24
+ DocumentContext(file_id="doc-123"),
25
+ metadata={"organization_id": "org-456", "user_id": "user-789"}
26
+ )
27
+ ```
28
+
29
+ ### Filtering Activities by Metadata
30
+
31
+ ```python
32
+ from agentexec import activity
33
+
34
+ # List only activities for a specific organization
35
+ activities = activity.list(
36
+ session,
37
+ metadata_filter={"organization_id": "org-456"}
38
+ )
39
+
40
+ # Get activity detail with tenant validation
41
+ # Returns None if the activity doesn't belong to this organization
42
+ detail = activity.detail(
43
+ session,
44
+ agent_id="...",
45
+ metadata_filter={"organization_id": "org-456"}
46
+ )
47
+ ```
48
+
49
+ ### Pipeline Example
50
+
51
+ ```python
52
+ pipeline = ax.Pipeline(pool)
53
+
54
+ class DocumentPipeline(pipeline.Base):
55
+ @pipeline.step(0)
56
+ async def extract(self, ctx: DocumentContext) -> ExtractedData:
57
+ ...
58
+
59
+ # Enqueue pipeline with metadata
60
+ task = await pipeline.enqueue(
61
+ context=DocumentContext(file_id="doc-123"),
62
+ metadata={"organization_id": "org-456"}
63
+ )
64
+ ```
65
+
66
+ ## Running the Example
67
+
68
+ ```bash
69
+ # Install dependencies
70
+ pip install agentexec
71
+
72
+ # Run the example
73
+ python example.py
74
+ ```
75
+
76
+ ## Database Schema
77
+
78
+ The metadata is stored as a JSON column on the `agentexec_activity` table:
79
+
80
+ ```sql
81
+ ALTER TABLE agentexec_activity ADD COLUMN metadata JSON;
82
+ ```
83
+
84
+ For PostgreSQL, this maps to JSONB which supports efficient filtering.
85
+
86
+ ## Notes
87
+
88
+ - Metadata is immutable once set at enqueue time
89
+ - Filtering uses exact string matching on metadata values
90
+ - **Metadata is excluded from API serialization by default** to prevent accidental leakage of tenant info
91
+ - Access metadata programmatically via the `.metadata` attribute (e.g., `activity.metadata`)
92
+ - To include metadata in API responses, explicitly add it to your response model
@@ -0,0 +1,188 @@
1
+ """Multi-tenancy example demonstrating activity metadata filtering.
2
+
3
+ This example shows how to:
4
+ 1. Attach metadata (like organization_id) when enqueueing tasks
5
+ 2. Filter activities by metadata for tenant isolation
6
+ 3. Use metadata in both list and detail views
7
+ """
8
+
9
+ import asyncio
10
+ from uuid import UUID
11
+
12
+ from pydantic import BaseModel
13
+ from sqlalchemy import create_engine
14
+ from sqlalchemy.orm import sessionmaker
15
+
16
+ import agentexec as ax
17
+
18
+
19
+ # --- Models ---
20
+
21
+
22
+ class DocumentContext(BaseModel):
23
+ """Input context for document processing."""
24
+
25
+ file_id: str
26
+ filename: str
27
+
28
+
29
+ class ProcessedDocument(BaseModel):
30
+ """Result of document processing."""
31
+
32
+ file_id: str
33
+ word_count: int
34
+ summary: str
35
+
36
+
37
+ # --- Setup ---
38
+
39
+ # Create in-memory SQLite database for demo
40
+ engine = create_engine("sqlite:///multi_tenant_demo.db", echo=False)
41
+ SessionLocal = sessionmaker(bind=engine)
42
+
43
+ # Create tables
44
+ ax.Base.metadata.create_all(bind=engine)
45
+
46
+ # Create worker pool
47
+ pool = ax.Pool(engine=engine)
48
+
49
+
50
+ # --- Task Definition ---
51
+
52
+
53
+ @pool.task("process_document")
54
+ async def process_document(
55
+ *,
56
+ agent_id: UUID,
57
+ context: DocumentContext,
58
+ ) -> ProcessedDocument:
59
+ """Simulate document processing."""
60
+ # Simulate some work
61
+ ax.activity.update(agent_id, "Extracting text...", percentage=25)
62
+ await asyncio.sleep(0.1)
63
+
64
+ ax.activity.update(agent_id, "Analyzing content...", percentage=50)
65
+ await asyncio.sleep(0.1)
66
+
67
+ ax.activity.update(agent_id, "Generating summary...", percentage=75)
68
+ await asyncio.sleep(0.1)
69
+
70
+ return ProcessedDocument(
71
+ file_id=context.file_id,
72
+ word_count=1234,
73
+ summary=f"Summary of {context.filename}",
74
+ )
75
+
76
+
77
+ # --- Demo Functions ---
78
+
79
+
80
+ async def enqueue_tasks_for_tenants():
81
+ """Enqueue tasks for different organizations."""
82
+ print("\n=== Enqueueing Tasks ===\n")
83
+
84
+ # Organization A: Enqueue 2 tasks
85
+ task1 = await ax.enqueue(
86
+ "process_document",
87
+ DocumentContext(file_id="doc-001", filename="report.pdf"),
88
+ metadata={"organization_id": "org-A", "user_id": "user-1"},
89
+ )
90
+ print(f"Org A - Task 1: {task1.agent_id}")
91
+
92
+ task2 = await ax.enqueue(
93
+ "process_document",
94
+ DocumentContext(file_id="doc-002", filename="invoice.pdf"),
95
+ metadata={"organization_id": "org-A", "user_id": "user-2"},
96
+ )
97
+ print(f"Org A - Task 2: {task2.agent_id}")
98
+
99
+ # Organization B: Enqueue 1 task
100
+ task3 = await ax.enqueue(
101
+ "process_document",
102
+ DocumentContext(file_id="doc-003", filename="contract.pdf"),
103
+ metadata={"organization_id": "org-B", "user_id": "user-3"},
104
+ )
105
+ print(f"Org B - Task 1: {task3.agent_id}")
106
+
107
+ return task1, task2, task3
108
+
109
+
110
+ def list_activities_for_tenant(org_id: str):
111
+ """List activities filtered by organization."""
112
+ print(f"\n=== Activities for {org_id} ===\n")
113
+
114
+ with SessionLocal() as session:
115
+ result = ax.activity.list(
116
+ session,
117
+ metadata_filter={"organization_id": org_id},
118
+ )
119
+
120
+ print(f"Total activities: {result.total}")
121
+ for item in result.items:
122
+ print(f" - {item.agent_id}: {item.agent_type} ({item.status})")
123
+ print(f" Metadata: {item.metadata}")
124
+
125
+
126
+ def get_activity_detail_with_tenant_check(agent_id: UUID, org_id: str):
127
+ """Get activity detail with tenant validation."""
128
+ print(f"\n=== Detail for {agent_id} (checking {org_id}) ===\n")
129
+
130
+ with SessionLocal() as session:
131
+ # This returns None if the activity doesn't belong to the org
132
+ detail = ax.activity.detail(
133
+ session,
134
+ agent_id,
135
+ metadata_filter={"organization_id": org_id},
136
+ )
137
+
138
+ if detail:
139
+ print(f"Found: {detail.agent_type}")
140
+ print(f"Metadata: {detail.metadata}")
141
+ print(f"Logs: {len(detail.logs)} entries")
142
+ else:
143
+ print(f"Not found (or doesn't belong to {org_id})")
144
+
145
+
146
+ async def main():
147
+ """Run the multi-tenancy demo."""
148
+ print("Multi-Tenancy Demo with Activity Metadata")
149
+ print("=" * 50)
150
+
151
+ # 1. Enqueue tasks for different organizations
152
+ task1, task2, task3 = await enqueue_tasks_for_tenants()
153
+
154
+ # 2. List all activities (no filter)
155
+ print("\n=== All Activities (no filter) ===\n")
156
+ with SessionLocal() as session:
157
+ all_activities = ax.activity.list(session)
158
+ print(f"Total: {all_activities.total}")
159
+ for item in all_activities.items:
160
+ print(f" - {item.agent_type}: {item.metadata}")
161
+
162
+ # 3. List activities filtered by organization
163
+ list_activities_for_tenant("org-A") # Should show 2 tasks
164
+ list_activities_for_tenant("org-B") # Should show 1 task
165
+ list_activities_for_tenant("org-C") # Should show 0 tasks
166
+
167
+ # 4. Detail view with tenant validation
168
+ # Try to access Org A's task as Org A (should work)
169
+ get_activity_detail_with_tenant_check(task1.agent_id, "org-A")
170
+
171
+ # Try to access Org A's task as Org B (should return None)
172
+ get_activity_detail_with_tenant_check(task1.agent_id, "org-B")
173
+
174
+ # 5. Filter by multiple metadata fields
175
+ print("\n=== Filter by org AND user ===\n")
176
+ with SessionLocal() as session:
177
+ result = ax.activity.list(
178
+ session,
179
+ metadata_filter={"organization_id": "org-A", "user_id": "user-1"},
180
+ )
181
+ print(f"Found {result.total} activities for org-A + user-1")
182
+
183
+ print("\n" + "=" * 50)
184
+ print("Demo complete!")
185
+
186
+
187
+ if __name__ == "__main__":
188
+ asyncio.run(main())
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentexec"
3
- version = "0.1.5"
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