| 1 | //! Coordinating work between agents and people: sessions, claims and |
| 2 | //! dependencies. |
| 3 | //! |
| 4 | //! The issue is the unit of work. A **session** is one run of someone's |
| 5 | //! own harness (a Claude Code session, a Codex run) on a repository, by a |
| 6 | //! person's token, optionally attached to an issue, a pull request and a |
| 7 | //! branch. A **claim** is an advisory, expiring "I'm working on #N": never |
| 8 | //! a lock, so two can stand on one issue, and the second claimant is told |
| 9 | //! about the first. An issue's `blocked_by` names the issues that have to |
| 10 | //! land first. |
| 11 | //! |
| 12 | //! Hooks and telemetry are evidence, never truth: what git and g1t record |
| 13 | //! is. So sessions tolerate missed heartbeats, reconnects and duplicate |
| 14 | //! events: starting one is idempotent on the harness's own session id, |
| 15 | //! entries are idempotent on their `key`, and a session or claim that |
| 16 | //! went quiet is read as idle, ended or expired when it is next looked at. |
| 17 | |
| 18 | use serde::{Deserialize, Serialize}; |
| 19 | |
| 20 | use super::{CheckStatus, Issue, Mergeable, NewSessionEntry, Overlap, PullStatus, QueueState, RequiredCheck, SessionEntry, State}; |
| 21 | use crate::credentials::Principal; |
| 22 | use crate::events::Event; |
| 23 | use crate::repos::RepoPath; |
| 24 | use crate::{User, Viewer}; |
| 25 | |
| 26 | /// A session with no heartbeat for this long reads as idle. |
| 27 | pub const SESSION_IDLE_MS: u64 = 15 * 60 * 1000; |
| 28 | /// A session with no heartbeat for this long has ended (`timed_out`). |
| 29 | pub const SESSION_TIMEOUT_MS: u64 = 2 * 60 * 60 * 1000; |
| 30 | /// How long a claim lasts unless renewed, in minutes. |
| 31 | pub const CLAIM_DEFAULT_MINUTES: u32 = 30; |
| 32 | /// The longest a claim may be asked to last, in minutes. |
| 33 | pub const CLAIM_MAX_MINUTES: u32 = 240; |
| 34 | /// The most issues one issue can be blocked by. |
| 35 | pub const MAX_BLOCKED_BY: usize = 50; |
| 36 | /// The most characters a session's title, a claim's note or a reason keeps. |
| 37 | pub const MAX_NOTE_CHARS: usize = 500; |
| 38 | /// The most characters an entry's idempotency key may have. |
| 39 | pub const MAX_KEY_CHARS: usize = 200; |
| 40 | |
| 41 | /// The event types about coordinating work, which `get_work` lists as an |
| 42 | /// issue's recent activity (with the merge queue's, once it sends them). |
| 43 | pub const COORDINATION_EVENTS: [&str; 11] = [ |
| 44 | "session.started", |
| 45 | "session.ended", |
| 46 | "work.claimed", |
| 47 | "work.released", |
| 48 | "work.claim_expired", |
| 49 | "work.handoff_requested", |
| 50 | "dependency.changed", |
| 51 | "overlap.detected", |
| 52 | "integration.failed", |
| 53 | "integration.passed", |
| 54 | "decision.recorded", |
| 55 | ]; |
| 56 | |
| 57 | /// The harness a session runs in. |
| 58 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 59 | #[serde(rename_all = "snake_case")] |
| 60 | pub enum Harness { |
| 61 | ClaudeCode, |
| 62 | Codex, |
| 63 | Cursor, |
| 64 | Copilot, |
| 65 | Other, |
| 66 | } |
| 67 | |
| 68 | impl Harness { |
| 69 | pub const ALL: [Harness; 5] = [Harness::ClaudeCode, Harness::Codex, Harness::Cursor, Harness::Copilot, Harness::Other]; |
| 70 | |
| 71 | pub fn as_str(self) -> &'static str { |
| 72 | match self { |
| 73 | Harness::ClaudeCode => "claude_code", |
| 74 | Harness::Codex => "codex", |
| 75 | Harness::Cursor => "cursor", |
| 76 | Harness::Copilot => "copilot", |
| 77 | Harness::Other => "other", |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | /// From its name, in either case, with `-` for `_`. |
| 82 | pub fn parse(name: &str) -> Option<Harness> { |
| 83 | let name = name.trim().to_ascii_lowercase().replace('-', "_"); |
| 84 | Harness::ALL.into_iter().find(|harness| harness.as_str() == name) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | /// Where a session stands. |
| 89 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 90 | #[serde(rename_all = "snake_case")] |
| 91 | pub enum SessionState { |
| 92 | /// Heard from in the last 15 minutes. |
| 93 | Active, |
| 94 | /// Not heard from for 15 minutes; it may still come back. |
| 95 | Idle, |
| 96 | /// Ended, or not heard from for 2 hours (`end_reason` `timed_out`). |
| 97 | Ended, |
| 98 | } |
| 99 | |
| 100 | impl SessionState { |
| 101 | pub fn as_str(self) -> &'static str { |
| 102 | match self { |
| 103 | SessionState::Active => "active", |
| 104 | SessionState::Idle => "idle", |
| 105 | SessionState::Ended => "ended", |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | pub fn parse(name: &str) -> Option<SessionState> { |
| 110 | match name.trim().to_ascii_lowercase().as_str() { |
| 111 | "active" => Some(SessionState::Active), |
| 112 | "idle" => Some(SessionState::Idle), |
| 113 | "ended" => Some(SessionState::Ended), |
| 114 | _ => None, |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | /// A session as it reads at `now`: `ended` when it was ended or has |
| 119 | /// been quiet for [`SESSION_TIMEOUT_MS`], `idle` after |
| 120 | /// [`SESSION_IDLE_MS`], else `active`. |
| 121 | pub fn at(ended: bool, last_seen_ms: u64, now_ms: u64) -> SessionState { |
| 122 | let quiet = now_ms.saturating_sub(last_seen_ms); |
| 123 | if ended || quiet >= SESSION_TIMEOUT_MS { |
| 124 | SessionState::Ended |
| 125 | } else if quiet >= SESSION_IDLE_MS { |
| 126 | SessionState::Idle |
| 127 | } else { |
| 128 | SessionState::Active |
| 129 | } |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | /// One run of someone's own harness on a repository. |
| 134 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 135 | #[serde(rename_all = "camelCase")] |
| 136 | pub struct Session { |
| 137 | /// `ses_…`. |
| 138 | pub id: String, |
| 139 | pub repo_id: String, |
| 140 | /// The issue it works on, if it said. |
| 141 | pub issue: Option<u32>, |
| 142 | /// The pull request it works on, if it said. |
| 143 | pub pull: Option<u32>, |
| 144 | pub branch: Option<String>, |
| 145 | /// Whose token started it. |
| 146 | pub actor: Principal, |
| 147 | pub harness: Harness, |
| 148 | /// The harness's own id for the session, which makes starting it again |
| 149 | /// return this one. |
| 150 | pub harness_session_id: Option<String>, |
| 151 | pub title: Option<String>, |
| 152 | pub state: SessionState, |
| 153 | /// The commit it last said it was at. |
| 154 | pub head_commit: Option<String>, |
| 155 | /// RFC 3339. |
| 156 | pub started_at: String, |
| 157 | /// RFC 3339: its last heartbeat or change. |
| 158 | pub last_seen_at: String, |
| 159 | /// RFC 3339, once ended. |
| 160 | pub ended_at: Option<String>, |
| 161 | /// Why it ended: what the harness said, or `timed_out`. |
| 162 | pub end_reason: Option<String>, |
| 163 | } |
| 164 | |
| 165 | /// Where a claim stands. |
| 166 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 167 | #[serde(rename_all = "snake_case")] |
| 168 | pub enum ClaimState { |
| 169 | Active, |
| 170 | Released, |
| 171 | Expired, |
| 172 | HandedOff, |
| 173 | } |
| 174 | |
| 175 | impl ClaimState { |
| 176 | pub fn as_str(self) -> &'static str { |
| 177 | match self { |
| 178 | ClaimState::Active => "active", |
| 179 | ClaimState::Released => "released", |
| 180 | ClaimState::Expired => "expired", |
| 181 | ClaimState::HandedOff => "handed_off", |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | pub fn parse(name: &str) -> Option<ClaimState> { |
| 186 | match name.trim().to_ascii_lowercase().as_str() { |
| 187 | "active" => Some(ClaimState::Active), |
| 188 | "released" => Some(ClaimState::Released), |
| 189 | "expired" => Some(ClaimState::Expired), |
| 190 | "handed_off" => Some(ClaimState::HandedOff), |
| 191 | _ => None, |
| 192 | } |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | /// An advisory, expiring "I'm working on #N". Never a lock. |
| 197 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 198 | #[serde(rename_all = "camelCase")] |
| 199 | pub struct Claim { |
| 200 | /// `clm_…`. |
| 201 | pub id: String, |
| 202 | pub repo_id: String, |
| 203 | /// The issue it is on. |
| 204 | pub issue: u32, |
| 205 | /// The session it was made from, whose heartbeats renew it. |
| 206 | pub session_id: Option<String>, |
| 207 | pub actor: Principal, |
| 208 | pub harness: Option<Harness>, |
| 209 | pub note: Option<String>, |
| 210 | pub state: ClaimState, |
| 211 | /// RFC 3339. |
| 212 | pub created_at: String, |
| 213 | /// RFC 3339: when it lapses unless renewed. |
| 214 | pub expires_at: String, |
| 215 | /// RFC 3339, once it stopped being active. |
| 216 | pub ended_at: Option<String>, |
| 217 | pub end_reason: Option<String>, |
| 218 | /// For a hand-off: the username it was handed to; none for anyone. |
| 219 | pub handoff_to: Option<String>, |
| 220 | } |
| 221 | |
| 222 | /// How long a claim lasts, in minutes: the default when none was asked, |
| 223 | /// at least one and at most [`CLAIM_MAX_MINUTES`]. |
| 224 | pub fn claim_minutes(asked: Option<u32>) -> u32 { |
| 225 | asked.unwrap_or(CLAIM_DEFAULT_MINUTES).clamp(1, CLAIM_MAX_MINUTES) |
| 226 | } |
| 227 | |
| 228 | /// `blocked_by` as asked for `number`, de-duplicated in the order given, |
| 229 | /// or why it cannot be: one names `number` itself or is not an issue of |
| 230 | /// the repository (`issues`), there are too many, or it would close a |
| 231 | /// cycle through the blockers already recorded (`graph`: each issue's |
| 232 | /// `blocked_by`). |
| 233 | pub fn check_blocked_by( |
| 234 | number: u32, |
| 235 | asked: &[u32], |
| 236 | issues: &std::collections::HashSet<u32>, |
| 237 | graph: &std::collections::HashMap<u32, Vec<u32>>, |
| 238 | ) -> Result<Vec<u32>, String> { |
| 239 | let mut wanted: Vec<u32> = Vec::new(); |
| 240 | for blocker in asked { |
| 241 | if !wanted.contains(blocker) { |
| 242 | wanted.push(*blocker); |
| 243 | } |
| 244 | } |
| 245 | if wanted.len() > MAX_BLOCKED_BY { |
| 246 | return Err(format!("An issue can be blocked by at most {MAX_BLOCKED_BY} issues.")); |
| 247 | } |
| 248 | if wanted.contains(&number) { |
| 249 | return Err(format!("#{number} cannot be blocked by itself.")); |
| 250 | } |
| 251 | if let Some(missing) = wanted.iter().find(|blocker| !issues.contains(blocker)) { |
| 252 | return Err(format!("#{missing} is not an issue in this repository.")); |
| 253 | } |
| 254 | // A cycle: some blocker is itself, through what blocks it, blocked by |
| 255 | // `number`. |
| 256 | let mut seen = std::collections::HashSet::new(); |
| 257 | let mut stack: Vec<(u32, Vec<u32>)> = wanted.iter().map(|blocker| (*blocker, vec![number, *blocker])).collect(); |
| 258 | while let Some((at, path)) = stack.pop() { |
| 259 | if !seen.insert(at) { |
| 260 | continue; |
| 261 | } |
| 262 | for next in graph.get(&at).into_iter().flatten() { |
| 263 | let mut longer = path.clone(); |
| 264 | longer.push(*next); |
| 265 | if *next == number { |
| 266 | let chain = longer.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(" → "); |
| 267 | return Err(format!("That would make a cycle: {chain}, where each is blocked by the next.")); |
| 268 | } |
| 269 | stack.push((*next, longer)); |
| 270 | } |
| 271 | } |
| 272 | Ok(wanted) |
| 273 | } |
| 274 | |
| 275 | /// The items of an issue's "Definition of done" section: each bulleted or |
| 276 | /// numbered line under the heading, up to the next heading. |
| 277 | pub fn definition_of_done(body: &str) -> Vec<String> { |
| 278 | let mut items = Vec::new(); |
| 279 | let mut inside = false; |
| 280 | for line in body.lines() { |
| 281 | let trimmed = line.trim(); |
| 282 | if trimmed.starts_with('#') { |
| 283 | inside = trimmed.trim_start_matches('#').trim().eq_ignore_ascii_case("definition of done"); |
| 284 | continue; |
| 285 | } |
| 286 | if !inside { |
| 287 | continue; |
| 288 | } |
| 289 | let item = trimmed |
| 290 | .strip_prefix("- ") |
| 291 | .or_else(|| trimmed.strip_prefix("* ")) |
| 292 | .or_else(|| trimmed.split_once(". ").filter(|(n, _)| n.chars().all(|c| c.is_ascii_digit()) && !n.is_empty()).map(|(_, rest)| rest)); |
| 293 | if let Some(item) = item { |
| 294 | let item = item.trim_start_matches("[ ] ").trim_start_matches("[x] ").trim_start_matches("[X] ").trim(); |
| 295 | if !item.is_empty() { |
| 296 | items.push(item.to_owned()); |
| 297 | } |
| 298 | } |
| 299 | } |
| 300 | items |
| 301 | } |
| 302 | |
| 303 | /// The pairs of pull requests that overlap now, against those already |
| 304 | /// announced: which are new (to announce) and which cleared (to forget, so |
| 305 | /// that they are announced again if they come back). |
| 306 | pub fn overlap_changes(current: &[u32], announced: &[u32]) -> (Vec<u32>, Vec<u32>) { |
| 307 | let new = current.iter().filter(|number| !announced.contains(number)).copied().collect(); |
| 308 | let cleared = announced.iter().filter(|number| !current.contains(number)).copied().collect(); |
| 309 | (new, cleared) |
| 310 | } |
| 311 | |
| 312 | // --- Arguments -------------------------------------------------------------- |
| 313 | |
| 314 | /// `start_session`. Returns `Outcome<Session>`: a new one, or the one |
| 315 | /// already started with the same `harness_session_id` (which then takes |
| 316 | /// the issue, pull request, branch, title and head given). |
| 317 | #[derive(Debug, Serialize, Deserialize)] |
| 318 | pub struct StartSessionArgs { |
| 319 | pub actor: User, |
| 320 | pub repo: RepoPath, |
| 321 | pub harness: Harness, |
| 322 | #[serde(default)] |
| 323 | pub harness_session_id: Option<String>, |
| 324 | #[serde(default)] |
| 325 | pub issue: Option<u32>, |
| 326 | #[serde(default)] |
| 327 | pub pull: Option<u32>, |
| 328 | #[serde(default)] |
| 329 | pub branch: Option<String>, |
| 330 | #[serde(default)] |
| 331 | pub title: Option<String>, |
| 332 | #[serde(default)] |
| 333 | pub head_commit: Option<String>, |
| 334 | } |
| 335 | |
| 336 | /// `update_session`: changes whichever fields are given; with none, it is a |
| 337 | /// heartbeat. Either way it renews the session's active claims. Returns |
| 338 | /// `Outcome<Session>`. |
| 339 | #[derive(Debug, Serialize, Deserialize)] |
| 340 | pub struct UpdateSessionArgs { |
| 341 | pub actor: User, |
| 342 | pub repo: RepoPath, |
| 343 | pub id: String, |
| 344 | #[serde(default)] |
| 345 | pub issue: Option<u32>, |
| 346 | #[serde(default)] |
| 347 | pub pull: Option<u32>, |
| 348 | #[serde(default)] |
| 349 | pub branch: Option<String>, |
| 350 | #[serde(default)] |
| 351 | pub head_commit: Option<String>, |
| 352 | #[serde(default)] |
| 353 | pub title: Option<String>, |
| 354 | } |
| 355 | |
| 356 | /// `end_session`. Returns `Outcome<Session>`; ending one already ended |
| 357 | /// returns it as it is. |
| 358 | #[derive(Debug, Serialize, Deserialize)] |
| 359 | pub struct EndSessionArgs { |
| 360 | pub actor: User, |
| 361 | pub repo: RepoPath, |
| 362 | pub id: String, |
| 363 | #[serde(default)] |
| 364 | pub reason: Option<String>, |
| 365 | } |
| 366 | |
| 367 | /// `get_work_session`: a session and its entries after `after_seq`. |
| 368 | /// Returns `Outcome<SessionDetail>`. |
| 369 | #[derive(Debug, Serialize, Deserialize)] |
| 370 | pub struct WorkSessionArgs { |
| 371 | pub viewer: Viewer, |
| 372 | pub repo: RepoPath, |
| 373 | pub id: String, |
| 374 | #[serde(default)] |
| 375 | pub after_seq: u32, |
| 376 | } |
| 377 | |
| 378 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 379 | pub struct SessionDetail { |
| 380 | pub session: Session, |
| 381 | /// Oldest first, numbered within the session. |
| 382 | pub entries: Vec<SessionEntry>, |
| 383 | } |
| 384 | |
| 385 | /// `list_work_sessions`: a repository's sessions, most recently heard from |
| 386 | /// first. Returns `Outcome<Vec<Session>>`. |
| 387 | #[derive(Debug, Serialize, Deserialize)] |
| 388 | pub struct ListWorkSessionsArgs { |
| 389 | pub viewer: Viewer, |
| 390 | pub repo: RepoPath, |
| 391 | #[serde(default)] |
| 392 | pub state: Option<SessionState>, |
| 393 | #[serde(default)] |
| 394 | pub issue: Option<u32>, |
| 395 | #[serde(default)] |
| 396 | pub pull: Option<u32>, |
| 397 | } |
| 398 | |
| 399 | /// An entry to record in a session. `key` makes recording it again a no-op. |
| 400 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 401 | pub struct KeyedSessionEntry { |
| 402 | #[serde(flatten)] |
| 403 | pub entry: NewSessionEntry, |
| 404 | #[serde(default)] |
| 405 | pub key: Option<String>, |
| 406 | } |
| 407 | |
| 408 | /// `record_session_entries`. Returns `Outcome<Recorded>`. |
| 409 | #[derive(Debug, Serialize, Deserialize)] |
| 410 | pub struct RecordEntriesArgs { |
| 411 | pub actor: User, |
| 412 | pub repo: RepoPath, |
| 413 | pub id: String, |
| 414 | pub entries: Vec<KeyedSessionEntry>, |
| 415 | } |
| 416 | |
| 417 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] |
| 418 | pub struct Recorded { |
| 419 | /// Entries newly recorded. |
| 420 | pub recorded: u32, |
| 421 | /// Entries skipped because one with the same key was recorded before. |
| 422 | pub duplicates: u32, |
| 423 | } |
| 424 | |
| 425 | /// `claim_issue`. Returns `Outcome<Claimed>`. Claiming an issue you hold |
| 426 | /// renews your claim (and changes its note or session when given). |
| 427 | #[derive(Debug, Serialize, Deserialize)] |
| 428 | pub struct ClaimArgs { |
| 429 | pub actor: User, |
| 430 | pub repo: RepoPath, |
| 431 | pub number: u32, |
| 432 | #[serde(default)] |
| 433 | pub session_id: Option<String>, |
| 434 | #[serde(default)] |
| 435 | pub note: Option<String>, |
| 436 | #[serde(default)] |
| 437 | pub ttl_minutes: Option<u32>, |
| 438 | } |
| 439 | |
| 440 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 441 | #[serde(rename_all = "camelCase")] |
| 442 | pub struct Claimed { |
| 443 | pub claim: Claim, |
| 444 | /// Other active claims on the issue: someone else is on it too. |
| 445 | pub others: Vec<Claim>, |
| 446 | /// What the issue is blocked by, and where each stands. |
| 447 | pub blocked_by: Vec<Blocker>, |
| 448 | } |
| 449 | |
| 450 | /// `release_claim`: gives up your active claim on an issue. Returns |
| 451 | /// `Outcome<Claim>`. |
| 452 | #[derive(Debug, Serialize, Deserialize)] |
| 453 | pub struct ReleaseArgs { |
| 454 | pub actor: User, |
| 455 | pub repo: RepoPath, |
| 456 | pub number: u32, |
| 457 | #[serde(default)] |
| 458 | pub reason: Option<String>, |
| 459 | } |
| 460 | |
| 461 | /// `hand_off_issue`: ends your active claim on an issue as handed off, to |
| 462 | /// someone (`to`, a username) or anyone, with a note on where it stands. |
| 463 | /// Returns `Outcome<Claim>`. |
| 464 | #[derive(Debug, Serialize, Deserialize)] |
| 465 | pub struct HandOffArgs { |
| 466 | pub actor: User, |
| 467 | pub repo: RepoPath, |
| 468 | pub number: u32, |
| 469 | #[serde(default)] |
| 470 | pub to: Option<String>, |
| 471 | pub note: String, |
| 472 | } |
| 473 | |
| 474 | /// `list_claims`: a repository's claims, newest first; active ones unless |
| 475 | /// `state` says otherwise. Returns `Outcome<Vec<Claim>>`. |
| 476 | #[derive(Debug, Serialize, Deserialize)] |
| 477 | pub struct ListClaimsArgs { |
| 478 | pub viewer: Viewer, |
| 479 | pub repo: RepoPath, |
| 480 | #[serde(default)] |
| 481 | pub state: Option<ClaimState>, |
| 482 | #[serde(default)] |
| 483 | pub issue: Option<u32>, |
| 484 | } |
| 485 | |
| 486 | /// `get_work`: everything an agent needs to pick up an issue. Returns |
| 487 | /// `Outcome<WorkContext>`. |
| 488 | #[derive(Debug, Serialize, Deserialize)] |
| 489 | pub struct GetWorkArgs { |
| 490 | pub viewer: Viewer, |
| 491 | pub repo: RepoPath, |
| 492 | pub number: u32, |
| 493 | } |
| 494 | |
| 495 | /// An issue in brief. |
| 496 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 497 | pub struct IssueBrief { |
| 498 | pub number: u32, |
| 499 | pub title: String, |
| 500 | pub state: State, |
| 501 | } |
| 502 | |
| 503 | /// A pull request in brief. |
| 504 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 505 | pub struct PullBrief { |
| 506 | pub number: u32, |
| 507 | pub title: String, |
| 508 | pub status: PullStatus, |
| 509 | } |
| 510 | |
| 511 | /// An issue another is blocked by, and where it stands. |
| 512 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 513 | pub struct Blocker { |
| 514 | pub number: u32, |
| 515 | pub title: String, |
| 516 | pub state: State, |
| 517 | /// Active claims on it. |
| 518 | pub claims: Vec<Claim>, |
| 519 | /// Its pull requests. |
| 520 | pub pulls: Vec<PullBrief>, |
| 521 | } |
| 522 | |
| 523 | /// A pull request's place in the merge queue. |
| 524 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 525 | pub struct QueuePlace { |
| 526 | pub state: QueueState, |
| 527 | /// 1 for the front of the queue. |
| 528 | pub position: u32, |
| 529 | } |
| 530 | |
| 531 | /// One of an issue's pull requests, with where it stands. |
| 532 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 533 | #[serde(rename_all = "camelCase")] |
| 534 | pub struct WorkPull { |
| 535 | pub number: u32, |
| 536 | pub title: String, |
| 537 | pub status: PullStatus, |
| 538 | pub branch: Option<String>, |
| 539 | pub head_commit: Option<String>, |
| 540 | pub author: Principal, |
| 541 | /// `failed` when the merge queue took it out, until its head moves. |
| 542 | pub check_status: Option<CheckStatus>, |
| 543 | /// The checks the branch it merges into requires, as they stand on its |
| 544 | /// head. |
| 545 | pub required_checks: Vec<RequiredCheck>, |
| 546 | pub mergeable: Mergeable, |
| 547 | /// Other pull requests in progress changing the same files. |
| 548 | pub overlaps: Vec<Overlap>, |
| 549 | /// Its place in the merge queue, while it is in it. |
| 550 | pub queue: Option<QueuePlace>, |
| 551 | } |
| 552 | |
| 553 | /// Everything about an issue an agent needs to pick it up: the one call to |
| 554 | /// make before starting. |
| 555 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 556 | #[serde(rename_all = "camelCase")] |
| 557 | pub struct WorkContext { |
| 558 | pub issue: Issue, |
| 559 | /// The items under its "Definition of done" heading. |
| 560 | pub definition_of_done: Vec<String>, |
| 561 | /// The checks every pull request into the default branch must pass. |
| 562 | pub required_checks: Vec<String>, |
| 563 | /// Active claims on it. |
| 564 | pub claims: Vec<Claim>, |
| 565 | /// Sessions on it or its pull requests, most recently heard from first. |
| 566 | pub sessions: Vec<Session>, |
| 567 | pub pulls: Vec<WorkPull>, |
| 568 | /// What it is blocked by, and where each stands. |
| 569 | pub blocked_by: Vec<Blocker>, |
| 570 | /// The issues it blocks. |
| 571 | pub blocks: Vec<IssueBrief>, |
| 572 | /// Decision records on it and its pull requests, newest first. |
| 573 | #[serde(default)] |
| 574 | pub decisions: Vec<crate::decisions::Decision>, |
| 575 | /// The latest `integration_failure` decision on it or its pull |
| 576 | /// requests, if any: which combination failed, and the check. |
| 577 | #[serde(default)] |
| 578 | pub last_integration_failure: Option<crate::decisions::Decision>, |
| 579 | /// The latest coordination events about it, newest first. |
| 580 | pub recent: Vec<Event>, |
| 581 | } |
| 582 | |
| 583 | // --- Events ----------------------------------------------------------------- |
| 584 | |
| 585 | /// `session.started` and `session.ended`. |
| 586 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 587 | #[serde(rename_all = "camelCase")] |
| 588 | pub struct SessionEvent { |
| 589 | pub repo_id: String, |
| 590 | /// The pull request it works on, else its issue. |
| 591 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 592 | pub number: Option<u32>, |
| 593 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 594 | pub issue: Option<u32>, |
| 595 | pub session: Session, |
| 596 | } |
| 597 | |
| 598 | impl SessionEvent { |
| 599 | pub fn of(session: &Session) -> SessionEvent { |
| 600 | SessionEvent { |
| 601 | repo_id: session.repo_id.clone(), |
| 602 | number: session.pull.or(session.issue), |
| 603 | issue: session.issue, |
| 604 | session: session.clone(), |
| 605 | } |
| 606 | } |
| 607 | } |
| 608 | |
| 609 | /// `work.claimed`, `work.released`, `work.claim_expired` and |
| 610 | /// `work.handoff_requested`: `number` and `issue` are the issue claimed. |
| 611 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 612 | #[serde(rename_all = "camelCase")] |
| 613 | pub struct ClaimEvent { |
| 614 | pub repo_id: String, |
| 615 | pub number: u32, |
| 616 | pub issue: u32, |
| 617 | pub claim: Claim, |
| 618 | /// On `work.claimed`: the other active claims on the issue. |
| 619 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 620 | pub others: Option<Vec<Claim>>, |
| 621 | /// On `work.claimed`, when it takes over a claim handed off. |
| 622 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 623 | pub took_over: Option<String>, |
| 624 | /// On `work.released`. |
| 625 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 626 | pub reason: Option<String>, |
| 627 | /// On `work.handoff_requested`: who handed it off. |
| 628 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 629 | pub from: Option<String>, |
| 630 | /// On `work.handoff_requested`: who it is for; absent for anyone. |
| 631 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 632 | pub to: Option<String>, |
| 633 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 634 | pub note: Option<String>, |
| 635 | } |
| 636 | |
| 637 | impl ClaimEvent { |
| 638 | pub fn of(claim: &Claim) -> ClaimEvent { |
| 639 | ClaimEvent { |
| 640 | repo_id: claim.repo_id.clone(), |
| 641 | number: claim.issue, |
| 642 | issue: claim.issue, |
| 643 | claim: claim.clone(), |
| 644 | others: None, |
| 645 | took_over: None, |
| 646 | reason: None, |
| 647 | from: None, |
| 648 | to: None, |
| 649 | note: None, |
| 650 | } |
| 651 | } |
| 652 | } |
| 653 | |
| 654 | /// `dependency.changed`: an issue's `blocked_by` changed, or one of the |
| 655 | /// issues it is blocked by closed or had its pull request merged. |
| 656 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] |
| 657 | #[serde(rename_all = "camelCase")] |
| 658 | pub struct DependencyChanged { |
| 659 | pub repo_id: String, |
| 660 | /// The issue whose dependencies changed. |
| 661 | pub number: u32, |
| 662 | pub issue: u32, |
| 663 | /// What it is blocked by now. |
| 664 | pub blocked_by: Vec<u32>, |
| 665 | /// Added to `blocked_by`. |
| 666 | pub added: Vec<u32>, |
| 667 | /// Taken out of `blocked_by`. |
| 668 | pub removed: Vec<u32>, |
| 669 | /// Blockers that just closed or had a pull request merged. |
| 670 | pub cleared: Vec<u32>, |
| 671 | /// Those of `blocked_by` still open: none means it is unblocked. |
| 672 | pub remaining: Vec<u32>, |
| 673 | /// When a merge cleared it: the pull request merged. |
| 674 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 675 | pub merged_pull: Option<u32>, |
| 676 | } |
| 677 | |
| 678 | /// `overlap.detected`: a pull request's latest push makes it change files |
| 679 | /// another open or draft pull request changes. Sent once for each side |
| 680 | /// when a pair starts overlapping, and again only after it stopped. |
| 681 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 682 | #[serde(rename_all = "camelCase")] |
| 683 | pub struct OverlapDetected { |
| 684 | pub repo_id: String, |
| 685 | /// The pull request this event is about: `pull`. |
| 686 | pub number: u32, |
| 687 | pub pull: u32, |
| 688 | pub other_pull: u32, |
| 689 | /// The files both change. |
| 690 | pub files: Vec<String>, |
| 691 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 692 | pub issue: Option<u32>, |
| 693 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 694 | pub other_issue: Option<u32>, |
| 695 | } |
| 696 | |
| 697 | #[cfg(test)] |
| 698 | mod tests { |
| 699 | use std::collections::{HashMap, HashSet}; |
| 700 | |
| 701 | use super::*; |
| 702 | |
| 703 | fn issues(numbers: &[u32]) -> HashSet<u32> { |
| 704 | numbers.iter().copied().collect() |
| 705 | } |
| 706 | |
| 707 | #[test] |
| 708 | fn a_session_goes_idle_then_ends_without_heartbeats() { |
| 709 | let start = 1_000_000; |
| 710 | assert_eq!(SessionState::at(false, start, start), SessionState::Active); |
| 711 | assert_eq!(SessionState::at(false, start, start + SESSION_IDLE_MS - 1), SessionState::Active); |
| 712 | assert_eq!(SessionState::at(false, start, start + SESSION_IDLE_MS), SessionState::Idle); |
| 713 | assert_eq!(SessionState::at(false, start, start + SESSION_TIMEOUT_MS), SessionState::Ended); |
| 714 | assert_eq!(SessionState::at(true, start, start), SessionState::Ended); |
| 715 | // A clock behind the last heartbeat reads as just heard from. |
| 716 | assert_eq!(SessionState::at(false, start, start - 5), SessionState::Active); |
| 717 | } |
| 718 | |
| 719 | #[test] |
| 720 | fn claims_last_thirty_minutes_up_to_four_hours() { |
| 721 | assert_eq!(claim_minutes(None), 30); |
| 722 | assert_eq!(claim_minutes(Some(0)), 1); |
| 723 | assert_eq!(claim_minutes(Some(90)), 90); |
| 724 | assert_eq!(claim_minutes(Some(10_000)), 240); |
| 725 | } |
| 726 | |
| 727 | #[test] |
| 728 | fn harnesses_are_named_one_way() { |
| 729 | assert_eq!(Harness::parse("claude-code"), Some(Harness::ClaudeCode)); |
| 730 | assert_eq!(Harness::parse("Codex"), Some(Harness::Codex)); |
| 731 | assert_eq!(Harness::parse("vim"), None); |
| 732 | assert_eq!(serde_json::to_value(Harness::ClaudeCode).unwrap(), "claude_code"); |
| 733 | assert_eq!(serde_json::to_value(ClaimState::HandedOff).unwrap(), "handed_off"); |
| 734 | } |
| 735 | |
| 736 | #[test] |
| 737 | fn blocked_by_is_issues_of_the_repository_never_itself() { |
| 738 | let graph = HashMap::new(); |
| 739 | assert_eq!(check_blocked_by(5, &[3, 4, 3], &issues(&[3, 4, 5]), &graph), Ok(vec![3, 4])); |
| 740 | assert_eq!(check_blocked_by(5, &[], &issues(&[5]), &graph), Ok(vec![])); |
| 741 | assert!(check_blocked_by(5, &[5], &issues(&[5]), &graph).unwrap_err().contains("itself")); |
| 742 | assert!(check_blocked_by(5, &[9], &issues(&[5]), &graph).unwrap_err().contains("#9 is not an issue")); |
| 743 | let many: Vec<u32> = (100..100 + MAX_BLOCKED_BY as u32 + 1).collect(); |
| 744 | assert!(check_blocked_by(5, &many, &many.iter().copied().collect(), &graph).is_err()); |
| 745 | } |
| 746 | |
| 747 | #[test] |
| 748 | fn blocked_by_never_makes_a_cycle() { |
| 749 | // 3 is blocked by 4, which is blocked by 5. |
| 750 | let graph: HashMap<u32, Vec<u32>> = [(3, vec![4]), (4, vec![5])].into_iter().collect(); |
| 751 | let all = issues(&[3, 4, 5, 6]); |
| 752 | let refused = check_blocked_by(5, &[3], &all, &graph).unwrap_err(); |
| 753 | assert!(refused.contains("#5 → #3 → #4 → #5"), "{refused}"); |
| 754 | // A diamond is not a cycle. |
| 755 | let diamond: HashMap<u32, Vec<u32>> = [(3, vec![5]), (4, vec![5])].into_iter().collect(); |
| 756 | assert_eq!(check_blocked_by(6, &[3, 4], &all, &diamond), Ok(vec![3, 4])); |
| 757 | // Replacing 5's own blockers is judged without them. |
| 758 | let graph: HashMap<u32, Vec<u32>> = [(5, vec![3]), (3, vec![4])].into_iter().collect(); |
| 759 | assert_eq!(check_blocked_by(5, &[4], &all, &graph), Ok(vec![4])); |
| 760 | } |
| 761 | |
| 762 | #[test] |
| 763 | fn the_definition_of_done_is_read_from_its_section() { |
| 764 | let body = "Make it greet.\n\n## Definition of done\n\n- `npm test` passes.\n- [ ] Docs say so\n1. Works offline\n\n## Notes\n- not this"; |
| 765 | assert_eq!(definition_of_done(body), ["`npm test` passes.", "Docs say so", "Works offline"]); |
| 766 | assert!(definition_of_done("No section").is_empty()); |
| 767 | } |
| 768 | |
| 769 | #[test] |
| 770 | fn an_overlap_is_announced_once_until_it_clears() { |
| 771 | assert_eq!(overlap_changes(&[7, 9], &[]), (vec![7, 9], vec![])); |
| 772 | // Pushing again with the same overlaps announces nothing. |
| 773 | assert_eq!(overlap_changes(&[7, 9], &[7, 9]), (vec![], vec![])); |
| 774 | // 9 stopped overlapping: forgotten, so a later overlap is new again. |
| 775 | assert_eq!(overlap_changes(&[7], &[7, 9]), (vec![], vec![9])); |
| 776 | assert_eq!(overlap_changes(&[7, 9], &[7]), (vec![9], vec![])); |
| 777 | } |
| 778 | |
| 779 | #[test] |
| 780 | fn events_name_the_issue_or_pull_request_they_are_about() { |
| 781 | let session: Session = serde_json::from_value(serde_json::json!({ |
| 782 | "id": "ses_1", "repoId": "rep_1", "issue": 4, "pull": 9, "branch": null, |
| 783 | "actor": { "id": "usr_1", "username": "ana" }, "harness": "claude_code", |
| 784 | "harnessSessionId": null, "title": null, "state": "active", "headCommit": null, |
| 785 | "startedAt": "", "lastSeenAt": "", "endedAt": null, "endReason": null |
| 786 | })) |
| 787 | .unwrap(); |
| 788 | let event = serde_json::to_value(SessionEvent::of(&session)).unwrap(); |
| 789 | assert_eq!((event["number"].as_u64(), event["issue"].as_u64()), (Some(9), Some(4))); |
| 790 | } |
| 791 | } |