1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
// Source: ~/claudecode/openclaudecode/src/utils/cronJitterConfig.ts
//! GrowthBook-backed cron jitter configuration.
//!
//! Separated from cron_scheduler so the scheduler can be bundled in the
//! Agent SDK public build without pulling in analytics/growthbook and
//! its large transitive dependency set (settings/hooks/config cycle).
//!
//! Usage:
//! REPL (use_scheduled_tasks): pass `get_jitter_config: get_cron_jitter_config`
//! Daemon/SDK: omit get_jitter_config -> DEFAULT_CRON_JITTER_CONFIG applies.
#![allow(dead_code)]
use crate::utils::cron_tasks::{CronJitterConfig, DEFAULT_CRON_JITTER_CONFIG};
/// How often to re-fetch tengu_kairos_cron_config from GrowthBook. Short because
/// this is an incident lever — when we push a config change to shed :00 load,
/// we want the fleet to converge within a minute, not on the next process
/// restart. The underlying call is a synchronous cache read; the refresh just
/// clears the memoized entry so the next read triggers a background fetch.
const JITTER_CONFIG_REFRESH_MS: u64 = 60 * 1000;
/// Upper bounds here are defense-in-depth against fat-fingered GrowthBook
/// pushes. Like poll_config, we reject the whole object on any violation
/// rather than partially trusting it — a config with one bad field falls back
/// to DEFAULT_CRON_JITTER_CONFIG entirely.
const HALF_HOUR_MS: u64 = 30 * 60 * 1000;
const THIRTY_DAYS_MS: u64 = 30 * 24 * 60 * 60 * 1000;
/// Validated cron jitter configuration.
#[derive(Debug, Clone)]
pub struct ValidatedCronJitterConfig {
pub recurring_frac: f64,
pub recurring_cap_ms: u64,
pub one_shot_max_ms: u64,
pub one_shot_floor_ms: u64,
pub one_shot_minute_mod: u64,
pub recurring_max_age_ms: u64,
}
impl ValidatedCronJitterConfig {
/// Validate the config fields.
fn validate(&self) -> bool {
self.recurring_frac >= 0.0
&& self.recurring_frac <= 1.0
&& self.recurring_cap_ms <= HALF_HOUR_MS
&& self.one_shot_max_ms <= HALF_HOUR_MS
&& self.one_shot_floor_ms <= HALF_HOUR_MS
&& self.one_shot_minute_mod >= 1
&& self.one_shot_minute_mod <= 60
&& self.recurring_max_age_ms <= THIRTY_DAYS_MS
&& self.one_shot_floor_ms <= self.one_shot_max_ms
}
}
/// Read `tengu_kairos_cron_config` from GrowthBook, validate, fall back to
/// defaults on absent/malformed/out-of-bounds config. Called from check()
/// every tick via the `get_jitter_config` callback — cheap (synchronous cache
/// hit). Refresh window: JITTER_CONFIG_REFRESH_MS.
///
/// Exported so ops runbooks can point at a single function when documenting
/// the lever, and so tests can spy on it without mocking GrowthBook itself.
///
/// Pass this as `get_jitter_config` when calling create_cron_scheduler in REPL
/// contexts. Daemon/SDK callers omit get_jitter_config and get defaults.
pub fn get_cron_jitter_config() -> CronJitterConfig {
let raw = get_feature_value_cached_with_refresh(
"tengu_kairos_cron_config",
DEFAULT_CRON_JITTER_CONFIG,
JITTER_CONFIG_REFRESH_MS,
);
// Validate the config
if let Some(validated) = validate_config(&raw) {
return CronJitterConfig {
recurring_frac: validated.recurring_frac,
recurring_cap_ms: validated.recurring_cap_ms,
one_shot_max_ms: validated.one_shot_max_ms,
one_shot_floor_ms: validated.one_shot_floor_ms,
one_shot_minute_mod: validated.one_shot_minute_mod,
recurring_max_age_ms: validated.recurring_max_age_ms,
};
}
// Fall back to defaults on validation failure
DEFAULT_CRON_JITTER_CONFIG
}
/// Validate a raw config value into a validated config.
fn validate_config(raw: &CronJitterConfig) -> Option<ValidatedCronJitterConfig> {
let validated = ValidatedCronJitterConfig {
recurring_frac: raw.recurring_frac,
recurring_cap_ms: raw.recurring_cap_ms,
one_shot_max_ms: raw.one_shot_max_ms,
one_shot_floor_ms: raw.one_shot_floor_ms,
one_shot_minute_mod: raw.one_shot_minute_mod,
recurring_max_age_ms: raw.recurring_max_age_ms,
};
if validated.validate() {
Some(validated)
} else {
None
}
}
/// Get a feature value from GrowthBook with cached refresh.
/// In a full implementation, this would query the GrowthBook SDK.
/// Here we return the default value.
fn get_feature_value_cached_with_refresh(
_feature_id: &str,
default: CronJitterConfig,
_refresh_ms: u64,
) -> CronJitterConfig {
// Without GrowthBook integration, return the default.
default
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::cron_tasks::DEFAULT_CRON_JITTER_CONFIG;
#[test]
fn test_validate_config_valid() {
let config = ValidatedCronJitterConfig {
recurring_frac: 0.1,
recurring_cap_ms: 5 * 60 * 1000,
one_shot_max_ms: 10 * 60 * 1000,
one_shot_floor_ms: 1 * 60 * 1000,
one_shot_minute_mod: 5,
recurring_max_age_ms: 7 * 24 * 60 * 60 * 1000,
};
assert!(config.validate());
}
#[test]
fn test_validate_config_invalid_frac() {
let config = ValidatedCronJitterConfig {
recurring_frac: 1.5, // > 1.0
recurring_cap_ms: 0,
one_shot_max_ms: 0,
one_shot_floor_ms: 0,
one_shot_minute_mod: 1,
recurring_max_age_ms: 0,
};
assert!(!config.validate());
}
#[test]
fn test_validate_config_floor_exceeds_max() {
let config = ValidatedCronJitterConfig {
recurring_frac: 0.1,
recurring_cap_ms: 0,
one_shot_max_ms: 1000,
one_shot_floor_ms: 2000, // floor > max
one_shot_minute_mod: 1,
recurring_max_age_ms: 0,
};
assert!(!config.validate());
}
#[test]
fn test_get_cron_jitter_config_returns_default() {
let config = get_cron_jitter_config();
assert_eq!(config.recurring_frac, DEFAULT_CRON_JITTER_CONFIG.recurring_frac);
}
}