Trigger Strategies¶
Trigger strategies define when a workflow runs and what tasks it produces. They decouple the scheduling mechanism from the dispatch logic, making it straightforward to add new trigger types without touching the runner loop.
Architecture¶
graph LR
Runner -->|calls next_tasks()| Strategy
Strategy -->|returns Vec<Task>| Runner
Runner -->|renders prompt + dispatches| Agent
subgraph "Trigger Layer"
Strategy["TriggerStrategy"]
Polling["PollingStrategy"]
CronS["CronStrategy"]
DelayS["DelayStrategy"]
EventS["EventStrategy"]
TaskSrc["TaskSource (e.g. GithubIssueSource)"]
EventBus["EventBus"]
Polling -->|delegates to| TaskSrc
EventS -->|subscribes to| EventBus
Strategy -.->|implemented by| Polling
Strategy -.->|implemented by| CronS
Strategy -.->|implemented by| DelayS
Strategy -.->|implemented by| EventS
end
The trigger layer answers: "Is there work to do right now?" The dispatch layer answers: "How do I send that work to an agent?"
Source code lives in crates/orchestrator/src/scheduler/.
TriggerStrategy Trait¶
File: crates/orchestrator/src/scheduler/strategy.rs
#[async_trait]
pub trait TriggerStrategy: Send + Sync {
/// Wait for the next trigger event and return tasks to dispatch.
///
/// Implementations should respect the `shutdown` receiver and return
/// promptly (with an empty vec or an error) when the signal fires.
///
/// Returning an empty `Vec<Task>` is valid and indicates that no work
/// is available at this time - the runner may call `next_tasks` again.
async fn next_tasks(&mut self, shutdown: &watch::Receiver<bool>) -> anyhow::Result<Vec<Task>>;
}
Contract¶
| Aspect | Behaviour |
|---|---|
| Return: tasks | A non-empty Vec<Task> - the runner dispatches each task to the agent in sequence |
| Return: empty vec | No work available; the runner calls next_tasks() again on the next iteration |
Return: Err |
Transient failure; the runner logs the error, applies backoff, and retries |
| Shutdown | When *shutdown.borrow() == true the implementation must return promptly - Ok(vec![]) is the correct response |
| Thread safety | Implementors must be Send + Sync so they can be boxed and moved across task boundaries |
Runner loop integration¶
The runner holds a Box<dyn TriggerStrategy> and drives it in a loop:
loop {
let tasks = strategy.next_tasks(&shutdown_rx).await?;
for task in tasks {
dispatch(task, &config, ®istry).await?;
}
}
The loop exits when the shutdown channel fires or next_tasks returns an unrecoverable error.
PollingStrategy - Reference Implementation¶
PollingStrategy is the built-in implementation used by all poll-based workflows (currently GitHub Issues and GitHub Pull Requests).
File: crates/orchestrator/src/scheduler/strategy.rs
How it works¶
- Sleep for
poll_interval_secs(interruptible by shutdown signal). - Call
TaskSource::fetch_tasks()on the underlying source. - Return the tasks, or apply backoff and propagate the error on failure.
Exponential backoff¶
When fetch_tasks() fails, PollingStrategy adds a linear backoff on top of the regular interval:
consecutive_errors resets to 0 on the first successful fetch. The maximum additional delay is 30 seconds.
Shutdown handling¶
PollingStrategy uses tokio::select! to make the sleep interruptible:
tokio::select! {
_ = tokio::time::sleep(sleep_dur) => {}
_ = shutdown.changed() => {
if *shutdown.borrow() { return Ok(vec![]); }
}
}
A workflow that is disabled or deleted responds to shutdown within milliseconds regardless of how long the configured poll interval is.
TriggerConfig Enum¶
TriggerConfig is the serialisable description of which strategy to use for a workflow. It is stored as JSON in the trigger_config column of the workflows table.
File: crates/orchestrator/src/scheduler/types.rs
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TriggerConfig {
GithubIssues {
owner: String,
repo: String,
labels: Vec<String>, // default: []
state: String, // default: "open"
},
GithubPullRequests {
owner: String,
repo: String,
labels: Vec<String>, // default: []
state: String, // default: "open"
},
Cron { expression: String }, // Phase 2
Delay { run_at: String }, // Phase 2 - ISO 8601
AgentLifecycle { event: String }, // Phase 3
DispatchResult { // Phase 3
source_workflow_id: Option<Uuid>,
status: Option<DispatchStatus>,
},
Webhook { secret: Option<String> }, // Phase 4
Manual {},
}
Implementation status
All trigger types are fully implemented.
- See Schedule Triggers for
cronanddelaydocumentation. - See Event-Driven Triggers for
agent_lifecycleanddispatch_resultdocumentation. - See Webhook Triggers for
webhookdocumentation. - See Manual Triggers for
manualdocumentation.
JSON tagged-union format¶
TriggerConfig uses #[serde(tag = "type")] - the discriminant is the type key:
{ "type": "github_issues", "owner": "myorg", "repo": "myrepo", "labels": ["agent"] }
{ "type": "github_pull_requests", "owner": "myorg", "repo": "myrepo", "state": "open" }
{ "type": "cron", "expression": "0 9 * * MON-FRI" }
{ "type": "delay", "run_at": "2026-04-01T09:00:00Z" }
{ "type": "agent_lifecycle", "event": "session_start" }
{ "type": "dispatch_result", "source_workflow_id": "<UUID>", "status": "completed" }
{ "type": "webhook", "secret": "my-hmac-secret" }
{ "type": "manual" }
TaskSource Trait¶
TriggerStrategy and TaskSource are two distinct abstractions:
| Trait | Responsibility |
|---|---|
TriggerStrategy |
When to run - owns timing, backoff, shutdown |
TaskSource |
What to fetch - owns the external API call |
#[async_trait]
pub trait TaskSource: Send + Sync {
async fn fetch_tasks(&self) -> anyhow::Result<Vec<Task>>;
fn source_type(&self) -> &'static str;
}
Implemented by GithubIssueSource and GithubPullRequestSource in crates/orchestrator/src/scheduler/github.rs. Both call the gh CLI to list issues/PRs and map them to Task structs.
Adding a New Trigger Type¶
To add a new trigger type (e.g. Cron):
- Add a variant to
TriggerConfiginscheduler/types.rs. - Implement
TriggerStrategy(or a newTaskSourceif it's poll-based) inscheduler/strategy.rsor a new file. - Wire it in
create_strategy()inscheduler/runner.rs. - Add CLI support in the
TriggerTypeenum incrates/cli/src/commands/orchestrator.rs. - Remove the "not yet implemented" guard in
scheduler/api.rsfor the new variant.
See Trigger Migration Guide for the history of the source_* → trigger_* rename.