pr_01m47d15m3e54sn21z27rpy5n9/services/work/src/compute.rs
| 1 | //! What the runner needs to apply a workspace's plan caps (see |
| 2 | //! `@g1t/contracts` `compute.ts`): how many agents it has at work, what an |
| 3 | //! issue's agents have spent, a comment from g1t-agent when it cannot |
| 4 | //! start, and somewhere for runs to wait for a free slot. |
| 5 | //! |
| 6 | //! The decisions are the runner's; this keeps the counts and the queue. |
| 7 | |
| 8 | use g1t_contracts::agents::*; |
| 9 | use g1t_contracts::events::CommentCreated; |
| 10 | use g1t_contracts::repos::RepoPath; |
| 11 | use g1t_contracts::time::rfc3339; |
| 12 | use g1t_contracts::{FailureCode, Outcome, User, new_id}; |
| 13 | use g1t_kit::now_ms; |
| 14 | use serde::Deserialize; |
| 15 | use worker::Result; |
| 16 | use worker::wasm_bindgen::JsValue; |
| 17 | |
| 18 | use crate::Work; |
| 19 | use crate::checks::hash; |
| 20 | use crate::reviews::{AGENT_ID, AGENT_NAME}; |
| 21 | use crate::rows::{NumberRow, ValueRow}; |
| 22 | use crate::runs::member_of; |
| 23 | |
| 24 | /// The longest comment g1t-agent leaves for a run it could not start. |
| 25 | const MAX_COMMENT_CHARS: usize = 2000; |
| 26 | /// The most runs one workspace may have waiting at once. |
| 27 | const MAX_WAITING: u32 = 50; |
| 28 | |
| 29 | #[derive(Deserialize)] |
| 30 | struct SpendRow { |
| 31 | spent: Option<f64>, |
| 32 | } |
| 33 | |
| 34 | #[derive(Deserialize)] |
| 35 | struct PullKey { |
| 36 | id: String, |
| 37 | issue_id: Option<String>, |
| 38 | } |
| 39 | |
| 40 | #[derive(Deserialize)] |
| 41 | struct WaitRow { |
| 42 | id: String, |
| 43 | workspace: String, |
| 44 | kind: String, |
| 45 | payload: String, |
| 46 | created_at: String, |
| 47 | } |
| 48 | |
| 49 | #[derive(Deserialize)] |
| 50 | struct CostRow { |
| 51 | cost_usd: Option<f64>, |
| 52 | token_hash: String, |
| 53 | } |
| 54 | |
| 55 | /// The kinds that count against the agents-at-once cap, as SQL. |
| 56 | fn agent_kinds_sql() -> String { |
| 57 | AGENT_KINDS |
| 58 | .iter() |
| 59 | .map(|kind| format!("'{}'", kind.as_str())) |
| 60 | .collect::<Vec<_>>() |
| 61 | .join(", ") |
| 62 | } |
| 63 | |
| 64 | /// Dollars as millionths, rounded up. |
| 65 | pub(crate) fn to_micros(usd: f64) -> i64 { |
| 66 | if usd.is_finite() && usd > 0.0 { |
| 67 | (usd * 1_000_000.0).ceil() as i64 |
| 68 | } else { |
| 69 | 0 |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | impl Work { |
| 74 | async fn repo_id_of(&self, path: &RepoPath) -> Result<Outcome<String>> { |
| 75 | let service = User { |
| 76 | id: "svc_runner".to_owned(), |
| 77 | username: "g1t".to_owned(), |
| 78 | ..User::default() |
| 79 | }; |
| 80 | Ok(match self.repo(path, &member_of(&service, &path.namespace)).await? { |
| 81 | Outcome::Ok(repo) => Outcome::Ok(repo.id), |
| 82 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 83 | }) |
| 84 | } |
| 85 | |
| 86 | pub(crate) async fn active_agents(&self, a: ActiveAgentsArgs) -> Result<u32> { |
| 87 | // A run whose sandbox died unnoticed does not hold a slot forever. |
| 88 | self.sweep_silent().await?; |
| 89 | let count = self |
| 90 | .db |
| 91 | .prepare(format!( |
| 92 | "SELECT count(*) AS n FROM agent_runs |
| 93 | WHERE workspace = ? AND status IN ('queued', 'running') AND kind IN ({})", |
| 94 | agent_kinds_sql() |
| 95 | )) |
| 96 | .bind(&[a.workspace.to_lowercase().into()])? |
| 97 | .first::<NumberRow>(None) |
| 98 | .await?; |
| 99 | Ok(count.map_or(0, |row| row.n)) |
| 100 | } |
| 101 | |
| 102 | pub(crate) async fn issue_spend(&self, a: IssueSpendArgs) -> Result<Outcome<IssueSpend>> { |
| 103 | let repo_id = match self.repo_id_of(&a.repo).await? { |
| 104 | Outcome::Ok(id) => id, |
| 105 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 106 | }; |
| 107 | let pull = self |
| 108 | .db |
| 109 | .prepare("SELECT id, issue_id FROM pulls WHERE repo_id = ? AND number = ?") |
| 110 | .bind(&[repo_id.as_str().into(), a.number.into()])? |
| 111 | .first::<PullKey>(None) |
| 112 | .await?; |
| 113 | // The issue a pull request is for, or the pull request itself. |
| 114 | let issue_id = match &pull { |
| 115 | Some(pull) => pull.issue_id.clone(), |
| 116 | None => self |
| 117 | .db |
| 118 | .prepare("SELECT id AS value FROM issues WHERE repo_id = ? AND number = ?") |
| 119 | .bind(&[repo_id.as_str().into(), a.number.into()])? |
| 120 | .first::<ValueRow>(None) |
| 121 | .await? |
| 122 | .map(|row| row.value), |
| 123 | }; |
| 124 | let (issue_number, spent) = match (&issue_id, &pull) { |
| 125 | (Some(issue_id), _) => { |
| 126 | let number = self |
| 127 | .db |
| 128 | .prepare("SELECT number AS n FROM issues WHERE id = ?") |
| 129 | .bind(&[issue_id.as_str().into()])? |
| 130 | .first::<NumberRow>(None) |
| 131 | .await? |
| 132 | .map_or(a.number, |row| row.n); |
| 133 | let spent = self |
| 134 | .db |
| 135 | .prepare( |
| 136 | "SELECT sum(cost_usd) AS spent FROM agent_runs |
| 137 | WHERE pull_id IN (SELECT id FROM pulls WHERE issue_id = ?1) |
| 138 | OR (repo_id = ?2 AND number = ?3 AND pull_id IS NULL)", |
| 139 | ) |
| 140 | .bind(&[issue_id.as_str().into(), repo_id.as_str().into(), number.into()])? |
| 141 | .first::<SpendRow>(None) |
| 142 | .await?; |
| 143 | (number, spent) |
| 144 | } |
| 145 | (None, Some(pull)) => { |
| 146 | let spent = self |
| 147 | .db |
| 148 | .prepare( |
| 149 | "SELECT sum(cost_usd) AS spent FROM agent_runs |
| 150 | WHERE pull_id = ?1 OR (repo_id = ?2 AND number = ?3 AND pull_id IS NULL)", |
| 151 | ) |
| 152 | .bind(&[pull.id.as_str().into(), repo_id.as_str().into(), a.number.into()])? |
| 153 | .first::<SpendRow>(None) |
| 154 | .await?; |
| 155 | (a.number, spent) |
| 156 | } |
| 157 | (None, None) => { |
| 158 | return Ok(Outcome::fail(FailureCode::NotFound, "No issue or pull request by that number.")); |
| 159 | } |
| 160 | }; |
| 161 | Ok(Outcome::Ok(IssueSpend { |
| 162 | issue: issue_number, |
| 163 | spent_micros: to_micros(spent.and_then(|row| row.spent).unwrap_or(0.0)), |
| 164 | })) |
| 165 | } |
| 166 | |
| 167 | pub(crate) async fn wait_for_slot(&self, a: WaitForSlotArgs) -> Result<bool> { |
| 168 | // The claim is given back as if it had never been taken, so the |
| 169 | // revision it counted does not count. |
| 170 | let released = self |
| 171 | .db |
| 172 | .prepare( |
| 173 | "UPDATE pulls SET |
| 174 | revisions = CASE WHEN working_on = 'revision' AND revisions > 0 THEN revisions - 1 ELSE revisions END, |
| 175 | working_on = NULL, working_until = NULL, stage_detail = ? |
| 176 | WHERE id = ? AND status = 'open' |
| 177 | RETURNING id AS value", |
| 178 | ) |
| 179 | .bind(&[a.reason.trim().into(), a.pull_id.as_str().into()])? |
| 180 | .first::<ValueRow>(None) |
| 181 | .await?; |
| 182 | Ok(released.is_some()) |
| 183 | } |
| 184 | |
| 185 | pub(crate) async fn agent_comment(&self, a: AgentCommentArgs) -> Result<bool> { |
| 186 | let body: String = a.body.trim().chars().take(MAX_COMMENT_CHARS).collect(); |
| 187 | if body.is_empty() { |
| 188 | return Ok(false); |
| 189 | } |
| 190 | let repo_id = match self.repo_id_of(&a.repo).await? { |
| 191 | Outcome::Ok(id) => id, |
| 192 | Outcome::Fail(_) => return Ok(false), |
| 193 | }; |
| 194 | let pull_id = self |
| 195 | .db |
| 196 | .prepare("SELECT id AS value FROM pulls WHERE repo_id = ? AND number = ?") |
| 197 | .bind(&[repo_id.as_str().into(), a.number.into()])? |
| 198 | .first::<ValueRow>(None) |
| 199 | .await? |
| 200 | .map(|row| row.value); |
| 201 | let table = if pull_id.is_some() { "pulls" } else { "issues" }; |
| 202 | let now = now_ms(); |
| 203 | let id = new_id("cmt", now); |
| 204 | let at = rfc3339(now); |
| 205 | self.db |
| 206 | .batch(vec![ |
| 207 | self.db |
| 208 | .prepare( |
| 209 | "INSERT INTO comments (id, repo_id, number, author_id, author_name, body, created_at) |
| 210 | VALUES (?, ?, ?, ?, ?, ?, ?)", |
| 211 | ) |
| 212 | .bind(&[ |
| 213 | id.as_str().into(), |
| 214 | repo_id.as_str().into(), |
| 215 | a.number.into(), |
| 216 | AGENT_ID.into(), |
| 217 | AGENT_NAME.into(), |
| 218 | body.as_str().into(), |
| 219 | at.as_str().into(), |
| 220 | ])?, |
| 221 | self.db |
| 222 | .prepare(format!("UPDATE {table} SET updated_at = ? WHERE repo_id = ? AND number = ?")) |
| 223 | .bind(&[at.as_str().into(), repo_id.as_str().into(), a.number.into()])?, |
| 224 | ]) |
| 225 | .await?; |
| 226 | self.publish_as( |
| 227 | "comment.created", |
| 228 | &repo_id, |
| 229 | Some(AGENT_ID.to_owned()), |
| 230 | CommentCreated { |
| 231 | comment_id: id, |
| 232 | repo_id: repo_id.clone(), |
| 233 | number: a.number, |
| 234 | pull_id, |
| 235 | verdict: None, |
| 236 | }, |
| 237 | ) |
| 238 | .await?; |
| 239 | Ok(true) |
| 240 | } |
| 241 | |
| 242 | pub(crate) async fn add_wait(&self, a: AddWaitArgs) -> Result<Outcome<bool>> { |
| 243 | let workspace = a.workspace.to_lowercase(); |
| 244 | let waiting = self |
| 245 | .db |
| 246 | .prepare("SELECT count(*) AS n FROM agent_waits WHERE workspace = ?") |
| 247 | .bind(&[workspace.as_str().into()])? |
| 248 | .first::<NumberRow>(None) |
| 249 | .await? |
| 250 | .map_or(0, |row| row.n); |
| 251 | if waiting >= MAX_WAITING { |
| 252 | return Ok(Outcome::fail( |
| 253 | FailureCode::Conflict, |
| 254 | format!("{MAX_WAITING} runs are already waiting for this workspace's agents. Try again when some have started."), |
| 255 | )); |
| 256 | } |
| 257 | let now = now_ms(); |
| 258 | self.db |
| 259 | .prepare("INSERT INTO agent_waits (id, workspace, kind, payload, created_at) VALUES (?, ?, ?, ?, ?)") |
| 260 | .bind(&[ |
| 261 | new_id("wait", now).into(), |
| 262 | workspace.into(), |
| 263 | a.kind.into(), |
| 264 | serde_json::to_string(&a.payload)?.into(), |
| 265 | rfc3339(now).into(), |
| 266 | ])? |
| 267 | .run() |
| 268 | .await?; |
| 269 | Ok(Outcome::Ok(true)) |
| 270 | } |
| 271 | |
| 272 | pub(crate) async fn waiting_workspaces(&self, _a: WaitingWorkspacesArgs) -> Result<Vec<String>> { |
| 273 | let rows = self |
| 274 | .db |
| 275 | .prepare( |
| 276 | "SELECT workspace AS value FROM agent_waits |
| 277 | GROUP BY workspace ORDER BY min(created_at) LIMIT 100", |
| 278 | ) |
| 279 | .all() |
| 280 | .await? |
| 281 | .results::<ValueRow>()?; |
| 282 | Ok(rows.into_iter().map(|row| row.value).collect()) |
| 283 | } |
| 284 | |
| 285 | pub(crate) async fn take_wait(&self, a: TakeWaitArgs) -> Result<Option<AgentWait>> { |
| 286 | let row = self |
| 287 | .db |
| 288 | .prepare( |
| 289 | "DELETE FROM agent_waits WHERE id = ( |
| 290 | SELECT id FROM agent_waits WHERE workspace = ? ORDER BY created_at, id LIMIT 1) |
| 291 | RETURNING id, workspace, kind, payload, created_at", |
| 292 | ) |
| 293 | .bind(&[a.workspace.to_lowercase().into()])? |
| 294 | .first::<WaitRow>(None) |
| 295 | .await?; |
| 296 | Ok(row.map(|row| AgentWait { |
| 297 | id: row.id, |
| 298 | workspace: row.workspace, |
| 299 | kind: row.kind, |
| 300 | payload: serde_json::from_str(&row.payload).unwrap_or(serde_json::Value::Null), |
| 301 | created_at: row.created_at, |
| 302 | })) |
| 303 | } |
| 304 | |
| 305 | pub(crate) async fn run_cost(&self, a: RunCostArgs) -> Result<Option<f64>> { |
| 306 | let row = self |
| 307 | .db |
| 308 | .prepare("SELECT cost_usd, token_hash FROM agent_runs WHERE id = ?") |
| 309 | .bind(&[JsValue::from(a.run_id.as_str())])? |
| 310 | .first::<CostRow>(None) |
| 311 | .await?; |
| 312 | Ok(row |
| 313 | .filter(|row| row.token_hash == hash(&a.token)) |
| 314 | .and_then(|row| row.cost_usd)) |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | #[cfg(test)] |
| 319 | mod tests { |
| 320 | use super::*; |
| 321 | |
| 322 | #[test] |
| 323 | fn dollars_become_micros_rounded_up() { |
| 324 | assert_eq!(to_micros(0.094), 94_000); |
| 325 | assert_eq!(to_micros(0.0000001), 1); |
| 326 | assert_eq!(to_micros(-1.0), 0); |
| 327 | assert_eq!(to_micros(f64::NAN), 0); |
| 328 | } |
| 329 | |
| 330 | #[test] |
| 331 | fn only_agent_kinds_hold_a_slot() { |
| 332 | let sql = agent_kinds_sql(); |
| 333 | assert!(sql.contains("'implement'") && sql.contains("'plan'")); |
| 334 | assert!(!sql.contains("'checks'") && !sql.contains("'queue'") && !sql.contains("'mergecheck'")); |
| 335 | } |
| 336 | } |