Skip to content

Commit f8d3d16

Browse files
committed
feat(cron): auto-disable usercron jobs on success
1 parent bb5b328 commit f8d3d16

6 files changed

Lines changed: 511 additions & 19 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ tokio = { version = "1", features = ["full"] }
99
serde = { version = "1", features = ["derive"] }
1010
serde_json = "1"
1111
toml = "0.8"
12+
toml_edit = "0.22"
1213
tracing = "0.1"
1314
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
1415
serenity = { version = "0.12", default-features = false, features = ["client", "gateway", "model", "rustls_backend", "cache"] }

docs/config-reference.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,33 @@ timezone = "UTC"
257257

258258
The external `cronjob.toml` uses `[[jobs]]` (same fields). See [Usercron docs](cronjob.md#usercron--hot-reload-with-cronjobtoml) for details.
259259

260+
### Usercron-only `[[jobs]]` fields
261+
262+
These fields are valid only in the external usercron file, for example `$HOME/.openab/cronjob.toml`. They are rejected in baseline `[[cron.jobs]]` because OpenAB only writes state back to the user-managed cron file.
263+
264+
| Key | Type | Default | Description |
265+
|-----|------|---------|-------------|
266+
| `id` | string | *required with `disable_on_success`* | Stable job ID used when the scheduler writes `enabled = false` or `thread_id` back to `cronjob.toml`. |
267+
| `disable_on_success` | string || Command to run before sending the scheduled prompt. |
268+
| `disable_on_success_match` | string | *required with `disable_on_success`* | Marker that must appear in stdout or stderr, in addition to exit code `0`, before the job is considered complete. |
269+
| `disable_on_success_timeout_secs` | integer | `60` | Timeout for the completion check command. |
270+
| `disable_on_success_working_dir` | string || Working directory for the completion check command. |
271+
272+
Example:
273+
274+
```toml
275+
[[jobs]]
276+
id = "fix-unit-tests"
277+
enabled = true
278+
schedule = "*/10 * * * *"
279+
channel = "123456789"
280+
message = "Unit tests are still failing. Continue fixing them."
281+
disable_on_success = "npm test && echo OPENAB_GOAL_SUCCESS"
282+
disable_on_success_match = "OPENAB_GOAL_SUCCESS"
283+
disable_on_success_timeout_secs = 120
284+
disable_on_success_working_dir = "/workspace/my-project"
285+
```
286+
260287
**Cron expression format:**
261288

262289
```

docs/cronjob.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,33 @@ Agent: ✅ Written to cronjob.toml, takes effect within 1 minute
256256
257257
This enables mobile-friendly schedule management — talk to your agent from your phone, and it updates the cron file for you.
258258
259+
### Goal-Driven Auto-Disable
260+
261+
Usercron jobs can stop themselves once a goal is complete. Add `disable_on_success` to run a command before the scheduled prompt is sent. The job is considered complete only when the command exits `0` **and** stdout or stderr contains `disable_on_success_match`.
262+
263+
```toml
264+
[[jobs]]
265+
id = "fix-unit-tests" # required for scheduler writeback
266+
enabled = true
267+
schedule = "*/10 * * * *"
268+
channel = "1490282656913559673"
269+
message = "Unit tests are still failing. Continue fixing them and report progress."
270+
271+
disable_on_success = "npm test && echo OPENAB_GOAL_SUCCESS"
272+
disable_on_success_match = "OPENAB_GOAL_SUCCESS"
273+
disable_on_success_timeout_secs = 120
274+
disable_on_success_working_dir = "/workspace/my-project"
275+
```
276+
277+
Execution flow:
278+
279+
1. The schedule matches.
280+
2. The scheduler runs `disable_on_success`.
281+
3. If the command exits `0` and output contains `disable_on_success_match`, OpenAB posts `✅ Goal achieved`, writes `enabled = false` back to `$HOME/.openab/cronjob.toml`, and skips the regular prompt.
282+
4. Otherwise, OpenAB sends the regular `message` and the agent continues working.
283+
284+
`disable_on_success` is supported only in usercron `[[jobs]]`, not baseline `[[cron.jobs]]`. This keeps scheduler writeback limited to the user-managed cron file.
285+
259286
### Kubernetes Deployment
260287

261288
Mount `cronjob.toml` on a PVC so it persists across pod restarts, and set `usercron_path` in your config.toml:
@@ -273,7 +300,7 @@ usercron_path = "cronjob.toml"
273300
- **Minute-aligned**: The scheduler aligns to minute boundaries (`:00`), so `0 9 * * *` fires at exactly 09:00:00, not at whatever second the process started.
274301
- **Overlap protection**: If a previous execution of the same job is still running, the next tick is skipped.
275302
- **Isolation**: Cron failures are logged but never block interactive chat traffic.
276-
- **Stateless**: No persistence needed. Schedules are re-evaluated from config on restart.
303+
- **Usercron persistence**: For usercron jobs, the scheduler may write `thread_id` and `enabled = false` back to `cronjob.toml`.
277304
- **Graceful shutdown**: In-flight cron tasks are waited on (up to 30 seconds) during shutdown.
278305

279306
## Sender Identity
@@ -320,3 +347,4 @@ See [Kubernetes CronJob Reference Architecture](cronjob_k8s_refarch.md) for the
320347
| Channel not found | Bot not in channel | Invite the bot to the target channel |
321348
| Usercron not reloading | File not saved / wrong path | Check logs for `usercron file changed, reloading` |
322349
| Usercron parse error | Invalid TOML syntax | Check logs for `failed to parse usercron file` |
350+
| Goal job does not auto-disable | Command did not exit `0` or output did not include `disable_on_success_match` | Run the command manually and confirm both conditions |

src/config.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,8 @@ pub struct PoolConfig {
336336

337337
#[derive(Debug, Clone, Deserialize)]
338338
pub struct CronJobConfig {
339+
/// Stable ID for usercron jobs that need scheduler writeback.
340+
pub id: Option<String>,
339341
/// Whether this cronjob is active (default: true)
340342
#[serde(default = "default_true")]
341343
pub enabled: bool,
@@ -356,6 +358,17 @@ pub struct CronJobConfig {
356358
/// Timezone (default: "UTC")
357359
#[serde(default = "default_cron_timezone")]
358360
pub timezone: String,
361+
/// Usercron-only: command to run before firing. Exit 0 plus a matching
362+
/// `disable_on_success_match` means the goal is complete and the scheduler
363+
/// disables the job in the usercron file.
364+
pub disable_on_success: Option<String>,
365+
/// Usercron-only: required output marker for `disable_on_success`.
366+
pub disable_on_success_match: Option<String>,
367+
/// Usercron-only: timeout for `disable_on_success`.
368+
#[serde(default = "default_disable_on_success_timeout_secs")]
369+
pub disable_on_success_timeout_secs: u64,
370+
/// Usercron-only: working directory for `disable_on_success`.
371+
pub disable_on_success_working_dir: Option<String>,
359372
}
360373

361374
fn default_cron_platform() -> String {
@@ -367,6 +380,9 @@ fn default_cron_sender() -> String {
367380
fn default_cron_timezone() -> String {
368381
"UTC".into()
369382
}
383+
fn default_disable_on_success_timeout_secs() -> u64 {
384+
60
385+
}
370386

371387
/// Controls how tool calls are rendered in chat messages.
372388
///

0 commit comments

Comments
 (0)