Switch alarms to structured triggers

This commit is contained in:
Eric Traut
2026-04-06 21:58:28 -07:00
parent 117ce78783
commit da2e581411
40 changed files with 1754 additions and 768 deletions
+4
View File
@@ -698,6 +698,7 @@
"chacha20_0.9.1": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cipher\",\"req\":\"^0.4.4\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.4.4\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3.3\"}],\"features\":{\"std\":[\"cipher/std\"],\"zeroize\":[\"cipher/zeroize\"]}}",
"chacha20poly1305_0.10.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"req\":\"^0.5\"},{\"default_features\":false,\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"aead\",\"req\":\"^0.5\"},{\"features\":[\"zeroize\"],\"name\":\"chacha20\",\"req\":\"^0.9\"},{\"name\":\"cipher\",\"req\":\"^0.4\"},{\"name\":\"poly1305\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1.5\"}],\"features\":{\"alloc\":[\"aead/alloc\"],\"default\":[\"alloc\",\"getrandom\"],\"getrandom\":[\"aead/getrandom\",\"rand_core\"],\"heapless\":[\"aead/heapless\"],\"rand_core\":[\"aead/rand_core\"],\"reduced-round\":[],\"std\":[\"aead/std\",\"alloc\"],\"stream\":[\"aead/stream\"]}}",
"chardetng_0.1.17": "{\"dependencies\":[{\"name\":\"arrayvec\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"detone\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"encoding_rs\",\"req\":\"^0.8.29\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.2.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"multithreading\":[\"rayon\",\"arrayvec\"],\"testing-only-no-semver-guarantees-do-not-use\":[]}}",
"chrono-tz_0.10.4": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"chrono\",\"req\":\"^0.4.25\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"kind\":\"build\",\"name\":\"chrono-tz-build\",\"optional\":true,\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"chrono-tz-build\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"phf\",\"req\":\"^0.12\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.99\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tzfile\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"uncased\",\"optional\":true,\"req\":\"^0.9\"}],\"features\":{\"case-insensitive\":[\"dep:uncased\",\"chrono-tz-build\",\"chrono-tz-build/case-insensitive\",\"phf/uncased\"],\"default\":[\"std\"],\"filter-by-regex\":[\"chrono-tz-build\",\"chrono-tz-build/filter-by-regex\"],\"serde\":[\"dep:serde\"],\"std\":[]}}",
"chrono_0.4.43": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.0\"},{\"name\":\"defmt\",\"optional\":true,\"req\":\"^1.0.1\"},{\"features\":[\"fallback\"],\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.45\",\"target\":\"cfg(unix)\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"pure-rust-locales\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7.43\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.99\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.66\"},{\"name\":\"windows-link\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"__internal_bench\":[],\"alloc\":[],\"clock\":[\"winapi\",\"iana-time-zone\",\"now\"],\"core-error\":[],\"default\":[\"clock\",\"std\",\"oldtime\",\"wasmbind\"],\"defmt\":[\"dep:defmt\",\"pure-rust-locales?/defmt\"],\"libc\":[],\"now\":[\"std\"],\"oldtime\":[],\"rkyv\":[\"dep:rkyv\",\"rkyv/size_32\"],\"rkyv-16\":[\"dep:rkyv\",\"rkyv?/size_16\"],\"rkyv-32\":[\"dep:rkyv\",\"rkyv?/size_32\"],\"rkyv-64\":[\"dep:rkyv\",\"rkyv?/size_64\"],\"rkyv-validation\":[\"rkyv?/validation\"],\"std\":[\"alloc\"],\"unstable-locales\":[\"pure-rust-locales\"],\"wasmbind\":[\"wasm-bindgen\",\"js-sys\"],\"winapi\":[\"windows-link\"]}}",
"chunked_transfer_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"}],\"features\":{}}",
"cipher_0.4.4": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crypto-common\",\"req\":\"^0.1.6\"},{\"name\":\"inout\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"alloc\":[],\"block-padding\":[\"inout/block-padding\"],\"dev\":[\"blobby\"],\"rand_core\":[\"crypto-common/rand_core\"],\"std\":[\"alloc\",\"crypto-common/std\",\"inout/std\"]}}",
@@ -1138,7 +1139,9 @@
"percent-encoding_2.3.2": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}",
"petgraph_0.6.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.3\"},{\"kind\":\"dev\",\"name\":\"defmac\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"name\":\"fixedbitset\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"name\":\"indexmap\",\"req\":\"^2.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12.1\"},{\"kind\":\"dev\",\"name\":\"odds\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.5.5\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.5.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"all\":[\"unstable\",\"quickcheck\",\"matrix_graph\",\"stable_graph\",\"graphmap\",\"rayon\"],\"default\":[\"graphmap\",\"stable_graph\",\"matrix_graph\"],\"generate\":[],\"graphmap\":[],\"matrix_graph\":[],\"rayon\":[\"dep:rayon\",\"indexmap/rayon\"],\"serde-1\":[\"serde\",\"serde_derive\"],\"stable_graph\":[],\"unstable\":[\"generate\"]}}",
"petgraph_0.8.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.3\"},{\"kind\":\"dev\",\"name\":\"defmac\",\"req\":\"^0.2.1\"},{\"name\":\"dot-parser\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"dot-parser-macros\",\"optional\":true,\"req\":\"^0.5.1\"},{\"default_features\":false,\"name\":\"fixedbitset\",\"req\":\"^0.5.7\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"indexmap\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12.1\"},{\"kind\":\"dev\",\"name\":\"odds\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.5.5\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.5.3\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"all\":[\"unstable\",\"quickcheck\",\"matrix_graph\",\"stable_graph\",\"graphmap\",\"rayon\",\"dot_parser\"],\"default\":[\"std\",\"graphmap\",\"stable_graph\",\"matrix_graph\"],\"dot_parser\":[\"std\",\"dep:dot-parser\",\"dep:dot-parser-macros\"],\"generate\":[],\"graphmap\":[],\"matrix_graph\":[],\"quickcheck\":[\"std\",\"dep:quickcheck\",\"graphmap\",\"stable_graph\"],\"rayon\":[\"std\",\"dep:rayon\",\"indexmap/rayon\",\"hashbrown/rayon\"],\"serde-1\":[\"serde\",\"serde_derive\"],\"stable_graph\":[\"serde?/alloc\"],\"std\":[\"indexmap/std\"],\"unstable\":[\"generate\"]}}",
"phf_0.12.1": "{\"dependencies\":[{\"name\":\"phf_macros\",\"optional\":true,\"req\":\"^0.12.0\"},{\"default_features\":false,\"name\":\"phf_shared\",\"req\":\"^0.12.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"macros\":[\"phf_macros\"],\"std\":[\"phf_shared/std\",\"serde?/std\"],\"uncased\":[\"phf_macros?/uncased\",\"phf_shared/uncased\"],\"unicase\":[\"phf_macros?/unicase\",\"phf_shared/unicase\"]}}",
"phf_shared_0.11.3": "{\"dependencies\":[{\"name\":\"siphasher\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"uncased\",\"optional\":true,\"req\":\"^0.9.9\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}",
"phf_shared_0.12.1": "{\"dependencies\":[{\"name\":\"siphasher\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"uncased\",\"optional\":true,\"req\":\"^0.9.9\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}",
"pin-project-internal_1.1.10": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0.25\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\",\"full\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2.0.1\"}],\"features\":{}}",
"pin-project-lite_0.2.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}",
"pin-project-lite_0.2.17": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}",
@@ -1236,6 +1239,7 @@
"ring_0.17.14": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.8\"},{\"default_features\":false,\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getrandom\",\"req\":\"^0.2.10\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(all(any(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), all(target_arch = \\\"arm\\\", target_endian = \\\"little\\\")), any(target_os = \\\"android\\\", target_os = \\\"linux\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_vendor = \\\"apple\\\", any(target_os = \\\"ios\\\", target_os = \\\"macos\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\")))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_os = \\\"windows\\\"))\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\",\"dev_urandom_fallback\"],\"dev_urandom_fallback\":[],\"less-safe-getrandom-custom-or-rdrand\":[],\"less-safe-getrandom-espidf\":[],\"slow_tests\":[],\"std\":[\"alloc\"],\"test_logging\":[],\"unstable-testing-arm-no-hw\":[],\"unstable-testing-arm-no-neon\":[],\"wasm32_unknown_unknown_js\":[\"getrandom/js\"]}}",
"rmcp-macros_0.15.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}",
"rmcp_0.15.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"axum\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"reqwest\"],\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls-tls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-tls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\",\"dep:pastey\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"dep:axum\",\"tower\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"]}}",
"rrule_0.14.0": "{\"dependencies\":[{\"name\":\"chrono\",\"req\":\"^0.4.39\"},{\"name\":\"chrono-tz\",\"req\":\"^0.10.1\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4.5.26\"},{\"name\":\"log\",\"req\":\"^0.4.25\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"orig_serde\",\"package\":\"serde\",\"req\":\"^1.0.217\"},{\"default_features\":false,\"features\":[\"perf\",\"std\"],\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.135\"},{\"name\":\"serde_with\",\"optional\":true,\"req\":\"^3.12.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0.11\"}],\"features\":{\"by-easter\":[],\"cli-tool\":[\"clap\"],\"default\":[],\"exrule\":[],\"serde\":[\"serde_with\",\"chrono/serde\",\"chrono-tz/serde\"]}}",
"rsa_0.9.10": "{\"dependencies\":[{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"base64ct\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"const-oid\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"oid\"],\"name\":\"digest\",\"req\":\"^0.10.5\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.1\"},{\"default_features\":false,\"features\":[\"i128\",\"prime\",\"zeroize\"],\"name\":\"num-bigint\",\"package\":\"num-bigint-dig\",\"req\":\"^0.8.6\"},{\"default_features\":false,\"name\":\"num-integer\",\"req\":\"^0.1.39\"},{\"default_features\":false,\"features\":[\"libm\"],\"name\":\"num-traits\",\"req\":\"^0.2.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"pkcs8\"],\"name\":\"pkcs1\",\"req\":\"^0.7.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"pkcs8\",\"req\":\"^0.10.2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.6.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.184\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.89\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha3\",\"req\":\"^0.10.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"digest\",\"rand_core\"],\"name\":\"signature\",\"req\":\">2.0, <2.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"spki\",\"req\":\"^0.7.3\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.1.1\"},{\"features\":[\"alloc\"],\"name\":\"zeroize\",\"req\":\"^1.5\"}],\"features\":{\"default\":[\"std\",\"pem\",\"u64_digit\"],\"getrandom\":[\"rand_core/getrandom\"],\"hazmat\":[],\"nightly\":[\"num-bigint/nightly\"],\"pem\":[\"pkcs1/pem\",\"pkcs8/pem\"],\"pkcs5\":[\"pkcs8/encryption\"],\"serde\":[\"dep:serde\",\"num-bigint/serde\"],\"std\":[\"digest/std\",\"pkcs1/std\",\"pkcs8/std\",\"rand_core/std\",\"signature/std\"],\"u64_digit\":[\"num-bigint/u64_digit\"]}}",
"rust-embed-impl_8.11.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"rust-embed-utils\",\"req\":\"^8.11.0\"},{\"name\":\"shellexpand\",\"optional\":true,\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"proc-macro\",\"printing\"],\"name\":\"syn\",\"req\":\"^2\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"compression\":[],\"debug-embed\":[],\"deterministic-timestamps\":[],\"include-exclude\":[\"rust-embed-utils/include-exclude\"],\"interpolate-folder-path\":[\"shellexpand\"],\"mime-guess\":[\"rust-embed-utils/mime-guess\"]}}",
"rust-embed-utils_8.11.0": "{\"dependencies\":[{\"name\":\"globset\",\"optional\":true,\"req\":\"^0.4.8\"},{\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0.4\"},{\"name\":\"sha2\",\"req\":\"^0.10.5\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"debug-embed\":[],\"include-exclude\":[\"globset\"],\"mime-guess\":[\"mime_guess\"]}}",
+44 -1
View File
@@ -1232,6 +1232,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf",
]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
@@ -1849,6 +1859,7 @@ dependencies = [
"base64 0.22.1",
"bm25",
"chrono",
"chrono-tz",
"clap",
"codex-analytics",
"codex-api",
@@ -1926,6 +1937,7 @@ dependencies = [
"regex-lite",
"reqwest",
"rmcp",
"rrule",
"schemars 0.8.22",
"serde",
"serde_json",
@@ -7188,6 +7200,15 @@ dependencies = [
"indexmap 2.13.0",
]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared 0.12.1",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
@@ -7197,6 +7218,15 @@ dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.10"
@@ -8347,6 +8377,19 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "rrule"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "720acfb4980b9d8a6a430f6d7a11933e701ebbeba5eee39cc9d8c5f932aaff74"
dependencies = [
"chrono",
"chrono-tz",
"log",
"regex",
"thiserror 2.0.18",
]
[[package]]
name = "rsa"
version = "0.9.10"
@@ -9693,7 +9736,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"phf_shared 0.11.3",
"precomputed-hash",
]
+2
View File
@@ -208,6 +208,7 @@ bm25 = "2.3.2"
bytes = "1.10.1"
chardetng = "0.1.17"
chrono = "0.4.43"
chrono-tz = "0.10.4"
clap = "4"
clap_complete = "4"
color-eyre = "0.6.3"
@@ -275,6 +276,7 @@ ratatui-macros = "0.6.0"
regex = "1.12.3"
regex-lite = "0.1.8"
reqwest = "0.12"
rrule = "0.14.0"
rmcp = { version = "0.15.0", default-features = false }
runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" }
rustls = { version = "0.23", default-features = false, features = [
@@ -12,6 +12,63 @@
],
"type": "string"
},
"AlarmTrigger": {
"oneOf": [
{
"properties": {
"kind": {
"enum": [
"delay"
],
"type": "string"
},
"repeat": {
"type": [
"boolean",
"null"
]
},
"seconds": {
"format": "uint64",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"kind",
"seconds"
],
"type": "object"
},
{
"properties": {
"dtstart": {
"type": [
"string",
"null"
]
},
"kind": {
"enum": [
"schedule"
],
"type": "string"
},
"rrule": {
"type": [
"string",
"null"
]
}
},
"required": [
"kind"
],
"title": "KindAlarmTrigger",
"type": "object"
}
]
},
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.",
"enum": [
@@ -97,6 +97,63 @@
],
"type": "string"
},
"AlarmTrigger": {
"oneOf": [
{
"properties": {
"kind": {
"enum": [
"delay"
],
"type": "string"
},
"repeat": {
"type": [
"boolean",
"null"
]
},
"seconds": {
"format": "uint64",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"kind",
"seconds"
],
"type": "object"
},
{
"properties": {
"dtstart": {
"type": [
"string",
"null"
]
},
"kind": {
"enum": [
"schedule"
],
"type": "string"
},
"rrule": {
"type": [
"string",
"null"
]
}
},
"required": [
"kind"
],
"title": "KindAlarmTrigger",
"type": "object"
}
]
},
"AppBranding": {
"description": "EXPERIMENTAL - app metadata returned by app-list APIs.",
"properties": {
@@ -2532,9 +2589,6 @@
"format": "int64",
"type": "integer"
},
"cronExpression": {
"type": "string"
},
"delivery": {
"$ref": "#/definitions/AlarmDelivery"
},
@@ -2558,17 +2612,16 @@
"prompt": {
"type": "string"
},
"runOnce": {
"type": "boolean"
"trigger": {
"$ref": "#/definitions/AlarmTrigger"
}
},
"required": [
"createdAt",
"cronExpression",
"delivery",
"id",
"prompt",
"runOnce"
"trigger"
],
"type": "object"
},
@@ -4966,6 +4966,63 @@
],
"type": "string"
},
"AlarmTrigger": {
"oneOf": [
{
"properties": {
"kind": {
"enum": [
"delay"
],
"type": "string"
},
"repeat": {
"type": [
"boolean",
"null"
]
},
"seconds": {
"format": "uint64",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"kind",
"seconds"
],
"type": "object"
},
{
"properties": {
"dtstart": {
"type": [
"string",
"null"
]
},
"kind": {
"enum": [
"schedule"
],
"type": "string"
},
"rrule": {
"type": [
"string",
"null"
]
}
},
"required": [
"kind"
],
"title": "KindAlarmTrigger",
"type": "object"
}
]
},
"AnalyticsConfig": {
"additionalProperties": true,
"properties": {
@@ -12333,9 +12390,6 @@
"format": "int64",
"type": "integer"
},
"cronExpression": {
"type": "string"
},
"delivery": {
"$ref": "#/definitions/v2/AlarmDelivery"
},
@@ -12359,17 +12413,16 @@
"prompt": {
"type": "string"
},
"runOnce": {
"type": "boolean"
"trigger": {
"$ref": "#/definitions/v2/AlarmTrigger"
}
},
"required": [
"createdAt",
"cronExpression",
"delivery",
"id",
"prompt",
"runOnce"
"trigger"
],
"type": "object"
},
@@ -149,6 +149,63 @@
],
"type": "string"
},
"AlarmTrigger": {
"oneOf": [
{
"properties": {
"kind": {
"enum": [
"delay"
],
"type": "string"
},
"repeat": {
"type": [
"boolean",
"null"
]
},
"seconds": {
"format": "uint64",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"kind",
"seconds"
],
"type": "object"
},
{
"properties": {
"dtstart": {
"type": [
"string",
"null"
]
},
"kind": {
"enum": [
"schedule"
],
"type": "string"
},
"rrule": {
"type": [
"string",
"null"
]
}
},
"required": [
"kind"
],
"title": "KindAlarmTrigger",
"type": "object"
}
]
},
"AnalyticsConfig": {
"additionalProperties": true,
"properties": {
@@ -10188,9 +10245,6 @@
"format": "int64",
"type": "integer"
},
"cronExpression": {
"type": "string"
},
"delivery": {
"$ref": "#/definitions/AlarmDelivery"
},
@@ -10214,17 +10268,16 @@
"prompt": {
"type": "string"
},
"runOnce": {
"type": "boolean"
"trigger": {
"$ref": "#/definitions/AlarmTrigger"
}
},
"required": [
"createdAt",
"cronExpression",
"delivery",
"id",
"prompt",
"runOnce"
"trigger"
],
"type": "object"
},
@@ -8,15 +8,69 @@
],
"type": "string"
},
"AlarmTrigger": {
"oneOf": [
{
"properties": {
"kind": {
"enum": [
"delay"
],
"type": "string"
},
"repeat": {
"type": [
"boolean",
"null"
]
},
"seconds": {
"format": "uint64",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"kind",
"seconds"
],
"type": "object"
},
{
"properties": {
"dtstart": {
"type": [
"string",
"null"
]
},
"kind": {
"enum": [
"schedule"
],
"type": "string"
},
"rrule": {
"type": [
"string",
"null"
]
}
},
"required": [
"kind"
],
"title": "KindAlarmTrigger",
"type": "object"
}
]
},
"ThreadAlarm": {
"properties": {
"createdAt": {
"format": "int64",
"type": "integer"
},
"cronExpression": {
"type": "string"
},
"delivery": {
"$ref": "#/definitions/AlarmDelivery"
},
@@ -40,17 +94,16 @@
"prompt": {
"type": "string"
},
"runOnce": {
"type": "boolean"
"trigger": {
"$ref": "#/definitions/AlarmTrigger"
}
},
"required": [
"createdAt",
"cronExpression",
"delivery",
"id",
"prompt",
"runOnce"
"trigger"
],
"type": "object"
}
@@ -8,15 +8,69 @@
],
"type": "string"
},
"AlarmTrigger": {
"oneOf": [
{
"properties": {
"kind": {
"enum": [
"delay"
],
"type": "string"
},
"repeat": {
"type": [
"boolean",
"null"
]
},
"seconds": {
"format": "uint64",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"kind",
"seconds"
],
"type": "object"
},
{
"properties": {
"dtstart": {
"type": [
"string",
"null"
]
},
"kind": {
"enum": [
"schedule"
],
"type": "string"
},
"rrule": {
"type": [
"string",
"null"
]
}
},
"required": [
"kind"
],
"title": "KindAlarmTrigger",
"type": "object"
}
]
},
"ThreadAlarm": {
"properties": {
"createdAt": {
"format": "int64",
"type": "integer"
},
"cronExpression": {
"type": "string"
},
"delivery": {
"$ref": "#/definitions/AlarmDelivery"
},
@@ -40,17 +94,16 @@
"prompt": {
"type": "string"
},
"runOnce": {
"type": "boolean"
"trigger": {
"$ref": "#/definitions/AlarmTrigger"
}
},
"required": [
"createdAt",
"cronExpression",
"delivery",
"id",
"prompt",
"runOnce"
"trigger"
],
"type": "object"
}
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AlarmTrigger = { "kind": "delay", seconds: number, repeat: boolean | null, } | { "kind": "schedule", dtstart: string | null, rrule: string | null, };
@@ -2,5 +2,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AlarmDelivery } from "./AlarmDelivery";
import type { AlarmTrigger } from "./AlarmTrigger";
export type ThreadAlarm = { id: string, cronExpression: string, prompt: string, runOnce: boolean, delivery: AlarmDelivery, createdAt: number, nextRunAt: number | null, lastRunAt: number | null, };
export type ThreadAlarm = { id: string, trigger: AlarmTrigger, prompt: string, delivery: AlarmDelivery, createdAt: number, nextRunAt: number | null, lastRunAt: number | null, };
@@ -9,6 +9,7 @@ export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermission
export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile";
export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification";
export type { AlarmDelivery } from "./AlarmDelivery";
export type { AlarmTrigger } from "./AlarmTrigger";
export type { AnalyticsConfig } from "./AnalyticsConfig";
export type { AppBranding } from "./AppBranding";
export type { AppInfo } from "./AppInfo";
@@ -1838,41 +1838,6 @@ mod tests {
assert_eq!(reason, Some("thread/realtime/outputAudio/delta"));
}
#[test]
fn thread_alarm_create_is_marked_experimental() {
let request = ClientRequest::ThreadAlarmCreate {
request_id: RequestId::Integer(1),
params: v2::ThreadAlarmCreateParams {
thread_id: "thr_123".to_string(),
cron_expression: "@after-turn".to_string(),
prompt: "run tests".to_string(),
run_once: Some(false),
delivery: v2::AlarmDelivery::AfterTurn,
},
};
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
assert_eq!(reason, Some("thread/alarm/create"));
}
#[test]
fn thread_alarm_fired_notification_is_marked_experimental() {
let notification = ServerNotification::ThreadAlarmFired(v2::ThreadAlarmFiredNotification {
thread_id: "thr_123".to_string(),
alarm: v2::ThreadAlarm {
id: "alarm_123".to_string(),
cron_expression: "@after-turn".to_string(),
prompt: "run tests".to_string(),
run_once: false,
delivery: v2::AlarmDelivery::AfterTurn,
created_at: 1,
next_run_at: None,
last_run_at: None,
},
});
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&notification);
assert_eq!(reason, Some("thread/alarm/fired"));
}
#[test]
fn command_execution_request_approval_additional_permissions_is_marked_experimental() {
let params = v2::CommandExecutionRequestApprovalParams {
@@ -3161,14 +3161,33 @@ pub enum AlarmDelivery {
SteerCurrentTurn,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "kind", rename_all = "camelCase")]
#[ts(tag = "kind")]
#[ts(export_to = "v2/")]
pub enum AlarmTrigger {
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Delay {
#[ts(type = "number")]
seconds: u64,
repeat: Option<bool>,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Schedule {
dtstart: Option<String>,
rrule: Option<String>,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAlarm {
pub id: String,
pub cron_expression: String,
pub trigger: AlarmTrigger,
pub prompt: String,
pub run_once: bool,
pub delivery: AlarmDelivery,
#[ts(type = "number")]
pub created_at: i64,
@@ -3183,10 +3202,8 @@ pub struct ThreadAlarm {
#[ts(export_to = "v2/")]
pub struct ThreadAlarmCreateParams {
pub thread_id: String,
pub cron_expression: String,
pub trigger: AlarmTrigger,
pub prompt: String,
#[ts(optional = nullable)]
pub run_once: Option<bool>,
pub delivery: AlarmDelivery,
}
+2 -2
View File
@@ -145,10 +145,10 @@ Example with notification opt-out:
- `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server shuts down and unloads the thread, then emits `thread/closed`.
- `thread/name/set` — set or update a thread’s user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success and emits `thread/name/updated` to initialized, opted-in clients. Thread names are not required to be unique; name lookups resolve to the most recently updated thread.
- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`.
- `thread/alarm/create` — create a runtime alarm on a loaded thread; accepts `cronExpression`, `prompt`, `delivery`, and optional `runOnce`, returns the created alarm, and requires the under-development feature flag `alarm_tool` to be enabled for that thread.
- `thread/alarm/create` — create a runtime alarm on a loaded thread; accepts a structured `trigger`, `prompt`, and `delivery`, returns the created alarm, and requires the under-development feature flag `alarm_tool` to be enabled for that thread. Delay triggers use `seconds` plus optional `repeat`; schedule triggers use optional `dtstart` and/or `rrule`.
- `thread/alarm/delete` — delete one runtime alarm from a loaded thread by id; returns `{ deleted }` and requires `alarm_tool`.
- `thread/alarm/list` — list runtime alarms for a loaded thread; returns `{ data }` and requires `alarm_tool`.
- `thread/alarm/updated` — notification emitted when a loaded thread’s alarm list changes, including run-once alarms deleting themselves after execution or alarms restored from disk.
- `thread/alarm/updated` — notification emitted when a loaded thread’s alarm list changes, including one-shot alarms deleting themselves after execution or alarms restored from disk.
- `thread/alarm/fired` — notification emitted when a thread alarm fires, either for a synthetic follow-on turn or a same-turn steer delivery.
- `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications.
- `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream.
@@ -15,6 +15,7 @@ use crate::thread_status::ThreadWatchManager;
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
use codex_app_server_protocol::AdditionalPermissionProfile as V2AdditionalPermissionProfile;
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::AlarmTrigger;
use codex_app_server_protocol::ApplyPatchApprovalParams;
use codex_app_server_protocol::ApplyPatchApprovalResponse;
use codex_app_server_protocol::CodexErrorInfo as V2CodexErrorInfo;
@@ -166,9 +167,8 @@ struct CommandExecutionCompletionItem {
fn thread_alarm_from_core(value: codex_core::alarms::ThreadAlarm) -> ThreadAlarm {
ThreadAlarm {
id: value.id,
cron_expression: value.cron_expression,
trigger: alarm_trigger_from_core(value.trigger),
prompt: value.prompt,
run_once: value.run_once,
delivery: match value.delivery {
codex_core::alarms::AlarmDelivery::AfterTurn => {
codex_app_server_protocol::AlarmDelivery::AfterTurn
@@ -182,6 +182,17 @@ fn thread_alarm_from_core(value: codex_core::alarms::ThreadAlarm) -> ThreadAlarm
last_run_at: value.last_run_at,
}
}
fn alarm_trigger_from_core(value: codex_core::alarms::ThreadAlarmTrigger) -> AlarmTrigger {
match value {
codex_core::alarms::ThreadAlarmTrigger::Delay { seconds, repeat } => {
AlarmTrigger::Delay { seconds, repeat }
}
codex_core::alarms::ThreadAlarmTrigger::Schedule { dtstart, rrule } => {
AlarmTrigger::Schedule { dtstart, rrule }
}
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn apply_bespoke_event_handling(
event: Event,
@@ -25,6 +25,7 @@ use codex_app_server_protocol::Account;
use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::AlarmDelivery as ApiAlarmDelivery;
use codex_app_server_protocol::AlarmTrigger as ApiAlarmTrigger;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
@@ -3704,9 +3705,8 @@ impl CodexMessageProcessor {
) {
let ThreadAlarmCreateParams {
thread_id,
cron_expression,
trigger,
prompt,
run_once,
delivery,
} = params;
let Some(thread) = self.load_alarm_thread(request_id.clone(), &thread_id).await else {
@@ -3714,9 +3714,8 @@ impl CodexMessageProcessor {
};
match thread
.create_alarm(
cron_expression,
alarm_trigger_to_core(trigger),
prompt,
run_once.unwrap_or(false),
alarm_delivery_to_core(delivery),
)
.await
@@ -9053,12 +9052,33 @@ fn api_alarm_delivery_from_core(value: codex_core::alarms::AlarmDelivery) -> Api
}
}
fn alarm_trigger_to_core(value: ApiAlarmTrigger) -> codex_core::alarms::ThreadAlarmTrigger {
match value {
ApiAlarmTrigger::Delay { seconds, repeat } => {
codex_core::alarms::ThreadAlarmTrigger::Delay { seconds, repeat }
}
ApiAlarmTrigger::Schedule { dtstart, rrule } => {
codex_core::alarms::ThreadAlarmTrigger::Schedule { dtstart, rrule }
}
}
}
fn api_alarm_trigger_from_core(value: codex_core::alarms::ThreadAlarmTrigger) -> ApiAlarmTrigger {
match value {
codex_core::alarms::ThreadAlarmTrigger::Delay { seconds, repeat } => {
ApiAlarmTrigger::Delay { seconds, repeat }
}
codex_core::alarms::ThreadAlarmTrigger::Schedule { dtstart, rrule } => {
ApiAlarmTrigger::Schedule { dtstart, rrule }
}
}
}
fn api_thread_alarm_from_core(value: codex_core::alarms::ThreadAlarm) -> ApiThreadAlarm {
ApiThreadAlarm {
id: value.id,
cron_expression: value.cron_expression,
trigger: api_alarm_trigger_from_core(value.trigger),
prompt: value.prompt,
run_once: value.run_once,
delivery: api_alarm_delivery_from_core(value.delivery),
created_at: value.created_at,
next_run_at: value.next_run_at,
+2
View File
@@ -24,6 +24,7 @@ async-trait = { workspace = true }
base64 = { workspace = true }
bm25 = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
chrono-tz = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-analytics = { workspace = true }
codex-api = { workspace = true }
@@ -87,6 +88,7 @@ once_cell = { workspace = true }
rand = { workspace = true }
regex-lite = { workspace = true }
reqwest = { workspace = true, features = ["json", "stream"] }
rrule = { workspace = true }
rmcp = { workspace = true, default-features = false, features = [
"base64",
"macros",
+606
View File
@@ -0,0 +1,606 @@
//! Trigger validation and next-fire calculation for persistent thread alarms.
//!
//! This module keeps calendar and delay scheduling details out of `alarms.rs`.
//! It owns the persisted trigger shape, local wall-clock schedule normalization,
//! and RRULE-backed recurrence evaluation.
use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::LocalResult;
use chrono::NaiveDateTime;
use chrono::TimeZone;
use chrono::Utc;
use rrule::RRuleSet;
use rrule::Tz;
use serde::Deserialize;
use serde::Serialize;
use std::time::Duration;
const LOCAL_DATE_TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
const RRULE_DATE_TIME_FORMAT: &str = "%Y%m%dT%H%M%S";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum AlarmTrigger {
Delay {
seconds: u64,
repeat: Option<bool>,
},
Schedule {
dtstart: Option<String>,
rrule: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TriggerTiming {
pub(crate) trigger: AlarmTrigger,
pub(crate) pending_run: bool,
pub(crate) next_run_at: Option<i64>,
pub(crate) timer_delay: Option<Duration>,
}
impl AlarmTrigger {
pub(crate) fn is_recurring(&self) -> bool {
match self {
Self::Delay { repeat, .. } => repeat.unwrap_or(false),
Self::Schedule { rrule, .. } => rrule.as_ref().is_some_and(|rrule| !rrule.is_empty()),
}
}
pub(crate) fn display(&self) -> String {
match self {
Self::Delay { seconds, repeat } => {
let suffix = if repeat.unwrap_or(false) {
", repeat"
} else {
""
};
format!("delay {seconds}s{suffix}")
}
Self::Schedule { dtstart, rrule } => match (dtstart, rrule) {
(Some(dtstart), Some(rrule)) => format!("schedule {dtstart}; {rrule}"),
(Some(dtstart), None) => format!("schedule {dtstart}"),
(None, Some(rrule)) => format!("schedule {rrule}"),
(None, None) => "invalid schedule".to_string(),
},
}
}
}
pub(crate) fn timing_for_new_trigger(
trigger: AlarmTrigger,
created_at: DateTime<Utc>,
now: DateTime<Utc>,
) -> Result<TriggerTiming, String> {
let timezone = local_timezone();
timing_for_new_trigger_with_timezone(trigger, created_at, now, timezone)
}
pub(crate) fn timing_for_restored_trigger(
trigger: AlarmTrigger,
created_at: i64,
persisted_pending_run: bool,
persisted_next_run_at: Option<i64>,
now: DateTime<Utc>,
) -> Result<TriggerTiming, String> {
let timezone = local_timezone();
timing_for_restored_trigger_with_timezone(
trigger,
created_at,
persisted_pending_run,
persisted_next_run_at,
now,
timezone,
)
}
pub(crate) fn next_run_after_due(
trigger: &AlarmTrigger,
created_at: i64,
now: DateTime<Utc>,
) -> Result<Option<i64>, String> {
let timezone = local_timezone();
next_run_after_due_with_timezone(trigger, created_at, now, timezone)
}
fn timing_for_new_trigger_with_timezone(
trigger: AlarmTrigger,
created_at: DateTime<Utc>,
now: DateTime<Utc>,
timezone: Tz,
) -> Result<TriggerTiming, String> {
let normalized = normalize_trigger(trigger, now, timezone)?;
match &normalized {
AlarmTrigger::Delay { seconds, repeat } => {
let repeat = repeat.unwrap_or(false);
if repeat && *seconds == 0 {
return Err("delay.repeat requires seconds to be greater than 0".to_string());
}
let next_run_at = checked_add_seconds(created_at, *seconds)?;
let pending_run = next_run_at <= now;
let next_run_at = if repeat {
if pending_run {
next_delay_recurring_run_at(created_at, *seconds, now)?
} else {
Some(next_run_at.timestamp())
}
} else {
Some(next_run_at.timestamp())
};
Ok(timing(normalized, pending_run, next_run_at, now))
}
AlarmTrigger::Schedule { rrule: None, .. } => {
let due_at = schedule_dtstart_utc(&normalized, timezone)?;
Ok(timing(
normalized,
due_at <= now,
Some(due_at.timestamp()),
now,
))
}
AlarmTrigger::Schedule { rrule: Some(_), .. } => {
let first = first_schedule_occurrence(&normalized, timezone)?;
let Some(first) = first else {
return Ok(timing(
normalized, /*pending_run*/ false, /*next_run_at*/ None, now,
));
};
let pending_run = first <= now.timestamp();
let next_run_at = if pending_run {
next_schedule_occurrence_after(&normalized, now, timezone)?
} else {
Some(first)
};
Ok(timing(normalized, pending_run, next_run_at, now))
}
}
}
fn timing_for_restored_trigger_with_timezone(
trigger: AlarmTrigger,
created_at: i64,
persisted_pending_run: bool,
persisted_next_run_at: Option<i64>,
now: DateTime<Utc>,
timezone: Tz,
) -> Result<TriggerTiming, String> {
let normalized = normalize_trigger(trigger, now, timezone)?;
match &normalized {
AlarmTrigger::Delay { seconds, repeat } => {
let repeat = repeat.unwrap_or(false);
if repeat && *seconds == 0 {
return Err("delay.repeat requires seconds to be greater than 0".to_string());
}
let next_run_at = persisted_next_run_at
.or_else(|| next_delay_run_at(created_at, *seconds))
.ok_or_else(|| "delay next run time is out of range".to_string())?;
let due = next_run_at <= now.timestamp();
let pending_run = persisted_pending_run || due;
let next_run_at = if repeat && due {
next_delay_recurring_run_at_from_timestamp(created_at, *seconds, now)?
} else {
Some(next_run_at)
};
Ok(timing(normalized, pending_run, next_run_at, now))
}
AlarmTrigger::Schedule { rrule: None, .. } => {
let next_run_at = persisted_next_run_at
.or_else(|| {
schedule_dtstart_utc(&normalized, timezone)
.ok()
.map(|dt| dt.timestamp())
})
.ok_or_else(|| "schedule next run time is out of range".to_string())?;
Ok(timing(
normalized,
persisted_pending_run || next_run_at <= now.timestamp(),
Some(next_run_at),
now,
))
}
AlarmTrigger::Schedule { rrule: Some(_), .. } => {
let due = persisted_next_run_at.is_none_or(|next| next <= now.timestamp());
let pending_run = persisted_pending_run || due;
let next_run_at = if due {
next_schedule_occurrence_after(&normalized, now, timezone)?
} else {
persisted_next_run_at
};
Ok(timing(normalized, pending_run, next_run_at, now))
}
}
}
fn next_run_after_due_with_timezone(
trigger: &AlarmTrigger,
created_at: i64,
now: DateTime<Utc>,
timezone: Tz,
) -> Result<Option<i64>, String> {
match trigger {
AlarmTrigger::Delay { seconds, repeat } => {
if repeat.unwrap_or(false) {
next_delay_recurring_run_at_from_timestamp(created_at, *seconds, now)
} else {
Ok(None)
}
}
AlarmTrigger::Schedule { rrule: None, .. } => Ok(None),
AlarmTrigger::Schedule { rrule: Some(_), .. } => {
next_schedule_occurrence_after(trigger, now, timezone)
}
}
}
fn normalize_trigger(
trigger: AlarmTrigger,
now: DateTime<Utc>,
timezone: Tz,
) -> Result<AlarmTrigger, String> {
match trigger {
AlarmTrigger::Delay { seconds, repeat } => {
if repeat.unwrap_or(false) && seconds == 0 {
return Err("delay.repeat requires seconds to be greater than 0".to_string());
}
Ok(AlarmTrigger::Delay { seconds, repeat })
}
AlarmTrigger::Schedule { dtstart, rrule } => {
let dtstart = normalize_optional_string(dtstart);
let rrule = normalize_optional_string(rrule);
if dtstart.is_none() && rrule.is_none() {
return Err("schedule trigger requires dtstart, rrule, or both".to_string());
}
let dtstart = match (dtstart, rrule.as_ref()) {
(Some(dtstart), _) => {
validate_dtstart(&dtstart, timezone)?;
Some(dtstart)
}
(None, Some(_)) => Some(format_local_dtstart(now, timezone)),
(None, None) => None,
};
let normalized = AlarmTrigger::Schedule { dtstart, rrule };
if matches!(normalized, AlarmTrigger::Schedule { rrule: Some(_), .. }) {
parse_rrule_set(&normalized, timezone)?;
}
Ok(normalized)
}
}
}
fn timing(
trigger: AlarmTrigger,
pending_run: bool,
next_run_at: Option<i64>,
now: DateTime<Utc>,
) -> TriggerTiming {
let keep_timer_for_pending = trigger.is_recurring();
let timer_delay = next_run_at
.filter(|_| !pending_run || keep_timer_for_pending)
.and_then(|next| timer_delay(next, now));
TriggerTiming {
trigger,
pending_run,
next_run_at,
timer_delay,
}
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn checked_add_seconds(start: DateTime<Utc>, seconds: u64) -> Result<DateTime<Utc>, String> {
let seconds = i64::try_from(seconds)
.map_err(|_| "delay seconds value is too large to schedule".to_string())?;
start
.checked_add_signed(ChronoDuration::seconds(seconds))
.ok_or_else(|| "delay next run time is out of range".to_string())
}
fn next_delay_run_at(created_at: i64, seconds: u64) -> Option<i64> {
let seconds = i64::try_from(seconds).ok()?;
created_at.checked_add(seconds)
}
fn next_delay_recurring_run_at(
created_at: DateTime<Utc>,
seconds: u64,
now: DateTime<Utc>,
) -> Result<Option<i64>, String> {
next_delay_recurring_run_at_from_timestamp(created_at.timestamp(), seconds, now)
}
fn next_delay_recurring_run_at_from_timestamp(
created_at: i64,
seconds: u64,
now: DateTime<Utc>,
) -> Result<Option<i64>, String> {
let seconds = i64::try_from(seconds)
.map_err(|_| "delay seconds value is too large to schedule".to_string())?;
if seconds <= 0 {
return Err("delay.repeat requires seconds to be greater than 0".to_string());
}
let elapsed = now.timestamp().saturating_sub(created_at);
let completed_intervals = elapsed.div_euclid(seconds) + 1;
created_at
.checked_add(
completed_intervals
.checked_mul(seconds)
.ok_or_else(|| "delay next run time is out of range".to_string())?,
)
.map(Some)
.ok_or_else(|| "delay next run time is out of range".to_string())
}
fn timer_delay(next_run_at: i64, now: DateTime<Utc>) -> Option<Duration> {
if next_run_at <= now.timestamp() {
return Some(Duration::ZERO);
}
u64::try_from(next_run_at - now.timestamp())
.ok()
.map(Duration::from_secs)
}
fn schedule_dtstart_utc(trigger: &AlarmTrigger, timezone: Tz) -> Result<DateTime<Utc>, String> {
let AlarmTrigger::Schedule {
dtstart: Some(dtstart),
..
} = trigger
else {
return Err("schedule trigger requires dtstart".to_string());
};
local_dtstart_to_utc(dtstart, timezone)
}
fn first_schedule_occurrence(trigger: &AlarmTrigger, timezone: Tz) -> Result<Option<i64>, String> {
let dtstart = schedule_dtstart_utc(trigger, timezone)?;
let after = dtstart
.checked_sub_signed(ChronoDuration::seconds(1))
.unwrap_or(dtstart);
next_schedule_occurrence_after(trigger, after, timezone)
}
fn next_schedule_occurrence_after(
trigger: &AlarmTrigger,
after: DateTime<Utc>,
timezone: Tz,
) -> Result<Option<i64>, String> {
let set = parse_rrule_set(trigger, timezone)?;
let result = set.after(after.with_timezone(&timezone)).all(1);
Ok(result
.dates
.into_iter()
.next()
.map(|next| next.with_timezone(&Utc).timestamp()))
}
fn parse_rrule_set(trigger: &AlarmTrigger, timezone: Tz) -> Result<RRuleSet, String> {
let AlarmTrigger::Schedule {
dtstart: Some(dtstart),
rrule: Some(rrule),
} = trigger
else {
return Err("schedule trigger requires dtstart and rrule".to_string());
};
let naive = parse_dtstart(dtstart)?;
let raw_rrule = rrule
.strip_prefix("RRULE:")
.or_else(|| rrule.strip_prefix("rrule:"))
.unwrap_or(rrule);
let rrule_set = format!(
"DTSTART;TZID={}:{}\nRRULE:{}",
timezone.name(),
naive.format(RRULE_DATE_TIME_FORMAT),
raw_rrule
);
rrule_set
.parse::<RRuleSet>()
.map_err(|err| format!("invalid schedule rrule `{rrule}`: {err}"))
}
fn validate_dtstart(dtstart: &str, timezone: Tz) -> Result<(), String> {
local_dtstart_to_utc(dtstart, timezone).map(|_| ())
}
fn local_dtstart_to_utc(dtstart: &str, timezone: Tz) -> Result<DateTime<Utc>, String> {
let naive = parse_dtstart(dtstart)?;
match timezone.from_local_datetime(&naive) {
LocalResult::Single(dt) => Ok(dt.with_timezone(&Utc)),
LocalResult::Ambiguous(earliest, _) => Ok(earliest.with_timezone(&Utc)),
LocalResult::None => Err(format!(
"schedule dtstart `{dtstart}` does not exist in local timezone {timezone}"
)),
}
}
fn parse_dtstart(dtstart: &str) -> Result<NaiveDateTime, String> {
NaiveDateTime::parse_from_str(dtstart, LOCAL_DATE_TIME_FORMAT)
.map_err(|_| format!("schedule dtstart `{dtstart}` must use format YYYY-MM-DDTHH:MM:SS"))
}
fn format_local_dtstart(now: DateTime<Utc>, timezone: Tz) -> String {
now.with_timezone(&timezone)
.naive_local()
.format(LOCAL_DATE_TIME_FORMAT)
.to_string()
}
fn local_timezone() -> Tz {
iana_time_zone::get_timezone()
.ok()
.and_then(|timezone| timezone.parse::<chrono_tz::Tz>().ok())
.map(Tz::Tz)
.unwrap_or(Tz::UTC)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use pretty_assertions::assert_eq;
const TS_1: i64 = 1;
const TS_100: i64 = 100;
const TS_135: i64 = 135;
const TS_1_700_000_000: i64 = 1_700_000_000;
fn utc(timestamp: i64) -> DateTime<Utc> {
Utc.timestamp_opt(timestamp, 0)
.single()
.expect("valid timestamp")
}
#[test]
fn delay_one_shot_becomes_pending_when_due() {
let timing = timing_for_new_trigger_with_timezone(
AlarmTrigger::Delay {
seconds: 0,
repeat: None,
},
utc(TS_100),
utc(TS_100),
Tz::UTC,
)
.expect("trigger should be valid");
assert_eq!(
timing,
TriggerTiming {
trigger: AlarmTrigger::Delay {
seconds: 0,
repeat: None,
},
pending_run: true,
next_run_at: Some(100),
timer_delay: None,
}
);
}
#[test]
fn delay_repeat_rejects_zero_seconds() {
assert_eq!(
timing_for_new_trigger_with_timezone(
AlarmTrigger::Delay {
seconds: 0,
repeat: Some(true),
},
utc(TS_100),
utc(TS_100),
Tz::UTC,
)
.expect_err("zero repeat should be invalid"),
"delay.repeat requires seconds to be greater than 0"
);
}
#[test]
fn delay_repeat_coalesces_overdue_runs() {
let timing = timing_for_restored_trigger_with_timezone(
AlarmTrigger::Delay {
seconds: 10,
repeat: Some(true),
},
/*created_at*/ 100,
/*persisted_pending_run*/ false,
Some(110),
utc(TS_135),
Tz::UTC,
)
.expect("trigger should be valid");
assert_eq!(timing.pending_run, true);
assert_eq!(timing.next_run_at, Some(140));
}
#[test]
fn schedule_rrule_only_resolves_dtstart_to_now() {
let timing = timing_for_new_trigger_with_timezone(
AlarmTrigger::Schedule {
dtstart: None,
rrule: Some("FREQ=HOURLY;BYMINUTE=0;BYSECOND=0".to_string()),
},
utc(TS_1_700_000_000),
utc(TS_1_700_000_000),
Tz::UTC,
)
.expect("trigger should be valid");
assert_eq!(
timing.trigger,
AlarmTrigger::Schedule {
dtstart: Some("2023-11-14T22:13:20".to_string()),
rrule: Some("FREQ=HOURLY;BYMINUTE=0;BYSECOND=0".to_string()),
}
);
assert_eq!(timing.pending_run, false);
assert_eq!(timing.next_run_at, Some(1_700_002_800));
}
#[test]
fn schedule_dtstart_only_is_one_shot() {
let timing = timing_for_new_trigger_with_timezone(
AlarmTrigger::Schedule {
dtstart: Some("2024-01-01T09:00:00".to_string()),
rrule: None,
},
utc(TS_1),
utc(TS_1),
Tz::UTC,
)
.expect("trigger should be valid");
assert_eq!(timing.pending_run, false);
assert_eq!(timing.next_run_at, Some(1_704_099_600));
}
#[test]
fn schedule_rejects_neither_dtstart_nor_rrule() {
assert_eq!(
timing_for_new_trigger_with_timezone(
AlarmTrigger::Schedule {
dtstart: None,
rrule: None,
},
utc(TS_1),
utc(TS_1),
Tz::UTC,
)
.expect_err("empty schedule should be invalid"),
"schedule trigger requires dtstart, rrule, or both"
);
}
#[test]
fn schedule_rejects_invalid_dtstart() {
assert_eq!(
timing_for_new_trigger_with_timezone(
AlarmTrigger::Schedule {
dtstart: Some("2024-01-01 09:00:00".to_string()),
rrule: None,
},
utc(TS_1),
utc(TS_1),
Tz::UTC,
)
.expect_err("bad dtstart should be invalid"),
"schedule dtstart `2024-01-01 09:00:00` must use format YYYY-MM-DDTHH:MM:SS"
);
}
#[test]
fn schedule_rejects_invalid_rrule() {
assert!(
timing_for_new_trigger_with_timezone(
AlarmTrigger::Schedule {
dtstart: Some("2024-01-01T09:00:00".to_string()),
rrule: Some("FREQ=NEVER".to_string()),
},
utc(TS_1),
utc(TS_1),
Tz::UTC,
)
.expect_err("bad rrule should be invalid")
.contains("invalid schedule rrule")
);
}
}
+182 -261
View File
@@ -1,12 +1,14 @@
//! Persistent thread-local alarm scheduling for follow-on turns and same-turn steer delivery.
//!
//! This module owns the in-memory alarm registry, schedule parsing, the hidden
//! This module owns the in-memory alarm registry, trigger evaluation, the hidden
//! alarm prompt injected when an alarm fires, and the JSON sidecar format used
//! to restore alarms after a harness restart.
use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::TimeZone;
use crate::alarm_trigger::AlarmTrigger;
use crate::alarm_trigger::TriggerTiming;
use crate::alarm_trigger::next_run_after_due;
use crate::alarm_trigger::timing_for_new_trigger;
use crate::alarm_trigger::timing_for_restored_trigger;
use chrono::Utc;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
@@ -19,16 +21,14 @@ use std::path::PathBuf;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
pub const AFTER_TURN_CRON_EXPRESSION: &str = "@after-turn";
const EVERY_PREFIX: &str = "@every ";
const EVERY_SECONDS_PREFIX: &str = "@every:";
pub const ALARM_UPDATED_BACKGROUND_EVENT_PREFIX: &str = "alarm_updated:";
pub const ALARM_FIRED_BACKGROUND_EVENT_PREFIX: &str = "alarm_fired:";
pub const MAX_ACTIVE_ALARMS_PER_THREAD: usize = 256;
const MAX_EVERY_SECONDS: u64 = i64::MAX as u64;
const ONE_SHOT_ALARM_PROMPT: &str = include_str!("../templates/alarms/one_shot_prompt.md");
const RECURRING_ALARM_PROMPT: &str = include_str!("../templates/alarms/recurring_prompt.md");
pub use crate::alarm_trigger::AlarmTrigger as ThreadAlarmTrigger;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AlarmDelivery {
@@ -48,9 +48,8 @@ impl AlarmDelivery {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThreadAlarm {
pub id: String,
pub cron_expression: String,
pub trigger: AlarmTrigger,
pub prompt: String,
pub run_once: bool,
pub delivery: AlarmDelivery,
pub created_at: i64,
pub next_run_at: Option<i64>,
@@ -60,9 +59,9 @@ pub struct ThreadAlarm {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AlarmInvocationContext {
pub(crate) current_alarm_id: String,
pub(crate) cron_expression: String,
pub(crate) trigger: AlarmTrigger,
pub(crate) prompt: String,
pub(crate) run_once: bool,
pub(crate) recurring: bool,
pub(crate) delivery: AlarmDelivery,
}
@@ -70,17 +69,16 @@ pub(crate) struct AlarmInvocationContext {
pub(crate) struct ClaimedAlarm {
pub(crate) alarm: ThreadAlarm,
pub(crate) context: AlarmInvocationContext,
pub(crate) deleted_run_once_alarm: bool,
pub(crate) deleted_one_shot_alarm: bool,
}
#[derive(Debug)]
pub(crate) struct CreateAlarm {
pub(crate) id: String,
pub(crate) cron_expression: String,
pub(crate) trigger: AlarmTrigger,
pub(crate) prompt: String,
pub(crate) run_once: bool,
pub(crate) delivery: AlarmDelivery,
pub(crate) now: DateTime<Utc>,
pub(crate) now: chrono::DateTime<Utc>,
}
#[derive(Debug, Default)]
@@ -91,17 +89,10 @@ pub(crate) struct AlarmsState {
#[derive(Debug)]
pub(crate) struct AlarmRuntime {
pub(crate) alarm: ThreadAlarm,
schedule: AlarmSchedule,
pending_run: bool,
pub(crate) timer_cancel: Option<CancellationToken>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AlarmSchedule {
AfterTurn,
EverySeconds(u64),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct PersistedAlarm {
pub(crate) alarm: ThreadAlarm,
@@ -110,54 +101,7 @@ pub(crate) struct PersistedAlarm {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AlarmTimerSpec {
pub(crate) seconds: u64,
pub(crate) initial_delay: std::time::Duration,
}
impl AlarmSchedule {
pub(crate) fn parse(cron_expression: &str) -> Result<Self, String> {
if cron_expression == AFTER_TURN_CRON_EXPRESSION {
return Ok(Self::AfterTurn);
}
if let Some(seconds) = cron_expression
.strip_prefix(EVERY_PREFIX)
.map(str::trim)
.and_then(parse_duration_literal)
{
return Self::parse_every_seconds(seconds, cron_expression);
}
if let Some(seconds) = cron_expression
.strip_prefix(EVERY_SECONDS_PREFIX)
.and_then(|raw| raw.parse::<u64>().ok())
.filter(|seconds| *seconds > 0)
{
return Self::parse_every_seconds(seconds, cron_expression);
}
Err(format!(
"unsupported cron_expression `{cron_expression}`; supported values are `{AFTER_TURN_CRON_EXPRESSION}`, `@every 5m`, or `@every:300`"
))
}
fn parse_every_seconds(seconds: u64, cron_expression: &str) -> Result<Self, String> {
if seconds > MAX_EVERY_SECONDS {
return Err(format!(
"unsupported cron_expression `{cron_expression}`; @every values must be between 1 and {MAX_EVERY_SECONDS} seconds"
));
}
Ok(Self::EverySeconds(seconds))
}
fn next_run_at(self, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
match self {
Self::AfterTurn => None,
Self::EverySeconds(seconds) => {
now.checked_add_signed(ChronoDuration::seconds(i64::try_from(seconds).ok()?))
}
}
}
pub(crate) delay: std::time::Duration,
}
impl AlarmsState {
@@ -197,7 +141,7 @@ impl AlarmsState {
&mut self,
create_alarm: CreateAlarm,
timer_cancel: Option<CancellationToken>,
) -> Result<ThreadAlarm, String> {
) -> Result<(ThreadAlarm, Option<AlarmTimerSpec>), String> {
if self.alarms.len() >= MAX_ACTIVE_ALARMS_PER_THREAD {
return Err(format!(
"too many active alarms; each thread supports at most {MAX_ACTIVE_ALARMS_PER_THREAD} alarms"
@@ -205,47 +149,41 @@ impl AlarmsState {
}
let CreateAlarm {
id,
cron_expression,
trigger,
prompt,
run_once,
delivery,
now,
} = create_alarm;
let schedule = AlarmSchedule::parse(&cron_expression)?;
let next_run_at = match schedule {
AlarmSchedule::AfterTurn => None,
AlarmSchedule::EverySeconds(_) => Some(schedule.next_run_at(now).ok_or_else(|| {
format!(
"unsupported cron_expression `{cron_expression}`; next run time is out of range"
)
})?),
};
let TriggerTiming {
trigger,
pending_run,
next_run_at,
timer_delay,
} = timing_for_new_trigger(trigger, now, now)?;
let alarm = ThreadAlarm {
id: id.clone(),
cron_expression,
trigger,
prompt,
run_once,
delivery,
created_at: now.timestamp(),
next_run_at: next_run_at.map(|value| value.timestamp()),
next_run_at,
last_run_at: None,
};
self.alarms.insert(
id,
AlarmRuntime {
alarm: alarm.clone(),
schedule,
pending_run: matches!(schedule, AlarmSchedule::AfterTurn),
pending_run,
timer_cancel,
},
);
Ok(alarm)
Ok((alarm, timer_delay.map(|delay| AlarmTimerSpec { delay })))
}
pub(crate) fn restore_alarm(
&mut self,
persisted: PersistedAlarm,
now: DateTime<Utc>,
now: chrono::DateTime<Utc>,
timer_cancel: Option<CancellationToken>,
) -> Result<Option<AlarmTimerSpec>, String> {
if self.alarms.len() >= MAX_ACTIVE_ALARMS_PER_THREAD {
@@ -253,46 +191,37 @@ impl AlarmsState {
"too many persisted alarms; each thread supports at most {MAX_ACTIVE_ALARMS_PER_THREAD} alarms"
));
}
let schedule = AlarmSchedule::parse(&persisted.alarm.cron_expression)?;
let mut alarm = persisted.alarm;
let mut pending_run = persisted.pending_run;
let timer_spec = match schedule {
AlarmSchedule::AfterTurn => {
alarm.next_run_at = None;
None
}
AlarmSchedule::EverySeconds(seconds) => {
let initial_delay = match alarm
.next_run_at
.and_then(|timestamp| Utc.timestamp_opt(timestamp, 0).single())
{
Some(next_run_at) if next_run_at > now => (next_run_at - now)
.to_std()
.map_err(|err| err.to_string())?,
_ => {
pending_run = true;
alarm.next_run_at =
schedule.next_run_at(now).map(|value| value.timestamp());
std::time::Duration::from_secs(seconds)
}
};
Some(AlarmTimerSpec {
seconds,
initial_delay,
})
}
let PersistedAlarm {
alarm,
pending_run: persisted_pending_run,
} = persisted;
let TriggerTiming {
trigger,
pending_run,
next_run_at,
timer_delay,
} = timing_for_restored_trigger(
alarm.trigger,
alarm.created_at,
persisted_pending_run,
alarm.next_run_at,
now,
)?;
let alarm = ThreadAlarm {
trigger,
next_run_at,
..alarm
};
let id = alarm.id.clone();
self.alarms.insert(
id,
AlarmRuntime {
alarm,
schedule,
pending_run,
timer_cancel,
},
);
Ok(timer_spec)
Ok(timer_delay.map(|delay| AlarmTimerSpec { delay }))
}
pub(crate) fn remove_alarm(&mut self, id: &str) -> Option<AlarmRuntime> {
@@ -309,37 +238,51 @@ impl AlarmsState {
}
}
pub(crate) fn mark_after_turn_alarms_due(&mut self) -> bool {
let mut changed = false;
for runtime in self.alarms.values_mut() {
if matches!(runtime.schedule, AlarmSchedule::AfterTurn) && !runtime.pending_run {
runtime.pending_run = true;
changed = true;
}
}
changed
}
pub(crate) fn mark_alarm_due(&mut self, id: &str, now: DateTime<Utc>) -> bool {
pub(crate) fn mark_alarm_due(&mut self, id: &str, now: chrono::DateTime<Utc>) -> bool {
let Some(runtime) = self.alarms.get_mut(id) else {
return false;
};
let mut changed = !runtime.pending_run;
runtime.pending_run = true;
let next_run_at = runtime
.schedule
.next_run_at(now)
.map(|value| value.timestamp());
if runtime.alarm.next_run_at != next_run_at {
runtime.alarm.next_run_at = next_run_at;
changed = true;
match next_run_after_due(&runtime.alarm.trigger, runtime.alarm.created_at, now) {
Ok(next_run_at) if runtime.alarm.next_run_at != next_run_at => {
runtime.alarm.next_run_at = next_run_at;
changed = true;
}
Ok(_) => {}
Err(err) => {
tracing::warn!(
"failed to advance alarm {} trigger: {err}",
runtime.alarm.id
);
}
}
changed
}
pub(crate) fn timer_spec_for_alarm(
&self,
id: &str,
now: chrono::DateTime<Utc>,
) -> Option<AlarmTimerSpec> {
let runtime = self.alarms.get(id)?;
let next_run_at = runtime.alarm.next_run_at?;
if runtime.pending_run && !runtime.alarm.trigger.is_recurring() {
return None;
}
Some(AlarmTimerSpec {
delay: if next_run_at <= now.timestamp() {
std::time::Duration::ZERO
} else {
let delay = u64::try_from(next_run_at - now.timestamp()).ok()?;
std::time::Duration::from_secs(delay)
},
})
}
pub(crate) fn claim_next_alarm(
&mut self,
now: DateTime<Utc>,
now: chrono::DateTime<Utc>,
can_after_turn: bool,
can_steer_current_turn: bool,
) -> Option<ClaimedAlarm> {
@@ -372,12 +315,12 @@ impl AlarmsState {
let runtime = self.alarms.remove(&next_alarm_id)?;
let AlarmRuntime {
mut alarm,
schedule,
pending_run: _,
timer_cancel,
} = runtime;
let deleted_run_once_alarm = alarm.run_once;
if deleted_run_once_alarm {
let is_recurring = alarm.trigger.is_recurring();
let deleted_one_shot_alarm = !is_recurring;
if deleted_one_shot_alarm {
if let Some(cancel) = timer_cancel.as_ref() {
cancel.cancel();
}
@@ -387,7 +330,6 @@ impl AlarmsState {
alarm.id.clone(),
AlarmRuntime {
alarm: alarm.clone(),
schedule,
pending_run: false,
timer_cancel,
},
@@ -397,21 +339,21 @@ impl AlarmsState {
alarm: alarm.clone(),
context: AlarmInvocationContext {
current_alarm_id: alarm.id,
cron_expression: alarm.cron_expression,
trigger: alarm.trigger,
prompt: alarm.prompt,
run_once: alarm.run_once,
recurring: is_recurring,
delivery: actual_delivery,
},
deleted_run_once_alarm,
deleted_one_shot_alarm,
})
}
}
pub(crate) fn alarm_prompt_input_item(alarm: &AlarmInvocationContext) -> ResponseInputItem {
let text = if alarm.run_once {
render_alarm_prompt_template(ONE_SHOT_ALARM_PROMPT, alarm)
} else {
let text = if alarm.recurring {
render_alarm_prompt_template(RECURRING_ALARM_PROMPT, alarm)
} else {
render_alarm_prompt_template(ONE_SHOT_ALARM_PROMPT, alarm)
};
ResponseInputItem::Message {
role: "developer".to_string(),
@@ -422,7 +364,7 @@ pub(crate) fn alarm_prompt_input_item(alarm: &AlarmInvocationContext) -> Respons
fn render_alarm_prompt_template(template: &str, alarm: &AlarmInvocationContext) -> String {
template
.replace("{{CURRENT_ALARM_ID}}", &alarm.current_alarm_id)
.replace("{{SCHEDULE}}", &alarm.cron_expression)
.replace("{{TRIGGER}}", &alarm.trigger.display())
.replace("{{PROMPT}}", &alarm.prompt)
.replace("{{DELIVERY}}", alarm.delivery.as_str())
.trim_end()
@@ -445,12 +387,25 @@ pub(crate) async fn load_alarm_sidecar(rollout_path: &Path) -> Result<Vec<Persis
));
}
};
serde_json::from_slice(&bytes).map_err(|err| {
let raw_alarms: Vec<serde_json::Value> = serde_json::from_slice(&bytes).map_err(|err| {
format!(
"failed to parse alarm sidecar `{}`: {err}",
sidecar_path.display()
)
})
})?;
let mut alarms = Vec::new();
for raw_alarm in raw_alarms {
match serde_json::from_value::<PersistedAlarm>(raw_alarm) {
Ok(alarm) => alarms.push(alarm),
Err(err) => {
tracing::warn!(
"skipping invalid persisted alarm from `{}`: {err}",
sidecar_path.display()
);
}
}
}
Ok(alarms)
}
pub(crate) async fn write_alarm_sidecar(
@@ -523,43 +478,19 @@ pub(crate) async fn write_alarm_sidecar(
}
}
fn parse_duration_literal(raw: &str) -> Option<u64> {
let mut digits = String::new();
let mut unit = String::new();
for ch in raw.chars() {
if ch.is_ascii_digit() && unit.is_empty() {
digits.push(ch);
} else if !ch.is_whitespace() {
unit.push(ch);
}
}
let value = digits.parse::<u64>().ok().filter(|value| *value > 0)?;
match unit.as_str() {
"s" | "sec" | "secs" | "second" | "seconds" => Some(value),
"m" | "min" | "mins" | "minute" | "minutes" => value.checked_mul(60),
"h" | "hr" | "hrs" | "hour" | "hours" => value.checked_mul(60 * 60),
"d" | "day" | "days" => value.checked_mul(60 * 60 * 24),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::AFTER_TURN_CRON_EXPRESSION;
use super::AlarmDelivery;
use super::AlarmInvocationContext;
use super::AlarmSchedule;
use super::AlarmsState;
use super::CreateAlarm;
use super::MAX_ACTIVE_ALARMS_PER_THREAD;
use super::MAX_EVERY_SECONDS;
use super::PersistedAlarm;
use super::alarm_prompt_input_item;
use super::alarm_sidecar_path_for_rollout;
use super::load_alarm_sidecar;
use super::write_alarm_sidecar;
use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use crate::alarm_trigger::AlarmTrigger;
use chrono::TimeZone;
use chrono::Utc;
use codex_protocol::models::ContentItem;
@@ -567,81 +498,31 @@ mod tests {
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[test]
fn parses_supported_alarm_schedules() {
assert_eq!(
AlarmSchedule::parse(AFTER_TURN_CRON_EXPRESSION),
Ok(AlarmSchedule::AfterTurn)
);
assert_eq!(
AlarmSchedule::parse("@every 5m"),
Ok(AlarmSchedule::EverySeconds(300))
);
assert_eq!(
AlarmSchedule::parse("@every:3600"),
Ok(AlarmSchedule::EverySeconds(3600))
);
const ZERO_SECONDS: u64 = 0;
const TEN_SECONDS: u64 = 10;
const SIXTY_SECONDS: u64 = 60;
fn delay(seconds: u64, repeat: Option<bool>) -> AlarmTrigger {
AlarmTrigger::Delay { seconds, repeat }
}
#[test]
fn rejects_overflowing_every_alarm_schedules() {
let too_large = MAX_EVERY_SECONDS + 1;
assert_eq!(
AlarmSchedule::parse(&format!("@every:{too_large}")),
Err(format!(
"unsupported cron_expression `@every:{too_large}`; @every values must be between 1 and {MAX_EVERY_SECONDS} seconds"
))
);
assert_eq!(
AlarmSchedule::parse(&format!("@every {too_large}s")),
Err(format!(
"unsupported cron_expression `@every {too_large}s`; @every values must be between 1 and {MAX_EVERY_SECONDS} seconds"
))
);
}
#[test]
fn create_alarm_rejects_every_schedule_when_next_run_at_is_out_of_range() {
let now = DateTime::<Utc>::MAX_UTC - ChronoDuration::seconds(1);
let mut alarms = AlarmsState::default();
let result = alarms.create_alarm(
CreateAlarm {
id: "alarm-1".to_string(),
cron_expression: "@every:2".to_string(),
prompt: "overflow".to_string(),
run_once: false,
delivery: AlarmDelivery::AfterTurn,
now,
},
/*timer_cancel*/ None,
);
assert_eq!(
result,
Err(
"unsupported cron_expression `@every:2`; next run time is out of range".to_string()
)
);
}
#[test]
fn claim_run_once_alarm_removes_it() {
fn claim_one_shot_alarm_removes_it() {
let now = Utc.timestamp_opt(100, 0).single().expect("valid timestamp");
let mut alarms = AlarmsState::default();
let alarm = alarms
let (alarm, timer_spec) = alarms
.create_alarm(
CreateAlarm {
id: "alarm-1".to_string(),
cron_expression: AFTER_TURN_CRON_EXPRESSION.to_string(),
trigger: delay(ZERO_SECONDS, /*repeat*/ None),
prompt: "run tests".to_string(),
run_once: true,
delivery: AlarmDelivery::AfterTurn,
now,
},
/*timer_cancel*/ None,
)
.expect("alarm should be created");
assert_eq!(timer_spec, None);
assert_eq!(alarms.list_alarms(), vec![alarm]);
let claimed = alarms
@@ -650,7 +531,7 @@ mod tests {
)
.expect("alarm should be claimed");
assert_eq!(claimed.context.current_alarm_id, "alarm-1");
assert!(claimed.deleted_run_once_alarm);
assert!(claimed.deleted_one_shot_alarm);
assert!(alarms.list_alarms().is_empty());
}
@@ -665,9 +546,8 @@ mod tests {
.create_alarm(
CreateAlarm {
id: "alarm-1".to_string(),
cron_expression: AFTER_TURN_CRON_EXPRESSION.to_string(),
trigger: delay(TEN_SECONDS, Some(true)),
prompt: "older recurring alarm".to_string(),
run_once: false,
delivery: AlarmDelivery::AfterTurn,
now: create_first,
},
@@ -678,15 +558,16 @@ mod tests {
.create_alarm(
CreateAlarm {
id: "alarm-2".to_string(),
cron_expression: AFTER_TURN_CRON_EXPRESSION.to_string(),
trigger: delay(TEN_SECONDS, Some(true)),
prompt: "newer recurring alarm".to_string(),
run_once: false,
delivery: AlarmDelivery::AfterTurn,
now: create_second,
},
/*timer_cancel*/ None,
)
.expect("alarm should be created");
alarms.mark_alarm_due("alarm-1", first_claimed_at);
alarms.mark_alarm_due("alarm-2", first_claimed_at);
let first = alarms
.claim_next_alarm(
@@ -697,8 +578,6 @@ mod tests {
.expect("first alarm should be claimed");
assert_eq!(first.context.current_alarm_id, "alarm-1");
alarms.mark_after_turn_alarms_due();
let second = alarms
.claim_next_alarm(
second_claimed_at,
@@ -718,9 +597,8 @@ mod tests {
.create_alarm(
CreateAlarm {
id: format!("alarm-{index}"),
cron_expression: AFTER_TURN_CRON_EXPRESSION.to_string(),
trigger: delay(SIXTY_SECONDS, Some(true)),
prompt: format!("prompt-{index}"),
run_once: false,
delivery: AlarmDelivery::AfterTurn,
now,
},
@@ -732,9 +610,8 @@ mod tests {
let result = alarms.create_alarm(
CreateAlarm {
id: "alarm-overflow".to_string(),
cron_expression: AFTER_TURN_CRON_EXPRESSION.to_string(),
trigger: delay(SIXTY_SECONDS, Some(true)),
prompt: "overflow".to_string(),
run_once: false,
delivery: AlarmDelivery::AfterTurn,
now,
},
@@ -753,9 +630,9 @@ mod tests {
fn alarm_prompt_input_is_hidden_developer_input() {
let item = alarm_prompt_input_item(&AlarmInvocationContext {
current_alarm_id: "alarm-1".to_string(),
cron_expression: "@every 10s".to_string(),
trigger: delay(TEN_SECONDS, Some(true)),
prompt: "run tests".to_string(),
run_once: false,
recurring: true,
delivery: AlarmDelivery::SteerCurrentTurn,
});
assert_eq!(
@@ -763,7 +640,7 @@ mod tests {
ResponseInputItem::Message {
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "Recurring scheduled alarm prompt:\nrun tests\n\ncurrentAlarmId: alarm-1\nConfigured delivery: steer-current-turn\nSchedule: @every 10s\n\nThis alarm should keep running on its schedule unless the user asked for a stopping condition and that condition is now satisfied.\nIf that stopping condition is satisfied, stop the alarm by calling AlarmDelete with {\"id\":\"alarm-1\"}.\nDo not expose scheduler internals unless they matter to the user.".to_string(),
text: "Recurring scheduled alarm prompt:\nrun tests\n\ncurrentAlarmId: alarm-1\nConfigured delivery: steer-current-turn\nTrigger: delay 10s, repeat\n\nThis alarm should keep running on its schedule unless the user asked for a stopping condition and that condition is now satisfied.\nIf that stopping condition is satisfied, stop the alarm by calling AlarmDelete with {\"id\":\"alarm-1\"}.\nDo not expose scheduler internals unless they matter to the user.".to_string(),
}],
}
);
@@ -773,9 +650,9 @@ mod tests {
fn one_shot_alarm_prompt_input_omits_delete_instruction() {
let item = alarm_prompt_input_item(&AlarmInvocationContext {
current_alarm_id: "alarm-1".to_string(),
cron_expression: "@after-turn".to_string(),
trigger: delay(ZERO_SECONDS, /*repeat*/ None),
prompt: "run tests once".to_string(),
run_once: true,
recurring: false,
delivery: AlarmDelivery::AfterTurn,
});
assert_eq!(
@@ -783,7 +660,7 @@ mod tests {
ResponseInputItem::Message {
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "One-shot scheduled alarm prompt:\nrun tests once\n\ncurrentAlarmId: alarm-1\nConfigured delivery: after-turn\nSchedule: @after-turn\n\nThis one-shot alarm has already been removed from the schedule, so you do not need to call AlarmDelete.\nDo not expose scheduler internals unless they matter to the user.".to_string(),
text: "One-shot scheduled alarm prompt:\nrun tests once\n\ncurrentAlarmId: alarm-1\nConfigured delivery: after-turn\nTrigger: delay 0s\n\nThis one-shot alarm has already been removed from the schedule, so you do not need to call AlarmDelete.\nDo not expose scheduler internals unless they matter to the user.".to_string(),
}],
}
);
@@ -796,9 +673,8 @@ mod tests {
let persisted = vec![PersistedAlarm {
alarm: super::ThreadAlarm {
id: "alarm-1".to_string(),
cron_expression: "@after-turn".to_string(),
trigger: delay(ZERO_SECONDS, /*repeat*/ None),
prompt: "run tests".to_string(),
run_once: false,
delivery: AlarmDelivery::AfterTurn,
created_at: 1,
next_run_at: None,
@@ -828,9 +704,8 @@ mod tests {
let original = vec![PersistedAlarm {
alarm: super::ThreadAlarm {
id: "alarm-1".to_string(),
cron_expression: "@after-turn".to_string(),
trigger: delay(ZERO_SECONDS, /*repeat*/ None),
prompt: "run tests".to_string(),
run_once: false,
delivery: AlarmDelivery::AfterTurn,
created_at: 1,
next_run_at: None,
@@ -841,9 +716,8 @@ mod tests {
let replacement = vec![PersistedAlarm {
alarm: super::ThreadAlarm {
id: "alarm-2".to_string(),
cron_expression: "@after-turn".to_string(),
trigger: delay(SIXTY_SECONDS, Some(true)),
prompt: "run different tests".to_string(),
run_once: true,
delivery: AlarmDelivery::SteerCurrentTurn,
created_at: 2,
next_run_at: None,
@@ -864,4 +738,51 @@ mod tests {
.expect("load overwritten sidecar");
assert_eq!(loaded, replacement);
}
#[tokio::test]
async fn alarm_sidecar_skips_invalid_entries() {
let tempdir = TempDir::new().expect("tempdir");
let rollout_path = tempdir.path().join("rollout.jsonl");
let sidecar_path = alarm_sidecar_path_for_rollout(&rollout_path);
tokio::fs::write(
&sidecar_path,
r#"[
{ "alarm": { "id": "old", "cronExpression": "@after-turn" }, "pending_run": true },
{
"alarm": {
"id": "alarm-1",
"trigger": { "kind": "delay", "seconds": 0, "repeat": null },
"prompt": "run tests",
"delivery": "after-turn",
"created_at": 1,
"next_run_at": null,
"last_run_at": null
},
"pending_run": true
}
]"#,
)
.await
.expect("write sidecar");
let loaded = load_alarm_sidecar(&rollout_path)
.await
.expect("load sidecar");
assert_eq!(
loaded,
vec![PersistedAlarm {
alarm: super::ThreadAlarm {
id: "alarm-1".to_string(),
trigger: delay(ZERO_SECONDS, /*repeat*/ None),
prompt: "run tests".to_string(),
delivery: AlarmDelivery::AfterTurn,
created_at: 1,
next_run_at: None,
last_run_at: None,
},
pending_run: true,
}]
);
}
}
+24 -45
View File
@@ -259,13 +259,13 @@ use crate::SkillsManager;
use crate::alarms::ALARM_FIRED_BACKGROUND_EVENT_PREFIX;
use crate::alarms::ALARM_UPDATED_BACKGROUND_EVENT_PREFIX;
use crate::alarms::AlarmDelivery;
use crate::alarms::AlarmSchedule;
use crate::alarms::AlarmTimerSpec;
use crate::alarms::AlarmsState;
use crate::alarms::ClaimedAlarm;
use crate::alarms::CreateAlarm;
use crate::alarms::PersistedAlarm;
use crate::alarms::ThreadAlarm;
use crate::alarms::ThreadAlarmTrigger;
use crate::alarms::alarm_prompt_input_item;
use crate::alarms::load_alarm_sidecar;
use crate::alarms::write_alarm_sidecar;
@@ -2700,9 +2700,8 @@ impl Session {
pub(crate) async fn create_alarm(
self: &Arc<Self>,
cron_expression: String,
trigger: ThreadAlarmTrigger,
prompt: String,
run_once: bool,
delivery: AlarmDelivery,
) -> Result<ThreadAlarm, String> {
self.ensure_rollout_materialized().await;
@@ -2710,22 +2709,19 @@ impl Session {
return Err("alarms require a persisted rollout path for restoration".to_string());
};
let schedule = AlarmSchedule::parse(&cron_expression)?;
let timer_cancel =
matches!(schedule, AlarmSchedule::EverySeconds(_)).then(CancellationToken::new);
let timer_cancel = CancellationToken::new();
let id = uuid::Uuid::new_v4().to_string();
let alarm = {
let (alarm, timer_spec) = {
let mut alarms = self.alarms.lock().await;
alarms.create_alarm(
CreateAlarm {
id: id.clone(),
cron_expression,
trigger,
prompt,
run_once,
delivery,
now: Utc::now(),
},
timer_cancel.clone(),
Some(timer_cancel.clone()),
)?
};
if let Err(err) = self.persist_alarms_to_rollout_sidecar(&rollout_path).await {
@@ -2735,15 +2731,8 @@ impl Session {
return Err(err);
}
if let (AlarmSchedule::EverySeconds(seconds), Some(cancel)) = (schedule, timer_cancel) {
self.spawn_alarm_timer(
id,
AlarmTimerSpec {
seconds,
initial_delay: std::time::Duration::from_secs(seconds),
},
cancel,
);
if let Some(timer_spec) = timer_spec {
self.spawn_alarm_timer(id, timer_spec, timer_cancel);
}
self.emit_alarm_updated_notification().await;
self.maybe_start_pending_alarm().await;
@@ -2767,25 +2756,18 @@ impl Session {
Ok(true)
}
pub(crate) async fn mark_after_turn_alarms_due(&self) {
let changed = self.alarms.lock().await.mark_after_turn_alarms_due();
if changed {
self.persist_alarms_sidecar_best_effort().await;
}
}
pub(crate) async fn maybe_start_pending_alarm(self: &Arc<Self>) {
let Some(ClaimedAlarm {
alarm,
context,
deleted_run_once_alarm,
deleted_one_shot_alarm,
}) = self.claim_next_alarm_for_delivery().await
else {
return;
};
self.emit_alarm_fired_notification(&alarm).await;
if deleted_run_once_alarm {
if deleted_one_shot_alarm {
self.emit_alarm_updated_notification().await;
}
self.persist_alarms_sidecar_best_effort().await;
@@ -2869,7 +2851,7 @@ impl Session {
let weak = Arc::downgrade(self);
let session_cancel = self.alarm_timers_cancellation_token.clone();
tokio::spawn(async move {
let mut delay = timer_spec.initial_delay;
let mut delay = timer_spec.delay;
loop {
tokio::select! {
_ = session_cancel.cancelled() => break,
@@ -2884,7 +2866,15 @@ impl Session {
session.persist_alarms_sidecar_best_effort().await;
}
session.maybe_start_pending_alarm().await;
delay = std::time::Duration::from_secs(timer_spec.seconds);
let next_timer_spec = session
.alarms
.lock()
.await
.timer_spec_for_alarm(&id, Utc::now());
let Some(next_timer_spec) = next_timer_spec else {
break;
};
delay = next_timer_spec.delay;
}
});
}
@@ -2939,24 +2929,13 @@ impl Session {
let mut normalized = Vec::<PersistedAlarm>::new();
for persisted_alarm in persisted {
let schedule = match AlarmSchedule::parse(&persisted_alarm.alarm.cron_expression) {
Ok(schedule) => schedule,
Err(err) => {
warn!(
"skipping invalid persisted alarm {}: {err}",
persisted_alarm.alarm.id
);
continue;
}
};
let timer_cancel =
matches!(schedule, AlarmSchedule::EverySeconds(_)).then(CancellationToken::new);
let timer_cancel = CancellationToken::new();
let timer_spec = {
let mut alarms = self.alarms.lock().await;
match alarms.restore_alarm(
persisted_alarm.clone(),
Utc::now(),
timer_cancel.clone(),
Some(timer_cancel.clone()),
) {
Ok(timer_spec) => {
normalized = alarms.persisted_alarms();
@@ -2971,8 +2950,8 @@ impl Session {
}
}
};
if let (Some(timer_spec), Some(cancel)) = (timer_spec, timer_cancel) {
self.spawn_alarm_timer(persisted_alarm.alarm.id.clone(), timer_spec, cancel);
if let Some(timer_spec) = timer_spec {
self.spawn_alarm_timer(persisted_alarm.alarm.id.clone(), timer_spec, timer_cancel);
}
}
+19 -8
View File
@@ -1,6 +1,7 @@
use super::*;
use crate::alarms::AlarmDelivery;
use crate::alarms::AlarmsState;
use crate::alarms::ThreadAlarmTrigger;
use crate::alarms::load_alarm_sidecar;
use crate::alarms::write_alarm_sidecar;
use crate::config::ConfigBuilder;
@@ -3677,9 +3678,11 @@ async fn maybe_start_pending_alarm_claims_only_one_alarm_while_start_is_in_progr
.create_alarm(
crate::alarms::CreateAlarm {
id: "alarm-1".to_string(),
cron_expression: crate::alarms::AFTER_TURN_CRON_EXPRESSION.to_string(),
trigger: ThreadAlarmTrigger::Delay {
seconds: 10,
repeat: Some(true),
},
prompt: "first".to_string(),
run_once: false,
delivery: AlarmDelivery::AfterTurn,
now,
},
@@ -3690,15 +3693,19 @@ async fn maybe_start_pending_alarm_claims_only_one_alarm_while_start_is_in_progr
.create_alarm(
crate::alarms::CreateAlarm {
id: "alarm-2".to_string(),
cron_expression: crate::alarms::AFTER_TURN_CRON_EXPRESSION.to_string(),
trigger: ThreadAlarmTrigger::Delay {
seconds: 10,
repeat: Some(true),
},
prompt: "second".to_string(),
run_once: false,
delivery: AlarmDelivery::AfterTurn,
now,
},
/*timer_cancel*/ None,
)
.expect("second alarm should be created");
alarms.mark_alarm_due("alarm-1", now);
alarms.mark_alarm_due("alarm-2", now);
}
let first = Arc::clone(&session);
@@ -3730,9 +3737,11 @@ async fn alarm_sidecar_persistence_serializes_stale_and_newer_snapshots() {
.create_alarm(
crate::alarms::CreateAlarm {
id: "alarm-1".to_string(),
cron_expression: crate::alarms::AFTER_TURN_CRON_EXPRESSION.to_string(),
trigger: ThreadAlarmTrigger::Delay {
seconds: 10,
repeat: Some(true),
},
prompt: "first".to_string(),
run_once: false,
delivery: AlarmDelivery::AfterTurn,
now,
},
@@ -3769,9 +3778,11 @@ async fn alarm_sidecar_persistence_serializes_stale_and_newer_snapshots() {
.create_alarm(
crate::alarms::CreateAlarm {
id: "alarm-2".to_string(),
cron_expression: crate::alarms::AFTER_TURN_CRON_EXPRESSION.to_string(),
trigger: ThreadAlarmTrigger::Delay {
seconds: 10,
repeat: Some(true),
},
prompt: "second".to_string(),
run_once: false,
delivery: AlarmDelivery::AfterTurn,
now,
},
+3 -3
View File
@@ -28,6 +28,7 @@ use tokio::sync::watch;
use crate::alarms::AlarmDelivery;
use crate::alarms::ThreadAlarm;
use crate::alarms::ThreadAlarmTrigger;
use codex_rollout::state_db::StateDbHandle;
#[derive(Clone, Debug)]
@@ -242,14 +243,13 @@ impl CodexThread {
pub async fn create_alarm(
&self,
cron_expression: String,
trigger: ThreadAlarmTrigger,
prompt: String,
run_once: bool,
delivery: AlarmDelivery,
) -> Result<ThreadAlarm, String> {
self.codex
.session
.create_alarm(cron_expression, prompt, run_once, delivery)
.create_alarm(trigger, prompt, delivery)
.await
}
+1
View File
@@ -28,6 +28,7 @@ pub mod connectors;
mod context_manager;
mod contextual_user_message;
pub use codex_utils_path::env;
mod alarm_trigger;
pub mod alarms;
mod environment_context;
pub mod exec;
+1 -1
View File
@@ -29,7 +29,7 @@ pub struct ShellSnapshot {
pub cwd: PathBuf,
}
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(20);
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
const SNAPSHOT_RETENTION: Duration = Duration::from_secs(60 * 60 * 24 * 3); // 3 days retention.
const SNAPSHOT_DIR: &str = "shell_snapshots";
const EXCLUDED_EXPORT_VARS: &[&str] = &["PWD", "OLDPWD"];
-1
View File
@@ -519,7 +519,6 @@ impl Session {
let session = Arc::clone(self);
let _scheduler = tokio::task::spawn_blocking(move || {
tokio::runtime::Handle::current().block_on(async move {
session.mark_after_turn_alarms_due().await;
session.maybe_start_pending_alarm().await;
if should_clear_active_turn {
session.maybe_start_turn_for_pending_work().await;
+3 -8
View File
@@ -6,6 +6,7 @@
use serde::Deserialize;
use crate::alarms::AlarmDelivery;
use crate::alarms::ThreadAlarmTrigger;
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
@@ -16,9 +17,8 @@ use crate::tools::registry::ToolKind;
#[derive(Deserialize)]
struct AlarmCreateArgs {
cron_expression: String,
trigger: ThreadAlarmTrigger,
prompt: String,
run_once: Option<bool>,
delivery: AlarmDelivery,
}
@@ -45,12 +45,7 @@ impl ToolHandler for AlarmCreateHandler {
let args: AlarmCreateArgs = parse_arguments(&arguments)?;
let alarm = invocation
.session
.create_alarm(
args.cron_expression,
args.prompt,
args.run_once.unwrap_or(false),
args.delivery,
)
.create_alarm(args.trigger, args.prompt, args.delivery)
.await
.map_err(FunctionCallError::RespondToModel)?;
let content = serde_json::to_string(&alarm).map_err(|err| {
@@ -3,7 +3,7 @@ One-shot scheduled alarm prompt:
currentAlarmId: {{CURRENT_ALARM_ID}}
Configured delivery: {{DELIVERY}}
Schedule: {{SCHEDULE}}
Trigger: {{TRIGGER}}
This one-shot alarm has already been removed from the schedule, so you do not need to call AlarmDelete.
Do not expose scheduler internals unless they matter to the user.
@@ -3,7 +3,7 @@ Recurring scheduled alarm prompt:
currentAlarmId: {{CURRENT_ALARM_ID}}
Configured delivery: {{DELIVERY}}
Schedule: {{SCHEDULE}}
Trigger: {{TRIGGER}}
This alarm should keep running on its schedule unless the user asked for a stopping condition and that condition is now satisfied.
If that stopping condition is satisfied, stop the alarm by calling AlarmDelete with {"id":"{{CURRENT_ALARM_ID}}"}.
+6 -12
View File
@@ -1,9 +1,9 @@
use anyhow::Result;
use anyhow::anyhow;
use codex_core::alarms::AFTER_TURN_CRON_EXPRESSION;
use codex_core::alarms::ALARM_FIRED_BACKGROUND_EVENT_PREFIX;
use codex_core::alarms::AlarmDelivery;
use codex_core::alarms::ThreadAlarm;
use codex_core::alarms::ThreadAlarmTrigger;
use codex_features::Feature;
use codex_protocol::protocol::EventMsg;
use core_test_support::responses::ev_assistant_message;
@@ -50,9 +50,11 @@ async fn assert_after_turn_alarm_starts_and_emits_fired_event() -> Result<()> {
let created = test
.codex
.create_alarm(
AFTER_TURN_CRON_EXPRESSION.to_string(),
ThreadAlarmTrigger::Delay {
seconds: 0,
repeat: None,
},
"run alarm".to_string(),
/*run_once*/ false,
AlarmDelivery::AfterTurn,
)
.await
@@ -67,15 +69,7 @@ async fn assert_after_turn_alarm_starts_and_emits_fired_event() -> Result<()> {
})
.await;
let fired: ThreadAlarm = serde_json::from_str(&payload)?;
let expected = ThreadAlarm {
last_run_at: Some(
fired
.last_run_at
.ok_or_else(|| anyhow!("claimed alarm should record last_run_at"))?,
),
..created
};
assert_eq!(fired, expected);
assert_eq!(fired, created);
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
-59
View File
@@ -456,65 +456,6 @@ async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Resul
Ok(())
}
#[tokio::test]
async fn list_threads_includes_alarm_only_sessions_without_user_messages() -> std::io::Result<()> {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let uuid = Uuid::from_u128(9014);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let path = write_session_file_without_user_message(home.path(), "2025-01-03T14-00-00", uuid)?;
fs::write(format!("{}.alarms.json", path.display()), "[]")?;
let runtime = codex_state::StateRuntime::init(
home.path().to_path_buf(),
config.model_provider_id.clone(),
)
.await
.expect("state db should initialize");
runtime
.mark_backfill_complete(/*last_watermark*/ None)
.await
.expect("backfill should be complete");
let created_at = chrono::Utc
.with_ymd_and_hms(2025, 1, 3, 14, 0, 0)
.single()
.expect("valid datetime");
let mut builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
path.clone(),
created_at,
SessionSource::Cli,
);
builder.model_provider = Some(config.model_provider_id.clone());
builder.cwd = home.path().to_path_buf();
let metadata = builder.build(config.model_provider_id.as_str());
runtime
.upsert_thread(&metadata)
.await
.expect("state db upsert should succeed");
let default_provider = config.model_provider_id.clone();
let page = RolloutRecorder::list_threads(
&config,
/*page_size*/ 1,
/*cursor*/ None,
ThreadSortKey::CreatedAt,
&[],
/*model_providers*/ None,
default_provider.as_str(),
/*search_term*/ None,
)
.await?;
assert_eq!(page.items.len(), 1);
assert_eq!(page.items[0].path, path);
assert_eq!(
page.items[0].first_user_message.as_deref(),
Some("(alarm configured)")
);
Ok(())
}
#[tokio::test]
async fn list_threads_db_enabled_fills_page_after_filtering_empty_threads() -> std::io::Result<()> {
let home = TempDir::new().expect("temp dir");
+36 -71
View File
@@ -1,4 +1,3 @@
use crate::alarm_sidecar::thread_preview_from_alarm_sidecar;
use crate::config::RolloutConfig;
use crate::config::RolloutConfigView;
use crate::list::Cursor;
@@ -227,81 +226,47 @@ pub async fn list_threads_db(
})
.collect();
let model_providers = model_providers.map(<[String]>::to_vec);
let db_sort_key = match sort_key {
ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt,
ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt,
};
let mut db_anchor = anchor;
let mut valid_items = Vec::with_capacity(page_size);
let mut next_anchor = None;
let mut num_scanned_rows = 0;
loop {
let remaining = page_size.saturating_sub(valid_items.len());
if remaining == 0 {
break;
}
let mut page = match ctx
.list_threads(
remaining,
db_anchor.as_ref(),
db_sort_key,
allowed_sources.as_slice(),
model_providers.as_deref(),
archived,
search_term,
)
.await
{
Ok(page) => page,
Err(err) => {
warn!("state db list_threads failed: {err}");
return None;
}
};
num_scanned_rows += page.num_scanned_rows;
let page_next_anchor = page.next_anchor.take();
for mut item in page.items {
if tokio::fs::try_exists(&item.rollout_path)
.await
.unwrap_or(false)
{
let missing_preview = item.first_user_message.as_deref().is_none_or(str::is_empty);
if missing_preview {
if let Some(alarm_preview) =
thread_preview_from_alarm_sidecar(&item.rollout_path).await
{
item.first_user_message = Some(alarm_preview);
} else {
continue;
}
match ctx
.list_threads(
page_size,
anchor.as_ref(),
match sort_key {
ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt,
ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt,
},
allowed_sources.as_slice(),
model_providers.as_deref(),
archived,
search_term,
)
.await
{
Ok(mut page) => {
let mut valid_items = Vec::with_capacity(page.items.len());
for item in page.items {
if tokio::fs::try_exists(&item.rollout_path)
.await
.unwrap_or(false)
{
valid_items.push(item);
} else {
warn!(
"state db list_threads returned stale rollout path for thread {}: {}",
item.id,
item.rollout_path.display()
);
warn!("state db discrepancy during list_threads_db: stale_db_path_dropped");
let _ = ctx.delete_thread(item.id).await;
}
valid_items.push(item);
} else {
warn!(
"state db list_threads returned stale rollout path for thread {}: {}",
item.id,
item.rollout_path.display()
);
warn!("state db discrepancy during list_threads_db: stale_db_path_dropped");
let _ = ctx.delete_thread(item.id).await;
}
page.items = valid_items;
Some(page)
}
if valid_items.len() == page_size {
next_anchor = page_next_anchor;
break;
}
match page_next_anchor {
Some(anchor) => db_anchor = Some(anchor),
None => break,
Err(err) => {
warn!("state db list_threads failed: {err}");
None
}
}
Some(codex_state::ThreadsPage {
items: valid_items,
next_anchor,
num_scanned_rows,
})
}
/// Look up the rollout path for a thread id using SQLite.
+1
View File
@@ -883,6 +883,7 @@ pub(super) fn push_thread_filters<'a>(
} else {
builder.push(" AND archived = 0");
}
builder.push(" AND first_user_message <> ''");
if !allowed_sources.is_empty() {
builder.push(" AND source IN (");
let mut separated = builder.separated(", ");
+40 -16
View File
@@ -10,31 +10,56 @@ use crate::ToolSpec;
use std::collections::BTreeMap;
pub fn create_alarm_create_tool() -> ToolSpec {
let properties = BTreeMap::from([
let trigger_properties = BTreeMap::from([
(
"cron_expression".to_string(),
"kind".to_string(),
JsonSchema::String {
description: Some("Trigger kind. Use `delay` or `schedule`.".to_string()),
},
),
(
"seconds".to_string(),
JsonSchema::Number {
description: Some("Delay trigger seconds from creation time.".to_string()),
},
),
(
"repeat".to_string(),
JsonSchema::Boolean {
description: Some("Delay trigger recurrence flag.".to_string()),
},
),
(
"dtstart".to_string(),
JsonSchema::String {
description: Some(
"Scheduler expression for the alarm. Supported values are scheduler-specific."
"Schedule trigger floating local datetime in YYYY-MM-DDTHH:MM:SS format."
.to_string(),
),
},
),
(
"rrule".to_string(),
JsonSchema::String {
description: Some("Schedule trigger RRULE string.".to_string()),
},
),
]);
let properties = BTreeMap::from([
(
"trigger".to_string(),
JsonSchema::Object {
properties: trigger_properties,
required: Some(vec!["kind".to_string()]),
additional_properties: Some(false.into()),
},
),
(
"prompt".to_string(),
JsonSchema::String {
description: Some("Prompt to execute when the alarm fires.".to_string()),
},
),
(
"run_once".to_string(),
JsonSchema::Boolean {
description: Some(
"Optional. When true, delete the alarm after its next execution is claimed."
.to_string(),
),
},
),
(
"delivery".to_string(),
JsonSchema::String {
@@ -48,15 +73,14 @@ pub fn create_alarm_create_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "AlarmCreate".to_string(),
description:
"Create a thread alarm using a structured scheduler expression, prompt, and delivery mode."
.to_string(),
description: "Create a thread alarm using a structured trigger, prompt, and delivery mode."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec![
"cron_expression".to_string(),
"trigger".to_string(),
"prompt".to_string(),
"delivery".to_string(),
]),
+300 -163
View File
@@ -1,151 +1,121 @@
use crate::AppServerTarget;
use crate::start_app_server_for_picker;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::AlarmDelivery;
use codex_app_server_protocol::AlarmTrigger;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::TurnStatus;
use codex_core::config::Config;
use regex_lite::Regex;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::user_input::UserInput;
use serde::Deserialize;
use std::sync::LazyLock;
use serde_json::Value;
use serde_json::json;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct ParsedAlarmSpec {
pub(crate) cron_expression: String,
pub(crate) trigger: AlarmTrigger,
pub(crate) prompt: String,
pub(crate) run_once: Option<bool>,
pub(crate) delivery: AlarmDelivery,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ParsedAlarmSpecResponse {
cron_expression: String,
prompt: String,
run_once: Option<bool>,
delivery: AlarmDelivery,
}
impl ParsedAlarmSpecResponse {
fn into_parsed(self) -> std::result::Result<ParsedAlarmSpec, String> {
let cron_expression = self.cron_expression.trim().to_string();
let prompt = self.prompt.trim().to_string();
if cron_expression.is_empty() {
return Err("Could not determine a supported schedule from /loop input.".to_string());
}
if prompt.is_empty() {
return Err("Could not determine a prompt from /loop input.".to_string());
}
Ok(ParsedAlarmSpec {
cron_expression,
prompt,
run_once: self.run_once,
delivery: self.delivery,
})
}
}
static EVERY_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\bevery\s+(\d+)\s*(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b")
.unwrap_or_else(|err| panic!("valid recurring alarm parser regex: {err}"))
});
static RUN_ONCE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\b(run once|once|one-shot|one shot|single-run|single run|just once)\b")
.unwrap_or_else(|err| panic!("valid one-shot alarm parser regex: {err}"))
});
static STEER_CURRENT_TURN_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)\b(as a steer|steer (?:the )?current turn|steer this turn|during (?:the )?current turn|during this turn|in (?:the )?current turn)\b",
)
.unwrap_or_else(|err| panic!("valid steer alarm parser regex: {err}"))
});
pub(crate) async fn parse_alarm_spec(
_config: Config,
_target: crate::AppServerTarget,
config: Config,
target: AppServerTarget,
spec: String,
) -> std::result::Result<ParsedAlarmSpec, String> {
parse_alarm_spec_text(&spec)
}
fn parse_alarm_spec_text(spec: &str) -> std::result::Result<ParsedAlarmSpec, String> {
let trimmed = spec.trim();
if trimmed.is_empty() {
if spec.trim().is_empty() {
return Err("Could not determine a prompt from /loop input.".to_string());
}
let delivery = if STEER_CURRENT_TURN_PATTERN.is_match(trimmed) {
AlarmDelivery::SteerCurrentTurn
} else {
AlarmDelivery::AfterTurn
};
let run_once = RUN_ONCE_PATTERN.is_match(trimmed).then_some(true);
let (cron_expression, prompt) = if let Some(captures) = EVERY_PATTERN.captures(trimmed) {
let quantity = captures.get(1).map(|m| m.as_str()).ok_or_else(|| {
"Could not determine a supported schedule from /loop input.".to_string()
})?;
let unit = captures.get(2).map(|m| m.as_str()).ok_or_else(|| {
"Could not determine a supported schedule from /loop input.".to_string()
})?;
let schedule = captures.get(0).ok_or_else(|| {
"Could not determine a supported schedule from /loop input.".to_string()
})?;
let prompt = remove_match(trimmed, schedule.start(), schedule.end());
(
format!("@every {quantity}{}", normalized_unit_suffix(unit)?),
clean_prompt_text(&prompt),
let user_prompt = build_user_prompt(&spec);
let mut app_server = start_app_server_for_picker(&config, &target)
.await
.map_err(|err| format!("failed to start alarm spec parser: {err}"))?;
let started = app_server
.start_ephemeral_thread_with_base_instructions(
&config,
PARSE_ALARM_SYSTEM_PROMPT.to_string(),
)
} else {
("@after-turn".to_string(), trimmed.to_string())
};
ParsedAlarmSpecResponse {
cron_expression,
prompt,
run_once,
delivery,
}
.into_parsed()
}
fn normalized_unit_suffix(unit: &str) -> std::result::Result<char, String> {
match unit.to_ascii_lowercase().as_str() {
"s" | "second" | "seconds" | "sec" | "secs" => Ok('s'),
"m" | "minute" | "minutes" | "min" | "mins" => Ok('m'),
"h" | "hour" | "hours" | "hr" | "hrs" => Ok('h'),
"d" | "day" | "days" => Ok('d'),
_ => Err("Could not determine a supported schedule from /loop input.".to_string()),
}
}
fn remove_match(text: &str, start: usize, end: usize) -> String {
let prefix = text[..start].trim_end();
let suffix = text[end..].trim_start_matches([' ', ',', ':', ';', '-']);
match (prefix.is_empty(), suffix.is_empty()) {
(true, true) => String::new(),
(true, false) => suffix.to_string(),
(false, true) => prefix.to_string(),
(false, false) => format!("{prefix} {suffix}"),
}
}
fn clean_prompt_text(prompt: &str) -> String {
prompt
.trim()
.trim_start_matches([',', ':', ';', '-'])
.trim()
.to_string()
.await
.map_err(|err| format!("failed to start alarm spec parser thread: {err}"))?;
let thread_id = started.session.thread_id;
let thread_id_string = thread_id.to_string();
let response = app_server
.turn_start(
thread_id,
vec![UserInput::Text {
text: user_prompt,
text_elements: Vec::new(),
}],
started.session.cwd,
started.session.approval_policy,
started.session.approvals_reviewer,
started.session.sandbox_policy,
started.session.model,
started.session.reasoning_effort,
config.model_reasoning_summary,
Some(config.service_tier),
/*collaboration_mode*/ None,
config.personality,
Some(output_schema()),
)
.await
.map_err(|err| format!("failed to parse alarm spec with model: {err}"))?;
let turn_id = response.turn.id;
let result = wait_for_parser_response(&mut app_server, thread_id_string, turn_id).await?;
let parsed: ParsedAlarmSpec = serde_json::from_str(&result)
.map_err(|err| format!("model returned invalid alarm parse output: {err}"))?;
validate_parsed_alarm_spec(parsed)
}
pub(crate) fn format_alarm_summary(
cron_expression: &str,
run_once: bool,
trigger: &AlarmTrigger,
delivery: AlarmDelivery,
prompt: &str,
) -> String {
let mode = if run_once { "one-shot" } else { "recurring" };
let mode = if trigger_is_recurring(trigger) {
"recurring"
} else {
"one-shot"
};
format!(
"{cron_expression} ({mode}, {}) -> {prompt}",
"{} ({mode}, {}) -> {prompt}",
format_alarm_trigger(trigger),
delivery_str(delivery)
)
}
pub(crate) fn format_alarm_trigger(trigger: &AlarmTrigger) -> String {
match trigger {
AlarmTrigger::Delay { seconds, repeat } => {
let suffix = if repeat.unwrap_or(false) {
", repeat"
} else {
""
};
format!("delay {seconds}s{suffix}")
}
AlarmTrigger::Schedule { dtstart, rrule } => match (dtstart, rrule) {
(Some(dtstart), Some(rrule)) => format!("schedule {dtstart}; {rrule}"),
(Some(dtstart), None) => format!("schedule {dtstart}"),
(None, Some(rrule)) => format!("schedule {rrule}"),
(None, None) => "invalid schedule".to_string(),
},
}
}
pub(crate) fn trigger_is_recurring(trigger: &AlarmTrigger) -> bool {
match trigger {
AlarmTrigger::Delay { repeat, .. } => repeat.unwrap_or(false),
AlarmTrigger::Schedule { rrule, .. } => {
rrule.as_ref().is_some_and(|rrule| !rrule.is_empty())
}
}
}
fn delivery_str(delivery: AlarmDelivery) -> &'static str {
match delivery {
AlarmDelivery::AfterTurn => "after-turn",
@@ -153,57 +123,224 @@ fn delivery_str(delivery: AlarmDelivery) -> &'static str {
}
}
fn build_user_prompt(spec: &str) -> String {
let now = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S");
let timezone = chrono::Local::now().offset().to_string();
format!("Current local datetime: {now}\nTimezone: {timezone}\nAlarm spec: {spec}")
}
async fn wait_for_parser_response(
app_server: &mut crate::app_server_session::AppServerSession,
thread_id: String,
turn_id: String,
) -> std::result::Result<String, String> {
let mut last_agent_message = None;
loop {
let event =
tokio::time::timeout(Duration::from_secs(/*secs*/ 120), app_server.next_event())
.await
.map_err(|_| "timed out while waiting for alarm spec parser".to_string())?
.ok_or_else(|| {
"alarm spec parser disconnected before returning output".to_string()
})?;
match event {
AppServerEvent::ServerNotification(ServerNotification::ItemCompleted(notification))
if notification.thread_id == thread_id && notification.turn_id == turn_id =>
{
if let Some(text) = thread_item_agent_text(&notification.item) {
last_agent_message = Some(text);
}
}
AppServerEvent::ServerNotification(ServerNotification::RawResponseItemCompleted(
notification,
)) if notification.thread_id == thread_id && notification.turn_id == turn_id => {
if let Some(text) = response_item_agent_text(&notification.item) {
last_agent_message = Some(text);
}
}
AppServerEvent::ServerNotification(ServerNotification::TurnCompleted(notification))
if notification.thread_id == thread_id && notification.turn.id == turn_id =>
{
if matches!(notification.turn.status, TurnStatus::Failed)
&& let Some(error) = notification.turn.error
{
return Err(format!("alarm spec parser failed: {}", error.message));
}
return last_agent_message.ok_or_else(|| {
"alarm spec parser did not return an agent message".to_string()
});
}
AppServerEvent::ServerNotification(_) | AppServerEvent::Lagged { .. } => {}
AppServerEvent::ServerRequest(_) => {
return Err("alarm spec parser unexpectedly requested user input".to_string());
}
AppServerEvent::Disconnected { message } => {
return Err(format!("alarm spec parser disconnected: {message}"));
}
}
}
}
fn thread_item_agent_text(item: &ThreadItem) -> Option<String> {
match item {
ThreadItem::AgentMessage { text, .. } if !text.trim().is_empty() => Some(text.clone()),
ThreadItem::AgentMessage { .. }
| ThreadItem::UserMessage { .. }
| ThreadItem::Reasoning { .. }
| ThreadItem::Plan { .. }
| ThreadItem::McpToolCall { .. }
| ThreadItem::WebSearch { .. }
| ThreadItem::DynamicToolCall { .. }
| ThreadItem::CommandExecution { .. }
| ThreadItem::FileChange { .. }
| ThreadItem::ImageView { .. }
| ThreadItem::ImageGeneration { .. }
| ThreadItem::HookPrompt { .. }
| ThreadItem::CollabAgentToolCall { .. }
| ThreadItem::EnteredReviewMode { .. }
| ThreadItem::ExitedReviewMode { .. }
| ThreadItem::ContextCompaction { .. } => None,
}
}
fn response_item_agent_text(item: &ResponseItem) -> Option<String> {
match item {
ResponseItem::Message { role, content, .. } if role == "assistant" => {
let text = content
.iter()
.filter_map(|content| match content {
ContentItem::OutputText { text } => Some(text.as_str()),
ContentItem::InputText { .. } | ContentItem::InputImage { .. } => None,
})
.collect::<String>();
(!text.trim().is_empty()).then_some(text)
}
ResponseItem::Message { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::Other => None,
}
}
fn validate_parsed_alarm_spec(
parsed: ParsedAlarmSpec,
) -> std::result::Result<ParsedAlarmSpec, String> {
if parsed.prompt.trim().is_empty() {
return Err("model did not return an alarm prompt".to_string());
}
Ok(ParsedAlarmSpec {
prompt: parsed.prompt.trim().to_string(),
..parsed
})
}
fn output_schema() -> Value {
json!({
"type": "object",
"properties": {
"trigger": {
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["delay", "schedule"] },
"seconds": { "type": ["integer", "null"], "minimum": 0 },
"repeat": { "type": ["boolean", "null"] },
"dtstart": { "type": ["string", "null"] },
"rrule": { "type": ["string", "null"] }
},
"required": ["kind", "seconds", "repeat", "dtstart", "rrule"],
"additionalProperties": false
},
"prompt": { "type": "string" },
"delivery": { "type": "string", "enum": ["after-turn", "steer-current-turn"] }
},
"required": ["trigger", "prompt", "delivery"],
"additionalProperties": false
})
}
const PARSE_ALARM_SYSTEM_PROMPT: &str = r#"Parse Codex `/loop` alarm specs into a structured alarm definition.
Return only the JSON object requested by the response schema.
Rules:
- Extract the alarm prompt by removing the scheduling phrase but preserving the user's requested task.
- Use delivery "after-turn" unless the user clearly asks for same-turn/current-turn steering; then use "steer-current-turn".
- For "now", "immediately", or specs with no explicit timing, use { "kind": "delay", "seconds": 0, "repeat": false }.
- For delay triggers, set dtstart and rrule to null.
- For schedule triggers, set seconds and repeat to null.
- For relative one-shot timing like "in 30 seconds", use a delay trigger with seconds set to the relative delay and repeat false.
- For interval timing like "every 5 minutes", use a delay trigger with seconds set to the interval and repeat true.
- For one-shot wall-clock timing like "at 9pm", use a schedule trigger with dtstart set to the next matching local datetime in YYYY-MM-DDTHH:MM:SS and rrule null.
- For recurring calendar timing, use a schedule trigger with rrule set to an RFC 5545 RRULE string and dtstart set when the user supplies a start datetime; otherwise null.
- For schedule triggers, use floating local wall-clock datetimes without timezone suffixes.
"#;
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn parse_alarm_spec_defaults_to_after_turn_without_interval() {
fn format_alarm_summary_renders_delay() {
assert_eq!(
parse_alarm_spec_text("give me a random animal name").expect("parse succeeds"),
format_alarm_summary(
&AlarmTrigger::Delay {
seconds: 30,
repeat: Some(false),
},
AlarmDelivery::AfterTurn,
"remind me to take a break",
),
"delay 30s (one-shot, after-turn) -> remind me to take a break"
);
}
#[test]
fn parser_output_schema_avoids_unsupported_union_keywords() {
let schema = output_schema();
assert_eq!(schema.pointer("/properties/trigger/oneOf"), None);
assert_eq!(schema.pointer("/properties/trigger/anyOf"), None);
assert_eq!(
schema.pointer("/properties/trigger/properties/kind/enum"),
Some(&json!(["delay", "schedule"]))
);
}
#[test]
fn parsed_alarm_spec_accepts_permissive_delay_trigger_shape() {
let parsed: ParsedAlarmSpec = serde_json::from_value(json!({
"trigger": {
"kind": "delay",
"seconds": 10,
"repeat": false,
"dtstart": null,
"rrule": null
},
"prompt": "tell me a joke",
"delivery": "after-turn"
}))
.expect("permissive parser schema output should deserialize");
assert_eq!(
parsed,
ParsedAlarmSpec {
cron_expression: "@after-turn".to_string(),
prompt: "give me a random animal name".to_string(),
run_once: None,
trigger: AlarmTrigger::Delay {
seconds: 10,
repeat: Some(false),
},
prompt: "tell me a joke".to_string(),
delivery: AlarmDelivery::AfterTurn,
}
);
}
#[test]
fn parse_alarm_spec_extracts_supported_recurring_interval() {
assert_eq!(
parse_alarm_spec_text("every 10 seconds give me an inspirational affirmation")
.expect("parse succeeds"),
ParsedAlarmSpec {
cron_expression: "@every 10s".to_string(),
prompt: "give me an inspirational affirmation".to_string(),
run_once: None,
delivery: AlarmDelivery::AfterTurn,
}
);
}
#[test]
fn parse_alarm_spec_detects_run_once_and_steer_delivery() {
assert_eq!(
parse_alarm_spec_text("once during this turn remind me to rebase")
.expect("parse succeeds"),
ParsedAlarmSpec {
cron_expression: "@after-turn".to_string(),
prompt: "once during this turn remind me to rebase".to_string(),
run_once: Some(true),
delivery: AlarmDelivery::SteerCurrentTurn,
}
);
}
#[test]
fn parse_alarm_spec_rejects_missing_prompt_after_schedule() {
assert_eq!(
parse_alarm_spec_text("every 10 seconds").expect_err("parse should fail"),
"Could not determine a prompt from /loop input.".to_string()
);
}
}
+3 -8
View File
@@ -4261,21 +4261,16 @@ impl App {
Ok(parsed) => match app_server
.thread_alarm_create(
thread_id,
parsed.cron_expression.clone(),
parsed.trigger.clone(),
parsed.prompt.clone(),
parsed.run_once,
parsed.delivery,
)
.await
{
Ok(alarm) => {
if self.chat_widget.thread_id() == Some(thread_id) {
let summary = format_alarm_summary(
&alarm.cron_expression,
alarm.run_once,
alarm.delivery,
&alarm.prompt,
);
let summary =
format_alarm_summary(&alarm.trigger, alarm.delivery, &alarm.prompt);
self.chat_widget.add_info_message(
format!("Created thread alarm from `/loop {spec}`."),
Some(summary),
+25 -4
View File
@@ -7,6 +7,7 @@ use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AlarmDelivery;
use codex_app_server_protocol::AlarmTrigger;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigBatchWriteParams;
@@ -320,6 +321,28 @@ impl AppServerSession {
started_thread_from_start_response(response, config).await
}
pub(crate) async fn start_ephemeral_thread_with_base_instructions(
&mut self,
config: &Config,
base_instructions: String,
) -> Result<AppServerStartedThread> {
let request_id = self.next_request_id();
let mut params = thread_start_params_from_config(
config,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
);
params.ephemeral = Some(true);
params.persist_extended_history = false;
params.base_instructions = Some(base_instructions);
let response: ThreadStartResponse = self
.client
.request_typed(ClientRequest::ThreadStart { request_id, params })
.await
.wrap_err("thread/start failed during alarm spec parsing")?;
started_thread_from_start_response(response, config).await
}
pub(crate) async fn resume_thread(
&mut self,
config: Config,
@@ -421,9 +444,8 @@ impl AppServerSession {
pub(crate) async fn thread_alarm_create(
&mut self,
thread_id: ThreadId,
cron_expression: String,
trigger: AlarmTrigger,
prompt: String,
run_once: Option<bool>,
delivery: AlarmDelivery,
) -> Result<ThreadAlarm> {
let request_id = self.next_request_id();
@@ -433,9 +455,8 @@ impl AppServerSession {
request_id,
params: ThreadAlarmCreateParams {
thread_id: thread_id.to_string(),
cron_expression,
trigger,
prompt,
run_once,
delivery,
},
})
+10 -12
View File
@@ -41,6 +41,8 @@ use std::time::Instant;
use url::Url;
use self::realtime::PendingSteerCompareKey;
use crate::alarm_scheduler::format_alarm_trigger;
use crate::alarm_scheduler::trigger_is_recurring;
use crate::app_command::AppCommand;
use crate::app_event::RealtimeAudioDeviceKind;
use crate::app_server_approval_conversions::network_approval_context_to_core;
@@ -9952,16 +9954,11 @@ impl ChatWidget {
codex_app_server_protocol::AlarmDelivery::AfterTurn => "after-turn",
codex_app_server_protocol::AlarmDelivery::SteerCurrentTurn => "steer-current-turn",
};
let schedule = if alarm.run_once {
format!(
"Running thread alarm • {} • one-shot • {}",
alarm.cron_expression, delivery
)
let trigger = format_alarm_trigger(&alarm.trigger);
let schedule = if trigger_is_recurring(&alarm.trigger) {
format!("Running thread alarm • {trigger} • {delivery}")
} else {
format!(
"Running thread alarm • {} • {}",
alarm.cron_expression, delivery
)
format!("Running thread alarm • {trigger} • one-shot • {delivery}")
};
self.add_info_message(alarm.prompt, Some(schedule));
}
@@ -9990,10 +9987,11 @@ impl ChatWidget {
id: alarm_id.clone(),
});
})];
let name = if alarm.run_once {
format!("{} • one-shot", alarm.cron_expression)
let trigger = format_alarm_trigger(&alarm.trigger);
let name = if trigger_is_recurring(&alarm.trigger) {
trigger
} else {
alarm.cron_expression.clone()
format!("{trigger} • one-shot")
};
let selected_description =
format!("{}\n\nPress Enter to delete this alarm.", alarm.prompt);
+10 -5
View File
@@ -1,5 +1,6 @@
use super::*;
use codex_app_server_protocol::AlarmDelivery;
use codex_app_server_protocol::AlarmTrigger;
use codex_app_server_protocol::ThreadAlarm;
use codex_app_server_protocol::ThreadAlarmFiredNotification;
use insta::assert_snapshot;
@@ -13,9 +14,11 @@ async fn thread_alarm_fired_renders_prompt_history() {
thread_id: ThreadId::new().to_string(),
alarm: ThreadAlarm {
id: "alarm-1".to_string(),
cron_expression: "@after-turn".to_string(),
trigger: AlarmTrigger::Delay {
seconds: 0,
repeat: None,
},
prompt: "Give me a random animal name.".to_string(),
run_once: false,
delivery: AlarmDelivery::AfterTurn,
created_at: 0,
next_run_at: None,
@@ -27,7 +30,7 @@ async fn thread_alarm_fired_renders_prompt_history() {
let cells = drain_insert_history(&mut rx);
let rendered = lines_to_single_string(&cells[0]);
assert_snapshot!(rendered, @"• Give me a random animal name. Running thread alarm • @after-turn • after-turn
assert_snapshot!(rendered, @"• Give me a random animal name. Running thread alarm • delay 0s • one-shot • after-turn
");
}
@@ -39,9 +42,11 @@ async fn thread_alarms_popup_keeps_selected_alarm_prompt_visible() {
ThreadId::new(),
vec![ThreadAlarm {
id: "alarm-1".to_string(),
cron_expression: "@after-turn".to_string(),
trigger: AlarmTrigger::Delay {
seconds: 0,
repeat: None,
},
prompt: "Give me a random animal name.".to_string(),
run_once: false,
delivery: AlarmDelivery::AfterTurn,
created_at: 0,
next_run_at: None,
@@ -5,7 +5,7 @@ expression: popup
Thread alarms
Review an alarm, then press Enter to delete it.
› 1. @after-turn Give me a random animal name.
Press Enter to delete this alarm.
› 1. delay 0s • one-shot Give me a random animal name.
Press Enter to delete this alarm.
Press enter to confirm or esc to go back