Commit 6a66ad3
feat(schedules): agent run schedules (v1) (#335)
## Summary
Adds per-agent **run schedules**: recurring schedules that fire a task
and deliver a configured initial input on a cron/interval cadence.
**Replaces the prior `schedules` implementation** (a bare-workflow
scheduler) on the same API path.
Each schedule is a Postgres row (the source of truth) plus a Temporal
Schedule that acts purely as the recurring clock (it carries only the
row id). On each fire, a thin, deterministic workflow runs a single
activity that creates a task and delivers the initial input via the same
path as a manual run — `message/send` for sync agents, `event/send` for
agentic agents — attributed to the schedule's stored creator principal.
## Feature flag
The API is gated behind `ENABLE_AGENT_RUN_SCHEDULES` (matches the
existing `ENABLE_HEALTH_CHECK_WORKFLOW` pattern), **disabled by default
in every environment** — when off, the routes are not registered at all.
Enable per-environment when ready to test (e.g. locally
`ENABLE_AGENT_RUN_SCHEDULES=true ./dev.sh`). The OpenAPI spec/SDK
document the endpoints regardless of the runtime default.
## Removed / breaking changes
This PR **deletes the previous `schedules` feature** (routes, schemas,
service, use case, and its tests). The old endpoint scheduled a raw
Temporal workflow and stored nothing in Postgres; the new one schedules
an agent *run* and is Postgres-backed. Because the API path
`/agents/{agent_id}/schedules` is reused with new semantics, this is
**breaking for existing consumers of the old endpoint**:
- `POST /agents/{agent_id}/schedules` — request/response schema changed
(schedules an agent run, not a bare workflow)
- `POST …/{name}/unpause` → **renamed** to `…/{name}/resume`
- Path param `{schedule_name}` → `{name}` (cosmetic)
- Adds the new `agent_run_schedules` table (the old scheduler was
Temporal-only)
(`…/{name}/trigger` is preserved — see below.)
## Endpoints
`/agents/{agent_id}/schedules`:
- `POST` — create
- `GET` — list (served from Postgres; no per-row Temporal call)
- `GET /{name}` — get (includes live Temporal state: next/last fire,
action count)
- `PATCH /{name}` — partial update (cadence, window, input, params,
paused; cron/interval stay mutually exclusive)
- `POST /{name}/pause` · `POST /{name}/resume`
- `POST /{name}/trigger` — immediate out-of-band run
- `DELETE /{name}`
## Implementation notes
- `ScheduledAgentRunWorkflow` (thin/deterministic) +
`launch_scheduled_agent_run` activity (all side effects live in the
activity).
- Deterministic per-fire task name makes `task/create` idempotent on
activity retry; a delivered marker guards against duplicate input
delivery.
- Fire-time authorization re-check under the stored creator principal —
a revoked creator stops firing cleanly.
- Scheduled tasks get a `task_metadata.display_name` (`Scheduled
Message: <name> · <fire time>`), stamped with the nominal fire time
(stable across retries) so they render with a label instead of "Unnamed
task".
- `delete`/`pause`/`resume`/`update` tolerate a missing Temporal
schedule so a partial failure can't strand an un-cleanable row.
- New `agent_run_schedules` table migration (new-table create;
schema-only, non-blocking).
## Testing
- 30 unit tests (service, activity, use case, env flag) pass, covering
create/list/get/update/pause/resume/trigger/delete, idempotency,
validation, and flag parsing.
- Verified end-to-end locally (flag on): both delivery paths (sync
`message/send` and agentic `event/send`), plus
pause/resume/update/trigger/delete reflected consistently in Postgres
and Temporal.
- Verified on a dev cluster (branch image, flag on): create → Temporal
schedule → worker fires on schedule → `message/send` delivered, with the
row persisted and the creator principal captured from real auth.
## Deployment dependency (authz provider)
Dev verification surfaced this: on a cluster using the SGP authz
provider (`AUTH_PROVIDER=sgp`), **the provider must learn the new
`schedule` resource type before this is usable there.** Today its
`/v1/authz/check` returns **422** for a `schedule` resource, so:
- **Create works** (it gates on `agent.update`, and `register` of the
`schedule` resource is tolerated).
- **Every op gated on the schedule resource — `GET /{name}`, `pause`,
`resume`, `trigger`, `PATCH`, `DELETE` — returns 422** until the
provider handles
`check`/`grant`/`revoke`/`register`/`deregister`/`search` for `schedule`
(mirroring `agent`/`task`/`api_key`).
This is provider-side work (the `schedule` type is already part of the
documented auth-provider contract); it should land alongside this
feature's rollout. Environments with authz disabled or a permissive
provider are unaffected.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
This PR replaces the previous bare-workflow scheduler with a fully
Postgres-backed agent run scheduling system. Each schedule is a Postgres
row (source of truth) paired with a Temporal Schedule acting as the
recurring clock; on each fire, a thin deterministic workflow runs a
single activity that creates a fresh task and delivers the configured
initial input under the stored creator principal.
- Adds `agent_run_schedules` table, ORM, repository, service, use case,
and full CRUD API
(`POST`/`GET`/`PATCH`/`DELETE`/`pause`/`resume`/`trigger`) gated behind
`ENABLE_AGENT_RUN_SCHEDULES`.
- Implements idempotent fire-and-deliver via deterministic task names
and a `scheduled_input_delivered` marker; all previously flagged issues
(partial-delete strand, list-path sequential Temporal RPCs, fire-time
stamp drift, manual-trigger paused bypass, auth-filter/limit ordering,
mutually-exclusive cadence validation) have been addressed in the
current code.
- The Temporal worker unconditionally registers the new workflow and
activities so that in-flight scheduled executions continue to be handled
even when the API flag is disabled.
<details><summary><h3>Confidence Score: 5/5</h3></summary>
Safe to merge; the core fire-and-deliver path is well-guarded, all
previously flagged issues are addressed, and the feature is off by
default.
All six issues called out in prior review threads have been resolved in
the current code. The new activity is idempotent (deterministic task
name + delivered marker), the rollback logic on failed creates is
correct, and the migration is non-blocking. Remaining observations are
non-blocking design trade-offs with documented intent.
agentex/src/temporal/scheduled_agent_run_factory.py — per-fire engine
allocation in `build_acp_use_case_for_principal` is worth revisiting if
fire rates grow. agentex/src/adapters/temporal/adapter_temporal.py —
string-based not-found detection is fragile to upstream message changes.
</details>
<details><summary><h3>Important Files Changed</h3></summary>
| Filename | Overview |
|----------|----------|
| agentex/src/domain/services/agent_run_schedule_service.py | Core
service managing the dual Postgres+Temporal state. All previously
flagged issues addressed. `trigger_schedule` uses `start_workflow` (not
`trigger_now`), so manual fires won't update Temporal Schedule live
stats. |
| agentex/src/temporal/activities/scheduled_agent_run_activities.py |
Well-structured with idempotent task creation, delivered marker guard,
fire-time authz re-check, and correct manual-trigger paused bypass. |
| agentex/src/adapters/temporal/adapter_temporal.py | Adds
`update_schedule`; fixes `start_workflow` to use `args=` keyword.
Not-found detection via string matching is fragile. |
| agentex/src/api/routes/agent_run_schedules.py | Clean route layer;
`_check_schedule_or_collapse_to_404` imported with private prefix is a
minor style concern. |
| agentex/src/temporal/scheduled_agent_run_factory.py | Per-fire factory
creates a new DB engine on each activity invocation; worth revisiting
under high fire rates. |
| agentex/src/api/schemas/agent_run_schedules.py | Schema validators
correctly enforce single cadence at create and prevent both-non-null
cadences at update. |
|
agentex/database/migrations/alembic/versions/2026_06_22_1200_add_agent_run_schedules_3b1c9d2e4f6a.py
| Non-blocking schema-only migration; unique index enforces permanently
reserved names consistently with application logic. |
| agentex/src/utils/schedule_metrics.py | Bounded categorical tags only
— no high-cardinality entity IDs. |
| agentex/src/temporal/workflows/scheduled_agent_run_workflow.py | Thin
deterministic wrapper; correctly passes trigger_type to the activity. |
</details>
<details><summary><h3>Sequence Diagram</h3></summary>
<a href="#gh-light-mode-only">
```mermaid
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant API as API Route
participant SVC as AgentRunScheduleService
participant PG as Postgres
participant TP as Temporal Schedule
participant WF as ScheduledAgentRunWorkflow
participant ACT as launch_scheduled_agent_run
participant ACP as AgentsACPUseCase
Note over API,TP: Create schedule
API->>SVC: create_schedule(agent, request, creator_principal)
SVC->>PG: create(AgentRunScheduleEntity)
SVC->>TP: "create_schedule(temporal_id, args=[row.id])"
TP-->>SVC: ScheduleHandle
Note over TP,ACP: Each cron/interval fire
TP->>WF: start ScheduledAgentRunWorkflow(schedule_id)
WF->>ACT: launch_scheduled_agent_run(schedule_id, fire_id, trigger_type)
ACT->>PG: get schedule row
ACT->>ACT: fire-time authz re-check (creator_principal)
ACT->>ACP: task/create (deterministic name)
ACT->>ACP: message/send or event/send
ACT->>PG: mark scheduled_input_delivered
Note over API,TP: Manual trigger
API->>SVC: trigger_schedule(agent_id, name)
SVC->>TP: "start_workflow(ScheduledAgentRunWorkflow, args=[row.id, 'manual'])"
TP-->>API: schedule response (async, no task info)
```
</a>
<a href="#gh-dark-mode-only">
```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant API as API Route
participant SVC as AgentRunScheduleService
participant PG as Postgres
participant TP as Temporal Schedule
participant WF as ScheduledAgentRunWorkflow
participant ACT as launch_scheduled_agent_run
participant ACP as AgentsACPUseCase
Note over API,TP: Create schedule
API->>SVC: create_schedule(agent, request, creator_principal)
SVC->>PG: create(AgentRunScheduleEntity)
SVC->>TP: "create_schedule(temporal_id, args=[row.id])"
TP-->>SVC: ScheduleHandle
Note over TP,ACP: Each cron/interval fire
TP->>WF: start ScheduledAgentRunWorkflow(schedule_id)
WF->>ACT: launch_scheduled_agent_run(schedule_id, fire_id, trigger_type)
ACT->>PG: get schedule row
ACT->>ACT: fire-time authz re-check (creator_principal)
ACT->>ACP: task/create (deterministic name)
ACT->>ACP: message/send or event/send
ACT->>PG: mark scheduled_input_delivered
Note over API,TP: Manual trigger
API->>SVC: trigger_schedule(agent_id, name)
SVC->>TP: "start_workflow(ScheduledAgentRunWorkflow, args=[row.id, 'manual'])"
TP-->>API: schedule response (async, no task info)
```
</a>
</details>
<sub>Reviews (10): Last reviewed commit: ["fix(temporal): pass workflow
args via
ar..."](e6ea64b)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=39353769)</sub>
<!-- /greptile_comment -->
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>1 parent 352eaaa commit 6a66ad3
32 files changed
Lines changed: 3769 additions & 3448 deletions
File tree
- agentex
- database/migrations/alembic/versions
- scripts
- src
- adapters
- temporal
- api
- routes
- schemas
- config
- domain
- entities
- repositories
- services
- use_cases
- temporal
- activities
- workflows
- utils
- tests
- integration/services
- unit
- api
- config
- services
- temporal
- use_cases
Lines changed: 83 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
166 | 166 | | |
167 | 167 | | |
168 | 168 | | |
| 169 | + | |
| 170 | + | |
169 | 171 | | |
170 | 172 | | |
171 | 173 | | |
| |||
0 commit comments