Add thread message delivery

This commit is contained in:
Eric Traut
2026-04-12 13:59:42 -07:00
parent a4d5112b37
commit 54f5a4b0fa
51 changed files with 5358 additions and 40 deletions
+4
View File
@@ -700,6 +700,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\"]}}",
@@ -1166,7 +1167,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\":{}}",
@@ -1268,6 +1271,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\"]}}",
"rtrb_0.3.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}",
"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\"]}}",
+44 -1
View File
@@ -1253,6 +1253,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"
@@ -1897,6 +1907,7 @@ dependencies = [
"base64 0.22.1",
"bm25",
"chrono",
"chrono-tz",
"clap",
"codex-analytics",
"codex-api",
@@ -1974,6 +1985,7 @@ dependencies = [
"regex-lite",
"reqwest",
"rmcp",
"rrule",
"serde",
"serde_json",
"serial_test",
@@ -7527,6 +7539,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"
@@ -7536,6 +7557,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"
@@ -8739,6 +8769,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"
@@ -10097,7 +10140,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"phf_shared 0.11.3",
"precomputed-hash",
]
+2
View File
@@ -210,6 +210,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"
@@ -277,6 +278,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 = [
+297
View File
@@ -17,6 +17,13 @@ use codex_cli::run_login_with_chatgpt;
use codex_cli::run_login_with_device_code;
use codex_cli::run_logout;
use codex_cloud_tasks::Cli as CloudTasksCli;
use codex_core::messages::MessagePayload;
use codex_core::messages::validate_meta_key;
use codex_core::timers::ThreadTimerStorageCreateParams;
use codex_core::timers::ThreadTimerTrigger;
use codex_core::timers::TimerDelivery;
use codex_core::timers::build_thread_timer_create_params;
use codex_core::timers::normalize_thread_timer_dtstart_input;
use codex_exec::Cli as ExecCli;
use codex_exec::Command as ExecCommand;
use codex_exec::ReviewArgs;
@@ -30,7 +37,9 @@ use codex_tui::ExitReason;
use codex_tui::UpdateAction;
use codex_utils_cli::CliConfigOverrides;
use owo_colors::OwoColorize;
use std::collections::BTreeMap;
use std::io::IsTerminal;
use std::path::Path;
use std::path::PathBuf;
use supports_color::Stream;
@@ -53,6 +62,7 @@ use codex_core::config::find_codex_home;
use codex_features::FEATURES;
use codex_features::Stage;
use codex_features::is_known_feature_key;
use codex_protocol::ThreadId;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::user_input::UserInput;
use codex_terminal_detection::TerminalName;
@@ -140,6 +150,9 @@ enum Subcommand {
/// Resume a previous interactive session (picker by default; use --last to continue the most recent).
Resume(ResumeCommand),
/// Queue a message to an existing thread.
Queue(QueueCommand),
/// Fork a previous interactive session (picker by default; use --last to fork the most recent).
Fork(ForkCommand),
@@ -217,6 +230,33 @@ struct DebugPromptInputCommand {
images: Vec<PathBuf>,
}
#[derive(Debug, Parser)]
struct QueueCommand {
/// Target thread id.
#[arg(long = "thread", value_name = "THREAD_ID")]
thread: String,
/// Message content.
#[arg(long = "content", value_name = "TEXT")]
content: String,
/// Optional instructions for immediate messages. Not allowed with --at.
#[arg(long = "instructions", value_name = "TEXT")]
instructions: Option<String>,
/// Message metadata as key=value. May be repeated.
#[arg(long = "meta", value_name = "KEY=VALUE")]
meta: Vec<String>,
/// Deliver by steering the current turn when possible.
#[arg(long = "steer")]
steer: bool,
/// Queue a one-shot timer for a local datetime or time, e.g. 2026-04-10T09:30:00 or 09:30.
#[arg(long = "at", value_name = "WHEN")]
at: Option<String>,
}
#[derive(Debug, Parser)]
struct ResumeCommand {
/// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses.
@@ -807,6 +847,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
.await?;
handle_app_exit(exit_info)?;
}
Some(Subcommand::Queue(queue_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"queue",
)?;
run_queue_command(queue_cli, &root_config_overrides, &interactive).await?;
}
Some(Subcommand::Fork(ForkCommand {
session_id,
last,
@@ -1274,6 +1322,137 @@ async fn run_debug_clear_memories_command(
Ok(())
}
async fn run_queue_command(
cmd: QueueCommand,
root_config_overrides: &CliConfigOverrides,
interactive: &TuiCli,
) -> anyhow::Result<()> {
validate_queue_command(&cmd)?;
let meta = parse_queue_meta(&cmd.meta)?;
let cli_kv_overrides = root_config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let overrides = ConfigOverrides {
config_profile: interactive.config_profile.clone(),
..Default::default()
};
let config =
Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?;
let thread_id = resolve_queue_thread_id(config.codex_home.as_path(), &cmd.thread).await?;
let state_db =
StateRuntime::init(config.sqlite_home.clone(), config.model_provider_id.clone()).await?;
let delivery = if cmd.steer {
TimerDelivery::SteerCurrentTurn
} else {
TimerDelivery::AfterTurn
};
if let Some(at) = cmd.at {
let dtstart = normalize_thread_timer_dtstart_input(&at).map_err(anyhow::Error::msg)?;
let timer_params = build_thread_timer_create_params(ThreadTimerStorageCreateParams {
thread_id,
source: "external".to_string(),
client_id: "codex-cli".to_string(),
trigger: ThreadTimerTrigger::Schedule {
dtstart: Some(dtstart.clone()),
rrule: None,
},
payload: MessagePayload {
content: cmd.content,
instructions: None,
meta,
},
delivery,
})
.map_err(anyhow::Error::msg)?;
state_db.create_thread_timer(&timer_params).await?;
println!(
"{}",
queue_timer_success_message(
&timer_params.id,
&timer_params.thread_id,
&dtstart,
timer_params.pending_run,
)
);
return Ok(());
}
let meta_json = serde_json::to_string(&meta)?;
let message_params = codex_state::ThreadMessageCreateParams::new(
thread_id,
"external".to_string(),
cmd.content,
cmd.instructions,
meta_json,
delivery.as_str().to_string(),
unix_timestamp_now()?,
);
state_db.create_thread_message(&message_params).await?;
println!(
"Queued message {} for thread {}.",
message_params.id, message_params.thread_id
);
Ok(())
}
fn queue_timer_success_message(
timer_id: &str,
thread_id: &str,
dtstart: &str,
pending_run: bool,
) -> String {
if pending_run {
format!("Queued timer {timer_id} for thread {thread_id}; it is due now.")
} else {
let local_time = dtstart.replace('T', " ");
format!(
"Queued timer {timer_id} for thread {thread_id}; it will fire at {local_time} local time."
)
}
}
async fn resolve_queue_thread_id(codex_home: &Path, target: &str) -> anyhow::Result<String> {
if let Ok(thread_id) = ThreadId::from_string(target) {
return Ok(thread_id.to_string());
}
let thread_ids = codex_core::find_thread_ids_by_name(codex_home, target).await?;
match thread_ids.as_slice() {
[] => anyhow::bail!("no thread named `{target}`"),
[thread_id] => Ok(thread_id.to_string()),
_ => anyhow::bail!("more than one thread is named `{target}`; use a thread id instead"),
}
}
fn validate_queue_command(cmd: &QueueCommand) -> anyhow::Result<()> {
if cmd.at.is_some() && cmd.instructions.is_some() {
anyhow::bail!("--instructions is not supported with --at");
}
Ok(())
}
fn unix_timestamp_now() -> anyhow::Result<i64> {
let duration = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|err| anyhow::anyhow!("system clock is before unix epoch: {err}"))?;
i64::try_from(duration.as_secs()).map_err(|_| anyhow::anyhow!("current time is out of range"))
}
fn parse_queue_meta(entries: &[String]) -> anyhow::Result<BTreeMap<String, String>> {
let mut meta = BTreeMap::new();
for entry in entries {
let Some((key, value)) = entry.split_once('=') else {
anyhow::bail!("metadata entry `{entry}` must use key=value syntax");
};
validate_meta_key(key).map_err(anyhow::Error::msg)?;
if meta.insert(key.to_string(), value.to_string()).is_some() {
anyhow::bail!("duplicate metadata key `{key}`");
}
}
Ok(meta)
}
/// Prepend root-level overrides so they have lower precedence than
/// CLI-specific ones specified after the subcommand (if any).
fn prepend_config_flags(
@@ -1517,6 +1696,7 @@ mod tests {
use codex_protocol::ThreadId;
use codex_protocol::protocol::TokenUsage;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn finalize_resume_from_args(args: &[&str]) -> TuiCli {
let cli = MultitoolCli::try_parse_from(args).expect("parse");
@@ -1575,6 +1755,123 @@ mod tests {
finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli)
}
#[test]
fn queue_command_parses_immediate_message() {
let cli = MultitoolCli::try_parse_from([
"codex",
"queue",
"--thread",
"thread-1",
"--content",
"do work",
"--instructions",
"be brief",
"--meta",
"ticket=ABC_123",
"--steer",
])
.expect("parse");
let Some(Subcommand::Queue(cmd)) = cli.subcommand else {
unreachable!()
};
assert_eq!(cmd.thread, "thread-1");
assert_eq!(cmd.content, "do work");
assert_eq!(cmd.instructions, Some("be brief".to_string()));
assert_eq!(cmd.meta, vec!["ticket=ABC_123".to_string()]);
assert!(cmd.steer);
assert_eq!(cmd.at, None);
}
#[test]
fn queue_meta_rejects_invalid_and_duplicate_keys() {
assert!(parse_queue_meta(&["bad-key=value".to_string()]).is_err());
assert!(parse_queue_meta(&["ticket=one".to_string(), "ticket=two".to_string()]).is_err());
}
#[test]
fn queue_at_rejects_instructions() {
let cli = MultitoolCli::try_parse_from([
"codex",
"queue",
"--thread",
"thread-1",
"--content",
"do work",
"--instructions",
"be brief",
"--at",
"2026-04-10T12:00:00",
])
.expect("parse");
let Some(Subcommand::Queue(cmd)) = cli.subcommand else {
unreachable!()
};
let err = validate_queue_command(&cmd).expect_err("queue command should be rejected");
assert_eq!(err.to_string(), "--instructions is not supported with --at");
}
#[test]
fn queued_timer_output_reports_fire_time() {
assert_eq!(
queue_timer_success_message("timer-1", "thread-1", "2026-04-10T08:59:00", false),
"Queued timer timer-1 for thread thread-1; it will fire at 2026-04-10 08:59:00 local time."
);
}
#[test]
fn queued_timer_output_reports_due_now() {
assert_eq!(
queue_timer_success_message("timer-1", "thread-1", "2026-04-10T08:59:00", true),
"Queued timer timer-1 for thread thread-1; it is due now."
);
}
#[tokio::test]
async fn queue_thread_resolves_thread_name() {
let temp = TempDir::new().expect("tempdir");
let thread_id = ThreadId::new();
codex_core::append_thread_name(temp.path(), thread_id, "named-thread")
.await
.expect("append thread name");
assert_eq!(
resolve_queue_thread_id(temp.path(), "named-thread")
.await
.expect("resolve"),
thread_id.to_string()
);
}
#[tokio::test]
async fn queue_thread_name_rejects_missing_and_ambiguous_names() {
let temp = TempDir::new().expect("tempdir");
let first = ThreadId::new();
let second = ThreadId::new();
codex_core::append_thread_name(temp.path(), first, "same")
.await
.expect("append first name");
codex_core::append_thread_name(temp.path(), second, "same")
.await
.expect("append second name");
assert_eq!(
resolve_queue_thread_id(temp.path(), "missing")
.await
.expect_err("missing name should fail")
.to_string(),
"no thread named `missing`"
);
assert_eq!(
resolve_queue_thread_id(temp.path(), "same")
.await
.expect_err("ambiguous name should fail")
.to_string(),
"more than one thread is named `same`; use a thread id instead"
);
}
#[test]
fn exec_resume_last_accepts_prompt_positional() {
let cli =
+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",
+6
View File
@@ -485,6 +485,9 @@
"steer": {
"type": "boolean"
},
"timer_tool": {
"type": "boolean"
},
"tool_call_mcp_elicitation": {
"type": "boolean"
},
@@ -2334,6 +2337,9 @@
"steer": {
"type": "boolean"
},
"timer_tool": {
"type": "boolean"
},
"tool_call_mcp_elicitation": {
"type": "boolean"
},
+52 -2
View File
@@ -4,6 +4,7 @@ use std::fmt::Debug;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
@@ -201,6 +202,9 @@ use codex_protocol::exec_output::StreamOutput;
mod rollout_reconstruction;
#[cfg(test)]
mod rollout_reconstruction_tests;
mod timer_runtime;
#[cfg(test)]
mod timer_runtime_tests;
#[derive(Debug, PartialEq)]
pub enum SteerInputError {
@@ -306,6 +310,7 @@ use crate::tasks::GhostSnapshotTask;
use crate::tasks::ReviewTask;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::timers::TimersState;
use crate::tools::ToolRouter;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::js_repl::JsReplHandle;
@@ -837,15 +842,27 @@ pub(crate) struct Session {
pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>,
pub(crate) conversation: Arc<RealtimeConversationManager>,
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
/// Prevents concurrent timers from claiming multiple timers before a
/// newly started turn becomes the active turn.
timer_start_in_progress: Mutex<bool>,
timer_db_sync_started: AtomicBool,
mailbox: Mailbox,
mailbox_rx: Mutex<MailboxReceiver>,
idle_pending_input: Mutex<Vec<ResponseInputItem>>, // TODO (jif) merge with mailbox!
timers: Mutex<TimersState>,
timer_tasks_cancellation_token: CancellationToken,
pub(crate) guardian_review_session: GuardianReviewSessionManager,
pub(crate) services: SessionServices,
js_repl: Arc<JsReplHandle>,
next_internal_sub_id: AtomicU64,
}
impl Drop for Session {
fn drop(&mut self) {
self.timer_tasks_cancellation_token.cancel();
}
}
#[derive(Clone, Debug)]
pub(crate) struct TurnSkillsContext {
pub(crate) outcome: Arc<SkillLoadOutcome>,
@@ -2074,9 +2091,13 @@ impl Session {
pending_mcp_server_refresh_config: Mutex::new(None),
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
timer_start_in_progress: Mutex::new(false),
timer_db_sync_started: AtomicBool::new(false),
mailbox,
mailbox_rx: Mutex::new(mailbox_rx),
idle_pending_input: Mutex::new(Vec::new()),
timers: Mutex::new(TimersState::default()),
timer_tasks_cancellation_token: CancellationToken::new(),
guardian_review_session: GuardianReviewSessionManager::default(),
services,
js_repl,
@@ -2114,6 +2135,7 @@ impl Session {
for event in events {
sess.send_event_raw(event).await;
}
sess.restore_timers_from_db().await;
// Start the watcher after SessionConfigured so it cannot emit earlier events.
sess.start_skills_watcher_listener();
@@ -2227,6 +2249,36 @@ impl Session {
self.services.state_db.clone()
}
async fn timer_state_db(&self) -> Result<state_db::StateDbHandle, String> {
if let Some(state_db) = self.state_db() {
return Ok(state_db);
}
let config = {
let state = self.state.lock().await;
state
.session_configuration
.original_config_do_not_use
.clone()
};
match codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.model_provider_id.clone(),
)
.await
{
Ok(runtime) => Ok(runtime),
Err(err) => {
let message = format!(
"failed to initialize SQLite state db for timers at {}: {err}",
config.sqlite_home.display()
);
warn!("{message}");
Err(message)
}
}
}
/// Flush rollout writes and return the final durability-barrier result.
pub(crate) async fn flush_rollout(&self) -> std::io::Result<()> {
let recorder = {
@@ -4317,8 +4369,6 @@ impl Session {
}
}
/// Queue response items to be injected into the next active turn created for this session.
#[cfg(test)]
pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec<ResponseInputItem>) {
if items.is_empty() {
return;
+733
View File
@@ -0,0 +1,733 @@
use super::BackgroundEventEvent;
use super::Event;
use super::EventMsg;
use super::INITIAL_SUBMIT_ID;
use super::Session;
use crate::messages::MessageInvocationContext;
use crate::messages::MessagePayload;
use crate::messages::ThreadMessage;
use crate::messages::db_message_to_thread_message;
use crate::messages::message_prompt_input_item;
use crate::messages::validate_meta;
use crate::timers::ClaimedTimer;
use crate::timers::CreateTimer;
use crate::timers::PersistedTimer;
use crate::timers::RestoredTimerTask;
use crate::timers::TIMER_FIRED_BACKGROUND_EVENT_PREFIX;
use crate::timers::TIMER_UPDATED_BACKGROUND_EVENT_PREFIX;
use crate::timers::ThreadTimer;
use crate::timers::ThreadTimerTrigger;
use crate::timers::TimerDelivery;
use crate::timers::TimerTaskSpec;
use crate::timers::TimersState;
use crate::timers::timer_prompt_input_item;
use chrono::Utc;
use codex_features::Feature;
use codex_protocol::models::ResponseInputItem;
use codex_rollout::state_db;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
use tracing::warn;
const TIMER_SOURCE_AGENT: &str = "agent";
const TIMER_CLIENT_ID_FALLBACK: &str = "codex-cli";
const TIMER_DB_SYNC_INTERVAL: Duration = Duration::from_secs(15);
const TIMER_DB_MAX_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
fn db_timer_to_persisted_timer(row: codex_state::ThreadTimer) -> Option<PersistedTimer> {
let trigger = match serde_json::from_str(&row.trigger_json) {
Ok(trigger) => trigger,
Err(err) => {
warn!("skipping invalid persisted timer {} trigger: {err}", row.id);
return None;
}
};
let delivery =
match serde_json::from_value::<TimerDelivery>(serde_json::Value::String(row.delivery)) {
Ok(delivery) => delivery,
Err(err) => {
warn!(
"skipping invalid persisted timer {} delivery: {err}",
row.id
);
return None;
}
};
let meta = match serde_json::from_str(&row.meta_json) {
Ok(meta) => meta,
Err(err) => {
warn!(
"skipping invalid persisted timer {} metadata: {err}",
row.id
);
return None;
}
};
if let Err(err) = validate_meta(&meta) {
warn!(
"skipping invalid persisted timer {} metadata: {err}",
row.id
);
return None;
}
Some(PersistedTimer {
timer: ThreadTimer {
id: row.id,
trigger,
content: row.content,
instructions: row.instructions,
meta,
delivery,
created_at: row.created_at,
next_run_at: row.next_run_at,
last_run_at: row.last_run_at,
},
pending_run: row.pending_run,
})
}
impl Session {
pub(crate) async fn list_timers(self: &Arc<Self>) -> Vec<ThreadTimer> {
self.sync_timers_from_db(/*emit_update*/ false).await;
self.list_timers_from_memory().await
}
async fn list_timers_from_memory(&self) -> Vec<ThreadTimer> {
self.timers.lock().await.list_timers()
}
pub(crate) async fn create_timer(
self: &Arc<Self>,
trigger: ThreadTimerTrigger,
payload: MessagePayload,
delivery: TimerDelivery,
) -> Result<ThreadTimer, String> {
validate_meta(&payload.meta)?;
self.ensure_rollout_materialized().await;
let state_db = self.timer_state_db().await?;
self.start_timer_db_sync_task(state_db.clone());
let timer_cancel = CancellationToken::new();
let id = uuid::Uuid::new_v4().to_string();
let (timer, persisted_timer, timer_spec) = {
let mut timers = self.timers.lock().await;
let (timer, timer_spec) = timers.create_timer(
CreateTimer {
id: id.clone(),
trigger,
payload,
delivery,
now: Utc::now(),
},
Some(timer_cancel.clone()),
)?;
let persisted_timer = timers
.persisted_timer(&id)
.ok_or_else(|| format!("created timer {id} was not stored in memory"))?;
(timer, persisted_timer, timer_spec)
};
let params = self
.thread_timer_create_params(&persisted_timer, TIMER_SOURCE_AGENT)
.await?;
if let Err(err) = state_db.create_thread_timer(&params).await {
if let Some(runtime) = self.timers.lock().await.remove_timer(&id) {
TimersState::cancel_runtime(&runtime);
}
return Err(format!("failed to persist timer to sqlite: {err}"));
}
if let Some(timer_spec) = timer_spec {
self.spawn_timer_task(id, timer_spec, timer_cancel);
}
self.emit_timer_updated_notification().await;
self.maybe_start_pending_timer().await;
Ok(timer)
}
pub(crate) async fn delete_timer(self: &Arc<Self>, id: &str) -> Result<bool, String> {
self.sync_timers_from_db(/*emit_update*/ false).await;
let state_db = self.timer_state_db().await?;
self.start_timer_db_sync_task(state_db.clone());
let runtime = self.timers.lock().await.remove_timer(id);
let deleted = match state_db
.delete_thread_timer(&self.thread_id_string(), id)
.await
{
Ok(deleted) => deleted,
Err(err) => {
if let Some(runtime) = runtime {
self.timers.lock().await.restore_runtime(runtime);
}
return Err(format!("failed to delete timer from sqlite: {err}"));
}
};
let Some(runtime) = runtime else {
return Ok(deleted);
};
if !deleted {
self.timers.lock().await.restore_runtime(runtime);
return Ok(false);
}
TimersState::cancel_runtime(&runtime);
self.emit_timer_updated_notification().await;
Ok(true)
}
pub(crate) async fn queue_message_to_thread(
self: &Arc<Self>,
thread_id: String,
payload: MessagePayload,
delivery: TimerDelivery,
) -> Result<ThreadMessage, String> {
validate_meta(&payload.meta)?;
let state_db = self.timer_state_db().await?;
self.start_timer_db_sync_task(state_db.clone());
let MessagePayload {
content,
instructions,
meta,
} = payload;
let params = codex_state::ThreadMessageCreateParams::new(
thread_id,
format!("thread {}", self.thread_id_string()),
content,
instructions,
serde_json::to_string(&meta)
.map_err(|err| format!("failed to serialize message metadata: {err}"))?,
delivery.as_str().to_string(),
Utc::now().timestamp(),
);
state_db
.create_thread_message(&params)
.await
.map_err(|err| format!("failed to queue message in sqlite: {err}"))?;
Ok(ThreadMessage {
id: params.id,
thread_id: params.thread_id,
source: params.source,
content: params.content,
instructions: params.instructions,
meta,
delivery,
queued_at: params.queued_at,
})
}
pub(crate) async fn maybe_start_pending_timer(self: &Arc<Self>) {
if !self.try_start_pending_timer().await {
self.maybe_start_pending_message().await;
}
}
async fn try_start_pending_timer(self: &Arc<Self>) -> bool {
let Some(ClaimedTimer {
timer,
context,
deleted_one_shot_timer,
}) = self.claim_next_timer_for_delivery().await
else {
return false;
};
self.emit_timer_fired_notification(&timer).await;
if deleted_one_shot_timer {
self.emit_timer_updated_notification().await;
}
let input_item = timer_prompt_input_item(&context);
match context.delivery {
TimerDelivery::SteerCurrentTurn => {
if !self.inject_timer_into_active_turn(input_item.clone()).await {
self.queue_response_items_for_next_turn(vec![input_item])
.await;
self.maybe_start_turn_for_pending_work().await;
}
}
TimerDelivery::AfterTurn => {
self.queue_response_items_for_next_turn(vec![input_item])
.await;
self.maybe_start_turn_for_pending_work().await;
}
}
*self.timer_start_in_progress.lock().await = false;
true
}
async fn maybe_start_pending_message(self: &Arc<Self>) {
let Some((input_item, delivery)) = self.claim_next_message_for_delivery().await else {
return;
};
match delivery {
TimerDelivery::SteerCurrentTurn => {
if !self
.inject_message_into_active_turn(input_item.clone())
.await
{
self.queue_response_items_for_next_turn(vec![input_item])
.await;
self.maybe_start_turn_for_pending_work().await;
}
}
TimerDelivery::AfterTurn => {
self.queue_response_items_for_next_turn(vec![input_item])
.await;
self.maybe_start_turn_for_pending_work().await;
}
}
*self.timer_start_in_progress.lock().await = false;
}
async fn claim_next_message_for_delivery(
self: &Arc<Self>,
) -> Option<(ResponseInputItem, TimerDelivery)> {
if !self.features.enabled(Feature::TimerScheduler) {
return None;
}
let mut timer_start_in_progress = self.timer_start_in_progress.lock().await;
if *timer_start_in_progress {
return None;
}
*timer_start_in_progress = true;
drop(timer_start_in_progress);
let has_pending_turn_inputs = self.has_queued_response_items_for_next_turn().await
|| self.has_trigger_turn_mailbox_items().await;
let (has_active_turn, active_turn_is_regular) = {
let active_turn = self.active_turn.lock().await;
let has_active_turn = active_turn.is_some();
let active_turn_is_regular = active_turn
.as_ref()
.and_then(|turn| turn.tasks.first())
.is_some_and(|(_, task)| matches!(task.kind, crate::state::TaskKind::Regular));
(has_active_turn, active_turn_is_regular)
};
let can_after_turn = !has_active_turn && !has_pending_turn_inputs;
let can_steer_current_turn = active_turn_is_regular;
let state_db = match self.timer_state_db().await {
Ok(state_db) => state_db,
Err(err) => {
warn!("failed to claim queued message from sqlite: {err}");
*self.timer_start_in_progress.lock().await = false;
return None;
}
};
self.start_timer_db_sync_task(state_db.clone());
loop {
let claim = match state_db
.claim_next_thread_message(
&self.thread_id_string(),
can_after_turn,
can_steer_current_turn,
)
.await
{
Ok(claim) => claim,
Err(err) => {
warn!("failed to claim queued message from sqlite: {err}");
*self.timer_start_in_progress.lock().await = false;
return None;
}
};
match claim {
Some(codex_state::ThreadMessageClaim::Claimed(row)) => {
let message = match db_message_to_thread_message(row) {
Ok(message) => message,
Err(err) => {
warn!("{err}");
continue;
}
};
let delivery = message.delivery;
let input_item = message_prompt_input_item(&MessageInvocationContext {
source: message.source.clone(),
content: message.content.clone(),
instructions: message.instructions.clone(),
meta: message.meta.clone(),
queued_at: message.queued_at,
});
return Some((input_item, delivery));
}
Some(codex_state::ThreadMessageClaim::Invalid { id, reason }) => {
warn!("dropped invalid queued message {id}: {reason}");
continue;
}
Some(codex_state::ThreadMessageClaim::NotReady) | None => {
*self.timer_start_in_progress.lock().await = false;
return None;
}
}
}
}
async fn claim_next_timer_for_delivery(self: &Arc<Self>) -> Option<ClaimedTimer> {
let mut timer_start_in_progress = self.timer_start_in_progress.lock().await;
if *timer_start_in_progress {
return None;
}
*timer_start_in_progress = true;
drop(timer_start_in_progress);
let has_pending_turn_inputs = self.has_queued_response_items_for_next_turn().await
|| self.has_trigger_turn_mailbox_items().await;
let (has_active_turn, active_turn_is_regular) = {
let active_turn = self.active_turn.lock().await;
let has_active_turn = active_turn.is_some();
let active_turn_is_regular = active_turn
.as_ref()
.and_then(|turn| turn.tasks.first())
.is_some_and(|(_, task)| matches!(task.kind, crate::state::TaskKind::Regular));
(has_active_turn, active_turn_is_regular)
};
let can_after_turn = !has_active_turn && !has_pending_turn_inputs;
let claimed = self.timers.lock().await.claim_next_timer(
Utc::now(),
can_after_turn,
active_turn_is_regular,
);
let Some(claimed) = claimed else {
*self.timer_start_in_progress.lock().await = false;
return None;
};
if !self.try_claim_timer_in_db(&claimed).await {
self.sync_timers_from_db(/*emit_update*/ true).await;
*self.timer_start_in_progress.lock().await = false;
return None;
}
Some(claimed)
}
async fn inject_timer_into_active_turn(&self, item: ResponseInputItem) -> bool {
self.inject_message_into_active_turn(item).await
}
async fn inject_message_into_active_turn(&self, item: ResponseInputItem) -> bool {
let turn_state = {
let active = self.active_turn.lock().await;
let Some(active_turn) = active.as_ref() else {
return false;
};
match active_turn.tasks.first().map(|(_, task)| task.kind) {
Some(crate::state::TaskKind::Regular) => Arc::clone(&active_turn.turn_state),
Some(crate::state::TaskKind::Review | crate::state::TaskKind::Compact) | None => {
return false;
}
}
};
let mut turn_state = turn_state.lock().await;
turn_state.push_pending_input(item);
true
}
fn spawn_timer_task(
self: &Arc<Self>,
id: String,
timer_spec: TimerTaskSpec,
cancellation_token: CancellationToken,
) {
let weak = Arc::downgrade(self);
let session_cancel = self.timer_tasks_cancellation_token.clone();
tokio::spawn(async move {
let mut delay = timer_spec.delay;
loop {
tokio::select! {
_ = session_cancel.cancelled() => break,
_ = cancellation_token.cancelled() => break,
_ = tokio::time::sleep(delay) => {}
}
let Some(session) = weak.upgrade() else {
break;
};
let changed = session.timers.lock().await.mark_timer_due(&id, Utc::now());
if changed && !session.persist_timer_due_best_effort(&id).await {
session.sync_timers_from_db(/*emit_update*/ true).await;
continue;
}
session.maybe_start_pending_timer().await;
let next_timer_spec = session
.timers
.lock()
.await
.timer_spec_for_timer(&id, Utc::now());
let Some(next_timer_spec) = next_timer_spec else {
break;
};
delay = next_timer_spec.delay;
}
});
}
async fn thread_timer_create_params(
&self,
persisted_timer: &PersistedTimer,
source: &str,
) -> Result<codex_state::ThreadTimerCreateParams, String> {
let timer = &persisted_timer.timer;
Ok(codex_state::ThreadTimerCreateParams {
id: timer.id.clone(),
thread_id: self.thread_id_string(),
source: source.to_string(),
client_id: self.timer_client_id().await,
trigger_json: serde_json::to_string(&timer.trigger)
.map_err(|err| format!("failed to serialize timer trigger: {err}"))?,
content: timer.content.clone(),
instructions: timer.instructions.clone(),
meta_json: serde_json::to_string(&timer.meta)
.map_err(|err| format!("failed to serialize timer metadata: {err}"))?,
delivery: timer.delivery.as_str().to_string(),
created_at: timer.created_at,
next_run_at: timer.next_run_at,
last_run_at: timer.last_run_at,
pending_run: persisted_timer.pending_run,
})
}
fn thread_timer_update_params(
&self,
persisted_timer: &PersistedTimer,
) -> Result<codex_state::ThreadTimerUpdateParams, String> {
let timer = &persisted_timer.timer;
Ok(codex_state::ThreadTimerUpdateParams {
trigger_json: serde_json::to_string(&timer.trigger)
.map_err(|err| format!("failed to serialize timer trigger: {err}"))?,
content: timer.content.clone(),
instructions: timer.instructions.clone(),
meta_json: serde_json::to_string(&timer.meta)
.map_err(|err| format!("failed to serialize timer metadata: {err}"))?,
delivery: timer.delivery.as_str().to_string(),
next_run_at: timer.next_run_at,
last_run_at: timer.last_run_at,
pending_run: persisted_timer.pending_run,
})
}
async fn persist_timer_due_best_effort(&self, id: &str) -> bool {
let state_db = match self.timer_state_db().await {
Ok(state_db) => state_db,
Err(err) => {
warn!("failed to persist due timer {id}: {err}");
return false;
}
};
let Some(persisted_timer) = self.timers.lock().await.persisted_timer(id) else {
return false;
};
match state_db
.update_thread_timer_due(
&self.thread_id_string(),
id,
persisted_timer.timer.next_run_at,
)
.await
{
Ok(updated) => updated,
Err(err) => {
warn!("failed to persist due timer {id}: {err}");
false
}
}
}
async fn try_claim_timer_in_db(&self, claimed: &ClaimedTimer) -> bool {
let state_db = match self.timer_state_db().await {
Ok(state_db) => state_db,
Err(err) => {
warn!(
"failed to claim timer {} in sqlite: {err}",
claimed.timer.id
);
return true;
}
};
let thread_id = self.thread_id_string();
let result = if claimed.deleted_one_shot_timer {
state_db
.claim_one_shot_thread_timer(&thread_id, &claimed.timer.id)
.await
} else {
let persisted_timer = PersistedTimer {
timer: claimed.timer.clone(),
pending_run: claimed.timer.trigger.is_idle_recurring(),
};
let Ok(params) = self.thread_timer_update_params(&persisted_timer) else {
return false;
};
state_db
.claim_recurring_thread_timer(&thread_id, &claimed.timer.id, &params)
.await
};
match result {
Ok(claimed) => claimed,
Err(err) => {
warn!(
"failed to claim timer {} in sqlite: {err}",
claimed.timer.id
);
false
}
}
}
pub(crate) async fn restore_timers_from_db(self: &Arc<Self>) {
if !self.features.enabled(Feature::TimerScheduler) {
return;
}
let Ok(state_db) = self.timer_state_db().await else {
return;
};
self.start_timer_db_sync_task(state_db);
self.sync_timers_from_db(/*emit_update*/ true).await;
self.maybe_start_pending_timer().await;
}
fn start_timer_db_sync_task(self: &Arc<Self>, state_db: state_db::StateDbHandle) {
if !self.features.enabled(Feature::TimerScheduler) {
return;
}
if self
.timer_db_sync_started
.swap(/*val*/ true, Ordering::SeqCst)
{
return;
}
let weak = Arc::downgrade(self);
let session_cancel = self.timer_tasks_cancellation_token.clone();
tokio::spawn(async move {
let checker = match state_db.timer_data_version_checker().await {
Ok(checker) => checker,
Err(err) => {
warn!("failed to start timer db sync: {err}");
if let Some(session) = weak.upgrade() {
session.timer_db_sync_started.store(false, Ordering::SeqCst);
}
return;
}
};
let mut last_data_version = checker.data_version().await.ok();
let mut last_full_refresh = tokio::time::Instant::now();
let mut interval = tokio::time::interval(TIMER_DB_SYNC_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = session_cancel.cancelled() => break,
_ = interval.tick() => {}
}
let current_data_version = match checker.data_version().await {
Ok(version) => Some(version),
Err(err) => {
warn!("failed to poll timer db data_version: {err}");
None
}
};
let version_changed =
current_data_version.is_some() && current_data_version != last_data_version;
let max_refresh_elapsed =
last_full_refresh.elapsed() >= TIMER_DB_MAX_REFRESH_INTERVAL;
if !version_changed && !max_refresh_elapsed {
continue;
}
last_data_version = current_data_version.or(last_data_version);
let Some(session) = weak.upgrade() else {
break;
};
session.sync_timers_from_db(/*emit_update*/ true).await;
session.maybe_start_pending_timer().await;
last_full_refresh = tokio::time::Instant::now();
}
});
}
async fn sync_timers_from_db(self: &Arc<Self>, emit_update: bool) -> bool {
if !self.features.enabled(Feature::TimerScheduler) {
return false;
}
let Ok(state_db) = self.timer_state_db().await else {
return false;
};
self.start_timer_db_sync_task(state_db.clone());
let thread_id = self.thread_id_string();
let db_timers = match state_db.list_thread_timers(&thread_id).await {
Ok(timers) => timers,
Err(err) => {
warn!("failed to load timers from sqlite for thread {thread_id}: {err}");
return false;
}
};
let persisted = db_timers
.into_iter()
.filter_map(db_timer_to_persisted_timer)
.collect::<Vec<_>>();
let (changed, restored_tasks) = self
.timers
.lock()
.await
.replace_timers_if_changed(persisted, Utc::now());
self.spawn_restored_timer_tasks(restored_tasks);
if changed && emit_update {
self.emit_timer_updated_notification().await;
}
changed
}
fn spawn_restored_timer_tasks(self: &Arc<Self>, restored_tasks: Vec<RestoredTimerTask>) {
for RestoredTimerTask {
id,
timer_spec,
timer_cancel,
} in restored_tasks
{
self.spawn_timer_task(id, timer_spec, timer_cancel);
}
}
fn thread_id_string(&self) -> String {
self.conversation_id.to_string()
}
async fn timer_client_id(&self) -> String {
let state = self.state.lock().await;
state
.session_configuration
.app_server_client_name
.clone()
.unwrap_or_else(|| TIMER_CLIENT_ID_FALLBACK.to_string())
}
async fn emit_timer_updated_notification(&self) {
let timers = self.list_timers_from_memory().await;
let Ok(payload) = serde_json::to_string(&timers) else {
warn!("failed to serialize timer update payload");
return;
};
self.send_event_raw(Event {
id: INITIAL_SUBMIT_ID.to_owned(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: format!("{TIMER_UPDATED_BACKGROUND_EVENT_PREFIX}{payload}"),
}),
})
.await;
}
async fn emit_timer_fired_notification(&self, timer: &ThreadTimer) {
let Ok(payload) = serde_json::to_string(timer) else {
warn!("failed to serialize timer fired payload");
return;
};
self.send_event_raw(Event {
id: INITIAL_SUBMIT_ID.to_owned(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: format!("{TIMER_FIRED_BACKGROUND_EVENT_PREFIX}{payload}"),
}),
})
.await;
}
}
@@ -0,0 +1,80 @@
use super::make_session_and_context_with_rx;
use crate::messages::MessagePayload;
use crate::timers::ThreadTimerTrigger;
use crate::timers::TimerDelivery;
use std::sync::Arc;
#[tokio::test]
async fn dropping_session_cancels_timer_tasks() {
let (session, _, _) = make_session_and_context_with_rx().await;
let cancel_token = session.timer_tasks_cancellation_token.clone();
drop(session);
assert!(cancel_token.is_cancelled());
}
#[tokio::test]
async fn maybe_start_pending_timer_claims_only_one_timer_while_start_is_in_progress() {
let (session, _, _) = make_session_and_context_with_rx().await;
let now = chrono::Utc::now();
{
let mut timers = session.timers.lock().await;
timers
.create_timer(
crate::timers::CreateTimer {
id: "timer-1".to_string(),
trigger: ThreadTimerTrigger::Delay {
seconds: 10,
repeat: Some(true),
},
payload: MessagePayload {
content: "first".to_string(),
instructions: None,
meta: Default::default(),
},
delivery: TimerDelivery::AfterTurn,
now,
},
/*timer_cancel*/ None,
)
.expect("first timer should be created");
timers
.create_timer(
crate::timers::CreateTimer {
id: "timer-2".to_string(),
trigger: ThreadTimerTrigger::Delay {
seconds: 10,
repeat: Some(true),
},
payload: MessagePayload {
content: "second".to_string(),
instructions: None,
meta: Default::default(),
},
delivery: TimerDelivery::AfterTurn,
now,
},
/*timer_cancel*/ None,
)
.expect("second timer should be created");
timers.mark_timer_due("timer-1", now);
timers.mark_timer_due("timer-2", now);
}
let first = Arc::clone(&session);
let second = Arc::clone(&session);
tokio::join!(
first.maybe_start_pending_timer(),
second.maybe_start_pending_timer()
);
let timers = session.timers.lock().await.list_timers();
assert_eq!(
timers
.iter()
.filter(|timer| timer.last_run_at.is_some())
.count(),
1
);
}
+9
View File
@@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError;
use crate::mcp_tool_exposure::DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD;
use crate::mcp_tool_exposure::build_mcp_tool_exposure;
use crate::shell::default_user_shell;
use crate::timers::TimersState;
use crate::tools::format_exec_output_str;
use codex_features::Features;
@@ -2941,9 +2942,13 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
pending_mcp_server_refresh_config: Mutex::new(None),
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
timer_start_in_progress: Mutex::new(false),
timer_db_sync_started: AtomicBool::new(false),
mailbox,
mailbox_rx: Mutex::new(mailbox_rx),
idle_pending_input: Mutex::new(Vec::new()),
timers: Mutex::new(TimersState::default()),
timer_tasks_cancellation_token: CancellationToken::new(),
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
services,
js_repl,
@@ -3786,9 +3791,13 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
pending_mcp_server_refresh_config: Mutex::new(None),
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
timer_start_in_progress: Mutex::new(false),
timer_db_sync_started: AtomicBool::new(false),
mailbox,
mailbox_rx: Mutex::new(mailbox_rx),
idle_pending_input: Mutex::new(Vec::new()),
timers: Mutex::new(TimersState::default()),
timer_tasks_cancellation_token: CancellationToken::new(),
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
services,
js_repl,
+24
View File
@@ -29,6 +29,10 @@ use std::path::PathBuf;
use tokio::sync::Mutex;
use tokio::sync::watch;
use crate::messages::MessagePayload;
use crate::timers::ThreadTimer;
use crate::timers::ThreadTimerTrigger;
use crate::timers::TimerDelivery;
use codex_rollout::state_db::StateDbHandle;
#[derive(Clone, Debug)]
@@ -276,6 +280,26 @@ impl CodexThread {
Ok(*guard)
}
pub async fn create_timer(
&self,
trigger: ThreadTimerTrigger,
payload: MessagePayload,
delivery: TimerDelivery,
) -> Result<ThreadTimer, String> {
self.codex
.session
.create_timer(trigger, payload, delivery)
.await
}
pub async fn delete_timer(&self, id: &str) -> Result<bool, String> {
self.codex.session.delete_timer(id).await
}
pub async fn list_timers(&self) -> Vec<ThreadTimer> {
self.codex.session.list_timers().await
}
}
fn pending_message_input_item(message: &ResponseItem) -> CodexResult<ResponseInputItem> {
+4
View File
@@ -42,6 +42,8 @@ mod hook_runtime;
mod installation_id;
pub(crate) mod instructions;
pub(crate) mod landlock;
mod timer_trigger;
pub mod timers;
pub use landlock::spawn_command_under_linux_sandbox;
pub(crate) mod mcp;
mod mcp_skill_dependencies;
@@ -62,6 +64,7 @@ mod mcp_tool_call;
mod memories;
pub(crate) mod mention_syntax;
pub(crate) mod message_history;
pub mod messages;
pub(crate) mod utils;
pub use mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
pub use mention_syntax::TOOL_MENTION_SIGIL;
@@ -167,6 +170,7 @@ pub use rollout::append_thread_name;
pub use rollout::find_archived_thread_path_by_id_str;
#[deprecated(note = "use find_thread_path_by_id_str")]
pub use rollout::find_conversation_path_by_id_str;
pub use rollout::find_thread_ids_by_name;
pub use rollout::find_thread_meta_by_name_str;
pub use rollout::find_thread_name_by_id;
pub use rollout::find_thread_names_by_ids;
+182
View File
@@ -0,0 +1,182 @@
//! Queued thread messages and model-visible message envelopes.
use crate::timers::TimerDelivery;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
pub const CODEX_MESSAGE_OPEN_TAG: &str = "<codex_message>";
pub const CODEX_MESSAGE_CLOSE_TAG: &str = "</codex_message>";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MessagePayload {
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(default)]
pub meta: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ThreadMessage {
pub id: String,
pub thread_id: String,
pub source: String,
pub content: String,
pub instructions: Option<String>,
pub meta: BTreeMap<String, String>,
pub delivery: TimerDelivery,
pub queued_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct MessageInvocationContext {
pub(crate) source: String,
pub(crate) content: String,
pub(crate) instructions: Option<String>,
pub(crate) meta: BTreeMap<String, String>,
pub(crate) queued_at: i64,
}
pub fn validate_meta_key(key: &str) -> Result<(), String> {
if key.is_empty() {
return Err("message metadata keys cannot be empty".to_string());
}
if key
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
{
return Ok(());
}
Err(format!(
"message metadata key `{key}` must contain only letters, digits, and underscores"
))
}
pub fn validate_meta(meta: &BTreeMap<String, String>) -> Result<(), String> {
for key in meta.keys() {
validate_meta_key(key)?;
}
Ok(())
}
pub(crate) fn message_prompt_input_item(message: &MessageInvocationContext) -> ResponseInputItem {
ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: render_message_prompt(message),
}],
}
}
pub(crate) fn render_message_prompt(message: &MessageInvocationContext) -> String {
let mut rendered = String::new();
rendered.push_str(CODEX_MESSAGE_OPEN_TAG);
rendered.push('\n');
push_tag(&mut rendered, "source", &message.source);
push_tag(&mut rendered, "queued_at", &message.queued_at.to_string());
push_block_tag(&mut rendered, "content", &message.content);
if let Some(instructions) = message.instructions.as_deref() {
push_block_tag(&mut rendered, "instructions", instructions);
}
if message.meta.is_empty() {
rendered.push_str("<meta />\n");
} else {
rendered.push_str("<meta>\n");
for (key, value) in &message.meta {
rendered.push_str(" <entry id=\"");
rendered.push_str(&xml_escape(key));
rendered.push_str("\">");
rendered.push_str(&xml_escape(value));
rendered.push_str("</entry>\n");
}
rendered.push_str("</meta>\n");
}
rendered.push_str(CODEX_MESSAGE_CLOSE_TAG);
rendered
}
pub(crate) fn db_message_to_thread_message(
row: codex_state::ThreadMessage,
) -> Result<ThreadMessage, String> {
let delivery =
serde_json::from_value::<TimerDelivery>(serde_json::Value::String(row.delivery.clone()))
.map_err(|err| format!("invalid message delivery `{}`: {err}", row.delivery))?;
let meta = serde_json::from_str::<BTreeMap<String, String>>(&row.meta_json)
.map_err(|err| format!("invalid message metadata json for {}: {err}", row.id))?;
validate_meta(&meta)?;
Ok(ThreadMessage {
id: row.id,
thread_id: row.thread_id,
source: row.source,
content: row.content,
instructions: row.instructions,
meta,
delivery,
queued_at: row.queued_at,
})
}
fn push_tag(rendered: &mut String, tag: &str, value: &str) {
rendered.push('<');
rendered.push_str(tag);
rendered.push('>');
rendered.push_str(&xml_escape(value));
rendered.push_str("</");
rendered.push_str(tag);
rendered.push_str(">\n");
}
fn push_block_tag(rendered: &mut String, tag: &str, value: &str) {
rendered.push('<');
rendered.push_str(tag);
rendered.push_str(">\n");
rendered.push_str(&xml_escape(value));
rendered.push('\n');
rendered.push_str("</");
rendered.push_str(tag);
rendered.push_str(">\n");
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn renders_message_prompt_with_escaped_metadata() {
let message = MessageInvocationContext {
source: "timer timer-1".to_string(),
content: "run <tests>".to_string(),
instructions: Some("stay \"brief\"".to_string()),
meta: BTreeMap::from([("ticket".to_string(), "ABC&123".to_string())]),
queued_at: 100,
};
assert_eq!(
render_message_prompt(&message),
"<codex_message>\n<source>timer timer-1</source>\n<queued_at>100</queued_at>\n<content>\nrun &lt;tests&gt;\n</content>\n<instructions>\nstay &quot;brief&quot;\n</instructions>\n<meta>\n <entry id=\"ticket\">ABC&amp;123</entry>\n</meta>\n</codex_message>"
);
}
#[test]
fn validate_meta_key_rejects_hyphens() {
assert_eq!(
validate_meta_key("bad-key"),
Err(
"message metadata key `bad-key` must contain only letters, digits, and underscores"
.to_string()
)
);
}
}
+1
View File
@@ -14,6 +14,7 @@ pub use codex_rollout::append_thread_name;
pub use codex_rollout::find_archived_thread_path_by_id_str;
#[deprecated(note = "use find_thread_path_by_id_str")]
pub use codex_rollout::find_conversation_path_by_id_str;
pub use codex_rollout::find_thread_ids_by_name;
pub use codex_rollout::find_thread_meta_by_name_str;
pub use codex_rollout::find_thread_name_by_id;
pub use codex_rollout::find_thread_names_by_ids;
+8 -8
View File
@@ -236,7 +236,7 @@ impl Session {
self.start_task(turn_context, input, task).await;
}
async fn start_task<T: SessionTask>(
pub(crate) async fn start_task<T: SessionTask>(
self: &Arc<Self>,
turn_context: Arc<TurnContext>,
input: Vec<UserInput>,
@@ -534,15 +534,15 @@ impl Session {
duration_ms,
});
self.send_event(turn_context.as_ref(), event).await;
if should_clear_active_turn {
let session = Arc::clone(self);
let _scheduler = tokio::task::spawn_blocking(move || {
tokio::runtime::Handle::current().block_on(async move {
let session = Arc::clone(self);
let _scheduler = tokio::task::spawn_blocking(move || {
tokio::runtime::Handle::current().block_on(async move {
session.maybe_start_pending_timer().await;
if should_clear_active_turn {
session.maybe_start_turn_for_pending_work().await;
});
}
});
}
});
}
async fn take_active_turn(&self) -> Option<ActiveTurn> {
+835
View File
@@ -0,0 +1,835 @@
//! Trigger validation and next-fire calculation for persistent thread timers.
//!
//! This module keeps calendar and delay scheduling details out of `timers.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::NaiveTime;
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";
const TIME_ONLY_FORMATS: &[&str] = &["%H:%M:%S", "%H:%M", "%H"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum TimerTrigger {
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: TimerTrigger,
pub(crate) pending_run: bool,
pub(crate) next_run_at: Option<i64>,
pub(crate) timer_delay: Option<Duration>,
}
impl TimerTrigger {
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 is_idle_recurring(&self) -> bool {
matches!(
self,
Self::Delay {
seconds: 0,
repeat: Some(true),
}
)
}
}
pub(crate) fn timing_for_new_trigger(
trigger: TimerTrigger,
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: TimerTrigger,
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: &TimerTrigger,
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)
}
pub(crate) fn normalize_schedule_dtstart_input(input: &str) -> Result<String, String> {
let timezone = local_timezone();
normalize_schedule_dtstart_input_with_timezone(input, Utc::now(), timezone)
}
fn normalize_schedule_dtstart_input_with_timezone(
input: &str,
now: DateTime<Utc>,
timezone: Tz,
) -> Result<String, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("schedule dtstart cannot be empty".to_string());
}
if parse_dtstart(trimmed).is_ok() {
validate_dtstart(trimmed, timezone)?;
return Ok(trimmed.to_string());
}
let Some(time) = parse_time_only(trimmed) else {
return Err(format!(
"schedule dtstart `{trimmed}` must use format YYYY-MM-DDTHH:MM:SS or a time like HH:MM"
));
};
let local_now = now.with_timezone(&timezone).naive_local();
let mut local_dtstart = local_now.date().and_time(time);
if local_dtstart <= local_now {
local_dtstart = local_dtstart
.checked_add_signed(ChronoDuration::days(1))
.ok_or_else(|| "schedule dtstart is out of range".to_string())?;
}
let dtstart = local_dtstart.format(LOCAL_DATE_TIME_FORMAT).to_string();
validate_dtstart(&dtstart, timezone)?;
Ok(dtstart)
}
fn timing_for_new_trigger_with_timezone(
trigger: TimerTrigger,
created_at: DateTime<Utc>,
now: DateTime<Utc>,
timezone: Tz,
) -> Result<TriggerTiming, String> {
let normalized = normalize_trigger(trigger, now, timezone)?;
match &normalized {
TimerTrigger::Delay { seconds, repeat } => {
let repeat = repeat.unwrap_or(false);
if repeat && *seconds == 0 {
return Ok(timing(
normalized, /*pending_run*/ true, /*next_run_at*/ None, now,
));
}
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))
}
TimerTrigger::Schedule { rrule: None, .. } => {
let due_at = schedule_dtstart_utc(&normalized, timezone)?;
Ok(timing(
normalized,
due_at <= now,
Some(due_at.timestamp()),
now,
))
}
TimerTrigger::Schedule { rrule: Some(_), .. } => {
let due_or_next = next_schedule_occurrence_at_or_after(&normalized, now, timezone)?;
let Some(due_or_next) = due_or_next else {
return Ok(timing(
normalized, /*pending_run*/ false, /*next_run_at*/ None, now,
));
};
let pending_run = due_or_next <= now.timestamp();
let next_run_at = if pending_run {
next_schedule_occurrence_after(&normalized, now, timezone)?
} else {
Some(due_or_next)
};
Ok(timing(normalized, pending_run, next_run_at, now))
}
}
}
fn timing_for_restored_trigger_with_timezone(
trigger: TimerTrigger,
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 {
TimerTrigger::Delay { seconds, repeat } => {
let repeat = repeat.unwrap_or(false);
if repeat && *seconds == 0 {
return Ok(timing(
normalized, /*pending_run*/ true, /*next_run_at*/ None, now,
));
}
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))
}
TimerTrigger::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,
))
}
TimerTrigger::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: &TimerTrigger,
created_at: i64,
now: DateTime<Utc>,
timezone: Tz,
) -> Result<Option<i64>, String> {
match trigger {
TimerTrigger::Delay { seconds, repeat } => {
if repeat.unwrap_or(false) {
if *seconds == 0 {
return Ok(None);
}
next_delay_recurring_run_at_from_timestamp(created_at, *seconds, now)
} else {
Ok(None)
}
}
TimerTrigger::Schedule { rrule: None, .. } => Ok(None),
TimerTrigger::Schedule { rrule: Some(_), .. } => {
next_schedule_occurrence_after(trigger, now, timezone)
}
}
}
fn normalize_trigger(
trigger: TimerTrigger,
now: DateTime<Utc>,
timezone: Tz,
) -> Result<TimerTrigger, String> {
match trigger {
TimerTrigger::Delay { seconds, repeat } => Ok(TimerTrigger::Delay { seconds, repeat }),
TimerTrigger::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 = TimerTrigger::Schedule { dtstart, rrule };
if matches!(normalized, TimerTrigger::Schedule { rrule: Some(_), .. }) {
parse_rrule_set(&normalized, timezone)?;
}
Ok(normalized)
}
}
}
fn timing(
trigger: TimerTrigger,
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: &TimerTrigger, timezone: Tz) -> Result<DateTime<Utc>, String> {
let TimerTrigger::Schedule {
dtstart: Some(dtstart),
..
} = trigger
else {
return Err("schedule trigger requires dtstart".to_string());
};
local_dtstart_to_utc(dtstart, timezone)
}
fn next_schedule_occurrence_after(
trigger: &TimerTrigger,
after: DateTime<Utc>,
timezone: Tz,
) -> Result<Option<i64>, String> {
let set = parse_rrule_set(trigger, timezone)?;
let after = after
.checked_add_signed(ChronoDuration::seconds(1))
.unwrap_or(after);
let result = set.after(after.with_timezone(&timezone)).all(1);
Ok(result
.dates
.into_iter()
.next()
.map(|next| next.with_timezone(&Utc).timestamp()))
}
fn next_schedule_occurrence_at_or_after(
trigger: &TimerTrigger,
at: DateTime<Utc>,
timezone: Tz,
) -> Result<Option<i64>, String> {
let after = at
.checked_sub_signed(ChronoDuration::seconds(1))
.unwrap_or(at);
next_schedule_occurrence_after(trigger, after, timezone)
}
fn parse_rrule_set(trigger: &TimerTrigger, timezone: Tz) -> Result<RRuleSet, String> {
let TimerTrigger::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 formatted_dtstart = naive.format(RRULE_DATE_TIME_FORMAT);
let dtstart = if timezone.is_local() {
format!("DTSTART:{formatted_dtstart}")
} else {
let timezone_name = timezone.name();
format!("DTSTART;TZID={timezone_name}:{formatted_dtstart}")
};
let rrule_set = format!("{dtstart}\nRRULE:{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 parse_time_only(input: &str) -> Option<NaiveTime> {
for format in TIME_ONLY_FORMATS {
if let Ok(time) = NaiveTime::parse_from_str(input, format) {
return Some(time);
}
}
let compact = input.to_ascii_lowercase().replace(' ', "");
let (time, is_pm) = if let Some(time) = compact.strip_suffix("am") {
(time, false)
} else if let Some(time) = compact.strip_suffix("pm") {
(time, true)
} else {
return None;
};
let parts = time.split(':').collect::<Vec<_>>();
if parts.is_empty() || parts.len() > 3 {
return None;
}
let hour = parts[0].parse::<u32>().ok()?;
if !(1..=12).contains(&hour) {
return None;
}
let minute = parts
.get(1)
.map_or(Some(0), |value| value.parse::<u32>().ok())?;
let second = parts
.get(2)
.map_or(Some(0), |value| value.parse::<u32>().ok())?;
let hour = if is_pm { (hour % 12) + 12 } else { hour % 12 };
NaiveTime::from_hms_opt(hour, minute, second)
}
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 {
timezone_from_name(iana_time_zone::get_timezone().ok())
}
fn timezone_from_name(timezone: Option<String>) -> Tz {
timezone
.as_deref()
.and_then(|timezone| timezone.parse::<chrono_tz::Tz>().ok())
.map(Tz::Tz)
.unwrap_or(Tz::LOCAL)
}
#[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")
}
fn utc_datetime(datetime: &str) -> DateTime<Utc> {
Utc.from_utc_datetime(
&NaiveDateTime::parse_from_str(datetime, LOCAL_DATE_TIME_FORMAT)
.expect("valid datetime"),
)
}
#[test]
fn delay_one_shot_becomes_pending_when_due() {
let timing = timing_for_new_trigger_with_timezone(
TimerTrigger::Delay {
seconds: 0,
repeat: None,
},
utc(TS_100),
utc(TS_100),
Tz::UTC,
)
.expect("trigger should be valid");
assert_eq!(
timing,
TriggerTiming {
trigger: TimerTrigger::Delay {
seconds: 0,
repeat: None,
},
pending_run: true,
next_run_at: Some(100),
timer_delay: None,
}
);
}
#[test]
fn delay_repeat_zero_is_idle_recurring() {
let timing = timing_for_new_trigger_with_timezone(
TimerTrigger::Delay {
seconds: 0,
repeat: Some(true),
},
utc(TS_100),
utc(TS_100),
Tz::UTC,
)
.expect("zero repeat should be a valid idle-recurring trigger");
assert_eq!(
timing,
TriggerTiming {
trigger: TimerTrigger::Delay {
seconds: 0,
repeat: Some(true),
},
pending_run: true,
next_run_at: None,
timer_delay: None,
}
);
assert_eq!(
next_run_after_due_with_timezone(
&TimerTrigger::Delay {
seconds: 0,
repeat: Some(true),
},
/*created_at*/ 100,
utc(TS_100),
Tz::UTC,
)
.expect("zero repeat should remain timer-free"),
None
);
}
#[test]
fn delay_repeat_coalesces_overdue_runs() {
let timing = timing_for_restored_trigger_with_timezone(
TimerTrigger::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(
TimerTrigger::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,
TimerTrigger::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(
TimerTrigger::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 normalize_schedule_dtstart_accepts_time_later_today() {
assert_eq!(
normalize_schedule_dtstart_input_with_timezone(
"15:30",
utc_datetime("2026-04-10T14:00:00"),
Tz::UTC,
)
.expect("time should normalize"),
"2026-04-10T15:30:00"
);
}
#[test]
fn normalize_schedule_dtstart_accepts_local_time_later_today() {
assert_eq!(
normalize_schedule_dtstart_input_with_timezone(
"8:59",
utc_datetime("2026-04-10T15:57:00"),
Tz::America__Los_Angeles,
)
.expect("time should normalize"),
"2026-04-10T08:59:00"
);
}
#[test]
fn normalize_schedule_dtstart_rolls_past_time_to_tomorrow() {
assert_eq!(
normalize_schedule_dtstart_input_with_timezone(
"15:30",
utc_datetime("2026-04-10T16:00:00"),
Tz::UTC,
)
.expect("time should normalize"),
"2026-04-11T15:30:00"
);
}
#[test]
fn normalize_schedule_dtstart_accepts_ampm_time() {
assert_eq!(
normalize_schedule_dtstart_input_with_timezone(
"3:05 pm",
utc_datetime("2026-04-10T14:00:00"),
Tz::UTC,
)
.expect("time should normalize"),
"2026-04-10T15:05:00"
);
}
#[test]
fn normalize_schedule_dtstart_preserves_full_datetime() {
assert_eq!(
normalize_schedule_dtstart_input_with_timezone(
"2026-04-10T15:30:45",
utc_datetime("2026-04-10T14:00:00"),
Tz::UTC,
)
.expect("datetime should normalize"),
"2026-04-10T15:30:45"
);
}
#[test]
fn local_timezone_fallback_uses_system_local_timezone() {
assert!(timezone_from_name(Some("Not/A_Real_Zone".to_string())).is_local());
}
#[test]
fn recurring_schedule_accepts_system_local_timezone() {
let timing = timing_for_new_trigger_with_timezone(
TimerTrigger::Schedule {
dtstart: Some("2026-04-10T09:00:00".to_string()),
rrule: Some("FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0".to_string()),
},
utc_datetime("2026-04-10T08:00:00"),
utc_datetime("2026-04-10T08:00:00"),
Tz::LOCAL,
)
.expect("local timezone should be valid for recurring schedules");
assert!(!timing.pending_run);
assert!(timing.next_run_at.is_some());
}
#[test]
fn schedule_recurring_historical_dtstart_waits_for_next_future_occurrence() {
let timing = timing_for_new_trigger_with_timezone(
TimerTrigger::Schedule {
dtstart: Some("2024-01-01T09:00:00".to_string()),
rrule: Some("FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0".to_string()),
},
utc_datetime("2024-01-02T08:00:00"),
utc_datetime("2024-01-02T08:00:00"),
Tz::UTC,
)
.expect("trigger should be valid");
assert_eq!(timing.pending_run, false);
assert_eq!(
timing.next_run_at,
Some(utc_datetime("2024-01-02T09:00:00").timestamp())
);
}
#[test]
fn schedule_recurring_due_now_becomes_pending() {
let timing = timing_for_new_trigger_with_timezone(
TimerTrigger::Schedule {
dtstart: Some("2024-01-01T09:00:00".to_string()),
rrule: Some("FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0".to_string()),
},
utc_datetime("2024-01-02T09:00:00"),
utc_datetime("2024-01-02T09:00:00"),
Tz::UTC,
)
.expect("trigger should be valid");
assert_eq!(timing.pending_run, true);
assert_eq!(
timing.next_run_at,
Some(utc_datetime("2024-01-03T09:00:00").timestamp())
);
}
#[test]
fn schedule_rejects_neither_dtstart_nor_rrule() {
assert_eq!(
timing_for_new_trigger_with_timezone(
TimerTrigger::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(
TimerTrigger::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(
TimerTrigger::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")
);
}
}
+804
View File
@@ -0,0 +1,804 @@
//! Persistent thread-local timer scheduling for follow-on turns and same-turn steer delivery.
//!
//! This module owns the in-memory timer registry, trigger evaluation, the user
//! message injected when a timer fires, and the persistent state shape used to
//! restore timers after a harness restart.
use crate::messages::MessageInvocationContext;
use crate::messages::MessagePayload;
use crate::messages::message_prompt_input_item;
use crate::messages::validate_meta;
use crate::timer_trigger::TimerTrigger;
use crate::timer_trigger::TriggerTiming;
use crate::timer_trigger::next_run_after_due;
use crate::timer_trigger::normalize_schedule_dtstart_input;
use crate::timer_trigger::timing_for_new_trigger;
use crate::timer_trigger::timing_for_restored_trigger;
use chrono::Utc;
use codex_protocol::models::ResponseInputItem;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::HashMap;
use tokio_util::sync::CancellationToken;
pub const TIMER_UPDATED_BACKGROUND_EVENT_PREFIX: &str = "timer_updated:";
pub const TIMER_FIRED_BACKGROUND_EVENT_PREFIX: &str = "timer_fired:";
pub const MAX_ACTIVE_TIMERS_PER_THREAD: usize = 256;
const RECURRING_TIMER_INSTRUCTIONS: &str = "This timer should keep running on its schedule after this invocation.\nDo not call delete_timer just because you completed this invocation.\nCall delete_timer with {\"id\":\"{{CURRENT_TIMER_ID}}\"} only if the user's timer message included an explicit stop condition, such as \"until\", \"stop when\", or \"while\", and that condition is now satisfied.\nDo not expose scheduler internals unless they matter to the user.";
const ONE_SHOT_TIMER_INSTRUCTIONS: &str = "This one-shot timer has already been removed from the schedule, so you do not need to call delete_timer.\nDo not expose scheduler internals unless they matter to the user.";
const TIMER_CONTENT_PREVIEW_MAX_CHARS: usize = 160;
pub use crate::timer_trigger::TimerTrigger as ThreadTimerTrigger;
pub fn normalize_thread_timer_dtstart_input(input: &str) -> Result<String, String> {
normalize_schedule_dtstart_input(input)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TimerDelivery {
AfterTurn,
SteerCurrentTurn,
}
impl TimerDelivery {
pub fn as_str(self) -> &'static str {
match self {
Self::AfterTurn => "after-turn",
Self::SteerCurrentTurn => "steer-current-turn",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThreadTimer {
pub id: String,
pub trigger: TimerTrigger,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(default)]
pub meta: BTreeMap<String, String>,
pub delivery: TimerDelivery,
pub created_at: i64,
pub next_run_at: Option<i64>,
pub last_run_at: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TimerInvocationContext {
pub(crate) current_timer_id: String,
pub(crate) content: String,
pub(crate) instructions: Option<String>,
pub(crate) meta: BTreeMap<String, String>,
pub(crate) recurring: bool,
pub(crate) delivery: TimerDelivery,
pub(crate) queued_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ClaimedTimer {
pub(crate) timer: ThreadTimer,
pub(crate) context: TimerInvocationContext,
pub(crate) deleted_one_shot_timer: bool,
}
#[derive(Debug)]
pub(crate) struct CreateTimer {
pub(crate) id: String,
pub(crate) trigger: TimerTrigger,
pub(crate) payload: MessagePayload,
pub(crate) delivery: TimerDelivery,
pub(crate) now: chrono::DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThreadTimerStorageCreateParams {
pub thread_id: String,
pub source: String,
pub client_id: String,
pub trigger: TimerTrigger,
pub payload: MessagePayload,
pub delivery: TimerDelivery,
}
#[derive(Debug, Default)]
pub(crate) struct TimersState {
timers: HashMap<String, TimerRuntime>,
}
#[derive(Debug)]
pub(crate) struct TimerRuntime {
pub(crate) timer: ThreadTimer,
pending_run: bool,
pub(crate) timer_cancel: Option<CancellationToken>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct PersistedTimer {
pub(crate) timer: ThreadTimer,
pub(crate) pending_run: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TimerTaskSpec {
pub(crate) delay: std::time::Duration,
}
#[derive(Debug)]
pub(crate) struct RestoredTimerTask {
pub(crate) id: String,
pub(crate) timer_spec: TimerTaskSpec,
pub(crate) timer_cancel: CancellationToken,
}
impl TimersState {
pub(crate) fn list_timers(&self) -> Vec<ThreadTimer> {
let mut timers = self
.timers
.values()
.map(|runtime| runtime.timer.clone())
.collect::<Vec<_>>();
timers.sort_by(|left, right| {
left.created_at
.cmp(&right.created_at)
.then_with(|| left.id.cmp(&right.id))
});
timers
}
pub(crate) fn persisted_timers(&self) -> Vec<PersistedTimer> {
let mut timers = self
.timers
.values()
.map(|runtime| PersistedTimer {
timer: runtime.timer.clone(),
pending_run: runtime.pending_run,
})
.collect::<Vec<_>>();
timers.sort_by(|left, right| {
left.timer
.created_at
.cmp(&right.timer.created_at)
.then_with(|| left.timer.id.cmp(&right.timer.id))
});
timers
}
pub(crate) fn persisted_timer(&self, id: &str) -> Option<PersistedTimer> {
self.timers.get(id).map(|runtime| PersistedTimer {
timer: runtime.timer.clone(),
pending_run: runtime.pending_run,
})
}
pub(crate) fn replace_timers_if_changed(
&mut self,
persisted: Vec<PersistedTimer>,
now: chrono::DateTime<Utc>,
) -> (bool, Vec<RestoredTimerTask>) {
if self.persisted_timers() == persisted {
return (false, Vec::new());
}
for runtime in self.timers.values() {
Self::cancel_runtime(runtime);
}
self.timers.clear();
let mut restored_tasks = Vec::new();
for persisted_timer in persisted {
let timer_cancel = CancellationToken::new();
let timer_id = persisted_timer.timer.id.clone();
match self.restore_timer(persisted_timer, now, Some(timer_cancel.clone())) {
Ok(Some(timer_spec)) => {
restored_tasks.push(RestoredTimerTask {
id: timer_id,
timer_spec,
timer_cancel,
});
}
Ok(None) => {}
Err(err) => {
tracing::warn!("skipping invalid persisted timer {timer_id}: {err}");
}
}
}
(true, restored_tasks)
}
pub(crate) fn create_timer(
&mut self,
create_timer: CreateTimer,
timer_cancel: Option<CancellationToken>,
) -> Result<(ThreadTimer, Option<TimerTaskSpec>), String> {
if self.timers.len() >= MAX_ACTIVE_TIMERS_PER_THREAD {
return Err(format!(
"too many active timers; each thread supports at most {MAX_ACTIVE_TIMERS_PER_THREAD} timers"
));
}
let CreateTimer {
id,
trigger,
payload,
delivery,
now,
} = create_timer;
let TriggerTiming {
trigger,
pending_run,
next_run_at,
timer_delay,
} = timing_for_new_trigger(trigger, now, now)?;
let timer = ThreadTimer {
id: id.clone(),
trigger,
content: payload.content,
instructions: payload.instructions,
meta: payload.meta,
delivery,
created_at: now.timestamp(),
next_run_at,
last_run_at: None,
};
self.timers.insert(
id,
TimerRuntime {
timer: timer.clone(),
pending_run,
timer_cancel,
},
);
Ok((timer, timer_delay.map(|delay| TimerTaskSpec { delay })))
}
pub(crate) fn restore_timer(
&mut self,
persisted: PersistedTimer,
now: chrono::DateTime<Utc>,
timer_cancel: Option<CancellationToken>,
) -> Result<Option<TimerTaskSpec>, String> {
if self.timers.len() >= MAX_ACTIVE_TIMERS_PER_THREAD {
return Err(format!(
"too many persisted timers; each thread supports at most {MAX_ACTIVE_TIMERS_PER_THREAD} timers"
));
}
let PersistedTimer {
timer,
pending_run: persisted_pending_run,
} = persisted;
let TriggerTiming {
trigger,
pending_run,
next_run_at,
timer_delay,
} = timing_for_restored_trigger(
timer.trigger,
timer.created_at,
persisted_pending_run,
timer.next_run_at,
now,
)?;
let timer = ThreadTimer {
trigger,
next_run_at,
..timer
};
let id = timer.id.clone();
self.timers.insert(
id,
TimerRuntime {
timer,
pending_run,
timer_cancel,
},
);
Ok(timer_delay.map(|delay| TimerTaskSpec { delay }))
}
pub(crate) fn remove_timer(&mut self, id: &str) -> Option<TimerRuntime> {
self.timers.remove(id)
}
pub(crate) fn restore_runtime(&mut self, runtime: TimerRuntime) {
self.timers.insert(runtime.timer.id.clone(), runtime);
}
pub(crate) fn cancel_runtime(runtime: &TimerRuntime) {
if let Some(cancel) = runtime.timer_cancel.as_ref() {
cancel.cancel();
}
}
pub(crate) fn mark_timer_due(&mut self, id: &str, now: chrono::DateTime<Utc>) -> bool {
let Some(runtime) = self.timers.get_mut(id) else {
return false;
};
let mut changed = !runtime.pending_run;
runtime.pending_run = true;
match next_run_after_due(&runtime.timer.trigger, runtime.timer.created_at, now) {
Ok(next_run_at) if runtime.timer.next_run_at != next_run_at => {
runtime.timer.next_run_at = next_run_at;
changed = true;
}
Ok(_) => {}
Err(err) => {
tracing::warn!(
"failed to advance timer {} trigger: {err}",
runtime.timer.id
);
}
}
changed
}
pub(crate) fn timer_spec_for_timer(
&self,
id: &str,
now: chrono::DateTime<Utc>,
) -> Option<TimerTaskSpec> {
let runtime = self.timers.get(id)?;
let next_run_at = runtime.timer.next_run_at?;
if runtime.pending_run && !runtime.timer.trigger.is_recurring() {
return None;
}
Some(TimerTaskSpec {
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_timer(
&mut self,
now: chrono::DateTime<Utc>,
can_after_turn: bool,
can_steer_current_turn: bool,
) -> Option<ClaimedTimer> {
let (next_timer_id, actual_delivery) = self
.timers
.values()
.filter(|runtime| runtime.pending_run)
.filter_map(|runtime| {
if runtime.timer.trigger.is_idle_recurring() {
if can_after_turn {
return Some((runtime, TimerDelivery::AfterTurn));
}
return None;
}
let actual_delivery = match runtime.timer.delivery {
TimerDelivery::AfterTurn if can_after_turn => TimerDelivery::AfterTurn,
TimerDelivery::AfterTurn => return None,
TimerDelivery::SteerCurrentTurn if can_steer_current_turn => {
TimerDelivery::SteerCurrentTurn
}
TimerDelivery::SteerCurrentTurn if can_after_turn => TimerDelivery::AfterTurn,
TimerDelivery::SteerCurrentTurn => return None,
};
Some((runtime, actual_delivery))
})
.min_by(|(left, _), (right, _)| {
left.timer
.last_run_at
.unwrap_or(left.timer.created_at)
.cmp(&right.timer.last_run_at.unwrap_or(right.timer.created_at))
.then_with(|| left.timer.created_at.cmp(&right.timer.created_at))
.then_with(|| left.timer.id.cmp(&right.timer.id))
})
.map(|(runtime, actual_delivery)| (runtime.timer.id.clone(), actual_delivery))?;
let runtime = self.timers.remove(&next_timer_id)?;
let TimerRuntime {
mut timer,
pending_run: _,
timer_cancel,
} = runtime;
let is_recurring = timer.trigger.is_recurring();
let deleted_one_shot_timer = !is_recurring;
if deleted_one_shot_timer {
if let Some(cancel) = timer_cancel.as_ref() {
cancel.cancel();
}
} else {
timer.last_run_at = Some(now.timestamp());
let pending_run = timer.trigger.is_idle_recurring();
self.timers.insert(
timer.id.clone(),
TimerRuntime {
timer: timer.clone(),
pending_run,
timer_cancel,
},
);
}
Some(ClaimedTimer {
timer: timer.clone(),
context: TimerInvocationContext {
current_timer_id: timer.id,
content: timer.content,
instructions: timer.instructions,
meta: timer.meta,
recurring: is_recurring,
delivery: actual_delivery,
queued_at: now.timestamp(),
},
deleted_one_shot_timer,
})
}
}
pub fn build_thread_timer_create_params(
params: ThreadTimerStorageCreateParams,
) -> Result<codex_state::ThreadTimerCreateParams, String> {
validate_meta(&params.payload.meta)?;
let id = uuid::Uuid::new_v4().to_string();
let now = Utc::now();
let TriggerTiming {
trigger,
pending_run,
next_run_at,
timer_delay: _,
} = timing_for_new_trigger(params.trigger, now, now)?;
Ok(codex_state::ThreadTimerCreateParams {
id,
thread_id: params.thread_id,
source: params.source,
client_id: params.client_id,
trigger_json: serde_json::to_string(&trigger)
.map_err(|err| format!("failed to serialize timer trigger: {err}"))?,
content: params.payload.content,
instructions: params.payload.instructions,
meta_json: serde_json::to_string(&params.payload.meta)
.map_err(|err| format!("failed to serialize timer metadata: {err}"))?,
delivery: params.delivery.as_str().to_string(),
created_at: now.timestamp(),
next_run_at,
last_run_at: None,
pending_run,
})
}
pub(crate) fn timer_prompt_input_item(timer: &TimerInvocationContext) -> ResponseInputItem {
let instructions = timer_message_instructions(timer);
message_prompt_input_item(&MessageInvocationContext {
source: format!("timer {}", timer.current_timer_id),
content: timer_message_content(timer),
instructions,
meta: timer.meta.clone(),
queued_at: timer.queued_at,
})
}
fn timer_message_content(timer: &TimerInvocationContext) -> String {
let preview = timer
.content
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if preview.is_empty() {
return "Timer fired.".to_string();
}
let preview = if preview.chars().count() > TIMER_CONTENT_PREVIEW_MAX_CHARS {
let mut truncated = preview
.chars()
.take(TIMER_CONTENT_PREVIEW_MAX_CHARS.saturating_sub(3))
.collect::<String>();
truncated.push_str("...");
truncated
} else {
preview
};
format!("Timer fired: {preview}")
}
fn timer_message_instructions(timer: &TimerInvocationContext) -> Option<String> {
let timer_instructions = if timer.recurring {
RECURRING_TIMER_INSTRUCTIONS.replace("{{CURRENT_TIMER_ID}}", &timer.current_timer_id)
} else {
ONE_SHOT_TIMER_INSTRUCTIONS.to_string()
};
let content = timer.content.trim();
let mut parts = Vec::new();
if !content.is_empty() {
parts.push(content.to_string());
}
if let Some(instructions) = timer.instructions.as_deref()
&& !instructions.trim().is_empty()
{
parts.push(instructions.trim().to_string());
}
parts.push(timer_instructions);
Some(parts.join("\n\n"))
}
#[cfg(test)]
mod tests {
use super::CreateTimer;
use super::MAX_ACTIVE_TIMERS_PER_THREAD;
use super::PersistedTimer;
use super::ThreadTimer;
use super::TimerDelivery;
use super::TimerInvocationContext;
use super::TimersState;
use super::timer_prompt_input_item;
use crate::messages::MessagePayload;
use crate::timer_trigger::TimerTrigger;
use chrono::TimeZone;
use chrono::Utc;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
const ZERO_SECONDS: u64 = 0;
const TEN_SECONDS: u64 = 10;
const SIXTY_SECONDS: u64 = 60;
fn delay(seconds: u64, repeat: Option<bool>) -> TimerTrigger {
TimerTrigger::Delay { seconds, repeat }
}
#[test]
fn claim_one_shot_timer_removes_it() {
let now = Utc.timestamp_opt(100, 0).single().expect("valid timestamp");
let mut timers = TimersState::default();
let (timer, timer_spec) = timers
.create_timer(
CreateTimer {
id: "timer-1".to_string(),
trigger: delay(ZERO_SECONDS, /*repeat*/ None),
payload: MessagePayload {
content: "run tests".to_string(),
instructions: None,
meta: BTreeMap::new(),
},
delivery: TimerDelivery::AfterTurn,
now,
},
/*timer_cancel*/ None,
)
.expect("timer should be created");
assert_eq!(timer_spec, None);
assert_eq!(timers.list_timers(), vec![timer]);
let claimed = timers
.claim_next_timer(
now, /*can_after_turn*/ true, /*can_steer_current_turn*/ true,
)
.expect("timer should be claimed");
assert_eq!(claimed.context.current_timer_id, "timer-1");
assert!(claimed.deleted_one_shot_timer);
assert!(timers.list_timers().is_empty());
}
#[test]
fn claim_next_timer_prefers_pending_timer_that_ran_least_recently() {
let create_first = Utc.timestamp_opt(100, 0).single().expect("valid timestamp");
let create_second = Utc.timestamp_opt(101, 0).single().expect("valid timestamp");
let first_claimed_at = Utc.timestamp_opt(110, 0).single().expect("valid timestamp");
let second_claimed_at = Utc.timestamp_opt(111, 0).single().expect("valid timestamp");
let mut timers = TimersState::default();
timers
.create_timer(
CreateTimer {
id: "timer-1".to_string(),
trigger: delay(TEN_SECONDS, Some(true)),
payload: MessagePayload {
content: "older recurring timer".to_string(),
instructions: None,
meta: BTreeMap::new(),
},
delivery: TimerDelivery::AfterTurn,
now: create_first,
},
/*timer_cancel*/ None,
)
.expect("timer should be created");
timers
.create_timer(
CreateTimer {
id: "timer-2".to_string(),
trigger: delay(TEN_SECONDS, Some(true)),
payload: MessagePayload {
content: "newer recurring timer".to_string(),
instructions: None,
meta: BTreeMap::new(),
},
delivery: TimerDelivery::AfterTurn,
now: create_second,
},
/*timer_cancel*/ None,
)
.expect("timer should be created");
timers.mark_timer_due("timer-1", first_claimed_at);
timers.mark_timer_due("timer-2", first_claimed_at);
let first = timers
.claim_next_timer(
first_claimed_at,
/*can_after_turn*/ true,
/*can_steer_current_turn*/ true,
)
.expect("first timer should be claimed");
assert_eq!(first.context.current_timer_id, "timer-1");
let second = timers
.claim_next_timer(
second_claimed_at,
/*can_after_turn*/ true,
/*can_steer_current_turn*/ true,
)
.expect("second timer should be claimed");
assert_eq!(second.context.current_timer_id, "timer-2");
}
#[test]
fn idle_recurring_timer_remains_pending_after_claim() {
let now = Utc.timestamp_opt(100, 0).single().expect("valid timestamp");
let mut timers = TimersState::default();
let (timer, timer_spec) = timers
.create_timer(
CreateTimer {
id: "timer-1".to_string(),
trigger: delay(ZERO_SECONDS, Some(true)),
payload: MessagePayload {
content: "keep going".to_string(),
instructions: None,
meta: BTreeMap::new(),
},
delivery: TimerDelivery::AfterTurn,
now,
},
/*timer_cancel*/ None,
)
.expect("timer should be created");
assert_eq!(timer_spec, None);
let claimed = timers
.claim_next_timer(
now, /*can_after_turn*/ true, /*can_steer_current_turn*/ true,
)
.expect("timer should be claimed");
assert!(!claimed.deleted_one_shot_timer);
assert_eq!(
timers.persisted_timers(),
vec![PersistedTimer {
timer: ThreadTimer {
last_run_at: Some(100),
..timer
},
pending_run: true,
}]
);
}
#[test]
fn idle_recurring_timer_waits_for_idle_even_if_delivery_requests_steer() {
let now = Utc.timestamp_opt(100, 0).single().expect("valid timestamp");
let mut timers = TimersState::default();
timers
.create_timer(
CreateTimer {
id: "timer-1".to_string(),
trigger: delay(ZERO_SECONDS, Some(true)),
payload: MessagePayload {
content: "keep going".to_string(),
instructions: None,
meta: BTreeMap::new(),
},
delivery: TimerDelivery::SteerCurrentTurn,
now,
},
/*timer_cancel*/ None,
)
.expect("timer should be created");
assert_eq!(
timers.claim_next_timer(
now, /*can_after_turn*/ false, /*can_steer_current_turn*/ true,
),
None
);
let claimed = timers
.claim_next_timer(
now, /*can_after_turn*/ true, /*can_steer_current_turn*/ false,
)
.expect("timer should be claimed when idle");
assert_eq!(claimed.context.delivery, TimerDelivery::AfterTurn);
}
#[test]
fn create_timer_rejects_more_than_maximum_active_timers() {
let now = Utc.timestamp_opt(100, 0).single().expect("valid timestamp");
let mut timers = TimersState::default();
for index in 0..MAX_ACTIVE_TIMERS_PER_THREAD {
timers
.create_timer(
CreateTimer {
id: format!("timer-{index}"),
trigger: delay(SIXTY_SECONDS, Some(true)),
payload: MessagePayload {
content: format!("content-{index}"),
instructions: None,
meta: BTreeMap::new(),
},
delivery: TimerDelivery::AfterTurn,
now,
},
/*timer_cancel*/ None,
)
.expect("timer should be created");
}
let result = timers.create_timer(
CreateTimer {
id: "timer-overflow".to_string(),
trigger: delay(SIXTY_SECONDS, Some(true)),
payload: MessagePayload {
content: "overflow".to_string(),
instructions: None,
meta: BTreeMap::new(),
},
delivery: TimerDelivery::AfterTurn,
now,
},
/*timer_cancel*/ None,
);
assert_eq!(
result,
Err(format!(
"too many active timers; each thread supports at most {MAX_ACTIVE_TIMERS_PER_THREAD} timers"
))
);
}
#[test]
fn timer_prompt_input_is_visible_user_input() {
let item = timer_prompt_input_item(&TimerInvocationContext {
current_timer_id: "timer-1".to_string(),
content: "run tests".to_string(),
instructions: None,
meta: BTreeMap::new(),
recurring: true,
delivery: TimerDelivery::SteerCurrentTurn,
queued_at: 100,
});
assert_eq!(
item,
ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<codex_message>\n<source>timer timer-1</source>\n<queued_at>100</queued_at>\n<content>\nTimer fired: run tests\n</content>\n<instructions>\nrun tests\n\nThis timer should keep running on its schedule after this invocation.\nDo not call delete_timer just because you completed this invocation.\nCall delete_timer with {&quot;id&quot;:&quot;timer-1&quot;} only if the user's timer message included an explicit stop condition, such as &quot;until&quot;, &quot;stop when&quot;, or &quot;while&quot;, and that condition is now satisfied.\nDo not expose scheduler internals unless they matter to the user.\n</instructions>\n<meta />\n</codex_message>".to_string(),
}],
}
);
}
#[test]
fn one_shot_timer_prompt_input_omits_delete_instruction() {
let item = timer_prompt_input_item(&TimerInvocationContext {
current_timer_id: "timer-1".to_string(),
content: "run tests once".to_string(),
instructions: Some("user-specific instruction".to_string()),
meta: BTreeMap::from([("ticket".to_string(), "ABC_123".to_string())]),
recurring: false,
delivery: TimerDelivery::AfterTurn,
queued_at: 101,
});
assert_eq!(
item,
ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<codex_message>\n<source>timer timer-1</source>\n<queued_at>101</queued_at>\n<content>\nTimer fired: run tests once\n</content>\n<instructions>\nrun tests once\n\nuser-specific instruction\n\nThis one-shot timer has already been removed from the schedule, so you do not need to call delete_timer.\nDo not expose scheduler internals unless they matter to the user.\n</instructions>\n<meta>\n <entry id=\"ticket\">ABC_123</entry>\n</meta>\n</codex_message>".to_string(),
}],
}
);
}
}
+6 -1
View File
@@ -1,5 +1,5 @@
pub(crate) mod agent_jobs;
pub(crate) mod apply_patch;
pub mod apply_patch;
mod dynamic;
mod js_repl;
mod list_dir;
@@ -13,6 +13,7 @@ mod request_permissions;
mod request_user_input;
mod shell;
mod test_sync;
mod timers;
mod tool_search;
mod tool_suggest;
pub(crate) mod unified_exec;
@@ -47,6 +48,10 @@ pub use request_user_input::RequestUserInputHandler;
pub use shell::ShellCommandHandler;
pub use shell::ShellHandler;
pub use test_sync::TestSyncHandler;
pub use timers::CreateTimerHandler;
pub use timers::DeleteTimerHandler;
pub use timers::ListTimersHandler;
pub use timers::QueueMessageHandler;
pub use tool_search::ToolSearchHandler;
pub use tool_suggest::ToolSuggestHandler;
pub use unified_exec::UnifiedExecHandler;
+176
View File
@@ -0,0 +1,176 @@
//! Built-in tool handlers for thread-local persistent timer management.
//!
//! These handlers bridge timer and queued-message tool calls onto the current
//! thread session's timer registry.
use serde::Deserialize;
use std::collections::BTreeMap;
use crate::function_tool::FunctionCallError;
use crate::messages::MessagePayload;
use crate::timers::ThreadTimerTrigger;
use crate::timers::TimerDelivery;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
#[derive(Deserialize)]
struct CreateTimerArgs {
trigger: ThreadTimerTrigger,
content: Option<String>,
prompt: Option<String>,
instructions: Option<String>,
#[serde(default)]
meta: BTreeMap<String, String>,
delivery: TimerDelivery,
}
#[derive(Deserialize)]
struct DeleteTimerArgs {
id: String,
}
#[derive(Deserialize)]
struct QueueMessageArgs {
thread_id: String,
content: String,
instructions: Option<String>,
#[serde(default)]
meta: BTreeMap<String, String>,
#[serde(default = "default_delivery")]
delivery: TimerDelivery,
}
fn default_delivery() -> TimerDelivery {
TimerDelivery::AfterTurn
}
pub struct CreateTimerHandler;
impl ToolHandler for CreateTimerHandler {
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolPayload::Function { arguments } = invocation.payload else {
return Err(FunctionCallError::RespondToModel(
"create_timer received unsupported payload".to_string(),
));
};
let args: CreateTimerArgs = parse_arguments(&arguments)?;
let content = args.content.or(args.prompt).ok_or_else(|| {
FunctionCallError::RespondToModel("create_timer requires `content`".to_string())
})?;
let timer = invocation
.session
.create_timer(
args.trigger,
MessagePayload {
content,
instructions: args.instructions,
meta: args.meta,
},
args.delivery,
)
.await
.map_err(FunctionCallError::RespondToModel)?;
let content = serde_json::to_string(&timer).map_err(|err| {
FunctionCallError::Fatal(format!("failed to serialize create_timer response: {err}"))
})?;
Ok(FunctionToolOutput::from_text(content, Some(true)))
}
}
pub struct QueueMessageHandler;
impl ToolHandler for QueueMessageHandler {
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolPayload::Function { arguments } = invocation.payload else {
return Err(FunctionCallError::RespondToModel(
"queue_message received unsupported payload".to_string(),
));
};
let args: QueueMessageArgs = parse_arguments(&arguments)?;
let message = invocation
.session
.queue_message_to_thread(
args.thread_id,
MessagePayload {
content: args.content,
instructions: args.instructions,
meta: args.meta,
},
args.delivery,
)
.await
.map_err(FunctionCallError::RespondToModel)?;
let content = serde_json::to_string(&message).map_err(|err| {
FunctionCallError::Fatal(format!("failed to serialize queue_message response: {err}"))
})?;
Ok(FunctionToolOutput::from_text(content, Some(true)))
}
}
pub struct DeleteTimerHandler;
impl ToolHandler for DeleteTimerHandler {
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolPayload::Function { arguments } = invocation.payload else {
return Err(FunctionCallError::RespondToModel(
"delete_timer received unsupported payload".to_string(),
));
};
let args: DeleteTimerArgs = parse_arguments(&arguments)?;
let deleted = invocation
.session
.delete_timer(&args.id)
.await
.map_err(FunctionCallError::RespondToModel)?;
let content = serde_json::json!({ "deleted": deleted }).to_string();
Ok(FunctionToolOutput::from_text(content, Some(deleted)))
}
}
pub struct ListTimersHandler;
impl ToolHandler for ListTimersHandler {
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
match invocation.payload {
ToolPayload::Function { .. } => {}
_ => {
return Err(FunctionCallError::RespondToModel(
"list_timers received unsupported payload".to_string(),
));
}
}
let timers = invocation.session.list_timers().await;
let content = serde_json::to_string(&timers).map_err(|err| {
FunctionCallError::Fatal(format!("failed to serialize list_timers response: {err}"))
})?;
Ok(FunctionToolOutput::from_text(content, Some(true)))
}
}
+16
View File
@@ -1,5 +1,9 @@
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::tools::handlers::CreateTimerHandler;
use crate::tools::handlers::DeleteTimerHandler;
use crate::tools::handlers::ListTimersHandler;
use crate::tools::handlers::QueueMessageHandler;
use crate::tools::handlers::agent_jobs::BatchJobHandler;
use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS;
use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS;
@@ -161,6 +165,18 @@ pub(crate) fn build_specs_with_discoverable_tools(
for handler in plan.handlers {
match handler.kind {
ToolHandlerKind::CreateTimer => {
builder.register_handler(handler.name, Arc::new(CreateTimerHandler));
}
ToolHandlerKind::DeleteTimer => {
builder.register_handler(handler.name, Arc::new(DeleteTimerHandler));
}
ToolHandlerKind::ListTimers => {
builder.register_handler(handler.name, Arc::new(ListTimersHandler));
}
ToolHandlerKind::QueueMessage => {
builder.register_handler(handler.name, Arc::new(QueueMessageHandler));
}
ToolHandlerKind::AgentJobs => {
builder.register_handler(handler.name, Arc::new(BatchJobHandler));
}
@@ -0,0 +1,13 @@
<timer_fired>
<id>{{CURRENT_TIMER_ID}}</id>
<trigger>{{TRIGGER}}</trigger>
<delivery>{{DELIVERY}}</delivery>
<recurring>false</recurring>
<prompt>
{{PROMPT}}
</prompt>
<instructions>
This one-shot timer has already been removed from the schedule, so you do not need to call delete_timer.
Do not expose scheduler internals unless they matter to the user.
</instructions>
</timer_fired>
@@ -0,0 +1,15 @@
<timer_fired>
<id>{{CURRENT_TIMER_ID}}</id>
<trigger>{{TRIGGER}}</trigger>
<delivery>{{DELIVERY}}</delivery>
<recurring>true</recurring>
<prompt>
{{PROMPT}}
</prompt>
<instructions>
This timer should keep running on its schedule after this invocation.
Do not call delete_timer just because you completed this invocation.
Call delete_timer with {"id":"{{CURRENT_TIMER_ID}}"} only if the user's timer prompt included an explicit stop condition, such as "until", "stop when", or "while", and that condition is now satisfied.
Do not expose scheduler internals unless they matter to the user.
</instructions>
</timer_fired>
+1
View File
@@ -143,6 +143,7 @@ mod sqlite_state;
mod stream_error_allows_next_turn;
mod stream_no_completed;
mod subagent_notifications;
mod timers;
mod tool_harness;
mod tool_parallelism;
mod tool_suggest;
+238
View File
@@ -0,0 +1,238 @@
use anyhow::Result;
use anyhow::anyhow;
use chrono::Utc;
use codex_core::messages::MessagePayload;
use codex_core::timers::TIMER_FIRED_BACKGROUND_EVENT_PREFIX;
use codex_core::timers::ThreadTimer;
use codex_core::timers::ThreadTimerTrigger;
use codex_core::timers::TimerDelivery;
use codex_features::Feature;
use codex_protocol::protocol::EventMsg;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use pretty_assertions::assert_eq;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_timer_emits_fired_background_event_when_timer_starts() -> Result<()> {
assert_after_turn_timer_starts_and_emits_fired_event().await
}
#[tokio::test(flavor = "current_thread")]
async fn create_timer_starts_on_current_thread_runtime() -> Result<()> {
assert_after_turn_timer_starts_and_emits_fired_event().await
}
async fn assert_after_turn_timer_starts_and_emits_fired_event() -> Result<()> {
let server = start_mock_server().await;
let mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "timer ran"),
ev_completed("resp-1"),
]),
)
.await;
let mut builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::TimerScheduler)
.unwrap_or_else(|err| panic!("test config should allow feature update: {err}"));
config
.features
.enable(Feature::Sqlite)
.unwrap_or_else(|err| panic!("test config should allow feature update: {err}"));
});
let test = builder.build(&server).await?;
let created = test
.codex
.create_timer(
ThreadTimerTrigger::Delay {
seconds: 0,
repeat: None,
},
MessagePayload {
content: "run timer".to_string(),
instructions: None,
meta: Default::default(),
},
TimerDelivery::AfterTurn,
)
.await
.map_err(|err| anyhow!("{err}"))?;
let payload = wait_for_event_match(&test.codex, |event| match event {
EventMsg::BackgroundEvent(event) => event
.message
.strip_prefix(TIMER_FIRED_BACKGROUND_EVENT_PREFIX)
.map(str::to_owned),
_ => None,
})
.await;
let fired: ThreadTimer = serde_json::from_str(&payload)?;
assert_eq!(fired, created);
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let user_messages = mock.single_request().message_input_texts("user");
let timer_messages = user_messages
.iter()
.filter(|message| message.contains("<codex_message>"))
.collect::<Vec<_>>();
assert_eq!(timer_messages.len(), 1);
let timer_message = timer_messages[0];
assert!(timer_message.contains(&format!("<source>timer {}</source>", created.id)));
assert!(timer_message.contains("<content>\nTimer fired: run timer\n</content>"));
assert!(timer_message.contains("<instructions>\nrun timer\n\nThis one-shot timer"));
assert!(timer_message.contains("<meta />"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_timer_persists_source_and_client_metadata() -> Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::TimerScheduler)
.unwrap_or_else(|err| panic!("test config should allow feature update: {err}"));
config
.features
.enable(Feature::Sqlite)
.unwrap_or_else(|err| panic!("test config should allow feature update: {err}"));
});
let test = builder.build(&server).await?;
let created = test
.codex
.create_timer(
ThreadTimerTrigger::Delay {
seconds: 60,
repeat: Some(true),
},
MessagePayload {
content: "run timer".to_string(),
instructions: None,
meta: Default::default(),
},
TimerDelivery::AfterTurn,
)
.await
.map_err(|err| anyhow!("{err}"))?;
let db = test.codex.state_db().expect("state db enabled");
let timers = db
.list_thread_timers(&test.session_configured.session_id.to_string())
.await?;
assert_eq!(timers.len(), 1);
assert_eq!(timers[0].id, created.id);
assert_eq!(timers[0].source, "agent");
assert_eq!(timers[0].client_id, "codex-cli");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_timer_lazily_opens_sqlite_for_ephemeral_thread() -> Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.ephemeral = true;
config
.features
.enable(Feature::TimerScheduler)
.unwrap_or_else(|err| panic!("test config should allow feature update: {err}"));
});
let test = builder.build(&server).await?;
let created = test
.codex
.create_timer(
ThreadTimerTrigger::Delay {
seconds: 60,
repeat: Some(true),
},
MessagePayload {
content: "run timer".to_string(),
instructions: None,
meta: Default::default(),
},
TimerDelivery::AfterTurn,
)
.await
.map_err(|err| anyhow!("{err}"))?;
assert_eq!(
test.codex.list_timers().await,
vec![created],
"ephemeral threads should still open sqlite timer storage lazily"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_timers_discovers_externally_inserted_timer() -> Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::TimerScheduler)
.unwrap_or_else(|err| panic!("test config should allow feature update: {err}"));
config
.features
.enable(Feature::Sqlite)
.unwrap_or_else(|err| panic!("test config should allow feature update: {err}"));
});
let test = builder.build(&server).await?;
let db = test.codex.state_db().expect("state db enabled");
let created_at = Utc::now().timestamp();
db.create_thread_timer(&codex_state::ThreadTimerCreateParams {
id: "external-timer".to_string(),
thread_id: test.session_configured.session_id.to_string(),
source: "client".to_string(),
client_id: "external-client".to_string(),
trigger_json: r#"{"kind":"delay","seconds":60,"repeat":true}"#.to_string(),
content: "external timer".to_string(),
instructions: None,
meta_json: "{}".to_string(),
delivery: "after-turn".to_string(),
created_at,
next_run_at: Some(created_at + 60),
last_run_at: None,
pending_run: false,
})
.await?;
let timers = test.codex.list_timers().await;
assert_eq!(timers.len(), 1);
assert_eq!(timers[0].id, "external-timer");
assert_eq!(
timers[0].trigger,
ThreadTimerTrigger::Delay {
seconds: 60,
repeat: Some(true),
}
);
assert_eq!(timers[0].content, "external timer");
assert_eq!(timers[0].delivery, TimerDelivery::AfterTurn);
assert_eq!(timers[0].created_at, created_at);
assert_eq!(timers[0].last_run_at, None);
Ok(())
}
+8
View File
@@ -172,6 +172,8 @@ pub enum Feature {
Personality,
/// Enable native artifact tools.
Artifact,
/// Enable per-thread persistent timer scheduling tools and APIs.
TimerScheduler,
/// Enable Fast mode selection in the TUI and request layer.
FastMode,
/// Enable experimental realtime voice conversation mode in the TUI.
@@ -857,6 +859,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::TimerScheduler,
key: "timer_tool",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::FastMode,
key: "fast_mode",
+1
View File
@@ -56,6 +56,7 @@ pub use recorder::RolloutRecorder;
pub use recorder::RolloutRecorderParams;
pub use recorder::append_rollout_item_to_path;
pub use session_index::append_thread_name;
pub use session_index::find_thread_ids_by_name;
pub use session_index::find_thread_meta_by_name_str;
pub use session_index::find_thread_name_by_id;
pub use session_index::find_thread_names_by_ids;
-1
View File
@@ -713,7 +713,6 @@ async fn build_thread_item(
{
return None;
}
// Apply filters: must have session meta and at least one user message event
if summary.saw_session_meta && summary.saw_user_event {
let HeadTailSummary {
thread_id,
+109 -10
View File
@@ -31,6 +31,23 @@ fn test_config(codex_home: &Path) -> RolloutConfig {
}
fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result<PathBuf> {
write_session_file_with_user_message(root, ts, uuid, /*include_user_message*/ true)
}
fn write_session_file_without_user_message(
root: &Path,
ts: &str,
uuid: Uuid,
) -> std::io::Result<PathBuf> {
write_session_file_with_user_message(root, ts, uuid, /*include_user_message*/ false)
}
fn write_session_file_with_user_message(
root: &Path,
ts: &str,
uuid: Uuid,
include_user_message: bool,
) -> std::io::Result<PathBuf> {
let day_dir = root.join("sessions/2025/01/03");
fs::create_dir_all(&day_dir)?;
let path = day_dir.join(format!("rollout-{ts}-{uuid}.jsonl"));
@@ -49,16 +66,18 @@ fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result<Path
},
});
writeln!(file, "{meta}")?;
let user_event = serde_json::json!({
"timestamp": ts,
"type": "event_msg",
"payload": {
"type": "user_message",
"message": "Hello from user",
"kind": "plain",
},
});
writeln!(file, "{user_event}")?;
if include_user_message {
let user_event = serde_json::json!({
"timestamp": ts,
"type": "event_msg",
"payload": {
"type": "user_message",
"message": "Hello from user",
"kind": "plain",
},
});
writeln!(file, "{user_event}")?;
}
Ok(path)
}
@@ -523,6 +542,86 @@ async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Resul
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");
let config = test_config(home.path());
let empty_uuid = Uuid::from_u128(9015);
let valid_uuid = Uuid::from_u128(9016);
let empty_thread_id =
ThreadId::from_string(&empty_uuid.to_string()).expect("valid empty thread id");
let valid_thread_id = ThreadId::from_string(&valid_uuid.to_string()).expect("valid thread id");
let empty_path =
write_session_file_without_user_message(home.path(), "2025-01-03T15-00-00", empty_uuid)?;
let valid_path = write_session_file(home.path(), "2025-01-03T14-00-00", valid_uuid)?;
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 empty_created_at = chrono::Utc
.with_ymd_and_hms(2025, 1, 3, 15, 0, 0)
.single()
.expect("valid empty thread datetime");
let mut empty_builder = codex_state::ThreadMetadataBuilder::new(
empty_thread_id,
empty_path,
empty_created_at,
SessionSource::Cli,
);
empty_builder.model_provider = Some(config.model_provider_id.clone());
empty_builder.cwd = home.path().to_path_buf();
let empty_metadata = empty_builder.build(config.model_provider_id.as_str());
runtime
.upsert_thread(&empty_metadata)
.await
.expect("state db empty upsert should succeed");
let valid_created_at = chrono::Utc
.with_ymd_and_hms(2025, 1, 3, 14, 0, 0)
.single()
.expect("valid thread datetime");
let mut valid_builder = codex_state::ThreadMetadataBuilder::new(
valid_thread_id,
valid_path.clone(),
valid_created_at,
SessionSource::Cli,
);
valid_builder.model_provider = Some(config.model_provider_id.clone());
valid_builder.cwd = home.path().to_path_buf();
let mut valid_metadata = valid_builder.build(config.model_provider_id.as_str());
valid_metadata.first_user_message = Some("Hello from user".to_string());
runtime
.upsert_thread(&valid_metadata)
.await
.expect("state db valid 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, valid_path);
assert!(page.next_cursor.is_none());
Ok(())
}
#[tokio::test]
async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Result<()> {
let home = TempDir::new().expect("temp dir");
+42
View File
@@ -112,6 +112,48 @@ pub async fn find_thread_names_by_ids(
Ok(names)
}
/// Find all thread ids whose latest indexed thread name exactly matches `name`.
pub async fn find_thread_ids_by_name(
codex_home: &Path,
name: &str,
) -> std::io::Result<Vec<ThreadId>> {
let name = name.trim();
if name.is_empty() {
return Ok(Vec::new());
}
let path = session_index_path(codex_home);
if !path.exists() {
return Ok(Vec::new());
}
let file = tokio::fs::File::open(&path).await?;
let reader = tokio::io::BufReader::new(file);
let mut lines = reader.lines();
let mut latest_names = HashMap::<ThreadId, String>::new();
while let Some(line) = lines.next_line().await? {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Ok(entry) = serde_json::from_str::<SessionIndexEntry>(trimmed) else {
continue;
};
let thread_name = entry.thread_name.trim();
if thread_name.is_empty() {
continue;
}
latest_names.insert(entry.id, thread_name.to_string());
}
let mut ids = latest_names
.into_iter()
.filter_map(|(thread_id, thread_name)| (thread_name == name).then_some(thread_id))
.collect::<Vec<_>>();
ids.sort_by_key(ToString::to_string);
Ok(ids)
}
/// Locate a recorded thread rollout and read its session metadata by thread name.
/// Returns the newest indexed name that still has a readable rollout header.
pub async fn find_thread_meta_by_name_str(
@@ -265,6 +265,47 @@ async fn find_thread_names_by_ids_prefers_latest_entry() -> std::io::Result<()>
Ok(())
}
#[tokio::test]
async fn find_thread_ids_by_name_uses_latest_name_per_thread() -> std::io::Result<()> {
let temp = TempDir::new()?;
let path = session_index_path(temp.path());
let id1 = ThreadId::new();
let id2 = ThreadId::new();
let id3 = ThreadId::new();
let lines = vec![
SessionIndexEntry {
id: id1,
thread_name: "target".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
SessionIndexEntry {
id: id2,
thread_name: "target".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
SessionIndexEntry {
id: id1,
thread_name: "renamed".to_string(),
updated_at: "2024-01-02T00:00:00Z".to_string(),
},
SessionIndexEntry {
id: id3,
thread_name: "target".to_string(),
updated_at: "2024-01-03T00:00:00Z".to_string(),
},
];
write_index(&path, &lines)?;
let found = find_thread_ids_by_name(temp.path(), "target").await?;
let expected = {
let mut ids = vec![id2, id3];
ids.sort_by_key(ToString::to_string);
ids
};
assert_eq!(found, expected);
Ok(())
}
#[test]
fn scan_index_finds_latest_match_among_mixed_entries() -> std::io::Result<()> {
let temp = TempDir::new()?;
@@ -0,0 +1,22 @@
CREATE TABLE thread_timers (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
source TEXT NOT NULL,
client_id TEXT NOT NULL,
trigger_json TEXT NOT NULL,
prompt TEXT NOT NULL,
delivery TEXT NOT NULL,
created_at INTEGER NOT NULL,
next_run_at INTEGER,
last_run_at INTEGER,
pending_run INTEGER NOT NULL
);
CREATE INDEX idx_thread_timers_thread_created
ON thread_timers(thread_id, created_at, id);
CREATE INDEX idx_thread_timers_thread_pending
ON thread_timers(thread_id, pending_run, created_at, id);
CREATE INDEX idx_thread_timers_thread_next_run
ON thread_timers(thread_id, next_run_at);
@@ -0,0 +1,77 @@
ALTER TABLE thread_timers RENAME TO thread_timers_old;
CREATE TABLE thread_timers (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
source TEXT NOT NULL,
client_id TEXT NOT NULL,
trigger_json TEXT NOT NULL,
content TEXT NOT NULL,
instructions TEXT,
meta_json TEXT NOT NULL,
delivery TEXT NOT NULL,
created_at INTEGER NOT NULL,
next_run_at INTEGER,
last_run_at INTEGER,
pending_run INTEGER NOT NULL
);
INSERT INTO thread_timers (
id,
thread_id,
source,
client_id,
trigger_json,
content,
instructions,
meta_json,
delivery,
created_at,
next_run_at,
last_run_at,
pending_run
)
SELECT
id,
thread_id,
source,
client_id,
trigger_json,
prompt,
NULL,
'{}',
delivery,
created_at,
next_run_at,
last_run_at,
pending_run
FROM thread_timers_old;
DROP TABLE thread_timers_old;
CREATE INDEX idx_thread_timers_thread_created
ON thread_timers(thread_id, created_at, id);
CREATE INDEX idx_thread_timers_thread_pending
ON thread_timers(thread_id, pending_run, created_at, id);
CREATE INDEX idx_thread_timers_thread_next_run
ON thread_timers(thread_id, next_run_at);
CREATE TABLE thread_messages (
seq INTEGER PRIMARY KEY,
id TEXT NOT NULL UNIQUE,
thread_id TEXT NOT NULL,
source TEXT NOT NULL,
content TEXT NOT NULL,
instructions TEXT,
meta_json TEXT NOT NULL,
delivery TEXT NOT NULL,
queued_at INTEGER NOT NULL
);
CREATE INDEX thread_messages_thread_order_idx
ON thread_messages(thread_id, queued_at, seq);
CREATE INDEX thread_messages_thread_delivery_order_idx
ON thread_messages(thread_id, delivery, queued_at, seq);
+8 -1
View File
@@ -43,10 +43,17 @@ pub use model::Stage1JobClaimOutcome;
pub use model::Stage1Output;
pub use model::Stage1OutputRef;
pub use model::Stage1StartupClaimParams;
pub use model::ThreadMessage;
pub use model::ThreadMessageClaim;
pub use model::ThreadMessageCreateParams;
pub use model::ThreadMetadata;
pub use model::ThreadMetadataBuilder;
pub use model::ThreadTimer;
pub use model::ThreadTimerCreateParams;
pub use model::ThreadTimerUpdateParams;
pub use model::ThreadsPage;
pub use runtime::RemoteControlEnrollmentRecord;
pub use runtime::TimerDataVersionChecker;
pub use runtime::logs_db_filename;
pub use runtime::logs_db_path;
pub use runtime::state_db_filename;
@@ -58,7 +65,7 @@ pub const SQLITE_HOME_ENV: &str = "CODEX_SQLITE_HOME";
pub const LOGS_DB_FILENAME: &str = "logs";
pub const LOGS_DB_VERSION: u32 = 2;
pub const STATE_DB_FILENAME: &str = "state";
pub const STATE_DB_VERSION: u32 = 5;
pub const STATE_DB_VERSION: u32 = 6;
/// Errors encountered during DB operations. Tags: [stage]
pub const DB_ERROR_METRIC: &str = "codex.db.error";
+10
View File
@@ -3,7 +3,9 @@ mod backfill_state;
mod graph;
mod log;
mod memories;
mod thread_message;
mod thread_metadata;
mod thread_timer;
pub use agent_job::AgentJob;
pub use agent_job::AgentJobCreateParams;
@@ -25,6 +27,9 @@ pub use memories::Stage1JobClaimOutcome;
pub use memories::Stage1Output;
pub use memories::Stage1OutputRef;
pub use memories::Stage1StartupClaimParams;
pub use thread_message::ThreadMessage;
pub use thread_message::ThreadMessageClaim;
pub use thread_message::ThreadMessageCreateParams;
pub use thread_metadata::Anchor;
pub use thread_metadata::BackfillStats;
pub use thread_metadata::ExtractionOutcome;
@@ -32,11 +37,16 @@ pub use thread_metadata::SortKey;
pub use thread_metadata::ThreadMetadata;
pub use thread_metadata::ThreadMetadataBuilder;
pub use thread_metadata::ThreadsPage;
pub use thread_timer::ThreadTimer;
pub use thread_timer::ThreadTimerCreateParams;
pub use thread_timer::ThreadTimerUpdateParams;
pub(crate) use agent_job::AgentJobItemRow;
pub(crate) use agent_job::AgentJobRow;
pub(crate) use memories::Stage1OutputRow;
pub(crate) use memories::stage1_output_ref_from_parts;
pub(crate) use thread_message::ThreadMessageRow;
pub(crate) use thread_metadata::ThreadRow;
pub(crate) use thread_metadata::anchor_from_item;
pub(crate) use thread_metadata::datetime_to_epoch_seconds;
pub(crate) use thread_timer::ThreadTimerRow;
@@ -0,0 +1,85 @@
use sqlx::FromRow;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ThreadMessageCreateParams {
pub id: String,
pub thread_id: String,
pub source: String,
pub content: String,
pub instructions: Option<String>,
pub meta_json: String,
pub delivery: String,
pub queued_at: i64,
}
impl ThreadMessageCreateParams {
pub fn new(
thread_id: String,
source: String,
content: String,
instructions: Option<String>,
meta_json: String,
delivery: String,
queued_at: i64,
) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
thread_id,
source,
content,
instructions,
meta_json,
delivery,
queued_at,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ThreadMessage {
pub seq: i64,
pub id: String,
pub thread_id: String,
pub source: String,
pub content: String,
pub instructions: Option<String>,
pub meta_json: String,
pub delivery: String,
pub queued_at: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ThreadMessageClaim {
Claimed(ThreadMessage),
Invalid { id: String, reason: String },
NotReady,
}
#[derive(Debug, FromRow)]
pub(crate) struct ThreadMessageRow {
pub seq: i64,
pub id: String,
pub thread_id: String,
pub source: String,
pub content: String,
pub instructions: Option<String>,
pub meta_json: String,
pub delivery: String,
pub queued_at: i64,
}
impl From<ThreadMessageRow> for ThreadMessage {
fn from(row: ThreadMessageRow) -> Self {
Self {
seq: row.seq,
id: row.id,
thread_id: row.thread_id,
source: row.source,
content: row.content,
instructions: row.instructions,
meta_json: row.meta_json,
delivery: row.delivery,
queued_at: row.queued_at,
}
}
}
+84
View File
@@ -0,0 +1,84 @@
use sqlx::FromRow;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ThreadTimerCreateParams {
pub id: String,
pub thread_id: String,
pub source: String,
pub client_id: String,
pub trigger_json: String,
pub content: String,
pub instructions: Option<String>,
pub meta_json: String,
pub delivery: String,
pub created_at: i64,
pub next_run_at: Option<i64>,
pub last_run_at: Option<i64>,
pub pending_run: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ThreadTimerUpdateParams {
pub trigger_json: String,
pub content: String,
pub instructions: Option<String>,
pub meta_json: String,
pub delivery: String,
pub next_run_at: Option<i64>,
pub last_run_at: Option<i64>,
pub pending_run: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ThreadTimer {
pub id: String,
pub thread_id: String,
pub source: String,
pub client_id: String,
pub trigger_json: String,
pub content: String,
pub instructions: Option<String>,
pub meta_json: String,
pub delivery: String,
pub created_at: i64,
pub next_run_at: Option<i64>,
pub last_run_at: Option<i64>,
pub pending_run: bool,
}
#[derive(Debug, FromRow)]
pub(crate) struct ThreadTimerRow {
pub id: String,
pub thread_id: String,
pub source: String,
pub client_id: String,
pub trigger_json: String,
pub content: String,
pub instructions: Option<String>,
pub meta_json: String,
pub delivery: String,
pub created_at: i64,
pub next_run_at: Option<i64>,
pub last_run_at: Option<i64>,
pub pending_run: i64,
}
impl From<ThreadTimerRow> for ThreadTimer {
fn from(row: ThreadTimerRow) -> Self {
Self {
id: row.id,
thread_id: row.thread_id,
source: row.source,
client_id: row.client_id,
trigger_json: row.trigger_json,
content: row.content,
instructions: row.instructions,
meta_json: row.meta_json,
delivery: row.delivery,
created_at: row.created_at,
next_run_at: row.next_run_at,
last_run_at: row.last_run_at,
pending_run: row.pending_run != 0,
}
}
}
+9
View File
@@ -13,8 +13,14 @@ use crate::LogRow;
use crate::STATE_DB_FILENAME;
use crate::STATE_DB_VERSION;
use crate::SortKey;
use crate::ThreadMessage;
use crate::ThreadMessageClaim;
use crate::ThreadMessageCreateParams;
use crate::ThreadMetadata;
use crate::ThreadMetadataBuilder;
use crate::ThreadTimer;
use crate::ThreadTimerCreateParams;
use crate::ThreadTimerUpdateParams;
use crate::ThreadsPage;
use crate::apply_rollout_item;
use crate::migrations::runtime_logs_migrator;
@@ -54,12 +60,15 @@ mod agent_jobs;
mod backfill;
mod logs;
mod memories;
mod messages;
mod remote_control;
#[cfg(test)]
mod test_support;
mod threads;
mod timers;
pub use remote_control::RemoteControlEnrollmentRecord;
pub use timers::TimerDataVersionChecker;
// "Partition" is the retained-log-content bucket we cap at 10 MiB:
// - one bucket per non-null thread_id
+365
View File
@@ -0,0 +1,365 @@
//! SQLite-backed state operations for queued thread messages.
//!
//! This module extends [`StateRuntime`] with the storage APIs used by message
//! producers and active threads. Claiming a message deletes the row inside the
//! same transaction, so competing runtimes deliver each queued message at most
//! once.
use super::*;
use crate::model::ThreadMessageRow;
const DELIVERY_AFTER_TURN: &str = "after-turn";
const DELIVERY_STEER_CURRENT_TURN: &str = "steer-current-turn";
impl StateRuntime {
pub async fn create_thread_message(
&self,
params: &ThreadMessageCreateParams,
) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO thread_messages (
id,
thread_id,
source,
content,
instructions,
meta_json,
delivery,
queued_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(params.id.as_str())
.bind(params.thread_id.as_str())
.bind(params.source.as_str())
.bind(params.content.as_str())
.bind(params.instructions.as_deref())
.bind(params.meta_json.as_str())
.bind(params.delivery.as_str())
.bind(params.queued_at)
.execute(self.pool.as_ref())
.await?;
Ok(())
}
pub async fn list_thread_messages(
&self,
thread_id: &str,
) -> anyhow::Result<Vec<ThreadMessage>> {
let rows = sqlx::query_as::<_, ThreadMessageRow>(
r#"
SELECT
seq,
id,
thread_id,
source,
content,
instructions,
meta_json,
delivery,
queued_at
FROM thread_messages
WHERE thread_id = ?
ORDER BY queued_at ASC, seq ASC
"#,
)
.bind(thread_id)
.fetch_all(self.pool.as_ref())
.await?;
Ok(rows.into_iter().map(ThreadMessage::from).collect())
}
pub async fn claim_next_thread_message(
&self,
thread_id: &str,
can_after_turn: bool,
can_steer_current_turn: bool,
) -> anyhow::Result<Option<ThreadMessageClaim>> {
let mut tx = self.pool.begin().await?;
let row = sqlx::query_as::<_, ThreadMessageRow>(
r#"
SELECT
seq,
id,
thread_id,
source,
content,
instructions,
meta_json,
delivery,
queued_at
FROM thread_messages
WHERE thread_id = ?
ORDER BY queued_at ASC, seq ASC
LIMIT 1
"#,
)
.bind(thread_id)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.commit().await?;
return Ok(None);
};
let can_claim = match row.delivery.as_str() {
DELIVERY_AFTER_TURN => can_after_turn,
DELIVERY_STEER_CURRENT_TURN => can_steer_current_turn || can_after_turn,
delivery => {
sqlx::query("DELETE FROM thread_messages WHERE seq = ? AND id = ?")
.bind(row.seq)
.bind(row.id.as_str())
.execute(&mut *tx)
.await?;
tx.commit().await?;
return Ok(Some(ThreadMessageClaim::Invalid {
id: row.id,
reason: format!("invalid delivery `{delivery}`"),
}));
}
};
if !can_claim {
tx.commit().await?;
return Ok(Some(ThreadMessageClaim::NotReady));
}
let result = sqlx::query("DELETE FROM thread_messages WHERE seq = ? AND id = ?")
.bind(row.seq)
.bind(row.id.as_str())
.execute(&mut *tx)
.await?;
tx.commit().await?;
if result.rows_affected() == 0 {
return Ok(None);
}
Ok(Some(ThreadMessageClaim::Claimed(ThreadMessage::from(row))))
}
}
#[cfg(test)]
mod tests {
use super::StateRuntime;
use super::test_support::unique_temp_dir;
use crate::ThreadMessageClaim;
use crate::ThreadMessageCreateParams;
use pretty_assertions::assert_eq;
fn message_params(id: &str, thread_id: &str, queued_at: i64) -> ThreadMessageCreateParams {
ThreadMessageCreateParams {
id: id.to_string(),
thread_id: thread_id.to_string(),
source: "external".to_string(),
content: "do something".to_string(),
instructions: Some("be concise".to_string()),
meta_json: r#"{"ticket":"ABC_123"}"#.to_string(),
delivery: "after-turn".to_string(),
queued_at,
}
}
async fn test_runtime() -> std::sync::Arc<StateRuntime> {
StateRuntime::init(unique_temp_dir(), "test-provider".to_string())
.await
.expect("initialize runtime")
}
#[tokio::test]
async fn thread_messages_table_and_indexes_exist() {
let runtime = test_runtime().await;
let names = sqlx::query_scalar::<_, String>(
r#"
SELECT name
FROM sqlite_master
WHERE tbl_name = 'thread_messages'
AND name NOT LIKE 'sqlite_autoindex_%'
ORDER BY name
"#,
)
.fetch_all(runtime.pool.as_ref())
.await
.expect("query schema objects");
assert_eq!(
names,
vec![
"thread_messages",
"thread_messages_thread_delivery_order_idx",
"thread_messages_thread_order_idx",
]
);
}
#[tokio::test]
async fn thread_message_rows_round_trip() {
let runtime = test_runtime().await;
let params = message_params("message-1", "thread-1", 100);
runtime
.create_thread_message(&params)
.await
.expect("create message");
let messages = runtime
.list_thread_messages("thread-1")
.await
.expect("list messages");
assert_eq!(messages.len(), 1);
let message = &messages[0];
assert_eq!(message.id, params.id);
assert_eq!(message.thread_id, params.thread_id);
assert_eq!(message.source, params.source);
assert_eq!(message.content, params.content);
assert_eq!(message.instructions, params.instructions);
assert_eq!(message.meta_json, params.meta_json);
assert_eq!(message.delivery, params.delivery);
assert_eq!(message.queued_at, params.queued_at);
}
#[tokio::test]
async fn claim_is_scoped_to_thread_id_and_ordered() {
let runtime = test_runtime().await;
runtime
.create_thread_message(&message_params("newer", "thread-1", 200))
.await
.expect("create newer message");
runtime
.create_thread_message(&message_params("other-thread", "thread-2", 50))
.await
.expect("create other thread message");
runtime
.create_thread_message(&message_params("older", "thread-1", 100))
.await
.expect("create older message");
let claim = runtime
.claim_next_thread_message(
"thread-1", /*can_after_turn*/ true, /*can_steer_current_turn*/ true,
)
.await
.expect("claim message");
let Some(ThreadMessageClaim::Claimed(claimed)) = claim else {
panic!("expected claimed message");
};
assert_eq!(claimed.id, "older");
assert_eq!(claimed.thread_id, "thread-1");
assert_eq!(claimed.queued_at, 100);
assert_eq!(
runtime
.list_thread_messages("thread-1")
.await
.expect("list remaining thread-1 messages")
.into_iter()
.map(|message| message.id)
.collect::<Vec<_>>(),
vec!["newer".to_string()]
);
assert_eq!(
runtime
.list_thread_messages("thread-2")
.await
.expect("list thread-2 messages")
.into_iter()
.map(|message| message.id)
.collect::<Vec<_>>(),
vec!["other-thread".to_string()]
);
}
#[tokio::test]
async fn claim_consumes_message_once() {
let runtime = test_runtime().await;
runtime
.create_thread_message(&message_params("message-1", "thread-1", 100))
.await
.expect("create message");
assert!(matches!(
runtime
.claim_next_thread_message(
"thread-1", /*can_after_turn*/ true, /*can_steer_current_turn*/ true,
)
.await
.expect("claim message"),
Some(ThreadMessageClaim::Claimed(_))
));
assert_eq!(
runtime
.claim_next_thread_message(
"thread-1", /*can_after_turn*/ true, /*can_steer_current_turn*/ true,
)
.await
.expect("claim message again"),
None
);
}
#[tokio::test]
async fn oldest_unclaimable_message_blocks_later_messages() {
let runtime = test_runtime().await;
let mut steer = message_params("steer", "thread-1", 100);
steer.delivery = "steer-current-turn".to_string();
runtime
.create_thread_message(&steer)
.await
.expect("create steer message");
runtime
.create_thread_message(&message_params("after", "thread-1", 200))
.await
.expect("create after-turn message");
assert_eq!(
runtime
.claim_next_thread_message(
"thread-1", /*can_after_turn*/ false,
/*can_steer_current_turn*/ false,
)
.await
.expect("claim message"),
Some(ThreadMessageClaim::NotReady)
);
assert_eq!(
runtime
.list_thread_messages("thread-1")
.await
.expect("list messages")
.into_iter()
.map(|message| message.id)
.collect::<Vec<_>>(),
vec!["steer".to_string(), "after".to_string()]
);
}
#[tokio::test]
async fn invalid_delivery_is_deleted_without_claiming() {
let runtime = test_runtime().await;
let mut params = message_params("bad", "thread-1", 100);
params.delivery = "bad-delivery".to_string();
runtime
.create_thread_message(&params)
.await
.expect("create message");
assert_eq!(
runtime
.claim_next_thread_message(
"thread-1", /*can_after_turn*/ true, /*can_steer_current_turn*/ true,
)
.await
.expect("claim message"),
Some(ThreadMessageClaim::Invalid {
id: "bad".to_string(),
reason: "invalid delivery `bad-delivery`".to_string(),
})
);
assert!(
runtime
.list_thread_messages("thread-1")
.await
.expect("list messages")
.is_empty()
);
}
}
+400
View File
@@ -0,0 +1,400 @@
//! SQLite-backed state operations for per-thread timers.
//!
//! This module extends [`StateRuntime`] with timer CRUD, due-state updates, and
//! atomic pending-run claims. It also exposes a lightweight `PRAGMA
//! data_version` checker so active threads can notice cross-process timer
//! changes without constantly reconciling full timer rows.
use super::*;
use crate::model::ThreadTimerRow;
use tokio::sync::Mutex;
pub struct TimerDataVersionChecker {
conn: Mutex<SqliteConnection>,
}
impl TimerDataVersionChecker {
pub async fn data_version(&self) -> anyhow::Result<i64> {
let mut conn = self.conn.lock().await;
let version = sqlx::query_scalar::<_, i64>("PRAGMA data_version")
.fetch_one(&mut *conn)
.await?;
Ok(version)
}
}
impl StateRuntime {
pub async fn timer_data_version_checker(&self) -> anyhow::Result<TimerDataVersionChecker> {
let state_path = state_db_path(self.codex_home());
let options = base_sqlite_options(state_path.as_path());
let conn = options.connect().await?;
Ok(TimerDataVersionChecker {
conn: Mutex::new(conn),
})
}
pub async fn create_thread_timer(
&self,
params: &ThreadTimerCreateParams,
) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO thread_timers (
id,
thread_id,
source,
client_id,
trigger_json,
content,
instructions,
meta_json,
delivery,
created_at,
next_run_at,
last_run_at,
pending_run
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(params.id.as_str())
.bind(params.thread_id.as_str())
.bind(params.source.as_str())
.bind(params.client_id.as_str())
.bind(params.trigger_json.as_str())
.bind(params.content.as_str())
.bind(params.instructions.as_deref())
.bind(params.meta_json.as_str())
.bind(params.delivery.as_str())
.bind(params.created_at)
.bind(params.next_run_at)
.bind(params.last_run_at)
.bind(i64::from(params.pending_run))
.execute(self.pool.as_ref())
.await?;
Ok(())
}
pub async fn list_thread_timers(&self, thread_id: &str) -> anyhow::Result<Vec<ThreadTimer>> {
let rows = sqlx::query_as::<_, ThreadTimerRow>(
r#"
SELECT
id,
thread_id,
source,
client_id,
trigger_json,
content,
instructions,
meta_json,
delivery,
created_at,
next_run_at,
last_run_at,
pending_run
FROM thread_timers
WHERE thread_id = ?
ORDER BY created_at ASC, id ASC
"#,
)
.bind(thread_id)
.fetch_all(self.pool.as_ref())
.await?;
Ok(rows.into_iter().map(ThreadTimer::from).collect())
}
pub async fn delete_thread_timer(&self, thread_id: &str, id: &str) -> anyhow::Result<bool> {
let result = sqlx::query("DELETE FROM thread_timers WHERE thread_id = ? AND id = ?")
.bind(thread_id)
.bind(id)
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn update_thread_timer_due(
&self,
thread_id: &str,
id: &str,
next_run_at: Option<i64>,
) -> anyhow::Result<bool> {
let result = sqlx::query(
r#"
UPDATE thread_timers
SET pending_run = 1,
next_run_at = ?
WHERE thread_id = ?
AND id = ?
"#,
)
.bind(next_run_at)
.bind(thread_id)
.bind(id)
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn claim_one_shot_thread_timer(
&self,
thread_id: &str,
id: &str,
) -> anyhow::Result<bool> {
let result = sqlx::query(
r#"
DELETE FROM thread_timers
WHERE thread_id = ?
AND id = ?
AND pending_run = 1
"#,
)
.bind(thread_id)
.bind(id)
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn claim_recurring_thread_timer(
&self,
thread_id: &str,
id: &str,
params: &ThreadTimerUpdateParams,
) -> anyhow::Result<bool> {
let result = sqlx::query(
r#"
UPDATE thread_timers
SET trigger_json = ?,
content = ?,
instructions = ?,
meta_json = ?,
delivery = ?,
next_run_at = ?,
last_run_at = ?,
pending_run = ?
WHERE thread_id = ?
AND id = ?
AND pending_run = 1
"#,
)
.bind(params.trigger_json.as_str())
.bind(params.content.as_str())
.bind(params.instructions.as_deref())
.bind(params.meta_json.as_str())
.bind(params.delivery.as_str())
.bind(params.next_run_at)
.bind(params.last_run_at)
.bind(i64::from(params.pending_run))
.bind(thread_id)
.bind(id)
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
}
#[cfg(test)]
mod tests {
use super::StateRuntime;
use super::test_support::unique_temp_dir;
use crate::ThreadTimerCreateParams;
use crate::ThreadTimerUpdateParams;
use pretty_assertions::assert_eq;
fn timer_params(id: &str, thread_id: &str) -> ThreadTimerCreateParams {
ThreadTimerCreateParams {
id: id.to_string(),
thread_id: thread_id.to_string(),
source: "agent".to_string(),
client_id: "codex-tui".to_string(),
trigger_json: r#"{"kind":"delay","seconds":10,"repeat":true}"#.to_string(),
content: "run tests".to_string(),
instructions: Some("keep output brief".to_string()),
meta_json: r#"{"ticket":"ABC_123"}"#.to_string(),
delivery: "after-turn".to_string(),
created_at: 100,
next_run_at: Some(110),
last_run_at: None,
pending_run: false,
}
}
async fn test_runtime() -> std::sync::Arc<StateRuntime> {
StateRuntime::init(unique_temp_dir(), "test-provider".to_string())
.await
.expect("initialize runtime")
}
#[tokio::test]
async fn thread_timers_table_and_indexes_exist() {
let runtime = test_runtime().await;
let names = sqlx::query_scalar::<_, String>(
r#"
SELECT name
FROM sqlite_master
WHERE tbl_name = 'thread_timers'
AND name NOT LIKE 'sqlite_autoindex_%'
ORDER BY name
"#,
)
.fetch_all(runtime.pool.as_ref())
.await
.expect("query schema objects");
assert_eq!(
names,
vec![
"idx_thread_timers_thread_created",
"idx_thread_timers_thread_next_run",
"idx_thread_timers_thread_pending",
"thread_timers",
]
);
}
#[tokio::test]
async fn thread_timer_rows_round_trip_source_and_client_metadata() {
let runtime = test_runtime().await;
let mut params = timer_params("timer-1", "thread-1");
params.pending_run = true;
params.last_run_at = Some(105);
runtime
.create_thread_timer(&params)
.await
.expect("create timer");
let timers = runtime
.list_thread_timers("thread-1")
.await
.expect("list timers");
assert_eq!(timers.len(), 1);
let timer = &timers[0];
assert_eq!(timer.id, params.id);
assert_eq!(timer.thread_id, params.thread_id);
assert_eq!(timer.source, params.source);
assert_eq!(timer.client_id, params.client_id);
assert_eq!(timer.trigger_json, params.trigger_json);
assert_eq!(timer.content, params.content);
assert_eq!(timer.instructions, params.instructions);
assert_eq!(timer.meta_json, params.meta_json);
assert_eq!(timer.delivery, params.delivery);
assert_eq!(timer.created_at, params.created_at);
assert_eq!(timer.next_run_at, params.next_run_at);
assert_eq!(timer.last_run_at, params.last_run_at);
assert_eq!(timer.pending_run, params.pending_run);
}
#[tokio::test]
async fn thread_timer_crud_is_scoped_to_thread_id() {
let runtime = test_runtime().await;
runtime
.create_thread_timer(&timer_params("timer-1", "thread-1"))
.await
.expect("create thread-1 timer");
runtime
.create_thread_timer(&timer_params("timer-2", "thread-2"))
.await
.expect("create thread-2 timer");
assert_eq!(
runtime
.list_thread_timers("thread-1")
.await
.expect("list thread-1 timers")
.into_iter()
.map(|timer| timer.id)
.collect::<Vec<_>>(),
vec!["timer-1".to_string()]
);
assert!(
!runtime
.delete_thread_timer("thread-1", "timer-2")
.await
.expect("delete wrong thread timer")
);
assert!(
runtime
.delete_thread_timer("thread-2", "timer-2")
.await
.expect("delete correct thread timer")
);
}
#[tokio::test]
async fn one_shot_claim_consumes_pending_timer_once() {
let runtime = test_runtime().await;
let mut params = timer_params("timer-1", "thread-1");
params.pending_run = true;
params.next_run_at = None;
runtime
.create_thread_timer(&params)
.await
.expect("create pending timer");
assert!(
runtime
.claim_one_shot_thread_timer("thread-1", "timer-1")
.await
.expect("claim timer")
);
assert!(
!runtime
.claim_one_shot_thread_timer("thread-1", "timer-1")
.await
.expect("claim timer again")
);
assert!(
runtime
.list_thread_timers("thread-1")
.await
.expect("list timers")
.is_empty()
);
}
#[tokio::test]
async fn recurring_claim_updates_pending_timer_once() {
let runtime = test_runtime().await;
let mut params = timer_params("timer-1", "thread-1");
params.pending_run = true;
runtime
.create_thread_timer(&params)
.await
.expect("create pending timer");
let update = ThreadTimerUpdateParams {
trigger_json: params.trigger_json.clone(),
content: "updated content".to_string(),
instructions: None,
meta_json: "{}".to_string(),
delivery: "steer-current-turn".to_string(),
next_run_at: Some(120),
last_run_at: Some(110),
pending_run: false,
};
assert!(
runtime
.claim_recurring_thread_timer("thread-1", "timer-1", &update)
.await
.expect("claim recurring timer")
);
assert!(
!runtime
.claim_recurring_thread_timer("thread-1", "timer-1", &update)
.await
.expect("claim recurring timer again")
);
let timers = runtime
.list_thread_timers("thread-1")
.await
.expect("list timers");
assert_eq!(timers.len(), 1);
assert_eq!(timers[0].delivery, "steer-current-turn");
assert_eq!(timers[0].content, "updated content");
assert_eq!(timers[0].instructions, None);
assert_eq!(timers[0].meta_json, "{}");
assert_eq!(timers[0].next_run_at, Some(120));
assert_eq!(timers[0].last_run_at, Some(110));
assert!(!timers[0].pending_run);
}
}
+5
View File
@@ -15,6 +15,7 @@ mod mcp_tool;
mod plan_tool;
mod request_user_input_tool;
mod responses_api;
mod timer_tool;
mod tool_config;
mod tool_definition;
mod tool_discovery;
@@ -89,6 +90,10 @@ pub use responses_api::dynamic_tool_to_responses_api_tool;
pub use responses_api::mcp_tool_to_deferred_responses_api_tool;
pub use responses_api::mcp_tool_to_responses_api_tool;
pub use responses_api::tool_definition_to_responses_api_tool;
pub use timer_tool::create_delete_timer_tool;
pub use timer_tool::create_list_timers_tool;
pub use timer_tool::create_queue_message_tool;
pub use timer_tool::create_timer_tool;
pub use tool_config::ShellCommandBackendConfig;
pub use tool_config::ToolUserShellType;
pub use tool_config::ToolsConfig;
+178
View File
@@ -0,0 +1,178 @@
//! Responses API tool specs for thread-local persistent timer and message management.
//!
//! These specs expose the `create_timer`, `delete_timer`, `list_timers`, and
//! `queue_message` built-in tools.
use crate::JsonSchema;
use crate::ResponsesApiTool;
use crate::ToolSpec;
use std::collections::BTreeMap;
pub fn create_timer_tool() -> ToolSpec {
let trigger_properties = BTreeMap::from([
(
"kind".to_string(),
JsonSchema::string(Some(
"Trigger kind. Use `delay` or `schedule`.".to_string(),
)),
),
(
"seconds".to_string(),
JsonSchema::number(Some(
"Delay trigger seconds from creation time.".to_string(),
)),
),
(
"repeat".to_string(),
JsonSchema::boolean(Some(
"Delay trigger recurrence flag. With seconds 0, repeat means run whenever the thread is idle."
.to_string(),
)),
),
(
"dtstart".to_string(),
JsonSchema::string(Some(
"Schedule trigger floating local datetime in YYYY-MM-DDTHH:MM:SS format."
.to_string(),
)),
),
(
"rrule".to_string(),
JsonSchema::string(Some("Schedule trigger RRULE string.".to_string())),
),
]);
let properties = BTreeMap::from([
(
"trigger".to_string(),
JsonSchema::object(
trigger_properties,
Some(vec!["kind".to_string()]),
Some(false.into()),
),
),
(
"content".to_string(),
JsonSchema::string(Some(
"Message content to execute when the timer fires.".to_string(),
)),
),
(
"instructions".to_string(),
JsonSchema::string(Some(
"Optional extra instructions for the timer message.".to_string(),
)),
),
(
"meta".to_string(),
JsonSchema::object(BTreeMap::new(), None, Some(true.into())),
),
(
"delivery".to_string(),
JsonSchema::string(Some(
"Delivery mode for the timer. Use `after-turn` or `steer-current-turn`."
.to_string(),
)),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "create_timer".to_string(),
description:
"Create a thread timer using a structured trigger, message content, and delivery mode."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec![
"trigger".to_string(),
"content".to_string(),
"delivery".to_string(),
]),
Some(false.into()),
),
output_schema: None,
})
}
pub fn create_queue_message_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"thread_id".to_string(),
JsonSchema::string(Some(
"Target thread id that should receive the queued message.".to_string(),
)),
),
(
"content".to_string(),
JsonSchema::string(Some(
"Message content visible in the target thread transcript.".to_string(),
)),
),
(
"instructions".to_string(),
JsonSchema::string(Some(
"Optional model-visible instructions hidden from transcript display.".to_string(),
)),
),
(
"meta".to_string(),
JsonSchema::object(BTreeMap::new(), None, Some(true.into())),
),
(
"delivery".to_string(),
JsonSchema::string(Some(
"Delivery mode for the message. Use `after-turn` or `steer-current-turn`."
.to_string(),
)),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "queue_message".to_string(),
description: "Queue a message for delivery to a target thread.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["thread_id".to_string(), "content".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
pub fn create_delete_timer_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"id".to_string(),
JsonSchema::string(Some("Identifier of the timer to delete.".to_string())),
)]);
ToolSpec::Function(ResponsesApiTool {
name: "delete_timer".to_string(),
description: "Delete a thread timer by id.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["id".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
pub fn create_list_timers_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "list_timers".to_string(),
description: "List thread timers for the current thread.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(BTreeMap::new(), None, Some(false.into())),
output_schema: None,
})
}
#[cfg(test)]
#[path = "timer_tool_tests.rs"]
mod tests;
+40
View File
@@ -0,0 +1,40 @@
use crate::ResponsesApiTool;
use crate::ToolSpec;
use pretty_assertions::assert_eq;
use super::create_delete_timer_tool;
use super::create_list_timers_tool;
use super::create_queue_message_tool;
use super::create_timer_tool;
#[test]
fn timer_create_tool_uses_expected_name() {
let ToolSpec::Function(ResponsesApiTool { name, .. }) = create_timer_tool() else {
panic!("expected function tool");
};
assert_eq!(name, "create_timer");
}
#[test]
fn timer_delete_tool_uses_expected_name() {
let ToolSpec::Function(ResponsesApiTool { name, .. }) = create_delete_timer_tool() else {
panic!("expected function tool");
};
assert_eq!(name, "delete_timer");
}
#[test]
fn timer_list_tool_uses_expected_name() {
let ToolSpec::Function(ResponsesApiTool { name, .. }) = create_list_timers_tool() else {
panic!("expected function tool");
};
assert_eq!(name, "list_timers");
}
#[test]
fn message_queue_tool_uses_expected_name() {
let ToolSpec::Function(ResponsesApiTool { name, .. }) = create_queue_message_tool() else {
panic!("expected function tool");
};
assert_eq!(name, "queue_message");
}
+6
View File
@@ -105,6 +105,8 @@ pub struct ToolsConfig {
pub collab_tools: bool,
pub multi_agent_v2: bool,
pub hide_spawn_agent_metadata: bool,
pub request_user_input: bool,
pub timer_scheduler: bool,
pub spawn_agent_usage_hint: bool,
pub spawn_agent_usage_hint_text: Option<String>,
pub default_mode_request_user_input: bool,
@@ -146,6 +148,8 @@ impl ToolsConfig {
let include_collab_tools = features.enabled(Feature::Collab);
let include_multi_agent_v2 = features.enabled(Feature::MultiAgentV2);
let include_agent_jobs = features.enabled(Feature::SpawnCsv);
let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_));
let include_timer_scheduler = features.enabled(Feature::TimerScheduler);
let include_default_mode_request_user_input =
features.enabled(Feature::DefaultModeRequestUserInput);
let include_search_tool =
@@ -226,6 +230,8 @@ impl ToolsConfig {
collab_tools: include_collab_tools,
multi_agent_v2: include_multi_agent_v2,
hide_spawn_agent_metadata: false,
request_user_input: include_request_user_input,
timer_scheduler: include_timer_scheduler,
spawn_agent_usage_hint: true,
spawn_agent_usage_hint_text: None,
default_mode_request_user_input: include_default_mode_request_user_input,
+31
View File
@@ -22,6 +22,7 @@ use crate::create_apply_patch_json_tool;
use crate::create_close_agent_tool_v1;
use crate::create_close_agent_tool_v2;
use crate::create_code_mode_tool;
use crate::create_delete_timer_tool;
use crate::create_exec_command_tool;
use crate::create_followup_task_tool;
use crate::create_image_generation_tool;
@@ -31,7 +32,9 @@ use crate::create_list_agents_tool;
use crate::create_list_dir_tool;
use crate::create_list_mcp_resource_templates_tool;
use crate::create_list_mcp_resources_tool;
use crate::create_list_timers_tool;
use crate::create_local_shell_tool;
use crate::create_queue_message_tool;
use crate::create_read_mcp_resource_tool;
use crate::create_report_agent_job_result_tool;
use crate::create_request_permissions_tool;
@@ -45,6 +48,7 @@ use crate::create_spawn_agent_tool_v1;
use crate::create_spawn_agent_tool_v2;
use crate::create_spawn_agents_on_csv_tool;
use crate::create_test_sync_tool;
use crate::create_timer_tool;
use crate::create_tool_search_tool;
use crate::create_tool_suggest_tool;
use crate::create_update_plan_tool;
@@ -247,6 +251,33 @@ pub fn build_tool_registry_plan(
plan.register_handler("request_permissions", ToolHandlerKind::RequestPermissions);
}
if config.timer_scheduler {
plan.push_spec(
create_queue_message_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.push_spec(
create_timer_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.push_spec(
create_delete_timer_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.push_spec(
create_list_timers_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("queue_message", ToolHandlerKind::QueueMessage);
plan.register_handler("create_timer", ToolHandlerKind::CreateTimer);
plan.register_handler("delete_timer", ToolHandlerKind::DeleteTimer);
plan.register_handler("list_timers", ToolHandlerKind::ListTimers);
}
if config.search_tool
&& let Some(deferred_mcp_tools) = params.deferred_mcp_tools
{
@@ -11,6 +11,10 @@ use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolHandlerKind {
CreateTimer,
DeleteTimer,
ListTimers,
QueueMessage,
AgentJobs,
ApplyPatch,
CloseAgentV1,
+9 -4
View File
@@ -2134,7 +2134,6 @@ impl App {
self.handle_feedback_thread_event(event);
}
}
/// Process the completed MCP inventory fetch: clear the loading spinner, then
/// render either the full tool/resource listing or an error into chat history.
///
@@ -7905,6 +7904,14 @@ mod tests {
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf();
let guardian_approvals = guardian_approvals_mode();
app.config
.features
.set_enabled(Feature::GuardianApproval, /*enabled*/ false)?;
app.chat_widget
.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false);
app.config.approvals_reviewer = ApprovalsReviewer::User;
app.chat_widget
.set_approvals_reviewer(ApprovalsReviewer::User);
app.update_feature_flags(vec![(Feature::GuardianApproval, true)])
.await;
@@ -7992,9 +7999,7 @@ mod tests {
let config_toml = "approvals_reviewer = \"guardian_subagent\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n";
std::fs::write(config_toml_path.as_path(), config_toml)?;
let user_config = toml::from_str::<TomlValue>(config_toml)?;
app.config.config_layer_stack = app
.config
.config_layer_stack
app.config.config_layer_stack = codex_core::config_loader::ConfigLayerStack::default()
.with_user_config(&config_toml_path, user_config);
app.config
.features
+33 -12
View File
@@ -80,6 +80,10 @@ use crate::terminal_title::SetTerminalTitleResult;
use crate::terminal_title::clear_terminal_title;
use crate::terminal_title::set_terminal_title;
use crate::text_formatting::proper_join;
use crate::user_message_display::UserMessageDisplay;
use crate::user_message_display::UserMessageDisplayOptions;
use crate::user_message_display::classify_user_message;
use crate::user_message_display::user_message_history_text;
use crate::version::CODEX_CLI_VERSION;
use codex_app_server_protocol::AppSummary;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
@@ -5535,19 +5539,20 @@ impl ChatWidget {
// Show replayable user content in conversation history.
if render_in_history && !text.is_empty() {
let history_text = user_message_history_text(&text).unwrap_or_else(|| text.clone());
let local_image_paths = local_images
.into_iter()
.map(|img| img.path)
.collect::<Vec<_>>();
self.last_rendered_user_message_event =
Some(Self::rendered_user_message_event_from_parts(
text.clone(),
text,
text_elements.clone(),
local_image_paths.clone(),
remote_image_urls.clone(),
));
self.add_to_history(history_cell::new_user_prompt(
text,
history_text,
text_elements,
local_image_paths,
remote_image_urls,
@@ -6938,16 +6943,32 @@ impl ChatWidget {
self.last_rendered_user_message_event =
Some(Self::rendered_user_message_event_from_event(&event));
let remote_image_urls = event.images.unwrap_or_default();
if !event.message.trim().is_empty()
|| !event.text_elements.is_empty()
|| !remote_image_urls.is_empty()
{
self.add_to_history(history_cell::new_user_prompt(
event.message,
event.text_elements,
event.local_images,
remote_image_urls,
));
let message_is_empty = event.message.trim().is_empty();
let allow_timer_info = remote_image_urls.is_empty()
&& event.local_images.is_empty()
&& event.text_elements.is_empty();
match classify_user_message(
event.message,
UserMessageDisplayOptions { allow_timer_info },
) {
UserMessageDisplay::TimerInfo { prompt, schedule } => {
self.add_info_message(prompt, Some(schedule));
self.needs_final_message_separator = false;
return;
}
UserMessageDisplay::History(message) => {
if !message_is_empty
|| !event.text_elements.is_empty()
|| !remote_image_urls.is_empty()
{
self.add_to_history(history_cell::new_user_prompt(
message,
event.text_elements,
event.local_images,
remote_image_urls,
));
}
}
}
// User messages reset separator state so the next agent response doesn't add a stray break.
+1
View File
@@ -266,6 +266,7 @@ mod review_mode;
mod slash_commands;
mod status_and_layout;
mod status_command_tests;
mod timers;
pub(crate) use helpers::make_chatwidget_manual_with_sender;
pub(crate) use helpers::set_chatgpt_auth;
@@ -0,0 +1,37 @@
use super::*;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::UserMessageEvent;
use insta::assert_snapshot;
#[tokio::test]
async fn codex_message_renders_content_history() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event_replay(Event {
id: "event-1".to_string(),
msg: EventMsg::UserMessage(UserMessageEvent {
message: "<codex_message>
<source>timer timer-1</source>
<queued_at>100</queued_at>
<content>
Give me a random animal name.
</content>
<instructions>
This one-shot timer has already been removed from the schedule, so you do not need to call delete_timer.
Do not expose scheduler internals unless they matter to the user.
</instructions>
<meta />
</codex_message>
"
.to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
}),
});
let cells = drain_insert_history(&mut rx);
let rendered = lines_to_single_string(&cells[0]);
assert_snapshot!(rendered, @"› Give me a random animal name.
");
}
+1
View File
@@ -161,6 +161,7 @@ pub(crate) mod update_action;
pub use update_action::UpdateAction;
mod update_prompt;
mod updates;
mod user_message_display;
mod version;
#[cfg(not(target_os = "linux"))]
mod voice;
+200
View File
@@ -0,0 +1,200 @@
//! Display handling for user-message payloads that carry hidden control data.
//!
//! Core can deliver user-message events whose model-visible text includes
//! machine-readable wrappers. The TUI keeps those wrappers out of the transcript
//! by translating them into either visible user content or a compact status
//! message.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum UserMessageDisplay {
History(String),
TimerInfo { prompt: String, schedule: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct UserMessageDisplayOptions {
pub(crate) allow_timer_info: bool,
}
pub(crate) fn classify_user_message(
message: String,
options: UserMessageDisplayOptions,
) -> UserMessageDisplay {
if options.allow_timer_info
&& let Some(timer) = parse_timer_fired_user_message(&message)
{
return UserMessageDisplay::TimerInfo {
schedule: timer.schedule(),
prompt: timer.prompt,
};
}
UserMessageDisplay::History(user_message_history_text(&message).unwrap_or(message))
}
pub(crate) fn user_message_history_text(message: &str) -> Option<String> {
parse_codex_message_content(message).or_else(|| parse_synthetic_user_message_display(message))
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TimerFiredUserMessage {
prompt: String,
trigger: Option<String>,
delivery: Option<String>,
recurring: bool,
}
impl TimerFiredUserMessage {
fn schedule(&self) -> String {
let mut parts = vec!["Running thread timer".to_string()];
if let Some(trigger) = self
.trigger
.as_deref()
.filter(|trigger| !trigger.is_empty())
{
parts.push(trigger.to_string());
}
if !self.recurring {
parts.push("one-shot".to_string());
}
if let Some(delivery) = self
.delivery
.as_deref()
.filter(|delivery| !delivery.is_empty())
{
parts.push(delivery.to_string());
}
parts.join(" • ")
}
}
fn parse_codex_message_content(message: &str) -> Option<String> {
let trimmed = message.trim();
if !trimmed.starts_with("<codex_message>") || !trimmed.ends_with("</codex_message>") {
return None;
}
let content = extract_xml_tag(trimmed, "content")?;
let content = xml_unescape(content.trim_matches('\n'));
(!content.is_empty()).then_some(content)
}
fn parse_timer_fired_user_message(message: &str) -> Option<TimerFiredUserMessage> {
let trimmed = message.trim();
if !trimmed.starts_with("<timer_fired>") || !trimmed.ends_with("</timer_fired>") {
return None;
}
let prompt = extract_xml_tag(trimmed, "prompt")
.unwrap_or_default()
.trim_matches('\n')
.to_string();
let trigger = extract_xml_tag(trimmed, "trigger").map(|text| text.trim().to_string());
let delivery = extract_xml_tag(trimmed, "delivery").map(|text| text.trim().to_string());
let recurring = extract_xml_tag(trimmed, "recurring")
.as_deref()
.map(str::trim)
== Some("true");
Some(TimerFiredUserMessage {
prompt,
trigger,
delivery,
recurring,
})
}
fn parse_synthetic_user_message_display(message: &str) -> Option<String> {
let trimmed = message.trim();
if !trimmed.starts_with("<codex_tui_synthetic_user_message>")
|| !trimmed.ends_with("</codex_tui_synthetic_user_message>")
{
return None;
}
let display = extract_xml_tag(trimmed, "display")?;
let display = xml_unescape(display.trim());
(!display.is_empty()).then_some(display)
}
fn extract_xml_tag(message: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let after_open = message.split_once(&open)?.1;
let value = after_open.split_once(&close)?.0;
Some(value.to_string())
}
fn xml_unescape(text: &str) -> String {
text.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&amp;", "&")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn codex_message_history_text_uses_content_only() {
assert_eq!(
user_message_history_text(
"<codex_message>
<source>timer timer-1</source>
<queued_at>100</queued_at>
<content>
Run &lt;tests&gt;.
</content>
<instructions>
Hidden
</instructions>
<meta />
</codex_message>"
),
Some("Run <tests>.".to_string())
);
}
#[test]
fn synthetic_message_history_text_uses_display_only() {
assert_eq!(
user_message_history_text(
"<codex_tui_synthetic_user_message><display>/review src</display><prompt>hidden</prompt></codex_tui_synthetic_user_message>"
),
Some("/review src".to_string())
);
}
#[test]
fn timer_fired_can_render_as_info_message() {
assert_eq!(
classify_user_message(
"<timer_fired><prompt>Run tests</prompt><trigger>every minute</trigger><delivery>after-turn</delivery><recurring>true</recurring></timer_fired>"
.to_string(),
UserMessageDisplayOptions {
allow_timer_info: true
},
),
UserMessageDisplay::TimerInfo {
prompt: "Run tests".to_string(),
schedule: "Running thread timer • every minute • after-turn".to_string(),
}
);
}
#[test]
fn timer_fired_can_remain_history_text_when_info_is_not_allowed() {
let message = "<timer_fired><prompt>Run tests</prompt></timer_fired>".to_string();
assert_eq!(
classify_user_message(
message.clone(),
UserMessageDisplayOptions {
allow_timer_info: false
},
),
UserMessageDisplay::History(message)
);
}
}